Skip to content
Snippets Groups Projects
Unverified Commit cd96fb38 authored by Joakim Sørensen's avatar Joakim Sørensen Committed by GitHub
Browse files

Import Traccar YAML configuration to Traccar Server (#109226)

* Import Traccar YAML configuration to Traccar Server

* Remove import
parent 0b0bf737
No related branches found
No related tags found
No related merge requests found
"""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 ( ...@@ -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( ...@@ -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,180 +183,62 @@ async def async_setup_scanner( ...@@ -188,180 +183,62 @@ 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:
await self._api.get_server()
except TraccarAuthenticationException:
_LOGGER.error("Authentication for Traccar failed")
return False
except TraccarConnectionException as exception:
_LOGGER.error("Connection with Traccar failed - %s", exception)
return False
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):
"""Update info from Traccar."""
_LOGGER.debug("Updating device data")
try: try:
( known_devices = await hass.async_add_executor_job(
self._devices, load_yaml_config_file, hass.config.path(YAML_DEVICES)
self._positions,
self._geofences,
) = await asyncio.gather(
self._api.get_devices(),
self._api.get_positions(),
self._api.get_geofences(),
) )
except TraccarException as ex: except (FileNotFoundError, HomeAssistantError):
_LOGGER.error("Error while updating device data: %s", ex) _LOGGER.debug(
return "No valid known_devices.yaml found, "
"skip removal of devices from known_devices.yaml"
self._hass.async_create_task(self.import_device_data())
if self._event_types:
self._hass.async_create_task(self.import_events())
async def import_device_data(self):
"""Import device data from Traccar."""
for position in self._positions:
device = next(
(dev for dev in self._devices if dev["id"] == position["deviceId"]),
None,
) )
if not device: if known_devices:
continue traccar_devices: list[str] = []
try:
attr = { resp = await api.get_devices()
ATTR_TRACKER: "traccar", traccar_devices = [slugify(device["name"]) for device in resp]
ATTR_ADDRESS: position["address"], except TraccarException as exception:
ATTR_SPEED: position["speed"], _LOGGER.error("Error while getting device data: %s", exception)
ATTR_ALTITUDE: position["altitude"], return
ATTR_MOTION: position["attributes"].get("motion", False),
ATTR_TRACCAR_ID: device["id"], for dev_name in traccar_devices:
ATTR_GEOFENCE: next( if dev_name in known_devices:
( await hass.async_add_executor_job(
geofence["name"] remove_device_from_config, hass, dev_name
for geofence in self._geofences )
if geofence["id"] in (position["geofenceIds"] or []) _LOGGER.debug("Removed device %s from known_devices.yaml", dev_name)
),
None, if not hass.states.async_available(f"device_tracker.{dev_name}"):
), hass.states.async_remove(f"device_tracker.{dev_name}")
ATTR_CATEGORY: device["category"],
ATTR_STATUS: device["status"], hass.async_create_task(
} hass.config_entries.flow.async_init(
"traccar_server",
skip_accuracy_filter = False context={"source": SOURCE_IMPORT},
data=config,
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): async_create_issue(
"""Import events from Traccar.""" hass,
# get_reports_events requires naive UTC datetimes as of 1.0.0 HOMEASSISTANT_DOMAIN,
start_intervel = dt_util.utcnow().replace(tzinfo=None) f"deprecated_yaml_{DOMAIN}",
events = await self._api.get_reports_events( breaks_in_ha_version="2024.8.0",
devices=[device["id"] for device in self._devices], is_fixable=False,
start_time=start_intervel, issue_domain=DOMAIN,
end_time=start_intervel - self._scan_interval, severity=IssueSeverity.WARNING,
event_types=self._event_types.keys(), translation_key="deprecated_yaml",
translation_placeholders={
"domain": DOMAIN,
"integration_title": "Traccar",
},
) )
if events is not None:
for event in events: hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, _run_import)
self._hass.bus.async_fire( return True
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"],
},
)
class TraccarEntity(TrackerEntity, RestoreEntity): class TraccarEntity(TrackerEntity, RestoreEntity):
......
"""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): ...@@ -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(
......
"""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
"""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( ...@@ -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,
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment