Compare commits
49 Commits
e1cfa22650
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d19e2ade76 | ||
|
|
5050443692 | ||
|
|
7802a4a865 | ||
|
|
ef8045559c | ||
|
|
0a82a9b6e6 | ||
|
|
743e405ff6 | ||
|
|
0300ef2407 | ||
|
|
f2fa623f4e | ||
|
|
2e5f54a0f5 | ||
|
|
0966c78521 | ||
|
|
539534b5aa | ||
|
|
061f27c17e | ||
|
|
ffb3e1b57f | ||
|
|
c2858e3ef2 | ||
|
|
663e2c8305 | ||
|
|
66d65d65dd | ||
|
|
e961baca01 | ||
|
|
d1b668fa60 | ||
|
|
cd35ba5389 | ||
|
|
6c9cd8d9be | ||
|
|
01f892a6db | ||
|
|
84135f1eb0 | ||
|
|
fa4fcbd18d | ||
|
|
042c9b462d | ||
|
|
c0d363cb79 | ||
|
|
4a93cf5c71 | ||
|
|
6b2aee72e0 | ||
|
|
f6cf6af719 | ||
|
|
54249016d3 | ||
|
|
6192f1d193 | ||
|
|
cc604a82c7 | ||
|
|
7bfd495a8f | ||
|
|
3434e5ff65 | ||
|
|
ffec7e2045 | ||
|
|
3393061b37 | ||
|
|
b4068a83bc | ||
|
|
076134ad91 | ||
|
|
67e655eb90 | ||
|
|
ea53f5da3d | ||
|
|
338704b6e8 | ||
|
|
c098e429a0 | ||
|
|
fd812253fe | ||
|
|
c2692dfcee | ||
|
|
607f0b4594 | ||
|
|
9df1d77d6a | ||
|
|
161af54388 | ||
|
|
7f61bf5239 | ||
|
|
caddd67bc4 | ||
|
|
db72792064 |
5
Makefile
5
Makefile
@@ -11,9 +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 ./data:/data \
|
||||
-v "$(shell pwd)/data":/data \
|
||||
--network=host \
|
||||
$(CLIENT_IMAGE)
|
||||
|
||||
@@ -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
|
||||
224
app/client.py
224
app/client.py
@@ -4,22 +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")
|
||||
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
|
||||
@@ -29,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):
|
||||
@@ -45,25 +71,37 @@ def command(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)
|
||||
|
||||
@@ -73,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
|
||||
@@ -91,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)
|
||||
|
||||
@@ -107,19 +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 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."""
|
||||
print(f"\n--- Server Command: run_task ---")
|
||||
print(f"Starting task {task_id} for {duration_seconds} seconds...")
|
||||
@@ -137,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')}")
|
||||
|
||||
@@ -153,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())
|
||||
@@ -50,7 +50,7 @@ class Config:
|
||||
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.")
|
||||
print(f"Error serializing config data to JSON: {e}. Ensure all values are JSON serializable.")
|
||||
|
||||
def get(self, key, default=None):
|
||||
"""
|
||||
@@ -89,6 +89,22 @@ class 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).
|
||||
@@ -108,7 +124,7 @@ class Config:
|
||||
try:
|
||||
return self.__getattribute__(name)
|
||||
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):
|
||||
"""
|
||||
@@ -136,4 +152,4 @@ class Config:
|
||||
self.delete(name)
|
||||
else:
|
||||
# Fallback for standard attributes
|
||||
super().__delattr__(name)
|
||||
super().__delattr__(name)
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from enum import Enum
|
||||
from typing import Dict, Any, List, Optional
|
||||
from typing import Dict, Any, List, Optional, Union
|
||||
|
||||
class DemodTypes(str, Enum):
|
||||
P25 = "P25"
|
||||
@@ -22,7 +22,7 @@ class ConfigGenerator:
|
||||
self,
|
||||
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 []
|
||||
}
|
||||
|
||||
@@ -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
46
app/utils.py
Normal 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
197
install.sh
Normal 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
|
||||
Reference in New Issue
Block a user