From 6ae908b883cb76f93a9d6303e8bab3d7a6619689 Mon Sep 17 00:00:00 2001 From: mvn23 Date: Sat, 5 Oct 2019 02:38:26 +0200 Subject: [PATCH] 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 --- .coveragerc | 5 +- .../opentherm_gw/.translations/en.json | 23 +++ .../opentherm_gw/.translations/nl.json | 23 +++ .../components/opentherm_gw/__init__.py | 70 +++++--- .../components/opentherm_gw/binary_sensor.py | 14 +- .../components/opentherm_gw/climate.py | 19 +- .../components/opentherm_gw/config_flow.py | 91 ++++++++++ .../components/opentherm_gw/const.py | 2 + .../components/opentherm_gw/manifest.json | 7 +- .../components/opentherm_gw/sensor.py | 15 +- .../components/opentherm_gw/strings.json | 23 +++ homeassistant/generated/config_flows.py | 1 + requirements_all.txt | 2 +- requirements_test_all.txt | 3 + script/gen_requirements_all.py | 1 + tests/components/opentherm_gw/__init__.py | 1 + .../opentherm_gw/test_config_flow.py | 163 ++++++++++++++++++ 17 files changed, 413 insertions(+), 50 deletions(-) create mode 100644 homeassistant/components/opentherm_gw/.translations/en.json create mode 100644 homeassistant/components/opentherm_gw/.translations/nl.json create mode 100644 homeassistant/components/opentherm_gw/config_flow.py create mode 100644 homeassistant/components/opentherm_gw/strings.json create mode 100644 tests/components/opentherm_gw/__init__.py create mode 100644 tests/components/opentherm_gw/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index 25ee023ac84..a6f65430074 100644 --- a/.coveragerc +++ b/.coveragerc @@ -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 diff --git a/homeassistant/components/opentherm_gw/.translations/en.json b/homeassistant/components/opentherm_gw/.translations/en.json new file mode 100644 index 00000000000..65d7d9e92bb --- /dev/null +++ b/homeassistant/components/opentherm_gw/.translations/en.json @@ -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" + } +} \ No newline at end of file diff --git a/homeassistant/components/opentherm_gw/.translations/nl.json b/homeassistant/components/opentherm_gw/.translations/nl.json new file mode 100644 index 00000000000..ef3daafe4fe --- /dev/null +++ b/homeassistant/components/opentherm_gw/.translations/nl.json @@ -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" + } +} \ No newline at end of file diff --git a/homeassistant/components/opentherm_gw/__init__.py b/homeassistant/components/opentherm_gw/__init__.py index a32c375ac65..ba6de4c0bea 100644 --- a/homeassistant/components/opentherm_gw/__init__.py +++ b/homeassistant/components/opentherm_gw/__init__.py @@ -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.""" - 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 - hass.async_create_task( - async_load_platform(hass, COMP_CLIMATE, DOMAIN, gw_id, 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) + if not hass.config_entries.async_entries(DOMAIN) and DOMAIN in config: + conf = config[DOMAIN] + for device_id, device_config in conf.items(): + device_config[CONF_ID] = device_id + + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=device_config + ) + ) 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.""" diff --git a/homeassistant/components/opentherm_gw/binary_sensor.py b/homeassistant/components/opentherm_gw/binary_sensor.py index 614829265e2..36867feda61 100644 --- a/homeassistant/components/opentherm_gw/binary_sensor.py +++ b/homeassistant/components/opentherm_gw/binary_sensor.py @@ -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) diff --git a/homeassistant/components/opentherm_gw/climate.py b/homeassistant/components/opentherm_gw/climate.py index fab028560bb..19763121e89 100644 --- a/homeassistant/components/opentherm_gw/climate.py +++ b/homeassistant/components/opentherm_gw/climate.py @@ -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 ) diff --git a/homeassistant/components/opentherm_gw/config_flow.py b/homeassistant/components/opentherm_gw/config_flow.py new file mode 100644 index 00000000000..e1b68f1ae49 --- /dev/null +++ b/homeassistant/components/opentherm_gw/config_flow.py @@ -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} + ) diff --git a/homeassistant/components/opentherm_gw/const.py b/homeassistant/components/opentherm_gw/const.py index 60042b92867..bd9b372de33 100644 --- a/homeassistant/components/opentherm_gw/const.py +++ b/homeassistant/components/opentherm_gw/const.py @@ -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" diff --git a/homeassistant/components/opentherm_gw/manifest.json b/homeassistant/components/opentherm_gw/manifest.json index 9c7f165c6df..a632096cd75 100644 --- a/homeassistant/components/opentherm_gw/manifest.json +++ b/homeassistant/components/opentherm_gw/manifest.json @@ -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 +} \ No newline at end of file diff --git a/homeassistant/components/opentherm_gw/sensor.py b/homeassistant/components/opentherm_gw/sensor.py index 1449caf5def..c77a73cd180 100644 --- a/homeassistant/components/opentherm_gw/sensor.py +++ b/homeassistant/components/opentherm_gw/sensor.py @@ -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) diff --git a/homeassistant/components/opentherm_gw/strings.json b/homeassistant/components/opentherm_gw/strings.json new file mode 100644 index 00000000000..a62a4625049 --- /dev/null +++ b/homeassistant/components/opentherm_gw/strings.json @@ -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" + } + } +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 7557fc32b40..1eb08709741 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -45,6 +45,7 @@ FLOWS = [ "mqtt", "nest", "notion", + "opentherm_gw", "openuv", "owntracks", "plaato", diff --git a/requirements_all.txt b/requirements_all.txt index 9ce8b070b1e..9f0ee493fa1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -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 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1e948ded8a7..e8114352b04 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -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 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index a6f2584e256..70c81c66025 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -138,6 +138,7 @@ TEST_REQUIREMENTS = ( "pynws", "pynx584", "pyopenuv", + "pyotgw", "pyotp", "pyps4-homeassistant", "pyqwikswitch", diff --git a/tests/components/opentherm_gw/__init__.py b/tests/components/opentherm_gw/__init__.py new file mode 100644 index 00000000000..2dfe9267651 --- /dev/null +++ b/tests/components/opentherm_gw/__init__.py @@ -0,0 +1 @@ +"""Tests for the Opentherm Gateway integration.""" diff --git a/tests/components/opentherm_gw/test_config_flow.py b/tests/components/opentherm_gw/test_config_flow.py new file mode 100644 index 00000000000..da80e2f9fbb --- /dev/null +++ b/tests/components/opentherm_gw/test_config_flow.py @@ -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