Add CAD calibration tool and endpoints for real-time detection optimization

This commit is contained in:
Lloyd
2025-10-31 22:10:45 +00:00
parent 2564f4b772
commit 3fc295e26c
4 changed files with 915 additions and 1 deletions

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,190 @@ class LogBuffer(logging.Handler):
_log_buffer = LogBuffer(max_lines=100)
class CADCalibrationEngine:
"""Real-time CAD calibration engine"""
def __init__(self, stats_getter: Optional[Callable] = None, event_loop=None):
self.stats_getter = stats_getter
self.event_loop = event_loop
self.running = False
self.results = {}
self.current_test = None
self.progress = {"current": 0, "total": 0}
self.clients = set() # SSE clients
self.calibration_thread = None
def get_test_ranges(self, spreading_factor: int):
"""Get CAD test ranges based on spreading factor"""
sf_ranges = {
7: (range(16, 29, 1), range(6, 15, 1)),
8: (range(16, 29, 1), range(6, 15, 1)),
9: (range(18, 31, 1), range(7, 16, 1)),
10: (range(20, 33, 1), range(8, 16, 1)),
11: (range(22, 35, 1), range(9, 17, 1)),
12: (range(24, 37, 1), range(10, 18, 1)),
}
return sf_ranges.get(spreading_factor, sf_ranges[8])
async def test_cad_config(self, radio, det_peak: int, det_min: int, samples: int = 8) -> Dict[str, Any]:
"""Test a single CAD configuration with multiple samples"""
detections = 0
for _ in range(samples):
try:
result = await radio.perform_cad(det_peak=det_peak, det_min=det_min, timeout=0.6)
if result:
detections += 1
except Exception:
pass
await asyncio.sleep(0.03)
return {
'det_peak': det_peak,
'det_min': det_min,
'samples': samples,
'detections': detections,
'detection_rate': (detections / samples) * 100,
}
def broadcast_to_clients(self, data):
"""Send data to all connected SSE clients"""
message = f"data: {json.dumps(data)}\n\n"
for client in self.clients.copy():
try:
client.write(message.encode())
client.flush()
except Exception:
self.clients.discard(client)
def calibration_worker(self, samples: int, delay_ms: int):
"""Worker thread for calibration process"""
try:
# Get radio from stats
if not self.stats_getter:
self.broadcast_to_clients({"type": "error", "message": "No stats getter available"})
return
stats = self.stats_getter()
if not stats or "radio_instance" not in stats:
self.broadcast_to_clients({"type": "error", "message": "Radio instance not available"})
return
radio = stats["radio_instance"]
if not hasattr(radio, 'perform_cad'):
self.broadcast_to_clients({"type": "error", "message": "Radio does not support CAD"})
return
# Get spreading factor
config = stats.get("config", {})
radio_config = config.get("radio", {})
sf = radio_config.get("spreading_factor", 8)
# Get test ranges
peak_range, min_range = self.get_test_ranges(sf)
total_tests = len(peak_range) * len(min_range)
self.progress = {"current": 0, "total": total_tests}
self.broadcast_to_clients({
"type": "status",
"message": f"Starting calibration: SF{sf}, {total_tests} tests"
})
current = 0
# Run calibration in event loop
if self.event_loop:
for det_peak in peak_range:
if not self.running:
break
for det_min in min_range:
if not self.running:
break
current += 1
self.progress["current"] = current
# Update progress
self.broadcast_to_clients({
"type": "progress",
"current": current,
"total": total_tests,
"peak": det_peak,
"min": det_min
})
# Run the test
future = asyncio.run_coroutine_threadsafe(
self.test_cad_config(radio, det_peak, det_min, samples),
self.event_loop
)
try:
result = future.result(timeout=30) # 30 second timeout per test
# Store result
key = f"{det_peak}-{det_min}"
self.results[key] = result
# Send result to clients
self.broadcast_to_clients({
"type": "result",
**result
})
except Exception as e:
logger.error(f"CAD test failed for peak={det_peak}, min={det_min}: {e}")
# Delay between tests
if self.running and delay_ms > 0:
time.sleep(delay_ms / 1000.0)
if self.running:
self.broadcast_to_clients({"type": "completed", "message": "Calibration completed"})
else:
self.broadcast_to_clients({"type": "status", "message": "Calibration stopped"})
except Exception as e:
logger.error(f"Calibration worker error: {e}")
self.broadcast_to_clients({"type": "error", "message": str(e)})
finally:
self.running = False
def start_calibration(self, samples: int = 8, delay_ms: int = 100):
"""Start calibration process"""
if self.running:
return False
self.running = True
self.results.clear()
self.progress = {"current": 0, "total": 0}
# Start calibration in separate thread
self.calibration_thread = threading.Thread(
target=self.calibration_worker,
args=(samples, delay_ms)
)
self.calibration_thread.daemon = True
self.calibration_thread.start()
return True
def stop_calibration(self):
"""Stop calibration process"""
self.running = False
if self.calibration_thread:
self.calibration_thread.join(timeout=2)
def add_client(self, response_stream):
"""Add SSE client"""
self.clients.add(response_stream)
def remove_client(self, response_stream):
"""Remove SSE client"""
self.clients.discard(response_stream)
class APIEndpoints:
def __init__(
@@ -54,6 +242,9 @@ class APIEndpoints:
self.send_advert_func = send_advert_func
self.config = config or {}
self.event_loop = event_loop # Store reference to main event loop
# Initialize CAD calibration engine
self.cad_calibration = CADCalibrationEngine(stats_getter, event_loop)
@cherrypy.expose
@cherrypy.tools.json_out()
@@ -167,6 +358,78 @@ class APIEndpoints:
logger.error(f"Error fetching logs: {e}")
return {"error": str(e), "logs": []}
# CAD Calibration endpoints
@cherrypy.expose
@cherrypy.tools.json_out()
@cherrypy.tools.json_in()
def cad_calibration_start(self):
"""Start CAD calibration"""
if cherrypy.request.method != "POST":
return {"success": False, "error": "Method not allowed"}
try:
data = cherrypy.request.json or {}
samples = data.get("samples", 8)
delay = data.get("delay", 100)
if self.cad_calibration.start_calibration(samples, delay):
return {"success": True, "message": "Calibration started"}
else:
return {"success": False, "error": "Calibration already running"}
except Exception as e:
logger.error(f"Error starting CAD calibration: {e}")
return {"success": False, "error": str(e)}
@cherrypy.expose
@cherrypy.tools.json_out()
def cad_calibration_stop(self):
"""Stop CAD calibration"""
if cherrypy.request.method != "POST":
return {"success": False, "error": "Method not allowed"}
try:
self.cad_calibration.stop_calibration()
return {"success": True, "message": "Calibration stopped"}
except Exception as e:
logger.error(f"Error stopping CAD calibration: {e}")
return {"success": False, "error": str(e)}
@cherrypy.expose
def cad_calibration_stream(self):
"""Server-Sent Events stream for real-time updates"""
cherrypy.response.headers['Content-Type'] = 'text/event-stream'
cherrypy.response.headers['Cache-Control'] = 'no-cache'
cherrypy.response.headers['Connection'] = 'keep-alive'
cherrypy.response.headers['Access-Control-Allow-Origin'] = '*'
def generate():
# Add client to calibration engine
response = cherrypy.response
self.cad_calibration.add_client(response)
try:
# Send initial connection message
yield f"data: {json.dumps({'type': 'connected', 'message': 'Connected to CAD calibration stream'})}\n\n"
# Keep connection alive - the calibration engine will send data
while True:
time.sleep(1)
# Send keepalive every second
yield f"data: {json.dumps({'type': 'keepalive'})}\n\n"
except Exception as e:
logger.error(f"SSE stream error: {e}")
finally:
# Remove client when connection closes
self.cad_calibration.remove_client(response)
return generate()
cad_calibration_stream._cp_config = {'response.stream': True}
class StatsApp:
@@ -231,6 +494,11 @@ class StatsApp:
"""Serve help documentation."""
return self._serve_template("help.html")
@cherrypy.expose
def cad_calibration(self):
"""Serve CAD calibration page."""
return self._serve_template("cad-calibration.html")
def _serve_template(self, template_name: str):
"""Serve HTML template with stats."""
if not self.template_dir:
@@ -270,6 +538,7 @@ class StatsApp:
"neighbors.html": "neighbors",
"statistics.html": "statistics",
"configuration.html": "configuration",
"cad-calibration.html": "cad-calibration",
"logs.html": "logs",
"help.html": "help",
}

View File

@@ -291,6 +291,8 @@ class RepeaterDaemon:
stats["public_key"] = pubkey.hex()
except Exception:
stats["public_key"] = None
if self.radio:
stats["radio_instance"] = self.radio
return stats
return {}

View File

@@ -0,0 +1,634 @@
<!DOCTYPE html>
<html>
<head>
<title>pyMC Repeater - CAD Calibration</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="/static/style.css">
<style>
.calibration-controls {
background: var(--bg-secondary);
border-radius: 8px;
padding: 20px;
margin: 20px 0;
border: 1px solid var(--border-color);
}
.heatmap-container {
background: var(--bg-secondary);
border-radius: 8px;
padding: 20px;
margin: 20px 0;
border: 1px solid var(--border-color);
min-height: 400px;
}
.heatmap-grid {
display: grid;
gap: 2px;
margin: 20px 0;
font-size: 11px;
}
.heatmap-cell {
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 3px;
cursor: pointer;
transition: all 0.2s ease;
color: white;
font-weight: bold;
text-shadow: 1px 1px 1px rgba(0,0,0,0.5);
}
.heatmap-cell:hover {
transform: scale(1.1);
z-index: 10;
position: relative;
}
.heatmap-cell.testing {
animation: pulse 1s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.6; }
}
.status-bar {
display: flex;
justify-content: space-between;
align-items: center;
background: var(--bg-tertiary);
padding: 10px 15px;
border-radius: 5px;
margin: 10px 0;
}
.controls-row {
display: flex;
gap: 15px;
align-items: center;
margin: 15px 0;
flex-wrap: wrap;
}
.control-group {
display: flex;
align-items: center;
gap: 8px;
}
.control-group label {
font-weight: 500;
min-width: 80px;
}
.control-group select, .control-group input {
padding: 5px 8px;
border: 1px solid var(--border-color);
border-radius: 4px;
background: var(--bg-primary);
color: var(--text-primary);
}
.btn {
padding: 8px 16px;
border: none;
border-radius: 5px;
cursor: pointer;
font-weight: 500;
transition: all 0.2s ease;
}
.btn-primary {
background: var(--accent-color);
color: white;
}
.btn-primary:hover:not(:disabled) {
background: var(--accent-hover);
}
.btn-secondary {
background: var(--bg-tertiary);
color: var(--text-primary);
border: 1px solid var(--border-color);
}
.btn-secondary:hover:not(:disabled) {
background: var(--bg-secondary);
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.legend {
display: flex;
gap: 15px;
align-items: center;
margin: 15px 0;
font-size: 12px;
}
.legend-item {
display: flex;
align-items: center;
gap: 5px;
}
.legend-color {
width: 16px;
height: 16px;
border-radius: 2px;
}
.progress-container {
margin: 15px 0;
}
.progress-bar {
width: 100%;
height: 8px;
background: var(--bg-tertiary);
border-radius: 4px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: var(--accent-color);
transition: width 0.3s ease;
width: 0%;
}
.results-summary {
background: var(--bg-tertiary);
padding: 15px;
border-radius: 5px;
margin: 15px 0;
}
.results-row {
display: flex;
justify-content: space-between;
margin: 5px 0;
}
.tooltip {
position: absolute;
background: rgba(0,0,0,0.9);
color: white;
padding: 8px 12px;
border-radius: 4px;
font-size: 11px;
pointer-events: none;
z-index: 1000;
display: none;
}
</style>
</head>
<body>
<div class="layout">
<!-- Navigation Component -->
<!-- NAVIGATION_PLACEHOLDER -->
<!-- Main Content -->
<main class="content">
<header>
<h1>CAD Calibration</h1>
<p>Real-time Channel Activity Detection calibration tool</p>
</header>
<div class="info-box">
This tool helps you find optimal CAD thresholds for your environment. Lower detection rates (blue/green) are better for mesh networking.
</div>
<!-- Controls -->
<div class="calibration-controls">
<h3>Calibration Settings</h3>
<div class="controls-row">
<div class="control-group">
<label for="samples">Samples:</label>
<select id="samples">
<option value="4">4 (Fast)</option>
<option value="8" selected>8 (Standard)</option>
<option value="16">16 (Thorough)</option>
</select>
</div>
<div class="control-group">
<label for="delay">Delay (ms):</label>
<input type="number" id="delay" value="100" min="50" max="1000" step="50">
</div>
<button id="startBtn" class="btn btn-primary">Start Calibration</button>
<button id="stopBtn" class="btn btn-secondary" disabled>Stop</button>
<button id="resetBtn" class="btn btn-secondary">Reset</button>
</div>
<div class="status-bar">
<div>
<strong>Status:</strong> <span id="status">Ready</span>
</div>
<div>
<strong>Progress:</strong> <span id="progress">0/0</span>
</div>
<div>
<strong>Current:</strong> Peak=<span id="currentPeak">-</span>, Min=<span id="currentMin">-</span>
</div>
</div>
<div class="progress-container">
<div class="progress-bar">
<div class="progress-fill" id="progressFill"></div>
</div>
</div>
</div>
<!-- Heatmap -->
<div class="heatmap-container">
<h3>Detection Rate Heatmap</h3>
<div class="legend">
<div class="legend-item">
<div class="legend-color" style="background: #2563eb;"></div>
<span>0% (Quiet)</span>
</div>
<div class="legend-item">
<div class="legend-color" style="background: #16a34a;"></div>
<span>1-10% (Low)</span>
</div>
<div class="legend-item">
<div class="legend-color" style="background: #eab308;"></div>
<span>11-30% (Medium)</span>
</div>
<div class="legend-item">
<div class="legend-color" style="background: #dc2626;"></div>
<span>31%+ (High)</span>
</div>
<div class="legend-item">
<div class="legend-color" style="background: #6b7280;"></div>
<span>Not tested</span>
</div>
</div>
<div class="heatmap-grid" id="heatmapGrid">
<!-- Grid will be populated by JavaScript -->
</div>
</div>
<!-- Results Summary -->
<div class="results-summary" id="resultsSummary" style="display: none;">
<h3>Best Results</h3>
<div id="bestResults">
<!-- Best results will be populated here -->
</div>
</div>
</main>
</div>
<!-- Tooltip -->
<div class="tooltip" id="tooltip"></div>
<script>
let calibrationState = {
running: false,
results: new Map(),
currentIndex: 0,
totalTests: 0,
peakRange: [],
minRange: [],
samples: 8,
delay: 100
};
let eventSource = null;
function initializeCalibration() {
// Get radio config to determine ranges
fetch('/api/stats')
.then(r => r.json())
.then(data => {
const config = data.config || {};
const radio = config.radio || {};
const sf = radio.spreading_factor || 8;
// Set ranges based on spreading factor (same logic as Python tool)
const ranges = {
7: { peak: [16, 28], min: [6, 14] },
8: { peak: [16, 28], min: [6, 14] },
9: { peak: [18, 30], min: [7, 15] },
10: { peak: [20, 32], min: [8, 15] },
11: { peak: [22, 34], min: [9, 16] },
12: { peak: [24, 36], min: [10, 17] }
};
const range = ranges[sf] || ranges[8];
calibrationState.peakRange = [];
calibrationState.minRange = [];
for (let i = range.peak[0]; i <= range.peak[1]; i++) {
calibrationState.peakRange.push(i);
}
for (let i = range.min[0]; i <= range.min[1]; i++) {
calibrationState.minRange.push(i);
}
calibrationState.totalTests = calibrationState.peakRange.length * calibrationState.minRange.length;
setupHeatmapGrid();
updateStatus('Ready - SF' + sf + ' ranges loaded');
})
.catch(e => {
console.error('Error loading radio config:', e);
updateStatus('Error loading radio configuration');
});
}
function setupHeatmapGrid() {
const grid = document.getElementById('heatmapGrid');
grid.innerHTML = '';
// Set grid template columns based on min range length
grid.style.gridTemplateColumns = `60px repeat(${calibrationState.minRange.length}, 30px)`;
// Add header row
const cornerCell = document.createElement('div');
cornerCell.className = 'heatmap-cell';
cornerCell.style.background = 'transparent';
cornerCell.style.color = 'var(--text-primary)';
cornerCell.style.fontWeight = 'bold';
cornerCell.textContent = 'Peak\\Min';
grid.appendChild(cornerCell);
calibrationState.minRange.forEach(minVal => {
const headerCell = document.createElement('div');
headerCell.className = 'heatmap-cell';
headerCell.style.background = 'var(--bg-tertiary)';
headerCell.style.color = 'var(--text-primary)';
headerCell.style.fontWeight = 'bold';
headerCell.textContent = minVal;
grid.appendChild(headerCell);
});
// Add data rows
calibrationState.peakRange.forEach(peakVal => {
// Row header
const rowHeader = document.createElement('div');
rowHeader.className = 'heatmap-cell';
rowHeader.style.background = 'var(--bg-tertiary)';
rowHeader.style.color = 'var(--text-primary)';
rowHeader.style.fontWeight = 'bold';
rowHeader.textContent = peakVal;
grid.appendChild(rowHeader);
// Data cells
calibrationState.minRange.forEach(minVal => {
const cell = document.createElement('div');
cell.className = 'heatmap-cell';
cell.id = `cell-${peakVal}-${minVal}`;
cell.style.background = '#6b7280'; // Not tested
cell.textContent = '-';
cell.addEventListener('mouseenter', (e) => showTooltip(e, peakVal, minVal));
cell.addEventListener('mouseleave', hideTooltip);
grid.appendChild(cell);
});
});
}
function showTooltip(event, peak, min) {
const tooltip = document.getElementById('tooltip');
const key = `${peak}-${min}`;
const result = calibrationState.results.get(key);
let content = `Peak: ${peak}, Min: ${min}`;
if (result) {
content += `<br>Detection Rate: ${result.detection_rate.toFixed(1)}%`;
content += `<br>Detections: ${result.detections}/${result.samples}`;
content += `<br>Status: ${getStatusText(result.detection_rate)}`;
} else {
content += '<br>Not tested yet';
}
tooltip.innerHTML = content;
tooltip.style.display = 'block';
tooltip.style.left = event.pageX + 10 + 'px';
tooltip.style.top = event.pageY + 10 + 'px';
}
function hideTooltip() {
document.getElementById('tooltip').style.display = 'none';
}
function getStatusText(rate) {
if (rate === 0) return 'QUIET';
if (rate < 10) return 'LOW';
if (rate < 30) return 'MEDIUM';
return 'HIGH';
}
function getColorForRate(rate) {
if (rate === 0) return '#2563eb'; // Blue - Quiet
if (rate < 10) return '#16a34a'; // Green - Low
if (rate < 30) return '#eab308'; // Yellow - Medium
return '#dc2626'; // Red - High
}
function updateHeatmapCell(peak, min, result) {
const cell = document.getElementById(`cell-${peak}-${min}`);
if (cell) {
const rate = result.detection_rate;
cell.style.background = getColorForRate(rate);
cell.textContent = Math.round(rate);
cell.classList.remove('testing');
}
}
function markCellTesting(peak, min) {
const cell = document.getElementById(`cell-${peak}-${min}`);
if (cell) {
cell.classList.add('testing');
}
}
function updateStatus(message) {
document.getElementById('status').textContent = message;
}
function updateProgress(current, total) {
document.getElementById('progress').textContent = `${current}/${total}`;
const percentage = total > 0 ? (current / total) * 100 : 0;
document.getElementById('progressFill').style.width = percentage + '%';
}
function updateCurrentTest(peak, min) {
document.getElementById('currentPeak').textContent = peak || '-';
document.getElementById('currentMin').textContent = min || '-';
}
function updateBestResults() {
const results = Array.from(calibrationState.results.values());
if (results.length === 0) return;
// Sort by detection_rate, then by peak (lower is better)
results.sort((a, b) => {
if (a.detection_rate !== b.detection_rate) {
return a.detection_rate - b.detection_rate;
}
return a.det_peak - b.det_peak;
});
const top5 = results.slice(0, 5);
const summaryDiv = document.getElementById('resultsSummary');
const bestDiv = document.getElementById('bestResults');
bestDiv.innerHTML = '';
top5.forEach((result, index) => {
const div = document.createElement('div');
div.className = 'results-row';
div.innerHTML = `
<span>#${index + 1}: Peak=${result.det_peak}, Min=${result.det_min}</span>
<span>${result.detection_rate.toFixed(1)}% (${result.detections}/${result.samples})</span>
`;
bestDiv.appendChild(div);
});
summaryDiv.style.display = 'block';
}
function startCalibration() {
if (calibrationState.running) return;
calibrationState.running = true;
calibrationState.currentIndex = 0;
calibrationState.samples = parseInt(document.getElementById('samples').value);
calibrationState.delay = parseInt(document.getElementById('delay').value);
document.getElementById('startBtn').disabled = true;
document.getElementById('stopBtn').disabled = false;
document.getElementById('samples').disabled = true;
document.getElementById('delay').disabled = true;
updateStatus('Starting calibration...');
// Start Server-Sent Events connection
eventSource = new EventSource('/api/cad-calibration-stream');
eventSource.onmessage = function(event) {
const data = JSON.parse(event.data);
if (data.type === 'progress') {
updateProgress(data.current, data.total);
updateCurrentTest(data.peak, data.min);
if (data.peak && data.min) {
markCellTesting(data.peak, data.min);
}
}
else if (data.type === 'result') {
const key = `${data.det_peak}-${data.det_min}`;
calibrationState.results.set(key, data);
updateHeatmapCell(data.det_peak, data.det_min, data);
updateBestResults();
}
else if (data.type === 'status') {
updateStatus(data.message);
}
else if (data.type === 'completed') {
stopCalibration();
updateStatus('Calibration completed');
}
else if (data.type === 'error') {
stopCalibration();
updateStatus('Error: ' + data.message);
}
};
eventSource.onerror = function() {
stopCalibration();
updateStatus('Connection error');
};
// Start the calibration process
fetch('/api/cad-calibration-start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
samples: calibrationState.samples,
delay: calibrationState.delay
})
})
.catch(e => {
stopCalibration();
updateStatus('Failed to start calibration');
console.error('Start calibration error:', e);
});
}
function stopCalibration() {
calibrationState.running = false;
if (eventSource) {
eventSource.close();
eventSource = null;
}
// Stop the calibration process
fetch('/api/cad-calibration-stop', { method: 'POST' })
.catch(e => console.error('Stop calibration error:', e));
document.getElementById('startBtn').disabled = false;
document.getElementById('stopBtn').disabled = true;
document.getElementById('samples').disabled = false;
document.getElementById('delay').disabled = false;
updateCurrentTest();
// Remove all testing classes
document.querySelectorAll('.heatmap-cell.testing').forEach(cell => {
cell.classList.remove('testing');
});
}
function resetCalibration() {
if (calibrationState.running) {
stopCalibration();
}
calibrationState.results.clear();
calibrationState.currentIndex = 0;
setupHeatmapGrid();
updateProgress(0, calibrationState.totalTests);
updateCurrentTest();
updateStatus('Reset - Ready to start');
document.getElementById('resultsSummary').style.display = 'none';
}
// Event listeners
document.getElementById('startBtn').addEventListener('click', startCalibration);
document.getElementById('stopBtn').addEventListener('click', stopCalibration);
document.getElementById('resetBtn').addEventListener('click', resetCalibration);
// Initialize on page load
document.addEventListener('DOMContentLoaded', initializeCalibration);
// Cleanup on page unload
window.addEventListener('beforeunload', () => {
if (eventSource) {
eventSource.close();
}
});
</script>
</body>
</html>

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 for better mesh performance.
<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">