From d5aad7cd6f59370cfd97fc5f44e1cfa21025e1ec Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Fri, 31 Jul 2026 14:26:36 +1000 Subject: [PATCH 1/7] Use VCSEC broadcasts for Teslemetry vehicle binary sensors 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. --- .../components/teslemetry/__init__.py | 39 ++- .../components/teslemetry/binary_sensor.py | 105 ++++++- homeassistant/components/teslemetry/ble.py | 183 ++++++++++++ homeassistant/components/teslemetry/models.py | 5 + tests/components/teslemetry/test_ble.py | 266 ++++++++++++++++++ 5 files changed, 584 insertions(+), 14 deletions(-) create mode 100644 homeassistant/components/teslemetry/ble.py create mode 100644 tests/components/teslemetry/test_ble.py diff --git a/homeassistant/components/teslemetry/__init__.py b/homeassistant/components/teslemetry/__init__.py index 01d9fe56729406..68f42aea930f20 100644 --- a/homeassistant/components/teslemetry/__init__.py +++ b/homeassistant/components/teslemetry/__init__.py @@ -17,6 +17,7 @@ TeslaFleetError, ) from tesla_fleet_api.router import VehicleRouter +from tesla_fleet_api.tesla.vehicle.bluetooth import VehicleBluetooth from tesla_fleet_api.teslemetry import Teslemetry, Vehicle from teslemetry_stream import TeslemetryStream @@ -48,6 +49,7 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.typing import ConfigType +from .ble import TeslemetryBLEDataManager from .const import ( CLIENT_ID, CONF_VIN, @@ -335,20 +337,24 @@ async def _async_resolve_vehicle_api( subentry_id: str, vin: str, cloud_vehicle: Vehicle, -) -> Vehicle | VehicleRouter: - """Return the API a vehicle's platforms should call. +) -> tuple[Vehicle | VehicleRouter, VehicleBluetooth | None]: + """Return the command API and the direct BLE client a vehicle should use. An unpaired vehicle (its subentry carries no BLE ``address``) uses the cloud - Vehicle. A paired vehicle always gets a VehicleRouter, whether or not it is - in range right now: the router's health check re-reads Home Assistant's - Bluetooth discovery cache on every command, so a vehicle that drives away - and comes back resumes local routing on its own. A vehicle out of range is - skipped by the health check, sending the command straight to cloud without - attempting Bluetooth. + Vehicle and has no BLE client. A paired vehicle always gets a VehicleRouter, + whether or not it is in range right now: the router's health check re-reads + Home Assistant's Bluetooth discovery cache on every command, so a vehicle + that drives away and comes back resumes local routing on its own. A vehicle + out of range is skipped by the health check, sending the command straight to + cloud without attempting Bluetooth. + + The same BLE client is returned directly alongside the router so local data + readers can take unsolicited broadcasts from it without going through the + router, for which cloud fallback is forbidden on BLE-sourced state. """ address = entry.subentries[subentry_id].data.get(CONF_ADDRESS) if not address: - return cloud_vehicle + return cloud_vehicle, None parent = await async_get_ble_parent(hass) # verify + raise_unconfirmed=False so an ambiguous BLE timeout resolves as a @@ -374,7 +380,8 @@ def _in_range() -> bool: bluetooth_vehicle.set_device(device) return True - return VehicleRouter(bluetooth_vehicle, cloud_vehicle, health=_in_range) + router = VehicleRouter(bluetooth_vehicle, cloud_vehicle, health=_in_range) + return router, bluetooth_vehicle async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) -> bool: @@ -511,8 +518,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) - ) # Route commands through Bluetooth first when the subentry has been - # paired; otherwise this returns the plain cloud Vehicle. - vehicle_api = await _async_resolve_vehicle_api( + # paired; otherwise this returns the plain cloud Vehicle. The direct + # BLE client comes back too so local data reads can bypass the router. + vehicle_api, bluetooth_vehicle = await _async_resolve_vehicle_api( hass, entry, subentry_id, @@ -520,6 +528,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) - vehicle, ) + ble: TeslemetryBLEDataManager | None = None + if bluetooth_vehicle is not None: + ble = TeslemetryBLEDataManager(hass, bluetooth_vehicle, vin) + ble.async_start() + entry.async_on_unload(ble.async_stop) + vehicles.append( TeslemetryVehicleData( api=vehicle_api, @@ -532,6 +546,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) - firmware=firmware or "Unknown", device=device, subentry_id=subentry_id, + ble=ble, ) ) diff --git a/homeassistant/components/teslemetry/binary_sensor.py b/homeassistant/components/teslemetry/binary_sensor.py index e64af7b6e0787a..e89a45dfcbf483 100644 --- a/homeassistant/components/teslemetry/binary_sensor.py +++ b/homeassistant/components/teslemetry/binary_sensor.py @@ -2,9 +2,14 @@ from collections.abc import Callable from dataclasses import dataclass -from typing import cast, override +from typing import Any, cast, override from tesla_fleet_api import firmware_at_least +from tesla_fleet_api.tesla.vehicle.proto.vcsec_pb2 import ( + ClosureState_E, + UserPresence_E, + VehicleSleepStatus_E, +) from teslemetry_stream.vehicle import TeslemetryStreamVehicle from homeassistant.components.binary_sensor import ( @@ -19,6 +24,7 @@ from homeassistant.helpers.typing import StateType from . import TeslemetryConfigEntry +from .ble import BroadcastRegister, TeslemetryVehicleBluetoothEntity from .const import TeslemetryState from .entity import ( TeslemetryEnergyInfoEntity, @@ -36,6 +42,37 @@ "Closed": False, } +# VCSEC closure enums that carry no usable state; both mean "not false". +_CLOSURE_UNAVAILABLE = ( + ClosureState_E.CLOSURESTATE_UNKNOWN, + ClosureState_E.CLOSURESTATE_FAILED_UNLATCH, +) + + +def _closure_is_open(value: int) -> bool | None: + """Map a VCSEC closure enum onto an open/closed binary state.""" + if value in _CLOSURE_UNAVAILABLE: + return None + return value != ClosureState_E.CLOSURESTATE_CLOSED + + +def _sleep_is_online(value: int) -> bool | None: + """Map the VCSEC sleep enum onto connectivity; unknown is unavailable.""" + if value == VehicleSleepStatus_E.VEHICLE_SLEEP_STATUS_AWAKE: + return True + if value == VehicleSleepStatus_E.VEHICLE_SLEEP_STATUS_ASLEEP: + return False + return None + + +def _user_is_present(value: int) -> bool | None: + """Map the VCSEC user-presence enum; unknown is unavailable.""" + if value == UserPresence_E.VEHICLE_USER_PRESENCE_PRESENT: + return True + if value == UserPresence_E.VEHICLE_USER_PRESENCE_NOT_PRESENT: + return False + return None + @dataclass(frozen=True, kw_only=True) class TeslemetryBinarySensorEntityDescription(BinarySensorEntityDescription): @@ -51,6 +88,8 @@ class TeslemetryBinarySensorEntityDescription(BinarySensorEntityDescription): | None ) = None streaming_firmware: str = "2024.26" + bluetooth_listener: BroadcastRegister | None = None + bluetooth_value_fn: Callable[[Any], bool | None] | None = None VEHICLE_DESCRIPTIONS: tuple[TeslemetryBinarySensorEntityDescription, ...] = ( @@ -59,6 +98,10 @@ class TeslemetryBinarySensorEntityDescription(BinarySensorEntityDescription): polling=True, polling_value_fn=lambda value: value == TeslemetryState.ONLINE, streaming_listener=lambda vehicle, callback: vehicle.listen_State(callback), + bluetooth_listener=lambda ble, callback: ble.listen_vehicle_sleep_status( + callback + ), + bluetooth_value_fn=_sleep_is_online, device_class=BinarySensorDeviceClass.CONNECTIVITY, ), TeslemetryBinarySensorEntityDescription( @@ -150,6 +193,8 @@ class TeslemetryBinarySensorEntityDescription(BinarySensorEntityDescription): TeslemetryBinarySensorEntityDescription( key="vehicle_state_is_user_present", polling=True, + bluetooth_listener=lambda ble, callback: ble.listen_user_presence(callback), + bluetooth_value_fn=_user_is_present, device_class=BinarySensorDeviceClass.PRESENCE, ), TeslemetryBinarySensorEntityDescription( @@ -227,6 +272,8 @@ class TeslemetryBinarySensorEntityDescription(BinarySensorEntityDescription): streaming_listener=lambda vehicle, callback: vehicle.listen_FrontDriverDoor( callback ), + bluetooth_listener=lambda ble, callback: ble.listen_front_driver_door(callback), + bluetooth_value_fn=_closure_is_open, entity_category=EntityCategory.DIAGNOSTIC, ), TeslemetryBinarySensorEntityDescription( @@ -236,6 +283,8 @@ class TeslemetryBinarySensorEntityDescription(BinarySensorEntityDescription): streaming_listener=lambda vehicle, callback: vehicle.listen_RearDriverDoor( callback ), + bluetooth_listener=lambda ble, callback: ble.listen_rear_driver_door(callback), + bluetooth_value_fn=_closure_is_open, entity_category=EntityCategory.DIAGNOSTIC, ), TeslemetryBinarySensorEntityDescription( @@ -245,6 +294,10 @@ class TeslemetryBinarySensorEntityDescription(BinarySensorEntityDescription): streaming_listener=lambda vehicle, callback: vehicle.listen_FrontPassengerDoor( callback ), + bluetooth_listener=lambda ble, callback: ble.listen_front_passenger_door( + callback + ), + bluetooth_value_fn=_closure_is_open, entity_category=EntityCategory.DIAGNOSTIC, ), TeslemetryBinarySensorEntityDescription( @@ -254,6 +307,10 @@ class TeslemetryBinarySensorEntityDescription(BinarySensorEntityDescription): streaming_listener=lambda vehicle, callback: vehicle.listen_RearPassengerDoor( callback ), + bluetooth_listener=lambda ble, callback: ble.listen_rear_passenger_door( + callback + ), + bluetooth_value_fn=_closure_is_open, entity_category=EntityCategory.DIAGNOSTIC, ), TeslemetryBinarySensorEntityDescription( @@ -555,7 +612,13 @@ async def async_setup_entry( entities: list[BinarySensorEntity] = [] for vehicle in entry.runtime_data.vehicles: for description in VEHICLE_DESCRIPTIONS: - if ( + # A paired vehicle sources its broadcast-backed sensors locally and + # never falls back to the stream or cloud for them. + if vehicle.ble is not None and description.bluetooth_listener is not None: + entities.append( + TeslemetryVehicleBluetoothBinarySensorEntity(vehicle, description) + ) + elif ( not vehicle.poll and description.streaming_listener and firmware_at_least(vehicle.firmware, description.streaming_firmware) @@ -648,6 +711,44 @@ def _async_value_from_stream(self, value: bool | None) -> None: self.async_write_ha_state() +class TeslemetryVehicleBluetoothBinarySensorEntity( + TeslemetryVehicleBluetoothEntity, BinarySensorEntity +): + """Base class for Teslemetry vehicle binary sensors sourced from BLE broadcasts.""" + + entity_description: TeslemetryBinarySensorEntityDescription + + def __init__( + self, + data: TeslemetryVehicleData, + description: TeslemetryBinarySensorEntityDescription, + ) -> None: + """Initialize the binary sensor.""" + self.entity_description = description + super().__init__(data, description.key) + + @override + async def async_added_to_hass(self) -> None: + """Register the broadcast listener for this sensor.""" + await super().async_added_to_hass() + assert self.entity_description.bluetooth_listener is not None + assert self.entity_description.bluetooth_value_fn is not None + value_fn = self.entity_description.bluetooth_value_fn + self.async_on_remove( + self.manager.async_on_broadcast( + self.entity_description.bluetooth_listener, + value_fn, + self._handle_broadcast, + ) + ) + + @property + @override + def is_on(self) -> bool | None: + """Return the last broadcast state; None while unavailable.""" + return cast("bool | None", self._value) + + class TeslemetryEnergyLiveBinarySensorEntity( TeslemetryEnergyLiveEntity, BinarySensorEntity ): diff --git a/homeassistant/components/teslemetry/ble.py b/homeassistant/components/teslemetry/ble.py new file mode 100644 index 00000000000000..7500a43d3be89c --- /dev/null +++ b/homeassistant/components/teslemetry/ble.py @@ -0,0 +1,183 @@ +"""Local BLE data source for Teslemetry vehicles. + +Values here come only from the vehicle's own Bluetooth link: unsolicited VCSEC +``VehicleStatus`` broadcasts, and (in later platforms) parked INFO reads. Once a +vehicle is BLE paired, its rerouted entities are strictly local - they go +unavailable on link loss and never fall back to a stream or cloud value. +""" + +from collections.abc import Callable +from datetime import datetime, timedelta +from typing import Any, override + +from tesla_fleet_api.tesla.vehicle.bluetooth import VehicleBluetooth + +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.event import async_track_time_interval + +from .entity import TeslemetryRootEntity +from .models import TeslemetryVehicleData + +# Poll only local process state to notice an unexpected link drop; the library +# has no connection-status callback yet. This issues no GATT call and never +# reconnects, scans, or wakes the vehicle. +CONNECTION_WATCH_INTERVAL = timedelta(seconds=5) + +type BroadcastRegister = Callable[ + [VehicleBluetooth, Callable[[Any], None]], Callable[[], None] +] + + +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) + + +class TeslemetryVehicleBluetoothEntity(TeslemetryRootEntity): + """Parent class for entities sourced from a vehicle's BLE broadcasts.""" + + manager: TeslemetryBLEDataManager + _value: Any = None + _generation: int = -1 + + def __init__(self, data: TeslemetryVehicleData, key: str) -> None: + """Initialize common aspects of a Teslemetry BLE entity.""" + assert data.ble is not None + self.vehicle = data + self.manager = data.ble + self.vin = data.vin + self._attr_translation_key = key + self._attr_unique_id = f"{data.vin}-{key}" + self._attr_device_info = data.device + + @override + async def async_added_to_hass(self) -> None: + """Re-evaluate availability whenever the link comes up or drops.""" + self.async_on_remove( + self.manager.async_on_connection_change(self._handle_connection_change) + ) + + @callback + def _handle_connection_change(self) -> None: + """Handle the link coming up or dropping.""" + self.async_write_ha_state() + + @callback + def _handle_broadcast(self, value: Any, generation: int) -> None: + """Store a freshly received broadcast value and its generation.""" + self._value = value + self._generation = generation + self.async_write_ha_state() + + @property + @override + def available(self) -> bool: + """Return True only for a value received on the current live link.""" + return ( + self.manager.connected + and self._generation == self.manager.generation + and self._value is not None + ) diff --git a/homeassistant/components/teslemetry/models.py b/homeassistant/components/teslemetry/models.py index aedb4d1fd9dc0f..5891fd937d6cbc 100644 --- a/homeassistant/components/teslemetry/models.py +++ b/homeassistant/components/teslemetry/models.py @@ -2,6 +2,7 @@ import asyncio from dataclasses import dataclass, field +from typing import TYPE_CHECKING from tesla_fleet_api.const import Scope from tesla_fleet_api.router import VehicleRouter @@ -19,6 +20,9 @@ TeslemetryVehicleDataCoordinator, ) +if TYPE_CHECKING: + from .ble import TeslemetryBLEDataManager + @dataclass class TeslemetryData: @@ -45,6 +49,7 @@ class TeslemetryVehicleData: firmware: str device: DeviceInfo subentry_id: str + ble: TeslemetryBLEDataManager | None = None wakelock: asyncio.Lock = field(default_factory=asyncio.Lock) diff --git a/tests/components/teslemetry/test_ble.py b/tests/components/teslemetry/test_ble.py new file mode 100644 index 00000000000000..ea95ecb3c79a4a --- /dev/null +++ b/tests/components/teslemetry/test_ble.py @@ -0,0 +1,266 @@ +"""Test the Teslemetry BLE broadcast data source.""" + +from collections.abc import Callable +from datetime import timedelta +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from tesla_fleet_api.tesla.vehicle.proto.vcsec_pb2 import ( + ClosureState_E, + UserPresence_E, + VehicleSleepStatus_E, +) + +from homeassistant.components.teslemetry.const import CONF_VIN, SUBENTRY_TYPE_VEHICLE +from homeassistant.config_entries import ConfigSubentryData +from homeassistant.const import ( + CONF_ADDRESS, + STATE_OFF, + STATE_ON, + STATE_UNAVAILABLE, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +from homeassistant.util import dt as dt_util + +from . import mock_config_entry, setup_platform + +from tests.common import MockConfigEntry, async_fire_time_changed + +VIN = "LRW3F7EK4NC700000" +ADDRESS = "AA:BB:CC:DD:EE:FF" + + +def _entry_with_ble() -> MockConfigEntry: + """Return a config entry whose vehicle subentry is already BLE-paired.""" + entry = mock_config_entry() + return MockConfigEntry( + domain=entry.domain, + version=entry.version, + minor_version=entry.minor_version, + unique_id=entry.unique_id, + data=dict(entry.data), + subentries_data=[ + ConfigSubentryData( + subentry_type=SUBENTRY_TYPE_VEHICLE, + unique_id=VIN, + title="Test", + data={CONF_VIN: VIN, CONF_ADDRESS: ADDRESS}, + ) + ], + ) + + +async def _setup_ble( + hass: HomeAssistant, connected: bool = False +) -> tuple[MockConfigEntry, MagicMock]: + """Set up the binary sensor platform for a BLE-paired vehicle. + + Returns the entry and the BLE client mock. The client's ``listen_*`` methods + record the manager's broadcast callbacks so tests can feed them raw values. + """ + entry = _entry_with_ble() + entry.add_to_hass(hass) + bluetooth_vehicle = MagicMock() + bluetooth_vehicle.client = MagicMock(is_connected=connected) + + with ( + patch( + "homeassistant.components.teslemetry.async_ble_device_from_address", + return_value=MagicMock(), + ), + patch( + "homeassistant.components.teslemetry.helpers.TeslaBluetooth" + ) as mock_parent, + patch( + "homeassistant.components.teslemetry.PLATFORMS", [Platform.BINARY_SENSOR] + ), + ): + mock_parent.return_value.get_private_key = AsyncMock() + mock_parent.return_value.vehicles.createBluetooth.return_value = ( + bluetooth_vehicle + ) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + return entry, bluetooth_vehicle + + +def _emit(mock_listener: MagicMock, value: Any) -> None: + """Invoke the manager's captured broadcast callback with a raw value.""" + callback: Callable[[Any], None] = mock_listener.call_args[0][0] + callback(value) + + +async def test_paired_vehicle_uses_broadcasts( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: + """A paired vehicle sources its rerouted sensors from BLE broadcasts.""" + entry, bluetooth = await _setup_ble(hass) + assert entry.runtime_data.vehicles[0].ble is not None + + state_id = entity_registry.async_get_entity_id( + "binary_sensor", "teslemetry", f"{VIN}-state" + ) + assert state_id is not None + # No broadcast yet: strictly local, so unavailable rather than a stream value. + assert hass.states.get(state_id).state == STATE_UNAVAILABLE + + _emit( + bluetooth.listen_vehicle_sleep_status, + VehicleSleepStatus_E.VEHICLE_SLEEP_STATUS_AWAKE, + ) + await hass.async_block_till_done() + assert hass.states.get(state_id).state == STATE_ON + + _emit( + bluetooth.listen_vehicle_sleep_status, + VehicleSleepStatus_E.VEHICLE_SLEEP_STATUS_ASLEEP, + ) + await hass.async_block_till_done() + assert hass.states.get(state_id).state == STATE_OFF + + +async def test_unpaired_vehicle_not_bluetooth(hass: HomeAssistant) -> None: + """Without a paired address a vehicle has no BLE data manager.""" + entry = await setup_platform(hass, [Platform.BINARY_SENSOR]) + assert entry.runtime_data.vehicles[0].ble is None + + +@pytest.mark.parametrize( + ("raw", "expected"), + [ + (ClosureState_E.CLOSURESTATE_CLOSED, STATE_OFF), + (ClosureState_E.CLOSURESTATE_OPEN, STATE_ON), + (ClosureState_E.CLOSURESTATE_AJAR, STATE_ON), + (ClosureState_E.CLOSURESTATE_OPENING, STATE_ON), + (ClosureState_E.CLOSURESTATE_CLOSING, STATE_ON), + (ClosureState_E.CLOSURESTATE_UNKNOWN, STATE_UNAVAILABLE), + (ClosureState_E.CLOSURESTATE_FAILED_UNLATCH, STATE_UNAVAILABLE), + ], +) +async def test_door_closure_conversion( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + raw: int, + expected: str, +) -> None: + """Each door closure enum maps to the right binary state; unknown is unavailable.""" + _entry, bluetooth = await _setup_ble(hass) + door_id = entity_registry.async_get_entity_id( + "binary_sensor", "teslemetry", f"{VIN}-vehicle_state_df" + ) + + _emit(bluetooth.listen_front_driver_door, raw) + await hass.async_block_till_done() + assert hass.states.get(door_id).state == expected + + +@pytest.mark.parametrize( + ("raw", "expected"), + [ + (UserPresence_E.VEHICLE_USER_PRESENCE_PRESENT, STATE_ON), + (UserPresence_E.VEHICLE_USER_PRESENCE_NOT_PRESENT, STATE_OFF), + (UserPresence_E.VEHICLE_USER_PRESENCE_UNKNOWN, STATE_UNAVAILABLE), + ], +) +async def test_user_presence_conversion( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + raw: int, + expected: str, +) -> None: + """User presence maps present/not-present to on/off; unknown is unavailable.""" + _entry, bluetooth = await _setup_ble(hass) + presence_id = entity_registry.async_get_entity_id( + "binary_sensor", "teslemetry", f"{VIN}-vehicle_state_is_user_present" + ) + + _emit(bluetooth.listen_user_presence, raw) + await hass.async_block_till_done() + assert hass.states.get(presence_id).state == expected + + +async def test_unknown_sleep_never_false( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: + """A default/unknown sleep enum is unavailable, never a false 'asleep'.""" + _entry, bluetooth = await _setup_ble(hass) + state_id = entity_registry.async_get_entity_id( + "binary_sensor", "teslemetry", f"{VIN}-state" + ) + + _emit( + bluetooth.listen_vehicle_sleep_status, + VehicleSleepStatus_E.VEHICLE_SLEEP_STATUS_UNKNOWN, + ) + await hass.async_block_till_done() + assert hass.states.get(state_id).state == STATE_UNAVAILABLE + + +async def test_link_loss_marks_unavailable( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: + """An unexpected link drop makes every broadcast sensor unavailable.""" + _entry, bluetooth = await _setup_ble(hass, connected=True) + state_id = entity_registry.async_get_entity_id( + "binary_sensor", "teslemetry", f"{VIN}-state" + ) + + _emit( + bluetooth.listen_vehicle_sleep_status, + VehicleSleepStatus_E.VEHICLE_SLEEP_STATUS_AWAKE, + ) + await hass.async_block_till_done() + assert hass.states.get(state_id).state == STATE_ON + + bluetooth.client.is_connected = False + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=6)) + await hass.async_block_till_done() + assert hass.states.get(state_id).state == STATE_UNAVAILABLE + + +async def test_reconnect_invalidates_stale_value( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: + """A value from a prior connection stays unavailable until a fresh broadcast.""" + _entry, bluetooth = await _setup_ble(hass, connected=True) + state_id = entity_registry.async_get_entity_id( + "binary_sensor", "teslemetry", f"{VIN}-state" + ) + + _emit( + bluetooth.listen_vehicle_sleep_status, + VehicleSleepStatus_E.VEHICLE_SLEEP_STATUS_AWAKE, + ) + await hass.async_block_till_done() + assert hass.states.get(state_id).state == STATE_ON + + bluetooth.client.is_connected = False + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=6)) + await hass.async_block_till_done() + assert hass.states.get(state_id).state == STATE_UNAVAILABLE + + # The link is back, but the pre-drop value is stale until a new broadcast. + bluetooth.client.is_connected = True + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=12)) + await hass.async_block_till_done() + assert hass.states.get(state_id).state == STATE_UNAVAILABLE + + _emit( + bluetooth.listen_vehicle_sleep_status, + VehicleSleepStatus_E.VEHICLE_SLEEP_STATUS_AWAKE, + ) + await hass.async_block_till_done() + assert hass.states.get(state_id).state == STATE_ON + + +async def test_no_info_requests(hass: HomeAssistant) -> None: + """A broadcast-only vehicle issues no BLE INFO reads.""" + _entry, bluetooth = await _setup_ble(hass) + + bluetooth.vehicle_data.assert_not_called() + bluetooth.closures_state.assert_not_called() + bluetooth.climate_state.assert_not_called() From 3e5f29e73e0176361fd870479e79a31a2d43f7fd Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Fri, 31 Jul 2026 14:40:52 +1000 Subject: [PATCH 2/7] Silence pylint no-name-in-module for VCSEC protobuf enums 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. --- homeassistant/components/teslemetry/binary_sensor.py | 2 ++ tests/components/teslemetry/test_ble.py | 2 ++ 2 files changed, 4 insertions(+) diff --git a/homeassistant/components/teslemetry/binary_sensor.py b/homeassistant/components/teslemetry/binary_sensor.py index e89a45dfcbf483..eb3fe873b76b24 100644 --- a/homeassistant/components/teslemetry/binary_sensor.py +++ b/homeassistant/components/teslemetry/binary_sensor.py @@ -5,6 +5,8 @@ from typing import Any, cast, override from tesla_fleet_api import firmware_at_least + +# pylint: disable-next=no-name-in-module from tesla_fleet_api.tesla.vehicle.proto.vcsec_pb2 import ( ClosureState_E, UserPresence_E, diff --git a/tests/components/teslemetry/test_ble.py b/tests/components/teslemetry/test_ble.py index ea95ecb3c79a4a..f1909db6c3908b 100644 --- a/tests/components/teslemetry/test_ble.py +++ b/tests/components/teslemetry/test_ble.py @@ -6,6 +6,8 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest + +# pylint: disable-next=no-name-in-module from tesla_fleet_api.tesla.vehicle.proto.vcsec_pb2 import ( ClosureState_E, UserPresence_E, From 91dc42dab139122bfcbb4d454c0f729da24091f0 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Fri, 31 Jul 2026 14:55:46 +1000 Subject: [PATCH 3/7] Route Teslemetry BLE entity commands through the shared api 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. --- homeassistant/components/teslemetry/ble.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/homeassistant/components/teslemetry/ble.py b/homeassistant/components/teslemetry/ble.py index 7500a43d3be89c..d2b883eb7e5b23 100644 --- a/homeassistant/components/teslemetry/ble.py +++ b/homeassistant/components/teslemetry/ble.py @@ -10,7 +10,9 @@ from datetime import datetime, timedelta from typing import Any, override +from tesla_fleet_api.router import VehicleRouter from tesla_fleet_api.tesla.vehicle.bluetooth import VehicleBluetooth +from tesla_fleet_api.teslemetry import Vehicle from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.event import async_track_time_interval @@ -140,6 +142,7 @@ class TeslemetryVehicleBluetoothEntity(TeslemetryRootEntity): """Parent class for entities sourced from a vehicle's BLE broadcasts.""" manager: TeslemetryBLEDataManager + api: Vehicle | VehicleRouter _value: Any = None _generation: int = -1 @@ -148,6 +151,8 @@ def __init__(self, data: TeslemetryVehicleData, key: str) -> None: assert data.ble is not None self.vehicle = data self.manager = data.ble + # Commands still route through the router; only reads are local. + self.api = data.api self.vin = data.vin self._attr_translation_key = key self._attr_unique_id = f"{data.vin}-{key}" From b17bfea6078697c00de1959f7c0b998ebea77b99 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Fri, 31 Jul 2026 15:19:47 +1000 Subject: [PATCH 4/7] Address review: use router.primary and inline the closure set 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. --- .../components/teslemetry/__init__.py | 41 +++++++++---------- .../components/teslemetry/binary_sensor.py | 17 ++++---- 2 files changed, 28 insertions(+), 30 deletions(-) diff --git a/homeassistant/components/teslemetry/__init__.py b/homeassistant/components/teslemetry/__init__.py index 68f42aea930f20..f03a67af78ccf2 100644 --- a/homeassistant/components/teslemetry/__init__.py +++ b/homeassistant/components/teslemetry/__init__.py @@ -17,7 +17,6 @@ TeslaFleetError, ) from tesla_fleet_api.router import VehicleRouter -from tesla_fleet_api.tesla.vehicle.bluetooth import VehicleBluetooth from tesla_fleet_api.teslemetry import Teslemetry, Vehicle from teslemetry_stream import TeslemetryStream @@ -337,24 +336,24 @@ async def _async_resolve_vehicle_api( subentry_id: str, vin: str, cloud_vehicle: Vehicle, -) -> tuple[Vehicle | VehicleRouter, VehicleBluetooth | None]: - """Return the command API and the direct BLE client a vehicle should use. +) -> Vehicle | VehicleRouter: + """Return the API a vehicle's platforms should call. An unpaired vehicle (its subentry carries no BLE ``address``) uses the cloud - Vehicle and has no BLE client. A paired vehicle always gets a VehicleRouter, - whether or not it is in range right now: the router's health check re-reads - Home Assistant's Bluetooth discovery cache on every command, so a vehicle - that drives away and comes back resumes local routing on its own. A vehicle - out of range is skipped by the health check, sending the command straight to - cloud without attempting Bluetooth. - - The same BLE client is returned directly alongside the router so local data - readers can take unsolicited broadcasts from it without going through the - router, for which cloud fallback is forbidden on BLE-sourced state. + Vehicle. A paired vehicle always gets a VehicleRouter, whether or not it is + in range right now: the router's health check re-reads Home Assistant's + Bluetooth discovery cache on every command, so a vehicle that drives away + and comes back resumes local routing on its own. A vehicle out of range is + skipped by the health check, sending the command straight to cloud without + attempting Bluetooth. + + The router's ``primary`` is the direct BLE client local data readers take + unsolicited broadcasts from, without going through the router, for which + cloud fallback is forbidden on BLE-sourced state. """ address = entry.subentries[subentry_id].data.get(CONF_ADDRESS) if not address: - return cloud_vehicle, None + return cloud_vehicle parent = await async_get_ble_parent(hass) # verify + raise_unconfirmed=False so an ambiguous BLE timeout resolves as a @@ -380,8 +379,7 @@ def _in_range() -> bool: bluetooth_vehicle.set_device(device) return True - router = VehicleRouter(bluetooth_vehicle, cloud_vehicle, health=_in_range) - return router, bluetooth_vehicle + return VehicleRouter(bluetooth_vehicle, cloud_vehicle, health=_in_range) async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) -> bool: @@ -518,9 +516,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) - ) # Route commands through Bluetooth first when the subentry has been - # paired; otherwise this returns the plain cloud Vehicle. The direct - # BLE client comes back too so local data reads can bypass the router. - vehicle_api, bluetooth_vehicle = await _async_resolve_vehicle_api( + # paired; otherwise this returns the plain cloud Vehicle. + vehicle_api = await _async_resolve_vehicle_api( hass, entry, subentry_id, @@ -528,9 +525,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) - vehicle, ) + # A paired vehicle's router exposes the direct BLE client as its + # primary; local data reads take broadcasts from it, never the router. ble: TeslemetryBLEDataManager | None = None - if bluetooth_vehicle is not None: - ble = TeslemetryBLEDataManager(hass, bluetooth_vehicle, vin) + if isinstance(vehicle_api, VehicleRouter): + ble = TeslemetryBLEDataManager(hass, vehicle_api.primary, vin) ble.async_start() entry.async_on_unload(ble.async_stop) diff --git a/homeassistant/components/teslemetry/binary_sensor.py b/homeassistant/components/teslemetry/binary_sensor.py index eb3fe873b76b24..f1ba16bab842fe 100644 --- a/homeassistant/components/teslemetry/binary_sensor.py +++ b/homeassistant/components/teslemetry/binary_sensor.py @@ -44,16 +44,15 @@ "Closed": False, } -# VCSEC closure enums that carry no usable state; both mean "not false". -_CLOSURE_UNAVAILABLE = ( - ClosureState_E.CLOSURESTATE_UNKNOWN, - ClosureState_E.CLOSURESTATE_FAILED_UNLATCH, -) - - def _closure_is_open(value: int) -> bool | None: - """Map a VCSEC closure enum onto an open/closed binary state.""" - if value in _CLOSURE_UNAVAILABLE: + """Map a VCSEC closure enum onto an open/closed binary state. + + UNKNOWN and FAILED_UNLATCH carry no usable state, so they are unavailable. + """ + if value in ( + ClosureState_E.CLOSURESTATE_UNKNOWN, + ClosureState_E.CLOSURESTATE_FAILED_UNLATCH, + ): return None return value != ClosureState_E.CLOSURESTATE_CLOSED From d6a8904e667b61c2b6ab4255c377d80a640fba91 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Fri, 31 Jul 2026 15:28:42 +1000 Subject: [PATCH 5/7] Format binary sensor after inlining the closure check --- homeassistant/components/teslemetry/binary_sensor.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/teslemetry/binary_sensor.py b/homeassistant/components/teslemetry/binary_sensor.py index f1ba16bab842fe..ef4de773bc38f0 100644 --- a/homeassistant/components/teslemetry/binary_sensor.py +++ b/homeassistant/components/teslemetry/binary_sensor.py @@ -44,6 +44,7 @@ "Closed": False, } + def _closure_is_open(value: int) -> bool | None: """Map a VCSEC closure enum onto an open/closed binary state. From 1f17b9b2068eb40807ba4fbb3918bb70bb06fee6 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Tue, 4 Aug 2026 12:39:06 +1000 Subject: [PATCH 6/7] Drive Teslemetry BLE connectivity from the library connection event 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. --- .../components/tesla_fleet/manifest.json | 2 +- homeassistant/components/teslemetry/ble.py | 42 +++++------- .../components/teslemetry/manifest.json | 2 +- homeassistant/components/tessie/manifest.json | 2 +- requirements_all.txt | 2 +- tests/components/teslemetry/test_ble.py | 65 ++++++++++++++----- 6 files changed, 69 insertions(+), 46 deletions(-) diff --git a/homeassistant/components/tesla_fleet/manifest.json b/homeassistant/components/tesla_fleet/manifest.json index 37aaf456b5ad55..fc4a559635a8be 100644 --- a/homeassistant/components/tesla_fleet/manifest.json +++ b/homeassistant/components/tesla_fleet/manifest.json @@ -8,5 +8,5 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["tesla-fleet-api"], - "requirements": ["tesla-fleet-api==1.7.6"] + "requirements": ["tesla-fleet-api==1.8.0"] } diff --git a/homeassistant/components/teslemetry/ble.py b/homeassistant/components/teslemetry/ble.py index d2b883eb7e5b23..c736d2f5ad5198 100644 --- a/homeassistant/components/teslemetry/ble.py +++ b/homeassistant/components/teslemetry/ble.py @@ -7,7 +7,6 @@ """ from collections.abc import Callable -from datetime import datetime, timedelta from typing import Any, override from tesla_fleet_api.router import VehicleRouter @@ -15,16 +14,10 @@ from tesla_fleet_api.teslemetry import Vehicle from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.event import async_track_time_interval from .entity import TeslemetryRootEntity from .models import TeslemetryVehicleData -# Poll only local process state to notice an unexpected link drop; the library -# has no connection-status callback yet. This issues no GATT call and never -# reconnects, scans, or wakes the vehicle. -CONNECTION_WATCH_INTERVAL = timedelta(seconds=5) - type BroadcastRegister = Callable[ [VehicleBluetooth, Callable[[Any], None]], Callable[[], None] ] @@ -49,7 +42,7 @@ def __init__( self._generation = 0 self._connected = False self._connection_listeners: list[Callable[[], None]] = [] - self._unsub_watcher: Callable[[], None] | None = None + self._unsub_connection: Callable[[], None] | None = None @property def bluetooth(self) -> VehicleBluetooth: @@ -68,26 +61,26 @@ def connected(self) -> bool: @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 + """Subscribe to the library's BLE connection-status events.""" + self._unsub_connection = self._bluetooth.listen_connection_status( + self._handle_connection_status ) @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 + """Stop listening for connection-status events on unload.""" + if self._unsub_connection is not None: + self._unsub_connection() + self._unsub_connection = 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. + def _handle_connection_status(self, connected: bool) -> None: + """Drive cached link state from the library's connection event. + + The library fires only genuine transitions and owns the stale-client + identity guard, so this just records the new state and, on a drop, bumps + the generation to make every value from the lost link stale. + """ if not connected: self._generation += 1 self._connected = connected @@ -128,11 +121,6 @@ def async_on_broadcast( @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) diff --git a/homeassistant/components/teslemetry/manifest.json b/homeassistant/components/teslemetry/manifest.json index eefa2bc5b78810..8a287b0bf8a317 100644 --- a/homeassistant/components/teslemetry/manifest.json +++ b/homeassistant/components/teslemetry/manifest.json @@ -9,5 +9,5 @@ "iot_class": "cloud_polling", "loggers": ["tesla_fleet_api", "teslemetry_stream"], "quality_scale": "platinum", - "requirements": ["tesla-fleet-api==1.7.6", "teslemetry-stream==0.10.0"] + "requirements": ["tesla-fleet-api==1.8.0", "teslemetry-stream==0.10.0"] } diff --git a/homeassistant/components/tessie/manifest.json b/homeassistant/components/tessie/manifest.json index 02f8c9a28edc68..e6f7ac522b7b83 100644 --- a/homeassistant/components/tessie/manifest.json +++ b/homeassistant/components/tessie/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["tessie", "tesla-fleet-api"], "quality_scale": "silver", - "requirements": ["tessie-api==0.1.3", "tesla-fleet-api==1.7.6"] + "requirements": ["tessie-api==0.1.3", "tesla-fleet-api==1.8.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 79241796359145..dedcbb727416fd 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3169,7 +3169,7 @@ temperusb==1.6.1 # homeassistant.components.tesla_fleet # homeassistant.components.teslemetry # homeassistant.components.tessie -tesla-fleet-api==1.7.6 +tesla-fleet-api==1.8.0 # homeassistant.components.powerwall tesla-powerwall==0.5.3 diff --git a/tests/components/teslemetry/test_ble.py b/tests/components/teslemetry/test_ble.py index f1909db6c3908b..9daa1fed7f6511 100644 --- a/tests/components/teslemetry/test_ble.py +++ b/tests/components/teslemetry/test_ble.py @@ -1,7 +1,6 @@ """Test the Teslemetry BLE broadcast data source.""" from collections.abc import Callable -from datetime import timedelta from typing import Any from unittest.mock import AsyncMock, MagicMock, patch @@ -25,11 +24,10 @@ ) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from homeassistant.util import dt as dt_util from . import mock_config_entry, setup_platform -from tests.common import MockConfigEntry, async_fire_time_changed +from tests.common import MockConfigEntry VIN = "LRW3F7EK4NC700000" ADDRESS = "AA:BB:CC:DD:EE:FF" @@ -56,17 +54,20 @@ def _entry_with_ble() -> MockConfigEntry: async def _setup_ble( - hass: HomeAssistant, connected: bool = False + hass: HomeAssistant, connected: bool = True ) -> tuple[MockConfigEntry, MagicMock]: """Set up the binary sensor platform for a BLE-paired vehicle. Returns the entry and the BLE client mock. The client's ``listen_*`` methods - record the manager's broadcast callbacks so tests can feed them raw values. + record the manager's broadcast callbacks so tests can feed them raw values; + ``listen_connection_status`` records the manager's connection callback. When + ``connected`` the link is brought up via that callback, as the library does + once a session is established. """ entry = _entry_with_ble() entry.add_to_hass(hass) bluetooth_vehicle = MagicMock() - bluetooth_vehicle.client = MagicMock(is_connected=connected) + bluetooth_vehicle.client = MagicMock() with ( patch( @@ -87,6 +88,10 @@ async def _setup_ble( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() + if connected: + _emit_connection(bluetooth_vehicle, True) + await hass.async_block_till_done() + return entry, bluetooth_vehicle @@ -96,6 +101,13 @@ def _emit(mock_listener: MagicMock, value: Any) -> None: callback(value) +def _emit_connection(bluetooth: MagicMock, connected: bool) -> None: + """Fire the library's connection-status callback the manager subscribed with.""" + listener = bluetooth.listen_connection_status + callback: Callable[[bool], None] = listener.call_args[0][0] + callback(connected) + + async def test_paired_vehicle_uses_broadcasts( hass: HomeAssistant, entity_registry: er.EntityRegistry ) -> None: @@ -205,8 +217,8 @@ async def test_unknown_sleep_never_false( async def test_link_loss_marks_unavailable( hass: HomeAssistant, entity_registry: er.EntityRegistry ) -> None: - """An unexpected link drop makes every broadcast sensor unavailable.""" - _entry, bluetooth = await _setup_ble(hass, connected=True) + """A library disconnect event makes every broadcast sensor unavailable.""" + _entry, bluetooth = await _setup_ble(hass) state_id = entity_registry.async_get_entity_id( "binary_sensor", "teslemetry", f"{VIN}-state" ) @@ -218,17 +230,42 @@ async def test_link_loss_marks_unavailable( await hass.async_block_till_done() assert hass.states.get(state_id).state == STATE_ON - bluetooth.client.is_connected = False - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=6)) + _emit_connection(bluetooth, False) await hass.async_block_till_done() assert hass.states.get(state_id).state == STATE_UNAVAILABLE +async def test_broadcast_without_connection_stays_unavailable( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: + """A broadcast alone no longer implies the link is up; state stays unavailable.""" + _entry, bluetooth = await _setup_ble(hass, connected=False) + state_id = entity_registry.async_get_entity_id( + "binary_sensor", "teslemetry", f"{VIN}-state" + ) + + _emit( + bluetooth.listen_vehicle_sleep_status, + VehicleSleepStatus_E.VEHICLE_SLEEP_STATUS_AWAKE, + ) + await hass.async_block_till_done() + assert hass.states.get(state_id).state == STATE_UNAVAILABLE + + # Only the library's connection event brings the sensor online. + _emit_connection(bluetooth, True) + _emit( + bluetooth.listen_vehicle_sleep_status, + VehicleSleepStatus_E.VEHICLE_SLEEP_STATUS_AWAKE, + ) + await hass.async_block_till_done() + assert hass.states.get(state_id).state == STATE_ON + + async def test_reconnect_invalidates_stale_value( hass: HomeAssistant, entity_registry: er.EntityRegistry ) -> None: """A value from a prior connection stays unavailable until a fresh broadcast.""" - _entry, bluetooth = await _setup_ble(hass, connected=True) + _entry, bluetooth = await _setup_ble(hass) state_id = entity_registry.async_get_entity_id( "binary_sensor", "teslemetry", f"{VIN}-state" ) @@ -240,14 +277,12 @@ async def test_reconnect_invalidates_stale_value( await hass.async_block_till_done() assert hass.states.get(state_id).state == STATE_ON - bluetooth.client.is_connected = False - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=6)) + _emit_connection(bluetooth, False) await hass.async_block_till_done() assert hass.states.get(state_id).state == STATE_UNAVAILABLE # The link is back, but the pre-drop value is stale until a new broadcast. - bluetooth.client.is_connected = True - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=12)) + _emit_connection(bluetooth, True) await hass.async_block_till_done() assert hass.states.get(state_id).state == STATE_UNAVAILABLE From 5fcb3d16afca28bdd24cb5b1fee7a6efa6a583a7 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Tue, 4 Aug 2026 12:55:23 +1000 Subject: [PATCH 7/7] Mock listen_connection_status as sync in BLE unload tests 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. --- tests/components/teslemetry/test_bluetooth.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/components/teslemetry/test_bluetooth.py b/tests/components/teslemetry/test_bluetooth.py index 00e334e1f9fd0a..35b5ea951e2b8e 100644 --- a/tests/components/teslemetry/test_bluetooth.py +++ b/tests/components/teslemetry/test_bluetooth.py @@ -294,6 +294,8 @@ async def test_unload_disconnects_bluetooth( entry.add_to_hass(hass) bluetooth_vehicle = AsyncMock() bluetooth_vehicle.disconnect = AsyncMock(side_effect=disconnect_error) + # listen_connection_status is synchronous and returns an unsubscribe callable. + bluetooth_vehicle.listen_connection_status = MagicMock(return_value=MagicMock()) with ( patch( @@ -328,6 +330,8 @@ async def test_unload_never_connected_bluetooth(hass: HomeAssistant) -> None: entry = _entry_with_ble() entry.add_to_hass(hass) bluetooth_vehicle = AsyncMock() + # listen_connection_status is synchronous and returns an unsubscribe callable. + bluetooth_vehicle.listen_connection_status = MagicMock(return_value=MagicMock()) with ( patch(