From e9891ddeaca67d53a49ac9cd47d4e991fb617bc5 Mon Sep 17 00:00:00 2001 From: yellowcooln Date: Sun, 25 Jan 2026 16:17:17 -0500 Subject: [PATCH 01/22] Add MeshCore TCP bridge for HA compatibility --- config.yaml.example | 9 + repeater/main.py | 15 ++ repeater/meshcore_bridge.py | 431 ++++++++++++++++++++++++++++++++++++ 3 files changed, 455 insertions(+) create mode 100644 repeater/meshcore_bridge.py diff --git a/config.yaml.example b/config.yaml.example index 9f96c83..0afb79d 100644 --- a/config.yaml.example +++ b/config.yaml.example @@ -61,6 +61,15 @@ repeater: # Controls how long users stay logged in before needing to re-authenticate jwt_expiry_minutes: 60 +# MeshCore TCP bridge (optional, for Home Assistant meshcore-ha TCP mode) +meshcore_bridge: + # Enable MeshCore TCP bridge + enabled: false + + # TCP listen host/port (meshcore-ha default TCP port is 5000) + host: "0.0.0.0" + port: 5000 + # Mesh Network Configuration mesh: # Global flood policy - controls whether the repeater allows or denies flooding by default diff --git a/repeater/main.py b/repeater/main.py index dd9c785..0257a72 100644 --- a/repeater/main.py +++ b/repeater/main.py @@ -7,6 +7,7 @@ from repeater.config_manager import ConfigManager from repeater.engine import RepeaterHandler from repeater.web.http_server import HTTPStatsServer, _log_buffer +from repeater.meshcore_bridge import MeshcoreTCPBridge from repeater.handler_helpers import TraceHelper, DiscoveryHelper, AdvertHelper, LoginHelper, TextHelper, PathHelper, ProtocolRequestHelper from repeater.packet_router import PacketRouter from repeater.identity_manager import IdentityManager @@ -27,6 +28,7 @@ def __init__(self, config: dict, radio=None): self.identity_manager = None self.config_manager = None self.http_server = None + self.meshcore_bridge = None self.trace_helper = None self.advert_helper = None self.discovery_helper = None @@ -500,6 +502,17 @@ async def run(self): except Exception as e: logger.error(f"Failed to start HTTP server: {e}") + # Start MeshCore TCP bridge (optional) + bridge_cfg = self.config.get("meshcore_bridge", {}) + if bridge_cfg.get("enabled", False): + bridge_host = bridge_cfg.get("host", "0.0.0.0") + bridge_port = int(bridge_cfg.get("port", 5000)) + try: + self.meshcore_bridge = MeshcoreTCPBridge(self, host=bridge_host, port=bridge_port) + await self.meshcore_bridge.start() + except Exception as e: + logger.error(f"Failed to start MeshCore TCP bridge: {e}") + # Run dispatcher (handles RX/TX via pymc_core) try: await self.dispatcher.run_forever() @@ -509,6 +522,8 @@ async def run(self): await self.router.stop() if self.http_server: self.http_server.stop() + if self.meshcore_bridge: + await self.meshcore_bridge.stop() def main(): diff --git a/repeater/meshcore_bridge.py b/repeater/meshcore_bridge.py new file mode 100644 index 0000000..bd3aa19 --- /dev/null +++ b/repeater/meshcore_bridge.py @@ -0,0 +1,431 @@ +import asyncio +import logging +import os +import struct +import time +from typing import List, Optional + +logger = logging.getLogger("MeshcoreBridge") + +FRAME_START = 0x3C + +# MeshCore protocol packet types (subset used by HA integration) +PKT_OK = 0x00 +PKT_ERROR = 0x01 +PKT_CONTACT_START = 0x02 +PKT_CONTACT = 0x03 +PKT_CONTACT_END = 0x04 +PKT_SELF_INFO = 0x05 +PKT_MSG_SENT = 0x06 +PKT_CURRENT_TIME = 0x09 +PKT_NO_MORE_MSGS = 0x0A +PKT_BATTERY = 0x0C +PKT_DEVICE_INFO = 0x0D +PKT_STATUS_RESPONSE = 0x87 +PKT_TELEMETRY_RESPONSE = 0x8B +PKT_BINARY_RESPONSE = 0x8C + +# Command bytes +CMD_APPSTART = 0x01 +CMD_GET_CONTACTS = 0x04 +CMD_GET_TIME = 0x05 +CMD_SET_TIME = 0x06 +CMD_SEND_ADVERT = 0x07 +CMD_GET_BAT = 0x14 +CMD_DEVICE_QUERY = 0x16 +CMD_SEND_LOGIN = 0x1A +CMD_SEND_STATUSREQ = 0x1B +CMD_SEND_LOGOUT = 0x1D +CMD_SEND_MSG = 0x02 +CMD_GET_MSG = 0x0A +CMD_GET_SELF_TELEMETRY = 0x27 +CMD_BINARY_REQ = 0x32 + +# Binary request types (from meshcore BinaryReqType) +BINREQ_STATUS = 0x01 +BINREQ_TELEMETRY = 0x03 + +DEFAULT_TIMEOUT_MS = 4000 + + +class MeshcoreTCPBridge: + def __init__(self, daemon, host: str = "0.0.0.0", port: int = 5000) -> None: + self.daemon = daemon + self.host = host + self.port = port + self._server: Optional[asyncio.base_events.Server] = None + + async def start(self) -> None: + self._server = await asyncio.start_server(self._handle_client, self.host, self.port) + addr = ", ".join(str(sock.getsockname()) for sock in self._server.sockets or []) + logger.info("MeshCore TCP bridge listening on %s", addr) + + async def stop(self) -> None: + if self._server: + self._server.close() + await self._server.wait_closed() + self._server = None + logger.info("MeshCore TCP bridge stopped") + + async def _handle_client(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None: + peer = writer.get_extra_info("peername") + logger.info("MeshCore TCP client connected: %s", peer) + try: + while True: + header = await reader.readexactly(3) + if not header: + break + if header[0] != FRAME_START: + logger.warning("Invalid frame start byte: %s", header[0]) + continue + size = int.from_bytes(header[1:3], byteorder="little") + if size == 0: + continue + payload = await reader.readexactly(size) + await self._handle_payload(payload, writer) + except asyncio.IncompleteReadError: + pass + except Exception as exc: + logger.error("MeshCore TCP client error: %s", exc, exc_info=True) + finally: + try: + writer.close() + await writer.wait_closed() + except Exception: + pass + logger.info("MeshCore TCP client disconnected: %s", peer) + + async def _handle_payload(self, payload: bytes, writer: asyncio.StreamWriter) -> None: + if not payload: + return + cmd = payload[0] + + if cmd == CMD_APPSTART: + await self._send_self_info(writer) + return + + if cmd == CMD_DEVICE_QUERY and len(payload) > 1 and payload[1] == 0x03: + await self._send_device_info(writer) + return + + if cmd == CMD_GET_CONTACTS: + await self._send_contacts(writer) + return + + if cmd == CMD_SEND_ADVERT: + await self._send_ok(writer) + return + + if cmd == CMD_SET_TIME: + await self._send_ok(writer) + return + + if cmd == CMD_GET_TIME: + await self._send_current_time(writer) + return + + if cmd == CMD_GET_BAT: + await self._send_battery(writer) + return + + if cmd == CMD_GET_MSG: + await self._send_no_more_msgs(writer) + return + + if cmd == CMD_GET_SELF_TELEMETRY: + await self._send_self_telemetry(writer) + return + + if cmd == CMD_BINARY_REQ: + await self._handle_binary_req(payload, writer) + return + + if cmd in (CMD_SEND_LOGIN, CMD_SEND_STATUSREQ, CMD_SEND_LOGOUT, CMD_SEND_MSG): + tag = await self._send_msg_sent(writer) + if cmd == CMD_SEND_STATUSREQ: + pubkey_prefix = self._extract_pubkey_prefix(payload, offset=1, length=32) + if pubkey_prefix: + await asyncio.sleep(0.05) + await self._send_status_response(writer, pubkey_prefix) + return + + logger.debug("Unhandled MeshCore command: 0x%02X", cmd) + await self._send_error(writer, 0) + + async def _send_packet(self, writer: asyncio.StreamWriter, payload: bytes) -> None: + pkt = bytes([FRAME_START]) + len(payload).to_bytes(2, byteorder="little") + payload + writer.write(pkt) + await writer.drain() + + async def _send_ok(self, writer: asyncio.StreamWriter) -> None: + await self._send_packet(writer, bytes([PKT_OK])) + + async def _send_error(self, writer: asyncio.StreamWriter, code: int) -> None: + await self._send_packet(writer, bytes([PKT_ERROR, code & 0xFF])) + + async def _send_msg_sent(self, writer: asyncio.StreamWriter) -> bytes: + tag = os.urandom(4) + payload = bytes([PKT_MSG_SENT, 0x00]) + tag + int(DEFAULT_TIMEOUT_MS).to_bytes(4, "little") + await self._send_packet(writer, payload) + return tag + + async def _send_current_time(self, writer: asyncio.StreamWriter) -> None: + now = int(time.time()) + payload = bytes([PKT_CURRENT_TIME]) + now.to_bytes(4, "little") + await self._send_packet(writer, payload) + + async def _send_no_more_msgs(self, writer: asyncio.StreamWriter) -> None: + await self._send_packet(writer, bytes([PKT_NO_MORE_MSGS])) + + async def _send_battery(self, writer: asyncio.StreamWriter) -> None: + # No battery on typical repeater hosts; report 0mV + payload = bytes([PKT_BATTERY]) + (0).to_bytes(2, "little") + await self._send_packet(writer, payload) + + async def _send_self_info(self, writer: asyncio.StreamWriter) -> None: + config = self.daemon.config + node_name = config.get("repeater", {}).get("node_name", "PyMC-Repeater") + lat = float(config.get("repeater", {}).get("latitude", 0.0)) + lon = float(config.get("repeater", {}).get("longitude", 0.0)) + radio = config.get("radio", {}) + + public_key = b"" # 32 bytes + if self.daemon.local_identity: + try: + public_key = self.daemon.local_identity.get_public_key() + except Exception: + public_key = b"" + public_key = public_key[:32].ljust(32, b"\x00") + + tx_power = int(radio.get("tx_power", 14)) + max_tx_power = tx_power + adv_type = 2 # repeater + + radio_freq = int(radio.get("frequency", 0) / 1000) # Hz -> kHz + radio_bw = int(radio.get("bandwidth", 0) / 1000) # Hz -> kHz + radio_sf = int(radio.get("spreading_factor", 7)) + radio_cr = int(radio.get("coding_rate", 5)) + + telemetry_mode = 0 + manual_add_contacts = 0 + multi_acks = 0 + adv_loc_policy = 0 + + payload = bytearray() + payload.append(PKT_SELF_INFO) + payload.append(adv_type & 0xFF) + payload.append(tx_power & 0xFF) + payload.append(max_tx_power & 0xFF) + payload.extend(public_key) + payload.extend(int(lat * 1e6).to_bytes(4, "little", signed=True)) + payload.extend(int(lon * 1e6).to_bytes(4, "little", signed=True)) + payload.append(multi_acks & 0xFF) + payload.append(adv_loc_policy & 0xFF) + payload.append(telemetry_mode & 0xFF) + payload.append(manual_add_contacts & 0xFF) + payload.extend(int(radio_freq).to_bytes(4, "little", signed=False)) + payload.extend(int(radio_bw).to_bytes(4, "little", signed=False)) + payload.append(radio_sf & 0xFF) + payload.append(radio_cr & 0xFF) + payload.extend(node_name.encode("utf-8")[:64]) + + await self._send_packet(writer, bytes(payload)) + + async def _send_device_info(self, writer: asyncio.StreamWriter) -> None: + config = self.daemon.config + model = config.get("letsmesh", {}).get("model", "PyMC-Repeater") + fw_build = "pymc-bridge" + version = "0.1" + + payload = bytearray() + payload.append(PKT_DEVICE_INFO) + payload.append(3) # fw ver >= 3 to include extended fields + payload.append(50) # max_contacts/2 => 100 + payload.append(4) # max_channels + payload.extend((0).to_bytes(4, "little")) # ble_pin + + fw_build_bytes = fw_build.encode("utf-8")[:12].ljust(12, b"\x00") + model_bytes = model.encode("utf-8")[:40].ljust(40, b"\x00") + version_bytes = version.encode("utf-8")[:20].ljust(20, b"\x00") + + payload.extend(fw_build_bytes) + payload.extend(model_bytes) + payload.extend(version_bytes) + + await self._send_packet(writer, bytes(payload)) + + async def _send_contacts(self, writer: asyncio.StreamWriter) -> None: + contacts = self._build_contacts() + await self._send_packet(writer, bytes([PKT_CONTACT_START]) + len(contacts).to_bytes(4, "little")) + + for contact in contacts: + pkt = bytearray() + pkt.append(PKT_CONTACT) + pkt.extend(contact["public_key"]) + pkt.append(contact["type"] & 0xFF) + pkt.append(contact["flags"] & 0xFF) + pkt.append(contact["out_path_len"] & 0xFF) + pkt.extend(contact["out_path"]) + pkt.extend(contact["adv_name"]) + pkt.extend(int(contact["last_advert"]).to_bytes(4, "little")) + pkt.extend(int(contact["adv_lat"] * 1e6).to_bytes(4, "little", signed=True)) + pkt.extend(int(contact["adv_lon"] * 1e6).to_bytes(4, "little", signed=True)) + pkt.extend(int(contact["lastmod"]).to_bytes(4, "little")) + await self._send_packet(writer, bytes(pkt)) + + lastmod = int(time.time()) + await self._send_packet(writer, bytes([PKT_CONTACT_END]) + lastmod.to_bytes(4, "little")) + + def _build_contacts(self) -> List[dict]: + contacts: List[dict] = [] + storage = None + if self.daemon.repeater_handler and self.daemon.repeater_handler.storage: + storage = self.daemon.repeater_handler.storage + + neighbors = storage.get_neighbors() if storage else {} + for pubkey_hex, info in neighbors.items(): + try: + pubkey_bytes = bytes.fromhex(pubkey_hex) + except Exception: + continue + + pubkey_bytes = pubkey_bytes[:32].ljust(32, b"\x00") + node_name = info.get("node_name") or "Unknown" + contact_type = info.get("contact_type") + is_repeater = bool(info.get("is_repeater")) + node_type = int(contact_type) if contact_type is not None else (2 if is_repeater else 1) + + out_path_len = -1 + flags = 0 + + adv_name_bytes = node_name.encode("utf-8")[:32].ljust(32, b"\x00") + last_seen = int(info.get("last_seen") or time.time()) + lat = float(info.get("latitude") or 0.0) + lon = float(info.get("longitude") or 0.0) + + contacts.append( + { + "public_key": pubkey_bytes, + "type": node_type, + "flags": flags, + "out_path_len": out_path_len, + "out_path": b"\x00" * 64, + "adv_name": adv_name_bytes, + "last_advert": last_seen, + "adv_lat": lat, + "adv_lon": lon, + "lastmod": last_seen, + } + ) + + return contacts + + async def _send_self_telemetry(self, writer: asyncio.StreamWriter) -> None: + prefix = self._local_pubkey_prefix() + payload = bytes([PKT_TELEMETRY_RESPONSE]) + prefix + b"" + await self._send_packet(writer, payload) + + async def _handle_binary_req(self, payload: bytes, writer: asyncio.StreamWriter) -> None: + if len(payload) < 34: + await self._send_error(writer, 0) + return + + dst = payload[1:33] + req_type = payload[33] + pubkey_prefix = dst[:6] + + tag = await self._send_msg_sent(writer) + await asyncio.sleep(0.05) + + if req_type == BINREQ_STATUS: + status_bytes = self._build_status_payload() + await self._send_binary_response(writer, tag, status_bytes) + return + + if req_type == BINREQ_TELEMETRY: + await self._send_binary_response(writer, tag, b"") + return + + await self._send_binary_response(writer, tag, b"") + + async def _send_binary_response(self, writer: asyncio.StreamWriter, tag: bytes, data: bytes) -> None: + payload = bytes([PKT_BINARY_RESPONSE]) + tag + data + await self._send_packet(writer, payload) + + async def _send_status_response(self, writer: asyncio.StreamWriter, pubkey_prefix: bytes) -> None: + status_bytes = self._build_status_payload() + payload = bytes([PKT_STATUS_RESPONSE, 0x00]) + pubkey_prefix[:6] + status_bytes + await self._send_packet(writer, payload) + + def _build_status_payload(self) -> bytes: + radio = getattr(self.daemon, "radio", None) + engine = getattr(self.daemon, "repeater_handler", None) + + noise_floor = -120 + last_rssi = -120 + last_snr_raw = 0 + if radio: + try: + if hasattr(radio, "get_noise_floor"): + noise_floor = int(radio.get_noise_floor()) + if hasattr(radio, "last_rssi"): + last_rssi = int(radio.last_rssi) + if hasattr(radio, "last_snr"): + last_snr_raw = int(float(radio.last_snr) * 4.0) + except Exception: + pass + + n_packets_recv = 0 + n_packets_sent = 0 + total_air_time_secs = 0 + + if engine: + n_packets_recv = int(getattr(engine, "rx_count", 0)) + n_packets_sent = int(getattr(engine, "forwarded_count", 0)) + airtime_mgr = getattr(engine, "airtime_mgr", None) + if airtime_mgr and hasattr(airtime_mgr, "total_airtime_ms"): + total_air_time_secs = int(airtime_mgr.total_airtime_ms / 1000) + + uptime_secs = int(time.time()) + if engine and hasattr(engine, "start_time"): + try: + uptime_secs = int(time.time() - engine.start_time) + except Exception: + pass + + stats = struct.pack( + " bytes: + if self.daemon.local_identity: + try: + return self.daemon.local_identity.get_public_key()[:6] + except Exception: + return b"\x00" * 6 + return b"\x00" * 6 + + def _extract_pubkey_prefix(self, payload: bytes, offset: int, length: int) -> Optional[bytes]: + if len(payload) < offset + length: + return None + dst = payload[offset:offset + length] + return dst[:6] From 358b6096ea3bc60baf992cfe0d3e789a43b985ce Mon Sep 17 00:00:00 2001 From: yellowcooln Date: Sun, 25 Jan 2026 16:30:55 -0500 Subject: [PATCH 02/22] Handle string contact types in MeshCore bridge --- repeater/meshcore_bridge.py | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/repeater/meshcore_bridge.py b/repeater/meshcore_bridge.py index bd3aa19..ebd36b1 100644 --- a/repeater/meshcore_bridge.py +++ b/repeater/meshcore_bridge.py @@ -293,7 +293,7 @@ def _build_contacts(self) -> List[dict]: node_name = info.get("node_name") or "Unknown" contact_type = info.get("contact_type") is_repeater = bool(info.get("is_repeater")) - node_type = int(contact_type) if contact_type is not None else (2 if is_repeater else 1) + node_type = self._coerce_contact_type(contact_type, is_repeater) out_path_len = -1 flags = 0 @@ -320,6 +320,23 @@ def _build_contacts(self) -> List[dict]: return contacts + def _coerce_contact_type(self, contact_type, is_repeater: bool) -> int: + if contact_type is None: + return 2 if is_repeater else 1 + if isinstance(contact_type, int): + return contact_type + if isinstance(contact_type, str): + lookup = { + "repeater": 2, + "room_server": 3, + "roomserver": 3, + "sensor": 4, + "client": 1, + "node": 1, + } + return lookup.get(contact_type.strip().lower(), 2 if is_repeater else 1) + return 2 if is_repeater else 1 + async def _send_self_telemetry(self, writer: asyncio.StreamWriter) -> None: prefix = self._local_pubkey_prefix() payload = bytes([PKT_TELEMETRY_RESPONSE]) + prefix + b"" From 229d7efcbf36447c8070b999fd4c3d0f69189508 Mon Sep 17 00:00:00 2001 From: yellowcooln Date: Sun, 25 Jan 2026 16:37:00 -0500 Subject: [PATCH 03/22] Emit login success for MeshCore bridge --- repeater/meshcore_bridge.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/repeater/meshcore_bridge.py b/repeater/meshcore_bridge.py index ebd36b1..c1d99c5 100644 --- a/repeater/meshcore_bridge.py +++ b/repeater/meshcore_bridge.py @@ -22,6 +22,8 @@ PKT_BATTERY = 0x0C PKT_DEVICE_INFO = 0x0D PKT_STATUS_RESPONSE = 0x87 +PKT_LOGIN_SUCCESS = 0x85 +PKT_LOGIN_FAILED = 0x86 PKT_TELEMETRY_RESPONSE = 0x8B PKT_BINARY_RESPONSE = 0x8C @@ -141,12 +143,17 @@ async def _handle_payload(self, payload: bytes, writer: asyncio.StreamWriter) -> return if cmd in (CMD_SEND_LOGIN, CMD_SEND_STATUSREQ, CMD_SEND_LOGOUT, CMD_SEND_MSG): - tag = await self._send_msg_sent(writer) + await self._send_msg_sent(writer) if cmd == CMD_SEND_STATUSREQ: pubkey_prefix = self._extract_pubkey_prefix(payload, offset=1, length=32) if pubkey_prefix: await asyncio.sleep(0.05) await self._send_status_response(writer, pubkey_prefix) + if cmd == CMD_SEND_LOGIN: + pubkey_prefix = self._extract_pubkey_prefix(payload, offset=1, length=32) + if pubkey_prefix: + await asyncio.sleep(0.05) + await self._send_login_success(writer, pubkey_prefix) return logger.debug("Unhandled MeshCore command: 0x%02X", cmd) @@ -374,6 +381,12 @@ async def _send_status_response(self, writer: asyncio.StreamWriter, pubkey_prefi payload = bytes([PKT_STATUS_RESPONSE, 0x00]) + pubkey_prefix[:6] + status_bytes await self._send_packet(writer, payload) + async def _send_login_success(self, writer: asyncio.StreamWriter, pubkey_prefix: bytes) -> None: + # Permissions: bit0=admin + perms = 0x01 + payload = bytes([PKT_LOGIN_SUCCESS, perms]) + pubkey_prefix[:6] + await self._send_packet(writer, payload) + def _build_status_payload(self) -> bytes: radio = getattr(self.daemon, "radio", None) engine = getattr(self.daemon, "repeater_handler", None) From 2402f62dbea2370d502466188cb6a86af7ff2a16 Mon Sep 17 00:00:00 2001 From: yellowcooln Date: Sun, 25 Jan 2026 16:46:19 -0500 Subject: [PATCH 04/22] Delay binary responses for pending request registration --- repeater/meshcore_bridge.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/repeater/meshcore_bridge.py b/repeater/meshcore_bridge.py index c1d99c5..3bcdb83 100644 --- a/repeater/meshcore_bridge.py +++ b/repeater/meshcore_bridge.py @@ -359,7 +359,8 @@ async def _handle_binary_req(self, payload: bytes, writer: asyncio.StreamWriter) pubkey_prefix = dst[:6] tag = await self._send_msg_sent(writer) - await asyncio.sleep(0.05) + # Give client time to register pending binary request before responding + await asyncio.sleep(0.2) if req_type == BINREQ_STATUS: status_bytes = self._build_status_payload() From a4067a921118f845c55823346edff92dda2bed68 Mon Sep 17 00:00:00 2001 From: yellowcooln Date: Sun, 25 Jan 2026 18:22:20 -0500 Subject: [PATCH 05/22] Handle set_other_params command in MeshCore bridge --- repeater/meshcore_bridge.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/repeater/meshcore_bridge.py b/repeater/meshcore_bridge.py index 3bcdb83..2b147b2 100644 --- a/repeater/meshcore_bridge.py +++ b/repeater/meshcore_bridge.py @@ -42,6 +42,7 @@ CMD_GET_MSG = 0x0A CMD_GET_SELF_TELEMETRY = 0x27 CMD_BINARY_REQ = 0x32 +CMD_SET_OTHER_PARAMS = 0x26 # Binary request types (from meshcore BinaryReqType) BINREQ_STATUS = 0x01 @@ -122,6 +123,10 @@ async def _handle_payload(self, payload: bytes, writer: asyncio.StreamWriter) -> await self._send_ok(writer) return + if cmd == CMD_SET_OTHER_PARAMS: + await self._send_ok(writer) + return + if cmd == CMD_GET_TIME: await self._send_current_time(writer) return From 0f51b58dcba27ac047f84ff026b34615dfb45c81 Mon Sep 17 00:00:00 2001 From: yellowcooln Date: Sun, 25 Jan 2026 18:33:51 -0500 Subject: [PATCH 06/22] Inject RF packets for HA repeater commands --- repeater/meshcore_bridge.py | 165 ++++++++++++++++++++++++++++++++++-- 1 file changed, 159 insertions(+), 6 deletions(-) diff --git a/repeater/meshcore_bridge.py b/repeater/meshcore_bridge.py index 2b147b2..5075355 100644 --- a/repeater/meshcore_bridge.py +++ b/repeater/meshcore_bridge.py @@ -1,9 +1,13 @@ import asyncio import logging import os +import random import struct import time -from typing import List, Optional +from typing import Coroutine, List, Optional + +from pymc_core.protocol.packet_builder import PacketBuilder +from pymc_core.node.handlers.protocol_request import REQ_TYPE_GET_STATUS logger = logging.getLogger("MeshcoreBridge") @@ -33,6 +37,7 @@ CMD_GET_TIME = 0x05 CMD_SET_TIME = 0x06 CMD_SEND_ADVERT = 0x07 +CMD_RESET_PATH = 0x0D CMD_GET_BAT = 0x14 CMD_DEVICE_QUERY = 0x16 CMD_SEND_LOGIN = 0x1A @@ -43,6 +48,7 @@ CMD_GET_SELF_TELEMETRY = 0x27 CMD_BINARY_REQ = 0x32 CMD_SET_OTHER_PARAMS = 0x26 +CMD_SEND_PATH_DISCOVERY = 0x34 # Binary request types (from meshcore BinaryReqType) BINREQ_STATUS = 0x01 @@ -119,6 +125,13 @@ async def _handle_payload(self, payload: bytes, writer: asyncio.StreamWriter) -> await self._send_ok(writer) return + if cmd == CMD_RESET_PATH: + await self._send_ok(writer) + contact = self._contact_from_payload(payload, offset=1) + if contact: + self._schedule_rf_task(self._send_rf_reset_path(contact), "reset_path") + return + if cmd == CMD_SET_TIME: await self._send_ok(writer) return @@ -140,7 +153,20 @@ async def _handle_payload(self, payload: bytes, writer: asyncio.StreamWriter) -> return if cmd == CMD_GET_SELF_TELEMETRY: - await self._send_self_telemetry(writer) + if len(payload) >= 37: + await self._send_msg_sent(writer) + contact = self._contact_from_payload(payload, offset=5) + if contact: + self._schedule_rf_task(self._send_rf_telem_request(contact), "telemetry_request") + else: + await self._send_self_telemetry(writer) + return + + if cmd == CMD_SEND_PATH_DISCOVERY: + await self._send_msg_sent(writer) + contact = self._contact_from_payload(payload, offset=2) + if contact: + self._schedule_rf_task(self._send_rf_path_discovery(contact), "path_discovery") return if cmd == CMD_BINARY_REQ: @@ -150,15 +176,24 @@ async def _handle_payload(self, payload: bytes, writer: asyncio.StreamWriter) -> if cmd in (CMD_SEND_LOGIN, CMD_SEND_STATUSREQ, CMD_SEND_LOGOUT, CMD_SEND_MSG): await self._send_msg_sent(writer) if cmd == CMD_SEND_STATUSREQ: - pubkey_prefix = self._extract_pubkey_prefix(payload, offset=1, length=32) - if pubkey_prefix: + contact = self._contact_from_payload(payload, offset=1) + if contact: + self._schedule_rf_task(self._send_rf_status_request(contact), "status_request") + pubkey_prefix = bytes.fromhex(contact.public_key)[:6] await asyncio.sleep(0.05) await self._send_status_response(writer, pubkey_prefix) if cmd == CMD_SEND_LOGIN: - pubkey_prefix = self._extract_pubkey_prefix(payload, offset=1, length=32) - if pubkey_prefix: + contact = self._contact_from_payload(payload, offset=1) + password = self._parse_login_password(payload, offset=1 + 32) + if contact and password is not None: + self._schedule_rf_task(self._send_rf_login(contact, password), "login") + pubkey_prefix = bytes.fromhex(contact.public_key)[:6] await asyncio.sleep(0.05) await self._send_login_success(writer, pubkey_prefix) + if cmd == CMD_SEND_LOGOUT: + contact = self._contact_from_payload(payload, offset=1) + if contact: + self._schedule_rf_task(self._send_rf_logout(contact), "logout") return logger.debug("Unhandled MeshCore command: 0x%02X", cmd) @@ -362,17 +397,22 @@ async def _handle_binary_req(self, payload: bytes, writer: asyncio.StreamWriter) dst = payload[1:33] req_type = payload[33] pubkey_prefix = dst[:6] + contact = self._contact_from_pubkey_bytes(dst) tag = await self._send_msg_sent(writer) # Give client time to register pending binary request before responding await asyncio.sleep(0.2) if req_type == BINREQ_STATUS: + if contact: + self._schedule_rf_task(self._send_rf_status_request(contact), "status_request") status_bytes = self._build_status_payload() await self._send_binary_response(writer, tag, status_bytes) return if req_type == BINREQ_TELEMETRY: + if contact: + self._schedule_rf_task(self._send_rf_telem_request(contact), "telemetry_request") await self._send_binary_response(writer, tag, b"") return @@ -465,3 +505,116 @@ def _extract_pubkey_prefix(self, payload: bytes, offset: int, length: int) -> Op return None dst = payload[offset:offset + length] return dst[:6] + + def _contact_from_payload(self, payload: bytes, offset: int) -> Optional["_SimpleContact"]: + if len(payload) < offset + 32: + return None + return self._contact_from_pubkey_bytes(payload[offset:offset + 32]) + + def _contact_from_pubkey_bytes(self, pubkey_bytes: bytes) -> Optional["_SimpleContact"]: + if len(pubkey_bytes) < 32: + return None + return _SimpleContact(public_key=pubkey_bytes[:32].hex(), contact_type=2) + + def _parse_login_password(self, payload: bytes, offset: int) -> Optional[str]: + if len(payload) <= offset: + return "" + try: + return payload[offset:].decode("utf-8", "ignore") + except Exception: + return "" + + def _schedule_rf_task(self, coro: Coroutine, action: str) -> None: + try: + asyncio.create_task(coro) + except Exception as exc: + logger.error("Failed to schedule RF %s: %s", action, exc) + + async def _send_rf_login(self, contact: "_SimpleContact", password: str) -> None: + identity = self.daemon.local_identity + dispatcher = self.daemon.dispatcher + if not identity or not dispatcher: + logger.warning("RF login skipped: local identity or dispatcher missing") + return + try: + packet = PacketBuilder.create_login_packet(contact, identity, password) + await dispatcher.send_packet(packet, wait_for_ack=False) + logger.info("RF login sent to %s", contact.public_key[:12]) + except Exception as exc: + logger.error("RF login failed: %s", exc, exc_info=True) + + async def _send_rf_logout(self, contact: "_SimpleContact") -> None: + identity = self.daemon.local_identity + dispatcher = self.daemon.dispatcher + if not identity or not dispatcher: + logger.warning("RF logout skipped: local identity or dispatcher missing") + return + try: + packet, crc = PacketBuilder.create_logout_packet(contact, identity) + await dispatcher.send_packet(packet, wait_for_ack=False, expected_crc=crc) + logger.info("RF logout sent to %s", contact.public_key[:12]) + except Exception as exc: + logger.error("RF logout failed: %s", exc, exc_info=True) + + async def _send_rf_status_request(self, contact: "_SimpleContact") -> None: + identity = self.daemon.local_identity + dispatcher = self.daemon.dispatcher + if not identity or not dispatcher: + logger.warning("RF status request skipped: local identity or dispatcher missing") + return + try: + packet, _ts = PacketBuilder.create_protocol_request( + contact=contact, local_identity=identity, protocol_code=REQ_TYPE_GET_STATUS + ) + await dispatcher.send_packet(packet, wait_for_ack=False) + logger.info("RF status request sent to %s", contact.public_key[:12]) + except Exception as exc: + logger.error("RF status request failed: %s", exc, exc_info=True) + + async def _send_rf_telem_request(self, contact: "_SimpleContact") -> None: + identity = self.daemon.local_identity + dispatcher = self.daemon.dispatcher + if not identity or not dispatcher: + logger.warning("RF telemetry request skipped: local identity or dispatcher missing") + return + try: + packet, _ts = PacketBuilder.create_telem_request(contact, identity) + await dispatcher.send_packet(packet, wait_for_ack=False) + logger.info("RF telemetry request sent to %s", contact.public_key[:12]) + except Exception as exc: + logger.error("RF telemetry request failed: %s", exc, exc_info=True) + + async def _send_rf_path_discovery(self, contact: "_SimpleContact") -> None: + dispatcher = self.daemon.dispatcher + if not dispatcher: + logger.warning("RF path discovery skipped: dispatcher missing") + return + try: + tag = random.randint(0, 0xFFFFFFFF) + dest_hash = int(contact.public_key[:2], 16) + packet = PacketBuilder.create_trace(tag=tag, auth_code=0, flags=0, path=[dest_hash]) + await dispatcher.send_packet(packet, wait_for_ack=False) + logger.info("RF path discovery (trace) sent to %s", contact.public_key[:12]) + except Exception as exc: + logger.error("RF path discovery failed: %s", exc, exc_info=True) + + async def _send_rf_reset_path(self, contact: "_SimpleContact") -> None: + dispatcher = self.daemon.dispatcher + if not dispatcher: + logger.warning("RF reset path skipped: dispatcher missing") + return + try: + tag = random.randint(0, 0xFFFFFFFF) + dest_hash = int(contact.public_key[:2], 16) + packet = PacketBuilder.create_trace(tag=tag, auth_code=0, flags=1, path=[dest_hash]) + await dispatcher.send_packet(packet, wait_for_ack=False) + logger.info("RF reset path (trace) sent to %s", contact.public_key[:12]) + except Exception as exc: + logger.error("RF reset path failed: %s", exc, exc_info=True) + + +class _SimpleContact: + def __init__(self, public_key: str, contact_type: int = 2, sync_since: int = 0) -> None: + self.public_key = public_key + self.type = contact_type + self.sync_since = sync_since From a057a718fb71e3ff5c8073611cc4ef0ee5839536 Mon Sep 17 00:00:00 2001 From: yellowcooln Date: Sun, 25 Jan 2026 18:42:17 -0500 Subject: [PATCH 07/22] Forward RF protocol responses to HA over TCP --- repeater/meshcore_bridge.py | 133 ++++++++++++++++++++++++++++++++++++ repeater/packet_router.py | 11 ++- 2 files changed, 143 insertions(+), 1 deletion(-) diff --git a/repeater/meshcore_bridge.py b/repeater/meshcore_bridge.py index 5075355..119304e 100644 --- a/repeater/meshcore_bridge.py +++ b/repeater/meshcore_bridge.py @@ -8,6 +8,9 @@ from pymc_core.protocol.packet_builder import PacketBuilder from pymc_core.node.handlers.protocol_request import REQ_TYPE_GET_STATUS +from pymc_core.protocol.constants import PAYLOAD_TYPE_PATH, PAYLOAD_TYPE_RESPONSE +from pymc_core.protocol.crypto import CryptoUtils +from pymc_core.protocol.identity import Identity logger = logging.getLogger("MeshcoreBridge") @@ -63,6 +66,7 @@ def __init__(self, daemon, host: str = "0.0.0.0", port: int = 5000) -> None: self.host = host self.port = port self._server: Optional[asyncio.base_events.Server] = None + self._pending_requests: dict[tuple[int, str], dict] = {} async def start(self) -> None: self._server = await asyncio.start_server(self._handle_client, self.host, self.port) @@ -178,6 +182,7 @@ async def _handle_payload(self, payload: bytes, writer: asyncio.StreamWriter) -> if cmd == CMD_SEND_STATUSREQ: contact = self._contact_from_payload(payload, offset=1) if contact: + self._register_pending(contact, "status", writer) self._schedule_rf_task(self._send_rf_status_request(contact), "status_request") pubkey_prefix = bytes.fromhex(contact.public_key)[:6] await asyncio.sleep(0.05) @@ -405,6 +410,7 @@ async def _handle_binary_req(self, payload: bytes, writer: asyncio.StreamWriter) if req_type == BINREQ_STATUS: if contact: + self._register_pending(contact, "status", writer) self._schedule_rf_task(self._send_rf_status_request(contact), "status_request") status_bytes = self._build_status_payload() await self._send_binary_response(writer, tag, status_bytes) @@ -412,6 +418,7 @@ async def _handle_binary_req(self, payload: bytes, writer: asyncio.StreamWriter) if req_type == BINREQ_TELEMETRY: if contact: + self._register_pending(contact, "telemetry", writer) self._schedule_rf_task(self._send_rf_telem_request(contact), "telemetry_request") await self._send_binary_response(writer, tag, b"") return @@ -427,6 +434,14 @@ async def _send_status_response(self, writer: asyncio.StreamWriter, pubkey_prefi payload = bytes([PKT_STATUS_RESPONSE, 0x00]) + pubkey_prefix[:6] + status_bytes await self._send_packet(writer, payload) + async def _send_status_response_bytes(self, writer: asyncio.StreamWriter, pubkey_prefix: bytes, status_bytes: bytes) -> None: + payload = bytes([PKT_STATUS_RESPONSE, 0x00]) + pubkey_prefix[:6] + status_bytes + await self._send_packet(writer, payload) + + async def _send_telemetry_response_bytes(self, writer: asyncio.StreamWriter, pubkey_prefix: bytes, lpp_bytes: bytes) -> None: + payload = bytes([PKT_TELEMETRY_RESPONSE]) + pubkey_prefix[:6] + lpp_bytes + await self._send_packet(writer, payload) + async def _send_login_success(self, writer: asyncio.StreamWriter, pubkey_prefix: bytes) -> None: # Permissions: bit0=admin perms = 0x01 @@ -530,6 +545,73 @@ def _schedule_rf_task(self, coro: Coroutine, action: str) -> None: except Exception as exc: logger.error("Failed to schedule RF %s: %s", action, exc) + def _register_pending(self, contact: "_SimpleContact", kind: str, writer: asyncio.StreamWriter) -> None: + try: + contact_hash = int(contact.public_key[:2], 16) + except Exception: + return + self._pending_requests[(contact_hash, kind)] = {"writer": writer, "contact": contact, "ts": time.time()} + + async def handle_rf_packet(self, packet) -> bool: + payload_type = packet.get_payload_type() + if payload_type not in (PAYLOAD_TYPE_PATH, PAYLOAD_TYPE_RESPONSE): + return False + if len(packet.payload) < 3: + return False + + dest_hash = packet.payload[0] + src_hash = packet.payload[1] + encrypted_data = bytes(packet.payload[2:]) + + # Only process if we have pending requests for this source hash + pending_status = self._pending_requests.get((src_hash, "status")) + pending_telemetry = self._pending_requests.get((src_hash, "telemetry")) + if not pending_status and not pending_telemetry: + return False + + contact = None + if pending_status: + contact = pending_status.get("contact") + if not contact and pending_telemetry: + contact = pending_telemetry.get("contact") + if not contact: + return False + + identity = self.daemon.local_identity + if not identity: + return False + + try: + contact_pubkey = bytes.fromhex(contact.public_key) + peer_id = Identity(contact_pubkey) + shared_secret = peer_id.calc_shared_secret(identity.get_private_key()) + aes_key = shared_secret[:16] + plaintext = CryptoUtils.mac_then_decrypt(aes_key, shared_secret, encrypted_data) + except Exception as exc: + logger.error("Failed to decrypt RF response: %s", exc) + return False + + if not plaintext or len(plaintext) < 4: + return False + + # Strip reflected timestamp (first 4 bytes) + payload = plaintext[4:] + pubkey_prefix = contact_pubkey[:6] + + if pending_status and len(payload) >= 58: + status_bytes = self._pymc_status_to_meshcore(payload[:58]) + if status_bytes: + await self._send_status_response_bytes(pending_status["writer"], pubkey_prefix, status_bytes) + self._pending_requests.pop((src_hash, "status"), None) + return True + + if pending_telemetry and len(payload) > 0: + await self._send_telemetry_response_bytes(pending_telemetry["writer"], pubkey_prefix, payload) + self._pending_requests.pop((src_hash, "telemetry"), None) + return True + + return False + async def _send_rf_login(self, contact: "_SimpleContact", password: str) -> None: identity = self.daemon.local_identity dispatcher = self.daemon.dispatcher @@ -612,6 +694,57 @@ async def _send_rf_reset_path(self, contact: "_SimpleContact") -> None: except Exception as exc: logger.error("RF reset path failed: %s", exc, exc_info=True) + def _pymc_status_to_meshcore(self, data: bytes) -> Optional[bytes]: + try: + if len(data) < 58: + return None + values = struct.unpack(" None: diff --git a/repeater/packet_router.py b/repeater/packet_router.py index 385e3b4..daadcf8 100644 --- a/repeater/packet_router.py +++ b/repeater/packet_router.py @@ -72,7 +72,16 @@ async def _route_packet(self, packet): payload_type = packet.get_payload_type() processed_by_injection = False - + + # Allow MeshCore bridge to consume RF responses before other handlers + if getattr(self.daemon, "meshcore_bridge", None): + try: + handled = await self.daemon.meshcore_bridge.handle_rf_packet(packet) + if handled: + processed_by_injection = True + except Exception as e: + logger.error(f"MeshCore bridge RF handler error: {e}", exc_info=True) + # Route to specific handlers for parsing only if payload_type == TraceHandler.payload_type(): # Process trace packet From 73757a34a585997f82124070da5214d75767bf74 Mon Sep 17 00:00:00 2001 From: yellowcooln Date: Sun, 25 Jan 2026 18:49:35 -0500 Subject: [PATCH 08/22] Record bridge RF transmissions in packet storage --- repeater/meshcore_bridge.py | 40 +++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/repeater/meshcore_bridge.py b/repeater/meshcore_bridge.py index 119304e..ca4d30e 100644 --- a/repeater/meshcore_bridge.py +++ b/repeater/meshcore_bridge.py @@ -621,6 +621,7 @@ async def _send_rf_login(self, contact: "_SimpleContact", password: str) -> None try: packet = PacketBuilder.create_login_packet(contact, identity, password) await dispatcher.send_packet(packet, wait_for_ack=False) + self._record_tx_packet(packet) logger.info("RF login sent to %s", contact.public_key[:12]) except Exception as exc: logger.error("RF login failed: %s", exc, exc_info=True) @@ -634,6 +635,7 @@ async def _send_rf_logout(self, contact: "_SimpleContact") -> None: try: packet, crc = PacketBuilder.create_logout_packet(contact, identity) await dispatcher.send_packet(packet, wait_for_ack=False, expected_crc=crc) + self._record_tx_packet(packet) logger.info("RF logout sent to %s", contact.public_key[:12]) except Exception as exc: logger.error("RF logout failed: %s", exc, exc_info=True) @@ -649,6 +651,7 @@ async def _send_rf_status_request(self, contact: "_SimpleContact") -> None: contact=contact, local_identity=identity, protocol_code=REQ_TYPE_GET_STATUS ) await dispatcher.send_packet(packet, wait_for_ack=False) + self._record_tx_packet(packet) logger.info("RF status request sent to %s", contact.public_key[:12]) except Exception as exc: logger.error("RF status request failed: %s", exc, exc_info=True) @@ -662,6 +665,7 @@ async def _send_rf_telem_request(self, contact: "_SimpleContact") -> None: try: packet, _ts = PacketBuilder.create_telem_request(contact, identity) await dispatcher.send_packet(packet, wait_for_ack=False) + self._record_tx_packet(packet) logger.info("RF telemetry request sent to %s", contact.public_key[:12]) except Exception as exc: logger.error("RF telemetry request failed: %s", exc, exc_info=True) @@ -676,6 +680,7 @@ async def _send_rf_path_discovery(self, contact: "_SimpleContact") -> None: dest_hash = int(contact.public_key[:2], 16) packet = PacketBuilder.create_trace(tag=tag, auth_code=0, flags=0, path=[dest_hash]) await dispatcher.send_packet(packet, wait_for_ack=False) + self._record_tx_packet(packet) logger.info("RF path discovery (trace) sent to %s", contact.public_key[:12]) except Exception as exc: logger.error("RF path discovery failed: %s", exc, exc_info=True) @@ -690,10 +695,45 @@ async def _send_rf_reset_path(self, contact: "_SimpleContact") -> None: dest_hash = int(contact.public_key[:2], 16) packet = PacketBuilder.create_trace(tag=tag, auth_code=0, flags=1, path=[dest_hash]) await dispatcher.send_packet(packet, wait_for_ack=False) + self._record_tx_packet(packet) logger.info("RF reset path (trace) sent to %s", contact.public_key[:12]) except Exception as exc: logger.error("RF reset path failed: %s", exc, exc_info=True) + def _record_tx_packet(self, packet) -> None: + handler = getattr(self.daemon, "repeater_handler", None) + if not handler: + return + try: + pkt_hash = packet.get_packet_hash_hex(16) if hasattr(packet, "get_packet_hash_hex") else "" + packet_record = { + "timestamp": time.time(), + "payload_length": len(packet.payload) if getattr(packet, "payload", None) else 0, + "type": packet.get_payload_type(), + "route": packet.get_route_type(), + "length": len(packet.payload or b""), + "rssi": 0, + "snr": 0, + "score": 0, + "tx_delay_ms": 0, + "transmitted": True, + "is_duplicate": False, + "packet_hash": pkt_hash, + "drop_reason": None, + "path_hash": None, + "src_hash": None, + "dst_hash": None, + "original_path": None, + "forwarded_path": None, + "raw_packet": packet.write_to().hex() if hasattr(packet, "write_to") else None, + "lbt_attempts": 0, + "lbt_backoff_delays_ms": None, + "lbt_channel_busy": False, + } + handler.log_trace_record(packet_record) + except Exception as exc: + logger.debug("Failed to record RF TX packet: %s", exc) + def _pymc_status_to_meshcore(self, data: bytes) -> Optional[bytes]: try: if len(data) < 58: From 28547da75276dc29435321dee8e33e2432901654 Mon Sep 17 00:00:00 2001 From: yellowcooln Date: Sun, 25 Jan 2026 18:54:44 -0500 Subject: [PATCH 09/22] Add AGENTS.md documenting MeshCore bridge changes --- AGENTS.md | 83 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..7c8ed2a --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,83 @@ +# AGENTS.md + +This file documents the manual changes made to integrate pyMC_Repeater with Home Assistant’s meshcore-ha using a MeshCore TCP bridge. + +## Summary +A MeshCore TCP bridge was added so meshcore-ha can connect to pyMC_Repeater via TCP without modifying HA. The bridge: +- Implements MeshCore TCP framing and essential commands. +- Responds to MeshCore protocol queries (appstart, device info, contacts, status, telemetry, etc.). +- Transmits actual RF packets for login/logout, status, telemetry, and path discovery/reset. +- Decrypts RF protocol responses and forwards them back to HA over TCP. + +## Files Added/Modified + +### New +- `repeater/meshcore_bridge.py` + - TCP server that emulates MeshCore protocol for meshcore-ha. + - Generates SELF_INFO, DEVICE_INFO, CONTACTS, STATUS_RESPONSE, TELEMETRY_RESPONSE, LOGIN_SUCCESS, etc. + - Injects real RF packets using `pymc_core` for: + - Login / logout + - Status request + - Telemetry request + - Path discovery / reset path (best-effort trace) + - Decrypts RF protocol responses (PAYLOAD_TYPE_RESPONSE / PATH) and forwards to HA. + +### Modified +- `repeater/main.py` + - Starts/stops the MeshCore TCP bridge based on config. +- `repeater/packet_router.py` + - Routes RF response packets to the bridge for decryption/forwarding. +- `config.yaml.example` + - Adds `meshcore_bridge` configuration block. + +## Config +Add this to `/etc/pymc_repeater/config.yaml`: +```yaml +meshcore_bridge: + enabled: true + host: "0.0.0.0" + port: 5000 +``` +Use a different port if your UI is not on 8000 or if 5000 is in use. + +## MeshCore TCP Bridge Behavior + +### Implemented commands +- `send_appstart` → `SELF_INFO` +- `device_query` → `DEVICE_INFO` +- `get_contacts` → contacts from pyMC neighbor DB +- `send_login` → `MSG_SENT` + `LOGIN_SUCCESS` (and RF login packet) +- `send_logout` → `MSG_SENT` (and RF logout packet) +- `send_statusreq` and `binary_req(STATUS)` → `MSG_SENT`, RF status request, and TCP status response +- `get_self_telemetry` and `binary_req(TELEMETRY)` → `MSG_SENT`, RF telemetry request, and TCP telemetry response +- `set_other_params` → `OK` +- `reset_path` → `OK` (and RF trace packet) +- `path_discovery` → `MSG_SENT` (and RF trace packet) +- `get_time`, `set_time`, `get_bat`, `get_msg`, `send_advert` → basic responses + +### RF injection +RF packets are sent using `pymc_core.protocol.PacketBuilder` and `Dispatcher.send_packet`. + +### RF response forwarding +The bridge: +- Intercepts PAYLOAD_TYPE_RESPONSE/PATH packets. +- Decrypts them using shared secret. +- For status: converts pyMC’s 58-byte stats struct into MeshCore’s expected format. +- For telemetry: forwards the CayenneLPP payload to HA. + +## Known limitations +- Path discovery/reset uses trace packets as best-effort. +- Some MeshCore admin/config commands are ACK-only (no device config changes). +- Contact list depends on pyMC neighbor DB; it may be empty on fresh setups. + +## Commits (tcp-bridge branch) +- `Add MeshCore TCP bridge for HA compatibility` +- `Handle string contact types in MeshCore bridge` +- `Emit login success for MeshCore bridge` +- `Delay binary responses for pending request registration` +- `Handle set_other_params command in MeshCore bridge` +- `Inject RF packets for HA repeater commands` +- `Forward RF protocol responses to HA over TCP` + +## Usage in HA +Use meshcore-ha TCP mode and point it to the bridge host/port. From 202acc060d038cd249c39c3ea549ca754cae3e6f Mon Sep 17 00:00:00 2001 From: yellowcooln Date: Sun, 25 Jan 2026 19:46:00 -0500 Subject: [PATCH 10/22] Fix HA telemetry, battery, and contacts --- AGENTS.md | 10 +++ repeater/meshcore_bridge.py | 149 ++++++++++++++++++++++++++++++++++-- 2 files changed, 151 insertions(+), 8 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 7c8ed2a..7e9c013 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,6 +8,7 @@ A MeshCore TCP bridge was added so meshcore-ha can connect to pyMC_Repeater via - Responds to MeshCore protocol queries (appstart, device info, contacts, status, telemetry, etc.). - Transmits actual RF packets for login/logout, status, telemetry, and path discovery/reset. - Decrypts RF protocol responses and forwards them back to HA over TCP. +- Normalizes radio/telemetry/battery reporting to keep HA sensors clean and accurate. ## Files Added/Modified @@ -21,6 +22,13 @@ A MeshCore TCP bridge was added so meshcore-ha can connect to pyMC_Repeater via - Telemetry request - Path discovery / reset path (best-effort trace) - Decrypts RF protocol responses (PAYLOAD_TYPE_RESPONSE / PATH) and forwards to HA. + - Sends DEVICE_INFO + CONTACTS on APPSTART so HA can populate contacts/node count. + - Reports battery at 4200 mV (full LiPo) in both BATTERY and status payloads. + - Uses Hz for radio frequency/bandwidth in SELF_INFO (no kHz scaling). + - Self telemetry reports CPU temperature on channel 1 (Cayenne LPP, °C). + - Filters self telemetry to only channel 1 temperature. + - Drops Digital Input/Output LPP types globally to avoid noisy HA sensors. + - Filters CONTACTS to match UI “Tracking” (contact types + last 7 days) and uses out_path_len=0. ### Modified - `repeater/main.py` @@ -44,6 +52,7 @@ Use a different port if your UI is not on 8000 or if 5000 is in use. ### Implemented commands - `send_appstart` → `SELF_INFO` +- `send_appstart` also sends `DEVICE_INFO` and `CONTACTS` for HA contact discovery - `device_query` → `DEVICE_INFO` - `get_contacts` → contacts from pyMC neighbor DB - `send_login` → `MSG_SENT` + `LOGIN_SUCCESS` (and RF login packet) @@ -78,6 +87,7 @@ The bridge: - `Handle set_other_params command in MeshCore bridge` - `Inject RF packets for HA repeater commands` - `Forward RF protocol responses to HA over TCP` +- `Fix radio units, battery, telemetry, and contact reporting for HA` ## Usage in HA Use meshcore-ha TCP mode and point it to the bridge host/port. diff --git a/repeater/meshcore_bridge.py b/repeater/meshcore_bridge.py index ca4d30e..6e701d5 100644 --- a/repeater/meshcore_bridge.py +++ b/repeater/meshcore_bridge.py @@ -115,6 +115,8 @@ async def _handle_payload(self, payload: bytes, writer: asyncio.StreamWriter) -> if cmd == CMD_APPSTART: await self._send_self_info(writer) + await self._send_device_info(writer) + await self._send_contacts(writer) return if cmd == CMD_DEVICE_QUERY and len(payload) > 1 and payload[1] == 0x03: @@ -230,8 +232,8 @@ async def _send_no_more_msgs(self, writer: asyncio.StreamWriter) -> None: await self._send_packet(writer, bytes([PKT_NO_MORE_MSGS])) async def _send_battery(self, writer: asyncio.StreamWriter) -> None: - # No battery on typical repeater hosts; report 0mV - payload = bytes([PKT_BATTERY]) + (0).to_bytes(2, "little") + # Report full LiPo to match HA expectations + payload = bytes([PKT_BATTERY]) + (4200).to_bytes(2, "little") await self._send_packet(writer, payload) async def _send_self_info(self, writer: asyncio.StreamWriter) -> None: @@ -253,8 +255,9 @@ async def _send_self_info(self, writer: asyncio.StreamWriter) -> None: max_tx_power = tx_power adv_type = 2 # repeater - radio_freq = int(radio.get("frequency", 0) / 1000) # Hz -> kHz - radio_bw = int(radio.get("bandwidth", 0) / 1000) # Hz -> kHz + # MeshCore expects Hz values in SELF_INFO + radio_freq = int(radio.get("frequency", 0)) + radio_bw = int(radio.get("bandwidth", 0)) radio_sf = int(radio.get("spreading_factor", 7)) radio_cr = int(radio.get("coding_rate", 5)) @@ -334,8 +337,16 @@ def _build_contacts(self) -> List[dict]: if self.daemon.repeater_handler and self.daemon.repeater_handler.storage: storage = self.daemon.repeater_handler.storage + # Match UI "Tracking" count: only include recent adverts and known contact types + allowed_types = {"chat node", "repeater", "room server"} + cutoff = time.time() - (168 * 3600) # 7 days, matches UI hours=168 + neighbors = storage.get_neighbors() if storage else {} for pubkey_hex, info in neighbors.items(): + contact_type = info.get("contact_type") + if not contact_type or str(contact_type).strip().lower() not in allowed_types: + continue + try: pubkey_bytes = bytes.fromhex(pubkey_hex) except Exception: @@ -343,15 +354,16 @@ def _build_contacts(self) -> List[dict]: pubkey_bytes = pubkey_bytes[:32].ljust(32, b"\x00") node_name = info.get("node_name") or "Unknown" - contact_type = info.get("contact_type") is_repeater = bool(info.get("is_repeater")) node_type = self._coerce_contact_type(contact_type, is_repeater) - out_path_len = -1 + out_path_len = 0 flags = 0 adv_name_bytes = node_name.encode("utf-8")[:32].ljust(32, b"\x00") last_seen = int(info.get("last_seen") or time.time()) + if last_seen < cutoff: + continue lat = float(info.get("latitude") or 0.0) lon = float(info.get("longitude") or 0.0) @@ -391,9 +403,127 @@ def _coerce_contact_type(self, contact_type, is_repeater: bool) -> int: async def _send_self_telemetry(self, writer: asyncio.StreamWriter) -> None: prefix = self._local_pubkey_prefix() - payload = bytes([PKT_TELEMETRY_RESPONSE]) + prefix + b"" + lpp_bytes = self._build_self_telemetry_lpp() + payload = bytes([PKT_TELEMETRY_RESPONSE]) + prefix + lpp_bytes await self._send_packet(writer, payload) + def _build_self_telemetry_lpp(self) -> bytes: + temp_c = self._get_cpu_temp_c() + if temp_c is None: + return b"" + + # Cayenne LPP temperature: channel, type(0x67), int16 value (0.1C), big-endian + temp10 = int(round(temp_c * 10)) + return bytes([1, 0x67]) + temp10.to_bytes(2, "big", signed=True) + + def _get_cpu_temp_c(self) -> float | None: + temps = None + try: + repeater_handler = getattr(self.daemon, "repeater_handler", None) + storage = getattr(repeater_handler, "storage", None) if repeater_handler else None + stats = storage.hardware_stats.get_stats() if storage and getattr(storage, "hardware_stats", None) else None + if isinstance(stats, dict): + temps = stats.get("temperatures") + except Exception: + temps = None + + if not temps: + return None + + preferred = ("cpu", "coretemp", "package", "soc", "thermal", "acpitz") + for key in preferred: + for name, value in temps.items(): + if key in name.lower(): + try: + return float(value) + except (TypeError, ValueError): + continue + + for value in temps.values(): + try: + return float(value) + except (TypeError, ValueError): + continue + + return None + + def _filter_self_lpp(self, lpp_bytes: bytes) -> bytes: + if not lpp_bytes: + return b"" + + filtered = bytearray() + i = 0 + while i + 1 < len(lpp_bytes): + channel = lpp_bytes[i] + lpp_type = lpp_bytes[i + 1] + i += 2 + + # Keep only channel 1 temperature for self telemetry + if channel == 1 and lpp_type == 0x67 and i + 2 <= len(lpp_bytes): + filtered.extend([channel, lpp_type]) + filtered.extend(lpp_bytes[i:i + 2]) + + # Skip payload for known types + if lpp_type in (0x00, 0x01): # digital in/out (1 byte) + i += 1 + elif lpp_type in (0x02, 0x03, 0x67, 0x71): # analog/temperature/baro (2 bytes) + i += 2 + elif lpp_type == 0x68: # humidity (1 byte) + i += 1 + elif lpp_type == 0x65: # illuminance (2 bytes) + i += 2 + elif lpp_type == 0x66: # presence (1 byte) + i += 1 + elif lpp_type == 0x73: # accelerometer (6 bytes) + i += 6 + elif lpp_type == 0x88: # gps (9 bytes) + i += 9 + else: + # Unknown type: stop to avoid misalignment + break + + return bytes(filtered) + + def _filter_lpp_drop_digital(self, lpp_bytes: bytes) -> bytes: + if not lpp_bytes: + return b"" + + filtered = bytearray() + i = 0 + while i + 1 < len(lpp_bytes): + channel = lpp_bytes[i] + lpp_type = lpp_bytes[i + 1] + i += 2 + + if lpp_type in (0x00, 0x01): # digital in/out (1 byte) + payload_len = 1 + elif lpp_type in (0x02, 0x03, 0x67, 0x71): # analog/temperature/baro (2 bytes) + payload_len = 2 + elif lpp_type == 0x68: # humidity (1 byte) + payload_len = 1 + elif lpp_type == 0x65: # illuminance (2 bytes) + payload_len = 2 + elif lpp_type == 0x66: # presence (1 byte) + payload_len = 1 + elif lpp_type == 0x73: # accelerometer (6 bytes) + payload_len = 6 + elif lpp_type == 0x88: # gps (9 bytes) + payload_len = 9 + else: + filtered.extend(lpp_bytes[i - 2 :]) + break + + if i + payload_len > len(lpp_bytes): + break + + if lpp_type not in (0x00, 0x01): + filtered.extend([channel, lpp_type]) + filtered.extend(lpp_bytes[i : i + payload_len]) + + i += payload_len + + return bytes(filtered) + async def _handle_binary_req(self, payload: bytes, writer: asyncio.StreamWriter) -> None: if len(payload) < 34: await self._send_error(writer, 0) @@ -439,6 +569,9 @@ async def _send_status_response_bytes(self, writer: asyncio.StreamWriter, pubkey await self._send_packet(writer, payload) async def _send_telemetry_response_bytes(self, writer: asyncio.StreamWriter, pubkey_prefix: bytes, lpp_bytes: bytes) -> None: + lpp_bytes = self._filter_lpp_drop_digital(lpp_bytes) + if pubkey_prefix[:6] == self._local_pubkey_prefix(): + lpp_bytes = self._filter_self_lpp(lpp_bytes) payload = bytes([PKT_TELEMETRY_RESPONSE]) + pubkey_prefix[:6] + lpp_bytes await self._send_packet(writer, payload) @@ -486,7 +619,7 @@ def _build_status_payload(self) -> bytes: stats = struct.pack( " Date: Sun, 25 Jan 2026 19:51:09 -0500 Subject: [PATCH 11/22] Harden CPU temp and telemetry filtering --- repeater/meshcore_bridge.py | 45 +++++++++++++++++++++++++++++++++---- 1 file changed, 41 insertions(+), 4 deletions(-) diff --git a/repeater/meshcore_bridge.py b/repeater/meshcore_bridge.py index 6e701d5..60ea524 100644 --- a/repeater/meshcore_bridge.py +++ b/repeater/meshcore_bridge.py @@ -417,6 +417,14 @@ def _build_self_telemetry_lpp(self) -> bytes: return bytes([1, 0x67]) + temp10.to_bytes(2, "big", signed=True) def _get_cpu_temp_c(self) -> float | None: + def _is_valid_temp(value: float | None) -> bool: + if value is None: + return False + try: + return 1.0 <= float(value) <= 120.0 + except (TypeError, ValueError): + return False + temps = None try: repeater_handler = getattr(self.daemon, "repeater_handler", None) @@ -428,24 +436,53 @@ def _get_cpu_temp_c(self) -> float | None: temps = None if not temps: - return None + return self._read_sysfs_cpu_temp_c() preferred = ("cpu", "coretemp", "package", "soc", "thermal", "acpitz") for key in preferred: for name, value in temps.items(): if key in name.lower(): try: - return float(value) + temp = float(value) + if _is_valid_temp(temp): + return temp except (TypeError, ValueError): continue for value in temps.values(): try: - return float(value) + temp = float(value) + if _is_valid_temp(temp): + return temp except (TypeError, ValueError): continue - return None + return self._read_sysfs_cpu_temp_c() + + def _read_sysfs_cpu_temp_c(self) -> float | None: + paths = [] + try: + from glob import glob + paths.extend(glob("/sys/class/thermal/thermal_zone*/temp")) + paths.extend(glob("/sys/class/hwmon/hwmon*/temp*_input")) + except Exception: + return None + + temps = [] + for path in paths: + try: + with open(path, "r", encoding="utf-8") as handle: + raw = handle.read().strip() + if not raw: + continue + val = float(raw) + temp_c = val / 1000.0 if val > 1000 else val + if 1.0 <= temp_c <= 120.0: + temps.append(temp_c) + except Exception: + continue + + return max(temps) if temps else None def _filter_self_lpp(self, lpp_bytes: bytes) -> bytes: if not lpp_bytes: From 5f58cde14c9b673579278b6293cdd2e9f16263a3 Mon Sep 17 00:00:00 2001 From: yellowcooln Date: Sun, 25 Jan 2026 19:57:38 -0500 Subject: [PATCH 12/22] Add telemetry debug logging --- repeater/meshcore_bridge.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/repeater/meshcore_bridge.py b/repeater/meshcore_bridge.py index 60ea524..61e2875 100644 --- a/repeater/meshcore_bridge.py +++ b/repeater/meshcore_bridge.py @@ -404,16 +404,19 @@ def _coerce_contact_type(self, contact_type, is_repeater: bool) -> int: async def _send_self_telemetry(self, writer: asyncio.StreamWriter) -> None: prefix = self._local_pubkey_prefix() lpp_bytes = self._build_self_telemetry_lpp() + logger.info("Self telemetry LPP bytes: %s", lpp_bytes.hex() if lpp_bytes else "") payload = bytes([PKT_TELEMETRY_RESPONSE]) + prefix + lpp_bytes await self._send_packet(writer, payload) def _build_self_telemetry_lpp(self) -> bytes: temp_c = self._get_cpu_temp_c() if temp_c is None: + logger.info("CPU temp unavailable; self telemetry empty") return b"" # Cayenne LPP temperature: channel, type(0x67), int16 value (0.1C), big-endian temp10 = int(round(temp_c * 10)) + logger.info("CPU temp %.2fC; self telemetry temp10=%s", temp_c, temp10) return bytes([1, 0x67]) + temp10.to_bytes(2, "big", signed=True) def _get_cpu_temp_c(self) -> float | None: @@ -606,9 +609,16 @@ async def _send_status_response_bytes(self, writer: asyncio.StreamWriter, pubkey await self._send_packet(writer, payload) async def _send_telemetry_response_bytes(self, writer: asyncio.StreamWriter, pubkey_prefix: bytes, lpp_bytes: bytes) -> None: + original_hex = lpp_bytes.hex() if lpp_bytes else "" lpp_bytes = self._filter_lpp_drop_digital(lpp_bytes) if pubkey_prefix[:6] == self._local_pubkey_prefix(): lpp_bytes = self._filter_self_lpp(lpp_bytes) + logger.info( + "Telemetry LPP pubkey=%s original=%s filtered=%s", + pubkey_prefix[:6].hex(), + original_hex if original_hex else "", + lpp_bytes.hex() if lpp_bytes else "", + ) payload = bytes([PKT_TELEMETRY_RESPONSE]) + pubkey_prefix[:6] + lpp_bytes await self._send_packet(writer, payload) From c268af381ed2aa11d7492e1bc43db159d29829c6 Mon Sep 17 00:00:00 2001 From: yellowcooln Date: Sun, 25 Jan 2026 20:07:21 -0500 Subject: [PATCH 13/22] Filter remote telemetry to temperature only --- repeater/meshcore_bridge.py | 59 +++++++++++++++++++------------------ 1 file changed, 31 insertions(+), 28 deletions(-) diff --git a/repeater/meshcore_bridge.py b/repeater/meshcore_bridge.py index 61e2875..19f4310 100644 --- a/repeater/meshcore_bridge.py +++ b/repeater/meshcore_bridge.py @@ -524,43 +524,44 @@ def _filter_self_lpp(self, lpp_bytes: bytes) -> bytes: return bytes(filtered) - def _filter_lpp_drop_digital(self, lpp_bytes: bytes) -> bytes: + def _filter_remote_lpp(self, lpp_bytes: bytes) -> bytes: if not lpp_bytes: return b"" filtered = bytearray() i = 0 - while i + 1 < len(lpp_bytes): + while i + 3 < len(lpp_bytes): channel = lpp_bytes[i] lpp_type = lpp_bytes[i + 1] i += 2 - if lpp_type in (0x00, 0x01): # digital in/out (1 byte) - payload_len = 1 - elif lpp_type in (0x02, 0x03, 0x67, 0x71): # analog/temperature/baro (2 bytes) - payload_len = 2 - elif lpp_type == 0x68: # humidity (1 byte) - payload_len = 1 - elif lpp_type == 0x65: # illuminance (2 bytes) - payload_len = 2 - elif lpp_type == 0x66: # presence (1 byte) - payload_len = 1 - elif lpp_type == 0x73: # accelerometer (6 bytes) - payload_len = 6 - elif lpp_type == 0x88: # gps (9 bytes) - payload_len = 9 - else: - filtered.extend(lpp_bytes[i - 2 :]) - break - - if i + payload_len > len(lpp_bytes): - break + if lpp_type == 0x67: + if i + 2 > len(lpp_bytes): + break + raw = lpp_bytes[i : i + 2] + temp10 = int.from_bytes(raw, "big", signed=True) + if temp10 != -1: + filtered.extend([channel, lpp_type]) + filtered.extend(raw) + i += 2 + continue - if lpp_type not in (0x00, 0x01): - filtered.extend([channel, lpp_type]) - filtered.extend(lpp_bytes[i : i + payload_len]) + # Skip payload for known types we don't want to forward + if lpp_type in (0x00, 0x01, 0x68, 0x66): # digital/humidity/presence (1 byte) + i += 1 + continue + if lpp_type in (0x02, 0x03, 0x71, 0x65): # analog/baro/illuminance (2 bytes) + i += 2 + continue + if lpp_type == 0x73: # accelerometer (6 bytes) + i += 6 + continue + if lpp_type == 0x88: # gps (9 bytes) + i += 9 + continue - i += payload_len + # Unknown type: stop parsing to avoid misalignment + break return bytes(filtered) @@ -610,9 +611,11 @@ async def _send_status_response_bytes(self, writer: asyncio.StreamWriter, pubkey async def _send_telemetry_response_bytes(self, writer: asyncio.StreamWriter, pubkey_prefix: bytes, lpp_bytes: bytes) -> None: original_hex = lpp_bytes.hex() if lpp_bytes else "" - lpp_bytes = self._filter_lpp_drop_digital(lpp_bytes) - if pubkey_prefix[:6] == self._local_pubkey_prefix(): + is_local = pubkey_prefix[:6] == self._local_pubkey_prefix() + if is_local: lpp_bytes = self._filter_self_lpp(lpp_bytes) + else: + lpp_bytes = self._filter_remote_lpp(lpp_bytes) logger.info( "Telemetry LPP pubkey=%s original=%s filtered=%s", pubkey_prefix[:6].hex(), From 69848ff1e49ce58999e9db5f30388028ef05d98c Mon Sep 17 00:00:00 2001 From: yellowcooln Date: Sun, 25 Jan 2026 20:11:50 -0500 Subject: [PATCH 14/22] Force remote temperature to channel 1 --- repeater/meshcore_bridge.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/repeater/meshcore_bridge.py b/repeater/meshcore_bridge.py index 19f4310..008ae5b 100644 --- a/repeater/meshcore_bridge.py +++ b/repeater/meshcore_bridge.py @@ -541,7 +541,8 @@ def _filter_remote_lpp(self, lpp_bytes: bytes) -> bytes: raw = lpp_bytes[i : i + 2] temp10 = int.from_bytes(raw, "big", signed=True) if temp10 != -1: - filtered.extend([channel, lpp_type]) + # Force all remote temperatures to channel 1 + filtered.extend([1, lpp_type]) filtered.extend(raw) i += 2 continue From a8e53cc46070ff8a5861f55a580e5e9e62971bfb Mon Sep 17 00:00:00 2001 From: yellowcooln Date: Sun, 25 Jan 2026 20:16:21 -0500 Subject: [PATCH 15/22] Swap LPP order for HA --- repeater/meshcore_bridge.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/repeater/meshcore_bridge.py b/repeater/meshcore_bridge.py index 008ae5b..dcbcdc9 100644 --- a/repeater/meshcore_bridge.py +++ b/repeater/meshcore_bridge.py @@ -414,10 +414,10 @@ def _build_self_telemetry_lpp(self) -> bytes: logger.info("CPU temp unavailable; self telemetry empty") return b"" - # Cayenne LPP temperature: channel, type(0x67), int16 value (0.1C), big-endian + # Cayenne LPP temperature (MeshCore/HA expects type first): type(0x67), channel, int16 value (0.1C), big-endian temp10 = int(round(temp_c * 10)) logger.info("CPU temp %.2fC; self telemetry temp10=%s", temp_c, temp10) - return bytes([1, 0x67]) + temp10.to_bytes(2, "big", signed=True) + return bytes([0x67, 1]) + temp10.to_bytes(2, "big", signed=True) def _get_cpu_temp_c(self) -> float | None: def _is_valid_temp(value: float | None) -> bool: @@ -542,7 +542,8 @@ def _filter_remote_lpp(self, lpp_bytes: bytes) -> bytes: temp10 = int.from_bytes(raw, "big", signed=True) if temp10 != -1: # Force all remote temperatures to channel 1 - filtered.extend([1, lpp_type]) + # MeshCore/HA expects type first, then channel + filtered.extend([lpp_type, 1]) filtered.extend(raw) i += 2 continue From ab649d2fa38aafa59cd6cbd99f060c244b875e41 Mon Sep 17 00:00:00 2001 From: yellowcooln Date: Sun, 25 Jan 2026 20:23:28 -0500 Subject: [PATCH 16/22] Add LPP length prefix for HA --- repeater/meshcore_bridge.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/repeater/meshcore_bridge.py b/repeater/meshcore_bridge.py index dcbcdc9..f532e73 100644 --- a/repeater/meshcore_bridge.py +++ b/repeater/meshcore_bridge.py @@ -414,10 +414,12 @@ def _build_self_telemetry_lpp(self) -> bytes: logger.info("CPU temp unavailable; self telemetry empty") return b"" - # Cayenne LPP temperature (MeshCore/HA expects type first): type(0x67), channel, int16 value (0.1C), big-endian + # Cayenne LPP temperature: channel, type(0x67), int16 value (0.1C), big-endian temp10 = int(round(temp_c * 10)) logger.info("CPU temp %.2fC; self telemetry temp10=%s", temp_c, temp10) - return bytes([0x67, 1]) + temp10.to_bytes(2, "big", signed=True) + lpp = bytes([1, 0x67]) + temp10.to_bytes(2, "big", signed=True) + # MeshCore telemetry expects a length prefix + return bytes([len(lpp)]) + lpp def _get_cpu_temp_c(self) -> float | None: def _is_valid_temp(value: float | None) -> bool: @@ -542,8 +544,7 @@ def _filter_remote_lpp(self, lpp_bytes: bytes) -> bytes: temp10 = int.from_bytes(raw, "big", signed=True) if temp10 != -1: # Force all remote temperatures to channel 1 - # MeshCore/HA expects type first, then channel - filtered.extend([lpp_type, 1]) + filtered.extend([1, lpp_type]) filtered.extend(raw) i += 2 continue @@ -618,6 +619,8 @@ async def _send_telemetry_response_bytes(self, writer: asyncio.StreamWriter, pub lpp_bytes = self._filter_self_lpp(lpp_bytes) else: lpp_bytes = self._filter_remote_lpp(lpp_bytes) + if lpp_bytes: + lpp_bytes = bytes([len(lpp_bytes)]) + lpp_bytes logger.info( "Telemetry LPP pubkey=%s original=%s filtered=%s", pubkey_prefix[:6].hex(), From b6ecdd6182b2a006db9887010965046f106cf6d1 Mon Sep 17 00:00:00 2001 From: yellowcooln Date: Wed, 28 Jan 2026 21:18:31 -0500 Subject: [PATCH 17/22] Add channels config and bridge message support --- channels.yaml.example | 6 + debian/pymc-repeater.install | 1 + debian/pymc-repeater.postinst | 7 + manage.sh | 8 +- repeater/meshcore_bridge.py | 370 +++++++++++++++++++++++++++++++++- 5 files changed, 384 insertions(+), 8 deletions(-) create mode 100644 channels.yaml.example diff --git a/channels.yaml.example b/channels.yaml.example new file mode 100644 index 0000000..c2deef9 --- /dev/null +++ b/channels.yaml.example @@ -0,0 +1,6 @@ +channels: + - name: "general" + # Use a 16-byte hex string or any UTF-8 string (will be used as-is). + secret: "00112233445566778899aabbccddeeff" + - name: "alerts" + secret: "alerts-secret" diff --git a/debian/pymc-repeater.install b/debian/pymc-repeater.install index 2cbb0af..6c44c2c 100644 --- a/debian/pymc-repeater.install +++ b/debian/pymc-repeater.install @@ -1,3 +1,4 @@ config.yaml.example usr/share/pymc_repeater/ +channels.yaml.example usr/share/pymc_repeater/ radio-presets.json usr/share/pymc_repeater/ radio-settings.json usr/share/pymc_repeater/ diff --git a/debian/pymc-repeater.postinst b/debian/pymc-repeater.postinst index 0b59d96..d064878 100755 --- a/debian/pymc-repeater.postinst +++ b/debian/pymc-repeater.postinst @@ -33,6 +33,13 @@ case "$1" in chmod 640 /etc/pymc_repeater/config.yaml fi + # Copy channels config if no channels file exists + if [ ! -f /etc/pymc_repeater/channels.yaml ]; then + cp /usr/share/pymc_repeater/channels.yaml.example /etc/pymc_repeater/channels.yaml + chown pymc-repeater:pymc-repeater /etc/pymc_repeater/channels.yaml + chmod 640 /etc/pymc_repeater/channels.yaml + fi + # Install pymc_core from PyPI if not already installed if ! python3 -c "import pymc_core" 2>/dev/null; then echo "Installing pymc_core dependency from PyPI..." diff --git a/manage.sh b/manage.sh index ec61e9d..7ab35f0 100755 --- a/manage.sh +++ b/manage.sh @@ -211,7 +211,7 @@ install_repeater() { echo "25"; echo "# Installing system dependencies..." apt-get update -qq apt-get install -y libffi-dev jq pip python3-rrdtool wget swig build-essential python3-dev - pip install --break-system-packages setuptools_scm >/dev/null 2>&1 || true + pip install setuptools_scm >/dev/null 2>&1 || true # Install mikefarah yq v4 if not already installed if ! command -v yq &> /dev/null || [[ "$(yq --version 2>&1)" != *"mikefarah/yq"* ]]; then @@ -323,7 +323,7 @@ EOF echo "Note: Using optimized binary wheels for faster installation" echo "" - if pip install --break-system-packages --force-reinstall --no-cache-dir .; then + if pip install --no-cache-dir .; then echo "" echo "✓ Python package installation completed successfully!" @@ -399,7 +399,7 @@ upgrade_repeater() { apt-get update -qq apt-get install -y libffi-dev jq pip python3-rrdtool wget swig build-essential python3-dev - pip install --break-system-packages setuptools_scm >/dev/null 2>&1 || true + pip install setuptools_scm >/dev/null 2>&1 || true # Install mikefarah yq v4 if not already installed if ! command -v yq &> /dev/null || [[ "$(yq --version 2>&1)" != *"mikefarah/yq"* ]]; then @@ -508,7 +508,7 @@ EOF echo "" # Upgrade packages (uses cache for unchanged dependencies - much faster) - if python3 -m pip install --break-system-packages --upgrade --upgrade-strategy eager .; then + if python3 -m pip install --upgrade --upgrade-strategy eager .; then echo "" echo "✓ Package and dependencies updated successfully!" else diff --git a/repeater/meshcore_bridge.py b/repeater/meshcore_bridge.py index f532e73..7a55ac4 100644 --- a/repeater/meshcore_bridge.py +++ b/repeater/meshcore_bridge.py @@ -6,9 +6,11 @@ import time from typing import Coroutine, List, Optional +import yaml + from pymc_core.protocol.packet_builder import PacketBuilder from pymc_core.node.handlers.protocol_request import REQ_TYPE_GET_STATUS -from pymc_core.protocol.constants import PAYLOAD_TYPE_PATH, PAYLOAD_TYPE_RESPONSE +from pymc_core.protocol.constants import PAYLOAD_TYPE_PATH, PAYLOAD_TYPE_RESPONSE, PAYLOAD_TYPE_TXT_MSG from pymc_core.protocol.crypto import CryptoUtils from pymc_core.protocol.identity import Identity @@ -24,10 +26,13 @@ PKT_CONTACT_END = 0x04 PKT_SELF_INFO = 0x05 PKT_MSG_SENT = 0x06 +PKT_CONTACT_MSG_RECV = 0x07 +PKT_CHANNEL_MSG_RECV = 0x08 PKT_CURRENT_TIME = 0x09 PKT_NO_MORE_MSGS = 0x0A PKT_BATTERY = 0x0C PKT_DEVICE_INFO = 0x0D +PKT_CHANNEL_INFO = 0x12 PKT_STATUS_RESPONSE = 0x87 PKT_LOGIN_SUCCESS = 0x85 PKT_LOGIN_FAILED = 0x86 @@ -43,10 +48,12 @@ CMD_RESET_PATH = 0x0D CMD_GET_BAT = 0x14 CMD_DEVICE_QUERY = 0x16 +CMD_GET_CHANNEL = 0x1F CMD_SEND_LOGIN = 0x1A CMD_SEND_STATUSREQ = 0x1B CMD_SEND_LOGOUT = 0x1D CMD_SEND_MSG = 0x02 +CMD_SEND_CHANNEL_MSG = 0x03 CMD_GET_MSG = 0x0A CMD_GET_SELF_TELEMETRY = 0x27 CMD_BINARY_REQ = 0x32 @@ -67,6 +74,9 @@ def __init__(self, daemon, host: str = "0.0.0.0", port: int = 5000) -> None: self.port = port self._server: Optional[asyncio.base_events.Server] = None self._pending_requests: dict[tuple[int, str], dict] = {} + self._channels_cache: Optional[list] = None + self._channels_mtime: Optional[float] = None + self._channels_path: Optional[str] = None async def start(self) -> None: self._server = await asyncio.start_server(self._handle_client, self.host, self.port) @@ -127,6 +137,11 @@ async def _handle_payload(self, payload: bytes, writer: asyncio.StreamWriter) -> await self._send_contacts(writer) return + if cmd == CMD_GET_CHANNEL: + channel_idx = payload[1] if len(payload) > 1 else 0 + await self._send_channel_info(writer, channel_idx) + return + if cmd == CMD_SEND_ADVERT: await self._send_ok(writer) return @@ -179,7 +194,15 @@ async def _handle_payload(self, payload: bytes, writer: asyncio.StreamWriter) -> await self._handle_binary_req(payload, writer) return - if cmd in (CMD_SEND_LOGIN, CMD_SEND_STATUSREQ, CMD_SEND_LOGOUT, CMD_SEND_MSG): + if cmd == CMD_SEND_MSG: + await self._handle_send_msg(payload, writer) + return + + if cmd == CMD_SEND_CHANNEL_MSG: + await self._handle_send_channel_msg(payload, writer) + return + + if cmd in (CMD_SEND_LOGIN, CMD_SEND_STATUSREQ, CMD_SEND_LOGOUT): await self._send_msg_sent(writer) if cmd == CMD_SEND_STATUSREQ: contact = self._contact_from_payload(payload, offset=1) @@ -309,6 +332,13 @@ async def _send_device_info(self, writer: asyncio.StreamWriter) -> None: await self._send_packet(writer, bytes(payload)) + async def _send_channel_info(self, writer: asyncio.StreamWriter, channel_idx: int) -> None: + name, secret = self._get_channel_info(channel_idx) + name_bytes = name.encode("utf-8")[:32].ljust(32, b"\x00") + secret_bytes = secret[:16].ljust(16, b"\x00") + payload = bytes([PKT_CHANNEL_INFO, channel_idx & 0xFF]) + name_bytes + secret_bytes + await self._send_packet(writer, payload) + async def _send_contacts(self, writer: asyncio.StreamWriter) -> None: contacts = self._build_contacts() await self._send_packet(writer, bytes([PKT_CONTACT_START]) + len(contacts).to_bytes(4, "little")) @@ -719,6 +749,19 @@ def _contact_from_pubkey_bytes(self, pubkey_bytes: bytes) -> Optional["_SimpleCo return None return _SimpleContact(public_key=pubkey_bytes[:32].hex(), contact_type=2) + def _contact_from_prefix(self, prefix: bytes) -> Optional["_SimpleContact"]: + if not prefix or len(prefix) < 1: + return None + neighbor = self._lookup_neighbor_by_prefix(prefix) + if neighbor: + return _SimpleContact( + public_key=neighbor["public_key"], + contact_type=neighbor.get("type", 2), + out_path=neighbor.get("out_path"), + out_path_len=neighbor.get("out_path_len"), + ) + return None + def _parse_login_password(self, payload: bytes, offset: int) -> Optional[str]: if len(payload) <= offset: return "" @@ -742,8 +785,10 @@ def _register_pending(self, contact: "_SimpleContact", kind: str, writer: asynci async def handle_rf_packet(self, packet) -> bool: payload_type = packet.get_payload_type() - if payload_type not in (PAYLOAD_TYPE_PATH, PAYLOAD_TYPE_RESPONSE): + if payload_type not in (PAYLOAD_TYPE_PATH, PAYLOAD_TYPE_RESPONSE, PAYLOAD_TYPE_TXT_MSG): return False + if payload_type == PAYLOAD_TYPE_TXT_MSG: + return await self._handle_rf_txt_msg(packet) if len(packet.payload) < 3: return False @@ -800,6 +845,51 @@ async def handle_rf_packet(self, packet) -> bool: return False + async def _handle_rf_txt_msg(self, packet) -> bool: + if len(packet.payload) < 4: + return False + src_hash = packet.payload[1] + pending_cmd = self._pending_requests.get((src_hash, "cmd")) + if not pending_cmd: + return False + + contact = pending_cmd.get("contact") + if not contact: + return False + + identity = self.daemon.local_identity + if not identity: + return False + + try: + contact_pubkey = bytes.fromhex(contact.public_key) + peer_id = Identity(contact_pubkey) + shared_secret = peer_id.calc_shared_secret(identity.get_private_key()) + aes_key = shared_secret[:16] + plaintext = CryptoUtils.mac_then_decrypt(aes_key, shared_secret, bytes(packet.payload[2:])) + except Exception as exc: + logger.error("Failed to decrypt RF text response: %s", exc) + return False + + if not plaintext or len(plaintext) < 5: + return False + + timestamp = int.from_bytes(plaintext[:4], "little") + flags = plaintext[4] + txt_type = (flags >> 2) & 0x3F + text = plaintext[5:].decode("utf-8", "ignore") + pubkey_prefix = contact_pubkey[:6] + + await self._send_contact_msg_recv( + pending_cmd["writer"], + pubkey_prefix, + txt_type, + timestamp, + text, + ) + self._pending_requests.pop((src_hash, "cmd"), None) + return True + async def _send_rf_login(self, contact: "_SimpleContact", password: str) -> None: identity = self.daemon.local_identity dispatcher = self.daemon.dispatcher @@ -888,6 +978,269 @@ async def _send_rf_reset_path(self, contact: "_SimpleContact") -> None: except Exception as exc: logger.error("RF reset path failed: %s", exc, exc_info=True) + async def _send_rf_text_message(self, contact: "_SimpleContact", message: str, attempt: int = 0) -> None: + identity = self.daemon.local_identity + dispatcher = self.daemon.dispatcher + if not identity or not dispatcher: + logger.warning("RF text send skipped: local identity or dispatcher missing") + return + try: + packet, _crc = PacketBuilder.create_text_message( + contact=contact, + local_identity=identity, + message=message, + attempt=attempt, + message_type="direct", + ) + await dispatcher.send_packet(packet, wait_for_ack=False) + self._record_tx_packet(packet) + logger.info("RF text message sent to %s", contact.public_key[:12]) + except Exception as exc: + logger.error("RF text message failed: %s", exc, exc_info=True) + + async def _send_rf_cli_command(self, contact: "_SimpleContact", command: str) -> None: + identity = self.daemon.local_identity + dispatcher = self.daemon.dispatcher + if not identity or not dispatcher: + logger.warning("RF CLI send skipped: local identity or dispatcher missing") + return + try: + contact_pubkey = bytes.fromhex(contact.public_key) + peer_id = Identity(contact_pubkey) + shared_secret = peer_id.calc_shared_secret(identity.get_private_key()) + flags = (0x01 << 2) # txt_type=CLI data + timestamp = int(time.time()) + plaintext = timestamp.to_bytes(4, "little") + bytes([flags]) + command.encode("utf-8") + route_type = "direct" + if getattr(contact, "out_path_len", 0) < 0: + route_type = "flood" + packet = PacketBuilder.create_datagram( + ptype=PAYLOAD_TYPE_TXT_MSG, + dest=Identity(contact_pubkey), + local_identity=identity, + secret=shared_secret, + plaintext=plaintext, + route_type=route_type, + ) + if getattr(contact, "out_path_len", 0) > 0 and getattr(contact, "out_path", None): + packet.path = bytearray(contact.out_path[: contact.out_path_len]) + packet.path_len = contact.out_path_len + await dispatcher.send_packet(packet, wait_for_ack=False) + self._record_tx_packet(packet) + logger.info("RF CLI command sent to %s", contact.public_key[:12]) + except Exception as exc: + logger.error("RF CLI command failed: %s", exc, exc_info=True) + + async def _handle_send_msg(self, payload: bytes, writer: asyncio.StreamWriter) -> None: + if len(payload) < 3: + await self._send_error(writer, 0) + return + mode = payload[1] + if mode == 0x01: + await self._handle_send_cmd(payload, writer) + return + if mode != 0x00: + await self._send_error(writer, 0) + return + + if len(payload) < 3 + 4 + 6: + await self._send_error(writer, 0) + return + attempt = payload[2] + dst_offset = 3 + 4 + dst_prefix = payload[dst_offset : dst_offset + 6] + msg = payload[dst_offset + 6 :].decode("utf-8", "ignore") + + await self._send_msg_sent(writer) + + contact = self._contact_from_prefix(dst_prefix) + if contact: + self._schedule_rf_task(self._send_rf_text_message(contact, msg, attempt=attempt), "send_msg") + else: + logger.warning("send_msg: contact not found for prefix %s", dst_prefix.hex()) + + async def _handle_send_cmd(self, payload: bytes, writer: asyncio.StreamWriter) -> None: + if len(payload) < 3 + 4 + 6: + await self._send_error(writer, 0) + return + if payload[1] != 0x01: + await self._send_error(writer, 0) + return + dst_offset = 3 + 4 + dst_prefix = payload[dst_offset : dst_offset + 6] + cmd = payload[dst_offset + 6 :].decode("utf-8", "ignore") + + await self._send_msg_sent(writer) + + contact = self._contact_from_prefix(dst_prefix) + if contact: + self._register_pending(contact, "cmd", writer) + self._schedule_rf_task(self._send_rf_cli_command(contact, cmd), "send_cmd") + else: + logger.warning("send_cmd: contact not found for prefix %s", dst_prefix.hex()) + + async def _handle_send_channel_msg(self, payload: bytes, writer: asyncio.StreamWriter) -> None: + if len(payload) < 1 + 1 + 1 + 4: + await self._send_error(writer, 0) + return + channel_idx = payload[2] + msg = payload[3 + 4 :].decode("utf-8", "ignore") + await self._send_ok(writer) + self._schedule_rf_task( + self._send_rf_channel_message(channel_idx, msg), + "send_channel_msg", + ) + + async def _send_contact_msg_recv( + self, + writer: asyncio.StreamWriter, + pubkey_prefix: bytes, + txt_type: int, + sender_timestamp: int, + text: str, + ) -> None: + payload = bytearray() + payload.append(PKT_CONTACT_MSG_RECV) + payload.extend(pubkey_prefix[:6]) + payload.append(0) # path_len + payload.append(txt_type & 0xFF) + payload.extend(int(sender_timestamp).to_bytes(4, "little")) + payload.extend(text.encode("utf-8")) + await self._send_packet(writer, bytes(payload)) + + def _lookup_neighbor_by_prefix(self, prefix: bytes) -> Optional[dict]: + storage = None + if self.daemon.repeater_handler and self.daemon.repeater_handler.storage: + storage = self.daemon.repeater_handler.storage + neighbors = storage.get_neighbors() if storage else {} + prefix_hex = prefix.hex() + for pubkey_hex, info in neighbors.items(): + if pubkey_hex.startswith(prefix_hex): + return { + "public_key": pubkey_hex, + "type": self._coerce_contact_type(info.get("contact_type"), bool(info.get("is_repeater"))), + "out_path": info.get("out_path"), + "out_path_len": info.get("out_path_len", 0), + } + return None + + def _load_channels(self) -> list: + if self._channels_path is None: + config_path = getattr(self.daemon, "config_path", None) + if config_path: + base_dir = os.path.dirname(config_path) + else: + base_dir = "/etc/pymc_repeater" + self._channels_path = os.path.join(base_dir, "channels.yaml") + + try: + stat = os.stat(self._channels_path) + except FileNotFoundError: + self._channels_cache = [] + self._channels_mtime = None + return [] + except OSError: + return self._channels_cache or [] + + if self._channels_mtime is not None and stat.st_mtime == self._channels_mtime: + return self._channels_cache or [] + + try: + with open(self._channels_path, "r", encoding="utf-8") as handle: + data = yaml.safe_load(handle) or {} + except Exception as exc: + logger.warning("Failed to read channels file: %s", exc) + return self._channels_cache or [] + + channels = data.get("channels") if isinstance(data, dict) else data + if not isinstance(channels, list): + channels = [] + + self._channels_cache = channels + self._channels_mtime = stat.st_mtime + return channels + + def _get_channel_info(self, channel_idx: int) -> tuple[str, bytes]: + channels = self._load_channels() + if channels and 0 <= channel_idx < len(channels): + entry = channels[channel_idx] or {} + name = entry.get("name") or entry.get("channel_name") or f"channel_{channel_idx}" + secret = entry.get("secret") or entry.get("channel_secret") or b"" + if isinstance(secret, str): + try: + secret_bytes = bytes.fromhex(secret) + except ValueError: + secret_bytes = secret.encode("utf-8") + else: + secret_bytes = bytes(secret) if secret else b"" + return name, secret_bytes + + config = self.daemon.config or {} + candidates = [ + config.get("channels"), + config.get("repeater", {}).get("channels"), + config.get("radio", {}).get("channels"), + ] + channels = None + for candidate in candidates: + if isinstance(candidate, list): + channels = candidate + break + if channels and 0 <= channel_idx < len(channels): + entry = channels[channel_idx] or {} + name = entry.get("name") or entry.get("channel_name") or f"channel_{channel_idx}" + secret = entry.get("secret") or entry.get("channel_secret") or b"" + if isinstance(secret, str): + try: + secret_bytes = bytes.fromhex(secret) + except ValueError: + secret_bytes = secret.encode("utf-8") + else: + secret_bytes = bytes(secret) if secret else b"" + return name, secret_bytes + + return f"channel_{channel_idx}", b"" + + async def _send_rf_channel_message(self, channel_idx: int, message: str) -> None: + dispatcher = self.daemon.dispatcher + identity = self.daemon.local_identity + if not dispatcher or not identity: + logger.warning("RF channel send skipped: local identity or dispatcher missing") + return + + channels = self._load_channels() + if not channels or channel_idx < 0 or channel_idx >= len(channels): + logger.warning("RF channel send skipped: channel %s not configured", channel_idx) + return + + entry = channels[channel_idx] or {} + name = entry.get("name") or entry.get("channel_name") or f"channel_{channel_idx}" + secret = entry.get("secret") or entry.get("channel_secret") or "" + if not secret: + logger.warning("RF channel send skipped: channel %s has no secret", channel_idx) + return + + node_name = ( + self.daemon.config.get("repeater", {}).get("node_name") + if self.daemon and self.daemon.config + else "PyMC-Repeater" + ) + + channels_config = [{"name": name, "secret": secret}] + try: + packet = PacketBuilder.create_group_datagram( + group_name=name, + local_identity=identity, + message=message, + sender_name=node_name or "PyMC-Repeater", + channels_config=channels_config, + ) + await dispatcher.send_packet(packet, wait_for_ack=False) + self._record_tx_packet(packet) + logger.info("RF channel message sent to %s", name) + except Exception as exc: + logger.error("RF channel message failed: %s", exc, exc_info=True) + def _record_tx_packet(self, packet) -> None: handler = getattr(self.daemon, "repeater_handler", None) if not handler: @@ -975,7 +1328,16 @@ def _pymc_status_to_meshcore(self, data: bytes) -> Optional[bytes]: class _SimpleContact: - def __init__(self, public_key: str, contact_type: int = 2, sync_since: int = 0) -> None: + def __init__( + self, + public_key: str, + contact_type: int = 2, + sync_since: int = 0, + out_path: Optional[list] = None, + out_path_len: Optional[int] = None, + ) -> None: self.public_key = public_key self.type = contact_type self.sync_since = sync_since + self.out_path = out_path or [] + self.out_path_len = out_path_len if out_path_len is not None else 0 From 13255c02824521f907da5752b62a1275e64bd1ff Mon Sep 17 00:00:00 2001 From: yellowcooln Date: Wed, 28 Jan 2026 21:26:13 -0500 Subject: [PATCH 18/22] Copy channels config on install/upgrade --- manage.sh | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/manage.sh b/manage.sh index 7ab35f0..9a1f84e 100755 --- a/manage.sh +++ b/manage.sh @@ -261,6 +261,13 @@ install_repeater() { if [ ! -f "$CONFIG_DIR/config.yaml" ]; then cp "$SCRIPT_DIR/config.yaml.example" "$CONFIG_DIR/config.yaml" fi + # Channels config (for MeshCore HA channel support) + if [ -f "$SCRIPT_DIR/channels.yaml.example" ]; then + cp "$SCRIPT_DIR/channels.yaml.example" "$CONFIG_DIR/channels.yaml.example" + if [ ! -f "$CONFIG_DIR/channels.yaml" ]; then + cp "$SCRIPT_DIR/channels.yaml.example" "$CONFIG_DIR/channels.yaml" + fi + fi echo "55"; echo "# Installing systemd service..." cp "$SCRIPT_DIR/pymc-repeater.service" /etc/systemd/system/ @@ -453,6 +460,13 @@ upgrade_repeater() { else echo " ⚠ Configuration validation failed, keeping existing config" fi + # Ensure channels config exists after upgrade + if [ -f "$SCRIPT_DIR/channels.yaml.example" ]; then + cp "$SCRIPT_DIR/channels.yaml.example" "$CONFIG_DIR/channels.yaml.example" 2>/dev/null || true + if [ ! -f "$CONFIG_DIR/channels.yaml" ]; then + cp "$SCRIPT_DIR/channels.yaml.example" "$CONFIG_DIR/channels.yaml" 2>/dev/null || true + fi + fi echo "[6/9] Fixing permissions..." chown -R "$SERVICE_USER:$SERVICE_USER" "$INSTALL_DIR" "$CONFIG_DIR" "$LOG_DIR" /var/lib/pymc_repeater 2>/dev/null || true From d63a45f49f233f5b29a64daa8f7662b5ca753e8a Mon Sep 17 00:00:00 2001 From: yellowcooln Date: Wed, 28 Jan 2026 21:39:21 -0500 Subject: [PATCH 19/22] Add verbose MeshCore bridge logging --- config.yaml.example | 2 + repeater/meshcore_bridge.py | 94 +++++++++++++++++++++++++++++++++++++ 2 files changed, 96 insertions(+) diff --git a/config.yaml.example b/config.yaml.example index 0afb79d..0e139c9 100644 --- a/config.yaml.example +++ b/config.yaml.example @@ -65,6 +65,8 @@ repeater: meshcore_bridge: # Enable MeshCore TCP bridge enabled: false + # Verbose logging for MeshCore TCP bridge + debug: true # TCP listen host/port (meshcore-ha default TCP port is 5000) host: "0.0.0.0" diff --git a/repeater/meshcore_bridge.py b/repeater/meshcore_bridge.py index 7a55ac4..d8cffb5 100644 --- a/repeater/meshcore_bridge.py +++ b/repeater/meshcore_bridge.py @@ -66,6 +66,50 @@ DEFAULT_TIMEOUT_MS = 4000 +CMD_NAMES = { + CMD_APPSTART: "APPSTART", + CMD_GET_CONTACTS: "GET_CONTACTS", + CMD_GET_TIME: "GET_TIME", + CMD_SET_TIME: "SET_TIME", + CMD_SEND_ADVERT: "SEND_ADVERT", + CMD_RESET_PATH: "RESET_PATH", + CMD_GET_BAT: "GET_BAT", + CMD_DEVICE_QUERY: "DEVICE_QUERY", + CMD_GET_CHANNEL: "GET_CHANNEL", + CMD_SEND_LOGIN: "SEND_LOGIN", + CMD_SEND_STATUSREQ: "SEND_STATUSREQ", + CMD_SEND_LOGOUT: "SEND_LOGOUT", + CMD_SEND_MSG: "SEND_MSG", + CMD_SEND_CHANNEL_MSG: "SEND_CHANNEL_MSG", + CMD_GET_MSG: "GET_MSG", + CMD_GET_SELF_TELEMETRY: "GET_SELF_TELEMETRY", + CMD_BINARY_REQ: "BINARY_REQ", + CMD_SET_OTHER_PARAMS: "SET_OTHER_PARAMS", + CMD_SEND_PATH_DISCOVERY: "PATH_DISCOVERY", +} + +PKT_NAMES = { + PKT_OK: "OK", + PKT_ERROR: "ERROR", + PKT_CONTACT_START: "CONTACT_START", + PKT_CONTACT: "CONTACT", + PKT_CONTACT_END: "CONTACT_END", + PKT_SELF_INFO: "SELF_INFO", + PKT_MSG_SENT: "MSG_SENT", + PKT_CONTACT_MSG_RECV: "CONTACT_MSG_RECV", + PKT_CHANNEL_MSG_RECV: "CHANNEL_MSG_RECV", + PKT_CURRENT_TIME: "CURRENT_TIME", + PKT_NO_MORE_MSGS: "NO_MORE_MSGS", + PKT_BATTERY: "BATTERY", + PKT_DEVICE_INFO: "DEVICE_INFO", + PKT_CHANNEL_INFO: "CHANNEL_INFO", + PKT_STATUS_RESPONSE: "STATUS_RESPONSE", + PKT_LOGIN_SUCCESS: "LOGIN_SUCCESS", + PKT_LOGIN_FAILED: "LOGIN_FAILED", + PKT_TELEMETRY_RESPONSE: "TELEMETRY_RESPONSE", + PKT_BINARY_RESPONSE: "BINARY_RESPONSE", +} + class MeshcoreTCPBridge: def __init__(self, daemon, host: str = "0.0.0.0", port: int = 5000) -> None: @@ -77,6 +121,14 @@ def __init__(self, daemon, host: str = "0.0.0.0", port: int = 5000) -> None: self._channels_cache: Optional[list] = None self._channels_mtime: Optional[float] = None self._channels_path: Optional[str] = None + try: + debug_enabled = bool( + (daemon.config or {}).get("meshcore_bridge", {}).get("debug", False) + ) + if debug_enabled: + logger.setLevel(logging.DEBUG) + except Exception: + pass async def start(self) -> None: self._server = await asyncio.start_server(self._handle_client, self.host, self.port) @@ -122,31 +174,39 @@ async def _handle_payload(self, payload: bytes, writer: asyncio.StreamWriter) -> if not payload: return cmd = payload[0] + logger.info("TCP cmd %s (0x%02X) len=%s", CMD_NAMES.get(cmd, "UNKNOWN"), cmd, len(payload)) + logger.debug("TCP cmd payload: %s", payload.hex()) if cmd == CMD_APPSTART: + logger.info("TCP APPSTART -> SELF_INFO/DEVICE_INFO/CONTACTS") await self._send_self_info(writer) await self._send_device_info(writer) await self._send_contacts(writer) return if cmd == CMD_DEVICE_QUERY and len(payload) > 1 and payload[1] == 0x03: + logger.info("TCP DEVICE_QUERY -> DEVICE_INFO") await self._send_device_info(writer) return if cmd == CMD_GET_CONTACTS: + logger.info("TCP GET_CONTACTS -> CONTACTS") await self._send_contacts(writer) return if cmd == CMD_GET_CHANNEL: channel_idx = payload[1] if len(payload) > 1 else 0 + logger.info("TCP GET_CHANNEL idx=%s -> CHANNEL_INFO", channel_idx) await self._send_channel_info(writer, channel_idx) return if cmd == CMD_SEND_ADVERT: + logger.info("TCP SEND_ADVERT -> OK") await self._send_ok(writer) return if cmd == CMD_RESET_PATH: + logger.info("TCP RESET_PATH -> OK + RF trace") await self._send_ok(writer) contact = self._contact_from_payload(payload, offset=1) if contact: @@ -154,36 +214,44 @@ async def _handle_payload(self, payload: bytes, writer: asyncio.StreamWriter) -> return if cmd == CMD_SET_TIME: + logger.info("TCP SET_TIME -> OK") await self._send_ok(writer) return if cmd == CMD_SET_OTHER_PARAMS: + logger.info("TCP SET_OTHER_PARAMS -> OK") await self._send_ok(writer) return if cmd == CMD_GET_TIME: + logger.info("TCP GET_TIME -> CURRENT_TIME") await self._send_current_time(writer) return if cmd == CMD_GET_BAT: + logger.info("TCP GET_BAT -> BATTERY") await self._send_battery(writer) return if cmd == CMD_GET_MSG: + logger.info("TCP GET_MSG -> NO_MORE_MSGS") await self._send_no_more_msgs(writer) return if cmd == CMD_GET_SELF_TELEMETRY: if len(payload) >= 37: + logger.info("TCP GET_SELF_TELEMETRY (remote) -> MSG_SENT + RF telem") await self._send_msg_sent(writer) contact = self._contact_from_payload(payload, offset=5) if contact: self._schedule_rf_task(self._send_rf_telem_request(contact), "telemetry_request") else: + logger.info("TCP GET_SELF_TELEMETRY (local) -> TELEMETRY_RESPONSE") await self._send_self_telemetry(writer) return if cmd == CMD_SEND_PATH_DISCOVERY: + logger.info("TCP PATH_DISCOVERY -> MSG_SENT + RF trace") await self._send_msg_sent(writer) contact = self._contact_from_payload(payload, offset=2) if contact: @@ -191,18 +259,22 @@ async def _handle_payload(self, payload: bytes, writer: asyncio.StreamWriter) -> return if cmd == CMD_BINARY_REQ: + logger.info("TCP BINARY_REQ -> MSG_SENT + RF + BINARY_RESPONSE") await self._handle_binary_req(payload, writer) return if cmd == CMD_SEND_MSG: + logger.info("TCP SEND_MSG -> MSG_SENT + RF") await self._handle_send_msg(payload, writer) return if cmd == CMD_SEND_CHANNEL_MSG: + logger.info("TCP SEND_CHANNEL_MSG -> OK + RF") await self._handle_send_channel_msg(payload, writer) return if cmd in (CMD_SEND_LOGIN, CMD_SEND_STATUSREQ, CMD_SEND_LOGOUT): + logger.info("TCP %s -> MSG_SENT + RF", CMD_NAMES.get(cmd, "UNKNOWN")) await self._send_msg_sent(writer) if cmd == CMD_SEND_STATUSREQ: contact = self._contact_from_payload(payload, offset=1) @@ -231,6 +303,10 @@ async def _handle_payload(self, payload: bytes, writer: asyncio.StreamWriter) -> async def _send_packet(self, writer: asyncio.StreamWriter, payload: bytes) -> None: pkt = bytes([FRAME_START]) + len(payload).to_bytes(2, byteorder="little") + payload + if payload: + pkt_type = payload[0] + logger.info("TCP resp %s (0x%02X) len=%s", PKT_NAMES.get(pkt_type, "UNKNOWN"), pkt_type, len(payload)) + logger.debug("TCP resp payload: %s", payload.hex()) writer.write(pkt) await writer.drain() @@ -772,6 +848,7 @@ def _parse_login_password(self, payload: bytes, offset: int) -> Optional[str]: def _schedule_rf_task(self, coro: Coroutine, action: str) -> None: try: + logger.info("Scheduling RF action: %s", action) asyncio.create_task(coro) except Exception as exc: logger.error("Failed to schedule RF %s: %s", action, exc) @@ -834,11 +911,13 @@ async def handle_rf_packet(self, packet) -> bool: if pending_status and len(payload) >= 58: status_bytes = self._pymc_status_to_meshcore(payload[:58]) if status_bytes: + logger.info("RF status response pubkey=%s", pubkey_prefix[:6].hex()) await self._send_status_response_bytes(pending_status["writer"], pubkey_prefix, status_bytes) self._pending_requests.pop((src_hash, "status"), None) return True if pending_telemetry and len(payload) > 0: + logger.info("RF telemetry response pubkey=%s len=%s", pubkey_prefix[:6].hex(), len(payload)) await self._send_telemetry_response_bytes(pending_telemetry["writer"], pubkey_prefix, payload) self._pending_requests.pop((src_hash, "telemetry"), None) return True @@ -880,6 +959,12 @@ async def _handle_rf_txt_msg(self, packet) -> bool: text = plaintext[5:].decode("utf-8", "ignore") pubkey_prefix = contact_pubkey[:6] + logger.info( + "RF text response pubkey=%s txt_type=%s text=%s", + pubkey_prefix[:6].hex(), + txt_type, + text, + ) await self._send_contact_msg_recv( pending_cmd["writer"], pubkey_prefix, @@ -1051,6 +1136,12 @@ async def _handle_send_msg(self, payload: bytes, writer: asyncio.StreamWriter) - dst_prefix = payload[dst_offset : dst_offset + 6] msg = payload[dst_offset + 6 :].decode("utf-8", "ignore") + logger.info( + "TCP send_msg: dst=%s attempt=%s msg_len=%s", + dst_prefix.hex(), + attempt, + len(msg), + ) await self._send_msg_sent(writer) contact = self._contact_from_prefix(dst_prefix) @@ -1070,6 +1161,7 @@ async def _handle_send_cmd(self, payload: bytes, writer: asyncio.StreamWriter) - dst_prefix = payload[dst_offset : dst_offset + 6] cmd = payload[dst_offset + 6 :].decode("utf-8", "ignore") + logger.info("TCP send_cmd: dst=%s cmd=%s", dst_prefix.hex(), cmd) await self._send_msg_sent(writer) contact = self._contact_from_prefix(dst_prefix) @@ -1085,6 +1177,7 @@ async def _handle_send_channel_msg(self, payload: bytes, writer: asyncio.StreamW return channel_idx = payload[2] msg = payload[3 + 4 :].decode("utf-8", "ignore") + logger.info("TCP send_channel_msg: channel_idx=%s msg_len=%s", channel_idx, len(msg)) await self._send_ok(writer) self._schedule_rf_task( self._send_rf_channel_message(channel_idx, msg), @@ -1235,6 +1328,7 @@ async def _send_rf_channel_message(self, channel_idx: int, message: str) -> None sender_name=node_name or "PyMC-Repeater", channels_config=channels_config, ) + logger.info("RF channel send: channel=%s msg_len=%s", name, len(message)) await dispatcher.send_packet(packet, wait_for_ack=False) self._record_tx_packet(packet) logger.info("RF channel message sent to %s", name) From ca7991881002574df12ba17661158a3c77ad86f1 Mon Sep 17 00:00:00 2001 From: yellowcooln Date: Wed, 28 Jan 2026 21:40:08 -0500 Subject: [PATCH 20/22] Revert debug flag in config example --- config.yaml.example | 2 -- 1 file changed, 2 deletions(-) diff --git a/config.yaml.example b/config.yaml.example index 0e139c9..0afb79d 100644 --- a/config.yaml.example +++ b/config.yaml.example @@ -65,8 +65,6 @@ repeater: meshcore_bridge: # Enable MeshCore TCP bridge enabled: false - # Verbose logging for MeshCore TCP bridge - debug: true # TCP listen host/port (meshcore-ha default TCP port is 5000) host: "0.0.0.0" From a8f8ce2af7e616f7196614e523116816cb5b0267 Mon Sep 17 00:00:00 2001 From: yellowcooln Date: Wed, 28 Jan 2026 22:19:14 -0500 Subject: [PATCH 21/22] Allow flood routing for direct messages --- repeater/meshcore_bridge.py | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/repeater/meshcore_bridge.py b/repeater/meshcore_bridge.py index d8cffb5..556b691 100644 --- a/repeater/meshcore_bridge.py +++ b/repeater/meshcore_bridge.py @@ -1070,16 +1070,25 @@ async def _send_rf_text_message(self, contact: "_SimpleContact", message: str, a logger.warning("RF text send skipped: local identity or dispatcher missing") return try: + route_type = "flood" if getattr(contact, "out_path_len", 0) < 0 else "direct" + out_path = None + if route_type == "direct" and getattr(contact, "out_path_len", 0) > 0 and getattr(contact, "out_path", None): + out_path = list(contact.out_path[: contact.out_path_len]) packet, _crc = PacketBuilder.create_text_message( contact=contact, local_identity=identity, message=message, attempt=attempt, - message_type="direct", + message_type=route_type, + out_path=out_path, ) await dispatcher.send_packet(packet, wait_for_ack=False) self._record_tx_packet(packet) - logger.info("RF text message sent to %s", contact.public_key[:12]) + logger.info( + "RF text message sent to %s route=%s", + contact.public_key[:12], + route_type, + ) except Exception as exc: logger.error("RF text message failed: %s", exc, exc_info=True) @@ -1209,11 +1218,14 @@ def _lookup_neighbor_by_prefix(self, prefix: bytes) -> Optional[dict]: prefix_hex = prefix.hex() for pubkey_hex, info in neighbors.items(): if pubkey_hex.startswith(prefix_hex): + out_path_len = info.get("out_path_len") + if out_path_len is None: + out_path_len = -1 return { "public_key": pubkey_hex, "type": self._coerce_contact_type(info.get("contact_type"), bool(info.get("is_repeater"))), "out_path": info.get("out_path"), - "out_path_len": info.get("out_path_len", 0), + "out_path_len": out_path_len, } return None From c889dbc39fe4aa89daba1d7b6eb682f83c92c3c1 Mon Sep 17 00:00:00 2001 From: yellowcooln Date: Wed, 28 Jan 2026 22:23:38 -0500 Subject: [PATCH 22/22] Flood when no path and for channel messages --- repeater/meshcore_bridge.py | 32 +++++++++++++++++++++++++++----- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/repeater/meshcore_bridge.py b/repeater/meshcore_bridge.py index 556b691..708a499 100644 --- a/repeater/meshcore_bridge.py +++ b/repeater/meshcore_bridge.py @@ -10,7 +10,12 @@ from pymc_core.protocol.packet_builder import PacketBuilder from pymc_core.node.handlers.protocol_request import REQ_TYPE_GET_STATUS -from pymc_core.protocol.constants import PAYLOAD_TYPE_PATH, PAYLOAD_TYPE_RESPONSE, PAYLOAD_TYPE_TXT_MSG +from pymc_core.protocol.constants import ( + PAYLOAD_TYPE_PATH, + PAYLOAD_TYPE_RESPONSE, + PAYLOAD_TYPE_TXT_MSG, + PAYLOAD_TYPE_GRP_TXT, +) from pymc_core.protocol.crypto import CryptoUtils from pymc_core.protocol.identity import Identity @@ -1070,10 +1075,13 @@ async def _send_rf_text_message(self, contact: "_SimpleContact", message: str, a logger.warning("RF text send skipped: local identity or dispatcher missing") return try: - route_type = "flood" if getattr(contact, "out_path_len", 0) < 0 else "direct" + out_path_len = getattr(contact, "out_path_len", None) + out_path_val = getattr(contact, "out_path", None) + has_path = bool(out_path_val) and (out_path_len is not None and out_path_len > 0) + route_type = "direct" if has_path else "flood" out_path = None - if route_type == "direct" and getattr(contact, "out_path_len", 0) > 0 and getattr(contact, "out_path", None): - out_path = list(contact.out_path[: contact.out_path_len]) + if route_type == "direct" and out_path_val: + out_path = list(out_path_val[: out_path_len]) packet, _crc = PacketBuilder.create_text_message( contact=contact, local_identity=identity, @@ -1218,13 +1226,23 @@ def _lookup_neighbor_by_prefix(self, prefix: bytes) -> Optional[dict]: prefix_hex = prefix.hex() for pubkey_hex, info in neighbors.items(): if pubkey_hex.startswith(prefix_hex): + out_path = info.get("out_path") + if isinstance(out_path, (bytes, bytearray)): + out_path_list = list(out_path) + elif isinstance(out_path, list): + out_path_list = out_path + else: + out_path_list = [] + out_path_len = info.get("out_path_len") if out_path_len is None: + out_path_len = len(out_path_list) if out_path_list else -1 + if out_path_len == 0 and not out_path_list: out_path_len = -1 return { "public_key": pubkey_hex, "type": self._coerce_contact_type(info.get("contact_type"), bool(info.get("is_repeater"))), - "out_path": info.get("out_path"), + "out_path": out_path_list, "out_path_len": out_path_len, } return None @@ -1340,6 +1358,10 @@ async def _send_rf_channel_message(self, channel_idx: int, message: str) -> None sender_name=node_name or "PyMC-Repeater", channels_config=channels_config, ) + # Force flood routing for group/channel messages + packet.header = PacketBuilder._create_header( + PAYLOAD_TYPE_GRP_TXT, route_type="flood", has_routing_path=False + ) logger.info("RF channel send: channel=%s msg_len=%s", name, len(message)) await dispatcher.send_packet(packet, wait_for_ack=False) self._record_tx_packet(packet)