diff --git a/.coveragerc b/.coveragerc index 47d9c84ba0ec1dd63bf81a7f120a7e11b1c0d772..3a274cd004f8b40832cd87a6a07dfea8fbda3db3 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 efb338dd4b4ccf179735ab6b0a2ee541e5d94d37..8785ce382cb6b10496fdc3e10a8ada07733c7e14 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 43e398b142fd2e51116a0295646093e8c5b4d7c7..25b4d3106da5c91031d8e3e6d1db63277d2f8d4e 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 4ec3c6e0abd65c5f7d9f44008b25a24fad272293..c4e12d170c01ec93480c5a6949c360a46b98c9fb 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 c318ccf7ec614f86a0417b96ff14e1c1eafd0c29..b1916fc4ed104c2e0b12697f81d04cd79afd2ee6 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 0000000000000000000000000000000000000000..058b782d208eeff84160097b25b994e48b2ec634 --- /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 ccb646eb47eec6ec65a537efec82944464445fbe..66bee128d4d27eb18a1cfa2375abbd44fe1a08f3 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 f2ede69793fde0e4c713f6eead4624c5659a397f..782ab88c48899e069e776740ceabc06cf3dc0653 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 9c1c4b543676466f46434cfee98d2a43b5e087b1..6841486734520ff41a3292d9fefd0290c7868eef 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 1bf1e072cebfd24b83fc9bd233eed43251b5efed..b395a48f28b7dad40de8830466c0a310e312542a 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 f9450b798ace1eaa0c4221d679c00144e46eaeb6..b618004b62222d49808a6ea855a8678cfe1e521b 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 b5b8b511aee11060ce80f038652337df71dce110..10165a171e0a035817c32aff991cb4b305c33d3f 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 20b266e550e61955606873907024ded5ed23cf68..d06bf0dee2fe5764d0e6a4c44d4b6c60d07fbbce 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 ffbcba6f0329717dd9952fdb762871684e207996..f90f9c5c81c77b178898cad0192431feb92bf4f6 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 afeeb5d57cc99bc504398482b4d96ed1ccebeab8..8371f2930c2fb169abe8ccdf9d5437fa7e76f93f 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 bab6bf3fc402e8184921bb5bd34cb08b0157ef53..a09f8af139459ece72575ee7fb1007c0e980e10a 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 0000000000000000000000000000000000000000..43a68f61e247a06b85b1ecfe6810c3488c59cfc2 --- /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 0da8bfe7030f57557347ab6027cdb0d169176d80..14911e11090fb48dcba91aaebf00662f42f25c1e 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 0000000000000000000000000000000000000000..d7730ba09b6e7456d69a9d478ada96b5a16e25b4 --- /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 c2e36d9f846872993eb69325c06ca4d01c87af2b..6366a3eb8879c6d11972bf3355b8cef9dff171f3 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 2a78b25a162418763ed1cc20dde961f3b4216a90..2cc29c3be4b5e6c404e7509662de14d7069dca95 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 024de6596b654be2c221d6e6566c602b5e54f818..2a447b539ef58b919bac5d17e6e3c3c03086f554 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 0000000000000000000000000000000000000000..68fc6d7b4d705324a95e38522d9695335aa87701 --- /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 0000000000000000000000000000000000000000..6bfec3b102e585598e2752801423749279964ed6 --- /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 0000000000000000000000000000000000000000..d3e360e0b9f8a5a38bd9cbdc046266490ffcc54e --- /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 0000000000000000000000000000000000000000..2775b73efd62991efb3069291a391f57c5d6ceb8 --- /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