Skip to content

Use VCSEC broadcasts for Teslemetry vehicle binary sensors - #977

Draft
Bre77 wants to merge 7 commits into
fm/teslemetry-ble-ctrl-h24from
fm/ble-d9-1-binsensor
Draft

Use VCSEC broadcasts for Teslemetry vehicle binary sensors#977
Bre77 wants to merge 7 commits into
fm/teslemetry-ble-ctrl-h24from
fm/ble-d9-1-binsensor

Conversation

@Bre77

@Bre77 Bre77 commented Jul 31, 2026

Copy link
Copy Markdown
Owner

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
VehicleStatus broadcasts when it is BLE paired. Nothing changes for an
unpaired 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

  • Dependency upgrade
  • Bugfix (non-breaking change which fixes an issue)
  • New integration (thank you!)
  • New feature (which adds functionality to an existing integration)
  • Deprecation (breaking change to happen in the future)
  • Breaking change (fix/feature causing existing functionality to break)
  • Code quality improvements to existing code or addition of tests

Additional information

  • This PR fixes or closes issue: fixes #
  • This PR is related to issue:
  • Link to documentation pull request:
  • Link to developer documentation pull request:
  • Link to frontend pull request:

Checklist

  • I understand the code I am submitting and can explain how it works.
  • The code change is tested and works locally.
  • Local tests pass. Your PR cannot be merged unless tests pass
  • There is no commented out code in this PR.
  • I have followed the development checklist
  • I have followed the perfect PR recommendations
  • The code has been formatted using Ruff (ruff format homeassistant tests)
  • Tests have been added to verify that the new code works.
  • Any generated code has been carefully reviewed for correctness and compliance with project standards.

If user exposed functionality or configuration variables are added/changed:

If the code communicates with devices, web services, or third-party tools:

  • The manifest file has all fields filled out correctly.
    Updated and included derived files by running: python3 -m script.hassfest.
  • New or updated dependencies have been added to requirements_all.txt.
    Updated by running python3 -m script.gen_requirements_all.
  • For the updated dependencies a diff between library versions and ideally a link to the changelog/release notes is added to the PR description.

To help with the load of incoming pull requests:

Bre77 added 2 commits July 31, 2026 14:26
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

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +48 to +51
_CLOSURE_UNAVAILABLE = (
ClosureState_E.CLOSURESTATE_UNKNOWN,
ClosureState_E.CLOSURESTATE_FAILED_UNLATCH,
)

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why define this when were only going to use it once? Just inline it?

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — inlined it into _closure_is_open. Pushed in b17bfea.

Comment on lines +33 to +138
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)

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be in Home Assistant or the library?

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Bre77 added 4 commits July 31, 2026 15:19
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant