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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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/.*'
Expand Down
15 changes: 13 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,26 @@ 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)
- You _must_ already be on v1.3.x or later of the SpanPanel/span integration if upgrading

## [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

Expand Down
28 changes: 28 additions & 0 deletions custom_components/span_panel/coordinator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion custom_components/span_panel/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down
5 changes: 5 additions & 0 deletions custom_components/span_panel/sensor_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
29 changes: 29 additions & 0 deletions custom_components/span_panel/sensor_circuit.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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]
Expand Down
8 changes: 5 additions & 3 deletions custom_components/span_panel/sensor_definitions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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,
),
Expand Down
2 changes: 1 addition & 1 deletion poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading