From 429be4df63b5d5cd2dd4f3c831e7e04443753c83 Mon Sep 17 00:00:00 2001 From: Joo Aun Saw Date: Wed, 28 Oct 2026 13:31:08 +1100 Subject: [PATCH 1/3] update doc --- src/powersensor_local/devices.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/powersensor_local/devices.py b/src/powersensor_local/devices.py index 0701de6..f46b5f8 100644 --- a/src/powersensor_local/devices.py +++ b/src/powersensor_local/devices.py @@ -127,7 +127,7 @@ def unsubscribe(self, mac: str) -> None: async def stop(self) -> None: """Stop event streaming and disconnect from all devices. - To restart, call start() (legacy) or add_plug() (zeroconf) again. + To restart, call start() again. """ for plug in list(self._plug_apis.values()): await plug.disconnect() From 2d1bb2e0cdf4d833aa2fde8b28598be6877369f1 Mon Sep 17 00:00:00 2001 From: Joo Aun Saw Date: Wed, 28 Oct 2026 13:31:22 +1100 Subject: [PATCH 2/3] bump version to 2.4.1rc3 --- src/powersensor_local/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/powersensor_local/__init__.py b/src/powersensor_local/__init__.py index 57897dd..1a7af1a 100644 --- a/src/powersensor_local/__init__.py +++ b/src/powersensor_local/__init__.py @@ -69,7 +69,7 @@ 'Message', 'CommonEventsDocs', ] -__version__ = "2.4.1rc2" +__version__ = "2.4.1rc3" from .devices import ( AsyncEventCallback, PowersensorDevices, From 983acb0c4cf7b6b1b1d294abb2db97b18a3a5b81 Mon Sep 17 00:00:00 2001 From: Joo Aun Saw Date: Wed, 28 Oct 2026 14:10:56 +1100 Subject: [PATCH 3/3] fix: missing sensor announcement causing sensor data to be ignored --- src/powersensor_local/devices.py | 15 ++++++++++++ src/powersensor_local/plug_api.py | 8 +++++++ tests/test_devices.py | 39 ++++++++++++++++++++++++++++++- 3 files changed, 61 insertions(+), 1 deletion(-) diff --git a/src/powersensor_local/devices.py b/src/powersensor_local/devices.py index f46b5f8..7d1e369 100644 --- a/src/powersensor_local/devices.py +++ b/src/powersensor_local/devices.py @@ -219,6 +219,21 @@ async def _add_device(self, mac: str, typ: str) -> None: async def _remove_device(self, mac: str) -> None: if mac in self._devices: self._devices.pop(mac) + # A sensor is announced only by now_relaying_for, which a PlugApi + # synthesizes for just the first message it relays for that sensor, + # and remembers having done so for as long as the PlugApi exists. + # A sensor that expires while its plug's PlugApi lives on, because + # its battery went flat, it was out of range of every plug, or its + # plug was off the network without its mDNS record being removed, + # would therefore never be announced again. Its data would resume + # through the same plug, but with no now_relaying_for to re-add it, + # _emit_if_subscribed would drop every event, and the caller would + # never hear from it again until it restarted the library. So make + # every plug forget it: whichever plug next relays for the sensor + # synthesizes now_relaying_for again, and it is announced as if + # seen for the first time, role included. + for api in self._plug_apis.values(): + api.forget_relayed(mac) if self._event_cb is not None: await self._event_cb({ 'event': 'device_lost', diff --git a/src/powersensor_local/plug_api.py b/src/powersensor_local/plug_api.py index fcfac86..8d65604 100644 --- a/src/powersensor_local/plug_api.py +++ b/src/powersensor_local/plug_api.py @@ -67,6 +67,14 @@ async def disconnect(self) -> None: """Disconnects from the plug and stops further connection attempts.""" await self._listener.disconnect() + def forget_relayed(self, mac: str) -> None: + """Forget having relayed messages for the given device. + + The next message relayed for it synthesizes 'now_relaying_for' again, + as if this plug had never relayed for it before. + """ + self._seen.discard(mac) + async def _on_message(self, _: str, message: Message) -> None: """Translates the raw message and emits the resulting messages, if any. diff --git a/tests/test_devices.py b/tests/test_devices.py index 9169940..a0384e5 100644 --- a/tests/test_devices.py +++ b/tests/test_devices.py @@ -4,12 +4,26 @@ from powersensor_local import devices as devices_module from powersensor_local.plug_api import PlugApi -from powersensor_local.xlatemsg import Event +from powersensor_local.xlatemsg import Event, Message from powersensor_local.zeroconf_devices import PowersensorZeroconfDevices PLUG = 'aabbccddeeff' SENSOR = '112233445566' +SENSOR_MESSAGE: Message = { + 'starttime': 1772164705, 'raw_rssi': -91, 'device': 'sensor', 'rssi': -89.0397534995969, + 'type': 'instant_power', 'summation_start': 1769972064, 'batteryMicrovolt': 3803104, + 'mac': SENSOR, 'duration': 30, 'role': 'house-net', 'power': 992, 'unit': 'w', + 'summation': -281230401, +} +# The events a plug relaying SENSOR_MESSAGE emits for it. +SENSOR_EVENTS = [ + ('average_power', SENSOR), + ('summation_energy', SENSOR), + ('battery_level', SENSOR), + ('radio_signal_quality', SENSOR), +] + async def with_plug(events: list[Event]) -> PowersensorZeroconfDevices: """Return devices with one plug discovered, recording every event. @@ -53,6 +67,29 @@ async def test_expired_plug_is_announced_again_when_its_data_resumes() -> None: ] +@pytest.mark.asyncio +async def test_expired_sensor_is_announced_again_when_its_data_resumes() -> None: + events: list[Event] = [] + with patch.object(PlugApi, 'connect'): + devices = await with_plug(events) + plug_api = devices._plug_apis[PLUG] + await plug_api._on_message('message', SENSOR_MESSAGE) + await expire_all(devices) + # The sensor's data resumes through the same PlugApi, which synthesized + # now_relaying_for for it before it expired and must do so again. + await plug_api._on_message('message', SENSOR_MESSAGE) + await devices.stop() + assert [(e['event'], e['mac']) for e in events] == [ + ('device_found', PLUG), + ('device_found', SENSOR), + *SENSOR_EVENTS, + ('device_lost', PLUG), + ('device_lost', SENSOR), + ('device_found', SENSOR), + *SENSOR_EVENTS, + ] + + @pytest.mark.asyncio async def test_data_for_an_unknown_device_is_not_announced() -> None: events: list[Event] = []