From ed678af4ca01e7364005768fc98285a0350fb891 Mon Sep 17 00:00:00 2001 From: Lloyd Date: Wed, 29 Oct 2025 00:08:06 +0000 Subject: [PATCH 01/12] Add TraceHandler for network diagnostics --- repeater/main.py | 108 ++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 88 insertions(+), 20 deletions(-) diff --git a/repeater/main.py b/repeater/main.py index 8e88715..1835ec5 100644 --- a/repeater/main.py +++ b/repeater/main.py @@ -6,6 +6,14 @@ 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") + + + + logger = logging.getLogger("RepeaterDaemon") @@ -21,6 +29,7 @@ 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") @@ -81,6 +90,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 +115,79 @@ 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 - - # 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) + + 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) + + + path_snrs = [] + path_hashes = [] + for i in range(packet.path_len): + if i < len(packet.path): + snr_val = packet.path[i] + path_snrs.append(f"{snr_val}({snr_val/4:.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)): + + + snr_scaled = int(packet.get_snr() * 4) + snr_byte = snr_scaled & 0xFF + + 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 {packet.get_snr():.1f}dB ({snr_byte}) 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 +233,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: From 85df2c5b0f9b55ae5d0644aa52a09e04d2a2fa07 Mon Sep 17 00:00:00 2001 From: Lloyd Date: Thu, 30 Oct 2025 00:11:04 +0000 Subject: [PATCH 02/12] Implement trace packet logging and SNR display enhancements --- repeater/engine.py | 16 +++- repeater/main.py | 61 +++++++++++- repeater/templates/dashboard.html | 154 +++++++++++++++++++++++++++++- 3 files changed, 222 insertions(+), 9 deletions(-) diff --git a/repeater/engine.py b/repeater/engine.py index d6252dc..3d58c76 100644 --- a/repeater/engine.py +++ b/repeater/engine.py @@ -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() @@ -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'): diff --git a/repeater/main.py b/repeater/main.py index 1835ec5..234585f 100644 --- a/repeater/main.py +++ b/repeater/main.py @@ -132,7 +132,59 @@ class RepeaterDaemon: 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_db = snr_val / 4.0 + path_snrs.append(f"{snr_val}({snr_db:.1f}dB)") + # Create hash->SNR mapping for display + 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): @@ -156,6 +208,13 @@ class RepeaterDaemon: 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 snr_scaled = int(packet.get_snr() * 4) snr_byte = snr_scaled & 0xFF 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; From 1c5b67de95064d7c4f1014cb9c8ce5428c9d1077 Mon Sep 17 00:00:00 2001 From: Lloyd Date: Fri, 31 Oct 2025 00:15:41 +0000 Subject: [PATCH 03/12] CAD radio initialization and SNR handling in RepeaterDaemon --- repeater/main.py | 54 ++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 43 insertions(+), 11 deletions(-) diff --git a/repeater/main.py b/repeater/main.py index 234585f..617da3c 100644 --- a/repeater/main.py +++ b/repeater/main.py @@ -31,14 +31,13 @@ class RepeaterDaemon: 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) @@ -51,12 +50,32 @@ class RepeaterDaemon: logger.info("Initializing radio hardware...") try: self.radio = get_radio_for_board(self.config) + + + if hasattr(self.radio, 'set_custom_cad_thresholds'): + self.radio.set_custom_cad_thresholds(peak=23, min_val=11) + logger.info("CAD thresholds set: peak=23, min=11") + 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 @@ -73,14 +92,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( @@ -146,9 +165,11 @@ class RepeaterDaemon: for i in range(packet.path_len): if i < len(packet.path): snr_val = packet.path[i] - snr_db = snr_val / 4.0 + + 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)") - # Create hash->SNR mapping for display + if i < len(trace_path): path_snr_details.append({ "hash": f"{trace_path[i]:02X}", @@ -190,7 +211,9 @@ class RepeaterDaemon: for i in range(packet.path_len): if i < len(packet.path): snr_val = packet.path[i] - path_snrs.append(f"{snr_val}({snr_val/4:.1f}dB)") + 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}") @@ -216,16 +239,25 @@ class RepeaterDaemon: record["drop_reason"] = "trace_forwarded" break - snr_scaled = int(packet.get_snr() * 4) - snr_byte = snr_scaled & 0xFF + 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 {packet.get_snr():.1f}dB ({snr_byte}) at position {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) From 2564f4b7728705075e91c07372732e753b7c4dc2 Mon Sep 17 00:00:00 2001 From: Lloyd Date: Fri, 31 Oct 2025 08:51:31 +0000 Subject: [PATCH 04/12] Fix formatting in log messages and debug statements for consistency --- repeater/engine.py | 40 ++++++++++++++++++++-------------------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/repeater/engine.py b/repeater/engine.py index 3d58c76..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 ), } @@ -421,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 @@ -429,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 @@ -495,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 @@ -504,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 @@ -542,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}") @@ -561,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 @@ -607,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, From 3fc295e26cb06f377ce63b19bfb4ab6598c1ccbb Mon Sep 17 00:00:00 2001 From: Lloyd Date: Fri, 31 Oct 2025 22:10:45 +0000 Subject: [PATCH 05/12] Add CAD calibration tool and endpoints for real-time detection optimization --- repeater/http_server.py | 271 +++++++++- repeater/main.py | 2 + repeater/templates/cad-calibration.html | 634 ++++++++++++++++++++++++ repeater/templates/configuration.html | 9 + 4 files changed, 915 insertions(+), 1 deletion(-) create mode 100644 repeater/templates/cad-calibration.html diff --git a/repeater/http_server.py b/repeater/http_server.py index ee519c4..35bb83d 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,190 @@ class LogBuffer(logging.Handler): _log_buffer = LogBuffer(max_lines=100) +class CADCalibrationEngine: + """Real-time CAD calibration engine""" + + def __init__(self, stats_getter: Optional[Callable] = None, event_loop=None): + self.stats_getter = stats_getter + 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""" + sf_ranges = { + 7: (range(16, 29, 1), range(6, 15, 1)), + 8: (range(16, 29, 1), range(6, 15, 1)), + 9: (range(18, 31, 1), range(7, 16, 1)), + 10: (range(20, 33, 1), range(8, 16, 1)), + 11: (range(22, 35, 1), range(9, 17, 1)), + 12: (range(24, 37, 1), range(10, 18, 1)), + } + 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.6) + if result: + detections += 1 + except Exception: + pass + await asyncio.sleep(0.03) + + 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""" + message = f"data: {json.dumps(data)}\n\n" + for client in self.clients.copy(): + try: + client.write(message.encode()) + client.flush() + except Exception: + self.clients.discard(client) + + def calibration_worker(self, samples: int, delay_ms: int): + """Worker thread for calibration process""" + try: + # Get radio from stats + if not self.stats_getter: + self.broadcast_to_clients({"type": "error", "message": "No stats getter available"}) + return + + stats = self.stats_getter() + if not stats or "radio_instance" not in stats: + self.broadcast_to_clients({"type": "error", "message": "Radio instance not available"}) + return + + radio = stats["radio_instance"] + if not hasattr(radio, 'perform_cad'): + self.broadcast_to_clients({"type": "error", "message": "Radio does not support CAD"}) + return + + # Get spreading factor + config = stats.get("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" + }) + + current = 0 + + # Run calibration in event loop + if self.event_loop: + for det_peak in peak_range: + if not self.running: + break + + for det_min in min_range: + 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: + self.broadcast_to_clients({"type": "completed", "message": "Calibration completed"}) + 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} + + # 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 add_client(self, response_stream): + """Add SSE client""" + self.clients.add(response_stream) + + def remove_client(self, response_stream): + """Remove SSE client""" + self.clients.discard(response_stream) + + class APIEndpoints: def __init__( @@ -54,6 +242,9 @@ class APIEndpoints: self.send_advert_func = send_advert_func self.config = config or {} self.event_loop = event_loop # Store reference to main event loop + + # Initialize CAD calibration engine + self.cad_calibration = CADCalibrationEngine(stats_getter, event_loop) @cherrypy.expose @cherrypy.tools.json_out() @@ -167,6 +358,78 @@ 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 + 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(): + # Add client to calibration engine + response = cherrypy.response + self.cad_calibration.add_client(response) + + try: + # Send initial connection message + yield f"data: {json.dumps({'type': 'connected', 'message': 'Connected to CAD calibration stream'})}\n\n" + + # Keep connection alive - the calibration engine will send data + while True: + time.sleep(1) + # Send keepalive every second + yield f"data: {json.dumps({'type': 'keepalive'})}\n\n" + + except Exception as e: + logger.error(f"SSE stream error: {e}") + finally: + # Remove client when connection closes + self.cad_calibration.remove_client(response) + + return generate() + + cad_calibration_stream._cp_config = {'response.stream': True} + + + class StatsApp: @@ -231,6 +494,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 +538,7 @@ class StatsApp: "neighbors.html": "neighbors", "statistics.html": "statistics", "configuration.html": "configuration", + "cad-calibration.html": "cad-calibration", "logs.html": "logs", "help.html": "help", } diff --git a/repeater/main.py b/repeater/main.py index 617da3c..7f78e74 100644 --- a/repeater/main.py +++ b/repeater/main.py @@ -291,6 +291,8 @@ class RepeaterDaemon: stats["public_key"] = pubkey.hex() except Exception: stats["public_key"] = None + if self.radio: + stats["radio_instance"] = self.radio return stats return {} diff --git a/repeater/templates/cad-calibration.html b/repeater/templates/cad-calibration.html new file mode 100644 index 0000000..f3222f4 --- /dev/null +++ b/repeater/templates/cad-calibration.html @@ -0,0 +1,634 @@ + + + + pyMC Repeater - CAD Calibration + + + + + + +
+ + + + +
+
+

CAD Calibration

+

Real-time Channel Activity Detection calibration tool

+
+ +
+ This tool helps you find optimal CAD thresholds for your environment. Lower detection rates (blue/green) are better for mesh networking. +
+ + +
+

Calibration Settings

+ +
+
+ + +
+ +
+ + +
+ + + + +
+ +
+
+ Status: Ready +
+
+ Progress: 0/0 +
+
+ Current: Peak=-, Min=- +
+
+ +
+
+
+
+
+
+ + +
+

Detection Rate Heatmap

+ +
+
+
+ 0% (Quiet) +
+
+
+ 1-10% (Low) +
+
+
+ 11-30% (Medium) +
+
+
+ 31%+ (High) +
+
+
+ Not tested +
+
+ +
+ +
+
+ + + +
+
+ + +
+ + + + \ No newline at end of file diff --git a/repeater/templates/configuration.html b/repeater/templates/configuration.html index 79e053d..1de67bc 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 for better mesh performance. + Launch CAD Calibration Tool → +

+
+

Radio Settings

From 32c6b4f490aa6ba93e6cc34ac3c343df70e984cd Mon Sep 17 00:00:00 2001 From: Lloyd Date: Fri, 31 Oct 2025 22:16:52 +0000 Subject: [PATCH 06/12] Refactor CAD calibration engine to use daemon instance instead of stats getter --- repeater/http_server.py | 26 ++++++++++++++------------ repeater/main.py | 3 +-- 2 files changed, 15 insertions(+), 14 deletions(-) diff --git a/repeater/http_server.py b/repeater/http_server.py index 35bb83d..74eea1c 100644 --- a/repeater/http_server.py +++ b/repeater/http_server.py @@ -47,8 +47,8 @@ _log_buffer = LogBuffer(max_lines=100) class CADCalibrationEngine: """Real-time CAD calibration engine""" - def __init__(self, stats_getter: Optional[Callable] = None, event_loop=None): - self.stats_getter = stats_getter + def __init__(self, daemon_instance=None, event_loop=None): + self.daemon_instance = daemon_instance self.event_loop = event_loop self.running = False self.results = {} @@ -103,17 +103,15 @@ class CADCalibrationEngine: def calibration_worker(self, samples: int, delay_ms: int): """Worker thread for calibration process""" try: - # Get radio from stats - if not self.stats_getter: - self.broadcast_to_clients({"type": "error", "message": "No stats getter available"}) + # Get radio from daemon instance + if not self.daemon_instance: + self.broadcast_to_clients({"type": "error", "message": "No daemon instance available"}) return - stats = self.stats_getter() - if not stats or "radio_instance" not in stats: + radio = getattr(self.daemon_instance, 'radio', None) + if not radio: self.broadcast_to_clients({"type": "error", "message": "Radio instance not available"}) return - - radio = stats["radio_instance"] if not hasattr(radio, 'perform_cad'): self.broadcast_to_clients({"type": "error", "message": "Radio does not support CAD"}) return @@ -236,15 +234,17 @@ class APIEndpoints: send_advert_func: Optional[Callable] = None, config: Optional[dict] = None, event_loop=None, + daemon_instance=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.daemon_instance = daemon_instance # Store reference to daemon instance # Initialize CAD calibration engine - self.cad_calibration = CADCalibrationEngine(stats_getter, event_loop) + self.cad_calibration = CADCalibrationEngine(daemon_instance, event_loop) @cherrypy.expose @cherrypy.tools.json_out() @@ -442,6 +442,7 @@ class StatsApp: send_advert_func: Optional[Callable] = None, config: Optional[dict] = None, event_loop=None, + daemon_instance=None, ): self.stats_getter = stats_getter @@ -452,7 +453,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) # Load template on init if template_dir: @@ -669,12 +670,13 @@ class HTTPStatsServer: send_advert_func: Optional[Callable] = None, config: Optional[dict] = None, event_loop=None, + daemon_instance=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 ) def start(self): diff --git a/repeater/main.py b/repeater/main.py index 7f78e74..44c7c0f 100644 --- a/repeater/main.py +++ b/repeater/main.py @@ -291,8 +291,6 @@ class RepeaterDaemon: stats["public_key"] = pubkey.hex() except Exception: stats["public_key"] = None - if self.radio: - stats["radio_instance"] = self.radio return stats return {} @@ -376,6 +374,7 @@ class RepeaterDaemon: send_advert_func=self.send_advert, config=self.config, # Pass the config reference event_loop=current_loop, # Pass the main event loop + daemon_instance=self, # Pass the daemon instance for CAD calibration ) try: From 24514543c291913d3dd454d7789f06d4064b2444 Mon Sep 17 00:00:00 2001 From: Lloyd Date: Fri, 31 Oct 2025 22:24:02 +0000 Subject: [PATCH 07/12] Add CAD Calibration Tool HTML template with real-time data visualization and controls - Implemented a responsive layout with navigation and calibration controls. --- repeater/http_server.py | 4 +- repeater/templates/cad-calibration.html | 918 +++++++++++------------- 2 files changed, 403 insertions(+), 519 deletions(-) diff --git a/repeater/http_server.py b/repeater/http_server.py index 74eea1c..2c9bd16 100644 --- a/repeater/http_server.py +++ b/repeater/http_server.py @@ -116,8 +116,8 @@ class CADCalibrationEngine: self.broadcast_to_clients({"type": "error", "message": "Radio does not support CAD"}) return - # Get spreading factor - config = stats.get("config", {}) + # Get spreading factor from daemon instance + config = getattr(self.daemon_instance, 'config', {}) radio_config = config.get("radio", {}) sf = radio_config.get("spreading_factor", 8) diff --git a/repeater/templates/cad-calibration.html b/repeater/templates/cad-calibration.html index f3222f4..d0bf5a1 100644 --- a/repeater/templates/cad-calibration.html +++ b/repeater/templates/cad-calibration.html @@ -5,6 +5,8 @@ + + -
- - +
+ + - -
-
-

CAD Calibration

-

Real-time Channel Activity Detection calibration tool

-
- -
- This tool helps you find optimal CAD thresholds for your environment. Lower detection rates (blue/green) are better for mesh networking. +
+
-

Calibration Settings

- -
-
- - -
- -
- - -
- - - - +
+ +
- -
-
- Status: Ready -
-
- Progress: 0/0 -
-
- Current: Peak=-, Min=- -
+
+ +
+
+ + +
+
+ +
+
Ready to start calibration
-
+
+
0 / 0 tests completed
- -
-

Detection Rate Heatmap

- -
-
-
- 0% (Quiet) -
-
-
- 1-10% (Low) -
-
-
- 11-30% (Medium) -
-
-
- 31%+ (High) -
-
-
- Not tested -
+ +
+
+
0
+
Tests Completed
- -
- +
+
0%
+
Best Detection Rate
+
+
+
0%
+
Average Rate
+
+
+
0s
+
Elapsed Time
- -
+
- -
- - + + + -
- - +
+ + -
- +

Real-time Channel Activity Detection calibration

+
-
- - -
-
- - -
- - + +
Ready to start calibration
+ +
@@ -205,7 +402,7 @@
- +
@@ -214,125 +411,426 @@ +
-
+