diff --git a/homeassistant/components/scorpiontrack/sensor.py b/homeassistant/components/scorpiontrack/sensor.py index d31b9007f0ee..360c7f4e9686 100644 --- a/homeassistant/components/scorpiontrack/sensor.py +++ b/homeassistant/components/scorpiontrack/sensor.py @@ -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()) diff --git a/homeassistant/components/scorpiontrack/strings.json b/homeassistant/components/scorpiontrack/strings.json index e2315e4b60f8..99274846d33d 100644 --- a/homeassistant/components/scorpiontrack/strings.json +++ b/homeassistant/components/scorpiontrack/strings.json @@ -27,6 +27,11 @@ "ignition": { "name": "Ignition" } + }, + "sensor": { + "last_reported": { + "name": "Last reported" + } } }, "exceptions": { diff --git a/tests/components/scorpiontrack/snapshots/test_sensor.ambr b/tests/components/scorpiontrack/snapshots/test_sensor.ambr index c1182dc6d533..20f412e679db 100644 --- a/tests/components/scorpiontrack/snapshots/test_sensor.ambr +++ b/tests/components/scorpiontrack/snapshots/test_sensor.ambr @@ -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': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.ab12_cde_last_reported', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Last reported', + 'options': dict({ + }), + 'original_device_class': , + '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({ + : 'timestamp', + : 'AB12 CDE Last reported', + }), + 'context': , + 'entity_id': 'sensor.ab12_cde_last_reported', + 'last_changed': , + 'last_reported': , + 'last_updated': , + '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': , }) # --- -# name: test_speed_sensor_snapshot[sensor.ab12_cde_speed-state] +# name: test_sensor_snapshot[sensor.ab12_cde_speed-state] StateSnapshot({ 'attributes': ReadOnlyDict({ : 'speed', diff --git a/tests/components/scorpiontrack/test_sensor.py b/tests/components/scorpiontrack/test_sensor.py index bedb6b3301cc..e26566644741 100644 --- a/tests/components/scorpiontrack/test_sensor.py +++ b/tests/components/scorpiontrack/test_sensor.py @@ -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