Merge pull request #7 from rightup/dev

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

No files matched your search

+4 -2
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "pymc_repeater"
version = "1.0.1"
version = "1.0.2"
authors = [
{name = "Lloyd", email = "lloyd@rightup.co.uk"},
]
@@ -29,14 +29,16 @@ classifiers = [
keywords = ["mesh", "networking", "lora", "repeater", "daemon", "iot"]
dependencies = [
"pymc_core[hardware]>=1.0.2",
"pymc_core[hardware]>=1.0.3",
"pyyaml>=6.0.0",
"cherrypy>=18.0.0",
]
[project.optional-dependencies]
dev = [
"pytest>=7.4.0",
+1 -1
View File
@@ -1 +1 @@
__version__ = "1.0.1"
__version__ = "1.0.2"
+32 -24
View File
@@ -90,7 +90,7 @@ class RepeaterHandler(BaseHandler):
monitor_mode = mode == "monitor"
logger.debug(
f"RX packet: header=0x{packet.header: 02x}, payload_len={len(packet.payload or b'')}, "
f"RX packet: header=0x{packet.header:02x}, payload_len={len(packet.payload or b'')}, "
f"path_len={len(packet.path) if packet.path else 0}, "
f"rssi={metadata.get('rssi', 'N/A')}, snr={metadata.get('snr', 'N/A')}, mode={mode}"
)
@@ -123,8 +123,8 @@ class RepeaterHandler(BaseHandler):
if not can_tx:
logger.warning(
f"Duty-cycle limit exceeded. Airtime={airtime_ms: .1f}ms, "
f"wait={wait_time: .1f}s before retry"
f"Duty-cycle limit exceeded. Airtime={airtime_ms:.1f}ms, "
f"wait={wait_time:.1f}s before retry"
)
self.dropped_count += 1
drop_reason = "Duty cycle limit"
@@ -152,7 +152,7 @@ class RepeaterHandler(BaseHandler):
payload_type = header_info["payload_type"]
route_type = header_info["route_type"]
logger.debug(
f"Packet header=0x{packet.header: 02x}, type={payload_type}, route={route_type}"
f"Packet header=0x{packet.header:02x}, type={payload_type}, route={route_type}"
)
# Check if this is a duplicate
@@ -173,7 +173,7 @@ class RepeaterHandler(BaseHandler):
)
if display_path and len(display_path) > 0:
# Format path as array of uppercase hex bytes
path_bytes = [f"{b: 02X}" for b in display_path[:8]] # First 8 bytes max
path_bytes = [f"{b:02X}" for b in display_path[:8]] # First 8 bytes max
if len(display_path) > 8:
path_bytes.append("...")
path_hash = "[" + ", ".join(path_bytes) + "]"
@@ -184,13 +184,13 @@ class RepeaterHandler(BaseHandler):
# Payload types with dest_hash and src_hash as first 2 bytes
if payload_type in [0x00, 0x01, 0x02, 0x08]:
if hasattr(packet, "payload") and packet.payload and len(packet.payload) >= 2:
dst_hash = f"{packet.payload[0]: 02X}"
src_hash = f"{packet.payload[1]: 02X}"
dst_hash = f"{packet.payload[0]:02X}"
src_hash = f"{packet.payload[1]:02X}"
# ADVERT packets have source identifier as first byte
elif payload_type == PAYLOAD_TYPE_ADVERT:
if hasattr(packet, "payload") and packet.payload and len(packet.payload) >= 1:
src_hash = f"{packet.payload[0]: 02X}"
src_hash = f"{packet.payload[0]:02X}"
# Record packet for charts
packet_record = {
@@ -211,9 +211,9 @@ class RepeaterHandler(BaseHandler):
"path_hash": path_hash,
"src_hash": src_hash,
"dst_hash": dst_hash,
"original_path": ([f"{b: 02X}" for b in original_path] if original_path else None),
"original_path": ([f"{b:02X}" for b in original_path] if original_path else None),
"forwarded_path": (
[f"{b: 02X}" for b in forwarded_path] if forwarded_path is not None else None
[f"{b:02X}" for b in forwarded_path] if forwarded_path is not None else None
),
}
@@ -239,6 +239,18 @@ class RepeaterHandler(BaseHandler):
if len(self.recent_packets) > self.max_recent_packets:
self.recent_packets.pop(0)
def log_trace_record(self, packet_record: dict) -> None:
self.recent_packets.append(packet_record)
self.rx_count += 1
if packet_record.get("transmitted", False):
self.forwarded_count += 1
else:
self.dropped_count += 1
if len(self.recent_packets) > self.max_recent_packets:
self.recent_packets.pop(0)
def cleanup_cache(self):
now = time.time()
@@ -409,7 +421,7 @@ class RepeaterHandler(BaseHandler):
next_hop = packet.path[0]
if next_hop != self.local_hash:
logger.debug(
f"Direct: not our hop (next={next_hop: 02X}, local={self.local_hash: 02X})"
f"Direct: not our hop (next={next_hop:02X}, local={self.local_hash:02X})"
)
return None
@@ -417,8 +429,8 @@ class RepeaterHandler(BaseHandler):
packet.path = bytearray(packet.path[1:])
packet.path_len = len(packet.path)
old_path = [f"{b: 02X}" for b in original_path]
new_path = [f"{b: 02X}" for b in packet.path]
old_path = [f"{b:02X}" for b in original_path]
new_path = [f"{b:02X}" for b in packet.path]
logger.debug(f"Direct: forwarding, path {old_path} -> {new_path}")
return packet
@@ -483,8 +495,8 @@ class RepeaterHandler(BaseHandler):
score_multiplier = max(0.2, 1.0 - score)
delay_s = delay_s * score_multiplier
logger.debug(
f"Congestion detected (delay >= 50ms), score={score: .2f}, "
f"delay multiplier={score_multiplier: .2f}"
f"Congestion detected (delay >= 50ms), score={score:.2f}, "
f"delay multiplier={score_multiplier:.2f}"
)
# Cap at 5 seconds maximum
@@ -492,7 +504,7 @@ class RepeaterHandler(BaseHandler):
logger.debug(
f"Route={'FLOOD' if route_type == ROUTE_TYPE_FLOOD else 'DIRECT'}, "
f"len={packet_len}B, airtime={airtime_ms: .1f}ms, delay={delay_s: .3f}s"
f"len={packet_len}B, airtime={airtime_ms:.1f}ms, delay={delay_s:.3f}s"
)
return delay_s
@@ -530,7 +542,7 @@ class RepeaterHandler(BaseHandler):
self.airtime_mgr.record_tx(airtime_ms)
packet_size = len(fwd_pkt.payload)
logger.info(
f"Retransmitted packet ({packet_size} bytes, {airtime_ms: .1f}ms airtime)"
f"Retransmitted packet ({packet_size} bytes, {airtime_ms:.1f}ms airtime)"
)
except Exception as e:
logger.error(f"Retransmit failed: {e}")
@@ -549,8 +561,8 @@ class RepeaterHandler(BaseHandler):
# Check if interval has elapsed
if time_since_last_advert >= interval_seconds:
logger.info(
f"Periodic advert interval elapsed ({time_since_last_advert: .0f}s >= "
f"{interval_seconds: .0f}s). Sending advert..."
f"Periodic advert interval elapsed ({time_since_last_advert:.0f}s >= "
f"{interval_seconds:.0f}s). Sending advert..."
)
try:
# Call the send_advert function
@@ -564,10 +576,6 @@ class RepeaterHandler(BaseHandler):
logger.error(f"Error sending periodic advert: {e}", exc_info=True)
def get_noise_floor(self) -> Optional[float]:
"""
Get the current noise floor (instantaneous RSSI) from the radio in dBm.
Returns None if radio is not available or reading fails.
"""
try:
radio = self.dispatcher.radio if self.dispatcher else None
if radio and hasattr(radio, 'get_noise_floor'):
@@ -599,7 +607,7 @@ class RepeaterHandler(BaseHandler):
noise_floor_dbm = self.get_noise_floor()
stats = {
"local_hash": f"0x{self.local_hash: 02x}",
"local_hash": f"0x{self.local_hash:02x}",
"duplicate_cache_size": len(self.seen_packets),
"cache_ttl": self.cache_ttl,
"rx_count": self.rx_count,
+429 -4
View File
@@ -1,9 +1,13 @@
import asyncio
import json
import logging
import os
import re
import threading
import time
from collections import deque
from datetime import datetime
from typing import Callable, Optional
from typing import Callable, Optional, Dict, Any
import cherrypy
from pymc_core.protocol.utils import PAYLOAD_TYPES, ROUTE_TYPES
@@ -40,6 +44,228 @@ class LogBuffer(logging.Handler):
_log_buffer = LogBuffer(max_lines=100)
class CADCalibrationEngine:
"""Real-time CAD calibration engine"""
def __init__(self, daemon_instance=None, event_loop=None):
self.daemon_instance = daemon_instance
self.event_loop = event_loop
self.running = False
self.results = {}
self.current_test = None
self.progress = {"current": 0, "total": 0}
self.clients = set() # SSE clients
self.calibration_thread = None
def get_test_ranges(self, spreading_factor: int):
"""Get CAD test ranges based on spreading factor - comprehensive coverage"""
sf_ranges = {
7: (range(17, 26, 1), range(7, 15, 1)), # Full range coverage
8: (range(17, 26, 1), range(7, 15, 1)), # Full range coverage
9: (range(19, 28, 1), range(8, 16, 1)), # Full range coverage
10: (range(21, 30, 1), range(9, 17, 1)), # Full range coverage
11: (range(23, 32, 1), range(10, 18, 1)), # Full range coverage
12: (range(25, 34, 1), range(11, 19, 1)), # Full range coverage
}
return sf_ranges.get(spreading_factor, sf_ranges[8])
async def test_cad_config(self, radio, det_peak: int, det_min: int, samples: int = 8) -> Dict[str, Any]:
"""Test a single CAD configuration with multiple samples"""
detections = 0
for _ in range(samples):
try:
result = await radio.perform_cad(det_peak=det_peak, det_min=det_min, timeout=0.3)
if result:
detections += 1
except Exception:
pass
await asyncio.sleep(0.01) # Reduced sleep time
return {
'det_peak': det_peak,
'det_min': det_min,
'samples': samples,
'detections': detections,
'detection_rate': (detections / samples) * 100,
}
def broadcast_to_clients(self, data):
"""Send data to all connected SSE clients"""
# Store the message for clients to pick up
self.last_message = data
# Also store in a queue for clients to consume
if not hasattr(self, 'message_queue'):
self.message_queue = []
self.message_queue.append(data)
def calibration_worker(self, samples: int, delay_ms: int):
"""Worker thread for calibration process"""
try:
# Get radio from daemon instance
if not self.daemon_instance:
self.broadcast_to_clients({"type": "error", "message": "No daemon instance available"})
return
radio = getattr(self.daemon_instance, 'radio', None)
if not radio:
self.broadcast_to_clients({"type": "error", "message": "Radio instance not available"})
return
if not hasattr(radio, 'perform_cad'):
self.broadcast_to_clients({"type": "error", "message": "Radio does not support CAD"})
return
# Get spreading factor from daemon instance
config = getattr(self.daemon_instance, 'config', {})
radio_config = config.get("radio", {})
sf = radio_config.get("spreading_factor", 8)
# Get test ranges
peak_range, min_range = self.get_test_ranges(sf)
total_tests = len(peak_range) * len(min_range)
self.progress = {"current": 0, "total": total_tests}
self.broadcast_to_clients({
"type": "status",
"message": f"Starting calibration: SF{sf}, {total_tests} tests",
"test_ranges": {
"peak_min": min(peak_range),
"peak_max": max(peak_range),
"min_min": min(min_range),
"min_max": max(min_range),
"spreading_factor": sf,
"total_tests": total_tests
}
})
current = 0
import random
peak_list = list(peak_range)
min_list = list(min_range)
# Create all test combinations
test_combinations = []
for det_peak in peak_list:
for det_min in min_list:
test_combinations.append((det_peak, det_min))
# Sort by distance from center for center-out pattern
peak_center = (max(peak_list) + min(peak_list)) / 2
min_center = (max(min_list) + min(min_list)) / 2
def distance_from_center(combo):
peak, min_val = combo
return ((peak - peak_center) ** 2 + (min_val - min_center) ** 2) ** 0.5
# Sort by distance from center
test_combinations.sort(key=distance_from_center)
band_size = max(1, len(test_combinations) // 8) # Create 8 bands
randomized_combinations = []
for i in range(0, len(test_combinations), band_size):
band = test_combinations[i:i + band_size]
random.shuffle(band) # Randomize within each band
randomized_combinations.extend(band)
# Run calibration in event loop with center-out randomized pattern
if self.event_loop:
for det_peak, det_min in randomized_combinations:
if not self.running:
break
current += 1
self.progress["current"] = current
# Update progress
self.broadcast_to_clients({
"type": "progress",
"current": current,
"total": total_tests,
"peak": det_peak,
"min": det_min
})
# Run the test
future = asyncio.run_coroutine_threadsafe(
self.test_cad_config(radio, det_peak, det_min, samples),
self.event_loop
)
try:
result = future.result(timeout=30) # 30 second timeout per test
# Store result
key = f"{det_peak}-{det_min}"
self.results[key] = result
# Send result to clients
self.broadcast_to_clients({
"type": "result",
**result
})
except Exception as e:
logger.error(f"CAD test failed for peak={det_peak}, min={det_min}: {e}")
# Delay between tests
if self.running and delay_ms > 0:
time.sleep(delay_ms / 1000.0)
if self.running:
# Find best result
best_result = None
if self.results:
best_result = max(self.results.values(), key=lambda x: x['detection_rate'])
self.broadcast_to_clients({
"type": "completed",
"message": "Calibration completed",
"results": {"best": best_result} if best_result else None
})
else:
self.broadcast_to_clients({"type": "status", "message": "Calibration stopped"})
except Exception as e:
logger.error(f"Calibration worker error: {e}")
self.broadcast_to_clients({"type": "error", "message": str(e)})
finally:
self.running = False
def start_calibration(self, samples: int = 8, delay_ms: int = 100):
"""Start calibration process"""
if self.running:
return False
self.running = True
self.results.clear()
self.progress = {"current": 0, "total": 0}
self.clear_message_queue() # Clear any old messages
# Start calibration in separate thread
self.calibration_thread = threading.Thread(
target=self.calibration_worker,
args=(samples, delay_ms)
)
self.calibration_thread.daemon = True
self.calibration_thread.start()
return True
def stop_calibration(self):
"""Stop calibration process"""
self.running = False
if self.calibration_thread:
self.calibration_thread.join(timeout=2)
def clear_message_queue(self):
"""Clear the message queue when starting a new calibration"""
if hasattr(self, 'message_queue'):
self.message_queue.clear()
class APIEndpoints:
def __init__(
@@ -48,12 +274,19 @@ class APIEndpoints:
send_advert_func: Optional[Callable] = None,
config: Optional[dict] = None,
event_loop=None,
daemon_instance=None,
config_path=None,
):
self.stats_getter = stats_getter
self.send_advert_func = send_advert_func
self.config = config or {}
self.event_loop = event_loop # Store reference to main event loop
self.event_loop = event_loop
self.daemon_instance = daemon_instance
self._config_path = config_path or '/etc/pymc_repeater/config.yaml'
# Initialize CAD calibration engine
self.cad_calibration = CADCalibrationEngine(daemon_instance, event_loop)
@cherrypy.expose
@cherrypy.tools.json_out()
@@ -62,6 +295,13 @@ class APIEndpoints:
try:
stats = self.stats_getter() if self.stats_getter else {}
stats["version"] = __version__
# Add pyMC_Core version
try:
import pymc_core
stats["core_version"] = pymc_core.__version__
except ImportError:
stats["core_version"] = "unknown"
return stats
except Exception as e:
@@ -167,6 +407,181 @@ class APIEndpoints:
logger.error(f"Error fetching logs: {e}")
return {"error": str(e), "logs": []}
# CAD Calibration endpoints
@cherrypy.expose
@cherrypy.tools.json_out()
@cherrypy.tools.json_in()
def cad_calibration_start(self):
"""Start CAD calibration"""
if cherrypy.request.method != "POST":
return {"success": False, "error": "Method not allowed"}
try:
data = cherrypy.request.json or {}
samples = data.get("samples", 8)
delay = data.get("delay", 100)
if self.cad_calibration.start_calibration(samples, delay):
return {"success": True, "message": "Calibration started"}
else:
return {"success": False, "error": "Calibration already running"}
except Exception as e:
logger.error(f"Error starting CAD calibration: {e}")
return {"success": False, "error": str(e)}
@cherrypy.expose
@cherrypy.tools.json_out()
def cad_calibration_stop(self):
"""Stop CAD calibration"""
if cherrypy.request.method != "POST":
return {"success": False, "error": "Method not allowed"}
try:
self.cad_calibration.stop_calibration()
return {"success": True, "message": "Calibration stopped"}
except Exception as e:
logger.error(f"Error stopping CAD calibration: {e}")
return {"success": False, "error": str(e)}
@cherrypy.expose
@cherrypy.tools.json_out()
@cherrypy.tools.json_in()
def save_cad_settings(self):
"""Save CAD calibration settings to config"""
if cherrypy.request.method != "POST":
return {"success": False, "error": "Method not allowed"}
try:
data = cherrypy.request.json or {}
peak = data.get("peak")
min_val = data.get("min_val")
detection_rate = data.get("detection_rate", 0)
if peak is None or min_val is None:
return {"success": False, "error": "Missing peak or min_val parameters"}
# Update the radio immediately if available
if self.daemon_instance and hasattr(self.daemon_instance, 'radio') and self.daemon_instance.radio:
if hasattr(self.daemon_instance.radio, 'set_custom_cad_thresholds'):
self.daemon_instance.radio.set_custom_cad_thresholds(peak=peak, min_val=min_val)
logger.info(f"Applied CAD settings to radio: peak={peak}, min={min_val}")
# Update the in-memory config
if "radio" not in self.config:
self.config["radio"] = {}
if "cad" not in self.config["radio"]:
self.config["radio"]["cad"] = {}
self.config["radio"]["cad"]["peak_threshold"] = peak
self.config["radio"]["cad"]["min_threshold"] = min_val
# Save to config file
config_path = getattr(self, '_config_path', '/etc/pymc_repeater/config.yaml')
self._save_config_to_file(config_path)
logger.info(f"Saved CAD settings to config: peak={peak}, min={min_val}, rate={detection_rate:.1f}%")
return {
"success": True,
"message": f"CAD settings saved: peak={peak}, min={min_val}",
"settings": {"peak": peak, "min_val": min_val, "detection_rate": detection_rate}
}
except Exception as e:
logger.error(f"Error saving CAD settings: {e}")
return {"success": False, "error": str(e)}
def _save_config_to_file(self, config_path):
"""Save current config to YAML file"""
try:
import yaml
import os
# Ensure directory exists
os.makedirs(os.path.dirname(config_path), exist_ok=True)
# Write config to file
with open(config_path, 'w') as f:
yaml.dump(self.config, f, default_flow_style=False, indent=2)
logger.info(f"Configuration saved to {config_path}")
except Exception as e:
logger.error(f"Failed to save config to {config_path}: {e}")
raise
@cherrypy.expose
def cad_calibration_stream(self):
"""Server-Sent Events stream for real-time updates"""
cherrypy.response.headers['Content-Type'] = 'text/event-stream'
cherrypy.response.headers['Cache-Control'] = 'no-cache'
cherrypy.response.headers['Connection'] = 'keep-alive'
cherrypy.response.headers['Access-Control-Allow-Origin'] = '*'
def generate():
if not hasattr(self.cad_calibration, 'message_queue'):
self.cad_calibration.message_queue = []
try:
yield f"data: {json.dumps({'type': 'connected', 'message': 'Connected to CAD calibration stream'})}\n\n"
if self.cad_calibration.running:
config = getattr(self.cad_calibration.daemon_instance, 'config', {})
radio_config = config.get("radio", {})
sf = radio_config.get("spreading_factor", 8)
peak_range, min_range = self.cad_calibration.get_test_ranges(sf)
total_tests = len(peak_range) * len(min_range)
status_message = {
"type": "status",
"message": f"Calibration in progress: SF{sf}, {total_tests} tests",
"test_ranges": {
"peak_min": min(peak_range),
"peak_max": max(peak_range),
"min_min": min(min_range),
"min_max": max(min_range),
"spreading_factor": sf,
"total_tests": total_tests
}
}
yield f"data: {json.dumps(status_message)}\n\n"
last_message_index = len(self.cad_calibration.message_queue)
while True:
current_queue_length = len(self.cad_calibration.message_queue)
if current_queue_length > last_message_index:
for i in range(last_message_index, current_queue_length):
message = self.cad_calibration.message_queue[i]
yield f"data: {json.dumps(message)}\n\n"
last_message_index = current_queue_length
else:
yield f"data: {json.dumps({'type': 'keepalive'})}\n\n"
time.sleep(0.5)
except Exception as e:
logger.error(f"SSE stream error: {e}")
finally:
pass
return generate()
cad_calibration_stream._cp_config = {'response.stream': True}
class StatsApp:
@@ -179,6 +594,8 @@ class StatsApp:
send_advert_func: Optional[Callable] = None,
config: Optional[dict] = None,
event_loop=None,
daemon_instance=None,
config_path=None,
):
self.stats_getter = stats_getter
@@ -189,7 +606,7 @@ class StatsApp:
self.config = config or {}
# Create nested API object for routing
self.api = APIEndpoints(stats_getter, send_advert_func, self.config, event_loop)
self.api = APIEndpoints(stats_getter, send_advert_func, self.config, event_loop, daemon_instance, config_path)
# Load template on init
if template_dir:
@@ -231,6 +648,11 @@ class StatsApp:
"""Serve help documentation."""
return self._serve_template("help.html")
@cherrypy.expose
def cad_calibration(self):
"""Serve CAD calibration page."""
return self._serve_template("cad-calibration.html")
def _serve_template(self, template_name: str):
"""Serve HTML template with stats."""
if not self.template_dir:
@@ -270,6 +692,7 @@ class StatsApp:
"neighbors.html": "neighbors",
"statistics.html": "statistics",
"configuration.html": "configuration",
"cad-calibration.html": "cad-calibration",
"logs.html": "logs",
"help.html": "help",
}
@@ -400,12 +823,14 @@ class HTTPStatsServer:
send_advert_func: Optional[Callable] = None,
config: Optional[dict] = None,
event_loop=None,
daemon_instance=None,
config_path=None,
):
self.host = host
self.port = port
self.app = StatsApp(
stats_getter, template_dir, node_name, pub_key, send_advert_func, config, event_loop
stats_getter, template_dir, node_name, pub_key, send_advert_func, config, event_loop, daemon_instance, config_path
)
def start(self):
+188 -27
View File
@@ -6,6 +6,8 @@ import sys
from repeater.config import get_radio_for_board, load_config
from repeater.engine import RepeaterHandler
from repeater.http_server import HTTPStatsServer, _log_buffer
from pymc_core.node.handlers.trace import TraceHandler
from pymc_core.protocol.constants import MAX_PATH_SIZE, ROUTE_TYPE_DIRECT
logger = logging.getLogger("RepeaterDaemon")
@@ -21,15 +23,15 @@ class RepeaterDaemon:
self.local_hash = None
self.local_identity = None
self.http_server = None
self.trace_handler = None
# Setup logging
log_level = config.get("logging", {}).get("level", "INFO")
logging.basicConfig(
level=getattr(logging, log_level),
format=config.get("logging", {}).get("format"),
)
# Add log buffer handler to capture logs for web display
root_logger = logging.getLogger()
_log_buffer.setLevel(getattr(logging, log_level))
root_logger.addHandler(_log_buffer)
@@ -42,12 +44,37 @@ class RepeaterDaemon:
logger.info("Initializing radio hardware...")
try:
self.radio = get_radio_for_board(self.config)
if hasattr(self.radio, 'set_custom_cad_thresholds'):
# Load CAD settings from config, with defaults
cad_config = self.config.get("radio", {}).get("cad", {})
peak_threshold = cad_config.get("peak_threshold", 23)
min_threshold = cad_config.get("min_threshold", 11)
self.radio.set_custom_cad_thresholds(peak=peak_threshold, min_val=min_threshold)
logger.info(f"CAD thresholds set from config: peak={peak_threshold}, min={min_threshold}")
else:
logger.warning("Radio does not support CAD configuration")
if hasattr(self.radio, 'get_frequency'):
logger.info(f"Radio config - Freq: {self.radio.get_frequency():.1f}MHz")
if hasattr(self.radio, 'get_spreading_factor'):
logger.info(f"Radio config - SF: {self.radio.get_spreading_factor()}")
if hasattr(self.radio, 'get_bandwidth'):
logger.info(f"Radio config - BW: {self.radio.get_bandwidth()}kHz")
if hasattr(self.radio, 'get_coding_rate'):
logger.info(f"Radio config - CR: {self.radio.get_coding_rate()}")
if hasattr(self.radio, 'get_tx_power'):
logger.info(f"Radio config - TX Power: {self.radio.get_tx_power()}dBm")
logger.info("Radio hardware initialized")
except Exception as e:
logger.error(f"Failed to initialize radio hardware: {e}")
raise RuntimeError("Repeater requires real LoRa hardware") from e
# Create dispatcher from pymc_core
try:
from pymc_core import LocalIdentity
from pymc_core.node.dispatcher import Dispatcher
@@ -64,14 +91,14 @@ class RepeaterDaemon:
self.local_identity = local_identity
self.dispatcher.local_identity = local_identity
# Get the actual hash from the identity (first byte of public key)
pubkey = local_identity.get_public_key()
self.local_hash = pubkey[0]
logger.info(f"Local identity set: {local_identity.get_address_bytes().hex()}")
local_hash_hex = f"0x{self.local_hash: 02x}"
logger.info(f"Local node hash (from identity): {local_hash_hex}")
# Override _is_own_packet to always return False
self.dispatcher._is_own_packet = lambda pkt: False
self.repeater_handler = RepeaterHandler(
@@ -81,6 +108,16 @@ class RepeaterDaemon:
self.dispatcher.register_fallback_handler(self._repeater_callback)
logger.info("Repeater handler registered (forwarder mode)")
self.trace_handler = TraceHandler(log_fn=logger.info)
self.dispatcher.register_handler(
TraceHandler.payload_type(),
self._trace_callback,
)
logger.info("Trace handler registered for network diagnostics")
except Exception as e:
logger.error(f"Failed to initialize dispatcher: {e}")
raise
@@ -96,30 +133,151 @@ class RepeaterDaemon:
}
await self.repeater_handler(packet, metadata)
def _get_keypair(self):
"""Create a PyNaCl SigningKey for map API."""
async def _trace_callback(self, packet):
try:
from nacl.signing import SigningKey
# Only process direct route trace packets
if packet.get_route_type() != ROUTE_TYPE_DIRECT or packet.path_len >= MAX_PATH_SIZE:
return
if not self.local_identity:
return None
parsed_data = self.trace_handler._parse_trace_payload(packet.payload)
if not parsed_data.get("valid", False):
logger.warning(f"[TraceHandler] Invalid trace packet: {parsed_data.get('error', 'Unknown error')}")
return
trace_path = parsed_data["trace_path"]
trace_path_len = len(trace_path)
if self.repeater_handler:
import time
trace_path_bytes = [f"{h:02X}" for h in trace_path[:8]]
if len(trace_path) > 8:
trace_path_bytes.append("...")
path_hash = "[" + ", ".join(trace_path_bytes) + "]"
path_snrs = []
path_snr_details = []
for i in range(packet.path_len):
if i < len(packet.path):
snr_val = packet.path[i]
snr_signed = snr_val if snr_val < 128 else snr_val - 256
snr_db = snr_signed / 4.0
path_snrs.append(f"{snr_val}({snr_db:.1f}dB)")
if i < len(trace_path):
path_snr_details.append({
"hash": f"{trace_path[i]:02X}",
"snr_raw": snr_val,
"snr_db": snr_db
})
packet_record = {
"timestamp": time.time(),
"type": packet.get_payload_type(), # 0x09 for trace
"route": packet.get_route_type(), # Should be direct (1)
"length": len(packet.payload or b""),
"rssi": getattr(packet, "rssi", 0),
"snr": getattr(packet, "snr", 0.0),
"score": self.repeater_handler.calculate_packet_score(
getattr(packet, "snr", 0.0),
len(packet.payload or b""),
self.repeater_handler.radio_config.get("spreading_factor", 8)
),
"tx_delay_ms": 0,
"transmitted": False,
"is_duplicate": False,
"packet_hash": packet.calculate_packet_hash().hex()[:16],
"drop_reason": "trace_received",
"path_hash": path_hash,
"src_hash": None,
"dst_hash": None,
"original_path": [f"{h:02X}" for h in trace_path],
"forwarded_path": None,
# Add trace-specific SNR path information
"path_snrs": path_snrs, # ["58(14.5dB)", "19(4.8dB)"]
"path_snr_details": path_snr_details, # [{"hash": "29", "snr_raw": 58, "snr_db": 14.5}]
"is_trace": True,
}
self.repeater_handler.log_trace_record(packet_record)
path_snrs = []
path_hashes = []
for i in range(packet.path_len):
if i < len(packet.path):
snr_val = packet.path[i]
snr_signed = snr_val if snr_val < 128 else snr_val - 256
snr_db = snr_signed / 4.0
path_snrs.append(f"{snr_val}({snr_db:.1f}dB)")
if i < len(trace_path):
path_hashes.append(f"0x{trace_path[i]:02x}")
parsed_data["snr"] = packet.get_snr()
parsed_data["rssi"] = getattr(packet, "rssi", 0)
formatted_response = self.trace_handler._format_trace_response(parsed_data)
logger.info(f"[TraceHandler] {formatted_response}")
logger.info(f"[TraceHandler] Path SNRs: [{', '.join(path_snrs)}], Hashes: [{', '.join(path_hashes)}]")
if (packet.path_len < trace_path_len and
len(trace_path) > packet.path_len and
trace_path[packet.path_len] == self.local_hash and
self.repeater_handler and not self.repeater_handler.is_duplicate(packet)):
if self.repeater_handler and hasattr(self.repeater_handler, 'recent_packets'):
packet_hash = packet.calculate_packet_hash().hex()[:16]
for record in reversed(self.repeater_handler.recent_packets):
if record.get("packet_hash") == packet_hash:
record["transmitted"] = True
record["drop_reason"] = "trace_forwarded"
break
current_snr = packet.get_snr()
snr_scaled = int(current_snr * 4)
if snr_scaled > 127:
snr_scaled = 127
elif snr_scaled < -128:
snr_scaled = -128
# Get the seed from config
identity_key = self.config.get("mesh", {}).get("identity_key")
if not identity_key:
return None
# Convert to bytes if it's a hex string, otherwise use as-is
if isinstance(identity_key, str):
seed_bytes = bytes.fromhex(identity_key)
snr_byte = snr_scaled if snr_scaled >= 0 else (256 + snr_scaled)
while len(packet.path) <= packet.path_len:
packet.path.append(0)
packet.path[packet.path_len] = snr_byte
packet.path_len += 1
logger.info(f"[TraceHandler] Forwarding trace, stored SNR {current_snr:.1f}dB at position {packet.path_len-1}")
# Mark as seen and forward directly (bypass normal routing, no ACK required)
self.repeater_handler.mark_seen(packet)
if self.dispatcher:
await self.dispatcher.send_packet(packet, wait_for_ack=False)
else:
seed_bytes = identity_key
# Show why we didn't forward
if packet.path_len >= trace_path_len:
logger.info(f"[TraceHandler] Trace completed (reached end of path)")
elif len(trace_path) <= packet.path_len:
logger.info(f"[TraceHandler] Path index out of bounds")
elif trace_path[packet.path_len] != self.local_hash:
expected_hash = trace_path[packet.path_len] if packet.path_len < len(trace_path) else None
logger.info(f"[TraceHandler] Not our turn (next hop: 0x{expected_hash:02x})")
elif self.repeater_handler and self.repeater_handler.is_duplicate(packet):
logger.info(f"[TraceHandler] Duplicate packet, ignoring")
signing_key = SigningKey(seed_bytes)
return signing_key
except Exception as e:
logger.warning(f"Failed to create keypair for map API: {e}")
return None
logger.error(f"[TraceHandler] Error processing trace packet: {e}")
def get_stats(self) -> dict:
@@ -165,7 +323,7 @@ class RepeaterDaemon:
)
# Send via dispatcher
await self.dispatcher.send_packet(packet)
await self.dispatcher.send_packet(packet, wait_for_ack=False)
# Mark our own advert as seen to prevent re-forwarding it
if self.repeater_handler:
@@ -202,7 +360,6 @@ class RepeaterDaemon:
else:
pub_key_formatted = pub_key_hex
# Get the current event loop (the main loop where the radio was initialized)
current_loop = asyncio.get_event_loop()
self.http_server = HTTPStatsServer(
@@ -213,8 +370,10 @@ class RepeaterDaemon:
node_name=node_name,
pub_key=pub_key_formatted,
send_advert_func=self.send_advert,
config=self.config, # Pass the config reference
event_loop=current_loop, # Pass the main event loop
config=self.config,
event_loop=current_loop,
daemon_instance=self,
config_path=getattr(self, 'config_path', '/etc/pymc_repeater/config.yaml'),
)
try:
@@ -250,12 +409,14 @@ def main():
# Load configuration
config = load_config(args.config)
config_path = args.config if args.config else '/etc/pymc_repeater/config.yaml'
if args.log_level:
config["logging"]["level"] = args.log_level
# Don't initialize radio here - it will be done inside the async event loop
daemon = RepeaterDaemon(config, radio=None)
daemon.config_path = config_path
# Run
try:
File diff suppressed because it is too large. Load diff
+9
View File
@@ -22,6 +22,15 @@
Configuration is read-only. To modify settings, edit the config file and restart the daemon.
</div>
<!-- CAD Calibration Tool -->
<div class="info-box" style="background: var(--accent-color); color: white; border: none;">
<strong>CAD Calibration Tool Available</strong>
<p style="margin: 8px 0 0 0;">
Optimize your Channel Activity Detection settings.
<a href="/cad-calibration" style="color: white; text-decoration: underline;">Launch CAD Calibration Tool →</a>
</p>
</div>
<!-- Radio Configuration -->
<h2>Radio Settings</h2>
<div class="config-section">
+150 -4
View File
@@ -229,6 +229,61 @@
<span class="signal-bars ${className}" title="Signal: ${className.replace('signal-', '')}">${'<span class="signal-bar"></span>'.repeat(4)}</span>
<span class="snr-value">${snr.toFixed(1)} dB</span>
</div>`;
}
// Helper function to display SNR for trace packets with path information
function getTraceSnrDisplay(pkt, localHash) {
if (!pkt.is_trace || !pkt.path_snr_details || pkt.path_snr_details.length === 0) {
// Regular packet or no path SNR data
return getSignalBars(pkt.snr);
}
// Build trace path SNR display
let pathSnrHtml = `<div class="trace-snr-container">`;
// Show received packet SNR first
pathSnrHtml += `<div class="rx-snr">${getSignalBars(pkt.snr)}</div>`;
// Show path SNRs if available
if (pkt.path_snr_details.length > 0) {
pathSnrHtml += `<div class="path-snrs">`;
pathSnrHtml += `<div class="path-snr-label">Path (${pkt.path_snr_details.length} hops):</div>`;
// Handle many hops - show first few and indicate if there are more
const maxDisplayHops = 4;
const hopsToShow = pkt.path_snr_details.slice(0, maxDisplayHops);
const hasMoreHops = pkt.path_snr_details.length > maxDisplayHops;
hopsToShow.forEach((pathSnr, index) => {
const isMyHash = localHash && pathSnr.hash === localHash;
const hashClass = isMyHash ? 'my-hash' : 'path-hash';
// Get signal quality class for color coding
let snrClass = 'snr-poor';
if (pathSnr.snr_db >= 10) snrClass = 'snr-excellent';
else if (pathSnr.snr_db >= 5) snrClass = 'snr-good';
else if (pathSnr.snr_db >= 0) snrClass = 'snr-fair';
pathSnrHtml += `<div class="path-snr-item">
<span class="hop-index">${index + 1}.</span>
<span class="${hashClass}">${pathSnr.hash}</span>
<span class="snr-value ${snrClass}">${pathSnr.snr_db.toFixed(1)}dB</span>
</div>`;
});
// Show indicator if there are more hops
if (hasMoreHops) {
const remainingCount = pkt.path_snr_details.length - maxDisplayHops;
pathSnrHtml += `<div class="path-snr-item">
<span class="more-hops">+${remainingCount} more hop${remainingCount > 1 ? 's' : ''}</span>
</div>`;
}
pathSnrHtml += `</div>`;
}
pathSnrHtml += `</div>`;
return pathSnrHtml;
} function updatePacketTable(packets, localHash) {
const tbody = document.getElementById('packet-table');
@@ -244,7 +299,13 @@
}
tbody.innerHTML = packets.slice(-20).map(pkt => {
const time = new Date(pkt.timestamp * 1000).toLocaleTimeString();
const time = new Date(pkt.timestamp * 1000).toLocaleTimeString('en-US', {
hour12: false,
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
fractionalSecondDigits: 3
});
// Match pyMC_core PAYLOAD_TYPES exactly (from constants.py)
const typeNames = {
0: 'REQ',
@@ -327,7 +388,7 @@
<td data-label="Len">${pkt.length}B</td>
<td data-label="Path/Hashes">${pathHashesHtml}</td>
<td data-label="RSSI">${pkt.rssi}</td>
<td data-label="SNR">${getSignalBars(pkt.snr)}</td>
<td data-label="SNR">${getTraceSnrDisplay(pkt, localHash)}</td>
<td data-label="Score"><span class="score">${pkt.score.toFixed(2)}</span></td>
<td data-label="TX Delay">${pkt.tx_delay_ms.toFixed(0)}ms</td>
<td data-label="Status">${statusHtml}</td>
@@ -337,7 +398,13 @@
// Add duplicate rows (always visible)
if (hasDuplicates) {
mainRow += pkt.duplicates.map(dupe => {
const dupeTime = new Date(dupe.timestamp * 1000).toLocaleTimeString();
const dupeTime = new Date(dupe.timestamp * 1000).toLocaleTimeString('en-US', {
hour12: false,
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
fractionalSecondDigits: 3
});
const dupeRoute = routeNames[dupe.route] || `UNKNOWN_${dupe.route}`;
// Format duplicate path/hashes - match main row format
@@ -375,7 +442,7 @@
<td data-label="Len">${dupe.length}B</td>
<td data-label="Path/Hashes">${dupePathHashesHtml}</td>
<td data-label="RSSI">${dupe.rssi}</td>
<td data-label="SNR">${getSignalBars(dupe.snr)}</td>
<td data-label="SNR">${getTraceSnrDisplay(dupe, localHash)}</td>
<td data-label="Score"><span class="score">${dupe.score.toFixed(2)}</span></td>
<td data-label="TX Delay">${dupe.tx_delay_ms.toFixed(0)}ms</td>
<td data-label="Status">${dupeStatusHtml}</td>
@@ -589,6 +656,85 @@
font-size: 0.8em;
color: #999;
white-space: nowrap;
}
/* Trace packet SNR display */
.trace-snr-container {
display: flex;
flex-direction: column;
gap: 6px;
align-items: center;
min-width: 120px;
}
.rx-snr {
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
padding-bottom: 4px;
}
.path-snrs {
display: flex;
flex-direction: column;
gap: 2px;
font-size: 0.85em;
width: 100%;
}
.path-snr-label {
font-size: 0.75em;
color: #888;
text-align: center;
margin-bottom: 2px;
font-weight: 500;
}
.path-snr-item {
display: flex;
align-items: center;
gap: 4px;
white-space: nowrap;
justify-content: space-between;
}
.hop-index {
font-size: 0.7em;
color: #666;
min-width: 16px;
text-align: right;
}
.path-snr-item .path-hash {
font-family: 'Courier New', monospace;
font-size: 0.75em;
color: #dcdcaa;
background: rgba(220, 220, 170, 0.1);
padding: 1px 3px;
border-radius: 3px;
min-width: 24px;
text-align: center;
}
.path-snr-item .my-hash {
font-family: 'Courier New', monospace;
font-size: 0.75em;
background: rgba(86, 156, 214, 0.2);
color: #569cd6;
font-weight: 700;
padding: 1px 3px;
border-radius: 3px;
min-width: 24px;
text-align: center;
}
.path-snr-item .snr-value {
font-size: 0.75em;
font-weight: 500;
min-width: 48px;
text-align: right;
}
/* SNR quality color coding */
.snr-excellent { color: #4ade80; }
.snr-good { color: #4ec9b0; }
.snr-fair { color: #fbbf24; }
.snr-poor { color: #f48771; }
.more-hops {
font-size: 0.7em;
color: #888;
font-style: italic;
text-align: center;
width: 100%;
} /* Path/Hashes column layout */
.path-info {
display: flex;
+31 -11
View File
@@ -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