Files
node-26/README.md
T
2026-04-06 02:47:02 -04:00

237 lines
12 KiB
Markdown

# DRB Client (Edge Node)
Software stack for a DRB (Discord Radio Bot) edge node. Runs on the SDR hardware machine. Decodes P25/DMR/NBFM radio with OP25, streams decoded audio via Icecast, and streams live audio into Discord voice channels on demand.
## System Overview
An edge node is a machine (typically a small Linux box or Raspberry Pi) with an RTL-SDR dongle attached. It connects to the central DRB C2 server over MQTT and waits for a radio system config. Once configured, it decodes transmissions and streams audio. When a Discord user runs `/join`, the server sends the node a `discord_join` command and the node puts a Discord bot in the user's voice channel that plays the live Icecast stream.
The Discord bot's speaking ring (green animated border) **only lights up when radio is actively transmitting** — not while it's silently waiting in the channel.
```
RTL-SDR dongle
op25-container ←── GnuRadio + OP25 + Liquidsoap
│ decodes P25/DMR/NBFM
│ streams MP3 audio to Icecast on port 8000
│ HTTP API on port 8001 (start/stop/generate-config)
│ HTTP terminal on port 8081 (live talkgroup metadata)
icecast ←── Icecast2
│ serves audio at http://localhost:8000/radio
drb-edge-node ←── FastAPI
│ polls OP25 terminal every 0.5s — detects call start/end events
│ publishes call metadata + status to C2 server over MQTT
│ records each call with FFmpeg, uploads recording to C2
│ receives discord_join/leave commands over MQTT
│ starts Discord bot (discord.py), joins voice channel
│ starts/stops FFmpeg→Discord stream on OP25 call events
│ serves local web dashboard on port 8080
C2 Server (remote — over MQTT + HTTP)
stores status, metadata, recordings in Firestore/GCS
sends commands: discord_join, discord_leave, op25_restart
pushes system configs on admin assignment
```
## Services
| Service | Container | Description |
|---|---|---|
| `icecast` | Icecast2 (Debian) | Audio streaming server — receives encoded audio from OP25/Liquidsoap, serves it over HTTP |
| `op25` | OP25 + GnuRadio (Debian) | SDR decoder — tunes to a radio system, decodes P25/DMR/NBFM, streams via Liquidsoap to Icecast |
| `edge-node` | Python 3.11 / FastAPI | Node agent — bridges OP25 metadata, C2 MQTT commands, call recording, and Discord voice |
All services run with `network_mode: host` so they share the host network and reach each other on `localhost` without any port mapping.
## Directory Structure
```
Client/
├── docker-compose.yml # Orchestrates icecast + op25 + edge-node
├── .env.example # Environment variable template
├── Makefile # setup / test / up / down / logs
├── drb-edge-node/ # FastAPI node agent
│ ├── app/
│ │ ├── main.py # Lifespan: wires all callbacks, starts/stops services
│ │ ├── config.py # Pydantic settings — loaded from .env
│ │ ├── models.py # NodeConfig, SystemConfig, CallEvent, etc.
│ │ ├── routers/
│ │ │ ├── api.py # /api/* REST endpoints (status, op25, discord control)
│ │ │ └── ui.py # GET / → serves index.html
│ │ ├── templates/
│ │ │ └── index.html # Local web dashboard (dark theme, auto-refreshes every 3s)
│ │ └── internal/
│ │ ├── mqtt_manager.py # MQTT: checkin/status/metadata pub + command/config/apikey sub
│ │ ├── metadata_watcher.py # Polls OP25 terminal at 0.5s — fires on_call_start/on_call_end
│ │ ├── op25_client.py # HTTP client to op25-container API (start/stop/status/config)
│ │ ├── discord_radio.py # discord.py bot — joins voice, streams Icecast, @mention commands
│ │ ├── call_recorder.py # FFmpeg recording from Icecast per call, uploads to C2
│ │ ├── credentials.py # Persists C2-issued API key to /configs/credentials.json
│ │ ├── config_manager.py # Reads/writes /configs/node_config.json
│ │ └── logger.py # Stdout logging setup
│ ├── tests/
│ │ ├── test_config_manager.py
│ │ └── test_metadata_watcher.py
│ ├── Dockerfile # Python 3.11-slim + ffmpeg + libopus
│ └── requirements.txt
├── op25-container/ # OP25 SDR decoder service
│ ├── app/
│ │ ├── main.py # FastAPI + Liquidsoap config generation on startup
│ │ ├── routers/
│ │ │ └── op25_controller.py # /op25/start /op25/stop /op25/status /op25/config
│ │ └── internal/
│ │ ├── op25_config_utils.py # Generates active.cfg.json for OP25's multi_rx
│ │ ├── op25_liq_template.py # Liquidsoap script template
│ │ └── liquidsoap_config_utils.py # Renders op25.liq with Icecast credentials
│ ├── Dockerfile # GnuRadio + OP25 (boatbod gr310) + Liquidsoap — builds ~15min
│ ├── run_multi-rx_service.sh # Launches OP25's multi_rx.py + HTTP terminal on port 8081
│ └── docker-entrypoint.sh # Starts uvicorn (CRLF-safe wrapper)
└── icecast/ # Icecast2 audio streaming server
├── Dockerfile # debian:bookworm-slim + icecast2
├── icecast.xml.template # Config template — env vars injected at runtime
└── entrypoint.sh # envsubst → icecast2 as dedicated icecast user
```
## Prerequisites
- Docker + Docker Compose
- An RTL-SDR (or compatible SDR dongle) connected to the host via USB
- Network access to the DRB C2 server (MQTT port 1883 + HTTP API)
- A node ID — any unique string to identify this machine in the server dashboard
## Setup
```bash
# 1. Copy env template
cp .env.example .env
# 2. Fill in at minimum: NODE_ID and MQTT_BROKER
nano .env
# 3. Build all images (op25 takes ~10-15 minutes first time)
docker compose build
# 4. Start
docker compose up -d
```
The node will appear as **pending** in the server admin dashboard. An admin must approve it before it becomes operational. After approval, assign a radio system in the dashboard and the node will start decoding automatically.
## Environment Variables (`.env`)
| Variable | Required | Default | Description |
|---|---|---|---|
| `NODE_ID` | Yes | — | Unique ID for this node (e.g. `node-east-01`). Used in MQTT topics, Firestore docs, and the dashboard. |
| `NODE_NAME` | No | `My Radio Node` | Human-readable name shown in the dashboard and Discord `/status` |
| `NODE_LAT` | No | `0.0` | GPS latitude (decimal degrees) — shown on the server's map view |
| `NODE_LON` | No | `0.0` | GPS longitude (decimal degrees) |
| `MQTT_BROKER` | Yes | `localhost` | IP or hostname of the C2 server's MQTT broker |
| `MQTT_PORT` | No | `1883` | MQTT port |
| `MQTT_USER` | No | — | MQTT username (if the broker requires authentication) |
| `MQTT_PASS` | No | — | MQTT password |
| `C2_URL` | No | — | C2 server HTTP API URL — required for audio upload (e.g. `http://192.168.1.10:8000`) |
| `ICECAST_HOST` | No | `localhost` | Icecast hostname (leave as localhost — host network mode) |
| `ICECAST_PORT` | No | `8000` | Icecast HTTP port |
| `ICECAST_MOUNT` | No | `/radio` | Icecast mount point |
| `ICECAST_SOURCE_PASSWORD` | No | `hackme` | Icecast source password — change this |
| `ICECAST_ADMIN_PASSWORD` | No | `hackme` | Icecast admin password — change this |
| `OP25_API_URL` | No | `http://localhost:8001` | OP25 container HTTP API |
| `OP25_TERMINAL_URL` | No | `http://localhost:8081` | OP25 HTTP terminal (live talkgroup metadata) |
## Node Provisioning Flow
1. Stack starts → edge-node connects to MQTT and sends a **checkin** with its ID, name, and coordinates
2. C2 server registers the node as **pending approval** in Firestore
3. Admin clicks **Approve** in the web dashboard → C2 generates a random API key and publishes it to the node over MQTT
4. Edge node saves the API key to `/configs/credentials.json` (persists across restarts)
5. Admin assigns a **radio system** → C2 pushes the full system config over MQTT (frequencies, talkgroups, Icecast settings)
6. Edge node translates the config to OP25 format, writes `/configs/active.cfg.json`, and (re)starts OP25
7. OP25 begins decoding and streaming audio to Icecast
8. After any container restart, OP25 resumes automatically if a config is already saved
## Radio System Config
Radio system configs are created in the server dashboard (`/systems`). A system has:
- **Type**: `P25`, `DMR`, or `NBFM`
- **Control channels**: List of frequencies in Hz or MHz
- **Talkgroups**: List of `{id, name}` pairs
The C2 server translates these into OP25's `active.cfg.json` format (with Hz frequencies, decimal talkgroup IDs, and a whitelist) and pushes the config to the node over MQTT. Only talkgroups in the list are decoded and streamed.
## Discord Voice
**How it works:**
1. A Discord user runs `/join` in the server bot, picking a radio system (and optionally a specific bot token from the pool).
2. Server sends a `discord_join` command to c2-core with the target guild ID, channel ID, and an optional preferred token.
3. c2-core picks a free token from the pool, marks it `in_use`, and forwards everything to the edge node over MQTT.
4. Edge node starts a `discord.py` bot with that token and connects it to the voice channel.
5. The bot **sits silently** in the channel until radio is active (no speaking ring while idle).
6. When OP25 detects an active call → `start_stream()` → FFmpegPCMAudio opens the Icecast URL → Discord speaking ring lights up.
7. When the call ends (1s of inactivity on the talkgroup) → `stop_stream()` → FFmpeg stops → ring goes dark.
8. On `/leave`, the bot disconnects and the token is released back to the pool.
**Direct bot commands:**
Once a radio bot is in a voice channel, mention it in any text channel:
- **@botname joinme** — Move the bot to your current voice channel
- **@botname leave** — Disconnect the bot from voice
> The `message_content` privileged intent must be enabled for each bot token in the [Discord Developer Portal](https://discord.com/developers/applications). Without it, the bot cannot read message text and mention commands won't work.
## Local Web Dashboard
The edge node serves a dashboard at `http://<node-ip>:8080`. It shows:
- **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`.
## Volume Mounts
| Host path | Container path | Contents |
|---|---|---|
| `./configs` | `/configs` | `node_config.json` (system assignment + configured flag), `credentials.json` (C2-issued API key), `active.cfg.json` (current OP25 config) |
| `./recordings` | `/recordings` | Per-call MP3 files (held until uploaded to C2, then kept as local backup) |
The `configs` volume is critical — without it, the node forgets its API key and system config on every restart.
## Call Recording
Every call is recorded via FFmpeg pulling from the Icecast stream. Recordings are:
- Saved to `/recordings/YYYYMMDD_HHMMSS_<call_id>.mp3`
- Uploaded to the C2 server (if `C2_URL` is set) using the node's API key as a Bearer token
- Capped at 600 seconds for safety
## Makefile Targets
```
make setup — copy .env.example → .env
make test — run pytest inside the edge-node container
make up — docker compose up -d
make down — docker compose down
make logs — follow edge-node logs
```
## Logs
```bash
docker compose logs -f edge-node # node agent, MQTT events, call detection, Discord
docker compose logs -f op25 # SDR decoder, Liquidsoap stream
docker compose logs -f icecast # audio streaming server
```