From 9cd98cd7ade25d142195664f30dce78ac9a7b485 Mon Sep 17 00:00:00 2001 From: Lloyd Date: Thu, 18 Dec 2025 11:21:38 +0000 Subject: [PATCH] Add room server messaging endpoints and database functions - Implemented functions to retrieve, post, delete, and clear messages in room servers. - Added API endpoints for room message retrieval, posting, and management. - added OpenAPI documentation to include new room server functionalities. --- repeater/data_acquisition/sqlite_handler.py | 84 +++ repeater/handler_helpers/room_server.py | 6 +- repeater/handler_helpers/text.py | 4 +- repeater/web/api_endpoints.py | 607 ++++++++++++++++++- repeater/web/openapi.yaml | 639 ++++++++++++++++++++ 5 files changed, 1337 insertions(+), 3 deletions(-) create mode 100644 repeater/web/openapi.yaml diff --git a/repeater/data_acquisition/sqlite_handler.py b/repeater/data_acquisition/sqlite_handler.py index 2acef95..806b707 100644 --- a/repeater/data_acquisition/sqlite_handler.py +++ b/repeater/data_acquisition/sqlite_handler.py @@ -1044,6 +1044,90 @@ class SQLiteHandler: logger.error(f"Failed to get room clients: {e}") return [] + def get_room_message_count(self, room_hash: str) -> int: + """Get total number of messages in a room.""" + try: + with sqlite3.connect(self.sqlite_path) as conn: + cursor = conn.execute(""" + SELECT COUNT(*) FROM room_messages WHERE room_hash = ? + """, (room_hash,)) + return cursor.fetchone()[0] + except Exception as e: + logger.error(f"Failed to get room message count: {e}") + return 0 + + def get_room_messages(self, room_hash: str, limit: int = 50, offset: int = 0) -> List[Dict]: + """Get messages from a room with pagination.""" + try: + with sqlite3.connect(self.sqlite_path) as conn: + conn.row_factory = sqlite3.Row + cursor = conn.execute(""" + SELECT * FROM room_messages + WHERE room_hash = ? + ORDER BY post_timestamp DESC + LIMIT ? OFFSET ? + """, (room_hash, limit, offset)) + return [dict(row) for row in cursor.fetchall()] + except Exception as e: + logger.error(f"Failed to get room messages: {e}") + return [] + + def get_messages_since(self, room_hash: str, since_timestamp: float, limit: int = 50) -> List[Dict]: + """Get messages posted after a specific timestamp.""" + try: + with sqlite3.connect(self.sqlite_path) as conn: + conn.row_factory = sqlite3.Row + cursor = conn.execute(""" + SELECT * FROM room_messages + WHERE room_hash = ? AND post_timestamp > ? + ORDER BY post_timestamp DESC + LIMIT ? + """, (room_hash, since_timestamp, limit)) + return [dict(row) for row in cursor.fetchall()] + except Exception as e: + logger.error(f"Failed to get messages since timestamp: {e}") + return [] + + def get_unsynced_count(self, room_hash: str, client_pubkey: str, sync_since: float) -> int: + """Get count of unsynced messages for a client.""" + try: + with sqlite3.connect(self.sqlite_path) as conn: + cursor = conn.execute(""" + SELECT COUNT(*) FROM room_messages + WHERE room_hash = ? + AND author_pubkey != ? + AND post_timestamp > ? + """, (room_hash, client_pubkey, sync_since)) + return cursor.fetchone()[0] + except Exception as e: + logger.error(f"Failed to get unsynced count: {e}") + return 0 + + def delete_room_message(self, room_hash: str, message_id: int) -> bool: + """Delete a specific message by ID.""" + try: + with sqlite3.connect(self.sqlite_path) as conn: + cursor = conn.execute(""" + DELETE FROM room_messages + WHERE room_hash = ? AND id = ? + """, (room_hash, message_id)) + return cursor.rowcount > 0 + except Exception as e: + logger.error(f"Failed to delete message: {e}") + return False + + def clear_room_messages(self, room_hash: str) -> int: + """Clear all messages from a room.""" + try: + with sqlite3.connect(self.sqlite_path) as conn: + cursor = conn.execute(""" + DELETE FROM room_messages WHERE room_hash = ? + """, (room_hash,)) + return cursor.rowcount + except Exception as e: + logger.error(f"Failed to clear room messages: {e}") + return 0 + def cleanup_old_messages(self, room_hash: str, keep_count: int = 32) -> int: """Keep only the most recent N messages per room.""" try: diff --git a/repeater/handler_helpers/room_server.py b/repeater/handler_helpers/room_server.py index 89a0790..d33a023 100644 --- a/repeater/handler_helpers/room_server.py +++ b/repeater/handler_helpers/room_server.py @@ -36,6 +36,9 @@ DB_ERROR_RETRY_DELAY = 60 # Wait 1 minute on DB error (seconds) # Backoff strategy for failed pushes (seconds) RETRY_BACKOFF_SCHEDULE = [0, 30, 300, 3600] # 0s, 30s, 5min, 1hr +# Special author pubkey for server/system messages (not filtered from any client) +SERVER_AUTHOR_PUBKEY = bytes(32) # 32 zeros - use for system announcements that go to ALL clients + # Global rate limiter (shared across all rooms) _global_push_limiter = None _global_push_lock = asyncio.Lock() @@ -148,7 +151,8 @@ class RoomServer: client_pubkey: bytes, message_text: str, sender_timestamp: int, - txt_type: int = TXT_TYPE_PLAIN + txt_type: int = TXT_TYPE_PLAIN, + allow_server_author: bool = False ) -> bool: try: diff --git a/repeater/handler_helpers/text.py b/repeater/handler_helpers/text.py index fa24193..cf66736 100644 --- a/repeater/handler_helpers/text.py +++ b/repeater/handler_helpers/text.py @@ -237,11 +237,13 @@ class TextHelper: txt_type = 0 # TXT_TYPE_PLAIN by default # Store message to room database + # SECURITY: Radio messages cannot use server author key await room_server.add_post( client_pubkey=sender_pubkey, message_text=message_text, sender_timestamp=sender_timestamp, - txt_type=txt_type + txt_type=txt_type, + allow_server_author=False # Block server key from radio ) logger.info( diff --git a/repeater/web/api_endpoints.py b/repeater/web/api_endpoints.py index 99d252e..0255709 100644 --- a/repeater/web/api_endpoints.py +++ b/repeater/web/api_endpoints.py @@ -59,6 +59,16 @@ logger = logging.getLogger("HTTPServer") # POST /api/acl_remove_client {"public_key": "...", "identity_hash": "0x42"} - Remove client from ACL # GET /api/acl_stats - Overall ACL statistics +# Room Server +# GET /api/room_messages?room_name=General&limit=50&offset=0 - Get messages from room +# GET /api/room_messages?room_hash=0x42&limit=50 - Get messages by hash +# POST /api/room_post_message {"room_name": "General", "message": "Hello", "author_pubkey": "abc123"} - Post message +# GET /api/room_stats?room_name=General - Get room statistics +# GET /api/room_stats - Get all rooms statistics +# GET /api/room_clients?room_name=General - Get clients synced to room +# DELETE /api/room_message?room_name=General&message_id=123 - Delete specific message +# DELETE /api/room_messages?room_name=General - Clear all messages in room + # Common Parameters # hours - Time range (default: 24) # resolution - 'average', 'max', 'min' (default: 'average') @@ -1929,4 +1939,599 @@ class APIEndpoints: except Exception as e: logger.error(f"Error getting ACL stats: {e}") - return self._error(e) \ No newline at end of file + return self._error(e) + + # ====================== + # Room Server Endpoints + # ====================== + + def _get_room_server_by_name_or_hash(self, room_name=None, room_hash=None): + """Helper to get room server instance and metadata by name or hash.""" + if not self.daemon_instance or not hasattr(self.daemon_instance, 'text_helper'): + raise Exception("Text helper not available") + + text_helper = self.daemon_instance.text_helper + if not text_helper or not hasattr(text_helper, 'room_servers'): + raise Exception("Room servers not initialized") + + identity_manager = text_helper.identity_manager + + # Find by name first + if room_name: + identities = identity_manager.get_identities_by_type("room_server") + for name, identity, config in identities: + if name == room_name: + hash_byte = identity.get_public_key()[0] + room_server = text_helper.room_servers.get(hash_byte) + if room_server: + return { + 'room_server': room_server, + 'name': name, + 'hash': hash_byte, + 'identity': identity, + 'config': config + } + raise Exception(f"Room '{room_name}' not found") + + # Find by hash + if room_hash: + if isinstance(room_hash, str): + if room_hash.startswith('0x'): + hash_byte = int(room_hash, 16) + else: + hash_byte = int(room_hash) + else: + hash_byte = room_hash + + room_server = text_helper.room_servers.get(hash_byte) + if room_server: + # Find name + identities = identity_manager.get_identities_by_type("room_server") + for name, identity, config in identities: + if identity.get_public_key()[0] == hash_byte: + return { + 'room_server': room_server, + 'name': name, + 'hash': hash_byte, + 'identity': identity, + 'config': config + } + # Found server but no name match + return { + 'room_server': room_server, + 'name': f"Room_0x{hash_byte:02X}", + 'hash': hash_byte, + 'identity': None, + 'config': {} + } + raise Exception(f"Room with hash {room_hash} not found") + + raise Exception("Must provide room_name or room_hash") + + @cherrypy.expose + @cherrypy.tools.json_out() + def room_messages(self, room_name=None, room_hash=None, limit=50, offset=0, since_timestamp=None): + """ + Get messages from a room server. + + Parameters: + room_name: Name of the room + room_hash: Hash of room identity (alternative to name) + limit: Max messages to return (default 50) + offset: Skip first N messages (default 0) + since_timestamp: Only return messages after this timestamp + + Returns: + { + "success": true, + "data": { + "room_name": "General", + "room_hash": "0x42", + "messages": [ + { + "id": 1, + "author_pubkey": "abc123...", + "author_prefix": "abc1", + "post_timestamp": 1234567890.0, + "sender_timestamp": 1234567890, + "message_text": "Hello world", + "txt_type": 0, + "created_at": 1234567890.0 + } + ], + "count": 1, + "total": 100, + "limit": 50, + "offset": 0 + } + } + """ + try: + room_info = self._get_room_server_by_name_or_hash(room_name, room_hash) + room_server = room_info['room_server'] + + # Get messages from database + db = room_server.db + room_hash_str = f"0x{room_info['hash']:02X}" + + # Get total count + total_count = db.get_room_message_count(room_hash_str) + + # Get messages + if since_timestamp: + messages = db.get_messages_since( + room_hash=room_hash_str, + since_timestamp=float(since_timestamp), + limit=int(limit) + ) + else: + messages = db.get_room_messages( + room_hash=room_hash_str, + limit=int(limit), + offset=int(offset) + ) + + # Format messages with author prefix + formatted_messages = [] + for msg in messages: + author_pubkey = msg['author_pubkey'] + formatted_msg = { + 'id': msg['id'], + 'author_pubkey': author_pubkey, + 'author_prefix': author_pubkey[:8] if author_pubkey else '', + 'post_timestamp': msg['post_timestamp'], + 'sender_timestamp': msg['sender_timestamp'], + 'message_text': msg['message_text'], + 'txt_type': msg['txt_type'], + 'created_at': msg.get('created_at', msg['post_timestamp']) + } + formatted_messages.append(formatted_msg) + + return self._success({ + 'room_name': room_info['name'], + 'room_hash': room_hash_str, + 'messages': formatted_messages, + 'count': len(formatted_messages), + 'total': total_count, + 'limit': int(limit), + 'offset': int(offset) + }) + + except Exception as e: + logger.error(f"Error getting room messages: {e}", exc_info=True) + return self._error(e) + + @cherrypy.expose + @cherrypy.tools.json_out() + @cherrypy.tools.json_in() + def room_post_message(self): + """ + Post a message to a room server. + + POST Body: + { + "room_name": "General", // or "room_hash": "0x42" + "message": "Hello world", + "author_pubkey": "abc123...", // hex string, or "server" for system messages + "txt_type": 0 // optional, default 0 + } + + Special Values for author_pubkey: + - "server" or "system": Uses SERVER_AUTHOR_PUBKEY (all zeros), message goes to ALL clients + - Any other hex string: Normal behavior, message NOT sent to that client + + Returns: + {"success": true, "data": {"message_id": 123}} + """ + try: + self._require_post() + + data = cherrypy.request.json + room_name = data.get('room_name') + room_hash = data.get('room_hash') + message = data.get('message') + author_pubkey = data.get('author_pubkey') + txt_type = data.get('txt_type', 0) + + if not message: + return self._error("message is required") + if not author_pubkey: + return self._error("author_pubkey is required") + + # Convert author_pubkey to bytes + try: + # Special case: "server" or "system" = all zeros (goes to ALL clients) + if isinstance(author_pubkey, str) and author_pubkey.lower() in ('server', 'system'): + author_bytes = bytes(32) # 32 zeros + author_pubkey = author_bytes.hex() + elif isinstance(author_pubkey, str): + author_bytes = bytes.fromhex(author_pubkey) + else: + author_bytes = bytes(author_pubkey) + except Exception as e: + return self._error(f"Invalid author_pubkey: {e}") + + # Get room server + room_info = self._get_room_server_by_name_or_hash(room_name, room_hash) + room_server = room_info['room_server'] + + # Add post to room (will be distributed asynchronously) + import asyncio + if self.event_loop: + sender_timestamp = int(time.time()) + # SECURITY: API is allowed to use server author key (for system messages) + # TODO: Add authentication/authorization check before allowing this + is_server_author = (author_bytes == bytes(32)) + future = asyncio.run_coroutine_threadsafe( + room_server.add_post( + client_pubkey=author_bytes, + message_text=message, + sender_timestamp=sender_timestamp, + txt_type=txt_type, + allow_server_author=is_server_author # Allow server key from API + ), + self.event_loop + ) + success = future.result(timeout=5) + + if success: + # Get the message ID (last inserted) + db = room_server.db + room_hash_str = f"0x{room_info['hash']:02X}" + messages = db.get_room_messages(room_hash_str, limit=1, offset=0) + message_id = messages[0]['id'] if messages else None + + is_server_msg = author_pubkey == "0" * 64 + + return self._success({ + 'message_id': message_id, + 'room_name': room_info['name'], + 'room_hash': room_hash_str, + 'queued_for_distribution': True, + 'is_server_message': is_server_msg, + 'author_filter_note': 'Server messages go to ALL clients' if is_server_msg else 'Message will NOT be sent to author' + }) + else: + return self._error("Failed to add message (rate limit or validation error)") + else: + return self._error("Event loop not available") + + except cherrypy.HTTPError: + raise + except Exception as e: + logger.error(f"Error posting room message: {e}", exc_info=True) + return self._error(e) + + @cherrypy.expose + @cherrypy.tools.json_out() + def room_stats(self, room_name=None, room_hash=None): + """ + Get statistics for one or all room servers. + + Parameters: + room_name: Name of specific room (optional) + room_hash: Hash of specific room (optional) + + If no parameters, returns stats for all rooms. + + Returns: + { + "success": true, + "data": { + "room_name": "General", + "room_hash": "0x42", + "total_messages": 100, + "total_clients": 5, + "active_clients": 3, + "max_posts": 32, + "sync_running": true, + "clients": [ + { + "pubkey": "abc123...", + "pubkey_prefix": "abc1", + "sync_since": 1234567890.0, + "unsynced_count": 2, + "pending_ack": false, + "push_failures": 0, + "last_activity": 1234567890.0 + } + ] + } + } + """ + try: + if not self.daemon_instance or not hasattr(self.daemon_instance, 'text_helper'): + return self._error("Text helper not available") + + text_helper = self.daemon_instance.text_helper + + # Get all rooms if no specific room requested + if not room_name and not room_hash: + all_rooms = [] + for hash_byte, room_server in text_helper.room_servers.items(): + # Find room name + room_name_found = f"Room_0x{hash_byte:02X}" + identities = text_helper.identity_manager.get_identities_by_type("room_server") + for name, identity, config in identities: + if identity.get_public_key()[0] == hash_byte: + room_name_found = name + break + + db = room_server.db + room_hash_str = f"0x{hash_byte:02X}" + + # Get basic stats + total_messages = db.get_room_message_count(room_hash_str) + all_clients_sync = db.get_all_room_clients(room_hash_str) + active_clients = sum(1 for c in all_clients_sync if c.get('last_activity', 0) > 0) + + all_rooms.append({ + 'room_name': room_name_found, + 'room_hash': room_hash_str, + 'total_messages': total_messages, + 'total_clients': len(all_clients_sync), + 'active_clients': active_clients, + 'max_posts': room_server.max_posts, + 'sync_running': room_server._running + }) + + return self._success({ + 'rooms': all_rooms, + 'total_rooms': len(all_rooms) + }) + + # Get specific room stats + room_info = self._get_room_server_by_name_or_hash(room_name, room_hash) + room_server = room_info['room_server'] + db = room_server.db + room_hash_str = f"0x{room_info['hash']:02X}" + + # Get message count + total_messages = db.get_room_message_count(room_hash_str) + + # Get client sync states + all_clients_sync = db.get_all_room_clients(room_hash_str) + + # Get ACL for this room + acl = None + if room_info['hash'] in text_helper.acl_dict: + acl = text_helper.acl_dict[room_info['hash']] + + # Format client info + clients_info = [] + active_count = 0 + for client_sync in all_clients_sync: + pubkey_hex = client_sync['client_pubkey'] + pubkey_bytes = bytes.fromhex(pubkey_hex) + + # Check if still in ACL + in_acl = False + if acl: + acl_clients = acl.get_all_clients() + in_acl = any(c.id.get_public_key() == pubkey_bytes for c in acl_clients) + + unsynced_count = db.get_unsynced_count( + room_hash=room_hash_str, + client_pubkey=pubkey_hex, + sync_since=client_sync.get('sync_since', 0) + ) + + is_active = client_sync.get('last_activity', 0) > 0 + if is_active: + active_count += 1 + + clients_info.append({ + 'pubkey': pubkey_hex, + 'pubkey_prefix': pubkey_hex[:8], + 'sync_since': client_sync.get('sync_since', 0), + 'unsynced_count': unsynced_count, + 'pending_ack': client_sync.get('pending_ack_crc', 0) != 0, + 'pending_ack_crc': client_sync.get('pending_ack_crc', 0), + 'push_failures': client_sync.get('push_failures', 0), + 'last_activity': client_sync.get('last_activity', 0), + 'in_acl': in_acl, + 'is_active': is_active + }) + + return self._success({ + 'room_name': room_info['name'], + 'room_hash': room_hash_str, + 'total_messages': total_messages, + 'total_clients': len(all_clients_sync), + 'active_clients': active_count, + 'max_posts': room_server.max_posts, + 'sync_running': room_server._running, + 'next_push_time': room_server.next_push_time, + 'last_cleanup_time': room_server.last_cleanup_time, + 'clients': clients_info + }) + + except Exception as e: + logger.error(f"Error getting room stats: {e}", exc_info=True) + return self._error(e) + + @cherrypy.expose + @cherrypy.tools.json_out() + def room_clients(self, room_name=None, room_hash=None): + """ + Get list of clients synced to a room. + + Parameters: + room_name: Name of the room + room_hash: Hash of room identity + + Returns: + { + "success": true, + "data": { + "room_name": "General", + "room_hash": "0x42", + "clients": [...] + } + } + """ + try: + # Reuse room_stats logic but return only clients + stats = self.room_stats(room_name=room_name, room_hash=room_hash) + if stats.get('success') and 'clients' in stats.get('data', {}): + data = stats['data'] + return self._success({ + 'room_name': data['room_name'], + 'room_hash': data['room_hash'], + 'clients': data['clients'], + 'total': len(data['clients']), + 'active': data['active_clients'] + }) + else: + return stats + except Exception as e: + logger.error(f"Error getting room clients: {e}") + return self._error(e) + + @cherrypy.expose + @cherrypy.tools.json_out() + def room_message(self, room_name=None, room_hash=None, message_id=None): + """ + Delete a specific message from a room. + + Parameters: + room_name: Name of the room + room_hash: Hash of room identity + message_id: ID of message to delete + + Returns: + {"success": true} + """ + try: + if cherrypy.request.method != "DELETE": + cherrypy.response.status = 405 + return self._error("Method not allowed. Use DELETE.") + + if not message_id: + return self._error("message_id is required") + + room_info = self._get_room_server_by_name_or_hash(room_name, room_hash) + room_server = room_info['room_server'] + db = room_server.db + room_hash_str = f"0x{room_info['hash']:02X}" + + # Delete message + deleted = db.delete_room_message(room_hash_str, int(message_id)) + + if deleted: + return self._success({ + 'deleted': True, + 'message_id': int(message_id), + 'room_name': room_info['name'] + }) + else: + return self._error("Message not found or already deleted") + + except Exception as e: + logger.error(f"Error deleting room message: {e}") + return self._error(e) + + @cherrypy.expose + @cherrypy.tools.json_out() + def room_messages_clear(self, room_name=None, room_hash=None): + """ + Clear all messages from a room. + + Parameters: + room_name: Name of the room + room_hash: Hash of room identity + + Returns: + {"success": true, "data": {"deleted_count": 123}} + """ + try: + if cherrypy.request.method != "DELETE": + cherrypy.response.status = 405 + return self._error("Method not allowed. Use DELETE.") + + room_info = self._get_room_server_by_name_or_hash(room_name, room_hash) + room_server = room_info['room_server'] + db = room_server.db + room_hash_str = f"0x{room_info['hash']:02X}" + + # Get count before deleting + count_before = db.get_room_message_count(room_hash_str) + + # Clear all messages + deleted = db.clear_room_messages(room_hash_str) + + return self._success({ + 'deleted_count': deleted or count_before, + 'room_name': room_info['name'], + 'room_hash': room_hash_str + }) + + except Exception as e: + logger.error(f"Error clearing room messages: {e}") + return self._error(e) + + # ====================== + # OpenAPI Documentation + # ====================== + + @cherrypy.expose + def openapi(self): + """Serve OpenAPI specification in YAML format.""" + import os + spec_path = os.path.join(os.path.dirname(__file__), 'openapi.yaml') + try: + with open(spec_path, 'r') as f: + spec_content = f.read() + cherrypy.response.headers['Content-Type'] = 'application/x-yaml' + return spec_content + except FileNotFoundError: + cherrypy.response.status = 404 + return "OpenAPI spec not found" + except Exception as e: + cherrypy.response.status = 500 + return f"Error loading OpenAPI spec: {e}" + + @cherrypy.expose + def docs(self): + """Serve Swagger UI for interactive API documentation.""" + swagger_html = """ + + + + + pyMC Repeater API Documentation + + + + +
+ + + + +""" + cherrypy.response.headers['Content-Type'] = 'text/html' + return swagger_html \ No newline at end of file diff --git a/repeater/web/openapi.yaml b/repeater/web/openapi.yaml new file mode 100644 index 0000000..bcb20a2 --- /dev/null +++ b/repeater/web/openapi.yaml @@ -0,0 +1,639 @@ +openapi: 3.0.0 +info: + title: pyMC Repeater API + description: | + REST API for pyMC Repeater - LoRa mesh network repeater with room server functionality. + + ## Features + - System statistics and monitoring + - Packet history and analysis + - Identity management + - Access Control Lists (ACL) + - Room server messaging + - CAD calibration + - Noise floor monitoring + version: 1.0.0 + contact: + name: pyMC Repeater + url: https://github.com/yourusername/pymc_repeater + +servers: + - url: http://localhost:8080/api + description: Local development server + - url: http://{host}:8080/api + description: Custom host + variables: + host: + default: localhost + description: Repeater IP address + +tags: + - name: System + description: System statistics and control + - name: Packets + description: Packet history and statistics + - name: Charts + description: Graph data and RRD metrics + - name: Noise Floor + description: Noise floor monitoring + - name: CAD Calibration + description: Channel Activity Detection calibration + - name: Identities + description: Identity management + - name: ACL + description: Access Control Lists + - name: Room Server + description: Room server messaging + +paths: + /stats: + get: + tags: [System] + summary: Get system statistics + description: Returns repeater uptime, packet counts, and version information + responses: + '200': + description: Successful response + content: + application/json: + schema: + type: object + properties: + uptime_secs: + type: integer + example: 3600 + packets_received: + type: integer + example: 150 + packets_sent: + type: integer + example: 120 + version: + type: string + example: "1.0.0" + core_version: + type: string + example: "0.5.0" + + /send_advert: + post: + tags: [System] + summary: Send repeater advertisement + description: Manually trigger sending a repeater advertisement packet + responses: + '200': + description: Advertisement sent + content: + application/json: + schema: + $ref: '#/components/schemas/SuccessResponse' + '405': + description: Method not allowed + + /set_mode: + post: + tags: [System] + summary: Set repeater mode + description: Switch between forward and monitor modes + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [mode] + properties: + mode: + type: string + enum: [forward, monitor] + example: forward + responses: + '200': + description: Mode changed + content: + application/json: + schema: + $ref: '#/components/schemas/SuccessResponse' + + /packet_stats: + get: + tags: [Packets] + summary: Get packet statistics + parameters: + - name: hours + in: query + schema: + type: integer + default: 24 + description: Time range in hours + responses: + '200': + description: Packet statistics + content: + application/json: + schema: + type: object + + /packet_by_hash: + get: + tags: [Packets] + summary: Get packet by hash + parameters: + - name: packet_hash + in: query + required: true + schema: + type: string + description: Packet hash to lookup + responses: + '200': + description: Packet details + + /noise_floor_history: + get: + tags: [Noise Floor] + summary: Get noise floor history + parameters: + - name: hours + in: query + schema: + type: integer + default: 24 + responses: + '200': + description: Noise floor history data + + /noise_floor_stats: + get: + tags: [Noise Floor] + summary: Get noise floor statistics + parameters: + - name: hours + in: query + schema: + type: integer + default: 24 + responses: + '200': + description: Noise floor stats + + /cad_calibration_start: + post: + tags: [CAD Calibration] + summary: Start CAD calibration + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + samples: + type: integer + default: 8 + delay: + type: integer + default: 100 + responses: + '200': + description: Calibration started + + /cad_calibration_stop: + post: + tags: [CAD Calibration] + summary: Stop CAD calibration + responses: + '200': + description: Calibration stopped + + /identities: + get: + tags: [Identities] + summary: List all identities + responses: + '200': + description: List of identities + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + data: + type: object + properties: + identities: + type: array + items: + $ref: '#/components/schemas/Identity' + + /identity: + get: + tags: [Identities] + summary: Get specific identity + parameters: + - name: name + in: query + required: true + schema: + type: string + responses: + '200': + description: Identity details + + /create_identity: + post: + tags: [Identities] + summary: Create new identity + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [name, type] + properties: + name: + type: string + identity_key: + type: string + type: + type: string + enum: [repeater, room_server] + settings: + type: object + responses: + '200': + description: Identity created + + /acl_info: + get: + tags: [ACL] + summary: Get ACL configuration + description: Returns ACL settings and statistics for all identities + responses: + '200': + description: ACL information + + /acl_clients: + get: + tags: [ACL] + summary: List authenticated clients + parameters: + - name: identity_hash + in: query + schema: + type: string + example: "0x42" + - name: identity_name + in: query + schema: + type: string + responses: + '200': + description: Client list + + /acl_remove_client: + post: + tags: [ACL] + summary: Remove client from ACL + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [public_key, identity_hash] + properties: + public_key: + type: string + identity_hash: + type: string + responses: + '200': + description: Client removed + + /acl_stats: + get: + tags: [ACL] + summary: Get ACL statistics + responses: + '200': + description: ACL statistics + + /room_messages: + get: + tags: [Room Server] + summary: Get room messages + description: Retrieve messages from a room with pagination + parameters: + - name: room_name + in: query + schema: + type: string + example: General + - name: room_hash + in: query + schema: + type: string + example: "0x42" + - name: limit + in: query + schema: + type: integer + default: 50 + - name: offset + in: query + schema: + type: integer + default: 0 + - name: since_timestamp + in: query + schema: + type: number + format: float + responses: + '200': + description: Messages retrieved + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + data: + type: object + properties: + room_name: + type: string + room_hash: + type: string + messages: + type: array + items: + $ref: '#/components/schemas/RoomMessage' + count: + type: integer + total: + type: integer + limit: + type: integer + offset: + type: integer + + /room_post_message: + post: + tags: [Room Server] + summary: Post message to room + description: | + Add a new message to a room server. Message will be distributed to all synced clients. + + **Special author values:** + - `"server"` or `"system"` - System message, goes to ALL clients + - Any hex string - Normal message, NOT sent to that client + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [message, author_pubkey] + properties: + room_name: + type: string + example: General + room_hash: + type: string + example: "0x42" + message: + type: string + maxLength: 160 + example: "Hello from API" + author_pubkey: + type: string + example: "abc123def456..." + description: "Hex string or 'server' for system messages" + txt_type: + type: integer + default: 0 + responses: + '200': + description: Message posted + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + data: + type: object + properties: + message_id: + type: integer + room_name: + type: string + room_hash: + type: string + queued_for_distribution: + type: boolean + is_server_message: + type: boolean + author_filter_note: + type: string + + /room_stats: + get: + tags: [Room Server] + summary: Get room statistics + description: Get detailed statistics for one or all rooms + parameters: + - name: room_name + in: query + schema: + type: string + - name: room_hash + in: query + schema: + type: string + responses: + '200': + description: Room statistics + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + data: + type: object + properties: + room_name: + type: string + room_hash: + type: string + total_messages: + type: integer + total_clients: + type: integer + active_clients: + type: integer + max_posts: + type: integer + example: 32 + sync_running: + type: boolean + clients: + type: array + items: + $ref: '#/components/schemas/RoomClient' + + /room_clients: + get: + tags: [Room Server] + summary: Get room clients + parameters: + - name: room_name + in: query + schema: + type: string + - name: room_hash + in: query + schema: + type: string + responses: + '200': + description: Client list + + /room_message: + delete: + tags: [Room Server] + summary: Delete specific message + parameters: + - name: room_name + in: query + schema: + type: string + - name: room_hash + in: query + schema: + type: string + - name: message_id + in: query + required: true + schema: + type: integer + responses: + '200': + description: Message deleted + + /room_messages_clear: + delete: + tags: [Room Server] + summary: Clear all room messages + description: ⚠️ Destructive operation - cannot be undone! + parameters: + - name: room_name + in: query + schema: + type: string + - name: room_hash + in: query + schema: + type: string + responses: + '200': + description: Messages cleared + +components: + schemas: + SuccessResponse: + type: object + properties: + success: + type: boolean + data: + type: object + + ErrorResponse: + type: object + properties: + success: + type: boolean + example: false + error: + type: string + + Identity: + type: object + properties: + name: + type: string + type: + type: string + enum: [repeater, room_server] + hash: + type: string + public_key: + type: string + + RoomMessage: + type: object + properties: + id: + type: integer + author_pubkey: + type: string + author_prefix: + type: string + post_timestamp: + type: number + format: float + sender_timestamp: + type: integer + message_text: + type: string + txt_type: + type: integer + created_at: + type: number + format: float + + RoomClient: + type: object + properties: + pubkey: + type: string + pubkey_prefix: + type: string + sync_since: + type: number + format: float + unsynced_count: + type: integer + pending_ack: + type: boolean + push_failures: + type: integer + last_activity: + type: number + format: float + in_acl: + type: boolean + is_active: + type: boolean + + securitySchemes: + # Future: API key authentication + ApiKeyAuth: + type: apiKey + in: header + name: X-API-Key + description: API key authentication (not yet implemented) + +# Future security - currently open +# security: +# - ApiKeyAuth: []