1
0
mirror of https://github.com/home-assistant/core.git synced 2026-07-12 09:07:59 +01:00

Add ScorpionTrack integration (#168737)

Co-authored-by: Joostlek <joostlek@outlook.com>
This commit is contained in:
Herbertmt978
2026-07-10 19:37:19 +01:00
committed by GitHub
parent 5e2694ad3c
commit c5d86d3eac
20 changed files with 1022 additions and 0 deletions
Generated
+2
View File
@@ -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
@@ -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)
@@ -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,
)
@@ -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,)
@@ -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
@@ -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
@@ -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
@@ -0,0 +1,9 @@
{
"entity": {
"device_tracker": {
"vehicle_location": {
"default": "mdi:car"
}
}
}
}
@@ -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"]
}
@@ -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
@@ -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."
}
}
}
+1
View File
@@ -669,6 +669,7 @@ FLOWS = {
"satel_integra",
"saunum",
"schlage",
"scorpiontrack",
"scrape",
"screenlogic",
"season",
@@ -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",
+3
View File
@@ -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
@@ -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()
@@ -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
@@ -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({
<DeviceTrackerEntityCapabilityAttribute.TRACKING_TYPE: 'tracking_type'>: <TrackingType.POSITION: 'position'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'device_tracker',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'device_tracker.ab12_cde',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'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({
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'AB12 CDE',
<TrackerEntityStateAttribute.GPS_ACCURACY: 'gps_accuracy'>: 0,
<DeviceTrackerEntityStateAttribute.IN_ZONES: 'in_zones'>: list([
]),
<TrackerEntityStateAttribute.LATITUDE: 'latitude'>: 51.5074,
<TrackerEntityStateAttribute.LONGITUDE: 'longitude'>: -0.1278,
<DeviceTrackerEntityStateAttribute.SOURCE_TYPE: 'source_type'>: <SourceType.GPS: 'gps'>,
<DeviceTrackerEntityCapabilityAttribute.TRACKING_TYPE: 'tracking_type'>: <TrackingType.POSITION: 'position'>,
}),
'context': <ANY>,
'entity_id': 'device_tracker.ab12_cde',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'not_home',
})
# ---
@@ -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"
@@ -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
@@ -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