#!/usr/bin/env python3 import argparse import struct import sys import time try: import serial except ImportError: print("Missing dependency: pyserial", file=sys.stderr) print("Install with: python -m pip install pyserial", file=sys.stderr) sys.exit(2) CMD_APP_START = 1 CMD_SEND_TXT_MSG = 2 CMD_SYNC_NEXT_MESSAGE = 10 CMD_GET_BATT_AND_STORAGE = 20 CMD_DEVICE_QUERY = 22 CMD_SEND_TELEMETRY_REQ = 39 CMD_GET_CUSTOM_VARS = 40 CMD_GET_STATS = 56 RESP_CODE_SELF_INFO = 5 RESP_CODE_SENT = 6 RESP_CODE_CONTACT_MSG_RECV_V3 = 16 RESP_CODE_NO_MORE_MESSAGES = 10 RESP_CODE_BATT_AND_STORAGE = 12 RESP_CODE_DEVICE_INFO = 13 RESP_CODE_CUSTOM_VARS = 21 RESP_CODE_STATS = 24 PUSH_CODE_SEND_CONFIRMED = 0x82 PUSH_CODE_TELEMETRY_RESPONSE = 0x8B PUSH_CODE_BINARY_RESPONSE = 0x8C TXT_TYPE_CLI_DATA = 1 STATS_TYPE_CORE = 0 STATS_TYPE_RADIO = 1 STATS_TYPE_PACKETS = 2 MESHCOM_PREFIX = b"MESHCM" def build_frame(payload: bytes) -> bytes: return b"<" + struct.pack("": continue hdr = ser.read(2) if len(hdr) != 2: return None length = struct.unpack("") return [] for idx, frame in enumerate(frames, start=1): print(f"RX[{idx}]:", frame.hex(" ")) decode_frame(frame) return frames def decode_frame(frame: bytes): code = frame[0] print(f"Code: 0x{code:02X} ({code})") if code == RESP_CODE_DEVICE_INFO: decode_device_info(frame) elif code == RESP_CODE_BATT_AND_STORAGE: decode_batt_and_storage(frame) elif code == RESP_CODE_SELF_INFO: print("Self info response received") elif code == RESP_CODE_CUSTOM_VARS: decode_custom_vars(frame) elif code == RESP_CODE_STATS: decode_stats(frame) elif code == RESP_CODE_CONTACT_MSG_RECV_V3: decode_contact_msg_recv_v3(frame) elif code == RESP_CODE_NO_MORE_MESSAGES: print("No more queued messages") elif code == RESP_CODE_SENT: decode_sent(frame) elif code == PUSH_CODE_SEND_CONFIRMED: decode_send_confirmed(frame) elif code == PUSH_CODE_TELEMETRY_RESPONSE: decode_telemetry(frame) elif code == PUSH_CODE_BINARY_RESPONSE: decode_binary_response(frame) else: print("Unhandled response") def decode_c_string(raw: bytes) -> str: return raw.split(b"\x00", 1)[0].decode("utf-8", errors="replace") def decode_device_info(frame: bytes): if len(frame) < 80: print("Short DEVICE_INFO frame") return fw_ver_code = frame[1] max_contacts_x2 = frame[2] max_channels = frame[3] ble_pin = struct.unpack("= 80 else "" print("Firmware code:", fw_ver_code) print("Max contacts:", max_contacts_x2 * 2) print("Max channels:", max_channels) print("BLE pin:", ble_pin) print("Build date:", build_date) print("Manufacturer:", manufacturer) if version: print("Version:", version) def decode_batt_and_storage(frame: bytes): if len(frame) < 11: print("Short BATT_AND_STORAGE frame") return battery_mv = struct.unpack("") def decode_stats(frame: bytes): if len(frame) < 2: print("Short STATS frame") return stats_type = frame[1] if stats_type == STATS_TYPE_CORE and len(frame) >= 11: battery_mv = struct.unpack("= 14: noise_floor = struct.unpack("= 30: values = struct.unpack("") def decode_contact_msg_recv_v3(frame: bytes): if len(frame) < 16: print("Short CONTACT_MSG_RECV_V3 frame") return snr_q4 = struct.unpack("") def _read_i16_be(buf: bytes) -> int: return struct.unpack(">h", buf)[0] def _read_u16_be(buf: bytes) -> int: return struct.unpack(">H", buf)[0] def _read_u32_be(buf: bytes) -> int: return struct.unpack(">I", buf)[0] def _read_i24_be(buf: bytes) -> int: val = int.from_bytes(buf, "big", signed=False) if val & 0x800000: val -= 1 << 24 return val def parse_lpp(payload: bytes): i = 0 items = [] while i + 2 <= len(payload): channel = payload[i] data_type = payload[i + 1] i += 2 if data_type in (0, 1, 102, 120, 142): if i + 1 > len(payload): break value = payload[i] i += 1 items.append((channel, data_type, value)) elif data_type in (2, 3): if i + 2 > len(payload): break value = _read_i16_be(payload[i:i + 2]) / 100.0 i += 2 items.append((channel, data_type, value)) elif data_type in (101, 115, 125, 128, 132): if i + 2 > len(payload): break value = _read_u16_be(payload[i:i + 2]) i += 2 if data_type == 115: value /= 10.0 items.append((channel, data_type, value)) elif data_type == 121: if i + 2 > len(payload): break value = _read_i16_be(payload[i:i + 2]) i += 2 items.append((channel, data_type, value)) elif data_type == 103: if i + 2 > len(payload): break value = _read_i16_be(payload[i:i + 2]) / 10.0 i += 2 items.append((channel, data_type, value)) elif data_type == 104: if i + 1 > len(payload): break value = payload[i] / 2.0 i += 1 items.append((channel, data_type, value)) elif data_type in (116,): if i + 2 > len(payload): break value = _read_u16_be(payload[i:i + 2]) / 100.0 i += 2 items.append((channel, data_type, value)) elif data_type in (117,): if i + 2 > len(payload): break value = _read_u16_be(payload[i:i + 2]) / 1000.0 i += 2 items.append((channel, data_type, value)) elif data_type in (100, 118, 131, 133): if i + 4 > len(payload): break value = _read_u32_be(payload[i:i + 4]) i += 4 if data_type == 131: value /= 1000.0 items.append((channel, data_type, value)) elif data_type == 130: if i + 4 > len(payload): break value = _read_u32_be(payload[i:i + 4]) / 1000.0 i += 4 items.append((channel, data_type, value)) elif data_type in (113, 134): if i + 6 > len(payload): break x = _read_i16_be(payload[i:i + 2]) y = _read_i16_be(payload[i + 2:i + 4]) z = _read_i16_be(payload[i + 4:i + 6]) i += 6 scale = 1000.0 if data_type == 113 else 100.0 items.append((channel, data_type, (x / scale, y / scale, z / scale))) elif data_type == 136: if i + 9 > len(payload): break lat = _read_i24_be(payload[i:i + 3]) / 10000.0 lon = _read_i24_be(payload[i + 3:i + 6]) / 10000.0 alt = _read_i24_be(payload[i + 6:i + 9]) / 100.0 i += 9 items.append((channel, data_type, (lat, lon, alt))) else: items.append((channel, data_type, "")) break return items def format_lpp_item(channel: int, data_type: int, value): names = { 0: "digital_input", 1: "digital_output", 2: "analog_input", 3: "analog_output", 100: "generic_sensor", 101: "luminosity", 102: "presence", 103: "temperature_c", 104: "humidity_pct", 113: "accelerometer_g", 115: "barometric_hpa", 116: "voltage_v", 117: "current_a", 118: "frequency_hz", 120: "percentage", 121: "altitude_m", 125: "concentration_ppm", 128: "power_w", 130: "distance_m", 131: "energy_kwh", 132: "direction_deg", 133: "unixtime", 134: "gyrometer_dps", 136: "gps", 142: "switch", } return f"ch{channel} {names.get(data_type, f'type_{data_type}')} = {value}" def drain_sync_messages(ser: serial.Serial, max_reads: int = 8): for idx in range(max_reads): frames = send_and_collect( ser, f"CMD_SYNC_NEXT_MESSAGE #{idx + 1}", bytes([CMD_SYNC_NEXT_MESSAGE]), listen_s=0.7, ) if not frames: break if any(frame[:1] == bytes([RESP_CODE_NO_MORE_MESSAGES]) for frame in frames): break def decode_telemetry(frame: bytes): if len(frame) < 8: print("Short TELEMETRY_RESPONSE frame") return reserved = frame[1] pubkey_prefix = frame[2:8].hex() lpp = frame[8:] print("Telemetry reserved:", reserved) print("Telemetry pubkey prefix:", pubkey_prefix) print("Telemetry raw LPP:", lpp.hex(" ")) items = parse_lpp(lpp) if not items: print("Telemetry parsed: ") return print("Telemetry parsed:") for channel, data_type, value in items: print(format_lpp_item(channel, data_type, value)) def build_meshcomod_cli(command: str) -> bytes: return ( bytes([CMD_SEND_TXT_MSG, TXT_TYPE_CLI_DATA, 0]) + struct.pack("