Compare commits

...

3 Commits

Author SHA1 Message Date
Logan Cusano
1d5359d788 Moved types to their own file 2025-05-24 14:56:56 -04:00
Logan Cusano
588e794ae9 Unified network param 2025-05-24 14:56:47 -04:00
Logan Cusano
81267e4c76 refactored nodes and implemented API function 2025-05-24 14:56:33 -04:00
6 changed files with 263 additions and 212 deletions

View File

@@ -15,5 +15,5 @@ run: build
--name $(SERVER_CONTAINER_NAME) \ --name $(SERVER_CONTAINER_NAME) \
-e DB_NAME=$(DB_NAME) \ -e DB_NAME=$(DB_NAME) \
-e MONGO_URL=$(MONGO_URL) \ -e MONGO_URL=$(MONGO_URL) \
--network host \ --network=host \
$(SERVER_IMAGE) $(SERVER_IMAGE)

View File

@@ -1,10 +1,10 @@
import os import os
import typing
import asyncio import asyncio
from uuid import uuid4 from uuid import uuid4
from typing import Optional, List, Dict, Any from typing import Optional, List, Dict, Any
from enum import Enum from enum import Enum
from internal.db_handler import MongoHandler from internal.db_handler import MongoHandler
from internal.types import System
# Init vars # Init vars
DB_NAME = os.getenv("DB_NAME", "default_db") DB_NAME = os.getenv("DB_NAME", "default_db")
@@ -12,115 +12,6 @@ MONGO_URL = os.getenv("MONGO_URL", "mongodb://10.10.202.4:27017/")
SYSTEM_DB_COLLECTION_NAME = "radio_systems" SYSTEM_DB_COLLECTION_NAME = "radio_systems"
# --- Types
class DemodTypes(str, Enum):
P25 = "P25"
DMR = "DMR"
ANALOG = "NBFM"
class TalkgroupTag:
"""Represents a talkgroup tag."""
def __init__(self, talkgroup: str, tagDec: int):
self.talkgroup = talkgroup
self.tagDec = tagDec
# Add a method to convert to a dictionary, useful for sending as JSON
def to_dict(self) -> Dict[str, Any]:
return {"talkgroup": self.talkgroup, "tagDec": self.tagDec}
class System:
"""
A basic data model for a channel/system entry in a radio system.
"""
def __init__(self,
_id: str,
_type: DemodTypes,
name: str,
frequency_khz: List[int],
location: str,
avail_on_nodes: List[str],
description: Optional[str] = "",
tags: Optional[List[TalkgroupTag]] = None,
whitelist: Optional[List[int]] = None):
"""
Initializes a System object.
Args:
_id: A unique identifier for the entry (e.g., MongoDB ObjectId string).
_type: The demodulation type (P25, NBFM, etc.).
name: The name of the channel/system.
frequency_khz: The frequency in kilohertz.
location: The geographical location or coverage area.
avail_on_nodes: A list of node identifiers where this is available.
description: A brief description.
"""
self._id: str = _id
self.type: DemodTypes = _type
self.name: str = name
self.frequency_khz: List[int] = frequency_khz
self.location: str = location
self.avail_on_nodes: List[str] = avail_on_nodes
self.description: str = description or ""
self.tags: List[TalkgroupTag] = tags or None
self.whitelist: List[int] = whitelist or None
def __repr__(self) -> str:
"""
Provides a developer-friendly string representation of the object.
"""
# Use self.type.value for string representation of the enum
return (f"System(_id='{self._id}', type='{self.type.value}', name='{self.name}', "
f"frequency_khz={self.frequency_khz}, location='{self.location}', "
f"avail_on_nodes={self.avail_on_nodes}, description='{self.description}',"
f" tags='{self.tags}', whitelist='{self.whitelist}')")
def to_dict(self) -> Dict[str, Any]:
"""
Converts the System object to a dictionary suitable for MongoDB.
Converts the DemodTypes enum to its string value.
"""
return {
"_id": self._id,
"type": self.type.value, # Store the enum value (string)
"name": self.name,
"frequency_khz": self.frequency_khz,
"location": self.location,
"avail_on_nodes": self.avail_on_nodes,
"description": self.description,
"tags": self.tags,
"whitelist": self.whitelist,
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "System":
"""
Creates a System object from a dictionary (e.g., from MongoDB).
Converts the 'type' string back to a DemodTypes enum member.
"""
# Ensure required keys exist and handle potential type mismatches if necessary
# Convert the type string back to the DemodTypes enum
system_type = DemodTypes(data.get("type")) if data.get("type") else None # Handle missing or invalid type
if system_type is None:
# Handle error: could raise an exception or return None/default
# For this example, let's raise an error if type is missing/invalid
raise ValueError(f"Invalid or missing 'type' in document data: {data}")
return cls(
_id=data.get("_id"),
_type=system_type,
name=data.get("name", ""), # Provide default empty string if name is missing
frequency_khz=data.get("frequency_khz", 0), # Provide default 0 if missing
location=data.get("location", ""),
avail_on_nodes=data.get("avail_on_nodes", []), # Provide default empty list
description=data.get("description", ""),
tags=data.get("tags", None),
whitelist=data.get("whitelist", None)
)
# --- System class --- # --- System class ---
class SystemDbController(): class SystemDbController():
def __init__(self): def __init__(self):

109
app/internal/types.py Normal file
View File

@@ -0,0 +1,109 @@
from typing import Optional, List, Dict, Any
from enum import Enum
class DemodTypes(str, Enum):
P25 = "P25"
DMR = "DMR"
ANALOG = "NBFM"
class TalkgroupTag:
"""Represents a talkgroup tag."""
def __init__(self, talkgroup: str, tagDec: int):
self.talkgroup = talkgroup
self.tagDec = tagDec
# Add a method to convert to a dictionary, useful for sending as JSON
def to_dict(self) -> Dict[str, Any]:
return {"talkgroup": self.talkgroup, "tagDec": self.tagDec}
class System:
"""
A basic data model for a channel/system entry in a radio system.
"""
def __init__(self,
_id: str,
_type: DemodTypes,
name: str,
frequency_khz: List[str],
location: str,
avail_on_nodes: List[str],
description: Optional[str] = "",
tags: Optional[List[TalkgroupTag]] = None,
whitelist: Optional[List[int]] = None):
"""
Initializes a System object.
Args:
_id: A unique identifier for the entry (e.g., MongoDB ObjectId string).
_type: The demodulation type (P25, NBFM, etc.).
name: The name of the channel/system.
frequency_khz: The frequency in kilohertz.
location: The geographical location or coverage area.
avail_on_nodes: A list of node identifiers where this is available.
description: A brief description.
"""
self._id: str = _id
self.type: DemodTypes = _type
self.name: str = name
self.frequency_khz: List[int] = frequency_khz
self.location: str = location
self.avail_on_nodes: List[str] = avail_on_nodes
self.description: str = description or ""
self.tags: List[TalkgroupTag] = tags or None
self.whitelist: List[int] = whitelist or None
def __repr__(self) -> str:
"""
Provides a developer-friendly string representation of the object.
"""
# Use self.type.value for string representation of the enum
return (f"System(_id='{self._id}', type='{self.type.value}', name='{self.name}', "
f"frequency_khz={self.frequency_khz}, location='{self.location}', "
f"avail_on_nodes={self.avail_on_nodes}, description='{self.description}',"
f" tags='{self.tags}', whitelist='{self.whitelist}')")
def to_dict(self) -> Dict[str, Any]:
"""
Converts the System object to a dictionary suitable for MongoDB.
Converts the DemodTypes enum to its string value.
"""
return {
"_id": self._id,
"type": self.type.value, # Store the enum value (string)
"name": self.name,
"frequency_khz": self.frequency_khz,
"location": self.location,
"avail_on_nodes": self.avail_on_nodes,
"description": self.description,
"tags": self.tags,
"whitelist": self.whitelist,
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "System":
"""
Creates a System object from a dictionary (e.g., from MongoDB).
Converts the 'type' string back to a DemodTypes enum member.
"""
# Ensure required keys exist and handle potential type mismatches if necessary
# Convert the type string back to the DemodTypes enum
system_type = DemodTypes(data.get("type")) if data.get("type") else None # Handle missing or invalid type
if system_type is None:
# Handle error: could raise an exception or return None/default
# For this example, let's raise an error if type is missing/invalid
raise ValueError(f"Invalid or missing 'type' in document data: {data}")
return cls(
_id=data.get("_id"),
_type=system_type,
name=data.get("name", ""), # Provide default empty string if name is missing
frequency_khz=data.get("frequency_khz", 0), # Provide default 0 if missing
location=data.get("location", ""),
avail_on_nodes=data.get("avail_on_nodes", []), # Provide default empty list
description=data.get("description", ""),
tags=data.get("tags", None),
whitelist=data.get("whitelist", None)
)

119
app/routers/nodes.py Normal file
View File

@@ -0,0 +1,119 @@
import json
from quart import Blueprint, jsonify, request, abort, current_app
from werkzeug.exceptions import HTTPException
from enum import Enum
nodes_bp = Blueprint('nodes', __name__)
class NodeCommands(str, Enum):
JOIN = "join_server"
LEAVE = "leave_server"
async def register_client(websocket, client_id):
"""Registers a new client connection."""
current_app.active_clients[client_id] = websocket
print(f"Client {client_id} connected.")
async def unregister_client(client_id):
"""Unregisters a disconnected client."""
if client_id in current_app.active_clients:
del current_app.active_clients[client_id]
print(f"Client {client_id} disconnected.")
async def send_command_to_client(client_id, command_name, *args):
"""Sends a command to a specific client."""
if client_id in current_app.active_clients:
websocket = current_app.active_clients[client_id]
message = json.dumps({"type": "command", "name": command_name, "args": args})
try:
await websocket.send(message)
print(f"Sent command '{command_name}' to client {client_id}")
except websockets.exceptions.ConnectionClosedError:
print(f"Failed to send to client {client_id}: connection closed.")
await unregister_client(client_id)
else:
print(f"Client {client_id} not found.")
async def send_command_to_all_clients(command_name, *args):
"""Sends a command to all connected clients."""
message = json.dumps({"type": "command", "name": command_name, "args": args})
# Use a list of items to avoid issues if clients disconnect during iteration
clients_to_send = list(current_app.active_clients.items())
for client_id, websocket in clients_to_send:
try:
await websocket.send(message)
print(f"Sent command '{command_name}' to client {client_id}")
except websockets.exceptions.ConnectionClosedError:
print(f"Failed to send to client {client_id}: connection closed.")
await unregister_client(client_id)
@nodes_bp.route("/", methods=['GET'])
async def get_nodes():
"""API endpoint to list currently connected client IDs."""
return jsonify(list(current_app.active_clients.keys()))
@nodes_bp.route("/join", methods=['POST'])
async def join():
"""
Send a join command to the specific system specified
"""
data = await request.get_json()
client_id = data.get("client_id")
system_id = data.get("system_id")
guild_id = data.get("guild_id")
channel_id = data.get("channel_id")
# Check to make sure the client is online
if client_id not in current_app.active_clients:
return jsonify({"error": f"Client {client_id} not found, it might be offline"}), 404
try:
args = [system_id, guild_id, channel_id]
if not isinstance(args, list):
return jsonify({"error": "'args' must be a list"}), 400
# Send the command asynchronously
await send_command_to_client(client_id, NodeCommands.JOIN, *args)
return jsonify({"status": "command sent", "client_id": client_id, "command": NodeCommands.JOIN}), 200
except Exception as e:
return jsonify({"error": f"Failed to send command: {e}"}), 500
@nodes_bp.route("/leave", methods=['POST'])
async def leave():
"""
Send a join command to the specific system specified
"""
data = await request.get_json()
client_id = data.get("client_id")
guild_id = data.get("guild_id")
# Check to make sure the client is online
if client_id not in current_app.active_clients:
return jsonify({"error": f"Client {client_id} not found, it might be offline"}), 404
try:
args = [guild_id]
if not isinstance(args, list):
return jsonify({"error": "'args' must be a list"}), 400
# Send the command asynchronously
await send_command_to_client(client_id, NodeCommands.LEAVE, *args)
return jsonify({"status": "command sent", "client_id": client_id, "command": NodeCommands.LEAVE}), 200
except Exception as e:
return jsonify({"error": f"Failed to send command: {e}"}), 500

View File

@@ -1,6 +1,7 @@
from quart import Blueprint, jsonify, request, abort from quart import Blueprint, jsonify, request, abort
from internal.db_wrappers import System, SystemDbController from internal.db_wrappers import SystemDbController
from werkzeug.exceptions import HTTPException from werkzeug.exceptions import HTTPException
from internal.types import System
systems_bp = Blueprint('systems', __name__) systems_bp = Blueprint('systems', __name__)
db_h = SystemDbController() db_h = SystemDbController()
@@ -257,3 +258,29 @@ async def dismiss_client_from_system_route(system_id: str):
except Exception as e: except Exception as e:
print(f"Error during system de-assignment: {e}") print(f"Error during system de-assignment: {e}")
abort(500, f"Internal server error: {e}") abort(500, f"Internal server error: {e}")
@systems_bp.route('/search', methods=['GET'])
async def search_systems_route():
"""
API endpoint to search for systems based on query parameters.
Allows searching by 'name', 'frequency_khz', or any other field present in the System model.
Example: /systems/search?name=MySystem&frequency_khz=1000
"""
print("\n--- Handling GET /systems/search ---")
try:
query_params = dict(request.args)
systems = await db_h.find_systems(query_params)
if systems:
# If systems are found, return them as a list of dictionaries
return jsonify([system.to_dict() for system in systems]), 200 # 200 OK
else:
# If no systems match the query, return 404 Not Found
return jsonify({"message": "No systems found matching the criteria"}), 404
except HTTPException:
raise
except Exception as e:
print(f"Error searching systems: {e}")
abort(500, f"Internal server error: {e}")

View File

@@ -4,48 +4,12 @@ import json
import uuid import uuid
from quart import Quart, jsonify, request from quart import Quart, jsonify, request
from routers.systems import systems_bp from routers.systems import systems_bp
from routers.nodes import nodes_bp, register_client, unregister_client
# --- WebSocket Server Components --- # --- WebSocket Server Components ---
# Dictionary to store active clients: {client_id: websocket} # Dictionary to store active clients: {client_id: websocket}
active_clients = {} active_clients = {}
async def register_client(websocket, client_id):
"""Registers a new client connection."""
active_clients[client_id] = websocket
print(f"Client {client_id} connected.")
async def unregister_client(client_id):
"""Unregisters a disconnected client."""
if client_id in active_clients:
del active_clients[client_id]
print(f"Client {client_id} disconnected.")
async def send_command_to_client(client_id, command_name, *args):
"""Sends a command to a specific client."""
if client_id in active_clients:
websocket = active_clients[client_id]
message = json.dumps({"type": "command", "name": command_name, "args": args})
try:
await websocket.send(message)
print(f"Sent command '{command_name}' to client {client_id}")
except websockets.exceptions.ConnectionClosedError:
print(f"Failed to send to client {client_id}: connection closed.")
await unregister_client(client_id)
else:
print(f"Client {client_id} not found.")
async def send_command_to_all_clients(command_name, *args):
"""Sends a command to all connected clients."""
message = json.dumps({"type": "command", "name": command_name, "args": args})
# Use a list of items to avoid issues if clients disconnect during iteration
clients_to_send = list(active_clients.items())
for client_id, websocket in clients_to_send:
try:
await websocket.send(message)
print(f"Sent command '{command_name}' to client {client_id}")
except websockets.exceptions.ConnectionClosedError:
print(f"Failed to send to client {client_id}: connection closed.")
await unregister_client(client_id)
async def websocket_server_handler(websocket): async def websocket_server_handler(websocket):
"""Handles incoming WebSocket connections and messages from clients.""" """Handles incoming WebSocket connections and messages from clients."""
@@ -79,40 +43,15 @@ async def websocket_server_handler(websocket):
if client_id: if client_id:
await unregister_client(client_id) await unregister_client(client_id)
# --- Radio Channel Data (Placeholder) ---
# In a real app, this would likely come from a database
channels = {
"channel_1": {
"id": "channel_1",
"name": "Local News Radio",
"frequency_list_khz": ["98500"],
"decode_mode": "P25",
"location": "Cityville",
"tags": [],
"tag_whitelist": [1,2,3],
"avail_on_nodes": ["client-abc123", "client-def456"], # List of client IDs that can tune this channel
"description": "Your source for local news and weather."
},
"channel_2": {
"id": "channel_2",
"name": "Music Mix FM",
"frequency_list_khz": [101300],
"decode_mode": "P25",
"location": "Townsville",
"tags": [],
"tag_whitelist": [6,7,8],
"avail_on_nodes": ["client-def456", "client-ghi789"],
"description": "Playing the hits, all day long."
}
# Add more channels here
}
# --- Quart API Components --- # --- Quart API Components ---
app = Quart(__name__) app = Quart(__name__)
# Store the websocket server instance # Store the websocket server instance
websocket_server_instance = None websocket_server_instance = None
# Make active_clients accessible via the app instance.
app.active_clients = active_clients
@app.before_serving @app.before_serving
async def startup_websocket_server(): async def startup_websocket_server():
"""Starts the WebSocket server when the Quart app starts.""" """Starts the WebSocket server when the Quart app starts."""
@@ -139,16 +78,12 @@ async def shutdown_websocket_server():
app.register_blueprint(systems_bp, url_prefix="/systems") app.register_blueprint(systems_bp, url_prefix="/systems")
app.register_blueprint(nodes_bp, url_prefix="/nodes")
@app.route('/') @app.route('/')
async def index(): async def index():
return "Welcome to the Radio App Server API!" return "Welcome to the Radio App Server API!"
@app.route('/clients', methods=['GET'])
async def list_clients():
"""API endpoint to list currently connected client IDs."""
return jsonify(list(active_clients.keys()))
@app.route('/request_token', methods=['POST']) @app.route('/request_token', methods=['POST'])
async def request_token(): async def request_token():
"""API endpoint to list currently connected client IDs.""" """API endpoint to list currently connected client IDs."""
@@ -157,36 +92,6 @@ async def request_token():
"token": "MTE5NjAwNTM2ODYzNjExMjk3Nw.GuCMXg.24iNNofNNumq46FIj68zMe9RmQgugAgfrvelEA" "token": "MTE5NjAwNTM2ODYzNjExMjk3Nw.GuCMXg.24iNNofNNumq46FIj68zMe9RmQgugAgfrvelEA"
}) })
# --- Example API endpoint to trigger a WebSocket command ---
# This is a simple example. More complex logic might be needed
# to validate commands or arguments before sending.
@app.route('/command/<client_id>/<command_name>', methods=['POST'])
async def api_send_command(client_id, command_name):
"""
API endpoint to send a command to a specific client via WebSocket.
Expects JSON body with 'args': [...]
e.g., POST to /command/client-abc123/print_message with body {"args": ["Hello!"]}
"""
if client_id not in active_clients:
return jsonify({"error": f"Client {client_id} not found"}), 404
# if command_name not in ["join_server", "leave_server", "set_status", "run_task"]: # Basic validation
# return jsonify({"error": f"Unknown command: {command_name}"}), 400
try:
request_data = await request.get_json()
args = request_data.get("args", [])
if not isinstance(args, list):
return jsonify({"error": "'args' must be a list"}), 400
# Send the command asynchronously
await send_command_to_client(client_id, command_name, *args)
return jsonify({"status": "command sent", "client_id": client_id, "command": command_name}), 200
except Exception as e:
return jsonify({"error": f"Failed to send command: {e}"}), 500
# --- Main Execution --- # --- Main Execution ---
if __name__ == "__main__": if __name__ == "__main__":
# Quart's app.run() will start the asyncio event loop and manage it. # Quart's app.run() will start the asyncio event loop and manage it.