citrascope 0.1.0__py3-none-any.whl → 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. citrascope/__main__.py +8 -5
  2. citrascope/api/abstract_api_client.py +7 -0
  3. citrascope/api/citra_api_client.py +30 -1
  4. citrascope/citra_scope_daemon.py +214 -61
  5. citrascope/hardware/abstract_astro_hardware_adapter.py +70 -2
  6. citrascope/hardware/adapter_registry.py +94 -0
  7. citrascope/hardware/indi_adapter.py +456 -16
  8. citrascope/hardware/kstars_dbus_adapter.py +179 -0
  9. citrascope/hardware/nina_adv_http_adapter.py +593 -0
  10. citrascope/hardware/nina_adv_http_survey_template.json +328 -0
  11. citrascope/logging/__init__.py +2 -1
  12. citrascope/logging/_citrascope_logger.py +80 -1
  13. citrascope/logging/web_log_handler.py +74 -0
  14. citrascope/settings/citrascope_settings.py +145 -0
  15. citrascope/settings/settings_file_manager.py +126 -0
  16. citrascope/tasks/runner.py +124 -28
  17. citrascope/tasks/scope/base_telescope_task.py +25 -10
  18. citrascope/tasks/scope/static_telescope_task.py +11 -3
  19. citrascope/web/__init__.py +1 -0
  20. citrascope/web/app.py +470 -0
  21. citrascope/web/server.py +123 -0
  22. citrascope/web/static/api.js +82 -0
  23. citrascope/web/static/app.js +500 -0
  24. citrascope/web/static/config.js +362 -0
  25. citrascope/web/static/img/citra.png +0 -0
  26. citrascope/web/static/img/favicon.png +0 -0
  27. citrascope/web/static/style.css +120 -0
  28. citrascope/web/static/websocket.js +127 -0
  29. citrascope/web/templates/dashboard.html +354 -0
  30. {citrascope-0.1.0.dist-info → citrascope-0.3.0.dist-info}/METADATA +68 -36
  31. citrascope-0.3.0.dist-info/RECORD +38 -0
  32. {citrascope-0.1.0.dist-info → citrascope-0.3.0.dist-info}/WHEEL +1 -1
  33. citrascope/settings/_citrascope_settings.py +0 -42
  34. citrascope-0.1.0.dist-info/RECORD +0 -21
  35. {citrascope-0.1.0.dist-info → citrascope-0.3.0.dist-info}/entry_points.txt +0 -0
citrascope/web/app.py ADDED
@@ -0,0 +1,470 @@
1
+ """FastAPI web application for CitraScope monitoring and configuration."""
2
+
3
+ import asyncio
4
+ import json
5
+ import os
6
+ from datetime import datetime, timezone
7
+ from pathlib import Path
8
+ from typing import Any, Dict, List, Optional
9
+
10
+ from fastapi import FastAPI, WebSocket, WebSocketDisconnect
11
+ from fastapi.middleware.cors import CORSMiddleware
12
+ from fastapi.responses import HTMLResponse, JSONResponse
13
+ from fastapi.staticfiles import StaticFiles
14
+ from pydantic import BaseModel
15
+
16
+ from citrascope.logging import CITRASCOPE_LOGGER
17
+
18
+ # ============================================================================
19
+ # URL CONSTANTS - Single source of truth for Citra web URLs
20
+ # ============================================================================
21
+ PROD_APP_URL = "https://app.citra.space"
22
+ DEV_APP_URL = "https://dev.app.citra.space"
23
+ # ============================================================================
24
+
25
+
26
+ class SystemStatus(BaseModel):
27
+ """Current system status."""
28
+
29
+ telescope_connected: bool = False
30
+ camera_connected: bool = False
31
+ current_task: Optional[str] = None
32
+ tasks_pending: int = 0
33
+ hardware_adapter: str = "unknown"
34
+ telescope_ra: Optional[float] = None
35
+ telescope_dec: Optional[float] = None
36
+ ground_station_id: Optional[str] = None
37
+ ground_station_name: Optional[str] = None
38
+ ground_station_url: Optional[str] = None
39
+ last_update: str = ""
40
+
41
+
42
+ class HardwareConfig(BaseModel):
43
+ """Hardware configuration settings."""
44
+
45
+ adapter: str
46
+ indi_server_url: Optional[str] = None
47
+ indi_server_port: Optional[int] = None
48
+ indi_telescope_name: Optional[str] = None
49
+ indi_camera_name: Optional[str] = None
50
+ nina_url_prefix: Optional[str] = None
51
+
52
+
53
+ class ConnectionManager:
54
+ """Manages WebSocket connections for real-time updates."""
55
+
56
+ def __init__(self):
57
+ self.active_connections: List[WebSocket] = []
58
+
59
+ async def connect(self, websocket: WebSocket):
60
+ await websocket.accept()
61
+ self.active_connections.append(websocket)
62
+ CITRASCOPE_LOGGER.info(f"WebSocket client connected. Total: {len(self.active_connections)}")
63
+
64
+ def disconnect(self, websocket: WebSocket):
65
+ if websocket in self.active_connections:
66
+ self.active_connections.remove(websocket)
67
+ CITRASCOPE_LOGGER.info(f"WebSocket client disconnected. Total: {len(self.active_connections)}")
68
+
69
+ async def broadcast(self, message: dict):
70
+ """Broadcast message to all connected clients."""
71
+ disconnected = []
72
+ for connection in self.active_connections:
73
+ try:
74
+ await connection.send_json(message)
75
+ except Exception as e:
76
+ CITRASCOPE_LOGGER.warning(f"Failed to send to WebSocket client: {e}")
77
+ disconnected.append(connection)
78
+
79
+ # Clean up disconnected clients
80
+ for connection in disconnected:
81
+ self.disconnect(connection)
82
+
83
+ async def broadcast_text(self, message: str):
84
+ """Broadcast text message to all connected clients."""
85
+ disconnected = []
86
+ for connection in self.active_connections:
87
+ try:
88
+ await connection.send_text(message)
89
+ except Exception as e:
90
+ CITRASCOPE_LOGGER.warning(f"Failed to send to WebSocket client: {e}")
91
+ disconnected.append(connection)
92
+
93
+ # Clean up disconnected clients
94
+ for connection in disconnected:
95
+ self.disconnect(connection)
96
+
97
+
98
+ class CitraScopeWebApp:
99
+ """Web application for CitraScope."""
100
+
101
+ def __init__(self, daemon=None, web_log_handler=None):
102
+ self.app = FastAPI(title="CitraScope", description="Telescope Control and Monitoring")
103
+ self.daemon = daemon
104
+ self.connection_manager = ConnectionManager()
105
+ self.status = SystemStatus()
106
+ self.web_log_handler = web_log_handler
107
+
108
+ # Configure CORS
109
+ self.app.add_middleware(
110
+ CORSMiddleware,
111
+ allow_origins=["*"],
112
+ allow_credentials=True,
113
+ allow_methods=["*"],
114
+ allow_headers=["*"],
115
+ )
116
+
117
+ # Mount static files
118
+ static_dir = Path(__file__).parent / "static"
119
+ if static_dir.exists():
120
+ self.app.mount("/static", StaticFiles(directory=str(static_dir)), name="static")
121
+
122
+ # Register routes
123
+ self._setup_routes()
124
+
125
+ def set_daemon(self, daemon):
126
+ """Set the daemon instance after initialization."""
127
+ self.daemon = daemon
128
+
129
+ def _setup_routes(self):
130
+ """Setup all API routes."""
131
+
132
+ @self.app.get("/", response_class=HTMLResponse)
133
+ async def root():
134
+ """Serve the main dashboard page."""
135
+ template_path = Path(__file__).parent / "templates" / "dashboard.html"
136
+ if template_path.exists():
137
+ return template_path.read_text()
138
+ else:
139
+ return HTMLResponse(
140
+ content="<h1>CitraScope Dashboard</h1><p>Template file not found</p>", status_code=500
141
+ )
142
+
143
+ @self.app.get("/api/status")
144
+ async def get_status():
145
+ """Get current system status."""
146
+ if self.daemon:
147
+ self._update_status_from_daemon()
148
+ return self.status
149
+
150
+ @self.app.get("/api/config")
151
+ async def get_config():
152
+ """Get current configuration."""
153
+ if not self.daemon or not self.daemon.settings:
154
+ return JSONResponse({"error": "Configuration not available"}, status_code=503)
155
+
156
+ settings = self.daemon.settings
157
+ # Determine app URL based on API host
158
+ app_url = DEV_APP_URL if "dev." in settings.host else PROD_APP_URL
159
+
160
+ # Get config file path
161
+ config_path = str(settings.config_manager.get_config_path())
162
+
163
+ # Get current log file path
164
+ log_file_path = (
165
+ str(settings.config_manager.get_current_log_path()) if settings.file_logging_enabled else None
166
+ )
167
+
168
+ # Get images directory path
169
+ images_dir_path = str(settings.get_images_dir())
170
+
171
+ return {
172
+ "host": settings.host,
173
+ "port": settings.port,
174
+ "use_ssl": settings.use_ssl,
175
+ "personal_access_token": settings.personal_access_token,
176
+ "telescope_id": settings.telescope_id,
177
+ "hardware_adapter": settings.hardware_adapter,
178
+ "adapter_settings": settings.adapter_settings,
179
+ "log_level": settings.log_level,
180
+ "keep_images": settings.keep_images,
181
+ "max_task_retries": settings.max_task_retries,
182
+ "initial_retry_delay_seconds": settings.initial_retry_delay_seconds,
183
+ "max_retry_delay_seconds": settings.max_retry_delay_seconds,
184
+ "app_url": app_url,
185
+ "config_file_path": config_path,
186
+ "log_file_path": log_file_path,
187
+ "images_dir_path": images_dir_path,
188
+ }
189
+
190
+ @self.app.get("/api/config/status")
191
+ async def get_config_status():
192
+ """Get configuration status."""
193
+ if not self.daemon or not self.daemon.settings:
194
+ return {"configured": False, "error": "Settings not available"}
195
+
196
+ return {
197
+ "configured": self.daemon.settings.is_configured(),
198
+ "error": getattr(self.daemon, "configuration_error", None),
199
+ }
200
+
201
+ @self.app.get("/api/hardware-adapters")
202
+ async def get_hardware_adapters():
203
+ """Get list of available hardware adapters."""
204
+ from citrascope.hardware.adapter_registry import list_adapters
205
+
206
+ adapters_info = list_adapters()
207
+ return {
208
+ "adapters": list(adapters_info.keys()),
209
+ "descriptions": {name: info["description"] for name, info in adapters_info.items()},
210
+ }
211
+
212
+ @self.app.get("/api/hardware-adapters/{adapter_name}/schema")
213
+ async def get_adapter_schema(adapter_name: str):
214
+ """Get configuration schema for a specific hardware adapter."""
215
+ from citrascope.hardware.adapter_registry import get_adapter_schema as get_schema
216
+
217
+ try:
218
+ schema = get_schema(adapter_name)
219
+ return {"schema": schema}
220
+ except ValueError as e:
221
+ # Invalid adapter name
222
+ return JSONResponse({"error": str(e)}, status_code=404)
223
+ except Exception as e:
224
+ CITRASCOPE_LOGGER.error(f"Error getting schema for {adapter_name}: {e}", exc_info=True)
225
+ return JSONResponse({"error": str(e)}, status_code=500)
226
+
227
+ @self.app.post("/api/config")
228
+ async def update_config(config: Dict[str, Any]):
229
+ """Update configuration and trigger hot-reload."""
230
+ try:
231
+ if not self.daemon:
232
+ return JSONResponse({"error": "Daemon not available"}, status_code=503)
233
+
234
+ # Validate required fields
235
+ required_fields = ["personal_access_token", "telescope_id", "hardware_adapter"]
236
+ for field in required_fields:
237
+ if field not in config or not config[field]:
238
+ return JSONResponse(
239
+ {"error": f"Missing required field: {field}"},
240
+ status_code=400,
241
+ )
242
+
243
+ # Validate adapter_settings against schema if adapter is specified
244
+ adapter_name = config.get("hardware_adapter")
245
+ adapter_settings = config.get("adapter_settings", {})
246
+
247
+ if adapter_name:
248
+ # Get schema for validation
249
+ schema_response = await get_adapter_schema(adapter_name)
250
+ if isinstance(schema_response, JSONResponse):
251
+ return schema_response # Error getting schema
252
+
253
+ schema = schema_response.get("schema", [])
254
+
255
+ # Validate required fields in adapter settings
256
+ for field_schema in schema:
257
+ field_name = field_schema.get("name")
258
+ is_required = field_schema.get("required", False)
259
+
260
+ if is_required and field_name not in adapter_settings:
261
+ return JSONResponse(
262
+ {"error": f"Missing required adapter setting: {field_name}"},
263
+ status_code=400,
264
+ )
265
+
266
+ # Validate type and constraints if present
267
+ if field_name in adapter_settings:
268
+ value = adapter_settings[field_name]
269
+ field_type = field_schema.get("type")
270
+
271
+ # Type validation
272
+ if field_type == "int":
273
+ try:
274
+ value = int(value)
275
+ adapter_settings[field_name] = value
276
+ except (ValueError, TypeError):
277
+ return JSONResponse(
278
+ {"error": f"Field '{field_name}' must be an integer"},
279
+ status_code=400,
280
+ )
281
+
282
+ # Range validation
283
+ if "min" in field_schema and value < field_schema["min"]:
284
+ return JSONResponse(
285
+ {"error": f"Field '{field_name}' must be >= {field_schema['min']}"},
286
+ status_code=400,
287
+ )
288
+ if "max" in field_schema and value > field_schema["max"]:
289
+ return JSONResponse(
290
+ {"error": f"Field '{field_name}' must be <= {field_schema['max']}"},
291
+ status_code=400,
292
+ )
293
+
294
+ elif field_type == "float":
295
+ try:
296
+ value = float(value)
297
+ adapter_settings[field_name] = value
298
+ except (ValueError, TypeError):
299
+ return JSONResponse(
300
+ {"error": f"Field '{field_name}' must be a number"},
301
+ status_code=400,
302
+ )
303
+
304
+ # Save configuration to file
305
+ from citrascope.settings.settings_file_manager import SettingsFileManager
306
+
307
+ config_manager = SettingsFileManager()
308
+ config_manager.save_config(config)
309
+
310
+ # Trigger hot-reload
311
+ success, error = self.daemon.reload_configuration()
312
+
313
+ if success:
314
+ return {
315
+ "status": "success",
316
+ "message": "Configuration updated and reloaded successfully",
317
+ }
318
+ else:
319
+ return JSONResponse(
320
+ {
321
+ "status": "error",
322
+ "message": f"Configuration saved but reload failed: {error}",
323
+ "error": error,
324
+ },
325
+ status_code=500,
326
+ )
327
+
328
+ except Exception as e:
329
+ CITRASCOPE_LOGGER.error(f"Error updating config: {e}", exc_info=True)
330
+ return JSONResponse({"error": str(e)}, status_code=500)
331
+
332
+ @self.app.get("/api/tasks")
333
+ async def get_tasks():
334
+ """Get current task queue."""
335
+ if not self.daemon or not hasattr(self.daemon, "task_manager") or self.daemon.task_manager is None:
336
+ return []
337
+
338
+ task_manager = self.daemon.task_manager
339
+ tasks = []
340
+
341
+ with task_manager.heap_lock:
342
+ for start_time, stop_time, task_id, task in task_manager.task_heap:
343
+ tasks.append(
344
+ {
345
+ "id": task_id,
346
+ "start_time": datetime.fromtimestamp(start_time, tz=timezone.utc).isoformat(),
347
+ "stop_time": (
348
+ datetime.fromtimestamp(stop_time, tz=timezone.utc).isoformat() if stop_time else None
349
+ ),
350
+ "status": task.status,
351
+ "target": getattr(task, "satelliteName", getattr(task, "target", "unknown")),
352
+ }
353
+ )
354
+
355
+ return tasks
356
+
357
+ @self.app.get("/api/logs")
358
+ async def get_logs(limit: int = 100):
359
+ """Get recent log entries."""
360
+ if self.web_log_handler:
361
+ logs = self.web_log_handler.get_recent_logs(limit)
362
+ return {"logs": logs}
363
+ return {"logs": []}
364
+
365
+ @self.app.websocket("/ws")
366
+ async def websocket_endpoint(websocket: WebSocket):
367
+ """WebSocket endpoint for real-time updates."""
368
+ await self.connection_manager.connect(websocket)
369
+ try:
370
+ # Send initial status
371
+ if self.daemon:
372
+ self._update_status_from_daemon()
373
+ await websocket.send_json({"type": "status", "data": self.status.dict()})
374
+
375
+ # Keep connection alive and listen for client messages
376
+ while True:
377
+ data = await websocket.receive_text()
378
+ # Handle client requests if needed
379
+ await websocket.send_json({"type": "pong", "data": data})
380
+
381
+ except WebSocketDisconnect:
382
+ self.connection_manager.disconnect(websocket)
383
+ except Exception as e:
384
+ CITRASCOPE_LOGGER.error(f"WebSocket error: {e}")
385
+ self.connection_manager.disconnect(websocket)
386
+
387
+ def _update_status_from_daemon(self):
388
+ """Update status from daemon state."""
389
+ if not self.daemon:
390
+ return
391
+
392
+ try:
393
+ self.status.hardware_adapter = self.daemon.settings.hardware_adapter
394
+
395
+ if hasattr(self.daemon, "hardware_adapter") and self.daemon.hardware_adapter:
396
+ # Check telescope connection status
397
+ try:
398
+ self.status.telescope_connected = self.daemon.hardware_adapter.is_telescope_connected()
399
+ if self.status.telescope_connected:
400
+ # If connected, try to get position
401
+ ra, dec = self.daemon.hardware_adapter.get_telescope_direction()
402
+ self.status.telescope_ra = ra
403
+ self.status.telescope_dec = dec
404
+ except Exception:
405
+ self.status.telescope_connected = False
406
+
407
+ # Check camera connection status
408
+ try:
409
+ self.status.camera_connected = self.daemon.hardware_adapter.is_camera_connected()
410
+ except Exception:
411
+ self.status.camera_connected = False
412
+
413
+ if hasattr(self.daemon, "task_manager") and self.daemon.task_manager:
414
+ task_manager = self.daemon.task_manager
415
+ self.status.current_task = task_manager.current_task_id
416
+ with task_manager.heap_lock:
417
+ self.status.tasks_pending = len(task_manager.task_heap)
418
+
419
+ # Get ground station information from daemon (available after API validation)
420
+ if hasattr(self.daemon, "ground_station") and self.daemon.ground_station:
421
+ gs_record = self.daemon.ground_station
422
+ gs_id = gs_record.get("id")
423
+ gs_name = gs_record.get("name", "Unknown")
424
+
425
+ # Build the URL based on the API host (dev vs prod)
426
+ api_host = self.daemon.settings.host
427
+ base_url = DEV_APP_URL if "dev." in api_host else PROD_APP_URL
428
+
429
+ self.status.ground_station_id = gs_id
430
+ self.status.ground_station_name = gs_name
431
+ self.status.ground_station_url = f"{base_url}/ground-stations/{gs_id}" if gs_id else None
432
+
433
+ self.status.last_update = datetime.now().isoformat()
434
+
435
+ except Exception as e:
436
+ CITRASCOPE_LOGGER.error(f"Error updating status: {e}")
437
+
438
+ async def broadcast_status(self):
439
+ """Broadcast current status to all connected clients."""
440
+ if self.daemon:
441
+ self._update_status_from_daemon()
442
+ await self.connection_manager.broadcast({"type": "status", "data": self.status.dict()})
443
+
444
+ async def broadcast_tasks(self):
445
+ """Broadcast current task queue to all connected clients."""
446
+ if not self.daemon or not hasattr(self.daemon, "task_manager") or self.daemon.task_manager is None:
447
+ return
448
+
449
+ task_manager = self.daemon.task_manager
450
+ tasks = []
451
+
452
+ with task_manager.heap_lock:
453
+ for start_time, stop_time, task_id, task in task_manager.task_heap:
454
+ tasks.append(
455
+ {
456
+ "id": task_id,
457
+ "start_time": datetime.fromtimestamp(start_time, tz=timezone.utc).isoformat(),
458
+ "stop_time": (
459
+ datetime.fromtimestamp(stop_time, tz=timezone.utc).isoformat() if stop_time else None
460
+ ),
461
+ "status": task.status,
462
+ "target": getattr(task, "satelliteName", getattr(task, "target", "unknown")),
463
+ }
464
+ )
465
+
466
+ await self.connection_manager.broadcast({"type": "tasks", "data": tasks})
467
+
468
+ async def broadcast_log(self, log_entry: dict):
469
+ """Broadcast log entry to all connected clients."""
470
+ await self.connection_manager.broadcast({"type": "log", "data": log_entry})
@@ -0,0 +1,123 @@
1
+ """Web server management for CitraScope."""
2
+
3
+ import asyncio
4
+ import logging
5
+ import threading
6
+ import time
7
+
8
+ import uvicorn
9
+
10
+ from citrascope.logging import CITRASCOPE_LOGGER, WebLogHandler
11
+
12
+
13
+ class CitraScopeWebServer:
14
+ """Manages the web server and its configuration."""
15
+
16
+ def __init__(self, daemon, host: str = "0.0.0.0", port: int = 24872):
17
+ self.daemon = daemon
18
+ self.host = host
19
+ self.port = port
20
+ self.web_app = None
21
+ self.web_log_handler = None
22
+
23
+ # Set up web log handler
24
+ self._setup_log_handler()
25
+
26
+ def _setup_log_handler(self):
27
+ """Set up the web log handler."""
28
+ self.web_log_handler = WebLogHandler(max_logs=1000)
29
+ # Use a simpler format for web display
30
+ formatter = logging.Formatter("%(levelname)s - %(name)s - %(message)s")
31
+ self.web_log_handler.setFormatter(formatter)
32
+ # Set handler level to DEBUG so it captures everything
33
+ self.web_log_handler.setLevel(logging.DEBUG)
34
+ CITRASCOPE_LOGGER.addHandler(self.web_log_handler)
35
+ CITRASCOPE_LOGGER.info("Web log handler attached to CITRASCOPE_LOGGER")
36
+
37
+ def ensure_log_handler(self):
38
+ """Ensure the web log handler is still attached to the logger."""
39
+ if self.web_log_handler and self.web_log_handler not in CITRASCOPE_LOGGER.handlers:
40
+ CITRASCOPE_LOGGER.addHandler(self.web_log_handler)
41
+ CITRASCOPE_LOGGER.info("Re-attached web log handler to CITRASCOPE_LOGGER")
42
+
43
+ def configure_uvicorn_logging(self):
44
+ """Configure Uvicorn to use CITRASCOPE_LOGGER."""
45
+ uvicorn_logger = logging.getLogger("uvicorn")
46
+ uvicorn_logger.handlers = CITRASCOPE_LOGGER.handlers
47
+ uvicorn_logger.setLevel(CITRASCOPE_LOGGER.level)
48
+ uvicorn_logger.propagate = False
49
+
50
+ uvicorn_access = logging.getLogger("uvicorn.access")
51
+ uvicorn_access.handlers = CITRASCOPE_LOGGER.handlers
52
+ uvicorn_access.setLevel(CITRASCOPE_LOGGER.level)
53
+ uvicorn_access.propagate = False
54
+
55
+ uvicorn_error = logging.getLogger("uvicorn.error")
56
+ uvicorn_error.handlers = CITRASCOPE_LOGGER.handlers
57
+ uvicorn_error.setLevel(CITRASCOPE_LOGGER.level)
58
+ uvicorn_error.propagate = False
59
+
60
+ def start(self):
61
+ """Start web server in a separate thread with its own event loop."""
62
+
63
+ def run_async_server():
64
+ loop = asyncio.new_event_loop()
65
+ asyncio.set_event_loop(loop)
66
+ try:
67
+ loop.run_until_complete(self.run())
68
+ except KeyboardInterrupt:
69
+ pass
70
+ finally:
71
+ loop.close()
72
+
73
+ thread = threading.Thread(target=run_async_server, daemon=True)
74
+ thread.start()
75
+ CITRASCOPE_LOGGER.info("Web server thread started")
76
+ # Give the web server a moment to start up
77
+ time.sleep(1)
78
+
79
+ async def run(self):
80
+ """Run the web server."""
81
+ try:
82
+ from citrascope.web.app import CitraScopeWebApp
83
+
84
+ self.web_app = CitraScopeWebApp(daemon=self.daemon, web_log_handler=self.web_log_handler)
85
+
86
+ # Connect the log handler to the web app for broadcasting
87
+ # Pass the current event loop so logs can be broadcast from other threads
88
+ if self.web_log_handler:
89
+ current_loop = asyncio.get_event_loop()
90
+ self.web_log_handler.set_web_app(self.web_app, current_loop)
91
+
92
+ CITRASCOPE_LOGGER.info(f"Starting web server on http://{self.host}:{self.port}")
93
+
94
+ # Configure Uvicorn logging
95
+ self.configure_uvicorn_logging()
96
+
97
+ config = uvicorn.Config(self.web_app.app, host=self.host, port=self.port, log_config=None, access_log=True)
98
+ server = uvicorn.Server(config)
99
+
100
+ # Start status broadcast loop
101
+ asyncio.create_task(self._status_broadcast_loop())
102
+
103
+ await server.serve()
104
+ except Exception as e:
105
+ CITRASCOPE_LOGGER.error(f"Web server error: {e}", exc_info=True)
106
+
107
+ async def _status_broadcast_loop(self):
108
+ """Periodically broadcast status and tasks to web clients."""
109
+ check_counter = 0
110
+ while True:
111
+ try:
112
+ await asyncio.sleep(2) # Update every 2 seconds
113
+ if self.web_app:
114
+ await self.web_app.broadcast_status()
115
+ await self.web_app.broadcast_tasks()
116
+
117
+ # Every 10 iterations (20 seconds), check if log handler is still attached
118
+ check_counter += 1
119
+ if check_counter >= 10:
120
+ check_counter = 0
121
+ self.ensure_log_handler()
122
+ except Exception as e:
123
+ CITRASCOPE_LOGGER.error(f"Status broadcast error: {e}")
@@ -0,0 +1,82 @@
1
+ // API client for CitraScope backend
2
+
3
+ /**
4
+ * Wrapper around fetch that handles JSON parsing and error responses
5
+ * @param {string} url - The URL to fetch
6
+ * @param {object} options - Fetch options (method, headers, body, etc.)
7
+ * @returns {Promise<{ok: boolean, status: number, data: any}>}
8
+ */
9
+ export async function fetchJSON(url, options = {}) {
10
+ try {
11
+ const response = await fetch(url, options);
12
+ const data = await response.json();
13
+
14
+ return {
15
+ ok: response.ok,
16
+ status: response.status,
17
+ data: data
18
+ };
19
+ } catch (error) {
20
+ console.error(`API request failed: ${url}`, error);
21
+ throw error;
22
+ }
23
+ }
24
+
25
+ /**
26
+ * Get current configuration
27
+ */
28
+ export async function getConfig() {
29
+ const result = await fetchJSON('/api/config');
30
+ return result.data;
31
+ }
32
+
33
+ /**
34
+ * Save configuration
35
+ */
36
+ export async function saveConfig(config) {
37
+ return await fetchJSON('/api/config', {
38
+ method: 'POST',
39
+ headers: {'Content-Type': 'application/json'},
40
+ body: JSON.stringify(config)
41
+ });
42
+ }
43
+
44
+ /**
45
+ * Get configuration status
46
+ */
47
+ export async function getConfigStatus() {
48
+ const result = await fetchJSON('/api/config/status');
49
+ return result.data;
50
+ }
51
+
52
+ /**
53
+ * Get available hardware adapters
54
+ */
55
+ export async function getHardwareAdapters() {
56
+ const result = await fetchJSON('/api/hardware-adapters');
57
+ return result.data;
58
+ }
59
+
60
+ /**
61
+ * Get hardware adapter schema
62
+ */
63
+ export async function getAdapterSchema(adapterName) {
64
+ const result = await fetchJSON(`/api/hardware-adapters/${adapterName}/schema`);
65
+ return result.data;
66
+ }
67
+
68
+ /**
69
+ * Get task queue
70
+ */
71
+ export async function getTasks() {
72
+ const result = await fetchJSON('/api/tasks');
73
+ return result.data;
74
+ }
75
+
76
+ /**
77
+ * Get recent logs
78
+ */
79
+ export async function getLogs(limit = 100) {
80
+ const result = await fetchJSON(`/api/logs?limit=${limit}`);
81
+ return result.data;
82
+ }