From fa7246b081c18936e80a9e45bd03e05e708b14db Mon Sep 17 00:00:00 2001 From: James Hilliard Date: Sat, 27 Oct 2018 01:58:17 -0600 Subject: [PATCH] Initial hlk-sw16 relay switch support --- .coveragerc | 1 + homeassistant/components/hlk_sw16.py | 504 ++++++++++++++++++++ homeassistant/components/switch/hlk_sw16.py | 65 +++ 3 files changed, 570 insertions(+) create mode 100644 homeassistant/components/hlk_sw16.py create mode 100644 homeassistant/components/switch/hlk_sw16.py diff --git a/.coveragerc b/.coveragerc index 599e155f8f3c28..00f0f2e1a9c1c8 100644 --- a/.coveragerc +++ b/.coveragerc @@ -819,6 +819,7 @@ omit = homeassistant/components/switch/edimax.py homeassistant/components/switch/fritzdect.py homeassistant/components/switch/hikvisioncam.py + homeassistant/components/switch/hlk_sw16.py homeassistant/components/switch/hook.py homeassistant/components/switch/kankun.py homeassistant/components/switch/mystrom.py diff --git a/homeassistant/components/hlk_sw16.py b/homeassistant/components/hlk_sw16.py new file mode 100644 index 00000000000000..8ffebea377b70d --- /dev/null +++ b/homeassistant/components/hlk_sw16.py @@ -0,0 +1,504 @@ +""" +Support for HLK-SW16 relay switch. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/hlk-sw16/ +""" +import asyncio +from collections import defaultdict +import logging +import async_timeout +import binascii + +import voluptuous as vol + +from homeassistant.const import ( + ATTR_ENTITY_ID, CONF_COMMAND, CONF_HOST, CONF_PORT, + EVENT_HOMEASSISTANT_STOP) +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 +from homeassistant.helpers.dispatcher import ( + async_dispatcher_send, async_dispatcher_connect) + +_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_RECONNECT_INTERVAL = 'reconnect_interval' +CONF_SIGNAL_REPETITIONS = 'signal_repetitions' + +DATA_DEVICE_REGISTER = 'hlk_sw16_device_register' +DATA_ENTITY_LOOKUP = 'hlk_sw16_entity_lookup' +DATA_ENTITY_GROUP_LOOKUP = 'hlk_sw16_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' + +HLK_SW16_GROUP_COMMANDS = ['allon', 'alloff'] + +DOMAIN = 'hlk_sw16' + +SERVICE_SEND_COMMAND = 'send_command' + +SIGNAL_AVAILABILITY = 'hlk_sw16_device_available' +SIGNAL_HANDLE_EVENT = 'hlk_sw16_handle_event_{}' + +TMP_ENTITY = 'tmp.{}' + +DEVICE_DEFAULTS_SCHEMA = vol.Schema({ + 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_RECONNECT_INTERVAL, + default=DEFAULT_RECONNECT_INTERVAL): int, + }), +}, extra=vol.ALLOW_EXTRA) + +SEND_COMMAND_SCHEMA = vol.Schema({ + vol.Required(CONF_DEVICE_ID): cv.string, + vol.Required(CONF_COMMAND): cv.string, +}) + +class SW16Protocol(asyncio.Protocol): + + transport = None # type: asyncio.Transport + + def __init__(self, loop=None, disconnect_callback=None) -> None: + if loop: + self.loop = loop + else: + self.loop = asyncio.get_event_loop() + self.packet = b'' + self.buffer = b'' + self.disconnect_callback = disconnect_callback + + def connection_made(self, transport): + self.transport = transport + _LOGGER.debug('connected') + + def data_received(self, data): + _LOGGER.debug('received data: %s', binascii.hexlify(data)) + _LOGGER.debug(data) + _LOGGER.debug('received buffer: %s', binascii.hexlify(self.buffer)) + self.buffer += data + self.handle_lines() + + def handle_lines(self): + while b'\xdd' in self.buffer: + linebuf, self.buffer = self.buffer.rsplit(b'\xdd', 1) + line = linebuf[-19:] + self.buffer += linebuf[:-19] + _LOGGER.debug('received line: %s', binascii.hexlify(line)) + if self.valid_packet(line): + _LOGGER.debug('received valid line: %s', binascii.hexlify(line)) + self.handle_raw_packet(line) + else: + _LOGGER.warning('dropping invalid data: %s', binascii.hexlify(line)) + + def valid_packet(self, raw_packet): + if raw_packet[0:1] != b'\xcc': + return False + if len(raw_packet) != 19: + return False + checksum = 0 + for i in range(1,17): + checksum += raw_packet[i] + if checksum != raw_packet[18]: + return False + return True + + def handle_raw_packet(self, raw_packet): + if raw_packet[1:2] == b'\x1f': + Year = raw_packet[2] + Month = raw_packet[3] + Day = raw_packet[4] + Hour = raw_packet[5] + Minute = raw_packet[6] + Sec = raw_packet[7] + Week = raw_packet[8] + _LOGGER.debug('received date: Year: %s, Month: %s, Day: %s, Hour: %s, Minute: %s, Sec: %s, Week %s', Year, Month, Day, Hour, Minute, Sec, Week) + elif raw_packet[1:2] == b'\x0c': + states = {} + for switch in range(0, 16): + if raw_packet[2+switch:3+switch] == b'\x02': + states[format(switch, 'x')] = "on" + elif raw_packet[2+switch:3+switch] == b'\x01': + states[format(switch, 'x')] = "off" + _LOGGER.debug(states) + else: + pass + + def formatCommand(self, command): + FRAME_HEADER = b"\xaa" + VERIFY = b"\x0b" + SEND_DELIMITER = b"\xbb" + return FRAME_HEADER + command.ljust(17, b"\x00") + VERIFY + SEND_DELIMITER + + def changeState(self, direction, switch=None): + if direction == "status": + return self.formatCommand(b"\x1e") + if switch != None: + if direction == "off": + return self.formatCommand(b"\x10" + switch + b"\x01") + if direction == "on": + return self.formatCommand(b"\x10" + switch + b"\x02") + if direction == "off": + return self.formatCommand(b"\x0a") + if direction == "on": + return self.formatCommand(b"\x0b") + + async def send_command(self, command): + """Encode and put packet string onto write buffer.""" + packet = self.changeState(command) + + _LOGGER.debug('got command: %s', command) + _LOGGER.debug(packet) + self.transport.write(packet) + return True + + def connection_lost(self, exc): + """Log when connection is closed, if needed call callback.""" + if exc: + _LOGGER.exception('disconnected due to exception') + else: + _LOGGER.info('disconnected because of close/abort.') + if self.disconnect_callback: + self.disconnect_callback(exc) + + +async def async_setup(hass, config): + """Set up the HLK-SW16 switch.""" + + # Allow entities to register themselves by device_id to be looked up when + # new HLK-SW16 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), + } + + # Allow platform to specify function to register new unknown devices + hass.data[DATA_DEVICE_REGISTER] = {} + + async def async_send_command(call): + """Send HLK-SW16 command.""" + _LOGGER.debug('HLK-SW16 command for %s', str(call.data)) + if not (await SW16Command.send_command( + call.data.get(CONF_DEVICE_ID), + call.data.get(CONF_COMMAND))): + _LOGGER.error('Failed HLK-SW16 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 HLK-SW16 events. + + HLK-SW16 events arrive as dictionaries of varying content + depending on their type. Identify the events and distribute + accordingly. + """ + _LOGGER.debug('event: %s', event) + + host = config[DOMAIN][CONF_HOST] + port = config[DOMAIN][CONF_PORT] + + @callback + def reconnect(exc=None): + """Schedule reconnect after connection has been unexpectedly lost.""" + # Reset protocol binding before starting reconnect + SW16Command.set_hlk_sw16_protocol(None) + + async_dispatcher_send(hass, SIGNAL_AVAILABILITY, False) + + # If HA is not stopping, initiate new connection + if hass.state != CoreState.stopping: + _LOGGER.warning('disconnected from HLK-SW16, reconnecting') + hass.async_create_task(connect()) + + async def connect(): + """Set up connection and hook it into HA for reconnect/shutdown.""" + _LOGGER.info('Initiating HLK-SW16 connection') + + # HLK-SW16 create_hlk_sw16_connection decides based on the value of host + # (string or None) if serial or tcp mode should be used + + try: + with async_timeout.timeout(CONNECTION_TIMEOUT, + loop=hass.loop): + transport, protocol = await hass.loop.create_connection( + lambda: SW16Protocol(disconnect_callback=reconnect, loop=hass.loop), + host=host, port=port) + _LOGGER.info(transport) + + except (ConnectionRefusedError, TimeoutError, OSError, asyncio.TimeoutError) as exc: + reconnect_interval = config[DOMAIN][CONF_RECONNECT_INTERVAL] + _LOGGER.exception( + "Error connecting to HLK-SW16, reconnecting in %s", + reconnect_interval) + # Connection to HLK-SW16 device is lost, make entities unavailable + async_dispatcher_send(hass, SIGNAL_AVAILABILITY, False) + + hass.loop.call_later(reconnect_interval, reconnect, exc) + return + + # There is a valid connection to a HLK-SW16 device now so + # mark entities as available + async_dispatcher_send(hass, SIGNAL_AVAILABILITY, True) + + # Bind protocol to command class to allow entities to send commands + SW16Command.set_hlk_sw16_protocol(protocol) + + # handle shutdown of HLK-SW16 asyncio transport + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, + lambda x: transport.close()) + + _LOGGER.info('Connected to HLK-SW16') + + hass.async_create_task(connect()) + return True + + +class SW16Device(Entity): + """Representation of a HLK-SW16 device. + + Contains the common logic for HLK-SW16 entities. + """ + + platform = None + _state = None + _available = True + + def __init__(self, device_id, initial_event=None, name=None, aliases=None, + group=True, group_aliases=None, nogroup_aliases=None, + signal_repetitions=DEFAULT_SIGNAL_REPETITIONS): + """Initialize the device.""" + # HLK-SW16 specific attributes for every component type + self._initial_event = initial_event + self._device_id = device_id + if name: + self._name = name + else: + self._name = device_id + + self._aliases = aliases + self._group = group + self._group_aliases = group_aliases + self._nogroup_aliases = nogroup_aliases + self._signal_repetitions = signal_repetitions + + @callback + def handle_event_callback(self, event): + # Propagate changes through ha + self.async_schedule_update_ha_state() + + 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 None + + @property + def available(self): + """Return True if entity is available.""" + return self._available + + @callback + def _availability_callback(self, availability): + """Update availability state.""" + self._available = availability + self.async_schedule_update_ha_state() + + async def async_added_to_hass(self): + """Register update callback.""" + # Remove temporary bogus entity_id if added + tmp_entity = TMP_ENTITY.format(self._device_id) + + # # Register id and aliases + # self.hass.data[DATA_ENTITY_LOOKUP][ + # EVENT_KEY_COMMAND][self._device_id].append(self.entity_id) + # if self._group: + # self.hass.data[DATA_ENTITY_GROUP_LOOKUP][ + # EVENT_KEY_COMMAND][self._device_id].append(self.entity_id) + # # aliases respond to both normal and group commands (allon/alloff) + # if self._aliases: + # for _id in self._aliases: + # self.hass.data[DATA_ENTITY_LOOKUP][ + # EVENT_KEY_COMMAND][_id].append(self.entity_id) + # self.hass.data[DATA_ENTITY_GROUP_LOOKUP][ + # EVENT_KEY_COMMAND][_id].append(self.entity_id) + # # group_aliases only respond to group commands (allon/alloff) + # if self._group_aliases: + # for _id in self._group_aliases: + # self.hass.data[DATA_ENTITY_GROUP_LOOKUP][ + # EVENT_KEY_COMMAND][_id].append(self.entity_id) + # # nogroup_aliases only respond to normal commands + # if self._nogroup_aliases: + # for _id in self._nogroup_aliases: + # self.hass.data[DATA_ENTITY_LOOKUP][ + # EVENT_KEY_COMMAND][_id].append(self.entity_id) + async_dispatcher_connect(self.hass, SIGNAL_AVAILABILITY, + self._availability_callback) + async_dispatcher_connect(self.hass, + SIGNAL_HANDLE_EVENT.format(self.entity_id), + self.handle_event_callback) + + # Process the initial event now that the entity is created + if self._initial_event: + self.handle_event_callback(self._initial_event) + + +class SW16Command(SW16Device): + """Singleton class to make HLK-SW16 command interface available to entities. + + This class is to be inherited by every Entity class that is actionable + (switches/lights). It exposes the HLK-S16 command interface for these + entities. + + The HLK-SW16 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_hlk_sw16_protocol(cls, protocol): + """Set the HLK-S16 asyncio protocol as a class variable.""" + cls._protocol = protocol + + @classmethod + def is_connected(cls): + """Return connection status.""" + return bool(cls._protocol) + + @classmethod + async def send_command(cls, device_id, action): + """Send device command to HLK-SW16 and wait for acknowledgement.""" + return await cls._protocol.send_command_ack(device_id, action) + + async def _async_handle_command(self, command, *args): + """Do bookkeeping for command, send it to HLK-SW16 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 == '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 [None, False] + + # 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 + await self._async_send_command(cmd, self._signal_repetitions) + + # Update state of entity + await 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 HLK-SW16 command (remote + switch) changes the state. + """ + # cancel any outstanding tasks from the previous state change + if self._repetition_task: + self._repetition_task.cancel() + + async def _async_send_command(self, cmd, repetitions): + """Send a command for device to HLK-SW16 gateway.""" + _LOGGER.debug( + "Sending command: %s to HLK-SW16 device: %s", cmd, self._device_id) + + #if not self.is_connected(): + #raise HomeAssistantError('Cannot send command, not connected!') + + self.hass.async_create_task(self._protocol.send_command(cmd)) + + if repetitions > 1: + self._repetition_task = self.hass.async_create_task( + self._async_send_command(cmd, repetitions - 1)) + + +class SwitchableSW16Device(SW16Command): + """HLK-SW16 entity which can switch on/off (eg: light, switch).""" + + def _handle_event(self, event): + """Adjust state if HLK-SW16 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") + diff --git a/homeassistant/components/switch/hlk_sw16.py b/homeassistant/components/switch/hlk_sw16.py new file mode 100644 index 00000000000000..a8f2e4eee0f373 --- /dev/null +++ b/homeassistant/components/switch/hlk_sw16.py @@ -0,0 +1,65 @@ +""" +Support for HLK-SW16 switches. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/switch.hlk-sw16/ +""" +import logging + +import voluptuous as vol + +from homeassistant.components.hlk_sw16 import ( + CONF_ALIASES, CONF_ALIASSES, CONF_DEVICE_DEFAULTS, CONF_DEVICES, + CONF_GROUP, CONF_GROUP_ALIASES, CONF_GROUP_ALIASSES, + CONF_NOGROUP_ALIASES, CONF_NOGROUP_ALIASSES, CONF_SIGNAL_REPETITIONS, + DEVICE_DEFAULTS_SCHEMA, SW16Device, SwitchableSW16Device) +from homeassistant.components.switch import ( + PLATFORM_SCHEMA, SwitchDevice) +import homeassistant.helpers.config_validation as cv +from homeassistant.const import CONF_NAME + +DEPENDENCIES = ['hlk_sw16'] + +_LOGGER = logging.getLogger(__name__) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_DEVICE_DEFAULTS, default=DEVICE_DEFAULTS_SCHEMA({})): + DEVICE_DEFAULTS_SCHEMA, + vol.Optional(CONF_DEVICES, default={}): { + cv.string: vol.Schema({ + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_ALIASES, default=[]): + vol.All(cv.ensure_list, [cv.string]), + vol.Optional(CONF_GROUP_ALIASES, default=[]): + vol.All(cv.ensure_list, [cv.string]), + vol.Optional(CONF_NOGROUP_ALIASES, default=[]): + vol.All(cv.ensure_list, [cv.string]), + vol.Optional(CONF_SIGNAL_REPETITIONS): vol.Coerce(int), + vol.Optional(CONF_GROUP, default=True): cv.boolean, + }) + }, +}, extra=vol.ALLOW_EXTRA) + + +def devices_from_config(domain_config): + """Parse configuration and add HLK-SW16 switch devices.""" + devices = [] + _LOGGER.info(domain_config) + for device_id, config in domain_config[CONF_DEVICES].items(): + device_config = dict(domain_config[CONF_DEVICE_DEFAULTS], **config) + _LOGGER.info(device_config) + device = SW16Switch(device_id, **device_config) + devices.append(device) + return devices + + +async def async_setup_platform(hass, config, async_add_entities, + discovery_info=None): + """Set up the HLK-SW16 platform.""" + async_add_entities(devices_from_config(config)) + + +class SW16Switch(SwitchableSW16Device, SwitchDevice): + """Representation of a HLK-SW16 switch.""" + + pass