1 """Support for the Withings API.
3 For more details about this platform, please refer to the documentation at
6 from __future__
import annotations
9 from collections.abc
import Awaitable, Callable
11 from dataclasses
import dataclass, field
12 from datetime
import timedelta
13 from typing
import TYPE_CHECKING, Any
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
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,
35 EVENT_HOMEASSISTANT_STOP,
42 async_get_config_entry_implementation,
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,
58 PLATFORMS = [Platform.BINARY_SENSOR, Platform.CALENDAR, Platform.SENSOR]
62 CONF_CLOUDHOOK_URL =
"cloudhook_url"
63 type WithingsConfigEntry = ConfigEntry[WithingsData]
66 @dataclass(slots=True)
68 """Dataclass to hold withings domain data."""
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)
81 """Collect all coordinators in a set."""
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,
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()
101 hass.config_entries.async_update_entry(
102 entry, data=new_data, unique_id=unique_id
105 client = WithingsClient(session=session)
109 refresh_lock = asyncio.Lock()
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]
116 assert isinstance(token, str)
119 client.refresh_token_function = _refresh_token
131 for coordinator
in withings_data.coordinators:
132 await coordinator.async_config_entry_first_refresh()
134 entry.runtime_data = withings_data
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)
143 if state
is cloud.CloudConnectionState.CLOUD_DISCONNECTED:
144 await webhook_manager.unregister_webhook(
None)
145 entry.async_on_unload(
149 if cloud.async_active_subscription(hass):
150 if cloud.async_is_connected(hass):
151 entry.async_on_unload(
154 entry.async_on_unload(
155 cloud.async_listen_connection_change(hass, manage_cloudhook)
158 entry.async_on_unload(
162 await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
168 """Unload Withings config entry."""
169 webhook_unregister(hass, entry.data[CONF_WEBHOOK_ID])
171 return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
175 """Subscribe to Withings webhooks."""
178 notification_to_subscribe = {
179 NotificationCategory.WEIGHT,
180 NotificationCategory.PRESSURE,
181 NotificationCategory.ACTIVITY,
182 NotificationCategory.SLEEP,
183 NotificationCategory.IN_BED,
184 NotificationCategory.OUT_BED,
187 for notification
in notification_to_subscribe:
189 "Subscribing %s for %s in %s seconds",
192 SUBSCRIBE_DELAY.total_seconds(),
196 await asyncio.sleep(SUBSCRIBE_DELAY.total_seconds())
197 await client.subscribe_notification(webhook_url, notification)
201 """Manager that manages the Withings webhooks."""
203 _webhooks_registered =
False
204 _register_lock = asyncio.Lock()
206 def __init__(self, hass: HomeAssistant, entry: WithingsConfigEntry) ->
None:
207 """Initialize webhook manager."""
213 """Return Withings data."""
214 return self.
entryentry.runtime_data
220 """Unregister webhooks at Withings."""
223 "Unregister Withings webhook (%s)", self.
entryentry.data[CONF_WEBHOOK_ID]
225 webhook_unregister(self.
hasshass, self.
entryentry.data[CONF_WEBHOOK_ID])
227 for coordinator
in self.
withings_datawithings_data.coordinators:
228 coordinator.webhook_subscription_listener(
False)
235 """Register webhooks at Withings."""
239 if cloud.async_active_subscription(self.
hasshass):
242 webhook_url = webhook_generate_url(
243 self.
hasshass, self.
entryentry.data[CONF_WEBHOOK_ID]
245 url =
URL(webhook_url)
246 if url.scheme !=
"https" or url.port != 443:
248 "Webhook not registered - "
249 "https and port 443 is required to register the webhook"
253 webhook_name =
"Withings"
254 if self.
entryentry.title != DEFAULT_TITLE:
255 webhook_name = f
"{DEFAULT_TITLE} {self.entry.title}"
261 self.
entryentry.data[CONF_WEBHOOK_ID],
263 allowed_methods=[METH_POST],
265 LOGGER.debug(
"Registered Withings webhook at hass: %s", 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(
280 """Unsubscribe to all Withings webhooks."""
282 current_webhooks = await client.list_notification_configurations()
284 LOGGER.exception(
"Error when unsubscribing webhooks")
287 for webhook_configuration
in current_webhooks:
289 "Unsubscribing %s for %s in %s seconds",
290 webhook_configuration.callback_url,
291 webhook_configuration.notification_category,
292 UNSUBSCRIBE_DELAY.total_seconds(),
296 await asyncio.sleep(UNSUBSCRIBE_DELAY.total_seconds())
297 await client.revoke_notification_configurations(
298 webhook_configuration.callback_url,
299 webhook_configuration.notification_category,
304 hass: HomeAssistant, entry: WithingsConfigEntry
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]
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)
317 return str(entry.data[CONF_CLOUDHOOK_URL])
321 """Cleanup when entry is removed."""
322 if cloud.async_active_subscription(hass):
325 "Removing Withings cloudhook (%s)", entry.data[CONF_WEBHOOK_ID]
327 await cloud.async_delete_cloudhook(hass, entry.data[CONF_WEBHOOK_ID])
333 """Produce common json output."""
334 return HomeAssistantView.json({
"message": message,
"code": message_code})
338 withings_data: WithingsData,
339 ) -> Callable[[HomeAssistant, str, Request], Awaitable[Response |
None]]:
340 """Return webhook handler."""
342 async
def async_webhook_handler(
343 hass: HomeAssistant, webhook_id: str, request: Request
344 ) -> Response |
None:
346 if not request.body_exists:
349 params = await request.post()
351 if "appli" not in params:
353 "Parameter appli not provided", message_code=20
356 notification_category = to_enum(
357 NotificationCategory,
358 int(params.getone(
"appli")),
359 NotificationCategory.UNKNOWN,
362 for coordinator
in withings_data.coordinators:
363 if notification_category
in coordinator.notification_categories:
364 await coordinator.async_webhook_data_updated(notification_category)
368 return async_webhook_handler
WithingsData withings_data(self)
bool _webhooks_registered
None unregister_webhook(self, Any _)
None register_webhook(self, Any _)
None __init__(self, HomeAssistant hass, WithingsConfigEntry entry)
str _async_cloudhook_generate_url(HomeAssistant hass, WithingsConfigEntry entry)
Callable[[HomeAssistant, str, Request], Awaitable[Response|None]] get_webhook_handler(WithingsData withings_data)
Response json_message_response(str message, int message_code)
None async_remove_entry(HomeAssistant hass, WithingsConfigEntry entry)
bool async_unload_entry(HomeAssistant hass, WithingsConfigEntry entry)
None async_unsubscribe_webhooks(WithingsClient client)
None async_subscribe_webhooks(WithingsClient client, str webhook_url)
bool async_setup_entry(HomeAssistant hass, WithingsConfigEntry entry)
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)