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
1 change: 1 addition & 0 deletions .cspell/custom-dictionary-workspace.txt
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,7 @@ Mosquitto
mpan
mppt
mprn
mpxn
mqtt
mult
myenergi
Expand Down
58 changes: 45 additions & 13 deletions apps/predbat/gateway.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -965,6 +967,26 @@ 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) :]
# 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).

Expand All @@ -974,10 +996,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})")
Comment thread
springfall2008 marked this conversation as resolved.
return

Expand All @@ -991,28 +1014,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):
Expand All @@ -1030,14 +1053,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).
Expand All @@ -1057,12 +1082,17 @@ async def switch_event(self, entity_id, service):
is_on = False
elif service == "toggle":
is_on = not old_value
Comment thread
springfall2008 marked this conversation as resolved.
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 {}
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):
Expand Down Expand Up @@ -1316,5 +1346,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)
162 changes: 162 additions & 0 deletions apps/predbat/tests/test_gateway.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -196,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."""

Expand Down Expand Up @@ -705,6 +780,7 @@ def _make_gateway(self):
gw.prefix = "predbat"
gw._last_status = None
gw._auto_configured = False
gw._suffix_to_serial = {}
gw.args = {}
gw._args = {}

Expand Down Expand Up @@ -929,6 +1005,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"
Expand Down Expand Up @@ -1021,6 +1098,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."""
Expand All @@ -1032,6 +1138,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"
Expand Down Expand Up @@ -1122,6 +1229,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."""
Expand All @@ -1133,6 +1267,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"
Expand Down Expand Up @@ -1228,6 +1363,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."""
Expand Down
Loading