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/__init__.py b/homeassistant/components/teslemetry/__init__.py index 01d9fe56729406..f03a67af78ccf2 100644 --- a/homeassistant/components/teslemetry/__init__.py +++ b/homeassistant/components/teslemetry/__init__.py @@ -48,6 +48,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, @@ -345,6 +346,10 @@ async def _async_resolve_vehicle_api( 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: @@ -520,6 +525,14 @@ 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 isinstance(vehicle_api, VehicleRouter): + ble = TeslemetryBLEDataManager(hass, vehicle_api.primary, vin) + ble.async_start() + entry.async_on_unload(ble.async_stop) + vehicles.append( TeslemetryVehicleData( api=vehicle_api, @@ -532,6 +545,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..ef4de773bc38f0 100644 --- a/homeassistant/components/teslemetry/binary_sensor.py +++ b/homeassistant/components/teslemetry/binary_sensor.py @@ -2,9 +2,16 @@ 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 + +# pylint: disable-next=no-name-in-module +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 +26,7 @@ from homeassistant.helpers.typing import StateType from . import TeslemetryConfigEntry +from .ble import BroadcastRegister, TeslemetryVehicleBluetoothEntity from .const import TeslemetryState from .entity import ( TeslemetryEnergyInfoEntity, @@ -37,6 +45,37 @@ } +def _closure_is_open(value: int) -> bool | None: + """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 + + +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): """Describes Teslemetry binary sensor entity.""" @@ -51,6 +90,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 +100,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 +195,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 +274,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 +285,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 +296,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 +309,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 +614,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 +713,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..c736d2f5ad5198 --- /dev/null +++ b/homeassistant/components/teslemetry/ble.py @@ -0,0 +1,176 @@ +"""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 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 .entity import TeslemetryRootEntity +from .models import TeslemetryVehicleData + +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_connection: 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: + """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 listening for connection-status events on unload.""" + if self._unsub_connection is not None: + self._unsub_connection() + self._unsub_connection = None + + @callback + 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 + 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: + 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 + api: Vehicle | VehicleRouter + _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 + # 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}" + 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/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/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/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 new file mode 100644 index 00000000000000..9daa1fed7f6511 --- /dev/null +++ b/tests/components/teslemetry/test_ble.py @@ -0,0 +1,303 @@ +"""Test the Teslemetry BLE broadcast data source.""" + +from collections.abc import Callable +from typing import Any +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, + 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 . import mock_config_entry, setup_platform + +from tests.common import MockConfigEntry + +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 = 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; + ``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() + + 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() + + if connected: + _emit_connection(bluetooth_vehicle, True) + 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) + + +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: + """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: + """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" + ) + + _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_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) + 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 + + _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. + _emit_connection(bluetooth, True) + 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() 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(