From 9d57c6c50865d7dec2fd5cb9afe2246e75e48044 Mon Sep 17 00:00:00 2001 From: CI Date: Sun, 20 Sep 2026 12:48:13 +0100 Subject: [PATCH] fix(gateway): bound the reserve target to what the inverter accepts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Some GivEnergy inverters refuse a battery reserve of 100% (Gen2, Gen3 and AIO are the documented ones — our own inverter-setup docs advise inverter_reserve_max: 98). Nothing on the gateway route knew that: the firmware reported no ceiling and GATEWAY_ATTRIBUTE_TABLE hardcodes "max": 100, so reserve_device_bounds() had nothing to clamp with. Every hold path writes min(soc_percent + 1, 100), so a battery sitting at 100% asks for a reserve of 100. The write is accepted on the wire, the register keeps its old value, and write_and_poll_value reads the mismatch back as a failed write — ten retries, 2s apart, every engine cycle, then record_status(..., had_errors=True). A customer saw "Warn: Inverter 0 write to reserve failed" from before 14:21 until about 17:40 on 17 Sep 2026, with "Trying to write 100 to reserve didn't complete got 4.0" in the log. The battery held correctly throughout — the gateway's own plan entry does the holding — so this was a false error that cost a support ticket and an escalation. Gateway firmware now reports the ceiling per inverter (predbat-gateway#346, PR #349). This publishes it as the reserve entity's "max" attribute in place of the hardcoded 100, which adjust_reserve() already honours through reserve_device_bounds() (GH#4826). A full-battery hold on GivEnergy therefore targets 98 and the write lands first time. - ControlStatus.reserve_soc_max (field 11), mirroring the firmware copy of gateway_status.proto; the two must stay in sync for wire compatibility. - 0 means "not reported" — older gateway firmware — and is read as 100, as is anything outside 1-100. The table entry is copied per inverter, so one inverter's ceiling cannot become another's. Appended field, so both directions stay compatible — verified by round-trip across the old and new generated bindings: new firmware -> old PredBat: parses cleanly, unknown field ignored old firmware -> new PredBat: parses, reserve_soc_max defaults to 0 Regenerated with grpcio-tools and the repo's black hook, keeping the 7.34.1 gencode header the file ships so the protobuf runtime floor does not move: against the unchanged schema the two generators produce a byte-identical body, so the result is what protoc 34.1 emits. Co-Authored-By: Claude Opus 5 (1M context) --- apps/predbat/gateway.py | 11 ++++++- apps/predbat/gateway_status.proto | 4 +++ apps/predbat/gateway_status_pb2.py | 44 ++++++++++++++-------------- apps/predbat/tests/test_gateway.py | 47 ++++++++++++++++++++++++++++++ 4 files changed, 83 insertions(+), 23 deletions(-) diff --git a/apps/predbat/gateway.py b/apps/predbat/gateway.py index 62614271b..d783cc3de 100644 --- a/apps/predbat/gateway.py +++ b/apps/predbat/gateway.py @@ -898,7 +898,16 @@ def _inject_inverter_entities(self, inv, suffix): self.dashboard_item(f"sensor.{pfx}_export_limit_w", export_limit_publish, attributes=GATEWAY_ATTRIBUTE_TABLE.get("export_limit_w", {}), app="gateway") self.dashboard_item(f"number.{pfx}_charge_rate", control.charge_rate_w, attributes=GATEWAY_ATTRIBUTE_TABLE.get("charge_rate", {}), app="gateway") self.dashboard_item(f"number.{pfx}_discharge_rate", control.discharge_rate_w, attributes=GATEWAY_ATTRIBUTE_TABLE.get("discharge_rate", {}), app="gateway") - self.dashboard_item(f"number.{pfx}_reserve_soc", control.reserve_soc, attributes=GATEWAY_ATTRIBUTE_TABLE.get("reserve_soc", {}), app="gateway") + # The reserve ceiling is per-inverter, so it overrides the table's 100: GivEnergy + # firmware refuses a reserve of 100 and the gateway reports 98 for it (gateway + # issue #346). adjust_reserve() honours this entity's "max" through + # reserve_device_bounds(), so publishing it is what stops PredBat asking a full + # battery to hold at a value the inverter will not take and reading the unchanged + # register back as a failed write. 0 is firmware predating the field: assume 100. + reserve_attributes = dict(GATEWAY_ATTRIBUTE_TABLE.get("reserve_soc", {})) + reserve_soc_max = getattr(control, "reserve_soc_max", 0) + reserve_attributes["max"] = reserve_soc_max if 1 <= reserve_soc_max <= 100 else 100 + self.dashboard_item(f"number.{pfx}_reserve_soc", control.reserve_soc, attributes=reserve_attributes, app="gateway") self.dashboard_item(f"number.{pfx}_target_soc", control.target_soc, attributes=GATEWAY_ATTRIBUTE_TABLE.get("target_soc", {}), app="gateway") # Schedule times (convert HHMM uint32 → HH:MM:SS string) # Always set with defaults so PredBat doesn't crash on missing charge_start_time diff --git a/apps/predbat/gateway_status.proto b/apps/predbat/gateway_status.proto index 847ad3ad7..751bf3960 100644 --- a/apps/predbat/gateway_status.proto +++ b/apps/predbat/gateway_status.proto @@ -70,6 +70,10 @@ message ControlStatus { uint32 force_power_w = 8; uint32 command_expires = 9; // unix timestamp uint32 export_limit_w = 10; // W — grid port max power output (HR 26); 99999 = not read (unlimited) + // Highest reserve % this inverter accepts — PredBat must bound its reserve + // target to this (GivEnergy refuses 100, see gateway issue #346). 0 = not + // reported (gateway firmware predating this field): assume 100. + uint32 reserve_soc_max = 11; } message ScheduleStatus { diff --git a/apps/predbat/gateway_status_pb2.py b/apps/predbat/gateway_status_pb2.py index 8bb9fbed7..4e74f50df 100644 --- a/apps/predbat/gateway_status_pb2.py +++ b/apps/predbat/gateway_status_pb2.py @@ -17,7 +17,7 @@ DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile( - b'\n\x14gateway_status.proto\x12\x07predbat"\xe5\x01\n\rBatteryStatus\x12\x13\n\x0bsoc_percent\x18\x01 \x01(\r\x12\x11\n\tvoltage_v\x18\x02 \x01(\x02\x12\x11\n\tcurrent_a\x18\x03 \x01(\x02\x12\x0f\n\x07power_w\x18\x04 \x01(\x05\x12\x15\n\rtemperature_c\x18\x05 \x01(\x02\x12\x13\n\x0bsoh_percent\x18\x06 \x01(\r\x12\x13\n\x0b\x63ycle_count\x18\x07 \x01(\r\x12\x13\n\x0b\x63\x61pacity_wh\x18\x08 \x01(\x05\x12\x12\n\nrate_max_w\x18\t \x01(\r\x12\x1e\n\x16\x64\x65pth_of_discharge_pct\x18\n \x01(\r"\x1b\n\x08PvStatus\x12\x0f\n\x07power_w\x18\x01 \x01(\x05"F\n\nGridStatus\x12\x0f\n\x07power_w\x18\x01 \x01(\x05\x12\x11\n\tvoltage_v\x18\x02 \x01(\x02\x12\x14\n\x0c\x66requency_hz\x18\x03 \x01(\x02"\x1d\n\nLoadStatus\x12\x0f\n\x07power_w\x18\x01 \x01(\x05"Q\n\x0cInverterData\x12\x16\n\x0e\x61\x63tive_power_w\x18\x01 \x01(\x05\x12\x15\n\rtemperature_c\x18\x02 \x01(\x02\x12\x12\n\nrate_max_w\x18\x03 \x01(\r"\xea\x01\n\rControlStatus\x12\x16\n\x0e\x63harge_enabled\x18\x02 \x01(\x08\x12\x19\n\x11\x64ischarge_enabled\x18\x03 \x01(\x08\x12\x15\n\rcharge_rate_w\x18\x04 \x01(\r\x12\x18\n\x10\x64ischarge_rate_w\x18\x05 \x01(\r\x12\x13\n\x0breserve_soc\x18\x06 \x01(\r\x12\x12\n\ntarget_soc\x18\x07 \x01(\r\x12\x15\n\rforce_power_w\x18\x08 \x01(\r\x12\x17\n\x0f\x63ommand_expires\x18\t \x01(\r\x12\x16\n\x0e\x65xport_limit_w\x18\n \x01(\rJ\x04\x08\x01\x10\x02"j\n\x0eScheduleStatus\x12\x14\n\x0c\x63harge_start\x18\x01 \x01(\r\x12\x12\n\ncharge_end\x18\x02 \x01(\r\x12\x17\n\x0f\x64ischarge_start\x18\x03 \x01(\r\x12\x15\n\rdischarge_end\x18\x04 \x01(\r"^\n\x0e\x45msSubInverter\x12\x0b\n\x03soc\x18\x01 \x01(\r\x12\x11\n\tbattery_w\x18\x02 \x01(\x05\x12\x0c\n\x04pv_w\x18\x03 \x01(\x05\x12\x0e\n\x06grid_w\x18\x04 \x01(\x05\x12\x0e\n\x06temp_c\x18\x05 \x01(\x02"\xd8\x01\n\tEmsStatus\x12\x15\n\rnum_inverters\x18\x01 \x01(\r\x12\x11\n\ttotal_soc\x18\x02 \x01(\r\x12\x16\n\x0etotal_charge_w\x18\x03 \x01(\x05\x12\x19\n\x11total_discharge_w\x18\x04 \x01(\x05\x12\x14\n\x0ctotal_grid_w\x18\x05 \x01(\x05\x12\x12\n\ntotal_pv_w\x18\x06 \x01(\x05\x12\x14\n\x0ctotal_load_w\x18\x07 \x01(\x05\x12.\n\rsub_inverters\x18\x08 \x03(\x0b\x32\x17.predbat.EmsSubInverter"\xc4\x01\n\x0e\x45nergyCounters\x12\x13\n\x0bpv_today_wh\x18\x01 \x01(\r\x12\x1c\n\x14grid_import_today_wh\x18\x02 \x01(\r\x12\x1c\n\x14grid_export_today_wh\x18\x03 \x01(\r\x12\x1f\n\x17\x62\x61ttery_charge_today_wh\x18\x04 \x01(\r\x12"\n\x1a\x62\x61ttery_discharge_today_wh\x18\x05 \x01(\r\x12\x1c\n\x14\x63onsumption_today_wh\x18\x06 \x01(\r"\x90\x04\n\rInverterEntry\x12#\n\x04type\x18\x01 \x01(\x0e\x32\x15.predbat.InverterType\x12\x0e\n\x06serial\x18\x02 \x01(\t\x12\n\n\x02ip\x18\x03 \x01(\t\x12\x11\n\tconnected\x18\x04 \x01(\x08\x12\x0e\n\x06\x61\x63tive\x18\x05 \x01(\x08\x12\'\n\x07\x62\x61ttery\x18\x06 \x01(\x0b\x32\x16.predbat.BatteryStatus\x12\x1d\n\x02pv\x18\x07 \x01(\x0b\x32\x11.predbat.PvStatus\x12!\n\x04grid\x18\x08 \x01(\x0b\x32\x13.predbat.GridStatus\x12!\n\x04load\x18\t \x01(\x0b\x32\x13.predbat.LoadStatus\x12\'\n\x08inverter\x18\n \x01(\x0b\x32\x15.predbat.InverterData\x12\'\n\x07\x63ontrol\x18\x0b \x01(\x0b\x32\x16.predbat.ControlStatus\x12)\n\x08schedule\x18\x0c \x01(\x0b\x32\x17.predbat.ScheduleStatus\x12\x1f\n\x03\x65ms\x18\r \x01(\x0b\x32\x12.predbat.EmsStatus\x12\'\n\x06\x65nergy\x18\x0e \x01(\x0b\x32\x17.predbat.EnergyCounters\x12\x0f\n\x07managed\x18\x0f \x01(\x08\x12\x0f\n\x07primary\x18\x10 \x01(\x08\x12\x15\n\rdongle_serial\x18\x11 \x01(\t\x12\r\n\x05model\x18\x12 \x01(\t"\x8b\x02\n\tEvCharger\x12\x11\n\tconnected\x18\x01 \x01(\x08\x12\x16\n\x0esession_active\x18\x02 \x01(\x08\x12\x0e\n\x06status\x18\x03 \x01(\t\x12\x0f\n\x07power_w\x18\x04 \x01(\x05\x12\x19\n\x11session_energy_wh\x18\x05 \x01(\r\x12\x14\n\x0csession_secs\x18\x06 \x01(\r\x12\x17\n\x0f\x63urrent_limit_a\x18\x07 \x01(\r\x12\x13\n\x0bsoc_percent\x18\x08 \x01(\r\x12\x17\n\x0f\x63harge_point_id\x18\t \x01(\t\x12\x15\n\rmax_current_a\x18\n \x01(\r\x12\x11\n\tvoltage_v\x18\x0b \x01(\r\x12\x10\n\x08\x65\x63o_mode\x18\x0c \x01(\t"\xcb\x03\n\rGatewayStatus\x12\x11\n\tdevice_id\x18\x01 \x01(\t\x12\x14\n\x0c\x64ongle_count\x18\x02 \x01(\r\x12\x10\n\x08\x66irmware\x18\x03 \x01(\t\x12\x11\n\ttimestamp\x18\x04 \x01(\r\x12\x16\n\x0eschema_version\x18\x05 \x01(\r\x12)\n\tinverters\x18\x06 \x03(\x0b\x32\x16.predbat.InverterEntry\x12\x16\n\x0euptime_seconds\x18\x07 \x01(\r\x12\x15\n\rwifi_rssi_dbm\x18\x08 \x01(\x05\x12\x17\n\x0f\x66ree_heap_bytes\x18\t \x01(\r\x12\x11\n\tlog_level\x18\n \x01(\r\x12\x0f\n\x07wifi_ip\x18\x0b \x01(\t\x12\x17\n\nev_control\x18\x0c \x01(\x08H\x00\x88\x01\x01\x12\'\n\x0b\x65v_chargers\x18\r \x03(\x0b\x32\x12.predbat.EvCharger\x12\x11\n\twifi_ssid\x18\x0e \x01(\t\x12\x1a\n\x12heap_internal_free\x18\x0f \x01(\r\x12\x1d\n\x15heap_internal_largest\x18\x10 \x01(\r\x12\x1e\n\x16heap_internal_min_free\x18\x11 \x01(\rB\r\n\x0b_ev_control"\xc9\x01\n\tPlanEntry\x12\x0f\n\x07\x65nabled\x18\x01 \x01(\x08\x12\x12\n\nstart_hour\x18\x02 \x01(\r\x12\x14\n\x0cstart_minute\x18\x03 \x01(\r\x12\x10\n\x08\x65nd_hour\x18\x04 \x01(\r\x12\x12\n\nend_minute\x18\x05 \x01(\r\x12\x0c\n\x04mode\x18\x06 \x01(\r\x12\x0f\n\x07power_w\x18\x07 \x01(\r\x12\x12\n\ntarget_soc\x18\x08 \x01(\r\x12\x14\n\x0c\x64\x61ys_of_week\x18\t \x01(\r\x12\x12\n\nuse_native\x18\n \x01(\x08"o\n\rExecutionPlan\x12\x11\n\ttimestamp\x18\x01 \x01(\r\x12\x14\n\x0cplan_version\x18\x02 \x01(\r\x12\x10\n\x08timezone\x18\x03 \x01(\t\x12#\n\x07\x65ntries\x18\x04 \x03(\x0b\x32\x12.predbat.PlanEntry*\xf2\x02\n\x0cInverterType\x12\x19\n\x15INVERTER_TYPE_UNKNOWN\x10\x00\x12\x1e\n\x1aINVERTER_TYPE_SOLIS_HYBRID\x10\x01\x12\x1a\n\x16INVERTER_TYPE_SOLIS_AC\x10\x02\x12\x1a\n\x16INVERTER_TYPE_SOFAR_G3\x10\x03\x12\x1d\n\x19INVERTER_TYPE_GROWATT_SPH\x10\x04\x12\x1e\n\x1aINVERTER_TYPE_DEYE_SUNSYNK\x10\x05\x12\x1b\n\x17INVERTER_TYPE_GIVENERGY\x10\x06\x12\x1f\n\x1bINVERTER_TYPE_GIVENERGY_EMS\x10\x07\x12#\n\x1fINVERTER_TYPE_GIVENERGY_GATEWAY\x10\x08\x12\x1b\n\x17INVERTER_TYPE_ALPHA_ESS\x10\t\x12\x16\n\x12INVERTER_TYPE_DEMO\x10\n\x12\x18\n\x14INVERTER_TYPE_CUSTOM\x10\x0b\x62\x06proto3' + b'\n\x14gateway_status.proto\x12\x07predbat"\xe5\x01\n\rBatteryStatus\x12\x13\n\x0bsoc_percent\x18\x01 \x01(\r\x12\x11\n\tvoltage_v\x18\x02 \x01(\x02\x12\x11\n\tcurrent_a\x18\x03 \x01(\x02\x12\x0f\n\x07power_w\x18\x04 \x01(\x05\x12\x15\n\rtemperature_c\x18\x05 \x01(\x02\x12\x13\n\x0bsoh_percent\x18\x06 \x01(\r\x12\x13\n\x0b\x63ycle_count\x18\x07 \x01(\r\x12\x13\n\x0b\x63\x61pacity_wh\x18\x08 \x01(\x05\x12\x12\n\nrate_max_w\x18\t \x01(\r\x12\x1e\n\x16\x64\x65pth_of_discharge_pct\x18\n \x01(\r"\x1b\n\x08PvStatus\x12\x0f\n\x07power_w\x18\x01 \x01(\x05"F\n\nGridStatus\x12\x0f\n\x07power_w\x18\x01 \x01(\x05\x12\x11\n\tvoltage_v\x18\x02 \x01(\x02\x12\x14\n\x0c\x66requency_hz\x18\x03 \x01(\x02"\x1d\n\nLoadStatus\x12\x0f\n\x07power_w\x18\x01 \x01(\x05"Q\n\x0cInverterData\x12\x16\n\x0e\x61\x63tive_power_w\x18\x01 \x01(\x05\x12\x15\n\rtemperature_c\x18\x02 \x01(\x02\x12\x12\n\nrate_max_w\x18\x03 \x01(\r"\x83\x02\n\rControlStatus\x12\x16\n\x0e\x63harge_enabled\x18\x02 \x01(\x08\x12\x19\n\x11\x64ischarge_enabled\x18\x03 \x01(\x08\x12\x15\n\rcharge_rate_w\x18\x04 \x01(\r\x12\x18\n\x10\x64ischarge_rate_w\x18\x05 \x01(\r\x12\x13\n\x0breserve_soc\x18\x06 \x01(\r\x12\x12\n\ntarget_soc\x18\x07 \x01(\r\x12\x15\n\rforce_power_w\x18\x08 \x01(\r\x12\x17\n\x0f\x63ommand_expires\x18\t \x01(\r\x12\x16\n\x0e\x65xport_limit_w\x18\n \x01(\r\x12\x17\n\x0freserve_soc_max\x18\x0b \x01(\rJ\x04\x08\x01\x10\x02"j\n\x0eScheduleStatus\x12\x14\n\x0c\x63harge_start\x18\x01 \x01(\r\x12\x12\n\ncharge_end\x18\x02 \x01(\r\x12\x17\n\x0f\x64ischarge_start\x18\x03 \x01(\r\x12\x15\n\rdischarge_end\x18\x04 \x01(\r"^\n\x0e\x45msSubInverter\x12\x0b\n\x03soc\x18\x01 \x01(\r\x12\x11\n\tbattery_w\x18\x02 \x01(\x05\x12\x0c\n\x04pv_w\x18\x03 \x01(\x05\x12\x0e\n\x06grid_w\x18\x04 \x01(\x05\x12\x0e\n\x06temp_c\x18\x05 \x01(\x02"\xd8\x01\n\tEmsStatus\x12\x15\n\rnum_inverters\x18\x01 \x01(\r\x12\x11\n\ttotal_soc\x18\x02 \x01(\r\x12\x16\n\x0etotal_charge_w\x18\x03 \x01(\x05\x12\x19\n\x11total_discharge_w\x18\x04 \x01(\x05\x12\x14\n\x0ctotal_grid_w\x18\x05 \x01(\x05\x12\x12\n\ntotal_pv_w\x18\x06 \x01(\x05\x12\x14\n\x0ctotal_load_w\x18\x07 \x01(\x05\x12.\n\rsub_inverters\x18\x08 \x03(\x0b\x32\x17.predbat.EmsSubInverter"\xc4\x01\n\x0e\x45nergyCounters\x12\x13\n\x0bpv_today_wh\x18\x01 \x01(\r\x12\x1c\n\x14grid_import_today_wh\x18\x02 \x01(\r\x12\x1c\n\x14grid_export_today_wh\x18\x03 \x01(\r\x12\x1f\n\x17\x62\x61ttery_charge_today_wh\x18\x04 \x01(\r\x12"\n\x1a\x62\x61ttery_discharge_today_wh\x18\x05 \x01(\r\x12\x1c\n\x14\x63onsumption_today_wh\x18\x06 \x01(\r"\x90\x04\n\rInverterEntry\x12#\n\x04type\x18\x01 \x01(\x0e\x32\x15.predbat.InverterType\x12\x0e\n\x06serial\x18\x02 \x01(\t\x12\n\n\x02ip\x18\x03 \x01(\t\x12\x11\n\tconnected\x18\x04 \x01(\x08\x12\x0e\n\x06\x61\x63tive\x18\x05 \x01(\x08\x12\'\n\x07\x62\x61ttery\x18\x06 \x01(\x0b\x32\x16.predbat.BatteryStatus\x12\x1d\n\x02pv\x18\x07 \x01(\x0b\x32\x11.predbat.PvStatus\x12!\n\x04grid\x18\x08 \x01(\x0b\x32\x13.predbat.GridStatus\x12!\n\x04load\x18\t \x01(\x0b\x32\x13.predbat.LoadStatus\x12\'\n\x08inverter\x18\n \x01(\x0b\x32\x15.predbat.InverterData\x12\'\n\x07\x63ontrol\x18\x0b \x01(\x0b\x32\x16.predbat.ControlStatus\x12)\n\x08schedule\x18\x0c \x01(\x0b\x32\x17.predbat.ScheduleStatus\x12\x1f\n\x03\x65ms\x18\r \x01(\x0b\x32\x12.predbat.EmsStatus\x12\'\n\x06\x65nergy\x18\x0e \x01(\x0b\x32\x17.predbat.EnergyCounters\x12\x0f\n\x07managed\x18\x0f \x01(\x08\x12\x0f\n\x07primary\x18\x10 \x01(\x08\x12\x15\n\rdongle_serial\x18\x11 \x01(\t\x12\r\n\x05model\x18\x12 \x01(\t"\x8b\x02\n\tEvCharger\x12\x11\n\tconnected\x18\x01 \x01(\x08\x12\x16\n\x0esession_active\x18\x02 \x01(\x08\x12\x0e\n\x06status\x18\x03 \x01(\t\x12\x0f\n\x07power_w\x18\x04 \x01(\x05\x12\x19\n\x11session_energy_wh\x18\x05 \x01(\r\x12\x14\n\x0csession_secs\x18\x06 \x01(\r\x12\x17\n\x0f\x63urrent_limit_a\x18\x07 \x01(\r\x12\x13\n\x0bsoc_percent\x18\x08 \x01(\r\x12\x17\n\x0f\x63harge_point_id\x18\t \x01(\t\x12\x15\n\rmax_current_a\x18\n \x01(\r\x12\x11\n\tvoltage_v\x18\x0b \x01(\r\x12\x10\n\x08\x65\x63o_mode\x18\x0c \x01(\t"\xcb\x03\n\rGatewayStatus\x12\x11\n\tdevice_id\x18\x01 \x01(\t\x12\x14\n\x0c\x64ongle_count\x18\x02 \x01(\r\x12\x10\n\x08\x66irmware\x18\x03 \x01(\t\x12\x11\n\ttimestamp\x18\x04 \x01(\r\x12\x16\n\x0eschema_version\x18\x05 \x01(\r\x12)\n\tinverters\x18\x06 \x03(\x0b\x32\x16.predbat.InverterEntry\x12\x16\n\x0euptime_seconds\x18\x07 \x01(\r\x12\x15\n\rwifi_rssi_dbm\x18\x08 \x01(\x05\x12\x17\n\x0f\x66ree_heap_bytes\x18\t \x01(\r\x12\x11\n\tlog_level\x18\n \x01(\r\x12\x0f\n\x07wifi_ip\x18\x0b \x01(\t\x12\x17\n\nev_control\x18\x0c \x01(\x08H\x00\x88\x01\x01\x12\'\n\x0b\x65v_chargers\x18\r \x03(\x0b\x32\x12.predbat.EvCharger\x12\x11\n\twifi_ssid\x18\x0e \x01(\t\x12\x1a\n\x12heap_internal_free\x18\x0f \x01(\r\x12\x1d\n\x15heap_internal_largest\x18\x10 \x01(\r\x12\x1e\n\x16heap_internal_min_free\x18\x11 \x01(\rB\r\n\x0b_ev_control"\xc9\x01\n\tPlanEntry\x12\x0f\n\x07\x65nabled\x18\x01 \x01(\x08\x12\x12\n\nstart_hour\x18\x02 \x01(\r\x12\x14\n\x0cstart_minute\x18\x03 \x01(\r\x12\x10\n\x08\x65nd_hour\x18\x04 \x01(\r\x12\x12\n\nend_minute\x18\x05 \x01(\r\x12\x0c\n\x04mode\x18\x06 \x01(\r\x12\x0f\n\x07power_w\x18\x07 \x01(\r\x12\x12\n\ntarget_soc\x18\x08 \x01(\r\x12\x14\n\x0c\x64\x61ys_of_week\x18\t \x01(\r\x12\x12\n\nuse_native\x18\n \x01(\x08"o\n\rExecutionPlan\x12\x11\n\ttimestamp\x18\x01 \x01(\r\x12\x14\n\x0cplan_version\x18\x02 \x01(\r\x12\x10\n\x08timezone\x18\x03 \x01(\t\x12#\n\x07\x65ntries\x18\x04 \x03(\x0b\x32\x12.predbat.PlanEntry*\xf2\x02\n\x0cInverterType\x12\x19\n\x15INVERTER_TYPE_UNKNOWN\x10\x00\x12\x1e\n\x1aINVERTER_TYPE_SOLIS_HYBRID\x10\x01\x12\x1a\n\x16INVERTER_TYPE_SOLIS_AC\x10\x02\x12\x1a\n\x16INVERTER_TYPE_SOFAR_G3\x10\x03\x12\x1d\n\x19INVERTER_TYPE_GROWATT_SPH\x10\x04\x12\x1e\n\x1aINVERTER_TYPE_DEYE_SUNSYNK\x10\x05\x12\x1b\n\x17INVERTER_TYPE_GIVENERGY\x10\x06\x12\x1f\n\x1bINVERTER_TYPE_GIVENERGY_EMS\x10\x07\x12#\n\x1fINVERTER_TYPE_GIVENERGY_GATEWAY\x10\x08\x12\x1b\n\x17INVERTER_TYPE_ALPHA_ESS\x10\t\x12\x16\n\x12INVERTER_TYPE_DEMO\x10\n\x12\x18\n\x14INVERTER_TYPE_CUSTOM\x10\x0b\x62\x06proto3' ) _globals = globals() @@ -25,8 +25,8 @@ _builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, "gateway_status_pb2", _globals) if not _descriptor._USE_C_DESCRIPTORS: DESCRIPTOR._loaded_options = None - _globals["_INVERTERTYPE"]._serialized_start = 2920 - _globals["_INVERTERTYPE"]._serialized_end = 3290 + _globals["_INVERTERTYPE"]._serialized_start = 2945 + _globals["_INVERTERTYPE"]._serialized_end = 3315 _globals["_BATTERYSTATUS"]._serialized_start = 34 _globals["_BATTERYSTATUS"]._serialized_end = 263 _globals["_PVSTATUS"]._serialized_start = 265 @@ -38,23 +38,23 @@ _globals["_INVERTERDATA"]._serialized_start = 397 _globals["_INVERTERDATA"]._serialized_end = 478 _globals["_CONTROLSTATUS"]._serialized_start = 481 - _globals["_CONTROLSTATUS"]._serialized_end = 715 - _globals["_SCHEDULESTATUS"]._serialized_start = 717 - _globals["_SCHEDULESTATUS"]._serialized_end = 823 - _globals["_EMSSUBINVERTER"]._serialized_start = 825 - _globals["_EMSSUBINVERTER"]._serialized_end = 919 - _globals["_EMSSTATUS"]._serialized_start = 922 - _globals["_EMSSTATUS"]._serialized_end = 1138 - _globals["_ENERGYCOUNTERS"]._serialized_start = 1141 - _globals["_ENERGYCOUNTERS"]._serialized_end = 1337 - _globals["_INVERTERENTRY"]._serialized_start = 1340 - _globals["_INVERTERENTRY"]._serialized_end = 1868 - _globals["_EVCHARGER"]._serialized_start = 1871 - _globals["_EVCHARGER"]._serialized_end = 2138 - _globals["_GATEWAYSTATUS"]._serialized_start = 2141 - _globals["_GATEWAYSTATUS"]._serialized_end = 2600 - _globals["_PLANENTRY"]._serialized_start = 2603 - _globals["_PLANENTRY"]._serialized_end = 2804 - _globals["_EXECUTIONPLAN"]._serialized_start = 2806 - _globals["_EXECUTIONPLAN"]._serialized_end = 2917 + _globals["_CONTROLSTATUS"]._serialized_end = 740 + _globals["_SCHEDULESTATUS"]._serialized_start = 742 + _globals["_SCHEDULESTATUS"]._serialized_end = 848 + _globals["_EMSSUBINVERTER"]._serialized_start = 850 + _globals["_EMSSUBINVERTER"]._serialized_end = 944 + _globals["_EMSSTATUS"]._serialized_start = 947 + _globals["_EMSSTATUS"]._serialized_end = 1163 + _globals["_ENERGYCOUNTERS"]._serialized_start = 1166 + _globals["_ENERGYCOUNTERS"]._serialized_end = 1362 + _globals["_INVERTERENTRY"]._serialized_start = 1365 + _globals["_INVERTERENTRY"]._serialized_end = 1893 + _globals["_EVCHARGER"]._serialized_start = 1896 + _globals["_EVCHARGER"]._serialized_end = 2163 + _globals["_GATEWAYSTATUS"]._serialized_start = 2166 + _globals["_GATEWAYSTATUS"]._serialized_end = 2625 + _globals["_PLANENTRY"]._serialized_start = 2628 + _globals["_PLANENTRY"]._serialized_end = 2829 + _globals["_EXECUTIONPLAN"]._serialized_start = 2831 + _globals["_EXECUTIONPLAN"]._serialized_end = 2942 # @@protoc_insertion_point(module_scope) diff --git a/apps/predbat/tests/test_gateway.py b/apps/predbat/tests/test_gateway.py index 048ab6f69..6d7d5d8e5 100644 --- a/apps/predbat/tests/test_gateway.py +++ b/apps/predbat/tests/test_gateway.py @@ -536,6 +536,53 @@ def test_export_limit_w_one_zero_limit_publishes_zero(self): state, _ = gw._dashboard_calls[entity] assert state == 0 + def test_reserve_soc_max_published_as_the_entity_max(self): + """The firmware's reserve ceiling becomes the reserve entity's max attribute, which is what adjust_reserve() clamps a hold to.""" + status = self._make_status() + status.inverters[0].control.reserve_soc_max = 98 + gw = self._make_gateway() + gw._inject_entities(status) + + entity = "number.predbat_gateway_456789_reserve_soc" + assert entity in gw._dashboard_calls + state, attrs = gw._dashboard_calls[entity] + assert state == 4 + assert attrs["max"] == 98 + # The rest of the table entry survives the override + assert attrs["min"] == 0 + assert attrs["step"] == 1 + assert attrs["unit_of_measurement"] == "%" + + def test_reserve_soc_max_zero_falls_back_to_100(self): + """Gateway firmware predating the field reports 0, which means "no limit reported" and must not cap the reserve at zero.""" + status = self._make_status() # control.reserve_soc_max defaults to 0 + gw = self._make_gateway() + gw._inject_entities(status) + + _, attrs = gw._dashboard_calls["number.predbat_gateway_456789_reserve_soc"] + assert attrs["max"] == 100 + + def test_reserve_soc_max_out_of_range_falls_back_to_100(self): + """A ceiling above 100 is nonsense and is ignored rather than published as a bound.""" + status = self._make_status() + status.inverters[0].control.reserve_soc_max = 255 + gw = self._make_gateway() + gw._inject_entities(status) + + _, attrs = gw._dashboard_calls["number.predbat_gateway_456789_reserve_soc"] + assert attrs["max"] == 100 + + def test_reserve_soc_max_does_not_mutate_the_shared_table(self): + """The ceiling is per-inverter, so it is applied to a copy: one inverter's 98 must not become every inverter's max.""" + from gateway import GATEWAY_ATTRIBUTE_TABLE + + status = self._make_status() + status.inverters[0].control.reserve_soc_max = 98 + gw = self._make_gateway() + gw._inject_entities(status) + + assert GATEWAY_ATTRIBUTE_TABLE["reserve_soc"]["max"] == 100 + def test_ems_aggregate_entities(self): """EMS aggregate and sub-inverter entities are published with table attributes.""" from gateway import GATEWAY_ATTRIBUTE_TABLE