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/.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/.*' diff --git a/CHANGELOG.md b/CHANGELOG.md index bd851d3..678e8cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,17 @@ 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) +- **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: - Requires firmware `spanos2/r202603/05` or later (v2 eBus MQTT) @@ -16,8 +27,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/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/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/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] diff --git a/custom_components/span_panel/sensor_definitions.py b/custom_components/span_panel/sensor_definitions.py index 89bee84..19611cb 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, ), 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"