12 KiB
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
# 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
- Stack starts → edge-node connects to MQTT and sends a checkin with its ID, name, and coordinates
- C2 server registers the node as pending approval in Firestore
- Admin clicks Approve in the web dashboard → C2 generates a random API key and publishes it to the node over MQTT
- Edge node saves the API key to
/configs/credentials.json(persists across restarts) - Admin assigns a radio system → C2 pushes the full system config over MQTT (frequencies, talkgroups, Icecast settings)
- Edge node translates the config to OP25 format, writes
/configs/active.cfg.json, and (re)starts OP25 - OP25 begins decoding and streaming audio to Icecast
- 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, orNBFM - 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:
- A Discord user runs
/joinin the server bot, picking a radio system (and optionally a specific bot token from the pool). - Server sends a
discord_joincommand to c2-core with the target guild ID, channel ID, and an optional preferred token. - c2-core picks a free token from the pool, marks it
in_use, and forwards everything to the edge node over MQTT. - Edge node starts a
discord.pybot with that token and connects it to the voice channel. - The bot sits silently in the channel until radio is active (no speaking ring while idle).
- When OP25 detects an active call →
start_stream()→ FFmpegPCMAudio opens the Icecast URL → Discord speaking ring lights up. - When the call ends (1s of inactivity on the talkgroup) →
stop_stream()→ FFmpeg stops → ring goes dark. - 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_contentprivileged intent must be enabled for each bot token in the Discord Developer Portal. 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_URLis 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
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