Merge pull request #8 from rightup/dev

Updated CAD calibration logic & new dialog for displaying packet information
This commit is contained in:
Lloyd
2025-11-04 14:30:41 -08:00
committed by GitHub
6 changed files with 527 additions and 21 deletions

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "pymc_repeater"
version = "1.0.2"
version = "1.0.3"
authors = [
{name = "Lloyd", email = "lloyd@rightup.co.uk"},
]
@@ -31,7 +31,7 @@ keywords = ["mesh", "networking", "lora", "repeater", "daemon", "iot"]
dependencies = [
"pymc_core[hardware]>=1.0.3",
"pymc_core[hardware]>=1.0.4",
"pyyaml>=6.0.0",
"cherrypy>=18.0.0",
]

View File

@@ -1 +1 @@
__version__ = "1.0.2"
__version__ = "1.0.3"

View File

@@ -195,6 +195,9 @@ class RepeaterHandler(BaseHandler):
# Record packet for charts
packet_record = {
"timestamp": time.time(),
"header": f"0x{packet.header:02X}" if hasattr(packet, "header") and packet.header is not None else None,
"payload": packet.payload.hex() if hasattr(packet, "payload") and packet.payload else None,
"payload_length": len(packet.payload) if hasattr(packet, "payload") and packet.payload else 0,
"type": payload_type,
"route": route_type,
"length": len(packet.payload or b""),
@@ -215,6 +218,7 @@ class RepeaterHandler(BaseHandler):
"forwarded_path": (
[f"{b:02X}" for b in forwarded_path] if forwarded_path is not None else None
),
}
# If this is a duplicate, try to attach it to the original packet

View File

@@ -58,38 +58,83 @@ class CADCalibrationEngine:
self.calibration_thread = None
def get_test_ranges(self, spreading_factor: int):
"""Get CAD test ranges based on spreading factor - comprehensive coverage"""
"""Get CAD test ranges"""
# Higher values = less sensitive, lower values = more sensitive
# Test from LESS sensitive to MORE sensitive to find the sweet spot
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
7: (range(22, 30, 1), range(12, 20, 1)),
8: (range(22, 30, 1), range(12, 20, 1)),
9: (range(24, 32, 1), range(14, 22, 1)),
10: (range(26, 34, 1), range(16, 24, 1)),
11: (range(28, 36, 1), range(18, 26, 1)),
12: (range(30, 38, 1), range(20, 28, 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"""
async def test_cad_config(self, radio, det_peak: int, det_min: int, samples: int = 20) -> Dict[str, Any]:
"""Test CAD configuration with proper spacing and baseline measurement"""
detections = 0
baseline_detections = 0
for _ in range(samples):
# First, get baseline with very insensitive settings (should detect nothing)
baseline_samples = 5
for _ in range(baseline_samples):
try:
# Use very high thresholds that should detect nothing
baseline_result = await radio.perform_cad(det_peak=35, det_min=25, timeout=0.3)
if baseline_result:
baseline_detections += 1
except Exception:
pass
await asyncio.sleep(0.1) # 100ms between baseline samples
# Wait before actual test
await asyncio.sleep(0.5)
# Now test the actual configuration
for i 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
# Variable delay to avoid sampling artifacts
delay = 0.05 + (i % 3) * 0.05 # 50ms, 100ms, 150ms rotation
await asyncio.sleep(delay)
# Calculate adjusted detection rate
baseline_rate = (baseline_detections / baseline_samples) * 100
detection_rate = (detections / samples) * 100
# Subtract baseline noise
adjusted_rate = max(0, detection_rate - baseline_rate)
return {
'det_peak': det_peak,
'det_min': det_min,
'samples': samples,
'samples': samples,
'detections': detections,
'detection_rate': (detections / samples) * 100,
'detection_rate': detection_rate,
'baseline_rate': baseline_rate,
'adjusted_rate': adjusted_rate, # This is the useful metric
'sensitivity_score': self._calculate_sensitivity_score(det_peak, det_min, adjusted_rate)
}
def _calculate_sensitivity_score(self, det_peak: int, det_min: int, adjusted_rate: float) -> float:
"""Calculate a sensitivity score - higher is better balance"""
# Ideal detection rate is around 10-30% for good sensitivity without false positives
ideal_rate = 20.0
rate_penalty = abs(adjusted_rate - ideal_rate) / ideal_rate
# Prefer moderate sensitivity settings (not too extreme)
sensitivity_penalty = (abs(det_peak - 25) + abs(det_min - 15)) / 20.0
# Lower penalty = higher score
score = max(0, 100 - (rate_penalty * 50) - (sensitivity_penalty * 20))
return score
def broadcast_to_clients(self, data):
"""Send data to all connected SSE clients"""
# Store the message for clients to pick up
@@ -217,15 +262,29 @@ class CADCalibrationEngine:
time.sleep(delay_ms / 1000.0)
if self.running:
# Find best result
# Find best result based on sensitivity score (not just detection rate)
best_result = None
recommended_result = None
if self.results:
best_result = max(self.results.values(), key=lambda x: x['detection_rate'])
# Find result with highest sensitivity score (best balance)
best_result = max(self.results.values(), key=lambda x: x.get('sensitivity_score', 0))
# Also find result with ideal adjusted detection rate (10-30%)
ideal_results = [r for r in self.results.values() if 10 <= r.get('adjusted_rate', 0) <= 30]
if ideal_results:
# Among ideal results, pick the one with best sensitivity score
recommended_result = max(ideal_results, key=lambda x: x.get('sensitivity_score', 0))
else:
recommended_result = best_result
self.broadcast_to_clients({
"type": "completed",
"message": "Calibration completed",
"results": {"best": best_result} if best_result else None
"results": {
"best": best_result,
"recommended": recommended_result,
"total_tests": len(self.results)
} if best_result else None
})
else:
self.broadcast_to_clients({"type": "status", "message": "Calibration stopped"})

View File

@@ -178,6 +178,9 @@ class RepeaterDaemon:
packet_record = {
"timestamp": time.time(),
"header": f"0x{packet.header:02X}" if hasattr(packet, "header") and packet.header is not None else None,
"payload": packet.payload.hex() if hasattr(packet, "payload") and packet.payload else None,
"payload_length": len(packet.payload) if hasattr(packet, "payload") and packet.payload else 0,
"type": packet.get_payload_type(), # 0x09 for trace
"route": packet.get_route_type(), # Should be direct (1)
"length": len(packet.payload or b""),

View File

@@ -97,6 +97,21 @@
</main>
</div>
<!-- Packet Details Dialog -->
<div id="packet-dialog" class="dialog-overlay">
<div class="dialog-content">
<div class="dialog-header">
<h3>Packet Details</h3>
<button class="dialog-close" onclick="closePacketDialog()">&times;</button>
</div>
<div class="dialog-body">
<div class="packet-details-grid">
<!-- Content will be populated by JavaScript -->
</div>
</div>
</div>
</div>
<script>
// Initialize charts
let packetRateChart = null;
@@ -381,7 +396,7 @@
}
let mainRow = `
<tr class="${hasDuplicates ? 'has-duplicates' : ''}">
<tr class="${hasDuplicates ? 'has-duplicates' : ''} clickable-row" onclick="showPacketDetails(${JSON.stringify(pkt).replace(/"/g, '&quot;')})">
<td data-label="Time">${time}</td>
<td data-label="Type"><span class="packet-type">${type}</span></td>
<td data-label="Route"><span class="route-${route.toLowerCase().replace('_', '-')}">${route}</span></td>
@@ -435,7 +450,7 @@
}
return `
<tr class="duplicate-row">
<tr class="duplicate-row clickable-row" onclick="showPacketDetails(${JSON.stringify(dupe).replace(/"/g, '&quot;')})">
<td data-label="Time" style="padding-left: 30px;">↳ ${dupeTime}</td>
<td data-label="Type"><span class="packet-type-dim">${type}</span></td>
<td data-label="Route"><span class="route-${dupeRoute.toLowerCase().replace('_', '-')}">${dupeRoute}</span></td>
@@ -506,6 +521,188 @@
signalQualityChart.update();
}
// Packet Details Dialog Functions
function showPacketDetails(packet) {
const dialog = document.getElementById('packet-dialog');
const detailsGrid = dialog.querySelector('.packet-details-grid');
// Format timestamp
const timestamp = new Date(packet.timestamp * 1000).toLocaleString();
// Format payload for display (with line breaks every 32 characters)
const formatPayload = (payload) => {
if (!payload) return 'None';
return payload.match(/.{1,32}/g).join('\n');
};
// Create the packet details HTML with sections
detailsGrid.innerHTML = `
<div class="detail-section">
<h4>Basic Information</h4>
<div class="detail-item">
<span class="detail-label">Timestamp:</span>
<span class="detail-value">${timestamp}</span>
</div>
<div class="detail-item">
<span class="detail-label">Packet Hash:</span>
<span class="detail-value monospace">${packet.packet_hash}</span>
</div>
<div class="detail-item">
<span class="detail-label">Header:</span>
<span class="detail-value monospace">${packet.header || 'None'}</span>
</div>
<div class="detail-item">
<span class="detail-label">Type:</span>
<span class="detail-value">${packet.type} (${getTypeName(packet.type)})</span>
</div>
<div class="detail-item">
<span class="detail-label">Route:</span>
<span class="detail-value">${packet.route} (${getRouteName(packet.route)})</span>
</div>
</div>
<div class="detail-section">
<h4>Payload Data</h4>
<div class="detail-item">
<span class="detail-label">Payload Length:</span>
<span class="detail-value">${packet.payload_length || packet.length} bytes</span>
</div>
<div class="detail-item">
<span class="detail-label">Payload (Hex):</span>
<div class="detail-value payload-hex">${formatPayload(packet.payload)}</div>
</div>
</div>
<div class="detail-section">
<h4>Path Information</h4>
<div class="detail-item">
<span class="detail-label">Original Path:</span>
<span class="detail-value monospace">${packet.original_path ? '[' + packet.original_path.join(', ') + ']' : 'None'}</span>
</div>
${packet.transmitted ? `
<div class="detail-item">
<span class="detail-label">Forwarded Path:</span>
<span class="detail-value monospace">${packet.forwarded_path ? '[' + packet.forwarded_path.join(', ') + ']' : 'None'}</span>
</div>
` : ''}
<div class="detail-item">
<span class="detail-label">Source Hash:</span>
<span class="detail-value monospace">${packet.src_hash || 'None'}</span>
</div>
<div class="detail-item">
<span class="detail-label">Destination Hash:</span>
<span class="detail-value monospace">${packet.dst_hash || 'None'}</span>
</div>
</div>
<div class="detail-section">
<h4>Signal & Processing</h4>
<div class="detail-item">
<span class="detail-label">RSSI:</span>
<span class="detail-value">${packet.rssi} dBm</span>
</div>
<div class="detail-item">
<span class="detail-label">SNR:</span>
<span class="detail-value">${packet.snr.toFixed(2)} dB</span>
</div>
${packet.is_trace && packet.path_snr_details && packet.path_snr_details.length > 0 ? `
<div class="detail-item">
<span class="detail-label">Path SNRs:</span>
<div class="detail-value">
<div class="path-snrs-detail">
${packet.path_snr_details.map((pathSnr, index) => {
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';
return `<div class="path-snr-item-detail">
<span class="hop-index">${index + 1}.</span>
<span class="path-hash">${pathSnr.hash}</span>
<span class="snr-value ${snrClass}">${pathSnr.snr_db.toFixed(1)}dB</span>
</div>`;
}).join('')}
</div>
</div>
</div>
` : ''}
<div class="detail-item">
<span class="detail-label">Score:</span>
<span class="detail-value">${packet.score.toFixed(3)}</span>
</div>
<div class="detail-item">
<span class="detail-label">TX Delay:</span>
<span class="detail-value">${packet.tx_delay_ms.toFixed(1)} ms</span>
</div>
<div class="detail-item">
<span class="detail-label">Transmitted:</span>
<span class="detail-value ${packet.transmitted ? 'status-tx' : 'status-dropped'}">${packet.transmitted ? 'Yes' : 'No'}</span>
</div>
<div class="detail-item">
<span class="detail-label">Is Duplicate:</span>
<span class="detail-value">${packet.is_duplicate ? 'Yes' : 'No'}</span>
</div>
${packet.drop_reason ? `
<div class="detail-item">
<span class="detail-label">Drop Reason:</span>
<span class="detail-value status-dropped">${packet.drop_reason}</span>
</div>
` : ''}
</div>
`;
dialog.style.display = 'flex';
document.body.style.overflow = 'hidden';
}
function closePacketDialog() {
const dialog = document.getElementById('packet-dialog');
dialog.style.display = 'none';
document.body.style.overflow = 'auto';
}
function getTypeName(type) {
const typeNames = {
0: 'REQ',
1: 'RESPONSE',
2: 'TXT_MSG',
3: 'ACK',
4: 'ADVERT',
5: 'GRP_TXT',
6: 'GRP_DATA',
7: 'ANON_REQ',
8: 'PATH',
9: 'TRACE',
15: 'RAW_CUSTOM'
};
return typeNames[type] || `Unknown (0x${type.toString(16).toUpperCase()})`;
}
function getRouteName(route) {
const routeNames = {
0: 'TRANSPORT_FLOOD',
1: 'FLOOD',
2: 'DIRECT',
3: 'TRANSPORT_DIRECT'
};
return routeNames[route] || `Unknown (${route})`;
}
// Close dialog when clicking outside
document.addEventListener('click', function(event) {
const dialog = document.getElementById('packet-dialog');
if (event.target === dialog) {
closePacketDialog();
}
});
// Close dialog on Escape key
document.addEventListener('keydown', function(event) {
if (event.key === 'Escape') {
closePacketDialog();
}
});
// Handle Send Advert button
function sendAdvert() {
const btn = document.getElementById('send-advert-btn');
@@ -797,6 +994,249 @@
font-style: italic;
}
/* Clickable row styling */
.clickable-row {
cursor: pointer;
transition: background-color 0.2s ease;
}
.clickable-row:hover {
background-color: rgba(78, 201, 176, 0.1);
}
/* Dialog styling */
.dialog-overlay {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.8);
z-index: 1000;
justify-content: center;
align-items: center;
backdrop-filter: blur(4px);
}
.dialog-content {
background: linear-gradient(135deg, #1e1e1e 0%, #252526 100%);
border-radius: 12px;
border: 1px solid rgba(86, 156, 214, 0.3);
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.6);
max-width: 800px;
max-height: 80vh;
width: 90%;
overflow: hidden;
}
.dialog-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 24px 16px;
border-bottom: 1px solid rgba(86, 156, 214, 0.2);
background: linear-gradient(90deg, rgba(86, 156, 214, 0.1) 0%, rgba(86, 156, 214, 0.05) 100%);
}
.dialog-header h3 {
margin: 0;
color: #569cd6;
font-size: 1.4em;
font-weight: 600;
}
.dialog-close {
background: none;
border: none;
color: #d4d4d4;
font-size: 1.8em;
cursor: pointer;
padding: 0;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 6px;
transition: all 0.2s ease;
}
.dialog-close:hover {
background-color: rgba(244, 135, 113, 0.2);
color: #f48771;
transform: scale(1.1);
}
.dialog-body {
padding: 24px;
overflow-y: auto;
max-height: calc(80vh - 80px);
}
.packet-details-grid {
display: grid;
gap: 24px;
}
.detail-section {
background: rgba(30, 30, 30, 0.6);
border: 1px solid rgba(86, 156, 214, 0.2);
border-radius: 8px;
padding: 20px;
}
.detail-section h4 {
margin: 0 0 16px 0;
color: #569cd6;
font-size: 1.1em;
font-weight: 600;
padding-bottom: 8px;
border-bottom: 1px solid rgba(86, 156, 214, 0.3);
}
.detail-item {
display: grid;
grid-template-columns: 140px 1fr;
gap: 16px;
padding: 8px 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
align-items: start;
}
.detail-item:last-child {
border-bottom: none;
}
.detail-label {
color: #dcdcaa;
font-weight: 500;
font-size: 0.9em;
}
.detail-value {
color: #d4d4d4;
font-size: 0.9em;
word-break: break-all;
}
.detail-value.monospace {
font-family: 'Courier New', monospace;
background: rgba(220, 220, 170, 0.1);
color: #dcdcaa;
padding: 4px 8px;
border-radius: 4px;
border: 1px solid rgba(220, 220, 170, 0.2);
font-size: 0.85em;
}
.payload-hex {
font-family: 'Courier New', monospace;
background: rgba(30, 30, 30, 0.8);
padding: 12px;
border-radius: 6px;
border: 1px solid rgba(86, 156, 214, 0.2);
font-size: 0.8em;
line-height: 1.4;
white-space: pre-wrap;
word-break: break-all;
max-height: 200px;
overflow-y: auto;
color: #dcdcaa;
}
/* Path SNR details in dialog */
.path-snrs-detail {
display: flex;
flex-direction: column;
gap: 6px;
background: rgba(30, 30, 30, 0.6);
padding: 12px;
border-radius: 6px;
border: 1px solid rgba(86, 156, 214, 0.2);
}
.path-snr-item-detail {
display: flex;
align-items: center;
gap: 8px;
white-space: nowrap;
}
.path-snr-item-detail .hop-index {
font-size: 0.8em;
color: #666;
min-width: 20px;
text-align: right;
}
.path-snr-item-detail .path-hash {
font-family: 'Courier New', monospace;
font-size: 0.85em;
color: #dcdcaa;
background: rgba(220, 220, 170, 0.1);
padding: 3px 6px;
border-radius: 4px;
min-width: 32px;
text-align: center;
border: 1px solid rgba(220, 220, 170, 0.2);
}
.path-snr-item-detail .snr-value {
font-size: 0.85em;
font-weight: 500;
min-width: 60px;
text-align: right;
}
/* Mobile optimization */
@media (max-width: 768px) {
.dialog-content {
width: 95%;
max-height: 90vh;
}
.dialog-header {
padding: 16px 20px 12px;
}
.dialog-header h3 {
font-size: 1.2em;
}
.dialog-body {
padding: 20px;
}
.detail-item {
grid-template-columns: 1fr;
gap: 8px;
}
.detail-label {
font-weight: 600;
color: #569cd6;
}
.payload-hex {
font-size: 0.75em;
padding: 8px;
}
.path-snrs-detail {
padding: 8px;
}
.path-snr-item-detail .path-hash {
font-size: 0.8em;
min-width: 28px;
}
.path-snr-item-detail .snr-value {
font-size: 0.8em;
min-width: 50px;
}
}
/* Mobile optimization */
@media (max-width: 768px) {
/* Keep path info on same line, allow wrapping if needed */