Skip to content

Commit c6d2367

Browse files
committed
Merge branch 'dev'
2 parents e95b167 + c5a0fa8 commit c6d2367

15 files changed

Lines changed: 2273 additions & 51 deletions

app/database.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1120,6 +1120,27 @@ def get_echoes_for_message(self, pkt_payload: str) -> List[Dict]:
11201120
).fetchall()
11211121
return [dict(r) for r in rows]
11221122

1123+
def get_echoes_for_payloads(self, payloads: List[str]) -> Dict[str, List[Dict]]:
1124+
"""Batch-fetch echoes for many pkt_payloads with chunked IN queries.
1125+
1126+
Returns {pkt_payload: [echo dicts ordered by received_at]}.
1127+
Chunked at 500 to stay under SQLite's host-parameter limit."""
1128+
result: Dict[str, List[Dict]] = {}
1129+
if not payloads:
1130+
return result
1131+
with self._connect() as conn:
1132+
for i in range(0, len(payloads), 500):
1133+
chunk = payloads[i:i + 500]
1134+
placeholders = ",".join("?" * len(chunk))
1135+
rows = conn.execute(
1136+
f"""SELECT * FROM echoes WHERE pkt_payload IN ({placeholders})
1137+
ORDER BY received_at ASC""",
1138+
chunk
1139+
).fetchall()
1140+
for r in rows:
1141+
result.setdefault(r['pkt_payload'], []).append(dict(r))
1142+
return result
1143+
11231144
def update_message_pkt_payload(self, msg_id: int, pkt_payload: str) -> None:
11241145
"""Set pkt_payload on a channel message (used for sent message echo correlation)."""
11251146
with self._connect() as conn:

app/routes/api.py

Lines changed: 184 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -390,6 +390,57 @@ def save_ui_settings(settings: dict) -> bool:
390390
return False
391391

392392

393+
def _build_channel_secrets(db) -> dict:
394+
"""Build {channel_idx: secret_hex} lookup for pkt_payload computation.
395+
396+
Uses DB channels (fast) instead of get_channels_cached() which can block
397+
on device communication when cache is cold."""
398+
channel_secrets = {}
399+
for ch_info in (db.get_channels() if db else []):
400+
ch_key = ch_info.get('secret', ch_info.get('key', ''))
401+
ch_idx = ch_info.get('idx', ch_info.get('index'))
402+
if ch_key and ch_idx is not None:
403+
channel_secrets[ch_idx] = ch_key
404+
return channel_secrets
405+
406+
407+
def _get_row_pkt_payload(row: dict, channel_secrets: dict):
408+
"""Return pkt_payload for a channel message row, computing it when not
409+
stored (v2: meshcore doesn't provide it). Returns None when it cannot
410+
be computed (legacy row or missing channel secret)."""
411+
pkt_payload = row.get('pkt_payload')
412+
if pkt_payload:
413+
return pkt_payload
414+
415+
ch_idx = row.get('channel_idx', 0)
416+
sender_ts = row.get('sender_timestamp')
417+
txt_type = row.get('txt_type', 0)
418+
if not sender_ts or ch_idx not in channel_secrets:
419+
return None
420+
421+
# Use original text from raw_json (preserves trailing whitespace)
422+
raw_text = None
423+
raw_json_str = row.get('raw_json')
424+
if raw_json_str:
425+
try:
426+
raw_text = json.loads(raw_json_str).get('text')
427+
except (json.JSONDecodeError, TypeError):
428+
pass
429+
# Fallback: reconstruct from sender + content
430+
if not raw_text:
431+
is_own = bool(row.get('is_own', 0))
432+
if is_own:
433+
device_name = runtime_config.get_device_name() or ''
434+
raw_text = f"{device_name}: {row.get('content', '')}" if device_name else row.get('content', '')
435+
else:
436+
sender = row.get('sender', '')
437+
raw_text = f"{sender}: {row.get('content', '')}" if sender else row.get('content', '')
438+
439+
return compute_pkt_payload(
440+
channel_secrets[ch_idx], sender_ts, txt_type, raw_text
441+
)
442+
443+
393444
@api_bp.route('/messages', methods=['GET'])
394445
def get_messages():
395446
"""
@@ -441,47 +492,15 @@ def get_messages():
441492
days=days,
442493
)
443494

444-
# Build channel secret lookup for pkt_payload computation
445-
# Use DB channels (fast) instead of get_channels_cached() which
446-
# can block on device communication when cache is cold
447-
channel_secrets = {}
448-
db_channels = db.get_channels() if db else []
449-
for ch_info in db_channels:
450-
ch_key = ch_info.get('secret', ch_info.get('key', ''))
451-
ch_idx = ch_info.get('idx', ch_info.get('index'))
452-
if ch_key and ch_idx is not None:
453-
channel_secrets[ch_idx] = ch_key
495+
channel_secrets = _build_channel_secrets(db)
454496

455497
# Convert DB rows to frontend-compatible format
456498
messages = []
457499
for row in db_messages:
458-
pkt_payload = row.get('pkt_payload')
459500
ch_idx = row.get('channel_idx', 0)
460501
sender_ts = row.get('sender_timestamp')
461502
txt_type = row.get('txt_type', 0)
462-
463-
# Compute pkt_payload if not stored (v2: meshcore doesn't provide it)
464-
if not pkt_payload and sender_ts and ch_idx in channel_secrets:
465-
# Use original text from raw_json (preserves trailing whitespace)
466-
raw_text = None
467-
raw_json_str = row.get('raw_json')
468-
if raw_json_str:
469-
try:
470-
raw_text = json.loads(raw_json_str).get('text')
471-
except (json.JSONDecodeError, TypeError):
472-
pass
473-
# Fallback: reconstruct from sender + content
474-
if not raw_text:
475-
is_own = bool(row.get('is_own', 0))
476-
if is_own:
477-
device_name = runtime_config.get_device_name() or ''
478-
raw_text = f"{device_name}: {row.get('content', '')}" if device_name else row.get('content', '')
479-
else:
480-
sender = row.get('sender', '')
481-
raw_text = f"{sender}: {row.get('content', '')}" if sender else row.get('content', '')
482-
pkt_payload = compute_pkt_payload(
483-
channel_secrets[ch_idx], sender_ts, txt_type, raw_text
484-
)
503+
pkt_payload = _get_row_pkt_payload(row, channel_secrets)
485504

486505
# Decode path_len into hop_count and path_hash_size
487506
path_len_raw = row.get('path_len')
@@ -508,18 +527,23 @@ def get_messages():
508527
'pkt_payload': pkt_payload,
509528
}
510529

511-
# Enrich with echo data and packet hash (frontend builds analyzer URL)
512530
if pkt_payload:
513531
msg['packet_hash'] = compute_packet_hash(pkt_payload)
514-
echoes = db.get_echoes_for_message(pkt_payload)
515-
if echoes:
516-
msg['echo_count'] = len(echoes)
517-
msg['echo_paths'] = [e.get('path', '') for e in echoes if e.get('path')]
518-
msg['echo_snrs'] = [e.get('snr') for e in echoes if e.get('snr') is not None]
519-
msg['echo_hash_sizes'] = [e.get('hash_size', 1) for e in echoes if e.get('path')]
520532

521533
messages.append(msg)
522534

535+
# Enrich with echo data in one batch query (per-message queries are
536+
# prohibitively slow on connection-per-call over bind mounts)
537+
payloads = [m['pkt_payload'] for m in messages if m.get('pkt_payload')]
538+
echoes_by_payload = db.get_echoes_for_payloads(payloads)
539+
for msg in messages:
540+
echoes = echoes_by_payload.get(msg.get('pkt_payload'))
541+
if echoes:
542+
msg['echo_count'] = len(echoes)
543+
msg['echo_paths'] = [e.get('path', '') for e in echoes if e.get('path')]
544+
msg['echo_snrs'] = [e.get('snr') for e in echoes if e.get('snr') is not None]
545+
msg['echo_hash_sizes'] = [e.get('hash_size', 1) for e in echoes if e.get('path')]
546+
523547
# Filter out blocked contacts' messages
524548
blocked_names = db.get_blocked_contact_names()
525549
if blocked_names:
@@ -550,6 +574,103 @@ def get_messages():
550574
}), 500
551575

552576

577+
@api_bp.route('/path-analyzer/messages', methods=['GET'])
578+
def get_path_analyzer_messages():
579+
"""
580+
Bulk channel messages across ALL channels with batched echo data.
581+
582+
Used by the Path Analyzer panel. Unlike /api/messages this returns all
583+
channels at once and fetches echoes in chunked batch queries instead of
584+
one query per message.
585+
586+
Query parameters:
587+
days (int): Time window in days (default 3, clamped to 1..30)
588+
589+
Returns:
590+
JSON with messages list; each message carries an echoes[] array of
591+
{path, snr, hash_size, direction, received_at}. Messages whose
592+
pkt_payload cannot be computed (legacy rows, missing channel secret)
593+
are returned with packet_hash null and empty echoes.
594+
RSSI is not included — it is not persisted for channel messages;
595+
it would require an Observer-backed capture store (future work).
596+
"""
597+
try:
598+
days = request.args.get('days', default=3, type=int)
599+
days = max(1, min(days, 30))
600+
601+
db = _get_db()
602+
if not db:
603+
return jsonify({'success': False, 'error': 'Database not available'}), 500
604+
605+
db_messages = db.get_channel_messages(channel_idx=None, limit=None, days=days)
606+
607+
channel_secrets = _build_channel_secrets(db)
608+
channel_names = {
609+
ch.get('idx'): ch.get('name', '')
610+
for ch in db.get_channels()
611+
}
612+
blocked_names = db.get_blocked_contact_names()
613+
614+
# First pass: compute payloads so echoes can be fetched in one batch
615+
prepared = []
616+
payloads = []
617+
for row in db_messages:
618+
if blocked_names and row.get('sender', '') in blocked_names:
619+
continue
620+
pkt_payload = _get_row_pkt_payload(row, channel_secrets)
621+
if pkt_payload:
622+
payloads.append(pkt_payload)
623+
prepared.append((row, pkt_payload))
624+
625+
echoes_by_payload = db.get_echoes_for_payloads(payloads)
626+
627+
messages = []
628+
for row, pkt_payload in prepared:
629+
path_len_raw = row.get('path_len')
630+
hop_count = None
631+
path_hash_size = 1
632+
if path_len_raw is not None:
633+
hop_count, path_hash_size, _ = decode_path_len(path_len_raw)
634+
635+
ch_idx = row.get('channel_idx', 0)
636+
messages.append({
637+
'id': row.get('id'),
638+
'channel_idx': ch_idx,
639+
'channel_name': channel_names.get(ch_idx, ''),
640+
'sender': row.get('sender', ''),
641+
'content': row.get('content', ''),
642+
'timestamp': row.get('timestamp', 0),
643+
'datetime': datetime.fromtimestamp(row['timestamp']).isoformat() if row.get('timestamp') else None,
644+
'is_own': bool(row.get('is_own', 0)),
645+
'snr': row.get('snr'),
646+
'hop_count': hop_count,
647+
'path_hash_size': path_hash_size,
648+
'packet_hash': compute_packet_hash(pkt_payload) if pkt_payload else None,
649+
'pkt_payload': pkt_payload,
650+
'echoes': [
651+
{
652+
'path': e.get('path', ''),
653+
'snr': e.get('snr'),
654+
'hash_size': e.get('hash_size', 1),
655+
'direction': e.get('direction', 'incoming'),
656+
'received_at': e.get('received_at'),
657+
}
658+
for e in echoes_by_payload.get(pkt_payload, [])
659+
] if pkt_payload else [],
660+
})
661+
662+
return jsonify({
663+
'success': True,
664+
'count': len(messages),
665+
'days': days,
666+
'messages': messages,
667+
}), 200
668+
669+
except Exception as e:
670+
logger.error(f"Error fetching path analyzer messages: {e}")
671+
return jsonify({'success': False, 'error': str(e)}), 500
672+
673+
553674
@api_bp.route('/messages/<int:msg_id>/meta', methods=['GET'])
554675
def get_message_meta(msg_id):
555676
"""Return metadata (SNR, hops, route, analyzer URL) for a single channel message."""
@@ -6274,6 +6395,28 @@ def delete_my_repeater(public_key):
62746395
return jsonify({'success': False, 'error': str(e)}), 500
62756396

62766397

6398+
@api_bp.route('/repeaters/<public_key>/password', methods=['GET'])
6399+
def get_my_repeater_password(public_key):
6400+
"""Return the saved password for a repeater (empty string when none).
6401+
6402+
Used to prefill the login-retry prompt: a wrong stored password and an
6403+
unreachable repeater are indistinguishable, so on a failed auto-login we
6404+
hand the (correct) saved password back to the same trusted local UI rather
6405+
than force the user to retype it. Passwords are already stored so the app
6406+
can log in on the user's behalf, so this stays within the local-app scope.
6407+
"""
6408+
db = _get_db()
6409+
if not db:
6410+
return jsonify({'success': False, 'error': 'Database not available'}), 503
6411+
pk = _normalize_repeater_key(public_key)
6412+
if not pk:
6413+
return jsonify({'success': False, 'error': 'Invalid public_key'}), 400
6414+
row = db.get_repeater(pk)
6415+
if not row:
6416+
return jsonify({'success': False, 'error': 'Repeater not in list'}), 404
6417+
return jsonify({'success': True, 'password': row.get('password') or ''}), 200
6418+
6419+
62776420
@api_bp.route('/repeaters/<public_key>/login', methods=['POST'])
62786421
def login_my_repeater(public_key):
62796422
"""Log into a repeater using the provided or saved password.

app/routes/views.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,15 @@ def console():
103103
)
104104

105105

106+
@views_bp.route('/path-analyzer')
107+
def path_analyzer():
108+
"""Path Analyzer - routing path analysis for channel messages."""
109+
return render_template(
110+
'path-analyzer.html',
111+
device_name=runtime_config.get_device_name()
112+
)
113+
114+
106115
@views_bp.route('/repeaters')
107116
def repeaters():
108117
"""My Repeaters - repeater administration panel (list + login)."""

app/static/css/style.css

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1637,6 +1637,13 @@ main {
16371637
color: white;
16381638
}
16391639

1640+
/* Path Analyzer FAB button + modal header (purple) */
1641+
.fab-pathanalyzer,
1642+
.pathanalyzer-header {
1643+
background: linear-gradient(135deg, #6f42c1 0%, #59359a 100%);
1644+
color: white;
1645+
}
1646+
16401647
/* Filter bar overlay - slides down from top of chat area */
16411648
.filter-bar {
16421649
position: absolute;
@@ -1883,7 +1890,8 @@ emoji-picker {
18831890
#contactsModal .modal-dialog.modal-fullscreen,
18841891
#logsModal .modal-dialog.modal-fullscreen,
18851892
#consoleModal .modal-dialog.modal-fullscreen,
1886-
#repeatersModal .modal-dialog.modal-fullscreen {
1893+
#repeatersModal .modal-dialog.modal-fullscreen,
1894+
#pathAnalyzerModal .modal-dialog.modal-fullscreen {
18871895
margin: 0 !important;
18881896
width: 100vw !important;
18891897
max-width: 100vw !important;
@@ -1895,7 +1903,8 @@ emoji-picker {
18951903
#contactsModal .modal-content,
18961904
#logsModal .modal-content,
18971905
#consoleModal .modal-content,
1898-
#repeatersModal .modal-content {
1906+
#repeatersModal .modal-content,
1907+
#pathAnalyzerModal .modal-content {
18991908
border: none !important;
19001909
border-radius: 0 !important;
19011910
height: 100vh !important;
@@ -1905,7 +1914,8 @@ emoji-picker {
19051914
#contactsModal .modal-body,
19061915
#logsModal .modal-body,
19071916
#consoleModal .modal-body,
1908-
#repeatersModal .modal-body {
1917+
#repeatersModal .modal-body,
1918+
#pathAnalyzerModal .modal-body {
19091919
overflow: hidden !important;
19101920
}
19111921

app/static/js/app.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6030,14 +6030,15 @@ const ITEM_PLACEMENT_DEFS = {
60306030
map: { fab: '#fab-map', menu: '#mapBtn' },
60316031
console: { fab: '#fab-console', menu: '#consoleBtn' },
60326032
repeaters: { fab: '#fab-repeaters', menu: '#repeatersBtn' },
6033+
pathanalyzer: { fab: '#fab-pathanalyzer', menu: '#pathAnalyzerBtn' },
60336034
deviceinfo: { fab: '#fab-deviceinfo', menu: '#deviceInfoBtn' },
60346035
syslog: { fab: '#fab-syslog', menu: '#logsBtn' },
60356036
};
60366037

60376038
const ITEM_PLACEMENT_DEFAULTS = {
60386039
filter: 'fab', search: 'fab', dm: 'fab', contacts: 'fab', settings: 'fab',
60396040
advert: 'menu', floodadvert: 'menu', map: 'menu',
6040-
console: 'menu', repeaters: 'menu', deviceinfo: 'menu', syslog: 'menu'
6041+
console: 'menu', repeaters: 'menu', pathanalyzer: 'menu', deviceinfo: 'menu', syslog: 'menu'
60416042
};
60426043

60436044
function readItemPlacements() {

0 commit comments

Comments
 (0)