""" ElkM1 message encode/decode. Message format: LLMM00CC, where: LL - length in ASCII hex MM - message code - message contents; length varies by message code 00 - constant two characters reserved (almost all messages) CC - checksum The panel numbers zones, keypads, etc. in base 1. The interface of this module refers to them using base 0. Conversion to base 1 is done on sending and conversion to base 0 is done on receiving. So, base 0/1 conversion is encapsulated in this module. """ from __future__ import annotations import datetime as dt import re import sys import time from collections import namedtuple from collections.abc import Callable from typing import Any, cast from .const import ( AlarmState, ArmedStatus, ArmLevel, ArmUpState, ChimeMode, ElkRPStatus, FunctionKeys, Max, SettingFormat, ThermostatFan, ThermostatMode, ThermostatSetting, ZoneAlarmState, ZoneLogicalStatus, ZonePhysicalStatus, ZoneType, ) MessageEncode = namedtuple("MessageEncode", ["message", "response_command"]) MsgHandler = Callable[..., None] def decode(msg: str) -> tuple[str, dict[str, Any]] | None: """Decode an Elk message by passing to appropriate decoder""" valid, error_msg = _is_valid_length_and_checksum(msg) if valid: cmd = msg[2:4] decoder = getattr(sys.modules[__name__], f"{cmd.lower()}_decode", None) if not decoder: return ("unknown", {"msg_code": cmd, "data": msg[4:-2]}) try: decoded_msg = decoder(msg) except (IndexError, ValueError, AttributeError) as exc: raise ValueError("Cannot decode message") from exc return (cmd, decoded_msg) if not msg or msg.startswith("Username: ") or msg.startswith("Password: "): return None if "Login successful" in msg: return ("login", {"succeeded": True}) if msg.startswith("Username/Password not found") or msg == "Disabled": return ("login", {"succeeded": False}) raise ValueError(error_msg) def _is_valid_length_and_checksum(msg: str) -> tuple[bool, str]: """Check packet length valid and that checksum is good.""" try: if int(msg[:2], 16) != (len(msg) - 2): return ( False, f"Incorrect message length, expected {msg[:2]}, got {len(msg) - 2:02X}. Msg {msg}", # noqa: E501 ) checksum = int(msg[-2:], 16) for char in msg[:-2]: checksum += ord(char) if (checksum % 256) != 0: return False, f"Bad checksum. Msg: {msg}" except ValueError: return False, "Message invalid" return True, "" def _chk_len(msg: str, msg_len: str) -> None: if msg[:2] != msg_len: raise ValueError(f"Expected msg len {msg_len}. Got msg {msg}") def am_decode(msg: str) -> dict[str, list[bool]]: """AM: Alarm memory by area report.""" _chk_len(msg, "0C") return {"alarm_memory": [x == "1" for x in msg[4 : 4 + Max.AREAS.value]]} def as_decode( msg: str, ) -> dict[str, list[ArmedStatus] | list[ArmUpState] | list[AlarmState]]: """AS: Arming status report.""" return { "armed_statuses": [ArmedStatus(x) for x in msg[4:12]], "arm_up_states": [ArmUpState(x) for x in msg[12:20]], "alarm_states": [AlarmState(x) for x in msg[20:28]], } def az_decode(msg: str) -> dict[str, list[ZoneAlarmState]]: """AZ: Alarm by zone report.""" _chk_len(msg, "D6") return {"alarm_status": [ZoneAlarmState(x) for x in msg[4 : 4 + Max.ZONES.value]]} def _cr_one_custom_value_decode(index: int, part: str) -> dict[str, Any]: value = int(part[0:5]) value_format = SettingFormat(int(part[5])) if value_format == SettingFormat.TIME_OF_DAY: ret: int | tuple[int, int] = ((value >> 8) & 0xFF, value & 0xFF) else: ret = value return {"index": index, "value": ret, "value_format": value_format} def cr_decode(msg: str) -> dict[str, Any]: """CR: Custom values""" if int(msg[4:6]) > 0: index = int(msg[4:6]) - 1 return {"values": [_cr_one_custom_value_decode(index, msg[6:12])]} part = 6 ret = [] for i in range(Max.SETTINGS.value): ret.append(_cr_one_custom_value_decode(i, msg[part : part + 6])) part += 6 return {"values": ret} def cc_decode(msg: str) -> dict[str, Any]: """CC: Output status for single output.""" return {"output": int(msg[4:7]) - 1, "output_status": msg[7] == "1"} def cs_decode(msg: str) -> dict[str, Any]: """CS: Output status for all outputs.""" output_status = [x == "1" for x in msg[4 : 4 + Max.OUTPUTS.value]] return {"output_status": output_status} def cv_decode(msg: str) -> dict[str, Any]: """CV: Counter value.""" return {"counter": int(msg[4:6]) - 1, "value": int(msg[6:11])} def ee_decode(msg: str) -> dict[str, int | bool | ArmedStatus]: """EE: Entry/exit timer report.""" return { "area": int(msg[4:5]) - 1, "is_exit": msg[5:6] == "0", "timer1": int(msg[6:9]), "timer2": int(msg[9:12]), "armed_status": ArmedStatus(msg[12:13]), } def ic_decode(msg: str) -> dict[str, Any]: """IC: Send Valid Or Invalid User Code Format.""" code = msg[4:16] if re.match(r"(0\d){6}", code): code = re.sub(r"0(\d)", r"\1", code) return { "code": code, "user": int(msg[16:19]) - 1, "keypad": int(msg[19:21]) - 1, } def ie_decode(msg: str) -> dict[str, str]: """IE: Installer mode exited.""" return {} def ka_decode(msg: str) -> dict[str, Any]: """KA: Keypad areas for all keypads.""" return {"keypad_areas": [ord(x) - 0x31 for x in msg[4 : 4 + Max.KEYPADS.value]]} def kc_decode(msg: str) -> dict[str, Any]: """KC: Keypad key change.""" return {"keypad": int(msg[4:6]) - 1, "key": int(msg[6:8])} def kf_decode(msg: str) -> dict[str, Any]: """KF: Keypad function key press.""" return { "keypad": int(msg[4:6]) - 1, "key": FunctionKeys(msg[6]), "chime_mode": [ChimeMode(int(x)) for x in msg[7:15]], } def ld_decode(msg: str) -> dict[str, Any]: """LD: System Log Data Update.""" area = int(msg[11]) - 1 hour = int(msg[12:14]) minute = int(msg[14:16]) month = int(msg[16:18]) day = int(msg[18:20]) year = int(msg[24:26]) + 2000 log_local_datetime = dt.datetime(year, month, day, hour, minute) log_local_time = time.mktime(log_local_datetime.timetuple()) log_gm_timestruct = time.gmtime(log_local_time) log: dict[str, Any] = {} log["event"] = int(msg[4:8]) log["number"] = int(msg[8:11]) log["index"] = int(msg[20:23]) log["timestamp"] = dt.datetime(*log_gm_timestruct[:6], tzinfo=dt.UTC).isoformat() return {"area": area, "log": log} def lw_decode(msg: str) -> dict[str, Any]: """LW: temperatures from all keypads and zones 1-16.""" keypad_temps = [] zone_temps = [] for i in range(16): keypad_temps.append(int(msg[4 + 3 * i : 7 + 3 * i]) - 40) zone_temps.append(int(msg[52 + 3 * i : 55 + 3 * i]) - 60) return {"keypad_temps": keypad_temps, "zone_temps": zone_temps} def pc_decode(msg: str) -> dict[str, Any]: """PC: PLC (lighting) change.""" housecode = msg[4:7] return { "housecode": housecode, "index": housecode_to_index(housecode), "light_level": int(msg[7:9]), } def ps_decode(msg: str) -> dict[str, Any]: """PS: PLC (lighting) status.""" return { "bank": ord(msg[4]) - 0x30, "statuses": [ord(x) - 0x30 for x in msg[5:69]], } def rp_decode(msg: str) -> dict[str, ElkRPStatus]: """RP: Remote programming status.""" return {"remote_programming_status": ElkRPStatus(int(msg[4:6]))} def rr_decode(msg: str) -> dict[str, str]: """RR: Realtime clock.""" return {"real_time_clock": msg[4:20]} def sd_decode(msg: str) -> dict[str, Any]: """SD: Description text.""" desc_ch1 = msg[9] show_on_keypad = ord(desc_ch1) >= 0x80 if show_on_keypad: desc_ch1 = chr(ord(desc_ch1) & 0x7F) return { "desc_type": int(msg[4:6]), "unit": int(msg[6:9]) - 1, "desc": (desc_ch1 + msg[10:25]).rstrip(), "show_on_keypad": show_on_keypad, } def ss_decode(msg: str) -> dict[str, str]: """SS: System status.""" return {"system_trouble_status": msg[4:-2]} def st_decode(msg: str) -> dict[str, int]: """ST: Temperature update.""" group = int(msg[4:5]) temperature = int(msg[7:10]) if group == 0: temperature -= 60 elif group == 1: temperature -= 40 return {"group": group, "device": int(msg[5:7]) - 1, "temperature": temperature} def tc_decode(msg: str) -> dict[str, int]: """TC: Task change.""" return {"task": int(msg[4:7]) - 1} def tr_decode(msg: str) -> dict[str, Any]: """TR: Thermostat data response.""" _chk_len(msg, "13") return { "thermostat_index": int(msg[4:6]) - 1, "mode": ThermostatMode(int(msg[6])), "hold": msg[7] == "1", "fan": ThermostatFan(int(msg[8])), "current_temp": int(msg[9:11]), "heat_setpoint": int(msg[11:13]), "cool_setpoint": int(msg[13:15]), "humidity": int(msg[15:17]), } def ua_decode(msg: str) -> dict[str, Any]: """UA: Valid User Code Areas.""" return { "user_code": int(msg[4:10]), "valid_areas": int(msg[10:12], 16), "diagnostic": msg[12:20], "user_code_length": int(msg[20]), "user_code_type": int(msg[21]), "temperature_units": msg[22], } def vn_decode(msg: str) -> dict[str, str]: """VN: Version information.""" elkm1_version = f"{int(msg[4:6], 16)}.{int(msg[6:8], 16)}.{int(msg[8:10], 16)}" xep_version = f"{int(msg[10:12], 16)}.{int(msg[12:14], 16)}.{int(msg[14:16], 16)}" return {"elkm1_version": elkm1_version, "xep_version": xep_version} def xk_decode(msg: str) -> dict[str, str]: """XK: Ethernet Test.""" return {"real_time_clock": msg[4:20]} def zb_decode(msg: str) -> dict[str, Any]: """ZB: Zone bypass report.""" return {"zone_number": int(msg[4:7]) - 1, "zone_bypassed": msg[7] == "1"} def zc_decode( msg: str, ) -> dict[str, int | tuple[ZoneLogicalStatus, ZonePhysicalStatus]]: """ZC: Zone Change.""" _chk_len(msg, "0A") status = _status_decode(int(msg[7:8], 16)) return {"zone_number": int(msg[4:7]) - 1, "zone_status": status} def zd_decode(msg: str) -> dict[str, list[ZoneType]]: """ZD: Zone definitions.""" _chk_len(msg, "D6") zone_definitions = [ZoneType(ord(x) - 0x30) for x in msg[4 : 4 + Max.ZONES.value]] return {"zone_definitions": zone_definitions} def zp_decode(msg: str) -> dict[str, list[int]]: """ZP: Zone partitions.""" zone_partitions = [ord(x) - 0x31 for x in msg[4 : 4 + Max.ZONES.value]] return {"zone_partitions": zone_partitions} def zs_decode( msg: str, ) -> dict[str, list[tuple[ZoneLogicalStatus, ZonePhysicalStatus]]]: """ZS: Zone statuses.""" _chk_len(msg, "D6") status = [_status_decode(int(x, 16)) for x in msg[4 : 4 + Max.ZONES.value]] return {"zone_statuses": status} def zv_decode(msg: str) -> dict[str, Any]: """ZV: Zone voltage.""" return {"zone_number": int(msg[4:7]) - 1, "zone_voltage": int(msg[7:10]) / 10} def housecode_to_index(housecode: str) -> int: """Convert a X10 housecode to a zero-based index""" match = re.search(r"^([A-P])(\d{1,2})$", housecode.upper()) if match: house_index = int(match.group(2)) if 1 <= house_index <= 16: return (ord(match.group(1)) - ord("A")) * 16 + house_index - 1 raise ValueError(f"Invalid X10 housecode: {housecode}") def index_to_housecode(index: int) -> str: """Convert a zero-based index to a X10 housecode.""" if index < 0 or index > 255: raise ValueError quotient, remainder = divmod(index, 16) return f"{chr(ord('A') + quotient)}{remainder + 1:02}" def get_elk_command(line: str) -> str: """Return the 2 character command in the message.""" if len(line) < 4: return "" return line[2:4] def _status_decode(status: int) -> tuple[ZoneLogicalStatus, ZonePhysicalStatus]: """Decode a 1 byte status into logical and physical statuses.""" logical_status = ZoneLogicalStatus((status & 0b00001100) >> 2) physical_status = ZonePhysicalStatus(status & 0b00000011) return (logical_status, physical_status) def al_encode(arm_mode: ArmLevel, area: int, user_code: int) -> MessageEncode: """al: Arm system. Note in 'al' the 'l' can vary""" return MessageEncode(f"0Da{arm_mode.value}{area + 1:1}{user_code:06}00", "AS") def as_encode() -> MessageEncode: """as: Get area status.""" return MessageEncode("06as00", "AS") def az_encode() -> MessageEncode: """az: Get alarm by zone.""" return MessageEncode("06az00", "AZ") def cf_encode(output: int) -> MessageEncode: """cf: Turn off output.""" return MessageEncode(f"09cf{output + 1:03}00", None) def ct_encode(output: int) -> MessageEncode: """ct: Toggle output.""" return MessageEncode(f"09ct{output + 1:03}00", None) def cn_encode(output: int, seconds: int) -> MessageEncode: """cn: Turn on output.""" return MessageEncode(f"0Ecn{output + 1:03}{seconds:05}00", None) def cs_encode() -> MessageEncode: """cs: Get all output status.""" return MessageEncode("06cs00", "CS") def cp_encode() -> MessageEncode: """cp: Get ALL custom values.""" return MessageEncode("06cp00", "CR") def cr_encode(index: int) -> MessageEncode: """cr: Get a custom value.""" return MessageEncode(f"08cr{index + 1:02}00", "CR") def cw_encode( index: int, value: int | tuple[int, int], value_format: SettingFormat ) -> MessageEncode: """cw: Write a custom value.""" if value_format == SettingFormat.TIME_OF_DAY: val = cast(tuple[int, int], value) enc = val[0] * 256 + val[1] else: enc = cast(int, value) return MessageEncode(f"0Dcw{index + 1:02}{enc:05}00", None) def cv_encode(counter: int) -> MessageEncode: """cv: Get counter.""" return MessageEncode(f"08cv{counter + 1:02}00", "CV") def cx_encode(counter: int, value: int) -> MessageEncode: """cx: Change counter value.""" return MessageEncode(f"0Dcx{counter + 1:02}{value:05}00", "CV") def dm_encode( keypad_area: int, clear: int, beep: bool, timeout: int, line1: str, line2: str ) -> MessageEncode: """dm: Display message on keypad.""" return MessageEncode( ( f"2Edm{keypad_area + 1:1}{clear:1}{beep:1}" f"{timeout:05}{line1:^<16.16}{line2:^<16.16}00" ), None, ) def ka_encode() -> MessageEncode: """ka: Get keypad areas.""" return MessageEncode("06ka00", "KA") def kf_encode( keypad: int, functionkey: FunctionKeys = FunctionKeys.FORCE_KF_SYNC ) -> MessageEncode: """kf: Function Key Press.""" return MessageEncode(f"09kf{keypad + 1:02}{functionkey.value}00", "KF") def lw_encode() -> MessageEncode: """lw: Get temperature data.""" return MessageEncode("06lw00", "LW") def pc_encode( index: int, function_code: int, extended_code: int, seconds: int ) -> MessageEncode: """pc: Control any PLC device.""" return MessageEncode( f"11pc{index_to_housecode(index)}{function_code:02}{extended_code:02}{seconds:04}00", None, ) def pf_encode(index: int) -> MessageEncode: """pf: Turn off light.""" return MessageEncode(f"09pf{index_to_housecode(index)}00", None) def pn_encode(index: int) -> MessageEncode: """pn: Turn on light.""" return MessageEncode(f"09pn{index_to_housecode(index)}00", None) def ps_encode(bank: int) -> MessageEncode: """ps: Get lighting status.""" return MessageEncode(f"07ps{bank:1}00", "PS") def pt_encode(index: int) -> MessageEncode: """pt: Toggle light.""" return MessageEncode(f"09pt{index_to_housecode(index)}00", None) def sd_encode(desc_type: int, unit: int) -> MessageEncode: """sd: Get description.""" return MessageEncode(f"0Bsd{desc_type:02}{unit + 1:03}00", "SD") def sp_encode(phrase: int) -> MessageEncode: """sp: Speak phrase.""" return MessageEncode(f"09sp{phrase:03}00", None) def ss_encode() -> MessageEncode: """ss: Get system trouble status.""" return MessageEncode("06ss00", "SS") def sw_encode(word: int) -> MessageEncode: """sw: Speak word.""" return MessageEncode(f"09sw{word:03}00", None) def rw_encode(date_time: dt.datetime) -> MessageEncode: """rw: Write time given a datetime.""" elk_weekday = (date_time.weekday() + 1) % 7 + 1 time_str = date_time.strftime("%S%M%H.%d%m%y").replace(".", str(elk_weekday)) return MessageEncode(f"13rw{time_str}00", None) def tn_encode(task: int) -> MessageEncode: """tn: Activate task.""" return MessageEncode(f"09tn{task + 1:03}00", None) def tr_encode(thermostat: int) -> MessageEncode: """tr: Request thermostat data.""" return MessageEncode(f"08tr{thermostat + 1:02}00", None) def ts_encode(thermostat: int, value: int, element: ThermostatSetting) -> MessageEncode: """ts: Set thermostat data.""" return MessageEncode(f"0Bts{thermostat + 1:02}{value:02}{element.value:1}00", None) def ua_encode(user_code: int) -> MessageEncode: """ua: Requst valid user code areas""" return MessageEncode(f"0Cua{user_code:06}00", "UA") def vn_encode() -> MessageEncode: """zd: Get panel software version information.""" return MessageEncode("06vn00", "VN") def zb_encode(zone: int, area: int, user_code: int) -> MessageEncode: """zb: Zone bypass. Zone < 0 unbypass all; Zone > Max bypass all.""" if zone < 0: zone = 0 elif zone > Max.ZONES.value: zone = 999 else: zone += 1 return MessageEncode( f"10zb{zone:03}{area + 1:1}{user_code:06}00", "ZB", ) def zd_encode() -> MessageEncode: """zd: Get zone definitions""" return MessageEncode("06zd00", "ZD") def zp_encode() -> MessageEncode: """zp: Get zone partitions""" return MessageEncode("06zp00", "ZP") def zs_encode() -> MessageEncode: """zs: Get zone statuses""" return MessageEncode("06zs00", "ZS") def zt_encode(zone: int) -> MessageEncode: """zt: Trigger zone.""" return MessageEncode(f"09zt{zone + 1:03}00", None) def zv_encode(zone: int) -> MessageEncode: """zv: Get zone voltage""" return MessageEncode(f"09zv{zone + 1:03}00", "ZV")