From e61025a7f9c79da6ea685dd32b016ac31972fd4c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 09:56:10 +0000 Subject: [PATCH 1/2] Fix lost events, CM10 event spam, stale revenue and energy resets - Event entities: write state after each triggered event. HA only publishes the latest event per state write, so several new logbook entries or news items in one refresh dropped all but the last. - CM10 status: a missing TestInfo.Latest (e.g. empty /site/Statuses) is no longer a change, so the event no longer re-fires every cycle. - Today's revenue: an empty Revenue list (the API omits days without data) now means 0 instead of keeping yesterday's value, and entries for several services are summed. The service name keeps its last known value. - Energy totals: ignore empty responses and decreases. Publishing 0 or a partial total made HA's statistics detect a meter reset and count the next value as the whole lifetime total again. Adds coordinator tests driven by a fake API client and event entity tests, with HA stubs that mirror EventEntity's publish-on-write behaviour. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Tt2YacbPwiWKinpfhCss29 --- README.md | 4 +- custom_components/checkwatt/__init__.py | 38 ++++-- custom_components/checkwatt/event.py | 12 +- tests/conftest.py | 42 ++++++- tests/test_coordinator.py | 154 ++++++++++++++++++++++++ tests/test_event.py | 72 ++++++++++- 6 files changed, 305 insertions(+), 17 deletions(-) create mode 100644 tests/test_coordinator.py diff --git a/README.md b/README.md index a4c6d30..66db1ee 100644 --- a/README.md +++ b/README.md @@ -120,9 +120,9 @@ logger: custom_components.checkwatt: debug ``` -### Energisensorer backar i värde +### Energisensorer står still -Om `total_increasing`-sensorerna ibland minskar beror det troligen på att CheckWatt-API:et returnerar justerade historiska värden. HA hanterar detta automatiskt och ignorerar minskningar. +Integrationen ignorerar tomma svar och minskningar av livstidsvärdena. HA tolkar annars en kraftig minskning som att mätaren nollställts och räknar nästa värde som ny energi i Energi-dashboarden. Om CheckWatt-API:et justerar historiska värden nedåt står sensorn därför still tills den verkliga summan passerat det tidigare värdet. --- diff --git a/custom_components/checkwatt/__init__.py b/custom_components/checkwatt/__init__.py index c0ff685..7c7933f 100644 --- a/custom_components/checkwatt/__init__.py +++ b/custom_components/checkwatt/__init__.py @@ -189,8 +189,12 @@ async def _async_update_data(self) -> dict: test_info: dict = status.get("TestInfo") or {} new_test_status = test_info.get("Latest") + # A missing status (e.g. an empty Statuses list) is not a change — + # otherwise the event would re-fire every cycle until it's back. cm10_status_changed = ( - self._last_test_status is not None and new_test_status != self._last_test_status + new_test_status is not None + and self._last_test_status is not None + and new_test_status != self._last_test_status ) cm10_status_prev = self._last_test_status if new_test_status is not None: @@ -369,10 +373,15 @@ async def _update_revenue(self, data: dict) -> None: self._site_id, today.isoformat(), today.isoformat() ) revenues = today_resp.get("Revenue", []) - if revenues: - data["today_revenue_sek"] = revenues[0].get("NetRevenue") - data["today_revenue_estimate"] = revenues[0].get("Estimate", False) - data["today_service_name"] = revenues[0].get("ServiceName") + # Days without data are omitted, so an empty list means nothing + # earned yet today. Sites running several services get one entry + # per service. + data["today_revenue_sek"] = sum(r.get("NetRevenue") or 0 for r in revenues) + data["today_revenue_estimate"] = any(r.get("Estimate") for r in revenues) + # The service describes enrollment, not today's earnings — keep the + # last known one rather than going unavailable every night. + if services := [r["ServiceName"] for r in revenues if r.get("ServiceName")]: + data["today_service_name"] = ", ".join(dict.fromkeys(services)) month_resp = await self._client.get_revenue( self._site_id, month_start.isoformat(), today.isoformat() @@ -480,12 +489,25 @@ async def _update_energy_totals(self, data: dict) -> None: continue try: resp = await self._client.get_energy_totals(ids) - total_wh = sum( + values = [ m.get("Value", 0) for meter in resp.get("Meters", []) for m in meter.get("Measurements", []) - ) - data[key] = round(total_wh / 1000, 3) # Wh → kWh + ] + if not values: + # Publishing 0 would read as a meter reset in HA's + # statistics, and the next real value would be counted as + # the whole lifetime total again in the Energy dashboard. + continue + total_kwh = round(sum(values) / 1000, 3) # Wh → kWh + previous = data.get(key) + if previous is not None and total_kwh < previous: + # Same risk for a partial response: never go backwards. + _LOGGER.debug( + "Ignoring decrease of %s from %s to %s kWh", key, previous, total_kwh + ) + continue + data[key] = total_kwh except Exception as err: _LOGGER.warning( "Energy total update failed for %s (%s): %s", diff --git a/custom_components/checkwatt/event.py b/custom_components/checkwatt/event.py index cb9b7f7..538a676 100644 --- a/custom_components/checkwatt/event.py +++ b/custom_components/checkwatt/event.py @@ -92,6 +92,12 @@ def _handle_coordinator_update(self) -> None: def _process_update(self, data: dict) -> None: raise NotImplementedError + def _fire(self, event_type: str, attributes: dict) -> None: + # HA publishes only the latest event per state write, so write after + # each one — batching them would drop all but the last. + self._trigger_event(event_type, attributes) + self.async_write_ha_state() + class CheckwattCm10StatusEvent(_CheckwattEventBase): """Fires when CM10 TestInfo.Latest changes.""" @@ -109,7 +115,7 @@ def _process_update(self, data: dict) -> None: return ti = data.get("test_info", {}) status = ti.get("Latest") - self._trigger_event( + self._fire( _map_cm10_event_type(status), { "status": status, @@ -133,7 +139,7 @@ def _key(self) -> str: def _process_update(self, data: dict) -> None: for entry in data.get("new_logbook_entries", []): - self._trigger_event( + self._fire( _map_logbook_event_type(entry.get("event", "")), { "event": entry.get("event"), @@ -157,7 +163,7 @@ def _key(self) -> str: def _process_update(self, data: dict) -> None: for item in data.get("new_news_items", []): title = item.get("RubrikEN") or item.get("Rubrik", "") - self._trigger_event( + self._fire( _map_news_event_type(item.get("Kategori", "")), { "title": title, diff --git a/tests/conftest.py b/tests/conftest.py index ea30359..f4d79aa 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -66,7 +66,28 @@ def _enum_stub(name: str, *values: str): SensorEntityDescription=_SensorEntityDescription, SensorStateClass=_enum_stub("SensorStateClass", "MEASUREMENT", "TOTAL", "TOTAL_INCREASING"), ) -_stub("homeassistant.components.event", EventEntity=object) + + +class _EventEntity: + """Mirrors HA: _trigger_event() only stores the event; it becomes a state + change (what automations see) only when async_write_ha_state() runs.""" + + def __init__(self): + self.published_events: list[tuple[str, dict | None]] = [] + self._pending_event: tuple[str, dict | None] | None = None + + def _trigger_event(self, event_type, event_attributes=None): + if event_type not in self._attr_event_types: + raise ValueError(f"Invalid event type {event_type}") + self._pending_event = (event_type, event_attributes) + + def async_write_ha_state(self): + if self._pending_event is not None: + self.published_events.append(self._pending_event) + self._pending_event = None + + +_stub("homeassistant.components.event", EventEntity=_EventEntity) _stub( "homeassistant.const", PERCENTAGE="PERCENTAGE", @@ -89,10 +110,25 @@ def __class_getitem__(cls, item): return cls +class _DataUpdateCoordinator(_Generic): + def __init__(self, hass, logger, **kwargs): + self.hass = hass + self.data = None + + +class _CoordinatorEntity(_Generic): + def __init__(self, coordinator): + super().__init__() + self.coordinator = coordinator + + def _handle_coordinator_update(self): + self.async_write_ha_state() + + _stub( "homeassistant.helpers.update_coordinator", - CoordinatorEntity=_Generic, - DataUpdateCoordinator=_Generic, + CoordinatorEntity=_CoordinatorEntity, + DataUpdateCoordinator=_DataUpdateCoordinator, UpdateFailed=Exception, ) _stub( diff --git a/tests/test_coordinator.py b/tests/test_coordinator.py new file mode 100644 index 0000000..12576e8 --- /dev/null +++ b/tests/test_coordinator.py @@ -0,0 +1,154 @@ +"""Tests for CheckwattCoordinator update cycles, driven by a fake API client.""" + +import asyncio + +from custom_components.checkwatt import CheckwattCoordinator + +_SLOW_UPDATE_ATTRS = ( + "_last_revenue_update", + "_last_price_update", + "_last_energy_update", + "_last_logbook_update", + "_last_diag_update", + "_last_news_update", +) + + +class FakeClient: + """Serves a single site; tests change the attributes between cycles.""" + + def __init__(self): + self.test_status: str | None = "Activated" + self.revenue: list[dict] = [] + self.meters: list[dict] = [{"Measurements": [{"Value": 5_000_000.0}]}] + + async def ensure_authenticated(self): + pass + + async def get_customer_details(self): + return {"Meter": [{"InstallationType": "SoC", "RpiSerial": "AABBCCDDEEFF"}]} + + async def get_site_id_by_serial(self, serial): + return 12345 + + async def get_energy_flow(self): + return {"SolarIds": [100002]} + + async def get_site_statuses(self, serial): + if self.test_status is None: + return [] + return [{"TestInfo": {"Latest": self.test_status}}] + + async def get_revenue(self, site_id, from_date, to_date): + return {"Revenue": self.revenue} + + async def get_price_zone(self): + return "SE4" + + async def get_spot_prices(self, zone, from_date, to_date): + return {"Prices": []} + + async def get_energy_totals(self, meter_ids): + return {"Meters": self.meters} + + async def get_connection_status(self, site_id): + return {} + + async def get_news(self): + return [] + + +def _coordinator() -> tuple[CheckwattCoordinator, FakeClient]: + client = FakeClient() + return CheckwattCoordinator(None, client, None), client + + +def _refresh(coordinator: CheckwattCoordinator) -> dict: + """Run one update cycle with every slow update due, storing data like HA does.""" + for attr in _SLOW_UPDATE_ATTRS: + setattr(coordinator, attr, None) + coordinator.data = asyncio.run(coordinator._async_update_data()) + return coordinator.data + + +class TestCm10StatusChange: + def test_first_cycle_fires_nothing(self): + coordinator, _ = _coordinator() + assert _refresh(coordinator)["cm10_status_changed"] is False + + def test_transition_fires_once(self): + coordinator, client = _coordinator() + _refresh(coordinator) + client.test_status = "Deactivated" + data = _refresh(coordinator) + assert data["cm10_status_changed"] is True + assert data["cm10_status_prev"] == "Activated" + assert _refresh(coordinator)["cm10_status_changed"] is False + + def test_missing_status_does_not_fire(self): + coordinator, client = _coordinator() + _refresh(coordinator) + client.test_status = None # /site/Statuses returned an empty list + for _ in range(3): + assert _refresh(coordinator)["cm10_status_changed"] is False + client.test_status = "Activated" # back, unchanged + assert _refresh(coordinator)["cm10_status_changed"] is False + + +class TestTodayRevenue: + def test_no_entries_yet_today_is_zero(self): + coordinator, client = _coordinator() + client.revenue = [{"ServiceName": "mFRR CM", "NetRevenue": 84.48, "Estimate": True}] + assert _refresh(coordinator)["today_revenue_sek"] == 84.48 + client.revenue = [] # new day: the API omits days without data + data = _refresh(coordinator) + assert data["today_revenue_sek"] == 0 + assert data["today_revenue_estimate"] is False + + def test_service_name_kept_when_no_entries(self): + coordinator, client = _coordinator() + client.revenue = [{"ServiceName": "mFRR CM", "NetRevenue": 84.48}] + _refresh(coordinator) + client.revenue = [] + assert _refresh(coordinator)["today_service_name"] == "mFRR CM" + + def test_entries_for_several_services_are_summed(self): + coordinator, client = _coordinator() + client.revenue = [ + {"ServiceName": "mFRR CM", "NetRevenue": 10.5, "Estimate": False}, + {"ServiceName": "FCR-D", "NetRevenue": 4.25, "Estimate": True}, + ] + data = _refresh(coordinator) + assert data["today_revenue_sek"] == 14.75 + assert data["today_revenue_estimate"] is True + assert data["today_service_name"] == "mFRR CM, FCR-D" + + def test_null_net_revenue_counts_as_zero(self): + coordinator, client = _coordinator() + client.revenue = [{"ServiceName": "FCR-D", "NetRevenue": None}] + assert _refresh(coordinator)["today_revenue_sek"] == 0 + + +class TestEnergyTotals: + def test_total_converted_to_kwh(self): + coordinator, _ = _coordinator() + assert _refresh(coordinator)["total_solar_kwh"] == 5000.0 + + def test_empty_response_keeps_previous_total(self): + coordinator, client = _coordinator() + _refresh(coordinator) + client.meters = [] # 200 OK, but no data + assert _refresh(coordinator)["total_solar_kwh"] == 5000.0 + + def test_decrease_is_ignored(self): + coordinator, client = _coordinator() + _refresh(coordinator) + client.meters = [{"Measurements": [{"Value": 4_000_000.0}]}] # partial response + assert _refresh(coordinator)["total_solar_kwh"] == 5000.0 + client.meters = [{"Measurements": [{"Value": 5_000_500.0}]}] + assert _refresh(coordinator)["total_solar_kwh"] == 5000.5 + + def test_empty_first_response_publishes_nothing(self): + coordinator, client = _coordinator() + client.meters = [{"Measurements": []}] + assert _refresh(coordinator)["total_solar_kwh"] is None diff --git a/tests/test_event.py b/tests/test_event.py index 3eee9cb..472146c 100644 --- a/tests/test_event.py +++ b/tests/test_event.py @@ -1,12 +1,82 @@ -"""Tests for event entity helper functions.""" +"""Tests for the event entities and their helper functions.""" + +from types import SimpleNamespace from custom_components.checkwatt.event import ( + CheckwattCm10StatusEvent, + CheckwattLogbookEvent, + CheckwattNewsEvent, _map_cm10_event_type, _map_logbook_event_type, _map_news_event_type, ) +def _entity(cls): + coordinator = SimpleNamespace(data={"rpi_serial": "aabbccddeeff"}, last_update_success=True) + return cls(coordinator) + + +def _update(entity, success: bool = True, **data) -> None: + """Deliver one coordinator refresh to *entity*.""" + entity.coordinator.data = {"rpi_serial": "aabbccddeeff", **data} + entity.coordinator.last_update_success = success + entity._handle_coordinator_update() + + +class TestEventPublishing: + def test_every_logbook_entry_is_published(self): + entity = _entity(CheckwattLogbookEvent) + entries = [ + {"event": "mfrrup ACTIVATED", "timestamp": "2026-09-01 10:00:00", "detail": ""}, + {"event": "mfrrup DEACTIVATE", "timestamp": "2026-09-01 10:15:00", "detail": ""}, + {"event": "FAIL", "timestamp": "2026-09-01 10:30:00", "detail": "timeout"}, + ] + _update(entity, new_logbook_entries=entries) + assert [t for t, _ in entity.published_events] == ["activated", "deactivated", "failed"] + assert [a["timestamp"] for _, a in entity.published_events] == [ + "2026-09-01 10:00:00", + "2026-09-01 10:15:00", + "2026-09-01 10:30:00", + ] + + def test_every_news_item_is_published(self): + entity = _entity(CheckwattNewsEvent) + items = [ + {"Rubrik": "Första", "Kategori": "Nyheter", "Tidstampel": "2026-09-01"}, + {"Rubrik": "Andra", "Kategori": "Uppdatering", "Tidstampel": "2026-09-02"}, + ] + _update(entity, new_news_items=items) + assert [(t, a["title"]) for t, a in entity.published_events] == [ + ("news", "Första"), + ("update", "Andra"), + ] + + def test_cm10_change_is_published(self): + entity = _entity(CheckwattCm10StatusEvent) + _update( + entity, + cm10_status_changed=True, + cm10_status_prev="Activated", + test_info={"Latest": "Deactivated", "Result": None, "FailedInARow": 0}, + ) + assert len(entity.published_events) == 1 + event_type, attributes = entity.published_events[0] + assert event_type == "deactivated" + assert attributes["previous_status"] == "Activated" + + def test_cycle_without_new_events_publishes_nothing(self): + entity = _entity(CheckwattLogbookEvent) + _update(entity, new_logbook_entries=[]) + assert entity.published_events == [] + + def test_failed_refresh_publishes_nothing(self): + entity = _entity(CheckwattLogbookEvent) + entries = [{"event": "ACTIVATED", "timestamp": "2026-09-01 10:00:00", "detail": ""}] + _update(entity, success=False, new_logbook_entries=entries) + assert entity.published_events == [] + + class TestMapCm10EventType: def test_activated(self): assert _map_cm10_event_type("Activated") == "activated" From 1a88889c44f6bf4abfb3f4e4af4d8d8726d2da0e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 09:56:10 +0000 Subject: [PATCH 2/2] Bump version to 1.5.1 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Tt2YacbPwiWKinpfhCss29 --- custom_components/checkwatt/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/checkwatt/manifest.json b/custom_components/checkwatt/manifest.json index 6fd31e7..7354893 100644 --- a/custom_components/checkwatt/manifest.json +++ b/custom_components/checkwatt/manifest.json @@ -1,7 +1,7 @@ { "domain": "checkwatt", "name": "CheckWatt", - "version": "1.5.0", + "version": "1.5.1", "documentation": "https://github.com/andreasthell/ha-cw", "issue_tracker": "https://github.com/andreasthell/ha-cw/issues", "codeowners": ["@andreasthell"],