343 lines
11 KiB
Python
343 lines
11 KiB
Python
"""Handle MySensors gateways."""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
from collections import defaultdict
|
|
import logging
|
|
import socket
|
|
import sys
|
|
from typing import Any, Callable, Coroutine
|
|
|
|
import async_timeout
|
|
from mysensors import BaseAsyncGateway, Message, Sensor, mysensors
|
|
import voluptuous as vol
|
|
|
|
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.helpers.typing import HomeAssistantType
|
|
|
|
from .const import (
|
|
CONF_BAUD_RATE,
|
|
CONF_DEVICE,
|
|
CONF_PERSISTENCE_FILE,
|
|
CONF_RETAIN,
|
|
CONF_TCP_PORT,
|
|
CONF_TOPIC_IN_PREFIX,
|
|
CONF_TOPIC_OUT_PREFIX,
|
|
CONF_VERSION,
|
|
DOMAIN,
|
|
MYSENSORS_GATEWAY_START_TASK,
|
|
MYSENSORS_GATEWAYS,
|
|
GatewayId,
|
|
)
|
|
from .handler import HANDLERS
|
|
from .helpers import (
|
|
discover_mysensors_platform,
|
|
on_unload,
|
|
validate_child,
|
|
validate_node,
|
|
)
|
|
|
|
_LOGGER = logging.getLogger(__name__)
|
|
|
|
GATEWAY_READY_TIMEOUT = 20.0
|
|
MQTT_COMPONENT = "mqtt"
|
|
|
|
|
|
def is_serial_port(value):
|
|
"""Validate that value is a windows serial port or a unix device."""
|
|
if sys.platform.startswith("win"):
|
|
ports = (f"COM{idx + 1}" for idx in range(256))
|
|
if value in ports:
|
|
return value
|
|
raise vol.Invalid(f"{value} is not a serial port")
|
|
return cv.isdevice(value)
|
|
|
|
|
|
def is_socket_address(value):
|
|
"""Validate that value is a valid address."""
|
|
try:
|
|
socket.getaddrinfo(value, None)
|
|
return value
|
|
except OSError as err:
|
|
raise vol.Invalid("Device is not a valid domain name or ip address") from err
|
|
|
|
|
|
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.Event()
|
|
|
|
def on_conn_made(_: BaseAsyncGateway) -> None:
|
|
gateway_ready.set()
|
|
|
|
gateway: BaseAsyncGateway | None = await _get_gateway(
|
|
hass,
|
|
device=user_input[CONF_DEVICE],
|
|
version=user_input[CONF_VERSION],
|
|
event_callback=lambda _: None,
|
|
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
|
|
gateway.on_conn_made = on_conn_made
|
|
|
|
connect_task = None
|
|
try:
|
|
connect_task = asyncio.create_task(gateway.start())
|
|
with async_timeout.timeout(GATEWAY_READY_TIMEOUT):
|
|
await gateway_ready.wait()
|
|
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
|
|
) -> BaseAsyncGateway | None:
|
|
"""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_gateway(
|
|
hass: HomeAssistantType, entry: ConfigEntry
|
|
) -> BaseAsyncGateway | None:
|
|
"""Set up the Gateway for the given ConfigEntry."""
|
|
|
|
ready_gateway = await _get_gateway(
|
|
hass,
|
|
device=entry.data[CONF_DEVICE],
|
|
version=entry.data[CONF_VERSION],
|
|
event_callback=_gw_callback_factory(hass, entry.entry_id),
|
|
persistence_file=entry.data.get(
|
|
CONF_PERSISTENCE_FILE, f"mysensors_{entry.entry_id}.json"
|
|
),
|
|
baud_rate=entry.data.get(CONF_BAUD_RATE),
|
|
tcp_port=entry.data.get(CONF_TCP_PORT),
|
|
topic_in_prefix=entry.data.get(CONF_TOPIC_IN_PREFIX),
|
|
topic_out_prefix=entry.data.get(CONF_TOPIC_OUT_PREFIX),
|
|
retain=entry.data.get(CONF_RETAIN, False),
|
|
)
|
|
return ready_gateway
|
|
|
|
|
|
async def _get_gateway(
|
|
hass: HomeAssistantType,
|
|
device: str,
|
|
version: str,
|
|
event_callback: Callable[[Message], None],
|
|
persistence_file: str | None = None,
|
|
baud_rate: int | None = None,
|
|
tcp_port: int | None = None,
|
|
topic_in_prefix: str | None = None,
|
|
topic_out_prefix: str | None = None,
|
|
retain: bool = False,
|
|
persistence: bool = True, # old persistence option has been deprecated. kwarg is here so we can run try_connect() without persistence
|
|
) -> BaseAsyncGateway | None:
|
|
"""Return gateway after setup of the gateway."""
|
|
|
|
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:
|
|
# what is the purpose of this?
|
|
# if not await async_setup_component(hass, MQTT_COMPONENT, entry):
|
|
# return None
|
|
mqtt = hass.components.mqtt
|
|
|
|
def pub_callback(topic, payload, qos, retain):
|
|
"""Call MQTT publish function."""
|
|
mqtt.async_publish(topic, payload, qos, retain)
|
|
|
|
def sub_callback(topic, sub_cb, qos):
|
|
"""Call MQTT subscribe function."""
|
|
|
|
@callback
|
|
def internal_callback(msg):
|
|
"""Call callback."""
|
|
sub_cb(msg.topic, msg.payload, msg.qos)
|
|
|
|
hass.async_create_task(mqtt.async_subscribe(topic, internal_callback, qos))
|
|
|
|
gateway = mysensors.AsyncMQTTGateway(
|
|
pub_callback,
|
|
sub_callback,
|
|
in_prefix=topic_in_prefix,
|
|
out_prefix=topic_out_prefix,
|
|
retain=retain,
|
|
loop=hass.loop,
|
|
event_callback=None,
|
|
persistence=persistence,
|
|
persistence_file=persistence_file,
|
|
protocol_version=version,
|
|
)
|
|
else:
|
|
try:
|
|
await hass.async_add_executor_job(is_serial_port, device)
|
|
gateway = mysensors.AsyncSerialGateway(
|
|
device,
|
|
baud=baud_rate,
|
|
loop=hass.loop,
|
|
event_callback=None,
|
|
persistence=persistence,
|
|
persistence_file=persistence_file,
|
|
protocol_version=version,
|
|
)
|
|
except vol.Invalid:
|
|
try:
|
|
await hass.async_add_executor_job(is_socket_address, device)
|
|
# valid ip address
|
|
gateway = mysensors.AsyncTCPGateway(
|
|
device,
|
|
port=tcp_port,
|
|
loop=hass.loop,
|
|
event_callback=None,
|
|
persistence=persistence,
|
|
persistence_file=persistence_file,
|
|
protocol_version=version,
|
|
)
|
|
except vol.Invalid:
|
|
# invalid ip address
|
|
_LOGGER.error("Connect failed: Invalid device %s", device)
|
|
return None
|
|
gateway.event_callback = event_callback
|
|
if persistence:
|
|
await gateway.start_persistence()
|
|
|
|
return gateway
|
|
|
|
|
|
async def finish_setup(
|
|
hass: HomeAssistantType, entry: ConfigEntry, gateway: BaseAsyncGateway
|
|
):
|
|
"""Load any persistent devices and platforms and start gateway."""
|
|
discover_tasks = []
|
|
start_tasks = []
|
|
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)
|
|
if start_tasks:
|
|
await asyncio.wait(start_tasks)
|
|
|
|
|
|
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: 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():
|
|
discover_mysensors_platform(hass, entry.entry_id, platform, dev_ids)
|
|
if tasks:
|
|
await asyncio.wait(tasks)
|
|
|
|
|
|
async def gw_stop(hass, entry: ConfigEntry, gateway: BaseAsyncGateway):
|
|
"""Stop the gateway."""
|
|
connect_task = hass.data[DOMAIN].pop(
|
|
MYSENSORS_GATEWAY_START_TASK.format(entry.entry_id), None
|
|
)
|
|
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."""
|
|
gateway_ready = asyncio.Event()
|
|
|
|
def gateway_connected(_: BaseAsyncGateway):
|
|
gateway_ready.set()
|
|
|
|
gateway.on_conn_made = gateway_connected
|
|
# Don't use hass.async_create_task to avoid holding up setup indefinitely.
|
|
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
|
|
|
|
async def stop_this_gw(_: Event):
|
|
await gw_stop(hass, entry, gateway)
|
|
|
|
await on_unload(
|
|
hass,
|
|
entry.entry_id,
|
|
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
|
|
try:
|
|
with async_timeout.timeout(GATEWAY_READY_TIMEOUT):
|
|
await gateway_ready.wait()
|
|
except asyncio.TimeoutError:
|
|
_LOGGER.warning(
|
|
"Gateway %s not connected after %s secs so continuing with setup",
|
|
entry.data[CONF_DEVICE],
|
|
GATEWAY_READY_TIMEOUT,
|
|
)
|
|
|
|
|
|
def _gw_callback_factory(
|
|
hass: HomeAssistantType, gateway_id: GatewayId
|
|
) -> Callable[[Message], None]:
|
|
"""Return a new callback for the gateway."""
|
|
|
|
@callback
|
|
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: Callable[
|
|
[Any, GatewayId, Message], Coroutine[None]
|
|
] = HANDLERS.get(msg_type.name)
|
|
|
|
if msg_handler is None:
|
|
return
|
|
|
|
hass.async_create_task(msg_handler(hass, gateway_id, msg))
|
|
|
|
return mysensors_callback
|