mirror of
https://github.com/pyMC-dev/pyMC_Repeater.git
synced 2026-11-06 05:12:27 +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:
9 files changed
+2060
-73
No files matched your search
+4
-2
@@ -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"
|
||||
+32
-24
@@ -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,
|
||||
|
||||
+429
-4
@@ -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):
|
||||
|
||||
+188
-27
@@ -6,6 +6,8 @@ import sys
|
||||
from repeater.config import get_radio_for_board, load_config
|
||||
from repeater.engine import RepeaterHandler
|
||||
from repeater.http_server import HTTPStatsServer, _log_buffer
|
||||
from pymc_core.node.handlers.trace import TraceHandler
|
||||
from pymc_core.protocol.constants import MAX_PATH_SIZE, ROUTE_TYPE_DIRECT
|
||||
|
||||
logger = logging.getLogger("RepeaterDaemon")
|
||||
|
||||
@@ -21,15 +23,15 @@ class RepeaterDaemon:
|
||||
self.local_hash = None
|
||||
self.local_identity = None
|
||||
self.http_server = None
|
||||
self.trace_handler = None
|
||||
|
||||
|
||||
# Setup logging
|
||||
log_level = config.get("logging", {}).get("level", "INFO")
|
||||
logging.basicConfig(
|
||||
level=getattr(logging, log_level),
|
||||
format=config.get("logging", {}).get("format"),
|
||||
)
|
||||
|
||||
# Add log buffer handler to capture logs for web display
|
||||
root_logger = logging.getLogger()
|
||||
_log_buffer.setLevel(getattr(logging, log_level))
|
||||
root_logger.addHandler(_log_buffer)
|
||||
@@ -42,12 +44,37 @@ class RepeaterDaemon:
|
||||
logger.info("Initializing radio hardware...")
|
||||
try:
|
||||
self.radio = get_radio_for_board(self.config)
|
||||
|
||||
|
||||
if hasattr(self.radio, 'set_custom_cad_thresholds'):
|
||||
# Load CAD settings from config, with defaults
|
||||
cad_config = self.config.get("radio", {}).get("cad", {})
|
||||
peak_threshold = cad_config.get("peak_threshold", 23)
|
||||
min_threshold = cad_config.get("min_threshold", 11)
|
||||
|
||||
self.radio.set_custom_cad_thresholds(peak=peak_threshold, min_val=min_threshold)
|
||||
logger.info(f"CAD thresholds set from config: peak={peak_threshold}, min={min_threshold}")
|
||||
else:
|
||||
logger.warning("Radio does not support CAD configuration")
|
||||
|
||||
|
||||
if hasattr(self.radio, 'get_frequency'):
|
||||
logger.info(f"Radio config - Freq: {self.radio.get_frequency():.1f}MHz")
|
||||
if hasattr(self.radio, 'get_spreading_factor'):
|
||||
logger.info(f"Radio config - SF: {self.radio.get_spreading_factor()}")
|
||||
if hasattr(self.radio, 'get_bandwidth'):
|
||||
logger.info(f"Radio config - BW: {self.radio.get_bandwidth()}kHz")
|
||||
if hasattr(self.radio, 'get_coding_rate'):
|
||||
logger.info(f"Radio config - CR: {self.radio.get_coding_rate()}")
|
||||
if hasattr(self.radio, 'get_tx_power'):
|
||||
logger.info(f"Radio config - TX Power: {self.radio.get_tx_power()}dBm")
|
||||
|
||||
logger.info("Radio hardware initialized")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to initialize radio hardware: {e}")
|
||||
raise RuntimeError("Repeater requires real LoRa hardware") from e
|
||||
|
||||
# Create dispatcher from pymc_core
|
||||
|
||||
try:
|
||||
from pymc_core import LocalIdentity
|
||||
from pymc_core.node.dispatcher import Dispatcher
|
||||
@@ -64,14 +91,14 @@ class RepeaterDaemon:
|
||||
self.local_identity = local_identity
|
||||
self.dispatcher.local_identity = local_identity
|
||||
|
||||
# Get the actual hash from the identity (first byte of public key)
|
||||
|
||||
pubkey = local_identity.get_public_key()
|
||||
self.local_hash = pubkey[0]
|
||||
logger.info(f"Local identity set: {local_identity.get_address_bytes().hex()}")
|
||||
local_hash_hex = f"0x{self.local_hash: 02x}"
|
||||
logger.info(f"Local node hash (from identity): {local_hash_hex}")
|
||||
|
||||
# Override _is_own_packet to always return False
|
||||
|
||||
self.dispatcher._is_own_packet = lambda pkt: False
|
||||
|
||||
self.repeater_handler = RepeaterHandler(
|
||||
@@ -81,6 +108,16 @@ class RepeaterDaemon:
|
||||
self.dispatcher.register_fallback_handler(self._repeater_callback)
|
||||
logger.info("Repeater handler registered (forwarder mode)")
|
||||
|
||||
self.trace_handler = TraceHandler(log_fn=logger.info)
|
||||
|
||||
self.dispatcher.register_handler(
|
||||
TraceHandler.payload_type(),
|
||||
self._trace_callback,
|
||||
)
|
||||
logger.info("Trace handler registered for network diagnostics")
|
||||
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to initialize dispatcher: {e}")
|
||||
raise
|
||||
@@ -96,30 +133,151 @@ class RepeaterDaemon:
|
||||
}
|
||||
await self.repeater_handler(packet, metadata)
|
||||
|
||||
def _get_keypair(self):
|
||||
"""Create a PyNaCl SigningKey for map API."""
|
||||
async def _trace_callback(self, packet):
|
||||
|
||||
try:
|
||||
from nacl.signing import SigningKey
|
||||
# Only process direct route trace packets
|
||||
if packet.get_route_type() != ROUTE_TYPE_DIRECT or packet.path_len >= MAX_PATH_SIZE:
|
||||
return
|
||||
|
||||
if not self.local_identity:
|
||||
return None
|
||||
|
||||
parsed_data = self.trace_handler._parse_trace_payload(packet.payload)
|
||||
|
||||
if not parsed_data.get("valid", False):
|
||||
logger.warning(f"[TraceHandler] Invalid trace packet: {parsed_data.get('error', 'Unknown error')}")
|
||||
return
|
||||
|
||||
trace_path = parsed_data["trace_path"]
|
||||
trace_path_len = len(trace_path)
|
||||
|
||||
|
||||
if self.repeater_handler:
|
||||
import time
|
||||
|
||||
trace_path_bytes = [f"{h:02X}" for h in trace_path[:8]]
|
||||
if len(trace_path) > 8:
|
||||
trace_path_bytes.append("...")
|
||||
path_hash = "[" + ", ".join(trace_path_bytes) + "]"
|
||||
|
||||
path_snrs = []
|
||||
path_snr_details = []
|
||||
for i in range(packet.path_len):
|
||||
if i < len(packet.path):
|
||||
snr_val = packet.path[i]
|
||||
|
||||
snr_signed = snr_val if snr_val < 128 else snr_val - 256
|
||||
snr_db = snr_signed / 4.0
|
||||
path_snrs.append(f"{snr_val}({snr_db:.1f}dB)")
|
||||
|
||||
if i < len(trace_path):
|
||||
path_snr_details.append({
|
||||
"hash": f"{trace_path[i]:02X}",
|
||||
"snr_raw": snr_val,
|
||||
"snr_db": snr_db
|
||||
})
|
||||
|
||||
packet_record = {
|
||||
"timestamp": time.time(),
|
||||
"type": packet.get_payload_type(), # 0x09 for trace
|
||||
"route": packet.get_route_type(), # Should be direct (1)
|
||||
"length": len(packet.payload or b""),
|
||||
"rssi": getattr(packet, "rssi", 0),
|
||||
"snr": getattr(packet, "snr", 0.0),
|
||||
"score": self.repeater_handler.calculate_packet_score(
|
||||
getattr(packet, "snr", 0.0),
|
||||
len(packet.payload or b""),
|
||||
self.repeater_handler.radio_config.get("spreading_factor", 8)
|
||||
),
|
||||
"tx_delay_ms": 0,
|
||||
"transmitted": False,
|
||||
"is_duplicate": False,
|
||||
"packet_hash": packet.calculate_packet_hash().hex()[:16],
|
||||
"drop_reason": "trace_received",
|
||||
"path_hash": path_hash,
|
||||
"src_hash": None,
|
||||
"dst_hash": None,
|
||||
"original_path": [f"{h:02X}" for h in trace_path],
|
||||
"forwarded_path": None,
|
||||
# Add trace-specific SNR path information
|
||||
"path_snrs": path_snrs, # ["58(14.5dB)", "19(4.8dB)"]
|
||||
"path_snr_details": path_snr_details, # [{"hash": "29", "snr_raw": 58, "snr_db": 14.5}]
|
||||
"is_trace": True,
|
||||
}
|
||||
self.repeater_handler.log_trace_record(packet_record)
|
||||
|
||||
path_snrs = []
|
||||
path_hashes = []
|
||||
for i in range(packet.path_len):
|
||||
if i < len(packet.path):
|
||||
snr_val = packet.path[i]
|
||||
snr_signed = snr_val if snr_val < 128 else snr_val - 256
|
||||
snr_db = snr_signed / 4.0
|
||||
path_snrs.append(f"{snr_val}({snr_db:.1f}dB)")
|
||||
if i < len(trace_path):
|
||||
path_hashes.append(f"0x{trace_path[i]:02x}")
|
||||
|
||||
|
||||
parsed_data["snr"] = packet.get_snr()
|
||||
parsed_data["rssi"] = getattr(packet, "rssi", 0)
|
||||
formatted_response = self.trace_handler._format_trace_response(parsed_data)
|
||||
|
||||
logger.info(f"[TraceHandler] {formatted_response}")
|
||||
logger.info(f"[TraceHandler] Path SNRs: [{', '.join(path_snrs)}], Hashes: [{', '.join(path_hashes)}]")
|
||||
|
||||
|
||||
if (packet.path_len < trace_path_len and
|
||||
len(trace_path) > packet.path_len and
|
||||
trace_path[packet.path_len] == self.local_hash and
|
||||
self.repeater_handler and not self.repeater_handler.is_duplicate(packet)):
|
||||
|
||||
if self.repeater_handler and hasattr(self.repeater_handler, 'recent_packets'):
|
||||
packet_hash = packet.calculate_packet_hash().hex()[:16]
|
||||
for record in reversed(self.repeater_handler.recent_packets):
|
||||
if record.get("packet_hash") == packet_hash:
|
||||
record["transmitted"] = True
|
||||
record["drop_reason"] = "trace_forwarded"
|
||||
break
|
||||
|
||||
current_snr = packet.get_snr()
|
||||
|
||||
|
||||
snr_scaled = int(current_snr * 4)
|
||||
|
||||
if snr_scaled > 127:
|
||||
snr_scaled = 127
|
||||
elif snr_scaled < -128:
|
||||
snr_scaled = -128
|
||||
|
||||
# Get the seed from config
|
||||
identity_key = self.config.get("mesh", {}).get("identity_key")
|
||||
if not identity_key:
|
||||
return None
|
||||
|
||||
# Convert to bytes if it's a hex string, otherwise use as-is
|
||||
if isinstance(identity_key, str):
|
||||
seed_bytes = bytes.fromhex(identity_key)
|
||||
snr_byte = snr_scaled if snr_scaled >= 0 else (256 + snr_scaled)
|
||||
|
||||
while len(packet.path) <= packet.path_len:
|
||||
packet.path.append(0)
|
||||
|
||||
packet.path[packet.path_len] = snr_byte
|
||||
packet.path_len += 1
|
||||
|
||||
logger.info(f"[TraceHandler] Forwarding trace, stored SNR {current_snr:.1f}dB at position {packet.path_len-1}")
|
||||
|
||||
# Mark as seen and forward directly (bypass normal routing, no ACK required)
|
||||
self.repeater_handler.mark_seen(packet)
|
||||
if self.dispatcher:
|
||||
await self.dispatcher.send_packet(packet, wait_for_ack=False)
|
||||
else:
|
||||
seed_bytes = identity_key
|
||||
# Show why we didn't forward
|
||||
if packet.path_len >= trace_path_len:
|
||||
logger.info(f"[TraceHandler] Trace completed (reached end of path)")
|
||||
elif len(trace_path) <= packet.path_len:
|
||||
logger.info(f"[TraceHandler] Path index out of bounds")
|
||||
elif trace_path[packet.path_len] != self.local_hash:
|
||||
expected_hash = trace_path[packet.path_len] if packet.path_len < len(trace_path) else None
|
||||
logger.info(f"[TraceHandler] Not our turn (next hop: 0x{expected_hash:02x})")
|
||||
elif self.repeater_handler and self.repeater_handler.is_duplicate(packet):
|
||||
logger.info(f"[TraceHandler] Duplicate packet, ignoring")
|
||||
|
||||
signing_key = SigningKey(seed_bytes)
|
||||
return signing_key
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to create keypair for map API: {e}")
|
||||
return None
|
||||
logger.error(f"[TraceHandler] Error processing trace packet: {e}")
|
||||
|
||||
|
||||
|
||||
def get_stats(self) -> dict:
|
||||
|
||||
@@ -165,7 +323,7 @@ class RepeaterDaemon:
|
||||
)
|
||||
|
||||
# Send via dispatcher
|
||||
await self.dispatcher.send_packet(packet)
|
||||
await self.dispatcher.send_packet(packet, wait_for_ack=False)
|
||||
|
||||
# Mark our own advert as seen to prevent re-forwarding it
|
||||
if self.repeater_handler:
|
||||
@@ -202,7 +360,6 @@ class RepeaterDaemon:
|
||||
else:
|
||||
pub_key_formatted = pub_key_hex
|
||||
|
||||
# Get the current event loop (the main loop where the radio was initialized)
|
||||
current_loop = asyncio.get_event_loop()
|
||||
|
||||
self.http_server = HTTPStatsServer(
|
||||
@@ -213,8 +370,10 @@ class RepeaterDaemon:
|
||||
node_name=node_name,
|
||||
pub_key=pub_key_formatted,
|
||||
send_advert_func=self.send_advert,
|
||||
config=self.config, # Pass the config reference
|
||||
event_loop=current_loop, # Pass the main event loop
|
||||
config=self.config,
|
||||
event_loop=current_loop,
|
||||
daemon_instance=self,
|
||||
config_path=getattr(self, 'config_path', '/etc/pymc_repeater/config.yaml'),
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -250,12 +409,14 @@ def main():
|
||||
|
||||
# Load configuration
|
||||
config = load_config(args.config)
|
||||
config_path = args.config if args.config else '/etc/pymc_repeater/config.yaml'
|
||||
|
||||
if args.log_level:
|
||||
config["logging"]["level"] = args.log_level
|
||||
|
||||
# Don't initialize radio here - it will be done inside the async event loop
|
||||
daemon = RepeaterDaemon(config, radio=None)
|
||||
daemon.config_path = config_path
|
||||
|
||||
# Run
|
||||
try:
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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;
|
||||
|
||||
+31
-11
@@ -103,7 +103,12 @@
|
||||
|
||||
<div style="display: flex; gap: 8px; align-items: center; margin-bottom: var(--spacing-md);">
|
||||
<div class="status-badge" id="status-badge" title="System operational status">Online</div>
|
||||
<div class="version-badge" id="version-badge" title="Software version">v1.0.0</div>
|
||||
</div>
|
||||
|
||||
<!-- Version badges -->
|
||||
<div style="display: flex; gap: 6px; align-items: center; margin-bottom: var(--spacing-md); flex-wrap: wrap;">
|
||||
<div class="version-badge" id="repeater-version-badge" title="pyMC_Repeater version">R:v1.0.1</div>
|
||||
<div class="version-badge" id="core-version-badge" title="pyMC_Core version">C:v1.0.2</div>
|
||||
</div>
|
||||
|
||||
<!-- Mode Toggle Buttons -->
|
||||
@@ -142,19 +147,27 @@
|
||||
</div>
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-top: 8px;">
|
||||
<small>Last updated: <span id="footer-update-time">{{ last_updated }}</span></small>
|
||||
<a href="https://github.com/rightup" target="_blank" class="github-link" title="GitHub">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
|
||||
</svg>
|
||||
</a>
|
||||
<div style="display: flex; gap: 12px; align-items: center;">
|
||||
<a href="https://buymeacoffee.com/rightup" target="_blank" class="coffee-link" title="Buy me a coffee">
|
||||
<svg width="18" height="18" viewBox="0 0 27 39" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M14.3206 17.9122C12.9282 18.5083 11.3481 19.1842 9.30013 19.1842C8.44341 19.1824 7.59085 19.0649 6.76562 18.8347L8.18203 33.3768C8.23216 33.9847 8.50906 34.5514 8.95772 34.9645C9.40638 35.3776 9.994 35.6069 10.6039 35.6068C10.6039 35.6068 12.6122 35.7111 13.2823 35.7111C14.0036 35.7111 16.1662 35.6068 16.1662 35.6068C16.776 35.6068 17.3635 35.3774 17.8121 34.9643C18.2606 34.5512 18.5374 33.9846 18.5876 33.3768L20.1046 17.3073C19.4267 17.0757 18.7425 16.9219 17.9712 16.9219C16.6372 16.9214 15.5623 17.3808 14.3206 17.9122Z" fill="#FFDD00"></path>
|
||||
<path d="M26.6584 10.3609L26.4451 9.28509C26.2537 8.31979 25.8193 7.40768 24.8285 7.05879C24.5109 6.94719 24.1505 6.89922 23.907 6.66819C23.6634 6.43716 23.5915 6.07837 23.5351 5.74565C23.4308 5.13497 23.3328 4.52377 23.2259 3.91413C23.1336 3.39002 23.0606 2.80125 22.8202 2.32042C22.5073 1.6748 21.858 1.29723 21.2124 1.04743C20.8815 0.923938 20.5439 0.819467 20.2012 0.734533C18.5882 0.308987 16.8922 0.152536 15.2328 0.0633591C13.241 -0.046547 11.244 -0.0134338 9.25692 0.162444C7.77794 0.296992 6.22021 0.459701 4.81476 0.971295C4.30108 1.15851 3.77175 1.38328 3.38115 1.78015C2.90189 2.26775 2.74544 3.02184 3.09537 3.62991C3.34412 4.06172 3.7655 4.3668 4.21242 4.56862C4.79457 4.82867 5.40253 5.02654 6.02621 5.15896C7.76282 5.54279 9.56148 5.6935 11.3356 5.75765C13.302 5.83701 15.2716 5.77269 17.2286 5.56521C17.7126 5.51202 18.1956 5.44822 18.6779 5.37382C19.2458 5.28673 19.6103 4.54411 19.4429 4.02678C19.2427 3.40828 18.7045 3.16839 18.0959 3.26173C18.0062 3.27581 17.917 3.28885 17.8273 3.30189L17.7626 3.31128C17.5565 3.33735 17.3503 3.36169 17.1441 3.38429C16.7182 3.43018 16.2913 3.46773 15.8633 3.49693C14.9048 3.56368 13.9437 3.59445 12.9831 3.59602C12.0391 3.59602 11.0947 3.56942 10.1529 3.50736C9.72314 3.4792 9.29447 3.44339 8.86684 3.39993C8.67232 3.37959 8.47832 3.35821 8.28432 3.33422L8.0997 3.31076L8.05955 3.30502L7.86816 3.27738C7.47703 3.21845 7.0859 3.15066 6.69895 3.06878C6.6599 3.06012 6.62498 3.03839 6.59994 3.0072C6.57491 2.976 6.56127 2.9372 6.56127 2.8972C6.56127 2.85721 6.57491 2.81841 6.59994 2.78721C6.62498 2.75602 6.6599 2.73429 6.69895 2.72563H6.70625C7.04158 2.65418 7.37951 2.59317 7.71849 2.53997C7.83148 2.52224 7.94482 2.50486 8.05851 2.48782H8.06164C8.27389 2.47374 8.48718 2.43567 8.69839 2.41064C10.536 2.2195 12.3845 2.15434 14.231 2.2156C15.1275 2.24168 16.0234 2.29435 16.9157 2.38509C17.1076 2.40491 17.2985 2.42577 17.4894 2.44923C17.5624 2.4581 17.6359 2.46853 17.7094 2.47739L17.8575 2.49878C18.2893 2.56309 18.7189 2.64115 19.1462 2.73293C19.7793 2.87061 20.5923 2.91546 20.8739 3.60906C20.9636 3.82913 21.0043 4.07371 21.0538 4.30474L21.1169 4.59939C21.1186 4.60467 21.1198 4.61008 21.1206 4.61555C21.2697 5.31089 21.4191 6.00623 21.5686 6.70157C21.5795 6.75293 21.5798 6.80601 21.5693 6.85748C21.5589 6.90895 21.5379 6.95771 21.5078 7.00072C21.4776 7.04373 21.4389 7.08007 21.3941 7.10747C21.3493 7.13487 21.2993 7.15274 21.2473 7.15997H21.2431L21.1519 7.17248L21.0617 7.18448C20.7759 7.22168 20.4897 7.25644 20.2033 7.28878C19.639 7.3531 19.0739 7.40872 18.5079 7.45566C17.3831 7.54918 16.2562 7.61055 15.127 7.63975C14.5516 7.65505 13.9763 7.66217 13.4013 7.66113C11.1124 7.65933 8.82553 7.5263 6.55188 7.2627C6.30574 7.2335 6.05959 7.20221 5.81344 7.1704C6.00431 7.19491 5.67472 7.15162 5.60797 7.14224C5.45152 7.12033 5.29506 7.09756 5.13861 7.07392C4.61346 6.99517 4.09144 6.89817 3.56733 6.81317C2.9337 6.70887 2.32771 6.76102 1.75458 7.07392C1.28413 7.33136 0.903361 7.72614 0.663078 8.20558C0.415886 8.71665 0.342354 9.2731 0.231796 9.82224C0.121237 10.3714 -0.0508594 10.9622 0.0143284 11.526C0.154613 12.7427 1.00518 13.7314 2.22863 13.9525C3.37959 14.1611 4.5368 14.3301 5.69714 14.474C10.2552 15.0323 14.8601 15.0991 19.4325 14.6733C19.8048 14.6385 20.1767 14.6006 20.548 14.5596C20.6639 14.5468 20.7813 14.5602 20.8914 14.5987C21.0016 14.6372 21.1017 14.6998 21.1845 14.782C21.2673 14.8642 21.3307 14.9639 21.37 15.0737C21.4093 15.1836 21.4235 15.3009 21.4116 15.4169L21.2958 16.5423C21.0625 18.8164 20.8292 21.0903 20.596 23.3641C20.3526 25.7519 20.1077 28.1395 19.8612 30.5269C19.7916 31.1993 19.7221 31.8715 19.6526 32.5436C19.5858 33.2054 19.5764 33.888 19.4507 34.542C19.2526 35.5704 18.5564 36.2019 17.5405 36.433C16.6098 36.6448 15.659 36.756 14.7045 36.7646C13.6464 36.7704 12.5888 36.7234 11.5307 36.7292C10.4011 36.7354 9.01755 36.6311 8.1456 35.7905C7.37951 35.052 7.27365 33.8958 7.16935 32.8961C7.03028 31.5725 6.89243 30.2491 6.75579 28.9259L5.98918 21.568L5.49324 16.8072C5.48489 16.7285 5.47655 16.6508 5.46873 16.5715C5.40927 16.0036 5.0072 15.4477 4.37357 15.4764C3.83121 15.5004 3.21479 15.9614 3.27841 16.5715L3.64607 20.1011L4.40642 27.4021C4.62302 29.4759 4.8391 31.5501 5.05465 33.6247C5.09637 34.022 5.13548 34.4205 5.17929 34.8179C5.41762 36.9894 7.07599 38.1596 9.12967 38.4892C10.3291 38.6822 11.5578 38.7218 12.775 38.7416C14.3353 38.7667 15.9113 38.8267 17.4461 38.544C19.7203 38.1268 21.4267 36.6082 21.6702 34.2526C21.7398 33.5725 21.8093 32.8923 21.8788 32.2119C22.11 29.9618 22.3409 27.7115 22.5714 25.4611L23.3255 18.1079L23.6713 14.7379C23.6885 14.5708 23.759 14.4137 23.8725 14.2898C23.986 14.1659 24.1363 14.0819 24.3012 14.0501C24.9515 13.9233 25.5732 13.7069 26.0357 13.212C26.7721 12.424 26.9187 11.3967 26.6584 10.3609ZM2.19525 11.0879C2.20516 11.0832 2.18691 11.1682 2.17909 11.2079C2.17752 11.1479 2.18065 11.0947 2.19525 11.0879ZM2.25836 11.5761C2.26357 11.5724 2.27921 11.5933 2.29538 11.6183C2.27087 11.5953 2.25523 11.5781 2.25783 11.5761H2.25836ZM2.32041 11.6579C2Line truncated
|
||||
</svg>
|
||||
</a>
|
||||
<a href="https://github.com/rightup" target="_blank" class="github-link" title="GitHub">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div> <!-- Close sidebar-content-wrapper -->
|
||||
</aside>
|
||||
|
||||
<style>
|
||||
/* GitHub link styling */
|
||||
.github-link {
|
||||
/* GitHub and Coffee link styling */
|
||||
.github-link, .coffee-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
color: #d4d4d4;
|
||||
@@ -165,7 +178,11 @@
|
||||
color: #4ec9b0;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
.github-link svg {
|
||||
.coffee-link:hover {
|
||||
color: #ff813f;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
.github-link svg, .coffee-link svg {
|
||||
display: block;
|
||||
}
|
||||
|
||||
@@ -457,9 +474,12 @@
|
||||
renderNoiseFloorSparkline();
|
||||
}
|
||||
|
||||
// Update version badge
|
||||
// Update version badges
|
||||
if (data.version) {
|
||||
document.getElementById('version-badge').textContent = 'v' + data.version;
|
||||
document.getElementById('repeater-version-badge').textContent = 'R:v' + data.version;
|
||||
}
|
||||
if (data.core_version) {
|
||||
document.getElementById('core-version-badge').textContent = 'C:v' + data.core_version;
|
||||
}
|
||||
|
||||
// Update duty cycle
|
||||
|
||||
Reference in new issue
Block a user