mirror of
https://github.com/rightup/pyMC_Repeater.git
synced 2026-03-28 17:43:06 +01:00
435 lines
17 KiB
Python
435 lines
17 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.web.http_server import HTTPStatsServer, _log_buffer
|
|
from pymc_core.node.handlers.trace import TraceHandler
|
|
from pymc_core.protocol.constants import MAX_PATH_SIZE, ROUTE_TYPE_DIRECT
|
|
|
|
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
|
|
self.trace_handler = 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)
|
|
|
|
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)
|
|
|
|
|
|
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)
|
|
|
|
self.radio.set_custom_cad_thresholds(peak=peak_threshold, min_val=min_threshold)
|
|
logger.info(f"CAD thresholds set from config: peak={peak_threshold}, min={min_threshold}")
|
|
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}")
|
|
raise RuntimeError("Repeater requires real LoRa hardware") from e
|
|
|
|
|
|
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
|
|
|
|
|
|
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}")
|
|
|
|
|
|
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)")
|
|
|
|
self.trace_handler = TraceHandler(log_fn=logger.info)
|
|
|
|
self.dispatcher.register_handler(
|
|
TraceHandler.payload_type(),
|
|
self._trace_callback,
|
|
)
|
|
logger.info("Trace handler registered for network diagnostics")
|
|
|
|
|
|
|
|
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)
|
|
|
|
async def _trace_callback(self, packet):
|
|
|
|
try:
|
|
# Only process direct route trace packets
|
|
if packet.get_route_type() != ROUTE_TYPE_DIRECT or packet.path_len >= MAX_PATH_SIZE:
|
|
return
|
|
|
|
|
|
parsed_data = self.trace_handler._parse_trace_payload(packet.payload)
|
|
|
|
if not parsed_data.get("valid", False):
|
|
logger.warning(f"[TraceHandler] Invalid trace packet: {parsed_data.get('error', 'Unknown error')}")
|
|
return
|
|
|
|
trace_path = parsed_data["trace_path"]
|
|
trace_path_len = len(trace_path)
|
|
|
|
|
|
if self.repeater_handler:
|
|
import time
|
|
|
|
trace_path_bytes = [f"{h:02X}" for h in trace_path[:8]]
|
|
if len(trace_path) > 8:
|
|
trace_path_bytes.append("...")
|
|
path_hash = "[" + ", ".join(trace_path_bytes) + "]"
|
|
|
|
path_snrs = []
|
|
path_snr_details = []
|
|
for i in range(packet.path_len):
|
|
if i < len(packet.path):
|
|
snr_val = packet.path[i]
|
|
|
|
snr_signed = snr_val if snr_val < 128 else snr_val - 256
|
|
snr_db = snr_signed / 4.0
|
|
path_snrs.append(f"{snr_val}({snr_db:.1f}dB)")
|
|
|
|
if i < len(trace_path):
|
|
path_snr_details.append({
|
|
"hash": f"{trace_path[i]:02X}",
|
|
"snr_raw": snr_val,
|
|
"snr_db": snr_db
|
|
})
|
|
|
|
packet_record = {
|
|
"timestamp": time.time(),
|
|
"header": f"0x{packet.header:02X}" if hasattr(packet, "header") and packet.header is not None else None,
|
|
"payload": packet.payload.hex() if hasattr(packet, "payload") and packet.payload else None,
|
|
"payload_length": len(packet.payload) if hasattr(packet, "payload") and packet.payload else 0,
|
|
"type": packet.get_payload_type(), # 0x09 for trace
|
|
"route": packet.get_route_type(), # Should be direct (1)
|
|
"length": len(packet.payload or b""),
|
|
"rssi": getattr(packet, "rssi", 0),
|
|
"snr": getattr(packet, "snr", 0.0),
|
|
"score": self.repeater_handler.calculate_packet_score(
|
|
getattr(packet, "snr", 0.0),
|
|
len(packet.payload or b""),
|
|
self.repeater_handler.radio_config.get("spreading_factor", 8)
|
|
),
|
|
"tx_delay_ms": 0,
|
|
"transmitted": False,
|
|
"is_duplicate": False,
|
|
"packet_hash": packet.calculate_packet_hash().hex()[:16],
|
|
"drop_reason": "trace_received",
|
|
"path_hash": path_hash,
|
|
"src_hash": None,
|
|
"dst_hash": None,
|
|
"original_path": [f"{h:02X}" for h in trace_path],
|
|
"forwarded_path": None,
|
|
# Add trace-specific SNR path information
|
|
"path_snrs": path_snrs, # ["58(14.5dB)", "19(4.8dB)"]
|
|
"path_snr_details": path_snr_details, # [{"hash": "29", "snr_raw": 58, "snr_db": 14.5}]
|
|
"is_trace": True,
|
|
"raw_packet": packet.write_to().hex() if hasattr(packet, "write_to") else None,
|
|
}
|
|
self.repeater_handler.log_trace_record(packet_record)
|
|
|
|
path_snrs = []
|
|
path_hashes = []
|
|
for i in range(packet.path_len):
|
|
if i < len(packet.path):
|
|
snr_val = packet.path[i]
|
|
snr_signed = snr_val if snr_val < 128 else snr_val - 256
|
|
snr_db = snr_signed / 4.0
|
|
path_snrs.append(f"{snr_val}({snr_db:.1f}dB)")
|
|
if i < len(trace_path):
|
|
path_hashes.append(f"0x{trace_path[i]:02x}")
|
|
|
|
|
|
parsed_data["snr"] = packet.get_snr()
|
|
parsed_data["rssi"] = getattr(packet, "rssi", 0)
|
|
formatted_response = self.trace_handler._format_trace_response(parsed_data)
|
|
|
|
logger.info(f"[TraceHandler] {formatted_response}")
|
|
logger.info(f"[TraceHandler] Path SNRs: [{', '.join(path_snrs)}], Hashes: [{', '.join(path_hashes)}]")
|
|
|
|
|
|
if (packet.path_len < trace_path_len and
|
|
len(trace_path) > packet.path_len and
|
|
trace_path[packet.path_len] == self.local_hash and
|
|
self.repeater_handler and not self.repeater_handler.is_duplicate(packet)):
|
|
|
|
if self.repeater_handler and hasattr(self.repeater_handler, 'recent_packets'):
|
|
packet_hash = packet.calculate_packet_hash().hex()[:16]
|
|
for record in reversed(self.repeater_handler.recent_packets):
|
|
if record.get("packet_hash") == packet_hash:
|
|
record["transmitted"] = True
|
|
record["drop_reason"] = "trace_forwarded"
|
|
break
|
|
|
|
current_snr = packet.get_snr()
|
|
|
|
|
|
snr_scaled = int(current_snr * 4)
|
|
|
|
if snr_scaled > 127:
|
|
snr_scaled = 127
|
|
elif snr_scaled < -128:
|
|
snr_scaled = -128
|
|
|
|
snr_byte = snr_scaled if snr_scaled >= 0 else (256 + snr_scaled)
|
|
|
|
while len(packet.path) <= packet.path_len:
|
|
packet.path.append(0)
|
|
|
|
packet.path[packet.path_len] = snr_byte
|
|
packet.path_len += 1
|
|
|
|
logger.info(f"[TraceHandler] Forwarding trace, stored SNR {current_snr:.1f}dB at position {packet.path_len-1}")
|
|
|
|
# Mark as seen and forward directly (bypass normal routing, no ACK required)
|
|
self.repeater_handler.mark_seen(packet)
|
|
if self.dispatcher:
|
|
await self.dispatcher.send_packet(packet, wait_for_ack=False)
|
|
else:
|
|
# Show why we didn't forward
|
|
if packet.path_len >= trace_path_len:
|
|
logger.info(f"[TraceHandler] Trace completed (reached end of path)")
|
|
elif len(trace_path) <= packet.path_len:
|
|
logger.info(f"[TraceHandler] Path index out of bounds")
|
|
elif trace_path[packet.path_len] != self.local_hash:
|
|
expected_hash = trace_path[packet.path_len] if packet.path_len < len(trace_path) else None
|
|
logger.info(f"[TraceHandler] Not our turn (next hop: 0x{expected_hash:02x})")
|
|
elif self.repeater_handler and self.repeater_handler.is_duplicate(packet):
|
|
logger.info(f"[TraceHandler] Duplicate packet, ignoring")
|
|
|
|
except Exception as e:
|
|
logger.error(f"[TraceHandler] Error processing trace packet: {e}")
|
|
|
|
|
|
|
|
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, wait_for_ack=False)
|
|
|
|
# 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")
|
|
|
|
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/pymc_repeater/config.yaml'),
|
|
)
|
|
|
|
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)
|
|
config_path = args.config if args.config else '/etc/pymc_repeater/config.yaml'
|
|
|
|
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)
|
|
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()
|