2019-02-14 04:35:12 +00:00
|
|
|
"""Support for OwnTracks."""
|
2018-11-28 21:20:13 +00:00
|
|
|
from collections import defaultdict
|
|
|
|
import json
|
|
|
|
import logging
|
|
|
|
import re
|
|
|
|
|
|
|
|
from aiohttp.web import json_response
|
|
|
|
import voluptuous as vol
|
|
|
|
|
|
|
|
from homeassistant import config_entries
|
2019-02-14 04:35:12 +00:00
|
|
|
from homeassistant.components import mqtt
|
2018-11-28 21:20:13 +00:00
|
|
|
from homeassistant.const import CONF_WEBHOOK_ID
|
|
|
|
from homeassistant.core import callback
|
|
|
|
import homeassistant.helpers.config_validation as cv
|
2019-02-14 04:35:12 +00:00
|
|
|
from homeassistant.setup import async_when_setup
|
2018-11-28 21:20:13 +00:00
|
|
|
|
2019-11-11 20:30:00 +00:00
|
|
|
from .const import DOMAIN
|
2018-11-28 21:20:13 +00:00
|
|
|
from .config_flow import CONF_SECRET
|
2019-05-25 20:34:53 +00:00
|
|
|
from .messages import async_handle_message
|
2018-11-28 21:20:13 +00:00
|
|
|
|
2019-02-14 04:35:12 +00:00
|
|
|
_LOGGER = logging.getLogger(__name__)
|
|
|
|
|
2019-07-31 19:25:30 +00:00
|
|
|
CONF_MAX_GPS_ACCURACY = "max_gps_accuracy"
|
|
|
|
CONF_WAYPOINT_IMPORT = "waypoints"
|
|
|
|
CONF_WAYPOINT_WHITELIST = "waypoint_whitelist"
|
|
|
|
CONF_MQTT_TOPIC = "mqtt_topic"
|
|
|
|
CONF_REGION_MAPPING = "region_mapping"
|
|
|
|
CONF_EVENTS_ONLY = "events_only"
|
|
|
|
BEACON_DEV_ID = "beacon"
|
|
|
|
|
|
|
|
DEFAULT_OWNTRACKS_TOPIC = "owntracks/#"
|
|
|
|
|
|
|
|
CONFIG_SCHEMA = vol.Schema(
|
|
|
|
{
|
|
|
|
vol.Optional(DOMAIN, default={}): {
|
|
|
|
vol.Optional(CONF_MAX_GPS_ACCURACY): vol.Coerce(float),
|
|
|
|
vol.Optional(CONF_WAYPOINT_IMPORT, default=True): cv.boolean,
|
|
|
|
vol.Optional(CONF_EVENTS_ONLY, default=False): cv.boolean,
|
|
|
|
vol.Optional(
|
|
|
|
CONF_MQTT_TOPIC, default=DEFAULT_OWNTRACKS_TOPIC
|
|
|
|
): mqtt.valid_subscribe_topic,
|
|
|
|
vol.Optional(CONF_WAYPOINT_WHITELIST): vol.All(cv.ensure_list, [cv.string]),
|
|
|
|
vol.Optional(CONF_SECRET): vol.Any(
|
|
|
|
vol.Schema({vol.Optional(cv.string): cv.string}), cv.string
|
|
|
|
),
|
|
|
|
vol.Optional(CONF_REGION_MAPPING, default={}): dict,
|
|
|
|
vol.Optional(CONF_WEBHOOK_ID): cv.string,
|
|
|
|
}
|
|
|
|
},
|
|
|
|
extra=vol.ALLOW_EXTRA,
|
|
|
|
)
|
2018-11-28 21:20:13 +00:00
|
|
|
|
|
|
|
|
|
|
|
async def async_setup(hass, config):
|
|
|
|
"""Initialize OwnTracks component."""
|
2019-07-31 19:25:30 +00:00
|
|
|
hass.data[DOMAIN] = {"config": config[DOMAIN], "devices": {}, "unsub": None}
|
2018-11-28 21:20:13 +00:00
|
|
|
if not hass.config_entries.async_entries(DOMAIN):
|
2019-07-31 19:25:30 +00:00
|
|
|
hass.async_create_task(
|
|
|
|
hass.config_entries.flow.async_init(
|
|
|
|
DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data={}
|
|
|
|
)
|
|
|
|
)
|
2018-11-28 21:20:13 +00:00
|
|
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
|
|
async def async_setup_entry(hass, entry):
|
|
|
|
"""Set up OwnTracks entry."""
|
2019-07-31 19:25:30 +00:00
|
|
|
config = hass.data[DOMAIN]["config"]
|
2018-11-28 21:20:13 +00:00
|
|
|
max_gps_accuracy = config.get(CONF_MAX_GPS_ACCURACY)
|
|
|
|
waypoint_import = config.get(CONF_WAYPOINT_IMPORT)
|
|
|
|
waypoint_whitelist = config.get(CONF_WAYPOINT_WHITELIST)
|
|
|
|
secret = config.get(CONF_SECRET) or entry.data[CONF_SECRET]
|
|
|
|
region_mapping = config.get(CONF_REGION_MAPPING)
|
|
|
|
events_only = config.get(CONF_EVENTS_ONLY)
|
|
|
|
mqtt_topic = config.get(CONF_MQTT_TOPIC)
|
|
|
|
|
2019-07-31 19:25:30 +00:00
|
|
|
context = OwnTracksContext(
|
|
|
|
hass,
|
|
|
|
secret,
|
|
|
|
max_gps_accuracy,
|
|
|
|
waypoint_import,
|
|
|
|
waypoint_whitelist,
|
|
|
|
region_mapping,
|
|
|
|
events_only,
|
|
|
|
mqtt_topic,
|
|
|
|
)
|
2018-11-28 21:20:13 +00:00
|
|
|
|
|
|
|
webhook_id = config.get(CONF_WEBHOOK_ID) or entry.data[CONF_WEBHOOK_ID]
|
|
|
|
|
2019-07-31 19:25:30 +00:00
|
|
|
hass.data[DOMAIN]["context"] = context
|
2018-11-28 21:20:13 +00:00
|
|
|
|
2019-07-31 19:25:30 +00:00
|
|
|
async_when_setup(hass, "mqtt", async_connect_mqtt)
|
2018-11-28 21:20:13 +00:00
|
|
|
|
|
|
|
hass.components.webhook.async_register(
|
2019-07-31 19:25:30 +00:00
|
|
|
DOMAIN, "OwnTracks", webhook_id, handle_webhook
|
|
|
|
)
|
2018-11-28 21:20:13 +00:00
|
|
|
|
2019-07-31 19:25:30 +00:00
|
|
|
hass.async_create_task(
|
|
|
|
hass.config_entries.async_forward_entry_setup(entry, "device_tracker")
|
|
|
|
)
|
2018-11-28 21:20:13 +00:00
|
|
|
|
2019-07-31 19:25:30 +00:00
|
|
|
hass.data[DOMAIN]["unsub"] = hass.helpers.dispatcher.async_dispatcher_connect(
|
|
|
|
DOMAIN, async_handle_message
|
|
|
|
)
|
2019-05-25 20:34:53 +00:00
|
|
|
|
2018-11-28 21:20:13 +00:00
|
|
|
return True
|
|
|
|
|
|
|
|
|
2019-05-14 09:59:11 +00:00
|
|
|
async def async_unload_entry(hass, entry):
|
|
|
|
"""Unload an OwnTracks config entry."""
|
|
|
|
hass.components.webhook.async_unregister(entry.data[CONF_WEBHOOK_ID])
|
2019-07-31 19:25:30 +00:00
|
|
|
await hass.config_entries.async_forward_entry_unload(entry, "device_tracker")
|
|
|
|
hass.data[DOMAIN]["unsub"]()
|
2019-05-25 20:34:53 +00:00
|
|
|
|
2019-05-14 09:59:11 +00:00
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
|
|
async def async_remove_entry(hass, entry):
|
|
|
|
"""Remove an OwnTracks config entry."""
|
2019-12-05 08:28:56 +00:00
|
|
|
if not entry.data.get("cloudhook"):
|
2019-05-14 09:59:11 +00:00
|
|
|
return
|
|
|
|
|
2019-07-31 19:25:30 +00:00
|
|
|
await hass.components.cloud.async_delete_cloudhook(entry.data[CONF_WEBHOOK_ID])
|
2019-05-14 09:59:11 +00:00
|
|
|
|
|
|
|
|
2018-11-28 21:20:13 +00:00
|
|
|
async def async_connect_mqtt(hass, component):
|
|
|
|
"""Subscribe to MQTT topic."""
|
2019-07-31 19:25:30 +00:00
|
|
|
context = hass.data[DOMAIN]["context"]
|
2018-11-28 21:20:13 +00:00
|
|
|
|
2019-03-14 17:58:32 +00:00
|
|
|
async def async_handle_mqtt_message(msg):
|
2018-11-28 21:20:13 +00:00
|
|
|
"""Handle incoming OwnTracks message."""
|
|
|
|
try:
|
2019-03-14 17:58:32 +00:00
|
|
|
message = json.loads(msg.payload)
|
2018-11-28 21:20:13 +00:00
|
|
|
except ValueError:
|
|
|
|
# If invalid JSON
|
2019-03-14 17:58:32 +00:00
|
|
|
_LOGGER.error("Unable to parse payload as JSON: %s", msg.payload)
|
2018-11-28 21:20:13 +00:00
|
|
|
return
|
|
|
|
|
2019-07-31 19:25:30 +00:00
|
|
|
message["topic"] = msg.topic
|
|
|
|
hass.helpers.dispatcher.async_dispatcher_send(DOMAIN, hass, context, message)
|
2018-11-28 21:20:13 +00:00
|
|
|
|
|
|
|
await hass.components.mqtt.async_subscribe(
|
2019-07-31 19:25:30 +00:00
|
|
|
context.mqtt_topic, async_handle_mqtt_message, 1
|
|
|
|
)
|
2018-11-28 21:20:13 +00:00
|
|
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
|
|
async def handle_webhook(hass, webhook_id, request):
|
2018-12-10 11:24:56 +00:00
|
|
|
"""Handle webhook callback.
|
|
|
|
|
|
|
|
iOS sets the "topic" as part of the payload.
|
|
|
|
Android does not set a topic but adds headers to the request.
|
|
|
|
"""
|
2019-07-31 19:25:30 +00:00
|
|
|
context = hass.data[DOMAIN]["context"]
|
2018-12-10 11:24:56 +00:00
|
|
|
|
|
|
|
try:
|
|
|
|
message = await request.json()
|
|
|
|
except ValueError:
|
2019-07-31 19:25:30 +00:00
|
|
|
_LOGGER.warning("Received invalid JSON from OwnTracks")
|
2018-12-10 11:24:56 +00:00
|
|
|
return json_response([])
|
2018-11-28 21:20:13 +00:00
|
|
|
|
|
|
|
# Android doesn't populate topic
|
2019-07-31 19:25:30 +00:00
|
|
|
if "topic" not in message:
|
2018-11-28 21:20:13 +00:00
|
|
|
headers = request.headers
|
2019-07-31 19:25:30 +00:00
|
|
|
user = headers.get("X-Limit-U")
|
|
|
|
device = headers.get("X-Limit-D", user)
|
2018-11-28 21:20:13 +00:00
|
|
|
|
2018-12-12 16:17:27 +00:00
|
|
|
if user:
|
2019-07-31 19:25:30 +00:00
|
|
|
topic_base = re.sub("/#$", "", context.mqtt_topic)
|
2019-09-03 18:35:00 +00:00
|
|
|
message["topic"] = f"{topic_base}/{user}/{device}"
|
2018-12-12 16:17:27 +00:00
|
|
|
|
2019-07-31 19:25:30 +00:00
|
|
|
elif message["_type"] != "encrypted":
|
|
|
|
_LOGGER.warning(
|
|
|
|
"No topic or user found in message. If on Android,"
|
|
|
|
" set a username in Connection -> Identification"
|
|
|
|
)
|
2018-12-10 11:24:56 +00:00
|
|
|
# Keep it as a 200 response so the incorrect packet is discarded
|
|
|
|
return json_response([])
|
2018-11-28 21:20:13 +00:00
|
|
|
|
2019-07-31 19:25:30 +00:00
|
|
|
hass.helpers.dispatcher.async_dispatcher_send(DOMAIN, hass, context, message)
|
2018-11-28 21:20:13 +00:00
|
|
|
return json_response([])
|
|
|
|
|
|
|
|
|
|
|
|
class OwnTracksContext:
|
|
|
|
"""Hold the current OwnTracks context."""
|
|
|
|
|
2019-07-31 19:25:30 +00:00
|
|
|
def __init__(
|
|
|
|
self,
|
|
|
|
hass,
|
|
|
|
secret,
|
|
|
|
max_gps_accuracy,
|
|
|
|
import_waypoints,
|
|
|
|
waypoint_whitelist,
|
|
|
|
region_mapping,
|
|
|
|
events_only,
|
|
|
|
mqtt_topic,
|
|
|
|
):
|
2018-11-28 21:20:13 +00:00
|
|
|
"""Initialize an OwnTracks context."""
|
|
|
|
self.hass = hass
|
|
|
|
self.secret = secret
|
|
|
|
self.max_gps_accuracy = max_gps_accuracy
|
|
|
|
self.mobile_beacons_active = defaultdict(set)
|
|
|
|
self.regions_entered = defaultdict(list)
|
|
|
|
self.import_waypoints = import_waypoints
|
|
|
|
self.waypoint_whitelist = waypoint_whitelist
|
|
|
|
self.region_mapping = region_mapping
|
|
|
|
self.events_only = events_only
|
|
|
|
self.mqtt_topic = mqtt_topic
|
2019-06-04 21:06:49 +00:00
|
|
|
self._pending_msg = []
|
2018-11-28 21:20:13 +00:00
|
|
|
|
|
|
|
@callback
|
|
|
|
def async_valid_accuracy(self, message):
|
|
|
|
"""Check if we should ignore this message."""
|
2019-07-31 19:25:30 +00:00
|
|
|
acc = message.get("acc")
|
2018-11-28 21:20:13 +00:00
|
|
|
|
|
|
|
if acc is None:
|
|
|
|
return False
|
|
|
|
|
|
|
|
try:
|
|
|
|
acc = float(acc)
|
|
|
|
except ValueError:
|
|
|
|
return False
|
|
|
|
|
|
|
|
if acc == 0:
|
|
|
|
_LOGGER.warning(
|
|
|
|
"Ignoring %s update because GPS accuracy is zero: %s",
|
2019-07-31 19:25:30 +00:00
|
|
|
message["_type"],
|
|
|
|
message,
|
|
|
|
)
|
2018-11-28 21:20:13 +00:00
|
|
|
return False
|
|
|
|
|
2019-07-31 19:25:30 +00:00
|
|
|
if self.max_gps_accuracy is not None and acc > self.max_gps_accuracy:
|
|
|
|
_LOGGER.info(
|
|
|
|
"Ignoring %s update because expected GPS " "accuracy %s is not met: %s",
|
|
|
|
message["_type"],
|
|
|
|
self.max_gps_accuracy,
|
|
|
|
message,
|
|
|
|
)
|
2018-11-28 21:20:13 +00:00
|
|
|
return False
|
|
|
|
|
|
|
|
return True
|
|
|
|
|
2019-06-04 21:06:49 +00:00
|
|
|
@callback
|
|
|
|
def set_async_see(self, func):
|
|
|
|
"""Set a new async_see function."""
|
|
|
|
self.async_see = func
|
|
|
|
for msg in self._pending_msg:
|
|
|
|
func(**msg)
|
|
|
|
self._pending_msg.clear()
|
|
|
|
|
|
|
|
# pylint: disable=method-hidden
|
2019-05-25 20:34:53 +00:00
|
|
|
@callback
|
|
|
|
def async_see(self, **data):
|
2018-11-28 21:20:13 +00:00
|
|
|
"""Send a see message to the device tracker."""
|
2019-06-04 21:06:49 +00:00
|
|
|
self._pending_msg.append(data)
|
2018-11-28 21:20:13 +00:00
|
|
|
|
2019-05-25 20:34:53 +00:00
|
|
|
@callback
|
|
|
|
def async_see_beacons(self, hass, dev_id, kwargs_param):
|
2018-11-28 21:20:13 +00:00
|
|
|
"""Set active beacons to the current location."""
|
|
|
|
kwargs = kwargs_param.copy()
|
|
|
|
|
|
|
|
# Mobile beacons should always be set to the location of the
|
|
|
|
# tracking device. I get the device state and make the necessary
|
|
|
|
# changes to kwargs.
|
2019-09-03 18:35:00 +00:00
|
|
|
device_tracker_state = hass.states.get(f"device_tracker.{dev_id}")
|
2018-11-28 21:20:13 +00:00
|
|
|
|
|
|
|
if device_tracker_state is not None:
|
|
|
|
acc = device_tracker_state.attributes.get("gps_accuracy")
|
|
|
|
lat = device_tracker_state.attributes.get("latitude")
|
|
|
|
lon = device_tracker_state.attributes.get("longitude")
|
2019-05-25 20:34:53 +00:00
|
|
|
|
|
|
|
if lat is not None and lon is not None:
|
2019-07-31 19:25:30 +00:00
|
|
|
kwargs["gps"] = (lat, lon)
|
|
|
|
kwargs["gps_accuracy"] = acc
|
2019-05-25 20:34:53 +00:00
|
|
|
else:
|
2019-07-31 19:25:30 +00:00
|
|
|
kwargs["gps"] = None
|
|
|
|
kwargs["gps_accuracy"] = None
|
2018-11-28 21:20:13 +00:00
|
|
|
|
|
|
|
# the battery state applies to the tracking device, not the beacon
|
|
|
|
# kwargs location is the beacon's configured lat/lon
|
2019-07-31 19:25:30 +00:00
|
|
|
kwargs.pop("battery", None)
|
2018-11-28 21:20:13 +00:00
|
|
|
for beacon in self.mobile_beacons_active[dev_id]:
|
2019-09-03 18:35:00 +00:00
|
|
|
kwargs["dev_id"] = f"{BEACON_DEV_ID}_{beacon}"
|
2019-07-31 19:25:30 +00:00
|
|
|
kwargs["host_name"] = beacon
|
2019-05-25 20:34:53 +00:00
|
|
|
self.async_see(**kwargs)
|