Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions docs/CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -477,6 +477,19 @@ storage:

Packets are stored in a local SQLite database. Old packets are pruned automatically based on `max_packets_retained`.

### Stray frames (Unknown RF)

When a LoRa frame fails **both** Meshtastic and MeshCore decode, Meshpoint can log RF metadata only (no relay, no re-encode). View results in the dashboard **Unknown RF** tab or export via `GET /api/stray_frames?format=csv`.

```yaml
stray_frames:
enabled: true
max_retained: 10000 # hard cap on SQLite rows
retention_hours: 168 # drop rows older than this (7 days)
```

Pruning runs in the background during the normal storage cleanup loop and does not block the receive path.

---

## Dashboard
Expand All @@ -490,6 +503,10 @@ dashboard:

Access at `http://<pi-ip>:8080`. Bind to `127.0.0.1` to restrict to local access only.

### Unknown RF tab

Open **Unknown RF** in the sidebar to browse undecodable frames logged by `stray_frames` (see Storage). Filter by time window and minimum RSSI; use **Export CSV** to download the current filter via `/api/stray_frames?format=csv`. Disable logging with `stray_frames.enabled: false` in `local.yaml`.

---

## Device Identity
Expand Down Expand Up @@ -744,6 +761,11 @@ storage: # local SQLite packet store
max_packets_retained: 100000
cleanup_interval_seconds: 3600

stray_frames: # undecodable RF metadata (Unknown RF tab)
enabled: true
max_retained: 10000
retention_hours: 168

dashboard: # local web UI
host: "0.0.0.0"
port: 8080
Expand Down
113 changes: 113 additions & 0 deletions frontend/css/unknown_rf.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
.section[data-section="unknown-rf"] {
overflow-y: auto;
}

.unknown-rf-panel {
padding: 1.25rem;
max-width: 1100px;
margin: 0 auto;
}

.unknown-rf-panel__header {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1rem;
}

.unknown-rf-panel__title {
margin: 0;
font-size: 1.1rem;
color: var(--text-primary, #f1f5f9);
}

.unknown-rf-panel__desc {
margin: 0.35rem 0 0;
font-size: 0.78rem;
color: var(--text-secondary, #94a3b8);
max-width: 40rem;
}

.unknown-rf-controls {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 0.75rem;
margin-bottom: 1rem;
}

.unknown-rf-field label {
display: block;
font-size: 0.68rem;
color: var(--text-muted, #64748b);
margin-bottom: 0.2rem;
}

.unknown-rf-field input,
.unknown-rf-field select {
background: rgba(15, 23, 42, 0.8);
border: 1px solid rgba(51, 65, 85, 0.8);
border-radius: 6px;
color: var(--text-primary, #e2e8f0);
padding: 0.35rem 0.5rem;
font-size: 0.78rem;
}

.unknown-rf-btn {
background: rgba(6, 182, 212, 0.15);
border: 1px solid rgba(6, 182, 212, 0.45);
color: #67e8f9;
border-radius: 6px;
padding: 0.4rem 0.75rem;
font-size: 0.75rem;
cursor: pointer;
}

.unknown-rf-btn:hover {
background: rgba(6, 182, 212, 0.25);
}

.unknown-rf-meta {
font-size: 0.72rem;
color: var(--text-secondary, #94a3b8);
margin-bottom: 0.75rem;
}

.unknown-rf-table-wrap {
overflow-x: auto;
border: 1px solid rgba(51, 65, 85, 0.6);
border-radius: 8px;
}

.unknown-rf-table {
width: 100%;
border-collapse: collapse;
font-size: 0.75rem;
}

.unknown-rf-table th,
.unknown-rf-table td {
padding: 0.45rem 0.6rem;
text-align: left;
border-bottom: 1px solid rgba(51, 65, 85, 0.35);
}

.unknown-rf-table th {
color: var(--text-muted, #64748b);
font-weight: 600;
background: rgba(15, 23, 42, 0.6);
}

.unknown-rf-table td {
color: var(--text-primary, #e2e8f0);
font-variant-numeric: tabular-nums;
}

.unknown-rf-empty {
padding: 2rem;
text-align: center;
color: var(--text-secondary, #94a3b8);
font-size: 0.8rem;
}
26 changes: 26 additions & 0 deletions frontend/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
<link rel="stylesheet" href="css/radio_channels.css" />
<link rel="stylesheet" href="css/radio_readout.css" />
<link rel="stylesheet" href="css/stats.css" />
<link rel="stylesheet" href="css/unknown_rf.css" />
<link rel="stylesheet" href="css/settings.css" />
<link rel="stylesheet" href="css/configuration.css" />
<link rel="stylesheet" href="css/gps.css" />
Expand Down Expand Up @@ -102,6 +103,24 @@
<span class="sidebar__label">Stats</span>
</a>
</li>
<li class="sidebar__item">
<a href="#/unknown-rf" class="sidebar__link" data-route="unknown-rf">
<span class="sidebar__icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M12 2v4"/>
<path d="M12 18v4"/>
<path d="M4.93 4.93l2.83 2.83"/>
<path d="M16.24 16.24l2.83 2.83"/>
<path d="M2 12h4"/>
<path d="M18 12h4"/>
<path d="M4.93 19.07l2.83-2.83"/>
<path d="M16.24 7.76l2.83-2.83"/>
<circle cx="12" cy="12" r="3"/>
</svg>
</span>
<span class="sidebar__label">Unknown RF</span>
</a>
</li>
<li class="sidebar__item">
<a href="#/messages" class="sidebar__link" data-route="messages">
<span class="sidebar__icon">
Expand Down Expand Up @@ -391,6 +410,12 @@
</div>
</section>

<section class="section" data-section="unknown-rf" style="display:none">
<div id="unknown-rf-panel" class="unknown-rf-panel">
<div class="stats-panel__loading">Loading unknown RF frames...</div>
</div>
</section>

<section class="section" data-section="messages" style="display:none">
<div id="messaging-panel"></div>
</section>
Expand Down Expand Up @@ -693,6 +718,7 @@ <h3 class="dangerous-panel__title">Service actions</h3>
<script src="js/radio_companion_card.js"></script>
<script src="js/radio_settings.js"></script>
<script src="js/stats_tab.js"></script>
<script src="js/unknown_rf_tab.js"></script>
<script src="js/signout_controller.js"></script>
<script src="js/settings/password_change_form.js"></script>
<script src="js/settings/sign_out_all_form.js"></script>
Expand Down
2 changes: 1 addition & 1 deletion frontend/js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ document.addEventListener('DOMContentLoaded', async () => {
const router = new Router({
defaultRoute: 'dashboard',
allowedRoutes: [
'dashboard', 'stats', 'messages', 'radio', 'terminal',
'dashboard', 'stats', 'unknown-rf', 'messages', 'radio', 'terminal',
'configuration/identity', 'configuration/radio',
'configuration/channels', 'configuration/transmit',
'configuration/mqtt',
Expand Down
173 changes: 173 additions & 0 deletions frontend/js/unknown_rf_tab.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
/**
* Unknown RF tab — undecodable frames from GET /api/stray_frames.
*/
class UnknownRfTab {
constructor(containerId) {
this._container = document.getElementById(containerId);
this._rendered = false;
this._hours = 24;
this._minRssi = '';
this._refreshInterval = null;
}

async refresh() {
if (!this._container) return;

try {
if (!this._rendered) {
this._buildLayout();
this._rendered = true;
}
const params = new URLSearchParams({
limit: '500',
hours: String(this._hours),
});
if (this._minRssi !== '' && !Number.isNaN(Number(this._minRssi))) {
params.set('min_rssi', this._minRssi);
}
const res = await fetch(`/api/stray_frames?${params}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
this._renderTable(data);
} catch (e) {
console.error('Unknown RF refresh failed:', e);
}

if (!this._refreshInterval) {
this._refreshInterval = setInterval(() => {
const section = document.querySelector('[data-section="unknown-rf"]');
if (section && section.classList.contains('section--active')) {
this.refresh();
} else {
clearInterval(this._refreshInterval);
this._refreshInterval = null;
}
}, 15_000);
}
}

_buildLayout() {
this._container.innerHTML = `
<div class="unknown-rf-panel">
<header class="unknown-rf-panel__header">
<div>
<h2 class="unknown-rf-panel__title">Unknown RF</h2>
<p class="unknown-rf-panel__desc">
Frames that failed both Meshtastic and MeshCore decode. RF metadata only —
no payload stored, no relay, no re-encode.
</p>
</div>
</header>
<div class="unknown-rf-controls">
<div class="unknown-rf-field">
<label for="urf-hours">Window (hours)</label>
<select id="urf-hours">
<option value="1">1h</option>
<option value="6">6h</option>
<option value="24" selected>24h</option>
<option value="168">7d</option>
</select>
</div>
<div class="unknown-rf-field">
<label for="urf-min-rssi">Min RSSI (dBm)</label>
<input id="urf-min-rssi" type="number" min="-150" max="0" step="1" placeholder="any">
</div>
<button type="button" class="unknown-rf-btn" id="urf-apply">Apply filters</button>
<a class="unknown-rf-btn" id="urf-csv" href="#">Export CSV</a>
</div>
<p class="unknown-rf-meta" id="urf-meta"></p>
<div class="unknown-rf-table-wrap">
<table class="unknown-rf-table">
<thead>
<tr>
<th>Time</th>
<th>Size</th>
<th>Ch</th>
<th>Freq</th>
<th>SF</th>
<th>BW</th>
<th>RSSI</th>
<th>SNR</th>
<th>Source</th>
</tr>
</thead>
<tbody id="urf-tbody"></tbody>
</table>
<div id="urf-empty" class="unknown-rf-empty" hidden>No stray frames in this window.</div>
</div>
</div>
`;

document.getElementById('urf-hours').addEventListener('change', (e) => {
this._hours = Number(e.target.value);
});
document.getElementById('urf-apply').addEventListener('click', () => {
this._minRssi = document.getElementById('urf-min-rssi').value.trim();
this.refresh();
});
document.getElementById('urf-csv').addEventListener('click', (e) => {
e.preventDefault();
const params = new URLSearchParams({
format: 'csv',
limit: '2000',
hours: String(this._hours),
});
if (this._minRssi !== '' && !Number.isNaN(Number(this._minRssi))) {
params.set('min_rssi', this._minRssi);
}
window.location.assign(`/api/stray_frames?${params}`);
});
}

_renderTable(data) {
const tbody = document.getElementById('urf-tbody');
const meta = document.getElementById('urf-meta');
const empty = document.getElementById('urf-empty');
if (!tbody) return;

const frames = data.frames || [];
meta.textContent =
`Showing ${frames.length} of ${data.total_stored ?? frames.length} stored `
+ `(last ${data.window_hours ?? this._hours}h)`;

if (!frames.length) {
tbody.innerHTML = '';
if (empty) empty.hidden = false;
return;
}
if (empty) empty.hidden = true;

tbody.innerHTML = frames.map((f) => {
const ts = this._formatTime(f.timestamp);
return `<tr>
<td>${ts}</td>
<td>${f.frame_size ?? '--'} B</td>
<td>${f.channel_hash != null ? f.channel_hash : '--'}</td>
<td>${f.frequency_mhz != null ? `${Number(f.frequency_mhz).toFixed(3)} MHz` : '--'}</td>
<td>${f.spreading_factor ?? '--'}</td>
<td>${f.bandwidth_khz != null ? `${f.bandwidth_khz} kHz` : '--'}</td>
<td>${f.rssi != null ? `${Number(f.rssi).toFixed(0)}` : '--'}</td>
<td>${f.snr != null ? `${Number(f.snr).toFixed(1)}` : '--'}</td>
<td>${this._esc(f.capture_source || '--')}</td>
</tr>`;
}).join('');
}

_formatTime(ts) {
if (!ts) return '--';
try {
const d = new Date(ts);
return d.toLocaleString();
} catch (_e) {
return ts;
}
}

_esc(str) {
const el = document.createElement('span');
el.textContent = str;
return el.innerHTML;
}
}

window.unknownRfTab = new UnknownRfTab('unknown-rf-panel');
Loading
Loading