core/homeassistant/components/rflink.py

403 lines
13 KiB
Python

"""Support for Rflink components.
For more details about this component, please refer to the documentation at
https://home-assistant.io/components/rflink/
Technical overview:
The Rflink gateway is a USB serial device (Arduino with Rflink firwmare)
connected to a 433Mhz transceiver module.
The the `rflink` Python module a asyncio transport/protocol is setup that
fires an callback for every (valid/supported) packet received by the Rflink
gateway.
This component uses this callback to distribute 'rflink packet events' over
the HASS bus which can be subscribed to by entities/platform implementations.
The platform implementions take care of creating new devices (if enabled) for
unsees incoming packet id's.
Device Entities take care of matching to the packet id, interpreting and
performing actions based on the packet contents. Common entitiy logic is
maintained in this file.
"""
import asyncio
from collections import defaultdict
import functools as ft
import logging
from homeassistant.const import (
ATTR_ENTITY_ID, CONF_HOST, CONF_PORT, EVENT_HOMEASSISTANT_STOP,
STATE_UNKNOWN)
from homeassistant.core import CoreState, callback
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity import Entity
import voluptuous as vol
REQUIREMENTS = ['rflink==0.0.24']
DOMAIN = 'rflink'
CONF_ALIASSES = 'aliasses'
CONF_DEVICES = 'devices'
CONF_DEVICE_DEFAULTS = 'device_defaults'
CONF_FIRE_EVENT = 'fire_event'
CONF_IGNORE_DEVICES = 'ignore_devices'
CONF_NEW_DEVICES_GROUP = 'new_devices_group'
CONF_RECONNECT_INTERVAL = 'reconnect_interval'
CONF_SIGNAL_REPETITIONS = 'signal_repetitions'
CONF_WAIT_FOR_ACK = 'wait_for_ack'
DEFAULT_SIGNAL_REPETITIONS = 1
DEFAULT_RECONNECT_INTERVAL = 10
DEVICE_DEFAULTS_SCHEMA = vol.Schema({
vol.Optional(CONF_FIRE_EVENT, default=False): cv.boolean,
vol.Optional(CONF_SIGNAL_REPETITIONS,
default=DEFAULT_SIGNAL_REPETITIONS): vol.Coerce(int),
})
CONFIG_SCHEMA = vol.Schema({
DOMAIN: vol.Schema({
vol.Required(CONF_PORT): vol.Any(cv.port, cv.string),
vol.Optional(CONF_HOST, default=None): cv.string,
vol.Optional(CONF_WAIT_FOR_ACK, default=True): cv.boolean,
vol.Optional(CONF_RECONNECT_INTERVAL,
default=DEFAULT_RECONNECT_INTERVAL): int,
vol.Optional(CONF_IGNORE_DEVICES, default=[]):
vol.All(cv.ensure_list, [cv.string]),
}),
}, extra=vol.ALLOW_EXTRA)
ATTR_EVENT = 'event'
ATTR_STATE = 'state'
DATA_DEVICE_REGISTER = 'rflink_device_register'
DATA_ENTITY_LOOKUP = 'rflink_entity_lookup'
EVENT_BUTTON_PRESSED = 'button_pressed'
EVENT_KEY_COMMAND = 'command'
EVENT_KEY_ID = 'id'
EVENT_KEY_SENSOR = 'sensor'
EVENT_KEY_UNIT = 'unit'
_LOGGER = logging.getLogger(__name__)
def identify_event_type(event):
"""Look at event to determine type of device.
Async friendly.
"""
if EVENT_KEY_COMMAND in event:
return EVENT_KEY_COMMAND
elif EVENT_KEY_SENSOR in event:
return EVENT_KEY_SENSOR
else:
return 'unknown'
@asyncio.coroutine
def async_setup(hass, config):
"""Setup the Rflink component."""
from rflink.protocol import create_rflink_connection
import serial
# allow entities to register themselves by device_id to be looked up when
# new rflink events arrive to be handled
hass.data[DATA_ENTITY_LOOKUP] = {
EVENT_KEY_COMMAND: defaultdict(list),
EVENT_KEY_SENSOR: defaultdict(list),
}
# allow platform to specify function to register new unknown devices
hass.data[DATA_DEVICE_REGISTER] = {}
@callback
def event_callback(event):
"""Handle incoming rflink events.
Rflink events arrive as dictionaries of varying content
depending on their type. Identify the events and distribute
accordingly.
"""
event_type = identify_event_type(event)
_LOGGER.debug('event of type %s: %s', event_type, event)
# don't propagate non entity events (eg: version string, ack response)
if event_type not in hass.data[DATA_ENTITY_LOOKUP]:
_LOGGER.debug('unhandled event of type: %s', event_type)
return
# lookup entities who registered this device id as device id or alias
event_id = event.get('id', None)
entities = hass.data[DATA_ENTITY_LOOKUP][event_type][event_id]
if entities:
# propagate event to every entity matching the device id
for entity in entities:
_LOGGER.debug('passing event to %s', entities)
entity.handle_event(event)
else:
_LOGGER.debug('device_id not known, adding new device')
# if device is not yet known, register with platform (if loaded)
if event_type in hass.data[DATA_DEVICE_REGISTER]:
hass.async_run_job(
hass.data[DATA_DEVICE_REGISTER][event_type], event)
# when connecting to tcp host instead of serial port (optional)
host = config[DOMAIN][CONF_HOST]
# tcp port when host configured, otherwise serial port
port = config[DOMAIN][CONF_PORT]
@callback
def reconnect(exc=None):
"""Schedule reconnect after connection has been unexpectedly lost."""
# reset protocol binding before starting reconnect
RflinkCommand.set_rflink_protocol(None)
# if HA is not stopping, initiate new connection
if hass.state != CoreState.stopping:
_LOGGER.warning('disconnected from Rflink, reconnecting')
hass.async_add_job(connect)
@asyncio.coroutine
def connect():
"""Setup connection and hook it into HA for reconnect/shutdown."""
_LOGGER.info('initiating Rflink connection')
# rflink create_rflink_connection decides based on the value of host
# (string or None) if serial or tcp mode should be used
# initiate serial/tcp connection to Rflink gateway
connection = create_rflink_connection(
port=port,
host=host,
event_callback=event_callback,
disconnect_callback=reconnect,
loop=hass.loop,
ignore=config[DOMAIN][CONF_IGNORE_DEVICES]
)
try:
transport, protocol = yield from connection
except (serial.serialutil.SerialException, ConnectionRefusedError,
TimeoutError) as exc:
reconnect_interval = config[DOMAIN][CONF_RECONNECT_INTERVAL]
_LOGGER.exception(
'error connecting to Rflink, reconnecting in %s',
reconnect_interval)
hass.loop.call_later(reconnect_interval, reconnect, exc)
return
# bind protocol to command class to allow entities to send commands
RflinkCommand.set_rflink_protocol(
protocol, config[DOMAIN][CONF_WAIT_FOR_ACK])
# handle shutdown of rflink asyncio transport
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP,
lambda x: transport.close())
_LOGGER.info('connected to Rflink')
# make initial connection
yield from connect()
# whoo
return True
class RflinkDevice(Entity):
"""Represents a Rflink device.
Contains the common logic for Rflink entities.
"""
# should be set by component implementation
platform = None
# default state
_state = STATE_UNKNOWN
def __init__(self, device_id, hass, name=None,
aliasses=None, fire_event=False,
signal_repetitions=DEFAULT_SIGNAL_REPETITIONS):
"""Initialize the device."""
self.hass = hass
# rflink specific attributes for every component type
self._device_id = device_id
if name:
self._name = name
else:
self._name = device_id
# generate list of device_ids to match against
if aliasses:
self._aliasses = aliasses
else:
self._aliasses = []
self._should_fire_event = fire_event
self._signal_repetitions = signal_repetitions
def handle_event(self, event):
"""Handle incoming event for device type."""
# call platform specific event handler
self._handle_event(event)
# propagate changes through ha
self.hass.async_add_job(self.async_update_ha_state())
# put command onto bus for user to subscribe to
if self._should_fire_event and identify_event_type(
event) == EVENT_KEY_COMMAND:
self.hass.bus.fire(EVENT_BUTTON_PRESSED, {
ATTR_ENTITY_ID: self.entity_id,
ATTR_STATE: event[EVENT_KEY_COMMAND],
})
_LOGGER.debug(
'fired bus event for %s: %s',
self.entity_id,
event[EVENT_KEY_COMMAND])
def _handle_event(self, event):
"""Platform specific event handler."""
raise NotImplementedError()
@property
def should_poll(self):
"""No polling needed."""
return False
@property
def name(self):
"""Return a name for the device."""
return self._name
@property
def is_on(self):
"""Return true if device is on."""
if self.assumed_state:
return False
return self._state
@property
def assumed_state(self):
"""Assume device state until first device event sets state."""
return self._state is STATE_UNKNOWN
class RflinkCommand(RflinkDevice):
"""Singleton class to make Rflink command interface available to entities.
This class is to be inherited by every Entity class that is actionable
(switches/lights). It exposes the Rflink command interface for these
entities.
The Rflink interface is managed as a class level and set during setup (and
reset on reconnect).
"""
# keep repetition tasks to cancel if state is changed before repetitions
# are sent
_repetition_task = None
@classmethod
def set_rflink_protocol(cls, protocol, wait_ack=None):
"""Set the Rflink asyncio protocol as a class variable."""
cls._protocol = protocol
if wait_ack is not None:
cls._wait_ack = wait_ack
@asyncio.coroutine
def _async_handle_command(self, command, *args):
"""Do bookkeeping for command, send it to rflink and update state."""
self.cancel_queued_send_commands()
if command == "turn_on":
cmd = 'on'
self._state = True
elif command == 'turn_off':
cmd = 'off'
self._state = False
elif command == 'dim':
# convert brightness to rflink dim level
cmd = str(int(args[0] / 17))
self._state = True
# send initial command and queue repetitions
# this allows the entity state to be updated quickly and not having to
# wait for all repetitions to be sent
yield from self._async_send_command(cmd, self._signal_repetitions)
# Update state of entity
yield from self.async_update_ha_state()
def cancel_queued_send_commands(self):
"""Cancel queued signal repetition commands.
For example when user changed state while repetitions are still
queued for broadcast. Or when a incoming Rflink command (remote
switch) changes the state.
"""
# cancel any outstanding tasks from the previous state change
if self._repetition_task:
self._repetition_task.cancel()
@asyncio.coroutine
def _async_send_command(self, cmd, repetitions):
"""Send a command for device to Rflink gateway."""
_LOGGER.debug('sending command: %s to rflink device: %s',
cmd, self._device_id)
if self._wait_ack:
# Puts command on outgoing buffer then waits for Rflink to confirm
# the command has been send out in the ether.
yield from self._protocol.send_command_ack(self._device_id, cmd)
else:
# Puts command on outgoing buffer and returns straight away.
# Rflink protocol/transport handles asynchronous writing of buffer
# to serial/tcp device. Does not wait for command send
# confirmation.
self.hass.loop.run_in_executor(None, ft.partial(
self._protocol.send_command, self._device_id, cmd))
if repetitions > 1:
self._repetition_task = self.hass.loop.create_task(
self._async_send_command(cmd, repetitions - 1))
class SwitchableRflinkDevice(RflinkCommand):
"""Rflink entity which can switch on/off (eg: light, switch)."""
def _handle_event(self, event):
"""Adjust state if Rflink picks up a remote command for this device."""
self.cancel_queued_send_commands()
command = event['command']
if command == 'on':
self._state = True
elif command == 'off':
self._state = False
@asyncio.coroutine
def async_turn_on(self, **kwargs):
"""Turn the device on."""
yield from self._async_handle_command("turn_on")
@asyncio.coroutine
def async_turn_off(self, **kwargs):
"""Turn the device off."""
yield from self._async_handle_command("turn_off")