From 0b483c4f6dfc1f586283c1a8c692df0fd04c9f93 Mon Sep 17 00:00:00 2001 From: Cameron Will Date: Mon, 16 Feb 2026 11:55:22 -0500 Subject: [PATCH] feat: Add path view on messages Fixes #24 --- src/meshcore_console/core/models.py | 1 + src/meshcore_console/meshcore/client.py | 2 ++ src/meshcore_console/meshcore/db.py | 2 ++ src/meshcore_console/meshcore/state_store.py | 12 ++++--- src/meshcore_console/mock/client.py | 2 ++ src/meshcore_console/mock/data.py | 10 ++++-- src/meshcore_console/ui_gtk/views/messages.py | 31 ++++++++++++++++--- .../ui_gtk/widgets/message_bubble.py | 6 +++- tests/unit/test_db.py | 1 + uv.lock | 2 +- 10 files changed, 56 insertions(+), 13 deletions(-) diff --git a/src/meshcore_console/core/models.py b/src/meshcore_console/core/models.py index 4492779..73e7686 100644 --- a/src/meshcore_console/core/models.py +++ b/src/meshcore_console/core/models.py @@ -36,6 +36,7 @@ class Message: created_at: datetime = field(default_factory=lambda: datetime.now(UTC)) is_outgoing: bool = False path_len: int = 0 + path_hops: list[str] = field(default_factory=list) snr: float | None = None rssi: int | None = None diff --git a/src/meshcore_console/meshcore/client.py b/src/meshcore_console/meshcore/client.py index 84a5577..bdb89cc 100644 --- a/src/meshcore_console/meshcore/client.py +++ b/src/meshcore_console/meshcore/client.py @@ -580,6 +580,7 @@ def _process_message_event(self, data: MeshEventDict, event_type: str = "") -> N snr = data.get("snr") rssi = data.get("rssi") path_len = data.get("path_len") or 0 + path_hops = data.get("path_hops", []) message = Message( message_id=msg_id, sender_id=sender_name, @@ -588,6 +589,7 @@ def _process_message_event(self, data: MeshEventDict, event_type: str = "") -> N created_at=datetime.now(UTC), is_outgoing=False, path_len=int(path_len) if path_len else 0, + path_hops=list(path_hops) if path_hops else [], snr=float(snr) if snr is not None else None, rssi=int(rssi) if rssi is not None else None, ) diff --git a/src/meshcore_console/meshcore/db.py b/src/meshcore_console/meshcore/db.py index bf0f6ba..3efd24b 100644 --- a/src/meshcore_console/meshcore/db.py +++ b/src/meshcore_console/meshcore/db.py @@ -72,6 +72,8 @@ tuple(stmt.strip() for stmt in SCHEMA_V1.split(";") if stmt.strip()), # v1 -> v2: add peer_name column to channels for original-case contact names ("ALTER TABLE channels ADD COLUMN peer_name TEXT",), + # v2 -> v3: add path_hops column to messages for route visualization + ("ALTER TABLE messages ADD COLUMN path_hops TEXT",), ] diff --git a/src/meshcore_console/meshcore/state_store.py b/src/meshcore_console/meshcore/state_store.py index 8e5d2ef..17302ff 100644 --- a/src/meshcore_console/meshcore/state_store.py +++ b/src/meshcore_console/meshcore/state_store.py @@ -23,8 +23,8 @@ def __init__(self, conn: sqlite3.Connection) -> None: def append(self, message: Message) -> None: self._conn.execute( "INSERT OR IGNORE INTO messages " - "(message_id, sender_id, body, channel_id, created_at, is_outgoing, path_len, snr, rssi) " - "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + "(message_id, sender_id, body, channel_id, created_at, is_outgoing, path_len, snr, rssi, path_hops) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", ( message.message_id, message.sender_id, @@ -35,6 +35,7 @@ def append(self, message: Message) -> None: message.path_len, message.snr, message.rssi, + json.dumps(message.path_hops) if message.path_hops else None, ), ) # Prune oldest messages beyond limit @@ -53,14 +54,14 @@ def flush_if_dirty(self) -> None: def get_all(self) -> list[Message]: rows = self._conn.execute( "SELECT message_id, sender_id, body, channel_id, created_at, " - "is_outgoing, path_len, snr, rssi FROM messages ORDER BY created_at" + "is_outgoing, path_len, snr, rssi, path_hops FROM messages ORDER BY created_at" ).fetchall() return [_row_to_message(r) for r in rows] def get_for_channel(self, channel_id: str, limit: int = 50) -> list[Message]: rows = self._conn.execute( "SELECT message_id, sender_id, body, channel_id, created_at, " - "is_outgoing, path_len, snr, rssi FROM messages " + "is_outgoing, path_len, snr, rssi, path_hops FROM messages " "WHERE channel_id = ? ORDER BY created_at", (channel_id,), ).fetchall() @@ -195,6 +196,8 @@ def _row_to_message(row: tuple) -> Message: created_at = datetime.fromisoformat(created_at) else: created_at = datetime.now(UTC) + path_hops_raw = row[9] if len(row) > 9 else None + path_hops = json.loads(path_hops_raw) if isinstance(path_hops_raw, str) else [] return Message( message_id=row[0], sender_id=row[1], @@ -203,6 +206,7 @@ def _row_to_message(row: tuple) -> Message: created_at=created_at, is_outgoing=bool(row[5]), path_len=row[6] or 0, + path_hops=path_hops, snr=row[7], rssi=row[8], ) diff --git a/src/meshcore_console/mock/client.py b/src/meshcore_console/mock/client.py index da4d021..5add9e5 100644 --- a/src/meshcore_console/mock/client.py +++ b/src/meshcore_console/mock/client.py @@ -240,6 +240,7 @@ def _process_event_for_messages(self, event: dict) -> None: if content_key in existing: return + path_hops = data.get("path_hops", []) message = Message( message_id=str(uuid4()), sender_id=sender_name, @@ -248,6 +249,7 @@ def _process_event_for_messages(self, event: dict) -> None: created_at=datetime.now(UTC), is_outgoing=False, path_len=int(data.get("path_len") or 0), + path_hops=list(path_hops) if path_hops else [], snr=float(data["snr"]) if data.get("snr") is not None else None, rssi=int(data["rssi"]) if data.get("rssi") is not None else None, ) diff --git a/src/meshcore_console/mock/data.py b/src/meshcore_console/mock/data.py index 758f929..3909f86 100644 --- a/src/meshcore_console/mock/data.py +++ b/src/meshcore_console/mock/data.py @@ -97,18 +97,24 @@ def create_mock_messages() -> list[Message]: sender_id="Relay A", body="Route check complete", channel_id="test", + path_len=2, + path_hops=["B7", "C2"], ), Message( message_id=str(uuid4()), sender_id="Backhaul B", body="Link stable at SF7", channel_id="public", + path_len=2, + path_hops=["relay-001", "relay-002"], ), Message( message_id=str(uuid4()), sender_id="Relay A", body="Forwarding advert burst", channel_id="ops", + path_len=1, + path_hops=["relay-002"], ), ] @@ -195,8 +201,8 @@ def _mock_ts(seed: str, day: datetime = now, spread_min: int = 120) -> str: "rssi": -95, "snr": -2.50, "payload_hex": "f589d410abcd1234567890abcdef", - "path_len": 0, - "path_hops": [], + "path_len": 2, + "path_hops": ["B7", "C2"], "packet_hash": "1234567890AB", }, }, diff --git a/src/meshcore_console/ui_gtk/views/messages.py b/src/meshcore_console/ui_gtk/views/messages.py index d91438c..7e5b74a 100644 --- a/src/meshcore_console/ui_gtk/views/messages.py +++ b/src/meshcore_console/ui_gtk/views/messages.py @@ -13,7 +13,8 @@ from meshcore_console.core.radio import snr_to_quality from meshcore_console.core.services import MeshcoreService from meshcore_console.core.time import to_local -from meshcore_console.ui_gtk.widgets import DetailRow, EmptyState, MessageBubble +from meshcore_console.ui_gtk.widgets import DetailRow, EmptyState, MessageBubble, PathVisualization +from meshcore_console.ui_gtk.widgets.node_badge import STYLE_DEFAULT, STYLE_SELF, find_peer_for_hop logger = logging.getLogger(__name__) @@ -316,14 +317,34 @@ def _show_message_details(self, message: Message) -> None: details_header.set_margin_top(8) self._details_box.append(details_header) - # Hops + # Route / Hops if message.is_outgoing: - hops_text = "Sent" + self._details_box.append(DetailRow("Hops:", "Sent")) + elif message.path_hops: + route_label = Gtk.Label(label="Route:") + route_label.add_css_class("detail-label") + route_label.set_halign(Gtk.Align.START) + self._details_box.append(route_label) + + all_peers = self._service.list_peers() + sender_name = message.sender_id + sender_peer = find_peer_for_hop(all_peers, sender_name) + sender_prefix = (sender_name or "??")[:2].upper() + + path = PathVisualization( + hops=message.path_hops, + peers=all_peers, + arrow="\u2190", + start=("Me", "You (this node)", None, STYLE_SELF), + end=(sender_prefix, sender_name, sender_peer, STYLE_DEFAULT), + ) + path.set_margin_top(4) + self._details_box.append(path) elif message.path_len == 0: - hops_text = "Direct" + self._details_box.append(DetailRow("Hops:", "Direct")) else: hops_text = f"{message.path_len} hop{'s' if message.path_len != 1 else ''}" - self._details_box.append(DetailRow("Hops:", hops_text)) + self._details_box.append(DetailRow("Hops:", hops_text)) # Time time_label = "Sent:" if message.is_outgoing else "Received:" diff --git a/src/meshcore_console/ui_gtk/widgets/message_bubble.py b/src/meshcore_console/ui_gtk/widgets/message_bubble.py index 4e3ddef..ec837fc 100644 --- a/src/meshcore_console/ui_gtk/widgets/message_bubble.py +++ b/src/meshcore_console/ui_gtk/widgets/message_bubble.py @@ -81,7 +81,11 @@ def __init__( if message.is_outgoing: meta_text = local_time else: - meta_text = f"{message.sender_id} {local_time}" + path_str = ",".join(message.path_hops) if message.path_hops else "" + if path_str: + meta_text = f"{message.sender_id} {local_time} {path_str}" + else: + meta_text = f"{message.sender_id} {local_time}" meta = Gtk.Label(label=meta_text) meta.add_css_class("message-meta") meta.set_xalign(1 if message.is_outgoing else 0) diff --git a/tests/unit/test_db.py b/tests/unit/test_db.py index dd69154..6d4d123 100644 --- a/tests/unit/test_db.py +++ b/tests/unit/test_db.py @@ -78,6 +78,7 @@ def conn(tmp_path): "path_len", "snr", "rssi", + "path_hops", ], "packets": ["id", "received_at", "data"], } diff --git a/uv.lock b/uv.lock index 88ab8d9..8819e32 100644 --- a/uv.lock +++ b/uv.lock @@ -521,7 +521,7 @@ wheels = [ [[package]] name = "meshcore-uconsole" -version = "1.3.0" +version = "1.3.1" source = { editable = "." } dependencies = [ { name = "pymc-core" },