Home Assistant Unofficial Reference 2024.12.1
__init__.py
Go to the documentation of this file.
1 """Support for OwnTracks."""
2 
3 from collections import defaultdict
4 import json
5 import logging
6 import re
7 
8 from aiohttp import web
9 import voluptuous as vol
10 
11 from homeassistant.components import cloud, mqtt, webhook
12 from homeassistant.config_entries import ConfigEntry
13 from homeassistant.const import (
14  ATTR_GPS_ACCURACY,
15  ATTR_LATITUDE,
16  ATTR_LONGITUDE,
17  CONF_WEBHOOK_ID,
18  Platform,
19 )
20 from homeassistant.core import HomeAssistant, callback
23  async_dispatcher_connect,
24  async_dispatcher_send,
25 )
26 from homeassistant.helpers.typing import ConfigType
27 from homeassistant.setup import async_when_setup
28 from homeassistant.util.json import json_loads
29 
30 from .config_flow import CONF_SECRET
31 from .const import DOMAIN
32 from .messages import async_handle_message, encrypt_message
33 
34 _LOGGER = logging.getLogger(__name__)
35 
36 CONF_MAX_GPS_ACCURACY = "max_gps_accuracy"
37 CONF_WAYPOINT_IMPORT = "waypoints"
38 CONF_WAYPOINT_WHITELIST = "waypoint_whitelist"
39 CONF_MQTT_TOPIC = "mqtt_topic"
40 CONF_REGION_MAPPING = "region_mapping"
41 CONF_EVENTS_ONLY = "events_only"
42 BEACON_DEV_ID = "beacon"
43 PLATFORMS = [Platform.DEVICE_TRACKER]
44 
45 DEFAULT_OWNTRACKS_TOPIC = "owntracks/#"
46 
47 CONFIG_SCHEMA = vol.All(
48  cv.removed(CONF_WEBHOOK_ID),
49  vol.Schema(
50  {
51  vol.Optional(DOMAIN, default={}): {
52  vol.Optional(CONF_MAX_GPS_ACCURACY): vol.Coerce(float),
53  vol.Optional(CONF_WAYPOINT_IMPORT, default=True): cv.boolean,
54  vol.Optional(CONF_EVENTS_ONLY, default=False): cv.boolean,
55  vol.Optional(
56  CONF_MQTT_TOPIC, default=DEFAULT_OWNTRACKS_TOPIC
57  ): mqtt.valid_subscribe_topic,
58  vol.Optional(CONF_WAYPOINT_WHITELIST): vol.All(
59  cv.ensure_list, [cv.string]
60  ),
61  vol.Optional(CONF_SECRET): vol.Any(
62  vol.Schema({vol.Optional(cv.string): cv.string}), cv.string
63  ),
64  vol.Optional(CONF_REGION_MAPPING, default={}): dict,
65  }
66  },
67  extra=vol.ALLOW_EXTRA,
68  ),
69 )
70 
71 
72 async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
73  """Initialize OwnTracks component."""
74  hass.data[DOMAIN] = {"config": config[DOMAIN], "devices": {}, "unsub": None}
75  return True
76 
77 
78 async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
79  """Set up OwnTracks entry."""
80  config = hass.data[DOMAIN]["config"]
81  max_gps_accuracy = config.get(CONF_MAX_GPS_ACCURACY)
82  waypoint_import = config.get(CONF_WAYPOINT_IMPORT)
83  waypoint_whitelist = config.get(CONF_WAYPOINT_WHITELIST)
84  secret = config.get(CONF_SECRET) or entry.data[CONF_SECRET]
85  region_mapping = config.get(CONF_REGION_MAPPING)
86  events_only = config.get(CONF_EVENTS_ONLY)
87  mqtt_topic = config.get(CONF_MQTT_TOPIC)
88 
89  context = OwnTracksContext(
90  hass,
91  secret,
92  max_gps_accuracy,
93  waypoint_import,
94  waypoint_whitelist,
95  region_mapping,
96  events_only,
97  mqtt_topic,
98  )
99 
100  webhook_id = config.get(CONF_WEBHOOK_ID) or entry.data[CONF_WEBHOOK_ID]
101 
102  hass.data[DOMAIN]["context"] = context
103 
104  async_when_setup(hass, "mqtt", async_connect_mqtt)
105 
106  webhook.async_register(hass, DOMAIN, "OwnTracks", webhook_id, handle_webhook)
107 
108  await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
109 
110  hass.data[DOMAIN]["unsub"] = async_dispatcher_connect(
111  hass, DOMAIN, async_handle_message
112  )
113 
114  return True
115 
116 
117 async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
118  """Unload an OwnTracks config entry."""
119  webhook.async_unregister(hass, entry.data[CONF_WEBHOOK_ID])
120  unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
121  hass.data[DOMAIN]["unsub"]()
122 
123  return unload_ok
124 
125 
126 async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
127  """Remove an OwnTracks config entry."""
128  if not entry.data.get("cloudhook"):
129  return
130 
131  await cloud.async_delete_cloudhook(hass, entry.data[CONF_WEBHOOK_ID])
132 
133 
134 async def async_connect_mqtt(hass, component):
135  """Subscribe to MQTT topic."""
136  context = hass.data[DOMAIN]["context"]
137 
138  @callback
139  def async_handle_mqtt_message(msg):
140  """Handle incoming OwnTracks message."""
141  try:
142  message = json_loads(msg.payload)
143  except ValueError:
144  # If invalid JSON
145  _LOGGER.error("Unable to parse payload as JSON: %s", msg.payload)
146  return
147 
148  message["topic"] = msg.topic
149  async_dispatcher_send(hass, DOMAIN, hass, context, message)
150 
151  await mqtt.async_subscribe(hass, context.mqtt_topic, async_handle_mqtt_message, 1)
152 
153  return True
154 
155 
156 async def handle_webhook(
157  hass: HomeAssistant, webhook_id: str, request: web.Request
158 ) -> web.Response:
159  """Handle webhook callback.
160 
161  iOS sets the "topic" as part of the payload.
162  Android does not set a topic but adds headers to the request.
163  """
164  context = hass.data[DOMAIN]["context"]
165  topic_base = re.sub("/#$", "", context.mqtt_topic)
166 
167  try:
168  message = await request.json()
169  except ValueError:
170  _LOGGER.warning("Received invalid JSON from OwnTracks")
171  return web.json_response([])
172 
173  # Android doesn't populate topic
174  if "topic" not in message:
175  headers = request.headers
176  user = headers.get("X-Limit-U")
177  device = headers.get("X-Limit-D", user)
178 
179  if user:
180  message["topic"] = f"{topic_base}/{user}/{device}"
181 
182  elif message["_type"] != "encrypted":
183  _LOGGER.warning(
184  "No topic or user found in message. If on Android,"
185  " set a username in Connection -> Identification"
186  )
187  # Keep it as a 200 response so the incorrect packet is discarded
188  return web.json_response([])
189 
190  async_dispatcher_send(hass, DOMAIN, hass, context, message)
191 
192  response = [
193  {
194  "_type": "location",
195  "lat": person.attributes["latitude"],
196  "lon": person.attributes["longitude"],
197  "tid": "".join(p[0] for p in person.name.split(" ")[:2]),
198  "tst": int(person.last_updated.timestamp()),
199  }
200  for person in hass.states.async_all("person")
201  if "latitude" in person.attributes and "longitude" in person.attributes
202  ]
203 
204  if message["_type"] == "encrypted" and context.secret:
205  return web.json_response(
206  {
207  "_type": "encrypted",
208  "data": encrypt_message(
209  context.secret, message["topic"], json.dumps(response)
210  ),
211  }
212  )
213 
214  return web.json_response(response)
215 
216 
218  """Hold the current OwnTracks context."""
219 
220  def __init__(
221  self,
222  hass,
223  secret,
224  max_gps_accuracy,
225  import_waypoints,
226  waypoint_whitelist,
227  region_mapping,
228  events_only,
229  mqtt_topic,
230  ):
231  """Initialize an OwnTracks context."""
232  self.hasshass = hass
233  self.secretsecret = secret
234  self.max_gps_accuracymax_gps_accuracy = max_gps_accuracy
235  self.mobile_beacons_activemobile_beacons_active = defaultdict(set)
236  self.regions_enteredregions_entered = defaultdict(list)
237  self.import_waypointsimport_waypoints = import_waypoints
238  self.waypoint_whitelistwaypoint_whitelist = waypoint_whitelist
239  self.region_mappingregion_mapping = region_mapping
240  self.events_onlyevents_only = events_only
241  self.mqtt_topicmqtt_topic = mqtt_topic
242  self._pending_msg_pending_msg = []
243 
244  @callback
245  def async_valid_accuracy(self, message):
246  """Check if we should ignore this message."""
247  if (acc := message.get("acc")) is None:
248  return False
249 
250  try:
251  acc = float(acc)
252  except ValueError:
253  return False
254 
255  if acc == 0:
256  _LOGGER.warning(
257  "Ignoring %s update because GPS accuracy is zero: %s",
258  message["_type"],
259  message,
260  )
261  return False
262 
263  if self.max_gps_accuracymax_gps_accuracy is not None and acc > self.max_gps_accuracymax_gps_accuracy:
264  _LOGGER.warning(
265  "Ignoring %s update because expected GPS accuracy %s is not met: %s",
266  message["_type"],
267  self.max_gps_accuracymax_gps_accuracy,
268  message,
269  )
270  return False
271 
272  return True
273 
274  @callback
275  def set_async_see(self, func):
276  """Set a new async_see function."""
277  self.async_seeasync_seeasync_see = func
278  for msg in self._pending_msg_pending_msg:
279  func(**msg)
280  self._pending_msg_pending_msg.clear()
281 
282  @callback
283  def async_see(self, **data):
284  """Send a see message to the device tracker."""
285  self._pending_msg_pending_msg.append(data)
286 
287  @callback
288  def async_see_beacons(self, hass, dev_id, kwargs_param):
289  """Set active beacons to the current location."""
290  kwargs = kwargs_param.copy()
291 
292  # Mobile beacons should always be set to the location of the
293  # tracking device. I get the device state and make the necessary
294  # changes to kwargs.
295  device_tracker_state = hass.states.get(f"device_tracker.{dev_id}")
296 
297  if device_tracker_state is not None:
298  acc = device_tracker_state.attributes.get(ATTR_GPS_ACCURACY)
299  lat = device_tracker_state.attributes.get(ATTR_LATITUDE)
300  lon = device_tracker_state.attributes.get(ATTR_LONGITUDE)
301 
302  if lat is not None and lon is not None:
303  kwargs["gps"] = (lat, lon)
304  kwargs["gps_accuracy"] = acc
305  else:
306  kwargs["gps"] = None
307  kwargs["gps_accuracy"] = None
308 
309  # the battery state applies to the tracking device, not the beacon
310  # kwargs location is the beacon's configured lat/lon
311  kwargs.pop("battery", None)
312  for beacon in self.mobile_beacons_activemobile_beacons_active[dev_id]:
313  kwargs["dev_id"] = f"{BEACON_DEV_ID}_{beacon}"
314  kwargs["host_name"] = beacon
315  self.async_seeasync_seeasync_see(**kwargs)
def async_see_beacons(self, hass, dev_id, kwargs_param)
Definition: __init__.py:288
def __init__(self, hass, secret, max_gps_accuracy, import_waypoints, waypoint_whitelist, region_mapping, events_only, mqtt_topic)
Definition: __init__.py:230
def encrypt_message(secret, topic, message)
Definition: messages.py:151
bool async_setup_entry(HomeAssistant hass, ConfigEntry entry)
Definition: __init__.py:78
None async_remove_entry(HomeAssistant hass, ConfigEntry entry)
Definition: __init__.py:126
web.Response handle_webhook(HomeAssistant hass, str webhook_id, web.Request request)
Definition: __init__.py:158
bool async_unload_entry(HomeAssistant hass, ConfigEntry entry)
Definition: __init__.py:117
bool async_setup(HomeAssistant hass, ConfigType config)
Definition: __init__.py:72
def async_connect_mqtt(hass, component)
Definition: __init__.py:134
Callable[[], None] async_dispatcher_connect(HomeAssistant hass, str signal, Callable[..., Any] target)
Definition: dispatcher.py:103
None async_dispatcher_send(HomeAssistant hass, str signal, *Any args)
Definition: dispatcher.py:193
None async_when_setup(core.HomeAssistant hass, str component, Callable[[core.HomeAssistant, str], Awaitable[None]] when_setup_cb)
Definition: setup.py:587