diff --git a/README.md b/README.md index 89697cd..a4c6d30 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ Integrationen loggar in **en gång** och återanvänder JWT-token i ~2 timmar. T ### Förutsättningar -- Home Assistant 2024.1 eller senare +- Home Assistant 2024.11 eller senare - [HACS](https://hacs.xyz) installerat ### Steg 1 – Lägg till som custom repository diff --git a/custom_components/checkwatt/__init__.py b/custom_components/checkwatt/__init__.py index ee5c5b9..0865908 100644 --- a/custom_components/checkwatt/__init__.py +++ b/custom_components/checkwatt/__init__.py @@ -4,6 +4,7 @@ import logging from datetime import UTC, date, datetime, timedelta +from zoneinfo import ZoneInfo from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME @@ -26,6 +27,28 @@ _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") + + +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*. + + *now_local* must be naive Swedish local time, matching the slot timestamps. + """ + current: float | None = None + for entry in prices: + try: + slot = datetime.fromisoformat(entry["Date"]) + except (KeyError, TypeError, ValueError): + continue + if slot <= now_local: + current = entry["Value"] + else: + break + return current + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up CheckWatt from a config entry.""" @@ -77,6 +100,9 @@ def __init__( self._rpi_serial: str | None = None self._price_zone: str | None = None + # Cached spot price slots; the current slot is re-selected every cycle. + self._spot_prices: list[dict] = [] + # Meter IDs for energy total sensors, filled from energyflow response. self._solar_ids: list[int] = [] self._charge_ids: list[int] = [] @@ -179,6 +205,13 @@ async def _async_update_data(self) -> dict: await self._update_news(data) self._last_news_update = now + # 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) + ) + data["price_zone"] = self._price_zone + return data except AuthenticationError as err: @@ -248,8 +281,6 @@ def _slow_data(self) -> dict: "total_export_kwh": None, "total_charge_kwh": None, "total_discharge_kwh": None, - "spot_price_sek_kwh": None, - "price_zone": None, } # Note: event signals (cm10_status_changed, new_logbook_entries) are # intentionally NOT carried over — they must only fire once per occurrence. @@ -265,8 +296,6 @@ def _slow_data(self) -> dict: "total_export_kwh", "total_charge_kwh", "total_discharge_kwh", - "spot_price_sek_kwh", - "price_zone", ) } @@ -286,14 +315,16 @@ async def _update_revenue(self, data: dict) -> None: month_resp = await self._client.get_revenue( self._site_id, month_start.isoformat(), today.isoformat() ) + # NetRevenue can be an explicit null for unsettled days. data["monthly_revenue_sek"] = sum( - r.get("NetRevenue", 0) for r in month_resp.get("Revenue", []) + r.get("NetRevenue") or 0 for r in month_resp.get("Revenue", []) ) except Exception as err: _LOGGER.warning("Revenue update failed (%s): %s", type(err).__name__, err) async def _update_spot_price(self, data: dict) -> None: - today = date.today() + # Use Sweden's "today" — the host may be in a different timezone. + today = datetime.now(_PRICE_TZ).date() tomorrow = today + timedelta(days=1) try: if self._price_zone is None: @@ -302,21 +333,7 @@ async def _update_spot_price(self, data: dict) -> None: resp = await self._client.get_spot_prices( self._price_zone, today.isoformat(), tomorrow.isoformat() ) - prices = resp.get("Prices", []) - # Prices are 15-min slots; find the latest slot not after now. - now_local = datetime.now().replace(tzinfo=None) - current: float | None = None - for entry in prices: - try: - slot = datetime.fromisoformat(entry["Date"]) - if slot <= now_local: - current = entry["Value"] - else: - break - except (KeyError, ValueError): - continue - data["spot_price_sek_kwh"] = current - data["price_zone"] = self._price_zone + self._spot_prices = resp.get("Prices", []) except Exception as err: _LOGGER.warning("Spot price update failed (%s): %s", type(err).__name__, err) diff --git a/custom_components/checkwatt/api.py b/custom_components/checkwatt/api.py index f9fbce9..1bab99c 100644 --- a/custom_components/checkwatt/api.py +++ b/custom_components/checkwatt/api.py @@ -138,6 +138,13 @@ async def validate_credentials(self) -> None: def _auth_headers(self) -> dict: return {"authorization": f"Bearer {self._jwt}"} + def _invalidate_jwt_on_401(self, status: int) -> None: + """Drop a server-rejected JWT so the next cycle re-authenticates + instead of retrying the locally-still-valid token until it expires.""" + if status == 401: + self._jwt = None + self._jwt_expiry = None + async def _get(self, path: str, params: dict | list | None = None) -> dict | list: """GET request. Use *params* for query parameters — aiohttp encodes them safely.""" url = f"{BASE_URL}{path}" @@ -148,6 +155,7 @@ async def _get(self, path: str, params: dict | list | None = None) -> dict | lis params=params, timeout=_REQUEST_TIMEOUT, ) as resp: + self._invalidate_jwt_on_401(resp.status) resp.raise_for_status() return await resp.json() except (ClientResponseError, ClientError) as err: @@ -160,6 +168,7 @@ async def _get_text(self, path: str) -> str: async with self._session.get( url, headers=self._auth_headers(), timeout=_REQUEST_TIMEOUT ) as resp: + self._invalidate_jwt_on_401(resp.status) resp.raise_for_status() return (await resp.text()).strip() except (ClientResponseError, ClientError) as err: diff --git a/custom_components/checkwatt/config_flow.py b/custom_components/checkwatt/config_flow.py index 9c4cb04..3098046 100644 --- a/custom_components/checkwatt/config_flow.py +++ b/custom_components/checkwatt/config_flow.py @@ -73,17 +73,26 @@ async def async_step_reauth_confirm( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: errors: dict[str, str] = {} + reauth_entry = self._get_reauth_entry() if user_input is not None: - errors = await _validate(self.hass, user_input) + # Reauth must not silently rebind the entry to a different account: + # the unique_id, device and serial would no longer match. + if user_input[CONF_USERNAME].lower() != reauth_entry.unique_id: + errors["base"] = "account_mismatch" + else: + errors = await _validate(self.hass, user_input) if not errors: return self.async_update_reload_and_abort( - self._get_reauth_entry(), + reauth_entry, data_updates=user_input, ) return self.async_show_form( step_id="reauth_confirm", - data_schema=_STEP_USER_SCHEMA, + data_schema=self.add_suggested_values_to_schema( + _STEP_USER_SCHEMA, + {CONF_USERNAME: reauth_entry.data.get(CONF_USERNAME)}, + ), errors=errors, ) diff --git a/custom_components/checkwatt/event.py b/custom_components/checkwatt/event.py index 1605d63..cb9b7f7 100644 --- a/custom_components/checkwatt/event.py +++ b/custom_components/checkwatt/event.py @@ -82,7 +82,11 @@ def _key(self) -> str: raise NotImplementedError def _handle_coordinator_update(self) -> None: - self._process_update(self.coordinator.data) + # Listeners also run after failed refreshes, where coordinator.data + # still holds the previous payload — skip processing so the same + # event signals don't fire again on every failed cycle. + if self.coordinator.last_update_success: + self._process_update(self.coordinator.data) super()._handle_coordinator_update() def _process_update(self, data: dict) -> None: diff --git a/custom_components/checkwatt/manifest.json b/custom_components/checkwatt/manifest.json index eeaa5a3..f094cd2 100644 --- a/custom_components/checkwatt/manifest.json +++ b/custom_components/checkwatt/manifest.json @@ -1,7 +1,7 @@ { "domain": "checkwatt", "name": "CheckWatt", - "version": "1.4.1", + "version": "1.4.2", "documentation": "https://github.com/andreasthell/ha-cw", "issue_tracker": "https://github.com/andreasthell/ha-cw/issues", "codeowners": ["@andreasthell"], diff --git a/custom_components/checkwatt/strings.json b/custom_components/checkwatt/strings.json index c129f37..e20a331 100644 --- a/custom_components/checkwatt/strings.json +++ b/custom_components/checkwatt/strings.json @@ -21,6 +21,7 @@ "error": { "invalid_auth": "Invalid email address or password.", "cannot_connect": "Could not connect to the CheckWatt API. Check your network connection.", + "account_mismatch": "The email address must match the account this entry was originally set up with.", "unknown": "An unexpected error occurred." }, "abort": { diff --git a/custom_components/checkwatt/translations/en.json b/custom_components/checkwatt/translations/en.json index c129f37..e20a331 100644 --- a/custom_components/checkwatt/translations/en.json +++ b/custom_components/checkwatt/translations/en.json @@ -21,6 +21,7 @@ "error": { "invalid_auth": "Invalid email address or password.", "cannot_connect": "Could not connect to the CheckWatt API. Check your network connection.", + "account_mismatch": "The email address must match the account this entry was originally set up with.", "unknown": "An unexpected error occurred." }, "abort": { diff --git a/custom_components/checkwatt/translations/sv.json b/custom_components/checkwatt/translations/sv.json index 9a82f14..8aa9e17 100644 --- a/custom_components/checkwatt/translations/sv.json +++ b/custom_components/checkwatt/translations/sv.json @@ -21,6 +21,7 @@ "error": { "invalid_auth": "Felaktig e-postadress eller lösenord.", "cannot_connect": "Kunde inte ansluta till CheckWatt-API:et. Kontrollera nätverksanslutningen.", + "account_mismatch": "E-postadressen måste vara samma som kontot ursprungligen konfigurerades med.", "unknown": "Ett oväntat fel uppstod." }, "abort": { diff --git a/hacs.json b/hacs.json index ccf513f..9719008 100644 --- a/hacs.json +++ b/hacs.json @@ -1,5 +1,5 @@ { "name": "CheckWatt", "render_readme": true, - "homeassistant": "2024.1.0" + "homeassistant": "2024.11.0" } diff --git a/tests/test_spot_price.py b/tests/test_spot_price.py new file mode 100644 index 0000000..dd19f1b --- /dev/null +++ b/tests/test_spot_price.py @@ -0,0 +1,38 @@ +"""Tests for the spot price slot selection logic.""" + +from datetime import datetime + +from custom_components.checkwatt import _select_spot_price + +PRICES = [ + {"Value": 1.10, "Date": "2026-06-08T00:00:00.000"}, + {"Value": 1.20, "Date": "2026-06-08T00:15:00.000"}, + {"Value": 1.30, "Date": "2026-06-08T00:30:00.000"}, + {"Value": 1.40, "Date": "2026-06-08T00:45:00.000"}, +] + + +class TestSelectSpotPrice: + def test_empty_list(self): + assert _select_spot_price([], datetime(2026, 6, 8, 12, 0)) is None + + def test_before_first_slot(self): + assert _select_spot_price(PRICES, datetime(2026, 6, 7, 23, 59)) is None + + def test_exact_slot_start(self): + assert _select_spot_price(PRICES, datetime(2026, 6, 8, 0, 15)) == 1.20 + + 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_malformed_entries_skipped(self): + prices = [ + {"Value": 1.0}, # no Date + {"Value": 2.0, "Date": "not-a-date"}, + {"Value": 3.0, "Date": None}, + {"Value": 4.0, "Date": "2026-06-08T00:00:00.000"}, + ] + assert _select_spot_price(prices, datetime(2026, 6, 8, 0, 5)) == 4.0