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/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"], 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"