542 lines
18 KiB
Python
542 lines
18 KiB
Python
"""Support the ElkM1 Gold and ElkM1 EZ8 alarm/integration panels."""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
from enum import Enum
|
|
import logging
|
|
import re
|
|
from types import MappingProxyType
|
|
from typing import Any, cast
|
|
from urllib.parse import urlparse
|
|
|
|
import async_timeout
|
|
from elkm1_lib.elements import Element
|
|
from elkm1_lib.elk import Elk
|
|
import voluptuous as vol
|
|
|
|
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
|
|
from homeassistant.const import (
|
|
ATTR_CONNECTIONS,
|
|
CONF_EXCLUDE,
|
|
CONF_HOST,
|
|
CONF_INCLUDE,
|
|
CONF_PASSWORD,
|
|
CONF_PREFIX,
|
|
CONF_TEMPERATURE_UNIT,
|
|
CONF_USERNAME,
|
|
CONF_ZONE,
|
|
TEMP_CELSIUS,
|
|
TEMP_FAHRENHEIT,
|
|
Platform,
|
|
)
|
|
from homeassistant.core import HomeAssistant, ServiceCall, callback
|
|
from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError
|
|
from homeassistant.helpers import config_validation as cv
|
|
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC
|
|
from homeassistant.helpers.entity import DeviceInfo, Entity
|
|
from homeassistant.helpers.event import async_track_time_interval
|
|
from homeassistant.helpers.typing import ConfigType
|
|
import homeassistant.util.dt as dt_util
|
|
from homeassistant.util.network import is_ip_address
|
|
|
|
from .const import (
|
|
ATTR_KEY,
|
|
ATTR_KEY_NAME,
|
|
ATTR_KEYPAD_ID,
|
|
ATTR_KEYPAD_NAME,
|
|
CONF_AREA,
|
|
CONF_AUTO_CONFIGURE,
|
|
CONF_COUNTER,
|
|
CONF_ENABLED,
|
|
CONF_KEYPAD,
|
|
CONF_OUTPUT,
|
|
CONF_PLC,
|
|
CONF_SETTING,
|
|
CONF_TASK,
|
|
CONF_THERMOSTAT,
|
|
DISCOVER_SCAN_TIMEOUT,
|
|
DISCOVERY_INTERVAL,
|
|
DOMAIN,
|
|
ELK_ELEMENTS,
|
|
EVENT_ELKM1_KEYPAD_KEY_PRESSED,
|
|
LOGIN_TIMEOUT,
|
|
)
|
|
from .discovery import (
|
|
async_discover_device,
|
|
async_discover_devices,
|
|
async_trigger_discovery,
|
|
async_update_entry_from_discovery,
|
|
)
|
|
|
|
SYNC_TIMEOUT = 120
|
|
|
|
_LOGGER = logging.getLogger(__name__)
|
|
|
|
PLATFORMS = [
|
|
Platform.ALARM_CONTROL_PANEL,
|
|
Platform.CLIMATE,
|
|
Platform.LIGHT,
|
|
Platform.SCENE,
|
|
Platform.SENSOR,
|
|
Platform.SWITCH,
|
|
]
|
|
|
|
SPEAK_SERVICE_SCHEMA = vol.Schema(
|
|
{
|
|
vol.Required("number"): vol.All(vol.Coerce(int), vol.Range(min=0, max=999)),
|
|
vol.Optional("prefix", default=""): cv.string,
|
|
}
|
|
)
|
|
|
|
SET_TIME_SERVICE_SCHEMA = vol.Schema(
|
|
{
|
|
vol.Optional("prefix", default=""): cv.string,
|
|
}
|
|
)
|
|
|
|
|
|
def _host_validator(config: dict[str, str]) -> dict[str, str]:
|
|
"""Validate that a host is properly configured."""
|
|
if config[CONF_HOST].startswith("elks://"):
|
|
if CONF_USERNAME not in config or CONF_PASSWORD not in config:
|
|
raise vol.Invalid("Specify username and password for elks://")
|
|
elif not config[CONF_HOST].startswith("elk://") and not config[
|
|
CONF_HOST
|
|
].startswith("serial://"):
|
|
raise vol.Invalid("Invalid host URL")
|
|
return config
|
|
|
|
|
|
def _elk_range_validator(rng: str) -> tuple[int, int]:
|
|
def _housecode_to_int(val: str) -> int:
|
|
match = re.search(r"^([a-p])(0[1-9]|1[0-6]|[1-9])$", val.lower())
|
|
if match:
|
|
return (ord(match.group(1)) - ord("a")) * 16 + int(match.group(2))
|
|
raise vol.Invalid("Invalid range")
|
|
|
|
def _elk_value(val: str) -> int:
|
|
return int(val) if val.isdigit() else _housecode_to_int(val)
|
|
|
|
vals = [s.strip() for s in str(rng).split("-")]
|
|
start = _elk_value(vals[0])
|
|
end = start if len(vals) == 1 else _elk_value(vals[1])
|
|
return (start, end)
|
|
|
|
|
|
def _has_all_unique_prefixes(value: list[dict[str, str]]) -> list[dict[str, str]]:
|
|
"""Validate that each m1 configured has a unique prefix.
|
|
|
|
Uniqueness is determined case-independently.
|
|
"""
|
|
prefixes = [device[CONF_PREFIX] for device in value]
|
|
schema = vol.Schema(vol.Unique())
|
|
schema(prefixes)
|
|
return value
|
|
|
|
|
|
DEVICE_SCHEMA_SUBDOMAIN = vol.Schema(
|
|
{
|
|
vol.Optional(CONF_ENABLED, default=True): cv.boolean,
|
|
vol.Optional(CONF_INCLUDE, default=[]): [_elk_range_validator],
|
|
vol.Optional(CONF_EXCLUDE, default=[]): [_elk_range_validator],
|
|
}
|
|
)
|
|
|
|
DEVICE_SCHEMA = vol.All(
|
|
cv.deprecated(CONF_TEMPERATURE_UNIT),
|
|
vol.Schema(
|
|
{
|
|
vol.Required(CONF_HOST): cv.string,
|
|
vol.Optional(CONF_PREFIX, default=""): vol.All(cv.string, vol.Lower),
|
|
vol.Optional(CONF_USERNAME, default=""): cv.string,
|
|
vol.Optional(CONF_PASSWORD, default=""): cv.string,
|
|
vol.Optional(CONF_AUTO_CONFIGURE, default=False): cv.boolean,
|
|
vol.Optional(CONF_TEMPERATURE_UNIT, default="F"): cv.temperature_unit,
|
|
vol.Optional(CONF_AREA, default={}): DEVICE_SCHEMA_SUBDOMAIN,
|
|
vol.Optional(CONF_COUNTER, default={}): DEVICE_SCHEMA_SUBDOMAIN,
|
|
vol.Optional(CONF_KEYPAD, default={}): DEVICE_SCHEMA_SUBDOMAIN,
|
|
vol.Optional(CONF_OUTPUT, default={}): DEVICE_SCHEMA_SUBDOMAIN,
|
|
vol.Optional(CONF_PLC, default={}): DEVICE_SCHEMA_SUBDOMAIN,
|
|
vol.Optional(CONF_SETTING, default={}): DEVICE_SCHEMA_SUBDOMAIN,
|
|
vol.Optional(CONF_TASK, default={}): DEVICE_SCHEMA_SUBDOMAIN,
|
|
vol.Optional(CONF_THERMOSTAT, default={}): DEVICE_SCHEMA_SUBDOMAIN,
|
|
vol.Optional(CONF_ZONE, default={}): DEVICE_SCHEMA_SUBDOMAIN,
|
|
},
|
|
_host_validator,
|
|
),
|
|
)
|
|
|
|
CONFIG_SCHEMA = vol.Schema(
|
|
{DOMAIN: vol.All(cv.ensure_list, [DEVICE_SCHEMA], _has_all_unique_prefixes)},
|
|
extra=vol.ALLOW_EXTRA,
|
|
)
|
|
|
|
|
|
async def async_setup(hass: HomeAssistant, hass_config: ConfigType) -> bool:
|
|
"""Set up the Elk M1 platform."""
|
|
hass.data.setdefault(DOMAIN, {})
|
|
_create_elk_services(hass)
|
|
|
|
async def _async_discovery(*_: Any) -> None:
|
|
async_trigger_discovery(
|
|
hass, await async_discover_devices(hass, DISCOVER_SCAN_TIMEOUT)
|
|
)
|
|
|
|
asyncio.create_task(_async_discovery())
|
|
async_track_time_interval(hass, _async_discovery, DISCOVERY_INTERVAL)
|
|
|
|
if DOMAIN not in hass_config:
|
|
return True
|
|
|
|
for index, conf in enumerate(hass_config[DOMAIN]):
|
|
_LOGGER.debug("Importing elkm1 #%d - %s", index, conf[CONF_HOST])
|
|
|
|
# The update of the config entry is done in async_setup
|
|
# to ensure the entry if updated before async_setup_entry
|
|
# is called to avoid a situation where the user has to restart
|
|
# twice for the changes to take effect
|
|
current_config_entry = _async_find_matching_config_entry(
|
|
hass, conf[CONF_PREFIX]
|
|
)
|
|
if current_config_entry:
|
|
# If they alter the yaml config we import the changes
|
|
# since there currently is no practical way to do an options flow
|
|
# with the large amount of include/exclude/enabled options that elkm1 has.
|
|
hass.config_entries.async_update_entry(current_config_entry, data=conf)
|
|
continue
|
|
|
|
hass.async_create_task(
|
|
hass.config_entries.flow.async_init(
|
|
DOMAIN,
|
|
context={"source": SOURCE_IMPORT},
|
|
data=conf,
|
|
)
|
|
)
|
|
|
|
return True
|
|
|
|
|
|
@callback
|
|
def _async_find_matching_config_entry(
|
|
hass: HomeAssistant, prefix: str
|
|
) -> ConfigEntry | None:
|
|
for entry in hass.config_entries.async_entries(DOMAIN):
|
|
if entry.unique_id == prefix:
|
|
return entry
|
|
return None
|
|
|
|
|
|
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|
"""Set up Elk-M1 Control from a config entry."""
|
|
conf: MappingProxyType[str, Any] = entry.data
|
|
|
|
host = urlparse(entry.data[CONF_HOST]).hostname
|
|
|
|
_LOGGER.debug("Setting up elkm1 %s", conf["host"])
|
|
|
|
if (not entry.unique_id or ":" not in entry.unique_id) and is_ip_address(host):
|
|
_LOGGER.debug(
|
|
"Unique id for %s is missing during setup, trying to fill from discovery",
|
|
host,
|
|
)
|
|
if device := await async_discover_device(hass, host):
|
|
async_update_entry_from_discovery(hass, entry, device)
|
|
|
|
config: dict[str, Any] = {}
|
|
|
|
if not conf[CONF_AUTO_CONFIGURE]:
|
|
# With elkm1-lib==0.7.16 and later auto configure is available
|
|
config["panel"] = {"enabled": True, "included": [True]}
|
|
for item, max_ in ELK_ELEMENTS.items():
|
|
config[item] = {
|
|
"enabled": conf[item][CONF_ENABLED],
|
|
"included": [not conf[item]["include"]] * max_,
|
|
}
|
|
try:
|
|
_included(conf[item]["include"], True, config[item]["included"])
|
|
_included(conf[item]["exclude"], False, config[item]["included"])
|
|
except (ValueError, vol.Invalid) as err:
|
|
_LOGGER.error("Config item: %s; %s", item, err)
|
|
return False
|
|
|
|
elk = Elk(
|
|
{
|
|
"url": conf[CONF_HOST],
|
|
"userid": conf[CONF_USERNAME],
|
|
"password": conf[CONF_PASSWORD],
|
|
}
|
|
)
|
|
elk.connect()
|
|
|
|
def _keypad_changed(keypad: Element, changeset: dict[str, Any]) -> None:
|
|
if (keypress := changeset.get("last_keypress")) is None:
|
|
return
|
|
|
|
hass.bus.async_fire(
|
|
EVENT_ELKM1_KEYPAD_KEY_PRESSED,
|
|
{
|
|
ATTR_KEYPAD_NAME: keypad.name,
|
|
ATTR_KEYPAD_ID: keypad.index + 1,
|
|
ATTR_KEY_NAME: keypress[0],
|
|
ATTR_KEY: keypress[1],
|
|
},
|
|
)
|
|
|
|
for keypad in elk.keypads:
|
|
keypad.add_callback(_keypad_changed)
|
|
|
|
try:
|
|
if not await async_wait_for_elk_to_sync(elk, LOGIN_TIMEOUT, SYNC_TIMEOUT):
|
|
return False
|
|
except asyncio.TimeoutError as exc:
|
|
raise ConfigEntryNotReady(f"Timed out connecting to {conf[CONF_HOST]}") from exc
|
|
|
|
elk_temp_unit = elk.panel.temperature_units
|
|
temperature_unit = TEMP_CELSIUS if elk_temp_unit == "C" else TEMP_FAHRENHEIT
|
|
config["temperature_unit"] = temperature_unit
|
|
hass.data[DOMAIN][entry.entry_id] = {
|
|
"elk": elk,
|
|
"prefix": conf[CONF_PREFIX],
|
|
"mac": entry.unique_id,
|
|
"auto_configure": conf[CONF_AUTO_CONFIGURE],
|
|
"config": config,
|
|
"keypads": {},
|
|
}
|
|
|
|
hass.config_entries.async_setup_platforms(entry, PLATFORMS)
|
|
|
|
return True
|
|
|
|
|
|
def _included(ranges: list[tuple[int, int]], set_to: bool, values: list[bool]) -> None:
|
|
for rng in ranges:
|
|
if not rng[0] <= rng[1] <= len(values):
|
|
raise vol.Invalid(f"Invalid range {rng}")
|
|
values[rng[0] - 1 : rng[1]] = [set_to] * (rng[1] - rng[0] + 1)
|
|
|
|
|
|
def _find_elk_by_prefix(hass: HomeAssistant, prefix: str) -> Elk | None:
|
|
"""Search all config entries for a given prefix."""
|
|
for entry_id in hass.data[DOMAIN]:
|
|
if hass.data[DOMAIN][entry_id]["prefix"] == prefix:
|
|
return cast(Elk, hass.data[DOMAIN][entry_id]["elk"])
|
|
return None
|
|
|
|
|
|
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|
"""Unload a config entry."""
|
|
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
|
|
|
# disconnect cleanly
|
|
hass.data[DOMAIN][entry.entry_id]["elk"].disconnect()
|
|
|
|
if unload_ok:
|
|
hass.data[DOMAIN].pop(entry.entry_id)
|
|
|
|
return unload_ok
|
|
|
|
|
|
async def async_wait_for_elk_to_sync(
|
|
elk: Elk,
|
|
login_timeout: int,
|
|
sync_timeout: int,
|
|
) -> bool:
|
|
"""Wait until the elk has finished sync. Can fail login or timeout."""
|
|
|
|
sync_event = asyncio.Event()
|
|
login_event = asyncio.Event()
|
|
|
|
success = True
|
|
|
|
def login_status(succeeded: bool) -> None:
|
|
nonlocal success
|
|
|
|
success = succeeded
|
|
if succeeded:
|
|
_LOGGER.debug("ElkM1 login succeeded")
|
|
login_event.set()
|
|
else:
|
|
elk.disconnect()
|
|
_LOGGER.error("ElkM1 login failed; invalid username or password")
|
|
login_event.set()
|
|
sync_event.set()
|
|
|
|
def sync_complete() -> None:
|
|
sync_event.set()
|
|
|
|
elk.add_handler("login", login_status)
|
|
elk.add_handler("sync_complete", sync_complete)
|
|
for name, event, timeout in (
|
|
("login", login_event, login_timeout),
|
|
("sync_complete", sync_event, sync_timeout),
|
|
):
|
|
_LOGGER.debug("Waiting for %s event for %s seconds", name, timeout)
|
|
try:
|
|
async with async_timeout.timeout(timeout):
|
|
await event.wait()
|
|
except asyncio.TimeoutError:
|
|
_LOGGER.debug("Timed out waiting for %s event", name)
|
|
elk.disconnect()
|
|
raise
|
|
_LOGGER.debug("Received %s event", name)
|
|
|
|
return success
|
|
|
|
|
|
def _create_elk_services(hass: HomeAssistant) -> None:
|
|
def _getelk(service: ServiceCall) -> Elk:
|
|
prefix = service.data["prefix"]
|
|
elk = _find_elk_by_prefix(hass, prefix)
|
|
if elk is None:
|
|
raise HomeAssistantError(f"No ElkM1 with prefix '{prefix}' found")
|
|
return elk
|
|
|
|
def _speak_word_service(service: ServiceCall) -> None:
|
|
_getelk(service).panel.speak_word(service.data["number"])
|
|
|
|
def _speak_phrase_service(service: ServiceCall) -> None:
|
|
_getelk(service).panel.speak_phrase(service.data["number"])
|
|
|
|
def _set_time_service(service: ServiceCall) -> None:
|
|
_getelk(service).panel.set_time(dt_util.now())
|
|
|
|
hass.services.async_register(
|
|
DOMAIN, "speak_word", _speak_word_service, SPEAK_SERVICE_SCHEMA
|
|
)
|
|
hass.services.async_register(
|
|
DOMAIN, "speak_phrase", _speak_phrase_service, SPEAK_SERVICE_SCHEMA
|
|
)
|
|
hass.services.async_register(
|
|
DOMAIN, "set_time", _set_time_service, SET_TIME_SERVICE_SCHEMA
|
|
)
|
|
|
|
|
|
def create_elk_entities(
|
|
elk_data: dict[str, Any],
|
|
elk_elements: list[Element],
|
|
element_type: str,
|
|
class_: Any,
|
|
entities: list[ElkEntity],
|
|
) -> list[ElkEntity] | None:
|
|
"""Create the ElkM1 devices of a particular class."""
|
|
auto_configure = elk_data["auto_configure"]
|
|
|
|
if not auto_configure and not elk_data["config"][element_type]["enabled"]:
|
|
return None
|
|
|
|
elk = elk_data["elk"]
|
|
_LOGGER.debug("Creating elk entities for %s", elk)
|
|
|
|
for element in elk_elements:
|
|
if auto_configure:
|
|
if not element.configured:
|
|
continue
|
|
# Only check the included list if auto configure is not
|
|
elif not elk_data["config"][element_type]["included"][element.index]:
|
|
continue
|
|
|
|
entities.append(class_(element, elk, elk_data))
|
|
return entities
|
|
|
|
|
|
class ElkEntity(Entity):
|
|
"""Base class for all Elk entities."""
|
|
|
|
def __init__(self, element: Element, elk: Elk, elk_data: dict[str, Any]) -> None:
|
|
"""Initialize the base of all Elk devices."""
|
|
self._elk = elk
|
|
self._element = element
|
|
self._mac = elk_data["mac"]
|
|
self._prefix = elk_data["prefix"]
|
|
self._name_prefix = f"{self._prefix} " if self._prefix else ""
|
|
self._temperature_unit: str = elk_data["config"]["temperature_unit"]
|
|
# unique_id starts with elkm1_ iff there is no prefix
|
|
# it starts with elkm1m_{prefix} iff there is a prefix
|
|
# this is to avoid a conflict between
|
|
# prefix=foo, name=bar (which would be elkm1_foo_bar)
|
|
# - and -
|
|
# prefix="", name="foo bar" (which would be elkm1_foo_bar also)
|
|
# we could have used elkm1__foo_bar for the latter, but that
|
|
# would have been a breaking change
|
|
if self._prefix != "":
|
|
uid_start = f"elkm1m_{self._prefix}"
|
|
else:
|
|
uid_start = "elkm1"
|
|
self._unique_id = f"{uid_start}_{self._element.default_name('_')}".lower()
|
|
|
|
@property
|
|
def name(self) -> str:
|
|
"""Name of the element."""
|
|
return f"{self._name_prefix}{self._element.name}"
|
|
|
|
@property
|
|
def unique_id(self) -> str:
|
|
"""Return unique id of the element."""
|
|
return self._unique_id
|
|
|
|
@property
|
|
def should_poll(self) -> bool:
|
|
"""Don't poll this device."""
|
|
return False
|
|
|
|
@property
|
|
def extra_state_attributes(self) -> dict[str, Any]:
|
|
"""Return the default attributes of the element."""
|
|
dict_as_str = {}
|
|
for key, val in self._element.as_dict().items():
|
|
dict_as_str[key] = val.value if isinstance(val, Enum) else val
|
|
return {**dict_as_str, **self.initial_attrs()}
|
|
|
|
@property
|
|
def available(self) -> bool:
|
|
"""Is the entity available to be updated."""
|
|
return self._elk.is_connected()
|
|
|
|
def initial_attrs(self) -> dict[str, Any]:
|
|
"""Return the underlying element's attributes as a dict."""
|
|
attrs = {}
|
|
attrs["index"] = self._element.index + 1
|
|
return attrs
|
|
|
|
def _element_changed(self, element: Element, changeset: dict[str, Any]) -> None:
|
|
pass
|
|
|
|
@callback
|
|
def _element_callback(self, element: Element, changeset: dict[str, Any]) -> None:
|
|
"""Handle callback from an Elk element that has changed."""
|
|
self._element_changed(element, changeset)
|
|
self.async_write_ha_state()
|
|
|
|
async def async_added_to_hass(self) -> None:
|
|
"""Register callback for ElkM1 changes and update entity state."""
|
|
self._element.add_callback(self._element_callback)
|
|
self._element_callback(self._element, {})
|
|
|
|
@property
|
|
def device_info(self) -> DeviceInfo:
|
|
"""Device info connecting via the ElkM1 system."""
|
|
return DeviceInfo(
|
|
via_device=(DOMAIN, f"{self._prefix}_system"),
|
|
)
|
|
|
|
|
|
class ElkAttachedEntity(ElkEntity):
|
|
"""An elk entity that is attached to the elk system."""
|
|
|
|
@property
|
|
def device_info(self) -> DeviceInfo:
|
|
"""Device info for the underlying ElkM1 system."""
|
|
device_name = "ElkM1"
|
|
if self._prefix:
|
|
device_name += f" {self._prefix}"
|
|
device_info = DeviceInfo(
|
|
identifiers={(DOMAIN, f"{self._prefix}_system")},
|
|
manufacturer="ELK Products, Inc.",
|
|
model="M1",
|
|
name=device_name,
|
|
sw_version=self._elk.panel.elkm1_version,
|
|
)
|
|
if self._mac:
|
|
device_info[ATTR_CONNECTIONS] = {(CONNECTION_NETWORK_MAC, self._mac)}
|
|
return device_info
|