467 lines
16 KiB
Python
467 lines
16 KiB
Python
"""
|
|
Support for Rflink components.
|
|
|
|
For more details about this component, please refer to the documentation at
|
|
https://home-assistant.io/components/rflink/
|
|
"""
|
|
import asyncio
|
|
from collections import defaultdict
|
|
import functools as ft
|
|
import logging
|
|
import async_timeout
|
|
|
|
import voluptuous as vol
|
|
|
|
from homeassistant.const import (
|
|
ATTR_ENTITY_ID, CONF_COMMAND, CONF_HOST, CONF_PORT,
|
|
EVENT_HOMEASSISTANT_STOP, STATE_UNKNOWN)
|
|
from homeassistant.core import CoreState, callback
|
|
from homeassistant.exceptions import HomeAssistantError
|
|
import homeassistant.helpers.config_validation as cv
|
|
from homeassistant.helpers.deprecation import get_deprecated
|
|
from homeassistant.helpers.entity import Entity
|
|
|
|
|
|
REQUIREMENTS = ['rflink==0.0.34']
|
|
|
|
_LOGGER = logging.getLogger(__name__)
|
|
|
|
ATTR_EVENT = 'event'
|
|
ATTR_STATE = 'state'
|
|
|
|
CONF_ALIASES = 'aliases'
|
|
CONF_ALIASSES = 'aliasses'
|
|
CONF_GROUP_ALIASES = 'group_aliases'
|
|
CONF_GROUP_ALIASSES = 'group_aliasses'
|
|
CONF_GROUP = 'group'
|
|
CONF_NOGROUP_ALIASES = 'nogroup_aliases'
|
|
CONF_NOGROUP_ALIASSES = 'nogroup_aliasses'
|
|
CONF_DEVICE_DEFAULTS = 'device_defaults'
|
|
CONF_DEVICE_ID = 'device_id'
|
|
CONF_DEVICES = 'devices'
|
|
CONF_AUTOMATIC_ADD = 'automatic_add'
|
|
CONF_FIRE_EVENT = 'fire_event'
|
|
CONF_IGNORE_DEVICES = 'ignore_devices'
|
|
CONF_RECONNECT_INTERVAL = 'reconnect_interval'
|
|
CONF_SIGNAL_REPETITIONS = 'signal_repetitions'
|
|
CONF_WAIT_FOR_ACK = 'wait_for_ack'
|
|
|
|
DATA_DEVICE_REGISTER = 'rflink_device_register'
|
|
DATA_ENTITY_LOOKUP = 'rflink_entity_lookup'
|
|
DATA_ENTITY_GROUP_LOOKUP = 'rflink_entity_group_only_lookup'
|
|
DEFAULT_RECONNECT_INTERVAL = 10
|
|
DEFAULT_SIGNAL_REPETITIONS = 1
|
|
CONNECTION_TIMEOUT = 10
|
|
|
|
EVENT_BUTTON_PRESSED = 'button_pressed'
|
|
EVENT_KEY_COMMAND = 'command'
|
|
EVENT_KEY_ID = 'id'
|
|
EVENT_KEY_SENSOR = 'sensor'
|
|
EVENT_KEY_UNIT = 'unit'
|
|
|
|
RFLINK_GROUP_COMMANDS = ['allon', 'alloff']
|
|
|
|
DOMAIN = 'rflink'
|
|
|
|
SERVICE_SEND_COMMAND = 'send_command'
|
|
|
|
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): 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)
|
|
|
|
SEND_COMMAND_SCHEMA = vol.Schema({
|
|
vol.Required(CONF_DEVICE_ID): cv.string,
|
|
vol.Required(CONF_COMMAND): cv.string,
|
|
})
|
|
|
|
|
|
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
|
|
return 'unknown'
|
|
|
|
|
|
@asyncio.coroutine
|
|
def async_setup(hass, config):
|
|
"""Set up 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),
|
|
}
|
|
hass.data[DATA_ENTITY_GROUP_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] = {}
|
|
|
|
@asyncio.coroutine
|
|
def async_send_command(call):
|
|
"""Send Rflink command."""
|
|
_LOGGER.debug('Rflink command for %s', str(call.data))
|
|
if not (yield from RflinkCommand.send_command(
|
|
call.data.get(CONF_DEVICE_ID),
|
|
call.data.get(CONF_COMMAND))):
|
|
_LOGGER.error('Failed Rflink command for %s', str(call.data))
|
|
|
|
hass.services.async_register(
|
|
DOMAIN, SERVICE_SEND_COMMAND, async_send_command,
|
|
schema=SEND_COMMAND_SCHEMA)
|
|
|
|
@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)
|
|
|
|
is_group_event = (event_type == EVENT_KEY_COMMAND and
|
|
event[EVENT_KEY_COMMAND] in RFLINK_GROUP_COMMANDS)
|
|
if is_group_event:
|
|
entities = hass.data[DATA_ENTITY_GROUP_LOOKUP][event_type].get(
|
|
event_id, [])
|
|
else:
|
|
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].get(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():
|
|
"""Set up 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:
|
|
with async_timeout.timeout(CONNECTION_TIMEOUT,
|
|
loop=hass.loop):
|
|
transport, protocol = yield from connection
|
|
|
|
except (serial.serialutil.SerialException, ConnectionRefusedError,
|
|
TimeoutError, OSError, asyncio.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')
|
|
|
|
hass.async_add_job(connect)
|
|
return True
|
|
|
|
|
|
class RflinkDevice(Entity):
|
|
"""Representation of a Rflink device.
|
|
|
|
Contains the common logic for Rflink entities.
|
|
"""
|
|
|
|
platform = None
|
|
_state = STATE_UNKNOWN
|
|
|
|
def __init__(self, device_id, hass, name=None, aliases=None, group=True,
|
|
group_aliases=None, nogroup_aliases=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
|
|
|
|
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.async_schedule_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
|
|
|
|
_protocol = 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
|
|
|
|
@classmethod
|
|
def is_connected(cls):
|
|
"""Return connection status."""
|
|
return bool(cls._protocol)
|
|
|
|
@classmethod
|
|
@asyncio.coroutine
|
|
def send_command(cls, device_id, action):
|
|
"""Send device command to Rflink and wait for acknowledgement."""
|
|
return (yield from cls._protocol.send_command_ack(device_id, action))
|
|
|
|
@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
|
|
|
|
elif command == 'toggle':
|
|
cmd = 'on'
|
|
# if the state is unknown or false, it gets set as true
|
|
# if the state is true, it gets set as false
|
|
self._state = self._state in [STATE_UNKNOWN, False]
|
|
|
|
# Cover options for RFlink
|
|
elif command == 'close_cover':
|
|
cmd = 'DOWN'
|
|
self._state = False
|
|
|
|
elif command == 'open_cover':
|
|
cmd = 'UP'
|
|
self._state = True
|
|
|
|
elif command == 'stop_cover':
|
|
cmd = 'STOP'
|
|
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 an 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 not self.is_connected():
|
|
raise HomeAssistantError('Cannot send command, not connected!')
|
|
|
|
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.async_add_job(ft.partial(
|
|
self._protocol.send_command, self._device_id, cmd))
|
|
|
|
if repetitions > 1:
|
|
self._repetition_task = self.hass.async_add_job(
|
|
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 in ['on', 'allon']:
|
|
self._state = True
|
|
elif command in ['off', 'alloff']:
|
|
self._state = False
|
|
|
|
def async_turn_on(self, **kwargs):
|
|
"""Turn the device on."""
|
|
return self._async_handle_command("turn_on")
|
|
|
|
def async_turn_off(self, **kwargs):
|
|
"""Turn the device off."""
|
|
return self._async_handle_command("turn_off")
|
|
|
|
|
|
DEPRECATED_CONFIG_OPTIONS = [
|
|
CONF_ALIASSES,
|
|
CONF_GROUP_ALIASSES,
|
|
CONF_NOGROUP_ALIASSES]
|
|
REPLACEMENT_CONFIG_OPTIONS = [
|
|
CONF_ALIASES,
|
|
CONF_GROUP_ALIASES,
|
|
CONF_NOGROUP_ALIASES]
|
|
|
|
|
|
def remove_deprecated(config):
|
|
"""Remove deprecated config options from device config."""
|
|
for index, deprecated_option in enumerate(DEPRECATED_CONFIG_OPTIONS):
|
|
if deprecated_option in config:
|
|
replacement_option = REPLACEMENT_CONFIG_OPTIONS[index]
|
|
# generate deprecation warning
|
|
get_deprecated(config, replacement_option, deprecated_option)
|
|
# remove old config value replacing new one
|
|
config[replacement_option] = config.pop(deprecated_option)
|