"""Support the ElkM1 Gold and ElkM1 EZ8 alarm/integration panels.""" import logging import re import voluptuous as vol from homeassistant.const import ( CONF_EXCLUDE, CONF_HOST, CONF_INCLUDE, CONF_PASSWORD, CONF_TEMPERATURE_UNIT, CONF_USERNAME, ) from homeassistant.core import HomeAssistant, callback # noqa from homeassistant.helpers import config_validation as cv from homeassistant.helpers import discovery from homeassistant.helpers.entity import Entity from homeassistant.helpers.typing import ConfigType # noqa DOMAIN = "elkm1" CONF_AREA = "area" CONF_COUNTER = "counter" CONF_ENABLED = "enabled" CONF_KEYPAD = "keypad" CONF_OUTPUT = "output" CONF_PLC = "plc" CONF_SETTING = "setting" CONF_TASK = "task" CONF_THERMOSTAT = "thermostat" CONF_ZONE = "zone" CONF_PREFIX = "prefix" _LOGGER = logging.getLogger(__name__) SUPPORTED_DOMAINS = [ "alarm_control_panel", "climate", "light", "scene", "sensor", "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, } ) def _host_validator(config): """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): def _housecode_to_int(val): 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): 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): """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.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_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.""" from elkm1_lib.const import Max import elkm1_lib as elkm1 devices = {} elk_datas = {} configs = { CONF_AREA: Max.AREAS.value, CONF_COUNTER: Max.COUNTERS.value, CONF_KEYPAD: Max.KEYPADS.value, CONF_OUTPUT: Max.OUTPUTS.value, CONF_PLC: Max.LIGHTS.value, CONF_SETTING: Max.SETTINGS.value, CONF_TASK: Max.TASKS.value, CONF_THERMOSTAT: Max.THERMOSTATS.value, CONF_ZONE: Max.ZONES.value, } def _included(ranges, set_to, values): 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) for index, conf in enumerate(hass_config[DOMAIN]): _LOGGER.debug("Setting up elkm1 #%d - %s", index, conf["host"]) config = {"temperature_unit": conf[CONF_TEMPERATURE_UNIT]} config["panel"] = {"enabled": True, "included": [True]} for item, max_ in configs.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 prefix = conf[CONF_PREFIX] elk = elkm1.Elk( { "url": conf[CONF_HOST], "userid": conf[CONF_USERNAME], "password": conf[CONF_PASSWORD], } ) elk.connect() devices[prefix] = elk elk_datas[prefix] = { "elk": elk, "prefix": prefix, "config": config, "keypads": {}, } _create_elk_services(hass, devices) hass.data[DOMAIN] = elk_datas for component in SUPPORTED_DOMAINS: hass.async_create_task( discovery.async_load_platform(hass, component, DOMAIN, {}, hass_config) ) return True def _create_elk_services(hass, elks): def _speak_word_service(service): prefix = service.data["prefix"] elk = elks.get(prefix) if elk is None: _LOGGER.error("No elk m1 with prefix for speak_word: '%s'", prefix) return elk.panel.speak_word(service.data["number"]) def _speak_phrase_service(service): prefix = service.data["prefix"] elk = elks.get(prefix) if elk is None: _LOGGER.error("No elk m1 with prefix for speak_phrase: '%s'", prefix) return elk.panel.speak_phrase(service.data["number"]) 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 ) def create_elk_entities(elk_data, elk_elements, element_type, class_, entities): """Create the ElkM1 devices of a particular class.""" if elk_data["config"][element_type]["enabled"]: elk = elk_data["elk"] _LOGGER.debug("Creating elk entities for %s", elk) for element in elk_elements: if elk_data["config"][element_type]["included"][element.index]: entities.append(class_(element, elk, elk_data)) return entities class ElkEntity(Entity): """Base class for all Elk entities.""" def __init__(self, element, elk, elk_data): """Initialize the base of all Elk devices.""" self._elk = elk self._element = element self._prefix = elk_data["prefix"] self._temperature_unit = 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 = "{uid_start}_{name}".format( uid_start=uid_start, name=self._element.default_name("_") ).lower() @property def name(self): """Name of the element.""" return f"{self._prefix}{self._element.name}" @property def unique_id(self): """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 device_state_attributes(self): """Return the default attributes of the element.""" return {**self._element.as_dict(), **self.initial_attrs()} @property def available(self): """Is the entity available to be updated.""" return self._elk.is_connected() def initial_attrs(self): """Return the underlying element's attributes as a dict.""" attrs = {} attrs["index"] = self._element.index + 1 return attrs def _element_changed(self, element, changeset): pass @callback def _element_callback(self, element, changeset): """Handle callback from an Elk element that has changed.""" self._element_changed(element, changeset) self.async_schedule_update_ha_state(True) async def async_added_to_hass(self): """Register callback for ElkM1 changes and update entity state.""" self._element.add_callback(self._element_callback) self._element_callback(self._element, {})