Initial commit — DRB client (edge node) stack

Includes edge-node (FastAPI/MQTT/Discord voice), op25-container (SDR decoder),
and icecast (audio streaming).
This commit is contained in:
Logan
2026-04-05 19:01:51 -04:00
commit 1a9c92b6db
47 changed files with 2496 additions and 0 deletions
@@ -0,0 +1,57 @@
name: release-tag
on:
push:
branches:
- dev
jobs:
release-image:
runs-on: ubuntu-latest
env:
DOCKER_LATEST: stable
CONTAINER_NAME: drb-client-discord-bot
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker BuildX
uses: docker/setup-buildx-action@v3
with: # replace it with your local IP
config-inline: |
[registry."git.vpn.cusano.net"]
http = false
insecure = false
- name: Login to DockerHub
uses: docker/login-action@v3
with:
registry: git.vpn.cusano.net # replace it with your local IP
username: ${{ secrets.GIT_REPO_USERNAME }}
password: ${{ secrets.GIT_REPO_PASSWORD }}
- name: Get Meta
id: meta
run: |
echo REPO_NAME=$(echo ${GITHUB_REPOSITORY} | awk -F"/" '{print $2}') >> $GITHUB_OUTPUT
echo REPO_VERSION=$(git describe --tags --always | sed 's/^v//') >> $GITHUB_OUTPUT
- name: Validate build configuration
uses: docker/build-push-action@v6
with:
call: check
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile
platforms: |
linux/arm64
push: true
tags: | # replace it with your local IP and tags
git.vpn.cusano.net/${{ vars.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}/${{ env.CONTAINER_NAME }}:${{ steps.meta.outputs.REPO_VERSION }}
git.vpn.cusano.net/${{ vars.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}/${{ env.CONTAINER_NAME }}:${{ env.DOCKER_LATEST }}
@@ -0,0 +1,60 @@
name: release-tag
on:
push:
branches:
- master
jobs:
release-image:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
env:
DOCKER_LATEST: stable
CONTAINER_NAME: op25-client
steps:
- name: Checkout
uses: actions/checkout@v5
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker BuildX
uses: docker/setup-buildx-action@v3
with:
config-inline: |
[registry."git.vpn.cusano.net"]
http = false
insecure = false
- name: Login to Gitea Container Registry
uses: docker/login-action@v3
with:
registry: git.vpn.cusano.net
username: ${{ gitea.actor }} # Uses the user or bot that triggered the workflow
password: ${{ secrets.GITHUB_COM_TOKEN }} # The built-in, temporary token
- name: Get Meta
id: meta
run: |
echo REPO_NAME=$(echo ${GITHUB_REPOSITORY} | awk -F"/" '{print $2}') >> $GITHUB_OUTPUT
echo REPO_VERSION=$(git describe --tags --always | sed 's/^v//') >> $GITHUB_OUTPUT
- name: Validate build configuration
uses: docker/build-push-action@v6
with:
call: check
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile
platforms: |
linux/arm64
push: true
tags: |
git.vpn.cusano.net/${{ vars.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}/${{ env.CONTAINER_NAME }}:${{ steps.meta.outputs.REPO_VERSION }}
git.vpn.cusano.net/${{ vars.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}/${{ env.CONTAINER_NAME }}:${{ env.DOCKER_LATEST }}
+30
View File
@@ -0,0 +1,30 @@
name: Lint
on:
push:
branches:
- master
pull_request:
branches:
- "*"
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.13'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install flake8
- name: Run Lint
run: |
flake8 --max-line-length=88 --ignore=E203,E302,E501 .
+6
View File
@@ -0,0 +1,6 @@
__pycache__*
bot-poc.py
configs*
.env
*.log*
.venv
+51
View File
@@ -0,0 +1,51 @@
## OP25 Core Container
FROM python:slim-trixie
# Set environment variables
ENV DEBIAN_FRONTEND=noninteractive
# Install system dependencies
RUN apt-get update && \
apt-get upgrade -y && \
apt-get install git pulseaudio pulseaudio-utils liquidsoap -y
# Clone the boatbod op25 repository
RUN git clone -b gr310 https://github.com/boatbod/op25 /op25
# Set the working directory
WORKDIR /op25
# Run the install script to set up op25
RUN sed -i 's/sudo //g' install.sh
RUN ./install.sh -f
# Install Python dependencies
COPY requirements.txt /tmp/requirements.txt
RUN pip3 install --no-cache-dir -r /tmp/requirements.txt
# Create the run_multi-rx_service.sh script
COPY run_multi-rx_service.sh /op25/op25/gr-op25_repeater/apps/run_multi-rx_service.sh
RUN chmod +x /op25/op25/gr-op25_repeater/apps/run_multi-rx_service.sh
# Expose ports for HTTP control as needed, for example:
EXPOSE 8001 8081
# Create and set up the configuration directory
VOLUME ["/configs"]
# Set the working directory in the container
WORKDIR /app
# Copy the rest of the directory contents into the container at /app
COPY ./app /app
# 1. Copy the wrapper script and make it executable
COPY docker-entrypoint.sh /usr/local/bin/
RUN sed -i 's/\r$//' /usr/local/bin/docker-entrypoint.sh && \
chmod +x /usr/local/bin/docker-entrypoint.sh
# 2. Update ENTRYPOINT to use the wrapper script
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
# 3. Use CMD to pass the uvicorn command as arguments to the ENTRYPOINT script
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8001", "--reload"]
@@ -0,0 +1,34 @@
import os
from internal.op25_liq_template import liquidsoap_config_template
from models import IcecastConfig
def generate_liquid_script(config: IcecastConfig):
"""
Generates the "*.liq" file that's run by OP25 on startup.
Placeholders in the template must be formatted as ${VARIABLE_NAME}.
Args:
config (dict): A dictionary of key-value pairs for substitution.
Keys should match the variable names in the template (e.g., 'icecast_host').
"""
try:
content = liquidsoap_config_template
# Replace variables
for key, value in config.model_dump().items():
placeholder = f"${{{key}}}"
# Ensure the value is converted to string for replacement
content = content.replace(placeholder, str(value))
print(f" - Replaced placeholder {placeholder}")
# Write the processed content to the output path
output_path = "/configs/op25.liq"
with open(output_path, 'w') as f:
f.write(content)
print(f"\nSuccessfully wrote processed configuration to: {output_path}")
except FileNotFoundError:
print(f"Error: Template file not found at {template_path}")
except Exception as e:
print(f"An unexpected error occurred: {e}")
+55
View File
@@ -0,0 +1,55 @@
import logging
from logging.handlers import RotatingFileHandler
def create_logger(name, level=logging.DEBUG, max_bytes=10485760, backup_count=2):
"""
Creates a logger with a console and rotating file handlers for both debug and info log levels.
Args:
name (str): The name for the logger.
level (int): The logging level for the logger. Defaults to logging.DEBUG.
max_bytes (int): Maximum size of the log file in bytes before it gets rotated. Defaults to 10 MB.
backup_count (int): Number of backup files to keep. Defaults to 2.
Returns:
logging.Logger: Configured logger.
"""
# Set the log file paths
debug_log_file = "./client.debug.log"
info_log_file = "./client.log"
# Create a logger
logger = logging.getLogger(name)
logger.setLevel(level)
# Check if the logger already has handlers to avoid duplicate logs
if not logger.hasHandlers():
# Create console handler
console_handler = logging.StreamHandler()
console_handler.setLevel(level)
# Create rotating file handler for debug level
debug_file_handler = RotatingFileHandler(debug_log_file, maxBytes=max_bytes, backupCount=backup_count)
debug_file_handler.setLevel(logging.DEBUG)
# Create rotating file handler for info level
info_file_handler = RotatingFileHandler(info_log_file, maxBytes=max_bytes, backupCount=backup_count)
info_file_handler.setLevel(logging.INFO)
# Create formatter and add it to the handlers
formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
console_handler.setFormatter(formatter)
debug_file_handler.setFormatter(formatter)
info_file_handler.setFormatter(formatter)
# Add the handlers to the logger
logger.addHandler(console_handler)
logger.addHandler(debug_file_handler)
logger.addHandler(info_file_handler)
return logger
# Example usage:
# logger = create_logger('my_logger')
# logger.debug('This is a debug message')
# logger.info('This is an info message')
@@ -0,0 +1,70 @@
import csv
import json
from models import TalkgroupTag
from typing import List
from internal.logger import create_logger
LOGGER = create_logger(__name__)
def save_talkgroup_tags(talkgroup_tags: List[TalkgroupTag]) -> None:
"""
Writes a list of tags to the tags file.
Args:
talkgroup_tags (List[TalkgroupTag]): The list of TalkgroupTag instances.
"""
with open("/configs/active.cfg.tags.tsv", 'w', newline='', encoding='utf-8') as file:
writer = csv.writer(file, delimiter='\t', lineterminator='\n')
# Write rows
for tag in talkgroup_tags:
writer.writerow([tag.tagDec, tag.talkgroup])
def save_whitelist(talkgroup_tags: List[int]) -> None:
"""
Writes a list of talkgroups to the whitelists file.
Args:
talkgroup_tags (List[int]): The list of decimals to whitelist.
"""
with open("/configs/active.cfg.whitelist.tsv", 'w', newline='', encoding='utf-8') as file:
writer = csv.writer(file, delimiter='\t', lineterminator='\n')
# Write rows
for tag in talkgroup_tags:
writer.writerow([tag])
def del_none_in_dict(d):
"""
Delete keys with the value ``None`` in a dictionary, recursively.
This alters the input so you may wish to ``copy`` the dict first.
"""
for key, value in list(d.items()):
LOGGER.info(f"Key: '{key}'\nValue: '{value}'")
if value is None:
del d[key]
elif isinstance(value, dict):
del_none_in_dict(value)
elif isinstance(value, list):
for iterative_value in value:
del_none_in_dict(iterative_value)
return d # For convenience
def get_current_system_from_config() -> str:
# Get the current config
with open('/configs/active.cfg.json', 'r') as f:
json_data = f.read()
if isinstance(json_data, str):
try:
data = json.loads(json_data)
except json.JSONDecodeError:
return None
elif isinstance(json_data, dict):
data = json_data
else:
return None
if "channels" in data and isinstance(data["channels"], list) and len(data["channels"]) > 0:
first_channel = data["channels"][0]
if "name" in first_channel:
return first_channel["name"]
return None
@@ -0,0 +1,44 @@
liquidsoap_config_template = """#!/usr/bin/liquidsoap
# OP25 → Icecast streaming (Liquidsoap 2.x)
settings.log.stdout.set(true)
settings.log.file.set(false)
settings.log.level.set(1)
settings.frame.audio.samplerate.set(8000)
settings.init.allow_root.set(true)
# ==========================================================
ICE_HOST = "${icecast_host}"
ICE_PORT = ${icecast_port}
ICE_MOUNT = "${icecast_mountpoint}"
ICE_PASSWORD = "${icecast_password}"
ICE_DESCRIPTION = "${icecast_description}"
ICE_GENRE = "${icecast_genre}"
# ==========================================================
input = mksafe(input.external(buffer=0.25, channels=2, samplerate=8000, restart_on_error=false, "./audio.py -x 2.5 -s"))
# Consider increasing the buffer value on slow systems such as RPi3. e.g. buffer=0.25
# Compression
input = compress(input, attack = 2.0, gain = 0.0, knee = 13.0, ratio = 2.0, release = 12.3, threshold = -18.0)
# Normalization
input = normalize(input, gain_max = 6.0, gain_min = -6.0, target = -16.0, threshold = -65.0)
# ==========================================================
# OUTPUT: Referencing the new variables
# ==========================================================
output.icecast(
%mp3(bitrate=16, samplerate=22050, stereo=false),
description=ICE_DESCRIPTION,
genre=ICE_GENRE,
url="",
fallible=false,
host=ICE_HOST,
port=ICE_PORT,
mount=ICE_MOUNT,
password=ICE_PASSWORD,
mean(input)
)
"""
+11
View File
@@ -0,0 +1,11 @@
from fastapi import FastAPI
import routers.op25_controller as op25_controller
from internal.logger import create_logger
# Initialize logging
LOGGER = create_logger(__name__)
# Define FastAPI app
app = FastAPI()
app.include_router(op25_controller.create_op25_router(), prefix="/op25")
+111
View File
@@ -0,0 +1,111 @@
from pydantic import BaseModel
from typing import List, Optional, Union
from enum import Enum
class DecodeMode(str, Enum):
P25 = "P25"
DMR = "DMR"
ANALOG = "NBFM"
class TalkgroupTag(BaseModel):
talkgroup: str
tagDec: int
class ConfigGenerator(BaseModel):
type: DecodeMode
systemName: str
channels: List[Union[int, str]]
tags: Optional[List[TalkgroupTag]]
whitelist: Optional[List[int]]
icecastConfig: Optional[IcecastConfig]
class DemodType(str, Enum):
CQPSK = "cqpsk"
FSK4 = "fsk4"
class FilterType(str, Enum):
RC = "rc"
WIDEPULSE = "widepulse"
class ChannelConfig(BaseModel):
name: str
trunking_sysname: Optional[str]
enable_analog: str
meta_stream_name: str
demod_type: DemodType
filter_type: FilterType
device: Optional[str] = "sdr"
cqpsk_tracking: Optional[bool] = None
frequency: Optional[float] = None
nbfmSquelch: Optional[float] = None
destination: Optional[str] = "udp://127.0.0.1:23456"
tracking_threshold: Optional[int] = 120
tracking_feedback: Optional[float] = 0.75
excess_bw: Optional[float] = 0.2
if_rate: Optional[int] = 24000
plot: Optional[str] = ""
symbol_rate: Optional[int] = 4800
blacklist: Optional[str] = ""
whitelist: Optional[str] = ""
class DeviceConfig(BaseModel):
args: Optional[str] = "rtl"
gains: Optional[str] = "lna:39"
gain_mode: Optional[bool] = False
name: Optional[str] = "sdr"
offset: Optional[int] = 0
ppm: Optional[float] = 0.0
rate: Optional[int] = 1920000
usable_bw_pct: Optional[float] = 0.85
tunable: Optional[bool] = True
class TrunkingChannelConfig(BaseModel):
sysname: str
control_channel_list: str
tagsFile: Optional[str] = None
whitelist: Optional[str] = None
nac: Optional[str] = ""
wacn: Optional[str] = ""
tdma_cc: Optional[bool] = False
crypt_behavior: Optional[int] = 2
class TrunkingConfig(BaseModel):
module: str
chans: List[TrunkingChannelConfig]
class MetadataStreamConfig(BaseModel):
stream_name: str = "stream_0"
meta_format_idle: str = "[idle]"
meta_format_tgid: str = "[%TGID%]"
meta_format_tag: str = "[%TGID%] %TAG%"
icecastServerAddress: str = "localhost"
icecastMountpoint: str = "NODE_ID"
icecastMountExt: str = ".xspf"
icecastPass: str = "PASSWORD"
delay: float = 0.0
class MetadataConfig(BaseModel):
module: str = "icemeta.py"
streams: List[MetadataStreamConfig]
class TerminalConfig(BaseModel):
module: Optional[str] = "terminal.py"
terminal_type: Optional[str] = "http:0.0.0.0:8081"
terminal_timeout: Optional[float] = 5.0
curses_plot_interval: Optional[float] = 0.2
http_plot_interval: Optional[float] = 1.0
http_plot_directory: Optional[str] = "../www/images"
tuning_step_large: Optional[int] = 1200
tuning_step_small: Optional[int] = 100
### ======================================================
# Icecast models
class IcecastConfig(BaseModel):
icecast_host: str
icecast_port: int
icecast_mountpoint: str
icecast_password: str
icecast_description: Optional[str] = "OP25"
icecast_genre: Optional[str] = "Public Safety"
@@ -0,0 +1,128 @@
from fastapi import HTTPException, APIRouter
import subprocess
import os
import signal
import json
from models import ConfigGenerator, DecodeMode, ChannelConfig, DeviceConfig, TrunkingConfig, TrunkingChannelConfig, TerminalConfig, MetadataConfig, MetadataStreamConfig
from internal.logger import create_logger
from internal.op25_config_utls import save_talkgroup_tags, save_whitelist, del_none_in_dict, get_current_system_from_config
from internal.liquidsoap_config_utils import generate_liquid_script
LOGGER = create_logger(__name__)
op25_process = None
OP25_PATH = "/op25/op25/gr-op25_repeater/apps/"
OP25_SCRIPT = "run_multi-rx_service.sh"
def create_op25_router():
router = APIRouter()
@router.post("/start")
async def start_op25():
global op25_process
if op25_process is None:
try:
op25_process = subprocess.Popen(os.path.join(OP25_PATH, OP25_SCRIPT), shell=True, preexec_fn=os.setsid, cwd=OP25_PATH)
LOGGER.debug(op25_process)
return {"status": "OP25 started"}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
else:
return {"status": "OP25 already running"}
@router.post("/stop")
async def stop_op25():
global op25_process
if op25_process is not None:
try:
os.killpg(os.getpgid(op25_process.pid), signal.SIGTERM)
op25_process = None
return {"status": "OP25 stopped"}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
else:
return {"status": "OP25 is not running"}
@router.get("/status")
async def get_status():
return {"status": "running" if op25_process else "stopped"}
@router.post("/generate-config")
async def generate_config(generator: ConfigGenerator):
try:
if generator.type == DecodeMode.P25:
channels = [ChannelConfig(
name=generator.systemName,
trunking_sysname=generator.systemName,
enable_analog="off",
demod_type="cqpsk",
cqpsk_tracking=True,
filter_type="rc",
meta_stream_name="stream_0"
)]
devices = [DeviceConfig()]
save_talkgroup_tags(generator.tags)
save_whitelist(generator.whitelist)
trunking = TrunkingConfig(
module="tk_p25.py",
chans=[TrunkingChannelConfig(
sysname=generator.systemName,
control_channel_list=','.join(generator.channels),
tagsFile="/configs/active.cfg.tags.tsv",
whitelist="/configs/active.cfg.whitelist.tsv"
)]
)
metadata = MetadataConfig(
streams=[
MetadataStreamConfig(
stream_name="stream_0",
icecastServerAddress = f"{generator.icecastConfig.icecast_host}:{generator.icecastConfig.icecast_port}",
icecastMountpoint = generator.icecastConfig.icecast_mountpoint,
icecastPass = generator.icecastConfig.icecast_password
)
]
)
# Generate the op25.liq file
generate_liquid_script(generator.icecastConfig)
terminal = TerminalConfig()
config_dict = {
"channels": [channel.dict() for channel in channels],
"devices": [device.dict() for device in devices],
"trunking": trunking.dict(),
"metadata": metadata.dict(),
"terminal": terminal.dict()
}
elif generator.type == DecodeMode.ANALOG:
generator = generator.config
channels = [ChannelConfig(
channelName=generator.systemName,
enableAnalog="on",
demodType="fsk4",
frequency=generator.frequency,
filterType="widepulse",
nbfmSquelch=generator.nbfmSquelch
)]
devices = [DeviceConfig(gain="LNA:32")]
config_dict = {
"channels": [channel.dict() for channel in channels],
"devices": [device.dict() for device in devices]
}
else:
raise HTTPException(status_code=400, detail="Invalid configuration type. Must be 'p25' or 'nbfm'.")
with open('/configs/active.cfg.json', 'w') as f:
json.dump(del_none_in_dict(config_dict), f, indent=2)
return {"message": "Config exported to '/configs/active.cfg.json'"}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
return router
+15
View File
@@ -0,0 +1,15 @@
#!/bin/bash
# --- Start PulseAudio Daemon ---
# The -D flag starts it as a daemon.
# The --exit-idle-time=-1 prevents it from automatically shutting down.
echo "Starting PulseAudio daemon..."
pulseaudio -D --exit-idle-time=-1 --system
# Wait a moment for PulseAudio to initialize
sleep 1
# --- Execute the main command (uvicorn) ---
echo "Starting FastAPI application..."
# The main application arguments are passed directly to this script
exec "$@"
+2
View File
@@ -0,0 +1,2 @@
uvicorn
fastapi
+22
View File
@@ -0,0 +1,22 @@
#!/bin/bash
# Configuration file path
CONFIG_FILE="/configs/active.cfg.json"
# --- Start the main OP25 receiver (multi_rx.py) in the background ---
# The '&' sends the process to the background.
echo "Starting multi_rx.py..."
./multi_rx.py -v 1 -c $CONFIG_FILE &
MULTI_RX_PID=$! # Store the PID of the background process
# --- Start the liquid-dsp plot utility (op25.liq) in the background ---
echo "Starting op25.liq..."
liquidsoap /configs/op25.liq &
LIQ_PID=$! # Store the PID of the op25.liq process
# Wait for both background jobs to finish.
# Since multi_rx.py is the core service, this script will effectively wait
# until multi_rx.py is externally stopped (via the API).
# The trap command ensures that SIGTERM is passed to the background jobs.
trap "kill $MULTI_RX_PID $LIQ_PID" SIGTERM SIGINT
wait $MULTI_RX_PID