mirror of
https://github.com/pyMC-dev/pyMC_Repeater.git
synced 2026-07-26 19:42:46 +02:00
1713 lines
70 KiB
Python
1713 lines
70 KiB
Python
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 <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
|
|
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()
|