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
55 changes: 34 additions & 21 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,14 @@ All notable changes to this project will be documented in this file.

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).

## [2.5.2] - 04/2026

### Fixed

- **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
building the next snapshot.
- **Snapshot cache invalidated on reboot** — the snapshot cache is now discarded when a reboot is detected, forcing a full rebuild from fresh data.

## [2.5.1] - 04/2026

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

## Version History Summary

| Version | Date | Transport | Summary |
| ---------- | ------- | ---------- | ---------------------------------------------------------------------------------- |
| **2.4.0** | 03/2026 | MQTT/Homie | proximityProven, injected HTTP client, executor file I/O, type alias, test cleanup |
| **2.3.2** | 03/2026 | MQTT/Homie | FQDN management endpoints |
| **2.3.1** | 03/2026 | MQTT/Homie | MQTT connection errors wrapped as SpanPanelConnectionError |
| **2.3.0** | 03/2026 | MQTT/Homie | Simulation engine removed |
| **2.2.4** | 03/2026 | MQTT/Homie | Negative zero fix on idle circuits |
| **2.2.3** | 03/2026 | MQTT/Homie | Panel size from Homie schema; `panel_size` always populated on snapshot |
| **2.0.2** | 03/2026 | MQTT/Homie | EVSE (EV charger) snapshot model, Homie parsing, simulation support |
| **2.0.1** | 03/2026 | MQTT/Homie | Full BESS metadata parsing, README documentation |
| **2.0.0** | 02/2026 | MQTT/Homie | Ground-up rewrite: MQTT-only, protocol-based API, real-time push, PV/BESS metadata |
| **1.1.14** | 12/2025 | REST | Keep-Alive and RemoteProtocolError handling |
| **1.1.9** | 9/2025 | REST | Simulation sign corrections |
| **1.1.8** | 2024 | REST | Simulation power sign fix |
| **1.1.6** | 2024 | REST | YAML simulation API, battery simulation |
| **1.1.5** | 2024 | REST | Simulation edge cases |
| **1.1.4** | 2024 | REST | Formatting and linting |
| **1.1.3** | 2024 | REST | Test and lint fixes |
| **1.1.2** | 2024 | REST | Simulation mode added |
| **1.1.1** | 2024 | REST | Dependency updates |
| **1.1.0** | 2024 | REST | Initial release |
| Version | Date | Transport | Summary |
| ---------- | ------- | ---------- | ------------------------------------------------------------------------------------------------- |
| **2.5.2** | 04/2026 | MQTT/Homie | Clear stale property values on panel reboot; fast reboot detection; cache generation invalidation |
| **2.5.1** | 04/2026 | MQTT/Homie | Replace assert with RuntimeError; fix bandit pre-commit hook |
| **2.5.0** | 03/2026 | MQTT/Homie | Homie accumulator layer, $target support, dirty-node snapshot caching |
| **2.4.2** | 03/2026 | MQTT/Homie | SSL context creation moved to executor |
| **2.4.1** | 03/2026 | MQTT/Homie | License metadata, loosened httpx constraint |
| **2.4.0** | 03/2026 | MQTT/Homie | proximityProven, injected HTTP client, executor file I/O, type alias, test cleanup |
| **2.3.2** | 03/2026 | MQTT/Homie | FQDN management endpoints |
| **2.3.1** | 03/2026 | MQTT/Homie | MQTT connection errors wrapped as SpanPanelConnectionError |
| **2.3.0** | 03/2026 | MQTT/Homie | Simulation engine removed |
| **2.2.4** | 03/2026 | MQTT/Homie | Negative zero fix on idle circuits |
| **2.2.3** | 03/2026 | MQTT/Homie | Panel size from Homie schema; `panel_size` always populated on snapshot |
| **2.0.2** | 03/2026 | MQTT/Homie | EVSE (EV charger) snapshot model, Homie parsing, simulation support |
| **2.0.1** | 03/2026 | MQTT/Homie | Full BESS metadata parsing, README documentation |
| **2.0.0** | 02/2026 | MQTT/Homie | Ground-up rewrite: MQTT-only, protocol-based API, real-time push, PV/BESS metadata |
| **1.1.14** | 12/2025 | REST | Keep-Alive and RemoteProtocolError handling |
| **1.1.9** | 9/2025 | REST | Simulation sign corrections |
| **1.1.8** | 2024 | REST | Simulation power sign fix |
| **1.1.6** | 2024 | REST | YAML simulation API, battery simulation |
| **1.1.5** | 2024 | REST | Simulation edge cases |
| **1.1.4** | 2024 | REST | Formatting and linting |
| **1.1.3** | 2024 | REST | Test and lint fixes |
| **1.1.2** | 2024 | REST | Simulation mode added |
| **1.1.1** | 2024 | REST | Dependency updates |
| **1.1.0** | 2024 | REST | Initial release |
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "span-panel-api"
version = "2.5.1"
version = "2.5.2"
description = "A client library for SPAN Panel API"
authors = [
{name = "SpanPanel"}
Expand Down
42 changes: 38 additions & 4 deletions src/span_panel_api/mqtt/accumulator.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,10 @@ def __init__(self, serial_number: str) -> None:
# Node type mapping from $description
self._node_types: dict[str, str] = {}

# Generation counter — incremented when $description clears property
# values so consumers can invalidate caches built from stale data.
self._generation: int = 0

# Dirty tracking
self._dirty_nodes: set[str] = set()

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

@property
def generation(self) -> int:
"""Counter incremented on the initial $description and after lifecycle resets."""
return self._generation

def is_ready(self) -> bool:
"""True when lifecycle is READY."""
return self._lifecycle == HomieLifecycle.READY
Expand Down Expand Up @@ -191,10 +200,17 @@ def _handle_state(self, payload: str) -> None:
self._received_state_ready = False
self._received_description = False
else:
# init, sleeping, alert, etc. — connected but not ready
if self._lifecycle == HomieLifecycle.DISCONNECTED:
self._lifecycle = HomieLifecycle.CONNECTED
# init, sleeping, alert, etc. — connected but not ready.
# Always move out of READY/DESCRIPTION_RECEIVED into a
# non-ready connected lifecycle state.
#
# Reset _received_description so that the upcoming $description
# triggers a property clear. This covers fast reboots where
# the broker's LWT ($state=disconnected) may not reach us
# before the panel publishes $state=init.
self._lifecycle = HomieLifecycle.CONNECTED
self._received_state_ready = False
self._received_description = False

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

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

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

self._received_description = True
self._node_types.clear()

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

_LOGGER.debug("Parsed $description with %d nodes", len(self._node_types))
_LOGGER.debug(
"Parsed $description with %d nodes (generation %d)",
len(self._node_types),
self._generation,
)

# Lifecycle transition
if self._received_state_ready:
Expand Down
7 changes: 7 additions & 0 deletions src/span_panel_api/mqtt/homie.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ def __init__(self, accumulator: HomiePropertyAccumulator, panel_size: int) -> No
self._acc = accumulator
self._panel_size = panel_size
self._cached_snapshot: SpanPanelSnapshot | None = None
self._cache_generation: int = 0

# -- Delegation to accumulator -------------------------------------------
# These thin wrappers allow SpanMqttClient (and legacy test code) to
Expand Down Expand Up @@ -118,6 +119,12 @@ def build_snapshot(self) -> SpanPanelSnapshot:

Must be called after accumulator is_ready() returns True.
"""
# Invalidate cache when the accumulator generation advances
# (panel reboot cleared all property values).
if self._acc.generation != self._cache_generation:
self._cached_snapshot = None
self._cache_generation = self._acc.generation

dirty = self._acc.dirty_node_ids()

if not dirty and self._cached_snapshot is not None:
Expand Down
125 changes: 125 additions & 0 deletions tests/test_accumulator.py
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,131 @@ def test_init_state_moves_to_connected(self):
assert acc.lifecycle == HomieLifecycle.CONNECTED


# ---------------------------------------------------------------------------
# Panel reboot: $description clears stale property values
# ---------------------------------------------------------------------------


class TestDescriptionClearsProperties:
"""Verify that a panel reboot (disconnected -> $description) clears stale data.

The clear only happens when _received_description is False, which requires
a $state=disconnected (or lost) to have reset the lifecycle first. A
re-delivered retained $description on a pure network reconnect does NOT
clear, because _received_description is still True from the previous session.
"""

def _simulate_reboot(self, acc: HomiePropertyAccumulator) -> None:
"""Simulate the panel reboot lifecycle transition."""
acc.handle_message(f"{PREFIX}/$state", "disconnected")
acc.handle_message(f"{PREFIX}/$description", SIMPLE_DESC)
acc.handle_message(f"{PREFIX}/$state", "ready")

def test_description_clears_property_values_on_reboot(self):
"""A panel reboot must clear property values from the previous lifecycle."""
acc = HomiePropertyAccumulator(SERIAL)
_make_ready(acc)
acc.handle_message(f"{PREFIX}/circuit-1/exported-energy", "1000")
assert acc.get_prop("circuit-1", "exported-energy") == "1000"

# Panel reboots — $state=disconnected resets lifecycle, then $description clears
self._simulate_reboot(acc)
assert acc.get_prop("circuit-1", "exported-energy") == ""

def test_description_clears_timestamps_on_reboot(self):
"""Timestamps must also be cleared so stale timing data doesn't persist."""
acc = HomiePropertyAccumulator(SERIAL)
_make_ready(acc)
acc.handle_message(f"{PREFIX}/circuit-1/exported-energy", "1000")
assert acc.get_timestamp("circuit-1", "exported-energy") > 0

self._simulate_reboot(acc)
assert acc.get_timestamp("circuit-1", "exported-energy") == 0

def test_description_clears_target_values_on_reboot(self):
"""Target values must also be cleared on panel reboot."""
acc = HomiePropertyAccumulator(SERIAL)
_make_ready(acc)
acc.handle_message(f"{PREFIX}/circuit-1/relay/$target", "OPEN")
assert acc.get_target("circuit-1", "relay") == "OPEN"

self._simulate_reboot(acc)
assert acc.get_target("circuit-1", "relay") is None

def test_reboot_increments_generation(self):
"""Each panel reboot must advance the generation counter."""
acc = HomiePropertyAccumulator(SERIAL)
assert acc.generation == 0

# First boot
acc.handle_message(f"{PREFIX}/$description", SIMPLE_DESC)
assert acc.generation == 1

# Reboot
acc.handle_message(f"{PREFIX}/$state", "disconnected")
acc.handle_message(f"{PREFIX}/$description", SIMPLE_DESC)
assert acc.generation == 2

def test_retained_redescription_does_not_clear(self):
"""A re-delivered retained $description without a disconnect must NOT clear."""
acc = HomiePropertyAccumulator(SERIAL)
_make_ready(acc)
acc.handle_message(f"{PREFIX}/circuit-1/exported-energy", "1000")

# Simulate network reconnect — $description re-delivered without $state=disconnected
acc.handle_message(f"{PREFIX}/$description", SIMPLE_DESC)

# Property values should be preserved
assert acc.get_prop("circuit-1", "exported-energy") == "1000"
assert acc.generation == 1 # still 1 from initial boot, no increment on re-delivery

def test_fresh_properties_available_after_reboot(self):
"""Post-reboot properties should be stored normally after clear."""
acc = HomiePropertyAccumulator(SERIAL)
_make_ready(acc)
acc.handle_message(f"{PREFIX}/circuit-1/exported-energy", "1000")

self._simulate_reboot(acc)

# Fresh post-reboot value
acc.handle_message(f"{PREFIX}/circuit-1/exported-energy", "50")
assert acc.get_prop("circuit-1", "exported-energy") == "50"

def test_fresh_target_values_available_after_reboot(self):
"""Post-reboot target values should be stored normally after clear."""
acc = HomiePropertyAccumulator(SERIAL)
_make_ready(acc)
acc.handle_message(f"{PREFIX}/circuit-1/relay/$target", "OPEN")
assert acc.get_target("circuit-1", "relay") == "OPEN"

self._simulate_reboot(acc)

# Fresh post-reboot target value
acc.handle_message(f"{PREFIX}/circuit-1/relay/$target", "CLOSED")
assert acc.get_target("circuit-1", "relay") == "CLOSED"

def test_fast_reboot_without_lwt_still_clears(self):
"""Panel reboots so fast that $state=disconnected (LWT) is skipped.

The panel goes directly from ready -> init -> description -> ready.
$state=init must reset _received_description so the subsequent
$description triggers the property clear.
"""
acc = HomiePropertyAccumulator(SERIAL)
_make_ready(acc)
acc.handle_message(f"{PREFIX}/circuit-1/exported-energy", "1000")
gen_before = acc.generation

# Fast reboot: no $state=disconnected, straight to init
acc.handle_message(f"{PREFIX}/$state", "init")
acc.handle_message(f"{PREFIX}/$description", SIMPLE_DESC)
acc.handle_message(f"{PREFIX}/$state", "ready")

# Property values should be cleared
assert acc.get_prop("circuit-1", "exported-energy") == ""
assert acc.generation == gen_before + 1


# ---------------------------------------------------------------------------
# Lifecycle: invalid JSON description
# ---------------------------------------------------------------------------
Expand Down
23 changes: 23 additions & 0 deletions tests/test_mqtt_homie.py
Original file line number Diff line number Diff line change
Expand Up @@ -905,6 +905,29 @@ def test_description_change_triggers_full_rebuild(self):
snap2 = consumer.build_snapshot()
assert snap2 is not snap1

def test_description_invalidates_cache_via_generation(self):
"""Panel reboot ($description) must clear cached snapshot so stale data is not reused."""
acc, consumer = _build_ready_consumer()
node = "aabbccdd-1122-3344-5566-778899001122"

# Pre-reboot: circuit has energy = 1000
acc.handle_message(f"{PREFIX}/{node}/exported-energy", "1000")
snap_pre = consumer.build_snapshot()
circuit_id = "aabbccdd112233445566778899001122"
assert snap_pre.circuits[circuit_id].consumed_energy_wh == 1000.0

# Panel reboots — $state=disconnected resets lifecycle, $description clears values
acc.handle_message(f"{PREFIX}/$state", "disconnected")
acc.handle_message(f"{PREFIX}/$description", _make_description(_full_description()))
acc.handle_message(f"{PREFIX}/$state", "ready")

# Post-reboot: circuit publishes reset energy = 50
acc.handle_message(f"{PREFIX}/{node}/exported-energy", "50")
snap_post = consumer.build_snapshot()

# Must reflect post-reboot value, not stale pre-reboot cache
assert snap_post.circuits[circuit_id].consumed_energy_wh == 50.0


# ---------------------------------------------------------------------------
# HomieDeviceConsumer — property callbacks
Expand Down
Loading