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.
This commit is contained in:
Lloyd
2025-12-18 11:21:38 +00:00
parent 1458ae00c6
commit 9cd98cd7ad
5 changed files with 1337 additions and 3 deletions
@@ -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:
+5 -1
View File
@@ -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:
+3 -1
View File
@@ -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(
+606 -1
View File
@@ -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)
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 = """<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>pyMC Repeater API Documentation</title>
<link rel="stylesheet" type="text/css" href="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui.css">
<style>
body { margin: 0; padding: 0; }
.topbar { display: none !important; }
</style>
</head>
<body>
<div id="swagger-ui"></div>
<script src="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
<script src="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui-standalone-preset.js"></script>
<script>
window.onload = function() {
SwaggerUIBundle({
url: '/api/openapi',
dom_id: '#swagger-ui',
deepLinking: true,
presets: [
SwaggerUIBundle.presets.apis,
SwaggerUIStandalonePreset
],
plugins: [
SwaggerUIBundle.plugins.DownloadUrl
],
layout: "StandaloneLayout",
tryItOutEnabled: true,
filter: true,
displayRequestDuration: true,
persistAuthorization: true
});
};
</script>
</body>
</html>"""
cherrypy.response.headers['Content-Type'] = 'text/html'
return swagger_html
+639
View File
@@ -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: []