import logging from pathlib import Path from typing import Any, Callable, Dict, Optional logger = logging.getLogger(__name__) class MeshCLI: def __init__( self, config_path: str, config: Dict[str, Any], config_manager, # ConfigManager instance for save & live updates identity_type: str = "repeater", enable_regions: bool = True, send_advert_callback: Optional[Callable] = None, identity=None, storage_handler=None, ): self.config_path = Path(config_path) self.config = config self.config_manager = config_manager self.identity_type = identity_type self.enable_regions = enable_regions self.send_advert_callback = send_advert_callback self.identity = identity self.storage_handler = storage_handler # Store event loop reference for thread-safe scheduling import asyncio try: self._event_loop = asyncio.get_running_loop() except RuntimeError: self._event_loop = None # Get repeater config shortcut self.repeater_config = config.get("repeater", {}) self.mesh_config = config.setdefault("mesh", {}) def _save_config_and_apply(self, sections=None) -> bool: """Persist the config, then live-apply the changed sections. ``ConfigManager.save_to_file`` returns a bare bool; the tuple form is tolerated for older manager doubles. Live update only runs after a successful save so a failed write never half-applies a change. Pass no sections to stage a change: saved to disk, applied on restart (radio parameters, matching firmware's reboot-to-apply). """ result = self.config_manager.save_to_file() saved = result[0] if isinstance(result, tuple) else bool(result) if not saved: return False if sections: self.config_manager.live_update_daemon(sections) return True def _get_security_config(self) -> Dict[str, Any]: """Return the repeater login security section (the one LoginHelper reads).""" security = self.repeater_config.get("security") return security if isinstance(security, dict) else {} def _get_node_name(self) -> str: """Return the configured node name, preferring the newer key when present.""" return self.repeater_config.get("node_name") or self.repeater_config.get("name", "Unknown") def _set_node_name(self, value: str) -> None: """Persist node name to both legacy and current config keys for compatibility.""" self.repeater_config["node_name"] = value self.repeater_config["name"] = value def _get_local_pubkey_hex(self) -> Optional[str]: """Return local node public key (hex) when available.""" try: if self.identity and hasattr(self.identity, "get_public_key"): pubkey = self.identity.get_public_key() if isinstance(pubkey, (bytes, bytearray)): return bytes(pubkey).hex().lower() if isinstance(pubkey, str): normalized = pubkey.strip().lower() if normalized.startswith("0x"): normalized = normalized[2:] if normalized: return normalized except Exception as exc: logger.debug("Unable to read local identity pubkey: %s", exc) key = self.repeater_config.get("identity_key") if isinstance(key, (bytes, bytearray)): return bytes(key).hex().lower() if isinstance(key, str): normalized = key.strip().lower() if normalized.startswith("0x"): normalized = normalized[2:] if normalized and all(ch in "0123456789abcdef" for ch in normalized): return normalized return None def _is_local_pubkey(self, pubkey_hex: str) -> bool: """Return True when a discovery result pubkey matches the local node.""" candidate = (pubkey_hex or "").strip().lower() if not candidate: return False if candidate.startswith("0x"): candidate = candidate[2:] local_pubkey = self._get_local_pubkey_hex() if not local_pubkey: return False # Prefix-only discovery may return fewer bytes than full identity pubkey. return local_pubkey.startswith(candidate) or candidate.startswith(local_pubkey) def _auto_add_discovery_result(self, result: Dict[str, Any]) -> Dict[str, Any]: """Persist discovered neighbors automatically, excluding this node itself.""" enriched = dict(result) pub_key = str(enriched.get("pub_key") or "").strip().lower() if not pub_key: return enriched if self._is_local_pubkey(pub_key): enriched["is_self"] = True enriched["known_neighbor"] = True return enriched if not self.storage_handler: return enriched record_advert = getattr(self.storage_handler, "record_advert", None) if not callable(record_advert): return enriched try: import time node_type = int(enriched.get("node_type", 0) or 0) contact_type = { 1: "Chat Node", 2: "Repeater", 3: "Room Server", }.get(node_type, "Unknown") rssi = enriched.get("rssi") snr = enriched.get("response_snr", enriched.get("snr")) advert_record = { "timestamp": time.time(), "pubkey": pub_key, "node_name": enriched.get("node_name"), "is_repeater": node_type == 2, "route_type": 2, "contact_type": contact_type, "latitude": None, "longitude": None, "rssi": int(rssi) if rssi is not None else None, "snr": float(snr) if snr is not None else None, "is_new_neighbor": True, "zero_hop": True, } record_advert(advert_record) enriched["known_neighbor"] = True enriched["auto_added"] = True except Exception as exc: logger.debug("Auto-add discovery result failed for %s: %s", pub_key, exc) return enriched def handle_command(self, sender_pubkey: bytes, command: str, is_admin: bool) -> str: # Check admin permission first if not is_admin: return "Error: Admin permission required" logger.debug(f"handle_command received: '{command}' (len={len(command)})") # Extract optional sequence prefix (XX|) prefix = "" if len(command) > 4 and command[2] == "|": prefix = command[:3] command = command[3:] logger.debug(f"Extracted prefix: '{prefix}', remaining command: '{command}'") # Strip leading/trailing whitespace command = command.strip() logger.debug(f"After strip: '{command}'") # Route to appropriate handler reply = self._route_command(command) # Add prefix back to reply if present if prefix: return prefix + reply return reply def _route_command(self, command: str) -> str: # Help if command == "help" or command.startswith("help "): return self._cmd_help(command) # System commands elif command == "reboot": return self._cmd_reboot() elif command == "advert": return self._cmd_advert() elif command.startswith("clock"): return self._cmd_clock(command) elif command.startswith("time "): return self._cmd_time(command) elif command == "http start": return self._cmd_http_start() elif command == "http stop": return self._cmd_http_stop() elif command == "start ota": return "Error: OTA not supported in Python repeater" elif command.startswith("password "): return self._cmd_password(command) elif command == "clear stats": return self._cmd_clear_stats() elif command == "ver": return self._cmd_version() # Get commands elif command.startswith("get "): return self._cmd_get(command[4:]) # Set commands elif command.startswith("set "): return self._cmd_set(command[4:]) # ACL commands elif command.startswith("setperm "): return self._cmd_setperm(command) elif command == "get acl": return "Error: Use 'get acl' via serial console only" # Region commands (repeaters only) elif command.startswith("region"): if self.enable_regions: return self._cmd_region(command) else: return "Error: Region commands not available for room servers" # Neighbor commands elif command == "neighbors": return self._cmd_neighbors() elif command.startswith("neighbor.remove "): return self._cmd_neighbor_remove(command) elif command.startswith("discover.neighbors"): return self._cmd_discover_neighbors(command) # Temporary radio params elif command.startswith("tempradio "): return self._cmd_tempradio(command) # Sensor commands elif command.startswith("sensor "): return "Error: Sensor commands not implemented in Python repeater" # GPS commands elif command.startswith("gps"): return "Error: GPS commands not implemented in Python repeater" # Logging commands elif command.startswith("log "): return self._cmd_log(command) # Statistics commands elif command.startswith("stats-"): return "Error: Stats commands not fully implemented yet" else: return "Unknown command" # ==================== Help Command ==================== def _cmd_help(self, command: str) -> str: """Show available commands or detailed help for a specific command.""" parts = command.split(None, 1) if len(parts) == 2: return self._help_detail(parts[1]) lines = [ "=== openHop CLI Commands ===", "", "System:", " reboot Restart the repeater service", " advert Send self advertisement", " clock Show current UTC time", " clock sync Sync clock (no-op, uses system time)", " http start Start the HTTP server", " http stop Stop the HTTP server", " ver Show version info", " password Change admin password", " clear stats Clear statistics", "", "Get:", " get name Node name", " get radio Radio params (freq,bw,sf,cr)", " get freq Frequency (MHz)", " get tx TX power", " get af Airtime factor", " get repeat Repeat mode (on/off)", " get lat / get lon GPS coordinates", " get role Identity role", " get owner.info Owner info text", " get guest.password Guest password", " get allow.read.only Read-only access setting", " get advert.interval Advert interval (minutes)", " get flood.advert.interval Flood advert interval (hours)", " get flood.max Max flood hops", " get path.hash.mode Flood advert path hash mode (0-2)", " get loop.detect Flood loop detection mode", " get rxdelay RX delay base", " get txdelay TX delay factor", " get direct.txdelay Direct TX delay factor", " get multi.acks Multi-ack count", " get int.thresh Interference threshold", " get agc.reset.interval AGC reset interval", "", "Set: (use 'help set' for details)", " set ", "", "Other:", " neighbors List neighbors", " neighbor.remove Remove neighbor by pubkey", " discover.neighbors Send zero-hop neighbor discovery", " tempradio ", " setperm Set ACL permissions", " log start|stop|erase Logging control", ] if self.enable_regions: lines.append(" region ... Region commands") lines += ["", "Type 'help ' for details on a specific command."] return "\n".join(lines) def _help_detail(self, topic: str) -> str: """Return detailed help for a specific command topic.""" topic = topic.strip() details = { "set": ( "Set commands \u2014 set :\n" " set name Set node name\n" " set radio Set radio (restart required)\n" " set freq Set frequency (restart required)\n" " set tx Set TX power\n" " set af Airtime factor\n" " set repeat on|off Enable/disable repeating\n" " set lat Latitude\n" " set lon Longitude\n" " set guest.password Guest password\n" " set owner.info Owner info text\n" " set allow.read.only on|off Read-only access\n" " set advert.interval 60-240 minutes\n" " set flood.advert.interval
3-168 hours\n" " set flood.max Max flood hops (max 64; 0 = unlimited, unlike firmware)\n" " set path.hash.mode <0-2> Path hash mode (0=1B,1=2B,2=3B)\n" " set loop.detect Flood loop detection\n" " set rxdelay RX delay base (0-20)\n" " set txdelay TX delay factor (0-2)\n" " set direct.txdelay Direct TX delay (0-2)\n" " set multi.acks Multi-ack count\n" " set int.thresh Interference threshold\n" " set agc.reset.interval AGC reset (rounded to x4)" ), "get": "Get commands \u2014 type 'help' to see all 'get' parameters.", "reboot": "Restart the repeater service via systemd.", "advert": "Trigger a self-advertisement flood packet.", "clock": "'clock' shows UTC time. 'clock sync' is a no-op (system time used).", "http": "http start|stop - Control the HTTP server.", "http start": "Start the HTTP server.", "http stop": "Stop the HTTP server.", "ver": "Show repeater version and identity type.", "password": "password \u2014 Change the admin password.", "tempradio": ( "tempradio \n" " Apply temporary radio parameters that revert after timeout.\n" " freq: 300-2500 MHz, bw: 7-500 kHz, sf: 5-12, cr: 5-8" ), "neighbors": "List known neighbor nodes from the routing table.", "discover.neighbors": "Send a neighbor discovery request.", "setperm": "setperm \u2014 Set ACL permissions for a node.", "log": "log start|stop|erase \u2014 Control logging.", } return details.get(topic, f"No detailed help for '{topic}'. Type 'help' for command list.") # ==================== System Commands ==================== def _cmd_reboot(self) -> str: """Reboot the repeater process.""" from repeater.service_utils import restart_service logger.warning("Reboot command received via mesh CLI") success, message = restart_service() if success: return f"OK - {message}" else: return f"Error: {message}" def _cmd_advert(self) -> str: """Send self advertisement.""" if not self.send_advert_callback: logger.warning("Advert command received but no callback configured") return "Error: Advert functionality not configured" try: import asyncio async def delayed_advert(): """Delay advert to let CLI response send first (matches C++ 1500ms delay).""" await asyncio.sleep(1.5) await self.send_advert_callback() if self._event_loop and self._event_loop.is_running(): asyncio.run_coroutine_threadsafe(delayed_advert(), self._event_loop) else: return "Error: Event loop not available" logger.info("Advert scheduled for sending (1.5s delay)") return "OK - Advert sent" except Exception as e: logger.error(f"Failed to schedule advert: {e}", exc_info=True) return f"Error: {e}" def _cmd_clock(self, command: str) -> str: """Handle clock commands.""" if command == "clock": # Display current time import datetime dt = datetime.datetime.utcnow() return f"{dt.hour:02d}:{dt.minute:02d} - {dt.day}/{dt.month}/{dt.year} UTC" elif command == "clock sync": # Clock sync happens automatically via sender_timestamp in protocol return "OK - clock sync not needed (system time used)" else: return "Unknown clock command" def _cmd_time(self, command: str) -> str: """Set time - not supported in Python (use system time).""" return "Error: Time setting not supported (system time is used)" def _cmd_http_start(self) -> str: """Start HTTP server.""" from repeater.service_utils import start_http_server daemon_instance = getattr(self.config_manager, "daemon", None) success, message = start_http_server(daemon_instance) if success: return f"OK - {message}" return f"Error: {message}" def _cmd_http_stop(self) -> str: """Stop HTTP server.""" from repeater.service_utils import stop_http_server daemon_instance = getattr(self.config_manager, "daemon", None) success, message = stop_http_server(daemon_instance) if success: return f"OK - {message}" return f"Error: {message}" def _cmd_password(self, command: str) -> str: """Change admin password.""" new_password = command[9:].strip() if not new_password: return "Error: Password cannot be empty" # LoginHelper authenticates from repeater.security.admin_password. self.repeater_config.setdefault("security", {})["admin_password"] = new_password # Save config and live update try: if not self._save_config_and_apply(["repeater"]): logger.error("Failed to save password: config save failed") return "Error: Failed to save config" return f"password now: {new_password}" except Exception as e: logger.error(f"Failed to save password: {e}") return "Error: Failed to save password" def _cmd_clear_stats(self) -> str: """Clear statistics.""" # TODO: Implement stats clearing return "Error: Not yet implemented" def _cmd_version(self) -> str: """Get version information.""" role = "room_server" if self.identity_type == "room_server" else "repeater" version = self.config.get("version", "13") return f"openHop_{role} v{version}" # ==================== Get Commands ==================== def _cmd_get(self, param: str) -> str: """Handle get commands.""" param = param.strip() logger.debug(f"_cmd_get called with param: '{param}' (len={len(param)})") if param == "af": af = self.repeater_config.get("airtime_factor", 1.0) return f"> {af}" elif param == "name": return f"> {self._get_node_name()}" elif param == "repeat": mode = self.repeater_config.get("mode", "forward") return f"> {'on' if mode == 'forward' else 'off'}" elif param == "lat": lat = self.repeater_config.get("latitude", 0.0) return f"> {lat}" elif param == "lon": lon = self.repeater_config.get("longitude", 0.0) return f"> {lon}" elif param == "radio": radio = self.config.get("radio", {}) freq_hz = radio.get("frequency", 915000000) bw_hz = radio.get("bandwidth", 125000) sf = radio.get("spreading_factor", 7) cr = radio.get("coding_rate", 5) # Convert Hz to MHz for freq, Hz to kHz for bandwidth (match C++ ftoa output) freq_mhz = freq_hz / 1_000_000.0 bw_khz = bw_hz / 1_000.0 return f"> {freq_mhz},{bw_khz},{sf},{cr}" elif param == "freq": freq_hz = self.config.get("radio", {}).get("frequency", 915000000) freq_mhz = freq_hz / 1_000_000.0 return f"> {freq_mhz}" elif param == "tx": power = self.config.get("radio", {}).get("tx_power", 20) return f"> {power}" elif param == "public.key": if not self.identity: return "Error: Identity not available" try: pubkey = self.identity.get_public_key() pubkey_hex = pubkey.hex() return f"> {pubkey_hex}" except Exception as e: logger.error(f"Failed to get public key: {e}") return f"Error: {e}" elif param == "role": role = "room_server" if self.identity_type == "room_server" else "repeater" return f"> {role}" elif param == "guest.password": guest_pw = self._get_security_config().get("guest_password") or "" return f"> {guest_pw}" elif param == "owner.info": owner_info = self.repeater_config.get("owner_info", "") return f"> {owner_info}" elif param == "allow.read.only": allow = self._get_security_config().get("allow_read_only", False) return f"> {'on' if allow else 'off'}" elif param == "advert.interval": interval = self.repeater_config.get("advert_interval_minutes", 120) return f"> {interval}" elif param == "flood.advert.interval": # The engine's flood-advert timer consumes send_advert_interval_hours. interval = self.repeater_config.get("send_advert_interval_hours", 10) return f"> {interval}" elif param == "flood.max": max_flood = self.repeater_config.get("max_flood_hops", 64) return f"> {max_flood}" elif param == "path.hash.mode": path_hash_mode = self.mesh_config.get("path_hash_mode", 0) return f"> {path_hash_mode}" elif param == "loop.detect": loop_detect = self.mesh_config.get("loop_detect", "off") return f"> {loop_detect}" elif param == "rxdelay": # The flood RX delay base lives in the delays section (the value # the dispatcher and web API use), not the repeater section. delay = self.config.get("delays", {}).get("rx_delay_base", 0.0) return f"> {delay}" elif param == "txdelay": # The TX delay factor lives in the delays section (the value the # engine consumes), not the repeater section. delay = self.config.get("delays", {}).get("tx_delay_factor", 1.0) return f"> {delay}" elif param == "direct.txdelay": # The direct TX delay factor lives in the delays section (the # value the engine consumes), not the repeater section. delay = self.config.get("delays", {}).get("direct_tx_delay_factor", 0.5) return f"> {delay}" elif param == "multi.acks": acks = self.repeater_config.get("multi_acks", 0) return f"> {acks}" elif param == "int.thresh": thresh = self.repeater_config.get("interference_threshold", -120) return f"> {thresh}" elif param == "agc.reset.interval": interval = self.repeater_config.get("agc_reset_interval", 0) return f"> {interval}" else: return f"??: {param}" # ==================== Set Commands ==================== def _cmd_set(self, param: str) -> str: """Handle set commands.""" parts = param.split(None, 1) if len(parts) < 2: return "Error: Missing value" key, value = parts[0], parts[1] try: if key == "af": self.repeater_config["airtime_factor"] = float(value) if not self._save_config_and_apply(["repeater"]): return "Error: Failed to save config" return "OK" elif key == "name": self._set_node_name(value) if not self._save_config_and_apply(["repeater"]): return "Error: Failed to save config" return "OK" elif key == "repeat": self.repeater_config["mode"] = "forward" if value.lower() == "on" else "monitor" if not self._save_config_and_apply(["repeater"]): return "Error: Failed to save config" return f"OK - repeat is now {'ON' if self.repeater_config['mode'] == 'forward' else 'OFF'}" elif key == "lat": self.repeater_config["latitude"] = float(value) if not self._save_config_and_apply(["repeater"]): return "Error: Failed to save config" return "OK" elif key == "lon": self.repeater_config["longitude"] = float(value) if not self._save_config_and_apply(["repeater"]): return "Error: Failed to save config" return "OK" elif key == "radio": # Format: freq(MHz) bw(kHz) sf cr — the config stores Hz. radio_parts = value.split() if len(radio_parts) != 4: return "Error: Expected freq bw sf cr" freq_mhz = float(radio_parts[0]) bw_khz = float(radio_parts[1]) sf = int(radio_parts[2]) cr = int(radio_parts[3]) if not ( 150.0 <= freq_mhz <= 2500.0 and 7.0 <= bw_khz <= 500.0 and 5 <= sf <= 12 and 5 <= cr <= 8 ): return "Error, invalid radio params" radio_config = self.config.setdefault("radio", {}) radio_config["frequency"] = int(round(freq_mhz * 1_000_000)) radio_config["bandwidth"] = int(round(bw_khz * 1_000)) radio_config["spreading_factor"] = sf radio_config["coding_rate"] = cr if not self._save_config_and_apply(): return "Error: Failed to save config" return "OK - restart repeater to apply" elif key == "freq": # CLI input is MHz (firmware parity); the config stores Hz. freq_mhz = float(value) self.config.setdefault("radio", {})["frequency"] = int(round(freq_mhz * 1_000_000)) if not self._save_config_and_apply(): return "Error: Failed to save config" return "OK - restart repeater to apply" elif key == "tx": self.config.setdefault("radio", {})["tx_power"] = int(value) if not self._save_config_and_apply(): return "Error: Failed to save config" return "OK - restart repeater to apply" elif key == "guest.password": # LoginHelper authenticates from repeater.security.guest_password. self.repeater_config.setdefault("security", {})["guest_password"] = value if not self._save_config_and_apply(["repeater"]): return "Error: Failed to save config" return "OK" elif key == "owner.info": self.repeater_config["owner_info"] = value.replace("|", "\n") if not self._save_config_and_apply(["repeater"]): return "Error: Failed to save config" return "OK" elif key == "allow.read.only": # LoginHelper reads repeater.security.allow_read_only. self.repeater_config.setdefault("security", {})["allow_read_only"] = ( value.lower() == "on" ) if not self._save_config_and_apply(["repeater"]): return "Error: Failed to save config" return "OK" elif key == "advert.interval": mins = int(value) if mins > 0 and (mins < 60 or mins > 240): return "Error: interval range is 60-240 minutes" self.repeater_config["advert_interval_minutes"] = mins if not self._save_config_and_apply(["repeater"]): return "Error: Failed to save config" return "OK" elif key == "flood.advert.interval": hours = int(value) if (hours > 0 and hours < 3) or hours > 168: return "Error: interval range is 3-168 hours" # The engine's flood-advert timer consumes send_advert_interval_hours. self.repeater_config["send_advert_interval_hours"] = hours if not self._save_config_and_apply(["repeater"]): return "Error: Failed to save config" return "OK" elif key == "flood.max": max_val = int(value) if max_val > 64: return "Error: max 64" self.repeater_config["max_flood_hops"] = max_val if not self._save_config_and_apply(["repeater"]): return "Error: Failed to save config" return "OK" elif key == "path.hash.mode": mode = int(value) if mode not in (0, 1, 2): return "Error: path.hash.mode must be 0, 1, or 2" self.mesh_config["path_hash_mode"] = mode if not self._save_config_and_apply(["mesh"]): return "Error: Failed to save config" return "OK" elif key == "loop.detect": mode = str(value).strip().lower() if mode not in ("off", "minimal", "moderate", "strict"): return "Error: loop.detect must be off, minimal, moderate, or strict" self.mesh_config["loop_detect"] = mode if not self._save_config_and_apply(["mesh"]): return "Error: Failed to save config" return "OK" elif key == "rxdelay": delay = float(value) if delay < 0 or delay > 20.0: return "Error, must be 0-20" self.config.setdefault("delays", {})["rx_delay_base"] = delay if not self._save_config_and_apply(["repeater", "delays"]): return "Error: Failed to save config" return "OK" elif key == "txdelay": delay = float(value) if delay < 0 or delay > 2.0: return "Error, must be 0-2" self.config.setdefault("delays", {})["tx_delay_factor"] = delay if not self._save_config_and_apply(["repeater", "delays"]): return "Error: Failed to save config" return "OK" elif key == "direct.txdelay": delay = float(value) if delay < 0 or delay > 2.0: return "Error, must be 0-2" self.config.setdefault("delays", {})["direct_tx_delay_factor"] = delay if not self._save_config_and_apply(["repeater", "delays"]): return "Error: Failed to save config" return "OK" elif key == "multi.acks": self.repeater_config["multi_acks"] = int(value) if not self._save_config_and_apply(["repeater"]): return "Error: Failed to save config" return "OK" elif key == "int.thresh": self.repeater_config["interference_threshold"] = int(value) if not self._save_config_and_apply(["repeater"]): return "Error: Failed to save config" return "OK" elif key == "agc.reset.interval": interval = int(value) # Round to nearest multiple of 4 rounded = (interval // 4) * 4 self.repeater_config["agc_reset_interval"] = rounded if not self._save_config_and_apply(["repeater"]): return "Error: Failed to save config" return f"OK - interval rounded to {rounded}" else: return f"unknown config: {key}" except ValueError as e: return f"Error: invalid value - {e}" except Exception as e: logger.error(f"Set command error: {e}") return f"Error: {e}" # ==================== ACL Commands ==================== def _cmd_setperm(self, command: str) -> str: """Set permissions for a public key.""" # Format: setperm {pubkey-hex} {permissions-int} parts = command[8:].split() if len(parts) < 2: return "Err - bad params" pubkey_hex = parts[0] try: permissions = int(parts[1]) except ValueError: return "Err - invalid permissions" # TODO: Apply permissions via ACL logger.info(f"setperm command: {pubkey_hex} -> {permissions}") return "Error: Not yet implemented - use config file" # ==================== Region Commands ==================== def _cmd_region(self, command: str) -> str: """Handle region commands with MeshCore-compatible response shapes.""" parts = command.split() if len(parts) == 1: return self._region_export_tree() subcommand = parts[1] if subcommand == "load": return "Err - region load not supported" if subcommand == "def": return "Err - region def not supported" if subcommand == "save": return self._region_save() if subcommand == "allowf" and len(parts) >= 3: return self._region_set_flood(parts[2], allow=True) if subcommand == "denyf" and len(parts) >= 3: return self._region_set_flood(parts[2], allow=False) if subcommand == "get" and len(parts) >= 3: return self._region_get(parts[2]) if subcommand == "home": if len(parts) >= 3: return self._region_home_set(parts[2]) return self._region_home_get() if subcommand == "default": if len(parts) >= 3: return self._region_default_set(parts[2]) return self._region_default_get() if subcommand == "put" and len(parts) >= 3: parent = parts[3] if len(parts) >= 4 else "*" return self._region_put(parts[2], parent) if subcommand == "remove" and len(parts) >= 3: return self._region_remove(parts[2]) if subcommand == "list" and len(parts) >= 3: return self._region_list(parts[2]) return "Err - ??" def _region_storage_available(self) -> bool: return bool( self.storage_handler and hasattr(self.storage_handler, "get_transport_keys") and callable(getattr(self.storage_handler, "get_transport_keys")) ) def _region_load_records(self) -> list[dict]: if not self._region_storage_available(): return [] records = self.storage_handler.get_transport_keys() return records if isinstance(records, list) else [] @staticmethod def _region_display_name(raw_name: str) -> str: name = str(raw_name or "").strip() if name.startswith("#"): return name[1:] return name def _region_find_prefix(self, query: str) -> Optional[dict]: q = str(query or "").strip() if not q: return None if q == "*": return { "id": 0, "name": "*", "display_name": "*", "flood_policy": "allow" if self._region_unscoped_allow() else "deny", "parent_id": None, } q_lower = q.lower() for rec in self._region_load_records(): display = self._region_display_name(rec.get("name", "")) if display.lower().startswith(q_lower): return {**rec, "display_name": display} return None def _region_find_exact(self, query: str) -> Optional[dict]: q = str(query or "").strip() if not q: return None if q == "*": return { "id": 0, "name": "*", "display_name": "*", "flood_policy": "allow" if self._region_unscoped_allow() else "deny", "parent_id": None, } q_lower = q.lower() for rec in self._region_load_records(): display = self._region_display_name(rec.get("name", "")) if display.lower() == q_lower: return {**rec, "display_name": display} return None def _region_unscoped_allow(self) -> bool: return bool( self.mesh_config.get( "unscoped_flood_allow", self.mesh_config.get("global_flood_allow", True), ) ) def _region_set_unscoped_allow(self, allow: bool) -> bool: self.mesh_config["unscoped_flood_allow"] = bool(allow) self.mesh_config["global_flood_allow"] = bool(allow) save_result = self.config_manager.save_to_file() saved = save_result[0] if isinstance(save_result, tuple) else bool(save_result) self.config_manager.live_update_daemon(["mesh"]) return bool(saved) def _region_get_default_name(self) -> Optional[str]: default_name = self.mesh_config.get("default_region") text = str(default_name).strip() if default_name is not None else "" return text or None def _region_set_default_name(self, value: Optional[str]) -> bool: self.mesh_config["default_region"] = value save_result = self.config_manager.save_to_file() saved = save_result[0] if isinstance(save_result, tuple) else bool(save_result) self.config_manager.live_update_daemon(["mesh"]) return bool(saved) def _region_export_tree(self) -> str: records = self._region_load_records() children_by_parent: Dict[int, list[dict]] = {} for rec in records: parent_id = rec.get("parent_id") parent_key = int(parent_id) if isinstance(parent_id, int) and parent_id > 0 else 0 children_by_parent.setdefault(parent_key, []).append(rec) for parent_list in children_by_parent.values(): parent_list.sort(key=lambda r: str(r.get("name", "")).lower()) home_name = str(self.repeater_config.get("region_home") or "").strip().lower() lines: list[str] = [] def append_line(indent: int, display_name: str, flood_policy: str): home_mark = "^" if home_name and display_name.lower() == home_name else "" flood_mark = " F" if flood_policy == "allow" else "" lines.append(f"{' ' * indent}{display_name}{home_mark}{flood_mark}") append_line(0, "*", "allow" if self._region_unscoped_allow() else "deny") def walk(parent_id: int, indent: int): for rec in children_by_parent.get(parent_id, []): display_name = self._region_display_name(rec.get("name", "")) append_line(indent, display_name, str(rec.get("flood_policy", "deny"))) walk(int(rec.get("id", 0)), indent + 1) walk(0, 1) return "\n".join(lines) def _region_save(self) -> str: save_result = self.config_manager.save_to_file() saved = save_result[0] if isinstance(save_result, tuple) else bool(save_result) return "OK" if saved else "Err - save failed" def _region_set_flood(self, name_prefix: str, allow: bool) -> str: region = self._region_find_prefix(name_prefix) if not region: return "Err - unknown region" if region.get("id") == 0: return "OK" if self._region_set_unscoped_allow(allow) else "Err - save failed" update_fn = getattr(self.storage_handler, "update_transport_key", None) if not callable(update_fn): return "Error: Region commands not supported by storage backend" ok = update_fn(int(region["id"]), flood_policy="allow" if allow else "deny") return "OK" if ok else "Err - unknown region" def _region_get(self, name_prefix: str) -> str: region = self._region_find_prefix(name_prefix) if not region: return "Err - unknown region" display_name = str( region.get("display_name") or self._region_display_name(region.get("name", "")) ) flood_suffix = "F" if region.get("flood_policy") == "allow" else "" parent_name = None parent_id = region.get("parent_id") if isinstance(parent_id, int) and parent_id > 0: for rec in self._region_load_records(): if int(rec.get("id", -1)) == parent_id: parent_name = self._region_display_name(rec.get("name", "")) break if parent_name: return f" {display_name} ({parent_name}) {flood_suffix}".rstrip() return f" {display_name} {flood_suffix}".rstrip() def _region_home_get(self) -> str: home = str(self.repeater_config.get("region_home") or "").strip() return f" home is {home or '*'}" def _region_home_set(self, name_prefix: str) -> str: region = self._region_find_prefix(name_prefix) if not region: return "Err - unknown region" display_name = str(region.get("display_name") or "*") self.repeater_config["region_home"] = display_name save_result = self.config_manager.save_to_file() saved = save_result[0] if isinstance(save_result, tuple) else bool(save_result) return f" home is now {display_name}" if saved else "Err - save failed" def _region_default_get(self) -> str: default_region = self._region_get_default_name() if default_region is None: return " default scope is " return f" default scope is {default_region}" def _region_default_set(self, value: str) -> str: text = str(value or "").strip() if text == "": saved = self._region_set_default_name(None) return " default scope is now " if saved else "Err - save failed" region = self._region_find_prefix(text) if region: display_name = str(region.get("display_name") or text) if region.get("id") not in (None, 0): update_fn = getattr(self.storage_handler, "update_transport_key", None) if callable(update_fn): update_fn(int(region["id"]), flood_policy="allow") saved = self._region_set_default_name(display_name) return f" default scope is now {display_name}" if saved else "Err - save failed" put_result = self._region_put(text, "*") if not put_result.startswith("OK"): return "Err - region table full" saved = self._region_set_default_name(text) return f" default scope is now {text}" if saved else "Err - save failed" def _region_put(self, name: str, parent_name: str) -> str: region_name = str(name or "").strip() if not region_name: return "Err - unable to put" parent = self._region_find_prefix(parent_name) if not parent: return "Err - unknown parent" parent_id = int(parent.get("id", 0)) parent_storage_id = None if parent_id == 0 else parent_id existing = self._region_find_exact(region_name) if existing and existing.get("id") != 0: update_fn = getattr(self.storage_handler, "update_transport_key", None) if not callable(update_fn): return "Err - unable to put" ok = update_fn( int(existing["id"]), flood_policy="allow", parent_id=parent_storage_id, ) return "OK - (flood allowed)" if ok else "Err - unable to put" create_fn = getattr(self.storage_handler, "create_transport_key", None) if not callable(create_fn): return "Err - unable to put" key_id = create_fn( region_name, "allow", None, parent_storage_id, None, ) return "OK - (flood allowed)" if key_id else "Err - unable to put" def _region_remove(self, name: str) -> str: region = self._region_find_exact(name) if not region or region.get("id") == 0: return "Err - not found" region_id = int(region["id"]) for rec in self._region_load_records(): if int(rec.get("parent_id") or 0) == region_id: return "Err - not empty" delete_fn = getattr(self.storage_handler, "delete_transport_key", None) if not callable(delete_fn): return "Err - not found" ok = delete_fn(region_id) if not ok: return "Err - not found" removed_name = str(region.get("display_name") or "") if ( str(self.repeater_config.get("region_home") or "").strip().lower() == removed_name.lower() ): self.repeater_config["region_home"] = "" default_name = self._region_get_default_name() if str(default_name or "").strip().lower() == removed_name.lower(): self.mesh_config["default_region"] = None return "OK" def _region_list(self, filter_name: str) -> str: mode = str(filter_name or "").strip().lower() if mode not in ("allowed", "denied"): return "Err - use 'allowed' or 'denied'" names: list[str] = [] unscoped_allowed = self._region_unscoped_allow() if (mode == "allowed" and unscoped_allowed) or (mode == "denied" and not unscoped_allowed): names.append("*") records = sorted( self._region_load_records(), key=lambda r: self._region_display_name(r.get("name", "")).lower(), ) for rec in records: flood_policy = str(rec.get("flood_policy", "deny")).lower() allowed = flood_policy == "allow" if (mode == "allowed" and allowed) or (mode == "denied" and not allowed): names.append(self._region_display_name(rec.get("name", ""))) return ",".join(names) if names else "-none-" # ==================== Neighbor Commands ==================== def _cmd_neighbors(self) -> str: """List neighbors.""" if not self.storage_handler: return "Error: Storage not available" try: neighbors = self.storage_handler.get_neighbors() if not neighbors: return "No neighbors discovered yet" # Match MeshCore behavior: show only zero-hop repeaters. filtered_neighbors = { pubkey: info for pubkey, info in neighbors.items() if info.get("is_repeater", False) and info.get("zero_hop", False) } if not filtered_neighbors: return "No zero hop repeaters discovered yet" # Format output similar to C++ version # Format: " heard Xs ago" import time current_time = int(time.time()) lines = [] for pubkey, info in filtered_neighbors.items(): last_seen = info.get("last_seen", 0) seconds_ago = int(current_time - last_seen) # Get first 4 bytes of pubkey as hex (match C++ format) pubkey_short = pubkey[:8] if len(pubkey) >= 8 else pubkey snr = info.get("snr", 0) or 0 # Format: <4byte_hex>:: (matches C++ format) lines.append(f"{pubkey_short}:{seconds_ago}:{int(snr)}") return "\n".join(lines) except Exception as e: logger.error(f"Failed to list neighbors: {e}", exc_info=True) return f"Error: {e}" def _cmd_neighbor_remove(self, command: str) -> str: """Remove a neighbor.""" raw_suffix = command[16:] pubkey_hex = raw_suffix.strip() # Keep MeshCore parity: plain empty is invalid, whitespace-only means remove all. if raw_suffix == "": return "ERR: Missing pubkey" if not self.storage_handler: return "Error: Storage not available" delete_fn = getattr(self.storage_handler, "delete_neighbors_by_pubkey_prefix", None) if not callable(delete_fn): return "Error: neighbor.remove not supported by storage backend" try: if pubkey_hex == "": delete_fn(None) return "OK" if any(ch not in "0123456789abcdefABCDEF" for ch in pubkey_hex): return "ERR: bad pubkey" delete_fn(pubkey_hex) return "OK" except Exception as e: logger.error(f"neighbor.remove failed: {e}", exc_info=True) return f"Error: {e}" def _cmd_discover_neighbors(self, command: str) -> str: """Send a discovery request for nearby repeaters.""" sub = command[18:] if sub.strip(): return "Err - discover.neighbors has no options" daemon_instance = getattr(self.config_manager, "daemon", None) discovery_helper = getattr(daemon_instance, "discovery_helper", None) if not discovery_helper: return "Error: Discovery helper not available" import asyncio loop = self._event_loop if loop is None: try: loop = asyncio.get_running_loop() except RuntimeError: loop = None if loop is None or not loop.is_running(): return "Error: Event loop not available" try: discovery_helper.cleanup_sessions() session = discovery_helper.create_session( timeout=5, filter_mask=(1 << 2), since=0, prefix_only=False, result_enricher=self._auto_add_discovery_result, ) loop.call_soon_threadsafe(discovery_helper.start_session_task, session["session_id"]) return "OK - Discover sent" except Exception as e: logger.error(f"discover.neighbors failed: {e}", exc_info=True) return f"Error: {e}" # ==================== Temporary Radio Commands ==================== def _cmd_tempradio(self, command: str) -> str: """Apply temporary radio parameters.""" # Format: tempradio {freq} {bw} {sf} {cr} {timeout_mins} parts = command[10:].split() if len(parts) < 5: return "Error: Expected freq bw sf cr timeout_mins" try: freq = float(parts[0]) bw = float(parts[1]) sf = int(parts[2]) cr = int(parts[3]) timeout_mins = int(parts[4]) # Validate if not (300.0 <= freq <= 2500.0): return "Error: invalid frequency" if not (7.0 <= bw <= 500.0): return "Error: invalid bandwidth" if not (5 <= sf <= 12): return "Error: invalid spreading factor" if not (5 <= cr <= 8): return "Error: invalid coding rate" if timeout_mins <= 0: return "Error: invalid timeout" # TODO: Apply temporary radio parameters logger.info(f"tempradio: {freq}MHz {bw}kHz SF{sf} CR4/{cr} for {timeout_mins}min") return "Error: Not yet implemented" except ValueError: return "Error, invalid params" # ==================== Logging Commands ==================== def _cmd_log(self, command: str) -> str: """Handle log commands.""" if command == "log start": # TODO: Enable logging return "Error: Not yet implemented" elif command == "log stop": # TODO: Disable logging return "Error: Not yet implemented" elif command == "log erase": # TODO: Clear log file return "Error: Not yet implemented" elif command == "log": return "Error: Use journalctl to view logs" else: return "Unknown log command"