From a1ae4ec23d1f5c73eb09c72a867f3dbd6eb9ba42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Tue, 2 Apr 2024 12:11:28 +0200 Subject: [PATCH] Add sensor entities to Traccar Server (#111374) --- .coveragerc | 1 + .../components/traccar_server/__init__.py | 2 +- .../traccar_server/device_tracker.py | 14 -- .../components/traccar_server/diagnostics.py | 21 ++- .../components/traccar_server/icons.json | 15 ++ .../components/traccar_server/sensor.py | 125 +++++++++++++++ .../components/traccar_server/strings.json | 13 ++ .../traccar_server/fixtures/devices.json | 2 +- .../traccar_server/fixtures/positions.json | 3 +- .../traccar_server/fixtures/server.json | 4 +- .../snapshots/test_diagnostics.ambr | 149 ++++++++++++++++-- .../traccar_server/test_diagnostics.py | 10 ++ 12 files changed, 318 insertions(+), 41 deletions(-) create mode 100644 homeassistant/components/traccar_server/icons.json create mode 100644 homeassistant/components/traccar_server/sensor.py diff --git a/.coveragerc b/.coveragerc index e32db823542..54cc470ac63 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1482,6 +1482,7 @@ omit = homeassistant/components/traccar_server/device_tracker.py homeassistant/components/traccar_server/entity.py homeassistant/components/traccar_server/helpers.py + homeassistant/components/traccar_server/sensor.py homeassistant/components/tractive/__init__.py homeassistant/components/tractive/binary_sensor.py homeassistant/components/tractive/device_tracker.py diff --git a/homeassistant/components/traccar_server/__init__.py b/homeassistant/components/traccar_server/__init__.py index fc513136681..703df6cbfa4 100644 --- a/homeassistant/components/traccar_server/__init__.py +++ b/homeassistant/components/traccar_server/__init__.py @@ -30,7 +30,7 @@ from .const import ( ) from .coordinator import TraccarServerCoordinator -PLATFORMS: list[Platform] = [Platform.DEVICE_TRACKER] +PLATFORMS: list[Platform] = [Platform.DEVICE_TRACKER, Platform.SENSOR] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/traccar_server/device_tracker.py b/homeassistant/components/traccar_server/device_tracker.py index e459cdacf14..d15ba084dad 100644 --- a/homeassistant/components/traccar_server/device_tracker.py +++ b/homeassistant/components/traccar_server/device_tracker.py @@ -10,12 +10,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ( - ATTR_ADDRESS, - ATTR_ALTITUDE, ATTR_CATEGORY, - ATTR_GEOFENCE, ATTR_MOTION, - ATTR_SPEED, ATTR_STATUS, ATTR_TRACCAR_ID, ATTR_TRACKER, @@ -44,23 +40,13 @@ class TraccarServerDeviceTracker(TraccarServerEntity, TrackerEntity): _attr_has_entity_name = True _attr_name = None - @property - def battery_level(self) -> int: - """Return battery value of the device.""" - return self.traccar_position["attributes"].get("batteryLevel", -1) - @property def extra_state_attributes(self) -> dict[str, Any]: """Return device specific attributes.""" - geofence_name = self.traccar_geofence["name"] if self.traccar_geofence else None return { **self.traccar_attributes, - ATTR_ADDRESS: self.traccar_position["address"], - ATTR_ALTITUDE: self.traccar_position["altitude"], ATTR_CATEGORY: self.traccar_device["category"], - ATTR_GEOFENCE: geofence_name, ATTR_MOTION: self.traccar_position["attributes"].get("motion", False), - ATTR_SPEED: self.traccar_position["speed"], ATTR_STATUS: self.traccar_device["status"], ATTR_TRACCAR_ID: self.traccar_device["id"], ATTR_TRACKER: DOMAIN, diff --git a/homeassistant/components/traccar_server/diagnostics.py b/homeassistant/components/traccar_server/diagnostics.py index ea861a9bffa..80dc7a9c7cd 100644 --- a/homeassistant/components/traccar_server/diagnostics.py +++ b/homeassistant/components/traccar_server/diagnostics.py @@ -4,7 +4,7 @@ from __future__ import annotations from typing import Any -from homeassistant.components.diagnostics import async_redact_data +from homeassistant.components.diagnostics import REDACTED, async_redact_data from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ADDRESS, CONF_LATITUDE, CONF_LONGITUDE from homeassistant.core import HomeAssistant @@ -13,21 +13,23 @@ from homeassistant.helpers import device_registry as dr, entity_registry as er from .const import DOMAIN from .coordinator import TraccarServerCoordinator -TO_REDACT = { +KEYS_TO_REDACT = { + "area", # This is the polygon area of a geofence CONF_ADDRESS, CONF_LATITUDE, CONF_LONGITUDE, - "area", # This is the polygon area of a geofence } def _entity_state( hass: HomeAssistant, entity: er.RegistryEntry, + coordinator: TraccarServerCoordinator, ) -> dict[str, Any] | None: + states_to_redact = {x["position"]["address"] for x in coordinator.data.values()} return ( { - "state": state.state, + "state": state.state if state.state not in states_to_redact else REDACTED, "attributes": state.attributes, } if (state := hass.states.get(entity.entity_id)) @@ -57,12 +59,13 @@ async def async_get_config_entry_diagnostics( { "enity_id": entity.entity_id, "disabled": entity.disabled, - "state": _entity_state(hass, entity), + "unit_of_measurement": entity.unit_of_measurement, + "state": _entity_state(hass, entity, coordinator), } for entity in entities ], }, - TO_REDACT, + KEYS_TO_REDACT, ) @@ -81,6 +84,7 @@ async def async_get_device_diagnostics( include_disabled_entities=True, ) + await hass.config_entries.async_reload(entry.entry_id) return async_redact_data( { "subscription_status": coordinator.client.subscription_status, @@ -90,10 +94,11 @@ async def async_get_device_diagnostics( { "enity_id": entity.entity_id, "disabled": entity.disabled, - "state": _entity_state(hass, entity), + "unit_of_measurement": entity.unit_of_measurement, + "state": _entity_state(hass, entity, coordinator), } for entity in entities ], }, - TO_REDACT, + KEYS_TO_REDACT, ) diff --git a/homeassistant/components/traccar_server/icons.json b/homeassistant/components/traccar_server/icons.json new file mode 100644 index 00000000000..59fc663e712 --- /dev/null +++ b/homeassistant/components/traccar_server/icons.json @@ -0,0 +1,15 @@ +{ + "entity": { + "sensor": { + "altitude": { + "default": "mdi:altimeter" + }, + "address": { + "default": "mdi:map-marker" + }, + "geofence": { + "default": "mdi:map-marker" + } + } + } +} diff --git a/homeassistant/components/traccar_server/sensor.py b/homeassistant/components/traccar_server/sensor.py new file mode 100644 index 00000000000..7f46399eb3f --- /dev/null +++ b/homeassistant/components/traccar_server/sensor.py @@ -0,0 +1,125 @@ +"""Support for Traccar server sensors.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Generic, Literal, TypeVar, cast + +from pytraccar import DeviceModel, GeofenceModel, PositionModel + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfLength, UnitOfSpeed +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType + +from .const import DOMAIN +from .coordinator import TraccarServerCoordinator +from .entity import TraccarServerEntity + +_T = TypeVar("_T") + + +@dataclass(frozen=True, kw_only=True) +class TraccarServerSensorEntityDescription(Generic[_T], SensorEntityDescription): + """Describe Traccar Server sensor entity.""" + + data_key: Literal["position", "device", "geofence", "attributes"] + entity_registry_enabled_default = False + entity_category = EntityCategory.DIAGNOSTIC + value_fn: Callable[[_T], StateType] + + +TRACCAR_SERVER_SENSOR_ENTITY_DESCRIPTIONS = ( + TraccarServerSensorEntityDescription[PositionModel]( + key="attributes.batteryLevel", + data_key="position", + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.BATTERY, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, + value_fn=lambda x: x["attributes"].get("batteryLevel", -1), + ), + TraccarServerSensorEntityDescription[PositionModel]( + key="speed", + data_key="position", + device_class=SensorDeviceClass.SPEED, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfSpeed.KNOTS, + suggested_display_precision=0, + value_fn=lambda x: x["speed"], + ), + TraccarServerSensorEntityDescription[PositionModel]( + key="altitude", + data_key="position", + translation_key="altitude", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfLength.METERS, + suggested_display_precision=1, + value_fn=lambda x: x["altitude"], + ), + TraccarServerSensorEntityDescription[PositionModel]( + key="address", + data_key="position", + translation_key="address", + value_fn=lambda x: x["address"], + ), + TraccarServerSensorEntityDescription[GeofenceModel | None]( + key="name", + data_key="geofence", + translation_key="geofence", + value_fn=lambda x: x["name"] if x else None, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up sensor entities.""" + coordinator: TraccarServerCoordinator = hass.data[DOMAIN][entry.entry_id] + async_add_entities( + TraccarServerSensor( + coordinator=coordinator, + device=entry["device"], + description=cast(TraccarServerSensorEntityDescription, description), + ) + for entry in coordinator.data.values() + for description in TRACCAR_SERVER_SENSOR_ENTITY_DESCRIPTIONS + ) + + +class TraccarServerSensor(TraccarServerEntity, SensorEntity): + """Represent a tracked device.""" + + _attr_has_entity_name = True + entity_description: TraccarServerSensorEntityDescription + + def __init__( + self, + coordinator: TraccarServerCoordinator, + device: DeviceModel, + description: TraccarServerSensorEntityDescription[_T], + ) -> None: + """Initialize the Traccar Server sensor.""" + super().__init__(coordinator, device) + self.entity_description = description + self._attr_unique_id = ( + f"{device['uniqueId']}_{description.data_key}_{description.key}" + ) + + @property + def native_value(self) -> StateType: + """Return the value of the sensor.""" + return self.entity_description.value_fn( + getattr(self, f"traccar_{self.entity_description.data_key}") + ) diff --git a/homeassistant/components/traccar_server/strings.json b/homeassistant/components/traccar_server/strings.json index 87da7e8cdd1..41adaace77e 100644 --- a/homeassistant/components/traccar_server/strings.json +++ b/homeassistant/components/traccar_server/strings.json @@ -41,5 +41,18 @@ } } } + }, + "entity": { + "sensor": { + "address": { + "name": "Address" + }, + "altitude": { + "name": "Altitude" + }, + "geofence": { + "name": "Geofence" + } + } } } diff --git a/tests/components/traccar_server/fixtures/devices.json b/tests/components/traccar_server/fixtures/devices.json index b04d53d9fdf..f3db1322e0b 100644 --- a/tests/components/traccar_server/fixtures/devices.json +++ b/tests/components/traccar_server/fixtures/devices.json @@ -3,7 +3,7 @@ "id": 0, "name": "X-Wing", "uniqueId": "abc123", - "status": "unknown", + "status": "online", "disabled": false, "lastUpdate": "1970-01-01T00:00:00Z", "positionId": 0, diff --git a/tests/components/traccar_server/fixtures/positions.json b/tests/components/traccar_server/fixtures/positions.json index 6b65116e804..7f661a7092a 100644 --- a/tests/components/traccar_server/fixtures/positions.json +++ b/tests/components/traccar_server/fixtures/positions.json @@ -18,7 +18,8 @@ "network": {}, "geofenceIds": [0], "attributes": { - "custom_attr_1": "custom_attr_1_value" + "custom_attr_1": "custom_attr_1_value", + "batteryLevel": 15.00000867601 } } ] diff --git a/tests/components/traccar_server/fixtures/server.json b/tests/components/traccar_server/fixtures/server.json index 039b6bfa1f4..7de1152b63d 100644 --- a/tests/components/traccar_server/fixtures/server.json +++ b/tests/components/traccar_server/fixtures/server.json @@ -17,5 +17,7 @@ "coordinateFormat": null, "openIdEnabled": true, "openIdForce": true, - "attributes": {} + "attributes": { + "speedUnit": "kn" + } } diff --git a/tests/components/traccar_server/snapshots/test_diagnostics.ambr b/tests/components/traccar_server/snapshots/test_diagnostics.ambr index f8fe3cc60f7..300444f10f1 100644 --- a/tests/components/traccar_server/snapshots/test_diagnostics.ambr +++ b/tests/components/traccar_server/snapshots/test_diagnostics.ambr @@ -30,7 +30,7 @@ 'name': 'X-Wing', 'phone': None, 'positionId': 0, - 'status': 'unknown', + 'status': 'online', 'uniqueId': 'abc123', }), 'geofence': dict({ @@ -47,6 +47,7 @@ 'address': '**REDACTED**', 'altitude': 546841384638, 'attributes': dict({ + 'batteryLevel': 15.00000867601, 'custom_attr_1': 'custom_attr_1_value', }), 'course': 360, @@ -75,25 +76,84 @@ 'enity_id': 'device_tracker.x_wing', 'state': dict({ 'attributes': dict({ - 'address': '**REDACTED**', - 'altitude': 546841384638, - 'battery_level': -1, 'category': 'starfighter', 'custom_attr_1': 'custom_attr_1_value', 'friendly_name': 'X-Wing', - 'geofence': 'Tatooine', 'gps_accuracy': 3.5, 'latitude': '**REDACTED**', 'longitude': '**REDACTED**', 'motion': False, 'source_type': 'gps', - 'speed': 4568795, - 'status': 'unknown', + 'status': 'online', 'traccar_id': 0, 'tracker': 'traccar_server', }), 'state': 'not_home', }), + 'unit_of_measurement': None, + }), + dict({ + 'disabled': False, + 'enity_id': 'sensor.x_wing_battery', + 'state': dict({ + 'attributes': dict({ + 'device_class': 'battery', + 'friendly_name': 'X-Wing Battery', + 'state_class': 'measurement', + 'unit_of_measurement': '%', + }), + 'state': '15.00000867601', + }), + 'unit_of_measurement': '%', + }), + dict({ + 'disabled': False, + 'enity_id': 'sensor.x_wing_speed', + 'state': dict({ + 'attributes': dict({ + 'device_class': 'speed', + 'friendly_name': 'X-Wing Speed', + 'state_class': 'measurement', + 'unit_of_measurement': 'kn', + }), + 'state': '4568795', + }), + 'unit_of_measurement': 'kn', + }), + dict({ + 'disabled': False, + 'enity_id': 'sensor.x_wing_altitude', + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'X-Wing Altitude', + 'state_class': 'measurement', + 'unit_of_measurement': 'm', + }), + 'state': '546841384638', + }), + 'unit_of_measurement': 'm', + }), + dict({ + 'disabled': False, + 'enity_id': 'sensor.x_wing_address', + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'X-Wing Address', + }), + 'state': '**REDACTED**', + }), + 'unit_of_measurement': None, + }), + dict({ + 'disabled': False, + 'enity_id': 'sensor.x_wing_geofence', + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'X-Wing Geofence', + }), + 'state': 'Tatooine', + }), + 'unit_of_measurement': None, }), ]), 'subscription_status': 'disconnected', @@ -130,7 +190,7 @@ 'name': 'X-Wing', 'phone': None, 'positionId': 0, - 'status': 'unknown', + 'status': 'online', 'uniqueId': 'abc123', }), 'geofence': dict({ @@ -147,6 +207,7 @@ 'address': '**REDACTED**', 'altitude': 546841384638, 'attributes': dict({ + 'batteryLevel': 15.00000867601, 'custom_attr_1': 'custom_attr_1_value', }), 'course': 360, @@ -170,10 +231,41 @@ }), }), 'entities': list([ + dict({ + 'disabled': True, + 'enity_id': 'sensor.x_wing_battery', + 'state': None, + 'unit_of_measurement': '%', + }), + dict({ + 'disabled': True, + 'enity_id': 'sensor.x_wing_speed', + 'state': None, + 'unit_of_measurement': 'kn', + }), + dict({ + 'disabled': True, + 'enity_id': 'sensor.x_wing_altitude', + 'state': None, + 'unit_of_measurement': 'm', + }), + dict({ + 'disabled': True, + 'enity_id': 'sensor.x_wing_address', + 'state': None, + 'unit_of_measurement': None, + }), + dict({ + 'disabled': True, + 'enity_id': 'sensor.x_wing_geofence', + 'state': None, + 'unit_of_measurement': None, + }), dict({ 'disabled': True, 'enity_id': 'device_tracker.x_wing', 'state': None, + 'unit_of_measurement': None, }), ]), 'subscription_status': 'disconnected', @@ -210,7 +302,7 @@ 'name': 'X-Wing', 'phone': None, 'positionId': 0, - 'status': 'unknown', + 'status': 'online', 'uniqueId': 'abc123', }), 'geofence': dict({ @@ -227,6 +319,7 @@ 'address': '**REDACTED**', 'altitude': 546841384638, 'attributes': dict({ + 'batteryLevel': 15.00000867601, 'custom_attr_1': 'custom_attr_1_value', }), 'course': 360, @@ -250,30 +343,56 @@ }), }), 'entities': list([ + dict({ + 'disabled': True, + 'enity_id': 'sensor.x_wing_battery', + 'state': None, + 'unit_of_measurement': '%', + }), + dict({ + 'disabled': True, + 'enity_id': 'sensor.x_wing_speed', + 'state': None, + 'unit_of_measurement': 'kn', + }), + dict({ + 'disabled': True, + 'enity_id': 'sensor.x_wing_altitude', + 'state': None, + 'unit_of_measurement': 'm', + }), + dict({ + 'disabled': True, + 'enity_id': 'sensor.x_wing_address', + 'state': None, + 'unit_of_measurement': None, + }), + dict({ + 'disabled': True, + 'enity_id': 'sensor.x_wing_geofence', + 'state': None, + 'unit_of_measurement': None, + }), dict({ 'disabled': False, 'enity_id': 'device_tracker.x_wing', 'state': dict({ 'attributes': dict({ - 'address': '**REDACTED**', - 'altitude': 546841384638, - 'battery_level': -1, 'category': 'starfighter', 'custom_attr_1': 'custom_attr_1_value', 'friendly_name': 'X-Wing', - 'geofence': 'Tatooine', 'gps_accuracy': 3.5, 'latitude': '**REDACTED**', 'longitude': '**REDACTED**', 'motion': False, 'source_type': 'gps', - 'speed': 4568795, - 'status': 'unknown', + 'status': 'online', 'traccar_id': 0, 'tracker': 'traccar_server', }), 'state': 'not_home', }), + 'unit_of_measurement': None, }), ]), 'subscription_status': 'disconnected', diff --git a/tests/components/traccar_server/test_diagnostics.py b/tests/components/traccar_server/test_diagnostics.py index faf1b628fcd..493f0ae92d1 100644 --- a/tests/components/traccar_server/test_diagnostics.py +++ b/tests/components/traccar_server/test_diagnostics.py @@ -44,6 +44,7 @@ async def test_device_diagnostics( mock_config_entry: MockConfigEntry, snapshot: SnapshotAssertion, device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, ) -> None: """Test device diagnostics.""" await setup_integration(hass, mock_config_entry) @@ -58,6 +59,15 @@ async def test_device_diagnostics( for device in dr.async_entries_for_config_entry( device_registry, mock_config_entry.entry_id ): + entities = er.async_entries_for_device( + entity_registry, + device_id=device.id, + include_disabled_entities=True, + ) + # Enable all entitits to show everything in snapshots + for entity in entities: + entity_registry.async_update_entity(entity.entity_id, disabled_by=None) + result = await get_diagnostics_for_device( hass, hass_client, mock_config_entry, device=device )