Home Assistant Unofficial Reference 2024.12.1
__init__.py
Go to the documentation of this file.
1 """Support the ElkM1 Gold and ElkM1 EZ8 alarm/integration panels."""
2 
3 from __future__ import annotations
4 
5 import asyncio
6 import logging
7 import re
8 from types import MappingProxyType
9 from typing import Any
10 
11 from elkm1_lib.elements import Element
12 from elkm1_lib.elk import Elk, Panel
13 from elkm1_lib.util import parse_url
14 import voluptuous as vol
15 
16 from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
17 from homeassistant.const import (
18  CONF_ENABLED,
19  CONF_EXCLUDE,
20  CONF_HOST,
21  CONF_INCLUDE,
22  CONF_PASSWORD,
23  CONF_PREFIX,
24  CONF_TEMPERATURE_UNIT,
25  CONF_USERNAME,
26  CONF_ZONE,
27  Platform,
28  UnitOfTemperature,
29 )
30 from homeassistant.core import HomeAssistant, ServiceCall, callback
31 from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError
32 from homeassistant.helpers import config_validation as cv
33 from homeassistant.helpers.event import async_track_time_interval
34 from homeassistant.helpers.typing import ConfigType
35 import homeassistant.util.dt as dt_util
36 from homeassistant.util.network import is_ip_address
37 
38 from .const import (
39  ATTR_KEY,
40  ATTR_KEY_NAME,
41  ATTR_KEYPAD_ID,
42  ATTR_KEYPAD_NAME,
43  CONF_AREA,
44  CONF_AUTO_CONFIGURE,
45  CONF_COUNTER,
46  CONF_KEYPAD,
47  CONF_OUTPUT,
48  CONF_PLC,
49  CONF_SETTING,
50  CONF_TASK,
51  CONF_THERMOSTAT,
52  DISCOVER_SCAN_TIMEOUT,
53  DISCOVERY_INTERVAL,
54  DOMAIN,
55  ELK_ELEMENTS,
56  EVENT_ELKM1_KEYPAD_KEY_PRESSED,
57  LOGIN_TIMEOUT,
58 )
59 from .discovery import (
60  async_discover_device,
61  async_discover_devices,
62  async_trigger_discovery,
63  async_update_entry_from_discovery,
64 )
65 from .models import ELKM1Data
66 
67 type ElkM1ConfigEntry = ConfigEntry[ELKM1Data]
68 
69 SYNC_TIMEOUT = 120
70 
71 _LOGGER = logging.getLogger(__name__)
72 
73 PLATFORMS = [
74  Platform.ALARM_CONTROL_PANEL,
75  Platform.BINARY_SENSOR,
76  Platform.CLIMATE,
77  Platform.LIGHT,
78  Platform.SCENE,
79  Platform.SENSOR,
80  Platform.SWITCH,
81 ]
82 
83 SPEAK_SERVICE_SCHEMA = vol.Schema(
84  {
85  vol.Required("number"): vol.All(vol.Coerce(int), vol.Range(min=0, max=999)),
86  vol.Optional("prefix", default=""): cv.string,
87  }
88 )
89 
90 SET_TIME_SERVICE_SCHEMA = vol.Schema(
91  {
92  vol.Optional("prefix", default=""): cv.string,
93  }
94 )
95 
96 
97 def hostname_from_url(url: str) -> str:
98  """Return the hostname from a url."""
99  return parse_url(url)[1]
100 
101 
102 def _host_validator(config: dict[str, str]) -> dict[str, str]:
103  """Validate that a host is properly configured."""
104  if config[CONF_HOST].startswith("elks://"):
105  if CONF_USERNAME not in config or CONF_PASSWORD not in config:
106  raise vol.Invalid("Specify username and password for elks://")
107  elif not config[CONF_HOST].startswith("elk://") and not config[
108  CONF_HOST
109  ].startswith("serial://"):
110  raise vol.Invalid("Invalid host URL")
111  return config
112 
113 
114 def _elk_range_validator(rng: str) -> tuple[int, int]:
115  def _housecode_to_int(val: str) -> int:
116  match = re.search(r"^([a-p])(0[1-9]|1[0-6]|[1-9])$", val.lower())
117  if match:
118  return (ord(match.group(1)) - ord("a")) * 16 + int(match.group(2))
119  raise vol.Invalid("Invalid range")
120 
121  def _elk_value(val: str) -> int:
122  return int(val) if val.isdigit() else _housecode_to_int(val)
123 
124  vals = [s.strip() for s in str(rng).split("-")]
125  start = _elk_value(vals[0])
126  end = start if len(vals) == 1 else _elk_value(vals[1])
127  return (start, end)
128 
129 
130 def _has_all_unique_prefixes(value: list[dict[str, str]]) -> list[dict[str, str]]:
131  """Validate that each m1 configured has a unique prefix.
132 
133  Uniqueness is determined case-independently.
134  """
135  prefixes = [device[CONF_PREFIX] for device in value]
136  schema = vol.Schema(vol.Unique())
137  schema(prefixes)
138  return value
139 
140 
141 DEVICE_SCHEMA_SUBDOMAIN = vol.Schema(
142  {
143  vol.Optional(CONF_ENABLED, default=True): cv.boolean,
144  vol.Optional(CONF_INCLUDE, default=[]): [_elk_range_validator],
145  vol.Optional(CONF_EXCLUDE, default=[]): [_elk_range_validator],
146  }
147 )
148 
149 DEVICE_SCHEMA = vol.All(
150  cv.deprecated(CONF_TEMPERATURE_UNIT),
151  vol.Schema(
152  {
153  vol.Required(CONF_HOST): cv.string,
154  vol.Optional(CONF_PREFIX, default=""): vol.All(cv.string, vol.Lower),
155  vol.Optional(CONF_USERNAME, default=""): cv.string,
156  vol.Optional(CONF_PASSWORD, default=""): cv.string,
157  vol.Optional(CONF_AUTO_CONFIGURE, default=False): cv.boolean,
158  vol.Optional(CONF_TEMPERATURE_UNIT, default="F"): cv.temperature_unit,
159  vol.Optional(CONF_AREA, default={}): DEVICE_SCHEMA_SUBDOMAIN,
160  vol.Optional(CONF_COUNTER, default={}): DEVICE_SCHEMA_SUBDOMAIN,
161  vol.Optional(CONF_KEYPAD, default={}): DEVICE_SCHEMA_SUBDOMAIN,
162  vol.Optional(CONF_OUTPUT, default={}): DEVICE_SCHEMA_SUBDOMAIN,
163  vol.Optional(CONF_PLC, default={}): DEVICE_SCHEMA_SUBDOMAIN,
164  vol.Optional(CONF_SETTING, default={}): DEVICE_SCHEMA_SUBDOMAIN,
165  vol.Optional(CONF_TASK, default={}): DEVICE_SCHEMA_SUBDOMAIN,
166  vol.Optional(CONF_THERMOSTAT, default={}): DEVICE_SCHEMA_SUBDOMAIN,
167  vol.Optional(CONF_ZONE, default={}): DEVICE_SCHEMA_SUBDOMAIN,
168  },
169  ),
170  _host_validator,
171 )
172 
173 CONFIG_SCHEMA = vol.Schema(
174  {DOMAIN: vol.All(cv.ensure_list, [DEVICE_SCHEMA], _has_all_unique_prefixes)},
175  extra=vol.ALLOW_EXTRA,
176 )
177 
178 
179 async def async_setup(hass: HomeAssistant, hass_config: ConfigType) -> bool:
180  """Set up the Elk M1 platform."""
182 
183  async def _async_discovery(*_: Any) -> None:
185  hass, await async_discover_devices(hass, DISCOVER_SCAN_TIMEOUT)
186  )
187 
188  hass.async_create_background_task(_async_discovery(), "elkm1 setup discovery")
190  hass, _async_discovery, DISCOVERY_INTERVAL, cancel_on_shutdown=True
191  )
192 
193  if DOMAIN not in hass_config:
194  return True
195 
196  for index, conf in enumerate(hass_config[DOMAIN]):
197  _LOGGER.debug("Importing elkm1 #%d - %s", index, conf[CONF_HOST])
198 
199  # The update of the config entry is done in async_setup
200  # to ensure the entry if updated before async_setup_entry
201  # is called to avoid a situation where the user has to restart
202  # twice for the changes to take effect
203  current_config_entry = _async_find_matching_config_entry(
204  hass, conf[CONF_PREFIX]
205  )
206  if current_config_entry:
207  # If they alter the yaml config we import the changes
208  # since there currently is no practical way to do an options flow
209  # with the large amount of include/exclude/enabled options that elkm1 has.
210  hass.config_entries.async_update_entry(current_config_entry, data=conf)
211  continue
212 
213  hass.async_create_task(
214  hass.config_entries.flow.async_init(
215  DOMAIN,
216  context={"source": SOURCE_IMPORT},
217  data=conf,
218  )
219  )
220 
221  return True
222 
223 
224 @callback
226  hass: HomeAssistant, prefix: str
227 ) -> ConfigEntry | None:
228  for entry in hass.config_entries.async_entries(DOMAIN):
229  if entry.unique_id == prefix:
230  return entry
231  return None
232 
233 
234 async def async_setup_entry(hass: HomeAssistant, entry: ElkM1ConfigEntry) -> bool:
235  """Set up Elk-M1 Control from a config entry."""
236  conf: MappingProxyType[str, Any] = entry.data
237 
238  host = hostname_from_url(entry.data[CONF_HOST])
239 
240  _LOGGER.debug("Setting up elkm1 %s", conf["host"])
241 
242  if (not entry.unique_id or ":" not in entry.unique_id) and is_ip_address(host):
243  _LOGGER.debug(
244  "Unique id for %s is missing during setup, trying to fill from discovery",
245  host,
246  )
247  if device := await async_discover_device(hass, host):
248  async_update_entry_from_discovery(hass, entry, device)
249 
250  config: dict[str, Any] = {}
251 
252  if not conf[CONF_AUTO_CONFIGURE]:
253  # With elkm1-lib==0.7.16 and later auto configure is available
254  config["panel"] = {"enabled": True, "included": [True]}
255  for item, max_ in ELK_ELEMENTS.items():
256  config[item] = {
257  "enabled": conf[item][CONF_ENABLED],
258  "included": [not conf[item]["include"]] * max_,
259  }
260  try:
261  _included(conf[item]["include"], True, config[item]["included"])
262  _included(conf[item]["exclude"], False, config[item]["included"])
263  except (ValueError, vol.Invalid) as err:
264  _LOGGER.error("Config item: %s; %s", item, err)
265  return False
266 
267  elk = Elk(
268  {
269  "url": conf[CONF_HOST],
270  "userid": conf[CONF_USERNAME],
271  "password": conf[CONF_PASSWORD],
272  }
273  )
274  elk.connect()
275 
276  def _keypad_changed(keypad: Element, changeset: dict[str, Any]) -> None:
277  if (keypress := changeset.get("last_keypress")) is None:
278  return
279 
280  hass.bus.async_fire(
281  EVENT_ELKM1_KEYPAD_KEY_PRESSED,
282  {
283  ATTR_KEYPAD_NAME: keypad.name,
284  ATTR_KEYPAD_ID: keypad.index + 1,
285  ATTR_KEY_NAME: keypress[0],
286  ATTR_KEY: keypress[1],
287  },
288  )
289 
290  for keypad in elk.keypads:
291  keypad.add_callback(_keypad_changed)
292 
293  try:
294  if not await async_wait_for_elk_to_sync(elk, LOGIN_TIMEOUT, SYNC_TIMEOUT):
295  return False
296  except TimeoutError as exc:
297  raise ConfigEntryNotReady(f"Timed out connecting to {conf[CONF_HOST]}") from exc
298 
299  elk_temp_unit = elk.panel.temperature_units
300  if elk_temp_unit == "C":
301  temperature_unit = UnitOfTemperature.CELSIUS
302  else:
303  temperature_unit = UnitOfTemperature.FAHRENHEIT
304  config["temperature_unit"] = temperature_unit
305  prefix: str = conf[CONF_PREFIX]
306  auto_configure: bool = conf[CONF_AUTO_CONFIGURE]
307  entry.runtime_data = ELKM1Data(
308  elk=elk,
309  prefix=prefix,
310  mac=entry.unique_id,
311  auto_configure=auto_configure,
312  config=config,
313  keypads={},
314  )
315 
316  await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
317 
318  return True
319 
320 
321 def _included(ranges: list[tuple[int, int]], set_to: bool, values: list[bool]) -> None:
322  for rng in ranges:
323  if not rng[0] <= rng[1] <= len(values):
324  raise vol.Invalid(f"Invalid range {rng}")
325  values[rng[0] - 1 : rng[1]] = [set_to] * (rng[1] - rng[0] + 1)
326 
327 
328 def _find_elk_by_prefix(hass: HomeAssistant, prefix: str) -> Elk | None:
329  """Search all config entries for a given prefix."""
330  for entry in hass.config_entries.async_entries(DOMAIN):
331  if not entry.runtime_data:
332  continue
333  elk_data: ELKM1Data = entry.runtime_data
334  if elk_data.prefix == prefix:
335  return elk_data.elk
336  return None
337 
338 
339 async def async_unload_entry(hass: HomeAssistant, entry: ElkM1ConfigEntry) -> bool:
340  """Unload a config entry."""
341  unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
342  # disconnect cleanly
343  entry.runtime_data.elk.disconnect()
344  return unload_ok
345 
346 
348  elk: Elk,
349  login_timeout: int,
350  sync_timeout: int,
351 ) -> bool:
352  """Wait until the elk has finished sync. Can fail login or timeout."""
353 
354  sync_event = asyncio.Event()
355  login_event = asyncio.Event()
356 
357  success = True
358 
359  def login_status(succeeded: bool) -> None:
360  nonlocal success
361 
362  success = succeeded
363  if succeeded:
364  _LOGGER.debug("ElkM1 login succeeded")
365  login_event.set()
366  else:
367  elk.disconnect()
368  _LOGGER.error("ElkM1 login failed; invalid username or password")
369  login_event.set()
370  sync_event.set()
371 
372  def sync_complete() -> None:
373  sync_event.set()
374 
375  elk.add_handler("login", login_status)
376  elk.add_handler("sync_complete", sync_complete)
377  for name, event, timeout in (
378  ("login", login_event, login_timeout),
379  ("sync_complete", sync_event, sync_timeout),
380  ):
381  _LOGGER.debug("Waiting for %s event for %s seconds", name, timeout)
382  try:
383  async with asyncio.timeout(timeout):
384  await event.wait()
385  except TimeoutError:
386  _LOGGER.debug("Timed out waiting for %s event", name)
387  elk.disconnect()
388  raise
389  _LOGGER.debug("Received %s event", name)
390 
391  return success
392 
393 
394 @callback
395 def _async_get_elk_panel(hass: HomeAssistant, service: ServiceCall) -> Panel:
396  """Get the ElkM1 panel from a service call."""
397  prefix = service.data["prefix"]
398  elk = _find_elk_by_prefix(hass, prefix)
399  if elk is None:
400  raise HomeAssistantError(f"No ElkM1 with prefix '{prefix}' found")
401  return elk.panel
402 
403 
404 def _create_elk_services(hass: HomeAssistant) -> None:
405  """Create ElkM1 services."""
406 
407  @callback
408  def _speak_word_service(service: ServiceCall) -> None:
409  _async_get_elk_panel(hass, service).speak_word(service.data["number"])
410 
411  @callback
412  def _speak_phrase_service(service: ServiceCall) -> None:
413  _async_get_elk_panel(hass, service).speak_phrase(service.data["number"])
414 
415  @callback
416  def _set_time_service(service: ServiceCall) -> None:
417  _async_get_elk_panel(hass, service).set_time(dt_util.now())
418 
419  hass.services.async_register(
420  DOMAIN, "speak_word", _speak_word_service, SPEAK_SERVICE_SCHEMA
421  )
422  hass.services.async_register(
423  DOMAIN, "speak_phrase", _speak_phrase_service, SPEAK_SERVICE_SCHEMA
424  )
425  hass.services.async_register(
426  DOMAIN, "set_time", _set_time_service, SET_TIME_SERVICE_SCHEMA
427  )
ElkSystem|None async_discover_device(HomeAssistant hass, str host)
Definition: discovery.py:78
bool async_update_entry_from_discovery(HomeAssistant hass, config_entries.ConfigEntry entry, ElkSystem device)
Definition: discovery.py:30
bool async_setup(HomeAssistant hass, ConfigType hass_config)
Definition: __init__.py:179
None _create_elk_services(HomeAssistant hass)
Definition: __init__.py:404
bool async_unload_entry(HomeAssistant hass, ElkM1ConfigEntry entry)
Definition: __init__.py:339
bool async_setup_entry(HomeAssistant hass, ElkM1ConfigEntry entry)
Definition: __init__.py:234
Panel _async_get_elk_panel(HomeAssistant hass, ServiceCall service)
Definition: __init__.py:395
Elk|None _find_elk_by_prefix(HomeAssistant hass, str prefix)
Definition: __init__.py:328
dict[str, str] _host_validator(dict[str, str] config)
Definition: __init__.py:102
ConfigEntry|None _async_find_matching_config_entry(HomeAssistant hass, str prefix)
Definition: __init__.py:227
bool async_wait_for_elk_to_sync(Elk elk, int login_timeout, int sync_timeout)
Definition: __init__.py:351
list[dict[str, str]] _has_all_unique_prefixes(list[dict[str, str]] value)
Definition: __init__.py:130
None _included(list[tuple[int, int]] ranges, bool set_to, list[bool] values)
Definition: __init__.py:321
str hostname_from_url(str url)
Definition: __init__.py:97
tuple[int, int] _elk_range_validator(str rng)
Definition: __init__.py:114
CALLBACK_TYPE async_track_time_interval(HomeAssistant hass, Callable[[datetime], Coroutine[Any, Any, None]|None] action, timedelta interval, *str|None name=None, bool|None cancel_on_shutdown=None)
Definition: event.py:1679
bool is_ip_address(str address)
Definition: network.py:63