From 35e132f2bbc0ddb823d73aaf1acd859bdae77e5e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 10:03:18 +0000 Subject: [PATCH 1/2] Fix timezone, stale spot price and diagnostics, JWT decoding and retries - Revenue dates and the energy totals' year use Swedish local time (Europe/Stockholm) like the spot price already did, instead of the host timezone. HA does not set the process timezone, so a UTC container got yesterday's revenue between 00:00 and 02:00. - Spot price: a slot is only valid until the next one starts (the last one as long as the one before it), so an outdated price list yields unknown instead of its last price forever. Entries without a Value are skipped instead of raising KeyError and failing the whole refresh. - JWT expiry is decoded with urlsafe_b64decode. b64decode dropped "-" and "_", so payloads containing them always fell back to 10 minutes. - The logbook is seeded with the newest timestamp among its entries, so a newest line without a timestamp no longer disables logbook events. - CM10 diagnostics older than 3 hours, or missing, are reported as unknown instead of showing the last connection state indefinitely. - A failed slow update is retried after 5 minutes instead of waiting its full interval (up to 4 hours for news). The six last-update timestamps are replaced by one next-due map. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Tt2YacbPwiWKinpfhCss29 --- custom_components/checkwatt/__init__.py | 168 ++++++++++++++---------- custom_components/checkwatt/api.py | 9 +- custom_components/checkwatt/const.py | 12 ++ tests/test_api.py | 29 ++++ tests/test_coordinator.py | 104 +++++++++++++-- tests/test_spot_price.py | 28 +++- 6 files changed, 261 insertions(+), 89 deletions(-) create mode 100644 tests/test_api.py 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/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 = [ From 733078997d3303773c19cf1e0c51235d49a772cc Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 10:03:18 +0000 Subject: [PATCH 2/2] Bump version to 1.5.2 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 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"],