diff --git a/CODEOWNERS b/CODEOWNERS index b3642514baf1..cedde68ab496 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1586,6 +1586,8 @@ CLAUDE.md @home-assistant/core /homeassistant/components/schlage/ @dknowles2 /tests/components/schlage/ @dknowles2 /homeassistant/components/schluter/ @prairieapps +/homeassistant/components/scorpiontrack/ @Herbertmt978 +/tests/components/scorpiontrack/ @Herbertmt978 /homeassistant/components/scrape/ @fabaff @gjohansson-ST /tests/components/scrape/ @fabaff @gjohansson-ST /homeassistant/components/screenlogic/ @dieselrabbit @bdraco diff --git a/homeassistant/components/scorpiontrack/__init__.py b/homeassistant/components/scorpiontrack/__init__.py new file mode 100644 index 000000000000..ebb42ebe8ee1 --- /dev/null +++ b/homeassistant/components/scorpiontrack/__init__.py @@ -0,0 +1,32 @@ +"""The ScorpionTrack integration.""" + +from pyscorpiontrack import ScorpionTrackClient + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import CONF_SHARE_TOKEN, PLATFORMS +from .coordinator import ScorpionTrackConfigEntry, ScorpionTrackCoordinator + + +async def async_setup_entry( + hass: HomeAssistant, entry: ScorpionTrackConfigEntry +) -> bool: + """Set up ScorpionTrack from a config entry.""" + client = ScorpionTrackClient( + session=async_get_clientsession(hass), + token=entry.data[CONF_SHARE_TOKEN], + ) + coordinator = ScorpionTrackCoordinator(hass, client, entry) + + await coordinator.async_config_entry_first_refresh() + entry.runtime_data = coordinator + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def async_unload_entry( + hass: HomeAssistant, entry: ScorpionTrackConfigEntry +) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/scorpiontrack/config_flow.py b/homeassistant/components/scorpiontrack/config_flow.py new file mode 100644 index 000000000000..f680177219f3 --- /dev/null +++ b/homeassistant/components/scorpiontrack/config_flow.py @@ -0,0 +1,92 @@ +"""Config flow for ScorpionTrack.""" + +import logging +from typing import Any, override + +from pyscorpiontrack import ( + ScorpionTrackClient, + ScorpionTrackConnectionError, + ScorpionTrackInvalidTokenError, + ScorpionTrackShare, + ScorpionTrackShareUnavailableError, +) +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import CONF_SHARE_TOKEN, DEFAULT_NAME, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +async def _async_validate_input( + hass: HomeAssistant, user_input: dict[str, Any] +) -> ScorpionTrackShare: + """Validate the provided share token or share URL.""" + try: + normalized_token = ScorpionTrackClient.extract_token( + user_input[CONF_SHARE_TOKEN] + ) + except (ScorpionTrackInvalidTokenError, ValueError) as err: + raise ScorpionTrackInvalidTokenError( + "Invalid ScorpionTrack share token" + ) from err + + client = ScorpionTrackClient( + session=async_get_clientsession(hass), + token=normalized_token, + ) + return await client.async_get_share() + + +def _share_title(share: ScorpionTrackShare) -> str: + """Return the best config entry title for a share.""" + if share.title: + return share.title + if share.vehicles: + return share.vehicles[0].display_name + return DEFAULT_NAME + + +class ScorpionTrackConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for ScorpionTrack.""" + + VERSION = 1 + + @override + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + + if user_input is not None: + try: + share = await _async_validate_input(self.hass, user_input) + except ScorpionTrackConnectionError: + errors["base"] = "cannot_connect" + except ScorpionTrackInvalidTokenError: + errors["base"] = "invalid_token" + except ScorpionTrackShareUnavailableError: + errors["base"] = "share_unavailable" + except Exception: + _LOGGER.exception( + "Unexpected exception while validating ScorpionTrack share" + ) + errors["base"] = "unknown" + else: + await self.async_set_unique_id(str(share.id)) + self._abort_if_unique_id_configured() + user_input[CONF_SHARE_TOKEN] = share.token + return self.async_create_entry( + title=_share_title(share), + data=user_input, + ) + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema({vol.Required(CONF_SHARE_TOKEN): str}), + errors=errors, + ) diff --git a/homeassistant/components/scorpiontrack/const.py b/homeassistant/components/scorpiontrack/const.py new file mode 100644 index 000000000000..851d745a35bc --- /dev/null +++ b/homeassistant/components/scorpiontrack/const.py @@ -0,0 +1,15 @@ +"""Constants for the ScorpionTrack integration.""" + +from datetime import timedelta + +from homeassistant.const import Platform + +DOMAIN = "scorpiontrack" +DEFAULT_NAME = "ScorpionTrack" +MANUFACTURER = "ScorpionTrack" + +CONF_SHARE_TOKEN = "share_token" + +DEFAULT_SCAN_INTERVAL = timedelta(minutes=2) + +PLATFORMS: tuple[Platform, ...] = (Platform.DEVICE_TRACKER,) diff --git a/homeassistant/components/scorpiontrack/coordinator.py b/homeassistant/components/scorpiontrack/coordinator.py new file mode 100644 index 000000000000..c8eda3c0a690 --- /dev/null +++ b/homeassistant/components/scorpiontrack/coordinator.py @@ -0,0 +1,71 @@ +"""Coordinator for ScorpionTrack.""" + +import logging +from typing import override + +from pyscorpiontrack import ( + ScorpionTrackClient, + ScorpionTrackConnectionError, + ScorpionTrackInvalidTokenError, + ScorpionTrackShare, + ScorpionTrackShareUnavailableError, + ScorpionTrackVehicle, +) + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryError +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DEFAULT_SCAN_INTERVAL, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +type ScorpionTrackConfigEntry = ConfigEntry[ScorpionTrackCoordinator] + + +class ScorpionTrackCoordinator(DataUpdateCoordinator[ScorpionTrackShare]): + """Coordinate shared-location updates.""" + + def __init__( + self, + hass: HomeAssistant, + client: ScorpionTrackClient, + entry: ScorpionTrackConfigEntry, + ) -> None: + """Initialize the coordinator.""" + self.client = client + self.vehicles_by_id: dict[int, ScorpionTrackVehicle] = {} + super().__init__( + hass, + _LOGGER, + config_entry=entry, + name=f"{DOMAIN}_{entry.entry_id}", + update_interval=DEFAULT_SCAN_INTERVAL, + always_update=False, + ) + + @override + async def _async_update_data(self) -> ScorpionTrackShare: + """Fetch updated share data.""" + try: + share = await self.client.async_get_share() + except ScorpionTrackConnectionError as err: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="cannot_connect", + ) from err + except ScorpionTrackInvalidTokenError as err: + raise ConfigEntryError( + translation_domain=DOMAIN, + translation_key="invalid_token", + ) from err + except ScorpionTrackShareUnavailableError as err: + raise ConfigEntryError( + translation_domain=DOMAIN, + translation_key="share_unavailable", + ) from err + else: + self.vehicles_by_id = {vehicle.id: vehicle for vehicle in share.vehicles} + return share diff --git a/homeassistant/components/scorpiontrack/device_tracker.py b/homeassistant/components/scorpiontrack/device_tracker.py new file mode 100644 index 000000000000..723f7b585f8b --- /dev/null +++ b/homeassistant/components/scorpiontrack/device_tracker.py @@ -0,0 +1,76 @@ +"""Device tracker platform for ScorpionTrack.""" + +from typing import override + +from pyscorpiontrack import ScorpionTrackVehicle + +from homeassistant.components.device_tracker import TrackerEntity +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import ScorpionTrackConfigEntry, ScorpionTrackCoordinator +from .entity import ScorpionTrackEntity + +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ScorpionTrackConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up ScorpionTrack tracker entities.""" + coordinator = entry.runtime_data + async_add_entities( + ScorpionTrackTrackerEntity(coordinator, vehicle.id) + for vehicle in coordinator.data.vehicles + ) + + +class ScorpionTrackTrackerEntity(ScorpionTrackEntity, TrackerEntity): + """Represent the latest shared GPS location for a vehicle.""" + + _attr_name = None + _attr_translation_key = "vehicle_location" + + def __init__(self, coordinator: ScorpionTrackCoordinator, vehicle_id: int) -> None: + """Initialize the tracker.""" + super().__init__(coordinator, vehicle_id) + self._attr_unique_id = f"{coordinator.data.id}_{vehicle_id}" + + def _available_vehicle(self) -> ScorpionTrackVehicle | None: + """Return the vehicle if the tracker is available.""" + if not super().available: + return None + return self.get_vehicle() + + @property + @override + def available(self) -> bool: + """Return if the tracker is available.""" + vehicle = self._available_vehicle() + if vehicle is None: + return False + + return ( + vehicle.position.latitude is not None + and vehicle.position.longitude is not None + ) + + @property + @override + def latitude(self) -> float | None: + """Return the latitude.""" + vehicle = self._available_vehicle() + if vehicle is None: + return None + return vehicle.position.latitude + + @property + @override + def longitude(self) -> float | None: + """Return the longitude.""" + vehicle = self._available_vehicle() + if vehicle is None: + return None + return vehicle.position.longitude diff --git a/homeassistant/components/scorpiontrack/entity.py b/homeassistant/components/scorpiontrack/entity.py new file mode 100644 index 000000000000..2fa3c597d6ce --- /dev/null +++ b/homeassistant/components/scorpiontrack/entity.py @@ -0,0 +1,44 @@ +"""Shared entity helpers for ScorpionTrack.""" + +from typing import override + +from pyscorpiontrack import ScorpionTrackShare, ScorpionTrackVehicle + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN, MANUFACTURER +from .coordinator import ScorpionTrackCoordinator + + +class ScorpionTrackEntity(CoordinatorEntity[ScorpionTrackCoordinator]): + """Base class for ScorpionTrack vehicle entities.""" + + _attr_has_entity_name = True + + def __init__(self, coordinator: ScorpionTrackCoordinator, vehicle_id: int) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + self._vehicle_id = vehicle_id + vehicle = self.get_vehicle() + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, f"{self.share.id}_{vehicle_id}")}, + manufacturer=vehicle.make or MANUFACTURER, + model=vehicle.model, + name=vehicle.display_name, + ) + + @property + def share(self) -> ScorpionTrackShare: + """Return the active share data.""" + return self.coordinator.data + + def get_vehicle(self) -> ScorpionTrackVehicle: + """Return the matching vehicle.""" + return self.coordinator.vehicles_by_id[self._vehicle_id] + + @property + @override + def available(self) -> bool: + """Return if the entity is available.""" + return super().available and self._vehicle_id in self.coordinator.vehicles_by_id diff --git a/homeassistant/components/scorpiontrack/icons.json b/homeassistant/components/scorpiontrack/icons.json new file mode 100644 index 000000000000..a792c09e859c --- /dev/null +++ b/homeassistant/components/scorpiontrack/icons.json @@ -0,0 +1,9 @@ +{ + "entity": { + "device_tracker": { + "vehicle_location": { + "default": "mdi:car" + } + } + } +} diff --git a/homeassistant/components/scorpiontrack/manifest.json b/homeassistant/components/scorpiontrack/manifest.json new file mode 100644 index 000000000000..0ced3f6c03d5 --- /dev/null +++ b/homeassistant/components/scorpiontrack/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "scorpiontrack", + "name": "ScorpionTrack", + "codeowners": ["@Herbertmt978"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/scorpiontrack", + "integration_type": "hub", + "iot_class": "cloud_polling", + "loggers": ["pyscorpiontrack"], + "quality_scale": "silver", + "requirements": ["pyscorpiontrack==0.1.1"] +} diff --git a/homeassistant/components/scorpiontrack/quality_scale.yaml b/homeassistant/components/scorpiontrack/quality_scale.yaml new file mode 100644 index 000000000000..2cacc2339081 --- /dev/null +++ b/homeassistant/components/scorpiontrack/quality_scale.yaml @@ -0,0 +1,93 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: This integration does not provide actions. + appropriate-polling: done + brands: done + common-modules: done + config-flow: done + config-flow-test-coverage: done + dependency-transparency: done + docs-actions: + status: exempt + comment: This integration does not provide actions. + docs-triggers: + status: exempt + comment: This integration does not provide triggers. + docs-conditions: + status: exempt + comment: This integration does not provide conditions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: done + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: This integration does not provide actions. + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: This integration does not provide an options flow. + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: + status: exempt + comment: | + ScorpionTrack share links are public tokens rather than account credentials. + Replacing an expired or revoked share requires configuring a new share link. + test-coverage: done + # Gold + devices: done + diagnostics: todo + discovery: + status: exempt + comment: | + Vehicles are exposed through a ScorpionTrack cloud shared-location/API + link generated from the ScorpionTrack website. The trackers are standalone + units that communicate over their SIM/cellular connection rather than + Wi-Fi or the local network, so Home Assistant has no local discovery + mechanism. + discovery-update-info: + status: exempt + comment: | + Vehicles are exposed through a ScorpionTrack cloud shared-location/API + link generated from the ScorpionTrack website. The trackers are standalone + units that communicate over their SIM/cellular connection rather than + Wi-Fi or the local network, so Home Assistant has no local discovery + mechanism. + docs-data-update: done + docs-examples: todo + docs-known-limitations: done + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: todo + dynamic-devices: todo + entity-category: done + entity-device-class: done + entity-disabled-by-default: + status: exempt + comment: The created entities are useful for shared vehicle tracking and should be enabled by default. + entity-translations: done + exception-translations: done + icon-translations: done + reconfiguration-flow: todo + repair-issues: todo + stale-devices: todo + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: todo diff --git a/homeassistant/components/scorpiontrack/strings.json b/homeassistant/components/scorpiontrack/strings.json new file mode 100644 index 000000000000..63595a78ae09 --- /dev/null +++ b/homeassistant/components/scorpiontrack/strings.json @@ -0,0 +1,36 @@ +{ + "config": { + "abort": { + "already_configured": "This shared location is already configured." + }, + "error": { + "cannot_connect": "The ScorpionTrack share could not be reached right now.", + "invalid_token": "Your shared-location link is invalid or incomplete.", + "share_unavailable": "Your shared-location link is invalid, expired, has been revoked, or no longer returns data.", + "unknown": "An unexpected error occurred." + }, + "step": { + "user": { + "data": { + "share_token": "Share URL or token" + }, + "data_description": { + "share_token": "Paste the full ScorpionTrack shared-location URL or just the token itself." + }, + "description": "Paste a ScorpionTrack shared-location URL or the token from that URL.", + "title": "Connect a ScorpionTrack share" + } + } + }, + "exceptions": { + "cannot_connect": { + "message": "The ScorpionTrack share could not be reached right now." + }, + "invalid_token": { + "message": "ScorpionTrack rejected the configured share token." + }, + "share_unavailable": { + "message": "The shared location is unavailable." + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index f3c531ae4f15..0d2fea79361d 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -669,6 +669,7 @@ FLOWS = { "satel_integra", "saunum", "schlage", + "scorpiontrack", "scrape", "screenlogic", "season", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 4957ec3ce51a..6459b2ea5ca1 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -6185,6 +6185,12 @@ "integration_type": "virtual", "supported_by": "opower" }, + "scorpiontrack": { + "name": "ScorpionTrack", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling" + }, "scrape": { "name": "Scrape", "integration_type": "hub", diff --git a/requirements_all.txt b/requirements_all.txt index ed824d5ed04f..37b079fcb578 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2534,6 +2534,9 @@ pysaunum==0.7.0 # homeassistant.components.schlage pyschlage==2025.9.0 +# homeassistant.components.scorpiontrack +pyscorpiontrack==0.1.1 + # homeassistant.components.sensibo pysensibo==1.2.1 diff --git a/tests/components/scorpiontrack/__init__.py b/tests/components/scorpiontrack/__init__.py new file mode 100644 index 000000000000..e6917b6c4a9b --- /dev/null +++ b/tests/components/scorpiontrack/__init__.py @@ -0,0 +1,12 @@ +"""Tests for the ScorpionTrack integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Set up the ScorpionTrack integration.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/scorpiontrack/conftest.py b/tests/components/scorpiontrack/conftest.py new file mode 100644 index 000000000000..efc871fafb79 --- /dev/null +++ b/tests/components/scorpiontrack/conftest.py @@ -0,0 +1,86 @@ +"""Test fixtures for the ScorpionTrack integration.""" + +from collections.abc import Iterator +from datetime import timedelta +from unittest.mock import AsyncMock, patch + +from pyscorpiontrack import ( + ScorpionTrackClient, + ScorpionTrackPosition, + ScorpionTrackShare, + ScorpionTrackVehicle, +) +import pytest + +from homeassistant.components.scorpiontrack.const import CONF_SHARE_TOKEN, DOMAIN +from homeassistant.util import dt as dt_util + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_share() -> ScorpionTrackShare: + """Return a representative ScorpionTrack share.""" + now = dt_util.utcnow() + return ScorpionTrackShare( + id=101, + token="canonical-token", + title="Family Cars", + owner_name="Ashby Herbert", + distance_units="miles", + created_at=now - timedelta(days=3), + expires_at=now + timedelta(days=28), + vehicles=( + ScorpionTrackVehicle( + id=1, + name="Golf R", + registration="AB12 CDE", + make="Volkswagen", + model="Golf R", + position=ScorpionTrackPosition( + latitude=51.5074, + longitude=-0.1278, + timestamp=now - timedelta(days=2), + speed_kmh=48.3, + ignition=True, + bearing=182.0, + address="Westminster, London", + ), + status="Moving", + ), + ), + ) + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return a mock config entry.""" + return MockConfigEntry( + domain=DOMAIN, + title="Family Cars", + data={CONF_SHARE_TOKEN: "canonical-token"}, + unique_id="101", + entry_id="01SCORPIONTRACK_TEST_ENTRY", + ) + + +@pytest.fixture(autouse=True) +def mock_scorpiontrack_client( + mock_share: ScorpionTrackShare, +) -> Iterator[AsyncMock]: + """Mock the ScorpionTrack client.""" + with ( + patch( + "homeassistant.components.scorpiontrack.ScorpionTrackClient", + autospec=True, + ) as mock_client, + patch( + "homeassistant.components.scorpiontrack.config_flow.ScorpionTrackClient", + new=mock_client, + ), + ): + client = mock_client.return_value + mock_client.extract_token.side_effect = ScorpionTrackClient.extract_token + client.token = "canonical-token" + client.async_get_share.return_value = mock_share + yield client diff --git a/tests/components/scorpiontrack/snapshots/test_device_tracker.ambr b/tests/components/scorpiontrack/snapshots/test_device_tracker.ambr new file mode 100644 index 000000000000..3f2df867a0ca --- /dev/null +++ b/tests/components/scorpiontrack/snapshots/test_device_tracker.ambr @@ -0,0 +1,60 @@ +# serializer version: 1 +# name: test_device_tracker_state[device_tracker.ab12_cde-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'device_tracker', + 'entity_category': , + 'entity_id': 'device_tracker.ab12_cde', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'scorpiontrack', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'vehicle_location', + 'unique_id': '101_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_device_tracker_state[device_tracker.ab12_cde-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'AB12 CDE', + : 0, + : list([ + ]), + : 51.5074, + : -0.1278, + : , + : , + }), + 'context': , + 'entity_id': 'device_tracker.ab12_cde', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'not_home', + }) +# --- diff --git a/tests/components/scorpiontrack/test_config_flow.py b/tests/components/scorpiontrack/test_config_flow.py new file mode 100644 index 000000000000..e970093b7a8c --- /dev/null +++ b/tests/components/scorpiontrack/test_config_flow.py @@ -0,0 +1,199 @@ +"""Test the ScorpionTrack config flow.""" + +from dataclasses import replace +from unittest.mock import AsyncMock, patch + +from pyscorpiontrack import ( + ScorpionTrackConnectionError, + ScorpionTrackInvalidTokenError, + ScorpionTrackShare, + ScorpionTrackShareUnavailableError, +) +import pytest + +from homeassistant.components.scorpiontrack.const import ( + CONF_SHARE_TOKEN, + DEFAULT_NAME, + DOMAIN, +) +from homeassistant.config_entries import SOURCE_USER +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResult, FlowResultType + +from tests.common import MockConfigEntry + + +async def _async_start_user_flow(hass: HomeAssistant) -> FlowResult: + """Start the user config flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + return result + + +async def test_user_flow_creates_entry( + hass: HomeAssistant, + mock_scorpiontrack_client: AsyncMock, +) -> None: + """A valid token should create a config entry.""" + result = await _async_start_user_flow(hass) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_SHARE_TOKEN: ( + " https://app.scorpiontrack.com/shared/location" + "?token=canonical-token " + ) + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Family Cars" + assert result["data"] == {CONF_SHARE_TOKEN: "canonical-token"} + assert result["result"].unique_id == "101" + mock_scorpiontrack_client.async_get_share.assert_awaited() + + +async def test_user_flow_uses_vehicle_display_name_without_share_title( + hass: HomeAssistant, + mock_share: ScorpionTrackShare, + mock_scorpiontrack_client: AsyncMock, +) -> None: + """The config entry title should fall back to the first vehicle display name.""" + share = replace(mock_share, title="") + mock_scorpiontrack_client.async_get_share.return_value = share + + result = await _async_start_user_flow(hass) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_SHARE_TOKEN: "canonical-token"}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == share.vehicles[0].display_name + assert result["result"].unique_id == "101" + + +async def test_user_flow_uses_default_name_without_title_or_vehicles( + hass: HomeAssistant, + mock_share: ScorpionTrackShare, + mock_scorpiontrack_client: AsyncMock, +) -> None: + """The config entry title should use the default name when nothing descriptive exists.""" + share = replace(mock_share, title="", vehicles=()) + mock_scorpiontrack_client.async_get_share.return_value = share + + result = await _async_start_user_flow(hass) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_SHARE_TOKEN: "canonical-token"}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == DEFAULT_NAME + assert result["result"].unique_id == "101" + + +async def test_user_flow_aborts_for_existing_share( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """The same share should not be configured twice.""" + mock_config_entry.add_to_hass(hass) + + result = await _async_start_user_flow(hass) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_SHARE_TOKEN: "canonical-token"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.parametrize( + ("side_effect", "expected_error"), + [ + ( + ScorpionTrackConnectionError("Connection failed"), + "cannot_connect", + ), + (ScorpionTrackInvalidTokenError("Invalid token"), "invalid_token"), + ( + ScorpionTrackShareUnavailableError("Share expired"), + "share_unavailable", + ), + (Exception("Unexpected error"), "unknown"), + ], +) +async def test_user_flow_shows_validation_errors( + hass: HomeAssistant, + mock_scorpiontrack_client: AsyncMock, + side_effect: Exception, + expected_error: str, +) -> None: + """Validation errors should surface the correct base error.""" + mock_scorpiontrack_client.async_get_share.side_effect = side_effect + + result = await _async_start_user_flow(hass) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_SHARE_TOKEN: "canonical-token"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": expected_error} + + +async def test_user_flow_maps_malformed_input_to_invalid_token( + hass: HomeAssistant, +) -> None: + """Malformed pasted values should show the invalid-token form error.""" + with patch( + "homeassistant.components.scorpiontrack.config_flow.ScorpionTrackClient.extract_token", + side_effect=ValueError("Could not parse share link"), + ): + result = await _async_start_user_flow(hass) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_SHARE_TOKEN: "not a share"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "invalid_token"} + + +async def test_user_flow_recovers_after_invalid_token( + hass: HomeAssistant, +) -> None: + """The user flow should recover after an invalid token error.""" + result = await _async_start_user_flow(hass) + + with patch( + "homeassistant.components.scorpiontrack.config_flow.ScorpionTrackClient.extract_token", + side_effect=ScorpionTrackInvalidTokenError("Invalid token"), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_SHARE_TOKEN: "not a share"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "invalid_token"} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_SHARE_TOKEN: "canonical-token"}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == {CONF_SHARE_TOKEN: "canonical-token"} + assert result["result"].unique_id == "101" diff --git a/tests/components/scorpiontrack/test_device_tracker.py b/tests/components/scorpiontrack/test_device_tracker.py new file mode 100644 index 000000000000..2dc2ab4c0681 --- /dev/null +++ b/tests/components/scorpiontrack/test_device_tracker.py @@ -0,0 +1,99 @@ +"""Test the ScorpionTrack device tracker platform.""" + +from dataclasses import replace +from unittest.mock import AsyncMock + +from freezegun.api import FrozenDateTimeFactory +from pyscorpiontrack import ScorpionTrackConnectionError, ScorpionTrackShare +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.scorpiontrack.const import DEFAULT_SCAN_INTERVAL +from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + + +async def test_device_tracker_state( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test the ScorpionTrack device tracker state and attributes.""" + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_removed_vehicle_becomes_unavailable( + 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.""" + await setup_integration(hass, mock_config_entry) + + 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() + + state = hass.states.get("device_tracker.ab12_cde") + assert state is not None + assert state.state == STATE_UNAVAILABLE + + +async def test_connection_error_makes_tracker_unavailable( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_config_entry: MockConfigEntry, + mock_scorpiontrack_client: AsyncMock, +) -> None: + """Test a tracker becomes unavailable after a refresh connection error.""" + await setup_integration(hass, mock_config_entry) + + mock_scorpiontrack_client.async_get_share.side_effect = ( + ScorpionTrackConnectionError("Connection failed") + ) + freezer.tick(DEFAULT_SCAN_INTERVAL) + 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 + + +async def test_new_vehicles_after_setup_do_not_add_tracker_entities( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_config_entry: MockConfigEntry, + mock_share: ScorpionTrackShare, + mock_scorpiontrack_client: AsyncMock, +) -> None: + """Vehicles that appear later should wait for a future dynamic-device PR.""" + await setup_integration(hass, mock_config_entry) + + new_vehicle = replace( + mock_share.vehicles[0], + id=2, + name="Tiguan", + registration="EF34 ABC", + model="Tiguan", + ) + mock_scorpiontrack_client.async_get_share.return_value = replace( + mock_share, vehicles=(*mock_share.vehicles, new_vehicle) + ) + freezer.tick(DEFAULT_SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get("device_tracker.ef34_abc") is None diff --git a/tests/components/scorpiontrack/test_init.py b/tests/components/scorpiontrack/test_init.py new file mode 100644 index 000000000000..570e222f072e --- /dev/null +++ b/tests/components/scorpiontrack/test_init.py @@ -0,0 +1,74 @@ +"""Test ScorpionTrack integration setup.""" + +from unittest.mock import AsyncMock + +from pyscorpiontrack import ( + ScorpionTrackConnectionError, + ScorpionTrackInvalidTokenError, + ScorpionTrackShareUnavailableError, +) +import pytest + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from . import setup_integration + +from tests.common import MockConfigEntry + + +async def test_setup_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test successful setup and unload of entry.""" + await setup_integration(hass, mock_config_entry) + assert mock_config_entry.state is ConfigEntryState.LOADED + + await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + + +async def test_device_is_registered( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test the ScorpionTrack vehicle device is registered.""" + await setup_integration(hass, mock_config_entry) + + device = device_registry.async_get_device(identifiers={("scorpiontrack", "101_1")}) + assert device is not None + assert device.name == "AB12 CDE" + assert device.manufacturer == "Volkswagen" + assert device.model == "Golf R" + + +@pytest.mark.parametrize( + ("exception", "expected_state"), + [ + (ScorpionTrackInvalidTokenError("Invalid token"), ConfigEntryState.SETUP_ERROR), + ( + ScorpionTrackShareUnavailableError("Share expired"), + ConfigEntryState.SETUP_ERROR, + ), + ( + ScorpionTrackConnectionError("Connection failed"), + ConfigEntryState.SETUP_RETRY, + ), + ], +) +async def test_setup_entry_errors( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_scorpiontrack_client: AsyncMock, + exception: Exception, + expected_state: ConfigEntryState, +) -> None: + """Test setup with token and connection errors.""" + mock_scorpiontrack_client.async_get_share.side_effect = exception + + await setup_integration(hass, mock_config_entry) + assert mock_config_entry.state is expected_state