Use VCSEC broadcasts for Teslemetry vehicle binary sensors - #977
Conversation
Reroute the awake/connectivity, user-presence, and four door binary sensors to unsolicited VCSEC broadcasts when a vehicle is BLE paired. A new BLE data manager holds the vehicle's direct Bluetooth client, tracks connection generations, and watches the link for an unexpected drop. A rerouted sensor is strictly local: its value is valid only in the connection generation it was received in, it goes unavailable on link loss, and it never falls back to the stream or cloud. Unknown closure and sleep/presence enums are unavailable rather than false.
The generated vcsec_pb2 module builds its enum wrappers dynamically, so pylint cannot see the names statically even though the shipped stub and mypy resolve them.
The BLE entity base now stores the command router so rerouted cover and lock entities keep sending open/close/lock over it while reading locally.
|
|
||
| return VehicleRouter(bluetooth_vehicle, cloud_vehicle, health=_in_range) | ||
| router = VehicleRouter(bluetooth_vehicle, cloud_vehicle, health=_in_range) | ||
| return router, bluetooth_vehicle |
There was a problem hiding this comment.
Why do we do this when we could just use the property that router provides for the bluetooth_vehicle. I believe it's fully typed too.
There was a problem hiding this comment.
Done — dropped the tuple return; the manager now takes the direct client from the router's typed primary property (vehicle_api.primary), and mypy is happy with it. Pushed in b17bfea.
| _CLOSURE_UNAVAILABLE = ( | ||
| ClosureState_E.CLOSURESTATE_UNKNOWN, | ||
| ClosureState_E.CLOSURESTATE_FAILED_UNLATCH, | ||
| ) |
There was a problem hiding this comment.
Why define this when were only going to use it once? Just inline it?
There was a problem hiding this comment.
Done — inlined it into _closure_is_open. Pushed in b17bfea.
| class TeslemetryBLEDataManager: | ||
| """Own a vehicle's direct Bluetooth link and its broadcast-sourced state. | ||
|
|
||
| Broadcasts only arrive while the link a command opened is still up, so a | ||
| value is valid only within the connection generation it was received in. An | ||
| unexpected drop bumps the generation, which makes every previously received | ||
| value stale and its entity unavailable. | ||
| """ | ||
|
|
||
| def __init__( | ||
| self, hass: HomeAssistant, bluetooth: VehicleBluetooth, vin: str | ||
| ) -> None: | ||
| """Initialize the manager around an already-created BLE client.""" | ||
| self.hass = hass | ||
| self.vin = vin | ||
| self._bluetooth = bluetooth | ||
| self._generation = 0 | ||
| self._connected = False | ||
| self._connection_listeners: list[Callable[[], None]] = [] | ||
| self._unsub_watcher: Callable[[], None] | None = None | ||
|
|
||
| @property | ||
| def bluetooth(self) -> VehicleBluetooth: | ||
| """Return the direct BLE client, never the command router.""" | ||
| return self._bluetooth | ||
|
|
||
| @property | ||
| def generation(self) -> int: | ||
| """Return the current connection generation.""" | ||
| return self._generation | ||
|
|
||
| @property | ||
| def connected(self) -> bool: | ||
| """Return whether the BLE link is currently up.""" | ||
| return self._connected | ||
|
|
||
| @callback | ||
| def async_start(self) -> None: | ||
| """Begin watching the link for an unexpected drop.""" | ||
| self._unsub_watcher = async_track_time_interval( | ||
| self.hass, self._async_watch_connection, CONNECTION_WATCH_INTERVAL | ||
| ) | ||
|
|
||
| @callback | ||
| def async_stop(self) -> None: | ||
| """Stop watching the link on unload.""" | ||
| if self._unsub_watcher is not None: | ||
| self._unsub_watcher() | ||
| self._unsub_watcher = None | ||
|
|
||
| @callback | ||
| def _async_watch_connection(self, now: datetime) -> None: | ||
| """Reconcile the cached link state with the client's, invalidating on loss.""" | ||
| client = self._bluetooth.client | ||
| connected = client is not None and client.is_connected | ||
| if connected == self._connected: | ||
| return | ||
| # A drop makes every value received on the old link stale. | ||
| if not connected: | ||
| self._generation += 1 | ||
| self._connected = connected | ||
| self._async_notify_connection() | ||
|
|
||
| @callback | ||
| def _async_notify_connection(self) -> None: | ||
| """Tell every entity to re-evaluate availability.""" | ||
| for listener in list(self._connection_listeners): | ||
| listener() | ||
|
|
||
| @callback | ||
| def async_on_connection_change( | ||
| self, listener: Callable[[], None] | ||
| ) -> Callable[[], None]: | ||
| """Register a callback fired when the link comes up or drops.""" | ||
| self._connection_listeners.append(listener) | ||
|
|
||
| @callback | ||
| def remove() -> None: | ||
| self._connection_listeners.remove(listener) | ||
|
|
||
| return remove | ||
|
|
||
| @callback | ||
| def async_on_broadcast( | ||
| self, | ||
| register: BroadcastRegister, | ||
| convert: Callable[[Any], Any], | ||
| update: Callable[[Any, int], None], | ||
| ) -> Callable[[], None]: | ||
| """Subscribe an entity to one VCSEC broadcast field. | ||
|
|
||
| ``register`` attaches the library's typed listener; ``convert`` maps the | ||
| raw protobuf value to the entity value (``None`` for an unknown enum); | ||
| ``update`` receives ``(value, generation)``. | ||
| """ | ||
|
|
||
| @callback | ||
| def handle(raw: Any) -> None: | ||
| # A broadcast is itself proof the link is up, so surface it at once | ||
| # rather than waiting up to a watch interval. | ||
| if not self._connected: | ||
| self._connected = True | ||
| self._async_notify_connection() | ||
| update(convert(raw), self._generation) | ||
|
|
||
| return register(self._bluetooth, handle) |
There was a problem hiding this comment.
Should this be in Home Assistant or the library?
There was a problem hiding this comment.
Good question. Inferring connected from an incoming broadcast (and the 5s client.is_connected watcher that detects an unexpected drop) lives here only because tesla-fleet-api has no connection-state signal yet. A listen_connection_status() / is-connected change event in the library would be the cleaner home and would let HA drop the polling watcher and this inference entirely. Since you own the library, I'll leave the boundary call to you rather than guess — happy to move it into the library and consume it here if you'd prefer that shape.
Take the direct BLE client from the router's typed primary property instead of returning it alongside the router, and inline the single-use closure-unavailable tuple.
Consume tesla-fleet-api 1.8.0's VehicleBluetooth.listen_connection_status to track the BLE link, replacing the interim HA-local 5-second is_connected watcher and the broadcast-implies-connected inference. The library fires only genuine connect/disconnect transitions and owns the stale-client identity guard, so the manager just records the state and bumps the generation on a drop to keep the no-fallback availability contract. Bump the shared tesla-fleet-api pin to 1.8.0.
The BLE unload tests build the vehicle as AsyncMock, which turned the library's synchronous listen_connection_status into an awaitable and left the manager holding a coroutine instead of an unsubscribe callable, so async_stop raised on unload. Configure the method to return a callable, matching the real VehicleBluetooth contract.
Breaking change
Proposed change
Reroute the six broadcast-backed vehicle binary sensors — awake/connectivity,
user presence, and the four doors — to the vehicle's own unsolicited VCSEC
VehicleStatusbroadcasts when it is BLE paired. Nothing changes for anunpaired vehicle; it keeps its stream/polling sensors.
A new per-vehicle BLE data manager holds the direct Bluetooth client (the same
one the command router uses, read directly rather than through the router),
registers the persistent VCSEC listeners, and tracks a connection generation so
availability follows the live link. A paired sensor is strictly local: its value
is valid only in the generation it was received in, it goes unavailable on link
loss, and it never falls back to the stream or cloud. Unknown closure and
sleep/presence enums resolve to unavailable rather than a false state. No INFO
poll is issued; broadcasts are received only while a command already holds the
link open.
This is the first of a stacked series that moves the cheapest-source vehicle
values onto the local Bluetooth link. It builds on home-assistant#176296 (its base branch),
which adds the BLE command routing this reuses.
Type of change
Additional information
Checklist
ruff format homeassistant tests)If user exposed functionality or configuration variables are added/changed:
If the code communicates with devices, web services, or third-party tools:
Updated and included derived files by running:
python3 -m script.hassfest.requirements_all.txt.Updated by running
python3 -m script.gen_requirements_all.To help with the load of incoming pull requests: