From 541dc71ec757af5357ed301c7eb9cb22d350481e Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Fri, 31 Jul 2026 15:10:20 +1000 Subject: [PATCH 1/3] Read Teslemetry sunroof state over parked Bluetooth Add a sleep-aware INFO scheduler to the BLE data manager and a sunroof cover sourced from it. The scheduler reads a single closures_state() endpoint only while a command-opened link is up and the vehicle reports awake (VCSEC) and parked (streamed gear P); it refreshes at most every 60 seconds, extends on user/charging/sentry activity, and after 15 idle minutes disconnects and stays quiet with no autonomous wake. Endpoint values are per-connection-generation, so a drop marks the sunroof unavailable with no stream or cloud fallback. Preserves the sunroof-installed metadata gate. --- .../components/teslemetry/__init__.py | 4 +- homeassistant/components/teslemetry/ble.py | 254 +++++++++++++++++- homeassistant/components/teslemetry/cover.py | 84 +++++- tests/components/teslemetry/test_ble.py | 158 +++++++++++ tests/components/teslemetry/test_bluetooth.py | 21 +- 5 files changed, 502 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/teslemetry/__init__.py b/homeassistant/components/teslemetry/__init__.py index 68f42aea930f20..954271d87c689d 100644 --- a/homeassistant/components/teslemetry/__init__.py +++ b/homeassistant/components/teslemetry/__init__.py @@ -530,7 +530,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) - ble: TeslemetryBLEDataManager | None = None if bluetooth_vehicle is not None: - ble = TeslemetryBLEDataManager(hass, bluetooth_vehicle, vin) + ble = TeslemetryBLEDataManager( + hass, bluetooth_vehicle, stream_vehicle, vin + ) ble.async_start() entry.async_on_unload(ble.async_stop) diff --git a/homeassistant/components/teslemetry/ble.py b/homeassistant/components/teslemetry/ble.py index d2b883eb7e5b23..401803bb61119f 100644 --- a/homeassistant/components/teslemetry/ble.py +++ b/homeassistant/components/teslemetry/ble.py @@ -1,21 +1,38 @@ """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. +``VehicleStatus`` broadcasts, and parked-only 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. + +The INFO scheduler never connects, scans, or wakes the vehicle. It reads an +endpoint only while a link a command already opened is up and the vehicle +reports itself awake (VCSEC) and parked (streamed gear ``P``); after 15 minutes +without activity it disconnects and stays quiet until fresh awake-and-parked +evidence arrives. """ -from collections.abc import Callable +import asyncio +from collections.abc import Awaitable, Callable from datetime import datetime, timedelta from typing import Any, override +from bleak.exc import BleakError +from tesla_fleet_api.exceptions import TeslaFleetError from tesla_fleet_api.router import VehicleRouter from tesla_fleet_api.tesla.vehicle.bluetooth import VehicleBluetooth + +# pylint: disable-next=no-name-in-module +from tesla_fleet_api.tesla.vehicle.proto.vcsec_pb2 import ( + UserPresence_E, + VehicleSleepStatus_E, +) from tesla_fleet_api.teslemetry import Vehicle +from teslemetry_stream.vehicle import TeslemetryStreamVehicle from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.event import async_track_time_interval +from homeassistant.util import dt as dt_util from .entity import TeslemetryRootEntity from .models import TeslemetryVehicleData @@ -25,31 +42,72 @@ # reconnects, scans, or wakes the vehicle. CONNECTION_WATCH_INTERVAL = timedelta(seconds=5) +# How often the scheduler re-evaluates whether a due INFO read may run. +SCHEDULER_INTERVAL = timedelta(seconds=5) + +# Minimum spacing between reads of a single endpoint. +INFO_REFRESH_INTERVAL = timedelta(seconds=60) + +# Stop reading and disconnect after this long without a qualifying activity. +REST_AFTER = timedelta(minutes=15) + +# Streamed gear value that means parked; every other value stops INFO reads. +PARKED_GEAR = "P" + type BroadcastRegister = Callable[ [VehicleBluetooth, Callable[[Any], None]], Callable[[], None] ] +type InfoReader = Callable[[VehicleBluetooth], Awaitable[Any]] + + +class _InfoEndpoint: + """A single parked-only INFO endpoint and its dependent entities.""" + + def __init__(self, reader: InfoReader) -> None: + """Initialize the endpoint around its single-endpoint reader.""" + self.reader = reader + self.value: Any = None + self.generation = -1 + self.last_read: datetime | None = None + self.reading = False + self.listeners: list[Callable[[Any, int], None]] = [] class TeslemetryBLEDataManager: - """Own a vehicle's direct Bluetooth link and its broadcast-sourced state. + """Own a vehicle's direct Bluetooth link and its locally 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 + Broadcasts only arrive while the link a command opened is 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. + value stale and its entity unavailable. INFO endpoints are read on the same + live link, gated by the park/awake scheduler. """ def __init__( - self, hass: HomeAssistant, bluetooth: VehicleBluetooth, vin: str + self, + hass: HomeAssistant, + bluetooth: VehicleBluetooth, + stream_vehicle: TeslemetryStreamVehicle, + vin: str, ) -> None: """Initialize the manager around an already-created BLE client.""" self.hass = hass self.vin = vin self._bluetooth = bluetooth + self._stream_vehicle = stream_vehicle self._generation = 0 self._connected = False self._connection_listeners: list[Callable[[], None]] = [] self._unsub_watcher: Callable[[], None] | None = None + self._unsub_scheduler: Callable[[], None] | None = None + self._gate_unsubs: list[Callable[[], None]] = [] + # Scheduler gate state; ``None`` means unknown, which never permits a read. + self._awake: bool | None = None + self._parked: bool | None = None + self._resting = False + self._last_activity = dt_util.utcnow() + self._info_lock = asyncio.Lock() + self._endpoints: dict[str, _InfoEndpoint] = {} @property def bluetooth(self) -> VehicleBluetooth: @@ -68,17 +126,35 @@ def connected(self) -> bool: @callback def async_start(self) -> None: - """Begin watching the link for an unexpected drop.""" + """Watch the link and gate signals, and run the INFO scheduler.""" self._unsub_watcher = async_track_time_interval( self.hass, self._async_watch_connection, CONNECTION_WATCH_INTERVAL ) + self._unsub_scheduler = async_track_time_interval( + self.hass, self._async_scheduler_tick, SCHEDULER_INTERVAL + ) + b = self._bluetooth + s = self._stream_vehicle + self._gate_unsubs = [ + b.listen_vehicle_sleep_status(self._handle_sleep), + b.listen_user_presence(self._handle_user_present), + s.listen_Gear(self._handle_gear), + s.listen_DetailedChargeState(self._handle_charge), + s.listen_SentryMode(self._handle_sentry), + ] @callback def async_stop(self) -> None: - """Stop watching the link on unload.""" + """Stop all timers and gate listeners on unload.""" if self._unsub_watcher is not None: self._unsub_watcher() self._unsub_watcher = None + if self._unsub_scheduler is not None: + self._unsub_scheduler() + self._unsub_scheduler = None + for unsub in self._gate_unsubs: + unsub() + self._gate_unsubs = [] @callback def _async_watch_connection(self, now: datetime) -> None: @@ -137,9 +213,161 @@ def handle(raw: Any) -> None: return register(self._bluetooth, handle) + @callback + def async_on_endpoint( + self, + name: str, + reader: InfoReader, + update: Callable[[Any, int], None], + ) -> Callable[[], None]: + """Subscribe an entity to a parked-only INFO endpoint. + + ``reader`` issues the single-endpoint request; ``update`` receives + ``(value, generation)``, with ``value`` ``None`` while unavailable. + """ + endpoint = self._endpoints.get(name) + if endpoint is None: + endpoint = _InfoEndpoint(reader) + self._endpoints[name] = endpoint + endpoint.listeners.append(update) + update(endpoint.value, endpoint.generation) + + @callback + def remove() -> None: + endpoint.listeners.remove(update) + if not endpoint.listeners: + self._endpoints.pop(name, None) + + return remove + + @callback + def _notify_endpoint(self, endpoint: _InfoEndpoint) -> None: + """Push an endpoint's current value to its dependent entities.""" + for listener in list(endpoint.listeners): + listener(endpoint.value, endpoint.generation) + + @callback + def _handle_sleep(self, value: int) -> None: + """Track VCSEC sleep as the hard read gate.""" + if value == VehicleSleepStatus_E.VEHICLE_SLEEP_STATUS_AWAKE: + self._awake = True + self._mark_activity() + elif value == VehicleSleepStatus_E.VEHICLE_SLEEP_STATUS_ASLEEP: + self._awake = False + self._async_stop_info() + else: + self._awake = None + self._async_stop_info() + + @callback + def _handle_gear(self, gear: str | None) -> None: + """Track streamed gear; only ``P`` permits a read.""" + if gear == PARKED_GEAR: + self._parked = True + elif gear is None: + self._parked = None + self._async_stop_info() + else: + self._parked = False + self._async_stop_info() + + @callback + def _handle_user_present(self, value: int) -> None: + """A present user extends the active window.""" + if value == UserPresence_E.VEHICLE_USER_PRESENCE_PRESENT: + self._mark_activity() + + @callback + def _handle_charge(self, value: str | None) -> None: + """Active charging extends the active window.""" + if value == "Charging": + self._mark_activity() + + @callback + def _handle_sentry(self, value: str | None) -> None: + """Active sentry extends the active window.""" + if value not in (None, "Off", "Unknown"): + self._mark_activity() + + @callback + def _mark_activity(self) -> None: + """Reset the inactivity timer and allow reading again.""" + self._last_activity = dt_util.utcnow() + self._resting = False + + @callback + def _async_stop_info(self) -> None: + """Mark every endpoint unavailable without disconnecting the link.""" + for endpoint in self._endpoints.values(): + if endpoint.value is not None: + endpoint.value = None + self._notify_endpoint(endpoint) + + @callback + def _enter_rest(self) -> None: + """Stop reading and disconnect after the inactivity window.""" + if self._resting: + return + self._resting = True + self._async_stop_info() + self.hass.async_create_task(self._async_disconnect()) + + async def _async_disconnect(self) -> None: + """Drop the link so the vehicle can sleep; never surface failures.""" + try: + await self._bluetooth.disconnect() + except BleakError, TeslaFleetError, TimeoutError: + pass + + @callback + def _async_scheduler_tick(self, now: datetime) -> None: + """Read any due endpoint while awake and parked, or rest when idle.""" + if self._resting or not ( + self._connected and self._awake is True and self._parked is True + ): + return + if dt_util.utcnow() - self._last_activity >= REST_AFTER: + self._enter_rest() + return + for endpoint in self._endpoints.values(): + if endpoint.reading: + continue + if ( + endpoint.last_read is None + or dt_util.utcnow() - endpoint.last_read >= INFO_REFRESH_INTERVAL + ): + self.hass.async_create_task(self._async_read_endpoint(endpoint)) + + async def _async_read_endpoint(self, endpoint: _InfoEndpoint) -> None: + """Read one endpoint under the shared lock so reads never overlap.""" + endpoint.reading = True + generation = self._generation + try: + async with self._info_lock: + if ( + generation != self._generation + or self._resting + or not (self._connected and self._awake and self._parked) + ): + return + result = await endpoint.reader(self._bluetooth) + except TeslaFleetError: + endpoint.value = None + self._notify_endpoint(endpoint) + return + finally: + endpoint.reading = False + # A drop while the read was in flight invalidates its result. + if generation != self._generation: + return + endpoint.value = result + endpoint.generation = generation + endpoint.last_read = dt_util.utcnow() + self._notify_endpoint(endpoint) + class TeslemetryVehicleBluetoothEntity(TeslemetryRootEntity): - """Parent class for entities sourced from a vehicle's BLE broadcasts.""" + """Parent class for entities sourced from a vehicle's local BLE data.""" manager: TeslemetryBLEDataManager api: Vehicle | VehicleRouter @@ -172,7 +400,7 @@ def _handle_connection_change(self) -> None: @callback def _handle_broadcast(self, value: Any, generation: int) -> None: - """Store a freshly received broadcast value and its generation.""" + """Store a freshly received value and its generation.""" self._value = value self._generation = generation self.async_write_ha_state() diff --git a/homeassistant/components/teslemetry/cover.py b/homeassistant/components/teslemetry/cover.py index 32eeb2d658e55d..24c55358b6714d 100644 --- a/homeassistant/components/teslemetry/cover.py +++ b/homeassistant/components/teslemetry/cover.py @@ -102,16 +102,33 @@ async def async_setup_entry( ) for vehicle in entry.runtime_data.vehicles ), + ( + TeslemetryBluetoothSunroofEntity(vehicle, entry.runtime_data.scopes) + for vehicle in entry.runtime_data.vehicles + if vehicle.ble is not None + and vehicle.coordinator.data.get("vehicle_config_sun_roof_installed") + ), ( TeslemetrySunroofEntity(vehicle, entry.runtime_data.scopes) for vehicle in entry.runtime_data.vehicles - if vehicle.poll + if vehicle.ble is None + and vehicle.poll and vehicle.coordinator.data.get("vehicle_config_sun_roof_installed") ), ) ) +def _sunroof_is_closed(closures: Any) -> bool | None: + """Map a BLE closures snapshot's sunroof oneof onto a closed state.""" + if not closures.HasField("sun_roof_state"): + return None + which = closures.sun_roof_state.WhichOneof("type") + if which in (None, "Unknown", "Calibrating"): + return None + return bool(which == "Closed") + + class CoverRestoreEntity(RestoreEntity, CoverEntity): """Restore class for cover entities.""" @@ -635,3 +652,68 @@ async def async_added_to_hass(self) -> None: self._handle_broadcast, ) ) + + +class TeslemetryBluetoothSunroofEntity(TeslemetryVehicleBluetoothEntity, CoverEntity): + """Bluetooth cover entity for the sunroof, read over a parked INFO snapshot.""" + + _attr_device_class = CoverDeviceClass.WINDOW + _attr_supported_features = ( + CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE | CoverEntityFeature.STOP + ) + _attr_entity_registry_enabled_default = False + _attr_is_closed: bool | None = None + _attr_current_cover_position: int | None = None + + def __init__(self, vehicle: TeslemetryVehicleData, scopes: list[Scope]) -> None: + """Initialize the cover.""" + super().__init__(vehicle, "vehicle_state_sun_roof_state") + self.scoped = Scope.VEHICLE_CMDS in scopes + if not self.scoped: + self._attr_supported_features = CoverEntityFeature(0) + + @override + async def async_added_to_hass(self) -> None: + """Register the parked closures INFO endpoint for the sunroof.""" + await super().async_added_to_hass() + self.async_on_remove( + self.manager.async_on_endpoint( + "closures", + lambda ble: ble.closures_state(), + self._handle_endpoint, + ) + ) + + @callback + def _handle_endpoint(self, value: Any, generation: int) -> None: + """Render the sunroof state and position from a closures snapshot.""" + self._value = value + self._generation = generation + if value is not None: + self._attr_is_closed = _sunroof_is_closed(value) + self._attr_current_cover_position = value.sun_roof_percent_open + self.async_write_ha_state() + + @override + async def async_open_cover(self, **kwargs: Any) -> None: + """Vent the sunroof.""" + self.raise_for_scope(Scope.VEHICLE_CMDS) + await handle_vehicle_command(self.api.sun_roof_control(SunRoofCommand.VENT)) + self._attr_is_closed = False + self.async_write_ha_state() + + @override + async def async_close_cover(self, **kwargs: Any) -> None: + """Close the sunroof.""" + self.raise_for_scope(Scope.VEHICLE_CMDS) + await handle_vehicle_command(self.api.sun_roof_control(SunRoofCommand.CLOSE)) + self._attr_is_closed = True + self.async_write_ha_state() + + @override + async def async_stop_cover(self, **kwargs: Any) -> None: + """Stop the sunroof.""" + self.raise_for_scope(Scope.VEHICLE_CMDS) + await handle_vehicle_command(self.api.sun_roof_control(SunRoofCommand.STOP)) + self._attr_is_closed = False + self.async_write_ha_state() diff --git a/tests/components/teslemetry/test_ble.py b/tests/components/teslemetry/test_ble.py index 3913177021ac02..913bf4cae4cc11 100644 --- a/tests/components/teslemetry/test_ble.py +++ b/tests/components/teslemetry/test_ble.py @@ -1,10 +1,12 @@ """Test the Teslemetry BLE broadcast data source.""" from collections.abc import Callable +from copy import deepcopy from datetime import timedelta from typing import Any from unittest.mock import AsyncMock, MagicMock, patch +from freezegun.api import FrozenDateTimeFactory import pytest # pylint: disable-next=no-name-in-module @@ -15,6 +17,10 @@ VehicleSleepStatus_E, ) +# pylint: disable-next=no-name-in-module +from tesla_fleet_api.tesla.vehicle.proto.vehicle_pb2 import ClosuresState +from teslemetry_stream import Signal + from homeassistant.components.lock import LockState from homeassistant.components.teslemetry.const import CONF_VIN, SUBENTRY_TYPE_VEHICLE from homeassistant.config_entries import ConfigSubentryData @@ -32,6 +38,7 @@ from homeassistant.util import dt as dt_util from . import mock_config_entry, setup_platform +from .const import PRODUCTS from tests.common import MockConfigEntry, async_fire_time_changed @@ -63,16 +70,23 @@ async def _setup_ble( hass: HomeAssistant, connected: bool = False, platforms: tuple[Platform, ...] = (Platform.BINARY_SENSOR,), + sunroof: bool = False, ) -> tuple[MockConfigEntry, MagicMock]: """Set up the given platforms 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. + ``sunroof`` reports the vehicle's metadata as sunroof-installed. """ entry = _entry_with_ble() entry.add_to_hass(hass) bluetooth_vehicle = MagicMock() bluetooth_vehicle.client = MagicMock(is_connected=connected) + bluetooth_vehicle.disconnect = AsyncMock() + + products = deepcopy(PRODUCTS) + if sunroof: + products["response"][0]["vehicle_config"]["sun_roof_installed"] = True with ( patch( @@ -83,6 +97,7 @@ async def _setup_ble( "homeassistant.components.teslemetry.helpers.TeslaBluetooth" ) as mock_parent, patch("homeassistant.components.teslemetry.PLATFORMS", list(platforms)), + patch("tesla_fleet_api.teslemetry.Teslemetry.products", return_value=products), ): mock_parent.return_value.get_private_key = AsyncMock() mock_parent.return_value.vehicles.createBluetooth.return_value = ( @@ -433,3 +448,146 @@ async def test_lock_command_routes_through_api( "lock", "lock", {"entity_id": lock_id}, blocking=True ) router.door_lock.assert_awaited_once() + + +def _closures(sunroof: str | None, percent: int = 0) -> ClosuresState: + """Build a BLE closures snapshot with a sunroof oneof case and position.""" + closures = ClosuresState(sun_roof_percent_open=percent) + if sunroof is not None: + getattr(closures.sun_roof_state, sunroof).SetInParent() + return closures + + +def _make_active(bluetooth: MagicMock, mock_add_listener: MagicMock) -> None: + """Drive the manager to connected, awake, and parked.""" + # A broadcast proves the link is up. + _emit(bluetooth.listen_charge_port, ClosureState_E.CLOSURESTATE_CLOSED) + # On a cover-only setup the manager is the only sleep/gear listener. + _emit( + bluetooth.listen_vehicle_sleep_status, + VehicleSleepStatus_E.VEHICLE_SLEEP_STATUS_AWAKE, + ) + mock_add_listener.send({"vin": VIN, "data": {Signal.GEAR: "P"}}) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_sunroof_reads_when_parked_and_awake( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_add_listener: MagicMock, +) -> None: + """A parked, awake vehicle reads the sunroof over a single closures snapshot.""" + _entry, bluetooth = await _setup_ble( + hass, connected=True, platforms=(Platform.COVER,), sunroof=True + ) + sunroof_id = entity_registry.async_get_entity_id( + "cover", "teslemetry", f"{VIN}-vehicle_state_sun_roof_state" + ) + assert sunroof_id is not None + assert hass.states.get(sunroof_id).state == STATE_UNAVAILABLE + + bluetooth.closures_state = AsyncMock(return_value=_closures("Closed", 20)) + _make_active(bluetooth, mock_add_listener) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=6)) + await hass.async_block_till_done() + + bluetooth.closures_state.assert_awaited_once() + bluetooth.vehicle_data.assert_not_called() + state = hass.states.get(sunroof_id) + assert state.state == STATE_CLOSED + assert state.attributes["current_position"] == 20 + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.parametrize( + ("sleep", "gear"), + [ + (VehicleSleepStatus_E.VEHICLE_SLEEP_STATUS_AWAKE, "D"), + (VehicleSleepStatus_E.VEHICLE_SLEEP_STATUS_AWAKE, "Unknown"), + (VehicleSleepStatus_E.VEHICLE_SLEEP_STATUS_ASLEEP, "P"), + (VehicleSleepStatus_E.VEHICLE_SLEEP_STATUS_UNKNOWN, "P"), + ], +) +async def test_sunroof_gate_blocks_reads( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_add_listener: MagicMock, + sleep: int, + gear: str, +) -> None: + """Neither a moving nor an asleep vehicle issues an INFO read.""" + _entry, bluetooth = await _setup_ble( + hass, connected=True, platforms=(Platform.COVER,), sunroof=True + ) + sunroof_id = entity_registry.async_get_entity_id( + "cover", "teslemetry", f"{VIN}-vehicle_state_sun_roof_state" + ) + bluetooth.closures_state = AsyncMock(return_value=_closures("Closed")) + + _emit(bluetooth.listen_charge_port, ClosureState_E.CLOSURESTATE_CLOSED) + _emit(bluetooth.listen_vehicle_sleep_status, sleep) + mock_add_listener.send({"vin": VIN, "data": {Signal.GEAR: gear}}) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=6)) + await hass.async_block_till_done() + + bluetooth.closures_state.assert_not_called() + assert hass.states.get(sunroof_id).state == STATE_UNAVAILABLE + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_sunroof_rests_and_disconnects_after_idle( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_add_listener: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """After 15 idle minutes the manager stops reading and disconnects.""" + _entry, bluetooth = await _setup_ble( + hass, connected=True, platforms=(Platform.COVER,), sunroof=True + ) + sunroof_id = entity_registry.async_get_entity_id( + "cover", "teslemetry", f"{VIN}-vehicle_state_sun_roof_state" + ) + + bluetooth.closures_state = AsyncMock(return_value=_closures("Closed")) + _make_active(bluetooth, mock_add_listener) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=6)) + await hass.async_block_till_done() + assert hass.states.get(sunroof_id).state == STATE_CLOSED + + freezer.tick(timedelta(minutes=16)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + bluetooth.disconnect.assert_awaited() + assert bluetooth.closures_state.await_count == 1 + # The manager also marks the link down once disconnect takes effect. + 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(sunroof_id).state == STATE_UNAVAILABLE + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_sunroof_absent_makes_no_reads( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_add_listener: MagicMock, +) -> None: + """A vehicle without a sunroof has no entity and issues no INFO reads.""" + _entry, bluetooth = await _setup_ble( + hass, connected=True, platforms=(Platform.COVER,), sunroof=False + ) + assert ( + entity_registry.async_get_entity_id( + "cover", "teslemetry", f"{VIN}-vehicle_state_sun_roof_state" + ) + is None + ) + + bluetooth.closures_state = AsyncMock(return_value=_closures("Closed")) + _make_active(bluetooth, mock_add_listener) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=6)) + await hass.async_block_till_done() + + bluetooth.closures_state.assert_not_called() diff --git a/tests/components/teslemetry/test_bluetooth.py b/tests/components/teslemetry/test_bluetooth.py index 00e334e1f9fd0a..4da43615b6c4ea 100644 --- a/tests/components/teslemetry/test_bluetooth.py +++ b/tests/components/teslemetry/test_bluetooth.py @@ -40,6 +40,19 @@ BLE_RESULT = {"response": {"result": True, "reason": "bluetooth"}} +def _ble_backend() -> AsyncMock: + """Return a BLE backend mock whose synchronous listener registration stays sync. + + ``AsyncMock`` would otherwise make the manager's ``listen_*`` gate + registrations return coroutines instead of unsubscribe callables. + """ + backend = AsyncMock() + backend.listen_vehicle_sleep_status = MagicMock(return_value=MagicMock()) + backend.listen_user_presence = MagicMock(return_value=MagicMock()) + return backend + + + def _entry_with_ble() -> MockConfigEntry: """Return a config entry whose vehicle subentry is already BLE-paired.""" entry = mock_config_entry() @@ -117,7 +130,7 @@ async def _paired_entry( """ entry = _entry_with_ble() entry.add_to_hass(hass) - bluetooth_vehicle = AsyncMock() + bluetooth_vehicle = _ble_backend() bluetooth_vehicle.set_device = MagicMock() with ( @@ -271,7 +284,7 @@ async def test_vehicle_paired_but_never_seen(hass: HomeAssistant) -> None: patch("homeassistant.components.teslemetry.PLATFORMS", []), ): mock_parent.return_value.get_private_key = AsyncMock() - mock_parent.return_value.vehicles.createBluetooth.return_value = AsyncMock() + mock_parent.return_value.vehicles.createBluetooth.return_value = _ble_backend() await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() @@ -292,7 +305,7 @@ async def test_unload_disconnects_bluetooth( """Unloading a routed entry disconnects its Bluetooth backend, errors and all.""" entry = _entry_with_ble() entry.add_to_hass(hass) - bluetooth_vehicle = AsyncMock() + bluetooth_vehicle = _ble_backend() bluetooth_vehicle.disconnect = AsyncMock(side_effect=disconnect_error) with ( @@ -327,7 +340,7 @@ async def test_unload_never_connected_bluetooth(hass: HomeAssistant) -> None: """ entry = _entry_with_ble() entry.add_to_hass(hass) - bluetooth_vehicle = AsyncMock() + bluetooth_vehicle = _ble_backend() with ( patch( From c4124b5c2c10f487cfe098ffd403bbaea0a2b28c Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Fri, 31 Jul 2026 15:29:07 +1000 Subject: [PATCH 2/3] Format bluetooth test after adding the backend mock helper --- tests/components/teslemetry/test_bluetooth.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/components/teslemetry/test_bluetooth.py b/tests/components/teslemetry/test_bluetooth.py index 4da43615b6c4ea..ae8c1ef3446927 100644 --- a/tests/components/teslemetry/test_bluetooth.py +++ b/tests/components/teslemetry/test_bluetooth.py @@ -52,7 +52,6 @@ def _ble_backend() -> AsyncMock: return backend - def _entry_with_ble() -> MockConfigEntry: """Return a config entry whose vehicle subentry is already BLE-paired.""" entry = mock_config_entry() From d38a161f21adcc9dddc717c9dd0d159783f3ae11 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Fri, 31 Jul 2026 15:38:32 +1000 Subject: [PATCH 3/3] Use contextlib.suppress for the idle disconnect (ruff SIM105) --- homeassistant/components/teslemetry/ble.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/teslemetry/ble.py b/homeassistant/components/teslemetry/ble.py index 401803bb61119f..663a1637e69cae 100644 --- a/homeassistant/components/teslemetry/ble.py +++ b/homeassistant/components/teslemetry/ble.py @@ -14,6 +14,7 @@ import asyncio from collections.abc import Awaitable, Callable +import contextlib from datetime import datetime, timedelta from typing import Any, override @@ -314,10 +315,8 @@ def _enter_rest(self) -> None: async def _async_disconnect(self) -> None: """Drop the link so the vehicle can sleep; never surface failures.""" - try: + with contextlib.suppress(BleakError, TeslaFleetError, TimeoutError): await self._bluetooth.disconnect() - except BleakError, TeslaFleetError, TimeoutError: - pass @callback def _async_scheduler_tick(self, now: datetime) -> None: