Home Assistant Unofficial Reference 2024.12.1
__init__.py
Go to the documentation of this file.
1 """The Netatmo integration."""
2 
3 from __future__ import annotations
4 
5 from http import HTTPStatus
6 import logging
7 import secrets
8 from typing import Any
9 
10 import aiohttp
11 import pyatmo
12 
13 from homeassistant.components import cloud
15  async_generate_url as webhook_generate_url,
16  async_register as webhook_register,
17  async_unregister as webhook_unregister,
18 )
19 from homeassistant.config_entries import ConfigEntry
20 from homeassistant.const import CONF_WEBHOOK_ID, EVENT_HOMEASSISTANT_STOP
21 from homeassistant.core import HomeAssistant
22 from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
23 from homeassistant.helpers import (
24  aiohttp_client,
25  config_entry_oauth2_flow,
26  config_validation as cv,
27 )
28 from homeassistant.helpers.device_registry import DeviceEntry
29 from homeassistant.helpers.dispatcher import async_dispatcher_send
30 from homeassistant.helpers.event import async_call_later
31 from homeassistant.helpers.start import async_at_started
32 from homeassistant.helpers.typing import ConfigType
33 
34 from . import api
35 from .const import (
36  AUTH,
37  CONF_CLOUDHOOK_URL,
38  DATA_CAMERAS,
39  DATA_DEVICE_IDS,
40  DATA_EVENTS,
41  DATA_HANDLER,
42  DATA_HOMES,
43  DATA_PERSONS,
44  DATA_SCHEDULES,
45  DOMAIN,
46  PLATFORMS,
47  WEBHOOK_DEACTIVATION,
48  WEBHOOK_PUSH_TYPE,
49 )
50 from .data_handler import NetatmoDataHandler
51 from .webhook import async_handle_webhook
52 
53 _LOGGER = logging.getLogger(__name__)
54 
55 CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
56 
57 MAX_WEBHOOK_RETRIES = 3
58 
59 
60 async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
61  """Set up the Netatmo component."""
62  hass.data[DOMAIN] = {
63  DATA_PERSONS: {},
64  DATA_DEVICE_IDS: {},
65  DATA_SCHEDULES: {},
66  DATA_HOMES: {},
67  DATA_EVENTS: {},
68  DATA_CAMERAS: {},
69  }
70 
71  return True
72 
73 
74 async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
75  """Set up Netatmo from a config entry."""
76  implementation = (
77  await config_entry_oauth2_flow.async_get_config_entry_implementation(
78  hass, entry
79  )
80  )
81 
82  # Set unique id if non was set (migration)
83  if not entry.unique_id:
84  hass.config_entries.async_update_entry(entry, unique_id=DOMAIN)
85 
86  session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation)
87  try:
88  await session.async_ensure_token_valid()
89  except aiohttp.ClientResponseError as ex:
90  _LOGGER.warning("API error: %s (%s)", ex.status, ex.message)
91  if ex.status in (
92  HTTPStatus.BAD_REQUEST,
93  HTTPStatus.UNAUTHORIZED,
94  HTTPStatus.FORBIDDEN,
95  ):
96  raise ConfigEntryAuthFailed("Token not valid, trigger renewal") from ex
97  raise ConfigEntryNotReady from ex
98 
99  required_scopes = api.get_api_scopes(entry.data["auth_implementation"])
100  if not (set(session.token["scope"]) & set(required_scopes)):
101  _LOGGER.warning(
102  "Session is missing scopes: %s",
103  set(required_scopes) - set(session.token["scope"]),
104  )
105  raise ConfigEntryAuthFailed("Token scope not valid, trigger renewal")
106 
107  hass.data[DOMAIN][entry.entry_id] = {
109  aiohttp_client.async_get_clientsession(hass), session
110  )
111  }
112 
113  data_handler = NetatmoDataHandler(hass, entry)
114  hass.data[DOMAIN][entry.entry_id][DATA_HANDLER] = data_handler
115  await data_handler.async_setup()
116 
117  async def unregister_webhook(
118  _: Any,
119  ) -> None:
120  if CONF_WEBHOOK_ID not in entry.data:
121  return
122  _LOGGER.debug("Unregister Netatmo webhook (%s)", entry.data[CONF_WEBHOOK_ID])
124  hass,
125  f"signal-{DOMAIN}-webhook-None",
126  {"type": "None", "data": {WEBHOOK_PUSH_TYPE: WEBHOOK_DEACTIVATION}},
127  )
128  webhook_unregister(hass, entry.data[CONF_WEBHOOK_ID])
129  try:
130  await hass.data[DOMAIN][entry.entry_id][AUTH].async_dropwebhook()
131  except pyatmo.ApiError:
132  _LOGGER.debug(
133  "No webhook to be dropped for %s", entry.data[CONF_WEBHOOK_ID]
134  )
135 
136  async def register_webhook(
137  _: Any,
138  ) -> None:
139  if CONF_WEBHOOK_ID not in entry.data:
140  data = {**entry.data, CONF_WEBHOOK_ID: secrets.token_hex()}
141  hass.config_entries.async_update_entry(entry, data=data)
142 
143  if cloud.async_active_subscription(hass):
144  webhook_url = await async_cloudhook_generate_url(hass, entry)
145  else:
146  webhook_url = webhook_generate_url(hass, entry.data[CONF_WEBHOOK_ID])
147 
148  if entry.data[
149  "auth_implementation"
150  ] == cloud.DOMAIN and not webhook_url.startswith("https://"):
151  _LOGGER.warning(
152  "Webhook not registered - "
153  "https and port 443 is required to register the webhook"
154  )
155  return
156 
157  webhook_register(
158  hass,
159  DOMAIN,
160  "Netatmo",
161  entry.data[CONF_WEBHOOK_ID],
162  async_handle_webhook,
163  )
164 
165  try:
166  await hass.data[DOMAIN][entry.entry_id][AUTH].async_addwebhook(webhook_url)
167  _LOGGER.debug("Register Netatmo webhook: %s", webhook_url)
168  except pyatmo.ApiError as err:
169  _LOGGER.error("Error during webhook registration - %s", err)
170  else:
171  entry.async_on_unload(
172  hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, unregister_webhook)
173  )
174 
175  async def manage_cloudhook(state: cloud.CloudConnectionState) -> None:
176  if state is cloud.CloudConnectionState.CLOUD_CONNECTED:
177  await register_webhook(None)
178 
179  if state is cloud.CloudConnectionState.CLOUD_DISCONNECTED:
180  await unregister_webhook(None)
181  entry.async_on_unload(async_call_later(hass, 30, register_webhook))
182 
183  if cloud.async_active_subscription(hass):
184  if cloud.async_is_connected(hass):
185  await register_webhook(None)
186  entry.async_on_unload(
187  cloud.async_listen_connection_change(hass, manage_cloudhook)
188  )
189  else:
190  entry.async_on_unload(async_at_started(hass, register_webhook))
191 
192  hass.services.async_register(DOMAIN, "register_webhook", register_webhook)
193  hass.services.async_register(DOMAIN, "unregister_webhook", unregister_webhook)
194 
195  entry.async_on_unload(entry.add_update_listener(async_config_entry_updated))
196 
197  return True
198 
199 
200 async def async_cloudhook_generate_url(hass: HomeAssistant, entry: ConfigEntry) -> str:
201  """Generate the full URL for a webhook_id."""
202  if CONF_CLOUDHOOK_URL not in entry.data:
203  webhook_url = await cloud.async_create_cloudhook(
204  hass, entry.data[CONF_WEBHOOK_ID]
205  )
206  data = {**entry.data, CONF_CLOUDHOOK_URL: webhook_url}
207  hass.config_entries.async_update_entry(entry, data=data)
208  return webhook_url
209  return str(entry.data[CONF_CLOUDHOOK_URL])
210 
211 
212 async def async_config_entry_updated(hass: HomeAssistant, entry: ConfigEntry) -> None:
213  """Handle signals of config entry being updated."""
214  async_dispatcher_send(hass, f"signal-{DOMAIN}-public-update-{entry.entry_id}")
215 
216 
217 async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
218  """Unload a config entry."""
219  data = hass.data[DOMAIN]
220 
221  if CONF_WEBHOOK_ID in entry.data:
222  webhook_unregister(hass, entry.data[CONF_WEBHOOK_ID])
223  try:
224  await data[entry.entry_id][AUTH].async_dropwebhook()
225  except pyatmo.ApiError:
226  _LOGGER.debug("No webhook to be dropped")
227  _LOGGER.debug("Unregister Netatmo webhook")
228 
229  unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
230 
231  if unload_ok and entry.entry_id in data:
232  data.pop(entry.entry_id)
233 
234  return unload_ok
235 
236 
237 async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
238  """Cleanup when entry is removed."""
239  if CONF_WEBHOOK_ID in entry.data and cloud.async_active_subscription(hass):
240  try:
241  _LOGGER.debug(
242  "Removing Netatmo cloudhook (%s)", entry.data[CONF_WEBHOOK_ID]
243  )
244  await cloud.async_delete_cloudhook(hass, entry.data[CONF_WEBHOOK_ID])
246  pass
247 
248 
250  hass: HomeAssistant, config_entry: ConfigEntry, device_entry: DeviceEntry
251 ) -> bool:
252  """Remove a config entry from a device."""
253  data = hass.data[DOMAIN][config_entry.entry_id][DATA_HANDLER]
254  modules = [m for h in data.account.homes.values() for m in h.modules]
255  rooms = [r for h in data.account.homes.values() for r in h.rooms]
256 
257  return not any(
258  identifier
259  for identifier in device_entry.identifiers
260  if identifier[0] == DOMAIN
261  and identifier[1] in modules
262  or identifier[1] in rooms
263  )
str async_cloudhook_generate_url(HomeAssistant hass, ConfigEntry entry)
Definition: __init__.py:200
bool async_setup(HomeAssistant hass, ConfigType config)
Definition: __init__.py:60
bool async_remove_config_entry_device(HomeAssistant hass, ConfigEntry config_entry, DeviceEntry device_entry)
Definition: __init__.py:251
None async_remove_entry(HomeAssistant hass, ConfigEntry entry)
Definition: __init__.py:237
None async_config_entry_updated(HomeAssistant hass, ConfigEntry entry)
Definition: __init__.py:212
bool async_unload_entry(HomeAssistant hass, ConfigEntry entry)
Definition: __init__.py:217
bool async_setup_entry(HomeAssistant hass, ConfigEntry entry)
Definition: __init__.py:74
None async_dispatcher_send(HomeAssistant hass, str signal, *Any args)
Definition: dispatcher.py:193
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
CALLBACK_TYPE async_at_started(HomeAssistant hass, Callable[[HomeAssistant], Coroutine[Any, Any, None]|None] at_start_cb)
Definition: start.py:80