MySensors config flow (#45421)

* MySensors: Add type annotations

Adds a bunch of type annotations that were created
while understanding the code.

* MySensors: Change GatewayId to string

In preparation for config flow.
The GatewayId used to be id(gateway).

With config flows, every gateway will have its own
ConfigEntry. Every ConfigEntry has a unique id.
Thus we would have two separate but one-to-one related ID systems.

This commit removes this unneeded duplication by using the id of the ConfigEntry
as GatewayId.

* MySensors: Add unique_id to all entities

This allows entities to work well with the frontend.

* MySensors: Add device_info to all entities

Entities belonging to the same node_id will now by grouped as a device.

* MySensors: clean up device.py a bit

* MySensors: Add config flow support

With this change the MySensors can be fully configured from the GUI.

Legacy configuration.yaml configs will be migrated by reading them once.
Note that custom node names are not migrated. Users will have to re-enter
the names in the front-end.
Since there is no straight-forward way to configure global settings,
all previously global settings are now per-gateway. These settings include:
- MQTT retain
- optimistic
- persistence enable
- MySensors version

When a MySensors integration is loaded, it works as follows:
1. __init__.async_setup_entry is called
2. for every platform, async_forward_entry_setup is called
3. the platform's async_setup_entry is called
4. __init__.setup_mysensors_platform is called
5. the entity's constructor (e.g. MySensorsCover) is called
6. the created entity is stored in a dict in the hass object

* MySensors: Fix linter errors

* MySensors: Remove unused import

* MySensors: Feedback from @MartinHjelmare

* MySensors: Multi-step config flow

* MySensors: More feedback

* MySensors: Move all storage in hass object under DOMAIN

The integration now stores everything under hass.data["mysensors"]
instead of using several top level keys.

* MySensors: await shutdown of gateway instead of creating a task

* MySensors: Rename Ethernet to TCP

* MySensors: Absolute imports and cosmetic changes

* MySensors: fix gw_stop

* MySensors: Allow user to specify persistence file

* MySensors: Nicer log message

* MySensors: Add lots of unit tests

* MySensors: Fix legacy import of persistence file name

Turns out tests help to find bugs :D

* MySensors: Improve test coverage

* MySensors: Use json persistence files by default

* MySensors: Code style improvements

* MySensors: Stop adding attributes to existing objects

This commit removes the extra attributes that were being
added to the gateway objects from pymysensors.

Most attributes were easy to remove, except for the gateway id.
The MySensorsDevice class needs the gateway id as it is part of its DevId
as well as the unique_id and device_info.
Most MySensorsDevices actually end up being Entities.
Entities have access to their ConfigEntry via self.platform.config_entry.

However, the device_tracker platform does not become an Entity.
For this reason, the gateway id is not fetched from self.plaform but
given as an argument.

Additionally, MySensorsDevices expose the address of the gateway
(CONF_DEVICE). Entities can easily fetch this information via self.platform,
but the device_tracker cannot. This commit chooses to remove the gateway
address from device_tracker. While this could in theory break some automations,
the simplicity of this solution was deemed worth it.
The alternative of adding the entire ConfigEntry as an argument to MySensorsDevices
is not viable, because device_tracker is initialized by the async_setup_scanner function
that isn't supplied a ConfigEntry. It only gets discovery_info.
Adding the entire ConfigEntry doesn't seem appropriate for this edge case.

* MySensors: Fix gw_stop and the translations

* MySensors: Fix incorrect function calls

* MySensors: Fewer comments in const.py

* MySensors: Remove union from _get_gateway and remove id from try_connect

* MySensors: Deprecate nodes option in configuration.yaml

* MySensors: Use version parser from packaging

* MySensors: Remove prefix from unique_id and change some private property names

* MySensors: Change _get_gateway function signature

* MySensors: add packaging==20.8 for the version parser

* MySensors: Rename some stuff

* MySensors: use pytest.mark.parametrize

* MySensors: Clean up test cases

* MySensors: Remove unneeded parameter from devices

* Revert "MySensors: add packaging==20.8 for the version parser"

This reverts commit 6b200ee01a.

* MySensors: Use core interface for testing configuration.yaml import

* MySensors: Fix test_init

* MySensors: Rename a few variables

* MySensors: cosmetic changes

* MySensors: Update strings.json

* MySensors: Still more feedback from @MartinHjelmare

* MySensors: Remove unused strings

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* MySensors: Fix typo and remove another unused string

* MySensors: More strings.json

* MySensors: Fix gateway ready handler

* MySensors: Add duplicate detection to config flows

* MySensors: Deal with non-existing topics and ports.

Includes unit tests for these cases.

* MySensors: Use awesomeversion instead of packaging

* Add string already_configured

* MySensors: Abort config flow when config is found to be invalid while importing

* MySensors: Copy all error messages to also be abort messages

All error strings may now also be used as an abort reason,
so the strings should be defined

* Use string references

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
This commit is contained in:
functionpointer 2021-02-05 22:13:57 +01:00 committed by GitHub
parent 0d620eb7c3
commit c01e01f797
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
26 changed files with 2371 additions and 333 deletions

View File

@ -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

View File

@ -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

View File

@ -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
if not gateways:
_LOGGER.error("No devices could be setup as gateways, check your configuration")
return False
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
]
hass.data[MYSENSORS_GATEWAYS] = gateways
hass.async_create_task(finish_setup(hass, config, gateways))
# 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
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_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
if DOMAIN not in hass.data:
hass.data[DOMAIN] = {}
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
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:

View File

@ -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,
),
)

View File

@ -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()

View File

@ -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

View File

@ -1,33 +1,69 @@
"""MySensors constants."""
from collections import defaultdict
from typing import Dict, List, Literal, Set, Tuple
ATTR_DEVICES = "devices"
ATTR_DEVICES: str = "devices"
ATTR_GATEWAY_ID: str = "gateway_id"
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"
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 = "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"
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
BINARY_SENSOR_TYPES = {
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",
}

View File

@ -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()

View File

@ -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,
)
)

View File

@ -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

View File

@ -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)
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
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
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 setup_gateways(hass, config):
"""Set up all gateways."""
conf = config[DOMAIN]
gateways = {}
async def setup_gateway(
hass: HomeAssistantType, entry: ConfigEntry
) -> Optional[BaseAsyncGateway]:
"""Set up the Gateway for the given ConfigEntry."""
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
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, config, gateway_conf, persistence_file):
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()
async def stop_this_gw(_: Event):
await gw_stop(hass, entry, gateway)
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, gw_stop)
if gateway.device == "mqtt":
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

View File

@ -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)

View File

@ -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

View File

@ -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()

View File

@ -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
}

View File

@ -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):

View File

@ -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%]"
}
}
}

View File

@ -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()

View File

@ -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"
}

View File

@ -136,6 +136,7 @@ FLOWS = [
"motion_blinds",
"mqtt",
"myq",
"mysensors",
"neato",
"nest",
"netatmo",

View File

@ -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

View File

@ -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

View File

@ -0,0 +1 @@
"""Tests for the MySensors integration."""

View File

@ -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]

View File

@ -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

View File

@ -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