From bb67c4d3eaabc906ca08f7abeca136487d5e36a6 Mon Sep 17 00:00:00 2001 From: MarekWo Date: Wed, 22 Jul 2026 07:05:52 +0200 Subject: [PATCH 1/3] fix: duplicate _refresh_channel_secret silently disabled exact echo matching The class defined _refresh_channel_secret twice. The second definition (legacy, returns None) shadowed the first one added in 77c3ffa, so send_channel_message never got the channel secret, expected_payloads was always empty, and every sent-message echo correlation fell through to the loose 60s channel-hash-byte fallback. Any foreign GRP_TXT echo on the same channel inside that window could then be mis-assigned to our sent message (wrong hash + physically impossible path on the badge and in Path Analyzer). Drop the legacy definition and let set_channel use the surviving one, which also re-reads the secret and updates cache + DB. Co-Authored-By: Claude Fable 5 --- app/device_manager.py | 21 +-------------------- 1 file changed, 1 insertion(+), 20 deletions(-) diff --git a/app/device_manager.py b/app/device_manager.py index 9ac5286..40866dc 100644 --- a/app/device_manager.py +++ b/app/device_manager.py @@ -2674,32 +2674,13 @@ class DeviceManager: # Read back the actual secret from device (firmware may have # generated it for # channels) and update in-memory cache + DB. - self._refresh_channel_secret(idx, name) + self._refresh_channel_secret(idx) return {'success': True, 'message': f'Channel {idx} set'} except Exception as e: logger.error(f"Failed to set channel: {e}") return {'success': False, 'error': str(e)} - def _refresh_channel_secret(self, idx: int, name: str = ''): - """Read back a channel's secret from device and update cache + DB.""" - try: - event = self.execute(self.mc.commands.get_channel(idx)) - if event: - data = getattr(event, 'payload', None) or {} - secret = data.get('channel_secret', data.get('secret', b'')) - if isinstance(secret, bytes): - secret = secret.hex() - if secret and len(secret) == 32: - self._channel_secrets[idx] = secret - ch_name = data.get('channel_name', data.get('name', '')) - if isinstance(ch_name, str): - ch_name = ch_name.strip('\x00').strip() - self.db.upsert_channel(idx, ch_name or name, secret) - logger.info(f"Refreshed channel {idx} secret into cache") - except Exception as e: - logger.warning(f"Failed to refresh channel {idx} secret: {e}") - def remove_channel(self, idx: int) -> Dict: """Remove a channel from the device.""" if not self.is_connected: From d8d3427dc93c4da6ec9b1c6c70bbdee72f84956d Mon Sep 17 00:00:00 2001 From: MarekWo Date: Thu, 23 Jul 2026 07:10:31 +0200 Subject: [PATCH 2/3] feat(pathanalyzer): chat route click opens the analyzer map deep-linked Clicking a route in the chat path popup now opens Path Analyzer on the Map view with that message selected and that exact echo path drawn, instead of copying the route to the clipboard. Copy stays available as a small per-route clipboard icon in the popup. Deep link flow: popup click stores {packet_hash, path hex} in window.paDeepLink; the modal show handler builds the iframe URL with ?hash=&path=; the analyzer resolves the message after load (widening the time range once to 7 days if needed), matches the echo by raw path hex (fallback: shortest), and switches to the map. A plain menu open still loads the analyzer without any deep link. Verified live via Playwright: clicked the 3rd (non-shortest) route of a multi-route message - the map opened with exactly that echo selected, including the 3-to-7-day widening retry (message was 4 days old). Co-Authored-By: Claude Opus 4.8 --- app/static/css/style.css | 19 ++++++++++++ app/static/js/app.js | 56 ++++++++++++++++++++++++++++------ app/static/js/path-analyzer.js | 49 ++++++++++++++++++++++++++++- app/templates/index.html | 8 ++++- 4 files changed, 121 insertions(+), 11 deletions(-) diff --git a/app/static/css/style.css b/app/static/css/style.css index 56059ca..cd62f62 100644 --- a/app/static/css/style.css +++ b/app/static/css/style.css @@ -1557,6 +1557,25 @@ main { border-bottom: 1px solid rgba(255, 255, 255, 0.15); word-break: break-all; cursor: pointer; + display: flex; + align-items: flex-start; + gap: 0.4rem; +} + +.path-popup .path-route { + flex: 1 1 auto; + min-width: 0; +} + +.path-popup .path-copy { + flex: 0 0 auto; + padding: 0.1rem 0.15rem; + opacity: 0.7; + cursor: pointer; +} + +.path-popup .path-copy:hover { + opacity: 1; } .path-popup .path-entry:active { diff --git a/app/static/js/app.js b/app/static/js/app.js index 15d2844..76b1c52 100644 --- a/app/static/js/app.js +++ b/app/static/js/app.js @@ -1187,7 +1187,7 @@ function updateMessageMetaDOM(wrapper, meta) { : segments.join('\u2192'); const pathsData = encodeURIComponent(JSON.stringify(paths)); const routeLabel = paths.length > 1 ? `Route (${paths.length})` : 'Route'; - metaParts.push(`${routeLabel}: ${shortPath}`); + metaParts.push(`${routeLabel}: ${shortPath}`); } const metaInfo = metaParts.join(' | '); @@ -1329,7 +1329,7 @@ function createMessageElement(msg) { : segments.join('\u2192'); const pathsData = encodeURIComponent(JSON.stringify(msg.paths)); const routeLabel = msg.paths.length > 1 ? `Route (${msg.paths.length})` : 'Route'; - metaParts.push(`${routeLabel}: ${shortPath}`); + metaParts.push(`${routeLabel}: ${shortPath}`); } const metaInfo = metaParts.join(' | '); @@ -1693,7 +1693,7 @@ async function blockContactFromChat(senderName) { /** * Show paths popup on tap (mobile-friendly, shows all routes) */ -function showPathsPopup(element, encodedPaths) { +function showPathsPopup(element, encodedPaths, packetHash) { // Remove any existing popup const existing = document.querySelector('.path-popup'); if (existing) existing.remove(); @@ -1717,16 +1717,42 @@ function showPathsPopup(element, encodedPaths) { const hops = segments.length; const entry = document.createElement('div'); entry.className = 'path-entry'; - entry.innerHTML = `${fullRoute}SNR: ${snr} | Hops: ${hops}`; - entry.title = 'Tap to copy route'; - entry.addEventListener('click', (e) => { + + const body = document.createElement('span'); + body.className = 'path-route'; + body.innerHTML = `${fullRoute}SNR: ${snr} | Hops: ${hops}`; + entry.appendChild(body); + + const copyBtn = document.createElement('i'); + copyBtn.className = 'bi bi-clipboard path-copy'; + copyBtn.title = 'Copy route'; + copyBtn.addEventListener('click', (e) => { e.stopPropagation(); navigator.clipboard.writeText(commaRoute).then(() => { - const orig = entry.innerHTML; - entry.innerHTML = 'Copied!'; - setTimeout(() => { entry.innerHTML = orig; }, 1000); + copyBtn.className = 'bi bi-clipboard-check path-copy'; + setTimeout(() => { copyBtn.className = 'bi bi-clipboard path-copy'; }, 1000); }); }); + entry.appendChild(copyBtn); + + if (packetHash && p.path && segments.length > 0) { + entry.title = 'Show this route on the Path Analyzer map'; + entry.addEventListener('click', (e) => { + e.stopPropagation(); + popup.remove(); + openPathInAnalyzer(packetHash, p.path); + }); + } else { + // No packet hash (or direct message): keep the copy behavior + entry.title = 'Tap to copy route'; + entry.addEventListener('click', (e) => { + e.stopPropagation(); + navigator.clipboard.writeText(commaRoute).then(() => { + copyBtn.className = 'bi bi-clipboard-check path-copy'; + setTimeout(() => { copyBtn.className = 'bi bi-clipboard path-copy'; }, 1000); + }); + }); + } popup.appendChild(entry); }); @@ -1757,6 +1783,18 @@ function showPathsPopup(element, encodedPaths) { }); } +/** + * Open the Path Analyzer modal deep-linked to one message + echo path. + * The modal's show handler (index.html) reads window.paDeepLink and builds + * the iframe URL from it. + */ +function openPathInAnalyzer(packetHash, pathHex) { + const modalEl = document.getElementById('pathAnalyzerModal'); + if (!modalEl) return; + window.paDeepLink = { hash: packetHash, path: pathHex }; + bootstrap.Modal.getOrCreateInstance(modalEl).show(); +} + /** * Load connection status */ diff --git a/app/static/js/path-analyzer.js b/app/static/js/path-analyzer.js index 1a692d6..2fbd252 100644 --- a/app/static/js/path-analyzer.js +++ b/app/static/js/path-analyzer.js @@ -88,6 +88,8 @@ let paCurrentView = 'messages'; // 'messages' | 'stats' | 'routes' | 'map' let paContacts = []; // /api/contacts/cached?format=full let paStatsSort = { key: 'relayed', dir: -1 }; let paRoutesSort = { key: 'echoes', dir: -1 }; +let paDeepLink = null; // ?hash=..&path=.. from a chat path popup +let paContactsReady = Promise.resolve(); async function paLoadContacts() { try { @@ -978,6 +980,45 @@ async function paLoadMessages() { } else { paRender(); } + + if (paDeepLink) paApplyDeepLink(); +} + +// Deep link from the chat path popup: jump to the map view with the +// linked message selected and the linked echo path drawn. +async function paApplyDeepLink() { + const dl = paDeepLink; + const hash = (dl.hash || '').toLowerCase(); + const msg = paMessages.find(m => (m.packet_hash || '').toLowerCase() === hash); + + if (!msg) { + // The chat can show messages older than the default window - widen + // to the max range once before giving up + const daysSel = document.getElementById('paDaysSelect'); + if (!dl.retried && daysSel.value !== '7') { + dl.retried = true; + daysSel.value = '7'; + paLoadMessages(); // re-enters paApplyDeepLink when done + return; + } + paDeepLink = null; + showNotification('This message is no longer in the analyzer data (max 7 days).', 'warning'); + return; + } + + paDeepLink = null; + await paContactsReady; // map needs contacts to resolve hop positions + + let echoIdx = msg.echoView.findIndex(e => + e.hops > 0 && (e.path || '').toLowerCase() === (dl.path || '').toLowerCase()); + if (echoIdx === -1) echoIdx = paShortestEchoIdx(msg); + if (echoIdx === null) { + showNotification('This message has no routed echoes to draw.', 'warning'); + return; + } + + paMapSelection = { msgId: msg.id, echoIdx: echoIdx }; + paSwitchView('map'); } // ================================================================ @@ -1024,6 +1065,12 @@ function paClearFilters() { document.addEventListener('DOMContentLoaded', () => { loadUiSettings(); + + const qs = new URLSearchParams(window.location.search); + if (qs.get('hash')) { + paDeepLink = { hash: qs.get('hash'), path: qs.get('path') || '' }; + } + document.getElementById('paDaysSelect').addEventListener('change', paLoadMessages); document.getElementById('paRefreshBtn').addEventListener('click', paLoadMessages); @@ -1068,6 +1115,6 @@ document.addEventListener('DOMContentLoaded', () => { }); }); - paLoadContacts(); + paContactsReady = paLoadContacts(); paLoadMessages(); }); diff --git a/app/templates/index.html b/app/templates/index.html index 40dfeaf..03aa6a4 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -350,7 +350,13 @@ pathAnalyzerModal.addEventListener('show.bs.modal', function () { const pathAnalyzerFrame = document.getElementById('pathAnalyzerFrame'); if (pathAnalyzerFrame) { - pathAnalyzerFrame.src = '/path-analyzer'; + // Deep link from a chat path popup (openPathInAnalyzer): + // preselect the message + echo on the map view + const dl = window.paDeepLink; + window.paDeepLink = null; + pathAnalyzerFrame.src = dl + ? `/path-analyzer?hash=${encodeURIComponent(dl.hash)}&path=${encodeURIComponent(dl.path)}` + : '/path-analyzer'; } }); } From 4963dd6a32f28b8247394b5140276326d60e2a9c Mon Sep 17 00:00:00 2001 From: MarekWo Date: Fri, 24 Jul 2026 09:49:22 +0200 Subject: [PATCH 3/3] docs: chat-route deep link into Path Analyzer; date the 2026-07-22 release Cover the two changes on dev since the last main merge (c6d2367): - d8d3427: clicking a route under a channel message now opens the Path Analyzer map deep-linked to that message + echo (user-guide new Group Chat Message Routes section, Path Analyzer to-open note, whatsnew New features, architecture deep-link URL contract). - bb67c4d: exact sent-message echo matching fix (whatsnew Reliability). Also finalize the previously-merged Unreleased-since-debb711 section by dating it 2026-07-22 (the c6d2367 merge), and open a fresh Unreleased-since-c6d2367 for the current changes. Co-Authored-By: Claude Opus 4.8 --- docs/architecture.md | 1 + docs/user-guide.md | 11 +++++++++++ docs/whatsnew.md | 14 +++++++++++++- 3 files changed, 25 insertions(+), 1 deletion(-) diff --git a/docs/architecture.md b/docs/architecture.md index 8606f5f..bce1868 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -112,6 +112,7 @@ The `/path-analyzer` panel (standalone iframe page, `path-analyzer.js`) is a rea - **Data source** — `GET /api/path-analyzer/messages?days=N` joins `channel_messages` with `echoes` (path hex + SNR + per-echo `hash_size`, keyed by `pkt_payload`). Echoes are fetched with `db.get_echoes_for_payloads()` — chunked `IN` queries (≤500 params, under SQLite's host-parameter limit) via `idx_echoes_pkt` — deliberately avoiding the per-message echo query the older `/api/messages` path still does. The pkt_payload reconstruction (raw_json text → channel-secret AES/HMAC compute) is shared with `/api/messages` via the `_get_row_pkt_payload()` helper - **All filtering/stats/map/routes logic is client-side** over the bulk payload (hundreds of KB for 7 days — fine on a LAN): filters operate on per-hop tokens split with each echo's own `hash_size` (mixed 1/2/3-byte networks are real), so SQL-side token filtering was rejected. Every view always reflects the active filters for free - **Four views** share the one payload: Messages (hop-by-hop echo detail), Repeaters (per-hash relay/SNR stats), Routes (consecutive hop-segment n-grams — user-selectable length 2–4, counted anywhere in a path), and Map (Leaflet path drawing). The repeater filter accepts a `>`-chained sequence (each element a hash prefix or contact name) matched as consecutive hops; Routes rows write such a sequence into that filter on click +- **Deep link** — `GET /path-analyzer?hash=&path=` opens straight on the Map view with that message selected and that exact echo drawn. Used by the chat path popup (`app.js` → `openPathInAnalyzer` stashes `{hash, path}` in `window.paDeepLink`; the modal's `show.bs.modal` handler builds the iframe URL). On load the analyzer resolves the message by `packet_hash`, widening the time range once to 7 days if it isn't in the current window, and matches the echo by raw path hex (fallback: shortest routed echo) - **SNR attribution** — echo SNR is measured at our receiver, so stats credit it to the *final* hop only; intermediate hops get relay counts but never SNR - **Hash→contact resolution** — pubkey-prefix match against `/api/contacts/cached?format=full` (memoized per token). 1-byte hashes collide by design; the map renders unresolved hops as amber candidate markers with manual pick, and the repeater-name filter is intentionally inclusive over candidates - Legacy rows whose `pkt_payload` cannot be recomputed (missing channel secret) are returned with `packet_hash: null` and no echoes rather than dropped diff --git a/docs/user-guide.md b/docs/user-guide.md index a242b76..5323856 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -170,6 +170,15 @@ Your own messages carry a small row of action buttons: - **Edit message** (pencil icon) - Copies the message text back into the composer so you can tweak it and send it again as a new message. - **Resend** (repeat-arrow icon) - Re-broadcasts the *same* packet. Repeaters that already forwarded the original ignore the duplicate, but nodes that never heard it can still pick it up — so the resend extends the repeater list on the existing message's badge instead of creating a new message. Handy right after sending when the delivery badge shows only partial coverage. This button only appears when your device runs companion firmware **1.16 or newer**; on older firmware it is hidden. +### Group Chat Message Routes + +When your node overheard how a channel message travelled, a **Route** line appears under the message (e.g. `Route (3): 92→AD→40→D1`). Tap it to open a popup listing every route the message arrived by, each with its SNR and hop count: + +- Tap a route to open the **[Path Analyzer](#path-analyzer)** on the Map view, with that message selected and that exact route drawn — the quickest way to see where a message physically travelled. +- Each route also has a small clipboard icon to copy it in comma-separated form (e.g. `92,AD,40,D1`) for use in the console `change_path` command. + +(In direct messages the same popup copies the route on tap, since only channel messages feed the Path Analyzer.) + --- ## Message Content Features @@ -624,6 +633,8 @@ To open: 1. Tap the hamburger menu (☰) 2. Select **Path Analyzer** from the menu +Or jump straight to a specific route: under any channel message, tap the **Route** line and pick one of its routes — the analyzer opens on the Map view with that message selected and that exact route already drawn (see [Group chat message routes](#group-chat-message-routes)). + Pick a time range at the top (last 1, 3, 5, or 7 days) — the tool loads every channel message from **all** your channels in that window, together with every copy (echo) of each message your node overheard. Everything below works on that data set; no extra loading. ### Filters diff --git a/docs/whatsnew.md b/docs/whatsnew.md index 1c09cfb..a3ac348 100644 --- a/docs/whatsnew.md +++ b/docs/whatsnew.md @@ -6,7 +6,19 @@ For deep technical notes, see [architecture.md](architecture.md). For the full g --- -## Unreleased (since debb711) +## Unreleased (since c6d2367) + +### New features + +- **Jump from a chat route straight to the map.** Tapping a route under a channel message used to just copy it to the clipboard. Now it opens the **Path Analyzer** on its map view with that message selected and that exact route already drawn — the quickest way to see where a message physically travelled. Copying isn't gone: each route in the popup keeps a small clipboard icon for pasting into the console's `change_path`. + +### Reliability & polish + +- **Your own sent channel messages no longer show a route that isn't theirs.** A bug could attach a stranger's overheard packet to your just-sent message, so the delivery badge and the Path Analyzer occasionally displayed a repeater hash and a physically impossible path that were never part of your message. Sent-message echoes are now matched exactly, so the route you see is really yours. + +--- + +## 2026-07-22 ### New features