mirror of
https://github.com/MarekWo/mc-webui.git
synced 2026-07-06 09:51:14 +02:00
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:
@@ -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)
|
||||
|
||||
@@ -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
@@ -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}")
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user