feat: Deterministic echo-to-message matching via pkt_payload computation

Replace unreliable timestamp-based heuristic (±10s window) with exact
cryptographic matching for incoming channel message routes. Compute
pkt_payload by reconstructing the AES-128-ECB encrypted packet from
message data (sender_timestamp, txt_type, text) + channel secret, then
match against echo data by exact key lookup.

Also accumulate ALL route paths per message (previously only last path
was kept due to dict overwrite), and display them in a multi-path popup
showing SNR and hops for each route.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
MarekWo
2026-02-21 07:29:49 +01:00
parent 2ed3dc3758
commit 28148d32d8
7 changed files with 128 additions and 52 deletions
+1 -1
View File
@@ -22,7 +22,7 @@ A lightweight web interface for meshcore-cli, providing browser-based access to
- **Message archives** - Automatic daily archiving with browse-by-date selector
- **Interactive Console** - Direct meshcli command execution via WebSocket
- **@Mentions autocomplete** - Type @ to see contact suggestions with fuzzy search
- **Echo tracking** - "Heard X repeats" with repeater IDs for sent messages, route path for incoming messages (persisted across restarts)
- **Echo tracking** - "Heard X repeats" with repeater IDs for sent messages, all route paths for incoming messages with deterministic payload matching (persisted across restarts)
- **MeshCore Analyzer** - View packet details on analyzer.letsmesh.net directly from channel messages
- **DM delivery tracking** - ACK-based delivery confirmation with SNR and route info
- **PWA support** - Browser notifications and installable app (experimental)
+4 -1
View File
@@ -69,7 +69,10 @@ def parse_message(line: Dict, allowed_channels: Optional[List[int]] = None) -> O
'is_own': is_own,
'snr': line.get('SNR'),
'path_len': line.get('path_len'),
'channel_idx': channel_idx
'channel_idx': channel_idx,
'sender_timestamp': line.get('sender_timestamp'),
'txt_type': line.get('txt_type', 0),
'raw_text': text
}
+48 -20
View File
@@ -3,12 +3,15 @@ REST API endpoints for mc-webui
"""
import hashlib
import hmac as hmac_mod
import logging
import json
import re
import base64
import struct
import time
import requests
from Crypto.Cipher import AES
from datetime import datetime
from io import BytesIO
from pathlib import Path
@@ -48,6 +51,29 @@ def compute_analyzer_url(pkt_payload):
return None
def compute_pkt_payload(channel_secret_hex, sender_timestamp, txt_type, text, attempt=0):
"""Compute pkt_payload from message data + channel secret.
Reconstructs the encrypted GRP_TXT payload:
channel_hash(1) + HMAC-MAC(2) + AES-128-ECB(plaintext)
where plaintext = timestamp(4 LE) + flags(1) + text(UTF-8) + null + zero-pad.
"""
secret = bytes.fromhex(channel_secret_hex)
flags = ((txt_type & 0x3F) << 2) | (attempt & 0x03)
plaintext = struct.pack('<I', sender_timestamp) + bytes([flags]) + text.encode('utf-8') + b'\x00'
# Pad to AES block boundary (16 bytes)
pad_len = (16 - len(plaintext) % 16) % 16
plaintext += b'\x00' * pad_len
# AES-128-ECB encrypt
cipher = AES.new(secret[:16], AES.MODE_ECB)
ciphertext = cipher.encrypt(plaintext)
# HMAC-SHA256 truncated to 2 bytes
mac = hmac_mod.new(secret, ciphertext, hashlib.sha256).digest()[:2]
# Channel hash: first byte of SHA256(secret)
chan_hash = hashlib.sha256(secret).digest()[0:1]
return (chan_hash + mac + ciphertext).hex()
def get_channels_cached(force_refresh=False):
"""
Get channels with caching to reduce USB/meshcli calls.
@@ -342,27 +368,29 @@ def get_messages():
break
# Merge incoming paths into received messages
# Match by timestamp proximity + path_len confirmation
# Deterministic matching via computed pkt_payload
incoming_by_payload = {ip['pkt_payload']: ip for ip in incoming_paths}
# Get channel secrets for payload computation
_, channels = get_channels_cached()
channel_secrets = {ch['index']: ch['key'] for ch in (channels or [])}
for msg in messages:
if not msg.get('is_own'):
best_match = None
best_delta = 10 # max 10 second window
for ip in incoming_paths:
delta = abs(msg['timestamp'] - ip['timestamp'])
if delta < best_delta:
# Prefer matches where path_len also matches
if msg.get('path_len') == ip.get('path_len'):
best_match = ip
best_delta = delta
elif best_match is None:
# Fallback: timestamp-only match
best_match = ip
best_delta = delta
if best_match:
msg['path'] = best_match['path']
pkt = best_match.get('pkt_payload')
if pkt:
msg['analyzer_url'] = compute_analyzer_url(pkt)
if not msg.get('is_own') and msg.get('sender_timestamp') and msg.get('channel_idx') in channel_secrets:
secret = channel_secrets[msg['channel_idx']]
for attempt in range(4):
try:
payload = compute_pkt_payload(
secret, msg['sender_timestamp'],
msg.get('txt_type', 0), msg.get('raw_text', ''), attempt
)
except Exception:
break
if payload in incoming_by_payload:
entry = incoming_by_payload[payload]
msg['paths'] = entry.get('paths', [])
msg['analyzer_url'] = compute_analyzer_url(payload)
break
except Exception as e:
logger.debug(f"Echo data fetch failed (non-critical): {e}")
+23 -6
View File
@@ -1152,22 +1152,39 @@ main {
position: relative;
}
/* Path popup (mobile-friendly tooltip) */
/* Path popup (mobile-friendly, multi-path) */
.path-popup {
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
left: 0;
background-color: #333;
color: #fff;
padding: 0.35rem 0.6rem;
padding: 0.4rem 0.6rem;
border-radius: 0.375rem;
font-size: 0.7rem;
white-space: nowrap;
white-space: normal;
z-index: 1000;
pointer-events: none;
pointer-events: auto;
margin-bottom: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
min-width: 180px;
max-width: 320px;
}
.path-popup .path-entry {
padding: 0.15rem 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.15);
word-break: break-all;
}
.path-popup .path-entry:last-child {
border-bottom: none;
}
.path-popup .path-detail {
display: block;
opacity: 0.7;
font-size: 0.6rem;
}
/* =============================================================================
+24 -9
View File
@@ -715,13 +715,16 @@ function createMessageElement(msg) {
if (msg.path_len !== undefined && msg.path_len !== null) {
metaInfo += ` | Hops: ${msg.path_len}`;
}
if (msg.path) {
const segments = msg.path.match(/.{1,2}/g) || [];
const fullPath = segments.join(' \u2192 ');
if (msg.paths && msg.paths.length > 0) {
// Show first path inline (shortest/first arrival)
const firstPath = msg.paths[0];
const segments = firstPath.path ? firstPath.path.match(/.{1,2}/g) || [] : [];
const shortPath = segments.length > 4
? `${segments[0]}\u2192...\u2192${segments[segments.length - 1]}`
: segments.join('\u2192');
metaInfo += ` | <span class="path-info" onclick="showPathPopup(this, '${fullPath}')">Route: ${shortPath}</span>`;
const pathsData = encodeURIComponent(JSON.stringify(msg.paths));
const routeLabel = msg.paths.length > 1 ? `Route (${msg.paths.length})` : 'Route';
metaInfo += ` | <span class="path-info" onclick="showPathsPopup(this, '${pathsData}')">${routeLabel}: ${shortPath}</span>`;
}
if (msg.is_own) {
@@ -908,22 +911,34 @@ function resendMessage(content) {
}
/**
* Show path popup on tap (mobile-friendly alternative to tooltip)
* Show paths popup on tap (mobile-friendly, shows all routes)
*/
function showPathPopup(element, fullPath) {
function showPathsPopup(element, encodedPaths) {
// Remove any existing popup
const existing = document.querySelector('.path-popup');
if (existing) existing.remove();
const paths = JSON.parse(decodeURIComponent(encodedPaths));
const popup = document.createElement('div');
popup.className = 'path-popup';
popup.textContent = `Path: ${fullPath}`;
let html = '';
paths.forEach((p, i) => {
const segments = p.path ? p.path.match(/.{1,2}/g) || [] : [];
const fullRoute = segments.join(' \u2192 ');
const snr = p.snr !== null && p.snr !== undefined ? `${p.snr.toFixed(1)} dB` : '?';
const hops = p.path_len !== null && p.path_len !== undefined ? p.path_len : segments.length;
html += `<div class="path-entry">${fullRoute}<span class="path-detail">SNR: ${snr} | Hops: ${hops}</span></div>`;
});
popup.innerHTML = html;
element.style.position = 'relative';
element.appendChild(popup);
// Auto-dismiss after 4 seconds or on outside tap
// Auto-dismiss after 8 seconds or on outside tap
const dismiss = () => popup.remove();
setTimeout(dismiss, 4000);
setTimeout(dismiss, 8000);
document.addEventListener('click', function handler(e) {
if (!element.contains(e.target)) {
dismiss();
+25 -15
View File
@@ -580,24 +580,29 @@ class MeshCLISession:
logger.info(f"Echo: correlated pkt_payload with sent message, first path: {path}")
return
# Not a sent echo -> store as incoming message path
self.incoming_paths[pkt_payload] = {
# Not a sent echo -> accumulate as incoming message path
if pkt_payload not in self.incoming_paths:
self.incoming_paths[pkt_payload] = {
'paths': [],
'first_ts': current_time,
}
self.incoming_paths[pkt_payload]['paths'].append({
'path': path,
'snr': echo_data.get('snr'),
'path_len': echo_data.get('path_len'),
'timestamp': current_time,
}
'ts': current_time,
})
self._save_echo({
'type': 'rx_echo', 'pkt_payload': pkt_payload,
'path': path, 'snr': echo_data.get('snr'),
'path_len': echo_data.get('path_len')
})
logger.debug(f"Echo: stored incoming path {path} (path_len={echo_data.get('path_len')})")
logger.debug(f"Echo: stored incoming path {path} (path_len={echo_data.get('path_len')}, total paths: {len(self.incoming_paths[pkt_payload]['paths'])})")
# Cleanup old incoming paths (> 1 hour)
cutoff = current_time - 3600
self.incoming_paths = {k: v for k, v in self.incoming_paths.items()
if v['timestamp'] > cutoff}
if v['first_ts'] > cutoff}
def register_pending_echo(self, channel_idx, timestamp):
"""Register a sent message for echo tracking."""
@@ -679,13 +684,18 @@ class MeshCLISession:
loaded_sent += 1
elif echo_type == 'rx_echo':
self.incoming_paths[pkt_payload] = {
if pkt_payload not in self.incoming_paths:
self.incoming_paths[pkt_payload] = {
'paths': [],
'first_ts': ts,
}
loaded_incoming += 1
self.incoming_paths[pkt_payload]['paths'].append({
'path': record.get('path', ''),
'snr': record.get('snr'),
'path_len': record.get('path_len'),
'timestamp': ts,
}
loaded_incoming += 1
'ts': ts,
})
# Rewrite file with only recent records (compact)
with open(self.echo_log_path, 'w', encoding='utf-8') as f:
@@ -1388,7 +1398,9 @@ def get_echo_counts():
...
],
"incoming_paths": [
{"timestamp": 1706500000.456, "path": "8a40a605", "path_len": 4, "snr": 11.0, "pkt_payload": "efgh..."},
{"pkt_payload": "efgh...", "timestamp": 1706500000.456, "paths": [
{"path": "8a40a605", "path_len": 4, "snr": 11.0, "ts": 1706500000.456}, ...
]},
...
]
}
@@ -1410,11 +1422,9 @@ def get_echo_counts():
incoming = []
for pkt_payload, data in meshcli_session.incoming_paths.items():
incoming.append({
'timestamp': data['timestamp'],
'path': data['path'],
'path_len': data.get('path_len'),
'snr': data.get('snr'),
'pkt_payload': pkt_payload,
'timestamp': data['first_ts'],
'paths': data['paths'],
})
return jsonify({
+3
View File
@@ -23,6 +23,9 @@ Pillow==10.1.0
# HTTP Client for MeshCore Bridge communication
requests==2.31.0
# Cryptography for pkt_payload computation (AES-128-ECB)
pycryptodome==3.21.0
# WebSocket support for console (threading mode - no gevent needed)
flask-socketio==5.3.6
python-socketio==5.10.0