diff --git a/homeassistant/components/teslemetry/__init__.py b/homeassistant/components/teslemetry/__init__.py index f03a67af78ccf2..7502d930127063 100644 --- a/homeassistant/components/teslemetry/__init__.py +++ b/homeassistant/components/teslemetry/__init__.py @@ -529,7 +529,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) - # primary; local data reads take broadcasts from it, never the router. ble: TeslemetryBLEDataManager | None = None if isinstance(vehicle_api, VehicleRouter): - ble = TeslemetryBLEDataManager(hass, vehicle_api.primary, vin) + ble = TeslemetryBLEDataManager( + hass, vehicle_api.primary, 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 c736d2f5ad5198..4c7eb3428dd993 100644 --- a/homeassistant/components/teslemetry/ble.py +++ b/homeassistant/components/teslemetry/ble.py @@ -1,48 +1,109 @@ """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 +import contextlib +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 +# 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_connection: 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: @@ -61,17 +122,35 @@ def connected(self) -> bool: @callback def async_start(self) -> None: - """Subscribe to the library's BLE connection-status events.""" + """Subscribe to connection-status and gate signals, and run the scheduler.""" self._unsub_connection = self._bluetooth.listen_connection_status( self._handle_connection_status ) + 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 listening for connection-status events on unload.""" + """Stop the connection listener, scheduler, and gate listeners on unload.""" if self._unsub_connection is not None: self._unsub_connection() self._unsub_connection = 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 _handle_connection_status(self, connected: bool) -> None: @@ -125,9 +204,159 @@ 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.""" + with contextlib.suppress(BleakError, TeslaFleetError, TimeoutError): + await self._bluetooth.disconnect() + + @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 @@ -160,7 +389,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 5a39656493f9e5..5b8b31c4054da8 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 37dcd6928a7370..ece7af3e1a0a69 100644 --- a/tests/components/teslemetry/test_ble.py +++ b/tests/components/teslemetry/test_ble.py @@ -1,9 +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 @@ -14,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 @@ -28,10 +35,12 @@ ) 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 .const import PRODUCTS -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed VIN = "LRW3F7EK4NC700000" ADDRESS = "AA:BB:CC:DD:EE:FF" @@ -61,6 +70,7 @@ async def _setup_ble( hass: HomeAssistant, connected: bool = True, platforms: tuple[Platform, ...] = (Platform.BINARY_SENSOR,), + sunroof: bool = False, ) -> tuple[MockConfigEntry, MagicMock]: """Set up the given platforms for a BLE-paired vehicle. @@ -68,12 +78,18 @@ async def _setup_ble( 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. + once a session is established. ``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() + bluetooth_vehicle.disconnect = AsyncMock() + + products = deepcopy(PRODUCTS) + if sunroof: + products["response"][0]["vehicle_config"]["sun_roof_installed"] = True with ( patch( @@ -84,6 +100,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 = ( @@ -466,3 +483,144 @@ 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 an already-connected manager to awake and parked.""" + _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 library reports the link down once the scheduler's disconnect lands. + _emit_connection(bluetooth, False) + 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 35b5ea951e2b8e..007af1461063f3 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_connection_status = MagicMock(return_value=MagicMock()) + 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,10 +305,8 @@ 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) - # listen_connection_status is synchronous and returns an unsubscribe callable. - bluetooth_vehicle.listen_connection_status = MagicMock(return_value=MagicMock()) with ( patch( @@ -329,9 +340,7 @@ 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()) + bluetooth_vehicle = _ble_backend() with ( patch(