diff --git a/.coveragerc b/.coveragerc index ff490303e47..c9038aafcb9 100644 --- a/.coveragerc +++ b/.coveragerc @@ -568,6 +568,12 @@ omit = homeassistant/components/mychevy/* homeassistant/components/mycroft/* homeassistant/components/mycroft/notify.py + homeassistant/components/mysensors/__init__.py + homeassistant/components/mysensors/const.py + homeassistant/components/mysensors/device.py + homeassistant/components/mysensors/handler.py + homeassistant/components/mysensors/helpers.py + homeassistant/components/mysensors/notify.py homeassistant/components/mysensors/binary_sensor.py homeassistant/components/mysensors/light.py homeassistant/components/mysensors/climate.py diff --git a/homeassistant/components/mysensors/__init__.py b/homeassistant/components/mysensors/__init__.py index 583508591fa..f1f139da857 100644 --- a/homeassistant/components/mysensors/__init__.py +++ b/homeassistant/components/mysensors/__init__.py @@ -1,4 +1,5 @@ """Connect to a MySensors gateway via pymysensors API.""" +import asyncio import logging from typing import Callable, Dict, List, Optional, Tuple, Type, Union @@ -199,8 +200,16 @@ async def async_unload_entry(hass: HomeAssistantType, entry: ConfigEntry) -> boo _LOGGER.error("Can't unload configentry %s, no gateway found", entry.entry_id) return False - for platform in SUPPORTED_PLATFORMS_WITH_ENTRY_SUPPORT: - await hass.config_entries.async_forward_entry_unload(entry, platform) + unload_ok = all( + await asyncio.gather( + *[ + hass.config_entries.async_forward_entry_unload(entry, platform) + for platform in SUPPORTED_PLATFORMS_WITH_ENTRY_SUPPORT + ] + ) + ) + if not unload_ok: + return False key = MYSENSORS_ON_UNLOAD.format(entry.entry_id) if key in hass.data[DOMAIN]: diff --git a/homeassistant/components/mysensors/manifest.json b/homeassistant/components/mysensors/manifest.json index 8856fb5bc83..be201b97101 100644 --- a/homeassistant/components/mysensors/manifest.json +++ b/homeassistant/components/mysensors/manifest.json @@ -9,7 +9,8 @@ "mqtt" ], "codeowners": [ - "@MartinHjelmare", "@functionpointer" + "@MartinHjelmare", + "@functionpointer" ], "config_flow": true -} +} \ No newline at end of file diff --git a/homeassistant/components/mysensors/strings.json b/homeassistant/components/mysensors/strings.json index 54113b502a1..402bc5848fe 100644 --- a/homeassistant/components/mysensors/strings.json +++ b/homeassistant/components/mysensors/strings.json @@ -2,13 +2,21 @@ "title": "MySensors", "config": { "step": { - "confirm": { - "description": "[%key:common::config_flow::description::confirm_setup%]" + "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": { - "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]", - "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } } } \ No newline at end of file diff --git a/homeassistant/components/mysensors/translations/en.json b/homeassistant/components/mysensors/translations/en.json index 0f1d9532459..873ed2ed721 100644 --- a/homeassistant/components/mysensors/translations/en.json +++ b/homeassistant/components/mysensors/translations/en.json @@ -1,58 +1,22 @@ { - "config": { - "abort": { - "single_instance_allowed": "Already configured. Only a single configuration possible." - }, - "error": { - "cannot_connect": "Failed to connect", - "invalid_subscribe_topic": "Invalid subscribe topic", - "invalid_publish_topic": "Invalid publish topic", - "invalid_port": "Invalid port number", - "invalid_persistence_file": "Invalid persistence file", - "invalid_ip": "Invalid IP address", - "invalid_serial": "Invalid serial port", - "invalid_device": "Invalid device", - "invalid_version": "Invalid MySensors version", - "not_a_number": "Please enter a number", - "port_out_of_range": "Port number must be at least 1 and at most 65535" - }, - "step": { - "user": { - "data": { - "optimistic": "optimistic", - "persistence": "persistence", - "gateway_type_mqtt": "MQTT" + "config": { + "abort": { + "already_configured": "Device is already configured" }, - "description": "Choose connection method to the gateway" - }, - "gw_tcp": { - "description": "Ethernet gateway setup", - "data": { - "device": "IP address of the gateway", - "tcp_port": "port", - "version": "MySensors version", - "persistence_file": "persistence file (leave empty to auto-generate)" + "error": { + "cannot_connect": "Failed to connect", + "invalid_auth": "Invalid authentication", + "unknown": "Unexpected error" + }, + "step": { + "user": { + "data": { + "host": "Host", + "password": "Password", + "username": "Username" + } + } } - }, - "gw_serial": { - "description": "Serial gateway setup", - "data": { - "device": "Serial port", - "baud_rate": "baud rate", - "version": "MySensors version", - "persistence_file": "persistence file (leave empty to auto-generate)" - } - }, - "gw_mqtt": { - "description": "MQTT gateway setup", - "data": { - "retain": "mqtt retain", - "topic_in_prefix": "prefix for input topics (topic_in_prefix)", - "topic_out_prefix": "prefix for output topics (topic_out_prefix)", - "version": "MySensors version", - "persistence_file": "persistence file (leave empty to auto-generate)" - } - } - } - } + }, + "title": "MySensors" } \ No newline at end of file diff --git a/tests/components/mysensors/test_config_flow.py b/tests/components/mysensors/test_config_flow.py new file mode 100644 index 00000000000..360e560a07b --- /dev/null +++ b/tests/components/mysensors/test_config_flow.py @@ -0,0 +1,559 @@ +"""Test the MySensors config flow.""" +from typing import Dict +from unittest.mock import patch + +import voluptuous as vol + +from homeassistant import config_entries, setup +from homeassistant.components.mysensors import async_setup +from homeassistant.components.mysensors.config_flow import MySensorsConfigFlowHandler +from homeassistant.components.mysensors.const import ( + CONF_BAUD_RATE, + CONF_DEVICE, + CONF_GATEWAY_TYPE, + CONF_GATEWAY_TYPE_MQTT, + CONF_GATEWAY_TYPE_SERIAL, + CONF_GATEWAY_TYPE_TCP, + CONF_GATEWAY_TYPE_TYPE, + CONF_GATEWAYS, + CONF_PERSISTENCE, + CONF_PERSISTENCE_FILE, + CONF_RETAIN, + CONF_TCP_PORT, + CONF_TOPIC_IN_PREFIX, + CONF_TOPIC_OUT_PREFIX, + CONF_VERSION, + DOMAIN, +) +from homeassistant.components.mysensors.gateway import is_serial_port +from homeassistant.helpers.typing import ConfigType, HomeAssistantType + + +async def get_form( + hass: HomeAssistantType, gatway_type: CONF_GATEWAY_TYPE_TYPE, expected_step_id: str +): + """Get a form for the given gateway type.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + stepuser = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert stepuser["type"] == "form" + assert not stepuser["errors"] + + actualstep = await hass.config_entries.flow.async_configure( + stepuser["flow_id"], + {CONF_GATEWAY_TYPE: gatway_type}, + ) + await hass.async_block_till_done() + assert actualstep["type"] == "form" + assert actualstep["step_id"] == expected_step_id + + return actualstep + + +async def test_config_mqtt(hass: HomeAssistantType): + """Test configuring a mqtt gateway.""" + step = await get_form(hass, CONF_GATEWAY_TYPE_MQTT, "gw_mqtt") + flowid = step["flow_id"] + + with patch( + "homeassistant.components.mysensors.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.mysensors.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + flowid, + { + CONF_RETAIN: True, + CONF_TOPIC_IN_PREFIX: "bla", + CONF_TOPIC_OUT_PREFIX: "blub", + CONF_VERSION: "2.4", + }, + ) + await hass.async_block_till_done() + + if "errors" in result2: + assert not result2["errors"] + assert result2["type"] == "create_entry" + assert result2["title"] == "mqtt" + assert result2["data"] == { + CONF_DEVICE: "mqtt", + CONF_RETAIN: True, + CONF_TOPIC_IN_PREFIX: "bla", + CONF_TOPIC_OUT_PREFIX: "blub", + CONF_VERSION: "2.4", + } + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +def test_is_serial_port_windows(hass: HomeAssistantType): + """Test windows serial ports.""" + tests = { + "COM5": True, + "asdf": False, + "COM17": True, + "COM": False, + "/dev/ttyACM0": False, + } + + def testport(port, result): + try: + is_serial_port(port) + except vol.Invalid: + success = False + else: + success = True + assert success == result + + with patch("sys.platform", "win32"): + for test, result in tests.items(): + testport(test, result) + + +async def test_config_serial(hass: HomeAssistantType): + """Test configuring a gateway via serial.""" + step = await get_form(hass, CONF_GATEWAY_TYPE_SERIAL, "gw_serial") + flowid = step["flow_id"] + + with patch( # mock is_serial_port because otherwise the test will be platform dependent (/dev/ttyACMx vs COMx) + "homeassistant.components.mysensors.config_flow.is_serial_port", + return_value=True, + ), patch( + "homeassistant.components.mysensors.config_flow.try_connect", return_value=True + ), patch( + "homeassistant.components.mysensors.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.mysensors.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + flowid, + { + CONF_BAUD_RATE: 115200, + CONF_DEVICE: "/dev/ttyACM0", + CONF_VERSION: "2.4", + }, + ) + await hass.async_block_till_done() + + if "errors" in result2: + assert not result2["errors"] + assert result2["type"] == "create_entry" + assert result2["title"] == "/dev/ttyACM0" + assert result2["data"] == { + CONF_DEVICE: "/dev/ttyACM0", + CONF_BAUD_RATE: 115200, + CONF_VERSION: "2.4", + } + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_config_tcp(hass: HomeAssistantType): + """Test configuring a gateway via tcp.""" + step = await get_form(hass, CONF_GATEWAY_TYPE_TCP, "gw_tcp") + flowid = step["flow_id"] + + with patch( + "homeassistant.components.mysensors.config_flow.try_connect", return_value=True + ), patch( + "homeassistant.components.mysensors.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.mysensors.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + flowid, + { + CONF_TCP_PORT: 5003, + CONF_DEVICE: "127.0.0.1", + CONF_VERSION: "2.4", + }, + ) + await hass.async_block_till_done() + + if "errors" in result2: + assert not result2["errors"] + assert result2["type"] == "create_entry" + assert result2["title"] == "127.0.0.1" + assert result2["data"] == { + CONF_DEVICE: "127.0.0.1", + CONF_TCP_PORT: 5003, + CONF_VERSION: "2.4", + } + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_fail_to_connect(hass: HomeAssistantType): + """Test configuring a gateway via tcp.""" + step = await get_form(hass, CONF_GATEWAY_TYPE_TCP, "gw_tcp") + flowid = step["flow_id"] + + with patch( + "homeassistant.components.mysensors.config_flow.try_connect", return_value=False + ), patch( + "homeassistant.components.mysensors.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.mysensors.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + flowid, + { + CONF_TCP_PORT: 5003, + CONF_DEVICE: "127.0.0.1", + CONF_VERSION: "2.4", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == "form" + assert "errors" in result2 + assert "base" in result2["errors"] + assert result2["errors"]["base"] == "cannot_connect" + assert len(mock_setup.mock_calls) == 0 + assert len(mock_setup_entry.mock_calls) == 0 + + +async def config_invalid( + hass: HomeAssistantType, + gatway_type: CONF_GATEWAY_TYPE_TYPE, + expected_step_id: str, + user_input: Dict[str, any], + err_field, + err_string, +): + """Perform a test that is expected to generate an error.""" + step = await get_form(hass, gatway_type, expected_step_id) + flowid = step["flow_id"] + + with patch( + "homeassistant.components.mysensors.config_flow.try_connect", return_value=True + ), patch( + "homeassistant.components.mysensors.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.mysensors.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + flowid, + user_input, + ) + await hass.async_block_till_done() + + assert result2["type"] == "form" + assert "errors" in result2 + assert err_field in result2["errors"] + assert result2["errors"][err_field] == err_string + assert len(mock_setup.mock_calls) == 0 + assert len(mock_setup_entry.mock_calls) == 0 + + +async def test_config_tcp_invalid_port(hass: HomeAssistantType): + """Test invalid port on a tcp gateway.""" + await config_invalid( + hass, + CONF_GATEWAY_TYPE_TCP, + "gw_tcp", + { + CONF_TCP_PORT: 60000000, + CONF_DEVICE: "127.0.0.1", + CONF_VERSION: "2.4", + }, + CONF_TCP_PORT, + "port_out_of_range", + ) + + +async def test_config_tcp_invalid_port_too_small(hass: HomeAssistantType): + """Test invalid port on a tcp gateway.""" + await config_invalid( + hass, + CONF_GATEWAY_TYPE_TCP, + "gw_tcp", + { + CONF_TCP_PORT: 0, + CONF_DEVICE: "127.0.0.1", + CONF_VERSION: "2.4", + }, + CONF_TCP_PORT, + "port_out_of_range", + ) + + +async def test_config_tcp_invalid_version(hass: HomeAssistantType): + """Test tcp gateway with invalid version.""" + await config_invalid( + hass, + CONF_GATEWAY_TYPE_TCP, + "gw_tcp", + { + CONF_TCP_PORT: 5003, + CONF_DEVICE: "127.0.0.1", + CONF_VERSION: "a", + }, + CONF_VERSION, + "invalid_version", + ) + + +async def test_config_tcp_invalid_version2(hass: HomeAssistantType): + """Test tcp gateway with invalid version.""" + await config_invalid( + hass, + CONF_GATEWAY_TYPE_TCP, + "gw_tcp", + { + CONF_TCP_PORT: 5003, + CONF_DEVICE: "127.0.0.1", + CONF_VERSION: "a.b", + }, + CONF_VERSION, + "invalid_version", + ) + + +async def test_config_tcp_no_version(hass: HomeAssistantType): + """Test tcp gateway with invalid version.""" + await config_invalid( + hass, + CONF_GATEWAY_TYPE_TCP, + "gw_tcp", + { + CONF_TCP_PORT: 5003, + CONF_DEVICE: "127.0.0.1", + }, + CONF_VERSION, + "invalid_version", + ) + + +async def test_config_tcp_invalid_address(hass: HomeAssistantType): + """Test tcp gateway with invalid ip address.""" + await config_invalid( + hass, + CONF_GATEWAY_TYPE_TCP, + "gw_tcp", + { + CONF_TCP_PORT: 5003, + CONF_DEVICE: "127.0.0.", + CONF_VERSION: "2.4", + }, + CONF_DEVICE, + "invalid_ip", + ) + + +async def test_config_mqtt_invalid_persistence_file(hass: HomeAssistantType): + """Test mqtt gateway with invalid input topic.""" + await config_invalid( + hass, + CONF_GATEWAY_TYPE_MQTT, + "gw_mqtt", + { + CONF_RETAIN: True, + CONF_TOPIC_IN_PREFIX: "bla", + CONF_TOPIC_OUT_PREFIX: "blub", + CONF_PERSISTENCE_FILE: "asdf.zip", + CONF_VERSION: "2.4", + }, + CONF_PERSISTENCE_FILE, + "invalid_persistence_file", + ) + + +async def test_config_mqtt_invalid_in_topic(hass: HomeAssistantType): + """Test mqtt gateway with invalid input topic.""" + await config_invalid( + hass, + CONF_GATEWAY_TYPE_MQTT, + "gw_mqtt", + { + CONF_RETAIN: True, + CONF_TOPIC_IN_PREFIX: "/#/#", + CONF_TOPIC_OUT_PREFIX: "blub", + CONF_VERSION: "2.4", + }, + CONF_TOPIC_IN_PREFIX, + "invalid_subscribe_topic", + ) + + +async def test_config_mqtt_invalid_out_topic(hass: HomeAssistantType): + """Test mqtt gateway with invalid output topic.""" + await config_invalid( + hass, + CONF_GATEWAY_TYPE_MQTT, + "gw_mqtt", + { + CONF_RETAIN: True, + CONF_TOPIC_IN_PREFIX: "asdf", + CONF_TOPIC_OUT_PREFIX: "/#/#", + CONF_VERSION: "2.4", + }, + CONF_TOPIC_OUT_PREFIX, + "invalid_publish_topic", + ) + + +async def attempt_import(hass: HomeAssistantType, config: ConfigType, expected_calls=1): + """Test importing a gateway.""" + with patch("sys.platform", "win32"), patch( + "homeassistant.components.mysensors.config_flow.try_connect", return_value=True + ), patch( + "homeassistant.components.mysensors.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await async_setup(hass, config) + assert result + await hass.async_block_till_done() + + assert len(mock_setup_entry.mock_calls) == expected_calls + return mock_setup_entry.call_args + + +async def test_import_serial(hass: HomeAssistantType): + """Test importing a gateway via serial.""" + args, _ = await attempt_import( + hass, + { + DOMAIN: { + CONF_GATEWAYS: [ + { + CONF_DEVICE: "COM5", + CONF_PERSISTENCE_FILE: "bla.json", + CONF_BAUD_RATE: 57600, + CONF_TCP_PORT: 5003, + } + ], + CONF_VERSION: "2.3", + CONF_PERSISTENCE: False, + CONF_RETAIN: True, + } + }, + 1, + ) + # check result + # we check in this weird way bc there may be some extra keys that we don't care about + wanted = { + CONF_GATEWAY_TYPE: CONF_GATEWAY_TYPE_SERIAL, + CONF_DEVICE: "COM5", + CONF_PERSISTENCE_FILE: "bla.json", + CONF_BAUD_RATE: 57600, + CONF_VERSION: "2.3", + } + for key, value in wanted.items(): + assert key in args[1].data + assert args[1].data[key] == value + + +async def test_import_tcp(hass: HomeAssistantType): + """Test configuring a gateway via serial.""" + args, _ = await attempt_import( + hass, + { + DOMAIN: { + CONF_GATEWAYS: [ + { + CONF_DEVICE: "127.0.0.1", + CONF_PERSISTENCE_FILE: "blub.pickle", + CONF_BAUD_RATE: 115200, + CONF_TCP_PORT: 343, + } + ], + CONF_VERSION: "2.4", + CONF_PERSISTENCE: False, + CONF_RETAIN: False, + } + }, + 1, + ) + # check result + # we check in this weird way bc there may be some extra keys that we don't care about + wanted = { + CONF_GATEWAY_TYPE: CONF_GATEWAY_TYPE_TCP, + CONF_DEVICE: "127.0.0.1", + CONF_PERSISTENCE_FILE: "blub.pickle", + CONF_TCP_PORT: 343, + CONF_VERSION: "2.4", + } + for key, value in wanted.items(): + assert key in args[1].data + assert args[1].data[key] == value + + +async def test_import_mqtt(hass: HomeAssistantType): + """Test configuring a gateway via serial.""" + args, _ = await attempt_import( + hass, + { + DOMAIN: { + CONF_GATEWAYS: [ + { + CONF_DEVICE: "mqtt", + CONF_BAUD_RATE: 115200, + CONF_TCP_PORT: 5003, + CONF_TOPIC_IN_PREFIX: "intopic", + CONF_TOPIC_OUT_PREFIX: "outtopic", + } + ], + CONF_VERSION: "2.4", + CONF_PERSISTENCE: False, + CONF_RETAIN: False, + } + }, + 1, + ) + # check result + # we check in this weird way bc there may be some extra keys that we don't care about + wanted = { + CONF_GATEWAY_TYPE: CONF_GATEWAY_TYPE_MQTT, + CONF_DEVICE: "mqtt", + CONF_VERSION: "2.4", + CONF_TOPIC_OUT_PREFIX: "outtopic", + CONF_TOPIC_IN_PREFIX: "intopic", + } + for key, value in wanted.items(): + assert key in args[1].data + assert args[1].data[key] == value + + +async def test_import_two(hass: HomeAssistantType): + """Test configuring a gateway via serial.""" + await attempt_import( + hass, + { + DOMAIN: { + CONF_GATEWAYS: [ + { + CONF_DEVICE: "mqtt", + CONF_PERSISTENCE_FILE: "bla.json", + CONF_BAUD_RATE: 115200, + CONF_TCP_PORT: 5003, + }, + { + CONF_DEVICE: "COM6", + CONF_PERSISTENCE_FILE: "bla.json", + CONF_BAUD_RATE: 115200, + CONF_TCP_PORT: 5003, + }, + ], + CONF_VERSION: "2.4", + CONF_PERSISTENCE: False, + CONF_RETAIN: False, + } + }, + 2, + ) + + +async def test_validate_common_none(hass: HomeAssistantType): + """Test validate common with None.""" + with patch("sys.platform", "win32"), patch( + "homeassistant.components.mysensors.config_flow.try_connect", return_value=True + ): + handler = MySensorsConfigFlowHandler() + assert await handler.validate_common(CONF_GATEWAY_TYPE_MQTT) == {}