diff --git a/freedata_server/api/__init__.py b/freedata_server/api/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/freedata_server/api/command_helpers.py b/freedata_server/api/command_helpers.py new file mode 100644 index 000000000..238766b1b --- /dev/null +++ b/freedata_server/api/command_helpers.py @@ -0,0 +1,27 @@ +# api/command_helpers.py +import asyncio + + +async def enqueue_tx_command(app, cmd_class, params={}): + """ + Enqueue a transmit command using the app's managers. + + Args: + app: The FastAPI app instance (e.g., request.app) containing config_manager, state_manager, etc. + cmd_class: The command class to instantiate and run. + params: A dict of parameters for the command. + + Returns: + True if the command was successfully enqueued and ran, False otherwise. + """ + try: + # Create an instance of the command using app components. + command = cmd_class(app.config_manager.read(), app.state_manager, app.event_manager, params) + print(f"Command {command.get_name()} running...") + # Run the command in a separate thread to avoid blocking the event loop. + result = await asyncio.to_thread(command.run, app.modem_events, app.service_manager.modem) + if result: + return True + except Exception as e: + print(f"Command failed: {e}") + return False diff --git a/freedata_server/api/common.py b/freedata_server/api/common.py new file mode 100644 index 000000000..303a02e2a --- /dev/null +++ b/freedata_server/api/common.py @@ -0,0 +1,25 @@ +from fastapi import HTTPException +from fastapi.responses import JSONResponse + +# Returns a standard API response +def api_response(data, status=200): + return JSONResponse(content=data, status_code=status) + + +def api_abort(message, code): + raise HTTPException(status_code=code, detail={"error": message}) + + +def api_ok(message="ok"): + return api_response({'message': message}) + + +# Validates a parameter +def validate(req, param, validator, is_required=True): + if param not in req: + if is_required: + api_abort(f"Required parameter '{param}' is missing.", 400) + else: + return True + if not validator(req[param]): + api_abort(f"Value of '{param}' is invalid.", 400) diff --git a/freedata_server/api/config.py b/freedata_server/api/config.py new file mode 100644 index 000000000..ca70ced90 --- /dev/null +++ b/freedata_server/api/config.py @@ -0,0 +1,167 @@ +from fastapi import APIRouter, Request +from api.common import api_response, api_abort, api_ok, validate +import api_validations as validations +router = APIRouter() + +@router.get("/", summary="Get Modem Configuration", tags=["Configuration"], responses={ + 200: { + "description": "Current modem configuration settings.", + "content": { + "application/json": { + "example": { + "AUDIO": { + "input_device": "2fc0", + "output_device": "3655", + "rx_audio_level": 0, + "tx_audio_level": 2 + }, + "MESSAGES": { + "enable_auto_repeat": True + }, + "MODEM": { + "enable_hmac": False, + "enable_morse_identifier": False, + "enable_socket_interface": False, + "maximum_bandwidth": 2438, + "tx_delay": 200 + }, + "NETWORK": { + "modemaddress": "", + "modemport": 5000 + }, + "RADIO": { + "control": "rigctld", + "data_bits": 8, + "model_id": 1001, + "ptt_port": "ignore", + "ptt_type": "USB", + "serial_dcd": "NONE", + "serial_dtr": "OFF", + "serial_handshake": "ignore", + "serial_port": "/dev/cu.Bluetooth-Incoming-Port", + "serial_speed": 38400, + "stop_bits": 1 + }, + "RIGCTLD": { + "arguments": "", + "command": "", + "ip": "127.0.0.1", + "path": "", + "port": 4532 + }, + "SOCKET_INTERFACE": { + "cmd_port": 0, + "data_port": 0, + "enable": False, + "host": "" + }, + "STATION": { + "enable_explorer": True, + "enable_stats": True, + "mycall": "LA3QMA", + "mygrid": "JP20ql", + "myssid": 0, + "ssid_list": [0,1,2,3,4,5,6,7,8,9], + "respond_to_cq": True, + }, + "TCI": { + "tci_ip": "127.0.0.1", + "tci_port": 50001 + } + } + } + } + }, + 404: { + "description": "The requested resource was not found.", + "content": { + "application/json": { + "example": { + "error": "Resource not found." + } + } + } + } +}) +async def get_config(request: Request): + """ + Retrieve the current modem configuration. + + Returns: + dict: The modem configuration settings. + """ + return request.app.config_manager.read() + + +@router.post("/", summary="Update Modem Configuration", tags=["Configuration"], responses={ + 200: { + "description": "Modem configuration updated successfully.", + "content": { + "application/json": { + "example": { + "AUDIO": { + "input_device": "2fc0", + "output_device": "3655", + "rx_audio_level": 0, + "tx_audio_level": 2 + }, + # ... + } + } + } + }, + 400: { + "description": "Invalid configuration data.", + "content": { + "application/json": { + "example": { + "error": "Invalid config" + } + } + } + }, + 500: { + "description": "Error writing configuration.", + "content": { + "application/json": { + "example": { + "error": "Error writing config" + } + } + } + }, + 404: { + "description": "The requested resource was not found.", + "content": { + "application/json": { + "example": { + "error": "Resource not found." + } + } + } + } +}) +async def post_config(request: Request): + """ + Update the modem configuration with new settings. + + Parameters: + request (Request): The HTTP request containing the new configuration in JSON format. + + Returns: + dict: The updated modem configuration. + + Raises: + HTTPException: If the provided configuration is invalid or an error occurs while writing the config. + """ + config = await request.json() + print(config) + if not validations.validate_remote_config(config): + api_abort("Invalid config", 400) + if request.app.config_manager.read() == config: + return config + set_config = request.app.config_manager.write(config) + if not set_config: + api_abort("Error writing config", 500) + request.app.modem_service.put("restart") + return set_config diff --git a/freedata_server/api/devices.py b/freedata_server/api/devices.py new file mode 100644 index 000000000..10c9d4643 --- /dev/null +++ b/freedata_server/api/devices.py @@ -0,0 +1,147 @@ +from fastapi import APIRouter, Request +from api.common import api_response, api_abort, api_ok, validate +router = APIRouter() +import audio +import serial_ports + + +@router.get("/audio", summary="Get Audio Devices", tags=["Devices"], responses={ + 200: { + "description": "List of available audio input and output devices.", + "content": { + "application/json": { + "example": { + "in": [ + { + "api": "ALSA", + "id": "8eb1", + "name": "pipewire", + "native_index": 4 + }, + { + "api": "ALSA", + "id": "8e7a", + "name": "default", + "native_index": 5 + } + ], + "out": [ + { + "api": "ALSA", + "id": "ae79", + "name": "HDA Intel HDMI: 0 (hw:0,3)", + "native_index": 0 + }, + { + "api": "ALSA", + "id": "67fd", + "name": "HDA Intel HDMI: 1 (hw:0,7)", + "native_index": 1 + }, + { + "api": "ALSA", + "id": "b68c", + "name": "HDA Intel HDMI: 2 (hw:0,8)", + "native_index": 2 + }, + { + "api": "ALSA", + "id": "ba84", + "name": "hdmi", + "native_index": 3 + }, + { + "api": "ALSA", + "id": "8eb1", + "name": "pipewire", + "native_index": 4 + }, + { + "api": "ALSA", + "id": "8e7a", + "name": "default", + "native_index": 5 + } + ] + } + } + } + }, + 404: { + "description": "The requested resource was not found.", + "content": { + "application/json": { + "example": { + "error": "Resource not found." + } + } + } + }, + 503: { + "description": "Modem not running.", + "content": { + "application/json": { + "example": { + "error": "Modem not running." + } + } + } + } +}) +async def get_audio_devices(): + """ + Retrieve a list of available audio input and output devices. + + Returns: + dict: A JSON object containing lists of input and output audio devices. + """ + # Uncomment the following line if using the actual function + # dev_in, dev_out = audio.get_audio_devices() + dev_in, dev_out = audio.fetch_audio_devices([], []) + return {'in': dev_in, 'out': dev_out} + + +@router.get("/serial", summary="Get Serial Devices", tags=["Devices"], responses={ + 200: { + "description": "List of available serial devices (COM ports).", + "content": { + "application/json": { + "example": [ + { + "description": "n/a [26a9]", + "port": "/dev/ttyS4" + } + ] + } + } + }, + 404: { + "description": "The requested resource was not found.", + "content": { + "application/json": { + "example": { + "error": "Resource not found." + } + } + } + }, + 503: { + "description": "Modem not running.", + "content": { + "application/json": { + "example": { + "error": "Modem not running." + } + } + } + } +}) +async def get_serial_devices(): + """ + Retrieve a list of available serial devices (COM ports). + + Returns: + list: A list of dictionaries containing serial port information. + """ + devices = serial_ports.get_ports() + return devices diff --git a/freedata_server/api/freedata.py b/freedata_server/api/freedata.py new file mode 100644 index 000000000..ab6a12940 --- /dev/null +++ b/freedata_server/api/freedata.py @@ -0,0 +1,712 @@ +from fastapi import APIRouter, Request +from api.common import api_response, api_abort, api_ok, validate +from api.command_helpers import enqueue_tx_command +from message_system_db_messages import DatabaseManagerMessages +from message_system_db_attachments import DatabaseManagerAttachments +from message_system_db_beacon import DatabaseManagerBeacon +from message_system_db_station import DatabaseManagerStations +import command_message_send +import adif_udp_logger +import wavelog_api_logger + + +router = APIRouter() + + +@router.get("/messages/{message_id}", summary="Get Message by ID", tags=["FreeDATA"], responses={ + 200: {"description": "Message found and returned."}, + 404: {"description": "Message not found."} +}) +async def get_freedata_message(message_id: str, request: Request): + message = DatabaseManagerMessages(request.app.event_manager).get_message_by_id_json(message_id) + return api_response(message) + + +@router.post("/messages", summary="Transmit Message", tags=["FreeDATA"], responses={ + 200: { + "description": "Message transmitted successfully.", + "content": { + "application/json": { + "example": { + "destination": "XX1XXX-6", + "body": "Hello FreeDATA" + } + } + } + }, + 404: { + "description": "The requested resource was not found.", + "content": { + "application/json": { + "example": { + "error": "Resource not found." + } + } + } + }, + 503: { + "description": "Modem not running or busy.", + "content": { + "application/json": { + "example": { + "error": "Modem not running." + } + } + } + } +}) +async def post_freedata_message(request: Request): + """ + Transmit a FreeDATA message. + + Parameters: + request (Request): The HTTP request containing the message data in JSON format. + + Returns: + dict: A JSON object containing the transmitted message details. + """ + data = await request.json() + await enqueue_tx_command(request.app, command_message_send.SendMessageCommand, data) + return api_response(data) + +@router.post("/messages/{message_id}/adif", summary="Send Message ADIF Log", tags=["FreeDATA"], responses={ + 200: { + "description": "ADIF log sent successfully.", + "content": { + "application/json": { + "example": { + "adif_output": "ADIF data..." + } + } + } + }, + 400: { + "description": "Bad Request: The request was malformed or missing required parameters.", + "content": { + "application/json": { + "example": { + "error": "Invalid message ID." + } + } + } + }, + 404: { + "description": "The requested resource was not found.", + "content": { + "application/json": { + "example": { + "error": "Message not found." + } + } + } + }, + 503: { + "description": "Modem not running.", + "content": { + "application/json": { + "example": { + "error": "Modem not running." + } + } + } + } +}) +async def post_freedata_message_adif_log(message_id: str, request:Request): + adif_output = DatabaseManagerMessages(request.app.event_manager).get_message_by_id_adif(message_id) + + # if message not found do not send adif as the return then is not valid + if not adif_output: + return + + # Send the ADIF data via UDP + adif_udp_logger.send_adif_qso_data(request.app.config_manager.read(), adif_output) + wavelog_api_logger.send_wavelog_qso_data(request.app.config_manager.read(), adif_output) + return api_response(adif_output) + +@router.patch("/messages/{message_id}", summary="Update Message by ID", tags=["FreeDATA"], responses={ + 200: { + "description": "Message updated successfully.", + "content": { + "application/json": { + "example": { + "is_read": True + } + } + } + }, + 400: { + "description": "Bad Request: The request was malformed or missing required parameters.", + "content": { + "application/json": { + "example": { + "error": "Invalid parameters." + } + } + } + }, + 404: { + "description": "Message not found.", + "content": { + "application/json": { + "example": { + "error": "Message not found." + } + } + } + } +}) +async def patch_freedata_message(message_id: str, request: Request): + """ + Update a FreeDATA message by its ID. + + Parameters: + message_id (str): The ID of the message to update. + request (Request): The HTTP request containing the update data in JSON format. + + Returns: + dict: A JSON object containing the updated message details. + """ + data = await request.json() + + if data.get("action") == "retransmit": + result = DatabaseManagerMessages(request.app.event_manager).update_message(message_id, update_data={'status': 'queued'}) + DatabaseManagerMessages(request.app.event_manager).increment_message_attempts(message_id) + else: + result = DatabaseManagerMessages(request.app.event_manager).update_message(message_id, update_data=data) + + return api_response(result) + + +@router.get("/messages", summary="Get All Messages", tags=["FreeDATA"], responses={ + 200: { + "description": "List of all messages.", + "content": { + "application/json": { + "example": { + "total_messages": 1, + "messages": [ + { + "id": "DXCALL-6_MYCALL-0_2024-04-12T20:39:05.302479", + "timestamp": "2024-04-12T20:39:05.302479", + "origin": "DXCALL-6", + "via": None, + "destination": "MYCALL-0", + "direction": "receive", + "body": "Hello !", + "attachments": [], + "status": "received", + "priority": 10, + "is_read": False, + "statistics": { + "total_bytes": 120, + "duration": 29.76698660850525, + "bytes_per_minute": 241, + "time_histogram": { + "0": "2024-04-12T20:39:23.423169", + "1": "2024-04-12T20:39:30.504638", + "2": "2024-04-12T20:39:37.745075" + }, + "snr_histogram": { + "0": -6, + "1": -6, + "2": -6 + }, + "bpm_histogram": { + "0": 198, + "1": 265, + "2": 252 + } + } + } + ] + } + } + } + }, + 404: { + "description": "The requested resource was not found.", + "content": { + "application/json": { + "example": { + "error": "Resource not found." + } + } + } + } +}) +async def get_freedata_messages(request: Request): + filters = {k: v for k, v in request.query_params.items() if v} + result = DatabaseManagerMessages(request.app.event_manager).get_all_messages_json(filters=filters) + return api_response(result) + + +@router.post("/messages", summary="Transmit Message", tags=["FreeDATA"], responses={ + 200: { + "description": "Message transmitted successfully.", + "content": { + "application/json": { + "example": { + "destination": "XX1XXX-6", + "body": "Hello FreeDATA" + } + } + } + }, + 404: { + "description": "The requested resource was not found.", + "content": { + "application/json": { + "example": { + "error": "Resource not found." + } + } + } + } +}) +async def post_freedata_message(request: Request): + """ + Transmit a FreeDATA message. + + Parameters: + request (Request): The HTTP request containing the message data in JSON format. + + Returns: + dict: A JSON object containing the transmitted message details. + """ + data = await request.json() + await enqueue_tx_command(request.app, command_message_send.SendMessageCommand, data) + return api_response(data) + + + +@router.delete("/messages/{message_id}", summary="Delete Message by ID", tags=["FreeDATA"], responses={ + 200: { + "description": "Message deleted successfully.", + "content": { + "application/json": { + "example": { + "message": "DXCALL-0_MYCALL-5_2024-04-04T17:22:14.002502 deleted", + "status": "success" + } + } + } + }, + 404: { + "description": "Message not found.", + "content": { + "application/json": { + "example": { + "message": "Message not found", + "status": "failure" + } + } + } + } +}) +async def delete_freedata_message(message_id: str, request:Request): + result = DatabaseManagerMessages(request.app.event_manager).delete_message(message_id) + if result: + return api_response({"message": f"{message_id} deleted", "status": "success"}) + else: + return api_response({"message": "Message not found", "status": "failure"}, status_code=404) + + +@router.get("/messages/{message_id}/attachments", summary="Get Attachments by Message ID", tags=["FreeDATA"], responses={ + 200: { + "description": "List of attachments for the specified message.", + "content": { + "application/json": { + "example": { + "attachments": [ + { + "id": "attachment1", + "filename": "file1.txt", + "file_size": 1024, + "file_type": "text/plain", + "data_sha512": "abcdef1234567890..." + }, + { + "id": "attachment2", + "filename": "image.png", + "file_size": 2048, + "file_type": "image/png", + "data_sha512": "123456abcdef7890..." + } + ] + } + } + } + }, + 404: { + "description": "The requested resource was not found.", + "content": { + "application/json": { + "example": { + "error": "Resource not found." + } + } + } + }, + 503: { + "description": "Modem not running.", + "content": { + "application/json": { + "example": { + "error": "Modem not running." + } + } + } + } +}) +async def get_message_attachments(message_id: str, request:Request): + attachments = DatabaseManagerAttachments(request.app.event_manager).get_attachments_by_message_id_json(message_id) + return api_response(attachments) + + +@router.get("/messages/attachment/{data_sha512}", summary="Get Attachment by SHA512", tags=["FreeDATA"], responses={ + 200: { + "description": "Retrieve a specific attachment by its SHA512 hash.", + "content": { + "application/json": { + "example": { + "id": "attachment1", + "filename": "file1.txt", + "file_size": 1024, + "file_type": "text/plain", + "data_sha512": "abcdef1234567890..." + } + } + } + }, + 404: { + "description": "The requested attachment was not found.", + "content": { + "application/json": { + "example": { + "error": "Attachment not found." + } + } + } + }, + 503: { + "description": "Modem not running.", + "content": { + "application/json": { + "example": { + "error": "Modem not running." + } + } + } + } +}) +async def get_message_attachment(data_sha512: str, request:Request): + attachment = DatabaseManagerAttachments(request.app.event_manager).get_attachment_by_sha512(data_sha512) + return api_response(attachment) + + +@router.get("/beacons", summary="Get Received Beacons", tags=["FreeDATA"], responses={ + 200: { + "description": "List of received beacons.", + "content": { + "application/json": { + "example": { + "total_beacons": 2, + "beacons": [ + { + "id": "DXCALL-0_MYCALL-5_2024-04-04T17:22:14.002502", + "timestamp": "2024-04-04T17:22:14.002502", + "origin": "DXCALL-0", + "via": None, + "destination": "MYCALL-5", + "direction": "receive", + "body": "Hello FreeDATA", + "attachments": [], + "status": "received", + "priority": 10, + "is_read": False, + "statistics": { + "total_bytes": 120, + "duration": 29.77, + "bytes_per_minute": 241, + "time_histogram": { + "0": "2024-04-04T17:22:23.423169", + "1": "2024-04-04T17:22:30.504638", + "2": "2024-04-04T17:22:37.745075" + }, + "snr_histogram": { + "0": -6, + "1": -6, + "2": -6 + }, + "bpm_histogram": { + "0": 198, + "1": 265, + "2": 252 + } + } + } + ] + } + } + } + }, + 400: { + "description": "Bad Request: The request was malformed or missing required parameters.", + "content": { + "application/json": { + "example": { + "error": "Invalid request." + } + } + } + }, + 404: { + "description": "The requested resource was not found.", + "content": { + "application/json": { + "example": { + "error": "Resource not found." + } + } + } + }, + 500: { + "description": "Internal Server Error: An unexpected error occurred on the server.", + "content": { + "application/json": { + "example": { + "error": "Internal server error." + } + } + } + }, + 503: { + "description": "Modem not running.", + "content": { + "application/json": { + "example": { + "error": "Modem not running." + } + } + } + } +}) +async def get_all_beacons(request:Request): + beacons = DatabaseManagerBeacon(request.app.event_manager).get_all_beacons() + return api_response(beacons) + + +@router.get("/beacons/{callsign}", summary="Get Beacon by Callsign", tags=["FreeDATA"], responses={ + 200: { + "description": "List of beacons from the specified callsign.", + "content": { + "application/json": { + "example": { + "beacons": [ + { + "id": "DXCALL-0_MYCALL-5_2024-04-04T17:22:14.002502", + "timestamp": "2024-04-04T17:22:14.002502", + "origin": "DXCALL-0", + "via": None, + "destination": "MYCALL-5", + "direction": "receive", + "body": "Hello FreeDATA", + "attachments": [], + "status": "received", + "priority": 10, + "is_read": False, + "statistics": { + "total_bytes": 120, + "duration": 29.77, + "bytes_per_minute": 241, + "time_histogram": { + "0": "2024-04-04T17:22:23.423169", + "1": "2024-04-04T17:22:30.504638", + "2": "2024-04-04T17:22:37.745075" + }, + "snr_histogram": { + "0": -6, + "1": -6, + "2": -6 + }, + "bpm_histogram": { + "0": 198, + "1": 265, + "2": 252 + } + } + } + ] + } + } + } + }, + 400: { + "description": "Bad Request: The request was malformed or missing required parameters.", + "content": { + "application/json": { + "example": { + "error": "Invalid request." + } + } + } + }, + 404: { + "description": "The requested resource was not found.", + "content": { + "application/json": { + "example": { + "error": "Resource not found." + } + } + } + }, + 500: { + "description": "Internal Server Error: An unexpected error occurred on the server.", + "content": { + "application/json": { + "example": { + "error": "Internal server error." + } + } + } + }, + 503: { + "description": "Modem not running.", + "content": { + "application/json": { + "example": { + "error": "Modem not running." + } + } + } + } +}) +async def get_beacons_by_callsign(callsign: str, request:Request): + beacons = DatabaseManagerBeacon(request.app.event_manager).get_beacons_by_callsign(callsign) + return api_response(beacons) + + +@router.get("/station/{callsign}", summary="Get Station Info", tags=["FreeDATA"], responses={ + 200: { + "description": "Retrieve station information by callsign.", + "content": { + "application/json": { + "example": { + "callsign": "MYCALL-0", + "location": "Springfield", + "frequency": "14093000", + "mode": "PKTUSB", + "status": "active", + "additional_info": "Station details here." + } + } + } + }, + 400: { + "description": "Bad Request: The request was malformed or missing required parameters.", + "content": { + "application/json": { + "example": { + "error": "Invalid callsign parameter." + } + } + } + }, + 404: { + "description": "The requested station was not found.", + "content": { + "application/json": { + "example": { + "error": "Station not found." + } + } + } + }, + 500: { + "description": "Internal Server Error: An unexpected error occurred on the server.", + "content": { + "application/json": { + "example": { + "error": "Internal server error." + } + } + } + }, + 503: { + "description": "Modem not running.", + "content": { + "application/json": { + "example": { + "error": "Modem not running." + } + } + } + } +}) +async def get_station_info(callsign: str, request: Request): + station = DatabaseManagerStations(request.app.event_manager).get_station(callsign) + return api_response(station) + + +@router.post("/station/{callsign}", summary="Set Station Info", tags=["FreeDATA"], responses={ + 200: { + "description": "Station information updated successfully.", + "content": { + "application/json": { + "example": { + "callsign": "MYCALL-0", + "location": "Springfield", + "frequency": "14093000", + "mode": "PKTUSB", + "status": "active", + "additional_info": "Updated station details." + } + } + } + }, + 400: { + "description": "Bad Request: The request was malformed or missing required parameters.", + "content": { + "application/json": { + "example": { + "error": "Invalid input data." + } + } + } + }, + 404: { + "description": "The requested station was not found.", + "content": { + "application/json": { + "example": { + "error": "Station not found." + } + } + } + }, + 500: { + "description": "Internal Server Error: An unexpected error occurred on the server.", + "content": { + "application/json": { + "example": { + "error": "Internal server error." + } + } + } + }, + 503: { + "description": "Modem not running.", + "content": { + "application/json": { + "example": { + "error": "Modem not running." + } + } + } + } +}) +async def set_station_info(callsign: str, request: Request): + data = await request.json() + result = DatabaseManagerStations(request.app.event_manager).update_station_info(callsign, new_info=data["info"]) + return api_response(result) \ No newline at end of file diff --git a/freedata_server/api/general.py b/freedata_server/api/general.py new file mode 100644 index 000000000..944fd8660 --- /dev/null +++ b/freedata_server/api/general.py @@ -0,0 +1,123 @@ +import platform + +from fastapi import APIRouter, Request +import platform + + + + +router = APIRouter() + +@router.get("/", summary="API Root", tags=["General"], responses={ + 200: { + "description": "API information.", + "content": { + "application/json": { + "example": { + "name": "FreeDATA API", + "description": "A sample API that provides free data services", + "api_version": 3, + "modem_version": "0.16.8-alpha", + "license": "GPL3.0", + "documentation": "https://wiki.freedata.app" + } + } + } + }, + 404: { + "description": "The requested resource was not found.", + "content": { + "application/json": { + "example": { + "error": "Resource not found." + } + } + } + }, + 503: { + "description": "Service unavailable.", + "content": { + "application/json": { + "example": { + "error": "Service unavailable." + } + } + } + } +}) +async def index(request: Request): + """ + Retrieve API metadata. + + Returns: + dict: A JSON object containing API metadata. + """ + return { + 'name': 'FreeDATA API', + 'description': 'A sample API that provides free data services', + 'api_version': request.app.API_VERSION, + 'modem_version': request.app.MODEM_VERSION, + 'license': "GPL3.0", + 'documentation': "https://wiki.freedata.app", + } + +@router.get("/version", summary="Get Modem Version", tags=["General"], responses={ + 200: { + "description": "Successful Response", + "content": { + "application/json": { + "example": { + "api_version": 3, + "modem_version": "0.16.8-alpha", + "os_info": { + "system": "Linux", + "node": "my-node", + "release": "5.4.0-74-generic", + "version": "#83-Ubuntu SMP Mon May 10 16:30:51 UTC 2021", + "machine": "x86_64", + "processor": "x86_64" + }, + "python_info": { + "build": ["default", "May 3 2021 19:12:05"], + "compiler": "GCC 9.3.0", + "branch": "", + "implementation": "CPython", + "revision": "", + "version": "3.8.5" + } + } + } + } + } +}) +async def get_modem_version(request: Request): + """ + Retrieve the modem version, API version, OS information, and Python information. + + Returns: + dict: A JSON object containing version information. + """ + os_info = { + 'system': platform.system(), + 'node': platform.node(), + 'release': platform.release(), + 'version': platform.version(), + 'machine': platform.machine(), + 'processor': platform.processor(), + } + + python_info = { + 'build': platform.python_build(), + 'compiler': platform.python_compiler(), + 'branch': platform.python_branch(), + 'implementation': platform.python_implementation(), + 'revision': platform.python_revision(), + 'version': platform.python_version() + } + + return { + 'api_version': request.app.API_VERSION, + 'modem_version': request.app.MODEM_VERSION, + 'os_info': os_info, + 'python_info': python_info + } diff --git a/freedata_server/api/modem.py b/freedata_server/api/modem.py new file mode 100644 index 000000000..594d56856 --- /dev/null +++ b/freedata_server/api/modem.py @@ -0,0 +1,810 @@ +from fastapi import APIRouter, Request +from api.common import api_response, api_abort, api_ok, validate +from api.command_helpers import enqueue_tx_command +import command_cq +import command_beacon +import command_ping +import command_fec +import command_test +import command_arq_raw +import api_validations as validations + +router = APIRouter() + + +@router.get("/state", summary="Get Modem State", tags=["Modem"], responses={ + 200: { + "description": "Current modem state information.", + "content": { + "application/json": { + "example": { + "activities": { + "161dd75ef3b5847a": { + "activity_type": "ARQ_BURST_ACK", + "direction": "received", + "frequency": "14093000", + "frequency_offset": 0, + "session_id": 105, + "snr": 4, + "timestamp": 1713034266 + }, + "168e90799d13b7b4": { + "activity_type": "ARQ_SESSION_INFO_ACK", + "direction": "received", + "frequency": "14093000", + "frequency_offset": 0, + "session_id": 105, + "snr": -3, + "timestamp": 1713034248 + }, + "2218b849e937d36d": { + "activity_type": "QRV", + "direction": "received", + "frequency": "14093000", + "frequency_offset": 0, + "gridsquare": "JP15OW", + "origin": "SOMECALL-1", + "snr": 2, + "timestamp": 1713034200 + }, + "3fb424827f4632ab": { + "activity_type": "BEACON", + "direction": "received", + "frequency": "14093000", + "frequency_offset": 0, + "gridsquare": "JP22AI", + "origin": "CALLSIGN-1", + "snr": -8, + "timestamp": 1713034455 + }, + "743222d1dd64ce9d": { + "activity_type": "ARQ_SESSION_OPEN_ACK", + "direction": "received", + "frequency": "14093000", + "frequency_offset": 0, + "origin": "CALL-1", + "session_id": 105, + "snr": -2, + "timestamp": 1713034243 + }, + "7589edf6bf23ceed": { + "activity_type": "ARQ_BURST_ACK", + "direction": "received", + "frequency": "14093000", + "frequency_offset": 0, + "session_id": 105, + "snr": 2, + "timestamp": 1713034275 + }, + "9d2c5a98fe0f9894": { + "activity_type": "QRV", + "direction": "received", + "frequency": "14093000", + "frequency_offset": 0, + "gridsquare": "JP12AW", + "origin": "CALLME-1", + "snr": 5, + "timestamp": 1713034178 + }, + "f85609dced4ea40a": { + "activity_type": "ARQ_BURST_ACK", + "direction": "received", + "frequency": "14093000", + "frequency_offset": 0, + "session_id": 105, + "snr": 0, + "timestamp": 1713034257 + } + }, + "audio_dbfs": -7.915304862713354, + "channel_busy_slot": [False, False, True, False, False], + "is_away_from_key": False, + "is_beacon_running": True, + "is_modem_busy": False, + "is_modem_running": True, + "radio_frequency": "14093000", + "radio_mode": "PKTUSB", + "radio_status": True, + "s_meter_strength": "20", + "type": "state" + } + } + } + }, + 404: { + "description": "The requested resource was not found.", + "content": { + "application/json": { + "example": { + "error": "Resource not found." + } + } + } + } +}) +async def get_modem_state(request:Request): + """ + Retrieve the current state of the modem. + + Returns: + dict: A JSON object containing modem state information. + """ + return request.app.state_manager.sendState() + + +@router.post("/cqcqcq", summary="Send CQ Command", tags=["Modem"], responses={ + 200: { + "description": "CQ command sent successfully.", + "content": { + "application/json": { + "example": { + "message": "ok" + } + } + } + }, + 404: { + "description": "The requested resource was not found.", + "content": { + "application/json": { + "example": { + "error": "Resource not found." + } + } + } + }, + 503: { + "description": "Modem not running.", + "content": { + "application/json": { + "example": { + "error": "Modem not running." + } + } + } + } +}) +async def post_cqcqcq(request:Request): + """ + Trigger the modem to send a CQ. + + Returns: + dict: A JSON object indicating success. + + Raises: + HTTPException: If the modem is not running. + """ + if not request.app.state_manager.is_modem_running: + api_abort("Modem not running", 503) + await enqueue_tx_command(request.app, command_cq.CQCommand) + return api_ok() + + +@router.post("/beacon", summary="Enable/Disable Modem Beacon", tags=["Modem"], responses={ + 200: { + "description": "Beacon status updated successfully.", + "content": { + "application/json": { + "example": { + "enabled": True, + "away_from_key": False + } + } + } + }, + 400: { + "description": "Invalid input parameters.", + "content": { + "application/json": { + "example": { + "error": "Incorrect value for 'enabled' or 'away_from_key'. Should be bool." + } + } + } + }, + 404: { + "description": "The requested resource was not found.", + "content": { + "application/json": { + "example": { + "error": "Resource not found." + } + } + } + }, + 503: { + "description": "Modem not running.", + "content": { + "application/json": { + "example": { + "error": "Modem not running." + } + } + } + } +}) +async def post_beacon(request: Request): + """ + Enable or disable the modem beacon. + + Parameters: + request (Request): The HTTP request containing the following JSON keys: + - 'enabled' (bool): True to enable the beacon, False to disable. + - 'away_from_key' (bool): True if away from key, False otherwise. + + Returns: + dict: A JSON object indicating the beacon status. + + Raises: + HTTPException: If parameters are invalid or modem is not running. + """ + data = await request.json() + if not isinstance(data.get('enabled'), bool) or not isinstance(data.get('away_from_key'), bool): + api_abort("Incorrect value for 'enabled' or 'away_from_key'. Should be bool.", 400) + if not request.app.state_manager.is_modem_running: + api_abort("Modem not running", 503) + request.app.state_manager.set('is_beacon_running', data['enabled']) + request.app.state_manager.set('is_away_from_key', data['away_from_key']) + if not request.app.state_manager.getARQ() and data['enabled']: + await enqueue_tx_command(request.app, command_beacon.BeaconCommand, data) + return api_response({"enabled": data['enabled'], "away_from_key": data['away_from_key']}) + + +@router.post("/ping_ping", summary="Trigger Modem to PING a Station", tags=["Modem"], responses={ + 200: { + "description": "Ping command sent successfully.", + "content": { + "application/json": { + "example": { + "message": True + } + } + } + }, + 400: { + "description": "Invalid input parameters.", + "content": { + "application/json": { + "example": { + "error": "Invalid 'dxcall' parameter." + } + } + } + }, + 404: { + "description": "The requested resource was not found.", + "content": { + "application/json": { + "example": { + "error": "Resource not found." + } + } + } + }, + 503: { + "description": "Modem not running.", + "content": { + "application/json": { + "example": { + "error": "Modem not running." + } + } + } + } +}) +async def post_ping(request: Request): + """ + Trigger the modem to send a PING to a station. + + Parameters: + request (Request): The HTTP request containing the following JSON key: + - 'dxcall' (str): Callsign of the station to ping. + + Returns: + dict: A JSON object indicating success. + + Raises: + HTTPException: If parameters are invalid or modem is not running. + """ + data = await request.json() + if not request.app.state_manager.is_modem_running: + api_abort("Modem not running", 503) + dxcall = data.get('dxcall') + if not dxcall or not validations.validate_freedata_callsign(dxcall): + api_abort("Invalid 'dxcall' parameter.", 400) + await enqueue_tx_command(request.app, command_ping.PingCommand, data) + return api_response({"message": True}) + + +@router.post("/send_test_frame", summary="Send Test Frame", tags=["Modem"], responses={ + 200: { + "description": "Test frame sent successfully.", + "content": { + "application/json": { + "example": { + "message": "ok" + } + } + } + }, + 404: { + "description": "The requested resource was not found.", + "content": { + "application/json": { + "example": { + "error": "Resource not found." + } + } + } + }, + 503: { + "description": "Modem not running.", + "content": { + "application/json": { + "example": { + "error": "Modem not running." + } + } + } + } +}) +async def post_send_test_frame(request:Request): + """ + Trigger the modem to send a test frame. + + Returns: + dict: A JSON object indicating success. + + Raises: + HTTPException: If the modem is not running. + """ + if not request.app.state_manager.is_modem_running: + api_abort("Modem not running", 503) + await enqueue_tx_command(request.app, command_test.TestCommand) + return api_ok() + +@router.post("/fec_transmit", summary="FEC Transmit", tags=["Modem"], responses={ + 200: { + "description": "FEC frame transmitted successfully.", + "content": { + "application/json": { + "example": { + "message": "FEC transmission started." + } + } + } + }, + 400: { + "description": "Bad Request: The request was malformed or missing required parameters.", + "content": { + "application/json": { + "example": { + "error": "Invalid parameters." + } + } + } + }, + 404: { + "description": "The requested resource was not found.", + "content": { + "application/json": { + "example": { + "error": "Resource not found." + } + } + } + }, + 500: { + "description": "Internal Server Error: An unexpected error occurred on the server.", + "content": { + "application/json": { + "example": { + "error": "Internal server error." + } + } + } + }, + 503: { + "description": "Modem not running.", + "content": { + "application/json": { + "example": { + "error": "Modem not running." + } + } + } + } +}) +async def post_send_fec_frame(request: Request): + """ + Trigger the modem to transmit a Forward Error Correction (FEC) frame. + + Parameters: + request (Request): The HTTP request containing transmission parameters in JSON format. + + Returns: + dict: A JSON object indicating success. + + Raises: + HTTPException: If the modem is not running, the request is malformed, or an internal error occurs. + """ + if not request.app.state_manager.is_modem_running: + api_abort("Modem not running", 503) + + try: + data = await request.json() + except Exception: + api_abort("Invalid parameters.", 400) + + # Validate required parameters (adjust based on actual requirements) + if 'message' not in data: + api_abort("Invalid parameters: 'message' field is required.", 400) + + # Enqueue the FEC transmission command + try: + await enqueue_tx_command(request.app, command_fec.FecCommand, data) + return api_response({"message": "FEC transmission started."}) + except Exception as e: + # Log the exception if necessary + api_abort("Internal server error.", 500) + + +from fastapi import HTTPException + +@router.get("/fec_is_writing", summary="Indicate User is Typing (FEC)", tags=["Modem"], responses={ + 501: { + "description": "Feature not implemented.", + "content": { + "application/json": { + "example": { + "error": "Feature not implemented yet." + } + } + } + }, + 404: { + "description": "The requested resource was not found.", + "content": { + "application/json": { + "example": { + "error": "Resource not found." + } + } + } + }, + 503: { + "description": "Modem not running.", + "content": { + "application/json": { + "example": { + "error": "Modem not running." + } + } + } + } +}) +async def get_fec_is_writing(request:Request): + """ + Trigger the modem to inform over RF that the user is typing a message. + + Returns: + dict: A JSON object indicating that the feature is not implemented. + + Raises: + HTTPException: If the modem is not running or the feature is not implemented. + """ + if not request.app.state_manager.is_modem_running: + api_abort("Modem not running", 503) + + # Since the feature is not implemented yet, return a 501 Not Implemented error + raise HTTPException(status_code=501, detail="Feature not implemented yet.") + + +@router.post("/start", summary="Start Modem", tags=["Modem"], responses={ + 200: { + "description": "Modem started successfully.", + "content": { + "application/json": { + "examples": { + "modem_started": { + "summary": "Modem Started", + "value": { + "modem": "started" + } + }, + "message_ok": { + "summary": "Message OK", + "value": { + "message": "ok" + } + } + } + } + } + }, + 400: { + "description": "Bad Request: The request was malformed or missing required parameters.", + "content": { + "application/json": { + "example": { + "error": "Invalid parameters." + } + } + } + }, + 404: { + "description": "The requested resource was not found.", + "content": { + "application/json": { + "example": { + "error": "Resource not found." + } + } + } + }, + 500: { + "description": "Internal Server Error: An unexpected error occurred on the server.", + "content": { + "application/json": { + "example": { + "error": "Internal server error." + } + } + } + } +}) +async def post_modem_start(request: Request): + """ + Trigger the modem to start. + + Parameters: + request (Request): The HTTP request containing the following JSON key: + - 'start' (bool): True to start the modem. + + Returns: + dict: A JSON object indicating the modem has started. + + Raises: + HTTPException: If parameters are invalid or an error occurs. + """ + try: + data = await request.json() + if 'start' not in data or not isinstance(data['start'], bool): + api_abort("Invalid parameters.", 400) + if not data['start']: + api_abort("Invalid 'start' parameter. Must be True.", 400) + except Exception: + api_abort("Invalid parameters.", 400) + + try: + request.app.modem_service.put("start") + return api_response({"modem": "started"}) + except Exception as e: + api_abort("Internal server error.", 500) + + +@router.post("/stop", summary="Stop Modem", tags=["Modem"], responses={ + 200: { + "description": "Modem stopped successfully.", + "content": { + "application/json": { + "examples": { + "modem_stopped": { + "summary": "Modem Stopped", + "value": { + "modem": "stopped" + } + }, + "message_ok": { + "summary": "Message OK", + "value": { + "message": "ok" + } + } + } + } + } + }, + 404: { + "description": "The requested resource was not found.", + "content": { + "application/json": { + "example": { + "error": "Resource not found." + } + } + } + }, + 503: { + "description": "Modem not running.", + "content": { + "application/json": { + "example": { + "error": "Modem not running." + } + } + } + } +}) +async def post_modem_stop(request:Request): + """ + Trigger the modem to stop. + + Returns: + dict: A JSON object indicating the modem has stopped. + + Raises: + HTTPException: If the modem is not running or an error occurs. + """ + if not request.app.state_manager.is_modem_running: + api_abort("Modem not running", 503) + + try: + request.app.modem_service.put("stop") + return api_response({"modem": "stopped"}) + except Exception: + api_abort("Internal server error.", 500) + + + +@router.post("/send_arq_raw", summary="Send ARQ Raw Data", tags=["Modem"], responses={ + 200: { + "description": "ARQ raw data sent successfully.", + "content": { + "application/json": { + "example": { + "data": "RnJlZURBVEEgaXMgdGhlIGJlc3Qh", + "dxcall": "XX1XXX-6", + "type": "raw" + } + } + } + }, + 404: { + "description": "The requested resource was not found.", + "content": { + "application/json": { + "example": { + "error": "Resource not found." + } + } + } + }, + 503: { + "description": "Modem not running or busy.", + "content": { + "application/json": { + "examples": { + "modem_not_running": { + "summary": "Modem Not Running", + "value": { + "error": "Modem not running." + } + }, + "modem_busy": { + "summary": "Modem Busy", + "value": { + "error": "Modem Busy." + } + } + } + } + } + } +}) +async def post_send_arq_raw(request: Request): + """ + Send ARQ raw data to a specified station. + + Parameters: + request (Request): The HTTP request containing the following JSON keys: + - 'dxcall' (str): Callsign of the station to send data to. + - 'type' (str): Data type ('raw', 'raw_lzma', 'raw_gzip'). + - 'data' (str): Base64 encoded data to send. + + Returns: + dict: A JSON object echoing the sent data. + + Raises: + HTTPException: If parameters are invalid or modem is not running/busy. + """ + if not request.app.state_manager.is_modem_running: + api_abort("Modem not running.", 503) + if request.app.state_manager.is_modem_busy: + api_abort("Modem Busy.", 503) + data = await request.json() + dxcall = data.get('dxcall') + data_type = data.get('type') + raw_data = data.get('data') + if not dxcall or not validations.validate_freedata_callsign(dxcall): + api_abort("Invalid 'dxcall' parameter.", 400) + if data_type not in ['raw', 'raw_lzma', 'raw_gzip']: + api_abort("Invalid 'type' parameter.", 400) + if not raw_data: + api_abort("Missing 'data' parameter.", 400) + await enqueue_tx_command(request.app, command_arq_raw.SendARQRawCommand, data) + return api_response({ + "data": raw_data, + "dxcall": dxcall, + "type": data_type + }) + + +@router.post("/stop_transmission", summary="Stop Transmission", tags=["Modem"], responses={ + 200: { + "description": "Transmission stopped successfully.", + "content": { + "application/json": { + "example": { + "message": "ok" + } + } + } + }, + 400: { + "description": "Bad Request: The request was malformed or missing required parameters.", + "content": { + "application/json": { + "example": { + "error": "Invalid request." + } + } + } + }, + 404: { + "description": "The requested resource was not found.", + "content": { + "application/json": { + "example": { + "error": "Resource not found." + } + } + } + }, + 500: { + "description": "Internal Server Error: An unexpected error occurred on the server.", + "content": { + "application/json": { + "example": { + "error": "Internal server error." + } + } + } + }, + 503: { + "description": "Modem not running.", + "content": { + "application/json": { + "example": { + "error": "Modem not running." + } + } + } + } +}) +async def post_modem_stop_transmission(request:Request): + """ + Stop the current transmission. + + Returns: + dict: A JSON object indicating success. + + Raises: + HTTPException: If the modem is not running or an error occurs. + """ + if not request.app.state_manager.is_modem_running: + api_abort("Modem not running", 503) + if request.app.state_manager.getARQ(): + try: + for session in request.app.state_manager.arq_irs_sessions.values(): + # session.abort_transmission() + session.transmission_aborted() + for session in request.app.state_manager.arq_iss_sessions.values(): + session.abort_transmission(send_stop=False) + session.transmission_aborted() + except Exception as e: + print(f"Error during transmission stopping: {e}") + return api_ok() + + + diff --git a/freedata_server/api/radio.py b/freedata_server/api/radio.py new file mode 100644 index 000000000..276b2d04f --- /dev/null +++ b/freedata_server/api/radio.py @@ -0,0 +1,187 @@ +from fastapi import APIRouter, Request +from api.common import api_response, api_abort, api_ok, validate +from api.command_helpers import enqueue_tx_command +import command_transmit_sine + +router = APIRouter() + + + +@router.get("/", summary="Get Radio Parameters", tags=["Radio"], responses={ + 200: { + "description": "Current radio parameters.", + "content": { + "application/json": { + "example": { + "radio_frequency": "14093000", + "radio_mode": "PKTUSB", + "radio_rf_level": 100, + "radio_status": True, + "radio_swr": 0, + "radio_tuner": False, + "s_meter_strength": "20" + } + } + } + }, + 404: { + "description": "The requested resource was not found.", + "content": { + "application/json": { + "example": { + "error": "Resource not found." + } + } + } + } +}) +async def get_radio(request: Request): + """ + Retrieve current radio parameters. + + Returns: + dict: A JSON object containing radio parameters. + """ + return request.app.state_manager.get_radio_status() + + +@router.post("/", summary="Set Radio Parameters", tags=["Radio"], responses={ + 200: { + "description": "Radio parameters updated successfully.", + "content": { + "application/json": { + "example": { + "radio_frequency": "14093000", + "radio_mode": "PKTUSB", + "radio_rf_level": 100, + "radio_status": True, + "radio_swr": 0, + "radio_tuner": True, + "s_meter_strength": "20" + } + } + } + }, + 400: { + "description": "Bad Request: The request was malformed or missing required parameters.", + "content": { + "application/json": { + "example": { + "error": "Invalid parameters." + } + } + } + }, + 404: { + "description": "The requested resource was not found.", + "content": { + "application/json": { + "example": { + "error": "Resource not found." + } + } + } + } +}) +async def post_radio(request: Request): + """ + Set radio parameters. + + Parameters: + request (Request): The HTTP request containing the radio parameters in JSON format. + + Returns: + dict: A JSON object containing the updated radio parameters. + """ + data = await request.json() + radio_manager = request.app.radio_manager + if "radio_frequency" in data: + radio_manager.set_frequency(data['radio_frequency']) + if "radio_mode" in data: + radio_manager.set_mode(data['radio_mode']) + if "radio_rf_level" in data: + radio_manager.set_rf_level(int(data['radio_rf_level'])) + if "radio_tuner" in data: + radio_manager.set_tuner(data['radio_tuner']) + return api_response(data) + + +@router.post("/tune", summary="Enable/Disable Radio Tuning", tags=["Radio"], responses={ + 200: { + "description": "Radio tuning status updated successfully.", + "content": { + "application/json": { + "example": { + "enable_tuning": True + } + } + } + }, + 400: { + "description": "Bad Request: The request was malformed or missing required parameters.", + "content": { + "application/json": { + "example": { + "error": "Invalid parameters." + } + } + } + }, + 404: { + "description": "The requested resource was not found.", + "content": { + "application/json": { + "example": { + "error": "Resource not found." + } + } + } + }, + 503: { + "description": "Modem not running or busy.", + "content": { + "application/json": { + "examples": { + "modem_not_running": { + "summary": "Modem Not Running", + "value": { + "error": "Modem not running." + } + }, + "modem_busy": { + "summary": "Modem Busy", + "value": { + "error": "Modem Busy." + } + } + } + } + } + } +}) +async def post_radio_tune(request: Request): + """ + Trigger the modem to inform over RF that the user is typing a message. + + Parameters: + request (Request): The HTTP request containing the following JSON key: + - 'enable_tuning' (bool): True to enable tuning, False to disable. + + Returns: + dict: A JSON object echoing the tuning status. + + Raises: + HTTPException: If the modem is not running/busy or if parameters are invalid. + """ + data = await request.json() + if "enable_tuning" in data: + if data['enable_tuning']: + if not request.app.state_manager.is_modem_running: + api_abort("Modem not running", 503) + await enqueue_tx_command(request.app, command_transmit_sine.TransmitSine) + else: + request.app.service_manager.modem.stop_sine() + else: + request.app.service_manager.modem.stop_sine() + + return api_response(data) diff --git a/freedata_server/api/websocket.py b/freedata_server/api/websocket.py new file mode 100644 index 000000000..08ab2549c --- /dev/null +++ b/freedata_server/api/websocket.py @@ -0,0 +1,18 @@ +from fastapi import APIRouter, Request, WebSocket +router = APIRouter() + +# WebSocket Event Handlers +@router.websocket("/events") +async def websocket_events(websocket: WebSocket): + await websocket.accept() + await websocket.app.wsm.handle_connection(websocket, websocket.app.wsm.events_client_list, websocket.app.modem_events) + +@router.websocket("/fft") +async def websocket_fft(websocket: WebSocket): + await websocket.accept() + await websocket.app.wsm.handle_connection(websocket, websocket.app.wsm.fft_client_list, websocket.app.modem_fft) + +@router.websocket("/states") +async def websocket_states(websocket: WebSocket): + await websocket.accept() + await websocket.app.wsm.handle_connection(websocket, websocket.app.wsm.states_client_list, websocket.app.state_queue) diff --git a/freedata_server/server.py b/freedata_server/server.py index 6a2bc9f92..85820c937 100644 --- a/freedata_server/server.py +++ b/freedata_server/server.py @@ -8,48 +8,37 @@ import queue import asyncio import webbrowser -import platform -from fastapi import FastAPI, Request, HTTPException, WebSocket, WebSocketDisconnect -from fastapi.responses import JSONResponse +from fastapi import FastAPI, Request, WebSocket from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles from contextlib import asynccontextmanager import uvicorn import threading -import serial_ports from config import CONFIG import audio import service_manager import state_manager import websocket_manager -import api_validations as validations -import command_cq -import command_beacon -import command_ping -import command_fec -import command_test -import command_transmit_sine -import command_arq_raw -import command_message_send + import event_manager import structlog -from log_handler import setup_logging -import adif_udp_logger -import wavelog_api_logger + from message_system_db_manager import DatabaseManager -from message_system_db_messages import DatabaseManagerMessages -from message_system_db_attachments import DatabaseManagerAttachments -from message_system_db_beacon import DatabaseManagerBeacon -from message_system_db_station import DatabaseManagerStations from schedule_manager import ScheduleManager +from api.general import router as general_router +from api.config import router as config_router +from api.devices import router as devices_router +from api.radio import router as radio_router +from api.modem import router as modem_router +from api.freedata import router as freedata_router +from api.websocket import router as websocket_router + # Constants CONFIG_ENV_VAR = 'FREEDATA_CONFIG' DEFAULT_CONFIG_FILE = 'config.ini' - MODEM_VERSION = "0.16.11-alpha" - API_VERSION = 3 LICENSE = 'GPL3.0' DOCUMENTATION_URL = 'https://wiki.freedata.app' @@ -65,7 +54,13 @@ async def lifespan(app: FastAPI): stop_server() app = FastAPI(lifespan=lifespan) - +app.include_router(general_router, prefix="") +app.include_router(config_router, prefix="/config") +app.include_router(devices_router, prefix="/devices") +app.include_router(radio_router, prefix="/radio") +app.include_router(modem_router, prefix="/modem") +app.include_router(freedata_router, prefix="/freedata") +app.include_router(websocket_router, prefix="") # custom logger for fastapi #setup_logging() logger = structlog.get_logger() @@ -121,28 +116,6 @@ async def http_middleware(request: Request, call_next): ) -# Returns a standard API response -def api_response(data, status=200): - return JSONResponse(content=data, status_code=status) - - -def api_abort(message, code): - raise HTTPException(status_code=code, detail={"error": message}) - - -def api_ok(message="ok"): - return api_response({'message': message}) - - -# Validates a parameter -def validate(req, param, validator, is_required=True): - if param not in req: - if is_required: - api_abort(f"Required parameter '{param}' is missing.", 400) - else: - return True - if not validator(req[param]): - api_abort(f"Value of '{param}' is invalid.", 400) # Set config file to use @@ -156,2132 +129,9 @@ def set_config(): return config_file -# Enqueue a transmit command -async def enqueue_tx_command(cmd_class, params={}): - try: - command = cmd_class(app.config_manager.read(), app.state_manager, app.event_manager, params) - print(f"Command {command.get_name()} running...") - - # Run the command in a separate thread to avoid blocking - result = await asyncio.to_thread(command.run, app.modem_events, app.service_manager.modem) # TODO: remove the app.modem_event custom queue - - if result: - return True - except Exception as e: - print(f"Command failed: {e}") - return False - -# API Endpoints -@app.get("/", summary="API Root", tags=["General"], responses={ - 200: { - "description": "API information.", - "content": { - "application/json": { - "example": { - "name": "FreeDATA API", - "description": "A sample API that provides free data services", - "api_version": 3, - "modem_version": "0.16.8-alpha", - "license": "GPL3.0", - "documentation": "https://wiki.freedata.app" - } - } - } - }, - 404: { - "description": "The requested resource was not found.", - "content": { - "application/json": { - "example": { - "error": "Resource not found." - } - } - } - }, - 503: { - "description": "Service unavailable.", - "content": { - "application/json": { - "example": { - "error": "Service unavailable." - } - } - } - } -}) -async def index(): - """ - Retrieve API metadata. - - Returns: - dict: A JSON object containing API metadata. - """ - return { - 'name': 'FreeDATA API', - 'description': 'A sample API that provides free data services', - 'api_version': 3, - 'modem_version': "0.16.8-alpha", - 'license': "GPL3.0", - 'documentation': "https://wiki.freedata.app", - } - - -@app.get("/config", summary="Get Modem Configuration", tags=["Configuration"], responses={ - 200: { - "description": "Current modem configuration settings.", - "content": { - "application/json": { - "example": { - "AUDIO": { - "input_device": "2fc0", - "output_device": "3655", - "rx_audio_level": 0, - "tx_audio_level": 2 - }, - "MESSAGES": { - "enable_auto_repeat": True - }, - "MODEM": { - "enable_hmac": False, - "enable_morse_identifier": False, - "enable_socket_interface": False, - "maximum_bandwidth": 2438, - "tx_delay": 200 - }, - "NETWORK": { - "modemaddress": "", - "modemport": 5000 - }, - "RADIO": { - "control": "rigctld", - "data_bits": 8, - "model_id": 1001, - "ptt_port": "ignore", - "ptt_type": "USB", - "serial_dcd": "NONE", - "serial_dtr": "OFF", - "serial_handshake": "ignore", - "serial_port": "/dev/cu.Bluetooth-Incoming-Port", - "serial_speed": 38400, - "stop_bits": 1 - }, - "RIGCTLD": { - "arguments": "", - "command": "", - "ip": "127.0.0.1", - "path": "", - "port": 4532 - }, - "SOCKET_INTERFACE": { - "cmd_port": 0, - "data_port": 0, - "enable": False, - "host": "" - }, - "STATION": { - "enable_explorer": True, - "enable_stats": True, - "mycall": "LA3QMA", - "mygrid": "JP20ql", - "myssid": 0, - "ssid_list": [0,1,2,3,4,5,6,7,8,9], - "respond_to_cq": True, - }, - "TCI": { - "tci_ip": "127.0.0.1", - "tci_port": 50001 - } - } - } - } - }, - 404: { - "description": "The requested resource was not found.", - "content": { - "application/json": { - "example": { - "error": "Resource not found." - } - } - } - } -}) -async def get_config(): - """ - Retrieve the current modem configuration. - - Returns: - dict: The modem configuration settings. - """ - return app.config_manager.read() - - -@app.post("/config", summary="Update Modem Configuration", tags=["Configuration"], responses={ - 200: { - "description": "Modem configuration updated successfully.", - "content": { - "application/json": { - "example": { - "AUDIO": { - "input_device": "2fc0", - "output_device": "3655", - "rx_audio_level": 0, - "tx_audio_level": 2 - }, - # ... (rest of the configuration as in the GET example) - } - } - } - }, - 400: { - "description": "Invalid configuration data.", - "content": { - "application/json": { - "example": { - "error": "Invalid config" - } - } - } - }, - 500: { - "description": "Error writing configuration.", - "content": { - "application/json": { - "example": { - "error": "Error writing config" - } - } - } - }, - 404: { - "description": "The requested resource was not found.", - "content": { - "application/json": { - "example": { - "error": "Resource not found." - } - } - } - } -}) -async def post_config(request: Request): - """ - Update the modem configuration with new settings. - - Parameters: - request (Request): The HTTP request containing the new configuration in JSON format. - - Returns: - dict: The updated modem configuration. - - Raises: - HTTPException: If the provided configuration is invalid or an error occurs while writing the config. - """ - config = await request.json() - print(config) - if not validations.validate_remote_config(config): - api_abort("Invalid config", 400) - if app.config_manager.read() == config: - return config - set_config = app.config_manager.write(config) - if not set_config: - api_abort("Error writing config", 500) - app.modem_service.put("restart") - return set_config - - -@app.get("/devices/audio", summary="Get Audio Devices", tags=["Devices"], responses={ - 200: { - "description": "List of available audio input and output devices.", - "content": { - "application/json": { - "example": { - "in": [ - { - "api": "ALSA", - "id": "8eb1", - "name": "pipewire", - "native_index": 4 - }, - { - "api": "ALSA", - "id": "8e7a", - "name": "default", - "native_index": 5 - } - ], - "out": [ - { - "api": "ALSA", - "id": "ae79", - "name": "HDA Intel HDMI: 0 (hw:0,3)", - "native_index": 0 - }, - { - "api": "ALSA", - "id": "67fd", - "name": "HDA Intel HDMI: 1 (hw:0,7)", - "native_index": 1 - }, - { - "api": "ALSA", - "id": "b68c", - "name": "HDA Intel HDMI: 2 (hw:0,8)", - "native_index": 2 - }, - { - "api": "ALSA", - "id": "ba84", - "name": "hdmi", - "native_index": 3 - }, - { - "api": "ALSA", - "id": "8eb1", - "name": "pipewire", - "native_index": 4 - }, - { - "api": "ALSA", - "id": "8e7a", - "name": "default", - "native_index": 5 - } - ] - } - } - } - }, - 404: { - "description": "The requested resource was not found.", - "content": { - "application/json": { - "example": { - "error": "Resource not found." - } - } - } - }, - 503: { - "description": "Modem not running.", - "content": { - "application/json": { - "example": { - "error": "Modem not running." - } - } - } - } -}) -async def get_audio_devices(): - """ - Retrieve a list of available audio input and output devices. - - Returns: - dict: A JSON object containing lists of input and output audio devices. - """ - # Uncomment the following line if using the actual function - # dev_in, dev_out = audio.get_audio_devices() - dev_in, dev_out = audio.fetch_audio_devices([], []) - return {'in': dev_in, 'out': dev_out} - - -@app.get("/devices/serial", summary="Get Serial Devices", tags=["Devices"], responses={ - 200: { - "description": "List of available serial devices (COM ports).", - "content": { - "application/json": { - "example": [ - { - "description": "n/a [26a9]", - "port": "/dev/ttyS4" - } - ] - } - } - }, - 404: { - "description": "The requested resource was not found.", - "content": { - "application/json": { - "example": { - "error": "Resource not found." - } - } - } - }, - 503: { - "description": "Modem not running.", - "content": { - "application/json": { - "example": { - "error": "Modem not running." - } - } - } - } -}) -async def get_serial_devices(): - """ - Retrieve a list of available serial devices (COM ports). - - Returns: - list: A list of dictionaries containing serial port information. - """ - devices = serial_ports.get_ports() - return devices - - -@app.get("/modem/state", summary="Get Modem State", tags=["Modem"], responses={ - 200: { - "description": "Current modem state information.", - "content": { - "application/json": { - "example": { - "activities": { - "161dd75ef3b5847a": { - "activity_type": "ARQ_BURST_ACK", - "direction": "received", - "frequency": "14093000", - "frequency_offset": 0, - "session_id": 105, - "snr": 4, - "timestamp": 1713034266 - }, - "168e90799d13b7b4": { - "activity_type": "ARQ_SESSION_INFO_ACK", - "direction": "received", - "frequency": "14093000", - "frequency_offset": 0, - "session_id": 105, - "snr": -3, - "timestamp": 1713034248 - }, - "2218b849e937d36d": { - "activity_type": "QRV", - "direction": "received", - "frequency": "14093000", - "frequency_offset": 0, - "gridsquare": "JP15OW", - "origin": "SOMECALL-1", - "snr": 2, - "timestamp": 1713034200 - }, - "3fb424827f4632ab": { - "activity_type": "BEACON", - "direction": "received", - "frequency": "14093000", - "frequency_offset": 0, - "gridsquare": "JP22AI", - "origin": "CALLSIGN-1", - "snr": -8, - "timestamp": 1713034455 - }, - "743222d1dd64ce9d": { - "activity_type": "ARQ_SESSION_OPEN_ACK", - "direction": "received", - "frequency": "14093000", - "frequency_offset": 0, - "origin": "CALL-1", - "session_id": 105, - "snr": -2, - "timestamp": 1713034243 - }, - "7589edf6bf23ceed": { - "activity_type": "ARQ_BURST_ACK", - "direction": "received", - "frequency": "14093000", - "frequency_offset": 0, - "session_id": 105, - "snr": 2, - "timestamp": 1713034275 - }, - "9d2c5a98fe0f9894": { - "activity_type": "QRV", - "direction": "received", - "frequency": "14093000", - "frequency_offset": 0, - "gridsquare": "JP12AW", - "origin": "CALLME-1", - "snr": 5, - "timestamp": 1713034178 - }, - "f85609dced4ea40a": { - "activity_type": "ARQ_BURST_ACK", - "direction": "received", - "frequency": "14093000", - "frequency_offset": 0, - "session_id": 105, - "snr": 0, - "timestamp": 1713034257 - } - }, - "audio_dbfs": -7.915304862713354, - "channel_busy_slot": [False, False, True, False, False], - "is_away_from_key": False, - "is_beacon_running": True, - "is_modem_busy": False, - "is_modem_running": True, - "radio_frequency": "14093000", - "radio_mode": "PKTUSB", - "radio_status": True, - "s_meter_strength": "20", - "type": "state" - } - } - } - }, - 404: { - "description": "The requested resource was not found.", - "content": { - "application/json": { - "example": { - "error": "Resource not found." - } - } - } - } -}) -async def get_modem_state(): - """ - Retrieve the current state of the modem. - - Returns: - dict: A JSON object containing modem state information. - """ - return app.state_manager.sendState() - - -@app.post("/modem/cqcqcq", summary="Send CQ Command", tags=["Modem"], responses={ - 200: { - "description": "CQ command sent successfully.", - "content": { - "application/json": { - "example": { - "message": "ok" - } - } - } - }, - 404: { - "description": "The requested resource was not found.", - "content": { - "application/json": { - "example": { - "error": "Resource not found." - } - } - } - }, - 503: { - "description": "Modem not running.", - "content": { - "application/json": { - "example": { - "error": "Modem not running." - } - } - } - } -}) -async def post_cqcqcq(): - """ - Trigger the modem to send a CQ. - - Returns: - dict: A JSON object indicating success. - - Raises: - HTTPException: If the modem is not running. - """ - if not app.state_manager.is_modem_running: - api_abort("Modem not running", 503) - await enqueue_tx_command(command_cq.CQCommand) - return api_ok() - - -@app.post("/modem/beacon", summary="Enable/Disable Modem Beacon", tags=["Modem"], responses={ - 200: { - "description": "Beacon status updated successfully.", - "content": { - "application/json": { - "example": { - "enabled": True, - "away_from_key": False - } - } - } - }, - 400: { - "description": "Invalid input parameters.", - "content": { - "application/json": { - "example": { - "error": "Incorrect value for 'enabled' or 'away_from_key'. Should be bool." - } - } - } - }, - 404: { - "description": "The requested resource was not found.", - "content": { - "application/json": { - "example": { - "error": "Resource not found." - } - } - } - }, - 503: { - "description": "Modem not running.", - "content": { - "application/json": { - "example": { - "error": "Modem not running." - } - } - } - } -}) -async def post_beacon(request: Request): - """ - Enable or disable the modem beacon. - - Parameters: - request (Request): The HTTP request containing the following JSON keys: - - 'enabled' (bool): True to enable the beacon, False to disable. - - 'away_from_key' (bool): True if away from key, False otherwise. - - Returns: - dict: A JSON object indicating the beacon status. - - Raises: - HTTPException: If parameters are invalid or modem is not running. - """ - data = await request.json() - if not isinstance(data.get('enabled'), bool) or not isinstance(data.get('away_from_key'), bool): - api_abort("Incorrect value for 'enabled' or 'away_from_key'. Should be bool.", 400) - if not app.state_manager.is_modem_running: - api_abort("Modem not running", 503) - app.state_manager.set('is_beacon_running', data['enabled']) - app.state_manager.set('is_away_from_key', data['away_from_key']) - if not app.state_manager.getARQ() and data['enabled']: - await enqueue_tx_command(command_beacon.BeaconCommand, data) - return api_response({"enabled": data['enabled'], "away_from_key": data['away_from_key']}) - - -@app.post("/modem/ping_ping", summary="Trigger Modem to PING a Station", tags=["Modem"], responses={ - 200: { - "description": "Ping command sent successfully.", - "content": { - "application/json": { - "example": { - "message": True - } - } - } - }, - 400: { - "description": "Invalid input parameters.", - "content": { - "application/json": { - "example": { - "error": "Invalid 'dxcall' parameter." - } - } - } - }, - 404: { - "description": "The requested resource was not found.", - "content": { - "application/json": { - "example": { - "error": "Resource not found." - } - } - } - }, - 503: { - "description": "Modem not running.", - "content": { - "application/json": { - "example": { - "error": "Modem not running." - } - } - } - } -}) -async def post_ping(request: Request): - """ - Trigger the modem to send a PING to a station. - - Parameters: - request (Request): The HTTP request containing the following JSON key: - - 'dxcall' (str): Callsign of the station to ping. - - Returns: - dict: A JSON object indicating success. - - Raises: - HTTPException: If parameters are invalid or modem is not running. - """ - data = await request.json() - if not app.state_manager.is_modem_running: - api_abort("Modem not running", 503) - dxcall = data.get('dxcall') - if not dxcall or not validations.validate_freedata_callsign(dxcall): - api_abort("Invalid 'dxcall' parameter.", 400) - await enqueue_tx_command(command_ping.PingCommand, data) - return api_response({"message": True}) - - -@app.post("/modem/send_test_frame", summary="Send Test Frame", tags=["Modem"], responses={ - 200: { - "description": "Test frame sent successfully.", - "content": { - "application/json": { - "example": { - "message": "ok" - } - } - } - }, - 404: { - "description": "The requested resource was not found.", - "content": { - "application/json": { - "example": { - "error": "Resource not found." - } - } - } - }, - 503: { - "description": "Modem not running.", - "content": { - "application/json": { - "example": { - "error": "Modem not running." - } - } - } - } -}) -async def post_send_test_frame(): - """ - Trigger the modem to send a test frame. - - Returns: - dict: A JSON object indicating success. - - Raises: - HTTPException: If the modem is not running. - """ - if not app.state_manager.is_modem_running: - api_abort("Modem not running", 503) - await enqueue_tx_command(command_test.TestCommand) - return api_ok() - -@app.post("/modem/fec_transmit", summary="FEC Transmit", tags=["Modem"], responses={ - 200: { - "description": "FEC frame transmitted successfully.", - "content": { - "application/json": { - "example": { - "message": "FEC transmission started." - } - } - } - }, - 400: { - "description": "Bad Request: The request was malformed or missing required parameters.", - "content": { - "application/json": { - "example": { - "error": "Invalid parameters." - } - } - } - }, - 404: { - "description": "The requested resource was not found.", - "content": { - "application/json": { - "example": { - "error": "Resource not found." - } - } - } - }, - 500: { - "description": "Internal Server Error: An unexpected error occurred on the server.", - "content": { - "application/json": { - "example": { - "error": "Internal server error." - } - } - } - }, - 503: { - "description": "Modem not running.", - "content": { - "application/json": { - "example": { - "error": "Modem not running." - } - } - } - } -}) -async def post_send_fec_frame(request: Request): - """ - Trigger the modem to transmit a Forward Error Correction (FEC) frame. - - Parameters: - request (Request): The HTTP request containing transmission parameters in JSON format. - - Returns: - dict: A JSON object indicating success. - - Raises: - HTTPException: If the modem is not running, the request is malformed, or an internal error occurs. - """ - if not app.state_manager.is_modem_running: - api_abort("Modem not running", 503) - - try: - data = await request.json() - except Exception: - api_abort("Invalid parameters.", 400) - - # Validate required parameters (adjust based on actual requirements) - if 'message' not in data: - api_abort("Invalid parameters: 'message' field is required.", 400) - - # Enqueue the FEC transmission command - try: - await enqueue_tx_command(command_fec.FecCommand, data) - return api_response({"message": "FEC transmission started."}) - except Exception as e: - # Log the exception if necessary - api_abort("Internal server error.", 500) - - -from fastapi import HTTPException - -@app.get("/modem/fec_is_writing", summary="Indicate User is Typing (FEC)", tags=["Modem"], responses={ - 501: { - "description": "Feature not implemented.", - "content": { - "application/json": { - "example": { - "error": "Feature not implemented yet." - } - } - } - }, - 404: { - "description": "The requested resource was not found.", - "content": { - "application/json": { - "example": { - "error": "Resource not found." - } - } - } - }, - 503: { - "description": "Modem not running.", - "content": { - "application/json": { - "example": { - "error": "Modem not running." - } - } - } - } -}) -async def get_fec_is_writing(): - """ - Trigger the modem to inform over RF that the user is typing a message. - - Returns: - dict: A JSON object indicating that the feature is not implemented. - - Raises: - HTTPException: If the modem is not running or the feature is not implemented. - """ - if not app.state_manager.is_modem_running: - api_abort("Modem not running", 503) - - # Since the feature is not implemented yet, return a 501 Not Implemented error - raise HTTPException(status_code=501, detail="Feature not implemented yet.") - - -@app.post("/modem/start", summary="Start Modem", tags=["Modem"], responses={ - 200: { - "description": "Modem started successfully.", - "content": { - "application/json": { - "examples": { - "modem_started": { - "summary": "Modem Started", - "value": { - "modem": "started" - } - }, - "message_ok": { - "summary": "Message OK", - "value": { - "message": "ok" - } - } - } - } - } - }, - 400: { - "description": "Bad Request: The request was malformed or missing required parameters.", - "content": { - "application/json": { - "example": { - "error": "Invalid parameters." - } - } - } - }, - 404: { - "description": "The requested resource was not found.", - "content": { - "application/json": { - "example": { - "error": "Resource not found." - } - } - } - }, - 500: { - "description": "Internal Server Error: An unexpected error occurred on the server.", - "content": { - "application/json": { - "example": { - "error": "Internal server error." - } - } - } - } -}) -async def post_modem_start(request: Request): - """ - Trigger the modem to start. - - Parameters: - request (Request): The HTTP request containing the following JSON key: - - 'start' (bool): True to start the modem. - - Returns: - dict: A JSON object indicating the modem has started. - - Raises: - HTTPException: If parameters are invalid or an error occurs. - """ - try: - data = await request.json() - if 'start' not in data or not isinstance(data['start'], bool): - api_abort("Invalid parameters.", 400) - if not data['start']: - api_abort("Invalid 'start' parameter. Must be True.", 400) - except Exception: - api_abort("Invalid parameters.", 400) - - try: - app.modem_service.put("start") - return api_response({"modem": "started"}) - except Exception as e: - api_abort("Internal server error.", 500) - - -@app.post("/modem/stop", summary="Stop Modem", tags=["Modem"], responses={ - 200: { - "description": "Modem stopped successfully.", - "content": { - "application/json": { - "examples": { - "modem_stopped": { - "summary": "Modem Stopped", - "value": { - "modem": "stopped" - } - }, - "message_ok": { - "summary": "Message OK", - "value": { - "message": "ok" - } - } - } - } - } - }, - 404: { - "description": "The requested resource was not found.", - "content": { - "application/json": { - "example": { - "error": "Resource not found." - } - } - } - }, - 503: { - "description": "Modem not running.", - "content": { - "application/json": { - "example": { - "error": "Modem not running." - } - } - } - } -}) -async def post_modem_stop(): - """ - Trigger the modem to stop. - - Returns: - dict: A JSON object indicating the modem has stopped. - - Raises: - HTTPException: If the modem is not running or an error occurs. - """ - if not app.state_manager.is_modem_running: - api_abort("Modem not running", 503) - - try: - app.modem_service.put("stop") - return api_response({"modem": "stopped"}) - except Exception: - api_abort("Internal server error.", 500) - - -@app.get("/version", summary="Get Modem Version", tags=["General"], responses={ - 200: { - "description": "Successful Response", - "content": { - "application/json": { - "example": { - "api_version": 3, - "modem_version": "0.16.8-alpha", - "os_info": { - "system": "Linux", - "node": "my-node", - "release": "5.4.0-74-generic", - "version": "#83-Ubuntu SMP Mon May 10 16:30:51 UTC 2021", - "machine": "x86_64", - "processor": "x86_64" - }, - "python_info": { - "build": ["default", "May 3 2021 19:12:05"], - "compiler": "GCC 9.3.0", - "branch": "", - "implementation": "CPython", - "revision": "", - "version": "3.8.5" - } - } - } - } - } -}) -async def get_modem_version(): - """ - Retrieve the modem version, API version, OS information, and Python information. - - Returns: - dict: A JSON object containing version information. - """ - os_info = { - 'system': platform.system(), - 'node': platform.node(), - 'release': platform.release(), - 'version': platform.version(), - 'machine': platform.machine(), - 'processor': platform.processor(), - } - - python_info = { - 'build': platform.python_build(), - 'compiler': platform.python_compiler(), - 'branch': platform.python_branch(), - 'implementation': platform.python_implementation(), - 'revision': platform.python_revision(), - 'version': platform.python_version() - } - - return { - 'api_version': API_VERSION, - 'modem_version': MODEM_VERSION, - 'os_info': os_info, - 'python_info': python_info - } - -@app.post("/modem/send_arq_raw", summary="Send ARQ Raw Data", tags=["Modem"], responses={ - 200: { - "description": "ARQ raw data sent successfully.", - "content": { - "application/json": { - "example": { - "data": "RnJlZURBVEEgaXMgdGhlIGJlc3Qh", - "dxcall": "XX1XXX-6", - "type": "raw" - } - } - } - }, - 404: { - "description": "The requested resource was not found.", - "content": { - "application/json": { - "example": { - "error": "Resource not found." - } - } - } - }, - 503: { - "description": "Modem not running or busy.", - "content": { - "application/json": { - "examples": { - "modem_not_running": { - "summary": "Modem Not Running", - "value": { - "error": "Modem not running." - } - }, - "modem_busy": { - "summary": "Modem Busy", - "value": { - "error": "Modem Busy." - } - } - } - } - } - } -}) -async def post_send_arq_raw(request: Request): - """ - Send ARQ raw data to a specified station. - - Parameters: - request (Request): The HTTP request containing the following JSON keys: - - 'dxcall' (str): Callsign of the station to send data to. - - 'type' (str): Data type ('raw', 'raw_lzma', 'raw_gzip'). - - 'data' (str): Base64 encoded data to send. - - Returns: - dict: A JSON object echoing the sent data. - - Raises: - HTTPException: If parameters are invalid or modem is not running/busy. - """ - if not app.state_manager.is_modem_running: - api_abort("Modem not running.", 503) - if app.state_manager.is_modem_busy: - api_abort("Modem Busy.", 503) - data = await request.json() - dxcall = data.get('dxcall') - data_type = data.get('type') - raw_data = data.get('data') - if not dxcall or not validations.validate_freedata_callsign(dxcall): - api_abort("Invalid 'dxcall' parameter.", 400) - if data_type not in ['raw', 'raw_lzma', 'raw_gzip']: - api_abort("Invalid 'type' parameter.", 400) - if not raw_data: - api_abort("Missing 'data' parameter.", 400) - await enqueue_tx_command(command_arq_raw.SendARQRawCommand, data) - return api_response({ - "data": raw_data, - "dxcall": dxcall, - "type": data_type - }) - - -@app.post("/modem/stop_transmission", summary="Stop Transmission", tags=["Modem"], responses={ - 200: { - "description": "Transmission stopped successfully.", - "content": { - "application/json": { - "example": { - "message": "ok" - } - } - } - }, - 400: { - "description": "Bad Request: The request was malformed or missing required parameters.", - "content": { - "application/json": { - "example": { - "error": "Invalid request." - } - } - } - }, - 404: { - "description": "The requested resource was not found.", - "content": { - "application/json": { - "example": { - "error": "Resource not found." - } - } - } - }, - 500: { - "description": "Internal Server Error: An unexpected error occurred on the server.", - "content": { - "application/json": { - "example": { - "error": "Internal server error." - } - } - } - }, - 503: { - "description": "Modem not running.", - "content": { - "application/json": { - "example": { - "error": "Modem not running." - } - } - } - } -}) -async def post_modem_stop_transmission(): - """ - Stop the current transmission. - - Returns: - dict: A JSON object indicating success. - - Raises: - HTTPException: If the modem is not running or an error occurs. - """ - if not app.state_manager.is_modem_running: - api_abort("Modem not running", 503) - if app.state_manager.getARQ(): - try: - for session in app.state_manager.arq_irs_sessions.values(): - # session.abort_transmission() - session.transmission_aborted() - for session in app.state_manager.arq_iss_sessions.values(): - session.abort_transmission(send_stop=False) - session.transmission_aborted() - except Exception as e: - print(f"Error during transmission stopping: {e}") - return api_ok() - - -@app.get("/radio", summary="Get Radio Parameters", tags=["Radio"], responses={ - 200: { - "description": "Current radio parameters.", - "content": { - "application/json": { - "example": { - "radio_frequency": "14093000", - "radio_mode": "PKTUSB", - "radio_rf_level": 100, - "radio_status": True, - "radio_swr": 0, - "radio_tuner": False, - "s_meter_strength": "20" - } - } - } - }, - 404: { - "description": "The requested resource was not found.", - "content": { - "application/json": { - "example": { - "error": "Resource not found." - } - } - } - } -}) -async def get_radio(): - """ - Retrieve current radio parameters. - - Returns: - dict: A JSON object containing radio parameters. - """ - return app.state_manager.get_radio_status() - - -@app.post("/radio", summary="Set Radio Parameters", tags=["Radio"], responses={ - 200: { - "description": "Radio parameters updated successfully.", - "content": { - "application/json": { - "example": { - "radio_frequency": "14093000", - "radio_mode": "PKTUSB", - "radio_rf_level": 100, - "radio_status": True, - "radio_swr": 0, - "radio_tuner": True, - "s_meter_strength": "20" - } - } - } - }, - 400: { - "description": "Bad Request: The request was malformed or missing required parameters.", - "content": { - "application/json": { - "example": { - "error": "Invalid parameters." - } - } - } - }, - 404: { - "description": "The requested resource was not found.", - "content": { - "application/json": { - "example": { - "error": "Resource not found." - } - } - } - } -}) -async def post_radio(request: Request): - """ - Set radio parameters. - - Parameters: - request (Request): The HTTP request containing the radio parameters in JSON format. - - Returns: - dict: A JSON object containing the updated radio parameters. - """ - data = await request.json() - radio_manager = app.radio_manager - if "radio_frequency" in data: - radio_manager.set_frequency(data['radio_frequency']) - if "radio_mode" in data: - radio_manager.set_mode(data['radio_mode']) - if "radio_rf_level" in data: - radio_manager.set_rf_level(int(data['radio_rf_level'])) - if "radio_tuner" in data: - radio_manager.set_tuner(data['radio_tuner']) - return api_response(data) - - -@app.post("/radio/tune", summary="Enable/Disable Radio Tuning", tags=["Radio"], responses={ - 200: { - "description": "Radio tuning status updated successfully.", - "content": { - "application/json": { - "example": { - "enable_tuning": True - } - } - } - }, - 400: { - "description": "Bad Request: The request was malformed or missing required parameters.", - "content": { - "application/json": { - "example": { - "error": "Invalid parameters." - } - } - } - }, - 404: { - "description": "The requested resource was not found.", - "content": { - "application/json": { - "example": { - "error": "Resource not found." - } - } - } - }, - 503: { - "description": "Modem not running or busy.", - "content": { - "application/json": { - "examples": { - "modem_not_running": { - "summary": "Modem Not Running", - "value": { - "error": "Modem not running." - } - }, - "modem_busy": { - "summary": "Modem Busy", - "value": { - "error": "Modem Busy." - } - } - } - } - } - } -}) -async def post_radio_tune(request: Request): - """ - Trigger the modem to inform over RF that the user is typing a message. - - Parameters: - request (Request): The HTTP request containing the following JSON key: - - 'enable_tuning' (bool): True to enable tuning, False to disable. - - Returns: - dict: A JSON object echoing the tuning status. - - Raises: - HTTPException: If the modem is not running/busy or if parameters are invalid. - """ - data = await request.json() - print(data) - if "enable_tuning" in data: - if data['enable_tuning']: - if not app.state_manager.is_modem_running: - api_abort("Modem not running", 503) - await enqueue_tx_command(command_transmit_sine.TransmitSine) - else: - app.service_manager.modem.stop_sine() - else: - app.service_manager.modem.stop_sine() - - return api_response(data) -@app.get("/freedata/messages/{message_id}", summary="Get Message by ID", tags=["FreeDATA"], responses={ - 200: {"description": "Message found and returned."}, - 404: {"description": "Message not found."} -}) -async def get_freedata_message(message_id: str): - message = DatabaseManagerMessages(app.event_manager).get_message_by_id_json(message_id) - return api_response(message) - - -@app.post("/freedata/messages", summary="Transmit Message", tags=["FreeDATA"], responses={ - 200: { - "description": "Message transmitted successfully.", - "content": { - "application/json": { - "example": { - "destination": "XX1XXX-6", - "body": "Hello FreeDATA" - } - } - } - }, - 404: { - "description": "The requested resource was not found.", - "content": { - "application/json": { - "example": { - "error": "Resource not found." - } - } - } - }, - 503: { - "description": "Modem not running or busy.", - "content": { - "application/json": { - "example": { - "error": "Modem not running." - } - } - } - } -}) -async def post_freedata_message(request: Request): - """ - Transmit a FreeDATA message. - - Parameters: - request (Request): The HTTP request containing the message data in JSON format. - - Returns: - dict: A JSON object containing the transmitted message details. - """ - data = await request.json() - await enqueue_tx_command(command_message_send.SendMessageCommand, data) - return api_response(data) - -@app.post("/freedata/messages/{message_id}/adif", summary="Send Message ADIF Log", tags=["FreeDATA"], responses={ - 200: { - "description": "ADIF log sent successfully.", - "content": { - "application/json": { - "example": { - "adif_output": "ADIF data..." - } - } - } - }, - 400: { - "description": "Bad Request: The request was malformed or missing required parameters.", - "content": { - "application/json": { - "example": { - "error": "Invalid message ID." - } - } - } - }, - 404: { - "description": "The requested resource was not found.", - "content": { - "application/json": { - "example": { - "error": "Message not found." - } - } - } - }, - 503: { - "description": "Modem not running.", - "content": { - "application/json": { - "example": { - "error": "Modem not running." - } - } - } - } -}) -async def post_freedata_message_adif_log(message_id: str): - adif_output = DatabaseManagerMessages(app.event_manager).get_message_by_id_adif(message_id) - - # if message not found do not send adif as the return then is not valid - if not adif_output: - return - - # Send the ADIF data via UDP - adif_udp_logger.send_adif_qso_data(app.config_manager.read(), adif_output) - wavelog_api_logger.send_wavelog_qso_data(app.config_manager.read(), adif_output) - return api_response(adif_output) - -@app.patch("/freedata/messages/{message_id}", summary="Update Message by ID", tags=["FreeDATA"], responses={ - 200: { - "description": "Message updated successfully.", - "content": { - "application/json": { - "example": { - "is_read": True - } - } - } - }, - 400: { - "description": "Bad Request: The request was malformed or missing required parameters.", - "content": { - "application/json": { - "example": { - "error": "Invalid parameters." - } - } - } - }, - 404: { - "description": "Message not found.", - "content": { - "application/json": { - "example": { - "error": "Message not found." - } - } - } - } -}) -async def patch_freedata_message(message_id: str, request: Request): - """ - Update a FreeDATA message by its ID. - - Parameters: - message_id (str): The ID of the message to update. - request (Request): The HTTP request containing the update data in JSON format. - - Returns: - dict: A JSON object containing the updated message details. - """ - data = await request.json() - - if data.get("action") == "retransmit": - result = DatabaseManagerMessages(app.event_manager).update_message(message_id, update_data={'status': 'queued'}) - DatabaseManagerMessages(app.event_manager).increment_message_attempts(message_id) - else: - result = DatabaseManagerMessages(app.event_manager).update_message(message_id, update_data=data) - - return api_response(result) - - -@app.get("/freedata/messages", summary="Get All Messages", tags=["FreeDATA"], responses={ - 200: { - "description": "List of all messages.", - "content": { - "application/json": { - "example": { - "total_messages": 1, - "messages": [ - { - "id": "DXCALL-6_MYCALL-0_2024-04-12T20:39:05.302479", - "timestamp": "2024-04-12T20:39:05.302479", - "origin": "DXCALL-6", - "via": None, - "destination": "MYCALL-0", - "direction": "receive", - "body": "Hello !", - "attachments": [], - "status": "received", - "priority": 10, - "is_read": False, - "statistics": { - "total_bytes": 120, - "duration": 29.76698660850525, - "bytes_per_minute": 241, - "time_histogram": { - "0": "2024-04-12T20:39:23.423169", - "1": "2024-04-12T20:39:30.504638", - "2": "2024-04-12T20:39:37.745075" - }, - "snr_histogram": { - "0": -6, - "1": -6, - "2": -6 - }, - "bpm_histogram": { - "0": 198, - "1": 265, - "2": 252 - } - } - } - ] - } - } - } - }, - 404: { - "description": "The requested resource was not found.", - "content": { - "application/json": { - "example": { - "error": "Resource not found." - } - } - } - } -}) -async def get_freedata_messages(request: Request): - filters = {k: v for k, v in request.query_params.items() if v} - result = DatabaseManagerMessages(app.event_manager).get_all_messages_json(filters=filters) - return api_response(result) - - -@app.post("/freedata/messages", summary="Transmit Message", tags=["FreeDATA"], responses={ - 200: { - "description": "Message transmitted successfully.", - "content": { - "application/json": { - "example": { - "destination": "XX1XXX-6", - "body": "Hello FreeDATA" - } - } - } - }, - 404: { - "description": "The requested resource was not found.", - "content": { - "application/json": { - "example": { - "error": "Resource not found." - } - } - } - } -}) -async def post_freedata_message(request: Request): - """ - Transmit a FreeDATA message. - - Parameters: - request (Request): The HTTP request containing the message data in JSON format. - - Returns: - dict: A JSON object containing the transmitted message details. - """ - data = await request.json() - await enqueue_tx_command(command_message_send.SendMessageCommand, data) - return api_response(data) - - - -@app.delete("/freedata/messages/{message_id}", summary="Delete Message by ID", tags=["FreeDATA"], responses={ - 200: { - "description": "Message deleted successfully.", - "content": { - "application/json": { - "example": { - "message": "DXCALL-0_MYCALL-5_2024-04-04T17:22:14.002502 deleted", - "status": "success" - } - } - } - }, - 404: { - "description": "Message not found.", - "content": { - "application/json": { - "example": { - "message": "Message not found", - "status": "failure" - } - } - } - } -}) -async def delete_freedata_message(message_id: str): - result = DatabaseManagerMessages(app.event_manager).delete_message(message_id) - if result: - return api_response({"message": f"{message_id} deleted", "status": "success"}) - else: - return api_response({"message": "Message not found", "status": "failure"}, status_code=404) - - -@app.get("/freedata/messages/{message_id}/attachments", summary="Get Attachments by Message ID", tags=["FreeDATA"], responses={ - 200: { - "description": "List of attachments for the specified message.", - "content": { - "application/json": { - "example": { - "attachments": [ - { - "id": "attachment1", - "filename": "file1.txt", - "file_size": 1024, - "file_type": "text/plain", - "data_sha512": "abcdef1234567890..." - }, - { - "id": "attachment2", - "filename": "image.png", - "file_size": 2048, - "file_type": "image/png", - "data_sha512": "123456abcdef7890..." - } - ] - } - } - } - }, - 404: { - "description": "The requested resource was not found.", - "content": { - "application/json": { - "example": { - "error": "Resource not found." - } - } - } - }, - 503: { - "description": "Modem not running.", - "content": { - "application/json": { - "example": { - "error": "Modem not running." - } - } - } - } -}) -async def get_message_attachments(message_id: str): - attachments = DatabaseManagerAttachments(app.event_manager).get_attachments_by_message_id_json(message_id) - return api_response(attachments) - - -@app.get("/freedata/messages/attachment/{data_sha512}", summary="Get Attachment by SHA512", tags=["FreeDATA"], responses={ - 200: { - "description": "Retrieve a specific attachment by its SHA512 hash.", - "content": { - "application/json": { - "example": { - "id": "attachment1", - "filename": "file1.txt", - "file_size": 1024, - "file_type": "text/plain", - "data_sha512": "abcdef1234567890..." - } - } - } - }, - 404: { - "description": "The requested attachment was not found.", - "content": { - "application/json": { - "example": { - "error": "Attachment not found." - } - } - } - }, - 503: { - "description": "Modem not running.", - "content": { - "application/json": { - "example": { - "error": "Modem not running." - } - } - } - } -}) -async def get_message_attachment(data_sha512: str): - attachment = DatabaseManagerAttachments(app.event_manager).get_attachment_by_sha512(data_sha512) - return api_response(attachment) - - -@app.get("/freedata/beacons", summary="Get Received Beacons", tags=["FreeDATA"], responses={ - 200: { - "description": "List of received beacons.", - "content": { - "application/json": { - "example": { - "total_beacons": 2, - "beacons": [ - { - "id": "DXCALL-0_MYCALL-5_2024-04-04T17:22:14.002502", - "timestamp": "2024-04-04T17:22:14.002502", - "origin": "DXCALL-0", - "via": None, - "destination": "MYCALL-5", - "direction": "receive", - "body": "Hello FreeDATA", - "attachments": [], - "status": "received", - "priority": 10, - "is_read": False, - "statistics": { - "total_bytes": 120, - "duration": 29.77, - "bytes_per_minute": 241, - "time_histogram": { - "0": "2024-04-04T17:22:23.423169", - "1": "2024-04-04T17:22:30.504638", - "2": "2024-04-04T17:22:37.745075" - }, - "snr_histogram": { - "0": -6, - "1": -6, - "2": -6 - }, - "bpm_histogram": { - "0": 198, - "1": 265, - "2": 252 - } - } - } - ] - } - } - } - }, - 400: { - "description": "Bad Request: The request was malformed or missing required parameters.", - "content": { - "application/json": { - "example": { - "error": "Invalid request." - } - } - } - }, - 404: { - "description": "The requested resource was not found.", - "content": { - "application/json": { - "example": { - "error": "Resource not found." - } - } - } - }, - 500: { - "description": "Internal Server Error: An unexpected error occurred on the server.", - "content": { - "application/json": { - "example": { - "error": "Internal server error." - } - } - } - }, - 503: { - "description": "Modem not running.", - "content": { - "application/json": { - "example": { - "error": "Modem not running." - } - } - } - } -}) -async def get_all_beacons(): - beacons = DatabaseManagerBeacon(app.event_manager).get_all_beacons() - return api_response(beacons) - - -@app.get("/freedata/beacons/{callsign}", summary="Get Beacon by Callsign", tags=["FreeDATA"], responses={ - 200: { - "description": "List of beacons from the specified callsign.", - "content": { - "application/json": { - "example": { - "beacons": [ - { - "id": "DXCALL-0_MYCALL-5_2024-04-04T17:22:14.002502", - "timestamp": "2024-04-04T17:22:14.002502", - "origin": "DXCALL-0", - "via": None, - "destination": "MYCALL-5", - "direction": "receive", - "body": "Hello FreeDATA", - "attachments": [], - "status": "received", - "priority": 10, - "is_read": False, - "statistics": { - "total_bytes": 120, - "duration": 29.77, - "bytes_per_minute": 241, - "time_histogram": { - "0": "2024-04-04T17:22:23.423169", - "1": "2024-04-04T17:22:30.504638", - "2": "2024-04-04T17:22:37.745075" - }, - "snr_histogram": { - "0": -6, - "1": -6, - "2": -6 - }, - "bpm_histogram": { - "0": 198, - "1": 265, - "2": 252 - } - } - } - ] - } - } - } - }, - 400: { - "description": "Bad Request: The request was malformed or missing required parameters.", - "content": { - "application/json": { - "example": { - "error": "Invalid request." - } - } - } - }, - 404: { - "description": "The requested resource was not found.", - "content": { - "application/json": { - "example": { - "error": "Resource not found." - } - } - } - }, - 500: { - "description": "Internal Server Error: An unexpected error occurred on the server.", - "content": { - "application/json": { - "example": { - "error": "Internal server error." - } - } - } - }, - 503: { - "description": "Modem not running.", - "content": { - "application/json": { - "example": { - "error": "Modem not running." - } - } - } - } -}) -async def get_beacons_by_callsign(callsign: str): - beacons = DatabaseManagerBeacon(app.event_manager).get_beacons_by_callsign(callsign) - return api_response(beacons) - - -@app.get("/freedata/station/{callsign}", summary="Get Station Info", tags=["FreeDATA"], responses={ - 200: { - "description": "Retrieve station information by callsign.", - "content": { - "application/json": { - "example": { - "callsign": "MYCALL-0", - "location": "Springfield", - "frequency": "14093000", - "mode": "PKTUSB", - "status": "active", - "additional_info": "Station details here." - } - } - } - }, - 400: { - "description": "Bad Request: The request was malformed or missing required parameters.", - "content": { - "application/json": { - "example": { - "error": "Invalid callsign parameter." - } - } - } - }, - 404: { - "description": "The requested station was not found.", - "content": { - "application/json": { - "example": { - "error": "Station not found." - } - } - } - }, - 500: { - "description": "Internal Server Error: An unexpected error occurred on the server.", - "content": { - "application/json": { - "example": { - "error": "Internal server error." - } - } - } - }, - 503: { - "description": "Modem not running.", - "content": { - "application/json": { - "example": { - "error": "Modem not running." - } - } - } - } -}) -async def get_station_info(callsign: str): - station = DatabaseManagerStations(app.event_manager).get_station(callsign) - return api_response(station) - - -@app.post("/freedata/station/{callsign}", summary="Set Station Info", tags=["FreeDATA"], responses={ - 200: { - "description": "Station information updated successfully.", - "content": { - "application/json": { - "example": { - "callsign": "MYCALL-0", - "location": "Springfield", - "frequency": "14093000", - "mode": "PKTUSB", - "status": "active", - "additional_info": "Updated station details." - } - } - } - }, - 400: { - "description": "Bad Request: The request was malformed or missing required parameters.", - "content": { - "application/json": { - "example": { - "error": "Invalid input data." - } - } - } - }, - 404: { - "description": "The requested station was not found.", - "content": { - "application/json": { - "example": { - "error": "Station not found." - } - } - } - }, - 500: { - "description": "Internal Server Error: An unexpected error occurred on the server.", - "content": { - "application/json": { - "example": { - "error": "Internal server error." - } - } - } - }, - 503: { - "description": "Modem not running.", - "content": { - "application/json": { - "example": { - "error": "Modem not running." - } - } - } - } -}) -async def set_station_info(callsign: str, request: Request): - data = await request.json() - result = DatabaseManagerStations(app.event_manager).update_station_info(callsign, new_info=data["info"]) - return api_response(result) - -# WebSocket Event Handlers -@app.websocket("/events") -async def websocket_events(websocket: WebSocket): - await websocket.accept() - await app.wsm.handle_connection(websocket, app.wsm.events_client_list, app.modem_events) - -@app.websocket("/fft") -async def websocket_fft(websocket: WebSocket): - await websocket.accept() - await app.wsm.handle_connection(websocket, app.wsm.fft_client_list, app.modem_fft) - -@app.websocket("/states") -async def websocket_states(websocket: WebSocket): - await websocket.accept() - await app.wsm.handle_connection(websocket, app.wsm.states_client_list, app.state_queue) # Signal Handler def signal_handler(sig, frame): @@ -2340,6 +190,8 @@ def main(): signal.signal(signal.SIGINT, signal_handler) app.MODEM_VERSION = MODEM_VERSION + app.API_VERSION = API_VERSION + config_file = set_config() app.config_manager = CONFIG(config_file) app.p2p_data_queue = queue.Queue() diff --git a/tests/test_message_protocol.py b/tests/test_message_protocol.py index 2cef6d8b0..db400f68c 100644 --- a/tests/test_message_protocol.py +++ b/tests/test_message_protocol.py @@ -21,7 +21,7 @@ from data_frame_factory import DataFrameFactory import codec2 import arq_session_irs -from server import enqueue_tx_command +from api.command_helpers import enqueue_tx_command import command_message_send