feat: Add MQTT publisher and tester scripts for USDA Vision Camera System

- Implemented mqtt_publisher_test.py for manual MQTT message publishing
- Created mqtt_test.py to test MQTT message reception and display statistics
- Developed test_api_changes.py to verify API changes for camera settings and filename handling
- Added test_camera_recovery_api.py for testing camera recovery API endpoints
- Introduced test_max_fps.py to demonstrate maximum FPS capture functionality
- Implemented test_mqtt_events_api.py to test MQTT events API endpoint
- Created test_mqtt_logging.py for enhanced MQTT logging and API endpoint testing
- Added sdk_config.py for SDK initialization and configuration with error suppression
This commit is contained in:
Alireza Vaezi
2025-07-28 16:30:14 -04:00
parent e2acebc056
commit 9cb043ef5f
40 changed files with 4485 additions and 838 deletions

View File

@@ -11,6 +11,7 @@ from pydantic import BaseModel, Field
class SystemStatusResponse(BaseModel):
"""System status response model"""
system_started: bool
mqtt_connected: bool
last_mqtt_message: Optional[str] = None
@@ -23,6 +24,7 @@ class SystemStatusResponse(BaseModel):
class MachineStatusResponse(BaseModel):
"""Machine status response model"""
name: str
state: str
last_updated: str
@@ -30,8 +32,22 @@ class MachineStatusResponse(BaseModel):
mqtt_topic: Optional[str] = None
class MQTTStatusResponse(BaseModel):
"""MQTT status response model"""
connected: bool
broker_host: str
broker_port: int
subscribed_topics: List[str]
last_message_time: Optional[str] = None
message_count: int
error_count: int
uptime_seconds: Optional[float] = None
class CameraStatusResponse(BaseModel):
"""Camera status response model"""
name: str
status: str
is_recording: bool
@@ -44,6 +60,7 @@ class CameraStatusResponse(BaseModel):
class RecordingInfoResponse(BaseModel):
"""Recording information response model"""
camera_name: str
filename: str
start_time: str
@@ -57,12 +74,16 @@ class RecordingInfoResponse(BaseModel):
class StartRecordingRequest(BaseModel):
"""Start recording request model"""
camera_name: str
filename: Optional[str] = None
exposure_ms: Optional[float] = Field(default=None, description="Exposure time in milliseconds")
gain: Optional[float] = Field(default=None, description="Camera gain value")
fps: Optional[float] = Field(default=None, description="Target frames per second")
class StartRecordingResponse(BaseModel):
"""Start recording response model"""
success: bool
message: str
filename: Optional[str] = None
@@ -70,11 +91,15 @@ class StartRecordingResponse(BaseModel):
class StopRecordingRequest(BaseModel):
"""Stop recording request model"""
camera_name: str
# Note: This model is currently unused as the stop recording endpoint
# only requires the camera_name from the URL path parameter
pass
class StopRecordingResponse(BaseModel):
"""Stop recording response model"""
success: bool
message: str
duration_seconds: Optional[float] = None
@@ -82,6 +107,7 @@ class StopRecordingResponse(BaseModel):
class StorageStatsResponse(BaseModel):
"""Storage statistics response model"""
base_path: str
total_files: int
total_size_bytes: int
@@ -91,6 +117,7 @@ class StorageStatsResponse(BaseModel):
class FileListRequest(BaseModel):
"""File list request model"""
camera_name: Optional[str] = None
start_date: Optional[str] = None
end_date: Optional[str] = None
@@ -99,17 +126,20 @@ class FileListRequest(BaseModel):
class FileListResponse(BaseModel):
"""File list response model"""
files: List[Dict[str, Any]]
total_count: int
class CleanupRequest(BaseModel):
"""Cleanup request model"""
max_age_days: Optional[int] = None
class CleanupResponse(BaseModel):
"""Cleanup response model"""
files_removed: int
bytes_freed: int
errors: List[str]
@@ -117,6 +147,7 @@ class CleanupResponse(BaseModel):
class EventResponse(BaseModel):
"""Event response model"""
event_type: str
source: str
data: Dict[str, Any]
@@ -125,6 +156,7 @@ class EventResponse(BaseModel):
class WebSocketMessage(BaseModel):
"""WebSocket message model"""
type: str
data: Dict[str, Any]
timestamp: Optional[str] = None
@@ -132,13 +164,53 @@ class WebSocketMessage(BaseModel):
class ErrorResponse(BaseModel):
"""Error response model"""
error: str
details: Optional[str] = None
timestamp: str = Field(default_factory=lambda: datetime.now().isoformat())
class CameraRecoveryResponse(BaseModel):
"""Camera recovery response model"""
success: bool
message: str
camera_name: str
operation: str
timestamp: str = Field(default_factory=lambda: datetime.now().isoformat())
class CameraTestResponse(BaseModel):
"""Camera connection test response model"""
success: bool
message: str
camera_name: str
timestamp: str = Field(default_factory=lambda: datetime.now().isoformat())
class MQTTEventResponse(BaseModel):
"""MQTT event response model"""
machine_name: str
topic: str
payload: str
normalized_state: str
timestamp: str
message_number: int
class MQTTEventsHistoryResponse(BaseModel):
"""MQTT events history response model"""
events: List[MQTTEventResponse]
total_events: int
last_updated: Optional[str] = None
class SuccessResponse(BaseModel):
"""Success response model"""
success: bool = True
message: str
data: Optional[Dict[str, Any]] = None

View File

@@ -25,31 +25,31 @@ from .models import *
class WebSocketManager:
"""Manages WebSocket connections for real-time updates"""
def __init__(self):
self.active_connections: List[WebSocket] = []
self.logger = logging.getLogger(f"{__name__}.WebSocketManager")
async def connect(self, websocket: WebSocket):
await websocket.accept()
self.active_connections.append(websocket)
self.logger.info(f"WebSocket connected. Total connections: {len(self.active_connections)}")
def disconnect(self, websocket: WebSocket):
if websocket in self.active_connections:
self.active_connections.remove(websocket)
self.logger.info(f"WebSocket disconnected. Total connections: {len(self.active_connections)}")
async def send_personal_message(self, message: dict, websocket: WebSocket):
try:
await websocket.send_text(json.dumps(message))
except Exception as e:
self.logger.error(f"Error sending personal message: {e}")
async def broadcast(self, message: dict):
if not self.active_connections:
return
disconnected = []
for connection in self.active_connections:
try:
@@ -57,7 +57,7 @@ class WebSocketManager:
except Exception as e:
self.logger.error(f"Error broadcasting to connection: {e}")
disconnected.append(connection)
# Remove disconnected connections
for connection in disconnected:
self.disconnect(connection)
@@ -65,9 +65,8 @@ class WebSocketManager:
class APIServer:
"""FastAPI server for the USDA Vision Camera System"""
def __init__(self, config: Config, state_manager: StateManager, event_system: EventSystem,
camera_manager, mqtt_client, storage_manager: StorageManager):
def __init__(self, config: Config, state_manager: StateManager, event_system: EventSystem, camera_manager, mqtt_client, storage_manager: StorageManager):
self.config = config
self.state_manager = state_manager
self.event_system = event_system
@@ -75,111 +74,101 @@ class APIServer:
self.mqtt_client = mqtt_client
self.storage_manager = storage_manager
self.logger = logging.getLogger(__name__)
# FastAPI app
self.app = FastAPI(
title="USDA Vision Camera System API",
description="API for monitoring and controlling the USDA vision camera system",
version="1.0.0"
)
self.app = FastAPI(title="USDA Vision Camera System API", description="API for monitoring and controlling the USDA vision camera system", version="1.0.0")
# WebSocket manager
self.websocket_manager = WebSocketManager()
# Server state
self.server_start_time = datetime.now()
self.running = False
self._server_thread: Optional[threading.Thread] = None
self._event_loop: Optional[asyncio.AbstractEventLoop] = None
# Setup CORS
self.app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # Configure appropriately for production
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
self.app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"]) # Configure appropriately for production
# Setup routes
self._setup_routes()
# Subscribe to events for WebSocket broadcasting
self._setup_event_subscriptions()
def _setup_routes(self):
"""Setup API routes"""
@self.app.get("/", response_model=SuccessResponse)
async def root():
return SuccessResponse(message="USDA Vision Camera System API")
@self.app.get("/health")
async def health_check():
return {"status": "healthy", "timestamp": datetime.now().isoformat()}
@self.app.get("/system/status", response_model=SystemStatusResponse)
async def get_system_status():
"""Get overall system status"""
try:
summary = self.state_manager.get_system_summary()
uptime = (datetime.now() - self.server_start_time).total_seconds()
return SystemStatusResponse(
system_started=summary["system_started"],
mqtt_connected=summary["mqtt_connected"],
last_mqtt_message=summary["last_mqtt_message"],
machines=summary["machines"],
cameras=summary["cameras"],
active_recordings=summary["active_recordings"],
total_recordings=summary["total_recordings"],
uptime_seconds=uptime
)
return SystemStatusResponse(system_started=summary["system_started"], mqtt_connected=summary["mqtt_connected"], last_mqtt_message=summary["last_mqtt_message"], machines=summary["machines"], cameras=summary["cameras"], active_recordings=summary["active_recordings"], total_recordings=summary["total_recordings"], uptime_seconds=uptime)
except Exception as e:
self.logger.error(f"Error getting system status: {e}")
raise HTTPException(status_code=500, detail=str(e))
@self.app.get("/machines", response_model=Dict[str, MachineStatusResponse])
async def get_machines():
"""Get all machine statuses"""
try:
machines = self.state_manager.get_all_machines()
return {
name: MachineStatusResponse(
name=machine.name,
state=machine.state.value,
last_updated=machine.last_updated.isoformat(),
last_message=machine.last_message,
mqtt_topic=machine.mqtt_topic
)
for name, machine in machines.items()
}
return {name: MachineStatusResponse(name=machine.name, state=machine.state.value, last_updated=machine.last_updated.isoformat(), last_message=machine.last_message, mqtt_topic=machine.mqtt_topic) for name, machine in machines.items()}
except Exception as e:
self.logger.error(f"Error getting machines: {e}")
raise HTTPException(status_code=500, detail=str(e))
@self.app.get("/mqtt/status", response_model=MQTTStatusResponse)
async def get_mqtt_status():
"""Get MQTT client status and statistics"""
try:
status = self.mqtt_client.get_status()
return MQTTStatusResponse(connected=status["connected"], broker_host=status["broker_host"], broker_port=status["broker_port"], subscribed_topics=status["subscribed_topics"], last_message_time=status["last_message_time"], message_count=status["message_count"], error_count=status["error_count"], uptime_seconds=status["uptime_seconds"])
except Exception as e:
self.logger.error(f"Error getting MQTT status: {e}")
raise HTTPException(status_code=500, detail=str(e))
@self.app.get("/mqtt/events", response_model=MQTTEventsHistoryResponse)
async def get_mqtt_events(limit: int = Query(default=5, ge=1, le=50, description="Number of recent events to retrieve")):
"""Get recent MQTT events history"""
try:
events = self.state_manager.get_recent_mqtt_events(limit)
total_events = self.state_manager.get_mqtt_event_count()
# Convert events to response format
event_responses = [MQTTEventResponse(machine_name=event.machine_name, topic=event.topic, payload=event.payload, normalized_state=event.normalized_state, timestamp=event.timestamp.isoformat(), message_number=event.message_number) for event in events]
last_updated = events[0].timestamp.isoformat() if events else None
return MQTTEventsHistoryResponse(events=event_responses, total_events=total_events, last_updated=last_updated)
except Exception as e:
self.logger.error(f"Error getting MQTT events: {e}")
raise HTTPException(status_code=500, detail=str(e))
@self.app.get("/cameras", response_model=Dict[str, CameraStatusResponse])
async def get_cameras():
"""Get all camera statuses"""
try:
cameras = self.state_manager.get_all_cameras()
return {
name: CameraStatusResponse(
name=camera.name,
status=camera.status.value,
is_recording=camera.is_recording,
last_checked=camera.last_checked.isoformat(),
last_error=camera.last_error,
device_info=camera.device_info,
current_recording_file=camera.current_recording_file,
recording_start_time=camera.recording_start_time.isoformat() if camera.recording_start_time else None
)
name: CameraStatusResponse(name=camera.name, status=camera.status.value, is_recording=camera.is_recording, last_checked=camera.last_checked.isoformat(), last_error=camera.last_error, device_info=camera.device_info, current_recording_file=camera.current_recording_file, recording_start_time=camera.recording_start_time.isoformat() if camera.recording_start_time else None)
for name, camera in cameras.items()
}
except Exception as e:
self.logger.error(f"Error getting cameras: {e}")
raise HTTPException(status_code=500, detail=str(e))
@self.app.get("/cameras/{camera_name}/status", response_model=CameraStatusResponse)
async def get_camera_status(camera_name: str):
"""Get specific camera status"""
@@ -187,70 +176,158 @@ class APIServer:
camera = self.state_manager.get_camera_status(camera_name)
if not camera:
raise HTTPException(status_code=404, detail=f"Camera not found: {camera_name}")
return CameraStatusResponse(
name=camera.name,
status=camera.status.value,
is_recording=camera.is_recording,
last_checked=camera.last_checked.isoformat(),
last_error=camera.last_error,
device_info=camera.device_info,
current_recording_file=camera.current_recording_file,
recording_start_time=camera.recording_start_time.isoformat() if camera.recording_start_time else None
)
return CameraStatusResponse(name=camera.name, status=camera.status.value, is_recording=camera.is_recording, last_checked=camera.last_checked.isoformat(), last_error=camera.last_error, device_info=camera.device_info, current_recording_file=camera.current_recording_file, recording_start_time=camera.recording_start_time.isoformat() if camera.recording_start_time else None)
except HTTPException:
raise
except Exception as e:
self.logger.error(f"Error getting camera status: {e}")
raise HTTPException(status_code=500, detail=str(e))
@self.app.post("/cameras/{camera_name}/start-recording", response_model=StartRecordingResponse)
async def start_recording(camera_name: str, request: StartRecordingRequest):
"""Manually start recording for a camera"""
try:
if not self.camera_manager:
raise HTTPException(status_code=503, detail="Camera manager not available")
success = self.camera_manager.manual_start_recording(camera_name, request.filename)
success = self.camera_manager.manual_start_recording(camera_name=camera_name, filename=request.filename, exposure_ms=request.exposure_ms, gain=request.gain, fps=request.fps)
if success:
return StartRecordingResponse(
success=True,
message=f"Recording started for {camera_name}",
filename=request.filename
)
# Get the actual filename that was used (with datetime prefix)
actual_filename = request.filename
if request.filename:
from ..core.timezone_utils import format_filename_timestamp
timestamp = format_filename_timestamp()
actual_filename = f"{timestamp}_{request.filename}"
return StartRecordingResponse(success=True, message=f"Recording started for {camera_name}", filename=actual_filename)
else:
return StartRecordingResponse(
success=False,
message=f"Failed to start recording for {camera_name}"
)
return StartRecordingResponse(success=False, message=f"Failed to start recording for {camera_name}")
except Exception as e:
self.logger.error(f"Error starting recording: {e}")
raise HTTPException(status_code=500, detail=str(e))
@self.app.post("/cameras/{camera_name}/stop-recording", response_model=StopRecordingResponse)
async def stop_recording(camera_name: str):
"""Manually stop recording for a camera"""
try:
if not self.camera_manager:
raise HTTPException(status_code=503, detail="Camera manager not available")
success = self.camera_manager.manual_stop_recording(camera_name)
if success:
return StopRecordingResponse(
success=True,
message=f"Recording stopped for {camera_name}"
)
return StopRecordingResponse(success=True, message=f"Recording stopped for {camera_name}")
else:
return StopRecordingResponse(
success=False,
message=f"Failed to stop recording for {camera_name}"
)
return StopRecordingResponse(success=False, message=f"Failed to stop recording for {camera_name}")
except Exception as e:
self.logger.error(f"Error stopping recording: {e}")
raise HTTPException(status_code=500, detail=str(e))
@self.app.post("/cameras/{camera_name}/test-connection", response_model=CameraTestResponse)
async def test_camera_connection(camera_name: str):
"""Test camera connection"""
try:
if not self.camera_manager:
raise HTTPException(status_code=503, detail="Camera manager not available")
success = self.camera_manager.test_camera_connection(camera_name)
if success:
return CameraTestResponse(success=True, message=f"Camera {camera_name} connection test passed", camera_name=camera_name)
else:
return CameraTestResponse(success=False, message=f"Camera {camera_name} connection test failed", camera_name=camera_name)
except Exception as e:
self.logger.error(f"Error testing camera connection: {e}")
raise HTTPException(status_code=500, detail=str(e))
@self.app.post("/cameras/{camera_name}/reconnect", response_model=CameraRecoveryResponse)
async def reconnect_camera(camera_name: str):
"""Reconnect to a camera"""
try:
if not self.camera_manager:
raise HTTPException(status_code=503, detail="Camera manager not available")
success = self.camera_manager.reconnect_camera(camera_name)
if success:
return CameraRecoveryResponse(success=True, message=f"Camera {camera_name} reconnected successfully", camera_name=camera_name, operation="reconnect")
else:
return CameraRecoveryResponse(success=False, message=f"Failed to reconnect camera {camera_name}", camera_name=camera_name, operation="reconnect")
except Exception as e:
self.logger.error(f"Error reconnecting camera: {e}")
raise HTTPException(status_code=500, detail=str(e))
@self.app.post("/cameras/{camera_name}/restart-grab", response_model=CameraRecoveryResponse)
async def restart_camera_grab(camera_name: str):
"""Restart camera grab process"""
try:
if not self.camera_manager:
raise HTTPException(status_code=503, detail="Camera manager not available")
success = self.camera_manager.restart_camera_grab(camera_name)
if success:
return CameraRecoveryResponse(success=True, message=f"Camera {camera_name} grab process restarted successfully", camera_name=camera_name, operation="restart-grab")
else:
return CameraRecoveryResponse(success=False, message=f"Failed to restart grab process for camera {camera_name}", camera_name=camera_name, operation="restart-grab")
except Exception as e:
self.logger.error(f"Error restarting camera grab: {e}")
raise HTTPException(status_code=500, detail=str(e))
@self.app.post("/cameras/{camera_name}/reset-timestamp", response_model=CameraRecoveryResponse)
async def reset_camera_timestamp(camera_name: str):
"""Reset camera timestamp"""
try:
if not self.camera_manager:
raise HTTPException(status_code=503, detail="Camera manager not available")
success = self.camera_manager.reset_camera_timestamp(camera_name)
if success:
return CameraRecoveryResponse(success=True, message=f"Camera {camera_name} timestamp reset successfully", camera_name=camera_name, operation="reset-timestamp")
else:
return CameraRecoveryResponse(success=False, message=f"Failed to reset timestamp for camera {camera_name}", camera_name=camera_name, operation="reset-timestamp")
except Exception as e:
self.logger.error(f"Error resetting camera timestamp: {e}")
raise HTTPException(status_code=500, detail=str(e))
@self.app.post("/cameras/{camera_name}/full-reset", response_model=CameraRecoveryResponse)
async def full_reset_camera(camera_name: str):
"""Perform full camera reset (uninitialize and reinitialize)"""
try:
if not self.camera_manager:
raise HTTPException(status_code=503, detail="Camera manager not available")
success = self.camera_manager.full_reset_camera(camera_name)
if success:
return CameraRecoveryResponse(success=True, message=f"Camera {camera_name} full reset completed successfully", camera_name=camera_name, operation="full-reset")
else:
return CameraRecoveryResponse(success=False, message=f"Failed to perform full reset for camera {camera_name}", camera_name=camera_name, operation="full-reset")
except Exception as e:
self.logger.error(f"Error performing full camera reset: {e}")
raise HTTPException(status_code=500, detail=str(e))
@self.app.post("/cameras/{camera_name}/reinitialize", response_model=CameraRecoveryResponse)
async def reinitialize_camera(camera_name: str):
"""Reinitialize a failed camera"""
try:
if not self.camera_manager:
raise HTTPException(status_code=503, detail="Camera manager not available")
success = self.camera_manager.reinitialize_failed_camera(camera_name)
if success:
return CameraRecoveryResponse(success=True, message=f"Camera {camera_name} reinitialized successfully", camera_name=camera_name, operation="reinitialize")
else:
return CameraRecoveryResponse(success=False, message=f"Failed to reinitialize camera {camera_name}", camera_name=camera_name, operation="reinitialize")
except Exception as e:
self.logger.error(f"Error reinitializing camera: {e}")
raise HTTPException(status_code=500, detail=str(e))
@self.app.get("/recordings", response_model=Dict[str, RecordingInfoResponse])
async def get_recordings():
"""Get all recording sessions"""
@@ -266,14 +343,14 @@ class APIServer:
file_size_bytes=recording.file_size_bytes,
frame_count=recording.frame_count,
duration_seconds=(recording.end_time - recording.start_time).total_seconds() if recording.end_time else None,
error_message=recording.error_message
error_message=recording.error_message,
)
for rid, recording in recordings.items()
}
except Exception as e:
self.logger.error(f"Error getting recordings: {e}")
raise HTTPException(status_code=500, detail=str(e))
@self.app.get("/storage/stats", response_model=StorageStatsResponse)
async def get_storage_stats():
"""Get storage statistics"""
@@ -283,34 +360,26 @@ class APIServer:
except Exception as e:
self.logger.error(f"Error getting storage stats: {e}")
raise HTTPException(status_code=500, detail=str(e))
@self.app.post("/storage/files", response_model=FileListResponse)
async def get_files(request: FileListRequest):
"""Get list of recording files"""
try:
start_date = None
end_date = None
if request.start_date:
start_date = datetime.fromisoformat(request.start_date)
if request.end_date:
end_date = datetime.fromisoformat(request.end_date)
files = self.storage_manager.get_recording_files(
camera_name=request.camera_name,
start_date=start_date,
end_date=end_date,
limit=request.limit
)
return FileListResponse(
files=files,
total_count=len(files)
)
files = self.storage_manager.get_recording_files(camera_name=request.camera_name, start_date=start_date, end_date=end_date, limit=request.limit)
return FileListResponse(files=files, total_count=len(files))
except Exception as e:
self.logger.error(f"Error getting files: {e}")
raise HTTPException(status_code=500, detail=str(e))
@self.app.post("/storage/cleanup", response_model=CleanupResponse)
async def cleanup_storage(request: CleanupRequest):
"""Clean up old storage files"""
@@ -320,7 +389,7 @@ class APIServer:
except Exception as e:
self.logger.error(f"Error during cleanup: {e}")
raise HTTPException(status_code=500, detail=str(e))
@self.app.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket):
"""WebSocket endpoint for real-time updates"""
@@ -330,9 +399,7 @@ class APIServer:
# Keep connection alive and handle incoming messages
data = await websocket.receive_text()
# Echo back for now - could implement commands later
await self.websocket_manager.send_personal_message(
{"type": "echo", "data": data}, websocket
)
await self.websocket_manager.send_personal_message({"type": "echo", "data": data}, websocket)
except WebSocketDisconnect:
self.websocket_manager.disconnect(websocket)
@@ -342,21 +409,12 @@ class APIServer:
def broadcast_event(event: Event):
"""Broadcast event to all WebSocket connections"""
try:
message = {
"type": "event",
"event_type": event.event_type.value,
"source": event.source,
"data": event.data,
"timestamp": event.timestamp.isoformat()
}
message = {"type": "event", "event_type": event.event_type.value, "source": event.source, "data": event.data, "timestamp": event.timestamp.isoformat()}
# Schedule the broadcast in the event loop thread-safely
if self._event_loop and not self._event_loop.is_closed():
# Use call_soon_threadsafe to schedule the coroutine from another thread
asyncio.run_coroutine_threadsafe(
self.websocket_manager.broadcast(message),
self._event_loop
)
asyncio.run_coroutine_threadsafe(self.websocket_manager.broadcast(message), self._event_loop)
else:
self.logger.debug("Event loop not available for broadcasting")
@@ -411,12 +469,7 @@ class APIServer:
self._event_loop = asyncio.new_event_loop()
asyncio.set_event_loop(self._event_loop)
uvicorn.run(
self.app,
host=self.config.system.api_host,
port=self.config.system.api_port,
log_level="info"
)
uvicorn.run(self.app, host=self.config.system.api_host, port=self.config.system.api_port, log_level="info")
except Exception as e:
self.logger.error(f"Error running API server: {e}")
finally:
@@ -429,11 +482,4 @@ class APIServer:
def get_server_info(self) -> Dict[str, Any]:
"""Get server information"""
return {
"running": self.running,
"host": self.config.system.api_host,
"port": self.config.system.api_port,
"start_time": self.server_start_time.isoformat(),
"uptime_seconds": (datetime.now() - self.server_start_time).total_seconds(),
"websocket_connections": len(self.websocket_manager.active_connections)
}
return {"running": self.running, "host": self.config.system.api_host, "port": self.config.system.api_port, "start_time": self.server_start_time.isoformat(), "uptime_seconds": (datetime.now() - self.server_start_time).total_seconds(), "websocket_connections": len(self.websocket_manager.active_connections)}