Skip to content
Snippets Groups Projects
Unverified Commit 064cc52a authored by James Hilliard's avatar James Hilliard Committed by GitHub
Browse files

Add config flow to HLK-SW16 (#37190)

* Add config flow to HLK-SW16

* Use entry_id for unique_id

* Add options update capability

* Refactor entry_id under domain

* Remove name from config

* Set options

* Remove options flow

* remove unneccesary else block from validate_input and move domain cleanup to async_unload_entry

* Add tests and config import

* Add back config schema

* Remove config import

* Refactor unload

* Add back config import

* Update coveragerc

* Don't mock validate_input

* Test duplicate configs

* Add import test

* Use patch for timeout test

* Use mock for testing timeout

* Use MockSW16Client for tests

* Check mock_calls count

* Remove unused NameExists exception

* Remove title from strings.json

* Mock setup for import test

* Set PARALLEL_UPDATES for switch

* Move hass.data.setdefault(DOMAIN, {}) to async_setup_entry
parent 76b46b91
No related branches found
No related tags found
No related merge requests found
Showing
with 477 additions and 85 deletions
......@@ -351,7 +351,8 @@ omit =
homeassistant/components/hisense_aehw4a1/*
homeassistant/components/hitron_coda/device_tracker.py
homeassistant/components/hive/*
homeassistant/components/hlk_sw16/*
homeassistant/components/hlk_sw16/__init__.py
homeassistant/components/hlk_sw16/switch.py
homeassistant/components/home_connect/*
homeassistant/components/homematic/*
homeassistant/components/homematic/climate.py
......
......@@ -172,6 +172,7 @@ homeassistant/components/hikvisioncam/* @fbradyirl
homeassistant/components/hisense_aehw4a1/* @bannhead
homeassistant/components/history/* @home-assistant/core
homeassistant/components/hive/* @Rendili @KJonline
homeassistant/components/hlk_sw16/* @jameshilliard
homeassistant/components/home_connect/* @DavidMStraub
homeassistant/components/homeassistant/* @home-assistant/core
homeassistant/components/homekit/* @bdraco
......
......@@ -4,31 +4,28 @@ import logging
from hlk_sw16 import create_hlk_sw16_connection
import voluptuous as vol
from homeassistant.const import (
CONF_HOST,
CONF_NAME,
CONF_PORT,
CONF_SWITCHES,
EVENT_HOMEASSISTANT_STOP,
)
from homeassistant.config_entries import SOURCE_IMPORT
from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT, CONF_SWITCHES
from homeassistant.core import callback
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.discovery import async_load_platform
from homeassistant.helpers.dispatcher import (
async_dispatcher_connect,
async_dispatcher_send,
)
from homeassistant.helpers.entity import Entity
from .const import (
CONNECTION_TIMEOUT,
DEFAULT_KEEP_ALIVE_INTERVAL,
DEFAULT_PORT,
DEFAULT_RECONNECT_INTERVAL,
DOMAIN,
)
_LOGGER = logging.getLogger(__name__)
DATA_DEVICE_REGISTER = "hlk_sw16_device_register"
DEFAULT_RECONNECT_INTERVAL = 10
DEFAULT_KEEP_ALIVE_INTERVAL = 3
CONNECTION_TIMEOUT = 10
DEFAULT_PORT = 8080
DOMAIN = "hlk_sw16"
DATA_DEVICE_LISTENER = "hlk_sw16_device_listener"
SWITCH_SCHEMA = vol.Schema({vol.Optional(CONF_NAME): cv.string})
......@@ -57,84 +54,112 @@ CONFIG_SCHEMA = vol.Schema(
async def async_setup(hass, config):
"""Set up the HLK-SW16 switch."""
# Allow platform to specify function to register new unknown devices
hass.data[DATA_DEVICE_REGISTER] = {}
def add_device(device):
switches = config[DOMAIN][device][CONF_SWITCHES]
host = config[DOMAIN][device][CONF_HOST]
port = config[DOMAIN][device][CONF_PORT]
@callback
def disconnected():
"""Schedule reconnect after connection has been lost."""
_LOGGER.warning("HLK-SW16 %s disconnected", device)
async_dispatcher_send(hass, f"hlk_sw16_device_available_{device}", False)
@callback
def reconnected():
"""Schedule reconnect after connection has been lost."""
_LOGGER.warning("HLK-SW16 %s connected", device)
async_dispatcher_send(hass, f"hlk_sw16_device_available_{device}", True)
async def connect():
"""Set up connection and hook it into HA for reconnect/shutdown."""
_LOGGER.info("Initiating HLK-SW16 connection to %s", device)
client = await create_hlk_sw16_connection(
host=host,
port=port,
disconnect_callback=disconnected,
reconnect_callback=reconnected,
loop=hass.loop,
timeout=CONNECTION_TIMEOUT,
reconnect_interval=DEFAULT_RECONNECT_INTERVAL,
keep_alive_interval=DEFAULT_KEEP_ALIVE_INTERVAL,
"""Component setup, do nothing."""
if DOMAIN not in config:
return True
for device_id in config[DOMAIN]:
conf = config[DOMAIN][device_id]
hass.async_create_task(
hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_IMPORT},
data={CONF_HOST: conf[CONF_HOST], CONF_PORT: conf[CONF_PORT]},
)
)
return True
hass.data[DATA_DEVICE_REGISTER][device] = client
# Load platforms
hass.async_create_task(
async_load_platform(hass, "switch", DOMAIN, (switches, device), config)
)
async def async_setup_entry(hass, entry):
"""Set up the HLK-SW16 switch."""
hass.data.setdefault(DOMAIN, {})
host = entry.data[CONF_HOST]
port = entry.data[CONF_PORT]
address = f"{host}:{port}"
# handle shutdown of HLK-SW16 asyncio transport
hass.bus.async_listen_once(
EVENT_HOMEASSISTANT_STOP, lambda x: client.stop()
)
hass.data[DOMAIN][entry.entry_id] = {}
@callback
def disconnected():
"""Schedule reconnect after connection has been lost."""
_LOGGER.warning("HLK-SW16 %s disconnected", address)
async_dispatcher_send(
hass, f"hlk_sw16_device_available_{entry.entry_id}", False
)
@callback
def reconnected():
"""Schedule reconnect after connection has been lost."""
_LOGGER.warning("HLK-SW16 %s connected", address)
async_dispatcher_send(hass, f"hlk_sw16_device_available_{entry.entry_id}", True)
async def connect():
"""Set up connection and hook it into HA for reconnect/shutdown."""
_LOGGER.info("Initiating HLK-SW16 connection to %s", address)
client = await create_hlk_sw16_connection(
host=host,
port=port,
disconnect_callback=disconnected,
reconnect_callback=reconnected,
loop=hass.loop,
timeout=CONNECTION_TIMEOUT,
reconnect_interval=DEFAULT_RECONNECT_INTERVAL,
keep_alive_interval=DEFAULT_KEEP_ALIVE_INTERVAL,
)
hass.data[DOMAIN][entry.entry_id][DATA_DEVICE_REGISTER] = client
# Load entities
hass.async_create_task(
hass.config_entries.async_forward_entry_setup(entry, "switch")
)
_LOGGER.info("Connected to HLK-SW16 device: %s", device)
_LOGGER.info("Connected to HLK-SW16 device: %s", address)
hass.loop.create_task(connect())
hass.loop.create_task(connect())
for device in config[DOMAIN]:
add_device(device)
return True
async def async_unload_entry(hass, entry):
"""Unload a config entry."""
client = hass.data[DOMAIN][entry.entry_id].pop(DATA_DEVICE_REGISTER)
client.stop()
unload_ok = await hass.config_entries.async_forward_entry_unload(entry, "switch")
if unload_ok:
if hass.data[DOMAIN][entry.entry_id]:
hass.data[DOMAIN].pop(entry.entry_id)
if not hass.data[DOMAIN]:
hass.data.pop(DOMAIN)
return unload_ok
class SW16Device(Entity):
"""Representation of a HLK-SW16 device.
Contains the common logic for HLK-SW16 entities.
"""
def __init__(self, relay_name, device_port, device_id, client):
def __init__(self, device_port, entry_id, client):
"""Initialize the device."""
# HLK-SW16 specific attributes for every component type
self._device_id = device_id
self._entry_id = entry_id
self._device_port = device_port
self._is_on = None
self._client = client
self._name = relay_name
self._name = device_port
@property
def unique_id(self):
"""Return a unique ID."""
return f"{self._entry_id}_{self._device_port}"
@callback
def handle_event_callback(self, event):
"""Propagate changes through ha."""
_LOGGER.debug("Relay %s new state callback: %r", self._device_port, event)
_LOGGER.debug("Relay %s new state callback: %r", self.unique_id, event)
self._is_on = event
self.async_write_ha_state()
......@@ -167,7 +192,7 @@ class SW16Device(Entity):
self.async_on_remove(
async_dispatcher_connect(
self.hass,
f"hlk_sw16_device_available_{self._device_id}",
f"hlk_sw16_device_available_{self._entry_id}",
self._availability_callback,
)
)
"""Config flow for HLK-SW16."""
import asyncio
from hlk_sw16 import create_hlk_sw16_connection
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.const import CONF_HOST, CONF_PORT
from homeassistant.core import HomeAssistant
from .const import (
CONNECTION_TIMEOUT,
DEFAULT_KEEP_ALIVE_INTERVAL,
DEFAULT_PORT,
DEFAULT_RECONNECT_INTERVAL,
DOMAIN,
)
from .errors import AlreadyConfigured, CannotConnect
DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_HOST): str,
vol.Optional(CONF_PORT, default=DEFAULT_PORT): vol.Coerce(int),
}
)
async def connect_client(hass, user_input):
"""Connect the HLK-SW16 client."""
client_aw = create_hlk_sw16_connection(
host=user_input[CONF_HOST],
port=user_input[CONF_PORT],
loop=hass.loop,
timeout=CONNECTION_TIMEOUT,
reconnect_interval=DEFAULT_RECONNECT_INTERVAL,
keep_alive_interval=DEFAULT_KEEP_ALIVE_INTERVAL,
)
return await asyncio.wait_for(client_aw, timeout=CONNECTION_TIMEOUT)
async def validate_input(hass: HomeAssistant, user_input):
"""Validate the user input allows us to connect."""
for entry in hass.config_entries.async_entries(DOMAIN):
if (
entry.data[CONF_HOST] == user_input[CONF_HOST]
and entry.data[CONF_PORT] == user_input[CONF_PORT]
):
raise AlreadyConfigured
try:
client = await connect_client(hass, user_input)
except asyncio.TimeoutError:
raise CannotConnect
try:
def disconnect_callback():
if client.in_transaction:
client.active_transaction.set_exception(CannotConnect)
client.disconnect_callback = disconnect_callback
await client.status()
except CannotConnect:
client.disconnect_callback = None
client.stop()
raise CannotConnect
else:
client.disconnect_callback = None
client.stop()
class SW16FlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a HLK-SW16 config flow."""
VERSION = 1
CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_PUSH
async def async_step_import(self, user_input):
"""Handle import."""
return await self.async_step_user(user_input)
async def async_step_user(self, user_input=None):
"""Handle the initial step."""
errors = {}
if user_input is not None:
try:
await validate_input(self.hass, user_input)
address = f"{user_input[CONF_HOST]}:{user_input[CONF_PORT]}"
return self.async_create_entry(title=address, data=user_input)
except AlreadyConfigured:
errors["base"] = "already_configured"
except CannotConnect:
errors["base"] = "cannot_connect"
return self.async_show_form(
step_id="user", data_schema=DATA_SCHEMA, errors=errors
)
"""Constants for HLK-SW16 component."""
DOMAIN = "hlk_sw16"
DEFAULT_NAME = "HLK-SW16"
DEFAULT_PORT = 8080
DEFAULT_RECONNECT_INTERVAL = 10
DEFAULT_KEEP_ALIVE_INTERVAL = 3
CONNECTION_TIMEOUT = 10
"""Errors for the HLK-SW16 component."""
from homeassistant.exceptions import HomeAssistantError
class SW16Exception(HomeAssistantError):
"""Base class for HLK-SW16 exceptions."""
class AlreadyConfigured(SW16Exception):
"""HLK-SW16 is already configured."""
class CannotConnect(SW16Exception):
"""Unable to connect to the HLK-SW16."""
......@@ -2,6 +2,11 @@
"domain": "hlk_sw16",
"name": "Hi-Link HLK-SW16",
"documentation": "https://www.home-assistant.io/integrations/hlk_sw16",
"requirements": ["hlk-sw16==0.0.8"],
"codeowners": []
}
"requirements": [
"hlk-sw16==0.0.8"
],
"codeowners": [
"@jameshilliard"
],
"config_flow": true
}
\ No newline at end of file
{
"config": {
"step": {
"user": {
"data": {
"host": "[%key:common::config_flow::data::host%]",
"username": "[%key:common::config_flow::data::username%]",
"password": "[%key:common::config_flow::data::password%]"
}
}
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
}
}
}
\ No newline at end of file
"""Support for HLK-SW16 switches."""
import logging
from homeassistant.components.switch import ToggleEntity
from homeassistant.const import CONF_NAME
from . import DATA_DEVICE_REGISTER, SW16Device
from .const import DOMAIN
PARALLEL_UPDATES = 0
_LOGGER = logging.getLogger(__name__)
async def async_setup_platform(hass, config, async_add_entities, discovery_info=None):
"""Set up the HLK-SW16 switches."""
def devices_from_config(hass, domain_config):
def devices_from_entities(hass, entry):
"""Parse configuration and add HLK-SW16 switch devices."""
switches = domain_config[0]
device_id = domain_config[1]
device_client = hass.data[DATA_DEVICE_REGISTER][device_id]
device_client = hass.data[DOMAIN][entry.entry_id][DATA_DEVICE_REGISTER]
devices = []
for device_port, device_config in switches.items():
device_name = device_config.get(CONF_NAME, device_port)
device = SW16Switch(device_name, device_port, device_id, device_client)
for i in range(16):
device_port = f"{i:01x}"
device = SW16Switch(device_port, entry.entry_id, device_client)
devices.append(device)
return devices
async def async_setup_platform(hass, config, async_add_entities, discovery_info=None):
async def async_setup_entry(hass, entry, async_add_entities):
"""Set up the HLK-SW16 platform."""
async_add_entities(devices_from_config(hass, discovery_info))
async_add_entities(devices_from_entities(hass, entry))
class SW16Switch(SW16Device, ToggleEntity):
......
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"step": {
"user": {
"data": {
"host": "[%key:common::config_flow::data::host%]",
"password": "[%key:common::config_flow::data::password%]",
"username": "[%key:common::config_flow::data::username%]"
}
}
}
},
"title": "Hi-Link HLK-SW16"
}
\ No newline at end of file
......@@ -71,6 +71,7 @@ FLOWS = [
"harmony",
"heos",
"hisense_aehw4a1",
"hlk_sw16",
"home_connect",
"homekit",
"homekit_controller",
......
......@@ -352,6 +352,9 @@ hdate==0.9.5
# homeassistant.components.here_travel_time
herepy==2.0.0
# homeassistant.components.hlk_sw16
hlk-sw16==0.0.8
# homeassistant.components.pi_hole
hole==0.5.1
......
"""Tests for the Hi-Link HLK-SW16 integration."""
"""Test the Hi-Link HLK-SW16 config flow."""
import asyncio
from homeassistant import config_entries, setup
from homeassistant.components.hlk_sw16.const import DOMAIN
from tests.async_mock import patch
class MockSW16Client:
"""Class to mock the SW16Client client."""
def __init__(self, fail):
"""Initialise client with failure modes."""
self.fail = fail
self.disconnect_callback = None
self.in_transaction = False
self.active_transaction = None
async def setup(self):
"""Mock successful setup."""
fut = asyncio.Future()
fut.set_result(True)
return fut
async def status(self):
"""Mock status based on failure mode."""
self.in_transaction = True
self.active_transaction = asyncio.Future()
if self.fail:
if self.disconnect_callback:
self.disconnect_callback()
return await self.active_transaction
else:
self.active_transaction.set_result(True)
return self.active_transaction
def stop(self):
"""Mock client stop."""
self.in_transaction = False
self.active_transaction = None
async def create_mock_hlk_sw16_connection(fail):
"""Create a mock HLK-SW16 client."""
client = MockSW16Client(fail)
await client.setup()
return client
async def test_form(hass):
"""Test we get the form."""
await setup.async_setup_component(hass, "persistent_notification", {})
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] == "form"
assert result["errors"] == {}
conf = {
"host": "127.0.0.1",
"port": 8080,
}
mock_hlk_sw16_connection = await create_mock_hlk_sw16_connection(False)
with patch(
"homeassistant.components.hlk_sw16.config_flow.create_hlk_sw16_connection",
return_value=mock_hlk_sw16_connection,
), patch(
"homeassistant.components.hlk_sw16.async_setup", return_value=True
) as mock_setup, patch(
"homeassistant.components.hlk_sw16.async_setup_entry", return_value=True,
) as mock_setup_entry:
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"], conf,
)
assert result2["type"] == "create_entry"
assert result2["title"] == "127.0.0.1:8080"
assert result2["data"] == {
"host": "127.0.0.1",
"port": 8080,
}
await hass.async_block_till_done()
assert len(mock_setup.mock_calls) == 1
assert len(mock_setup_entry.mock_calls) == 1
mock_hlk_sw16_connection = await create_mock_hlk_sw16_connection(False)
with patch(
"homeassistant.components.hlk_sw16.config_flow.create_hlk_sw16_connection",
return_value=mock_hlk_sw16_connection,
):
result3 = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result3["type"] == "form"
assert result3["errors"] == {}
result4 = await hass.config_entries.flow.async_configure(result3["flow_id"], conf,)
assert result4["type"] == "form"
assert result4["errors"] == {"base": "already_configured"}
await hass.async_block_till_done()
async def test_import(hass):
"""Test we get the form."""
await setup.async_setup_component(hass, "persistent_notification", {})
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_IMPORT}
)
assert result["type"] == "form"
assert result["errors"] == {}
conf = {
"host": "127.0.0.1",
"port": 8080,
}
mock_hlk_sw16_connection = await create_mock_hlk_sw16_connection(False)
with patch(
"homeassistant.components.hlk_sw16.config_flow.connect_client",
return_value=mock_hlk_sw16_connection,
), patch(
"homeassistant.components.hlk_sw16.async_setup", return_value=True
) as mock_setup, patch(
"homeassistant.components.hlk_sw16.async_setup_entry", return_value=True,
) as mock_setup_entry:
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"], conf,
)
assert result2["type"] == "create_entry"
assert result2["title"] == "127.0.0.1:8080"
assert result2["data"] == {
"host": "127.0.0.1",
"port": 8080,
}
await hass.async_block_till_done()
assert len(mock_setup.mock_calls) == 1
assert len(mock_setup_entry.mock_calls) == 1
async def test_form_invalid_data(hass):
"""Test we handle invalid auth."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
mock_hlk_sw16_connection = await create_mock_hlk_sw16_connection(True)
conf = {
"host": "127.0.0.1",
"port": 8080,
}
with patch(
"homeassistant.components.hlk_sw16.config_flow.connect_client",
return_value=mock_hlk_sw16_connection,
):
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"], conf,
)
assert result2["type"] == "form"
assert result2["errors"] == {"base": "cannot_connect"}
async def test_form_cannot_connect(hass):
"""Test we handle cannot connect error."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
conf = {
"host": "127.0.0.1",
"port": 8080,
}
with patch(
"homeassistant.components.hlk_sw16.config_flow.connect_client",
side_effect=asyncio.TimeoutError,
return_value=None,
):
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"], conf,
)
assert result2["type"] == "form"
assert result2["errors"] == {"base": "cannot_connect"}
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment