@@ -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' ])
394445def 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' ])
554675def 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' ])
62786421def login_my_repeater (public_key ):
62796422 """Log into a repeater using the provided or saved password.
0 commit comments