update: scanner dashboard UI and Docker configuration for edge node
CI / lint (push) Failing after 5s
CI / test (push) Successful in 20s
Build edge-node / build (push) Successful in 33s

This commit is contained in:
Logan Cusano
2026-07-12 21:44:36 -04:00
parent 3fbdc50536
commit 9f026fa262
5 changed files with 104 additions and 17 deletions
+2 -7
View File
@@ -217,14 +217,9 @@ Once a radio bot is in a voice channel, mention it in any text channel:
## Local Web Dashboard ## Local Web Dashboard
The edge node serves a dashboard at `http://<node-ip>:8080`. It shows: The edge node serves a dynamic dual-mode dashboard at `http://<node-ip>/`.
- **Status**: Node state (online / recording / unconfigured), MQTT connection, Discord connection, recording state For complete details on both the Network Configuration Dashboard and the offline/portable Scanner UI (including hardware button mapping), please read the [UI & Hardware Guide](docs/UI_GUIDE.md).
- **Current call**: Talkgroup name + ID, call ID, system name, OP25 status
- **Node info**: Coordinates, assigned system, configured flag, live Icecast listen button
- **Unconfigured banner**: Displayed if the node has not yet been assigned a radio system
Auto-refreshes every 3 seconds by polling `/api/status`.
## Volume Mounts ## Volume Mounts
+60
View File
@@ -0,0 +1,60 @@
# DRB Edge Node — UI & Hardware Guide
The DRB Edge Node comes equipped with a highly versatile, dual-mode web interface designed to support both remote network management and local hardware integration.
This document details how to use both interfaces and how to configure physical hardware (like an LCD screen and buttons) to build a standalone, portable scanner unit.
---
## 1. Network Configuration Dashboard (`/`)
The primary dashboard is served at the root URL (e.g., `http://172.16.100.85/` or `http://localhost/`). It is a premium, responsive, glassmorphic web application designed for on-network control.
### Features
- **Live Status Monitoring**: View MQTT connectivity, Discord bot status, recording status, and the underlying OP25 core state.
- **Active Call Tracking**: When a transmission begins, the "Live Activity" card highlights to display the Talkgroup Name, System, and Talkgroup ID in real-time.
- **Local Audio Streaming**: Features an embedded HTML5 audio player hooked directly into the node's Icecast stream. **Note**: Icecast has a 2-5 second buffering delay by design, which is normal.
- **Network-Aware**: The audio stream dynamically routes to the host IP you are accessing it from (no broken `localhost` links when accessing from another PC).
---
## 2. Scanner Display Mode (`/scanner`)
This dedicated view is optimized for small physical displays (e.g., 3.5" or 5" Raspberry Pi Touchscreens). It mimics the high-contrast, information-dense layout of professional digital scanners like the Uniden SDS100.
### Features
- **High Visibility**: Large, bold typography over a pure black background.
- **Visual Call Alerts**: The channel display pulses blue when an active transmission is received.
- **Kiosk Ready**: Fixed layout with no scrolling required; perfect for running in `chromium-browser --kiosk` mode on boot.
### Hardware Button Mapping
To make the node operate like a real scanner, the UI listens for standard keyboard events. By wiring physical buttons to the Raspberry Pi's GPIO pins and using a script (like `gpiozero` or `python-uinput`) to emit keystrokes, you can build a fully functional portable unit.
| UI Action | Trigger Key | Description |
| :--- | :--- | :--- |
| **Volume Up** | `ArrowUp` | Increases the local Icecast stream volume by 10%. Shows a temporary on-screen overlay. |
| **Volume Down** | `ArrowDown` | Decreases volume by 10%. |
| **Toggle Hold** | `Enter` or `H` | Toggles the Hold state (Currently a UI mock, backend support planned). |
| **Play/Pause** | `P` | Pauses or resumes the audio stream. Useful for temporarily silencing the unit. |
| **Legend Modal** | Mouse Click | Clicking the "KEY LEGEND" button opens an on-screen modal reminding users of these mappings. |
---
## Building a Portable Unit
If you are taking the node into the field (e.g., running off a battery in the woods without internet):
1. **Hardware Requirements**:
- Raspberry Pi (3B+ or 4 recommended).
- Compatible SDR Dongle.
- Small HDMI or SPI LCD Screen.
- 3 to 5 tactile push buttons.
- External battery pack (Ensure it can supply 3A for the Pi + SDR).
2. **Software Setup**:
- Configure the OS to boot to desktop and auto-launch Chromium in kiosk mode pointing to `http://localhost/scanner`.
- Run a Python script in the background that maps GPIO button presses to keyboard events (using the `keyboard` or `uinput` library) matching the table above.
3. **Offline Operation**:
- The node will still boot and OP25 will decode the last assigned radio system, even if the MQTT broker and Discord are unreachable.
- You can listen locally through the audio jack/speakers while watching the Scanner UI!
+1 -1
View File
@@ -15,4 +15,4 @@ RUN pip install uv && uv pip install --system --no-cache-dir -r requirements.txt
COPY app/ ./app/ COPY app/ ./app/
COPY tests/ ./tests/ COPY tests/ ./tests/
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080", "--reload"] CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "80", "--reload"]
+5 -2
View File
@@ -409,11 +409,14 @@
document.getElementById('system-id').textContent = d.assigned_system_id ?? '—'; document.getElementById('system-id').textContent = d.assigned_system_id ?? '—';
// Audio Player // Audio Player
if (d.icecast_url && d.icecast_url !== currentIcecastUrl) { if (d.icecast_url) {
currentIcecastUrl = d.icecast_url; const dynamicUrl = d.icecast_url.replace('localhost', window.location.hostname);
if (dynamicUrl !== currentIcecastUrl) {
currentIcecastUrl = dynamicUrl;
player.src = currentIcecastUrl; player.src = currentIcecastUrl;
document.getElementById('icecast-link').href = currentIcecastUrl; document.getElementById('icecast-link').href = currentIcecastUrl;
} }
}
document.getElementById('unconfigured-banner').style.display = d.configured ? 'none' : 'flex'; document.getElementById('unconfigured-banner').style.display = d.configured ? 'none' : 'flex';
document.getElementById('last-updated').textContent = new Date().toLocaleTimeString(); document.getElementById('last-updated').textContent = new Date().toLocaleTimeString();
+31 -2
View File
@@ -37,7 +37,20 @@
font-weight: bold; font-weight: bold;
border-bottom: 2px solid #333; border-bottom: 2px solid #333;
background-color: #111; background-color: #111;
align-items: center;
} }
.legend-btn {
background: #333; color: #fff; border: 1px solid #555; padding: 2px 6px; font-size: 10px; cursor: pointer; border-radius: 4px;
}
.legend-modal {
display: none; position: absolute; top: 10%; left: 10%; right: 10%; bottom: 10%;
background: rgba(10,10,10,0.95); border: 2px solid #555; z-index: 200; padding: 20px; overflow-y: auto;
}
.legend-modal h2 { margin-bottom: 15px; color: var(--sys-color); }
.legend-modal ul { list-style: none; line-height: 1.8; margin-bottom: 20px; }
.legend-modal li { font-size: 14px; }
.legend-modal li span { display: inline-block; width: 100px; font-weight: bold; color: #fff; }
.close-legend { padding: 10px; background: #c00; color: #fff; border: none; cursor: pointer; width: 100%; font-weight: bold; border-radius: 4px;}
/* Main Display Area */ /* Main Display Area */
.main-display { .main-display {
@@ -130,10 +143,22 @@
<body> <body>
<div class="status-bar"> <div class="status-bar">
<div id="status-icon">▶ RUNNING</div> <div id="status-icon">▶ RUNNING</div>
<div><button class="legend-btn" onclick="document.getElementById('legend-modal').style.display='block'">KEY LEGEND</button></div>
<div id="clock">12:00:00</div> <div id="clock">12:00:00</div>
<div id="vol-indicator">VOL: 100%</div> <div id="vol-indicator">VOL: 100%</div>
</div> </div>
<div class="legend-modal" id="legend-modal">
<h2>Hardware Key Legend</h2>
<ul>
<li><span>Up Arrow</span> Volume Up</li>
<li><span>Down Arrow</span> Volume Down</li>
<li><span>Enter / H</span> Toggle Hold (UI mock)</li>
<li><span>P</span> Play/Pause Stream</li>
</ul>
<button class="close-legend" onclick="document.getElementById('legend-modal').style.display='none'">CLOSE</button>
</div>
<div class="main-display" id="main-display"> <div class="main-display" id="main-display">
<div class="row-large system-row" id="disp-system">NO SYSTEM</div> <div class="row-large system-row" id="disp-system">NO SYSTEM</div>
<div class="row-large dept-row" id="disp-dept">Scanning...</div> <div class="row-large dept-row" id="disp-dept">Scanning...</div>
@@ -207,12 +232,16 @@
elTgid.textContent = '---'; elTgid.textContent = '---';
} }
if (data.icecast_url && currentIcecastUrl !== data.icecast_url) { if (data.icecast_url) {
currentIcecastUrl = data.icecast_url; // Replace localhost with actual hostname for network access
const dynamicUrl = data.icecast_url.replace('localhost', window.location.hostname);
if (currentIcecastUrl !== dynamicUrl) {
currentIcecastUrl = dynamicUrl;
audioPlayer.src = currentIcecastUrl; audioPlayer.src = currentIcecastUrl;
// Modern browsers might block autoplay without interaction // Modern browsers might block autoplay without interaction
audioPlayer.play().catch(e => console.log('Autoplay blocked', e)); audioPlayer.play().catch(e => console.log('Autoplay blocked', e));
} }
}
} catch (err) { } catch (err) {
console.error('Failed to fetch status', err); console.error('Failed to fetch status', err);
} }