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