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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
59 changes: 38 additions & 21 deletions custom_components/checkwatt/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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."""
Expand Down Expand Up @@ -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] = []
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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.
Expand All @@ -265,8 +296,6 @@ def _slow_data(self) -> dict:
"total_export_kwh",
"total_charge_kwh",
"total_discharge_kwh",
"spot_price_sek_kwh",
"price_zone",
)
}

Expand All @@ -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:
Expand All @@ -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)

Expand Down
9 changes: 9 additions & 0 deletions custom_components/checkwatt/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
Expand All @@ -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:
Expand All @@ -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:
Expand Down
15 changes: 12 additions & 3 deletions custom_components/checkwatt/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
6 changes: 5 additions & 1 deletion custom_components/checkwatt/event.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
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.4.1",
"version": "1.4.2",
"documentation": "https://github.com/andreasthell/ha-cw",
"issue_tracker": "https://github.com/andreasthell/ha-cw/issues",
"codeowners": ["@andreasthell"],
Expand Down
1 change: 1 addition & 0 deletions custom_components/checkwatt/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
1 change: 1 addition & 0 deletions custom_components/checkwatt/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
1 change: 1 addition & 0 deletions custom_components/checkwatt/translations/sv.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
2 changes: 1 addition & 1 deletion hacs.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"name": "CheckWatt",
"render_readme": true,
"homeassistant": "2024.1.0"
"homeassistant": "2024.11.0"
}
38 changes: 38 additions & 0 deletions tests/test_spot_price.py
Original file line number Diff line number Diff line change
@@ -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
Loading