Gate Teslemetry polling-only vehicle entities behind vehicle metadata (#181351)

This commit is contained in:
Brett Adams
2026-09-08 12:19:06 +02:00
committed by GitHub
parent 08ccec7d68
commit c203dcc762
6 changed files with 189 additions and 44 deletions
@@ -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)
+20 -1
View File
@@ -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
+53 -32
View File
@@ -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):
+11 -1
View File
@@ -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)
+91 -8
View File
@@ -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])