Skip to content

Commit 353a234

Browse files
CIclaude
andcommitted
fix(gateway): hold rate writes until an inverter has reported its maximum rate (#5478)
Until an inverter has reported its maximum rate, the rate Predbat works to is the 6000 W default. The plan still needs a figure, but it is only an assumption, so the gateway no longer acts on it: a charge or discharge rate write above zero for that inverter is not sent, and nor is its set_rate_cap. Predbat reads the unchanged rate back and tries again, so the write goes through once the rating is known. A rate of zero is always sent, so a charge or discharge can still be stopped. A Gateway or Plant EMS is not held: it has no battery rating of its own. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 3c68181 commit 353a234

2 files changed

Lines changed: 109 additions & 1 deletion

File tree

‎apps/predbat/gateway.py‎

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,7 @@ class GatewayMQTT(ComponentBase):
240240
_inverter_slot_serials = ()
241241
_slots_without_capacity = frozenset()
242242
_last_ratings = {} # replaced, never changed in place
243+
_assumed_ratings = frozenset()
243244
gateway_device_id = None
244245

245246
def initialize(self, gateway_device_id=None, mqtt_host=None, mqtt_port=8883, mqtt_token=None, gateway_inverter_serial=None, gateway_evc_automatic=False, gateway_evc_control=False, gateway_shared_ct=True, gateway_integrate_power=False, **kwargs):
@@ -347,6 +348,7 @@ def initialize(self, gateway_device_id=None, mqtt_host=None, mqtt_port=8883, mqt
347348
self._pv_only_serials = () # listed inverters with no battery: counted for PV, never written to
348349
self._slots_without_capacity = frozenset() # slots bound with no battery size sensor, re-bound once the hub reports a size
349350
self._last_ratings = {} # (entity suffix, rating name) -> the last real value the hub reported
351+
self._assumed_ratings = frozenset() # entity suffixes of inverters whose maximum rate has never been reported
350352
self._discovery = {} # upper-case serial -> what the hub last reported it to be, for gateway_inverter_serial
351353
self._discovery_dirty = False
352354
self._discovery_save_retry_at = 0
@@ -1253,7 +1255,13 @@ def _inject_inverter_entities(self, inv, suffix, site_energy=None):
12531255
# ratings unpopulated (seen for a few minutes after a hub restart) otherwise turned a 3kW
12541256
# inverter into a 6kW one with no export limit, and those rates were written to it (#5478).
12551257
record = self._discovery.get(inv.serial.upper(), {})
1256-
battery_rate_max = self._last_rating(suffix, "battery_rate_max", bat.rate_max_w) or record.get("rate_max_w") or 6000
1258+
battery_rate_max = self._last_rating(suffix, "battery_rate_max", bat.rate_max_w) or record.get("rate_max_w")
1259+
# With no rating ever reported the plan still needs a figure, but it is only an assumption:
1260+
# rate writes for the inverter are held until the real one arrives (see _rate_write_held)
1261+
assumed = not battery_rate_max and inv.type not in GATEWAY_COORDINATOR_TYPES
1262+
if assumed != (suffix in self._assumed_ratings):
1263+
self._assumed_ratings = self._assumed_ratings | {suffix} if assumed else self._assumed_ratings - {suffix}
1264+
battery_rate_max = battery_rate_max or 6000
12571265
self.dashboard_item(f"sensor.{pfx}_battery_rate_max", battery_rate_max, attributes=GATEWAY_ATTRIBUTE_TABLE.get("battery_rate_max", {}), app="gateway")
12581266

12591267
self.dashboard_item(f"sensor.{pfx}_pv_power", inv.pv.power_w, attributes=GATEWAY_ATTRIBUTE_TABLE.get("pv_power", {}), app="gateway")
@@ -2412,6 +2420,9 @@ async def _check_rate_caps(self):
24122420
for serial, caps in self._inverter_rate_caps().items():
24132421
if self._rate_caps_sent.get(serial) == caps:
24142422
continue
2423+
if _serial_suffix(serial) in self._assumed_ratings:
2424+
# The cap comes from a rating the inverter has not reported yet (see _rate_write_held)
2425+
continue
24152426
charge_cap_w, discharge_cap_w = caps
24162427
await self.publish_command("set_rate_cap", serial=serial, charge_cap_w=charge_cap_w, discharge_cap_w=discharge_cap_w)
24172428
self._rate_caps_sent[serial] = caps
@@ -2643,6 +2654,8 @@ async def number_event(self, entity_id, value):
26432654
if serial is None:
26442655
self.log(f"Warn: GatewayMQTT: number_event: cannot resolve serial for entity '{entity_id}' — command not sent")
26452656
return
2657+
if ("_discharge_rate" in entity_id or "_charge_rate" in entity_id) and self._rate_write_held(serial, val):
2658+
return
26462659
if "_discharge_rate" in entity_id:
26472660
await self._send_control(entity_id, "set_discharge_rate", power_w=val, serial=serial)
26482661
elif "_charge_rate" in entity_id:
@@ -2652,6 +2665,27 @@ async def number_event(self, entity_id, value):
26522665
elif "_target_soc" in entity_id:
26532666
await self._send_control(entity_id, "set_target_soc", target_soc=val, serial=serial)
26542667

2668+
def _rate_write_held(self, serial, power_w):
2669+
"""Whether a charge or discharge rate write must wait for the inverter's rating.
2670+
2671+
Until an inverter has reported its maximum rate, the rate Predbat works to is the 6000W
2672+
default, and writing that to a 3kW inverter is the fault in #5478. Such a write is not
2673+
sent. Predbat reads the unchanged rate back as a write that did not complete and tries
2674+
again, so it goes through once the rating is known. A rate of zero is safe on any
2675+
inverter and is always sent, so a charge or discharge can still be stopped.
2676+
2677+
Args:
2678+
serial: The inverter the write is for.
2679+
power_w: The rate being written.
2680+
2681+
Returns:
2682+
bool: True if the write is held.
2683+
"""
2684+
if power_w <= 0 or _serial_suffix(serial) not in self._assumed_ratings:
2685+
return False
2686+
self.log(f"Warn: GatewayMQTT: inverter {serial} has not reported its maximum rate — rate write of {power_w}W held until it does")
2687+
return True
2688+
26552689
async def switch_event(self, entity_id, service):
26562690
"""Handle switch entity service calls (charge/discharge enable).
26572691

‎apps/predbat/tests/test_gateway.py‎

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4042,6 +4042,80 @@ def test_ratings_never_reported_keep_the_existing_defaults(self):
40424042
assert gw._states["sensor.predbat_gateway_25g400_inverter_rate_max"] == 6000
40434043
assert gw._states["sensor.predbat_gateway_25g400_export_limit_w"] == 99999
40444044

4045+
def _writable(self, gw):
4046+
"""Let *gw* take control writes, recording the commands it publishes."""
4047+
from unittest.mock import MagicMock
4048+
4049+
gw._mqtt_connected = True
4050+
gw._mqtt_client = MagicMock()
4051+
gw._gateway_online = True
4052+
gw._rate_caps_sent = {}
4053+
gw.is_alive = lambda: True
4054+
gw.get_arg = lambda name, default=None, **kwargs: default
4055+
gw._published = []
4056+
4057+
async def fake_publish_command(command, **kwargs):
4058+
gw._published.append((command, kwargs))
4059+
4060+
gw.publish_command = fake_publish_command
4061+
return gw
4062+
4063+
def test_rate_writes_are_held_until_the_inverter_reports_its_rating(self):
4064+
"""With no rating ever reported the 6000W default is only an assumption, so no rate above zero is written on it."""
4065+
gw = self._writable(self._listed_gateway([]))
4066+
gw._process_telemetry(self._rated_status(rate_max_w=0, inverter_rate_max_w=0).SerializeToString())
4067+
assert gw._states["sensor.predbat_gateway_25g400_battery_rate_max"] == 6000
4068+
4069+
asyncio.run(gw.number_event("number.predbat_gateway_25g400_discharge_rate", 6000))
4070+
asyncio.run(gw.number_event("number.predbat_gateway_25g400_charge_rate", 6000))
4071+
assert gw._published == []
4072+
assert self._logged(gw, "Warn", "CE2225G400", "has not reported its maximum rate")
4073+
4074+
# Stopping a charge or discharge is safe at any rating, as are the other controls and the other inverter
4075+
asyncio.run(gw.number_event("number.predbat_gateway_25g400_charge_rate", 0))
4076+
asyncio.run(gw.number_event("number.predbat_gateway_25g400_reserve_soc", 4))
4077+
asyncio.run(gw.number_event("number.predbat_gateway_23g800_discharge_rate", 3000))
4078+
assert gw._published == [
4079+
("set_charge_rate", {"power_w": 0, "serial": "CE2225G400"}),
4080+
("set_reserve", {"target_soc": 4, "serial": "CE2225G400"}),
4081+
("set_discharge_rate", {"power_w": 3000, "serial": "CE2223G800"}),
4082+
]
4083+
4084+
# Once the rating arrives the write goes through, and a later gap in the rating does not hold it again
4085+
for rate_max_w in (3000, 0):
4086+
gw._published.clear()
4087+
gw._process_telemetry(self._rated_status(rate_max_w=rate_max_w).SerializeToString())
4088+
asyncio.run(gw.number_event("number.predbat_gateway_25g400_discharge_rate", 3000))
4089+
assert gw._published == [("set_discharge_rate", {"power_w": 3000, "serial": "CE2225G400"})], rate_max_w
4090+
4091+
def test_rate_cap_is_not_sent_on_an_assumed_rating(self):
4092+
"""The cap sent to the hub comes from the same rating, so it waits for the real one too."""
4093+
from types import SimpleNamespace
4094+
4095+
gw = self._writable(self._listed_gateway([]))
4096+
gw._process_telemetry(self._rated_status(rate_max_w=0, inverter_rate_max_w=0).SerializeToString())
4097+
gw.base.inverters = [SimpleNamespace(inverter_type="GWMQTT", id=slot, battery_rate_max_charge=0.05, battery_rate_max_discharge=0.05) for slot in (0, 1)]
4098+
gw._inverter_rate_caps = lambda: {"CE2223G800": (3000, 3000), "CE2225G400": (6000, 6000)}
4099+
4100+
asyncio.run(gw._check_rate_caps())
4101+
assert gw._published == [("set_rate_cap", {"serial": "CE2223G800", "charge_cap_w": 3000, "discharge_cap_w": 3000})]
4102+
4103+
gw._process_telemetry(self._rated_status().SerializeToString())
4104+
gw._inverter_rate_caps = lambda: {"CE2223G800": (3000, 3000), "CE2225G400": (3000, 3000)}
4105+
asyncio.run(gw._check_rate_caps())
4106+
assert gw._published[1:] == [("set_rate_cap", {"serial": "CE2225G400", "charge_cap_w": 3000, "discharge_cap_w": 3000})]
4107+
4108+
def test_coordinator_rate_writes_are_not_held(self):
4109+
"""A Gateway or Plant EMS is not judged on a battery rating of its own."""
4110+
gw = self._writable(self._listed_gateway([]))
4111+
status = self._gateway_plus_aios_status(["CH2414G318", "CH9999G999"])
4112+
status.inverters[0].battery.rate_max_w = 0
4113+
gw._process_telemetry(status.SerializeToString())
4114+
assert gw._inverter_slot_serials == ["GW2347G077"]
4115+
4116+
asyncio.run(gw.number_event("number.predbat_gateway_47g077_charge_rate", 6000))
4117+
assert gw._published == [("set_charge_rate", {"power_w": 6000, "serial": "GW2347G077"})]
4118+
40454119
def test_stored_ratings_are_used_after_a_restart(self):
40464120
"""With a serial list the ratings are in the discovery record, so a restart does not fall back to 6000W."""
40474121
first = self._listed_gateway(["CE2223G800", "CE2225G400"])

0 commit comments

Comments
 (0)