diff --git a/homeassistant/components/ituran/__init__.py b/homeassistant/components/ituran/__init__.py index b0a26cf7db2..bf9cff238cd 100644 --- a/homeassistant/components/ituran/__init__.py +++ b/homeassistant/components/ituran/__init__.py @@ -9,6 +9,7 @@ from .coordinator import IturanConfigEntry, IturanDataUpdateCoordinator PLATFORMS: list[Platform] = [ Platform.DEVICE_TRACKER, + Platform.SENSOR, ] diff --git a/homeassistant/components/ituran/icons.json b/homeassistant/components/ituran/icons.json index a20ea5b7304..bd9182f1569 100644 --- a/homeassistant/components/ituran/icons.json +++ b/homeassistant/components/ituran/icons.json @@ -4,6 +4,17 @@ "car": { "default": "mdi:car" } + }, + "sensor": { + "address": { + "default": "mdi:map-marker" + }, + "battery_voltage": { + "default": "mdi:car-battery" + }, + "heading": { + "default": "mdi:compass" + } } } } diff --git a/homeassistant/components/ituran/quality_scale.yaml b/homeassistant/components/ituran/quality_scale.yaml index 71d0d9698da..cd7e17c3b12 100644 --- a/homeassistant/components/ituran/quality_scale.yaml +++ b/homeassistant/components/ituran/quality_scale.yaml @@ -55,10 +55,7 @@ rules: Only device_tracker platform. devices: done entity-category: todo - entity-disabled-by-default: - status: exempt - comment: | - No noisy entities + entity-disabled-by-default: done discovery: status: exempt comment: | diff --git a/homeassistant/components/ituran/sensor.py b/homeassistant/components/ituran/sensor.py new file mode 100644 index 00000000000..e962f5bd561 --- /dev/null +++ b/homeassistant/components/ituran/sensor.py @@ -0,0 +1,119 @@ +"""Sensors for Ituran vehicles.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from datetime import datetime + +from pyituran import Vehicle + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, +) +from homeassistant.const import ( + DEGREE, + UnitOfElectricPotential, + UnitOfLength, + UnitOfSpeed, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType + +from . import IturanConfigEntry +from .coordinator import IturanDataUpdateCoordinator +from .entity import IturanBaseEntity + + +@dataclass(frozen=True, kw_only=True) +class IturanSensorEntityDescription(SensorEntityDescription): + """Describes Ituran sensor entity.""" + + value_fn: Callable[[Vehicle], StateType | datetime] + + +SENSOR_TYPES: list[IturanSensorEntityDescription] = [ + IturanSensorEntityDescription( + key="address", + translation_key="address", + entity_registry_enabled_default=False, + value_fn=lambda vehicle: vehicle.address, + ), + IturanSensorEntityDescription( + key="battery_voltage", + translation_key="battery_voltage", + device_class=SensorDeviceClass.VOLTAGE, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + suggested_display_precision=0, + entity_registry_enabled_default=False, + value_fn=lambda vehicle: vehicle.battery_voltage, + ), + IturanSensorEntityDescription( + key="heading", + translation_key="heading", + native_unit_of_measurement=DEGREE, + suggested_display_precision=0, + entity_registry_enabled_default=False, + value_fn=lambda vehicle: vehicle.heading, + ), + IturanSensorEntityDescription( + key="last_update_from_vehicle", + translation_key="last_update_from_vehicle", + device_class=SensorDeviceClass.TIMESTAMP, + entity_registry_enabled_default=False, + value_fn=lambda vehicle: vehicle.last_update, + ), + IturanSensorEntityDescription( + key="mileage", + translation_key="mileage", + device_class=SensorDeviceClass.DISTANCE, + native_unit_of_measurement=UnitOfLength.KILOMETERS, + suggested_display_precision=2, + value_fn=lambda vehicle: vehicle.mileage, + ), + IturanSensorEntityDescription( + key="speed", + device_class=SensorDeviceClass.SPEED, + native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, + suggested_display_precision=0, + value_fn=lambda vehicle: vehicle.speed, + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: IturanConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Ituran sensors from config entry.""" + coordinator = config_entry.runtime_data + async_add_entities( + IturanSensor(coordinator, license_plate, description) + for description in SENSOR_TYPES + for license_plate in coordinator.data + ) + + +class IturanSensor(IturanBaseEntity, SensorEntity): + """Ituran device tracker.""" + + entity_description: IturanSensorEntityDescription + + def __init__( + self, + coordinator: IturanDataUpdateCoordinator, + license_plate: str, + description: IturanSensorEntityDescription, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator, license_plate, description.key) + self.entity_description = description + + @property + def native_value(self) -> StateType | datetime: + """Return the state of the device.""" + return self.entity_description.value_fn(self.vehicle) diff --git a/homeassistant/components/ituran/strings.json b/homeassistant/components/ituran/strings.json index 212dbd1b86a..efc60ef454b 100644 --- a/homeassistant/components/ituran/strings.json +++ b/homeassistant/components/ituran/strings.json @@ -35,6 +35,25 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" } }, + "entity": { + "sensor": { + "address": { + "name": "Address" + }, + "battery_voltage": { + "name": "Battery voltage" + }, + "heading": { + "name": "Heading" + }, + "last_update_from_vehicle": { + "name": "Last update from vehicle" + }, + "mileage": { + "name": "Mileage" + } + } + }, "exceptions": { "api_error": { "message": "An error occurred while communicating with the Ituran service." diff --git a/tests/components/ituran/conftest.py b/tests/components/ituran/conftest.py index ef22c90591d..5093cc301a1 100644 --- a/tests/components/ituran/conftest.py +++ b/tests/components/ituran/conftest.py @@ -3,6 +3,7 @@ from collections.abc import Generator from datetime import datetime from unittest.mock import AsyncMock, PropertyMock, patch +from zoneinfo import ZoneInfo import pytest @@ -56,7 +57,10 @@ class MockVehicle: self.gps_coordinates = (25.0, -71.0) self.address = "Bermuda Triangle" self.heading = 150 - self.last_update = datetime(2024, 1, 1, 0, 0, 0) + self.last_update = datetime( + 2024, 1, 1, 0, 0, 0, tzinfo=ZoneInfo("Asia/Jerusalem") + ) + self.battery_voltage = 12.0 @pytest.fixture diff --git a/tests/components/ituran/snapshots/test_sensor.ambr b/tests/components/ituran/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..c1512de912f --- /dev/null +++ b/tests/components/ituran/snapshots/test_sensor.ambr @@ -0,0 +1,297 @@ +# serializer version: 1 +# name: test_sensor[sensor.mock_model_address-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mock_model_address', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Address', + 'platform': 'ituran', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'address', + 'unique_id': '12345678-address', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.mock_model_address-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'mock model Address', + }), + 'context': , + 'entity_id': 'sensor.mock_model_address', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Bermuda Triangle', + }) +# --- +# name: test_sensor[sensor.mock_model_battery_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mock_model_battery_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery voltage', + 'platform': 'ituran', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'battery_voltage', + 'unique_id': '12345678-battery_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.mock_model_battery_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'mock model Battery voltage', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.mock_model_battery_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '12.0', + }) +# --- +# name: test_sensor[sensor.mock_model_heading-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mock_model_heading', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Heading', + 'platform': 'ituran', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'heading', + 'unique_id': '12345678-heading', + 'unit_of_measurement': '°', + }) +# --- +# name: test_sensor[sensor.mock_model_heading-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'mock model Heading', + 'unit_of_measurement': '°', + }), + 'context': , + 'entity_id': 'sensor.mock_model_heading', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '150', + }) +# --- +# name: test_sensor[sensor.mock_model_last_update_from_vehicle-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mock_model_last_update_from_vehicle', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Last update from vehicle', + 'platform': 'ituran', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'last_update_from_vehicle', + 'unique_id': '12345678-last_update_from_vehicle', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.mock_model_last_update_from_vehicle-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'mock model Last update from vehicle', + }), + 'context': , + 'entity_id': 'sensor.mock_model_last_update_from_vehicle', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2023-12-31T22:00:00+00:00', + }) +# --- +# name: test_sensor[sensor.mock_model_mileage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mock_model_mileage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Mileage', + 'platform': 'ituran', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'mileage', + 'unique_id': '12345678-mileage', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.mock_model_mileage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'mock model Mileage', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.mock_model_mileage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1000', + }) +# --- +# name: test_sensor[sensor.mock_model_speed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mock_model_speed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Speed', + 'platform': 'ituran', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12345678-speed', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.mock_model_speed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'speed', + 'friendly_name': 'mock model Speed', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.mock_model_speed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '20', + }) +# --- diff --git a/tests/components/ituran/test_device_tracker.py b/tests/components/ituran/test_device_tracker.py index 7bcb314cde7..4fe92154e91 100644 --- a/tests/components/ituran/test_device_tracker.py +++ b/tests/components/ituran/test_device_tracker.py @@ -1,13 +1,13 @@ """Test the Ituran device_tracker.""" -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, patch from freezegun.api import FrozenDateTimeFactory from pyituran.exceptions import IturanApiError from syrupy.assertion import SnapshotAssertion from homeassistant.components.ituran.const import UPDATE_INTERVAL -from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.const import STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -24,7 +24,8 @@ async def test_device_tracker( mock_config_entry: MockConfigEntry, ) -> None: """Test state of device_tracker.""" - await setup_integration(hass, mock_config_entry) + with patch("homeassistant.components.ituran.PLATFORMS", [Platform.DEVICE_TRACKER]): + await setup_integration(hass, mock_config_entry) await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) diff --git a/tests/components/ituran/test_sensor.py b/tests/components/ituran/test_sensor.py new file mode 100644 index 00000000000..a057f59b81f --- /dev/null +++ b/tests/components/ituran/test_sensor.py @@ -0,0 +1,76 @@ +"""Test the Ituran device_tracker.""" + +from unittest.mock import AsyncMock, patch + +from freezegun.api import FrozenDateTimeFactory +from pyituran.exceptions import IturanApiError +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.ituran.const import UPDATE_INTERVAL +from homeassistant.const import STATE_UNAVAILABLE, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_sensor( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_ituran: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test state of sensor.""" + with patch("homeassistant.components.ituran.PLATFORMS", [Platform.SENSOR]): + await setup_integration(hass, mock_config_entry) + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_availability( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_ituran: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test sensor is marked as unavailable when we can't reach the Ituran service.""" + entities = [ + "sensor.mock_model_address", + "sensor.mock_model_battery_voltage", + "sensor.mock_model_heading", + "sensor.mock_model_last_update_from_vehicle", + "sensor.mock_model_mileage", + "sensor.mock_model_speed", + ] + + await setup_integration(hass, mock_config_entry) + + for entity_id in entities: + state = hass.states.get(entity_id) + assert state + assert state.state != STATE_UNAVAILABLE + + mock_ituran.get_vehicles.side_effect = IturanApiError + freezer.tick(UPDATE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + for entity_id in entities: + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_UNAVAILABLE + + mock_ituran.get_vehicles.side_effect = None + freezer.tick(UPDATE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + for entity_id in entities: + state = hass.states.get(entity_id) + assert state + assert state.state != STATE_UNAVAILABLE