Skip to content

Commit d3a9643

Browse files
SantaFoxclaude
andcommitted
0.1.3: backfill long-term statistics for cumulative kWh sensors
The total_increasing sensors HA already records hourly, but only from the live state — which only moves when we poll (every 6h by default). Between polls the recorder writes nothing, so the Energy Dashboard graph develops gaps right where the EAC API has data. This commit adds a small statistics module that, on every coordinator refresh, takes the full window of readings the API returned (up to HISTORY_DAYS, default 14) and pushes each cumulative kWh point as a long-term-statistics row attached to the existing sensor's entity_id via async_import_statistics. HA's recorder is idempotent on (statistic_id, hour), so per-poll overlap is harmless and we do not have to track a "last imported" cursor ourselves — exactly the "fill the gaps without duplicating" property requested. v1 scope: cumulative channels only (S-KWH-24H, NORMAL, OFFPEAK, EXP). The 30-min profile has no cumulative counter on the API side and needs synthesis; left for a follow-up. To backfill more than the default 14 days, temporarily raise "History window" in the options flow, wait one poll, then lower again. Coordinator's _async_update_data now extracts the full reading list once per channel (was discarding all but the latest) and forwards it to the statistics import for cumulative kWh channels. The unused _latest_reading helper is removed. Bumps manifest 0.1.2 → 0.1.3. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 6fa1924 commit d3a9643

4 files changed

Lines changed: 242 additions & 13 deletions

File tree

custom_components/eac_cyprus/coordinator.py

Lines changed: 19 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@
1010
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
1111

1212
from eac_dso_portal import (
13-
ChannelReadings,
1413
EacApiError,
1514
EacAuthError,
1615
EacClient,
@@ -27,6 +26,7 @@
2726
DEFAULT_SCAN_INTERVAL_MINUTES,
2827
DOMAIN,
2928
)
29+
from .statistics import import_cumulative_history
3030

3131
_LOGGER = logging.getLogger(__name__)
3232

@@ -122,9 +122,24 @@ async def _async_update_data(self) -> EacData:
122122
"No readings for sp=%s mc=%s: %s", sp.id, ch.id, err
123123
)
124124
continue
125-
latest = _latest_reading(per_channel, ch.id)
126-
if latest is not None:
127-
channels[ch.id] = ChannelState(channel=ch, latest=latest)
125+
matching = next(
126+
(cr for cr in per_channel if cr.channel_id == ch.id), None
127+
)
128+
if matching is None or not matching.readings:
129+
continue
130+
latest = max(matching.readings, key=lambda r: r.dt)
131+
channels[ch.id] = ChannelState(channel=ch, latest=latest)
132+
# Backfill long-term statistics for cumulative kWh channels
133+
# so the Energy Dashboard graph stays continuous between
134+
# polls. Idempotent — safe to call on every refresh.
135+
if ch.uom.upper() == "KWH" and not ch.interval:
136+
import_cumulative_history(
137+
self.hass,
138+
sp.id,
139+
ch.id,
140+
ch.type,
141+
matching.readings,
142+
)
128143

129144
points[sp.id] = ServicePointState(
130145
service_point=sp, active_meter=active_meter, channels=channels
@@ -133,14 +148,6 @@ async def _async_update_data(self) -> EacData:
133148
return EacData(user=user, points=points, last_success_time=datetime.now(UTC))
134149

135150

136-
def _latest_reading(channels: list[ChannelReadings], channel_id: str) -> Reading | None:
137-
for cr in channels:
138-
if cr.channel_id != channel_id or not cr.readings:
139-
continue
140-
return max(cr.readings, key=lambda r: r.dt)
141-
return None
142-
143-
144151
def _interval_from_options(entry: ConfigEntry) -> timedelta:
145152
minutes = entry.options.get(
146153
CONF_SCAN_INTERVAL_MINUTES, DEFAULT_SCAN_INTERVAL_MINUTES

custom_components/eac_cyprus/manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,5 +9,5 @@
99
"iot_class": "cloud_polling",
1010
"issue_tracker": "https://github.com/SantaFox/ha-eac/issues",
1111
"requirements": ["eac-dso-portal==0.1.0"],
12-
"version": "0.1.2"
12+
"version": "0.1.3"
1313
}
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
"""Long-term-statistics backfill for cumulative kWh sensors.
2+
3+
The sensor platform's ``state_class=total_increasing`` already produces hourly
4+
statistics from the live state on every recorder tick — but the state only
5+
reflects the *latest* reading the integration has fetched. Between polls (6
6+
hours by default) the recorder has nothing fresh to record, so the Energy
7+
Dashboard graph develops gaps right where the EAC API actually has data.
8+
9+
This module closes those gaps. On every poll the coordinator hands us the full
10+
window of readings the API returned (typically the last 14 days), and we push
11+
each one as a long-term-statistics record attached to the existing sensor's
12+
``entity_id``. HA's ``async_import_statistics`` is idempotent on
13+
``(statistic_id, start_hour)``: re-pushing a row for an hour we already have
14+
*replaces* it, so the per-poll overlap is harmless. We never need to track a
15+
"last imported timestamp" ourselves.
16+
17+
v1 scope: cumulative kWh channels only (those that carry a running ``reading``
18+
in addition to the per-slot ``value``). Interval channels like the 30-min load
19+
profile have no cumulative counter on the API side — backfilling them needs to
20+
synthesise a running sum, which is left for a follow-up.
21+
"""
22+
from __future__ import annotations
23+
24+
import logging
25+
from datetime import UTC
26+
27+
from homeassistant.components.recorder.models.statistics import (
28+
StatisticData,
29+
StatisticMetaData,
30+
)
31+
from homeassistant.components.recorder.statistics import async_import_statistics
32+
from homeassistant.const import UnitOfEnergy
33+
from homeassistant.core import HomeAssistant
34+
from homeassistant.helpers import entity_registry as er
35+
36+
from eac_dso_portal import Reading
37+
38+
from .const import DOMAIN
39+
40+
_LOGGER = logging.getLogger(__name__)
41+
42+
43+
def _resolve_entity_id(
44+
hass: HomeAssistant, sp_id: str, channel_id: str
45+
) -> str | None:
46+
"""Map our (service-point, channel) unique_id to the sensor's entity_id."""
47+
registry = er.async_get(hass)
48+
return registry.async_get_entity_id("sensor", DOMAIN, f"{sp_id}_{channel_id}")
49+
50+
51+
def import_cumulative_history(
52+
hass: HomeAssistant,
53+
sp_id: str,
54+
channel_id: str,
55+
channel_name: str,
56+
readings: tuple[Reading, ...],
57+
) -> None:
58+
"""Push readings for one cumulative kWh channel as hourly statistics.
59+
60+
No-op when the sensor entity does not yet exist in the registry — that
61+
happens on the very first poll for a brand-new install; HA will call us
62+
again on the next refresh once the platform has registered the entity.
63+
"""
64+
entity_id = _resolve_entity_id(hass, sp_id, channel_id)
65+
if entity_id is None:
66+
return
67+
68+
points = [r for r in readings if r.reading is not None]
69+
if not points:
70+
return
71+
72+
metadata = StatisticMetaData(
73+
has_mean=False,
74+
has_sum=True,
75+
name=channel_name,
76+
source="recorder",
77+
statistic_id=entity_id,
78+
unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
79+
)
80+
81+
stats: list[StatisticData] = []
82+
for r in points:
83+
# The portal's `dt` is naive ISO; daily readings sit at 00:00 Cyprus
84+
# local time. Treat as UTC for statistics: a few hours of TZ drift on
85+
# a *daily* lifetime counter is invisible in Energy Dashboard, and
86+
# converting properly would require pulling the user's TZ config.
87+
start = r.dt.replace(minute=0, second=0, microsecond=0)
88+
if start.tzinfo is None:
89+
start = start.replace(tzinfo=UTC)
90+
stats.append(StatisticData(start=start, sum=r.reading, state=r.reading))
91+
92+
_LOGGER.debug(
93+
"Importing %d statistics rows for %s (sp=%s mc=%s)",
94+
len(stats),
95+
entity_id,
96+
sp_id,
97+
channel_id,
98+
)
99+
async_import_statistics(hass, metadata, stats)

tests/test_statistics.py

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
"""Tests for the long-term-statistics backfill module."""
2+
from __future__ import annotations
3+
4+
from datetime import UTC, datetime
5+
from unittest.mock import patch
6+
7+
import pytest
8+
from eac_dso_portal import Reading
9+
from homeassistant.const import UnitOfEnergy
10+
from homeassistant.core import HomeAssistant
11+
from homeassistant.helpers import entity_registry as er
12+
from pytest_homeassistant_custom_component.common import MockConfigEntry
13+
14+
from custom_components.eac_cyprus.const import CONF_EMAIL, CONF_PASSWORD, DOMAIN
15+
from custom_components.eac_cyprus.statistics import import_cumulative_history
16+
17+
18+
def _entry(hass: HomeAssistant) -> MockConfigEntry:
19+
entry = MockConfigEntry(
20+
domain=DOMAIN,
21+
unique_id="test@example.com",
22+
data={CONF_EMAIL: "test@example.com", CONF_PASSWORD: "x"},
23+
title="Test User",
24+
)
25+
entry.add_to_hass(hass)
26+
return entry
27+
28+
29+
def _readings(*pairs: tuple[str, float | None, float]) -> tuple[Reading, ...]:
30+
"""Build Readings from (iso_dt, reading|None, value) triples."""
31+
return tuple(
32+
Reading(dt=datetime.fromisoformat(dt), reading=r, value=v)
33+
for dt, r, v in pairs
34+
)
35+
36+
37+
async def test_no_op_when_entity_not_registered(hass: HomeAssistant) -> None:
38+
"""First-run safety: skip silently if the sensor is not in the registry yet."""
39+
rdgs = _readings(("2026-05-01T00:00:00", 100.0, 5.0))
40+
with patch(
41+
"custom_components.eac_cyprus.statistics.async_import_statistics"
42+
) as mock_import:
43+
import_cumulative_history(hass, "999", "mc-X", "S-KWH-24H", rdgs)
44+
mock_import.assert_not_called()
45+
46+
47+
async def test_no_op_when_no_cumulative_readings(hass: HomeAssistant) -> None:
48+
"""Interval channels (reading=None) produce no statistics."""
49+
registry = er.async_get(hass)
50+
registry.async_get_or_create("sensor", DOMAIN, "111_mc-30min")
51+
interval_only = _readings(
52+
("2026-05-01T01:00:00", None, 0.5),
53+
("2026-05-01T01:30:00", None, 0.6),
54+
)
55+
with patch(
56+
"custom_components.eac_cyprus.statistics.async_import_statistics"
57+
) as mock_import:
58+
import_cumulative_history(hass, "111", "mc-30min", "KWH-30MIN-LP-IMP", interval_only)
59+
mock_import.assert_not_called()
60+
61+
62+
async def test_imports_cumulative_kwh_with_correct_metadata(
63+
hass: HomeAssistant,
64+
) -> None:
65+
registry = er.async_get(hass)
66+
entry = registry.async_get_or_create("sensor", DOMAIN, "111_mc-total")
67+
rdgs = _readings(
68+
("2026-05-01T00:00:00", 2900.0, 23.0),
69+
("2026-05-02T00:00:00", 2924.0, 24.0),
70+
("2026-05-03T00:00:00", 2950.0, 26.0),
71+
)
72+
73+
with patch(
74+
"custom_components.eac_cyprus.statistics.async_import_statistics"
75+
) as mock_import:
76+
import_cumulative_history(hass, "111", "mc-total", "S-KWH-24H", rdgs)
77+
78+
mock_import.assert_called_once()
79+
_, metadata, stats = mock_import.call_args.args
80+
assert metadata["statistic_id"] == entry.entity_id
81+
assert metadata["source"] == "recorder"
82+
assert metadata["has_sum"] is True
83+
assert metadata["has_mean"] is False
84+
assert metadata["unit_of_measurement"] == UnitOfEnergy.KILO_WATT_HOUR
85+
assert len(stats) == 3
86+
# Each stat has tz-aware UTC start at the hour boundary, sum and state set.
87+
for s, expected_reading in zip(stats, [2900.0, 2924.0, 2950.0], strict=True):
88+
assert s["start"].tzinfo is UTC
89+
assert s["start"].minute == 0 and s["start"].second == 0
90+
assert s["sum"] == expected_reading
91+
assert s["state"] == expected_reading
92+
93+
94+
async def test_coordinator_pushes_stats_for_cumulative_channel_only(
95+
hass: HomeAssistant, mock_client
96+
) -> None:
97+
"""Integration check: cumulative channel triggers import, 30-min channel does not."""
98+
from custom_components.eac_cyprus.coordinator import EacCoordinator
99+
100+
entry = _entry(hass)
101+
102+
# Pre-register the two sensors so import has somewhere to attach.
103+
registry = er.async_get(hass)
104+
registry.async_get_or_create("sensor", DOMAIN, "111111111111_mc-total-24h")
105+
registry.async_get_or_create("sensor", DOMAIN, "111111111111_mc-30min-imp")
106+
107+
coord = EacCoordinator(hass, mock_client, entry)
108+
with patch(
109+
"custom_components.eac_cyprus.coordinator.import_cumulative_history"
110+
) as mock_import:
111+
await coord._async_update_data()
112+
113+
# The shared mock_client returns a 30-min reading (no `reading`) and a
114+
# daily total (with `reading`); only the latter qualifies.
115+
called_channels = [c.kwargs.get("channel_id") or c.args[2] for c in mock_import.call_args_list]
116+
assert "mc-total-24h" in called_channels
117+
assert "mc-30min-imp" not in called_channels
118+
119+
120+
@pytest.mark.usefixtures("hass")
121+
def _unused_marker():
122+
"""Anchor for collecting pytest-homeassistant-custom-component fixtures."""
123+
pass

0 commit comments

Comments
 (0)