citrascope 0.1.0__py3-none-any.whl → 0.4.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 (37) hide show
  1. citrascope/__main__.py +13 -13
  2. citrascope/api/abstract_api_client.py +7 -0
  3. citrascope/api/citra_api_client.py +43 -2
  4. citrascope/citra_scope_daemon.py +205 -61
  5. citrascope/constants.py +23 -0
  6. citrascope/hardware/abstract_astro_hardware_adapter.py +70 -2
  7. citrascope/hardware/adapter_registry.py +94 -0
  8. citrascope/hardware/indi_adapter.py +456 -16
  9. citrascope/hardware/kstars_dbus_adapter.py +179 -0
  10. citrascope/hardware/nina_adv_http_adapter.py +593 -0
  11. citrascope/hardware/nina_adv_http_survey_template.json +328 -0
  12. citrascope/logging/__init__.py +2 -1
  13. citrascope/logging/_citrascope_logger.py +80 -1
  14. citrascope/logging/web_log_handler.py +75 -0
  15. citrascope/settings/citrascope_settings.py +140 -0
  16. citrascope/settings/settings_file_manager.py +126 -0
  17. citrascope/tasks/runner.py +129 -29
  18. citrascope/tasks/scope/base_telescope_task.py +25 -10
  19. citrascope/tasks/scope/static_telescope_task.py +11 -3
  20. citrascope/web/__init__.py +1 -0
  21. citrascope/web/app.py +479 -0
  22. citrascope/web/server.py +132 -0
  23. citrascope/web/static/api.js +82 -0
  24. citrascope/web/static/app.js +502 -0
  25. citrascope/web/static/config.js +438 -0
  26. citrascope/web/static/img/citra.png +0 -0
  27. citrascope/web/static/img/favicon.png +0 -0
  28. citrascope/web/static/style.css +152 -0
  29. citrascope/web/static/websocket.js +127 -0
  30. citrascope/web/templates/dashboard.html +407 -0
  31. {citrascope-0.1.0.dist-info → citrascope-0.4.0.dist-info}/METADATA +87 -47
  32. citrascope-0.4.0.dist-info/RECORD +38 -0
  33. {citrascope-0.1.0.dist-info → citrascope-0.4.0.dist-info}/WHEEL +1 -1
  34. citrascope/settings/_citrascope_settings.py +0 -42
  35. citrascope-0.1.0.dist-info/RECORD +0 -21
  36. docs/index.md +0 -47
  37. {citrascope-0.1.0.dist-info → citrascope-0.4.0.dist-info}/entry_points.txt +0 -0
citrascope/web/app.py ADDED
@@ -0,0 +1,479 @@
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 importlib.metadata import PackageNotFoundError, version
8
+ from pathlib import Path
9
+ from typing import Any, Dict, List, Optional
10
+
11
+ from fastapi import FastAPI, WebSocket, WebSocketDisconnect
12
+ from fastapi.middleware.cors import CORSMiddleware
13
+ from fastapi.responses import HTMLResponse, JSONResponse
14
+ from fastapi.staticfiles import StaticFiles
15
+ from pydantic import BaseModel
16
+
17
+ from citrascope.constants import (
18
+ DEV_API_HOST,
19
+ DEV_APP_URL,
20
+ PROD_API_HOST,
21
+ PROD_APP_URL,
22
+ )
23
+ from citrascope.logging import CITRASCOPE_LOGGER
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 settings.host == DEV_API_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/version")
202
+ async def get_version():
203
+ """Get CitraScope version."""
204
+ try:
205
+ pkg_version = version("citrascope")
206
+ return {"version": pkg_version}
207
+ except PackageNotFoundError:
208
+ return {"version": "development"}
209
+
210
+ @self.app.get("/api/hardware-adapters")
211
+ async def get_hardware_adapters():
212
+ """Get list of available hardware adapters."""
213
+ from citrascope.hardware.adapter_registry import list_adapters
214
+
215
+ adapters_info = list_adapters()
216
+ return {
217
+ "adapters": list(adapters_info.keys()),
218
+ "descriptions": {name: info["description"] for name, info in adapters_info.items()},
219
+ }
220
+
221
+ @self.app.get("/api/hardware-adapters/{adapter_name}/schema")
222
+ async def get_adapter_schema(adapter_name: str):
223
+ """Get configuration schema for a specific hardware adapter."""
224
+ from citrascope.hardware.adapter_registry import get_adapter_schema as get_schema
225
+
226
+ try:
227
+ schema = get_schema(adapter_name)
228
+ return {"schema": schema}
229
+ except ValueError as e:
230
+ # Invalid adapter name
231
+ return JSONResponse({"error": str(e)}, status_code=404)
232
+ except Exception as e:
233
+ CITRASCOPE_LOGGER.error(f"Error getting schema for {adapter_name}: {e}", exc_info=True)
234
+ return JSONResponse({"error": str(e)}, status_code=500)
235
+
236
+ @self.app.post("/api/config")
237
+ async def update_config(config: Dict[str, Any]):
238
+ """Update configuration and trigger hot-reload."""
239
+ try:
240
+ if not self.daemon:
241
+ return JSONResponse({"error": "Daemon not available"}, status_code=503)
242
+
243
+ # Validate required fields
244
+ required_fields = ["personal_access_token", "telescope_id", "hardware_adapter"]
245
+ for field in required_fields:
246
+ if field not in config or not config[field]:
247
+ return JSONResponse(
248
+ {"error": f"Missing required field: {field}"},
249
+ status_code=400,
250
+ )
251
+
252
+ # Validate adapter_settings against schema if adapter is specified
253
+ adapter_name = config.get("hardware_adapter")
254
+ adapter_settings = config.get("adapter_settings", {})
255
+
256
+ if adapter_name:
257
+ # Get schema for validation
258
+ schema_response = await get_adapter_schema(adapter_name)
259
+ if isinstance(schema_response, JSONResponse):
260
+ return schema_response # Error getting schema
261
+
262
+ schema = schema_response.get("schema", [])
263
+
264
+ # Validate required fields in adapter settings
265
+ for field_schema in schema:
266
+ field_name = field_schema.get("name")
267
+ is_required = field_schema.get("required", False)
268
+
269
+ if is_required and field_name not in adapter_settings:
270
+ return JSONResponse(
271
+ {"error": f"Missing required adapter setting: {field_name}"},
272
+ status_code=400,
273
+ )
274
+
275
+ # Validate type and constraints if present
276
+ if field_name in adapter_settings:
277
+ value = adapter_settings[field_name]
278
+ field_type = field_schema.get("type")
279
+
280
+ # Type validation
281
+ if field_type == "int":
282
+ try:
283
+ value = int(value)
284
+ adapter_settings[field_name] = value
285
+ except (ValueError, TypeError):
286
+ return JSONResponse(
287
+ {"error": f"Field '{field_name}' must be an integer"},
288
+ status_code=400,
289
+ )
290
+
291
+ # Range validation
292
+ if "min" in field_schema and value < field_schema["min"]:
293
+ return JSONResponse(
294
+ {"error": f"Field '{field_name}' must be >= {field_schema['min']}"},
295
+ status_code=400,
296
+ )
297
+ if "max" in field_schema and value > field_schema["max"]:
298
+ return JSONResponse(
299
+ {"error": f"Field '{field_name}' must be <= {field_schema['max']}"},
300
+ status_code=400,
301
+ )
302
+
303
+ elif field_type == "float":
304
+ try:
305
+ value = float(value)
306
+ adapter_settings[field_name] = value
307
+ except (ValueError, TypeError):
308
+ return JSONResponse(
309
+ {"error": f"Field '{field_name}' must be a number"},
310
+ status_code=400,
311
+ )
312
+
313
+ # Save configuration to file
314
+ from citrascope.settings.settings_file_manager import SettingsFileManager
315
+
316
+ config_manager = SettingsFileManager()
317
+ config_manager.save_config(config)
318
+
319
+ # Trigger hot-reload
320
+ success, error = self.daemon.reload_configuration()
321
+
322
+ if success:
323
+ return {
324
+ "status": "success",
325
+ "message": "Configuration updated and reloaded successfully",
326
+ }
327
+ else:
328
+ return JSONResponse(
329
+ {
330
+ "status": "error",
331
+ "message": f"Configuration saved but reload failed: {error}",
332
+ "error": error,
333
+ },
334
+ status_code=500,
335
+ )
336
+
337
+ except Exception as e:
338
+ CITRASCOPE_LOGGER.error(f"Error updating config: {e}", exc_info=True)
339
+ return JSONResponse({"error": str(e)}, status_code=500)
340
+
341
+ @self.app.get("/api/tasks")
342
+ async def get_tasks():
343
+ """Get current task queue."""
344
+ if not self.daemon or not hasattr(self.daemon, "task_manager") or self.daemon.task_manager is None:
345
+ return []
346
+
347
+ task_manager = self.daemon.task_manager
348
+ tasks = []
349
+
350
+ with task_manager.heap_lock:
351
+ for start_time, stop_time, task_id, task in task_manager.task_heap:
352
+ tasks.append(
353
+ {
354
+ "id": task_id,
355
+ "start_time": datetime.fromtimestamp(start_time, tz=timezone.utc).isoformat(),
356
+ "stop_time": (
357
+ datetime.fromtimestamp(stop_time, tz=timezone.utc).isoformat() if stop_time else None
358
+ ),
359
+ "status": task.status,
360
+ "target": getattr(task, "satelliteName", getattr(task, "target", "unknown")),
361
+ }
362
+ )
363
+
364
+ return tasks
365
+
366
+ @self.app.get("/api/logs")
367
+ async def get_logs(limit: int = 100):
368
+ """Get recent log entries."""
369
+ if self.web_log_handler:
370
+ logs = self.web_log_handler.get_recent_logs(limit)
371
+ return {"logs": logs}
372
+ return {"logs": []}
373
+
374
+ @self.app.websocket("/ws")
375
+ async def websocket_endpoint(websocket: WebSocket):
376
+ """WebSocket endpoint for real-time updates."""
377
+ await self.connection_manager.connect(websocket)
378
+ try:
379
+ # Send initial status
380
+ if self.daemon:
381
+ self._update_status_from_daemon()
382
+ await websocket.send_json({"type": "status", "data": self.status.dict()})
383
+
384
+ # Keep connection alive and listen for client messages
385
+ while True:
386
+ data = await websocket.receive_text()
387
+ # Handle client requests if needed
388
+ await websocket.send_json({"type": "pong", "data": data})
389
+
390
+ except WebSocketDisconnect:
391
+ self.connection_manager.disconnect(websocket)
392
+ except Exception as e:
393
+ CITRASCOPE_LOGGER.error(f"WebSocket error: {e}")
394
+ self.connection_manager.disconnect(websocket)
395
+
396
+ def _update_status_from_daemon(self):
397
+ """Update status from daemon state."""
398
+ if not self.daemon:
399
+ return
400
+
401
+ try:
402
+ self.status.hardware_adapter = self.daemon.settings.hardware_adapter
403
+
404
+ if hasattr(self.daemon, "hardware_adapter") and self.daemon.hardware_adapter:
405
+ # Check telescope connection status
406
+ try:
407
+ self.status.telescope_connected = self.daemon.hardware_adapter.is_telescope_connected()
408
+ if self.status.telescope_connected:
409
+ # If connected, try to get position
410
+ ra, dec = self.daemon.hardware_adapter.get_telescope_direction()
411
+ self.status.telescope_ra = ra
412
+ self.status.telescope_dec = dec
413
+ except Exception:
414
+ self.status.telescope_connected = False
415
+
416
+ # Check camera connection status
417
+ try:
418
+ self.status.camera_connected = self.daemon.hardware_adapter.is_camera_connected()
419
+ except Exception:
420
+ self.status.camera_connected = False
421
+
422
+ if hasattr(self.daemon, "task_manager") and self.daemon.task_manager:
423
+ task_manager = self.daemon.task_manager
424
+ self.status.current_task = task_manager.current_task_id
425
+ with task_manager.heap_lock:
426
+ self.status.tasks_pending = len(task_manager.task_heap)
427
+
428
+ # Get ground station information from daemon (available after API validation)
429
+ if hasattr(self.daemon, "ground_station") and self.daemon.ground_station:
430
+ gs_record = self.daemon.ground_station
431
+ gs_id = gs_record.get("id")
432
+ gs_name = gs_record.get("name", "Unknown")
433
+
434
+ # Build the URL based on the API host (dev vs prod)
435
+ api_host = self.daemon.settings.host
436
+ base_url = DEV_APP_URL if "dev." in api_host else PROD_APP_URL
437
+
438
+ self.status.ground_station_id = gs_id
439
+ self.status.ground_station_name = gs_name
440
+ self.status.ground_station_url = f"{base_url}/ground-stations/{gs_id}" if gs_id else None
441
+
442
+ self.status.last_update = datetime.now().isoformat()
443
+
444
+ except Exception as e:
445
+ CITRASCOPE_LOGGER.error(f"Error updating status: {e}")
446
+
447
+ async def broadcast_status(self):
448
+ """Broadcast current status to all connected clients."""
449
+ if self.daemon:
450
+ self._update_status_from_daemon()
451
+ await self.connection_manager.broadcast({"type": "status", "data": self.status.dict()})
452
+
453
+ async def broadcast_tasks(self):
454
+ """Broadcast current task queue to all connected clients."""
455
+ if not self.daemon or not hasattr(self.daemon, "task_manager") or self.daemon.task_manager is None:
456
+ return
457
+
458
+ task_manager = self.daemon.task_manager
459
+ tasks = []
460
+
461
+ with task_manager.heap_lock:
462
+ for start_time, stop_time, task_id, task in task_manager.task_heap:
463
+ tasks.append(
464
+ {
465
+ "id": task_id,
466
+ "start_time": datetime.fromtimestamp(start_time, tz=timezone.utc).isoformat(),
467
+ "stop_time": (
468
+ datetime.fromtimestamp(stop_time, tz=timezone.utc).isoformat() if stop_time else None
469
+ ),
470
+ "status": task.status,
471
+ "target": getattr(task, "satelliteName", getattr(task, "target", "unknown")),
472
+ }
473
+ )
474
+
475
+ await self.connection_manager.broadcast({"type": "tasks", "data": tasks})
476
+
477
+ async def broadcast_log(self, log_entry: dict):
478
+ """Broadcast log entry to all connected clients."""
479
+ await self.connection_manager.broadcast({"type": "log", "data": log_entry})
@@ -0,0 +1,132 @@
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.constants import DEFAULT_WEB_PORT
11
+ from citrascope.logging import CITRASCOPE_LOGGER, WebLogHandler
12
+
13
+
14
+ class CitraScopeWebServer:
15
+ """Manages the web server and its configuration."""
16
+
17
+ def __init__(self, daemon, host: str = "0.0.0.0", port: int = DEFAULT_WEB_PORT):
18
+ self.daemon = daemon
19
+ self.host = host
20
+ self.port = port
21
+ self.web_app = None
22
+ self.web_log_handler = None
23
+
24
+ # Set up web log handler
25
+ self._setup_log_handler()
26
+
27
+ def _setup_log_handler(self):
28
+ """Set up the web log handler."""
29
+ self.web_log_handler = WebLogHandler(max_logs=1000)
30
+ # Use a simpler format for web display
31
+ formatter = logging.Formatter("%(levelname)s - %(name)s - %(message)s")
32
+ self.web_log_handler.setFormatter(formatter)
33
+ # Set handler level to DEBUG so it captures everything
34
+ self.web_log_handler.setLevel(logging.DEBUG)
35
+ CITRASCOPE_LOGGER.addHandler(self.web_log_handler)
36
+ CITRASCOPE_LOGGER.info("Web log handler attached to CITRASCOPE_LOGGER")
37
+
38
+ def ensure_log_handler(self):
39
+ """Ensure the web log handler is still attached to the logger."""
40
+ if self.web_log_handler and self.web_log_handler not in CITRASCOPE_LOGGER.handlers:
41
+ CITRASCOPE_LOGGER.addHandler(self.web_log_handler)
42
+ CITRASCOPE_LOGGER.info("Re-attached web log handler to CITRASCOPE_LOGGER")
43
+
44
+ def configure_uvicorn_logging(self):
45
+ """Configure Uvicorn to use CITRASCOPE_LOGGER."""
46
+ uvicorn_logger = logging.getLogger("uvicorn")
47
+ uvicorn_logger.handlers = CITRASCOPE_LOGGER.handlers
48
+ uvicorn_logger.setLevel(CITRASCOPE_LOGGER.level)
49
+ uvicorn_logger.propagate = False
50
+
51
+ uvicorn_access = logging.getLogger("uvicorn.access")
52
+ uvicorn_access.handlers = CITRASCOPE_LOGGER.handlers
53
+ uvicorn_access.setLevel(CITRASCOPE_LOGGER.level)
54
+ uvicorn_access.propagate = False
55
+
56
+ uvicorn_error = logging.getLogger("uvicorn.error")
57
+ uvicorn_error.handlers = CITRASCOPE_LOGGER.handlers
58
+ uvicorn_error.setLevel(CITRASCOPE_LOGGER.level)
59
+ uvicorn_error.propagate = False
60
+
61
+ def start(self):
62
+ """Start web server in a separate thread with its own event loop."""
63
+
64
+ def run_async_server():
65
+ loop = asyncio.new_event_loop()
66
+ asyncio.set_event_loop(loop)
67
+ try:
68
+ loop.run_until_complete(self.run())
69
+ except KeyboardInterrupt:
70
+ pass
71
+ finally:
72
+ loop.close()
73
+
74
+ thread = threading.Thread(target=run_async_server, daemon=True)
75
+ thread.start()
76
+ CITRASCOPE_LOGGER.info("Web server thread started")
77
+ # Give the web server a moment to start up
78
+ time.sleep(1)
79
+
80
+ async def run(self):
81
+ """Run the web server."""
82
+ try:
83
+ from citrascope.web.app import CitraScopeWebApp
84
+
85
+ self.web_app = CitraScopeWebApp(daemon=self.daemon, web_log_handler=self.web_log_handler)
86
+
87
+ # Connect the log handler to the web app for broadcasting
88
+ # Pass the current event loop so logs can be broadcast from other threads
89
+ if self.web_log_handler:
90
+ current_loop = asyncio.get_event_loop()
91
+ self.web_log_handler.set_web_app(self.web_app, current_loop)
92
+
93
+ CITRASCOPE_LOGGER.info(f"Starting web server on http://{self.host}:{self.port}")
94
+
95
+ # Configure Uvicorn logging
96
+ self.configure_uvicorn_logging()
97
+
98
+ config = uvicorn.Config(self.web_app.app, host=self.host, port=self.port, log_config=None, access_log=True)
99
+ server = uvicorn.Server(config)
100
+
101
+ # Start status broadcast loop
102
+ asyncio.create_task(self._status_broadcast_loop())
103
+
104
+ await server.serve()
105
+ except OSError as e:
106
+ if e.errno == 48: # Address already in use
107
+ CITRASCOPE_LOGGER.error(
108
+ f"Port {self.port} is already in use. Please stop any other services using this port "
109
+ f"or use --web-port to specify a different port."
110
+ )
111
+ else:
112
+ CITRASCOPE_LOGGER.error(f"Web server OS error: {e}", exc_info=True)
113
+ except Exception as e:
114
+ CITRASCOPE_LOGGER.error(f"Web server error: {e}", exc_info=True)
115
+
116
+ async def _status_broadcast_loop(self):
117
+ """Periodically broadcast status and tasks to web clients."""
118
+ check_counter = 0
119
+ while True:
120
+ try:
121
+ await asyncio.sleep(2) # Update every 2 seconds
122
+ if self.web_app:
123
+ await self.web_app.broadcast_status()
124
+ await self.web_app.broadcast_tasks()
125
+
126
+ # Every 10 iterations (20 seconds), check if log handler is still attached
127
+ check_counter += 1
128
+ if check_counter >= 10:
129
+ check_counter = 0
130
+ self.ensure_log_handler()
131
+ except Exception as e:
132
+ 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
+ }