* Wait for future mysensors gateway ready * Add an asyncio future that is done when the gateway reports the gateway ready message, I_GATEWAY_READY. * This will make sure that the gateway is ready before home assistant fires the home assistant start event. Automations can now send messages to the gateway when home assistant is started. * Use async timeout to wait max 15 seconds for ready gateway. * Address comments
705 lines
26 KiB
Python
705 lines
26 KiB
Python
"""
|
|
Connect to a MySensors gateway via pymysensors API.
|
|
|
|
For more details about this component, please refer to the documentation at
|
|
https://home-assistant.io/components/mysensors/
|
|
"""
|
|
import asyncio
|
|
from collections import defaultdict
|
|
import logging
|
|
import os
|
|
import socket
|
|
import sys
|
|
from timeit import default_timer as timer
|
|
|
|
import async_timeout
|
|
import voluptuous as vol
|
|
|
|
from homeassistant.components.mqtt import (
|
|
valid_publish_topic, valid_subscribe_topic)
|
|
from homeassistant.const import (
|
|
ATTR_BATTERY_LEVEL, CONF_NAME, CONF_OPTIMISTIC, EVENT_HOMEASSISTANT_STOP,
|
|
STATE_OFF, STATE_ON)
|
|
from homeassistant.core import callback
|
|
from homeassistant.helpers import discovery
|
|
import homeassistant.helpers.config_validation as cv
|
|
from homeassistant.helpers.dispatcher import (
|
|
async_dispatcher_connect, async_dispatcher_send)
|
|
from homeassistant.helpers.entity import Entity
|
|
from homeassistant.setup import async_setup_component
|
|
|
|
REQUIREMENTS = ['pymysensors==0.14.0']
|
|
|
|
_LOGGER = logging.getLogger(__name__)
|
|
|
|
ATTR_CHILD_ID = 'child_id'
|
|
ATTR_DESCRIPTION = 'description'
|
|
ATTR_DEVICE = 'device'
|
|
ATTR_DEVICES = 'devices'
|
|
ATTR_NODE_ID = 'node_id'
|
|
|
|
CONF_BAUD_RATE = 'baud_rate'
|
|
CONF_DEBUG = 'debug'
|
|
CONF_DEVICE = 'device'
|
|
CONF_GATEWAYS = 'gateways'
|
|
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_NODES = 'nodes'
|
|
CONF_NODE_NAME = 'name'
|
|
|
|
DEFAULT_BAUD_RATE = 115200
|
|
DEFAULT_TCP_PORT = 5003
|
|
DEFAULT_VERSION = '1.4'
|
|
DOMAIN = 'mysensors'
|
|
|
|
GATEWAY_READY_TIMEOUT = 15.0
|
|
MQTT_COMPONENT = 'mqtt'
|
|
MYSENSORS_GATEWAYS = 'mysensors_gateways'
|
|
MYSENSORS_PLATFORM_DEVICES = 'mysensors_devices_{}'
|
|
MYSENSORS_GATEWAY_READY = 'mysensors_gateway_ready_{}'
|
|
PLATFORM = 'platform'
|
|
SCHEMA = 'schema'
|
|
SIGNAL_CALLBACK = 'mysensors_callback_{}_{}_{}_{}'
|
|
TYPE = 'type'
|
|
|
|
|
|
def is_socket_address(value):
|
|
"""Validate that value is a valid address."""
|
|
try:
|
|
socket.getaddrinfo(value, None)
|
|
return value
|
|
except OSError:
|
|
raise vol.Invalid('Device is not a valid domain name or ip address')
|
|
|
|
|
|
def has_parent_dir(value):
|
|
"""Validate that value is in an existing directory which is writeable."""
|
|
parent = os.path.dirname(os.path.realpath(value))
|
|
is_dir_writable = os.path.isdir(parent) and os.access(parent, os.W_OK)
|
|
if not is_dir_writable:
|
|
raise vol.Invalid(
|
|
'{} directory does not exist or is not writeable'.format(parent))
|
|
return value
|
|
|
|
|
|
def has_all_unique_files(value):
|
|
"""Validate that all persistence files are unique and set if any is set."""
|
|
persistence_files = [
|
|
gateway.get(CONF_PERSISTENCE_FILE) for gateway in value]
|
|
if None in persistence_files and any(
|
|
name is not None for name in persistence_files):
|
|
raise vol.Invalid(
|
|
'persistence file name of all devices must be set if any is set')
|
|
if not all(name is None for name in persistence_files):
|
|
schema = vol.Schema(vol.Unique())
|
|
schema(persistence_files)
|
|
return value
|
|
|
|
|
|
def is_persistence_file(value):
|
|
"""Validate that persistence file path ends in either .pickle or .json."""
|
|
if value.endswith(('.json', '.pickle')):
|
|
return value
|
|
else:
|
|
raise vol.Invalid(
|
|
'{} does not end in either `.json` or `.pickle`'.format(value))
|
|
|
|
|
|
def is_serial_port(value):
|
|
"""Validate that value is a windows serial port or a unix device."""
|
|
if sys.platform.startswith('win'):
|
|
ports = ('COM{}'.format(idx + 1) for idx in range(256))
|
|
if value in ports:
|
|
return value
|
|
else:
|
|
raise vol.Invalid('{} is not a serial port'.format(value))
|
|
else:
|
|
return cv.isdevice(value)
|
|
|
|
|
|
def deprecated(key):
|
|
"""Mark key as deprecated in configuration."""
|
|
def validator(config):
|
|
"""Check if key is in config, log warning and remove key."""
|
|
if key not in config:
|
|
return config
|
|
_LOGGER.warning(
|
|
'%s option for %s is deprecated. Please remove %s from your '
|
|
'configuration file', key, DOMAIN, key)
|
|
config.pop(key)
|
|
return config
|
|
return validator
|
|
|
|
|
|
NODE_SCHEMA = vol.Schema({
|
|
cv.positive_int: {
|
|
vol.Required(CONF_NODE_NAME): cv.string
|
|
}
|
|
})
|
|
|
|
CONFIG_SCHEMA = vol.Schema({
|
|
DOMAIN: vol.Schema(vol.All(deprecated(CONF_DEBUG), {
|
|
vol.Required(CONF_GATEWAYS): vol.All(
|
|
cv.ensure_list, has_all_unique_files,
|
|
[{
|
|
vol.Required(CONF_DEVICE):
|
|
vol.Any(MQTT_COMPONENT, is_socket_address, is_serial_port),
|
|
vol.Optional(CONF_PERSISTENCE_FILE):
|
|
vol.All(cv.string, is_persistence_file, has_parent_dir),
|
|
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,
|
|
}]
|
|
),
|
|
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,
|
|
}))
|
|
}, extra=vol.ALLOW_EXTRA)
|
|
|
|
|
|
# MySensors const schemas
|
|
BINARY_SENSOR_SCHEMA = {PLATFORM: 'binary_sensor', TYPE: 'V_TRIPPED'}
|
|
CLIMATE_SCHEMA = {PLATFORM: 'climate', TYPE: 'V_HVAC_FLOW_STATE'}
|
|
LIGHT_DIMMER_SCHEMA = {
|
|
PLATFORM: 'light', TYPE: 'V_DIMMER',
|
|
SCHEMA: {'V_DIMMER': cv.string, 'V_LIGHT': cv.string}}
|
|
LIGHT_PERCENTAGE_SCHEMA = {
|
|
PLATFORM: 'light', TYPE: 'V_PERCENTAGE',
|
|
SCHEMA: {'V_PERCENTAGE': cv.string, 'V_STATUS': cv.string}}
|
|
LIGHT_RGB_SCHEMA = {
|
|
PLATFORM: 'light', TYPE: 'V_RGB', SCHEMA: {
|
|
'V_RGB': cv.string, 'V_STATUS': cv.string}}
|
|
LIGHT_RGBW_SCHEMA = {
|
|
PLATFORM: 'light', TYPE: 'V_RGBW', SCHEMA: {
|
|
'V_RGBW': cv.string, 'V_STATUS': cv.string}}
|
|
NOTIFY_SCHEMA = {PLATFORM: 'notify', TYPE: 'V_TEXT'}
|
|
DEVICE_TRACKER_SCHEMA = {PLATFORM: 'device_tracker', TYPE: 'V_POSITION'}
|
|
DUST_SCHEMA = [
|
|
{PLATFORM: 'sensor', TYPE: 'V_DUST_LEVEL'},
|
|
{PLATFORM: 'sensor', TYPE: 'V_LEVEL'}]
|
|
SWITCH_LIGHT_SCHEMA = {PLATFORM: 'switch', TYPE: 'V_LIGHT'}
|
|
SWITCH_STATUS_SCHEMA = {PLATFORM: 'switch', TYPE: 'V_STATUS'}
|
|
MYSENSORS_CONST_SCHEMA = {
|
|
'S_DOOR': [BINARY_SENSOR_SCHEMA, {PLATFORM: 'switch', TYPE: 'V_ARMED'}],
|
|
'S_MOTION': [BINARY_SENSOR_SCHEMA, {PLATFORM: 'switch', TYPE: 'V_ARMED'}],
|
|
'S_SMOKE': [BINARY_SENSOR_SCHEMA, {PLATFORM: 'switch', TYPE: 'V_ARMED'}],
|
|
'S_SPRINKLER': [
|
|
BINARY_SENSOR_SCHEMA, {PLATFORM: 'switch', TYPE: 'V_STATUS'}],
|
|
'S_WATER_LEAK': [
|
|
BINARY_SENSOR_SCHEMA, {PLATFORM: 'switch', TYPE: 'V_ARMED'}],
|
|
'S_SOUND': [
|
|
BINARY_SENSOR_SCHEMA, {PLATFORM: 'sensor', TYPE: 'V_LEVEL'},
|
|
{PLATFORM: 'switch', TYPE: 'V_ARMED'}],
|
|
'S_VIBRATION': [
|
|
BINARY_SENSOR_SCHEMA, {PLATFORM: 'sensor', TYPE: 'V_LEVEL'},
|
|
{PLATFORM: 'switch', TYPE: 'V_ARMED'}],
|
|
'S_MOISTURE': [
|
|
BINARY_SENSOR_SCHEMA, {PLATFORM: 'sensor', TYPE: 'V_LEVEL'},
|
|
{PLATFORM: 'switch', TYPE: 'V_ARMED'}],
|
|
'S_HVAC': [CLIMATE_SCHEMA],
|
|
'S_COVER': [
|
|
{PLATFORM: 'cover', TYPE: 'V_DIMMER'},
|
|
{PLATFORM: 'cover', TYPE: 'V_PERCENTAGE'},
|
|
{PLATFORM: 'cover', TYPE: 'V_LIGHT'},
|
|
{PLATFORM: 'cover', TYPE: 'V_STATUS'}],
|
|
'S_DIMMER': [LIGHT_DIMMER_SCHEMA, LIGHT_PERCENTAGE_SCHEMA],
|
|
'S_RGB_LIGHT': [LIGHT_RGB_SCHEMA],
|
|
'S_RGBW_LIGHT': [LIGHT_RGBW_SCHEMA],
|
|
'S_INFO': [NOTIFY_SCHEMA, {PLATFORM: 'sensor', TYPE: 'V_TEXT'}],
|
|
'S_GPS': [
|
|
DEVICE_TRACKER_SCHEMA, {PLATFORM: 'sensor', TYPE: 'V_POSITION'}],
|
|
'S_TEMP': [{PLATFORM: 'sensor', TYPE: 'V_TEMP'}],
|
|
'S_HUM': [{PLATFORM: 'sensor', TYPE: 'V_HUM'}],
|
|
'S_BARO': [
|
|
{PLATFORM: 'sensor', TYPE: 'V_PRESSURE'},
|
|
{PLATFORM: 'sensor', TYPE: 'V_FORECAST'}],
|
|
'S_WIND': [
|
|
{PLATFORM: 'sensor', TYPE: 'V_WIND'},
|
|
{PLATFORM: 'sensor', TYPE: 'V_GUST'},
|
|
{PLATFORM: 'sensor', TYPE: 'V_DIRECTION'}],
|
|
'S_RAIN': [
|
|
{PLATFORM: 'sensor', TYPE: 'V_RAIN'},
|
|
{PLATFORM: 'sensor', TYPE: 'V_RAINRATE'}],
|
|
'S_UV': [{PLATFORM: 'sensor', TYPE: 'V_UV'}],
|
|
'S_WEIGHT': [
|
|
{PLATFORM: 'sensor', TYPE: 'V_WEIGHT'},
|
|
{PLATFORM: 'sensor', TYPE: 'V_IMPEDANCE'}],
|
|
'S_POWER': [
|
|
{PLATFORM: 'sensor', TYPE: 'V_WATT'},
|
|
{PLATFORM: 'sensor', TYPE: 'V_KWH'},
|
|
{PLATFORM: 'sensor', TYPE: 'V_VAR'},
|
|
{PLATFORM: 'sensor', TYPE: 'V_VA'},
|
|
{PLATFORM: 'sensor', TYPE: 'V_POWER_FACTOR'}],
|
|
'S_DISTANCE': [{PLATFORM: 'sensor', TYPE: 'V_DISTANCE'}],
|
|
'S_LIGHT_LEVEL': [
|
|
{PLATFORM: 'sensor', TYPE: 'V_LIGHT_LEVEL'},
|
|
{PLATFORM: 'sensor', TYPE: 'V_LEVEL'}],
|
|
'S_IR': [
|
|
{PLATFORM: 'sensor', TYPE: 'V_IR_RECEIVE'},
|
|
{PLATFORM: 'switch', TYPE: 'V_IR_SEND',
|
|
SCHEMA: {'V_IR_SEND': cv.string, 'V_LIGHT': cv.string}}],
|
|
'S_WATER': [
|
|
{PLATFORM: 'sensor', TYPE: 'V_FLOW'},
|
|
{PLATFORM: 'sensor', TYPE: 'V_VOLUME'}],
|
|
'S_CUSTOM': [
|
|
{PLATFORM: 'sensor', TYPE: 'V_VAR1'},
|
|
{PLATFORM: 'sensor', TYPE: 'V_VAR2'},
|
|
{PLATFORM: 'sensor', TYPE: 'V_VAR3'},
|
|
{PLATFORM: 'sensor', TYPE: 'V_VAR4'},
|
|
{PLATFORM: 'sensor', TYPE: 'V_VAR5'},
|
|
{PLATFORM: 'sensor', TYPE: 'V_CUSTOM'}],
|
|
'S_SCENE_CONTROLLER': [
|
|
{PLATFORM: 'sensor', TYPE: 'V_SCENE_ON'},
|
|
{PLATFORM: 'sensor', TYPE: 'V_SCENE_OFF'}],
|
|
'S_COLOR_SENSOR': [{PLATFORM: 'sensor', TYPE: 'V_RGB'}],
|
|
'S_MULTIMETER': [
|
|
{PLATFORM: 'sensor', TYPE: 'V_VOLTAGE'},
|
|
{PLATFORM: 'sensor', TYPE: 'V_CURRENT'},
|
|
{PLATFORM: 'sensor', TYPE: 'V_IMPEDANCE'}],
|
|
'S_GAS': [
|
|
{PLATFORM: 'sensor', TYPE: 'V_FLOW'},
|
|
{PLATFORM: 'sensor', TYPE: 'V_VOLUME'}],
|
|
'S_WATER_QUALITY': [
|
|
{PLATFORM: 'sensor', TYPE: 'V_TEMP'},
|
|
{PLATFORM: 'sensor', TYPE: 'V_PH'},
|
|
{PLATFORM: 'sensor', TYPE: 'V_ORP'},
|
|
{PLATFORM: 'sensor', TYPE: 'V_EC'},
|
|
{PLATFORM: 'switch', TYPE: 'V_STATUS'}],
|
|
'S_AIR_QUALITY': DUST_SCHEMA,
|
|
'S_DUST': DUST_SCHEMA,
|
|
'S_LIGHT': [SWITCH_LIGHT_SCHEMA],
|
|
'S_BINARY': [SWITCH_STATUS_SCHEMA],
|
|
'S_LOCK': [{PLATFORM: 'switch', TYPE: 'V_LOCK_STATUS'}],
|
|
}
|
|
|
|
|
|
async def async_setup(hass, config):
|
|
"""Set up the MySensors component."""
|
|
import mysensors.mysensors as mysensors
|
|
|
|
version = config[DOMAIN].get(CONF_VERSION)
|
|
persistence = config[DOMAIN].get(CONF_PERSISTENCE)
|
|
|
|
async def setup_gateway(
|
|
device, persistence_file, baud_rate, tcp_port, in_prefix,
|
|
out_prefix):
|
|
"""Return gateway after setup of the gateway."""
|
|
if device == MQTT_COMPONENT:
|
|
if not await async_setup_component(hass, MQTT_COMPONENT, config):
|
|
return None
|
|
mqtt = hass.components.mqtt
|
|
retain = config[DOMAIN].get(CONF_RETAIN)
|
|
|
|
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(*args):
|
|
"""Call callback."""
|
|
sub_cb(*args)
|
|
|
|
hass.async_add_job(
|
|
mqtt.async_subscribe(topic, internal_callback, qos))
|
|
|
|
gateway = mysensors.AsyncMQTTGateway(
|
|
pub_callback, sub_callback, in_prefix=in_prefix,
|
|
out_prefix=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_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:
|
|
gateway = mysensors.AsyncTCPGateway(
|
|
device, port=tcp_port, loop=hass.loop, event_callback=None,
|
|
persistence=persistence, persistence_file=persistence_file,
|
|
protocol_version=version)
|
|
gateway.metric = hass.config.units.is_metric
|
|
gateway.optimistic = config[DOMAIN].get(CONF_OPTIMISTIC)
|
|
gateway.device = device
|
|
gateway.event_callback = gw_callback_factory(hass)
|
|
if persistence:
|
|
await gateway.start_persistence()
|
|
|
|
return gateway
|
|
|
|
# Setup all devices from config
|
|
gateways = {}
|
|
conf_gateways = config[DOMAIN][CONF_GATEWAYS]
|
|
|
|
for index, gway in enumerate(conf_gateways):
|
|
device = gway[CONF_DEVICE]
|
|
persistence_file = gway.get(
|
|
CONF_PERSISTENCE_FILE,
|
|
hass.config.path('mysensors{}.pickle'.format(index + 1)))
|
|
baud_rate = gway.get(CONF_BAUD_RATE)
|
|
tcp_port = gway.get(CONF_TCP_PORT)
|
|
in_prefix = gway.get(CONF_TOPIC_IN_PREFIX, '')
|
|
out_prefix = gway.get(CONF_TOPIC_OUT_PREFIX, '')
|
|
gateway = await setup_gateway(
|
|
device, persistence_file, baud_rate, tcp_port, in_prefix,
|
|
out_prefix)
|
|
if gateway is not None:
|
|
gateway.nodes_config = gway.get(CONF_NODES)
|
|
gateways[id(gateway)] = gateway
|
|
|
|
if not gateways:
|
|
_LOGGER.error(
|
|
"No devices could be setup as gateways, check your configuration")
|
|
return False
|
|
|
|
hass.data[MYSENSORS_GATEWAYS] = gateways
|
|
|
|
hass.async_add_job(finish_setup(hass, gateways))
|
|
|
|
return True
|
|
|
|
|
|
async def finish_setup(hass, gateways):
|
|
"""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, gateway))
|
|
start_tasks.append(gw_start(hass, gateway))
|
|
if discover_tasks:
|
|
# Make sure all devices and platforms are loaded before gateway start.
|
|
await asyncio.wait(discover_tasks, loop=hass.loop)
|
|
if start_tasks:
|
|
await asyncio.wait(start_tasks, loop=hass.loop)
|
|
|
|
|
|
async def gw_start(hass, gateway):
|
|
"""Start the gateway."""
|
|
@callback
|
|
def gw_stop(event):
|
|
"""Trigger to stop the gateway."""
|
|
hass.async_add_job(gateway.stop())
|
|
|
|
await gateway.start()
|
|
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, gw_stop)
|
|
if gateway.device == 'mqtt':
|
|
# 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
|
|
|
|
try:
|
|
with async_timeout.timeout(GATEWAY_READY_TIMEOUT, loop=hass.loop):
|
|
await gateway_ready
|
|
except asyncio.TimeoutError:
|
|
_LOGGER.warning(
|
|
"Gateway %s not ready after %s secs so continuing with setup",
|
|
gateway.device, GATEWAY_READY_TIMEOUT)
|
|
finally:
|
|
hass.data.pop(gateway_ready_key, None)
|
|
|
|
|
|
@callback
|
|
def set_gateway_ready(hass, msg):
|
|
"""Set asyncio future result if gateway is ready."""
|
|
if (msg.type != msg.gateway.const.MessageType.internal or
|
|
msg.sub_type != msg.gateway.const.Internal.I_GATEWAY_READY):
|
|
return
|
|
gateway_ready = hass.data.get(MYSENSORS_GATEWAY_READY.format(
|
|
id(msg.gateway)))
|
|
if gateway_ready is None or gateway_ready.cancelled():
|
|
return
|
|
gateway_ready.set_result(True)
|
|
|
|
|
|
def validate_child(gateway, node_id, child):
|
|
"""Validate that a child has the correct values according to schema.
|
|
|
|
Return a dict of platform with a list of device ids for validated devices.
|
|
"""
|
|
validated = defaultdict(list)
|
|
|
|
if not child.values:
|
|
_LOGGER.debug(
|
|
"No child values for node %s child %s", node_id, child.id)
|
|
return validated
|
|
if gateway.sensors[node_id].sketch_name is None:
|
|
_LOGGER.debug("Node %s is missing sketch name", node_id)
|
|
return validated
|
|
pres = gateway.const.Presentation
|
|
set_req = gateway.const.SetReq
|
|
s_name = next(
|
|
(member.name for member in pres if member.value == child.type), None)
|
|
if s_name not in MYSENSORS_CONST_SCHEMA:
|
|
_LOGGER.warning("Child type %s is not supported", s_name)
|
|
return validated
|
|
child_schemas = MYSENSORS_CONST_SCHEMA[s_name]
|
|
|
|
def msg(name):
|
|
"""Return a message for an invalid schema."""
|
|
return "{} requires value_type {}".format(
|
|
pres(child.type).name, set_req[name].name)
|
|
|
|
for schema in child_schemas:
|
|
platform = schema[PLATFORM]
|
|
v_name = schema[TYPE]
|
|
value_type = next(
|
|
(member.value for member in set_req if member.name == v_name),
|
|
None)
|
|
if value_type is None:
|
|
continue
|
|
_child_schema = child.get_schema(gateway.protocol_version)
|
|
vol_schema = _child_schema.extend(
|
|
{vol.Required(set_req[key].value, msg=msg(key)):
|
|
_child_schema.schema.get(set_req[key].value, val)
|
|
for key, val in schema.get(SCHEMA, {v_name: cv.string}).items()},
|
|
extra=vol.ALLOW_EXTRA)
|
|
try:
|
|
vol_schema(child.values)
|
|
except vol.Invalid as exc:
|
|
level = (logging.WARNING if value_type in child.values
|
|
else logging.DEBUG)
|
|
_LOGGER.log(
|
|
level,
|
|
"Invalid values: %s: %s platform: node %s child %s: %s",
|
|
child.values, platform, node_id, child.id, exc)
|
|
continue
|
|
dev_id = id(gateway), node_id, child.id, value_type
|
|
validated[platform].append(dev_id)
|
|
return validated
|
|
|
|
|
|
@callback
|
|
def discover_mysensors_platform(hass, platform, new_devices):
|
|
"""Discover a MySensors platform."""
|
|
task = hass.async_add_job(discovery.async_load_platform(
|
|
hass, platform, DOMAIN,
|
|
{ATTR_DEVICES: new_devices, CONF_NAME: DOMAIN}))
|
|
return task
|
|
|
|
|
|
async def discover_persistent_devices(hass, gateway):
|
|
"""Discover platforms for devices loaded via persistence file."""
|
|
tasks = []
|
|
new_devices = defaultdict(list)
|
|
for node_id in gateway.sensors:
|
|
node = gateway.sensors[node_id]
|
|
for child in node.children.values():
|
|
validated = validate_child(gateway, node_id, child)
|
|
for platform, dev_ids in validated.items():
|
|
new_devices[platform].extend(dev_ids)
|
|
for platform, dev_ids in new_devices.items():
|
|
tasks.append(discover_mysensors_platform(hass, platform, dev_ids))
|
|
if tasks:
|
|
await asyncio.wait(tasks, loop=hass.loop)
|
|
|
|
|
|
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)]
|
|
|
|
|
|
def gw_callback_factory(hass):
|
|
"""Return a new callback for the gateway."""
|
|
@callback
|
|
def mysensors_callback(msg):
|
|
"""Handle messages from a MySensors gateway."""
|
|
start = timer()
|
|
_LOGGER.debug(
|
|
"Node update: node %s child %s", msg.node_id, msg.child_id)
|
|
|
|
set_gateway_ready(hass, msg)
|
|
|
|
try:
|
|
child = msg.gateway.sensors[msg.node_id].children[msg.child_id]
|
|
except KeyError:
|
|
_LOGGER.debug("Not a child update for node %s", msg.node_id)
|
|
return
|
|
|
|
signals = []
|
|
|
|
# Update all platforms for the device via dispatcher.
|
|
# Add/update entity if schema validates to true.
|
|
validated = validate_child(msg.gateway, msg.node_id, child)
|
|
for platform, dev_ids in validated.items():
|
|
devices = get_mysensors_devices(hass, platform)
|
|
new_dev_ids = []
|
|
for dev_id in dev_ids:
|
|
if dev_id in devices:
|
|
signals.append(SIGNAL_CALLBACK.format(*dev_id))
|
|
else:
|
|
new_dev_ids.append(dev_id)
|
|
if new_dev_ids:
|
|
discover_mysensors_platform(hass, 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.
|
|
# FOR LATER: Add timer to not signal if another update comes in.
|
|
async_dispatcher_send(hass, signal)
|
|
end = timer()
|
|
if end - start > 0.1:
|
|
_LOGGER.debug(
|
|
"Callback for node %s child %s took %.3f seconds",
|
|
msg.node_id, msg.child_id, end - start)
|
|
return mysensors_callback
|
|
|
|
|
|
def get_mysensors_name(gateway, node_id, child_id):
|
|
"""Return a name for a node child."""
|
|
node_name = '{} {}'.format(
|
|
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)
|
|
return '{} {}'.format(node_name, child_id)
|
|
|
|
|
|
def get_mysensors_gateway(hass, gateway_id):
|
|
"""Return MySensors gateway."""
|
|
if MYSENSORS_GATEWAYS not in hass.data:
|
|
hass.data[MYSENSORS_GATEWAYS] = {}
|
|
gateways = hass.data.get(MYSENSORS_GATEWAYS)
|
|
return gateways.get(gateway_id)
|
|
|
|
|
|
@callback
|
|
def setup_mysensors_platform(
|
|
hass, domain, discovery_info, device_class, device_args=None,
|
|
async_add_devices=None):
|
|
"""Set up a MySensors platform."""
|
|
# Only act if called via mysensors by discovery event.
|
|
# Otherwise gateway is not setup.
|
|
if not discovery_info:
|
|
return
|
|
if device_args is None:
|
|
device_args = ()
|
|
new_devices = []
|
|
new_dev_ids = discovery_info[ATTR_DEVICES]
|
|
for dev_id in new_dev_ids:
|
|
devices = get_mysensors_devices(hass, domain)
|
|
if dev_id in devices:
|
|
continue
|
|
gateway_id, node_id, child_id, value_type = dev_id
|
|
gateway = get_mysensors_gateway(hass, gateway_id)
|
|
if not gateway:
|
|
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)
|
|
devices[dev_id] = device_class_copy(*args_copy)
|
|
new_devices.append(devices[dev_id])
|
|
if new_devices:
|
|
_LOGGER.info("Adding new devices: %s", new_devices)
|
|
if async_add_devices is not None:
|
|
async_add_devices(new_devices, True)
|
|
return new_devices
|
|
|
|
|
|
class MySensorsDevice(object):
|
|
"""Representation of a MySensors device."""
|
|
|
|
def __init__(self, gateway, node_id, child_id, name, value_type):
|
|
"""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._values = {}
|
|
|
|
@property
|
|
def name(self):
|
|
"""Return the name of this entity."""
|
|
return self._name
|
|
|
|
@property
|
|
def device_state_attributes(self):
|
|
"""Return device specific state attributes."""
|
|
node = self.gateway.sensors[self.node_id]
|
|
child = node.children[self.child_id]
|
|
attr = {
|
|
ATTR_BATTERY_LEVEL: node.battery_level,
|
|
ATTR_CHILD_ID: self.child_id,
|
|
ATTR_DESCRIPTION: child.description,
|
|
ATTR_DEVICE: self.gateway.device,
|
|
ATTR_NODE_ID: self.node_id,
|
|
}
|
|
|
|
set_req = self.gateway.const.SetReq
|
|
|
|
for value_type, value in self._values.items():
|
|
attr[set_req(value_type).name] = value
|
|
|
|
return attr
|
|
|
|
async def async_update(self):
|
|
"""Update the controller with the latest value from a sensor."""
|
|
node = self.gateway.sensors[self.node_id]
|
|
child = node.children[self.child_id]
|
|
set_req = self.gateway.const.SetReq
|
|
for value_type, value in child.values.items():
|
|
_LOGGER.debug(
|
|
"Entity update: %s: value_type %s, value = %s",
|
|
self._name, value_type, value)
|
|
if value_type in (set_req.V_ARMED, set_req.V_LIGHT,
|
|
set_req.V_LOCK_STATUS, set_req.V_TRIPPED):
|
|
self._values[value_type] = (
|
|
STATE_ON if int(value) == 1 else STATE_OFF)
|
|
elif value_type == set_req.V_DIMMER:
|
|
self._values[value_type] = int(value)
|
|
else:
|
|
self._values[value_type] = value
|
|
|
|
|
|
class MySensorsEntity(MySensorsDevice, Entity):
|
|
"""Representation of a MySensors entity."""
|
|
|
|
@property
|
|
def should_poll(self):
|
|
"""Return the polling state. The gateway pushes its states."""
|
|
return False
|
|
|
|
@property
|
|
def available(self):
|
|
"""Return true if entity is available."""
|
|
return self.value_type in self._values
|
|
|
|
@callback
|
|
def async_update_callback(self):
|
|
"""Update the entity."""
|
|
self.async_schedule_update_ha_state(True)
|
|
|
|
async def async_added_to_hass(self):
|
|
"""Register update callback."""
|
|
dev_id = id(self.gateway), self.node_id, self.child_id, self.value_type
|
|
async_dispatcher_connect(
|
|
self.hass, SIGNAL_CALLBACK.format(*dev_id),
|
|
self.async_update_callback)
|