update: scanner dashboard UI and Docker configuration for edge node
This commit is contained in:
@@ -217,14 +217,9 @@ Once a radio bot is in a voice channel, mention it in any text channel:
|
||||
|
||||
## 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
|
||||
- **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`.
|
||||
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).
|
||||
|
||||
## Volume Mounts
|
||||
|
||||
|
||||
@@ -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!
|
||||
@@ -15,4 +15,4 @@ RUN pip install uv && uv pip install --system --no-cache-dir -r requirements.txt
|
||||
COPY app/ ./app/
|
||||
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"]
|
||||
|
||||
@@ -409,10 +409,13 @@
|
||||
document.getElementById('system-id').textContent = d.assigned_system_id ?? '—';
|
||||
|
||||
// Audio Player
|
||||
if (d.icecast_url && d.icecast_url !== currentIcecastUrl) {
|
||||
currentIcecastUrl = d.icecast_url;
|
||||
player.src = currentIcecastUrl;
|
||||
document.getElementById('icecast-link').href = currentIcecastUrl;
|
||||
if (d.icecast_url) {
|
||||
const dynamicUrl = d.icecast_url.replace('localhost', window.location.hostname);
|
||||
if (dynamicUrl !== currentIcecastUrl) {
|
||||
currentIcecastUrl = dynamicUrl;
|
||||
player.src = currentIcecastUrl;
|
||||
document.getElementById('icecast-link').href = currentIcecastUrl;
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('unconfigured-banner').style.display = d.configured ? 'none' : 'flex';
|
||||
|
||||
@@ -37,7 +37,20 @@
|
||||
font-weight: bold;
|
||||
border-bottom: 2px solid #333;
|
||||
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 {
|
||||
@@ -130,10 +143,22 @@
|
||||
<body>
|
||||
<div class="status-bar">
|
||||
<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="vol-indicator">VOL: 100%</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="row-large system-row" id="disp-system">NO SYSTEM</div>
|
||||
<div class="row-large dept-row" id="disp-dept">Scanning...</div>
|
||||
@@ -207,11 +232,15 @@
|
||||
elTgid.textContent = '---';
|
||||
}
|
||||
|
||||
if (data.icecast_url && currentIcecastUrl !== data.icecast_url) {
|
||||
currentIcecastUrl = data.icecast_url;
|
||||
audioPlayer.src = currentIcecastUrl;
|
||||
// Modern browsers might block autoplay without interaction
|
||||
audioPlayer.play().catch(e => console.log('Autoplay blocked', e));
|
||||
if (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;
|
||||
// Modern browsers might block autoplay without interaction
|
||||
audioPlayer.play().catch(e => console.log('Autoplay blocked', e));
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch status', err);
|
||||
|
||||
Reference in New Issue
Block a user