Home Assistant Unofficial Reference 2024.12.1
__init__.py
Go to the documentation of this file.
1 """Component for interacting with a Lutron Caseta system."""
2 
3 from __future__ import annotations
4 
5 import asyncio
6 import contextlib
7 from itertools import chain
8 import logging
9 import ssl
10 from typing import Any, cast
11 
12 from pylutron_caseta import BUTTON_STATUS_PRESSED
13 from pylutron_caseta.smartbridge import Smartbridge
14 import voluptuous as vol
15 
16 from homeassistant import config_entries
17 from homeassistant.const import ATTR_DEVICE_ID, CONF_HOST, Platform
18 from homeassistant.core import HomeAssistant, callback
19 from homeassistant.exceptions import ConfigEntryNotReady
20 from homeassistant.helpers import device_registry as dr, entity_registry as er
22 from homeassistant.helpers.device_registry import DeviceInfo
23 from homeassistant.helpers.typing import ConfigType
24 
25 from .const import (
26  ACTION_PRESS,
27  ACTION_RELEASE,
28  ATTR_ACTION,
29  ATTR_AREA_NAME,
30  ATTR_BUTTON_NUMBER,
31  ATTR_BUTTON_TYPE,
32  ATTR_DEVICE_NAME,
33  ATTR_LEAP_BUTTON_NUMBER,
34  ATTR_SERIAL,
35  ATTR_TYPE,
36  BRIDGE_DEVICE_ID,
37  BRIDGE_TIMEOUT,
38  CONF_CA_CERTS,
39  CONF_CERTFILE,
40  CONF_KEYFILE,
41  CONF_SUBTYPE,
42  DOMAIN,
43  LUTRON_CASETA_BUTTON_EVENT,
44  MANUFACTURER,
45  UNASSIGNED_AREA,
46 )
47 from .device_trigger import (
48  DEVICE_TYPE_SUBTYPE_MAP_TO_LIP,
49  KEYPAD_LEAP_BUTTON_NAME_OVERRIDE,
50  LEAP_TO_DEVICE_TYPE_SUBTYPE_MAP,
51  LUTRON_BUTTON_TRIGGER_SCHEMA,
52 )
53 from .models import (
54  LUTRON_BUTTON_LEAP_BUTTON_NUMBER,
55  LUTRON_KEYPAD_AREA_NAME,
56  LUTRON_KEYPAD_BUTTONS,
57  LUTRON_KEYPAD_DEVICE_REGISTRY_DEVICE_ID,
58  LUTRON_KEYPAD_LUTRON_DEVICE_ID,
59  LUTRON_KEYPAD_MODEL,
60  LUTRON_KEYPAD_NAME,
61  LUTRON_KEYPAD_SERIAL,
62  LUTRON_KEYPAD_TYPE,
63  LutronButton,
64  LutronCasetaConfigEntry,
65  LutronCasetaData,
66  LutronKeypad,
67  LutronKeypadData,
68 )
69 from .util import area_name_from_id, serial_to_unique_id
70 
71 _LOGGER = logging.getLogger(__name__)
72 
73 DATA_BRIDGE_CONFIG = "lutron_caseta_bridges"
74 
75 CONFIG_SCHEMA = vol.Schema(
76  {
77  DOMAIN: vol.All(
78  cv.ensure_list,
79  [
80  {
81  vol.Required(CONF_HOST): cv.string,
82  vol.Required(CONF_KEYFILE): cv.string,
83  vol.Required(CONF_CERTFILE): cv.string,
84  vol.Required(CONF_CA_CERTS): cv.string,
85  }
86  ],
87  )
88  },
89  extra=vol.ALLOW_EXTRA,
90 )
91 
92 PLATFORMS = [
93  Platform.BINARY_SENSOR,
94  Platform.BUTTON,
95  Platform.COVER,
96  Platform.FAN,
97  Platform.LIGHT,
98  Platform.SCENE,
99  Platform.SWITCH,
100 ]
101 
102 
103 async def async_setup(hass: HomeAssistant, base_config: ConfigType) -> bool:
104  """Set up the Lutron component."""
105  if DOMAIN in base_config:
106  bridge_configs = base_config[DOMAIN]
107  for config in bridge_configs:
108  hass.async_create_task(
109  hass.config_entries.flow.async_init(
110  DOMAIN,
111  context={"source": config_entries.SOURCE_IMPORT},
112  # extract the config keys one-by-one just to be explicit
113  data={
114  CONF_HOST: config[CONF_HOST],
115  CONF_KEYFILE: config[CONF_KEYFILE],
116  CONF_CERTFILE: config[CONF_CERTFILE],
117  CONF_CA_CERTS: config[CONF_CA_CERTS],
118  },
119  )
120  )
121 
122  return True
123 
124 
126  hass: HomeAssistant, entry: LutronCasetaConfigEntry
127 ) -> None:
128  """Migrate entities since the occupancygroup were not actually unique."""
129 
130  dev_reg = dr.async_get(hass)
131  bridge_unique_id = entry.unique_id
132 
133  @callback
134  def _async_migrator(entity_entry: er.RegistryEntry) -> dict[str, Any] | None:
135  if not (unique_id := entity_entry.unique_id):
136  return None
137  if not unique_id.startswith("occupancygroup_") or unique_id.startswith(
138  f"occupancygroup_{bridge_unique_id}"
139  ):
140  return None
141  sensor_id = unique_id.split("_")[1]
142  new_unique_id = f"occupancygroup_{bridge_unique_id}_{sensor_id}"
143  if dev_entry := dev_reg.async_get_device(identifiers={(DOMAIN, unique_id)}):
144  dev_reg.async_update_device(
145  dev_entry.id, new_identifiers={(DOMAIN, new_unique_id)}
146  )
147  return {"new_unique_id": f"occupancygroup_{bridge_unique_id}_{sensor_id}"}
148 
149  await er.async_migrate_entries(hass, entry.entry_id, _async_migrator)
150 
151 
153  hass: HomeAssistant, entry: LutronCasetaConfigEntry
154 ) -> bool:
155  """Set up a bridge from a config entry."""
156  entry_id = entry.entry_id
157  host = entry.data[CONF_HOST]
158  keyfile = hass.config.path(entry.data[CONF_KEYFILE])
159  certfile = hass.config.path(entry.data[CONF_CERTFILE])
160  ca_certs = hass.config.path(entry.data[CONF_CA_CERTS])
161  bridge = None
162 
163  try:
164  bridge = Smartbridge.create_tls(
165  hostname=host, keyfile=keyfile, certfile=certfile, ca_certs=ca_certs
166  )
167  except ssl.SSLError:
168  _LOGGER.error("Invalid certificate used to connect to bridge at %s", host)
169  return False
170 
171  timed_out = True
172  with contextlib.suppress(TimeoutError):
173  async with asyncio.timeout(BRIDGE_TIMEOUT):
174  await bridge.connect()
175  timed_out = False
176 
177  if timed_out or not bridge.is_connected():
178  await bridge.close()
179  if timed_out:
180  raise ConfigEntryNotReady(f"Timed out while trying to connect to {host}")
181  if not bridge.is_connected():
182  raise ConfigEntryNotReady(f"Cannot connect to {host}")
183 
184  _LOGGER.debug("Connected to Lutron Caseta bridge via LEAP at %s", host)
185  await _async_migrate_unique_ids(hass, entry)
186 
187  bridge_devices = bridge.get_devices()
188  bridge_device = bridge_devices[BRIDGE_DEVICE_ID]
189 
190  if not entry.unique_id:
191  hass.config_entries.async_update_entry(
192  entry, unique_id=serial_to_unique_id(bridge_device["serial"])
193  )
194 
195  _async_register_bridge_device(hass, entry_id, bridge_device, bridge)
196 
197  keypad_data = _async_setup_keypads(hass, entry_id, bridge, bridge_device)
198 
199  # Store this bridge (keyed by entry_id) so it can be retrieved by the
200  # platforms we're setting up.
201 
202  entry.runtime_data = LutronCasetaData(bridge, bridge_device, keypad_data)
203 
204  await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
205 
206  return True
207 
208 
209 @callback
211  hass: HomeAssistant, config_entry_id: str, bridge_device: dict, bridge: Smartbridge
212 ) -> None:
213  """Register the bridge device in the device registry."""
214  device_registry = dr.async_get(hass)
215 
216  device_args = DeviceInfo(
217  name=bridge_device["name"],
218  manufacturer=MANUFACTURER,
219  identifiers={(DOMAIN, bridge_device["serial"])},
220  model=f"{bridge_device['model']} ({bridge_device['type']})",
221  via_device=(DOMAIN, bridge_device["serial"]),
222  configuration_url="https://device-login.lutron.com",
223  )
224 
225  area = area_name_from_id(bridge.areas, bridge_device["area"])
226  if area != UNASSIGNED_AREA:
227  device_args["suggested_area"] = area
228 
229  device_registry.async_get_or_create(**device_args, config_entry_id=config_entry_id)
230 
231 
232 @callback
234  hass: HomeAssistant,
235  config_entry_id: str,
236  bridge: Smartbridge,
237  bridge_device: dict[str, str | int],
238 ) -> LutronKeypadData:
239  """Register keypad devices (Keypads and Pico Remotes) in the device registry."""
240 
241  device_registry = dr.async_get(hass)
242 
243  bridge_devices: dict[str, dict[str, str | int]] = bridge.get_devices()
244  bridge_buttons: dict[str, dict[str, str | int]] = bridge.buttons
245 
246  dr_device_id_to_keypad: dict[str, LutronKeypad] = {}
247  keypads: dict[int, LutronKeypad] = {}
248  keypad_buttons: dict[int, LutronButton] = {}
249  keypad_button_names_to_leap: dict[int, dict[str, int]] = {}
250  leap_to_keypad_button_names: dict[int, dict[int, str]] = {}
251 
252  for bridge_button in bridge_buttons.values():
253  parent_device = cast(str, bridge_button["parent_device"])
254  bridge_keypad = bridge_devices[parent_device]
255  keypad_lutron_device_id = cast(int, bridge_keypad["device_id"])
256  button_lutron_device_id = cast(int, bridge_button["device_id"])
257  leap_button_number = cast(int, bridge_button["button_number"])
258  button_led_device_id = None
259  if "button_led" in bridge_button:
260  button_led_device_id = cast(str, bridge_button["button_led"])
261 
262  if not (keypad := keypads.get(keypad_lutron_device_id)):
263  # First time seeing this keypad, build keypad data and store in keypads
264  keypad = keypads[keypad_lutron_device_id] = _async_build_lutron_keypad(
265  bridge, bridge_device, bridge_keypad, keypad_lutron_device_id
266  )
267 
268  # Register the keypad device
269  dr_device = device_registry.async_get_or_create(
270  **keypad["device_info"], config_entry_id=config_entry_id
271  )
272  keypad[LUTRON_KEYPAD_DEVICE_REGISTRY_DEVICE_ID] = dr_device.id
273  dr_device_id_to_keypad[dr_device.id] = keypad
274 
275  button_name = _get_button_name(keypad, bridge_button)
276  keypad_lutron_device_id = keypad[LUTRON_KEYPAD_LUTRON_DEVICE_ID]
277 
278  # Add button to parent keypad, and build keypad_buttons and keypad_button_names_to_leap
279  keypad_buttons[button_lutron_device_id] = LutronButton(
280  lutron_device_id=button_lutron_device_id,
281  leap_button_number=leap_button_number,
282  button_name=button_name,
283  led_device_id=button_led_device_id,
284  parent_keypad=keypad_lutron_device_id,
285  )
286 
287  keypad[LUTRON_KEYPAD_BUTTONS].append(button_lutron_device_id)
288 
289  button_name_to_leap = keypad_button_names_to_leap.setdefault(
290  keypad_lutron_device_id, {}
291  )
292  button_name_to_leap[button_name] = leap_button_number
293  leap_to_button_name = leap_to_keypad_button_names.setdefault(
294  keypad_lutron_device_id, {}
295  )
296  leap_to_button_name[leap_button_number] = button_name
297 
298  keypad_trigger_schemas = _async_build_trigger_schemas(keypad_button_names_to_leap)
299 
301  hass=hass,
302  bridge=bridge,
303  keypads=keypads,
304  keypad_buttons=keypad_buttons,
305  leap_to_keypad_button_names=leap_to_keypad_button_names,
306  )
307 
308  return LutronKeypadData(
309  dr_device_id_to_keypad,
310  keypads,
311  keypad_buttons,
312  keypad_button_names_to_leap,
313  keypad_trigger_schemas,
314  )
315 
316 
317 @callback
319  keypad_button_names_to_leap: dict[int, dict[str, int]],
320 ) -> dict[int, vol.Schema]:
321  """Build device trigger schemas."""
322 
323  return {
324  keypad_id: LUTRON_BUTTON_TRIGGER_SCHEMA.extend(
325  {
326  vol.Required(CONF_SUBTYPE): vol.In(
327  keypad_button_names_to_leap[keypad_id]
328  ),
329  }
330  )
331  for keypad_id in keypad_button_names_to_leap
332  }
333 
334 
335 @callback
337  bridge: Smartbridge,
338  bridge_device: dict[str, Any],
339  bridge_keypad: dict[str, Any],
340  keypad_device_id: int,
341 ) -> LutronKeypad:
342  # First time seeing this keypad, build keypad data and store in keypads
343  area_name = area_name_from_id(bridge.areas, bridge_keypad["area"])
344  keypad_name = bridge_keypad["name"].split("_")[-1]
345  keypad_serial = _handle_none_keypad_serial(bridge_keypad, bridge_device["serial"])
346  device_info = DeviceInfo(
347  name=f"{area_name} {keypad_name}",
348  manufacturer=MANUFACTURER,
349  identifiers={(DOMAIN, keypad_serial)},
350  model=f"{bridge_keypad['model']} ({bridge_keypad['type']})",
351  via_device=(DOMAIN, bridge_device["serial"]),
352  )
353  if area_name != UNASSIGNED_AREA:
354  device_info["suggested_area"] = area_name
355 
356  return LutronKeypad(
357  lutron_device_id=keypad_device_id,
358  dr_device_id="",
359  area_id=bridge_keypad["area"],
360  area_name=area_name,
361  name=keypad_name,
362  serial=keypad_serial,
363  device_info=device_info,
364  model=bridge_keypad["model"],
365  type=bridge_keypad["type"],
366  buttons=[],
367  )
368 
369 
370 def _get_button_name(keypad: LutronKeypad, bridge_button: dict[str, Any]) -> str:
371  """Get the LEAP button name and check for override."""
372 
373  button_number = bridge_button["button_number"]
374  button_name = bridge_button.get("device_name")
375 
376  if button_name is None:
377  # This is a Caseta Button retrieve name from hardcoded trigger definitions.
378  return _get_button_name_from_triggers(keypad, button_number)
379 
380  keypad_model = keypad[LUTRON_KEYPAD_MODEL]
381  if keypad_model_override := KEYPAD_LEAP_BUTTON_NAME_OVERRIDE.get(keypad_model):
382  if alt_button_name := keypad_model_override.get(button_number):
383  return alt_button_name
384 
385  return button_name
386 
387 
388 def _get_button_name_from_triggers(keypad: LutronKeypad, button_number: int) -> str:
389  """Retrieve the caseta button name from device triggers."""
390  button_number_map = LEAP_TO_DEVICE_TYPE_SUBTYPE_MAP.get(keypad["type"], {})
391  return (
392  button_number_map.get(
393  button_number,
394  f"button {button_number}",
395  )
396  .replace("_", " ")
397  .title()
398  )
399 
400 
401 def _handle_none_keypad_serial(keypad_device: dict, bridge_serial: int) -> str:
402  return keypad_device["serial"] or f"{bridge_serial}_{keypad_device['device_id']}"
403 
404 
405 @callback
406 def async_get_lip_button(device_type: str, leap_button: int) -> int | None:
407  """Get the LIP button for a given LEAP button."""
408  if (
409  lip_buttons_name_to_num := DEVICE_TYPE_SUBTYPE_MAP_TO_LIP.get(device_type)
410  ) is None or (
411  leap_button_num_to_name := LEAP_TO_DEVICE_TYPE_SUBTYPE_MAP.get(device_type)
412  ) is None:
413  return None
414  return lip_buttons_name_to_num[leap_button_num_to_name[leap_button]]
415 
416 
417 @callback
419  hass: HomeAssistant,
420  bridge: Smartbridge,
421  keypads: dict[int, LutronKeypad],
422  keypad_buttons: dict[int, LutronButton],
423  leap_to_keypad_button_names: dict[int, dict[int, str]],
424 ):
425  """Subscribe to lutron events."""
426 
427  @callback
428  def _async_button_event(button_id, event_type):
429  if not (button := keypad_buttons.get(button_id)) or not (
430  keypad := keypads.get(button["parent_keypad"])
431  ):
432  return
433 
434  if event_type == BUTTON_STATUS_PRESSED:
435  action = ACTION_PRESS
436  else:
437  action = ACTION_RELEASE
438 
439  keypad_type = keypad[LUTRON_KEYPAD_TYPE]
440  keypad_device_id = keypad[LUTRON_KEYPAD_LUTRON_DEVICE_ID]
441  leap_button_number = button[LUTRON_BUTTON_LEAP_BUTTON_NUMBER]
442  lip_button_number = async_get_lip_button(keypad_type, leap_button_number)
443  button_type = LEAP_TO_DEVICE_TYPE_SUBTYPE_MAP.get(
444  keypad_type, leap_to_keypad_button_names[keypad_device_id]
445  )[leap_button_number]
446 
447  hass.bus.async_fire(
448  LUTRON_CASETA_BUTTON_EVENT,
449  {
450  ATTR_SERIAL: keypad[LUTRON_KEYPAD_SERIAL],
451  ATTR_TYPE: keypad_type,
452  ATTR_BUTTON_NUMBER: lip_button_number,
453  ATTR_LEAP_BUTTON_NUMBER: leap_button_number,
454  ATTR_DEVICE_NAME: keypad[LUTRON_KEYPAD_NAME],
455  ATTR_DEVICE_ID: keypad[LUTRON_KEYPAD_DEVICE_REGISTRY_DEVICE_ID],
456  ATTR_AREA_NAME: keypad[LUTRON_KEYPAD_AREA_NAME],
457  ATTR_BUTTON_TYPE: button_type,
458  ATTR_ACTION: action,
459  },
460  )
461 
462  for button_id in keypad_buttons:
463  bridge.add_button_subscriber(
464  str(button_id),
465  lambda event_type, button_id=button_id: _async_button_event(
466  button_id, event_type
467  ),
468  )
469 
470 
472  hass: HomeAssistant, entry: LutronCasetaConfigEntry
473 ) -> bool:
474  """Unload the bridge from a config entry."""
475  data = entry.runtime_data
476  await data.bridge.close()
477  return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
478 
479 
480 def _id_to_identifier(lutron_id: str) -> tuple[str, str]:
481  """Convert a lutron caseta identifier to a device identifier."""
482  return (DOMAIN, lutron_id)
483 
484 
486  hass: HomeAssistant, entry: LutronCasetaConfigEntry, device_entry: dr.DeviceEntry
487 ) -> bool:
488  """Remove lutron_caseta config entry from a device."""
489  data = entry.runtime_data
490  bridge = data.bridge
491  devices = bridge.get_devices()
492  buttons = bridge.buttons
493  occupancy_groups = bridge.occupancy_groups
494  bridge_device = devices[BRIDGE_DEVICE_ID]
495  bridge_unique_id = serial_to_unique_id(bridge_device["serial"])
496  all_identifiers: set[tuple[str, str]] = {
497  # Base bridge
498  _id_to_identifier(bridge_unique_id),
499  # Motion sensors and occupancy groups
500  *(
502  f"occupancygroup_{bridge_unique_id}_{device['occupancy_group_id']}"
503  )
504  for device in occupancy_groups.values()
505  ),
506  # Button devices such as pico remotes and all other devices
507  *(
508  _id_to_identifier(device["serial"])
509  for device in chain(devices.values(), buttons.values())
510  ),
511  }
512  return not any(
513  identifier
514  for identifier in device_entry.identifiers
515  if identifier in all_identifiers
516  )
str area_name_from_id(dict[str, dict] areas, str|None area_id)
Definition: util.py:13
str _handle_none_keypad_serial(dict keypad_device, int bridge_serial)
Definition: __init__.py:401
bool async_setup_entry(HomeAssistant hass, LutronCasetaConfigEntry entry)
Definition: __init__.py:154
str _get_button_name_from_triggers(LutronKeypad keypad, int button_number)
Definition: __init__.py:388
bool async_unload_entry(HomeAssistant hass, LutronCasetaConfigEntry entry)
Definition: __init__.py:473
bool async_remove_config_entry_device(HomeAssistant hass, LutronCasetaConfigEntry entry, dr.DeviceEntry device_entry)
Definition: __init__.py:487
None _async_migrate_unique_ids(HomeAssistant hass, LutronCasetaConfigEntry entry)
Definition: __init__.py:127
tuple[str, str] _id_to_identifier(str lutron_id)
Definition: __init__.py:480
LutronKeypadData _async_setup_keypads(HomeAssistant hass, str config_entry_id, Smartbridge bridge, dict[str, str|int] bridge_device)
Definition: __init__.py:238
LutronKeypad _async_build_lutron_keypad(Smartbridge bridge, dict[str, Any] bridge_device, dict[str, Any] bridge_keypad, int keypad_device_id)
Definition: __init__.py:341
def _async_subscribe_keypad_events(HomeAssistant hass, Smartbridge bridge, dict[int, LutronKeypad] keypads, dict[int, LutronButton] keypad_buttons, dict[int, dict[int, str]] leap_to_keypad_button_names)
Definition: __init__.py:424
str _get_button_name(LutronKeypad keypad, dict[str, Any] bridge_button)
Definition: __init__.py:370
int|None async_get_lip_button(str device_type, int leap_button)
Definition: __init__.py:406
None _async_register_bridge_device(HomeAssistant hass, str config_entry_id, dict bridge_device, Smartbridge bridge)
Definition: __init__.py:212
dict[int, vol.Schema] _async_build_trigger_schemas(dict[int, dict[str, int]] keypad_button_names_to_leap)
Definition: __init__.py:320
bool async_setup(HomeAssistant hass, ConfigType base_config)
Definition: __init__.py:103