Compare commits

..

51 Commits

Author SHA1 Message Date
Logan Cusano
d19e2ade76 Update install to use the stable build 2025-07-13 01:03:58 -04:00
Logan Cusano
5050443692 Pull the latest code when building the docker container 2025-07-06 19:35:41 -04:00
Logan Cusano
7802a4a865 Update bot presence when joining discord as well as when starting OP25 (through join command) 2025-06-29 20:52:01 -04:00
Logan Cusano
ef8045559c Update the presence of the bot when the op25 config is set, or the bot is started 2025-06-29 18:48:55 -04:00
Logan Cusano
0a82a9b6e6 Fix bug with token in join server 2025-06-29 18:02:20 -04:00
Logan Cusano
743e405ff6 Use the active token if one exists 2025-06-29 18:00:21 -04:00
Logan Cusano
0300ef2407 Update the install script 2025-06-29 14:23:32 -04:00
Logan Cusano
f2fa623f4e Update the active token when a status update is requested 2025-06-29 02:52:02 -04:00
Logan Cusano
2e5f54a0f5 Check discord status for token on startup 2025-06-29 02:48:29 -04:00
Logan Cusano
0966c78521 Send the active token in the handshake (for reconnection) 2025-06-29 02:36:58 -04:00
Logan Cusano
539534b5aa fix typo in install 2025-06-29 02:06:35 -04:00
Logan Cusano
061f27c17e Update dir for json creation 2025-06-29 02:04:53 -04:00
Logan Cusano
ffb3e1b57f Update install 2025-06-29 02:00:07 -04:00
Logan Cusano
c2858e3ef2 Add install for DRB bot 2025-06-29 00:33:47 -04:00
Logan Cusano
663e2c8305 Convert freqs to string before sending to DRB API 2025-06-23 00:50:39 -04:00
Logan Cusano
66d65d65dd Add debug to client set config 2025-06-22 23:21:02 -04:00
Logan Cusano
e961baca01 Update install and make 2025-06-22 22:35:05 -04:00
Logan Cusano
d1b668fa60 Redo install with docker container 2025-06-22 22:30:57 -04:00
Logan Cusano
cd35ba5389 Fix typos 2025-06-22 22:21:23 -04:00
Logan Cusano
6c9cd8d9be init install.sh 2025-06-22 22:20:32 -04:00
Logan Cusano
01f892a6db Implement static config for server info 2025-06-22 22:08:25 -04:00
Logan Cusano
84135f1eb0 Replace env vars with persistent config 2025-06-13 22:01:14 -04:00
Logan Cusano
fa4fcbd18d fix typo 2025-06-07 23:40:07 -04:00
Logan Cusano
042c9b462d added debugging 2025-06-07 23:28:52 -04:00
Logan Cusano
c0d363cb79 Fix name 2025-06-07 23:24:35 -04:00
Logan Cusano
4a93cf5c71 replace requests with httpx 2025-06-07 23:24:02 -04:00
Logan Cusano
6b2aee72e0 Add default node nickname 2025-06-07 23:22:16 -04:00
Logan Cusano
f6cf6af719 Implement access token 2025-06-07 23:07:34 -04:00
Logan Cusano
54249016d3 Implement a basic retry system to avoid the app closing when disconnected from the server 2025-05-28 22:18:10 -04:00
Logan Cusano
6192f1d193 Revert endpoint change 2025-05-26 00:23:05 -04:00
Logan Cusano
cc604a82c7 fix request token endpoint 2025-05-26 00:18:05 -04:00
Logan Cusano
7bfd495a8f Fix copy mistake 2025-05-26 00:00:16 -04:00
Logan Cusano
3434e5ff65 Update get_status to actively fetch the status 2025-05-25 23:59:25 -04:00
Logan Cusano
ffec7e2045 Update config on OP25 changes via webhook 2025-05-25 23:50:03 -04:00
Logan Cusano
3393061b37 Fixed missed webhook param 2025-05-25 23:38:46 -04:00
Logan Cusano
b4068a83bc Made OP25 logic optional when joining discord 2025-05-25 23:35:54 -04:00
Logan Cusano
076134ad91 Add OP25 commands to websocket 2025-05-25 22:58:31 -04:00
Logan Cusano
67e655eb90 Remove comment 2025-05-25 22:06:16 -04:00
Logan Cusano
ea53f5da3d Update command reply 2025-05-25 21:56:26 -04:00
Logan Cusano
338704b6e8 add request ID to the args list 2025-05-25 21:50:37 -04:00
Logan Cusano
c098e429a0 Fix enum bug 2025-05-25 21:43:00 -04:00
Logan Cusano
fd812253fe Add response type to message 2025-05-25 21:36:41 -04:00
Logan Cusano
c2692dfcee Add get status function 2025-05-25 21:33:48 -04:00
Logan Cusano
607f0b4594 convert the guild ID strings to ints to check in bot response 2025-05-24 23:12:30 -04:00
Logan Cusano
9df1d77d6a refactored frequency_khz to frequencies 2025-05-24 18:39:00 -04:00
Logan Cusano
161af54388 Fix typo 2025-05-24 18:27:16 -04:00
Logan Cusano
7f61bf5239 Update request token endpoint URL and logic 2025-05-24 18:08:47 -04:00
Logan Cusano
caddd67bc4 fixed makefile 2025-05-23 22:09:51 -04:00
Logan Cusano
db72792064 Updated keys 2025-05-23 22:07:13 -04:00
Logan Cusano
e1cfa22650 Changed naming 2025-05-11 20:36:47 -04:00
Logan Cusano
f17f7fc36a Implement config storage 2025-05-11 20:36:31 -04:00
11 changed files with 626 additions and 133 deletions

4
.gitignore vendored
View File

@@ -1,2 +1,4 @@
*.venv
*__pycache__
*__pycache__
*.bat
*.json

View File

@@ -4,6 +4,9 @@ FROM python:3.13-slim
# Set the working directory in the container
WORKDIR /app
# Create the data dir (this should be a volume on the local machine to store data)
RUN mkdir -p data
# Copy the requirements file into the container
COPY requirements.txt .

View File

@@ -11,8 +11,6 @@ build:
# Target to run the server container using the host network
run: build
docker run -it --rm \
-e SERVER_WS_URI=${SERVER_WS_URI} \
-e SERVER_API_URL=${SERVER_API_URL} \
-e CLIENT_API_URL=${CLIENT_API_URL} \
-v "$(shell pwd)/data":/data \
--network=host \
$(CLIENT_IMAGE)

View File

@@ -1,6 +1,10 @@
import httpx
class BaseAPI():
def __init__(self):
self._client = httpx.AsyncClient()
self.access_token = None # Placeholder, will be set by derived class or external config
async def __aenter__(self):
"""Allows using the client with async with."""
return self
@@ -11,68 +15,29 @@ class BaseAPI():
async def close(self):
"""Closes the underlying asynchronous HTTP client."""
await self._client.close()
if self._client: # Ensure _client exists before trying to close
await self._client.close()
async def _post(self, endpoint: str, data: dict = None):
"""
Asynchronous helper method for making POST requests.
Args:
endpoint: The API endpoint (e.g., "/op25/start").
data: The data to send in the request body (as a dictionary).
Returns:
The JSON response from the API.
Raises:
httpx.RequestError: If the request fails.
httpx.HTTPStatusError: If the API returns an error status code (4xx or 5xx).
This method will now implicitly use the _request method,
so authentication logic is centralized.
"""
url = f"{self.base_url}{endpoint}"
try:
# Use await with the asynchronous httpx client
response = await self._client.post(url, json=data)
response.raise_for_status() # Raise HTTPStatusError for bad responses (4xx or 5xx)
return response.json()
except httpx.HTTPStatusError as e:
print(f"HTTP error occurred: {e}")
print(f"Response body: {e.response.text}") # Access response text from the exception
raise
except httpx.RequestError as e:
print(f"Request to '{url}' failed: {e}")
raise
return await self._request("POST", endpoint, json=data)
async def _get(self, endpoint: str):
"""
Asynchronous helper method for making GET requests.
Args:
endpoint: The API endpoint (e.g., "/op25/status").
Returns:
The JSON response from the API.
Raises:
httpx.RequestError: If the request fails.
httpx.HTTPStatusError: If the API returns an error status code (4xx or 5xx).
This method will now implicitly use the _request method,
so authentication logic is centralized.
"""
url = f"{self.base_url}{endpoint}"
try:
# Use await with the asynchronous httpx client
response = await self._client.get(url)
response.raise_for_status() # Raise HTTPStatusError for bad responses (4xx or 5xx)
return response.json()
except httpx.HTTPStatusError as e:
print(f"HTTP error occurred: {e}")
print(f"Response body: {e.response.text}") # Access response text from the exception
raise
except httpx.RequestError as e:
print(f"Request to '{url}' failed: {e}")
raise
return await self._request("GET", endpoint)
async def _request(self, method, endpoint, **kwargs):
"""
Helper method to make an asynchronous HTTP request.
This is where the access_token will be injected.
Args:
method (str): The HTTP method (e.g., 'GET', 'POST').
@@ -86,15 +51,20 @@ class BaseAPI():
httpx.HTTPStatusError: If the request returns a non-2xx status code.
httpx.RequestError: For other request-related errors.
"""
url = f"{self.base_url}{endpoint}"
url = f"{self.base_url}{endpoint}" # base_url must be defined in derived class or passed to BaseAPI
headers = kwargs.pop("headers", {})
if self.access_token: # Check if access_token is set
headers["Authorization"] = f"Bearer {self.access_token}"
kwargs["headers"] = headers
try:
response = await self._client.request(method, url, **kwargs)
response.raise_for_status() # Raise an exception for bad status codes (4xx or 5xx)
return response.json()
except httpx.HTTPStatusError as e:
print(f"HTTP error occurred: {e}")
# You might want to return the error response body or raise the exception
raise
except httpx.RequestError as e:
print(f"An error occurred while requesting {e.request.url!r}: {e}")
raise
raise

View File

@@ -4,17 +4,34 @@ import json
import uuid
import os
from drb_cdb_api import DRBCDBAPI
from drb_cdb_types import ConfigGenerator
from drb_cdb_types import ConfigGenerator, TalkgroupTag
from server_api import RadioAPIClient
from enum import Enum
from config import Config
from utils import generate_node_nickname
app_conf = Config()
# --- Client Configuration ---
SERVER_WS_URI = os.getenv("SERVER_WS_URI", "ws://localhost:8765")
SERVER_API_URL = os.getenv("SERVER_API_URL", "http://localhost:5000")
CLIENT_API_URL = os.getenv("CLIENT_API_URL", "http://localhost:8001")
# Generate or define a unique ID for THIS client instance
# In a real app, this might come from config or a login process
CLIENT_ID = f"client-{uuid.uuid4().hex[:8]}" # TODO - Implement persistent ID
SERVER_WS_URI = app_conf.get("SERVER_WS_URI", "ws://localhost:8765")
SERVER_API_URL = app_conf.get("SERVER_API_URL", "http://localhost:5000")
CLIENT_API_URL = app_conf.get("CLIENT_API_URL", "http://localhost:8001")
# Get/set the ID of this node
if not app_conf.get("client_id"):
app_conf.set("client_id", f"client-{uuid.uuid4().hex[:8]}")
CLIENT_ID = app_conf.client_id
# Get the nickname or set the reg ID
if not app_conf.get("nickname"):
generated_nickname = generate_node_nickname()
print("Generated nickname: ", generated_nickname)
app_conf.set("nickname", generated_nickname)
NICKNAME = app_conf.get("nickname")
print(app_conf)
# ----------------------------
# Dictionary mapping command names (strings) to local client functions
@@ -24,12 +41,26 @@ command_handlers = {}
drb_api = DRBCDBAPI(CLIENT_API_URL)
srv_api = RadioAPIClient(SERVER_API_URL)
# --- Define the client status object ---
class StatusValues(Enum):
ONLINE = "online" # The client is online
LISTENING = "listening" # The client bot is online and listening to a system
# Hold the active token
bot_token = None
client_status = StatusValues.ONLINE
# --- Define the client status object ---
class DiscordStatusValues(str, Enum):
INVOICE = "in_voice" # The discord client is in at least one voice channel
ONLINE = "online" # The discord client is online
OFFLINE = "offline" # The discord client is offline
class OP25StatusValues(str, Enum):
LISTENING = "listening" # OP25 is online and listening
ONLINE = "online" # OP25 is online
OFFLINE = "offline" # OP25 is offline
client_status = {
"op25_status": OP25StatusValues.OFFLINE,
"discord_status": DiscordStatusValues.OFFLINE
}
# --- Define decorator creation function ---
def command(func):
@@ -38,28 +69,39 @@ def command(func):
return func
# --- Define Client-Side Command Handlers (The "API" functions) ---
# Join server
@command
async def join_server(system_id, guild_id, channel_id):
# Takes system ID, guild ID, channel ID
async def join_server(websocket, system_id, guild_id, channel_id):
# Takes guild ID, channel ID, and optionally system_id. If system ID is not included then it will skip OP25 logic
global bot_token
bot_status = await drb_api.get_bot_status()
print("Bot status:", bot_status)
# Check if the bot is running
if 'bot_running' not in bot_status or not bot_status['bot_running']:
# Get a token
bot_token = await srv_api.request_token()
print("Bot token:", bot_token)
if not bot_token or "token" not in bot_token or not bot_token['token']:
raise Exception("No bot token received") # TODO - Handle this better
# Get a token if one is not already
if not bot_token:
bot_token = await srv_api.request_token()
if not bot_token or "token" not in bot_token or not bot_token['token']:
raise Exception("No bot token received") # TODO - Handle this better
bot_token = bot_token['token']
print("Bot token:", bot_token)
# Run the bot if not
await drb_api.start_bot(bot_token['token'])
await drb_api.start_bot(bot_token)
# Set the presence of the bot
await drb_api.update_bot_presence()
# Check if the bot is connected to the guild, if not join
if 'connected_guilds' not in bot_status or guild_id not in bot_status['connected_guilds']:
if 'connected_guilds' not in bot_status or int(guild_id) not in bot_status['connected_guilds']:
# Join the server
await drb_api.join_voice_channel(guild_id, channel_id)
# Update status
client_status = StatusValues.LISTENING
client_status['discord_status'] = DiscordStatusValues.INVOICE
print("Join server completed")
# If there is no system ID, skip the OP25 starting / setting logic
if not system_id:
return
op25_status = await drb_api.get_op25_status()
print("OP25 status:", op25_status)
@@ -69,17 +111,17 @@ async def join_server(system_id, guild_id, channel_id):
print("System details:", sys_details)
if not sys_details:
# TODO - handle not having channel details
pass
return
# Generate the config for the channel requested
tags_list = [TalkgroupTag(**tag_dict) for tag_dict in sys_details.get('tags', []) if tag_dict] if sys_details.get('tags') is not None else None
sys_config = ConfigGenerator(
type=sys_details.get('decode_mode'),
type=sys_details.get('type'),
systemName=sys_details['name'],
channels=sys_details.get('frequency_list_khz'), # Assuming 'channels' is the correct field name
channels=sys_details.get('frequencies'),
tags=tags_list,
whitelist=sys_details.get('tag_whitelist') # Use .get for optional fields
whitelist=sys_details.get('tag_whitelist')
)
# Set the OP25 config
@@ -87,13 +129,18 @@ async def join_server(system_id, guild_id, channel_id):
# Start OP25
await drb_api.start_op25()
# Update the presence of the discord bot
await drb_api.update_bot_presence()
client_status['op25_status'] = OP25StatusValues.LISTENING
print("Join server completed")
print("OP25 Startup Complete")
# Leave server
@command
async def leave_server(guild_id):
# Takes guild ID
async def leave_server(websocket, guild_id):
# Takes guild ID
bot_status = await drb_api.get_bot_status()
print("Bot status:", bot_status)
@@ -103,26 +150,81 @@ async def leave_server(guild_id):
return
# Check if the bot is in the guild
if not "connected_guilds" in bot_status or guild_id not in bot_status['connected_guilds']:
if not "connected_guilds" in bot_status or int(guild_id) not in bot_status['connected_guilds']:
return
# Leave the server specified
await drb_api.leave_voice_channel(guild_id)
# Update status
client_status = StatusValues.ONLINE
client_status['discord_status'] = DiscordStatusValues.ONLINE
print("Leave server completed")
# Get the client status
@command
async def set_status(status_text):
"""Example command: Sets or displays a status."""
print(f"\n--- Server Command: set_status ---")
print(f"Status updated to: {status_text}")
print("----------------------------------")
async def get_status(websocket, request_id):
# Get the OP25 Status
op25_status = await drb_api.get_op25_status()
if 'status' not in op25_status or op25_status['status'] == "stopped":
client_status['op25_status'] = OP25StatusValues.OFFLINE
else:
client_status['op25_status'] = OP25StatusValues.LISTENING
# Get the discord Status
discord_status = await drb_api.get_bot_status()
if 'bot_running' not in discord_status or not discord_status['bot_running']:
client_status['discord_status'] = DiscordStatusValues.OFFLINE
elif discord_status['bot_running'] and ('connected_guilds' in discord_status and len(discord_status['connected_guilds']) > 0):
client_status['discord_status'] = DiscordStatusValues.INVOICE
else:
client_status['discord_status'] = DiscordStatusValues.ONLINE
# Check if the active token was passed and update the global
if "active_token" in discord_status and discord_status['active_token']:
# Update the bot token
global bot_token
bot_token = discord_status['active_token']
# Return the status object
response_payload = {"status": client_status}
# Corrected line: Convert the dictionary to a JSON string before sending
await websocket.send(json.dumps({"type":"response", "request_id": request_id, "payload": response_payload}))
# Start OP25
@command
async def run_task(task_id, duration_seconds):
async def op25_start(websocket):
await drb_api.start_op25()
client_status['op25_status'] = OP25StatusValues.LISTENING
# Stop OP25
@command
async def op25_stop(websocket):
await drb_api.stop_op25()
client_status['op25_status'] = OP25StatusValues.OFFLINE
# Set OP25 Config
@command
async def op25_set(websocket, system_id):
system_config = await srv_api.get_system_details(system_id)
temp_config = ConfigGenerator(
type=system_config['type'],
systemName=system_config['name'],
channels=system_config['frequencies'],
tags=system_config['tags'],
whitelist=system_config['whitelist']
)
await drb_api.generate_op25_config(temp_config)
# Update the presence of the discord bot (if running)
await drb_api.update_bot_presence()
# Example command
@command
async def run_task(websocket, task_id, duration_seconds):
"""Example command: Simulates running a task."""
print(f"\n--- Server Command: run_task ---")
print(f"Starting task {task_id} for {duration_seconds} seconds...")
@@ -130,11 +232,7 @@ async def run_task(task_id, duration_seconds):
print(f"Task {task_id} finished.")
print("------------------------------")
# Add more command handlers as needed...
# ------------------------------------------------------------------
async def receive_commands(websocket):
"""Listens for and processes command messages from the server."""
async for message in websocket:
@@ -144,14 +242,22 @@ async def receive_commands(websocket):
command_name = data.get("name")
args = data.get("args", [])
# Check if there is a req ID, if so add it to the args
req_id = data.get("request_id", None)
if req_id:
args.append(req_id)
if command_name in command_handlers:
print(f"Executing command: {command_name} with args {args}")
# Execute the registered async function
await command_handlers[command_name](*args)
await command_handlers[command_name](websocket, *args)
else:
print(f"Received unknown command: {command_name}")
elif data.get("type") == "handshake_ack":
print(f"Server acknowledged handshake.")
# Set the session token
app_conf.set("access_token", data.get("access_token"))
print(f"Server acknowledged handshake.")
else:
print(f"Received unknown message type: {data.get('type')}")
@@ -160,34 +266,39 @@ async def receive_commands(websocket):
except Exception as e:
print(f"Error processing message: {e}")
async def main_client():
"""Connects to the server and handles communication."""
print(f"Client {CLIENT_ID} connecting to {SERVER_WS_URI}...")
try:
async with websockets.connect(SERVER_WS_URI) as websocket:
print("Connection established.")
"""Connects to the server and handles communication with a robust retry system."""
retry_delay = 5 # seconds
# Handshake: Send client ID immediately after connecting
handshake_message = json.dumps({"type": "handshake", "id": CLIENT_ID})
await websocket.send(handshake_message)
print(f"Sent handshake with ID: {CLIENT_ID}")
# Get initial status from the discord bot
discord_status = await drb_api.get_bot_status()
if "active_token" in discord_status and discord_status['active_token']:
# If there's a status and the active token is set, update the global var
global bot_token
bot_token = discord_status['active_token']
# Start receiving commands and keep the connection alive
await receive_commands(websocket)
while True:
print(f"Client {CLIENT_ID} attempting to connect to {SERVER_WS_URI}...")
try:
async with websockets.connect(SERVER_WS_URI) as websocket:
print("Connection established.")
except ConnectionRefusedError:
print(f"Connection refused. Is the server running at {SERVER_WS_URI}?")
except websockets.exceptions.ConnectionClosedOK:
print("Connection closed gracefully by server.")
except websockets.exceptions.ConnectionClosedError as e:
print(f"Connection closed unexpectedly: {e}")
except Exception as e:
print(f"An error occurred: {e}")
# Handshake: Send client ID immediately after connecting
handshake_message = json.dumps({"type": "handshake", "id": CLIENT_ID, "nickname": NICKNAME, "active_token": bot_token})
await websocket.send(handshake_message)
print(f"Sent handshake with ID: {CLIENT_ID}")
print(f"Client {CLIENT_ID} stopped.")
# Start receiving commands and keep the connection alive
await receive_commands(websocket)
except (ConnectionRefusedError, websockets.exceptions.ConnectionClosedOK, websockets.exceptions.ConnectionClosedError) as e:
print(f"Connection error: {e}. Retrying in {retry_delay} seconds...")
await asyncio.sleep(retry_delay)
except Exception as e:
print(f"An unexpected error occurred: {e}. Retrying in {retry_delay} seconds...")
await asyncio.sleep(retry_delay)
if __name__ == "__main__":
# Note: In a real application, you'd want a more robust way
# to manage the event loop and handle potential reconnections.
asyncio.run(main_client())

155
app/config.py Normal file
View File

@@ -0,0 +1,155 @@
import os
import json
class Config:
"""
Manages application configuration stored in a JSON file.
Provides attribute-style access to configuration values.
"""
def __init__(self, file_path='/data/config.json'):
"""
Initializes the Config manager.
Loads configuration from the JSON file. Creates the file with an
empty JSON object if it doesn't exist.
Args:
file_path (str): The path to the JSON configuration file.
"""
self.file_path = file_path
self._config_data = {} # Internal dictionary to hold config data
self._load_config()
def _load_config(self):
"""Loads configuration key-value pairs from the JSON file."""
# Check if the file exists. If not, create it with an empty JSON object.
if not os.path.exists(self.file_path):
self._save_config() # Create the file with empty data
try:
with open(self.file_path, 'r') as f:
# Load data from the file. Handle empty file case.
content = f.read()
if content:
self._config_data = json.loads(content)
else:
self._config_data = {} # Initialize as empty if file is empty
except (FileNotFoundError, json.JSONDecodeError) as e:
print(f"Error loading config file {self.file_path}: {e}")
# If there's an error loading, initialize with empty data
self._config_data = {}
# Optionally, you might want to back up the problematic file
# or log the error more severely in a real application.
def _save_config(self):
"""Saves the current configuration data to the JSON file."""
try:
with open(self.file_path, 'w') as f:
json.dump(self._config_data, f, indent=4) # Use indent for readability
except IOError as e:
print(f"Error saving config file {self.file_path}: {e}")
except TypeError as e:
print(f"Error serializing config data to JSON: {e}. Ensure all values are JSON serializable.")
def get(self, key, default=None):
"""
Retrieves a configuration value by key.
Args:
key (str): The configuration key.
default: The value to return if the key is not found.
Returns:
The configuration value or the default value if not found.
"""
return self._config_data.get(key, default)
def set(self, key, value):
"""
Sets or updates a configuration value in memory and saves to the file.
Args:
key (str): The configuration key.
value: The configuration value. Must be JSON serializable.
"""
self._config_data[key] = value
self._save_config()
def delete(self, key):
"""
Deletes a configuration key-value pair from memory and saves to the file.
Args:
key (str): The configuration key to delete.
"""
if key in self._config_data:
del self._config_data[key]
self._save_config()
else:
print(f"Warning: Key '{key}' not found in config.")
def __str__(self):
"""
Returns a neat, formatted string representation of all configuration
key-value pairs. This method is automatically called when the object
is converted to a string (e.g., by print()).
"""
if not self._config_data:
return "\n--- Configuration is Empty ---\n"
output = ["\n--- Current Configuration ---"]
max_key_len = max(len(key) for key in self._config_data)
for key, value in self._config_data.items():
output.append(f"{key.ljust(max_key_len)} : {json.dumps(value, indent=None)}")
output.append("-----------------------------\n")
return "\n".join(output)
def __getattr__(self, name):
"""
Allows accessing configuration values using attribute notation (e.g., config.my_key).
Args:
name (str): The attribute name (configuration key).
Returns:
The configuration value associated with the key.
Raises:
AttributeError: If the key is not found in the configuration.
"""
if name in self._config_data:
return self._config_data[name]
# Fallback for standard attributes if not found in config data
try:
return self.__getattribute__(name)
except AttributeError:
raise AttributeError(f"'Config' object has no attribute '{name}' and key '{name}' not found in config.")
def __setattr__(self, name, value):
"""
Allows setting configuration values using attribute notation (e.g., config.my_key = value).
Args:
name (str): The attribute name (configuration key).
value: The value to set. Must be JSON serializable.
"""
# Handle setting internal attributes like file_path or _config_data
if name in ('file_path', '_config_data'):
super().__setattr__(name, value)
else:
# Treat other attribute assignments as setting config values
self.set(name, value)
def __delattr__(self, name):
"""
Allows deleting configuration values using attribute notation (e.g., del config.my_key).
Args:
name (str): The attribute name (configuration key) to delete.
"""
if name in self._config_data:
self.delete(name)
else:
# Fallback for standard attributes
super().__delattr__(name)

View File

@@ -46,8 +46,9 @@ class DRBCDBAPI(BaseAPI):
config_data: A ConfigGenerator object representing the configuration data.
"""
# Convert the ConfigGenerator object to a dictionary before sending as JSON
print(f"Generate OP25 config")
return await self._post("/op25/generate-config", data=config_data.to_dict())
config_data = config_data.to_dict()
print(f"Generate OP25 config", config_data)
return await self._post("/op25/generate-config", data=config_data)
# --- Pulse Audio Endpoints ---
@@ -99,6 +100,11 @@ class DRBCDBAPI(BaseAPI):
print("Getting bot status")
return await self._get("/bot/status")
async def update_bot_presence(self):
"""Updates the bot's precense with the configured system."""
print("Getting bot status")
return await self._post("/op25/update-presence")
# Example Usage (assuming your FastAPI app is running on http://localhost:8000)
async def example_usage():
"""Demonstrates asynchronous API interaction using httpx."""

View File

@@ -1,7 +1,7 @@
from enum import Enum
from typing import Dict, Any, List, Optional
from typing import Dict, Any, List, Optional, Union
class DecodeMode(str, Enum):
class DemodTypes(str, Enum):
P25 = "P25"
DMR = "DMR"
ANALOG = "NBFM" # Note: The API code uses "NBFM" for analog
@@ -20,9 +20,9 @@ class ConfigGenerator:
"""Represents the configuration data structure for the API."""
def __init__(
self,
type: DecodeMode,
type: DemodTypes,
systemName: str,
channels: List[str],
channels: List[Union[str, int]],
tags: Optional[List[TalkgroupTag]] = None,
whitelist: Optional[List[int]] = None
):
@@ -37,7 +37,7 @@ class ConfigGenerator:
data = {
"type": self.type,
"systemName": self.systemName,
"channels": self.channels,
"channels": [str(channel) for channel in self.channels ],
"tags": [tag.to_dict() for tag in self.tags] if self.tags else [],
"whitelist": self.whitelist if self.whitelist else []
}

View File

@@ -1,6 +1,10 @@
import httpx
import json
from base_api import BaseAPI
from config import Config
import asyncio # Add this import for the example usage
app_config = Config()
class RadioAPIClient(BaseAPI):
"""
@@ -16,8 +20,9 @@ class RadioAPIClient(BaseAPI):
"""
super().__init__()
self.base_url = base_url
# Use an AsyncClient for making asynchronous requests
self._client = httpx.AsyncClient()
# Set the access token on the BaseAPI instance
self.access_token = app_config.get("access_token") # Or app_config.access_token
async def get_systems(self):
"""
@@ -62,8 +67,9 @@ class RadioAPIClient(BaseAPI):
Returns:
str: A token for the bot to use
"""
print(f"Fetching a token from {self.base_url}/request_token")
return await self._request("POST", "/request_token")
url = "/bots/request_token"
print(f"Fetching a token from {self.base_url}{url}")
return await self._request("POST", url, json={"client_id":app_config.client_id})
async def send_command(self, client_id: str, command_name: str, args: list = None):
"""
@@ -157,5 +163,4 @@ if __name__ == "__main__":
# 1. Ensure the server (server.py) is running.
# 2. Ensure at least one client (client.py) is running.
# 3. Run this script: python api_client.py
asyncio.run(example_api_usage())
asyncio.run(example_api_usage())

46
app/utils.py Normal file
View File

@@ -0,0 +1,46 @@
import socket
import httpx
def generate_node_nickname():
"""
Generates a temporary unit registration ID in the format:
"Node-{last octet of local IP}-{last octet of public IP}".
This ID is intended for initial unit registration and will be replaced
by a server-generated GUID upon assignment.
Returns:
str: The formatted registration ID.
"""
local_ip_octet = "unknown"
public_ip_octet = "unknown"
# Get the last octet of the local IP address
try:
# Create a UDP socket
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
# Connect to an external host (Google's DNS) to get the local IP
# This doesn't send any data, just establishes a connection for getsockname()
s.connect(("8.8.8.8", 80))
local_ip = s.getsockname()[0]
s.close()
local_ip_octet = local_ip.split('.')[-1]
except Exception as e:
print(f"Warning: Could not determine local IP address. Error: {e}")
# Get the last octet of the public IP address using an external service with httpx
try:
# Use ipify.org to get the public IP (a widely used, simple service)
# httpx.get() is synchronous by default
response = httpx.get('https://api.ipify.org?format=json')
response.raise_for_status() # Raise an HTTPStatusError for bad responses (4xx or 5xx)
public_ip = response.json()['ip']
public_ip_octet = public_ip.split('.')[-1]
except httpx.RequestError as e:
print(f"Warning: Could not determine public IP address (network error with httpx). Error: {e}")
except httpx.HTTPStatusError as e:
print(f"Warning: Could not determine public IP address (HTTP status error with httpx). Error: {e}")
except ValueError as e:
print(f"Warning: Could not parse public IP response. Error: {e}")
return f"Node-{local_ip_octet}-{public_ip_octet}"

197
install.sh Normal file
View File

@@ -0,0 +1,197 @@
#!/bin/bash
# Define ANSI color codes
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[0;33m' # Added yellow for warnings/info
NC='\033[0m' # No Color - resets text to default color
install_prereqs() {
if id -Gn $(whoami) | grep -qw docker; then
echo -e "${GREEN}Prerequisites Already Installed${NC}"
else
echo -e "${GREEN}Installing Prerequisites...${NC}"
sudo apt-get update -y
sudo apt-get install docker.io -y
sudo usermod -aG docker $(whoami)
sudo su $(whoami)
fi
}
run_discord_bot() {
local drb_container_name="drb-client-discord-bot"
# Stop and remove existing container if it's running/exists
if docker ps -a --format '{{.Names}}' | grep -q "^${drb_container_name}$"; then
echo -e "${YELLOW}Container '${drb_container_name}' already exists. Stopping and removing it...${NC}"
docker stop "${drb_container_name}" &> /dev/null
docker rm "${drb_container_name}" &> /dev/null
if [ $? -ne 0 ]; then
echo -e "${RED}Warning: Could not stop/remove existing container '${drb_container_name}'. It might not be running.${NC}"
fi
fi
mkdir -p $(pwd)/configs
echo -e "${GREEN}Installing the discord bot...${NC}"
docker pull git.vpn.cusano.net/logan/drb-client-discord-bot/drb-client-discord-bot:stable
docker run -d --privileged \
-v /dev:/dev \
-v $(pwd)/configs:/configs \
--name "${drb_container_name}" \
--network=host \
--restart unless-stopped \
git.vpn.cusano.net/logan/drb-client-discord-bot/drb-client-discord-bot:stable
}
create_config_json() {
echo -e "${GREEN}Creating config.json file...${NC}"
# Define the JSON content directly as a multi-line string
local config_content='{
"SERVER_WS_URI": "ws://drb-sock.vpn.cusano.net",
"SERVER_API_URL": "https://drb-api.vpn.cusano.net",
"CLIENT_API_URL": "http://localhost:8001",
"nickname": ""
}'
# Create data folder if it doesn't exist
mkdir -p $(pwd)/data
# Write the content to config.json
echo "$config_content" > $(pwd)/data/config.json
# Check if the file was successfully created
if [ -f "$(pwd)/data/config.json" ]; then
echo -e "${GREEN}Successfully created 'config.json'.${NC}"
echo -e "${GREEN}Content of config.json:${NC}"
cat $(pwd)/data/config.json
else
echo -e "${RED}Error: Failed to create 'config.json'.${NC}"
exit 1 # Exit with an error code if file creation fails
fi
}
start_docker_container() {
local container_name="drb-client-app-container" # A unique name for your container
local image_name="drb-client-app" # From your Makefile
# Check to make sure the repo is up to date
git pull
echo -e "${GREEN}Building Docker image '${image_name}'...${NC}"
# Build the Docker image from the current directory
if ! docker build -t "${image_name}" .; then
echo -e "${RED}Error: Failed to build Docker image '${image_name}'. Please check your Dockerfile and context.${NC}"
exit 1
fi
echo -e "${GREEN}Docker image '${image_name}' built successfully.${NC}"
echo -e "${GREEN}Starting Docker container...${NC}"
# Stop and remove existing container if it's running/exists
if docker ps -a --format '{{.Names}}' | grep -q "^${container_name}$"; then
echo -e "${YELLOW}Container '${container_name}' already exists. Stopping and removing it...${NC}"
docker stop "${container_name}" &> /dev/null
docker rm "${container_name}" &> /dev/null
if [ $? -ne 0 ]; then
echo -e "${RED}Warning: Could not stop/remove existing container '${container_name}'. It might not be running.${NC}"
fi
fi
echo -e "${GREEN}Starting new container '${container_name}' from image '${image_name}' with restart policy 'unless-stopped'.${NC}"
# Run the container
docker run -d \
--name "${container_name}" \
--restart unless-stopped \
-v "$(pwd)/data":/data \
--network=host \
"${image_name}"
if docker ps -a --format '{{.Names}}' | grep -q "^${container_name}$"; then
echo -e "${GREEN}Docker container '${container_name}' started successfully in detached mode.${NC}"
echo -e "${GREEN}It is configured to restart automatically on daemon startup or if it exits (unless stopped manually).${NC}"
echo -e "${GREEN}You can check its status with: docker ps -f name=${container_name}${NC}"
echo -e "${GREEN}You can view its logs with: docker logs -f ${container_name}${NC}"
else
echo -e "${RED}Error: Failed to start Docker container '${container_name}'.${NC}"
exit 1
fi
}
# --- Main script execution ---
# Default flags
RUN_BOTH_DOCKERS=false
RUN_DISCORD_DOCKER=false
RUN_CLIENT_DOCKER=false
FULL_INSTALL=true
# Parse command-line arguments
# We use a while loop with a case statement to handle different options.
# getopts is for short options (e.g., -r)
# For long options (e.g., --run), we'll handle them manually or use a more advanced parser.
while [[ "$#" -gt 0 ]]; do
case "$1" in
-r|--run)
RUN_BOTH_DOCKERS=true
FULL_INSTALL=false # If -r is passed, we don't do a full install
shift # Move to next argument
;;
-c|--client)
RUN_CLIENT_DOCKER=true
FULL_INSTALL=false
shift
;;
-d|--discord|-l)
RUN_DISCORD_DOCKER=true
FULL_INSTALL=false
shift
;;
*)
echo "Unknown parameter passed: $1"
shift
;;
esac
done
if [ "$FULL_INSTALL" = true ]; then
echo -e "${GREEN}--- Starting full installation and setup ---${NC}"
# Install PreReqs
install_prereqs
# Create config.json
create_config_json
# Build and Start Docker container
start_docker_container
# Download/update and run the drb discord bot
run_discord_bot
echo -e "${GREEN}--- All installation and startup steps finished ---${NC}"
elif [ "$RUN_BOTH_DOCKERS" = true ]; then
# Build and run the DRB client container
echo -e "${GREEN}--- Starting DRB Client Docker container ---${NC}"
start_docker_container
# Download/update and run the DRB Discord Bot container
echo -e "${GREEN}--- Starting DRB Listener Docker container ---${NC}"
run_discord_bot
echo -e "${GREEN}--- Docker containers startup finished ---${NC}"
elif [ "$RUN_DISCORD_DOCKER" = true ]; then
# Download/update and run the DRB Discord Bot container
echo -e "${GREEN}--- Starting DRB Listener Docker container ---${NC}"
run_discord_bot
echo -e "${GREEN}--- Discord Docker container startup finished ---${NC}"
elif [ "$RUN_CLIENT_DOCKER" = true ]; then
# Build and run the DRB client container
echo -e "${GREEN}--- Starting DRB Client Docker container ---${NC}"
start_docker_container
echo -e "${GREEN}--- Client Docker container startup finished ---${NC}"
else
# This case should ideally not be hit if flags are handled correctly,
# but it's here for completeness.
echo -e "${YELLOW}---No valid operation specified. Exiting.${NC}"
exit 1
fi