diff --git a/repeater/engine.py b/repeater/engine.py
index d6252dc..3d58c76 100644
--- a/repeater/engine.py
+++ b/repeater/engine.py
@@ -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()
@@ -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'):
diff --git a/repeater/main.py b/repeater/main.py
index 1835ec5..234585f 100644
--- a/repeater/main.py
+++ b/repeater/main.py
@@ -132,7 +132,59 @@ class RepeaterDaemon:
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_db = snr_val / 4.0
+ path_snrs.append(f"{snr_val}({snr_db:.1f}dB)")
+ # Create hash->SNR mapping for display
+ 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):
@@ -156,6 +208,13 @@ class RepeaterDaemon:
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
snr_scaled = int(packet.get_snr() * 4)
snr_byte = snr_scaled & 0xFF
diff --git a/repeater/templates/dashboard.html b/repeater/templates/dashboard.html
index 50946f9..da558db 100644
--- a/repeater/templates/dashboard.html
+++ b/repeater/templates/dashboard.html
@@ -229,6 +229,61 @@
${''.repeat(4)}
${snr.toFixed(1)} dB
`;
+ }
+
+ // 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 = `
`;
+
+ // Show received packet SNR first
+ pathSnrHtml += `
${getSignalBars(pkt.snr)}
`;
+
+ // Show path SNRs if available
+ if (pkt.path_snr_details.length > 0) {
+ pathSnrHtml += `
`;
+ pathSnrHtml += `
Path (${pkt.path_snr_details.length} hops):
`;
+
+ // 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 += `
+ ${index + 1}.
+ ${pathSnr.hash}
+ ${pathSnr.snr_db.toFixed(1)}dB
+
`;
+ });
+
+ // Show indicator if there are more hops
+ if (hasMoreHops) {
+ const remainingCount = pkt.path_snr_details.length - maxDisplayHops;
+ pathSnrHtml += `
+ +${remainingCount} more hop${remainingCount > 1 ? 's' : ''}
+
`;
+ }
+
+ pathSnrHtml += `
`;
+ }
+
+ pathSnrHtml += `
`;
+ 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 @@
${pkt.length}B |
${pathHashesHtml} |
${pkt.rssi} |
- ${getSignalBars(pkt.snr)} |
+ ${getTraceSnrDisplay(pkt, localHash)} |
${pkt.score.toFixed(2)} |
${pkt.tx_delay_ms.toFixed(0)}ms |
${statusHtml} |
@@ -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 @@
${dupe.length}B |
${dupePathHashesHtml} |
${dupe.rssi} |
- ${getSignalBars(dupe.snr)} |
+ ${getTraceSnrDisplay(dupe, localHash)} |
${dupe.score.toFixed(2)} |
${dupe.tx_delay_ms.toFixed(0)}ms |
${dupeStatusHtml} |
@@ -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;