From dc5c1783dc76f01e7ab91eeea1eafd7286153e01 Mon Sep 17 00:00:00 2001 From: escoand Date: Sat, 10 Aug 2019 00:14:03 +0200 Subject: [PATCH] Webhook for Traccar (#24762) * add initial traccar webhook support * remove unused import * add tests but disabled atm * remove translations * add timestamp parameter * use post for tests * rename config_flow * format using black * format tests using black * Use str instead of float * fix most comments * check id * add two device test * reformat * fix failuers * Update tests/components/traccar/test_init.py Co-Authored-By: Martin Hjelmare * Update tests/components/traccar/test_init.py Co-Authored-By: Martin Hjelmare * Update tests/components/traccar/test_init.py Co-Authored-By: Martin Hjelmare * Update tests/components/traccar/test_init.py Co-Authored-By: Martin Hjelmare * black --- homeassistant/components/traccar/__init__.py | 111 +++++++- .../components/traccar/config_flow.py | 10 + homeassistant/components/traccar/const.py | 12 +- .../components/traccar/device_tracker.py | 177 ++++++++++++- .../components/traccar/manifest.json | 7 +- homeassistant/generated/config_flows.py | 1 + tests/components/traccar/__init__.py | 1 + tests/components/traccar/test_init.py | 243 ++++++++++++++++++ 8 files changed, 556 insertions(+), 6 deletions(-) create mode 100644 homeassistant/components/traccar/config_flow.py create mode 100644 tests/components/traccar/__init__.py create mode 100644 tests/components/traccar/test_init.py diff --git a/homeassistant/components/traccar/__init__.py b/homeassistant/components/traccar/__init__.py index 03805760c53..8e3f90fb66f 100644 --- a/homeassistant/components/traccar/__init__.py +++ b/homeassistant/components/traccar/__init__.py @@ -1 +1,110 @@ -"""The traccar component.""" +"""Support for Traccar.""" +import logging + +import voluptuous as vol +from aiohttp import web + +import homeassistant.helpers.config_validation as cv +from homeassistant.const import HTTP_UNPROCESSABLE_ENTITY, HTTP_OK, CONF_WEBHOOK_ID +from homeassistant.helpers import config_entry_flow +from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.components.device_tracker import DOMAIN as DEVICE_TRACKER +from .const import ( + ATTR_ACCURACY, + ATTR_ALTITUDE, + ATTR_BATTERY, + ATTR_BEARING, + ATTR_ID, + ATTR_LATITUDE, + ATTR_LONGITUDE, + ATTR_SPEED, + ATTR_TIMESTAMP, + DOMAIN, +) + +_LOGGER = logging.getLogger(__name__) + +TRACKER_UPDATE = "{}_tracker_update".format(DOMAIN) + + +DEFAULT_ACCURACY = 200 +DEFAULT_BATTERY = -1 + + +def _id(value: str) -> str: + """Coerce id by removing '-'.""" + return value.replace("-", "") + + +WEBHOOK_SCHEMA = vol.Schema( + { + vol.Required(ATTR_ID): vol.All(cv.string, _id), + vol.Required(ATTR_LATITUDE): cv.latitude, + vol.Required(ATTR_LONGITUDE): cv.longitude, + vol.Optional(ATTR_ACCURACY, default=DEFAULT_ACCURACY): vol.Coerce(float), + vol.Optional(ATTR_ALTITUDE): vol.Coerce(float), + vol.Optional(ATTR_BATTERY, default=DEFAULT_BATTERY): vol.Coerce(float), + vol.Optional(ATTR_BEARING): vol.Coerce(float), + vol.Optional(ATTR_SPEED): vol.Coerce(float), + vol.Optional(ATTR_TIMESTAMP): vol.Coerce(int), + } +) + + +async def async_setup(hass, hass_config): + """Set up the Traccar component.""" + hass.data[DOMAIN] = {"devices": set(), "unsub_device_tracker": {}} + return True + + +async def handle_webhook(hass, webhook_id, request): + """Handle incoming webhook with Traccar request.""" + try: + data = WEBHOOK_SCHEMA(dict(request.query)) + except vol.MultipleInvalid as error: + return web.Response(text=error.error_message, status=HTTP_UNPROCESSABLE_ENTITY) + + attrs = { + ATTR_ALTITUDE: data.get(ATTR_ALTITUDE), + ATTR_BEARING: data.get(ATTR_BEARING), + ATTR_SPEED: data.get(ATTR_SPEED), + } + + device = data[ATTR_ID] + + async_dispatcher_send( + hass, + TRACKER_UPDATE, + device, + data[ATTR_LATITUDE], + data[ATTR_LONGITUDE], + data[ATTR_BATTERY], + data[ATTR_ACCURACY], + attrs, + ) + + return web.Response(text="Setting location for {}".format(device), status=HTTP_OK) + + +async def async_setup_entry(hass, entry): + """Configure based on config entry.""" + hass.components.webhook.async_register( + DOMAIN, "Traccar", entry.data[CONF_WEBHOOK_ID], handle_webhook + ) + + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, DEVICE_TRACKER) + ) + return True + + +async def async_unload_entry(hass, entry): + """Unload a config entry.""" + hass.components.webhook.async_unregister(entry.data[CONF_WEBHOOK_ID]) + hass.data[DOMAIN]["unsub_device_tracker"].pop(entry.entry_id)() + await hass.config_entries.async_forward_entry_unload(entry, DEVICE_TRACKER) + return True + + +# pylint: disable=invalid-name +async_remove_entry = config_entry_flow.webhook_async_remove_entry diff --git a/homeassistant/components/traccar/config_flow.py b/homeassistant/components/traccar/config_flow.py new file mode 100644 index 00000000000..cc3f1f23727 --- /dev/null +++ b/homeassistant/components/traccar/config_flow.py @@ -0,0 +1,10 @@ +"""Config flow for Traccar.""" +from homeassistant.helpers import config_entry_flow +from .const import DOMAIN + + +config_entry_flow.register_webhook_flow( + DOMAIN, + "Traccar Webhook", + {"docs_url": "https://www.home-assistant.io/components/traccar/"}, +) diff --git a/homeassistant/components/traccar/const.py b/homeassistant/components/traccar/const.py index 58f7168cf43..56c0ab5ba1d 100644 --- a/homeassistant/components/traccar/const.py +++ b/homeassistant/components/traccar/const.py @@ -1,16 +1,26 @@ """Constants for Traccar integration.""" +DOMAIN = "traccar" + CONF_MAX_ACCURACY = "max_accuracy" CONF_SKIP_ACCURACY_ON = "skip_accuracy_filter_on" +ATTR_ACCURACY = "accuracy" ATTR_ADDRESS = "address" +ATTR_ALTITUDE = "altitude" +ATTR_BATTERY = "batt" +ATTR_BEARING = "bearing" ATTR_CATEGORY = "category" ATTR_GEOFENCE = "geofence" +ATTR_ID = "id" +ATTR_LATITUDE = "lat" +ATTR_LONGITUDE = "lon" ATTR_MOTION = "motion" ATTR_SPEED = "speed" +ATTR_STATUS = "status" +ATTR_TIMESTAMP = "timestamp" ATTR_TRACKER = "tracker" ATTR_TRACCAR_ID = "traccar_id" -ATTR_STATUS = "status" EVENT_DEVICE_MOVING = "device_moving" EVENT_COMMAND_RESULT = "command_result" diff --git a/homeassistant/components/traccar/device_tracker.py b/homeassistant/components/traccar/device_tracker.py index bc1eebf05da..c7fdda013b0 100644 --- a/homeassistant/components/traccar/device_tracker.py +++ b/homeassistant/components/traccar/device_tracker.py @@ -4,7 +4,7 @@ import logging import voluptuous as vol -from homeassistant.components.device_tracker import PLATFORM_SCHEMA +from homeassistant.core import callback from homeassistant.const import ( CONF_HOST, CONF_PORT, @@ -16,19 +16,33 @@ from homeassistant.const import ( CONF_MONITORED_CONDITIONS, CONF_EVENT, ) +from homeassistant.components.device_tracker import PLATFORM_SCHEMA, SOURCE_TYPE_GPS +from homeassistant.components.device_tracker.config_entry import TrackerEntity +from homeassistant.helpers import device_registry +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.restore_state import RestoreEntity +from homeassistant.helpers.typing import HomeAssistantType import homeassistant.helpers.config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.event import async_track_time_interval from homeassistant.util import slugify + +from . import DOMAIN, TRACKER_UPDATE from .const import ( + ATTR_ACCURACY, ATTR_ADDRESS, + ATTR_ALTITUDE, + ATTR_BATTERY, + ATTR_BEARING, ATTR_CATEGORY, ATTR_GEOFENCE, + ATTR_LATITUDE, + ATTR_LONGITUDE, ATTR_MOTION, ATTR_SPEED, + ATTR_STATUS, ATTR_TRACKER, ATTR_TRACCAR_ID, - ATTR_STATUS, EVENT_DEVICE_MOVING, EVENT_COMMAND_RESULT, EVENT_DEVICE_FUEL_DROP, @@ -101,6 +115,45 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( ) +async def async_setup_entry(hass: HomeAssistantType, entry, async_add_entities): + """Configure a dispatcher connection based on a config entry.""" + + @callback + def _receive_data(device, latitude, longitude, battery, accuracy, attrs): + """Receive set location.""" + if device in hass.data[DOMAIN]["devices"]: + return + + hass.data[DOMAIN]["devices"].add(device) + + async_add_entities( + [TraccarEntity(device, latitude, longitude, battery, accuracy, attrs)] + ) + + hass.data[DOMAIN]["unsub_device_tracker"][ + entry.entry_id + ] = async_dispatcher_connect(hass, TRACKER_UPDATE, _receive_data) + + # Restore previously loaded devices + dev_reg = await device_registry.async_get_registry(hass) + dev_ids = { + identifier[1] + for device in dev_reg.devices.values() + for identifier in device.identifiers + if identifier[0] == DOMAIN + } + if not dev_ids: + return + + entities = [] + for dev_id in dev_ids: + hass.data[DOMAIN]["devices"].add(dev_id) + entity = TraccarEntity(dev_id, None, None, None, None, None) + entities.append(entity) + + async_add_entities(entities) + + async def async_setup_scanner(hass, config, async_see, discovery_info=None): """Validate the configuration and return a Traccar scanner.""" from pytraccar.api import API @@ -273,3 +326,123 @@ class TraccarScanner: "attributes": event["attributes"], }, ) + + +class TraccarEntity(TrackerEntity, RestoreEntity): + """Represent a tracked device.""" + + def __init__(self, device, latitude, longitude, battery, accuracy, attributes): + """Set up Geofency entity.""" + self._accuracy = accuracy + self._attributes = attributes + self._name = device + self._battery = battery + self._latitude = latitude + self._longitude = longitude + self._unsub_dispatcher = None + self._unique_id = device + + @property + def battery_level(self): + """Return battery value of the device.""" + return self._battery + + @property + def device_state_attributes(self): + """Return device specific attributes.""" + return self._attributes + + @property + def latitude(self): + """Return latitude value of the device.""" + return self._latitude + + @property + def longitude(self): + """Return longitude value of the device.""" + return self._longitude + + @property + def location_accuracy(self): + """Return the gps accuracy of the device.""" + return self._accuracy + + @property + def name(self): + """Return the name of the device.""" + return self._name + + @property + def should_poll(self): + """No polling needed.""" + return False + + @property + def unique_id(self): + """Return the unique ID.""" + return self._unique_id + + @property + def device_info(self): + """Return the device info.""" + return {"name": self._name, "identifiers": {(DOMAIN, self._unique_id)}} + + @property + def source_type(self): + """Return the source type, eg gps or router, of the device.""" + return SOURCE_TYPE_GPS + + async def async_added_to_hass(self): + """Register state update callback.""" + await super().async_added_to_hass() + self._unsub_dispatcher = async_dispatcher_connect( + self.hass, TRACKER_UPDATE, self._async_receive_data + ) + + # don't restore if we got created with data + if self._latitude is not None or self._longitude is not None: + return + + state = await self.async_get_last_state() + if state is None: + self._latitude = None + self._longitude = None + self._accuracy = None + self._attributes = { + ATTR_ALTITUDE: None, + ATTR_BEARING: None, + ATTR_SPEED: None, + } + self._battery = None + return + + attr = state.attributes + self._latitude = attr.get(ATTR_LATITUDE) + self._longitude = attr.get(ATTR_LONGITUDE) + self._accuracy = attr.get(ATTR_ACCURACY) + self._attributes = { + ATTR_ALTITUDE: attr.get(ATTR_ALTITUDE), + ATTR_BEARING: attr.get(ATTR_BEARING), + ATTR_SPEED: attr.get(ATTR_SPEED), + } + self._battery = attr.get(ATTR_BATTERY) + + async def async_will_remove_from_hass(self): + """Clean up after entity before removal.""" + await super().async_will_remove_from_hass() + self._unsub_dispatcher() + + @callback + def _async_receive_data( + self, device, latitude, longitude, battery, accuracy, attributes + ): + """Mark the device as seen.""" + if device != self.name: + return + + self._latitude = latitude + self._longitude = longitude + self._battery = battery + self._accuracy = accuracy + self._attributes.update(attributes) + self.async_write_ha_state() diff --git a/homeassistant/components/traccar/manifest.json b/homeassistant/components/traccar/manifest.json index 15b78d0ec7b..7d3e2f22d65 100644 --- a/homeassistant/components/traccar/manifest.json +++ b/homeassistant/components/traccar/manifest.json @@ -1,13 +1,16 @@ { "domain": "traccar", "name": "Traccar", + "config_flow": true, "documentation": "https://www.home-assistant.io/components/traccar", "requirements": [ "pytraccar==0.9.0", "stringcase==1.2.0" ], - "dependencies": [], + "dependencies": [ + "webhook" + ], "codeowners": [ "@ludeeus" ] -} \ No newline at end of file +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 827b946f776..497686d0a2e 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -51,6 +51,7 @@ FLOWS = [ "tellduslive", "toon", "tplink", + "traccar", "tradfri", "twentemilieu", "twilio", diff --git a/tests/components/traccar/__init__.py b/tests/components/traccar/__init__.py new file mode 100644 index 00000000000..48c7818452f --- /dev/null +++ b/tests/components/traccar/__init__.py @@ -0,0 +1 @@ +"""Tests for the Traccar component.""" diff --git a/tests/components/traccar/test_init.py b/tests/components/traccar/test_init.py new file mode 100644 index 00000000000..5a2dabcf6c2 --- /dev/null +++ b/tests/components/traccar/test_init.py @@ -0,0 +1,243 @@ +"""The tests the for Traccar device tracker platform.""" +from unittest.mock import Mock, patch + +import pytest + +from homeassistant import data_entry_flow +from homeassistant.components import traccar, zone +from homeassistant.components.device_tracker import DOMAIN as DEVICE_TRACKER_DOMAIN +from homeassistant.components.traccar import DOMAIN, TRACKER_UPDATE +from homeassistant.const import ( + HTTP_OK, + HTTP_UNPROCESSABLE_ENTITY, + STATE_HOME, + STATE_NOT_HOME, +) +from homeassistant.helpers.dispatcher import DATA_DISPATCHER +from homeassistant.setup import async_setup_component + +HOME_LATITUDE = 37.239622 +HOME_LONGITUDE = -115.815811 + + +@pytest.fixture(autouse=True) +def mock_dev_track(mock_device_tracker_conf): + """Mock device tracker config loading.""" + pass + + +@pytest.fixture(name="client") +async def traccar_client(loop, hass, aiohttp_client): + """Mock client for Traccar (unauthenticated).""" + assert await async_setup_component(hass, "persistent_notification", {}) + + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + + await hass.async_block_till_done() + + with patch("homeassistant.components.device_tracker.legacy.update_config"): + return await aiohttp_client(hass.http.app) + + +@pytest.fixture(autouse=True) +async def setup_zones(loop, hass): + """Set up Zone config in HA.""" + assert await async_setup_component( + hass, + zone.DOMAIN, + { + "zone": { + "name": "Home", + "latitude": HOME_LATITUDE, + "longitude": HOME_LONGITUDE, + "radius": 100, + } + }, + ) + await hass.async_block_till_done() + + +@pytest.fixture(name="webhook_id") +async def webhook_id_fixture(hass, client): + """Initialize the Traccar component and get the webhook_id.""" + hass.config.api = Mock(base_url="http://example.com") + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "user"} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM, result + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + + await hass.async_block_till_done() + return result["result"].data["webhook_id"] + + +async def test_missing_data(hass, client, webhook_id): + """Test missing data.""" + url = "/api/webhook/{}".format(webhook_id) + data = {"lat": "1.0", "lon": "1.1", "id": "123"} + + # No data + req = await client.post(url) + await hass.async_block_till_done() + assert req.status == HTTP_UNPROCESSABLE_ENTITY + + # No latitude + copy = data.copy() + del copy["lat"] + req = await client.post(url, params=copy) + await hass.async_block_till_done() + assert req.status == HTTP_UNPROCESSABLE_ENTITY + + # No device + copy = data.copy() + del copy["id"] + req = await client.post(url, params=copy) + await hass.async_block_till_done() + assert req.status == HTTP_UNPROCESSABLE_ENTITY + + +async def test_enter_and_exit(hass, client, webhook_id): + """Test when there is a known zone.""" + url = "/api/webhook/{}".format(webhook_id) + data = {"lat": str(HOME_LATITUDE), "lon": str(HOME_LONGITUDE), "id": "123"} + + # Enter the Home + req = await client.post(url, params=data) + await hass.async_block_till_done() + assert req.status == HTTP_OK + state_name = hass.states.get( + "{}.{}".format(DEVICE_TRACKER_DOMAIN, data["id"]) + ).state + assert STATE_HOME == state_name + + # Enter Home again + req = await client.post(url, params=data) + await hass.async_block_till_done() + assert req.status == HTTP_OK + state_name = hass.states.get( + "{}.{}".format(DEVICE_TRACKER_DOMAIN, data["id"]) + ).state + assert STATE_HOME == state_name + + data["lon"] = 0 + data["lat"] = 0 + + # Enter Somewhere else + req = await client.post(url, params=data) + await hass.async_block_till_done() + assert req.status == HTTP_OK + state_name = hass.states.get( + "{}.{}".format(DEVICE_TRACKER_DOMAIN, data["id"]) + ).state + assert STATE_NOT_HOME == state_name + + dev_reg = await hass.helpers.device_registry.async_get_registry() + assert len(dev_reg.devices) == 1 + + ent_reg = await hass.helpers.entity_registry.async_get_registry() + assert len(ent_reg.entities) == 1 + + +async def test_enter_with_attrs(hass, client, webhook_id): + """Test when additional attributes are present.""" + url = "/api/webhook/{}".format(webhook_id) + data = { + "timestamp": 123456789, + "lat": "1.0", + "lon": "1.1", + "id": "123", + "accuracy": "10.5", + "batt": 10, + "speed": 100, + "bearing": "105.32", + "altitude": 102, + } + + req = await client.post(url, params=data) + await hass.async_block_till_done() + assert req.status == HTTP_OK + state = hass.states.get("{}.{}".format(DEVICE_TRACKER_DOMAIN, data["id"])) + assert state.state == STATE_NOT_HOME + assert state.attributes["gps_accuracy"] == 10.5 + assert state.attributes["battery_level"] == 10.0 + assert state.attributes["speed"] == 100.0 + assert state.attributes["bearing"] == 105.32 + assert state.attributes["altitude"] == 102.0 + + data = { + "lat": str(HOME_LATITUDE), + "lon": str(HOME_LONGITUDE), + "id": "123", + "accuracy": 123, + "batt": 23, + "speed": 23, + "bearing": 123, + "altitude": 123, + } + + req = await client.post(url, params=data) + await hass.async_block_till_done() + assert req.status == HTTP_OK + state = hass.states.get("{}.{}".format(DEVICE_TRACKER_DOMAIN, data["id"])) + assert state.state == STATE_HOME + assert state.attributes["gps_accuracy"] == 123 + assert state.attributes["battery_level"] == 23 + assert state.attributes["speed"] == 23 + assert state.attributes["bearing"] == 123 + assert state.attributes["altitude"] == 123 + + +async def test_two_devices(hass, client, webhook_id): + """Test updating two different devices.""" + url = "/api/webhook/{}".format(webhook_id) + + data_device_1 = {"lat": "1.0", "lon": "1.1", "id": "device_1"} + + # Exit Home + req = await client.post(url, params=data_device_1) + await hass.async_block_till_done() + assert req.status == HTTP_OK + + state = hass.states.get("{}.{}".format(DEVICE_TRACKER_DOMAIN, data_device_1["id"])) + assert state.state == "not_home" + + # Enter Home + data_device_2 = dict(data_device_1) + data_device_2["lat"] = str(HOME_LATITUDE) + data_device_2["lon"] = str(HOME_LONGITUDE) + data_device_2["id"] = "device_2" + req = await client.post(url, params=data_device_2) + await hass.async_block_till_done() + assert req.status == HTTP_OK + + state = hass.states.get("{}.{}".format(DEVICE_TRACKER_DOMAIN, data_device_2["id"])) + assert state.state == "home" + state = hass.states.get("{}.{}".format(DEVICE_TRACKER_DOMAIN, data_device_1["id"])) + assert state.state == "not_home" + + +@pytest.mark.xfail( + reason="The device_tracker component does not support unloading yet." +) +async def test_load_unload_entry(hass, client, webhook_id): + """Test that the appropriate dispatch signals are added and removed.""" + url = "/api/webhook/{}".format(webhook_id) + data = {"lat": str(HOME_LATITUDE), "lon": str(HOME_LONGITUDE), "id": "123"} + + # Enter the Home + req = await client.post(url, params=data) + await hass.async_block_till_done() + assert req.status == HTTP_OK + state_name = hass.states.get( + "{}.{}".format(DEVICE_TRACKER_DOMAIN, data["id"]) + ).state + assert STATE_HOME == state_name + assert len(hass.data[DATA_DISPATCHER][TRACKER_UPDATE]) == 1 + + entry = hass.config_entries.async_entries(DOMAIN)[0] + + assert await traccar.async_unload_entry(hass, entry) + await hass.async_block_till_done() + assert not hass.data[DATA_DISPATCHER][TRACKER_UPDATE]