Home Assistant Unofficial Reference 2024.12.1
__init__.py
Go to the documentation of this file.
1 """The tractive integration."""
2 
3 from __future__ import annotations
4 
5 import asyncio
6 from dataclasses import dataclass
7 import logging
8 from typing import Any, cast
9 
10 import aiotractive
11 
12 from homeassistant.config_entries import ConfigEntry
13 from homeassistant.const import (
14  ATTR_BATTERY_CHARGING,
15  ATTR_BATTERY_LEVEL,
16  CONF_EMAIL,
17  CONF_PASSWORD,
18  EVENT_HOMEASSISTANT_STOP,
19  Platform,
20 )
21 from homeassistant.core import Event, HomeAssistant
22 from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
23 from homeassistant.helpers.aiohttp_client import async_get_clientsession
24 from homeassistant.helpers.dispatcher import async_dispatcher_send
25 
26 from .const import (
27  ATTR_ACTIVITY_LABEL,
28  ATTR_CALORIES,
29  ATTR_DAILY_GOAL,
30  ATTR_MINUTES_ACTIVE,
31  ATTR_MINUTES_DAY_SLEEP,
32  ATTR_MINUTES_NIGHT_SLEEP,
33  ATTR_MINUTES_REST,
34  ATTR_SLEEP_LABEL,
35  ATTR_TRACKER_STATE,
36  CLIENT_ID,
37  RECONNECT_INTERVAL,
38  SERVER_UNAVAILABLE,
39  SWITCH_KEY_MAP,
40  TRACKER_HARDWARE_STATUS_UPDATED,
41  TRACKER_POSITION_UPDATED,
42  TRACKER_SWITCH_STATUS_UPDATED,
43  TRACKER_WELLNESS_STATUS_UPDATED,
44 )
45 
46 PLATFORMS = [
47  Platform.BINARY_SENSOR,
48  Platform.DEVICE_TRACKER,
49  Platform.SENSOR,
50  Platform.SWITCH,
51 ]
52 
53 
54 _LOGGER = logging.getLogger(__name__)
55 
56 
57 @dataclass
58 class Trackables:
59  """A class that describes trackables."""
60 
61  tracker: aiotractive.tracker.Tracker
62  trackable: dict
63  tracker_details: dict
64  hw_info: dict
65  pos_report: dict
66 
67 
68 @dataclass(slots=True)
70  """Class for Tractive data."""
71 
72  client: TractiveClient
73  trackables: list[Trackables]
74 
75 
76 type TractiveConfigEntry = ConfigEntry[TractiveData]
77 
78 
79 async def async_setup_entry(hass: HomeAssistant, entry: TractiveConfigEntry) -> bool:
80  """Set up tractive from a config entry."""
81  data = entry.data
82 
83  client = aiotractive.Tractive(
84  data[CONF_EMAIL],
85  data[CONF_PASSWORD],
86  session=async_get_clientsession(hass),
87  client_id=CLIENT_ID,
88  )
89  try:
90  creds = await client.authenticate()
91  except aiotractive.exceptions.UnauthorizedError as error:
92  await client.close()
93  raise ConfigEntryAuthFailed from error
94  except aiotractive.exceptions.TractiveError as error:
95  await client.close()
96  raise ConfigEntryNotReady from error
97 
98  tractive = TractiveClient(hass, client, creds["user_id"], entry)
99 
100  try:
101  trackable_objects = await client.trackable_objects()
102  trackables = await asyncio.gather(
103  *(_generate_trackables(client, item) for item in trackable_objects)
104  )
105  except aiotractive.exceptions.TractiveError as error:
106  raise ConfigEntryNotReady from error
107 
108  # When the pet defined in Tractive has no tracker linked we get None as `trackable`.
109  # So we have to remove None values from trackables list.
110  filtered_trackables = [item for item in trackables if item]
111 
112  entry.runtime_data = TractiveData(tractive, filtered_trackables)
113 
114  await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
115 
116  async def cancel_listen_task(_: Event) -> None:
117  await tractive.unsubscribe()
118 
119  entry.async_on_unload(
120  hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, cancel_listen_task)
121  )
122  entry.async_on_unload(tractive.unsubscribe)
123 
124  return True
125 
126 
128  client: aiotractive.Tractive,
129  trackable: aiotractive.trackable_object.TrackableObject,
130 ) -> Trackables | None:
131  """Generate trackables."""
132  trackable = await trackable.details()
133 
134  # Check that the pet has tracker linked.
135  if not trackable.get("device_id"):
136  return None
137 
138  if "details" not in trackable:
139  _LOGGER.warning(
140  "Tracker %s has no details and will be skipped. This happens for shared trackers",
141  trackable["device_id"],
142  )
143  return None
144 
145  tracker = client.tracker(trackable["device_id"])
146 
147  tracker_details, hw_info, pos_report = await asyncio.gather(
148  tracker.details(), tracker.hw_info(), tracker.pos_report()
149  )
150 
151  if not tracker_details.get("_id"):
152  raise ConfigEntryNotReady(
153  f"Tractive API returns incomplete data for tracker {trackable['device_id']}",
154  )
155 
156  return Trackables(tracker, trackable, tracker_details, hw_info, pos_report)
157 
158 
159 async def async_unload_entry(hass: HomeAssistant, entry: TractiveConfigEntry) -> bool:
160  """Unload a config entry."""
161  return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
162 
163 
165  """A Tractive client."""
166 
167  def __init__(
168  self,
169  hass: HomeAssistant,
170  client: aiotractive.Tractive,
171  user_id: str,
172  config_entry: ConfigEntry,
173  ) -> None:
174  """Initialize the client."""
175  self._hass_hass = hass
176  self._client_client = client
177  self._user_id_user_id = user_id
178  self._last_hw_time_last_hw_time = 0
179  self._last_pos_time_last_pos_time = 0
180  self._listen_task_listen_task: asyncio.Task | None = None
181  self._config_entry_config_entry = config_entry
182 
183  @property
184  def user_id(self) -> str:
185  """Return user id."""
186  return self._user_id_user_id
187 
188  @property
189  def subscribed(self) -> bool:
190  """Return True if subscribed."""
191  if self._listen_task_listen_task is None:
192  return False
193 
194  return not self._listen_task_listen_task.cancelled()
195 
196  async def trackable_objects(
197  self,
198  ) -> list[aiotractive.trackable_object.TrackableObject]:
199  """Get list of trackable objects."""
200  return cast(
201  list[aiotractive.trackable_object.TrackableObject],
202  await self._client_client.trackable_objects(),
203  )
204 
205  def tracker(self, tracker_id: str) -> aiotractive.tracker.Tracker:
206  """Get tracker by id."""
207  return self._client_client.tracker(tracker_id)
208 
209  def subscribe(self) -> None:
210  """Start event listener coroutine."""
211  self._listen_task_listen_task = asyncio.create_task(self._listen_listen())
212 
213  async def unsubscribe(self) -> None:
214  """Stop event listener coroutine."""
215  if self._listen_task_listen_task:
216  self._listen_task_listen_task.cancel()
217  await self._client_client.close()
218 
219  async def _listen(self) -> None:
220  server_was_unavailable = False
221  while True:
222  try:
223  async for event in self._client_client.events():
224  _LOGGER.debug("Received event: %s", event)
225  if server_was_unavailable:
226  _LOGGER.debug("Tractive is back online")
227  server_was_unavailable = False
228  if event["message"] == "wellness_overview":
229  self._send_wellness_update_send_wellness_update(event)
230  continue
231  if (
232  "hardware" in event
233  and self._last_hw_time_last_hw_time != event["hardware"]["time"]
234  ):
235  self._last_hw_time_last_hw_time = event["hardware"]["time"]
236  self._send_hardware_update_send_hardware_update(event)
237  if (
238  "position" in event
239  and self._last_pos_time_last_pos_time != event["position"]["time"]
240  ):
241  self._last_pos_time_last_pos_time = event["position"]["time"]
242  self._send_position_update_send_position_update(event)
243  # If any key belonging to the switch is present in the event,
244  # we send a switch status update
245  if bool(set(SWITCH_KEY_MAP.values()).intersection(event)):
246  self._send_switch_update_send_switch_update(event)
247  except aiotractive.exceptions.UnauthorizedError:
248  self._config_entry_config_entry.async_start_reauth(self._hass_hass)
249  await self.unsubscribeunsubscribe()
250  _LOGGER.error(
251  "Authentication failed for %s, try reconfiguring device",
252  self._config_entry_config_entry.data[CONF_EMAIL],
253  )
254  return
255  except (KeyError, TypeError) as error:
256  _LOGGER.error("Error while listening for events: %s", error)
257  continue
258  except aiotractive.exceptions.TractiveError:
259  _LOGGER.debug(
260  (
261  "Tractive is not available. Internet connection is down?"
262  " Sleeping %i seconds and retrying"
263  ),
264  RECONNECT_INTERVAL.total_seconds(),
265  )
266  self._last_hw_time_last_hw_time = 0
267  self._last_pos_time_last_pos_time = 0
269  self._hass_hass, f"{SERVER_UNAVAILABLE}-{self._user_id}"
270  )
271  await asyncio.sleep(RECONNECT_INTERVAL.total_seconds())
272  server_was_unavailable = True
273  continue
274 
275  def _send_hardware_update(self, event: dict[str, Any]) -> None:
276  # Sometimes hardware event doesn't contain complete data.
277  payload = {
278  ATTR_BATTERY_LEVEL: event["hardware"]["battery_level"],
279  ATTR_TRACKER_STATE: event["tracker_state"].lower(),
280  ATTR_BATTERY_CHARGING: event["charging_state"] == "CHARGING",
281  }
282  self._dispatch_tracker_event_dispatch_tracker_event(
283  TRACKER_HARDWARE_STATUS_UPDATED, event["tracker_id"], payload
284  )
285 
286  def _send_switch_update(self, event: dict[str, Any]) -> None:
287  # Sometimes the event contains data for all switches, sometimes only for one.
288  payload = {}
289  for switch, key in SWITCH_KEY_MAP.items():
290  if switch_data := event.get(key):
291  payload[switch] = switch_data["active"]
292  self._dispatch_tracker_event_dispatch_tracker_event(
293  TRACKER_SWITCH_STATUS_UPDATED, event["tracker_id"], payload
294  )
295 
296  def _send_wellness_update(self, event: dict[str, Any]) -> None:
297  sleep_day = None
298  sleep_night = None
299  if isinstance(event["sleep"], dict):
300  sleep_day = event["sleep"]["minutes_day_sleep"]
301  sleep_night = event["sleep"]["minutes_night_sleep"]
302  payload = {
303  ATTR_ACTIVITY_LABEL: event["wellness"].get("activity_label"),
304  ATTR_CALORIES: event["activity"]["calories"],
305  ATTR_DAILY_GOAL: event["activity"]["minutes_goal"],
306  ATTR_MINUTES_ACTIVE: event["activity"]["minutes_active"],
307  ATTR_MINUTES_DAY_SLEEP: sleep_day,
308  ATTR_MINUTES_NIGHT_SLEEP: sleep_night,
309  ATTR_MINUTES_REST: event["activity"]["minutes_rest"],
310  ATTR_SLEEP_LABEL: event["wellness"].get("sleep_label"),
311  }
312  self._dispatch_tracker_event_dispatch_tracker_event(
313  TRACKER_WELLNESS_STATUS_UPDATED, event["pet_id"], payload
314  )
315 
316  def _send_position_update(self, event: dict[str, Any]) -> None:
317  payload = {
318  "latitude": event["position"]["latlong"][0],
319  "longitude": event["position"]["latlong"][1],
320  "accuracy": event["position"]["accuracy"],
321  "sensor_used": event["position"]["sensor_used"],
322  }
323  self._dispatch_tracker_event_dispatch_tracker_event(
324  TRACKER_POSITION_UPDATED, event["tracker_id"], payload
325  )
326 
328  self, event_name: str, tracker_id: str, payload: dict[str, Any]
329  ) -> None:
331  self._hass_hass,
332  f"{event_name}-{tracker_id}",
333  payload,
334  )
None __init__(self, HomeAssistant hass, aiotractive.Tractive client, str user_id, ConfigEntry config_entry)
Definition: __init__.py:173
None _send_hardware_update(self, dict[str, Any] event)
Definition: __init__.py:275
None _send_switch_update(self, dict[str, Any] event)
Definition: __init__.py:286
None _send_position_update(self, dict[str, Any] event)
Definition: __init__.py:316
aiotractive.tracker.Tracker tracker(self, str tracker_id)
Definition: __init__.py:205
None _send_wellness_update(self, dict[str, Any] event)
Definition: __init__.py:296
list[aiotractive.trackable_object.TrackableObject] trackable_objects(self)
Definition: __init__.py:198
None _dispatch_tracker_event(self, str event_name, str tracker_id, dict[str, Any] payload)
Definition: __init__.py:329
web.Response get(self, web.Request request, str config_key)
Definition: view.py:88
Trackables|None _generate_trackables(aiotractive.Tractive client, aiotractive.trackable_object.TrackableObject trackable)
Definition: __init__.py:130
bool async_setup_entry(HomeAssistant hass, TractiveConfigEntry entry)
Definition: __init__.py:79
bool async_unload_entry(HomeAssistant hass, TractiveConfigEntry entry)
Definition: __init__.py:159
aiohttp.ClientSession async_get_clientsession(HomeAssistant hass, bool verify_ssl=True, socket.AddressFamily family=socket.AF_UNSPEC, ssl_util.SSLCipherList ssl_cipher=ssl_util.SSLCipherList.PYTHON_DEFAULT)
None async_dispatcher_send(HomeAssistant hass, str signal, *Any args)
Definition: dispatcher.py:193