diff --git a/homeassistant/components/ituran/device_tracker.py b/homeassistant/components/ituran/device_tracker.py index 5f816709864..0656bdfa497 100644 --- a/homeassistant/components/ituran/device_tracker.py +++ b/homeassistant/components/ituran/device_tracker.py @@ -2,6 +2,8 @@ from __future__ import annotations +from propcache.api import cached_property + from homeassistant.components.device_tracker import TrackerEntity from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -38,12 +40,12 @@ class IturanDeviceTracker(IturanBaseEntity, TrackerEntity): """Initialize the device tracker.""" super().__init__(coordinator, license_plate, "device_tracker") - @property + @cached_property def latitude(self) -> float | None: """Return latitude value of the device.""" return self.vehicle.gps_coordinates[0] - @property + @cached_property def longitude(self) -> float | None: """Return longitude value of the device.""" return self.vehicle.gps_coordinates[1] diff --git a/homeassistant/components/ituran/icons.json b/homeassistant/components/ituran/icons.json index bd9182f1569..0b721ca5001 100644 --- a/homeassistant/components/ituran/icons.json +++ b/homeassistant/components/ituran/icons.json @@ -9,6 +9,9 @@ "address": { "default": "mdi:map-marker" }, + "battery_range": { + "default": "mdi:ev-station" + }, "battery_voltage": { "default": "mdi:car-battery" }, diff --git a/homeassistant/components/ituran/sensor.py b/homeassistant/components/ituran/sensor.py index a115b2be89c..50e86b374a1 100644 --- a/homeassistant/components/ituran/sensor.py +++ b/homeassistant/components/ituran/sensor.py @@ -6,6 +6,7 @@ from collections.abc import Callable from dataclasses import dataclass from datetime import datetime +from propcache.api import cached_property from pyituran import Vehicle from homeassistant.components.sensor import ( @@ -15,6 +16,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import ( DEGREE, + PERCENTAGE, UnitOfElectricPotential, UnitOfLength, UnitOfSpeed, @@ -33,6 +35,7 @@ class IturanSensorEntityDescription(SensorEntityDescription): """Describes Ituran sensor entity.""" value_fn: Callable[[Vehicle], StateType | datetime] + supported_fn: Callable[[Vehicle], bool] = lambda _: True SENSOR_TYPES: list[IturanSensorEntityDescription] = [ @@ -42,6 +45,22 @@ SENSOR_TYPES: list[IturanSensorEntityDescription] = [ entity_registry_enabled_default=False, value_fn=lambda vehicle: vehicle.address, ), + IturanSensorEntityDescription( + key="battery_level", + device_class=SensorDeviceClass.BATTERY, + native_unit_of_measurement=PERCENTAGE, + value_fn=lambda vehicle: vehicle.battery_level, + supported_fn=lambda vehicle: vehicle.is_electric_vehicle, + ), + IturanSensorEntityDescription( + key="battery_range", + translation_key="battery_range", + device_class=SensorDeviceClass.DISTANCE, + native_unit_of_measurement=UnitOfLength.KILOMETERS, + suggested_display_precision=0, + value_fn=lambda vehicle: vehicle.battery_range, + supported_fn=lambda vehicle: vehicle.is_electric_vehicle, + ), IturanSensorEntityDescription( key="battery_voltage", translation_key="battery_voltage", @@ -92,14 +111,15 @@ async def async_setup_entry( """Set up the Ituran sensors from config entry.""" coordinator = config_entry.runtime_data async_add_entities( - IturanSensor(coordinator, license_plate, description) + IturanSensor(coordinator, vehicle.license_plate, description) + for vehicle in coordinator.data.values() for description in SENSOR_TYPES - for license_plate in coordinator.data + if description.supported_fn(vehicle) ) class IturanSensor(IturanBaseEntity, SensorEntity): - """Ituran device tracker.""" + """Ituran sensor.""" entity_description: IturanSensorEntityDescription @@ -113,7 +133,7 @@ class IturanSensor(IturanBaseEntity, SensorEntity): super().__init__(coordinator, license_plate, description.key) self.entity_description = description - @property + @cached_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 efc60ef454b..ededb5232f5 100644 --- a/homeassistant/components/ituran/strings.json +++ b/homeassistant/components/ituran/strings.json @@ -40,6 +40,9 @@ "address": { "name": "Address" }, + "battery_range": { + "name": "Remaining range" + }, "battery_voltage": { "name": "Battery voltage" }, diff --git a/tests/components/ituran/conftest.py b/tests/components/ituran/conftest.py index 5093cc301a1..1cb922b94e9 100644 --- a/tests/components/ituran/conftest.py +++ b/tests/components/ituran/conftest.py @@ -47,7 +47,7 @@ def mock_config_entry() -> MockConfigEntry: class MockVehicle: """Mock vehicle.""" - def __init__(self) -> None: + def __init__(self, is_electric_vehicle=False) -> None: """Initialize mock vehicle.""" self.license_plate = "12345678" self.make = "mock make" @@ -61,11 +61,18 @@ class MockVehicle: 2024, 1, 1, 0, 0, 0, tzinfo=ZoneInfo("Asia/Jerusalem") ) self.battery_voltage = 12.0 + self.is_electric_vehicle = is_electric_vehicle + if is_electric_vehicle: + self.battery_level = 42 + self.battery_range = 150 + else: + self.battery_level = 0 + self.battery_range = 0 @pytest.fixture -def mock_ituran() -> Generator[AsyncMock]: - """Return a mocked PalazzettiClient.""" +def mock_ituran(request: pytest.FixtureRequest) -> Generator[AsyncMock]: + """Return a mocked Ituran.""" with ( patch( "homeassistant.components.ituran.coordinator.Ituran", @@ -79,7 +86,8 @@ def mock_ituran() -> Generator[AsyncMock]: mock_ituran = ituran.return_value mock_ituran.is_authenticated.return_value = False mock_ituran.authenticate.return_value = True - mock_ituran.get_vehicles.return_value = [MockVehicle()] + is_electric_vehicle = getattr(request, "param", False) + mock_ituran.get_vehicles.return_value = [MockVehicle(is_electric_vehicle)] type(mock_ituran).mobile_id = PropertyMock( return_value=MOCK_CONFIG_DATA[CONF_MOBILE_ID] ) diff --git a/tests/components/ituran/snapshots/test_sensor.ambr b/tests/components/ituran/snapshots/test_sensor.ambr index 5278c657a66..a577d836b0e 100644 --- a/tests/components/ituran/snapshots/test_sensor.ambr +++ b/tests/components/ituran/snapshots/test_sensor.ambr @@ -1,4 +1,415 @@ # serializer version: 1 +# name: test_ev_sensor[True][sensor.mock_model_address-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_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, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'address', + 'unique_id': '12345678-address', + 'unit_of_measurement': None, + }) +# --- +# name: test_ev_sensor[True][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_ev_sensor[True][sensor.mock_model_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mock_model_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'ituran', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12345678-battery_level', + 'unit_of_measurement': '%', + }) +# --- +# name: test_ev_sensor[True][sensor.mock_model_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'mock model Battery', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.mock_model_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '42', + }) +# --- +# name: test_ev_sensor[True][sensor.mock_model_battery_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_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, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery_voltage', + 'unique_id': '12345678-battery_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_ev_sensor[True][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_ev_sensor[True][sensor.mock_model_heading-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_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, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'heading', + 'unique_id': '12345678-heading', + 'unit_of_measurement': '°', + }) +# --- +# name: test_ev_sensor[True][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_ev_sensor[True][sensor.mock_model_last_update_from_vehicle-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_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, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'last_update_from_vehicle', + 'unique_id': '12345678-last_update_from_vehicle', + 'unit_of_measurement': None, + }) +# --- +# name: test_ev_sensor[True][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_ev_sensor[True][sensor.mock_model_mileage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_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, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'mileage', + 'unique_id': '12345678-mileage', + 'unit_of_measurement': , + }) +# --- +# name: test_ev_sensor[True][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_ev_sensor[True][sensor.mock_model_remaining_range-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mock_model_remaining_range', + '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': 'Remaining range', + 'platform': 'ituran', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery_range', + 'unique_id': '12345678-battery_range', + 'unit_of_measurement': , + }) +# --- +# name: test_ev_sensor[True][sensor.mock_model_remaining_range-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'mock model Remaining range', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.mock_model_remaining_range', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '150', + }) +# --- +# name: test_ev_sensor[True][sensor.mock_model_speed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_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, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12345678-speed', + 'unit_of_measurement': , + }) +# --- +# name: test_ev_sensor[True][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', + }) +# --- # name: test_sensor[sensor.mock_model_address-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/ituran/test_sensor.py b/tests/components/ituran/test_sensor.py index a057f59b81f..4293cf08f2d 100644 --- a/tests/components/ituran/test_sensor.py +++ b/tests/components/ituran/test_sensor.py @@ -32,13 +32,27 @@ async def test_sensor( @pytest.mark.usefixtures("entity_registry_enabled_by_default") -async def test_availability( +@pytest.mark.parametrize("mock_ituran", [True], indirect=True) +async def test_ev_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) + + +async def __test_availability( hass: HomeAssistant, freezer: FrozenDateTimeFactory, mock_ituran: AsyncMock, mock_config_entry: MockConfigEntry, + ev_entity_names: list[str] | None = None, ) -> 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", @@ -46,6 +60,7 @@ async def test_availability( "sensor.mock_model_last_update_from_vehicle", "sensor.mock_model_mileage", "sensor.mock_model_speed", + *(ev_entity_names if ev_entity_names is not None else []), ] await setup_integration(hass, mock_config_entry) @@ -74,3 +89,32 @@ async def test_availability( state = hass.states.get(entity_id) assert state assert state.state != STATE_UNAVAILABLE + + +@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 ICE sensor is marked as unavailable when we can't reach the Ituran service.""" + await __test_availability(hass, freezer, mock_ituran, mock_config_entry) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.parametrize("mock_ituran", [True], indirect=True) +async def test_ev_availability( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_ituran: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test EV sensor is marked as unavailable when we can't reach the Ituran service.""" + ev_entities = [ + "sensor.mock_model_battery", + "sensor.mock_model_remaining_range", + ] + await __test_availability( + hass, freezer, mock_ituran, mock_config_entry, ev_entities + )