mirror of
https://github.com/home-assistant/core.git
synced 2025-07-25 06:07:17 +00:00
Add Traccar server integration (#109002)
* Add Traccar server integration * Add explination * Update homeassistant/components/traccar_server/coordinator.py Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com> * Add data_description --------- Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
This commit is contained in:
parent
52a692df3e
commit
640463c559
@ -1424,6 +1424,11 @@ omit =
|
|||||||
homeassistant/components/tplink_omada/controller.py
|
homeassistant/components/tplink_omada/controller.py
|
||||||
homeassistant/components/tplink_omada/update.py
|
homeassistant/components/tplink_omada/update.py
|
||||||
homeassistant/components/traccar/device_tracker.py
|
homeassistant/components/traccar/device_tracker.py
|
||||||
|
homeassistant/components/traccar_server/__init__.py
|
||||||
|
homeassistant/components/traccar_server/coordinator.py
|
||||||
|
homeassistant/components/traccar_server/device_tracker.py
|
||||||
|
homeassistant/components/traccar_server/entity.py
|
||||||
|
homeassistant/components/traccar_server/helpers.py
|
||||||
homeassistant/components/tractive/__init__.py
|
homeassistant/components/tractive/__init__.py
|
||||||
homeassistant/components/tractive/binary_sensor.py
|
homeassistant/components/tractive/binary_sensor.py
|
||||||
homeassistant/components/tractive/device_tracker.py
|
homeassistant/components/tractive/device_tracker.py
|
||||||
|
@ -1394,6 +1394,8 @@ build.json @home-assistant/supervisor
|
|||||||
/tests/components/tplink_omada/ @MarkGodwin
|
/tests/components/tplink_omada/ @MarkGodwin
|
||||||
/homeassistant/components/traccar/ @ludeeus
|
/homeassistant/components/traccar/ @ludeeus
|
||||||
/tests/components/traccar/ @ludeeus
|
/tests/components/traccar/ @ludeeus
|
||||||
|
/homeassistant/components/traccar_server/ @ludeeus
|
||||||
|
/tests/components/traccar_server/ @ludeeus
|
||||||
/homeassistant/components/trace/ @home-assistant/core
|
/homeassistant/components/trace/ @home-assistant/core
|
||||||
/tests/components/trace/ @home-assistant/core
|
/tests/components/trace/ @home-assistant/core
|
||||||
/homeassistant/components/tractive/ @Danielhiversen @zhulik @bieniu
|
/homeassistant/components/tractive/ @Danielhiversen @zhulik @bieniu
|
||||||
|
70
homeassistant/components/traccar_server/__init__.py
Normal file
70
homeassistant/components/traccar_server/__init__.py
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
"""The Traccar Server integration."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pytraccar import ApiClient
|
||||||
|
|
||||||
|
from homeassistant.config_entries import ConfigEntry
|
||||||
|
from homeassistant.const import (
|
||||||
|
CONF_HOST,
|
||||||
|
CONF_PASSWORD,
|
||||||
|
CONF_PORT,
|
||||||
|
CONF_SSL,
|
||||||
|
CONF_USERNAME,
|
||||||
|
CONF_VERIFY_SSL,
|
||||||
|
Platform,
|
||||||
|
)
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||||
|
|
||||||
|
from .const import (
|
||||||
|
CONF_CUSTOM_ATTRIBUTES,
|
||||||
|
CONF_EVENTS,
|
||||||
|
CONF_MAX_ACCURACY,
|
||||||
|
CONF_SKIP_ACCURACY_FILTER_FOR,
|
||||||
|
DOMAIN,
|
||||||
|
)
|
||||||
|
from .coordinator import TraccarServerCoordinator
|
||||||
|
|
||||||
|
PLATFORMS: list[Platform] = [Platform.DEVICE_TRACKER]
|
||||||
|
|
||||||
|
|
||||||
|
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
|
"""Set up Traccar Server from a config entry."""
|
||||||
|
coordinator = TraccarServerCoordinator(
|
||||||
|
hass=hass,
|
||||||
|
client=ApiClient(
|
||||||
|
client_session=async_get_clientsession(hass),
|
||||||
|
host=entry.data[CONF_HOST],
|
||||||
|
port=entry.data[CONF_PORT],
|
||||||
|
username=entry.data[CONF_USERNAME],
|
||||||
|
password=entry.data[CONF_PASSWORD],
|
||||||
|
ssl=entry.data[CONF_SSL],
|
||||||
|
verify_ssl=entry.data[CONF_VERIFY_SSL],
|
||||||
|
),
|
||||||
|
events=entry.options.get(CONF_EVENTS, []),
|
||||||
|
max_accuracy=entry.options.get(CONF_MAX_ACCURACY, 0.0),
|
||||||
|
skip_accuracy_filter_for=entry.options.get(CONF_SKIP_ACCURACY_FILTER_FOR, []),
|
||||||
|
custom_attributes=entry.options.get(CONF_CUSTOM_ATTRIBUTES, []),
|
||||||
|
)
|
||||||
|
|
||||||
|
await coordinator.async_config_entry_first_refresh()
|
||||||
|
|
||||||
|
hass.data.setdefault(DOMAIN, {})
|
||||||
|
hass.data[DOMAIN][entry.entry_id] = coordinator
|
||||||
|
|
||||||
|
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||||
|
entry.async_on_unload(entry.add_update_listener(async_reload_entry))
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
|
"""Unload a config entry."""
|
||||||
|
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
|
||||||
|
hass.data[DOMAIN].pop(entry.entry_id)
|
||||||
|
return unload_ok
|
||||||
|
|
||||||
|
|
||||||
|
async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||||
|
"""Handle an options update."""
|
||||||
|
await hass.config_entries.async_reload(entry.entry_id)
|
168
homeassistant/components/traccar_server/config_flow.py
Normal file
168
homeassistant/components/traccar_server/config_flow.py
Normal file
@ -0,0 +1,168 @@
|
|||||||
|
"""Config flow for Traccar Server integration."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from pytraccar import ApiClient, ServerModel, TraccarException
|
||||||
|
import voluptuous as vol
|
||||||
|
|
||||||
|
from homeassistant import config_entries
|
||||||
|
from homeassistant.const import (
|
||||||
|
CONF_HOST,
|
||||||
|
CONF_PASSWORD,
|
||||||
|
CONF_PORT,
|
||||||
|
CONF_SSL,
|
||||||
|
CONF_USERNAME,
|
||||||
|
CONF_VERIFY_SSL,
|
||||||
|
)
|
||||||
|
from homeassistant.core import callback
|
||||||
|
from homeassistant.data_entry_flow import FlowResult
|
||||||
|
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||||
|
from homeassistant.helpers.schema_config_entry_flow import (
|
||||||
|
SchemaFlowFormStep,
|
||||||
|
SchemaOptionsFlowHandler,
|
||||||
|
)
|
||||||
|
from homeassistant.helpers.selector import (
|
||||||
|
BooleanSelector,
|
||||||
|
BooleanSelectorConfig,
|
||||||
|
NumberSelector,
|
||||||
|
NumberSelectorConfig,
|
||||||
|
NumberSelectorMode,
|
||||||
|
SelectSelector,
|
||||||
|
SelectSelectorConfig,
|
||||||
|
SelectSelectorMode,
|
||||||
|
TextSelector,
|
||||||
|
TextSelectorConfig,
|
||||||
|
TextSelectorType,
|
||||||
|
)
|
||||||
|
|
||||||
|
from .const import (
|
||||||
|
CONF_CUSTOM_ATTRIBUTES,
|
||||||
|
CONF_EVENTS,
|
||||||
|
CONF_MAX_ACCURACY,
|
||||||
|
CONF_SKIP_ACCURACY_FILTER_FOR,
|
||||||
|
DOMAIN,
|
||||||
|
EVENTS,
|
||||||
|
LOGGER,
|
||||||
|
)
|
||||||
|
|
||||||
|
STEP_USER_DATA_SCHEMA = vol.Schema(
|
||||||
|
{
|
||||||
|
vol.Required(CONF_HOST): TextSelector(
|
||||||
|
TextSelectorConfig(type=TextSelectorType.TEXT)
|
||||||
|
),
|
||||||
|
vol.Optional(CONF_PORT, default="8082"): TextSelector(
|
||||||
|
TextSelectorConfig(type=TextSelectorType.TEXT)
|
||||||
|
),
|
||||||
|
vol.Required(CONF_USERNAME): TextSelector(
|
||||||
|
TextSelectorConfig(type=TextSelectorType.EMAIL)
|
||||||
|
),
|
||||||
|
vol.Required(CONF_PASSWORD): TextSelector(
|
||||||
|
TextSelectorConfig(type=TextSelectorType.PASSWORD)
|
||||||
|
),
|
||||||
|
vol.Optional(CONF_SSL, default=False): BooleanSelector(BooleanSelectorConfig()),
|
||||||
|
vol.Optional(CONF_VERIFY_SSL, default=True): BooleanSelector(
|
||||||
|
BooleanSelectorConfig()
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
OPTIONS_FLOW = {
|
||||||
|
"init": SchemaFlowFormStep(
|
||||||
|
schema=vol.Schema(
|
||||||
|
{
|
||||||
|
vol.Optional(CONF_MAX_ACCURACY, default=0.0): NumberSelector(
|
||||||
|
NumberSelectorConfig(
|
||||||
|
mode=NumberSelectorMode.BOX,
|
||||||
|
min=0.0,
|
||||||
|
)
|
||||||
|
),
|
||||||
|
vol.Optional(CONF_CUSTOM_ATTRIBUTES, default=[]): SelectSelector(
|
||||||
|
SelectSelectorConfig(
|
||||||
|
mode=SelectSelectorMode.DROPDOWN,
|
||||||
|
multiple=True,
|
||||||
|
sort=True,
|
||||||
|
custom_value=True,
|
||||||
|
options=[],
|
||||||
|
)
|
||||||
|
),
|
||||||
|
vol.Optional(CONF_SKIP_ACCURACY_FILTER_FOR, default=[]): SelectSelector(
|
||||||
|
SelectSelectorConfig(
|
||||||
|
mode=SelectSelectorMode.DROPDOWN,
|
||||||
|
multiple=True,
|
||||||
|
sort=True,
|
||||||
|
custom_value=True,
|
||||||
|
options=[],
|
||||||
|
)
|
||||||
|
),
|
||||||
|
vol.Optional(CONF_EVENTS, default=[]): SelectSelector(
|
||||||
|
SelectSelectorConfig(
|
||||||
|
mode=SelectSelectorMode.DROPDOWN,
|
||||||
|
multiple=True,
|
||||||
|
sort=True,
|
||||||
|
custom_value=True,
|
||||||
|
options=list(EVENTS),
|
||||||
|
)
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TraccarServerConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||||
|
"""Handle a config flow for Traccar Server."""
|
||||||
|
|
||||||
|
async def _get_server_info(self, user_input: dict[str, Any]) -> ServerModel:
|
||||||
|
"""Get server info."""
|
||||||
|
client = ApiClient(
|
||||||
|
client_session=async_get_clientsession(self.hass),
|
||||||
|
host=user_input[CONF_HOST],
|
||||||
|
port=user_input[CONF_PORT],
|
||||||
|
username=user_input[CONF_USERNAME],
|
||||||
|
password=user_input[CONF_PASSWORD],
|
||||||
|
ssl=user_input[CONF_SSL],
|
||||||
|
verify_ssl=user_input[CONF_VERIFY_SSL],
|
||||||
|
)
|
||||||
|
return await client.get_server()
|
||||||
|
|
||||||
|
async def async_step_user(
|
||||||
|
self,
|
||||||
|
user_input: dict[str, Any] | None = None,
|
||||||
|
) -> FlowResult:
|
||||||
|
"""Handle the initial step."""
|
||||||
|
errors: dict[str, str] = {}
|
||||||
|
if user_input is not None:
|
||||||
|
self._async_abort_entries_match(
|
||||||
|
{
|
||||||
|
CONF_HOST: user_input[CONF_HOST],
|
||||||
|
CONF_PORT: user_input[CONF_PORT],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await self._get_server_info(user_input)
|
||||||
|
except TraccarException as exception:
|
||||||
|
LOGGER.error("Unable to connect to Traccar Server: %s", exception)
|
||||||
|
errors["base"] = "cannot_connect"
|
||||||
|
except Exception: # pylint: disable=broad-except
|
||||||
|
LOGGER.exception("Unexpected exception")
|
||||||
|
errors["base"] = "unknown"
|
||||||
|
else:
|
||||||
|
return self.async_create_entry(
|
||||||
|
title=f"{user_input[CONF_HOST]}:{user_input[CONF_PORT]}",
|
||||||
|
data=user_input,
|
||||||
|
)
|
||||||
|
|
||||||
|
return self.async_show_form(
|
||||||
|
step_id="user",
|
||||||
|
data_schema=STEP_USER_DATA_SCHEMA,
|
||||||
|
errors=errors,
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
@callback
|
||||||
|
def async_get_options_flow(
|
||||||
|
config_entry: config_entries.ConfigEntry,
|
||||||
|
) -> SchemaOptionsFlowHandler:
|
||||||
|
"""Get the options flow for this handler."""
|
||||||
|
return SchemaOptionsFlowHandler(config_entry, OPTIONS_FLOW)
|
39
homeassistant/components/traccar_server/const.py
Normal file
39
homeassistant/components/traccar_server/const.py
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
"""Constants for the Traccar Server integration."""
|
||||||
|
from logging import getLogger
|
||||||
|
|
||||||
|
DOMAIN = "traccar_server"
|
||||||
|
LOGGER = getLogger(__package__)
|
||||||
|
|
||||||
|
ATTR_ADDRESS = "address"
|
||||||
|
ATTR_ALTITUDE = "altitude"
|
||||||
|
ATTR_CATEGORY = "category"
|
||||||
|
ATTR_GEOFENCE = "geofence"
|
||||||
|
ATTR_MOTION = "motion"
|
||||||
|
ATTR_SPEED = "speed"
|
||||||
|
ATTR_STATUS = "status"
|
||||||
|
ATTR_TRACKER = "tracker"
|
||||||
|
ATTR_TRACCAR_ID = "traccar_id"
|
||||||
|
|
||||||
|
CONF_MAX_ACCURACY = "max_accuracy"
|
||||||
|
CONF_CUSTOM_ATTRIBUTES = "custom_attributes"
|
||||||
|
CONF_EVENTS = "events"
|
||||||
|
CONF_SKIP_ACCURACY_FILTER_FOR = "skip_accuracy_filter_for"
|
||||||
|
|
||||||
|
EVENTS = {
|
||||||
|
"deviceMoving": "device_moving",
|
||||||
|
"commandResult": "command_result",
|
||||||
|
"deviceFuelDrop": "device_fuel_drop",
|
||||||
|
"geofenceEnter": "geofence_enter",
|
||||||
|
"deviceOffline": "device_offline",
|
||||||
|
"driverChanged": "driver_changed",
|
||||||
|
"geofenceExit": "geofence_exit",
|
||||||
|
"deviceOverspeed": "device_overspeed",
|
||||||
|
"deviceOnline": "device_online",
|
||||||
|
"deviceStopped": "device_stopped",
|
||||||
|
"maintenance": "maintenance",
|
||||||
|
"alarm": "alarm",
|
||||||
|
"textMessage": "text_message",
|
||||||
|
"deviceUnknown": "device_unknown",
|
||||||
|
"ignitionOff": "ignition_off",
|
||||||
|
"ignitionOn": "ignition_on",
|
||||||
|
}
|
165
homeassistant/components/traccar_server/coordinator.py
Normal file
165
homeassistant/components/traccar_server/coordinator.py
Normal file
@ -0,0 +1,165 @@
|
|||||||
|
"""Data update coordinator for Traccar Server."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import TYPE_CHECKING, Any, TypedDict
|
||||||
|
|
||||||
|
from pytraccar import (
|
||||||
|
ApiClient,
|
||||||
|
DeviceModel,
|
||||||
|
GeofenceModel,
|
||||||
|
PositionModel,
|
||||||
|
TraccarException,
|
||||||
|
)
|
||||||
|
|
||||||
|
from homeassistant.config_entries import ConfigEntry
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||||
|
from homeassistant.util import dt as dt_util
|
||||||
|
|
||||||
|
from .const import DOMAIN, EVENTS, LOGGER
|
||||||
|
from .helpers import get_device, get_first_geofence
|
||||||
|
|
||||||
|
|
||||||
|
class TraccarServerCoordinatorDataDevice(TypedDict):
|
||||||
|
"""Traccar Server coordinator data."""
|
||||||
|
|
||||||
|
device: DeviceModel
|
||||||
|
geofence: GeofenceModel | None
|
||||||
|
position: PositionModel
|
||||||
|
attributes: dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
|
TraccarServerCoordinatorData = dict[str, TraccarServerCoordinatorDataDevice]
|
||||||
|
|
||||||
|
|
||||||
|
class TraccarServerCoordinator(DataUpdateCoordinator[TraccarServerCoordinatorData]):
|
||||||
|
"""Class to manage fetching Traccar Server data."""
|
||||||
|
|
||||||
|
config_entry: ConfigEntry
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
hass: HomeAssistant,
|
||||||
|
client: ApiClient,
|
||||||
|
*,
|
||||||
|
events: list[str],
|
||||||
|
max_accuracy: float,
|
||||||
|
skip_accuracy_filter_for: list[str],
|
||||||
|
custom_attributes: list[str],
|
||||||
|
) -> None:
|
||||||
|
"""Initialize global Traccar Server data updater."""
|
||||||
|
super().__init__(
|
||||||
|
hass=hass,
|
||||||
|
logger=LOGGER,
|
||||||
|
name=DOMAIN,
|
||||||
|
update_interval=timedelta(seconds=30),
|
||||||
|
)
|
||||||
|
self.client = client
|
||||||
|
self.custom_attributes = custom_attributes
|
||||||
|
self.events = events
|
||||||
|
self.max_accuracy = max_accuracy
|
||||||
|
self.skip_accuracy_filter_for = skip_accuracy_filter_for
|
||||||
|
self._last_event_import: datetime | None = None
|
||||||
|
|
||||||
|
async def _async_update_data(self) -> TraccarServerCoordinatorData:
|
||||||
|
"""Fetch data from Traccar Server."""
|
||||||
|
LOGGER.debug("Updating device data")
|
||||||
|
data: TraccarServerCoordinatorData = {}
|
||||||
|
try:
|
||||||
|
(
|
||||||
|
devices,
|
||||||
|
positions,
|
||||||
|
geofences,
|
||||||
|
) = await asyncio.gather(
|
||||||
|
self.client.get_devices(),
|
||||||
|
self.client.get_positions(),
|
||||||
|
self.client.get_geofences(),
|
||||||
|
)
|
||||||
|
except TraccarException as ex:
|
||||||
|
raise UpdateFailed("Error while updating device data: %s") from ex
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
assert isinstance(devices, list[DeviceModel]) # type: ignore[misc]
|
||||||
|
assert isinstance(positions, list[PositionModel]) # type: ignore[misc]
|
||||||
|
assert isinstance(geofences, list[GeofenceModel]) # type: ignore[misc]
|
||||||
|
|
||||||
|
for position in positions:
|
||||||
|
if (device := get_device(position["deviceId"], devices)) is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
attr = {}
|
||||||
|
skip_accuracy_filter = False
|
||||||
|
|
||||||
|
for custom_attr in self.custom_attributes:
|
||||||
|
attr[custom_attr] = getattr(
|
||||||
|
device["attributes"],
|
||||||
|
custom_attr,
|
||||||
|
getattr(position["attributes"], custom_attr, None),
|
||||||
|
)
|
||||||
|
if custom_attr in self.skip_accuracy_filter_for:
|
||||||
|
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,
|
||||||
|
device["id"],
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
data[device["uniqueId"]] = {
|
||||||
|
"device": device,
|
||||||
|
"geofence": get_first_geofence(
|
||||||
|
geofences,
|
||||||
|
position["geofenceIds"] or [],
|
||||||
|
),
|
||||||
|
"position": position,
|
||||||
|
"attributes": attr,
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.events:
|
||||||
|
self.hass.async_create_task(self.import_events(devices))
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
async def import_events(self, devices: list[DeviceModel]) -> None:
|
||||||
|
"""Import events from Traccar."""
|
||||||
|
start_time = dt_util.utcnow().replace(tzinfo=None)
|
||||||
|
end_time = None
|
||||||
|
|
||||||
|
if self._last_event_import is not None:
|
||||||
|
end_time = start_time - (start_time - self._last_event_import)
|
||||||
|
|
||||||
|
events = await self.client.get_reports_events(
|
||||||
|
devices=[device["id"] for device in devices],
|
||||||
|
start_time=start_time,
|
||||||
|
end_time=end_time,
|
||||||
|
event_types=self.events,
|
||||||
|
)
|
||||||
|
if not events:
|
||||||
|
return
|
||||||
|
|
||||||
|
self._last_event_import = start_time
|
||||||
|
for event in events:
|
||||||
|
device = get_device(event["deviceId"], devices)
|
||||||
|
self.hass.bus.async_fire(
|
||||||
|
# This goes against two of the HA core guidelines:
|
||||||
|
# 1. Event names should be prefixed with the domain name of the integration
|
||||||
|
# 2. This should be event entities
|
||||||
|
# However, to not break it for those who currently use the "old" integration, this is kept as is.
|
||||||
|
f"traccar_{EVENTS[event['type']]}",
|
||||||
|
{
|
||||||
|
"device_traccar_id": event["deviceId"],
|
||||||
|
"device_name": getattr(device, "name", None),
|
||||||
|
"type": event["type"],
|
||||||
|
"serverTime": event["eventTime"],
|
||||||
|
"attributes": event["attributes"],
|
||||||
|
},
|
||||||
|
)
|
85
homeassistant/components/traccar_server/device_tracker.py
Normal file
85
homeassistant/components/traccar_server/device_tracker.py
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
"""Support for Traccar server device tracking."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from homeassistant.components.device_tracker import SourceType, TrackerEntity
|
||||||
|
from homeassistant.config_entries import ConfigEntry
|
||||||
|
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,
|
||||||
|
DOMAIN,
|
||||||
|
)
|
||||||
|
from .coordinator import TraccarServerCoordinator
|
||||||
|
from .entity import TraccarServerEntity
|
||||||
|
|
||||||
|
|
||||||
|
async def async_setup_entry(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
entry: ConfigEntry,
|
||||||
|
async_add_entities: AddEntitiesCallback,
|
||||||
|
) -> None:
|
||||||
|
"""Set up device tracker entities."""
|
||||||
|
coordinator: TraccarServerCoordinator = hass.data[DOMAIN][entry.entry_id]
|
||||||
|
async_add_entities(
|
||||||
|
TraccarServerDeviceTracker(coordinator, entry["device"])
|
||||||
|
for entry in coordinator.data.values()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TraccarServerDeviceTracker(TraccarServerEntity, TrackerEntity):
|
||||||
|
"""Represent a tracked device."""
|
||||||
|
|
||||||
|
_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."""
|
||||||
|
return {
|
||||||
|
**self.traccar_attributes,
|
||||||
|
ATTR_ADDRESS: self.traccar_position["address"],
|
||||||
|
ATTR_ALTITUDE: self.traccar_position["altitude"],
|
||||||
|
ATTR_CATEGORY: self.traccar_device["category"],
|
||||||
|
ATTR_GEOFENCE: getattr(self.traccar_geofence, "name", None),
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def latitude(self) -> float:
|
||||||
|
"""Return latitude value of the device."""
|
||||||
|
return self.traccar_position["latitude"]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def longitude(self) -> float:
|
||||||
|
"""Return longitude value of the device."""
|
||||||
|
return self.traccar_position["longitude"]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def location_accuracy(self) -> int:
|
||||||
|
"""Return the gps accuracy of the device."""
|
||||||
|
return self.traccar_position["accuracy"]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def source_type(self) -> SourceType:
|
||||||
|
"""Return the source type, eg gps or router, of the device."""
|
||||||
|
return SourceType.GPS
|
59
homeassistant/components/traccar_server/entity.py
Normal file
59
homeassistant/components/traccar_server/entity.py
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
"""Base entity for Traccar Server."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from pytraccar import DeviceModel, GeofenceModel, PositionModel
|
||||||
|
|
||||||
|
from homeassistant.helpers.device_registry import DeviceInfo
|
||||||
|
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||||
|
|
||||||
|
from .const import DOMAIN
|
||||||
|
from .coordinator import TraccarServerCoordinator
|
||||||
|
|
||||||
|
|
||||||
|
class TraccarServerEntity(CoordinatorEntity[TraccarServerCoordinator]):
|
||||||
|
"""Base entity for Traccar Server."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
coordinator: TraccarServerCoordinator,
|
||||||
|
device: DeviceModel,
|
||||||
|
) -> None:
|
||||||
|
"""Initialize the Traccar Server entity."""
|
||||||
|
super().__init__(coordinator)
|
||||||
|
self.device_id = device["uniqueId"]
|
||||||
|
self._attr_device_info = DeviceInfo(
|
||||||
|
identifiers={(DOMAIN, device["uniqueId"])},
|
||||||
|
model=device["model"],
|
||||||
|
name=device["name"],
|
||||||
|
)
|
||||||
|
self._attr_unique_id = device["uniqueId"]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def available(self) -> bool:
|
||||||
|
"""Return True if entity is available."""
|
||||||
|
return (
|
||||||
|
self.coordinator.last_update_success
|
||||||
|
and self.device_id in self.coordinator.data
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def traccar_device(self) -> DeviceModel:
|
||||||
|
"""Return the device."""
|
||||||
|
return self.coordinator.data[self.device_id]["device"]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def traccar_geofence(self) -> GeofenceModel | None:
|
||||||
|
"""Return the geofence."""
|
||||||
|
return self.coordinator.data[self.device_id]["geofence"]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def traccar_position(self) -> PositionModel:
|
||||||
|
"""Return the position."""
|
||||||
|
return self.coordinator.data[self.device_id]["position"]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def traccar_attributes(self) -> dict[str, Any]:
|
||||||
|
"""Return the attributes."""
|
||||||
|
return self.coordinator.data[self.device_id]["attributes"]
|
23
homeassistant/components/traccar_server/helpers.py
Normal file
23
homeassistant/components/traccar_server/helpers.py
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
"""Helper functions for the Traccar Server integration."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pytraccar import DeviceModel, GeofenceModel
|
||||||
|
|
||||||
|
|
||||||
|
def get_device(device_id: int, devices: list[DeviceModel]) -> DeviceModel | None:
|
||||||
|
"""Return the device."""
|
||||||
|
return next(
|
||||||
|
(dev for dev in devices if dev["id"] == device_id),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_first_geofence(
|
||||||
|
geofences: list[GeofenceModel],
|
||||||
|
target: list[int],
|
||||||
|
) -> GeofenceModel | None:
|
||||||
|
"""Return the geofence."""
|
||||||
|
return next(
|
||||||
|
(geofence for geofence in geofences if geofence["id"] in target),
|
||||||
|
None,
|
||||||
|
)
|
9
homeassistant/components/traccar_server/manifest.json
Normal file
9
homeassistant/components/traccar_server/manifest.json
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"domain": "traccar_server",
|
||||||
|
"name": "Traccar Server",
|
||||||
|
"codeowners": ["@ludeeus"],
|
||||||
|
"config_flow": true,
|
||||||
|
"documentation": "https://www.home-assistant.io/integrations/traccar_server",
|
||||||
|
"iot_class": "local_polling",
|
||||||
|
"requirements": ["pytraccar==2.0.0"]
|
||||||
|
}
|
45
homeassistant/components/traccar_server/strings.json
Normal file
45
homeassistant/components/traccar_server/strings.json
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
{
|
||||||
|
"config": {
|
||||||
|
"step": {
|
||||||
|
"user": {
|
||||||
|
"data": {
|
||||||
|
"host": "[%key:common::config_flow::data::host%]",
|
||||||
|
"password": "[%key:common::config_flow::data::password%]",
|
||||||
|
"port": "[%key:common::config_flow::data::port%]",
|
||||||
|
"ssl": "[%key:common::config_flow::data::ssl%]",
|
||||||
|
"username": "[%key:common::config_flow::data::username%]",
|
||||||
|
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
|
||||||
|
},
|
||||||
|
"data_description": {
|
||||||
|
"host": "The hostname or IP address of your Traccar Server",
|
||||||
|
"username": "The username (email) you use to login to your Traccar Server"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||||
|
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||||
|
},
|
||||||
|
"abort": {
|
||||||
|
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"step": {
|
||||||
|
"init": {
|
||||||
|
"data": {
|
||||||
|
"max_accuracy": "Max accuracy",
|
||||||
|
"skip_accuracy_filter_for": "Position skip filter for attributes",
|
||||||
|
"custom_attributes": "Custom attributes",
|
||||||
|
"events": "Events"
|
||||||
|
},
|
||||||
|
"data_description": {
|
||||||
|
"max_accuracy": "Any position reports with accuracy higher than this value will be ignored",
|
||||||
|
"skip_accuracy_filter_for": "Attributes defined here will bypass the accuracy filter if they are present in the update",
|
||||||
|
"custom_attributes": "Add any custom or calculated attributes here. These will be added to the device attributes",
|
||||||
|
"events": "Selected events will be fired in Home Assistant"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -538,6 +538,7 @@ FLOWS = {
|
|||||||
"tplink",
|
"tplink",
|
||||||
"tplink_omada",
|
"tplink_omada",
|
||||||
"traccar",
|
"traccar",
|
||||||
|
"traccar_server",
|
||||||
"tractive",
|
"tractive",
|
||||||
"tradfri",
|
"tradfri",
|
||||||
"trafikverket_camera",
|
"trafikverket_camera",
|
||||||
|
@ -6168,6 +6168,12 @@
|
|||||||
"config_flow": true,
|
"config_flow": true,
|
||||||
"iot_class": "local_polling"
|
"iot_class": "local_polling"
|
||||||
},
|
},
|
||||||
|
"traccar_server": {
|
||||||
|
"name": "Traccar Server",
|
||||||
|
"integration_type": "hub",
|
||||||
|
"config_flow": true,
|
||||||
|
"iot_class": "local_polling"
|
||||||
|
},
|
||||||
"tractive": {
|
"tractive": {
|
||||||
"name": "Tractive",
|
"name": "Tractive",
|
||||||
"integration_type": "device",
|
"integration_type": "device",
|
||||||
|
@ -2305,6 +2305,7 @@ pytomorrowio==0.3.6
|
|||||||
pytouchline==0.7
|
pytouchline==0.7
|
||||||
|
|
||||||
# homeassistant.components.traccar
|
# homeassistant.components.traccar
|
||||||
|
# homeassistant.components.traccar_server
|
||||||
pytraccar==2.0.0
|
pytraccar==2.0.0
|
||||||
|
|
||||||
# homeassistant.components.tradfri
|
# homeassistant.components.tradfri
|
||||||
|
@ -1760,6 +1760,7 @@ pytile==2023.04.0
|
|||||||
pytomorrowio==0.3.6
|
pytomorrowio==0.3.6
|
||||||
|
|
||||||
# homeassistant.components.traccar
|
# homeassistant.components.traccar
|
||||||
|
# homeassistant.components.traccar_server
|
||||||
pytraccar==2.0.0
|
pytraccar==2.0.0
|
||||||
|
|
||||||
# homeassistant.components.tradfri
|
# homeassistant.components.tradfri
|
||||||
|
1
tests/components/traccar_server/__init__.py
Normal file
1
tests/components/traccar_server/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
"""Tests for the Traccar Server integration."""
|
14
tests/components/traccar_server/conftest.py
Normal file
14
tests/components/traccar_server/conftest.py
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
"""Common fixtures for the Traccar Server tests."""
|
||||||
|
from collections.abc import Generator
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_setup_entry() -> Generator[AsyncMock, None, None]:
|
||||||
|
"""Override async_setup_entry."""
|
||||||
|
with patch(
|
||||||
|
"homeassistant.components.traccar_server.async_setup_entry", return_value=True
|
||||||
|
) as mock_setup_entry:
|
||||||
|
yield mock_setup_entry
|
189
tests/components/traccar_server/test_config_flow.py
Normal file
189
tests/components/traccar_server/test_config_flow.py
Normal file
@ -0,0 +1,189 @@
|
|||||||
|
"""Test the Traccar Server config flow."""
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from pytraccar import TraccarException
|
||||||
|
|
||||||
|
from homeassistant import config_entries
|
||||||
|
from homeassistant.components.traccar_server.const import (
|
||||||
|
CONF_CUSTOM_ATTRIBUTES,
|
||||||
|
CONF_EVENTS,
|
||||||
|
CONF_MAX_ACCURACY,
|
||||||
|
CONF_SKIP_ACCURACY_FILTER_FOR,
|
||||||
|
DOMAIN,
|
||||||
|
)
|
||||||
|
from homeassistant.const import (
|
||||||
|
CONF_HOST,
|
||||||
|
CONF_PASSWORD,
|
||||||
|
CONF_PORT,
|
||||||
|
CONF_SSL,
|
||||||
|
CONF_USERNAME,
|
||||||
|
CONF_VERIFY_SSL,
|
||||||
|
)
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.data_entry_flow import FlowResultType
|
||||||
|
|
||||||
|
from tests.common import MockConfigEntry
|
||||||
|
|
||||||
|
|
||||||
|
async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None:
|
||||||
|
"""Test we get the form."""
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||||
|
)
|
||||||
|
assert result["type"] == FlowResultType.FORM
|
||||||
|
assert result["errors"] == {}
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"homeassistant.components.traccar_server.config_flow.ApiClient.get_server",
|
||||||
|
return_value={"id": "1234"},
|
||||||
|
):
|
||||||
|
result = await hass.config_entries.flow.async_configure(
|
||||||
|
result["flow_id"],
|
||||||
|
{
|
||||||
|
CONF_HOST: "1.1.1.1",
|
||||||
|
CONF_USERNAME: "test-username",
|
||||||
|
CONF_PASSWORD: "test-password",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
assert result["type"] == FlowResultType.CREATE_ENTRY
|
||||||
|
assert result["title"] == "1.1.1.1:8082"
|
||||||
|
assert result["data"] == {
|
||||||
|
CONF_HOST: "1.1.1.1",
|
||||||
|
CONF_PORT: "8082",
|
||||||
|
CONF_USERNAME: "test-username",
|
||||||
|
CONF_PASSWORD: "test-password",
|
||||||
|
CONF_SSL: False,
|
||||||
|
CONF_VERIFY_SSL: True,
|
||||||
|
}
|
||||||
|
assert len(mock_setup_entry.mock_calls) == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("side_effect", "error"),
|
||||||
|
(
|
||||||
|
(TraccarException, "cannot_connect"),
|
||||||
|
(Exception, "unknown"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
async def test_form_cannot_connect(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
mock_setup_entry: AsyncMock,
|
||||||
|
side_effect: Exception,
|
||||||
|
error: str,
|
||||||
|
) -> None:
|
||||||
|
"""Test we handle cannot connect error."""
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"homeassistant.components.traccar_server.config_flow.ApiClient.get_server",
|
||||||
|
side_effect=side_effect,
|
||||||
|
):
|
||||||
|
result = await hass.config_entries.flow.async_configure(
|
||||||
|
result["flow_id"],
|
||||||
|
{
|
||||||
|
CONF_HOST: "1.1.1.1",
|
||||||
|
CONF_USERNAME: "test-username",
|
||||||
|
CONF_PASSWORD: "test-password",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["type"] == FlowResultType.FORM
|
||||||
|
assert result["errors"] == {"base": error}
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"homeassistant.components.traccar_server.config_flow.ApiClient.get_server",
|
||||||
|
return_value={"id": "1234"},
|
||||||
|
):
|
||||||
|
result = await hass.config_entries.flow.async_configure(
|
||||||
|
result["flow_id"],
|
||||||
|
{
|
||||||
|
CONF_HOST: "1.1.1.1",
|
||||||
|
CONF_USERNAME: "test-username",
|
||||||
|
CONF_PASSWORD: "test-password",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
assert result["type"] == FlowResultType.CREATE_ENTRY
|
||||||
|
assert result["title"] == "1.1.1.1:8082"
|
||||||
|
assert result["data"] == {
|
||||||
|
CONF_HOST: "1.1.1.1",
|
||||||
|
CONF_PORT: "8082",
|
||||||
|
CONF_USERNAME: "test-username",
|
||||||
|
CONF_PASSWORD: "test-password",
|
||||||
|
CONF_SSL: False,
|
||||||
|
CONF_VERIFY_SSL: True,
|
||||||
|
}
|
||||||
|
assert len(mock_setup_entry.mock_calls) == 1
|
||||||
|
|
||||||
|
|
||||||
|
async def test_options(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
mock_setup_entry: AsyncMock,
|
||||||
|
) -> None:
|
||||||
|
"""Test options flow."""
|
||||||
|
|
||||||
|
config_entry = MockConfigEntry(
|
||||||
|
domain=DOMAIN,
|
||||||
|
data={},
|
||||||
|
)
|
||||||
|
|
||||||
|
config_entry.add_to_hass(hass)
|
||||||
|
|
||||||
|
assert await hass.config_entries.async_setup(config_entry.entry_id)
|
||||||
|
|
||||||
|
assert CONF_MAX_ACCURACY not in config_entry.options
|
||||||
|
|
||||||
|
result = await hass.config_entries.options.async_init(config_entry.entry_id)
|
||||||
|
|
||||||
|
result = await hass.config_entries.options.async_configure(
|
||||||
|
result["flow_id"],
|
||||||
|
user_input={CONF_MAX_ACCURACY: 2.0},
|
||||||
|
)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
assert result["type"] == FlowResultType.CREATE_ENTRY
|
||||||
|
assert config_entry.options == {
|
||||||
|
CONF_MAX_ACCURACY: 2.0,
|
||||||
|
CONF_EVENTS: [],
|
||||||
|
CONF_CUSTOM_ATTRIBUTES: [],
|
||||||
|
CONF_SKIP_ACCURACY_FILTER_FOR: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_abort_already_configured(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
mock_setup_entry: AsyncMock,
|
||||||
|
) -> None:
|
||||||
|
"""Test abort for existing server."""
|
||||||
|
|
||||||
|
config_entry = MockConfigEntry(
|
||||||
|
domain=DOMAIN,
|
||||||
|
data={CONF_HOST: "1.1.1.1", CONF_PORT: "8082"},
|
||||||
|
)
|
||||||
|
|
||||||
|
config_entry.add_to_hass(hass)
|
||||||
|
|
||||||
|
assert await hass.config_entries.async_setup(config_entry.entry_id)
|
||||||
|
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await hass.config_entries.flow.async_configure(
|
||||||
|
result["flow_id"],
|
||||||
|
{
|
||||||
|
CONF_HOST: "1.1.1.1",
|
||||||
|
CONF_PORT: "8082",
|
||||||
|
CONF_USERNAME: "test-username",
|
||||||
|
CONF_PASSWORD: "test-password",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["type"] == FlowResultType.ABORT
|
||||||
|
assert result["reason"] == "already_configured"
|
Loading…
x
Reference in New Issue
Block a user