Files
pyMC_Repeater/repeater/main.py
Lloyd 97256eb132 Initial commit: PyMC Repeater Daemon
This commit sets up the initial project structure for the PyMC Repeater Daemon.
It includes base configuration files, dependency definitions, and scaffolding
for the main daemon service responsible for handling PyMC repeating operations.
2025-10-24 23:13:48 +01:00

272 lines
9.1 KiB
Python

import asyncio
import logging
import os
import sys
from repeater.config import get_radio_for_board, load_config
from repeater.engine import RepeaterHandler
from repeater.http_server import HTTPStatsServer, _log_buffer
logger = logging.getLogger("RepeaterDaemon")
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.http_server = None
# Setup logging
log_level = config.get("logging", {}).get("level", "INFO")
logging.basicConfig(
level=getattr(logging, log_level),
format=config.get("logging", {}).get("format"),
)
# Add log buffer handler to capture logs for web display
root_logger = logging.getLogger()
_log_buffer.setLevel(getattr(logging, log_level))
root_logger.addHandler(_log_buffer)
async def initialize(self):
logger.info(f"Initializing repeater: {self.config['repeater']['node_name']}")
if self.radio is None:
logger.info("Initializing radio hardware...")
try:
self.radio = get_radio_for_board(self.config)
logger.info("Radio hardware initialized")
except Exception as e:
logger.error(f"Failed to initialize radio hardware: {e}")
raise RuntimeError("Repeater requires real LoRa hardware") from e
# Create dispatcher from pymc_core
try:
from pymc_core import LocalIdentity
from pymc_core.node.dispatcher import Dispatcher
self.dispatcher = Dispatcher(self.radio)
logger.info("Dispatcher initialized")
identity_key = self.config.get("mesh", {}).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
# Get the actual hash from the identity (first byte of public key)
pubkey = local_identity.get_public_key()
self.local_hash = pubkey[0]
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}")
# Override _is_own_packet to always return False
self.dispatcher._is_own_packet = lambda pkt: False
self.repeater_handler = RepeaterHandler(
self.config, self.dispatcher, self.local_hash, send_advert_func=self.send_advert
)
self.dispatcher.register_fallback_handler(self._repeater_callback)
logger.info("Repeater handler registered (forwarder mode)")
except Exception as e:
logger.error(f"Failed to initialize dispatcher: {e}")
raise
async def _repeater_callback(self, packet):
if self.repeater_handler:
metadata = {
"rssi": getattr(packet, "rssi", 0),
"snr": getattr(packet, "snr", 0.0),
"timestamp": getattr(packet, "timestamp", 0),
}
await self.repeater_handler(packet, metadata)
def _get_keypair(self):
"""Create a PyNaCl SigningKey for map API."""
try:
from nacl.signing import SigningKey
if not self.local_identity:
return None
# Get the seed from config
identity_key = self.config.get("mesh", {}).get("identity_key")
if not identity_key:
return None
# Convert to bytes if it's a hex string, otherwise use as-is
if isinstance(identity_key, str):
seed_bytes = bytes.fromhex(identity_key)
else:
seed_bytes = identity_key
signing_key = SigningKey(seed_bytes)
return signing_key
except Exception as e:
logger.warning(f"Failed to create keypair for map API: {e}")
return None
def get_stats(self) -> dict:
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
return stats
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
try:
from pymc_core.protocol import PacketBuilder
from pymc_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)
flags = ADVERT_FLAG_IS_REPEATER | ADVERT_FLAG_HAS_NAME
packet = PacketBuilder.create_advert(
local_identity=self.local_identity,
name=node_name,
lat=latitude,
lon=longitude,
feature1=0,
feature2=0,
flags=flags,
route_type="flood",
)
# Send via dispatcher
await self.dispatcher.send_packet(packet)
# Mark our own advert as seen to prevent re-forwarding it
if self.repeater_handler:
self.repeater_handler.mark_seen(packet)
logger.debug("Marked own advert as seen in duplicate cache")
logger.info(f"Sent flood advert '{node_name}' at ({latitude: .6f}, {longitude: .6f})")
return True
except Exception as e:
logger.error(f"Failed to send advert: {e}", exc_info=True)
return False
async def run(self):
logger.info("Repeater daemon started")
await self.initialize()
# Start HTTP stats server
http_port = self.config.get("http", {}).get("port", 8000)
http_host = self.config.get("http", {}).get("host", "0.0.0.0")
template_dir = os.path.join(os.path.dirname(__file__), "templates")
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
# Get the current event loop (the main loop where the radio was initialized)
current_loop = asyncio.get_event_loop()
self.http_server = HTTPStatsServer(
host=http_host,
port=http_port,
stats_getter=self.get_stats,
template_dir=template_dir,
node_name=node_name,
pub_key=pub_key_formatted,
send_advert_func=self.send_advert,
config=self.config, # Pass the config reference
event_loop=current_loop, # Pass the main event loop
)
try:
self.http_server.start()
except Exception as e:
logger.error(f"Failed to start HTTP server: {e}")
# Run dispatcher (handles RX/TX via pymc_core)
try:
await self.dispatcher.run_forever()
except KeyboardInterrupt:
logger.info("Shutting down...")
if self.http_server:
self.http_server.stop()
def main():
import argparse
parser = argparse.ArgumentParser(description="pyMC Repeater Daemon")
parser.add_argument(
"--config",
help="Path to config file (default: /etc/pymc_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)
if args.log_level:
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)
# 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()