Files
pyMC_Repeater/repeater/main.py
T
agessaman 8310ccdee6 fix(repeater): make SIGTERM actually shut the daemon down
SIGTERM did not stop the daemon. Observed: the process stayed alive 18+
minutes with all three companion listen sockets still bound and the serial
port still held, so a restart could not reopen the radio. Two defects
compounded.

Cleanup never ran. The signal handler cancelled run(), whose finally then
awaited _shutdown() from inside an already-cancelled task -- so the first
await raised CancelledError, run() returned, and asyncio.run() tore the loop
down before a single shutdown step executed. Not one step logged. Running
cleanup in a sibling task instead does not fix it either: run() returns as
soon as the dispatcher stops and asyncio.run() cancels every leftover task
on the way out, which showed up as 'frame server :5050' being cancelled
mid-stop. The handler now unwinds run() cooperatively by stopping the
dispatcher, so run_forever() returns on its own and cleanup runs in a task
that is not being cancelled. Cancelling is kept only as a fallback for a
failure before the dispatcher exists.

Nothing bounded the steps. Frame servers, bridges, router and Glass had no
timeout, so one stuck step stranded every step after it -- including
releasing the radio. Each step is now bounded by SHUTDOWN_STEP_TIMEOUT_S and
logged by name, so a hang is both survivable and diagnosable, and the sync
steps (HTTP stop, sensor manager, GPS, radio cleanup) run off-loop so a
blocking close cannot stall the sequence. The dispatcher is stopped first so
RX ends before its radio is released.

Even with cleanup fixed, a single non-daemon thread that never returns hangs
SIGTERM forever: interpreter finalization joins them with no timeout, which
is where the original 18-minute hang sat (main thread parked in
Py_FinalizeEx -> wait_for_thread_shutdown). Report any that are still alive
by name so the offender can be fixed at the source, excluding asyncio's own
executor workers since asyncio.run() joins those under its own timeout. Then
arm a daemon watchdog that forces the process down SHUTDOWN_EXIT_GRACE_S
after cleanup finishes, so a future stray thread costs a delayed exit rather
than a stuck service.

Verified on hardware: SIGTERM and SIGINT both exit in 1s with zero warnings,
all ports and the serial device released, and MQTT publishing its offline
status before disconnecting.
2026-07-24 15:29:40 -07:00

1886 lines
80 KiB
Python

import asyncio
import functools
import logging
import os
import signal
import socket
import sys
import threading
import time
from openhop_core.companion.radio_capabilities import resolve_max_tx_power_dbm
from openhop_core.protocol.constants import PAYLOAD_TYPE_RAW_CUSTOM
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.exceptions import ConfigurationError
from repeater.handler_helpers import (
AdvertHelper,
DiscoveryHelper,
LoginHelper,
PathHelper,
ProtocolRequestHelper,
TextHelper,
TraceHelper,
)
from repeater.identity_manager import IdentityConfigurationError, IdentityManager, IdentitySpec
from repeater.logging_utils import normalize_log_level
from repeater.packet_router import PacketRouter
from repeater.region_map_builder import build_region_map
from repeater.sensors import SensorManager
from repeater.utils_packet import create_scoped_advert_packet
from repeater.web.http_server import HTTPStatsServer, _log_buffer
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 = []
# Shared RegionMap describing the named regions this repeater serves.
# Wired into the dispatcher and every companion bridge so core can
# re-scope flood replies to the region their request arrived under
# (firmware sendFloodReply parity). Rebuilt on any transport_keys change.
self._region_map = None
# 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
# Set by the first shutdown signal so a second one is ignored while
# run() is still unwinding.
self._stop_requested = False
self.radio_status = "unknown"
self.radio_error = None
log_level = normalize_log_level(config.get("logging", {}).get("level", "INFO"))
logging.basicConfig(
level=log_level,
format=config.get("logging", {}).get("format"),
)
root_logger = logging.getLogger()
_log_buffer.setLevel(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)
def _get_sqlite_handler(self):
"""Return the shared SQLiteHandler, or None if storage is unavailable."""
handler = self.repeater_handler
storage = getattr(handler, "storage", None) if handler else None
return getattr(storage, "sqlite_handler", None) if storage else None
def _init_region_map(self) -> None:
"""Build the shared RegionMap, wire it into the dispatcher, and hook rebuilds.
Called once storage is available (right after ``repeater_handler`` is
created) and before any companion bridge is built, so the dispatcher and
every bridge share the same instance. Runtime region edits (transport_keys
CRUD from the CLI, web API, or Glass sync) fire the storage change hook,
which reruns ``refresh_region_map``.
"""
sqlite_handler = self._get_sqlite_handler()
self._region_map = build_region_map(self.config, sqlite_handler)
if self.dispatcher is not None:
self.dispatcher.region_map = self._region_map
if sqlite_handler is not None and hasattr(
sqlite_handler, "set_transport_keys_changed_callback"
):
sqlite_handler.set_transport_keys_changed_callback(self.refresh_region_map)
logger.info(
"Region map initialized with %d served region(s)",
len(self._region_map.regions),
)
def refresh_region_map(self) -> None:
"""Rebuild the RegionMap and reassign it to the dispatcher and all bridges.
Fires from the storage transport_keys change hook whenever a named region
is added, removed, or has its flood policy changed. A fresh instance is
reassigned (rather than mutated in place) because this may run in a
cherrypy worker thread while ``find_match`` iterates the map on the RX
hot path in the event-loop thread: an attribute rebind is atomic under
the GIL, so an in-flight match keeps using the old, fully-built map.
New bridges pick up the current instance at creation time.
"""
new_map = build_region_map(self.config, self._get_sqlite_handler())
self._region_map = new_map
if self.dispatcher is not None:
self.dispatcher.region_map = new_map
for bridge in list(self.companion_bridges.values()):
try:
bridge.region_map = new_map
except Exception:
logger.debug("Failed to update region map on a companion bridge", exc_info=True)
logger.info("Region map refreshed with %d served region(s)", len(new_map.regions))
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 ConfigurationError("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,
)
# Storage now exists: build the served-region map and wire it into the
# dispatcher so flood replies are re-scoped to their request's region.
# Runs before any companion bridge is created so all share one instance.
self._init_region_map()
# 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,
)
# Share the dispatcher's served-region map so this bridge re-scopes
# its own flood replies to the region the request arrived under.
bridge.region_map = self._region_map
# Feed this bridge every pre-dedup copy of a flood reply so its
# return-path teacher can pick the best-received route rather than
# the first-arrived one. The router hands a bridge only the first
# copy (later ones are dropped by the engine's seen-table) and the
# pre-dedup firehose lives on the dispatcher, which the bridge does
# not own -- so the host has to wire it.
if self.dispatcher:
self.dispatcher.add_raw_packet_subscriber(bridge.note_flood_copy)
# 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, "companion")
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,
)
# Share the current served-region map (hot-reload path) so this bridge
# re-scopes its flood replies to the region the request arrived under.
bridge.region_map = self._region_map
# Feed this bridge every pre-dedup copy of a flood reply so its
# return-path teacher can pick the best-received route rather than
# the first-arrived one. The router hands a bridge only the first
# copy (later ones are dropped by the engine's seen-table) and the
# pre-dedup firehose lives on the dispatcher, which the bridge does
# not own -- so the host has to wire it.
if self.dispatcher:
self.dispatcher.add_raw_packet_subscriber(bridge.note_flood_copy)
# 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 or self._stop_requested:
logger.info(f"Received signal {sig.name}, shutdown already in progress")
return
logger.info(f"Received signal {sig.name}, shutting down...")
self._stop_requested = True
# Unwind run() *cooperatively* rather than cancelling it: stopping the
# dispatcher makes run_forever() return, and run()'s finally then does
# the cleanup inside a task that is not being cancelled.
#
# Cancelling run() instead — the previous behaviour — meant its finally
# awaited _shutdown() from inside an already-cancelled task, so the first
# await raised CancelledError, run() returned, and asyncio.run() tore the
# loop down before any cleanup happened. Observed on SIGTERM: not one
# shutdown step logged, all three companion listen sockets still bound
# and the serial port still held, and the process then hung
# indefinitely in interpreter finalization. Running cleanup in a sibling
# task does not fix it either: run() returns as soon as the dispatcher
# stops, and asyncio.run() cancels every leftover task on the way out.
if self.dispatcher is not None and hasattr(self.dispatcher, "stop"):
loop.create_task(self.dispatcher.stop())
elif self._main_task and not self._main_task.done():
# No dispatcher to stop (a failure before startup finished): fall
# back to cancelling, and accept the reduced cleanup.
self._main_task.cancel()
# Per-step ceiling for shutdown. A best-effort shutdown must never be able to
# hang: one stuck step used to strand the whole sequence, leaving sockets
# bound and the serial port held.
SHUTDOWN_STEP_TIMEOUT_S = 5.0
# Grace period after cleanup before the process is forced down. Interpreter
# finalization joins non-daemon threads with no timeout of its own, so a
# single library thread that never returns hangs SIGTERM forever.
SHUTDOWN_EXIT_GRACE_S = 5.0
async def _shutdown_step(self, name: str, awaitable, timeout: float = None) -> None:
"""Await one shutdown step, bounded and logged; never raise."""
try:
await asyncio.wait_for(
awaitable, timeout=self.SHUTDOWN_STEP_TIMEOUT_S if timeout is None else timeout
)
except asyncio.TimeoutError:
logger.warning("Shutdown step '%s' timed out; continuing", name)
except asyncio.CancelledError:
logger.warning("Shutdown step '%s' was cancelled; continuing", name)
except Exception as e:
logger.warning("Shutdown step '%s' failed: %s", name, e)
async def _shutdown(self):
"""Best-effort shutdown: stop background services and release hardware."""
if self._shutdown_started:
return
self._shutdown_started = True
logger.info("Shutdown: stopping services and releasing hardware")
# Stop the dispatcher first so RX stops before its radio is taken away.
if self.dispatcher is not None and hasattr(self.dispatcher, "stop"):
await self._shutdown_step("dispatcher", self.dispatcher.stop())
# Stop companion frame servers first to close client sockets and child workers.
for frame_server in getattr(self, "companion_frame_servers", []):
await self._shutdown_step(
f"frame server :{getattr(frame_server, 'port', '?')}", frame_server.stop()
)
# Stop companion bridges to flush/persist state.
if hasattr(self, "companion_bridges"):
for companion_hash, bridge in self.companion_bridges.items():
if hasattr(bridge, "stop"):
await self._shutdown_step(f"bridge 0x{companion_hash:02X}", bridge.stop())
# Stop router
if self.router:
await self._shutdown_step("router", self.router.stop())
# Stop HTTP server. Sync stop() runs off-loop so a wedged handler thread
# cannot block the sequence.
if self.http_server:
await self._shutdown_step(
"http server", asyncio.to_thread(self.http_server.stop), timeout=3
)
# Stop Glass inform loop
if self.glass_handler:
await self._shutdown_step("glass handler", self.glass_handler.stop())
# Stop sensor manager.
if self.sensor_manager:
await self._shutdown_step("sensor manager", asyncio.to_thread(self.sensor_manager.stop))
# Stop GPS diagnostics.
if self.gps_service:
await self._shutdown_step("gps service", asyncio.to_thread(self.gps_service.stop))
# Close storage publishers (MQTT/LetsMesh) to stop their worker threads.
if self.repeater_handler and self.repeater_handler.storage:
await self._shutdown_step(
"storage publishers",
asyncio.to_thread(self.repeater_handler.storage.close),
timeout=5,
)
# Release radio resources. Off-loop for the same reason as the HTTP
# server: closing a serial port can block on a stuck driver.
if self.radio and hasattr(self.radio, "cleanup"):
await self._shutdown_step("radio cleanup", asyncio.to_thread(self.radio.cleanup))
# 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.
logger.info("Shutdown: services stopped")
self._report_lingering_threads()
self._arm_exit_watchdog()
@staticmethod
def _report_lingering_threads() -> None:
"""Name any non-daemon threads that will block interpreter exit.
Python joins non-daemon threads at finalization with no timeout, so one
library thread that never returns hangs SIGTERM indefinitely — observed
here for 18 minutes, the main thread parked in
``Py_FinalizeEx -> wait_for_thread_shutdown``. The watchdog below stops
that from holding up a restart; this names the culprit so it can be
fixed at the source rather than papered over every time.
"""
current = threading.current_thread()
expected, unexpected = [], []
for t in threading.enumerate():
if t is current or t is threading.main_thread() or t.daemon or not t.is_alive():
continue
# asyncio names its default-executor workers "asyncio_N". Those are
# joined by asyncio.run() under its own timeout, so they are not the
# unbounded kind; warning about them every shutdown would bury the
# thread that actually matters.
(expected if t.name.startswith("asyncio_") else unexpected).append(t.name)
if unexpected:
logger.warning(
"Non-daemon threads still alive; these block interpreter exit: %s",
", ".join(sorted(unexpected)),
)
if expected:
logger.debug("Executor threads still winding down: %s", ", ".join(sorted(expected)))
def _arm_exit_watchdog(self) -> None:
"""Force the process down if finalization does not complete promptly.
Cleanup is finished by the time this is armed, so exiting hard costs
nothing and guarantees SIGTERM is honoured. A daemon timer, so it never
becomes the thing holding the process open.
"""
grace = self.SHUTDOWN_EXIT_GRACE_S
def _force_exit() -> None:
logger.warning(
"Shutdown did not complete within %.0fs of cleanup finishing; exiting now",
grace,
)
logging.shutdown()
os._exit(0)
watchdog = threading.Timer(grace, _force_exit)
watchdog.name = "shutdown-watchdog"
watchdog.daemon = True
watchdog.start()
@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 <first8...last8>
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, build the daemon, and run it. Config mistakes (a
# missing or invalid config file, a missing required key, colliding local
# identities) surface as ConfigurationError and exit cleanly with just the
# message; only unexpected failures get the full traceback.
try:
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
asyncio.run(daemon.run())
except KeyboardInterrupt:
logger.info("Repeater stopped")
except ConfigurationError as e:
# An actionable config problem, not a crash: report just the message.
logger.error("Configuration error: %s", e)
sys.exit(1)
except Exception as e:
logger.error(f"Fatal error: {e}", exc_info=True)
sys.exit(1)
if __name__ == "__main__":
main()