Add sensors to Ituran integration (#133359)

Add sensors to Ituran
This commit is contained in:
Assaf Inbal 2024-12-18 09:36:42 +02:00 committed by GitHub
parent dfdd83789a
commit c10473844f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 533 additions and 8 deletions

View File

@ -9,6 +9,7 @@ from .coordinator import IturanConfigEntry, IturanDataUpdateCoordinator
PLATFORMS: list[Platform] = [ PLATFORMS: list[Platform] = [
Platform.DEVICE_TRACKER, Platform.DEVICE_TRACKER,
Platform.SENSOR,
] ]

View File

@ -4,6 +4,17 @@
"car": { "car": {
"default": "mdi:car" "default": "mdi:car"
} }
},
"sensor": {
"address": {
"default": "mdi:map-marker"
},
"battery_voltage": {
"default": "mdi:car-battery"
},
"heading": {
"default": "mdi:compass"
}
} }
} }
} }

View File

@ -55,10 +55,7 @@ rules:
Only device_tracker platform. Only device_tracker platform.
devices: done devices: done
entity-category: todo entity-category: todo
entity-disabled-by-default: entity-disabled-by-default: done
status: exempt
comment: |
No noisy entities
discovery: discovery:
status: exempt status: exempt
comment: | comment: |

View File

@ -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)

View File

@ -35,6 +35,25 @@
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]" "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": { "exceptions": {
"api_error": { "api_error": {
"message": "An error occurred while communicating with the Ituran service." "message": "An error occurred while communicating with the Ituran service."

View File

@ -3,6 +3,7 @@
from collections.abc import Generator from collections.abc import Generator
from datetime import datetime from datetime import datetime
from unittest.mock import AsyncMock, PropertyMock, patch from unittest.mock import AsyncMock, PropertyMock, patch
from zoneinfo import ZoneInfo
import pytest import pytest
@ -56,7 +57,10 @@ class MockVehicle:
self.gps_coordinates = (25.0, -71.0) self.gps_coordinates = (25.0, -71.0)
self.address = "Bermuda Triangle" self.address = "Bermuda Triangle"
self.heading = 150 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 @pytest.fixture

View File

@ -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': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.mock_model_address',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'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': <ANY>,
'entity_id': 'sensor.mock_model_address',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'Bermuda Triangle',
})
# ---
# name: test_sensor[sensor.mock_model_battery_voltage-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'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': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
'sensor': dict({
'suggested_display_precision': 0,
}),
}),
'original_device_class': <SensorDeviceClass.VOLTAGE: 'voltage'>,
'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': <UnitOfElectricPotential.VOLT: 'V'>,
})
# ---
# name: test_sensor[sensor.mock_model_battery_voltage-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'voltage',
'friendly_name': 'mock model Battery voltage',
'unit_of_measurement': <UnitOfElectricPotential.VOLT: 'V'>,
}),
'context': <ANY>,
'entity_id': 'sensor.mock_model_battery_voltage',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '12.0',
})
# ---
# name: test_sensor[sensor.mock_model_heading-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.mock_model_heading',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'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': <ANY>,
'entity_id': 'sensor.mock_model_heading',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '150',
})
# ---
# name: test_sensor[sensor.mock_model_last_update_from_vehicle-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'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': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <SensorDeviceClass.TIMESTAMP: 'timestamp'>,
'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': <ANY>,
'entity_id': 'sensor.mock_model_last_update_from_vehicle',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'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': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.mock_model_mileage',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
}),
}),
'original_device_class': <SensorDeviceClass.DISTANCE: 'distance'>,
'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': <UnitOfLength.KILOMETERS: 'km'>,
})
# ---
# name: test_sensor[sensor.mock_model_mileage-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'distance',
'friendly_name': 'mock model Mileage',
'unit_of_measurement': <UnitOfLength.KILOMETERS: 'km'>,
}),
'context': <ANY>,
'entity_id': 'sensor.mock_model_mileage',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '1000',
})
# ---
# name: test_sensor[sensor.mock_model_speed-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.mock_model_speed',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
'sensor': dict({
'suggested_display_precision': 0,
}),
}),
'original_device_class': <SensorDeviceClass.SPEED: 'speed'>,
'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': <UnitOfSpeed.KILOMETERS_PER_HOUR: 'km/h'>,
})
# ---
# name: test_sensor[sensor.mock_model_speed-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'speed',
'friendly_name': 'mock model Speed',
'unit_of_measurement': <UnitOfSpeed.KILOMETERS_PER_HOUR: 'km/h'>,
}),
'context': <ANY>,
'entity_id': 'sensor.mock_model_speed',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '20',
})
# ---

View File

@ -1,13 +1,13 @@
"""Test the Ituran device_tracker.""" """Test the Ituran device_tracker."""
from unittest.mock import AsyncMock from unittest.mock import AsyncMock, patch
from freezegun.api import FrozenDateTimeFactory from freezegun.api import FrozenDateTimeFactory
from pyituran.exceptions import IturanApiError from pyituran.exceptions import IturanApiError
from syrupy.assertion import SnapshotAssertion from syrupy.assertion import SnapshotAssertion
from homeassistant.components.ituran.const import UPDATE_INTERVAL 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.core import HomeAssistant
from homeassistant.helpers import entity_registry as er from homeassistant.helpers import entity_registry as er
@ -24,7 +24,8 @@ async def test_device_tracker(
mock_config_entry: MockConfigEntry, mock_config_entry: MockConfigEntry,
) -> None: ) -> None:
"""Test state of device_tracker.""" """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) await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)

View File

@ -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