From 3435281bd1c1962773470e231799c6a16fd73104 Mon Sep 17 00:00:00 2001 From: Kit Klein <33464407+kit-klein@users.noreply.github.com> Date: Tue, 11 Feb 2020 16:04:42 -0500 Subject: [PATCH] Support Konnected Pro alarm panel, embrace async, leverage latest HA features/architecture (#30894) * fix unique_id computation for switches * update konnected component to use async, config entries, registries. Pro board support and tests * clean up formatting comments from PR * use standard interfaces in tests * migrate config flow to use options * address latest pr feedback * format for import as part of config schema validation * address pr feedback * lint fix * simplify check based on pr feedback * clarify default schema validation * fix other schema checks * fix translations Co-authored-by: Nate Clark --- CODEOWNERS | 2 +- .../konnected/.translations/en.json | 101 ++ .../components/konnected/__init__.py | 630 ++++------ .../components/konnected/binary_sensor.py | 24 +- .../components/konnected/config_flow.py | 739 ++++++++++++ homeassistant/components/konnected/const.py | 24 +- homeassistant/components/konnected/errors.py | 10 + .../components/konnected/manifest.json | 19 +- homeassistant/components/konnected/panel.py | 359 ++++++ homeassistant/components/konnected/sensor.py | 26 +- .../components/konnected/strings.json | 101 ++ homeassistant/components/konnected/switch.py | 53 +- homeassistant/generated/config_flows.py | 1 + homeassistant/generated/ssdp.py | 5 + requirements_all.txt | 2 +- requirements_test_all.txt | 3 + tests/components/konnected/__init__.py | 1 + .../components/konnected/test_config_flow.py | 1052 +++++++++++++++++ tests/components/konnected/test_init.py | 601 ++++++++++ tests/components/konnected/test_panel.py | 375 ++++++ 20 files changed, 3692 insertions(+), 436 deletions(-) create mode 100644 homeassistant/components/konnected/.translations/en.json create mode 100644 homeassistant/components/konnected/config_flow.py create mode 100644 homeassistant/components/konnected/errors.py create mode 100644 homeassistant/components/konnected/panel.py create mode 100644 homeassistant/components/konnected/strings.json create mode 100644 tests/components/konnected/__init__.py create mode 100644 tests/components/konnected/test_config_flow.py create mode 100644 tests/components/konnected/test_init.py create mode 100644 tests/components/konnected/test_panel.py diff --git a/CODEOWNERS b/CODEOWNERS index 8f44c3caebc..658fca1e8fc 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -186,7 +186,7 @@ homeassistant/components/kef/* @basnijholt homeassistant/components/keyboard_remote/* @bendavid homeassistant/components/knx/* @Julius2342 homeassistant/components/kodi/* @armills -homeassistant/components/konnected/* @heythisisnate +homeassistant/components/konnected/* @heythisisnate @kit-klein homeassistant/components/lametric/* @robbiet480 homeassistant/components/launch_library/* @ludeeus homeassistant/components/lcn/* @alengwenus diff --git a/homeassistant/components/konnected/.translations/en.json b/homeassistant/components/konnected/.translations/en.json new file mode 100644 index 00000000000..6459fcebc53 --- /dev/null +++ b/homeassistant/components/konnected/.translations/en.json @@ -0,0 +1,101 @@ +{ + "config": { + "abort": { + "already_configured": "Device is already configured", + "already_in_progress": "Config flow for device is already in progress.", + "not_konn_panel": "Not a recognized Konnected.io device", + "unknown": "Unknown error occurred" + }, + "error": { + "cannot_connect": "Unable to connect to a Konnected Panel at {host}:{port}" + }, + "step": { + "confirm": { + "description": "Model: {model}\nHost: {host}\nPort: {port}\n\nYou can configure the IO and panel behavior in the Konnected Alarm Panel settings.", + "title": "Konnected Device Ready" + }, + "user": { + "data": { + "host": "Konnected device IP address", + "port": "Konnected device port" + }, + "description": "Please enter the host information for your Konnected Panel.", + "title": "Discover Konnected Device" + } + }, + "title": "Konnected.io" + }, + "options": { + "abort": { + "not_konn_panel": "Not a recognized Konnected.io device" + }, + "error": {}, + "step": { + "options_binary": { + "data": { + "inverse": "Invert the open/close state", + "name": "Name (optional)", + "type": "Binary Sensor Type" + }, + "description": "Please select the options for the binary sensor attached to {zone}", + "title": "Configure Binary Sensor" + }, + "options_digital": { + "data": { + "name": "Name (optional)", + "poll_interval": "Poll Interval (minutes) (optional)", + "type": "Sensor Type" + }, + "description": "Please select the options for the digital sensor attached to {zone}", + "title": "Configure Digital Sensor" + }, + "options_io": { + "data": { + "1": "Zone 1", + "2": "Zone 2", + "3": "Zone 3", + "4": "Zone 4", + "5": "Zone 5", + "6": "Zone 6", + "7": "Zone 7", + "out": "OUT" + }, + "description": "Discovered a {model} at {host}. Select the base configuration of each I/O below - depending on the I/O it may allow for binary sensors (open/close contacts), digital sensors (dht and ds18b20), or switchable outputs. You'll be able to configure detailed options in the next steps.", + "title": "Configure I/O" + }, + "options_io_ext": { + "data": { + "10": "Zone 10", + "11": "Zone 11", + "12": "Zone 12", + "8": "Zone 8", + "9": "Zone 9", + "alarm1": "ALARM1", + "alarm2_out2": "OUT2/ALARM2", + "out1": "OUT1" + }, + "description": "Select the configuration of the remaining I/O below. You'll be able to configure detailed options in the next steps.", + "title": "Configure Extended I/O" + }, + "options_misc": { + "data": { + "blink": "Blink panel LED on when sending state change" + }, + "description": "Please select the desired behavior for your panel", + "title": "Configure Misc" + }, + "options_switch": { + "data": { + "activation": "Output when on", + "momentary": "Pulse duration (ms) (optional)", + "name": "Name (optional)", + "pause": "Pause between pulses (ms) (optional)", + "repeat": "Times to repeat (-1=infinite) (optional)" + }, + "description": "Please select the output options for {zone}", + "title": "Configure Switchable Output" + } + }, + "title": "Konnected Alarm Panel Options" + } +} \ No newline at end of file diff --git a/homeassistant/components/konnected/__init__.py b/homeassistant/components/konnected/__init__.py index 28e62c322ad..94508b01483 100644 --- a/homeassistant/components/konnected/__init__.py +++ b/homeassistant/components/konnected/__init__.py @@ -1,20 +1,20 @@ """Support for Konnected devices.""" import asyncio +import copy import hmac import json import logging from aiohttp.hdrs import AUTHORIZATION from aiohttp.web import Request, Response -import konnected import voluptuous as vol +from homeassistant import config_entries from homeassistant.components.binary_sensor import DEVICE_CLASSES_SCHEMA -from homeassistant.components.discovery import SERVICE_KONNECTED from homeassistant.components.http import HomeAssistantView +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_ENTITY_ID, - ATTR_STATE, CONF_ACCESS_TOKEN, CONF_BINARY_SENSORS, CONF_DEVICES, @@ -27,45 +27,106 @@ from homeassistant.const import ( CONF_SWITCHES, CONF_TYPE, CONF_ZONE, - EVENT_HOMEASSISTANT_START, HTTP_BAD_REQUEST, HTTP_NOT_FOUND, HTTP_UNAUTHORIZED, + STATE_OFF, STATE_ON, ) -from homeassistant.helpers import config_validation as cv, discovery -from homeassistant.helpers.dispatcher import dispatcher_send +from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv +from .config_flow import ( # Loading the config flow file will register the flow + CONF_DEFAULT_OPTIONS, + CONF_IO, + CONF_IO_BIN, + CONF_IO_DIG, + CONF_IO_SWI, + OPTIONS_SCHEMA, +) from .const import ( CONF_ACTIVATION, CONF_API_HOST, CONF_BLINK, - CONF_DHT_SENSORS, CONF_DISCOVERY, - CONF_DS18B20_SENSORS, CONF_INVERSE, CONF_MOMENTARY, CONF_PAUSE, CONF_POLL_INTERVAL, CONF_REPEAT, DOMAIN, - ENDPOINT_ROOT, PIN_TO_ZONE, - SIGNAL_SENSOR_UPDATE, STATE_HIGH, STATE_LOW, UPDATE_ENDPOINT, ZONE_TO_PIN, + ZONES, ) +from .errors import CannotConnect from .handlers import HANDLERS +from .panel import AlarmPanel _LOGGER = logging.getLogger(__name__) -_BINARY_SENSOR_SCHEMA = vol.All( + +def ensure_pin(value): + """Check if valid pin and coerce to string.""" + if value is None: + raise vol.Invalid("pin value is None") + + if PIN_TO_ZONE.get(str(value)) is None: + raise vol.Invalid("pin not valid") + + return str(value) + + +def ensure_zone(value): + """Check if valid zone and coerce to string.""" + if value is None: + raise vol.Invalid("zone value is None") + + if str(value) not in ZONES is None: + raise vol.Invalid("zone not valid") + + return str(value) + + +def import_validator(config): + """Validate zones and reformat for import.""" + config = copy.deepcopy(config) + io_cfgs = {} + # Replace pins with zones + for conf_platform, conf_io in ( + (CONF_BINARY_SENSORS, CONF_IO_BIN), + (CONF_SENSORS, CONF_IO_DIG), + (CONF_SWITCHES, CONF_IO_SWI), + ): + for zone in config.get(conf_platform, []): + if zone.get(CONF_PIN): + zone[CONF_ZONE] = PIN_TO_ZONE[zone[CONF_PIN]] + del zone[CONF_PIN] + io_cfgs[zone[CONF_ZONE]] = conf_io + + # Migrate config_entry data into default_options structure + config[CONF_IO] = io_cfgs + config[CONF_DEFAULT_OPTIONS] = OPTIONS_SCHEMA(config) + + # clean up fields migrated to options + config.pop(CONF_BINARY_SENSORS, None) + config.pop(CONF_SENSORS, None) + config.pop(CONF_SWITCHES, None) + config.pop(CONF_BLINK, None) + config.pop(CONF_DISCOVERY, None) + config.pop(CONF_IO, None) + return config + + +# configuration.yaml schemas (legacy) +BINARY_SENSOR_SCHEMA_YAML = vol.All( vol.Schema( { - vol.Exclusive(CONF_PIN, "s_pin"): vol.Any(*PIN_TO_ZONE), - vol.Exclusive(CONF_ZONE, "s_pin"): vol.Any(*ZONE_TO_PIN), + vol.Exclusive(CONF_ZONE, "s_io"): ensure_zone, + vol.Exclusive(CONF_PIN, "s_io"): ensure_pin, vol.Required(CONF_TYPE): DEVICE_CLASSES_SCHEMA, vol.Optional(CONF_NAME): cv.string, vol.Optional(CONF_INVERSE, default=False): cv.boolean, @@ -74,14 +135,14 @@ _BINARY_SENSOR_SCHEMA = vol.All( cv.has_at_least_one_key(CONF_PIN, CONF_ZONE), ) -_SENSOR_SCHEMA = vol.All( +SENSOR_SCHEMA_YAML = vol.All( vol.Schema( { - vol.Exclusive(CONF_PIN, "s_pin"): vol.Any(*PIN_TO_ZONE), - vol.Exclusive(CONF_ZONE, "s_pin"): vol.Any(*ZONE_TO_PIN), + vol.Exclusive(CONF_ZONE, "s_io"): ensure_zone, + vol.Exclusive(CONF_PIN, "s_io"): ensure_pin, vol.Required(CONF_TYPE): vol.All(vol.Lower, vol.In(["dht", "ds18b20"])), vol.Optional(CONF_NAME): cv.string, - vol.Optional(CONF_POLL_INTERVAL): vol.All( + vol.Optional(CONF_POLL_INTERVAL, default=3): vol.All( vol.Coerce(int), vol.Range(min=1) ), } @@ -89,11 +150,11 @@ _SENSOR_SCHEMA = vol.All( cv.has_at_least_one_key(CONF_PIN, CONF_ZONE), ) -_SWITCH_SCHEMA = vol.All( +SWITCH_SCHEMA_YAML = vol.All( vol.Schema( { - vol.Exclusive(CONF_PIN, "a_pin"): vol.Any(*PIN_TO_ZONE), - vol.Exclusive(CONF_ZONE, "a_pin"): vol.Any(*ZONE_TO_PIN), + vol.Exclusive(CONF_ZONE, "s_io"): ensure_zone, + vol.Exclusive(CONF_PIN, "s_io"): ensure_pin, vol.Optional(CONF_NAME): cv.string, vol.Optional(CONF_ACTIVATION, default=STATE_HIGH): vol.All( vol.Lower, vol.Any(STATE_HIGH, STATE_LOW) @@ -106,6 +167,24 @@ _SWITCH_SCHEMA = vol.All( cv.has_at_least_one_key(CONF_PIN, CONF_ZONE), ) +DEVICE_SCHEMA_YAML = vol.All( + vol.Schema( + { + vol.Required(CONF_ID): cv.matches_regex("[0-9a-f]{12}"), + vol.Optional(CONF_BINARY_SENSORS): vol.All( + cv.ensure_list, [BINARY_SENSOR_SCHEMA_YAML] + ), + vol.Optional(CONF_SENSORS): vol.All(cv.ensure_list, [SENSOR_SCHEMA_YAML]), + vol.Optional(CONF_SWITCHES): vol.All(cv.ensure_list, [SWITCH_SCHEMA_YAML]), + vol.Inclusive(CONF_HOST, "host_info"): cv.string, + vol.Inclusive(CONF_PORT, "host_info"): cv.port, + vol.Optional(CONF_BLINK, default=True): cv.boolean, + vol.Optional(CONF_DISCOVERY, default=True): cv.boolean, + } + ), + import_validator, +) + # pylint: disable=no-value-for-parameter CONFIG_SCHEMA = vol.Schema( { @@ -113,352 +192,88 @@ CONFIG_SCHEMA = vol.Schema( { vol.Required(CONF_ACCESS_TOKEN): cv.string, vol.Optional(CONF_API_HOST): vol.Url(), - vol.Required(CONF_DEVICES): [ - { - vol.Required(CONF_ID): cv.matches_regex("[0-9a-f]{12}"), - vol.Optional(CONF_BINARY_SENSORS): vol.All( - cv.ensure_list, [_BINARY_SENSOR_SCHEMA] - ), - vol.Optional(CONF_SENSORS): vol.All( - cv.ensure_list, [_SENSOR_SCHEMA] - ), - vol.Optional(CONF_SWITCHES): vol.All( - cv.ensure_list, [_SWITCH_SCHEMA] - ), - vol.Optional(CONF_HOST): cv.string, - vol.Optional(CONF_PORT): cv.port, - vol.Optional(CONF_BLINK, default=True): cv.boolean, - vol.Optional(CONF_DISCOVERY, default=True): cv.boolean, - } - ], + vol.Optional(CONF_DEVICES): vol.All( + cv.ensure_list, [DEVICE_SCHEMA_YAML] + ), } ) }, extra=vol.ALLOW_EXTRA, ) +YAML_CONFIGS = "yaml_configs" +PLATFORMS = ["binary_sensor", "sensor", "switch"] -async def async_setup(hass, config): + +async def async_setup(hass: HomeAssistant, config: dict): """Set up the Konnected platform.""" cfg = config.get(DOMAIN) if cfg is None: cfg = {} - access_token = cfg.get(CONF_ACCESS_TOKEN) if DOMAIN not in hass.data: hass.data[DOMAIN] = { - CONF_ACCESS_TOKEN: access_token, + CONF_ACCESS_TOKEN: cfg.get(CONF_ACCESS_TOKEN), CONF_API_HOST: cfg.get(CONF_API_HOST), + CONF_DEVICES: {}, } - def setup_device(host, port): - """Set up a Konnected device at `host` listening on `port`.""" - discovered = DiscoveredDevice(hass, host, port) - if discovered.is_configured: - discovered.setup() - else: - _LOGGER.warning( - "Konnected device %s was discovered on the network" - " but not specified in configuration.yaml", - discovered.device_id, + hass.http.register_view(KonnectedView) + + # Check if they have yaml configured devices + if CONF_DEVICES not in cfg: + return True + + for device in cfg.get(CONF_DEVICES, []): + # Attempt to importing the cfg. Use + # hass.async_add_job to avoid a deadlock. + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=device, ) - - def device_discovered(service, info): - """Call when a Konnected device has been discovered.""" - host = info.get(CONF_HOST) - port = info.get(CONF_PORT) - setup_device(host, port) - - async def manual_discovery(event): - """Init devices on the network with manually assigned addresses.""" - specified = [ - dev - for dev in cfg.get(CONF_DEVICES) - if dev.get(CONF_HOST) and dev.get(CONF_PORT) - ] - - while specified: - for dev in specified: - _LOGGER.debug( - "Discovering Konnected device %s at %s:%s", - dev.get(CONF_ID), - dev.get(CONF_HOST), - dev.get(CONF_PORT), - ) - try: - await hass.async_add_executor_job( - setup_device, dev.get(CONF_HOST), dev.get(CONF_PORT) - ) - specified.remove(dev) - except konnected.Client.ClientError as err: - _LOGGER.error(err) - await asyncio.sleep(10) # try again in 10 seconds - - # Initialize devices specified in the configuration on boot - for device in cfg.get(CONF_DEVICES): - ConfiguredDevice(hass, device, config).save_data() - - discovery.async_listen(hass, SERVICE_KONNECTED, device_discovered) - - hass.http.register_view(KonnectedView(access_token)) - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, manual_discovery) - + ) return True -class ConfiguredDevice: - """A representation of a configured Konnected device.""" +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): + """Set up panel from a config entry.""" + client = AlarmPanel(hass, entry) + # create a data store in hass.data[DOMAIN][CONF_DEVICES] + await client.async_save_data() - def __init__(self, hass, config, hass_config): - """Initialize the Konnected device.""" - self.hass = hass - self.config = config - self.hass_config = hass_config + try: + await client.async_connect() + except CannotConnect: + # this will trigger a retry in the future + raise config_entries.ConfigEntryNotReady - @property - def device_id(self): - """Device id is the MAC address as string with punctuation removed.""" - return self.config.get(CONF_ID) - - def save_data(self): - """Save the device configuration to `hass.data`.""" - binary_sensors = {} - for entity in self.config.get(CONF_BINARY_SENSORS) or []: - if CONF_ZONE in entity: - pin = ZONE_TO_PIN[entity[CONF_ZONE]] - else: - pin = entity[CONF_PIN] - - binary_sensors[pin] = { - CONF_TYPE: entity[CONF_TYPE], - CONF_NAME: entity.get( - CONF_NAME, - "Konnected {} Zone {}".format(self.device_id[6:], PIN_TO_ZONE[pin]), - ), - CONF_INVERSE: entity.get(CONF_INVERSE), - ATTR_STATE: None, - } - _LOGGER.debug( - "Set up binary_sensor %s (initial state: %s)", - binary_sensors[pin].get("name"), - binary_sensors[pin].get(ATTR_STATE), - ) - - actuators = [] - for entity in self.config.get(CONF_SWITCHES) or []: - if CONF_ZONE in entity: - pin = ZONE_TO_PIN[entity[CONF_ZONE]] - else: - pin = entity[CONF_PIN] - - act = { - CONF_PIN: pin, - CONF_NAME: entity.get( - CONF_NAME, - "Konnected {} Actuator {}".format( - self.device_id[6:], PIN_TO_ZONE[pin] - ), - ), - ATTR_STATE: None, - CONF_ACTIVATION: entity[CONF_ACTIVATION], - CONF_MOMENTARY: entity.get(CONF_MOMENTARY), - CONF_PAUSE: entity.get(CONF_PAUSE), - CONF_REPEAT: entity.get(CONF_REPEAT), - } - actuators.append(act) - _LOGGER.debug("Set up switch %s", act) - - sensors = [] - for entity in self.config.get(CONF_SENSORS) or []: - if CONF_ZONE in entity: - pin = ZONE_TO_PIN[entity[CONF_ZONE]] - else: - pin = entity[CONF_PIN] - - sensor = { - CONF_PIN: pin, - CONF_NAME: entity.get( - CONF_NAME, - "Konnected {} Sensor {}".format( - self.device_id[6:], PIN_TO_ZONE[pin] - ), - ), - CONF_TYPE: entity[CONF_TYPE], - CONF_POLL_INTERVAL: entity.get(CONF_POLL_INTERVAL), - } - sensors.append(sensor) - _LOGGER.debug( - "Set up %s sensor %s (initial state: %s)", - sensor.get(CONF_TYPE), - sensor.get(CONF_NAME), - sensor.get(ATTR_STATE), - ) - - device_data = { - CONF_BINARY_SENSORS: binary_sensors, - CONF_SENSORS: sensors, - CONF_SWITCHES: actuators, - CONF_BLINK: self.config.get(CONF_BLINK), - CONF_DISCOVERY: self.config.get(CONF_DISCOVERY), - } - - if CONF_DEVICES not in self.hass.data[DOMAIN]: - self.hass.data[DOMAIN][CONF_DEVICES] = {} - - _LOGGER.debug( - "Storing data in hass.data[%s][%s][%s]: %s", - DOMAIN, - CONF_DEVICES, - self.device_id, - device_data, + for component in PLATFORMS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, component) ) - self.hass.data[DOMAIN][CONF_DEVICES][self.device_id] = device_data - - for platform in ["binary_sensor", "sensor", "switch"]: - discovery.load_platform( - self.hass, - platform, - DOMAIN, - {"device_id": self.device_id}, - self.hass_config, - ) + entry.add_update_listener(async_entry_updated) + return True -class DiscoveredDevice: - """A representation of a discovered Konnected device.""" - - def __init__(self, hass, host, port): - """Initialize the Konnected device.""" - self.hass = hass - self.host = host - self.port = port - - self.client = konnected.Client(host, str(port)) - self.status = self.client.get_status() - - def setup(self): - """Set up a newly discovered Konnected device.""" - _LOGGER.info( - "Discovered Konnected device %s. Open http://%s:%s in a " - "web browser to view device status.", - self.device_id, - self.host, - self.port, +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 PLATFORMS + ] ) - self.save_data() - self.update_initial_states() - self.sync_device_config() + ) + if unload_ok: + hass.data[DOMAIN][CONF_DEVICES].pop(entry.data[CONF_ID]) - def save_data(self): - """Save the discovery information to `hass.data`.""" - self.stored_configuration["client"] = self.client - self.stored_configuration["host"] = self.host - self.stored_configuration["port"] = self.port + return unload_ok - @property - def device_id(self): - """Device id is the MAC address as string with punctuation removed.""" - return self.status["mac"].replace(":", "") - @property - def is_configured(self): - """Return true if device_id is specified in the configuration.""" - return bool(self.hass.data[DOMAIN][CONF_DEVICES].get(self.device_id)) - - @property - def stored_configuration(self): - """Return the configuration stored in `hass.data` for this device.""" - return self.hass.data[DOMAIN][CONF_DEVICES].get(self.device_id) - - def binary_sensor_configuration(self): - """Return the configuration map for syncing binary sensors.""" - return [{"pin": p} for p in self.stored_configuration[CONF_BINARY_SENSORS]] - - def actuator_configuration(self): - """Return the configuration map for syncing actuators.""" - return [ - { - "pin": data.get(CONF_PIN), - "trigger": (0 if data.get(CONF_ACTIVATION) in [0, STATE_LOW] else 1), - } - for data in self.stored_configuration[CONF_SWITCHES] - ] - - def dht_sensor_configuration(self): - """Return the configuration map for syncing DHT sensors.""" - return [ - {CONF_PIN: sensor[CONF_PIN], CONF_POLL_INTERVAL: sensor[CONF_POLL_INTERVAL]} - for sensor in self.stored_configuration[CONF_SENSORS] - if sensor[CONF_TYPE] == "dht" - ] - - def ds18b20_sensor_configuration(self): - """Return the configuration map for syncing DS18B20 sensors.""" - return [ - {"pin": sensor[CONF_PIN]} - for sensor in self.stored_configuration[CONF_SENSORS] - if sensor[CONF_TYPE] == "ds18b20" - ] - - def update_initial_states(self): - """Update the initial state of each sensor from status poll.""" - for sensor_data in self.status.get("sensors"): - sensor_config = self.stored_configuration[CONF_BINARY_SENSORS].get( - sensor_data.get(CONF_PIN), {} - ) - entity_id = sensor_config.get(ATTR_ENTITY_ID) - - state = bool(sensor_data.get(ATTR_STATE)) - if sensor_config.get(CONF_INVERSE): - state = not state - - dispatcher_send(self.hass, SIGNAL_SENSOR_UPDATE.format(entity_id), state) - - def desired_settings_payload(self): - """Return a dict representing the desired device configuration.""" - desired_api_host = ( - self.hass.data[DOMAIN].get(CONF_API_HOST) or self.hass.config.api.base_url - ) - desired_api_endpoint = desired_api_host + ENDPOINT_ROOT - - return { - "sensors": self.binary_sensor_configuration(), - "actuators": self.actuator_configuration(), - "dht_sensors": self.dht_sensor_configuration(), - "ds18b20_sensors": self.ds18b20_sensor_configuration(), - "auth_token": self.hass.data[DOMAIN].get(CONF_ACCESS_TOKEN), - "endpoint": desired_api_endpoint, - "blink": self.stored_configuration.get(CONF_BLINK), - "discovery": self.stored_configuration.get(CONF_DISCOVERY), - } - - def current_settings_payload(self): - """Return a dict of configuration currently stored on the device.""" - settings = self.status["settings"] - if not settings: - settings = {} - - return { - "sensors": [{"pin": s[CONF_PIN]} for s in self.status.get("sensors")], - "actuators": self.status.get("actuators"), - "dht_sensors": self.status.get(CONF_DHT_SENSORS), - "ds18b20_sensors": self.status.get(CONF_DS18B20_SENSORS), - "auth_token": settings.get("token"), - "endpoint": settings.get("apiUrl"), - "blink": settings.get(CONF_BLINK), - "discovery": settings.get(CONF_DISCOVERY), - } - - def sync_device_config(self): - """Sync the new pin configuration to the Konnected device if needed.""" - _LOGGER.debug( - "Device %s settings payload: %s", - self.device_id, - self.desired_settings_payload(), - ) - if self.desired_settings_payload() != self.current_settings_payload(): - _LOGGER.info("pushing settings to device %s", self.device_id) - self.client.put_settings(**self.desired_settings_payload()) +async def async_entry_updated(hass: HomeAssistant, entry: ConfigEntry): + """Reload the config entry when options change.""" + await hass.config_entries.async_reload(entry.entry_id) class KonnectedView(HomeAssistantView): @@ -468,9 +283,8 @@ class KonnectedView(HomeAssistantView): name = "api:konnected" requires_auth = False # Uses access token from configuration - def __init__(self, auth_token): + def __init__(self): """Initialize the view.""" - self.auth_token = auth_token @staticmethod def binary_value(state, activation): @@ -479,50 +293,29 @@ class KonnectedView(HomeAssistantView): return 1 if state == STATE_ON else 0 return 0 if state == STATE_ON else 1 - async def get(self, request: Request, device_id) -> Response: - """Return the current binary state of a switch.""" + async def update_sensor(self, request: Request, device_id) -> Response: + """Process a put or post.""" hass = request.app["hass"] - pin_num = int(request.query.get("pin")) data = hass.data[DOMAIN] - device = data[CONF_DEVICES][device_id] - if not device: - return self.json_message( - f"Device {device_id} not configured", status_code=HTTP_NOT_FOUND - ) - - try: - pin = next( - filter( - lambda switch: switch[CONF_PIN] == pin_num, device[CONF_SWITCHES] - ) - ) - except StopIteration: - pin = None - - if not pin: - return self.json_message( - format("Switch on pin {} not configured", pin_num), - status_code=HTTP_NOT_FOUND, - ) - - return self.json( - { - "pin": pin_num, - "state": self.binary_value( - hass.states.get(pin[ATTR_ENTITY_ID]).state, pin[CONF_ACTIVATION] - ), - } + auth = request.headers.get(AUTHORIZATION, None) + tokens = [] + if hass.data[DOMAIN].get(CONF_ACCESS_TOKEN): + tokens.extend([hass.data[DOMAIN][CONF_ACCESS_TOKEN]]) + tokens.extend( + [ + entry.data[CONF_ACCESS_TOKEN] + for entry in hass.config_entries.async_entries(DOMAIN) + ] ) - - async def put(self, request: Request, device_id) -> Response: - """Receive a sensor update via PUT request and async set state.""" - hass = request.app["hass"] - data = hass.data[DOMAIN] + if auth is None or not next( + (True for token in tokens if hmac.compare_digest(f"Bearer {token}", auth)), + False, + ): + return self.json_message("unauthorized", status_code=HTTP_UNAUTHORIZED) try: # Konnected 2.2.0 and above supports JSON payloads payload = await request.json() - pin_num = payload["pin"] except json.decoder.JSONDecodeError: _LOGGER.error( ( @@ -532,30 +325,97 @@ class KonnectedView(HomeAssistantView): ) ) - auth = request.headers.get(AUTHORIZATION, None) - if not hmac.compare_digest(f"Bearer {self.auth_token}", auth): - return self.json_message("unauthorized", status_code=HTTP_UNAUTHORIZED) - pin_num = int(pin_num) device = data[CONF_DEVICES].get(device_id) if device is None: return self.json_message( "unregistered device", status_code=HTTP_BAD_REQUEST ) - pin_data = device[CONF_BINARY_SENSORS].get(pin_num) or next( - (s for s in device[CONF_SENSORS] if s[CONF_PIN] == pin_num), None - ) - if pin_data is None: + try: + zone_num = str(payload.get(CONF_ZONE) or PIN_TO_ZONE[payload[CONF_PIN]]) + zone_data = device[CONF_BINARY_SENSORS].get(zone_num) or next( + (s for s in device[CONF_SENSORS] if s[CONF_ZONE] == zone_num), None + ) + except KeyError: + zone_data = None + + if zone_data is None: return self.json_message( "unregistered sensor/actuator", status_code=HTTP_BAD_REQUEST ) - pin_data["device_id"] = device_id + zone_data["device_id"] = device_id for attr in ["state", "temp", "humi", "addr"]: value = payload.get(attr) handler = HANDLERS.get(attr) if value is not None and handler: - hass.async_create_task(handler(hass, pin_data, payload)) + hass.async_create_task(handler(hass, zone_data, payload)) return self.json_message("ok") + + async def get(self, request: Request, device_id) -> Response: + """Return the current binary state of a switch.""" + hass = request.app["hass"] + data = hass.data[DOMAIN] + + device = data[CONF_DEVICES].get(device_id) + if not device: + return self.json_message( + f"Device {device_id} not configured", status_code=HTTP_NOT_FOUND + ) + + # Our data model is based on zone ids but we convert from/to pin ids + # based on whether they are specified in the request + try: + zone_num = str( + request.query.get(CONF_ZONE) or PIN_TO_ZONE[request.query[CONF_PIN]] + ) + zone = next( + ( + switch + for switch in device[CONF_SWITCHES] + if switch[CONF_ZONE] == zone_num + ) + ) + + except StopIteration: + zone = None + except KeyError: + zone = None + zone_num = None + + if not zone: + target = request.query.get( + CONF_ZONE, request.query.get(CONF_PIN, "unknown") + ) + return self.json_message( + f"Switch on zone or pin {target} not configured", + status_code=HTTP_NOT_FOUND, + ) + + resp = {} + if request.query.get(CONF_ZONE): + resp[CONF_ZONE] = zone_num + else: + resp[CONF_PIN] = ZONE_TO_PIN[zone_num] + + # Make sure entity is setup + zone_entity_id = zone.get(ATTR_ENTITY_ID) + if zone_entity_id: + resp["state"] = self.binary_value( + hass.states.get(zone_entity_id).state, zone[CONF_ACTIVATION], + ) + return self.json(resp) + + _LOGGER.warning("Konnected entity not yet setup, returning default") + resp["state"] = self.binary_value(STATE_OFF, zone[CONF_ACTIVATION]) + return self.json(resp) + + async def put(self, request: Request, device_id) -> Response: + """Receive a sensor update via PUT request and async set state.""" + return await self.update_sensor(request, device_id) + + async def post(self, request: Request, device_id) -> Response: + """Receive a sensor update via POST request and async set state.""" + return await self.update_sensor(request, device_id) diff --git a/homeassistant/components/konnected/binary_sensor.py b/homeassistant/components/konnected/binary_sensor.py index 486c228d6fb..dc4dae7787f 100644 --- a/homeassistant/components/konnected/binary_sensor.py +++ b/homeassistant/components/konnected/binary_sensor.py @@ -13,18 +13,15 @@ from homeassistant.const import ( from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from . import DOMAIN as KONNECTED_DOMAIN, PIN_TO_ZONE, SIGNAL_SENSOR_UPDATE +from .const import DOMAIN as KONNECTED_DOMAIN, SIGNAL_SENSOR_UPDATE _LOGGER = logging.getLogger(__name__) -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): - """Set up binary sensors attached to a Konnected device.""" - if discovery_info is None: - return - +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up binary sensors attached to a Konnected device from a config entry.""" data = hass.data[KONNECTED_DOMAIN] - device_id = discovery_info["device_id"] + device_id = config_entry.data["id"] sensors = [ KonnectedBinarySensor(device_id, pin_num, pin_data) for pin_num, pin_data in data[CONF_DEVICES][device_id][ @@ -37,14 +34,14 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= class KonnectedBinarySensor(BinarySensorDevice): """Representation of a Konnected binary sensor.""" - def __init__(self, device_id, pin_num, data): + def __init__(self, device_id, zone_num, data): """Initialize the Konnected binary sensor.""" self._data = data self._device_id = device_id - self._pin_num = pin_num + self._zone_num = zone_num self._state = self._data.get(ATTR_STATE) self._device_class = self._data.get(CONF_TYPE) - self._unique_id = "{}-{}".format(device_id, PIN_TO_ZONE[pin_num]) + self._unique_id = f"{device_id}-{zone_num}" self._name = self._data.get(CONF_NAME) @property @@ -72,6 +69,13 @@ class KonnectedBinarySensor(BinarySensorDevice): """Return the device class.""" return self._device_class + @property + def device_info(self): + """Return the device info.""" + return { + "identifiers": {(KONNECTED_DOMAIN, self._device_id)}, + } + async def async_added_to_hass(self): """Store entity_id and register state change callback.""" self._data[ATTR_ENTITY_ID] = self.entity_id diff --git a/homeassistant/components/konnected/config_flow.py b/homeassistant/components/konnected/config_flow.py new file mode 100644 index 00000000000..447211308ae --- /dev/null +++ b/homeassistant/components/konnected/config_flow.py @@ -0,0 +1,739 @@ +"""Config flow for konnected.io integration.""" +import asyncio +import copy +import logging +import random +import string +from urllib.parse import urlparse + +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.components.binary_sensor import ( + DEVICE_CLASS_DOOR, + DEVICE_CLASSES_SCHEMA, +) +from homeassistant.components.ssdp import ATTR_UPNP_MANUFACTURER, ATTR_UPNP_MODEL_NAME +from homeassistant.const import ( + CONF_ACCESS_TOKEN, + CONF_BINARY_SENSORS, + CONF_HOST, + CONF_ID, + CONF_NAME, + CONF_PORT, + CONF_SENSORS, + CONF_SWITCHES, + CONF_TYPE, + CONF_ZONE, +) +from homeassistant.core import callback +from homeassistant.helpers import config_validation as cv + +from .const import ( + CONF_ACTIVATION, + CONF_BLINK, + CONF_DISCOVERY, + CONF_INVERSE, + CONF_MODEL, + CONF_MOMENTARY, + CONF_PAUSE, + CONF_POLL_INTERVAL, + CONF_REPEAT, + DOMAIN, + STATE_HIGH, + STATE_LOW, + ZONES, +) +from .errors import CannotConnect +from .panel import KONN_MODEL, KONN_MODEL_PRO, get_status + +_LOGGER = logging.getLogger(__name__) + +ATTR_KONN_UPNP_MODEL_NAME = "model_name" # standard upnp is modelName +CONF_IO = "io" +CONF_IO_DIS = "Disabled" +CONF_IO_BIN = "Binary Sensor" +CONF_IO_DIG = "Digital Sensor" +CONF_IO_SWI = "Switchable Output" + +KONN_MANUFACTURER = "konnected.io" +KONN_PANEL_MODEL_NAMES = { + KONN_MODEL: "Konnected Alarm Panel", + KONN_MODEL_PRO: "Konnected Alarm Panel Pro", +} + +OPTIONS_IO_ANY = vol.In([CONF_IO_DIS, CONF_IO_BIN, CONF_IO_DIG, CONF_IO_SWI]) +OPTIONS_IO_INPUT_ONLY = vol.In([CONF_IO_DIS, CONF_IO_BIN, CONF_IO_DIG]) +OPTIONS_IO_OUTPUT_ONLY = vol.In([CONF_IO_DIS, CONF_IO_SWI]) + + +# Config entry schemas +IO_SCHEMA = vol.Schema( + { + vol.Optional("1", default=CONF_IO_DIS): OPTIONS_IO_ANY, + vol.Optional("2", default=CONF_IO_DIS): OPTIONS_IO_ANY, + vol.Optional("3", default=CONF_IO_DIS): OPTIONS_IO_ANY, + vol.Optional("4", default=CONF_IO_DIS): OPTIONS_IO_ANY, + vol.Optional("5", default=CONF_IO_DIS): OPTIONS_IO_ANY, + vol.Optional("6", default=CONF_IO_DIS): OPTIONS_IO_ANY, + vol.Optional("7", default=CONF_IO_DIS): OPTIONS_IO_ANY, + vol.Optional("8", default=CONF_IO_DIS): OPTIONS_IO_ANY, + vol.Optional("9", default=CONF_IO_DIS): OPTIONS_IO_INPUT_ONLY, + vol.Optional("10", default=CONF_IO_DIS): OPTIONS_IO_INPUT_ONLY, + vol.Optional("11", default=CONF_IO_DIS): OPTIONS_IO_INPUT_ONLY, + vol.Optional("12", default=CONF_IO_DIS): OPTIONS_IO_INPUT_ONLY, + vol.Optional("out", default=CONF_IO_DIS): OPTIONS_IO_OUTPUT_ONLY, + vol.Optional("alarm1", default=CONF_IO_DIS): OPTIONS_IO_OUTPUT_ONLY, + vol.Optional("out1", default=CONF_IO_DIS): OPTIONS_IO_OUTPUT_ONLY, + vol.Optional("alarm2_out2", default=CONF_IO_DIS): OPTIONS_IO_OUTPUT_ONLY, + } +) + +BINARY_SENSOR_SCHEMA = vol.Schema( + { + vol.Required(CONF_ZONE): vol.In(ZONES), + vol.Required(CONF_TYPE, default=DEVICE_CLASS_DOOR): DEVICE_CLASSES_SCHEMA, + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_INVERSE, default=False): cv.boolean, + } +) + +SENSOR_SCHEMA = vol.Schema( + { + vol.Required(CONF_ZONE): vol.In(ZONES), + vol.Required(CONF_TYPE, default="dht"): vol.All( + vol.Lower, vol.In(["dht", "ds18b20"]) + ), + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_POLL_INTERVAL, default=3): vol.All( + vol.Coerce(int), vol.Range(min=1) + ), + } +) + +SWITCH_SCHEMA = vol.Schema( + { + vol.Required(CONF_ZONE): vol.In(ZONES), + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_ACTIVATION, default=STATE_HIGH): vol.All( + vol.Lower, vol.Any(STATE_HIGH, STATE_LOW) + ), + vol.Optional(CONF_MOMENTARY): vol.All(vol.Coerce(int), vol.Range(min=10)), + vol.Optional(CONF_PAUSE): vol.All(vol.Coerce(int), vol.Range(min=10)), + vol.Optional(CONF_REPEAT): vol.All(vol.Coerce(int), vol.Range(min=-1)), + } +) + +OPTIONS_SCHEMA = vol.Schema( + { + vol.Required(CONF_IO): IO_SCHEMA, + vol.Optional(CONF_BINARY_SENSORS): vol.All( + cv.ensure_list, [BINARY_SENSOR_SCHEMA] + ), + vol.Optional(CONF_SENSORS): vol.All(cv.ensure_list, [SENSOR_SCHEMA]), + vol.Optional(CONF_SWITCHES): vol.All(cv.ensure_list, [SWITCH_SCHEMA]), + vol.Optional(CONF_BLINK, default=True): cv.boolean, + vol.Optional(CONF_DISCOVERY, default=True): cv.boolean, + }, + extra=vol.REMOVE_EXTRA, +) + +CONF_DEFAULT_OPTIONS = "default_options" +CONFIG_ENTRY_SCHEMA = vol.Schema( + { + vol.Required(CONF_ID): cv.matches_regex("[0-9a-f]{12}"), + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_PORT): cv.port, + vol.Required(CONF_MODEL): vol.Any(*KONN_PANEL_MODEL_NAMES), + vol.Required(CONF_ACCESS_TOKEN): cv.matches_regex("[a-zA-Z0-9]+"), + vol.Required(CONF_DEFAULT_OPTIONS): OPTIONS_SCHEMA, + }, + extra=vol.REMOVE_EXTRA, +) + + +class KonnectedFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for NEW_NAME.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_PUSH + + # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167 + + def __init__(self): + """Initialize the Konnected flow.""" + self.data = {} + self.options = OPTIONS_SCHEMA({CONF_IO: {}}) + + async def async_gen_config(self, host, port): + """Populate self.data based on panel status. + + This will raise CannotConnect if an error occurs + """ + self.data[CONF_HOST] = host + self.data[CONF_PORT] = port + try: + status = await get_status(self.hass, host, port) + self.data[CONF_ID] = status["mac"].replace(":", "") + except (CannotConnect, KeyError): + raise CannotConnect + else: + self.data[CONF_MODEL] = status.get("name", KONN_MODEL) + self.data[CONF_ACCESS_TOKEN] = "".join( + random.choices(f"{string.ascii_uppercase}{string.digits}", k=20) + ) + + async def async_step_import(self, device_config): + """Import a configuration.yaml config. + + This flow is triggered by `async_setup` for configured panels. + """ + _LOGGER.debug(device_config) + + # save the data and confirm connection via user step + await self.async_set_unique_id(device_config["id"]) + self.options = device_config[CONF_DEFAULT_OPTIONS] + + # config schema ensures we have port if we have host + if device_config.get(CONF_HOST): + return await self.async_step_user( + user_input={ + CONF_HOST: device_config[CONF_HOST], + CONF_PORT: device_config[CONF_PORT], + } + ) + + # if we have no host info wait for it or abort if previously configured + self._abort_if_unique_id_configured() + return await self.async_step_user() + + async def async_step_ssdp(self, discovery_info): + """Handle a discovered konnected panel. + + This flow is triggered by the SSDP component. It will check if the + device is already configured and attempt to finish the config if not. + """ + _LOGGER.debug(discovery_info) + + try: + if discovery_info[ATTR_UPNP_MANUFACTURER] != KONN_MANUFACTURER: + return self.async_abort(reason="not_konn_panel") + + if not any( + name in discovery_info[ATTR_UPNP_MODEL_NAME] + for name in KONN_PANEL_MODEL_NAMES + ): + _LOGGER.warning( + "Discovered unrecognized Konnected device %s", + discovery_info.get(ATTR_UPNP_MODEL_NAME, "Unknown"), + ) + return self.async_abort(reason="not_konn_panel") + + # If MAC is missing it is a bug in the device fw but we'll guard + # against it since the field is so vital + except KeyError: + _LOGGER.error("Malformed Konnected SSDP info") + else: + # extract host/port from ssdp_location + netloc = urlparse(discovery_info["ssdp_location"]).netloc.split(":") + return await self.async_step_user( + user_input={CONF_HOST: netloc[0], CONF_PORT: int(netloc[1])} + ) + + return self.async_abort(reason="unknown") + + async def async_step_user(self, user_input=None): + """Connect to panel and get config.""" + errors = {} + if user_input: + # build config info and wait for user confirmation + self.data[CONF_HOST] = user_input[CONF_HOST] + self.data[CONF_PORT] = user_input[CONF_PORT] + self.data[CONF_ACCESS_TOKEN] = self.hass.data.get(DOMAIN, {}).get( + CONF_ACCESS_TOKEN + ) or "".join( + random.choices(f"{string.ascii_uppercase}{string.digits}", k=20) + ) + + # brief delay to allow processing of recent status req + await asyncio.sleep(0.1) + try: + status = await get_status( + self.hass, self.data[CONF_HOST], self.data[CONF_PORT] + ) + except CannotConnect: + errors["base"] = "cannot_connect" + else: + self.data[CONF_ID] = status["mac"].replace(":", "") + self.data[CONF_MODEL] = status.get("name", KONN_MODEL) + return await self.async_step_confirm() + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_HOST, default=self.data.get(CONF_HOST)): str, + vol.Required(CONF_PORT, default=self.data.get(CONF_PORT)): int, + } + ), + errors=errors, + ) + + async def async_step_confirm(self, user_input=None): + """Attempt to link with the Konnected panel. + + Given a configured host, will ask the user to confirm and finalize + the connection. + """ + if user_input is None: + # update an existing config entry if host info changes + entry = await self.async_set_unique_id( + self.data[CONF_ID], raise_on_progress=False + ) + if entry and ( + entry.data[CONF_HOST] != self.data[CONF_HOST] + or entry.data[CONF_PORT] != self.data[CONF_PORT] + ): + entry_data = copy.deepcopy(entry.data) + entry_data.update(self.data) + self.hass.config_entries.async_update_entry(entry, data=entry_data) + + self._abort_if_unique_id_configured() + return self.async_show_form( + step_id="confirm", + description_placeholders={ + "model": KONN_PANEL_MODEL_NAMES[self.data[CONF_MODEL]], + "host": self.data[CONF_HOST], + "port": self.data[CONF_PORT], + }, + ) + + # Attach default options and create entry + self.data[CONF_DEFAULT_OPTIONS] = self.options + return self.async_create_entry( + title=KONN_PANEL_MODEL_NAMES[self.data[CONF_MODEL]], data=self.data, + ) + + @staticmethod + @callback + def async_get_options_flow(config_entry): + """Return the Options Flow.""" + return OptionsFlowHandler(config_entry) + + +class OptionsFlowHandler(config_entries.OptionsFlow): + """Handle a option flow for a Konnected Panel.""" + + def __init__(self, config_entry: config_entries.ConfigEntry): + """Initialize options flow.""" + self.entry = config_entry + self.model = self.entry.data[CONF_MODEL] + self.current_opt = self.entry.options or self.entry.data[CONF_DEFAULT_OPTIONS] + + # as config proceeds we'll build up new options and then replace what's in the config entry + self.new_opt = {CONF_IO: {}} + self.active_cfg = None + self.io_cfg = {} + + @callback + def get_current_cfg(self, io_type, zone): + """Get the current zone config.""" + return next( + ( + cfg + for cfg in self.current_opt.get(io_type, []) + if cfg[CONF_ZONE] == zone + ), + {}, + ) + + async def async_step_init(self, user_input=None): + """Handle options flow.""" + return await self.async_step_options_io() + + async def async_step_options_io(self, user_input=None): + """Configure legacy panel IO or first half of pro IO.""" + errors = {} + current_io = self.current_opt.get(CONF_IO, {}) + + if user_input is not None: + # strip out disabled io and save for options cfg + for key, value in user_input.items(): + if value != CONF_IO_DIS: + self.new_opt[CONF_IO][key] = value + return await self.async_step_options_io_ext() + + if self.model == KONN_MODEL: + return self.async_show_form( + step_id="options_io", + data_schema=vol.Schema( + { + vol.Required( + "1", default=current_io.get("1", CONF_IO_DIS) + ): OPTIONS_IO_ANY, + vol.Required( + "2", default=current_io.get("2", CONF_IO_DIS) + ): OPTIONS_IO_ANY, + vol.Required( + "3", default=current_io.get("3", CONF_IO_DIS) + ): OPTIONS_IO_ANY, + vol.Required( + "4", default=current_io.get("4", CONF_IO_DIS) + ): OPTIONS_IO_ANY, + vol.Required( + "5", default=current_io.get("5", CONF_IO_DIS) + ): OPTIONS_IO_ANY, + vol.Required( + "6", default=current_io.get("6", CONF_IO_DIS) + ): OPTIONS_IO_ANY, + vol.Required( + "out", default=current_io.get("out", CONF_IO_DIS) + ): OPTIONS_IO_OUTPUT_ONLY, + } + ), + description_placeholders={ + "model": KONN_PANEL_MODEL_NAMES[self.model], + "host": self.entry.data[CONF_HOST], + }, + errors=errors, + ) + + # configure the first half of the pro board io + if self.model == KONN_MODEL_PRO: + return self.async_show_form( + step_id="options_io", + data_schema=vol.Schema( + { + vol.Required( + "1", default=current_io.get("1", CONF_IO_DIS) + ): OPTIONS_IO_ANY, + vol.Required( + "2", default=current_io.get("2", CONF_IO_DIS) + ): OPTIONS_IO_ANY, + vol.Required( + "3", default=current_io.get("3", CONF_IO_DIS) + ): OPTIONS_IO_ANY, + vol.Required( + "4", default=current_io.get("4", CONF_IO_DIS) + ): OPTIONS_IO_ANY, + vol.Required( + "5", default=current_io.get("5", CONF_IO_DIS) + ): OPTIONS_IO_ANY, + vol.Required( + "6", default=current_io.get("6", CONF_IO_DIS) + ): OPTIONS_IO_ANY, + vol.Required( + "7", default=current_io.get("7", CONF_IO_DIS) + ): OPTIONS_IO_ANY, + } + ), + description_placeholders={ + "model": KONN_PANEL_MODEL_NAMES[self.model], + "host": self.entry.data[CONF_HOST], + }, + errors=errors, + ) + + return self.async_abort(reason="not_konn_panel") + + async def async_step_options_io_ext(self, user_input=None): + """Allow the user to configure the extended IO for pro.""" + errors = {} + current_io = self.current_opt.get(CONF_IO, {}) + + if user_input is not None: + # strip out disabled io and save for options cfg + for key, value in user_input.items(): + if value != CONF_IO_DIS: + self.new_opt[CONF_IO].update({key: value}) + self.io_cfg = copy.deepcopy(self.new_opt[CONF_IO]) + return await self.async_step_options_binary() + + if self.model == KONN_MODEL: + self.io_cfg = copy.deepcopy(self.new_opt[CONF_IO]) + return await self.async_step_options_binary() + + if self.model == KONN_MODEL_PRO: + return self.async_show_form( + step_id="options_io_ext", + data_schema=vol.Schema( + { + vol.Required( + "8", default=current_io.get("8", CONF_IO_DIS) + ): OPTIONS_IO_ANY, + vol.Required( + "9", default=current_io.get("9", CONF_IO_DIS) + ): OPTIONS_IO_INPUT_ONLY, + vol.Required( + "10", default=current_io.get("10", CONF_IO_DIS) + ): OPTIONS_IO_INPUT_ONLY, + vol.Required( + "11", default=current_io.get("11", CONF_IO_DIS) + ): OPTIONS_IO_INPUT_ONLY, + vol.Required( + "12", default=current_io.get("12", CONF_IO_DIS) + ): OPTIONS_IO_INPUT_ONLY, + vol.Required( + "alarm1", default=current_io.get("alarm1", CONF_IO_DIS) + ): OPTIONS_IO_OUTPUT_ONLY, + vol.Required( + "out1", default=current_io.get("out1", CONF_IO_DIS) + ): OPTIONS_IO_OUTPUT_ONLY, + vol.Required( + "alarm2_out2", + default=current_io.get("alarm2_out2", CONF_IO_DIS), + ): OPTIONS_IO_OUTPUT_ONLY, + } + ), + description_placeholders={ + "model": KONN_PANEL_MODEL_NAMES[self.model], + "host": self.entry.data[CONF_HOST], + }, + errors=errors, + ) + + return self.async_abort(reason="not_konn_panel") + + async def async_step_options_binary(self, user_input=None): + """Allow the user to configure the IO options for binary sensors.""" + errors = {} + if user_input is not None: + zone = {"zone": self.active_cfg} + zone.update(user_input) + self.new_opt[CONF_BINARY_SENSORS] = self.new_opt.get( + CONF_BINARY_SENSORS, [] + ) + [zone] + self.io_cfg.pop(self.active_cfg) + self.active_cfg = None + + if self.active_cfg: + current_cfg = self.get_current_cfg(CONF_BINARY_SENSORS, self.active_cfg) + return self.async_show_form( + step_id="options_binary", + data_schema=vol.Schema( + { + vol.Required( + CONF_TYPE, + default=current_cfg.get(CONF_TYPE, DEVICE_CLASS_DOOR), + ): DEVICE_CLASSES_SCHEMA, + vol.Optional( + CONF_NAME, default=current_cfg.get(CONF_NAME, vol.UNDEFINED) + ): str, + vol.Optional( + CONF_INVERSE, default=current_cfg.get(CONF_INVERSE, False) + ): bool, + } + ), + description_placeholders={ + "zone": f"Zone {self.active_cfg}" + if len(self.active_cfg) < 3 + else self.active_cfg.upper + }, + errors=errors, + ) + + # find the next unconfigured binary sensor + for key, value in self.io_cfg.items(): + if value == CONF_IO_BIN: + self.active_cfg = key + current_cfg = self.get_current_cfg(CONF_BINARY_SENSORS, self.active_cfg) + return self.async_show_form( + step_id="options_binary", + data_schema=vol.Schema( + { + vol.Required( + CONF_TYPE, + default=current_cfg.get(CONF_TYPE, DEVICE_CLASS_DOOR), + ): DEVICE_CLASSES_SCHEMA, + vol.Optional( + CONF_NAME, + default=current_cfg.get(CONF_NAME, vol.UNDEFINED), + ): str, + vol.Optional( + CONF_INVERSE, + default=current_cfg.get(CONF_INVERSE, False), + ): bool, + } + ), + description_placeholders={ + "zone": "Zone {self.active_cfg}" + if len(self.active_cfg) < 3 + else self.active_cfg.upper + }, + errors=errors, + ) + + return await self.async_step_options_digital() + + async def async_step_options_digital(self, user_input=None): + """Allow the user to configure the IO options for digital sensors.""" + errors = {} + if user_input is not None: + zone = {"zone": self.active_cfg} + zone.update(user_input) + self.new_opt[CONF_SENSORS] = self.new_opt.get(CONF_SENSORS, []) + [zone] + self.io_cfg.pop(self.active_cfg) + self.active_cfg = None + + if self.active_cfg: + current_cfg = self.get_current_cfg(CONF_SENSORS, self.active_cfg) + return self.async_show_form( + step_id="options_digital", + data_schema=vol.Schema( + { + vol.Required( + CONF_TYPE, default=current_cfg.get(CONF_TYPE, "dht") + ): vol.All(vol.Lower, vol.In(["dht", "ds18b20"])), + vol.Optional( + CONF_NAME, default=current_cfg.get(CONF_NAME, vol.UNDEFINED) + ): str, + vol.Optional( + CONF_POLL_INTERVAL, + default=current_cfg.get(CONF_POLL_INTERVAL, 3), + ): vol.All(vol.Coerce(int), vol.Range(min=1)), + } + ), + description_placeholders={ + "zone": "Zone {self.active_cfg}" + if len(self.active_cfg) < 3 + else self.active_cfg.upper() + }, + errors=errors, + ) + + # find the next unconfigured digital sensor + for key, value in self.io_cfg.items(): + if value == CONF_IO_DIG: + self.active_cfg = key + current_cfg = self.get_current_cfg(CONF_SENSORS, self.active_cfg) + return self.async_show_form( + step_id="options_digital", + data_schema=vol.Schema( + { + vol.Required( + CONF_TYPE, default=current_cfg.get(CONF_TYPE, "dht") + ): vol.All(vol.Lower, vol.In(["dht", "ds18b20"])), + vol.Optional( + CONF_NAME, + default=current_cfg.get(CONF_NAME, vol.UNDEFINED), + ): str, + vol.Optional( + CONF_POLL_INTERVAL, + default=current_cfg.get(CONF_POLL_INTERVAL, 3), + ): vol.All(vol.Coerce(int), vol.Range(min=1)), + } + ), + description_placeholders={ + "zone": "Zone {self.active_cfg}" + if len(self.active_cfg) < 3 + else self.active_cfg.upper() + }, + errors=errors, + ) + + return await self.async_step_options_switch() + + async def async_step_options_switch(self, user_input=None): + """Allow the user to configure the IO options for switches.""" + errors = {} + if user_input is not None: + zone = {"zone": self.active_cfg} + zone.update(user_input) + self.new_opt[CONF_SWITCHES] = self.new_opt.get(CONF_SWITCHES, []) + [zone] + self.io_cfg.pop(self.active_cfg) + self.active_cfg = None + + if self.active_cfg: + current_cfg = self.get_current_cfg(CONF_SWITCHES, self.active_cfg) + return self.async_show_form( + step_id="options_switch", + data_schema=vol.Schema( + { + vol.Optional( + CONF_NAME, default=current_cfg.get(CONF_NAME, vol.UNDEFINED) + ): str, + vol.Optional( + CONF_ACTIVATION, + default=current_cfg.get(CONF_ACTIVATION, STATE_HIGH), + ): vol.All(vol.Lower, vol.Any(STATE_HIGH, STATE_LOW)), + vol.Optional( + CONF_MOMENTARY, + default=current_cfg.get(CONF_MOMENTARY, vol.UNDEFINED), + ): vol.All(vol.Coerce(int), vol.Range(min=10)), + vol.Optional( + CONF_PAUSE, + default=current_cfg.get(CONF_PAUSE, vol.UNDEFINED), + ): vol.All(vol.Coerce(int), vol.Range(min=10)), + vol.Optional( + CONF_REPEAT, + default=current_cfg.get(CONF_REPEAT, vol.UNDEFINED), + ): vol.All(vol.Coerce(int), vol.Range(min=-1)), + } + ), + description_placeholders={ + "zone": "Zone {self.active_cfg}" + if len(self.active_cfg) < 3 + else self.active_cfg.upper() + }, + errors=errors, + ) + + # find the next unconfigured switch + for key, value in self.io_cfg.items(): + if value == CONF_IO_SWI: + self.active_cfg = key + current_cfg = self.get_current_cfg(CONF_SWITCHES, self.active_cfg) + return self.async_show_form( + step_id="options_switch", + data_schema=vol.Schema( + { + vol.Optional( + CONF_NAME, + default=current_cfg.get(CONF_NAME, vol.UNDEFINED), + ): str, + vol.Optional( + CONF_ACTIVATION, + default=current_cfg.get(CONF_ACTIVATION, "high"), + ): vol.In(["low", "high"]), + vol.Optional( + CONF_MOMENTARY, + default=current_cfg.get(CONF_MOMENTARY, vol.UNDEFINED), + ): vol.All(vol.Coerce(int), vol.Range(min=10)), + vol.Optional( + CONF_PAUSE, + default=current_cfg.get(CONF_PAUSE, vol.UNDEFINED), + ): vol.All(vol.Coerce(int), vol.Range(min=10)), + vol.Optional( + CONF_REPEAT, + default=current_cfg.get(CONF_REPEAT, vol.UNDEFINED), + ): vol.All(vol.Coerce(int), vol.Range(min=-1)), + } + ), + description_placeholders={ + "zone": "Zone {self.active_cfg}" + if len(self.active_cfg) < 3 + else self.active_cfg.upper() + }, + errors=errors, + ) + + return await self.async_step_options_misc() + + async def async_step_options_misc(self, user_input=None): + """Allow the user to configure the LED behavior.""" + errors = {} + if user_input is not None: + self.new_opt[CONF_BLINK] = user_input[CONF_BLINK] + return self.async_create_entry(title="", data=self.new_opt) + + return self.async_show_form( + step_id="options_misc", + data_schema=vol.Schema( + { + vol.Required( + CONF_BLINK, default=self.current_opt.get(CONF_BLINK, True) + ): bool, + } + ), + errors=errors, + ) diff --git a/homeassistant/components/konnected/const.py b/homeassistant/components/konnected/const.py index 0107b341532..d8777a5611e 100644 --- a/homeassistant/components/konnected/const.py +++ b/homeassistant/components/konnected/const.py @@ -14,11 +14,33 @@ CONF_BLINK = "blink" CONF_DISCOVERY = "discovery" CONF_DHT_SENSORS = "dht_sensors" CONF_DS18B20_SENSORS = "ds18b20_sensors" +CONF_MODEL = "model" STATE_LOW = "low" STATE_HIGH = "high" -PIN_TO_ZONE = {1: 1, 2: 2, 5: 3, 6: 4, 7: 5, 8: "out", 9: 6} +ZONES = [ + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9", + "10", + "11", + "12", + "alarm1", + "out1", + "alarm2_out2", + "out", +] + +# alarm panel pro only handles zones, +# alarm panel allows specifying pins via configuration.yaml +PIN_TO_ZONE = {"1": "1", "2": "2", "5": "3", "6": "4", "7": "5", "8": "out", "9": "6"} ZONE_TO_PIN = {zone: pin for pin, zone in PIN_TO_ZONE.items()} ENDPOINT_ROOT = "/api/konnected" diff --git a/homeassistant/components/konnected/errors.py b/homeassistant/components/konnected/errors.py new file mode 100644 index 00000000000..5a0207f3f8d --- /dev/null +++ b/homeassistant/components/konnected/errors.py @@ -0,0 +1,10 @@ +"""Errors for the Konnected component.""" +from homeassistant.exceptions import HomeAssistantError + + +class KonnectedException(HomeAssistantError): + """Base class for Konnected exceptions.""" + + +class CannotConnect(KonnectedException): + """Unable to connect to the panel.""" diff --git a/homeassistant/components/konnected/manifest.json b/homeassistant/components/konnected/manifest.json index feb6a4589cb..3a74e2165df 100644 --- a/homeassistant/components/konnected/manifest.json +++ b/homeassistant/components/konnected/manifest.json @@ -1,8 +1,21 @@ { "domain": "konnected", "name": "Konnected", + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/konnected", - "requirements": ["konnected==0.1.5"], - "dependencies": ["http"], - "codeowners": ["@heythisisnate"] + "requirements": [ + "konnected==1.1.0" + ], + "ssdp": [ + { + "manufacturer": "konnected.io" + } + ], + "dependencies": [ + "http" + ], + "codeowners": [ + "@heythisisnate", + "@kit-klein" + ] } diff --git a/homeassistant/components/konnected/panel.py b/homeassistant/components/konnected/panel.py new file mode 100644 index 00000000000..9f4b39e82bc --- /dev/null +++ b/homeassistant/components/konnected/panel.py @@ -0,0 +1,359 @@ +"""Support for Konnected devices.""" +import asyncio +import logging + +import konnected + +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_STATE, + CONF_ACCESS_TOKEN, + CONF_BINARY_SENSORS, + CONF_DEVICES, + CONF_HOST, + CONF_ID, + CONF_NAME, + CONF_PIN, + CONF_PORT, + CONF_SENSORS, + CONF_SWITCHES, + CONF_TYPE, + CONF_ZONE, +) +from homeassistant.core import callback +from homeassistant.helpers import aiohttp_client, device_registry as dr +from homeassistant.helpers.dispatcher import async_dispatcher_send + +from .const import ( + CONF_ACTIVATION, + CONF_API_HOST, + CONF_BLINK, + CONF_DHT_SENSORS, + CONF_DISCOVERY, + CONF_DS18B20_SENSORS, + CONF_INVERSE, + CONF_MOMENTARY, + CONF_PAUSE, + CONF_POLL_INTERVAL, + CONF_REPEAT, + DOMAIN, + ENDPOINT_ROOT, + SIGNAL_SENSOR_UPDATE, + STATE_LOW, + ZONE_TO_PIN, +) +from .errors import CannotConnect + +_LOGGER = logging.getLogger(__name__) + +KONN_MODEL = "Konnected" +KONN_MODEL_PRO = "Konnected Pro" + +# Indicate how each unit is controlled (pin or zone) +KONN_API_VERSIONS = { + KONN_MODEL: CONF_PIN, + KONN_MODEL_PRO: CONF_ZONE, +} + + +class AlarmPanel: + """A representation of a Konnected alarm panel.""" + + def __init__(self, hass, config_entry): + """Initialize the Konnected device.""" + self.hass = hass + self.config_entry = config_entry + self.config = config_entry.data + self.options = config_entry.options + self.host = self.config.get(CONF_HOST) + self.port = self.config.get(CONF_PORT) + self.client = None + self.status = None + self.api_version = KONN_API_VERSIONS[KONN_MODEL] + + @property + def device_id(self): + """Device id is the MAC address as string with punctuation removed.""" + return self.config.get(CONF_ID) + + @property + def stored_configuration(self): + """Return the configuration stored in `hass.data` for this device.""" + return self.hass.data[DOMAIN][CONF_DEVICES].get(self.device_id) + + def format_zone(self, zone, other_items=None): + """Get zone or pin based dict based on the client type.""" + payload = { + self.api_version: zone + if self.api_version == CONF_ZONE + else ZONE_TO_PIN[zone] + } + payload.update(other_items or {}) + return payload + + async def async_connect(self): + """Connect to and setup a Konnected device.""" + try: + self.client = konnected.Client( + host=self.host, + port=str(self.port), + websession=aiohttp_client.async_get_clientsession(self.hass), + ) + self.status = await self.client.get_status() + self.api_version = KONN_API_VERSIONS.get( + self.status.get("model", KONN_MODEL), KONN_API_VERSIONS[KONN_MODEL] + ) + _LOGGER.info( + "Connected to new %s device", self.status.get("model", "Konnected") + ) + _LOGGER.debug(self.status) + + await self.async_update_initial_states() + # brief delay to allow processing of recent status req + await asyncio.sleep(0.1) + await self.async_sync_device_config() + + except self.client.ClientError as err: + _LOGGER.warning("Exception trying to connect to panel: %s", err) + raise CannotConnect + + _LOGGER.info( + "Set up Konnected device %s. Open http://%s:%s in a " + "web browser to view device status", + self.device_id, + self.host, + self.port, + ) + + device_registry = await dr.async_get_registry(self.hass) + + device_registry.async_get_or_create( + config_entry_id=self.config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, self.status.get("mac"))}, + identifiers={(DOMAIN, self.device_id)}, + manufacturer="Konnected.io", + name=self.config_entry.title, + model=self.config_entry.title, + sw_version=self.status.get("swVersion"), + ) + + async def update_switch(self, zone, state, momentary=None, times=None, pause=None): + """Update the state of a switchable output.""" + try: + if self.client: + if self.api_version == CONF_ZONE: + return await self.client.put_zone( + zone, state, momentary, times, pause, + ) + + # device endpoint uses pin number instead of zone + return await self.client.put_device( + ZONE_TO_PIN[zone], state, momentary, times, pause, + ) + + except self.client.ClientError as err: + _LOGGER.warning("Exception trying to update panel: %s", err) + + raise CannotConnect + + async def async_save_data(self): + """Save the device configuration to `hass.data`.""" + binary_sensors = {} + for entity in self.options.get(CONF_BINARY_SENSORS) or []: + zone = entity[CONF_ZONE] + + binary_sensors[zone] = { + CONF_TYPE: entity[CONF_TYPE], + CONF_NAME: entity.get( + CONF_NAME, f"Konnected {self.device_id[6:]} Zone {zone}" + ), + CONF_INVERSE: entity.get(CONF_INVERSE), + ATTR_STATE: None, + } + _LOGGER.debug( + "Set up binary_sensor %s (initial state: %s)", + binary_sensors[zone].get("name"), + binary_sensors[zone].get(ATTR_STATE), + ) + + actuators = [] + for entity in self.options.get(CONF_SWITCHES) or []: + zone = entity[CONF_ZONE] + + act = { + CONF_ZONE: zone, + CONF_NAME: entity.get( + CONF_NAME, f"Konnected {self.device_id[6:]} Actuator {zone}", + ), + ATTR_STATE: None, + CONF_ACTIVATION: entity[CONF_ACTIVATION], + CONF_MOMENTARY: entity.get(CONF_MOMENTARY), + CONF_PAUSE: entity.get(CONF_PAUSE), + CONF_REPEAT: entity.get(CONF_REPEAT), + } + actuators.append(act) + _LOGGER.debug("Set up switch %s", act) + + sensors = [] + for entity in self.options.get(CONF_SENSORS) or []: + zone = entity[CONF_ZONE] + + sensor = { + CONF_ZONE: zone, + CONF_NAME: entity.get( + CONF_NAME, f"Konnected {self.device_id[6:]} Sensor {zone}" + ), + CONF_TYPE: entity[CONF_TYPE], + CONF_POLL_INTERVAL: entity.get(CONF_POLL_INTERVAL), + } + sensors.append(sensor) + _LOGGER.debug( + "Set up %s sensor %s (initial state: %s)", + sensor.get(CONF_TYPE), + sensor.get(CONF_NAME), + sensor.get(ATTR_STATE), + ) + + device_data = { + CONF_BINARY_SENSORS: binary_sensors, + CONF_SENSORS: sensors, + CONF_SWITCHES: actuators, + CONF_BLINK: self.options.get(CONF_BLINK), + CONF_DISCOVERY: self.options.get(CONF_DISCOVERY), + CONF_HOST: self.host, + CONF_PORT: self.port, + "panel": self, + } + + if CONF_DEVICES not in self.hass.data[DOMAIN]: + self.hass.data[DOMAIN][CONF_DEVICES] = {} + + _LOGGER.debug( + "Storing data in hass.data[%s][%s][%s]: %s", + DOMAIN, + CONF_DEVICES, + self.device_id, + device_data, + ) + self.hass.data[DOMAIN][CONF_DEVICES][self.device_id] = device_data + + @callback + def async_binary_sensor_configuration(self): + """Return the configuration map for syncing binary sensors.""" + return [ + self.format_zone(p) for p in self.stored_configuration[CONF_BINARY_SENSORS] + ] + + @callback + def async_actuator_configuration(self): + """Return the configuration map for syncing actuators.""" + return [ + self.format_zone( + data[CONF_ZONE], + {"trigger": (0 if data.get(CONF_ACTIVATION) in [0, STATE_LOW] else 1)}, + ) + for data in self.stored_configuration[CONF_SWITCHES] + ] + + @callback + def async_dht_sensor_configuration(self): + """Return the configuration map for syncing DHT sensors.""" + return [ + self.format_zone( + sensor[CONF_ZONE], {CONF_POLL_INTERVAL: sensor[CONF_POLL_INTERVAL]} + ) + for sensor in self.stored_configuration[CONF_SENSORS] + if sensor[CONF_TYPE] == "dht" + ] + + @callback + def async_ds18b20_sensor_configuration(self): + """Return the configuration map for syncing DS18B20 sensors.""" + return [ + self.format_zone(sensor[CONF_ZONE]) + for sensor in self.stored_configuration[CONF_SENSORS] + if sensor[CONF_TYPE] == "ds18b20" + ] + + async def async_update_initial_states(self): + """Update the initial state of each sensor from status poll.""" + for sensor_data in self.status.get("sensors"): + sensor_config = self.stored_configuration[CONF_BINARY_SENSORS].get( + sensor_data.get(CONF_ZONE, sensor_data.get(CONF_PIN)), {} + ) + entity_id = sensor_config.get(ATTR_ENTITY_ID) + + state = bool(sensor_data.get(ATTR_STATE)) + if sensor_config.get(CONF_INVERSE): + state = not state + + async_dispatcher_send( + self.hass, SIGNAL_SENSOR_UPDATE.format(entity_id), state + ) + + @callback + def async_desired_settings_payload(self): + """Return a dict representing the desired device configuration.""" + desired_api_host = ( + self.hass.data[DOMAIN].get(CONF_API_HOST) or self.hass.config.api.base_url + ) + desired_api_endpoint = desired_api_host + ENDPOINT_ROOT + + return { + "sensors": self.async_binary_sensor_configuration(), + "actuators": self.async_actuator_configuration(), + "dht_sensors": self.async_dht_sensor_configuration(), + "ds18b20_sensors": self.async_ds18b20_sensor_configuration(), + "auth_token": self.config.get(CONF_ACCESS_TOKEN), + "endpoint": desired_api_endpoint, + "blink": self.options.get(CONF_BLINK, True), + "discovery": self.options.get(CONF_DISCOVERY, True), + } + + @callback + def async_current_settings_payload(self): + """Return a dict of configuration currently stored on the device.""" + settings = self.status["settings"] + if not settings: + settings = {} + + return { + "sensors": [ + {self.api_version: s[self.api_version]} + for s in self.status.get("sensors") + ], + "actuators": self.status.get("actuators"), + "dht_sensors": self.status.get(CONF_DHT_SENSORS), + "ds18b20_sensors": self.status.get(CONF_DS18B20_SENSORS), + "auth_token": settings.get("token"), + "endpoint": settings.get("endpoint"), + "blink": settings.get(CONF_BLINK), + "discovery": settings.get(CONF_DISCOVERY), + } + + async def async_sync_device_config(self): + """Sync the new zone configuration to the Konnected device if needed.""" + _LOGGER.debug( + "Device %s settings payload: %s", + self.device_id, + self.async_desired_settings_payload(), + ) + if ( + self.async_desired_settings_payload() + != self.async_current_settings_payload() + ): + _LOGGER.info("pushing settings to device %s", self.device_id) + await self.client.put_settings(**self.async_desired_settings_payload()) + + +async def get_status(hass, host, port): + """Get the status of a Konnected Panel.""" + client = konnected.Client( + host, str(port), aiohttp_client.async_get_clientsession(hass) + ) + try: + return await client.get_status() + + except client.ClientError as err: + _LOGGER.error("Exception trying to get panel status: %s", err) + raise CannotConnect diff --git a/homeassistant/components/konnected/sensor.py b/homeassistant/components/konnected/sensor.py index 7498f2bde1d..d189ac8809a 100644 --- a/homeassistant/components/konnected/sensor.py +++ b/homeassistant/components/konnected/sensor.py @@ -4,9 +4,9 @@ import logging from homeassistant.const import ( CONF_DEVICES, CONF_NAME, - CONF_PIN, CONF_SENSORS, CONF_TYPE, + CONF_ZONE, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_TEMPERATURE, TEMP_CELSIUS, @@ -25,13 +25,10 @@ SENSOR_TYPES = { } -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): - """Set up sensors attached to a Konnected device.""" - if discovery_info is None: - return - +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up sensors attached to a Konnected device from a config entry.""" data = hass.data[KONNECTED_DOMAIN] - device_id = discovery_info["device_id"] + device_id = config_entry.data["id"] sensors = [] # Initialize all DHT sensors. @@ -53,7 +50,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= ( s for s in data[CONF_DEVICES][device_id][CONF_SENSORS] - if s[CONF_TYPE] == "ds18b20" and s[CONF_PIN] == attrs.get(CONF_PIN) + if s[CONF_TYPE] == "ds18b20" and s[CONF_ZONE] == attrs.get(CONF_ZONE) ), None, ) @@ -85,10 +82,10 @@ class KonnectedSensor(Entity): self._data = data self._device_id = device_id self._type = sensor_type - self._pin_num = self._data.get(CONF_PIN) + self._zone_num = self._data.get(CONF_ZONE) self._unit_of_measurement = SENSOR_TYPES[sensor_type][1] self._unique_id = addr or "{}-{}-{}".format( - device_id, self._pin_num, sensor_type + device_id, self._zone_num, sensor_type ) # set initial state if known at initialization @@ -99,7 +96,7 @@ class KonnectedSensor(Entity): # set entity name if given self._name = self._data.get(CONF_NAME) if self._name: - self._name += " " + SENSOR_TYPES[sensor_type][0] + self._name += f" {SENSOR_TYPES[sensor_type][0]}" @property def unique_id(self) -> str: @@ -121,6 +118,13 @@ class KonnectedSensor(Entity): """Return the unit of measurement.""" return self._unit_of_measurement + @property + def device_info(self): + """Return the device info.""" + return { + "identifiers": {(KONNECTED_DOMAIN, self._device_id)}, + } + async def async_added_to_hass(self): """Store entity_id and register state change callback.""" entity_id_key = self._addr or self._type diff --git a/homeassistant/components/konnected/strings.json b/homeassistant/components/konnected/strings.json new file mode 100644 index 00000000000..1f27b04d811 --- /dev/null +++ b/homeassistant/components/konnected/strings.json @@ -0,0 +1,101 @@ +{ + "config": { + "title": "Konnected.io", + "step": { + "user": { + "title": "Discover Konnected Device", + "description": "Please enter the host information for your Konnected Panel.", + "data": { + "host": "Konnected device IP address", + "port": "Konnected device port" + } + }, + "confirm": { + "title": "Konnected Device Ready", + "description": "Model: {model}\nHost: {host}\nPort: {port}\n\nYou can configure the IO and panel behavior in the Konnected Alarm Panel settings." + } + }, + "error": { + "cannot_connect": "Unable to connect to a Konnected Panel at {host}:{port}" + }, + "abort": { + "unknown": "Unknown error occurred", + "already_configured": "Device is already configured", + "already_in_progress": "Config flow for device is already in progress.", + "not_konn_panel": "Not a recognized Konnected.io device" + } + }, + "options": { + "title": "Konnected Alarm Panel Options", + "step": { + "options_io": { + "title": "Configure I/O", + "description": "Discovered a {model} at {host}. Select the base configuration of each I/O below - depending on the I/O it may allow for binary sensors (open/close contacts), digital sensors (dht and ds18b20), or switchable outputs. You'll be able to configure detailed options in the next steps.", + "data": { + "1": "Zone 1", + "2": "Zone 2", + "3": "Zone 3", + "4": "Zone 4", + "5": "Zone 5", + "6": "Zone 6", + "7": "Zone 7", + "out": "OUT" + } + }, + "options_io_ext": { + "title": "Configure Extended I/O", + "description": "Select the configuration of the remaining I/O below. You'll be able to configure detailed options in the next steps.", + "data": { + "8": "Zone 8", + "9": "Zone 9", + "10": "Zone 10", + "11": "Zone 11", + "12": "Zone 12", + "out1": "OUT1", + "alarm1": "ALARM1", + "alarm2_out2": "OUT2/ALARM2" + } + }, + "options_binary": { + "title": "Configure Binary Sensor", + "description": "Please select the options for the binary sensor attached to {zone}", + "data": { + "type": "Binary Sensor Type", + "name": "Name (optional)", + "inverse": "Invert the open/close state" + } + }, + "options_digital": { + "title": "Configure Digital Sensor", + "description": "Please select the options for the digital sensor attached to {zone}", + "data": { + "type": "Sensor Type", + "name": "Name (optional)", + "poll_interval": "Poll Interval (minutes) (optional)" + } + }, + "options_switch": { + "title": "Configure Switchable Output", + "description": "Please select the output options for {zone}", + "data": { + "name": "Name (optional)", + "activation": "Output when on", + "momentary": "Pulse duration (ms) (optional)", + "pause": "Pause between pulses (ms) (optional)", + "repeat": "Times to repeat (-1=infinite) (optional)" + } + }, + "options_misc": { + "title": "Configure Misc", + "description": "Please select the desired behavior for your panel", + "data": { + "blink": "Blink panel LED on when sending state change" + } + } + }, + "error": {}, + "abort": { + "not_konn_panel": "Not a recognized Konnected.io device" + } + } +} diff --git a/homeassistant/components/konnected/switch.py b/homeassistant/components/konnected/switch.py index a88281826c0..d16051eb8da 100644 --- a/homeassistant/components/konnected/switch.py +++ b/homeassistant/components/konnected/switch.py @@ -5,12 +5,12 @@ from homeassistant.const import ( ATTR_STATE, CONF_DEVICES, CONF_NAME, - CONF_PIN, CONF_SWITCHES, + CONF_ZONE, ) from homeassistant.helpers.entity import ToggleEntity -from . import ( +from .const import ( CONF_ACTIVATION, CONF_MOMENTARY, CONF_PAUSE, @@ -23,16 +23,13 @@ from . import ( _LOGGER = logging.getLogger(__name__) -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): - """Set switches attached to a Konnected device.""" - if discovery_info is None: - return - +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up switches attached to a Konnected device from a config entry.""" data = hass.data[KONNECTED_DOMAIN] - device_id = discovery_info["device_id"] + device_id = config_entry.data["id"] switches = [ - KonnectedSwitch(device_id, pin_data.get(CONF_PIN), pin_data) - for pin_data in data[CONF_DEVICES][device_id][CONF_SWITCHES] + KonnectedSwitch(device_id, zone_data.get(CONF_ZONE), zone_data) + for zone_data in data[CONF_DEVICES][device_id][CONF_SWITCHES] ] async_add_entities(switches) @@ -40,11 +37,11 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= class KonnectedSwitch(ToggleEntity): """Representation of a Konnected switch.""" - def __init__(self, device_id, pin_num, data): + def __init__(self, device_id, zone_num, data): """Initialize the Konnected switch.""" self._data = data self._device_id = device_id - self._pin_num = pin_num + self._zone_num = zone_num self._activation = self._data.get(CONF_ACTIVATION, STATE_HIGH) self._momentary = self._data.get(CONF_MOMENTARY) self._pause = self._data.get(CONF_PAUSE) @@ -52,7 +49,7 @@ class KonnectedSwitch(ToggleEntity): self._state = self._boolean_state(self._data.get(ATTR_STATE)) self._name = self._data.get(CONF_NAME) self._unique_id = "{}-{}-{}-{}-{}".format( - device_id, self._pin_num, self._momentary, self._pause, self._repeat + device_id, self._zone_num, self._momentary, self._pause, self._repeat ) @property @@ -71,16 +68,22 @@ class KonnectedSwitch(ToggleEntity): return self._state @property - def client(self): + def panel(self): """Return the Konnected HTTP client.""" - return self.hass.data[KONNECTED_DOMAIN][CONF_DEVICES][self._device_id].get( - "client" - ) + device_data = self.hass.data[KONNECTED_DOMAIN][CONF_DEVICES][self._device_id] + return device_data.get("panel") - def turn_on(self, **kwargs): + @property + def device_info(self): + """Return the device info.""" + return { + "identifiers": {(KONNECTED_DOMAIN, self._device_id)}, + } + + async def async_turn_on(self, **kwargs): """Send a command to turn on the switch.""" - resp = self.client.put_device( - self._pin_num, + resp = await self.panel.update_switch( + self._zone_num, int(self._activation == STATE_HIGH), self._momentary, self._repeat, @@ -94,9 +97,11 @@ class KonnectedSwitch(ToggleEntity): # Immediately set the state back off for momentary switches self._set_state(False) - def turn_off(self, **kwargs): + async def async_turn_off(self, **kwargs): """Send a command to turn off the switch.""" - resp = self.client.put_device(self._pin_num, int(self._activation == STATE_LOW)) + resp = await self.panel.update_switch( + self._zone_num, int(self._activation == STATE_LOW) + ) if resp.get(ATTR_STATE) is not None: self._set_state(self._boolean_state(resp.get(ATTR_STATE))) @@ -111,9 +116,9 @@ class KonnectedSwitch(ToggleEntity): def _set_state(self, state): self._state = state - self.schedule_update_ha_state() + self.async_schedule_update_ha_state() _LOGGER.debug( - "Setting status of %s actuator pin %s to %s", + "Setting status of %s actuator zone %s to %s", self._device_id, self.name, state, diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 8b6c0e77585..4c5449d5b2a 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -47,6 +47,7 @@ FLOWS = [ "ipma", "iqvia", "izone", + "konnected", "life360", "lifx", "linky", diff --git a/homeassistant/generated/ssdp.py b/homeassistant/generated/ssdp.py index bea04484b11..0eb9af0231d 100644 --- a/homeassistant/generated/ssdp.py +++ b/homeassistant/generated/ssdp.py @@ -36,6 +36,11 @@ SSDP = { "modelName": "Philips hue bridge 2015" } ], + "konnected": [ + { + "manufacturer": "konnected.io" + } + ], "samsungtv": [ { "st": "urn:samsung.com:device:RemoteControlReceiver:1" diff --git a/requirements_all.txt b/requirements_all.txt index 22732005d35..70d87011a71 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -767,7 +767,7 @@ keyrings.alt==3.4.0 kiwiki-client==0.1.1 # homeassistant.components.konnected -konnected==0.1.5 +konnected==1.1.0 # homeassistant.components.eufy lakeside==0.12 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8aa12234896..81c3021e2e9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -287,6 +287,9 @@ keyring==20.0.0 # homeassistant.scripts.keyring keyrings.alt==3.4.0 +# homeassistant.components.konnected +konnected==1.1.0 + # homeassistant.components.dyson libpurecool==0.6.1 diff --git a/tests/components/konnected/__init__.py b/tests/components/konnected/__init__.py new file mode 100644 index 00000000000..c5de5224a5d --- /dev/null +++ b/tests/components/konnected/__init__.py @@ -0,0 +1 @@ +"""Tests for the Konnected component.""" diff --git a/tests/components/konnected/test_config_flow.py b/tests/components/konnected/test_config_flow.py new file mode 100644 index 00000000000..9b7a498731d --- /dev/null +++ b/tests/components/konnected/test_config_flow.py @@ -0,0 +1,1052 @@ +"""Tests for Konnected Alarm Panel config flow.""" +from asynctest import patch +import pytest + +from homeassistant.components import konnected +from homeassistant.components.konnected import config_flow + +from tests.common import MockConfigEntry + + +@pytest.fixture(name="mock_panel") +async def mock_panel_fixture(): + """Mock a Konnected Panel bridge.""" + with patch("konnected.Client", autospec=True) as konn_client: + + def mock_constructor(host, port, websession): + """Fake the panel constructor.""" + konn_client.host = host + konn_client.port = port + return konn_client + + konn_client.side_effect = mock_constructor + konn_client.ClientError = config_flow.CannotConnect + yield konn_client + + +async def test_flow_works(hass, mock_panel): + """Test config flow .""" + result = await hass.config_entries.flow.async_init( + config_flow.DOMAIN, context={"source": "user"} + ) + assert result["type"] == "form" + assert result["step_id"] == "user" + + mock_panel.get_status.return_value = { + "mac": "11:22:33:44:55:66", + "name": "Konnected", + } + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={"port": 1234, "host": "1.2.3.4"} + ) + assert result["type"] == "form" + assert result["step_id"] == "confirm" + assert result["description_placeholders"] == { + "model": "Konnected Alarm Panel", + "host": "1.2.3.4", + "port": 1234, + } + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + assert result["type"] == "create_entry" + assert result["data"]["host"] == "1.2.3.4" + assert result["data"]["port"] == 1234 + assert result["data"]["model"] == "Konnected" + assert len(result["data"]["access_token"]) == 20 # confirm generated token size + assert result["data"]["default_options"] == config_flow.OPTIONS_SCHEMA( + {config_flow.CONF_IO: {}} + ) + + +async def test_pro_flow_works(hass, mock_panel): + """Test config flow .""" + result = await hass.config_entries.flow.async_init( + config_flow.DOMAIN, context={"source": "user"} + ) + assert result["type"] == "form" + assert result["step_id"] == "user" + + mock_panel.get_status.return_value = { + "mac": "11:22:33:44:55:66", + "name": "Konnected Pro", + } + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={"port": 1234, "host": "1.2.3.4"} + ) + assert result["type"] == "form" + assert result["step_id"] == "confirm" + assert result["description_placeholders"] == { + "model": "Konnected Alarm Panel Pro", + "host": "1.2.3.4", + "port": 1234, + } + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + assert result["type"] == "create_entry" + assert result["data"]["host"] == "1.2.3.4" + assert result["data"]["port"] == 1234 + assert result["data"]["model"] == "Konnected Pro" + assert len(result["data"]["access_token"]) == 20 # confirm generated token size + assert result["data"]["default_options"] == config_flow.OPTIONS_SCHEMA( + {config_flow.CONF_IO: {}} + ) + + +async def test_ssdp(hass, mock_panel): + """Test a panel being discovered.""" + mock_panel.get_status.return_value = { + "mac": "11:22:33:44:55:66", + "name": "Konnected", + } + + result = await hass.config_entries.flow.async_init( + config_flow.DOMAIN, + context={"source": "ssdp"}, + data={ + "ssdp_location": "http://1.2.3.4:1234/Device.xml", + "manufacturer": config_flow.KONN_MANUFACTURER, + "modelName": config_flow.KONN_MODEL, + }, + ) + + assert result["type"] == "form" + assert result["step_id"] == "confirm" + assert result["description_placeholders"] == { + "model": "Konnected Alarm Panel", + "host": "1.2.3.4", + "port": 1234, + } + + +async def test_import_no_host_user_finish(hass, mock_panel): + """Test importing a panel with no host info.""" + mock_panel.get_status.return_value = { + "mac": "11:22:33:44:55:66", + "name": "Konnected Pro", + } + + result = await hass.config_entries.flow.async_init( + config_flow.DOMAIN, + context={"source": "import"}, + data={ + "default_options": { + "blink": True, + "discovery": True, + "io": { + "1": "Disabled", + "10": "Disabled", + "11": "Disabled", + "12": "Disabled", + "2": "Disabled", + "3": "Disabled", + "4": "Disabled", + "5": "Disabled", + "6": "Disabled", + "7": "Disabled", + "8": "Disabled", + "9": "Disabled", + "alarm1": "Disabled", + "alarm2_out2": "Disabled", + "out": "Disabled", + "out1": "Disabled", + }, + }, + "id": "aabbccddeeff", + }, + ) + assert result["type"] == "form" + assert result["step_id"] == "user" + + # confirm user is prompted to enter host + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={"host": "1.1.1.1", "port": 1234} + ) + assert result["type"] == "form" + assert result["step_id"] == "confirm" + assert result["description_placeholders"] == { + "model": "Konnected Alarm Panel Pro", + "host": "1.1.1.1", + "port": 1234, + } + + # final confirmation + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + assert result["type"] == "create_entry" + + +async def test_ssdp_already_configured(hass, mock_panel): + """Test if a discovered panel has already been configured.""" + MockConfigEntry( + domain="konnected", + data={"host": "0.0.0.0", "port": 1234}, + unique_id="112233445566", + ).add_to_hass(hass) + mock_panel.get_status.return_value = { + "mac": "11:22:33:44:55:66", + "name": "Konnected Pro", + } + + result = await hass.config_entries.flow.async_init( + config_flow.DOMAIN, + context={"source": "ssdp"}, + data={ + "ssdp_location": "http://0.0.0.0:1234/Device.xml", + "manufacturer": config_flow.KONN_MANUFACTURER, + "modelName": config_flow.KONN_MODEL_PRO, + }, + ) + assert result["type"] == "abort" + assert result["reason"] == "already_configured" + + +async def test_ssdp_host_update(hass, mock_panel): + """Test if a discovered panel has already been configured but changed host.""" + device_config = config_flow.CONFIG_ENTRY_SCHEMA( + { + "host": "1.2.3.4", + "port": 1234, + "id": "112233445566", + "model": "Konnected Pro", + "access_token": "11223344556677889900", + "default_options": config_flow.OPTIONS_SCHEMA({config_flow.CONF_IO: {}}), + } + ) + + device_options = config_flow.OPTIONS_SCHEMA( + { + "io": { + "2": "Binary Sensor", + "6": "Binary Sensor", + "10": "Binary Sensor", + "3": "Digital Sensor", + "7": "Digital Sensor", + "11": "Digital Sensor", + "4": "Switchable Output", + "out1": "Switchable Output", + "alarm1": "Switchable Output", + }, + "binary_sensors": [ + {"zone": "2", "type": "door"}, + {"zone": "6", "type": "window", "name": "winder", "inverse": True}, + {"zone": "10", "type": "door"}, + ], + "sensors": [ + {"zone": "3", "type": "dht"}, + {"zone": "7", "type": "ds18b20", "name": "temper"}, + {"zone": "11", "type": "dht"}, + ], + "switches": [ + {"zone": "4"}, + { + "zone": "8", + "name": "switcher", + "activation": "low", + "momentary": 50, + "pause": 100, + "repeat": 4, + }, + {"zone": "out1"}, + {"zone": "alarm1"}, + ], + } + ) + + MockConfigEntry( + domain="konnected", + data=device_config, + options=device_options, + unique_id="112233445566", + ).add_to_hass(hass) + mock_panel.get_status.return_value = { + "mac": "11:22:33:44:55:66", + "name": "Konnected Pro", + } + + result = await hass.config_entries.flow.async_init( + config_flow.DOMAIN, + context={"source": "ssdp"}, + data={ + "ssdp_location": "http://1.1.1.1:1234/Device.xml", + "manufacturer": config_flow.KONN_MANUFACTURER, + "modelName": config_flow.KONN_MODEL_PRO, + }, + ) + assert result["type"] == "abort" + + # confirm the host value was updated + entry = hass.config_entries.async_entries(config_flow.DOMAIN)[0] + assert entry.data["host"] == "1.1.1.1" + assert entry.data["port"] == 1234 + + +async def test_import_existing_config(hass, mock_panel): + """Test importing a host with an existing config file.""" + mock_panel.get_status.return_value = { + "mac": "11:22:33:44:55:66", + "name": "Konnected Pro", + } + + result = await hass.config_entries.flow.async_init( + config_flow.DOMAIN, + context={"source": "import"}, + data=konnected.DEVICE_SCHEMA_YAML( + { + "host": "1.2.3.4", + "port": 1234, + "id": "112233445566", + "binary_sensors": [ + {"zone": "2", "type": "door"}, + {"zone": 6, "type": "window", "name": "winder", "inverse": True}, + {"zone": "10", "type": "door"}, + ], + "sensors": [ + {"zone": "3", "type": "dht"}, + {"zone": 7, "type": "ds18b20", "name": "temper"}, + {"zone": "11", "type": "dht"}, + ], + "switches": [ + {"zone": "4"}, + { + "zone": 8, + "name": "switcher", + "activation": "low", + "momentary": 50, + "pause": 100, + "repeat": 4, + }, + {"zone": "out1"}, + {"zone": "alarm1"}, + ], + } + ), + ) + assert result["type"] == "form" + assert result["step_id"] == "confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + assert result["type"] == "create_entry" + assert result["data"] == { + "host": "1.2.3.4", + "port": 1234, + "id": "112233445566", + "model": "Konnected Pro", + "access_token": result["data"]["access_token"], + "default_options": { + "io": { + "1": "Disabled", + "5": "Disabled", + "9": "Disabled", + "12": "Disabled", + "out": "Disabled", + "alarm2_out2": "Disabled", + "2": "Binary Sensor", + "6": "Binary Sensor", + "10": "Binary Sensor", + "3": "Digital Sensor", + "7": "Digital Sensor", + "11": "Digital Sensor", + "4": "Switchable Output", + "8": "Switchable Output", + "out1": "Switchable Output", + "alarm1": "Switchable Output", + }, + "blink": True, + "discovery": True, + "binary_sensors": [ + {"zone": "2", "type": "door", "inverse": False}, + {"zone": "6", "type": "window", "name": "winder", "inverse": True}, + {"zone": "10", "type": "door", "inverse": False}, + ], + "sensors": [ + {"zone": "3", "type": "dht", "poll_interval": 3}, + {"zone": "7", "type": "ds18b20", "name": "temper", "poll_interval": 3}, + {"zone": "11", "type": "dht", "poll_interval": 3}, + ], + "switches": [ + {"activation": "high", "zone": "4"}, + { + "zone": "8", + "name": "switcher", + "activation": "low", + "momentary": 50, + "pause": 100, + "repeat": 4, + }, + {"activation": "high", "zone": "out1"}, + {"activation": "high", "zone": "alarm1"}, + ], + }, + } + + +async def test_import_existing_config_entry(hass, mock_panel): + """Test importing a host that has an existing config entry.""" + MockConfigEntry( + domain="konnected", + data={ + "host": "0.0.0.0", + "port": 1111, + "id": "112233445566", + "extra": "something", + }, + unique_id="112233445566", + ).add_to_hass(hass) + + mock_panel.get_status.return_value = { + "mac": "11:22:33:44:55:66", + "name": "Konnected Pro", + } + + # utilize a global access token this time + hass.data[config_flow.DOMAIN] = {"access_token": "SUPERSECRETTOKEN"} + result = await hass.config_entries.flow.async_init( + config_flow.DOMAIN, + context={"source": "import"}, + data={ + "host": "1.2.3.4", + "port": 1234, + "id": "112233445566", + "default_options": { + "blink": True, + "discovery": True, + "io": { + "1": "Disabled", + "10": "Binary Sensor", + "11": "Disabled", + "12": "Disabled", + "2": "Binary Sensor", + "3": "Disabled", + "4": "Disabled", + "5": "Disabled", + "6": "Binary Sensor", + "7": "Disabled", + "8": "Disabled", + "9": "Disabled", + "alarm1": "Disabled", + "alarm2_out2": "Disabled", + "out": "Disabled", + "out1": "Disabled", + }, + "binary_sensors": [ + {"inverse": False, "type": "door", "zone": "2"}, + {"inverse": True, "type": "Window", "name": "winder", "zone": "6"}, + {"inverse": False, "type": "door", "zone": "10"}, + ], + }, + }, + ) + + assert result["type"] == "abort" + + # We should have updated the entry + assert len(hass.config_entries.async_entries("konnected")) == 1 + assert hass.config_entries.async_entries("konnected")[0].data == { + "host": "1.2.3.4", + "port": 1234, + "id": "112233445566", + "model": "Konnected Pro", + "access_token": "SUPERSECRETTOKEN", + "extra": "something", + } + + +async def test_import_pin_config(hass, mock_panel): + """Test importing a host with an existing config file that specifies pin configs.""" + mock_panel.get_status.return_value = { + "mac": "11:22:33:44:55:66", + "name": "Konnected Pro", + } + + result = await hass.config_entries.flow.async_init( + config_flow.DOMAIN, + context={"source": "import"}, + data=konnected.DEVICE_SCHEMA_YAML( + { + "host": "1.2.3.4", + "port": 1234, + "id": "112233445566", + "binary_sensors": [ + {"pin": 1, "type": "door"}, + {"pin": "2", "type": "window", "name": "winder", "inverse": True}, + {"zone": "3", "type": "door"}, + ], + "sensors": [ + {"zone": 4, "type": "dht"}, + {"pin": "7", "type": "ds18b20", "name": "temper"}, + ], + "switches": [ + { + "pin": "8", + "name": "switcher", + "activation": "low", + "momentary": 50, + "pause": 100, + "repeat": 4, + }, + {"zone": "6"}, + ], + } + ), + ) + assert result["type"] == "form" + assert result["step_id"] == "confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + assert result["type"] == "create_entry" + assert result["data"] == { + "host": "1.2.3.4", + "port": 1234, + "id": "112233445566", + "model": "Konnected Pro", + "access_token": result["data"]["access_token"], + "default_options": { + "io": { + "7": "Disabled", + "8": "Disabled", + "9": "Disabled", + "10": "Disabled", + "11": "Disabled", + "12": "Disabled", + "out1": "Disabled", + "alarm1": "Disabled", + "alarm2_out2": "Disabled", + "1": "Binary Sensor", + "2": "Binary Sensor", + "3": "Binary Sensor", + "4": "Digital Sensor", + "5": "Digital Sensor", + "6": "Switchable Output", + "out": "Switchable Output", + }, + "blink": True, + "discovery": True, + "binary_sensors": [ + {"zone": "1", "type": "door", "inverse": False}, + {"zone": "2", "type": "window", "name": "winder", "inverse": True}, + {"zone": "3", "type": "door", "inverse": False}, + ], + "sensors": [ + {"zone": "4", "type": "dht", "poll_interval": 3}, + {"zone": "5", "type": "ds18b20", "name": "temper", "poll_interval": 3}, + ], + "switches": [ + { + "zone": "out", + "name": "switcher", + "activation": "low", + "momentary": 50, + "pause": 100, + "repeat": 4, + }, + {"activation": "high", "zone": "6"}, + ], + }, + } + + +async def test_option_flow(hass, mock_panel): + """Test config flow options.""" + device_config = config_flow.CONFIG_ENTRY_SCHEMA( + { + "host": "1.2.3.4", + "port": 1234, + "id": "112233445566", + "model": "Konnected", + "access_token": "11223344556677889900", + "default_options": config_flow.OPTIONS_SCHEMA({config_flow.CONF_IO: {}}), + } + ) + + device_options = config_flow.OPTIONS_SCHEMA({"io": {}}) + + entry = MockConfigEntry( + domain="konnected", + data=device_config, + options=device_options, + unique_id="112233445566", + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.options.async_init( + entry.entry_id, context={"source": "test"} + ) + assert result["type"] == "form" + assert result["step_id"] == "options_io" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + "1": "Disabled", + "2": "Binary Sensor", + "3": "Digital Sensor", + "4": "Switchable Output", + "5": "Disabled", + "6": "Binary Sensor", + "out": "Switchable Output", + }, + ) + assert result["type"] == "form" + assert result["step_id"] == "options_binary" + + # zone 2 + result = await hass.config_entries.options.async_configure( + result["flow_id"], user_input={"type": "door"} + ) + assert result["type"] == "form" + assert result["step_id"] == "options_binary" + + # zone 6 + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={"type": "window", "name": "winder", "inverse": True}, + ) + assert result["type"] == "form" + assert result["step_id"] == "options_digital" + + # zone 3 + result = await hass.config_entries.options.async_configure( + result["flow_id"], user_input={"type": "dht"} + ) + assert result["type"] == "form" + assert result["step_id"] == "options_switch" + + # zone 4 + result = await hass.config_entries.options.async_configure( + result["flow_id"], user_input={} + ) + assert result["type"] == "form" + assert result["step_id"] == "options_switch" + + # zone out + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + "name": "switcher", + "activation": "low", + "momentary": 50, + "pause": 100, + "repeat": 4, + }, + ) + + assert result["type"] == "form" + assert result["step_id"] == "options_misc" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], user_input={"blink": True}, + ) + assert result["type"] == "create_entry" + assert result["data"] == { + "io": { + "2": "Binary Sensor", + "3": "Digital Sensor", + "4": "Switchable Output", + "6": "Binary Sensor", + "out": "Switchable Output", + }, + "blink": True, + "binary_sensors": [ + {"zone": "2", "type": "door", "inverse": False}, + {"zone": "6", "type": "window", "name": "winder", "inverse": True}, + ], + "sensors": [{"zone": "3", "type": "dht", "poll_interval": 3}], + "switches": [ + {"activation": "high", "zone": "4"}, + { + "zone": "out", + "name": "switcher", + "activation": "low", + "momentary": 50, + "pause": 100, + "repeat": 4, + }, + ], + } + + +async def test_option_flow_pro(hass, mock_panel): + """Test config flow options for pro board.""" + device_config = config_flow.CONFIG_ENTRY_SCHEMA( + { + "host": "1.2.3.4", + "port": 1234, + "id": "112233445566", + "model": "Konnected Pro", + "access_token": "11223344556677889900", + "default_options": config_flow.OPTIONS_SCHEMA({config_flow.CONF_IO: {}}), + } + ) + + device_options = config_flow.OPTIONS_SCHEMA({"io": {}}) + + entry = MockConfigEntry( + domain="konnected", + data=device_config, + options=device_options, + unique_id="112233445566", + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.options.async_init( + entry.entry_id, context={"source": "test"} + ) + assert result["type"] == "form" + assert result["step_id"] == "options_io" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + "1": "Disabled", + "2": "Binary Sensor", + "3": "Digital Sensor", + "4": "Switchable Output", + "5": "Disabled", + "6": "Binary Sensor", + "7": "Digital Sensor", + }, + ) + assert result["type"] == "form" + assert result["step_id"] == "options_io_ext" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + "8": "Switchable Output", + "9": "Disabled", + "10": "Binary Sensor", + "11": "Digital Sensor", + "12": "Disabled", + "out1": "Switchable Output", + "alarm1": "Switchable Output", + "alarm2_out2": "Disabled", + }, + ) + assert result["type"] == "form" + assert result["step_id"] == "options_binary" + + # zone 2 + result = await hass.config_entries.options.async_configure( + result["flow_id"], user_input={"type": "door"} + ) + assert result["type"] == "form" + assert result["step_id"] == "options_binary" + + # zone 6 + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={"type": "window", "name": "winder", "inverse": True}, + ) + assert result["type"] == "form" + assert result["step_id"] == "options_binary" + + # zone 10 + result = await hass.config_entries.options.async_configure( + result["flow_id"], user_input={"type": "door"} + ) + assert result["type"] == "form" + assert result["step_id"] == "options_digital" + + # zone 3 + result = await hass.config_entries.options.async_configure( + result["flow_id"], user_input={"type": "dht"} + ) + assert result["type"] == "form" + assert result["step_id"] == "options_digital" + + # zone 7 + result = await hass.config_entries.options.async_configure( + result["flow_id"], user_input={"type": "ds18b20", "name": "temper"} + ) + assert result["type"] == "form" + assert result["step_id"] == "options_digital" + + # zone 11 + result = await hass.config_entries.options.async_configure( + result["flow_id"], user_input={"type": "dht"} + ) + assert result["type"] == "form" + assert result["step_id"] == "options_switch" + + # zone 4 + result = await hass.config_entries.options.async_configure( + result["flow_id"], user_input={} + ) + assert result["type"] == "form" + assert result["step_id"] == "options_switch" + + # zone 8 + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + "name": "switcher", + "activation": "low", + "momentary": 50, + "pause": 100, + "repeat": 4, + }, + ) + assert result["type"] == "form" + assert result["step_id"] == "options_switch" + + # zone out1 + result = await hass.config_entries.options.async_configure( + result["flow_id"], user_input={} + ) + assert result["type"] == "form" + assert result["step_id"] == "options_switch" + + # zone alarm1 + result = await hass.config_entries.options.async_configure( + result["flow_id"], user_input={} + ) + assert result["type"] == "form" + assert result["step_id"] == "options_misc" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], user_input={"blink": True}, + ) + + assert result["type"] == "create_entry" + assert result["data"] == { + "io": { + "10": "Binary Sensor", + "11": "Digital Sensor", + "2": "Binary Sensor", + "3": "Digital Sensor", + "4": "Switchable Output", + "6": "Binary Sensor", + "7": "Digital Sensor", + "8": "Switchable Output", + "alarm1": "Switchable Output", + "out1": "Switchable Output", + }, + "blink": True, + "binary_sensors": [ + {"zone": "2", "type": "door", "inverse": False}, + {"zone": "6", "type": "window", "name": "winder", "inverse": True}, + {"zone": "10", "type": "door", "inverse": False}, + ], + "sensors": [ + {"zone": "3", "type": "dht", "poll_interval": 3}, + {"zone": "7", "type": "ds18b20", "name": "temper", "poll_interval": 3}, + {"zone": "11", "type": "dht", "poll_interval": 3}, + ], + "switches": [ + {"activation": "high", "zone": "4"}, + { + "zone": "8", + "name": "switcher", + "activation": "low", + "momentary": 50, + "pause": 100, + "repeat": 4, + }, + {"activation": "high", "zone": "out1"}, + {"activation": "high", "zone": "alarm1"}, + ], + } + + +async def test_option_flow_import(hass, mock_panel): + """Test config flow options imported from configuration.yaml.""" + device_options = config_flow.OPTIONS_SCHEMA( + { + "io": { + "1": "Binary Sensor", + "2": "Digital Sensor", + "3": "Switchable Output", + }, + "binary_sensors": [ + {"zone": "1", "type": "window", "name": "winder", "inverse": True}, + ], + "sensors": [{"zone": "2", "type": "ds18b20", "name": "temper"}], + "switches": [ + { + "zone": "3", + "name": "switcher", + "activation": "low", + "momentary": 50, + "pause": 100, + "repeat": 4, + }, + ], + } + ) + + device_config = config_flow.CONFIG_ENTRY_SCHEMA( + { + "host": "1.2.3.4", + "port": 1234, + "id": "112233445566", + "model": "Konnected Pro", + "access_token": "11223344556677889900", + "default_options": device_options, + } + ) + + entry = MockConfigEntry( + domain="konnected", data=device_config, unique_id="112233445566" + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.options.async_init( + entry.entry_id, context={"source": "test"} + ) + assert result["type"] == "form" + assert result["step_id"] == "options_io" + + # confirm the defaults are set based on current config - we"ll spot check this throughout + schema = result["data_schema"]({}) + assert schema["1"] == "Binary Sensor" + assert schema["2"] == "Digital Sensor" + assert schema["3"] == "Switchable Output" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + "1": "Binary Sensor", + "2": "Digital Sensor", + "3": "Switchable Output", + }, + ) + assert result["type"] == "form" + assert result["step_id"] == "options_io_ext" + schema = result["data_schema"]({}) + assert schema["8"] == "Disabled" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], user_input={}, + ) + assert result["type"] == "form" + assert result["step_id"] == "options_binary" + + # zone 1 + schema = result["data_schema"]({}) + assert schema["type"] == "window" + assert schema["name"] == "winder" + assert schema["inverse"] is True + result = await hass.config_entries.options.async_configure( + result["flow_id"], user_input={"type": "door"} + ) + assert result["type"] == "form" + assert result["step_id"] == "options_digital" + + # zone 2 + schema = result["data_schema"]({}) + assert schema["type"] == "ds18b20" + assert schema["name"] == "temper" + result = await hass.config_entries.options.async_configure( + result["flow_id"], user_input={"type": "dht"}, + ) + assert result["type"] == "form" + assert result["step_id"] == "options_switch" + + # zone 3 + schema = result["data_schema"]({}) + assert schema["name"] == "switcher" + assert schema["activation"] == "low" + assert schema["momentary"] == 50 + assert schema["pause"] == 100 + assert schema["repeat"] == 4 + result = await hass.config_entries.options.async_configure( + result["flow_id"], user_input={"activation": "high"} + ) + assert result["type"] == "form" + assert result["step_id"] == "options_misc" + + schema = result["data_schema"]({}) + assert schema["blink"] is True + result = await hass.config_entries.options.async_configure( + result["flow_id"], user_input={"blink": False}, + ) + + # verify the updated fields + assert result["type"] == "create_entry" + assert result["data"] == { + "io": {"1": "Binary Sensor", "2": "Digital Sensor", "3": "Switchable Output"}, + "blink": False, + "binary_sensors": [ + {"zone": "1", "type": "door", "inverse": True, "name": "winder"}, + ], + "sensors": [ + {"zone": "2", "type": "dht", "poll_interval": 3, "name": "temper"}, + ], + "switches": [ + { + "zone": "3", + "name": "switcher", + "activation": "high", + "momentary": 50, + "pause": 100, + "repeat": 4, + }, + ], + } + + +async def test_option_flow_existing(hass, mock_panel): + """Test config flow options with existing already in place.""" + device_options = config_flow.OPTIONS_SCHEMA( + { + "io": { + "1": "Binary Sensor", + "2": "Digital Sensor", + "3": "Switchable Output", + }, + "binary_sensors": [ + {"zone": "1", "type": "window", "name": "winder", "inverse": True}, + ], + "sensors": [{"zone": "2", "type": "ds18b20", "name": "temper"}], + "switches": [ + { + "zone": "3", + "name": "switcher", + "activation": "low", + "momentary": 50, + "pause": 100, + "repeat": 4, + }, + ], + } + ) + + device_config = config_flow.CONFIG_ENTRY_SCHEMA( + { + "host": "1.2.3.4", + "port": 1234, + "id": "112233445566", + "model": "Konnected Pro", + "access_token": "11223344556677889900", + "default_options": config_flow.OPTIONS_SCHEMA({"io": {}}), + } + ) + + entry = MockConfigEntry( + domain="konnected", + data=device_config, + options=device_options, + unique_id="112233445566", + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.options.async_init( + entry.entry_id, context={"source": "test"} + ) + assert result["type"] == "form" + assert result["step_id"] == "options_io" + + # confirm the defaults are pulled in from the existing options + schema = result["data_schema"]({}) + assert schema["1"] == "Binary Sensor" + assert schema["2"] == "Digital Sensor" + assert schema["3"] == "Switchable Output" diff --git a/tests/components/konnected/test_init.py b/tests/components/konnected/test_init.py new file mode 100644 index 00000000000..e1a1d2e72f8 --- /dev/null +++ b/tests/components/konnected/test_init.py @@ -0,0 +1,601 @@ +"""Test Konnected setup process.""" +from asynctest import patch +import pytest + +from homeassistant.components import konnected +from homeassistant.components.konnected import config_flow +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry + + +@pytest.fixture(name="mock_panel") +async def mock_panel_fixture(): + """Mock a Konnected Panel bridge.""" + with patch("konnected.Client", autospec=True) as konn_client: + + def mock_constructor(host, port, websession): + """Fake the panel constructor.""" + konn_client.host = host + konn_client.port = port + return konn_client + + konn_client.side_effect = mock_constructor + konn_client.ClientError = config_flow.CannotConnect + konn_client.get_status.return_value = { + "hwVersion": "2.3.0", + "swVersion": "2.3.1", + "heap": 10000, + "uptime": 12222, + "ip": "192.168.1.90", + "port": 9123, + "sensors": [], + "actuators": [], + "dht_sensors": [], + "ds18b20_sensors": [], + "mac": "11:22:33:44:55:66", + "settings": {}, + } + yield konn_client + + +async def test_config_schema(hass): + """Test that config schema is imported properly.""" + config = { + konnected.DOMAIN: { + konnected.CONF_ACCESS_TOKEN: "abcdefgh", + konnected.CONF_DEVICES: [{konnected.CONF_ID: "aabbccddeeff"}], + } + } + assert konnected.CONFIG_SCHEMA(config) == { + "konnected": { + "access_token": "abcdefgh", + "devices": [ + { + "default_options": { + "blink": True, + "discovery": True, + "io": { + "1": "Disabled", + "10": "Disabled", + "11": "Disabled", + "12": "Disabled", + "2": "Disabled", + "3": "Disabled", + "4": "Disabled", + "5": "Disabled", + "6": "Disabled", + "7": "Disabled", + "8": "Disabled", + "9": "Disabled", + "alarm1": "Disabled", + "alarm2_out2": "Disabled", + "out": "Disabled", + "out1": "Disabled", + }, + }, + "id": "aabbccddeeff", + } + ], + } + } + + # check with host info + config = { + konnected.DOMAIN: { + konnected.CONF_ACCESS_TOKEN: "abcdefgh", + konnected.CONF_DEVICES: [ + {konnected.CONF_ID: "aabbccddeeff", "host": "192.168.1.1", "port": 1234} + ], + } + } + assert konnected.CONFIG_SCHEMA(config) == { + "konnected": { + "access_token": "abcdefgh", + "devices": [ + { + "default_options": { + "blink": True, + "discovery": True, + "io": { + "1": "Disabled", + "10": "Disabled", + "11": "Disabled", + "12": "Disabled", + "2": "Disabled", + "3": "Disabled", + "4": "Disabled", + "5": "Disabled", + "6": "Disabled", + "7": "Disabled", + "8": "Disabled", + "9": "Disabled", + "alarm1": "Disabled", + "alarm2_out2": "Disabled", + "out": "Disabled", + "out1": "Disabled", + }, + }, + "id": "aabbccddeeff", + "host": "192.168.1.1", + "port": 1234, + } + ], + } + } + + # check pin to zone + config = { + konnected.DOMAIN: { + konnected.CONF_ACCESS_TOKEN: "abcdefgh", + konnected.CONF_DEVICES: [ + { + konnected.CONF_ID: "aabbccddeeff", + "binary_sensors": [ + {"pin": 2, "type": "door"}, + {"zone": 1, "type": "door"}, + ], + } + ], + } + } + assert konnected.CONFIG_SCHEMA(config) == { + "konnected": { + "access_token": "abcdefgh", + "devices": [ + { + "default_options": { + "blink": True, + "discovery": True, + "io": { + "1": "Binary Sensor", + "10": "Disabled", + "11": "Disabled", + "12": "Disabled", + "2": "Binary Sensor", + "3": "Disabled", + "4": "Disabled", + "5": "Disabled", + "6": "Disabled", + "7": "Disabled", + "8": "Disabled", + "9": "Disabled", + "alarm1": "Disabled", + "alarm2_out2": "Disabled", + "out": "Disabled", + "out1": "Disabled", + }, + "binary_sensors": [ + {"inverse": False, "type": "door", "zone": "2"}, + {"inverse": False, "type": "door", "zone": "1"}, + ], + }, + "id": "aabbccddeeff", + } + ], + } + } + + +async def test_setup_with_no_config(hass): + """Test that we do not discover anything or try to set up a Konnected panel.""" + assert await async_setup_component(hass, konnected.DOMAIN, {}) + + # No flows started + assert len(hass.config_entries.flow.async_progress()) == 0 + + # Nothing saved from configuration.yaml + assert hass.data[konnected.DOMAIN][konnected.CONF_ACCESS_TOKEN] is None + assert hass.data[konnected.DOMAIN][konnected.CONF_API_HOST] is None + assert konnected.YAML_CONFIGS not in hass.data[konnected.DOMAIN] + + +async def test_setup_defined_hosts_known_auth(hass): + """Test we don't initiate a config entry if configured panel is known.""" + MockConfigEntry( + domain="konnected", + unique_id="112233445566", + data={"host": "0.0.0.0", "id": "112233445566"}, + ).add_to_hass(hass) + MockConfigEntry( + domain="konnected", + unique_id="aabbccddeeff", + data={"host": "1.2.3.4", "id": "aabbccddeeff"}, + ).add_to_hass(hass) + + assert ( + await async_setup_component( + hass, + konnected.DOMAIN, + { + konnected.DOMAIN: { + konnected.CONF_ACCESS_TOKEN: "abcdefgh", + konnected.CONF_DEVICES: [ + { + config_flow.CONF_ID: "aabbccddeeff", + config_flow.CONF_HOST: "0.0.0.0", + config_flow.CONF_PORT: 1234, + }, + ], + } + }, + ) + is True + ) + + assert hass.data[konnected.DOMAIN][konnected.CONF_ACCESS_TOKEN] == "abcdefgh" + assert konnected.YAML_CONFIGS not in hass.data[konnected.DOMAIN] + + # Flow aborted + assert len(hass.config_entries.flow.async_progress()) == 0 + + +async def test_setup_defined_hosts_no_known_auth(hass): + """Test we initiate config entry if config panel is not known.""" + assert ( + await async_setup_component( + hass, + konnected.DOMAIN, + { + konnected.DOMAIN: { + konnected.CONF_ACCESS_TOKEN: "abcdefgh", + konnected.CONF_DEVICES: [{konnected.CONF_ID: "aabbccddeeff"}], + } + }, + ) + is True + ) + + # Flow started for discovered bridge + assert len(hass.config_entries.flow.async_progress()) == 1 + + +async def test_config_passed_to_config_entry(hass): + """Test that configured options for a host are loaded via config entry.""" + entry = MockConfigEntry( + domain=konnected.DOMAIN, + data={config_flow.CONF_ID: "aabbccddeeff", config_flow.CONF_HOST: "0.0.0.0"}, + ) + entry.add_to_hass(hass) + with patch.object(konnected, "AlarmPanel", autospec=True) as mock_int: + assert ( + await async_setup_component( + hass, + konnected.DOMAIN, + { + konnected.DOMAIN: { + konnected.CONF_ACCESS_TOKEN: "abcdefgh", + konnected.CONF_DEVICES: [{konnected.CONF_ID: "aabbccddeeff"}], + } + }, + ) + is True + ) + + assert len(mock_int.mock_calls) == 3 + p_hass, p_entry = mock_int.mock_calls[0][1] + + assert p_hass is hass + assert p_entry is entry + + +async def test_unload_entry(hass, mock_panel): + """Test being able to unload an entry.""" + entry = MockConfigEntry( + domain=konnected.DOMAIN, data={konnected.CONF_ID: "aabbccddeeff"} + ) + entry.add_to_hass(hass) + + assert await async_setup_component(hass, konnected.DOMAIN, {}) is True + assert hass.data[konnected.DOMAIN]["devices"].get("aabbccddeeff") is not None + assert await konnected.async_unload_entry(hass, entry) + assert hass.data[konnected.DOMAIN]["devices"] == {} + + +async def test_api(hass, aiohttp_client, mock_panel): + """Test callback view.""" + await async_setup_component(hass, "http", {"http": {}}) + + device_config = config_flow.CONFIG_ENTRY_SCHEMA( + { + "host": "1.2.3.4", + "port": 1234, + "id": "112233445566", + "model": "Konnected Pro", + "access_token": "abcdefgh", + "default_options": config_flow.OPTIONS_SCHEMA({config_flow.CONF_IO: {}}), + } + ) + + device_options = config_flow.OPTIONS_SCHEMA( + { + "io": { + "1": "Binary Sensor", + "2": "Binary Sensor", + "3": "Binary Sensor", + "4": "Digital Sensor", + "5": "Digital Sensor", + "6": "Switchable Output", + "out": "Switchable Output", + }, + "binary_sensors": [ + {"zone": "1", "type": "door"}, + {"zone": "2", "type": "window", "name": "winder", "inverse": True}, + {"zone": "3", "type": "door"}, + ], + "sensors": [ + {"zone": "4", "type": "dht"}, + {"zone": "5", "type": "ds18b20", "name": "temper"}, + ], + "switches": [ + { + "zone": "out", + "name": "switcher", + "activation": "low", + "momentary": 50, + "pause": 100, + "repeat": 4, + }, + {"zone": "6"}, + ], + } + ) + + entry = MockConfigEntry( + domain="konnected", + title="Konnected Alarm Panel", + data=device_config, + options=device_options, + ) + entry.add_to_hass(hass) + + assert ( + await async_setup_component( + hass, + konnected.DOMAIN, + {konnected.DOMAIN: {konnected.CONF_ACCESS_TOKEN: "globaltoken"}}, + ) + is True + ) + + client = await aiohttp_client(hass.http.app) + + # Test the get endpoint for switch status polling + resp = await client.get("/api/konnected") + assert resp.status == 404 # no device provided + + resp = await client.get("/api/konnected/223344556677") + assert resp.status == 404 # unknown device provided + + resp = await client.get("/api/konnected/device/112233445566") + assert resp.status == 404 # no zone provided + result = await resp.json() + assert result == {"message": "Switch on zone or pin unknown not configured"} + + resp = await client.get("/api/konnected/device/112233445566?zone=8") + assert resp.status == 404 # invalid zone + result = await resp.json() + assert result == {"message": "Switch on zone or pin 8 not configured"} + + resp = await client.get("/api/konnected/device/112233445566?pin=12") + assert resp.status == 404 # invalid pin + result = await resp.json() + assert result == {"message": "Switch on zone or pin 12 not configured"} + + resp = await client.get("/api/konnected/device/112233445566?zone=out") + assert resp.status == 200 + result = await resp.json() + assert result == {"state": 1, "zone": "out"} + + resp = await client.get("/api/konnected/device/112233445566?pin=8") + assert resp.status == 200 + result = await resp.json() + assert result == {"state": 1, "pin": "8"} + + # Test the post endpoint for sensor updates + resp = await client.post("/api/konnected/device", json={"zone": "1", "state": 1}) + assert resp.status == 404 + + resp = await client.post( + "/api/konnected/device/112233445566", json={"zone": "1", "state": 1} + ) + assert resp.status == 401 + result = await resp.json() + assert result == {"message": "unauthorized"} + + resp = await client.post( + "/api/konnected/device/223344556677", + headers={"Authorization": "Bearer abcdefgh"}, + json={"zone": "1", "state": 1}, + ) + assert resp.status == 400 + + resp = await client.post( + "/api/konnected/device/112233445566", + headers={"Authorization": "Bearer abcdefgh"}, + json={"zone": "15", "state": 1}, + ) + assert resp.status == 400 + result = await resp.json() + assert result == {"message": "unregistered sensor/actuator"} + + resp = await client.post( + "/api/konnected/device/112233445566", + headers={"Authorization": "Bearer abcdefgh"}, + json={"zone": "1", "state": 1}, + ) + assert resp.status == 200 + result = await resp.json() + assert result == {"message": "ok"} + + resp = await client.post( + "/api/konnected/device/112233445566", + headers={"Authorization": "Bearer globaltoken"}, + json={"zone": "1", "state": 1}, + ) + assert resp.status == 200 + result = await resp.json() + assert result == {"message": "ok"} + + resp = await client.post( + "/api/konnected/device/112233445566", + headers={"Authorization": "Bearer abcdefgh"}, + json={"zone": "4", "temp": 22, "humi": 20}, + ) + assert resp.status == 200 + result = await resp.json() + assert result == {"message": "ok"} + + # Test the put endpoint for sensor updates + resp = await client.post( + "/api/konnected/device/112233445566", + headers={"Authorization": "Bearer abcdefgh"}, + json={"zone": "1", "state": 1}, + ) + assert resp.status == 200 + result = await resp.json() + assert result == {"message": "ok"} + + +async def test_state_updates(hass, aiohttp_client, mock_panel): + """Test callback view.""" + await async_setup_component(hass, "http", {"http": {}}) + + device_config = config_flow.CONFIG_ENTRY_SCHEMA( + { + "host": "1.2.3.4", + "port": 1234, + "id": "112233445566", + "model": "Konnected Pro", + "access_token": "abcdefgh", + "default_options": config_flow.OPTIONS_SCHEMA({config_flow.CONF_IO: {}}), + } + ) + + device_options = config_flow.OPTIONS_SCHEMA( + { + "io": { + "1": "Binary Sensor", + "2": "Binary Sensor", + "3": "Binary Sensor", + "4": "Digital Sensor", + "5": "Digital Sensor", + "6": "Switchable Output", + "out": "Switchable Output", + }, + "binary_sensors": [ + {"zone": "1", "type": "door"}, + {"zone": "2", "type": "window", "name": "winder", "inverse": True}, + {"zone": "3", "type": "door"}, + ], + "sensors": [ + {"zone": "4", "type": "dht"}, + {"zone": "5", "type": "ds18b20", "name": "temper"}, + ], + "switches": [ + { + "zone": "out", + "name": "switcher", + "activation": "low", + "momentary": 50, + "pause": 100, + "repeat": 4, + }, + {"zone": "6"}, + ], + } + ) + + entry = MockConfigEntry( + domain="konnected", + title="Konnected Alarm Panel", + data=device_config, + options=device_options, + ) + entry.add_to_hass(hass) + + assert ( + await async_setup_component( + hass, + konnected.DOMAIN, + {konnected.DOMAIN: {konnected.CONF_ACCESS_TOKEN: "1122334455"}}, + ) + is True + ) + + client = await aiohttp_client(hass.http.app) + + # Test updating a binary sensor + resp = await client.post( + "/api/konnected/device/112233445566", + headers={"Authorization": "Bearer abcdefgh"}, + json={"zone": "1", "state": 0}, + ) + assert resp.status == 200 + result = await resp.json() + assert result == {"message": "ok"} + await hass.async_block_till_done() + assert hass.states.get("binary_sensor.konnected_445566_zone_1").state == "off" + + resp = await client.post( + "/api/konnected/device/112233445566", + headers={"Authorization": "Bearer abcdefgh"}, + json={"zone": "1", "state": 1}, + ) + assert resp.status == 200 + result = await resp.json() + assert result == {"message": "ok"} + await hass.async_block_till_done() + assert hass.states.get("binary_sensor.konnected_445566_zone_1").state == "on" + + # Test updating sht sensor + resp = await client.post( + "/api/konnected/device/112233445566", + headers={"Authorization": "Bearer abcdefgh"}, + json={"zone": "4", "temp": 22, "humi": 20}, + ) + assert resp.status == 200 + result = await resp.json() + assert result == {"message": "ok"} + await hass.async_block_till_done() + assert hass.states.get("sensor.konnected_445566_sensor_4_humidity").state == "20" + assert ( + hass.states.get("sensor.konnected_445566_sensor_4_temperature").state == "22.0" + ) + + resp = await client.post( + "/api/konnected/device/112233445566", + headers={"Authorization": "Bearer abcdefgh"}, + json={"zone": "4", "temp": 25, "humi": 23}, + ) + assert resp.status == 200 + result = await resp.json() + assert result == {"message": "ok"} + await hass.async_block_till_done() + assert hass.states.get("sensor.konnected_445566_sensor_4_humidity").state == "23" + assert ( + hass.states.get("sensor.konnected_445566_sensor_4_temperature").state == "25.0" + ) + + # Test updating ds sensor + resp = await client.post( + "/api/konnected/device/112233445566", + headers={"Authorization": "Bearer abcdefgh"}, + json={"zone": "5", "temp": 32, "addr": 1}, + ) + assert resp.status == 200 + result = await resp.json() + assert result == {"message": "ok"} + await hass.async_block_till_done() + assert hass.states.get("sensor.temper_temperature").state == "32.0" + + resp = await client.post( + "/api/konnected/device/112233445566", + headers={"Authorization": "Bearer abcdefgh"}, + json={"zone": "5", "temp": 42, "addr": 1}, + ) + assert resp.status == 200 + result = await resp.json() + assert result == {"message": "ok"} + await hass.async_block_till_done() + assert hass.states.get("sensor.temper_temperature").state == "42.0" diff --git a/tests/components/konnected/test_panel.py b/tests/components/konnected/test_panel.py new file mode 100644 index 00000000000..0ad384bd35e --- /dev/null +++ b/tests/components/konnected/test_panel.py @@ -0,0 +1,375 @@ +"""Test Konnected setup process.""" +from asynctest import patch +import pytest + +from homeassistant.components.konnected import config_flow, panel + +from tests.common import MockConfigEntry + + +@pytest.fixture(name="mock_panel") +async def mock_panel_fixture(): + """Mock a Konnected Panel bridge.""" + with patch("konnected.Client", autospec=True) as konn_client: + + def mock_constructor(host, port, websession): + """Fake the panel constructor.""" + konn_client.host = host + konn_client.port = port + return konn_client + + konn_client.side_effect = mock_constructor + konn_client.ClientError = config_flow.CannotConnect + konn_client.get_status.return_value = { + "hwVersion": "2.3.0", + "swVersion": "2.3.1", + "heap": 10000, + "uptime": 12222, + "ip": "192.168.1.90", + "port": 9123, + "sensors": [], + "actuators": [], + "dht_sensors": [], + "ds18b20_sensors": [], + "mac": "11:22:33:44:55:66", + "model": "Konnected Pro", # `model` field only included in pro + "settings": {}, + } + yield konn_client + + +async def test_create_and_setup(hass, mock_panel): + """Test that we create a Konnected Panel and save the data.""" + device_config = config_flow.CONFIG_ENTRY_SCHEMA( + { + "host": "1.2.3.4", + "port": 1234, + "id": "112233445566", + "model": "Konnected Pro", + "access_token": "11223344556677889900", + "default_options": config_flow.OPTIONS_SCHEMA({config_flow.CONF_IO: {}}), + } + ) + + device_options = config_flow.OPTIONS_SCHEMA( + { + "io": { + "1": "Binary Sensor", + "2": "Binary Sensor", + "3": "Binary Sensor", + "4": "Digital Sensor", + "5": "Digital Sensor", + "6": "Switchable Output", + "out": "Switchable Output", + }, + "binary_sensors": [ + {"zone": "1", "type": "door"}, + {"zone": "2", "type": "window", "name": "winder", "inverse": True}, + {"zone": "3", "type": "door"}, + ], + "sensors": [ + {"zone": "4", "type": "dht"}, + {"zone": "5", "type": "ds18b20", "name": "temper"}, + ], + "switches": [ + { + "zone": "out", + "name": "switcher", + "activation": "low", + "momentary": 50, + "pause": 100, + "repeat": 4, + }, + {"zone": "6"}, + ], + } + ) + + entry = MockConfigEntry( + domain="konnected", + title="Konnected Alarm Panel", + data=device_config, + options=device_options, + ) + entry.add_to_hass(hass) + hass.data[panel.DOMAIN] = { + panel.CONF_API_HOST: "192.168.1.1", + } + + # override get_status to reflect non-pro board + mock_panel.get_status.return_value = { + "hwVersion": "2.3.0", + "swVersion": "2.3.1", + "heap": 10000, + "uptime": 12222, + "ip": "192.168.1.90", + "port": 9123, + "sensors": [], + "actuators": [], + "dht_sensors": [], + "ds18b20_sensors": [], + "mac": "11:22:33:44:55:66", + "settings": {}, + } + device = panel.AlarmPanel(hass, entry) + await device.async_save_data() + await device.async_connect() + await device.update_switch("1", 0) + + # confirm the correct api is used + # pylint: disable=no-member + assert device.client.put_device.call_count == 1 + assert device.client.put_zone.call_count == 0 + + # confirm the settings are sent to the panel + # pylint: disable=no-member + assert device.client.put_settings.call_args_list[0][1] == { + "sensors": [{"pin": "1"}, {"pin": "2"}, {"pin": "5"}], + "actuators": [{"trigger": 0, "pin": "8"}, {"trigger": 1, "pin": "9"}], + "dht_sensors": [{"poll_interval": 3, "pin": "6"}], + "ds18b20_sensors": [{"pin": "7"}], + "auth_token": "11223344556677889900", + "blink": True, + "discovery": True, + "endpoint": "192.168.1.1/api/konnected", + } + + # confirm the device settings are saved in hass.data + assert hass.data[panel.DOMAIN][panel.CONF_DEVICES] == { + "112233445566": { + "binary_sensors": { + "1": { + "inverse": False, + "name": "Konnected 445566 Zone 1", + "state": None, + "type": "door", + }, + "2": { + "inverse": True, + "name": "winder", + "state": None, + "type": "window", + }, + "3": { + "inverse": False, + "name": "Konnected 445566 Zone 3", + "state": None, + "type": "door", + }, + }, + "blink": True, + "panel": device, + "discovery": True, + "host": "1.2.3.4", + "port": 1234, + "sensors": [ + { + "name": "Konnected 445566 Sensor 4", + "poll_interval": 3, + "type": "dht", + "zone": "4", + }, + {"name": "temper", "poll_interval": 3, "type": "ds18b20", "zone": "5"}, + ], + "switches": [ + { + "activation": "low", + "momentary": 50, + "name": "switcher", + "pause": 100, + "repeat": 4, + "state": None, + "zone": "out", + }, + { + "activation": "high", + "momentary": None, + "name": "Konnected 445566 Actuator 6", + "pause": None, + "repeat": None, + "state": None, + "zone": "6", + }, + ], + } + } + + +async def test_create_and_setup_pro(hass, mock_panel): + """Test that we create a Konnected Pro Panel and save the data.""" + device_config = config_flow.CONFIG_ENTRY_SCHEMA( + { + "host": "1.2.3.4", + "port": 1234, + "id": "112233445566", + "model": "Konnected Pro", + "access_token": "11223344556677889900", + "default_options": config_flow.OPTIONS_SCHEMA({config_flow.CONF_IO: {}}), + } + ) + + device_options = config_flow.OPTIONS_SCHEMA( + { + "io": { + "2": "Binary Sensor", + "6": "Binary Sensor", + "10": "Binary Sensor", + "3": "Digital Sensor", + "7": "Digital Sensor", + "11": "Digital Sensor", + "4": "Switchable Output", + "8": "Switchable Output", + "out1": "Switchable Output", + "alarm1": "Switchable Output", + }, + "binary_sensors": [ + {"zone": "2", "type": "door"}, + {"zone": "6", "type": "window", "name": "winder", "inverse": True}, + {"zone": "10", "type": "door"}, + ], + "sensors": [ + {"zone": "3", "type": "dht"}, + {"zone": "7", "type": "ds18b20", "name": "temper"}, + {"zone": "11", "type": "dht", "poll_interval": 5}, + ], + "switches": [ + {"zone": "4"}, + { + "zone": "8", + "name": "switcher", + "activation": "low", + "momentary": 50, + "pause": 100, + "repeat": 4, + }, + {"zone": "out1"}, + {"zone": "alarm1"}, + ], + } + ) + + entry = MockConfigEntry( + domain="konnected", + title="Konnected Pro Alarm Panel", + data=device_config, + options=device_options, + ) + entry.add_to_hass(hass) + hass.data[panel.DOMAIN] = { + panel.CONF_API_HOST: "192.168.1.1", + } + + device = panel.AlarmPanel(hass, entry) + await device.async_save_data() + await device.async_connect() + await device.update_switch("2", 1) + + # confirm the correct api is used + # pylint: disable=no-member + assert device.client.put_device.call_count == 0 + assert device.client.put_zone.call_count == 1 + + # confirm the settings are sent to the panel + # pylint: disable=no-member + assert device.client.put_settings.call_args_list[0][1] == { + "sensors": [{"zone": "2"}, {"zone": "6"}, {"zone": "10"}], + "actuators": [ + {"trigger": 1, "zone": "4"}, + {"trigger": 0, "zone": "8"}, + {"trigger": 1, "zone": "out1"}, + {"trigger": 1, "zone": "alarm1"}, + ], + "dht_sensors": [ + {"poll_interval": 3, "zone": "3"}, + {"poll_interval": 5, "zone": "11"}, + ], + "ds18b20_sensors": [{"zone": "7"}], + "auth_token": "11223344556677889900", + "blink": True, + "discovery": True, + "endpoint": "192.168.1.1/api/konnected", + } + + # confirm the device settings are saved in hass.data + assert hass.data[panel.DOMAIN][panel.CONF_DEVICES] == { + "112233445566": { + "binary_sensors": { + "10": { + "inverse": False, + "name": "Konnected 445566 Zone 10", + "state": None, + "type": "door", + }, + "2": { + "inverse": False, + "name": "Konnected 445566 Zone 2", + "state": None, + "type": "door", + }, + "6": { + "inverse": True, + "name": "winder", + "state": None, + "type": "window", + }, + }, + "blink": True, + "panel": device, + "discovery": True, + "host": "1.2.3.4", + "port": 1234, + "sensors": [ + { + "name": "Konnected 445566 Sensor 3", + "poll_interval": 3, + "type": "dht", + "zone": "3", + }, + {"name": "temper", "poll_interval": 3, "type": "ds18b20", "zone": "7"}, + { + "name": "Konnected 445566 Sensor 11", + "poll_interval": 5, + "type": "dht", + "zone": "11", + }, + ], + "switches": [ + { + "activation": "high", + "momentary": None, + "name": "Konnected 445566 Actuator 4", + "pause": None, + "repeat": None, + "state": None, + "zone": "4", + }, + { + "activation": "low", + "momentary": 50, + "name": "switcher", + "pause": 100, + "repeat": 4, + "state": None, + "zone": "8", + }, + { + "activation": "high", + "momentary": None, + "name": "Konnected 445566 Actuator out1", + "pause": None, + "repeat": None, + "state": None, + "zone": "out1", + }, + { + "activation": "high", + "momentary": None, + "name": "Konnected 445566 Actuator alarm1", + "pause": None, + "repeat": None, + "state": None, + "zone": "alarm1", + }, + ], + } + }