Home Assistant Unofficial Reference 2024.12.1
__init__.py
Go to the documentation of this file.
1 """The Hyperion component."""
2 
3 from __future__ import annotations
4 
5 import asyncio
6 from collections.abc import Callable
7 from contextlib import suppress
8 import logging
9 from typing import Any, cast
10 
11 from awesomeversion import AwesomeVersion
12 from hyperion import client, const as hyperion_const
13 
14 from homeassistant.config_entries import ConfigEntry
15 from homeassistant.const import CONF_HOST, CONF_PORT, CONF_TOKEN, Platform
16 from homeassistant.core import HomeAssistant, callback
17 from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
18 from homeassistant.helpers import device_registry as dr
20  async_dispatcher_connect,
21  async_dispatcher_send,
22 )
23 
24 from .const import (
25  CONF_INSTANCE_CLIENTS,
26  CONF_ON_UNLOAD,
27  CONF_ROOT_CLIENT,
28  DEFAULT_NAME,
29  DOMAIN,
30  HYPERION_RELEASES_URL,
31  HYPERION_VERSION_WARN_CUTOFF,
32  SIGNAL_INSTANCE_ADD,
33  SIGNAL_INSTANCE_REMOVE,
34 )
35 
36 PLATFORMS = [Platform.CAMERA, Platform.LIGHT, Platform.SENSOR, Platform.SWITCH]
37 
38 _LOGGER = logging.getLogger(__name__)
39 
40 # Unique ID
41 # =========
42 # A config entry represents a connection to a single Hyperion server. The config entry
43 # unique_id is the server id returned from the Hyperion instance (a unique ID per
44 # server).
45 #
46 # Each server connection may create multiple entities. The unique_id for each entity is
47 # <server id>_<instance #>_<name>, where <server_id> will be the unique_id on the
48 # relevant config entry (as above), <instance #> will be the server instance # and
49 # <name> will be a unique identifying type name for each entity associated with this
50 # server/instance (e.g. "hyperion_light").
51 #
52 # The get_hyperion_unique_id method will create a per-entity unique id when given the
53 # server id, an instance number and a name.
54 
55 # hass.data format
56 # ================
57 #
58 # hass.data[DOMAIN] = {
59 # <config_entry.entry_id>: {
60 # "ROOT_CLIENT": <Hyperion Client>,
61 # "ON_UNLOAD": [<callable>, ...],
62 # }
63 # }
64 
65 
66 def get_hyperion_unique_id(server_id: str, instance: int, name: str) -> str:
67  """Get a unique_id for a Hyperion instance."""
68  return f"{server_id}_{instance}_{name}"
69 
70 
71 def get_hyperion_device_id(server_id: str, instance: int) -> str:
72  """Get an id for a Hyperion device/instance."""
73  return f"{server_id}_{instance}"
74 
75 
76 def split_hyperion_unique_id(unique_id: str) -> tuple[str, int, str] | None:
77  """Split a unique_id into a (server_id, instance, type) tuple."""
78  data = tuple(unique_id.split("_", 2))
79  if len(data) != 3:
80  return None
81  try:
82  return (data[0], int(data[1]), data[2])
83  except ValueError:
84  return None
85 
86 
88  *args: Any,
89  **kwargs: Any,
90 ) -> client.HyperionClient:
91  """Create a Hyperion Client."""
92  return client.HyperionClient(*args, **kwargs)
93 
94 
96  *args: Any,
97  **kwargs: Any,
98 ) -> client.HyperionClient | None:
99  """Create and connect a Hyperion Client."""
100  hyperion_client = create_hyperion_client(*args, **kwargs)
101 
102  if not await hyperion_client.async_client_connect():
103  return None
104  return hyperion_client
105 
106 
107 @callback
109  hass: HomeAssistant,
110  config_entry: ConfigEntry,
111  add_func: Callable,
112  remove_func: Callable,
113 ) -> None:
114  """Listen for instance additions/removals."""
115 
116  hass.data[DOMAIN][config_entry.entry_id][CONF_ON_UNLOAD].extend(
117  [
119  hass,
120  SIGNAL_INSTANCE_ADD.format(config_entry.entry_id),
121  add_func,
122  ),
124  hass,
125  SIGNAL_INSTANCE_REMOVE.format(config_entry.entry_id),
126  remove_func,
127  ),
128  ]
129  )
130 
131 
132 async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
133  """Set up Hyperion from a config entry."""
134  host = entry.data[CONF_HOST]
135  port = entry.data[CONF_PORT]
136  token = entry.data.get(CONF_TOKEN)
137 
138  hyperion_client = await async_create_connect_hyperion_client(
139  host, port, token=token, raw_connection=True
140  )
141 
142  # Client won't connect? => Not ready.
143  if not hyperion_client:
144  raise ConfigEntryNotReady
145  version = await hyperion_client.async_sysinfo_version()
146  if version is not None:
147  with suppress(ValueError):
148  if AwesomeVersion(version) < AwesomeVersion(HYPERION_VERSION_WARN_CUTOFF):
149  _LOGGER.warning(
150  (
151  "Using a Hyperion server version < %s is not recommended --"
152  " some features may be unavailable or may not function"
153  " correctly. Please consider upgrading: %s"
154  ),
155  HYPERION_VERSION_WARN_CUTOFF,
156  HYPERION_RELEASES_URL,
157  )
158 
159  # Client needs authentication, but no token provided? => Reauth.
160  auth_resp = await hyperion_client.async_is_auth_required()
161  if (
162  auth_resp is not None
163  and client.ResponseOK(auth_resp)
164  and auth_resp.get(hyperion_const.KEY_INFO, {}).get(
165  hyperion_const.KEY_REQUIRED, False
166  )
167  and token is None
168  ):
169  await hyperion_client.async_client_disconnect()
170  raise ConfigEntryAuthFailed
171 
172  # Client login doesn't work? => Reauth.
173  if not await hyperion_client.async_client_login():
174  await hyperion_client.async_client_disconnect()
175  raise ConfigEntryAuthFailed
176 
177  # Cannot switch instance or cannot load state? => Not ready.
178  if (
179  not await hyperion_client.async_client_switch_instance()
180  or not client.ServerInfoResponseOK(await hyperion_client.async_get_serverinfo())
181  ):
182  await hyperion_client.async_client_disconnect()
183  raise ConfigEntryNotReady
184 
185  # We need 1 root client (to manage instances being removed/added) and then 1 client
186  # per Hyperion server instance which is shared for all entities associated with
187  # that instance.
188  hass.data.setdefault(DOMAIN, {})
189  hass.data[DOMAIN][entry.entry_id] = {
190  CONF_ROOT_CLIENT: hyperion_client,
191  CONF_INSTANCE_CLIENTS: {},
192  CONF_ON_UNLOAD: [],
193  }
194 
195  async def async_instances_to_clients(response: dict[str, Any]) -> None:
196  """Convert instances to Hyperion clients."""
197  if not response or hyperion_const.KEY_DATA not in response:
198  return
199  await async_instances_to_clients_raw(response[hyperion_const.KEY_DATA])
200 
201  async def async_instances_to_clients_raw(instances: list[dict[str, Any]]) -> None:
202  """Convert instances to Hyperion clients."""
203  device_registry = dr.async_get(hass)
204  running_instances: set[int] = set()
205  stopped_instances: set[int] = set()
206  existing_instances = hass.data[DOMAIN][entry.entry_id][CONF_INSTANCE_CLIENTS]
207  server_id = cast(str, entry.unique_id)
208 
209  # In practice, an instance can be in 3 states as seen by this function:
210  #
211  # * Exists, and is running: Should be present in HASS/registry.
212  # * Exists, but is not running: Cannot add it yet, but entity may have be
213  # registered from a previous time it was running.
214  # * No longer exists at all: Should not be present in HASS/registry.
215 
216  # Add instances that are missing.
217  for instance in instances:
218  instance_num = instance.get(hyperion_const.KEY_INSTANCE)
219  if instance_num is None:
220  continue
221  if not instance.get(hyperion_const.KEY_RUNNING, False):
222  stopped_instances.add(instance_num)
223  continue
224  running_instances.add(instance_num)
225  if instance_num in existing_instances:
226  continue
227  hyperion_client = await async_create_connect_hyperion_client(
228  host, port, instance=instance_num, token=token
229  )
230  if not hyperion_client:
231  continue
232  existing_instances[instance_num] = hyperion_client
233  instance_name = instance.get(hyperion_const.KEY_FRIENDLY_NAME, DEFAULT_NAME)
235  hass,
236  SIGNAL_INSTANCE_ADD.format(entry.entry_id),
237  instance_num,
238  instance_name,
239  )
240 
241  # Remove entities that are not running instances on Hyperion.
242  for instance_num in set(existing_instances) - running_instances:
243  del existing_instances[instance_num]
245  hass, SIGNAL_INSTANCE_REMOVE.format(entry.entry_id), instance_num
246  )
247 
248  # Ensure every device associated with this config entry is still in the list of
249  # motionEye cameras, otherwise remove the device (and thus entities).
250  known_devices = {
251  get_hyperion_device_id(server_id, instance_num)
252  for instance_num in running_instances | stopped_instances
253  }
254  for device_entry in dr.async_entries_for_config_entry(
255  device_registry, entry.entry_id
256  ):
257  for kind, key in device_entry.identifiers:
258  if kind == DOMAIN and key in known_devices:
259  break
260  else:
261  device_registry.async_remove_device(device_entry.id)
262 
263  hyperion_client.set_callbacks(
264  {
265  f"{hyperion_const.KEY_INSTANCE}-{hyperion_const.KEY_UPDATE}": async_instances_to_clients,
266  }
267  )
268 
269  await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
270  assert hyperion_client
271  if hyperion_client.instances is not None:
272  await async_instances_to_clients_raw(hyperion_client.instances)
273  hass.data[DOMAIN][entry.entry_id][CONF_ON_UNLOAD].append(
274  entry.add_update_listener(_async_entry_updated)
275  )
276 
277  return True
278 
279 
280 async def _async_entry_updated(hass: HomeAssistant, config_entry: ConfigEntry) -> None:
281  """Handle entry updates."""
282  await hass.config_entries.async_reload(config_entry.entry_id)
283 
284 
285 async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
286  """Unload a config entry."""
287  unload_ok = await hass.config_entries.async_unload_platforms(
288  config_entry, PLATFORMS
289  )
290  if unload_ok and config_entry.entry_id in hass.data[DOMAIN]:
291  config_data = hass.data[DOMAIN].pop(config_entry.entry_id)
292  for func in config_data[CONF_ON_UNLOAD]:
293  func()
294 
295  # Disconnect the shared instance clients.
296  await asyncio.gather(
297  *(
298  config_data[CONF_INSTANCE_CLIENTS][
299  instance_num
300  ].async_client_disconnect()
301  for instance_num in config_data[CONF_INSTANCE_CLIENTS]
302  )
303  )
304 
305  # Disconnect the root client.
306  root_client = config_data[CONF_ROOT_CLIENT]
307  await root_client.async_client_disconnect()
308  return unload_ok
web.Response get(self, web.Request request, str config_key)
Definition: view.py:88
tuple[str, int, str]|None split_hyperion_unique_id(str unique_id)
Definition: __init__.py:76
client.HyperionClient|None async_create_connect_hyperion_client(*Any args, **Any kwargs)
Definition: __init__.py:98
str get_hyperion_device_id(str server_id, int instance)
Definition: __init__.py:71
client.HyperionClient create_hyperion_client(*Any args, **Any kwargs)
Definition: __init__.py:90
str get_hyperion_unique_id(str server_id, int instance, str name)
Definition: __init__.py:66
None listen_for_instance_updates(HomeAssistant hass, ConfigEntry config_entry, Callable add_func, Callable remove_func)
Definition: __init__.py:113
bool async_unload_entry(HomeAssistant hass, ConfigEntry config_entry)
Definition: __init__.py:285
bool async_setup_entry(HomeAssistant hass, ConfigEntry entry)
Definition: __init__.py:132
None _async_entry_updated(HomeAssistant hass, ConfigEntry config_entry)
Definition: __init__.py:280
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