Add ScorpionTrack last reported sensor (#178755)

Co-authored-by: Erwin Douna <e.douna@gmail.com>
This commit is contained in:
Anthony
2026-09-07 19:45:13 +02:00
committed by GitHub
co-authored by Erwin Douna
parent b6af61f7a4
commit 3f4cebe868
4 changed files with 174 additions and 32 deletions
@@ -1,15 +1,22 @@
"""Sensor platform for ScorpionTrack."""
from collections.abc import Callable
from dataclasses import dataclass
from datetime import datetime
from typing import override
from pyscorpiontrack import ScorpionTrackShare, ScorpionTrackVehicle
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.const import UnitOfSpeed
from homeassistant.const import EntityCategory, UnitOfSpeed
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from .coordinator import ScorpionTrackConfigEntry, ScorpionTrackCoordinator
from .entity import ScorpionTrackEntity
@@ -17,49 +24,86 @@ from .entity import ScorpionTrackEntity
PARALLEL_UPDATES = 0
@dataclass(frozen=True, kw_only=True)
class ScorpionTrackSensorEntityDescription(SensorEntityDescription):
"""Describe a ScorpionTrack sensor."""
value_fn: Callable[[ScorpionTrackVehicle], StateType | datetime]
available_fn: Callable[[ScorpionTrackVehicle], bool] = lambda _: True
suggested_unit_fn: Callable[[ScorpionTrackShare], str] | None = None
SENSORS: tuple[ScorpionTrackSensorEntityDescription, ...] = (
ScorpionTrackSensorEntityDescription(
key="speed",
device_class=SensorDeviceClass.SPEED,
native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=1,
value_fn=lambda vehicle: vehicle.position.speed_kmh,
available_fn=lambda vehicle: vehicle.position.speed_kmh is not None,
suggested_unit_fn=lambda share: (
UnitOfSpeed.MILES_PER_HOUR
if share.uses_miles
else UnitOfSpeed.KILOMETERS_PER_HOUR
),
),
ScorpionTrackSensorEntityDescription(
key="last_reported",
translation_key="last_reported",
device_class=SensorDeviceClass.TIMESTAMP,
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda vehicle: vehicle.position.timestamp,
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: ScorpionTrackConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up ScorpionTrack speed sensors."""
"""Set up ScorpionTrack sensors."""
coordinator = entry.runtime_data
async_add_entities(
ScorpionTrackSpeedSensor(coordinator, vehicle.id)
ScorpionTrackSensor(coordinator, vehicle.id, entity_description)
for vehicle in coordinator.data.vehicles
for entity_description in SENSORS
)
class ScorpionTrackSpeedSensor(ScorpionTrackEntity, SensorEntity):
"""Represent the latest shared vehicle speed."""
class ScorpionTrackSensor(ScorpionTrackEntity, SensorEntity):
"""Represent a ScorpionTrack vehicle sensor."""
_attr_device_class = SensorDeviceClass.SPEED
_attr_native_unit_of_measurement = UnitOfSpeed.KILOMETERS_PER_HOUR
_attr_state_class = SensorStateClass.MEASUREMENT
_attr_suggested_display_precision = 1
entity_description: ScorpionTrackSensorEntityDescription
def __init__(self, coordinator: ScorpionTrackCoordinator, vehicle_id: int) -> None:
"""Initialize the speed sensor."""
def __init__(
self,
coordinator: ScorpionTrackCoordinator,
vehicle_id: int,
entity_description: ScorpionTrackSensorEntityDescription,
) -> None:
"""Initialize the sensor."""
super().__init__(coordinator, vehicle_id)
self._attr_unique_id = f"{coordinator.data.id}_{vehicle_id}_speed"
self._attr_suggested_unit_of_measurement = (
UnitOfSpeed.MILES_PER_HOUR
if coordinator.data.uses_miles
else UnitOfSpeed.KILOMETERS_PER_HOUR
self.entity_description = entity_description
self._attr_unique_id = (
f"{coordinator.data.id}_{vehicle_id}_{entity_description.key}"
)
def _available_speed(self) -> float | None:
"""Return the speed if the sensor is available."""
return self.get_vehicle().position.speed_kmh
if (suggested_unit_fn := entity_description.suggested_unit_fn) is not None:
self._attr_suggested_unit_of_measurement = suggested_unit_fn(
coordinator.data
)
@property
@override
def available(self) -> bool:
"""Return if the speed sensor is available."""
return super().available and self._available_speed() is not None
"""Return if the sensor is available."""
return super().available and self.entity_description.available_fn(
self.get_vehicle()
)
@property
@override
def native_value(self) -> float | None:
"""Return the speed in kilometres per hour."""
return self._available_speed()
def native_value(self) -> StateType | datetime:
"""Return the sensor value."""
return self.entity_description.value_fn(self.get_vehicle())
@@ -27,6 +27,11 @@
"ignition": {
"name": "Ignition"
}
},
"sensor": {
"last_reported": {
"name": "Last reported"
}
}
},
"exceptions": {
@@ -1,5 +1,56 @@
# serializer version: 1
# name: test_speed_sensor_snapshot[sensor.ab12_cde_speed-entry]
# name: test_sensor_snapshot[sensor.ab12_cde_last_reported-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'sensor.ab12_cde_last_reported',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Last reported',
'options': dict({
}),
'original_device_class': <SensorDeviceClass.TIMESTAMP: 'timestamp'>,
'original_icon': None,
'original_name': 'Last reported',
'platform': 'scorpiontrack',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'last_reported',
'unique_id': '101_1_last_reported',
'unit_of_measurement': None,
})
# ---
# name: test_sensor_snapshot[sensor.ab12_cde_last_reported-state]
StateSnapshot({
'attributes': ReadOnlyDict({
<EntityStateAttribute.DEVICE_CLASS: 'device_class'>: 'timestamp',
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'AB12 CDE Last reported',
}),
'context': <ANY>,
'entity_id': 'sensor.ab12_cde_last_reported',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '2026-08-09T12:00:00+00:00',
})
# ---
# name: test_sensor_snapshot[sensor.ab12_cde_speed-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
@@ -44,7 +95,7 @@
'unit_of_measurement': <UnitOfSpeed.MILES_PER_HOUR: 'mph'>,
})
# ---
# name: test_speed_sensor_snapshot[sensor.ab12_cde_speed-state]
# name: test_sensor_snapshot[sensor.ab12_cde_speed-state]
StateSnapshot({
'attributes': ReadOnlyDict({
<EntityStateAttribute.DEVICE_CLASS: 'device_class'>: 'speed',
+47 -5
View File
@@ -14,6 +14,7 @@ from homeassistant.const import (
ATTR_LONGITUDE,
ATTR_UNIT_OF_MEASUREMENT,
STATE_UNAVAILABLE,
STATE_UNKNOWN,
Platform,
UnitOfSpeed,
)
@@ -25,6 +26,7 @@ from . import setup_integration
from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform
ENTITY_ID = "sensor.ab12_cde_speed"
LAST_REPORTED_ENTITY_ID = "sensor.ab12_cde_last_reported"
async def test_speed_sensor_state(
@@ -44,13 +46,14 @@ async def test_speed_sensor_state(
mock_scorpiontrack_client.async_get_share.assert_awaited_once_with()
async def test_speed_sensor_snapshot(
@pytest.mark.freeze_time("2026-08-11 12:00:00+00:00")
async def test_sensor_snapshot(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
entity_registry: er.EntityRegistry,
snapshot: SnapshotAssertion,
) -> None:
"""Test the speed sensor entity and state attributes."""
"""Test the sensor entities and state attributes."""
with patch("homeassistant.components.scorpiontrack.PLATFORMS", (Platform.SENSOR,)):
await setup_integration(hass, mock_config_entry)
@@ -110,14 +113,22 @@ async def test_speed_sensor_availability(
assert state.state == expected_state
async def test_removed_vehicle_makes_speed_sensor_unavailable(
@pytest.mark.parametrize(
"entity_id",
[
pytest.param(ENTITY_ID, id="speed"),
pytest.param(LAST_REPORTED_ENTITY_ID, id="last-reported"),
],
)
async def test_removed_vehicle_makes_sensor_unavailable(
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
mock_config_entry: MockConfigEntry,
mock_share: ScorpionTrackShare,
mock_scorpiontrack_client: AsyncMock,
entity_id: str,
) -> None:
"""Test a speed sensor becomes unavailable if its vehicle leaves the share."""
"""Test a sensor becomes unavailable if its vehicle leaves the share."""
await setup_integration(hass, mock_config_entry)
mock_scorpiontrack_client.async_get_share.return_value = replace(
@@ -127,7 +138,7 @@ async def test_removed_vehicle_makes_speed_sensor_unavailable(
async_fire_time_changed(hass)
await hass.async_block_till_done()
state = hass.states.get(ENTITY_ID)
state = hass.states.get(entity_id)
assert state is not None
assert state.state == STATE_UNAVAILABLE
@@ -151,3 +162,34 @@ async def test_speed_sensor_uses_existing_vehicle_device(
device = device_registry.async_get(speed_entry.device_id)
assert device is not None
assert device.identifiers == {("scorpiontrack", "101_1")}
async def test_last_reported_sensor_without_timestamp(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_share: ScorpionTrackShare,
mock_scorpiontrack_client: AsyncMock,
) -> None:
"""Test a missing timestamp is unknown without affecting the tracker."""
vehicle = mock_share.vehicles[0]
mock_scorpiontrack_client.async_get_share.return_value = replace(
mock_share,
vehicles=(
replace(
vehicle,
position=replace(vehicle.position, timestamp=None),
),
),
)
await setup_integration(hass, mock_config_entry)
state = hass.states.get(LAST_REPORTED_ENTITY_ID)
assert state is not None
assert state.state == STATE_UNKNOWN
tracker_state = hass.states.get("device_tracker.ab12_cde")
assert tracker_state is not None
assert tracker_state.state != STATE_UNAVAILABLE
assert tracker_state.attributes[ATTR_LATITUDE] == vehicle.position.latitude
assert tracker_state.attributes[ATTR_LONGITUDE] == vehicle.position.longitude