diff --git a/CODEOWNERS b/CODEOWNERS index 62e6f0d8e6683b35c039b23ae1b27f2cbd144741..cbd4ae11c248589ad8f45aa8f58d34085dac15a7 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -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 diff --git a/homeassistant/components/elkm1/.translations/en.json b/homeassistant/components/elkm1/.translations/en.json new file mode 100644 index 0000000000000000000000000000000000000000..a5246a004c3ee2684cbecfd3b24c66c726a32a51 --- /dev/null +++ b/homeassistant/components/elkm1/.translations/en.json @@ -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" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/elkm1/__init__.py b/homeassistant/components/elkm1/__init__.py index 2acb8030cf1d2fc638d9addc3aa727bf0d9df025..2f08b046d9c06509d19644afaf18cf26b7d7a156 100644 --- a/homeassistant/components/elkm1/__init__.py +++ b/homeassistant/components/elkm1/__init__.py @@ -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 = {} - - 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) + 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_, @@ -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", + } diff --git a/homeassistant/components/elkm1/alarm_control_panel.py b/homeassistant/components/elkm1/alarm_control_panel.py index de1cb62234c78985aaa36c58d01f945fa12af979..d7cd5cf2ad09792f62962c055ca53b5f9487efab 100644 --- a/homeassistant/components/elkm1/alarm_control_panel.py +++ b/homeassistant/components/elkm1/alarm_control_panel.py @@ -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): - """Set up the ElkM1 alarm platform.""" - if discovery_info is None: - return - elk_datas = hass.data[DOMAIN] +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the ElkM1 alarm platform.""" + 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) - - 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 = entity_platform.current_platform.get() + + 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 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 _display_message(self, clear, beep, timeout, line1, line2): + 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) diff --git a/homeassistant/components/elkm1/climate.py b/homeassistant/components/elkm1/climate.py index abc9dc0933c57e427c2f024c97286e567f23cf80..3c5c70b2bd02d884b93167085c9bf228676d5d91 100644 --- a/homeassistant/components/elkm1/climate.py +++ b/homeassistant/components/elkm1/climate.py @@ -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): diff --git a/homeassistant/components/elkm1/config_flow.py b/homeassistant/components/elkm1/config_flow.py new file mode 100644 index 0000000000000000000000000000000000000000..cad3ecac42a72a663d1d996af246fb88e91c913a --- /dev/null +++ b/homeassistant/components/elkm1/config_flow.py @@ -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.""" diff --git a/homeassistant/components/elkm1/const.py b/homeassistant/components/elkm1/const.py new file mode 100644 index 0000000000000000000000000000000000000000..bad6d7fbcf1a646286f8ebc4d87cb2e8cb312106 --- /dev/null +++ b/homeassistant/components/elkm1/const.py @@ -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, +} diff --git a/homeassistant/components/elkm1/light.py b/homeassistant/components/elkm1/light.py index 10a9ae1b931e10091b58add7032b7a9f789208ec..b7cfe20dfd8eab73c10a076ee17cdb4ab7fce5ae 100644 --- a/homeassistant/components/elkm1/light.py +++ b/homeassistant/components/elkm1/light.py @@ -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) diff --git a/homeassistant/components/elkm1/manifest.json b/homeassistant/components/elkm1/manifest.json index c75da1ef039174d6bb57ab22f8392e5f3b9502fc..17b016fcb8b1157847de8e656f06a8360926cffe 100644 --- a/homeassistant/components/elkm1/manifest.json +++ b/homeassistant/components/elkm1/manifest.json @@ -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 } diff --git a/homeassistant/components/elkm1/scene.py b/homeassistant/components/elkm1/scene.py index dc5ea39d15478a9137a2f7fa840b042aa86b457a..1f894cc76814f12c6cbd88d6d916daf1e2240b85 100644 --- a/homeassistant/components/elkm1/scene.py +++ b/homeassistant/components/elkm1/scene.py @@ -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): diff --git a/homeassistant/components/elkm1/sensor.py b/homeassistant/components/elkm1/sensor.py index df29e1cda7ee375e43b1f02c5aa699201a91de30..79987d806a1fef7390ff10bd2459799d34f9ebb6 100644 --- a/homeassistant/components/elkm1/sensor.py +++ b/homeassistant/components/elkm1/sensor.py @@ -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): - """Create the Elk-M1 sensor platform.""" - if discovery_info is None: - return - elk_datas = hass.data[ELK_DOMAIN] +async def async_setup_entry(hass, config_entry, async_add_entities): + """Create the Elk-M1 sensor platform.""" + 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: diff --git a/homeassistant/components/elkm1/strings.json b/homeassistant/components/elkm1/strings.json new file mode 100644 index 0000000000000000000000000000000000000000..a5246a004c3ee2684cbecfd3b24c66c726a32a51 --- /dev/null +++ b/homeassistant/components/elkm1/strings.json @@ -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" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/elkm1/switch.py b/homeassistant/components/elkm1/switch.py index e6dd82dc0ac13651de8a46bf28180ff1640b0258..af32e81bc4c4b85b11193640236998730181eb78 100644 --- a/homeassistant/components/elkm1/switch.py +++ b/homeassistant/components/elkm1/switch.py @@ -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 diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 8c03702e8f95a23530765c8ebfef521461ae19dd..05bc4a7ba4a306232693e547f80a4a055bb8e3dd 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -28,6 +28,7 @@ FLOWS = [ "dynalite", "ecobee", "elgato", + "elkm1", "emulated_roku", "esphome", "freebox", diff --git a/requirements_all.txt b/requirements_all.txt index f0ba017f5740ae59572d0c4c3d6911ed07360625..8d377e3602d746ecfdbb8ea96a1aacbff81cd7a7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -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 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f94245b6df502ac2eee9f2e53f42f838f6be55f8..7bded1c1d217ecbd728996e89c534ed821c9d211 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -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 diff --git a/tests/components/elkm1/__init__.py b/tests/components/elkm1/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..8ae7f6d7b49eb14349d391615fd8c5dea1f0a33e --- /dev/null +++ b/tests/components/elkm1/__init__.py @@ -0,0 +1 @@ +"""Tests for the Elk-M1 Control integration.""" diff --git a/tests/components/elkm1/test_config_flow.py b/tests/components/elkm1/test_config_flow.py new file mode 100644 index 0000000000000000000000000000000000000000..466005f3d43c4f8d1e47e319382a443157575e84 --- /dev/null +++ b/tests/components/elkm1/test_config_flow.py @@ -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