Skip to content

Commit fc7ecbb

Browse files
authored
feat(ble): add BleBroadcastStreamGlue for teslemetry-stream ingest() (#139)
* feat(ble): add BleBroadcastStreamGlue for teslemetry-stream ingest() Feeds VCSEC broadcast listeners (lock state, charge port, front trunk) into any duck-typed sink shaped like python-teslemetry-stream's ingest(data, metadata) - no import of teslemetry_stream, same structural-Protocol pattern EnergySiteRouter uses for aiopowerwall. Promotes funnel.py's LOCK_STATES/CLOSURE_STATES decode maps to module-level so the glue can reuse them without a pyright reportPrivateUsage violation. * no-mistakes(review): Replace AST/substring test-quality checks with behavioral tomllib assertions * Apply suggestions from code review Co-authored-by: Brett Adams <Bre77@users.noreply.github.com>
1 parent e49f1e0 commit fc7ecbb

6 files changed

Lines changed: 405 additions & 5 deletions

File tree

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,7 @@ Keep the `tesla-protocol` floor at `>=0.5.0`; earlier releases have generated `.
136136
- **Broadcast-as-confirmation races the ack wait for lock/unlock**: the vehicle keeps emitting unsolicited VCSEC status broadcasts on the same notification subscription even when it emits no addressed ack for a lock/unlock actuation. `_send`'s `confirm_broadcast` param (threaded through `Commands._command`/`_sendVehicleSecurity`, ignored by the Fleet-signed transport) arms a per-domain watcher in `_on_message` (`_broadcast_watchers`, `bluetooth.py`) that decodes broadcast frames via `_decode_vcsec_status` and races them against the addressed-reply wait in `_await_response_or_broadcast`; first to satisfy the plan's predicate wins, and only the addressed-reply path can raise a car-side rejection. A mismatching broadcast doesn't fail fast (it's appended to `mismatches`) since a later broadcast in the same window could still confirm success — but if the whole window elapses with a mismatch as the last word and nothing else confirming, `_await_response_or_broadcast` raises `BluetoothCommandFailed` instead of the ambiguous timeout. This reuses the same `_vcsec_verify_plan` predicate as the `"verify"` rung above, applied to a broadcast's decoded `VehicleStatus`; it currently covers only lock/unlock, the one VCSEC actuation with an observed status broadcast. See `tests/test_ble_broadcast_confirmation.py`.
137137
- **Persistent broadcast listeners (`tesla_fleet_api/tesla/vehicle/broadcast.py`)**: `VehicleBluetooth` fans the same VCSEC status broadcasts out to long-lived per-field listeners, dispatched from the same `_on_message`. Each modeled `VehicleStatus` leaf field gets a typed `listen_<field>` method (`listen_vehicle_lock_state`, `listen_vehicle_sleep_status`, `listen_user_presence`, the 8 door/trunk/charge-port/tonneau closure listeners, `listen_tonneau_percent_open`); anything not decoded into `VehicleStatus` is covered by the untyped `listen_broadcast(domain, callback)`. Closure/tonneau-percent listeners gate on `HasField` since those are submessages with real proto3 presence tracking; the three scalar enum fields (`vehicleLockState`/`vehicleSleepStatus`/`userPresence`) have none, so they fire on every status broadcast rather than only on change. Each `listen_*` returns an `unsubscribe()` closure; registries live for the `VehicleBluetooth` instance's lifetime and are unaffected by reconnects, matching `_queues`. Listener callback exceptions are logged and isolated from later listeners/message routing, except `KeyboardInterrupt`/`SystemExit`. See `docs/bluetooth_vehicles.md` and `tests/test_ble_broadcast_listeners.py`.
138138
- **Connection-status listener**: `VehicleBluetooth.listen_connection_status()` reports BLE session transitions, including unexpected transport loss; the authoritative contract is in `docs/bluetooth_vehicles.md#connection-status-events`, regression coverage in `tests/test_ble_connection_status.py`.
139+
- **`BleBroadcastStreamGlue` (`tesla_fleet_api/tesla/vehicle/stream_glue.py`) never imports `teslemetry_stream`**: it wires the three broadcast listeners above (`listen_vehicle_lock_state`/`listen_charge_port`/`listen_front_trunk`) to `sink.ingest(data, metadata)` calls against a local structural `StreamSink` `Protocol` ("has `ingest(data, metadata=None)`"), the same duck-typed-dependency pattern `EnergySiteRouter` uses for aiopowerwall — `python-teslemetry-stream`'s `TeslemetryStream(Vehicle).ingest()` satisfies it with no coupling either direction and no dependency added. It reuses `funnel.py`'s `LOCK_STATES`/`CLOSURE_STATES` decode maps (module-level, not underscore-private, precisely so this cross-module import is legal under pyright strict) and is push-only — no `request()`/`release()` demand gating, since VCSEC broadcasts regardless of listeners. `stop()` unsubscribes all three and is idempotent. See `docs/bluetooth_vehicles.md#feeding-broadcasts-into-a-teslemetry-stream` and `tests/test_ble_stream_glue.py`.
139140
- **`BluetoothUnconfirmedCommand` vs `BluetoothCommandFailed` (`exceptions.py`), and how `Router` treats each**: `_sendVehicleSecurity`/`_sendInfotainment` (`bluetooth.py`) wrap a caught `BluetoothTimeout` into `BluetoothUnconfirmedCommand` when the ladder is genuinely unresolved — either the write succeeded but the ack/broadcast was lost, or the write entered backend I/O and failed with delivery unprovable, so the vehicle may have executed the command. With default `raise_unconfirmed=False` that unresolved outcome returns best-effort success; with `raise_unconfirmed=True` it reaches the caller. `BluetoothCommandFailed` is the other, distinct outcome: a state check (the `"verify"` rung's read, or a mismatching broadcast still standing at window-end) actively *proved* the command did not apply — it does **not** subclass `BluetoothTimeout`/`BluetoothUnconfirmedCommand`. `Router._dispatch` (`router/base.py`) special-cases only `BluetoothUnconfirmedCommand` to skip its normal per-command failover and re-raise immediately, since replaying an already-possibly-executed command risks double-execution; `BluetoothCommandFailed` carries no such risk and falls through `Router`'s ordinary error handling like any other error. A plain read (`_getVehicleSecurity`/`_getInfotainment`) still raises unadorned `BluetoothTimeout` on the same kind of wait timeout, since a read has no side effect to be unconfirmed about.
140141
- **Write-delivery certainty splits `BluetoothTransportError` from `BluetoothTimeout` at the GATT write in `_send`**: `write_gatt_char` failures are not uniformly `BluetoothTransportError`. `BleakCharacteristicNotFoundError` (bleak resolves `WRITE_UUID` synchronously, before any backend I/O) is the only case provably pre-submission, so it alone stays `BluetoothTransportError` and is safe for `Router` to retry. Every other `BleakError`/`TimeoutError` from that call happens inside backend I/O (D-Bus/CoreBluetooth/an ESPHome proxy) where delivery can't be proven either way, so `_send` instead races any already-armed broadcast watcher for the rest of the window and, failing that, raises plain `BluetoothTimeout` — which, because `BluetoothUnconfirmedCommand` subclasses `BluetoothTimeout`, lands in the same ladder as a lost post-write ack. `_send_optimistic` gets the equivalent treatment explicitly since it bypasses that ladder. A read is unaffected since it has no double-execution risk. Tests: `tests/test_ble_send_transport.py`, `tests/test_ble_broadcast_confirmation.py`, `tests/test_ble_write_timeout_router.py`.
141142
- **`wake_up()` is best-effort; confirm readiness with an INFO read**: `wake_up()` is a VCSEC actuation, so a terminal ack returns promptly when observed, but an unresolved wake remains only an inconclusive wake signal, not command failure (`BluetoothUnconfirmedCommand` when `raise_unconfirmed=True`, best-effort success by default). Confirm readiness by retrying a cheap INFO read instead (see the boot-delay gotcha above). Hold one connection across a whole batch of related commands rather than reconnecting between each.

docs/bluetooth_vehicles.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -574,6 +574,30 @@ the `unsubscribe()` closure returned at registration.
574574
Callback exceptions are logged and do not stop later listeners or normal
575575
message routing; `KeyboardInterrupt` and `SystemExit` still propagate.
576576

577+
### Feeding broadcasts into a Teslemetry stream
578+
579+
`BleBroadcastStreamGlue` wires the lock-state, charge-port, and front-trunk
580+
broadcast listeners above into any object with a
581+
[python-teslemetry-stream](https://github.com/Teslemetry/python-teslemetry-stream)-shaped
582+
`ingest(data, metadata=None)` method, translating each broadcast into the
583+
same stream-shaped payload a native SSE event produces:
584+
585+
```python
586+
from tesla_fleet_api import BleBroadcastStreamGlue
587+
588+
glue = BleBroadcastStreamGlue(vehicle, stream_vehicle) # stream_vehicle: TeslemetryStreamVehicle
589+
...
590+
glue.stop()
591+
```
592+
593+
It does not import `teslemetry_stream` - the sink only needs to satisfy the
594+
structural `ingest` contract, so this works with any object shaped like one,
595+
including a test double. `stop()` unsubscribes every listener and is safe to
596+
call more than once; register it with `entry.async_on_unload(glue.stop)` (or
597+
equivalent) in a consumer that needs cleanup. There is no source ranking
598+
between a BLE broadcast and a native stream event reaching the same sink -
599+
`ingest()`'s own dispatch already guarantees that.
600+
577601
### Passive listening without a private key
578602

579603
A vehicle that only decodes broadcasts and never sends a command has no

tesla_fleet_api/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
from tesla_fleet_api.tesla.bluetooth import TeslaBluetooth
2525
from tesla_fleet_api.tesla.fleet import TeslaFleetApi
2626
from tesla_fleet_api.tesla.oauth import TeslaFleetOAuth
27+
from tesla_fleet_api.tesla.vehicle.stream_glue import BleBroadcastStreamGlue, StreamSink
2728
from tesla_fleet_api.teslemetry.teslemetry import (
2829
Teslemetry,
2930
TeslemetryClientRegistration,
@@ -34,12 +35,14 @@
3435

3536
__all__ = [
3637
"BleBroadcastPublisher",
38+
"BleBroadcastStreamGlue",
3739
"FieldPath",
3840
"Observation",
3941
"ObservationFunnel",
4042
"ObservationSink",
4143
"Publisher",
4244
"Region",
45+
"StreamSink",
4346
"TariffPeriod",
4447
"TariffRate",
4548
"TariffResolution",

tesla_fleet_api/funnel.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -268,7 +268,7 @@ def _notify_demand(self) -> None:
268268
#
269269
# Only a locked state reads as locked; any unlocked state, however partial
270270
# or selective, reads as unlocked.
271-
_LOCK_STATES: Mapping[int, bool] = {
271+
LOCK_STATES: Mapping[int, bool] = {
272272
VehicleLockState_E.VEHICLELOCKSTATE_LOCKED: True,
273273
VehicleLockState_E.VEHICLELOCKSTATE_UNLOCKED: False,
274274
VehicleLockState_E.VEHICLELOCKSTATE_INTERNAL_LOCKED: True,
@@ -278,7 +278,7 @@ def _notify_demand(self) -> None:
278278
# UNKNOWN and FAILED_UNLATCH are unmapped: reducing either to one boolean is
279279
# unvalidated against live frames, and emitting nothing keeps the last
280280
# confirmed value instead of guessing.
281-
_CLOSURE_STATES: Mapping[int, bool] = {
281+
CLOSURE_STATES: Mapping[int, bool] = {
282282
ClosureState_E.CLOSURESTATE_CLOSED: False,
283283
ClosureState_E.CLOSURESTATE_OPEN: True,
284284
ClosureState_E.CLOSURESTATE_AJAR: True,
@@ -287,9 +287,9 @@ def _notify_demand(self) -> None:
287287
}
288288

289289
_BROADCAST_MAPS: Mapping[FieldPath, Mapping[int, bool]] = {
290-
FieldPath.LOCKED: _LOCK_STATES,
291-
FieldPath.CHARGE_PORT_DOOR_OPEN: _CLOSURE_STATES,
292-
FieldPath.DOOR_STATE_TRUNK_FRONT: _CLOSURE_STATES,
290+
FieldPath.LOCKED: LOCK_STATES,
291+
FieldPath.CHARGE_PORT_DOOR_OPEN: CLOSURE_STATES,
292+
FieldPath.DOOR_STATE_TRUNK_FRONT: CLOSURE_STATES,
293293
}
294294

295295
_BROADCAST_PATHS = frozenset(_BROADCAST_MAPS)
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
"""Feeds BLE broadcasts into a stream-shaped ``ingest()`` sink.
2+
3+
``BleBroadcastStreamGlue`` wires the typed listeners in ``broadcast.py`` to a
4+
duck-typed sink (structurally: python-teslemetry-stream's
5+
``TeslemetryStream(Vehicle).ingest``) so a BLE observation reaches the same
6+
listeners a native stream event does, translated into the identical
7+
stream-shaped payload. This module never imports ``teslemetry_stream`` - the
8+
sink contract below is a structural :class:`typing.Protocol`, matching the
9+
duck-typed ``EnergySite`` composition :class:`~tesla_fleet_api.router.EnergySiteRouter`
10+
already uses for aiopowerwall.
11+
"""
12+
13+
from __future__ import annotations
14+
15+
from typing import TYPE_CHECKING, Any, Mapping, Protocol
16+
17+
from tesla_fleet_api.funnel import CLOSURE_STATES, LOCK_STATES
18+
from tesla_fleet_api.tesla.vehicle.broadcast import Unsubscribe
19+
from tesla_protocol.command.vcsec_pb2 import ClosureState_E, VehicleLockState_E
20+
21+
if TYPE_CHECKING:
22+
from tesla_fleet_api.tesla.vehicle.bluetooth import VehicleBluetooth
23+
24+
25+
class StreamSink(Protocol):
26+
"""Structurally identical to ``TeslemetryStream(Vehicle).ingest`` - not imported."""
27+
28+
def ingest(
29+
self, data: Mapping[str, Any], metadata: Mapping[str, Any] | None = None
30+
) -> Mapping[str, Any]: ...
31+
32+
33+
class BleBroadcastStreamGlue:
34+
"""Translates a :class:`VehicleBluetooth`'s broadcasts into ``sink.ingest()`` calls.
35+
36+
Reuses the same lock/closure decode maps
37+
:class:`~tesla_fleet_api.funnel.BleBroadcastPublisher` does, so the "any
38+
unlocked state reads as unlocked" ruling and the deliberate
39+
UNKNOWN/FAILED_UNLATCH omission carry over unchanged. There is no source
40+
ranking here or in ``ingest()`` itself - every call reaches listeners in
41+
arrival order alongside whatever the sink's own stream connection reports.
42+
"""
43+
44+
def __init__(self, vehicle: "VehicleBluetooth[Any]", sink: StreamSink) -> None:
45+
self._sink = sink
46+
self._unsubs: list[Unsubscribe] = [
47+
vehicle.listen_vehicle_lock_state(self._on_lock_state),
48+
vehicle.listen_charge_port(self._on_charge_port),
49+
vehicle.listen_front_trunk(self._on_front_trunk),
50+
]
51+
52+
def stop(self) -> None:
53+
"""Unsubscribe from every broadcast listener; safe to call more than once."""
54+
for unsub in self._unsubs:
55+
unsub()
56+
self._unsubs = []
57+
58+
def _on_lock_state(self, raw: int) -> None:
59+
if raw not in LOCK_STATES:
60+
return
61+
self._sink.ingest(
62+
{"Locked": LOCK_STATES[raw]},
63+
{"source": "bluetooth", "raw": VehicleLockState_E.Name(raw)},
64+
)
65+
66+
def _on_charge_port(self, raw: int) -> None:
67+
if raw not in CLOSURE_STATES:
68+
return
69+
self._sink.ingest(
70+
{"ChargePortDoorOpen": CLOSURE_STATES[raw]},
71+
{"source": "bluetooth", "raw": ClosureState_E.Name(raw)},
72+
)
73+
74+
def _on_front_trunk(self, raw: int) -> None:
75+
if raw not in CLOSURE_STATES:
76+
return
77+
self._sink.ingest(
78+
{"DoorState": {"TrunkFront": CLOSURE_STATES[raw]}},
79+
{"source": "bluetooth", "raw": ClosureState_E.Name(raw)},
80+
)

0 commit comments

Comments
 (0)