Skip to content

Commit 3d60127

Browse files
committed
feat: add listen_WallConnectorTotals for wall_connector_totals events
1 parent e80662c commit 3d60127

4 files changed

Lines changed: 136 additions & 3 deletions

File tree

‎README.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -366,5 +366,8 @@ Listen for energy site info events. The callback receives the `site_info` docume
366366
### `listen_TariffContentV2(callback: Callable[[dict | None], None]) -> Callable[[],None]`
367367
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.
368368

369+
### `listen_WallConnectorTotals(callback: Callable[[WallConnectorTotalsEvent], None]) -> Callable[[],None]`
370+
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.
371+
369372
### `listen_EnergyTotals(callback: Callable[[EnergyHistoryTotals], None]) -> Callable[[],None]`
370373
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.

‎teslemetry_stream/const.py‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -378,6 +378,7 @@ class SseTopic(StrEnum):
378378
SITE_INFO = "site_info"
379379
TARIFF_CONTENT_V2 = "tariff_content_v2"
380380
ENERGY_TOTALS = "energy_totals"
381+
WALL_CONNECTOR_TOTALS = "wall_connector_totals"
381382
CREDITS = "credits"
382383

383384

@@ -400,6 +401,7 @@ class SseTopic(StrEnum):
400401
SseTopic.SITE_INFO,
401402
SseTopic.TARIFF_CONTENT_V2,
402403
SseTopic.ENERGY_TOTALS,
404+
SseTopic.WALL_CONNECTOR_TOTALS,
403405
)
404406

405407
#: Convenience preset - every account-wide topic.
@@ -491,6 +493,47 @@ def from_dict(cls, data: dict[str, Any]) -> EnergyTotalsEvent:
491493
)
492494

493495

496+
def is_energy_totals(totals: dict[str, Any]) -> bool:
497+
"""Tell an `energy_totals` payload from a `wall_connector_totals` one.
498+
499+
Both events share the `{id, date, createdAt, isCache?, totals}` envelope
500+
and the decoded event carries no topic name, so the `totals` keys decide:
501+
`energy_totals` always carries the fixed `EnergyHistoryTotals` names
502+
(null when the day is empty), while `wall_connector_totals` is keyed by
503+
Wall Connector DIN and may be empty.
504+
"""
505+
return any(field in totals for field in EnergyHistoryTotals.__dataclass_fields__)
506+
507+
508+
@dataclass
509+
class WallConnectorTotalsEvent:
510+
"""A `wall_connector_totals` notification.
511+
512+
`totals` maps Wall Connector DIN to Wh delivered on `date`; a Wall
513+
Connector with no session that day is absent. `date` is the
514+
installation-local day, passed through exactly as sent - never
515+
reinterpreted in the client's own timezone. Key updates by
516+
`(site_id, din, date)` and use `created_at` to reject an older replay.
517+
`is_cache` is `True` only for the connect-time snapshot, which may be
518+
an earlier date.
519+
"""
520+
521+
date: str
522+
created_at: datetime
523+
is_cache: bool
524+
totals: dict[str, float]
525+
526+
@classmethod
527+
def from_dict(cls, data: dict[str, Any]) -> WallConnectorTotalsEvent:
528+
"""Build from the event's top-level dict."""
529+
return cls(
530+
date=data[Key.DATE],
531+
created_at=parse_created_at(data[Key.CREATED_AT]),
532+
is_cache=bool(data.get(Key.IS_CACHE, False)),
533+
totals=dict(data[Key.TOTALS]),
534+
)
535+
536+
494537
@dataclass
495538
class CreditsEvent:
496539
"""A credits accounting event.

‎teslemetry_stream/energysite.py‎

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from collections.abc import Callable
66
from typing import TYPE_CHECKING, Any
77

8-
from .const import EnergyTotalsEvent, Key
8+
from .const import EnergyTotalsEvent, Key, WallConnectorTotalsEvent, is_energy_totals
99

1010
if TYPE_CHECKING:
1111
from .stream import TeslemetryStream
@@ -98,7 +98,28 @@ def listen_EnergyTotals(
9898
wrapping it under `.totals`.
9999
"""
100100
def _handler(x: dict[str, Any]) -> None:
101-
if int(x[Key.ID]) == self.site_id:
101+
if int(x[Key.ID]) == self.site_id and is_energy_totals(x[Key.TOTALS]):
102102
callback(EnergyTotalsEvent.from_dict(x))
103103

104104
return self.stream.async_add_listener(_handler, {Key.TOTALS: None})
105+
106+
def listen_WallConnectorTotals(
107+
self, callback: Callable[[WallConnectorTotalsEvent], None]
108+
) -> Callable[[], None]:
109+
"""Listen for wall_connector_totals notifications.
110+
111+
The callback receives a `WallConnectorTotalsEvent`: `totals` maps
112+
Wall Connector DIN to Wh delivered on `date`, the installation-local
113+
day, verbatim. A Wall Connector with no session that day is absent.
114+
The server delivers a connect-time snapshot (`is_cache: True`, which
115+
may be an earlier date), publishes while any Wall Connector is
116+
charging and once more after charging stops - that closing event
117+
carries the session's final total. Key updates by
118+
`(site_id, din, date)` and use `created_at` to reject an older
119+
replay. Like `energy_totals`, the site id rides `id`, not `site_id`.
120+
"""
121+
def _handler(x: dict[str, Any]) -> None:
122+
if int(x[Key.ID]) == self.site_id and not is_energy_totals(x[Key.TOTALS]):
123+
callback(WallConnectorTotalsEvent.from_dict(x))
124+
125+
return self.stream.async_add_listener(_handler, {Key.TOTALS: None})

‎tests/test_energysite_events.py‎

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,11 @@
1212
from datetime import datetime, timezone
1313
from typing import Any
1414

15-
from teslemetry_stream.const import EnergyHistoryTotals, EnergyTotalsEvent
15+
from teslemetry_stream.const import (
16+
EnergyHistoryTotals,
17+
EnergyTotalsEvent,
18+
WallConnectorTotalsEvent,
19+
)
1620
from teslemetry_stream.stream import TeslemetryStream, recursive_match
1721

1822
SITE_A = "12345"
@@ -75,6 +79,13 @@
7579
"site_id": int(SITE_A),
7680
}
7781

82+
WALL_CONNECTOR_TOTALS_EVENT: dict[str, Any] = {
83+
"id": SITE_A,
84+
"date": "2026-07-29",
85+
"createdAt": "2026-07-29T10:16:00.000Z",
86+
"totals": {"DIN-1": 12500.5, "DIN-2": 800},
87+
}
88+
7889
OTHER_SITE_LIVE_STATUS: dict[str, Any] = {
7990
"createdAt": "2026-07-28T10:16:00.000Z",
8091
"site_id": SITE_B,
@@ -384,6 +395,61 @@ def main() -> None:
384395
)
385396
)
386397

398+
# listen_WallConnectorTotals delivers per-DIN totals with date, created_at, is_cache.
399+
stream = make_stream()
400+
site = stream.get_energysite(SITE_A)
401+
wc_received: list[WallConnectorTotalsEvent] = []
402+
site.listen_WallConnectorTotals(wc_received.append)
403+
dispatch(stream, WALL_CONNECTOR_TOTALS_EVENT)
404+
dispatch(
405+
stream,
406+
{
407+
**WALL_CONNECTOR_TOTALS_EVENT,
408+
"id": int(SITE_A),
409+
"isCache": True,
410+
"date": "2026-07-28",
411+
},
412+
)
413+
dispatch(stream, {**WALL_CONNECTOR_TOTALS_EVENT, "id": SITE_B})
414+
results.append(
415+
check(
416+
"listen_WallConnectorTotals parses totals, matches str/int id, filters other site",
417+
wc_received
418+
== [
419+
WallConnectorTotalsEvent(
420+
date="2026-07-29",
421+
created_at=datetime(2026, 7, 29, 10, 16, 0, tzinfo=timezone.utc),
422+
is_cache=False,
423+
totals={"DIN-1": 12500.5, "DIN-2": 800},
424+
),
425+
WallConnectorTotalsEvent(
426+
date="2026-07-28",
427+
created_at=datetime(2026, 7, 29, 10, 16, 0, tzinfo=timezone.utc),
428+
is_cache=True,
429+
totals={"DIN-1": 12500.5, "DIN-2": 800},
430+
),
431+
],
432+
f"got {wc_received}",
433+
)
434+
)
435+
436+
# The two totals events share an envelope; each listener ignores the other's.
437+
stream = make_stream()
438+
site = stream.get_energysite(SITE_A)
439+
wc_received = []
440+
totals_received = []
441+
site.listen_WallConnectorTotals(wc_received.append)
442+
site.listen_EnergyTotals(totals_received.append)
443+
dispatch(stream, ENERGY_TOTALS_EVENT)
444+
dispatch(stream, {**WALL_CONNECTOR_TOTALS_EVENT, "totals": {}})
445+
results.append(
446+
check(
447+
"energy_totals and wall_connector_totals do not cross-deliver",
448+
len(totals_received) == 1 and len(wc_received) == 1 and wc_received[0].totals == {},
449+
f"energy={totals_received} wc={wc_received}",
450+
)
451+
)
452+
387453
# get_energysite is idempotent per id, mirroring get_vehicle.
388454
stream = make_stream()
389455
results.append(

0 commit comments

Comments
 (0)