readme update
This commit is contained in:
@@ -1,71 +1,236 @@
|
|||||||
# DRB Client (Edge Node)
|
# DRB Client (Edge Node)
|
||||||
|
|
||||||
The client-side stack for the Discord Radio Bot system. Runs on the SDR hardware machine. Decodes radio with OP25, streams audio via Icecast, and connects to the C2 server over MQTT.
|
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
|
## Services
|
||||||
|
|
||||||
| Service | Description |
|
| Service | Container | Description |
|
||||||
|---|---|
|
|---|---|---|
|
||||||
| `op25` | OP25 SDR decoder — tunes to radio systems and streams decoded audio |
|
| `icecast` | Icecast2 (Debian) | Audio streaming server — receives encoded audio from OP25/Liquidsoap, serves it over HTTP |
|
||||||
| `icecast` | Audio streaming server — receives audio from OP25 and 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` | FastAPI node agent — bridges OP25 metadata, MQTT C2 commands, and Discord voice |
|
| `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 can reach each other on localhost.
|
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
|
## Prerequisites
|
||||||
|
|
||||||
- Docker + Docker Compose
|
- Docker + Docker Compose
|
||||||
- An RTL-SDR or compatible SDR dongle connected to the host
|
- An RTL-SDR (or compatible SDR dongle) connected to the host via USB
|
||||||
- Network access to the DRB server (C2 server)
|
- 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
|
## Setup
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. Copy env file
|
# 1. Copy env template
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
|
|
||||||
# 2. Fill in .env
|
# 2. Fill in at minimum: NODE_ID and MQTT_BROKER
|
||||||
# At minimum: NODE_ID, MQTT_BROKER (IP of the C2 server)
|
nano .env
|
||||||
|
|
||||||
# 3. Build and start
|
# 3. Build all images (op25 takes ~10-15 minutes first time)
|
||||||
docker compose build
|
docker compose build
|
||||||
|
|
||||||
|
# 4. Start
|
||||||
docker compose up -d
|
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`)
|
## Environment Variables (`.env`)
|
||||||
|
|
||||||
| Variable | Description | Default |
|
| Variable | Required | Default | Description |
|
||||||
|---|---|---|
|
|---|---|---|---|
|
||||||
| `NODE_ID` | Unique identifier for this node | required |
|
| `NODE_ID` | Yes | — | Unique ID for this node (e.g. `node-east-01`). Used in MQTT topics, Firestore docs, and the dashboard. |
|
||||||
| `NODE_NAME` | Human-readable name shown in the UI | `My Radio Node` |
|
| `NODE_NAME` | No | `My Radio Node` | Human-readable name shown in the dashboard and Discord `/status` |
|
||||||
| `NODE_LAT` / `NODE_LON` | GPS coordinates for the map view | `0.0` |
|
| `NODE_LAT` | No | `0.0` | GPS latitude (decimal degrees) — shown on the server's map view |
|
||||||
| `MQTT_BROKER` | IP or hostname of the C2 server | `localhost` |
|
| `NODE_LON` | No | `0.0` | GPS longitude (decimal degrees) |
|
||||||
| `MQTT_PORT` | MQTT broker port | `1883` |
|
| `MQTT_BROKER` | Yes | `localhost` | IP or hostname of the C2 server's MQTT broker |
|
||||||
| `C2_URL` | C2 server HTTP API URL (for audio uploads) | — |
|
| `MQTT_PORT` | No | `1883` | MQTT port |
|
||||||
| `ICECAST_HOST` | Icecast hostname (leave as localhost) | `localhost` |
|
| `MQTT_USER` | No | — | MQTT username (if the broker requires authentication) |
|
||||||
| `ICECAST_PORT` | Icecast port | `8000` |
|
| `MQTT_PASS` | No | — | MQTT password |
|
||||||
| `ICECAST_MOUNT` | Icecast mount point | `/radio` |
|
| `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
|
## Node Provisioning Flow
|
||||||
|
|
||||||
1. Start the client stack — the edge node connects to MQTT and sends a **checkin**
|
1. Stack starts → edge-node connects to MQTT and sends a **checkin** with its ID, name, and coordinates
|
||||||
2. The C2 server registers the node as **pending approval**
|
2. C2 server registers the node as **pending approval** in Firestore
|
||||||
3. An admin approves the node in the web UI
|
3. Admin clicks **Approve** in the web dashboard → C2 generates a random API key and publishes it to the node over MQTT
|
||||||
4. The admin assigns a **radio system** to the node via the UI
|
4. Edge node saves the API key to `/configs/credentials.json` (persists across restarts)
|
||||||
5. The C2 server pushes the system config over MQTT
|
5. Admin assigns a **radio system** → C2 pushes the full system config over MQTT (frequencies, talkgroups, Icecast settings)
|
||||||
6. The edge node writes the config and **starts OP25**
|
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
|
7. OP25 begins decoding and streaming audio to Icecast
|
||||||
|
8. After any container restart, OP25 resumes automatically if a config is already saved
|
||||||
|
|
||||||
After a restart, OP25 resumes automatically if the node was already configured.
|
## 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
|
## Discord Voice
|
||||||
|
|
||||||
When a user runs `/join` in Discord (or clicks "Join Discord" in the UI), the C2 server sends a `discord_join` command to this node over MQTT. The edge node spins up a Discord bot using a token from the server's token pool and connects it to the requested voice channel, streaming live audio from Icecast.
|
**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
|
## Logs
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose logs -f edge-node
|
docker compose logs -f edge-node # node agent, MQTT events, call detection, Discord
|
||||||
docker compose logs -f op25
|
docker compose logs -f op25 # SDR decoder, Liquidsoap stream
|
||||||
docker compose logs -f icecast
|
docker compose logs -f icecast # audio streaming server
|
||||||
```
|
```
|
||||||
|
|||||||
Reference in New Issue
Block a user