Home Assistant Unofficial Reference 2024.12.1
__init__.py
Go to the documentation of this file.
1 """Support for the Withings API.
2 
3 For more details about this platform, please refer to the documentation at
4 """
5 
6 from __future__ import annotations
7 
8 import asyncio
9 from collections.abc import Awaitable, Callable
10 import contextlib
11 from dataclasses import dataclass, field
12 from datetime import timedelta
13 from typing import TYPE_CHECKING, Any
14 
15 from aiohttp import ClientError
16 from aiohttp.hdrs import METH_POST
17 from aiohttp.web import Request, Response
18 from aiowithings import NotificationCategory, WithingsClient
19 from aiowithings.util import to_enum
20 from yarl import URL
21 
22 from homeassistant.components import cloud
23 from homeassistant.components.http import HomeAssistantView
25  async_generate_id as webhook_generate_id,
26  async_generate_url as webhook_generate_url,
27  async_register as webhook_register,
28  async_unregister as webhook_unregister,
29 )
30 from homeassistant.config_entries import ConfigEntry
31 from homeassistant.const import (
32  CONF_ACCESS_TOKEN,
33  CONF_TOKEN,
34  CONF_WEBHOOK_ID,
35  EVENT_HOMEASSISTANT_STOP,
36  Platform,
37 )
38 from homeassistant.core import HomeAssistant
39 from homeassistant.helpers.aiohttp_client import async_get_clientsession
41  OAuth2Session,
42  async_get_config_entry_implementation,
43 )
44 from homeassistant.helpers.event import async_call_later
45 
46 from .const import DEFAULT_TITLE, DOMAIN, LOGGER
47 from .coordinator import (
48  WithingsActivityDataUpdateCoordinator,
49  WithingsBedPresenceDataUpdateCoordinator,
50  WithingsDataUpdateCoordinator,
51  WithingsDeviceDataUpdateCoordinator,
52  WithingsGoalsDataUpdateCoordinator,
53  WithingsMeasurementDataUpdateCoordinator,
54  WithingsSleepDataUpdateCoordinator,
55  WithingsWorkoutDataUpdateCoordinator,
56 )
57 
58 PLATFORMS = [Platform.BINARY_SENSOR, Platform.CALENDAR, Platform.SENSOR]
59 
60 SUBSCRIBE_DELAY = timedelta(seconds=5)
61 UNSUBSCRIBE_DELAY = timedelta(seconds=1)
62 CONF_CLOUDHOOK_URL = "cloudhook_url"
63 type WithingsConfigEntry = ConfigEntry[WithingsData]
64 
65 
66 @dataclass(slots=True)
68  """Dataclass to hold withings domain data."""
69 
70  client: WithingsClient
71  measurement_coordinator: WithingsMeasurementDataUpdateCoordinator
72  sleep_coordinator: WithingsSleepDataUpdateCoordinator
73  bed_presence_coordinator: WithingsBedPresenceDataUpdateCoordinator
74  goals_coordinator: WithingsGoalsDataUpdateCoordinator
75  activity_coordinator: WithingsActivityDataUpdateCoordinator
76  workout_coordinator: WithingsWorkoutDataUpdateCoordinator
77  device_coordinator: WithingsDeviceDataUpdateCoordinator
78  coordinators: set[WithingsDataUpdateCoordinator] = field(default_factory=set)
79 
80  def __post_init__(self) -> None:
81  """Collect all coordinators in a set."""
82  self.coordinatorscoordinators = {
83  self.measurement_coordinator,
84  self.sleep_coordinator,
85  self.bed_presence_coordinator,
86  self.goals_coordinator,
87  self.activity_coordinator,
88  self.workout_coordinator,
89  self.device_coordinator,
90  }
91 
92 
93 async def async_setup_entry(hass: HomeAssistant, entry: WithingsConfigEntry) -> bool:
94  """Set up Withings from a config entry."""
95  if CONF_WEBHOOK_ID not in entry.data or entry.unique_id is None:
96  new_data = entry.data.copy()
97  unique_id = str(entry.data[CONF_TOKEN]["userid"])
98  if CONF_WEBHOOK_ID not in new_data:
99  new_data[CONF_WEBHOOK_ID] = webhook_generate_id()
100 
101  hass.config_entries.async_update_entry(
102  entry, data=new_data, unique_id=unique_id
103  )
104  session = async_get_clientsession(hass)
105  client = WithingsClient(session=session)
106  implementation = await async_get_config_entry_implementation(hass, entry)
107  oauth_session = OAuth2Session(hass, entry, implementation)
108 
109  refresh_lock = asyncio.Lock()
110 
111  async def _refresh_token() -> str:
112  async with refresh_lock:
113  await oauth_session.async_ensure_token_valid()
114  token = oauth_session.token[CONF_ACCESS_TOKEN]
115  if TYPE_CHECKING:
116  assert isinstance(token, str)
117  return token
118 
119  client.refresh_token_function = _refresh_token
120  withings_data = WithingsData(
121  client=client,
122  measurement_coordinator=WithingsMeasurementDataUpdateCoordinator(hass, client),
123  sleep_coordinator=WithingsSleepDataUpdateCoordinator(hass, client),
124  bed_presence_coordinator=WithingsBedPresenceDataUpdateCoordinator(hass, client),
125  goals_coordinator=WithingsGoalsDataUpdateCoordinator(hass, client),
126  activity_coordinator=WithingsActivityDataUpdateCoordinator(hass, client),
127  workout_coordinator=WithingsWorkoutDataUpdateCoordinator(hass, client),
128  device_coordinator=WithingsDeviceDataUpdateCoordinator(hass, client),
129  )
130 
131  for coordinator in withings_data.coordinators:
132  await coordinator.async_config_entry_first_refresh()
133 
134  entry.runtime_data = withings_data
135 
136  webhook_manager = WithingsWebhookManager(hass, entry)
137 
138  async def manage_cloudhook(state: cloud.CloudConnectionState) -> None:
139  LOGGER.debug("Cloudconnection state changed to %s", state)
140  if state is cloud.CloudConnectionState.CLOUD_CONNECTED:
141  await webhook_manager.register_webhook(None)
142 
143  if state is cloud.CloudConnectionState.CLOUD_DISCONNECTED:
144  await webhook_manager.unregister_webhook(None)
145  entry.async_on_unload(
146  async_call_later(hass, 30, webhook_manager.register_webhook)
147  )
148 
149  if cloud.async_active_subscription(hass):
150  if cloud.async_is_connected(hass):
151  entry.async_on_unload(
152  async_call_later(hass, 1, webhook_manager.register_webhook)
153  )
154  entry.async_on_unload(
155  cloud.async_listen_connection_change(hass, manage_cloudhook)
156  )
157  else:
158  entry.async_on_unload(
159  async_call_later(hass, 1, webhook_manager.register_webhook)
160  )
161 
162  await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
163 
164  return True
165 
166 
167 async def async_unload_entry(hass: HomeAssistant, entry: WithingsConfigEntry) -> bool:
168  """Unload Withings config entry."""
169  webhook_unregister(hass, entry.data[CONF_WEBHOOK_ID])
170 
171  return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
172 
173 
174 async def async_subscribe_webhooks(client: WithingsClient, webhook_url: str) -> None:
175  """Subscribe to Withings webhooks."""
176  await async_unsubscribe_webhooks(client)
177 
178  notification_to_subscribe = {
179  NotificationCategory.WEIGHT,
180  NotificationCategory.PRESSURE,
181  NotificationCategory.ACTIVITY,
182  NotificationCategory.SLEEP,
183  NotificationCategory.IN_BED,
184  NotificationCategory.OUT_BED,
185  }
186 
187  for notification in notification_to_subscribe:
188  LOGGER.debug(
189  "Subscribing %s for %s in %s seconds",
190  webhook_url,
191  notification,
192  SUBSCRIBE_DELAY.total_seconds(),
193  )
194  # Withings will HTTP HEAD the callback_url and needs some downtime
195  # between each call or there is a higher chance of failure.
196  await asyncio.sleep(SUBSCRIBE_DELAY.total_seconds())
197  await client.subscribe_notification(webhook_url, notification)
198 
199 
201  """Manager that manages the Withings webhooks."""
202 
203  _webhooks_registered = False
204  _register_lock = asyncio.Lock()
205 
206  def __init__(self, hass: HomeAssistant, entry: WithingsConfigEntry) -> None:
207  """Initialize webhook manager."""
208  self.hasshass = hass
209  self.entryentry = entry
210 
211  @property
212  def withings_data(self) -> WithingsData:
213  """Return Withings data."""
214  return self.entryentry.runtime_data
215 
217  self,
218  _: Any,
219  ) -> None:
220  """Unregister webhooks at Withings."""
221  async with self._register_lock_register_lock:
222  LOGGER.debug(
223  "Unregister Withings webhook (%s)", self.entryentry.data[CONF_WEBHOOK_ID]
224  )
225  webhook_unregister(self.hasshass, self.entryentry.data[CONF_WEBHOOK_ID])
226  await async_unsubscribe_webhooks(self.withings_datawithings_data.client)
227  for coordinator in self.withings_datawithings_data.coordinators:
228  coordinator.webhook_subscription_listener(False)
229  self._webhooks_registered_webhooks_registered_webhooks_registered = False
230 
231  async def register_webhook(
232  self,
233  _: Any,
234  ) -> None:
235  """Register webhooks at Withings."""
236  async with self._register_lock_register_lock:
237  if self._webhooks_registered_webhooks_registered_webhooks_registered:
238  return
239  if cloud.async_active_subscription(self.hasshass):
240  webhook_url = await _async_cloudhook_generate_url(self.hasshass, self.entryentry)
241  else:
242  webhook_url = webhook_generate_url(
243  self.hasshass, self.entryentry.data[CONF_WEBHOOK_ID]
244  )
245  url = URL(webhook_url)
246  if url.scheme != "https" or url.port != 443:
247  LOGGER.warning(
248  "Webhook not registered - "
249  "https and port 443 is required to register the webhook"
250  )
251  return
252 
253  webhook_name = "Withings"
254  if self.entryentry.title != DEFAULT_TITLE:
255  webhook_name = f"{DEFAULT_TITLE} {self.entry.title}"
256 
257  webhook_register(
258  self.hasshass,
259  DOMAIN,
260  webhook_name,
261  self.entryentry.data[CONF_WEBHOOK_ID],
262  get_webhook_handler(self.withings_datawithings_data),
263  allowed_methods=[METH_POST],
264  )
265  LOGGER.debug("Registered Withings webhook at hass: %s", webhook_url)
266 
267  await async_subscribe_webhooks(self.withings_datawithings_data.client, webhook_url)
268  for coordinator in self.withings_datawithings_data.coordinators:
269  coordinator.webhook_subscription_listener(True)
270  LOGGER.debug("Registered Withings webhook at Withings: %s", webhook_url)
271  self.entryentry.async_on_unload(
272  self.hasshass.bus.async_listen_once(
273  EVENT_HOMEASSISTANT_STOP, self.unregister_webhookunregister_webhook
274  )
275  )
276  self._webhooks_registered_webhooks_registered_webhooks_registered = True
277 
278 
279 async def async_unsubscribe_webhooks(client: WithingsClient) -> None:
280  """Unsubscribe to all Withings webhooks."""
281  try:
282  current_webhooks = await client.list_notification_configurations()
283  except ClientError:
284  LOGGER.exception("Error when unsubscribing webhooks")
285  return
286 
287  for webhook_configuration in current_webhooks:
288  LOGGER.debug(
289  "Unsubscribing %s for %s in %s seconds",
290  webhook_configuration.callback_url,
291  webhook_configuration.notification_category,
292  UNSUBSCRIBE_DELAY.total_seconds(),
293  )
294  # Quick calls to Withings can result in the service returning errors.
295  # Give them some time to cool down.
296  await asyncio.sleep(UNSUBSCRIBE_DELAY.total_seconds())
297  await client.revoke_notification_configurations(
298  webhook_configuration.callback_url,
299  webhook_configuration.notification_category,
300  )
301 
302 
304  hass: HomeAssistant, entry: WithingsConfigEntry
305 ) -> str:
306  """Generate the full URL for a webhook_id."""
307  if CONF_CLOUDHOOK_URL not in entry.data:
308  webhook_id = entry.data[CONF_WEBHOOK_ID]
309  # Some users already have their webhook as cloudhook.
310  # We remove them to be sure we can create a new one.
311  with contextlib.suppress(ValueError):
312  await cloud.async_delete_cloudhook(hass, webhook_id)
313  webhook_url = await cloud.async_create_cloudhook(hass, webhook_id)
314  data = {**entry.data, CONF_CLOUDHOOK_URL: webhook_url}
315  hass.config_entries.async_update_entry(entry, data=data)
316  return webhook_url
317  return str(entry.data[CONF_CLOUDHOOK_URL])
318 
319 
320 async def async_remove_entry(hass: HomeAssistant, entry: WithingsConfigEntry) -> None:
321  """Cleanup when entry is removed."""
322  if cloud.async_active_subscription(hass):
323  try:
324  LOGGER.debug(
325  "Removing Withings cloudhook (%s)", entry.data[CONF_WEBHOOK_ID]
326  )
327  await cloud.async_delete_cloudhook(hass, entry.data[CONF_WEBHOOK_ID])
329  pass
330 
331 
332 def json_message_response(message: str, message_code: int) -> Response:
333  """Produce common json output."""
334  return HomeAssistantView.json({"message": message, "code": message_code})
335 
336 
338  withings_data: WithingsData,
339 ) -> Callable[[HomeAssistant, str, Request], Awaitable[Response | None]]:
340  """Return webhook handler."""
341 
342  async def async_webhook_handler(
343  hass: HomeAssistant, webhook_id: str, request: Request
344  ) -> Response | None:
345  # Handle http post calls to the path.
346  if not request.body_exists:
347  return json_message_response("No request body", message_code=12)
348 
349  params = await request.post()
350 
351  if "appli" not in params:
352  return json_message_response(
353  "Parameter appli not provided", message_code=20
354  )
355 
356  notification_category = to_enum(
357  NotificationCategory,
358  int(params.getone("appli")), # type: ignore[arg-type]
359  NotificationCategory.UNKNOWN,
360  )
361 
362  for coordinator in withings_data.coordinators:
363  if notification_category in coordinator.notification_categories:
364  await coordinator.async_webhook_data_updated(notification_category)
365 
366  return json_message_response("Success", message_code=0)
367 
368  return async_webhook_handler
None __init__(self, HomeAssistant hass, WithingsConfigEntry entry)
Definition: __init__.py:206
str _async_cloudhook_generate_url(HomeAssistant hass, WithingsConfigEntry entry)
Definition: __init__.py:305
Callable[[HomeAssistant, str, Request], Awaitable[Response|None]] get_webhook_handler(WithingsData withings_data)
Definition: __init__.py:339
Response json_message_response(str message, int message_code)
Definition: __init__.py:332
None async_remove_entry(HomeAssistant hass, WithingsConfigEntry entry)
Definition: __init__.py:320
bool async_unload_entry(HomeAssistant hass, WithingsConfigEntry entry)
Definition: __init__.py:167
None async_unsubscribe_webhooks(WithingsClient client)
Definition: __init__.py:279
None async_subscribe_webhooks(WithingsClient client, str webhook_url)
Definition: __init__.py:174
bool async_setup_entry(HomeAssistant hass, WithingsConfigEntry entry)
Definition: __init__.py:93
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)
AbstractOAuth2Implementation async_get_config_entry_implementation(HomeAssistant hass, config_entries.ConfigEntry config_entry)
CALLBACK_TYPE async_call_later(HomeAssistant hass, float|timedelta delay, HassJob[[datetime], Coroutine[Any, Any, None]|None]|Callable[[datetime], Coroutine[Any, Any, None]|None] action)
Definition: event.py:1597