Config flow for elkm1 (#33297)

* Config flow for elkm1

* As entity ids can now be changed, the “alarm_control_panel”
attribute “changed_by_entity_id” is now “changed_by_keypad”
and will show the name of the Elk keypad instead of the entity id.

* An auto configure mode has been introduced which avoids the
need to setup the complex include and exclude filters.  This
functionality still exists when configuring from yaml for power
users who want more control over which entities elkm1 generates.

* restore _has_all_unique_prefixes

* preserve legacy behavior of creating alarm_control_panels that have no linked keypads when auto_configure is False

* unroll loop
pull/33315/head
J. Nick Koston 2020-03-27 15:38:35 -05:00 committed by GitHub
parent c629e7dc0e
commit 18a4829314
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 823 additions and 214 deletions

View File

@ -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

View File

@ -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"
}
}
}

View File

@ -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,
@ -15,23 +17,29 @@ from homeassistant.const import (
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__)
@ -110,6 +118,7 @@ DEVICE_SCHEMA = vol.Schema(
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,
@ -132,34 +141,53 @@ CONFIG_SCHEMA = vol.Schema(
async def async_setup(hass: HomeAssistant, hass_config: ConfigType) -> bool:
"""Set up the Elk M1 platform."""
devices = {}
elk_datas = {}
hass.data.setdefault(DOMAIN, {})
_create_elk_services(hass)
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)
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)
continue
config = {"temperature_unit": conf[CONF_TEMPERATURE_UNIT]}
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
_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 configs.items():
for item, max_ in ELK_ELEMENTS.items():
config[item] = {
"enabled": conf[item][CONF_ENABLED],
"included": [not conf[item]["include"]] * max_,
@ -171,39 +199,92 @@ async def async_setup(hass: HomeAssistant, hass_config: ConfigType) -> bool:
_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()
_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()
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
@ -211,7 +292,7 @@ def _create_elk_services(hass, elks):
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
@ -227,12 +308,23 @@ def _create_elk_services(hass, elks):
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
@ -297,9 +389,34 @@ class ElkEntity(Entity):
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",
}

View File

@ -1,4 +1,6 @@
"""Each ElkM1 area will be created as a separate alarm_control_panel."""
import logging
from elkm1_lib.const import AlarmState, ArmedStatus, ArmLevel, ArmUpState
import voluptuous as vol
@ -22,24 +24,18 @@ from homeassistant.const import (
STATE_ALARM_PENDING,
STATE_ALARM_TRIGGERED,
)
from homeassistant.helpers import entity_platform
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.dispatcher import (
async_dispatcher_connect,
async_dispatcher_send,
)
from . import (
DOMAIN,
SERVICE_ALARM_ARM_HOME_INSTANT,
SERVICE_ALARM_ARM_NIGHT_INSTANT,
SERVICE_ALARM_ARM_VACATION,
SERVICE_ALARM_DISPLAY_MESSAGE,
ElkEntity,
ElkAttachedEntity,
create_elk_entities,
)
SIGNAL_ARM_ENTITY = "elkm1_arm"
SIGNAL_DISPLAY_MESSAGE = "elkm1_display_message"
from .const import DOMAIN
ELK_ALARM_SERVICE_SCHEMA = vol.Schema(
{
@ -61,69 +57,57 @@ DISPLAY_MESSAGE_SERVICE_SCHEMA = vol.Schema(
}
)
_LOGGER = logging.getLogger(__name__)
async def async_setup_platform(hass, config, async_add_entities, discovery_info=None):
async def async_setup_entry(hass, config_entry, async_add_entities):
"""Set up the ElkM1 alarm platform."""
if discovery_info is None:
return
elk_datas = hass.data[DOMAIN]
elk_data = hass.data[DOMAIN][config_entry.entry_id]
entities = []
for elk_data in elk_datas.values():
elk = elk_data["elk"]
entities = create_elk_entities(elk_data, elk.areas, "area", ElkArea, entities)
elk = elk_data["elk"]
areas_with_keypad = set()
for keypad in elk.keypads:
areas_with_keypad.add(keypad.area)
areas = []
for area in elk.areas:
if area.index in areas_with_keypad or elk_data["auto_configure"] is False:
areas.append(area)
create_elk_entities(elk_data, areas, "area", ElkArea, entities)
async_add_entities(entities, True)
def _dispatch(signal, entity_ids, *args):
for entity_id in entity_ids:
async_dispatcher_send(hass, f"{signal}_{entity_id}", *args)
platform = entity_platform.current_platform.get()
def _arm_service(service):
entity_ids = service.data.get(ATTR_ENTITY_ID, [])
arm_level = _arm_services().get(service.service)
args = (arm_level, service.data.get(ATTR_CODE))
_dispatch(SIGNAL_ARM_ENTITY, entity_ids, *args)
for service in _arm_services():
hass.services.async_register(
DOMAIN, service, _arm_service, ELK_ALARM_SERVICE_SCHEMA
)
def _display_message_service(service):
entity_ids = service.data.get(ATTR_ENTITY_ID, [])
data = service.data
args = (
data["clear"],
data["beep"],
data["timeout"],
data["line1"],
data["line2"],
)
_dispatch(SIGNAL_DISPLAY_MESSAGE, entity_ids, *args)
hass.services.async_register(
DOMAIN,
platform.async_register_entity_service(
SERVICE_ALARM_ARM_VACATION,
ELK_ALARM_SERVICE_SCHEMA,
"async_alarm_arm_vacation",
)
platform.async_register_entity_service(
SERVICE_ALARM_ARM_HOME_INSTANT,
ELK_ALARM_SERVICE_SCHEMA,
"async_alarm_arm_home_instant",
)
platform.async_register_entity_service(
SERVICE_ALARM_ARM_NIGHT_INSTANT,
ELK_ALARM_SERVICE_SCHEMA,
"async_alarm_arm_night_instant",
)
platform.async_register_entity_service(
SERVICE_ALARM_DISPLAY_MESSAGE,
_display_message_service,
DISPLAY_MESSAGE_SERVICE_SCHEMA,
"async_display_message",
)
def _arm_services():
return {
SERVICE_ALARM_ARM_VACATION: ArmLevel.ARMED_VACATION.value,
SERVICE_ALARM_ARM_HOME_INSTANT: ArmLevel.ARMED_STAY_INSTANT.value,
SERVICE_ALARM_ARM_NIGHT_INSTANT: ArmLevel.ARMED_NIGHT_INSTANT.value,
}
class ElkArea(ElkEntity, AlarmControlPanel):
class ElkArea(ElkAttachedEntity, AlarmControlPanel):
"""Representation of an Area / Partition within the ElkM1 alarm panel."""
def __init__(self, element, elk, elk_data):
"""Initialize Area as Alarm Control Panel."""
super().__init__(element, elk, elk_data)
self._changed_by_entity_id = ""
self._changed_by_keypad = None
self._state = None
async def async_added_to_hass(self):
@ -131,23 +115,13 @@ class ElkArea(ElkEntity, AlarmControlPanel):
await super().async_added_to_hass()
for keypad in self._elk.keypads:
keypad.add_callback(self._watch_keypad)
async_dispatcher_connect(
self.hass, f"{SIGNAL_ARM_ENTITY}_{self.entity_id}", self._arm_service
)
async_dispatcher_connect(
self.hass,
f"{SIGNAL_DISPLAY_MESSAGE}_{self.entity_id}",
self._display_message,
)
def _watch_keypad(self, keypad, changeset):
if keypad.area != self._element.index:
return
if changeset.get("last_user") is not None:
self._changed_by_entity_id = self.hass.data[DOMAIN][self._prefix][
"keypads"
].get(keypad.index, "")
self.async_schedule_update_ha_state(True)
self._changed_by_keypad = keypad.name
self.async_write_ha_state()
@property
def code_format(self):
@ -178,7 +152,7 @@ class ElkArea(ElkEntity, AlarmControlPanel):
attrs["arm_up_state"] = ArmUpState(elmt.arm_up_state).name.lower()
if elmt.alarm_state is not None:
attrs["alarm_state"] = AlarmState(elmt.alarm_state).name.lower()
attrs["changed_by_entity_id"] = self._changed_by_entity_id
attrs["changed_by_keypad"] = self._changed_by_keypad
return attrs
def _element_changed(self, element, changeset):
@ -225,9 +199,18 @@ class ElkArea(ElkEntity, AlarmControlPanel):
"""Send arm night command."""
self._element.arm(ArmLevel.ARMED_NIGHT.value, int(code))
async def _arm_service(self, arm_level, code):
self._element.arm(arm_level, code)
async def async_alarm_arm_home_instant(self, code=None):
"""Send arm stay instant command."""
self._element.arm(ArmLevel.ARMED_STAY_INSTANT.value, int(code))
async def _display_message(self, clear, beep, timeout, line1, line2):
async def async_alarm_arm_night_instant(self, code=None):
"""Send arm night instant command."""
self._element.arm(ArmLevel.ARMED_NIGHT_INSTANT.value, int(code))
async def async_alarm_arm_vacation(self, code=None):
"""Send arm vacation command."""
self._element.arm(ArmLevel.ARMED_VACATION.value, int(code))
async def async_display_message(self, clear, beep, timeout, line1, line2):
"""Display a message on all keypads for the area."""
self._element.display_message(clear, beep, timeout, line1, line2)

View File

@ -14,9 +14,10 @@ from homeassistant.components.climate.const import (
SUPPORT_FAN_MODE,
SUPPORT_TARGET_TEMPERATURE_RANGE,
)
from homeassistant.const import PRECISION_WHOLE, STATE_ON
from homeassistant.const import PRECISION_WHOLE, STATE_ON, TEMP_CELSIUS, TEMP_FAHRENHEIT
from . import DOMAIN as ELK_DOMAIN, ElkEntity, create_elk_entities
from . import ElkEntity, create_elk_entities
from .const import DOMAIN
SUPPORT_HVAC = [
HVAC_MODE_OFF,
@ -27,18 +28,14 @@ SUPPORT_HVAC = [
]
async def async_setup_platform(hass, config, async_add_entities, discovery_info=None):
async def async_setup_entry(hass, config_entry, async_add_entities):
"""Create the Elk-M1 thermostat platform."""
if discovery_info is None:
return
elk_datas = hass.data[ELK_DOMAIN]
elk_data = hass.data[DOMAIN][config_entry.entry_id]
entities = []
for elk_data in elk_datas.values():
elk = elk_data["elk"]
entities = create_elk_entities(
elk_data, elk.thermostats, "thermostat", ElkThermostat, entities
)
elk = elk_data["elk"]
create_elk_entities(
elk_data, elk.thermostats, "thermostat", ElkThermostat, entities
)
async_add_entities(entities, True)
@ -58,7 +55,7 @@ class ElkThermostat(ElkEntity, ClimateDevice):
@property
def temperature_unit(self):
"""Return the temperature unit."""
return self._temperature_unit
return TEMP_FAHRENHEIT if self._temperature_unit == "F" else TEMP_CELSIUS
@property
def current_temperature(self):

View File

@ -0,0 +1,164 @@
"""Config flow for Elk-M1 Control integration."""
import logging
from urllib.parse import urlparse
import elkm1_lib as elkm1
import voluptuous as vol
from homeassistant import config_entries, exceptions
from homeassistant.const import (
CONF_ADDRESS,
CONF_HOST,
CONF_PASSWORD,
CONF_PROTOCOL,
CONF_TEMPERATURE_UNIT,
CONF_USERNAME,
)
from homeassistant.util import slugify
from . import async_wait_for_elk_to_sync
from .const import CONF_AUTO_CONFIGURE, CONF_PREFIX
from .const import DOMAIN # pylint:disable=unused-import
_LOGGER = logging.getLogger(__name__)
PROTOCOL_MAP = {"secure": "elks://", "non-secure": "elk://", "serial": "serial://"}
DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_PROTOCOL, default="secure"): vol.In(
["secure", "non-secure", "serial"]
),
vol.Required(CONF_ADDRESS): str,
vol.Optional(CONF_USERNAME, default=""): str,
vol.Optional(CONF_PASSWORD, default=""): str,
vol.Optional(CONF_PREFIX, default=""): str,
vol.Optional(CONF_TEMPERATURE_UNIT, default="F"): vol.In(["F", "C"]),
}
)
VALIDATE_TIMEOUT = 35
async def validate_input(data):
"""Validate the user input allows us to connect.
Data has the keys from DATA_SCHEMA with values provided by the user.
"""
userid = data.get(CONF_USERNAME)
password = data.get(CONF_PASSWORD)
prefix = data[CONF_PREFIX]
url = _make_url_from_data(data)
requires_password = url.startswith("elks://")
if requires_password and (not userid or not password):
raise InvalidAuth
elk = elkm1.Elk(
{"url": url, "userid": userid, "password": password, "element_list": ["panel"]}
)
elk.connect()
timed_out = False
if not await async_wait_for_elk_to_sync(elk, VALIDATE_TIMEOUT):
_LOGGER.error(
"Timed out after %d seconds while trying to sync with elkm1",
VALIDATE_TIMEOUT,
)
timed_out = True
elk.disconnect()
if timed_out:
raise CannotConnect
if elk.invalid_auth:
raise InvalidAuth
device_name = data[CONF_PREFIX] if data[CONF_PREFIX] else "ElkM1"
# Return info that you want to store in the config entry.
return {"title": device_name, CONF_HOST: url, CONF_PREFIX: slugify(prefix)}
def _make_url_from_data(data):
host = data.get(CONF_HOST)
if host:
return host
protocol = PROTOCOL_MAP[data[CONF_PROTOCOL]]
address = data[CONF_ADDRESS]
return f"{protocol}{address}"
class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Elk-M1 Control."""
VERSION = 1
CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_PUSH
def __init__(self):
"""Initialize the elkm1 config flow."""
self.importing = False
async def async_step_user(self, user_input=None):
"""Handle the initial step."""
errors = {}
if user_input is not None:
if self._url_already_configured(_make_url_from_data(user_input)):
return self.async_abort(reason="address_already_configured")
try:
info = await validate_input(user_input)
except CannotConnect:
errors["base"] = "cannot_connect"
except InvalidAuth:
errors["base"] = "invalid_auth"
except Exception: # pylint: disable=broad-except
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
if "base" not in errors:
await self.async_set_unique_id(user_input[CONF_PREFIX])
self._abort_if_unique_id_configured()
if self.importing:
return self.async_create_entry(title=info["title"], data=user_input)
return self.async_create_entry(
title=info["title"],
data={
CONF_HOST: info[CONF_HOST],
CONF_USERNAME: user_input[CONF_USERNAME],
CONF_PASSWORD: user_input[CONF_PASSWORD],
CONF_AUTO_CONFIGURE: True,
CONF_TEMPERATURE_UNIT: user_input[CONF_TEMPERATURE_UNIT],
CONF_PREFIX: info[CONF_PREFIX],
},
)
return self.async_show_form(
step_id="user", data_schema=DATA_SCHEMA, errors=errors
)
async def async_step_import(self, user_input):
"""Handle import."""
self.importing = True
return await self.async_step_user(user_input)
def _url_already_configured(self, url):
"""See if we already have a elkm1 matching user input configured."""
existing_hosts = {
urlparse(entry.data[CONF_HOST]).hostname
for entry in self._async_current_entries()
}
return urlparse(url).hostname in existing_hosts
class CannotConnect(exceptions.HomeAssistantError):
"""Error to indicate we cannot connect."""
class InvalidAuth(exceptions.HomeAssistantError):
"""Error to indicate there is invalid auth."""

View File

@ -0,0 +1,31 @@
"""Support the ElkM1 Gold and ElkM1 EZ8 alarm/integration panels."""
from elkm1_lib.const import Max
DOMAIN = "elkm1"
CONF_AUTO_CONFIGURE = "auto_configure"
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"
ELK_ELEMENTS = {
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,
}

View File

@ -1,18 +1,17 @@
"""Support for control of ElkM1 lighting (X10, UPB, etc)."""
from homeassistant.components.light import ATTR_BRIGHTNESS, SUPPORT_BRIGHTNESS, Light
from . import DOMAIN as ELK_DOMAIN, ElkEntity, create_elk_entities
from . import ElkEntity, create_elk_entities
from .const import DOMAIN
async def async_setup_platform(hass, config, async_add_entities, discovery_info=None):
async def async_setup_entry(hass, config_entry, async_add_entities):
"""Set up the Elk light platform."""
if discovery_info is None:
return
elk_datas = hass.data[ELK_DOMAIN]
elk_data = hass.data[DOMAIN][config_entry.entry_id]
entities = []
for elk_data in elk_datas.values():
elk = elk_data["elk"]
create_elk_entities(elk_data, elk.lights, "plc", ElkLight, entities)
elk = elk_data["elk"]
create_elk_entities(elk_data, elk.lights, "plc", ElkLight, entities)
async_add_entities(entities, True)

View File

@ -2,7 +2,12 @@
"domain": "elkm1",
"name": "Elk-M1 Control",
"documentation": "https://www.home-assistant.io/integrations/elkm1",
"requirements": ["elkm1-lib==0.7.15"],
"requirements": [
"elkm1-lib==0.7.17"
],
"dependencies": [],
"codeowners": []
"codeowners": [
"@bdraco"
],
"config_flow": true
}

View File

@ -1,22 +1,20 @@
"""Support for control of ElkM1 tasks ("macros")."""
from homeassistant.components.scene import Scene
from . import DOMAIN as ELK_DOMAIN, ElkEntity, create_elk_entities
from . import ElkAttachedEntity, create_elk_entities
from .const import DOMAIN
async def async_setup_platform(hass, config, async_add_entities, discovery_info=None):
async def async_setup_entry(hass, config_entry, async_add_entities):
"""Create the Elk-M1 scene platform."""
if discovery_info is None:
return
elk_datas = hass.data[ELK_DOMAIN]
elk_data = hass.data[DOMAIN][config_entry.entry_id]
entities = []
for elk_data in elk_datas.values():
elk = elk_data["elk"]
entities = create_elk_entities(elk_data, elk.tasks, "task", ElkTask, entities)
elk = elk_data["elk"]
create_elk_entities(elk_data, elk.tasks, "task", ElkTask, entities)
async_add_entities(entities, True)
class ElkTask(ElkEntity, Scene):
class ElkTask(ElkAttachedEntity, Scene):
"""Elk-M1 task as scene."""
async def async_activate(self):

View File

@ -7,31 +7,22 @@ from elkm1_lib.const import (
)
from elkm1_lib.util import pretty_const, username
from . import DOMAIN as ELK_DOMAIN, ElkEntity, create_elk_entities
from . import ElkAttachedEntity, create_elk_entities
from .const import DOMAIN
UNDEFINED_TEMPATURE = -40
async def async_setup_platform(hass, config, async_add_entities, discovery_info=None):
async def async_setup_entry(hass, config_entry, async_add_entities):
"""Create the Elk-M1 sensor platform."""
if discovery_info is None:
return
elk_datas = hass.data[ELK_DOMAIN]
elk_data = hass.data[DOMAIN][config_entry.entry_id]
entities = []
for elk_data in elk_datas.values():
elk = elk_data["elk"]
entities = create_elk_entities(
elk_data, elk.counters, "counter", ElkCounter, entities
)
entities = create_elk_entities(
elk_data, elk.keypads, "keypad", ElkKeypad, entities
)
entities = create_elk_entities(
elk_data, [elk.panel], "panel", ElkPanel, entities
)
entities = create_elk_entities(
elk_data, elk.settings, "setting", ElkSetting, entities
)
entities = create_elk_entities(elk_data, elk.zones, "zone", ElkZone, entities)
elk = elk_data["elk"]
create_elk_entities(elk_data, elk.counters, "counter", ElkCounter, entities)
create_elk_entities(elk_data, elk.keypads, "keypad", ElkKeypad, entities)
create_elk_entities(elk_data, [elk.panel], "panel", ElkPanel, entities)
create_elk_entities(elk_data, elk.settings, "setting", ElkSetting, entities)
create_elk_entities(elk_data, elk.zones, "zone", ElkZone, entities)
async_add_entities(entities, True)
@ -40,7 +31,7 @@ def temperature_to_state(temperature, undefined_temperature):
return temperature if temperature > undefined_temperature else None
class ElkSensor(ElkEntity):
class ElkSensor(ElkAttachedEntity):
"""Base representation of Elk-M1 sensor."""
def __init__(self, element, elk, elk_data):
@ -89,7 +80,7 @@ class ElkKeypad(ElkSensor):
"""Attributes of the sensor."""
attrs = self.initial_attrs()
attrs["area"] = self._element.area + 1
attrs["temperature"] = self._element.temperature
attrs["temperature"] = self._state
attrs["last_user_time"] = self._element.last_user_time.isoformat()
attrs["last_user"] = self._element.last_user + 1
attrs["code"] = self._element.code
@ -98,14 +89,9 @@ class ElkKeypad(ElkSensor):
return attrs
def _element_changed(self, element, changeset):
self._state = temperature_to_state(self._element.temperature, -40)
async def async_added_to_hass(self):
"""Register callback for ElkM1 changes and update entity state."""
await super().async_added_to_hass()
elk_datas = self.hass.data[ELK_DOMAIN]
for elk_data in elk_datas.values():
elk_data["keypads"][self._element.index] = self.entity_id
self._state = temperature_to_state(
self._element.temperature, UNDEFINED_TEMPATURE
)
class ElkPanel(ElkSensor):
@ -214,7 +200,9 @@ class ElkZone(ElkSensor):
def _element_changed(self, element, changeset):
if self._element.definition == ZoneType.TEMPERATURE.value:
self._state = temperature_to_state(self._element.temperature, -60)
self._state = temperature_to_state(
self._element.temperature, UNDEFINED_TEMPATURE
)
elif self._element.definition == ZoneType.ANALOG_ZONE.value:
self._state = self._element.voltage
else:

View File

@ -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"
}
}
}

View File

@ -1,24 +1,20 @@
"""Support for control of ElkM1 outputs (relays)."""
from homeassistant.components.switch import SwitchDevice
from . import DOMAIN as ELK_DOMAIN, ElkEntity, create_elk_entities
from . import ElkAttachedEntity, create_elk_entities
from .const import DOMAIN
async def async_setup_platform(hass, config, async_add_entities, discovery_info=None):
async def async_setup_entry(hass, config_entry, async_add_entities):
"""Create the Elk-M1 switch platform."""
if discovery_info is None:
return
elk_datas = hass.data[ELK_DOMAIN]
elk_data = hass.data[DOMAIN][config_entry.entry_id]
entities = []
for elk_data in elk_datas.values():
elk = elk_data["elk"]
entities = create_elk_entities(
elk_data, elk.outputs, "output", ElkOutput, entities
)
elk = elk_data["elk"]
create_elk_entities(elk_data, elk.outputs, "output", ElkOutput, entities)
async_add_entities(entities, True)
class ElkOutput(ElkEntity, SwitchDevice):
class ElkOutput(ElkAttachedEntity, SwitchDevice):
"""Elk output as switch."""
@property

View File

@ -28,6 +28,7 @@ FLOWS = [
"dynalite",
"ecobee",
"elgato",
"elkm1",
"emulated_roku",
"esphome",
"freebox",

View File

@ -495,7 +495,7 @@ elgato==0.2.0
eliqonline==1.2.2
# homeassistant.components.elkm1
elkm1-lib==0.7.15
elkm1-lib==0.7.17
# homeassistant.components.emulated_roku
emulated_roku==0.2.1

View File

@ -198,6 +198,9 @@ eebrightbox==0.0.4
# homeassistant.components.elgato
elgato==0.2.0
# homeassistant.components.elkm1
elkm1-lib==0.7.17
# homeassistant.components.emulated_roku
emulated_roku==0.2.1

View File

@ -0,0 +1 @@
"""Tests for the Elk-M1 Control integration."""

View File

@ -0,0 +1,269 @@
"""Test the Elk-M1 Control config flow."""
from asynctest import CoroutineMock, MagicMock, PropertyMock, patch
from homeassistant import config_entries, setup
from homeassistant.components.elkm1.const import DOMAIN
def mock_elk(invalid_auth=None, sync_complete=None):
"""Mock m1lib Elk."""
mocked_elk = MagicMock()
type(mocked_elk).invalid_auth = PropertyMock(return_value=invalid_auth)
type(mocked_elk).sync_complete = CoroutineMock()
return mocked_elk
async def test_form_user_with_secure_elk(hass):
"""Test we can setup a secure elk."""
await setup.async_setup_component(hass, "persistent_notification", {})
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] == "form"
assert result["errors"] == {}
mocked_elk = mock_elk(invalid_auth=False)
with patch(
"homeassistant.components.elkm1.config_flow.elkm1.Elk", return_value=mocked_elk,
), patch(
"homeassistant.components.elkm1.async_setup", return_value=True
) as mock_setup, patch(
"homeassistant.components.elkm1.async_setup_entry", return_value=True,
) as mock_setup_entry:
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
"protocol": "secure",
"address": "1.2.3.4",
"username": "test-username",
"password": "test-password",
"temperature_unit": "F",
"prefix": "",
},
)
assert result2["type"] == "create_entry"
assert result2["title"] == "ElkM1"
assert result2["data"] == {
"auto_configure": True,
"host": "elks://1.2.3.4",
"password": "test-password",
"prefix": "",
"temperature_unit": "F",
"username": "test-username",
}
await hass.async_block_till_done()
assert len(mock_setup.mock_calls) == 1
assert len(mock_setup_entry.mock_calls) == 1
async def test_form_user_with_non_secure_elk(hass):
"""Test we can setup a non-secure elk."""
await setup.async_setup_component(hass, "persistent_notification", {})
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] == "form"
assert result["errors"] == {}
mocked_elk = mock_elk(invalid_auth=False)
with patch(
"homeassistant.components.elkm1.config_flow.elkm1.Elk", return_value=mocked_elk,
), patch(
"homeassistant.components.elkm1.async_setup", return_value=True
) as mock_setup, patch(
"homeassistant.components.elkm1.async_setup_entry", return_value=True,
) as mock_setup_entry:
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
"protocol": "non-secure",
"address": "1.2.3.4",
"temperature_unit": "F",
"prefix": "guest_house",
},
)
assert result2["type"] == "create_entry"
assert result2["title"] == "guest_house"
assert result2["data"] == {
"auto_configure": True,
"host": "elk://1.2.3.4",
"prefix": "guest_house",
"username": "",
"password": "",
"temperature_unit": "F",
}
await hass.async_block_till_done()
assert len(mock_setup.mock_calls) == 1
assert len(mock_setup_entry.mock_calls) == 1
async def test_form_user_with_serial_elk(hass):
"""Test we can setup a serial elk."""
await setup.async_setup_component(hass, "persistent_notification", {})
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] == "form"
assert result["errors"] == {}
mocked_elk = mock_elk(invalid_auth=False)
with patch(
"homeassistant.components.elkm1.config_flow.elkm1.Elk", return_value=mocked_elk,
), patch(
"homeassistant.components.elkm1.async_setup", return_value=True
) as mock_setup, patch(
"homeassistant.components.elkm1.async_setup_entry", return_value=True,
) as mock_setup_entry:
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
"protocol": "serial",
"address": "/dev/ttyS0:115200",
"temperature_unit": "F",
"prefix": "",
},
)
assert result2["type"] == "create_entry"
assert result2["title"] == "ElkM1"
assert result2["data"] == {
"auto_configure": True,
"host": "serial:///dev/ttyS0:115200",
"prefix": "",
"username": "",
"password": "",
"temperature_unit": "F",
}
await hass.async_block_till_done()
assert len(mock_setup.mock_calls) == 1
assert len(mock_setup_entry.mock_calls) == 1
async def test_form_cannot_connect(hass):
"""Test we handle cannot connect error."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
mocked_elk = mock_elk(invalid_auth=False)
with patch(
"homeassistant.components.elkm1.config_flow.elkm1.Elk", return_value=mocked_elk,
), patch(
"homeassistant.components.elkm1.config_flow.async_wait_for_elk_to_sync",
return_value=False,
): # async_wait_for_elk_to_sync is being patched to avoid making the test wait 45s
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
"protocol": "secure",
"address": "1.2.3.4",
"username": "test-username",
"password": "test-password",
"temperature_unit": "F",
"prefix": "",
},
)
assert result2["type"] == "form"
assert result2["errors"] == {"base": "cannot_connect"}
async def test_form_invalid_auth(hass):
"""Test we handle cannot connect error."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
mocked_elk = mock_elk(invalid_auth=True)
with patch(
"homeassistant.components.elkm1.config_flow.elkm1.Elk", return_value=mocked_elk,
):
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
"protocol": "secure",
"address": "1.2.3.4",
"username": "test-username",
"password": "test-password",
"temperature_unit": "F",
"prefix": "",
},
)
assert result2["type"] == "form"
assert result2["errors"] == {"base": "invalid_auth"}
async def test_form_import(hass):
"""Test we get the form with import source."""
await setup.async_setup_component(hass, "persistent_notification", {})
mocked_elk = mock_elk(invalid_auth=False)
with patch(
"homeassistant.components.elkm1.config_flow.elkm1.Elk", return_value=mocked_elk,
), patch(
"homeassistant.components.elkm1.async_setup", return_value=True
) as mock_setup, patch(
"homeassistant.components.elkm1.async_setup_entry", return_value=True,
) as mock_setup_entry:
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_IMPORT},
data={
"host": "elks://1.2.3.4",
"username": "friend",
"password": "love",
"temperature_unit": "C",
"auto_configure": False,
"keypad": {
"enabled": True,
"exclude": [],
"include": [[1, 1], [2, 2], [3, 3]],
},
"output": {"enabled": False, "exclude": [], "include": []},
"counter": {"enabled": False, "exclude": [], "include": []},
"plc": {"enabled": False, "exclude": [], "include": []},
"prefix": "ohana",
"setting": {"enabled": False, "exclude": [], "include": []},
"area": {"enabled": False, "exclude": [], "include": []},
"task": {"enabled": False, "exclude": [], "include": []},
"thermostat": {"enabled": False, "exclude": [], "include": []},
"zone": {
"enabled": True,
"exclude": [[15, 15], [28, 208]],
"include": [],
},
},
)
assert result["type"] == "create_entry"
assert result["title"] == "ohana"
assert result["data"] == {
"auto_configure": False,
"host": "elks://1.2.3.4",
"keypad": {"enabled": True, "exclude": [], "include": [[1, 1], [2, 2], [3, 3]]},
"output": {"enabled": False, "exclude": [], "include": []},
"password": "love",
"plc": {"enabled": False, "exclude": [], "include": []},
"prefix": "ohana",
"setting": {"enabled": False, "exclude": [], "include": []},
"area": {"enabled": False, "exclude": [], "include": []},
"counter": {"enabled": False, "exclude": [], "include": []},
"task": {"enabled": False, "exclude": [], "include": []},
"temperature_unit": "C",
"thermostat": {"enabled": False, "exclude": [], "include": []},
"username": "friend",
"zone": {"enabled": True, "exclude": [[15, 15], [28, 208]], "include": []},
}
await hass.async_block_till_done()
assert len(mock_setup.mock_calls) == 1
assert len(mock_setup_entry.mock_calls) == 1