From 613cd7eb2eb7370db8ce37b9a344c8b27c9a8373 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Sat, 22 Aug 2026 19:51:57 +1000 Subject: [PATCH 1/7] feat(router): add StreamRouter, a read router that never polls Commands already fail over between transports; reads do not, so a field bound to a single source goes unavailable whenever that source is down. StreamRouter arbitrates per-field push observations across sources and reports transport loss as availability rather than as a data value. The router is entirely synchronous and structurally unable to originate a request: no async, polling loop, request callable, or scheduling task. Polling stays with an external consumer, which may gate its own schedule on listen_demand and feed results back through VehicleDataResultPublisher. First vertical slice: Locked, ChargePortDoorOpen, DoorState.TrunkFront, over the existing VehicleBluetooth broadcast and connection-status seams. --- AGENTS.md | 2 + tesla_fleet_api/router/__init__.py | 22 + tesla_fleet_api/router/stream.py | 712 +++++++++++++++++++++++ tests/test_stream_router.py | 524 +++++++++++++++++ tests/test_stream_router_bluetooth.py | 380 ++++++++++++ tests/test_stream_router_vehicle_data.py | 238 ++++++++ 6 files changed, 1878 insertions(+) create mode 100644 tesla_fleet_api/router/stream.py create mode 100644 tests/test_stream_router.py create mode 100644 tests/test_stream_router_bluetooth.py create mode 100644 tests/test_stream_router_vehicle_data.py diff --git a/AGENTS.md b/AGENTS.md index 345a8fe..7479cd3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -69,6 +69,8 @@ Commands (vehicle/commands.py) - protobuf-based signed command implementation (A `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. +`StreamRouter` (`router/stream.py`) is the **read** router, a separate mechanism from the command `Router` above and not in its inheritance chain. It arbitrates per-field push observations across sources so a field bound to one source does not go unavailable when that source does. It is **entirely synchronous and can never originate a request**: no `async def`/`await`, no polling loop, no request callable, no scheduling task, no cost or billing policy anywhere in the module — `tests/test_stream_router.py::TestRouterCannotOriginateWork` 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 router (which is itself the `PublisherSink`) via `publish(Observation)` and `set_health(source_id, healthy)`; health is a separate channel from data, so transport loss never reaches a listener as `None`. Selection per `FieldPath` is: healthy + within the capability's `max_age` (`None` = connection-bound, silence is not staleness) → exact over lossy → lowest `priority` int (`PRIORITY_STREAM` < `PRIORITY_BLE_PUSH` < `PRIORITY_BROADCAST` < `PRIORITY_SUPPLIED_RESULT`, overridable per publisher) → sticky within a tier → most recent. `recovered_at` applies the failback delay only to a source that regained health after a loss, never to one coming up for the first time. `is_available(path)` recomputes freshness at call time because grace expiry has no event to fire a callback on; `value(path)` returns last-known and its `None` means only "never observed". 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. `BleBroadcastPublisher` reuses the existing `VehicleBluetooth` `listen_vehicle_lock_state`/`listen_charge_port`/`listen_front_trunk`/`listen_connection_status` 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 router deduplicates the repeats. VCSEC `INTERNAL_LOCKED`/`SELECTIVE_UNLOCKED` and closure `UNKNOWN`/`FAILED_UNLATCH` are unmapped pending live-frame validation. + `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. ### Vehicle Collections diff --git a/tesla_fleet_api/router/__init__.py b/tesla_fleet_api/router/__init__.py index 4de5f9f..91c086f 100644 --- a/tesla_fleet_api/router/__init__.py +++ b/tesla_fleet_api/router/__init__.py @@ -3,10 +3,32 @@ from tesla_fleet_api.router.base import HealthCheck, Router from tesla_fleet_api.router.vehicle import VehicleRouter from tesla_fleet_api.router.energysite import EnergySiteRouter +from tesla_fleet_api.router.stream import ( + BleBroadcastPublisher, + Capability, + Delivery, + FieldPath, + Fidelity, + Observation, + Publisher, + PublisherSink, + StreamRouter, + VehicleDataResultPublisher, +) __all__ = [ "Router", "VehicleRouter", "EnergySiteRouter", "HealthCheck", + "StreamRouter", + "FieldPath", + "Capability", + "Delivery", + "Fidelity", + "Observation", + "Publisher", + "PublisherSink", + "BleBroadcastPublisher", + "VehicleDataResultPublisher", ] diff --git a/tesla_fleet_api/router/stream.py b/tesla_fleet_api/router/stream.py new file mode 100644 index 0000000..a6c781d --- /dev/null +++ b/tesla_fleet_api/router/stream.py @@ -0,0 +1,712 @@ +"""Read router: per-field arbitration of pushed vehicle observations. + +A field bound to a single source disappears whenever that source is +unavailable. This router keeps a canonical field alive while any attached +source can still supply it, and reports transport loss as availability rather +than as a data value. + +The router is a pure consumer of observations pushed to it. It is entirely +synchronous and holds no request callable, polling loop, HTTP/BLE read, +scheduling task, or cost policy: a source the router could drive is a source +it could be made to poll. +""" + +from __future__ import annotations + +import time +from collections.abc import Mapping +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Any, Callable, Iterator, NamedTuple, Protocol, TypeVar + +from tesla_fleet_api.const import LOGGER, StrEnum +from tesla_protocol.command.vcsec_pb2 import ( + ClosureState_E, + VehicleLockState_E, +) + +if TYPE_CHECKING: + from tesla_fleet_api.tesla.vehicle.bluetooth import VehicleBluetooth + +Unsubscribe = Callable[[], None] +Clock = Callable[[], float] + +T = TypeVar("T") + +_FATAL_CALLBACK_ERRORS = (KeyboardInterrupt, SystemExit) + +# Last-known data is retained this long after the last source able to supply a +# field drops out, before the field is reported unavailable. +DEFAULT_GRACE = 300.0 + +# A source that regains health must hold it this long before it can take a +# field back off a working standby. +DEFAULT_FAILBACK_DELAY = 5.0 + +# A supplied result is a point-in-time snapshot, so it expires by age. +DEFAULT_RESULT_MAX_AGE = 300.0 + +# Publisher priority, lower wins. A local-first consumer may override any +# publisher's priority. +PRIORITY_STREAM = 10 +PRIORITY_BLE_PUSH = 20 +PRIORITY_BROADCAST = 30 +PRIORITY_SUPPLIED_RESULT = 40 + + +class FieldPath(StrEnum): + """A canonical routable field, valued to match its public signal name. + + A compound signal is split into one path per facet, because a broadcast and + a ``vehicle_data`` result each expose individual leaves and selection is + per facet. + """ + + LOCKED = "Locked" + CHARGE_PORT_DOOR_OPEN = "ChargePortDoorOpen" + DOOR_STATE_TRUNK_FRONT = "DoorState.TrunkFront" + + +class Delivery(StrEnum): + """How a publisher delivers a field.""" + + ON_CHANGE = "on_change" + CADENCED_PUSH = "cadenced_push" + PASSIVE_RESULT = "passive_result" + + +class Fidelity(StrEnum): + """How faithfully a publisher's translation preserves the field.""" + + EXACT = "exact" + LOSSY = "lossy" + + +@dataclass(frozen=True, slots=True) +class Capability: + """What one publisher can supply for one field path.""" + + path: FieldPath + delivery: Delivery + fidelity: Fidelity = Fidelity.EXACT + # None is connection-bound: silence from an on-change source is not + # staleness, only loss of health is. + max_age: float | None = None + + +@dataclass(frozen=True, slots=True) +class Observation: + """One timestamped value for one field, from one source.""" + + path: FieldPath + value: bool + observed_at: float + source_id: str + source_sequence: int | None = None + + +class PublisherSink(Protocol): + """The handle a publisher is given to feed the router. + + Health is a separate channel from data so that transport loss can never + reach a listener as a value. + """ + + def publish(self, observation: Observation) -> None: ... + + def set_health(self, source_id: str, healthy: bool) -> None: ... + + +class Publisher(Protocol): + """A source of observations for one or more canonical fields.""" + + source_id: str + priority: int + + @property + def capabilities(self) -> tuple[Capability, ...]: ... + + def attach(self, sink: PublisherSink) -> Unsubscribe: ... + + def request(self, paths: frozenset[FieldPath]) -> None: + """Begin supplying ``paths``: subscription accounting, not a data request.""" + + def release(self, paths: frozenset[FieldPath]) -> None: + """Stop supplying ``paths``.""" + + +def _dispatch(callback: Callable[[T], None], value: T) -> None: + try: + callback(value) + except _FATAL_CALLBACK_ERRORS: + raise + except BaseException: + LOGGER.exception("stream router listener callback failed") + + +@dataclass +class _SourceState: + publisher: Publisher + capabilities: dict[FieldPath, Capability] + detach: Unsubscribe + healthy: bool = False + has_been_healthy: bool = False + # Set only when health returns after a loss, which is the only case the + # failback delay guards against. + recovered_at: float | None = None + observations: dict[FieldPath, Observation] = field( + default_factory=dict[FieldPath, Observation] + ) + + +class _Candidate(NamedTuple): + state: _SourceState + observation: Observation + capability: Capability + + +def _rank(candidate: _Candidate) -> tuple[int, int]: + """Lower sorts better: exact translations first, then source priority.""" + return ( + 0 if candidate.capability.fidelity is Fidelity.EXACT else 1, + candidate.state.publisher.priority, + ) + + +@dataclass +class _DemandObserver: + paths: frozenset[FieldPath] + callback: Callable[[bool], None] + active: bool + + +class StreamRouter: + """Arbitrates canonical field observations across attached publishers. + + Every value reaches a listener through selection here; a publisher never + calls a public listener directly, so one source can never bypass another's + arbitration. + """ + + def __init__( + self, + *, + grace: float = DEFAULT_GRACE, + failback_delay: float = DEFAULT_FAILBACK_DELAY, + clock: Clock = time.monotonic, + ) -> None: + self._clock = clock + self._grace = grace + self._failback_delay = failback_delay + self._sources: dict[str, _SourceState] = {} + self._listeners: dict[FieldPath, list[Callable[[bool], None]]] = {} + self._availability_listeners: dict[FieldPath, list[Callable[[bool], None]]] = {} + self._demand_observers: list[_DemandObserver] = [] + self._selected: dict[FieldPath, str] = {} + self._values: dict[FieldPath, bool] = {} + self._observed_at: dict[FieldPath, float] = {} + self._announced_availability: dict[FieldPath, bool] = {} + + # -- Publishers -------------------------------------------------------- + + def attach(self, publisher: Publisher) -> Unsubscribe: + """Attach ``publisher`` and return its detach closure.""" + source_id = publisher.source_id + if source_id in self._sources: + raise ValueError(f"source_id {source_id!r} is already attached") + + capabilities = {c.path: c for c in publisher.capabilities} + state = _SourceState( + publisher=publisher, capabilities=capabilities, detach=_noop + ) + # Registered before attaching so a publisher that reports health or an + # observation synchronously is not discarded as unknown. + self._sources[source_id] = state + state.detach = publisher.attach(self) + + active = self._active_paths(capabilities) + if active: + publisher.request(active) + return lambda: self._detach(source_id, state) + + def _detach(self, source_id: str, state: _SourceState) -> None: + if self._sources.get(source_id) is not state: + return + del self._sources[source_id] + active = self._active_paths(state.capabilities) + if active: + state.publisher.release(active) + state.detach() + for path in state.capabilities: + self._reselect(path) + + def _active_paths( + self, capabilities: Mapping[FieldPath, Capability] + ) -> frozenset[FieldPath]: + return frozenset(p for p in capabilities if self._listeners.get(p)) + + # -- Sink --------------------------------------------------------------- + + def publish(self, observation: Observation) -> None: + """Accept one observation from an attached publisher.""" + state = self._sources.get(observation.source_id) + if state is None or observation.path not in state.capabilities: + return + previous = state.observations.get(observation.path) + if previous is not None and previous.observed_at > observation.observed_at: + return + state.observations[observation.path] = observation + self._reselect(observation.path) + + def set_health(self, source_id: str, healthy: bool) -> None: + """Record a source's transport health.""" + state = self._sources.get(source_id) + if state is None or state.healthy == healthy: + return + state.healthy = healthy + if healthy: + state.recovered_at = self._clock() if state.has_been_healthy else None + state.has_been_healthy = True + else: + # A lost transport cannot vouch for what it last reported, so its + # readings are dropped rather than allowed to win on reconnect. + state.observations.clear() + for path in state.capabilities: + self._reselect(path) + + # -- Selection ---------------------------------------------------------- + + def _candidates(self, path: FieldPath) -> list[_Candidate]: + now = self._clock() + candidates: list[_Candidate] = [] + for state in self._sources.values(): + capability = state.capabilities.get(path) + if capability is None or not state.healthy: + continue + observation = state.observations.get(path) + if observation is None: + continue + if ( + capability.max_age is not None + and now - observation.observed_at > capability.max_age + ): + continue + candidates.append(_Candidate(state, observation, capability)) + return candidates + + def _reselect(self, path: FieldPath) -> None: + candidates = self._candidates(path) + if not candidates: + self._selected.pop(path, None) + self._announce_availability(path) + return + + selected_id = self._selected.get(path) + current = next( + (c for c in candidates if c.state.publisher.source_id == selected_id), None + ) + best_rank = min(_rank(c) for c in candidates) + if current is not None and _rank(current) == best_rank: + # Stickiness within a tier: an equally ranked source does not + # displace the one already selected. + chosen = current + else: + better = [c for c in candidates if _rank(c) == best_rank] + if current is not None: + # A source that just regained health holds it briefly before it + # can take the field back off a working standby. + now = self._clock() + better = [ + c + for c in better + if c.state.recovered_at is None + or now - c.state.recovered_at >= self._failback_delay + ] + chosen = ( + max(better, key=lambda c: c.observation.observed_at) + if better + else current + ) + + if chosen is None: + self._selected.pop(path, None) + self._announce_availability(path) + return + + self._selected[path] = chosen.state.publisher.source_id + self._observed_at[path] = chosen.observation.observed_at + changed = ( + path not in self._values or self._values[path] != chosen.observation.value + ) + self._values[path] = chosen.observation.value + self._announce_availability(path) + if changed: + for callback in list(self._listeners.get(path, ())): + _dispatch(callback, chosen.observation.value) + + # -- Values and availability -------------------------------------------- + + def value(self, path: FieldPath) -> bool | None: + """The last known value for ``path``, or ``None`` if never observed. + + ``None`` here means no source has ever reported the field; it is never + produced by transport loss, which shows up in :meth:`is_available`. + """ + return self._values.get(path) + + def is_available(self, path: FieldPath) -> bool: + """Whether ``path`` has a usable source, or last-known data still in grace. + + Freshness is recomputed here rather than read off the last selection, so + an expiry that had no event to fire on is still reported honestly. + """ + if self._candidates(path): + return True + if path not in self._values: + return False + return self._clock() - self._observed_at[path] <= self._grace + + def _announce_availability(self, path: FieldPath) -> None: + available = self.is_available(path) + if self._announced_availability.get(path) == available: + return + self._announced_availability[path] = available + for callback in list(self._availability_listeners.get(path, ())): + _dispatch(callback, available) + + # -- Public listeners --------------------------------------------------- + + def listen(self, path: FieldPath, callback: Callable[[bool], None]) -> Unsubscribe: + """Register a value listener for ``path``. + + The first listener for a path activates it on every capable publisher; + the last one to leave releases it. + """ + listeners = self._listeners.setdefault(path, []) + listeners.append(callback) + if len(listeners) == 1: + paths = frozenset({path}) + for state in list(self._sources.values()): + if path in state.capabilities: + state.publisher.request(paths) + self._notify_demand() + + released = False + + def unsubscribe() -> None: + nonlocal released + if released: + return + released = True + try: + listeners.remove(callback) + except ValueError: + return + if not listeners: + paths = frozenset({path}) + for state in list(self._sources.values()): + if path in state.capabilities: + state.publisher.release(paths) + self._notify_demand() + + return unsubscribe + + def listen_availability( + self, path: FieldPath, callback: Callable[[bool], None] + ) -> Unsubscribe: + """Register an availability listener for ``path``. + + Fires with the current state at registration and then on transitions + caused by an event. Grace expiry has no event to fire on, so a consumer + that must observe it reads :meth:`is_available`. + """ + listeners = self._availability_listeners.setdefault(path, []) + listeners.append(callback) + available = self.is_available(path) + self._announced_availability.setdefault(path, available) + _dispatch(callback, available) + + def unsubscribe() -> None: + try: + listeners.remove(callback) + except ValueError: + pass + + return unsubscribe + + # -- Demand ------------------------------------------------------------- + + def listen_demand( + self, paths: frozenset[FieldPath], callback: Callable[[bool], None] + ) -> Unsubscribe: + """Observe whether any of ``paths`` has at least one live value listener. + + Derived from the activation counts the router already keeps. It reports; + it never starts work of its own. + """ + observer = _DemandObserver( + paths=paths, callback=callback, active=self._demand(paths) + ) + self._demand_observers.append(observer) + _dispatch(callback, observer.active) + + def unsubscribe() -> None: + try: + self._demand_observers.remove(observer) + except ValueError: + pass + + return unsubscribe + + def _demand(self, paths: frozenset[FieldPath]) -> bool: + return any(self._listeners.get(p) for p in paths) + + def _notify_demand(self) -> None: + for observer in list(self._demand_observers): + active = self._demand(observer.paths) + if active != observer.active: + observer.active = active + _dispatch(observer.callback, active) + + +def _noop() -> None: + return None + + +# -- Bluetooth broadcast publisher ----------------------------------------- + +# UNLOCKED is 0 and the enum has no proto3 presence, so every status broadcast +# reports a lock state and the router deduplicates the repeats. +# +# INTERNAL_LOCKED and SELECTIVE_UNLOCKED are deliberately unmapped: reducing +# either to one boolean is unvalidated against live frames, and emitting +# nothing keeps the last confirmed value instead of guessing. +_LOCK_STATES: Mapping[int, bool] = { + VehicleLockState_E.VEHICLELOCKSTATE_LOCKED: True, + VehicleLockState_E.VEHICLELOCKSTATE_UNLOCKED: False, +} + +# UNKNOWN and FAILED_UNLATCH are unmapped for the same reason. +_CLOSURE_STATES: Mapping[int, bool] = { + ClosureState_E.CLOSURESTATE_CLOSED: False, + ClosureState_E.CLOSURESTATE_OPEN: True, + ClosureState_E.CLOSURESTATE_AJAR: True, + ClosureState_E.CLOSURESTATE_OPENING: True, + ClosureState_E.CLOSURESTATE_CLOSING: True, +} + +_BROADCAST_MAPS: Mapping[FieldPath, Mapping[int, bool]] = { + FieldPath.LOCKED: _LOCK_STATES, + FieldPath.CHARGE_PORT_DOOR_OPEN: _CLOSURE_STATES, + FieldPath.DOOR_STATE_TRUNK_FRONT: _CLOSURE_STATES, +} + +_BROADCAST_CAPABILITIES = tuple( + Capability(path=path, delivery=Delivery.ON_CHANGE, fidelity=Fidelity.EXACT) + for path in _BROADCAST_MAPS +) + + +class BleBroadcastPublisher: + """Publishes VCSEC status broadcasts from an existing BLE session. + + Registration only: it subscribes to the broadcast and connection-status + listeners a ``VehicleBluetooth`` already fans out, and never connects, + reads, or commands. + """ + + def __init__( + self, + vehicle: VehicleBluetooth[Any], + *, + source_id: str = "ble-broadcast", + priority: int = PRIORITY_BROADCAST, + clock: Clock = time.monotonic, + ) -> None: + self._vehicle = vehicle + self._clock = clock + self._sink: PublisherSink | None = None + self._subscriptions: dict[FieldPath, Unsubscribe] = {} + self.source_id = source_id + self.priority = priority + + @property + def capabilities(self) -> tuple[Capability, ...]: + return _BROADCAST_CAPABILITIES + + def attach(self, sink: PublisherSink) -> Unsubscribe: + self._sink = sink + unsubscribe_health = self._vehicle.listen_connection_status( + lambda connected: sink.set_health(self.source_id, connected) + ) + # listen_connection_status only fires on transitions, so the session + # already in progress at attach time has to be reported here. + client = self._vehicle.client + sink.set_health(self.source_id, bool(client and client.is_connected)) + + def detach() -> None: + unsubscribe_health() + self.release(frozenset(self._subscriptions)) + self._sink = None + + return detach + + def request(self, paths: frozenset[FieldPath]) -> None: + for path in paths: + if path in self._subscriptions or path not in _BROADCAST_MAPS: + continue + self._subscriptions[path] = _LISTENER_FOR[path]( + self._vehicle, self._observer(path) + ) + + def release(self, paths: frozenset[FieldPath]) -> None: + for path in paths: + unsubscribe = self._subscriptions.pop(path, None) + if unsubscribe is not None: + unsubscribe() + + def _observer(self, path: FieldPath) -> Callable[[int], None]: + states = _BROADCAST_MAPS[path] + + def on_broadcast(raw: int) -> None: + sink = self._sink + if sink is None or raw not in states: + return + sink.publish( + Observation( + path=path, + value=states[raw], + observed_at=self._clock(), + source_id=self.source_id, + ) + ) + + return on_broadcast + + +_LISTENER_FOR: Mapping[ + FieldPath, Callable[["VehicleBluetooth[Any]", Callable[[int], None]], Unsubscribe] +] = { + FieldPath.LOCKED: lambda vehicle, cb: vehicle.listen_vehicle_lock_state(cb), + FieldPath.CHARGE_PORT_DOOR_OPEN: lambda vehicle, cb: vehicle.listen_charge_port(cb), + FieldPath.DOOR_STATE_TRUNK_FRONT: lambda vehicle, cb: vehicle.listen_front_trunk( + cb + ), +} + + +# -- Supplied vehicle_data result publisher --------------------------------- + + +def _leaf(section: object, key: str) -> object: + """A present leaf, or ``None``: an absent key is not a falsy value.""" + if isinstance(section, Mapping) and key in section: + return section[key] # pyright: ignore[reportUnknownVariableType] + return None + + +def _int_code(value: object) -> int | None: + """A JSON integer code, rejecting ``bool`` so ``False`` cannot pass as 0.""" + if isinstance(value, bool) or not isinstance(value, int): + return None + return value + + +class VehicleDataResultPublisher: + """Translates a caller-supplied ``vehicle_data`` result into observations. + + The result is an argument. This class holds no client, session, endpoint, + or callable able to obtain one, so nothing here can originate a request or + cause a refresh. + """ + + def __init__( + self, + *, + source_id: str = "vehicle-data", + max_age: float = DEFAULT_RESULT_MAX_AGE, + priority: int = PRIORITY_SUPPLIED_RESULT, + clock: Clock = time.monotonic, + ) -> None: + self._clock = clock + self._sink: PublisherSink | None = None + self._capabilities = tuple( + Capability( + path=path, + delivery=Delivery.PASSIVE_RESULT, + fidelity=Fidelity.EXACT, + max_age=max_age, + ) + for path in FieldPath + ) + self.source_id = source_id + self.priority = priority + + @property + def capabilities(self) -> tuple[Capability, ...]: + return self._capabilities + + def attach(self, sink: PublisherSink) -> Unsubscribe: + self._sink = sink + # A supplied result carries its own freshness and there is no session + # to lose, so the source is healthy for as long as it is attached. + sink.set_health(self.source_id, True) + + def detach() -> None: + sink.set_health(self.source_id, False) + self._sink = None + + return detach + + def request(self, paths: frozenset[FieldPath]) -> None: + """Passive source: activation subscribes to nothing.""" + + def release(self, paths: frozenset[FieldPath]) -> None: + """Passive source: activation subscribes to nothing.""" + + def publish_result( + self, result: Mapping[str, Any], *, observed_at: float | None = None + ) -> tuple[Observation, ...]: + """Translate a supplied result and feed the audited leaves to the sink.""" + at = self._clock() if observed_at is None else observed_at + observations = tuple(self._translate(result, at)) + sink = self._sink + if sink is not None: + for observation in observations: + sink.publish(observation) + return observations + + def _translate( + self, result: Mapping[str, Any], observed_at: float + ) -> Iterator[Observation]: + payload: Mapping[str, Any] = result + response = _leaf(payload, "response") + if isinstance(response, Mapping): + payload = response # pyright: ignore[reportUnknownVariableType] + + vehicle_state = _leaf(payload, "vehicle_state") + charge_state = _leaf(payload, "charge_state") + + locked = _leaf(vehicle_state, "locked") + if isinstance(locked, bool): + yield self._observation(FieldPath.LOCKED, locked, observed_at) + + charge_port = _leaf(charge_state, "charge_port_door_open") + if isinstance(charge_port, bool): + yield self._observation( + FieldPath.CHARGE_PORT_DOOR_OPEN, charge_port, observed_at + ) + + # ``ft`` is an ajar/open code, and only 0 and 1 have a documented + # boolean meaning. + front_trunk = _int_code(_leaf(vehicle_state, "ft")) + if front_trunk in (0, 1): + yield self._observation( + FieldPath.DOOR_STATE_TRUNK_FRONT, front_trunk == 1, observed_at + ) + + def _observation( + self, path: FieldPath, value: bool, observed_at: float + ) -> Observation: + return Observation( + path=path, value=value, observed_at=observed_at, source_id=self.source_id + ) diff --git a/tests/test_stream_router.py b/tests/test_stream_router.py new file mode 100644 index 0000000..17f22cd --- /dev/null +++ b/tests/test_stream_router.py @@ -0,0 +1,524 @@ +"""Unit tests for the StreamRouter core: selection, freshness, activation, demand. + +Uses plain fake publishers so no BLE hardware, network access, or event loop is +involved; the router is synchronous by construction. +""" + +from __future__ import annotations + +import ast +from pathlib import Path +from typing import Any +from unittest import TestCase + +from tesla_fleet_api.router import ( + Capability, + Delivery, + FieldPath, + Fidelity, + Observation, + PublisherSink, + StreamRouter, +) +from tesla_fleet_api.router.stream import ( + PRIORITY_BROADCAST, + PRIORITY_STREAM, + Unsubscribe, +) + +ALL_PATHS = frozenset(FieldPath) + + +class _Clock: + """A hand-advanced clock so freshness and hysteresis are deterministic.""" + + def __init__(self, now: float = 0.0) -> None: + self.now = now + + def __call__(self) -> float: + return self.now + + +class _FakePublisher: + """A publisher a test drives directly, recording activation accounting.""" + + def __init__( + self, + source_id: str, + *, + priority: int = PRIORITY_STREAM, + paths: frozenset[FieldPath] = ALL_PATHS, + fidelity: Fidelity = Fidelity.EXACT, + max_age: float | None = None, + ) -> None: + self.source_id = source_id + self.priority = priority + self.requested: list[frozenset[FieldPath]] = [] + self.released: list[frozenset[FieldPath]] = [] + self.detached = 0 + self._sink: PublisherSink | None = None + self._capabilities = tuple( + Capability( + path=path, + delivery=Delivery.ON_CHANGE, + fidelity=fidelity, + max_age=max_age, + ) + for path in sorted(paths) + ) + + @property + def capabilities(self) -> tuple[Capability, ...]: + return self._capabilities + + def attach(self, sink: PublisherSink) -> Unsubscribe: + self._sink = sink + + def detach() -> None: + self.detached += 1 + self._sink = None + + return detach + + def request(self, paths: frozenset[FieldPath]) -> None: + self.requested.append(paths) + + def release(self, paths: frozenset[FieldPath]) -> None: + self.released.append(paths) + + # -- test drivers ------------------------------------------------------ + + def emit(self, path: FieldPath, value: bool, observed_at: float) -> None: + assert self._sink is not None + self._sink.publish( + Observation( + path=path, + value=value, + observed_at=observed_at, + source_id=self.source_id, + ) + ) + + def health(self, healthy: bool) -> None: + assert self._sink is not None + self._sink.set_health(self.source_id, healthy) + + +class _Recorder: + """An ordered timeline of everything a listener was handed.""" + + def __init__(self) -> None: + self.values: list[Any] = [] + self.availability: list[Any] = [] + + def on_value(self, value: Any) -> None: + self.values.append(value) + + def on_availability(self, available: Any) -> None: + self.availability.append(available) + + +class TestSelectionAndFailover(TestCase): + def test_fails_over_both_directions_without_a_transient_none(self) -> None: + """The bug this router exists to prevent: a source loss blanking a field. + + Both the failover and the failback must carry a real value from the + source that still has one; nothing may reach a listener as ``None``, + and availability must never dip. + """ + clock = _Clock() + router = StreamRouter(clock=clock, failback_delay=5.0) + stream = _FakePublisher("stream", priority=PRIORITY_STREAM) + standby = _FakePublisher("standby", priority=PRIORITY_BROADCAST) + router.attach(stream) + router.attach(standby) + + recorder = _Recorder() + router.listen(FieldPath.LOCKED, recorder.on_value) + router.listen_availability(FieldPath.LOCKED, recorder.on_availability) + self.assertEqual(recorder.availability, [False]) + + stream.health(True) + standby.health(True) + stream.emit(FieldPath.LOCKED, True, observed_at=0.0) + self.assertEqual(recorder.values, [True]) + + # The standby disagrees, but the preferred source is selected, so its + # observation must not reach the listener. + clock.now = 1.0 + standby.emit(FieldPath.LOCKED, False, observed_at=1.0) + self.assertEqual(recorder.values, [True]) + self.assertIs(router.value(FieldPath.LOCKED), True) + + # Failover: the preferred source dies, the standby's real value lands. + clock.now = 2.0 + stream.health(False) + self.assertEqual(recorder.values, [True, False]) + self.assertIs(router.value(FieldPath.LOCKED), False) + self.assertTrue(router.is_available(FieldPath.LOCKED)) + + # A recovered source may not take the field back until it has held + # health for the failback delay. + clock.now = 3.0 + stream.health(True) + clock.now = 3.5 + stream.emit(FieldPath.LOCKED, True, observed_at=3.5) + self.assertEqual(recorder.values, [True, False]) + + # Failback, once that window has elapsed. + clock.now = 10.0 + stream.emit(FieldPath.LOCKED, True, observed_at=10.0) + self.assertEqual(recorder.values, [True, False, True]) + + self.assertNotIn(None, recorder.values) + self.assertTrue(all(isinstance(v, bool) for v in recorder.values)) + # One False at registration, one True on the first value: never dipped. + self.assertEqual(recorder.availability, [False, True]) + + def test_losing_every_source_keeps_last_known_and_emits_nothing(self) -> None: + clock = _Clock() + router = StreamRouter(clock=clock, grace=50.0) + stream = _FakePublisher("stream") + router.attach(stream) + recorder = _Recorder() + router.listen(FieldPath.LOCKED, recorder.on_value) + router.listen_availability(FieldPath.LOCKED, recorder.on_availability) + + stream.health(True) + stream.emit(FieldPath.LOCKED, True, observed_at=0.0) + clock.now = 5.0 + stream.health(False) + + self.assertEqual(recorder.values, [True]) + self.assertIs(router.value(FieldPath.LOCKED), True) + # Within grace the last known value still stands. + self.assertTrue(router.is_available(FieldPath.LOCKED)) + self.assertEqual(recorder.availability, [False, True]) + + clock.now = 60.0 + self.assertFalse(router.is_available(FieldPath.LOCKED)) + self.assertIs(router.value(FieldPath.LOCKED), True) + + def test_exact_translation_outranks_lossy_from_a_better_priority(self) -> None: + clock = _Clock() + router = StreamRouter(clock=clock) + lossy = _FakePublisher( + "lossy", priority=PRIORITY_STREAM, fidelity=Fidelity.LOSSY + ) + exact = _FakePublisher("exact", priority=PRIORITY_BROADCAST) + router.attach(lossy) + router.attach(exact) + lossy.health(True) + exact.health(True) + + recorder = _Recorder() + router.listen(FieldPath.LOCKED, recorder.on_value) + lossy.emit(FieldPath.LOCKED, True, observed_at=0.0) + exact.emit(FieldPath.LOCKED, False, observed_at=0.0) + + self.assertEqual(recorder.values, [True, False]) + # The lossy source cannot take it back. + lossy.emit(FieldPath.LOCKED, True, observed_at=1.0) + self.assertEqual(recorder.values, [True, False]) + + def test_equal_tier_is_sticky(self) -> None: + clock = _Clock() + router = StreamRouter(clock=clock) + first = _FakePublisher("first", priority=PRIORITY_STREAM) + second = _FakePublisher("second", priority=PRIORITY_STREAM) + router.attach(first) + router.attach(second) + first.health(True) + second.health(True) + + recorder = _Recorder() + router.listen(FieldPath.LOCKED, recorder.on_value) + first.emit(FieldPath.LOCKED, True, observed_at=0.0) + second.emit(FieldPath.LOCKED, False, observed_at=1.0) + self.assertEqual(recorder.values, [True]) + + +class TestFreshness(TestCase): + def test_connection_bound_source_never_goes_stale_while_healthy(self) -> None: + clock = _Clock() + router = StreamRouter(clock=clock) + stream = _FakePublisher("stream", max_age=None) + router.attach(stream) + stream.health(True) + stream.emit(FieldPath.LOCKED, True, observed_at=0.0) + + clock.now = 100_000.0 + self.assertTrue(router.is_available(FieldPath.LOCKED)) + self.assertIs(router.value(FieldPath.LOCKED), True) + + def test_expired_observation_is_rejected_and_yields_to_a_fresh_source(self) -> None: + clock = _Clock() + router = StreamRouter(clock=clock) + aged = _FakePublisher("aged", priority=PRIORITY_STREAM, max_age=60.0) + fresh = _FakePublisher("fresh", priority=PRIORITY_BROADCAST, max_age=60.0) + router.attach(aged) + router.attach(fresh) + aged.health(True) + fresh.health(True) + + recorder = _Recorder() + router.listen(FieldPath.LOCKED, recorder.on_value) + aged.emit(FieldPath.LOCKED, True, observed_at=0.0) + self.assertEqual(recorder.values, [True]) + + # Past its max age the preferred source is no longer a candidate. + clock.now = 100.0 + fresh.emit(FieldPath.LOCKED, False, observed_at=100.0) + self.assertEqual(recorder.values, [True, False]) + + def test_out_of_order_observation_is_ignored(self) -> None: + clock = _Clock() + router = StreamRouter(clock=clock) + stream = _FakePublisher("stream") + router.attach(stream) + stream.health(True) + + recorder = _Recorder() + router.listen(FieldPath.LOCKED, recorder.on_value) + stream.emit(FieldPath.LOCKED, True, observed_at=10.0) + stream.emit(FieldPath.LOCKED, False, observed_at=5.0) + self.assertEqual(recorder.values, [True]) + + +class TestActivation(TestCase): + def test_first_listener_activates_and_last_releases_exactly_once(self) -> None: + router = StreamRouter(clock=_Clock()) + stream = _FakePublisher("stream") + router.attach(stream) + self.assertEqual(stream.requested, []) + + first = router.listen(FieldPath.LOCKED, lambda _: None) + self.assertEqual(stream.requested, [frozenset({FieldPath.LOCKED})]) + + second = router.listen(FieldPath.LOCKED, lambda _: None) + self.assertEqual(len(stream.requested), 1) + + first() + self.assertEqual(stream.released, []) + + second() + self.assertEqual(stream.released, [frozenset({FieldPath.LOCKED})]) + + # Unsubscribing again must not release a second time. + second() + first() + self.assertEqual(len(stream.released), 1) + + def test_activation_is_per_path(self) -> None: + router = StreamRouter(clock=_Clock()) + stream = _FakePublisher("stream") + router.attach(stream) + router.listen(FieldPath.LOCKED, lambda _: None) + router.listen(FieldPath.CHARGE_PORT_DOOR_OPEN, lambda _: None) + self.assertEqual( + stream.requested, + [ + frozenset({FieldPath.LOCKED}), + frozenset({FieldPath.CHARGE_PORT_DOOR_OPEN}), + ], + ) + + def test_publisher_attached_later_is_asked_for_already_active_paths(self) -> None: + router = StreamRouter(clock=_Clock()) + router.listen(FieldPath.LOCKED, lambda _: None) + stream = _FakePublisher("stream") + detach = router.attach(stream) + self.assertEqual(stream.requested, [frozenset({FieldPath.LOCKED})]) + + detach() + self.assertEqual(stream.released, [frozenset({FieldPath.LOCKED})]) + self.assertEqual(stream.detached, 1) + + def test_detaching_the_selected_source_reselects_without_emitting_none( + self, + ) -> None: + clock = _Clock() + router = StreamRouter(clock=clock) + stream = _FakePublisher("stream", priority=PRIORITY_STREAM) + standby = _FakePublisher("standby", priority=PRIORITY_BROADCAST) + detach_stream = router.attach(stream) + router.attach(standby) + stream.health(True) + standby.health(True) + + recorder = _Recorder() + router.listen(FieldPath.LOCKED, recorder.on_value) + stream.emit(FieldPath.LOCKED, True, observed_at=0.0) + standby.emit(FieldPath.LOCKED, False, observed_at=0.0) + + detach_stream() + self.assertEqual(recorder.values, [True, False]) + self.assertNotIn(None, recorder.values) + + def test_attaching_a_duplicate_source_id_is_rejected(self) -> None: + router = StreamRouter(clock=_Clock()) + router.attach(_FakePublisher("stream")) + with self.assertRaises(ValueError): + router.attach(_FakePublisher("stream")) + + def test_a_detached_publisher_can_no_longer_feed_the_router(self) -> None: + clock = _Clock() + router = StreamRouter(clock=clock) + stream = _FakePublisher("stream") + sink_holder: list[PublisherSink] = [] + + original_attach = stream.attach + + def capture(sink: PublisherSink) -> Unsubscribe: + sink_holder.append(sink) + return original_attach(sink) + + stream.attach = capture # type: ignore[method-assign] + detach = router.attach(stream) + stream.health(True) + recorder = _Recorder() + router.listen(FieldPath.LOCKED, recorder.on_value) + detach() + + sink_holder[0].publish( + Observation( + path=FieldPath.LOCKED, + value=True, + observed_at=0.0, + source_id="stream", + ) + ) + self.assertEqual(recorder.values, []) + + def test_a_listener_exception_does_not_stop_later_listeners(self) -> None: + router = StreamRouter(clock=_Clock()) + stream = _FakePublisher("stream") + router.attach(stream) + stream.health(True) + seen: list[bool] = [] + + def boom(_: bool) -> None: + raise RuntimeError("listener blew up") + + router.listen(FieldPath.LOCKED, boom) + router.listen(FieldPath.LOCKED, seen.append) + with self.assertLogs("tesla_fleet_api", level="ERROR"): + stream.emit(FieldPath.LOCKED, True, observed_at=0.0) + self.assertEqual(seen, [True]) + + +class TestDemand(TestCase): + def test_demand_reports_initial_state_then_only_aggregate_edges(self) -> None: + router = StreamRouter(clock=_Clock()) + seen: list[bool] = [] + router.listen_demand(ALL_PATHS, seen.append) + self.assertEqual(seen, [False]) + + locked = router.listen(FieldPath.LOCKED, lambda _: None) + self.assertEqual(seen, [False, True]) + + # A second path in the same set is not a new edge. + port = router.listen(FieldPath.CHARGE_PORT_DOOR_OPEN, lambda _: None) + trunk = router.listen(FieldPath.DOOR_STATE_TRUNK_FRONT, lambda _: None) + self.assertEqual(seen, [False, True]) + + locked() + port() + self.assertEqual(seen, [False, True]) + + # Only when every path in the set is back to zero. + trunk() + self.assertEqual(seen, [False, True, False]) + + def test_demand_starts_true_when_a_listener_already_exists(self) -> None: + router = StreamRouter(clock=_Clock()) + router.listen(FieldPath.LOCKED, lambda _: None) + seen: list[bool] = [] + router.listen_demand(ALL_PATHS, seen.append) + self.assertEqual(seen, [True]) + + def test_demand_unsubscribe_removes_only_that_observer(self) -> None: + router = StreamRouter(clock=_Clock()) + first: list[bool] = [] + second: list[bool] = [] + unsubscribe = router.listen_demand(ALL_PATHS, first.append) + router.listen_demand(ALL_PATHS, second.append) + unsubscribe() + router.listen(FieldPath.LOCKED, lambda _: None) + self.assertEqual(first, [False]) + self.assertEqual(second, [False, True]) + + +class TestRouterCannotOriginateWork(TestCase): + """The load-bearing invariant: the router is structurally unable to poll. + + Asserted against the module's own syntax tree rather than its behaviour, + because a request path that exists but is merely unused would still be a + request path. + """ + + @classmethod + def setUpClass(cls) -> None: + import tesla_fleet_api.router.stream as module + + source = Path(module.__file__).read_text(encoding="utf-8") + cls.tree = ast.parse(source) + + def test_the_module_is_entirely_synchronous(self) -> None: + for node in ast.walk(self.tree): + self.assertNotIsInstance(node, ast.AsyncFunctionDef) + self.assertNotIsInstance(node, ast.Await) + self.assertNotIsInstance(node, ast.AsyncFor) + self.assertNotIsInstance(node, ast.AsyncWith) + + def test_the_module_imports_no_transport_or_scheduling_machinery(self) -> None: + forbidden = { + "asyncio", + "aiohttp", + "aiofiles", + "bleak", + "threading", + "sched", + "requests", + "urllib", + "socket", + } + imported: set[str] = set() + for node in ast.walk(self.tree): + if isinstance(node, ast.Import): + imported.update(alias.name.split(".")[0] for alias in node.names) + elif isinstance(node, ast.ImportFrom) and node.module: + imported.add(node.module.split(".")[0]) + self.assertEqual(imported & forbidden, set()) + + def test_the_module_calls_nothing_that_could_originate_a_request(self) -> None: + forbidden = { + "sleep", + "create_task", + "ensure_future", + "run_coroutine_threadsafe", + "call_later", + "call_soon", + "vehicle_data", + "charge_state", + "vehicle_state", + "connect", + "connect_if_needed", + "wake_up", + "_send", + "_request", + "_getVehicleSecurity", + "_getInfotainment", + "Thread", + "Timer", + } + called: set[str] = set() + for node in ast.walk(self.tree): + if not isinstance(node, ast.Call): + continue + func = node.func + if isinstance(func, ast.Name): + called.add(func.id) + elif isinstance(func, ast.Attribute): + called.add(func.attr) + self.assertEqual(called & forbidden, set()) diff --git a/tests/test_stream_router_bluetooth.py b/tests/test_stream_router_bluetooth.py new file mode 100644 index 0000000..a49bd0a --- /dev/null +++ b/tests/test_stream_router_bluetooth.py @@ -0,0 +1,380 @@ +"""Tests for BleBroadcastPublisher over a real VehicleBluetooth's listener seams. + +Broadcasts are injected through ``vehicle._on_message``, the same routing path +the vehicle uses in production, so the publisher is exercised against the real +``BroadcastListeners`` registries rather than a stand-in. No BLE connection, +GATT traffic, or event loop is involved. +""" + +from __future__ import annotations + +from typing import Any +from unittest import TestCase +from unittest.mock import AsyncMock, MagicMock + +from cryptography.hazmat.primitives.asymmetric import ec + +from tesla_fleet_api.router import ( + BleBroadcastPublisher, + FieldPath, + Observation, + StreamRouter, +) +from tesla_fleet_api.router.stream import PRIORITY_BROADCAST, PRIORITY_STREAM +from tesla_fleet_api.tesla.vehicle.bluetooth import VehicleBluetooth +from tesla_protocol.command.universal_message_pb2 import ( + Destination, + Domain, + RoutableMessage, +) +from tesla_protocol.command.vcsec_pb2 import ( + ClosureState_E, + ClosureStatuses, + FromVCSECMessage, + VehicleLockState_E, + VehicleStatus, +) + +VIN = "5YJXCAE43LF123456" +DOMAIN = Domain.DOMAIN_VEHICLE_SECURITY + + +class _Clock: + def __init__(self, now: float = 0.0) -> None: + self.now = now + + def __call__(self) -> float: + return self.now + + +def _make_vehicle(*, connected: bool = True) -> VehicleBluetooth[Any]: + parent = MagicMock() + parent.private_key = ec.generate_private_key(ec.SECP256R1()) + vehicle = VehicleBluetooth(parent, VIN) + vehicle.connect_if_needed = AsyncMock() # type: ignore[method-assign] + vehicle.connect = AsyncMock() # type: ignore[method-assign] + vehicle.client = MagicMock() + vehicle.client.is_connected = connected + vehicle.client.write_gatt_char = AsyncMock() + return vehicle + + +def _broadcast(status: VehicleStatus) -> RoutableMessage: + """An unsolicited (unaddressed) VCSEC status broadcast.""" + return RoutableMessage( + from_destination=Destination(domain=DOMAIN), + protobuf_message_as_bytes=FromVCSECMessage( + vehicleStatus=status + ).SerializeToString(), + ) + + +def _lock(state: VehicleLockState_E) -> RoutableMessage: + return _broadcast(VehicleStatus(vehicleLockState=state)) + + +def _closures(**kwargs: ClosureState_E) -> RoutableMessage: + return _broadcast(VehicleStatus(closureStatuses=ClosureStatuses(**kwargs))) + + +CLOSURE_PATHS = frozenset( + {FieldPath.CHARGE_PORT_DOOR_OPEN, FieldPath.DOOR_STATE_TRUNK_FRONT} +) + + +class _Sink: + """Collects observations and health straight off the publisher.""" + + def __init__(self) -> None: + self.observations: list[Observation] = [] + self.health: list[tuple[str, bool]] = [] + + def publish(self, observation: Observation) -> None: + self.observations.append(observation) + + def set_health(self, source_id: str, healthy: bool) -> None: + self.health.append((source_id, healthy)) + + +def _attached( + vehicle: VehicleBluetooth[Any], *, paths: frozenset[FieldPath] | None = None +) -> tuple[BleBroadcastPublisher, _Sink]: + publisher = BleBroadcastPublisher(vehicle, clock=_Clock()) + sink = _Sink() + publisher.attach(sink) + publisher.request(frozenset(FieldPath) if paths is None else paths) + return publisher, sink + + +class TestBroadcastTranslation(TestCase): + def test_ordinary_lock_states_map_to_booleans(self) -> None: + vehicle = _make_vehicle() + _, sink = _attached(vehicle) + + vehicle._on_message(_lock(VehicleLockState_E.VEHICLELOCKSTATE_LOCKED)) + vehicle._on_message(_lock(VehicleLockState_E.VEHICLELOCKSTATE_UNLOCKED)) + + self.assertEqual( + [(o.path, o.value) for o in sink.observations], + [(FieldPath.LOCKED, True), (FieldPath.LOCKED, False)], + ) + + def test_unvalidated_lock_states_emit_no_observation(self) -> None: + """An unmapped enum keeps the last confirmed value instead of guessing.""" + vehicle = _make_vehicle() + _, sink = _attached(vehicle) + + vehicle._on_message(_lock(VehicleLockState_E.VEHICLELOCKSTATE_INTERNAL_LOCKED)) + vehicle._on_message( + _lock(VehicleLockState_E.VEHICLELOCKSTATE_SELECTIVE_UNLOCKED) + ) + + self.assertEqual(sink.observations, []) + + def test_closure_states_map_to_booleans(self) -> None: + vehicle = _make_vehicle() + _, sink = _attached(vehicle, paths=CLOSURE_PATHS) + + for state, expected in ( + (ClosureState_E.CLOSURESTATE_CLOSED, False), + (ClosureState_E.CLOSURESTATE_OPEN, True), + (ClosureState_E.CLOSURESTATE_AJAR, True), + (ClosureState_E.CLOSURESTATE_OPENING, True), + (ClosureState_E.CLOSURESTATE_CLOSING, True), + ): + sink.observations.clear() + vehicle._on_message(_closures(chargePort=state, frontTrunk=state)) + self.assertEqual( + {(o.path, o.value) for o in sink.observations}, + { + (FieldPath.CHARGE_PORT_DOOR_OPEN, expected), + (FieldPath.DOOR_STATE_TRUNK_FRONT, expected), + }, + msg=f"closure state {state}", + ) + + def test_ambiguous_closure_states_emit_no_observation(self) -> None: + vehicle = _make_vehicle() + _, sink = _attached(vehicle, paths=CLOSURE_PATHS) + + for state in ( + ClosureState_E.CLOSURESTATE_UNKNOWN, + ClosureState_E.CLOSURESTATE_FAILED_UNLATCH, + ): + vehicle._on_message(_closures(chargePort=state, frontTrunk=state)) + + self.assertEqual(sink.observations, []) + + def test_a_broadcast_without_closures_emits_no_closure_observation(self) -> None: + """proto3 tracks presence for the submessage, so absence is not CLOSED.""" + vehicle = _make_vehicle() + _, sink = _attached(vehicle) + + vehicle._on_message(_lock(VehicleLockState_E.VEHICLELOCKSTATE_LOCKED)) + + self.assertEqual([o.path for o in sink.observations], [FieldPath.LOCKED]) + + def test_every_status_broadcast_carries_a_lock_state(self) -> None: + """UNLOCKED is 0, so the wire cannot distinguish it from an absent field. + + A closure-only broadcast therefore still reports the lock state, and + the router deduplicates the repeat rather than the publisher guessing. + """ + vehicle = _make_vehicle() + _, sink = _attached(vehicle, paths=frozenset({FieldPath.LOCKED})) + + vehicle._on_message(_closures(chargePort=ClosureState_E.CLOSURESTATE_OPEN)) + + self.assertEqual( + [(o.path, o.value) for o in sink.observations], + [(FieldPath.LOCKED, False)], + ) + + +class TestBroadcastActivation(TestCase): + def test_request_and_release_bracket_the_ble_listener(self) -> None: + vehicle = _make_vehicle() + publisher, sink = _attached(vehicle, paths=frozenset({FieldPath.LOCKED})) + + vehicle._on_message(_lock(VehicleLockState_E.VEHICLELOCKSTATE_LOCKED)) + self.assertEqual(len(sink.observations), 1) + + publisher.release(frozenset({FieldPath.LOCKED})) + vehicle._on_message(_lock(VehicleLockState_E.VEHICLELOCKSTATE_UNLOCKED)) + self.assertEqual(len(sink.observations), 1) + + def test_only_requested_paths_are_subscribed(self) -> None: + vehicle = _make_vehicle() + _, sink = _attached(vehicle, paths=frozenset({FieldPath.CHARGE_PORT_DOOR_OPEN})) + + vehicle._on_message( + _closures( + chargePort=ClosureState_E.CLOSURESTATE_OPEN, + frontTrunk=ClosureState_E.CLOSURESTATE_OPEN, + ) + ) + self.assertEqual( + [(o.path, o.value) for o in sink.observations], + [(FieldPath.CHARGE_PORT_DOOR_OPEN, True)], + ) + + def test_detach_drops_every_subscription(self) -> None: + vehicle = _make_vehicle() + publisher = BleBroadcastPublisher(vehicle, clock=_Clock()) + sink = _Sink() + detach = publisher.attach(sink) + publisher.request(frozenset(FieldPath)) + detach() + + vehicle._on_message(_lock(VehicleLockState_E.VEHICLELOCKSTATE_LOCKED)) + self.assertEqual(sink.observations, []) + + def test_health_follows_the_existing_connection_status_seam(self) -> None: + vehicle = _make_vehicle(connected=False) + publisher = BleBroadcastPublisher(vehicle, clock=_Clock()) + sink = _Sink() + publisher.attach(sink) + self.assertEqual(sink.health, [("ble-broadcast", False)]) + + vehicle._set_connected(True) + vehicle._set_connected(False) + self.assertEqual( + sink.health, + [ + ("ble-broadcast", False), + ("ble-broadcast", True), + ("ble-broadcast", False), + ], + ) + + def test_a_session_already_up_at_attach_is_reported_healthy(self) -> None: + """listen_connection_status only fires on transitions.""" + vehicle = _make_vehicle(connected=True) + publisher = BleBroadcastPublisher(vehicle, clock=_Clock()) + sink = _Sink() + publisher.attach(sink) + self.assertEqual(sink.health, [("ble-broadcast", True)]) + + def test_the_publisher_never_drives_the_transport(self) -> None: + vehicle = _make_vehicle() + publisher, _ = _attached(vehicle) + vehicle._on_message(_lock(VehicleLockState_E.VEHICLELOCKSTATE_LOCKED)) + publisher.release(frozenset(FieldPath)) + + vehicle.connect.assert_not_awaited() # type: ignore[attr-defined] + vehicle.connect_if_needed.assert_not_awaited() # type: ignore[attr-defined] + vehicle.client.write_gatt_char.assert_not_awaited() + + +class TestRegressionWalkthrough(TestCase): + """The captain's 6.1.1 failure: Bluetooth cannot connect, the stream can. + + Charge port, front trunk and lock must stay available with real values + rather than going unavailable because a source they were bound to is down. + """ + + def _router_with_stream_and_ble( + self, vehicle: VehicleBluetooth[Any], clock: _Clock + ) -> tuple[StreamRouter, _StubStreamPublisher]: + router = StreamRouter(clock=clock) + stream = _StubStreamPublisher(clock) + router.attach(stream) + router.attach( + BleBroadcastPublisher(vehicle, priority=PRIORITY_BROADCAST, clock=clock) + ) + return router, stream + + def test_unavailable_bluetooth_does_not_blank_streamed_fields(self) -> None: + clock = _Clock() + vehicle = _make_vehicle(connected=False) + router, stream = self._router_with_stream_and_ble(vehicle, clock) + + seen: dict[FieldPath, list[Any]] = {p: [] for p in FieldPath} + for path in FieldPath: + router.listen(path, seen[path].append) + + stream.emit(FieldPath.CHARGE_PORT_DOOR_OPEN, False) + stream.emit(FieldPath.DOOR_STATE_TRUNK_FRONT, False) + stream.emit(FieldPath.LOCKED, True) + + self.assertEqual(seen[FieldPath.CHARGE_PORT_DOOR_OPEN], [False]) + self.assertEqual(seen[FieldPath.DOOR_STATE_TRUNK_FRONT], [False]) + self.assertEqual(seen[FieldPath.LOCKED], [True]) + for path in FieldPath: + self.assertTrue(router.is_available(path), msg=str(path)) + self.assertNotIn(None, seen[path]) + + def test_ble_takes_over_when_the_stream_drops_then_hands_back(self) -> None: + clock = _Clock() + vehicle = _make_vehicle(connected=True) + router, stream = self._router_with_stream_and_ble(vehicle, clock) + + seen: list[Any] = [] + available: list[Any] = [] + router.listen(FieldPath.LOCKED, seen.append) + router.listen_availability(FieldPath.LOCKED, available.append) + + stream.emit(FieldPath.LOCKED, True) + vehicle._on_message(_lock(VehicleLockState_E.VEHICLELOCKSTATE_LOCKED)) + self.assertEqual(seen, [True]) + + clock.now = 10.0 + stream.set_health(False) + vehicle._on_message(_lock(VehicleLockState_E.VEHICLELOCKSTATE_UNLOCKED)) + self.assertEqual(seen, [True, False]) + + # The stream must hold health for the failback delay before it takes + # the field back off a working Bluetooth session. + clock.now = 100.0 + stream.set_health(True) + stream.emit(FieldPath.LOCKED, True) + self.assertEqual(seen, [True, False]) + + clock.now = 200.0 + stream.emit(FieldPath.LOCKED, True) + self.assertEqual(seen, [True, False, True]) + + self.assertNotIn(None, seen) + self.assertEqual(available, [False, True]) + + +class _StubStreamPublisher: + """Stands in for the stream library's SSE adapter, which is a later slice.""" + + def __init__(self, clock: _Clock) -> None: + self.source_id = "stream" + self.priority = PRIORITY_STREAM + self._clock = clock + self._sink: Any = None + + @property + def capabilities(self) -> tuple[Any, ...]: + from tesla_fleet_api.router.stream import Capability, Delivery + + return tuple( + Capability(path=path, delivery=Delivery.ON_CHANGE) for path in FieldPath + ) + + def attach(self, sink: Any) -> Any: + self._sink = sink + sink.set_health(self.source_id, True) + return lambda: None + + def request(self, paths: frozenset[FieldPath]) -> None: + return None + + def release(self, paths: frozenset[FieldPath]) -> None: + return None + + def emit(self, path: FieldPath, value: bool) -> None: + self._sink.publish( + Observation( + path=path, + value=value, + observed_at=self._clock(), + source_id=self.source_id, + ) + ) + + def set_health(self, healthy: bool) -> None: + self._sink.set_health(self.source_id, healthy) diff --git a/tests/test_stream_router_vehicle_data.py b/tests/test_stream_router_vehicle_data.py new file mode 100644 index 0000000..1939784 --- /dev/null +++ b/tests/test_stream_router_vehicle_data.py @@ -0,0 +1,238 @@ +"""Tests for VehicleDataResultPublisher: audited leaves, and no way to fetch. + +Every result here is a literal dictionary written in the test. The publisher is +given no client, session, or callable, so a value it produces can only have come +from that literal - which is what makes the "cannot request" claim checkable +rather than asserted. +""" + +from __future__ import annotations + +import inspect +from typing import Any +from unittest import TestCase + +from tesla_fleet_api.router import ( + FieldPath, + Observation, + StreamRouter, + VehicleDataResultPublisher, +) + +# A trimmed but structurally real vehicle_data response. +RESULT: dict[str, Any] = { + "response": { + "id": 1234567890, + "vin": "5YJXCAE43LF123456", + "state": "online", + "charge_state": { + "battery_level": 72, + "charge_port_door_open": True, + "charge_port_latch": "Engaged", + }, + "climate_state": {"inside_temp": 21.5}, + "vehicle_state": { + "locked": True, + "ft": 0, + "rt": 0, + "df": 0, + "car_version": "2025.14.3", + }, + } +} + + +class _Clock: + def __init__(self, now: float = 0.0) -> None: + self.now = now + + def __call__(self) -> float: + return self.now + + +class _Sink: + def __init__(self) -> None: + self.observations: list[Observation] = [] + self.health: list[tuple[str, bool]] = [] + + def publish(self, observation: Observation) -> None: + self.observations.append(observation) + + def set_health(self, source_id: str, healthy: bool) -> None: + self.health.append((source_id, healthy)) + + +def _translate(result: dict[str, Any]) -> dict[FieldPath, bool]: + publisher = VehicleDataResultPublisher(clock=_Clock()) + return {o.path: o.value for o in publisher.publish_result(result)} + + +class TestAuditedLeaves(TestCase): + def test_maps_exactly_the_three_audited_leaves(self) -> None: + self.assertEqual( + _translate(RESULT), + { + FieldPath.LOCKED: True, + FieldPath.CHARGE_PORT_DOOR_OPEN: True, + FieldPath.DOOR_STATE_TRUNK_FRONT: False, + }, + ) + + def test_a_bare_response_body_is_accepted(self) -> None: + self.assertEqual(_translate(RESULT["response"]), _translate(RESULT)) + + def test_absent_leaves_emit_no_observation(self) -> None: + self.assertEqual(_translate({"response": {}}), {}) + self.assertEqual( + _translate({"response": {"vehicle_state": {}, "charge_state": {}}}), {} + ) + + def test_a_null_leaf_emits_no_observation(self) -> None: + result = {"response": {"vehicle_state": {"locked": None, "ft": None}}} + self.assertEqual(_translate(result), {}) + + def test_a_non_boolean_locked_emits_no_observation(self) -> None: + result = {"response": {"vehicle_state": {"locked": "true"}}} + self.assertEqual(_translate(result), {}) + + def test_front_trunk_maps_only_the_documented_codes(self) -> None: + for code, expected in ((0, False), (1, True)): + result = {"response": {"vehicle_state": {"ft": code}}} + self.assertEqual( + _translate(result), {FieldPath.DOOR_STATE_TRUNK_FRONT: expected} + ) + + for code in (2, 3, 255, -1): + result = {"response": {"vehicle_state": {"ft": code}}} + self.assertEqual(_translate(result), {}, msg=f"ft={code}") + + def test_a_boolean_front_trunk_is_not_read_as_a_code(self) -> None: + """``False == 0`` in Python; the wire code and a bool are not the same fact.""" + result = {"response": {"vehicle_state": {"ft": False}}} + self.assertEqual(_translate(result), {}) + + def test_unaudited_leaves_are_never_routed(self) -> None: + """A present, easily flattened leaf is still not a routable field.""" + observations = VehicleDataResultPublisher(clock=_Clock()).publish_result(RESULT) + self.assertEqual({o.path for o in observations}, set(FieldPath)) + + def test_a_malformed_result_is_ignored_rather_than_guessed(self) -> None: + self.assertEqual(_translate({}), {}) + self.assertEqual(_translate({"response": None}), {}) + self.assertEqual(_translate({"response": "unavailable"}), {}) + self.assertEqual(_translate({"response": {"vehicle_state": []}}), {}) + + +class TestSuppliedResultRouting(TestCase): + def test_a_supplied_result_reaches_listeners_through_arbitration(self) -> None: + clock = _Clock() + router = StreamRouter(clock=clock) + publisher = VehicleDataResultPublisher(clock=clock) + router.attach(publisher) + + seen: list[Any] = [] + router.listen(FieldPath.LOCKED, seen.append) + publisher.publish_result(RESULT) + + self.assertEqual(seen, [True]) + self.assertIs(router.value(FieldPath.LOCKED), True) + self.assertTrue(router.is_available(FieldPath.LOCKED)) + + def test_a_supplied_result_expires_and_schedules_no_refresh(self) -> None: + clock = _Clock() + router = StreamRouter(clock=clock, grace=0.0) + publisher = VehicleDataResultPublisher(clock=clock, max_age=60.0) + router.attach(publisher) + router.listen(FieldPath.LOCKED, lambda _: None) + + publisher.publish_result(RESULT) + clock.now = 30.0 + self.assertTrue(router.is_available(FieldPath.LOCKED)) + + clock.now = 120.0 + self.assertFalse(router.is_available(FieldPath.LOCKED)) + # Expiry retains the last known value and originates nothing. + self.assertIs(router.value(FieldPath.LOCKED), True) + + def test_activation_asks_a_passive_source_for_nothing(self) -> None: + router = StreamRouter(clock=_Clock()) + publisher = VehicleDataResultPublisher(clock=_Clock()) + router.attach(publisher) + sink = _Sink() + publisher.attach(sink) + + unsubscribe = router.listen(FieldPath.LOCKED, lambda _: None) + unsubscribe() + # A request/release round trip produced no observation of its own. + self.assertEqual(sink.observations, []) + + def test_publishing_before_attach_translates_but_reaches_no_listener(self) -> None: + router = StreamRouter(clock=_Clock()) + publisher = VehicleDataResultPublisher(clock=_Clock()) + seen: list[Any] = [] + router.listen(FieldPath.LOCKED, seen.append) + + self.assertEqual(len(publisher.publish_result(RESULT)), 3) + self.assertEqual(seen, []) + + +class TestPublisherCannotRequestData(TestCase): + """The publisher's only data source is the dictionary handed to it.""" + + def test_it_exposes_no_coroutine_and_no_awaitable_member(self) -> None: + publisher = VehicleDataResultPublisher(clock=_Clock()) + for name, member in inspect.getmembers(publisher): + self.assertFalse( + inspect.iscoroutinefunction(member), + msg=f"{name} is a coroutine function", + ) + self.assertFalse(inspect.isawaitable(member), msg=f"{name} is awaitable") + + def test_it_holds_no_client_session_or_fetch_callable(self) -> None: + publisher = VehicleDataResultPublisher(clock=_Clock()) + held = { + name: value + for name, value in vars(publisher).items() + if name not in ("_clock", "_sink", "_capabilities") + } + self.assertEqual(held, {"source_id": "vehicle-data", "priority": 40}) + + # The clock is the only callable it keeps, and it takes no arguments. + self.assertEqual( + list(inspect.signature(publisher._clock).parameters), # type: ignore[attr-defined] + [], + ) + + def test_it_yields_nothing_when_no_result_is_supplied(self) -> None: + """With the fake input withheld there is no other source to fall back on.""" + clock = _Clock() + router = StreamRouter(clock=clock) + publisher = VehicleDataResultPublisher(clock=clock) + router.attach(publisher) + + seen: list[Any] = [] + router.listen(FieldPath.LOCKED, seen.append) + router.listen(FieldPath.CHARGE_PORT_DOOR_OPEN, seen.append) + router.listen(FieldPath.DOOR_STATE_TRUNK_FRONT, seen.append) + + clock.now = 10_000.0 + self.assertEqual(seen, []) + for path in FieldPath: + self.assertIsNone(router.value(path)) + self.assertFalse(router.is_available(path)) + + def test_an_object_that_could_fetch_is_never_called(self) -> None: + """A result-shaped mapping whose lookups are counted proves the reads.""" + calls: list[str] = [] + + class _Tripwire(dict[str, Any]): + def __getitem__(self, key: str) -> Any: + calls.append(key) + return super().__getitem__(key) + + def fetch(self) -> None: # pragma: no cover - must never be reached + raise AssertionError("the publisher invoked a fetch callable") + + publisher = VehicleDataResultPublisher(clock=_Clock()) + publisher.publish_result(_Tripwire(RESULT)) + self.assertEqual(calls, ["response"]) From e758ab4b9a33d1b8a2c7192894a07a80fd8186da Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Sat, 22 Aug 2026 20:11:25 +1000 Subject: [PATCH 2/7] fix(router): ignore observations from an unhealthy source A frame already in flight can land after its transport loss is recorded. set_health(False) cleared cached observations but publish() kept accepting new ones, so the pre-disconnect reading was re-cached and, because broadcast capabilities are connection-bound and never expire, selected and emitted as a current reading of the recovered session. --- AGENTS.md | 2 +- tesla_fleet_api/router/stream.py | 4 +++ tests/test_stream_router.py | 46 ++++++++++++++++++++++++++++++++ 3 files changed, 51 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 7479cd3..f2003c7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -69,7 +69,7 @@ Commands (vehicle/commands.py) - protobuf-based signed command implementation (A `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. -`StreamRouter` (`router/stream.py`) is the **read** router, a separate mechanism from the command `Router` above and not in its inheritance chain. It arbitrates per-field push observations across sources so a field bound to one source does not go unavailable when that source does. It is **entirely synchronous and can never originate a request**: no `async def`/`await`, no polling loop, no request callable, no scheduling task, no cost or billing policy anywhere in the module — `tests/test_stream_router.py::TestRouterCannotOriginateWork` 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 router (which is itself the `PublisherSink`) via `publish(Observation)` and `set_health(source_id, healthy)`; health is a separate channel from data, so transport loss never reaches a listener as `None`. Selection per `FieldPath` is: healthy + within the capability's `max_age` (`None` = connection-bound, silence is not staleness) → exact over lossy → lowest `priority` int (`PRIORITY_STREAM` < `PRIORITY_BLE_PUSH` < `PRIORITY_BROADCAST` < `PRIORITY_SUPPLIED_RESULT`, overridable per publisher) → sticky within a tier → most recent. `recovered_at` applies the failback delay only to a source that regained health after a loss, never to one coming up for the first time. `is_available(path)` recomputes freshness at call time because grace expiry has no event to fire a callback on; `value(path)` returns last-known and its `None` means only "never observed". 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. `BleBroadcastPublisher` reuses the existing `VehicleBluetooth` `listen_vehicle_lock_state`/`listen_charge_port`/`listen_front_trunk`/`listen_connection_status` 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 router deduplicates the repeats. VCSEC `INTERNAL_LOCKED`/`SELECTIVE_UNLOCKED` and closure `UNKNOWN`/`FAILED_UNLATCH` are unmapped pending live-frame validation. +`StreamRouter` (`router/stream.py`) is the **read** router, a separate mechanism from the command `Router` above and not in its inheritance chain. It arbitrates per-field push observations across sources so a field bound to one source does not go unavailable when that source does. It is **entirely synchronous and can never originate a request**: no `async def`/`await`, no polling loop, no request callable, no scheduling task, no cost or billing policy anywhere in the module — `tests/test_stream_router.py::TestRouterCannotOriginateWork` 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 router (which is itself the `PublisherSink`) via `publish(Observation)` and `set_health(source_id, healthy)`; health is a separate channel from data, so transport loss never reaches a listener as `None`. An unhealthy source is dead in both directions: `set_health(False)` clears its observations **and** `publish()` drops anything arriving while it is unhealthy, so a frame racing its own disconnect cannot be cached and then resurrected as a current reading when the source reconnects (connection-bound capabilities never expire it). Selection per `FieldPath` is: healthy + within the capability's `max_age` (`None` = connection-bound, silence is not staleness) → exact over lossy → lowest `priority` int (`PRIORITY_STREAM` < `PRIORITY_BLE_PUSH` < `PRIORITY_BROADCAST` < `PRIORITY_SUPPLIED_RESULT`, overridable per publisher) → sticky within a tier → most recent. `recovered_at` applies the failback delay only to a source that regained health after a loss, never to one coming up for the first time. `is_available(path)` recomputes freshness at call time because grace expiry has no event to fire a callback on; `value(path)` returns last-known and its `None` means only "never observed". 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. `BleBroadcastPublisher` reuses the existing `VehicleBluetooth` `listen_vehicle_lock_state`/`listen_charge_port`/`listen_front_trunk`/`listen_connection_status` 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 router deduplicates the repeats. VCSEC `INTERNAL_LOCKED`/`SELECTIVE_UNLOCKED` and closure `UNKNOWN`/`FAILED_UNLATCH` are unmapped pending live-frame validation. `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. diff --git a/tesla_fleet_api/router/stream.py b/tesla_fleet_api/router/stream.py index a6c781d..4eee956 100644 --- a/tesla_fleet_api/router/stream.py +++ b/tesla_fleet_api/router/stream.py @@ -251,6 +251,10 @@ def publish(self, observation: Observation) -> None: state = self._sources.get(observation.source_id) if state is None or observation.path not in state.capabilities: return + # A frame racing its own transport loss cannot be cached, or reconnect + # would resurrect it as a current reading of the recovered session. + if not state.healthy: + return previous = state.observations.get(observation.path) if previous is not None and previous.observed_at > observation.observed_at: return diff --git a/tests/test_stream_router.py b/tests/test_stream_router.py index 17f22cd..f9649ef 100644 --- a/tests/test_stream_router.py +++ b/tests/test_stream_router.py @@ -175,6 +175,52 @@ def test_fails_over_both_directions_without_a_transient_none(self) -> None: # One False at registration, one True on the first value: never dipped. self.assertEqual(recorder.availability, [False, True]) + def test_a_frame_racing_a_disconnect_is_not_resurrected_by_reconnect(self) -> None: + """A dead source's reading may not come back as a live one. + + A broadcast already in flight can land after the transport loss is + recorded. Broadcast capabilities are connection-bound, so caching it + would let reconnect present a pre-disconnect value as an observation of + the recovered session, and nothing would ever expire it. + """ + clock = _Clock() + router = StreamRouter(clock=clock, grace=50.0) + ble = _FakePublisher("ble", priority=PRIORITY_BROADCAST, max_age=None) + router.attach(ble) + ble.health(True) + + recorder = _Recorder() + router.listen(FieldPath.LOCKED, recorder.on_value) + router.listen_availability(FieldPath.LOCKED, recorder.on_availability) + ble.emit(FieldPath.LOCKED, False, observed_at=0.0) + self.assertEqual(recorder.values, [False]) + + clock.now = 10.0 + ble.health(False) + + # The race: the frame for a lock that happened as the link dropped + # arrives after the loss was recorded. + clock.now = 11.0 + ble.emit(FieldPath.LOCKED, True, observed_at=11.0) + + # The car is unlocked again while the link is down, so that frame is + # already wrong by the time the link comes back. + clock.now = 20.0 + ble.health(True) + self.assertEqual(recorder.values, [False]) + self.assertIs(router.value(FieldPath.LOCKED), False) + + # Nor may it hold the field open: with nothing observed in the new + # session, last-known availability still expires on the grace window. + clock.now = 70.0 + self.assertFalse(router.is_available(FieldPath.LOCKED)) + self.assertEqual(recorder.values, [False]) + + # The recovered session itself is unaffected. + ble.emit(FieldPath.LOCKED, True, observed_at=70.0) + self.assertEqual(recorder.values, [False, True]) + self.assertTrue(router.is_available(FieldPath.LOCKED)) + def test_losing_every_source_keeps_last_known_and_emits_nothing(self) -> None: clock = _Clock() router = StreamRouter(clock=clock, grace=50.0) From 7361fe3b305d7aa4958bec7d16718e5be639d6ae Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Sat, 22 Aug 2026 20:29:26 +1000 Subject: [PATCH 3/7] fix(router): rank only eligible sources and correct announced availability A source held by its failback delay was ranked before being filtered out, so an eligible middle-priority source could not displace a worse one while the top source waited. Availability announced to a late listener was recorded with setdefault, leaving a stale True that suppressed the next real recovery for that listener. --- tesla_fleet_api/router/stream.py | 44 ++++++++------- tests/test_stream_router.py | 93 ++++++++++++++++++++++++++++++++ 2 files changed, 114 insertions(+), 23 deletions(-) diff --git a/tesla_fleet_api/router/stream.py b/tesla_fleet_api/router/stream.py index 4eee956..31f533c 100644 --- a/tesla_fleet_api/router/stream.py +++ b/tesla_fleet_api/router/stream.py @@ -308,33 +308,29 @@ def _reselect(self, path: FieldPath) -> None: current = next( (c for c in candidates if c.state.publisher.source_id == selected_id), None ) - best_rank = min(_rank(c) for c in candidates) + eligible = candidates + if current is not None: + # A source that just regained health holds it briefly before it can + # take the field back off a working standby. Ineligible sources are + # removed before ranking so a delayed top priority cannot mask a + # better source that is eligible right now. + now = self._clock() + eligible = [ + c + for c in candidates + if c is current + or c.state.recovered_at is None + or now - c.state.recovered_at >= self._failback_delay + ] + + best_rank = min(_rank(c) for c in eligible) if current is not None and _rank(current) == best_rank: # Stickiness within a tier: an equally ranked source does not # displace the one already selected. chosen = current else: - better = [c for c in candidates if _rank(c) == best_rank] - if current is not None: - # A source that just regained health holds it briefly before it - # can take the field back off a working standby. - now = self._clock() - better = [ - c - for c in better - if c.state.recovered_at is None - or now - c.state.recovered_at >= self._failback_delay - ] - chosen = ( - max(better, key=lambda c: c.observation.observed_at) - if better - else current - ) - - if chosen is None: - self._selected.pop(path, None) - self._announce_availability(path) - return + better = [c for c in eligible if _rank(c) == best_rank] + chosen = max(better, key=lambda c: c.observation.observed_at) self._selected[path] = chosen.state.publisher.source_id self._observed_at[path] = chosen.observation.observed_at @@ -426,7 +422,9 @@ def listen_availability( listeners = self._availability_listeners.setdefault(path, []) listeners.append(callback) available = self.is_available(path) - self._announced_availability.setdefault(path, available) + # Assigned, not defaulted: a cache still holding True for a field that + # has since aged out of grace would suppress the next real recovery. + self._announced_availability[path] = available _dispatch(callback, available) def unsubscribe() -> None: diff --git a/tests/test_stream_router.py b/tests/test_stream_router.py index f9649ef..5f6fc4a 100644 --- a/tests/test_stream_router.py +++ b/tests/test_stream_router.py @@ -21,8 +21,10 @@ StreamRouter, ) from tesla_fleet_api.router.stream import ( + PRIORITY_BLE_PUSH, PRIORITY_BROADCAST, PRIORITY_STREAM, + PRIORITY_SUPPLIED_RESULT, Unsubscribe, ) @@ -283,6 +285,97 @@ def test_equal_tier_is_sticky(self) -> None: second.emit(FieldPath.LOCKED, False, observed_at=1.0) self.assertEqual(recorder.values, [True]) + def test_a_delayed_top_source_does_not_mask_an_eligible_middle_one(self) -> None: + """A field must not sit on a worse backend while a better one is ready. + + The top source is inside its failback delay, so it cannot take the field + back yet. That must not stop an untouched middle-priority source from + displacing the worst one it is currently sitting on. + """ + clock = _Clock() + router = StreamRouter(clock=clock, failback_delay=5.0) + stream = _FakePublisher("stream", priority=PRIORITY_STREAM) + ble = _FakePublisher("ble", priority=PRIORITY_BLE_PUSH) + supplied = _FakePublisher("supplied", priority=PRIORITY_SUPPLIED_RESULT) + for publisher in (stream, ble, supplied): + router.attach(publisher) + publisher.health(True) + + recorder = _Recorder() + router.listen(FieldPath.LOCKED, recorder.on_value) + + # The top source holds the field; the middle one has said nothing yet. + stream.emit(FieldPath.LOCKED, True, observed_at=0.0) + supplied.emit(FieldPath.LOCKED, False, observed_at=0.0) + self.assertEqual(recorder.values, [True]) + + # The top source drops, leaving only the worst source with a reading. + clock.now = 10.0 + stream.health(False) + self.assertEqual(recorder.values, [True, False]) + + # It comes back and reports, but is held by the failback delay. + clock.now = 20.0 + stream.health(True) + stream.emit(FieldPath.LOCKED, True, observed_at=20.0) + self.assertEqual(recorder.values, [True, False]) + + # The middle source never lost health, so it is eligible now and must + # take the field off the worst source rather than waiting out a delay + # that belongs to a different source. + ble.emit(FieldPath.LOCKED, True, observed_at=21.0) + self.assertEqual(recorder.values, [True, False, True]) + + # Prove the middle source really holds it: only the selected source can + # move the value, and the held top source still cannot. + stream.emit(FieldPath.LOCKED, False, observed_at=22.0) + self.assertEqual(recorder.values, [True, False, True]) + ble.emit(FieldPath.LOCKED, False, observed_at=23.0) + self.assertEqual(recorder.values, [True, False, True, False]) + + +class TestAvailabilityAnnouncement(TestCase): + def test_a_listener_registered_after_grace_expiry_still_gets_the_recovery( + self, + ) -> None: + """A late listener must not be permanently stuck on unavailable. + + Grace expiry has no event to announce, so a listener registering after + it is told the recomputed truth. If that recomputation did not also + correct what the router believes it has announced, the next real + recovery looks like a repeat and this listener never hears it. + """ + clock = _Clock() + router = StreamRouter(clock=clock, grace=300.0) + source = _FakePublisher("source") + router.attach(source) + source.health(True) + + source.emit(FieldPath.LOCKED, True, observed_at=0.0) + early = _Recorder() + router.listen_availability(FieldPath.LOCKED, early.on_availability) + self.assertEqual(early.availability, [True]) + + # Transport loss: last known data still stands during grace. + source.health(False) + self.assertEqual(early.availability, [True]) + self.assertTrue(router.is_available(FieldPath.LOCKED)) + + # Grace lapses silently; there is no event to fire on. + clock.now = 400.0 + self.assertFalse(router.is_available(FieldPath.LOCKED)) + self.assertEqual(early.availability, [True]) + + late = _Recorder() + router.listen_availability(FieldPath.LOCKED, late.on_availability) + self.assertEqual(late.availability, [False]) + + # A real recovery must reach the listener that was told False. + source.health(True) + source.emit(FieldPath.LOCKED, True, observed_at=400.0) + self.assertEqual(late.availability, [False, True]) + self.assertTrue(router.is_available(FieldPath.LOCKED)) + class TestFreshness(TestCase): def test_connection_bound_source_never_goes_stale_while_healthy(self) -> None: From d96fc8e3fa5eca21903baf1fd47ff12da25d7d84 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Sat, 22 Aug 2026 20:33:37 +1000 Subject: [PATCH 4/7] no-mistakes(document): docs(router): fix stale __init__ docstring for StreamRouter --- tesla_fleet_api/router/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tesla_fleet_api/router/__init__.py b/tesla_fleet_api/router/__init__.py index 91c086f..5cd72f9 100644 --- a/tesla_fleet_api/router/__init__.py +++ b/tesla_fleet_api/router/__init__.py @@ -1,4 +1,4 @@ -"""Routing wrappers that chain backends with per-command failover.""" +"""Routing wrappers: per-command failover (`Router`) and read-side arbitration (`StreamRouter`).""" from tesla_fleet_api.router.base import HealthCheck, Router from tesla_fleet_api.router.vehicle import VehicleRouter From e4622f4813a00ce137cf17d92b0c4bd01d573cbe Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Sat, 22 Aug 2026 20:45:44 +1000 Subject: [PATCH 5/7] fix(router): anchor grace to source loss, not to the last observation An on-change source only speaks when the value changes, so a healthy BLE broadcast source is legitimately hours behind. Measuring the grace window from the chosen observation's own timestamp meant that when such a source dropped, the window was already spent and the field went unavailable instantly - defeating grace for exactly the source type it exists to protect. Track when the last candidate for a path was lost and measure grace from there, including the case where an age-bounded source expires with no event to fire on. Two further findings from a sweep of the same surface: - A value callback that publishes re-entrantly left every listener the outer dispatch had not yet reached holding the superseded value, permanently disagreeing with the router's own value(). - Releasing one availability registration twice dropped a second registration of the same callback. --- tesla_fleet_api/router/stream.py | 41 +++++++++- tests/test_stream_router.py | 124 +++++++++++++++++++++++++++++++ 2 files changed, 162 insertions(+), 3 deletions(-) diff --git a/tesla_fleet_api/router/stream.py b/tesla_fleet_api/router/stream.py index 31f533c..ba5f4a1 100644 --- a/tesla_fleet_api/router/stream.py +++ b/tesla_fleet_api/router/stream.py @@ -13,6 +13,7 @@ from __future__ import annotations +import math import time from collections.abc import Mapping from dataclasses import dataclass, field @@ -203,8 +204,9 @@ def __init__( self._demand_observers: list[_DemandObserver] = [] self._selected: dict[FieldPath, str] = {} self._values: dict[FieldPath, bool] = {} - self._observed_at: dict[FieldPath, float] = {} + self._usable_until: dict[FieldPath, float] = {} self._announced_availability: dict[FieldPath, bool] = {} + self._revision: dict[FieldPath, int] = {} # -- Publishers -------------------------------------------------------- @@ -297,12 +299,32 @@ def _candidates(self, path: FieldPath) -> list[_Candidate]: candidates.append(_Candidate(state, observation, capability)) return candidates + def _usable_deadline(self, candidates: list[_Candidate]) -> float: + """When last-known data would stop being usable if nothing else arrives.""" + ends = [ + c.observation.observed_at + c.capability.max_age + for c in candidates + if c.capability.max_age is not None + ] + if len(ends) < len(candidates): + # A connection-bound source is silent, not stale, so nothing but + # its loss can end candidacy, and that loss sets the deadline. + return math.inf + return max(ends) + self._grace + def _reselect(self, path: FieldPath) -> None: candidates = self._candidates(path) if not candidates: + # Grace runs from the loss of the last candidate. An on-change + # field that simply has not changed is legitimately old, so + # measuring it from the observation would expire the field at once. + self._usable_until[path] = min( + self._usable_until.get(path, math.inf), self._clock() + self._grace + ) self._selected.pop(path, None) self._announce_availability(path) return + self._usable_until[path] = self._usable_deadline(candidates) selected_id = self._selected.get(path) current = next( @@ -333,14 +355,19 @@ def _reselect(self, path: FieldPath) -> None: chosen = max(better, key=lambda c: c.observation.observed_at) self._selected[path] = chosen.state.publisher.source_id - self._observed_at[path] = chosen.observation.observed_at changed = ( path not in self._values or self._values[path] != chosen.observation.value ) self._values[path] = chosen.observation.value + revision = self._revision[path] = self._revision.get(path, 0) + 1 self._announce_availability(path) if changed: for callback in list(self._listeners.get(path, ())): + # A callback may publish, and that nested update has already + # given every listener the newer value; continuing here would + # leave the rest holding a value the router no longer holds. + if self._revision[path] != revision: + return _dispatch(callback, chosen.observation.value) # -- Values and availability -------------------------------------------- @@ -363,7 +390,7 @@ def is_available(self, path: FieldPath) -> bool: return True if path not in self._values: return False - return self._clock() - self._observed_at[path] <= self._grace + return self._clock() <= self._usable_until[path] def _announce_availability(self, path: FieldPath) -> None: available = self.is_available(path) @@ -427,7 +454,15 @@ def listen_availability( self._announced_availability[path] = available _dispatch(callback, available) + released = False + def unsubscribe() -> None: + # Guarded per registration: the same callback may be registered + # twice, and a repeated release must not drop the other one. + nonlocal released + if released: + return + released = True try: listeners.remove(callback) except ValueError: diff --git a/tests/test_stream_router.py b/tests/test_stream_router.py index 5f6fc4a..03862c2 100644 --- a/tests/test_stream_router.py +++ b/tests/test_stream_router.py @@ -390,6 +390,80 @@ def test_connection_bound_source_never_goes_stale_while_healthy(self) -> None: self.assertTrue(router.is_available(FieldPath.LOCKED)) self.assertIs(router.value(FieldPath.LOCKED), True) + def test_grace_runs_from_source_loss_not_from_the_last_change(self) -> None: + """The window a grace period exists to provide, for an on-change source. + + A connection-bound source only speaks when the value changes, so a + healthy one is legitimately hours behind. Measuring grace from the + observation would expire the field the instant that source dropped, + which is precisely the case grace is there to cover. + """ + clock = _Clock() + router = StreamRouter(clock=clock, grace=300.0) + ble = _FakePublisher("ble", priority=PRIORITY_BROADCAST, max_age=None) + router.attach(ble) + recorder = _Recorder() + router.listen_availability(FieldPath.LOCKED, recorder.on_availability) + + ble.health(True) + ble.emit(FieldPath.LOCKED, True, observed_at=0.0) + self.assertEqual(recorder.availability, [False, True]) + + # Hours of a locked car: nothing changed, so nothing was broadcast. + clock.now = 20_000.0 + self.assertTrue(router.is_available(FieldPath.LOCKED)) + + ble.health(False) + self.assertEqual(recorder.availability, [False, True]) + + clock.now = 20_299.0 + self.assertTrue(router.is_available(FieldPath.LOCKED)) + self.assertIs(router.value(FieldPath.LOCKED), True) + + clock.now = 20_301.0 + self.assertFalse(router.is_available(FieldPath.LOCKED)) + self.assertIs(router.value(FieldPath.LOCKED), True) + + def test_grace_after_a_silent_expiry_runs_from_that_expiry(self) -> None: + """An age-bounded source expires with no event to fire on. + + Grace is anchored to when candidacy actually ended, so the first caller + to ask cannot restart the window merely by asking late. + """ + clock = _Clock() + router = StreamRouter(clock=clock, grace=100.0) + result = _FakePublisher("result", max_age=60.0) + router.attach(result) + result.health(True) + result.emit(FieldPath.LOCKED, True, observed_at=0.0) + + clock.now = 159.0 + self.assertTrue(router.is_available(FieldPath.LOCKED)) + clock.now = 161.0 + self.assertFalse(router.is_available(FieldPath.LOCKED)) + + def test_grace_restarts_when_a_recovered_source_is_lost_again(self) -> None: + clock = _Clock() + router = StreamRouter(clock=clock, grace=100.0) + source = _FakePublisher("source", max_age=None) + router.attach(source) + source.health(True) + source.emit(FieldPath.LOCKED, True, observed_at=0.0) + + clock.now = 10.0 + source.health(False) + clock.now = 50.0 + source.health(True) + source.emit(FieldPath.LOCKED, True, observed_at=50.0) + clock.now = 60.0 + source.health(False) + + # Anchored to the second loss, not the first. + clock.now = 159.0 + self.assertTrue(router.is_available(FieldPath.LOCKED)) + clock.now = 161.0 + self.assertFalse(router.is_available(FieldPath.LOCKED)) + def test_expired_observation_is_rejected_and_yields_to_a_fresh_source(self) -> None: clock = _Clock() router = StreamRouter(clock=clock) @@ -529,6 +603,56 @@ def capture(sink: PublisherSink) -> Unsubscribe: ) self.assertEqual(recorder.values, []) + def test_releasing_one_availability_registration_twice_keeps_the_other( + self, + ) -> None: + """Two registrations of one callback are two subscriptions, not one.""" + router = StreamRouter() + source = _FakePublisher("source") + router.attach(source) + recorder = _Recorder() + first = router.listen_availability(FieldPath.LOCKED, recorder.on_availability) + router.listen_availability(FieldPath.LOCKED, recorder.on_availability) + self.assertEqual(recorder.availability, [False, False]) + + first() + first() + + source.health(True) + source.emit(FieldPath.LOCKED, True, observed_at=0.0) + self.assertEqual(recorder.availability, [False, False, True]) + + def test_a_callback_that_publishes_leaves_no_listener_on_the_old_value( + self, + ) -> None: + """A nested update supersedes the one being delivered. + + Without that, the outer dispatch carries on handing the superseded + value to the listeners it had not reached yet, and they are left + holding a value the router itself no longer holds. + """ + router = StreamRouter() + source = _FakePublisher("source") + router.attach(source) + source.health(True) + + first: list[bool] = [] + second: list[bool] = [] + + def on_first(value: bool) -> None: + first.append(value) + if value is True: + source.emit(FieldPath.LOCKED, False, observed_at=2.0) + + router.listen(FieldPath.LOCKED, on_first) + router.listen(FieldPath.LOCKED, second.append) + + source.emit(FieldPath.LOCKED, True, observed_at=1.0) + + self.assertIs(router.value(FieldPath.LOCKED), False) + self.assertEqual(first, [True, False]) + self.assertEqual(second, [False]) + def test_a_listener_exception_does_not_stop_later_listeners(self) -> None: router = StreamRouter(clock=_Clock()) stream = _FakePublisher("stream") From 15e31149d3fa60dd0d0485cb7fb57b6bbcc48df0 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Sat, 22 Aug 2026 20:54:27 +1000 Subject: [PATCH 6/7] no-mistakes(review): {"summary": "fix(router): guard availability dispatch reentrancy and demand double-unsubscribe"} --- tesla_fleet_api/router/stream.py | 16 ++++++++++ tests/test_stream_router.py | 54 ++++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+) diff --git a/tesla_fleet_api/router/stream.py b/tesla_fleet_api/router/stream.py index ba5f4a1..897b8c8 100644 --- a/tesla_fleet_api/router/stream.py +++ b/tesla_fleet_api/router/stream.py @@ -207,6 +207,7 @@ def __init__( self._usable_until: dict[FieldPath, float] = {} self._announced_availability: dict[FieldPath, bool] = {} self._revision: dict[FieldPath, int] = {} + self._availability_revision: dict[FieldPath, int] = {} # -- Publishers -------------------------------------------------------- @@ -397,7 +398,14 @@ def _announce_availability(self, path: FieldPath) -> None: if self._announced_availability.get(path) == available: return self._announced_availability[path] = available + revision = self._availability_revision[path] = ( + self._availability_revision.get(path, 0) + 1 + ) for callback in list(self._availability_listeners.get(path, ())): + # A callback may re-enter and re-run this dispatch; continuing here + # would leave the rest holding availability the router superseded. + if self._availability_revision[path] != revision: + return _dispatch(callback, available) # -- Public listeners --------------------------------------------------- @@ -486,7 +494,15 @@ def listen_demand( self._demand_observers.append(observer) _dispatch(callback, observer.active) + released = False + def unsubscribe() -> None: + # Guarded per registration: the same callback may be registered + # twice, and a repeated release must not drop the other one. + nonlocal released + if released: + return + released = True try: self._demand_observers.remove(observer) except ValueError: diff --git a/tests/test_stream_router.py b/tests/test_stream_router.py index 03862c2..be56a96 100644 --- a/tests/test_stream_router.py +++ b/tests/test_stream_router.py @@ -7,6 +7,7 @@ from __future__ import annotations import ast +from itertools import count from pathlib import Path from typing import Any from unittest import TestCase @@ -376,6 +377,39 @@ def test_a_listener_registered_after_grace_expiry_still_gets_the_recovery( self.assertEqual(late.availability, [False, True]) self.assertTrue(router.is_available(FieldPath.LOCKED)) + def test_a_re_entrant_availability_change_leaves_no_listener_on_the_stale_value( + self, + ) -> None: + """A nested availability change supersedes the one being delivered. + + Mirrors test_a_callback_that_publishes_leaves_no_listener_on_the_old_value + for the sibling availability dispatch loop: without a revision guard + there, the outer dispatch keeps handing listeners it has not yet + reached the availability the router no longer holds. + """ + ticks = count(step=0.001) + router = StreamRouter(clock=lambda: next(ticks), grace=0.0) + source = _FakePublisher("source") + router.attach(source) + + first: list[bool] = [] + second: list[bool] = [] + + def on_first(available: bool) -> None: + first.append(available) + if available is True: + source.health(False) + + router.listen_availability(FieldPath.LOCKED, on_first) + router.listen_availability(FieldPath.LOCKED, second.append) + + source.health(True) + source.emit(FieldPath.LOCKED, True, observed_at=0.0) + + self.assertFalse(router.is_available(FieldPath.LOCKED)) + self.assertEqual(first, [False, True, False]) + self.assertEqual(second, [False, False]) + class TestFreshness(TestCase): def test_connection_bound_source_never_goes_stale_while_healthy(self) -> None: @@ -693,6 +727,26 @@ def test_demand_reports_initial_state_then_only_aggregate_edges(self) -> None: trunk() self.assertEqual(seen, [False, True, False]) + def test_releasing_one_demand_registration_twice_keeps_the_other(self) -> None: + """Two registrations of one callback are two subscriptions, not one. + + _DemandObserver is a plain dataclass compared by field values, so two + registrations with the same paths and callback are equal; unsubscribe + must not let a repeated release of one drop the other via that + equality match. + """ + router = StreamRouter(clock=_Clock()) + seen: list[bool] = [] + first = router.listen_demand(ALL_PATHS, seen.append) + router.listen_demand(ALL_PATHS, seen.append) + self.assertEqual(seen, [False, False]) + + first() + first() + + router.listen(FieldPath.LOCKED, lambda _: None) + self.assertEqual(seen, [False, False, True]) + def test_demand_starts_true_when_a_listener_already_exists(self) -> None: router = StreamRouter(clock=_Clock()) router.listen(FieldPath.LOCKED, lambda _: None) From 74d3108efec8fe9db6cf781a0fdd21f93b5ae14e Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Sat, 22 Aug 2026 21:16:07 +1000 Subject: [PATCH 7/7] refactor(funnel): replace StreamRouter with a many-to-one ObservationFunnel Every publisher feeds the same per-field listeners; nothing selects between sources. Source health, availability, grace windows, failback delay, priority, stickiness and all per-field arbitration are deleted: unavailability is a value a source reports, never something inferred from a link dropping. The surviving logic is hard-coded and not configurable: ignore an observation older than the last one for that field, and do not re-dispatch an unchanged value. --- AGENTS.md | 2 +- tesla_fleet_api/__init__.py | 16 + tesla_fleet_api/funnel.py | 455 ++++++++++ tesla_fleet_api/router/__init__.py | 24 +- tesla_fleet_api/router/stream.py | 765 ---------------- tests/test_funnel.py | 554 ++++++++++++ ..._bluetooth.py => test_funnel_bluetooth.py} | 258 +++--- ...le_data.py => test_funnel_vehicle_data.py} | 195 ++-- tests/test_stream_router.py | 841 ------------------ 9 files changed, 1239 insertions(+), 1871 deletions(-) create mode 100644 tesla_fleet_api/funnel.py delete mode 100644 tesla_fleet_api/router/stream.py create mode 100644 tests/test_funnel.py rename tests/{test_stream_router_bluetooth.py => test_funnel_bluetooth.py} (58%) rename tests/{test_stream_router_vehicle_data.py => test_funnel_vehicle_data.py} (53%) delete mode 100644 tests/test_stream_router.py diff --git a/AGENTS.md b/AGENTS.md index f2003c7..3837053 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -69,7 +69,7 @@ Commands (vehicle/commands.py) - protobuf-based signed command implementation (A `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. -`StreamRouter` (`router/stream.py`) is the **read** router, a separate mechanism from the command `Router` above and not in its inheritance chain. It arbitrates per-field push observations across sources so a field bound to one source does not go unavailable when that source does. It is **entirely synchronous and can never originate a request**: no `async def`/`await`, no polling loop, no request callable, no scheduling task, no cost or billing policy anywhere in the module — `tests/test_stream_router.py::TestRouterCannotOriginateWork` 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 router (which is itself the `PublisherSink`) via `publish(Observation)` and `set_health(source_id, healthy)`; health is a separate channel from data, so transport loss never reaches a listener as `None`. An unhealthy source is dead in both directions: `set_health(False)` clears its observations **and** `publish()` drops anything arriving while it is unhealthy, so a frame racing its own disconnect cannot be cached and then resurrected as a current reading when the source reconnects (connection-bound capabilities never expire it). Selection per `FieldPath` is: healthy + within the capability's `max_age` (`None` = connection-bound, silence is not staleness) → exact over lossy → lowest `priority` int (`PRIORITY_STREAM` < `PRIORITY_BLE_PUSH` < `PRIORITY_BROADCAST` < `PRIORITY_SUPPLIED_RESULT`, overridable per publisher) → sticky within a tier → most recent. `recovered_at` applies the failback delay only to a source that regained health after a loss, never to one coming up for the first time. `is_available(path)` recomputes freshness at call time because grace expiry has no event to fire a callback on; `value(path)` returns last-known and its `None` means only "never observed". 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. `BleBroadcastPublisher` reuses the existing `VehicleBluetooth` `listen_vehicle_lock_state`/`listen_charge_port`/`listen_front_trunk`/`listen_connection_status` 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 router deduplicates the repeats. VCSEC `INTERNAL_LOCKED`/`SELECTIVE_UNLOCKED` and closure `UNKNOWN`/`FAILED_UNLATCH` are unmapped pending live-frame validation. +`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. VCSEC `INTERNAL_LOCKED`/`SELECTIVE_UNLOCKED` and closure `UNKNOWN`/`FAILED_UNLATCH` are unmapped pending live-frame validation. `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. diff --git a/tesla_fleet_api/__init__.py b/tesla_fleet_api/__init__.py index f7207a3..3d5d0df 100644 --- a/tesla_fleet_api/__init__.py +++ b/tesla_fleet_api/__init__.py @@ -4,6 +4,15 @@ __version__ = "1.10.1" from tesla_fleet_api.const import Region, is_valid_region +from tesla_fleet_api.funnel import ( + BleBroadcastPublisher, + FieldPath, + Observation, + ObservationFunnel, + ObservationSink, + Publisher, + VehicleDataResultPublisher, +) from tesla_fleet_api.tariff import ( TariffPeriod, TariffRate, @@ -23,6 +32,12 @@ from tesla_fleet_api.util import firmware_at_least, firmware_compare __all__ = [ + "BleBroadcastPublisher", + "FieldPath", + "Observation", + "ObservationFunnel", + "ObservationSink", + "Publisher", "Region", "TariffPeriod", "TariffRate", @@ -33,6 +48,7 @@ "Teslemetry", "TeslemetryClientRegistration", "Tessie", + "VehicleDataResultPublisher", "firmware_at_least", "firmware_compare", "get_tariff_periods", diff --git a/tesla_fleet_api/funnel.py b/tesla_fleet_api/funnel.py new file mode 100644 index 0000000..f3cbbdc --- /dev/null +++ b/tesla_fleet_api/funnel.py @@ -0,0 +1,455 @@ +"""Observation funnel: many push sources, one listener per field. + +A field bound to a single source disappears whenever that source is +unavailable. Every publisher attached here feeds the same listeners, so a +field survives the loss of any one source. Nothing in this module chooses +between sources: unavailability is a value a source reports, never something +the funnel infers from a link dropping. + +The funnel is a pure consumer of observations pushed to it. It is entirely +synchronous and holds no request callable, polling loop, HTTP/BLE read, or +scheduling task: a source the funnel could drive is a source it could be made +to poll. +""" + +from __future__ import annotations + +import time +from collections.abc import Mapping +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any, Callable, Iterator, Protocol, TypeVar + +from tesla_fleet_api.const import LOGGER, StrEnum +from tesla_protocol.command.vcsec_pb2 import ( + ClosureState_E, + VehicleLockState_E, +) + +if TYPE_CHECKING: + from tesla_fleet_api.tesla.vehicle.bluetooth import VehicleBluetooth + +Unsubscribe = Callable[[], None] +Clock = Callable[[], float] + +# A reading, or None for a source reporting the field itself as unavailable. +Value = bool | None + +T = TypeVar("T") + +_FATAL_CALLBACK_ERRORS = (KeyboardInterrupt, SystemExit) + + +class FieldPath(StrEnum): + """A canonical routable field, valued to match its public signal name. + + A compound signal is split into one path per facet, because a broadcast and + a ``vehicle_data`` result each expose individual leaves. + """ + + LOCKED = "Locked" + CHARGE_PORT_DOOR_OPEN = "ChargePortDoorOpen" + DOOR_STATE_TRUNK_FRONT = "DoorState.TrunkFront" + + +@dataclass(frozen=True, slots=True) +class Observation: + """One timestamped value for one field.""" + + path: FieldPath + value: Value + # Seconds off one monotonic clock shared by every publisher on a funnel; + # observations from different sources are ordered against each other. + observed_at: float + + +class ObservationSink(Protocol): + """The handle a publisher is given to feed the funnel.""" + + def publish(self, observation: Observation) -> None: ... + + +class Publisher(Protocol): + """A source of observations for one or more canonical fields.""" + + @property + def paths(self) -> frozenset[FieldPath]: ... + + def attach(self, sink: ObservationSink) -> Unsubscribe: ... + + def request(self, paths: frozenset[FieldPath]) -> None: + """Begin supplying ``paths``: subscription accounting, not a data request.""" + + def release(self, paths: frozenset[FieldPath]) -> None: + """Stop supplying ``paths``.""" + + +def _dispatch(callback: Callable[[T], None], value: T) -> None: + try: + callback(value) + except _FATAL_CALLBACK_ERRORS: + raise + except BaseException: + LOGGER.exception("observation funnel listener callback failed") + + +def _noop() -> None: + return None + + +@dataclass(eq=False) +class _Attached: + publisher: Publisher + detach: Unsubscribe + + +@dataclass(eq=False) +class _DemandObserver: + paths: frozenset[FieldPath] + callback: Callable[[bool], None] + active: bool + + +class ObservationFunnel: + """Funnels observations from every attached publisher into one listener set. + + Many to one: a listener registered for a field receives what any publisher + reports for it, in arrival order. There is no source selection, ranking, or + health model here, and detaching or silencing a publisher produces no value + of its own. + """ + + def __init__(self) -> None: + self._publishers: list[_Attached] = [] + self._listeners: dict[FieldPath, list[Callable[[Value], None]]] = {} + self._demand_observers: list[_DemandObserver] = [] + self._observed: dict[FieldPath, Observation] = {} + + # -- Publishers -------------------------------------------------------- + + def attach(self, publisher: Publisher) -> Unsubscribe: + """Attach ``publisher`` and return its detach closure.""" + attached = _Attached(publisher=publisher, detach=_noop) + # Registered before attaching so a publisher that reports an + # observation synchronously is not discarded as unknown. + self._publishers.append(attached) + attached.detach = publisher.attach(self) + active = self._active_paths(publisher) + if active: + publisher.request(active) + + def detach() -> None: + if attached not in self._publishers: + return + self._publishers.remove(attached) + active = self._active_paths(publisher) + if active: + publisher.release(active) + attached.detach() + + return detach + + def _active_paths(self, publisher: Publisher) -> frozenset[FieldPath]: + return frozenset(p for p in publisher.paths if self._listeners.get(p)) + + # -- Sink --------------------------------------------------------------- + + def publish(self, observation: Observation) -> None: + """Accept one observation from any publisher and fan it out.""" + path = observation.path + previous = self._observed.get(path) + if previous is not None and observation.observed_at < previous.observed_at: + # A later reading of this field already stands. + return + self._observed[path] = observation + if previous is not None and previous.value == observation.value: + return + for callback in list(self._listeners.get(path, ())): + # A callback may publish, and that nested update has already given + # every listener the newer value; continuing here would leave the + # rest holding a value the funnel no longer holds. + if self._observed[path] is not observation: + return + _dispatch(callback, observation.value) + + # -- Values and listeners ----------------------------------------------- + + def value(self, path: FieldPath) -> Value: + """The last value observed for ``path``. + + ``None`` means there is no reading: either nothing has ever been + observed, or a source reported the field as unavailable. + """ + observation = self._observed.get(path) + return None if observation is None else observation.value + + def listen(self, path: FieldPath, callback: Callable[[Value], None]) -> Unsubscribe: + """Register a listener for ``path``, called on each changed value. + + The first listener for a path activates it on every capable publisher; + the last one to leave releases it. Registration dispatches nothing; + the value standing at that moment is :meth:`value`. + """ + listeners = self._listeners.setdefault(path, []) + listeners.append(callback) + if len(listeners) == 1: + paths = frozenset({path}) + for attached in list(self._publishers): + if path in attached.publisher.paths: + attached.publisher.request(paths) + self._notify_demand() + + released = False + + def unsubscribe() -> None: + # Guarded per registration: the same callback may be registered + # twice, and a repeated release must not drop the other one. + nonlocal released + if released: + return + released = True + try: + listeners.remove(callback) + except ValueError: + return + if not listeners: + paths = frozenset({path}) + for attached in list(self._publishers): + if path in attached.publisher.paths: + attached.publisher.release(paths) + self._notify_demand() + + return unsubscribe + + # -- Demand ------------------------------------------------------------- + + def listen_demand( + self, paths: frozenset[FieldPath], callback: Callable[[bool], None] + ) -> Unsubscribe: + """Observe whether any of ``paths`` has at least one live listener. + + Derived from the activation counts the funnel already keeps. It + reports; it never starts work of its own. + """ + observer = _DemandObserver( + paths=paths, callback=callback, active=self._demand(paths) + ) + self._demand_observers.append(observer) + _dispatch(callback, observer.active) + + released = False + + def unsubscribe() -> None: + nonlocal released + if released: + return + released = True + try: + self._demand_observers.remove(observer) + except ValueError: + pass + + return unsubscribe + + def _demand(self, paths: frozenset[FieldPath]) -> bool: + return any(self._listeners.get(p) for p in paths) + + def _notify_demand(self) -> None: + for observer in list(self._demand_observers): + active = self._demand(observer.paths) + if active != observer.active: + observer.active = active + _dispatch(observer.callback, active) + + +# -- Bluetooth broadcast publisher ----------------------------------------- + +# UNLOCKED is 0 and the enum has no proto3 presence, so every status broadcast +# reports a lock state and the funnel deduplicates the repeats. +# +# INTERNAL_LOCKED and SELECTIVE_UNLOCKED are deliberately unmapped: reducing +# either to one boolean is unvalidated against live frames, and emitting +# nothing keeps the last confirmed value instead of guessing. +_LOCK_STATES: Mapping[int, bool] = { + VehicleLockState_E.VEHICLELOCKSTATE_LOCKED: True, + VehicleLockState_E.VEHICLELOCKSTATE_UNLOCKED: False, +} + +# UNKNOWN and FAILED_UNLATCH are unmapped for the same reason. +_CLOSURE_STATES: Mapping[int, bool] = { + ClosureState_E.CLOSURESTATE_CLOSED: False, + ClosureState_E.CLOSURESTATE_OPEN: True, + ClosureState_E.CLOSURESTATE_AJAR: True, + ClosureState_E.CLOSURESTATE_OPENING: True, + ClosureState_E.CLOSURESTATE_CLOSING: True, +} + +_BROADCAST_MAPS: Mapping[FieldPath, Mapping[int, bool]] = { + FieldPath.LOCKED: _LOCK_STATES, + FieldPath.CHARGE_PORT_DOOR_OPEN: _CLOSURE_STATES, + FieldPath.DOOR_STATE_TRUNK_FRONT: _CLOSURE_STATES, +} + +_BROADCAST_PATHS = frozenset(_BROADCAST_MAPS) + + +class BleBroadcastPublisher: + """Publishes VCSEC status broadcasts from an existing BLE session. + + Registration only: it subscribes to the broadcast listeners a + ``VehicleBluetooth`` already fans out, and never connects, reads, or + commands. A dropped BLE session ends the broadcasts and says nothing about + the fields themselves, so it publishes nothing. + """ + + def __init__( + self, + vehicle: VehicleBluetooth[Any], + *, + clock: Clock = time.monotonic, + ) -> None: + self._vehicle = vehicle + self._clock = clock + self._sink: ObservationSink | None = None + self._subscriptions: dict[FieldPath, Unsubscribe] = {} + + @property + def paths(self) -> frozenset[FieldPath]: + return _BROADCAST_PATHS + + def attach(self, sink: ObservationSink) -> Unsubscribe: + self._sink = sink + + def detach() -> None: + self.release(frozenset(self._subscriptions)) + self._sink = None + + return detach + + def request(self, paths: frozenset[FieldPath]) -> None: + for path in paths: + if path in self._subscriptions or path not in _BROADCAST_MAPS: + continue + self._subscriptions[path] = _LISTENER_FOR[path]( + self._vehicle, self._observer(path) + ) + + def release(self, paths: frozenset[FieldPath]) -> None: + for path in paths: + unsubscribe = self._subscriptions.pop(path, None) + if unsubscribe is not None: + unsubscribe() + + def _observer(self, path: FieldPath) -> Callable[[int], None]: + states = _BROADCAST_MAPS[path] + + def on_broadcast(raw: int) -> None: + sink = self._sink + if sink is None or raw not in states: + return + sink.publish( + Observation(path=path, value=states[raw], observed_at=self._clock()) + ) + + return on_broadcast + + +_LISTENER_FOR: Mapping[ + FieldPath, Callable[["VehicleBluetooth[Any]", Callable[[int], None]], Unsubscribe] +] = { + FieldPath.LOCKED: lambda vehicle, cb: vehicle.listen_vehicle_lock_state(cb), + FieldPath.CHARGE_PORT_DOOR_OPEN: lambda vehicle, cb: vehicle.listen_charge_port(cb), + FieldPath.DOOR_STATE_TRUNK_FRONT: lambda vehicle, cb: vehicle.listen_front_trunk( + cb + ), +} + + +# -- Supplied vehicle_data result publisher --------------------------------- + +_ABSENT = object() + + +def _leaf(section: object, key: str) -> object: + """A present leaf, or ``_ABSENT``: a missing key is not a null reading.""" + if isinstance(section, Mapping) and key in section: + return section[key] # pyright: ignore[reportUnknownVariableType] + return _ABSENT + + +def _int_code(value: object) -> int | None: + """A JSON integer code, rejecting ``bool`` so ``False`` cannot pass as 0.""" + if isinstance(value, bool) or not isinstance(value, int): + return None + return value + + +class VehicleDataResultPublisher: + """Translates a caller-supplied ``vehicle_data`` result into observations. + + The result is an argument. This class holds no client, session, endpoint, + or callable able to obtain one, so nothing here can originate a request or + cause a refresh. + """ + + def __init__(self, *, clock: Clock = time.monotonic) -> None: + self._clock = clock + self._sink: ObservationSink | None = None + + @property + def paths(self) -> frozenset[FieldPath]: + return frozenset(FieldPath) + + def attach(self, sink: ObservationSink) -> Unsubscribe: + self._sink = sink + + def detach() -> None: + self._sink = None + + return detach + + def request(self, paths: frozenset[FieldPath]) -> None: + """Passive source: activation subscribes to nothing.""" + + def release(self, paths: frozenset[FieldPath]) -> None: + """Passive source: activation subscribes to nothing.""" + + def publish_result( + self, result: Mapping[str, Any], *, observed_at: float | None = None + ) -> tuple[Observation, ...]: + """Translate a supplied result and feed the audited leaves to the sink.""" + at = self._clock() if observed_at is None else observed_at + observations = tuple(self._translate(result, at)) + sink = self._sink + if sink is not None: + for observation in observations: + sink.publish(observation) + return observations + + def _translate( + self, result: Mapping[str, Any], observed_at: float + ) -> Iterator[Observation]: + payload: Mapping[str, Any] = result + response = _leaf(payload, "response") + if isinstance(response, Mapping): + payload = response # pyright: ignore[reportUnknownVariableType] + + vehicle_state = _leaf(payload, "vehicle_state") + charge_state = _leaf(payload, "charge_state") + + # An explicit null is the vehicle reporting the field unavailable; an + # absent or unrecognised leaf is no reading at all. + locked = _leaf(vehicle_state, "locked") + if locked is None or isinstance(locked, bool): + yield Observation(FieldPath.LOCKED, locked, observed_at) + + charge_port = _leaf(charge_state, "charge_port_door_open") + if charge_port is None or isinstance(charge_port, bool): + yield Observation(FieldPath.CHARGE_PORT_DOOR_OPEN, charge_port, observed_at) + + front_trunk = _leaf(vehicle_state, "ft") + if front_trunk is None: + yield Observation(FieldPath.DOOR_STATE_TRUNK_FRONT, None, observed_at) + # ``ft`` is an ajar/open code, and only 0 and 1 have a documented + # boolean meaning. + elif (code := _int_code(front_trunk)) in (0, 1): + yield Observation(FieldPath.DOOR_STATE_TRUNK_FRONT, code == 1, observed_at) diff --git a/tesla_fleet_api/router/__init__.py b/tesla_fleet_api/router/__init__.py index 5cd72f9..4913eba 100644 --- a/tesla_fleet_api/router/__init__.py +++ b/tesla_fleet_api/router/__init__.py @@ -1,34 +1,12 @@ -"""Routing wrappers: per-command failover (`Router`) and read-side arbitration (`StreamRouter`).""" +"""Routing wrappers with per-command failover across backends.""" from tesla_fleet_api.router.base import HealthCheck, Router from tesla_fleet_api.router.vehicle import VehicleRouter from tesla_fleet_api.router.energysite import EnergySiteRouter -from tesla_fleet_api.router.stream import ( - BleBroadcastPublisher, - Capability, - Delivery, - FieldPath, - Fidelity, - Observation, - Publisher, - PublisherSink, - StreamRouter, - VehicleDataResultPublisher, -) __all__ = [ "Router", "VehicleRouter", "EnergySiteRouter", "HealthCheck", - "StreamRouter", - "FieldPath", - "Capability", - "Delivery", - "Fidelity", - "Observation", - "Publisher", - "PublisherSink", - "BleBroadcastPublisher", - "VehicleDataResultPublisher", ] diff --git a/tesla_fleet_api/router/stream.py b/tesla_fleet_api/router/stream.py deleted file mode 100644 index 897b8c8..0000000 --- a/tesla_fleet_api/router/stream.py +++ /dev/null @@ -1,765 +0,0 @@ -"""Read router: per-field arbitration of pushed vehicle observations. - -A field bound to a single source disappears whenever that source is -unavailable. This router keeps a canonical field alive while any attached -source can still supply it, and reports transport loss as availability rather -than as a data value. - -The router is a pure consumer of observations pushed to it. It is entirely -synchronous and holds no request callable, polling loop, HTTP/BLE read, -scheduling task, or cost policy: a source the router could drive is a source -it could be made to poll. -""" - -from __future__ import annotations - -import math -import time -from collections.abc import Mapping -from dataclasses import dataclass, field -from typing import TYPE_CHECKING, Any, Callable, Iterator, NamedTuple, Protocol, TypeVar - -from tesla_fleet_api.const import LOGGER, StrEnum -from tesla_protocol.command.vcsec_pb2 import ( - ClosureState_E, - VehicleLockState_E, -) - -if TYPE_CHECKING: - from tesla_fleet_api.tesla.vehicle.bluetooth import VehicleBluetooth - -Unsubscribe = Callable[[], None] -Clock = Callable[[], float] - -T = TypeVar("T") - -_FATAL_CALLBACK_ERRORS = (KeyboardInterrupt, SystemExit) - -# Last-known data is retained this long after the last source able to supply a -# field drops out, before the field is reported unavailable. -DEFAULT_GRACE = 300.0 - -# A source that regains health must hold it this long before it can take a -# field back off a working standby. -DEFAULT_FAILBACK_DELAY = 5.0 - -# A supplied result is a point-in-time snapshot, so it expires by age. -DEFAULT_RESULT_MAX_AGE = 300.0 - -# Publisher priority, lower wins. A local-first consumer may override any -# publisher's priority. -PRIORITY_STREAM = 10 -PRIORITY_BLE_PUSH = 20 -PRIORITY_BROADCAST = 30 -PRIORITY_SUPPLIED_RESULT = 40 - - -class FieldPath(StrEnum): - """A canonical routable field, valued to match its public signal name. - - A compound signal is split into one path per facet, because a broadcast and - a ``vehicle_data`` result each expose individual leaves and selection is - per facet. - """ - - LOCKED = "Locked" - CHARGE_PORT_DOOR_OPEN = "ChargePortDoorOpen" - DOOR_STATE_TRUNK_FRONT = "DoorState.TrunkFront" - - -class Delivery(StrEnum): - """How a publisher delivers a field.""" - - ON_CHANGE = "on_change" - CADENCED_PUSH = "cadenced_push" - PASSIVE_RESULT = "passive_result" - - -class Fidelity(StrEnum): - """How faithfully a publisher's translation preserves the field.""" - - EXACT = "exact" - LOSSY = "lossy" - - -@dataclass(frozen=True, slots=True) -class Capability: - """What one publisher can supply for one field path.""" - - path: FieldPath - delivery: Delivery - fidelity: Fidelity = Fidelity.EXACT - # None is connection-bound: silence from an on-change source is not - # staleness, only loss of health is. - max_age: float | None = None - - -@dataclass(frozen=True, slots=True) -class Observation: - """One timestamped value for one field, from one source.""" - - path: FieldPath - value: bool - observed_at: float - source_id: str - source_sequence: int | None = None - - -class PublisherSink(Protocol): - """The handle a publisher is given to feed the router. - - Health is a separate channel from data so that transport loss can never - reach a listener as a value. - """ - - def publish(self, observation: Observation) -> None: ... - - def set_health(self, source_id: str, healthy: bool) -> None: ... - - -class Publisher(Protocol): - """A source of observations for one or more canonical fields.""" - - source_id: str - priority: int - - @property - def capabilities(self) -> tuple[Capability, ...]: ... - - def attach(self, sink: PublisherSink) -> Unsubscribe: ... - - def request(self, paths: frozenset[FieldPath]) -> None: - """Begin supplying ``paths``: subscription accounting, not a data request.""" - - def release(self, paths: frozenset[FieldPath]) -> None: - """Stop supplying ``paths``.""" - - -def _dispatch(callback: Callable[[T], None], value: T) -> None: - try: - callback(value) - except _FATAL_CALLBACK_ERRORS: - raise - except BaseException: - LOGGER.exception("stream router listener callback failed") - - -@dataclass -class _SourceState: - publisher: Publisher - capabilities: dict[FieldPath, Capability] - detach: Unsubscribe - healthy: bool = False - has_been_healthy: bool = False - # Set only when health returns after a loss, which is the only case the - # failback delay guards against. - recovered_at: float | None = None - observations: dict[FieldPath, Observation] = field( - default_factory=dict[FieldPath, Observation] - ) - - -class _Candidate(NamedTuple): - state: _SourceState - observation: Observation - capability: Capability - - -def _rank(candidate: _Candidate) -> tuple[int, int]: - """Lower sorts better: exact translations first, then source priority.""" - return ( - 0 if candidate.capability.fidelity is Fidelity.EXACT else 1, - candidate.state.publisher.priority, - ) - - -@dataclass -class _DemandObserver: - paths: frozenset[FieldPath] - callback: Callable[[bool], None] - active: bool - - -class StreamRouter: - """Arbitrates canonical field observations across attached publishers. - - Every value reaches a listener through selection here; a publisher never - calls a public listener directly, so one source can never bypass another's - arbitration. - """ - - def __init__( - self, - *, - grace: float = DEFAULT_GRACE, - failback_delay: float = DEFAULT_FAILBACK_DELAY, - clock: Clock = time.monotonic, - ) -> None: - self._clock = clock - self._grace = grace - self._failback_delay = failback_delay - self._sources: dict[str, _SourceState] = {} - self._listeners: dict[FieldPath, list[Callable[[bool], None]]] = {} - self._availability_listeners: dict[FieldPath, list[Callable[[bool], None]]] = {} - self._demand_observers: list[_DemandObserver] = [] - self._selected: dict[FieldPath, str] = {} - self._values: dict[FieldPath, bool] = {} - self._usable_until: dict[FieldPath, float] = {} - self._announced_availability: dict[FieldPath, bool] = {} - self._revision: dict[FieldPath, int] = {} - self._availability_revision: dict[FieldPath, int] = {} - - # -- Publishers -------------------------------------------------------- - - def attach(self, publisher: Publisher) -> Unsubscribe: - """Attach ``publisher`` and return its detach closure.""" - source_id = publisher.source_id - if source_id in self._sources: - raise ValueError(f"source_id {source_id!r} is already attached") - - capabilities = {c.path: c for c in publisher.capabilities} - state = _SourceState( - publisher=publisher, capabilities=capabilities, detach=_noop - ) - # Registered before attaching so a publisher that reports health or an - # observation synchronously is not discarded as unknown. - self._sources[source_id] = state - state.detach = publisher.attach(self) - - active = self._active_paths(capabilities) - if active: - publisher.request(active) - return lambda: self._detach(source_id, state) - - def _detach(self, source_id: str, state: _SourceState) -> None: - if self._sources.get(source_id) is not state: - return - del self._sources[source_id] - active = self._active_paths(state.capabilities) - if active: - state.publisher.release(active) - state.detach() - for path in state.capabilities: - self._reselect(path) - - def _active_paths( - self, capabilities: Mapping[FieldPath, Capability] - ) -> frozenset[FieldPath]: - return frozenset(p for p in capabilities if self._listeners.get(p)) - - # -- Sink --------------------------------------------------------------- - - def publish(self, observation: Observation) -> None: - """Accept one observation from an attached publisher.""" - state = self._sources.get(observation.source_id) - if state is None or observation.path not in state.capabilities: - return - # A frame racing its own transport loss cannot be cached, or reconnect - # would resurrect it as a current reading of the recovered session. - if not state.healthy: - return - previous = state.observations.get(observation.path) - if previous is not None and previous.observed_at > observation.observed_at: - return - state.observations[observation.path] = observation - self._reselect(observation.path) - - def set_health(self, source_id: str, healthy: bool) -> None: - """Record a source's transport health.""" - state = self._sources.get(source_id) - if state is None or state.healthy == healthy: - return - state.healthy = healthy - if healthy: - state.recovered_at = self._clock() if state.has_been_healthy else None - state.has_been_healthy = True - else: - # A lost transport cannot vouch for what it last reported, so its - # readings are dropped rather than allowed to win on reconnect. - state.observations.clear() - for path in state.capabilities: - self._reselect(path) - - # -- Selection ---------------------------------------------------------- - - def _candidates(self, path: FieldPath) -> list[_Candidate]: - now = self._clock() - candidates: list[_Candidate] = [] - for state in self._sources.values(): - capability = state.capabilities.get(path) - if capability is None or not state.healthy: - continue - observation = state.observations.get(path) - if observation is None: - continue - if ( - capability.max_age is not None - and now - observation.observed_at > capability.max_age - ): - continue - candidates.append(_Candidate(state, observation, capability)) - return candidates - - def _usable_deadline(self, candidates: list[_Candidate]) -> float: - """When last-known data would stop being usable if nothing else arrives.""" - ends = [ - c.observation.observed_at + c.capability.max_age - for c in candidates - if c.capability.max_age is not None - ] - if len(ends) < len(candidates): - # A connection-bound source is silent, not stale, so nothing but - # its loss can end candidacy, and that loss sets the deadline. - return math.inf - return max(ends) + self._grace - - def _reselect(self, path: FieldPath) -> None: - candidates = self._candidates(path) - if not candidates: - # Grace runs from the loss of the last candidate. An on-change - # field that simply has not changed is legitimately old, so - # measuring it from the observation would expire the field at once. - self._usable_until[path] = min( - self._usable_until.get(path, math.inf), self._clock() + self._grace - ) - self._selected.pop(path, None) - self._announce_availability(path) - return - self._usable_until[path] = self._usable_deadline(candidates) - - selected_id = self._selected.get(path) - current = next( - (c for c in candidates if c.state.publisher.source_id == selected_id), None - ) - eligible = candidates - if current is not None: - # A source that just regained health holds it briefly before it can - # take the field back off a working standby. Ineligible sources are - # removed before ranking so a delayed top priority cannot mask a - # better source that is eligible right now. - now = self._clock() - eligible = [ - c - for c in candidates - if c is current - or c.state.recovered_at is None - or now - c.state.recovered_at >= self._failback_delay - ] - - best_rank = min(_rank(c) for c in eligible) - if current is not None and _rank(current) == best_rank: - # Stickiness within a tier: an equally ranked source does not - # displace the one already selected. - chosen = current - else: - better = [c for c in eligible if _rank(c) == best_rank] - chosen = max(better, key=lambda c: c.observation.observed_at) - - self._selected[path] = chosen.state.publisher.source_id - changed = ( - path not in self._values or self._values[path] != chosen.observation.value - ) - self._values[path] = chosen.observation.value - revision = self._revision[path] = self._revision.get(path, 0) + 1 - self._announce_availability(path) - if changed: - for callback in list(self._listeners.get(path, ())): - # A callback may publish, and that nested update has already - # given every listener the newer value; continuing here would - # leave the rest holding a value the router no longer holds. - if self._revision[path] != revision: - return - _dispatch(callback, chosen.observation.value) - - # -- Values and availability -------------------------------------------- - - def value(self, path: FieldPath) -> bool | None: - """The last known value for ``path``, or ``None`` if never observed. - - ``None`` here means no source has ever reported the field; it is never - produced by transport loss, which shows up in :meth:`is_available`. - """ - return self._values.get(path) - - def is_available(self, path: FieldPath) -> bool: - """Whether ``path`` has a usable source, or last-known data still in grace. - - Freshness is recomputed here rather than read off the last selection, so - an expiry that had no event to fire on is still reported honestly. - """ - if self._candidates(path): - return True - if path not in self._values: - return False - return self._clock() <= self._usable_until[path] - - def _announce_availability(self, path: FieldPath) -> None: - available = self.is_available(path) - if self._announced_availability.get(path) == available: - return - self._announced_availability[path] = available - revision = self._availability_revision[path] = ( - self._availability_revision.get(path, 0) + 1 - ) - for callback in list(self._availability_listeners.get(path, ())): - # A callback may re-enter and re-run this dispatch; continuing here - # would leave the rest holding availability the router superseded. - if self._availability_revision[path] != revision: - return - _dispatch(callback, available) - - # -- Public listeners --------------------------------------------------- - - def listen(self, path: FieldPath, callback: Callable[[bool], None]) -> Unsubscribe: - """Register a value listener for ``path``. - - The first listener for a path activates it on every capable publisher; - the last one to leave releases it. - """ - listeners = self._listeners.setdefault(path, []) - listeners.append(callback) - if len(listeners) == 1: - paths = frozenset({path}) - for state in list(self._sources.values()): - if path in state.capabilities: - state.publisher.request(paths) - self._notify_demand() - - released = False - - def unsubscribe() -> None: - nonlocal released - if released: - return - released = True - try: - listeners.remove(callback) - except ValueError: - return - if not listeners: - paths = frozenset({path}) - for state in list(self._sources.values()): - if path in state.capabilities: - state.publisher.release(paths) - self._notify_demand() - - return unsubscribe - - def listen_availability( - self, path: FieldPath, callback: Callable[[bool], None] - ) -> Unsubscribe: - """Register an availability listener for ``path``. - - Fires with the current state at registration and then on transitions - caused by an event. Grace expiry has no event to fire on, so a consumer - that must observe it reads :meth:`is_available`. - """ - listeners = self._availability_listeners.setdefault(path, []) - listeners.append(callback) - available = self.is_available(path) - # Assigned, not defaulted: a cache still holding True for a field that - # has since aged out of grace would suppress the next real recovery. - self._announced_availability[path] = available - _dispatch(callback, available) - - released = False - - def unsubscribe() -> None: - # Guarded per registration: the same callback may be registered - # twice, and a repeated release must not drop the other one. - nonlocal released - if released: - return - released = True - try: - listeners.remove(callback) - except ValueError: - pass - - return unsubscribe - - # -- Demand ------------------------------------------------------------- - - def listen_demand( - self, paths: frozenset[FieldPath], callback: Callable[[bool], None] - ) -> Unsubscribe: - """Observe whether any of ``paths`` has at least one live value listener. - - Derived from the activation counts the router already keeps. It reports; - it never starts work of its own. - """ - observer = _DemandObserver( - paths=paths, callback=callback, active=self._demand(paths) - ) - self._demand_observers.append(observer) - _dispatch(callback, observer.active) - - released = False - - def unsubscribe() -> None: - # Guarded per registration: the same callback may be registered - # twice, and a repeated release must not drop the other one. - nonlocal released - if released: - return - released = True - try: - self._demand_observers.remove(observer) - except ValueError: - pass - - return unsubscribe - - def _demand(self, paths: frozenset[FieldPath]) -> bool: - return any(self._listeners.get(p) for p in paths) - - def _notify_demand(self) -> None: - for observer in list(self._demand_observers): - active = self._demand(observer.paths) - if active != observer.active: - observer.active = active - _dispatch(observer.callback, active) - - -def _noop() -> None: - return None - - -# -- Bluetooth broadcast publisher ----------------------------------------- - -# UNLOCKED is 0 and the enum has no proto3 presence, so every status broadcast -# reports a lock state and the router deduplicates the repeats. -# -# INTERNAL_LOCKED and SELECTIVE_UNLOCKED are deliberately unmapped: reducing -# either to one boolean is unvalidated against live frames, and emitting -# nothing keeps the last confirmed value instead of guessing. -_LOCK_STATES: Mapping[int, bool] = { - VehicleLockState_E.VEHICLELOCKSTATE_LOCKED: True, - VehicleLockState_E.VEHICLELOCKSTATE_UNLOCKED: False, -} - -# UNKNOWN and FAILED_UNLATCH are unmapped for the same reason. -_CLOSURE_STATES: Mapping[int, bool] = { - ClosureState_E.CLOSURESTATE_CLOSED: False, - ClosureState_E.CLOSURESTATE_OPEN: True, - ClosureState_E.CLOSURESTATE_AJAR: True, - ClosureState_E.CLOSURESTATE_OPENING: True, - ClosureState_E.CLOSURESTATE_CLOSING: True, -} - -_BROADCAST_MAPS: Mapping[FieldPath, Mapping[int, bool]] = { - FieldPath.LOCKED: _LOCK_STATES, - FieldPath.CHARGE_PORT_DOOR_OPEN: _CLOSURE_STATES, - FieldPath.DOOR_STATE_TRUNK_FRONT: _CLOSURE_STATES, -} - -_BROADCAST_CAPABILITIES = tuple( - Capability(path=path, delivery=Delivery.ON_CHANGE, fidelity=Fidelity.EXACT) - for path in _BROADCAST_MAPS -) - - -class BleBroadcastPublisher: - """Publishes VCSEC status broadcasts from an existing BLE session. - - Registration only: it subscribes to the broadcast and connection-status - listeners a ``VehicleBluetooth`` already fans out, and never connects, - reads, or commands. - """ - - def __init__( - self, - vehicle: VehicleBluetooth[Any], - *, - source_id: str = "ble-broadcast", - priority: int = PRIORITY_BROADCAST, - clock: Clock = time.monotonic, - ) -> None: - self._vehicle = vehicle - self._clock = clock - self._sink: PublisherSink | None = None - self._subscriptions: dict[FieldPath, Unsubscribe] = {} - self.source_id = source_id - self.priority = priority - - @property - def capabilities(self) -> tuple[Capability, ...]: - return _BROADCAST_CAPABILITIES - - def attach(self, sink: PublisherSink) -> Unsubscribe: - self._sink = sink - unsubscribe_health = self._vehicle.listen_connection_status( - lambda connected: sink.set_health(self.source_id, connected) - ) - # listen_connection_status only fires on transitions, so the session - # already in progress at attach time has to be reported here. - client = self._vehicle.client - sink.set_health(self.source_id, bool(client and client.is_connected)) - - def detach() -> None: - unsubscribe_health() - self.release(frozenset(self._subscriptions)) - self._sink = None - - return detach - - def request(self, paths: frozenset[FieldPath]) -> None: - for path in paths: - if path in self._subscriptions or path not in _BROADCAST_MAPS: - continue - self._subscriptions[path] = _LISTENER_FOR[path]( - self._vehicle, self._observer(path) - ) - - def release(self, paths: frozenset[FieldPath]) -> None: - for path in paths: - unsubscribe = self._subscriptions.pop(path, None) - if unsubscribe is not None: - unsubscribe() - - def _observer(self, path: FieldPath) -> Callable[[int], None]: - states = _BROADCAST_MAPS[path] - - def on_broadcast(raw: int) -> None: - sink = self._sink - if sink is None or raw not in states: - return - sink.publish( - Observation( - path=path, - value=states[raw], - observed_at=self._clock(), - source_id=self.source_id, - ) - ) - - return on_broadcast - - -_LISTENER_FOR: Mapping[ - FieldPath, Callable[["VehicleBluetooth[Any]", Callable[[int], None]], Unsubscribe] -] = { - FieldPath.LOCKED: lambda vehicle, cb: vehicle.listen_vehicle_lock_state(cb), - FieldPath.CHARGE_PORT_DOOR_OPEN: lambda vehicle, cb: vehicle.listen_charge_port(cb), - FieldPath.DOOR_STATE_TRUNK_FRONT: lambda vehicle, cb: vehicle.listen_front_trunk( - cb - ), -} - - -# -- Supplied vehicle_data result publisher --------------------------------- - - -def _leaf(section: object, key: str) -> object: - """A present leaf, or ``None``: an absent key is not a falsy value.""" - if isinstance(section, Mapping) and key in section: - return section[key] # pyright: ignore[reportUnknownVariableType] - return None - - -def _int_code(value: object) -> int | None: - """A JSON integer code, rejecting ``bool`` so ``False`` cannot pass as 0.""" - if isinstance(value, bool) or not isinstance(value, int): - return None - return value - - -class VehicleDataResultPublisher: - """Translates a caller-supplied ``vehicle_data`` result into observations. - - The result is an argument. This class holds no client, session, endpoint, - or callable able to obtain one, so nothing here can originate a request or - cause a refresh. - """ - - def __init__( - self, - *, - source_id: str = "vehicle-data", - max_age: float = DEFAULT_RESULT_MAX_AGE, - priority: int = PRIORITY_SUPPLIED_RESULT, - clock: Clock = time.monotonic, - ) -> None: - self._clock = clock - self._sink: PublisherSink | None = None - self._capabilities = tuple( - Capability( - path=path, - delivery=Delivery.PASSIVE_RESULT, - fidelity=Fidelity.EXACT, - max_age=max_age, - ) - for path in FieldPath - ) - self.source_id = source_id - self.priority = priority - - @property - def capabilities(self) -> tuple[Capability, ...]: - return self._capabilities - - def attach(self, sink: PublisherSink) -> Unsubscribe: - self._sink = sink - # A supplied result carries its own freshness and there is no session - # to lose, so the source is healthy for as long as it is attached. - sink.set_health(self.source_id, True) - - def detach() -> None: - sink.set_health(self.source_id, False) - self._sink = None - - return detach - - def request(self, paths: frozenset[FieldPath]) -> None: - """Passive source: activation subscribes to nothing.""" - - def release(self, paths: frozenset[FieldPath]) -> None: - """Passive source: activation subscribes to nothing.""" - - def publish_result( - self, result: Mapping[str, Any], *, observed_at: float | None = None - ) -> tuple[Observation, ...]: - """Translate a supplied result and feed the audited leaves to the sink.""" - at = self._clock() if observed_at is None else observed_at - observations = tuple(self._translate(result, at)) - sink = self._sink - if sink is not None: - for observation in observations: - sink.publish(observation) - return observations - - def _translate( - self, result: Mapping[str, Any], observed_at: float - ) -> Iterator[Observation]: - payload: Mapping[str, Any] = result - response = _leaf(payload, "response") - if isinstance(response, Mapping): - payload = response # pyright: ignore[reportUnknownVariableType] - - vehicle_state = _leaf(payload, "vehicle_state") - charge_state = _leaf(payload, "charge_state") - - locked = _leaf(vehicle_state, "locked") - if isinstance(locked, bool): - yield self._observation(FieldPath.LOCKED, locked, observed_at) - - charge_port = _leaf(charge_state, "charge_port_door_open") - if isinstance(charge_port, bool): - yield self._observation( - FieldPath.CHARGE_PORT_DOOR_OPEN, charge_port, observed_at - ) - - # ``ft`` is an ajar/open code, and only 0 and 1 have a documented - # boolean meaning. - front_trunk = _int_code(_leaf(vehicle_state, "ft")) - if front_trunk in (0, 1): - yield self._observation( - FieldPath.DOOR_STATE_TRUNK_FRONT, front_trunk == 1, observed_at - ) - - def _observation( - self, path: FieldPath, value: bool, observed_at: float - ) -> Observation: - return Observation( - path=path, value=value, observed_at=observed_at, source_id=self.source_id - ) diff --git a/tests/test_funnel.py b/tests/test_funnel.py new file mode 100644 index 0000000..81f3fb4 --- /dev/null +++ b/tests/test_funnel.py @@ -0,0 +1,554 @@ +"""Unit tests for the ObservationFunnel core: fan-in, dedup, activation, demand. + +Uses plain fake publishers so no BLE hardware, network access, or event loop is +involved; the funnel is synchronous by construction. +""" + +from __future__ import annotations + +import ast +from pathlib import Path +from typing import Any +from unittest import TestCase + +from tesla_fleet_api.funnel import ( + FieldPath, + Observation, + ObservationFunnel, + ObservationSink, + Unsubscribe, + Value, +) + +ALL_PATHS = frozenset(FieldPath) +LOCKED = FieldPath.LOCKED +CHARGE_PORT = FieldPath.CHARGE_PORT_DOOR_OPEN +TRUNK = FieldPath.DOOR_STATE_TRUNK_FRONT + + +class _FakePublisher: + """A publisher a test drives directly, recording activation accounting.""" + + def __init__(self, paths: frozenset[FieldPath] = ALL_PATHS) -> None: + self.requested: list[frozenset[FieldPath]] = [] + self.released: list[frozenset[FieldPath]] = [] + self.detached = 0 + self._paths = paths + self._sink: ObservationSink | None = None + + @property + def paths(self) -> frozenset[FieldPath]: + return self._paths + + def attach(self, sink: ObservationSink) -> Unsubscribe: + self._sink = sink + + def detach() -> None: + self.detached += 1 + self._sink = None + + return detach + + def request(self, paths: frozenset[FieldPath]) -> None: + self.requested.append(paths) + + def release(self, paths: frozenset[FieldPath]) -> None: + self.released.append(paths) + + # -- test driver ------------------------------------------------------- + + def emit(self, path: FieldPath, value: Value, observed_at: float) -> None: + assert self._sink is not None + self._sink.publish(Observation(path=path, value=value, observed_at=observed_at)) + + +class TestFanIn(TestCase): + """Many publishers, one listener: nothing here chooses between sources.""" + + def test_both_sources_reach_the_same_listener_with_no_transient_none(self) -> None: + """The bug this exists to prevent: a field blanking when one source drops. + + Both publishers feed the same listener. The stream carries the field, + then goes away entirely and Bluetooth carries it, then Bluetooth goes + away and the stream carries it again. Nothing the funnel emits across + the whole run is ``None``, because no source ever reported one. + """ + funnel = ObservationFunnel() + stream = _FakePublisher() + ble = _FakePublisher() + detach_stream = funnel.attach(stream) + detach_ble = funnel.attach(ble) + + seen: list[Value] = [] + funnel.listen(LOCKED, seen.append) + + stream.emit(LOCKED, True, 1.0) + self.assertEqual(seen, [True]) + + # Stream to Bluetooth: the source is gone, not the field. + detach_stream() + self.assertEqual(seen, [True]) + self.assertIs(funnel.value(LOCKED), True) + + ble.emit(LOCKED, False, 2.0) + ble.emit(LOCKED, True, 3.0) + self.assertEqual(seen, [True, False, True]) + + # Bluetooth back to stream, the other direction. + detach_ble() + detach_stream = funnel.attach(stream) + stream.emit(LOCKED, False, 4.0) + + self.assertEqual(seen, [True, False, True, False]) + self.assertNotIn(None, seen) + self.assertIs(funnel.value(LOCKED), False) + + def test_both_sources_stay_live_together_and_interleave(self) -> None: + """Neither source is a standby: both are heard, in arrival order.""" + funnel = ObservationFunnel() + stream = _FakePublisher() + ble = _FakePublisher() + funnel.attach(stream) + funnel.attach(ble) + + seen: list[Value] = [] + funnel.listen(CHARGE_PORT, seen.append) + + stream.emit(CHARGE_PORT, True, 1.0) + ble.emit(CHARGE_PORT, False, 2.0) + stream.emit(CHARGE_PORT, True, 3.0) + + self.assertEqual(seen, [True, False, True]) + + def test_detaching_every_publisher_emits_nothing(self) -> None: + """Transport loss is not a reading, so the funnel asserts nothing.""" + funnel = ObservationFunnel() + stream = _FakePublisher() + ble = _FakePublisher() + detach_stream = funnel.attach(stream) + detach_ble = funnel.attach(ble) + + seen: list[Value] = [] + funnel.listen(TRUNK, seen.append) + stream.emit(TRUNK, True, 1.0) + + detach_stream() + detach_ble() + + self.assertEqual(seen, [True]) + self.assertIs(funnel.value(TRUNK), True) + + def test_a_reported_unavailable_value_is_delivered_as_such(self) -> None: + """Unavailability is a value a source reports, and it is passed through.""" + funnel = ObservationFunnel() + publisher = _FakePublisher() + funnel.attach(publisher) + + seen: list[Value] = [] + funnel.listen(LOCKED, seen.append) + + publisher.emit(LOCKED, True, 1.0) + publisher.emit(LOCKED, None, 2.0) + publisher.emit(LOCKED, True, 3.0) + + self.assertEqual(seen, [True, None, True]) + + def test_detaching_twice_is_idempotent(self) -> None: + funnel = ObservationFunnel() + publisher = _FakePublisher() + detach = funnel.attach(publisher) + funnel.listen(LOCKED, lambda _: None) + + detach() + detach() + + self.assertEqual(publisher.detached, 1) + self.assertEqual(publisher.released, [frozenset({LOCKED})]) + + +class TestSurvivingLogic(TestCase): + """The only arbitration left: drop the out-of-order, drop the unchanged.""" + + def test_an_observation_older_than_the_last_one_is_ignored(self) -> None: + funnel = ObservationFunnel() + stream = _FakePublisher() + ble = _FakePublisher() + funnel.attach(stream) + funnel.attach(ble) + + seen: list[Value] = [] + funnel.listen(LOCKED, seen.append) + + stream.emit(LOCKED, True, 10.0) + ble.emit(LOCKED, False, 5.0) + + self.assertEqual(seen, [True]) + self.assertIs(funnel.value(LOCKED), True) + + def test_a_stale_frame_cannot_win_by_repeating_after_a_newer_one(self) -> None: + """A skipped repeat still advances the clock it is compared against.""" + funnel = ObservationFunnel() + publisher = _FakePublisher() + funnel.attach(publisher) + + seen: list[Value] = [] + funnel.listen(LOCKED, seen.append) + + publisher.emit(LOCKED, True, 10.0) + publisher.emit(LOCKED, True, 20.0) # unchanged, not re-dispatched + publisher.emit(LOCKED, False, 15.0) # older than the 20.0 reading + + self.assertEqual(seen, [True]) + + def test_an_unchanged_value_is_not_re_dispatched(self) -> None: + funnel = ObservationFunnel() + stream = _FakePublisher() + ble = _FakePublisher() + funnel.attach(stream) + funnel.attach(ble) + + seen: list[Value] = [] + funnel.listen(LOCKED, seen.append) + + stream.emit(LOCKED, True, 1.0) + stream.emit(LOCKED, True, 2.0) + ble.emit(LOCKED, True, 3.0) + ble.emit(LOCKED, False, 4.0) + stream.emit(LOCKED, False, 5.0) + + self.assertEqual(seen, [True, False]) + + def test_a_repeated_unavailable_value_is_also_deduplicated(self) -> None: + funnel = ObservationFunnel() + publisher = _FakePublisher() + funnel.attach(publisher) + + seen: list[Value] = [] + funnel.listen(LOCKED, seen.append) + + publisher.emit(LOCKED, None, 1.0) + publisher.emit(LOCKED, None, 2.0) + + self.assertEqual(seen, [None]) + + def test_an_equal_timestamp_is_not_treated_as_older(self) -> None: + funnel = ObservationFunnel() + publisher = _FakePublisher() + funnel.attach(publisher) + + seen: list[Value] = [] + funnel.listen(LOCKED, seen.append) + + publisher.emit(LOCKED, True, 1.0) + publisher.emit(LOCKED, False, 1.0) + + self.assertEqual(seen, [True, False]) + + def test_fields_are_ordered_independently(self) -> None: + funnel = ObservationFunnel() + publisher = _FakePublisher() + funnel.attach(publisher) + + locked: list[Value] = [] + trunk: list[Value] = [] + funnel.listen(LOCKED, locked.append) + funnel.listen(TRUNK, trunk.append) + + publisher.emit(LOCKED, True, 10.0) + publisher.emit(TRUNK, True, 5.0) + + self.assertEqual(locked, [True]) + self.assertEqual(trunk, [True]) + + +class TestValues(TestCase): + def test_an_unobserved_field_has_no_value(self) -> None: + funnel = ObservationFunnel() + for path in FieldPath: + self.assertIsNone(funnel.value(path)) + + def test_registration_dispatches_nothing(self) -> None: + funnel = ObservationFunnel() + publisher = _FakePublisher() + funnel.attach(publisher) + funnel.listen(LOCKED, lambda _: None) + publisher.emit(LOCKED, True, 1.0) + + late: list[Value] = [] + funnel.listen(LOCKED, late.append) + + self.assertEqual(late, []) + self.assertIs(funnel.value(LOCKED), True) + + +class TestActivation(TestCase): + def test_first_listener_activates_and_last_releases_exactly_once(self) -> None: + funnel = ObservationFunnel() + publisher = _FakePublisher() + funnel.attach(publisher) + + first = funnel.listen(LOCKED, lambda _: None) + second = funnel.listen(LOCKED, lambda _: None) + self.assertEqual(publisher.requested, [frozenset({LOCKED})]) + + first() + self.assertEqual(publisher.released, []) + second() + self.assertEqual(publisher.released, [frozenset({LOCKED})]) + + # A repeated release must not double-count either. + second() + self.assertEqual(publisher.released, [frozenset({LOCKED})]) + + def test_the_same_callback_twice_needs_two_releases(self) -> None: + funnel = ObservationFunnel() + publisher = _FakePublisher() + funnel.attach(publisher) + + def callback(_: Value) -> None: + return None + + first = funnel.listen(LOCKED, callback) + second = funnel.listen(LOCKED, callback) + first() + self.assertEqual(publisher.released, []) + second() + self.assertEqual(publisher.released, [frozenset({LOCKED})]) + + def test_activation_is_per_path(self) -> None: + funnel = ObservationFunnel() + publisher = _FakePublisher() + funnel.attach(publisher) + + funnel.listen(LOCKED, lambda _: None) + funnel.listen(TRUNK, lambda _: None) + + self.assertEqual(publisher.requested, [frozenset({LOCKED}), frozenset({TRUNK})]) + + def test_a_publisher_is_asked_only_for_paths_it_supplies(self) -> None: + funnel = ObservationFunnel() + publisher = _FakePublisher(paths=frozenset({LOCKED})) + funnel.attach(publisher) + + funnel.listen(TRUNK, lambda _: None) + self.assertEqual(publisher.requested, []) + + funnel.listen(LOCKED, lambda _: None) + self.assertEqual(publisher.requested, [frozenset({LOCKED})]) + + def test_a_publisher_attached_later_is_asked_for_active_paths(self) -> None: + funnel = ObservationFunnel() + funnel.listen(LOCKED, lambda _: None) + + publisher = _FakePublisher() + funnel.attach(publisher) + + self.assertEqual(publisher.requested, [frozenset({LOCKED})]) + + def test_detaching_releases_the_active_paths(self) -> None: + funnel = ObservationFunnel() + publisher = _FakePublisher() + detach = funnel.attach(publisher) + funnel.listen(LOCKED, lambda _: None) + + detach() + + self.assertEqual(publisher.released, [frozenset({LOCKED})]) + self.assertEqual(publisher.detached, 1) + + +class TestDispatchSafety(TestCase): + def test_a_callback_that_publishes_leaves_no_listener_on_the_old_value( + self, + ) -> None: + funnel = ObservationFunnel() + publisher = _FakePublisher() + funnel.attach(publisher) + + first: list[Value] = [] + second: list[Value] = [] + + def republish(value: Value) -> None: + first.append(value) + if value is True: + publisher.emit(LOCKED, False, 2.0) + + funnel.listen(LOCKED, republish) + funnel.listen(LOCKED, second.append) + + publisher.emit(LOCKED, True, 1.0) + + self.assertEqual(first, [True, False]) + # The second listener saw only the value that still stands. + self.assertEqual(second, [False]) + self.assertIs(funnel.value(LOCKED), False) + + def test_a_listener_exception_does_not_stop_later_listeners(self) -> None: + funnel = ObservationFunnel() + publisher = _FakePublisher() + funnel.attach(publisher) + + def explode(_: Value) -> None: + raise RuntimeError("listener failed") + + seen: list[Value] = [] + funnel.listen(LOCKED, explode) + funnel.listen(LOCKED, seen.append) + + with self.assertLogs("tesla_fleet_api", level="ERROR"): + publisher.emit(LOCKED, True, 1.0) + + self.assertEqual(seen, [True]) + + +class TestDemand(TestCase): + def test_demand_reports_initial_state_then_only_aggregate_edges(self) -> None: + funnel = ObservationFunnel() + seen: list[bool] = [] + funnel.listen_demand(ALL_PATHS, seen.append) + self.assertEqual(seen, [False]) + + locked = funnel.listen(LOCKED, lambda _: None) + self.assertEqual(seen, [False, True]) + + # Still demanded: a second path rising is not an aggregate edge. + trunk = funnel.listen(TRUNK, lambda _: None) + self.assertEqual(seen, [False, True]) + + locked() + self.assertEqual(seen, [False, True]) + trunk() + self.assertEqual(seen, [False, True, False]) + + def test_demand_starts_true_when_a_listener_already_exists(self) -> None: + funnel = ObservationFunnel() + funnel.listen(LOCKED, lambda _: None) + + seen: list[bool] = [] + funnel.listen_demand(ALL_PATHS, seen.append) + self.assertEqual(seen, [True]) + + def test_demand_ignores_listeners_outside_its_path_set(self) -> None: + funnel = ObservationFunnel() + seen: list[bool] = [] + funnel.listen_demand(frozenset({LOCKED}), seen.append) + + funnel.listen(TRUNK, lambda _: None) + self.assertEqual(seen, [False]) + + funnel.listen(LOCKED, lambda _: None) + self.assertEqual(seen, [False, True]) + + def test_unsubscribe_removes_only_that_observer(self) -> None: + funnel = ObservationFunnel() + kept: list[bool] = [] + dropped: list[bool] = [] + funnel.listen_demand(ALL_PATHS, kept.append) + release = funnel.listen_demand(ALL_PATHS, dropped.append) + + release() + release() + funnel.listen(LOCKED, lambda _: None) + + self.assertEqual(kept, [False, True]) + self.assertEqual(dropped, [False]) + + def test_releasing_one_of_two_identical_registrations_keeps_the_other( + self, + ) -> None: + funnel = ObservationFunnel() + seen: list[bool] = [] + funnel.listen_demand(ALL_PATHS, seen.append) + release = funnel.listen_demand(ALL_PATHS, seen.append) + + release() + funnel.listen(LOCKED, lambda _: None) + + self.assertEqual(seen, [False, False, True]) + + +class TestFunnelCannotOriginateWork(TestCase): + """The load-bearing invariant: the funnel is structurally unable to poll. + + Asserted against the module's own syntax tree rather than its behaviour, + because a request path that exists but is merely unused would still be a + request path. + """ + + tree: ast.Module + + @classmethod + def setUpClass(cls) -> None: + import tesla_fleet_api.funnel as module + + source = Path(module.__file__).read_text(encoding="utf-8") + cls.tree = ast.parse(source) + + def test_the_module_is_entirely_synchronous(self) -> None: + for node in ast.walk(self.tree): + self.assertNotIsInstance(node, ast.AsyncFunctionDef) + self.assertNotIsInstance(node, ast.Await) + self.assertNotIsInstance(node, ast.AsyncFor) + self.assertNotIsInstance(node, ast.AsyncWith) + + def test_the_module_imports_no_transport_or_scheduling_machinery(self) -> None: + forbidden = { + "asyncio", + "aiohttp", + "aiofiles", + "bleak", + "threading", + "sched", + "requests", + "urllib", + "socket", + } + imported: set[str] = set() + for node in ast.walk(self.tree): + if isinstance(node, ast.Import): + imported.update(alias.name.split(".")[0] for alias in node.names) + elif isinstance(node, ast.ImportFrom) and node.module: + imported.add(node.module.split(".")[0]) + self.assertEqual(imported & forbidden, set()) + + def test_the_module_calls_nothing_that_could_originate_a_request(self) -> None: + forbidden = { + "sleep", + "create_task", + "ensure_future", + "run_coroutine_threadsafe", + "call_later", + "call_soon", + "vehicle_data", + "charge_state", + "vehicle_state", + "connect", + "connect_if_needed", + "wake_up", + "_send", + "_request", + "_getVehicleSecurity", + "_getInfotainment", + "Thread", + "Timer", + } + called: set[str] = set() + for node in ast.walk(self.tree): + if not isinstance(node, ast.Call): + continue + func = node.func + if isinstance(func, ast.Name): + called.add(func.id) + elif isinstance(func, ast.Attribute): + called.add(func.attr) + self.assertEqual(called & forbidden, set()) + + def test_the_funnel_exposes_no_awaitable_member(self) -> None: + import inspect + + funnel: Any = ObservationFunnel() + for name, member in inspect.getmembers(funnel): + self.assertFalse( + inspect.iscoroutinefunction(member), + msg=f"{name} is a coroutine function", + ) diff --git a/tests/test_stream_router_bluetooth.py b/tests/test_funnel_bluetooth.py similarity index 58% rename from tests/test_stream_router_bluetooth.py rename to tests/test_funnel_bluetooth.py index a49bd0a..c4f0936 100644 --- a/tests/test_stream_router_bluetooth.py +++ b/tests/test_funnel_bluetooth.py @@ -14,13 +14,14 @@ from cryptography.hazmat.primitives.asymmetric import ec -from tesla_fleet_api.router import ( +from tesla_fleet_api.funnel import ( BleBroadcastPublisher, FieldPath, Observation, - StreamRouter, + ObservationFunnel, + Value, + VehicleDataResultPublisher, ) -from tesla_fleet_api.router.stream import PRIORITY_BROADCAST, PRIORITY_STREAM from tesla_fleet_api.tesla.vehicle.bluetooth import VehicleBluetooth from tesla_protocol.command.universal_message_pb2 import ( Destination, @@ -83,18 +84,14 @@ def _closures(**kwargs: ClosureState_E) -> RoutableMessage: class _Sink: - """Collects observations and health straight off the publisher.""" + """Collects observations straight off the publisher.""" def __init__(self) -> None: self.observations: list[Observation] = [] - self.health: list[tuple[str, bool]] = [] def publish(self, observation: Observation) -> None: self.observations.append(observation) - def set_health(self, source_id: str, healthy: bool) -> None: - self.health.append((source_id, healthy)) - def _attached( vehicle: VehicleBluetooth[Any], *, paths: frozenset[FieldPath] | None = None @@ -166,29 +163,29 @@ def test_ambiguous_closure_states_emit_no_observation(self) -> None: self.assertEqual(sink.observations, []) def test_a_broadcast_without_closures_emits_no_closure_observation(self) -> None: - """proto3 tracks presence for the submessage, so absence is not CLOSED.""" + """Closures have proto3 presence, so an absent submessage says nothing.""" vehicle = _make_vehicle() - _, sink = _attached(vehicle) + _, sink = _attached(vehicle, paths=CLOSURE_PATHS) vehicle._on_message(_lock(VehicleLockState_E.VEHICLELOCKSTATE_LOCKED)) - self.assertEqual([o.path for o in sink.observations], [FieldPath.LOCKED]) + self.assertEqual(sink.observations, []) def test_every_status_broadcast_carries_a_lock_state(self) -> None: - """UNLOCKED is 0, so the wire cannot distinguish it from an absent field. - - A closure-only broadcast therefore still reports the lock state, and - the router deduplicates the repeat rather than the publisher guessing. - """ + """UNLOCKED is 0 with no presence, so the funnel dedupes the repeats.""" vehicle = _make_vehicle() - _, sink = _attached(vehicle, paths=frozenset({FieldPath.LOCKED})) + funnel = ObservationFunnel() + publisher = BleBroadcastPublisher(vehicle, clock=_Clock(1.0)) + funnel.attach(publisher) - vehicle._on_message(_closures(chargePort=ClosureState_E.CLOSURESTATE_OPEN)) + seen: list[Value] = [] + funnel.listen(FieldPath.LOCKED, seen.append) - self.assertEqual( - [(o.path, o.value) for o in sink.observations], - [(FieldPath.LOCKED, False)], - ) + # A closure-only broadcast still reports vehicleLockState = UNLOCKED. + vehicle._on_message(_closures(frontTrunk=ClosureState_E.CLOSURESTATE_CLOSED)) + vehicle._on_message(_closures(frontTrunk=ClosureState_E.CLOSURESTATE_OPEN)) + + self.assertEqual(seen, [False]) class TestBroadcastActivation(TestCase): @@ -205,7 +202,7 @@ def test_request_and_release_bracket_the_ble_listener(self) -> None: def test_only_requested_paths_are_subscribed(self) -> None: vehicle = _make_vehicle() - _, sink = _attached(vehicle, paths=frozenset({FieldPath.CHARGE_PORT_DOOR_OPEN})) + _, sink = _attached(vehicle, paths=frozenset({FieldPath.LOCKED})) vehicle._on_message( _closures( @@ -213,10 +210,8 @@ def test_only_requested_paths_are_subscribed(self) -> None: frontTrunk=ClosureState_E.CLOSURESTATE_OPEN, ) ) - self.assertEqual( - [(o.path, o.value) for o in sink.observations], - [(FieldPath.CHARGE_PORT_DOOR_OPEN, True)], - ) + + self.assertEqual([o.path for o in sink.observations], [FieldPath.LOCKED]) def test_detach_drops_every_subscription(self) -> None: vehicle = _make_vehicle() @@ -224,157 +219,114 @@ def test_detach_drops_every_subscription(self) -> None: sink = _Sink() detach = publisher.attach(sink) publisher.request(frozenset(FieldPath)) - detach() + detach() vehicle._on_message(_lock(VehicleLockState_E.VEHICLELOCKSTATE_LOCKED)) - self.assertEqual(sink.observations, []) - - def test_health_follows_the_existing_connection_status_seam(self) -> None: - vehicle = _make_vehicle(connected=False) - publisher = BleBroadcastPublisher(vehicle, clock=_Clock()) - sink = _Sink() - publisher.attach(sink) - self.assertEqual(sink.health, [("ble-broadcast", False)]) - - vehicle._set_connected(True) - vehicle._set_connected(False) - self.assertEqual( - sink.health, - [ - ("ble-broadcast", False), - ("ble-broadcast", True), - ("ble-broadcast", False), - ], - ) - def test_a_session_already_up_at_attach_is_reported_healthy(self) -> None: - """listen_connection_status only fires on transitions.""" - vehicle = _make_vehicle(connected=True) - publisher = BleBroadcastPublisher(vehicle, clock=_Clock()) - sink = _Sink() - publisher.attach(sink) - self.assertEqual(sink.health, [("ble-broadcast", True)]) + self.assertEqual(sink.observations, []) def test_the_publisher_never_drives_the_transport(self) -> None: vehicle = _make_vehicle() publisher, _ = _attached(vehicle) - vehicle._on_message(_lock(VehicleLockState_E.VEHICLELOCKSTATE_LOCKED)) publisher.release(frozenset(FieldPath)) vehicle.connect.assert_not_awaited() # type: ignore[attr-defined] vehicle.connect_if_needed.assert_not_awaited() # type: ignore[attr-defined] vehicle.client.write_gatt_char.assert_not_awaited() + def test_a_lost_session_publishes_nothing(self) -> None: + """A dropped link ends the broadcasts; it is not a reading of the field.""" + vehicle = _make_vehicle() + funnel = ObservationFunnel() + detach = funnel.attach(BleBroadcastPublisher(vehicle, clock=_Clock(1.0))) -class TestRegressionWalkthrough(TestCase): - """The captain's 6.1.1 failure: Bluetooth cannot connect, the stream can. - - Charge port, front trunk and lock must stay available with real values - rather than going unavailable because a source they were bound to is down. - """ - - def _router_with_stream_and_ble( - self, vehicle: VehicleBluetooth[Any], clock: _Clock - ) -> tuple[StreamRouter, _StubStreamPublisher]: - router = StreamRouter(clock=clock) - stream = _StubStreamPublisher(clock) - router.attach(stream) - router.attach( - BleBroadcastPublisher(vehicle, priority=PRIORITY_BROADCAST, clock=clock) - ) - return router, stream - - def test_unavailable_bluetooth_does_not_blank_streamed_fields(self) -> None: - clock = _Clock() - vehicle = _make_vehicle(connected=False) - router, stream = self._router_with_stream_and_ble(vehicle, clock) - - seen: dict[FieldPath, list[Any]] = {p: [] for p in FieldPath} - for path in FieldPath: - router.listen(path, seen[path].append) - - stream.emit(FieldPath.CHARGE_PORT_DOOR_OPEN, False) - stream.emit(FieldPath.DOOR_STATE_TRUNK_FRONT, False) - stream.emit(FieldPath.LOCKED, True) - - self.assertEqual(seen[FieldPath.CHARGE_PORT_DOOR_OPEN], [False]) - self.assertEqual(seen[FieldPath.DOOR_STATE_TRUNK_FRONT], [False]) - self.assertEqual(seen[FieldPath.LOCKED], [True]) - for path in FieldPath: - self.assertTrue(router.is_available(path), msg=str(path)) - self.assertNotIn(None, seen[path]) - - def test_ble_takes_over_when_the_stream_drops_then_hands_back(self) -> None: - clock = _Clock() - vehicle = _make_vehicle(connected=True) - router, stream = self._router_with_stream_and_ble(vehicle, clock) - - seen: list[Any] = [] - available: list[Any] = [] - router.listen(FieldPath.LOCKED, seen.append) - router.listen_availability(FieldPath.LOCKED, available.append) - - stream.emit(FieldPath.LOCKED, True) + seen: list[Value] = [] + funnel.listen(FieldPath.LOCKED, seen.append) vehicle._on_message(_lock(VehicleLockState_E.VEHICLELOCKSTATE_LOCKED)) - self.assertEqual(seen, [True]) - clock.now = 10.0 - stream.set_health(False) - vehicle._on_message(_lock(VehicleLockState_E.VEHICLELOCKSTATE_UNLOCKED)) - self.assertEqual(seen, [True, False]) - - # The stream must hold health for the failback delay before it takes - # the field back off a working Bluetooth session. - clock.now = 100.0 - stream.set_health(True) - stream.emit(FieldPath.LOCKED, True) - self.assertEqual(seen, [True, False]) - - clock.now = 200.0 - stream.emit(FieldPath.LOCKED, True) - self.assertEqual(seen, [True, False, True]) + vehicle.client.is_connected = False + detach() - self.assertNotIn(None, seen) - self.assertEqual(available, [False, True]) + self.assertEqual(seen, [True]) + self.assertIs(funnel.value(FieldPath.LOCKED), True) -class _StubStreamPublisher: - """Stands in for the stream library's SSE adapter, which is a later slice.""" +class TestRegressionWalkthrough(TestCase): + """The reported failure: lock, charge port and front trunk go unavailable. - def __init__(self, clock: _Clock) -> None: - self.source_id = "stream" - self.priority = PRIORITY_STREAM - self._clock = clock - self._sink: Any = None + Firmware routed the three fields to Bluetooth broadcasts. With Bluetooth + unreachable they had no other source, so the entities blanked. Here the + same funnel is fed by both a supplied ``vehicle_data`` result and BLE + broadcasts, and each field keeps a value whichever source is producing. + """ - @property - def capabilities(self) -> tuple[Any, ...]: - from tesla_fleet_api.router.stream import Capability, Delivery + def _compose( + self, + ) -> tuple[ + ObservationFunnel, + VehicleBluetooth[Any], + VehicleDataResultPublisher, + dict[FieldPath, list[Value]], + ]: + vehicle = _make_vehicle() + funnel = ObservationFunnel() + funnel.attach(BleBroadcastPublisher(vehicle, clock=_Clock(100.0))) + result_publisher = VehicleDataResultPublisher(clock=_Clock(0.0)) + funnel.attach(result_publisher) - return tuple( - Capability(path=path, delivery=Delivery.ON_CHANGE) for path in FieldPath + seen: dict[FieldPath, list[Value]] = {path: [] for path in FieldPath} + for path in FieldPath: + funnel.listen(path, seen[path].append) + return funnel, vehicle, result_publisher, seen + + def test_unreachable_bluetooth_does_not_blank_the_three_fields(self) -> None: + funnel, _, result_publisher, seen = self._compose() + + # Bluetooth never connects, so it broadcasts nothing at all. + result_publisher.publish_result( + { + "response": { + "vehicle_state": {"locked": True, "ft": 0}, + "charge_state": {"charge_port_door_open": False}, + } + }, + observed_at=1.0, ) - def attach(self, sink: Any) -> Any: - self._sink = sink - sink.set_health(self.source_id, True) - return lambda: None - - def request(self, paths: frozenset[FieldPath]) -> None: - return None - - def release(self, paths: frozenset[FieldPath]) -> None: - return None + self.assertEqual( + seen, + { + FieldPath.LOCKED: [True], + FieldPath.CHARGE_PORT_DOOR_OPEN: [False], + FieldPath.DOOR_STATE_TRUNK_FRONT: [False], + }, + ) + for path in FieldPath: + self.assertIsNotNone(funnel.value(path)) + + def test_bluetooth_recovering_updates_the_same_listeners(self) -> None: + funnel, vehicle, result_publisher, seen = self._compose() + result_publisher.publish_result( + { + "response": { + "vehicle_state": {"locked": True, "ft": 0}, + "charge_state": {"charge_port_door_open": False}, + } + }, + observed_at=1.0, + ) - def emit(self, path: FieldPath, value: bool) -> None: - self._sink.publish( - Observation( - path=path, - value=value, - observed_at=self._clock(), - source_id=self.source_id, + # Bluetooth comes up and the vehicle is opened up. + vehicle._on_message(_lock(VehicleLockState_E.VEHICLELOCKSTATE_UNLOCKED)) + vehicle._on_message( + _closures( + chargePort=ClosureState_E.CLOSURESTATE_OPEN, + frontTrunk=ClosureState_E.CLOSURESTATE_OPEN, ) ) - def set_health(self, healthy: bool) -> None: - self._sink.set_health(self.source_id, healthy) + self.assertEqual(seen[FieldPath.LOCKED], [True, False]) + self.assertEqual(seen[FieldPath.CHARGE_PORT_DOOR_OPEN], [False, True]) + self.assertEqual(seen[FieldPath.DOOR_STATE_TRUNK_FRONT], [False, True]) + for values in seen.values(): + self.assertNotIn(None, values) diff --git a/tests/test_stream_router_vehicle_data.py b/tests/test_funnel_vehicle_data.py similarity index 53% rename from tests/test_stream_router_vehicle_data.py rename to tests/test_funnel_vehicle_data.py index 1939784..000c4aa 100644 --- a/tests/test_stream_router_vehicle_data.py +++ b/tests/test_funnel_vehicle_data.py @@ -12,10 +12,10 @@ from typing import Any from unittest import TestCase -from tesla_fleet_api.router import ( +from tesla_fleet_api.funnel import ( FieldPath, - Observation, - StreamRouter, + ObservationFunnel, + Value, VehicleDataResultPublisher, ) @@ -50,19 +50,7 @@ def __call__(self) -> float: return self.now -class _Sink: - def __init__(self) -> None: - self.observations: list[Observation] = [] - self.health: list[tuple[str, bool]] = [] - - def publish(self, observation: Observation) -> None: - self.observations.append(observation) - - def set_health(self, source_id: str, healthy: bool) -> None: - self.health.append((source_id, healthy)) - - -def _translate(result: dict[str, Any]) -> dict[FieldPath, bool]: +def _translate(result: dict[str, Any]) -> dict[FieldPath, Value]: publisher = VehicleDataResultPublisher(clock=_Clock()) return {o.path: o.value for o in publisher.publish_result(result)} @@ -82,97 +70,135 @@ def test_a_bare_response_body_is_accepted(self) -> None: self.assertEqual(_translate(RESULT["response"]), _translate(RESULT)) def test_absent_leaves_emit_no_observation(self) -> None: - self.assertEqual(_translate({"response": {}}), {}) self.assertEqual( - _translate({"response": {"vehicle_state": {}, "charge_state": {}}}), {} + _translate({"response": {"vehicle_state": {"car_version": "2025.14.3"}}}), + {}, ) - def test_a_null_leaf_emits_no_observation(self) -> None: - result = {"response": {"vehicle_state": {"locked": None, "ft": None}}} - self.assertEqual(_translate(result), {}) + def test_a_null_leaf_is_an_explicit_unavailable_reading(self) -> None: + """A null is the vehicle reporting the field unavailable, not silence.""" + self.assertEqual( + _translate( + { + "response": { + "vehicle_state": {"locked": None, "ft": None}, + "charge_state": {"charge_port_door_open": None}, + } + } + ), + { + FieldPath.LOCKED: None, + FieldPath.CHARGE_PORT_DOOR_OPEN: None, + FieldPath.DOOR_STATE_TRUNK_FRONT: None, + }, + ) def test_a_non_boolean_locked_emits_no_observation(self) -> None: - result = {"response": {"vehicle_state": {"locked": "true"}}} - self.assertEqual(_translate(result), {}) + self.assertEqual( + _translate({"response": {"vehicle_state": {"locked": "true"}}}), {} + ) def test_front_trunk_maps_only_the_documented_codes(self) -> None: for code, expected in ((0, False), (1, True)): - result = {"response": {"vehicle_state": {"ft": code}}} self.assertEqual( - _translate(result), {FieldPath.DOOR_STATE_TRUNK_FRONT: expected} + _translate({"response": {"vehicle_state": {"ft": code}}}), + {FieldPath.DOOR_STATE_TRUNK_FRONT: expected}, + ) + for code in (2, 3, -1): + self.assertEqual( + _translate({"response": {"vehicle_state": {"ft": code}}}), + {}, + msg=f"ft code {code}", ) - - for code in (2, 3, 255, -1): - result = {"response": {"vehicle_state": {"ft": code}}} - self.assertEqual(_translate(result), {}, msg=f"ft={code}") def test_a_boolean_front_trunk_is_not_read_as_a_code(self) -> None: - """``False == 0`` in Python; the wire code and a bool are not the same fact.""" - result = {"response": {"vehicle_state": {"ft": False}}} - self.assertEqual(_translate(result), {}) + self.assertEqual(_translate({"response": {"vehicle_state": {"ft": False}}}), {}) def test_unaudited_leaves_are_never_routed(self) -> None: - """A present, easily flattened leaf is still not a routable field.""" - observations = VehicleDataResultPublisher(clock=_Clock()).publish_result(RESULT) - self.assertEqual({o.path for o in observations}, set(FieldPath)) + """No reflective flattening: a sibling closure leaf produces nothing.""" + self.assertEqual( + _translate({"response": {"vehicle_state": {"rt": 1, "df": 1}}}), {} + ) def test_a_malformed_result_is_ignored_rather_than_guessed(self) -> None: - self.assertEqual(_translate({}), {}) - self.assertEqual(_translate({"response": None}), {}) - self.assertEqual(_translate({"response": "unavailable"}), {}) - self.assertEqual(_translate({"response": {"vehicle_state": []}}), {}) + for result in ( + {}, + {"response": None}, + {"response": {"vehicle_state": None}}, + {"response": {"vehicle_state": "locked"}}, + ): + self.assertEqual(_translate(result), {}, msg=f"{result}") + + +class TestSuppliedResultFunnelling(TestCase): + def test_a_supplied_result_reaches_listeners(self) -> None: + funnel = ObservationFunnel() + publisher = VehicleDataResultPublisher(clock=_Clock()) + funnel.attach(publisher) + seen: dict[FieldPath, list[Value]] = {path: [] for path in FieldPath} + for path in FieldPath: + funnel.listen(path, seen[path].append) -class TestSuppliedResultRouting(TestCase): - def test_a_supplied_result_reaches_listeners_through_arbitration(self) -> None: - clock = _Clock() - router = StreamRouter(clock=clock) - publisher = VehicleDataResultPublisher(clock=clock) - router.attach(publisher) + publisher.publish_result(RESULT, observed_at=1.0) - seen: list[Any] = [] - router.listen(FieldPath.LOCKED, seen.append) - publisher.publish_result(RESULT) + self.assertEqual( + seen, + { + FieldPath.LOCKED: [True], + FieldPath.CHARGE_PORT_DOOR_OPEN: [True], + FieldPath.DOOR_STATE_TRUNK_FRONT: [False], + }, + ) + + def test_a_repeated_result_is_not_re_dispatched(self) -> None: + funnel = ObservationFunnel() + publisher = VehicleDataResultPublisher(clock=_Clock()) + funnel.attach(publisher) + + seen: list[Value] = [] + funnel.listen(FieldPath.LOCKED, seen.append) + + publisher.publish_result(RESULT, observed_at=1.0) + publisher.publish_result(RESULT, observed_at=2.0) self.assertEqual(seen, [True]) - self.assertIs(router.value(FieldPath.LOCKED), True) - self.assertTrue(router.is_available(FieldPath.LOCKED)) - def test_a_supplied_result_expires_and_schedules_no_refresh(self) -> None: + def test_a_supplied_result_never_expires_by_itself(self) -> None: + """Age is not a reading either: nothing here blanks a stale value.""" clock = _Clock() - router = StreamRouter(clock=clock, grace=0.0) - publisher = VehicleDataResultPublisher(clock=clock, max_age=60.0) - router.attach(publisher) - router.listen(FieldPath.LOCKED, lambda _: None) + funnel = ObservationFunnel() + publisher = VehicleDataResultPublisher(clock=clock) + funnel.attach(publisher) + seen: list[Value] = [] + funnel.listen(FieldPath.LOCKED, seen.append) publisher.publish_result(RESULT) - clock.now = 30.0 - self.assertTrue(router.is_available(FieldPath.LOCKED)) - clock.now = 120.0 - self.assertFalse(router.is_available(FieldPath.LOCKED)) - # Expiry retains the last known value and originates nothing. - self.assertIs(router.value(FieldPath.LOCKED), True) + clock.now = 100_000.0 + self.assertEqual(seen, [True]) + self.assertIs(funnel.value(FieldPath.LOCKED), True) - def test_activation_asks_a_passive_source_for_nothing(self) -> None: - router = StreamRouter(clock=_Clock()) + def test_activation_subscribes_a_passive_source_to_nothing(self) -> None: + funnel = ObservationFunnel() publisher = VehicleDataResultPublisher(clock=_Clock()) - router.attach(publisher) - sink = _Sink() - publisher.attach(sink) + funnel.attach(publisher) + before = dict(vars(publisher)) + + release = funnel.listen(FieldPath.LOCKED, lambda _: None) + release() - unsubscribe = router.listen(FieldPath.LOCKED, lambda _: None) - unsubscribe() - # A request/release round trip produced no observation of its own. - self.assertEqual(sink.observations, []) + self.assertEqual(vars(publisher), before) def test_publishing_before_attach_translates_but_reaches_no_listener(self) -> None: - router = StreamRouter(clock=_Clock()) + funnel = ObservationFunnel() publisher = VehicleDataResultPublisher(clock=_Clock()) - seen: list[Any] = [] - router.listen(FieldPath.LOCKED, seen.append) - self.assertEqual(len(publisher.publish_result(RESULT)), 3) + seen: list[Value] = [] + funnel.listen(FieldPath.LOCKED, seen.append) + observations = publisher.publish_result(RESULT, observed_at=1.0) + + self.assertEqual(len(observations), 3) self.assertEqual(seen, []) @@ -190,12 +216,7 @@ def test_it_exposes_no_coroutine_and_no_awaitable_member(self) -> None: def test_it_holds_no_client_session_or_fetch_callable(self) -> None: publisher = VehicleDataResultPublisher(clock=_Clock()) - held = { - name: value - for name, value in vars(publisher).items() - if name not in ("_clock", "_sink", "_capabilities") - } - self.assertEqual(held, {"source_id": "vehicle-data", "priority": 40}) + self.assertEqual(set(vars(publisher)), {"_clock", "_sink"}) # The clock is the only callable it keeps, and it takes no arguments. self.assertEqual( @@ -206,20 +227,18 @@ def test_it_holds_no_client_session_or_fetch_callable(self) -> None: def test_it_yields_nothing_when_no_result_is_supplied(self) -> None: """With the fake input withheld there is no other source to fall back on.""" clock = _Clock() - router = StreamRouter(clock=clock) + funnel = ObservationFunnel() publisher = VehicleDataResultPublisher(clock=clock) - router.attach(publisher) + funnel.attach(publisher) - seen: list[Any] = [] - router.listen(FieldPath.LOCKED, seen.append) - router.listen(FieldPath.CHARGE_PORT_DOOR_OPEN, seen.append) - router.listen(FieldPath.DOOR_STATE_TRUNK_FRONT, seen.append) + seen: list[Value] = [] + for path in FieldPath: + funnel.listen(path, seen.append) clock.now = 10_000.0 self.assertEqual(seen, []) for path in FieldPath: - self.assertIsNone(router.value(path)) - self.assertFalse(router.is_available(path)) + self.assertIsNone(funnel.value(path)) def test_an_object_that_could_fetch_is_never_called(self) -> None: """A result-shaped mapping whose lookups are counted proves the reads.""" diff --git a/tests/test_stream_router.py b/tests/test_stream_router.py deleted file mode 100644 index be56a96..0000000 --- a/tests/test_stream_router.py +++ /dev/null @@ -1,841 +0,0 @@ -"""Unit tests for the StreamRouter core: selection, freshness, activation, demand. - -Uses plain fake publishers so no BLE hardware, network access, or event loop is -involved; the router is synchronous by construction. -""" - -from __future__ import annotations - -import ast -from itertools import count -from pathlib import Path -from typing import Any -from unittest import TestCase - -from tesla_fleet_api.router import ( - Capability, - Delivery, - FieldPath, - Fidelity, - Observation, - PublisherSink, - StreamRouter, -) -from tesla_fleet_api.router.stream import ( - PRIORITY_BLE_PUSH, - PRIORITY_BROADCAST, - PRIORITY_STREAM, - PRIORITY_SUPPLIED_RESULT, - Unsubscribe, -) - -ALL_PATHS = frozenset(FieldPath) - - -class _Clock: - """A hand-advanced clock so freshness and hysteresis are deterministic.""" - - def __init__(self, now: float = 0.0) -> None: - self.now = now - - def __call__(self) -> float: - return self.now - - -class _FakePublisher: - """A publisher a test drives directly, recording activation accounting.""" - - def __init__( - self, - source_id: str, - *, - priority: int = PRIORITY_STREAM, - paths: frozenset[FieldPath] = ALL_PATHS, - fidelity: Fidelity = Fidelity.EXACT, - max_age: float | None = None, - ) -> None: - self.source_id = source_id - self.priority = priority - self.requested: list[frozenset[FieldPath]] = [] - self.released: list[frozenset[FieldPath]] = [] - self.detached = 0 - self._sink: PublisherSink | None = None - self._capabilities = tuple( - Capability( - path=path, - delivery=Delivery.ON_CHANGE, - fidelity=fidelity, - max_age=max_age, - ) - for path in sorted(paths) - ) - - @property - def capabilities(self) -> tuple[Capability, ...]: - return self._capabilities - - def attach(self, sink: PublisherSink) -> Unsubscribe: - self._sink = sink - - def detach() -> None: - self.detached += 1 - self._sink = None - - return detach - - def request(self, paths: frozenset[FieldPath]) -> None: - self.requested.append(paths) - - def release(self, paths: frozenset[FieldPath]) -> None: - self.released.append(paths) - - # -- test drivers ------------------------------------------------------ - - def emit(self, path: FieldPath, value: bool, observed_at: float) -> None: - assert self._sink is not None - self._sink.publish( - Observation( - path=path, - value=value, - observed_at=observed_at, - source_id=self.source_id, - ) - ) - - def health(self, healthy: bool) -> None: - assert self._sink is not None - self._sink.set_health(self.source_id, healthy) - - -class _Recorder: - """An ordered timeline of everything a listener was handed.""" - - def __init__(self) -> None: - self.values: list[Any] = [] - self.availability: list[Any] = [] - - def on_value(self, value: Any) -> None: - self.values.append(value) - - def on_availability(self, available: Any) -> None: - self.availability.append(available) - - -class TestSelectionAndFailover(TestCase): - def test_fails_over_both_directions_without_a_transient_none(self) -> None: - """The bug this router exists to prevent: a source loss blanking a field. - - Both the failover and the failback must carry a real value from the - source that still has one; nothing may reach a listener as ``None``, - and availability must never dip. - """ - clock = _Clock() - router = StreamRouter(clock=clock, failback_delay=5.0) - stream = _FakePublisher("stream", priority=PRIORITY_STREAM) - standby = _FakePublisher("standby", priority=PRIORITY_BROADCAST) - router.attach(stream) - router.attach(standby) - - recorder = _Recorder() - router.listen(FieldPath.LOCKED, recorder.on_value) - router.listen_availability(FieldPath.LOCKED, recorder.on_availability) - self.assertEqual(recorder.availability, [False]) - - stream.health(True) - standby.health(True) - stream.emit(FieldPath.LOCKED, True, observed_at=0.0) - self.assertEqual(recorder.values, [True]) - - # The standby disagrees, but the preferred source is selected, so its - # observation must not reach the listener. - clock.now = 1.0 - standby.emit(FieldPath.LOCKED, False, observed_at=1.0) - self.assertEqual(recorder.values, [True]) - self.assertIs(router.value(FieldPath.LOCKED), True) - - # Failover: the preferred source dies, the standby's real value lands. - clock.now = 2.0 - stream.health(False) - self.assertEqual(recorder.values, [True, False]) - self.assertIs(router.value(FieldPath.LOCKED), False) - self.assertTrue(router.is_available(FieldPath.LOCKED)) - - # A recovered source may not take the field back until it has held - # health for the failback delay. - clock.now = 3.0 - stream.health(True) - clock.now = 3.5 - stream.emit(FieldPath.LOCKED, True, observed_at=3.5) - self.assertEqual(recorder.values, [True, False]) - - # Failback, once that window has elapsed. - clock.now = 10.0 - stream.emit(FieldPath.LOCKED, True, observed_at=10.0) - self.assertEqual(recorder.values, [True, False, True]) - - self.assertNotIn(None, recorder.values) - self.assertTrue(all(isinstance(v, bool) for v in recorder.values)) - # One False at registration, one True on the first value: never dipped. - self.assertEqual(recorder.availability, [False, True]) - - def test_a_frame_racing_a_disconnect_is_not_resurrected_by_reconnect(self) -> None: - """A dead source's reading may not come back as a live one. - - A broadcast already in flight can land after the transport loss is - recorded. Broadcast capabilities are connection-bound, so caching it - would let reconnect present a pre-disconnect value as an observation of - the recovered session, and nothing would ever expire it. - """ - clock = _Clock() - router = StreamRouter(clock=clock, grace=50.0) - ble = _FakePublisher("ble", priority=PRIORITY_BROADCAST, max_age=None) - router.attach(ble) - ble.health(True) - - recorder = _Recorder() - router.listen(FieldPath.LOCKED, recorder.on_value) - router.listen_availability(FieldPath.LOCKED, recorder.on_availability) - ble.emit(FieldPath.LOCKED, False, observed_at=0.0) - self.assertEqual(recorder.values, [False]) - - clock.now = 10.0 - ble.health(False) - - # The race: the frame for a lock that happened as the link dropped - # arrives after the loss was recorded. - clock.now = 11.0 - ble.emit(FieldPath.LOCKED, True, observed_at=11.0) - - # The car is unlocked again while the link is down, so that frame is - # already wrong by the time the link comes back. - clock.now = 20.0 - ble.health(True) - self.assertEqual(recorder.values, [False]) - self.assertIs(router.value(FieldPath.LOCKED), False) - - # Nor may it hold the field open: with nothing observed in the new - # session, last-known availability still expires on the grace window. - clock.now = 70.0 - self.assertFalse(router.is_available(FieldPath.LOCKED)) - self.assertEqual(recorder.values, [False]) - - # The recovered session itself is unaffected. - ble.emit(FieldPath.LOCKED, True, observed_at=70.0) - self.assertEqual(recorder.values, [False, True]) - self.assertTrue(router.is_available(FieldPath.LOCKED)) - - def test_losing_every_source_keeps_last_known_and_emits_nothing(self) -> None: - clock = _Clock() - router = StreamRouter(clock=clock, grace=50.0) - stream = _FakePublisher("stream") - router.attach(stream) - recorder = _Recorder() - router.listen(FieldPath.LOCKED, recorder.on_value) - router.listen_availability(FieldPath.LOCKED, recorder.on_availability) - - stream.health(True) - stream.emit(FieldPath.LOCKED, True, observed_at=0.0) - clock.now = 5.0 - stream.health(False) - - self.assertEqual(recorder.values, [True]) - self.assertIs(router.value(FieldPath.LOCKED), True) - # Within grace the last known value still stands. - self.assertTrue(router.is_available(FieldPath.LOCKED)) - self.assertEqual(recorder.availability, [False, True]) - - clock.now = 60.0 - self.assertFalse(router.is_available(FieldPath.LOCKED)) - self.assertIs(router.value(FieldPath.LOCKED), True) - - def test_exact_translation_outranks_lossy_from_a_better_priority(self) -> None: - clock = _Clock() - router = StreamRouter(clock=clock) - lossy = _FakePublisher( - "lossy", priority=PRIORITY_STREAM, fidelity=Fidelity.LOSSY - ) - exact = _FakePublisher("exact", priority=PRIORITY_BROADCAST) - router.attach(lossy) - router.attach(exact) - lossy.health(True) - exact.health(True) - - recorder = _Recorder() - router.listen(FieldPath.LOCKED, recorder.on_value) - lossy.emit(FieldPath.LOCKED, True, observed_at=0.0) - exact.emit(FieldPath.LOCKED, False, observed_at=0.0) - - self.assertEqual(recorder.values, [True, False]) - # The lossy source cannot take it back. - lossy.emit(FieldPath.LOCKED, True, observed_at=1.0) - self.assertEqual(recorder.values, [True, False]) - - def test_equal_tier_is_sticky(self) -> None: - clock = _Clock() - router = StreamRouter(clock=clock) - first = _FakePublisher("first", priority=PRIORITY_STREAM) - second = _FakePublisher("second", priority=PRIORITY_STREAM) - router.attach(first) - router.attach(second) - first.health(True) - second.health(True) - - recorder = _Recorder() - router.listen(FieldPath.LOCKED, recorder.on_value) - first.emit(FieldPath.LOCKED, True, observed_at=0.0) - second.emit(FieldPath.LOCKED, False, observed_at=1.0) - self.assertEqual(recorder.values, [True]) - - def test_a_delayed_top_source_does_not_mask_an_eligible_middle_one(self) -> None: - """A field must not sit on a worse backend while a better one is ready. - - The top source is inside its failback delay, so it cannot take the field - back yet. That must not stop an untouched middle-priority source from - displacing the worst one it is currently sitting on. - """ - clock = _Clock() - router = StreamRouter(clock=clock, failback_delay=5.0) - stream = _FakePublisher("stream", priority=PRIORITY_STREAM) - ble = _FakePublisher("ble", priority=PRIORITY_BLE_PUSH) - supplied = _FakePublisher("supplied", priority=PRIORITY_SUPPLIED_RESULT) - for publisher in (stream, ble, supplied): - router.attach(publisher) - publisher.health(True) - - recorder = _Recorder() - router.listen(FieldPath.LOCKED, recorder.on_value) - - # The top source holds the field; the middle one has said nothing yet. - stream.emit(FieldPath.LOCKED, True, observed_at=0.0) - supplied.emit(FieldPath.LOCKED, False, observed_at=0.0) - self.assertEqual(recorder.values, [True]) - - # The top source drops, leaving only the worst source with a reading. - clock.now = 10.0 - stream.health(False) - self.assertEqual(recorder.values, [True, False]) - - # It comes back and reports, but is held by the failback delay. - clock.now = 20.0 - stream.health(True) - stream.emit(FieldPath.LOCKED, True, observed_at=20.0) - self.assertEqual(recorder.values, [True, False]) - - # The middle source never lost health, so it is eligible now and must - # take the field off the worst source rather than waiting out a delay - # that belongs to a different source. - ble.emit(FieldPath.LOCKED, True, observed_at=21.0) - self.assertEqual(recorder.values, [True, False, True]) - - # Prove the middle source really holds it: only the selected source can - # move the value, and the held top source still cannot. - stream.emit(FieldPath.LOCKED, False, observed_at=22.0) - self.assertEqual(recorder.values, [True, False, True]) - ble.emit(FieldPath.LOCKED, False, observed_at=23.0) - self.assertEqual(recorder.values, [True, False, True, False]) - - -class TestAvailabilityAnnouncement(TestCase): - def test_a_listener_registered_after_grace_expiry_still_gets_the_recovery( - self, - ) -> None: - """A late listener must not be permanently stuck on unavailable. - - Grace expiry has no event to announce, so a listener registering after - it is told the recomputed truth. If that recomputation did not also - correct what the router believes it has announced, the next real - recovery looks like a repeat and this listener never hears it. - """ - clock = _Clock() - router = StreamRouter(clock=clock, grace=300.0) - source = _FakePublisher("source") - router.attach(source) - source.health(True) - - source.emit(FieldPath.LOCKED, True, observed_at=0.0) - early = _Recorder() - router.listen_availability(FieldPath.LOCKED, early.on_availability) - self.assertEqual(early.availability, [True]) - - # Transport loss: last known data still stands during grace. - source.health(False) - self.assertEqual(early.availability, [True]) - self.assertTrue(router.is_available(FieldPath.LOCKED)) - - # Grace lapses silently; there is no event to fire on. - clock.now = 400.0 - self.assertFalse(router.is_available(FieldPath.LOCKED)) - self.assertEqual(early.availability, [True]) - - late = _Recorder() - router.listen_availability(FieldPath.LOCKED, late.on_availability) - self.assertEqual(late.availability, [False]) - - # A real recovery must reach the listener that was told False. - source.health(True) - source.emit(FieldPath.LOCKED, True, observed_at=400.0) - self.assertEqual(late.availability, [False, True]) - self.assertTrue(router.is_available(FieldPath.LOCKED)) - - def test_a_re_entrant_availability_change_leaves_no_listener_on_the_stale_value( - self, - ) -> None: - """A nested availability change supersedes the one being delivered. - - Mirrors test_a_callback_that_publishes_leaves_no_listener_on_the_old_value - for the sibling availability dispatch loop: without a revision guard - there, the outer dispatch keeps handing listeners it has not yet - reached the availability the router no longer holds. - """ - ticks = count(step=0.001) - router = StreamRouter(clock=lambda: next(ticks), grace=0.0) - source = _FakePublisher("source") - router.attach(source) - - first: list[bool] = [] - second: list[bool] = [] - - def on_first(available: bool) -> None: - first.append(available) - if available is True: - source.health(False) - - router.listen_availability(FieldPath.LOCKED, on_first) - router.listen_availability(FieldPath.LOCKED, second.append) - - source.health(True) - source.emit(FieldPath.LOCKED, True, observed_at=0.0) - - self.assertFalse(router.is_available(FieldPath.LOCKED)) - self.assertEqual(first, [False, True, False]) - self.assertEqual(second, [False, False]) - - -class TestFreshness(TestCase): - def test_connection_bound_source_never_goes_stale_while_healthy(self) -> None: - clock = _Clock() - router = StreamRouter(clock=clock) - stream = _FakePublisher("stream", max_age=None) - router.attach(stream) - stream.health(True) - stream.emit(FieldPath.LOCKED, True, observed_at=0.0) - - clock.now = 100_000.0 - self.assertTrue(router.is_available(FieldPath.LOCKED)) - self.assertIs(router.value(FieldPath.LOCKED), True) - - def test_grace_runs_from_source_loss_not_from_the_last_change(self) -> None: - """The window a grace period exists to provide, for an on-change source. - - A connection-bound source only speaks when the value changes, so a - healthy one is legitimately hours behind. Measuring grace from the - observation would expire the field the instant that source dropped, - which is precisely the case grace is there to cover. - """ - clock = _Clock() - router = StreamRouter(clock=clock, grace=300.0) - ble = _FakePublisher("ble", priority=PRIORITY_BROADCAST, max_age=None) - router.attach(ble) - recorder = _Recorder() - router.listen_availability(FieldPath.LOCKED, recorder.on_availability) - - ble.health(True) - ble.emit(FieldPath.LOCKED, True, observed_at=0.0) - self.assertEqual(recorder.availability, [False, True]) - - # Hours of a locked car: nothing changed, so nothing was broadcast. - clock.now = 20_000.0 - self.assertTrue(router.is_available(FieldPath.LOCKED)) - - ble.health(False) - self.assertEqual(recorder.availability, [False, True]) - - clock.now = 20_299.0 - self.assertTrue(router.is_available(FieldPath.LOCKED)) - self.assertIs(router.value(FieldPath.LOCKED), True) - - clock.now = 20_301.0 - self.assertFalse(router.is_available(FieldPath.LOCKED)) - self.assertIs(router.value(FieldPath.LOCKED), True) - - def test_grace_after_a_silent_expiry_runs_from_that_expiry(self) -> None: - """An age-bounded source expires with no event to fire on. - - Grace is anchored to when candidacy actually ended, so the first caller - to ask cannot restart the window merely by asking late. - """ - clock = _Clock() - router = StreamRouter(clock=clock, grace=100.0) - result = _FakePublisher("result", max_age=60.0) - router.attach(result) - result.health(True) - result.emit(FieldPath.LOCKED, True, observed_at=0.0) - - clock.now = 159.0 - self.assertTrue(router.is_available(FieldPath.LOCKED)) - clock.now = 161.0 - self.assertFalse(router.is_available(FieldPath.LOCKED)) - - def test_grace_restarts_when_a_recovered_source_is_lost_again(self) -> None: - clock = _Clock() - router = StreamRouter(clock=clock, grace=100.0) - source = _FakePublisher("source", max_age=None) - router.attach(source) - source.health(True) - source.emit(FieldPath.LOCKED, True, observed_at=0.0) - - clock.now = 10.0 - source.health(False) - clock.now = 50.0 - source.health(True) - source.emit(FieldPath.LOCKED, True, observed_at=50.0) - clock.now = 60.0 - source.health(False) - - # Anchored to the second loss, not the first. - clock.now = 159.0 - self.assertTrue(router.is_available(FieldPath.LOCKED)) - clock.now = 161.0 - self.assertFalse(router.is_available(FieldPath.LOCKED)) - - def test_expired_observation_is_rejected_and_yields_to_a_fresh_source(self) -> None: - clock = _Clock() - router = StreamRouter(clock=clock) - aged = _FakePublisher("aged", priority=PRIORITY_STREAM, max_age=60.0) - fresh = _FakePublisher("fresh", priority=PRIORITY_BROADCAST, max_age=60.0) - router.attach(aged) - router.attach(fresh) - aged.health(True) - fresh.health(True) - - recorder = _Recorder() - router.listen(FieldPath.LOCKED, recorder.on_value) - aged.emit(FieldPath.LOCKED, True, observed_at=0.0) - self.assertEqual(recorder.values, [True]) - - # Past its max age the preferred source is no longer a candidate. - clock.now = 100.0 - fresh.emit(FieldPath.LOCKED, False, observed_at=100.0) - self.assertEqual(recorder.values, [True, False]) - - def test_out_of_order_observation_is_ignored(self) -> None: - clock = _Clock() - router = StreamRouter(clock=clock) - stream = _FakePublisher("stream") - router.attach(stream) - stream.health(True) - - recorder = _Recorder() - router.listen(FieldPath.LOCKED, recorder.on_value) - stream.emit(FieldPath.LOCKED, True, observed_at=10.0) - stream.emit(FieldPath.LOCKED, False, observed_at=5.0) - self.assertEqual(recorder.values, [True]) - - -class TestActivation(TestCase): - def test_first_listener_activates_and_last_releases_exactly_once(self) -> None: - router = StreamRouter(clock=_Clock()) - stream = _FakePublisher("stream") - router.attach(stream) - self.assertEqual(stream.requested, []) - - first = router.listen(FieldPath.LOCKED, lambda _: None) - self.assertEqual(stream.requested, [frozenset({FieldPath.LOCKED})]) - - second = router.listen(FieldPath.LOCKED, lambda _: None) - self.assertEqual(len(stream.requested), 1) - - first() - self.assertEqual(stream.released, []) - - second() - self.assertEqual(stream.released, [frozenset({FieldPath.LOCKED})]) - - # Unsubscribing again must not release a second time. - second() - first() - self.assertEqual(len(stream.released), 1) - - def test_activation_is_per_path(self) -> None: - router = StreamRouter(clock=_Clock()) - stream = _FakePublisher("stream") - router.attach(stream) - router.listen(FieldPath.LOCKED, lambda _: None) - router.listen(FieldPath.CHARGE_PORT_DOOR_OPEN, lambda _: None) - self.assertEqual( - stream.requested, - [ - frozenset({FieldPath.LOCKED}), - frozenset({FieldPath.CHARGE_PORT_DOOR_OPEN}), - ], - ) - - def test_publisher_attached_later_is_asked_for_already_active_paths(self) -> None: - router = StreamRouter(clock=_Clock()) - router.listen(FieldPath.LOCKED, lambda _: None) - stream = _FakePublisher("stream") - detach = router.attach(stream) - self.assertEqual(stream.requested, [frozenset({FieldPath.LOCKED})]) - - detach() - self.assertEqual(stream.released, [frozenset({FieldPath.LOCKED})]) - self.assertEqual(stream.detached, 1) - - def test_detaching_the_selected_source_reselects_without_emitting_none( - self, - ) -> None: - clock = _Clock() - router = StreamRouter(clock=clock) - stream = _FakePublisher("stream", priority=PRIORITY_STREAM) - standby = _FakePublisher("standby", priority=PRIORITY_BROADCAST) - detach_stream = router.attach(stream) - router.attach(standby) - stream.health(True) - standby.health(True) - - recorder = _Recorder() - router.listen(FieldPath.LOCKED, recorder.on_value) - stream.emit(FieldPath.LOCKED, True, observed_at=0.0) - standby.emit(FieldPath.LOCKED, False, observed_at=0.0) - - detach_stream() - self.assertEqual(recorder.values, [True, False]) - self.assertNotIn(None, recorder.values) - - def test_attaching_a_duplicate_source_id_is_rejected(self) -> None: - router = StreamRouter(clock=_Clock()) - router.attach(_FakePublisher("stream")) - with self.assertRaises(ValueError): - router.attach(_FakePublisher("stream")) - - def test_a_detached_publisher_can_no_longer_feed_the_router(self) -> None: - clock = _Clock() - router = StreamRouter(clock=clock) - stream = _FakePublisher("stream") - sink_holder: list[PublisherSink] = [] - - original_attach = stream.attach - - def capture(sink: PublisherSink) -> Unsubscribe: - sink_holder.append(sink) - return original_attach(sink) - - stream.attach = capture # type: ignore[method-assign] - detach = router.attach(stream) - stream.health(True) - recorder = _Recorder() - router.listen(FieldPath.LOCKED, recorder.on_value) - detach() - - sink_holder[0].publish( - Observation( - path=FieldPath.LOCKED, - value=True, - observed_at=0.0, - source_id="stream", - ) - ) - self.assertEqual(recorder.values, []) - - def test_releasing_one_availability_registration_twice_keeps_the_other( - self, - ) -> None: - """Two registrations of one callback are two subscriptions, not one.""" - router = StreamRouter() - source = _FakePublisher("source") - router.attach(source) - recorder = _Recorder() - first = router.listen_availability(FieldPath.LOCKED, recorder.on_availability) - router.listen_availability(FieldPath.LOCKED, recorder.on_availability) - self.assertEqual(recorder.availability, [False, False]) - - first() - first() - - source.health(True) - source.emit(FieldPath.LOCKED, True, observed_at=0.0) - self.assertEqual(recorder.availability, [False, False, True]) - - def test_a_callback_that_publishes_leaves_no_listener_on_the_old_value( - self, - ) -> None: - """A nested update supersedes the one being delivered. - - Without that, the outer dispatch carries on handing the superseded - value to the listeners it had not reached yet, and they are left - holding a value the router itself no longer holds. - """ - router = StreamRouter() - source = _FakePublisher("source") - router.attach(source) - source.health(True) - - first: list[bool] = [] - second: list[bool] = [] - - def on_first(value: bool) -> None: - first.append(value) - if value is True: - source.emit(FieldPath.LOCKED, False, observed_at=2.0) - - router.listen(FieldPath.LOCKED, on_first) - router.listen(FieldPath.LOCKED, second.append) - - source.emit(FieldPath.LOCKED, True, observed_at=1.0) - - self.assertIs(router.value(FieldPath.LOCKED), False) - self.assertEqual(first, [True, False]) - self.assertEqual(second, [False]) - - def test_a_listener_exception_does_not_stop_later_listeners(self) -> None: - router = StreamRouter(clock=_Clock()) - stream = _FakePublisher("stream") - router.attach(stream) - stream.health(True) - seen: list[bool] = [] - - def boom(_: bool) -> None: - raise RuntimeError("listener blew up") - - router.listen(FieldPath.LOCKED, boom) - router.listen(FieldPath.LOCKED, seen.append) - with self.assertLogs("tesla_fleet_api", level="ERROR"): - stream.emit(FieldPath.LOCKED, True, observed_at=0.0) - self.assertEqual(seen, [True]) - - -class TestDemand(TestCase): - def test_demand_reports_initial_state_then_only_aggregate_edges(self) -> None: - router = StreamRouter(clock=_Clock()) - seen: list[bool] = [] - router.listen_demand(ALL_PATHS, seen.append) - self.assertEqual(seen, [False]) - - locked = router.listen(FieldPath.LOCKED, lambda _: None) - self.assertEqual(seen, [False, True]) - - # A second path in the same set is not a new edge. - port = router.listen(FieldPath.CHARGE_PORT_DOOR_OPEN, lambda _: None) - trunk = router.listen(FieldPath.DOOR_STATE_TRUNK_FRONT, lambda _: None) - self.assertEqual(seen, [False, True]) - - locked() - port() - self.assertEqual(seen, [False, True]) - - # Only when every path in the set is back to zero. - trunk() - self.assertEqual(seen, [False, True, False]) - - def test_releasing_one_demand_registration_twice_keeps_the_other(self) -> None: - """Two registrations of one callback are two subscriptions, not one. - - _DemandObserver is a plain dataclass compared by field values, so two - registrations with the same paths and callback are equal; unsubscribe - must not let a repeated release of one drop the other via that - equality match. - """ - router = StreamRouter(clock=_Clock()) - seen: list[bool] = [] - first = router.listen_demand(ALL_PATHS, seen.append) - router.listen_demand(ALL_PATHS, seen.append) - self.assertEqual(seen, [False, False]) - - first() - first() - - router.listen(FieldPath.LOCKED, lambda _: None) - self.assertEqual(seen, [False, False, True]) - - def test_demand_starts_true_when_a_listener_already_exists(self) -> None: - router = StreamRouter(clock=_Clock()) - router.listen(FieldPath.LOCKED, lambda _: None) - seen: list[bool] = [] - router.listen_demand(ALL_PATHS, seen.append) - self.assertEqual(seen, [True]) - - def test_demand_unsubscribe_removes_only_that_observer(self) -> None: - router = StreamRouter(clock=_Clock()) - first: list[bool] = [] - second: list[bool] = [] - unsubscribe = router.listen_demand(ALL_PATHS, first.append) - router.listen_demand(ALL_PATHS, second.append) - unsubscribe() - router.listen(FieldPath.LOCKED, lambda _: None) - self.assertEqual(first, [False]) - self.assertEqual(second, [False, True]) - - -class TestRouterCannotOriginateWork(TestCase): - """The load-bearing invariant: the router is structurally unable to poll. - - Asserted against the module's own syntax tree rather than its behaviour, - because a request path that exists but is merely unused would still be a - request path. - """ - - @classmethod - def setUpClass(cls) -> None: - import tesla_fleet_api.router.stream as module - - source = Path(module.__file__).read_text(encoding="utf-8") - cls.tree = ast.parse(source) - - def test_the_module_is_entirely_synchronous(self) -> None: - for node in ast.walk(self.tree): - self.assertNotIsInstance(node, ast.AsyncFunctionDef) - self.assertNotIsInstance(node, ast.Await) - self.assertNotIsInstance(node, ast.AsyncFor) - self.assertNotIsInstance(node, ast.AsyncWith) - - def test_the_module_imports_no_transport_or_scheduling_machinery(self) -> None: - forbidden = { - "asyncio", - "aiohttp", - "aiofiles", - "bleak", - "threading", - "sched", - "requests", - "urllib", - "socket", - } - imported: set[str] = set() - for node in ast.walk(self.tree): - if isinstance(node, ast.Import): - imported.update(alias.name.split(".")[0] for alias in node.names) - elif isinstance(node, ast.ImportFrom) and node.module: - imported.add(node.module.split(".")[0]) - self.assertEqual(imported & forbidden, set()) - - def test_the_module_calls_nothing_that_could_originate_a_request(self) -> None: - forbidden = { - "sleep", - "create_task", - "ensure_future", - "run_coroutine_threadsafe", - "call_later", - "call_soon", - "vehicle_data", - "charge_state", - "vehicle_state", - "connect", - "connect_if_needed", - "wake_up", - "_send", - "_request", - "_getVehicleSecurity", - "_getInfotainment", - "Thread", - "Timer", - } - called: set[str] = set() - for node in ast.walk(self.tree): - if not isinstance(node, ast.Call): - continue - func = node.func - if isinstance(func, ast.Name): - called.add(func.id) - elif isinstance(func, ast.Attribute): - called.add(func.attr) - self.assertEqual(called & forbidden, set())