Add sensor entities to Traccar Server (#111374)

This commit is contained in:
Joakim Sørensen 2024-04-02 12:11:28 +02:00 committed by GitHub
parent e473914407
commit a1ae4ec23d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 318 additions and 41 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,15 @@
{
"entity": {
"sensor": {
"altitude": {
"default": "mdi:altimeter"
},
"address": {
"default": "mdi:map-marker"
},
"geofence": {
"default": "mdi:map-marker"
}
}
}
}

View file

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

View file

@ -41,5 +41,18 @@
}
}
}
},
"entity": {
"sensor": {
"address": {
"name": "Address"
},
"altitude": {
"name": "Altitude"
},
"geofence": {
"name": "Geofence"
}
}
}
}

View file

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

View file

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

View file

@ -17,5 +17,7 @@
"coordinateFormat": null,
"openIdEnabled": true,
"openIdForce": true,
"attributes": {}
"attributes": {
"speedUnit": "kn"
}
}

View file

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

View file

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