pax_global_header00006660000000000000000000000064136116540770014524gustar00rootroot0000000000000052 comment=5000485d0f513dccb3a3939da65e30fb93638038 jameshilliard-hlk-sw16-69c590e/000077500000000000000000000000001361165407700162545ustar00rootroot00000000000000jameshilliard-hlk-sw16-69c590e/.gitignore000066400000000000000000000000431361165407700202410ustar00rootroot00000000000000*.pyc *.egg-info .tox dist/ build/ jameshilliard-hlk-sw16-69c590e/.travis.yml000066400000000000000000000010021361165407700203560ustar00rootroot00000000000000sudo: false matrix: fast_finish: true include: - python: "3.5.3" env: TOXENV=lint - python: "3.5.3" env: TOXENV=pylint - python: "3.6" env: TOXENV=py36 - python: "3.7" env: TOXENV=py37 dist: xenial sudo: yes - python: "3.8-dev" env: TOXENV=py38 dist: xenial sudo: yes allow_failures: - python: "3.8-dev" env: TOXENV=py38 dist: xenial sudo: yes install: pip install -U tox language: python script: tox --develop jameshilliard-hlk-sw16-69c590e/LICENSE000066400000000000000000000020571361165407700172650ustar00rootroot00000000000000MIT License Copyright (c) 2018 James Hilliard Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. jameshilliard-hlk-sw16-69c590e/hlk_sw16/000077500000000000000000000000001361165407700177125ustar00rootroot00000000000000jameshilliard-hlk-sw16-69c590e/hlk_sw16/__init__.py000066400000000000000000000001411361165407700220170ustar00rootroot00000000000000"""HLK-SW16 Protocol Support.""" from hlk_sw16.protocol import create_hlk_sw16_connection # noqa jameshilliard-hlk-sw16-69c590e/hlk_sw16/protocol.py000066400000000000000000000313731361165407700221340ustar00rootroot00000000000000"""HLK-SW16 Protocol Support.""" import asyncio from collections import deque import logging import codecs import binascii class SW16Protocol(asyncio.Protocol): """HLK-SW16 relay control protocol.""" transport = None # type: asyncio.Transport def __init__(self, client, disconnect_callback=None, loop=None, logger=None): """Initialize the HLK-SW16 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"\x12") 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.""" while b'\xdd' in self._buffer: linebuf, self._buffer = self._buffer.rsplit(b'\xdd', 1) line = linebuf[-19:] self._buffer += linebuf[:-19] 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 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): """Parse incoming packet.""" if raw_packet[1:2] == b'\x1f': self._reset_timeout() 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] self.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'\x0e': self._reset_timeout() sec = raw_packet[2] minute = raw_packet[3] hour = raw_packet[4] day = raw_packet[5] month = raw_packet[6] week = raw_packet[7] year = raw_packet[8] self.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': self._reset_timeout() states = {} changes = [] for switch in range(0, 16): if raw_packet[2+switch:3+switch] == b'\x01': states[format(switch, 'x')] = True if (self.client.states.get(format(switch, 'x'), None) is not True): changes.append(format(switch, 'x')) self.client.states[format(switch, 'x')] = True elif raw_packet[2+switch:3+switch] == b'\x02': states[format(switch, 'x')] = False if (self.client.states.get(format(switch, 'x'), None) is not False): changes.append(format(switch, 'x')) self.client.states[format(switch, 'x')] = False for switch in changes: for status_cb in self.client.status_callbacks.get(switch, []): status_cb(states[switch]) self.logger.debug(states) 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"\xaa" verify = b"\x0b" send_delim = b"\xbb" return frame_header + command.ljust(17, b"\x00") + 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 SW16Client: """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: SW16Protocol( 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"\x1e") 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 = codecs.decode(switch.rjust(2, '0'), 'hex') packet = self.protocol.format_packet(b"\x10" + switch + b"\x01") else: packet = self.protocol.format_packet(b"\x0a") states = await self._send(packet) return states async def turn_off(self, switch=None): """Turn off relay.""" if switch is not None: switch = codecs.decode(switch.rjust(2, '0'), 'hex') packet = self.protocol.format_packet(b"\x10" + switch + b"\x02") else: packet = self.protocol.format_packet(b"\x0b") states = await self._send(packet) return states async def status(self, switch=None): """Get current relay status.""" if switch is not None: 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"\x1e") states = await self._send(packet) state = states[switch] else: 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"\x1e") state = await self._send(packet) return state async def create_hlk_sw16_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 Client class.""" client = SW16Client(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 jameshilliard-hlk-sw16-69c590e/pylintrc000066400000000000000000000025221361165407700200440ustar00rootroot00000000000000[MESSAGES CONTROL] # Reasons disabled: # locally-disabled - it spams too much # duplicate-code - unavoidable # cyclic-import - doesn't test if both import on load # abstract-class-little-used - prevents from setting right foundation # unused-argument - generic callbacks and setup methods create a lot of warnings # global-statement - used for the on-demand requirement installation # redefined-variable-type - this is Python, we're duck typing! # too-many-* - are not enforced for the sake of readability # too-few-* - same as too-many-* # abstract-method - with intro of async there are always methods missing # inconsistent-return-statements - doesn't handle raise # not-an-iterable - https://github.com/PyCQA/pylint/issues/2311 disable= abstract-class-little-used, abstract-method, cyclic-import, duplicate-code, global-statement, inconsistent-return-statements, locally-disabled, not-an-iterable, not-context-manager, redefined-variable-type, too-few-public-methods, too-many-arguments, too-many-branches, too-many-instance-attributes, too-many-lines, too-many-locals, too-many-public-methods, too-many-return-statements, too-many-statements, unused-argument [REPORTS] reports=no [TYPECHECK] # For attrs ignored-classes=_CountingAttr generated-members=botocore.errorfactory [FORMAT] expected-line-ending-format=LF jameshilliard-hlk-sw16-69c590e/setup.py000066400000000000000000000006071361165407700177710ustar00rootroot00000000000000#!/usr/bin/env python from setuptools import setup setup( name='hlk-sw16', version='0.0.8', description='Python client for HLK-SW16', url='https://github.com/jameshilliard/hlk-sw16', author='James Hilliard', author_email='james.hilliard1@gmail.com', license='MIT', packages=[ 'hlk_sw16', ], ) jameshilliard-hlk-sw16-69c590e/tox.ini000066400000000000000000000006111361165407700175650ustar00rootroot00000000000000[tox] envlist = py35, py36, py37, py38, lint, pylint skip_missing_interpreters = True [testenv:pylint] basepython = {env:PYTHON3_PATH:python3} ignore_errors = True deps = pylint commands = pylint {posargs} hlk_sw16 [testenv:lint] basepython = {env:PYTHON3_PATH:python3} deps = flake8 pydocstyle commands = flake8 {posargs} pydocstyle {posargs:hlk_sw16}