mirror of
https://github.com/home-assistant/core.git
synced 2026-09-25 17:04:04 -04:00
Add ScorpionTrack last reported sensor (#178755)
Co-authored-by: Erwin Douna <e.douna@gmail.com>
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user