From e63324a0f163cbe5265f1f3dcb93a411b66ebf86 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Tue, 10 Mar 2026 17:01:12 -0700 Subject: [PATCH 1/6] Negate battery power to match HA sign convention The panel reports battery power from its own perspective (positive = charging), but HA energy cards expect positive = discharging. Negate the value to align with HA conventions, consistent with how PV power is already handled. Fixes #184 --- CHANGELOG.md | 6 ++++++ custom_components/span_panel/sensor_definitions.py | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bd851d3..f0fd1df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,12 @@ All notable changes to this project will be documented in this file. - **Panel size always available** — `panel_size` is now sourced from the Homie schema by the underlying `span-panel-api` Previously some users could see fewer unmapped sensors when trailing breaker positions were empty. +### Fixed + +- **Battery power sign inverted** — Battery power sensor now uses the correct sign convention. Previously, charging was reported as positive and discharging as + negative, which caused HA energy cards to show the battery discharging when it was actually charging. The panel reports power from its own perspective; the + sensor now negates the value to match HA conventions (positive = discharging), consistent with how PV power is already handled. (#184) + **Important** 2.0.1 cautions still apply — read those carefully if not on 2.0.1 BEFORE proceeding: - Requires firmware `spanos2/r202603/05` or later (v2 eBus MQTT) diff --git a/custom_components/span_panel/sensor_definitions.py b/custom_components/span_panel/sensor_definitions.py index 89bee84..3bf318b 100644 --- a/custom_components/span_panel/sensor_definitions.py +++ b/custom_components/span_panel/sensor_definitions.py @@ -483,7 +483,7 @@ class SpanPVMetadataSensorEntityDescription( state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=0, device_class=SensorDeviceClass.POWER, - value_fn=lambda s: s.power_flow_battery if s.power_flow_battery is not None else 0.0, + value_fn=lambda s: -s.power_flow_battery if s.power_flow_battery is not None else 0.0, ) # PV power sensor (conditionally created when PV is commissioned) From 832c8372fdc607aa03df04314c6f85f1dd4f9f39 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Tue, 10 Mar 2026 17:19:59 -0700 Subject: [PATCH 2/6] Normalize negative zero on negated power sensors Power sensors that negate values (PV circuits, battery, PV power) could produce IEEE 754 -0.0 when idle, causing HA to display -0W instead of 0W. All negation sites now normalize zero with `or 0.0`. Fixes #185 --- CHANGELOG.md | 6 ++++-- custom_components/span_panel/sensor_definitions.py | 8 +++++--- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f0fd1df..d867004 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,8 @@ All notable changes to this project will be documented in this file. - **Battery power sign inverted** — Battery power sensor now uses the correct sign convention. Previously, charging was reported as positive and discharging as negative, which caused HA energy cards to show the battery discharging when it was actually charging. The panel reports power from its own perspective; the sensor now negates the value to match HA conventions (positive = discharging), consistent with how PV power is already handled. (#184) +- **Idle circuits showing -0W** — Power sensors that negate values (PV circuits, battery, PV power) could produce IEEE 754 negative zero (`-0.0`) when the + circuit was idle, causing HA to display `-0W` instead of `0W`. All negation sites now normalize zero to positive. (#185) **Important** 2.0.1 cautions still apply — read those carefully if not on 2.0.1 BEFORE proceeding: @@ -22,8 +24,8 @@ All notable changes to this project will be documented in this file. ## [2.0.1] - 3/2026 -⚠️ **STOP — If your SPAN panel is not on firmware `spanos2/r202603/05` or later, do not upgrade. Ensure you are on v1.3.0 or later BEFORE upgrading to -2.0. This upgrade migrates to the SPAN official eBus API. Make a backup first.** ⚠️ +⚠️ **STOP — If your SPAN panel is not on firmware `spanos2/r202603/05` or later, do not upgrade. Ensure you are on v1.3.0 or later BEFORE upgrading to 2.0. This +upgrade migrates to the SPAN official eBus API. Make a backup first.** ⚠️ ### Breaking Changes diff --git a/custom_components/span_panel/sensor_definitions.py b/custom_components/span_panel/sensor_definitions.py index 3bf318b..3909b14 100644 --- a/custom_components/span_panel/sensor_definitions.py +++ b/custom_components/span_panel/sensor_definitions.py @@ -483,7 +483,7 @@ class SpanPVMetadataSensorEntityDescription( state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=0, device_class=SensorDeviceClass.POWER, - value_fn=lambda s: -s.power_flow_battery if s.power_flow_battery is not None else 0.0, + value_fn=lambda s: (-s.power_flow_battery or 0.0) if s.power_flow_battery is not None else 0.0, ) # PV power sensor (conditionally created when PV is commissioned) @@ -494,7 +494,7 @@ class SpanPVMetadataSensorEntityDescription( state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=0, device_class=SensorDeviceClass.POWER, - value_fn=lambda s: -s.power_flow_pv if s.power_flow_pv is not None else 0.0, + value_fn=lambda s: (-s.power_flow_pv or 0.0) if s.power_flow_pv is not None else 0.0, ) # Site power sensor (conditionally created when power-flows data is available) @@ -591,7 +591,9 @@ class SpanPVMetadataSensorEntityDescription( state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=0, device_class=SensorDeviceClass.POWER, - value_fn=lambda c: -c.instant_power_w if c.device_type == "pv" else c.instant_power_w, + value_fn=lambda c: (-c.instant_power_w or 0.0) + if c.device_type == "pv" + else c.instant_power_w, entity_registry_enabled_default=True, entity_registry_visible_default=True, ), From 94e1b7872c720b383b7bc9def60d9921e2254317 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Tue, 10 Mar 2026 17:32:57 -0700 Subject: [PATCH 3/6] bump span-panel-api to 2.2.4 --- .github/workflows/ci.yml | 2 +- custom_components/span_panel/manifest.json | 2 +- poetry.lock | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 20520c1..400d250 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,7 +25,7 @@ jobs: - name: Install dependencies run: | # Replace path dependencies with PyPI versions for CI - sed -i 's/span-panel-api = {path = "..\/span-panel-api", develop = true}/span-panel-api = ">=2.2.3"/' pyproject.toml + sed -i 's/span-panel-api = {path = "..\/span-panel-api", develop = true}/span-panel-api = ">=2.2.4"/' pyproject.toml sed -i 's/ha-synthetic-sensors = {path = "..\/ha-synthetic-sensors", develop = true}/ha-synthetic-sensors = "^1.1.13"/' pyproject.toml # Regenerate lock file with the modified dependencies poetry lock diff --git a/custom_components/span_panel/manifest.json b/custom_components/span_panel/manifest.json index 3be5f5e..05dcdea 100644 --- a/custom_components/span_panel/manifest.json +++ b/custom_components/span_panel/manifest.json @@ -12,7 +12,7 @@ "iot_class": "local_push", "issue_tracker": "https://github.com/SpanPanel/span/issues", "requirements": [ - "span-panel-api>=2.2.3" + "span-panel-api>=2.2.4" ], "version": "2.0.2", "zeroconf": [ diff --git a/poetry.lock b/poetry.lock index 194a8b1..38dbbfc 100644 --- a/poetry.lock +++ b/poetry.lock @@ -4520,7 +4520,7 @@ test = ["covdefaults (==2.3.0)", "pytest (==8.4.1)", "pytest-aiohttp (==1.1.0)", [[package]] name = "span-panel-api" -version = "2.2.1" +version = "2.2.4" description = "A client library for SPAN Panel API" optional = false python-versions = ">=3.10,<4.0" From c6d3d6f23369eb4a0f36038ea64a5a623e225f44 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Tue, 10 Mar 2026 18:34:40 -0700 Subject: [PATCH 4/6] Align net energy with dip-compensated consumed/produced When energy dip compensation is enabled, consumed and produced sensors apply an offset to the raw value but net energy was computed from raw snapshot values, causing a visible mismatch. Consumed/produced sensors now register on the coordinator so net energy can read their offsets directly and adjust its value to stay consistent. --- CHANGELOG.md | 3 ++ custom_components/span_panel/coordinator.py | 28 ++++++++++++++++++ custom_components/span_panel/sensor_base.py | 5 ++++ .../span_panel/sensor_circuit.py | 29 +++++++++++++++++++ 4 files changed, 65 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d867004..678e8cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,9 @@ All notable changes to this project will be documented in this file. sensor now negates the value to match HA conventions (positive = discharging), consistent with how PV power is already handled. (#184) - **Idle circuits showing -0W** — Power sensors that negate values (PV circuits, battery, PV power) could produce IEEE 754 negative zero (`-0.0`) when the circuit was idle, causing HA to display `-0W` instead of `0W`. All negation sites now normalize zero to positive. (#185) +- **Net energy inconsistent with dip-compensated consumed/produced** — When energy dip compensation was enabled, consumed and produced sensors applied an + offset but net energy computed from raw snapshot values, causing a visible mismatch. Net energy now reads dip offsets from its sibling sensors so the + displayed value always equals compensated consumed minus compensated produced. **Important** 2.0.1 cautions still apply — read those carefully if not on 2.0.1 BEFORE proceeding: diff --git a/custom_components/span_panel/coordinator.py b/custom_components/span_panel/coordinator.py index c6c98f5..9666958 100644 --- a/custom_components/span_panel/coordinator.py +++ b/custom_components/span_panel/coordinator.py @@ -6,6 +6,7 @@ from datetime import timedelta import logging from time import time as _epoch_time +from typing import Protocol from homeassistant.components.persistent_notification import async_create from homeassistant.config_entries import ConfigEntry @@ -34,6 +35,16 @@ from .helpers import build_circuit_unique_id from .options import ENERGY_REPORTING_GRACE_PERIOD + +class SpanCircuitEnergySensorProtocol(Protocol): + """Protocol for circuit energy sensors that expose their dip offset.""" + + @property + def energy_offset(self) -> float: + """Cumulative dip compensation offset.""" + ... + + _LOGGER = logging.getLogger(__name__) # Suppress the noisy "Manually updated span_panel data" DEBUG message that @@ -93,6 +104,10 @@ def __init__( # drained and surfaced as a persistent notification after each cycle. self._pending_dip_events: list[tuple[str, float, float]] = [] + # Circuit energy sensor registry — consumed/produced sensors register + # here so net energy sensors can read their dip offsets directly. + self._circuit_energy_sensors: dict[tuple[str, str], SpanCircuitEnergySensorProtocol] = {} + # MQTT streaming: push is the primary update path; poll is a safety net. # Simulation: poll is the only update path; use the snapshot interval. if isinstance(client, SpanMqttClient): @@ -140,6 +155,19 @@ def report_energy_dip(self, entity_id: str, delta: float, cumulative_offset: flo """ self._pending_dip_events.append((entity_id, delta, cumulative_offset)) + def register_circuit_energy_sensor( + self, circuit_id: str, energy_type: str, sensor: SpanCircuitEnergySensorProtocol + ) -> None: + """Register a consumed/produced energy sensor so net energy can read its dip offset.""" + self._circuit_energy_sensors[(circuit_id, energy_type)] = sensor + + def get_circuit_dip_offset(self, circuit_id: str, energy_type: str) -> float: + """Return the cumulative dip offset from the registered sensor, or 0.""" + sensor = self._circuit_energy_sensors.get((circuit_id, energy_type)) + if sensor is None: + return 0.0 + return sensor.energy_offset + async def _fire_dip_notification(self) -> None: """Create a persistent notification summarising energy dips this cycle.""" if not self._pending_dip_events: diff --git a/custom_components/span_panel/sensor_base.py b/custom_components/span_panel/sensor_base.py index ceee6a3..9305eb2 100644 --- a/custom_components/span_panel/sensor_base.py +++ b/custom_components/span_panel/sensor_base.py @@ -474,6 +474,11 @@ def __init__( ENABLE_ENERGY_DIP_COMPENSATION, False ) + @property + def energy_offset(self) -> float: + """Return the cumulative dip compensation offset.""" + return self._energy_offset + def _process_raw_value(self, raw_value: float | int | str | None) -> None: """Process the raw value with energy dip compensation for TOTAL_INCREASING sensors.""" if ( diff --git a/custom_components/span_panel/sensor_circuit.py b/custom_components/span_panel/sensor_circuit.py index 0339023..6aa86b6 100644 --- a/custom_components/span_panel/sensor_circuit.py +++ b/custom_components/span_panel/sensor_circuit.py @@ -243,6 +243,13 @@ def __init__( if device_info_override is not None: self._attr_device_info = device_info_override + async def async_added_to_hass(self) -> None: + """Register consumed/produced sensors on the coordinator for net energy lookup.""" + await super().async_added_to_hass() + energy_type = self._ENERGY_TYPE_MAP.get(self.original_key) + if energy_type: + self.coordinator.register_circuit_energy_sensor(self.circuit_id, energy_type, self) + def _generate_unique_id( self, snapshot: SpanPanelSnapshot, description: SpanPanelCircuitsSensorEntityDescription ) -> str: @@ -296,6 +303,28 @@ def _generate_panel_name( circuit_identifier = _resolve_circuit_identifier_for_sync(circuit, self.circuit_id) return f"{circuit_identifier} {description.name}" + # Map original_key to the energy type used for coordinator dip offset tracking + _ENERGY_TYPE_MAP: dict[str, str] = { + "circuit_energy_consumed": "consumed", + "circuit_energy_produced": "produced", + } + + def _process_raw_value(self, raw_value: float | int | str | None) -> None: + """Process raw value, adjusting net energy for dip compensation consistency. + + Consumed/produced sensors apply dip offsets via the base class. The net + energy sensor reads those offsets from the registered sibling sensors + so its value stays equal to compensated_consumed - compensated_produced. + """ + super()._process_raw_value(raw_value) + + if self.original_key == "circuit_energy_net" and isinstance(self._attr_native_value, float): + consumed_offset = self.coordinator.get_circuit_dip_offset(self.circuit_id, "consumed") + produced_offset = self.coordinator.get_circuit_dip_offset(self.circuit_id, "produced") + net_adjustment = consumed_offset - produced_offset + if net_adjustment: + self._attr_native_value += net_adjustment + def get_data_source(self, snapshot: SpanPanelSnapshot) -> SpanCircuitSnapshot: """Get the data source for the circuit energy sensor.""" return snapshot.circuits[self.circuit_id] From 3634a6dd00deb75e7d0a85ca2381f1428136d532 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Tue, 10 Mar 2026 19:06:19 -0700 Subject: [PATCH 5/6] Fix ruff format for CI compatibility Reformat ternary lambda in circuit power sensor to match CI ruff version's preferred style. --- custom_components/span_panel/sensor_definitions.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/custom_components/span_panel/sensor_definitions.py b/custom_components/span_panel/sensor_definitions.py index 3909b14..19611cb 100644 --- a/custom_components/span_panel/sensor_definitions.py +++ b/custom_components/span_panel/sensor_definitions.py @@ -591,9 +591,9 @@ class SpanPVMetadataSensorEntityDescription( state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=0, device_class=SensorDeviceClass.POWER, - value_fn=lambda c: (-c.instant_power_w or 0.0) - if c.device_type == "pv" - else c.instant_power_w, + value_fn=lambda c: ( + (-c.instant_power_w or 0.0) if c.device_type == "pv" else c.instant_power_w + ), entity_registry_enabled_default=True, entity_registry_visible_default=True, ), From 7339e0d7183f4ca5b7f6ff26da2c0b6976551d5f Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Tue, 10 Mar 2026 19:07:47 -0700 Subject: [PATCH 6/6] Sync pre-commit ruff version with pyproject.toml (0.15.1) Pre-commit was pinned to ruff 0.11.13 while CI used 0.15.1 from pyproject.toml, causing formatting disagreements on ternary lambdas. --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 5a01a89..fb5695e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -36,7 +36,7 @@ repos: # Ruff for linting, import sorting, and primary formatting - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.11.13 + rev: v0.15.1 hooks: - id: ruff-format exclude: '^tests/.*|scripts/.*|\..*_cache/.*|dist/.*|venv/.*'