Skip to content

Commit f00612e

Browse files
authored
feat(funnel): add Teslemetry stream publisher (#136)
Streaming is meant to be the primary source of truth for the funnel's three fields, with Bluetooth opportunistic - but until now only the Bluetooth publisher existed. Add TeslemetryStreamPublisher alongside it, translating a caller-supplied stream signal update into the same FieldPath set BleBroadcastPublisher already feeds. Follows VehicleDataResultPublisher's precedent: the update is an argument (one stream push's data mapping, keyed by signal name) rather than a held client, so this library stays independent of the separate teslemetry-stream package. No source ranking is introduced - the funnel has none by design, and a real-world race between the two sources is what will decide it.
1 parent 1c30d47 commit f00612e

4 files changed

Lines changed: 382 additions & 1 deletion

File tree

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ Commands (vehicle/commands.py) - protobuf-based signed command implementation (A
6969

7070
`Router` (`router/base.py`) is an entity-agnostic composition wrapper (not part of the inheritance chain) that chains an ordered list of two-or-more backends sharing a common method surface and dispatches each method call down the chain with automatic per-command failover: it tries the first backend that has the method and, on any exception except `BluetoothUnconfirmedCommand`, retries the same call on the next backend that has it, returning the first success (raising the last error only if every applicable backend fails, `AttributeError` only if none has the method). Non-callable attributes resolve to the first backend that has them. Constructor: `Router(primary, secondary, *more_backends, health=None)`. The health check (`bool` | sync callable | async callable returning `bool`; omitted = attempt primary, fail over on exception with no probe) gates **only the primary**; the rest of the chain is reached purely through per-command failover — there is deliberately no per-backend health matrix. Double-execution caveat: a non-idempotent command that fails mid-flight can be re-run on the next backend, except for `BluetoothUnconfirmedCommand`, which propagates without replay.
7171

72-
`ObservationFunnel` (`funnel.py`) is the **read** side, a separate mechanism from the command `Router` and not in its inheritance chain. It is a **funnel, not a selector**: every attached publisher feeds the same per-field listeners, so a field bound to one source survives that source dropping. There is deliberately no source health, availability, grace window, failback delay, priority, stickiness or per-field selection anywhere in it — **unavailability is a value a source reports** (a null/SNA reading), never something the funnel infers from a link dropping; inferring it would be the funnel asserting data it does not have. The only arbitration is `publish()` ignoring an observation older than the last one for that field and not re-dispatching an unchanged value; both are hard-coded, not configurable. It is **entirely synchronous and can never originate a request**: no `async def`/`await`, no polling loop, no request callable, no scheduling task — `tests/test_funnel.py::TestFunnelCannotOriginateWork` locks that in against the module's own AST, so keep the module synchronous rather than adding a fetch path. Polling belongs entirely to an external consumer, which may gate its own schedule on `listen_demand(paths, cb)` (a read-only observer over the activation counts) and feed results back through `VehicleDataResultPublisher.publish_result(dict)` — that publisher holds no client, session, or callable able to obtain one. Publishers push into the funnel (which is itself the `ObservationSink`) via `publish(Observation)`; `observed_at` values must come from one monotonic clock shared by every publisher on a funnel. `value(path)` returns the last observed value, its `None` meaning either never observed or reported unavailable. Fields are deliberately three (`Locked`, `ChargePortDoorOpen`, `DoorState.TrunkFront`); translations are positive allowlists, and an unmapped VCSEC enum or absent JSON leaf emits no observation rather than a guess, while an explicit JSON null emits an unavailable value. `BleBroadcastPublisher` reuses the existing `VehicleBluetooth` `listen_vehicle_lock_state`/`listen_charge_port`/`listen_front_trunk` seams and never connects, reads, or commands; because `VEHICLELOCKSTATE_UNLOCKED` is 0 with no proto3 presence, every VCSEC status broadcast reports a lock state and the funnel deduplicates the repeats. Any unlocked VCSEC lock state, including `INTERNAL_LOCKED`→locked and `SELECTIVE_UNLOCKED`→unlocked, maps to a boolean per the "any unlocked is unlocked" ruling; closure `UNKNOWN`/`FAILED_UNLATCH` remain unmapped pending live-frame validation.
72+
`ObservationFunnel` (`funnel.py`) is the **read** side, a separate mechanism from the command `Router` and not in its inheritance chain. It is a **funnel, not a selector**: every attached publisher feeds the same per-field listeners, so a field bound to one source survives that source dropping. There is deliberately no source health, availability, grace window, failback delay, priority, stickiness or per-field selection anywhere in it — **unavailability is a value a source reports** (a null/SNA reading), never something the funnel infers from a link dropping; inferring it would be the funnel asserting data it does not have. The only arbitration is `publish()` ignoring an observation older than the last one for that field and not re-dispatching an unchanged value; both are hard-coded, not configurable. It is **entirely synchronous and can never originate a request**: no `async def`/`await`, no polling loop, no request callable, no scheduling task — `tests/test_funnel.py::TestFunnelCannotOriginateWork` locks that in against the module's own AST, so keep the module synchronous rather than adding a fetch path. Polling belongs entirely to an external consumer, which may gate its own schedule on `listen_demand(paths, cb)` (a read-only observer over the activation counts) and feed results back through `VehicleDataResultPublisher.publish_result(dict)` — that publisher holds no client, session, or callable able to obtain one. Publishers push into the funnel (which is itself the `ObservationSink`) via `publish(Observation)`; `observed_at` values must come from one monotonic clock shared by every publisher on a funnel. `value(path)` returns the last observed value, its `None` meaning either never observed or reported unavailable. Fields are deliberately three (`Locked`, `ChargePortDoorOpen`, `DoorState.TrunkFront`); translations are positive allowlists, and an unmapped VCSEC enum or absent JSON leaf emits no observation rather than a guess, while an explicit JSON null emits an unavailable value. `BleBroadcastPublisher` reuses the existing `VehicleBluetooth` `listen_vehicle_lock_state`/`listen_charge_port`/`listen_front_trunk` seams and never connects, reads, or commands; because `VEHICLELOCKSTATE_UNLOCKED` is 0 with no proto3 presence, every VCSEC status broadcast reports a lock state and the funnel deduplicates the repeats. Any unlocked VCSEC lock state, including `INTERNAL_LOCKED`→locked and `SELECTIVE_UNLOCKED`→unlocked, maps to a boolean per the "any unlocked is unlocked" ruling; closure `UNKNOWN`/`FAILED_UNLATCH` remain unmapped pending live-frame validation. `TeslemetryStreamPublisher` is the intended primary source (Bluetooth is opportunistic) and follows the same caller-supplied-payload shape as `VehicleDataResultPublisher` rather than depending on the separate `teslemetry-stream` package: `publish_update(data)` takes one stream push's `data` mapping, keyed by signal name (`Locked`, `ChargePortDoorOpen`, `DoorState` with a nested `TrunkFront`) — the same string keys `teslemetry-stream`'s own `Signal` `StrEnum` values equal, so a caller's dict matches whether or not it's keyed with that enum. It coerces the `"true"`/`"false"` wire strings some vehicles stream in place of JSON booleans. The funnel has no source ranking between Bluetooth and stream publishers by design — see the `ObservationFunnel` description above.
7373

7474
`VehicleRouter` and `EnergySiteRouter` (`router/vehicle.py`, `router/energysite.py`) are thin entity-specific `Router` subclasses. `VehicleRouter(bluetooth_primary, teslemetry_secondary)` pairs a `VehicleBluetooth` primary with a cloud (`TeslemetryVehicle`) secondary; `EnergySiteRouter(local_energysite, teslemetry_energysite)` pairs a duck-typed local `EnergySite`-shaped object (e.g. aiopowerwall's `PowerwallEnergySite`, no dependency added) with a cloud `TeslemetryEnergySite` fallback. Both re-export from `router/__init__.py` (`tesla_fleet_api.router.Router` etc.) and from `tesla/__init__.py` (`tesla_fleet_api.tesla.Router`) for backward compatibility. They have no factory on the `Vehicles`/`EnergySites` collections. This repo owns the RSA keypair lifecycle and cloud registration (`Tesla.get_rsa_private_key`, `EnergySite.add_authorized_client`) that aiopowerwall's local signed transport depends on but does not implement itself; see `docs/energy_local_control.md` for the end-to-end pairing + `EnergySiteRouter` composition flow. The cloud-only `set_island_mode`/`go_off_grid`/`reconnect_grid` (`tesla/energysite.py`) can only send an unsigned `grpc_command`, which gateways can acknowledge without actuating the contactor — rather than ship that as a silent no-op, they unconditionally raise `SignedCommandRequired` (`exceptions.py`); only the signed local path via `add_authorized_client` + `EnergySiteRouter` actually actuates, and a success response from that transport still doesn't prove the contactor moved — verify state after the call.
7575

tesla_fleet_api/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
ObservationFunnel,
1212
ObservationSink,
1313
Publisher,
14+
TeslemetryStreamPublisher,
1415
VehicleDataResultPublisher,
1516
)
1617
from tesla_fleet_api.tariff import (
@@ -47,6 +48,7 @@
4748
"TeslaFleetOAuth",
4849
"Teslemetry",
4950
"TeslemetryClientRegistration",
51+
"TeslemetryStreamPublisher",
5052
"Tessie",
5153
"VehicleDataResultPublisher",
5254
"firmware_at_least",

tesla_fleet_api/funnel.py

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -456,3 +456,88 @@ def _translate(
456456
# boolean meaning.
457457
elif (code := _int_code(front_trunk)) in (0, 1):
458458
yield Observation(FieldPath.DOOR_STATE_TRUNK_FRONT, code == 1, observed_at)
459+
460+
461+
# -- Supplied Teslemetry stream publisher -----------------------------------
462+
463+
464+
def _coerce_stream_bool(value: object) -> object:
465+
"""``"true"``/``"false"`` become real booleans; anything else passes through.
466+
467+
Some vehicles stream a boolean signal as that literal string rather than a
468+
JSON boolean even when the stream prefers typed values.
469+
"""
470+
if value == "true":
471+
return True
472+
if value == "false":
473+
return False
474+
return value
475+
476+
477+
class TeslemetryStreamPublisher:
478+
"""Translates a caller-supplied Teslemetry stream signal update into observations.
479+
480+
The update is an argument, keyed by signal name the way a Teslemetry
481+
stream push does (``Locked``, ``ChargePortDoorOpen``, ``DoorState`` with a
482+
nested ``TrunkFront``). This class holds no stream client, connection, or
483+
callable able to obtain one, so nothing here can originate a subscription
484+
or a connection - the same shape ``VehicleDataResultPublisher`` uses for a
485+
supplied ``vehicle_data`` result, kept here rather than depending on the
486+
separate stream client package for a type this library does not need.
487+
"""
488+
489+
def __init__(self, *, clock: Clock = time.monotonic) -> None:
490+
self._clock = clock
491+
self._sink: ObservationSink | None = None
492+
493+
@property
494+
def paths(self) -> frozenset[FieldPath]:
495+
return frozenset(FieldPath)
496+
497+
def attach(self, sink: ObservationSink) -> Unsubscribe:
498+
self._sink = sink
499+
500+
def detach() -> None:
501+
self._sink = None
502+
503+
return detach
504+
505+
def request(self, paths: frozenset[FieldPath]) -> None:
506+
"""Passive source: activation subscribes to nothing."""
507+
508+
def release(self, paths: frozenset[FieldPath]) -> None:
509+
"""Passive source: activation subscribes to nothing."""
510+
511+
def publish_update(
512+
self, data: Mapping[str, Any], *, observed_at: float | None = None
513+
) -> tuple[Observation, ...]:
514+
"""Translate a supplied stream signal update and feed it to the sink."""
515+
at = self._clock() if observed_at is None else observed_at
516+
observations = tuple(self._translate(data, at))
517+
sink = self._sink
518+
if sink is not None:
519+
for observation in observations:
520+
sink.publish(observation)
521+
return observations
522+
523+
def _translate(
524+
self, data: Mapping[str, Any], observed_at: float
525+
) -> Iterator[Observation]:
526+
locked = _coerce_stream_bool(_leaf(data, "Locked"))
527+
if locked is None or isinstance(locked, bool):
528+
yield Observation(FieldPath.LOCKED, locked, observed_at)
529+
530+
charge_port = _coerce_stream_bool(_leaf(data, "ChargePortDoorOpen"))
531+
if charge_port is None or isinstance(charge_port, bool):
532+
yield Observation(FieldPath.CHARGE_PORT_DOOR_OPEN, charge_port, observed_at)
533+
534+
door_state = _leaf(data, "DoorState")
535+
if door_state is None:
536+
yield Observation(FieldPath.DOOR_STATE_TRUNK_FRONT, None, observed_at)
537+
elif isinstance(door_state, Mapping):
538+
door_section: Mapping[str, Any] = door_state # pyright: ignore[reportUnknownVariableType]
539+
trunk_front = _coerce_stream_bool(_leaf(door_section, "TrunkFront"))
540+
if trunk_front is None or isinstance(trunk_front, bool):
541+
yield Observation(
542+
FieldPath.DOOR_STATE_TRUNK_FRONT, trunk_front, observed_at
543+
)

0 commit comments

Comments
 (0)