From 3521db15fa3b4427b97983a08d2cf5fbf2aaf47a Mon Sep 17 00:00:00 2001 From: Anthony <210036686+Herbertmt978@users.noreply.github.com> Date: Thu, 10 Sep 2026 13:09:46 +0100 Subject: [PATCH] Discover newly shared ScorpionTrack vehicles (#181659) --- .../components/scorpiontrack/binary_sensor.py | 22 ++- .../scorpiontrack/device_tracker.py | 22 ++- .../scorpiontrack/quality_scale.yaml | 2 +- .../components/scorpiontrack/sensor.py | 24 +++- .../scorpiontrack/test_device_tracker.py | 18 ++- tests/components/scorpiontrack/test_init.py | 132 +++++++++++++++++- 6 files changed, 197 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/scorpiontrack/binary_sensor.py b/homeassistant/components/scorpiontrack/binary_sensor.py index 6ef69e78bea7..0f89ca263048 100644 --- a/homeassistant/components/scorpiontrack/binary_sensor.py +++ b/homeassistant/components/scorpiontrack/binary_sensor.py @@ -3,7 +3,7 @@ from typing import override from homeassistant.components.binary_sensor import BinarySensorEntity -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import ScorpionTrackConfigEntry, ScorpionTrackCoordinator @@ -19,10 +19,22 @@ async def async_setup_entry( ) -> None: """Set up ScorpionTrack ignition binary sensors.""" coordinator = entry.runtime_data - async_add_entities( - ScorpionTrackIgnitionBinarySensor(coordinator, vehicle.id) - for vehicle in coordinator.data.vehicles - ) + known_vehicles: set[int] = set() + + @callback + def async_add_new_vehicles() -> None: + """Add ignition sensors for vehicles newly included in the share.""" + new_vehicles = coordinator.vehicles_by_id.keys() - known_vehicles + if not new_vehicles: + return + known_vehicles.update(new_vehicles) + async_add_entities( + ScorpionTrackIgnitionBinarySensor(coordinator, vehicle_id) + for vehicle_id in new_vehicles + ) + + async_add_new_vehicles() + entry.async_on_unload(coordinator.async_add_listener(async_add_new_vehicles)) class ScorpionTrackIgnitionBinarySensor(ScorpionTrackEntity, BinarySensorEntity): diff --git a/homeassistant/components/scorpiontrack/device_tracker.py b/homeassistant/components/scorpiontrack/device_tracker.py index 723f7b585f8b..c8e6b1923a96 100644 --- a/homeassistant/components/scorpiontrack/device_tracker.py +++ b/homeassistant/components/scorpiontrack/device_tracker.py @@ -5,7 +5,7 @@ from typing import override from pyscorpiontrack import ScorpionTrackVehicle from homeassistant.components.device_tracker import TrackerEntity -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import ScorpionTrackConfigEntry, ScorpionTrackCoordinator @@ -21,10 +21,22 @@ async def async_setup_entry( ) -> None: """Set up ScorpionTrack tracker entities.""" coordinator = entry.runtime_data - async_add_entities( - ScorpionTrackTrackerEntity(coordinator, vehicle.id) - for vehicle in coordinator.data.vehicles - ) + known_vehicles: set[int] = set() + + @callback + def async_add_new_vehicles() -> None: + """Add trackers for vehicles newly included in the share.""" + new_vehicles = coordinator.vehicles_by_id.keys() - known_vehicles + if not new_vehicles: + return + known_vehicles.update(new_vehicles) + async_add_entities( + ScorpionTrackTrackerEntity(coordinator, vehicle_id) + for vehicle_id in new_vehicles + ) + + async_add_new_vehicles() + entry.async_on_unload(coordinator.async_add_listener(async_add_new_vehicles)) class ScorpionTrackTrackerEntity(ScorpionTrackEntity, TrackerEntity): diff --git a/homeassistant/components/scorpiontrack/quality_scale.yaml b/homeassistant/components/scorpiontrack/quality_scale.yaml index a41fb6e77f82..23b86489161d 100644 --- a/homeassistant/components/scorpiontrack/quality_scale.yaml +++ b/homeassistant/components/scorpiontrack/quality_scale.yaml @@ -74,7 +74,7 @@ rules: docs-supported-functions: done docs-troubleshooting: done docs-use-cases: todo - dynamic-devices: todo + dynamic-devices: done entity-category: done entity-device-class: done entity-disabled-by-default: done diff --git a/homeassistant/components/scorpiontrack/sensor.py b/homeassistant/components/scorpiontrack/sensor.py index 286e9b59af47..1dde86826c37 100644 --- a/homeassistant/components/scorpiontrack/sensor.py +++ b/homeassistant/components/scorpiontrack/sensor.py @@ -14,7 +14,7 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.const import DEGREE, EntityCategory, UnitOfSpeed -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType @@ -74,11 +74,23 @@ async def async_setup_entry( ) -> None: """Set up ScorpionTrack sensors.""" coordinator = entry.runtime_data - async_add_entities( - ScorpionTrackSensor(coordinator, vehicle.id, entity_description) - for vehicle in coordinator.data.vehicles - for entity_description in SENSORS - ) + known_vehicles: set[int] = set() + + @callback + def async_add_new_vehicles() -> None: + """Add sensors for vehicles newly included in the share.""" + new_vehicles = coordinator.vehicles_by_id.keys() - known_vehicles + if not new_vehicles: + return + known_vehicles.update(new_vehicles) + async_add_entities( + ScorpionTrackSensor(coordinator, vehicle_id, entity_description) + for vehicle_id in new_vehicles + for entity_description in SENSORS + ) + + async_add_new_vehicles() + entry.async_on_unload(coordinator.async_add_listener(async_add_new_vehicles)) class ScorpionTrackSensor(ScorpionTrackEntity, SensorEntity): diff --git a/tests/components/scorpiontrack/test_device_tracker.py b/tests/components/scorpiontrack/test_device_tracker.py index 22e8c8b77d3d..6a06881d3ea6 100644 --- a/tests/components/scorpiontrack/test_device_tracker.py +++ b/tests/components/scorpiontrack/test_device_tracker.py @@ -5,10 +5,11 @@ from unittest.mock import AsyncMock, patch from freezegun.api import FrozenDateTimeFactory from pyscorpiontrack import ScorpionTrackConnectionError, ScorpionTrackShare +import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components.scorpiontrack.const import DEFAULT_SCAN_INTERVAL -from homeassistant.const import STATE_UNAVAILABLE, Platform +from homeassistant.const import STATE_NOT_HOME, STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -75,14 +76,20 @@ async def test_connection_error_makes_tracker_unavailable( assert state.state == STATE_UNAVAILABLE -async def test_new_vehicles_after_setup_do_not_add_tracker_entities( +@pytest.mark.parametrize( + ("latitude", "expected_state"), + [(51.5074, STATE_NOT_HOME), (None, STATE_UNAVAILABLE)], +) +async def test_new_vehicle_tracker_availability( hass: HomeAssistant, freezer: FrozenDateTimeFactory, mock_config_entry: MockConfigEntry, mock_share: ScorpionTrackShare, mock_scorpiontrack_client: AsyncMock, + latitude: float | None, + expected_state: str, ) -> None: - """Vehicles that appear later should wait for a future dynamic-device PR.""" + """Test a newly discovered tracker still requires coordinates.""" await setup_integration(hass, mock_config_entry) new_vehicle = replace( @@ -91,6 +98,7 @@ async def test_new_vehicles_after_setup_do_not_add_tracker_entities( name="Tiguan", registration="EF34 ABC", model="Tiguan", + position=replace(mock_share.vehicles[0].position, latitude=latitude), ) mock_scorpiontrack_client.async_get_share.return_value = replace( mock_share, vehicles=(*mock_share.vehicles, new_vehicle) @@ -99,4 +107,6 @@ async def test_new_vehicles_after_setup_do_not_add_tracker_entities( async_fire_time_changed(hass) await hass.async_block_till_done() - assert hass.states.get("device_tracker.ef34_abc") is None + state = hass.states.get("device_tracker.ef34_abc") + assert state is not None + assert state.state == expected_state diff --git a/tests/components/scorpiontrack/test_init.py b/tests/components/scorpiontrack/test_init.py index ee5b059acb97..e1c048981229 100644 --- a/tests/components/scorpiontrack/test_init.py +++ b/tests/components/scorpiontrack/test_init.py @@ -1,21 +1,26 @@ """Test ScorpionTrack integration setup.""" +from dataclasses import replace from unittest.mock import AsyncMock +from freezegun.api import FrozenDateTimeFactory from pyscorpiontrack import ( ScorpionTrackConnectionError, ScorpionTrackInvalidTokenError, + ScorpionTrackShare, ScorpionTrackShareUnavailableError, ) import pytest +from homeassistant.components.scorpiontrack.const import DEFAULT_SCAN_INTERVAL from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import device_registry as dr, entity_registry as er from . import setup_integration -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed async def test_setup_entry( @@ -74,3 +79,126 @@ async def test_setup_entry_errors( await setup_integration(hass, mock_config_entry) assert mock_config_entry.state is expected_state + + +@pytest.mark.parametrize( + "entity_id", + [ + "device_tracker.xy34_abc", + "sensor.xy34_abc_speed", + "sensor.xy34_abc_last_reported", + "binary_sensor.xy34_abc_ignition", + ], +) +async def test_vehicle_added_after_setup( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_config_entry: MockConfigEntry, + mock_share: ScorpionTrackShare, + mock_scorpiontrack_client: AsyncMock, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + entity_id: str, +) -> None: + """Test discovery, repeat updates, returning vehicles, and reload.""" + await setup_integration(hass, mock_config_entry) + assert hass.states.get(entity_id) is None + initial_entities = len( + er.async_entries_for_config_entry(entity_registry, mock_config_entry.entry_id) + ) + + added_vehicle = replace(mock_share.vehicles[0], id=2, registration="XY34 ABC") + updated_share = replace(mock_share, vehicles=(*mock_share.vehicles, added_vehicle)) + mock_scorpiontrack_client.async_get_share.return_value = updated_share + freezer.tick(DEFAULT_SCAN_INTERVAL) + 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 + registry_entry = entity_registry.async_get(entity_id) + assert registry_entry is not None + device = device_registry.async_get_device_by_identifier( + ("scorpiontrack", "101_2"), mock_config_entry.entry_id + ) + assert device is not None + assert registry_entry.device_id == device.id + heading = entity_registry.async_get("sensor.xy34_abc_heading") + assert heading is not None + assert heading.device_id == device.id + assert heading.disabled_by is er.RegistryEntryDisabler.INTEGRATION + assert hass.states.get(heading.entity_id) is None + assert mock_scorpiontrack_client.async_get_share.await_count == 2 + + freezer.tick(DEFAULT_SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert ( + len( + er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + ) + == initial_entities * 2 + ) + + 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 hass.states.get(entity_id).state == STATE_UNAVAILABLE + + mock_scorpiontrack_client.async_get_share.return_value = updated_share + freezer.tick(DEFAULT_SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert hass.states.get(entity_id).state == state.state + assert entity_registry.async_get(entity_id).id == registry_entry.id + assert ( + len( + er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + ) + == initial_entities * 2 + ) + + assert await hass.config_entries.async_reload(mock_config_entry.entry_id) + await hass.async_block_till_done() + assert hass.states.get(entity_id).state == state.state + assert entity_registry.async_get(entity_id).id == registry_entry.id + + +async def test_discovery_stops_on_unload( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_config_entry: MockConfigEntry, + mock_share: ScorpionTrackShare, + mock_scorpiontrack_client: AsyncMock, + entity_registry: er.EntityRegistry, +) -> None: + """Test unloading removes the discovery listeners.""" + await setup_integration(hass, mock_config_entry) + initial_entities = len( + er.async_entries_for_config_entry(entity_registry, mock_config_entry.entry_id) + ) + await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + added_vehicle = replace(mock_share.vehicles[0], id=2, registration="XY34 ABC") + mock_scorpiontrack_client.async_get_share.return_value = replace( + mock_share, vehicles=(*mock_share.vehicles, added_vehicle) + ) + freezer.tick(DEFAULT_SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + mock_scorpiontrack_client.async_get_share.assert_awaited_once_with() + assert ( + len( + er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + ) + == initial_entities + )