mirror of
https://github.com/home-assistant/core.git
synced 2026-09-24 07:25:52 -05:00
Add Bluetooth control for Teslemetry vehicles (#176296)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
co-authored by
Copilot Autofix powered by AI
parent
8c64cf2248
commit
8f40062f29
@@ -8,16 +8,19 @@ from typing import Any, Final, cast
|
||||
|
||||
from aiohttp import ClientError
|
||||
from aiopowerwall import PowerwallClient, PowerwallEnergySite, PowerwallError
|
||||
from bleak.exc import BleakError
|
||||
from tesla_fleet_api.const import Scope
|
||||
from tesla_fleet_api.exceptions import (
|
||||
Forbidden,
|
||||
InvalidToken,
|
||||
LoginRequired,
|
||||
PrivateKeyError,
|
||||
SubscriptionRequired,
|
||||
TeslaFleetError,
|
||||
)
|
||||
from tesla_fleet_api.router import VehicleRouter
|
||||
from tesla_fleet_api.tesla import EnergySiteRouter
|
||||
from tesla_fleet_api.teslemetry import EnergySite, Teslemetry
|
||||
from tesla_fleet_api.teslemetry import EnergySite, Teslemetry, Vehicle
|
||||
from teslemetry_stream import TeslemetryStream, TeslemetryStreamAuthenticationError
|
||||
from teslemetry_stream.const import SseTopic
|
||||
|
||||
@@ -25,8 +28,15 @@ from homeassistant.components.application_credentials import (
|
||||
ClientCredential,
|
||||
async_import_client_credential,
|
||||
)
|
||||
from homeassistant.components.bluetooth import async_ble_device_from_address
|
||||
from homeassistant.config_entries import ConfigEntry, ConfigEntryState
|
||||
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST, CONF_PASSWORD, Platform
|
||||
from homeassistant.const import (
|
||||
CONF_ACCESS_TOKEN,
|
||||
CONF_ADDRESS,
|
||||
CONF_HOST,
|
||||
CONF_PASSWORD,
|
||||
Platform,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import (
|
||||
ConfigEntryAuthFailed,
|
||||
@@ -50,11 +60,13 @@ from homeassistant.helpers.update_coordinator import UpdateFailed
|
||||
|
||||
from .const import (
|
||||
CLIENT_ID,
|
||||
CONF_VIN,
|
||||
DOMAIN,
|
||||
LOGGER,
|
||||
POWERWALL_KEY_FILE,
|
||||
RSA_PARENT_KEY,
|
||||
SUBENTRY_TYPE_ENERGY_SITE,
|
||||
SUBENTRY_TYPE_VEHICLE,
|
||||
VEHICLE_ISSUE_LEARN_MORE,
|
||||
)
|
||||
from .coordinator import (
|
||||
@@ -64,7 +76,7 @@ from .coordinator import (
|
||||
TeslemetryMetadataCoordinator,
|
||||
TeslemetryVehicleDataCoordinator,
|
||||
)
|
||||
from .helpers import async_update_device_sw_version, flatten
|
||||
from .helpers import async_get_ble_parent, async_update_device_sw_version, flatten
|
||||
from .models import TeslemetryData, TeslemetryEnergyData, TeslemetryVehicleData
|
||||
from .services import async_setup_services
|
||||
|
||||
@@ -271,6 +283,71 @@ def _setup_vehicle_repairs(
|
||||
)
|
||||
|
||||
|
||||
def _ble_address_for_vin(entry: TeslemetryConfigEntry, vin: str) -> str | None:
|
||||
"""Return the paired Bluetooth address for a vehicle, if one was added."""
|
||||
for subentry in entry.subentries.values():
|
||||
if (
|
||||
subentry.subentry_type == SUBENTRY_TYPE_VEHICLE
|
||||
and subentry.data.get(CONF_VIN) == vin
|
||||
):
|
||||
return subentry.data.get(CONF_ADDRESS)
|
||||
return None
|
||||
|
||||
|
||||
# Two failure shapes must be caught to fall back to cloud control: the library
|
||||
# wraps existing-key failures in PrivateKeyError, the create-race path raises raw errors.
|
||||
_BLE_KEY_ERRORS: Final = (
|
||||
OSError,
|
||||
ValueError,
|
||||
AssertionError,
|
||||
TypeError,
|
||||
PrivateKeyError,
|
||||
)
|
||||
|
||||
|
||||
async def _async_resolve_vehicle_api(
|
||||
hass: HomeAssistant,
|
||||
entry: TeslemetryConfigEntry,
|
||||
vin: str,
|
||||
cloud_vehicle: Vehicle,
|
||||
) -> Vehicle | VehicleRouter:
|
||||
"""Return the API a vehicle's platforms should call."""
|
||||
address = _ble_address_for_vin(entry, vin)
|
||||
if not address:
|
||||
return cloud_vehicle
|
||||
|
||||
# A bad BLE key file for one vehicle must not tear down the whole entry.
|
||||
try:
|
||||
parent = await async_get_ble_parent(hass)
|
||||
except _BLE_KEY_ERRORS:
|
||||
LOGGER.warning(
|
||||
"Failed to load the Bluetooth key for vehicle %s; "
|
||||
"falling back to cloud control",
|
||||
vin,
|
||||
exc_info=True,
|
||||
)
|
||||
return cloud_vehicle
|
||||
# disable keep alive to allow vehicles to sleep
|
||||
bluetooth_vehicle = parent.vehicles.createBluetooth(
|
||||
vin,
|
||||
confirmation="verify",
|
||||
raise_unconfirmed=False,
|
||||
keepalive_interval=None,
|
||||
)
|
||||
|
||||
@callback
|
||||
def _in_range() -> bool:
|
||||
"""Report whether the vehicle is currently reachable over Bluetooth."""
|
||||
device = async_ble_device_from_address(hass, address, connectable=True)
|
||||
if device is None:
|
||||
return False
|
||||
# The library never refreshes the BLE handle, so set it here while it is known fresh.
|
||||
bluetooth_vehicle.set_device(device)
|
||||
return True
|
||||
|
||||
return VehicleRouter(bluetooth_vehicle, cloud_vehicle, health=_in_range)
|
||||
|
||||
|
||||
def _find_energy_subentry_id(entry: TeslemetryConfigEntry, site_id: int) -> str | None:
|
||||
"""Return the user-added local-control subentry id bound to site_id, if any."""
|
||||
return next(
|
||||
@@ -333,20 +410,17 @@ async def _async_get_rsa_key_pem(hass: HomeAssistant) -> bytes:
|
||||
pem: bytes | None = hass.data.get(RSA_PARENT_KEY)
|
||||
if pem is None:
|
||||
path = hass.config.path(POWERWALL_KEY_FILE)
|
||||
try:
|
||||
await Teslemetry(
|
||||
session=async_get_clientsession(hass), access_token=""
|
||||
).get_rsa_private_key(path)
|
||||
except TypeError as err:
|
||||
# An encrypted PEM surfaces as TypeError from the cryptography loader.
|
||||
raise ValueError("RSA private key file is encrypted") from err
|
||||
await Teslemetry(
|
||||
session=async_get_clientsession(hass), access_token=""
|
||||
).get_rsa_private_key(path)
|
||||
pem = await hass.async_add_executor_job(Path(path).read_bytes)
|
||||
hass.data[RSA_PARENT_KEY] = pem
|
||||
return pem
|
||||
|
||||
|
||||
# aiopowerwall raises PowerwallError; key I/O and parsing raise OSError/ValueError.
|
||||
_LOCAL_CONTROL_ERRORS: Final = (OSError, ValueError, PowerwallError)
|
||||
# Both key-load failure shapes (wrapped PrivateKeyError, raw OSError/ValueError) plus
|
||||
# PowerwallError must be caught to fall back to cloud control.
|
||||
_LOCAL_CONTROL_ERRORS: Final = (OSError, ValueError, PowerwallError, PrivateKeyError)
|
||||
|
||||
|
||||
async def _async_resolve_local_control(
|
||||
@@ -526,9 +600,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) -
|
||||
)
|
||||
stream_vehicle = stream.get_vehicle(vin)
|
||||
|
||||
vehicle_api = await _async_resolve_vehicle_api(
|
||||
hass,
|
||||
entry,
|
||||
vin,
|
||||
vehicle,
|
||||
)
|
||||
|
||||
vehicles.append(
|
||||
TeslemetryVehicleData(
|
||||
api=vehicle,
|
||||
api=vehicle_api,
|
||||
config_entry=entry,
|
||||
coordinator=coordinator,
|
||||
poll=poll,
|
||||
@@ -697,7 +778,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) -
|
||||
def _setup_subentry_change_reload(
|
||||
hass: HomeAssistant, entry: TeslemetryConfigEntry
|
||||
) -> None:
|
||||
"""Reload the entry when a local-energy-site subentry is added or removed."""
|
||||
"""Reload the entry when a subentry is added or removed."""
|
||||
known = set(entry.subentries)
|
||||
|
||||
async def _handle_update(
|
||||
@@ -818,7 +899,20 @@ async def _async_setup_energy_site(
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) -> bool:
|
||||
"""Unload Teslemetry Config."""
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
unloaded = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
if unloaded:
|
||||
# Release any on-demand Bluetooth link only after platforms unloaded, or the still-loaded entry's backends must keep working.
|
||||
for vehicle in entry.runtime_data.vehicles:
|
||||
if isinstance(vehicle.api, VehicleRouter):
|
||||
try:
|
||||
await vehicle.api.primary.disconnect()
|
||||
except (BleakError, TeslaFleetError, TimeoutError) as err:
|
||||
# Swallowed so one stuck link cannot block the unload, but
|
||||
# warn: a leaked BLE connection can keep the vehicle awake.
|
||||
LOGGER.warning(
|
||||
"Error disconnecting Bluetooth for %s: %s", vehicle.vin, err
|
||||
)
|
||||
return unloaded
|
||||
|
||||
|
||||
async def async_migrate_entry(
|
||||
|
||||
@@ -5,6 +5,7 @@ from dataclasses import dataclass
|
||||
from typing import Any, override
|
||||
|
||||
from tesla_fleet_api.const import Scope
|
||||
from tesla_fleet_api.router import VehicleRouter
|
||||
from tesla_fleet_api.teslemetry import Vehicle
|
||||
|
||||
from homeassistant.components.button import ButtonEntity, ButtonEntityDescription
|
||||
@@ -75,7 +76,7 @@ async def async_setup_entry(
|
||||
class TeslemetryButtonEntity(TeslemetryVehicleStreamEntity, ButtonEntity):
|
||||
"""Base class for Teslemetry buttons."""
|
||||
|
||||
api: Vehicle
|
||||
api: Vehicle | VehicleRouter
|
||||
entity_description: TeslemetryButtonEntityDescription
|
||||
|
||||
def __init__(
|
||||
|
||||
@@ -5,6 +5,7 @@ from typing import Any, cast, override
|
||||
|
||||
from tesla_fleet_api import firmware_at_least
|
||||
from tesla_fleet_api.const import CabinOverheatProtectionTemp, Scope
|
||||
from tesla_fleet_api.router import VehicleRouter
|
||||
from tesla_fleet_api.teslemetry import Vehicle
|
||||
|
||||
from homeassistant.components.climate import (
|
||||
@@ -90,7 +91,7 @@ async def async_setup_entry(
|
||||
class TeslemetryClimateEntity(TeslemetryRootEntity, ClimateEntity):
|
||||
"""Vehicle Climate Control."""
|
||||
|
||||
api: Vehicle
|
||||
api: Vehicle | VehicleRouter
|
||||
_attr_precision = PRECISION_HALVES
|
||||
_attr_temperature_unit = UnitOfTemperature.CELSIUS
|
||||
_attr_hvac_modes = [HVACMode.HEAT_COOL, HVACMode.OFF]
|
||||
@@ -385,7 +386,7 @@ COP_LEVELS = {
|
||||
class TeslemetryCabinOverheatProtectionEntity(TeslemetryRootEntity, ClimateEntity):
|
||||
"""Vehicle Cabin Overheat Protection."""
|
||||
|
||||
api: Vehicle
|
||||
api: Vehicle | VehicleRouter
|
||||
_attr_precision = PRECISION_WHOLE
|
||||
_attr_target_temperature_step = 5
|
||||
_attr_min_temp = 30
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Config Flow for Teslemetry integration."""
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Mapping
|
||||
import logging
|
||||
from pathlib import Path
|
||||
@@ -7,16 +8,23 @@ from typing import TYPE_CHECKING, Any, cast, override
|
||||
|
||||
from aiohttp import ClientError
|
||||
from aiopowerwall import PowerwallAuthenticationError, PowerwallClient, PowerwallError
|
||||
from bleak.exc import BleakError
|
||||
from tesla_fleet_api.const import (
|
||||
AuthorizedClientKeyType,
|
||||
AuthorizedClientState,
|
||||
AuthorizedClientType,
|
||||
)
|
||||
from tesla_fleet_api.exceptions import (
|
||||
BluetoothTimeout,
|
||||
BluetoothTransportError,
|
||||
InvalidToken,
|
||||
NotOnWhitelistFault,
|
||||
PrivateKeyError,
|
||||
SubscriptionRequired,
|
||||
TeslaFleetError,
|
||||
WhitelistOperationAttemptingToAddExistingKey,
|
||||
)
|
||||
from tesla_fleet_api.tesla.vehicle.bluetooth import VehicleBluetooth
|
||||
from tesla_fleet_api.teslemetry import Teslemetry
|
||||
from tesla_fleet_api.teslemetry.energysite import AuthorizedClient, TeslemetryEnergySite
|
||||
import voluptuous as vol
|
||||
@@ -25,6 +33,10 @@ from homeassistant.components.application_credentials import (
|
||||
ClientCredential,
|
||||
async_import_client_credential,
|
||||
)
|
||||
from homeassistant.components.bluetooth import (
|
||||
async_discovered_service_info,
|
||||
async_request_active_scan,
|
||||
)
|
||||
from homeassistant.config_entries import (
|
||||
SOURCE_REAUTH,
|
||||
SOURCE_RECONFIGURE,
|
||||
@@ -34,20 +46,29 @@ from homeassistant.config_entries import (
|
||||
ConfigSubentryFlow,
|
||||
SubentryFlowResult,
|
||||
)
|
||||
from homeassistant.const import CONF_HOST, CONF_PASSWORD
|
||||
from homeassistant.const import CONF_ADDRESS, CONF_HOST, CONF_PASSWORD
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers import config_entry_oauth2_flow
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.selector import (
|
||||
SelectOptionDict,
|
||||
SelectSelector,
|
||||
SelectSelectorConfig,
|
||||
SelectSelectorMode,
|
||||
)
|
||||
|
||||
from . import TeslemetryConfigEntry
|
||||
from . import _BLE_KEY_ERRORS, TeslemetryConfigEntry
|
||||
from .const import (
|
||||
CLIENT_ID,
|
||||
CONF_SITE_ID,
|
||||
CONF_VIN,
|
||||
DOMAIN,
|
||||
LOGGER,
|
||||
POWERWALL_KEY_FILE,
|
||||
SUBENTRY_TYPE_ENERGY_SITE,
|
||||
SUBENTRY_TYPE_VEHICLE,
|
||||
)
|
||||
from .helpers import async_get_ble_parent
|
||||
|
||||
|
||||
class PowerwallLookupError(Exception):
|
||||
@@ -85,7 +106,10 @@ class OAuth2FlowHandler(
|
||||
cls, config_entry: ConfigEntry
|
||||
) -> dict[str, type[ConfigSubentryFlow]]:
|
||||
"""Return the subentry types supported by this integration."""
|
||||
return {SUBENTRY_TYPE_ENERGY_SITE: EnergySiteSubentryFlowHandler}
|
||||
return {
|
||||
SUBENTRY_TYPE_VEHICLE: VehicleSubentryFlowHandler,
|
||||
SUBENTRY_TYPE_ENERGY_SITE: EnergySiteSubentryFlowHandler,
|
||||
}
|
||||
|
||||
@override
|
||||
async def async_step_user(
|
||||
@@ -187,6 +211,210 @@ class OAuth2FlowHandler(
|
||||
return await self.async_step_user()
|
||||
|
||||
|
||||
class VehicleSubentryFlowHandler(ConfigSubentryFlow):
|
||||
"""Add local Bluetooth control to one of the account's vehicles."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the vehicle subentry flow."""
|
||||
self._vin: str | None = None
|
||||
self._title: str | None = None
|
||||
self._address: str | None = None
|
||||
self._vehicle: VehicleBluetooth | None = None
|
||||
self._pair_task: asyncio.Task[None] | None = None
|
||||
self._pair_error: dict[str, str] = {}
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> SubentryFlowResult:
|
||||
"""Select an account vehicle to add over Bluetooth, then pair it."""
|
||||
entry = self._get_entry()
|
||||
if entry.state is not ConfigEntryState.LOADED:
|
||||
return self.async_abort(reason="entry_not_loaded")
|
||||
already_added = {
|
||||
subentry.data[CONF_VIN]
|
||||
for subentry in entry.get_subentries_of_type(SUBENTRY_TYPE_VEHICLE)
|
||||
if CONF_VIN in subentry.data
|
||||
}
|
||||
choices = {
|
||||
vehicle.vin: vehicle.device["name"] or vehicle.vin
|
||||
for vehicle in entry.runtime_data.vehicles
|
||||
if vehicle.vin not in already_added
|
||||
}
|
||||
if not choices:
|
||||
return self.async_abort(reason="no_vehicles")
|
||||
|
||||
if user_input is not None:
|
||||
self._vin = user_input[CONF_VIN]
|
||||
self._title = choices[self._vin]
|
||||
return await self.async_step_scan()
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_VIN): SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
options=[
|
||||
SelectOptionDict(value=vin, label=name)
|
||||
for vin, name in choices.items()
|
||||
],
|
||||
mode=SelectSelectorMode.DROPDOWN,
|
||||
)
|
||||
)
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
async def async_step_scan(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> SubentryFlowResult:
|
||||
"""Find the vehicle over Bluetooth and connect to it."""
|
||||
if TYPE_CHECKING:
|
||||
assert self._vin is not None
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is not None:
|
||||
try:
|
||||
parent = await async_get_ble_parent(self.hass)
|
||||
except _BLE_KEY_ERRORS as err:
|
||||
LOGGER.debug("Bluetooth key load failed: %s", err)
|
||||
errors["base"] = "cannot_connect"
|
||||
else:
|
||||
# The advertised BLE name is a hash of the VIN; match on its prefix.
|
||||
expected = parent.get_name(self._vin)[:17]
|
||||
device = None
|
||||
# The name is only in scan responses, so an active scan may be needed to see it.
|
||||
await async_request_active_scan(self.hass)
|
||||
for info in async_discovered_service_info(self.hass, connectable=True):
|
||||
if info.name and info.name.startswith(expected):
|
||||
device = info.device
|
||||
self._address = info.address
|
||||
break
|
||||
|
||||
if device is None:
|
||||
errors["base"] = "device_not_found"
|
||||
else:
|
||||
# Uses default keepalive so the link survives the on-screen key-approval wait.
|
||||
self._vehicle = parent.vehicles.createBluetooth(
|
||||
self._vin, device=device
|
||||
)
|
||||
try:
|
||||
await self._vehicle.connect()
|
||||
except (BleakError, TeslaFleetError, TimeoutError) as err:
|
||||
LOGGER.error("Failed to connect over Bluetooth: %s", err)
|
||||
await self._async_disconnect()
|
||||
errors["base"] = "cannot_connect"
|
||||
else:
|
||||
return await self.async_step_pair()
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="scan",
|
||||
errors=errors,
|
||||
description_placeholders={"vin": self._vin},
|
||||
)
|
||||
|
||||
async def async_step_pair(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> SubentryFlowResult:
|
||||
"""Check whether the virtual key is already whitelisted on the vehicle."""
|
||||
if TYPE_CHECKING:
|
||||
assert self._vehicle is not None
|
||||
try:
|
||||
await self._vehicle.handshakeVehicleSecurity()
|
||||
except NotOnWhitelistFault:
|
||||
return await self.async_step_instructions()
|
||||
except (BleakError, TeslaFleetError, TimeoutError) as err:
|
||||
LOGGER.error("Bluetooth security handshake failed: %s", err)
|
||||
await self._async_disconnect()
|
||||
# The scan step owns the form; re-show it so a retry redoes scan and connect.
|
||||
return self.async_show_form(
|
||||
step_id="scan",
|
||||
errors={"base": "cannot_connect"},
|
||||
description_placeholders={"vin": self._vin or ""},
|
||||
)
|
||||
if TYPE_CHECKING:
|
||||
assert self._address is not None
|
||||
assert self._vin is not None
|
||||
await self._async_disconnect()
|
||||
return self.async_create_entry(
|
||||
title=self._title or self._vin,
|
||||
data={CONF_VIN: self._vin, CONF_ADDRESS: self._address},
|
||||
unique_id=self._vin,
|
||||
)
|
||||
|
||||
async def async_step_instructions(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> SubentryFlowResult:
|
||||
"""Ask the user to approve the virtual key on the vehicle touchscreen."""
|
||||
if user_input is not None:
|
||||
return await self.async_step_authorize()
|
||||
errors = self._pair_error
|
||||
self._pair_error = {}
|
||||
return self.async_show_form(
|
||||
step_id="instructions",
|
||||
errors=errors,
|
||||
description_placeholders={"vin": self._vin or ""},
|
||||
)
|
||||
|
||||
async def async_step_authorize(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> SubentryFlowResult:
|
||||
"""Add the virtual key to the vehicle while showing pairing progress."""
|
||||
if self._pair_task is None:
|
||||
if TYPE_CHECKING:
|
||||
assert self._vehicle is not None
|
||||
# pair() can take minutes, so run it as a progress task rather than blocking the flow.
|
||||
self._pair_task = self.hass.async_create_task(self._vehicle.pair())
|
||||
|
||||
if not self._pair_task.done():
|
||||
return self.async_show_progress(
|
||||
step_id="authorize",
|
||||
progress_action="pair",
|
||||
progress_task=self._pair_task,
|
||||
description_placeholders={"vin": self._vin or ""},
|
||||
)
|
||||
|
||||
task = self._pair_task
|
||||
self._pair_task = None
|
||||
try:
|
||||
task.result()
|
||||
except (BluetoothTransportError, BleakError) as err:
|
||||
LOGGER.debug("Bluetooth transport failed during pairing: %s", err)
|
||||
self._pair_error = {"base": "cannot_connect"}
|
||||
return self.async_show_progress_done(next_step_id="instructions")
|
||||
except (BluetoothTimeout, TimeoutError) as err:
|
||||
LOGGER.debug("Bluetooth pairing timed out: %s", err)
|
||||
self._pair_error = {"base": "timeout"}
|
||||
return self.async_show_progress_done(next_step_id="instructions")
|
||||
except WhitelistOperationAttemptingToAddExistingKey as err:
|
||||
LOGGER.debug("Virtual key is already on the whitelist: %s", err)
|
||||
except TeslaFleetError as err:
|
||||
LOGGER.error("Bluetooth pairing was rejected: %s", err)
|
||||
self._pair_error = {"base": "pair_failed"}
|
||||
return self.async_show_progress_done(next_step_id="instructions")
|
||||
return self.async_show_progress_done(next_step_id="pair")
|
||||
|
||||
async def _async_disconnect(self) -> None:
|
||||
"""Disconnect the BLE link, if any, and drop the reference to it."""
|
||||
vehicle = self._vehicle
|
||||
if vehicle is not None:
|
||||
try:
|
||||
await vehicle.disconnect()
|
||||
except (BleakError, TeslaFleetError, TimeoutError) as err:
|
||||
LOGGER.debug("Error disconnecting Bluetooth: %s", err)
|
||||
finally:
|
||||
self._vehicle = None
|
||||
|
||||
@callback
|
||||
@override
|
||||
def async_remove(self) -> None:
|
||||
"""Release resources if the flow is abandoned mid-pairing."""
|
||||
if self._pair_task is not None and not self._pair_task.done():
|
||||
self._pair_task.cancel()
|
||||
if self._vehicle is not None:
|
||||
self.hass.async_create_task(self._async_disconnect())
|
||||
|
||||
|
||||
class EnergySiteSubentryFlowHandler(ConfigSubentryFlow):
|
||||
"""Pair a local Powerwall gateway for TEDAPI v1r command routing."""
|
||||
|
||||
@@ -257,10 +485,7 @@ class EnergySiteSubentryFlowHandler(ConfigSubentryFlow):
|
||||
async def _prepare_energy_site(
|
||||
self, energy_site: TeslemetryEnergySite
|
||||
) -> SubentryFlowResult | None:
|
||||
"""Discover the gateway address and load the integration's RSA key.
|
||||
|
||||
Returns an abort result if the RSA key cannot be loaded, else None.
|
||||
"""
|
||||
"""Discover the gateway address and load the integration's RSA key."""
|
||||
self._energy_site = energy_site
|
||||
|
||||
try:
|
||||
@@ -277,15 +502,11 @@ class EnergySiteSubentryFlowHandler(ConfigSubentryFlow):
|
||||
session=async_get_clientsession(self.hass), access_token=""
|
||||
)
|
||||
try:
|
||||
try:
|
||||
await keyholder.get_rsa_private_key(path)
|
||||
except TypeError as err:
|
||||
# An encrypted PEM surfaces as TypeError from the cryptography loader.
|
||||
raise ValueError("RSA private key file is encrypted") from err
|
||||
await keyholder.get_rsa_private_key(path)
|
||||
self._key_pem = await self.hass.async_add_executor_job(
|
||||
Path(path).read_bytes
|
||||
)
|
||||
except (OSError, ValueError) as err:
|
||||
except (OSError, ValueError, PrivateKeyError) as err:
|
||||
LOGGER.debug("RSA key load failed: %s", err)
|
||||
return self.async_abort(reason="cannot_connect")
|
||||
self._public_key_der = keyholder.rsa_public_der_pkcs1
|
||||
|
||||
@@ -12,6 +12,12 @@ AUTHORIZE_URL = "https://teslemetry.com/connect"
|
||||
TOKEN_URL = "https://api.teslemetry.com/oauth/token"
|
||||
CLIENT_ID = "homeassistant"
|
||||
|
||||
SUBENTRY_TYPE_VEHICLE = "vehicle"
|
||||
CONF_VIN = "vin"
|
||||
VEHICLE_KEY_FILE = "tesla_vehicle.key"
|
||||
BLE_PARENT_KEY = f"{DOMAIN}_ble_parent"
|
||||
BLE_PARENT_LOCK_KEY = f"{DOMAIN}_ble_parent_lock"
|
||||
|
||||
SUBENTRY_TYPE_ENERGY_SITE = "energy_site"
|
||||
CONF_SITE_ID = "site_id"
|
||||
POWERWALL_KEY_FILE = "tesla_powerwall.key"
|
||||
|
||||
@@ -11,6 +11,7 @@ from tesla_fleet_api.const import (
|
||||
Trunk,
|
||||
WindowCommand,
|
||||
)
|
||||
from tesla_fleet_api.router import VehicleRouter
|
||||
from tesla_fleet_api.teslemetry import Vehicle
|
||||
from teslemetry_stream import Signal
|
||||
from teslemetry_stream.const import WindowState
|
||||
@@ -120,7 +121,7 @@ class CoverRestoreEntity(RestoreEntity, CoverEntity):
|
||||
class TeslemetryWindowEntity(TeslemetryRootEntity, CoverEntity):
|
||||
"""Base class for window cover entities."""
|
||||
|
||||
api: Vehicle
|
||||
api: Vehicle | VehicleRouter
|
||||
_attr_device_class = CoverDeviceClass.WINDOW
|
||||
_attr_supported_features = CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE
|
||||
|
||||
@@ -254,7 +255,7 @@ class TeslemetryChargePortEntity(
|
||||
):
|
||||
"""Base class for for charge port cover entities."""
|
||||
|
||||
api: Vehicle
|
||||
api: Vehicle | VehicleRouter
|
||||
_attr_device_class = CoverDeviceClass.DOOR
|
||||
_attr_supported_features = CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE
|
||||
|
||||
@@ -340,7 +341,7 @@ class TeslemetryStreamingChargePortEntity(
|
||||
class TeslemetryFrontTrunkEntity(TeslemetryRootEntity, CoverEntity):
|
||||
"""Base class for the front trunk cover entities."""
|
||||
|
||||
api: Vehicle
|
||||
api: Vehicle | VehicleRouter
|
||||
_attr_device_class = CoverDeviceClass.DOOR
|
||||
_attr_supported_features = CoverEntityFeature.OPEN
|
||||
|
||||
@@ -407,7 +408,7 @@ class TeslemetryStreamingFrontTrunkEntity(
|
||||
class TeslemetryRearTrunkEntity(TeslemetryRootEntity, CoverEntity):
|
||||
"""Cover entity for the rear trunk."""
|
||||
|
||||
api: Vehicle
|
||||
api: Vehicle | VehicleRouter
|
||||
_attr_device_class = CoverDeviceClass.DOOR
|
||||
_attr_supported_features = CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE
|
||||
|
||||
@@ -482,7 +483,7 @@ class TeslemetryStreamingRearTrunkEntity(
|
||||
class TeslemetrySunroofEntity(TeslemetryVehiclePollingEntity, CoverEntity):
|
||||
"""Cover entity for the sunroof."""
|
||||
|
||||
api: Vehicle
|
||||
api: Vehicle | VehicleRouter
|
||||
_attr_device_class = CoverDeviceClass.WINDOW
|
||||
_attr_supported_features = (
|
||||
CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE | CoverEntityFeature.STOP
|
||||
@@ -538,7 +539,7 @@ class TeslemetrySunroofEntity(TeslemetryVehiclePollingEntity, CoverEntity):
|
||||
class TeslemetryTonneauEntity(TeslemetryRootEntity, CoverEntity):
|
||||
"""Base class for the Cybertruck tonneau cover entity."""
|
||||
|
||||
api: Vehicle
|
||||
api: Vehicle | VehicleRouter
|
||||
_attr_device_class = CoverDeviceClass.DOOR
|
||||
_attr_supported_features = (
|
||||
CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE | CoverEntityFeature.STOP
|
||||
|
||||
@@ -4,6 +4,7 @@ from abc import abstractmethod
|
||||
from typing import Any, override
|
||||
|
||||
from tesla_fleet_api.const import Scope
|
||||
from tesla_fleet_api.router import VehicleRouter
|
||||
from tesla_fleet_api.tesla import EnergySiteRouter
|
||||
from tesla_fleet_api.teslemetry import EnergySite, Vehicle
|
||||
|
||||
@@ -106,7 +107,7 @@ class TeslemetryVehiclePollingEntity(TeslemetryPollingEntity):
|
||||
"""Parent class for Teslemetry Vehicle entities."""
|
||||
|
||||
_last_update: int = 0
|
||||
api: Vehicle
|
||||
api: Vehicle | VehicleRouter
|
||||
vehicle: TeslemetryVehicleData
|
||||
|
||||
def __init__(
|
||||
@@ -259,7 +260,7 @@ class TeslemetryWallConnectorEntity(TeslemetryPollingEntity):
|
||||
class TeslemetryVehicleStreamEntity(TeslemetryRootEntity):
|
||||
"""Parent class for Teslemetry Vehicle Stream entities."""
|
||||
|
||||
api: Vehicle
|
||||
api: Vehicle | VehicleRouter
|
||||
|
||||
def __init__(self, data: TeslemetryVehicleData, key: str) -> None:
|
||||
"""Initialize common aspects of a Teslemetry entity."""
|
||||
|
||||
@@ -1,15 +1,30 @@
|
||||
"""Teslemetry helper functions."""
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Awaitable
|
||||
from typing import Any
|
||||
|
||||
from tesla_fleet_api.exceptions import TeslaFleetError
|
||||
from tesla_fleet_api.tesla.bluetooth import TeslaBluetooth
|
||||
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import device_registry as dr, entity_registry as er
|
||||
|
||||
from .const import DOMAIN, LOGGER
|
||||
from .const import BLE_PARENT_KEY, BLE_PARENT_LOCK_KEY, DOMAIN, LOGGER, VEHICLE_KEY_FILE
|
||||
|
||||
|
||||
async def async_get_ble_parent(hass: HomeAssistant) -> TeslaBluetooth:
|
||||
"""Return a shared TeslaBluetooth parent with the private key loaded."""
|
||||
lock: asyncio.Lock = hass.data.setdefault(BLE_PARENT_LOCK_KEY, asyncio.Lock())
|
||||
async with lock:
|
||||
existing: TeslaBluetooth | None = hass.data.get(BLE_PARENT_KEY)
|
||||
if existing is not None:
|
||||
return existing
|
||||
parent = TeslaBluetooth()
|
||||
await parent.get_private_key(hass.config.path(VEHICLE_KEY_FILE))
|
||||
hass.data[BLE_PARENT_KEY] = parent
|
||||
return parent
|
||||
|
||||
|
||||
def flatten(
|
||||
|
||||
@@ -5,6 +5,7 @@ from typing import Any, override
|
||||
|
||||
from tesla_fleet_api import firmware_at_least
|
||||
from tesla_fleet_api.const import Scope
|
||||
from tesla_fleet_api.router import VehicleRouter
|
||||
from tesla_fleet_api.teslemetry import Vehicle
|
||||
|
||||
from homeassistant.components.lock import LockEntity
|
||||
@@ -64,7 +65,7 @@ async def async_setup_entry(
|
||||
class TeslemetryVehicleLockEntity(TeslemetryRootEntity, LockEntity):
|
||||
"""Base vehicle lock entity for Teslemetry."""
|
||||
|
||||
api: Vehicle
|
||||
api: Vehicle | VehicleRouter
|
||||
|
||||
@override
|
||||
async def async_lock(self, **kwargs: Any) -> None:
|
||||
@@ -141,7 +142,7 @@ class TeslemetryStreamingVehicleLockEntity(
|
||||
class TeslemetryCableLockEntity(TeslemetryRootEntity, LockEntity):
|
||||
"""Base cable Lock entity for Teslemetry."""
|
||||
|
||||
api: Vehicle
|
||||
api: Vehicle | VehicleRouter
|
||||
|
||||
@override
|
||||
async def async_lock(self, **kwargs: Any) -> None:
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "Teslemetry",
|
||||
"codeowners": ["@Bre77"],
|
||||
"config_flow": true,
|
||||
"dependencies": ["application_credentials"],
|
||||
"dependencies": ["application_credentials", "bluetooth_adapters"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/teslemetry",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_polling",
|
||||
|
||||
@@ -4,6 +4,7 @@ from typing import override
|
||||
|
||||
from tesla_fleet_api import firmware_at_least
|
||||
from tesla_fleet_api.const import Scope
|
||||
from tesla_fleet_api.router import VehicleRouter
|
||||
from tesla_fleet_api.teslemetry import Vehicle
|
||||
|
||||
from homeassistant.components.media_player import (
|
||||
@@ -64,7 +65,7 @@ async def async_setup_entry(
|
||||
class TeslemetryMediaEntity(TeslemetryRootEntity, MediaPlayerEntity):
|
||||
"""Base vehicle media player class."""
|
||||
|
||||
api: Vehicle
|
||||
api: Vehicle | VehicleRouter
|
||||
_attr_device_class = MediaPlayerDeviceClass.SPEAKER
|
||||
_attr_volume_step = VOLUME_STEP
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import asyncio
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from tesla_fleet_api.const import Scope
|
||||
from tesla_fleet_api.router import VehicleRouter
|
||||
from tesla_fleet_api.tesla import EnergySiteRouter
|
||||
from tesla_fleet_api.teslemetry import EnergySite, Vehicle
|
||||
from teslemetry_stream import TeslemetryStream, TeslemetryStreamVehicle
|
||||
@@ -35,7 +36,7 @@ class TeslemetryData:
|
||||
class TeslemetryVehicleData:
|
||||
"""Data for a vehicle in the Teslemetry integration."""
|
||||
|
||||
api: Vehicle
|
||||
api: Vehicle | VehicleRouter
|
||||
config_entry: ConfigEntry
|
||||
coordinator: TeslemetryVehicleDataCoordinator
|
||||
poll: bool
|
||||
|
||||
@@ -7,6 +7,7 @@ from typing import Any, override
|
||||
|
||||
from tesla_fleet_api import firmware_at_least
|
||||
from tesla_fleet_api.const import Scope
|
||||
from tesla_fleet_api.router import VehicleRouter
|
||||
from tesla_fleet_api.tesla import EnergySiteRouter
|
||||
from tesla_fleet_api.teslemetry import EnergySite, Vehicle
|
||||
from teslemetry_stream import TeslemetryStreamVehicle
|
||||
@@ -45,7 +46,7 @@ PARALLEL_UPDATES = 0
|
||||
class TeslemetryNumberVehicleEntityDescription(NumberEntityDescription):
|
||||
"""Describes Teslemetry Number entity."""
|
||||
|
||||
func: Callable[[Vehicle, int], Awaitable[Any]]
|
||||
func: Callable[[Vehicle | VehicleRouter, int], Awaitable[Any]]
|
||||
min_key: str | None = None
|
||||
max_key: str
|
||||
native_min_value: float
|
||||
@@ -171,7 +172,7 @@ async def async_setup_entry(
|
||||
class TeslemetryVehicleNumberEntity(TeslemetryRootEntity, NumberEntity):
|
||||
"""Vehicle number entity base class."""
|
||||
|
||||
api: Vehicle
|
||||
api: Vehicle | VehicleRouter
|
||||
entity_description: TeslemetryNumberVehicleEntityDescription
|
||||
|
||||
@override
|
||||
|
||||
@@ -6,6 +6,7 @@ from typing import Any, override
|
||||
|
||||
from tesla_fleet_api import firmware_at_least
|
||||
from tesla_fleet_api.const import EnergyExportMode, EnergyOperationMode, Scope, Seat
|
||||
from tesla_fleet_api.router import VehicleRouter
|
||||
from tesla_fleet_api.teslemetry import Vehicle
|
||||
from teslemetry_stream import TeslemetryStreamVehicle
|
||||
|
||||
@@ -43,7 +44,7 @@ LEVEL = {OFF: 0, LOW: 1, MEDIUM: 2, HIGH: 3}
|
||||
class TeslemetrySelectEntityDescription(SelectEntityDescription):
|
||||
"""Seat Heater entity description."""
|
||||
|
||||
select_fn: Callable[[Vehicle, int], Awaitable[Any]]
|
||||
select_fn: Callable[[Vehicle | VehicleRouter, int], Awaitable[Any]]
|
||||
supported_fn: Callable[[dict], bool] = lambda _: True
|
||||
streaming_listener: (
|
||||
Callable[
|
||||
@@ -273,7 +274,7 @@ async def async_setup_entry(
|
||||
class TeslemetrySelectEntity(TeslemetryRootEntity, SelectEntity):
|
||||
"""Parent vehicle select entity class."""
|
||||
|
||||
api: Vehicle
|
||||
api: Vehicle | VehicleRouter
|
||||
entity_description: TeslemetrySelectEntityDescription
|
||||
_climate: bool = False
|
||||
|
||||
|
||||
@@ -48,6 +48,7 @@
|
||||
"energy_site": {
|
||||
"abort": {
|
||||
"all_sites_added": "Every accessible energy site with a Powerwall on your Teslemetry account has already been added for local control.",
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"entry_not_loaded": "The Teslemetry account must be loaded before setting up local control. Try again once it has finished loading.",
|
||||
"no_powerwall": "Local control requires a Powerwall, and no energy site with one is currently accessible on your Teslemetry account."
|
||||
@@ -84,6 +85,46 @@
|
||||
"title": "Add local energy site"
|
||||
}
|
||||
}
|
||||
},
|
||||
"vehicle": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||
"entry_not_loaded": "The Teslemetry configuration entry is not loaded. Please ensure it is set up correctly before adding a vehicle.",
|
||||
"no_vehicles": "Every vehicle in your Teslemetry account has already been added over Bluetooth."
|
||||
},
|
||||
"entry_type": "Bluetooth vehicle",
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"device_not_found": "No matching Tesla vehicle was found nearby over Bluetooth. Make sure the vehicle is awake and within range, then try again.",
|
||||
"pair_failed": "The vehicle rejected the key. Make sure it is not in valet mode and does not already have the maximum number of keys, then try again.",
|
||||
"timeout": "Timed out waiting for the vehicle to accept the key. Try again after approving the key on the vehicle's touchscreen."
|
||||
},
|
||||
"initiate_flow": {
|
||||
"user": "Add Bluetooth vehicle"
|
||||
},
|
||||
"progress": {
|
||||
"pair": "Approve Home Assistant's virtual key on the vehicle's touchscreen. Waiting for the vehicle to accept the key."
|
||||
},
|
||||
"step": {
|
||||
"instructions": {
|
||||
"description": "Select **Submit**, then place your key card against the center console card reader of vehicle {vin} to approve Home Assistant's virtual key. This only needs to be done once.",
|
||||
"title": "Approve the virtual key"
|
||||
},
|
||||
"scan": {
|
||||
"description": "Home Assistant will look for vehicle {vin} over Bluetooth to enable local command control. Make sure the vehicle is awake and within Bluetooth range of a Home Assistant Bluetooth adapter, then continue.",
|
||||
"title": "Set up Bluetooth control"
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"vin": "Vehicle"
|
||||
},
|
||||
"data_description": {
|
||||
"vin": "The account vehicle to enable local Bluetooth control for."
|
||||
},
|
||||
"description": "Select which of your Teslemetry account vehicles to add over Bluetooth for local command control.",
|
||||
"title": "Add Bluetooth vehicle"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
|
||||
@@ -6,6 +6,7 @@ from typing import Any, override
|
||||
|
||||
from tesla_fleet_api import firmware_at_least
|
||||
from tesla_fleet_api.const import AutoSeat, Scope
|
||||
from tesla_fleet_api.router import VehicleRouter
|
||||
from tesla_fleet_api.teslemetry import Vehicle
|
||||
from teslemetry_stream import TeslemetryStreamVehicle
|
||||
|
||||
@@ -37,8 +38,8 @@ class TeslemetrySwitchEntityDescription(SwitchEntityDescription):
|
||||
"""Describes Teslemetry Switch entity."""
|
||||
|
||||
polling: bool = False
|
||||
on_func: Callable[[Vehicle], Awaitable[dict[str, Any]]]
|
||||
off_func: Callable[[Vehicle], Awaitable[dict[str, Any]]]
|
||||
on_func: Callable[[Vehicle | VehicleRouter], Awaitable[dict[str, Any]]]
|
||||
off_func: Callable[[Vehicle | VehicleRouter], Awaitable[dict[str, Any]]]
|
||||
scopes: list[Scope]
|
||||
value_func: Callable[[StateType], bool] = bool
|
||||
streaming_listener: Callable[
|
||||
@@ -200,7 +201,7 @@ async def async_setup_entry(
|
||||
class TeslemetryVehicleSwitchEntity(TeslemetryRootEntity, SwitchEntity):
|
||||
"""Base class for all Teslemetry switch entities."""
|
||||
|
||||
api: Vehicle
|
||||
api: Vehicle | VehicleRouter
|
||||
_attr_device_class = SwitchDeviceClass.SWITCH
|
||||
entity_description: TeslemetrySwitchEntityDescription
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ from typing import Any, override
|
||||
|
||||
from tesla_fleet_api import firmware_at_least
|
||||
from tesla_fleet_api.const import Scope
|
||||
from tesla_fleet_api.router import VehicleRouter
|
||||
from tesla_fleet_api.teslemetry import Vehicle
|
||||
|
||||
from homeassistant.components.update import (
|
||||
@@ -51,7 +52,7 @@ async def async_setup_entry(
|
||||
class TeslemetryUpdateEntity(TeslemetryRootEntity, UpdateEntity):
|
||||
"""Teslemetry Updates entity."""
|
||||
|
||||
api: Vehicle
|
||||
api: Vehicle | VehicleRouter
|
||||
_attr_supported_features = UpdateEntityFeature.PROGRESS
|
||||
|
||||
@override
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Test the Teslemetry config flow."""
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Generator
|
||||
from copy import deepcopy
|
||||
import time
|
||||
@@ -13,16 +14,24 @@ from aiopowerwall import (
|
||||
PowerwallConnectionError,
|
||||
PowerwallFaultError,
|
||||
)
|
||||
from bleak.exc import BleakError
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
import pytest
|
||||
from tesla_fleet_api.const import AuthorizedClientState
|
||||
from tesla_fleet_api.exceptions import (
|
||||
BluetoothTimeout,
|
||||
BluetoothTransportError,
|
||||
InvalidResponse,
|
||||
InvalidToken,
|
||||
NotOnWhitelistFault,
|
||||
PrivateKeyError,
|
||||
SubscriptionRequired,
|
||||
TeslaFleetError,
|
||||
WhitelistOperationAttemptingToAddExistingKey,
|
||||
)
|
||||
from tesla_fleet_api.tesla import VehicleRouter
|
||||
from tesla_fleet_api.tesla.bluetooth import TeslaBluetooth
|
||||
from tesla_fleet_api.teslemetry.energysite import AuthorizedClient, AuthorizedClients
|
||||
import voluptuous as vol
|
||||
|
||||
@@ -34,20 +43,27 @@ from homeassistant.components.teslemetry.const import (
|
||||
AUTHORIZE_URL,
|
||||
CLIENT_ID,
|
||||
CONF_SITE_ID,
|
||||
CONF_VIN,
|
||||
DOMAIN,
|
||||
SUBENTRY_TYPE_ENERGY_SITE,
|
||||
SUBENTRY_TYPE_VEHICLE,
|
||||
TOKEN_URL,
|
||||
)
|
||||
from homeassistant.config_entries import (
|
||||
SOURCE_USER,
|
||||
ConfigEntryState,
|
||||
ConfigSubentry,
|
||||
ConfigSubentryData,
|
||||
SubentryFlowResult,
|
||||
)
|
||||
from homeassistant.const import CONF_HOST, CONF_PASSWORD
|
||||
from homeassistant.const import CONF_ADDRESS, CONF_HOST, CONF_PASSWORD
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.data_entry_flow import FlowResultType
|
||||
from homeassistant.helpers import config_entry_oauth2_flow, device_registry as dr
|
||||
from homeassistant.helpers import (
|
||||
config_entry_oauth2_flow,
|
||||
device_registry as dr,
|
||||
entity_registry as er,
|
||||
)
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
||||
from . import mock_config_entry, setup_platform
|
||||
@@ -139,7 +155,7 @@ async def test_reauth(
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "reauth_confirm"
|
||||
|
||||
flows = hass.config_entries.flow.async_progress()
|
||||
flows = hass.config_entries.flow.async_progress_by_handler(DOMAIN)
|
||||
assert len(flows) == 1
|
||||
|
||||
# Progress from reauth_confirm to external OAuth step
|
||||
@@ -219,10 +235,7 @@ async def test_reauth_loaded_schedules_reload(
|
||||
hass_client_no_auth: ClientSessionGenerator,
|
||||
aioclient_mock: AiohttpClientMocker,
|
||||
) -> None:
|
||||
"""A data-only reauth schedules the reload itself to apply the token.
|
||||
|
||||
The subentry set is unchanged, so the update listener never reloads.
|
||||
"""
|
||||
"""A data-only reauth schedules the reload itself to apply the token."""
|
||||
mock_entry = await setup_platform(hass, [])
|
||||
assert mock_entry.state is ConfigEntryState.LOADED
|
||||
|
||||
@@ -680,6 +693,718 @@ async def test_migrate_error_from_future(
|
||||
assert entry.state is ConfigEntryState.MIGRATION_ERROR
|
||||
|
||||
|
||||
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},
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def _discovered_info() -> MagicMock:
|
||||
"""Return a fake discovered service info matching the test VIN."""
|
||||
info = MagicMock()
|
||||
info.name = TeslaBluetooth().get_name(VIN)
|
||||
info.address = ADDRESS
|
||||
info.device = MagicMock()
|
||||
return info
|
||||
|
||||
|
||||
def _mock_vehicle(*, on_whitelist: bool = True) -> AsyncMock:
|
||||
"""Return a mock VehicleBluetooth for the pairing flow."""
|
||||
vehicle = AsyncMock()
|
||||
if on_whitelist:
|
||||
vehicle.handshakeVehicleSecurity = AsyncMock()
|
||||
else:
|
||||
vehicle.handshakeVehicleSecurity = AsyncMock(
|
||||
side_effect=[NotOnWhitelistFault(), None]
|
||||
)
|
||||
return vehicle
|
||||
|
||||
|
||||
def _mock_ble_parent(vehicle: AsyncMock) -> MagicMock:
|
||||
"""Return a mock shared TeslaBluetooth parent for the pairing flow."""
|
||||
parent = MagicMock()
|
||||
parent.get_name.return_value = TeslaBluetooth().get_name(VIN)
|
||||
parent.vehicles.createBluetooth.return_value = vehicle
|
||||
return parent
|
||||
|
||||
|
||||
async def _setup_account_entry(hass: HomeAssistant) -> MockConfigEntry:
|
||||
"""Set up an account entry with no vehicle subentry."""
|
||||
entry = mock_config_entry()
|
||||
entry.add_to_hass(hass)
|
||||
with patch("homeassistant.components.teslemetry.PLATFORMS", []):
|
||||
await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
return entry
|
||||
|
||||
|
||||
async def _setup_paired_entry(hass: HomeAssistant) -> MockConfigEntry:
|
||||
"""Set up an entry whose only account vehicle is already BLE-paired."""
|
||||
entry = _entry_with_ble()
|
||||
entry.add_to_hass(hass)
|
||||
with (
|
||||
patch(
|
||||
"homeassistant.components.teslemetry.async_ble_device_from_address",
|
||||
return_value=None,
|
||||
),
|
||||
patch(
|
||||
"homeassistant.components.teslemetry.helpers.TeslaBluetooth"
|
||||
) as mock_parent,
|
||||
patch("homeassistant.components.teslemetry.PLATFORMS", []),
|
||||
):
|
||||
mock_parent.return_value.get_private_key = AsyncMock()
|
||||
mock_parent.return_value.vehicles.createBluetooth.return_value = AsyncMock()
|
||||
await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
return entry
|
||||
|
||||
|
||||
async def _start_pairing_at_scan(
|
||||
hass: HomeAssistant, entry: MockConfigEntry
|
||||
) -> SubentryFlowResult:
|
||||
"""Open the add flow and advance past VIN selection to the scan step."""
|
||||
result = await hass.config_entries.subentries.async_init(
|
||||
(entry.entry_id, SUBENTRY_TYPE_VEHICLE),
|
||||
context={"source": "user"},
|
||||
)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "user"
|
||||
|
||||
result = await hass.config_entries.subentries.async_configure(
|
||||
result["flow_id"], {CONF_VIN: VIN}
|
||||
)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "scan"
|
||||
return result
|
||||
|
||||
|
||||
async def test_subentry_pairing_already_whitelisted(hass: HomeAssistant) -> None:
|
||||
"""The add flow creates the subentry when the key is already whitelisted."""
|
||||
entry = await _setup_account_entry(hass)
|
||||
vehicle = _mock_vehicle(on_whitelist=True)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"homeassistant.components.teslemetry.config_flow.async_discovered_service_info",
|
||||
return_value=[_discovered_info()],
|
||||
),
|
||||
patch(
|
||||
"homeassistant.components.teslemetry.config_flow.async_get_ble_parent",
|
||||
return_value=_mock_ble_parent(vehicle),
|
||||
),
|
||||
patch.object(hass.config_entries, "async_schedule_reload"),
|
||||
):
|
||||
result = await _start_pairing_at_scan(hass, entry)
|
||||
result = await hass.config_entries.subentries.async_configure(
|
||||
result["flow_id"], {}
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
subentries = entry.get_subentries_of_type(SUBENTRY_TYPE_VEHICLE)
|
||||
assert len(subentries) == 1
|
||||
# The subentry is created atomically with its credentials, never identity-only.
|
||||
assert subentries[0].unique_id == VIN
|
||||
assert subentries[0].data == {CONF_VIN: VIN, CONF_ADDRESS: ADDRESS}
|
||||
vehicle.connect.assert_awaited_once()
|
||||
vehicle.disconnect.assert_awaited_once()
|
||||
|
||||
|
||||
async def test_subentry_pairing_duplicate_vin_aborts(hass: HomeAssistant) -> None:
|
||||
"""A second flow racing on the same VIN aborts with already_configured."""
|
||||
entry = await _setup_account_entry(hass)
|
||||
vehicle = _mock_vehicle(on_whitelist=True)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"homeassistant.components.teslemetry.config_flow.async_discovered_service_info",
|
||||
return_value=[_discovered_info()],
|
||||
),
|
||||
patch(
|
||||
"homeassistant.components.teslemetry.config_flow.async_get_ble_parent",
|
||||
return_value=_mock_ble_parent(vehicle),
|
||||
),
|
||||
patch.object(hass.config_entries, "async_schedule_reload"),
|
||||
):
|
||||
result = await _start_pairing_at_scan(hass, entry)
|
||||
# Simulate a concurrent flow that paired the same VIN first.
|
||||
hass.config_entries.async_add_subentry(
|
||||
entry,
|
||||
ConfigSubentry(
|
||||
data={CONF_VIN: VIN, CONF_ADDRESS: ADDRESS},
|
||||
subentry_type=SUBENTRY_TYPE_VEHICLE,
|
||||
title="Test",
|
||||
unique_id=VIN,
|
||||
),
|
||||
)
|
||||
result = await hass.config_entries.subentries.async_configure(
|
||||
result["flow_id"], {}
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "already_configured"
|
||||
# The pre-existing subentry from the winning flow is left untouched.
|
||||
assert len(entry.get_subentries_of_type(SUBENTRY_TYPE_VEHICLE)) == 1
|
||||
|
||||
|
||||
async def test_subentry_pairing_requires_key_approval(hass: HomeAssistant) -> None:
|
||||
"""Pairing walks through instructions and key install when not whitelisted."""
|
||||
entry = await _setup_account_entry(hass)
|
||||
vehicle = _mock_vehicle(on_whitelist=False)
|
||||
release = asyncio.Event()
|
||||
|
||||
async def _pair() -> None:
|
||||
await release.wait()
|
||||
|
||||
vehicle.pair = AsyncMock(side_effect=_pair)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"homeassistant.components.teslemetry.config_flow.async_discovered_service_info",
|
||||
return_value=[_discovered_info()],
|
||||
),
|
||||
patch(
|
||||
"homeassistant.components.teslemetry.config_flow.async_get_ble_parent",
|
||||
return_value=_mock_ble_parent(vehicle),
|
||||
),
|
||||
patch.object(hass.config_entries, "async_schedule_reload"),
|
||||
):
|
||||
result = await _start_pairing_at_scan(hass, entry)
|
||||
result = await hass.config_entries.subentries.async_configure(
|
||||
result["flow_id"], {}
|
||||
)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "instructions"
|
||||
|
||||
result = await hass.config_entries.subentries.async_configure(
|
||||
result["flow_id"], {}
|
||||
)
|
||||
assert result["type"] is FlowResultType.SHOW_PROGRESS
|
||||
assert result["progress_action"] == "pair"
|
||||
|
||||
release.set()
|
||||
await hass.async_block_till_done()
|
||||
result = await hass.config_entries.subentries.async_configure(result["flow_id"])
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
subentries = entry.get_subentries_of_type(SUBENTRY_TYPE_VEHICLE)
|
||||
assert len(subentries) == 1
|
||||
assert subentries[0].data == {CONF_VIN: VIN, CONF_ADDRESS: ADDRESS}
|
||||
vehicle.pair.assert_awaited_once()
|
||||
|
||||
|
||||
async def test_subentry_scan_connect_fails(hass: HomeAssistant) -> None:
|
||||
"""The scan step re-shows the form with an error when BLE connect fails."""
|
||||
entry = await _setup_account_entry(hass)
|
||||
vehicle = _mock_vehicle()
|
||||
vehicle.connect = AsyncMock(side_effect=BleakError("nope"))
|
||||
|
||||
with (
|
||||
patch(
|
||||
"homeassistant.components.teslemetry.config_flow.async_discovered_service_info",
|
||||
return_value=[_discovered_info()],
|
||||
),
|
||||
patch(
|
||||
"homeassistant.components.teslemetry.config_flow.async_get_ble_parent",
|
||||
return_value=_mock_ble_parent(vehicle),
|
||||
),
|
||||
):
|
||||
result = await _start_pairing_at_scan(hass, entry)
|
||||
result = await hass.config_entries.subentries.async_configure(
|
||||
result["flow_id"], {}
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "scan"
|
||||
assert result["errors"] == {"base": "cannot_connect"}
|
||||
# A failed pairing never creates a subentry.
|
||||
assert not entry.get_subentries_of_type(SUBENTRY_TYPE_VEHICLE)
|
||||
vehicle.disconnect.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("error", "expected"),
|
||||
[
|
||||
(BluetoothTimeout, "timeout"),
|
||||
(BluetoothTransportError, "cannot_connect"),
|
||||
(TeslaFleetError, "pair_failed"),
|
||||
],
|
||||
ids=["timeout", "transport", "rejected"],
|
||||
)
|
||||
async def test_subentry_authorize_failure(
|
||||
hass: HomeAssistant, error: type[TeslaFleetError], expected: str
|
||||
) -> None:
|
||||
"""Each pairing failure surfaces its own error, not a blanket timeout."""
|
||||
entry = await _setup_account_entry(hass)
|
||||
vehicle = _mock_vehicle(on_whitelist=False)
|
||||
release = asyncio.Event()
|
||||
|
||||
async def _pair() -> None:
|
||||
await release.wait()
|
||||
raise error
|
||||
|
||||
vehicle.pair = AsyncMock(side_effect=_pair)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"homeassistant.components.teslemetry.config_flow.async_discovered_service_info",
|
||||
return_value=[_discovered_info()],
|
||||
),
|
||||
patch(
|
||||
"homeassistant.components.teslemetry.config_flow.async_get_ble_parent",
|
||||
return_value=_mock_ble_parent(vehicle),
|
||||
),
|
||||
):
|
||||
result = await _start_pairing_at_scan(hass, entry)
|
||||
result = await hass.config_entries.subentries.async_configure(
|
||||
result["flow_id"], {}
|
||||
)
|
||||
assert result["step_id"] == "instructions"
|
||||
|
||||
result = await hass.config_entries.subentries.async_configure(
|
||||
result["flow_id"], {}
|
||||
)
|
||||
assert result["type"] is FlowResultType.SHOW_PROGRESS
|
||||
|
||||
release.set()
|
||||
await hass.async_block_till_done()
|
||||
result = await hass.config_entries.subentries.async_configure(result["flow_id"])
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "instructions"
|
||||
assert result["errors"] == {"base": expected}
|
||||
assert not entry.get_subentries_of_type(SUBENTRY_TYPE_VEHICLE)
|
||||
# pair() is a single bounded op; it is never re-sent.
|
||||
vehicle.pair.assert_awaited_once()
|
||||
|
||||
|
||||
async def test_subentry_authorize_existing_key_finishes(hass: HomeAssistant) -> None:
|
||||
"""Approving the key after a timeout, then retrying, completes the pairing."""
|
||||
entry = await _setup_account_entry(hass)
|
||||
vehicle = _mock_vehicle(on_whitelist=False)
|
||||
releases = [asyncio.Event(), asyncio.Event()]
|
||||
attempts = iter(
|
||||
zip(
|
||||
releases,
|
||||
[BluetoothTimeout(), WhitelistOperationAttemptingToAddExistingKey()],
|
||||
strict=True,
|
||||
)
|
||||
)
|
||||
|
||||
async def _pair() -> None:
|
||||
release, error = next(attempts)
|
||||
await release.wait()
|
||||
raise error
|
||||
|
||||
vehicle.pair = AsyncMock(side_effect=_pair)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"homeassistant.components.teslemetry.config_flow.async_discovered_service_info",
|
||||
return_value=[_discovered_info()],
|
||||
),
|
||||
patch(
|
||||
"homeassistant.components.teslemetry.config_flow.async_get_ble_parent",
|
||||
return_value=_mock_ble_parent(vehicle),
|
||||
),
|
||||
patch.object(hass.config_entries, "async_schedule_reload"),
|
||||
):
|
||||
result = await _start_pairing_at_scan(hass, entry)
|
||||
result = await hass.config_entries.subentries.async_configure(
|
||||
result["flow_id"], {}
|
||||
)
|
||||
assert result["step_id"] == "instructions"
|
||||
|
||||
result = await hass.config_entries.subentries.async_configure(
|
||||
result["flow_id"], {}
|
||||
)
|
||||
assert result["type"] is FlowResultType.SHOW_PROGRESS
|
||||
|
||||
releases[0].set()
|
||||
await hass.async_block_till_done()
|
||||
result = await hass.config_entries.subentries.async_configure(result["flow_id"])
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["errors"] == {"base": "timeout"}
|
||||
|
||||
result = await hass.config_entries.subentries.async_configure(
|
||||
result["flow_id"], {}
|
||||
)
|
||||
assert result["type"] is FlowResultType.SHOW_PROGRESS
|
||||
|
||||
releases[1].set()
|
||||
await hass.async_block_till_done()
|
||||
result = await hass.config_entries.subentries.async_configure(result["flow_id"])
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
subentries = entry.get_subentries_of_type(SUBENTRY_TYPE_VEHICLE)
|
||||
assert len(subentries) == 1
|
||||
assert subentries[0].data == {CONF_VIN: VIN, CONF_ADDRESS: ADDRESS}
|
||||
assert vehicle.pair.await_count == 2
|
||||
vehicle.disconnect.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"handshake_error",
|
||||
[
|
||||
pytest.param(TeslaFleetError(), id="tesla_fleet_error"),
|
||||
pytest.param(BleakError("boom"), id="bleak_error"),
|
||||
pytest.param(TimeoutError(), id="timeout_error"),
|
||||
],
|
||||
)
|
||||
async def test_subentry_handshake_error_recovers(
|
||||
hass: HomeAssistant, handshake_error: Exception
|
||||
) -> None:
|
||||
"""A handshake failure re-shows the scan form; retrying then pairs."""
|
||||
entry = await _setup_account_entry(hass)
|
||||
vehicle = _mock_vehicle()
|
||||
vehicle.handshakeVehicleSecurity = AsyncMock(side_effect=[handshake_error, None])
|
||||
vehicle.disconnect = AsyncMock(side_effect=BleakError("boom"))
|
||||
|
||||
with (
|
||||
patch(
|
||||
"homeassistant.components.teslemetry.config_flow.async_discovered_service_info",
|
||||
return_value=[_discovered_info()],
|
||||
),
|
||||
patch(
|
||||
"homeassistant.components.teslemetry.config_flow.async_get_ble_parent",
|
||||
return_value=_mock_ble_parent(vehicle),
|
||||
),
|
||||
patch.object(hass.config_entries, "async_schedule_reload"),
|
||||
):
|
||||
result = await _start_pairing_at_scan(hass, entry)
|
||||
result = await hass.config_entries.subentries.async_configure(
|
||||
result["flow_id"], {}
|
||||
)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "scan"
|
||||
assert result["errors"] == {"base": "cannot_connect"}
|
||||
assert not entry.get_subentries_of_type(SUBENTRY_TYPE_VEHICLE)
|
||||
|
||||
result = await hass.config_entries.subentries.async_configure(
|
||||
result["flow_id"], {}
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
subentries = entry.get_subentries_of_type(SUBENTRY_TYPE_VEHICLE)
|
||||
assert len(subentries) == 1
|
||||
assert subentries[0].data == {CONF_VIN: VIN, CONF_ADDRESS: ADDRESS}
|
||||
# Both the failed and successful attempts disconnected; the disconnect error is swallowed.
|
||||
assert vehicle.disconnect.await_count == 2
|
||||
|
||||
|
||||
async def test_subentry_pairing_abandoned(hass: HomeAssistant) -> None:
|
||||
"""Abandoning the flow mid-pairing cancels the pair task and disconnects."""
|
||||
entry = await _setup_account_entry(hass)
|
||||
vehicle = _mock_vehicle(on_whitelist=False)
|
||||
cancelled = asyncio.Event()
|
||||
|
||||
async def _pair() -> None:
|
||||
try:
|
||||
await asyncio.Event().wait()
|
||||
except asyncio.CancelledError:
|
||||
cancelled.set()
|
||||
raise
|
||||
|
||||
vehicle.pair = AsyncMock(side_effect=_pair)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"homeassistant.components.teslemetry.config_flow.async_discovered_service_info",
|
||||
return_value=[_discovered_info()],
|
||||
),
|
||||
patch(
|
||||
"homeassistant.components.teslemetry.config_flow.async_get_ble_parent",
|
||||
return_value=_mock_ble_parent(vehicle),
|
||||
),
|
||||
):
|
||||
result = await _start_pairing_at_scan(hass, entry)
|
||||
result = await hass.config_entries.subentries.async_configure(
|
||||
result["flow_id"], {}
|
||||
)
|
||||
result = await hass.config_entries.subentries.async_configure(
|
||||
result["flow_id"], {}
|
||||
)
|
||||
assert result["type"] is FlowResultType.SHOW_PROGRESS
|
||||
|
||||
hass.config_entries.subentries.async_abort(result["flow_id"])
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert cancelled.is_set()
|
||||
vehicle.disconnect.assert_awaited_once()
|
||||
# An abandoned pairing never creates a subentry.
|
||||
assert not entry.get_subentries_of_type(SUBENTRY_TYPE_VEHICLE)
|
||||
|
||||
|
||||
async def test_subentry_scan_device_not_found(hass: HomeAssistant) -> None:
|
||||
"""The scan step re-shows the form with an error when no device is found."""
|
||||
entry = await _setup_account_entry(hass)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"homeassistant.components.teslemetry.config_flow.async_discovered_service_info",
|
||||
return_value=[],
|
||||
),
|
||||
patch(
|
||||
"homeassistant.components.teslemetry.config_flow.async_get_ble_parent",
|
||||
return_value=MagicMock(),
|
||||
),
|
||||
):
|
||||
result = await _start_pairing_at_scan(hass, entry)
|
||||
result = await hass.config_entries.subentries.async_configure(
|
||||
result["flow_id"], {}
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "scan"
|
||||
assert result["errors"] == {"base": "device_not_found"}
|
||||
assert not entry.get_subentries_of_type(SUBENTRY_TYPE_VEHICLE)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"key_error",
|
||||
[
|
||||
pytest.param(OSError("disk gone"), id="os_error"),
|
||||
pytest.param(ValueError("bad key"), id="value_error"),
|
||||
# PrivateKeyError is the wrapped existing-key-file shape the scan step must recover from too.
|
||||
pytest.param(
|
||||
PrivateKeyError("malformed", "Not a valid PEM private key"),
|
||||
id="private_key_error",
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_subentry_scan_key_load_recovers(
|
||||
hass: HomeAssistant, key_error: Exception
|
||||
) -> None:
|
||||
"""A Bluetooth key-load failure re-shows the scan form; a loadable key then pairs."""
|
||||
entry = await _setup_account_entry(hass)
|
||||
vehicle = _mock_vehicle(on_whitelist=True)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"homeassistant.components.teslemetry.config_flow.async_discovered_service_info",
|
||||
return_value=[_discovered_info()],
|
||||
),
|
||||
patch(
|
||||
"homeassistant.components.teslemetry.config_flow.async_get_ble_parent",
|
||||
side_effect=[key_error, _mock_ble_parent(vehicle)],
|
||||
) as mock_ble_parent,
|
||||
patch.object(hass.config_entries, "async_schedule_reload"),
|
||||
):
|
||||
result = await _start_pairing_at_scan(hass, entry)
|
||||
result = await hass.config_entries.subentries.async_configure(
|
||||
result["flow_id"], {}
|
||||
)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "scan"
|
||||
assert result["errors"] == {"base": "cannot_connect"}
|
||||
assert not entry.get_subentries_of_type(SUBENTRY_TYPE_VEHICLE)
|
||||
|
||||
result = await hass.config_entries.subentries.async_configure(
|
||||
result["flow_id"], {}
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
subentries = entry.get_subentries_of_type(SUBENTRY_TYPE_VEHICLE)
|
||||
assert len(subentries) == 1
|
||||
assert subentries[0].data == {CONF_VIN: VIN, CONF_ADDRESS: ADDRESS}
|
||||
# Both attempts ran the key load; only the second one loaded a usable key.
|
||||
assert mock_ble_parent.call_count == 2
|
||||
vehicle.connect.assert_awaited_once()
|
||||
|
||||
|
||||
async def test_subentry_scan_finds_device_after_active_scan(
|
||||
hass: HomeAssistant,
|
||||
) -> None:
|
||||
"""An awake in-range car only in scan responses is found via active scan."""
|
||||
entry = await _setup_account_entry(hass)
|
||||
vehicle = _mock_vehicle()
|
||||
mock_discovered = MagicMock(return_value=[])
|
||||
|
||||
async def _active_scan(hass: HomeAssistant) -> None:
|
||||
mock_discovered.return_value = [_discovered_info()]
|
||||
|
||||
with (
|
||||
patch(
|
||||
"homeassistant.components.teslemetry.config_flow.async_discovered_service_info",
|
||||
mock_discovered,
|
||||
),
|
||||
patch(
|
||||
"homeassistant.components.teslemetry.config_flow.async_request_active_scan",
|
||||
AsyncMock(side_effect=_active_scan),
|
||||
) as mock_active_scan,
|
||||
patch(
|
||||
"homeassistant.components.teslemetry.config_flow.async_get_ble_parent",
|
||||
return_value=_mock_ble_parent(vehicle),
|
||||
),
|
||||
patch.object(hass.config_entries, "async_schedule_reload"),
|
||||
):
|
||||
result = await _start_pairing_at_scan(hass, entry)
|
||||
result = await hass.config_entries.subentries.async_configure(
|
||||
result["flow_id"], {}
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
mock_active_scan.assert_awaited_once()
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
subentries = entry.get_subentries_of_type(SUBENTRY_TYPE_VEHICLE)
|
||||
assert len(subentries) == 1
|
||||
assert subentries[0].data == {CONF_VIN: VIN, CONF_ADDRESS: ADDRESS}
|
||||
vehicle.connect.assert_awaited_once()
|
||||
|
||||
|
||||
async def test_subentry_add_flow_keeps_device_on_parent(
|
||||
hass: HomeAssistant,
|
||||
device_registry: dr.DeviceRegistry,
|
||||
entity_registry: er.EntityRegistry,
|
||||
) -> None:
|
||||
"""The add flow pairs an account vehicle without moving its device off the parent entry."""
|
||||
entry = mock_config_entry()
|
||||
entry.add_to_hass(hass)
|
||||
await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# No Bluetooth subentry exists until the user adds one.
|
||||
assert not entry.get_subentries_of_type(SUBENTRY_TYPE_VEHICLE)
|
||||
existing_device = device_registry.async_get_device_by_identifier(
|
||||
(DOMAIN, VIN), entry.entry_id
|
||||
)
|
||||
assert existing_device is not None
|
||||
# The device and its entities start on the parent entry, owned by no subentry.
|
||||
assert existing_device.config_subentry_id is None
|
||||
vehicle_entities = er.async_entries_for_device(
|
||||
entity_registry, existing_device.id, include_disabled_entities=True
|
||||
)
|
||||
assert vehicle_entities
|
||||
assert all(entity.config_subentry_id is None for entity in vehicle_entities)
|
||||
|
||||
vehicle = _mock_vehicle(on_whitelist=True)
|
||||
|
||||
result = await hass.config_entries.subentries.async_init(
|
||||
(entry.entry_id, SUBENTRY_TYPE_VEHICLE),
|
||||
context={"source": "user"},
|
||||
)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "user"
|
||||
|
||||
# async_schedule_reload is left unpatched so the real reload runs here with the
|
||||
# committed BLE address; keep the setup-time Bluetooth mocks active so it neither
|
||||
# writes the vehicle key file nor opens a real connection.
|
||||
with (
|
||||
patch(
|
||||
"homeassistant.components.teslemetry.config_flow.async_discovered_service_info",
|
||||
return_value=[_discovered_info()],
|
||||
),
|
||||
patch(
|
||||
"homeassistant.components.teslemetry.config_flow.async_get_ble_parent",
|
||||
return_value=_mock_ble_parent(vehicle),
|
||||
),
|
||||
patch(
|
||||
"homeassistant.components.teslemetry.async_ble_device_from_address",
|
||||
return_value=MagicMock(),
|
||||
),
|
||||
patch(
|
||||
"homeassistant.components.teslemetry.helpers.TeslaBluetooth"
|
||||
) as mock_parent,
|
||||
):
|
||||
mock_parent.return_value.get_private_key = AsyncMock()
|
||||
mock_parent.return_value.vehicles.createBluetooth.return_value = MagicMock()
|
||||
|
||||
result = await hass.config_entries.subentries.async_configure(
|
||||
result["flow_id"], {CONF_VIN: VIN}
|
||||
)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "scan"
|
||||
|
||||
result = await hass.config_entries.subentries.async_configure(
|
||||
result["flow_id"], {}
|
||||
)
|
||||
# The subentry commits after the flow step returns; its change listener
|
||||
# then schedules the reload, which runs to completion here.
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
|
||||
subentries = entry.get_subentries_of_type(SUBENTRY_TYPE_VEHICLE)
|
||||
assert len(subentries) == 1
|
||||
subentry = subentries[0]
|
||||
assert subentry.unique_id == VIN
|
||||
assert subentry.data == {CONF_VIN: VIN, CONF_ADDRESS: ADDRESS}
|
||||
|
||||
# The real reload picked up the stored address: the reloaded vehicle now
|
||||
# routes over Bluetooth instead of staying cloud-only.
|
||||
assert isinstance(entry.runtime_data.vehicles[0].api, VehicleRouter)
|
||||
|
||||
# The pairing reuses the vehicle's existing device, never a duplicate.
|
||||
bound_device = device_registry.async_get_device_by_identifier(
|
||||
(DOMAIN, VIN), entry.entry_id
|
||||
)
|
||||
assert bound_device is not None
|
||||
# The same device ID is kept and it stays on the parent entry, not the
|
||||
# subentry, so removing the pairing never deletes the cloud vehicle.
|
||||
assert bound_device.id == existing_device.id
|
||||
assert bound_device.config_subentry_id is None
|
||||
|
||||
# The vehicle entities keep their unique IDs and stay on the parent entry.
|
||||
bound_entities = er.async_entries_for_device(
|
||||
entity_registry, bound_device.id, include_disabled_entities=True
|
||||
)
|
||||
assert {entity.unique_id for entity in bound_entities} == {
|
||||
entity.unique_id for entity in vehicle_entities
|
||||
}
|
||||
assert all(entity.config_subentry_id is None for entity in bound_entities)
|
||||
|
||||
|
||||
async def test_subentry_add_flow_no_available_vehicles(hass: HomeAssistant) -> None:
|
||||
"""The add flow aborts when every account vehicle is already added."""
|
||||
entry = await _setup_paired_entry(hass)
|
||||
|
||||
result = await hass.config_entries.subentries.async_init(
|
||||
(entry.entry_id, SUBENTRY_TYPE_VEHICLE),
|
||||
context={"source": "user"},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "no_vehicles"
|
||||
|
||||
|
||||
async def test_subentry_add_flow_entry_not_loaded(hass: HomeAssistant) -> None:
|
||||
"""The add flow aborts when the parent entry is not loaded."""
|
||||
entry = mock_config_entry()
|
||||
entry.add_to_hass(hass)
|
||||
assert entry.state is ConfigEntryState.NOT_LOADED
|
||||
|
||||
result = await hass.config_entries.subentries.async_init(
|
||||
(entry.entry_id, SUBENTRY_TYPE_VEHICLE),
|
||||
context={"source": "user"},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "entry_not_loaded"
|
||||
|
||||
|
||||
SITE_ID = 123456
|
||||
WALL_CONNECTOR_SITE_ID = 555555
|
||||
HOST = "192.168.91.1"
|
||||
@@ -1458,18 +2183,18 @@ async def test_pair_step_second_lookup_errors(
|
||||
ValueError,
|
||||
id="key_read_valueerror",
|
||||
),
|
||||
# An encrypted key PEM surfaces as TypeError from the cryptography loader.
|
||||
# An encrypted existing key file surfaces as PrivateKeyError("encrypted").
|
||||
pytest.param(
|
||||
"homeassistant.components.teslemetry.config_flow.Teslemetry.get_rsa_private_key",
|
||||
TypeError,
|
||||
id="key_fetch_typeerror",
|
||||
PrivateKeyError("encrypted", "Private key file is encrypted"),
|
||||
id="key_fetch_private_key_error",
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_rsa_key_load_failure_aborts(
|
||||
hass: HomeAssistant,
|
||||
patch_target: str,
|
||||
error: type[Exception],
|
||||
error: type[Exception] | Exception,
|
||||
) -> None:
|
||||
"""A failure loading the integration's RSA key aborts site preparation."""
|
||||
entry = await _setup_account_no_subentry(hass)
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
"""Test the Teslemetry init."""
|
||||
|
||||
import asyncio
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager
|
||||
from copy import deepcopy
|
||||
import logging
|
||||
import time
|
||||
@@ -8,23 +11,28 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from aiohttp import ClientResponseError
|
||||
from aiopowerwall import PowerwallError
|
||||
from bleak.exc import BleakError
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
from freezegun.api import FrozenDateTimeFactory
|
||||
import pytest
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
from tesla_fleet_api.exceptions import (
|
||||
BluetoothCommandFailed,
|
||||
BluetoothTransportError,
|
||||
BluetoothUnconfirmedCommand,
|
||||
Forbidden,
|
||||
InsufficientCredits,
|
||||
InvalidResponse,
|
||||
InvalidToken,
|
||||
LoginRequired,
|
||||
PrivateKeyError,
|
||||
RateLimited,
|
||||
SubscriptionRequired,
|
||||
TeslaFleetError,
|
||||
)
|
||||
from tesla_fleet_api.tesla import EnergySiteRouter
|
||||
from tesla_fleet_api.teslemetry import EnergySite
|
||||
from tesla_fleet_api.tesla import EnergySiteRouter, VehicleRouter
|
||||
from tesla_fleet_api.teslemetry import EnergySite, Vehicle
|
||||
from teslemetry_stream import TeslemetryStreamAuthenticationError
|
||||
|
||||
from homeassistant.components.teslemetry import (
|
||||
@@ -35,8 +43,10 @@ from homeassistant.components.teslemetry import (
|
||||
from homeassistant.components.teslemetry.const import (
|
||||
CLIENT_ID,
|
||||
CONF_SITE_ID,
|
||||
CONF_VIN,
|
||||
DOMAIN,
|
||||
SUBENTRY_TYPE_ENERGY_SITE,
|
||||
SUBENTRY_TYPE_VEHICLE,
|
||||
)
|
||||
|
||||
# Coordinator constants
|
||||
@@ -46,6 +56,7 @@ from homeassistant.components.teslemetry.coordinator import (
|
||||
METADATA_INTERVAL,
|
||||
VEHICLE_INTERVAL,
|
||||
)
|
||||
from homeassistant.components.teslemetry.helpers import async_get_ble_parent
|
||||
from homeassistant.components.teslemetry.models import TeslemetryData
|
||||
from homeassistant.components.teslemetry.oauth import TeslemetryImplementation
|
||||
from homeassistant.config_entries import (
|
||||
@@ -54,6 +65,7 @@ from homeassistant.config_entries import (
|
||||
ConfigSubentryData,
|
||||
)
|
||||
from homeassistant.const import (
|
||||
CONF_ADDRESS,
|
||||
CONF_HOST,
|
||||
CONF_PASSWORD,
|
||||
STATE_OFF,
|
||||
@@ -882,11 +894,7 @@ async def test_vehicle_polling_stops_when_all_entities_disabled(
|
||||
keep_one_enabled: bool,
|
||||
expected_polled: bool,
|
||||
) -> None:
|
||||
"""Test the vehicle coordinator stops polling once every entity is disabled.
|
||||
|
||||
With no listeners left, core unschedules the coordinator so the charged
|
||||
vehicle_data poll stops entirely; a single enabled entity keeps it running.
|
||||
"""
|
||||
"""Test the vehicle coordinator stops polling once every entity is disabled."""
|
||||
vin = "LRW3F7EK4NC700000"
|
||||
entry = await setup_platform(hass, [Platform.SENSOR])
|
||||
|
||||
@@ -1221,13 +1229,7 @@ def _oauth_session(hass: HomeAssistant, entry: MockConfigEntry) -> OAuth2Session
|
||||
async def test_get_access_token_dead_token_during_setup_triggers_auth_failed(
|
||||
hass: HomeAssistant,
|
||||
) -> None:
|
||||
"""A dead/revoked refresh token during setup must raise ConfigEntryAuthFailed.
|
||||
|
||||
OAuth servers commonly report a dead refresh token with a non-401 status
|
||||
(e.g. 400 invalid_grant). Only recognizing status 401 let this fall
|
||||
through to ConfigEntryNotReady, which retries setup indefinitely without
|
||||
ever prompting the user to reauthenticate.
|
||||
"""
|
||||
"""A dead/revoked refresh token during setup must raise ConfigEntryAuthFailed."""
|
||||
mock_entry = mock_config_entry()
|
||||
mock_entry.add_to_hass(hass)
|
||||
mock_entry.mock_state(hass, ConfigEntryState.SETUP_IN_PROGRESS)
|
||||
@@ -1271,10 +1273,7 @@ async def test_get_access_token_rate_limited_during_setup_is_not_fatal(
|
||||
async def test_get_access_token_dead_token_after_setup_starts_reauth(
|
||||
hass: HomeAssistant,
|
||||
) -> None:
|
||||
"""Test a token dying after setup (re)starts reauth without tearing down.
|
||||
|
||||
The coordinator handles the rest once the exception is re-raised.
|
||||
"""
|
||||
"""Test a token dying after setup (re)starts reauth without tearing down."""
|
||||
mock_entry = mock_config_entry()
|
||||
mock_entry.add_to_hass(hass)
|
||||
mock_entry.mock_state(hass, ConfigEntryState.LOADED)
|
||||
@@ -1428,12 +1427,7 @@ async def test_energy_site_cloud_without_powerwall(hass: HomeAssistant) -> None:
|
||||
async def test_energy_site_subentry_without_credentials_uses_cloud(
|
||||
hass: HomeAssistant,
|
||||
) -> None:
|
||||
"""A subentry that exists but is not yet paired resolves to the cloud API.
|
||||
|
||||
A site whose subentry was created but has no gateway host/password stored
|
||||
keeps that subentry_id (so it stays opted in) while falling back to the
|
||||
plain cloud API rather than building an EnergySiteRouter.
|
||||
"""
|
||||
"""A subentry that exists but is not yet paired resolves to the cloud API."""
|
||||
entry = mock_config_entry()
|
||||
paired = MockConfigEntry(
|
||||
domain=entry.domain,
|
||||
@@ -1480,6 +1474,10 @@ async def test_no_subentry_created_at_setup(hass: HomeAssistant) -> None:
|
||||
pytest.param(OSError("disk gone"), id="os_error"),
|
||||
pytest.param(ValueError("bad key"), id="value_error"),
|
||||
pytest.param(PowerwallError("client boom"), id="powerwall_error"),
|
||||
pytest.param(
|
||||
PrivateKeyError("malformed", "Not a valid PEM private key"),
|
||||
id="private_key_error",
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_local_control_failure_falls_back_to_cloud(
|
||||
@@ -1487,12 +1485,7 @@ async def test_local_control_failure_falls_back_to_cloud(
|
||||
local_error: Exception,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""A failure resolving a paired site's local gateway falls back to cloud.
|
||||
|
||||
Local control is opt-in per site, so one site's bad local config must leave
|
||||
the entry loaded with cloud functionality intact rather than tearing the
|
||||
whole integration down.
|
||||
"""
|
||||
"""A failure resolving a paired site's local gateway falls back to cloud."""
|
||||
entry = _entry_with_powerwall()
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
@@ -1519,20 +1512,29 @@ async def test_local_control_failure_falls_back_to_cloud(
|
||||
)
|
||||
|
||||
|
||||
async def test_local_control_encrypted_key_falls_back_to_cloud(
|
||||
@pytest.mark.parametrize(
|
||||
"rsa_key_error",
|
||||
[
|
||||
# PrivateKeyError is the wrapped existing-key-file failure shape.
|
||||
pytest.param(
|
||||
PrivateKeyError("encrypted", "Private key file is encrypted"),
|
||||
id="private_key_error",
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_local_control_key_load_failure_falls_back_to_cloud(
|
||||
hass: HomeAssistant,
|
||||
rsa_key_error: Exception,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Fall back to cloud control when RSA key loading reports an encrypted PEM."""
|
||||
"""Fall back to cloud control when RSA key loading fails on an existing file."""
|
||||
entry = _entry_with_powerwall()
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"homeassistant.components.teslemetry.Teslemetry.get_rsa_private_key",
|
||||
side_effect=TypeError(
|
||||
"Password was not given but private key is encrypted"
|
||||
),
|
||||
side_effect=rsa_key_error,
|
||||
),
|
||||
patch("homeassistant.components.teslemetry.PLATFORMS", []),
|
||||
caplog.at_level(logging.WARNING),
|
||||
@@ -1551,13 +1553,7 @@ async def test_local_control_encrypted_key_falls_back_to_cloud(
|
||||
async def test_local_control_unexpected_typeerror_is_not_swallowed(
|
||||
hass: HomeAssistant,
|
||||
) -> None:
|
||||
"""A TypeError outside the key load is a real bug and must not degrade silently.
|
||||
|
||||
``_LOCAL_CONTROL_ERRORS`` deliberately excludes TypeError: only the key
|
||||
loader's encrypted-PEM TypeError is converted to ValueError. A TypeError
|
||||
from anywhere else in the resolve path (here, client construction) must
|
||||
fail setup rather than silently falling back to cloud control.
|
||||
"""
|
||||
"""A TypeError outside the key load is a real bug and must not degrade silently."""
|
||||
entry = _entry_with_powerwall()
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
@@ -1763,11 +1759,7 @@ async def test_stale_cleanup_preserves_pairing_without_energy_scope(
|
||||
|
||||
|
||||
async def test_update_listener_ignores_token_refresh(hass: HomeAssistant) -> None:
|
||||
"""An entry update that only changes token data must not reload the entry.
|
||||
|
||||
OAuth token refreshes call async_update_entry with new token data on every
|
||||
expiry; reloading on those would needlessly drop the stream and re-fetch.
|
||||
"""
|
||||
"""An entry update that only changes token data must not reload the entry."""
|
||||
entry = mock_config_entry()
|
||||
entry.add_to_hass(hass)
|
||||
with patch("homeassistant.components.teslemetry.PLATFORMS", []):
|
||||
@@ -1921,3 +1913,584 @@ async def test_energy_stream_disconnect_marks_unavailable_and_recovers(
|
||||
for flow in hass.config_entries.flow.async_progress()
|
||||
if flow["handler"] == DOMAIN
|
||||
]
|
||||
|
||||
|
||||
VIN = "LRW3F7EK4NC700000"
|
||||
ADDRESS = "AA:BB:CC:DD:EE:FF"
|
||||
CLOUD_RESULT = {"response": {"result": True, "reason": "cloud"}}
|
||||
BLE_RESULT = {"response": {"result": True, "reason": "bluetooth"}}
|
||||
|
||||
|
||||
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 test_vehicle_router_with_bluetooth(hass: HomeAssistant) -> None:
|
||||
"""A BLE-paired vehicle wraps its cloud API in a VehicleRouter."""
|
||||
entry = _entry_with_ble()
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
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", []),
|
||||
):
|
||||
mock_parent.return_value.get_private_key = AsyncMock()
|
||||
mock_parent.return_value.vehicles.createBluetooth.return_value = MagicMock()
|
||||
await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
vehicle = entry.runtime_data.vehicles[0]
|
||||
assert isinstance(vehicle.api, VehicleRouter)
|
||||
# Avoid replaying ambiguous commands or keeping the vehicle awake.
|
||||
mock_parent.return_value.vehicles.createBluetooth.assert_called_once_with(
|
||||
VIN,
|
||||
confirmation="verify",
|
||||
raise_unconfirmed=False,
|
||||
keepalive_interval=None,
|
||||
)
|
||||
|
||||
|
||||
async def test_vehicle_cloud_without_bluetooth(hass: HomeAssistant) -> None:
|
||||
"""A vehicle without a paired address keeps the plain cloud API."""
|
||||
entry = mock_config_entry()
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
with patch("homeassistant.components.teslemetry.PLATFORMS", []):
|
||||
await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
vehicle = entry.runtime_data.vehicles[0]
|
||||
assert isinstance(vehicle.api, Vehicle)
|
||||
assert not isinstance(vehicle.api, VehicleRouter)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"key_error",
|
||||
[
|
||||
pytest.param(OSError("disk gone"), id="os_error"),
|
||||
pytest.param(ValueError("bad key"), id="value_error"),
|
||||
# A raw TypeError only escapes the key create/generation path now.
|
||||
pytest.param(
|
||||
TypeError("unexpected keyword argument"),
|
||||
id="typeerror",
|
||||
),
|
||||
# PrivateKeyError is the wrapped existing-key-file shape; it must degrade too.
|
||||
pytest.param(
|
||||
PrivateKeyError("unreadable", "Could not read private key file"),
|
||||
id="private_key_unreadable",
|
||||
),
|
||||
pytest.param(
|
||||
PrivateKeyError("malformed", "Not a valid PEM private key"),
|
||||
id="private_key_malformed",
|
||||
),
|
||||
pytest.param(
|
||||
PrivateKeyError("encrypted", "Private key file is encrypted"),
|
||||
id="private_key_encrypted",
|
||||
),
|
||||
pytest.param(
|
||||
PrivateKeyError("wrong_type", "Not an EllipticCurvePrivateKey"),
|
||||
id="private_key_wrong_type",
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_vehicle_bluetooth_key_load_falls_back_to_cloud(
|
||||
hass: HomeAssistant,
|
||||
key_error: Exception,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""A vehicle whose Bluetooth key fails to load degrades to cloud control."""
|
||||
entry = _entry_with_ble()
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
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", []),
|
||||
caplog.at_level(logging.WARNING),
|
||||
):
|
||||
mock_parent.return_value.get_private_key = AsyncMock(side_effect=key_error)
|
||||
await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert entry.state is ConfigEntryState.LOADED
|
||||
vehicle = entry.runtime_data.vehicles[0]
|
||||
assert isinstance(vehicle.api, Vehicle)
|
||||
assert not isinstance(vehicle.api, VehicleRouter)
|
||||
# The rest of the account is unaffected: the energy site still loads.
|
||||
assert len(entry.runtime_data.energysites) == 1
|
||||
assert "falling back to cloud control" in caplog.text
|
||||
assert any(
|
||||
record.levelname == "WARNING" and VIN in record.message
|
||||
for record in caplog.records
|
||||
)
|
||||
|
||||
|
||||
async def test_vehicle_bluetooth_key_load_recovers_on_reload(
|
||||
hass: HomeAssistant,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""A vehicle degraded to cloud by a key-load failure regains BLE control on reload."""
|
||||
entry = _entry_with_ble()
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
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", []),
|
||||
caplog.at_level(logging.WARNING),
|
||||
):
|
||||
mock_parent.return_value.get_private_key = AsyncMock(
|
||||
side_effect=OSError("disk gone")
|
||||
)
|
||||
mock_parent.return_value.vehicles.createBluetooth.return_value = MagicMock()
|
||||
|
||||
await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert not isinstance(entry.runtime_data.vehicles[0].api, VehicleRouter)
|
||||
assert "falling back to cloud control" in caplog.text
|
||||
|
||||
# The key becomes readable again; a reload must restore local Bluetooth control.
|
||||
mock_parent.return_value.get_private_key = AsyncMock()
|
||||
caplog.clear()
|
||||
|
||||
await hass.config_entries.async_reload(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert entry.state is ConfigEntryState.LOADED
|
||||
assert isinstance(entry.runtime_data.vehicles[0].api, VehicleRouter)
|
||||
assert "falling back to cloud control" not in caplog.text
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def _paired_entry(
|
||||
hass: HomeAssistant, ble_lookup: MagicMock
|
||||
) -> AsyncIterator[tuple[VehicleRouter, AsyncMock, AsyncMock]]:
|
||||
"""Set up a BLE-paired entry, yielding its router and both backends."""
|
||||
entry = _entry_with_ble()
|
||||
entry.add_to_hass(hass)
|
||||
bluetooth_vehicle = AsyncMock()
|
||||
bluetooth_vehicle.set_device = MagicMock()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"homeassistant.components.teslemetry.async_ble_device_from_address",
|
||||
ble_lookup,
|
||||
),
|
||||
patch(
|
||||
"homeassistant.components.teslemetry.helpers.TeslaBluetooth"
|
||||
) as mock_parent,
|
||||
patch("homeassistant.components.teslemetry.PLATFORMS", []),
|
||||
):
|
||||
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()
|
||||
|
||||
router = entry.runtime_data.vehicles[0].api
|
||||
cloud = AsyncMock(return_value=CLOUD_RESULT)
|
||||
router.secondary.flash_lights = cloud
|
||||
yield router, bluetooth_vehicle, cloud
|
||||
|
||||
|
||||
async def test_vehicle_bluetooth_out_of_range(hass: HomeAssistant) -> None:
|
||||
"""A paired vehicle out of range still gets a router, and skips Bluetooth."""
|
||||
async with _paired_entry(hass, MagicMock(return_value=None)) as (
|
||||
router,
|
||||
bluetooth_vehicle,
|
||||
cloud,
|
||||
):
|
||||
assert isinstance(router, VehicleRouter)
|
||||
|
||||
assert await router.flash_lights() == CLOUD_RESULT
|
||||
|
||||
cloud.assert_awaited_once()
|
||||
bluetooth_vehicle.flash_lights.assert_not_called()
|
||||
|
||||
|
||||
async def test_vehicle_router_resumes_bluetooth_when_vehicle_returns(
|
||||
hass: HomeAssistant,
|
||||
) -> None:
|
||||
"""A vehicle away at setup routes locally again once it comes home."""
|
||||
ble_lookup = MagicMock(return_value=None)
|
||||
|
||||
async with _paired_entry(hass, ble_lookup) as (router, bluetooth_vehicle, cloud):
|
||||
bluetooth_vehicle.flash_lights.return_value = BLE_RESULT
|
||||
|
||||
assert await router.flash_lights() == CLOUD_RESULT
|
||||
bluetooth_vehicle.flash_lights.assert_not_called()
|
||||
|
||||
ble_lookup.return_value = MagicMock()
|
||||
|
||||
assert await router.flash_lights() == BLE_RESULT
|
||||
bluetooth_vehicle.flash_lights.assert_awaited_once()
|
||||
cloud.assert_awaited_once()
|
||||
|
||||
|
||||
async def test_vehicle_router_falls_back_when_vehicle_leaves(
|
||||
hass: HomeAssistant,
|
||||
) -> None:
|
||||
"""A vehicle in range at setup routes to cloud once it drives away."""
|
||||
ble_lookup = MagicMock(return_value=MagicMock())
|
||||
|
||||
async with _paired_entry(hass, ble_lookup) as (router, bluetooth_vehicle, cloud):
|
||||
bluetooth_vehicle.flash_lights.return_value = BLE_RESULT
|
||||
|
||||
assert await router.flash_lights() == BLE_RESULT
|
||||
cloud.assert_not_called()
|
||||
|
||||
ble_lookup.return_value = None
|
||||
|
||||
assert await router.flash_lights() == CLOUD_RESULT
|
||||
cloud.assert_awaited_once()
|
||||
bluetooth_vehicle.flash_lights.assert_awaited_once()
|
||||
|
||||
|
||||
async def test_vehicle_router_refreshes_device_handle(hass: HomeAssistant) -> None:
|
||||
"""Each command refreshes the BLE handle from the cache before connecting."""
|
||||
first_device = MagicMock()
|
||||
second_device = MagicMock()
|
||||
ble_lookup = MagicMock(return_value=first_device)
|
||||
|
||||
async with _paired_entry(hass, ble_lookup) as (router, bluetooth_vehicle, _cloud):
|
||||
await router.flash_lights()
|
||||
bluetooth_vehicle.set_device.assert_called_once_with(first_device)
|
||||
|
||||
ble_lookup.return_value = second_device
|
||||
await router.flash_lights()
|
||||
|
||||
bluetooth_vehicle.set_device.assert_called_with(second_device)
|
||||
|
||||
|
||||
async def test_vehicle_router_fails_over_on_stale_cache_hit(
|
||||
hass: HomeAssistant,
|
||||
) -> None:
|
||||
"""A cache entry outliving the vehicle costs one failed attempt, not a failure."""
|
||||
async with _paired_entry(hass, MagicMock(return_value=MagicMock())) as (
|
||||
router,
|
||||
bluetooth_vehicle,
|
||||
cloud,
|
||||
):
|
||||
bluetooth_vehicle.flash_lights.side_effect = BluetoothTransportError()
|
||||
|
||||
assert await router.flash_lights() == CLOUD_RESULT
|
||||
|
||||
bluetooth_vehicle.flash_lights.assert_awaited_once()
|
||||
cloud.assert_awaited_once()
|
||||
|
||||
|
||||
async def test_vehicle_paired_but_never_seen(hass: HomeAssistant) -> None:
|
||||
"""A paired vehicle never seen by Bluetooth is built without a device handle."""
|
||||
entry = _entry_with_ble()
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"homeassistant.components.teslemetry.async_ble_device_from_address",
|
||||
MagicMock(return_value=None),
|
||||
),
|
||||
patch(
|
||||
"homeassistant.components.teslemetry.helpers.TeslaBluetooth"
|
||||
) as mock_parent,
|
||||
patch("homeassistant.components.teslemetry.PLATFORMS", []),
|
||||
):
|
||||
mock_parent.return_value.get_private_key = AsyncMock()
|
||||
mock_parent.return_value.vehicles.createBluetooth.return_value = AsyncMock()
|
||||
await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert (
|
||||
"device"
|
||||
not in mock_parent.return_value.vehicles.createBluetooth.call_args.kwargs
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"disconnect_error",
|
||||
[None, BleakError("boom")],
|
||||
ids=["clean", "error_swallowed"],
|
||||
)
|
||||
async def test_unload_disconnects_bluetooth(
|
||||
hass: HomeAssistant, disconnect_error: Exception | None
|
||||
) -> None:
|
||||
"""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.disconnect = AsyncMock(side_effect=disconnect_error)
|
||||
|
||||
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", []),
|
||||
):
|
||||
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()
|
||||
assert isinstance(entry.runtime_data.vehicles[0].api, VehicleRouter)
|
||||
|
||||
assert await hass.config_entries.async_unload(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
bluetooth_vehicle.disconnect.assert_awaited_once()
|
||||
|
||||
|
||||
async def test_unload_never_connected_bluetooth(hass: HomeAssistant) -> None:
|
||||
"""Unloading a paired vehicle that was never in range does not raise."""
|
||||
entry = _entry_with_ble()
|
||||
entry.add_to_hass(hass)
|
||||
bluetooth_vehicle = AsyncMock()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"homeassistant.components.teslemetry.async_ble_device_from_address",
|
||||
return_value=None,
|
||||
),
|
||||
patch(
|
||||
"homeassistant.components.teslemetry.helpers.TeslaBluetooth"
|
||||
) as mock_parent,
|
||||
patch("homeassistant.components.teslemetry.PLATFORMS", []),
|
||||
):
|
||||
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()
|
||||
|
||||
assert await hass.config_entries.async_unload(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
bluetooth_vehicle.disconnect.assert_awaited_once()
|
||||
|
||||
|
||||
async def test_ble_parent_shared_and_cached(hass: HomeAssistant) -> None:
|
||||
"""The BLE parent (holding the private key) is created once and reused."""
|
||||
with patch(
|
||||
"homeassistant.components.teslemetry.helpers.TeslaBluetooth"
|
||||
) as mock_parent:
|
||||
mock_parent.return_value.get_private_key = AsyncMock()
|
||||
first = await async_get_ble_parent(hass)
|
||||
second = await async_get_ble_parent(hass)
|
||||
|
||||
assert first is second
|
||||
mock_parent.assert_called_once()
|
||||
mock_parent.return_value.get_private_key.assert_awaited_once()
|
||||
|
||||
|
||||
async def test_ble_parent_concurrent_first_init(hass: HomeAssistant) -> None:
|
||||
"""Concurrent first-time callers still create and load the key exactly once."""
|
||||
|
||||
async def _get_private_key(path: str) -> None:
|
||||
await asyncio.sleep(0)
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.teslemetry.helpers.TeslaBluetooth"
|
||||
) as mock_parent:
|
||||
mock_parent.return_value.get_private_key = AsyncMock(
|
||||
side_effect=_get_private_key
|
||||
)
|
||||
parents = await asyncio.gather(*(async_get_ble_parent(hass) for _ in range(5)))
|
||||
|
||||
assert all(parent is parents[0] for parent in parents)
|
||||
mock_parent.assert_called_once()
|
||||
mock_parent.return_value.get_private_key.assert_awaited_once()
|
||||
|
||||
|
||||
async def test_router_does_not_fail_over_on_unconfirmed() -> None:
|
||||
"""An unconfirmed BLE command is never replayed on the cloud backend."""
|
||||
bluetooth = AsyncMock()
|
||||
bluetooth.actuate_trunk = AsyncMock(side_effect=BluetoothUnconfirmedCommand())
|
||||
cloud = AsyncMock()
|
||||
cloud.actuate_trunk = AsyncMock(return_value={"response": {"result": True}})
|
||||
router = VehicleRouter(bluetooth, cloud)
|
||||
|
||||
with pytest.raises(BluetoothUnconfirmedCommand):
|
||||
await router.actuate_trunk()
|
||||
|
||||
cloud.actuate_trunk.assert_not_called()
|
||||
|
||||
|
||||
async def test_router_fails_over_on_command_failed() -> None:
|
||||
"""A command proven not to have applied over BLE fails over to the cloud."""
|
||||
bluetooth = AsyncMock()
|
||||
bluetooth.actuate_trunk = AsyncMock(side_effect=BluetoothCommandFailed())
|
||||
cloud = AsyncMock()
|
||||
cloud.actuate_trunk = AsyncMock(return_value={"response": {"result": True}})
|
||||
router = VehicleRouter(bluetooth, cloud)
|
||||
|
||||
result = await router.actuate_trunk()
|
||||
|
||||
assert result == {"response": {"result": True}}
|
||||
bluetooth.actuate_trunk.assert_awaited_once()
|
||||
cloud.actuate_trunk.assert_awaited_once()
|
||||
|
||||
|
||||
async def _setup_paired_entry(hass: HomeAssistant) -> MockConfigEntry:
|
||||
"""Set up an entry whose only account vehicle is already BLE-paired."""
|
||||
entry = _entry_with_ble()
|
||||
entry.add_to_hass(hass)
|
||||
with (
|
||||
patch(
|
||||
"homeassistant.components.teslemetry.async_ble_device_from_address",
|
||||
return_value=None,
|
||||
),
|
||||
patch(
|
||||
"homeassistant.components.teslemetry.helpers.TeslaBluetooth"
|
||||
) as mock_parent,
|
||||
patch("homeassistant.components.teslemetry.PLATFORMS", []),
|
||||
):
|
||||
mock_parent.return_value.get_private_key = AsyncMock()
|
||||
mock_parent.return_value.vehicles.createBluetooth.return_value = AsyncMock()
|
||||
await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
return entry
|
||||
|
||||
|
||||
async def test_subentry_removal_reloads(hass: HomeAssistant) -> None:
|
||||
"""Removing a vehicle subentry reloads once; later updates do not re-schedule."""
|
||||
entry = await _setup_paired_entry(hass)
|
||||
subentry_id = entry.get_subentries_of_type(SUBENTRY_TYPE_VEHICLE)[0].subentry_id
|
||||
|
||||
with patch.object(hass.config_entries, "async_schedule_reload") as mock_reload:
|
||||
assert hass.config_entries.async_remove_subentry(entry, subentry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# A later entry update before the reload runs must not re-schedule it.
|
||||
hass.config_entries.async_update_entry(
|
||||
entry, data={**entry.data, "marker": True}
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
mock_reload.assert_called_once_with(entry.entry_id)
|
||||
|
||||
|
||||
async def test_subentry_removal_keeps_vehicle_device_and_entities(
|
||||
hass: HomeAssistant,
|
||||
device_registry: dr.DeviceRegistry,
|
||||
entity_registry: er.EntityRegistry,
|
||||
) -> None:
|
||||
"""Removing a vehicle subentry leaves the cloud vehicle device and entities intact."""
|
||||
entry = _entry_with_ble()
|
||||
entry.add_to_hass(hass)
|
||||
with (
|
||||
patch(
|
||||
"homeassistant.components.teslemetry.async_ble_device_from_address",
|
||||
return_value=None,
|
||||
),
|
||||
patch(
|
||||
"homeassistant.components.teslemetry.helpers.TeslaBluetooth"
|
||||
) as mock_parent,
|
||||
):
|
||||
mock_parent.return_value.get_private_key = AsyncMock()
|
||||
mock_parent.return_value.vehicles.createBluetooth.return_value = AsyncMock()
|
||||
await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
subentry_id = entry.get_subentries_of_type(SUBENTRY_TYPE_VEHICLE)[0].subentry_id
|
||||
|
||||
device = device_registry.async_get_device_by_identifier(
|
||||
(DOMAIN, VIN), entry.entry_id
|
||||
)
|
||||
assert device is not None
|
||||
# The device and its entities belong to the parent entry, never the subentry.
|
||||
assert device.config_subentry_id is None
|
||||
entities_before = er.async_entries_for_device(
|
||||
entity_registry, device.id, include_disabled_entities=True
|
||||
)
|
||||
assert entities_before
|
||||
assert all(entity.config_subentry_id is None for entity in entities_before)
|
||||
unique_ids_before = {entity.unique_id for entity in entities_before}
|
||||
|
||||
# Patch the reload so only the subentry removal itself is exercised here.
|
||||
with patch.object(hass.config_entries, "async_schedule_reload"):
|
||||
assert hass.config_entries.async_remove_subentry(entry, subentry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# The vehicle device and every entity on it survive the removal.
|
||||
device_after = device_registry.async_get_device_by_identifier(
|
||||
(DOMAIN, VIN), entry.entry_id
|
||||
)
|
||||
assert device_after is not None
|
||||
assert device_after.id == device.id
|
||||
entities_after = er.async_entries_for_device(
|
||||
entity_registry, device_after.id, include_disabled_entities=True
|
||||
)
|
||||
assert {entity.unique_id for entity in entities_after} == unique_ids_before
|
||||
|
||||
|
||||
async def test_no_subentry_auto_created_at_setup(hass: HomeAssistant) -> None:
|
||||
"""Setup never auto-creates a Bluetooth subentry for account vehicles."""
|
||||
entry = mock_config_entry()
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
with patch("homeassistant.components.teslemetry.PLATFORMS", []):
|
||||
await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert not entry.get_subentries_of_type(SUBENTRY_TYPE_VEHICLE)
|
||||
|
||||
|
||||
async def test_user_subentry_persists_across_reload(hass: HomeAssistant) -> None:
|
||||
"""A paired vehicle subentry survives a reload even if its vehicle leaves the account."""
|
||||
entry = await _setup_paired_entry(hass)
|
||||
subentry_id = entry.get_subentries_of_type(SUBENTRY_TYPE_VEHICLE)[0].subentry_id
|
||||
|
||||
# The vehicle drops off the account, so setup builds no vehicle for it, yet
|
||||
# the user-added subentry (with its stored credentials) must not be removed.
|
||||
with (
|
||||
patch(
|
||||
"tesla_fleet_api.teslemetry.Teslemetry.products",
|
||||
return_value={"response": []},
|
||||
),
|
||||
patch("homeassistant.components.teslemetry.PLATFORMS", []),
|
||||
):
|
||||
await hass.config_entries.async_reload(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
subentries = entry.get_subentries_of_type(SUBENTRY_TYPE_VEHICLE)
|
||||
assert len(subentries) == 1
|
||||
assert subentries[0].subentry_id == subentry_id
|
||||
assert subentries[0].data == {CONF_VIN: VIN, CONF_ADDRESS: ADDRESS}
|
||||
|
||||
Reference in New Issue
Block a user