From 12bb53aa64dd923878c18b4384d8cd0eb348fae6 Mon Sep 17 00:00:00 2001 From: sh4un <97253929+sh4un-dot-com@users.noreply.github.com> Date: Wed, 4 Feb 2026 10:15:16 -0500 Subject: [PATCH] feat: update Meshcore Companion Radio Protocol handler for USB framing and payload processing --- ammb/config_handler.py | 8 +-- ammb/protocol.py | 115 +++++++++++++++++++---------------------- 2 files changed, 54 insertions(+), 69 deletions(-) diff --git a/ammb/config_handler.py b/ammb/config_handler.py index 541fc2f..c7609a0 100644 --- a/ammb/config_handler.py +++ b/ammb/config_handler.py @@ -81,7 +81,7 @@ DEFAULT_CONFIG = { } VALID_LOG_LEVELS = {"CRITICAL", "ERROR", "WARNING", "INFO", "DEBUG"} -VALID_SERIAL_PROTOCOLS = {"json_newline", "raw_serial"} +VALID_SERIAL_PROTOCOLS = {"json_newline", "raw_serial", "companion_radio"} VALID_TRANSPORTS = {"serial", "mqtt"} VALID_MQTT_QOS = {0, 1, 2} @@ -107,12 +107,6 @@ def load_config(config_path: str = CONFIG_FILE) -> Optional[BridgeConfig]: logger.info("Reading configuration from: %s", config_path) config.read(config_path) - if "DEFAULT" not in config.sections(): - logger.warning( - "Configuration file '%s' lacks the [DEFAULT] section.", - config_path, - ) - logger.warning("Using only defaults.") cfg_section = config["DEFAULT"] if "DEFAULT" in config else DEFAULT_CONFIG # Only set meshtastic_port if explicitly present and not commented out diff --git a/ammb/protocol.py b/ammb/protocol.py index 892c5bb..2c53f2d 100644 --- a/ammb/protocol.py +++ b/ammb/protocol.py @@ -170,37 +170,53 @@ import struct class MeshcoreCompanionProtocol(MeshcoreProtocolHandler): """ - Handles the Meshcore Companion Radio Protocol (binary, framed). - Frame format: - [0x7E][len][payload...][CRC16][0x7E] + Handles the Meshcore Companion Radio Protocol (USB framing). + Outbound (radio -> app): [0x3E][len_le][payload...] + Inbound (app -> radio): [0x3C][len_le][payload...] """ - FRAME_DELIM = 0x7E - CRC_LEN = 2 + + OUTBOUND_START = 0x3E # '>' + INBOUND_START = 0x3C # '<' + + def __init__(self): + super().__init__() + self._rx_buffer = bytearray() def read(self, serial_port) -> Optional[bytes]: - """Reads a full frame delimited by 0x7E, returns the raw frame (including delimiters).""" - frame = bytearray() - in_frame = False - while serial_port.in_waiting > 0: - byte = serial_port.read(1) - if not byte: - break - b = byte[0] - if b == self.FRAME_DELIM: - if in_frame and len(frame) > 0: - # End of frame - frame.append(b) - return bytes(frame) - else: - # Start of frame - frame = bytearray([b]) - in_frame = True - elif in_frame: - frame.append(b) - return None + """Reads and returns a single outbound payload frame (without framing).""" + if serial_port.in_waiting > 0: + chunk = serial_port.read(serial_port.in_waiting) + if chunk: + self.logger.debug("Companion RAW bytes: %s", chunk.hex()) + self._rx_buffer.extend(chunk) + + while True: + if len(self._rx_buffer) < 3: + return None + + if self._rx_buffer[0] != self.OUTBOUND_START: + # Resync to next '>' + try: + next_idx = self._rx_buffer.index(self.OUTBOUND_START) + del self._rx_buffer[:next_idx] + except ValueError: + self._rx_buffer.clear() + return None + + if len(self._rx_buffer) < 3: + return None + + length = self._rx_buffer[1] | (self._rx_buffer[2] << 8) + frame_len = 3 + length + if len(self._rx_buffer) < frame_len: + return None + + payload = bytes(self._rx_buffer[3:frame_len]) + del self._rx_buffer[:frame_len] + return payload def encode(self, data: Dict[str, Any]) -> Optional[bytes]: - """Encodes a payload dict into a framed binary message.""" + """Encodes a payload dict into an inbound frame.""" try: payload = data.get("payload", b"") if isinstance(payload, str): @@ -209,50 +225,25 @@ class MeshcoreCompanionProtocol(MeshcoreProtocolHandler): return None length = len(payload) frame = bytearray() - frame.append(self.FRAME_DELIM) - frame.append(length) + frame.append(self.INBOUND_START) + frame.append(length & 0xFF) + frame.append((length >> 8) & 0xFF) frame.extend(payload) - crc = self._crc16_ccitt(frame[1:2+length]) - frame.extend(struct.pack('>H', crc)) - frame.append(self.FRAME_DELIM) return bytes(frame) except Exception as e: self.logger.error("Error encoding companion frame: %s", e) return None def decode(self, raw_data: bytes) -> Optional[Dict[str, Any]]: - """Decodes a framed binary message into a dict.""" - try: - if not raw_data or raw_data[0] != self.FRAME_DELIM or raw_data[-1] != self.FRAME_DELIM: - return None - length = raw_data[1] - payload = raw_data[2:2+length] - crc_recv = struct.unpack('>H', raw_data[2+length:2+length+2])[0] - crc_calc = self._crc16_ccitt(raw_data[1:2+length]) - if crc_recv != crc_calc: - self.logger.warning("CRC mismatch: recv=%04X calc=%04X", crc_recv, crc_calc) - return None - return { - "destination_meshtastic_id": "^all", - "payload": payload, - "raw_binary": True, - "protocol": "companion_radio" - } - except Exception as e: - self.logger.error("Error decoding companion frame: %s", e) + """Wraps received payload bytes into a dict.""" + if not raw_data: return None - - @staticmethod - def _crc16_ccitt(data: bytes, crc: int = 0xFFFF) -> int: - for b in data: - crc ^= b << 8 - for _ in range(8): - if crc & 0x8000: - crc = (crc << 1) ^ 0x1021 - else: - crc <<= 1 - crc &= 0xFFFF - return crc + return { + "destination_meshtastic_id": "^all", + "payload": raw_data, + "raw_binary": True, + "protocol": "companion_radio", + } _serial_protocol_handlers = { "json_newline": JsonNewlineProtocol,