From c203dcc762a5290ddbd719cbaa8416e16b84db24 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Tue, 8 Sep 2026 20:19:06 +1000 Subject: [PATCH] Gate Teslemetry polling-only vehicle entities behind vehicle metadata (#181351) --- .../components/teslemetry/binary_sensor.py | 13 ++- .../components/teslemetry/helpers.py | 21 +++- homeassistant/components/teslemetry/select.py | 85 ++++++++++------ homeassistant/components/teslemetry/sensor.py | 12 ++- tests/components/teslemetry/test_init.py | 99 +++++++++++++++++-- tests/components/teslemetry/test_select.py | 3 + 6 files changed, 189 insertions(+), 44 deletions(-) diff --git a/homeassistant/components/teslemetry/binary_sensor.py b/homeassistant/components/teslemetry/binary_sensor.py index ca0a37127d1b..2761bae6d1b2 100644 --- a/homeassistant/components/teslemetry/binary_sensor.py +++ b/homeassistant/components/teslemetry/binary_sensor.py @@ -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) diff --git a/homeassistant/components/teslemetry/helpers.py b/homeassistant/components/teslemetry/helpers.py index a9cd818aeed9..7c85f8cca9bd 100644 --- a/homeassistant/components/teslemetry/helpers.py +++ b/homeassistant/components/teslemetry/helpers.py @@ -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 diff --git a/homeassistant/components/teslemetry/select.py b/homeassistant/components/teslemetry/select.py index 3601c2d353ff..0f337fddabf5 100644 --- a/homeassistant/components/teslemetry/select.py +++ b/homeassistant/components/teslemetry/select.py @@ -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): diff --git a/homeassistant/components/teslemetry/sensor.py b/homeassistant/components/teslemetry/sensor.py index 5a9c88adb518..1085020024ee 100644 --- a/homeassistant/components/teslemetry/sensor.py +++ b/homeassistant/components/teslemetry/sensor.py @@ -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) diff --git a/tests/components/teslemetry/test_init.py b/tests/components/teslemetry/test_init.py index f01ed390d93b..febc99f0cc5d 100644 --- a/tests/components/teslemetry/test_init.py +++ b/tests/components/teslemetry/test_init.py @@ -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, diff --git a/tests/components/teslemetry/test_select.py b/tests/components/teslemetry/test_select.py index d140f35ffe7a..00ab3dd6fb8b 100644 --- a/tests/components/teslemetry/test_select.py +++ b/tests/components/teslemetry/test_select.py @@ -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])