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
168 changes: 98 additions & 70 deletions custom_components/checkwatt/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -16,6 +15,8 @@

from .api import AuthenticationError, CheckwattApiClient
from .const import (
API_TZ,
DIAG_MAX_AGE,
DIAG_UPDATE_INTERVAL,
DOMAIN,
ENERGY_UPDATE_INTERVAL,
Expand All @@ -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:
Expand Down Expand Up @@ -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

# ------------------------------------------------------------------
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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(
Expand All @@ -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:
Expand Down Expand Up @@ -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(
Expand All @@ -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:
Expand All @@ -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

Expand All @@ -418,64 +433,74 @@ 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]
data["logbook_raw"] = raw

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

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:
data["new_news_items"] = new_items
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,
Expand All @@ -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
Expand Down Expand Up @@ -515,3 +541,5 @@ async def _update_energy_totals(self, data: dict) -> None:
type(err).__name__,
err,
)
ok = False
return ok
9 changes: 5 additions & 4 deletions custom_components/checkwatt/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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"),
Expand Down
12 changes: 12 additions & 0 deletions custom_components/checkwatt/const.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Constants for the CheckWatt integration."""

from datetime import timedelta
from zoneinfo import ZoneInfo

from homeassistant.const import Platform

Expand All @@ -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")
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.1",
"version": "1.5.2",
"documentation": "https://github.com/andreasthell/ha-cw",
"issue_tracker": "https://github.com/andreasthell/ha-cw/issues",
"codeowners": ["@andreasthell"],
Expand Down
Loading
Loading