Add opentherm_gw config flow (#27148)

* Add config flow support to opentherm_gw.
Bump pyotgw to 0.5b0 (required for connection testing)
Existing entries in configuration.yaml will be converted to config entries and ignored in future runs.

* Fix not connecting to Gateway on startup.
Pylint fixes.

* Add tests for config flow.
Remove non-essential options from config flow.
Restructure config entry data.

* Make sure gw_id is slugified
This commit is contained in:
mvn23 2019-10-05 02:38:26 +02:00 committed by Paulus Schoutsen
parent 2e49303401
commit 6ae908b883
17 changed files with 413 additions and 50 deletions

View file

@ -464,7 +464,10 @@ omit =
homeassistant/components/openhome/media_player.py
homeassistant/components/opensensemap/air_quality.py
homeassistant/components/opensky/sensor.py
homeassistant/components/opentherm_gw/*
homeassistant/components/opentherm_gw/__init__.py
homeassistant/components/opentherm_gw/binary_sensor.py
homeassistant/components/opentherm_gw/climate.py
homeassistant/components/opentherm_gw/sensor.py
homeassistant/components/openuv/__init__.py
homeassistant/components/openuv/binary_sensor.py
homeassistant/components/openuv/sensor.py

View file

@ -0,0 +1,23 @@
{
"config": {
"error": {
"already_configured": "Gateway already configured",
"id_exists": "Gateway id already exists",
"serial_error": "Error connecting to device",
"timeout": "Connection attempt timed out"
},
"step": {
"init": {
"data": {
"device": "Path or URL",
"floor_temperature": "Floor climate temperature",
"id": "ID",
"name": "Name",
"precision": "Climate temperature precision"
},
"title": "OpenTherm Gateway"
}
},
"title": "OpenTherm Gateway"
}
}

View file

@ -0,0 +1,23 @@
{
"config": {
"error": {
"already_configured": "Gateway is reeds geconfigureerd",
"id_exists": "Gateway id bestaat reeds",
"serial_error": "Kan niet verbinden met de Gateway",
"timeout": "Time-out van de verbinding"
},
"step": {
"init": {
"data": {
"device": "Pad of URL",
"floor_temperature": "Thermostaat temperaturen naar beneden afronden",
"id": "ID",
"name": "Naam",
"precision": "Thermostaat temperatuur precisie"
},
"title": "OpenTherm Gateway"
}
},
"title": "OpenTherm Gateway"
}
}

View file

@ -6,6 +6,7 @@ import pyotgw
import pyotgw.vars as gw_vars
import voluptuous as vol
from homeassistant.config_entries import SOURCE_IMPORT
from homeassistant.components.binary_sensor import DOMAIN as COMP_BINARY_SENSOR
from homeassistant.components.climate import DOMAIN as COMP_CLIMATE
from homeassistant.components.sensor import DOMAIN as COMP_SENSOR
@ -16,13 +17,13 @@ from homeassistant.const import (
ATTR_TEMPERATURE,
ATTR_TIME,
CONF_DEVICE,
CONF_ID,
CONF_NAME,
EVENT_HOMEASSISTANT_STOP,
PRECISION_HALVES,
PRECISION_TENTHS,
PRECISION_WHOLE,
)
from homeassistant.helpers.discovery import async_load_platform
from homeassistant.helpers.dispatcher import async_dispatcher_send
import homeassistant.helpers.config_validation as cv
@ -36,6 +37,7 @@ from .const import (
CONF_PRECISION,
DATA_GATEWAYS,
DATA_OPENTHERM_GW,
DOMAIN,
SERVICE_RESET_GATEWAY,
SERVICE_SET_CLOCK,
SERVICE_SET_CONTROL_SETPOINT,
@ -50,8 +52,6 @@ from .const import (
_LOGGER = logging.getLogger(__name__)
DOMAIN = "opentherm_gw"
CLIMATE_SCHEMA = vol.Schema(
{
vol.Optional(CONF_PRECISION): vol.In(
@ -75,25 +75,38 @@ CONFIG_SCHEMA = vol.Schema(
)
async def async_setup_entry(hass, config_entry):
"""Set up the OpenTherm Gateway component."""
if DATA_OPENTHERM_GW not in hass.data:
hass.data[DATA_OPENTHERM_GW] = {DATA_GATEWAYS: {}}
gateway = OpenThermGatewayDevice(hass, config_entry)
hass.data[DATA_OPENTHERM_GW][DATA_GATEWAYS][config_entry.data[CONF_ID]] = gateway
# Schedule directly on the loop to avoid blocking HA startup.
hass.loop.create_task(gateway.connect_and_subscribe())
for comp in [COMP_BINARY_SENSOR, COMP_CLIMATE, COMP_SENSOR]:
hass.async_create_task(
hass.config_entries.async_forward_entry_setup(config_entry, comp)
)
register_services(hass)
return True
async def async_setup(hass, config):
"""Set up the OpenTherm Gateway component."""
if not hass.config_entries.async_entries(DOMAIN) and DOMAIN in config:
conf = config[DOMAIN]
hass.data[DATA_OPENTHERM_GW] = {DATA_GATEWAYS: {}}
for gw_id, cfg in conf.items():
gateway = OpenThermGatewayDevice(hass, gw_id, cfg)
hass.data[DATA_OPENTHERM_GW][DATA_GATEWAYS][gw_id] = gateway
for device_id, device_config in conf.items():
device_config[CONF_ID] = device_id
hass.async_create_task(
async_load_platform(hass, COMP_CLIMATE, DOMAIN, gw_id, config)
hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_IMPORT}, data=device_config
)
hass.async_create_task(
async_load_platform(hass, COMP_BINARY_SENSOR, DOMAIN, gw_id, config)
)
hass.async_create_task(
async_load_platform(hass, COMP_SENSOR, DOMAIN, gw_id, config)
)
# Schedule directly on the loop to avoid blocking HA startup.
hass.loop.create_task(gateway.connect_and_subscribe(cfg[CONF_DEVICE]))
register_services(hass)
return True
@ -326,20 +339,21 @@ def register_services(hass):
class OpenThermGatewayDevice:
"""OpenTherm Gateway device class."""
def __init__(self, hass, gw_id, config):
def __init__(self, hass, config_entry):
"""Initialize the OpenTherm Gateway."""
self.hass = hass
self.gw_id = gw_id
self.name = config.get(CONF_NAME, gw_id)
self.climate_config = config[CONF_CLIMATE]
self.device_path = config_entry.data[CONF_DEVICE]
self.gw_id = config_entry.data[CONF_ID]
self.name = config_entry.data[CONF_NAME]
self.climate_config = config_entry.options
self.status = {}
self.update_signal = f"{DATA_OPENTHERM_GW}_{gw_id}_update"
self.update_signal = f"{DATA_OPENTHERM_GW}_{self.gw_id}_update"
self.gateway = pyotgw.pyotgw()
async def connect_and_subscribe(self, device_path):
async def connect_and_subscribe(self):
"""Connect to serial device and subscribe report handler."""
await self.gateway.connect(self.hass.loop, device_path)
_LOGGER.debug("Connected to OpenTherm Gateway at %s", device_path)
await self.gateway.connect(self.hass.loop, self.device_path)
_LOGGER.debug("Connected to OpenTherm Gateway at %s", self.device_path)
async def cleanup(event):
"""Reset overrides on the gateway."""

View file

@ -2,6 +2,7 @@
import logging
from homeassistant.components.binary_sensor import ENTITY_ID_FORMAT, BinarySensorDevice
from homeassistant.const import CONF_ID
from homeassistant.core import callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity import async_generate_entity_id
@ -12,18 +13,21 @@ from .const import BINARY_SENSOR_INFO, DATA_GATEWAYS, DATA_OPENTHERM_GW
_LOGGER = logging.getLogger(__name__)
async def async_setup_platform(hass, config, async_add_entities, discovery_info=None):
async def async_setup_entry(hass, config_entry, async_add_entities):
"""Set up the OpenTherm Gateway binary sensors."""
if discovery_info is None:
return
gw_dev = hass.data[DATA_OPENTHERM_GW][DATA_GATEWAYS][discovery_info]
sensors = []
for var, info in BINARY_SENSOR_INFO.items():
device_class = info[0]
friendly_name_format = info[1]
sensors.append(
OpenThermBinarySensor(gw_dev, var, device_class, friendly_name_format)
OpenThermBinarySensor(
hass.data[DATA_OPENTHERM_GW][DATA_GATEWAYS][config_entry.data[CONF_ID]],
var,
device_class,
friendly_name_format,
)
)
async_add_entities(sensors)

View file

@ -17,6 +17,7 @@ from homeassistant.components.climate.const import (
)
from homeassistant.const import (
ATTR_TEMPERATURE,
CONF_ID,
PRECISION_HALVES,
PRECISION_TENTHS,
PRECISION_WHOLE,
@ -33,12 +34,16 @@ _LOGGER = logging.getLogger(__name__)
SUPPORT_FLAGS = SUPPORT_TARGET_TEMPERATURE | SUPPORT_PRESET_MODE
async def async_setup_platform(hass, config, async_add_entities, discovery_info=None):
"""Set up the opentherm_gw device."""
gw_dev = hass.data[DATA_OPENTHERM_GW][DATA_GATEWAYS][discovery_info]
async def async_setup_entry(hass, config_entry, async_add_entities):
"""Set up an OpenTherm Gateway climate entity."""
ents = []
ents.append(
OpenThermClimate(
hass.data[DATA_OPENTHERM_GW][DATA_GATEWAYS][config_entry.data[CONF_ID]]
)
)
gateway = OpenThermClimate(gw_dev)
async_add_entities([gateway])
async_add_entities(ents)
class OpenThermClimate(ClimateDevice):
@ -48,7 +53,7 @@ class OpenThermClimate(ClimateDevice):
"""Initialize the device."""
self._gateway = gw_dev
self.friendly_name = gw_dev.name
self.floor_temp = gw_dev.climate_config[CONF_FLOOR_TEMP]
self.floor_temp = gw_dev.climate_config.get(CONF_FLOOR_TEMP)
self.temp_precision = gw_dev.climate_config.get(CONF_PRECISION)
self._current_operation = None
self._current_temperature = None
@ -62,7 +67,7 @@ class OpenThermClimate(ClimateDevice):
async def async_added_to_hass(self):
"""Connect to the OpenTherm Gateway device."""
_LOGGER.debug("Added device %s", self.friendly_name)
_LOGGER.debug("Added OpenTherm Gateway climate device %s", self.friendly_name)
async_dispatcher_connect(
self.hass, self._gateway.update_signal, self.receive_report
)

View file

@ -0,0 +1,91 @@
"""OpenTherm Gateway config flow."""
import asyncio
from serial import SerialException
import pyotgw
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.const import CONF_DEVICE, CONF_ID, CONF_NAME
import homeassistant.helpers.config_validation as cv
from . import DOMAIN
class OpenThermGwConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""OpenTherm Gateway Config Flow."""
VERSION = 1
CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_PUSH
async def async_step_init(self, info=None):
"""Handle config flow initiation."""
if info:
name = info[CONF_NAME]
device = info[CONF_DEVICE]
gw_id = cv.slugify(info.get(CONF_ID, name))
entries = [e.data for e in self.hass.config_entries.async_entries(DOMAIN)]
if gw_id in [e[CONF_ID] for e in entries]:
return self._show_form({"base": "id_exists"})
if device in [e[CONF_DEVICE] for e in entries]:
return self._show_form({"base": "already_configured"})
async def test_connection():
"""Try to connect to the OpenTherm Gateway."""
otgw = pyotgw.pyotgw()
status = await otgw.connect(self.hass.loop, device)
await otgw.disconnect()
return status.get(pyotgw.OTGW_ABOUT)
try:
res = await asyncio.wait_for(test_connection(), timeout=10)
except asyncio.TimeoutError:
return self._show_form({"base": "timeout"})
except SerialException:
return self._show_form({"base": "serial_error"})
if res:
return self._create_entry(gw_id, name, device)
return self._show_form()
async def async_step_user(self, info=None):
"""Handle manual initiation of the config flow."""
return await self.async_step_init(info)
async def async_step_import(self, import_config):
"""
Import an OpenTherm Gateway device as a config entry.
This flow is triggered by `async_setup` for configured devices.
"""
formatted_config = {
CONF_NAME: import_config.get(CONF_NAME, import_config[CONF_ID]),
CONF_DEVICE: import_config[CONF_DEVICE],
CONF_ID: import_config[CONF_ID],
}
return await self.async_step_init(info=formatted_config)
def _show_form(self, errors=None):
"""Show the config flow form with possible errors."""
return self.async_show_form(
step_id="init",
data_schema=vol.Schema(
{
vol.Required(CONF_NAME): str,
vol.Required(CONF_DEVICE): str,
vol.Optional(CONF_ID): str,
}
),
errors=errors or {},
)
def _create_entry(self, gw_id, name, device):
"""Create entry for the OpenTherm Gateway device."""
return self.async_create_entry(
title=name, data={CONF_ID: gw_id, CONF_DEVICE: device, CONF_NAME: name}
)

View file

@ -18,6 +18,8 @@ DEVICE_CLASS_COLD = "cold"
DEVICE_CLASS_HEAT = "heat"
DEVICE_CLASS_PROBLEM = "problem"
DOMAIN = "opentherm_gw"
SERVICE_RESET_GATEWAY = "reset_gateway"
SERVICE_SET_CLOCK = "set_clock"
SERVICE_SET_CONTROL_SETPOINT = "set_control_setpoint"

View file

@ -3,10 +3,11 @@
"name": "Opentherm Gateway",
"documentation": "https://www.home-assistant.io/integrations/opentherm_gw",
"requirements": [
"pyotgw==0.4b4"
"pyotgw==0.5b0"
],
"dependencies": [],
"codeowners": [
"@mvn23"
]
],
"config_flow": true
}

View file

@ -2,6 +2,7 @@
import logging
from homeassistant.components.sensor import ENTITY_ID_FORMAT
from homeassistant.const import CONF_ID
from homeassistant.core import callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity import Entity, async_generate_entity_id
@ -12,19 +13,23 @@ from .const import DATA_GATEWAYS, DATA_OPENTHERM_GW, SENSOR_INFO
_LOGGER = logging.getLogger(__name__)
async def async_setup_platform(hass, config, async_add_entities, discovery_info=None):
async def async_setup_entry(hass, config_entry, async_add_entities):
"""Set up the OpenTherm Gateway sensors."""
if discovery_info is None:
return
gw_dev = hass.data[DATA_OPENTHERM_GW][DATA_GATEWAYS][discovery_info]
sensors = []
for var, info in SENSOR_INFO.items():
device_class = info[0]
unit = info[1]
friendly_name_format = info[2]
sensors.append(
OpenThermSensor(gw_dev, var, device_class, unit, friendly_name_format)
OpenThermSensor(
hass.data[DATA_OPENTHERM_GW][DATA_GATEWAYS][config_entry.data[CONF_ID]],
var,
device_class,
unit,
friendly_name_format,
)
)
async_add_entities(sensors)

View file

@ -0,0 +1,23 @@
{
"config": {
"title": "OpenTherm Gateway",
"step": {
"init": {
"title": "OpenTherm Gateway",
"data": {
"name": "Name",
"device": "Path or URL",
"id": "ID",
"precision": "Climate temperature precision",
"floor_temperature": "Floor climate temperature"
}
}
},
"error": {
"already_configured": "Gateway already configured",
"id_exists": "Gateway id already exists",
"serial_error": "Error connecting to device",
"timeout": "Connection attempt timed out"
}
}
}

View file

@ -45,6 +45,7 @@ FLOWS = [
"mqtt",
"nest",
"notion",
"opentherm_gw",
"openuv",
"owntracks",
"plaato",

View file

@ -1370,7 +1370,7 @@ pyoppleio==1.0.5
pyota==2.0.5
# homeassistant.components.opentherm_gw
pyotgw==0.4b4
pyotgw==0.5b0
# homeassistant.auth.mfa_modules.notify
# homeassistant.auth.mfa_modules.totp

View file

@ -335,6 +335,9 @@ pynx584==0.4
# homeassistant.components.openuv
pyopenuv==1.0.9
# homeassistant.components.opentherm_gw
pyotgw==0.5b0
# homeassistant.auth.mfa_modules.notify
# homeassistant.auth.mfa_modules.totp
# homeassistant.components.otp

View file

@ -138,6 +138,7 @@ TEST_REQUIREMENTS = (
"pynws",
"pynx584",
"pyopenuv",
"pyotgw",
"pyotp",
"pyps4-homeassistant",
"pyqwikswitch",

View file

@ -0,0 +1 @@
"""Tests for the Opentherm Gateway integration."""

View file

@ -0,0 +1,163 @@
"""Test the Opentherm Gateway config flow."""
import asyncio
from serial import SerialException
from unittest.mock import patch
from homeassistant import config_entries, setup
from homeassistant.const import CONF_DEVICE, CONF_ID, CONF_NAME
from homeassistant.components.opentherm_gw.const import DOMAIN
from pyotgw import OTGW_ABOUT
from tests.common import mock_coro
async def test_form_user(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"] == {}
with patch(
"homeassistant.components.opentherm_gw.async_setup",
return_value=mock_coro(True),
) as mock_setup, patch(
"homeassistant.components.opentherm_gw.async_setup_entry",
return_value=mock_coro(True),
) as mock_setup_entry, patch(
"pyotgw.pyotgw.connect",
return_value=mock_coro({OTGW_ABOUT: "OpenTherm Gateway 4.2.5"}),
) as mock_pyotgw_connect, patch(
"pyotgw.pyotgw.disconnect", return_value=mock_coro(None)
) as mock_pyotgw_disconnect:
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"], {CONF_NAME: "Test Entry 1", CONF_DEVICE: "/dev/ttyUSB0"}
)
assert result2["type"] == "create_entry"
assert result2["title"] == "Test Entry 1"
assert result2["data"] == {
CONF_NAME: "Test Entry 1",
CONF_DEVICE: "/dev/ttyUSB0",
CONF_ID: "test_entry_1",
}
await hass.async_block_till_done()
assert len(mock_setup.mock_calls) == 1
assert len(mock_setup_entry.mock_calls) == 1
assert len(mock_pyotgw_connect.mock_calls) == 1
assert len(mock_pyotgw_disconnect.mock_calls) == 1
async def test_form_import(hass):
"""Test import from existing config."""
await setup.async_setup_component(hass, "persistent_notification", {})
with patch(
"homeassistant.components.opentherm_gw.async_setup",
return_value=mock_coro(True),
) as mock_setup, patch(
"homeassistant.components.opentherm_gw.async_setup_entry",
return_value=mock_coro(True),
) as mock_setup_entry, patch(
"pyotgw.pyotgw.connect",
return_value=mock_coro({OTGW_ABOUT: "OpenTherm Gateway 4.2.5"}),
) as mock_pyotgw_connect, patch(
"pyotgw.pyotgw.disconnect", return_value=mock_coro(None)
) as mock_pyotgw_disconnect:
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_IMPORT},
data={CONF_ID: "legacy_gateway", CONF_DEVICE: "/dev/ttyUSB1"},
)
assert result["type"] == "create_entry"
assert result["title"] == "legacy_gateway"
assert result["data"] == {
CONF_NAME: "legacy_gateway",
CONF_DEVICE: "/dev/ttyUSB1",
CONF_ID: "legacy_gateway",
}
assert len(mock_setup.mock_calls) == 1
assert len(mock_setup_entry.mock_calls) == 1
assert len(mock_pyotgw_connect.mock_calls) == 1
assert len(mock_pyotgw_disconnect.mock_calls) == 1
async def test_form_duplicate_entries(hass):
"""Test duplicate device or id errors."""
flow1 = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
flow2 = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
flow3 = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
with patch(
"homeassistant.components.opentherm_gw.async_setup",
return_value=mock_coro(True),
) as mock_setup, patch(
"homeassistant.components.opentherm_gw.async_setup_entry",
return_value=mock_coro(True),
) as mock_setup_entry, patch(
"pyotgw.pyotgw.connect",
return_value=mock_coro({OTGW_ABOUT: "OpenTherm Gateway 4.2.5"}),
) as mock_pyotgw_connect, patch(
"pyotgw.pyotgw.disconnect", return_value=mock_coro(None)
) as mock_pyotgw_disconnect:
result1 = await hass.config_entries.flow.async_configure(
flow1["flow_id"], {CONF_NAME: "Test Entry 1", CONF_DEVICE: "/dev/ttyUSB0"}
)
result2 = await hass.config_entries.flow.async_configure(
flow2["flow_id"], {CONF_NAME: "Test Entry 1", CONF_DEVICE: "/dev/ttyUSB1"}
)
result3 = await hass.config_entries.flow.async_configure(
flow3["flow_id"], {CONF_NAME: "Test Entry 2", CONF_DEVICE: "/dev/ttyUSB0"}
)
assert result1["type"] == "create_entry"
assert result2["type"] == "form"
assert result2["errors"] == {"base": "id_exists"}
assert result3["type"] == "form"
assert result3["errors"] == {"base": "already_configured"}
assert len(mock_setup.mock_calls) == 1
assert len(mock_setup_entry.mock_calls) == 1
assert len(mock_pyotgw_connect.mock_calls) == 1
assert len(mock_pyotgw_disconnect.mock_calls) == 1
async def test_form_connection_timeout(hass):
"""Test we handle connection timeout."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
with patch(
"pyotgw.pyotgw.connect", side_effect=(asyncio.TimeoutError)
) as mock_connect:
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_NAME: "Test Entry 1", CONF_DEVICE: "socket://192.0.2.254:1234"},
)
assert result2["type"] == "form"
assert result2["errors"] == {"base": "timeout"}
assert len(mock_connect.mock_calls) == 1
async def test_form_connection_error(hass):
"""Test we handle serial connection error."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
with patch("pyotgw.pyotgw.connect", side_effect=(SerialException)) as mock_connect:
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"], {CONF_NAME: "Test Entry 1", CONF_DEVICE: "/dev/ttyUSB0"}
)
assert result2["type"] == "form"
assert result2["errors"] == {"base": "serial_error"}
assert len(mock_connect.mock_calls) == 1