Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
54 commits
Select commit Hold shift + click to select a range
ee3ecc1
feat: cross-panel Favorites services for circuits and sub-devices
cayossarian Apr 15, 2026
b3f21c7
build: sync span-card 0.9.3 bundles and document Favorites in changelog
cayossarian Apr 15, 2026
e8ddbdc
docs: document Favorites view in frontend.md and sync bundle
cayossarian Apr 15, 2026
57a44f6
build: sync span-card bundle (heart icon for Favorite section)
cayossarian Apr 15, 2026
2947dc7
fix: backend hardening from code review
cayossarian Apr 15, 2026
26bf1bf
language clarification for Favorites
cayossarian Apr 15, 2026
5e0309d
chore: ignore local .mcp.json
cayossarian Apr 15, 2026
6bbb2b7
build: sync span-card bundle (re-sort list view on hass updates)
cayossarian Apr 15, 2026
9f2794c
build: sync span-card bundle (compact expanded list rows)
cayossarian Apr 15, 2026
1790fc6
build: sync span-card bundle (enable slide-confirm in list views)
cayossarian Apr 15, 2026
85bcd4e
build: sync span-card bundle (favorites utilization %)
cayossarian Apr 15, 2026
ffef1ae
build: sync span-card bundle (move utilization % next to breaker)
cayossarian Apr 15, 2026
7978337
build: sync span-card bundle (By Panel utilization %)
cayossarian Apr 15, 2026
a7d7edb
build: sync span-card bundle (inline panel tabs)
cayossarian Apr 15, 2026
b289948
build: sync span-card bundle (drop Favorites title)
cayossarian Apr 15, 2026
86dceba
build: sync span-card bundle (toggle-pill + Favorites slider)
cayossarian Apr 15, 2026
3c97675
build: sync span-card bundle (favorites per-panel stats grid)
cayossarian Apr 15, 2026
84388f9
build: sync span-card bundle (stop double-rendering; fresh history on…
cayossarian Apr 15, 2026
b364b88
build: sync span-card bundle (list view columns setting)
cayossarian Apr 15, 2026
1a35391
build: sync span-card bundle (cell-constrained expansion in grid mode)
cayossarian Apr 15, 2026
864bbcd
build: sync span-card bundle (keep single-cell expand; sort still row…
cayossarian Apr 16, 2026
5ade487
build: sync span-card bundle (code-review followups: render tokens, k…
cayossarian Apr 16, 2026
c68ce16
build: sync span-card 0.9.4 bundle and update changelog reference
cayossarian Apr 16, 2026
7f98c1b
docs(frontend): document list view columns setting
cayossarian Apr 16, 2026
bd77931
Fix language to denote dashboard only release
cayossarian Apr 16, 2026
95efbac
build: sync span-card bundle (copilot review fixes)
cayossarian Apr 16, 2026
3a3bfcd
fix: README.md casing in changelog; update fr.json get_favorites desc…
cayossarian Apr 16, 2026
79ec78d
feat: include panel_status binary sensor in topology response
cayossarian Apr 16, 2026
36970f3
fix: preserve serial casing in panel_status lookup
cayossarian Apr 16, 2026
8402fa2
build: sync span-card bundle with error management changes
cayossarian Apr 16, 2026
52044cc
build: sync span-card bundle (final error management fixes)
cayossarian Apr 16, 2026
5f6432c
build: sync span-card bundle (retry removal from relay toggle)
cayossarian Apr 16, 2026
f07059c
build: sync span-card bundle (retry scoping refactor)
cayossarian Apr 16, 2026
bd58eed
build: sync span-card bundle (Copilot review fixes)
cayossarian Apr 16, 2026
dc2b25d
sync frontend: utilization pct fallback
cayossarian Apr 16, 2026
de65246
bump span-panel-api to 2.6.0
cayossarian Apr 16, 2026
a6d3e1b
feat(coordinator): wire broker-connection callback for sub-second off…
cayossarian Apr 16, 2026
a375071
refactor(coordinator): guard listener fan-out on real edge changes
cayossarian Apr 16, 2026
0d219ab
fix(types): resolve pre-existing strict-type issues
cayossarian Apr 16, 2026
ba611b4
refactor(coordinator): label SpanPanelStaleDataError as expected offl…
cayossarian Apr 16, 2026
b448b53
docs(changelog): note panel-offline status sensor fix in 2.0.6
cayossarian Apr 16, 2026
f5d3830
bump span-panel-api to 2.6.1
cayossarian Apr 16, 2026
93f8ce3
update changelog for span-panel-api version change
cayossarian Apr 16, 2026
3c1ba27
fix(frontend): remove config_panel_domain so Configure opens options …
cayossarian Apr 16, 2026
c7e59fc
fix: phase 1 — mechanical review fixes across services, typing, defaults
cayossarian Apr 17, 2026
44fcafb
build(frontend): sync dist bundle with span-card code-review fixes
cayossarian Apr 17, 2026
6ad4ef4
fix: unload platforms before shutting down coordinator (B1)
cayossarian Apr 17, 2026
eb25b8a
docs(id_builder): warn against normalising serial casing
cayossarian Apr 17, 2026
ea3311c
refactor(monitoring): narrow threshold/override dicts from Any to int…
cayossarian Apr 17, 2026
98558d1
feat(icons): add icons for 6 energy sensors (M16)
cayossarian Apr 17, 2026
322dbed
build(frontend): sync dist bundle with Favorites BESS chart fixes
cayossarian Apr 17, 2026
d65ac95
fix(favorites): route EVSE feed-circuit sensors to circuit, not sub-d…
cayossarian Apr 17, 2026
f243fe4
update the frontend dist files for the span panel card and span panel
cayossarian Apr 17, 2026
a59c4ba
build(frontend): sync dist bundle with Favorites Monitoring header fix
cayossarian Apr 17, 2026
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ docs/agile-readme.md
.claude/
.superpowers/
docs/superpowers/
.mcp.json

# Docker dev environment
ha-config/
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,18 @@ All notable changes to this project will be documented in this file.
- By Activity: circuits sorted by power consumption with expandable graphs and search filtering
- By Area: circuits grouped by Home Assistant area with live area registry updates
- Shared tab bar across panel and card with configurable text/icon style
- **Cross-panel Favorites view** (span-card 0.9.4) — A synthetic "Favorites" entry in the dashboard panel dropdown aggregates favorited circuits and sub-devices
(BESS, EVSE) across every configured SPAN panel into a single workspace. Heart toggles in the Graph Settings and per-circuit / per-sub-device side panels
persist favorites and the view to the integration storage so the Favorites view is reconstituted on restart. See the Favorites explanation in the frontend
dashboard link via the README.md.

### Fixed

- **Dashboard goes blank after idle** — Panel and card migrated to LitElement and refresh after losing focus (span-card 0.9.1)
- **Dashboard graph fidelity** — Circuit charts now use step interpolation instead of linear, eliminating misleading diagonal ramps between data points.
Continuous signals (PV solar output, BESS SoC/SoE) retain linear interpolation to faithfully represent their gradual behavior.
- **Panel status showing "Connected" while the panel is offline** — the panel status sensor now reflects the true connection state and updates within a second
of the panel going offline or coming back online (including the bump to span-panel-api v2.6.1)

## [2.0.5] - 4/2026

Expand Down
20 changes: 16 additions & 4 deletions custom_components/span_panel/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
from span_panel_api.mqtt.models import MqttClientConfig

# Import config flow to ensure it's registered
from . import config_flow # noqa: F401 # type: ignore[misc]
from . import config_flow # noqa: F401
from .const import (
CONF_API_VERSION,
CONF_EBUS_BROKER_HOST,
Expand All @@ -45,14 +45,15 @@
PANEL_FRONTEND_DIR as PANEL_FRONTEND_DIR,
PANEL_URL as PANEL_URL,
_async_ensure_lovelace_resource as _async_ensure_lovelace_resource,
async_apply_panel_registration,
async_apply_panel_registration as async_apply_panel_registration,
async_load_panel_settings as async_load_panel_settings,
async_save_panel_settings as async_save_panel_settings,
)
from .graph_horizon import GraphHorizonManager
from .migrations import CURRENT_CONFIG_VERSION, async_migrate_entry # noqa: F401
from .options import SNAPSHOT_UPDATE_INTERVAL
from .services import ( # noqa: F401
_async_register_favorites_services,
_async_register_graph_horizon_services,
_async_register_monitoring_services,
_async_register_services,
Expand Down Expand Up @@ -93,6 +94,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
_async_register_services(hass)
_async_register_monitoring_services(hass)
_async_register_graph_horizon_services(hass)
_async_register_favorites_services(hass)

await async_apply_panel_registration(hass)

Expand Down Expand Up @@ -246,15 +248,25 @@ async def async_setup_entry(hass: HomeAssistant, entry: SpanPanelConfigEntry) ->


async def async_unload_entry(hass: HomeAssistant, entry: SpanPanelConfigEntry) -> bool:
"""Unload a config entry."""
"""Unload a config entry.

Unload the platforms first; only tear the coordinator down if that
succeeded. If a platform raises during unload, HA retries with the
coordinator still alive — shutting it down first would leave
entities pointing at a closed MQTT client.
"""
_LOGGER.debug("Unloading SPAN Panel integration")

unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if not unload_ok:
return False

if hasattr(entry, "runtime_data") and entry.runtime_data is not None:
if entry.runtime_data.coordinator.current_monitor is not None:
entry.runtime_data.coordinator.current_monitor.async_stop()
await entry.runtime_data.coordinator.async_shutdown()

return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
return True


async def async_remove_config_entry_device(
Expand Down
6 changes: 4 additions & 2 deletions custom_components/span_panel/alert_dispatcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,8 @@ def dispatch_alert(
over_threshold_since: str | None = None,
) -> None:
"""Dispatch alert through all enabled notification channels."""
local_time = dt_util.now().strftime("%-I:%M %p")
# `%-I` is glibc-only; strip any leading zero ourselves for portability.
local_time = dt_util.now().strftime("%I:%M %p").lstrip("0")

event_data: dict[str, Any] = {
"alert_source": alert_source,
Expand Down Expand Up @@ -249,7 +250,8 @@ def dispatch_test_alert(
utilization_pct = 91.5
panel_serial = "TEST"
window_duration_s = 300
local_time = dt_util.now().strftime("%-I:%M %p")
# `%-I` is glibc-only; strip any leading zero ourselves for portability.
local_time = dt_util.now().strftime("%I:%M %p").lstrip("0")

raw_targets = settings.get(NOTIFY_TARGETS, "")
if isinstance(raw_targets, str):
Expand Down
2 changes: 1 addition & 1 deletion custom_components/span_panel/binary_sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import EntityCategory # type: ignore[attr-defined]
from homeassistant.helpers.entity import EntityCategory
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from span_panel_api import SpanEvseSnapshot, SpanPanelSnapshot

Expand Down
3 changes: 1 addition & 2 deletions custom_components/span_panel/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@

from homeassistant import config_entries
from homeassistant.config_entries import (
ConfigEntry,
ConfigFlowContext,
ConfigFlowResult,
)
Expand Down Expand Up @@ -634,7 +633,7 @@ def create_new_entry(

def _update_v2_entry(self, entry_id: str) -> ConfigFlowResult:
"""Update an existing config entry with new v2 MQTT credentials."""
entry: ConfigEntry[Any] | None = self.hass.config_entries.async_get_entry(entry_id)
entry: SpanPanelConfigEntry | None = self.hass.config_entries.async_get_entry(entry_id)
if entry is None:
_LOGGER.error("Config entry %s does not exist during v2 reauth", entry_id)
return self.async_abort(reason="reauth_failed")
Expand Down
2 changes: 1 addition & 1 deletion custom_components/span_panel/config_flow_options.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ def get_general_options_defaults(
),
ENERGY_REPORTING_GRACE_PERIOD: config_entry.options.get(ENERGY_REPORTING_GRACE_PERIOD, 15),
ENABLE_ENERGY_DIP_COMPENSATION: config_entry.options.get(
ENABLE_ENERGY_DIP_COMPENSATION, False
ENABLE_ENERGY_DIP_COMPENSATION, True
),
}

Expand Down
72 changes: 56 additions & 16 deletions custom_components/span_panel/coordinator.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,14 @@
from datetime import timedelta
import logging
from time import time as _epoch_time
from typing import TYPE_CHECKING, Any, Protocol
from typing import TYPE_CHECKING, Protocol

if TYPE_CHECKING:
from . import SpanPanelConfigEntry
from .current_monitor import CurrentMonitor
from .graph_horizon import GraphHorizonManager

from homeassistant.components.persistent_notification import async_create
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import (
ConfigEntryAuthFailed,
Expand All @@ -23,11 +23,10 @@
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from span_panel_api import SpanMqttClient, SpanPanelSnapshot
from span_panel_api.exceptions import SpanPanelAuthError
from span_panel_api.exceptions import SpanPanelAuthError, SpanPanelStaleDataError

from .const import DOMAIN
from .id_builder import build_circuit_unique_id
from .options import ENERGY_REPORTING_GRACE_PERIOD
from .schema_validation import collect_sensor_definitions, validate_field_metadata


Expand All @@ -37,6 +36,7 @@ class SpanCircuitEnergySensorProtocol(Protocol):
@property
def energy_offset(self) -> float:
"""Cumulative dip compensation offset."""
...


_LOGGER = logging.getLogger(__name__)
Expand Down Expand Up @@ -66,22 +66,21 @@ def __init__(
self,
hass: HomeAssistant,
client: SpanMqttClient,
config_entry: ConfigEntry,
config_entry: SpanPanelConfigEntry,
) -> None:
"""Initialize the coordinator."""
self._client = client
self.config_entry: ConfigEntry[Any] = config_entry
self.config_entry: SpanPanelConfigEntry = config_entry
# Track last tick for visibility into cadence
self._last_tick_epoch: float | None = None
# Flag to track if a reload was requested
self._reload_requested = False
# Flag to track if panel is offline/unreachable
self._panel_offline = False
# Track last grace period value for comparison
self._last_grace_period = config_entry.options.get(ENERGY_REPORTING_GRACE_PERIOD, 15)

# Streaming state
self._unregister_streaming: Callable[[], None] | None = None
self._unregister_connection: Callable[[], None] | None = None

# Hardware capability tracking — detect when BESS/PV are commissioned
# and trigger a reload so the factory creates the appropriate sensors.
Expand Down Expand Up @@ -142,13 +141,19 @@ def _mark_panel_online(self) -> None:
_LOGGER.info("%s is back online", self.config_entry.title or "SPAN Panel")
self._panel_offline = False

def _mark_panel_offline(self, err: Exception) -> None:
"""Mark the panel offline and log the transition once."""
def _mark_panel_offline(self, reason: Exception | str) -> None:
"""Mark the panel offline and log the transition once.

`reason` is rendered with %s — both Exception and str format
correctly. The broker-disconnect path (from the MQTT client
connection callback) passes a short string; the snapshot-poll
path passes a SpanPanelStaleDataError or unexpected Exception.
"""
if not self._panel_offline:
_LOGGER.info(
"%s is unavailable: %s",
self.config_entry.title or "SPAN Panel",
err,
reason,
)
self._panel_offline = True

Expand Down Expand Up @@ -209,11 +214,34 @@ async def _fire_dip_notification(self) -> None:
# --- Streaming ---

async def async_setup_streaming(self) -> None:
"""Set up push streaming."""
"""Set up push streaming and broker-connection state listening."""
self._unregister_connection = self._client.register_connection_callback(
self._on_connection_change
)
self._unregister_streaming = self._client.register_snapshot_callback(self._on_snapshot_push)
await self._client.start_streaming()
_LOGGER.info("MQTT push streaming started")

def _on_connection_change(self, connected: bool) -> None:
"""Handle a broker connection state edge from the MQTT client.

Called on the event loop when the bridge transitions between
connected and disconnected. Flips the panel-offline flag and
pushes an immediate listener update so sensors enter or exit
grace-period logic without waiting for the 60 s fallback poll.

Listener fan-out is guarded by a real state change so a misbehaving
or future-version library that re-emits the same edge does not
trigger spurious entity re-renders.
"""
was_offline = self._panel_offline
if connected:
self._mark_panel_online()
else:
self._mark_panel_offline("MQTT broker disconnected")
if self._panel_offline != was_offline:
self.async_update_listeners()

async def _on_snapshot_push(self, snapshot: SpanPanelSnapshot) -> None:
"""Handle a pushed snapshot from MQTT streaming."""
self._mark_panel_online()
Expand All @@ -223,6 +251,10 @@ async def _on_snapshot_push(self, snapshot: SpanPanelSnapshot) -> None:

async def async_shutdown(self) -> None:
"""Shut down the coordinator and release resources."""
if self._unregister_connection is not None:
self._unregister_connection()
self._unregister_connection = None

if self._unregister_streaming is not None:
self._unregister_streaming()
self._unregister_streaming = None
Expand Down Expand Up @@ -466,12 +498,20 @@ async def _async_update_data(self) -> SpanPanelSnapshot:
except ConfigEntryAuthFailed:
raise

except Exception as err:
except SpanPanelStaleDataError as err:
# Expected offline path — the library signals the client
# isn't live. Same handling as other offline errors.
self._mark_panel_offline(err)
if self.data is not None:
return self.data
raise

# Return last known data to keep coordinator updating for grace period logic.
# On first refresh (self.data is None), re-raise so async_config_entry_first_refresh
# surfaces the error properly.
except Exception as err:
# Unexpected error — log the transition but keep the
# coordinator ticking on last-known data for grace-period logic.
# On first refresh (self.data is None), re-raise so
# async_config_entry_first_refresh surfaces the error properly.
self._mark_panel_offline(err)
if self.data is not None:
return self.data
raise
Expand Down
Loading