Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion apps/predbat/gateway.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions apps/predbat/gateway_status.proto
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
44 changes: 22 additions & 22 deletions apps/predbat/gateway_status_pb2.py

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

47 changes: 47 additions & 0 deletions apps/predbat/tests/test_gateway.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading