Skip to content

Commit ea22c9f

Browse files
authored
Merge pull request #130 from SpanPanel/release/v2.5.2
fix: clear stale property values on panel reboot to prevent mixed-gen snapshots
2 parents 925a0d2 + 9db07ee commit ea22c9f

7 files changed

Lines changed: 229 additions & 45 deletions

File tree

CHANGELOG.md

Lines changed: 34 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,14 @@ All notable changes to this project will be documented in this file.
44

55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
66

7+
## [2.5.2] - 04/2026
8+
9+
### Fixed
10+
11+
- **Clear stale property values on panel reboot** — after a panel reboot, snapshots could mix pre-reboot and post-reboot data. The accumulator now detects reboots (including fast reboots where the broker LWT is skipped) and clears stale state before
12+
building the next snapshot.
13+
- **Snapshot cache invalidated on reboot** — the snapshot cache is now discarded when a reboot is detected, forcing a full rebuild from fresh data.
14+
715
## [2.5.1] - 04/2026
816

917
### Fixed
@@ -326,24 +334,29 @@ Package versions prior to 2.0.0 depend on the SPAN v1 REST API. SPAN will sunset
326334

327335
## Version History Summary
328336

329-
| Version | Date | Transport | Summary |
330-
| ---------- | ------- | ---------- | ---------------------------------------------------------------------------------- |
331-
| **2.4.0** | 03/2026 | MQTT/Homie | proximityProven, injected HTTP client, executor file I/O, type alias, test cleanup |
332-
| **2.3.2** | 03/2026 | MQTT/Homie | FQDN management endpoints |
333-
| **2.3.1** | 03/2026 | MQTT/Homie | MQTT connection errors wrapped as SpanPanelConnectionError |
334-
| **2.3.0** | 03/2026 | MQTT/Homie | Simulation engine removed |
335-
| **2.2.4** | 03/2026 | MQTT/Homie | Negative zero fix on idle circuits |
336-
| **2.2.3** | 03/2026 | MQTT/Homie | Panel size from Homie schema; `panel_size` always populated on snapshot |
337-
| **2.0.2** | 03/2026 | MQTT/Homie | EVSE (EV charger) snapshot model, Homie parsing, simulation support |
338-
| **2.0.1** | 03/2026 | MQTT/Homie | Full BESS metadata parsing, README documentation |
339-
| **2.0.0** | 02/2026 | MQTT/Homie | Ground-up rewrite: MQTT-only, protocol-based API, real-time push, PV/BESS metadata |
340-
| **1.1.14** | 12/2025 | REST | Keep-Alive and RemoteProtocolError handling |
341-
| **1.1.9** | 9/2025 | REST | Simulation sign corrections |
342-
| **1.1.8** | 2024 | REST | Simulation power sign fix |
343-
| **1.1.6** | 2024 | REST | YAML simulation API, battery simulation |
344-
| **1.1.5** | 2024 | REST | Simulation edge cases |
345-
| **1.1.4** | 2024 | REST | Formatting and linting |
346-
| **1.1.3** | 2024 | REST | Test and lint fixes |
347-
| **1.1.2** | 2024 | REST | Simulation mode added |
348-
| **1.1.1** | 2024 | REST | Dependency updates |
349-
| **1.1.0** | 2024 | REST | Initial release |
337+
| Version | Date | Transport | Summary |
338+
| ---------- | ------- | ---------- | ------------------------------------------------------------------------------------------------- |
339+
| **2.5.2** | 04/2026 | MQTT/Homie | Clear stale property values on panel reboot; fast reboot detection; cache generation invalidation |
340+
| **2.5.1** | 04/2026 | MQTT/Homie | Replace assert with RuntimeError; fix bandit pre-commit hook |
341+
| **2.5.0** | 03/2026 | MQTT/Homie | Homie accumulator layer, $target support, dirty-node snapshot caching |
342+
| **2.4.2** | 03/2026 | MQTT/Homie | SSL context creation moved to executor |
343+
| **2.4.1** | 03/2026 | MQTT/Homie | License metadata, loosened httpx constraint |
344+
| **2.4.0** | 03/2026 | MQTT/Homie | proximityProven, injected HTTP client, executor file I/O, type alias, test cleanup |
345+
| **2.3.2** | 03/2026 | MQTT/Homie | FQDN management endpoints |
346+
| **2.3.1** | 03/2026 | MQTT/Homie | MQTT connection errors wrapped as SpanPanelConnectionError |
347+
| **2.3.0** | 03/2026 | MQTT/Homie | Simulation engine removed |
348+
| **2.2.4** | 03/2026 | MQTT/Homie | Negative zero fix on idle circuits |
349+
| **2.2.3** | 03/2026 | MQTT/Homie | Panel size from Homie schema; `panel_size` always populated on snapshot |
350+
| **2.0.2** | 03/2026 | MQTT/Homie | EVSE (EV charger) snapshot model, Homie parsing, simulation support |
351+
| **2.0.1** | 03/2026 | MQTT/Homie | Full BESS metadata parsing, README documentation |
352+
| **2.0.0** | 02/2026 | MQTT/Homie | Ground-up rewrite: MQTT-only, protocol-based API, real-time push, PV/BESS metadata |
353+
| **1.1.14** | 12/2025 | REST | Keep-Alive and RemoteProtocolError handling |
354+
| **1.1.9** | 9/2025 | REST | Simulation sign corrections |
355+
| **1.1.8** | 2024 | REST | Simulation power sign fix |
356+
| **1.1.6** | 2024 | REST | YAML simulation API, battery simulation |
357+
| **1.1.5** | 2024 | REST | Simulation edge cases |
358+
| **1.1.4** | 2024 | REST | Formatting and linting |
359+
| **1.1.3** | 2024 | REST | Test and lint fixes |
360+
| **1.1.2** | 2024 | REST | Simulation mode added |
361+
| **1.1.1** | 2024 | REST | Dependency updates |
362+
| **1.1.0** | 2024 | REST | Initial release |

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "span-panel-api"
3-
version = "2.5.1"
3+
version = "2.5.2"
44
description = "A client library for SPAN Panel API"
55
authors = [
66
{name = "SpanPanel"}

src/span_panel_api/mqtt/accumulator.py

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,10 @@ def __init__(self, serial_number: str) -> None:
5656
# Node type mapping from $description
5757
self._node_types: dict[str, str] = {}
5858

59+
# Generation counter — incremented when $description clears property
60+
# values so consumers can invalidate caches built from stale data.
61+
self._generation: int = 0
62+
5963
# Dirty tracking
6064
self._dirty_nodes: set[str] = set()
6165

@@ -79,6 +83,11 @@ def ready_since(self) -> float:
7983
"""Monotonic timestamp of the last READY transition, 0.0 if never ready."""
8084
return self._ready_since
8185

86+
@property
87+
def generation(self) -> int:
88+
"""Counter incremented on the initial $description and after lifecycle resets."""
89+
return self._generation
90+
8291
def is_ready(self) -> bool:
8392
"""True when lifecycle is READY."""
8493
return self._lifecycle == HomieLifecycle.READY
@@ -191,10 +200,17 @@ def _handle_state(self, payload: str) -> None:
191200
self._received_state_ready = False
192201
self._received_description = False
193202
else:
194-
# init, sleeping, alert, etc. — connected but not ready
195-
if self._lifecycle == HomieLifecycle.DISCONNECTED:
196-
self._lifecycle = HomieLifecycle.CONNECTED
203+
# init, sleeping, alert, etc. — connected but not ready.
204+
# Always move out of READY/DESCRIPTION_RECEIVED into a
205+
# non-ready connected lifecycle state.
206+
#
207+
# Reset _received_description so that the upcoming $description
208+
# triggers a property clear. This covers fast reboots where
209+
# the broker's LWT ($state=disconnected) may not reach us
210+
# before the panel publishes $state=init.
211+
self._lifecycle = HomieLifecycle.CONNECTED
197212
self._received_state_ready = False
213+
self._received_description = False
198214

199215
_LOGGER.debug("Homie $state: %s → lifecycle=%s", payload, self._lifecycle.value)
200216

@@ -206,6 +222,20 @@ def _handle_description(self, payload: str) -> None:
206222
_LOGGER.warning("Invalid $description JSON")
207223
return
208224

225+
# _handle_state() already reset _received_description to False due to
226+
# a state change that starts a new panel lifecycle, including
227+
# $state=disconnected/lost and other non-ready states such as init.
228+
# This means the panel rebooted while we were connected. On a pure
229+
# MQTT reconnect (no panel reboot), _received_description is still
230+
# True from the previous session so we skip the clear — the retained
231+
# property messages will carry the correct (unchanged) values.
232+
if not self._received_description:
233+
self._property_values.clear()
234+
self._property_timestamps.clear()
235+
self._target_values.clear()
236+
self._generation += 1
237+
_LOGGER.debug("Cleared stale property values (generation %d)", self._generation)
238+
209239
self._received_description = True
210240
self._node_types.clear()
211241

@@ -220,7 +250,11 @@ def _handle_description(self, payload: str) -> None:
220250
# Mark all known nodes dirty
221251
self._dirty_nodes.update(self._node_types.keys())
222252

223-
_LOGGER.debug("Parsed $description with %d nodes", len(self._node_types))
253+
_LOGGER.debug(
254+
"Parsed $description with %d nodes (generation %d)",
255+
len(self._node_types),
256+
self._generation,
257+
)
224258

225259
# Lifecycle transition
226260
if self._received_state_ready:

src/span_panel_api/mqtt/homie.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ def __init__(self, accumulator: HomiePropertyAccumulator, panel_size: int) -> No
6464
self._acc = accumulator
6565
self._panel_size = panel_size
6666
self._cached_snapshot: SpanPanelSnapshot | None = None
67+
self._cache_generation: int = 0
6768

6869
# -- Delegation to accumulator -------------------------------------------
6970
# These thin wrappers allow SpanMqttClient (and legacy test code) to
@@ -118,6 +119,12 @@ def build_snapshot(self) -> SpanPanelSnapshot:
118119
119120
Must be called after accumulator is_ready() returns True.
120121
"""
122+
# Invalidate cache when the accumulator generation advances
123+
# (panel reboot cleared all property values).
124+
if self._acc.generation != self._cache_generation:
125+
self._cached_snapshot = None
126+
self._cache_generation = self._acc.generation
127+
121128
dirty = self._acc.dirty_node_ids()
122129

123130
if not dirty and self._cached_snapshot is not None:

tests/test_accumulator.py

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,131 @@ def test_init_state_moves_to_connected(self):
163163
assert acc.lifecycle == HomieLifecycle.CONNECTED
164164

165165

166+
# ---------------------------------------------------------------------------
167+
# Panel reboot: $description clears stale property values
168+
# ---------------------------------------------------------------------------
169+
170+
171+
class TestDescriptionClearsProperties:
172+
"""Verify that a panel reboot (disconnected -> $description) clears stale data.
173+
174+
The clear only happens when _received_description is False, which requires
175+
a $state=disconnected (or lost) to have reset the lifecycle first. A
176+
re-delivered retained $description on a pure network reconnect does NOT
177+
clear, because _received_description is still True from the previous session.
178+
"""
179+
180+
def _simulate_reboot(self, acc: HomiePropertyAccumulator) -> None:
181+
"""Simulate the panel reboot lifecycle transition."""
182+
acc.handle_message(f"{PREFIX}/$state", "disconnected")
183+
acc.handle_message(f"{PREFIX}/$description", SIMPLE_DESC)
184+
acc.handle_message(f"{PREFIX}/$state", "ready")
185+
186+
def test_description_clears_property_values_on_reboot(self):
187+
"""A panel reboot must clear property values from the previous lifecycle."""
188+
acc = HomiePropertyAccumulator(SERIAL)
189+
_make_ready(acc)
190+
acc.handle_message(f"{PREFIX}/circuit-1/exported-energy", "1000")
191+
assert acc.get_prop("circuit-1", "exported-energy") == "1000"
192+
193+
# Panel reboots — $state=disconnected resets lifecycle, then $description clears
194+
self._simulate_reboot(acc)
195+
assert acc.get_prop("circuit-1", "exported-energy") == ""
196+
197+
def test_description_clears_timestamps_on_reboot(self):
198+
"""Timestamps must also be cleared so stale timing data doesn't persist."""
199+
acc = HomiePropertyAccumulator(SERIAL)
200+
_make_ready(acc)
201+
acc.handle_message(f"{PREFIX}/circuit-1/exported-energy", "1000")
202+
assert acc.get_timestamp("circuit-1", "exported-energy") > 0
203+
204+
self._simulate_reboot(acc)
205+
assert acc.get_timestamp("circuit-1", "exported-energy") == 0
206+
207+
def test_description_clears_target_values_on_reboot(self):
208+
"""Target values must also be cleared on panel reboot."""
209+
acc = HomiePropertyAccumulator(SERIAL)
210+
_make_ready(acc)
211+
acc.handle_message(f"{PREFIX}/circuit-1/relay/$target", "OPEN")
212+
assert acc.get_target("circuit-1", "relay") == "OPEN"
213+
214+
self._simulate_reboot(acc)
215+
assert acc.get_target("circuit-1", "relay") is None
216+
217+
def test_reboot_increments_generation(self):
218+
"""Each panel reboot must advance the generation counter."""
219+
acc = HomiePropertyAccumulator(SERIAL)
220+
assert acc.generation == 0
221+
222+
# First boot
223+
acc.handle_message(f"{PREFIX}/$description", SIMPLE_DESC)
224+
assert acc.generation == 1
225+
226+
# Reboot
227+
acc.handle_message(f"{PREFIX}/$state", "disconnected")
228+
acc.handle_message(f"{PREFIX}/$description", SIMPLE_DESC)
229+
assert acc.generation == 2
230+
231+
def test_retained_redescription_does_not_clear(self):
232+
"""A re-delivered retained $description without a disconnect must NOT clear."""
233+
acc = HomiePropertyAccumulator(SERIAL)
234+
_make_ready(acc)
235+
acc.handle_message(f"{PREFIX}/circuit-1/exported-energy", "1000")
236+
237+
# Simulate network reconnect — $description re-delivered without $state=disconnected
238+
acc.handle_message(f"{PREFIX}/$description", SIMPLE_DESC)
239+
240+
# Property values should be preserved
241+
assert acc.get_prop("circuit-1", "exported-energy") == "1000"
242+
assert acc.generation == 1 # still 1 from initial boot, no increment on re-delivery
243+
244+
def test_fresh_properties_available_after_reboot(self):
245+
"""Post-reboot properties should be stored normally after clear."""
246+
acc = HomiePropertyAccumulator(SERIAL)
247+
_make_ready(acc)
248+
acc.handle_message(f"{PREFIX}/circuit-1/exported-energy", "1000")
249+
250+
self._simulate_reboot(acc)
251+
252+
# Fresh post-reboot value
253+
acc.handle_message(f"{PREFIX}/circuit-1/exported-energy", "50")
254+
assert acc.get_prop("circuit-1", "exported-energy") == "50"
255+
256+
def test_fresh_target_values_available_after_reboot(self):
257+
"""Post-reboot target values should be stored normally after clear."""
258+
acc = HomiePropertyAccumulator(SERIAL)
259+
_make_ready(acc)
260+
acc.handle_message(f"{PREFIX}/circuit-1/relay/$target", "OPEN")
261+
assert acc.get_target("circuit-1", "relay") == "OPEN"
262+
263+
self._simulate_reboot(acc)
264+
265+
# Fresh post-reboot target value
266+
acc.handle_message(f"{PREFIX}/circuit-1/relay/$target", "CLOSED")
267+
assert acc.get_target("circuit-1", "relay") == "CLOSED"
268+
269+
def test_fast_reboot_without_lwt_still_clears(self):
270+
"""Panel reboots so fast that $state=disconnected (LWT) is skipped.
271+
272+
The panel goes directly from ready -> init -> description -> ready.
273+
$state=init must reset _received_description so the subsequent
274+
$description triggers the property clear.
275+
"""
276+
acc = HomiePropertyAccumulator(SERIAL)
277+
_make_ready(acc)
278+
acc.handle_message(f"{PREFIX}/circuit-1/exported-energy", "1000")
279+
gen_before = acc.generation
280+
281+
# Fast reboot: no $state=disconnected, straight to init
282+
acc.handle_message(f"{PREFIX}/$state", "init")
283+
acc.handle_message(f"{PREFIX}/$description", SIMPLE_DESC)
284+
acc.handle_message(f"{PREFIX}/$state", "ready")
285+
286+
# Property values should be cleared
287+
assert acc.get_prop("circuit-1", "exported-energy") == ""
288+
assert acc.generation == gen_before + 1
289+
290+
166291
# ---------------------------------------------------------------------------
167292
# Lifecycle: invalid JSON description
168293
# ---------------------------------------------------------------------------

tests/test_mqtt_homie.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -905,6 +905,29 @@ def test_description_change_triggers_full_rebuild(self):
905905
snap2 = consumer.build_snapshot()
906906
assert snap2 is not snap1
907907

908+
def test_description_invalidates_cache_via_generation(self):
909+
"""Panel reboot ($description) must clear cached snapshot so stale data is not reused."""
910+
acc, consumer = _build_ready_consumer()
911+
node = "aabbccdd-1122-3344-5566-778899001122"
912+
913+
# Pre-reboot: circuit has energy = 1000
914+
acc.handle_message(f"{PREFIX}/{node}/exported-energy", "1000")
915+
snap_pre = consumer.build_snapshot()
916+
circuit_id = "aabbccdd112233445566778899001122"
917+
assert snap_pre.circuits[circuit_id].consumed_energy_wh == 1000.0
918+
919+
# Panel reboots — $state=disconnected resets lifecycle, $description clears values
920+
acc.handle_message(f"{PREFIX}/$state", "disconnected")
921+
acc.handle_message(f"{PREFIX}/$description", _make_description(_full_description()))
922+
acc.handle_message(f"{PREFIX}/$state", "ready")
923+
924+
# Post-reboot: circuit publishes reset energy = 50
925+
acc.handle_message(f"{PREFIX}/{node}/exported-energy", "50")
926+
snap_post = consumer.build_snapshot()
927+
928+
# Must reflect post-reboot value, not stale pre-reboot cache
929+
assert snap_post.circuits[circuit_id].consumed_energy_wh == 50.0
930+
908931

909932
# ---------------------------------------------------------------------------
910933
# HomieDeviceConsumer — property callbacks

0 commit comments

Comments
 (0)