You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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
Copy file name to clipboardExpand all lines: AGENTS.md
+2-2Lines changed: 2 additions & 2 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -134,9 +134,9 @@ Keep the `tesla-protocol` floor at `>=1.4.0`; earlier releases have generated `.
134
134
-**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.
135
135
- **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.
136
136
- **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`.
138
138
-**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`.
140
140
- **`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.
141
141
- **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`.
142
142
-**`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.
0 commit comments