Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion homeassistant/components/tesla_fleet/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
}
14 changes: 14 additions & 0 deletions homeassistant/components/teslemetry/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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,
Expand All @@ -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,
)
)

Expand Down
107 changes: 105 additions & 2 deletions homeassistant/components/teslemetry/binary_sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -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,
Expand All @@ -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."""
Expand All @@ -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, ...] = (
Expand All @@ -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(
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand All @@ -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(
Expand All @@ -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(
Expand All @@ -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(
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
):
Expand Down
Loading
Loading