diff --git a/config.yaml.example b/config.yaml.example index e1498b3..39b7130 100644 --- a/config.yaml.example +++ b/config.yaml.example @@ -100,7 +100,23 @@ identities: # longitude: 0.0 # admin_password: "social_admin_123" # guest_password: "social_guest_123" - + + # Companion Identities + # Each companion exposes the MeshCore frame protocol over TCP for standard clients. + # One TCP client per companion at a time. Clients connect to repeater-ip:tcp_port. + companions: + # - name: "RepeaterCompanion" + # identity_key: "your_companion_identity_key_hex_here" + # settings: + # node_name: "RepeaterCompanion" + # tcp_port: 5000 + # bind_address: "0.0.0.0" + # - name: "BotCompanion" + # identity_key: "another_companion_identity_key_hex" + # settings: + # node_name: "meshcore-bot" + # tcp_port: 5001 + radio: # Frequency in Hz (869.618 MHz for EU) frequency: 869618000 diff --git a/repeater/companion/__init__.py b/repeater/companion/__init__.py new file mode 100644 index 0000000..ecfa313 --- /dev/null +++ b/repeater/companion/__init__.py @@ -0,0 +1,28 @@ +"""Companion identity support for pyMC Repeater. + +Exposes the MeshCore companion frame protocol over TCP for standard clients. +""" + +from .frame_server import CompanionFrameServer +from .constants import ( + CMD_APP_START, + CMD_GET_CONTACTS, + CMD_SEND_TXT_MSG, + CMD_SYNC_NEXT_MESSAGE, + CMD_SEND_LOGIN, + RESP_CODE_OK, + RESP_CODE_ERR, + PUSH_CODE_MSG_WAITING, +) + +__all__ = [ + "CompanionFrameServer", + "CMD_APP_START", + "CMD_GET_CONTACTS", + "CMD_SEND_TXT_MSG", + "CMD_SYNC_NEXT_MESSAGE", + "CMD_SEND_LOGIN", + "RESP_CODE_OK", + "RESP_CODE_ERR", + "PUSH_CODE_MSG_WAITING", +] diff --git a/repeater/companion/constants.py b/repeater/companion/constants.py new file mode 100644 index 0000000..3ae73be --- /dev/null +++ b/repeater/companion/constants.py @@ -0,0 +1,135 @@ +"""Companion frame protocol constants (MeshCore Companion Radio Protocol).""" + +# Commands (app -> radio) +CMD_APP_START = 1 +CMD_SEND_TXT_MSG = 2 +CMD_SEND_CHANNEL_TXT_MSG = 3 +CMD_GET_CONTACTS = 4 +CMD_GET_DEVICE_TIME = 5 +CMD_SET_DEVICE_TIME = 6 +CMD_SEND_SELF_ADVERT = 7 +CMD_SET_ADVERT_NAME = 8 +CMD_ADD_UPDATE_CONTACT = 9 +CMD_SYNC_NEXT_MESSAGE = 10 +CMD_SET_RADIO_PARAMS = 11 +CMD_SET_RADIO_TX_POWER = 12 +CMD_RESET_PATH = 13 +CMD_SET_ADVERT_LATLON = 14 +CMD_REMOVE_CONTACT = 15 +CMD_SHARE_CONTACT = 16 +CMD_EXPORT_CONTACT = 17 +CMD_IMPORT_CONTACT = 18 +CMD_REBOOT = 19 +CMD_GET_BATT_AND_STORAGE = 20 +CMD_SET_TUNING_PARAMS = 21 +CMD_DEVICE_QUERY = 22 +CMD_EXPORT_PRIVATE_KEY = 23 +CMD_IMPORT_PRIVATE_KEY = 24 +CMD_SEND_RAW_DATA = 25 +CMD_SEND_LOGIN = 26 +CMD_SEND_STATUS_REQ = 27 +CMD_HAS_CONNECTION = 28 +CMD_LOGOUT = 29 +CMD_GET_CONTACT_BY_KEY = 30 +CMD_GET_CHANNEL = 31 +CMD_SET_CHANNEL = 32 +CMD_SIGN_START = 33 +CMD_SIGN_DATA = 34 +CMD_SIGN_FINISH = 35 +CMD_SEND_TRACE_PATH = 36 +CMD_SET_DEVICE_PIN = 37 +CMD_SET_OTHER_PARAMS = 38 +CMD_SEND_TELEMETRY_REQ = 39 +CMD_GET_CUSTOM_VARS = 40 +CMD_SET_CUSTOM_VAR = 41 +CMD_GET_ADVERT_PATH = 42 +CMD_GET_TUNING_PARAMS = 43 +CMD_SEND_BINARY_REQ = 50 +CMD_FACTORY_RESET = 51 +CMD_SEND_PATH_DISCOVERY_REQ = 52 +CMD_SET_FLOOD_SCOPE = 54 +CMD_SEND_CONTROL_DATA = 55 +CMD_GET_STATS = 56 +CMD_SEND_ANON_REQ = 57 +CMD_SET_AUTOADD_CONFIG = 58 +CMD_GET_AUTOADD_CONFIG = 59 + +# Response codes (radio -> app) +RESP_CODE_OK = 0 +RESP_CODE_ERR = 1 +RESP_CODE_CONTACTS_START = 2 +RESP_CODE_CONTACT = 3 +RESP_CODE_END_OF_CONTACTS = 4 +RESP_CODE_SELF_INFO = 5 +RESP_CODE_SENT = 6 +RESP_CODE_CONTACT_MSG_RECV = 7 +RESP_CODE_CHANNEL_MSG_RECV = 8 +RESP_CODE_CURR_TIME = 9 +RESP_CODE_NO_MORE_MESSAGES = 10 +RESP_CODE_EXPORT_CONTACT = 11 +RESP_CODE_BATT_AND_STORAGE = 12 +RESP_CODE_DEVICE_INFO = 13 # CMD_DEVICE_QUERY response +RESP_CODE_PRIVATE_KEY = 14 +RESP_CODE_DISABLED = 15 +RESP_CODE_CONTACT_MSG_RECV_V3 = 16 +RESP_CODE_CHANNEL_MSG_RECV_V3 = 17 +RESP_CODE_CHANNEL_INFO = 18 +RESP_CODE_SIGN_START = 19 +RESP_CODE_SIGNATURE = 20 +RESP_CODE_CUSTOM_VARS = 21 +RESP_CODE_ADVERT_PATH = 22 +RESP_CODE_TUNING_PARAMS = 23 +RESP_CODE_STATS = 24 +RESP_CODE_AUTOADD_CONFIG = 25 + +# Push codes (radio -> app, unsolicited) +PUSH_CODE_ADVERT = 0x80 +PUSH_CODE_PATH_UPDATED = 0x81 +PUSH_CODE_SEND_CONFIRMED = 0x82 +PUSH_CODE_MSG_WAITING = 0x83 +PUSH_CODE_RAW_DATA = 0x84 +PUSH_CODE_LOGIN_SUCCESS = 0x85 +PUSH_CODE_LOGIN_FAIL = 0x86 +PUSH_CODE_STATUS_RESPONSE = 0x87 +PUSH_CODE_LOG_RX_DATA = 0x88 +PUSH_CODE_TRACE_DATA = 0x89 +PUSH_CODE_NEW_ADVERT = 0x8A +PUSH_CODE_TELEMETRY_RESPONSE = 0x8B +PUSH_CODE_BINARY_RESPONSE = 0x8C +PUSH_CODE_PATH_DISCOVERY_RESPONSE = 0x8D +PUSH_CODE_CONTROL_DATA = 0x8E +PUSH_CODE_CONTACT_DELETED = 0x8F +PUSH_CODE_CONTACTS_FULL = 0x90 + +# Error codes +ERR_CODE_UNSUPPORTED_CMD = 1 +ERR_CODE_NOT_FOUND = 2 +ERR_CODE_TABLE_FULL = 3 +ERR_CODE_BAD_STATE = 4 +ERR_CODE_FILE_IO_ERROR = 5 +ERR_CODE_ILLEGAL_ARG = 6 + +# Stats sub-types +STATS_TYPE_CORE = 0 +STATS_TYPE_RADIO = 1 +STATS_TYPE_PACKETS = 2 + +# Frame delimiters (USB/TCP: > = outbound, < = inbound) +FRAME_OUTBOUND_PREFIX = 0x3E # '>' +FRAME_INBOUND_PREFIX = 0x3C # '<' +MAX_FRAME_SIZE = 512 +PUB_KEY_SIZE = 32 +MAX_PATH_SIZE = 64 + +# ADV types +ADV_TYPE_CHAT = 1 +ADV_TYPE_REPEATER = 2 +ADV_TYPE_ROOM = 3 +ADV_TYPE_SENSOR = 4 + +# Default Public channel PSK (from firmware) +PUBLIC_GROUP_PSK = b"izOH6cXN6mrJ5e26oRXNcg==" + +# Default public channel secret (hex) - used for channel 0 when no channels loaded +# Matches MeshCore firmware default for new radios +DEFAULT_PUBLIC_CHANNEL_SECRET = bytes.fromhex("8b3387e9c5cdea6ac9e5edbaa115cd72") diff --git a/repeater/companion/frame_server.py b/repeater/companion/frame_server.py new file mode 100644 index 0000000..91ef2b1 --- /dev/null +++ b/repeater/companion/frame_server.py @@ -0,0 +1,729 @@ +""" +Companion frame protocol TCP server. + +Implements the MeshCore Companion Radio Protocol over TCP for standard clients. +Frame format: outbound '>' + 2-byte len (LE) + data; inbound '<' + 2-byte len + data. +""" + +import asyncio +import base64 +import logging +import struct +import time +from typing import Optional + +from pymc_core.companion.constants import ADV_TYPE_CHAT +from pymc_core.companion.models import Contact, QueuedMessage + +from .constants import ( + RESP_CODE_DEVICE_INFO, + CMD_ADD_UPDATE_CONTACT, + CMD_GET_CHANNEL, + CMD_SET_CHANNEL, + CMD_SET_FLOOD_SCOPE, + CMD_APP_START, + CMD_DEVICE_QUERY, + CMD_GET_BATT_AND_STORAGE, + CMD_GET_CONTACTS, + CMD_GET_STATS, + CMD_IMPORT_CONTACT, + CMD_REMOVE_CONTACT, + CMD_RESET_PATH, + CMD_SEND_CHANNEL_TXT_MSG, + CMD_SEND_LOGIN, + CMD_SEND_SELF_ADVERT, + CMD_SEND_TXT_MSG, + CMD_SET_ADVERT_LATLON, + CMD_SET_ADVERT_NAME, + CMD_SYNC_NEXT_MESSAGE, + ERR_CODE_BAD_STATE, + ERR_CODE_ILLEGAL_ARG, + ERR_CODE_NOT_FOUND, + ERR_CODE_UNSUPPORTED_CMD, + FRAME_INBOUND_PREFIX, + FRAME_OUTBOUND_PREFIX, + MAX_FRAME_SIZE, + MAX_PATH_SIZE, + PUB_KEY_SIZE, + PUSH_CODE_ADVERT, + PUSH_CODE_MSG_WAITING, + PUSH_CODE_PATH_UPDATED, + PUSH_CODE_SEND_CONFIRMED, + RESP_CODE_BATT_AND_STORAGE, + RESP_CODE_CHANNEL_INFO, + RESP_CODE_CHANNEL_MSG_RECV, + RESP_CODE_CHANNEL_MSG_RECV_V3, + RESP_CODE_CONTACT, + RESP_CODE_CONTACT_MSG_RECV_V3, + RESP_CODE_CONTACT_MSG_RECV, + RESP_CODE_CONTACTS_START, + RESP_CODE_END_OF_CONTACTS, + RESP_CODE_ERR, + RESP_CODE_NO_MORE_MESSAGES, + RESP_CODE_OK, + RESP_CODE_SELF_INFO, + RESP_CODE_SENT, + RESP_CODE_STATS, + STATS_TYPE_CORE, + STATS_TYPE_PACKETS, + STATS_TYPE_RADIO, +) + +logger = logging.getLogger("CompanionFrameServer") + + +class CompanionFrameServer: + """TCP server for the MeshCore companion frame protocol. + + One client per companion at a time. Listens on configured port. + """ + + def __init__( + self, + bridge, + companion_hash: str, + port: int = 5000, + bind_address: str = "0.0.0.0", + sqlite_handler=None, + ): + self.bridge = bridge + self.companion_hash = companion_hash + self.port = port + self.bind_address = bind_address + self.sqlite_handler = sqlite_handler + self._server: Optional[asyncio.Server] = None + self._client_writer: Optional[asyncio.StreamWriter] = None + self._client_reader: Optional[asyncio.StreamReader] = None + self._app_target_ver = 0 + + async def start(self) -> None: + """Start the TCP server.""" + self._server = await asyncio.start_server( + self._handle_client, + self.bind_address, + self.port, + ) + addr = self._server.sockets[0].getsockname() if self._server.sockets else (self.bind_address, self.port) + logger.info(f"Companion frame server listening on {addr[0]}:{addr[1]} (hash=0x{int(self.companion_hash):02x})") + + async def stop(self) -> None: + """Stop the TCP server and disconnect any client.""" + if self._client_writer: + try: + self._client_writer.close() + await self._client_writer.wait_closed() + except Exception: + pass + self._client_writer = None + self._client_reader = None + if self._server: + self._server.close() + await self._server.wait_closed() + self._server = None + logger.info(f"Companion frame server stopped (port={self.port})") + + def _setup_push_callbacks(self) -> None: + """Subscribe to bridge events and send PUSH frames to connected client.""" + + def _write_push(data: bytes) -> None: + if self._client_writer and not self._client_writer.is_closing(): + try: + frame = bytes([FRAME_OUTBOUND_PREFIX]) + struct.pack("= 32: + _write_push(bytes([PUSH_CODE_ADVERT]) + pubkey[:32]) + + async def on_contact_path_updated(pub_key, path_len, path): + if isinstance(pub_key, bytes) and len(pub_key) >= 32: + _write_push(bytes([PUSH_CODE_PATH_UPDATED]) + pub_key[:32]) + + async def on_channel_message_received(channel_name, sender_name, message_text, timestamp, path_len=0): + channel_idx = 0 + max_ch = getattr( + getattr(self.bridge, "channels", None), "max_channels", 40 + ) + for idx in range(max_ch): + ch = self.bridge.get_channel(idx) + if ch and ch.name == channel_name: + channel_idx = idx + break + if self.sqlite_handler: + self.sqlite_handler.companion_push_message( + self.companion_hash, + { + "sender_key": b"", + "text": message_text, + "timestamp": timestamp, + "txt_type": 0, + "is_channel": True, + "channel_idx": channel_idx, + "path_len": path_len, + }, + ) + _write_push(bytes([PUSH_CODE_MSG_WAITING])) + + self.bridge.on_message_received(on_message_received) + self.bridge.on_channel_message_received(on_channel_message_received) + self.bridge.on_send_confirmed(on_send_confirmed) + self.bridge.on_advert_received(on_advert_received) + self.bridge.on_contact_path_updated(on_contact_path_updated) + + async def _drain_writer(self) -> None: + if self._client_writer: + try: + await self._client_writer.drain() + except Exception: + pass + + def _write_frame(self, data: bytes) -> None: + """Send a frame to the connected client (outbound format).""" + if self._client_writer and not self._client_writer.is_closing(): + frame = bytes([FRAME_OUTBOUND_PREFIX]) + struct.pack(" None: + self._write_frame(bytes([RESP_CODE_OK])) + + def _write_err(self, err_code: int) -> None: + self._write_frame(bytes([RESP_CODE_ERR, err_code])) + + def _save_contacts(self) -> None: + """Persist contacts to SQLite.""" + if not self.sqlite_handler: + return + contacts = self.bridge.get_contacts() + dicts = [] + for c in contacts: + pk = c.public_key if isinstance(c.public_key, bytes) else bytes.fromhex(c.public_key) + dicts.append({ + "pubkey": pk, + "name": c.name, + "adv_type": c.adv_type, + "flags": c.flags, + "out_path_len": c.out_path_len, + "out_path": c.out_path if isinstance(c.out_path, bytes) else (bytes.fromhex(c.out_path) if c.out_path else b""), + "last_advert_timestamp": c.last_advert_timestamp, + "lastmod": c.lastmod, + "gps_lat": c.gps_lat, + "gps_lon": c.gps_lon, + "sync_since": c.sync_since, + }) + self.sqlite_handler.companion_save_contacts(self.companion_hash, dicts) + + async def _handle_client( + self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter + ) -> None: + """Handle a new client connection. One client at a time.""" + if self._client_writer: + logger.warning("Companion already has a client; rejecting new connection") + writer.close() + await writer.wait_closed() + return + + self._client_reader = reader + self._client_writer = writer + self._setup_push_callbacks() + logger.info(f"Companion client connected (port={self.port})") + + try: + while True: + prefix = await reader.read(1) + if not prefix: + break + if prefix[0] != FRAME_INBOUND_PREFIX: + logger.warning(f"Invalid frame prefix: 0x{prefix[0]:02x}") + continue + len_bytes = await reader.readexactly(2) + frame_len = struct.unpack(" MAX_FRAME_SIZE: + logger.warning(f"Frame too long: {frame_len}") + break + payload = await reader.readexactly(frame_len) + await self._handle_cmd(payload) + except asyncio.IncompleteReadError: + pass + except ConnectionResetError: + pass + except Exception as e: + logger.error(f"Client handler error: {e}", exc_info=True) + finally: + self._client_writer = None + self._client_reader = None + logger.info(f"Companion client disconnected (port={self.port})") + + async def _handle_cmd(self, payload: bytes) -> None: + """Dispatch command to handler.""" + if not payload: + return + cmd = payload[0] + data = payload[1:] + if cmd in (CMD_GET_CHANNEL, CMD_SET_CHANNEL): + logger.debug(f"Companion cmd 0x{cmd:02x} ({'GET_CHANNEL' if cmd == CMD_GET_CHANNEL else 'SET_CHANNEL'}), payload_len={len(payload)}") + + try: + if cmd == CMD_APP_START: + await self._cmd_app_start(data) + elif cmd == CMD_DEVICE_QUERY: + await self._cmd_device_query(data) + elif cmd == CMD_GET_CONTACTS: + await self._cmd_get_contacts(data) + elif cmd == CMD_SEND_TXT_MSG: + await self._cmd_send_txt_msg(data) + elif cmd == CMD_SEND_CHANNEL_TXT_MSG: + await self._cmd_send_channel_txt_msg(data) + elif cmd == CMD_SYNC_NEXT_MESSAGE: + await self._cmd_sync_next_message(data) + elif cmd == CMD_SEND_LOGIN: + await self._cmd_send_login(data) + elif cmd == CMD_SEND_SELF_ADVERT: + await self._cmd_send_self_advert(data) + elif cmd == CMD_SET_ADVERT_NAME: + await self._cmd_set_advert_name(data) + elif cmd == CMD_SET_ADVERT_LATLON: + await self._cmd_set_advert_latlon(data) + elif cmd == CMD_ADD_UPDATE_CONTACT: + await self._cmd_add_update_contact(data) + elif cmd == CMD_REMOVE_CONTACT: + await self._cmd_remove_contact(data) + elif cmd == CMD_RESET_PATH: + await self._cmd_reset_path(data) + elif cmd == CMD_GET_BATT_AND_STORAGE: + await self._cmd_get_batt_and_storage(data) + elif cmd == CMD_GET_STATS: + await self._cmd_get_stats(data) + elif cmd == CMD_IMPORT_CONTACT: + await self._cmd_import_contact(data) + elif cmd == CMD_GET_CHANNEL: + await self._cmd_get_channel(data) + elif cmd == CMD_SET_CHANNEL: + await self._cmd_set_channel(data) + elif cmd == CMD_SET_FLOOD_SCOPE: + # App sends this on connect; no-op for repeater companion (no radio scope) + self._write_ok() + else: + self._write_err(ERR_CODE_UNSUPPORTED_CMD) + except Exception as e: + logger.error(f"Cmd 0x{cmd:02x} error: {e}", exc_info=True) + self._write_err(ERR_CODE_ILLEGAL_ARG) + + async def _cmd_app_start(self, data: bytes) -> None: + if len(data) >= 1: + self._app_target_ver = data[0] + # RESP_CODE_SELF_INFO - name is varchar (remainder of frame) + # Send name without null terminator; client displays remainder of frame as-is + prefs = self.bridge.get_self_info() + pubkey = self.bridge.get_public_key() + name = prefs.node_name.encode("utf-8", errors="replace") + lat = int(getattr(prefs, "latitude", 0) * 1e6) + lon = int(getattr(prefs, "longitude", 0) * 1e6) + frame = ( + bytes([RESP_CODE_SELF_INFO, ADV_TYPE_CHAT, prefs.tx_power_dbm, 22]) + + pubkey + + struct.pack(" None: + if len(data) >= 1: + self._app_target_ver = data[0] + firmware_ver = 8 + # Protocol: max_contacts_div_2 and max_channels are bytes (ver 3+) + max_contacts = getattr( + getattr(self.bridge, "contacts", None), "max_contacts", 1000 + ) + max_channels_val = getattr( + getattr(self.bridge, "channels", None), "max_channels", 40 + ) + max_contacts_div_2 = min(max_contacts // 2, 255) + max_channels = min(max_channels_val, 255) + ble_pin = 0 + build_date = b"13 Feb 2026\x00"[:12].ljust(12, b"\x00") + model = b"pyMC-Repeater-Companion\x00"[:40].ljust(40, b"\x00") + version = b"1.0.0\x00"[:20].ljust(20, b"\x00") + frame = ( + bytes([RESP_CODE_DEVICE_INFO, firmware_ver, max_contacts_div_2, max_channels]) + + struct.pack(" None: + since = struct.unpack("= 4 else 0 + contacts = self.bridge.get_contacts(since=since) + self._write_frame(bytes([RESP_CODE_CONTACTS_START]) + struct.pack(" 0xFF, else 0-255 + opl = c.out_path_len if hasattr(c, "out_path_len") else -1 + opl_byte = 0xFF if opl < 0 else min(opl, 255) + frame = ( + bytes([RESP_CODE_CONTACT]) + + pubkey + + bytes([c.adv_type if hasattr(c, "adv_type") else 0, c.flags if hasattr(c, "flags") else 0]) + + bytes([opl_byte]) + + (c.out_path[:MAX_PATH_SIZE] if hasattr(c, "out_path") and c.out_path else b"").ljust(MAX_PATH_SIZE, b"\x00") + + name + + struct.pack(" None: + if len(data) < 12: + self._write_err(ERR_CODE_ILLEGAL_ARG) + return + txt_type = data[0] + attempt = data[1] + sender_ts = struct.unpack_from(" None: + if len(data) < 6: + self._write_err(ERR_CODE_ILLEGAL_ARG) + return + # Protocol: txt_type(1) + channel_idx(1) + sender_timestamp(4) + text + channel_idx = data[1] + sender_ts = struct.unpack_from(" None: + msg = self.bridge.sync_next_message() + if msg is None and self.sqlite_handler: + msg_dict = self.sqlite_handler.companion_pop_message(self.companion_hash) + if msg_dict: + msg = QueuedMessage( + sender_key=msg_dict.get("sender_key", b""), + txt_type=msg_dict.get("txt_type", 0), + timestamp=msg_dict.get("timestamp", 0), + text=msg_dict.get("text", ""), + is_channel=bool(msg_dict.get("is_channel", False)), + channel_idx=msg_dict.get("channel_idx", 0), + path_len=msg_dict.get("path_len", 0), + ) + if msg is None: + self._write_frame(bytes([RESP_CODE_NO_MORE_MESSAGES])) + return + if msg.is_channel: + path_len_byte = msg.path_len if msg.path_len < 256 else 0xFF + txt_type = 0 # TXT_TYPE_PLAIN + text_bytes = msg.text.encode("utf-8", errors="replace") + if self._app_target_ver >= 3: + # RESP_CODE_CHANNEL_MSG_RECV_V3: code + snr + reserved(2) + channel_idx + path_len + txt_type + timestamp + text + frame = bytes([ + RESP_CODE_CHANNEL_MSG_RECV_V3, + 0, # snr (we don't have it when popping from queue) + 0, 0, # reserved + msg.channel_idx, + path_len_byte, + txt_type, + ]) + struct.pack("= 6 else msg.sender_key.ljust(6, b"\x00") + path_len_byte = msg.path_len if msg.path_len < 256 else 0xFF + text_bytes = msg.text.encode("utf-8", errors="replace") + if self._app_target_ver >= 3: + frame = bytes([ + RESP_CODE_CONTACT_MSG_RECV_V3, + 0, 0, 0, # snr + reserved + ]) + prefix + bytes([path_len_byte, msg.txt_type]) + struct.pack(" None: + if len(data) < 33: + self._write_err(ERR_CODE_ILLEGAL_ARG) + return + pubkey = data[:32] + password = data[32:].decode("utf-8", errors="replace").rstrip("\x00") + self._write_frame(bytes([RESP_CODE_SENT, 1]) + struct.pack(" None: + flood = len(data) >= 1 and data[0] == 1 + ok = await self.bridge.advertise(flood=flood) + self._write_ok() if ok else self._write_err(ERR_CODE_BAD_STATE) + + async def _cmd_set_advert_name(self, data: bytes) -> None: + name = data.decode("utf-8", errors="replace").rstrip("\x00") + self.bridge.set_advert_name(name) + self._write_ok() + + async def _cmd_set_advert_latlon(self, data: bytes) -> None: + if len(data) < 8: + self._write_err(ERR_CODE_ILLEGAL_ARG) + return + lat, lon = struct.unpack_from(" None: + if len(data) < 73: + self._write_err(ERR_CODE_ILLEGAL_ARG) + return + pubkey = data[0:32] + adv_type = data[32] + flags = data[33] + out_path_len = data[34] + out_path = data[35:35 + MAX_PATH_SIZE] + name_raw = data[35 + MAX_PATH_SIZE:35 + MAX_PATH_SIZE + 32] + name = name_raw.split(b"\x00")[0].decode("utf-8", errors="replace") + last_advert = struct.unpack_from("= 35 + MAX_PATH_SIZE + 32 + 12: + gps_lat = struct.unpack_from(" None: + if len(data) < 32: + self._write_err(ERR_CODE_ILLEGAL_ARG) + return + pubkey = data[:32] + ok = self.bridge.remove_contact(pubkey) + if ok and self.sqlite_handler: + self._save_contacts() + self._write_ok() if ok else self._write_err(ERR_CODE_NOT_FOUND) + + async def _cmd_reset_path(self, data: bytes) -> None: + if len(data) < 32: + self._write_err(ERR_CODE_ILLEGAL_ARG) + return + pubkey = data[:32] + ok = self.bridge.reset_path(pubkey) + self._write_ok() if ok else self._write_err(ERR_CODE_NOT_FOUND) + + async def _cmd_get_batt_and_storage(self, data: bytes) -> None: + millivolts = 0 + used_kb = 0 + total_kb = 0 + frame = bytes([RESP_CODE_BATT_AND_STORAGE]) + struct.pack(" None: + stats_type = data[0] if len(data) >= 1 else STATS_TYPE_PACKETS + stats = self.bridge.get_stats(stats_type) + frame = bytes([RESP_CODE_STATS, stats_type]) + if stats_type == STATS_TYPE_CORE: + frame += struct.pack(" None: + ok = self.bridge.import_contact(data) + self._write_ok() if ok else self._write_err(ERR_CODE_ILLEGAL_ARG) + + async def _cmd_get_channel(self, data: bytes) -> None: + # Payload: channel index (1 byte), or empty for "get full list" (some apps send + # one request with no payload to receive all channels in one go). + channel_idx = data[0] if len(data) >= 1 else 0 + get_full_list = len(data) == 0 + max_channels_val = getattr( + getattr(self.bridge, "channels", None), "max_channels", 40 + ) + logger.debug( + f"CMD_GET_CHANNEL: idx={channel_idx}, data_len={len(data)}, get_full_list={get_full_list}" + ) + + def _channel_info_frame(idx: int, ch, include_idx: bool = True) -> bytes: + if ch is None: + name = b"\x00" * 32 + secret = b"\x00" * 32 + else: + name = ch.name.encode("utf-8", errors="replace")[:32].ljust( + 32, b"\x00" + ) + secret = ( + ch.secret[:32].ljust(32, b"\x00") if ch.secret else b"\x00" * 32 + ) + if include_idx: + return bytes([RESP_CODE_CHANNEL_INFO, idx]) + name + secret + # Some apps expect code + name(32) + secret(32) only (no index byte) + return bytes([RESP_CODE_CHANNEL_INFO]) + name + secret + + if get_full_list: + for idx in range(max_channels_val): + ch = self.bridge.get_channel(idx) + frame = _channel_info_frame(idx, ch, include_idx=True) + self._write_frame(frame) + logger.debug(f"CMD_GET_CHANNEL: sent full list ({max_channels_val} slots)") + return + + if channel_idx < 0 or channel_idx >= max_channels_val: + logger.debug(f"CMD_GET_CHANNEL: channel {channel_idx} out of range") + self._write_err(ERR_CODE_NOT_FOUND) + return + ch = self.bridge.get_channel(channel_idx) + if ch is None: + logger.debug(f"CMD_GET_CHANNEL: returning empty slot {channel_idx}") + else: + logger.debug(f"CMD_GET_CHANNEL: returning {ch.name!r}, secret_len=32") + # Send code + name(32) + secret(32) without channel_idx so the app sees + # name at offset 1 (avoids app treating channel_idx as first byte of name). + frame = _channel_info_frame(channel_idx, ch, include_idx=False) + self._write_frame(frame) + + async def _cmd_set_channel(self, data: bytes) -> None: + # MeshCore format: channel_idx(1) + name(32) + secret(32) or secret_hex(64) + logger.debug(f"CMD_SET_CHANNEL: data_len={len(data)}, data_hex={data[:50].hex()}...") + if len(data) < 34: # minimum: idx + name(32) + at least 1 byte secret + logger.debug(f"CMD_SET_CHANNEL: rejected (len {len(data)} < 34)") + self._write_err(ERR_CODE_ILLEGAL_ARG) + return + channel_idx = data[0] + name_raw = data[1:33] + name = name_raw.split(b"\x00")[0].decode("utf-8", errors="replace").strip() + if len(data) >= 97: + # Hex secret: 64 hex chars = 32 bytes + try: + secret = bytes.fromhex(data[33:97].decode("ascii")) + logger.debug(f"CMD_SET_CHANNEL: parsed hex secret, len={len(secret)}") + except (ValueError, UnicodeDecodeError) as e: + logger.debug(f"CMD_SET_CHANNEL: hex secret parse failed: {e}") + self._write_err(ERR_CODE_ILLEGAL_ARG) + return + elif len(data) >= 65: + # Binary secret: 32 bytes (MeshCore DataStore format) + secret = data[33:65] + logger.debug(f"CMD_SET_CHANNEL: parsed 32-byte binary secret") + elif len(data) >= 49: + # Legacy: 16-byte binary secret + secret = data[33:49] + logger.debug(f"CMD_SET_CHANNEL: parsed 16-byte binary secret") + else: + logger.debug(f"CMD_SET_CHANNEL: rejected (len {len(data)} not in 49/65/97)") + self._write_err(ERR_CODE_ILLEGAL_ARG) + return + logger.debug(f"CMD_SET_CHANNEL: idx={channel_idx}, name={name!r}, secret_len={len(secret)}") + ok = self.bridge.set_channel(channel_idx, name, secret) + if ok and self.sqlite_handler: + self._save_channels() + logger.debug(f"CMD_SET_CHANNEL: set_channel ok={ok}") + + self._write_ok() if ok else self._write_err(ERR_CODE_TABLE_FULL) + + def _save_channels(self) -> None: + """Persist channels to SQLite.""" + if not self.sqlite_handler: + return + channels = [] + max_ch = getattr( + getattr(self.bridge, "channels", None), "max_channels", 40 + ) + for idx in range(max_ch): + ch = self.bridge.get_channel(idx) + if ch is not None: + channels.append({ + "channel_idx": idx, + "name": ch.name, + "secret": ch.secret, + }) + self.sqlite_handler.companion_save_channels(self.companion_hash, channels) diff --git a/repeater/config_manager.py b/repeater/config_manager.py index b4c13f8..e2457a3 100644 --- a/repeater/config_manager.py +++ b/repeater/config_manager.py @@ -1,7 +1,9 @@ +from __future__ import annotations + import logging import os import yaml -from typing import Optional, Dict, Any, List +from typing import Any, Dict, List, Optional logger = logging.getLogger("ConfigManager") @@ -22,32 +24,35 @@ class ConfigManager: self.config = config self.daemon = daemon_instance - def save_to_file(self) -> bool: + def save_to_file(self) -> tuple[bool, str]: """ Save current config to YAML file. - + Returns: - True if successful, False otherwise + (True, "") if successful, (False, error_message) otherwise """ try: - os.makedirs(os.path.dirname(self.config_path), exist_ok=True) + dirpath = os.path.dirname(self.config_path) + if dirpath: + os.makedirs(dirpath, exist_ok=True) with open(self.config_path, 'w') as f: # Use safe_dump with explicit width to prevent line wrapping # Setting width to a very large number prevents truncation of long strings like identity keys yaml.safe_dump( - self.config, - f, - default_flow_style=False, - indent=2, + self.config, + f, + default_flow_style=False, + indent=2, width=1000000, # Very large width to prevent any line wrapping sort_keys=False, allow_unicode=True ) logger.info(f"Configuration saved to {self.config_path}") - return True + return True, "" except Exception as e: - logger.error(f"Failed to save config to {self.config_path}: {e}", exc_info=True) - return False + msg = f"Failed to save config to {self.config_path}: {e}" + logger.error(msg, exc_info=True) + return False, str(e) def live_update_daemon(self, sections: Optional[List[str]] = None) -> bool: """ @@ -140,10 +145,11 @@ class ConfigManager: self.config[section] = values # Save to file - result["saved"] = self.save_to_file() - + saved, err = self.save_to_file() + result["saved"] = saved + if not result["saved"]: - result["error"] = "Failed to save config to file" + result["error"] = err or "Failed to save config to file" return result # Live update daemon if requested diff --git a/repeater/data_acquisition/sqlite_handler.py b/repeater/data_acquisition/sqlite_handler.py index 0f198c9..21ebca5 100644 --- a/repeater/data_acquisition/sqlite_handler.py +++ b/repeater/data_acquisition/sqlite_handler.py @@ -249,9 +249,75 @@ class SQLiteHandler: (migration_name, time.time()) ) logger.info(f"Migration '{migration_name}' applied successfully") - + + # Migration 4: Add companion tables for companion identity persistence + migration_name = "add_companion_tables" + existing = conn.execute( + "SELECT migration_name FROM migrations WHERE migration_name = ?", + (migration_name,) + ).fetchone() + + if not existing: + cursor = conn.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name='companion_contacts'" + ) + if not cursor.fetchone(): + conn.execute(""" + CREATE TABLE companion_contacts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + companion_hash TEXT NOT NULL, + pubkey BLOB NOT NULL, + name TEXT NOT NULL, + adv_type INTEGER NOT NULL DEFAULT 0, + flags INTEGER NOT NULL DEFAULT 0, + out_path_len INTEGER NOT NULL DEFAULT -1, + out_path BLOB, + last_advert_timestamp INTEGER NOT NULL DEFAULT 0, + lastmod INTEGER NOT NULL DEFAULT 0, + gps_lat REAL NOT NULL DEFAULT 0, + gps_lon REAL NOT NULL DEFAULT 0, + sync_since INTEGER NOT NULL DEFAULT 0, + updated_at REAL NOT NULL + ) + """) + conn.execute(""" + CREATE TABLE companion_channels ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + companion_hash TEXT NOT NULL, + channel_idx INTEGER NOT NULL, + name TEXT NOT NULL, + secret BLOB NOT NULL, + updated_at REAL NOT NULL + ) + """) + conn.execute(""" + CREATE TABLE companion_messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + companion_hash TEXT NOT NULL, + sender_key BLOB NOT NULL, + txt_type INTEGER NOT NULL DEFAULT 0, + timestamp INTEGER NOT NULL DEFAULT 0, + text TEXT NOT NULL, + is_channel INTEGER NOT NULL DEFAULT 0, + channel_idx INTEGER NOT NULL DEFAULT 0, + path_len INTEGER NOT NULL DEFAULT 0, + created_at REAL NOT NULL + ) + """) + conn.execute("CREATE INDEX IF NOT EXISTS idx_companion_contacts_hash ON companion_contacts(companion_hash)") + conn.execute("CREATE INDEX IF NOT EXISTS idx_companion_contacts_pubkey ON companion_contacts(companion_hash, pubkey)") + conn.execute("CREATE INDEX IF NOT EXISTS idx_companion_channels_hash ON companion_channels(companion_hash)") + conn.execute("CREATE INDEX IF NOT EXISTS idx_companion_messages_hash ON companion_messages(companion_hash)") + logger.info("Created companion_contacts, companion_channels, companion_messages tables") + + conn.execute( + "INSERT INTO migrations (migration_name, applied_at) VALUES (?, ?)", + (migration_name, time.time()) + ) + logger.info(f"Migration '{migration_name}' applied successfully") + conn.commit() - + except Exception as e: logger.error(f"Failed to run migrations: {e}") @@ -1344,3 +1410,151 @@ class SQLiteHandler: except Exception as e: logger.error(f"Failed to cleanup old messages: {e}") return 0 + + # Companion persistence methods + def companion_load_contacts(self, companion_hash: str) -> List[Dict]: + """Load contacts for a companion from storage.""" + try: + with sqlite3.connect(self.sqlite_path) as conn: + conn.row_factory = sqlite3.Row + cursor = conn.execute(""" + SELECT pubkey, name, adv_type, flags, out_path_len, out_path, + last_advert_timestamp, lastmod, gps_lat, gps_lon, sync_since + FROM companion_contacts WHERE companion_hash = ? + """, (companion_hash,)) + return [dict(row) for row in cursor.fetchall()] + except Exception as e: + logger.error(f"Failed to load companion contacts: {e}") + return [] + + def companion_save_contacts(self, companion_hash: str, contacts: List[Dict]) -> bool: + """Replace all contacts for a companion in storage.""" + try: + with sqlite3.connect(self.sqlite_path) as conn: + conn.execute("DELETE FROM companion_contacts WHERE companion_hash = ?", (companion_hash,)) + now = time.time() + for c in contacts: + conn.execute(""" + INSERT INTO companion_contacts + (companion_hash, pubkey, name, adv_type, flags, out_path_len, out_path, + last_advert_timestamp, lastmod, gps_lat, gps_lon, sync_since, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, ( + companion_hash, + c.get("pubkey", b""), + c.get("name", ""), + c.get("adv_type", 0), + c.get("flags", 0), + c.get("out_path_len", -1), + c.get("out_path", b""), + c.get("last_advert_timestamp", 0), + c.get("lastmod", 0), + c.get("gps_lat", 0.0), + c.get("gps_lon", 0.0), + c.get("sync_since", 0), + now, + )) + conn.commit() + return True + except Exception as e: + logger.error(f"Failed to save companion contacts: {e}") + return False + + def companion_load_channels(self, companion_hash: str) -> List[Dict]: + """Load channels for a companion from storage.""" + try: + with sqlite3.connect(self.sqlite_path) as conn: + conn.row_factory = sqlite3.Row + cursor = conn.execute(""" + SELECT channel_idx, name, secret FROM companion_channels + WHERE companion_hash = ? ORDER BY channel_idx + """, (companion_hash,)) + return [dict(row) for row in cursor.fetchall()] + except Exception as e: + logger.error(f"Failed to load companion channels: {e}") + return [] + + def companion_save_channels(self, companion_hash: str, channels: List[Dict]) -> bool: + """Replace all channels for a companion in storage.""" + try: + with sqlite3.connect(self.sqlite_path) as conn: + conn.execute("DELETE FROM companion_channels WHERE companion_hash = ?", (companion_hash,)) + now = time.time() + for ch in channels: + conn.execute(""" + INSERT INTO companion_channels + (companion_hash, channel_idx, name, secret, updated_at) + VALUES (?, ?, ?, ?, ?) + """, ( + companion_hash, + ch.get("channel_idx", 0), + ch.get("name", ""), + ch.get("secret", b""), + now, + )) + conn.commit() + return True + except Exception as e: + logger.error(f"Failed to save companion channels: {e}") + return False + + def companion_load_messages(self, companion_hash: str, limit: int = 100) -> List[Dict]: + """Load queued messages for a companion (oldest first for queue order).""" + try: + with sqlite3.connect(self.sqlite_path) as conn: + conn.row_factory = sqlite3.Row + cursor = conn.execute(""" + SELECT sender_key, txt_type, timestamp, text, is_channel, channel_idx, path_len + FROM companion_messages WHERE companion_hash = ? + ORDER BY created_at ASC LIMIT ? + """, (companion_hash, limit)) + return [dict(row) for row in cursor.fetchall()] + except Exception as e: + logger.error(f"Failed to load companion messages: {e}") + return [] + + def companion_push_message(self, companion_hash: str, msg: Dict) -> bool: + """Append a message to the companion's queue.""" + try: + with sqlite3.connect(self.sqlite_path) as conn: + conn.execute(""" + INSERT INTO companion_messages + (companion_hash, sender_key, txt_type, timestamp, text, is_channel, channel_idx, path_len, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + """, ( + companion_hash, + msg.get("sender_key", b""), + msg.get("txt_type", 0), + msg.get("timestamp", 0), + msg.get("text", ""), + int(msg.get("is_channel", False)), + msg.get("channel_idx", 0), + msg.get("path_len", 0), + time.time(), + )) + conn.commit() + return True + except Exception as e: + logger.error(f"Failed to push companion message: {e}") + return False + + def companion_pop_message(self, companion_hash: str) -> Optional[Dict]: + """Remove and return the oldest message from the companion's queue.""" + try: + with sqlite3.connect(self.sqlite_path) as conn: + conn.row_factory = sqlite3.Row + cursor = conn.execute(""" + SELECT id, sender_key, txt_type, timestamp, text, is_channel, channel_idx, path_len + FROM companion_messages WHERE companion_hash = ? + ORDER BY created_at ASC LIMIT 1 + """, (companion_hash,)) + row = cursor.fetchone() + if not row: + return None + msg = dict(row) + conn.execute("DELETE FROM companion_messages WHERE id = ?", (msg["id"],)) + conn.commit() + return {k: v for k, v in msg.items() if k != "id"} + except Exception as e: + logger.error(f"Failed to pop companion message: {e}") + return None diff --git a/repeater/handler_helpers/mesh_cli.py b/repeater/handler_helpers/mesh_cli.py index 63600c0..2f7dd12 100644 --- a/repeater/handler_helpers/mesh_cli.py +++ b/repeater/handler_helpers/mesh_cli.py @@ -197,7 +197,10 @@ class MeshCLI: # Save config and live update try: - self.config_manager.save_to_file() + saved, err = self.config_manager.save_to_file() + if not saved: + logger.error(f"Failed to save password: {err}") + return f"Error: Failed to save config: {err}" self.config_manager.live_update_daemon(['security']) return f"password now: {new_password}" except Exception as e: @@ -337,32 +340,32 @@ class MeshCLI: try: if key == "af": self.repeater_config['airtime_factor'] = float(value) - self.config_manager.save_to_file() + saved, _ = self.config_manager.save_to_file() self.config_manager.live_update_daemon(['repeater']) return "OK" elif key == "name": self.repeater_config['node_name'] = value - self.config_manager.save_to_file() + saved, _ = self.config_manager.save_to_file() self.config_manager.live_update_daemon(['repeater']) return "OK" elif key == "repeat": disabled = value.lower() == "off" self.repeater_config['disable_forward'] = disabled - self.config_manager.save_to_file() + saved, _ = self.config_manager.save_to_file() self.config_manager.live_update_daemon(['repeater']) return f"OK - repeat is now {'OFF' if disabled else 'ON'}" elif key == "lat": self.repeater_config['latitude'] = float(value) - self.config_manager.save_to_file() + saved, _ = self.config_manager.save_to_file() self.config_manager.live_update_daemon(['repeater']) return "OK" elif key == "lon": self.repeater_config['longitude'] = float(value) - self.config_manager.save_to_file() + saved, _ = self.config_manager.save_to_file() self.config_manager.live_update_daemon(['repeater']) return "OK" @@ -379,7 +382,7 @@ class MeshCLI: self.config['radio']['bandwidth'] = float(radio_parts[1]) self.config['radio']['spreading_factor'] = int(radio_parts[2]) self.config['radio']['coding_rate'] = int(radio_parts[3]) - self.config_manager.save_to_file() + saved, _ = self.config_manager.save_to_file() self.config_manager.live_update_daemon(['radio']) return "OK - restart repeater to apply" @@ -387,7 +390,7 @@ class MeshCLI: if 'radio' not in self.config: self.config['radio'] = {} self.config['radio']['frequency'] = float(value) - self.config_manager.save_to_file() + saved, _ = self.config_manager.save_to_file() self.config_manager.live_update_daemon(['radio']) return "OK - restart repeater to apply" @@ -395,7 +398,7 @@ class MeshCLI: if 'radio' not in self.config: self.config['radio'] = {} self.config['radio']['tx_power'] = int(value) - self.config_manager.save_to_file() + saved, _ = self.config_manager.save_to_file() self.config_manager.live_update_daemon(['radio']) return "OK" @@ -403,7 +406,7 @@ class MeshCLI: if 'security' not in self.config: self.config['security'] = {} self.config['security']['guest_password'] = value - self.config_manager.save_to_file() + saved, _ = self.config_manager.save_to_file() self.config_manager.live_update_daemon(['security']) return "OK" @@ -411,7 +414,7 @@ class MeshCLI: if 'security' not in self.config: self.config['security'] = {} self.config['security']['allow_read_only'] = value.lower() == "on" - self.config_manager.save_to_file() + saved, _ = self.config_manager.save_to_file() self.config_manager.live_update_daemon(['security']) return "OK" @@ -420,7 +423,7 @@ class MeshCLI: if mins > 0 and (mins < 60 or mins > 240): return "Error: interval range is 60-240 minutes" self.repeater_config['advert_interval_minutes'] = mins - self.config_manager.save_to_file() + saved, _ = self.config_manager.save_to_file() self.config_manager.live_update_daemon(['repeater']) return "OK" @@ -429,7 +432,7 @@ class MeshCLI: if (hours > 0 and hours < 3) or hours > 48: return "Error: interval range is 3-48 hours" self.repeater_config['flood_advert_interval_hours'] = hours - self.config_manager.save_to_file() + saved, _ = self.config_manager.save_to_file() self.config_manager.live_update_daemon(['repeater']) return "OK" @@ -438,7 +441,7 @@ class MeshCLI: if max_val > 64: return "Error: max 64" self.repeater_config['max_flood_hops'] = max_val - self.config_manager.save_to_file() + saved, _ = self.config_manager.save_to_file() self.config_manager.live_update_daemon(['repeater']) return "OK" @@ -447,7 +450,7 @@ class MeshCLI: if delay < 0: return "Error: cannot be negative" self.repeater_config['rx_delay_base'] = delay - self.config_manager.save_to_file() + saved, _ = self.config_manager.save_to_file() self.config_manager.live_update_daemon(['repeater', 'delays']) return "OK" @@ -456,7 +459,7 @@ class MeshCLI: if delay < 0: return "Error: cannot be negative" self.repeater_config['tx_delay_factor'] = delay - self.config_manager.save_to_file() + saved, _ = self.config_manager.save_to_file() self.config_manager.live_update_daemon(['repeater', 'delays']) return "OK" @@ -465,19 +468,19 @@ class MeshCLI: if delay < 0: return "Error: cannot be negative" self.repeater_config['direct_tx_delay_factor'] = delay - self.config_manager.save_to_file() + saved, _ = self.config_manager.save_to_file() self.config_manager.live_update_daemon(['repeater', 'delays']) return "OK" elif key == "multi.acks": self.repeater_config['multi_acks'] = int(value) - self.config_manager.save_to_file() + saved, _ = self.config_manager.save_to_file() self.config_manager.live_update_daemon(['repeater']) return "OK" elif key == "int.thresh": self.repeater_config['interference_threshold'] = int(value) - self.config_manager.save_to_file() + saved, _ = self.config_manager.save_to_file() self.config_manager.live_update_daemon(['repeater']) return "OK" @@ -486,7 +489,7 @@ class MeshCLI: # Round to nearest multiple of 4 rounded = (interval // 4) * 4 self.repeater_config['agc_reset_interval'] = rounded - self.config_manager.save_to_file() + saved, _ = self.config_manager.save_to_file() self.config_manager.live_update_daemon(['repeater']) return f"OK - interval rounded to {rounded}" diff --git a/repeater/main.py b/repeater/main.py index 365e7f9..41cb33e 100644 --- a/repeater/main.py +++ b/repeater/main.py @@ -36,6 +36,8 @@ class RepeaterDaemon: self.protocol_request_helper = None self.acl = None self.router = None + self.companion_bridges: dict[int, object] = {} + self.companion_frame_servers: list = [] log_level = config.get("logging", {}).get("level", "INFO") @@ -256,6 +258,9 @@ class RepeaterDaemon: ) logger.info("Protocol request handler initialized") + # Load companion identities (CompanionBridge + frame server per companion) + await self._load_companion_identities() + except Exception as e: logger.error(f"Failed to initialize dispatcher: {e}") raise @@ -319,6 +324,136 @@ class RepeaterDaemon: total_identities = len(self.identity_manager.list_identities()) logger.info(f"Identity manager loaded {total_identities} total identities") + async def _load_companion_identities(self) -> None: + """Load companion identities from config and create CompanionBridge + frame server for each.""" + from pymc_core import LocalIdentity + from pymc_core.companion import CompanionBridge + from pymc_core.companion.models import Contact, Channel + + from repeater.companion import CompanionFrameServer + + companions_config = self.config.get("identities", {}).get("companions") or [] + if not companions_config: + return + + sqlite_handler = None + if self.repeater_handler and self.repeater_handler.storage: + sqlite_handler = self.repeater_handler.storage.sqlite_handler + + radio_config = self.repeater_handler.radio_config if self.repeater_handler else self.config.get("radio", {}) + + for comp_config in companions_config: + try: + name = comp_config.get("name") + identity_key = comp_config.get("identity_key") + settings = comp_config.get("settings") or {} + + if not name or not identity_key: + logger.warning("Skipping companion config: missing name or identity_key") + continue + + if isinstance(identity_key, str): + try: + identity_key_bytes = bytes.fromhex(identity_key) + except ValueError as e: + logger.error(f"Companion '{name}' identity_key invalid hex: {e}") + continue + elif isinstance(identity_key, bytes): + identity_key_bytes = identity_key + else: + logger.error(f"Companion '{name}' identity_key has unknown type") + continue + + if len(identity_key_bytes) not in (32, 64): + logger.error( + f"Companion '{name}' identity_key must be 32 bytes (hex) or 64 bytes (MeshCore firmware key)" + ) + continue + + identity = LocalIdentity(seed=identity_key_bytes) + pubkey = identity.get_public_key() + companion_hash = pubkey[0] + companion_hash_str = f"{companion_hash:02x}" + + node_name = settings.get("node_name", name) + tcp_port = settings.get("tcp_port", 5000) + bind_address = settings.get("bind_address", "0.0.0.0") + + bridge = CompanionBridge( + identity=identity, + packet_injector=self.router.inject_packet, + node_name=node_name, + radio_config=radio_config, + ) + + # Load contacts from SQLite + if sqlite_handler: + contact_rows = sqlite_handler.companion_load_contacts(companion_hash_str) + if contact_rows: + records = [] + for row in contact_rows: + d = dict(row) + d["public_key"] = d.pop("pubkey", d.get("public_key", b"")) + records.append(d) + bridge.contacts.load_from_dicts(records) + + # Load channels from SQLite + channel_rows = sqlite_handler.companion_load_channels(companion_hash_str) + for row in channel_rows: + ch = Channel( + name=row.get("name", ""), + secret=row.get("secret", b"") if isinstance(row.get("secret"), bytes) else (bytes.fromhex(row.get("secret", "")) if row.get("secret") else b""), + ) + bridge.channels.set(row.get("channel_idx", 0), ch) + + # Preload queued messages from SQLite into bridge + for msg_dict in sqlite_handler.companion_load_messages(companion_hash_str): + from pymc_core.companion.models import QueuedMessage + sk = msg_dict.get("sender_key", b"") + if isinstance(sk, str): + sk = bytes.fromhex(sk) + bridge.message_queue.push(QueuedMessage( + sender_key=sk, + txt_type=msg_dict.get("txt_type", 0), + timestamp=msg_dict.get("timestamp", 0), + text=msg_dict.get("text", ""), + is_channel=bool(msg_dict.get("is_channel", False)), + channel_idx=msg_dict.get("channel_idx", 0), + path_len=msg_dict.get("path_len", 0), + )) + + # Ensure public channel (0) exists with default key for new companions + from repeater.companion.constants import DEFAULT_PUBLIC_CHANNEL_SECRET + if bridge.get_channel(0) is None: + bridge.set_channel(0, "Public", DEFAULT_PUBLIC_CHANNEL_SECRET) + + self.companion_bridges[companion_hash] = bridge + + frame_server = CompanionFrameServer( + bridge=bridge, + companion_hash=companion_hash_str, + port=tcp_port, + bind_address=bind_address, + sqlite_handler=sqlite_handler, + ) + await frame_server.start() + self.companion_frame_servers.append(frame_server) + + self.identity_manager.register_identity( + name=name, + identity=identity, + config=comp_config, + identity_type="companion", + ) + + logger.info( + f"Loaded companion '{name}': hash=0x{companion_hash:02x}, " + f"port={tcp_port}, bind={bind_address}" + ) + + except Exception as e: + logger.error(f"Failed to load companion '{name}': {e}", exc_info=True) + def _register_identity_everywhere( self, name: str, @@ -509,6 +644,18 @@ class RepeaterDaemon: await self.dispatcher.run_forever() except KeyboardInterrupt: logger.info("Shutting down...") + for frame_server in getattr(self, "companion_frame_servers", []): + try: + await frame_server.stop() + except Exception as e: + logger.debug(f"Companion frame server stop: {e}") + if hasattr(self, "companion_bridges"): + for bridge in self.companion_bridges.values(): + if hasattr(bridge, "stop"): + try: + await bridge.stop() + except Exception as e: + logger.debug(f"Companion bridge stop: {e}") if self.router: await self.router.stop() if self.http_server: diff --git a/repeater/packet_router.py b/repeater/packet_router.py index 385e3b4..785c429 100644 --- a/repeater/packet_router.py +++ b/repeater/packet_router.py @@ -8,7 +8,8 @@ from pymc_core.node.handlers.login_server import LoginServerHandler from pymc_core.node.handlers.text import TextMessageHandler from pymc_core.node.handlers.path import PathHandler from pymc_core.node.handlers.protocol_request import ProtocolRequestHandler - +from pymc_core.node.handlers.group_text import GroupTextHandler +from pymc_core.node.handlers.protocol_response import ProtocolResponseHandler logger = logging.getLogger("PacketRouter") class PacketRouter: @@ -93,36 +94,72 @@ class PacketRouter: rssi = getattr(packet, "rssi", 0) snr = getattr(packet, "snr", 0.0) await self.daemon.advert_helper.process_advert_packet(packet, rssi, snr) - + # Also feed adverts to companion bridges (for contact/path updates) + for bridge in getattr(self.daemon, "companion_bridges", {}).values(): + try: + await bridge.process_received_packet(packet) + except Exception as e: + logger.debug(f"Companion bridge advert error: {e}") + elif payload_type == LoginServerHandler.payload_type(): - # Process ANON_REQ login packet for all identities - if self.daemon.login_helper: + # Route to companion if dest is a companion; else to login_helper + dest_hash = packet.payload[0] if packet.payload else None + companion_bridges = getattr(self.daemon, "companion_bridges", {}) + if dest_hash is not None and dest_hash in companion_bridges: + await companion_bridges[dest_hash].process_received_packet(packet) + processed_by_injection = True + elif self.daemon.login_helper: handled = await self.daemon.login_helper.process_login_packet(packet) - # Only skip forwarding if we actually handled it if handled: processed_by_injection = True - + elif payload_type == TextMessageHandler.payload_type(): - # Process TXT_MSG packet for all identities - if self.daemon.text_helper: + dest_hash = packet.payload[0] if packet.payload else None + companion_bridges = getattr(self.daemon, "companion_bridges", {}) + if dest_hash is not None and dest_hash in companion_bridges: + await companion_bridges[dest_hash].process_received_packet(packet) + processed_by_injection = True + elif self.daemon.text_helper: handled = await self.daemon.text_helper.process_text_packet(packet) - # Only skip forwarding if we actually handled it if handled: processed_by_injection = True - + elif payload_type == PathHandler.payload_type(): - # Process PATH packet to update client out_path for direct routing - if self.daemon.path_helper: + dest_hash = packet.payload[0] if packet.payload else None + companion_bridges = getattr(self.daemon, "companion_bridges", {}) + if dest_hash is not None and dest_hash in companion_bridges: + await companion_bridges[dest_hash].process_received_packet(packet) + processed_by_injection = True + elif self.daemon.path_helper: await self.daemon.path_helper.process_path_packet(packet) - # Note: process_path_packet returns False to allow forwarding - + + elif payload_type == ProtocolResponseHandler.payload_type(): + dest_hash = packet.payload[0] if packet.payload else None + companion_bridges = getattr(self.daemon, "companion_bridges", {}) + if dest_hash is not None and dest_hash in companion_bridges: + await companion_bridges[dest_hash].process_received_packet(packet) + processed_by_injection = True + elif payload_type == ProtocolRequestHandler.payload_type(): - # Process protocol request packet (status, telemetry, neighbors, etc.) - if self.daemon.protocol_request_helper: + dest_hash = packet.payload[0] if packet.payload else None + companion_bridges = getattr(self.daemon, "companion_bridges", {}) + if dest_hash is not None and dest_hash in companion_bridges: + await companion_bridges[dest_hash].process_received_packet(packet) + processed_by_injection = True + elif self.daemon.protocol_request_helper: handled = await self.daemon.protocol_request_helper.process_request_packet(packet) if handled: processed_by_injection = True - + + elif payload_type == GroupTextHandler.payload_type(): + # GRP_TXT: pass to all companions (they filter by channel); still forward + companion_bridges = getattr(self.daemon, "companion_bridges", {}) + for bridge in companion_bridges.values(): + try: + await bridge.process_received_packet(packet) + except Exception as e: + logger.debug(f"Companion bridge GRP_TXT error: {e}") + # Only pass to repeater engine if not already processed by injection if self.daemon.repeater_handler and not processed_by_injection: metadata = { diff --git a/repeater/web/api_endpoints.py b/repeater/web/api_endpoints.py index 857f403..0ed865e 100644 --- a/repeater/web/api_endpoints.py +++ b/repeater/web/api_endpoints.py @@ -627,12 +627,15 @@ class APIEndpoints: live_update=True, live_update_sections=['duty_cycle'] ) - + + if not result.get("saved", False): + return self._error(result.get("error", "Failed to save configuration to file")) + logger.info(f"Duty cycle config updated: {', '.join(applied)}") - + return self._success({ "applied": applied, - "persisted": result.get("saved", False), + "persisted": True, "live_update": result.get("live_updated", False), "restart_required": False, "message": "Duty cycle settings applied immediately." @@ -1126,8 +1129,10 @@ class APIEndpoints: self.config["radio"]["cad"]["min_threshold"] = min_val config_path = getattr(self, '_config_path', '/etc/pymc_repeater/config.yaml') - self.config_manager.save_to_file() - + saved, err = self.config_manager.save_to_file() + if not saved: + return self._error(err or "Failed to save configuration to file") + logger.info(f"Saved CAD settings to config: peak={peak}, min={min_val}, rate={detection_rate:.1f}%") return { "success": True, @@ -1330,12 +1335,15 @@ class APIEndpoints: live_update=True, live_update_sections=live_sections ) - + + if not result.get("saved", False): + return self._error(result.get("error", "Failed to save configuration to file")) + logger.info(f"Radio config updated: {', '.join(applied)}") - + return self._success({ "applied": applied, - "persisted": result.get("saved", False), + "persisted": True, "live_update": result.get("live_updated", False), "restart_required": not result.get("live_updated", False), "message": "Settings applied immediately." if result.get("live_updated") else "Settings saved. Restart service to apply changes." @@ -1649,8 +1657,12 @@ class APIEndpoints: # Update the configuration file using ConfigManager try: - self.config_manager.save_to_file() - logger.info(f"Updated running config and saved global flood policy to file: {'allow' if global_flood_allow else 'deny'}") + saved, err = self.config_manager.save_to_file() + if saved: + logger.info(f"Updated running config and saved global flood policy to file: {'allow' if global_flood_allow else 'deny'}") + else: + logger.error(f"Failed to save global flood policy to file: {err}") + return self._error(err or "Failed to save configuration to file") except Exception as e: logger.error(f"Failed to save global flood policy to file: {e}") return self._error(f"Failed to save configuration to file: {e}") @@ -2006,8 +2018,10 @@ class APIEndpoints: self.config["identities"]["room_servers"] = room_servers # Save to file - self.config_manager.save_to_file() - + saved, err = self.config_manager.save_to_file() + if not saved: + return self._error(err or "Failed to save configuration to file") + logger.info(f"Created new identity: {name} (type: {identity_type}){' with auto-generated key' if key_was_generated else ''}") # Hot reload - register identity immediately @@ -2152,9 +2166,11 @@ class APIEndpoints: # Save to config room_servers[identity_index] = identity self.config["identities"]["room_servers"] = room_servers - - self.config_manager.save_to_file() - + + saved, err = self.config_manager.save_to_file() + if not saved: + return self._error(err or "Failed to save configuration to file") + logger.info(f"Updated identity: {name}") # Hot reload - re-register identity if key changed or name changed @@ -2249,9 +2265,11 @@ class APIEndpoints: # Update config self.config["identities"]["room_servers"] = room_servers - - self.config_manager.save_to_file() - + + saved, err = self.config_manager.save_to_file() + if not saved: + return self._error(err or "Failed to save configuration to file") + logger.info(f"Deleted identity: {name}") unregister_success = False diff --git a/repeater/web/auth_endpoints.py b/repeater/web/auth_endpoints.py index 2ceb572..acf8f51 100644 --- a/repeater/web/auth_endpoints.py +++ b/repeater/web/auth_endpoints.py @@ -435,7 +435,8 @@ class AuthEndpoints: # Save to config file using ConfigManager if self.config_manager: - if self.config_manager.save_to_file(): + saved, _ = self.config_manager.save_to_file() + if saved: logger.info(f"Admin password changed successfully by user {user['username']}") return json.dumps({ 'success': True,