Skip to content

Commit cb9bee3

Browse files
committed
feat: add listen_ActiveRoute* listeners that clear when navigation ends
Port of the Home Assistant Teslemetry listen_active_route helper. chore: bump version to 1.2.0
1 parent 79287bd commit cb9bee3

5 files changed

Lines changed: 225 additions & 3 deletions

File tree

‎README.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -342,6 +342,11 @@ Listen for vehicle error events. The callback receives a list of dictionaries co
342342
### `listen_ChargerPower(callback: Callable[[float | None], None]) -> Callable[[],None]`
343343
Listen for charger power whether the vehicle is AC or DC charging. Combines `ACChargingPower` and `DCChargingPower` (DC preferred), gated by `DetailedChargeState`: the value drops to `0` when charging ends, since neither source is reliably reset then, and a lone zero while charging is held until both sources have reported. Enables all three fields; the returned function removes all three listeners.
344344

345+
### `listen_ActiveRouteDestinationLocation(callback: Callable[[TeslaLocation | None], None]) -> Callable[[],None]`
346+
### `listen_ActiveRouteTrafficMinutesDelay(callback: Callable[[int | None], None]) -> Callable[[],None]`
347+
### `listen_ActiveRouteExpectedEnergyPercentAtTripArrival(callback: Callable[[int | None], None]) -> Callable[[],None]`
348+
Listen for a route field only while navigation is active. The car keeps reporting the last trip's `DestinationLocation`, `RouteTrafficMinutesDelay` and `ExpectedEnergyPercentAtTripArrival` after arriving, but `MinutesToArrival` goes null. Each listener pairs its field with `MinutesToArrival`, applying both from one event together: it reports `None` once `MinutesToArrival` is null, and otherwise the field's value once both have been seen. Enables both fields; the returned function removes all listeners.
349+
345350
### `listen_*` Methods
346351
The `TeslemetryStreamVehicle` class contains a `listen_*` methods for each telemetry signal.
347352
These methods allow you to listen to specific signals and handle their data in a type-safe manner.

‎pyproject.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ requires = ["setuptools>=77.0"]
44

55
[project]
66
name = "teslemetry_stream"
7-
version = "1.1.0"
7+
version = "1.2.0"
88
license = "Apache-2.0"
99
description = "Teslemetry Streaming API library for Python"
1010
readme = "README.md"

‎teslemetry_stream/vehicle.py‎

Lines changed: 77 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import logging
77
from collections.abc import Callable
88
from datetime import datetime, timezone
9-
from typing import TYPE_CHECKING, Any, cast
9+
from typing import TYPE_CHECKING, Any, TypeVar, cast
1010

1111
import aiohttp
1212

@@ -66,6 +66,9 @@
6666
LOGGER = logging.getLogger(__package__)
6767

6868

69+
_T = TypeVar("_T")
70+
71+
6972
class TeslemetryStreamVehicle:
7073
"""Handle streaming field updates."""
7174

@@ -3135,6 +3138,79 @@ def _unsubscribe() -> None:
31353138

31363139
return _unsubscribe
31373140

3141+
def _listen_active_route(
3142+
self,
3143+
listen: Callable[[Callable[[_T | None], None]], Callable[[], None]],
3144+
callback: Callable[[_T | None], None],
3145+
) -> Callable[[], None]:
3146+
"""Listen for a route field, reporting None while no navigation is active.
3147+
3148+
After navigation ends Minutes to Arrival goes null, but the car keeps
3149+
the last trip's destination location, arrival energy and traffic delay.
3150+
Reports None once Minutes to Arrival is null, otherwise the value once
3151+
both have been seen.
3152+
"""
3153+
value: _T | None = None
3154+
minutes: float | None = None
3155+
value_seen = minutes_seen = changed = False
3156+
3157+
def _value_callback(new_value: _T | None) -> None:
3158+
nonlocal value, value_seen, changed
3159+
value, value_seen, changed = new_value, True, True
3160+
3161+
def _minutes_callback(new_minutes: float | None) -> None:
3162+
nonlocal minutes, minutes_seen, changed
3163+
minutes, minutes_seen, changed = new_minutes, True, True
3164+
3165+
def _event_callback(event: dict[str, Any]) -> None:
3166+
# Registered last, so both fields from one event are applied
3167+
# together rather than briefly reporting a value from a route
3168+
# that just ended
3169+
nonlocal changed
3170+
if not changed:
3171+
return
3172+
changed = False
3173+
if minutes_seen and minutes is None:
3174+
callback(None)
3175+
elif minutes_seen and value_seen:
3176+
callback(value)
3177+
3178+
unsubs = (
3179+
listen(_value_callback),
3180+
self.listen_MinutesToArrival(_minutes_callback),
3181+
self.stream.async_add_listener(
3182+
_event_callback, {"vin": self.vin, "data": {}}
3183+
),
3184+
)
3185+
3186+
def _unsubscribe() -> None:
3187+
for unsub in unsubs:
3188+
unsub()
3189+
3190+
return _unsubscribe
3191+
3192+
def listen_ActiveRouteDestinationLocation(
3193+
self, callback: Callable[[TeslaLocation | None], None]
3194+
) -> Callable[[], None]:
3195+
"""Listen for Destination Location during navigation."""
3196+
return self._listen_active_route(self.listen_DestinationLocation, callback)
3197+
3198+
def listen_ActiveRouteTrafficMinutesDelay(
3199+
self, callback: Callable[[int | None], None]
3200+
) -> Callable[[], None]:
3201+
"""Listen for Route Traffic Minutes Delay during navigation."""
3202+
return self._listen_active_route(
3203+
self.listen_RouteTrafficMinutesDelay, callback
3204+
)
3205+
3206+
def listen_ActiveRouteExpectedEnergyPercentAtTripArrival(
3207+
self, callback: Callable[[int | None], None]
3208+
) -> Callable[[], None]:
3209+
"""Listen for Expected Energy Percent at Trip Arrival during navigation."""
3210+
return self._listen_active_route(
3211+
self.listen_ExpectedEnergyPercentAtTripArrival, callback
3212+
)
3213+
31383214

31393215
def make_int(
31403216
signal: Signal, callback: Callable[[int | None], None]

‎tests/test_active_route.py‎

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
"""Tests for the listen_ActiveRoute* composite listeners.
2+
3+
After navigation ends Minutes to Arrival goes null, but the car keeps
4+
reporting the last trip's destination, arrival energy and traffic delay. The
5+
composites pair each route field with Minutes to Arrival. Scenarios mirror the
6+
Home Assistant Teslemetry tests the helper was ported from.
7+
"""
8+
from __future__ import annotations
9+
10+
import asyncio
11+
from typing import Any
12+
13+
from teslemetry_stream.const import Signal, TeslaLocation
14+
from teslemetry_stream.stream import TeslemetryStream
15+
from teslemetry_stream.vehicle import TeslemetryStreamVehicle
16+
17+
VIN = "TESTVIN0000000001"
18+
MINUTES = Signal.MINUTES_TO_ARRIVAL
19+
DELAY = Signal.ROUTE_TRAFFIC_MINUTES_DELAY
20+
ENERGY = Signal.EXPECTED_ENERGY_PERCENT_AT_TRIP_ARRIVAL
21+
DESTINATION = Signal.DESTINATION_LOCATION
22+
LOCATION = {"latitude": -27.824252, "longitude": 153.328079}
23+
24+
25+
def check(label: str, ok: bool, detail: str = "") -> bool:
26+
print(f"{label:<72} {'PASS' if ok else 'FAIL'}{' ' + detail if detail else ''}")
27+
return ok
28+
29+
30+
def make_vehicle() -> tuple[TeslemetryStream, TeslemetryStreamVehicle]:
31+
stream = TeslemetryStream(None, "token", manual=True) # type: ignore[arg-type]
32+
vehicle = TeslemetryStreamVehicle(stream, VIN)
33+
vehicle.fields = {s.value: {} for s in (MINUTES, DELAY, ENERGY, DESTINATION)}
34+
vehicle._populated = True
35+
return stream, vehicle
36+
37+
38+
def run(listener: str, messages: list[dict[Signal, Any]]) -> list[Any]:
39+
"""Return every value the callback received, in order."""
40+
stream, vehicle = make_vehicle()
41+
values: list[Any] = []
42+
unsub = getattr(vehicle, listener)(values.append)
43+
for message in messages:
44+
stream.ingest({k.value: v for k, v in message.items()}, vin=VIN)
45+
unsub()
46+
return values
47+
48+
49+
SEQUENCES: list[tuple[str, str, list[dict[Signal, Any]], list[Any]]] = [
50+
(
51+
"route_ends_delay",
52+
"listen_ActiveRouteTrafficMinutesDelay",
53+
[
54+
{MINUTES: 12.5, DELAY: 3, ENERGY: 62},
55+
{MINUTES: None, DELAY: 0, ENERGY: 62},
56+
],
57+
[3, None],
58+
),
59+
(
60+
"route_ends_energy",
61+
"listen_ActiveRouteExpectedEnergyPercentAtTripArrival",
62+
[
63+
{MINUTES: 12.5, DELAY: 3, ENERGY: 62},
64+
{MINUTES: None, DELAY: 0, ENERGY: 62},
65+
],
66+
[62, None],
67+
),
68+
(
69+
"route_restarts",
70+
"listen_ActiveRouteTrafficMinutesDelay",
71+
[
72+
{MINUTES: 12.5, DELAY: 3},
73+
{MINUTES: None, DELAY: 0},
74+
{MINUTES: 30.0, DELAY: 5},
75+
],
76+
[3, None, 5],
77+
),
78+
(
79+
"route_restarts_with_unchanged_value",
80+
"listen_ActiveRouteTrafficMinutesDelay",
81+
[{MINUTES: 12.5, DELAY: 3}, {MINUTES: None}, {MINUTES: 20.0}],
82+
[3, None, 3],
83+
),
84+
(
85+
"waits_for_minutes_to_arrival",
86+
"listen_ActiveRouteTrafficMinutesDelay",
87+
[{DELAY: 3}, {MINUTES: 12.5}],
88+
[3],
89+
),
90+
(
91+
"energy_never_reported",
92+
"listen_ActiveRouteExpectedEnergyPercentAtTripArrival",
93+
[{MINUTES: 12.5, DELAY: 3}, {MINUTES: None, DELAY: 0}, {MINUTES: 30.0}],
94+
[None],
95+
),
96+
(
97+
"destination_route_ends",
98+
"listen_ActiveRouteDestinationLocation",
99+
[
100+
{DESTINATION: LOCATION, MINUTES: 12.5},
101+
{DESTINATION: LOCATION, MINUTES: None},
102+
],
103+
[TeslaLocation(**LOCATION), None],
104+
),
105+
(
106+
"unrelated_event_ignored",
107+
"listen_ActiveRouteTrafficMinutesDelay",
108+
[{MINUTES: 12.5, DELAY: 3}, {Signal.VEHICLE_SPEED: 40}],
109+
[3],
110+
),
111+
]
112+
113+
114+
async def main() -> None:
115+
results: list[bool] = []
116+
for name, listener, messages, expected in SEQUENCES:
117+
got = run(listener, messages)
118+
results.append(check(f"sequence {name}", got == expected, f"got {got!r}"))
119+
120+
stream, vehicle = make_vehicle()
121+
before = len(stream._listeners)
122+
unsub = vehicle.listen_ActiveRouteDestinationLocation(lambda v: None)
123+
added = len(stream._listeners) - before
124+
unsub()
125+
results.append(
126+
check(
127+
"one unsubscribe removes all three listeners",
128+
added == 3 and len(stream._listeners) == before,
129+
f"added {added}, left {len(stream._listeners) - before}",
130+
)
131+
)
132+
await asyncio.sleep(0)
133+
134+
print("-" * 72)
135+
print("ALL PASS" if all(results) else "FAILURES PRESENT")
136+
if not all(results):
137+
raise SystemExit(1)
138+
139+
140+
if __name__ == "__main__":
141+
asyncio.run(main())

‎uv.lock‎

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)