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/mychevy/*
homeassistant/components/mycroft/* homeassistant/components/mycroft/*
homeassistant/components/mycroft/notify.py 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/binary_sensor.py
homeassistant/components/mystrom/light.py homeassistant/components/mystrom/light.py
homeassistant/components/mystrom/switch.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/mqtt/* @home-assistant/core @emontnemery
homeassistant/components/msteams/* @peroyvind homeassistant/components/msteams/* @peroyvind
homeassistant/components/myq/* @bdraco homeassistant/components/myq/* @bdraco
homeassistant/components/mysensors/* @MartinHjelmare homeassistant/components/mysensors/* @MartinHjelmare @functionpointer
homeassistant/components/mystrom/* @fabaff homeassistant/components/mystrom/* @fabaff
homeassistant/components/neato/* @dshokouhi @Santobert homeassistant/components/neato/* @dshokouhi @Santobert
homeassistant/components/nederlandse_spoorwegen/* @YarmoM homeassistant/components/nederlandse_spoorwegen/* @YarmoM

View file

@ -1,12 +1,18 @@
"""Connect to a MySensors gateway via pymysensors API.""" """Connect to a MySensors gateway via pymysensors API."""
import asyncio
import logging import logging
from typing import Callable, Dict, List, Optional, Tuple, Type, Union
from mysensors import BaseAsyncGateway
import voluptuous as vol import voluptuous as vol
from homeassistant import config_entries
from homeassistant.components.mqtt import valid_publish_topic, valid_subscribe_topic 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.const import CONF_OPTIMISTIC
from homeassistant.core import callback from homeassistant.core import callback
import homeassistant.helpers.config_validation as cv import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.typing import ConfigType, HomeAssistantType
from .const import ( from .const import (
ATTR_DEVICES, ATTR_DEVICES,
@ -23,9 +29,14 @@ from .const import (
CONF_VERSION, CONF_VERSION,
DOMAIN, DOMAIN,
MYSENSORS_GATEWAYS, MYSENSORS_GATEWAYS,
MYSENSORS_ON_UNLOAD,
SUPPORTED_PLATFORMS_WITH_ENTRY_SUPPORT,
DevId,
GatewayId,
SensorType,
) )
from .device import get_mysensors_devices from .device import MySensorsDevice, MySensorsEntity, get_mysensors_devices
from .gateway import finish_setup, get_mysensors_gateway, setup_gateways from .gateway import finish_setup, get_mysensors_gateway, gw_stop, setup_gateway
_LOGGER = logging.getLogger(__name__) _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}}) NODE_SCHEMA = vol.Schema({cv.positive_int: {vol.Required(CONF_NODE_NAME): cv.string}})
GATEWAY_SCHEMA = { GATEWAY_SCHEMA = vol.Schema(
vol.Required(CONF_DEVICE): cv.string, vol.All(
vol.Optional(CONF_PERSISTENCE_FILE): vol.All(cv.string, is_persistence_file), deprecated(CONF_NODES),
vol.Optional(CONF_BAUD_RATE, default=DEFAULT_BAUD_RATE): cv.positive_int, {
vol.Optional(CONF_TCP_PORT, default=DEFAULT_TCP_PORT): cv.port, vol.Required(CONF_DEVICE): cv.string,
vol.Optional(CONF_TOPIC_IN_PREFIX): valid_subscribe_topic, vol.Optional(CONF_PERSISTENCE_FILE): vol.All(
vol.Optional(CONF_TOPIC_OUT_PREFIX): valid_publish_topic, cv.string, is_persistence_file
vol.Optional(CONF_NODES, default={}): NODE_SCHEMA, ),
} 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( CONFIG_SCHEMA = vol.Schema(
{ {
DOMAIN: vol.Schema( DOMAIN: vol.Schema(
vol.All( vol.All(
deprecated(CONF_DEBUG), deprecated(CONF_DEBUG),
deprecated(CONF_OPTIMISTIC),
deprecated(CONF_PERSISTENCE),
{ {
vol.Required(CONF_GATEWAYS): vol.All( vol.Required(CONF_GATEWAYS): vol.All(
cv.ensure_list, has_all_unique_files, [GATEWAY_SCHEMA] 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_RETAIN, default=True): cv.boolean,
vol.Optional(CONF_VERSION, default=DEFAULT_VERSION): cv.string, 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.""" """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: config = config[DOMAIN]
_LOGGER.error("No devices could be setup as gateways, check your configuration") user_inputs = [
return False {
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 # there is an actual configuration in configuration.yaml, so we have to process it
for user_input in user_inputs:
hass.async_create_task(finish_setup(hass, config, gateways)) hass.async_create_task(
hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_IMPORT},
data=user_input,
)
)
return True return True
def _get_mysensors_name(gateway, node_id, child_id): async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry) -> bool:
"""Return a name for a node child.""" """Set up an instance of the MySensors integration.
node_name = f"{gateway.sensors[node_id].sketch_name} {node_id}"
node_name = next( Every instance has a connection to exactly one Gateway.
( """
node[CONF_NODE_NAME] gateway = await setup_gateway(hass, entry)
for conf_id, node in gateway.nodes_config.items()
if node.get(CONF_NODE_NAME) is not None and conf_id == node_id if not gateway:
), _LOGGER.error("Gateway setup failed for %s", entry.data)
node_name, 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 @callback
def setup_mysensors_platform( def setup_mysensors_platform(
hass, hass,
domain, domain: str, # hass platform name
discovery_info, discovery_info: Optional[Dict[str, List[DevId]]],
device_class, device_class: Union[Type[MySensorsDevice], Dict[SensorType, Type[MySensorsEntity]]],
device_args=None, device_args: Optional[
async_add_entities=None, Tuple
): ] = None, # extra arguments that will be given to the entity constructor
"""Set up a MySensors platform.""" 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. # Only act if called via MySensors by discovery event.
# Otherwise gateway is not set up. # Otherwise gateway is not set up.
if not discovery_info: if not discovery_info:
_LOGGER.debug("Skipping setup due to no discovery info")
return None return None
if device_args is None: if device_args is None:
device_args = () device_args = ()
new_devices = [] new_devices: List[MySensorsDevice] = []
new_dev_ids = discovery_info[ATTR_DEVICES] new_dev_ids: List[DevId] = discovery_info[ATTR_DEVICES]
for dev_id in new_dev_ids: 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: if dev_id in devices:
_LOGGER.debug(
"Skipping setup of %s for platform %s as it already exists",
dev_id,
domain,
)
continue continue
gateway_id, node_id, child_id, value_type = dev_id 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: if not gateway:
_LOGGER.warning("Skipping setup of %s, no gateway found", dev_id)
continue continue
device_class_copy = device_class device_class_copy = device_class
if isinstance(device_class, dict): if isinstance(device_class, dict):
child = gateway.sensors[node_id].children[child_id] child = gateway.sensors[node_id].children[child_id]
s_type = gateway.const.Presentation(child.type).name s_type = gateway.const.Presentation(child.type).name
device_class_copy = device_class[s_type] 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) devices[dev_id] = device_class_copy(*args_copy)
new_devices.append(devices[dev_id]) new_devices.append(devices[dev_id])
if new_devices: if new_devices:

View file

@ -1,4 +1,6 @@
"""Support for MySensors binary sensors.""" """Support for MySensors binary sensors."""
from typing import Callable
from homeassistant.components import mysensors from homeassistant.components import mysensors
from homeassistant.components.binary_sensor import ( from homeassistant.components.binary_sensor import (
DEVICE_CLASS_MOISTURE, DEVICE_CLASS_MOISTURE,
@ -10,7 +12,13 @@ from homeassistant.components.binary_sensor import (
DOMAIN, DOMAIN,
BinarySensorEntity, 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.const import STATE_ON
from homeassistant.core import callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.typing import HomeAssistantType
SENSORS = { SENSORS = {
"S_DOOR": "door", "S_DOOR": "door",
@ -24,14 +32,30 @@ SENSORS = {
} }
async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): async def async_setup_entry(
"""Set up the mysensors platform for binary sensors.""" hass: HomeAssistantType, config_entry: ConfigEntry, async_add_entities: Callable
mysensors.setup_mysensors_platform( ):
"""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, hass,
DOMAIN, config_entry,
discovery_info, async_dispatcher_connect(
MySensorsBinarySensor, hass,
async_add_entities=async_add_entities, 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.""" """MySensors platform that offers a Climate (MySensors-HVAC) component."""
from typing import Callable
from homeassistant.components import mysensors from homeassistant.components import mysensors
from homeassistant.components.climate import ClimateEntity from homeassistant.components.climate import ClimateEntity
from homeassistant.components.climate.const import ( from homeassistant.components.climate.const import (
@ -13,7 +15,12 @@ from homeassistant.components.climate.const import (
SUPPORT_TARGET_TEMPERATURE, SUPPORT_TARGET_TEMPERATURE,
SUPPORT_TARGET_TEMPERATURE_RANGE, 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.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 = { DICT_HA_TO_MYS = {
HVAC_MODE_AUTO: "AutoChangeOver", 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] 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): async def async_setup_entry(
"""Set up the mysensors climate.""" hass: HomeAssistantType, config_entry: ConfigEntry, async_add_entities: Callable
mysensors.setup_mysensors_platform( ):
"""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, hass,
DOMAIN, config_entry,
discovery_info, async_dispatcher_connect(
MySensorsHVAC, hass,
async_add_entities=async_add_entities, 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 features = features | SUPPORT_TARGET_TEMPERATURE
return features return features
@property
def assumed_state(self):
"""Return True if unable to access real state of entity."""
return self.gateway.optimistic
@property @property
def temperature_unit(self): def temperature_unit(self):
"""Return the unit of measurement.""" """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 @property
def current_temperature(self): def current_temperature(self):
@ -159,7 +176,7 @@ class MySensorsHVAC(mysensors.device.MySensorsEntity, ClimateEntity):
self.gateway.set_child_value( self.gateway.set_child_value(
self.node_id, self.child_id, value_type, value, ack=1 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 # Optimistically assume that device has changed state
self._values[value_type] = value self._values[value_type] = value
self.async_write_ha_state() self.async_write_ha_state()
@ -170,7 +187,7 @@ class MySensorsHVAC(mysensors.device.MySensorsEntity, ClimateEntity):
self.gateway.set_child_value( self.gateway.set_child_value(
self.node_id, self.child_id, set_req.V_HVAC_SPEED, fan_mode, ack=1 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 # Optimistically assume that device has changed state
self._values[set_req.V_HVAC_SPEED] = fan_mode self._values[set_req.V_HVAC_SPEED] = fan_mode
self.async_write_ha_state() self.async_write_ha_state()
@ -184,7 +201,7 @@ class MySensorsHVAC(mysensors.device.MySensorsEntity, ClimateEntity):
DICT_HA_TO_MYS[hvac_mode], DICT_HA_TO_MYS[hvac_mode],
ack=1, ack=1,
) )
if self.gateway.optimistic: if self.assumed_state:
# Optimistically assume that device has changed state # Optimistically assume that device has changed state
self._values[self.value_type] = hvac_mode self._values[self.value_type] = hvac_mode
self.async_write_ha_state() 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.""" """MySensors constants."""
from collections import defaultdict 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_BAUD_RATE: str = "baud_rate"
CONF_DEVICE = "device" CONF_DEVICE: str = "device"
CONF_GATEWAYS = "gateways" CONF_GATEWAYS: str = "gateways"
CONF_NODES = "nodes" CONF_NODES: str = "nodes"
CONF_PERSISTENCE = "persistence" CONF_PERSISTENCE: str = "persistence"
CONF_PERSISTENCE_FILE = "persistence_file" CONF_PERSISTENCE_FILE: str = "persistence_file"
CONF_RETAIN = "retain" CONF_RETAIN: str = "retain"
CONF_TCP_PORT = "tcp_port" CONF_TCP_PORT: str = "tcp_port"
CONF_TOPIC_IN_PREFIX = "topic_in_prefix" CONF_TOPIC_IN_PREFIX: str = "topic_in_prefix"
CONF_TOPIC_OUT_PREFIX = "topic_out_prefix" CONF_TOPIC_OUT_PREFIX: str = "topic_out_prefix"
CONF_VERSION = "version" 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_DOOR": {"V_TRIPPED"},
"S_MOTION": {"V_TRIPPED"}, "S_MOTION": {"V_TRIPPED"},
"S_SMOKE": {"V_TRIPPED"}, "S_SMOKE": {"V_TRIPPED"},
@ -38,21 +74,23 @@ BINARY_SENSOR_TYPES = {
"S_MOISTURE": {"V_TRIPPED"}, "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_DIMMER": {"V_DIMMER", "V_PERCENTAGE"},
"S_RGB_LIGHT": {"V_RGB"}, "S_RGB_LIGHT": {"V_RGB"},
"S_RGBW_LIGHT": {"V_RGBW"}, "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_SOUND": {"V_LEVEL"},
"S_VIBRATION": {"V_LEVEL"}, "S_VIBRATION": {"V_LEVEL"},
"S_MOISTURE": {"V_LEVEL"}, "S_MOISTURE": {"V_LEVEL"},
@ -80,7 +118,7 @@ SENSOR_TYPES = {
"S_DUST": {"V_DUST_LEVEL", "V_LEVEL"}, "S_DUST": {"V_DUST_LEVEL", "V_LEVEL"},
} }
SWITCH_TYPES = { SWITCH_TYPES: Dict[SensorType, Set[ValueType]] = {
"S_LIGHT": {"V_LIGHT"}, "S_LIGHT": {"V_LIGHT"},
"S_BINARY": {"V_STATUS"}, "S_BINARY": {"V_STATUS"},
"S_DOOR": {"V_ARMED"}, "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, "binary_sensor": BINARY_SENSOR_TYPES,
"climate": CLIMATE_TYPES, "climate": CLIMATE_TYPES,
"cover": COVER_TYPES, "cover": COVER_TYPES,
@ -108,13 +146,19 @@ PLATFORM_TYPES = {
"switch": SWITCH_TYPES, "switch": SWITCH_TYPES,
} }
FLAT_PLATFORM_TYPES = { FLAT_PLATFORM_TYPES: Dict[Tuple[str, SensorType], Set[ValueType]] = {
(platform, s_type_name): v_type_name (platform, s_type_name): v_type_name
for platform, platform_types in PLATFORM_TYPES.items() for platform, platform_types in PLATFORM_TYPES.items()
for s_type_name, v_type_name 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 platform, platform_types in PLATFORM_TYPES.items():
for s_type_name in platform_types: for s_type_name in platform_types:
TYPE_TO_PLATFORMS[s_type_name].append(platform) 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.""" """Support for MySensors covers."""
import logging
from typing import Callable
from homeassistant.components import mysensors from homeassistant.components import mysensors
from homeassistant.components.cover import ATTR_POSITION, DOMAIN, CoverEntity 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.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): async def async_setup_entry(
"""Set up the mysensors platform for covers.""" hass: HomeAssistantType, config_entry: ConfigEntry, async_add_entities: Callable
mysensors.setup_mysensors_platform( ):
"""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, hass,
DOMAIN, config_entry.entry_id,
discovery_info, async_dispatcher_connect(
MySensorsCover, hass,
async_add_entities=async_add_entities, MYSENSORS_DISCOVERY.format(config_entry.entry_id, DOMAIN),
async_discover,
),
) )
class MySensorsCover(mysensors.device.MySensorsEntity, CoverEntity): class MySensorsCover(mysensors.device.MySensorsEntity, CoverEntity):
"""Representation of the value of a MySensors Cover child node.""" """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 @property
def is_closed(self): def is_closed(self):
"""Return True if cover is closed.""" """Return True if cover is closed."""
@ -46,7 +66,7 @@ class MySensorsCover(mysensors.device.MySensorsEntity, CoverEntity):
self.gateway.set_child_value( self.gateway.set_child_value(
self.node_id, self.child_id, set_req.V_UP, 1, ack=1 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. # Optimistically assume that cover has changed state.
if set_req.V_DIMMER in self._values: if set_req.V_DIMMER in self._values:
self._values[set_req.V_DIMMER] = 100 self._values[set_req.V_DIMMER] = 100
@ -60,7 +80,7 @@ class MySensorsCover(mysensors.device.MySensorsEntity, CoverEntity):
self.gateway.set_child_value( self.gateway.set_child_value(
self.node_id, self.child_id, set_req.V_DOWN, 1, ack=1 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. # Optimistically assume that cover has changed state.
if set_req.V_DIMMER in self._values: if set_req.V_DIMMER in self._values:
self._values[set_req.V_DIMMER] = 0 self._values[set_req.V_DIMMER] = 0
@ -75,7 +95,7 @@ class MySensorsCover(mysensors.device.MySensorsEntity, CoverEntity):
self.gateway.set_child_value( self.gateway.set_child_value(
self.node_id, self.child_id, set_req.V_DIMMER, position, ack=1 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. # Optimistically assume that cover has changed state.
self._values[set_req.V_DIMMER] = position self._values[set_req.V_DIMMER] = position
self.async_write_ha_state() self.async_write_ha_state()

View file

@ -1,13 +1,26 @@
"""Handle MySensors devices.""" """Handle MySensors devices."""
from functools import partial from functools import partial
import logging 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.const import ATTR_BATTERY_LEVEL, STATE_OFF, STATE_ON
from homeassistant.core import callback from homeassistant.core import callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity import Entity 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__) _LOGGER = logging.getLogger(__name__)
@ -19,33 +32,94 @@ ATTR_HEARTBEAT = "heartbeat"
MYSENSORS_PLATFORM_DEVICES = "mysensors_devices_{}" 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: class MySensorsDevice:
"""Representation of a MySensors device.""" """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.""" """Set up the MySensors device."""
self.gateway = gateway self.gateway_id: GatewayId = gateway_id
self.node_id = node_id self.gateway: BaseAsyncGateway = gateway
self.child_id = child_id self.node_id: int = node_id
self._name = name self.child_id: int = child_id
self.value_type = value_type self.value_type: int = value_type # value_type as int. string variant can be looked up in gateway consts
child = gateway.sensors[node_id].children[child_id] self.child_type = self._child.type
self.child_type = child.type
self._values = {} self._values = {}
self._update_scheduled = False self._update_scheduled = False
self.hass = None 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 @property
def name(self): def name(self):
"""Return the name of this entity.""" """Return the name of this entity."""
return self._name return f"{self.node_name} {self.child_id}"
@property @property
def device_state_attributes(self): def device_state_attributes(self):
@ -57,9 +131,12 @@ class MySensorsDevice:
ATTR_HEARTBEAT: node.heartbeat, ATTR_HEARTBEAT: node.heartbeat,
ATTR_CHILD_ID: self.child_id, ATTR_CHILD_ID: self.child_id,
ATTR_DESCRIPTION: child.description, ATTR_DESCRIPTION: child.description,
ATTR_DEVICE: self.gateway.device,
ATTR_NODE_ID: self.node_id, 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 set_req = self.gateway.const.SetReq
@ -76,7 +153,7 @@ class MySensorsDevice:
for value_type, value in child.values.items(): for value_type, value in child.values.items():
_LOGGER.debug( _LOGGER.debug(
"Entity update: %s: value_type %s, value = %s", "Entity update: %s: value_type %s, value = %s",
self._name, self.name,
value_type, value_type,
value, value,
) )
@ -116,6 +193,13 @@ class MySensorsDevice:
self.hass.loop.call_later(UPDATE_DELAY, delayed_update) 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): class MySensorsEntity(MySensorsDevice, Entity):
"""Representation of a MySensors entity.""" """Representation of a MySensors entity."""
@ -135,17 +219,17 @@ class MySensorsEntity(MySensorsDevice, Entity):
async def async_added_to_hass(self): async def async_added_to_hass(self):
"""Register update callback.""" """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( self.async_on_remove(
async_dispatcher_connect( 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( self.async_on_remove(
async_dispatcher_connect( async_dispatcher_connect(
self.hass, self.hass,
NODE_CALLBACK.format(gateway_id, self.node_id), NODE_CALLBACK.format(self.gateway_id, self.node_id),
self.async_update_callback, self.async_update_callback,
) )
) )

View file

@ -1,11 +1,16 @@
"""Support for tracking MySensors devices.""" """Support for tracking MySensors devices."""
from homeassistant.components import mysensors from homeassistant.components import mysensors
from homeassistant.components.device_tracker import DOMAIN 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.dispatcher import async_dispatcher_connect
from homeassistant.helpers.typing import HomeAssistantType
from homeassistant.util import slugify 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.""" """Set up the MySensors device scanner."""
new_devices = mysensors.setup_mysensors_platform( new_devices = mysensors.setup_mysensors_platform(
hass, hass,
@ -18,17 +23,25 @@ async def async_setup_scanner(hass, config, async_see, discovery_info=None):
return False return False
for device in new_devices: for device in new_devices:
gateway_id = id(device.gateway) gateway_id: GatewayId = discovery_info[ATTR_GATEWAY_ID]
dev_id = (gateway_id, device.node_id, device.child_id, device.value_type) dev_id: DevId = (gateway_id, device.node_id, device.child_id, device.value_type)
async_dispatcher_connect( await on_unload(
hass, hass,
mysensors.const.CHILD_CALLBACK.format(*dev_id), gateway_id,
device.async_update_callback, async_dispatcher_connect(
hass,
mysensors.const.CHILD_CALLBACK.format(*dev_id),
device.async_update_callback,
),
) )
async_dispatcher_connect( await on_unload(
hass, hass,
mysensors.const.NODE_CALLBACK.format(gateway_id, device.node_id), gateway_id,
device.async_update_callback, async_dispatcher_connect(
hass,
mysensors.const.NODE_CALLBACK.format(gateway_id, device.node_id),
device.async_update_callback,
),
) )
return True return True
@ -37,7 +50,7 @@ async def async_setup_scanner(hass, config, async_see, discovery_info=None):
class MySensorsDeviceScanner(mysensors.device.MySensorsDevice): class MySensorsDeviceScanner(mysensors.device.MySensorsDevice):
"""Represent a MySensors scanner.""" """Represent a MySensors scanner."""
def __init__(self, hass, async_see, *args): def __init__(self, hass: HomeAssistantType, async_see, *args):
"""Set up instance.""" """Set up instance."""
super().__init__(*args) super().__init__(*args)
self.async_see = async_see self.async_see = async_see

View file

@ -4,22 +4,21 @@ from collections import defaultdict
import logging import logging
import socket import socket
import sys import sys
from typing import Any, Callable, Coroutine, Dict, Optional
import async_timeout import async_timeout
from mysensors import mysensors from mysensors import BaseAsyncGateway, Message, Sensor, mysensors
import voluptuous as vol import voluptuous as vol
from homeassistant.const import CONF_OPTIMISTIC, EVENT_HOMEASSISTANT_STOP from homeassistant.config_entries import ConfigEntry
from homeassistant.core import callback from homeassistant.const import EVENT_HOMEASSISTANT_STOP
from homeassistant.core import Event, callback
import homeassistant.helpers.config_validation as cv import homeassistant.helpers.config_validation as cv
from homeassistant.setup import async_setup_component from homeassistant.helpers.typing import HomeAssistantType
from .const import ( from .const import (
CONF_BAUD_RATE, CONF_BAUD_RATE,
CONF_DEVICE, CONF_DEVICE,
CONF_GATEWAYS,
CONF_NODES,
CONF_PERSISTENCE,
CONF_PERSISTENCE_FILE, CONF_PERSISTENCE_FILE,
CONF_RETAIN, CONF_RETAIN,
CONF_TCP_PORT, CONF_TCP_PORT,
@ -28,7 +27,9 @@ from .const import (
CONF_VERSION, CONF_VERSION,
DOMAIN, DOMAIN,
MYSENSORS_GATEWAY_READY, MYSENSORS_GATEWAY_READY,
MYSENSORS_GATEWAY_START_TASK,
MYSENSORS_GATEWAYS, MYSENSORS_GATEWAYS,
GatewayId,
) )
from .handler import HANDLERS from .handler import HANDLERS
from .helpers import discover_mysensors_platform, validate_child, validate_node 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 raise vol.Invalid("Device is not a valid domain name or ip address") from err
def get_mysensors_gateway(hass, gateway_id): async def try_connect(hass: HomeAssistantType, user_input: Dict[str, str]) -> bool:
"""Return MySensors gateway.""" """Try to connect to a gateway and report if it worked."""
if MYSENSORS_GATEWAYS not in hass.data: if user_input[CONF_DEVICE] == MQTT_COMPONENT:
hass.data[MYSENSORS_GATEWAYS] = {} return True # dont validate mqtt. mqtt gateways dont send ready messages :(
gateways = hass.data.get(MYSENSORS_GATEWAYS) 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) return gateways.get(gateway_id)
async def setup_gateways(hass, config): async def setup_gateway(
"""Set up all gateways.""" hass: HomeAssistantType, entry: ConfigEntry
conf = config[DOMAIN] ) -> Optional[BaseAsyncGateway]:
gateways = {} """Set up the Gateway for the given ConfigEntry."""
for index, gateway_conf in enumerate(conf[CONF_GATEWAYS]): ready_gateway = await _get_gateway(
persistence_file = gateway_conf.get( hass,
CONF_PERSISTENCE_FILE, device=entry.data[CONF_DEVICE],
hass.config.path(f"mysensors{index + 1}.pickle"), version=entry.data[CONF_VERSION],
) event_callback=_gw_callback_factory(hass, entry.entry_id),
ready_gateway = await _get_gateway(hass, config, gateway_conf, persistence_file) persistence_file=entry.data.get(
if ready_gateway is not None: CONF_PERSISTENCE_FILE, f"mysensors_{entry.entry_id}.json"
gateways[id(ready_gateway)] = ready_gateway ),
baud_rate=entry.data.get(CONF_BAUD_RATE),
return gateways 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.""" """Return gateway after setup of the gateway."""
conf = config[DOMAIN] if persistence_file is not None:
persistence = conf[CONF_PERSISTENCE] # interpret relative paths to be in hass config folder. absolute paths will be left as they are
version = conf[CONF_VERSION] persistence_file = hass.config.path(persistence_file)
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 device == MQTT_COMPONENT: if device == MQTT_COMPONENT:
if not await async_setup_component(hass, MQTT_COMPONENT, config): # what is the purpose of this?
return None # if not await async_setup_component(hass, MQTT_COMPONENT, entry):
# return None
mqtt = hass.components.mqtt mqtt = hass.components.mqtt
retain = conf[CONF_RETAIN]
def pub_callback(topic, payload, qos, retain): def pub_callback(topic, payload, qos, retain):
"""Call MQTT publish function.""" """Call MQTT publish function."""
@ -118,8 +185,8 @@ async def _get_gateway(hass, config, gateway_conf, persistence_file):
gateway = mysensors.AsyncMQTTGateway( gateway = mysensors.AsyncMQTTGateway(
pub_callback, pub_callback,
sub_callback, sub_callback,
in_prefix=in_prefix, in_prefix=topic_in_prefix,
out_prefix=out_prefix, out_prefix=topic_out_prefix,
retain=retain, retain=retain,
loop=hass.loop, loop=hass.loop,
event_callback=None, event_callback=None,
@ -154,25 +221,23 @@ async def _get_gateway(hass, config, gateway_conf, persistence_file):
) )
except vol.Invalid: except vol.Invalid:
# invalid ip address # invalid ip address
_LOGGER.error("Connect failed: Invalid device %s", device)
return None return None
gateway.metric = hass.config.units.is_metric gateway.event_callback = event_callback
gateway.optimistic = conf[CONF_OPTIMISTIC]
gateway.device = device
gateway.event_callback = _gw_callback_factory(hass, config)
gateway.nodes_config = gateway_conf[CONF_NODES]
if persistence: if persistence:
await gateway.start_persistence() await gateway.start_persistence()
return gateway 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.""" """Load any persistent devices and platforms and start gateway."""
discover_tasks = [] discover_tasks = []
start_tasks = [] start_tasks = []
for gateway in gateways.values(): discover_tasks.append(_discover_persistent_devices(hass, entry, gateway))
discover_tasks.append(_discover_persistent_devices(hass, hass_config, gateway)) start_tasks.append(_gw_start(hass, entry, gateway))
start_tasks.append(_gw_start(hass, gateway))
if discover_tasks: if discover_tasks:
# Make sure all devices and platforms are loaded before gateway start. # Make sure all devices and platforms are loaded before gateway start.
await asyncio.wait(discover_tasks) await asyncio.wait(discover_tasks)
@ -180,43 +245,58 @@ async def finish_setup(hass, hass_config, gateways):
await asyncio.wait(start_tasks) 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.""" """Discover platforms for devices loaded via persistence file."""
tasks = [] tasks = []
new_devices = defaultdict(list) new_devices = defaultdict(list)
for node_id in gateway.sensors: for node_id in gateway.sensors:
if not validate_node(gateway, node_id): if not validate_node(gateway, node_id):
continue continue
node = gateway.sensors[node_id] node: Sensor = gateway.sensors[node_id]
for child in node.children.values(): for child in node.children.values(): # child is of type ChildSensor
validated = validate_child(gateway, node_id, child) validated = validate_child(entry.entry_id, gateway, node_id, child)
for platform, dev_ids in validated.items(): for platform, dev_ids in validated.items():
new_devices[platform].extend(dev_ids) new_devices[platform].extend(dev_ids)
_LOGGER.debug("discovering persistent devices: %s", new_devices)
for platform, dev_ids in new_devices.items(): 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: if tasks:
await asyncio.wait(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.""" """Start the gateway."""
# Don't use hass.async_create_task to avoid holding up setup indefinitely. # 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 async def stop_this_gw(_: Event):
def gw_stop(event): await gw_stop(hass, entry, gateway)
"""Trigger to stop the gateway."""
hass.async_create_task(gateway.stop())
if not connect_task.done():
connect_task.cancel()
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, gw_stop) hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, stop_this_gw)
if gateway.device == "mqtt": if entry.data[CONF_DEVICE] == MQTT_COMPONENT:
# Gatways connected via mqtt doesn't send gateway ready message. # Gatways connected via mqtt doesn't send gateway ready message.
return return
gateway_ready = asyncio.Future() gateway_ready = asyncio.Future()
gateway_ready_key = MYSENSORS_GATEWAY_READY.format(id(gateway)) gateway_ready_key = MYSENSORS_GATEWAY_READY.format(entry.entry_id)
hass.data[gateway_ready_key] = gateway_ready hass.data[DOMAIN][gateway_ready_key] = gateway_ready
try: try:
with async_timeout.timeout(GATEWAY_READY_TIMEOUT): with async_timeout.timeout(GATEWAY_READY_TIMEOUT):
@ -224,27 +304,35 @@ async def _gw_start(hass, gateway):
except asyncio.TimeoutError: except asyncio.TimeoutError:
_LOGGER.warning( _LOGGER.warning(
"Gateway %s not ready after %s secs so continuing with setup", "Gateway %s not ready after %s secs so continuing with setup",
gateway.device, entry.data[CONF_DEVICE],
GATEWAY_READY_TIMEOUT, GATEWAY_READY_TIMEOUT,
) )
finally: 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.""" """Return a new callback for the gateway."""
@callback @callback
def mysensors_callback(msg): def mysensors_callback(msg: Message):
"""Handle messages from a MySensors gateway.""" """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) _LOGGER.debug("Node update: node %s child %s", msg.node_id, msg.child_id)
msg_type = msg.gateway.const.MessageType(msg.type) 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: if msg_handler is None:
return return
hass.async_create_task(msg_handler(hass, hass_config, msg)) hass.async_create_task(msg_handler(hass, gateway_id, msg))
return mysensors_callback return mysensors_callback

View file

@ -1,9 +1,21 @@
"""Handle MySensors messages.""" """Handle MySensors messages."""
from typing import Dict, List
from mysensors import Message
from homeassistant.core import callback from homeassistant.core import callback
from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.typing import HomeAssistantType
from homeassistant.util import decorator 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 .device import get_mysensors_devices
from .helpers import discover_mysensors_platform, validate_set_msg from .helpers import discover_mysensors_platform, validate_set_msg
@ -11,75 +23,91 @@ HANDLERS = decorator.Registry()
@HANDLERS.register("set") @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.""" """Handle a mysensors set message."""
validated = validate_set_msg(msg) validated = validate_set_msg(gateway_id, msg)
_handle_child_update(hass, hass_config, validated) _handle_child_update(hass, gateway_id, validated)
@HANDLERS.register("internal") @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.""" """Handle a mysensors internal message."""
internal = msg.gateway.const.Internal(msg.sub_type) internal = msg.gateway.const.Internal(msg.sub_type)
handler = HANDLERS.get(internal.name) handler = HANDLERS.get(internal.name)
if handler is None: if handler is None:
return return
await handler(hass, hass_config, msg) await handler(hass, gateway_id, msg)
@HANDLERS.register("I_BATTERY_LEVEL") @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 an internal battery level message."""
_handle_node_update(hass, msg) _handle_node_update(hass, gateway_id, msg)
@HANDLERS.register("I_HEARTBEAT_RESPONSE") @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 an heartbeat."""
_handle_node_update(hass, msg) _handle_node_update(hass, gateway_id, msg)
@HANDLERS.register("I_SKETCH_NAME") @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 an internal sketch name message."""
_handle_node_update(hass, msg) _handle_node_update(hass, gateway_id, msg)
@HANDLERS.register("I_SKETCH_VERSION") @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 an internal sketch version message."""
_handle_node_update(hass, msg) _handle_node_update(hass, gateway_id, msg)
@HANDLERS.register("I_GATEWAY_READY") @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. """Handle an internal gateway ready message.
Set asyncio future result if gateway is ready. 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(): if gateway_ready is None or gateway_ready.cancelled():
return return
gateway_ready.set_result(True) gateway_ready.set_result(True)
@callback @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.""" """Handle a child update."""
signals = [] signals: List[str] = []
# Update all platforms for the device via dispatcher. # Update all platforms for the device via dispatcher.
# Add/update entity for validated children. # Add/update entity for validated children.
for platform, dev_ids in validated.items(): for platform, dev_ids in validated.items():
devices = get_mysensors_devices(hass, platform) devices = get_mysensors_devices(hass, platform)
new_dev_ids = [] new_dev_ids: List[DevId] = []
for dev_id in dev_ids: for dev_id in dev_ids:
if dev_id in devices: if dev_id in devices:
signals.append(CHILD_CALLBACK.format(*dev_id)) signals.append(CHILD_CALLBACK.format(*dev_id))
else: else:
new_dev_ids.append(dev_id) new_dev_ids.append(dev_id)
if new_dev_ids: 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): for signal in set(signals):
# Only one signal per device is needed. # Only one signal per device is needed.
# A device can have multiple platforms, ie multiple schemas. # A device can have multiple platforms, ie multiple schemas.
@ -87,7 +115,7 @@ def _handle_child_update(hass, hass_config, validated):
@callback @callback
def _handle_node_update(hass, msg): def _handle_node_update(hass: HomeAssistantType, gateway_id: GatewayId, msg: Message):
"""Handle a node update.""" """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) async_dispatcher_send(hass, signal)

View file

@ -1,78 +1,109 @@
"""Helper functions for mysensors package.""" """Helper functions for mysensors package."""
from collections import defaultdict from collections import defaultdict
from enum import IntEnum
import logging import logging
from typing import DefaultDict, Dict, List, Optional, Set
from mysensors import BaseAsyncGateway, Message
from mysensors.sensor import ChildSensor
import voluptuous as vol import voluptuous as vol
from homeassistant.const import CONF_NAME from homeassistant.const import CONF_NAME
from homeassistant.core import callback from homeassistant.core import callback
from homeassistant.helpers import discovery
import homeassistant.helpers.config_validation as cv import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.util.decorator import Registry 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__) _LOGGER = logging.getLogger(__name__)
SCHEMAS = Registry() SCHEMAS = Registry()
@callback @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.""" """Discover a MySensors platform."""
task = hass.async_create_task( _LOGGER.debug("Discovering platform %s with devIds: %s", platform, new_devices)
discovery.async_load_platform( async_dispatcher_send(
hass, hass,
platform, MYSENSORS_DISCOVERY.format(gateway_id, platform),
DOMAIN, {
{ATTR_DEVICES: new_devices, CONF_NAME: DOMAIN}, ATTR_DEVICES: new_devices,
hass_config, 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.""" """Return a default validation schema for value types."""
schema = {value_type_name: cv.string} schema = {value_type_name: cv.string}
return get_child_schema(gateway, child, value_type_name, schema) return get_child_schema(gateway, child, value_type_name, schema)
@SCHEMAS.register(("light", "V_DIMMER")) @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.""" """Return a validation schema for V_DIMMER."""
schema = {"V_DIMMER": cv.string, "V_LIGHT": cv.string} schema = {"V_DIMMER": cv.string, "V_LIGHT": cv.string}
return get_child_schema(gateway, child, value_type_name, schema) return get_child_schema(gateway, child, value_type_name, schema)
@SCHEMAS.register(("light", "V_PERCENTAGE")) @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.""" """Return a validation schema for V_PERCENTAGE."""
schema = {"V_PERCENTAGE": cv.string, "V_STATUS": cv.string} schema = {"V_PERCENTAGE": cv.string, "V_STATUS": cv.string}
return get_child_schema(gateway, child, value_type_name, schema) return get_child_schema(gateway, child, value_type_name, schema)
@SCHEMAS.register(("light", "V_RGB")) @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.""" """Return a validation schema for V_RGB."""
schema = {"V_RGB": cv.string, "V_STATUS": cv.string} schema = {"V_RGB": cv.string, "V_STATUS": cv.string}
return get_child_schema(gateway, child, value_type_name, schema) return get_child_schema(gateway, child, value_type_name, schema)
@SCHEMAS.register(("light", "V_RGBW")) @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.""" """Return a validation schema for V_RGBW."""
schema = {"V_RGBW": cv.string, "V_STATUS": cv.string} schema = {"V_RGBW": cv.string, "V_STATUS": cv.string}
return get_child_schema(gateway, child, value_type_name, schema) return get_child_schema(gateway, child, value_type_name, schema)
@SCHEMAS.register(("switch", "V_IR_SEND")) @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.""" """Return a validation schema for V_IR_SEND."""
schema = {"V_IR_SEND": cv.string, "V_LIGHT": cv.string} schema = {"V_IR_SEND": cv.string, "V_LIGHT": cv.string}
return get_child_schema(gateway, child, value_type_name, schema) 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.""" """Return a child schema."""
set_req = gateway.const.SetReq set_req = gateway.const.SetReq
child_schema = child.get_schema(gateway.protocol_version) child_schema = child.get_schema(gateway.protocol_version)
@ -88,7 +119,9 @@ def get_child_schema(gateway, child, value_type_name, schema):
return 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.""" """Return a message for an invalid child during schema validation."""
pres = gateway.const.Presentation pres = gateway.const.Presentation
set_req = gateway.const.SetReq 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.""" """Validate a set message."""
if not validate_node(msg.gateway, msg.node_id): if not validate_node(msg.gateway, msg.node_id):
return {} return {}
child = msg.gateway.sensors[msg.node_id].children[msg.child_id] 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.""" """Validate a node."""
if gateway.sensors[node_id].sketch_name is None: if gateway.sensors[node_id].sketch_name is None:
_LOGGER.debug("Node %s is missing sketch name", node_id) _LOGGER.debug("Node %s is missing sketch name", node_id)
@ -113,31 +146,39 @@ def validate_node(gateway, node_id):
return True return True
def validate_child(gateway, node_id, child, value_type=None): def validate_child(
"""Validate a child.""" gateway_id: GatewayId,
validated = defaultdict(list) gateway: BaseAsyncGateway,
pres = gateway.const.Presentation node_id: int,
set_req = gateway.const.SetReq child: ChildSensor,
child_type_name = next( 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 (member.name for member in pres if member.value == child.type), None
) )
value_types = {value_type} if value_type else {*child.values} value_types: Set[int] = {value_type} if value_type else {*child.values}
value_type_names = { value_type_names: Set[ValueType] = {
member.name for member in set_req if member.value in value_types 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: if not platforms:
_LOGGER.warning("Child type %s is not supported", child.type) _LOGGER.warning("Child type %s is not supported", child.type)
return validated return validated
for platform in platforms: for platform in platforms:
platform_v_names = FLAT_PLATFORM_TYPES[platform, child_type_name] platform_v_names: Set[ValueType] = FLAT_PLATFORM_TYPES[
v_names = platform_v_names & value_type_names platform, child_type_name
]
v_names: Set[ValueType] = platform_v_names & value_type_names
if not v_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 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: for v_name in v_names:
child_schema_gen = SCHEMAS.get((platform, v_name), default_schema) 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, exc,
) )
continue 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) validated[platform].append(dev_id)
return validated return validated

View file

@ -1,4 +1,6 @@
"""Support for MySensors lights.""" """Support for MySensors lights."""
from typing import Callable
from homeassistant.components import mysensors from homeassistant.components import mysensors
from homeassistant.components.light import ( from homeassistant.components.light import (
ATTR_BRIGHTNESS, ATTR_BRIGHTNESS,
@ -10,27 +12,47 @@ from homeassistant.components.light import (
SUPPORT_WHITE_VALUE, SUPPORT_WHITE_VALUE,
LightEntity, 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.const import STATE_OFF, STATE_ON
from homeassistant.core import callback 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 import homeassistant.util.color as color_util
from homeassistant.util.color import rgb_hex_to_rgb_list from homeassistant.util.color import rgb_hex_to_rgb_list
SUPPORT_MYSENSORS_RGBW = SUPPORT_COLOR | SUPPORT_WHITE_VALUE SUPPORT_MYSENSORS_RGBW = SUPPORT_COLOR | SUPPORT_WHITE_VALUE
async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): async def async_setup_entry(
"""Set up the mysensors platform for lights.""" hass: HomeAssistantType, config_entry: ConfigEntry, async_add_entities: Callable
):
"""Set up this platform for a specific ConfigEntry(==Gateway)."""
device_class_map = { device_class_map = {
"S_DIMMER": MySensorsLightDimmer, "S_DIMMER": MySensorsLightDimmer,
"S_RGB_LIGHT": MySensorsLightRGB, "S_RGB_LIGHT": MySensorsLightRGB,
"S_RGBW_LIGHT": MySensorsLightRGBW, "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, hass,
DOMAIN, config_entry,
discovery_info, async_dispatcher_connect(
device_class_map, hass,
async_add_entities=async_add_entities, 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 the white value of this light between 0..255."""
return self._white return self._white
@property
def assumed_state(self):
"""Return true if unable to access real state of entity."""
return self.gateway.optimistic
@property @property
def is_on(self): def is_on(self):
"""Return true if device is on.""" """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 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 # optimistically assume that light has changed state
self._state = True self._state = True
self._values[set_req.V_LIGHT] = STATE_ON 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 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 # optimistically assume that light has changed state
self._brightness = brightness self._brightness = brightness
self._values[set_req.V_DIMMER] = percent 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 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 # optimistically assume that light has changed state
self._hs = color_util.color_RGB_to_hs(*rgb) self._hs = color_util.color_RGB_to_hs(*rgb)
self._white = white self._white = white
@ -145,7 +162,7 @@ class MySensorsLight(mysensors.device.MySensorsEntity, LightEntity):
"""Turn the device off.""" """Turn the device off."""
value_type = self.gateway.const.SetReq.V_LIGHT value_type = self.gateway.const.SetReq.V_LIGHT
self.gateway.set_child_value(self.node_id, self.child_id, value_type, 0, ack=1) 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 # optimistically assume that light has changed state
self._state = False self._state = False
self._values[value_type] = STATE_OFF self._values[value_type] = STATE_OFF
@ -188,7 +205,7 @@ class MySensorsLightDimmer(MySensorsLight):
"""Turn the device on.""" """Turn the device on."""
self._turn_on_light() self._turn_on_light()
self._turn_on_dimmer(**kwargs) self._turn_on_dimmer(**kwargs)
if self.gateway.optimistic: if self.assumed_state:
self.async_write_ha_state() self.async_write_ha_state()
async def async_update(self): async def async_update(self):
@ -214,7 +231,7 @@ class MySensorsLightRGB(MySensorsLight):
self._turn_on_light() self._turn_on_light()
self._turn_on_dimmer(**kwargs) self._turn_on_dimmer(**kwargs)
self._turn_on_rgb_and_w("%02x%02x%02x", **kwargs) self._turn_on_rgb_and_w("%02x%02x%02x", **kwargs)
if self.gateway.optimistic: if self.assumed_state:
self.async_write_ha_state() self.async_write_ha_state()
async def async_update(self): async def async_update(self):
@ -241,5 +258,5 @@ class MySensorsLightRGBW(MySensorsLightRGB):
self._turn_on_light() self._turn_on_light()
self._turn_on_dimmer(**kwargs) self._turn_on_dimmer(**kwargs)
self._turn_on_rgb_and_w("%02x%02x%02x%02x", **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() self.async_write_ha_state()

View file

@ -2,7 +2,15 @@
"domain": "mysensors", "domain": "mysensors",
"name": "MySensors", "name": "MySensors",
"documentation": "https://www.home-assistant.io/integrations/mysensors", "documentation": "https://www.home-assistant.io/integrations/mysensors",
"requirements": ["pymysensors==0.18.0"], "requirements": [
"after_dependencies": ["mqtt"], "pymysensors==0.20.1"
"codeowners": ["@MartinHjelmare"] ],
"after_dependencies": [
"mqtt"
],
"codeowners": [
"@MartinHjelmare",
"@functionpointer"
],
"config_flow": true
} }

View file

@ -1,6 +1,11 @@
"""Support for MySensors sensors.""" """Support for MySensors sensors."""
from typing import Callable
from homeassistant.components import mysensors 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.components.sensor import DOMAIN
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ( from homeassistant.const import (
CONDUCTIVITY, CONDUCTIVITY,
DEGREE, DEGREE,
@ -18,6 +23,8 @@ from homeassistant.const import (
VOLT, VOLT,
VOLUME_CUBIC_METERS, VOLUME_CUBIC_METERS,
) )
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.typing import HomeAssistantType
SENSORS = { SENSORS = {
"V_TEMP": [None, "mdi:thermometer"], "V_TEMP": [None, "mdi:thermometer"],
@ -54,14 +61,29 @@ SENSORS = {
} }
async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): async def async_setup_entry(
"""Set up the MySensors platform for sensors.""" hass: HomeAssistantType, config_entry: ConfigEntry, async_add_entities: Callable
mysensors.setup_mysensors_platform( ):
"""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, hass,
DOMAIN, config_entry,
discovery_info, async_dispatcher_connect(
MySensorsSensor, hass,
async_add_entities=async_add_entities, MYSENSORS_DISCOVERY.format(config_entry.entry_id, DOMAIN),
async_discover,
),
) )
@ -105,7 +127,7 @@ class MySensorsSensor(mysensors.device.MySensorsEntity):
pres = self.gateway.const.Presentation pres = self.gateway.const.Presentation
set_req = self.gateway.const.SetReq set_req = self.gateway.const.SetReq
SENSORS[set_req.V_TEMP.name][0] = ( 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]) sensor_type = SENSORS.get(set_req(self.value_type).name, [None, None])
if isinstance(sensor_type, dict): 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.""" """Support for MySensors switches."""
from typing import Callable
import voluptuous as vol import voluptuous as vol
from homeassistant.components import mysensors 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 from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON
import homeassistant.helpers.config_validation as cv 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" 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): async def async_setup_entry(
"""Set up the mysensors platform for switches.""" hass: HomeAssistantType, config_entry: ConfigEntry, async_add_entities: Callable
):
"""Set up this platform for a specific ConfigEntry(==Gateway)."""
device_class_map = { device_class_map = {
"S_DOOR": MySensorsSwitch, "S_DOOR": MySensorsSwitch,
"S_MOTION": MySensorsSwitch, "S_MOTION": MySensorsSwitch,
@ -32,13 +40,16 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=
"S_MOISTURE": MySensorsSwitch, "S_MOISTURE": MySensorsSwitch,
"S_WATER_QUALITY": MySensorsSwitch, "S_WATER_QUALITY": MySensorsSwitch,
} }
mysensors.setup_mysensors_platform(
hass, async def async_discover(discovery_info):
DOMAIN, """Discover and add a MySensors switch."""
discovery_info, mysensors.setup_mysensors_platform(
device_class_map, hass,
async_add_entities=async_add_entities, DOMAIN,
) discovery_info,
device_class_map,
async_add_entities=async_add_entities,
)
async def async_send_ir_code_service(service): async def async_send_ir_code_service(service):
"""Set IR code as device state attribute.""" """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, 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): class MySensorsSwitch(mysensors.device.MySensorsEntity, SwitchEntity):
"""Representation of the value of a MySensors Switch child node.""" """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 @property
def current_power_w(self): def current_power_w(self):
"""Return the current power usage in W.""" """Return the current power usage in W."""
@ -96,7 +112,7 @@ class MySensorsSwitch(mysensors.device.MySensorsEntity, SwitchEntity):
self.gateway.set_child_value( self.gateway.set_child_value(
self.node_id, self.child_id, self.value_type, 1, ack=1 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 # Optimistically assume that switch has changed state
self._values[self.value_type] = STATE_ON self._values[self.value_type] = STATE_ON
self.async_write_ha_state() self.async_write_ha_state()
@ -106,7 +122,7 @@ class MySensorsSwitch(mysensors.device.MySensorsEntity, SwitchEntity):
self.gateway.set_child_value( self.gateway.set_child_value(
self.node_id, self.child_id, self.value_type, 0, ack=1 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 # Optimistically assume that switch has changed state
self._values[self.value_type] = STATE_OFF self._values[self.value_type] = STATE_OFF
self.async_write_ha_state() self.async_write_ha_state()
@ -137,7 +153,7 @@ class MySensorsIRSwitch(MySensorsSwitch):
self.gateway.set_child_value( self.gateway.set_child_value(
self.node_id, self.child_id, set_req.V_LIGHT, 1, ack=1 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 # Optimistically assume that switch has changed state
self._values[self.value_type] = self._ir_code self._values[self.value_type] = self._ir_code
self._values[set_req.V_LIGHT] = STATE_ON self._values[set_req.V_LIGHT] = STATE_ON
@ -151,7 +167,7 @@ class MySensorsIRSwitch(MySensorsSwitch):
self.gateway.set_child_value( self.gateway.set_child_value(
self.node_id, self.child_id, set_req.V_LIGHT, 0, ack=1 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 # Optimistically assume that switch has changed state
self._values[set_req.V_LIGHT] = STATE_OFF self._values[set_req.V_LIGHT] = STATE_OFF
self.async_write_ha_state() 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", "motion_blinds",
"mqtt", "mqtt",
"myq", "myq",
"mysensors",
"neato", "neato",
"nest", "nest",
"netatmo", "netatmo",

View file

@ -1551,7 +1551,7 @@ pymusiccast==0.1.6
pymyq==2.0.14 pymyq==2.0.14
# homeassistant.components.mysensors # homeassistant.components.mysensors
pymysensors==0.18.0 pymysensors==0.20.1
# homeassistant.components.nanoleaf # homeassistant.components.nanoleaf
pynanoleaf==0.0.5 pynanoleaf==0.0.5

View file

@ -810,6 +810,9 @@ pymonoprice==0.3
# homeassistant.components.myq # homeassistant.components.myq
pymyq==2.0.14 pymyq==2.0.14
# homeassistant.components.mysensors
pymysensors==0.20.1
# homeassistant.components.nuki # homeassistant.components.nuki
pynuki==1.3.8 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