Remove unshared ScorpionTrack vehicles automatically (#181852)

This commit is contained in:
Anthony
2026-09-12 23:39:49 +02:00
committed by GitHub
parent 55066d4ed9
commit ad4dd4b12d
10 changed files with 249 additions and 17 deletions
@@ -24,6 +24,7 @@ async def async_setup_entry(
@callback
def async_add_new_vehicles() -> None:
"""Add ignition sensors for vehicles newly included in the share."""
known_vehicles.intersection_update(coordinator.vehicles_by_id)
new_vehicles = coordinator.vehicles_by_id.keys() - known_vehicles
if not new_vehicles:
return
@@ -15,6 +15,7 @@ from pyscorpiontrack import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryError
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DEFAULT_SCAN_INTERVAL, DOMAIN
@@ -28,6 +29,8 @@ type ScorpionTrackConfigEntry = ConfigEntry[ScorpionTrackCoordinator]
class ScorpionTrackCoordinator(DataUpdateCoordinator[ScorpionTrackShare]):
"""Coordinate shared-location updates."""
config_entry: ScorpionTrackConfigEntry
def __init__(
self,
hass: HomeAssistant,
@@ -68,4 +71,14 @@ class ScorpionTrackCoordinator(DataUpdateCoordinator[ScorpionTrackShare]):
) from err
else:
self.vehicles_by_id = {vehicle.id: vehicle for vehicle in share.vehicles}
device_registry = dr.async_get(self.hass)
vehicle_identifiers = {
(DOMAIN, f"{share.id}_{vehicle_id}")
for vehicle_id in self.vehicles_by_id
}
for device in dr.async_entries_for_config_entry(
device_registry, self.config_entry.entry_id
):
if device.identifiers.isdisjoint(vehicle_identifiers):
device_registry.async_remove_device(device.id)
return share
@@ -26,6 +26,7 @@ async def async_setup_entry(
@callback
def async_add_new_vehicles() -> None:
"""Add trackers for vehicles newly included in the share."""
known_vehicles.intersection_update(coordinator.vehicles_by_id)
new_vehicles = coordinator.vehicles_by_id.keys() - known_vehicles
if not new_vehicles:
return
@@ -83,7 +83,7 @@ rules:
icon-translations: done
reconfiguration-flow: todo
repair-issues: todo
stale-devices: todo
stale-devices: done
# Platinum
async-dependency: done
@@ -79,6 +79,7 @@ async def async_setup_entry(
@callback
def async_add_new_vehicles() -> None:
"""Add sensors for vehicles newly included in the share."""
known_vehicles.intersection_update(coordinator.vehicles_by_id)
new_vehicles = coordinator.vehicles_by_id.keys() - known_vehicles
if not new_vehicles:
return
@@ -93,14 +93,14 @@ async def test_ignition_binary_sensor_availability(
assert state.state == expected_state
async def test_removed_vehicle_makes_ignition_binary_sensor_unavailable(
async def test_removed_vehicle_removes_ignition_binary_sensor(
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
mock_config_entry: MockConfigEntry,
mock_share: ScorpionTrackShare,
mock_scorpiontrack_client: AsyncMock,
) -> None:
"""Test ignition becomes unavailable if its vehicle leaves the share."""
"""Test ignition is removed if its vehicle leaves the share."""
await setup_integration(hass, mock_config_entry)
mock_scorpiontrack_client.async_get_share.return_value = replace(
@@ -110,9 +110,7 @@ async def test_removed_vehicle_makes_ignition_binary_sensor_unavailable(
async_fire_time_changed(hass)
await hass.async_block_till_done()
state = hass.states.get(ENTITY_ID)
assert state is not None
assert state.state == STATE_UNAVAILABLE
assert hass.states.get(ENTITY_ID) is None
async def test_ignition_binary_sensor_uses_existing_vehicle_device(
@@ -0,0 +1,220 @@
"""Test automatic removal of ScorpionTrack vehicles."""
from dataclasses import replace
from unittest.mock import AsyncMock, patch
from freezegun.api import FrozenDateTimeFactory
from pyscorpiontrack import (
ScorpionTrackConnectionError,
ScorpionTrackInvalidTokenError,
ScorpionTrackShare,
ScorpionTrackShareUnavailableError,
)
import pytest
from homeassistant.components.scorpiontrack.const import DEFAULT_SCAN_INTERVAL, DOMAIN
from homeassistant.const import STATE_UNAVAILABLE
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr, entity_registry as er
from . import setup_integration
from tests.common import MockConfigEntry, async_fire_time_changed
@pytest.mark.parametrize("latitude", [51.5074, None])
async def test_shared_vehicle_is_kept(
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
mock_config_entry: MockConfigEntry,
mock_share: ScorpionTrackShare,
mock_scorpiontrack_client: AsyncMock,
device_registry: dr.DeviceRegistry,
latitude: float | None,
) -> None:
"""Keep shared vehicles even when their GPS location is missing."""
await setup_integration(hass, mock_config_entry)
device = device_registry.async_get_device_by_identifier(
(DOMAIN, "101_1"), mock_config_entry.entry_id
)
assert device is not None
vehicle = mock_share.vehicles[0]
mock_scorpiontrack_client.async_get_share.return_value = replace(
mock_share,
vehicles=(
replace(vehicle, position=replace(vehicle.position, latitude=latitude)),
),
)
freezer.tick(DEFAULT_SCAN_INTERVAL)
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert device_registry.async_get(device.id) == device
assert mock_scorpiontrack_client.async_get_share.await_count == 2
@pytest.mark.parametrize(
"exception",
[
ScorpionTrackConnectionError(),
ScorpionTrackInvalidTokenError(),
ScorpionTrackShareUnavailableError(),
],
)
async def test_failed_update_keeps_devices(
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
mock_config_entry: MockConfigEntry,
mock_share: ScorpionTrackShare,
mock_scorpiontrack_client: AsyncMock,
device_registry: dr.DeviceRegistry,
entity_registry: er.EntityRegistry,
exception: Exception,
) -> None:
"""Remove absent vehicles only once a successful update confirms it."""
await setup_integration(hass, mock_config_entry)
device = device_registry.async_get_device_by_identifier(
(DOMAIN, "101_1"), mock_config_entry.entry_id
)
assert device is not None
entities = er.async_entries_for_config_entry(
entity_registry, mock_config_entry.entry_id
)
mock_scorpiontrack_client.async_get_share.side_effect = exception
freezer.tick(DEFAULT_SCAN_INTERVAL)
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert device_registry.async_get(device.id) == device
assert (
er.async_entries_for_config_entry(entity_registry, mock_config_entry.entry_id)
== entities
)
assert hass.states.get("sensor.ab12_cde_speed").state == STATE_UNAVAILABLE
mock_scorpiontrack_client.async_get_share.side_effect = None
mock_scorpiontrack_client.async_get_share.return_value = replace(
mock_share, vehicles=()
)
freezer.tick(DEFAULT_SCAN_INTERVAL)
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert device_registry.async_get(device.id) is None
assert not er.async_entries_for_config_entry(
entity_registry, mock_config_entry.entry_id
)
async def test_removed_vehicle_returns(
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
mock_config_entry: MockConfigEntry,
mock_share: ScorpionTrackShare,
mock_scorpiontrack_client: AsyncMock,
device_registry: dr.DeviceRegistry,
entity_registry: er.EntityRegistry,
) -> None:
"""Remove only this share's device and rediscover it without a reload."""
await setup_integration(hass, mock_config_entry)
entity_registry.async_update_entity(
"sensor.ab12_cde_speed", new_entity_id="sensor.vehicle_speed", name="My speed"
)
await hass.async_block_till_done()
original_entities = {
entity.entity_id: entity.id
for entity in er.async_entries_for_config_entry(
entity_registry, mock_config_entry.entry_id
)
}
other_entry = MockConfigEntry(
domain=DOMAIN, unique_id="202", data={"share_token": "other-token"}
)
other_client = AsyncMock()
other_client.async_get_share.return_value = replace(mock_share, id=202)
with patch(
"homeassistant.components.scorpiontrack.ScorpionTrackClient",
return_value=other_client,
):
await setup_integration(hass, other_entry)
other_entities = er.async_entries_for_config_entry(
entity_registry, other_entry.entry_id
)
device = device_registry.async_get_device_by_identifier(
(DOMAIN, "101_1"), mock_config_entry.entry_id
)
assert device is not None
mock_scorpiontrack_client.async_get_share.return_value = replace(
mock_share, vehicles=()
)
freezer.tick(DEFAULT_SCAN_INTERVAL)
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert device_registry.async_get(device.id) is None
assert not er.async_entries_for_config_entry(
entity_registry, mock_config_entry.entry_id
)
assert all(hass.states.get(entity_id) is None for entity_id in original_entities)
assert er.async_entries_for_config_entry(entity_registry, other_entry.entry_id) == (
other_entities
)
mock_scorpiontrack_client.async_get_share.return_value = mock_share
freezer.tick(DEFAULT_SCAN_INTERVAL)
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert {
entity.entity_id: entity.id
for entity in er.async_entries_for_config_entry(
entity_registry, mock_config_entry.entry_id
)
} == original_entities
assert hass.states.get("sensor.vehicle_speed").state != STATE_UNAVAILABLE
assert entity_registry.async_get("sensor.vehicle_speed").name == "My speed"
assert (
device_registry.async_get_device_by_identifier(
(DOMAIN, "101_1"), mock_config_entry.entry_id
)
is not None
)
assert (
entity_registry.async_get("sensor.ab12_cde_heading").disabled_by
is er.RegistryEntryDisabler.INTEGRATION
)
assert er.async_entries_for_config_entry(entity_registry, other_entry.entry_id) == (
other_entities
)
assert mock_scorpiontrack_client.async_get_share.await_count == 3
async def test_vehicle_removed_while_unloaded(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_share: ScorpionTrackShare,
mock_scorpiontrack_client: AsyncMock,
device_registry: dr.DeviceRegistry,
entity_registry: er.EntityRegistry,
) -> None:
"""Clean up saved devices absent from the first successful setup response."""
await setup_integration(hass, mock_config_entry)
device = device_registry.async_get_device_by_identifier(
(DOMAIN, "101_1"), mock_config_entry.entry_id
)
assert device is not None
assert await hass.config_entries.async_unload(mock_config_entry.entry_id)
mock_scorpiontrack_client.async_get_share.return_value = replace(
mock_share, vehicles=()
)
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert device_registry.async_get(device.id) is None
assert not er.async_entries_for_config_entry(
entity_registry, mock_config_entry.entry_id
)
@@ -33,14 +33,14 @@ async def test_device_tracker_state(
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
async def test_removed_vehicle_becomes_unavailable(
async def test_removed_vehicle_removes_tracker(
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
mock_config_entry: MockConfigEntry,
mock_share: ScorpionTrackShare,
mock_scorpiontrack_client: AsyncMock,
) -> None:
"""Test a tracker becomes unavailable if its vehicle leaves the share."""
"""Test a tracker is removed if its vehicle leaves the share."""
await setup_integration(hass, mock_config_entry)
mock_scorpiontrack_client.async_get_share.return_value = replace(
@@ -50,9 +50,7 @@ async def test_removed_vehicle_becomes_unavailable(
async_fire_time_changed(hass)
await hass.async_block_till_done()
state = hass.states.get("device_tracker.ab12_cde")
assert state is not None
assert state.state == STATE_UNAVAILABLE
assert hass.states.get("device_tracker.ab12_cde") is None
async def test_connection_error_makes_tracker_unavailable(
+3 -1
View File
@@ -147,7 +147,9 @@ async def test_vehicle_added_after_setup(
freezer.tick(DEFAULT_SCAN_INTERVAL)
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert hass.states.get(entity_id).state == STATE_UNAVAILABLE
assert hass.states.get(entity_id) is None
assert entity_registry.async_get(entity_id) is None
assert device_registry.async_get(device.id) is None
mock_scorpiontrack_client.async_get_share.return_value = updated_share
freezer.tick(DEFAULT_SCAN_INTERVAL)
@@ -194,7 +194,7 @@ async def test_speed_sensor_availability(
pytest.param(HEADING_ENTITY_ID, id="heading"),
],
)
async def test_removed_vehicle_makes_sensor_unavailable(
async def test_removed_vehicle_removes_sensor(
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
mock_config_entry: MockConfigEntry,
@@ -202,7 +202,7 @@ async def test_removed_vehicle_makes_sensor_unavailable(
mock_scorpiontrack_client: AsyncMock,
entity_id: str,
) -> None:
"""Test a sensor becomes unavailable if its vehicle leaves the share."""
"""Test a sensor is removed if its vehicle leaves the share."""
await setup_integration(hass, mock_config_entry)
mock_scorpiontrack_client.async_get_share.return_value = replace(
@@ -212,9 +212,7 @@ async def test_removed_vehicle_makes_sensor_unavailable(
async_fire_time_changed(hass)
await hass.async_block_till_done()
state = hass.states.get(entity_id)
assert state is not None
assert state.state == STATE_UNAVAILABLE
assert hass.states.get(entity_id) is None
@pytest.mark.parametrize(