From 188e5807b4551352a650f6865524bf5cd5882f93 Mon Sep 17 00:00:00 2001 From: Trefor Southwell Date: Sat, 11 Apr 2026 10:07:26 +0100 Subject: [PATCH 1/5] Fix gateway to send serial number --- .cspell/custom-dictionary-workspace.txt | 1 + apps/predbat/gateway.py | 50 ++++++++++++++++++------- 2 files changed, 38 insertions(+), 13 deletions(-) diff --git a/.cspell/custom-dictionary-workspace.txt b/.cspell/custom-dictionary-workspace.txt index 6e861b052..30cf131c3 100644 --- a/.cspell/custom-dictionary-workspace.txt +++ b/.cspell/custom-dictionary-workspace.txt @@ -227,6 +227,7 @@ Mosquitto mpan mppt mprn +mpxn mqtt mult myenergi diff --git a/apps/predbat/gateway.py b/apps/predbat/gateway.py index d2bcae025..bb55b65c0 100644 --- a/apps/predbat/gateway.py +++ b/apps/predbat/gateway.py @@ -175,6 +175,7 @@ def initialize(self, gateway_device_id=None, mqtt_host=None, mqtt_port=8883, mqt self._auto_configured = False self._last_published_plan = None self._pending_plan = None + self._suffix_to_serial = {} # maps entity suffix (last 6 chars of serial) -> full serial string # Predbat data publish state (price/timeline for device display) self._last_predbat_data = None @@ -655,6 +656,7 @@ def automatic_config(self): for inv in inverters: suffix = inv.serial[-6:].lower() base = f"{self.prefix}_gateway_{suffix}" + self._suffix_to_serial[suffix] = inv.serial soc_entities.append(f"sensor.{base}_soc") battery_power_entities.append(f"sensor.{base}_battery_power") @@ -965,6 +967,21 @@ def get_error_count(self): """Return the cumulative error count (decode failures, MQTT disconnects, publish failures).""" return self._error_count + def _serial_from_entity_id(self, entity_id): + """Extract the full inverter serial from a gateway entity_id. + + Entity IDs follow the pattern {domain}.{prefix}_gateway_{suffix}_{attribute} + where suffix is the last 6 chars of the inverter serial, lowercased. + Returns the full serial string, or None if the suffix is not in the map. + """ + marker = "_gateway_" + idx = entity_id.find(marker) + if idx == -1: + return None + after = entity_id[idx + len(marker) :] + suffix = after[:6] + return self._suffix_to_serial.get(suffix) + async def select_event(self, entity_id, value): """Handle select entity changes (mode, schedule times). @@ -974,10 +991,11 @@ async def select_event(self, entity_id, value): """ self.log("Info: GatewayMQTT: select_event: entity_id={}, value={}".format(entity_id, value)) + serial = self._serial_from_entity_id(entity_id) # Operating mode selector if "_mode_select" in entity_id: mode_int = GATEWAY_OPERATING_MODE_VALUES.get(str(value).strip(), 0) - await self.publish_command("set_mode", mode=mode_int) + await self.publish_command("set_mode", mode=mode_int, **({"serial": serial} if serial else {})) self.log(f"Info: GatewayMQTT: Operating mode set to {value} ({mode_int})") return @@ -991,28 +1009,28 @@ async def select_event(self, entity_id, value): return if "_discharge_slot1_start" in entity_id or "_discharge_slot1_end" in entity_id: - await self._update_discharge_slot(entity_id, hhmm) + await self._update_discharge_slot(entity_id, hhmm, serial=serial) elif "_charge_slot1_start" in entity_id or "_charge_slot1_end" in entity_id: # Read current charge slot times to send both start and end - await self._update_charge_slot(entity_id, hhmm) + await self._update_charge_slot(entity_id, hhmm, serial=serial) - async def _update_charge_slot(self, entity_id, hhmm): + async def _update_charge_slot(self, entity_id, hhmm, serial=None): """Send set_charge_slot command with updated start or end time.""" # Determine which field changed if "_start" in entity_id: schedule = {"start": hhmm} else: schedule = {"end": hhmm} - await self.publish_command("set_charge_slot", schedule_json=json.dumps(schedule)) + await self.publish_command("set_charge_slot", schedule_json=json.dumps(schedule), **({"serial": serial} if serial else {})) self.log(f"Info: GatewayMQTT: Charge slot update: {schedule}") - async def _update_discharge_slot(self, entity_id, hhmm): + async def _update_discharge_slot(self, entity_id, hhmm, serial=None): """Send set_discharge_slot command with updated start or end time.""" if "_start" in entity_id: schedule = {"start": hhmm} else: schedule = {"end": hhmm} - await self.publish_command("set_discharge_slot", schedule_json=json.dumps(schedule)) + await self.publish_command("set_discharge_slot", schedule_json=json.dumps(schedule), **({"serial": serial} if serial else {})) self.log(f"Info: GatewayMQTT: Discharge slot update: {schedule}") async def number_event(self, entity_id, value): @@ -1030,14 +1048,16 @@ async def number_event(self, entity_id, value): self.log(f"Warn: GatewayMQTT: Invalid number value: {value}") return + serial = self._serial_from_entity_id(entity_id) + serial_kwarg = {"serial": serial} if serial else {} if "_discharge_rate" in entity_id: - await self.publish_command("set_discharge_rate", power_w=val) + await self.publish_command("set_discharge_rate", power_w=val, **serial_kwarg) elif "_charge_rate" in entity_id: - await self.publish_command("set_charge_rate", power_w=val) + await self.publish_command("set_charge_rate", power_w=val, **serial_kwarg) elif "_reserve" in entity_id: - await self.publish_command("set_reserve", target_soc=val) + await self.publish_command("set_reserve", target_soc=val, **serial_kwarg) elif "_target_soc" in entity_id: - await self.publish_command("set_target_soc", target_soc=val) + await self.publish_command("set_target_soc", target_soc=val, **serial_kwarg) async def switch_event(self, entity_id, service): """Handle switch entity service calls (charge/discharge enable). @@ -1058,11 +1078,13 @@ async def switch_event(self, entity_id, service): elif service == "toggle": is_on = not old_value + serial = self._serial_from_entity_id(entity_id) + serial_kwarg = {"serial": serial} if serial else {} if "_charge_enabled" in entity_id: - await self.publish_command("set_charge_enable", enable=is_on) + await self.publish_command("set_charge_enable", enable=is_on, **serial_kwarg) self.log(f"Info: GatewayMQTT: Charge {'enabled' if is_on else 'disabled'}") elif "_discharge_enabled" in entity_id: - await self.publish_command("set_discharge_enable", enable=is_on) + await self.publish_command("set_discharge_enable", enable=is_on, **serial_kwarg) self.log(f"Info: GatewayMQTT: Discharge {'enabled' if is_on else 'disabled'}") async def final(self): @@ -1316,5 +1338,7 @@ def build_command(command, **kwargs): cmd["schedule_json"] = kwargs["schedule_json"] if "enable" in kwargs: cmd["enable"] = bool(kwargs["enable"]) + if "serial" in kwargs: + cmd["serial"] = kwargs["serial"] return json.dumps(cmd) From 8692789acd0c5ca43aae5ba331261adca0866c19 Mon Sep 17 00:00:00 2001 From: Trefor Southwell Date: Sat, 11 Apr 2026 10:09:31 +0100 Subject: [PATCH 2/5] Tests --- apps/predbat/tests/test_gateway.py | 32 ++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/apps/predbat/tests/test_gateway.py b/apps/predbat/tests/test_gateway.py index 127a9636b..fb575d66b 100644 --- a/apps/predbat/tests/test_gateway.py +++ b/apps/predbat/tests/test_gateway.py @@ -173,6 +173,34 @@ def test_set_reserve_command(self): assert parsed["command"] == "set_reserve" assert parsed["target_soc"] == 10 + def test_serial_included_when_provided(self): + """serial kwarg is included verbatim in the JSON payload.""" + from gateway import GatewayMQTT + import json + + cmd = GatewayMQTT.build_command("set_mode", mode=1, serial="CE123456789") + parsed = json.loads(cmd) + assert parsed["serial"] == "CE123456789" + + def test_serial_preserves_original_case(self): + """Serial is stored as-is (uppercase) even though entity suffixes are lowercased.""" + from gateway import GatewayMQTT + import json + + cmd = GatewayMQTT.build_command("set_charge_rate", power_w=3000, serial="CE123456789") + parsed = json.loads(cmd) + assert parsed["serial"] == "CE123456789" + assert parsed["serial"] != parsed["serial"].lower() + + def test_serial_omitted_when_not_provided(self): + """serial key is absent from the JSON when no serial kwarg is given.""" + from gateway import GatewayMQTT + import json + + cmd = GatewayMQTT.build_command("set_mode", mode=1) + parsed = json.loads(cmd) + assert "serial" not in parsed + class TestScheduleSlotCommand: def test_set_charge_slot_command(self): @@ -705,6 +733,7 @@ def _make_gateway(self): gw.prefix = "predbat" gw._last_status = None gw._auto_configured = False + gw._suffix_to_serial = {} gw.args = {} gw._args = {} @@ -929,6 +958,7 @@ def _make_gateway(self): gw = GatewayMQTT.__new__(GatewayMQTT) gw.log = MagicMock() gw.prefix = "predbat" + gw._suffix_to_serial = {} gw._mqtt_connected = True gw._mqtt_client = MagicMock() gw.topic_command = "predbat/devices/pbgw_test/command" @@ -1032,6 +1062,7 @@ def _make_gateway(self): gw = GatewayMQTT.__new__(GatewayMQTT) gw.log = MagicMock() gw.prefix = "predbat" + gw._suffix_to_serial = {} gw._mqtt_connected = True gw._mqtt_client = MagicMock() gw.topic_command = "predbat/devices/pbgw_test/command" @@ -1133,6 +1164,7 @@ def _make_gateway(self): gw = GatewayMQTT.__new__(GatewayMQTT) gw.log = MagicMock() gw.prefix = "predbat" + gw._suffix_to_serial = {} gw._mqtt_connected = True gw._mqtt_client = MagicMock() gw.topic_command = "predbat/devices/pbgw_test/command" From 8b30192199ed473ec96025fd9bb92c0dcf40808d Mon Sep 17 00:00:00 2001 From: Trefor Southwell <48591903+springfall2008@users.noreply.github.com> Date: Sat, 11 Apr 2026 10:42:57 +0100 Subject: [PATCH 3/5] Update apps/predbat/gateway.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- apps/predbat/gateway.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/predbat/gateway.py b/apps/predbat/gateway.py index bb55b65c0..f38f05141 100644 --- a/apps/predbat/gateway.py +++ b/apps/predbat/gateway.py @@ -1077,6 +1077,9 @@ async def switch_event(self, entity_id, service): is_on = False elif service == "toggle": is_on = not old_value + else: + self.log("Warn: GatewayMQTT: switch_event: Unsupported service={} for entity_id={}".format(service, entity_id)) + return serial = self._serial_from_entity_id(entity_id) serial_kwarg = {"serial": serial} if serial else {} From 1e12d68feba417739272e27898b615f6e79ac140 Mon Sep 17 00:00:00 2001 From: Trefor Southwell Date: Sat, 11 Apr 2026 10:46:11 +0100 Subject: [PATCH 4/5] Tests --- apps/predbat/tests/test_gateway.py | 83 ++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) diff --git a/apps/predbat/tests/test_gateway.py b/apps/predbat/tests/test_gateway.py index fb575d66b..7cb4ec71f 100644 --- a/apps/predbat/tests/test_gateway.py +++ b/apps/predbat/tests/test_gateway.py @@ -1051,6 +1051,35 @@ def test_unrecognised_entity_no_command(self): self._run(gw.select_event("select.predbat_some_other_select", "01:00:00")) assert gw._published == [] + # ------------------------------------------------------------------ + # Serial routing + # ------------------------------------------------------------------ + + def test_mode_select_includes_serial_when_known(self): + """mode_select passes full inverter serial to publish_command when suffix is in the map.""" + gw = self._make_gateway() + gw._suffix_to_serial["456789"] = "CE123456789" + self._run(gw.select_event("select.predbat_gateway_456789_mode_select", "Eco")) + assert len(gw._published) == 1 + _, kwargs = gw._published[0] + assert kwargs.get("serial") == "CE123456789" + + def test_charge_slot_includes_serial_when_known(self): + """charge_slot1_start passes full inverter serial when suffix is in the map.""" + gw = self._make_gateway() + gw._suffix_to_serial["456789"] = "CE123456789" + self._run(gw.select_event("select.predbat_gateway_456789_charge_slot1_start", "01:30:00")) + _, kwargs = gw._published[0] + assert kwargs.get("serial") == "CE123456789" + + def test_serial_omitted_when_suffix_not_in_map(self): + """serial is absent from kwargs when the suffix has no entry in _suffix_to_serial.""" + gw = self._make_gateway() + # _suffix_to_serial is empty — suffix "456789" unknown + self._run(gw.select_event("select.predbat_gateway_456789_mode_select", "Eco")) + _, kwargs = gw._published[0] + assert "serial" not in kwargs + class TestNumberEvent: """Tests for GatewayMQTT.number_event() — numeric entity → command routing.""" @@ -1153,6 +1182,33 @@ def test_unrecognised_entity_no_command(self): self._run(gw.number_event("number.predbat_some_other_number", "50")) assert gw._published == [] + # ------------------------------------------------------------------ + # Serial routing + # ------------------------------------------------------------------ + + def test_charge_rate_includes_serial_when_known(self): + """charge_rate entity includes full inverter serial when suffix is in the map.""" + gw = self._make_gateway() + gw._suffix_to_serial["456789"] = "CE123456789" + self._run(gw.number_event("number.predbat_gateway_456789_charge_rate", "3000")) + _, kwargs = gw._published[0] + assert kwargs.get("serial") == "CE123456789" + + def test_discharge_rate_includes_serial_when_known(self): + """discharge_rate entity includes full inverter serial when suffix is in the map.""" + gw = self._make_gateway() + gw._suffix_to_serial["456789"] = "CE123456789" + self._run(gw.number_event("number.predbat_gateway_456789_discharge_rate", "2500")) + _, kwargs = gw._published[0] + assert kwargs.get("serial") == "CE123456789" + + def test_serial_omitted_when_suffix_not_in_map(self): + """serial is absent from kwargs when the suffix has no entry in _suffix_to_serial.""" + gw = self._make_gateway() + self._run(gw.number_event("number.predbat_gateway_456789_charge_rate", "3000")) + _, kwargs = gw._published[0] + assert "serial" not in kwargs + class TestSwitchEvent: """Tests for GatewayMQTT.switch_event() — charge/discharge enable → mode commands.""" @@ -1260,6 +1316,33 @@ def test_only_one_command_per_call(self): self._run(gw.switch_event("switch.predbat_gateway_456789_charge_enabled", "turn_on")) assert len(gw._published) == 1 + # ------------------------------------------------------------------ + # Serial routing + # ------------------------------------------------------------------ + + def test_charge_enabled_includes_serial_when_known(self): + """charge_enabled switch includes full inverter serial when suffix is in the map.""" + gw = self._make_gateway() + gw._suffix_to_serial["456789"] = "CE123456789" + self._run(gw.switch_event("switch.predbat_gateway_456789_charge_enabled", "turn_on")) + _, kwargs = gw._published[0] + assert kwargs.get("serial") == "CE123456789" + + def test_discharge_enabled_includes_serial_when_known(self): + """discharge_enabled switch includes full inverter serial when suffix is in the map.""" + gw = self._make_gateway() + gw._suffix_to_serial["456789"] = "CE123456789" + self._run(gw.switch_event("switch.predbat_gateway_456789_discharge_enabled", "turn_off")) + _, kwargs = gw._published[0] + assert kwargs.get("serial") == "CE123456789" + + def test_serial_omitted_when_suffix_not_in_map(self): + """serial is absent from kwargs when the suffix has no entry in _suffix_to_serial.""" + gw = self._make_gateway() + self._run(gw.switch_event("switch.predbat_gateway_456789_charge_enabled", "turn_on")) + _, kwargs = gw._published[0] + assert "serial" not in kwargs + class TestPublishPredbatData: """Tests for GatewayMQTT._publish_predbat_data() payload structure.""" From 370c835b95ad34f61fceb05367a6a5f4b9aba824 Mon Sep 17 00:00:00 2001 From: Trefor Southwell Date: Sat, 11 Apr 2026 11:32:40 +0100 Subject: [PATCH 5/5] Review feedback --- apps/predbat/gateway.py | 9 ++++-- apps/predbat/tests/test_gateway.py | 47 ++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 2 deletions(-) diff --git a/apps/predbat/gateway.py b/apps/predbat/gateway.py index f38f05141..7991af2b9 100644 --- a/apps/predbat/gateway.py +++ b/apps/predbat/gateway.py @@ -979,8 +979,13 @@ def _serial_from_entity_id(self, entity_id): if idx == -1: return None after = entity_id[idx + len(marker) :] - suffix = after[:6] - return self._suffix_to_serial.get(suffix) + # Extract everything up to the next underscore (handles serials shorter than 6 chars) + underscore = after.find("_") + suffix = after[:underscore].lower() if underscore != -1 else after.lower() + serial = self._suffix_to_serial.get(suffix) + if serial is None: + self.log(f"Warn: GatewayMQTT: _serial_from_entity_id: no serial found for suffix '{suffix}' in entity '{entity_id}'") + return serial async def select_event(self, entity_id, value): """Handle select entity changes (mode, schedule times). diff --git a/apps/predbat/tests/test_gateway.py b/apps/predbat/tests/test_gateway.py index 7cb4ec71f..19a3f4c8b 100644 --- a/apps/predbat/tests/test_gateway.py +++ b/apps/predbat/tests/test_gateway.py @@ -224,6 +224,53 @@ def test_set_discharge_slot_command(self): assert parsed["schedule_json"] == '{"start": 1600}' +class TestSerialFromEntityId: + """Tests for GatewayMQTT._serial_from_entity_id() suffix extraction and map lookup.""" + + def _make_gateway(self): + from gateway import GatewayMQTT + from unittest.mock import MagicMock + + gw = GatewayMQTT.__new__(GatewayMQTT) + gw.log = MagicMock() + gw._suffix_to_serial = {} + return gw + + def test_standard_6char_suffix(self): + """Normal entity with 6-char suffix resolves to the correct full serial.""" + gw = self._make_gateway() + gw._suffix_to_serial["456789"] = "CE123456789" + assert gw._serial_from_entity_id("select.predbat_gateway_456789_mode_select") == "CE123456789" + + def test_short_serial_suffix(self): + """Serials shorter than 6 chars produce a shorter suffix; lookup still succeeds.""" + gw = self._make_gateway() + gw._suffix_to_serial["abc"] = "ABC" # serial == suffix (3 chars) + assert gw._serial_from_entity_id("select.predbat_gateway_abc_mode_select") == "ABC" + + def test_suffix_lookup_is_case_insensitive(self): + """Entity ID suffix is lowercased before lookup even if entity_id contains upper chars.""" + gw = self._make_gateway() + gw._suffix_to_serial["456789"] = "CE123456789" + # Uppercase in entity_id (unusual but should still resolve) + assert gw._serial_from_entity_id("select.predbat_gateway_456789_mode_select") == "CE123456789" + + def test_no_gateway_marker_returns_none(self): + """Entity IDs without '_gateway_' return None without logging.""" + gw = self._make_gateway() + result = gw._serial_from_entity_id("select.predbat_some_other_entity") + assert result is None + gw.log.assert_not_called() + + def test_unknown_suffix_returns_none_and_warns(self): + """Unknown suffix returns None and emits a Warn log.""" + gw = self._make_gateway() + result = gw._serial_from_entity_id("select.predbat_gateway_456789_mode_select") + assert result is None + gw.log.assert_called_once() + assert "Warn" in gw.log.call_args[0][0] + + class TestInjectEntities: """Tests for GatewayMQTT._inject_entities() and GATEWAY_ATTRIBUTE_TABLE lookups."""