diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..02dbf7a --- /dev/null +++ b/__init__.py @@ -0,0 +1,198 @@ +"""Support for HLK-SW16 (old) relay switches.""" +import logging + +from .protocol import create_hlk_sw16_old_connection +import voluptuous as vol + +from homeassistant.config_entries import SOURCE_IMPORT +from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT, CONF_SWITCHES +from homeassistant.core import callback +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 .const import ( + CONNECTION_TIMEOUT, + DEFAULT_KEEP_ALIVE_INTERVAL, + DEFAULT_PORT, + DEFAULT_RECONNECT_INTERVAL, + DOMAIN, +) + +_LOGGER = logging.getLogger(__name__) + +DATA_DEVICE_REGISTER = "hlk_sw16_old_device_register" +DATA_DEVICE_LISTENER = "hlk_sw16_old_device_listener" + +SWITCH_SCHEMA = vol.Schema({vol.Optional(CONF_NAME): cv.string}) + +RELAY_ID = vol.All( + vol.Any(0, 1, 2, 3, 4, 5, 6, 7, 8, 9, "a", "b", "c", "d", "e", "f"), vol.Coerce(str) +) + +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: vol.Schema( + { + cv.string: vol.Schema( + { + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + vol.Required(CONF_SWITCHES): vol.Schema( + {RELAY_ID: SWITCH_SCHEMA} + ), + } + ) + } + ) + }, + extra=vol.ALLOW_EXTRA, +) + + +async def async_setup(hass, config): + """Component setup, do nothing.""" + if DOMAIN not in config: + return True + + for device_id in config[DOMAIN]: + conf = config[DOMAIN][device_id] + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={CONF_HOST: conf[CONF_HOST], CONF_PORT: conf[CONF_PORT]}, + ) + ) + return True + + +async def async_setup_entry(hass, entry): + """Set up the HLK-SW16 (old) switch.""" + hass.data.setdefault(DOMAIN, {}) + host = entry.data[CONF_HOST] + port = entry.data[CONF_PORT] + address = f"{host}:{port}" + + hass.data[DOMAIN][entry.entry_id] = {} + + @callback + def disconnected(): + """Schedule reconnect after connection has been lost.""" + _LOGGER.warning("HLK-SW16 (old) %s disconnected", address) + async_dispatcher_send( + hass, f"hlk_sw16_old_device_available_{entry.entry_id}", False + ) + + @callback + def reconnected(): + """Schedule reconnect after connection has been lost.""" + _LOGGER.warning("HLK-SW16 (old) %s connected", address) + async_dispatcher_send(hass, f"hlk_sw16_old_device_available_{entry.entry_id}", True) + + async def connect(): + """Set up connection and hook it into HA for reconnect/shutdown.""" + _LOGGER.info("Initiating HLK-SW16 (old) connection to %s", address) + + client = await create_hlk_sw16_old_connection( + host=host, + port=port, + disconnect_callback=disconnected, + reconnect_callback=reconnected, + loop=hass.loop, + timeout=CONNECTION_TIMEOUT, + reconnect_interval=DEFAULT_RECONNECT_INTERVAL, + keep_alive_interval=DEFAULT_KEEP_ALIVE_INTERVAL, + ) + + hass.data[DOMAIN][entry.entry_id][DATA_DEVICE_REGISTER] = client + + # Load entities + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, "switch") + ) + + _LOGGER.info("Connected to HLK-SW16 (old) device: %s", address) + + hass.loop.create_task(connect()) + + return True + + +async def async_unload_entry(hass, entry): + """Unload a config entry.""" + client = hass.data[DOMAIN][entry.entry_id].pop(DATA_DEVICE_REGISTER) + client.stop() + unload_ok = await hass.config_entries.async_forward_entry_unload(entry, "switch") + + if unload_ok: + if hass.data[DOMAIN][entry.entry_id]: + hass.data[DOMAIN].pop(entry.entry_id) + if not hass.data[DOMAIN]: + hass.data.pop(DOMAIN) + return unload_ok + + +class SW16OldDevice(Entity): + """Representation of a HLK-SW16 (old) device. + + Contains the common logic for HLK-SW16 (old) entities. + """ + + def __init__(self, device_relay, entry_id, client): + """Initialize the device.""" + # HLK-SW16 (old) specific attributes for every component type + self._entry_id = entry_id + self._device_relay = device_relay + self._is_on = None + self._client = client + self._name = device_relay + + @property + def unique_id(self): + """Return a unique ID.""" + return f"{self._entry_id}_{self._device_relay}" + + @callback + def handle_event_callback(self, event): + """Propagate changes through ha.""" + _LOGGER.debug("Relay %s new state callback: %r", self.unique_id, event) + self._is_on = event + self.async_write_ha_state() + + @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 available(self): + """Return True if entity is available.""" + return bool(self._client.is_connected) + + @callback + def _availability_callback(self, availability): + """Update availability state.""" + self.async_write_ha_state() + + async def async_added_to_hass(self): + """Register update callback.""" + self._client.register_status_callback( + self.handle_event_callback, self._device_relay + ) + self._is_on = await self._client.status(self._device_relay) + self.async_on_remove( + async_dispatcher_connect( + self.hass, + f"hlk_sw16_old_device_available_{self._entry_id}", + self._availability_callback, + ) + ) diff --git a/config_flow.py b/config_flow.py new file mode 100644 index 0000000..b739179 --- /dev/null +++ b/config_flow.py @@ -0,0 +1,96 @@ +"""Config flow for HLK-SW16 (old).""" +import asyncio + +from .protocol import create_hlk_sw16_old_connection +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.core import HomeAssistant + +from .const import ( + CONNECTION_TIMEOUT, + DEFAULT_KEEP_ALIVE_INTERVAL, + DEFAULT_PORT, + DEFAULT_RECONNECT_INTERVAL, + DOMAIN, +) +from .errors import AlreadyConfigured, CannotConnect + +DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): str, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): vol.Coerce(int), + } +) + + +async def connect_client(hass, user_input): + """Connect the HLK-SW16 (old) client.""" + client_aw = create_hlk_sw16_old_connection( + host=user_input[CONF_HOST], + port=user_input[CONF_PORT], + loop=hass.loop, + timeout=CONNECTION_TIMEOUT, + reconnect_interval=DEFAULT_RECONNECT_INTERVAL, + keep_alive_interval=DEFAULT_KEEP_ALIVE_INTERVAL, + ) + return await asyncio.wait_for(client_aw, timeout=CONNECTION_TIMEOUT) + + +async def validate_input(hass: HomeAssistant, user_input): + """Validate the user input allows us to connect.""" + for entry in hass.config_entries.async_entries(DOMAIN): + if ( + entry.data[CONF_HOST] == user_input[CONF_HOST] + and entry.data[CONF_PORT] == user_input[CONF_PORT] + ): + raise AlreadyConfigured + + try: + client = await connect_client(hass, user_input) + except asyncio.TimeoutError as err: + raise CannotConnect from err + try: + + def disconnect_callback(): + if client.in_transaction: + client.active_transaction.set_exception(CannotConnect) + + client.disconnect_callback = disconnect_callback + await client.status() + except CannotConnect: + client.disconnect_callback = None + client.stop() + raise + else: + client.disconnect_callback = None + client.stop() + + +class SW16FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a HLK-SW16 config flow.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_PUSH + + async def async_step_import(self, user_input): + """Handle import.""" + return await self.async_step_user(user_input) + + async def async_step_user(self, user_input=None): + """Handle the initial step.""" + errors = {} + if user_input is not None: + try: + await validate_input(self.hass, user_input) + address = f"{user_input[CONF_HOST]}:{user_input[CONF_PORT]}" + return self.async_create_entry(title=address, data=user_input) + except AlreadyConfigured: + errors["base"] = "already_configured" + except CannotConnect: + errors["base"] = "cannot_connect" + + return self.async_show_form( + step_id="user", data_schema=DATA_SCHEMA, errors=errors + ) diff --git a/const.py b/const.py new file mode 100644 index 0000000..9479e62 --- /dev/null +++ b/const.py @@ -0,0 +1,9 @@ +"""Constants for HLK-SW16 (old) component.""" + +DOMAIN = "hlk_sw16_old" + +DEFAULT_NAME = "HLK-SW16 (old)" +DEFAULT_PORT = 8080 +DEFAULT_RECONNECT_INTERVAL = 10 +DEFAULT_KEEP_ALIVE_INTERVAL = 3 +CONNECTION_TIMEOUT = 10 diff --git a/errors.py b/errors.py new file mode 100644 index 0000000..b022aa5 --- /dev/null +++ b/errors.py @@ -0,0 +1,14 @@ +"""Errors for the HLK-SW16 (old) component.""" +from homeassistant.exceptions import HomeAssistantError + + +class SW16Exception(HomeAssistantError): + """Base class for HLK-SW16 (old) exceptions.""" + + +class AlreadyConfigured(SW16Exception): + """HLK-SW16 (old) is already configured.""" + + +class CannotConnect(SW16Exception): + """Unable to connect to the HLK-SW16 (old).""" diff --git a/hlk_sw16_cmd.java b/hlk_sw16_cmd.java new file mode 100644 index 0000000..7becb61 --- /dev/null +++ b/hlk_sw16_cmd.java @@ -0,0 +1,104 @@ +package com.slydiman.hlk_sw16_cmd; + +import java.io.BufferedInputStream; +import java.io.BufferedOutputStream; +import java.io.IOException; +import java.net.Socket; + +public class hlk_sw16_cmd { + public static void main(String[] args) { + + if (args.length != 2 && args.length != 4) { + System.err.println("usage: hlk_sw16_cmd [ ]"); + System.err.println("relay_#: 0..15 or all"); + System.err.println("value: 1/0 or on/off"); + return; + } + + String ip = args[0]; + int port = Integer.parseInt(args[1]); + + byte cmd[] = {'#','*',0x01,0x30,0x01, 0x01,'*','#'}; + + int response_len; + + if (args.length == 2) { + response_len = 19; + cmd[2] = (byte)(0x4F); + }else{ + int num = -1; + if (!"all".equals(args[2])) { + num = Integer.parseInt(args[2]); + } + + if (num < -1 || num > 15) { + System.err.println("hlk_sw16_cmd: invalid "); + return; + } + + boolean val = ("1".equals(args[3]) || "on".equals(args[3])); + + if (num == -1) { // all + response_len = 19; + cmd[2] = (byte)(val ? 0x1F : 0x1E); + } else { + response_len = 8; + cmd[2] = (byte)(0x30 + num); + cmd[3] = (byte)(val ? 0x30 : 0x20); + } + } + cmd[5] = (byte)(cmd[2] + cmd[3] + cmd[4]); + + System.out.println("hlk_sw16_cmd: connecting..."); + Socket socket = null; + try { + socket = new Socket(ip, port); + BufferedOutputStream out = new BufferedOutputStream(socket.getOutputStream()); + System.out.println("hlk_sw16_cmd: sending..."); + out.write(cmd); + out.flush(); + + System.out.println("hlk_sw16_cmd: reading..."); + BufferedInputStream in = new BufferedInputStream(socket.getInputStream()); + byte [] data = new byte[response_len]; + int len = in.read(data); + + if (len == 8) + { + if((data[0]=='a')&&(data[1]=='a')&&(data[6]=='b')&&(data[7]=='b')) { + System.out.println("Relay # " + (data[2] - 0x30) + ": " + (data[3] == 0x30 ? "ON" : (data[3] == 0x20 ? "OFF" : "UNKNOWN"))); + } else { + System.err.println("Error response [8]"); + } + } + else if (len == 19) + { + int sum = 0; + for (int i=1; i<=16; ++i) { + sum += data[i]; + } + if ((data[0]=='#') && (data[18]=='*') && ((sum & 0xFF) == data[17])) + { + for (int i=0; i<16; ++i) { + System.out.println("Relay # " + ((i+1) & 0xF) + ": " + (data[i+1] == 0x02 ? "ON" : (data[i+1] == 0x01 ? "OFF" : "UNKNOWN(0x"+Integer.toHexString(data[i+1])+")"))); + } + } else { + System.err.println("Error response [19]"); + } + } else { + System.err.println("Error response ["+len+"]"); + } + } catch (Exception e) { + e.printStackTrace(); + } finally { + if (socket != null) { + try { + socket.close(); + } catch (IOException e) { + e.printStackTrace(); + } + socket = null; + } + } + } +} diff --git a/hlk_sw16_old.jpg b/hlk_sw16_old.jpg new file mode 100644 index 0000000..5f715fd Binary files /dev/null and b/hlk_sw16_old.jpg differ diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..4f19d62 --- /dev/null +++ b/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "hlk_sw16_old", + "name": "Hi-Link HLK-SW16 (old)", + "documentation": "https://github.com/slydiman/hlk_sw16_old", + "requirements": [], + "codeowners": ["@jameshilliard", "@slydiman"], + "config_flow": true +} \ No newline at end of file diff --git a/protocol.py b/protocol.py new file mode 100644 index 0000000..ae79536 --- /dev/null +++ b/protocol.py @@ -0,0 +1,360 @@ +"""HLK-SW16 (old) Protocol Support.""" +import asyncio +from collections import deque +import logging +import codecs +import binascii + + +class SW16OldProtocol(asyncio.Protocol): + """HLK-SW16 (old) relay control protocol.""" + + transport = None # type: asyncio.Transport + + def __init__(self, client, disconnect_callback=None, loop=None, + logger=None): + """Initialize the HLK-SW16 (old) protocol.""" + self.client = client + self.loop = loop + self.logger = logger + self._buffer = b'' + self.disconnect_callback = disconnect_callback + self._timeout = None + self._cmd_timeout = None + self._keep_alive = None + + def connection_made(self, transport): + """Initialize protocol transport.""" + self.transport = transport + self._reset_timeout() + + def _send_keepalive_packet(self): + """Send a keep alive packet.""" + if not self.client.in_transaction: + packet = self.format_packet(b"O0\x01") + self.logger.debug('sending keep alive packet') + self.transport.write(packet) + + def _reset_timeout(self): + """Reset timeout for date keep alive.""" + if self._timeout: + self._timeout.cancel() + self._timeout = self.loop.call_later(self.client.timeout, + self.transport.close) + if self._keep_alive: + self._keep_alive.cancel() + self._keep_alive = self.loop.call_later( + self.client.keep_alive_interval, + self._send_keepalive_packet) + + def reset_cmd_timeout(self): + """Reset timeout for command execution.""" + if self._cmd_timeout: + self._cmd_timeout.cancel() + self._cmd_timeout = self.loop.call_later(self.client.timeout, + self.transport.close) + + def data_received(self, data): + """Add incoming data to buffer.""" + self._buffer += data + self._handle_lines() + + def _handle_lines(self): + """Assemble incoming data into per-line packets.""" + if self._buffer.startswith(b"aa"): + line = self._buffer[:8] + self._buffer = self._buffer[8:] + elif self._buffer.startswith(b"#"): + line = self._buffer[:19] + self._buffer = self._buffer[19:] + else: + line = self._buffer + self._buffer = b'' + if self._valid_packet(line): + self._handle_raw_packet(line) + else: + self.logger.warning('dropping invalid data: %s', binascii.hexlify(line)) + + @staticmethod + def _valid_packet(raw_packet): + """Validate incoming packet.""" + if len(raw_packet) == 8 and raw_packet.startswith(b"aa") and raw_packet.endswith(b"bb") and (raw_packet[3:4] == b'0' or raw_packet[3:4] == b' '): + return True + elif len(raw_packet) == 19 and raw_packet.startswith(b"#") and raw_packet.endswith(b"*"): + checksum = 0 + for x in raw_packet[1:17]: + checksum += x + if (checksum & 0xFF) == ord(raw_packet[17:18]): + return True + return False + + def _handle_raw_packet(self, raw_packet): + """Parse incoming packet.""" + if len(raw_packet) == 8: + self._reset_timeout() + changes = False + switch = ord(raw_packet[2:3]) - ord('0') + switchx = format(switch, 'x') + if raw_packet[3:4] == b'0': + state = True + if (self.client.states.get(switchx, None) + is not True): + changes = True + self.client.states[switchx] = True + elif raw_packet[3:4] == b' ': + state = False + if (self.client.states.get(switchx, None) + is not False): + changes = True + self.client.states[switchx] = False + else: + self.logger.warning('received unknown state: %s', binascii.hexlify(raw_packet)) + return + self.logger.debug('received [{}]={}, changes={}'.format(switch,state,changes)) + if changes: + for status_cb in self.client.status_callbacks.get(switchx, []): + status_cb(state) + if self.client.in_transaction: + self.client.in_transaction = False + self.client.active_packet = False + self.client.active_transaction.set_result(state) + while self.client.status_waiters: + waiter = self.client.status_waiters.popleft() + waiter.set_result(state) + if self.client.waiters: + self.send_packet() + else: + self._cmd_timeout.cancel() + elif self._cmd_timeout: + self._cmd_timeout.cancel() + elif len(raw_packet) == 19: + self._reset_timeout() + states = {} + changes = [] + for switch in range(16): + switchx = format(switch, 'x') + switch1 = (switch-1) & 0x0F + if raw_packet[1+switch1:2+switch1] == b'\x02': + states[switchx] = True + if (self.client.states.get(switchx, None) + is not True): + changes.append(switchx) + self.client.states[switchx] = True + elif raw_packet[1+switch1:2+switch1] == b'\x01': + states[format(switch, 'x')] = False + if (self.client.states.get(switchx, None) + is not False): + changes.append(switchx) + self.client.states[switchx] = False + else: + self.logger.warning('received unknown state: %s', binascii.hexlify(raw_packet)) + return + self.logger.debug('received: {}'.format(states)) + for switchx in changes: + for status_cb in self.client.status_callbacks.get(switchx, []): + status_cb(states[switchx]) + if self.client.in_transaction: + self.client.in_transaction = False + self.client.active_packet = False + self.client.active_transaction.set_result(states) + while self.client.status_waiters: + waiter = self.client.status_waiters.popleft() + waiter.set_result(states) + if self.client.waiters: + self.send_packet() + else: + self._cmd_timeout.cancel() + elif self._cmd_timeout: + self._cmd_timeout.cancel() + else: + self.logger.warning('received unknown packet: %s', + binascii.hexlify(raw_packet)) + + def send_packet(self): + """Write next packet in send queue.""" + waiter, packet = self.client.waiters.popleft() + self.logger.debug('sending packet: %s', binascii.hexlify(packet)) + self.client.active_transaction = waiter + self.client.in_transaction = True + self.client.active_packet = packet + self.reset_cmd_timeout() + self.transport.write(packet) + + @staticmethod + def format_packet(command): + """Format packet to be sent.""" + frame_header = b"#*" + verify = bytes([(command[0] + command[1] + command[2]) & 0xFF]) + send_delim = b"*#" + return frame_header + command + verify + send_delim + + def connection_lost(self, exc): + """Log when connection is closed, if needed call callback.""" + if exc: + self.logger.error('disconnected due to error') + else: + self.logger.info('disconnected because of close/abort.') + if self._keep_alive: + self._keep_alive.cancel() + if self.disconnect_callback: + asyncio.ensure_future(self.disconnect_callback(), loop=self.loop) + + +class SW16OldClient: + """HLK-SW16 client wrapper class.""" + + def __init__(self, host, port=8080, + disconnect_callback=None, reconnect_callback=None, + loop=None, logger=None, timeout=10, reconnect_interval=10, + keep_alive_interval=3): + """Initialize the HLK-SW16 client wrapper.""" + if loop: + self.loop = loop + else: + self.loop = asyncio.get_event_loop() + if logger: + self.logger = logger + else: + self.logger = logging.getLogger(__name__) + self.host = host + self.port = port + self.transport = None + self.protocol = None + self.is_connected = False + self.reconnect = True + self.timeout = timeout + self.reconnect_interval = reconnect_interval + self.keep_alive_interval = keep_alive_interval + self.disconnect_callback = disconnect_callback + self.reconnect_callback = reconnect_callback + self.waiters = deque() + self.status_waiters = deque() + self.in_transaction = False + self.active_transaction = None + self.active_packet = None + self.status_callbacks = {} + self.states = {} + + async def setup(self): + """Set up the connection with automatic retry.""" + while True: + fut = self.loop.create_connection( + lambda: SW16OldProtocol( + self, + disconnect_callback=self.handle_disconnect_callback, + loop=self.loop, logger=self.logger), + host=self.host, + port=self.port) + try: + self.transport, self.protocol = \ + await asyncio.wait_for(fut, timeout=self.timeout) + except asyncio.TimeoutError: + self.logger.warning("Could not connect due to timeout error.") + except OSError as exc: + self.logger.warning("Could not connect due to error: %s", + str(exc)) + else: + self.is_connected = True + if self.reconnect_callback: + self.reconnect_callback() + break + await asyncio.sleep(self.reconnect_interval) + + def stop(self): + """Shut down transport.""" + self.reconnect = False + self.logger.debug("Shutting down.") + if self.transport: + self.transport.close() + + async def handle_disconnect_callback(self): + """Reconnect automatically unless stopping.""" + self.is_connected = False + if self.disconnect_callback: + self.disconnect_callback() + if self.reconnect: + self.logger.debug("Protocol disconnected...reconnecting") + await self.setup() + self.protocol.reset_cmd_timeout() + if self.in_transaction: + self.protocol.transport.write(self.active_packet) + else: + packet = self.protocol.format_packet(b"O0\x01") + self.protocol.transport.write(packet) + + def register_status_callback(self, callback, switch): + """Register a callback which will fire when state changes.""" + if self.status_callbacks.get(switch, None) is None: + self.status_callbacks[switch] = [] + self.status_callbacks[switch].append(callback) + + def _send(self, packet): + """Add packet to send queue.""" + fut = self.loop.create_future() + self.waiters.append((fut, packet)) + if self.waiters and self.in_transaction is False: + self.protocol.send_packet() + return fut + + async def turn_on(self, switch=None): + """Turn on relay.""" + if switch is not None: + switch = ord(codecs.decode(switch.rjust(2, '0'), 'hex')) + packet = self.protocol.format_packet(bytes([ord('0') + switch]) + b"0" + b"\x01") + else: + packet = self.protocol.format_packet(b"\x1F0\x01") + states = await self._send(packet) + return states + + async def turn_off(self, switch=None): + """Turn off relay.""" + if switch is not None: + switch = ord(codecs.decode(switch.rjust(2, '0'), 'hex')) + packet = self.protocol.format_packet(bytes([ord('0') + switch]) + b" " + b"\x01") + else: + packet = self.protocol.format_packet(b"\x1E0\x01") + states = await self._send(packet) + return states + + async def status(self, switch=None): + """Get current relay status.""" + # TODO: FIXME + if switch is not None: + self.logger.debug("status({})...".format(switch)) + if self.waiters or self.in_transaction: + fut = self.loop.create_future() + self.status_waiters.append(fut) + states = await fut + state = states[switch] + else: + packet = self.protocol.format_packet(b"O0\x01") + states = await self._send(packet) + state = states[switch] + else: + self.logger.debug("status(None)...") + if self.waiters or self.in_transaction: + fut = self.loop.create_future() + self.status_waiters.append(fut) + state = await fut + else: + packet = self.protocol.format_packet(b"O0\x01") + state = await self._send(packet) + return state + + +async def create_hlk_sw16_old_connection(port=None, host=None, + disconnect_callback=None, + reconnect_callback=None, loop=None, + logger=None, timeout=None, + reconnect_interval=None, + keep_alive_interval=None): + """Create HLK-SW16 (old) Client class.""" + client = SW16OldClient(host, port=port, + disconnect_callback=disconnect_callback, + reconnect_callback=reconnect_callback, + loop=loop, logger=logger, + timeout=timeout, reconnect_interval=reconnect_interval, + keep_alive_interval=keep_alive_interval) + await client.setup() + + return client diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..513ca26 --- /dev/null +++ b/readme.md @@ -0,0 +1,10 @@ +This is a fork of hlk_sw16 [https://github.com/home-assistant/core/tree/dev/homeassistant/components/hlk_sw16] ans [https://github.com/jameshilliard/hlk-sw16], created by me to support the early version of HLK-SW16 without RTC clock. +The relay control protocol is little bit different. + +![](hlk_sw16_old.jpg) + +Thanks to @jameshilliard for the initial code. + +## Install + +Download hlk_sw16.zip, extract and copy `hlk_sw16` folder to `custom_components` folder in your config folder. diff --git a/strings.json b/strings.json new file mode 100644 index 0000000..2480ac6 --- /dev/null +++ b/strings.json @@ -0,0 +1,21 @@ +{ + "config": { + "step": { + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + } +} \ No newline at end of file diff --git a/switch.py b/switch.py new file mode 100644 index 0000000..625aba2 --- /dev/null +++ b/switch.py @@ -0,0 +1,40 @@ +"""Support for HLK-SW16 (old) switches.""" +from homeassistant.components.switch import ToggleEntity + +from . import DATA_DEVICE_REGISTER, SW16OldDevice +from .const import DOMAIN + +PARALLEL_UPDATES = 0 + + +def devices_from_entities(hass, entry): + """Parse configuration and add HLK-SW16 (old) switch devices.""" + device_client = hass.data[DOMAIN][entry.entry_id][DATA_DEVICE_REGISTER] + devices = [] + for i in range(16): + device_relay = f"{i:01x}" + device = SW16OldSwitch(device_relay, entry.entry_id, device_client) + devices.append(device) + return devices + + +async def async_setup_entry(hass, entry, async_add_entities): + """Set up the HLK-SW16 platform.""" + async_add_entities(devices_from_entities(hass, entry)) + + +class SW16OldSwitch(SW16OldDevice, ToggleEntity): + """Representation of a HLK-SW16 (old) switch.""" + + @property + def is_on(self): + """Return true if device is on.""" + return self._is_on + + async def async_turn_on(self, **kwargs): + """Turn the device on.""" + await self._client.turn_on(self._device_relay) + + async def async_turn_off(self, **kwargs): + """Turn the device off.""" + await self._client.turn_off(self._device_relay) diff --git a/translations/ca.json b/translations/ca.json new file mode 100644 index 0000000..df8218b --- /dev/null +++ b/translations/ca.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "El dispositiu ja est\u00e0 configurat" + }, + "error": { + "cannot_connect": "Ha fallat la connexi\u00f3", + "invalid_auth": "Autenticaci\u00f3 inv\u00e0lida", + "unknown": "Error inesperat" + }, + "step": { + "user": { + "data": { + "host": "Amfitri\u00f3", + "password": "Contrasenya", + "username": "Nom d'usuari" + } + } + } + } +} \ No newline at end of file diff --git a/translations/cs.json b/translations/cs.json new file mode 100644 index 0000000..1801c0d --- /dev/null +++ b/translations/cs.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Za\u0159\u00edzen\u00ed ji\u017e je nastaveno" + }, + "error": { + "cannot_connect": "Nelze se p\u0159ipojit", + "invalid_auth": "Neplatn\u00e9 ov\u011b\u0159en\u00ed", + "unknown": "Neo\u010dek\u00e1van\u00e1 chyba" + }, + "step": { + "user": { + "data": { + "host": "Hostitel", + "password": "Heslo", + "username": "U\u017eivatelsk\u00e9 jm\u00e9no" + } + } + } + } +} \ No newline at end of file diff --git a/translations/de.json b/translations/de.json new file mode 100644 index 0000000..6f39806 --- /dev/null +++ b/translations/de.json @@ -0,0 +1,12 @@ +{ + "config": { + "step": { + "user": { + "data": { + "password": "Passwort", + "username": "Benutzername" + } + } + } + } +} \ No newline at end of file diff --git a/translations/en.json b/translations/en.json new file mode 100644 index 0000000..f15fe84 --- /dev/null +++ b/translations/en.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Device is already configured" + }, + "error": { + "cannot_connect": "Failed to connect", + "invalid_auth": "Invalid authentication", + "unknown": "Unexpected error" + }, + "step": { + "user": { + "data": { + "host": "Host", + "password": "Password", + "username": "Username" + } + } + } + } +} \ No newline at end of file diff --git a/translations/es.json b/translations/es.json new file mode 100644 index 0000000..2609ee0 --- /dev/null +++ b/translations/es.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "El dispositivo ya est\u00e1 configurado" + }, + "error": { + "cannot_connect": "No se pudo conectar", + "invalid_auth": "Autenticaci\u00f3n no v\u00e1lida", + "unknown": "Error inesperado" + }, + "step": { + "user": { + "data": { + "host": "Host", + "password": "Contrase\u00f1a", + "username": "Usuario" + } + } + } + } +} \ No newline at end of file diff --git a/translations/et.json b/translations/et.json new file mode 100644 index 0000000..7898215 --- /dev/null +++ b/translations/et.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Seade on juba h\u00e4\u00e4lestatud" + }, + "error": { + "cannot_connect": "\u00dchendamine nurjus", + "invalid_auth": "Tuvastamine nurjus", + "unknown": "Tundmatu viga" + }, + "step": { + "user": { + "data": { + "host": "", + "password": "Salas\u00f5na", + "username": "Kasutajanimi" + } + } + } + } +} \ No newline at end of file diff --git a/translations/fr.json b/translations/fr.json new file mode 100644 index 0000000..45620fe --- /dev/null +++ b/translations/fr.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9" + }, + "error": { + "cannot_connect": "\u00c9chec de connexion", + "invalid_auth": "Authentification invalide", + "unknown": "Erreur inattendue" + }, + "step": { + "user": { + "data": { + "host": "H\u00f4te", + "password": "Mot de passe", + "username": "Nom d'utilisateur" + } + } + } + } +} \ No newline at end of file diff --git a/translations/hu.json b/translations/hu.json new file mode 100644 index 0000000..3b2d79a --- /dev/null +++ b/translations/hu.json @@ -0,0 +1,7 @@ +{ + "config": { + "abort": { + "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van" + } + } +} \ No newline at end of file diff --git a/translations/it.json b/translations/it.json new file mode 100644 index 0000000..e935648 --- /dev/null +++ b/translations/it.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Il dispositivo \u00e8 gi\u00e0 configurato" + }, + "error": { + "cannot_connect": "Impossibile connettersi", + "invalid_auth": "Autenticazione non valida", + "unknown": "Errore imprevisto" + }, + "step": { + "user": { + "data": { + "host": "Host", + "password": "Password", + "username": "Nome utente" + } + } + } + } +} \ No newline at end of file diff --git a/translations/lb.json b/translations/lb.json new file mode 100644 index 0000000..e235a2f --- /dev/null +++ b/translations/lb.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Apparat ass scho konfigur\u00e9iert" + }, + "error": { + "cannot_connect": "Feeler beim verbannen", + "invalid_auth": "Ong\u00eblteg Authentifikatioun", + "unknown": "Onerwaarte Feeler" + }, + "step": { + "user": { + "data": { + "host": "Host", + "password": "Passwuert", + "username": "Benotzernumm" + } + } + } + } +} \ No newline at end of file diff --git a/translations/nl.json b/translations/nl.json new file mode 100644 index 0000000..0569c39 --- /dev/null +++ b/translations/nl.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "Apparaat is al geconfigureerd" + }, + "error": { + "invalid_auth": "Ongeldige authenticatie", + "unknown": "Onverwachte fout" + }, + "step": { + "user": { + "data": { + "host": "Host", + "password": "Wachtwoord", + "username": "Gebruikersnaam" + } + } + } + } +} \ No newline at end of file diff --git a/translations/no.json b/translations/no.json new file mode 100644 index 0000000..249711b --- /dev/null +++ b/translations/no.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Enheten er allerede konfigurert" + }, + "error": { + "cannot_connect": "Tilkobling mislyktes", + "invalid_auth": "Ugyldig godkjenning", + "unknown": "Uventet feil" + }, + "step": { + "user": { + "data": { + "host": "Vert", + "password": "Passord", + "username": "Brukernavn" + } + } + } + } +} \ No newline at end of file diff --git a/translations/pl.json b/translations/pl.json new file mode 100644 index 0000000..25dab56 --- /dev/null +++ b/translations/pl.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Urz\u0105dzenie jest ju\u017c skonfigurowane" + }, + "error": { + "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia", + "invalid_auth": "Niepoprawne uwierzytelnienie", + "unknown": "Nieoczekiwany b\u0142\u0105d" + }, + "step": { + "user": { + "data": { + "host": "Nazwa hosta lub adres IP", + "password": "Has\u0142o", + "username": "Nazwa u\u017cytkownika" + } + } + } + } +} \ No newline at end of file diff --git a/translations/pt.json b/translations/pt.json new file mode 100644 index 0000000..561c8d7 --- /dev/null +++ b/translations/pt.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "O dispositivo j\u00e1 est\u00e1 configurado" + }, + "error": { + "cannot_connect": "Falha na liga\u00e7\u00e3o", + "invalid_auth": "Autentica\u00e7\u00e3o inv\u00e1lida", + "unknown": "Erro inesperado" + }, + "step": { + "user": { + "data": { + "host": "Servidor", + "password": "Palavra-passe", + "username": "Nome de Utilizador" + } + } + } + } +} \ No newline at end of file diff --git a/translations/ru.json b/translations/ru.json new file mode 100644 index 0000000..6f71ee4 --- /dev/null +++ b/translations/ru.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "\u042d\u0442\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0443\u0436\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u043e \u0432 Home Assistant." + }, + "error": { + "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f.", + "invalid_auth": "\u041d\u0435\u0432\u0435\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f.", + "unknown": "\u041d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430." + }, + "step": { + "user": { + "data": { + "host": "\u0425\u043e\u0441\u0442", + "password": "\u041f\u0430\u0440\u043e\u043b\u044c", + "username": "\u041b\u043e\u0433\u0438\u043d" + } + } + } + } +} \ No newline at end of file diff --git a/translations/zh-Hans.json b/translations/zh-Hans.json new file mode 100644 index 0000000..a5f4ff1 --- /dev/null +++ b/translations/zh-Hans.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "username": "\u7528\u6237\u540d" + } + } + } + } +} \ No newline at end of file diff --git a/translations/zh-Hant.json b/translations/zh-Hant.json new file mode 100644 index 0000000..cad7d73 --- /dev/null +++ b/translations/zh-Hant.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "\u88dd\u7f6e\u7d93\u8a2d\u5b9a\u5b8c\u6210" + }, + "error": { + "cannot_connect": "\u9023\u7dda\u5931\u6557", + "invalid_auth": "\u9a57\u8b49\u78bc\u7121\u6548", + "unknown": "\u672a\u9810\u671f\u932f\u8aa4" + }, + "step": { + "user": { + "data": { + "host": "\u4e3b\u6a5f\u7aef", + "password": "\u5bc6\u78bc", + "username": "\u4f7f\u7528\u8005\u540d\u7a31" + } + } + } + } +} \ No newline at end of file