import logging import serial import threading import asyncio import queue from typing import Optional, Dict from itertools import chain from enum import Enum _LOGGER = logging.getLogger(__name__) class Model(Enum): UNKNOWN = 0 LITEJET = 1 LITEJET_48 = 2 def __str__(self): if self is Model.LITEJET: return "LiteJet" if self is Model.LITEJET_48: return "LiteJet 48" return self.name class LiteJetError(Exception): pass class LiteJetTimeout(LiteJetError): pass class AsyncSerialAdapter: def __init__(self, url: str): self._url = url self._loop = asyncio.get_running_loop() self._thread_lock = threading.Lock() self._serial = None self.connected_changed = None def _connected_changed(self, connected: bool, reason: str): asyncio.run_coroutine_threadsafe( self.connected_changed(connected, reason), self._loop ) def _ensure_connection(self): with self._thread_lock: serial_instance = self._serial if serial_instance is not None and not serial_instance.is_open: serial_instance = self._serial = None self._loop.call_soon_threadsafe(self._connected_changed, False, None) if serial_instance is None: _LOGGER.debug("Connecting to %s", self._url) try: serial_instance = serial.serial_for_url( self._url, baudrate=19200, parity=serial.PARITY_NONE, stopbits=serial.STOPBITS_ONE, ) except serial.SerialException as exc: raise LiteJetError(str(exc)) from exc self._serial = serial_instance _LOGGER.debug("Connected to %s", self._url) self._loop.call_soon_threadsafe(self._connected_changed, True, None) return serial_instance async def read(self) -> bytes: return await self._loop.run_in_executor(None, self._read) def _read(self) -> bytes: serial_instance = self._ensure_connection() try: return self._serial.read_until(expected=b"\r") except serial.SerialException as exc: self._close(f"due to exception: {exc}") raise LiteJetError() from exc async def write(self, data: bytes): await self._loop.run_in_executor(None, self._write, data) def _write(self, data: bytes): serial_instance = self._ensure_connection() try: serial_instance.write(data) except serial.SerialException as exc: self._close(f"due to exception: {exc}") raise LiteJetError() from exc async def open(self): await self._loop.run_in_executor(None, self._open) def _open(self): self._ensure_connection() async def close(self, reason="by request"): await self._loop.run_in_executor(None, self._close, reason) def _close(self, reason): with self._thread_lock: if self._serial is not None: _LOGGER.info("Disconnecting %s", reason) self._serial.close() self._serial = None self._loop.call_soon_threadsafe(self._connected_changed, False, reason) @property def connected(self): return self._serial is not None class LiteJet: FIRST_LOAD = 1 LAST_LOAD = 40 FIRST_LOAD_RELAY = 1 LAST_LOAD_RELAY = 24 FIRST_LOAD_FAN = 25 LAST_LOAD_FAN = 28 FIRST_LOAD_LVRB = 29 LAST_LOAD_LVRB = 40 FIRST_SCENE = 1 LAST_SCENE = 41 FIRST_SWITCH = 1 LAST_BUTTON_SWITCH = 96 LAST_SWITCH = 138 KEYPAD_COUNT = 16 RELAY_RATE_SECONDS = [ 0, 1, 2, 3, 4, 5, 6, 7, 9, 11, 13, 16, 19, 23, 28, 34, 41, 49, 60, 75, 90, 110, 140, 175, 210, 250, 300, 380, 450, 550, 675, 800, ] FAN_RATE_SECONDS = [0] LVRB_RATE_SECONDS = [ 0, 0.25, 0.50, 0.75, 1.00, 1.50, 2.00, 2.50, 3, 4, 5, 6, 7, 8, 10, 12, 14, 16, 18, 20, 25, 30, 45, 60, 90, 120, 300, 600, 900, 1200, 1800, 2700, ] _serial: serial.Serial _adapter: AsyncSerialAdapter _reader_task: asyncio.Task _start: str def __init__(self): self._events = {} self._command_lock = asyncio.Lock() self._recv_event = asyncio.Event() self._recv_line = None self._reader_active = False self._open = False self.connected = False self.board_count = 1 self.model = Model.UNKNOWN @property def model_name(self): return str(self.model) async def open(self, url: str): self._adapter = AsyncSerialAdapter(url) self._adapter.connected_changed = self._connected_changed await self._adapter.open() try: self._reader_active = True self._reader_task = asyncio.create_task(self._reader_impl()) # Auto detect which start symbol the MCP expects. self._start = "^" try: await self.get_all_load_states() except LiteJetTimeout: _LOGGER.info("No response to '^'. Trying '+'...") self._start = "+" try: await self.get_all_load_states() except LiteJetTimeout: raise LiteJetError( "No response to '+' or '^' command. No LiteJet MCP connected?" ) # Auto detect if this is a dual MCP. is_dual = False try: await self._sendrecv(f"{self._start}g") # A single MCP can respond as both the "other" and "this" # board so also check the first load's names are different. my_name = await self._sendrecv(f"{self._start}L001") other_name = await self._sendrecv(f"{self._start}l001") is_dual = my_name != other_name except LiteJetTimeout: pass if is_dual: self.model = Model.LITEJET_48 self.board_count = 2 else: self.model = Model.LITEJET self._open = True await self._connected_changed(True, None) except: await self._adapter.close() raise async def _reader_impl(self): while self._reader_active: try: line = await self._adapter.read() except Exception as exc: _LOGGER.debug(f"Read error: {exc}") await asyncio.sleep(5) continue try: line = line[0:-1].decode("utf-8") _LOGGER.debug(f'Read "{line}" ({len(line)})') if len(line) == 4 and (line[0] == "P" or line[0] == "R"): # Switch press/release event self._notify_event(line) elif len(line) == 4 and (line[0] == "F" or line[0] == "N"): # Load on/off event self._notify_event(line, None) elif len(line) == 7 and line[0] == "^" and line[1] == "K": # Dimming event new_level = line[5:7] event_name = "F" if new_level == "00" else "N" self._notify_event(event_name + line[2:5], int(new_level)) else: # Possible command response self._recv_line = line self._recv_event.set() except Exception as exc: _LOGGER.error(f"While handling '{line}': {exc}") async def close(self): self._open = False self._reader_active = False await self._adapter.close() self._reader_task.cancel() async def _send(self, command: str): _LOGGER.debug('WantToSend "%s"', command) async with self._command_lock: _LOGGER.debug('Send "%s"', command) await self._adapter.write(bytes(f"{command}\n", "utf-8")) async def _sendrecv(self, command: str): _LOGGER.debug('WantToSendRecv "%s"', command) async with self._command_lock: self._recv_event.clear() _LOGGER.debug('SendRecv(S) "%s"', command) await self._adapter.write(bytes(f"{command}\n", "utf-8")) _LOGGER.debug("SendRecv(W)") try: await asyncio.wait_for(self._recv_event.wait(), timeout=1) except asyncio.exceptions.TimeoutError as exc: raise LiteJetTimeout() from exc result = self._recv_line _LOGGER.debug('SendRecv(R) "%s"', result) return result def _add_event(self, event_name, handler): event_list = self._events.get(event_name, None) if event_list is None: event_list = [] self._events[event_name] = event_list event_list.append(handler) def _notify_event(self, event_name, *args): _LOGGER.debug('Event "%s"', event_name) event_list = self._events.get(event_name, None) if event_list is not None: for handler in event_list: handler(*args) def unsubscribe(self, handler): for event_name, event_list in self._events.items(): event_list[:] = [x for x in event_list if x != handler] def _hex2bits(self, response: str, input_first: int, input_last: int, output_first: int, output: Dict[int, bool]): output_number = output_first for digit in range(input_first, input_last, 2): digit_value = int(response[digit : digit + 2], 16) for bit in range(0, 8): bit_value = (digit_value & (1 << bit)) != 0 output[output_number] = bit_value output_number += 1 return output def _seconds2rate(self, seconds, table): for candidate_rate, candidate_seconds in enumerate(table): if seconds <= candidate_seconds: return candidate_rate return len(table) - 1 async def _connected_changed(self, connected: bool, reason: str): if not self._open: connected = False if connected: try: await self.get_all_load_states() except: connected = False reason = "due to non-responsive MCP" await self._adapter.close(reason) if connected != self.connected: self.connected = connected self._notify_event("CONN", connected, reason) def on_connected_changed(self, handler): self._add_event("CONN", handler) def on_load_activated(self, index: int, handler): self._add_event(f"N{index:03d}", handler) def on_load_deactivated(self, index: int, handler): self._add_event(f"F{index:03d}", handler) def on_switch_pressed(self, index: int, handler): self._add_event(f"P{index:03d}", handler) def on_switch_released(self, index: int, handler): self._add_event(f"R{index:03d}", handler) def _command(self, command: str, index: Optional[int] = None, last_index: Optional[int] = None): # If index exceeds this MCP's range, send it to the other MCP. if index is not None and index > last_index: command = command.lower() index -= last_index if index is None: return f"{self._start}{command}" return f"{self._start}{command}{index:03d}" async def activate_load(self, index: int): await self._send(self._command("A", index, LiteJet.LAST_LOAD)) async def deactivate_load(self, index: int): await self._send(self._command("B", index, LiteJet.LAST_LOAD)) async def activate_scene(self, index: int): await self._send(self._command("C", index, LiteJet.LAST_SCENE)) async def deactivate_scene(self, index: int): await self._send(self._command("D", index, LiteJet.LAST_SCENE)) async def activate_load_at(self, index: int, level: int, rate_seconds: int): if index >= LiteJet.FIRST_LOAD_RELAY and index <= LiteJet.LAST_LOAD_RELAY: table = LiteJet.RELAY_RATE_SECONDS elif index >= LiteJet.FIRST_LOAD_LVRB and index <= LiteJet.LAST_LOAD_LVRB: table = LiteJet.LVRB_RATE_SECONDS else: table = LiteJet.FAN_RATE_SECONDS rate = self._seconds2rate(rate_seconds, table) command = self._command("E", index, LiteJet.LAST_LOAD) await self._send(f"{command}{level:02d}{rate:02d}") async def get_load_level(self, index: int) -> int: return int(await self._sendrecv(self._command("F", index, LiteJet.LAST_LOAD))) # ^G: Get instant on/off status of all loads on this board # ^H: Get instant on/off status of all switches on this board. async def get_all_load_states(self): bits = {} response = await self._sendrecv(self._command("G")) self._hex2bits(response, 0, 11, LiteJet.FIRST_LOAD, bits) if self.board_count > 1: response = await self._sendrecv(self._command("g")) self._hex2bits(response, 0, 11, LiteJet.LAST_LOAD + 1, bits) return bits async def get_all_switch_states(self): bits = {} response = await self._sendrecv(self._command("H")) self._hex2bits(response, 0, 39, LiteJet.FIRST_SWITCH, bits) if self.board_count > 1: response = await self._sendrecv(self._command("h")) self._hex2bits(response, 0, 39, LiteJet.LAST_SWITCH + 1, bits) return bits async def press_switch(self, index: int): await self._send(self._command("I", index, LiteJet.LAST_SWITCH)) async def release_switch(self, index: int): await self._send(self._command("J", index, LiteJet.LAST_SWITCH)) async def get_switch_name(self, index: int): return (await self._sendrecv(self._command("K", index, LiteJet.LAST_SWITCH))).strip() async def get_load_name(self, index: int): return (await self._sendrecv(self._command("L", index, LiteJet.LAST_LOAD))).strip() async def get_scene_name(self, index: int): return (await self._sendrecv(self._command("M", index, LiteJet.LAST_SCENE))).strip() def loads(self): return range(LiteJet.FIRST_LOAD, LiteJet.LAST_LOAD * self.board_count + 1) def button_switches(self): result = range(LiteJet.FIRST_SWITCH, LiteJet.LAST_BUTTON_SWITCH + 1) if self.board_count > 1: result = chain(result, range(LiteJet.LAST_SWITCH + 1, LiteJet.LAST_SWITCH + LiteJet.LAST_BUTTON_SWITCH + 1)) return result def all_switches(self): return range(LiteJet.FIRST_SWITCH, LiteJet.LAST_SWITCH * self.board_count + 1) def scenes(self): return range(LiteJet.FIRST_SCENE, LiteJet.LAST_SCENE + 1) def get_switch_keypad_number(self, index: int): for board in range(0, self.board_count): if index < LiteJet.FIRST_SWITCH: return None if index <= LiteJet.LAST_BUTTON_SWITCH: # Keypad #1 has switches 1-6, #2 has 7-12, ... return (board * LiteJet.KEYPAD_COUNT) + (int((index - 1) / 6) + 1) if index <= LiteJet.LAST_SWITCH: # Touch Panel Programmer return 0 index -= LiteJet.LAST_SWITCH def get_switch_keypad_name(self, index: int): keypad_number = self.get_switch_keypad_number(index) if keypad_number == 0: return "Touch Panel Programmer" return f"Keypad #{keypad_number}" async def open(url): lj = LiteJet() await lj.open(url) return lj