Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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"
}
}
}
249 changes: 183 additions & 66 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 @@ -110,6 +118,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 @@ -132,34 +141,53 @@ def _has_all_unique_prefixes(value):

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

config = {"temperature_unit": conf[CONF_TEMPERATURE_UNIT]}
config["panel"] = {"enabled": True, "included": [True]}
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


async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
"""Set up Elk-M1 Control from a config entry."""

conf = entry.data

for item, max_ in configs.items():
_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 +199,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.

_create_elk_services(hass, devices)
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

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 +308,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 +389,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