2026-04-11 13:43:59 -04:00
2026-04-11 13:43:59 -04:00
2026-04-06 00:23:33 -04:00
2026-04-11 13:43:59 -04:00
2026-04-06 00:23:33 -04:00
2026-04-06 02:47:02 -04:00

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

  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. 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

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
S
Description
No description provided
Readme 220 KiB
Languages
Python 81.8%
Shell 7.6%
HTML 7.1%
Dockerfile 2.8%
Makefile 0.7%