diff --git a/README.md b/README.md index 63aa86e..d8c7a82 100644 --- a/README.md +++ b/README.md @@ -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://:8080`. It shows: +The edge node serves a dynamic dual-mode dashboard at `http:///`. -- **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 diff --git a/docs/UI_GUIDE.md b/docs/UI_GUIDE.md new file mode 100644 index 0000000..80370a4 --- /dev/null +++ b/docs/UI_GUIDE.md @@ -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! diff --git a/drb-edge-node/Dockerfile b/drb-edge-node/Dockerfile index 10a65f6..af71952 100644 --- a/drb-edge-node/Dockerfile +++ b/drb-edge-node/Dockerfile @@ -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"] diff --git a/drb-edge-node/app/templates/index.html b/drb-edge-node/app/templates/index.html index 9d10407..a21fba9 100644 --- a/drb-edge-node/app/templates/index.html +++ b/drb-edge-node/app/templates/index.html @@ -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'; diff --git a/drb-edge-node/app/templates/scanner.html b/drb-edge-node/app/templates/scanner.html index 8b9e21c..e1b65e9 100644 --- a/drb-edge-node/app/templates/scanner.html +++ b/drb-edge-node/app/templates/scanner.html @@ -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 @@
▶ RUNNING
+
12:00:00
VOL: 100%
+
+

Hardware Key Legend

+
    +
  • Up Arrow Volume Up
  • +
  • Down Arrow Volume Down
  • +
  • Enter / H Toggle Hold (UI mock)
  • +
  • P Play/Pause Stream
  • +
+ +
+
NO SYSTEM
Scanning...
@@ -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);