Compare commits

..

49 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
9 changed files with 476 additions and 121 deletions

View File

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

View File

@@ -1,6 +1,10 @@
import httpx import httpx
class BaseAPI(): 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): async def __aenter__(self):
"""Allows using the client with async with.""" """Allows using the client with async with."""
return self return self
@@ -11,68 +15,29 @@ class BaseAPI():
async def close(self): async def close(self):
"""Closes the underlying asynchronous HTTP client.""" """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): async def _post(self, endpoint: str, data: dict = None):
""" """
Asynchronous helper method for making POST requests. Asynchronous helper method for making POST requests.
This method will now implicitly use the _request method,
Args: so authentication logic is centralized.
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).
""" """
url = f"{self.base_url}{endpoint}" return await self._request("POST", endpoint, json=data)
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
async def _get(self, endpoint: str): async def _get(self, endpoint: str):
""" """
Asynchronous helper method for making GET requests. Asynchronous helper method for making GET requests.
This method will now implicitly use the _request method,
Args: so authentication logic is centralized.
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).
""" """
url = f"{self.base_url}{endpoint}" return await self._request("GET", 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
async def _request(self, method, endpoint, **kwargs): async def _request(self, method, endpoint, **kwargs):
""" """
Helper method to make an asynchronous HTTP request. Helper method to make an asynchronous HTTP request.
This is where the access_token will be injected.
Args: Args:
method (str): The HTTP method (e.g., 'GET', 'POST'). 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.HTTPStatusError: If the request returns a non-2xx status code.
httpx.RequestError: For other request-related errors. 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: try:
response = await self._client.request(method, url, **kwargs) response = await self._client.request(method, url, **kwargs)
response.raise_for_status() # Raise an exception for bad status codes (4xx or 5xx) response.raise_for_status() # Raise an exception for bad status codes (4xx or 5xx)
return response.json() return response.json()
except httpx.HTTPStatusError as e: except httpx.HTTPStatusError as e:
print(f"HTTP error occurred: {e}") print(f"HTTP error occurred: {e}")
# You might want to return the error response body or raise the exception
raise raise
except httpx.RequestError as e: except httpx.RequestError as e:
print(f"An error occurred while requesting {e.request.url!r}: {e}") print(f"An error occurred while requesting {e.request.url!r}: {e}")
raise raise

View File

@@ -4,22 +4,34 @@ import json
import uuid import uuid
import os import os
from drb_cdb_api import DRBCDBAPI 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 server_api import RadioAPIClient
from enum import Enum from enum import Enum
from config import Config from config import Config
from utils import generate_node_nickname
app_conf = Config() app_conf = Config()
# --- Client Configuration --- # --- Client Configuration ---
SERVER_WS_URI = os.getenv("SERVER_WS_URI", "ws://localhost:8765") SERVER_WS_URI = app_conf.get("SERVER_WS_URI", "ws://localhost:8765")
SERVER_API_URL = os.getenv("SERVER_API_URL", "http://localhost:5000") SERVER_API_URL = app_conf.get("SERVER_API_URL", "http://localhost:5000")
CLIENT_API_URL = os.getenv("CLIENT_API_URL", "http://localhost:8001") CLIENT_API_URL = app_conf.get("CLIENT_API_URL", "http://localhost:8001")
# Get/set the ID of this node # Get/set the ID of this node
if not app_conf.get("client_id"): if not app_conf.get("client_id"):
app_conf.set("client_id", f"client-{uuid.uuid4().hex[:8]}") app_conf.set("client_id", f"client-{uuid.uuid4().hex[:8]}")
CLIENT_ID = app_conf.client_id 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 # Dictionary mapping command names (strings) to local client functions
@@ -29,12 +41,26 @@ command_handlers = {}
drb_api = DRBCDBAPI(CLIENT_API_URL) drb_api = DRBCDBAPI(CLIENT_API_URL)
srv_api = RadioAPIClient(SERVER_API_URL) srv_api = RadioAPIClient(SERVER_API_URL)
# --- Define the client status object --- # Hold the active token
class StatusValues(Enum): bot_token = None
ONLINE = "online" # The client is online
LISTENING = "listening" # The client bot is online and listening to a system
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 --- # --- Define decorator creation function ---
def command(func): def command(func):
@@ -45,25 +71,37 @@ def command(func):
# --- Define Client-Side Command Handlers (The "API" functions) --- # --- Define Client-Side Command Handlers (The "API" functions) ---
# Join server # Join server
@command @command
async def join_server(system_id, guild_id, channel_id): async def join_server(websocket, system_id, guild_id, channel_id):
# Takes 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() bot_status = await drb_api.get_bot_status()
print("Bot status:", bot_status) print("Bot status:", bot_status)
# Check if the bot is running # Check if the bot is running
if 'bot_running' not in bot_status or not bot_status['bot_running']: if 'bot_running' not in bot_status or not bot_status['bot_running']:
# Get a token # Get a token if one is not already
bot_token = await srv_api.request_token() if not bot_token:
print("Bot token:", bot_token) bot_token = await srv_api.request_token()
if not bot_token or "token" not in bot_token or not bot_token['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 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 # 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 # 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 # Join the server
await drb_api.join_voice_channel(guild_id, channel_id) await drb_api.join_voice_channel(guild_id, channel_id)
# Update status # 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() op25_status = await drb_api.get_op25_status()
print("OP25 status:", op25_status) print("OP25 status:", op25_status)
@@ -73,17 +111,17 @@ async def join_server(system_id, guild_id, channel_id):
print("System details:", sys_details) print("System details:", sys_details)
if not sys_details: if not sys_details:
# TODO - handle not having channel details # TODO - handle not having channel details
pass return
# Generate the config for the channel requested # 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 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( sys_config = ConfigGenerator(
type=sys_details.get('decode_mode'), type=sys_details.get('type'),
systemName=sys_details['name'], 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, tags=tags_list,
whitelist=sys_details.get('tag_whitelist') # Use .get for optional fields whitelist=sys_details.get('tag_whitelist')
) )
# Set the OP25 config # Set the OP25 config
@@ -91,13 +129,18 @@ async def join_server(system_id, guild_id, channel_id):
# Start OP25 # Start OP25
await drb_api.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 # Leave server
@command @command
async def leave_server(guild_id): async def leave_server(websocket, guild_id):
# Takes guild ID # Takes guild ID
bot_status = await drb_api.get_bot_status() bot_status = await drb_api.get_bot_status()
print("Bot status:", bot_status) print("Bot status:", bot_status)
@@ -107,19 +150,81 @@ async def leave_server(guild_id):
return return
# Check if the bot is in the guild # 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 return
# Leave the server specified # Leave the server specified
await drb_api.leave_voice_channel(guild_id) await drb_api.leave_voice_channel(guild_id)
# Update status # Update status
client_status = StatusValues.ONLINE client_status['discord_status'] = DiscordStatusValues.ONLINE
print("Leave server completed") print("Leave server completed")
# Get the client status
@command @command
async def run_task(task_id, duration_seconds): 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 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.""" """Example command: Simulates running a task."""
print(f"\n--- Server Command: run_task ---") print(f"\n--- Server Command: run_task ---")
print(f"Starting task {task_id} for {duration_seconds} seconds...") print(f"Starting task {task_id} for {duration_seconds} seconds...")
@@ -137,14 +242,22 @@ async def receive_commands(websocket):
command_name = data.get("name") command_name = data.get("name")
args = data.get("args", []) 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: if command_name in command_handlers:
print(f"Executing command: {command_name} with args {args}") print(f"Executing command: {command_name} with args {args}")
# Execute the registered async function # Execute the registered async function
await command_handlers[command_name](*args) await command_handlers[command_name](websocket, *args)
else: else:
print(f"Received unknown command: {command_name}") print(f"Received unknown command: {command_name}")
elif data.get("type") == "handshake_ack": 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: else:
print(f"Received unknown message type: {data.get('type')}") print(f"Received unknown message type: {data.get('type')}")
@@ -153,34 +266,39 @@ async def receive_commands(websocket):
except Exception as e: except Exception as e:
print(f"Error processing message: {e}") print(f"Error processing message: {e}")
async def main_client(): async def main_client():
"""Connects to the server and handles communication.""" """Connects to the server and handles communication with a robust retry system."""
print(f"Client {CLIENT_ID} connecting to {SERVER_WS_URI}...") retry_delay = 5 # seconds
try:
async with websockets.connect(SERVER_WS_URI) as websocket:
print("Connection established.")
# Handshake: Send client ID immediately after connecting # Get initial status from the discord bot
handshake_message = json.dumps({"type": "handshake", "id": CLIENT_ID}) discord_status = await drb_api.get_bot_status()
await websocket.send(handshake_message) if "active_token" in discord_status and discord_status['active_token']:
print(f"Sent handshake with ID: {CLIENT_ID}") # 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 while True:
await receive_commands(websocket) 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: # Handshake: Send client ID immediately after connecting
print(f"Connection refused. Is the server running at {SERVER_WS_URI}?") handshake_message = json.dumps({"type": "handshake", "id": CLIENT_ID, "nickname": NICKNAME, "active_token": bot_token})
except websockets.exceptions.ConnectionClosedOK: await websocket.send(handshake_message)
print("Connection closed gracefully by server.") print(f"Sent handshake with ID: {CLIENT_ID}")
except websockets.exceptions.ConnectionClosedError as e:
print(f"Connection closed unexpectedly: {e}")
except Exception as e:
print(f"An error occurred: {e}")
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__": 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()) asyncio.run(main_client())

View File

@@ -50,7 +50,7 @@ class Config:
except IOError as e: except IOError as e:
print(f"Error saving config file {self.file_path}: {e}") print(f"Error saving config file {self.file_path}: {e}")
except TypeError as e: except TypeError as e:
print(f"Error serializing config data to JSON: {e}. Ensure all values are JSON serializable.") print(f"Error serializing config data to JSON: {e}. Ensure all values are JSON serializable.")
def get(self, key, default=None): def get(self, key, default=None):
""" """
@@ -89,6 +89,22 @@ class Config:
else: else:
print(f"Warning: Key '{key}' not found in config.") 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): def __getattr__(self, name):
""" """
Allows accessing configuration values using attribute notation (e.g., config.my_key). Allows accessing configuration values using attribute notation (e.g., config.my_key).
@@ -108,7 +124,7 @@ class Config:
try: try:
return self.__getattribute__(name) return self.__getattribute__(name)
except AttributeError: except AttributeError:
raise AttributeError(f"'Config' object has no attribute '{name}' and key '{name}' not found in config.") raise AttributeError(f"'Config' object has no attribute '{name}' and key '{name}' not found in config.")
def __setattr__(self, name, value): def __setattr__(self, name, value):
""" """
@@ -136,4 +152,4 @@ class Config:
self.delete(name) self.delete(name)
else: else:
# Fallback for standard attributes # Fallback for standard attributes
super().__delattr__(name) super().__delattr__(name)

View File

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

View File

@@ -1,5 +1,5 @@
from enum import Enum from enum import Enum
from typing import Dict, Any, List, Optional from typing import Dict, Any, List, Optional, Union
class DemodTypes(str, Enum): class DemodTypes(str, Enum):
P25 = "P25" P25 = "P25"
@@ -22,7 +22,7 @@ class ConfigGenerator:
self, self,
type: DemodTypes, type: DemodTypes,
systemName: str, systemName: str,
channels: List[str], channels: List[Union[str, int]],
tags: Optional[List[TalkgroupTag]] = None, tags: Optional[List[TalkgroupTag]] = None,
whitelist: Optional[List[int]] = None whitelist: Optional[List[int]] = None
): ):
@@ -37,7 +37,7 @@ class ConfigGenerator:
data = { data = {
"type": self.type, "type": self.type,
"systemName": self.systemName, "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 [], "tags": [tag.to_dict() for tag in self.tags] if self.tags else [],
"whitelist": self.whitelist if self.whitelist else [] "whitelist": self.whitelist if self.whitelist else []
} }

View File

@@ -1,6 +1,10 @@
import httpx import httpx
import json import json
from base_api import BaseAPI from base_api import BaseAPI
from config import Config
import asyncio # Add this import for the example usage
app_config = Config()
class RadioAPIClient(BaseAPI): class RadioAPIClient(BaseAPI):
""" """
@@ -16,8 +20,9 @@ class RadioAPIClient(BaseAPI):
""" """
super().__init__() super().__init__()
self.base_url = base_url self.base_url = base_url
# Use an AsyncClient for making asynchronous requests
self._client = httpx.AsyncClient() 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): async def get_systems(self):
""" """
@@ -62,8 +67,9 @@ class RadioAPIClient(BaseAPI):
Returns: Returns:
str: A token for the bot to use str: A token for the bot to use
""" """
print(f"Fetching a token from {self.base_url}/request_token") url = "/bots/request_token"
return await self._request("POST", "/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): 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. # 1. Ensure the server (server.py) is running.
# 2. Ensure at least one client (client.py) is running. # 2. Ensure at least one client (client.py) is running.
# 3. Run this script: python api_client.py # 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