diff --git a/custom_components/checkwatt/__init__.py b/custom_components/checkwatt/__init__.py index 7c7933f..bcb7361 100644 --- a/custom_components/checkwatt/__init__.py +++ b/custom_components/checkwatt/__init__.py @@ -4,8 +4,7 @@ import json import logging -from datetime import UTC, date, datetime, timedelta -from zoneinfo import ZoneInfo +from datetime import UTC, datetime, timedelta from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME @@ -16,6 +15,8 @@ from .api import AuthenticationError, CheckwattApiClient from .const import ( + API_TZ, + DIAG_MAX_AGE, DIAG_UPDATE_INTERVAL, DOMAIN, ENERGY_UPDATE_INTERVAL, @@ -24,32 +25,63 @@ PLATFORMS, PRICE_UPDATE_INTERVAL, REVENUE_UPDATE_INTERVAL, + SLOW_RETRY_INTERVAL, UPDATE_INTERVAL, ) _LOGGER = logging.getLogger(__name__) -# Spot price slot timestamps from the API are naive Swedish local time, -# regardless of where the HA host runs. -_PRICE_TZ = ZoneInfo("Europe/Stockholm") +_DEFAULT_SLOT_LENGTH = timedelta(minutes=15) + +# Diagnostics keys cleared when the CM10 has not reported recently. +_DIAG_KEYS = ( + "battery_temp_high_c", + "battery_temp_low_c", + "internet_connection", + "cm10_uptime_s", + "default_route", +) def _select_spot_price(prices: list[dict], now_local: datetime) -> float | None: - """Return the price of the latest 15-min slot not after *now_local*. + """Return the price of the slot covering *now_local*, or None if none does. *now_local* must be naive Swedish local time, matching the slot timestamps. + A slot lasts until the next one starts; the last one is assumed to be as + long as the one before it, so an outdated price list yields None rather + than its last price forever. """ current: float | None = None + current_start: datetime | None = None + slot_length = _DEFAULT_SLOT_LENGTH for entry in prices: + if not isinstance(entry, dict) or entry.get("Value") is None: + continue try: slot = datetime.fromisoformat(entry["Date"]) except (KeyError, TypeError, ValueError): continue - if slot <= now_local: - current = entry["Value"] - else: - break - return current + if slot > now_local: + return current + if current_start is not None and slot > current_start: + slot_length = slot - current_start + current, current_start = entry["Value"], slot + if current_start is not None and now_local < current_start + slot_length: + return current + return None + + +def _latest_logbook_ts(entries: list[dict]) -> str | None: + """Return the newest timestamp among logbook entries, skipping lines without one.""" + return max((e["timestamp"] for e in entries if e.get("timestamp")), default=None) + + +def _parse_utc(value: str | None) -> datetime | None: + """Parse an ISO 8601 UTC timestamp such as ``2026-09-16T08:13:35Z``.""" + try: + return datetime.fromisoformat(value.replace("Z", "+00:00")).astimezone(UTC) + except (AttributeError, ValueError): + return None def _parse_diag_blob(blob: dict) -> dict: @@ -155,13 +187,8 @@ def __init__( # Previous CM10 test status, used to detect transitions. self._last_test_status: str | None = None - # Timestamps tracking when slow-update data was last refreshed. - self._last_revenue_update: datetime | None = None - self._last_price_update: datetime | None = None - self._last_energy_update: datetime | None = None - self._last_logbook_update: datetime | None = None - self._last_diag_update: datetime | None = None - self._last_news_update: datetime | None = None + # When each slow update is next due; missing means due now. + self._next_update: dict[str, datetime] = {} self._last_news_ts: str | None = None # ------------------------------------------------------------------ @@ -236,35 +263,26 @@ async def _async_update_data(self) -> dict: } now = datetime.now(UTC) - - if self._due(self._last_revenue_update, REVENUE_UPDATE_INTERVAL): - await self._update_revenue(data) - self._last_revenue_update = now - - if self._due(self._last_price_update, PRICE_UPDATE_INTERVAL): - await self._update_spot_price(data) - self._last_price_update = now - - if self._due(self._last_energy_update, ENERGY_UPDATE_INTERVAL): - await self._update_energy_totals(data) - self._last_energy_update = now - - if self._due(self._last_logbook_update, LOGBOOK_UPDATE_INTERVAL): - await self._update_logbook(data) - self._last_logbook_update = now - - if self._due(self._last_diag_update, DIAG_UPDATE_INTERVAL): - await self._update_diagnostics(data) - self._last_diag_update = now - - if self._due(self._last_news_update, NEWS_UPDATE_INTERVAL): - await self._update_news(data) - self._last_news_update = now + for name, interval, update in ( + ("revenue", REVENUE_UPDATE_INTERVAL, self._update_revenue), + ("price", PRICE_UPDATE_INTERVAL, self._update_spot_price), + ("energy", ENERGY_UPDATE_INTERVAL, self._update_energy_totals), + ("logbook", LOGBOOK_UPDATE_INTERVAL, self._update_logbook), + ("diagnostics", DIAG_UPDATE_INTERVAL, self._update_diagnostics), + ("news", NEWS_UPDATE_INTERVAL, self._update_news), + ): + if now < self._next_update.get(name, now): + continue + ok = await update(data) + # Retry a failed update soon instead of waiting a full interval. + self._next_update[name] = now + ( + interval if ok else min(interval, SLOW_RETRY_INTERVAL) + ) # Re-select the current 15-min slot every cycle so the sensor # doesn't lag behind the hourly price fetch. data["spot_price_sek_kwh"] = _select_spot_price( - self._spot_prices, datetime.now(_PRICE_TZ).replace(tzinfo=None) + self._spot_prices, datetime.now(API_TZ).replace(tzinfo=None) ) data["price_zone"] = self._price_zone @@ -304,8 +322,7 @@ async def _bootstrap(self) -> None: from .sensor import _parse_logbook _, entries = _parse_logbook(self._logbook_raw) - if entries: - self._last_logbook_ts = entries[0].get("timestamp") + self._last_logbook_ts = _latest_logbook_ts(entries) self._site_id = await self._client.get_site_id_by_serial(self._rpi_serial) _LOGGER.debug( @@ -318,12 +335,6 @@ async def _bootstrap(self) -> None: # Slow-update helpers # ------------------------------------------------------------------ - @staticmethod - def _due(last: datetime | None, interval: timedelta) -> bool: - if last is None: - return True - return datetime.now(UTC) - last >= interval - def _slow_data(self) -> dict: """Return previous slow-update values so they survive fast-update cycles.""" if self.data is None: @@ -365,8 +376,8 @@ def _slow_data(self) -> dict: ) } - async def _update_revenue(self, data: dict) -> None: - today = date.today() + async def _update_revenue(self, data: dict) -> bool: + today = datetime.now(API_TZ).date() month_start = today.replace(day=1) try: today_resp = await self._client.get_revenue( @@ -392,10 +403,12 @@ async def _update_revenue(self, data: dict) -> None: ) except Exception as err: _LOGGER.warning("Revenue update failed (%s): %s", type(err).__name__, err) + return False + return True - async def _update_spot_price(self, data: dict) -> None: + async def _update_spot_price(self, data: dict) -> bool: # Use Sweden's "today" — the host may be in a different timezone. - today = datetime.now(_PRICE_TZ).date() + today = datetime.now(API_TZ).date() tomorrow = today + timedelta(days=1) try: if self._price_zone is None: @@ -407,8 +420,10 @@ async def _update_spot_price(self, data: dict) -> None: self._spot_prices = resp.get("Prices", []) except Exception as err: _LOGGER.warning("Spot price update failed (%s): %s", type(err).__name__, err) + return False + return True - async def _update_logbook(self, data: dict) -> None: + async def _update_logbook(self, data: dict) -> bool: """Re-fetch logbook and detect new entries since last check.""" from .sensor import _LOGBOOK_MAX_BYTES, _parse_logbook # avoid circular at module level @@ -418,7 +433,7 @@ async def _update_logbook(self, data: dict) -> None: soc_meter = next((m for m in meters if m.get("InstallationType") == "SoC"), None) raw = (soc_meter.get("Logbook") or "") if soc_meter else "" if not raw: - return + return True _, entries = _parse_logbook(raw) self._logbook_raw = raw[:_LOGBOOK_MAX_BYTES] @@ -426,9 +441,8 @@ async def _update_logbook(self, data: dict) -> None: if self._last_logbook_ts is None: # First logbook fetch — record latest timestamp but fire no events. - if entries: - self._last_logbook_ts = entries[0].get("timestamp") - return + self._last_logbook_ts = _latest_logbook_ts(entries) + return True new_entries = [ e for e in entries if e.get("timestamp") and e["timestamp"] > self._last_logbook_ts @@ -436,29 +450,38 @@ async def _update_logbook(self, data: dict) -> None: if new_entries: # Fire oldest-first so automations see them in chronological order. data["new_logbook_entries"] = list(reversed(new_entries)) - self._last_logbook_ts = new_entries[0].get("timestamp") + self._last_logbook_ts = _latest_logbook_ts(new_entries) except Exception as err: _LOGGER.warning("Logbook update failed (%s): %s", type(err).__name__, err) + return False + return True - async def _update_diagnostics(self, data: dict) -> None: + async def _update_diagnostics(self, data: dict) -> bool: """Fetch CM10 diagnostics: battery temperatures and internet connection.""" try: resp = await self._client.get_connection_status(self._site_id) - blob_raw = (resp.get("Current") or {}).get("Blob") - if not blob_raw: - return - blob = json.loads(blob_raw) + current = resp.get("Current") or {} + blob = json.loads(current["Blob"]) if current.get("Blob") else None + # Current is the CM10's latest report, however old — an offline + # CM10 would otherwise show its last connection state forever. + reported_at = _parse_utc(current.get("Timestamp")) + if reported_at is not None and datetime.now(UTC) - reported_at > DIAG_MAX_AGE: + blob = None if isinstance(blob, dict): data.update(_parse_diag_blob(blob)) + else: + data.update(dict.fromkeys(_DIAG_KEYS)) except Exception as err: _LOGGER.warning("Diagnostics update failed (%s): %s", type(err).__name__, err) + return False + return True - async def _update_news(self, data: dict) -> None: + async def _update_news(self, data: dict) -> bool: """Fetch news and detect items published since the last check.""" try: items = await self._client.get_news() if not items: - return + return True # Sort ascending by timestamp so we can compare and fire oldest-first. items.sort(key=lambda x: x.get("Tidstampel", "")) @@ -466,7 +489,7 @@ async def _update_news(self, data: dict) -> None: if self._last_news_ts is None: # First fetch — seed timestamp but fire no events. self._last_news_ts = items[-1].get("Tidstampel", "") - return + return True new_items = [i for i in items if i.get("Tidstampel", "") > self._last_news_ts] if new_items: @@ -474,8 +497,10 @@ async def _update_news(self, data: dict) -> None: self._last_news_ts = new_items[-1].get("Tidstampel", "") except Exception as err: _LOGGER.warning("News update failed (%s): %s", type(err).__name__, err) + return False + return True - async def _update_energy_totals(self, data: dict) -> None: + async def _update_energy_totals(self, data: dict) -> bool: """Sum all-time yearly measurements for each meter group.""" meter_groups = { "total_solar_kwh": self._solar_ids, @@ -484,6 +509,7 @@ async def _update_energy_totals(self, data: dict) -> None: "total_charge_kwh": self._charge_ids, "total_discharge_kwh": self._discharge_ids, } + ok = True for key, ids in meter_groups.items(): if not ids: continue @@ -515,3 +541,5 @@ async def _update_energy_totals(self, data: dict) -> None: type(err).__name__, err, ) + ok = False + return ok diff --git a/custom_components/checkwatt/api.py b/custom_components/checkwatt/api.py index 8c8d48a..dec470f 100644 --- a/custom_components/checkwatt/api.py +++ b/custom_components/checkwatt/api.py @@ -9,6 +9,8 @@ from aiohttp import ClientError, ClientResponseError, ClientSession, ClientTimeout +from .const import API_TZ + _LOGGER = logging.getLogger(__name__) BASE_URL = "https://api.checkwatt.se" @@ -64,7 +66,8 @@ def _store_tokens(self, data: dict) -> None: try: payload_b64 = self._jwt.split(".")[1] payload_b64 += "=" * (-len(payload_b64) % 4) - claims = json.loads(base64.b64decode(payload_b64)) + # JWTs use the URL-safe alphabet; b64decode would drop "-" and "_". + claims = json.loads(base64.urlsafe_b64decode(payload_b64)) raw_expiry = datetime.fromtimestamp(claims["exp"], tz=UTC) max_expiry = datetime.now(UTC) + timedelta(hours=24) self._jwt_expiry = min(raw_expiry, max_expiry) @@ -244,9 +247,7 @@ async def get_connection_status(self, site_id: int) -> dict: async def get_energy_totals(self, meter_ids: list[int]) -> dict: """Fetch all-time yearly-grouped totals for the given meter IDs.""" - from datetime import date - - year = date.today().year + year = datetime.now(API_TZ).year # aiohttp accepts a list of tuples for repeated query parameters. params: list[tuple[str, str | int]] = [ ("grouping", "3"), diff --git a/custom_components/checkwatt/const.py b/custom_components/checkwatt/const.py index 45538ec..e6e213a 100644 --- a/custom_components/checkwatt/const.py +++ b/custom_components/checkwatt/const.py @@ -1,6 +1,7 @@ """Constants for the CheckWatt integration.""" from datetime import timedelta +from zoneinfo import ZoneInfo from homeassistant.const import Platform @@ -14,3 +15,14 @@ LOGBOOK_UPDATE_INTERVAL = timedelta(minutes=30) DIAG_UPDATE_INTERVAL = timedelta(minutes=5) NEWS_UPDATE_INTERVAL = timedelta(hours=4) + +# A failed slow update is retried after this instead of its full interval. +SLOW_RETRY_INTERVAL = timedelta(minutes=5) + +# CM10 diagnostics older than this are treated as unknown. The CM10 does not +# report every cycle — a healthy site's blob can be over an hour old. +DIAG_MAX_AGE = timedelta(hours=3) + +# The API's dates (revenue days, spot price slots, yearly totals) are Swedish +# local time, regardless of where the HA host runs. +API_TZ = ZoneInfo("Europe/Stockholm") diff --git a/custom_components/checkwatt/manifest.json b/custom_components/checkwatt/manifest.json index 7354893..c8d1d72 100644 --- a/custom_components/checkwatt/manifest.json +++ b/custom_components/checkwatt/manifest.json @@ -1,7 +1,7 @@ { "domain": "checkwatt", "name": "CheckWatt", - "version": "1.5.1", + "version": "1.5.2", "documentation": "https://github.com/andreasthell/ha-cw", "issue_tracker": "https://github.com/andreasthell/ha-cw/issues", "codeowners": ["@andreasthell"], diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..f4d04ea --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,29 @@ +"""Tests for the API client's token handling.""" + +import base64 +import json +from datetime import UTC, datetime + +from custom_components.checkwatt.api import CheckwattApiClient + + +def _jwt(claims: dict) -> str: + payload = base64.urlsafe_b64encode(json.dumps(claims).encode()).rstrip(b"=").decode() + return f"header.{payload}.signature" + + +class TestStoreTokens: + def test_expiry_read_from_urlsafe_payload(self): + exp = int(datetime.now(UTC).timestamp()) + 900 + # "?>" and "~" encode to "-" and "_" in base64url. + token = _jwt({"exp": exp, "iss": "https://example.com/?a>b~c", "name": "Åsa Öberg"}) + assert "-" in token or "_" in token + client = CheckwattApiClient(None, "user", "password") + client._store_tokens( + { + "JwtToken": token, + "RefreshToken": "refresh", + "RefreshTokenExpires": "2030-01-01T00:00:00Z", + } + ) + assert client._jwt_expiry == datetime.fromtimestamp(exp, tz=UTC) diff --git a/tests/test_coordinator.py b/tests/test_coordinator.py index 12576e8..441d61b 100644 --- a/tests/test_coordinator.py +++ b/tests/test_coordinator.py @@ -1,18 +1,12 @@ """Tests for CheckwattCoordinator update cycles, driven by a fake API client.""" import asyncio +import json +from datetime import UTC, datetime, timedelta +import custom_components.checkwatt as checkwatt 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.""" @@ -21,12 +15,17 @@ def __init__(self): self.test_status: str | None = "Activated" self.revenue: list[dict] = [] self.meters: list[dict] = [{"Measurements": [{"Value": 5_000_000.0}]}] + self.logbook = "" + self.connection_status: dict = {} + self.news_error: Exception | None = None + self.revenue_dates: list[tuple[str, str]] = [] async def ensure_authenticated(self): pass async def get_customer_details(self): - return {"Meter": [{"InstallationType": "SoC", "RpiSerial": "AABBCCDDEEFF"}]} + meter = {"InstallationType": "SoC", "RpiSerial": "AABBCCDDEEFF", "Logbook": self.logbook} + return {"Meter": [meter]} async def get_site_id_by_serial(self, serial): return 12345 @@ -40,6 +39,7 @@ async def get_site_statuses(self, serial): return [{"TestInfo": {"Latest": self.test_status}}] async def get_revenue(self, site_id, from_date, to_date): + self.revenue_dates.append((from_date, to_date)) return {"Revenue": self.revenue} async def get_price_zone(self): @@ -52,9 +52,11 @@ async def get_energy_totals(self, meter_ids): return {"Meters": self.meters} async def get_connection_status(self, site_id): - return {} + return self.connection_status async def get_news(self): + if self.news_error: + raise self.news_error return [] @@ -65,8 +67,7 @@ def _coordinator() -> tuple[CheckwattCoordinator, FakeClient]: 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._next_update.clear() coordinator.data = asyncio.run(coordinator._async_update_data()) return coordinator.data @@ -152,3 +153,80 @@ def test_empty_first_response_publishes_nothing(self): coordinator, client = _coordinator() client.meters = [{"Measurements": []}] assert _refresh(coordinator)["total_solar_kwh"] is None + + +class TestSwedishDates: + def test_revenue_uses_swedish_date(self, monkeypatch): + # 22:30 UTC on 30 Sep is already 1 Oct in Sweden. + frozen = datetime(2026, 9, 30, 22, 30, tzinfo=UTC) + + class FrozenDatetime(datetime): + @classmethod + def now(cls, tz=None): + return frozen.astimezone(tz) if tz else frozen.replace(tzinfo=None) + + monkeypatch.setattr(checkwatt, "datetime", FrozenDatetime) + coordinator, client = _coordinator() + _refresh(coordinator) + assert client.revenue_dates == [ + ("2026-10-01", "2026-10-01"), # today + ("2026-10-01", "2026-10-01"), # month to date + ] + + +class TestSlowUpdateRetry: + def test_failed_update_is_retried_soon(self): + coordinator, client = _coordinator() + client.news_error = ConnectionError("news host down") + before = datetime.now(UTC) + _refresh(coordinator) + assert coordinator._next_update["news"] <= before + timedelta(minutes=6) + + def test_successful_update_waits_full_interval(self): + coordinator, _ = _coordinator() + before = datetime.now(UTC) + _refresh(coordinator) + assert coordinator._next_update["news"] >= before + timedelta(hours=4) + + +class TestLogbookSeeding: + def test_newest_line_without_timestamp_still_seeds(self): + coordinator, client = _coordinator() + client.logbook = ( + "[Note] manual comment without a date\n" + "[mfrrup ACTIVATED] 2026-09-01 10:00:00 API-BACKEND\n" + ) + _refresh(coordinator) + assert coordinator._last_logbook_ts == "2026-09-01 10:00:00" + client.logbook = "[mfrrup DEACTIVATE] 2026-09-01 11:00:00 API-BACKEND\n" + client.logbook + data = _refresh(coordinator) + assert [e["timestamp"] for e in data["new_logbook_entries"]] == ["2026-09-01 11:00:00"] + + +def _connection_status(age: timedelta) -> dict: + reported = (datetime.now(UTC) - age).strftime("%Y-%m-%dT%H:%M:%SZ") + blob = {"hello_eth0": True, "uptime_s": 100} + return {"Current": {"Timestamp": reported, "Blob": json.dumps(blob)}} + + +class TestDiagnostics: + def test_recent_report_is_used(self): + coordinator, client = _coordinator() + client.connection_status = _connection_status(timedelta(hours=1)) + assert _refresh(coordinator)["internet_connection"] == "Network cable (LAN1)" + + def test_stale_report_is_unknown(self): + coordinator, client = _coordinator() + client.connection_status = _connection_status(timedelta(minutes=10)) + _refresh(coordinator) + client.connection_status = _connection_status(timedelta(hours=5)) + data = _refresh(coordinator) + assert data["internet_connection"] is None + assert data["cm10_uptime_s"] is None + + def test_missing_report_is_unknown(self): + coordinator, client = _coordinator() + client.connection_status = _connection_status(timedelta(minutes=10)) + _refresh(coordinator) + client.connection_status = {"Current": None} + assert _refresh(coordinator)["internet_connection"] is None diff --git a/tests/test_spot_price.py b/tests/test_spot_price.py index dd19f1b..815c128 100644 --- a/tests/test_spot_price.py +++ b/tests/test_spot_price.py @@ -25,8 +25,32 @@ def test_exact_slot_start(self): def test_mid_slot(self): assert _select_spot_price(PRICES, datetime(2026, 6, 8, 0, 37)) == 1.30 - def test_after_last_slot(self): - assert _select_spot_price(PRICES, datetime(2026, 6, 8, 23, 0)) == 1.40 + def test_within_last_slot(self): + assert _select_spot_price(PRICES, datetime(2026, 6, 8, 0, 59)) == 1.40 + + def test_after_last_slot_is_unknown(self): + # An outdated price list must not show its last price forever. + assert _select_spot_price(PRICES, datetime(2026, 6, 8, 1, 0)) is None + assert _select_spot_price(PRICES, datetime(2026, 6, 8, 23, 0)) is None + + def test_hourly_slots(self): + prices = [ + {"Value": 1.0, "Date": "2026-06-08T22:00:00"}, + {"Value": 2.0, "Date": "2026-06-08T23:00:00"}, + ] + assert _select_spot_price(prices, datetime(2026, 6, 8, 23, 45)) == 2.0 + assert _select_spot_price(prices, datetime(2026, 6, 9, 0, 0)) is None + + def test_entry_without_value_skipped(self): + prices = [ + {"Value": 1.0, "Date": "2026-06-08T00:00:00"}, + {"Date": "2026-06-08T00:15:00"}, + None, + ] + # Previously a missing Value raised KeyError and failed the whole refresh. + assert _select_spot_price(prices, datetime(2026, 6, 8, 0, 10)) == 1.0 + # The 00:15 price is unknown, so it must not fall back to 00:00's. + assert _select_spot_price(prices, datetime(2026, 6, 8, 0, 20)) is None def test_malformed_entries_skipped(self): prices = [