From 3893d8a87612ed807f77d52db1d863eb7cefad83 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Mon, 25 Jun 2018 13:58:16 +0200 Subject: [PATCH] Reorganize mysensors (#15123) * Move mysensors.py to package * Move mysensors component to package * Split code into multiple modules. * Update coveragerc --- .coveragerc | 2 +- .../components/binary_sensor/mysensors.py | 3 +- homeassistant/components/climate/mysensors.py | 2 +- homeassistant/components/cover/mysensors.py | 2 +- .../components/device_tracker/mysensors.py | 4 +- homeassistant/components/light/mysensors.py | 2 +- homeassistant/components/mysensors.py | 705 ------------------ .../components/mysensors/__init__.py | 167 +++++ homeassistant/components/mysensors/const.py | 138 ++++ homeassistant/components/mysensors/device.py | 109 +++ homeassistant/components/mysensors/gateway.py | 328 ++++++++ homeassistant/components/notify/mysensors.py | 2 +- homeassistant/components/sensor/mysensors.py | 2 +- homeassistant/components/switch/mysensors.py | 2 +- 14 files changed, 753 insertions(+), 715 deletions(-) delete mode 100644 homeassistant/components/mysensors.py create mode 100644 homeassistant/components/mysensors/__init__.py create mode 100644 homeassistant/components/mysensors/const.py create mode 100644 homeassistant/components/mysensors/device.py create mode 100644 homeassistant/components/mysensors/gateway.py diff --git a/.coveragerc b/.coveragerc index d059d62b5f3..90b0a7f475d 100644 --- a/.coveragerc +++ b/.coveragerc @@ -192,7 +192,7 @@ omit = homeassistant/components/mychevy.py homeassistant/components/*/mychevy.py - homeassistant/components/mysensors.py + homeassistant/components/mysensors/* homeassistant/components/*/mysensors.py homeassistant/components/neato.py diff --git a/homeassistant/components/binary_sensor/mysensors.py b/homeassistant/components/binary_sensor/mysensors.py index 21443021193..abb19129d52 100644 --- a/homeassistant/components/binary_sensor/mysensors.py +++ b/homeassistant/components/binary_sensor/mysensors.py @@ -29,7 +29,8 @@ async def async_setup_platform( async_add_devices=async_add_devices) -class MySensorsBinarySensor(mysensors.MySensorsEntity, BinarySensorDevice): +class MySensorsBinarySensor( + mysensors.device.MySensorsEntity, BinarySensorDevice): """Representation of a MySensors Binary Sensor child node.""" @property diff --git a/homeassistant/components/climate/mysensors.py b/homeassistant/components/climate/mysensors.py index 9fab56c61ac..37ae29fdf81 100644 --- a/homeassistant/components/climate/mysensors.py +++ b/homeassistant/components/climate/mysensors.py @@ -39,7 +39,7 @@ async def async_setup_platform( async_add_devices=async_add_devices) -class MySensorsHVAC(mysensors.MySensorsEntity, ClimateDevice): +class MySensorsHVAC(mysensors.device.MySensorsEntity, ClimateDevice): """Representation of a MySensors HVAC.""" @property diff --git a/homeassistant/components/cover/mysensors.py b/homeassistant/components/cover/mysensors.py index 3f8eb054710..c815cf44df2 100644 --- a/homeassistant/components/cover/mysensors.py +++ b/homeassistant/components/cover/mysensors.py @@ -17,7 +17,7 @@ async def async_setup_platform( async_add_devices=async_add_devices) -class MySensorsCover(mysensors.MySensorsEntity, CoverDevice): +class MySensorsCover(mysensors.device.MySensorsEntity, CoverDevice): """Representation of the value of a MySensors Cover child node.""" @property diff --git a/homeassistant/components/device_tracker/mysensors.py b/homeassistant/components/device_tracker/mysensors.py index b0d29bf0566..49d3f3207ba 100644 --- a/homeassistant/components/device_tracker/mysensors.py +++ b/homeassistant/components/device_tracker/mysensors.py @@ -23,13 +23,13 @@ async def async_setup_scanner(hass, config, async_see, discovery_info=None): id(device.gateway), device.node_id, device.child_id, device.value_type) async_dispatcher_connect( - hass, mysensors.SIGNAL_CALLBACK.format(*dev_id), + hass, mysensors.const.SIGNAL_CALLBACK.format(*dev_id), device.async_update_callback) return True -class MySensorsDeviceScanner(mysensors.MySensorsDevice): +class MySensorsDeviceScanner(mysensors.device.MySensorsDevice): """Represent a MySensors scanner.""" def __init__(self, async_see, *args): diff --git a/homeassistant/components/light/mysensors.py b/homeassistant/components/light/mysensors.py index 55387288d7f..4139abd40fa 100644 --- a/homeassistant/components/light/mysensors.py +++ b/homeassistant/components/light/mysensors.py @@ -28,7 +28,7 @@ async def async_setup_platform( async_add_devices=async_add_devices) -class MySensorsLight(mysensors.MySensorsEntity, Light): +class MySensorsLight(mysensors.device.MySensorsEntity, Light): """Representation of a MySensors Light child node.""" def __init__(self, *args): diff --git a/homeassistant/components/mysensors.py b/homeassistant/components/mysensors.py deleted file mode 100644 index 1e7e252bd9d..00000000000 --- a/homeassistant/components/mysensors.py +++ /dev/null @@ -1,705 +0,0 @@ -""" -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) diff --git a/homeassistant/components/mysensors/__init__.py b/homeassistant/components/mysensors/__init__.py new file mode 100644 index 00000000000..3aa8e82911e --- /dev/null +++ b/homeassistant/components/mysensors/__init__.py @@ -0,0 +1,167 @@ +""" +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 logging + +import voluptuous as vol + +from homeassistant.components.mqtt import ( + valid_publish_topic, valid_subscribe_topic) +from homeassistant.const import CONF_OPTIMISTIC +from homeassistant.core import callback +import homeassistant.helpers.config_validation as cv + +from .const import ( + ATTR_DEVICES, CONF_BAUD_RATE, CONF_DEVICE, CONF_GATEWAYS, + CONF_NODES, CONF_PERSISTENCE, CONF_PERSISTENCE_FILE, CONF_RETAIN, + CONF_TCP_PORT, CONF_TOPIC_IN_PREFIX, CONF_TOPIC_OUT_PREFIX, CONF_VERSION, + DOMAIN, MYSENSORS_GATEWAYS) +from .device import get_mysensors_devices +from .gateway import get_mysensors_gateway, setup_gateways, finish_setup + +REQUIREMENTS = ['pymysensors==0.14.0'] + +_LOGGER = logging.getLogger(__name__) + +CONF_DEBUG = 'debug' +CONF_NODE_NAME = 'name' + +DEFAULT_BAUD_RATE = 115200 +DEFAULT_TCP_PORT = 5003 +DEFAULT_VERSION = '1.4' + + +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 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 + } +}) + +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, +} + +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, [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, + })) +}, extra=vol.ALLOW_EXTRA) + + +async def async_setup(hass, config): + """Set up the MySensors component.""" + gateways = await setup_gateways(hass, config) + + 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 + + +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) + + +@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 diff --git a/homeassistant/components/mysensors/const.py b/homeassistant/components/mysensors/const.py new file mode 100644 index 00000000000..4f9718a39db --- /dev/null +++ b/homeassistant/components/mysensors/const.py @@ -0,0 +1,138 @@ +"""MySensors constants.""" +import homeassistant.helpers.config_validation as cv + +ATTR_DEVICES = 'devices' + +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' + +DOMAIN = 'mysensors' +MYSENSORS_GATEWAYS = 'mysensors_gateways' +PLATFORM = 'platform' +SCHEMA = 'schema' +SIGNAL_CALLBACK = 'mysensors_callback_{}_{}_{}_{}' +TYPE = 'type' + +# 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'}], +} diff --git a/homeassistant/components/mysensors/device.py b/homeassistant/components/mysensors/device.py new file mode 100644 index 00000000000..b0770f90c1d --- /dev/null +++ b/homeassistant/components/mysensors/device.py @@ -0,0 +1,109 @@ +"""Handle MySensors devices.""" +import logging + +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 SIGNAL_CALLBACK + +_LOGGER = logging.getLogger(__name__) + +ATTR_CHILD_ID = 'child_id' +ATTR_DESCRIPTION = 'description' +ATTR_DEVICE = 'device' +ATTR_NODE_ID = 'node_id' +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(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) diff --git a/homeassistant/components/mysensors/gateway.py b/homeassistant/components/mysensors/gateway.py new file mode 100644 index 00000000000..a7719a80d99 --- /dev/null +++ b/homeassistant/components/mysensors/gateway.py @@ -0,0 +1,328 @@ +"""Handle MySensors gateways.""" +import asyncio +from collections import defaultdict +import logging +import socket +import sys +from timeit import default_timer as timer + +import async_timeout +import voluptuous as vol + +from homeassistant.const import ( + CONF_NAME, CONF_OPTIMISTIC, EVENT_HOMEASSISTANT_STOP) +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.setup import async_setup_component + +from .const import ( + ATTR_DEVICES, CONF_BAUD_RATE, CONF_DEVICE, CONF_GATEWAYS, CONF_NODES, + CONF_PERSISTENCE, CONF_PERSISTENCE_FILE, CONF_RETAIN, CONF_TCP_PORT, + CONF_TOPIC_IN_PREFIX, CONF_TOPIC_OUT_PREFIX, CONF_VERSION, DOMAIN, + MYSENSORS_CONST_SCHEMA, MYSENSORS_GATEWAYS, PLATFORM, SCHEMA, + SIGNAL_CALLBACK, TYPE) +from .device import get_mysensors_devices + +_LOGGER = logging.getLogger(__name__) + +GATEWAY_READY_TIMEOUT = 15.0 +MQTT_COMPONENT = 'mqtt' +MYSENSORS_GATEWAY_READY = 'mysensors_gateway_ready_{}' + + +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 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 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) + + +async def setup_gateways(hass, config): + """Set up all gateways.""" + conf = config[DOMAIN] + gateways = {} + + for index, gateway_conf in enumerate(conf[CONF_GATEWAYS]): + persistence_file = gateway_conf.get( + CONF_PERSISTENCE_FILE, + hass.config.path('mysensors{}.pickle'.format(index + 1))) + 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 + + +async def _get_gateway(hass, config, gateway_conf, persistence_file): + """Return gateway after setup of the gateway.""" + import mysensors.mysensors as mysensors + + 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 device == MQTT_COMPONENT: + if not await async_setup_component(hass, MQTT_COMPONENT, config): + return None + mqtt = hass.components.mqtt + retain = conf[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: + try: + await hass.async_add_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 + return None + gateway.metric = hass.config.units.is_metric + gateway.optimistic = conf[CONF_OPTIMISTIC] + gateway.device = device + gateway.event_callback = _gw_callback_factory(hass) + gateway.nodes_config = gateway_conf[CONF_NODES] + if persistence: + await gateway.start_persistence() + + return gateway + + +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 _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) + + +@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 _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) + + +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 + + +@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 diff --git a/homeassistant/components/notify/mysensors.py b/homeassistant/components/notify/mysensors.py index db568514dea..71ce7fb0b74 100644 --- a/homeassistant/components/notify/mysensors.py +++ b/homeassistant/components/notify/mysensors.py @@ -18,7 +18,7 @@ async def async_get_service(hass, config, discovery_info=None): return MySensorsNotificationService(hass) -class MySensorsNotificationDevice(mysensors.MySensorsDevice): +class MySensorsNotificationDevice(mysensors.device.MySensorsDevice): """Represent a MySensors Notification device.""" def send_msg(self, msg): diff --git a/homeassistant/components/sensor/mysensors.py b/homeassistant/components/sensor/mysensors.py index 1add4157f0e..2fbfc0e97a4 100644 --- a/homeassistant/components/sensor/mysensors.py +++ b/homeassistant/components/sensor/mysensors.py @@ -42,7 +42,7 @@ async def async_setup_platform( async_add_devices=async_add_devices) -class MySensorsSensor(mysensors.MySensorsEntity): +class MySensorsSensor(mysensors.device.MySensorsEntity): """Representation of a MySensors Sensor child node.""" @property diff --git a/homeassistant/components/switch/mysensors.py b/homeassistant/components/switch/mysensors.py index a91ca6d11e7..340eed83b56 100644 --- a/homeassistant/components/switch/mysensors.py +++ b/homeassistant/components/switch/mysensors.py @@ -65,7 +65,7 @@ async def async_setup_platform( schema=SEND_IR_CODE_SERVICE_SCHEMA) -class MySensorsSwitch(mysensors.MySensorsEntity, SwitchDevice): +class MySensorsSwitch(mysensors.device.MySensorsEntity, SwitchDevice): """Representation of the value of a MySensors Switch child node.""" @property