Merge pull request #7 from rightup/dev

feat: add Repeater logging, SNR dashboard, CAD calibration and LBT features
This commit is contained in:
Lloyd
2025-11-02 15:21:48 -08:00
committed by GitHub
9 changed files with 2060 additions and 73 deletions

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "pymc_repeater"
version = "1.0.1"
version = "1.0.2"
authors = [
{name = "Lloyd", email = "lloyd@rightup.co.uk"},
]
@@ -29,14 +29,16 @@ classifiers = [
keywords = ["mesh", "networking", "lora", "repeater", "daemon", "iot"]
dependencies = [
"pymc_core[hardware]>=1.0.2",
"pymc_core[hardware]>=1.0.3",
"pyyaml>=6.0.0",
"cherrypy>=18.0.0",
]
[project.optional-dependencies]
dev = [
"pytest>=7.4.0",

View File

@@ -1 +1 @@
__version__ = "1.0.1"
__version__ = "1.0.2"

View File

@@ -90,7 +90,7 @@ class RepeaterHandler(BaseHandler):
monitor_mode = mode == "monitor"
logger.debug(
f"RX packet: header=0x{packet.header: 02x}, payload_len={len(packet.payload or b'')}, "
f"RX packet: header=0x{packet.header:02x}, payload_len={len(packet.payload or b'')}, "
f"path_len={len(packet.path) if packet.path else 0}, "
f"rssi={metadata.get('rssi', 'N/A')}, snr={metadata.get('snr', 'N/A')}, mode={mode}"
)
@@ -123,8 +123,8 @@ class RepeaterHandler(BaseHandler):
if not can_tx:
logger.warning(
f"Duty-cycle limit exceeded. Airtime={airtime_ms: .1f}ms, "
f"wait={wait_time: .1f}s before retry"
f"Duty-cycle limit exceeded. Airtime={airtime_ms:.1f}ms, "
f"wait={wait_time:.1f}s before retry"
)
self.dropped_count += 1
drop_reason = "Duty cycle limit"
@@ -152,7 +152,7 @@ class RepeaterHandler(BaseHandler):
payload_type = header_info["payload_type"]
route_type = header_info["route_type"]
logger.debug(
f"Packet header=0x{packet.header: 02x}, type={payload_type}, route={route_type}"
f"Packet header=0x{packet.header:02x}, type={payload_type}, route={route_type}"
)
# Check if this is a duplicate
@@ -173,7 +173,7 @@ class RepeaterHandler(BaseHandler):
)
if display_path and len(display_path) > 0:
# Format path as array of uppercase hex bytes
path_bytes = [f"{b: 02X}" for b in display_path[:8]] # First 8 bytes max
path_bytes = [f"{b:02X}" for b in display_path[:8]] # First 8 bytes max
if len(display_path) > 8:
path_bytes.append("...")
path_hash = "[" + ", ".join(path_bytes) + "]"
@@ -184,13 +184,13 @@ class RepeaterHandler(BaseHandler):
# Payload types with dest_hash and src_hash as first 2 bytes
if payload_type in [0x00, 0x01, 0x02, 0x08]:
if hasattr(packet, "payload") and packet.payload and len(packet.payload) >= 2:
dst_hash = f"{packet.payload[0]: 02X}"
src_hash = f"{packet.payload[1]: 02X}"
dst_hash = f"{packet.payload[0]:02X}"
src_hash = f"{packet.payload[1]:02X}"
# ADVERT packets have source identifier as first byte
elif payload_type == PAYLOAD_TYPE_ADVERT:
if hasattr(packet, "payload") and packet.payload and len(packet.payload) >= 1:
src_hash = f"{packet.payload[0]: 02X}"
src_hash = f"{packet.payload[0]:02X}"
# Record packet for charts
packet_record = {
@@ -211,9 +211,9 @@ class RepeaterHandler(BaseHandler):
"path_hash": path_hash,
"src_hash": src_hash,
"dst_hash": dst_hash,
"original_path": ([f"{b: 02X}" for b in original_path] if original_path else None),
"original_path": ([f"{b:02X}" for b in original_path] if original_path else None),
"forwarded_path": (
[f"{b: 02X}" for b in forwarded_path] if forwarded_path is not None else None
[f"{b:02X}" for b in forwarded_path] if forwarded_path is not None else None
),
}
@@ -239,6 +239,18 @@ class RepeaterHandler(BaseHandler):
if len(self.recent_packets) > self.max_recent_packets:
self.recent_packets.pop(0)
def log_trace_record(self, packet_record: dict) -> None:
self.recent_packets.append(packet_record)
self.rx_count += 1
if packet_record.get("transmitted", False):
self.forwarded_count += 1
else:
self.dropped_count += 1
if len(self.recent_packets) > self.max_recent_packets:
self.recent_packets.pop(0)
def cleanup_cache(self):
now = time.time()
@@ -409,7 +421,7 @@ class RepeaterHandler(BaseHandler):
next_hop = packet.path[0]
if next_hop != self.local_hash:
logger.debug(
f"Direct: not our hop (next={next_hop: 02X}, local={self.local_hash: 02X})"
f"Direct: not our hop (next={next_hop:02X}, local={self.local_hash:02X})"
)
return None
@@ -417,8 +429,8 @@ class RepeaterHandler(BaseHandler):
packet.path = bytearray(packet.path[1:])
packet.path_len = len(packet.path)
old_path = [f"{b: 02X}" for b in original_path]
new_path = [f"{b: 02X}" for b in packet.path]
old_path = [f"{b:02X}" for b in original_path]
new_path = [f"{b:02X}" for b in packet.path]
logger.debug(f"Direct: forwarding, path {old_path} -> {new_path}")
return packet
@@ -483,8 +495,8 @@ class RepeaterHandler(BaseHandler):
score_multiplier = max(0.2, 1.0 - score)
delay_s = delay_s * score_multiplier
logger.debug(
f"Congestion detected (delay >= 50ms), score={score: .2f}, "
f"delay multiplier={score_multiplier: .2f}"
f"Congestion detected (delay >= 50ms), score={score:.2f}, "
f"delay multiplier={score_multiplier:.2f}"
)
# Cap at 5 seconds maximum
@@ -492,7 +504,7 @@ class RepeaterHandler(BaseHandler):
logger.debug(
f"Route={'FLOOD' if route_type == ROUTE_TYPE_FLOOD else 'DIRECT'}, "
f"len={packet_len}B, airtime={airtime_ms: .1f}ms, delay={delay_s: .3f}s"
f"len={packet_len}B, airtime={airtime_ms:.1f}ms, delay={delay_s:.3f}s"
)
return delay_s
@@ -530,7 +542,7 @@ class RepeaterHandler(BaseHandler):
self.airtime_mgr.record_tx(airtime_ms)
packet_size = len(fwd_pkt.payload)
logger.info(
f"Retransmitted packet ({packet_size} bytes, {airtime_ms: .1f}ms airtime)"
f"Retransmitted packet ({packet_size} bytes, {airtime_ms:.1f}ms airtime)"
)
except Exception as e:
logger.error(f"Retransmit failed: {e}")
@@ -549,8 +561,8 @@ class RepeaterHandler(BaseHandler):
# Check if interval has elapsed
if time_since_last_advert >= interval_seconds:
logger.info(
f"Periodic advert interval elapsed ({time_since_last_advert: .0f}s >= "
f"{interval_seconds: .0f}s). Sending advert..."
f"Periodic advert interval elapsed ({time_since_last_advert:.0f}s >= "
f"{interval_seconds:.0f}s). Sending advert..."
)
try:
# Call the send_advert function
@@ -564,10 +576,6 @@ class RepeaterHandler(BaseHandler):
logger.error(f"Error sending periodic advert: {e}", exc_info=True)
def get_noise_floor(self) -> Optional[float]:
"""
Get the current noise floor (instantaneous RSSI) from the radio in dBm.
Returns None if radio is not available or reading fails.
"""
try:
radio = self.dispatcher.radio if self.dispatcher else None
if radio and hasattr(radio, 'get_noise_floor'):
@@ -599,7 +607,7 @@ class RepeaterHandler(BaseHandler):
noise_floor_dbm = self.get_noise_floor()
stats = {
"local_hash": f"0x{self.local_hash: 02x}",
"local_hash": f"0x{self.local_hash:02x}",
"duplicate_cache_size": len(self.seen_packets),
"cache_ttl": self.cache_ttl,
"rx_count": self.rx_count,

View File

@@ -1,9 +1,13 @@
import asyncio
import json
import logging
import os
import re
import threading
import time
from collections import deque
from datetime import datetime
from typing import Callable, Optional
from typing import Callable, Optional, Dict, Any
import cherrypy
from pymc_core.protocol.utils import PAYLOAD_TYPES, ROUTE_TYPES
@@ -40,6 +44,228 @@ class LogBuffer(logging.Handler):
_log_buffer = LogBuffer(max_lines=100)
class CADCalibrationEngine:
"""Real-time CAD calibration engine"""
def __init__(self, daemon_instance=None, event_loop=None):
self.daemon_instance = daemon_instance
self.event_loop = event_loop
self.running = False
self.results = {}
self.current_test = None
self.progress = {"current": 0, "total": 0}
self.clients = set() # SSE clients
self.calibration_thread = None
def get_test_ranges(self, spreading_factor: int):
"""Get CAD test ranges based on spreading factor - comprehensive coverage"""
sf_ranges = {
7: (range(17, 26, 1), range(7, 15, 1)), # Full range coverage
8: (range(17, 26, 1), range(7, 15, 1)), # Full range coverage
9: (range(19, 28, 1), range(8, 16, 1)), # Full range coverage
10: (range(21, 30, 1), range(9, 17, 1)), # Full range coverage
11: (range(23, 32, 1), range(10, 18, 1)), # Full range coverage
12: (range(25, 34, 1), range(11, 19, 1)), # Full range coverage
}
return sf_ranges.get(spreading_factor, sf_ranges[8])
async def test_cad_config(self, radio, det_peak: int, det_min: int, samples: int = 8) -> Dict[str, Any]:
"""Test a single CAD configuration with multiple samples"""
detections = 0
for _ in range(samples):
try:
result = await radio.perform_cad(det_peak=det_peak, det_min=det_min, timeout=0.3)
if result:
detections += 1
except Exception:
pass
await asyncio.sleep(0.01) # Reduced sleep time
return {
'det_peak': det_peak,
'det_min': det_min,
'samples': samples,
'detections': detections,
'detection_rate': (detections / samples) * 100,
}
def broadcast_to_clients(self, data):
"""Send data to all connected SSE clients"""
# Store the message for clients to pick up
self.last_message = data
# Also store in a queue for clients to consume
if not hasattr(self, 'message_queue'):
self.message_queue = []
self.message_queue.append(data)
def calibration_worker(self, samples: int, delay_ms: int):
"""Worker thread for calibration process"""
try:
# Get radio from daemon instance
if not self.daemon_instance:
self.broadcast_to_clients({"type": "error", "message": "No daemon instance available"})
return
radio = getattr(self.daemon_instance, 'radio', None)
if not radio:
self.broadcast_to_clients({"type": "error", "message": "Radio instance not available"})
return
if not hasattr(radio, 'perform_cad'):
self.broadcast_to_clients({"type": "error", "message": "Radio does not support CAD"})
return
# Get spreading factor from daemon instance
config = getattr(self.daemon_instance, 'config', {})
radio_config = config.get("radio", {})
sf = radio_config.get("spreading_factor", 8)
# Get test ranges
peak_range, min_range = self.get_test_ranges(sf)
total_tests = len(peak_range) * len(min_range)
self.progress = {"current": 0, "total": total_tests}
self.broadcast_to_clients({
"type": "status",
"message": f"Starting calibration: SF{sf}, {total_tests} tests",
"test_ranges": {
"peak_min": min(peak_range),
"peak_max": max(peak_range),
"min_min": min(min_range),
"min_max": max(min_range),
"spreading_factor": sf,
"total_tests": total_tests
}
})
current = 0
import random
peak_list = list(peak_range)
min_list = list(min_range)
# Create all test combinations
test_combinations = []
for det_peak in peak_list:
for det_min in min_list:
test_combinations.append((det_peak, det_min))
# Sort by distance from center for center-out pattern
peak_center = (max(peak_list) + min(peak_list)) / 2
min_center = (max(min_list) + min(min_list)) / 2
def distance_from_center(combo):
peak, min_val = combo
return ((peak - peak_center) ** 2 + (min_val - min_center) ** 2) ** 0.5
# Sort by distance from center
test_combinations.sort(key=distance_from_center)
band_size = max(1, len(test_combinations) // 8) # Create 8 bands
randomized_combinations = []
for i in range(0, len(test_combinations), band_size):
band = test_combinations[i:i + band_size]
random.shuffle(band) # Randomize within each band
randomized_combinations.extend(band)
# Run calibration in event loop with center-out randomized pattern
if self.event_loop:
for det_peak, det_min in randomized_combinations:
if not self.running:
break
current += 1
self.progress["current"] = current
# Update progress
self.broadcast_to_clients({
"type": "progress",
"current": current,
"total": total_tests,
"peak": det_peak,
"min": det_min
})
# Run the test
future = asyncio.run_coroutine_threadsafe(
self.test_cad_config(radio, det_peak, det_min, samples),
self.event_loop
)
try:
result = future.result(timeout=30) # 30 second timeout per test
# Store result
key = f"{det_peak}-{det_min}"
self.results[key] = result
# Send result to clients
self.broadcast_to_clients({
"type": "result",
**result
})
except Exception as e:
logger.error(f"CAD test failed for peak={det_peak}, min={det_min}: {e}")
# Delay between tests
if self.running and delay_ms > 0:
time.sleep(delay_ms / 1000.0)
if self.running:
# Find best result
best_result = None
if self.results:
best_result = max(self.results.values(), key=lambda x: x['detection_rate'])
self.broadcast_to_clients({
"type": "completed",
"message": "Calibration completed",
"results": {"best": best_result} if best_result else None
})
else:
self.broadcast_to_clients({"type": "status", "message": "Calibration stopped"})
except Exception as e:
logger.error(f"Calibration worker error: {e}")
self.broadcast_to_clients({"type": "error", "message": str(e)})
finally:
self.running = False
def start_calibration(self, samples: int = 8, delay_ms: int = 100):
"""Start calibration process"""
if self.running:
return False
self.running = True
self.results.clear()
self.progress = {"current": 0, "total": 0}
self.clear_message_queue() # Clear any old messages
# Start calibration in separate thread
self.calibration_thread = threading.Thread(
target=self.calibration_worker,
args=(samples, delay_ms)
)
self.calibration_thread.daemon = True
self.calibration_thread.start()
return True
def stop_calibration(self):
"""Stop calibration process"""
self.running = False
if self.calibration_thread:
self.calibration_thread.join(timeout=2)
def clear_message_queue(self):
"""Clear the message queue when starting a new calibration"""
if hasattr(self, 'message_queue'):
self.message_queue.clear()
class APIEndpoints:
def __init__(
@@ -48,12 +274,19 @@ class APIEndpoints:
send_advert_func: Optional[Callable] = None,
config: Optional[dict] = None,
event_loop=None,
daemon_instance=None,
config_path=None,
):
self.stats_getter = stats_getter
self.send_advert_func = send_advert_func
self.config = config or {}
self.event_loop = event_loop # Store reference to main event loop
self.event_loop = event_loop
self.daemon_instance = daemon_instance
self._config_path = config_path or '/etc/pymc_repeater/config.yaml'
# Initialize CAD calibration engine
self.cad_calibration = CADCalibrationEngine(daemon_instance, event_loop)
@cherrypy.expose
@cherrypy.tools.json_out()
@@ -62,6 +295,13 @@ class APIEndpoints:
try:
stats = self.stats_getter() if self.stats_getter else {}
stats["version"] = __version__
# Add pyMC_Core version
try:
import pymc_core
stats["core_version"] = pymc_core.__version__
except ImportError:
stats["core_version"] = "unknown"
return stats
except Exception as e:
@@ -167,6 +407,181 @@ class APIEndpoints:
logger.error(f"Error fetching logs: {e}")
return {"error": str(e), "logs": []}
# CAD Calibration endpoints
@cherrypy.expose
@cherrypy.tools.json_out()
@cherrypy.tools.json_in()
def cad_calibration_start(self):
"""Start CAD calibration"""
if cherrypy.request.method != "POST":
return {"success": False, "error": "Method not allowed"}
try:
data = cherrypy.request.json or {}
samples = data.get("samples", 8)
delay = data.get("delay", 100)
if self.cad_calibration.start_calibration(samples, delay):
return {"success": True, "message": "Calibration started"}
else:
return {"success": False, "error": "Calibration already running"}
except Exception as e:
logger.error(f"Error starting CAD calibration: {e}")
return {"success": False, "error": str(e)}
@cherrypy.expose
@cherrypy.tools.json_out()
def cad_calibration_stop(self):
"""Stop CAD calibration"""
if cherrypy.request.method != "POST":
return {"success": False, "error": "Method not allowed"}
try:
self.cad_calibration.stop_calibration()
return {"success": True, "message": "Calibration stopped"}
except Exception as e:
logger.error(f"Error stopping CAD calibration: {e}")
return {"success": False, "error": str(e)}
@cherrypy.expose
@cherrypy.tools.json_out()
@cherrypy.tools.json_in()
def save_cad_settings(self):
"""Save CAD calibration settings to config"""
if cherrypy.request.method != "POST":
return {"success": False, "error": "Method not allowed"}
try:
data = cherrypy.request.json or {}
peak = data.get("peak")
min_val = data.get("min_val")
detection_rate = data.get("detection_rate", 0)
if peak is None or min_val is None:
return {"success": False, "error": "Missing peak or min_val parameters"}
# Update the radio immediately if available
if self.daemon_instance and hasattr(self.daemon_instance, 'radio') and self.daemon_instance.radio:
if hasattr(self.daemon_instance.radio, 'set_custom_cad_thresholds'):
self.daemon_instance.radio.set_custom_cad_thresholds(peak=peak, min_val=min_val)
logger.info(f"Applied CAD settings to radio: peak={peak}, min={min_val}")
# Update the in-memory config
if "radio" not in self.config:
self.config["radio"] = {}
if "cad" not in self.config["radio"]:
self.config["radio"]["cad"] = {}
self.config["radio"]["cad"]["peak_threshold"] = peak
self.config["radio"]["cad"]["min_threshold"] = min_val
# Save to config file
config_path = getattr(self, '_config_path', '/etc/pymc_repeater/config.yaml')
self._save_config_to_file(config_path)
logger.info(f"Saved CAD settings to config: peak={peak}, min={min_val}, rate={detection_rate:.1f}%")
return {
"success": True,
"message": f"CAD settings saved: peak={peak}, min={min_val}",
"settings": {"peak": peak, "min_val": min_val, "detection_rate": detection_rate}
}
except Exception as e:
logger.error(f"Error saving CAD settings: {e}")
return {"success": False, "error": str(e)}
def _save_config_to_file(self, config_path):
"""Save current config to YAML file"""
try:
import yaml
import os
# Ensure directory exists
os.makedirs(os.path.dirname(config_path), exist_ok=True)
# Write config to file
with open(config_path, 'w') as f:
yaml.dump(self.config, f, default_flow_style=False, indent=2)
logger.info(f"Configuration saved to {config_path}")
except Exception as e:
logger.error(f"Failed to save config to {config_path}: {e}")
raise
@cherrypy.expose
def cad_calibration_stream(self):
"""Server-Sent Events stream for real-time updates"""
cherrypy.response.headers['Content-Type'] = 'text/event-stream'
cherrypy.response.headers['Cache-Control'] = 'no-cache'
cherrypy.response.headers['Connection'] = 'keep-alive'
cherrypy.response.headers['Access-Control-Allow-Origin'] = '*'
def generate():
if not hasattr(self.cad_calibration, 'message_queue'):
self.cad_calibration.message_queue = []
try:
yield f"data: {json.dumps({'type': 'connected', 'message': 'Connected to CAD calibration stream'})}\n\n"
if self.cad_calibration.running:
config = getattr(self.cad_calibration.daemon_instance, 'config', {})
radio_config = config.get("radio", {})
sf = radio_config.get("spreading_factor", 8)
peak_range, min_range = self.cad_calibration.get_test_ranges(sf)
total_tests = len(peak_range) * len(min_range)
status_message = {
"type": "status",
"message": f"Calibration in progress: SF{sf}, {total_tests} tests",
"test_ranges": {
"peak_min": min(peak_range),
"peak_max": max(peak_range),
"min_min": min(min_range),
"min_max": max(min_range),
"spreading_factor": sf,
"total_tests": total_tests
}
}
yield f"data: {json.dumps(status_message)}\n\n"
last_message_index = len(self.cad_calibration.message_queue)
while True:
current_queue_length = len(self.cad_calibration.message_queue)
if current_queue_length > last_message_index:
for i in range(last_message_index, current_queue_length):
message = self.cad_calibration.message_queue[i]
yield f"data: {json.dumps(message)}\n\n"
last_message_index = current_queue_length
else:
yield f"data: {json.dumps({'type': 'keepalive'})}\n\n"
time.sleep(0.5)
except Exception as e:
logger.error(f"SSE stream error: {e}")
finally:
pass
return generate()
cad_calibration_stream._cp_config = {'response.stream': True}
class StatsApp:
@@ -179,6 +594,8 @@ class StatsApp:
send_advert_func: Optional[Callable] = None,
config: Optional[dict] = None,
event_loop=None,
daemon_instance=None,
config_path=None,
):
self.stats_getter = stats_getter
@@ -189,7 +606,7 @@ class StatsApp:
self.config = config or {}
# Create nested API object for routing
self.api = APIEndpoints(stats_getter, send_advert_func, self.config, event_loop)
self.api = APIEndpoints(stats_getter, send_advert_func, self.config, event_loop, daemon_instance, config_path)
# Load template on init
if template_dir:
@@ -231,6 +648,11 @@ class StatsApp:
"""Serve help documentation."""
return self._serve_template("help.html")
@cherrypy.expose
def cad_calibration(self):
"""Serve CAD calibration page."""
return self._serve_template("cad-calibration.html")
def _serve_template(self, template_name: str):
"""Serve HTML template with stats."""
if not self.template_dir:
@@ -270,6 +692,7 @@ class StatsApp:
"neighbors.html": "neighbors",
"statistics.html": "statistics",
"configuration.html": "configuration",
"cad-calibration.html": "cad-calibration",
"logs.html": "logs",
"help.html": "help",
}
@@ -400,12 +823,14 @@ class HTTPStatsServer:
send_advert_func: Optional[Callable] = None,
config: Optional[dict] = None,
event_loop=None,
daemon_instance=None,
config_path=None,
):
self.host = host
self.port = port
self.app = StatsApp(
stats_getter, template_dir, node_name, pub_key, send_advert_func, config, event_loop
stats_getter, template_dir, node_name, pub_key, send_advert_func, config, event_loop, daemon_instance, config_path
)
def start(self):

View File

@@ -6,6 +6,8 @@ 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
from pymc_core.node.handlers.trace import TraceHandler
from pymc_core.protocol.constants import MAX_PATH_SIZE, ROUTE_TYPE_DIRECT
logger = logging.getLogger("RepeaterDaemon")
@@ -21,15 +23,15 @@ class RepeaterDaemon:
self.local_hash = None
self.local_identity = None
self.http_server = None
self.trace_handler = 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)
@@ -42,12 +44,37 @@ class RepeaterDaemon:
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
# Create dispatcher from pymc_core
try:
from pymc_core import LocalIdentity
from pymc_core.node.dispatcher import Dispatcher
@@ -64,14 +91,14 @@ class RepeaterDaemon:
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(
@@ -81,6 +108,16 @@ class RepeaterDaemon:
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
@@ -96,30 +133,151 @@ class RepeaterDaemon:
}
await self.repeater_handler(packet, metadata)
def _get_keypair(self):
"""Create a PyNaCl SigningKey for map API."""
async def _trace_callback(self, packet):
try:
from nacl.signing import SigningKey
# Only process direct route trace packets
if packet.get_route_type() != ROUTE_TYPE_DIRECT or packet.path_len >= MAX_PATH_SIZE:
return
if not self.local_identity:
return None
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(),
"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,
}
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
# 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)
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:
seed_bytes = identity_key
# 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")
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
logger.error(f"[TraceHandler] Error processing trace packet: {e}")
def get_stats(self) -> dict:
@@ -165,7 +323,7 @@ class RepeaterDaemon:
)
# Send via dispatcher
await self.dispatcher.send_packet(packet)
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:
@@ -202,7 +360,6 @@ class RepeaterDaemon:
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(
@@ -213,8 +370,10 @@ class RepeaterDaemon:
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
config=self.config,
event_loop=current_loop,
daemon_instance=self,
config_path=getattr(self, 'config_path', '/etc/pymc_repeater/config.yaml'),
)
try:
@@ -250,12 +409,14 @@ def main():
# 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:

File diff suppressed because it is too large Load Diff

View File

@@ -22,6 +22,15 @@
Configuration is read-only. To modify settings, edit the config file and restart the daemon.
</div>
<!-- CAD Calibration Tool -->
<div class="info-box" style="background: var(--accent-color); color: white; border: none;">
<strong>CAD Calibration Tool Available</strong>
<p style="margin: 8px 0 0 0;">
Optimize your Channel Activity Detection settings.
<a href="/cad-calibration" style="color: white; text-decoration: underline;">Launch CAD Calibration Tool →</a>
</p>
</div>
<!-- Radio Configuration -->
<h2>Radio Settings</h2>
<div class="config-section">

View File

@@ -229,6 +229,61 @@
<span class="signal-bars ${className}" title="Signal: ${className.replace('signal-', '')}">${'<span class="signal-bar"></span>'.repeat(4)}</span>
<span class="snr-value">${snr.toFixed(1)} dB</span>
</div>`;
}
// Helper function to display SNR for trace packets with path information
function getTraceSnrDisplay(pkt, localHash) {
if (!pkt.is_trace || !pkt.path_snr_details || pkt.path_snr_details.length === 0) {
// Regular packet or no path SNR data
return getSignalBars(pkt.snr);
}
// Build trace path SNR display
let pathSnrHtml = `<div class="trace-snr-container">`;
// Show received packet SNR first
pathSnrHtml += `<div class="rx-snr">${getSignalBars(pkt.snr)}</div>`;
// Show path SNRs if available
if (pkt.path_snr_details.length > 0) {
pathSnrHtml += `<div class="path-snrs">`;
pathSnrHtml += `<div class="path-snr-label">Path (${pkt.path_snr_details.length} hops):</div>`;
// Handle many hops - show first few and indicate if there are more
const maxDisplayHops = 4;
const hopsToShow = pkt.path_snr_details.slice(0, maxDisplayHops);
const hasMoreHops = pkt.path_snr_details.length > maxDisplayHops;
hopsToShow.forEach((pathSnr, index) => {
const isMyHash = localHash && pathSnr.hash === localHash;
const hashClass = isMyHash ? 'my-hash' : 'path-hash';
// Get signal quality class for color coding
let snrClass = 'snr-poor';
if (pathSnr.snr_db >= 10) snrClass = 'snr-excellent';
else if (pathSnr.snr_db >= 5) snrClass = 'snr-good';
else if (pathSnr.snr_db >= 0) snrClass = 'snr-fair';
pathSnrHtml += `<div class="path-snr-item">
<span class="hop-index">${index + 1}.</span>
<span class="${hashClass}">${pathSnr.hash}</span>
<span class="snr-value ${snrClass}">${pathSnr.snr_db.toFixed(1)}dB</span>
</div>`;
});
// Show indicator if there are more hops
if (hasMoreHops) {
const remainingCount = pkt.path_snr_details.length - maxDisplayHops;
pathSnrHtml += `<div class="path-snr-item">
<span class="more-hops">+${remainingCount} more hop${remainingCount > 1 ? 's' : ''}</span>
</div>`;
}
pathSnrHtml += `</div>`;
}
pathSnrHtml += `</div>`;
return pathSnrHtml;
} function updatePacketTable(packets, localHash) {
const tbody = document.getElementById('packet-table');
@@ -244,7 +299,13 @@
}
tbody.innerHTML = packets.slice(-20).map(pkt => {
const time = new Date(pkt.timestamp * 1000).toLocaleTimeString();
const time = new Date(pkt.timestamp * 1000).toLocaleTimeString('en-US', {
hour12: false,
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
fractionalSecondDigits: 3
});
// Match pyMC_core PAYLOAD_TYPES exactly (from constants.py)
const typeNames = {
0: 'REQ',
@@ -327,7 +388,7 @@
<td data-label="Len">${pkt.length}B</td>
<td data-label="Path/Hashes">${pathHashesHtml}</td>
<td data-label="RSSI">${pkt.rssi}</td>
<td data-label="SNR">${getSignalBars(pkt.snr)}</td>
<td data-label="SNR">${getTraceSnrDisplay(pkt, localHash)}</td>
<td data-label="Score"><span class="score">${pkt.score.toFixed(2)}</span></td>
<td data-label="TX Delay">${pkt.tx_delay_ms.toFixed(0)}ms</td>
<td data-label="Status">${statusHtml}</td>
@@ -337,7 +398,13 @@
// Add duplicate rows (always visible)
if (hasDuplicates) {
mainRow += pkt.duplicates.map(dupe => {
const dupeTime = new Date(dupe.timestamp * 1000).toLocaleTimeString();
const dupeTime = new Date(dupe.timestamp * 1000).toLocaleTimeString('en-US', {
hour12: false,
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
fractionalSecondDigits: 3
});
const dupeRoute = routeNames[dupe.route] || `UNKNOWN_${dupe.route}`;
// Format duplicate path/hashes - match main row format
@@ -375,7 +442,7 @@
<td data-label="Len">${dupe.length}B</td>
<td data-label="Path/Hashes">${dupePathHashesHtml}</td>
<td data-label="RSSI">${dupe.rssi}</td>
<td data-label="SNR">${getSignalBars(dupe.snr)}</td>
<td data-label="SNR">${getTraceSnrDisplay(dupe, localHash)}</td>
<td data-label="Score"><span class="score">${dupe.score.toFixed(2)}</span></td>
<td data-label="TX Delay">${dupe.tx_delay_ms.toFixed(0)}ms</td>
<td data-label="Status">${dupeStatusHtml}</td>
@@ -589,6 +656,85 @@
font-size: 0.8em;
color: #999;
white-space: nowrap;
}
/* Trace packet SNR display */
.trace-snr-container {
display: flex;
flex-direction: column;
gap: 6px;
align-items: center;
min-width: 120px;
}
.rx-snr {
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
padding-bottom: 4px;
}
.path-snrs {
display: flex;
flex-direction: column;
gap: 2px;
font-size: 0.85em;
width: 100%;
}
.path-snr-label {
font-size: 0.75em;
color: #888;
text-align: center;
margin-bottom: 2px;
font-weight: 500;
}
.path-snr-item {
display: flex;
align-items: center;
gap: 4px;
white-space: nowrap;
justify-content: space-between;
}
.hop-index {
font-size: 0.7em;
color: #666;
min-width: 16px;
text-align: right;
}
.path-snr-item .path-hash {
font-family: 'Courier New', monospace;
font-size: 0.75em;
color: #dcdcaa;
background: rgba(220, 220, 170, 0.1);
padding: 1px 3px;
border-radius: 3px;
min-width: 24px;
text-align: center;
}
.path-snr-item .my-hash {
font-family: 'Courier New', monospace;
font-size: 0.75em;
background: rgba(86, 156, 214, 0.2);
color: #569cd6;
font-weight: 700;
padding: 1px 3px;
border-radius: 3px;
min-width: 24px;
text-align: center;
}
.path-snr-item .snr-value {
font-size: 0.75em;
font-weight: 500;
min-width: 48px;
text-align: right;
}
/* SNR quality color coding */
.snr-excellent { color: #4ade80; }
.snr-good { color: #4ec9b0; }
.snr-fair { color: #fbbf24; }
.snr-poor { color: #f48771; }
.more-hops {
font-size: 0.7em;
color: #888;
font-style: italic;
text-align: center;
width: 100%;
} /* Path/Hashes column layout */
.path-info {
display: flex;

File diff suppressed because one or more lines are too long