Skip to content

Commit a51757b

Browse files
authored
feat(ble): glue 11 more BLE broadcast fields into BleBroadcastStreamGlue (#141)
Extends BleBroadcastStreamGlue from 3 to 11 wired fields: rear trunk, all 4 side doors, gear, tonneau position, and tonneau open percent - every BLE-derivable field the current teslemetry-stream 0.13.0 API can carry. Gear and tonneau position translate through new GEAR_STATES/ TONNEAU_POSITION_STATES maps into the <Prefix><Option>-shaped wire strings ("ShiftStateP", "TonneauPositionStateClosed") that package's own TeslemetryEnum-based listeners expect, verified against its actual installed source rather than guessed. Vehicle sleep status, user presence, and UI desire stay unwired: ingest() unconditionally nests its payload under the data (signal-topic) key of a wire event and has no way to produce a state-topic-shaped event for sleep status, and user presence/UI desire have no Signal entry or listener in teslemetry-stream 0.13.0 at all. Claude-Session: https://claude.ai/code/session_01B6Jgdgk52WL4KWP57hYyyF
1 parent 59f213d commit a51757b

4 files changed

Lines changed: 324 additions & 29 deletions

File tree

‎AGENTS.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -134,9 +134,9 @@ Keep the `tesla-protocol` floor at `>=1.4.0`; earlier releases have generated `.
134134
- **BLE mutating-command timeout is inconclusive — never assume "the write didn't land"**: mutating VCSEC/RKE actions can raise `BluetoothTimeout` yet have physically executed, most likely because the vehicle doesn't reliably return an observable ack within the timeout. Treat `BluetoothUnconfirmedCommand` (a `BluetoothTimeout` subclass) from any mutating BLE command as **inconclusive, not failure**: snapshot state before acting, then verify the outcome with a follow-up state read whenever a mutation times out. Never blind-retry a non-idempotent command (toggles like `media_toggle_playback`, volume steps, schedule add/remove) on timeout alone — see the retry double-execution entry below. VCSEC actuations use the shorter `_actuation_timeout` when their terminal ack is lost.
135135
- **The BLE mutating-command confirmation ladder is one `confirmation` enum + one `raise_unconfirmed` bool**: `VehicleBluetooth.confirmation` (`"optimistic" | "ack" | "verify"`, default `"ack"`; threaded through `Vehicles`/`VehiclesBluetooth.create*`) picks how many of write → ack-or-broadcast wait → state-read confirmation run; `raise_unconfirmed` (default `False`) picks what happens when the ladder still can't tell. `"optimistic"` short-circuits `_sendVehicleSecurity`/`_sendInfotainment` (`bluetooth.py`) to `_send_optimistic()`, which signs and writes but never waits for any reply — a provably pre-submission write failure still raises `BluetoothTransportError` unconditionally, but a submitted-then-ambiguous write follows `raise_unconfirmed` like every other rung. `"verify"` adds a post-timeout state-read rung: on an unresolved ack/broadcast wait, `_resolve_timeout()` reads the mapped prover state (`_vcsec_verify_plan`/`_INFOTAINMENT_VERIFY_PLANS` in `bluetooth.py`; only clearly-derivable absolute commands are covered — lock/unlock, `set_charge_limit`, `set_charging_amps`, `adjust_volume` absolute, `set_temps`, `auto_conditioning_start/stop`) and returns success on a match, raises `BluetoothCommandFailed` on a proven mismatch, or returns `None` (still unresolved) if the read itself couldn't complete — `None` falls through to `raise_unconfirmed`. Commands with no plan (true toggles, relative steps, ack-only actions) always fall through regardless of `confirmation`. The legacy `optimistic`/`verify_commands` boolean surface is deprecated: both warn (`DeprecationWarning`) and map onto `confirmation` (a positional bool in the `confirmation` slot is treated as old `verify_commands`; `optimistic=True` wins if both are set), and remain as read-only properties (`confirmation == "optimistic"`/`"verify"`) for existing readers. See `docs/bluetooth_vehicles.md` for the user-facing table and defaults.
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`.
137-
- **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`.
137+
- **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`, `listen_gear`, `listen_ui_desire`, 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 five scalar enum fields (`vehicleLockState`/`vehicleSleepStatus`/`userPresence`/`gear`/`uiDesire`) 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`.
139+
- **`BleBroadcastStreamGlue` (`tesla_fleet_api/tesla/vehicle/stream_glue.py`) never imports `teslemetry_stream`**: it wires 11 of the 14 BLE-derivable broadcast listeners in `broadcast.py` (lock state, charge port, all 6 `DoorState` leaves, gear, tonneau position, tonneau open percent) 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 for the boolean fields (module-level, not underscore-private, precisely so this cross-module import is legal under pyright strict) and adds its own `GEAR_STATES`/`TONNEAU_POSITION_STATES` maps for the two fields teslemetry-stream carries as `<Prefix><Option>`-shaped wire strings rather than booleans (e.g. `"ShiftStateP"`, `"TonneauPositionStateClosed"` — verified against that package's own `TeslemetryEnum`-based listeners, not guessed). `TONNEAU_POSITION_STATES` only maps CLOSED/OPEN/AJAR; OPENING/CLOSING joins UNKNOWN/FAILED_UNLATCH as unmapped since `TonneauPosition` has no in-transit state to translate to. Vehicle sleep status, user presence, and UI desire are the 3 fields left unwired: sleep status is blocked because `ingest()` unconditionally nests its payload under the `data` (signal-topic) key of a wire event and has no way to produce a `state`-topic-shaped event instead; user presence and UI desire have no `Signal` entry or `listen_*` method in teslemetry-stream 0.13.0 at all. It is push-only — no `request()`/`release()` demand gating, since VCSEC broadcasts regardless of listeners. `stop()` unsubscribes every listener and is idempotent. See `docs/bluetooth_vehicles.md#feeding-broadcasts-into-a-teslemetry-stream` and `tests/test_ble_stream_glue.py`.
140140
- **`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.
141141
- **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`.
142142
- **`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: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -580,11 +580,13 @@ message routing; `KeyboardInterrupt` and `SystemExit` still propagate.
580580

581581
### Feeding broadcasts into a Teslemetry stream
582582

583-
`BleBroadcastStreamGlue` wires the lock-state, charge-port, and front-trunk
584-
broadcast listeners above into any object with a
583+
`BleBroadcastStreamGlue` wires every BLE-derivable field the current
584+
teslemetry-stream API can carry into any object with a
585585
[python-teslemetry-stream](https://github.com/Teslemetry/python-teslemetry-stream)-shaped
586586
`ingest(data, metadata=None)` method, translating each broadcast into the
587-
same stream-shaped payload a native SSE event produces:
587+
same stream-shaped payload a native SSE event produces: lock state, charge
588+
port, all 6 `DoorState` leaves (front/rear driver and passenger doors, front
589+
and rear trunk), gear, tonneau position, and tonneau open percent.
588590

589591
```python
590592
from tesla_fleet_api import BleBroadcastStreamGlue
@@ -602,6 +604,19 @@ equivalent) in a consumer that needs cleanup. There is no source ranking
602604
between a BLE broadcast and a native stream event reaching the same sink -
603605
`ingest()`'s own dispatch already guarantees that.
604606

607+
Three BLE-derivable fields are not wired, each blocked on a teslemetry-stream
608+
addition rather than worked around:
609+
610+
- **Vehicle sleep status** streams on the separate `state` topic
611+
(`online`/`offline`/`asleep`), but `ingest()` unconditionally nests its
612+
payload under the `data` key of a wire event - the shape a *signal* update
613+
carries. Carrying it would need an `ingest()` addition (e.g. a `topic`
614+
argument, or a separate `ingest_state()`) that can produce a `state`-shaped
615+
event instead.
616+
- **User presence** and **UI desire** have no `Signal` entry or `listen_*`
617+
method in teslemetry-stream 0.13.0, so no wire field name exists yet to
618+
target - each needs a new `Signal` value and listener added there first.
619+
605620
### Passive listening without a private key
606621

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

0 commit comments

Comments
 (0)