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:
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';
}
});
}
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