Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
39 KiB
Pager & 433 Sensor Display Revamp — Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Replace the plain card feed for Pager and 433 Sensor modes with a source-directory split-view and a per-device station dashboard, each with a toggle back to the classic feed.
Architecture: #pagerDirectoryView (left panel) + #output (right feed) share a flex wrapper for the pager split; #sensorDashboardView replaces #output in sensor dashboard mode while #output continues receiving cards silently. Two new IIFE components (PagerDirectory, SensorDashboard) are notified via one-line hooks in the existing addMessage() and addSensorReading() functions. No backend changes.
Tech Stack: Vanilla JS (IIFE pattern), CSS custom properties from variables.css, SVG sparklines, Flask/Jinja2 templates, pytest for template structure tests.
File Map
| Action | Path | Responsibility |
|---|---|---|
| Create | static/css/components/pager-directory.css |
Flex wrap layout, directory panel, entry rows, highlight, toggle buttons |
| Create | static/css/components/sensor-dashboard.css |
Station card grid, flash animations, sparkline, state-only devices |
| Create | static/js/components/pager-directory.js |
PagerDirectory IIFE — address tracking, highlight, show/hide, reset |
| Create | static/js/components/sensor-dashboard.js |
SensorDashboard IIFE — station cards, sparklines, flash, show/hide, reset |
| Modify | templates/index.html |
Wrap #output, add view containers, toggle buttons, <link>/<script> tags, two hook calls, two applyViewState calls, two reset calls |
| Modify | tests/test_app.py |
Template structure tests for new elements |
Task 1: HTML scaffolding + template tests
Files:
-
Modify:
tests/test_app.py -
Modify:
templates/index.html -
Step 1: Write failing template tests
Add to tests/test_app.py:
def test_pager_directory_elements_present(client):
response = client.get('/')
assert b'id="signalViewWrap"' in response.data
assert b'id="pagerDirectoryView"' in response.data
assert b'id="pagerDirEntries"' in response.data
assert b'id="pagerFeedHeader"' in response.data
assert b'id="pagerToggleDir"' in response.data
assert b'pager-directory.css' in response.data
assert b'pager-directory.js' in response.data
def test_sensor_dashboard_elements_present(client):
response = client.get('/')
assert b'id="sensorDashboardView"' in response.data
assert b'id="sensorDashboardGrid"' in response.data
assert b'id="sensorToggleDash"' in response.data
assert b'sensor-dashboard.css' in response.data
assert b'sensor-dashboard.js' in response.data
- Step 2: Run tests to confirm they fail
pytest tests/test_app.py::test_pager_directory_elements_present tests/test_app.py::test_sensor_dashboard_elements_present -v
Expected: 2 FAILED (elements not yet in template).
- Step 3: Add CSS
<link>tags
In templates/index.html, find this line (near line 58):
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/signal-cards.css') }}">
Add immediately after:
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/pager-directory.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/sensor-dashboard.css') }}">
- Step 4: Add toggle buttons to
#pagerStats
In templates/index.html, find this exact closing tag (after the FLEX count div, around line 812):
</div>
<div class="stats" id="sensorStats">
Change it to:
<div class="view-toggle-group">
<button class="view-toggle-btn view-toggle-btn--active" id="pagerToggleDir" onclick="PagerDirectory.show()">Directory</button>
<button class="view-toggle-btn" id="pagerToggleFeed" onclick="PagerDirectory.hide()">Feed</button>
</div>
</div>
<div class="stats" id="sensorStats">
- Step 5: Add toggle buttons to
#sensorStats
Find the closing </div> of #sensorStats (after the device count div, around line 816):
</div>
<div class="stats" id="wifiStats">
Change it to:
<div class="view-toggle-group">
<button class="view-toggle-btn view-toggle-btn--active" id="sensorToggleDash" onclick="SensorDashboard.show()">Dashboard</button>
<button class="view-toggle-btn" id="sensorToggleFeed" onclick="SensorDashboard.hide()">Feed</button>
</div>
</div>
<div class="stats" id="wifiStats">
- Step 6: Wrap
#outputand add new view containers
Find this exact block in templates/index.html (around line 3600):
<div class="output-content signal-feed" id="output">
Replace with:
<div id="signalViewWrap">
<div id="pagerDirectoryView" class="pdir-panel" style="display:none;">
<div class="pdir-header">Sources — <span id="pagerDirCount">0</span> active</div>
<div id="pagerDirEntries" class="pdir-entries"></div>
</div>
<div id="sensorDashboardView" class="sdb-view" style="display:none;">
<div id="sensorDashboardGrid" class="sdb-grid"></div>
</div>
<div class="pdir-feed-col">
<div class="pdir-feed-header" id="pagerFeedHeader" style="display:none;">
<span id="pagerFeedLabel">All messages</span>
<button id="pagerClearHighlight" class="pdir-clear-btn" onclick="PagerDirectory.clearHighlight()" style="display:none;">clear highlight</button>
</div>
<div class="output-content signal-feed" id="output">
Then find the </div> that closes #output followed by <div class="status-bar"> (around line 3607-3609):
</div>
<div class="status-bar">
Replace with:
</div>
</div><!-- .pdir-feed-col -->
</div><!-- #signalViewWrap -->
<div class="status-bar">
- Step 7: Add
<script>tags
Find this line (around line 3641):
<script src="{{ url_for('static', filename='js/components/signal-cards.js') }}"></script>
Add immediately after:
<script src="{{ url_for('static', filename='js/components/pager-directory.js') }}"></script>
<script src="{{ url_for('static', filename='js/components/sensor-dashboard.js') }}"></script>
- Step 8: Run tests to confirm they pass
pytest tests/test_app.py::test_pager_directory_elements_present tests/test_app.py::test_sensor_dashboard_elements_present -v
Expected: 2 PASSED.
- Step 9: Smoke-test the page loads without JS errors
python intercept.py
Open http://localhost:5000 in a browser. Check the DevTools console — there should be no JS errors (the new script tags reference files that don't exist yet, but that causes a network 404, not a console error that breaks the page).
- Step 10: Commit
git add templates/index.html tests/test_app.py
git commit -m "feat: add HTML scaffolding for pager directory and sensor dashboard views"
Task 2: Pager directory CSS
Files:
-
Create:
static/css/components/pager-directory.css -
Step 1: Create the CSS file
Create static/css/components/pager-directory.css with this exact content:
/* ============================================================
Signal View Wrap — flex container for split-panel layouts
============================================================ */
#signalViewWrap {
display: flex;
flex: 1;
min-height: 0;
overflow: hidden;
}
/* Feed column — wraps feed header + #output, fills remaining space */
.pdir-feed-col {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
overflow: hidden;
}
/* Feed header strip — shown in directory mode above the message list */
.pdir-feed-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 4px 10px;
background: var(--bg-card);
border-bottom: 1px solid var(--border-color);
font-family: var(--font-mono);
font-size: var(--text-xs);
color: var(--text-secondary);
flex-shrink: 0;
}
.pdir-clear-btn {
background: none;
border: none;
color: var(--text-muted);
font-family: var(--font-mono);
font-size: var(--text-xs);
cursor: pointer;
padding: 2px 6px;
border-radius: var(--radius-sm);
transition: color var(--transition-fast);
}
.pdir-clear-btn:hover { color: var(--text-dim); }
/* ---- Directory panel (left side of split) ---- */
.pdir-panel {
width: 200px;
flex-shrink: 0;
border-right: 1px solid var(--border-color);
flex-direction: column;
overflow: hidden;
background: var(--bg-secondary);
font-family: var(--font-mono);
}
.pdir-header {
padding: 6px 10px;
font-size: var(--text-xs);
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 1px;
border-bottom: 1px solid var(--border-color);
background: var(--bg-card);
flex-shrink: 0;
}
.pdir-entries {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
}
/* ---- Individual address entry ---- */
.pdir-entry {
padding: 7px 10px;
border-bottom: 1px solid rgba(var(--accent-cyan-rgb), 0.04);
cursor: pointer;
position: relative;
transition: background var(--transition-fast);
}
.pdir-entry:hover { background: var(--bg-tertiary); }
.pdir-entry--active {
background: rgba(var(--accent-cyan-rgb), 0.06);
border-left: 2px solid var(--accent-cyan);
padding-left: 8px;
}
.pdir-entry-top {
display: flex;
align-items: center;
gap: 5px;
margin-bottom: 3px;
}
.pdir-proto {
font-size: 8px;
padding: 1px 4px;
border-radius: var(--radius-sm);
font-weight: var(--font-bold);
flex-shrink: 0;
}
.pdir-proto--p { background: rgba(var(--accent-cyan-rgb), 0.15); color: var(--accent-cyan); }
.pdir-proto--f { background: rgba(143, 123, 214, 0.15); color: var(--accent-purple); }
.pdir-addr {
font-size: var(--text-xs);
color: var(--text-secondary);
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pdir-new-dot {
display: inline-block;
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--accent-green);
flex-shrink: 0;
opacity: 0;
}
.pdir-new-dot--active {
animation: pdir-dot-fade 3s ease-out forwards;
}
@keyframes pdir-dot-fade {
0% { opacity: 1; }
85% { opacity: 1; }
100% { opacity: 0; }
}
.pdir-count { font-size: 9px; color: var(--text-muted); flex-shrink: 0; }
.pdir-bar-wrap { height: 2px; background: var(--bg-tertiary); border-radius: 1px; margin-bottom: 2px; }
.pdir-bar { height: 2px; background: var(--accent-cyan); border-radius: 1px; transition: width var(--transition-slow); }
.pdir-bar--flex { background: var(--accent-purple); }
.pdir-age { font-size: 8px; color: var(--text-muted); }
/* ---- Highlight applied to signal-cards in #output ---- */
.signal-card.pdir-hl {
border-left: 2px solid var(--accent-cyan) !important;
background: rgba(var(--accent-cyan-rgb), 0.04) !important;
}
/* ---- View toggle button group (inside .stats) ---- */
.stats .view-toggle-group { display: none; }
.stats.active .view-toggle-group { display: flex; }
.view-toggle-group {
gap: 2px;
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
padding: 2px;
margin-left: 6px;
}
.view-toggle-btn {
padding: 2px 8px;
font-size: 9px;
font-family: var(--font-mono);
text-transform: uppercase;
letter-spacing: 0.5px;
border: none;
border-radius: var(--radius-sm);
background: none;
color: var(--text-muted);
cursor: pointer;
transition: background var(--transition-fast), color var(--transition-fast);
}
.view-toggle-btn:hover { color: var(--text-dim); }
.view-toggle-btn--active {
background: var(--accent-cyan-dim);
color: var(--accent-cyan);
}
- Step 2: Verify styles load without errors
With python intercept.py running, open http://localhost:5000, DevTools → Network tab. Confirm pager-directory.css returns HTTP 200.
- Step 3: Commit
git add static/css/components/pager-directory.css
git commit -m "feat: add pager directory view CSS"
Task 3: PagerDirectory JS component
Files:
-
Create:
static/js/components/pager-directory.js -
Step 1: Create the component file
Create static/js/components/pager-directory.js with this exact content:
const PagerDirectory = (function () {
'use strict';
const STORAGE_KEY = 'pagerView';
// Map<address, { count, protocol, lastSeen }>
const addresses = new Map();
let highlighted = null;
// ---- Helpers ----
function esc(str) {
return String(str)
.replace(/&/g, '&').replace(/</g, '<')
.replace(/>/g, '>').replace(/"/g, '"');
}
function formatAge(ts) {
const s = Math.floor((Date.now() - ts) / 1000);
if (s < 10) return 'just now';
if (s < 60) return `${s}s ago`;
return `${Math.floor(s / 60)}m ago`;
}
// ---- Directory rendering ----
function renderDirectory() {
const entriesEl = document.getElementById('pagerDirEntries');
const countEl = document.getElementById('pagerDirCount');
if (!entriesEl) return;
const sorted = [...addresses.entries()].sort((a, b) => b[1].count - a[1].count);
const maxCount = sorted.length > 0 ? sorted[0][1].count : 1;
if (countEl) countEl.textContent = sorted.length;
sorted.forEach(([addr, data]) => {
let el = entriesEl.querySelector(`[data-pdir-addr="${CSS.escape(addr)}"]`);
const isActive = addr === highlighted;
const pct = Math.round((data.count / maxCount) * 100);
const isPocsag = data.protocol !== 'flex';
const protoClass = isPocsag ? 'pdir-proto--p' : 'pdir-proto--f';
const barClass = isPocsag ? '' : 'pdir-bar--flex';
const html = `
<div class="pdir-entry-top">
<span class="pdir-proto ${protoClass}">${isPocsag ? 'P' : 'F'}</span>
<span class="pdir-addr">${esc(addr)}</span>
<span class="pdir-new-dot"></span>
<span class="pdir-count">×${data.count}</span>
</div>
<div class="pdir-bar-wrap"><div class="pdir-bar ${barClass}" style="width:${pct}%"></div></div>
<div class="pdir-age">${formatAge(data.lastSeen)}</div>`;
if (!el) {
el = document.createElement('div');
el.className = 'pdir-entry';
el.dataset.pdirAddr = addr;
el.addEventListener('click', () => toggleHighlight(addr));
entriesEl.appendChild(el);
}
el.classList.toggle('pdir-entry--active', isActive);
el.innerHTML = html;
});
// Re-order DOM to match sort
sorted.forEach(([addr]) => {
const el = entriesEl.querySelector(`[data-pdir-addr="${CSS.escape(addr)}"]`);
if (el) entriesEl.appendChild(el);
});
}
function flashNewDot(addr) {
// Find the dot inside this entry after the current render frame
setTimeout(() => {
const entriesEl = document.getElementById('pagerDirEntries');
const entry = entriesEl?.querySelector(`[data-pdir-addr="${CSS.escape(addr)}"]`);
const dot = entry?.querySelector('.pdir-new-dot');
if (!dot) return;
dot.classList.remove('pdir-new-dot--active');
void dot.offsetWidth; // force reflow to restart animation
dot.classList.add('pdir-new-dot--active');
}, 0);
}
// ---- Highlight ----
function toggleHighlight(addr) {
if (highlighted === addr) clearHighlight();
else highlight(addr);
}
function highlight(addr) {
highlighted = addr;
renderDirectory();
const feedLabel = document.getElementById('pagerFeedLabel');
const clearBtn = document.getElementById('pagerClearHighlight');
if (feedLabel) feedLabel.textContent = `${addr} highlighted`;
if (clearBtn) clearBtn.style.display = 'inline';
const output = document.getElementById('output');
if (!output) return;
output.querySelectorAll('.signal-card').forEach(card => {
card.classList.toggle('pdir-hl', card.dataset.address === addr);
});
const first = output.querySelector(`.signal-card[data-address="${CSS.escape(addr)}"]`);
if (first) first.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
function clearHighlight() {
highlighted = null;
renderDirectory();
const feedLabel = document.getElementById('pagerFeedLabel');
const clearBtn = document.getElementById('pagerClearHighlight');
if (feedLabel) feedLabel.textContent = 'All messages';
if (clearBtn) clearBtn.style.display = 'none';
document.getElementById('output')
?.querySelectorAll('.pdir-hl')
.forEach(c => c.classList.remove('pdir-hl'));
}
// ---- Public: message hook ----
function addMessage(msg) {
const addr = msg.address;
if (!addr) return;
const proto = (msg.protocol || '').includes('FLEX') ? 'flex' : 'pocsag';
const entry = addresses.get(addr);
if (entry) {
entry.count++;
entry.lastSeen = Date.now();
entry.protocol = proto;
} else {
addresses.set(addr, { count: 1, protocol: proto, lastSeen: Date.now() });
}
renderDirectory();
flashNewDot(addr);
// Re-apply highlight class to the newly inserted card (caller inserts it after this hook)
if (highlighted === addr) {
setTimeout(() => {
const output = document.getElementById('output');
output?.querySelectorAll(`.signal-card[data-address="${CSS.escape(addr)}"]`)
.forEach(c => c.classList.add('pdir-hl'));
}, 0);
}
}
// ---- Show / hide / reset ----
function applyViewState(mode) {
const dirPanel = document.getElementById('pagerDirectoryView');
const feedHeader = document.getElementById('pagerFeedHeader');
if (mode === 'pager') {
const saved = localStorage.getItem(STORAGE_KEY) || 'directory';
const isDir = saved === 'directory';
if (dirPanel) dirPanel.style.display = isDir ? 'flex' : 'none';
if (feedHeader) feedHeader.style.display = isDir ? 'flex' : 'none';
_updateToggle(isDir);
} else {
if (dirPanel) dirPanel.style.display = 'none';
if (feedHeader) feedHeader.style.display = 'none';
clearHighlight();
}
}
function show() {
localStorage.setItem(STORAGE_KEY, 'directory');
applyViewState('pager');
}
function hide() {
localStorage.setItem(STORAGE_KEY, 'feed');
applyViewState('pager');
}
function _updateToggle(isDir) {
document.getElementById('pagerToggleDir')?.classList.toggle('view-toggle-btn--active', isDir);
document.getElementById('pagerToggleFeed')?.classList.toggle('view-toggle-btn--active', !isDir);
}
function reset() {
addresses.clear();
highlighted = null;
const entriesEl = document.getElementById('pagerDirEntries');
const countEl = document.getElementById('pagerDirCount');
if (entriesEl) entriesEl.innerHTML = '';
if (countEl) countEl.textContent = '0';
clearHighlight();
}
return { addMessage, highlight, clearHighlight, show, hide, reset, applyViewState };
})();
- Step 2: Verify the script loads without errors
With the dev server running, open http://localhost:5000, switch to pager mode in the UI. Check DevTools console — no errors. Open DevTools console and run PagerDirectory — should return the object (not undefined).
- Step 3: Commit
git add static/js/components/pager-directory.js
git commit -m "feat: add PagerDirectory JS component"
Task 4: Sensor dashboard CSS
Files:
-
Create:
static/css/components/sensor-dashboard.css -
Step 1: Create the CSS file
Create static/css/components/sensor-dashboard.css with this exact content:
/* ============================================================
Sensor Dashboard View
============================================================ */
.sdb-view {
flex: 1;
overflow-y: auto;
min-height: 0;
background: var(--bg-primary);
}
.sdb-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 8px;
padding: 10px;
}
/* ---- Station card ---- */
.sdb-card {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
padding: 10px;
font-family: var(--font-mono);
overflow: hidden;
}
.sdb-card--new {
border-color: rgba(56, 193, 128, 0.3);
animation: sdb-slide-in 0.4s ease-out;
}
@keyframes sdb-slide-in {
from { opacity: 0; transform: translateY(-6px); }
to { opacity: 1; transform: none; }
}
.sdb-card--flash-blue {
animation: sdb-flash-blue 0.8s ease-out;
}
@keyframes sdb-flash-blue {
0% { background: rgba(var(--accent-cyan-rgb), 0.10); border-color: rgba(var(--accent-cyan-rgb), 0.30); }
100% { background: var(--bg-card); border-color: var(--border-color); }
}
.sdb-card--flash-purple {
animation: sdb-flash-purple 0.8s ease-out;
}
@keyframes sdb-flash-purple {
0% { background: rgba(143, 123, 214, 0.10); border-color: rgba(143, 123, 214, 0.30); }
100% { background: var(--bg-card); border-color: var(--border-color); }
}
/* ---- Card header ---- */
.sdb-card-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 8px;
}
.sdb-name {
font-size: var(--text-xs);
color: var(--accent-cyan);
font-weight: var(--font-semibold);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 120px;
}
.sdb-id { font-size: 8px; color: var(--text-muted); margin-top: 1px; }
.sdb-age { font-size: 8px; color: var(--text-muted); white-space: nowrap; }
.sdb-age--fresh { color: var(--accent-green); }
/* ---- Readings grid ---- */
.sdb-readings {
display: flex;
gap: 10px;
flex-wrap: wrap;
margin-bottom: 8px;
min-height: 36px;
align-items: flex-end;
}
.sdb-reading { text-align: center; min-width: 34px; }
.sdb-reading-val { font-size: 15px; font-weight: var(--font-bold); line-height: 1; }
.sdb-reading-unit { font-size: 8px; color: var(--text-muted); }
.sdb-reading-label { font-size: 8px; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.5px; margin-top: 1px; }
.sdb-no-readings { font-size: 9px; color: var(--text-muted); align-self: center; }
/* ---- State-only device ---- */
.sdb-state { display: flex; align-items: center; gap: 6px; min-height: 36px; }
.sdb-state-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
.sdb-state-dot--on { background: var(--accent-green); box-shadow: 0 0 5px var(--accent-green); }
.sdb-state-dot--off { background: var(--text-muted); }
.sdb-state-label { font-size: 9px; color: var(--text-secondary); }
/* ---- Sparkline ---- */
.sdb-spark { margin-bottom: 6px; }
.sdb-spark svg { width: 100%; height: 22px; display: block; }
.sdb-spark-placeholder {
height: 22px;
background: var(--bg-secondary);
border-radius: var(--radius-sm);
display: flex;
align-items: center;
padding: 0 6px;
font-size: 8px;
color: var(--text-muted);
}
/* ---- Card footer ---- */
.sdb-footer {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 8px;
}
.sdb-bat--ok { color: var(--accent-green); }
.sdb-bat--low { color: var(--accent-red); }
.sdb-snr { color: var(--text-muted); }
.sdb-freq {
padding: 1px 4px;
border-radius: var(--radius-sm);
background: var(--bg-secondary);
color: var(--text-muted);
}
- Step 2: Verify styles load without errors
With dev server running, DevTools → Network — confirm sensor-dashboard.css returns HTTP 200.
- Step 3: Commit
git add static/css/components/sensor-dashboard.css
git commit -m "feat: add sensor dashboard view CSS"
Task 5: SensorDashboard JS component
Files:
-
Create:
static/js/components/sensor-dashboard.js -
Step 1: Create the component file
Create static/js/components/sensor-dashboard.js with this exact content:
const SensorDashboard = (function () {
'use strict';
const STORAGE_KEY = 'sensorView';
const MAX_SPARK_PTS = 30;
// Map<deviceKey, { card: HTMLElement, history: number[], primaryColor: string }>
const devices = new Map();
// ---- Helpers ----
function esc(str) {
return String(str)
.replace(/&/g, '&').replace(/</g, '<')
.replace(/>/g, '>').replace(/"/g, '"');
}
function formatAge(timestamp) {
if (!timestamp) return '';
const ts = typeof timestamp === 'string' ? new Date(timestamp).getTime() : Number(timestamp);
const s = Math.floor((Date.now() - ts) / 1000);
if (s < 10) return 'just now';
if (s < 60) return `${s}s ago`;
return `${Math.floor(s / 60)}m ago`;
}
function isRecent(timestamp) {
if (!timestamp) return false;
const ts = typeof timestamp === 'string' ? new Date(timestamp).getTime() : Number(timestamp);
return (Date.now() - ts) < 10000;
}
// ---- Primary value for sparkline ----
function getPrimary(msg) {
if (msg.temperature !== undefined)
return { value: msg.temperature, color: '#f59e0b' };
if (msg.pressure !== undefined)
return { value: msg.pressure, color: '#a78bfa' };
if (msg.wind_speed !== undefined)
return { value: msg.wind_speed, color: '#4aa3ff' };
return null;
}
function getFlashClass(msg) {
return msg.temperature !== undefined ? 'sdb-card--flash-blue' : 'sdb-card--flash-purple';
}
// ---- HTML builders ----
function buildReadingsHTML(msg) {
// State-only device (no continuous numeric field)
if (msg.state !== undefined && msg.temperature === undefined
&& msg.pressure === undefined && msg.wind_speed === undefined) {
const raw = String(msg.state);
const isOn = raw === '1' || raw === 'true' || raw === 'on' || raw === 'active';
return `<div class="sdb-state">
<span class="sdb-state-dot ${isOn ? 'sdb-state-dot--on' : 'sdb-state-dot--off'}"></span>
<span class="sdb-state-label">${esc(raw.toUpperCase())}</span>
</div>`;
}
const parts = [];
if (msg.temperature !== undefined)
parts.push({ val: msg.temperature, unit: `°${msg.temperature_unit || 'C'}`, label: 'Temp', color: '#f59e0b' });
if (msg.humidity !== undefined)
parts.push({ val: msg.humidity, unit: '%', label: 'Humid', color: '#38bdf8' });
if (msg.pressure !== undefined)
parts.push({ val: msg.pressure, unit: msg.pressure_unit || 'hPa', label: 'Press', color: '#a78bfa' });
if (msg.wind_speed !== undefined)
parts.push({ val: msg.wind_speed, unit: msg.wind_unit || 'km/h', label: 'Wind', color: '#4aa3ff' });
if (msg.rain !== undefined)
parts.push({ val: msg.rain, unit: msg.rain_unit || 'mm', label: 'Rain', color: '#38bdf8' });
if (parts.length === 0)
return `<div class="sdb-no-readings">No numeric data</div>`;
return parts.map(p => `
<div class="sdb-reading">
<div class="sdb-reading-val" style="color:${p.color}">${p.val}</div>
<div class="sdb-reading-unit">${esc(p.unit)}</div>
<div class="sdb-reading-label">${p.label}</div>
</div>`).join('');
}
function buildSparklineHTML(history, color) {
if (history.length < 2)
return `<div class="sdb-spark-placeholder">Collecting data…</div>`;
const W = 120, H = 22, PAD = 2;
const min = Math.min(...history);
const max = Math.max(...history);
const range = max - min || 1;
const pts = history.map((v, i) => {
const x = (i / (history.length - 1)) * (W - PAD * 2) + PAD;
const y = H - PAD - ((v - min) / range) * (H - PAD * 2);
return `${x.toFixed(1)},${y.toFixed(1)}`;
}).join(' ');
const last = pts.split(' ').pop().split(',');
return `<svg viewBox="0 0 ${W} ${H}" preserveAspectRatio="none" xmlns="http://www.w3.org/2000/svg">
<rect fill="var(--bg-secondary)" width="${W}" height="${H}"/>
<polyline points="${pts}" fill="none" stroke="${color}" stroke-width="1.5" opacity="0.85"/>
<circle cx="${last[0]}" cy="${last[1]}" r="2" fill="${color}"/>
</svg>`;
}
function buildCardHTML(msg, history, primaryColor) {
const age = formatAge(msg.timestamp);
const fresh = isRecent(msg.timestamp);
const batOk = msg.battery === 'OK';
const batLow = msg.battery === 'LOW';
const sparkHTML = history.length > 0
? buildSparklineHTML(history, primaryColor || '#4aa3ff')
: `<div class="sdb-spark-placeholder">Waiting for data…</div>`;
return `
<div class="sdb-card-header">
<div>
<div class="sdb-name">${esc(msg.model || 'Unknown')}</div>
<div class="sdb-id">ID ${esc(String(msg.id || 'N/A'))}${msg.channel ? ` · Ch ${msg.channel}` : ''}</div>
</div>
<div class="sdb-age${fresh ? ' sdb-age--fresh' : ''}">${age}</div>
</div>
<div class="sdb-readings">${buildReadingsHTML(msg)}</div>
<div class="sdb-spark">${sparkHTML}</div>
<div class="sdb-footer">
${msg.battery ? `<span class="sdb-bat ${batLow ? 'sdb-bat--low' : 'sdb-bat--ok'}">● BAT ${esc(msg.battery)}</span>` : '<span></span>'}
${msg.snr !== undefined ? `<span class="sdb-snr">SNR ${msg.snr} dB</span>` : '<span></span>'}
${msg.frequency ? `<span class="sdb-freq">${esc(String(msg.frequency))}</span>` : '<span></span>'}
</div>`;
}
// ---- Public: reading hook ----
function addReading(msg) {
const key = `${msg.model || 'Unknown'}_${msg.id || msg.channel || '0'}`;
const primary = getPrimary(msg);
if (devices.has(key)) {
const dev = devices.get(key);
if (primary) {
dev.history.push(primary.value);
if (dev.history.length > MAX_SPARK_PTS) dev.history.shift();
dev.primaryColor = primary.color;
}
dev.card.innerHTML = buildCardHTML(msg, dev.history, dev.primaryColor);
const cls = getFlashClass(msg);
dev.card.classList.add(cls);
setTimeout(() => dev.card.classList.remove(cls), 820);
} else {
const history = primary ? [primary.value] : [];
const grid = document.getElementById('sensorDashboardGrid');
if (!grid) return;
const card = document.createElement('div');
card.className = 'sdb-card sdb-card--new';
card.innerHTML = buildCardHTML(msg, history, primary ? primary.color : '#4aa3ff');
grid.insertBefore(card, grid.firstChild);
setTimeout(() => card.classList.remove('sdb-card--new'), 2000);
devices.set(key, { card, history, primaryColor: primary ? primary.color : '#4aa3ff' });
}
}
// ---- Show / hide / reset ----
function applyViewState(mode) {
const view = document.getElementById('sensorDashboardView');
const output = document.getElementById('output');
if (mode === 'sensor') {
const saved = localStorage.getItem(STORAGE_KEY) || 'dashboard';
const isDash = saved === 'dashboard';
if (view) view.style.display = isDash ? 'block' : 'none';
if (output) output.style.display = isDash ? 'none' : '';
_updateToggle(isDash);
} else {
if (view) view.style.display = 'none';
if (output) output.style.display = '';
}
}
function show() {
localStorage.setItem(STORAGE_KEY, 'dashboard');
applyViewState('sensor');
}
function hide() {
localStorage.setItem(STORAGE_KEY, 'feed');
applyViewState('sensor');
}
function _updateToggle(isDash) {
document.getElementById('sensorToggleDash')?.classList.toggle('view-toggle-btn--active', isDash);
document.getElementById('sensorToggleFeed')?.classList.toggle('view-toggle-btn--active', !isDash);
}
function reset() {
devices.clear();
const grid = document.getElementById('sensorDashboardGrid');
if (grid) grid.innerHTML = '';
}
return { addReading, show, hide, reset, applyViewState };
})();
- Step 2: Verify the script loads
Open http://localhost:5000 in a browser, switch to 433 mode, open DevTools console, run SensorDashboard — should return the object.
- Step 3: Commit
git add static/js/components/sensor-dashboard.js
git commit -m "feat: add SensorDashboard JS component"
Task 6: Wire up hooks, mode integration, and reset
Files:
-
Modify:
templates/index.html -
Step 1: Add hook in
addMessage()
In templates/index.html, find this line inside addMessage() (around line 7247):
const msgEl = SignalCards.createPagerCard(msg);
output.insertBefore(msgEl, output.firstChild);
Add the hook call immediately before output.insertBefore:
const msgEl = SignalCards.createPagerCard(msg);
if (typeof PagerDirectory !== 'undefined') PagerDirectory.addMessage(msg);
output.insertBefore(msgEl, output.firstChild);
- Step 2: Add hook in
addSensorReading()
Find this line inside addSensorReading() (around line 5771):
const card = SignalCards.createSensorCard(msg);
output.insertBefore(card, output.firstChild);
Replace with (the two if lines copy snr/rssi from raw data into msg so the dashboard footer can display them):
if (data.snr !== undefined) msg.snr = data.snr;
if (data.rssi !== undefined) msg.rssi = data.rssi;
const card = SignalCards.createSensorCard(msg);
if (typeof SensorDashboard !== 'undefined') SensorDashboard.addReading(msg);
output.insertBefore(card, output.firstChild);
- Step 3: Add
applyViewStatecalls inswitchMode()
Find these two lines in switchMode() (around line 4787):
document.getElementById('pagerStats')?.classList.toggle('active', mode === 'pager');
document.getElementById('sensorStats')?.classList.toggle('active', mode === 'sensor');
Add immediately after:
if (typeof PagerDirectory !== 'undefined') PagerDirectory.applyViewState(mode);
if (typeof SensorDashboard !== 'undefined') SensorDashboard.applyViewState(mode);
- Step 4: Add reset calls in
clearOutput()
Find these lines in the clear output function (around line 7346):
msgCount = 0;
pocsagCount = 0;
flexCount = 0;
sensorCount = 0;
Add immediately before:
if (typeof PagerDirectory !== 'undefined') PagerDirectory.reset();
if (typeof SensorDashboard !== 'undefined') SensorDashboard.reset();
msgCount = 0;
- Step 5: Verify pager directory end-to-end
Start python intercept.py, open http://localhost:5000, switch to Pager mode.
- The output header should show Directory | Feed toggle buttons.
- Click Start Decoding with a valid SDR connected (or wait for agent replay if available).
- On first message: an address entry should appear in the left panel with a green dot.
- Click that address entry: cards from that address get a blue left border; the feed header shows
"<addr> highlighted"; clicking the same address again clears it. - Click Feed: left panel disappears, classic cards fill the full width. Click Directory: panel returns.
- Refresh the page: the view preference is restored from localStorage.
If no SDR is available, manually invoke from the browser console to verify DOM updates:
PagerDirectory.addMessage({ address: '1234567', protocol: 'POCSAG-1200', message: 'TEST', timestamp: new Date().toISOString() });
PagerDirectory.addMessage({ address: '9990001', protocol: 'FLEX', message: 'HELLO', timestamp: new Date().toISOString() });
// Expect: two entries in the directory panel
PagerDirectory.highlight('1234567');
// Expect: first entry highlighted with blue border
- Step 6: Verify sensor dashboard end-to-end
Switch to 433 mode.
- The header should show Dashboard | Feed toggle buttons.
- Start Listening (or use console):
SensorDashboard.addReading({ model: 'Acurite-Tower', id: 42, channel: 'A', temperature: 21.4, temperature_unit: 'C', humidity: 67, battery: 'OK', snr: 14, frequency: '433.92', timestamp: new Date().toISOString() });
SensorDashboard.addReading({ model: 'Acurite-Tower', id: 42, channel: 'A', temperature: 21.6, temperature_unit: 'C', humidity: 68, battery: 'OK', snr: 14, frequency: '433.92', timestamp: new Date().toISOString() });
// Expect: one card, second call updates in place with flash + sparkline with two points
SensorDashboard.addReading({ model: 'Interlogix-PIR', id: '0x44A', state: 'active', battery: 'OK', snr: 18, frequency: '433.92', timestamp: new Date().toISOString() });
// Expect: second card with green state dot
-
Click Feed: dashboard hides, classic cards become visible. Click Dashboard: returns.
-
Switching to a different mode (e.g. ADS-B) and back: dashboard is restored.
-
Step 7: Run full test suite
pytest
Expected: all existing tests pass (no regressions from template edits).
- Step 8: Commit
git add templates/index.html
git commit -m "feat: wire PagerDirectory and SensorDashboard into pager and sensor modes"