Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CODEOWNERS
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ homeassistant/components/edl21/* @mtdcr
homeassistant/components/egardia/* @jeroenterheerdt
homeassistant/components/eight_sleep/* @mezz64
homeassistant/components/elgato/* @frenck
homeassistant/components/elkm1/* @bdraco
homeassistant/components/elv/* @majuss
homeassistant/components/emby/* @mezz64
homeassistant/components/emoncms/* @borpin
Expand Down
28 changes: 28 additions & 0 deletions homeassistant/components/elkm1/.translations/en.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
{
"config": {
"title": "Elk-M1 Control",
"step": {
"user": {
"title": "Connect to Elk-M1 Control",
"description": "The address string must be in the form 'address[:port]' for 'secure' and 'non-secure'. Example: '192.168.1.1'. The port is optional and defaults to 2101 for 'non-secure' and 2601 for 'secure'. For the serial protocol, the address must be in the form 'tty[:baud]'. Example: '/dev/ttyS1'. The baud is optional and defaults to 115200.",
"data": {
"protocol": "Protocol",
"address": "The IP address or domain or serial port if connecting via serial.",
"username": "Username (secure only).",
"password": "Password (secure only).",
"prefix": "A unique prefix (leave blank if you only have one ElkM1).",
"temperature_unit": "The temperature unit ElkM1 uses."
}
}
},
"error": {
"cannot_connect": "Failed to connect, please try again",
"invalid_auth": "Invalid authentication",
"unknown": "Unexpected error"
},
"abort": {
"already_configured": "An ElkM1 with this prefix is already configured",
"address_already_configured": "An ElkM1 with this address is already configured"
}
}
}
263 changes: 184 additions & 79 deletions homeassistant/components/elkm1/__init__.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
"""Support the ElkM1 Gold and ElkM1 EZ8 alarm/integration panels."""
import asyncio
import logging
import re

import async_timeout
import elkm1_lib as elkm1
from elkm1_lib.const import Max
import voluptuous as vol

from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
from homeassistant.const import (
CONF_EXCLUDE,
CONF_HOST,
Expand All @@ -15,23 +17,29 @@
CONF_USERNAME,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv, discovery
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.typing import ConfigType

DOMAIN = "elkm1"
from .const import (
CONF_AREA,
CONF_AUTO_CONFIGURE,
CONF_COUNTER,
CONF_ENABLED,
CONF_KEYPAD,
CONF_OUTPUT,
CONF_PLC,
CONF_PREFIX,
CONF_SETTING,
CONF_TASK,
CONF_THERMOSTAT,
CONF_ZONE,
DOMAIN,
ELK_ELEMENTS,
)

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"
SYNC_TIMEOUT = 55

_LOGGER = logging.getLogger(__name__)

Expand Down Expand Up @@ -85,17 +93,6 @@ def _elk_value(val):
return (start, end)


def _has_all_unique_prefixes(value):
Comment thread
bdraco marked this conversation as resolved.
"""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,
Expand All @@ -110,6 +107,7 @@ def _has_all_unique_prefixes(value):
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,
Expand All @@ -125,41 +123,59 @@ def _has_all_unique_prefixes(value):
)

CONFIG_SCHEMA = vol.Schema(
{DOMAIN: vol.All(cv.ensure_list, [DEVICE_SCHEMA], _has_all_unique_prefixes)},
extra=vol.ALLOW_EXTRA,
{DOMAIN: vol.All(cv.ensure_list, [DEVICE_SCHEMA])}, extra=vol.ALLOW_EXTRA,
)


async def async_setup(hass: HomeAssistant, hass_config: ConfigType) -> bool:
"""Set up the Elk M1 platform."""
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,
}
hass.data.setdefault(DOMAIN, {})
_create_elk_services(hass)

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)
if DOMAIN not in hass_config:
return True

for index, conf in enumerate(hass_config[DOMAIN]):
_LOGGER.debug("Setting up elkm1 #%d - %s", index, conf["host"])
_LOGGER.debug("Importing elkm1 #%d - %s", index, conf[CONF_HOST])
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)
Comment thread
bdraco marked this conversation as resolved.
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, prefix):
for entry in hass.config_entries.async_entries(DOMAIN):
if entry.unique_id == prefix:
return entry

config = {"temperature_unit": conf[CONF_TEMPERATURE_UNIT]}
config["panel"] = {"enabled": True, "included": [True]}

for item, max_ in configs.items():
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
"""Set up Elk-M1 Control from a config entry."""

conf = entry.data

_LOGGER.debug("Setting up elkm1 %s", conf["host"])

config = {"temperature_unit": conf[CONF_TEMPERATURE_UNIT]}

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_,
Expand All @@ -171,47 +187,100 @@ def _included(ranges, set_to, values):
_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": {},
elk = elkm1.Elk(
{
"url": conf[CONF_HOST],
"userid": conf[CONF_USERNAME],
"password": conf[CONF_PASSWORD],
}
)
elk.connect()
Comment thread
bdraco marked this conversation as resolved.

if not await async_wait_for_elk_to_sync(elk, SYNC_TIMEOUT):
_LOGGER.error(
"Timed out after %d seconds while trying to sync with ElkM1", SYNC_TIMEOUT,
)
elk.disconnect()
Comment thread
bdraco marked this conversation as resolved.
raise ConfigEntryNotReady

_create_elk_services(hass, devices)
if elk.invalid_auth:
_LOGGER.error("Authentication failed for ElkM1")
return False

hass.data[DOMAIN][entry.entry_id] = {
"elk": elk,
"prefix": conf[CONF_PREFIX],
"auto_configure": conf[CONF_AUTO_CONFIGURE],
"config": config,
"keypads": {},
}

hass.data[DOMAIN] = elk_datas
for component in SUPPORTED_DOMAINS:
hass.async_create_task(
discovery.async_load_platform(hass, component, DOMAIN, {}, hass_config)
hass.config_entries.async_forward_entry_setup(entry, component)
)

return True


def _create_elk_services(hass, elks):
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)


def _find_elk_by_prefix(hass, prefix):
"""Search all config entries for a given prefix."""
for entry_id in hass.data[DOMAIN]:
if hass.data[DOMAIN][entry_id]["prefix"] == prefix:
return hass.data[DOMAIN][entry_id]["elk"]


async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry):
"""Unload a config entry."""
unload_ok = all(
await asyncio.gather(
*[
hass.config_entries.async_forward_entry_unload(entry, component)
for component in SUPPORTED_DOMAINS
]
)
)

# 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, timeout):
"""Wait until the elk system has finished sync."""
try:
with async_timeout.timeout(timeout):
await elk.sync_complete()
return True
except asyncio.TimeoutError:
elk.disconnect()

return False


def _create_elk_services(hass):
def _speak_word_service(service):
prefix = service.data["prefix"]
elk = elks.get(prefix)
elk = _find_elk_by_prefix(hass, 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)
elk = _find_elk_by_prefix(hass, prefix)
if elk is None:
_LOGGER.error("No elk m1 with prefix for speak_phrase: '%s'", prefix)
return
Expand All @@ -227,12 +296,23 @@ def _speak_phrase_service(service):

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))
auto_configure = elk_data["auto_configure"]

if not auto_configure and not elk_data["config"][element_type]["enabled"]:
return

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


Expand Down Expand Up @@ -297,9 +377,34 @@ def _element_changed(self, element, changeset):
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)
self.async_write_ha_state()

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, {})

@property
def device_info(self):
"""Device info connecting via the ElkM1 system."""
return {
"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):
"""Device info for the underlying ElkM1 system."""
device_name = "ElkM1"
if self._prefix:
device_name += f" {self._prefix}"
return {
"name": device_name,
"identifiers": {(DOMAIN, f"{self._prefix}_system")},
"sw_version": self._elk.panel.elkm1_version,
"manufacturer": "ELK Products, Inc.",
"model": "M1",
}
Loading