From 1e21644d74395eaf8fd42ad61afc38a43adf713f Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Sat, 21 Mar 2026 13:15:18 -0700 Subject: [PATCH 01/64] Swap repeaters and room servers for better ordering, and the less common contact type at the bottom --- frontend/src/components/Sidebar.tsx | 30 ++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index ad98404..d6b7bae 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -945,21 +945,6 @@ export function Sidebar({ )} - {/* Room Servers */} - {nonFavoriteRooms.length > 0 && ( - <> - {renderSectionHeader( - 'Room Servers', - roomsCollapsed, - () => setRoomsCollapsed((prev) => !prev), - 'rooms', - roomsUnreadCount, - roomsUnreadCount > 0 - )} - {(isSearching || !roomsCollapsed) && roomRows.map((row) => renderConversationRow(row))} - - )} - {/* Repeaters */} {nonFavoriteRepeaters.length > 0 && ( <> @@ -975,6 +960,21 @@ export function Sidebar({ )} + {/* Room Servers */} + {nonFavoriteRooms.length > 0 && ( + <> + {renderSectionHeader( + 'Room Servers', + roomsCollapsed, + () => setRoomsCollapsed((prev) => !prev), + 'rooms', + roomsUnreadCount, + roomsUnreadCount > 0 + )} + {(isSearching || !roomsCollapsed) && roomRows.map((row) => renderConversationRow(row))} + + )} + {/* Empty state */} {nonFavoriteContacts.length === 0 && nonFavoriteRooms.length === 0 && From 9de4158a6c7190ebd7fabc7f857a607dd92e884b Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Sat, 21 Mar 2026 22:46:59 -0700 Subject: [PATCH 02/64] Monkeypatch the meshcore_py lib for frame-start handling --- app/main.py | 34 +++++++++++++++++ tests/test_main_startup.py | 76 +++++++++++++++++++++++++++++++++++++- 2 files changed, 109 insertions(+), 1 deletion(-) diff --git a/app/main.py b/app/main.py index 911635b..67e8992 100644 --- a/app/main.py +++ b/app/main.py @@ -45,6 +45,40 @@ setup_logging() logger = logging.getLogger(__name__) +def _install_meshcore_serial_junk_prefix_patch(serial_connection_cls=None) -> None: + """Make meshcore serial framing tolerant of leading junk bytes. + + Some radios emit console/debug text on the same UART as the companion + protocol. The current meshcore serial parser searches for the frame start + marker but then incorrectly begins parsing at byte 0 of the chunk instead + of the located marker offset, which can drop valid response frames. + + TODO: Remove this monkeypatch once meshcore_py includes the upstream fix: + https://github.com/meshcore-dev/meshcore_py/pull/67 + """ + if serial_connection_cls is None: + from meshcore.serial_cx import SerialConnection as serial_connection_cls + + original_handle_rx = serial_connection_cls.handle_rx + if getattr(original_handle_rx, "_rtmesh_junk_prefix_patch", False): + return + + def patched_handle_rx(self, data: bytearray): + if len(self.header) == 0: + idx = data.find(b"\x3e") + if idx < 0: + return + if idx > 0: + data = data[idx:] + return original_handle_rx(self, data) + + patched_handle_rx._rtmesh_junk_prefix_patch = True + serial_connection_cls.handle_rx = patched_handle_rx + + +_install_meshcore_serial_junk_prefix_patch() + + async def _startup_radio_connect_and_setup() -> None: """Connect/setup the radio in the background so HTTP serving can start immediately.""" try: diff --git a/tests/test_main_startup.py b/tests/test_main_startup.py index 0bb5cb7..00655fc 100644 --- a/tests/test_main_startup.py +++ b/tests/test_main_startup.py @@ -5,10 +5,84 @@ from unittest.mock import AsyncMock, patch import pytest -from app.main import app, lifespan +from app.main import _install_meshcore_serial_junk_prefix_patch, app, lifespan class TestStartupLifespan: + @pytest.mark.asyncio + async def test_meshcore_serial_patch_discards_leading_junk_before_frame(self): + class RecordingReader: + def __init__(self): + self.frames = [] + + async def handle_rx(self, data): + self.frames.append(bytes(data)) + + class FakeSerialConnection: + def __init__(self): + self.header = b"" + self.reader = None + self.frame_expected_size = 0 + self.inframe = b"" + + def set_reader(self, reader): + self.reader = reader + + def handle_rx(self, data: bytearray): + if len(self.header) == 0: + idx = data.find(b"\x3e") + if idx < 0: + return + self.header = data[0:1] + data = data[1:] + + if len(self.header) < 3: + while len(self.header) < 3 and len(data) > 0: + self.header = self.header + data[0:1] + data = data[1:] + if len(self.header) < 3: + return + + self.frame_expected_size = int.from_bytes( + self.header[1:], "little", signed=False + ) + if self.frame_expected_size > 300: + self.header = b"" + self.inframe = b"" + self.frame_expected_size = 0 + if len(data) > 0: + self.handle_rx(data) + return + + upbound = self.frame_expected_size - len(self.inframe) + if len(data) < upbound: + self.inframe = self.inframe + data + return + + self.inframe = self.inframe + data[0:upbound] + data = data[upbound:] + if self.reader is not None: + asyncio.create_task(self.reader.handle_rx(self.inframe)) + self.inframe = b"" + self.header = b"" + self.frame_expected_size = 0 + if len(data) > 0: + self.handle_rx(data) + + _install_meshcore_serial_junk_prefix_patch(FakeSerialConnection) + + conn = FakeSerialConnection() + reader = RecordingReader() + conn.set_reader(reader) + + payload = b"\x00\x01\x02\x53" + frame = b"\x3e" + len(payload).to_bytes(2, "little") + payload + + conn.handle_rx(b"junk bytes\r\n" + frame) + await asyncio.sleep(0) + + assert reader.frames == [payload] + @pytest.mark.asyncio async def test_lifespan_does_not_wait_for_radio_setup(self): """HTTP serving should start before post-connect setup finishes.""" From d840159f9ce34347a811dc3c864872ca2ae23fa5 Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Sun, 22 Mar 2026 11:06:24 -0700 Subject: [PATCH 03/64] Update meshcore_py and remove monkeypatch for serial frame start detection. --- app/main.py | 34 ----------------- pyproject.toml | 2 +- tests/test_main_startup.py | 76 +------------------------------------- uv.lock | 8 ++-- 4 files changed, 6 insertions(+), 114 deletions(-) diff --git a/app/main.py b/app/main.py index 67e8992..911635b 100644 --- a/app/main.py +++ b/app/main.py @@ -45,40 +45,6 @@ setup_logging() logger = logging.getLogger(__name__) -def _install_meshcore_serial_junk_prefix_patch(serial_connection_cls=None) -> None: - """Make meshcore serial framing tolerant of leading junk bytes. - - Some radios emit console/debug text on the same UART as the companion - protocol. The current meshcore serial parser searches for the frame start - marker but then incorrectly begins parsing at byte 0 of the chunk instead - of the located marker offset, which can drop valid response frames. - - TODO: Remove this monkeypatch once meshcore_py includes the upstream fix: - https://github.com/meshcore-dev/meshcore_py/pull/67 - """ - if serial_connection_cls is None: - from meshcore.serial_cx import SerialConnection as serial_connection_cls - - original_handle_rx = serial_connection_cls.handle_rx - if getattr(original_handle_rx, "_rtmesh_junk_prefix_patch", False): - return - - def patched_handle_rx(self, data: bytearray): - if len(self.header) == 0: - idx = data.find(b"\x3e") - if idx < 0: - return - if idx > 0: - data = data[idx:] - return original_handle_rx(self, data) - - patched_handle_rx._rtmesh_junk_prefix_patch = True - serial_connection_cls.handle_rx = patched_handle_rx - - -_install_meshcore_serial_junk_prefix_patch() - - async def _startup_radio_connect_and_setup() -> None: """Connect/setup the radio in the background so HTTP serving can start immediately.""" try: diff --git a/pyproject.toml b/pyproject.toml index ef8e5e5..03d0331 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,7 +12,7 @@ dependencies = [ "httpx>=0.28.1", "pycryptodome>=3.20.0", "pynacl>=1.5.0", - "meshcore==2.3.1", + "meshcore==2.3.2", "aiomqtt>=2.0", "apprise>=1.9.7", "boto3>=1.38.0", diff --git a/tests/test_main_startup.py b/tests/test_main_startup.py index 00655fc..0bb5cb7 100644 --- a/tests/test_main_startup.py +++ b/tests/test_main_startup.py @@ -5,84 +5,10 @@ from unittest.mock import AsyncMock, patch import pytest -from app.main import _install_meshcore_serial_junk_prefix_patch, app, lifespan +from app.main import app, lifespan class TestStartupLifespan: - @pytest.mark.asyncio - async def test_meshcore_serial_patch_discards_leading_junk_before_frame(self): - class RecordingReader: - def __init__(self): - self.frames = [] - - async def handle_rx(self, data): - self.frames.append(bytes(data)) - - class FakeSerialConnection: - def __init__(self): - self.header = b"" - self.reader = None - self.frame_expected_size = 0 - self.inframe = b"" - - def set_reader(self, reader): - self.reader = reader - - def handle_rx(self, data: bytearray): - if len(self.header) == 0: - idx = data.find(b"\x3e") - if idx < 0: - return - self.header = data[0:1] - data = data[1:] - - if len(self.header) < 3: - while len(self.header) < 3 and len(data) > 0: - self.header = self.header + data[0:1] - data = data[1:] - if len(self.header) < 3: - return - - self.frame_expected_size = int.from_bytes( - self.header[1:], "little", signed=False - ) - if self.frame_expected_size > 300: - self.header = b"" - self.inframe = b"" - self.frame_expected_size = 0 - if len(data) > 0: - self.handle_rx(data) - return - - upbound = self.frame_expected_size - len(self.inframe) - if len(data) < upbound: - self.inframe = self.inframe + data - return - - self.inframe = self.inframe + data[0:upbound] - data = data[upbound:] - if self.reader is not None: - asyncio.create_task(self.reader.handle_rx(self.inframe)) - self.inframe = b"" - self.header = b"" - self.frame_expected_size = 0 - if len(data) > 0: - self.handle_rx(data) - - _install_meshcore_serial_junk_prefix_patch(FakeSerialConnection) - - conn = FakeSerialConnection() - reader = RecordingReader() - conn.set_reader(reader) - - payload = b"\x00\x01\x02\x53" - frame = b"\x3e" + len(payload).to_bytes(2, "little") + payload - - conn.handle_rx(b"junk bytes\r\n" + frame) - await asyncio.sleep(0) - - assert reader.frames == [payload] - @pytest.mark.asyncio async def test_lifespan_does_not_wait_for_radio_setup(self): """HTTP serving should start before post-connect setup finishes.""" diff --git a/uv.lock b/uv.lock index 55690bb..0433bb7 100644 --- a/uv.lock +++ b/uv.lock @@ -534,7 +534,7 @@ wheels = [ [[package]] name = "meshcore" -version = "2.3.1" +version = "2.3.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "bleak" }, @@ -542,9 +542,9 @@ dependencies = [ { name = "pycryptodome" }, { name = "pyserial-asyncio-fast" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c2/a8/79f84f32cad056358b1e31dbb343d7f986f78fd93021dbbde306a9b4d36e/meshcore-2.3.1.tar.gz", hash = "sha256:07bd2267cb84a335b915ea6dab1601ae7ae13cad5923793e66b2356c3e351e24", size = 69503 } +sdist = { url = "https://files.pythonhosted.org/packages/4c/32/6e7a3e7dcc379888bc2bfcbbdf518af89e47b3697977cbfefd0b87fdf333/meshcore-2.3.2.tar.gz", hash = "sha256:98ceb8c28a8abe5b5b77f0941b30f99ba3d4fc2350f76de99b6c8a4e778dad6f", size = 69871 } wheels = [ - { url = "https://files.pythonhosted.org/packages/14/df/66d615298b717c2c6471592e2b96117f391ae3c99f477d7f424449897bf0/meshcore-2.3.1-py3-none-any.whl", hash = "sha256:59bb8b66fd9e3261dbdb0e69fc038d4606bfd4ad1a260bbdd8659066e4bf12d2", size = 53084 }, + { url = "https://files.pythonhosted.org/packages/db/e4/9aafcd70315e48ca1bbae2f4ad1e00a13d5ef00019c486f964b31c34c488/meshcore-2.3.2-py3-none-any.whl", hash = "sha256:7b98e6d71f2c1e1ee146dd2fe96da40eb5bf33077e34ca840557ee53b192e322", size = 53325 }, ] [[package]] @@ -1142,7 +1142,7 @@ requires-dist = [ { name = "fastapi", specifier = ">=0.115.0" }, { name = "httpx", specifier = ">=0.28.1" }, { name = "httpx", marker = "extra == 'test'", specifier = ">=0.27.0" }, - { name = "meshcore", specifier = "==2.3.1" }, + { name = "meshcore", specifier = "==2.3.2" }, { name = "pycryptodome", specifier = ">=3.20.0" }, { name = "pydantic-settings", specifier = ">=2.0.0" }, { name = "pynacl", specifier = ">=1.5.0" }, From da31b67d5420e253e2aa3eb15dab07e7f750d3dc Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Sun, 22 Mar 2026 21:34:41 -0700 Subject: [PATCH 04/64] Add on-receive packet analyzer for canonical copy. Closes #97. --- app/events.py | 1 + app/models.py | 19 ++ app/repository/messages.py | 29 ++- app/repository/raw_packets.py | 12 ++ app/routers/packets.py | 42 ++++- app/services/dm_ingest.py | 1 + app/services/messages.py | 10 + frontend/src/api.ts | 2 + frontend/src/components/ConversationPane.tsx | 1 + frontend/src/components/MessageList.tsx | 82 +++++++- frontend/src/components/PathModal.tsx | 11 ++ .../src/components/RawPacketDetailModal.tsx | 177 ++++++++++++++++-- frontend/src/components/RawPacketFeedView.tsx | 109 ++--------- .../settings/SettingsDatabaseSection.tsx | 4 +- frontend/src/hooks/useConversationMessages.ts | 71 +++++-- frontend/src/hooks/useRealtimeAppState.ts | 16 +- frontend/src/test/settingsModal.test.tsx | 4 + .../src/test/useWebSocket.dispatch.test.ts | 15 +- frontend/src/types.ts | 1 + frontend/src/useWebSocket.ts | 15 +- frontend/src/wsEvents.ts | 1 + tests/test_echo_dedup.py | 5 +- tests/test_event_handlers.py | 1 + tests/test_packets_router.py | 42 +++++ 24 files changed, 534 insertions(+), 137 deletions(-) diff --git a/app/events.py b/app/events.py index 51c6ecb..35a9b87 100644 --- a/app/events.py +++ b/app/events.py @@ -44,6 +44,7 @@ class MessageAckedPayload(TypedDict): message_id: int ack_count: int paths: NotRequired[list[MessagePath]] + packet_id: NotRequired[int | None] class ToastPayload(TypedDict): diff --git a/app/models.py b/app/models.py index 650ec18..e2774d8 100644 --- a/app/models.py +++ b/app/models.py @@ -413,6 +413,10 @@ class Message(BaseModel): acked: int = 0 sender_name: str | None = None channel_name: str | None = None + packet_id: int | None = Field( + default=None, + description="Representative raw packet row ID when archival raw bytes exist", + ) class MessagesAroundResponse(BaseModel): @@ -458,6 +462,21 @@ class RawPacketBroadcast(BaseModel): decrypted_info: RawPacketDecryptedInfo | None = None +class RawPacketDetail(BaseModel): + """Stored raw-packet detail returned by the packet API.""" + + id: int + timestamp: int + data: str = Field(description="Hex-encoded packet data") + payload_type: str = Field(description="Packet type name (e.g. GROUP_TEXT, ADVERT)") + snr: float | None = Field(default=None, description="Signal-to-noise ratio in dB if available") + rssi: int | None = Field( + default=None, description="Received signal strength in dBm if available" + ) + decrypted: bool = False + decrypted_info: RawPacketDecryptedInfo | None = None + + class SendMessageRequest(BaseModel): text: str = Field(min_length=1) diff --git a/app/repository/messages.py b/app/repository/messages.py index 945017e..ad1559b 100644 --- a/app/repository/messages.py +++ b/app/repository/messages.py @@ -331,6 +331,12 @@ class MessageRepository: @staticmethod def _row_to_message(row: Any) -> Message: """Convert a database row to a Message model.""" + packet_id = None + if hasattr(row, "keys"): + row_keys = row.keys() + if "packet_id" in row_keys: + packet_id = row["packet_id"] + return Message( id=row["id"], type=row["type"], @@ -345,6 +351,14 @@ class MessageRepository: outgoing=bool(row["outgoing"]), acked=row["acked"], sender_name=row["sender_name"], + packet_id=packet_id, + ) + + @staticmethod + def _message_select(message_alias: str = "messages") -> str: + return ( + f"{message_alias}.*, " + f"(SELECT MIN(id) FROM raw_packets WHERE message_id = {message_alias}.id) AS packet_id" ) @staticmethod @@ -363,7 +377,7 @@ class MessageRepository: ) -> list[Message]: search_query = MessageRepository._parse_search_query(q) if q else None query = ( - "SELECT messages.* FROM messages " + f"SELECT {MessageRepository._message_select('messages')} FROM messages " "LEFT JOIN contacts ON messages.type = 'PRIV' " "AND LOWER(messages.conversation_key) = LOWER(contacts.public_key) " "LEFT JOIN channels ON messages.type = 'CHAN' " @@ -470,7 +484,8 @@ class MessageRepository: # 1. Get the target message (must satisfy filters if provided) target_cursor = await db.conn.execute( - f"SELECT * FROM messages WHERE id = ? AND {where_sql}", + f"SELECT {MessageRepository._message_select('messages')} " + f"FROM messages WHERE id = ? AND {where_sql}", (message_id, *base_params), ) target_row = await target_cursor.fetchone() @@ -481,7 +496,7 @@ class MessageRepository: # 2. Get context_size+1 messages before target (DESC) before_query = f""" - SELECT * FROM messages WHERE {where_sql} + SELECT {MessageRepository._message_select("messages")} FROM messages WHERE {where_sql} AND (received_at < ? OR (received_at = ? AND id < ?)) ORDER BY received_at DESC, id DESC LIMIT ? """ @@ -500,7 +515,7 @@ class MessageRepository: # 3. Get context_size+1 messages after target (ASC) after_query = f""" - SELECT * FROM messages WHERE {where_sql} + SELECT {MessageRepository._message_select("messages")} FROM messages WHERE {where_sql} AND (received_at > ? OR (received_at = ? AND id > ?)) ORDER BY received_at ASC, id ASC LIMIT ? """ @@ -545,7 +560,7 @@ class MessageRepository: async def get_by_id(message_id: int) -> "Message | None": """Look up a message by its ID.""" cursor = await db.conn.execute( - "SELECT * FROM messages WHERE id = ?", + f"SELECT {MessageRepository._message_select('messages')} FROM messages WHERE id = ?", (message_id,), ) row = await cursor.fetchone() @@ -570,7 +585,9 @@ class MessageRepository: ) -> "Message | None": """Look up a message by its unique content fields.""" query = """ - SELECT * FROM messages + SELECT messages.*, + (SELECT MIN(id) FROM raw_packets WHERE message_id = messages.id) AS packet_id + FROM messages WHERE type = ? AND conversation_key = ? AND text = ? AND (sender_timestamp = ? OR (sender_timestamp IS NULL AND ? IS NULL)) """ diff --git a/app/repository/raw_packets.py b/app/repository/raw_packets.py index 3a31e23..c773a67 100644 --- a/app/repository/raw_packets.py +++ b/app/repository/raw_packets.py @@ -121,6 +121,18 @@ class RawPacketRepository: return None return row["message_id"] + @staticmethod + async def get_by_id(packet_id: int) -> tuple[int, bytes, int, int | None] | None: + """Return a raw packet row as (id, data, timestamp, message_id).""" + cursor = await db.conn.execute( + "SELECT id, data, timestamp, message_id FROM raw_packets WHERE id = ?", + (packet_id,), + ) + row = await cursor.fetchone() + if not row: + return None + return (row["id"], bytes(row["data"]), row["timestamp"], row["message_id"]) + @staticmethod async def prune_old_undecrypted(max_age_days: int) -> int: """Delete undecrypted packets older than max_age_days. Returns count deleted.""" diff --git a/app/routers/packets.py b/app/routers/packets.py index 00316ee..4c6374c 100644 --- a/app/routers/packets.py +++ b/app/routers/packets.py @@ -8,8 +8,9 @@ from pydantic import BaseModel, Field from app.database import db from app.decoder import parse_packet, try_decrypt_packet_with_channel_key +from app.models import RawPacketDecryptedInfo, RawPacketDetail from app.packet_processor import create_message_from_decrypted, run_historical_dm_decryption -from app.repository import ChannelRepository, RawPacketRepository +from app.repository import ChannelRepository, MessageRepository, RawPacketRepository from app.websocket import broadcast_success logger = logging.getLogger(__name__) @@ -102,6 +103,45 @@ async def get_undecrypted_count() -> dict: return {"count": count} +@router.get("/{packet_id}", response_model=RawPacketDetail) +async def get_raw_packet(packet_id: int) -> RawPacketDetail: + """Fetch one stored raw packet by row ID for on-demand inspection.""" + packet_row = await RawPacketRepository.get_by_id(packet_id) + if packet_row is None: + raise HTTPException(status_code=404, detail="Raw packet not found") + + stored_packet_id, packet_data, packet_timestamp, message_id = packet_row + packet_info = parse_packet(packet_data) + payload_type_name = packet_info.payload_type.name if packet_info else "Unknown" + + decrypted_info: RawPacketDecryptedInfo | None = None + if message_id is not None: + message = await MessageRepository.get_by_id(message_id) + if message is not None: + if message.type == "CHAN": + channel = await ChannelRepository.get_by_key(message.conversation_key) + decrypted_info = RawPacketDecryptedInfo( + channel_name=channel.name if channel else None, + sender=message.sender_name, + channel_key=message.conversation_key, + contact_key=message.sender_key, + ) + else: + decrypted_info = RawPacketDecryptedInfo( + sender=message.sender_name, + contact_key=message.conversation_key, + ) + + return RawPacketDetail( + id=stored_packet_id, + timestamp=packet_timestamp, + data=packet_data.hex(), + payload_type=payload_type_name, + decrypted=message_id is not None, + decrypted_info=decrypted_info, + ) + + @router.post("/decrypt/historical", response_model=DecryptResult) async def decrypt_historical_packets( request: DecryptRequest, background_tasks: BackgroundTasks, response: Response diff --git a/app/services/dm_ingest.py b/app/services/dm_ingest.py index df01e46..bfd09ca 100644 --- a/app/services/dm_ingest.py +++ b/app/services/dm_ingest.py @@ -238,6 +238,7 @@ async def _store_direct_message( sender_key=sender_key, outgoing=outgoing, sender_name=sender_name, + packet_id=packet_id, ) broadcast_message(message=message, broadcast_fn=broadcast_fn, realtime=realtime) diff --git a/app/services/messages.py b/app/services/messages.py index 5508a6a..f5d1ea9 100644 --- a/app/services/messages.py +++ b/app/services/messages.py @@ -62,6 +62,7 @@ def build_message_model( acked: int = 0, sender_name: str | None = None, channel_name: str | None = None, + packet_id: int | None = None, ) -> Message: """Build a Message model with the canonical backend payload shape.""" return Message( @@ -79,6 +80,7 @@ def build_message_model( acked=acked, sender_name=sender_name, channel_name=channel_name, + packet_id=packet_id, ) @@ -131,6 +133,7 @@ def broadcast_message_acked( message_id: int, ack_count: int, paths: list[MessagePath] | None, + packet_id: int | None, broadcast_fn: BroadcastFn, ) -> None: """Broadcast a message_acked payload.""" @@ -140,6 +143,7 @@ def broadcast_message_acked( "message_id": message_id, "ack_count": ack_count, "paths": [path.model_dump() for path in paths] if paths else [], + "packet_id": packet_id, }, ) @@ -182,11 +186,16 @@ async def reconcile_duplicate_message( else: ack_count = existing_msg.acked + representative_packet_id = ( + existing_msg.packet_id if existing_msg.packet_id is not None else packet_id + ) + if existing_msg.outgoing or path is not None: broadcast_message_acked( message_id=existing_msg.id, ack_count=ack_count, paths=paths, + packet_id=representative_packet_id, broadcast_fn=broadcast_fn, ) @@ -307,6 +316,7 @@ async def create_message_from_decrypted( sender_name=sender, sender_key=resolved_sender_key, channel_name=channel_name, + packet_id=packet_id, ), broadcast_fn=broadcast_fn, realtime=realtime, diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 56a610c..0c70c54 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -15,6 +15,7 @@ import type { MessagesAroundResponse, MigratePreferencesRequest, MigratePreferencesResponse, + RawPacket, RadioAdvertMode, RadioConfig, RadioConfigUpdate, @@ -247,6 +248,7 @@ export const api = { ), // Packets + getPacket: (packetId: number) => fetchJson(`/packets/${packetId}`), getUndecryptedPacketCount: () => fetchJson<{ count: number }>('/packets/undecrypted/count'), decryptHistoricalPackets: (params: { key_type: 'channel' | 'contact'; diff --git a/frontend/src/components/ConversationPane.tsx b/frontend/src/components/ConversationPane.tsx index c2f2ced..809fda3 100644 --- a/frontend/src/components/ConversationPane.tsx +++ b/frontend/src/components/ConversationPane.tsx @@ -261,6 +261,7 @@ export function ConversationPane({ key={activeConversation.id} messages={messages} contacts={contacts} + channels={channels} loading={messagesLoading} loadingOlder={loadingOlder} hasOlderMessages={hasOlderMessages} diff --git a/frontend/src/components/MessageList.tsx b/frontend/src/components/MessageList.tsx index fedcbaf..06f46f8 100644 --- a/frontend/src/components/MessageList.tsx +++ b/frontend/src/components/MessageList.tsx @@ -8,19 +8,23 @@ import { useState, type ReactNode, } from 'react'; -import type { Contact, Message, MessagePath, RadioConfig } from '../types'; +import type { Channel, Contact, Message, MessagePath, RadioConfig, RawPacket } from '../types'; import { CONTACT_TYPE_REPEATER, CONTACT_TYPE_ROOM } from '../types'; +import { api } from '../api'; import { formatTime, parseSenderFromText } from '../utils/messageParser'; import { formatHopCounts, type SenderInfo } from '../utils/pathUtils'; import { getDirectContactRoute } from '../utils/pathUtils'; import { ContactAvatar } from './ContactAvatar'; import { PathModal } from './PathModal'; +import { RawPacketInspectorDialog } from './RawPacketDetailModal'; +import { toast } from './ui/sonner'; import { handleKeyboardActivate } from '../utils/a11y'; import { cn } from '@/lib/utils'; interface MessageListProps { messages: Message[]; contacts: Contact[]; + channels?: Channel[]; loading: boolean; loadingOlder?: boolean; hasOlderMessages?: boolean; @@ -153,6 +157,8 @@ function HopCountBadge({ paths, onClick, variant }: HopCountBadgeProps) { const RESEND_WINDOW_SECONDS = 30; const CORRUPT_SENDER_LABEL = ''; +const ANALYZE_PACKET_NOTICE = + 'This analyzer shows one stored full packet copy only. When multiple receives have identical payloads, the backend deduplicates them to a single stored packet and appends any additional receive paths onto the message path history instead of storing multiple full packet copies.'; function hasUnexpectedControlChars(text: string): boolean { for (const char of text) { @@ -173,6 +179,7 @@ function hasUnexpectedControlChars(text: string): boolean { export function MessageList({ messages, contacts, + channels = [], loading, loadingOlder = false, hasOlderMessages = false, @@ -199,10 +206,18 @@ export function MessageList({ paths: MessagePath[]; senderInfo: SenderInfo; messageId?: number; + packetId?: number | null; isOutgoingChan?: boolean; } | null>(null); const [resendableIds, setResendableIds] = useState>(new Set()); const resendTimersRef = useRef>>(new Map()); + const packetCacheRef = useRef>(new Map()); + const [packetInspectorSource, setPacketInspectorSource] = useState< + | { kind: 'packet'; packet: RawPacket } + | { kind: 'loading'; message: string } + | { kind: 'unavailable'; message: string } + | null + >(null); const [highlightedMessageId, setHighlightedMessageId] = useState(null); const [showJumpToUnread, setShowJumpToUnread] = useState(false); const [jumpToUnreadDismissed, setJumpToUnreadDismissed] = useState(false); @@ -221,6 +236,43 @@ export function MessageList({ // Track conversation key to detect when entire message set changes const prevConvKeyRef = useRef(null); + const handleAnalyzePacket = useCallback(async (message: Message) => { + if (message.packet_id == null) { + setPacketInspectorSource({ + kind: 'unavailable', + message: + 'No archival raw packet is available for this message, so packet analysis cannot be shown.', + }); + return; + } + + const cached = packetCacheRef.current.get(message.packet_id); + if (cached) { + setPacketInspectorSource({ kind: 'packet', packet: cached }); + return; + } + + setPacketInspectorSource({ kind: 'loading', message: 'Loading packet analysis...' }); + + try { + const packet = await api.getPacket(message.packet_id); + packetCacheRef.current.set(message.packet_id, packet); + setPacketInspectorSource({ kind: 'packet', packet }); + } catch (error) { + const description = error instanceof Error ? error.message : 'Unknown error'; + const isMissing = error instanceof Error && /not found/i.test(error.message); + if (!isMissing) { + toast.error('Failed to load raw packet', { description }); + } + setPacketInspectorSource({ + kind: 'unavailable', + message: isMissing + ? 'The archival raw packet for this message is no longer available. It may have been purged from Settings > Database, so only the stored message and merged route history remain.' + : `Could not load the archival raw packet for this message: ${description}`, + }); + } + }, []); + // Handle scroll position AFTER render useLayoutEffect(() => { if (!listRef.current) return; @@ -833,6 +885,8 @@ export function MessageList({ setSelectedPath({ paths: msg.paths!, senderInfo: getSenderInfo(msg, contact, directSenderName || sender), + messageId: msg.id, + packetId: msg.packet_id, }) } /> @@ -859,6 +913,8 @@ export function MessageList({ setSelectedPath({ paths: msg.paths!, senderInfo: getSenderInfo(msg, contact, directSenderName || sender), + messageId: msg.id, + packetId: msg.packet_id, }) } /> @@ -879,6 +935,7 @@ export function MessageList({ paths: msg.paths!, senderInfo: selfSenderInfo, messageId: msg.id, + packetId: msg.packet_id, isOutgoingChan: msg.type === 'CHAN' && !!onResendChannelMessage, }); }} @@ -900,6 +957,7 @@ export function MessageList({ paths: [], senderInfo: selfSenderInfo, messageId: msg.id, + packetId: msg.packet_id, isOutgoingChan: true, }); }} @@ -997,9 +1055,31 @@ export function MessageList({ contacts={contacts} config={config ?? null} messageId={selectedPath.messageId} + packetId={selectedPath.packetId} isOutgoingChan={selectedPath.isOutgoingChan} isResendable={isSelectedMessageResendable} onResend={onResendChannelMessage} + onAnalyzePacket={ + selectedPath.packetId != null + ? () => { + const message = messages.find((entry) => entry.id === selectedPath.messageId); + if (message) { + void handleAnalyzePacket(message); + } + } + : undefined + } + /> + )} + {packetInspectorSource && ( + !isOpen && setPacketInspectorSource(null)} + channels={channels} + source={packetInspectorSource} + title="Analyze Packet" + description="On-demand raw packet analysis for a message-backed archival packet." + notice={ANALYZE_PACKET_NOTICE} /> )} diff --git a/frontend/src/components/PathModal.tsx b/frontend/src/components/PathModal.tsx index e2e5f68..9ff0916 100644 --- a/frontend/src/components/PathModal.tsx +++ b/frontend/src/components/PathModal.tsx @@ -29,9 +29,11 @@ interface PathModalProps { contacts: Contact[]; config: RadioConfig | null; messageId?: number; + packetId?: number | null; isOutgoingChan?: boolean; isResendable?: boolean; onResend?: (messageId: number, newTimestamp?: boolean) => void; + onAnalyzePacket?: () => void; } export function PathModal({ @@ -42,14 +44,17 @@ export function PathModal({ contacts, config, messageId, + packetId, isOutgoingChan, isResendable, onResend, + onAnalyzePacket, }: PathModalProps) { const { distanceUnit } = useDistanceUnit(); const [expandedMaps, setExpandedMaps] = useState>(new Set()); const hasResendActions = isOutgoingChan && messageId !== undefined && onResend; const hasPaths = paths.length > 0; + const showAnalyzePacket = hasPaths && packetId != null && onAnalyzePacket; // Resolve all paths const resolvedPaths = hasPaths @@ -90,6 +95,12 @@ export function PathModal({ {hasPaths && (
+ {showAnalyzePacket ? ( + + ) : null} + {/* Raw path summary */}
{paths.map((p, index) => { diff --git a/frontend/src/components/RawPacketDetailModal.tsx b/frontend/src/components/RawPacketDetailModal.tsx index 4498755..73c633f 100644 --- a/frontend/src/components/RawPacketDetailModal.tsx +++ b/frontend/src/components/RawPacketDetailModal.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from 'react'; +import { useEffect, useMemo, useState, type ReactNode } from 'react'; import { ChannelCrypto, PayloadType } from '@michaelhart/meshcore-decoder'; import type { Channel, RawPacket } from '../types'; @@ -18,6 +18,33 @@ interface RawPacketDetailModalProps { onClose: () => void; } +type RawPacketInspectorDialogSource = + | { + kind: 'packet'; + packet: RawPacket; + } + | { + kind: 'paste'; + } + | { + kind: 'loading'; + message: string; + } + | { + kind: 'unavailable'; + message: string; + }; + +interface RawPacketInspectorDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + channels: Channel[]; + source: RawPacketInspectorDialogSource; + title: string; + description: string; + notice?: ReactNode; +} + interface RawPacketInspectionPanelProps { packet: RawPacket; channels: Channel[]; @@ -365,6 +392,36 @@ function renderFieldValue(field: PacketByteField) { ); } +function normalizePacketHex(input: string): string { + return input.replace(/\s+/g, '').toUpperCase(); +} + +function validatePacketHex(input: string): string | null { + if (!input) { + return 'Paste a packet hex string to analyze.'; + } + if (!/^[0-9A-F]+$/.test(input)) { + return 'Packet hex may only contain 0-9 and A-F characters.'; + } + if (input.length % 2 !== 0) { + return 'Packet hex must contain an even number of characters.'; + } + return null; +} + +function buildPastedRawPacket(packetHex: string): RawPacket { + return { + id: -1, + timestamp: Math.floor(Date.now() / 1000), + data: packetHex, + payload_type: 'Unknown', + snr: null, + rssi: null, + decrypted: false, + decrypted_info: null, + }; +} + function FieldBox({ field, palette, @@ -645,22 +702,118 @@ export function RawPacketInspectionPanel({ packet, channels }: RawPacketInspecti ); } +export function RawPacketInspectorDialog({ + open, + onOpenChange, + channels, + source, + title, + description, + notice, +}: RawPacketInspectorDialogProps) { + const [packetInput, setPacketInput] = useState(''); + + useEffect(() => { + if (!open || source.kind !== 'paste') { + setPacketInput(''); + } + }, [open, source.kind]); + + const normalizedPacketInput = useMemo(() => normalizePacketHex(packetInput), [packetInput]); + const packetInputError = useMemo( + () => (normalizedPacketInput.length > 0 ? validatePacketHex(normalizedPacketInput) : null), + [normalizedPacketInput] + ); + const analyzedPacket = useMemo( + () => + normalizedPacketInput.length > 0 && packetInputError === null + ? buildPastedRawPacket(normalizedPacketInput) + : null, + [normalizedPacketInput, packetInputError] + ); + + let body: ReactNode; + if (source.kind === 'packet') { + body = ; + } else if (source.kind === 'paste') { + body = ( + <> +
+
+ +