From 5a295ad42bebc0e87ebbd53a16e03f37c69f95df Mon Sep 17 00:00:00 2001 From: Fredrik Erlandsson Date: Sun, 16 Dec 2018 16:19:18 +0100 Subject: [PATCH] Add config flow for Daikin (#19182) * config flow for daikin * tox test * return fixes * tox test fixes * tox formatting --- .coveragerc | 3 +- homeassistant/components/climate/daikin.py | 28 ++-- .../components/daikin/.translations/en.json | 19 +++ homeassistant/components/daikin/__init__.py | 135 +++++++++--------- .../components/daikin/config_flow.py | 74 ++++++++++ homeassistant/components/daikin/const.py | 25 ++++ homeassistant/components/daikin/strings.json | 19 +++ homeassistant/components/discovery.py | 2 +- homeassistant/components/sensor/daikin.py | 55 +++---- homeassistant/config_entries.py | 1 + tests/components/daikin/__init__.py | 1 + tests/components/daikin/test_config_flow.py | 93 ++++++++++++ 12 files changed, 331 insertions(+), 124 deletions(-) create mode 100644 homeassistant/components/daikin/.translations/en.json create mode 100644 homeassistant/components/daikin/config_flow.py create mode 100644 homeassistant/components/daikin/const.py create mode 100644 homeassistant/components/daikin/strings.json create mode 100644 tests/components/daikin/__init__.py create mode 100644 tests/components/daikin/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index 55b1ec18792..c7861d7aa34 100644 --- a/.coveragerc +++ b/.coveragerc @@ -73,7 +73,8 @@ omit = homeassistant/components/comfoconnect.py homeassistant/components/*/comfoconnect.py - homeassistant/components/daikin.py + homeassistant/components/daikin/__init__.py + homeassistant/components/daikin/const.py homeassistant/components/*/daikin.py homeassistant/components/digital_ocean.py diff --git a/homeassistant/components/climate/daikin.py b/homeassistant/components/climate/daikin.py index 38c78bfdb3d..d1a7e076ae1 100644 --- a/homeassistant/components/climate/daikin.py +++ b/homeassistant/components/climate/daikin.py @@ -15,9 +15,9 @@ from homeassistant.components.climate import ( STATE_FAN_ONLY, STATE_HEAT, STATE_OFF, SUPPORT_FAN_MODE, SUPPORT_OPERATION_MODE, SUPPORT_SWING_MODE, SUPPORT_TARGET_TEMPERATURE, ClimateDevice) -from homeassistant.components.daikin import ( - ATTR_INSIDE_TEMPERATURE, ATTR_OUTSIDE_TEMPERATURE, ATTR_TARGET_TEMPERATURE, - daikin_api_setup) +from homeassistant.components.daikin import DOMAIN as DAIKIN_DOMAIN +from homeassistant.components.daikin.const import ( + ATTR_INSIDE_TEMPERATURE, ATTR_OUTSIDE_TEMPERATURE, ATTR_TARGET_TEMPERATURE) from homeassistant.const import ( ATTR_TEMPERATURE, CONF_HOST, CONF_NAME, TEMP_CELSIUS) import homeassistant.helpers.config_validation as cv @@ -60,18 +60,18 @@ HA_ATTR_TO_DAIKIN = { def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the Daikin HVAC platform.""" - if discovery_info is not None: - host = discovery_info.get('ip') - name = None - _LOGGER.debug("Discovered a Daikin AC on %s", host) - else: - host = config.get(CONF_HOST) - name = config.get(CONF_NAME) - _LOGGER.debug("Added Daikin AC on %s", host) + """Old way of setting up the Daikin HVAC platform. - api = daikin_api_setup(hass, host, name) - add_entities([DaikinClimate(api)], True) + Can only be called when a user accidentally mentions the platform in their + config. But even in that case it would have been ignored. + """ + pass + + +async def async_setup_entry(hass, entry, async_add_entities): + """Set up Daikin climate based on config_entry.""" + daikin_api = hass.data[DAIKIN_DOMAIN].get(entry.entry_id) + async_add_entities([DaikinClimate(daikin_api)]) class DaikinClimate(ClimateDevice): diff --git a/homeassistant/components/daikin/.translations/en.json b/homeassistant/components/daikin/.translations/en.json new file mode 100644 index 00000000000..4badc8b72d7 --- /dev/null +++ b/homeassistant/components/daikin/.translations/en.json @@ -0,0 +1,19 @@ +{ + "config": { + "title": "Daikin AC", + "step": { + "user": { + "title": "Configure Daikin AC", + "description": "Enter IP address of your Daikin AC.", + "data": { + "host": "Host" + } + } + }, + "abort": { + "device_timeout": "Timeout connecting to the device.", + "device_fail": "Unexpected error creating device.", + "already_configured": "Device is already configured" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/daikin/__init__.py b/homeassistant/components/daikin/__init__.py index e2e4572939d..73dff74a1dc 100644 --- a/homeassistant/components/daikin/__init__.py +++ b/homeassistant/components/daikin/__init__.py @@ -4,112 +4,105 @@ Platform for the Daikin AC. For more details about this component, please refer to the documentation https://home-assistant.io/components/daikin/ """ -import logging +import asyncio from datetime import timedelta +import logging from socket import timeout +import async_timeout import voluptuous as vol +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOSTS import homeassistant.helpers.config_validation as cv -from homeassistant.components.discovery import SERVICE_DAIKIN -from homeassistant.const import ( - CONF_HOSTS, CONF_ICON, CONF_MONITORED_CONDITIONS, CONF_NAME, CONF_TYPE -) -from homeassistant.helpers import discovery -from homeassistant.helpers.discovery import load_platform +from homeassistant.helpers.typing import HomeAssistantType from homeassistant.util import Throttle +from . import config_flow # noqa pylint_disable=unused-import +from .const import KEY_HOST + REQUIREMENTS = ['pydaikin==0.8'] _LOGGER = logging.getLogger(__name__) DOMAIN = 'daikin' -ATTR_TARGET_TEMPERATURE = 'target_temperature' -ATTR_INSIDE_TEMPERATURE = 'inside_temperature' -ATTR_OUTSIDE_TEMPERATURE = 'outside_temperature' MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60) COMPONENT_TYPES = ['climate', 'sensor'] -SENSOR_TYPE_TEMPERATURE = 'temperature' - -SENSOR_TYPES = { - ATTR_INSIDE_TEMPERATURE: { - CONF_NAME: 'Inside Temperature', - CONF_ICON: 'mdi:thermometer', - CONF_TYPE: SENSOR_TYPE_TEMPERATURE - }, - ATTR_OUTSIDE_TEMPERATURE: { - CONF_NAME: 'Outside Temperature', - CONF_ICON: 'mdi:thermometer', - CONF_TYPE: SENSOR_TYPE_TEMPERATURE - } - -} - CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ vol.Optional( CONF_HOSTS, default=[] ): vol.All(cv.ensure_list, [cv.string]), - vol.Optional( - CONF_MONITORED_CONDITIONS, - default=list(SENSOR_TYPES.keys()) - ): vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]) }) }, extra=vol.ALLOW_EXTRA) -def setup(hass, config): +async def async_setup(hass, config): """Establish connection with Daikin.""" - def discovery_dispatch(service, discovery_info): - """Dispatcher for Daikin discovery events.""" - host = discovery_info.get('ip') - - if daikin_api_setup(hass, host) is None: - return - - for component in COMPONENT_TYPES: - load_platform(hass, component, DOMAIN, discovery_info, - config) - - discovery.listen(hass, SERVICE_DAIKIN, discovery_dispatch) - - for host in config.get(DOMAIN, {}).get(CONF_HOSTS, []): - if daikin_api_setup(hass, host) is None: - continue - - discovery_info = { - 'ip': host, - CONF_MONITORED_CONDITIONS: - config[DOMAIN][CONF_MONITORED_CONDITIONS] - } - load_platform(hass, 'sensor', DOMAIN, discovery_info, config) + if DOMAIN not in config: + return True + hosts = config[DOMAIN].get(CONF_HOSTS) + if not hosts: + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={'source': config.SOURCE_IMPORT})) + for host in hosts: + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={'source': config.SOURCE_IMPORT}, + data={ + KEY_HOST: host, + })) return True -def daikin_api_setup(hass, host, name=None): +async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry): + """Establish connection with Daikin.""" + conf = entry.data + daikin_api = await daikin_api_setup(hass, conf[KEY_HOST]) + if not daikin_api: + return False + hass.data.setdefault(DOMAIN, {}).update({entry.entry_id: daikin_api}) + await asyncio.wait([ + hass.config_entries.async_forward_entry_setup(entry, component) + for component in COMPONENT_TYPES + ]) + return True + + +async def async_unload_entry(hass, config_entry): + """Unload a config entry.""" + await asyncio.wait([ + hass.config_entries.async_forward_entry_unload(config_entry, component) + for component in COMPONENT_TYPES + ]) + hass.data[DOMAIN].pop(config_entry.entry_id) + if not hass.data[DOMAIN]: + hass.data.pop(DOMAIN) + return True + + +async def daikin_api_setup(hass, host): """Create a Daikin instance only once.""" - if DOMAIN not in hass.data: - hass.data[DOMAIN] = {} + from pydaikin.appliance import Appliance + try: + with async_timeout.timeout(10): + device = await hass.async_add_executor_job(Appliance, host) + except asyncio.TimeoutError: + _LOGGER.error("Connection to Daikin could not be established") + return None + except Exception: # pylint: disable=broad-except + _LOGGER.error("Unexpected error creating device") + return None - api = hass.data[DOMAIN].get(host) - if api is None: - from pydaikin import appliance - - try: - device = appliance.Appliance(host) - except timeout: - _LOGGER.error("Connection to Daikin could not be established") - return False - - if name is None: - name = device.values['name'] - - api = DaikinApi(device, name) + name = device.values['name'] + api = DaikinApi(device, name) return api diff --git a/homeassistant/components/daikin/config_flow.py b/homeassistant/components/daikin/config_flow.py new file mode 100644 index 00000000000..b46e800c3d8 --- /dev/null +++ b/homeassistant/components/daikin/config_flow.py @@ -0,0 +1,74 @@ +"""Config flow for the Daikin platform.""" +import asyncio +import logging + +import async_timeout +import voluptuous as vol + +from homeassistant import config_entries + +from .const import KEY_HOST, KEY_IP, KEY_MAC + +_LOGGER = logging.getLogger(__name__) + + +@config_entries.HANDLERS.register('daikin') +class FlowHandler(config_entries.ConfigFlow): + """Handle a config flow.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL + + async def _create_entry(self, host, mac): + """Register new entry.""" + # Check if mac already is registered + for entry in self._async_current_entries(): + if entry.data[KEY_MAC] == mac: + return self.async_abort(reason='already_configured') + + return self.async_create_entry( + title=host, + data={ + KEY_HOST: host, + KEY_MAC: mac + }) + + async def _create_device(self, host): + """Create device.""" + from pydaikin.appliance import Appliance + try: + with async_timeout.timeout(10): + device = await self.hass.async_add_executor_job( + Appliance, host) + except asyncio.TimeoutError: + return self.async_abort(reason='device_timeout') + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected error creating device") + return self.async_abort(reason='device_fail') + + mac = device.values.get('mac') + return await self._create_entry(host, mac) + + async def async_step_user(self, user_input=None): + """User initiated config flow.""" + if user_input is None: + return self.async_show_form( + step_id='user', + data_schema=vol.Schema({ + vol.Required(KEY_HOST): str + }) + ) + return await self._create_device(user_input[KEY_HOST]) + + async def async_step_import(self, user_input): + """Import a config entry.""" + host = user_input.get(KEY_HOST) + if not host: + return await self.async_step_user() + return await self._create_device(host) + + async def async_step_discovery(self, user_input): + """Initialize step from discovery.""" + _LOGGER.info("Discovered device: %s", user_input) + return await self._create_entry(user_input[KEY_IP], + user_input[KEY_MAC]) diff --git a/homeassistant/components/daikin/const.py b/homeassistant/components/daikin/const.py new file mode 100644 index 00000000000..cd2742b5b5e --- /dev/null +++ b/homeassistant/components/daikin/const.py @@ -0,0 +1,25 @@ +"""Constants for Daikin.""" +from homeassistant.const import CONF_ICON, CONF_NAME, CONF_TYPE + +ATTR_TARGET_TEMPERATURE = 'target_temperature' +ATTR_INSIDE_TEMPERATURE = 'inside_temperature' +ATTR_OUTSIDE_TEMPERATURE = 'outside_temperature' + +SENSOR_TYPE_TEMPERATURE = 'temperature' + +SENSOR_TYPES = { + ATTR_INSIDE_TEMPERATURE: { + CONF_NAME: 'Inside Temperature', + CONF_ICON: 'mdi:thermometer', + CONF_TYPE: SENSOR_TYPE_TEMPERATURE + }, + ATTR_OUTSIDE_TEMPERATURE: { + CONF_NAME: 'Outside Temperature', + CONF_ICON: 'mdi:thermometer', + CONF_TYPE: SENSOR_TYPE_TEMPERATURE + } +} + +KEY_HOST = 'host' +KEY_MAC = 'mac' +KEY_IP = 'ip' diff --git a/homeassistant/components/daikin/strings.json b/homeassistant/components/daikin/strings.json new file mode 100644 index 00000000000..4badc8b72d7 --- /dev/null +++ b/homeassistant/components/daikin/strings.json @@ -0,0 +1,19 @@ +{ + "config": { + "title": "Daikin AC", + "step": { + "user": { + "title": "Configure Daikin AC", + "description": "Enter IP address of your Daikin AC.", + "data": { + "host": "Host" + } + } + }, + "abort": { + "device_timeout": "Timeout connecting to the device.", + "device_fail": "Unexpected error creating device.", + "already_configured": "Device is already configured" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/discovery.py b/homeassistant/components/discovery.py index 00805bd76b8..f85ccbcce00 100644 --- a/homeassistant/components/discovery.py +++ b/homeassistant/components/discovery.py @@ -46,6 +46,7 @@ SERVICE_HOMEKIT = 'homekit' SERVICE_OCTOPRINT = 'octoprint' CONFIG_ENTRY_HANDLERS = { + SERVICE_DAIKIN: 'daikin', SERVICE_DECONZ: 'deconz', 'google_cast': 'cast', SERVICE_HUE: 'hue', @@ -63,7 +64,6 @@ SERVICE_HANDLERS = { SERVICE_APPLE_TV: ('apple_tv', None), SERVICE_WINK: ('wink', None), SERVICE_XIAOMI_GW: ('xiaomi_aqara', None), - SERVICE_DAIKIN: ('daikin', None), SERVICE_SABNZBD: ('sabnzbd', None), SERVICE_SAMSUNG_PRINTER: ('sensor', 'syncthru'), SERVICE_KONNECTED: ('konnected', None), diff --git a/homeassistant/components/sensor/daikin.py b/homeassistant/components/sensor/daikin.py index eae0c6e9614..55b04dc1a68 100644 --- a/homeassistant/components/sensor/daikin.py +++ b/homeassistant/components/sensor/daikin.py @@ -6,52 +6,33 @@ https://home-assistant.io/components/sensor.daikin/ """ import logging -import voluptuous as vol - -import homeassistant.helpers.config_validation as cv -from homeassistant.components.daikin import ( - SENSOR_TYPES, SENSOR_TYPE_TEMPERATURE, - ATTR_INSIDE_TEMPERATURE, ATTR_OUTSIDE_TEMPERATURE, - daikin_api_setup -) -from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import ( - CONF_HOST, CONF_ICON, CONF_NAME, CONF_MONITORED_CONDITIONS, CONF_TYPE -) +from homeassistant.components.daikin import DOMAIN as DAIKIN_DOMAIN +from homeassistant.components.daikin.const import ( + ATTR_INSIDE_TEMPERATURE, ATTR_OUTSIDE_TEMPERATURE, SENSOR_TYPE_TEMPERATURE, + SENSOR_TYPES) +from homeassistant.const import CONF_ICON, CONF_NAME, CONF_TYPE from homeassistant.helpers.entity import Entity from homeassistant.util.unit_system import UnitSystem -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_HOST): cv.string, - vol.Optional(CONF_NAME): cv.string, - vol.Optional(CONF_MONITORED_CONDITIONS, default=list(SENSOR_TYPES)): - vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]), -}) - _LOGGER = logging.getLogger(__name__) def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the Daikin sensors.""" - if discovery_info is not None: - host = discovery_info.get('ip') - name = None - monitored_conditions = discovery_info.get( - CONF_MONITORED_CONDITIONS, list(SENSOR_TYPES.keys()) - ) - else: - host = config[CONF_HOST] - name = config.get(CONF_NAME) - monitored_conditions = config[CONF_MONITORED_CONDITIONS] - _LOGGER.info("Added Daikin AC sensor on %s", host) + """Old way of setting up the Daikin sensors. - api = daikin_api_setup(hass, host, name) - units = hass.config.units - sensors = [] - for monitored_state in monitored_conditions: - sensors.append(DaikinClimateSensor(api, monitored_state, units, name)) + Can only be called when a user accidentally mentions the platform in their + config. But even in that case it would have been ignored. + """ + pass - add_entities(sensors, True) + +async def async_setup_entry(hass, entry, async_add_entities): + """Set up Daikin climate based on config_entry.""" + daikin_api = hass.data[DAIKIN_DOMAIN].get(entry.entry_id) + async_add_entities([ + DaikinClimateSensor(daikin_api, sensor, hass.config.units) + for sensor in SENSOR_TYPES + ]) class DaikinClimateSensor(Entity): diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 39270b36108..0bfb781b305 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -136,6 +136,7 @@ HANDLERS = Registry() # Components that have config flows. In future we will auto-generate this list. FLOWS = [ 'cast', + 'daikin', 'deconz', 'dialogflow', 'hangouts', diff --git a/tests/components/daikin/__init__.py b/tests/components/daikin/__init__.py new file mode 100644 index 00000000000..f56a38dd38c --- /dev/null +++ b/tests/components/daikin/__init__.py @@ -0,0 +1 @@ +"""Tests for the Daikin component.""" diff --git a/tests/components/daikin/test_config_flow.py b/tests/components/daikin/test_config_flow.py new file mode 100644 index 00000000000..f4ad676b9aa --- /dev/null +++ b/tests/components/daikin/test_config_flow.py @@ -0,0 +1,93 @@ +# pylint: disable=W0621 +"""Tests for the Daikin config flow.""" +import asyncio + +import pytest + +from homeassistant import data_entry_flow +from homeassistant.components.daikin import config_flow +from homeassistant.components.daikin.const import KEY_HOST, KEY_IP, KEY_MAC + +from tests.common import MockConfigEntry, MockDependency + +MAC = 'AABBCCDDEEFF' +HOST = '127.0.0.1' + + +def init_config_flow(hass): + """Init a configuration flow.""" + flow = config_flow.FlowHandler() + flow.hass = hass + return flow + + +@pytest.fixture +def mock_daikin(): + """Mock tellduslive.""" + with MockDependency('pydaikin.appliance') as mock_daikin_: + mock_daikin_.Appliance().values.get.return_value = 'AABBCCDDEEFF' + yield mock_daikin_ + + +async def test_user(hass, mock_daikin): + """Test user config.""" + flow = init_config_flow(hass) + + result = await flow.async_step_user() + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM + assert result['step_id'] == 'user' + + result = await flow.async_step_user({KEY_HOST: HOST}) + assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result['title'] == HOST + assert result['data'][KEY_HOST] == HOST + assert result['data'][KEY_MAC] == MAC + + +async def test_abort_if_already_setup(hass, mock_daikin): + """Test we abort if Daikin is already setup.""" + flow = init_config_flow(hass) + MockConfigEntry(domain='daikin', data={KEY_MAC: MAC}).add_to_hass(hass) + + result = await flow.async_step_user({KEY_HOST: HOST}) + assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT + assert result['reason'] == 'already_configured' + + +async def test_import(hass, mock_daikin): + """Test import step.""" + flow = init_config_flow(hass) + + result = await flow.async_step_import({}) + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM + assert result['step_id'] == 'user' + + result = await flow.async_step_import({KEY_HOST: HOST}) + assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result['title'] == HOST + assert result['data'][KEY_HOST] == HOST + assert result['data'][KEY_MAC] == MAC + + +async def test_discovery(hass, mock_daikin): + """Test discovery step.""" + flow = init_config_flow(hass) + + result = await flow.async_step_discovery({KEY_IP: HOST, KEY_MAC: MAC}) + assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result['title'] == HOST + assert result['data'][KEY_HOST] == HOST + assert result['data'][KEY_MAC] == MAC + + +@pytest.mark.parametrize('s_effect,reason', + [(asyncio.TimeoutError, 'device_timeout'), + (Exception, 'device_fail')]) +async def test_device_abort(hass, mock_daikin, s_effect, reason): + """Test device abort.""" + flow = init_config_flow(hass) + mock_daikin.Appliance.side_effect = s_effect + + result = await flow.async_step_user({KEY_HOST: HOST}) + assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT + assert result['reason'] == reason