mirror of
https://github.com/rightup/pyMC_Repeater.git
synced 2026-03-28 17:43:06 +01:00
Merge pull request #7 from rightup/dev
feat: add Repeater logging, SNR dashboard, CAD calibration and LBT features
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "1.0.1"
|
||||
__version__ = "1.0.2"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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):
|
||||
|
||||
215
repeater/main.py
215
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:
|
||||
|
||||
1216
repeater/templates/cad-calibration.html
Normal file
1216
repeater/templates/cad-calibration.html
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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">
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user