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
75 changes: 75 additions & 0 deletions custom_components/checkwatt/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from __future__ import annotations

import json
import logging
from datetime import UTC, date, datetime, timedelta
from zoneinfo import ZoneInfo
Expand All @@ -15,6 +16,7 @@

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

Expand Down Expand Up @@ -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,
Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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",
)
}

Expand Down Expand Up @@ -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:
Expand Down
17 changes: 17 additions & 0 deletions custom_components/checkwatt/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions custom_components/checkwatt/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
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.3",
"version": "1.5.0",
"documentation": "https://github.com/andreasthell/ha-cw",
"issue_tracker": "https://github.com/andreasthell/ha-cw/issues",
"codeowners": ["@andreasthell"],
Expand Down
50 changes: 50 additions & 0 deletions custom_components/checkwatt/sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
PERCENTAGE,
UnitOfEnergy,
UnitOfPower,
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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
5 changes: 5 additions & 0 deletions custom_components/checkwatt/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
Expand Down
5 changes: 5 additions & 0 deletions custom_components/checkwatt/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
Expand Down
5 changes: 5 additions & 0 deletions custom_components/checkwatt/translations/sv.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
Expand Down
73 changes: 73 additions & 0 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
Loading
Loading