1 """Support the ElkM1 Gold and ElkM1 EZ8 alarm/integration panels."""
3 from __future__
import annotations
8 from types
import MappingProxyType
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
24 CONF_TEMPERATURE_UNIT,
52 DISCOVER_SCAN_TIMEOUT,
56 EVENT_ELKM1_KEYPAD_KEY_PRESSED,
59 from .discovery
import (
60 async_discover_device,
61 async_discover_devices,
62 async_trigger_discovery,
63 async_update_entry_from_discovery,
65 from .models
import ELKM1Data
67 type ElkM1ConfigEntry = ConfigEntry[ELKM1Data]
71 _LOGGER = logging.getLogger(__name__)
74 Platform.ALARM_CONTROL_PANEL,
75 Platform.BINARY_SENSOR,
83 SPEAK_SERVICE_SCHEMA = vol.Schema(
85 vol.Required(
"number"): vol.All(vol.Coerce(int), vol.Range(min=0, max=999)),
86 vol.Optional(
"prefix", default=
""): cv.string,
90 SET_TIME_SERVICE_SCHEMA = vol.Schema(
92 vol.Optional(
"prefix", default=
""): cv.string,
98 """Return the hostname from a url."""
99 return parse_url(url)[1]
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[
109 ].startswith(
"serial://"):
110 raise vol.Invalid(
"Invalid host URL")
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())
118 return (ord(match.group(1)) - ord(
"a")) * 16 +
int(match.group(2))
119 raise vol.Invalid(
"Invalid range")
121 def _elk_value(val: str) -> int:
122 return int(val)
if val.isdigit()
else _housecode_to_int(val)
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])
131 """Validate that each m1 configured has a unique prefix.
133 Uniqueness is determined case-independently.
135 prefixes = [device[CONF_PREFIX]
for device
in value]
136 schema = vol.Schema(vol.Unique())
141 DEVICE_SCHEMA_SUBDOMAIN = vol.Schema(
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],
149 DEVICE_SCHEMA = vol.All(
150 cv.deprecated(CONF_TEMPERATURE_UNIT),
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,
173 CONFIG_SCHEMA = vol.Schema(
174 {DOMAIN: vol.All(cv.ensure_list, [DEVICE_SCHEMA], _has_all_unique_prefixes)},
175 extra=vol.ALLOW_EXTRA,
179 async
def async_setup(hass: HomeAssistant, hass_config: ConfigType) -> bool:
180 """Set up the Elk M1 platform."""
183 async
def _async_discovery(*_: Any) ->
None:
188 hass.async_create_background_task(_async_discovery(),
"elkm1 setup discovery")
190 hass, _async_discovery, DISCOVERY_INTERVAL, cancel_on_shutdown=
True
193 if DOMAIN
not in hass_config:
196 for index, conf
in enumerate(hass_config[DOMAIN]):
197 _LOGGER.debug(
"Importing elkm1 #%d - %s", index, conf[CONF_HOST])
204 hass, conf[CONF_PREFIX]
206 if current_config_entry:
210 hass.config_entries.async_update_entry(current_config_entry, data=conf)
213 hass.async_create_task(
214 hass.config_entries.flow.async_init(
216 context={
"source": SOURCE_IMPORT},
226 hass: HomeAssistant, prefix: str
227 ) -> ConfigEntry |
None:
228 for entry
in hass.config_entries.async_entries(DOMAIN):
229 if entry.unique_id == prefix:
235 """Set up Elk-M1 Control from a config entry."""
236 conf: MappingProxyType[str, Any] = entry.data
240 _LOGGER.debug(
"Setting up elkm1 %s", conf[
"host"])
242 if (
not entry.unique_id
or ":" not in entry.unique_id)
and is_ip_address(host):
244 "Unique id for %s is missing during setup, trying to fill from discovery",
250 config: dict[str, Any] = {}
252 if not conf[CONF_AUTO_CONFIGURE]:
254 config[
"panel"] = {
"enabled":
True,
"included": [
True]}
255 for item, max_
in ELK_ELEMENTS.items():
257 "enabled": conf[item][CONF_ENABLED],
258 "included": [
not conf[item][
"include"]] * max_,
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)
269 "url": conf[CONF_HOST],
270 "userid": conf[CONF_USERNAME],
271 "password": conf[CONF_PASSWORD],
276 def _keypad_changed(keypad: Element, changeset: dict[str, Any]) ->
None:
277 if (keypress := changeset.get(
"last_keypress"))
is None:
281 EVENT_ELKM1_KEYPAD_KEY_PRESSED,
283 ATTR_KEYPAD_NAME: keypad.name,
284 ATTR_KEYPAD_ID: keypad.index + 1,
285 ATTR_KEY_NAME: keypress[0],
286 ATTR_KEY: keypress[1],
290 for keypad
in elk.keypads:
291 keypad.add_callback(_keypad_changed)
296 except TimeoutError
as exc:
299 elk_temp_unit = elk.panel.temperature_units
300 if elk_temp_unit ==
"C":
301 temperature_unit = UnitOfTemperature.CELSIUS
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]
311 auto_configure=auto_configure,
316 await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
321 def _included(ranges: list[tuple[int, int]], set_to: bool, values: list[bool]) ->
None:
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)
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:
333 elk_data: ELKM1Data = entry.runtime_data
334 if elk_data.prefix == prefix:
340 """Unload a config entry."""
341 unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
343 entry.runtime_data.elk.disconnect()
352 """Wait until the elk has finished sync. Can fail login or timeout."""
354 sync_event = asyncio.Event()
355 login_event = asyncio.Event()
359 def login_status(succeeded: bool) ->
None:
364 _LOGGER.debug(
"ElkM1 login succeeded")
368 _LOGGER.error(
"ElkM1 login failed; invalid username or password")
372 def sync_complete() -> None:
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),
381 _LOGGER.debug(
"Waiting for %s event for %s seconds", name, timeout)
383 async
with asyncio.timeout(timeout):
386 _LOGGER.debug(
"Timed out waiting for %s event", name)
389 _LOGGER.debug(
"Received %s event", name)
396 """Get the ElkM1 panel from a service call."""
397 prefix = service.data[
"prefix"]
405 """Create ElkM1 services."""
408 def _speak_word_service(service: ServiceCall) ->
None:
412 def _speak_phrase_service(service: ServiceCall) ->
None:
416 def _set_time_service(service: ServiceCall) ->
None:
419 hass.services.async_register(
420 DOMAIN,
"speak_word", _speak_word_service, SPEAK_SERVICE_SCHEMA
422 hass.services.async_register(
423 DOMAIN,
"speak_phrase", _speak_phrase_service, SPEAK_SERVICE_SCHEMA
425 hass.services.async_register(
426 DOMAIN,
"set_time", _set_time_service, SET_TIME_SERVICE_SCHEMA
ElkSystem|None async_discover_device(HomeAssistant hass, str host)
bool async_update_entry_from_discovery(HomeAssistant hass, config_entries.ConfigEntry entry, ElkSystem device)
bool async_setup(HomeAssistant hass, ConfigType hass_config)
None _create_elk_services(HomeAssistant hass)
bool async_unload_entry(HomeAssistant hass, ElkM1ConfigEntry entry)
bool async_setup_entry(HomeAssistant hass, ElkM1ConfigEntry entry)
Panel _async_get_elk_panel(HomeAssistant hass, ServiceCall service)
Elk|None _find_elk_by_prefix(HomeAssistant hass, str prefix)
dict[str, str] _host_validator(dict[str, str] config)
ConfigEntry|None _async_find_matching_config_entry(HomeAssistant hass, str prefix)
bool async_wait_for_elk_to_sync(Elk elk, int login_timeout, int sync_timeout)
list[dict[str, str]] _has_all_unique_prefixes(list[dict[str, str]] value)
None _included(list[tuple[int, int]] ranges, bool set_to, list[bool] values)
str hostname_from_url(str url)
tuple[int, int] _elk_range_validator(str rng)
dict[str, Device] async_discover_devices(HomeAssistant hass)
None async_trigger_discovery(HomeAssistant hass, dict[str, Device] discovered_devices)
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)
bool is_ip_address(str address)