#!/usr/bin/env python3 """MeshCore repeater telemetry monitor. Periodically logs into configured repeaters and fetches telemetry data (battery voltage, temperature, etc.). Uses route escalation to minimise flooding: last-known route → zero-hop direct → flood, then learns the return path for next time. Configuration lives in ~/.donglora/telemetry.json: { "repeaters": [ { "name": "Hilltop-1", "password": "guest123" } ], "poll_interval_secs": 3600 } The "pubkey" field is optional. If missing (or empty), the monitor listens for MeshCore ADVERTs, matches by name, fills in the pubkey automatically, and saves the config back to disk. """ import hashlib import hmac import json import os import random import struct import sys import time from pathlib import Path import serial from cobs import cobs from Crypto.Cipher import AES from nacl.bindings import crypto_scalarmult from nacl.signing import SigningKey, VerifyKey import donglora as dl # ── Constants ───────────────────────────────────────────────────── DATA_DIR = Path.home() / ".donglora" CONFIG_FILE = DATA_DIR / "telemetry.json" KEY_FILE = DATA_DIR / "telemetry_key.bin" STATE_FILE = DATA_DIR / "telemetry_state.json" DEFAULT_POLL_INTERVAL = 3600 # 1 hour # MeshCore protocol constants REQ_TYPE_GET_TELEMETRY_DATA = 0x03 TELEM_PERM_BASE = 0x01 TELEM_PERM_LOCATION = 0x02 TELEM_PERM_ENVIRONMENT = 0x04 # Timeouts and retries LOGIN_TIMEOUT = 15.0 # seconds to wait for login response TELEM_TIMEOUT = 15.0 # seconds to wait for telemetry response TX_SETTLE = 1.0 # delay between transmissions FLOOD_RETRIES = 3 # retry flood login/telemetry this many times RADIO_CONFIG = { "freq_hz": 910_525_000, "bw": 6, # 62.5 kHz "sf": 7, "cr": 5, # CR 4/5 "sync_word": 0x3444, "tx_power_dbm": -128, # TX_POWER_MAX } # ── ANSI helpers ────────────────────────────────────────────────── RST = "\033[0m" DIM = "\033[2m" RED = "\033[31m" GRN = "\033[32m" YEL = "\033[33m" CYN = "\033[36m" BCYN = "\033[1;36m" BGRN = "\033[1;32m" BWHT = "\033[1;37m" # ── Identity ────────────────────────────────────────────────────── _signing_key: SigningKey | None = None _verify_key: VerifyKey | None = None _pubkey_bytes: bytes = b"" def _init_identity(): global _signing_key, _verify_key, _pubkey_bytes DATA_DIR.mkdir(parents=True, exist_ok=True) if KEY_FILE.is_file(): seed = KEY_FILE.read_bytes() if len(seed) == 32: _signing_key = SigningKey(seed) print(f" {DIM}Loaded keypair from {KEY_FILE}{RST}") else: _signing_key = SigningKey.generate() KEY_FILE.write_bytes(bytes(_signing_key)) else: _signing_key = SigningKey.generate() KEY_FILE.write_bytes(bytes(_signing_key)) print(f" {BWHT}Generated new keypair → {KEY_FILE}{RST}") _verify_key = _signing_key.verify_key _pubkey_bytes = bytes(_verify_key) print(f" {DIM}Pubkey: {_pubkey_bytes.hex()}{RST}") print(f" {DIM}Node hash: 0x{_pubkey_bytes[0]:02x}{RST}") def _my_hash() -> int: return _pubkey_bytes[0] # ── Crypto ──────────────────────────────────────────────────────── def _ecdh_shared_secret(peer_ed25519_pub: bytes) -> bytes: my_x25519 = bytes(_signing_key.to_curve25519_private_key()) peer_verify = VerifyKey(peer_ed25519_pub) peer_x25519 = bytes(peer_verify.to_curve25519_public_key()) return crypto_scalarmult(my_x25519, peer_x25519) def _encrypt(shared_secret: bytes, plaintext: bytes) -> bytes: """Encrypt-then-MAC. Returns mac(2) + ciphertext.""" pad_len = (16 - len(plaintext) % 16) % 16 plaintext += b"\x00" * pad_len cipher = AES.new(shared_secret[:16], AES.MODE_ECB) ciphertext = b"" for i in range(0, len(plaintext), 16): ciphertext += cipher.encrypt(plaintext[i : i + 16]) mac = hmac.new(shared_secret[:32], ciphertext, hashlib.sha256).digest()[:2] return mac + ciphertext def _verify_and_decrypt(shared_secret: bytes, mac_bytes: bytes, ciphertext: bytes) -> bytes | None: computed = hmac.new(shared_secret[:32], ciphertext, hashlib.sha256).digest()[:2] if computed != mac_bytes: return None cipher = AES.new(shared_secret[:16], AES.MODE_ECB) plaintext = b"" for i in range(0, len(ciphertext), 16): plaintext += cipher.decrypt(ciphertext[i : i + 16]) return plaintext # ── Route table ─────────────────────────────────────────────────── # pubkey_hex → (path_hops: list[bytes], hash_size: int) _route_table: dict[str, tuple[list[bytes], int]] = {} MIN_HASH_SIZE = 2 # always use 2-byte routing minimum def _learn_route(pubkey_hex: str, path_hops: list[bytes], hash_size: int): """Learn a return route from an incoming packet's path (reversed).""" if hash_size < MIN_HASH_SIZE: return # reject 1-byte routes existing = _route_table.get(pubkey_hex) if existing is not None: _, existing_hs = existing if hash_size < existing_hs: return _route_table[pubkey_hex] = (list(reversed(path_hops)), hash_size) def _get_route(pubkey_hex: str) -> tuple[list[bytes], int] | None: return _route_table.get(pubkey_hex) # ── Persistent state ───────────────────────────────────────────── _state: dict = {"routes": {}} def _load_state(): global _state if STATE_FILE.is_file(): try: with open(STATE_FILE) as f: loaded = json.load(f) _state.update(loaded) # Restore routes from state for pk_hex, route_data in _state.get("routes", {}).items(): hops = [bytes.fromhex(h) for h in route_data["hops"]] _route_table[pk_hex] = (hops, route_data["hash_size"]) print(f" {DIM}Loaded state ({len(_route_table)} routes){RST}") except Exception as e: print(f" {YEL}Failed to load state: {e}{RST}") def _save_state(): try: # Serialise routes routes = {} for pk_hex, (hops, hs) in _route_table.items(): routes[pk_hex] = {"hops": [h.hex() for h in hops], "hash_size": hs} _state["routes"] = routes DATA_DIR.mkdir(parents=True, exist_ok=True) tmp = STATE_FILE.with_suffix(".tmp") with open(tmp, "w") as f: json.dump(_state, f, indent=2) tmp.rename(STATE_FILE) except Exception as e: print(f" {RED}Failed to save state: {e}{RST}") # ── Serial / USB ────────────────────────────────────────────────── def open_connection(port: str | None = None): """Open a DongLoRa connection (mux auto-detected, falls back to USB).""" return dl.connect(port=port, timeout=2) # ── COBS framing ───────────────────────────────────────────────── def cobs_frame(payload: bytes) -> bytes: return cobs.encode(payload) + b"\x00" def read_frame(ser: serial.Serial) -> bytes | None: buf = b"" while True: b = ser.read(1) if not b: return None if b == b"\x00": break buf += b if not buf: return None try: return cobs.decode(buf) except cobs.DecodeError: return None # ── Command encoding / response decoding ───────────────────────── def encode_radio_config(cfg: dict) -> bytes: return struct.pack( " bytes: kind = cmd["type"] tags = { "Ping": 0, "GetConfig": 1, "SetConfig": 2, "StartRx": 3, "StopRx": 4, "Transmit": 5, "DisplayOn": 6, "DisplayOff": 7, } out = bytes([tags[kind]]) if kind == "SetConfig": out += encode_radio_config(cmd["config"]) elif kind == "Transmit": config = cmd.get("config") if config is None: out += b"\x00" else: out += b"\x01" + encode_radio_config(config) payload = cmd["payload"] out += struct.pack(" dict: if not data: return {"type": "Empty"} tag = data[0] rest = data[1:] if tag == 0: return {"type": "Pong"} elif tag == 1: return {"type": "Config", "raw": rest.hex()} elif tag == 2: rssi = struct.unpack_from(" dict | None: payload = encode_command(cmd) frame = cobs_frame(payload) if label: print(f" {DIM}>>> {label}{RST}") ser.write(frame) ser.flush() for _ in range(50): resp_data = read_frame(ser) if resp_data is None: return None resp = decode_response(resp_data) if resp["type"] == "RxPacket": continue return resp return None def transmit(ser: serial.Serial, packet: bytes, label: str = "TX"): resp = send_cmd(ser, {"type": "Transmit", "payload": packet}, label=label) if resp is None: print(f" {RED}TX failed: no response{RST}") elif resp["type"] == "Error": print(f" {RED}TX error: {resp}{RST}") # ── MeshCore packet parsing ────────────────────────────────────── def parse_header_and_path(packet: bytes): """Parse MeshCore header + path. Returns (payload_type, route_type, pos, payload, path_hops, hash_size) or None. """ if len(packet) < 3: return None header = packet[0] route_type = header & 0x03 payload_type = (header >> 2) & 0x0F payload_ver = (header >> 6) & 0x03 if payload_ver != 0: return None has_tc = route_type in (0, 3) for skip_tc in ([True, False] if has_tc else [False]): pos = 5 if skip_tc else 1 if pos >= len(packet): continue path_len_byte = packet[pos] pos += 1 hash_size_code = path_len_byte >> 6 if hash_size_code == 3: continue hash_size = hash_size_code + 1 hop_count = path_len_byte & 0x3F path_bytes = hop_count * hash_size if path_bytes > 64 or pos + path_bytes > len(packet): continue hops = [] for i in range(hop_count): hops.append(packet[pos + i * hash_size : pos + (i + 1) * hash_size]) pos += path_bytes payload = packet[pos:] if payload and len(payload) <= 184: return payload_type, route_type, pos, payload, hops, hash_size return None # ── Packet construction ────────────────────────────────────────── _RT_NAMES = {0: "tflood", 1: "flood", 2: "direct", 3: "tdirect"} def _build_routed_packet( payload_type: int, payload: bytes, route: tuple[list[bytes], int] | None, ) -> tuple[bytes, str]: """Build a packet with the given route. Returns (packet, route_description).""" if route is not None: hops, hash_size = route hash_size_code = hash_size - 1 path_len_byte = (hash_size_code << 6) | (len(hops) & 0x3F) path_data = b"".join(hops) header = bytes([(payload_type << 2) | 2]) # DIRECT return header + bytes([path_len_byte]) + path_data + payload, "direct" else: header = bytes([(payload_type << 2) | 1]) # FLOOD path_len = bytes([0x40]) # 0 hops, 2-byte hash mode return header + path_len + payload, "flood" def _build_direct_zero_hop_packet(payload_type: int, payload: bytes) -> bytes: """Build a zero-hop direct packet (reaches only immediate neighbours).""" header = bytes([(payload_type << 2) | 2]) # DIRECT path_len = bytes([0x40]) # 0 hops, 2-byte hash mode return header + path_len + payload # ── ANON_REQ (login) ───────────────────────────────────────────── def build_login_packet( repeater_pubkey: bytes, password: str, route: tuple[list[bytes], int] | None, ) -> tuple[bytes, str]: """Build an ANON_REQ login packet for a repeater. Plaintext: timestamp(4) + password (UTF-8, no null terminator needed — encryption pads to 16-byte boundary, server reads until null/end). Returns (packet, route_description). """ shared_secret = _ecdh_shared_secret(repeater_pubkey) # Repeater login plaintext: timestamp(4) + password bytes plaintext = struct.pack(" tuple[bytes, str]: """Build a REQ packet with protocol_code=GET_TELEMETRY_DATA. Plaintext: timestamp(4) + protocol_code(1) + inverse_perm_mask(1) We want base + environment (battery + temp), skip location. Returns (packet, route_description). """ shared_secret = _ecdh_shared_secret(repeater_pubkey) # inverse perm mask: skip location only inv_mask = TELEM_PERM_LOCATION plaintext = struct.pack(" list[dict]: """Decode CayenneLPP payload. Returns list of sensor readings.""" sensors = [] pos = 0 while pos < len(data): if pos + 2 > len(data): break channel = data[pos] type_id = data[pos + 1] pos += 2 type_info = CAYENNE_TYPES.get(type_id) if type_info is None: # Unknown type — can't determine size, stop parsing break name, size, divisor = type_info if pos + size > len(data): break raw = data[pos : pos + size] pos += size # Decode value (big-endian per CayenneLPP spec) signed = name in ("temperature", "analog_in", "analog_out", "voltage", "current") val_raw = int.from_bytes(raw, "big", signed=signed) if size > 1 else raw[0] value = val_raw / divisor UNITS = { "temperature": "°C", "humidity": "%", "barometer": "hPa", "voltage": "V", "analog_in": "V", "analog_out": "V", "current": "A", "power": "W", "distance": "m", "illuminance": "lux", "percentage": "%", } sensors.append({ "channel": channel, "type": name, "value": value, "unit": UNITS.get(name, ""), }) return sensors # ── Response waiting ────────────────────────────────────────────── def wait_for_response( ser: serial.Serial, repeater_pubkey: bytes, expected_types: set[int], timeout: float, ) -> tuple[dict | None, list[bytes], int]: """Wait for a MeshCore response addressed to us from the repeater. Returns (parsed_info, path_hops, hash_size) or (None, [], 0). parsed_info depends on payload type. """ pk_hex = repeater_pubkey.hex() shared_secret = _ecdh_shared_secret(repeater_pubkey) dest_hash = repeater_pubkey[0] deadline = time.monotonic() + timeout old_timeout = ser.timeout ser.timeout = 1 try: while time.monotonic() < deadline: data = read_frame(ser) if data is None: continue resp = decode_response(data) if resp["type"] != "RxPacket": continue packet = resp["payload"] parsed = parse_header_and_path(packet) if parsed is None: continue payload_type, route_type, pos, raw_payload, path_hops, hash_size = parsed if payload_type not in expected_types: continue # ── PATH (0x08) — responses routed back via PATH ── if payload_type == 0x08: if len(raw_payload) < 20: continue dst = raw_payload[0] src = raw_payload[1] if dst != _my_hash(): continue if src != dest_hash: continue mac_bytes = raw_payload[2:4] ciphertext = raw_payload[4:] plaintext = _verify_and_decrypt(shared_secret, mac_bytes, ciphertext) if plaintext is None: continue # PATH inner: path_len(1) + path_data + extra_type(1) + extra_data inner_path_len_byte = plaintext[0] inner_hash_size = (inner_path_len_byte >> 6) + 1 inner_hop_count = inner_path_len_byte & 0x3F inner_path_bytes = inner_hop_count * inner_hash_size ipos = 1 + inner_path_bytes if ipos < len(plaintext): extra_type = plaintext[ipos] extra_data = plaintext[ipos + 1:] else: extra_type = 0xFF extra_data = b"" # Learn route from inner path if inner_hop_count > 0: inner_hops = [] for i in range(inner_hop_count): inner_hops.append(plaintext[1 + i * inner_hash_size : 1 + (i + 1) * inner_hash_size]) _learn_route(pk_hex, inner_hops, inner_hash_size) _save_state() if extra_type != 0x01 or len(extra_data) < 4: continue timestamp = struct.unpack_from("= 13 and extra_data[4] in (0x00, 0x80) and extra_data[12] in (0x01, 0x02, 0x03) ) if is_login_response: if 0x01 in expected_types: # Waiting for telemetry — skip this stale login response continue # skip stale login response response_code = extra_data[4] is_admin = extra_data[6] if len(extra_data) > 6 else 0 return { "type": "login_response", "response_code": response_code, "is_admin": bool(is_admin), "timestamp": timestamp, }, path_hops, hash_size # Telemetry response: ts(4) + CayenneLPP (no response_code) if 0x01 in expected_types: lpp_data = extra_data[4:] while lpp_data and lpp_data[-1] == 0: lpp_data = lpp_data[:-1] sensors = decode_cayenne_lpp(lpp_data) if lpp_data else [] return { "type": "telemetry", "reflected_timestamp": timestamp, "sensors": sensors, "raw_hex": extra_data.hex(), }, path_hops, hash_size continue # ── RESPONSE (0x01) — telemetry data ── if payload_type == 0x01: if len(raw_payload) < 20: continue dst = raw_payload[0] src = raw_payload[1] if dst != _my_hash(): continue if src != dest_hash: continue mac_bytes = raw_payload[2:4] ciphertext = raw_payload[4:] plaintext = _verify_and_decrypt(shared_secret, mac_bytes, ciphertext) if plaintext is None: continue # Telemetry response: timestamp(4) + CayenneLPP (no response_code) if len(plaintext) < 4: continue reflected_ts = struct.unpack_from(" list[tuple[tuple[list[bytes], int] | None, str]]: """Return route strategies in escalation order. a) last known route b) zero-hop direct c) flood """ strategies = [] # a) Last known route known = _get_route(pk_hex) if known is not None: strategies.append((known, "last-known")) # b) Zero-hop direct (immediate neighbours only — no flooding) strategies.append(("zero-hop", "zero-hop")) # c) Flood (only as last resort) strategies.append((None, "flood")) return strategies # ── Poll one repeater ───────────────────────────────────────────── def poll_repeater(ser: serial.Serial, repeater: dict) -> dict | None: """Login to repeater, fetch telemetry. Returns telemetry dict or None.""" name = repeater["name"] pubkey = bytes.fromhex(repeater["pubkey"]) password = repeater["password"] pk_hex = repeater["pubkey"] print(f"\n {BCYN}── {name} ──{RST}") strategies = _route_strategies(pk_hex) # Step 1: Login with route escalation + flood retries login_ok = False for route_val, route_desc in strategies: # Flood gets multiple attempts since it should always eventually work attempts = FLOOD_RETRIES if route_val is None else 1 for attempt in range(attempts): if route_val == "zero-hop": shared_secret = _ecdh_shared_secret(pubkey) plaintext = struct.pack(" 1 and attempt > 0 else "" print(f" {DIM}Login via {actual_desc}{retry_label}...{RST}", end="", flush=True) transmit(ser, packet, label=f"LOGIN({actual_desc})→{name}") time.sleep(TX_SETTLE) result, path_hops, hash_size = wait_for_response( ser, pubkey, {0x08}, LOGIN_TIMEOUT ) if result is not None and result.get("response_code") in (0x00, 0x80): login_ok = True if path_hops: _learn_route(pk_hex, path_hops, hash_size) _save_state() print(f" {GRN}OK{RST} {DIM}(learned {len(path_hops)}-hop route){RST}") else: print(f" {GRN}OK{RST}") break elif result is not None: print(f" {RED}rejected (code=0x{result.get('response_code', -1):02x}){RST}") login_ok = False break # Wrong password — don't retry else: print(f" {YEL}timeout{RST}") if login_ok or (result is not None and result.get("response_code") not in (0x00, 0x80)): break if not login_ok: print(f" {RED}Login failed for {name}{RST}") return None # Step 2: Request telemetry with escalation (direct → flood with retries) telem_strategies = [] known = _get_route(pk_hex) if known is not None: telem_strategies.append((known, "direct")) telem_strategies.append((None, "flood")) result = None for route, route_desc in telem_strategies: attempts = FLOOD_RETRIES if route is None else 1 for attempt in range(attempts): packet, _ = build_telemetry_request(pubkey, route) retry_label = f" #{attempt + 1}" if attempts > 1 and attempt > 0 else "" print(f" {DIM}Requesting telemetry via {route_desc}{retry_label}...{RST}", end="", flush=True) transmit(ser, packet, label=f"TELEM_REQ({route_desc})→{name}") time.sleep(TX_SETTLE) result, path_hops, hash_size = wait_for_response( ser, pubkey, {0x01, 0x08}, TELEM_TIMEOUT ) if result is not None: if path_hops: _learn_route(pk_hex, path_hops, hash_size) _save_state() print(f" {GRN}OK{RST}") break else: print(f" {YEL}timeout{RST}") if result is not None: break # Clear stale route before escalating to next strategy _route_table.pop(pk_hex, None) if result is None: print(f" {RED}Telemetry failed for {name}{RST}") return None # Display raw hex for debugging raw_hex = result.get("raw_hex", "") if raw_hex: print(f" {DIM}raw: {raw_hex}{RST}") # Display parsed sensors — deduplicate by (channel, type), keep first # (external sensors are added before MCU fallback in firmware) sensors = result.get("sensors", []) seen = set() deduped = [] for s in sensors: key = (s["channel"], s["type"]) if key not in seen: seen.add(key) deduped.append(s) if deduped: for s in deduped: stype = s["type"] val = s["value"] unit = s.get("unit", "") if stype == "temperature": f_val = val * 9 / 5 + 32 print(f" {BWHT}temperature{RST}: {val:.1f}°C / {f_val:.1f}°F") elif stype in ("voltage", "analog_in", "analog_out") and unit == "V": print(f" {BWHT}voltage{RST}: {val:.2f}V") elif stype in ("digital_in", "digital_out"): state = "On" if val else "Off" print(f" {DIM}digital {stype.split('_')[1]}{RST}: {state}") else: print(f" {BWHT}{stype}{RST} ch{s['channel']}: {val:.2f}{unit}") return result # ── ADVERT decoding ────────────────────────────────────────────── def decode_advert(raw_payload: bytes) -> tuple[bytes, str, int] | None: """Decode and verify an ADVERT. Returns (pubkey, name, node_type) or None.""" if len(raw_payload) < 100: return None pubkey = raw_payload[0:32] timestamp = raw_payload[32:36] signature = raw_payload[36:100] app_data = raw_payload[100:] try: verify_key = VerifyKey(pubkey) sign_msg = pubkey + timestamp + app_data verify_key.verify(sign_msg, signature) except Exception: return None name = "" node_type = 0 if app_data: flags = app_data[0] node_type = flags & 0x0F pos = 1 if flags & 0x10: # has location pos += 8 if flags & 0x20: # feat1 pos += 2 if flags & 0x40: # feat2 pos += 2 if flags & 0x80 and pos < len(app_data): # has name name = app_data[pos:].decode("utf-8", errors="replace") return pubkey, name if name else pubkey.hex()[:8], node_type def discover_pubkeys(ser: serial.Serial, wanted: list[dict]) -> int: """Listen for ADVERTs and match against repeaters missing pubkeys. Returns number of pubkeys discovered. """ # Build a lookup: lowercase name → list of config entries wanting that name by_name: dict[str, list[dict]] = {} for r in wanted: by_name.setdefault(r["name"].lower(), []).append(r) found = 0 total = len(wanted) print(f"\n {BCYN}Waiting for ADVERTs from {total} repeater(s):{RST}") for r in wanted: print(f" {CYN}{r['name']}{RST}") old_timeout = ser.timeout ser.timeout = 1 try: while found < total: data = read_frame(ser) if data is None: continue resp = decode_response(data) if resp["type"] != "RxPacket": continue packet = resp["payload"] parsed = parse_header_and_path(packet) if parsed is None: continue payload_type, route_type, pos, raw_payload, path_hops, hash_size = parsed if payload_type != 0x04: # not ADVERT continue result = decode_advert(raw_payload) if result is None: continue pubkey, adv_name, node_type = result pk_hex = pubkey.hex() # Match against wanted names (case-insensitive) matches = by_name.get(adv_name.lower(), []) if not matches: print(f" {DIM}ADVERT {adv_name} (not in config){RST}") continue for r in matches: if r.get("pubkey"): continue # already discovered r["pubkey"] = pk_hex found += 1 # Learn route from the ADVERT path too if path_hops: _learn_route(pk_hex, path_hops, hash_size) print( f" {BGRN}Discovered {adv_name}{RST} " f"{DIM}pubkey={pk_hex[:16]}... " f"hash=0x{pubkey[0]:02x}{RST}" ) if found >= total: break finally: ser.timeout = old_timeout return found # ── Config ──────────────────────────────────────────────────────── def load_config() -> dict: if not CONFIG_FILE.is_file(): print(f" {RED}Config not found: {CONFIG_FILE}{RST}") print(f" {DIM}Create it with:{RST}") print(f""" {{ "repeaters": [ {{ "name": "MyRepeater", "password": "guest123" }} ], "poll_interval_secs": 3600 }}""") sys.exit(1) with open(CONFIG_FILE) as f: config = json.load(f) repeaters = config.get("repeaters", []) if not repeaters: print(f" {RED}No repeaters configured in {CONFIG_FILE}{RST}") sys.exit(1) for r in repeaters: if "name" not in r or "password" not in r: print(f" {RED}Repeater missing name or password: {r}{RST}") sys.exit(1) pk = r.get("pubkey", "") if pk and len(pk) != 64: print(f" {RED}Invalid pubkey length for {r['name']}: expected 64 hex chars{RST}") sys.exit(1) return config def save_config(config: dict): """Write config back to disk (atomic).""" try: DATA_DIR.mkdir(parents=True, exist_ok=True) tmp = CONFIG_FILE.with_suffix(".tmp") with open(tmp, "w") as f: json.dump(config, f, indent=4, ensure_ascii=False) tmp.rename(CONFIG_FILE) except Exception as e: print(f" {RED}Failed to save config: {e}{RST}") def needs_discovery(config: dict) -> list[dict]: """Return repeaters that are missing their pubkey.""" return [r for r in config["repeaters"] if not r.get("pubkey")] # ── Main loop ───────────────────────────────────────────────────── def _print_repeater_status(repeaters: list[dict]): for r in repeaters: pk = r.get("pubkey", "") if pk: route = _get_route(pk) route_str = f"{len(route[0])}-hop" if route else "none" print(f" {CYN}{r['name']}{RST} {DIM}(hash=0x{bytes.fromhex(pk)[0]:02x} route={route_str}){RST}") else: print(f" {CYN}{r['name']}{RST} {YEL}(pubkey unknown — will discover via ADVERT){RST}") def main(): import argparse parser = argparse.ArgumentParser(description="MeshCore repeater telemetry monitor") parser.add_argument("--port", "-p", default=None, help="Serial port (auto-detect if omitted)") args = parser.parse_args() _init_identity() _load_state() config = load_config() repeaters = config["repeaters"] poll_interval = config.get("poll_interval_secs", DEFAULT_POLL_INTERVAL) print(f"\n {BWHT}Telemetry Monitor{RST}") print(f" {DIM}Repeaters: {len(repeaters)} | Poll interval: {poll_interval}s{RST}") _print_repeater_status(repeaters) port = args.port while True: try: print(f" {DIM}Connecting...{RST}") ser = open_connection(port) print(f" {GRN}Connected{RST}") send_cmd(ser, {"type": "Ping"}, "Ping") send_cmd(ser, {"type": "SetConfig", "config": RADIO_CONFIG}, "SetConfig") send_cmd(ser, {"type": "StartRx"}, "StartRx") # Discover any repeaters missing their pubkey missing = needs_discovery(config) if missing: found = discover_pubkeys(ser, missing) if found: save_config(config) _save_state() print(f" {BGRN}Saved {found} pubkey(s) to {CONFIG_FILE}{RST}") still_missing = needs_discovery(config) if still_missing: names = ", ".join(r["name"] for r in still_missing) print(f" {YEL}Still waiting for: {names}{RST}") print(f" {DIM}Will retry discovery each poll cycle{RST}") while True: ts = time.strftime("%Y-%m-%d %H:%M:%S %Z") print(f"\n {BWHT}═══ Poll cycle {ts} ═══{RST}") # Re-check for missing pubkeys each cycle missing = needs_discovery(config) if missing: print(f" {DIM}Listening for {len(missing)} missing ADVERT(s)...{RST}") found = discover_pubkeys(ser, missing) if found: save_config(config) _save_state() for repeater in repeaters: if not repeater.get("pubkey"): print(f"\n {BCYN}── {repeater['name']} ──{RST}") print(f" {YEL}Skipping — pubkey not yet discovered{RST}") continue try: poll_repeater(ser, repeater) except (ConnectionError, OSError): raise # mux/serial disconnect — let outer loop reconnect except Exception as e: print(f" {RED}Error polling {repeater['name']}: {e}{RST}") _save_state() next_poll = time.strftime( "%H:%M:%S", time.localtime(time.time() + poll_interval) ) print(f"\n {DIM}Next poll at {next_poll} (sleeping {poll_interval}s){RST}") # Sleep in 1-second increments so Ctrl-C is responsive deadline = time.monotonic() + poll_interval while time.monotonic() < deadline: time.sleep(1) except (serial.SerialException, ConnectionError, OSError) as e: print(f"\n {RED}Disconnected: {e}{RST}") print(f" {DIM}Reconnecting...{RST}") time.sleep(1) except KeyboardInterrupt: print() _save_state() try: ser.timeout = 2 send_cmd(ser, {"type": "StopRx"}, "StopRx") except Exception: pass break if __name__ == "__main__": main()