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
65 changes: 38 additions & 27 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,17 @@ 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
## [2.5.3] - 04/2026

### Fixed

- **Preserve property values on lifecycle reset** — the 2.5.2 property clear caused `_parse_float('')` to return `0.0` for energy counters during panel reboots or network interruptions, triggering false dip-compensation offsets in the integration that
permanently inflated energy sensor values. Removed the property/timestamp/target clearing from `_handle_description()`. Pre-reboot values now serve as safe placeholders until the panel re-publishes fresh data.
- **Snapshot cache invalidated on reboot** — the generation counter still increments on lifecycle resets, forcing consumers to discard cached snapshots and rebuild from current accumulator state.

## [2.5.2] - 04/2026 (retired)

> **Retired:** The property-clearing behavior introduced in this release caused false energy dip spikes. Superseded by 2.5.3.

### Fixed

Expand Down Expand Up @@ -334,29 +344,30 @@ 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.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 |
| Version | Date | Transport | Summary |
| ---------- | ------- | ---------- | ----------------------------------------------------------------------------------------- |
| **2.5.3** | 04/2026 | MQTT/Homie | Preserve property values on lifecycle reset; fix false energy dip spikes from 2.5.2 clear |
| **2.5.2** | 04/2026 | MQTT/Homie | _(retired)_ Clear stale property values on panel reboot; caused false energy dip spikes |
| **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.2"
version = "2.5.3"
description = "A client library for SPAN Panel API"
authors = [
{name = "SpanPanel"}
Expand Down
28 changes: 17 additions & 11 deletions src/span_panel_api/mqtt/accumulator.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,9 @@ 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.
# Generation counter — incremented whenever a new lifecycle's
# $description is accepted (including initial boot) so consumers
# can invalidate snapshot caches built from prior-lifecycle data.
self._generation: int = 0

# Dirty tracking
Expand Down Expand Up @@ -85,7 +86,7 @@ def ready_since(self) -> float:

@property
def generation(self) -> int:
"""Counter incremented on the initial $description and after lifecycle resets."""
"""Counter incremented on each new lifecycle's ``$description`` to invalidate consumer caches."""
return self._generation

def is_ready(self) -> bool:
Expand Down Expand Up @@ -225,16 +226,21 @@ def _handle_description(self, payload: str) -> None:
# _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.
# Increment the generation counter to invalidate consumer snapshot
# caches, but preserve property values — pre-reboot readings serve
# as safe placeholders until the panel re-publishes. Clearing values
# would emit 0.0 for energy counters via _parse_float(""), triggering
# false dip-compensation offsets in the integration.
#
# On a pure MQTT reconnect (no panel reboot), _received_description
# is still True from the previous session so we skip this block —
# the retained property messages 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)
_LOGGER.debug(
"Panel reboot detected (generation %d); preserving property values as placeholders",
self._generation,
)

self._received_description = True
self._node_types.clear()
Expand Down
68 changes: 37 additions & 31 deletions tests/test_accumulator.py
Original file line number Diff line number Diff line change
Expand Up @@ -164,17 +164,21 @@ def test_init_state_moves_to_connected(self):


# ---------------------------------------------------------------------------
# Panel reboot: $description clears stale property values
# Panel reboot: $description preserves property values as placeholders
# ---------------------------------------------------------------------------


class TestDescriptionClearsProperties:
"""Verify that a panel reboot (disconnected -> $description) clears stale data.
class TestDescriptionOnReboot:
"""Verify that a panel reboot preserves property values as placeholders.

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.
On lifecycle reset ($state=disconnected/lost or non-ready states like init),
_received_description is set to False. The subsequent $description increments
the generation counter (invalidating consumer snapshot caches) but does NOT
clear property values — pre-reboot values serve as safe placeholders until
the panel re-publishes fresh data.

A re-delivered retained $description on a pure network reconnect does NOT
increment generation, because _received_description is still True.
"""

def _simulate_reboot(self, acc: HomiePropertyAccumulator) -> None:
Expand All @@ -183,36 +187,37 @@ def _simulate_reboot(self, acc: HomiePropertyAccumulator) -> None:
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."""
def test_reboot_preserves_property_values(self):
"""A panel reboot must preserve property values as placeholders."""
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
# Panel reboots — $state=disconnected resets lifecycle, then $description preserves
self._simulate_reboot(acc)
assert acc.get_prop("circuit-1", "exported-energy") == ""
assert acc.get_prop("circuit-1", "exported-energy") == "1000"

def test_description_clears_timestamps_on_reboot(self):
"""Timestamps must also be cleared so stale timing data doesn't persist."""
def test_reboot_preserves_timestamps(self):
"""Timestamps must be preserved on reboot so callers can detect stale data if needed."""
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
ts_before = acc.get_timestamp("circuit-1", "exported-energy")
assert ts_before > 0

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

def test_description_clears_target_values_on_reboot(self):
"""Target values must also be cleared on panel reboot."""
def test_reboot_preserves_target_values(self):
"""Target values must also be preserved 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
assert acc.get_target("circuit-1", "relay") == "OPEN"

def test_reboot_increments_generation(self):
"""Each panel reboot must advance the generation counter."""
Expand All @@ -228,50 +233,51 @@ def test_reboot_increments_generation(self):
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."""
def test_retained_redescription_preserves_values_without_generation_bump(self):
"""A re-delivered retained $description without a disconnect must NOT bump generation."""
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
# Property values should be preserved and generation must not increment
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."""
def test_fresh_properties_overwrite_preserved_after_reboot(self):
"""Post-reboot properties should overwrite the preserved placeholder values."""
acc = HomiePropertyAccumulator(SERIAL)
_make_ready(acc)
acc.handle_message(f"{PREFIX}/circuit-1/exported-energy", "1000")

self._simulate_reboot(acc)

# Fresh post-reboot value
# Fresh post-reboot value overwrites preserved placeholder
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."""
def test_fresh_target_values_overwrite_preserved_after_reboot(self):
"""Post-reboot target values should overwrite the preserved placeholder values."""
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
# Fresh post-reboot target value overwrites preserved placeholder
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):
def test_fast_reboot_without_lwt_preserves_values(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.
$description increments the generation counter but does NOT clear
property values — pre-reboot values serve as safe placeholders.
"""
acc = HomiePropertyAccumulator(SERIAL)
_make_ready(acc)
Expand All @@ -283,8 +289,8 @@ def test_fast_reboot_without_lwt_still_clears(self):
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") == ""
# Property values should be preserved
assert acc.get_prop("circuit-1", "exported-energy") == "1000"
assert acc.generation == gen_before + 1


Expand Down
Loading