diff --git a/homeassistant/components/scorpiontrack/icons.json b/homeassistant/components/scorpiontrack/icons.json index a792c09e859c..00f7e9d47964 100644 --- a/homeassistant/components/scorpiontrack/icons.json +++ b/homeassistant/components/scorpiontrack/icons.json @@ -4,6 +4,11 @@ "vehicle_location": { "default": "mdi:car" } + }, + "sensor": { + "heading": { + "default": "mdi:compass" + } } } } diff --git a/homeassistant/components/scorpiontrack/quality_scale.yaml b/homeassistant/components/scorpiontrack/quality_scale.yaml index 2cacc2339081..726a2e41a9ef 100644 --- a/homeassistant/components/scorpiontrack/quality_scale.yaml +++ b/homeassistant/components/scorpiontrack/quality_scale.yaml @@ -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 diff --git a/homeassistant/components/scorpiontrack/sensor.py b/homeassistant/components/scorpiontrack/sensor.py index 360c7f4e9686..286e9b59af47 100644 --- a/homeassistant/components/scorpiontrack/sensor.py +++ b/homeassistant/components/scorpiontrack/sensor.py @@ -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, diff --git a/homeassistant/components/scorpiontrack/strings.json b/homeassistant/components/scorpiontrack/strings.json index 99274846d33d..4d012eec9ce6 100644 --- a/homeassistant/components/scorpiontrack/strings.json +++ b/homeassistant/components/scorpiontrack/strings.json @@ -29,6 +29,9 @@ } }, "sensor": { + "heading": { + "name": "Heading" + }, "last_reported": { "name": "Last reported" } diff --git a/tests/components/scorpiontrack/snapshots/test_sensor.ambr b/tests/components/scorpiontrack/snapshots/test_sensor.ambr index 20f412e679db..9038537bc7a9 100644 --- a/tests/components/scorpiontrack/snapshots/test_sensor.ambr +++ b/tests/components/scorpiontrack/snapshots/test_sensor.ambr @@ -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({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ab12_cde_heading', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + '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({ + : 'AB12 CDE Heading', + : , + : '°', + }), + 'context': , + 'entity_id': 'sensor.ab12_cde_heading', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '182.0', + }) +# --- # name: test_sensor_snapshot[sensor.ab12_cde_last_reported-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/scorpiontrack/test_sensor.py b/tests/components/scorpiontrack/test_sensor.py index e26566644741..ba6c9f941207 100644 --- a/tests/components/scorpiontrack/test_sensor.py +++ b/tests/components/scorpiontrack/test_sensor.py @@ -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")}