Import Traccar YAML configuration to Traccar Server (#109226)

* Import Traccar YAML configuration to Traccar Server

* Remove import
This commit is contained in:
Joakim Sørensen 2024-01-31 18:16:23 +01:00 committed by GitHub
parent 0b0bf73780
commit cd96fb381f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 230 additions and 271 deletions

View file

@ -1,30 +1,25 @@
"""Support for Traccar device tracking.""" """Support for Traccar device tracking."""
from __future__ import annotations from __future__ import annotations
import asyncio
from datetime import timedelta from datetime import timedelta
import logging import logging
from typing import Any
from pytraccar import ( from pytraccar import ApiClient, TraccarException
ApiClient,
DeviceModel,
GeofenceModel,
PositionModel,
TraccarAuthenticationException,
TraccarConnectionException,
TraccarException,
)
from stringcase import camelcase
import voluptuous as vol import voluptuous as vol
from homeassistant.components.device_tracker import ( from homeassistant.components.device_tracker import (
CONF_SCAN_INTERVAL,
PLATFORM_SCHEMA as PARENT_PLATFORM_SCHEMA, PLATFORM_SCHEMA as PARENT_PLATFORM_SCHEMA,
AsyncSeeCallback, AsyncSeeCallback,
SourceType, SourceType,
TrackerEntity, TrackerEntity,
) )
from homeassistant.config_entries import ConfigEntry from homeassistant.components.device_tracker.legacy import (
YAML_DEVICES,
remove_device_from_config,
)
from homeassistant.config import load_yaml_config_file
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
from homeassistant.const import ( from homeassistant.const import (
CONF_EVENT, CONF_EVENT,
CONF_HOST, CONF_HOST,
@ -34,34 +29,34 @@ from homeassistant.const import (
CONF_SSL, CONF_SSL,
CONF_USERNAME, CONF_USERNAME,
CONF_VERIFY_SSL, CONF_VERIFY_SSL,
EVENT_HOMEASSISTANT_STARTED,
) )
from homeassistant.core import HomeAssistant, callback from homeassistant.core import (
DOMAIN as HOMEASSISTANT_DOMAIN,
Event,
HomeAssistant,
callback,
)
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers import config_validation as cv, device_registry as dr
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.restore_state import RestoreEntity
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util import dt as dt_util, slugify from homeassistant.util import slugify
from . import DOMAIN, TRACKER_UPDATE from . import DOMAIN, TRACKER_UPDATE
from .const import ( from .const import (
ATTR_ACCURACY, ATTR_ACCURACY,
ATTR_ADDRESS,
ATTR_ALTITUDE, ATTR_ALTITUDE,
ATTR_BATTERY, ATTR_BATTERY,
ATTR_BEARING, ATTR_BEARING,
ATTR_CATEGORY,
ATTR_GEOFENCE,
ATTR_LATITUDE, ATTR_LATITUDE,
ATTR_LONGITUDE, ATTR_LONGITUDE,
ATTR_MOTION,
ATTR_SPEED, ATTR_SPEED,
ATTR_STATUS,
ATTR_TRACCAR_ID,
ATTR_TRACKER,
CONF_MAX_ACCURACY, CONF_MAX_ACCURACY,
CONF_SKIP_ACCURACY_ON, CONF_SKIP_ACCURACY_ON,
EVENT_ALARM, EVENT_ALARM,
@ -178,7 +173,7 @@ async def async_setup_scanner(
async_see: AsyncSeeCallback, async_see: AsyncSeeCallback,
discovery_info: DiscoveryInfoType | None = None, discovery_info: DiscoveryInfoType | None = None,
) -> bool: ) -> bool:
"""Validate the configuration and return a Traccar scanner.""" """Import configuration to the new integration."""
api = ApiClient( api = ApiClient(
host=config[CONF_HOST], host=config[CONF_HOST],
port=config[CONF_PORT], port=config[CONF_PORT],
@ -188,181 +183,63 @@ async def async_setup_scanner(
client_session=async_get_clientsession(hass, config[CONF_VERIFY_SSL]), client_session=async_get_clientsession(hass, config[CONF_VERIFY_SSL]),
) )
scanner = TraccarScanner( async def _run_import(_: Event):
api, known_devices: dict[str, dict[str, Any]] = {}
hass,
async_see,
config.get(CONF_SCAN_INTERVAL, SCAN_INTERVAL),
config[CONF_MAX_ACCURACY],
config[CONF_SKIP_ACCURACY_ON],
config[CONF_MONITORED_CONDITIONS],
config[CONF_EVENT],
)
return await scanner.async_init()
class TraccarScanner:
"""Define an object to retrieve Traccar data."""
def __init__(
self,
api: ApiClient,
hass: HomeAssistant,
async_see: AsyncSeeCallback,
scan_interval: timedelta,
max_accuracy: int,
skip_accuracy_on: bool,
custom_attributes: list[str],
event_types: list[str],
) -> None:
"""Initialize."""
if EVENT_ALL_EVENTS in event_types:
event_types = EVENTS
self._event_types = {camelcase(evt): evt for evt in event_types}
self._custom_attributes = custom_attributes
self._scan_interval = scan_interval
self._async_see = async_see
self._api = api
self._hass = hass
self._max_accuracy = max_accuracy
self._skip_accuracy_on = skip_accuracy_on
self._devices: list[DeviceModel] = []
self._positions: list[PositionModel] = []
self._geofences: list[GeofenceModel] = []
async def async_init(self):
"""Further initialize connection to Traccar."""
try: try:
await self._api.get_server() known_devices = await hass.async_add_executor_job(
except TraccarAuthenticationException: load_yaml_config_file, hass.config.path(YAML_DEVICES)
_LOGGER.error("Authentication for Traccar failed") )
return False except (FileNotFoundError, HomeAssistantError):
except TraccarConnectionException as exception: _LOGGER.debug(
_LOGGER.error("Connection with Traccar failed - %s", exception) "No valid known_devices.yaml found, "
return False "skip removal of devices from known_devices.yaml"
await self._async_update()
async_track_time_interval(
self._hass, self._async_update, self._scan_interval, cancel_on_shutdown=True
) )
return True
async def _async_update(self, now=None): if known_devices:
"""Update info from Traccar.""" traccar_devices: list[str] = []
_LOGGER.debug("Updating device data")
try: try:
( resp = await api.get_devices()
self._devices, traccar_devices = [slugify(device["name"]) for device in resp]
self._positions, except TraccarException as exception:
self._geofences, _LOGGER.error("Error while getting device data: %s", exception)
) = await asyncio.gather(
self._api.get_devices(),
self._api.get_positions(),
self._api.get_geofences(),
)
except TraccarException as ex:
_LOGGER.error("Error while updating device data: %s", ex)
return return
self._hass.async_create_task(self.import_device_data()) for dev_name in traccar_devices:
if self._event_types: if dev_name in known_devices:
self._hass.async_create_task(self.import_events()) await hass.async_add_executor_job(
remove_device_from_config, hass, dev_name
)
_LOGGER.debug("Removed device %s from known_devices.yaml", dev_name)
async def import_device_data(self): if not hass.states.async_available(f"device_tracker.{dev_name}"):
"""Import device data from Traccar.""" hass.states.async_remove(f"device_tracker.{dev_name}")
for position in self._positions:
device = next( hass.async_create_task(
(dev for dev in self._devices if dev["id"] == position["deviceId"]), hass.config_entries.flow.async_init(
None, "traccar_server",
context={"source": SOURCE_IMPORT},
data=config,
)
) )
if not device: async_create_issue(
continue hass,
HOMEASSISTANT_DOMAIN,
attr = { f"deprecated_yaml_{DOMAIN}",
ATTR_TRACKER: "traccar", breaks_in_ha_version="2024.8.0",
ATTR_ADDRESS: position["address"], is_fixable=False,
ATTR_SPEED: position["speed"], issue_domain=DOMAIN,
ATTR_ALTITUDE: position["altitude"], severity=IssueSeverity.WARNING,
ATTR_MOTION: position["attributes"].get("motion", False), translation_key="deprecated_yaml",
ATTR_TRACCAR_ID: device["id"], translation_placeholders={
ATTR_GEOFENCE: next( "domain": DOMAIN,
( "integration_title": "Traccar",
geofence["name"]
for geofence in self._geofences
if geofence["id"] in (position["geofenceIds"] or [])
),
None,
),
ATTR_CATEGORY: device["category"],
ATTR_STATUS: device["status"],
}
skip_accuracy_filter = False
for custom_attr in self._custom_attributes:
if device["attributes"].get(custom_attr) is not None:
attr[custom_attr] = position["attributes"][custom_attr]
if custom_attr in self._skip_accuracy_on:
skip_accuracy_filter = True
if position["attributes"].get(custom_attr) is not None:
attr[custom_attr] = position["attributes"][custom_attr]
if custom_attr in self._skip_accuracy_on:
skip_accuracy_filter = True
accuracy = position["accuracy"] or 0.0
if (
not skip_accuracy_filter
and self._max_accuracy > 0
and accuracy > self._max_accuracy
):
_LOGGER.debug(
"Excluded position by accuracy filter: %f (%s)",
accuracy,
attr[ATTR_TRACCAR_ID],
)
continue
await self._async_see(
dev_id=slugify(device["name"]),
gps=(position["latitude"], position["longitude"]),
gps_accuracy=accuracy,
battery=position["attributes"].get("batteryLevel", -1),
attributes=attr,
)
async def import_events(self):
"""Import events from Traccar."""
# get_reports_events requires naive UTC datetimes as of 1.0.0
start_intervel = dt_util.utcnow().replace(tzinfo=None)
events = await self._api.get_reports_events(
devices=[device["id"] for device in self._devices],
start_time=start_intervel,
end_time=start_intervel - self._scan_interval,
event_types=self._event_types.keys(),
)
if events is not None:
for event in events:
self._hass.bus.async_fire(
f"traccar_{self._event_types.get(event['type'])}",
{
"device_traccar_id": event["deviceId"],
"device_name": next(
(
dev["name"]
for dev in self._devices
if dev["id"] == event["deviceId"]
),
None,
),
"type": event["type"],
"serverTime": event["eventTime"],
"attributes": event["attributes"],
}, },
) )
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, _run_import)
return True
class TraccarEntity(TrackerEntity, RestoreEntity): class TraccarEntity(TrackerEntity, RestoreEntity):
"""Represent a tracked device.""" """Represent a tracked device."""

View file

@ -1,6 +1,7 @@
"""Config flow for Traccar Server integration.""" """Config flow for Traccar Server integration."""
from __future__ import annotations from __future__ import annotations
from collections.abc import Mapping
from typing import Any from typing import Any
from pytraccar import ApiClient, ServerModel, TraccarException from pytraccar import ApiClient, ServerModel, TraccarException
@ -159,6 +160,39 @@ class TraccarServerConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
errors=errors, errors=errors,
) )
async def async_step_import(self, import_info: Mapping[str, Any]) -> FlowResult:
"""Import an entry."""
configured_port = str(import_info[CONF_PORT])
self._async_abort_entries_match(
{
CONF_HOST: import_info[CONF_HOST],
CONF_PORT: configured_port,
}
)
if "all_events" in (imported_events := import_info.get("event", [])):
events = list(EVENTS.values())
else:
events = imported_events
return self.async_create_entry(
title=f"{import_info[CONF_HOST]}:{configured_port}",
data={
CONF_HOST: import_info[CONF_HOST],
CONF_PORT: configured_port,
CONF_SSL: import_info.get(CONF_SSL, False),
CONF_VERIFY_SSL: import_info.get(CONF_VERIFY_SSL, True),
CONF_USERNAME: import_info[CONF_USERNAME],
CONF_PASSWORD: import_info[CONF_PASSWORD],
},
options={
CONF_MAX_ACCURACY: import_info[CONF_MAX_ACCURACY],
CONF_EVENTS: events,
CONF_CUSTOM_ATTRIBUTES: import_info.get("monitored_conditions", []),
CONF_SKIP_ACCURACY_FILTER_FOR: import_info.get(
"skip_accuracy_filter_on", []
),
},
)
@staticmethod @staticmethod
@callback @callback
def async_get_options_flow( def async_get_options_flow(

View file

@ -1,78 +0,0 @@
"""The tests for the Traccar device tracker platform."""
from unittest.mock import AsyncMock, patch
from pytraccar import ReportsEventeModel
from homeassistant.components.device_tracker import DOMAIN
from homeassistant.components.traccar.device_tracker import (
PLATFORM_SCHEMA as TRACCAR_PLATFORM_SCHEMA,
)
from homeassistant.const import (
CONF_EVENT,
CONF_HOST,
CONF_PASSWORD,
CONF_PLATFORM,
CONF_USERNAME,
)
from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component
from homeassistant.util import dt as dt_util
from tests.common import async_capture_events
async def test_import_events_catch_all(hass: HomeAssistant) -> None:
"""Test importing all events and firing them in HA using their event types."""
conf_dict = {
DOMAIN: TRACCAR_PLATFORM_SCHEMA(
{
CONF_PLATFORM: "traccar",
CONF_HOST: "fake_host",
CONF_USERNAME: "fake_user",
CONF_PASSWORD: "fake_pass",
CONF_EVENT: ["all_events"],
}
)
}
device = {"id": 1, "name": "abc123"}
api_mock = AsyncMock()
api_mock.devices = [device]
api_mock.get_reports_events.return_value = [
ReportsEventeModel(
**{
"id": 1,
"positionId": 1,
"geofenceId": 1,
"maintenanceId": 1,
"deviceId": device["id"],
"type": "ignitionOn",
"eventTime": dt_util.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ"),
"attributes": {},
}
),
ReportsEventeModel(
**{
"id": 2,
"positionId": 2,
"geofenceId": 1,
"maintenanceId": 1,
"deviceId": device["id"],
"type": "ignitionOff",
"eventTime": dt_util.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ"),
"attributes": {},
}
),
]
events_ignition_on = async_capture_events(hass, "traccar_ignition_on")
events_ignition_off = async_capture_events(hass, "traccar_ignition_off")
with patch(
"homeassistant.components.traccar.device_tracker.ApiClient",
return_value=api_mock,
):
assert await async_setup_component(hass, DOMAIN, conf_dict)
assert len(events_ignition_on) == 1
assert len(events_ignition_off) == 1

View file

@ -1,16 +1,19 @@
"""Test the Traccar Server config flow.""" """Test the Traccar Server config flow."""
from typing import Any
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, patch
import pytest import pytest
from pytraccar import TraccarException from pytraccar import TraccarException
from homeassistant import config_entries from homeassistant import config_entries
from homeassistant.components.traccar.device_tracker import PLATFORM_SCHEMA
from homeassistant.components.traccar_server.const import ( from homeassistant.components.traccar_server.const import (
CONF_CUSTOM_ATTRIBUTES, CONF_CUSTOM_ATTRIBUTES,
CONF_EVENTS, CONF_EVENTS,
CONF_MAX_ACCURACY, CONF_MAX_ACCURACY,
CONF_SKIP_ACCURACY_FILTER_FOR, CONF_SKIP_ACCURACY_FILTER_FOR,
DOMAIN, DOMAIN,
EVENTS,
) )
from homeassistant.const import ( from homeassistant.const import (
CONF_HOST, CONF_HOST,
@ -156,6 +159,129 @@ async def test_options(
} }
@pytest.mark.parametrize(
("imported", "data", "options"),
(
(
{
CONF_HOST: "1.1.1.1",
CONF_PORT: 443,
CONF_USERNAME: "test-username",
CONF_PASSWORD: "test-password",
},
{
CONF_HOST: "1.1.1.1",
CONF_PORT: "443",
CONF_VERIFY_SSL: True,
CONF_SSL: False,
CONF_USERNAME: "test-username",
CONF_PASSWORD: "test-password",
},
{
CONF_EVENTS: [],
CONF_CUSTOM_ATTRIBUTES: [],
CONF_SKIP_ACCURACY_FILTER_FOR: [],
CONF_MAX_ACCURACY: 0,
},
),
(
{
CONF_HOST: "1.1.1.1",
CONF_USERNAME: "test-username",
CONF_PASSWORD: "test-password",
CONF_SSL: True,
"event": ["device_online", "device_offline"],
},
{
CONF_HOST: "1.1.1.1",
CONF_PORT: "8082",
CONF_VERIFY_SSL: True,
CONF_SSL: True,
CONF_USERNAME: "test-username",
CONF_PASSWORD: "test-password",
},
{
CONF_EVENTS: ["device_online", "device_offline"],
CONF_CUSTOM_ATTRIBUTES: [],
CONF_SKIP_ACCURACY_FILTER_FOR: [],
CONF_MAX_ACCURACY: 0,
},
),
(
{
CONF_HOST: "1.1.1.1",
CONF_USERNAME: "test-username",
CONF_PASSWORD: "test-password",
CONF_SSL: True,
"event": ["device_online", "device_offline", "all_events"],
},
{
CONF_HOST: "1.1.1.1",
CONF_PORT: "8082",
CONF_VERIFY_SSL: True,
CONF_SSL: True,
CONF_USERNAME: "test-username",
CONF_PASSWORD: "test-password",
},
{
CONF_EVENTS: list(EVENTS.values()),
CONF_CUSTOM_ATTRIBUTES: [],
CONF_SKIP_ACCURACY_FILTER_FOR: [],
CONF_MAX_ACCURACY: 0,
},
),
),
)
async def test_import_from_yaml(
hass: HomeAssistant,
mock_setup_entry: AsyncMock,
imported: dict[str, Any],
data: dict[str, Any],
options: dict[str, Any],
) -> None:
"""Test importing configuration from YAML."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_IMPORT},
data=PLATFORM_SCHEMA({"platform": "traccar", **imported}),
)
assert result["type"] == FlowResultType.CREATE_ENTRY
assert result["title"] == f"{data[CONF_HOST]}:{data[CONF_PORT]}"
assert result["data"] == data
assert result["options"] == options
async def test_abort_import_already_configured(
hass: HomeAssistant,
mock_setup_entry: AsyncMock,
) -> None:
"""Test abort for existing server while importing."""
config_entry = MockConfigEntry(
domain=DOMAIN,
data={CONF_HOST: "1.1.1.1", CONF_PORT: "8082"},
)
config_entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_IMPORT},
data=PLATFORM_SCHEMA(
{
"platform": "traccar",
CONF_USERNAME: "test-username",
CONF_PASSWORD: "test-password",
CONF_HOST: "1.1.1.1",
CONF_PORT: "8082",
}
),
)
assert result["type"] == FlowResultType.ABORT
assert result["reason"] == "already_configured"
async def test_abort_already_configured( async def test_abort_already_configured(
hass: HomeAssistant, hass: HomeAssistant,
mock_setup_entry: AsyncMock, mock_setup_entry: AsyncMock,