Merge branch 'dev'

This commit is contained in:
MarekWo
2026-07-24 10:11:44 +02:00
8 changed files with 147 additions and 32 deletions
+1 -20
View File
@@ -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:
+19
View File
@@ -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 {
+47 -9
View File
@@ -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(`<span class="path-info" onclick="showPathsPopup(this, '${pathsData}')">${routeLabel}: ${shortPath}</span>`);
metaParts.push(`<span class="path-info" onclick="showPathsPopup(this, '${pathsData}', '${meta.packet_hash || ''}')">${routeLabel}: ${shortPath}</span>`);
}
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(`<span class="path-info" onclick="showPathsPopup(this, '${pathsData}')">${routeLabel}: ${shortPath}</span>`);
metaParts.push(`<span class="path-info" onclick="showPathsPopup(this, '${pathsData}', '${msg.packet_hash || ''}')">${routeLabel}: ${shortPath}</span>`);
}
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}<span class="path-detail">SNR: ${snr} | Hops: ${hops}</span>`;
entry.title = 'Tap to copy route';
entry.addEventListener('click', (e) => {
const body = document.createElement('span');
body.className = 'path-route';
body.innerHTML = `${fullRoute}<span class="path-detail">SNR: ${snr} | Hops: ${hops}</span>`;
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 = '<span style="opacity:0.8">Copied!</span>';
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
*/
+48 -1
View File
@@ -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();
});
+7 -1
View File
@@ -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';
}
});
}
+1
View File
@@ -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 24, 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=<packet_hash>&path=<echo path hex>` 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
+11
View File
@@ -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
+13 -1
View File
@@ -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