Skip to content

Commit

Permalink
Add tractive integration (home-assistant#51002)
Browse files Browse the repository at this point in the history
* 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 <[email protected]>

* Add @Danielhiversen as a codeowner

* Remove the title from strings and translations

* Update homeassistant/components/tractive/__init__.py

Co-authored-by: Franck Nijhof <[email protected]>

* 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 <[email protected]>
Co-authored-by: Franck Nijhof <[email protected]>
  • Loading branch information
3 people authored Aug 5, 2021
1 parent 91ab86c commit 25eb27c
Show file tree
Hide file tree
Showing 14 changed files with 525 additions and 0 deletions.
2 changes: 2 additions & 0 deletions .coveragerc
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions CODEOWNERS
Validating CODEOWNERS rules …
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
153 changes: 153 additions & 0 deletions homeassistant/components/tractive/__init__.py
Original file line number Diff line number Diff line change
@@ -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,
)
74 changes: 74 additions & 0 deletions homeassistant/components/tractive/config_flow.py
Original file line number Diff line number Diff line change
@@ -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."""
12 changes: 12 additions & 0 deletions homeassistant/components/tractive/const.py
Original file line number Diff line number Diff line change
@@ -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"
Loading

0 comments on commit 25eb27c

Please sign in to comment.