diff --git a/.coveragerc b/.coveragerc index 39055879a8d..263cfd76172 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1079,6 +1079,8 @@ omit = homeassistant/components/traccar/device_tracker.py homeassistant/components/traccar/const.py homeassistant/components/trackr/device_tracker.py + homeassistant/components/tractive/__init__.py + homeassistant/components/tractive/device_tracker.py homeassistant/components/tradfri/* homeassistant/components/trafikverket_train/sensor.py homeassistant/components/trafikverket_weatherstation/sensor.py diff --git a/CODEOWNERS b/CODEOWNERS index 371a03a0d91..86622690fb9 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -526,6 +526,7 @@ homeassistant/components/totalconnect/* @austinmroczek homeassistant/components/tplink/* @rytilahti @thegardenmonkey homeassistant/components/traccar/* @ludeeus homeassistant/components/trace/* @home-assistant/core +homeassistant/components/tractive/* @Danielhiversen @zhulik homeassistant/components/trafikverket_train/* @endor-force homeassistant/components/trafikverket_weatherstation/* @endor-force homeassistant/components/transmission/* @engrbm87 @JPHutchins diff --git a/homeassistant/components/tractive/__init__.py b/homeassistant/components/tractive/__init__.py new file mode 100644 index 00000000000..cb8eff1c8bb --- /dev/null +++ b/homeassistant/components/tractive/__init__.py @@ -0,0 +1,153 @@ +"""The tractive integration.""" +from __future__ import annotations + +import asyncio +import logging + +import aiotractive + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, EVENT_HOMEASSISTANT_STOP +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.dispatcher import async_dispatcher_send + +from .const import ( + DOMAIN, + RECONNECT_INTERVAL, + SERVER_UNAVAILABLE, + TRACKER_HARDWARE_STATUS_UPDATED, + TRACKER_POSITION_UPDATED, +) + +PLATFORMS = ["device_tracker"] + + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up tractive from a config entry.""" + data = entry.data + + hass.data.setdefault(DOMAIN, {}) + + client = aiotractive.Tractive( + data[CONF_EMAIL], data[CONF_PASSWORD], session=async_get_clientsession(hass) + ) + try: + creds = await client.authenticate() + except aiotractive.exceptions.TractiveError as error: + await client.close() + raise ConfigEntryNotReady from error + + tractive = TractiveClient(hass, client, creds["user_id"]) + tractive.subscribe() + + hass.data[DOMAIN][entry.entry_id] = tractive + + hass.config_entries.async_setup_platforms(entry, PLATFORMS) + + async def cancel_listen_task(_): + await tractive.unsubscribe() + + entry.async_on_unload( + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, cancel_listen_task) + ) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if unload_ok: + tractive = hass.data[DOMAIN].pop(entry.entry_id) + await tractive.unsubscribe() + return unload_ok + + +class TractiveClient: + """A Tractive client.""" + + def __init__(self, hass, client, user_id): + """Initialize the client.""" + self._hass = hass + self._client = client + self._user_id = user_id + self._listen_task = None + + @property + def user_id(self): + """Return user id.""" + return self._user_id + + async def trackable_objects(self): + """Get list of trackable objects.""" + return await self._client.trackable_objects() + + def tracker(self, tracker_id): + """Get tracker by id.""" + return self._client.tracker(tracker_id) + + def subscribe(self): + """Start event listener coroutine.""" + self._listen_task = asyncio.create_task(self._listen()) + + async def unsubscribe(self): + """Stop event listener coroutine.""" + if self._listen_task: + self._listen_task.cancel() + await self._client.close() + + async def _listen(self): + server_was_unavailable = False + while True: + try: + async for event in self._client.events(): + if server_was_unavailable: + _LOGGER.debug("Tractive is back online") + server_was_unavailable = False + if event["message"] != "tracker_status": + continue + + if "hardware" in event: + self._send_hardware_update(event) + + if "position" in event: + self._send_position_update(event) + except aiotractive.exceptions.TractiveError: + _LOGGER.debug( + "Tractive is not available. Internet connection is down? Sleeping %i seconds and retrying", + RECONNECT_INTERVAL.total_seconds(), + ) + async_dispatcher_send( + self._hass, f"{SERVER_UNAVAILABLE}-{self._user_id}" + ) + await asyncio.sleep(RECONNECT_INTERVAL.total_seconds()) + server_was_unavailable = True + continue + + def _send_hardware_update(self, event): + payload = {"battery_level": event["hardware"]["battery_level"]} + self._dispatch_tracker_event( + TRACKER_HARDWARE_STATUS_UPDATED, event["tracker_id"], payload + ) + + def _send_position_update(self, event): + payload = { + "latitude": event["position"]["latlong"][0], + "longitude": event["position"]["latlong"][1], + "accuracy": event["position"]["accuracy"], + } + self._dispatch_tracker_event( + TRACKER_POSITION_UPDATED, event["tracker_id"], payload + ) + + def _dispatch_tracker_event(self, event_name, tracker_id, payload): + async_dispatcher_send( + self._hass, + f"{event_name}-{tracker_id}", + payload, + ) diff --git a/homeassistant/components/tractive/config_flow.py b/homeassistant/components/tractive/config_flow.py new file mode 100644 index 00000000000..70ed9071c7b --- /dev/null +++ b/homeassistant/components/tractive/config_flow.py @@ -0,0 +1,74 @@ +"""Config flow for tractive integration.""" +from __future__ import annotations + +import logging +from typing import Any + +import aiotractive +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResult +from homeassistant.exceptions import HomeAssistantError + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema({CONF_EMAIL: str, CONF_PASSWORD: str}) + + +async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, Any]: + """Validate the user input allows us to connect. + + Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user. + """ + + client = aiotractive.api.API(data[CONF_EMAIL], data[CONF_PASSWORD]) + try: + user_id = await client.user_id() + except aiotractive.exceptions.UnauthorizedError as error: + raise InvalidAuth from error + finally: + await client.close() + + return {"title": data[CONF_EMAIL], "user_id": user_id} + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for tractive.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + if user_input is None: + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA + ) + + errors = {} + + try: + info = await validate_input(self.hass, user_input) + except InvalidAuth: + errors["base"] = "invalid_auth" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + await self.async_set_unique_id(info["user_id"]) + self._abort_if_unique_id_configured() + return self.async_create_entry(title=info["title"], data=user_input) + + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) + + +class InvalidAuth(HomeAssistantError): + """Error to indicate there is invalid auth.""" diff --git a/homeassistant/components/tractive/const.py b/homeassistant/components/tractive/const.py new file mode 100644 index 00000000000..5d265c489ff --- /dev/null +++ b/homeassistant/components/tractive/const.py @@ -0,0 +1,12 @@ +"""Constants for the tractive integration.""" + +from datetime import timedelta + +DOMAIN = "tractive" + +RECONNECT_INTERVAL = timedelta(seconds=10) + +TRACKER_HARDWARE_STATUS_UPDATED = "tracker_hardware_status_updated" +TRACKER_POSITION_UPDATED = "tracker_position_updated" + +SERVER_UNAVAILABLE = "tractive_server_unavailable" diff --git a/homeassistant/components/tractive/device_tracker.py b/homeassistant/components/tractive/device_tracker.py new file mode 100644 index 00000000000..1365faa6419 --- /dev/null +++ b/homeassistant/components/tractive/device_tracker.py @@ -0,0 +1,145 @@ +"""Support for Tractive device trackers.""" + +import asyncio +import logging + +from homeassistant.components.device_tracker import SOURCE_TYPE_GPS +from homeassistant.components.device_tracker.config_entry import TrackerEntity +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect + +from .const import ( + DOMAIN, + SERVER_UNAVAILABLE, + TRACKER_HARDWARE_STATUS_UPDATED, + TRACKER_POSITION_UPDATED, +) + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass, entry, async_add_entities): + """Set up Tractive device trackers.""" + client = hass.data[DOMAIN][entry.entry_id] + + trackables = await client.trackable_objects() + + entities = await asyncio.gather( + *(create_trackable_entity(client, trackable) for trackable in trackables) + ) + + async_add_entities(entities) + + +async def create_trackable_entity(client, trackable): + """Create an entity instance.""" + trackable = await trackable.details() + tracker = client.tracker(trackable["device_id"]) + + tracker_details, hw_info, pos_report = await asyncio.gather( + tracker.details(), tracker.hw_info(), tracker.pos_report() + ) + + return TractiveDeviceTracker( + client.user_id, trackable, tracker_details, hw_info, pos_report + ) + + +class TractiveDeviceTracker(TrackerEntity): + """Tractive device tracker.""" + + def __init__(self, user_id, trackable, tracker_details, hw_info, pos_report): + """Initialize tracker entity.""" + self._user_id = user_id + + self._battery_level = hw_info["battery_level"] + self._latitude = pos_report["latlong"][0] + self._longitude = pos_report["latlong"][1] + self._accuracy = pos_report["pos_uncertainty"] + self._tracker_id = tracker_details["_id"] + + self._attr_name = f"{self._tracker_id} {trackable['details']['name']}" + self._attr_unique_id = trackable["_id"] + self._attr_icon = "mdi:paw" + self._attr_device_info = { + "identifiers": {(DOMAIN, self._tracker_id)}, + "name": f"Tractive ({self._tracker_id})", + "manufacturer": "Tractive GmbH", + "sw_version": tracker_details["fw_version"], + "model": tracker_details["model_number"], + } + + @property + def source_type(self): + """Return the source type, eg gps or router, of the device.""" + return SOURCE_TYPE_GPS + + @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 battery_level(self): + """Return the battery level of the device.""" + return self._battery_level + + async def async_added_to_hass(self): + """Handle entity which will be added.""" + + @callback + def handle_hardware_status_update(event): + self._battery_level = event["battery_level"] + self._attr_available = True + self.async_write_ha_state() + + self.async_on_remove( + async_dispatcher_connect( + self.hass, + f"{TRACKER_HARDWARE_STATUS_UPDATED}-{self._tracker_id}", + handle_hardware_status_update, + ) + ) + + @callback + def handle_position_update(event): + self._latitude = event["latitude"] + self._longitude = event["longitude"] + self._accuracy = event["accuracy"] + self._attr_available = True + self.async_write_ha_state() + + self.async_on_remove( + async_dispatcher_connect( + self.hass, + f"{TRACKER_POSITION_UPDATED}-{self._tracker_id}", + handle_position_update, + ) + ) + + @callback + def handle_server_unavailable(): + self._latitude = None + self._longitude = None + self._accuracy = None + self._battery_level = None + self._attr_available = False + self.async_write_ha_state() + + self.async_on_remove( + async_dispatcher_connect( + self.hass, + f"{SERVER_UNAVAILABLE}-{self._user_id}", + handle_server_unavailable, + ) + ) diff --git a/homeassistant/components/tractive/manifest.json b/homeassistant/components/tractive/manifest.json new file mode 100644 index 00000000000..2328c07f905 --- /dev/null +++ b/homeassistant/components/tractive/manifest.json @@ -0,0 +1,14 @@ +{ + "domain": "tractive", + "name": "Tractive", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/tractive", + "requirements": [ + "aiotractive==0.5.1" + ], + "codeowners": [ + "@Danielhiversen", + "@zhulik" + ], + "iot_class": "cloud_push" +} diff --git a/homeassistant/components/tractive/strings.json b/homeassistant/components/tractive/strings.json new file mode 100644 index 00000000000..510b5697e56 --- /dev/null +++ b/homeassistant/components/tractive/strings.json @@ -0,0 +1,19 @@ +{ + "config": { + "step": { + "user": { + "data": { + "email": "[%key:common::config_flow::data::email%]", + "password": "[%key:common::config_flow::data::password%]" + } + } + }, + "error": { + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tractive/translations/en.json b/homeassistant/components/tractive/translations/en.json new file mode 100644 index 00000000000..4abfd682903 --- /dev/null +++ b/homeassistant/components/tractive/translations/en.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "Device is already configured" + }, + "error": { + "invalid_auth": "Invalid authentication", + "unknown": "Unexpected error" + }, + "step": { + "user": { + "data": { + "email": "E-Mail", + "password": "Password" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 3d6730fe65a..d125f507d3a 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -272,6 +272,7 @@ FLOWS = [ "totalconnect", "tplink", "traccar", + "tractive", "tradfri", "transmission", "tuya", diff --git a/requirements_all.txt b/requirements_all.txt index 1bf4ed9b063..ec370ad1dcd 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -245,6 +245,9 @@ aioswitcher==2.0.4 # homeassistant.components.syncthing aiosyncthing==0.5.1 +# homeassistant.components.tractive +aiotractive==0.5.1 + # homeassistant.components.unifi aiounifi==26 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7d72f300d4e..0eca53e6cdc 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -166,6 +166,9 @@ aioswitcher==2.0.4 # homeassistant.components.syncthing aiosyncthing==0.5.1 +# homeassistant.components.tractive +aiotractive==0.5.1 + # homeassistant.components.unifi aiounifi==26 diff --git a/tests/components/tractive/__init__.py b/tests/components/tractive/__init__.py new file mode 100644 index 00000000000..dcde4b87436 --- /dev/null +++ b/tests/components/tractive/__init__.py @@ -0,0 +1 @@ +"""Tests for the tractive integration.""" diff --git a/tests/components/tractive/test_config_flow.py b/tests/components/tractive/test_config_flow.py new file mode 100644 index 00000000000..080aadb2bc7 --- /dev/null +++ b/tests/components/tractive/test_config_flow.py @@ -0,0 +1,78 @@ +"""Test the tractive config flow.""" +from unittest.mock import patch + +import aiotractive + +from homeassistant import config_entries, setup +from homeassistant.components.tractive.const import DOMAIN +from homeassistant.core import HomeAssistant + +USER_INPUT = { + "email": "test-email@example.com", + "password": "test-password", +} + + +async def test_form(hass: HomeAssistant) -> None: + """Test we get the form.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == "form" + assert result["errors"] is None + + with patch( + "aiotractive.api.API.user_id", return_value={"user_id": "user_id"} + ), patch( + "homeassistant.components.tractive.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + USER_INPUT, + ) + await hass.async_block_till_done() + + assert result2["type"] == "create_entry" + assert result2["title"] == "test-email@example.com" + assert result2["data"] == USER_INPUT + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_invalid_auth(hass: HomeAssistant) -> None: + """Test we handle invalid auth.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "aiotractive.api.API.user_id", + side_effect=aiotractive.exceptions.UnauthorizedError, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + USER_INPUT, + ) + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "invalid_auth"} + + +async def test_form_unknown_error(hass: HomeAssistant) -> None: + """Test we handle invalid auth.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "aiotractive.api.API.user_id", + side_effect=Exception, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + USER_INPUT, + ) + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "unknown"}