Add tractive integration (#51002)

* Scaffold

* Implement config flow

* Add dymmy device tracker and TractiveClient

* Add simple DeviceTracker

* Add device info

* Listen to tractive event and update tracker entities accordingly

* Refactoring

* Fix logging level

* Handle connection errors

* Remove sleep

* Fix logging

* Remove unused strings

* Replace username config with email

* Update aiotractive

* Use debug instead of info

* Cover config_flow

* Update .coveragerc

* Add quality scale to manifest

* pylint

* Update aiotractive

* Do not emit SERVER_AVAILABLE, properly handle availability

* Use async_get_clientsession

Co-authored-by:  Daniel Hjelseth Høyer <mail@dahoiv.net>

* Add @Danielhiversen as a codeowner

* Remove the title from strings and translations

* Update homeassistant/components/tractive/__init__.py

Co-authored-by: Franck Nijhof <frenck@frenck.nl>

* Force CI

* Use _attr style properties instead of methods

* Remove entry_type

* Remove quality scale

* Make pyupgrade happy

Co-authored-by: Daniel Hjelseth Høyer <mail@dahoiv.net>
Co-authored-by: Franck Nijhof <frenck@frenck.nl>
This commit is contained in:
Gleb Sinyavskiy
2021-08-05 12:47:42 +02:00
committed by GitHub
parent 91ab86c17c
commit 25eb27cb9f
14 changed files with 525 additions and 0 deletions

View File

@ -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

View File

@ -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

View File

@ -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,
)

View File

@ -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."""

View File

@ -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"

View File

@ -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,
)
)

View File

@ -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"
}

View File

@ -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%]"
}
}
}

View File

@ -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"
}
}
}
}
}

View File

@ -272,6 +272,7 @@ FLOWS = [
"totalconnect",
"tplink",
"traccar",
"tractive",
"tradfri",
"transmission",
"tuya",

View File

@ -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

View File

@ -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

View File

@ -0,0 +1 @@
"""Tests for the tractive integration."""

View File

@ -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"}