Add ScorpionTrack heading sensor (#181601)

This commit is contained in:
Anthony
2026-09-08 08:24:45 +02:00
committed by GitHub
parent 57184907b2
commit f8e39d0983
6 changed files with 166 additions and 12 deletions
@@ -4,6 +4,11 @@
"vehicle_location": {
"default": "mdi:car"
}
},
"sensor": {
"heading": {
"default": "mdi:compass"
}
}
}
}
@@ -77,9 +77,7 @@ rules:
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-disabled-by-default: done
entity-translations: done
exception-translations: done
icon-translations: done
@@ -13,7 +13,7 @@ from homeassistant.components.sensor import (
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.const import EntityCategory, UnitOfSpeed
from homeassistant.const import DEGREE, EntityCategory, UnitOfSpeed
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
@@ -34,6 +34,15 @@ class ScorpionTrackSensorEntityDescription(SensorEntityDescription):
SENSORS: tuple[ScorpionTrackSensorEntityDescription, ...] = (
ScorpionTrackSensorEntityDescription(
key="heading",
translation_key="heading",
native_unit_of_measurement=DEGREE,
state_class=SensorStateClass.MEASUREMENT_ANGLE,
suggested_display_precision=0,
entity_registry_enabled_default=False,
value_fn=lambda vehicle: vehicle.position.bearing,
),
ScorpionTrackSensorEntityDescription(
key="speed",
device_class=SensorDeviceClass.SPEED,
@@ -29,6 +29,9 @@
}
},
"sensor": {
"heading": {
"name": "Heading"
},
"last_reported": {
"name": "Last reported"
}
@@ -1,4 +1,61 @@
# serializer version: 1
# name: test_sensor_snapshot[sensor.ab12_cde_heading-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
<SensorEntityCapabilityAttribute.STATE_CLASS: 'state_class'>: <SensorStateClass.MEASUREMENT_ANGLE: 'measurement_angle'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.ab12_cde_heading',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Heading',
'options': dict({
'sensor': dict({
'suggested_display_precision': 0,
}),
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Heading',
'platform': 'scorpiontrack',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'heading',
'unique_id': '101_1_heading',
'unit_of_measurement': '°',
})
# ---
# name: test_sensor_snapshot[sensor.ab12_cde_heading-state]
StateSnapshot({
'attributes': ReadOnlyDict({
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'AB12 CDE Heading',
<SensorEntityCapabilityAttribute.STATE_CLASS: 'state_class'>: <SensorStateClass.MEASUREMENT_ANGLE: 'measurement_angle'>,
<EntityStateAttribute.UNIT_OF_MEASUREMENT: 'unit_of_measurement'>: '°',
}),
'context': <ANY>,
'entity_id': 'sensor.ab12_cde_heading',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '182.0',
})
# ---
# name: test_sensor_snapshot[sensor.ab12_cde_last_reported-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
+90 -8
View File
@@ -4,7 +4,7 @@ from dataclasses import replace
from unittest.mock import AsyncMock, patch
from freezegun.api import FrozenDateTimeFactory
from pyscorpiontrack import ScorpionTrackShare
from pyscorpiontrack import ScorpionTrackConnectionError, ScorpionTrackShare
import pytest
from syrupy.assertion import SnapshotAssertion
@@ -27,6 +27,7 @@ from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_plat
ENTITY_ID = "sensor.ab12_cde_speed"
LAST_REPORTED_ENTITY_ID = "sensor.ab12_cde_last_reported"
HEADING_ENTITY_ID = "sensor.ab12_cde_heading"
async def test_speed_sensor_state(
@@ -46,10 +47,12 @@ async def test_speed_sensor_state(
mock_scorpiontrack_client.async_get_share.assert_awaited_once_with()
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
@pytest.mark.freeze_time("2026-08-11 12:00:00+00:00")
async def test_sensor_snapshot(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_scorpiontrack_client: AsyncMock,
entity_registry: er.EntityRegistry,
snapshot: SnapshotAssertion,
) -> None:
@@ -58,6 +61,75 @@ async def test_sensor_snapshot(
await setup_integration(hass, mock_config_entry)
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
mock_scorpiontrack_client.async_get_share.assert_awaited_once_with()
async def test_heading_sensor_disabled_by_default(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
entity_registry: er.EntityRegistry,
) -> None:
"""Test heading is registered without creating a state until enabled."""
await setup_integration(hass, mock_config_entry)
entry = entity_registry.async_get(HEADING_ENTITY_ID)
assert entry is not None
assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION
assert hass.states.get(HEADING_ENTITY_ID) is None
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
@pytest.mark.parametrize(
("bearing", "expected_state"),
[
pytest.param(0.0, "0.0", id="north"),
pytest.param(None, STATE_UNKNOWN, id="missing"),
],
)
async def test_heading_sensor_value(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_share: ScorpionTrackShare,
mock_scorpiontrack_client: AsyncMock,
bearing: float | None,
expected_state: str,
) -> None:
"""Test north is valid and a missing bearing is unknown."""
vehicle = mock_share.vehicles[0]
mock_scorpiontrack_client.async_get_share.return_value = replace(
mock_share,
vehicles=(
replace(vehicle, position=replace(vehicle.position, bearing=bearing)),
),
)
await setup_integration(hass, mock_config_entry)
state = hass.states.get(HEADING_ENTITY_ID)
assert state is not None
assert state.state == expected_state
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_heading_sensor_update_failure(
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
mock_config_entry: MockConfigEntry,
mock_scorpiontrack_client: AsyncMock,
) -> None:
"""Test a failed share update makes heading unavailable."""
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(HEADING_ENTITY_ID)
assert state is not None
assert state.state == STATE_UNAVAILABLE
async def test_speed_sensor_metric_display(
@@ -113,11 +185,13 @@ async def test_speed_sensor_availability(
assert state.state == expected_state
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
@pytest.mark.parametrize(
"entity_id",
[
pytest.param(ENTITY_ID, id="speed"),
pytest.param(LAST_REPORTED_ENTITY_ID, id="last-reported"),
pytest.param(HEADING_ENTITY_ID, id="heading"),
],
)
async def test_removed_vehicle_makes_sensor_unavailable(
@@ -143,23 +217,31 @@ async def test_removed_vehicle_makes_sensor_unavailable(
assert state.state == STATE_UNAVAILABLE
async def test_speed_sensor_uses_existing_vehicle_device(
@pytest.mark.parametrize(
"entity_id",
[
pytest.param(ENTITY_ID, id="speed"),
pytest.param(HEADING_ENTITY_ID, id="heading"),
],
)
async def test_sensor_uses_existing_vehicle_device(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
device_registry: dr.DeviceRegistry,
entity_registry: er.EntityRegistry,
entity_id: str,
) -> None:
"""Test the speed sensor shares the vehicle device with the tracker."""
"""Test the sensor shares the vehicle device with the tracker."""
await setup_integration(hass, mock_config_entry)
speed_entry = entity_registry.async_get(ENTITY_ID)
sensor_entry = entity_registry.async_get(entity_id)
tracker_entry = entity_registry.async_get("device_tracker.ab12_cde")
assert speed_entry is not None
assert sensor_entry is not None
assert tracker_entry is not None
assert speed_entry.device_id == tracker_entry.device_id
assert speed_entry.device_id is not None
assert sensor_entry.device_id == tracker_entry.device_id
assert sensor_entry.device_id is not None
device = device_registry.async_get(speed_entry.device_id)
device = device_registry.async_get(sensor_entry.device_id)
assert device is not None
assert device.identifiers == {("scorpiontrack", "101_1")}