From c01e01f7973cb0f577d2d62eb044c72649d1deba Mon Sep 17 00:00:00 2001 From: functionpointer <suspendfunction@gmail.com> Date: Fri, 5 Feb 2021 22:13:57 +0100 Subject: [PATCH] MySensors config flow (#45421) * MySensors: Add type annotations Adds a bunch of type annotations that were created while understanding the code. * MySensors: Change GatewayId to string In preparation for config flow. The GatewayId used to be id(gateway). With config flows, every gateway will have its own ConfigEntry. Every ConfigEntry has a unique id. Thus we would have two separate but one-to-one related ID systems. This commit removes this unneeded duplication by using the id of the ConfigEntry as GatewayId. * MySensors: Add unique_id to all entities This allows entities to work well with the frontend. * MySensors: Add device_info to all entities Entities belonging to the same node_id will now by grouped as a device. * MySensors: clean up device.py a bit * MySensors: Add config flow support With this change the MySensors can be fully configured from the GUI. Legacy configuration.yaml configs will be migrated by reading them once. Note that custom node names are not migrated. Users will have to re-enter the names in the front-end. Since there is no straight-forward way to configure global settings, all previously global settings are now per-gateway. These settings include: - MQTT retain - optimistic - persistence enable - MySensors version When a MySensors integration is loaded, it works as follows: 1. __init__.async_setup_entry is called 2. for every platform, async_forward_entry_setup is called 3. the platform's async_setup_entry is called 4. __init__.setup_mysensors_platform is called 5. the entity's constructor (e.g. MySensorsCover) is called 6. the created entity is stored in a dict in the hass object * MySensors: Fix linter errors * MySensors: Remove unused import * MySensors: Feedback from @MartinHjelmare * MySensors: Multi-step config flow * MySensors: More feedback * MySensors: Move all storage in hass object under DOMAIN The integration now stores everything under hass.data["mysensors"] instead of using several top level keys. * MySensors: await shutdown of gateway instead of creating a task * MySensors: Rename Ethernet to TCP * MySensors: Absolute imports and cosmetic changes * MySensors: fix gw_stop * MySensors: Allow user to specify persistence file * MySensors: Nicer log message * MySensors: Add lots of unit tests * MySensors: Fix legacy import of persistence file name Turns out tests help to find bugs :D * MySensors: Improve test coverage * MySensors: Use json persistence files by default * MySensors: Code style improvements * MySensors: Stop adding attributes to existing objects This commit removes the extra attributes that were being added to the gateway objects from pymysensors. Most attributes were easy to remove, except for the gateway id. The MySensorsDevice class needs the gateway id as it is part of its DevId as well as the unique_id and device_info. Most MySensorsDevices actually end up being Entities. Entities have access to their ConfigEntry via self.platform.config_entry. However, the device_tracker platform does not become an Entity. For this reason, the gateway id is not fetched from self.plaform but given as an argument. Additionally, MySensorsDevices expose the address of the gateway (CONF_DEVICE). Entities can easily fetch this information via self.platform, but the device_tracker cannot. This commit chooses to remove the gateway address from device_tracker. While this could in theory break some automations, the simplicity of this solution was deemed worth it. The alternative of adding the entire ConfigEntry as an argument to MySensorsDevices is not viable, because device_tracker is initialized by the async_setup_scanner function that isn't supplied a ConfigEntry. It only gets discovery_info. Adding the entire ConfigEntry doesn't seem appropriate for this edge case. * MySensors: Fix gw_stop and the translations * MySensors: Fix incorrect function calls * MySensors: Fewer comments in const.py * MySensors: Remove union from _get_gateway and remove id from try_connect * MySensors: Deprecate nodes option in configuration.yaml * MySensors: Use version parser from packaging * MySensors: Remove prefix from unique_id and change some private property names * MySensors: Change _get_gateway function signature * MySensors: add packaging==20.8 for the version parser * MySensors: Rename some stuff * MySensors: use pytest.mark.parametrize * MySensors: Clean up test cases * MySensors: Remove unneeded parameter from devices * Revert "MySensors: add packaging==20.8 for the version parser" This reverts commit 6b200ee01a3c0eee98176380bdd0b73e5a25b2dd. * MySensors: Use core interface for testing configuration.yaml import * MySensors: Fix test_init * MySensors: Rename a few variables * MySensors: cosmetic changes * MySensors: Update strings.json * MySensors: Still more feedback from @MartinHjelmare * MySensors: Remove unused strings Co-authored-by: Martin Hjelmare <marhje52@gmail.com> * MySensors: Fix typo and remove another unused string * MySensors: More strings.json * MySensors: Fix gateway ready handler * MySensors: Add duplicate detection to config flows * MySensors: Deal with non-existing topics and ports. Includes unit tests for these cases. * MySensors: Use awesomeversion instead of packaging * Add string already_configured * MySensors: Abort config flow when config is found to be invalid while importing * MySensors: Copy all error messages to also be abort messages All error strings may now also be used as an abort reason, so the strings should be defined * Use string references Co-authored-by: Martin Hjelmare <marhje52@gmail.com> --- .coveragerc | 15 +- CODEOWNERS | 2 +- .../components/mysensors/__init__.py | 205 ++++- .../components/mysensors/binary_sensor.py | 38 +- homeassistant/components/mysensors/climate.py | 49 +- .../components/mysensors/config_flow.py | 300 +++++++ homeassistant/components/mysensors/const.py | 120 ++- homeassistant/components/mysensors/cover.py | 50 +- homeassistant/components/mysensors/device.py | 130 +++- .../components/mysensors/device_tracker.py | 33 +- homeassistant/components/mysensors/gateway.py | 238 ++++-- homeassistant/components/mysensors/handler.py | 72 +- homeassistant/components/mysensors/helpers.py | 120 ++- homeassistant/components/mysensors/light.py | 55 +- .../components/mysensors/manifest.json | 14 +- homeassistant/components/mysensors/sensor.py | 38 +- .../components/mysensors/strings.json | 79 ++ homeassistant/components/mysensors/switch.py | 54 +- .../components/mysensors/translations/en.json | 79 ++ homeassistant/generated/config_flows.py | 1 + requirements_all.txt | 2 +- requirements_test_all.txt | 3 + tests/components/mysensors/__init__.py | 1 + .../components/mysensors/test_config_flow.py | 735 ++++++++++++++++++ tests/components/mysensors/test_gateway.py | 30 + tests/components/mysensors/test_init.py | 251 ++++++ 26 files changed, 2376 insertions(+), 338 deletions(-) create mode 100644 homeassistant/components/mysensors/config_flow.py create mode 100644 homeassistant/components/mysensors/strings.json create mode 100644 homeassistant/components/mysensors/translations/en.json create mode 100644 tests/components/mysensors/__init__.py create mode 100644 tests/components/mysensors/test_config_flow.py create mode 100644 tests/components/mysensors/test_gateway.py create mode 100644 tests/components/mysensors/test_init.py diff --git a/.coveragerc b/.coveragerc index 47d9c84ba0e..3a274cd004f 100644 --- a/.coveragerc +++ b/.coveragerc @@ -578,7 +578,20 @@ omit = homeassistant/components/mychevy/* homeassistant/components/mycroft/* homeassistant/components/mycroft/notify.py - homeassistant/components/mysensors/* + homeassistant/components/mysensors/__init__.py + homeassistant/components/mysensors/binary_sensor.py + homeassistant/components/mysensors/climate.py + homeassistant/components/mysensors/const.py + homeassistant/components/mysensors/cover.py + homeassistant/components/mysensors/device.py + homeassistant/components/mysensors/device_tracker.py + homeassistant/components/mysensors/gateway.py + homeassistant/components/mysensors/handler.py + homeassistant/components/mysensors/helpers.py + homeassistant/components/mysensors/light.py + homeassistant/components/mysensors/notify.py + homeassistant/components/mysensors/sensor.py + homeassistant/components/mysensors/switch.py homeassistant/components/mystrom/binary_sensor.py homeassistant/components/mystrom/light.py homeassistant/components/mystrom/switch.py diff --git a/CODEOWNERS b/CODEOWNERS index efb338dd4b4..8785ce382cb 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -288,7 +288,7 @@ homeassistant/components/mpd/* @fabaff homeassistant/components/mqtt/* @home-assistant/core @emontnemery homeassistant/components/msteams/* @peroyvind homeassistant/components/myq/* @bdraco -homeassistant/components/mysensors/* @MartinHjelmare +homeassistant/components/mysensors/* @MartinHjelmare @functionpointer homeassistant/components/mystrom/* @fabaff homeassistant/components/neato/* @dshokouhi @Santobert homeassistant/components/nederlandse_spoorwegen/* @YarmoM diff --git a/homeassistant/components/mysensors/__init__.py b/homeassistant/components/mysensors/__init__.py index 43e398b142f..25b4d3106da 100644 --- a/homeassistant/components/mysensors/__init__.py +++ b/homeassistant/components/mysensors/__init__.py @@ -1,12 +1,18 @@ """Connect to a MySensors gateway via pymysensors API.""" +import asyncio import logging +from typing import Callable, Dict, List, Optional, Tuple, Type, Union +from mysensors import BaseAsyncGateway import voluptuous as vol +from homeassistant import config_entries from homeassistant.components.mqtt import valid_publish_topic, valid_subscribe_topic +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_OPTIMISTIC from homeassistant.core import callback import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.typing import ConfigType, HomeAssistantType from .const import ( ATTR_DEVICES, @@ -23,9 +29,14 @@ from .const import ( CONF_VERSION, DOMAIN, MYSENSORS_GATEWAYS, + MYSENSORS_ON_UNLOAD, + SUPPORTED_PLATFORMS_WITH_ENTRY_SUPPORT, + DevId, + GatewayId, + SensorType, ) -from .device import get_mysensors_devices -from .gateway import finish_setup, get_mysensors_gateway, setup_gateways +from .device import MySensorsDevice, MySensorsEntity, get_mysensors_devices +from .gateway import finish_setup, get_mysensors_gateway, gw_stop, setup_gateway _LOGGER = logging.getLogger(__name__) @@ -81,29 +92,38 @@ def deprecated(key): NODE_SCHEMA = vol.Schema({cv.positive_int: {vol.Required(CONF_NODE_NAME): cv.string}}) -GATEWAY_SCHEMA = { - vol.Required(CONF_DEVICE): cv.string, - vol.Optional(CONF_PERSISTENCE_FILE): vol.All(cv.string, is_persistence_file), - vol.Optional(CONF_BAUD_RATE, default=DEFAULT_BAUD_RATE): cv.positive_int, - vol.Optional(CONF_TCP_PORT, default=DEFAULT_TCP_PORT): cv.port, - vol.Optional(CONF_TOPIC_IN_PREFIX): valid_subscribe_topic, - vol.Optional(CONF_TOPIC_OUT_PREFIX): valid_publish_topic, - vol.Optional(CONF_NODES, default={}): NODE_SCHEMA, -} +GATEWAY_SCHEMA = vol.Schema( + vol.All( + deprecated(CONF_NODES), + { + vol.Required(CONF_DEVICE): cv.string, + vol.Optional(CONF_PERSISTENCE_FILE): vol.All( + cv.string, is_persistence_file + ), + vol.Optional(CONF_BAUD_RATE, default=DEFAULT_BAUD_RATE): cv.positive_int, + vol.Optional(CONF_TCP_PORT, default=DEFAULT_TCP_PORT): cv.port, + vol.Optional(CONF_TOPIC_IN_PREFIX): valid_subscribe_topic, + vol.Optional(CONF_TOPIC_OUT_PREFIX): valid_publish_topic, + vol.Optional(CONF_NODES, default={}): NODE_SCHEMA, + }, + ) +) CONFIG_SCHEMA = vol.Schema( { DOMAIN: vol.Schema( vol.All( deprecated(CONF_DEBUG), + deprecated(CONF_OPTIMISTIC), + deprecated(CONF_PERSISTENCE), { vol.Required(CONF_GATEWAYS): vol.All( cv.ensure_list, has_all_unique_files, [GATEWAY_SCHEMA] ), - vol.Optional(CONF_OPTIMISTIC, default=False): cv.boolean, - vol.Optional(CONF_PERSISTENCE, default=True): cv.boolean, vol.Optional(CONF_RETAIN, default=True): cv.boolean, vol.Optional(CONF_VERSION, default=DEFAULT_VERSION): cv.string, + vol.Optional(CONF_OPTIMISTIC, default=False): cv.boolean, + vol.Optional(CONF_PERSISTENCE, default=True): cv.boolean, }, ) ) @@ -112,69 +132,168 @@ CONFIG_SCHEMA = vol.Schema( ) -async def async_setup(hass, config): +async def async_setup(hass: HomeAssistantType, config: ConfigType) -> bool: """Set up the MySensors component.""" - gateways = await setup_gateways(hass, config) + if DOMAIN not in config or bool(hass.config_entries.async_entries(DOMAIN)): + return True + + config = config[DOMAIN] + user_inputs = [ + { + CONF_DEVICE: gw[CONF_DEVICE], + CONF_BAUD_RATE: gw[CONF_BAUD_RATE], + CONF_TCP_PORT: gw[CONF_TCP_PORT], + CONF_TOPIC_OUT_PREFIX: gw.get(CONF_TOPIC_OUT_PREFIX, ""), + CONF_TOPIC_IN_PREFIX: gw.get(CONF_TOPIC_IN_PREFIX, ""), + CONF_RETAIN: config[CONF_RETAIN], + CONF_VERSION: config[CONF_VERSION], + CONF_PERSISTENCE_FILE: gw.get(CONF_PERSISTENCE_FILE) + # nodes config ignored at this time. renaming nodes can now be done from the frontend. + } + for gw in config[CONF_GATEWAYS] + ] + user_inputs = [ + {k: v for k, v in userinput.items() if v is not None} + for userinput in user_inputs + ] + + # there is an actual configuration in configuration.yaml, so we have to process it + for user_input in user_inputs: + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data=user_input, + ) + ) + + return True - if not gateways: - _LOGGER.error("No devices could be setup as gateways, check your configuration") + +async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry) -> bool: + """Set up an instance of the MySensors integration. + + Every instance has a connection to exactly one Gateway. + """ + gateway = await setup_gateway(hass, entry) + + if not gateway: + _LOGGER.error("Gateway setup failed for %s", entry.data) return False - hass.data[MYSENSORS_GATEWAYS] = gateways + if DOMAIN not in hass.data: + hass.data[DOMAIN] = {} - hass.async_create_task(finish_setup(hass, config, gateways)) + if MYSENSORS_GATEWAYS not in hass.data[DOMAIN]: + hass.data[DOMAIN][MYSENSORS_GATEWAYS] = {} + hass.data[DOMAIN][MYSENSORS_GATEWAYS][entry.entry_id] = gateway + + async def finish(): + await asyncio.gather( + *[ + hass.config_entries.async_forward_entry_setup(entry, platform) + for platform in SUPPORTED_PLATFORMS_WITH_ENTRY_SUPPORT + ] + ) + await finish_setup(hass, entry, gateway) + + hass.async_create_task(finish()) return True -def _get_mysensors_name(gateway, node_id, child_id): - """Return a name for a node child.""" - node_name = f"{gateway.sensors[node_id].sketch_name} {node_id}" - node_name = next( - ( - node[CONF_NODE_NAME] - for conf_id, node in gateway.nodes_config.items() - if node.get(CONF_NODE_NAME) is not None and conf_id == node_id - ), - node_name, +async def async_unload_entry(hass: HomeAssistantType, entry: ConfigEntry) -> bool: + """Remove an instance of the MySensors integration.""" + + gateway = get_mysensors_gateway(hass, entry.entry_id) + + unload_ok = all( + await asyncio.gather( + *[ + hass.config_entries.async_forward_entry_unload(entry, platform) + for platform in SUPPORTED_PLATFORMS_WITH_ENTRY_SUPPORT + ] + ) ) - return f"{node_name} {child_id}" + if not unload_ok: + return False + + key = MYSENSORS_ON_UNLOAD.format(entry.entry_id) + if key in hass.data[DOMAIN]: + for fnct in hass.data[DOMAIN][key]: + fnct() + + del hass.data[DOMAIN][MYSENSORS_GATEWAYS][entry.entry_id] + + await gw_stop(hass, entry, gateway) + return True + + +async def on_unload( + hass: HomeAssistantType, entry: Union[ConfigEntry, GatewayId], fnct: Callable +) -> None: + """Register a callback to be called when entry is unloaded. + + This function is used by platforms to cleanup after themselves + """ + if isinstance(entry, GatewayId): + uniqueid = entry + else: + uniqueid = entry.entry_id + key = MYSENSORS_ON_UNLOAD.format(uniqueid) + if key not in hass.data[DOMAIN]: + hass.data[DOMAIN][key] = [] + hass.data[DOMAIN][key].append(fnct) @callback def setup_mysensors_platform( hass, - domain, - discovery_info, - device_class, - device_args=None, - async_add_entities=None, -): - """Set up a MySensors platform.""" + domain: str, # hass platform name + discovery_info: Optional[Dict[str, List[DevId]]], + device_class: Union[Type[MySensorsDevice], Dict[SensorType, Type[MySensorsEntity]]], + device_args: Optional[ + Tuple + ] = None, # extra arguments that will be given to the entity constructor + async_add_entities: Callable = None, +) -> Optional[List[MySensorsDevice]]: + """Set up a MySensors platform. + + Sets up a bunch of instances of a single platform that is supported by this integration. + The function is given a list of device ids, each one describing an instance to set up. + The function is also given a class. + A new instance of the class is created for every device id, and the device id is given to the constructor of the class + """ # Only act if called via MySensors by discovery event. # Otherwise gateway is not set up. if not discovery_info: + _LOGGER.debug("Skipping setup due to no discovery info") return None if device_args is None: device_args = () - new_devices = [] - new_dev_ids = discovery_info[ATTR_DEVICES] + new_devices: List[MySensorsDevice] = [] + new_dev_ids: List[DevId] = discovery_info[ATTR_DEVICES] for dev_id in new_dev_ids: - devices = get_mysensors_devices(hass, domain) + devices: Dict[DevId, MySensorsDevice] = get_mysensors_devices(hass, domain) if dev_id in devices: + _LOGGER.debug( + "Skipping setup of %s for platform %s as it already exists", + dev_id, + domain, + ) continue gateway_id, node_id, child_id, value_type = dev_id - gateway = get_mysensors_gateway(hass, gateway_id) + gateway: Optional[BaseAsyncGateway] = get_mysensors_gateway(hass, gateway_id) if not gateway: + _LOGGER.warning("Skipping setup of %s, no gateway found", dev_id) continue device_class_copy = device_class if isinstance(device_class, dict): child = gateway.sensors[node_id].children[child_id] s_type = gateway.const.Presentation(child.type).name device_class_copy = device_class[s_type] - name = _get_mysensors_name(gateway, node_id, child_id) - args_copy = (*device_args, gateway, node_id, child_id, name, value_type) + args_copy = (*device_args, gateway_id, gateway, node_id, child_id, value_type) devices[dev_id] = device_class_copy(*args_copy) new_devices.append(devices[dev_id]) if new_devices: diff --git a/homeassistant/components/mysensors/binary_sensor.py b/homeassistant/components/mysensors/binary_sensor.py index 4ec3c6e0abd..c4e12d170c0 100644 --- a/homeassistant/components/mysensors/binary_sensor.py +++ b/homeassistant/components/mysensors/binary_sensor.py @@ -1,4 +1,6 @@ """Support for MySensors binary sensors.""" +from typing import Callable + from homeassistant.components import mysensors from homeassistant.components.binary_sensor import ( DEVICE_CLASS_MOISTURE, @@ -10,7 +12,13 @@ from homeassistant.components.binary_sensor import ( DOMAIN, BinarySensorEntity, ) +from homeassistant.components.mysensors import on_unload +from homeassistant.components.mysensors.const import MYSENSORS_DISCOVERY +from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_ON +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.typing import HomeAssistantType SENSORS = { "S_DOOR": "door", @@ -24,14 +32,30 @@ SENSORS = { } -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): - """Set up the mysensors platform for binary sensors.""" - mysensors.setup_mysensors_platform( +async def async_setup_entry( + hass: HomeAssistantType, config_entry: ConfigEntry, async_add_entities: Callable +): + """Set up this platform for a specific ConfigEntry(==Gateway).""" + + @callback + def async_discover(discovery_info): + """Discover and add a MySensors binary_sensor.""" + mysensors.setup_mysensors_platform( + hass, + DOMAIN, + discovery_info, + MySensorsBinarySensor, + async_add_entities=async_add_entities, + ) + + await on_unload( hass, - DOMAIN, - discovery_info, - MySensorsBinarySensor, - async_add_entities=async_add_entities, + config_entry, + async_dispatcher_connect( + hass, + MYSENSORS_DISCOVERY.format(config_entry.entry_id, DOMAIN), + async_discover, + ), ) diff --git a/homeassistant/components/mysensors/climate.py b/homeassistant/components/mysensors/climate.py index c318ccf7ec6..b1916fc4ed1 100644 --- a/homeassistant/components/mysensors/climate.py +++ b/homeassistant/components/mysensors/climate.py @@ -1,4 +1,6 @@ """MySensors platform that offers a Climate (MySensors-HVAC) component.""" +from typing import Callable + from homeassistant.components import mysensors from homeassistant.components.climate import ClimateEntity from homeassistant.components.climate.const import ( @@ -13,7 +15,12 @@ from homeassistant.components.climate.const import ( SUPPORT_TARGET_TEMPERATURE, SUPPORT_TARGET_TEMPERATURE_RANGE, ) +from homeassistant.components.mysensors import on_unload +from homeassistant.components.mysensors.const import MYSENSORS_DISCOVERY +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, TEMP_CELSIUS, TEMP_FAHRENHEIT +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.typing import HomeAssistantType DICT_HA_TO_MYS = { HVAC_MODE_AUTO: "AutoChangeOver", @@ -32,14 +39,29 @@ FAN_LIST = ["Auto", "Min", "Normal", "Max"] OPERATION_LIST = [HVAC_MODE_OFF, HVAC_MODE_AUTO, HVAC_MODE_COOL, HVAC_MODE_HEAT] -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): - """Set up the mysensors climate.""" - mysensors.setup_mysensors_platform( +async def async_setup_entry( + hass: HomeAssistantType, config_entry: ConfigEntry, async_add_entities: Callable +): + """Set up this platform for a specific ConfigEntry(==Gateway).""" + + async def async_discover(discovery_info): + """Discover and add a MySensors climate.""" + mysensors.setup_mysensors_platform( + hass, + DOMAIN, + discovery_info, + MySensorsHVAC, + async_add_entities=async_add_entities, + ) + + await on_unload( hass, - DOMAIN, - discovery_info, - MySensorsHVAC, - async_add_entities=async_add_entities, + config_entry, + async_dispatcher_connect( + hass, + MYSENSORS_DISCOVERY.format(config_entry.entry_id, DOMAIN), + async_discover, + ), ) @@ -62,15 +84,10 @@ class MySensorsHVAC(mysensors.device.MySensorsEntity, ClimateEntity): features = features | SUPPORT_TARGET_TEMPERATURE return features - @property - def assumed_state(self): - """Return True if unable to access real state of entity.""" - return self.gateway.optimistic - @property def temperature_unit(self): """Return the unit of measurement.""" - return TEMP_CELSIUS if self.gateway.metric else TEMP_FAHRENHEIT + return TEMP_CELSIUS if self.hass.config.units.is_metric else TEMP_FAHRENHEIT @property def current_temperature(self): @@ -159,7 +176,7 @@ class MySensorsHVAC(mysensors.device.MySensorsEntity, ClimateEntity): self.gateway.set_child_value( self.node_id, self.child_id, value_type, value, ack=1 ) - if self.gateway.optimistic: + if self.assumed_state: # Optimistically assume that device has changed state self._values[value_type] = value self.async_write_ha_state() @@ -170,7 +187,7 @@ class MySensorsHVAC(mysensors.device.MySensorsEntity, ClimateEntity): self.gateway.set_child_value( self.node_id, self.child_id, set_req.V_HVAC_SPEED, fan_mode, ack=1 ) - if self.gateway.optimistic: + if self.assumed_state: # Optimistically assume that device has changed state self._values[set_req.V_HVAC_SPEED] = fan_mode self.async_write_ha_state() @@ -184,7 +201,7 @@ class MySensorsHVAC(mysensors.device.MySensorsEntity, ClimateEntity): DICT_HA_TO_MYS[hvac_mode], ack=1, ) - if self.gateway.optimistic: + if self.assumed_state: # Optimistically assume that device has changed state self._values[self.value_type] = hvac_mode self.async_write_ha_state() diff --git a/homeassistant/components/mysensors/config_flow.py b/homeassistant/components/mysensors/config_flow.py new file mode 100644 index 00000000000..058b782d208 --- /dev/null +++ b/homeassistant/components/mysensors/config_flow.py @@ -0,0 +1,300 @@ +"""Config flow for MySensors.""" +import logging +import os +from typing import Any, Dict, Optional + +from awesomeversion import ( + AwesomeVersion, + AwesomeVersionStrategy, + AwesomeVersionStrategyException, +) +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.components.mqtt import valid_publish_topic, valid_subscribe_topic +from homeassistant.components.mysensors import ( + CONF_DEVICE, + DEFAULT_BAUD_RATE, + DEFAULT_TCP_PORT, + is_persistence_file, +) +from homeassistant.config_entries import ConfigEntry +import homeassistant.helpers.config_validation as cv + +from . import CONF_RETAIN, CONF_VERSION, DEFAULT_VERSION + +# pylint: disable=unused-import +from .const import ( + CONF_BAUD_RATE, + CONF_GATEWAY_TYPE, + CONF_GATEWAY_TYPE_ALL, + CONF_GATEWAY_TYPE_MQTT, + CONF_GATEWAY_TYPE_SERIAL, + CONF_GATEWAY_TYPE_TCP, + CONF_PERSISTENCE_FILE, + CONF_TCP_PORT, + CONF_TOPIC_IN_PREFIX, + CONF_TOPIC_OUT_PREFIX, + DOMAIN, + ConfGatewayType, +) +from .gateway import MQTT_COMPONENT, is_serial_port, is_socket_address, try_connect + +_LOGGER = logging.getLogger(__name__) + + +def _get_schema_common() -> dict: + """Create a schema with options common to all gateway types.""" + schema = { + vol.Required( + CONF_VERSION, default="", description={"suggested_value": DEFAULT_VERSION} + ): str, + vol.Optional( + CONF_PERSISTENCE_FILE, + ): str, + } + return schema + + +def _validate_version(version: str) -> Dict[str, str]: + """Validate a version string from the user.""" + version_okay = False + try: + version_okay = bool( + AwesomeVersion.ensure_strategy( + version, + [AwesomeVersionStrategy.SIMPLEVER, AwesomeVersionStrategy.SEMVER], + ) + ) + except AwesomeVersionStrategyException: + pass + if version_okay: + return {} + return {CONF_VERSION: "invalid_version"} + + +def _is_same_device( + gw_type: ConfGatewayType, user_input: Dict[str, str], entry: ConfigEntry +): + """Check if another ConfigDevice is actually the same as user_input. + + This function only compares addresses and tcp ports, so it is possible to fool it with tricks like port forwarding. + """ + if entry.data[CONF_DEVICE] != user_input[CONF_DEVICE]: + return False + if gw_type == CONF_GATEWAY_TYPE_TCP: + return entry.data[CONF_TCP_PORT] == user_input[CONF_TCP_PORT] + if gw_type == CONF_GATEWAY_TYPE_MQTT: + entry_topics = { + entry.data[CONF_TOPIC_IN_PREFIX], + entry.data[CONF_TOPIC_OUT_PREFIX], + } + return ( + user_input.get(CONF_TOPIC_IN_PREFIX) in entry_topics + or user_input.get(CONF_TOPIC_OUT_PREFIX) in entry_topics + ) + return True + + +class MySensorsConfigFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow.""" + + async def async_step_import(self, user_input: Optional[Dict[str, str]] = None): + """Import a config entry. + + This method is called by async_setup and it has already + prepared the dict to be compatible with what a user would have + entered from the frontend. + Therefore we process it as though it came from the frontend. + """ + if user_input[CONF_DEVICE] == MQTT_COMPONENT: + user_input[CONF_GATEWAY_TYPE] = CONF_GATEWAY_TYPE_MQTT + else: + try: + await self.hass.async_add_executor_job( + is_serial_port, user_input[CONF_DEVICE] + ) + except vol.Invalid: + user_input[CONF_GATEWAY_TYPE] = CONF_GATEWAY_TYPE_TCP + else: + user_input[CONF_GATEWAY_TYPE] = CONF_GATEWAY_TYPE_SERIAL + + result: Dict[str, Any] = await self.async_step_user(user_input=user_input) + if result["type"] == "form": + return self.async_abort(reason=next(iter(result["errors"].values()))) + return result + + async def async_step_user(self, user_input: Optional[Dict[str, str]] = None): + """Create a config entry from frontend user input.""" + schema = {vol.Required(CONF_GATEWAY_TYPE): vol.In(CONF_GATEWAY_TYPE_ALL)} + schema = vol.Schema(schema) + + if user_input is not None: + gw_type = user_input[CONF_GATEWAY_TYPE] + input_pass = user_input if CONF_DEVICE in user_input else None + if gw_type == CONF_GATEWAY_TYPE_MQTT: + return await self.async_step_gw_mqtt(input_pass) + if gw_type == CONF_GATEWAY_TYPE_TCP: + return await self.async_step_gw_tcp(input_pass) + if gw_type == CONF_GATEWAY_TYPE_SERIAL: + return await self.async_step_gw_serial(input_pass) + + return self.async_show_form(step_id="user", data_schema=schema) + + async def async_step_gw_serial(self, user_input: Optional[Dict[str, str]] = None): + """Create config entry for a serial gateway.""" + errors = {} + if user_input is not None: + errors.update( + await self.validate_common(CONF_GATEWAY_TYPE_SERIAL, errors, user_input) + ) + if not errors: + return self.async_create_entry( + title=f"{user_input[CONF_DEVICE]}", data=user_input + ) + + schema = _get_schema_common() + schema[ + vol.Required(CONF_BAUD_RATE, default=DEFAULT_BAUD_RATE) + ] = cv.positive_int + schema[vol.Required(CONF_DEVICE, default="/dev/ttyACM0")] = str + + schema = vol.Schema(schema) + return self.async_show_form( + step_id="gw_serial", data_schema=schema, errors=errors + ) + + async def async_step_gw_tcp(self, user_input: Optional[Dict[str, str]] = None): + """Create a config entry for a tcp gateway.""" + errors = {} + if user_input is not None: + if CONF_TCP_PORT in user_input: + port: int = user_input[CONF_TCP_PORT] + if not (0 < port <= 65535): + errors[CONF_TCP_PORT] = "port_out_of_range" + + errors.update( + await self.validate_common(CONF_GATEWAY_TYPE_TCP, errors, user_input) + ) + if not errors: + return self.async_create_entry( + title=f"{user_input[CONF_DEVICE]}", data=user_input + ) + + schema = _get_schema_common() + schema[vol.Required(CONF_DEVICE, default="127.0.0.1")] = str + # Don't use cv.port as that would show a slider *facepalm* + schema[vol.Optional(CONF_TCP_PORT, default=DEFAULT_TCP_PORT)] = vol.Coerce(int) + + schema = vol.Schema(schema) + return self.async_show_form(step_id="gw_tcp", data_schema=schema, errors=errors) + + def _check_topic_exists(self, topic: str) -> bool: + for other_config in self.hass.config_entries.async_entries(DOMAIN): + if topic == other_config.data.get( + CONF_TOPIC_IN_PREFIX + ) or topic == other_config.data.get(CONF_TOPIC_OUT_PREFIX): + return True + return False + + async def async_step_gw_mqtt(self, user_input: Optional[Dict[str, str]] = None): + """Create a config entry for a mqtt gateway.""" + errors = {} + if user_input is not None: + user_input[CONF_DEVICE] = MQTT_COMPONENT + + try: + valid_subscribe_topic(user_input[CONF_TOPIC_IN_PREFIX]) + except vol.Invalid: + errors[CONF_TOPIC_IN_PREFIX] = "invalid_subscribe_topic" + else: + if self._check_topic_exists(user_input[CONF_TOPIC_IN_PREFIX]): + errors[CONF_TOPIC_IN_PREFIX] = "duplicate_topic" + + try: + valid_publish_topic(user_input[CONF_TOPIC_OUT_PREFIX]) + except vol.Invalid: + errors[CONF_TOPIC_OUT_PREFIX] = "invalid_publish_topic" + if not errors: + if ( + user_input[CONF_TOPIC_IN_PREFIX] + == user_input[CONF_TOPIC_OUT_PREFIX] + ): + errors[CONF_TOPIC_OUT_PREFIX] = "same_topic" + elif self._check_topic_exists(user_input[CONF_TOPIC_OUT_PREFIX]): + errors[CONF_TOPIC_OUT_PREFIX] = "duplicate_topic" + + errors.update( + await self.validate_common(CONF_GATEWAY_TYPE_MQTT, errors, user_input) + ) + if not errors: + return self.async_create_entry( + title=f"{user_input[CONF_DEVICE]}", data=user_input + ) + schema = _get_schema_common() + schema[vol.Required(CONF_RETAIN, default=True)] = bool + schema[vol.Required(CONF_TOPIC_IN_PREFIX)] = str + schema[vol.Required(CONF_TOPIC_OUT_PREFIX)] = str + + schema = vol.Schema(schema) + return self.async_show_form( + step_id="gw_mqtt", data_schema=schema, errors=errors + ) + + def _normalize_persistence_file(self, path: str) -> str: + return os.path.realpath(os.path.normcase(self.hass.config.path(path))) + + async def validate_common( + self, + gw_type: ConfGatewayType, + errors: Dict[str, str], + user_input: Optional[Dict[str, str]] = None, + ) -> Dict[str, str]: + """Validate parameters common to all gateway types.""" + if user_input is not None: + errors.update(_validate_version(user_input.get(CONF_VERSION))) + + if gw_type != CONF_GATEWAY_TYPE_MQTT: + if gw_type == CONF_GATEWAY_TYPE_TCP: + verification_func = is_socket_address + else: + verification_func = is_serial_port + + try: + await self.hass.async_add_executor_job( + verification_func, user_input.get(CONF_DEVICE) + ) + except vol.Invalid: + errors[CONF_DEVICE] = ( + "invalid_ip" + if gw_type == CONF_GATEWAY_TYPE_TCP + else "invalid_serial" + ) + if CONF_PERSISTENCE_FILE in user_input: + try: + is_persistence_file(user_input[CONF_PERSISTENCE_FILE]) + except vol.Invalid: + errors[CONF_PERSISTENCE_FILE] = "invalid_persistence_file" + else: + real_persistence_path = self._normalize_persistence_file( + user_input[CONF_PERSISTENCE_FILE] + ) + for other_entry in self.hass.config_entries.async_entries(DOMAIN): + if CONF_PERSISTENCE_FILE not in other_entry.data: + continue + if real_persistence_path == self._normalize_persistence_file( + other_entry.data[CONF_PERSISTENCE_FILE] + ): + errors[CONF_PERSISTENCE_FILE] = "duplicate_persistence_file" + break + + for other_entry in self.hass.config_entries.async_entries(DOMAIN): + if _is_same_device(gw_type, user_input, other_entry): + errors["base"] = "already_configured" + break + + # if no errors so far, try to connect + if not errors and not await try_connect(self.hass, user_input): + errors["base"] = "cannot_connect" + + return errors diff --git a/homeassistant/components/mysensors/const.py b/homeassistant/components/mysensors/const.py index ccb646eb47e..66bee128d4d 100644 --- a/homeassistant/components/mysensors/const.py +++ b/homeassistant/components/mysensors/const.py @@ -1,33 +1,69 @@ """MySensors constants.""" from collections import defaultdict - -ATTR_DEVICES = "devices" - -CONF_BAUD_RATE = "baud_rate" -CONF_DEVICE = "device" -CONF_GATEWAYS = "gateways" -CONF_NODES = "nodes" -CONF_PERSISTENCE = "persistence" -CONF_PERSISTENCE_FILE = "persistence_file" -CONF_RETAIN = "retain" -CONF_TCP_PORT = "tcp_port" -CONF_TOPIC_IN_PREFIX = "topic_in_prefix" -CONF_TOPIC_OUT_PREFIX = "topic_out_prefix" -CONF_VERSION = "version" - -DOMAIN = "mysensors" -MYSENSORS_GATEWAY_READY = "mysensors_gateway_ready_{}" -MYSENSORS_GATEWAYS = "mysensors_gateways" -PLATFORM = "platform" -SCHEMA = "schema" -CHILD_CALLBACK = "mysensors_child_callback_{}_{}_{}_{}" -NODE_CALLBACK = "mysensors_node_callback_{}_{}" -TYPE = "type" -UPDATE_DELAY = 0.1 - -SERVICE_SEND_IR_CODE = "send_ir_code" - -BINARY_SENSOR_TYPES = { +from typing import Dict, List, Literal, Set, Tuple + +ATTR_DEVICES: str = "devices" +ATTR_GATEWAY_ID: str = "gateway_id" + +CONF_BAUD_RATE: str = "baud_rate" +CONF_DEVICE: str = "device" +CONF_GATEWAYS: str = "gateways" +CONF_NODES: str = "nodes" +CONF_PERSISTENCE: str = "persistence" +CONF_PERSISTENCE_FILE: str = "persistence_file" +CONF_RETAIN: str = "retain" +CONF_TCP_PORT: str = "tcp_port" +CONF_TOPIC_IN_PREFIX: str = "topic_in_prefix" +CONF_TOPIC_OUT_PREFIX: str = "topic_out_prefix" +CONF_VERSION: str = "version" +CONF_GATEWAY_TYPE: str = "gateway_type" +ConfGatewayType = Literal["Serial", "TCP", "MQTT"] +CONF_GATEWAY_TYPE_SERIAL: ConfGatewayType = "Serial" +CONF_GATEWAY_TYPE_TCP: ConfGatewayType = "TCP" +CONF_GATEWAY_TYPE_MQTT: ConfGatewayType = "MQTT" +CONF_GATEWAY_TYPE_ALL: List[str] = [ + CONF_GATEWAY_TYPE_MQTT, + CONF_GATEWAY_TYPE_SERIAL, + CONF_GATEWAY_TYPE_TCP, +] + + +DOMAIN: str = "mysensors" +MYSENSORS_GATEWAY_READY: str = "mysensors_gateway_ready_{}" +MYSENSORS_GATEWAY_START_TASK: str = "mysensors_gateway_start_task_{}" +MYSENSORS_GATEWAYS: str = "mysensors_gateways" +PLATFORM: str = "platform" +SCHEMA: str = "schema" +CHILD_CALLBACK: str = "mysensors_child_callback_{}_{}_{}_{}" +NODE_CALLBACK: str = "mysensors_node_callback_{}_{}" +MYSENSORS_DISCOVERY = "mysensors_discovery_{}_{}" +MYSENSORS_ON_UNLOAD = "mysensors_on_unload_{}" +TYPE: str = "type" +UPDATE_DELAY: float = 0.1 + +SERVICE_SEND_IR_CODE: str = "send_ir_code" + +SensorType = str +# S_DOOR, S_MOTION, S_SMOKE, ... + +ValueType = str +# V_TRIPPED, V_ARMED, V_STATUS, V_PERCENTAGE, ... + +GatewayId = str +# a unique id generated by config_flow.py and stored in the ConfigEntry as the entry id. +# +# Gateway may be fetched by giving the gateway id to get_mysensors_gateway() + +DevId = Tuple[GatewayId, int, int, int] +# describes the backend of a hass entity. Contents are: GatewayId, node_id, child_id, v_type as int +# +# The string version of v_type can be looked up in the enum gateway.const.SetReq of the appropriate BaseAsyncGateway +# Home Assistant Entities are quite limited and only ever do one thing. +# MySensors Nodes have multiple child_ids each with a s_type several associated v_types +# The MySensors integration brings these together by creating an entity for every v_type of every child_id of every node. +# The DevId tuple perfectly captures this. + +BINARY_SENSOR_TYPES: Dict[SensorType, Set[ValueType]] = { "S_DOOR": {"V_TRIPPED"}, "S_MOTION": {"V_TRIPPED"}, "S_SMOKE": {"V_TRIPPED"}, @@ -38,21 +74,23 @@ BINARY_SENSOR_TYPES = { "S_MOISTURE": {"V_TRIPPED"}, } -CLIMATE_TYPES = {"S_HVAC": {"V_HVAC_FLOW_STATE"}} +CLIMATE_TYPES: Dict[SensorType, Set[ValueType]] = {"S_HVAC": {"V_HVAC_FLOW_STATE"}} -COVER_TYPES = {"S_COVER": {"V_DIMMER", "V_PERCENTAGE", "V_LIGHT", "V_STATUS"}} +COVER_TYPES: Dict[SensorType, Set[ValueType]] = { + "S_COVER": {"V_DIMMER", "V_PERCENTAGE", "V_LIGHT", "V_STATUS"} +} -DEVICE_TRACKER_TYPES = {"S_GPS": {"V_POSITION"}} +DEVICE_TRACKER_TYPES: Dict[SensorType, Set[ValueType]] = {"S_GPS": {"V_POSITION"}} -LIGHT_TYPES = { +LIGHT_TYPES: Dict[SensorType, Set[ValueType]] = { "S_DIMMER": {"V_DIMMER", "V_PERCENTAGE"}, "S_RGB_LIGHT": {"V_RGB"}, "S_RGBW_LIGHT": {"V_RGBW"}, } -NOTIFY_TYPES = {"S_INFO": {"V_TEXT"}} +NOTIFY_TYPES: Dict[SensorType, Set[ValueType]] = {"S_INFO": {"V_TEXT"}} -SENSOR_TYPES = { +SENSOR_TYPES: Dict[SensorType, Set[ValueType]] = { "S_SOUND": {"V_LEVEL"}, "S_VIBRATION": {"V_LEVEL"}, "S_MOISTURE": {"V_LEVEL"}, @@ -80,7 +118,7 @@ SENSOR_TYPES = { "S_DUST": {"V_DUST_LEVEL", "V_LEVEL"}, } -SWITCH_TYPES = { +SWITCH_TYPES: Dict[SensorType, Set[ValueType]] = { "S_LIGHT": {"V_LIGHT"}, "S_BINARY": {"V_STATUS"}, "S_DOOR": {"V_ARMED"}, @@ -97,7 +135,7 @@ SWITCH_TYPES = { } -PLATFORM_TYPES = { +PLATFORM_TYPES: Dict[str, Dict[SensorType, Set[ValueType]]] = { "binary_sensor": BINARY_SENSOR_TYPES, "climate": CLIMATE_TYPES, "cover": COVER_TYPES, @@ -108,13 +146,19 @@ PLATFORM_TYPES = { "switch": SWITCH_TYPES, } -FLAT_PLATFORM_TYPES = { +FLAT_PLATFORM_TYPES: Dict[Tuple[str, SensorType], Set[ValueType]] = { (platform, s_type_name): v_type_name for platform, platform_types in PLATFORM_TYPES.items() for s_type_name, v_type_name in platform_types.items() } -TYPE_TO_PLATFORMS = defaultdict(list) +TYPE_TO_PLATFORMS: Dict[SensorType, List[str]] = defaultdict(list) + for platform, platform_types in PLATFORM_TYPES.items(): for s_type_name in platform_types: TYPE_TO_PLATFORMS[s_type_name].append(platform) + +SUPPORTED_PLATFORMS_WITH_ENTRY_SUPPORT = set(PLATFORM_TYPES.keys()) - { + "notify", + "device_tracker", +} diff --git a/homeassistant/components/mysensors/cover.py b/homeassistant/components/mysensors/cover.py index f2ede69793f..782ab88c488 100644 --- a/homeassistant/components/mysensors/cover.py +++ b/homeassistant/components/mysensors/cover.py @@ -1,28 +1,48 @@ """Support for MySensors covers.""" +import logging +from typing import Callable + from homeassistant.components import mysensors from homeassistant.components.cover import ATTR_POSITION, DOMAIN, CoverEntity +from homeassistant.components.mysensors import on_unload +from homeassistant.components.mysensors.const import MYSENSORS_DISCOVERY +from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_OFF, STATE_ON +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.typing import HomeAssistantType + +_LOGGER = logging.getLogger(__name__) -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): - """Set up the mysensors platform for covers.""" - mysensors.setup_mysensors_platform( +async def async_setup_entry( + hass: HomeAssistantType, config_entry: ConfigEntry, async_add_entities: Callable +): + """Set up this platform for a specific ConfigEntry(==Gateway).""" + + async def async_discover(discovery_info): + """Discover and add a MySensors cover.""" + mysensors.setup_mysensors_platform( + hass, + DOMAIN, + discovery_info, + MySensorsCover, + async_add_entities=async_add_entities, + ) + + await on_unload( hass, - DOMAIN, - discovery_info, - MySensorsCover, - async_add_entities=async_add_entities, + config_entry.entry_id, + async_dispatcher_connect( + hass, + MYSENSORS_DISCOVERY.format(config_entry.entry_id, DOMAIN), + async_discover, + ), ) class MySensorsCover(mysensors.device.MySensorsEntity, CoverEntity): """Representation of the value of a MySensors Cover child node.""" - @property - def assumed_state(self): - """Return True if unable to access real state of entity.""" - return self.gateway.optimistic - @property def is_closed(self): """Return True if cover is closed.""" @@ -46,7 +66,7 @@ class MySensorsCover(mysensors.device.MySensorsEntity, CoverEntity): self.gateway.set_child_value( self.node_id, self.child_id, set_req.V_UP, 1, ack=1 ) - if self.gateway.optimistic: + if self.assumed_state: # Optimistically assume that cover has changed state. if set_req.V_DIMMER in self._values: self._values[set_req.V_DIMMER] = 100 @@ -60,7 +80,7 @@ class MySensorsCover(mysensors.device.MySensorsEntity, CoverEntity): self.gateway.set_child_value( self.node_id, self.child_id, set_req.V_DOWN, 1, ack=1 ) - if self.gateway.optimistic: + if self.assumed_state: # Optimistically assume that cover has changed state. if set_req.V_DIMMER in self._values: self._values[set_req.V_DIMMER] = 0 @@ -75,7 +95,7 @@ class MySensorsCover(mysensors.device.MySensorsEntity, CoverEntity): self.gateway.set_child_value( self.node_id, self.child_id, set_req.V_DIMMER, position, ack=1 ) - if self.gateway.optimistic: + if self.assumed_state: # Optimistically assume that cover has changed state. self._values[set_req.V_DIMMER] = position self.async_write_ha_state() diff --git a/homeassistant/components/mysensors/device.py b/homeassistant/components/mysensors/device.py index 9c1c4b54367..68414867345 100644 --- a/homeassistant/components/mysensors/device.py +++ b/homeassistant/components/mysensors/device.py @@ -1,13 +1,26 @@ """Handle MySensors devices.""" from functools import partial import logging +from typing import Any, Dict, Optional + +from mysensors import BaseAsyncGateway, Sensor +from mysensors.sensor import ChildSensor from homeassistant.const import ATTR_BATTERY_LEVEL, STATE_OFF, STATE_ON from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity -from .const import CHILD_CALLBACK, NODE_CALLBACK, UPDATE_DELAY +from .const import ( + CHILD_CALLBACK, + CONF_DEVICE, + DOMAIN, + NODE_CALLBACK, + PLATFORM_TYPES, + UPDATE_DELAY, + DevId, + GatewayId, +) _LOGGER = logging.getLogger(__name__) @@ -19,33 +32,94 @@ ATTR_HEARTBEAT = "heartbeat" MYSENSORS_PLATFORM_DEVICES = "mysensors_devices_{}" -def get_mysensors_devices(hass, domain): - """Return MySensors devices for a platform.""" - if MYSENSORS_PLATFORM_DEVICES.format(domain) not in hass.data: - hass.data[MYSENSORS_PLATFORM_DEVICES.format(domain)] = {} - return hass.data[MYSENSORS_PLATFORM_DEVICES.format(domain)] - - class MySensorsDevice: """Representation of a MySensors device.""" - def __init__(self, gateway, node_id, child_id, name, value_type): + def __init__( + self, + gateway_id: GatewayId, + gateway: BaseAsyncGateway, + node_id: int, + child_id: int, + value_type: int, + ): """Set up the MySensors device.""" - self.gateway = gateway - self.node_id = node_id - self.child_id = child_id - self._name = name - self.value_type = value_type - child = gateway.sensors[node_id].children[child_id] - self.child_type = child.type + self.gateway_id: GatewayId = gateway_id + self.gateway: BaseAsyncGateway = gateway + self.node_id: int = node_id + self.child_id: int = child_id + self.value_type: int = value_type # value_type as int. string variant can be looked up in gateway consts + self.child_type = self._child.type self._values = {} self._update_scheduled = False self.hass = None + @property + def dev_id(self) -> DevId: + """Return the DevId of this device. + + It is used to route incoming MySensors messages to the correct device/entity. + """ + return self.gateway_id, self.node_id, self.child_id, self.value_type + + @property + def _logger(self): + return logging.getLogger(f"{__name__}.{self.name}") + + async def async_will_remove_from_hass(self): + """Remove this entity from home assistant.""" + for platform in PLATFORM_TYPES: + platform_str = MYSENSORS_PLATFORM_DEVICES.format(platform) + if platform_str in self.hass.data[DOMAIN]: + platform_dict = self.hass.data[DOMAIN][platform_str] + if self.dev_id in platform_dict: + del platform_dict[self.dev_id] + self._logger.debug( + "deleted %s from platform %s", self.dev_id, platform + ) + + @property + def _node(self) -> Sensor: + return self.gateway.sensors[self.node_id] + + @property + def _child(self) -> ChildSensor: + return self._node.children[self.child_id] + + @property + def sketch_name(self) -> str: + """Return the name of the sketch running on the whole node (will be the same for several entities!).""" + return self._node.sketch_name + + @property + def sketch_version(self) -> str: + """Return the version of the sketch running on the whole node (will be the same for several entities!).""" + return self._node.sketch_version + + @property + def node_name(self) -> str: + """Name of the whole node (will be the same for several entities!).""" + return f"{self.sketch_name} {self.node_id}" + + @property + def unique_id(self) -> str: + """Return a unique ID for use in home assistant.""" + return f"{self.gateway_id}-{self.node_id}-{self.child_id}-{self.value_type}" + + @property + def device_info(self) -> Optional[Dict[str, Any]]: + """Return a dict that allows home assistant to puzzle all entities belonging to a node together.""" + return { + "identifiers": {(DOMAIN, f"{self.gateway_id}-{self.node_id}")}, + "name": self.node_name, + "manufacturer": DOMAIN, + "sw_version": self.sketch_version, + } + @property def name(self): """Return the name of this entity.""" - return self._name + return f"{self.node_name} {self.child_id}" @property def device_state_attributes(self): @@ -57,9 +131,12 @@ class MySensorsDevice: ATTR_HEARTBEAT: node.heartbeat, ATTR_CHILD_ID: self.child_id, ATTR_DESCRIPTION: child.description, - ATTR_DEVICE: self.gateway.device, ATTR_NODE_ID: self.node_id, } + # This works when we are actually an Entity (i.e. all platforms except device_tracker) + if hasattr(self, "platform"): + # pylint: disable=no-member + attr[ATTR_DEVICE] = self.platform.config_entry.data[CONF_DEVICE] set_req = self.gateway.const.SetReq @@ -76,7 +153,7 @@ class MySensorsDevice: for value_type, value in child.values.items(): _LOGGER.debug( "Entity update: %s: value_type %s, value = %s", - self._name, + self.name, value_type, value, ) @@ -116,6 +193,13 @@ class MySensorsDevice: self.hass.loop.call_later(UPDATE_DELAY, delayed_update) +def get_mysensors_devices(hass, domain: str) -> Dict[DevId, MySensorsDevice]: + """Return MySensors devices for a hass platform name.""" + if MYSENSORS_PLATFORM_DEVICES.format(domain) not in hass.data[DOMAIN]: + hass.data[DOMAIN][MYSENSORS_PLATFORM_DEVICES.format(domain)] = {} + return hass.data[DOMAIN][MYSENSORS_PLATFORM_DEVICES.format(domain)] + + class MySensorsEntity(MySensorsDevice, Entity): """Representation of a MySensors entity.""" @@ -135,17 +219,17 @@ class MySensorsEntity(MySensorsDevice, Entity): async def async_added_to_hass(self): """Register update callback.""" - gateway_id = id(self.gateway) - dev_id = gateway_id, self.node_id, self.child_id, self.value_type self.async_on_remove( async_dispatcher_connect( - self.hass, CHILD_CALLBACK.format(*dev_id), self.async_update_callback + self.hass, + CHILD_CALLBACK.format(*self.dev_id), + self.async_update_callback, ) ) self.async_on_remove( async_dispatcher_connect( self.hass, - NODE_CALLBACK.format(gateway_id, self.node_id), + NODE_CALLBACK.format(self.gateway_id, self.node_id), self.async_update_callback, ) ) diff --git a/homeassistant/components/mysensors/device_tracker.py b/homeassistant/components/mysensors/device_tracker.py index 1bf1e072ceb..b395a48f28b 100644 --- a/homeassistant/components/mysensors/device_tracker.py +++ b/homeassistant/components/mysensors/device_tracker.py @@ -1,11 +1,16 @@ """Support for tracking MySensors devices.""" from homeassistant.components import mysensors from homeassistant.components.device_tracker import DOMAIN +from homeassistant.components.mysensors import DevId, on_unload +from homeassistant.components.mysensors.const import ATTR_GATEWAY_ID, GatewayId from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.typing import HomeAssistantType from homeassistant.util import slugify -async def async_setup_scanner(hass, config, async_see, discovery_info=None): +async def async_setup_scanner( + hass: HomeAssistantType, config, async_see, discovery_info=None +): """Set up the MySensors device scanner.""" new_devices = mysensors.setup_mysensors_platform( hass, @@ -18,17 +23,25 @@ async def async_setup_scanner(hass, config, async_see, discovery_info=None): return False for device in new_devices: - gateway_id = id(device.gateway) - dev_id = (gateway_id, device.node_id, device.child_id, device.value_type) - async_dispatcher_connect( + gateway_id: GatewayId = discovery_info[ATTR_GATEWAY_ID] + dev_id: DevId = (gateway_id, device.node_id, device.child_id, device.value_type) + await on_unload( hass, - mysensors.const.CHILD_CALLBACK.format(*dev_id), - device.async_update_callback, + gateway_id, + async_dispatcher_connect( + hass, + mysensors.const.CHILD_CALLBACK.format(*dev_id), + device.async_update_callback, + ), ) - async_dispatcher_connect( + await on_unload( hass, - mysensors.const.NODE_CALLBACK.format(gateway_id, device.node_id), - device.async_update_callback, + gateway_id, + async_dispatcher_connect( + hass, + mysensors.const.NODE_CALLBACK.format(gateway_id, device.node_id), + device.async_update_callback, + ), ) return True @@ -37,7 +50,7 @@ async def async_setup_scanner(hass, config, async_see, discovery_info=None): class MySensorsDeviceScanner(mysensors.device.MySensorsDevice): """Represent a MySensors scanner.""" - def __init__(self, hass, async_see, *args): + def __init__(self, hass: HomeAssistantType, async_see, *args): """Set up instance.""" super().__init__(*args) self.async_see = async_see diff --git a/homeassistant/components/mysensors/gateway.py b/homeassistant/components/mysensors/gateway.py index f9450b798ac..b618004b622 100644 --- a/homeassistant/components/mysensors/gateway.py +++ b/homeassistant/components/mysensors/gateway.py @@ -4,22 +4,21 @@ from collections import defaultdict import logging import socket import sys +from typing import Any, Callable, Coroutine, Dict, Optional import async_timeout -from mysensors import mysensors +from mysensors import BaseAsyncGateway, Message, Sensor, mysensors import voluptuous as vol -from homeassistant.const import CONF_OPTIMISTIC, EVENT_HOMEASSISTANT_STOP -from homeassistant.core import callback +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EVENT_HOMEASSISTANT_STOP +from homeassistant.core import Event, callback import homeassistant.helpers.config_validation as cv -from homeassistant.setup import async_setup_component +from homeassistant.helpers.typing import HomeAssistantType from .const import ( CONF_BAUD_RATE, CONF_DEVICE, - CONF_GATEWAYS, - CONF_NODES, - CONF_PERSISTENCE, CONF_PERSISTENCE_FILE, CONF_RETAIN, CONF_TCP_PORT, @@ -28,7 +27,9 @@ from .const import ( CONF_VERSION, DOMAIN, MYSENSORS_GATEWAY_READY, + MYSENSORS_GATEWAY_START_TASK, MYSENSORS_GATEWAYS, + GatewayId, ) from .handler import HANDLERS from .helpers import discover_mysensors_platform, validate_child, validate_node @@ -58,48 +59,114 @@ def is_socket_address(value): raise vol.Invalid("Device is not a valid domain name or ip address") from err -def get_mysensors_gateway(hass, gateway_id): - """Return MySensors gateway.""" - if MYSENSORS_GATEWAYS not in hass.data: - hass.data[MYSENSORS_GATEWAYS] = {} - gateways = hass.data.get(MYSENSORS_GATEWAYS) - return gateways.get(gateway_id) - +async def try_connect(hass: HomeAssistantType, user_input: Dict[str, str]) -> bool: + """Try to connect to a gateway and report if it worked.""" + if user_input[CONF_DEVICE] == MQTT_COMPONENT: + return True # dont validate mqtt. mqtt gateways dont send ready messages :( + try: + gateway_ready = asyncio.Future() + + def gateway_ready_callback(msg): + msg_type = msg.gateway.const.MessageType(msg.type) + _LOGGER.debug("Received MySensors msg type %s: %s", msg_type.name, msg) + if msg_type.name != "internal": + return + internal = msg.gateway.const.Internal(msg.sub_type) + if internal.name != "I_GATEWAY_READY": + return + _LOGGER.debug("Received gateway ready") + gateway_ready.set_result(True) + + gateway: Optional[BaseAsyncGateway] = await _get_gateway( + hass, + device=user_input[CONF_DEVICE], + version=user_input[CONF_VERSION], + event_callback=gateway_ready_callback, + persistence_file=None, + baud_rate=user_input.get(CONF_BAUD_RATE), + tcp_port=user_input.get(CONF_TCP_PORT), + topic_in_prefix=None, + topic_out_prefix=None, + retain=False, + persistence=False, + ) + if gateway is None: + return False -async def setup_gateways(hass, config): - """Set up all gateways.""" - conf = config[DOMAIN] - gateways = {} + connect_task = None + try: + connect_task = asyncio.create_task(gateway.start()) + with async_timeout.timeout(5): + await gateway_ready + return True + except asyncio.TimeoutError: + _LOGGER.info("Try gateway connect failed with timeout") + return False + finally: + if connect_task is not None and not connect_task.done(): + connect_task.cancel() + asyncio.create_task(gateway.stop()) + except OSError as err: + _LOGGER.info("Try gateway connect failed with exception", exc_info=err) + return False - for index, gateway_conf in enumerate(conf[CONF_GATEWAYS]): - persistence_file = gateway_conf.get( - CONF_PERSISTENCE_FILE, - hass.config.path(f"mysensors{index + 1}.pickle"), - ) - ready_gateway = await _get_gateway(hass, config, gateway_conf, persistence_file) - if ready_gateway is not None: - gateways[id(ready_gateway)] = ready_gateway - return gateways +def get_mysensors_gateway( + hass: HomeAssistantType, gateway_id: GatewayId +) -> Optional[BaseAsyncGateway]: + """Return the Gateway for a given GatewayId.""" + if MYSENSORS_GATEWAYS not in hass.data[DOMAIN]: + hass.data[DOMAIN][MYSENSORS_GATEWAYS] = {} + gateways = hass.data[DOMAIN].get(MYSENSORS_GATEWAYS) + return gateways.get(gateway_id) -async def _get_gateway(hass, config, gateway_conf, persistence_file): +async def setup_gateway( + hass: HomeAssistantType, entry: ConfigEntry +) -> Optional[BaseAsyncGateway]: + """Set up the Gateway for the given ConfigEntry.""" + + ready_gateway = await _get_gateway( + hass, + device=entry.data[CONF_DEVICE], + version=entry.data[CONF_VERSION], + event_callback=_gw_callback_factory(hass, entry.entry_id), + persistence_file=entry.data.get( + CONF_PERSISTENCE_FILE, f"mysensors_{entry.entry_id}.json" + ), + baud_rate=entry.data.get(CONF_BAUD_RATE), + tcp_port=entry.data.get(CONF_TCP_PORT), + topic_in_prefix=entry.data.get(CONF_TOPIC_IN_PREFIX), + topic_out_prefix=entry.data.get(CONF_TOPIC_OUT_PREFIX), + retain=entry.data.get(CONF_RETAIN, False), + ) + return ready_gateway + + +async def _get_gateway( + hass: HomeAssistantType, + device: str, + version: str, + event_callback: Callable[[Message], None], + persistence_file: Optional[str] = None, + baud_rate: Optional[int] = None, + tcp_port: Optional[int] = None, + topic_in_prefix: Optional[str] = None, + topic_out_prefix: Optional[str] = None, + retain: bool = False, + persistence: bool = True, # old persistence option has been deprecated. kwarg is here so we can run try_connect() without persistence +) -> Optional[BaseAsyncGateway]: """Return gateway after setup of the gateway.""" - conf = config[DOMAIN] - persistence = conf[CONF_PERSISTENCE] - version = conf[CONF_VERSION] - device = gateway_conf[CONF_DEVICE] - baud_rate = gateway_conf[CONF_BAUD_RATE] - tcp_port = gateway_conf[CONF_TCP_PORT] - in_prefix = gateway_conf.get(CONF_TOPIC_IN_PREFIX, "") - out_prefix = gateway_conf.get(CONF_TOPIC_OUT_PREFIX, "") + if persistence_file is not None: + # interpret relative paths to be in hass config folder. absolute paths will be left as they are + persistence_file = hass.config.path(persistence_file) if device == MQTT_COMPONENT: - if not await async_setup_component(hass, MQTT_COMPONENT, config): - return None + # what is the purpose of this? + # if not await async_setup_component(hass, MQTT_COMPONENT, entry): + # return None mqtt = hass.components.mqtt - retain = conf[CONF_RETAIN] def pub_callback(topic, payload, qos, retain): """Call MQTT publish function.""" @@ -118,8 +185,8 @@ async def _get_gateway(hass, config, gateway_conf, persistence_file): gateway = mysensors.AsyncMQTTGateway( pub_callback, sub_callback, - in_prefix=in_prefix, - out_prefix=out_prefix, + in_prefix=topic_in_prefix, + out_prefix=topic_out_prefix, retain=retain, loop=hass.loop, event_callback=None, @@ -154,25 +221,23 @@ async def _get_gateway(hass, config, gateway_conf, persistence_file): ) except vol.Invalid: # invalid ip address + _LOGGER.error("Connect failed: Invalid device %s", device) return None - gateway.metric = hass.config.units.is_metric - gateway.optimistic = conf[CONF_OPTIMISTIC] - gateway.device = device - gateway.event_callback = _gw_callback_factory(hass, config) - gateway.nodes_config = gateway_conf[CONF_NODES] + gateway.event_callback = event_callback if persistence: await gateway.start_persistence() return gateway -async def finish_setup(hass, hass_config, gateways): +async def finish_setup( + hass: HomeAssistantType, entry: ConfigEntry, gateway: BaseAsyncGateway +): """Load any persistent devices and platforms and start gateway.""" discover_tasks = [] start_tasks = [] - for gateway in gateways.values(): - discover_tasks.append(_discover_persistent_devices(hass, hass_config, gateway)) - start_tasks.append(_gw_start(hass, gateway)) + discover_tasks.append(_discover_persistent_devices(hass, entry, gateway)) + start_tasks.append(_gw_start(hass, entry, gateway)) if discover_tasks: # Make sure all devices and platforms are loaded before gateway start. await asyncio.wait(discover_tasks) @@ -180,43 +245,58 @@ async def finish_setup(hass, hass_config, gateways): await asyncio.wait(start_tasks) -async def _discover_persistent_devices(hass, hass_config, gateway): +async def _discover_persistent_devices( + hass: HomeAssistantType, entry: ConfigEntry, gateway: BaseAsyncGateway +): """Discover platforms for devices loaded via persistence file.""" tasks = [] new_devices = defaultdict(list) for node_id in gateway.sensors: if not validate_node(gateway, node_id): continue - node = gateway.sensors[node_id] - for child in node.children.values(): - validated = validate_child(gateway, node_id, child) + node: Sensor = gateway.sensors[node_id] + for child in node.children.values(): # child is of type ChildSensor + validated = validate_child(entry.entry_id, gateway, node_id, child) for platform, dev_ids in validated.items(): new_devices[platform].extend(dev_ids) + _LOGGER.debug("discovering persistent devices: %s", new_devices) for platform, dev_ids in new_devices.items(): - tasks.append(discover_mysensors_platform(hass, hass_config, platform, dev_ids)) + discover_mysensors_platform(hass, entry.entry_id, platform, dev_ids) if tasks: await asyncio.wait(tasks) -async def _gw_start(hass, gateway): +async def gw_stop(hass, entry: ConfigEntry, gateway: BaseAsyncGateway): + """Stop the gateway.""" + connect_task = hass.data[DOMAIN].get( + MYSENSORS_GATEWAY_START_TASK.format(entry.entry_id) + ) + if connect_task is not None and not connect_task.done(): + connect_task.cancel() + await gateway.stop() + + +async def _gw_start( + hass: HomeAssistantType, entry: ConfigEntry, gateway: BaseAsyncGateway +): """Start the gateway.""" # Don't use hass.async_create_task to avoid holding up setup indefinitely. - connect_task = hass.loop.create_task(gateway.start()) + hass.data[DOMAIN][ + MYSENSORS_GATEWAY_START_TASK.format(entry.entry_id) + ] = asyncio.create_task( + gateway.start() + ) # store the connect task so it can be cancelled in gw_stop - @callback - def gw_stop(event): - """Trigger to stop the gateway.""" - hass.async_create_task(gateway.stop()) - if not connect_task.done(): - connect_task.cancel() - - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, gw_stop) - if gateway.device == "mqtt": + async def stop_this_gw(_: Event): + await gw_stop(hass, entry, gateway) + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, stop_this_gw) + if entry.data[CONF_DEVICE] == MQTT_COMPONENT: # Gatways connected via mqtt doesn't send gateway ready message. return gateway_ready = asyncio.Future() - gateway_ready_key = MYSENSORS_GATEWAY_READY.format(id(gateway)) - hass.data[gateway_ready_key] = gateway_ready + gateway_ready_key = MYSENSORS_GATEWAY_READY.format(entry.entry_id) + hass.data[DOMAIN][gateway_ready_key] = gateway_ready try: with async_timeout.timeout(GATEWAY_READY_TIMEOUT): @@ -224,27 +304,35 @@ async def _gw_start(hass, gateway): except asyncio.TimeoutError: _LOGGER.warning( "Gateway %s not ready after %s secs so continuing with setup", - gateway.device, + entry.data[CONF_DEVICE], GATEWAY_READY_TIMEOUT, ) finally: - hass.data.pop(gateway_ready_key, None) + hass.data[DOMAIN].pop(gateway_ready_key, None) -def _gw_callback_factory(hass, hass_config): +def _gw_callback_factory( + hass: HomeAssistantType, gateway_id: GatewayId +) -> Callable[[Message], None]: """Return a new callback for the gateway.""" @callback - def mysensors_callback(msg): - """Handle messages from a MySensors gateway.""" + def mysensors_callback(msg: Message): + """Handle messages from a MySensors gateway. + + All MySenors messages are received here. + The messages are passed to handler functions depending on their type. + """ _LOGGER.debug("Node update: node %s child %s", msg.node_id, msg.child_id) msg_type = msg.gateway.const.MessageType(msg.type) - msg_handler = HANDLERS.get(msg_type.name) + msg_handler: Callable[ + [Any, GatewayId, Message], Coroutine[None] + ] = HANDLERS.get(msg_type.name) if msg_handler is None: return - hass.async_create_task(msg_handler(hass, hass_config, msg)) + hass.async_create_task(msg_handler(hass, gateway_id, msg)) return mysensors_callback diff --git a/homeassistant/components/mysensors/handler.py b/homeassistant/components/mysensors/handler.py index b5b8b511aee..10165a171e0 100644 --- a/homeassistant/components/mysensors/handler.py +++ b/homeassistant/components/mysensors/handler.py @@ -1,9 +1,21 @@ """Handle MySensors messages.""" +from typing import Dict, List + +from mysensors import Message + from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.typing import HomeAssistantType from homeassistant.util import decorator -from .const import CHILD_CALLBACK, MYSENSORS_GATEWAY_READY, NODE_CALLBACK +from .const import ( + CHILD_CALLBACK, + DOMAIN, + MYSENSORS_GATEWAY_READY, + NODE_CALLBACK, + DevId, + GatewayId, +) from .device import get_mysensors_devices from .helpers import discover_mysensors_platform, validate_set_msg @@ -11,75 +23,91 @@ HANDLERS = decorator.Registry() @HANDLERS.register("set") -async def handle_set(hass, hass_config, msg): +async def handle_set( + hass: HomeAssistantType, gateway_id: GatewayId, msg: Message +) -> None: """Handle a mysensors set message.""" - validated = validate_set_msg(msg) - _handle_child_update(hass, hass_config, validated) + validated = validate_set_msg(gateway_id, msg) + _handle_child_update(hass, gateway_id, validated) @HANDLERS.register("internal") -async def handle_internal(hass, hass_config, msg): +async def handle_internal( + hass: HomeAssistantType, gateway_id: GatewayId, msg: Message +) -> None: """Handle a mysensors internal message.""" internal = msg.gateway.const.Internal(msg.sub_type) handler = HANDLERS.get(internal.name) if handler is None: return - await handler(hass, hass_config, msg) + await handler(hass, gateway_id, msg) @HANDLERS.register("I_BATTERY_LEVEL") -async def handle_battery_level(hass, hass_config, msg): +async def handle_battery_level( + hass: HomeAssistantType, gateway_id: GatewayId, msg: Message +) -> None: """Handle an internal battery level message.""" - _handle_node_update(hass, msg) + _handle_node_update(hass, gateway_id, msg) @HANDLERS.register("I_HEARTBEAT_RESPONSE") -async def handle_heartbeat(hass, hass_config, msg): +async def handle_heartbeat( + hass: HomeAssistantType, gateway_id: GatewayId, msg: Message +) -> None: """Handle an heartbeat.""" - _handle_node_update(hass, msg) + _handle_node_update(hass, gateway_id, msg) @HANDLERS.register("I_SKETCH_NAME") -async def handle_sketch_name(hass, hass_config, msg): +async def handle_sketch_name( + hass: HomeAssistantType, gateway_id: GatewayId, msg: Message +) -> None: """Handle an internal sketch name message.""" - _handle_node_update(hass, msg) + _handle_node_update(hass, gateway_id, msg) @HANDLERS.register("I_SKETCH_VERSION") -async def handle_sketch_version(hass, hass_config, msg): +async def handle_sketch_version( + hass: HomeAssistantType, gateway_id: GatewayId, msg: Message +) -> None: """Handle an internal sketch version message.""" - _handle_node_update(hass, msg) + _handle_node_update(hass, gateway_id, msg) @HANDLERS.register("I_GATEWAY_READY") -async def handle_gateway_ready(hass, hass_config, msg): +async def handle_gateway_ready( + hass: HomeAssistantType, gateway_id: GatewayId, msg: Message +) -> None: """Handle an internal gateway ready message. Set asyncio future result if gateway is ready. """ - gateway_ready = hass.data.get(MYSENSORS_GATEWAY_READY.format(id(msg.gateway))) + gateway_ready = hass.data[DOMAIN].get(MYSENSORS_GATEWAY_READY.format(gateway_id)) if gateway_ready is None or gateway_ready.cancelled(): return gateway_ready.set_result(True) @callback -def _handle_child_update(hass, hass_config, validated): +def _handle_child_update( + hass: HomeAssistantType, gateway_id: GatewayId, validated: Dict[str, List[DevId]] +): """Handle a child update.""" - signals = [] + signals: List[str] = [] # Update all platforms for the device via dispatcher. # Add/update entity for validated children. for platform, dev_ids in validated.items(): devices = get_mysensors_devices(hass, platform) - new_dev_ids = [] + new_dev_ids: List[DevId] = [] for dev_id in dev_ids: if dev_id in devices: signals.append(CHILD_CALLBACK.format(*dev_id)) else: new_dev_ids.append(dev_id) if new_dev_ids: - discover_mysensors_platform(hass, hass_config, platform, new_dev_ids) + discover_mysensors_platform(hass, gateway_id, platform, new_dev_ids) for signal in set(signals): # Only one signal per device is needed. # A device can have multiple platforms, ie multiple schemas. @@ -87,7 +115,7 @@ def _handle_child_update(hass, hass_config, validated): @callback -def _handle_node_update(hass, msg): +def _handle_node_update(hass: HomeAssistantType, gateway_id: GatewayId, msg: Message): """Handle a node update.""" - signal = NODE_CALLBACK.format(id(msg.gateway), msg.node_id) + signal = NODE_CALLBACK.format(gateway_id, msg.node_id) async_dispatcher_send(hass, signal) diff --git a/homeassistant/components/mysensors/helpers.py b/homeassistant/components/mysensors/helpers.py index 20b266e550e..d06bf0dee2f 100644 --- a/homeassistant/components/mysensors/helpers.py +++ b/homeassistant/components/mysensors/helpers.py @@ -1,78 +1,109 @@ """Helper functions for mysensors package.""" from collections import defaultdict +from enum import IntEnum import logging +from typing import DefaultDict, Dict, List, Optional, Set +from mysensors import BaseAsyncGateway, Message +from mysensors.sensor import ChildSensor import voluptuous as vol from homeassistant.const import CONF_NAME from homeassistant.core import callback -from homeassistant.helpers import discovery import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.util.decorator import Registry -from .const import ATTR_DEVICES, DOMAIN, FLAT_PLATFORM_TYPES, TYPE_TO_PLATFORMS +from .const import ( + ATTR_DEVICES, + ATTR_GATEWAY_ID, + DOMAIN, + FLAT_PLATFORM_TYPES, + MYSENSORS_DISCOVERY, + TYPE_TO_PLATFORMS, + DevId, + GatewayId, + SensorType, + ValueType, +) _LOGGER = logging.getLogger(__name__) SCHEMAS = Registry() @callback -def discover_mysensors_platform(hass, hass_config, platform, new_devices): +def discover_mysensors_platform( + hass, gateway_id: GatewayId, platform: str, new_devices: List[DevId] +) -> None: """Discover a MySensors platform.""" - task = hass.async_create_task( - discovery.async_load_platform( - hass, - platform, - DOMAIN, - {ATTR_DEVICES: new_devices, CONF_NAME: DOMAIN}, - hass_config, - ) + _LOGGER.debug("Discovering platform %s with devIds: %s", platform, new_devices) + async_dispatcher_send( + hass, + MYSENSORS_DISCOVERY.format(gateway_id, platform), + { + ATTR_DEVICES: new_devices, + CONF_NAME: DOMAIN, + ATTR_GATEWAY_ID: gateway_id, + }, ) - return task -def default_schema(gateway, child, value_type_name): +def default_schema( + gateway: BaseAsyncGateway, child: ChildSensor, value_type_name: ValueType +) -> vol.Schema: """Return a default validation schema for value types.""" schema = {value_type_name: cv.string} return get_child_schema(gateway, child, value_type_name, schema) @SCHEMAS.register(("light", "V_DIMMER")) -def light_dimmer_schema(gateway, child, value_type_name): +def light_dimmer_schema( + gateway: BaseAsyncGateway, child: ChildSensor, value_type_name: ValueType +) -> vol.Schema: """Return a validation schema for V_DIMMER.""" schema = {"V_DIMMER": cv.string, "V_LIGHT": cv.string} return get_child_schema(gateway, child, value_type_name, schema) @SCHEMAS.register(("light", "V_PERCENTAGE")) -def light_percentage_schema(gateway, child, value_type_name): +def light_percentage_schema( + gateway: BaseAsyncGateway, child: ChildSensor, value_type_name: ValueType +) -> vol.Schema: """Return a validation schema for V_PERCENTAGE.""" schema = {"V_PERCENTAGE": cv.string, "V_STATUS": cv.string} return get_child_schema(gateway, child, value_type_name, schema) @SCHEMAS.register(("light", "V_RGB")) -def light_rgb_schema(gateway, child, value_type_name): +def light_rgb_schema( + gateway: BaseAsyncGateway, child: ChildSensor, value_type_name: ValueType +) -> vol.Schema: """Return a validation schema for V_RGB.""" schema = {"V_RGB": cv.string, "V_STATUS": cv.string} return get_child_schema(gateway, child, value_type_name, schema) @SCHEMAS.register(("light", "V_RGBW")) -def light_rgbw_schema(gateway, child, value_type_name): +def light_rgbw_schema( + gateway: BaseAsyncGateway, child: ChildSensor, value_type_name: ValueType +) -> vol.Schema: """Return a validation schema for V_RGBW.""" schema = {"V_RGBW": cv.string, "V_STATUS": cv.string} return get_child_schema(gateway, child, value_type_name, schema) @SCHEMAS.register(("switch", "V_IR_SEND")) -def switch_ir_send_schema(gateway, child, value_type_name): +def switch_ir_send_schema( + gateway: BaseAsyncGateway, child: ChildSensor, value_type_name: ValueType +) -> vol.Schema: """Return a validation schema for V_IR_SEND.""" schema = {"V_IR_SEND": cv.string, "V_LIGHT": cv.string} return get_child_schema(gateway, child, value_type_name, schema) -def get_child_schema(gateway, child, value_type_name, schema): +def get_child_schema( + gateway: BaseAsyncGateway, child: ChildSensor, value_type_name: ValueType, schema +) -> vol.Schema: """Return a child schema.""" set_req = gateway.const.SetReq child_schema = child.get_schema(gateway.protocol_version) @@ -88,7 +119,9 @@ def get_child_schema(gateway, child, value_type_name, schema): return schema -def invalid_msg(gateway, child, value_type_name): +def invalid_msg( + gateway: BaseAsyncGateway, child: ChildSensor, value_type_name: ValueType +): """Return a message for an invalid child during schema validation.""" pres = gateway.const.Presentation set_req = gateway.const.SetReq @@ -97,15 +130,15 @@ def invalid_msg(gateway, child, value_type_name): ) -def validate_set_msg(msg): +def validate_set_msg(gateway_id: GatewayId, msg: Message) -> Dict[str, List[DevId]]: """Validate a set message.""" if not validate_node(msg.gateway, msg.node_id): return {} child = msg.gateway.sensors[msg.node_id].children[msg.child_id] - return validate_child(msg.gateway, msg.node_id, child, msg.sub_type) + return validate_child(gateway_id, msg.gateway, msg.node_id, child, msg.sub_type) -def validate_node(gateway, node_id): +def validate_node(gateway: BaseAsyncGateway, node_id: int) -> bool: """Validate a node.""" if gateway.sensors[node_id].sketch_name is None: _LOGGER.debug("Node %s is missing sketch name", node_id) @@ -113,31 +146,39 @@ def validate_node(gateway, node_id): return True -def validate_child(gateway, node_id, child, value_type=None): - """Validate a child.""" - validated = defaultdict(list) - pres = gateway.const.Presentation - set_req = gateway.const.SetReq - child_type_name = next( +def validate_child( + gateway_id: GatewayId, + gateway: BaseAsyncGateway, + node_id: int, + child: ChildSensor, + value_type: Optional[int] = None, +) -> DefaultDict[str, List[DevId]]: + """Validate a child. Returns a dict mapping hass platform names to list of DevId.""" + validated: DefaultDict[str, List[DevId]] = defaultdict(list) + pres: IntEnum = gateway.const.Presentation + set_req: IntEnum = gateway.const.SetReq + child_type_name: Optional[SensorType] = next( (member.name for member in pres if member.value == child.type), None ) - value_types = {value_type} if value_type else {*child.values} - value_type_names = { + value_types: Set[int] = {value_type} if value_type else {*child.values} + value_type_names: Set[ValueType] = { member.name for member in set_req if member.value in value_types } - platforms = TYPE_TO_PLATFORMS.get(child_type_name, []) + platforms: List[str] = TYPE_TO_PLATFORMS.get(child_type_name, []) if not platforms: _LOGGER.warning("Child type %s is not supported", child.type) return validated for platform in platforms: - platform_v_names = FLAT_PLATFORM_TYPES[platform, child_type_name] - v_names = platform_v_names & value_type_names + platform_v_names: Set[ValueType] = FLAT_PLATFORM_TYPES[ + platform, child_type_name + ] + v_names: Set[ValueType] = platform_v_names & value_type_names if not v_names: - child_value_names = { + child_value_names: Set[ValueType] = { member.name for member in set_req if member.value in child.values } - v_names = platform_v_names & child_value_names + v_names: Set[ValueType] = platform_v_names & child_value_names for v_name in v_names: child_schema_gen = SCHEMAS.get((platform, v_name), default_schema) @@ -153,7 +194,12 @@ def validate_child(gateway, node_id, child, value_type=None): exc, ) continue - dev_id = id(gateway), node_id, child.id, set_req[v_name].value + dev_id: DevId = ( + gateway_id, + node_id, + child.id, + set_req[v_name].value, + ) validated[platform].append(dev_id) return validated diff --git a/homeassistant/components/mysensors/light.py b/homeassistant/components/mysensors/light.py index ffbcba6f032..f90f9c5c81c 100644 --- a/homeassistant/components/mysensors/light.py +++ b/homeassistant/components/mysensors/light.py @@ -1,4 +1,6 @@ """Support for MySensors lights.""" +from typing import Callable + from homeassistant.components import mysensors from homeassistant.components.light import ( ATTR_BRIGHTNESS, @@ -10,27 +12,47 @@ from homeassistant.components.light import ( SUPPORT_WHITE_VALUE, LightEntity, ) +from homeassistant.components.mysensors import on_unload +from homeassistant.components.mysensors.const import MYSENSORS_DISCOVERY +from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.typing import HomeAssistantType import homeassistant.util.color as color_util from homeassistant.util.color import rgb_hex_to_rgb_list SUPPORT_MYSENSORS_RGBW = SUPPORT_COLOR | SUPPORT_WHITE_VALUE -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): - """Set up the mysensors platform for lights.""" +async def async_setup_entry( + hass: HomeAssistantType, config_entry: ConfigEntry, async_add_entities: Callable +): + """Set up this platform for a specific ConfigEntry(==Gateway).""" device_class_map = { "S_DIMMER": MySensorsLightDimmer, "S_RGB_LIGHT": MySensorsLightRGB, "S_RGBW_LIGHT": MySensorsLightRGBW, } - mysensors.setup_mysensors_platform( + + async def async_discover(discovery_info): + """Discover and add a MySensors light.""" + mysensors.setup_mysensors_platform( + hass, + DOMAIN, + discovery_info, + device_class_map, + async_add_entities=async_add_entities, + ) + + await on_unload( hass, - DOMAIN, - discovery_info, - device_class_map, - async_add_entities=async_add_entities, + config_entry, + async_dispatcher_connect( + hass, + MYSENSORS_DISCOVERY.format(config_entry.entry_id, DOMAIN), + async_discover, + ), ) @@ -60,11 +82,6 @@ class MySensorsLight(mysensors.device.MySensorsEntity, LightEntity): """Return the white value of this light between 0..255.""" return self._white - @property - def assumed_state(self): - """Return true if unable to access real state of entity.""" - return self.gateway.optimistic - @property def is_on(self): """Return true if device is on.""" @@ -80,7 +97,7 @@ class MySensorsLight(mysensors.device.MySensorsEntity, LightEntity): self.node_id, self.child_id, set_req.V_LIGHT, 1, ack=1 ) - if self.gateway.optimistic: + if self.assumed_state: # optimistically assume that light has changed state self._state = True self._values[set_req.V_LIGHT] = STATE_ON @@ -102,7 +119,7 @@ class MySensorsLight(mysensors.device.MySensorsEntity, LightEntity): self.node_id, self.child_id, set_req.V_DIMMER, percent, ack=1 ) - if self.gateway.optimistic: + if self.assumed_state: # optimistically assume that light has changed state self._brightness = brightness self._values[set_req.V_DIMMER] = percent @@ -135,7 +152,7 @@ class MySensorsLight(mysensors.device.MySensorsEntity, LightEntity): self.node_id, self.child_id, self.value_type, hex_color, ack=1 ) - if self.gateway.optimistic: + if self.assumed_state: # optimistically assume that light has changed state self._hs = color_util.color_RGB_to_hs(*rgb) self._white = white @@ -145,7 +162,7 @@ class MySensorsLight(mysensors.device.MySensorsEntity, LightEntity): """Turn the device off.""" value_type = self.gateway.const.SetReq.V_LIGHT self.gateway.set_child_value(self.node_id, self.child_id, value_type, 0, ack=1) - if self.gateway.optimistic: + if self.assumed_state: # optimistically assume that light has changed state self._state = False self._values[value_type] = STATE_OFF @@ -188,7 +205,7 @@ class MySensorsLightDimmer(MySensorsLight): """Turn the device on.""" self._turn_on_light() self._turn_on_dimmer(**kwargs) - if self.gateway.optimistic: + if self.assumed_state: self.async_write_ha_state() async def async_update(self): @@ -214,7 +231,7 @@ class MySensorsLightRGB(MySensorsLight): self._turn_on_light() self._turn_on_dimmer(**kwargs) self._turn_on_rgb_and_w("%02x%02x%02x", **kwargs) - if self.gateway.optimistic: + if self.assumed_state: self.async_write_ha_state() async def async_update(self): @@ -241,5 +258,5 @@ class MySensorsLightRGBW(MySensorsLightRGB): self._turn_on_light() self._turn_on_dimmer(**kwargs) self._turn_on_rgb_and_w("%02x%02x%02x%02x", **kwargs) - if self.gateway.optimistic: + if self.assumed_state: self.async_write_ha_state() diff --git a/homeassistant/components/mysensors/manifest.json b/homeassistant/components/mysensors/manifest.json index afeeb5d57cc..8371f2930c2 100644 --- a/homeassistant/components/mysensors/manifest.json +++ b/homeassistant/components/mysensors/manifest.json @@ -2,7 +2,15 @@ "domain": "mysensors", "name": "MySensors", "documentation": "https://www.home-assistant.io/integrations/mysensors", - "requirements": ["pymysensors==0.18.0"], - "after_dependencies": ["mqtt"], - "codeowners": ["@MartinHjelmare"] + "requirements": [ + "pymysensors==0.20.1" + ], + "after_dependencies": [ + "mqtt" + ], + "codeowners": [ + "@MartinHjelmare", + "@functionpointer" + ], + "config_flow": true } diff --git a/homeassistant/components/mysensors/sensor.py b/homeassistant/components/mysensors/sensor.py index bab6bf3fc40..a09f8af1394 100644 --- a/homeassistant/components/mysensors/sensor.py +++ b/homeassistant/components/mysensors/sensor.py @@ -1,6 +1,11 @@ """Support for MySensors sensors.""" +from typing import Callable + from homeassistant.components import mysensors +from homeassistant.components.mysensors import on_unload +from homeassistant.components.mysensors.const import MYSENSORS_DISCOVERY from homeassistant.components.sensor import DOMAIN +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONDUCTIVITY, DEGREE, @@ -18,6 +23,8 @@ from homeassistant.const import ( VOLT, VOLUME_CUBIC_METERS, ) +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.typing import HomeAssistantType SENSORS = { "V_TEMP": [None, "mdi:thermometer"], @@ -54,14 +61,29 @@ SENSORS = { } -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): - """Set up the MySensors platform for sensors.""" - mysensors.setup_mysensors_platform( +async def async_setup_entry( + hass: HomeAssistantType, config_entry: ConfigEntry, async_add_entities: Callable +): + """Set up this platform for a specific ConfigEntry(==Gateway).""" + + async def async_discover(discovery_info): + """Discover and add a MySensors sensor.""" + mysensors.setup_mysensors_platform( + hass, + DOMAIN, + discovery_info, + MySensorsSensor, + async_add_entities=async_add_entities, + ) + + await on_unload( hass, - DOMAIN, - discovery_info, - MySensorsSensor, - async_add_entities=async_add_entities, + config_entry, + async_dispatcher_connect( + hass, + MYSENSORS_DISCOVERY.format(config_entry.entry_id, DOMAIN), + async_discover, + ), ) @@ -105,7 +127,7 @@ class MySensorsSensor(mysensors.device.MySensorsEntity): pres = self.gateway.const.Presentation set_req = self.gateway.const.SetReq SENSORS[set_req.V_TEMP.name][0] = ( - TEMP_CELSIUS if self.gateway.metric else TEMP_FAHRENHEIT + TEMP_CELSIUS if self.hass.config.units.is_metric else TEMP_FAHRENHEIT ) sensor_type = SENSORS.get(set_req(self.value_type).name, [None, None]) if isinstance(sensor_type, dict): diff --git a/homeassistant/components/mysensors/strings.json b/homeassistant/components/mysensors/strings.json new file mode 100644 index 00000000000..43a68f61e24 --- /dev/null +++ b/homeassistant/components/mysensors/strings.json @@ -0,0 +1,79 @@ +{ + "title": "MySensors", + "config": { + "step": { + "user": { + "data": { + "gateway_type": "Gateway type" + }, + "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)" + } + }, + "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)" + } + } + }, + "error": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "invalid_subscribe_topic": "Invalid subscribe topic", + "invalid_publish_topic": "Invalid publish topic", + "duplicate_topic": "Topic already in use", + "same_topic": "Subscribe and publish topics are the same", + "invalid_port": "Invalid port number", + "invalid_persistence_file": "Invalid persistence file", + "duplicate_persistence_file": "Persistence file already in use", + "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", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "invalid_subscribe_topic": "Invalid subscribe topic", + "invalid_publish_topic": "Invalid publish topic", + "duplicate_topic": "Topic already in use", + "same_topic": "Subscribe and publish topics are the same", + "invalid_port": "Invalid port number", + "invalid_persistence_file": "Invalid persistence file", + "duplicate_persistence_file": "Persistence file already in use", + "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", + "unknown": "[%key:common::config_flow::error::unknown%]" + } + } +} diff --git a/homeassistant/components/mysensors/switch.py b/homeassistant/components/mysensors/switch.py index 0da8bfe7030..14911e11090 100644 --- a/homeassistant/components/mysensors/switch.py +++ b/homeassistant/components/mysensors/switch.py @@ -1,4 +1,6 @@ """Support for MySensors switches.""" +from typing import Callable + import voluptuous as vol from homeassistant.components import mysensors @@ -6,7 +8,11 @@ from homeassistant.components.switch import DOMAIN, SwitchEntity from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON import homeassistant.helpers.config_validation as cv -from .const import DOMAIN as MYSENSORS_DOMAIN, SERVICE_SEND_IR_CODE +from . import on_unload +from ...config_entries import ConfigEntry +from ...helpers.dispatcher import async_dispatcher_connect +from ...helpers.typing import HomeAssistantType +from .const import DOMAIN as MYSENSORS_DOMAIN, MYSENSORS_DISCOVERY, SERVICE_SEND_IR_CODE ATTR_IR_CODE = "V_IR_SEND" @@ -15,8 +21,10 @@ SEND_IR_CODE_SERVICE_SCHEMA = vol.Schema( ) -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): - """Set up the mysensors platform for switches.""" +async def async_setup_entry( + hass: HomeAssistantType, config_entry: ConfigEntry, async_add_entities: Callable +): + """Set up this platform for a specific ConfigEntry(==Gateway).""" device_class_map = { "S_DOOR": MySensorsSwitch, "S_MOTION": MySensorsSwitch, @@ -32,13 +40,16 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= "S_MOISTURE": MySensorsSwitch, "S_WATER_QUALITY": MySensorsSwitch, } - mysensors.setup_mysensors_platform( - hass, - DOMAIN, - discovery_info, - device_class_map, - async_add_entities=async_add_entities, - ) + + async def async_discover(discovery_info): + """Discover and add a MySensors switch.""" + mysensors.setup_mysensors_platform( + hass, + DOMAIN, + discovery_info, + device_class_map, + async_add_entities=async_add_entities, + ) async def async_send_ir_code_service(service): """Set IR code as device state attribute.""" @@ -71,15 +82,20 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= schema=SEND_IR_CODE_SERVICE_SCHEMA, ) + await on_unload( + hass, + config_entry, + async_dispatcher_connect( + hass, + MYSENSORS_DISCOVERY.format(config_entry.entry_id, DOMAIN), + async_discover, + ), + ) + class MySensorsSwitch(mysensors.device.MySensorsEntity, SwitchEntity): """Representation of the value of a MySensors Switch child node.""" - @property - def assumed_state(self): - """Return True if unable to access real state of entity.""" - return self.gateway.optimistic - @property def current_power_w(self): """Return the current power usage in W.""" @@ -96,7 +112,7 @@ class MySensorsSwitch(mysensors.device.MySensorsEntity, SwitchEntity): self.gateway.set_child_value( self.node_id, self.child_id, self.value_type, 1, ack=1 ) - if self.gateway.optimistic: + if self.assumed_state: # Optimistically assume that switch has changed state self._values[self.value_type] = STATE_ON self.async_write_ha_state() @@ -106,7 +122,7 @@ class MySensorsSwitch(mysensors.device.MySensorsEntity, SwitchEntity): self.gateway.set_child_value( self.node_id, self.child_id, self.value_type, 0, ack=1 ) - if self.gateway.optimistic: + if self.assumed_state: # Optimistically assume that switch has changed state self._values[self.value_type] = STATE_OFF self.async_write_ha_state() @@ -137,7 +153,7 @@ class MySensorsIRSwitch(MySensorsSwitch): self.gateway.set_child_value( self.node_id, self.child_id, set_req.V_LIGHT, 1, ack=1 ) - if self.gateway.optimistic: + if self.assumed_state: # Optimistically assume that switch has changed state self._values[self.value_type] = self._ir_code self._values[set_req.V_LIGHT] = STATE_ON @@ -151,7 +167,7 @@ class MySensorsIRSwitch(MySensorsSwitch): self.gateway.set_child_value( self.node_id, self.child_id, set_req.V_LIGHT, 0, ack=1 ) - if self.gateway.optimistic: + if self.assumed_state: # Optimistically assume that switch has changed state self._values[set_req.V_LIGHT] = STATE_OFF self.async_write_ha_state() diff --git a/homeassistant/components/mysensors/translations/en.json b/homeassistant/components/mysensors/translations/en.json new file mode 100644 index 00000000000..d7730ba09b6 --- /dev/null +++ b/homeassistant/components/mysensors/translations/en.json @@ -0,0 +1,79 @@ +{ + "config": { + "abort": { + "already_configured": "Device is already configured", + "cannot_connect": "Failed to connect", + "invalid_subscribe_topic": "Invalid subscribe topic", + "invalid_publish_topic": "Invalid publish topic", + "duplicate_topic": "Topic already in use", + "same_topic": "Subscribe and publish topics are the same", + "invalid_port": "Invalid port number", + "invalid_persistence_file": "Invalid persistence file", + "duplicate_persistence_file": "Persistence file already in use", + "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", + "unknown": "Unexpected error" + }, + "error": { + "already_configured": "Device is already configured", + "cannot_connect": "Failed to connect", + "invalid_subscribe_topic": "Invalid subscribe topic", + "invalid_publish_topic": "Invalid publish topic", + "duplicate_topic": "Topic already in use", + "same_topic": "Subscribe and publish topics are the same", + "invalid_port": "Invalid port number", + "invalid_persistence_file": "Invalid persistence file", + "duplicate_persistence_file": "Persistence file already in use", + "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", + "unknown": "Unexpected error" + }, + "step": { + "user": { + "data": { + "optimistic": "optimistic", + "persistence": "persistence", + "gateway_type": "Gateway type" + }, + "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)" + } + }, + "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/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index c2e36d9f846..6366a3eb887 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -136,6 +136,7 @@ FLOWS = [ "motion_blinds", "mqtt", "myq", + "mysensors", "neato", "nest", "netatmo", diff --git a/requirements_all.txt b/requirements_all.txt index 2a78b25a162..2cc29c3be4b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1551,7 +1551,7 @@ pymusiccast==0.1.6 pymyq==2.0.14 # homeassistant.components.mysensors -pymysensors==0.18.0 +pymysensors==0.20.1 # homeassistant.components.nanoleaf pynanoleaf==0.0.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 024de6596b6..2a447b539ef 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -810,6 +810,9 @@ pymonoprice==0.3 # homeassistant.components.myq pymyq==2.0.14 +# homeassistant.components.mysensors +pymysensors==0.20.1 + # homeassistant.components.nuki pynuki==1.3.8 diff --git a/tests/components/mysensors/__init__.py b/tests/components/mysensors/__init__.py new file mode 100644 index 00000000000..68fc6d7b4d7 --- /dev/null +++ b/tests/components/mysensors/__init__.py @@ -0,0 +1 @@ +"""Tests for the MySensors integration.""" diff --git a/tests/components/mysensors/test_config_flow.py b/tests/components/mysensors/test_config_flow.py new file mode 100644 index 00000000000..6bfec3b102e --- /dev/null +++ b/tests/components/mysensors/test_config_flow.py @@ -0,0 +1,735 @@ +"""Test the MySensors config flow.""" +from typing import Dict, Optional, Tuple +from unittest.mock import patch + +import pytest + +from homeassistant import config_entries, setup +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_PERSISTENCE, + CONF_PERSISTENCE_FILE, + CONF_RETAIN, + CONF_TCP_PORT, + CONF_TOPIC_IN_PREFIX, + CONF_TOPIC_OUT_PREFIX, + CONF_VERSION, + DOMAIN, + ConfGatewayType, +) +from homeassistant.helpers.typing import HomeAssistantType + +from tests.common import MockConfigEntry + + +async def get_form( + hass: HomeAssistantType, gatway_type: ConfGatewayType, 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"] + + result = await hass.config_entries.flow.async_configure( + stepuser["flow_id"], + {CONF_GATEWAY_TYPE: gatway_type}, + ) + await hass.async_block_till_done() + assert result["type"] == "form" + assert result["step_id"] == expected_step_id + + return result + + +async def test_config_mqtt(hass: HomeAssistantType): + """Test configuring a mqtt gateway.""" + step = await get_form(hass, CONF_GATEWAY_TYPE_MQTT, "gw_mqtt") + flow_id = 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( + flow_id, + { + 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 + + +async def test_config_serial(hass: HomeAssistantType): + """Test configuring a gateway via serial.""" + step = await get_form(hass, CONF_GATEWAY_TYPE_SERIAL, "gw_serial") + flow_id = 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( + flow_id, + { + 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") + flow_id = 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( + flow_id, + { + 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") + flow_id = 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( + flow_id, + { + 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 + + +@pytest.mark.parametrize( + "gateway_type, expected_step_id, user_input, err_field, err_string", + [ + ( + CONF_GATEWAY_TYPE_TCP, + "gw_tcp", + { + CONF_TCP_PORT: 600_000, + CONF_DEVICE: "127.0.0.1", + CONF_VERSION: "2.4", + }, + CONF_TCP_PORT, + "port_out_of_range", + ), + ( + 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", + ), + ( + CONF_GATEWAY_TYPE_TCP, + "gw_tcp", + { + CONF_TCP_PORT: 5003, + CONF_DEVICE: "127.0.0.1", + CONF_VERSION: "a", + }, + CONF_VERSION, + "invalid_version", + ), + ( + CONF_GATEWAY_TYPE_TCP, + "gw_tcp", + { + CONF_TCP_PORT: 5003, + CONF_DEVICE: "127.0.0.1", + CONF_VERSION: "a.b", + }, + CONF_VERSION, + "invalid_version", + ), + ( + CONF_GATEWAY_TYPE_TCP, + "gw_tcp", + { + CONF_TCP_PORT: 5003, + CONF_DEVICE: "127.0.0.1", + }, + CONF_VERSION, + "invalid_version", + ), + ( + CONF_GATEWAY_TYPE_TCP, + "gw_tcp", + { + CONF_TCP_PORT: 5003, + CONF_DEVICE: "127.0.0.1", + CONF_VERSION: "4", + }, + CONF_VERSION, + "invalid_version", + ), + ( + CONF_GATEWAY_TYPE_TCP, + "gw_tcp", + { + CONF_TCP_PORT: 5003, + CONF_DEVICE: "127.0.0.1", + CONF_VERSION: "v3", + }, + CONF_VERSION, + "invalid_version", + ), + ( + CONF_GATEWAY_TYPE_TCP, + "gw_tcp", + { + CONF_TCP_PORT: 5003, + CONF_DEVICE: "127.0.0.", + }, + CONF_DEVICE, + "invalid_ip", + ), + ( + CONF_GATEWAY_TYPE_TCP, + "gw_tcp", + { + CONF_TCP_PORT: 5003, + CONF_DEVICE: "abcd", + }, + CONF_DEVICE, + "invalid_ip", + ), + ( + 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", + ), + ( + 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", + ), + ( + 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", + ), + ( + CONF_GATEWAY_TYPE_MQTT, + "gw_mqtt", + { + CONF_RETAIN: True, + CONF_TOPIC_IN_PREFIX: "asdf", + CONF_TOPIC_OUT_PREFIX: "asdf", + CONF_VERSION: "2.4", + }, + CONF_TOPIC_OUT_PREFIX, + "same_topic", + ), + ], +) +async def test_config_invalid( + hass: HomeAssistantType, + gateway_type: ConfGatewayType, + 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, gateway_type, expected_step_id) + flow_id = 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( + flow_id, + 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 + + +@pytest.mark.parametrize( + "user_input", + [ + { + CONF_DEVICE: "COM5", + CONF_BAUD_RATE: 57600, + CONF_TCP_PORT: 5003, + CONF_RETAIN: True, + CONF_VERSION: "2.3", + CONF_PERSISTENCE_FILE: "bla.json", + }, + { + 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, + }, + { + 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, + }, + { + 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, + }, + ], +) +async def test_import(hass: HomeAssistantType, user_input: Dict): + """Test importing a gateway.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + + 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, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, data=user_input, context={"source": config_entries.SOURCE_IMPORT} + ) + await hass.async_block_till_done() + + assert result["type"] == "create_entry" + + +@pytest.mark.parametrize( + "first_input, second_input, expected_result", + [ + ( + { + CONF_DEVICE: "mqtt", + CONF_VERSION: "2.3", + CONF_TOPIC_IN_PREFIX: "same1", + CONF_TOPIC_OUT_PREFIX: "same2", + }, + { + CONF_DEVICE: "mqtt", + CONF_VERSION: "2.3", + CONF_TOPIC_IN_PREFIX: "same1", + CONF_TOPIC_OUT_PREFIX: "same2", + }, + (CONF_TOPIC_IN_PREFIX, "duplicate_topic"), + ), + ( + { + CONF_DEVICE: "mqtt", + CONF_VERSION: "2.3", + CONF_TOPIC_IN_PREFIX: "different1", + CONF_TOPIC_OUT_PREFIX: "different2", + }, + { + CONF_DEVICE: "mqtt", + CONF_VERSION: "2.3", + CONF_TOPIC_IN_PREFIX: "different3", + CONF_TOPIC_OUT_PREFIX: "different4", + }, + None, + ), + ( + { + CONF_DEVICE: "mqtt", + CONF_VERSION: "2.3", + CONF_TOPIC_IN_PREFIX: "same1", + CONF_TOPIC_OUT_PREFIX: "different2", + }, + { + CONF_DEVICE: "mqtt", + CONF_VERSION: "2.3", + CONF_TOPIC_IN_PREFIX: "same1", + CONF_TOPIC_OUT_PREFIX: "different4", + }, + (CONF_TOPIC_IN_PREFIX, "duplicate_topic"), + ), + ( + { + CONF_DEVICE: "mqtt", + CONF_VERSION: "2.3", + CONF_TOPIC_IN_PREFIX: "same1", + CONF_TOPIC_OUT_PREFIX: "different2", + }, + { + CONF_DEVICE: "mqtt", + CONF_VERSION: "2.3", + CONF_TOPIC_IN_PREFIX: "different1", + CONF_TOPIC_OUT_PREFIX: "same1", + }, + (CONF_TOPIC_OUT_PREFIX, "duplicate_topic"), + ), + ( + { + CONF_DEVICE: "mqtt", + CONF_VERSION: "2.3", + CONF_TOPIC_IN_PREFIX: "same1", + CONF_TOPIC_OUT_PREFIX: "different2", + }, + { + CONF_DEVICE: "mqtt", + CONF_VERSION: "2.3", + CONF_TOPIC_IN_PREFIX: "same1", + CONF_TOPIC_OUT_PREFIX: "different1", + }, + (CONF_TOPIC_IN_PREFIX, "duplicate_topic"), + ), + ( + { + CONF_DEVICE: "127.0.0.1", + CONF_PERSISTENCE_FILE: "same.json", + CONF_TCP_PORT: 343, + CONF_VERSION: "2.3", + CONF_PERSISTENCE: False, + CONF_RETAIN: False, + }, + { + CONF_DEVICE: "192.168.1.2", + CONF_PERSISTENCE_FILE: "same.json", + CONF_TCP_PORT: 343, + CONF_VERSION: "2.3", + CONF_PERSISTENCE: False, + CONF_RETAIN: False, + }, + ("persistence_file", "duplicate_persistence_file"), + ), + ( + { + CONF_DEVICE: "127.0.0.1", + CONF_TCP_PORT: 343, + CONF_VERSION: "2.3", + CONF_PERSISTENCE: False, + CONF_RETAIN: False, + }, + { + CONF_DEVICE: "192.168.1.2", + CONF_PERSISTENCE_FILE: "same.json", + CONF_TCP_PORT: 343, + CONF_VERSION: "2.3", + CONF_PERSISTENCE: False, + CONF_RETAIN: False, + }, + None, + ), + ( + { + CONF_DEVICE: "127.0.0.1", + CONF_TCP_PORT: 343, + CONF_VERSION: "2.3", + CONF_PERSISTENCE: False, + CONF_RETAIN: False, + }, + { + CONF_DEVICE: "192.168.1.2", + CONF_TCP_PORT: 343, + CONF_VERSION: "2.3", + CONF_PERSISTENCE: False, + CONF_RETAIN: False, + }, + None, + ), + ( + { + CONF_DEVICE: "192.168.1.2", + CONF_PERSISTENCE_FILE: "different1.json", + CONF_TCP_PORT: 343, + CONF_VERSION: "2.3", + CONF_PERSISTENCE: False, + CONF_RETAIN: False, + }, + { + CONF_DEVICE: "192.168.1.2", + CONF_PERSISTENCE_FILE: "different2.json", + CONF_TCP_PORT: 343, + CONF_VERSION: "2.3", + CONF_PERSISTENCE: False, + CONF_RETAIN: False, + }, + ("base", "already_configured"), + ), + ( + { + CONF_DEVICE: "192.168.1.2", + CONF_PERSISTENCE_FILE: "different1.json", + CONF_TCP_PORT: 343, + CONF_VERSION: "2.3", + CONF_PERSISTENCE: False, + CONF_RETAIN: False, + }, + { + CONF_DEVICE: "192.168.1.2", + CONF_PERSISTENCE_FILE: "different2.json", + CONF_TCP_PORT: 5003, + CONF_VERSION: "2.3", + CONF_PERSISTENCE: False, + CONF_RETAIN: False, + }, + None, + ), + ( + { + CONF_DEVICE: "192.168.1.2", + CONF_TCP_PORT: 5003, + CONF_VERSION: "2.3", + CONF_PERSISTENCE: False, + CONF_RETAIN: False, + }, + { + CONF_DEVICE: "192.168.1.3", + CONF_TCP_PORT: 5003, + CONF_VERSION: "2.3", + CONF_PERSISTENCE: False, + CONF_RETAIN: False, + }, + None, + ), + ( + { + CONF_DEVICE: "COM5", + CONF_TCP_PORT: 5003, + CONF_RETAIN: True, + CONF_VERSION: "2.3", + CONF_PERSISTENCE_FILE: "different1.json", + }, + { + CONF_DEVICE: "COM5", + CONF_TCP_PORT: 5003, + CONF_RETAIN: True, + CONF_VERSION: "2.3", + CONF_PERSISTENCE_FILE: "different2.json", + }, + ("base", "already_configured"), + ), + ( + { + CONF_DEVICE: "COM6", + CONF_BAUD_RATE: 57600, + CONF_RETAIN: True, + CONF_VERSION: "2.3", + }, + { + CONF_DEVICE: "COM5", + CONF_TCP_PORT: 5003, + CONF_RETAIN: True, + CONF_VERSION: "2.3", + }, + None, + ), + ( + { + CONF_DEVICE: "COM5", + CONF_BAUD_RATE: 115200, + CONF_RETAIN: True, + CONF_VERSION: "2.3", + CONF_PERSISTENCE_FILE: "different1.json", + }, + { + CONF_DEVICE: "COM5", + CONF_BAUD_RATE: 57600, + CONF_RETAIN: True, + CONF_VERSION: "2.3", + CONF_PERSISTENCE_FILE: "different2.json", + }, + ("base", "already_configured"), + ), + ( + { + CONF_DEVICE: "COM5", + CONF_BAUD_RATE: 115200, + CONF_RETAIN: True, + CONF_VERSION: "2.3", + CONF_PERSISTENCE_FILE: "same.json", + }, + { + CONF_DEVICE: "COM6", + CONF_BAUD_RATE: 57600, + CONF_RETAIN: True, + CONF_VERSION: "2.3", + CONF_PERSISTENCE_FILE: "same.json", + }, + ("persistence_file", "duplicate_persistence_file"), + ), + ( + { + CONF_DEVICE: "mqtt", + CONF_PERSISTENCE_FILE: "bla.json", + CONF_BAUD_RATE: 115200, + CONF_TCP_PORT: 5003, + CONF_VERSION: "1.4", + }, + { + CONF_DEVICE: "COM6", + CONF_PERSISTENCE_FILE: "bla2.json", + CONF_BAUD_RATE: 115200, + CONF_TCP_PORT: 5003, + CONF_VERSION: "1.4", + }, + None, + ), + ], +) +async def test_duplicate( + hass: HomeAssistantType, + first_input: Dict, + second_input: Dict, + expected_result: Optional[Tuple[str, str]], +): + """Test duplicate detection.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + + 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, + ): + MockConfigEntry(domain=DOMAIN, data=first_input).add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, data=second_input, context={"source": config_entries.SOURCE_IMPORT} + ) + await hass.async_block_till_done() + if expected_result is None: + assert result["type"] == "create_entry" + else: + assert result["type"] == "abort" + assert result["reason"] == expected_result[1] diff --git a/tests/components/mysensors/test_gateway.py b/tests/components/mysensors/test_gateway.py new file mode 100644 index 00000000000..d3e360e0b9f --- /dev/null +++ b/tests/components/mysensors/test_gateway.py @@ -0,0 +1,30 @@ +"""Test function in gateway.py.""" +from unittest.mock import patch + +import pytest +import voluptuous as vol + +from homeassistant.components.mysensors.gateway import is_serial_port +from homeassistant.helpers.typing import HomeAssistantType + + +@pytest.mark.parametrize( + "port, expect_valid", + [ + ("COM5", True), + ("asdf", False), + ("COM17", True), + ("COM", False), + ("/dev/ttyACM0", False), + ], +) +def test_is_serial_port_windows(hass: HomeAssistantType, port: str, expect_valid: bool): + """Test windows serial port.""" + + with patch("sys.platform", "win32"): + try: + is_serial_port(port) + except vol.Invalid: + assert not expect_valid + else: + assert expect_valid diff --git a/tests/components/mysensors/test_init.py b/tests/components/mysensors/test_init.py new file mode 100644 index 00000000000..2775b73efd6 --- /dev/null +++ b/tests/components/mysensors/test_init.py @@ -0,0 +1,251 @@ +"""Test function in __init__.py.""" +from typing import Dict +from unittest.mock import patch + +import pytest + +from homeassistant.components.mysensors import ( + CONF_BAUD_RATE, + CONF_DEVICE, + CONF_GATEWAYS, + CONF_PERSISTENCE, + CONF_PERSISTENCE_FILE, + CONF_RETAIN, + CONF_TCP_PORT, + CONF_VERSION, + DEFAULT_VERSION, + DOMAIN, +) +from homeassistant.components.mysensors.const import ( + CONF_GATEWAY_TYPE, + CONF_GATEWAY_TYPE_MQTT, + CONF_GATEWAY_TYPE_SERIAL, + CONF_GATEWAY_TYPE_TCP, + CONF_TOPIC_IN_PREFIX, + CONF_TOPIC_OUT_PREFIX, +) +from homeassistant.helpers.typing import ConfigType, HomeAssistantType +from homeassistant.setup import async_setup_component + + +@pytest.mark.parametrize( + "config, expected_calls, expected_to_succeed, expected_config_flow_user_input", + [ + ( + { + 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, + True, + { + CONF_GATEWAY_TYPE: CONF_GATEWAY_TYPE_SERIAL, + CONF_DEVICE: "COM5", + CONF_PERSISTENCE_FILE: "bla.json", + CONF_BAUD_RATE: 57600, + CONF_VERSION: "2.3", + }, + ), + ( + { + 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, + True, + { + 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", + }, + ), + ( + { + DOMAIN: { + CONF_GATEWAYS: [ + { + CONF_DEVICE: "127.0.0.1", + } + ], + CONF_PERSISTENCE: False, + CONF_RETAIN: False, + } + }, + 1, + True, + { + CONF_GATEWAY_TYPE: CONF_GATEWAY_TYPE_TCP, + CONF_DEVICE: "127.0.0.1", + CONF_TCP_PORT: 5003, + CONF_VERSION: DEFAULT_VERSION, + }, + ), + ( + { + 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_PERSISTENCE: False, + CONF_RETAIN: False, + } + }, + 1, + True, + { + CONF_GATEWAY_TYPE: CONF_GATEWAY_TYPE_MQTT, + CONF_DEVICE: "mqtt", + CONF_VERSION: DEFAULT_VERSION, + CONF_TOPIC_OUT_PREFIX: "outtopic", + CONF_TOPIC_IN_PREFIX: "intopic", + }, + ), + ( + { + DOMAIN: { + CONF_GATEWAYS: [ + { + CONF_DEVICE: "mqtt", + CONF_BAUD_RATE: 115200, + CONF_TCP_PORT: 5003, + } + ], + CONF_PERSISTENCE: False, + CONF_RETAIN: False, + } + }, + 0, + True, + {}, + ), + ( + { + DOMAIN: { + CONF_GATEWAYS: [ + { + CONF_DEVICE: "mqtt", + CONF_PERSISTENCE_FILE: "bla.json", + CONF_TOPIC_OUT_PREFIX: "out", + CONF_TOPIC_IN_PREFIX: "in", + CONF_BAUD_RATE: 115200, + CONF_TCP_PORT: 5003, + }, + { + CONF_DEVICE: "COM6", + CONF_PERSISTENCE_FILE: "bla2.json", + CONF_BAUD_RATE: 115200, + CONF_TCP_PORT: 5003, + }, + ], + CONF_VERSION: "2.4", + CONF_PERSISTENCE: False, + CONF_RETAIN: False, + } + }, + 2, + True, + {}, + ), + ( + { + 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, + } + }, + 0, + False, + {}, + ), + ( + { + DOMAIN: { + CONF_GATEWAYS: [ + { + CONF_DEVICE: "COMx", + CONF_PERSISTENCE_FILE: "bla.json", + CONF_BAUD_RATE: 115200, + CONF_TCP_PORT: 5003, + }, + ], + CONF_VERSION: "2.4", + CONF_PERSISTENCE: False, + CONF_RETAIN: False, + } + }, + 0, + True, + {}, + ), + ], +) +async def test_import( + hass: HomeAssistantType, + config: ConfigType, + expected_calls: int, + expected_to_succeed: bool, + expected_config_flow_user_input: Dict[str, any], +): + """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_component(hass, DOMAIN, config) + assert result == expected_to_succeed + await hass.async_block_till_done() + + assert len(mock_setup_entry.mock_calls) == expected_calls + + if expected_calls > 0: + config_flow_user_input = mock_setup_entry.mock_calls[0][1][1].data + for key, value in expected_config_flow_user_input.items(): + assert key in config_flow_user_input + assert config_flow_user_input[key] == value -- GitLab