mirror of
https://github.com/home-assistant/core.git
synced 2026-09-24 15:31:52 -05:00
Gate Teslemetry polling-only vehicle entities behind vehicle metadata (#181351)
This commit is contained in:
@@ -12,7 +12,7 @@ from homeassistant.components.binary_sensor import (
|
||||
BinarySensorEntity,
|
||||
BinarySensorEntityDescription,
|
||||
)
|
||||
from homeassistant.const import STATE_ON, EntityCategory
|
||||
from homeassistant.const import STATE_ON, EntityCategory, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.restore_state import RestoreEntity
|
||||
@@ -26,6 +26,7 @@ from .entity import (
|
||||
TeslemetryVehiclePollingEntity,
|
||||
TeslemetryVehicleStreamEntity,
|
||||
)
|
||||
from .helpers import async_remove_stale_vehicle_entities
|
||||
from .models import TeslemetryEnergyData, TeslemetryVehicleData
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
@@ -566,7 +567,8 @@ async def async_setup_entry(
|
||||
entities.append(
|
||||
TeslemetryVehicleStreamingBinarySensorEntity(vehicle, description)
|
||||
)
|
||||
elif description.polling:
|
||||
elif description.polling and vehicle.poll is not False:
|
||||
# poll may be None (unknown); only an explicit False is stream-only
|
||||
entities.append(
|
||||
TeslemetryVehiclePollingBinarySensorEntity(vehicle, description)
|
||||
)
|
||||
@@ -585,6 +587,13 @@ async def async_setup_entry(
|
||||
if description.key in energysite.info_coordinator.data
|
||||
)
|
||||
|
||||
async_remove_stale_vehicle_entities(
|
||||
hass,
|
||||
entry.entry_id,
|
||||
Platform.BINARY_SENSOR,
|
||||
{vehicle.vin for vehicle in entry.runtime_data.vehicles},
|
||||
{entity.unique_id for entity in entities if entity.unique_id},
|
||||
)
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ from tesla_fleet_api.exceptions import TeslaFleetError
|
||||
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers import device_registry as dr, entity_registry as er
|
||||
|
||||
from .const import DOMAIN, LOGGER
|
||||
|
||||
@@ -79,6 +79,25 @@ async def handle_vehicle_command(command: Awaitable[dict[str, Any]]) -> Any:
|
||||
return result
|
||||
|
||||
|
||||
@callback
|
||||
def async_remove_stale_vehicle_entities(
|
||||
hass: HomeAssistant,
|
||||
config_entry_id: str,
|
||||
domain: str,
|
||||
vins: set[str],
|
||||
valid_unique_ids: set[str],
|
||||
) -> None:
|
||||
"""Remove registry entries for vehicle entities that are no longer created."""
|
||||
entity_registry = er.async_get(hass)
|
||||
for entity in er.async_entries_for_config_entry(entity_registry, config_entry_id):
|
||||
if (
|
||||
entity.domain == domain
|
||||
and entity.unique_id not in valid_unique_ids
|
||||
and any(entity.unique_id.startswith(f"{vin}-") for vin in vins)
|
||||
):
|
||||
entity_registry.async_remove(entity.entity_id)
|
||||
|
||||
|
||||
@callback
|
||||
def async_update_device_sw_version(
|
||||
hass: HomeAssistant, identifier: str, config_entry_id: str, sw_version: str
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass
|
||||
from itertools import chain
|
||||
from typing import Any, override
|
||||
|
||||
from tesla_fleet_api import firmware_at_least
|
||||
@@ -11,6 +10,7 @@ from tesla_fleet_api.teslemetry import Vehicle
|
||||
from teslemetry_stream import TeslemetryStreamVehicle
|
||||
|
||||
from homeassistant.components.select import SelectEntity, SelectEntityDescription
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.restore_state import RestoreEntity
|
||||
@@ -22,7 +22,11 @@ from .entity import (
|
||||
TeslemetryVehiclePollingEntity,
|
||||
TeslemetryVehicleStreamEntity,
|
||||
)
|
||||
from .helpers import handle_command, handle_vehicle_command
|
||||
from .helpers import (
|
||||
async_remove_stale_vehicle_entities,
|
||||
handle_command,
|
||||
handle_vehicle_command,
|
||||
)
|
||||
from .models import TeslemetryEnergyData, TeslemetryVehicleData
|
||||
|
||||
OFF = "off"
|
||||
@@ -214,39 +218,56 @@ async def async_setup_entry(
|
||||
) -> None:
|
||||
"""Set up the Teslemetry select platform from a config entry."""
|
||||
|
||||
async_add_entities(
|
||||
chain(
|
||||
(
|
||||
TeslemetryVehiclePollingSelectEntity(
|
||||
vehicle, description, entry.runtime_data.scopes
|
||||
vehicles_metadata = entry.runtime_data.metadata_coordinator.data.get("vehicles", {})
|
||||
entities: list[SelectEntity] = []
|
||||
for description in VEHICLE_DESCRIPTIONS:
|
||||
for vehicle in entry.runtime_data.vehicles:
|
||||
if not description.supported_fn(
|
||||
vehicles_metadata.get(vehicle.vin, {}).get("config", {})
|
||||
):
|
||||
continue
|
||||
if description.streaming_listener is None:
|
||||
# Polling-only feature; poll may be None (unknown), only an
|
||||
# explicit False marks a stream-only vehicle.
|
||||
if vehicle.poll is not False:
|
||||
entities.append(
|
||||
TeslemetryVehiclePollingSelectEntity(
|
||||
vehicle, description, entry.runtime_data.scopes
|
||||
)
|
||||
)
|
||||
elif vehicle.poll or not firmware_at_least(vehicle.firmware, "2024.26"):
|
||||
entities.append(
|
||||
TeslemetryVehiclePollingSelectEntity(
|
||||
vehicle, description, entry.runtime_data.scopes
|
||||
)
|
||||
)
|
||||
if vehicle.poll
|
||||
or not firmware_at_least(vehicle.firmware, "2024.26")
|
||||
or description.streaming_listener is None
|
||||
else TeslemetryStreamingSelectEntity(
|
||||
vehicle, description, entry.runtime_data.scopes
|
||||
else:
|
||||
entities.append(
|
||||
TeslemetryStreamingSelectEntity(
|
||||
vehicle, description, entry.runtime_data.scopes
|
||||
)
|
||||
)
|
||||
for description in VEHICLE_DESCRIPTIONS
|
||||
for vehicle in entry.runtime_data.vehicles
|
||||
if description.supported_fn(
|
||||
entry.runtime_data.metadata_coordinator.data.get("vehicles", {})
|
||||
.get(vehicle.vin, {})
|
||||
.get("config", {})
|
||||
)
|
||||
),
|
||||
(
|
||||
TeslemetryOperationSelectEntity(energysite, entry.runtime_data.scopes)
|
||||
for energysite in entry.runtime_data.energysites
|
||||
if energysite.info_coordinator.data.get("components_battery")
|
||||
),
|
||||
(
|
||||
TeslemetryExportRuleSelectEntity(energysite, entry.runtime_data.scopes)
|
||||
for energysite in entry.runtime_data.energysites
|
||||
if energysite.info_coordinator.data.get("components_battery")
|
||||
and energysite.info_coordinator.data.get("components_solar")
|
||||
),
|
||||
)
|
||||
|
||||
entities.extend(
|
||||
TeslemetryOperationSelectEntity(energysite, entry.runtime_data.scopes)
|
||||
for energysite in entry.runtime_data.energysites
|
||||
if energysite.info_coordinator.data.get("components_battery")
|
||||
)
|
||||
entities.extend(
|
||||
TeslemetryExportRuleSelectEntity(energysite, entry.runtime_data.scopes)
|
||||
for energysite in entry.runtime_data.energysites
|
||||
if energysite.info_coordinator.data.get("components_battery")
|
||||
and energysite.info_coordinator.data.get("components_solar")
|
||||
)
|
||||
|
||||
async_remove_stale_vehicle_entities(
|
||||
hass,
|
||||
entry.entry_id,
|
||||
Platform.SELECT,
|
||||
{vehicle.vin for vehicle in entry.runtime_data.vehicles},
|
||||
{entity.unique_id for entity in entities if entity.unique_id},
|
||||
)
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class TeslemetrySelectEntity(TeslemetryRootEntity, SelectEntity):
|
||||
|
||||
@@ -20,6 +20,7 @@ from homeassistant.const import (
|
||||
DEGREE,
|
||||
PERCENTAGE,
|
||||
EntityCategory,
|
||||
Platform,
|
||||
UnitOfElectricCurrent,
|
||||
UnitOfElectricPotential,
|
||||
UnitOfEnergy,
|
||||
@@ -47,6 +48,7 @@ from .entity import (
|
||||
TeslemetryVehicleStreamEntity,
|
||||
TeslemetryWallConnectorEntity,
|
||||
)
|
||||
from .helpers import async_remove_stale_vehicle_entities
|
||||
from .models import TeslemetryEnergyData, TeslemetryVehicleData
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
@@ -1681,7 +1683,8 @@ async def async_setup_entry(
|
||||
)
|
||||
):
|
||||
entities.append(TeslemetryStreamSensorEntity(vehicle, description))
|
||||
elif description.polling:
|
||||
elif description.polling and vehicle.poll is not False:
|
||||
# poll may be None (unknown); only an explicit False is stream-only
|
||||
entities.append(TeslemetryVehicleSensorEntity(vehicle, description))
|
||||
|
||||
for time_description in VEHICLE_TIME_DESCRIPTIONS:
|
||||
@@ -1739,6 +1742,13 @@ async def async_setup_entry(
|
||||
)
|
||||
)
|
||||
|
||||
async_remove_stale_vehicle_entities(
|
||||
hass,
|
||||
entry.entry_id,
|
||||
Platform.SENSOR,
|
||||
{vehicle.vin for vehicle in entry.runtime_data.vehicles},
|
||||
{entity.unique_id for entity in entities if entity.unique_id},
|
||||
)
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
|
||||
@@ -199,10 +199,6 @@ async def test_vehicle_stream(
|
||||
assert state is not None
|
||||
assert state.state == STATE_UNKNOWN
|
||||
|
||||
state = hass.states.get("binary_sensor.test_user_present")
|
||||
assert state is not None
|
||||
assert state.state == STATE_UNAVAILABLE
|
||||
|
||||
mock_add_listener.send(
|
||||
{
|
||||
"vin": VEHICLE_DATA_ALT["response"]["vin"],
|
||||
@@ -217,10 +213,6 @@ async def test_vehicle_stream(
|
||||
assert state is not None
|
||||
assert state.state == STATE_ON
|
||||
|
||||
state = hass.states.get("binary_sensor.test_user_present")
|
||||
assert state is not None
|
||||
assert state.state == STATE_ON
|
||||
|
||||
mock_add_listener.send(
|
||||
{
|
||||
"vin": VEHICLE_DATA_ALT["response"]["vin"],
|
||||
@@ -924,6 +916,97 @@ async def test_vehicle_polling_stops_when_all_entities_disabled(
|
||||
assert (mock_vehicle_data.call_count > 0) is expected_polled
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("polling", "has_polling_only"),
|
||||
[
|
||||
(True, True),
|
||||
(None, True),
|
||||
(False, False),
|
||||
],
|
||||
ids=["polling", "unknown_polling", "streaming"],
|
||||
)
|
||||
async def test_polling_only_entities_require_metadata(
|
||||
hass: HomeAssistant,
|
||||
entity_registry: er.EntityRegistry,
|
||||
mock_metadata: AsyncMock,
|
||||
polling: bool | None,
|
||||
has_polling_only: bool,
|
||||
) -> None:
|
||||
"""Create a polling-only entity unless the vehicle is explicitly stream-only.
|
||||
|
||||
A null polling flag is unknown, not stream-only, so its entities are kept.
|
||||
"""
|
||||
vin = "LRW3F7EK4NC700000"
|
||||
metadata = deepcopy(METADATA)
|
||||
metadata["vehicles"][vin]["polling"] = polling
|
||||
mock_metadata.return_value = metadata
|
||||
|
||||
entry = await setup_platform(hass, [Platform.BINARY_SENSOR])
|
||||
|
||||
# is_user_present is a polling-only binary sensor (no streaming source).
|
||||
assert (
|
||||
entity_registry.async_get_entity_id(
|
||||
Platform.BINARY_SENSOR, DOMAIN, f"{vin}-vehicle_state_is_user_present"
|
||||
)
|
||||
is not None
|
||||
) is has_polling_only
|
||||
# A feature with a streaming source exists regardless of the polling flags.
|
||||
assert (
|
||||
entity_registry.async_get_entity_id(
|
||||
Platform.BINARY_SENSOR, DOMAIN, f"{vin}-state"
|
||||
)
|
||||
is not None
|
||||
)
|
||||
assert entry.state is ConfigEntryState.LOADED
|
||||
|
||||
|
||||
async def test_streaming_vehicle_coordinator_never_polls(
|
||||
hass: HomeAssistant,
|
||||
mock_vehicle_data: AsyncMock,
|
||||
freezer: FrozenDateTimeFactory,
|
||||
) -> None:
|
||||
"""A plain streaming vehicle is never polled."""
|
||||
await setup_platform(hass, [Platform.BINARY_SENSOR])
|
||||
|
||||
freezer.tick(VEHICLE_INTERVAL)
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert mock_vehicle_data.call_count == 0
|
||||
|
||||
|
||||
async def test_stale_polling_only_entity_removed_on_setup(
|
||||
hass: HomeAssistant,
|
||||
entity_registry: er.EntityRegistry,
|
||||
) -> None:
|
||||
"""Prune a polling-only entity when its vehicle no longer qualifies."""
|
||||
vin = "LRW3F7EK4NC700000"
|
||||
entry = mock_config_entry()
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
# Left over from before the vehicle stopped qualifying for polling.
|
||||
stale = entity_registry.async_get_or_create(
|
||||
Platform.BINARY_SENSOR,
|
||||
DOMAIN,
|
||||
f"{vin}-vehicle_state_is_user_present",
|
||||
config_entry=entry,
|
||||
)
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.teslemetry.PLATFORMS", [Platform.BINARY_SENSOR]
|
||||
):
|
||||
await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Default metadata is a plain streaming vehicle, which no longer qualifies.
|
||||
assert (
|
||||
entity_registry.async_get_entity_id(
|
||||
Platform.BINARY_SENSOR, DOMAIN, stale.unique_id
|
||||
)
|
||||
is None
|
||||
)
|
||||
|
||||
|
||||
async def test_energy_site_version_update(
|
||||
hass: HomeAssistant,
|
||||
device_registry: dr.DeviceRegistry,
|
||||
|
||||
@@ -111,6 +111,9 @@ async def test_rear_seat_heater_configurations(
|
||||
"""
|
||||
metadata = deepcopy(METADATA)
|
||||
metadata["vehicles"][VEHICLE_VIN]["config"] = config
|
||||
# Rear seat heaters are polling-only, so the vehicle must be a polling
|
||||
# vehicle for them to be created at all.
|
||||
metadata["vehicles"][VEHICLE_VIN]["polling"] = True
|
||||
mock_metadata.return_value = metadata
|
||||
|
||||
entry = await setup_platform(hass, [Platform.SELECT])
|
||||
|
||||
Reference in New Issue
Block a user