mirror of
https://github.com/home-assistant/core.git
synced 2025-06-25 01:21:51 +02:00
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:
@ -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
|
||||
|
@ -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
|
||||
|
153
homeassistant/components/tractive/__init__.py
Normal file
153
homeassistant/components/tractive/__init__.py
Normal 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,
|
||||
)
|
74
homeassistant/components/tractive/config_flow.py
Normal file
74
homeassistant/components/tractive/config_flow.py
Normal 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."""
|
12
homeassistant/components/tractive/const.py
Normal file
12
homeassistant/components/tractive/const.py
Normal 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"
|
145
homeassistant/components/tractive/device_tracker.py
Normal file
145
homeassistant/components/tractive/device_tracker.py
Normal 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,
|
||||
)
|
||||
)
|
14
homeassistant/components/tractive/manifest.json
Normal file
14
homeassistant/components/tractive/manifest.json
Normal 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"
|
||||
}
|
19
homeassistant/components/tractive/strings.json
Normal file
19
homeassistant/components/tractive/strings.json
Normal 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%]"
|
||||
}
|
||||
}
|
||||
}
|
19
homeassistant/components/tractive/translations/en.json
Normal file
19
homeassistant/components/tractive/translations/en.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -272,6 +272,7 @@ FLOWS = [
|
||||
"totalconnect",
|
||||
"tplink",
|
||||
"traccar",
|
||||
"tractive",
|
||||
"tradfri",
|
||||
"transmission",
|
||||
"tuya",
|
||||
|
@ -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
|
||||
|
||||
|
@ -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
|
||||
|
||||
|
1
tests/components/tractive/__init__.py
Normal file
1
tests/components/tractive/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""Tests for the tractive integration."""
|
78
tests/components/tractive/test_config_flow.py
Normal file
78
tests/components/tractive/test_config_flow.py
Normal 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"}
|
Reference in New Issue
Block a user