diff --git a/README.md b/README.md index 04a4bcc..d559926 100644 --- a/README.md +++ b/README.md @@ -366,5 +366,8 @@ Listen for energy site info events. The callback receives the `site_info` docume ### `listen_TariffContentV2(callback: Callable[[dict | None], None]) -> Callable[[],None]` Listen for the site's V2 tariff document. The callback receives the `tariff_content_v2` document verbatim, or `None` when the server sends an explicit removal signal. Published only when it changes. +### `listen_WallConnectorTotals(callback: Callable[[WallConnectorTotalsEvent], None]) -> Callable[[],None]` +Listen for `wall_connector_totals` notifications. The callback receives a `WallConnectorTotalsEvent` with `date` (the installation-local day, verbatim), `created_at`, `is_cache` and `totals`, a dict of Wall Connector DIN to Wh delivered on `date` (a Wall Connector with no session that day is absent). Published while any Wall Connector is charging and once more after charging stops; a connect-time snapshot has `is_cache=True` and may be an earlier date. Key updates by `(site id, din, date)` and use `created_at` to reject an older replay. + ### `listen_EnergyTotals(callback: Callable[[EnergyHistoryTotals], None]) -> Callable[[],None]` Listen for `energy_totals` refresh notifications. The callback receives an `EnergyHistoryTotals` dataclass of cumulative per-type totals - never the full time series. Fires only when the server's periodic history poll detects a change; a consumer wanting the full series should GET the underlying event's `url` via their own REST client. diff --git a/teslemetry_stream/const.py b/teslemetry_stream/const.py index df05b95..bc46373 100644 --- a/teslemetry_stream/const.py +++ b/teslemetry_stream/const.py @@ -378,6 +378,7 @@ class SseTopic(StrEnum): SITE_INFO = "site_info" TARIFF_CONTENT_V2 = "tariff_content_v2" ENERGY_TOTALS = "energy_totals" + WALL_CONNECTOR_TOTALS = "wall_connector_totals" CREDITS = "credits" @@ -400,6 +401,7 @@ class SseTopic(StrEnum): SseTopic.SITE_INFO, SseTopic.TARIFF_CONTENT_V2, SseTopic.ENERGY_TOTALS, + SseTopic.WALL_CONNECTOR_TOTALS, ) #: Convenience preset - every account-wide topic. @@ -491,6 +493,47 @@ def from_dict(cls, data: dict[str, Any]) -> EnergyTotalsEvent: ) +def is_energy_totals(totals: dict[str, Any]) -> bool: + """Tell an `energy_totals` payload from a `wall_connector_totals` one. + + Both events share the `{id, date, createdAt, isCache?, totals}` envelope + and the decoded event carries no topic name, so the `totals` keys decide: + `energy_totals` always carries the fixed `EnergyHistoryTotals` names + (null when the day is empty), while `wall_connector_totals` is keyed by + Wall Connector DIN and may be empty. + """ + return any(field in totals for field in EnergyHistoryTotals.__dataclass_fields__) + + +@dataclass +class WallConnectorTotalsEvent: + """A `wall_connector_totals` notification. + + `totals` maps Wall Connector DIN to Wh delivered on `date`; a Wall + Connector with no session that day is absent. `date` is the + installation-local day, passed through exactly as sent - never + reinterpreted in the client's own timezone. Key updates by + `(site_id, din, date)` and use `created_at` to reject an older replay. + `is_cache` is `True` only for the connect-time snapshot, which may be + an earlier date. + """ + + date: str + created_at: datetime + is_cache: bool + totals: dict[str, float] + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> WallConnectorTotalsEvent: + """Build from the event's top-level dict.""" + return cls( + date=data[Key.DATE], + created_at=parse_created_at(data[Key.CREATED_AT]), + is_cache=bool(data.get(Key.IS_CACHE, False)), + totals=dict(data[Key.TOTALS]), + ) + + @dataclass class CreditsEvent: """A credits accounting event. diff --git a/teslemetry_stream/energysite.py b/teslemetry_stream/energysite.py index 69f8dfd..d2bc420 100644 --- a/teslemetry_stream/energysite.py +++ b/teslemetry_stream/energysite.py @@ -5,7 +5,7 @@ from collections.abc import Callable from typing import TYPE_CHECKING, Any -from .const import EnergyTotalsEvent, Key +from .const import EnergyTotalsEvent, Key, WallConnectorTotalsEvent, is_energy_totals if TYPE_CHECKING: from .stream import TeslemetryStream @@ -98,7 +98,28 @@ def listen_EnergyTotals( wrapping it under `.totals`. """ def _handler(x: dict[str, Any]) -> None: - if int(x[Key.ID]) == self.site_id: + if int(x[Key.ID]) == self.site_id and is_energy_totals(x[Key.TOTALS]): callback(EnergyTotalsEvent.from_dict(x)) return self.stream.async_add_listener(_handler, {Key.TOTALS: None}) + + def listen_WallConnectorTotals( + self, callback: Callable[[WallConnectorTotalsEvent], None] + ) -> Callable[[], None]: + """Listen for wall_connector_totals notifications. + + The callback receives a `WallConnectorTotalsEvent`: `totals` maps + Wall Connector DIN to Wh delivered on `date`, the installation-local + day, verbatim. A Wall Connector with no session that day is absent. + The server delivers a connect-time snapshot (`is_cache: True`, which + may be an earlier date), publishes while any Wall Connector is + charging and once more after charging stops - that closing event + carries the session's final total. Key updates by + `(site_id, din, date)` and use `created_at` to reject an older + replay. Like `energy_totals`, the site id rides `id`, not `site_id`. + """ + def _handler(x: dict[str, Any]) -> None: + if int(x[Key.ID]) == self.site_id and not is_energy_totals(x[Key.TOTALS]): + callback(WallConnectorTotalsEvent.from_dict(x)) + + return self.stream.async_add_listener(_handler, {Key.TOTALS: None}) diff --git a/tests/test_energysite_events.py b/tests/test_energysite_events.py index 982070f..2bd778f 100644 --- a/tests/test_energysite_events.py +++ b/tests/test_energysite_events.py @@ -12,7 +12,11 @@ from datetime import datetime, timezone from typing import Any -from teslemetry_stream.const import EnergyHistoryTotals, EnergyTotalsEvent +from teslemetry_stream.const import ( + EnergyHistoryTotals, + EnergyTotalsEvent, + WallConnectorTotalsEvent, +) from teslemetry_stream.stream import TeslemetryStream, recursive_match SITE_A = "12345" @@ -75,6 +79,13 @@ "site_id": int(SITE_A), } +WALL_CONNECTOR_TOTALS_EVENT: dict[str, Any] = { + "id": SITE_A, + "date": "2026-07-29", + "createdAt": "2026-07-29T10:16:00.000Z", + "totals": {"DIN-1": 12500.5, "DIN-2": 800}, +} + OTHER_SITE_LIVE_STATUS: dict[str, Any] = { "createdAt": "2026-07-28T10:16:00.000Z", "site_id": SITE_B, @@ -384,6 +395,61 @@ def main() -> None: ) ) + # listen_WallConnectorTotals delivers per-DIN totals with date, created_at, is_cache. + stream = make_stream() + site = stream.get_energysite(SITE_A) + wc_received: list[WallConnectorTotalsEvent] = [] + site.listen_WallConnectorTotals(wc_received.append) + dispatch(stream, WALL_CONNECTOR_TOTALS_EVENT) + dispatch( + stream, + { + **WALL_CONNECTOR_TOTALS_EVENT, + "id": int(SITE_A), + "isCache": True, + "date": "2026-07-28", + }, + ) + dispatch(stream, {**WALL_CONNECTOR_TOTALS_EVENT, "id": SITE_B}) + results.append( + check( + "listen_WallConnectorTotals parses totals, matches str/int id, filters other site", + wc_received + == [ + WallConnectorTotalsEvent( + date="2026-07-29", + created_at=datetime(2026, 7, 29, 10, 16, 0, tzinfo=timezone.utc), + is_cache=False, + totals={"DIN-1": 12500.5, "DIN-2": 800}, + ), + WallConnectorTotalsEvent( + date="2026-07-28", + created_at=datetime(2026, 7, 29, 10, 16, 0, tzinfo=timezone.utc), + is_cache=True, + totals={"DIN-1": 12500.5, "DIN-2": 800}, + ), + ], + f"got {wc_received}", + ) + ) + + # The two totals events share an envelope; each listener ignores the other's. + stream = make_stream() + site = stream.get_energysite(SITE_A) + wc_received = [] + totals_received = [] + site.listen_WallConnectorTotals(wc_received.append) + site.listen_EnergyTotals(totals_received.append) + dispatch(stream, ENERGY_TOTALS_EVENT) + dispatch(stream, {**WALL_CONNECTOR_TOTALS_EVENT, "totals": {}}) + results.append( + check( + "energy_totals and wall_connector_totals do not cross-deliver", + len(totals_received) == 1 and len(wc_received) == 1 and wc_received[0].totals == {}, + f"energy={totals_received} wc={wc_received}", + ) + ) + # get_energysite is idempotent per id, mirroring get_vehicle. stream = make_stream() results.append(