diff --git a/pyproject.toml b/pyproject.toml index b88f506..8044bd6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", diff --git a/repeater/__init__.py b/repeater/__init__.py index 5c4105c..7863915 100644 --- a/repeater/__init__.py +++ b/repeater/__init__.py @@ -1 +1 @@ -__version__ = "1.0.1" +__version__ = "1.0.2" diff --git a/repeater/engine.py b/repeater/engine.py index d6252dc..163c342 100644 --- a/repeater/engine.py +++ b/repeater/engine.py @@ -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, diff --git a/repeater/http_server.py b/repeater/http_server.py index ee519c4..9615a2c 100644 --- a/repeater/http_server.py +++ b/repeater/http_server.py @@ -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): diff --git a/repeater/main.py b/repeater/main.py index 8e88715..e2a9b12 100644 --- a/repeater/main.py +++ b/repeater/main.py @@ -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: diff --git a/repeater/templates/cad-calibration.html b/repeater/templates/cad-calibration.html new file mode 100644 index 0000000..d5e1fa9 --- /dev/null +++ b/repeater/templates/cad-calibration.html @@ -0,0 +1,1216 @@ + + + + pyMC Repeater - CAD Calibration + + + + + + + + + +
+ + + + +
+
+

CAD Calibration Tool

+

Real-time Channel Activity Detection calibration

+
+ + +
+
+ + +
+
+ + +
+
Ready to start calibration
+ + +
+
+
+
+
0 / 0 tests completed
+
+
+ + +
+
+
0
+
Tests Completed
+
+
+
0%
+
Best Detection Rate
+
+
+
0%
+
Average Rate
+
+
+
0s
+
Elapsed Time
+
+
+ + +
+
+
+ + + +
+
+ + + + \ No newline at end of file diff --git a/repeater/templates/configuration.html b/repeater/templates/configuration.html index 79e053d..5adb4dd 100644 --- a/repeater/templates/configuration.html +++ b/repeater/templates/configuration.html @@ -22,6 +22,15 @@ Configuration is read-only. To modify settings, edit the config file and restart the daemon. + +
+ CAD Calibration Tool Available +

+ Optimize your Channel Activity Detection settings. + Launch CAD Calibration Tool → +

+
+

Radio Settings

diff --git a/repeater/templates/dashboard.html b/repeater/templates/dashboard.html index 50946f9..da558db 100644 --- a/repeater/templates/dashboard.html +++ b/repeater/templates/dashboard.html @@ -229,6 +229,61 @@ ${''.repeat(4)} ${snr.toFixed(1)} dB
`; + } + + // 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 = `
`; + + // Show received packet SNR first + pathSnrHtml += `
${getSignalBars(pkt.snr)}
`; + + // Show path SNRs if available + if (pkt.path_snr_details.length > 0) { + pathSnrHtml += `
`; + pathSnrHtml += `
Path (${pkt.path_snr_details.length} hops):
`; + + // 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 += `
+ ${index + 1}. + ${pathSnr.hash} + ${pathSnr.snr_db.toFixed(1)}dB +
`; + }); + + // Show indicator if there are more hops + if (hasMoreHops) { + const remainingCount = pkt.path_snr_details.length - maxDisplayHops; + pathSnrHtml += `
+ +${remainingCount} more hop${remainingCount > 1 ? 's' : ''} +
`; + } + + pathSnrHtml += `
`; + } + + pathSnrHtml += `
`; + 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 @@ ${pkt.length}B ${pathHashesHtml} ${pkt.rssi} - ${getSignalBars(pkt.snr)} + ${getTraceSnrDisplay(pkt, localHash)} ${pkt.score.toFixed(2)} ${pkt.tx_delay_ms.toFixed(0)}ms ${statusHtml} @@ -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 @@ ${dupe.length}B ${dupePathHashesHtml} ${dupe.rssi} - ${getSignalBars(dupe.snr)} + ${getTraceSnrDisplay(dupe, localHash)} ${dupe.score.toFixed(2)} ${dupe.tx_delay_ms.toFixed(0)}ms ${dupeStatusHtml} @@ -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; diff --git a/repeater/templates/nav.html b/repeater/templates/nav.html index 9eb248b..5417728 100644 --- a/repeater/templates/nav.html +++ b/repeater/templates/nav.html @@ -103,7 +103,12 @@
Online
-
v1.0.0
+
+ + +
+
R:v1.0.1
+
C:v1.0.2
@@ -142,19 +147,27 @@
Last updated: {{ last_updated }} - - - +
+ + + + + + + + + +