diff --git a/custom_components/checkwatt/__init__.py b/custom_components/checkwatt/__init__.py index 0865908..c0ff685 100644 --- a/custom_components/checkwatt/__init__.py +++ b/custom_components/checkwatt/__init__.py @@ -2,6 +2,7 @@ from __future__ import annotations +import json import logging from datetime import UTC, date, datetime, timedelta from zoneinfo import ZoneInfo @@ -15,6 +16,7 @@ from .api import AuthenticationError, CheckwattApiClient from .const import ( + DIAG_UPDATE_INTERVAL, DOMAIN, ENERGY_UPDATE_INTERVAL, LOGBOOK_UPDATE_INTERVAL, @@ -50,6 +52,43 @@ def _select_spot_price(prices: list[dict], now_local: datetime) -> float | None: return current +def _parse_diag_blob(blob: dict) -> dict: + """Extract HA-relevant values from a connectionStatus Current.Blob. + + Battery temperatures come from the inverter stats; with several inverters + the highest high and lowest low are reported, matching the EIB UI. + """ + temps_h: list[float] = [] + temps_l: list[float] = [] + inverter_stats = (blob.get("topics") or {}).get("ems/inverter_stat") or {} + for inverters in inverter_stats.values(): + if not isinstance(inverters, list): + continue + for inv in inverters: + if not isinstance(inv, dict): + continue + if inv.get("temp_h") is not None: + temps_h.append(inv["temp_h"]) + if inv.get("temp_l") is not None: + temps_l.append(inv["temp_l"]) + + default_route = blob.get("default_route") or [] + if blob.get("hello_eth0"): + connection = "Network cable (LAN1)" + elif "ppp0" in default_route: + connection = "Mobile internet (4G)" + else: + connection = "No internet" + + return { + "battery_temp_high_c": max(temps_h) if temps_h else None, + "battery_temp_low_c": min(temps_l) if temps_l else None, + "internet_connection": connection, + "cm10_uptime_s": blob.get("uptime_s"), + "default_route": default_route, + } + + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up CheckWatt from a config entry.""" # H2: dedicated session with explicit SSL verification. @@ -121,6 +160,7 @@ def __init__( 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 self._last_news_ts: str | None = None @@ -156,6 +196,12 @@ async def _async_update_data(self) -> dict: if new_test_status is not None: self._last_test_status = new_test_status + # Available power the battery can offer CheckWatt right now + # (the EIB "Available power" panel). + related_meters = { + m.get("Type"): m.get("PeakAcKw") for m in status.get("RelatedMeters") or [] + } + data: dict = { # Identity "site_id": self._site_id, @@ -172,6 +218,8 @@ async def _async_update_data(self) -> dict: "operation_preference": status.get("OperationPreference"), "fp_up_kw": status.get("FpUpInKw"), "fp_down_kw": status.get("FpDownInKw"), + "available_charge_kw": related_meters.get("Charging"), + "available_discharge_kw": related_meters.get("Discharging"), "test_info": test_info, "logbook_raw": self._logbook_raw, # Event signals — reset each cycle, set below if triggered. @@ -201,6 +249,10 @@ async def _async_update_data(self) -> dict: 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 @@ -281,6 +333,11 @@ def _slow_data(self) -> dict: "total_export_kwh": None, "total_charge_kwh": None, "total_discharge_kwh": None, + "battery_temp_high_c": None, + "battery_temp_low_c": None, + "internet_connection": None, + "cm10_uptime_s": None, + "default_route": None, } # Note: event signals (cm10_status_changed, new_logbook_entries) are # intentionally NOT carried over — they must only fire once per occurrence. @@ -296,6 +353,11 @@ def _slow_data(self) -> dict: "total_export_kwh", "total_charge_kwh", "total_discharge_kwh", + "battery_temp_high_c", + "battery_temp_low_c", + "internet_connection", + "cm10_uptime_s", + "default_route", ) } @@ -369,6 +431,19 @@ async def _update_logbook(self, data: dict) -> None: except Exception as err: _LOGGER.warning("Logbook update failed (%s): %s", type(err).__name__, err) + async def _update_diagnostics(self, data: dict) -> None: + """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) + if isinstance(blob, dict): + data.update(_parse_diag_blob(blob)) + except Exception as err: + _LOGGER.warning("Diagnostics update failed (%s): %s", type(err).__name__, err) + async def _update_news(self, data: dict) -> None: """Fetch news and detect items published since the last check.""" try: diff --git a/custom_components/checkwatt/api.py b/custom_components/checkwatt/api.py index 02038e2..8c8d48a 100644 --- a/custom_components/checkwatt/api.py +++ b/custom_components/checkwatt/api.py @@ -225,6 +225,23 @@ async def get_news(self) -> list: except (ClientResponseError, ClientError) as err: raise ConnectionError(f"News request failed: {type(err).__name__}") from err + async def get_connection_status(self, site_id: int) -> dict: + """Fetch CM10 diagnostics: connectivity, uptime and inverter temperatures. + + New endpoint 2026-09, backs the EIB "Internet connection" and + "Battery temperature" panels. The interesting data lives in + Current.Blob, a JSON-encoded string. + """ + now = datetime.now(UTC) + fmt = "%Y-%m-%dT%H:%M:%S.000Z" + return await self._get( + f"/diag/{site_id}/connectionStatus", + params={ + "from": (now - timedelta(minutes=5)).strftime(fmt), + "to": now.strftime(fmt), + }, + ) + 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 diff --git a/custom_components/checkwatt/const.py b/custom_components/checkwatt/const.py index 700c2f8..45538ec 100644 --- a/custom_components/checkwatt/const.py +++ b/custom_components/checkwatt/const.py @@ -12,4 +12,5 @@ PRICE_UPDATE_INTERVAL = timedelta(minutes=60) ENERGY_UPDATE_INTERVAL = timedelta(minutes=15) LOGBOOK_UPDATE_INTERVAL = timedelta(minutes=30) +DIAG_UPDATE_INTERVAL = timedelta(minutes=5) NEWS_UPDATE_INTERVAL = timedelta(hours=4) diff --git a/custom_components/checkwatt/manifest.json b/custom_components/checkwatt/manifest.json index e706170..6fd31e7 100644 --- a/custom_components/checkwatt/manifest.json +++ b/custom_components/checkwatt/manifest.json @@ -1,7 +1,7 @@ { "domain": "checkwatt", "name": "CheckWatt", - "version": "1.4.3", + "version": "1.5.0", "documentation": "https://github.com/andreasthell/ha-cw", "issue_tracker": "https://github.com/andreasthell/ha-cw/issues", "codeowners": ["@andreasthell"], diff --git a/custom_components/checkwatt/sensor.py b/custom_components/checkwatt/sensor.py index 29624ae..bb82e67 100644 --- a/custom_components/checkwatt/sensor.py +++ b/custom_components/checkwatt/sensor.py @@ -18,6 +18,7 @@ PERCENTAGE, UnitOfEnergy, UnitOfPower, + UnitOfTemperature, ) from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo @@ -164,6 +165,44 @@ def _parse_dt(value: str | None) -> datetime | None: suggested_display_precision=0, value_fn=lambda d: d.get("battery_soc_pct"), ), + # ---- Available power (EIB "Available power" panel) ---------------------- + CheckwattSensorDescription( + key="available_charge_kw", + translation_key="available_charge_power", + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPower.KILO_WATT, + suggested_display_precision=1, + value_fn=lambda d: d.get("available_charge_kw"), + ), + CheckwattSensorDescription( + key="available_discharge_kw", + translation_key="available_discharge_power", + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPower.KILO_WATT, + suggested_display_precision=1, + value_fn=lambda d: d.get("available_discharge_kw"), + ), + # ---- Battery temperature ------------------------------------------------ + CheckwattSensorDescription( + key="battery_temp_high_c", + translation_key="battery_temp_high", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + suggested_display_precision=1, + value_fn=lambda d: d.get("battery_temp_high_c"), + ), + CheckwattSensorDescription( + key="battery_temp_low_c", + translation_key="battery_temp_low", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + suggested_display_precision=1, + value_fn=lambda d: d.get("battery_temp_low_c"), + ), # ---- Revenue ------------------------------------------------------------ CheckwattSensorDescription( key="today_revenue_sek", @@ -293,6 +332,12 @@ def _parse_dt(value: str | None) -> datetime | None: icon="mdi:notebook-outline", value_fn=lambda d: _logbook_state(d.get("logbook_raw")), ), + CheckwattSensorDescription( + key="internet_connection", + translation_key="internet_connection", + icon="mdi:lan", + value_fn=lambda d: d.get("internet_connection"), + ), CheckwattSensorDescription( key="firmware_version", translation_key="firmware_version", @@ -366,4 +411,9 @@ def extra_state_attributes(self) -> dict | None: for e in entries[:5] ] return {"recent_entries": recent} + if key == "internet_connection": + return { + "uptime_s": self.coordinator.data.get("cm10_uptime_s"), + "default_route": self.coordinator.data.get("default_route"), + } return None diff --git a/custom_components/checkwatt/strings.json b/custom_components/checkwatt/strings.json index e20a331..2657857 100644 --- a/custom_components/checkwatt/strings.json +++ b/custom_components/checkwatt/strings.json @@ -44,6 +44,11 @@ "total_grid_export": {"name": "Total Grid Export"}, "total_battery_charge": {"name": "Total Battery Charge"}, "total_battery_discharge": {"name": "Total Battery Discharge"}, + "available_charge_power": {"name": "Available Charge Power"}, + "available_discharge_power": {"name": "Available Discharge Power"}, + "battery_temp_high": {"name": "Battery Temperature High"}, + "battery_temp_low": {"name": "Battery Temperature Low"}, + "internet_connection": {"name": "Internet Connection"}, "cm10_status": {"name": "CM10 Status"}, "last_seen_cm10": {"name": "CM10 Last Seen"}, "last_seen_inverter": {"name": "Inverter Last Seen"}, diff --git a/custom_components/checkwatt/translations/en.json b/custom_components/checkwatt/translations/en.json index e20a331..2657857 100644 --- a/custom_components/checkwatt/translations/en.json +++ b/custom_components/checkwatt/translations/en.json @@ -44,6 +44,11 @@ "total_grid_export": {"name": "Total Grid Export"}, "total_battery_charge": {"name": "Total Battery Charge"}, "total_battery_discharge": {"name": "Total Battery Discharge"}, + "available_charge_power": {"name": "Available Charge Power"}, + "available_discharge_power": {"name": "Available Discharge Power"}, + "battery_temp_high": {"name": "Battery Temperature High"}, + "battery_temp_low": {"name": "Battery Temperature Low"}, + "internet_connection": {"name": "Internet Connection"}, "cm10_status": {"name": "CM10 Status"}, "last_seen_cm10": {"name": "CM10 Last Seen"}, "last_seen_inverter": {"name": "Inverter Last Seen"}, diff --git a/custom_components/checkwatt/translations/sv.json b/custom_components/checkwatt/translations/sv.json index 8aa9e17..465674f 100644 --- a/custom_components/checkwatt/translations/sv.json +++ b/custom_components/checkwatt/translations/sv.json @@ -44,6 +44,11 @@ "total_grid_export": {"name": "Total nätexport"}, "total_battery_charge": {"name": "Total batteriladdning"}, "total_battery_discharge": {"name": "Total batteriurladdning"}, + "available_charge_power": {"name": "Tillgänglig laddeffekt"}, + "available_discharge_power": {"name": "Tillgänglig urladdningseffekt"}, + "battery_temp_high": {"name": "Batteritemperatur högsta"}, + "battery_temp_low": {"name": "Batteritemperatur lägsta"}, + "internet_connection": {"name": "Internetanslutning"}, "cm10_status": {"name": "CM10 Status"}, "last_seen_cm10": {"name": "CM10 senast sedd"}, "last_seen_inverter": {"name": "Växelriktare senast sedd"}, diff --git a/docs/api.md b/docs/api.md index e33e688..d74c074 100644 --- a/docs/api.md +++ b/docs/api.md @@ -825,6 +825,78 @@ Returns pending EMS configuration changes. Response is an array of service strin --- +## Diagnostics + +### Get connection status (new 2026-09) + +``` +GET /diag/{site_id}/connectionStatus?from={iso_utc}&to={iso_utc} +Authorization: Bearer {jwt_token} +``` + +Backs the EIB "Internet connection" and "Battery temperature" panels. The web +app queries a 5-minute window ending now (ISO 8601 UTC with milliseconds, e.g. +`2026-09-16T09:20:46.097Z`). + +**Response 200:** +```json +{ + "SiteId": 12345, + "From": "2026-09-16T09:20:46.097Z", + "To": "2026-09-16T09:25:46.097Z", + "Current": {"Timestamp": "2026-09-16T08:13:35Z", "Blob": "{...json string...}"}, + "History": [] +} +``` + +`Current.Blob` is a **JSON-encoded string** with CM10 diagnostics: + +```json +{ + "eth": {"eth0": {"rx_packets": 4527, "carrier_changes": 0}, "eth1": {}, "win_s": 4197}, + "topics": { + "ems/inverter_stat": { + "goodwe": [ + { + "id": "192.168.5.128", + "soc": 85.0, + "model": "GW10KN-ET", + "serial": "...", + "fw_ver": "master:13-slave:13 /arm:33", + "temp_h": 36.4, + "temp_l": 20.8, + "pv_power": 0.0, + "battery_power": -12.0, + "set_point": 0.0 + } + ] + }, + "ems/datastream_energyPv": 0.0, + "ems/datastream_energyImport": 186.0, + "ems/datastream_energyDischarge": -12.0 + }, + "screens": ["15814.sender", "15811.goodwe", "..."], + "arp_eth1": [{"ip": "192.168.5.128", "mac": "...", "state": ["REACHABLE"]}], + "uptime_s": 1114187, + "linux_ips": [{"ip": "...", "iface": "eth0"}], + "hello_eth0": true, + "modem_stat": {"ts": "2026-09-16 04:05:04", "status": "modem"}, + "default_route": ["ppp0", "eth0"] +} +``` + +Key fields: +- `topics."ems/inverter_stat".{vendor}[].temp_h` / `temp_l` – battery temperature highest/lowest (°C) +- `hello_eth0` – LAN1 internet check succeeded (EIB shows "Primary – Network cable (LAN1): Connected") +- `default_route` – active route interfaces (`eth0` = LAN1, `ppp0` = 4G modem) +- `uptime_s` – CM10 uptime in seconds +- `eth1` / `arp_eth1` – the inverter-facing LAN port + +The "Available power" panel (Can charge / Can discharge) comes from +`RelatedMeters[].PeakAcKw` in `/site/Statuses`, not from this endpoint. + +--- + ## Misc ### Get energy providers list @@ -849,6 +921,7 @@ No auth required. Returns all energy retailers across SE/NO/DK/FI with Id and Di | Monthly revenue | 15 min | `/revenue/{siteId}` | | Spot price | 60 min (or at :00 when tomorrow's prices arrive ~13:00) | `/ems/spotprice` | | Site status / CM10 seen | 5 min | `/site/Statuses` | +| Diagnostics (battery temp, connectivity) | 5 min | `/diag/{siteId}/connectionStatus` | | Site details / tariff | On setup + daily | `/site/{siteId}`, `/Tariff/{id}` | ### Token lifecycle diff --git a/tests/conftest.py b/tests/conftest.py index 23b36eb..ea30359 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -60,6 +60,7 @@ def _enum_stub(name: str, *values: str): "MONETARY", "ENERGY", "TIMESTAMP", + "TEMPERATURE", ), SensorEntity=object, SensorEntityDescription=_SensorEntityDescription, @@ -70,7 +71,8 @@ def _enum_stub(name: str, *values: str): "homeassistant.const", PERCENTAGE="PERCENTAGE", UnitOfEnergy=type("UnitOfEnergy", (), {"KILO_WATT_HOUR": "kWh"}), - UnitOfPower=type("UnitOfPower", (), {"WATT": "W"}), + UnitOfPower=type("UnitOfPower", (), {"WATT": "W", "KILO_WATT": "kW"}), + UnitOfTemperature=type("UnitOfTemperature", (), {"CELSIUS": "°C"}), CONF_USERNAME="username", CONF_PASSWORD="password", Platform=type("Platform", (), {"SENSOR": "sensor", "EVENT": "event"}), diff --git a/tests/test_diagnostics.py b/tests/test_diagnostics.py new file mode 100644 index 0000000..b455940 --- /dev/null +++ b/tests/test_diagnostics.py @@ -0,0 +1,85 @@ +"""Tests for connectionStatus diagnostics blob parsing.""" + +from custom_components.checkwatt import _parse_diag_blob + + +def _blob(**overrides) -> dict: + """A realistic Current.Blob dict, shaped like the 2026-09 API response.""" + blob = { + "eth": {"eth0": {"rx_packets": 4527}, "eth1": {"rx_packets": 61803}}, + "topics": { + "ems/inverter_stat": { + "goodwe": [ + { + "id": "192.168.5.128", + "soc": 85.0, + "model": "GW10KN-ET ", + "temp_h": 36.4, + "temp_l": 20.8, + "pv_power": 0.0, + "battery_power": -12.0, + } + ] + }, + "ems/datastream_energyPv": 0.0, + }, + "uptime_s": 1114187, + "hello_eth0": True, + "modem_stat": {"ts": "2026-09-16 04:05:04", "status": "modem"}, + "default_route": ["ppp0", "eth0"], + } + blob.update(overrides) + return blob + + +class TestParseDiagBlob: + def test_battery_temperatures(self): + result = _parse_diag_blob(_blob()) + assert result["battery_temp_high_c"] == 36.4 + assert result["battery_temp_low_c"] == 20.8 + + def test_multiple_inverters_take_extremes(self): + blob = _blob() + blob["topics"]["ems/inverter_stat"]["goodwe"].append({"temp_h": 40.1, "temp_l": 25.0}) + result = _parse_diag_blob(blob) + assert result["battery_temp_high_c"] == 40.1 + assert result["battery_temp_low_c"] == 20.8 + + def test_no_inverter_stats(self): + result = _parse_diag_blob(_blob(topics={})) + assert result["battery_temp_high_c"] is None + assert result["battery_temp_low_c"] is None + + def test_missing_temps_ignored(self): + blob = _blob() + blob["topics"]["ems/inverter_stat"]["goodwe"] = [{"temp_h": None, "temp_l": None}] + result = _parse_diag_blob(blob) + assert result["battery_temp_high_c"] is None + assert result["battery_temp_low_c"] is None + + def test_lan_connected(self): + result = _parse_diag_blob(_blob()) + assert result["internet_connection"] == "Network cable (LAN1)" + + def test_modem_fallback(self): + result = _parse_diag_blob(_blob(hello_eth0=False)) + assert result["internet_connection"] == "Mobile internet (4G)" + + def test_no_internet(self): + result = _parse_diag_blob(_blob(hello_eth0=False, default_route=["eth0"])) + assert result["internet_connection"] == "No internet" + + def test_no_internet_when_routes_missing(self): + result = _parse_diag_blob(_blob(hello_eth0=False, default_route=None)) + assert result["internet_connection"] == "No internet" + + def test_uptime_and_routes(self): + result = _parse_diag_blob(_blob()) + assert result["cm10_uptime_s"] == 1114187 + assert result["default_route"] == ["ppp0", "eth0"] + + def test_empty_blob(self): + result = _parse_diag_blob({}) + assert result["battery_temp_high_c"] is None + assert result["internet_connection"] == "No internet" + assert result["cm10_uptime_s"] is None