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
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
43 changes: 43 additions & 0 deletions teslemetry_stream/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"


Expand All @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
25 changes: 23 additions & 2 deletions teslemetry_stream/energysite.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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})
68 changes: 67 additions & 1 deletion tests/test_energysite_events.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down
Loading