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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

---

Expand Down
38 changes: 30 additions & 8 deletions custom_components/checkwatt/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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",
Expand Down
12 changes: 9 additions & 3 deletions custom_components/checkwatt/event.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand All @@ -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,
Expand All @@ -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"),
Expand All @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion custom_components/checkwatt/manifest.json
Original file line number Diff line number Diff line change
@@ -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"],
Expand Down
42 changes: 39 additions & 3 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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(
Expand Down
154 changes: 154 additions & 0 deletions tests/test_coordinator.py
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading