diff --git a/app/models.py b/app/models.py index ec9e99c..b5d1eee 100644 --- a/app/models.py +++ b/app/models.py @@ -387,6 +387,8 @@ class MessagePath(BaseModel): default=None, description="Hop count. None = legacy (infer as len(path)//2, i.e. 1-byte hops)", ) + rssi: int | None = Field(default=None, description="Last-hop RSSI in dBm") + snr: float | None = Field(default=None, description="Last-hop SNR in dB") class Message(BaseModel): diff --git a/app/packet_processor.py b/app/packet_processor.py index 3d1ab18..a87e4cb 100644 --- a/app/packet_processor.py +++ b/app/packet_processor.py @@ -68,6 +68,8 @@ async def create_message_from_decrypted( received_at: int | None = None, path: str | None = None, path_len: int | None = None, + rssi: int | None = None, + snr: float | None = None, channel_name: str | None = None, realtime: bool = True, ) -> int | None: @@ -81,6 +83,8 @@ async def create_message_from_decrypted( received_at=received_at, path=path, path_len=path_len, + rssi=rssi, + snr=snr, channel_name=channel_name, realtime=realtime, broadcast_fn=broadcast_event, @@ -95,6 +99,8 @@ async def create_dm_message_from_decrypted( received_at: int | None = None, path: str | None = None, path_len: int | None = None, + rssi: int | None = None, + snr: float | None = None, outgoing: bool = False, realtime: bool = True, ) -> int | None: @@ -107,6 +113,8 @@ async def create_dm_message_from_decrypted( received_at=received_at, path=path, path_len=path_len, + rssi=rssi, + snr=snr, outgoing=outgoing, realtime=realtime, broadcast_fn=broadcast_event, @@ -319,7 +327,9 @@ async def process_raw_packet( # deduplication in create_message_from_decrypted handles adding paths to existing messages. # This is more reliable than trying to look up the message via raw packet linking. if payload_type == PayloadType.GROUP_TEXT: - decrypt_result = await _process_group_text(raw_bytes, packet_id, ts, packet_info) + decrypt_result = await _process_group_text( + raw_bytes, packet_id, ts, packet_info, rssi=rssi, snr=snr + ) if decrypt_result: result.update(decrypt_result) @@ -330,7 +340,9 @@ async def process_raw_packet( elif payload_type == PayloadType.TEXT_MESSAGE: # Try to decrypt direct messages using stored private key and known contacts - decrypt_result = await _process_direct_message(raw_bytes, packet_id, ts, packet_info) + decrypt_result = await _process_direct_message( + raw_bytes, packet_id, ts, packet_info, rssi=rssi, snr=snr + ) if decrypt_result: result.update(decrypt_result) @@ -367,6 +379,8 @@ async def _process_group_text( packet_id: int, timestamp: int, packet_info: PacketInfo | None, + rssi: int | None = None, + snr: float | None = None, ) -> dict | None: """ Process a GroupText (channel message) packet. @@ -403,6 +417,8 @@ async def _process_group_text( received_at=timestamp, path=packet_info.path.hex() if packet_info else None, path_len=packet_info.path_length if packet_info else None, + rssi=rssi, + snr=snr, ) return { @@ -544,6 +560,8 @@ async def _process_direct_message( packet_id: int, timestamp: int, packet_info: PacketInfo | None, + rssi: int | None = None, + snr: float | None = None, ) -> dict | None: """ Process a TEXT_MESSAGE (direct message) packet. @@ -644,6 +662,8 @@ async def _process_direct_message( received_at=timestamp, path=packet_info.path.hex() if packet_info else None, path_len=packet_info.path_length if packet_info else None, + rssi=rssi, + snr=snr, outgoing=is_outgoing, ) diff --git a/app/repository/messages.py b/app/repository/messages.py index e0aafb8..cee28d3 100644 --- a/app/repository/messages.py +++ b/app/repository/messages.py @@ -57,6 +57,8 @@ class MessageRepository: sender_timestamp: int | None = None, path: str | None = None, path_len: int | None = None, + rssi: int | None = None, + snr: float | None = None, txt_type: int = 0, signature: str | None = None, outgoing: bool = False, @@ -78,6 +80,10 @@ class MessageRepository: entry: dict = {"path": path, "received_at": received_at} if path_len is not None: entry["path_len"] = path_len + if rssi is not None: + entry["rssi"] = rssi + if snr is not None: + entry["snr"] = snr paths_json = json.dumps([entry]) # Normalize sender_key to lowercase so queries can match without LOWER(). @@ -116,6 +122,8 @@ class MessageRepository: path: str, received_at: int | None = None, path_len: int | None = None, + rssi: int | None = None, + snr: float | None = None, ) -> list[MessagePath]: """Add a new path to an existing message. @@ -129,6 +137,10 @@ class MessageRepository: entry: dict = {"path": path, "received_at": ts} if path_len is not None: entry["path_len"] = path_len + if rssi is not None: + entry["rssi"] = rssi + if snr is not None: + entry["snr"] = snr new_entry = json.dumps(entry) await db.conn.execute( """UPDATE messages SET paths = json_insert( diff --git a/app/services/dm_ingest.py b/app/services/dm_ingest.py index bfd09ca..783cce4 100644 --- a/app/services/dm_ingest.py +++ b/app/services/dm_ingest.py @@ -144,6 +144,8 @@ async def _store_direct_message( received_at: int, path: str | None, path_len: int | None, + rssi: int | None = None, + snr: float | None = None, outgoing: bool, txt_type: int, signature: str | None, @@ -170,6 +172,8 @@ async def _store_direct_message( path=path, received_at=received_at, path_len=path_len, + rssi=rssi, + snr=snr, broadcast_fn=broadcast_fn, ) return None @@ -189,6 +193,8 @@ async def _store_direct_message( path=path, received_at=received_at, path_len=path_len, + rssi=rssi, + snr=snr, broadcast_fn=broadcast_fn, ) return None @@ -201,6 +207,8 @@ async def _store_direct_message( received_at=received_at, path=path, path_len=path_len, + rssi=rssi, + snr=snr, txt_type=txt_type, signature=signature, outgoing=outgoing, @@ -218,6 +226,8 @@ async def _store_direct_message( path=path, received_at=received_at, path_len=path_len, + rssi=rssi, + snr=snr, broadcast_fn=broadcast_fn, ) return None @@ -232,7 +242,7 @@ async def _store_direct_message( text=text, sender_timestamp=sender_timestamp, received_at=received_at, - paths=build_message_paths(path, received_at, path_len), + paths=build_message_paths(path, received_at, path_len, rssi=rssi, snr=snr), txt_type=txt_type, signature=signature, sender_key=sender_key, @@ -261,6 +271,8 @@ async def ingest_decrypted_direct_message( received_at: int | None = None, path: str | None = None, path_len: int | None = None, + rssi: int | None = None, + snr: float | None = None, outgoing: bool = False, realtime: bool = True, broadcast_fn: BroadcastFn, @@ -311,6 +323,8 @@ async def ingest_decrypted_direct_message( received_at=received, path=path, path_len=path_len, + rssi=rssi, + snr=snr, outgoing=outgoing, txt_type=decrypted.txt_type, signature=signature, diff --git a/app/services/messages.py b/app/services/messages.py index f5d1ea9..f6445b3 100644 --- a/app/services/messages.py +++ b/app/services/messages.py @@ -37,10 +37,16 @@ def build_message_paths( path: str | None, received_at: int, path_len: int | None = None, + rssi: int | None = None, + snr: float | None = None, ) -> list[MessagePath] | None: """Build the single-path list used by message payloads.""" return ( - [MessagePath(path=path or "", received_at=received_at, path_len=path_len)] + [ + MessagePath( + path=path or "", received_at=received_at, path_len=path_len, rssi=rssi, snr=snr + ) + ] if path is not None else None ) @@ -166,6 +172,8 @@ async def reconcile_duplicate_message( path: str | None, received_at: int, path_len: int | None, + rssi: int | None = None, + snr: float | None = None, broadcast_fn: BroadcastFn, ) -> None: logger.debug( @@ -177,7 +185,9 @@ async def reconcile_duplicate_message( ) if path is not None: - paths = await MessageRepository.add_path(existing_msg.id, path, received_at, path_len) + paths = await MessageRepository.add_path( + existing_msg.id, path, received_at, path_len, rssi=rssi, snr=snr + ) else: paths = existing_msg.paths or [] @@ -214,6 +224,8 @@ async def handle_duplicate_message( path: str | None, received_at: int, path_len: int | None = None, + rssi: int | None = None, + snr: float | None = None, broadcast_fn: BroadcastFn, ) -> None: """Handle a duplicate message by updating paths/acks on the existing record.""" @@ -239,6 +251,8 @@ async def handle_duplicate_message( path=path, received_at=received_at, path_len=path_len, + rssi=rssi, + snr=snr, broadcast_fn=broadcast_fn, ) @@ -253,6 +267,8 @@ async def create_message_from_decrypted( received_at: int | None = None, path: str | None = None, path_len: int | None = None, + rssi: int | None = None, + snr: float | None = None, channel_name: str | None = None, realtime: bool = True, broadcast_fn: BroadcastFn, @@ -276,6 +292,8 @@ async def create_message_from_decrypted( received_at=received, path=path, path_len=path_len, + rssi=rssi, + snr=snr, sender_name=sender, sender_key=resolved_sender_key, ) @@ -291,6 +309,8 @@ async def create_message_from_decrypted( path=path, received_at=received, path_len=path_len, + rssi=rssi, + snr=snr, broadcast_fn=broadcast_fn, ) return None @@ -312,7 +332,7 @@ async def create_message_from_decrypted( text=text, sender_timestamp=timestamp, received_at=received, - paths=build_message_paths(path, received, path_len), + paths=build_message_paths(path, received, path_len, rssi=rssi, snr=snr), sender_name=sender, sender_key=resolved_sender_key, channel_name=channel_name, @@ -334,6 +354,8 @@ async def create_dm_message_from_decrypted( received_at: int | None = None, path: str | None = None, path_len: int | None = None, + rssi: int | None = None, + snr: float | None = None, outgoing: bool = False, realtime: bool = True, broadcast_fn: BroadcastFn, @@ -348,6 +370,8 @@ async def create_dm_message_from_decrypted( received_at=received_at, path=path, path_len=path_len, + rssi=rssi, + snr=snr, outgoing=outgoing, realtime=realtime, broadcast_fn=broadcast_fn, diff --git a/frontend/src/components/MessageList.tsx b/frontend/src/components/MessageList.tsx index 64d96ce..688c345 100644 --- a/frontend/src/components/MessageList.tsx +++ b/frontend/src/components/MessageList.tsx @@ -300,6 +300,9 @@ export function MessageList({ const [resendableIds, setResendableIds] = useState>(new Set()); const resendTimersRef = useRef>>(new Map()); const packetCacheRef = useRef>(new Map()); + const packetSignalOverrideRef = useRef<{ rssi: number | null; snr: number | null } | undefined>( + undefined + ); const [packetInspectorSource, setPacketInspectorSource] = useState< | { kind: 'packet'; packet: RawPacket } | { kind: 'loading'; message: string } @@ -325,6 +328,13 @@ export function MessageList({ const prevConvKeyRef = useRef(null); const handleAnalyzePacket = useCallback(async (message: Message) => { + // Extract signal from the first path if available + const firstPath = message.paths?.[0]; + packetSignalOverrideRef.current = + firstPath && (firstPath.rssi != null || firstPath.snr != null) + ? { rssi: firstPath.rssi ?? null, snr: firstPath.snr ?? null } + : undefined; + if (message.packet_id == null) { setPacketInspectorSource({ kind: 'unavailable', @@ -1180,12 +1190,18 @@ export function MessageList({ {packetInspectorSource && ( !isOpen && setPacketInspectorSource(null)} + onOpenChange={(isOpen) => { + if (!isOpen) { + setPacketInspectorSource(null); + packetSignalOverrideRef.current = undefined; + } + }} channels={channels} source={packetInspectorSource} title="Analyze Packet" description="On-demand raw packet analysis for a message-backed archival packet." notice={ANALYZE_PACKET_NOTICE} + signalOverride={packetSignalOverrideRef.current} /> )} diff --git a/frontend/src/components/PathModal.tsx b/frontend/src/components/PathModal.tsx index db2c824..b5c1cf6 100644 --- a/frontend/src/components/PathModal.tsx +++ b/frontend/src/components/PathModal.tsx @@ -103,14 +103,25 @@ export function PathModal({ ) : null} {/* Raw path summary */} -
+
{paths.map((p, index) => { const hops = parsePathHops(p.path, p.path_len); const rawPath = hops.length > 0 ? hops.join('->') : 'direct'; + const hasSignal = p.rssi != null || p.snr != null; return (
- Path {index + 1}:{' '} - {rawPath} +
+ Path {index + 1}:{' '} + {rawPath} +
+ {hasSignal && ( +
+ Last hop (as heard by you):{' '} + {p.rssi != null && {p.rssi} dBm RSSI} + {p.rssi != null && p.snr != null && · } + {p.snr != null && {p.snr.toFixed(1)} dB SNR} +
+ )}
); })} diff --git a/frontend/src/components/RawPacketDetailModal.tsx b/frontend/src/components/RawPacketDetailModal.tsx index 328c732..2f3d721 100644 --- a/frontend/src/components/RawPacketDetailModal.tsx +++ b/frontend/src/components/RawPacketDetailModal.tsx @@ -35,6 +35,11 @@ type RawPacketInspectorDialogSource = message: string; }; +interface SignalOverride { + rssi: number | null; + snr: number | null; +} + interface RawPacketInspectorDialogProps { open: boolean; onOpenChange: (open: boolean) => void; @@ -43,10 +48,12 @@ interface RawPacketInspectorDialogProps { title: string; description: string; notice?: ReactNode; + signalOverride?: SignalOverride; } interface RawPacketInspectionPanelProps { packet: RawPacket; + signalOverride?: SignalOverride; channels: Channel[]; } @@ -125,15 +132,21 @@ function formatTimestamp(timestamp: number): string { }); } -function formatSignal(packet: RawPacket): string { - const parts: string[] = []; - if (packet.rssi !== null) { - parts.push(`${packet.rssi} dBm RSSI`); - } - if (packet.snr !== null) { - parts.push(`${packet.snr.toFixed(1)} dB SNR`); - } - return parts.length > 0 ? parts.join(' · ') : 'No signal sample'; +function formatSignal( + packet: RawPacket, + signalOverride?: SignalOverride +): { lines: string[]; label: string } { + const rssi = signalOverride?.rssi ?? packet.rssi; + const snr = signalOverride?.snr ?? packet.snr; + const lines: string[] = []; + if (rssi !== null) lines.push(`${rssi} dBm RSSI`); + if (snr !== null) lines.push(`${snr.toFixed(1)} dB SNR`); + const isOverride = + signalOverride != null && (signalOverride.rssi != null || signalOverride.snr != null); + return { + lines: lines.length > 0 ? lines : ['No signal sample'], + label: isOverride ? 'Last Hop Signal' : 'Signal', + }; } function formatByteRange(field: PacketByteField): string { @@ -569,7 +582,11 @@ function FieldSection({ ); } -export function RawPacketInspectionPanel({ packet, channels }: RawPacketInspectionPanelProps) { +export function RawPacketInspectionPanel({ + packet, + channels, + signalOverride, +}: RawPacketInspectionPanelProps) { const decoderOptions = useMemo(() => createDecoderOptions(channels), [channels]); const groupTextCandidates = useMemo( () => buildGroupTextResolutionCandidates(channels), @@ -641,11 +658,24 @@ export function RawPacketInspectionPanel({ packet, channels }: RawPacketInspecti primary={`${inspection.routeTypeName} · ${inspection.payloadTypeName}`} secondary={`${inspection.payloadVersionName} · ${formatPathMode(inspection.decoded?.pathHashSize, inspection.pathTokens.length)}`} /> - + {(() => { + const sig = formatSignal(packet, signalOverride); + return ( +
+
+ {sig.label} +
+ {sig.lines.map((line, i) => ( +
+ {line} +
+ ))} +
+ ); + })()}
@@ -715,6 +745,7 @@ export function RawPacketInspectorDialog({ title, description, notice, + signalOverride, }: RawPacketInspectorDialogProps) { const [packetInput, setPacketInput] = useState(''); @@ -739,7 +770,13 @@ export function RawPacketInspectorDialog({ let body: ReactNode; if (source.kind === 'packet') { - body = ; + body = ( + + ); } else if (source.kind === 'paste') { body = ( <> diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 53ba5ec..458a26d 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -255,6 +255,10 @@ export interface MessagePath { received_at: number; /** Hop count (number of intermediate nodes). Null for legacy data (infer as len(path)/2). */ path_len?: number | null; + /** Last-hop RSSI in dBm (null if not available, e.g. older data) */ + rssi?: number | null; + /** Last-hop SNR in dB (null if not available, e.g. older data) */ + snr?: number | null; } export interface Message { diff --git a/tests/test_echo_dedup.py b/tests/test_echo_dedup.py index 005098d..69f08a6 100644 --- a/tests/test_echo_dedup.py +++ b/tests/test_echo_dedup.py @@ -883,7 +883,7 @@ class TestDirectMessageDirectionDetection: message_broadcasts = [b for b in broadcasts if b["type"] == "message"] assert len(message_broadcasts) == 1 assert message_broadcasts[0]["data"]["paths"] == [ - {"path": "", "received_at": SENDER_TIMESTAMP, "path_len": 0} + {"path": "", "received_at": SENDER_TIMESTAMP, "path_len": 0, "rssi": None, "snr": None} ] @pytest.mark.asyncio