import asyncio import functools import logging import os import signal import socket import sys import time from repeater.companion.utils import ( CompanionContactCapacityError, CompanionStateLoadError, effective_max_contacts, enforce_companion_contact_capacity, format_companion_bridge_limits, normalize_companion_identity_key, parse_companion_bridge_kwargs, validate_companion_node_name, ) from repeater.config import NullRadio, get_radio_for_board, load_config, save_config from repeater.config_manager import ConfigManager from repeater.data_acquisition.glass_handler import GlassHandler from repeater.data_acquisition.gps_service import GPSService from repeater.engine import RepeaterHandler from repeater.handler_helpers import ( AdvertHelper, DiscoveryHelper, LoginHelper, PathHelper, ProtocolRequestHelper, TextHelper, TraceHelper, ) from repeater.identity_manager import IdentityConfigurationError, IdentityManager, IdentitySpec from repeater.packet_router import PacketRouter from repeater.sensors import SensorManager from repeater.utils_packet import create_scoped_advert_packet from repeater.web.http_server import HTTPStatsServer, _log_buffer from openhop_core.companion.radio_capabilities import resolve_max_tx_power_dbm from openhop_core.protocol.constants import PAYLOAD_TYPE_RAW_CUSTOM logger = logging.getLogger("RepeaterDaemon") _COMPANION_LOAD_RETRY_DELAY_SEC = 0.5 async def _load_companion_rows_verified( loader, counter, kind: str, companion_hash_str: str, name: str, **loader_kwargs ): """Load persisted companion rows, cross-checking empty results against the table. A transient SQLite error at boot must not present as "no data" — the companion would start with an empty store and later saves would overwrite the persisted state. Retries once after a short delay when the load failed (loader returned None) or returned empty while the table has rows for this companion; raises CompanionStateLoadError if it still cannot load. Returns (rows, stored_count). """ stored = 0 for attempt in (1, 2): rows = loader(companion_hash_str, **loader_kwargs) stored = counter(companion_hash_str) if rows is not None and (rows or stored == 0): return rows, stored if attempt == 1: logger.warning( "Companion %s ('%s'): %s load %s but table has %d row(s); retrying once", companion_hash_str, name, kind, "failed" if rows is None else "returned empty", stored, ) await asyncio.sleep(_COMPANION_LOAD_RETRY_DELAY_SEC) raise CompanionStateLoadError( f"Companion {companion_hash_str} ('{name}'): could not load persisted {kind} " f"(table has {stored} row(s)); refusing to start with an empty store" ) class RepeaterDaemon: def __init__(self, config: dict, radio=None): self.config = config self.radio = radio self.dispatcher = None self.repeater_handler = None self.local_hash = None self.local_identity = None self.identity_manager = None self.config_manager = None self.http_server = None self.trace_helper = None self.advert_helper = None self.discovery_helper = None self.login_helper = None self.text_helper = None self.path_helper = None self.protocol_request_helper = None self.glass_handler = None self.gps_service = None self.sensor_manager = None self.acl = None self.router = None self.companion_bridges: dict[int, object] = {} self.companion_frame_servers: list = [] # Parsed once during the startup preflight; the identity loaders reuse # them so config parsing (and its warnings) does not run twice. self._room_server_specs: list[IdentitySpec] | None = None self._companion_specs: list[IdentitySpec] | None = None self._shutdown_started = False self._main_task = None self.radio_status = "unknown" self.radio_error = None log_level = config.get("logging", {}).get("level", "INFO") logging.basicConfig( level=getattr(logging, log_level), format=config.get("logging", {}).get("format"), ) root_logger = logging.getLogger() _log_buffer.setLevel(getattr(logging, log_level)) root_logger.addHandler(_log_buffer) def _configured_identity_specs(self, identity_type: str) -> list[IdentitySpec]: """Build valid configured local identities without registering them. Invalid optional room-server or companion entries retain the existing skip-and-log behavior. Valid entries are returned for collision validation before they can create helper, database, or TCP state. """ from openhop_core import LocalIdentity config_key = { "room_server": "room_servers", "companion": "companions", }[identity_type] configs = self.config.get("identities", {}).get(config_key) or [] specs = [] for identity_config in configs: name = identity_config.get("name") identity_key = identity_config.get("identity_key") label = "Companion" if identity_type == "companion" else "Room server" if not name or not identity_key: logger.warning("Skipping %s config: missing name or identity_key", label.lower()) continue try: if isinstance(identity_key, str): key_hex = ( normalize_companion_identity_key(identity_key) if identity_type == "companion" else identity_key ) identity_key_bytes = bytes.fromhex(key_hex) elif isinstance(identity_key, bytes): identity_key_bytes = identity_key else: logger.error("%s '%s' identity_key has unknown type", label, name) continue except ValueError as error: logger.error("%s '%s' identity_key invalid hex: %s", label, name, error) continue if len(identity_key_bytes) not in (32, 64): logger.error( "%s '%s' identity_key must be 32 bytes (hex) or 64 bytes " "(MeshCore firmware key)", label, name, ) continue try: identity = LocalIdentity(seed=identity_key_bytes) except Exception as error: logger.error("Failed to create %s identity '%s': %s", label.lower(), name, error) continue specs.append( IdentitySpec( name=name, identity=identity, config=identity_config, identity_type=identity_type, ) ) return specs def _preflight_configured_local_identities(self, local_identity) -> None: """Validate every configured local identity before stateful setup begins. The parsed room-server and companion specs are cached so the identity loaders reuse them instead of re-parsing the config (and re-logging every invalid entry). Collision rules live in ``IdentityManager.validate_specs``; at this point the manager holds no registered identities, so this is a pure batch check. """ self._room_server_specs = self._configured_identity_specs("room_server") self._companion_specs = self._configured_identity_specs("companion") specs = [ IdentitySpec("repeater", local_identity, self.config, "repeater"), *self._room_server_specs, *self._companion_specs, ] manager = self.identity_manager or IdentityManager(self.config) manager.validate_specs(specs) async def initialize(self): logger.info(f"Initializing repeater: {self.config['repeater']['node_name']}") # ----------------------------------------------- # Get the actual Network IP Address try: # This looks for the IP assigned to the default hostname host_name = socket.gethostname() # We try to get the IP associated with the hostname self.network_ip = socket.gethostbyname(host_name) # If that still gives 127.0.x.x, let's try a different internal method if self.network_ip.startswith("127."): s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) # We use a non-routable IP that doesn't require an actual connection s.connect(("10.255.255.255", 1)) self.network_ip = s.getsockname()[0] s.close() except Exception as e: logger.warning(f"Could not determine network IP: {e}") self.network_ip = "Unknown" logger.info(f"System Network IP: {self.network_ip}") # ----------------------------------------------- if self.radio is None: radio_type_raw = self.config.get("radio_type") radio_type = "none" if radio_type_raw is None else str(radio_type_raw) radio_type_lower = radio_type.lower().strip() radio_explicitly_disabled = radio_type_lower in ( "", "none", "null", "disabled", "off", "no_radio", ) logger.info(f"Initializing radio hardware... (radio_type={radio_type})") try: self.radio = get_radio_for_board(self.config) if isinstance(self.radio, NullRadio): self.radio_status = "disabled" if radio_explicitly_disabled else "degraded" if self.radio_status == "disabled": self.radio_error = None else: self.radio_error = ( self.radio_error or f"Radio type '{radio_type}' unavailable; running in no-radio mode" ) else: self.radio_status = "ok" self.radio_error = None # KISS modem: schedule RX callbacks on the event loop for thread safety if hasattr(self.radio, "set_event_loop"): self.radio.set_event_loop(asyncio.get_running_loop()) if hasattr(self.radio, "set_custom_cad_thresholds"): # Load CAD settings from config, with defaults cad_config = self.config.get("radio", {}).get("cad", {}) peak_threshold = cad_config.get("peak_threshold", 23) min_threshold = cad_config.get("min_threshold", 11) symbol_num = cad_config.get("symbol_num", 2) try: symbol_num = int(symbol_num) except (TypeError, ValueError): symbol_num = 2 if symbol_num not in {1, 2, 4, 8, 16}: logger.warning( "Invalid CAD symbol_num in config (%s); defaulting to 2", symbol_num, ) symbol_num = 2 self.radio.set_custom_cad_thresholds(peak=peak_threshold, min_val=min_threshold) if hasattr(self.radio, "set_custom_cad_symbol_num"): self.radio.set_custom_cad_symbol_num(symbol_num) logger.info( "CAD settings set from config: peak=%s, min=%s, symbols=%s", peak_threshold, min_threshold, symbol_num, ) else: logger.warning("Radio does not support CAD configuration") if hasattr(self.radio, "get_frequency"): logger.info(f"Radio config - Freq: {self.radio.get_frequency():.1f}MHz") if hasattr(self.radio, "get_spreading_factor"): logger.info(f"Radio config - SF: {self.radio.get_spreading_factor()}") if hasattr(self.radio, "get_bandwidth"): logger.info(f"Radio config - BW: {self.radio.get_bandwidth()}kHz") if hasattr(self.radio, "get_coding_rate"): logger.info(f"Radio config - CR: {self.radio.get_coding_rate()}") if hasattr(self.radio, "get_tx_power"): logger.info(f"Radio config - TX Power: {self.radio.get_tx_power()}dBm") logger.info("Radio hardware initialized") except Exception as e: logger.error(f"Failed to initialize radio hardware: {e}") self.radio_status = "degraded" self.radio_error = str(e) logger.warning( "Radio type '%s' unavailable; starting in no-radio mode to keep service alive. " "Check radio configuration and hardware mapping.", radio_type, ) self.radio = NullRadio() try: from openhop_core import LocalIdentity from openhop_core.node.dispatcher import Dispatcher dedupe_enabled = bool( self.config.get("repeater", {}).get("dispatcher_dedupe_enabled", False) ) self.dispatcher = Dispatcher(self.radio, dedupe_enabled=dedupe_enabled) # Flood reception-quality delay base (MeshCore "set rxdelay"); # 0 keeps flood processing immediate, the firmware default. self.dispatcher.rx_delay_base = float( self.config.get("delays", {}).get("rx_delay_base", 0.0) ) logger.info("Dispatcher initialized") logger.info("Dispatcher dedupe enabled: %s", dedupe_enabled) # Track every local identity, including the default repeater. self.identity_manager = IdentityManager(self.config) logger.info("Identity manager initialized") # Set up the default repeater identity. identity_key = self.config.get("repeater", {}).get("identity_key") if not identity_key: logger.error("No identity key found in configuration. Cannot init repeater.") raise RuntimeError("Identity key is required for repeater operation") local_identity = LocalIdentity(seed=identity_key) self.local_identity = local_identity self.dispatcher.local_identity = local_identity # A one-byte public-key prefix selects local routing, companion # bridges, and companion SQLite namespaces. Reject all configured # collisions before helpers, databases, or companion TCP servers # have any state to overwrite. self._preflight_configured_local_identities(local_identity) if not self.identity_manager.register_identity( name="repeater", identity=local_identity, config=self.config, identity_type="repeater", ): raise IdentityConfigurationError("Failed to register repeater identity") pubkey = local_identity.get_public_key() self.local_hash = pubkey[0] self.local_hash_bytes = bytes(pubkey[:3]) logger.info(f"Local identity set: {local_identity.get_address_bytes().hex()}") local_hash_hex = f"0x{self.local_hash:02x}" logger.info(f"Local node hash (from identity): {local_hash_hex}") # Load additional identities from config (e.g., room servers) await self._load_additional_identities() self.repeater_handler = RepeaterHandler( self.config, self.dispatcher, self.local_hash, local_hash_bytes=self.local_hash_bytes, send_advert_func=self.send_advert, ) # Create router self.router = PacketRouter(self) await self.router.start() # Register router as entry point for ALL packets via fallback handler # All received packets flow through router → helpers → repeater engine self.dispatcher.register_fallback_handler(self._router_callback) logger.info("Packet router registered as fallback (catches all packets)") # Final-hop RAW_CUSTOM is local-only. Direct packets with remaining # hops are handed to the router; flood RAW_CUSTOM is discarded. self._register_raw_custom_handler() # Set default path hash mode for flood 0-hop packets (adverts, etc.) path_hash_mode = self.config.get("mesh", {}).get("path_hash_mode", 0) if path_hash_mode not in (0, 1, 2): logger.warning( f"Invalid mesh.path_hash_mode={path_hash_mode}, must be 0/1/2; using 0" ) path_hash_mode = 0 self.dispatcher.set_default_path_hash_mode(path_hash_mode) mode_names = {0: "1-byte", 1: "2-byte", 2: "3-byte"} logger.info( f"Path hash mode set to {mode_names[path_hash_mode]} (mesh.path_hash_mode={path_hash_mode})" ) # Create processing helpers (handlers created internally) self.trace_helper = TraceHelper( local_hash=self.local_hash, repeater_handler=self.repeater_handler, packet_injector=self.router.inject_packet, log_fn=logger.info, local_identity=self.local_identity, ) logger.info("Trace processing helper initialized") # Create advert helper for neighbor tracking self.advert_helper = AdvertHelper( local_identity=self.local_identity, storage=self.repeater_handler.storage if self.repeater_handler else None, config=self.config, log_fn=logger.info, ) logger.info("Advert processing helper initialized") # Set up discovery handler if enabled allow_discovery = self.config.get("repeater", {}).get("allow_discovery", True) if allow_discovery: self.discovery_helper = DiscoveryHelper( local_identity=self.local_identity, packet_injector=self.router.inject_packet, node_type=2, log_fn=logger.info, debug_log_fn=logger.debug, ) logger.info("Discovery processing helper initialized") else: logger.info("Discovery response handler disabled") # Create login helper (will create per-identity ACLs) self.login_helper = LoginHelper( identity_manager=self.identity_manager, packet_injector=self.router.inject_packet, log_fn=logger.info, sqlite_handler=( self.repeater_handler.storage.sqlite_handler if self.repeater_handler and self.repeater_handler.storage else None ), # For anon regions-discovery replies config=self.config, # For owner-info / feature-flags replies ) # Register default repeater identity self.login_helper.register_identity( name="repeater", identity=self.local_identity, identity_type="repeater", config=self.config, # Pass full config so repeater can access top-level security section ) # Register room server identities with their configs for name, identity, config in self.identity_manager.get_identities_by_type( "room_server" ): self.login_helper.register_identity( name=name, identity=identity, identity_type="room_server", config=config, # Pass room-specific config ) logger.info("Login processing helper initialized") # Initialize ConfigManager for centralized config management self.config_manager = ConfigManager( config_path=getattr(self, "config_path", "/etc/openhop_repeater/config.yaml"), config=self.config, daemon_instance=self, ) logger.info("Config manager initialized") self.sensor_manager = SensorManager(self.config) self.sensor_manager.start() if self.sensor_manager.get_summary().get("loaded", 0): logger.info("Sensor manager initialized") else: logger.info("No configured sensors loaded") self.gps_service = GPSService( self.config, location_update_callback=self._update_repeater_location_from_gps, ) self.gps_service.start() if self.config.get("gps", {}).get("enabled", False): logger.info("GPS diagnostics initialized") else: logger.info("GPS diagnostics disabled") # Initialize text message helper with per-identity ACLs self.text_helper = TextHelper( identity_manager=self.identity_manager, packet_injector=self.router.inject_packet, acl_dict=self.login_helper.get_acl_dict(), # Per-identity ACLs log_fn=logger.info, config_path=getattr(self, "config_path", None), # For CLI to save changes config=self.config, # For CLI to read/modify settings config_manager=self.config_manager, # New centralized config manager sqlite_handler=( self.repeater_handler.storage.sqlite_handler if self.repeater_handler and self.repeater_handler.storage else None ), # For room server database send_advert_callback=self.send_advert, # For CLI advert command ) self.text_helper._loop = asyncio.get_running_loop() # Register default repeater identity for text messages self.text_helper.register_identity( name="repeater", identity=self.local_identity, identity_type="repeater", radio_config=self.config.get("radio", {}), ) # Register room server identities for text messages for name, identity, config in self.identity_manager.get_identities_by_type( "room_server" ): self.text_helper.register_identity( name=name, identity=identity, identity_type="room_server", radio_config=config, # Pass room-specific config (includes max_posts, etc.) ) logger.info("Text message processing helper initialized") # Initialize PATH packet helper for updating client out_path self.path_helper = PathHelper( acl_dict=self.login_helper.get_acl_dict(), # Per-identity ACLs log_fn=logger.info, ack_received_callback=( self.dispatcher._register_ack_received if self.dispatcher and hasattr(self.dispatcher, "_register_ack_received") else None ), ) logger.info("PATH packet processing helper initialized") # Initialize protocol request handler for status/telemetry requests self.protocol_request_helper = ProtocolRequestHelper( identity_manager=self.identity_manager, packet_injector=self.router.inject_packet, acl_dict=self.login_helper.get_acl_dict(), radio=self.radio, engine=self.repeater_handler, neighbor_tracker=self.advert_helper, config=self.config, sensor_manager=self.sensor_manager, ) # Register repeater identity for protocol requests self.protocol_request_helper.register_identity( name="repeater", identity=self.local_identity, identity_type="repeater" ) logger.info("Protocol request handler initialized") # Load companion identities (CompanionBridge + frame server per companion) await self._load_companion_identities() # Subscribe to raw RX in openhop-core so we can push PUSH_CODE_LOG_RX_DATA to companion clients self.dispatcher.add_raw_rx_subscriber(self._on_raw_rx_for_companions) n = len(getattr(self, "companion_frame_servers", [])) logger.info( "Raw RX subscriber registered (%s companion frame server(s)). Connect a client to see rx_log (0x88).", n, ) self._register_duplicate_logging_hook(dedupe_enabled) # When trace reaches final node, push PUSH_CODE_TRACE_DATA (0x89) to companion clients (firmware onTraceRecv) self.trace_helper.on_trace_complete = self._on_trace_complete_for_companions # Optional pyMC_Glass integration loop (inform/control plane) self.glass_handler = GlassHandler( config=self.config, daemon_instance=self, config_manager=self.config_manager, ) await self.glass_handler.start() if ( self.repeater_handler and self.repeater_handler.storage and hasattr(self.repeater_handler.storage, "set_glass_publisher") ): self.repeater_handler.storage.set_glass_publisher( self.glass_handler.publish_telemetry ) except Exception as e: logger.error(f"Failed to initialize dispatcher: {e}") raise async def _load_additional_identities(self): room_specs = self._room_server_specs if room_specs is None: room_specs = self._configured_identity_specs("room_server") self.identity_manager.validate_specs(room_specs) for spec in room_specs: name, room_identity = spec.name, spec.identity try: # Register with the manager and all helpers success = self._register_identity_everywhere( name=name, identity=room_identity, config=spec.config, identity_type="room_server", ) if success: room_hash = room_identity.get_public_key()[0] logger.info( f"Loaded room server '{name}': hash=0x{room_hash:02x}, " f"address={room_identity.get_address_bytes().hex()}" ) else: raise IdentityConfigurationError( f"Failed to register room server identity '{name}'" ) except IdentityConfigurationError: raise except Exception as e: logger.error(f"Failed to load room server identity '{name}': {e}") # Summary logging total_identities = len(self.identity_manager.list_identities()) logger.info(f"Identity manager loaded {total_identities} total identities") def _get_companion_radio_settings(self) -> dict: """Return the current repeater radio settings for virtual companions. The values are read-only to companion sessions. Prefer attributes of the active backend, then retain the configured value when a backend cannot expose that field. """ config = ( self.repeater_handler.radio_config if self.repeater_handler else self.config.get("radio", {}) ) settings = dict(config) if isinstance(config, dict) else {} radio = self.radio if radio is None: return settings for config_key, attr in ( ("frequency", "frequency"), ("bandwidth", "bandwidth"), ("spreading_factor", "spreading_factor"), ("coding_rate", "coding_rate"), ("tx_power", "tx_power"), ): value = getattr(radio, attr, None) if value is not None: settings[config_key] = value return settings def _get_companion_max_tx_power_dbm(self): """Return the active backend's TX limit when it declares one. The backend can expose a ``get_max_tx_power_dbm`` method, a ``max_tx_power_dbm`` attribute (SX1262 backends declare their 22 dBm driver limit this way), or a validated deployment setting. Returning ``None`` lets Core use its generic protocol fallback. """ return resolve_max_tx_power_dbm(self.radio, self._get_companion_radio_settings()) async def _load_companion_identities(self) -> None: """Load companion identities from config and create CompanionBridge + frame server for each.""" from repeater.companion import CompanionFrameServer, RepeaterCompanionBridge companion_specs = self._companion_specs if companion_specs is None: companion_specs = self._configured_identity_specs("companion") if not companion_specs: return # Validate the complete companion set before any bridge can restore or # mutate a hash-keyed SQLite namespace, or any TCP server can bind. self.identity_manager.validate_specs(companion_specs) sqlite_handler = None if self.repeater_handler and self.repeater_handler.storage: sqlite_handler = self.repeater_handler.storage.sqlite_handler if not sqlite_handler: logger.warning( "Companion persistence disabled: no storage (contacts/channels will not survive restart or disconnect)" ) radio_config = ( self.repeater_handler.radio_config if self.repeater_handler else self.config.get("radio", {}) ) for spec in companion_specs: name, identity, comp_config = spec.name, spec.identity, spec.config try: settings = comp_config.get("settings") or {} pubkey = identity.get_public_key() companion_hash = pubkey[0] companion_hash_str = f"0x{companion_hash:02x}" node_name = settings.get("node_name", name) tcp_port = settings.get("tcp_port", 5000) bind_address = settings.get("bind_address", "0.0.0.0") # nosec B104 tcp_timeout_raw = settings.get("tcp_timeout", 8 * 60 * 60) # 8 hours client_idle_timeout_sec = None if tcp_timeout_raw == 0 else int(tcp_timeout_raw) def _make_sync_node_name_to_config(companion_name: str): """Return a callback that syncs node_name to config for this companion (binds name at creation).""" def _sync(new_node_name: str) -> None: try: validated = validate_companion_node_name(new_node_name) except ValueError: return companions = (self.config.get("identities") or {}).get("companions") or [] for entry in companions: if entry.get("name") == companion_name: if "settings" not in entry: entry["settings"] = {} entry["settings"]["node_name"] = validated config_path = getattr(self, "config_path", None) if config_path: save_config(self.config, config_path) break return _sync bridge_kwargs = parse_companion_bridge_kwargs(settings) max_contacts = effective_max_contacts(bridge_kwargs) if sqlite_handler: trimmed = enforce_companion_contact_capacity( companion_hash_str, max_contacts, sqlite_handler, trim=bool(settings.get("trim_contacts_on_overflow")), companion_name=name, ) if trimmed: logger.warning( "Companion '%s': trimmed %d contact(s) to fit " "max_contacts=%d (trim_contacts_on_overflow)", name, trimmed, max_contacts, ) bridge = RepeaterCompanionBridge( identity=identity, # Tag the injector with this companion's hash so inject_packet can # skip its own frame server when echoing TX as raw RX (a node never # hears its own transmission). packet_injector=functools.partial( self.router.inject_packet, origin_hash=companion_hash_str ), node_name=node_name, radio_config=radio_config, radio_settings_getter=self._get_companion_radio_settings, max_tx_power_getter=self._get_companion_max_tx_power_dbm, sqlite_handler=sqlite_handler, companion_hash=companion_hash_str, on_prefs_saved=_make_sync_node_name_to_config(name), **bridge_kwargs, ) # Restore persisted state (contacts/channels/messages) from SQLite. # Raises CompanionStateLoadError instead of continuing with an # empty store when persisted rows exist but cannot be loaded. if sqlite_handler: await self._restore_companion_state( sqlite_handler, bridge, companion_hash_str, name ) # Ensure public channel (0) exists with default key for new companions from repeater.companion.constants import DEFAULT_PUBLIC_CHANNEL_SECRET if bridge.get_channel(0) is None: bridge.set_channel(0, "Public", DEFAULT_PUBLIC_CHANNEL_SECRET) self.companion_bridges[companion_hash] = bridge frame_server = CompanionFrameServer( bridge=bridge, companion_hash=companion_hash_str, port=tcp_port, bind_address=bind_address, client_idle_timeout_sec=client_idle_timeout_sec, sqlite_handler=sqlite_handler, local_hash=self.local_hash, stats_getter=self._get_companion_stats, control_handler=( self.discovery_helper.control_handler if self.discovery_helper else None ), ) await frame_server.start() self.companion_frame_servers.append(frame_server) if not self.identity_manager.register_identity( name=name, identity=identity, config=comp_config, identity_type="companion", ): # The complete set was prevalidated above. A failure here # signals a concurrent/configuration error and must not be # silently treated as a running companion. raise IdentityConfigurationError( f"Failed to register companion identity '{name}'" ) limits = format_companion_bridge_limits(bridge_kwargs) logger.info( f"Loaded companion '{name}': hash=0x{companion_hash:02x}, " f"port={tcp_port}, bind={bind_address}, " f"client_idle_timeout_sec={client_idle_timeout_sec}{limits}" ) except CompanionContactCapacityError as e: logger.error("%s", e) except CompanionStateLoadError as e: logger.error("Companion init aborted: %s", e) except IdentityConfigurationError: raise except Exception as e: logger.error(f"Failed to load companion '{name}': {e}", exc_info=True) async def _restore_companion_state( self, sqlite_handler, bridge, companion_hash_str: str, name: str ) -> None: """Restore persisted contacts and channels from SQLite into a bridge. Each load is cross-checked against the table's row count for this companion and retried once on mismatch; raises CompanionStateLoadError when persisted rows exist but cannot be loaded, so the companion fails init loudly instead of starting with an empty store. """ from openhop_core.companion.models import Channel contact_rows, contact_count = await _load_companion_rows_verified( sqlite_handler.companion_load_contacts, sqlite_handler.companion_count_contacts, "contacts", companion_hash_str, name, ) if contact_rows: records = [] for row in contact_rows: d = dict(row) d["public_key"] = d.pop("pubkey", d.get("public_key", b"")) records.append(d) bridge.contacts.load_from_dicts(records) # Load channels (normalize secret to 32 bytes to match # CompanionBase.set_channel and GroupTextHandler/PacketBuilder) channel_rows, channel_count = await _load_companion_rows_verified( sqlite_handler.companion_load_channels, sqlite_handler.companion_count_channels, "channels", companion_hash_str, name, ) for row in channel_rows: s = row.get("secret", b"") if isinstance(s, bytes): raw = s elif isinstance(s, (bytearray, memoryview)): raw = bytes(s) elif s: raw = bytes.fromhex(s if isinstance(s, str) else str(s)) else: raw = b"" if len(raw) < 32: raw = raw + b"\x00" * (32 - len(raw)) elif len(raw) > 32: raw = raw[:32] idx = row.get("channel_idx", 0) ch = Channel(name=row.get("name", ""), secret=raw) if not bridge.channels.set(idx, ch): logger.error( "Companion %s ('%s'): channel store rejected persisted channel " "idx=%r name=%r (index out of range?)", companion_hash_str, name, idx, row.get("name", ""), ) logger.info( "Companion %s ('%s'): restored %d/%d contact(s), %d/%d channel(s); " "queued messages remain in SQLite", companion_hash_str, name, len(contact_rows), contact_count, len(channel_rows), channel_count, ) async def add_companion_from_config(self, comp_config: dict) -> None: """ Load a single companion from config and register it (hot-reload). Creates RepeaterCompanionBridge, CompanionFrameServer, starts the server, and registers with identity_manager. Raises on error. """ from openhop_core import LocalIdentity from repeater.companion import CompanionFrameServer, RepeaterCompanionBridge from repeater.companion.constants import DEFAULT_PUBLIC_CHANNEL_SECRET name = comp_config.get("name") identity_key = comp_config.get("identity_key") settings = comp_config.get("settings") or {} if not name or not identity_key: raise ValueError("Companion config missing name or identity_key") if isinstance(identity_key, str): try: identity_key_bytes = bytes.fromhex(normalize_companion_identity_key(identity_key)) except ValueError as e: raise ValueError(f"Companion '{name}' identity_key invalid hex: {e}") from e elif isinstance(identity_key, bytes): identity_key_bytes = identity_key else: raise ValueError(f"Companion '{name}' identity_key has unknown type") if len(identity_key_bytes) not in (32, 64): raise ValueError( f"Companion '{name}' identity_key must be 32 bytes (hex) or 64 bytes (MeshCore firmware key)" ) # Already registered? if name in self.identity_manager.named_identities: raise ValueError(f"Companion '{name}' is already registered") identity = LocalIdentity(seed=identity_key_bytes) pubkey = identity.get_public_key() companion_hash = pubkey[0] companion_hash_str = f"0x{companion_hash:02x}" if self.identity_manager is None: raise RuntimeError("Identity manager must be initialized before adding a companion") registration_error = self.identity_manager.registration_error(name, identity) if registration_error: raise ValueError(f"Cannot add companion: {registration_error}") if companion_hash in self.companion_bridges: raise ValueError(f"Companion with hash 0x{companion_hash:02x} already loaded") sqlite_handler = None if self.repeater_handler and self.repeater_handler.storage: sqlite_handler = self.repeater_handler.storage.sqlite_handler radio_config = ( self.repeater_handler.radio_config if self.repeater_handler else self.config.get("radio", {}) ) node_name = settings.get("node_name", name) tcp_port = settings.get("tcp_port", 5000) bind_address = settings.get("bind_address", "0.0.0.0") # nosec B104 tcp_timeout_raw = settings.get("tcp_timeout", 120) client_idle_timeout_sec = None if tcp_timeout_raw == 0 else int(tcp_timeout_raw) bridge_kwargs = parse_companion_bridge_kwargs(settings) max_contacts = effective_max_contacts(bridge_kwargs) if sqlite_handler: trimmed = enforce_companion_contact_capacity( companion_hash_str, max_contacts, sqlite_handler, trim=bool(settings.get("trim_contacts_on_overflow")), companion_name=name, ) if trimmed: logger.warning( "Hot-reload companion '%s': trimmed %d contact(s) to fit " "max_contacts=%d (trim_contacts_on_overflow)", name, trimmed, max_contacts, ) bridge = RepeaterCompanionBridge( identity=identity, packet_injector=functools.partial( self.router.inject_packet, origin_hash=companion_hash_str ), node_name=node_name, radio_config=radio_config, radio_settings_getter=self._get_companion_radio_settings, max_tx_power_getter=self._get_companion_max_tx_power_dbm, sqlite_handler=sqlite_handler, companion_hash=companion_hash_str, **bridge_kwargs, ) # Restore persisted state; raises CompanionStateLoadError when persisted # rows exist but cannot be loaded (hot-reload callers surface the error). if sqlite_handler: await self._restore_companion_state(sqlite_handler, bridge, companion_hash_str, name) if bridge.get_channel(0) is None: bridge.set_channel(0, "Public", DEFAULT_PUBLIC_CHANNEL_SECRET) self.companion_bridges[companion_hash] = bridge frame_server = CompanionFrameServer( bridge=bridge, companion_hash=companion_hash_str, port=tcp_port, bind_address=bind_address, client_idle_timeout_sec=client_idle_timeout_sec, sqlite_handler=sqlite_handler, local_hash=self.local_hash, stats_getter=self._get_companion_stats, control_handler=( self.discovery_helper.control_handler if self.discovery_helper else None ), ) await frame_server.start() self.companion_frame_servers.append(frame_server) if not self.identity_manager.register_identity( name=name, identity=identity, config=comp_config, identity_type="companion", ): raise IdentityConfigurationError(f"Failed to register companion identity '{name}'") limits = format_companion_bridge_limits(bridge_kwargs) logger.info( f"Hot-reload: Loaded companion '{name}': hash=0x{companion_hash:02x}, " f"port={tcp_port}, bind={bind_address}, " f"client_idle_timeout_sec={client_idle_timeout_sec}{limits}" ) async def _on_raw_rx_for_companions( self, data: bytes, rssi: int, snr: float, exclude_hash: str | None = None ) -> None: """Raw RX subscriber: push PUSH_CODE_LOG_RX_DATA (0x88) to connected companion clients. ``exclude_hash`` skips the frame server for that companion hash; used when echoing a companion's own injected TX so it never hears its own transmission. OTA RX subscribers leave it unset, so received packets reach every companion. """ servers = getattr(self, "companion_frame_servers", []) if not servers: return for fs in servers: if exclude_hash is not None and getattr(fs, "companion_hash", None) == exclude_hash: continue try: fs.push_rx_raw(snr, rssi, data) except Exception as e: logger.debug("Push RX raw to companion: %s", e) def _register_raw_custom_handler(self) -> None: """Register firmware-compatible RAW_CUSTOM handling ahead of fallback routing.""" if self.dispatcher: self.dispatcher.register_handler( PAYLOAD_TYPE_RAW_CUSTOM, self._on_raw_data_for_companions ) async def _on_raw_data_for_companions(self, packet) -> None: """Deliver final direct RAW_CUSTOM packets and route direct intermediate hops.""" if not packet.is_route_direct(): return if getattr(packet, "path", None): await self._router_callback(packet) return handler = self.repeater_handler if handler: if handler.is_duplicate(packet): return handler.mark_seen(packet) for bridge in self.companion_bridges.values(): try: await bridge.process_received_packet(packet) except Exception as e: logger.debug("Companion bridge RAW_CUSTOM error: %s", e) def _register_duplicate_logging_hook(self, dedupe_enabled: bool) -> None: """Register pre-dedup duplicate logging only when dispatcher dedupe is active.""" if not self.dispatcher or not dedupe_enabled: return # When dispatcher dedupe is disabled, duplicates still flow through # router -> repeater_handler and are already recorded there. self.dispatcher.add_raw_packet_subscriber(self._on_raw_packet_for_dedup_logging) def _on_raw_packet_for_dedup_logging(self, pkt, data: bytes, analysis: dict) -> None: """Record duplicate packets for UI visibility. Called by Dispatcher's raw_packet_subscriber (pre-dedup) so we see all path variants. Only records packets the engine has already seen; novel packets are left for the normal handler path. """ if not self.repeater_handler: return if not self.repeater_handler.is_duplicate(pkt): return # First variant — will reach engine via normal handler path rssi = getattr(pkt, "_rssi", 0) or 0 snr = getattr(pkt, "_snr", 0.0) or 0.0 self.repeater_handler.record_duplicate(pkt, rssi=rssi, snr=snr) async def deliver_control_data( self, snr: float, rssi: int, path_len: int, path_bytes: bytes, payload_bytes: bytes, ) -> None: """Deliver CONTROL payload (e.g. discovery response) to companion clients (PUSH_CODE_CONTROL_DATA 0x8E).""" # Only push discovery responses (0x90); client expects these, not the request (0x80) if len(payload_bytes) < 6 or (payload_bytes[0] & 0xF0) != 0x90: return # Push every discovery response to the client, including our own (snr=0, rssi=0 = local node's response) servers = getattr(self, "companion_frame_servers", []) if not servers: return tag = int.from_bytes(payload_bytes[2:6], "little") if len(payload_bytes) >= 6 else 0 logger.debug( "Delivering discovery response to %s companion(s): tag=0x%08X, len=%s", len(servers), tag, len(payload_bytes), ) for fs in servers: try: await fs.push_control_data(snr, rssi, path_len, path_bytes, payload_bytes) except Exception as e: logger.warning("Companion push_control_data error: %s", e) async def _on_trace_complete_for_companions(self, packet, parsed_data) -> None: """Trace completed at this node: push PUSH_CODE_TRACE_DATA (0x89) to companion clients (firmware onTraceRecv).""" path_hashes = parsed_data.get("trace_path_bytes") or b"" if not path_hashes: return flags = parsed_data.get("flags", 0) path_sz = flags & 0x03 hash_len = len(path_hashes) expected_snr_len = hash_len >> path_sz if expected_snr_len <= 0: return tag = parsed_data.get("tag", 0) auth_code = parsed_data.get("auth_code", 0) snr_scaled = max(-128, min(127, int(round(packet.get_snr() * 4)))) snr_byte = snr_scaled if snr_scaled >= 0 else (256 + snr_scaled) # Firmware: memcpy path_snrs from pkt->path (length hash_len >> path_sz), then final SNR byte raw = bytes(packet.path)[:expected_snr_len] if len(raw) < expected_snr_len: raw = raw + b"\x00" * (expected_snr_len - len(raw)) path_snrs = raw for fs in getattr(self, "companion_frame_servers", []): try: await fs.push_trace_data_async( hash_len, flags, tag, auth_code, path_hashes, path_snrs, snr_byte ) except Exception as e: logger.debug("Push trace data to companion: %s", e) def _register_identity_everywhere( self, name: str, identity, config: dict, identity_type: str ) -> bool: """ Register an identity with the manager and all helpers in one place. This is the single source of truth for identity registration. """ # Register with identity manager success = self.identity_manager.register_identity( name=name, identity=identity, config=config, identity_type=identity_type ) if not success: return False # Register with all helpers if self.login_helper: self.login_helper.register_identity( name=name, identity=identity, identity_type=identity_type, config=config ) if self.text_helper: self.text_helper.register_identity( name=name, identity=identity, identity_type=identity_type, radio_config=self.config.get("radio", {}), ) if self.protocol_request_helper: self.protocol_request_helper.register_identity( name=name, identity=identity, identity_type=identity_type ) return True async def _router_callback(self, packet): """ Single entry point for ALL packets. Enqueues packets for router processing. """ if self.router: try: await self.router.enqueue(packet) except Exception as e: logger.error(f"Error enqueuing packet in router: {e}", exc_info=True) def register_text_handler_for_identity( self, name: str, identity, identity_type: str = "room_server", radio_config: dict = None ): if not self.text_helper: logger.warning("Text helper not initialized, cannot register identity") return False try: self.text_helper.register_identity( name=name, identity=identity, identity_type=identity_type, radio_config=radio_config or self.config.get("radio", {}), ) logger.info(f"Registered text handler for {identity_type} '{name}'") return True except Exception as e: logger.error(f"Failed to register text handler for '{name}': {e}") return False def get_stats(self) -> dict: stats = {} if self.repeater_handler: stats = self.repeater_handler.get_stats() # Add public key if available if self.local_identity: try: pubkey = self.local_identity.get_public_key() stats["public_key"] = pubkey.hex() except Exception: stats["public_key"] = None if self.gps_service: stats["gps"] = self.gps_service.get_summary() if self.sensor_manager: stats["sensors"] = self.sensor_manager.get_summary() stats["radio_status"] = self.radio_status if self.radio_error: stats["radio_error"] = self.radio_error return stats async def _get_companion_stats(self, stats_type: int) -> dict: """Return stats dict for companion CMD_GET_STATS (format expected by frame_server + meshcore_py).""" from repeater.companion.constants import ( STATS_TYPE_CORE, STATS_TYPE_PACKETS, STATS_TYPE_RADIO, ) if not self.repeater_handler: return {} engine = self.repeater_handler airtime = engine.airtime_mgr.get_stats() uptime_secs = int(time.time() - engine.start_time) queue_len = 0 for bridge in getattr(self, "companion_bridges", {}).values(): queue_len += getattr(getattr(bridge, "message_queue", None), "count", 0) or 0 if stats_type == STATS_TYPE_CORE: return { "battery_mv": 0, "uptime_secs": uptime_secs, "errors": 0, "queue_len": min(255, queue_len), } if stats_type == STATS_TYPE_RADIO: noise_floor = int(engine.get_cached_noise_floor() or 0) radio = getattr(self, "dispatcher", None) and getattr(self.dispatcher, "radio", None) if radio: _r = getattr(radio, "get_last_rssi", lambda: 0) _s = getattr(radio, "get_last_snr", lambda: 0.0) last_rssi = _r() if callable(_r) else _r last_snr = _s() if callable(_s) else _s else: last_rssi, last_snr = 0, 0.0 tx_air_secs = int(airtime.get("total_airtime_ms", 0) / 1000) return { "noise_floor": noise_floor, "last_rssi": int(last_rssi) if last_rssi is not None else 0, "last_snr": float(last_snr) if last_snr is not None else 0.0, "tx_air_secs": tx_air_secs, "rx_air_secs": 0, } if stats_type == STATS_TYPE_PACKETS: return { "recv": getattr(engine, "rx_count", 0), "sent": getattr(engine, "forwarded_count", 0), "flood_tx": getattr(engine, "forwarded_count", 0), "direct_tx": 0, "flood_rx": getattr(engine, "rx_count", 0), "direct_rx": 0, "recv_errors": getattr(engine, "dropped_count", 0), } return {} async def send_advert(self) -> bool: if not self.dispatcher or not self.local_identity: logger.error("Cannot send advert: dispatcher or identity not initialized") return False mode = self.config.get("repeater", {}).get("mode", "forward") if mode == "no_tx": logger.debug("Adverts disabled in no_tx mode") return False try: from openhop_core.protocol.constants import ( ADVERT_FLAG_HAS_NAME, ADVERT_FLAG_IS_REPEATER, ) # Get node name and location from config repeater_config = self.config.get("repeater", {}) node_name = repeater_config.get("node_name", "Repeater") latitude = repeater_config.get("latitude", 0.0) longitude = repeater_config.get("longitude", 0.0) location_source = "config" if self.gps_service: location = self.gps_service.get_repeater_location() latitude = location.get("latitude", latitude) longitude = location.get("longitude", longitude) location_source = str(location.get("source", location_source)) flags = ADVERT_FLAG_IS_REPEATER | ADVERT_FLAG_HAS_NAME mesh_config = self.config.get("mesh", {}) default_region = mesh_config.get("default_region") packet, scoped_region_name = create_scoped_advert_packet( local_identity=self.local_identity, node_name=node_name, latitude=latitude, longitude=longitude, flags=flags, default_region=default_region, scope_label="advert", ) injector = getattr(getattr(self, "router", None), "inject_packet", None) if callable(injector): sent = await injector(packet, wait_for_ack=False) else: sent = await self.dispatcher.send_packet(packet, wait_for_ack=False) if not sent: logger.error("Failed to send advert: packet transmission was rejected") return False if not callable(injector) and self.repeater_handler: self.repeater_handler.mark_seen(packet) pkt_hash = packet.calculate_packet_hash().hex()[:16] self.dispatcher.packet_filter.track_packet(pkt_hash) logger.debug("Marked own advert as seen in duplicate cache") logger.info( "Sent flood advert '%s' at (%.6f, %.6f) source=%s", node_name, latitude, longitude, location_source, ) if scoped_region_name: logger.info("Advert scoped to default region '%s'", scoped_region_name) return True except Exception as e: logger.error(f"Failed to send advert: {e}", exc_info=True) return False def _update_repeater_location_from_gps(self, location: dict) -> bool: """Persist the latest valid GPS fix as the repeater's advertised location.""" latitude = location.get("latitude") longitude = location.get("longitude") if latitude is None or longitude is None: return False repeater_config = self.config.setdefault("repeater", {}) current_latitude = repeater_config.get("latitude") current_longitude = repeater_config.get("longitude") try: if ( current_latitude is not None and current_longitude is not None and abs(float(current_latitude) - float(latitude)) < 0.000001 and abs(float(current_longitude) - float(longitude)) < 0.000001 ): return False except (TypeError, ValueError): pass updates = { "repeater": { "latitude": float(latitude), "longitude": float(longitude), } } if self.config_manager: result = self.config_manager.update_and_save( updates=updates, live_update=True, live_update_sections=["repeater"], ) if not result.get("success"): logger.warning( "GPS location fix could not update repeater config: %s", result.get("error", "unknown error"), ) return False else: repeater_config.update(updates["repeater"]) logger.info( "Updated repeater location from GPS fix: latitude=%.6f longitude=%.6f", latitude, longitude, ) return True def _signal_shutdown(self, sig, loop): """Handle SIGTERM/SIGINT by scheduling async shutdown.""" if self._shutdown_started: logger.info(f"Received signal {sig.name}, shutdown already in progress") return logger.info(f"Received signal {sig.name}, shutting down...") loop.create_task(self._shutdown()) # Cancel run() so dispatcher.run_forever() unwinds cleanly. if self._main_task and not self._main_task.done(): self._main_task.cancel() async def _shutdown(self): """Best-effort shutdown: stop background services and release hardware.""" if self._shutdown_started: return self._shutdown_started = True # Stop companion frame servers first to close client sockets and child workers. for frame_server in getattr(self, "companion_frame_servers", []): try: await frame_server.stop() except Exception as e: logger.warning(f"Companion frame server stop error: {e}") # Stop companion bridges to flush/persist state. if hasattr(self, "companion_bridges"): for bridge in self.companion_bridges.values(): if hasattr(bridge, "stop"): try: await bridge.stop() except Exception as e: logger.warning(f"Companion bridge stop error: {e}") # Stop router if self.router: try: await self.router.stop() except Exception as e: logger.warning(f"Error stopping router: {e}") # Stop HTTP server if self.http_server: try: await asyncio.wait_for(asyncio.to_thread(self.http_server.stop), timeout=3) except asyncio.TimeoutError: logger.warning("Timeout stopping HTTP server") except Exception as e: logger.warning(f"Error stopping HTTP server: {e}") # Stop Glass inform loop if self.glass_handler: try: await self.glass_handler.stop() except Exception as e: logger.warning(f"Error stopping Glass handler: {e}") # Stop sensor manager. if self.sensor_manager: try: self.sensor_manager.stop() except Exception as e: logger.warning(f"Error stopping sensor manager: {e}") # Stop GPS diagnostics. if self.gps_service: try: self.gps_service.stop() except Exception as e: logger.warning(f"Error stopping GPS diagnostics: {e}") # Close storage publishers (MQTT/LetsMesh) to stop their worker threads. try: if self.repeater_handler and self.repeater_handler.storage: await asyncio.wait_for( asyncio.to_thread(self.repeater_handler.storage.close), timeout=5 ) except asyncio.TimeoutError: logger.warning("Timeout closing storage publishers") except Exception as e: logger.warning(f"Error closing storage: {e}") # Release radio resources if self.radio and hasattr(self.radio, "cleanup"): try: self.radio.cleanup() except Exception as e: logger.warning(f"Error cleaning up radio: {e}") # Release CH341 USB device if in use try: radio_type_raw = self.config.get("radio_type") radio_type = "" if radio_type_raw is None else str(radio_type_raw).lower() if radio_type == "sx1262_ch341": from openhop_core.hardware.ch341.ch341_async import CH341Async CH341Async.reset_instance() except Exception as e: logger.debug(f"CH341 reset skipped/failed: {e}") # Do not force-stop the event loop here; asyncio.run() owns loop lifecycle. @staticmethod def _detect_container() -> bool: """Detect if running inside an LXC/Docker/systemd-nspawn container.""" try: with open("/proc/1/environ", "rb") as f: if b"container=" in f.read(): return True except (OSError, PermissionError): pass return os.path.exists("/run/host/container-manager") async def run(self): logger.info("Repeater daemon started") self._main_task = asyncio.current_task() # Register signal handlers for graceful shutdown loop = asyncio.get_running_loop() for sig in (signal.SIGTERM, signal.SIGINT): loop.add_signal_handler( sig, functools.partial(self._signal_shutdown, sig, loop), ) # Warn if running inside a container (udev rules won't work here) if os.path.exists("/.dockerenv") or os.environ.get("container") or self._detect_container(): logger.warning( "Container environment detected. " "USB device udev rules must be configured on the HOST, not inside this container." ) try: await self.initialize() # Start HTTP stats server http_config = self.config.get("http", {}) http_port = http_config.get("port", 8000) http_host = http_config.get("host", "0.0.0.0") # nosec B104 http_enabled_raw = http_config.get("enabled", True) if isinstance(http_enabled_raw, str): http_enabled = http_enabled_raw.strip().lower() in ( "1", "true", "yes", "on", ) else: http_enabled = bool(http_enabled_raw) node_name = self.config.get("repeater", {}).get("node_name", "Repeater") # Format public key for display pub_key_formatted = "" if self.local_identity: pub_key_hex = self.local_identity.get_public_key().hex() # Format as if len(pub_key_hex) >= 16: pub_key_formatted = f"{pub_key_hex[:8]}...{pub_key_hex[-8:]}" else: pub_key_formatted = pub_key_hex current_loop = asyncio.get_event_loop() self.http_server = HTTPStatsServer( host=http_host, port=http_port, stats_getter=self.get_stats, node_name=node_name, pub_key=pub_key_formatted, send_advert_func=self.send_advert, config=self.config, event_loop=current_loop, daemon_instance=self, config_path=getattr(self, "config_path", "/etc/openhop_repeater/config.yaml"), ) if http_enabled: try: self.http_server.start() except Exception as e: logger.error(f"Failed to start HTTP server: {e}") else: logger.info("HTTP server startup skipped (http.enabled=false)") # Run dispatcher (handles RX/TX via openhop_core) try: await self.dispatcher.run_forever() except asyncio.CancelledError: logger.info("Dispatcher loop cancelled for shutdown") except KeyboardInterrupt: logger.info("Shutting down...") for frame_server in getattr(self, "companion_frame_servers", []): try: await frame_server.stop() except Exception as e: logger.debug(f"Companion frame server stop: {e}") if hasattr(self, "companion_bridges"): for bridge in self.companion_bridges.values(): if hasattr(bridge, "stop"): try: await bridge.stop() except Exception as e: logger.debug(f"Companion bridge stop: {e}") if self.router: await self.router.stop() if self.http_server: self.http_server.stop() finally: await self._shutdown() def main(): import argparse parser = argparse.ArgumentParser(description="openHop Repeater Daemon") parser.add_argument( "--config", help="Path to config file (default: /etc/openhop_repeater/config.yaml)", ) parser.add_argument( "--log-level", choices=["DEBUG", "INFO", "WARNING", "ERROR"], help="Log level (default: INFO)", ) args = parser.parse_args() # Load configuration config = load_config(args.config) config_path = args.config if args.config else "/etc/openhop_repeater/config.yaml" if args.log_level: if "logging" not in config: config["logging"] = {} config["logging"]["level"] = args.log_level # Don't initialize radio here - it will be done inside the async event loop daemon = RepeaterDaemon(config, radio=None) daemon.config_path = config_path # Run try: asyncio.run(daemon.run()) except KeyboardInterrupt: logger.info("Repeater stopped") except Exception as e: logger.error(f"Fatal error: {e}", exc_info=True) sys.exit(1) if __name__ == "__main__": main()