25 Commits

Author SHA1 Message Date
Logan Cusano
6324f82789 Update the OP25 generator model to take either a string or an int
Some checks failed
release-tag / release-image (push) Successful in 1h14m31s
Lint / lint (push) Failing after 12s
2025-06-22 23:25:13 -04:00
Logan Cusano
3086da0e2b Update docker to use supervisor
Some checks failed
Lint / lint (push) Failing after 6s
release-tag / release-image (push) Has been cancelled
2025-05-29 01:02:07 -04:00
Logan Cusano
021f27d62e Add custom liquid and service files and updated docker
Some checks failed
release-tag / release-image (push) Successful in 1h14m39s
Lint / lint (push) Failing after 7s
2025-05-28 23:43:57 -04:00
Logan Cusano
d3e7e780f3 Update docker to include liquidsoap
Some checks failed
release-tag / release-image (push) Failing after 1m54s
Lint / lint (push) Failing after 32s
2025-05-28 23:28:56 -04:00
Logan Cusano
872bbf2965 Ignore reconnection logic when disconnecting
Some checks failed
release-tag / release-image (push) Successful in 1h14m15s
Lint / lint (push) Failing after 10s
2025-05-02 22:18:16 -04:00
Logan Cusano
e7956577d7 Replace old classes
Some checks failed
Lint / lint (push) Waiting to run
release-tag / release-image (push) Has been cancelled
2025-05-02 21:59:20 -04:00
Logan Cusano
c31984e2d8 Remove channel ID req from leave channel as it's not used
Some checks failed
Lint / lint (push) Waiting to run
release-tag / release-image (push) Has been cancelled
2025-05-02 21:57:28 -04:00
Logan Cusano
5191433e5d Add wait to joining voice
Some checks failed
release-tag / release-image (push) Successful in 1h14m57s
Lint / lint (push) Failing after 12s
2025-04-27 03:29:06 -04:00
Logan Cusano
9dfee88789 Add missed param
Some checks failed
Lint / lint (push) Waiting to run
release-tag / release-image (push) Has been cancelled
2025-04-27 03:18:50 -04:00
Logan Cusano
46ec27c359 Update bot manager to respond to start bot when bot is ready
Some checks failed
Lint / lint (push) Waiting to run
release-tag / release-image (push) Has been cancelled
2025-04-27 03:15:15 -04:00
Logan Cusano
75b2d0007d Add deploy option for make
Some checks failed
release-tag / release-image (push) Failing after 1m41s
Lint / lint (push) Successful in 20s
2025-03-08 20:17:04 -05:00
c616acd6af update gitignore
Some checks failed
release-tag / release-image (push) Failing after 1m49s
Lint / lint (push) Successful in 23s
2025-03-08 18:44:33 -05:00
Logan Cusano
7e44e0e803 Typo in build
Some checks failed
release-tag / release-image (push) Failing after 1m38s
Lint / lint (push) Successful in 19s
2025-03-04 22:04:36 -05:00
2418ac2701 Merge pull request 'Improving reconnection logic' (#2) from fix-disconnection-bug into master
Some checks failed
release-tag / release-image (push) Failing after 46s
Lint / lint (push) Successful in 19s
Reviewed-on: #2
2025-03-04 22:02:32 -05:00
f9d30b0c8b Linting
All checks were successful
Lint / lint (pull_request) Successful in 19s
2025-03-04 22:00:02 -05:00
a5ff9fa1be Merge branch 'master' into fix-disconnection-bug
Some checks failed
Lint / lint (pull_request) Failing after 18s
2025-03-04 21:47:41 -05:00
5b3f9bfaba Fix dockerfile and bot_manager
Some checks failed
Lint / lint (pull_request) Failing after 19s
2025-03-04 21:45:57 -05:00
Logan Cusano
9f10914b8b Remove extra container
Some checks failed
Lint / lint (push) Successful in 1m6s
release-tag / release-image (push) Failing after 50s
2025-03-01 01:56:28 -05:00
Logan Cusano
2c3c372da1 Potential Workaround dns issue
Some checks failed
Lint / lint (push) Failing after 20s
release-tag / release-image (push) Failing after 20s
2025-03-01 01:36:08 -05:00
71af2c83d9 Merge remote-tracking branch 'origin/master' into fix-disconnection-bug
Some checks failed
Lint / lint (pull_request) Failing after 20s
2025-03-01 01:31:29 -05:00
59ee866ac9 Refactored to better split everything up 2025-03-01 01:31:17 -05:00
Logan Cusano
50b11efd80 Retry build with new buildx release and dockerfile changes
Some checks failed
Lint / lint (push) Failing after 20s
release-tag / release-image (push) Failing after 20s
2025-03-01 01:02:53 -05:00
Logan Cusano
f1de077b72 Merge remote-tracking branch 'origin/master' into fix-disconnection-bug
All checks were successful
Lint / lint (pull_request) Successful in 38s
2025-02-23 21:42:54 -05:00
Logan Cusano
7362715a21 Uniform apt-get
All checks were successful
Lint / lint (push) Successful in 32s
2025-02-23 21:33:06 -05:00
Logan Cusano
74e66eb796 Linting
All checks were successful
Lint / lint (pull_request) Successful in 37s
2025-02-23 20:37:40 -05:00
19 changed files with 517 additions and 279 deletions

View File

@@ -8,8 +8,6 @@ on:
jobs: jobs:
release-image: release-image:
runs-on: ubuntu-latest runs-on: ubuntu-latest
container:
image: catthehacker/ubuntu:act-latest
env: env:
DOCKER_LATEST: nightly DOCKER_LATEST: nightly
CONTAINER_NAME: drb-client-discord-bot CONTAINER_NAME: drb-client-discord-bot
@@ -32,8 +30,8 @@ jobs:
uses: docker/login-action@v3 uses: docker/login-action@v3
with: with:
registry: git.vpn.cusano.net # replace it with your local IP registry: git.vpn.cusano.net # replace it with your local IP
username: ${{ secrets.DOCKER_USERNAME }} username: ${{ secrets.GIT_REPO_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }} password: ${{ secrets.GIT_REPO_PASSWORD }}
- name: Get Meta - name: Get Meta
id: meta id: meta
@@ -52,7 +50,7 @@ jobs:
context: . context: .
file: ./Dockerfile file: ./Dockerfile
platforms: | platforms: |
linux/amd64 linux/arm64
push: true push: true
tags: | # replace it with your local IP and tags 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 }}:${{ steps.meta.outputs.REPO_VERSION }}

1
.gitignore vendored
View File

@@ -2,3 +2,4 @@ __pycache__*
bot-poc.py bot-poc.py
configs* configs*
.env .env
*.log*

View File

@@ -5,9 +5,9 @@ FROM ubuntu:22.04
ENV DEBIAN_FRONTEND=noninteractive ENV DEBIAN_FRONTEND=noninteractive
# Install system dependencies # Install system dependencies
RUN apt update && \ RUN apt-get update && \
apt-get install -y --no-install-recommends libc-bin apt-transport-https && \ apt-get install -y --no-install-recommends libc-bin apt-transport-https tzdata && \
apt install -y --no-install-recommends git \ apt-get install -y --no-install-recommends git \
curl \ curl \
python3 \ python3 \
python3-pip \ python3-pip \
@@ -26,7 +26,9 @@ RUN apt update && \
libportaudio2 \ libportaudio2 \
libpulse-dev \ libpulse-dev \
apulse \ apulse \
ffmpeg ffmpeg \
liquidsoap \
supervisor
# Clone the boatbod op25 repository # Clone the boatbod op25 repository
RUN git clone -b gr310 https://github.com/boatbod/op25 /op25 RUN git clone -b gr310 https://github.com/boatbod/op25 /op25
@@ -37,6 +39,9 @@ WORKDIR /op25
# Run the install script to set up op25 # Run the install script to set up op25
RUN ./install.sh -f RUN ./install.sh -f
# Update the liquid file
COPY op25.liq /op25/op25.liq
# Install Python dependencies # Install Python dependencies
COPY requirements.txt /tmp/requirements.txt COPY requirements.txt /tmp/requirements.txt
RUN pip3 install --no-cache-dir -r /tmp/requirements.txt RUN pip3 install --no-cache-dir -r /tmp/requirements.txt
@@ -60,11 +65,14 @@ VOLUME ["/configs"]
# Set the working directory in the container # Set the working directory in the container
WORKDIR /app WORKDIR /app
# Copy opus first to break up the build time
COPY ./app/internal/opus /app/internal/opus
# Copy the rest of the directory contents into the container at /app # Copy the rest of the directory contents into the container at /app
COPY ./app /app COPY ./app /app
# Copy the pre-built opus libraries # Add Supervisord configuration
COPY ./opus /app/opus COPY supervisord.conf /etc/supervisor/conf.d/supervisord.conf
# Run the node script # Modify the ENTRYPOINT to run Supervisord
ENTRYPOINT ["uvicorn", "bot:app", "--host", "0.0.0.0", "--port", "8001", "--reload"] ENTRYPOINT ["/usr/bin/supervisord", "-n", "-c", "/etc/supervisor/conf.d/supervisord.conf"]

View File

@@ -23,6 +23,16 @@ run: build
--network=host \ --network=host \
$(IMAGE_NAME) $(IMAGE_NAME)
# Deploy docker
deploy: build
docker run --rm -d \
--privileged \
-v /dev:/dev \
-v $(shell pwd)/configs:/configs \
--name $(CONTAINER_NAME) \
--network=host \
$(IMAGE_NAME)
# Stop the Docker container # Stop the Docker container
stop: stop:
docker stop $(CONTAINER_NAME) docker stop $(CONTAINER_NAME)

View File

@@ -1,168 +0,0 @@
import asyncio
import logging
import discord
from discord.ext import commands
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
from typing import Optional, Dict
from NoiseGatev2 import NoiseGate
import op25_controller
import pulse
# Initialize logging
logging.basicConfig(level=logging.INFO)
LOGGER = logging.getLogger(__name__)
# Define FastAPI app
app = FastAPI()
intents = discord.Intents.default()
intents.voice_states = True
intents.guilds = True
class BotConfig(BaseModel):
token: str
class VoiceChannelRequest(BaseModel):
guild_id: int
channel_id: int
class DiscordBotManager:
def __init__(self):
self.bot: Optional[commands.Bot] = None
self.bot_task: Optional[asyncio.Task] = None
self.voice_clients: Dict[int, discord.VoiceClient] = {}
self.token: Optional[str] = None
self.loop = asyncio.get_event_loop()
self.lock = asyncio.Lock()
async def start_bot(self, token: str):
async with self.lock:
if self.bot and not self.bot.is_closed():
raise RuntimeError("Bot is already running.")
if self.bot_task and not self.bot_task.done():
raise RuntimeError("Bot is already running.")
self.token = token
self.bot = commands.Bot(command_prefix="!", intents=intents)
@self.bot.event
async def on_ready():
LOGGER.info(f'Logged in as {self.bot.user}')
@self.bot.event
async def on_voice_state_update(member, before, after):
# Check if the bot was disconnected
if member == self.bot.user and after.channel is None:
guild_id = before.channel.guild.id
LOGGER.info(f"Bot was disconnected from channel in guild {guild_id}. Attempting to reconnect...")
try:
leave_voice_channel(guild_id)
except Exception as e:
LOGGER.warning(f"Error to leave voice channel", e)
# Attempt to reconnect to the channel after a brief pause
await asyncio.sleep(2)
await self.join_voice_channel(guild_id, before.channel.id)
self.bot_task = self.loop.create_task(self.bot.start(token))
async def stop_bot(self):
async with self.lock:
if self.bot:
await self.bot.close()
self.bot = None
if self.bot_task:
await self.bot_task
self.bot_task = None
self.voice_clients.clear()
LOGGER.info("Bot has been stopped.")
async def join_voice_channel(self, guild_id: int, channel_id: int, ng_threshold: int = 50, device_id: int = 4):
if not self.bot:
raise RuntimeError("Bot is not running.")
guild = self.bot.get_guild(guild_id)
if not guild:
raise ValueError("Guild not found.")
channel = guild.get_channel(channel_id)
if not isinstance(channel, discord.VoiceChannel):
raise ValueError("Channel is not a voice channel.")
if guild_id in self.voice_clients:
raise RuntimeError("Already connected to this guild's voice channel.")
try:
voice_client = await channel.connect(timeout=60.0, reconnect=True)
streamHandler = NoiseGate(
_input_device_index=device_id,
_voice_connection=voice_client,
_noise_gate_threshold=ng_threshold)
streamHandler.run()
self.voice_clients[guild_id] = voice_client
LOGGER.info(f"Joined guild {guild_id} voice channel {channel_id}.")
except Exception as e:
LOGGER.error(f"Failed to connect to voice channel: {e}")
async def leave_voice_channel(self, guild_id: int):
if not self.bot:
raise RuntimeError("Bot is not running.")
voice_client = self.voice_clients.get(guild_id)
if not voice_client:
raise RuntimeError("Not connected to the specified guild's voice channel.")
await voice_client.disconnect()
del self.voice_clients[guild_id]
LOGGER.info(f"Left guild {guild_id} voice channel.")
# Initialize Discord Bot Manager
bot_manager = DiscordBotManager()
# API Endpoints
@app.post("/start_bot")
async def start_bot(config: BotConfig):
try:
await bot_manager.start_bot(config.token)
return {"status": "Bot started successfully."}
except Exception as e:
LOGGER.error(f"Error starting bot: {e}")
raise HTTPException(status_code=400, detail=str(e))
@app.post("/stop_bot")
async def stop_bot():
try:
await bot_manager.stop_bot()
return {"status": "Bot stopped successfully."}
except Exception as e:
LOGGER.error(f"Error stopping bot: {e}")
raise HTTPException(status_code=400, detail=str(e))
@app.post("/join_voice")
async def join_voice_channel(request: VoiceChannelRequest):
try:
await bot_manager.join_voice_channel(request.guild_id, request.channel_id)
return {"status": f"Joined guild {request.guild_id} voice channel {request.channel_id}."}
except Exception as e:
LOGGER.error(f"Error joining voice channel: {e}")
raise HTTPException(status_code=400, detail=str(e))
@app.post("/leave_voice")
async def leave_voice_channel(request: VoiceChannelRequest):
try:
await bot_manager.leave_voice_channel(request.guild_id)
return {"status": f"Left guild {request.guild_id} voice channel."}
except Exception as e:
LOGGER.error(f"Error leaving voice channel: {e}")
raise HTTPException(status_code=400, detail=str(e))
@app.get("/status")
async def get_status():
status = {
"bot_running": bot_manager.bot is not None and not bot_manager.bot.is_closed(),
"connected_guilds": list(bot_manager.voice_clients.keys())
}
return status
app.include_router(op25_controller.router, prefix="/op25")
app.include_router(pulse.router, prefix="/pulse")

View File

@@ -1,15 +1,15 @@
import audioop import audioop
import logging
import math import math
import time import time
import pyaudio import pyaudio
import discord import discord
import numpy import numpy
from internal.logger import create_logger
voice_connection = None voice_connection = None
LOGGER = logging.getLogger("Discord_Radio_Bot.NoiseGateV2") LOGGER = create_logger(__name__)
# noinspection PyUnresolvedReferences # noinspection PyUnresolvedReferences

173
app/internal/bot_manager.py Normal file
View File

@@ -0,0 +1,173 @@
import asyncio
import platform
import os
from discord import VoiceClient, VoiceChannel, opus, Activity, ActivityType, Intents
from discord.ext import commands
from typing import Optional, Dict
from internal.NoiseGatev2 import NoiseGate
from internal.logger import create_logger
LOGGER = create_logger(__name__)
# Configure discord intents
intents = Intents.default()
intents.voice_states = True
intents.guilds = True
class DiscordBotManager:
def __init__(self):
self.bot: Optional[commands.Bot] = None
self.bot_task: Optional[asyncio.Task] = None
self.voice_clients: Dict[int, VoiceClient] = {}
self.token: Optional[str] = None
self.loop = asyncio.get_event_loop()
self.lock = asyncio.Lock()
self._ready_event = asyncio.Event()
self._voice_ready_event = asyncio.Event()
async def start_bot(self, token: str):
async with self.lock:
if self.bot and not self.bot.is_closed():
raise RuntimeError("Bot is already running.")
if self.bot_task and not self.bot_task.done():
raise RuntimeError("Bot is already running.")
self.token = token
self.bot = commands.Bot(command_prefix="!", intents=intents)
@self.bot.event
async def on_ready():
LOGGER.info(f'Logged in as {self.bot.user}')
# Set the event when on_ready is called
self._ready_event.set()
@self.bot.event
async def on_voice_state_update(member, before, after):
# Check if the bot was disconnected
if member == self.bot.user and after.channel is None:
guild_id = before.channel.guild.id
if not self.voice_clients.get(guild_id):
LOGGER.info("Bot has left channel, reconnection ignored.")
return
LOGGER.info(f"Bot was disconnected from channel in guild {guild_id}. Attempting to reconnect...")
try:
await self.leave_voice_channel(guild_id)
except Exception as e:
LOGGER.warning(f"Error leaving voice channel: '{e}'")
# Attempt to reconnect to the channel after a brief pause
await asyncio.sleep(2)
await self.join_voice_channel(guild_id, before.channel.id)
if member == self.bot.user and before.channel is None and after.channel is not None:
print(f"{member.name} joined voice channel {after.channel.name}")
self._voice_ready_event.set()
# Load Opus for the current CPU
await self.load_opus()
# Create the task to run the bot in the background
self.bot_task = self.loop.create_task(self.bot.start(token))
# Wait for the on_ready event to be set by the bot task
LOGGER.info("Waiting for bot to become ready...")
try:
await asyncio.wait_for(self._ready_event.wait(), timeout=60.0)
LOGGER.info("Bot is ready, start_bot returning.")
return
except asyncio.TimeoutError:
LOGGER.error("Timeout waiting for bot to become ready. Bot might have failed to start.")
if self.bot_task and not self.bot_task.done():
self.bot_task.cancel()
raise RuntimeError("Bot failed to become ready within timeout.")
async def stop_bot(self):
async with self.lock:
if self.bot:
await self.bot.close()
self.bot = None
if self.bot_task:
await self.bot_task
self.bot_task = None
self.voice_clients.clear()
self._ready_event.clear()
LOGGER.info("Bot has been stopped.")
async def join_voice_channel(self, guild_id: int, channel_id: int, ng_threshold: int = 50, device_id: int = 4):
if not self.bot:
raise RuntimeError("Bot is not running.")
guild = self.bot.get_guild(guild_id)
if not guild:
raise ValueError("Guild not found.")
if not opus.is_loaded():
raise RuntimeError("Opus is not loaded.")
channel = guild.get_channel(channel_id)
if not isinstance(channel, VoiceChannel):
raise ValueError("Channel is not a voice channel.")
if guild_id in self.voice_clients:
raise RuntimeError("Already connected to this guild's voice channel.")
try:
voice_client = await channel.connect(timeout=60.0, reconnect=True)
LOGGER.debug("Voice Connected.")
streamHandler = NoiseGate(
_input_device_index=device_id,
_voice_connection=voice_client,
_noise_gate_threshold=ng_threshold)
streamHandler.run()
LOGGER.debug("Stream is running.")
self.voice_clients[guild_id] = voice_client
LOGGER.info(f"Joined guild {guild_id} voice channel {channel_id} and stream is running.")
except Exception as e:
LOGGER.error(f"Failed to connect to voice channel: {e}")
LOGGER.info("Waiting for bot to join voice...")
try:
await asyncio.wait_for(self._voice_ready_event.wait(), timeout=60.0)
LOGGER.info("Bot joined voice, returning.")
return
except asyncio.TimeoutError:
LOGGER.error("Timeout waiting for bot to join voice.")
raise RuntimeError("Bot failed to join voice within timeout.")
async def leave_voice_channel(self, guild_id: int):
if not self.bot:
raise RuntimeError("Bot is not running.")
voice_client = self.voice_clients.get(guild_id)
if not voice_client:
raise RuntimeError("Not connected to the specified guild's voice channel.")
await voice_client.disconnect()
del self.voice_clients[guild_id]
LOGGER.info(f"Left guild {guild_id} voice channel.")
async def load_opus(self):
""" Load the proper OPUS library for the device being used """
processor = platform.machine()
script_dir = os.path.dirname(os.path.abspath(__file__))
LOGGER.debug("Processor: ", processor)
if os.name == 'nt':
if processor == "AMD64":
opus.load_opus(os.path.join(script_dir, './opus/libopus_amd64.dll'))
LOGGER.info("Loaded OPUS library for AMD64")
return "AMD64"
else:
if processor == "aarch64":
opus.load_opus(os.path.join(script_dir, './opus/libopus_aarcch64.so'))
LOGGER.info("Loaded OPUS library for aarch64")
return "aarch64"
elif processor == "armv7l":
opus.load_opus(os.path.join(script_dir, './opus/libopus_armv7l.so'))
LOGGER.info("Loaded OPUS library for armv7l")
return "armv7l"
async def set_presence(self, presence: str):
""" Set the presense (activity) of the bot """
try:
await self.bot.change_presence(activity=Activity(type=ActivityType.listening, name=presence))
except Exception as pe:
LOGGER.error(f"Unable to set presence: '{pe}'")

55
app/internal/logger.py Normal file
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')

15
app/main.py Normal file
View File

@@ -0,0 +1,15 @@
from fastapi import FastAPI
import routers.op25_controller as op25_controller
import routers.pulse as pulse
import routers.bot as bot
from internal.logger import create_logger
# Initialize logging
LOGGER = create_logger(__name__)
# Define FastAPI app
app = FastAPI()
app.include_router(op25_controller.router, prefix="/op25")
app.include_router(pulse.router, prefix="/pulse")
app.include_router(bot.router, prefix="/bot")

104
app/models.py Normal file
View File

@@ -0,0 +1,104 @@
from pydantic import BaseModel
from typing import List, Optional, Union
from enum import Enum
class BotConfig(BaseModel):
token: str
class VoiceChannelJoinRequest(BaseModel):
guild_id: int
channel_id: int
class VoiceChannelLeaveRequest(BaseModel):
guild_id: int
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]]
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
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 AudioInstanceConfig(BaseModel):
instance_name: Optional[str] = "audio0"
device_name: Optional[str] = "pulse"
udp_port: Optional[int] = 23456
audio_gain: Optional[float] = 2.5
number_channels: Optional[int] = 1
class AudioConfig(BaseModel):
module: Optional[str] = "sockaudio.py"
instances: Optional[List[AudioInstanceConfig]] = [AudioInstanceConfig()]
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

57
app/routers/bot.py Normal file
View File

@@ -0,0 +1,57 @@
from fastapi import APIRouter, HTTPException
from models import BotConfig, VoiceChannelJoinRequest, VoiceChannelLeaveRequest
from internal.bot_manager import DiscordBotManager
from internal.logger import create_logger
LOGGER = create_logger(__name__)
# Define FastAPI app
router = APIRouter()
# Initialize Discord Bot Manager
bot_manager = DiscordBotManager()
# API Endpoints
@router.post("/start_bot")
async def start_bot(config: BotConfig):
try:
await bot_manager.start_bot(config.token)
return {"status": "Bot started successfully."}
except Exception as e:
LOGGER.error(f"Error starting bot: {e}")
raise HTTPException(status_code=400, detail=str(e))
@router.post("/stop_bot")
async def stop_bot():
try:
await bot_manager.stop_bot()
return {"status": "Bot stopped successfully."}
except Exception as e:
LOGGER.error(f"Error stopping bot: {e}")
raise HTTPException(status_code=400, detail=str(e))
@router.post("/join_voice")
async def join_voice_channel(request: VoiceChannelJoinRequest):
try:
await bot_manager.join_voice_channel(request.guild_id, request.channel_id)
return {"status": f"Joined guild {request.guild_id} voice channel {request.channel_id}."}
except Exception as e:
LOGGER.error(f"Error joining voice channel: {e}")
raise HTTPException(status_code=400, detail=str(e))
@router.post("/leave_voice")
async def leave_voice_channel(request: VoiceChannelLeaveRequest):
try:
await bot_manager.leave_voice_channel(request.guild_id)
return {"status": f"Left guild {request.guild_id} voice channel."}
except Exception as e:
LOGGER.error(f"Error leaving voice channel: {e}")
raise HTTPException(status_code=400, detail=str(e))
@router.get("/status")
async def get_status():
status = {
"bot_running": bot_manager.bot is not None and not bot_manager.bot.is_closed(),
"connected_guilds": list(bot_manager.voice_clients.keys())
}
return status

View File

@@ -1,14 +1,15 @@
from fastapi import HTTPException, APIRouter from fastapi import HTTPException, APIRouter
from pydantic import BaseModel
from enum import Enum
import subprocess import subprocess
import os import os
import signal import signal
import json import json
import csv import csv
from typing import List, Optional from models import ConfigGenerator, DecodeMode, ChannelConfig, DeviceConfig, TrunkingConfig, TrunkingChannelConfig, AudioConfig, TerminalConfig, TalkgroupTag
from internal.logger import create_logger
from typing import List
router = APIRouter() router = APIRouter()
LOGGER = create_logger(__name__)
op25_process = None op25_process = None
OP25_PATH = "/op25/op25/gr-op25_repeater/apps/" OP25_PATH = "/op25/op25/gr-op25_repeater/apps/"
@@ -20,7 +21,7 @@ async def start_op25():
if op25_process is None: if op25_process is None:
try: try:
op25_process = subprocess.Popen(os.path.join(OP25_PATH, OP25_SCRIPT), shell=True, preexec_fn=os.setsid, cwd=OP25_PATH) op25_process = subprocess.Popen(os.path.join(OP25_PATH, OP25_SCRIPT), shell=True, preexec_fn=os.setsid, cwd=OP25_PATH)
print(op25_process) LOGGER.debug(op25_process)
return {"status": "OP25 started"} return {"status": "OP25 started"}
except Exception as e: except Exception as e:
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@@ -44,96 +45,6 @@ async def stop_op25():
async def get_status(): async def get_status():
return {"status": "running" if op25_process else "stopped"} return {"status": "running" if op25_process else "stopped"}
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[str]
tags: List[TalkgroupTag]
whitelist: List[int]
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
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 AudioInstanceConfig(BaseModel):
instance_name: Optional[str] = "audio0"
device_name: Optional[str] = "pulse"
udp_port: Optional[int] = 23456
audio_gain: Optional[float] = 2.5
number_channels: Optional[int] = 1
class AudioConfig(BaseModel):
module: Optional[str] = "sockaudio.py"
instances: Optional[List[AudioInstanceConfig]] = [AudioInstanceConfig()]
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
@router.post("/generate-config") @router.post("/generate-config")
async def generate_config(generator: ConfigGenerator): async def generate_config(generator: ConfigGenerator):
try: try:
@@ -231,7 +142,7 @@ def del_none_in_dict(d):
This alters the input so you may wish to ``copy`` the dict first. This alters the input so you may wish to ``copy`` the dict first.
""" """
for key, value in list(d.items()): for key, value in list(d.items()):
print(f"Key: '{key}'\nValue: '{value}'") LOGGER.info(f"Key: '{key}'\nValue: '{value}'")
if value is None: if value is None:
del d[key] del d[key]
elif isinstance(value, dict): elif isinstance(value, dict):

54
op25.liq Normal file
View File

@@ -0,0 +1,54 @@
#!/usr/bin/liquidsoap
# Example liquidsoap streaming from op25 to icecast
# (c) 2019-2021 gnorbury@bondcar.com, wllmbecks@gmail.com
#
set("log.stdout", true)
set("log.file", false)
set("log.level", 1)
# Make the native sample rate compatible with op25
set("frame.audio.samplerate", 8000)
input = mksafe(input.external(buffer=0.25, channels=2, samplerate=8000, restart_on_error=false, "./audio.py -x 1.5 -s"))
# Consider increasing the buffer value on slow systems such as RPi3. e.g. buffer=0.25
# Longer buffer results in less choppy audio but at the expense of increased latency.
# OPTIONAL AUDIO SIGNAL PROCESSING BLOCKS
# Uncomment to enable
#
# High pass filter
#input = filter.iir.butterworth.high(frequency = 200.0, order = 4, input)
# Low pass filter
#input = filter.iir.butterworth.low(frequency = 3250.0, order = 4, input)
# 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)
# LOCAL AUDIO OUTPUT
# Uncomment the appropriate line below to enable local sound
#
# Default audio subsystem
#out (input)
#
# PulseAudio
#output.pulseaudio(input)
#
# ALSA
#output.alsa(input)
# ICECAST STREAMING
# Uncomment to enable output to an icecast server
# Change the "host", "password", and "mount" strings appropriately first!
# For metadata to work properly, the host address given here MUST MATCH the address in op25's meta.json file
#
output.icecast(%mp3(bitrate=16, samplerate=22050, stereo=false), description="op25", genre="Public Safety", url="", fallible=false, host="localhost", port=8000, mount="mountpoint", password="hackme", mean(input))

20
supervisord.conf Normal file
View File

@@ -0,0 +1,20 @@
[supervisord]
nodaemon=true
[program:op25-liq]
command=/usr/bin/liquidsoap /op25/op25.liq
directory=/op25
autostart=true
autorestart=true
stderr_logfile=/var/log/supervisor/op25-liq.err.log
stdout_logfile=/var/log/supervisor/op25-liq.out.log
user=root
[program:drb-client-discord]
command=uvicorn main:app --host 0.0.0.0 --port 8001 --reload
directory=/app
autostart=true
autorestart=true
stderr_logfile=/var/log/supervisor/main_app.err.log
stdout_logfile=/var/log/supervisor/main_app.out.log
user=root