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:
parent
0d620eb7c3
commit
c01e01f797
26 changed files with 2371 additions and 333 deletions
15
.coveragerc
15
.coveragerc
|
@ -578,7 +578,20 @@ omit =
|
|||
homeassistant/components/mychevy/*
|
||||
homeassistant/components/mycroft/*
|
||||
homeassistant/components/mycroft/notify.py
|
||||
homeassistant/components/mysensors/*
|
||||
homeassistant/components/mysensors/__init__.py
|
||||
homeassistant/components/mysensors/binary_sensor.py
|
||||
homeassistant/components/mysensors/climate.py
|
||||
homeassistant/components/mysensors/const.py
|
||||
homeassistant/components/mysensors/cover.py
|
||||
homeassistant/components/mysensors/device.py
|
||||
homeassistant/components/mysensors/device_tracker.py
|
||||
homeassistant/components/mysensors/gateway.py
|
||||
homeassistant/components/mysensors/handler.py
|
||||
homeassistant/components/mysensors/helpers.py
|
||||
homeassistant/components/mysensors/light.py
|
||||
homeassistant/components/mysensors/notify.py
|
||||
homeassistant/components/mysensors/sensor.py
|
||||
homeassistant/components/mysensors/switch.py
|
||||
homeassistant/components/mystrom/binary_sensor.py
|
||||
homeassistant/components/mystrom/light.py
|
||||
homeassistant/components/mystrom/switch.py
|
||||
|
|
|
@ -288,7 +288,7 @@ homeassistant/components/mpd/* @fabaff
|
|||
homeassistant/components/mqtt/* @home-assistant/core @emontnemery
|
||||
homeassistant/components/msteams/* @peroyvind
|
||||
homeassistant/components/myq/* @bdraco
|
||||
homeassistant/components/mysensors/* @MartinHjelmare
|
||||
homeassistant/components/mysensors/* @MartinHjelmare @functionpointer
|
||||
homeassistant/components/mystrom/* @fabaff
|
||||
homeassistant/components/neato/* @dshokouhi @Santobert
|
||||
homeassistant/components/nederlandse_spoorwegen/* @YarmoM
|
||||
|
|
|
@ -1,12 +1,18 @@
|
|||
"""Connect to a MySensors gateway via pymysensors API."""
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Callable, Dict, List, Optional, Tuple, Type, Union
|
||||
|
||||
from mysensors import BaseAsyncGateway
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.components.mqtt import valid_publish_topic, valid_subscribe_topic
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_OPTIMISTIC
|
||||
from homeassistant.core import callback
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.helpers.typing import ConfigType, HomeAssistantType
|
||||
|
||||
from .const import (
|
||||
ATTR_DEVICES,
|
||||
|
@ -23,9 +29,14 @@ from .const import (
|
|||
CONF_VERSION,
|
||||
DOMAIN,
|
||||
MYSENSORS_GATEWAYS,
|
||||
MYSENSORS_ON_UNLOAD,
|
||||
SUPPORTED_PLATFORMS_WITH_ENTRY_SUPPORT,
|
||||
DevId,
|
||||
GatewayId,
|
||||
SensorType,
|
||||
)
|
||||
from .device import get_mysensors_devices
|
||||
from .gateway import finish_setup, get_mysensors_gateway, setup_gateways
|
||||
from .device import MySensorsDevice, MySensorsEntity, get_mysensors_devices
|
||||
from .gateway import finish_setup, get_mysensors_gateway, gw_stop, setup_gateway
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
@ -81,29 +92,38 @@ def deprecated(key):
|
|||
|
||||
NODE_SCHEMA = vol.Schema({cv.positive_int: {vol.Required(CONF_NODE_NAME): cv.string}})
|
||||
|
||||
GATEWAY_SCHEMA = {
|
||||
vol.Required(CONF_DEVICE): cv.string,
|
||||
vol.Optional(CONF_PERSISTENCE_FILE): vol.All(cv.string, is_persistence_file),
|
||||
vol.Optional(CONF_BAUD_RATE, default=DEFAULT_BAUD_RATE): cv.positive_int,
|
||||
vol.Optional(CONF_TCP_PORT, default=DEFAULT_TCP_PORT): cv.port,
|
||||
vol.Optional(CONF_TOPIC_IN_PREFIX): valid_subscribe_topic,
|
||||
vol.Optional(CONF_TOPIC_OUT_PREFIX): valid_publish_topic,
|
||||
vol.Optional(CONF_NODES, default={}): NODE_SCHEMA,
|
||||
}
|
||||
GATEWAY_SCHEMA = vol.Schema(
|
||||
vol.All(
|
||||
deprecated(CONF_NODES),
|
||||
{
|
||||
vol.Required(CONF_DEVICE): cv.string,
|
||||
vol.Optional(CONF_PERSISTENCE_FILE): vol.All(
|
||||
cv.string, is_persistence_file
|
||||
),
|
||||
vol.Optional(CONF_BAUD_RATE, default=DEFAULT_BAUD_RATE): cv.positive_int,
|
||||
vol.Optional(CONF_TCP_PORT, default=DEFAULT_TCP_PORT): cv.port,
|
||||
vol.Optional(CONF_TOPIC_IN_PREFIX): valid_subscribe_topic,
|
||||
vol.Optional(CONF_TOPIC_OUT_PREFIX): valid_publish_topic,
|
||||
vol.Optional(CONF_NODES, default={}): NODE_SCHEMA,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
CONFIG_SCHEMA = vol.Schema(
|
||||
{
|
||||
DOMAIN: vol.Schema(
|
||||
vol.All(
|
||||
deprecated(CONF_DEBUG),
|
||||
deprecated(CONF_OPTIMISTIC),
|
||||
deprecated(CONF_PERSISTENCE),
|
||||
{
|
||||
vol.Required(CONF_GATEWAYS): vol.All(
|
||||
cv.ensure_list, has_all_unique_files, [GATEWAY_SCHEMA]
|
||||
),
|
||||
vol.Optional(CONF_OPTIMISTIC, default=False): cv.boolean,
|
||||
vol.Optional(CONF_PERSISTENCE, default=True): cv.boolean,
|
||||
vol.Optional(CONF_RETAIN, default=True): cv.boolean,
|
||||
vol.Optional(CONF_VERSION, default=DEFAULT_VERSION): cv.string,
|
||||
vol.Optional(CONF_OPTIMISTIC, default=False): cv.boolean,
|
||||
vol.Optional(CONF_PERSISTENCE, default=True): cv.boolean,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
@ -112,69 +132,168 @@ CONFIG_SCHEMA = vol.Schema(
|
|||
)
|
||||
|
||||
|
||||
async def async_setup(hass, config):
|
||||
async def async_setup(hass: HomeAssistantType, config: ConfigType) -> bool:
|
||||
"""Set up the MySensors component."""
|
||||
gateways = await setup_gateways(hass, config)
|
||||
if DOMAIN not in config or bool(hass.config_entries.async_entries(DOMAIN)):
|
||||
return True
|
||||
|
||||
if not gateways:
|
||||
_LOGGER.error("No devices could be setup as gateways, check your configuration")
|
||||
return False
|
||||
config = config[DOMAIN]
|
||||
user_inputs = [
|
||||
{
|
||||
CONF_DEVICE: gw[CONF_DEVICE],
|
||||
CONF_BAUD_RATE: gw[CONF_BAUD_RATE],
|
||||
CONF_TCP_PORT: gw[CONF_TCP_PORT],
|
||||
CONF_TOPIC_OUT_PREFIX: gw.get(CONF_TOPIC_OUT_PREFIX, ""),
|
||||
CONF_TOPIC_IN_PREFIX: gw.get(CONF_TOPIC_IN_PREFIX, ""),
|
||||
CONF_RETAIN: config[CONF_RETAIN],
|
||||
CONF_VERSION: config[CONF_VERSION],
|
||||
CONF_PERSISTENCE_FILE: gw.get(CONF_PERSISTENCE_FILE)
|
||||
# nodes config ignored at this time. renaming nodes can now be done from the frontend.
|
||||
}
|
||||
for gw in config[CONF_GATEWAYS]
|
||||
]
|
||||
user_inputs = [
|
||||
{k: v for k, v in userinput.items() if v is not None}
|
||||
for userinput in user_inputs
|
||||
]
|
||||
|
||||
hass.data[MYSENSORS_GATEWAYS] = gateways
|
||||
|
||||
hass.async_create_task(finish_setup(hass, config, gateways))
|
||||
# there is an actual configuration in configuration.yaml, so we have to process it
|
||||
for user_input in user_inputs:
|
||||
hass.async_create_task(
|
||||
hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": config_entries.SOURCE_IMPORT},
|
||||
data=user_input,
|
||||
)
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def _get_mysensors_name(gateway, node_id, child_id):
|
||||
"""Return a name for a node child."""
|
||||
node_name = f"{gateway.sensors[node_id].sketch_name} {node_id}"
|
||||
node_name = next(
|
||||
(
|
||||
node[CONF_NODE_NAME]
|
||||
for conf_id, node in gateway.nodes_config.items()
|
||||
if node.get(CONF_NODE_NAME) is not None and conf_id == node_id
|
||||
),
|
||||
node_name,
|
||||
async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry) -> bool:
|
||||
"""Set up an instance of the MySensors integration.
|
||||
|
||||
Every instance has a connection to exactly one Gateway.
|
||||
"""
|
||||
gateway = await setup_gateway(hass, entry)
|
||||
|
||||
if not gateway:
|
||||
_LOGGER.error("Gateway setup failed for %s", entry.data)
|
||||
return False
|
||||
|
||||
if DOMAIN not in hass.data:
|
||||
hass.data[DOMAIN] = {}
|
||||
|
||||
if MYSENSORS_GATEWAYS not in hass.data[DOMAIN]:
|
||||
hass.data[DOMAIN][MYSENSORS_GATEWAYS] = {}
|
||||
hass.data[DOMAIN][MYSENSORS_GATEWAYS][entry.entry_id] = gateway
|
||||
|
||||
async def finish():
|
||||
await asyncio.gather(
|
||||
*[
|
||||
hass.config_entries.async_forward_entry_setup(entry, platform)
|
||||
for platform in SUPPORTED_PLATFORMS_WITH_ENTRY_SUPPORT
|
||||
]
|
||||
)
|
||||
await finish_setup(hass, entry, gateway)
|
||||
|
||||
hass.async_create_task(finish())
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistantType, entry: ConfigEntry) -> bool:
|
||||
"""Remove an instance of the MySensors integration."""
|
||||
|
||||
gateway = get_mysensors_gateway(hass, entry.entry_id)
|
||||
|
||||
unload_ok = all(
|
||||
await asyncio.gather(
|
||||
*[
|
||||
hass.config_entries.async_forward_entry_unload(entry, platform)
|
||||
for platform in SUPPORTED_PLATFORMS_WITH_ENTRY_SUPPORT
|
||||
]
|
||||
)
|
||||
)
|
||||
return f"{node_name} {child_id}"
|
||||
if not unload_ok:
|
||||
return False
|
||||
|
||||
key = MYSENSORS_ON_UNLOAD.format(entry.entry_id)
|
||||
if key in hass.data[DOMAIN]:
|
||||
for fnct in hass.data[DOMAIN][key]:
|
||||
fnct()
|
||||
|
||||
del hass.data[DOMAIN][MYSENSORS_GATEWAYS][entry.entry_id]
|
||||
|
||||
await gw_stop(hass, entry, gateway)
|
||||
return True
|
||||
|
||||
|
||||
async def on_unload(
|
||||
hass: HomeAssistantType, entry: Union[ConfigEntry, GatewayId], fnct: Callable
|
||||
) -> None:
|
||||
"""Register a callback to be called when entry is unloaded.
|
||||
|
||||
This function is used by platforms to cleanup after themselves
|
||||
"""
|
||||
if isinstance(entry, GatewayId):
|
||||
uniqueid = entry
|
||||
else:
|
||||
uniqueid = entry.entry_id
|
||||
key = MYSENSORS_ON_UNLOAD.format(uniqueid)
|
||||
if key not in hass.data[DOMAIN]:
|
||||
hass.data[DOMAIN][key] = []
|
||||
hass.data[DOMAIN][key].append(fnct)
|
||||
|
||||
|
||||
@callback
|
||||
def setup_mysensors_platform(
|
||||
hass,
|
||||
domain,
|
||||
discovery_info,
|
||||
device_class,
|
||||
device_args=None,
|
||||
async_add_entities=None,
|
||||
):
|
||||
"""Set up a MySensors platform."""
|
||||
domain: str, # hass platform name
|
||||
discovery_info: Optional[Dict[str, List[DevId]]],
|
||||
device_class: Union[Type[MySensorsDevice], Dict[SensorType, Type[MySensorsEntity]]],
|
||||
device_args: Optional[
|
||||
Tuple
|
||||
] = None, # extra arguments that will be given to the entity constructor
|
||||
async_add_entities: Callable = None,
|
||||
) -> Optional[List[MySensorsDevice]]:
|
||||
"""Set up a MySensors platform.
|
||||
|
||||
Sets up a bunch of instances of a single platform that is supported by this integration.
|
||||
The function is given a list of device ids, each one describing an instance to set up.
|
||||
The function is also given a class.
|
||||
A new instance of the class is created for every device id, and the device id is given to the constructor of the class
|
||||
"""
|
||||
# Only act if called via MySensors by discovery event.
|
||||
# Otherwise gateway is not set up.
|
||||
if not discovery_info:
|
||||
_LOGGER.debug("Skipping setup due to no discovery info")
|
||||
return None
|
||||
if device_args is None:
|
||||
device_args = ()
|
||||
new_devices = []
|
||||
new_dev_ids = discovery_info[ATTR_DEVICES]
|
||||
new_devices: List[MySensorsDevice] = []
|
||||
new_dev_ids: List[DevId] = discovery_info[ATTR_DEVICES]
|
||||
for dev_id in new_dev_ids:
|
||||
devices = get_mysensors_devices(hass, domain)
|
||||
devices: Dict[DevId, MySensorsDevice] = get_mysensors_devices(hass, domain)
|
||||
if dev_id in devices:
|
||||
_LOGGER.debug(
|
||||
"Skipping setup of %s for platform %s as it already exists",
|
||||
dev_id,
|
||||
domain,
|
||||
)
|
||||
continue
|
||||
gateway_id, node_id, child_id, value_type = dev_id
|
||||
gateway = get_mysensors_gateway(hass, gateway_id)
|
||||
gateway: Optional[BaseAsyncGateway] = get_mysensors_gateway(hass, gateway_id)
|
||||
if not gateway:
|
||||
_LOGGER.warning("Skipping setup of %s, no gateway found", dev_id)
|
||||
continue
|
||||
device_class_copy = device_class
|
||||
if isinstance(device_class, dict):
|
||||
child = gateway.sensors[node_id].children[child_id]
|
||||
s_type = gateway.const.Presentation(child.type).name
|
||||
device_class_copy = device_class[s_type]
|
||||
name = _get_mysensors_name(gateway, node_id, child_id)
|
||||
|
||||
args_copy = (*device_args, gateway, node_id, child_id, name, value_type)
|
||||
args_copy = (*device_args, gateway_id, gateway, node_id, child_id, value_type)
|
||||
devices[dev_id] = device_class_copy(*args_copy)
|
||||
new_devices.append(devices[dev_id])
|
||||
if new_devices:
|
||||
|
|
|
@ -1,4 +1,6 @@
|
|||
"""Support for MySensors binary sensors."""
|
||||
from typing import Callable
|
||||
|
||||
from homeassistant.components import mysensors
|
||||
from homeassistant.components.binary_sensor import (
|
||||
DEVICE_CLASS_MOISTURE,
|
||||
|
@ -10,7 +12,13 @@ from homeassistant.components.binary_sensor import (
|
|||
DOMAIN,
|
||||
BinarySensorEntity,
|
||||
)
|
||||
from homeassistant.components.mysensors import on_unload
|
||||
from homeassistant.components.mysensors.const import MYSENSORS_DISCOVERY
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import STATE_ON
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.helpers.typing import HomeAssistantType
|
||||
|
||||
SENSORS = {
|
||||
"S_DOOR": "door",
|
||||
|
@ -24,14 +32,30 @@ SENSORS = {
|
|||
}
|
||||
|
||||
|
||||
async def async_setup_platform(hass, config, async_add_entities, discovery_info=None):
|
||||
"""Set up the mysensors platform for binary sensors."""
|
||||
mysensors.setup_mysensors_platform(
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistantType, config_entry: ConfigEntry, async_add_entities: Callable
|
||||
):
|
||||
"""Set up this platform for a specific ConfigEntry(==Gateway)."""
|
||||
|
||||
@callback
|
||||
def async_discover(discovery_info):
|
||||
"""Discover and add a MySensors binary_sensor."""
|
||||
mysensors.setup_mysensors_platform(
|
||||
hass,
|
||||
DOMAIN,
|
||||
discovery_info,
|
||||
MySensorsBinarySensor,
|
||||
async_add_entities=async_add_entities,
|
||||
)
|
||||
|
||||
await on_unload(
|
||||
hass,
|
||||
DOMAIN,
|
||||
discovery_info,
|
||||
MySensorsBinarySensor,
|
||||
async_add_entities=async_add_entities,
|
||||
config_entry,
|
||||
async_dispatcher_connect(
|
||||
hass,
|
||||
MYSENSORS_DISCOVERY.format(config_entry.entry_id, DOMAIN),
|
||||
async_discover,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
|
|
@ -1,4 +1,6 @@
|
|||
"""MySensors platform that offers a Climate (MySensors-HVAC) component."""
|
||||
from typing import Callable
|
||||
|
||||
from homeassistant.components import mysensors
|
||||
from homeassistant.components.climate import ClimateEntity
|
||||
from homeassistant.components.climate.const import (
|
||||
|
@ -13,7 +15,12 @@ from homeassistant.components.climate.const import (
|
|||
SUPPORT_TARGET_TEMPERATURE,
|
||||
SUPPORT_TARGET_TEMPERATURE_RANGE,
|
||||
)
|
||||
from homeassistant.components.mysensors import on_unload
|
||||
from homeassistant.components.mysensors.const import MYSENSORS_DISCOVERY
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import ATTR_TEMPERATURE, TEMP_CELSIUS, TEMP_FAHRENHEIT
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.helpers.typing import HomeAssistantType
|
||||
|
||||
DICT_HA_TO_MYS = {
|
||||
HVAC_MODE_AUTO: "AutoChangeOver",
|
||||
|
@ -32,14 +39,29 @@ FAN_LIST = ["Auto", "Min", "Normal", "Max"]
|
|||
OPERATION_LIST = [HVAC_MODE_OFF, HVAC_MODE_AUTO, HVAC_MODE_COOL, HVAC_MODE_HEAT]
|
||||
|
||||
|
||||
async def async_setup_platform(hass, config, async_add_entities, discovery_info=None):
|
||||
"""Set up the mysensors climate."""
|
||||
mysensors.setup_mysensors_platform(
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistantType, config_entry: ConfigEntry, async_add_entities: Callable
|
||||
):
|
||||
"""Set up this platform for a specific ConfigEntry(==Gateway)."""
|
||||
|
||||
async def async_discover(discovery_info):
|
||||
"""Discover and add a MySensors climate."""
|
||||
mysensors.setup_mysensors_platform(
|
||||
hass,
|
||||
DOMAIN,
|
||||
discovery_info,
|
||||
MySensorsHVAC,
|
||||
async_add_entities=async_add_entities,
|
||||
)
|
||||
|
||||
await on_unload(
|
||||
hass,
|
||||
DOMAIN,
|
||||
discovery_info,
|
||||
MySensorsHVAC,
|
||||
async_add_entities=async_add_entities,
|
||||
config_entry,
|
||||
async_dispatcher_connect(
|
||||
hass,
|
||||
MYSENSORS_DISCOVERY.format(config_entry.entry_id, DOMAIN),
|
||||
async_discover,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
@ -62,15 +84,10 @@ class MySensorsHVAC(mysensors.device.MySensorsEntity, ClimateEntity):
|
|||
features = features | SUPPORT_TARGET_TEMPERATURE
|
||||
return features
|
||||
|
||||
@property
|
||||
def assumed_state(self):
|
||||
"""Return True if unable to access real state of entity."""
|
||||
return self.gateway.optimistic
|
||||
|
||||
@property
|
||||
def temperature_unit(self):
|
||||
"""Return the unit of measurement."""
|
||||
return TEMP_CELSIUS if self.gateway.metric else TEMP_FAHRENHEIT
|
||||
return TEMP_CELSIUS if self.hass.config.units.is_metric else TEMP_FAHRENHEIT
|
||||
|
||||
@property
|
||||
def current_temperature(self):
|
||||
|
@ -159,7 +176,7 @@ class MySensorsHVAC(mysensors.device.MySensorsEntity, ClimateEntity):
|
|||
self.gateway.set_child_value(
|
||||
self.node_id, self.child_id, value_type, value, ack=1
|
||||
)
|
||||
if self.gateway.optimistic:
|
||||
if self.assumed_state:
|
||||
# Optimistically assume that device has changed state
|
||||
self._values[value_type] = value
|
||||
self.async_write_ha_state()
|
||||
|
@ -170,7 +187,7 @@ class MySensorsHVAC(mysensors.device.MySensorsEntity, ClimateEntity):
|
|||
self.gateway.set_child_value(
|
||||
self.node_id, self.child_id, set_req.V_HVAC_SPEED, fan_mode, ack=1
|
||||
)
|
||||
if self.gateway.optimistic:
|
||||
if self.assumed_state:
|
||||
# Optimistically assume that device has changed state
|
||||
self._values[set_req.V_HVAC_SPEED] = fan_mode
|
||||
self.async_write_ha_state()
|
||||
|
@ -184,7 +201,7 @@ class MySensorsHVAC(mysensors.device.MySensorsEntity, ClimateEntity):
|
|||
DICT_HA_TO_MYS[hvac_mode],
|
||||
ack=1,
|
||||
)
|
||||
if self.gateway.optimistic:
|
||||
if self.assumed_state:
|
||||
# Optimistically assume that device has changed state
|
||||
self._values[self.value_type] = hvac_mode
|
||||
self.async_write_ha_state()
|
||||
|
|
300
homeassistant/components/mysensors/config_flow.py
Normal file
300
homeassistant/components/mysensors/config_flow.py
Normal 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
|
|
@ -1,33 +1,69 @@
|
|||
"""MySensors constants."""
|
||||
from collections import defaultdict
|
||||
from typing import Dict, List, Literal, Set, Tuple
|
||||
|
||||
ATTR_DEVICES = "devices"
|
||||
ATTR_DEVICES: str = "devices"
|
||||
ATTR_GATEWAY_ID: str = "gateway_id"
|
||||
|
||||
CONF_BAUD_RATE = "baud_rate"
|
||||
CONF_DEVICE = "device"
|
||||
CONF_GATEWAYS = "gateways"
|
||||
CONF_NODES = "nodes"
|
||||
CONF_PERSISTENCE = "persistence"
|
||||
CONF_PERSISTENCE_FILE = "persistence_file"
|
||||
CONF_RETAIN = "retain"
|
||||
CONF_TCP_PORT = "tcp_port"
|
||||
CONF_TOPIC_IN_PREFIX = "topic_in_prefix"
|
||||
CONF_TOPIC_OUT_PREFIX = "topic_out_prefix"
|
||||
CONF_VERSION = "version"
|
||||
CONF_BAUD_RATE: str = "baud_rate"
|
||||
CONF_DEVICE: str = "device"
|
||||
CONF_GATEWAYS: str = "gateways"
|
||||
CONF_NODES: str = "nodes"
|
||||
CONF_PERSISTENCE: str = "persistence"
|
||||
CONF_PERSISTENCE_FILE: str = "persistence_file"
|
||||
CONF_RETAIN: str = "retain"
|
||||
CONF_TCP_PORT: str = "tcp_port"
|
||||
CONF_TOPIC_IN_PREFIX: str = "topic_in_prefix"
|
||||
CONF_TOPIC_OUT_PREFIX: str = "topic_out_prefix"
|
||||
CONF_VERSION: str = "version"
|
||||
CONF_GATEWAY_TYPE: str = "gateway_type"
|
||||
ConfGatewayType = Literal["Serial", "TCP", "MQTT"]
|
||||
CONF_GATEWAY_TYPE_SERIAL: ConfGatewayType = "Serial"
|
||||
CONF_GATEWAY_TYPE_TCP: ConfGatewayType = "TCP"
|
||||
CONF_GATEWAY_TYPE_MQTT: ConfGatewayType = "MQTT"
|
||||
CONF_GATEWAY_TYPE_ALL: List[str] = [
|
||||
CONF_GATEWAY_TYPE_MQTT,
|
||||
CONF_GATEWAY_TYPE_SERIAL,
|
||||
CONF_GATEWAY_TYPE_TCP,
|
||||
]
|
||||
|
||||
DOMAIN = "mysensors"
|
||||
MYSENSORS_GATEWAY_READY = "mysensors_gateway_ready_{}"
|
||||
MYSENSORS_GATEWAYS = "mysensors_gateways"
|
||||
PLATFORM = "platform"
|
||||
SCHEMA = "schema"
|
||||
CHILD_CALLBACK = "mysensors_child_callback_{}_{}_{}_{}"
|
||||
NODE_CALLBACK = "mysensors_node_callback_{}_{}"
|
||||
TYPE = "type"
|
||||
UPDATE_DELAY = 0.1
|
||||
|
||||
SERVICE_SEND_IR_CODE = "send_ir_code"
|
||||
DOMAIN: str = "mysensors"
|
||||
MYSENSORS_GATEWAY_READY: str = "mysensors_gateway_ready_{}"
|
||||
MYSENSORS_GATEWAY_START_TASK: str = "mysensors_gateway_start_task_{}"
|
||||
MYSENSORS_GATEWAYS: str = "mysensors_gateways"
|
||||
PLATFORM: str = "platform"
|
||||
SCHEMA: str = "schema"
|
||||
CHILD_CALLBACK: str = "mysensors_child_callback_{}_{}_{}_{}"
|
||||
NODE_CALLBACK: str = "mysensors_node_callback_{}_{}"
|
||||
MYSENSORS_DISCOVERY = "mysensors_discovery_{}_{}"
|
||||
MYSENSORS_ON_UNLOAD = "mysensors_on_unload_{}"
|
||||
TYPE: str = "type"
|
||||
UPDATE_DELAY: float = 0.1
|
||||
|
||||
BINARY_SENSOR_TYPES = {
|
||||
SERVICE_SEND_IR_CODE: str = "send_ir_code"
|
||||
|
||||
SensorType = str
|
||||
# S_DOOR, S_MOTION, S_SMOKE, ...
|
||||
|
||||
ValueType = str
|
||||
# V_TRIPPED, V_ARMED, V_STATUS, V_PERCENTAGE, ...
|
||||
|
||||
GatewayId = str
|
||||
# a unique id generated by config_flow.py and stored in the ConfigEntry as the entry id.
|
||||
#
|
||||
# Gateway may be fetched by giving the gateway id to get_mysensors_gateway()
|
||||
|
||||
DevId = Tuple[GatewayId, int, int, int]
|
||||
# describes the backend of a hass entity. Contents are: GatewayId, node_id, child_id, v_type as int
|
||||
#
|
||||
# The string version of v_type can be looked up in the enum gateway.const.SetReq of the appropriate BaseAsyncGateway
|
||||
# Home Assistant Entities are quite limited and only ever do one thing.
|
||||
# MySensors Nodes have multiple child_ids each with a s_type several associated v_types
|
||||
# The MySensors integration brings these together by creating an entity for every v_type of every child_id of every node.
|
||||
# The DevId tuple perfectly captures this.
|
||||
|
||||
BINARY_SENSOR_TYPES: Dict[SensorType, Set[ValueType]] = {
|
||||
"S_DOOR": {"V_TRIPPED"},
|
||||
"S_MOTION": {"V_TRIPPED"},
|
||||
"S_SMOKE": {"V_TRIPPED"},
|
||||
|
@ -38,21 +74,23 @@ BINARY_SENSOR_TYPES = {
|
|||
"S_MOISTURE": {"V_TRIPPED"},
|
||||
}
|
||||
|
||||
CLIMATE_TYPES = {"S_HVAC": {"V_HVAC_FLOW_STATE"}}
|
||||
CLIMATE_TYPES: Dict[SensorType, Set[ValueType]] = {"S_HVAC": {"V_HVAC_FLOW_STATE"}}
|
||||
|
||||
COVER_TYPES = {"S_COVER": {"V_DIMMER", "V_PERCENTAGE", "V_LIGHT", "V_STATUS"}}
|
||||
COVER_TYPES: Dict[SensorType, Set[ValueType]] = {
|
||||
"S_COVER": {"V_DIMMER", "V_PERCENTAGE", "V_LIGHT", "V_STATUS"}
|
||||
}
|
||||
|
||||
DEVICE_TRACKER_TYPES = {"S_GPS": {"V_POSITION"}}
|
||||
DEVICE_TRACKER_TYPES: Dict[SensorType, Set[ValueType]] = {"S_GPS": {"V_POSITION"}}
|
||||
|
||||
LIGHT_TYPES = {
|
||||
LIGHT_TYPES: Dict[SensorType, Set[ValueType]] = {
|
||||
"S_DIMMER": {"V_DIMMER", "V_PERCENTAGE"},
|
||||
"S_RGB_LIGHT": {"V_RGB"},
|
||||
"S_RGBW_LIGHT": {"V_RGBW"},
|
||||
}
|
||||
|
||||
NOTIFY_TYPES = {"S_INFO": {"V_TEXT"}}
|
||||
NOTIFY_TYPES: Dict[SensorType, Set[ValueType]] = {"S_INFO": {"V_TEXT"}}
|
||||
|
||||
SENSOR_TYPES = {
|
||||
SENSOR_TYPES: Dict[SensorType, Set[ValueType]] = {
|
||||
"S_SOUND": {"V_LEVEL"},
|
||||
"S_VIBRATION": {"V_LEVEL"},
|
||||
"S_MOISTURE": {"V_LEVEL"},
|
||||
|
@ -80,7 +118,7 @@ SENSOR_TYPES = {
|
|||
"S_DUST": {"V_DUST_LEVEL", "V_LEVEL"},
|
||||
}
|
||||
|
||||
SWITCH_TYPES = {
|
||||
SWITCH_TYPES: Dict[SensorType, Set[ValueType]] = {
|
||||
"S_LIGHT": {"V_LIGHT"},
|
||||
"S_BINARY": {"V_STATUS"},
|
||||
"S_DOOR": {"V_ARMED"},
|
||||
|
@ -97,7 +135,7 @@ SWITCH_TYPES = {
|
|||
}
|
||||
|
||||
|
||||
PLATFORM_TYPES = {
|
||||
PLATFORM_TYPES: Dict[str, Dict[SensorType, Set[ValueType]]] = {
|
||||
"binary_sensor": BINARY_SENSOR_TYPES,
|
||||
"climate": CLIMATE_TYPES,
|
||||
"cover": COVER_TYPES,
|
||||
|
@ -108,13 +146,19 @@ PLATFORM_TYPES = {
|
|||
"switch": SWITCH_TYPES,
|
||||
}
|
||||
|
||||
FLAT_PLATFORM_TYPES = {
|
||||
FLAT_PLATFORM_TYPES: Dict[Tuple[str, SensorType], Set[ValueType]] = {
|
||||
(platform, s_type_name): v_type_name
|
||||
for platform, platform_types in PLATFORM_TYPES.items()
|
||||
for s_type_name, v_type_name in platform_types.items()
|
||||
}
|
||||
|
||||
TYPE_TO_PLATFORMS = defaultdict(list)
|
||||
TYPE_TO_PLATFORMS: Dict[SensorType, List[str]] = defaultdict(list)
|
||||
|
||||
for platform, platform_types in PLATFORM_TYPES.items():
|
||||
for s_type_name in platform_types:
|
||||
TYPE_TO_PLATFORMS[s_type_name].append(platform)
|
||||
|
||||
SUPPORTED_PLATFORMS_WITH_ENTRY_SUPPORT = set(PLATFORM_TYPES.keys()) - {
|
||||
"notify",
|
||||
"device_tracker",
|
||||
}
|
||||
|
|
|
@ -1,28 +1,48 @@
|
|||
"""Support for MySensors covers."""
|
||||
import logging
|
||||
from typing import Callable
|
||||
|
||||
from homeassistant.components import mysensors
|
||||
from homeassistant.components.cover import ATTR_POSITION, DOMAIN, CoverEntity
|
||||
from homeassistant.components.mysensors import on_unload
|
||||
from homeassistant.components.mysensors.const import MYSENSORS_DISCOVERY
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import STATE_OFF, STATE_ON
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.helpers.typing import HomeAssistantType
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_setup_platform(hass, config, async_add_entities, discovery_info=None):
|
||||
"""Set up the mysensors platform for covers."""
|
||||
mysensors.setup_mysensors_platform(
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistantType, config_entry: ConfigEntry, async_add_entities: Callable
|
||||
):
|
||||
"""Set up this platform for a specific ConfigEntry(==Gateway)."""
|
||||
|
||||
async def async_discover(discovery_info):
|
||||
"""Discover and add a MySensors cover."""
|
||||
mysensors.setup_mysensors_platform(
|
||||
hass,
|
||||
DOMAIN,
|
||||
discovery_info,
|
||||
MySensorsCover,
|
||||
async_add_entities=async_add_entities,
|
||||
)
|
||||
|
||||
await on_unload(
|
||||
hass,
|
||||
DOMAIN,
|
||||
discovery_info,
|
||||
MySensorsCover,
|
||||
async_add_entities=async_add_entities,
|
||||
config_entry.entry_id,
|
||||
async_dispatcher_connect(
|
||||
hass,
|
||||
MYSENSORS_DISCOVERY.format(config_entry.entry_id, DOMAIN),
|
||||
async_discover,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class MySensorsCover(mysensors.device.MySensorsEntity, CoverEntity):
|
||||
"""Representation of the value of a MySensors Cover child node."""
|
||||
|
||||
@property
|
||||
def assumed_state(self):
|
||||
"""Return True if unable to access real state of entity."""
|
||||
return self.gateway.optimistic
|
||||
|
||||
@property
|
||||
def is_closed(self):
|
||||
"""Return True if cover is closed."""
|
||||
|
@ -46,7 +66,7 @@ class MySensorsCover(mysensors.device.MySensorsEntity, CoverEntity):
|
|||
self.gateway.set_child_value(
|
||||
self.node_id, self.child_id, set_req.V_UP, 1, ack=1
|
||||
)
|
||||
if self.gateway.optimistic:
|
||||
if self.assumed_state:
|
||||
# Optimistically assume that cover has changed state.
|
||||
if set_req.V_DIMMER in self._values:
|
||||
self._values[set_req.V_DIMMER] = 100
|
||||
|
@ -60,7 +80,7 @@ class MySensorsCover(mysensors.device.MySensorsEntity, CoverEntity):
|
|||
self.gateway.set_child_value(
|
||||
self.node_id, self.child_id, set_req.V_DOWN, 1, ack=1
|
||||
)
|
||||
if self.gateway.optimistic:
|
||||
if self.assumed_state:
|
||||
# Optimistically assume that cover has changed state.
|
||||
if set_req.V_DIMMER in self._values:
|
||||
self._values[set_req.V_DIMMER] = 0
|
||||
|
@ -75,7 +95,7 @@ class MySensorsCover(mysensors.device.MySensorsEntity, CoverEntity):
|
|||
self.gateway.set_child_value(
|
||||
self.node_id, self.child_id, set_req.V_DIMMER, position, ack=1
|
||||
)
|
||||
if self.gateway.optimistic:
|
||||
if self.assumed_state:
|
||||
# Optimistically assume that cover has changed state.
|
||||
self._values[set_req.V_DIMMER] = position
|
||||
self.async_write_ha_state()
|
||||
|
|
|
@ -1,13 +1,26 @@
|
|||
"""Handle MySensors devices."""
|
||||
from functools import partial
|
||||
import logging
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from mysensors import BaseAsyncGateway, Sensor
|
||||
from mysensors.sensor import ChildSensor
|
||||
|
||||
from homeassistant.const import ATTR_BATTERY_LEVEL, STATE_OFF, STATE_ON
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.helpers.entity import Entity
|
||||
|
||||
from .const import CHILD_CALLBACK, NODE_CALLBACK, UPDATE_DELAY
|
||||
from .const import (
|
||||
CHILD_CALLBACK,
|
||||
CONF_DEVICE,
|
||||
DOMAIN,
|
||||
NODE_CALLBACK,
|
||||
PLATFORM_TYPES,
|
||||
UPDATE_DELAY,
|
||||
DevId,
|
||||
GatewayId,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
@ -19,33 +32,94 @@ ATTR_HEARTBEAT = "heartbeat"
|
|||
MYSENSORS_PLATFORM_DEVICES = "mysensors_devices_{}"
|
||||
|
||||
|
||||
def get_mysensors_devices(hass, domain):
|
||||
"""Return MySensors devices for a platform."""
|
||||
if MYSENSORS_PLATFORM_DEVICES.format(domain) not in hass.data:
|
||||
hass.data[MYSENSORS_PLATFORM_DEVICES.format(domain)] = {}
|
||||
return hass.data[MYSENSORS_PLATFORM_DEVICES.format(domain)]
|
||||
|
||||
|
||||
class MySensorsDevice:
|
||||
"""Representation of a MySensors device."""
|
||||
|
||||
def __init__(self, gateway, node_id, child_id, name, value_type):
|
||||
def __init__(
|
||||
self,
|
||||
gateway_id: GatewayId,
|
||||
gateway: BaseAsyncGateway,
|
||||
node_id: int,
|
||||
child_id: int,
|
||||
value_type: int,
|
||||
):
|
||||
"""Set up the MySensors device."""
|
||||
self.gateway = gateway
|
||||
self.node_id = node_id
|
||||
self.child_id = child_id
|
||||
self._name = name
|
||||
self.value_type = value_type
|
||||
child = gateway.sensors[node_id].children[child_id]
|
||||
self.child_type = child.type
|
||||
self.gateway_id: GatewayId = gateway_id
|
||||
self.gateway: BaseAsyncGateway = gateway
|
||||
self.node_id: int = node_id
|
||||
self.child_id: int = child_id
|
||||
self.value_type: int = value_type # value_type as int. string variant can be looked up in gateway consts
|
||||
self.child_type = self._child.type
|
||||
self._values = {}
|
||||
self._update_scheduled = False
|
||||
self.hass = None
|
||||
|
||||
@property
|
||||
def dev_id(self) -> DevId:
|
||||
"""Return the DevId of this device.
|
||||
|
||||
It is used to route incoming MySensors messages to the correct device/entity.
|
||||
"""
|
||||
return self.gateway_id, self.node_id, self.child_id, self.value_type
|
||||
|
||||
@property
|
||||
def _logger(self):
|
||||
return logging.getLogger(f"{__name__}.{self.name}")
|
||||
|
||||
async def async_will_remove_from_hass(self):
|
||||
"""Remove this entity from home assistant."""
|
||||
for platform in PLATFORM_TYPES:
|
||||
platform_str = MYSENSORS_PLATFORM_DEVICES.format(platform)
|
||||
if platform_str in self.hass.data[DOMAIN]:
|
||||
platform_dict = self.hass.data[DOMAIN][platform_str]
|
||||
if self.dev_id in platform_dict:
|
||||
del platform_dict[self.dev_id]
|
||||
self._logger.debug(
|
||||
"deleted %s from platform %s", self.dev_id, platform
|
||||
)
|
||||
|
||||
@property
|
||||
def _node(self) -> Sensor:
|
||||
return self.gateway.sensors[self.node_id]
|
||||
|
||||
@property
|
||||
def _child(self) -> ChildSensor:
|
||||
return self._node.children[self.child_id]
|
||||
|
||||
@property
|
||||
def sketch_name(self) -> str:
|
||||
"""Return the name of the sketch running on the whole node (will be the same for several entities!)."""
|
||||
return self._node.sketch_name
|
||||
|
||||
@property
|
||||
def sketch_version(self) -> str:
|
||||
"""Return the version of the sketch running on the whole node (will be the same for several entities!)."""
|
||||
return self._node.sketch_version
|
||||
|
||||
@property
|
||||
def node_name(self) -> str:
|
||||
"""Name of the whole node (will be the same for several entities!)."""
|
||||
return f"{self.sketch_name} {self.node_id}"
|
||||
|
||||
@property
|
||||
def unique_id(self) -> str:
|
||||
"""Return a unique ID for use in home assistant."""
|
||||
return f"{self.gateway_id}-{self.node_id}-{self.child_id}-{self.value_type}"
|
||||
|
||||
@property
|
||||
def device_info(self) -> Optional[Dict[str, Any]]:
|
||||
"""Return a dict that allows home assistant to puzzle all entities belonging to a node together."""
|
||||
return {
|
||||
"identifiers": {(DOMAIN, f"{self.gateway_id}-{self.node_id}")},
|
||||
"name": self.node_name,
|
||||
"manufacturer": DOMAIN,
|
||||
"sw_version": self.sketch_version,
|
||||
}
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Return the name of this entity."""
|
||||
return self._name
|
||||
return f"{self.node_name} {self.child_id}"
|
||||
|
||||
@property
|
||||
def device_state_attributes(self):
|
||||
|
@ -57,9 +131,12 @@ class MySensorsDevice:
|
|||
ATTR_HEARTBEAT: node.heartbeat,
|
||||
ATTR_CHILD_ID: self.child_id,
|
||||
ATTR_DESCRIPTION: child.description,
|
||||
ATTR_DEVICE: self.gateway.device,
|
||||
ATTR_NODE_ID: self.node_id,
|
||||
}
|
||||
# This works when we are actually an Entity (i.e. all platforms except device_tracker)
|
||||
if hasattr(self, "platform"):
|
||||
# pylint: disable=no-member
|
||||
attr[ATTR_DEVICE] = self.platform.config_entry.data[CONF_DEVICE]
|
||||
|
||||
set_req = self.gateway.const.SetReq
|
||||
|
||||
|
@ -76,7 +153,7 @@ class MySensorsDevice:
|
|||
for value_type, value in child.values.items():
|
||||
_LOGGER.debug(
|
||||
"Entity update: %s: value_type %s, value = %s",
|
||||
self._name,
|
||||
self.name,
|
||||
value_type,
|
||||
value,
|
||||
)
|
||||
|
@ -116,6 +193,13 @@ class MySensorsDevice:
|
|||
self.hass.loop.call_later(UPDATE_DELAY, delayed_update)
|
||||
|
||||
|
||||
def get_mysensors_devices(hass, domain: str) -> Dict[DevId, MySensorsDevice]:
|
||||
"""Return MySensors devices for a hass platform name."""
|
||||
if MYSENSORS_PLATFORM_DEVICES.format(domain) not in hass.data[DOMAIN]:
|
||||
hass.data[DOMAIN][MYSENSORS_PLATFORM_DEVICES.format(domain)] = {}
|
||||
return hass.data[DOMAIN][MYSENSORS_PLATFORM_DEVICES.format(domain)]
|
||||
|
||||
|
||||
class MySensorsEntity(MySensorsDevice, Entity):
|
||||
"""Representation of a MySensors entity."""
|
||||
|
||||
|
@ -135,17 +219,17 @@ class MySensorsEntity(MySensorsDevice, Entity):
|
|||
|
||||
async def async_added_to_hass(self):
|
||||
"""Register update callback."""
|
||||
gateway_id = id(self.gateway)
|
||||
dev_id = gateway_id, self.node_id, self.child_id, self.value_type
|
||||
self.async_on_remove(
|
||||
async_dispatcher_connect(
|
||||
self.hass, CHILD_CALLBACK.format(*dev_id), self.async_update_callback
|
||||
self.hass,
|
||||
CHILD_CALLBACK.format(*self.dev_id),
|
||||
self.async_update_callback,
|
||||
)
|
||||
)
|
||||
self.async_on_remove(
|
||||
async_dispatcher_connect(
|
||||
self.hass,
|
||||
NODE_CALLBACK.format(gateway_id, self.node_id),
|
||||
NODE_CALLBACK.format(self.gateway_id, self.node_id),
|
||||
self.async_update_callback,
|
||||
)
|
||||
)
|
||||
|
|
|
@ -1,11 +1,16 @@
|
|||
"""Support for tracking MySensors devices."""
|
||||
from homeassistant.components import mysensors
|
||||
from homeassistant.components.device_tracker import DOMAIN
|
||||
from homeassistant.components.mysensors import DevId, on_unload
|
||||
from homeassistant.components.mysensors.const import ATTR_GATEWAY_ID, GatewayId
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.helpers.typing import HomeAssistantType
|
||||
from homeassistant.util import slugify
|
||||
|
||||
|
||||
async def async_setup_scanner(hass, config, async_see, discovery_info=None):
|
||||
async def async_setup_scanner(
|
||||
hass: HomeAssistantType, config, async_see, discovery_info=None
|
||||
):
|
||||
"""Set up the MySensors device scanner."""
|
||||
new_devices = mysensors.setup_mysensors_platform(
|
||||
hass,
|
||||
|
@ -18,17 +23,25 @@ async def async_setup_scanner(hass, config, async_see, discovery_info=None):
|
|||
return False
|
||||
|
||||
for device in new_devices:
|
||||
gateway_id = id(device.gateway)
|
||||
dev_id = (gateway_id, device.node_id, device.child_id, device.value_type)
|
||||
async_dispatcher_connect(
|
||||
gateway_id: GatewayId = discovery_info[ATTR_GATEWAY_ID]
|
||||
dev_id: DevId = (gateway_id, device.node_id, device.child_id, device.value_type)
|
||||
await on_unload(
|
||||
hass,
|
||||
mysensors.const.CHILD_CALLBACK.format(*dev_id),
|
||||
device.async_update_callback,
|
||||
gateway_id,
|
||||
async_dispatcher_connect(
|
||||
hass,
|
||||
mysensors.const.CHILD_CALLBACK.format(*dev_id),
|
||||
device.async_update_callback,
|
||||
),
|
||||
)
|
||||
async_dispatcher_connect(
|
||||
await on_unload(
|
||||
hass,
|
||||
mysensors.const.NODE_CALLBACK.format(gateway_id, device.node_id),
|
||||
device.async_update_callback,
|
||||
gateway_id,
|
||||
async_dispatcher_connect(
|
||||
hass,
|
||||
mysensors.const.NODE_CALLBACK.format(gateway_id, device.node_id),
|
||||
device.async_update_callback,
|
||||
),
|
||||
)
|
||||
|
||||
return True
|
||||
|
@ -37,7 +50,7 @@ async def async_setup_scanner(hass, config, async_see, discovery_info=None):
|
|||
class MySensorsDeviceScanner(mysensors.device.MySensorsDevice):
|
||||
"""Represent a MySensors scanner."""
|
||||
|
||||
def __init__(self, hass, async_see, *args):
|
||||
def __init__(self, hass: HomeAssistantType, async_see, *args):
|
||||
"""Set up instance."""
|
||||
super().__init__(*args)
|
||||
self.async_see = async_see
|
||||
|
|
|
@ -4,22 +4,21 @@ from collections import defaultdict
|
|||
import logging
|
||||
import socket
|
||||
import sys
|
||||
from typing import Any, Callable, Coroutine, Dict, Optional
|
||||
|
||||
import async_timeout
|
||||
from mysensors import mysensors
|
||||
from mysensors import BaseAsyncGateway, Message, Sensor, mysensors
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.const import CONF_OPTIMISTIC, EVENT_HOMEASSISTANT_STOP
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import EVENT_HOMEASSISTANT_STOP
|
||||
from homeassistant.core import Event, callback
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.setup import async_setup_component
|
||||
from homeassistant.helpers.typing import HomeAssistantType
|
||||
|
||||
from .const import (
|
||||
CONF_BAUD_RATE,
|
||||
CONF_DEVICE,
|
||||
CONF_GATEWAYS,
|
||||
CONF_NODES,
|
||||
CONF_PERSISTENCE,
|
||||
CONF_PERSISTENCE_FILE,
|
||||
CONF_RETAIN,
|
||||
CONF_TCP_PORT,
|
||||
|
@ -28,7 +27,9 @@ from .const import (
|
|||
CONF_VERSION,
|
||||
DOMAIN,
|
||||
MYSENSORS_GATEWAY_READY,
|
||||
MYSENSORS_GATEWAY_START_TASK,
|
||||
MYSENSORS_GATEWAYS,
|
||||
GatewayId,
|
||||
)
|
||||
from .handler import HANDLERS
|
||||
from .helpers import discover_mysensors_platform, validate_child, validate_node
|
||||
|
@ -58,48 +59,114 @@ def is_socket_address(value):
|
|||
raise vol.Invalid("Device is not a valid domain name or ip address") from err
|
||||
|
||||
|
||||
def get_mysensors_gateway(hass, gateway_id):
|
||||
"""Return MySensors gateway."""
|
||||
if MYSENSORS_GATEWAYS not in hass.data:
|
||||
hass.data[MYSENSORS_GATEWAYS] = {}
|
||||
gateways = hass.data.get(MYSENSORS_GATEWAYS)
|
||||
async def try_connect(hass: HomeAssistantType, user_input: Dict[str, str]) -> bool:
|
||||
"""Try to connect to a gateway and report if it worked."""
|
||||
if user_input[CONF_DEVICE] == MQTT_COMPONENT:
|
||||
return True # dont validate mqtt. mqtt gateways dont send ready messages :(
|
||||
try:
|
||||
gateway_ready = asyncio.Future()
|
||||
|
||||
def gateway_ready_callback(msg):
|
||||
msg_type = msg.gateway.const.MessageType(msg.type)
|
||||
_LOGGER.debug("Received MySensors msg type %s: %s", msg_type.name, msg)
|
||||
if msg_type.name != "internal":
|
||||
return
|
||||
internal = msg.gateway.const.Internal(msg.sub_type)
|
||||
if internal.name != "I_GATEWAY_READY":
|
||||
return
|
||||
_LOGGER.debug("Received gateway ready")
|
||||
gateway_ready.set_result(True)
|
||||
|
||||
gateway: Optional[BaseAsyncGateway] = await _get_gateway(
|
||||
hass,
|
||||
device=user_input[CONF_DEVICE],
|
||||
version=user_input[CONF_VERSION],
|
||||
event_callback=gateway_ready_callback,
|
||||
persistence_file=None,
|
||||
baud_rate=user_input.get(CONF_BAUD_RATE),
|
||||
tcp_port=user_input.get(CONF_TCP_PORT),
|
||||
topic_in_prefix=None,
|
||||
topic_out_prefix=None,
|
||||
retain=False,
|
||||
persistence=False,
|
||||
)
|
||||
if gateway is None:
|
||||
return False
|
||||
|
||||
connect_task = None
|
||||
try:
|
||||
connect_task = asyncio.create_task(gateway.start())
|
||||
with async_timeout.timeout(5):
|
||||
await gateway_ready
|
||||
return True
|
||||
except asyncio.TimeoutError:
|
||||
_LOGGER.info("Try gateway connect failed with timeout")
|
||||
return False
|
||||
finally:
|
||||
if connect_task is not None and not connect_task.done():
|
||||
connect_task.cancel()
|
||||
asyncio.create_task(gateway.stop())
|
||||
except OSError as err:
|
||||
_LOGGER.info("Try gateway connect failed with exception", exc_info=err)
|
||||
return False
|
||||
|
||||
|
||||
def get_mysensors_gateway(
|
||||
hass: HomeAssistantType, gateway_id: GatewayId
|
||||
) -> Optional[BaseAsyncGateway]:
|
||||
"""Return the Gateway for a given GatewayId."""
|
||||
if MYSENSORS_GATEWAYS not in hass.data[DOMAIN]:
|
||||
hass.data[DOMAIN][MYSENSORS_GATEWAYS] = {}
|
||||
gateways = hass.data[DOMAIN].get(MYSENSORS_GATEWAYS)
|
||||
return gateways.get(gateway_id)
|
||||
|
||||
|
||||
async def setup_gateways(hass, config):
|
||||
"""Set up all gateways."""
|
||||
conf = config[DOMAIN]
|
||||
gateways = {}
|
||||
async def setup_gateway(
|
||||
hass: HomeAssistantType, entry: ConfigEntry
|
||||
) -> Optional[BaseAsyncGateway]:
|
||||
"""Set up the Gateway for the given ConfigEntry."""
|
||||
|
||||
for index, gateway_conf in enumerate(conf[CONF_GATEWAYS]):
|
||||
persistence_file = gateway_conf.get(
|
||||
CONF_PERSISTENCE_FILE,
|
||||
hass.config.path(f"mysensors{index + 1}.pickle"),
|
||||
)
|
||||
ready_gateway = await _get_gateway(hass, config, gateway_conf, persistence_file)
|
||||
if ready_gateway is not None:
|
||||
gateways[id(ready_gateway)] = ready_gateway
|
||||
|
||||
return gateways
|
||||
ready_gateway = await _get_gateway(
|
||||
hass,
|
||||
device=entry.data[CONF_DEVICE],
|
||||
version=entry.data[CONF_VERSION],
|
||||
event_callback=_gw_callback_factory(hass, entry.entry_id),
|
||||
persistence_file=entry.data.get(
|
||||
CONF_PERSISTENCE_FILE, f"mysensors_{entry.entry_id}.json"
|
||||
),
|
||||
baud_rate=entry.data.get(CONF_BAUD_RATE),
|
||||
tcp_port=entry.data.get(CONF_TCP_PORT),
|
||||
topic_in_prefix=entry.data.get(CONF_TOPIC_IN_PREFIX),
|
||||
topic_out_prefix=entry.data.get(CONF_TOPIC_OUT_PREFIX),
|
||||
retain=entry.data.get(CONF_RETAIN, False),
|
||||
)
|
||||
return ready_gateway
|
||||
|
||||
|
||||
async def _get_gateway(hass, config, gateway_conf, persistence_file):
|
||||
async def _get_gateway(
|
||||
hass: HomeAssistantType,
|
||||
device: str,
|
||||
version: str,
|
||||
event_callback: Callable[[Message], None],
|
||||
persistence_file: Optional[str] = None,
|
||||
baud_rate: Optional[int] = None,
|
||||
tcp_port: Optional[int] = None,
|
||||
topic_in_prefix: Optional[str] = None,
|
||||
topic_out_prefix: Optional[str] = None,
|
||||
retain: bool = False,
|
||||
persistence: bool = True, # old persistence option has been deprecated. kwarg is here so we can run try_connect() without persistence
|
||||
) -> Optional[BaseAsyncGateway]:
|
||||
"""Return gateway after setup of the gateway."""
|
||||
|
||||
conf = config[DOMAIN]
|
||||
persistence = conf[CONF_PERSISTENCE]
|
||||
version = conf[CONF_VERSION]
|
||||
device = gateway_conf[CONF_DEVICE]
|
||||
baud_rate = gateway_conf[CONF_BAUD_RATE]
|
||||
tcp_port = gateway_conf[CONF_TCP_PORT]
|
||||
in_prefix = gateway_conf.get(CONF_TOPIC_IN_PREFIX, "")
|
||||
out_prefix = gateway_conf.get(CONF_TOPIC_OUT_PREFIX, "")
|
||||
if persistence_file is not None:
|
||||
# interpret relative paths to be in hass config folder. absolute paths will be left as they are
|
||||
persistence_file = hass.config.path(persistence_file)
|
||||
|
||||
if device == MQTT_COMPONENT:
|
||||
if not await async_setup_component(hass, MQTT_COMPONENT, config):
|
||||
return None
|
||||
# what is the purpose of this?
|
||||
# if not await async_setup_component(hass, MQTT_COMPONENT, entry):
|
||||
# return None
|
||||
mqtt = hass.components.mqtt
|
||||
retain = conf[CONF_RETAIN]
|
||||
|
||||
def pub_callback(topic, payload, qos, retain):
|
||||
"""Call MQTT publish function."""
|
||||
|
@ -118,8 +185,8 @@ async def _get_gateway(hass, config, gateway_conf, persistence_file):
|
|||
gateway = mysensors.AsyncMQTTGateway(
|
||||
pub_callback,
|
||||
sub_callback,
|
||||
in_prefix=in_prefix,
|
||||
out_prefix=out_prefix,
|
||||
in_prefix=topic_in_prefix,
|
||||
out_prefix=topic_out_prefix,
|
||||
retain=retain,
|
||||
loop=hass.loop,
|
||||
event_callback=None,
|
||||
|
@ -154,25 +221,23 @@ async def _get_gateway(hass, config, gateway_conf, persistence_file):
|
|||
)
|
||||
except vol.Invalid:
|
||||
# invalid ip address
|
||||
_LOGGER.error("Connect failed: Invalid device %s", device)
|
||||
return None
|
||||
gateway.metric = hass.config.units.is_metric
|
||||
gateway.optimistic = conf[CONF_OPTIMISTIC]
|
||||
gateway.device = device
|
||||
gateway.event_callback = _gw_callback_factory(hass, config)
|
||||
gateway.nodes_config = gateway_conf[CONF_NODES]
|
||||
gateway.event_callback = event_callback
|
||||
if persistence:
|
||||
await gateway.start_persistence()
|
||||
|
||||
return gateway
|
||||
|
||||
|
||||
async def finish_setup(hass, hass_config, gateways):
|
||||
async def finish_setup(
|
||||
hass: HomeAssistantType, entry: ConfigEntry, gateway: BaseAsyncGateway
|
||||
):
|
||||
"""Load any persistent devices and platforms and start gateway."""
|
||||
discover_tasks = []
|
||||
start_tasks = []
|
||||
for gateway in gateways.values():
|
||||
discover_tasks.append(_discover_persistent_devices(hass, hass_config, gateway))
|
||||
start_tasks.append(_gw_start(hass, gateway))
|
||||
discover_tasks.append(_discover_persistent_devices(hass, entry, gateway))
|
||||
start_tasks.append(_gw_start(hass, entry, gateway))
|
||||
if discover_tasks:
|
||||
# Make sure all devices and platforms are loaded before gateway start.
|
||||
await asyncio.wait(discover_tasks)
|
||||
|
@ -180,43 +245,58 @@ async def finish_setup(hass, hass_config, gateways):
|
|||
await asyncio.wait(start_tasks)
|
||||
|
||||
|
||||
async def _discover_persistent_devices(hass, hass_config, gateway):
|
||||
async def _discover_persistent_devices(
|
||||
hass: HomeAssistantType, entry: ConfigEntry, gateway: BaseAsyncGateway
|
||||
):
|
||||
"""Discover platforms for devices loaded via persistence file."""
|
||||
tasks = []
|
||||
new_devices = defaultdict(list)
|
||||
for node_id in gateway.sensors:
|
||||
if not validate_node(gateway, node_id):
|
||||
continue
|
||||
node = gateway.sensors[node_id]
|
||||
for child in node.children.values():
|
||||
validated = validate_child(gateway, node_id, child)
|
||||
node: Sensor = gateway.sensors[node_id]
|
||||
for child in node.children.values(): # child is of type ChildSensor
|
||||
validated = validate_child(entry.entry_id, gateway, node_id, child)
|
||||
for platform, dev_ids in validated.items():
|
||||
new_devices[platform].extend(dev_ids)
|
||||
_LOGGER.debug("discovering persistent devices: %s", new_devices)
|
||||
for platform, dev_ids in new_devices.items():
|
||||
tasks.append(discover_mysensors_platform(hass, hass_config, platform, dev_ids))
|
||||
discover_mysensors_platform(hass, entry.entry_id, platform, dev_ids)
|
||||
if tasks:
|
||||
await asyncio.wait(tasks)
|
||||
|
||||
|
||||
async def _gw_start(hass, gateway):
|
||||
async def gw_stop(hass, entry: ConfigEntry, gateway: BaseAsyncGateway):
|
||||
"""Stop the gateway."""
|
||||
connect_task = hass.data[DOMAIN].get(
|
||||
MYSENSORS_GATEWAY_START_TASK.format(entry.entry_id)
|
||||
)
|
||||
if connect_task is not None and not connect_task.done():
|
||||
connect_task.cancel()
|
||||
await gateway.stop()
|
||||
|
||||
|
||||
async def _gw_start(
|
||||
hass: HomeAssistantType, entry: ConfigEntry, gateway: BaseAsyncGateway
|
||||
):
|
||||
"""Start the gateway."""
|
||||
# Don't use hass.async_create_task to avoid holding up setup indefinitely.
|
||||
connect_task = hass.loop.create_task(gateway.start())
|
||||
hass.data[DOMAIN][
|
||||
MYSENSORS_GATEWAY_START_TASK.format(entry.entry_id)
|
||||
] = asyncio.create_task(
|
||||
gateway.start()
|
||||
) # store the connect task so it can be cancelled in gw_stop
|
||||
|
||||
@callback
|
||||
def gw_stop(event):
|
||||
"""Trigger to stop the gateway."""
|
||||
hass.async_create_task(gateway.stop())
|
||||
if not connect_task.done():
|
||||
connect_task.cancel()
|
||||
async def stop_this_gw(_: Event):
|
||||
await gw_stop(hass, entry, gateway)
|
||||
|
||||
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, gw_stop)
|
||||
if gateway.device == "mqtt":
|
||||
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, stop_this_gw)
|
||||
if entry.data[CONF_DEVICE] == MQTT_COMPONENT:
|
||||
# Gatways connected via mqtt doesn't send gateway ready message.
|
||||
return
|
||||
gateway_ready = asyncio.Future()
|
||||
gateway_ready_key = MYSENSORS_GATEWAY_READY.format(id(gateway))
|
||||
hass.data[gateway_ready_key] = gateway_ready
|
||||
gateway_ready_key = MYSENSORS_GATEWAY_READY.format(entry.entry_id)
|
||||
hass.data[DOMAIN][gateway_ready_key] = gateway_ready
|
||||
|
||||
try:
|
||||
with async_timeout.timeout(GATEWAY_READY_TIMEOUT):
|
||||
|
@ -224,27 +304,35 @@ async def _gw_start(hass, gateway):
|
|||
except asyncio.TimeoutError:
|
||||
_LOGGER.warning(
|
||||
"Gateway %s not ready after %s secs so continuing with setup",
|
||||
gateway.device,
|
||||
entry.data[CONF_DEVICE],
|
||||
GATEWAY_READY_TIMEOUT,
|
||||
)
|
||||
finally:
|
||||
hass.data.pop(gateway_ready_key, None)
|
||||
hass.data[DOMAIN].pop(gateway_ready_key, None)
|
||||
|
||||
|
||||
def _gw_callback_factory(hass, hass_config):
|
||||
def _gw_callback_factory(
|
||||
hass: HomeAssistantType, gateway_id: GatewayId
|
||||
) -> Callable[[Message], None]:
|
||||
"""Return a new callback for the gateway."""
|
||||
|
||||
@callback
|
||||
def mysensors_callback(msg):
|
||||
"""Handle messages from a MySensors gateway."""
|
||||
def mysensors_callback(msg: Message):
|
||||
"""Handle messages from a MySensors gateway.
|
||||
|
||||
All MySenors messages are received here.
|
||||
The messages are passed to handler functions depending on their type.
|
||||
"""
|
||||
_LOGGER.debug("Node update: node %s child %s", msg.node_id, msg.child_id)
|
||||
|
||||
msg_type = msg.gateway.const.MessageType(msg.type)
|
||||
msg_handler = HANDLERS.get(msg_type.name)
|
||||
msg_handler: Callable[
|
||||
[Any, GatewayId, Message], Coroutine[None]
|
||||
] = HANDLERS.get(msg_type.name)
|
||||
|
||||
if msg_handler is None:
|
||||
return
|
||||
|
||||
hass.async_create_task(msg_handler(hass, hass_config, msg))
|
||||
hass.async_create_task(msg_handler(hass, gateway_id, msg))
|
||||
|
||||
return mysensors_callback
|
||||
|
|
|
@ -1,9 +1,21 @@
|
|||
"""Handle MySensors messages."""
|
||||
from typing import Dict, List
|
||||
|
||||
from mysensors import Message
|
||||
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
||||
from homeassistant.helpers.typing import HomeAssistantType
|
||||
from homeassistant.util import decorator
|
||||
|
||||
from .const import CHILD_CALLBACK, MYSENSORS_GATEWAY_READY, NODE_CALLBACK
|
||||
from .const import (
|
||||
CHILD_CALLBACK,
|
||||
DOMAIN,
|
||||
MYSENSORS_GATEWAY_READY,
|
||||
NODE_CALLBACK,
|
||||
DevId,
|
||||
GatewayId,
|
||||
)
|
||||
from .device import get_mysensors_devices
|
||||
from .helpers import discover_mysensors_platform, validate_set_msg
|
||||
|
||||
|
@ -11,75 +23,91 @@ HANDLERS = decorator.Registry()
|
|||
|
||||
|
||||
@HANDLERS.register("set")
|
||||
async def handle_set(hass, hass_config, msg):
|
||||
async def handle_set(
|
||||
hass: HomeAssistantType, gateway_id: GatewayId, msg: Message
|
||||
) -> None:
|
||||
"""Handle a mysensors set message."""
|
||||
validated = validate_set_msg(msg)
|
||||
_handle_child_update(hass, hass_config, validated)
|
||||
validated = validate_set_msg(gateway_id, msg)
|
||||
_handle_child_update(hass, gateway_id, validated)
|
||||
|
||||
|
||||
@HANDLERS.register("internal")
|
||||
async def handle_internal(hass, hass_config, msg):
|
||||
async def handle_internal(
|
||||
hass: HomeAssistantType, gateway_id: GatewayId, msg: Message
|
||||
) -> None:
|
||||
"""Handle a mysensors internal message."""
|
||||
internal = msg.gateway.const.Internal(msg.sub_type)
|
||||
handler = HANDLERS.get(internal.name)
|
||||
if handler is None:
|
||||
return
|
||||
await handler(hass, hass_config, msg)
|
||||
await handler(hass, gateway_id, msg)
|
||||
|
||||
|
||||
@HANDLERS.register("I_BATTERY_LEVEL")
|
||||
async def handle_battery_level(hass, hass_config, msg):
|
||||
async def handle_battery_level(
|
||||
hass: HomeAssistantType, gateway_id: GatewayId, msg: Message
|
||||
) -> None:
|
||||
"""Handle an internal battery level message."""
|
||||
_handle_node_update(hass, msg)
|
||||
_handle_node_update(hass, gateway_id, msg)
|
||||
|
||||
|
||||
@HANDLERS.register("I_HEARTBEAT_RESPONSE")
|
||||
async def handle_heartbeat(hass, hass_config, msg):
|
||||
async def handle_heartbeat(
|
||||
hass: HomeAssistantType, gateway_id: GatewayId, msg: Message
|
||||
) -> None:
|
||||
"""Handle an heartbeat."""
|
||||
_handle_node_update(hass, msg)
|
||||
_handle_node_update(hass, gateway_id, msg)
|
||||
|
||||
|
||||
@HANDLERS.register("I_SKETCH_NAME")
|
||||
async def handle_sketch_name(hass, hass_config, msg):
|
||||
async def handle_sketch_name(
|
||||
hass: HomeAssistantType, gateway_id: GatewayId, msg: Message
|
||||
) -> None:
|
||||
"""Handle an internal sketch name message."""
|
||||
_handle_node_update(hass, msg)
|
||||
_handle_node_update(hass, gateway_id, msg)
|
||||
|
||||
|
||||
@HANDLERS.register("I_SKETCH_VERSION")
|
||||
async def handle_sketch_version(hass, hass_config, msg):
|
||||
async def handle_sketch_version(
|
||||
hass: HomeAssistantType, gateway_id: GatewayId, msg: Message
|
||||
) -> None:
|
||||
"""Handle an internal sketch version message."""
|
||||
_handle_node_update(hass, msg)
|
||||
_handle_node_update(hass, gateway_id, msg)
|
||||
|
||||
|
||||
@HANDLERS.register("I_GATEWAY_READY")
|
||||
async def handle_gateway_ready(hass, hass_config, msg):
|
||||
async def handle_gateway_ready(
|
||||
hass: HomeAssistantType, gateway_id: GatewayId, msg: Message
|
||||
) -> None:
|
||||
"""Handle an internal gateway ready message.
|
||||
|
||||
Set asyncio future result if gateway is ready.
|
||||
"""
|
||||
gateway_ready = hass.data.get(MYSENSORS_GATEWAY_READY.format(id(msg.gateway)))
|
||||
gateway_ready = hass.data[DOMAIN].get(MYSENSORS_GATEWAY_READY.format(gateway_id))
|
||||
if gateway_ready is None or gateway_ready.cancelled():
|
||||
return
|
||||
gateway_ready.set_result(True)
|
||||
|
||||
|
||||
@callback
|
||||
def _handle_child_update(hass, hass_config, validated):
|
||||
def _handle_child_update(
|
||||
hass: HomeAssistantType, gateway_id: GatewayId, validated: Dict[str, List[DevId]]
|
||||
):
|
||||
"""Handle a child update."""
|
||||
signals = []
|
||||
signals: List[str] = []
|
||||
|
||||
# Update all platforms for the device via dispatcher.
|
||||
# Add/update entity for validated children.
|
||||
for platform, dev_ids in validated.items():
|
||||
devices = get_mysensors_devices(hass, platform)
|
||||
new_dev_ids = []
|
||||
new_dev_ids: List[DevId] = []
|
||||
for dev_id in dev_ids:
|
||||
if dev_id in devices:
|
||||
signals.append(CHILD_CALLBACK.format(*dev_id))
|
||||
else:
|
||||
new_dev_ids.append(dev_id)
|
||||
if new_dev_ids:
|
||||
discover_mysensors_platform(hass, hass_config, platform, new_dev_ids)
|
||||
discover_mysensors_platform(hass, gateway_id, platform, new_dev_ids)
|
||||
for signal in set(signals):
|
||||
# Only one signal per device is needed.
|
||||
# A device can have multiple platforms, ie multiple schemas.
|
||||
|
@ -87,7 +115,7 @@ def _handle_child_update(hass, hass_config, validated):
|
|||
|
||||
|
||||
@callback
|
||||
def _handle_node_update(hass, msg):
|
||||
def _handle_node_update(hass: HomeAssistantType, gateway_id: GatewayId, msg: Message):
|
||||
"""Handle a node update."""
|
||||
signal = NODE_CALLBACK.format(id(msg.gateway), msg.node_id)
|
||||
signal = NODE_CALLBACK.format(gateway_id, msg.node_id)
|
||||
async_dispatcher_send(hass, signal)
|
||||
|
|
|
@ -1,78 +1,109 @@
|
|||
"""Helper functions for mysensors package."""
|
||||
from collections import defaultdict
|
||||
from enum import IntEnum
|
||||
import logging
|
||||
from typing import DefaultDict, Dict, List, Optional, Set
|
||||
|
||||
from mysensors import BaseAsyncGateway, Message
|
||||
from mysensors.sensor import ChildSensor
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.const import CONF_NAME
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers import discovery
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
||||
from homeassistant.util.decorator import Registry
|
||||
|
||||
from .const import ATTR_DEVICES, DOMAIN, FLAT_PLATFORM_TYPES, TYPE_TO_PLATFORMS
|
||||
from .const import (
|
||||
ATTR_DEVICES,
|
||||
ATTR_GATEWAY_ID,
|
||||
DOMAIN,
|
||||
FLAT_PLATFORM_TYPES,
|
||||
MYSENSORS_DISCOVERY,
|
||||
TYPE_TO_PLATFORMS,
|
||||
DevId,
|
||||
GatewayId,
|
||||
SensorType,
|
||||
ValueType,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
SCHEMAS = Registry()
|
||||
|
||||
|
||||
@callback
|
||||
def discover_mysensors_platform(hass, hass_config, platform, new_devices):
|
||||
def discover_mysensors_platform(
|
||||
hass, gateway_id: GatewayId, platform: str, new_devices: List[DevId]
|
||||
) -> None:
|
||||
"""Discover a MySensors platform."""
|
||||
task = hass.async_create_task(
|
||||
discovery.async_load_platform(
|
||||
hass,
|
||||
platform,
|
||||
DOMAIN,
|
||||
{ATTR_DEVICES: new_devices, CONF_NAME: DOMAIN},
|
||||
hass_config,
|
||||
)
|
||||
_LOGGER.debug("Discovering platform %s with devIds: %s", platform, new_devices)
|
||||
async_dispatcher_send(
|
||||
hass,
|
||||
MYSENSORS_DISCOVERY.format(gateway_id, platform),
|
||||
{
|
||||
ATTR_DEVICES: new_devices,
|
||||
CONF_NAME: DOMAIN,
|
||||
ATTR_GATEWAY_ID: gateway_id,
|
||||
},
|
||||
)
|
||||
return task
|
||||
|
||||
|
||||
def default_schema(gateway, child, value_type_name):
|
||||
def default_schema(
|
||||
gateway: BaseAsyncGateway, child: ChildSensor, value_type_name: ValueType
|
||||
) -> vol.Schema:
|
||||
"""Return a default validation schema for value types."""
|
||||
schema = {value_type_name: cv.string}
|
||||
return get_child_schema(gateway, child, value_type_name, schema)
|
||||
|
||||
|
||||
@SCHEMAS.register(("light", "V_DIMMER"))
|
||||
def light_dimmer_schema(gateway, child, value_type_name):
|
||||
def light_dimmer_schema(
|
||||
gateway: BaseAsyncGateway, child: ChildSensor, value_type_name: ValueType
|
||||
) -> vol.Schema:
|
||||
"""Return a validation schema for V_DIMMER."""
|
||||
schema = {"V_DIMMER": cv.string, "V_LIGHT": cv.string}
|
||||
return get_child_schema(gateway, child, value_type_name, schema)
|
||||
|
||||
|
||||
@SCHEMAS.register(("light", "V_PERCENTAGE"))
|
||||
def light_percentage_schema(gateway, child, value_type_name):
|
||||
def light_percentage_schema(
|
||||
gateway: BaseAsyncGateway, child: ChildSensor, value_type_name: ValueType
|
||||
) -> vol.Schema:
|
||||
"""Return a validation schema for V_PERCENTAGE."""
|
||||
schema = {"V_PERCENTAGE": cv.string, "V_STATUS": cv.string}
|
||||
return get_child_schema(gateway, child, value_type_name, schema)
|
||||
|
||||
|
||||
@SCHEMAS.register(("light", "V_RGB"))
|
||||
def light_rgb_schema(gateway, child, value_type_name):
|
||||
def light_rgb_schema(
|
||||
gateway: BaseAsyncGateway, child: ChildSensor, value_type_name: ValueType
|
||||
) -> vol.Schema:
|
||||
"""Return a validation schema for V_RGB."""
|
||||
schema = {"V_RGB": cv.string, "V_STATUS": cv.string}
|
||||
return get_child_schema(gateway, child, value_type_name, schema)
|
||||
|
||||
|
||||
@SCHEMAS.register(("light", "V_RGBW"))
|
||||
def light_rgbw_schema(gateway, child, value_type_name):
|
||||
def light_rgbw_schema(
|
||||
gateway: BaseAsyncGateway, child: ChildSensor, value_type_name: ValueType
|
||||
) -> vol.Schema:
|
||||
"""Return a validation schema for V_RGBW."""
|
||||
schema = {"V_RGBW": cv.string, "V_STATUS": cv.string}
|
||||
return get_child_schema(gateway, child, value_type_name, schema)
|
||||
|
||||
|
||||
@SCHEMAS.register(("switch", "V_IR_SEND"))
|
||||
def switch_ir_send_schema(gateway, child, value_type_name):
|
||||
def switch_ir_send_schema(
|
||||
gateway: BaseAsyncGateway, child: ChildSensor, value_type_name: ValueType
|
||||
) -> vol.Schema:
|
||||
"""Return a validation schema for V_IR_SEND."""
|
||||
schema = {"V_IR_SEND": cv.string, "V_LIGHT": cv.string}
|
||||
return get_child_schema(gateway, child, value_type_name, schema)
|
||||
|
||||
|
||||
def get_child_schema(gateway, child, value_type_name, schema):
|
||||
def get_child_schema(
|
||||
gateway: BaseAsyncGateway, child: ChildSensor, value_type_name: ValueType, schema
|
||||
) -> vol.Schema:
|
||||
"""Return a child schema."""
|
||||
set_req = gateway.const.SetReq
|
||||
child_schema = child.get_schema(gateway.protocol_version)
|
||||
|
@ -88,7 +119,9 @@ def get_child_schema(gateway, child, value_type_name, schema):
|
|||
return schema
|
||||
|
||||
|
||||
def invalid_msg(gateway, child, value_type_name):
|
||||
def invalid_msg(
|
||||
gateway: BaseAsyncGateway, child: ChildSensor, value_type_name: ValueType
|
||||
):
|
||||
"""Return a message for an invalid child during schema validation."""
|
||||
pres = gateway.const.Presentation
|
||||
set_req = gateway.const.SetReq
|
||||
|
@ -97,15 +130,15 @@ def invalid_msg(gateway, child, value_type_name):
|
|||
)
|
||||
|
||||
|
||||
def validate_set_msg(msg):
|
||||
def validate_set_msg(gateway_id: GatewayId, msg: Message) -> Dict[str, List[DevId]]:
|
||||
"""Validate a set message."""
|
||||
if not validate_node(msg.gateway, msg.node_id):
|
||||
return {}
|
||||
child = msg.gateway.sensors[msg.node_id].children[msg.child_id]
|
||||
return validate_child(msg.gateway, msg.node_id, child, msg.sub_type)
|
||||
return validate_child(gateway_id, msg.gateway, msg.node_id, child, msg.sub_type)
|
||||
|
||||
|
||||
def validate_node(gateway, node_id):
|
||||
def validate_node(gateway: BaseAsyncGateway, node_id: int) -> bool:
|
||||
"""Validate a node."""
|
||||
if gateway.sensors[node_id].sketch_name is None:
|
||||
_LOGGER.debug("Node %s is missing sketch name", node_id)
|
||||
|
@ -113,31 +146,39 @@ def validate_node(gateway, node_id):
|
|||
return True
|
||||
|
||||
|
||||
def validate_child(gateway, node_id, child, value_type=None):
|
||||
"""Validate a child."""
|
||||
validated = defaultdict(list)
|
||||
pres = gateway.const.Presentation
|
||||
set_req = gateway.const.SetReq
|
||||
child_type_name = next(
|
||||
def validate_child(
|
||||
gateway_id: GatewayId,
|
||||
gateway: BaseAsyncGateway,
|
||||
node_id: int,
|
||||
child: ChildSensor,
|
||||
value_type: Optional[int] = None,
|
||||
) -> DefaultDict[str, List[DevId]]:
|
||||
"""Validate a child. Returns a dict mapping hass platform names to list of DevId."""
|
||||
validated: DefaultDict[str, List[DevId]] = defaultdict(list)
|
||||
pres: IntEnum = gateway.const.Presentation
|
||||
set_req: IntEnum = gateway.const.SetReq
|
||||
child_type_name: Optional[SensorType] = next(
|
||||
(member.name for member in pres if member.value == child.type), None
|
||||
)
|
||||
value_types = {value_type} if value_type else {*child.values}
|
||||
value_type_names = {
|
||||
value_types: Set[int] = {value_type} if value_type else {*child.values}
|
||||
value_type_names: Set[ValueType] = {
|
||||
member.name for member in set_req if member.value in value_types
|
||||
}
|
||||
platforms = TYPE_TO_PLATFORMS.get(child_type_name, [])
|
||||
platforms: List[str] = TYPE_TO_PLATFORMS.get(child_type_name, [])
|
||||
if not platforms:
|
||||
_LOGGER.warning("Child type %s is not supported", child.type)
|
||||
return validated
|
||||
|
||||
for platform in platforms:
|
||||
platform_v_names = FLAT_PLATFORM_TYPES[platform, child_type_name]
|
||||
v_names = platform_v_names & value_type_names
|
||||
platform_v_names: Set[ValueType] = FLAT_PLATFORM_TYPES[
|
||||
platform, child_type_name
|
||||
]
|
||||
v_names: Set[ValueType] = platform_v_names & value_type_names
|
||||
if not v_names:
|
||||
child_value_names = {
|
||||
child_value_names: Set[ValueType] = {
|
||||
member.name for member in set_req if member.value in child.values
|
||||
}
|
||||
v_names = platform_v_names & child_value_names
|
||||
v_names: Set[ValueType] = platform_v_names & child_value_names
|
||||
|
||||
for v_name in v_names:
|
||||
child_schema_gen = SCHEMAS.get((platform, v_name), default_schema)
|
||||
|
@ -153,7 +194,12 @@ def validate_child(gateway, node_id, child, value_type=None):
|
|||
exc,
|
||||
)
|
||||
continue
|
||||
dev_id = id(gateway), node_id, child.id, set_req[v_name].value
|
||||
dev_id: DevId = (
|
||||
gateway_id,
|
||||
node_id,
|
||||
child.id,
|
||||
set_req[v_name].value,
|
||||
)
|
||||
validated[platform].append(dev_id)
|
||||
|
||||
return validated
|
||||
|
|
|
@ -1,4 +1,6 @@
|
|||
"""Support for MySensors lights."""
|
||||
from typing import Callable
|
||||
|
||||
from homeassistant.components import mysensors
|
||||
from homeassistant.components.light import (
|
||||
ATTR_BRIGHTNESS,
|
||||
|
@ -10,27 +12,47 @@ from homeassistant.components.light import (
|
|||
SUPPORT_WHITE_VALUE,
|
||||
LightEntity,
|
||||
)
|
||||
from homeassistant.components.mysensors import on_unload
|
||||
from homeassistant.components.mysensors.const import MYSENSORS_DISCOVERY
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import STATE_OFF, STATE_ON
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.helpers.typing import HomeAssistantType
|
||||
import homeassistant.util.color as color_util
|
||||
from homeassistant.util.color import rgb_hex_to_rgb_list
|
||||
|
||||
SUPPORT_MYSENSORS_RGBW = SUPPORT_COLOR | SUPPORT_WHITE_VALUE
|
||||
|
||||
|
||||
async def async_setup_platform(hass, config, async_add_entities, discovery_info=None):
|
||||
"""Set up the mysensors platform for lights."""
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistantType, config_entry: ConfigEntry, async_add_entities: Callable
|
||||
):
|
||||
"""Set up this platform for a specific ConfigEntry(==Gateway)."""
|
||||
device_class_map = {
|
||||
"S_DIMMER": MySensorsLightDimmer,
|
||||
"S_RGB_LIGHT": MySensorsLightRGB,
|
||||
"S_RGBW_LIGHT": MySensorsLightRGBW,
|
||||
}
|
||||
mysensors.setup_mysensors_platform(
|
||||
|
||||
async def async_discover(discovery_info):
|
||||
"""Discover and add a MySensors light."""
|
||||
mysensors.setup_mysensors_platform(
|
||||
hass,
|
||||
DOMAIN,
|
||||
discovery_info,
|
||||
device_class_map,
|
||||
async_add_entities=async_add_entities,
|
||||
)
|
||||
|
||||
await on_unload(
|
||||
hass,
|
||||
DOMAIN,
|
||||
discovery_info,
|
||||
device_class_map,
|
||||
async_add_entities=async_add_entities,
|
||||
config_entry,
|
||||
async_dispatcher_connect(
|
||||
hass,
|
||||
MYSENSORS_DISCOVERY.format(config_entry.entry_id, DOMAIN),
|
||||
async_discover,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
@ -60,11 +82,6 @@ class MySensorsLight(mysensors.device.MySensorsEntity, LightEntity):
|
|||
"""Return the white value of this light between 0..255."""
|
||||
return self._white
|
||||
|
||||
@property
|
||||
def assumed_state(self):
|
||||
"""Return true if unable to access real state of entity."""
|
||||
return self.gateway.optimistic
|
||||
|
||||
@property
|
||||
def is_on(self):
|
||||
"""Return true if device is on."""
|
||||
|
@ -80,7 +97,7 @@ class MySensorsLight(mysensors.device.MySensorsEntity, LightEntity):
|
|||
self.node_id, self.child_id, set_req.V_LIGHT, 1, ack=1
|
||||
)
|
||||
|
||||
if self.gateway.optimistic:
|
||||
if self.assumed_state:
|
||||
# optimistically assume that light has changed state
|
||||
self._state = True
|
||||
self._values[set_req.V_LIGHT] = STATE_ON
|
||||
|
@ -102,7 +119,7 @@ class MySensorsLight(mysensors.device.MySensorsEntity, LightEntity):
|
|||
self.node_id, self.child_id, set_req.V_DIMMER, percent, ack=1
|
||||
)
|
||||
|
||||
if self.gateway.optimistic:
|
||||
if self.assumed_state:
|
||||
# optimistically assume that light has changed state
|
||||
self._brightness = brightness
|
||||
self._values[set_req.V_DIMMER] = percent
|
||||
|
@ -135,7 +152,7 @@ class MySensorsLight(mysensors.device.MySensorsEntity, LightEntity):
|
|||
self.node_id, self.child_id, self.value_type, hex_color, ack=1
|
||||
)
|
||||
|
||||
if self.gateway.optimistic:
|
||||
if self.assumed_state:
|
||||
# optimistically assume that light has changed state
|
||||
self._hs = color_util.color_RGB_to_hs(*rgb)
|
||||
self._white = white
|
||||
|
@ -145,7 +162,7 @@ class MySensorsLight(mysensors.device.MySensorsEntity, LightEntity):
|
|||
"""Turn the device off."""
|
||||
value_type = self.gateway.const.SetReq.V_LIGHT
|
||||
self.gateway.set_child_value(self.node_id, self.child_id, value_type, 0, ack=1)
|
||||
if self.gateway.optimistic:
|
||||
if self.assumed_state:
|
||||
# optimistically assume that light has changed state
|
||||
self._state = False
|
||||
self._values[value_type] = STATE_OFF
|
||||
|
@ -188,7 +205,7 @@ class MySensorsLightDimmer(MySensorsLight):
|
|||
"""Turn the device on."""
|
||||
self._turn_on_light()
|
||||
self._turn_on_dimmer(**kwargs)
|
||||
if self.gateway.optimistic:
|
||||
if self.assumed_state:
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_update(self):
|
||||
|
@ -214,7 +231,7 @@ class MySensorsLightRGB(MySensorsLight):
|
|||
self._turn_on_light()
|
||||
self._turn_on_dimmer(**kwargs)
|
||||
self._turn_on_rgb_and_w("%02x%02x%02x", **kwargs)
|
||||
if self.gateway.optimistic:
|
||||
if self.assumed_state:
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_update(self):
|
||||
|
@ -241,5 +258,5 @@ class MySensorsLightRGBW(MySensorsLightRGB):
|
|||
self._turn_on_light()
|
||||
self._turn_on_dimmer(**kwargs)
|
||||
self._turn_on_rgb_and_w("%02x%02x%02x%02x", **kwargs)
|
||||
if self.gateway.optimistic:
|
||||
if self.assumed_state:
|
||||
self.async_write_ha_state()
|
||||
|
|
|
@ -2,7 +2,15 @@
|
|||
"domain": "mysensors",
|
||||
"name": "MySensors",
|
||||
"documentation": "https://www.home-assistant.io/integrations/mysensors",
|
||||
"requirements": ["pymysensors==0.18.0"],
|
||||
"after_dependencies": ["mqtt"],
|
||||
"codeowners": ["@MartinHjelmare"]
|
||||
"requirements": [
|
||||
"pymysensors==0.20.1"
|
||||
],
|
||||
"after_dependencies": [
|
||||
"mqtt"
|
||||
],
|
||||
"codeowners": [
|
||||
"@MartinHjelmare",
|
||||
"@functionpointer"
|
||||
],
|
||||
"config_flow": true
|
||||
}
|
||||
|
|
|
@ -1,6 +1,11 @@
|
|||
"""Support for MySensors sensors."""
|
||||
from typing import Callable
|
||||
|
||||
from homeassistant.components import mysensors
|
||||
from homeassistant.components.mysensors import on_unload
|
||||
from homeassistant.components.mysensors.const import MYSENSORS_DISCOVERY
|
||||
from homeassistant.components.sensor import DOMAIN
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import (
|
||||
CONDUCTIVITY,
|
||||
DEGREE,
|
||||
|
@ -18,6 +23,8 @@ from homeassistant.const import (
|
|||
VOLT,
|
||||
VOLUME_CUBIC_METERS,
|
||||
)
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.helpers.typing import HomeAssistantType
|
||||
|
||||
SENSORS = {
|
||||
"V_TEMP": [None, "mdi:thermometer"],
|
||||
|
@ -54,14 +61,29 @@ SENSORS = {
|
|||
}
|
||||
|
||||
|
||||
async def async_setup_platform(hass, config, async_add_entities, discovery_info=None):
|
||||
"""Set up the MySensors platform for sensors."""
|
||||
mysensors.setup_mysensors_platform(
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistantType, config_entry: ConfigEntry, async_add_entities: Callable
|
||||
):
|
||||
"""Set up this platform for a specific ConfigEntry(==Gateway)."""
|
||||
|
||||
async def async_discover(discovery_info):
|
||||
"""Discover and add a MySensors sensor."""
|
||||
mysensors.setup_mysensors_platform(
|
||||
hass,
|
||||
DOMAIN,
|
||||
discovery_info,
|
||||
MySensorsSensor,
|
||||
async_add_entities=async_add_entities,
|
||||
)
|
||||
|
||||
await on_unload(
|
||||
hass,
|
||||
DOMAIN,
|
||||
discovery_info,
|
||||
MySensorsSensor,
|
||||
async_add_entities=async_add_entities,
|
||||
config_entry,
|
||||
async_dispatcher_connect(
|
||||
hass,
|
||||
MYSENSORS_DISCOVERY.format(config_entry.entry_id, DOMAIN),
|
||||
async_discover,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
@ -105,7 +127,7 @@ class MySensorsSensor(mysensors.device.MySensorsEntity):
|
|||
pres = self.gateway.const.Presentation
|
||||
set_req = self.gateway.const.SetReq
|
||||
SENSORS[set_req.V_TEMP.name][0] = (
|
||||
TEMP_CELSIUS if self.gateway.metric else TEMP_FAHRENHEIT
|
||||
TEMP_CELSIUS if self.hass.config.units.is_metric else TEMP_FAHRENHEIT
|
||||
)
|
||||
sensor_type = SENSORS.get(set_req(self.value_type).name, [None, None])
|
||||
if isinstance(sensor_type, dict):
|
||||
|
|
79
homeassistant/components/mysensors/strings.json
Normal file
79
homeassistant/components/mysensors/strings.json
Normal 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%]"
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,4 +1,6 @@
|
|||
"""Support for MySensors switches."""
|
||||
from typing import Callable
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components import mysensors
|
||||
|
@ -6,7 +8,11 @@ from homeassistant.components.switch import DOMAIN, SwitchEntity
|
|||
from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
|
||||
from .const import DOMAIN as MYSENSORS_DOMAIN, SERVICE_SEND_IR_CODE
|
||||
from . import on_unload
|
||||
from ...config_entries import ConfigEntry
|
||||
from ...helpers.dispatcher import async_dispatcher_connect
|
||||
from ...helpers.typing import HomeAssistantType
|
||||
from .const import DOMAIN as MYSENSORS_DOMAIN, MYSENSORS_DISCOVERY, SERVICE_SEND_IR_CODE
|
||||
|
||||
ATTR_IR_CODE = "V_IR_SEND"
|
||||
|
||||
|
@ -15,8 +21,10 @@ SEND_IR_CODE_SERVICE_SCHEMA = vol.Schema(
|
|||
)
|
||||
|
||||
|
||||
async def async_setup_platform(hass, config, async_add_entities, discovery_info=None):
|
||||
"""Set up the mysensors platform for switches."""
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistantType, config_entry: ConfigEntry, async_add_entities: Callable
|
||||
):
|
||||
"""Set up this platform for a specific ConfigEntry(==Gateway)."""
|
||||
device_class_map = {
|
||||
"S_DOOR": MySensorsSwitch,
|
||||
"S_MOTION": MySensorsSwitch,
|
||||
|
@ -32,13 +40,16 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=
|
|||
"S_MOISTURE": MySensorsSwitch,
|
||||
"S_WATER_QUALITY": MySensorsSwitch,
|
||||
}
|
||||
mysensors.setup_mysensors_platform(
|
||||
hass,
|
||||
DOMAIN,
|
||||
discovery_info,
|
||||
device_class_map,
|
||||
async_add_entities=async_add_entities,
|
||||
)
|
||||
|
||||
async def async_discover(discovery_info):
|
||||
"""Discover and add a MySensors switch."""
|
||||
mysensors.setup_mysensors_platform(
|
||||
hass,
|
||||
DOMAIN,
|
||||
discovery_info,
|
||||
device_class_map,
|
||||
async_add_entities=async_add_entities,
|
||||
)
|
||||
|
||||
async def async_send_ir_code_service(service):
|
||||
"""Set IR code as device state attribute."""
|
||||
|
@ -71,15 +82,20 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=
|
|||
schema=SEND_IR_CODE_SERVICE_SCHEMA,
|
||||
)
|
||||
|
||||
await on_unload(
|
||||
hass,
|
||||
config_entry,
|
||||
async_dispatcher_connect(
|
||||
hass,
|
||||
MYSENSORS_DISCOVERY.format(config_entry.entry_id, DOMAIN),
|
||||
async_discover,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class MySensorsSwitch(mysensors.device.MySensorsEntity, SwitchEntity):
|
||||
"""Representation of the value of a MySensors Switch child node."""
|
||||
|
||||
@property
|
||||
def assumed_state(self):
|
||||
"""Return True if unable to access real state of entity."""
|
||||
return self.gateway.optimistic
|
||||
|
||||
@property
|
||||
def current_power_w(self):
|
||||
"""Return the current power usage in W."""
|
||||
|
@ -96,7 +112,7 @@ class MySensorsSwitch(mysensors.device.MySensorsEntity, SwitchEntity):
|
|||
self.gateway.set_child_value(
|
||||
self.node_id, self.child_id, self.value_type, 1, ack=1
|
||||
)
|
||||
if self.gateway.optimistic:
|
||||
if self.assumed_state:
|
||||
# Optimistically assume that switch has changed state
|
||||
self._values[self.value_type] = STATE_ON
|
||||
self.async_write_ha_state()
|
||||
|
@ -106,7 +122,7 @@ class MySensorsSwitch(mysensors.device.MySensorsEntity, SwitchEntity):
|
|||
self.gateway.set_child_value(
|
||||
self.node_id, self.child_id, self.value_type, 0, ack=1
|
||||
)
|
||||
if self.gateway.optimistic:
|
||||
if self.assumed_state:
|
||||
# Optimistically assume that switch has changed state
|
||||
self._values[self.value_type] = STATE_OFF
|
||||
self.async_write_ha_state()
|
||||
|
@ -137,7 +153,7 @@ class MySensorsIRSwitch(MySensorsSwitch):
|
|||
self.gateway.set_child_value(
|
||||
self.node_id, self.child_id, set_req.V_LIGHT, 1, ack=1
|
||||
)
|
||||
if self.gateway.optimistic:
|
||||
if self.assumed_state:
|
||||
# Optimistically assume that switch has changed state
|
||||
self._values[self.value_type] = self._ir_code
|
||||
self._values[set_req.V_LIGHT] = STATE_ON
|
||||
|
@ -151,7 +167,7 @@ class MySensorsIRSwitch(MySensorsSwitch):
|
|||
self.gateway.set_child_value(
|
||||
self.node_id, self.child_id, set_req.V_LIGHT, 0, ack=1
|
||||
)
|
||||
if self.gateway.optimistic:
|
||||
if self.assumed_state:
|
||||
# Optimistically assume that switch has changed state
|
||||
self._values[set_req.V_LIGHT] = STATE_OFF
|
||||
self.async_write_ha_state()
|
||||
|
|
79
homeassistant/components/mysensors/translations/en.json
Normal file
79
homeassistant/components/mysensors/translations/en.json
Normal 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"
|
||||
}
|
|
@ -136,6 +136,7 @@ FLOWS = [
|
|||
"motion_blinds",
|
||||
"mqtt",
|
||||
"myq",
|
||||
"mysensors",
|
||||
"neato",
|
||||
"nest",
|
||||
"netatmo",
|
||||
|
|
|
@ -1551,7 +1551,7 @@ pymusiccast==0.1.6
|
|||
pymyq==2.0.14
|
||||
|
||||
# homeassistant.components.mysensors
|
||||
pymysensors==0.18.0
|
||||
pymysensors==0.20.1
|
||||
|
||||
# homeassistant.components.nanoleaf
|
||||
pynanoleaf==0.0.5
|
||||
|
|
|
@ -810,6 +810,9 @@ pymonoprice==0.3
|
|||
# homeassistant.components.myq
|
||||
pymyq==2.0.14
|
||||
|
||||
# homeassistant.components.mysensors
|
||||
pymysensors==0.20.1
|
||||
|
||||
# homeassistant.components.nuki
|
||||
pynuki==1.3.8
|
||||
|
||||
|
|
1
tests/components/mysensors/__init__.py
Normal file
1
tests/components/mysensors/__init__.py
Normal file
|
@ -0,0 +1 @@
|
|||
"""Tests for the MySensors integration."""
|
735
tests/components/mysensors/test_config_flow.py
Normal file
735
tests/components/mysensors/test_config_flow.py
Normal 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]
|
30
tests/components/mysensors/test_gateway.py
Normal file
30
tests/components/mysensors/test_gateway.py
Normal 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
|
251
tests/components/mysensors/test_init.py
Normal file
251
tests/components/mysensors/test_init.py
Normal 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
|
Loading…
Add table
Reference in a new issue