citrascope 0.3.0__py3-none-any.whl → 0.5.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.
@@ -10,6 +10,7 @@ import platformdirs
10
10
  APP_NAME = "citrascope"
11
11
  APP_AUTHOR = "citra-space"
12
12
 
13
+ from citrascope.constants import DEFAULT_API_PORT, DEFAULT_WEB_PORT, PROD_API_HOST
13
14
  from citrascope.logging import CITRASCOPE_LOGGER
14
15
  from citrascope.settings.settings_file_manager import SettingsFileManager
15
16
 
@@ -17,18 +18,11 @@ from citrascope.settings.settings_file_manager import SettingsFileManager
17
18
  class CitraScopeSettings:
18
19
  """Settings for CitraScope loaded from JSON configuration file."""
19
20
 
20
- def __init__(
21
- self,
22
- dev: bool = False,
23
- log_level: str = "INFO",
24
- keep_images: bool = False,
25
- ):
21
+ def __init__(self, web_port: int = DEFAULT_WEB_PORT):
26
22
  """Initialize settings from JSON config file.
27
23
 
28
24
  Args:
29
- dev: If True, use development API endpoint
30
- log_level: Logging level (DEBUG, INFO, WARNING, ERROR)
31
- keep_images: If True, preserve captured images
25
+ web_port: Port for web interface (default: 24872) - bootstrap option only
32
26
  """
33
27
  self.config_manager = SettingsFileManager()
34
28
 
@@ -38,9 +32,9 @@ class CitraScopeSettings:
38
32
  # Application data directories
39
33
  self._images_dir = Path(platformdirs.user_data_dir(APP_NAME, appauthor=APP_AUTHOR)) / "images"
40
34
 
41
- # API Settings
42
- self.host: str = config.get("host", "dev.api.citra.space" if dev else "api.citra.space")
43
- self.port: int = config.get("port", 443)
35
+ # API Settings (all loaded from config file)
36
+ self.host: str = config.get("host", PROD_API_HOST)
37
+ self.port: int = config.get("port", DEFAULT_API_PORT)
44
38
  self.use_ssl: bool = config.get("use_ssl", True)
45
39
  self.personal_access_token: str = config.get("personal_access_token", "")
46
40
  self.telescope_id: str = config.get("telescope_id", "")
@@ -48,12 +42,19 @@ class CitraScopeSettings:
48
42
  # Hardware adapter selection
49
43
  self.hardware_adapter: str = config.get("hardware_adapter", "")
50
44
 
51
- # Hardware adapter-specific settings stored as dict
52
- self.adapter_settings: Dict[str, Any] = config.get("adapter_settings", {})
45
+ # Hardware adapter-specific settings stored as nested dict per adapter
46
+ # Format: {"adapter_name": {"setting_key": value, ...}, ...}
47
+ self._all_adapter_settings: Dict[str, Dict[str, Any]] = config.get("adapter_settings", {})
53
48
 
54
- # Runtime settings (can be overridden by CLI flags)
55
- self.log_level: str = log_level if log_level != "INFO" else config.get("log_level", "INFO")
56
- self.keep_images: bool = keep_images if keep_images else config.get("keep_images", False)
49
+ # Current adapter's settings slice
50
+ self.adapter_settings: Dict[str, Any] = self._all_adapter_settings.get(self.hardware_adapter, {})
51
+
52
+ # Runtime settings (all loaded from config file, configurable via web UI)
53
+ self.log_level: str = config.get("log_level", "INFO")
54
+ self.keep_images: bool = config.get("keep_images", False)
55
+
56
+ # Web port: CLI override if non-default, otherwise use config file
57
+ self.web_port: int = web_port if web_port != DEFAULT_WEB_PORT else config.get("web_port", DEFAULT_WEB_PORT)
57
58
 
58
59
  # Task retry configuration
59
60
  self.max_task_retries: int = config.get("max_task_retries", 3)
@@ -64,10 +65,6 @@ class CitraScopeSettings:
64
65
  self.file_logging_enabled: bool = config.get("file_logging_enabled", True)
65
66
  self.log_retention_days: int = config.get("log_retention_days", 30)
66
67
 
67
- if dev:
68
- self.host = "dev.api.citra.space"
69
- CITRASCOPE_LOGGER.info("Using development API endpoint.")
70
-
71
68
  def get_images_dir(self) -> Path:
72
69
  """Get the path to the images directory.
73
70
 
@@ -102,9 +99,10 @@ class CitraScopeSettings:
102
99
  "personal_access_token": self.personal_access_token,
103
100
  "telescope_id": self.telescope_id,
104
101
  "hardware_adapter": self.hardware_adapter,
105
- "adapter_settings": self.adapter_settings,
102
+ "adapter_settings": self._all_adapter_settings,
106
103
  "log_level": self.log_level,
107
104
  "keep_images": self.keep_images,
105
+ "web_port": self.web_port,
108
106
  "max_task_retries": self.max_task_retries,
109
107
  "initial_retry_delay_seconds": self.initial_retry_delay_seconds,
110
108
  "max_retry_delay_seconds": self.max_retry_delay_seconds,
@@ -114,32 +112,23 @@ class CitraScopeSettings:
114
112
 
115
113
  def save(self) -> None:
116
114
  """Save current settings to JSON config file."""
115
+ # Update nested dict with current adapter's settings before saving
116
+ if self.hardware_adapter:
117
+ self._all_adapter_settings[self.hardware_adapter] = self.adapter_settings
118
+
117
119
  self.config_manager.save_config(self.to_dict())
118
120
  CITRASCOPE_LOGGER.info(f"Configuration saved to {self.config_manager.get_config_path()}")
119
121
 
120
- @classmethod
121
- def from_dict(cls, config: Dict[str, Any]) -> "CitraScopeSettings":
122
- """Create settings instance from dictionary.
122
+ def update_and_save(self, config: Dict[str, Any]) -> None:
123
+ """Update settings from dict and save, preserving other adapters' settings.
123
124
 
124
125
  Args:
125
- config: Dictionary of configuration values.
126
-
127
- Returns:
128
- New CitraScopeSettings instance.
126
+ config: Configuration dict with flat adapter_settings for current adapter.
129
127
  """
130
- settings = cls()
131
- settings.host = config.get("host", settings.host)
132
- settings.port = config.get("port", settings.port)
133
- settings.use_ssl = config.get("use_ssl", settings.use_ssl)
134
- settings.personal_access_token = config.get("personal_access_token", "")
135
- settings.telescope_id = config.get("telescope_id", "")
136
- settings.hardware_adapter = config.get("hardware_adapter", "")
137
- settings.adapter_settings = config.get("adapter_settings", {})
138
- settings.log_level = config.get("log_level", "INFO")
139
- settings.keep_images = config.get("keep_images", False)
140
- settings.max_task_retries = config.get("max_task_retries", 3)
141
- settings.initial_retry_delay_seconds = config.get("initial_retry_delay_seconds", 30)
142
- settings.max_retry_delay_seconds = config.get("max_retry_delay_seconds", 300)
143
- settings.file_logging_enabled = config.get("file_logging_enabled", True)
144
- settings.log_retention_days = config.get("log_retention_days", 30)
145
- return settings
128
+ # Nest incoming adapter_settings under hardware_adapter key
129
+ adapter = config.get("hardware_adapter", self.hardware_adapter)
130
+ if adapter:
131
+ self._all_adapter_settings[adapter] = config.get("adapter_settings", {})
132
+ config["adapter_settings"] = self._all_adapter_settings
133
+
134
+ self.config_manager.save_config(config)
@@ -11,6 +11,9 @@ from citrascope.tasks.scope.static_telescope_task import StaticTelescopeTask
11
11
  from citrascope.tasks.scope.tracking_telescope_task import TrackingTelescopeTask
12
12
  from citrascope.tasks.task import Task
13
13
 
14
+ # Task polling interval in seconds
15
+ TASK_POLL_INTERVAL_SECONDS = 15
16
+
14
17
 
15
18
  class TaskManager:
16
19
  def __init__(
@@ -37,6 +40,9 @@ class TaskManager:
37
40
  self.keep_images = keep_images
38
41
  self.task_retry_counts = {} # Track retry attempts per task ID
39
42
  self.task_last_failure = {} # Track last failure timestamp per task ID
43
+ # Task processing control (always starts active)
44
+ self._processing_active = True
45
+ self._processing_lock = threading.Lock()
40
46
 
41
47
  def poll_tasks(self):
42
48
  while not self._stop_event.is_set():
@@ -44,8 +50,9 @@ class TaskManager:
44
50
  self._report_online()
45
51
  tasks = self.api_client.get_telescope_tasks(self.telescope_record["id"])
46
52
 
47
- # If API call failed (timeout, network error, etc.), skip this poll iteration
53
+ # If API call failed (timeout, network error, etc.), wait before retrying
48
54
  if tasks is None:
55
+ self._stop_event.wait(TASK_POLL_INTERVAL_SECONDS)
49
56
  continue
50
57
 
51
58
  added = 0
@@ -113,7 +120,7 @@ class TaskManager:
113
120
  except Exception as e:
114
121
  self.logger.error(f"Exception in poll_tasks loop: {e}", exc_info=True)
115
122
  time.sleep(5) # avoid tight error loop
116
- self._stop_event.wait(15)
123
+ self._stop_event.wait(TASK_POLL_INTERVAL_SECONDS)
117
124
 
118
125
  def _report_online(self):
119
126
  """
@@ -126,6 +133,14 @@ class TaskManager:
126
133
 
127
134
  def task_runner(self):
128
135
  while not self._stop_event.is_set():
136
+ # Check if task processing is paused
137
+ with self._processing_lock:
138
+ is_paused = not self._processing_active
139
+
140
+ if is_paused:
141
+ self._stop_event.wait(1)
142
+ continue
143
+
129
144
  try:
130
145
  now = int(time.time())
131
146
  completed = 0
@@ -239,6 +254,25 @@ class TaskManager:
239
254
  summary += "No tasks scheduled."
240
255
  return summary
241
256
 
257
+ def pause(self) -> bool:
258
+ """Pause task processing. Returns new state (False)."""
259
+ with self._processing_lock:
260
+ self._processing_active = False
261
+ self.logger.info("Task processing paused")
262
+ return self._processing_active
263
+
264
+ def resume(self) -> bool:
265
+ """Resume task processing. Returns new state (True)."""
266
+ with self._processing_lock:
267
+ self._processing_active = True
268
+ self.logger.info("Task processing resumed")
269
+ return self._processing_active
270
+
271
+ def is_processing_active(self) -> bool:
272
+ """Check if task processing is currently active."""
273
+ with self._processing_lock:
274
+ return self._processing_active
275
+
242
276
  def start(self):
243
277
  self._stop_event.clear()
244
278
  self.poll_thread = threading.Thread(target=self.poll_tasks, daemon=True)
citrascope/web/app.py CHANGED
@@ -4,6 +4,7 @@ import asyncio
4
4
  import json
5
5
  import os
6
6
  from datetime import datetime, timezone
7
+ from importlib.metadata import PackageNotFoundError, version
7
8
  from pathlib import Path
8
9
  from typing import Any, Dict, List, Optional
9
10
 
@@ -13,15 +14,14 @@ from fastapi.responses import HTMLResponse, JSONResponse
13
14
  from fastapi.staticfiles import StaticFiles
14
15
  from pydantic import BaseModel
15
16
 
17
+ from citrascope.constants import (
18
+ DEV_API_HOST,
19
+ DEV_APP_URL,
20
+ PROD_API_HOST,
21
+ PROD_APP_URL,
22
+ )
16
23
  from citrascope.logging import CITRASCOPE_LOGGER
17
24
 
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
25
 
26
26
  class SystemStatus(BaseModel):
27
27
  """Current system status."""
@@ -30,6 +30,7 @@ class SystemStatus(BaseModel):
30
30
  camera_connected: bool = False
31
31
  current_task: Optional[str] = None
32
32
  tasks_pending: int = 0
33
+ processing_active: bool = True
33
34
  hardware_adapter: str = "unknown"
34
35
  telescope_ra: Optional[float] = None
35
36
  telescope_dec: Optional[float] = None
@@ -155,7 +156,7 @@ class CitraScopeWebApp:
155
156
 
156
157
  settings = self.daemon.settings
157
158
  # Determine app URL based on API host
158
- app_url = DEV_APP_URL if "dev." in settings.host else PROD_APP_URL
159
+ app_url = DEV_APP_URL if settings.host == DEV_API_HOST else PROD_APP_URL
159
160
 
160
161
  # Get config file path
161
162
  config_path = str(settings.config_manager.get_config_path())
@@ -198,6 +199,15 @@ class CitraScopeWebApp:
198
199
  "error": getattr(self.daemon, "configuration_error", None),
199
200
  }
200
201
 
202
+ @self.app.get("/api/version")
203
+ async def get_version():
204
+ """Get CitraScope version."""
205
+ try:
206
+ pkg_version = version("citrascope")
207
+ return {"version": pkg_version}
208
+ except PackageNotFoundError:
209
+ return {"version": "development"}
210
+
201
211
  @self.app.get("/api/hardware-adapters")
202
212
  async def get_hardware_adapters():
203
213
  """Get list of available hardware adapters."""
@@ -231,6 +241,13 @@ class CitraScopeWebApp:
231
241
  if not self.daemon:
232
242
  return JSONResponse({"error": "Daemon not available"}, status_code=503)
233
243
 
244
+ # Block config changes during autofocus
245
+ if self.daemon.is_autofocus_in_progress():
246
+ return JSONResponse(
247
+ {"error": "Cannot save configuration while autofocus is running. Check logs for progress."},
248
+ status_code=409,
249
+ )
250
+
234
251
  # Validate required fields
235
252
  required_fields = ["personal_access_token", "telescope_id", "hardware_adapter"]
236
253
  for field in required_fields:
@@ -301,11 +318,7 @@ class CitraScopeWebApp:
301
318
  status_code=400,
302
319
  )
303
320
 
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)
321
+ self.daemon.settings.update_and_save(config)
309
322
 
310
323
  # Trigger hot-reload
311
324
  success, error = self.daemon.reload_configuration()
@@ -362,6 +375,113 @@ class CitraScopeWebApp:
362
375
  return {"logs": logs}
363
376
  return {"logs": []}
364
377
 
378
+ @self.app.post("/api/tasks/pause")
379
+ async def pause_tasks():
380
+ """Pause task processing."""
381
+ if not self.daemon or not self.daemon.task_manager:
382
+ return JSONResponse({"error": "Task manager not available"}, status_code=503)
383
+
384
+ self.daemon.task_manager.pause()
385
+ await self.broadcast_status()
386
+
387
+ return {"status": "paused", "message": "Task processing paused"}
388
+
389
+ @self.app.post("/api/tasks/resume")
390
+ async def resume_tasks():
391
+ """Resume task processing."""
392
+ if not self.daemon or not self.daemon.task_manager:
393
+ return JSONResponse({"error": "Task manager not available"}, status_code=503)
394
+
395
+ # Block resume during autofocus
396
+ if self.daemon.is_autofocus_in_progress():
397
+ return JSONResponse(
398
+ {"error": "Cannot resume task processing during autofocus. Check logs for progress."},
399
+ status_code=409,
400
+ )
401
+
402
+ self.daemon.task_manager.resume()
403
+ await self.broadcast_status()
404
+
405
+ return {"status": "active", "message": "Task processing resumed"}
406
+
407
+ @self.app.get("/api/adapter/filters")
408
+ async def get_filters():
409
+ """Get current filter configuration."""
410
+ if not self.daemon or not self.daemon.hardware_adapter:
411
+ return JSONResponse({"error": "Hardware adapter not available"}, status_code=503)
412
+
413
+ if not self.daemon.hardware_adapter.supports_filter_management():
414
+ return JSONResponse({"error": "Adapter does not support filter management"}, status_code=404)
415
+
416
+ try:
417
+ filter_config = self.daemon.hardware_adapter.get_filter_config()
418
+ return {"filters": filter_config}
419
+ except Exception as e:
420
+ CITRASCOPE_LOGGER.error(f"Error getting filter config: {e}", exc_info=True)
421
+ return JSONResponse({"error": str(e)}, status_code=500)
422
+
423
+ @self.app.patch("/api/adapter/filters/{filter_id}")
424
+ async def update_filter(filter_id: str, update: dict):
425
+ """Update focus position for a specific filter."""
426
+ if not self.daemon or not self.daemon.hardware_adapter:
427
+ return JSONResponse({"error": "Hardware adapter not available"}, status_code=503)
428
+
429
+ if not self.daemon.hardware_adapter.supports_filter_management():
430
+ return JSONResponse({"error": "Adapter does not support filter management"}, status_code=404)
431
+
432
+ try:
433
+ # Validate focus_position is provided and is a valid integer within hardware limits
434
+ if "focus_position" not in update:
435
+ return JSONResponse({"error": "focus_position is required"}, status_code=400)
436
+
437
+ focus_position = update["focus_position"]
438
+ if not isinstance(focus_position, int):
439
+ return JSONResponse({"error": "focus_position must be an integer"}, status_code=400)
440
+
441
+ # Typical focuser range is 0-65535 (16-bit unsigned)
442
+ if focus_position < 0 or focus_position > 65535:
443
+ return JSONResponse({"error": "focus_position must be between 0 and 65535"}, status_code=400)
444
+
445
+ # Get current filter config
446
+ filter_config = self.daemon.hardware_adapter.get_filter_config()
447
+ if filter_id not in filter_config:
448
+ return JSONResponse({"error": f"Filter {filter_id} not found"}, status_code=404)
449
+
450
+ # Update via adapter interface
451
+ if not self.daemon.hardware_adapter.update_filter_focus(filter_id, focus_position):
452
+ return JSONResponse({"error": "Failed to update filter in adapter"}, status_code=500)
453
+
454
+ # Save to settings
455
+ self.daemon._save_filter_config()
456
+
457
+ return {"success": True, "filter_id": filter_id, "focus_position": focus_position}
458
+ except ValueError:
459
+ return JSONResponse({"error": "Invalid filter_id format"}, status_code=400)
460
+ except Exception as e:
461
+ CITRASCOPE_LOGGER.error(f"Error updating filter: {e}", exc_info=True)
462
+ return JSONResponse({"error": str(e)}, status_code=500)
463
+
464
+ @self.app.post("/api/adapter/autofocus")
465
+ async def trigger_autofocus():
466
+ """Trigger autofocus routine."""
467
+ if not self.daemon:
468
+ return JSONResponse({"error": "Daemon not available"}, status_code=503)
469
+
470
+ if not self.daemon.hardware_adapter or not self.daemon.hardware_adapter.supports_filter_management():
471
+ return JSONResponse({"error": "Filter management not supported"}, status_code=404)
472
+
473
+ try:
474
+ # Run autofocus in a separate thread to avoid blocking the async event loop
475
+ # Autofocus can take several minutes (slewing + focusing multiple filters)
476
+ success, error = await asyncio.to_thread(self.daemon.trigger_autofocus)
477
+ if success:
478
+ return {"success": True, "message": "Autofocus completed successfully"}
479
+ else:
480
+ return JSONResponse({"error": error}, status_code=500)
481
+ except Exception as e:
482
+ CITRASCOPE_LOGGER.error(f"Error triggering autofocus: {e}", exc_info=True)
483
+ return JSONResponse({"error": str(e)}, status_code=500)
484
+
365
485
  @self.app.websocket("/ws")
366
486
  async def websocket_endpoint(websocket: WebSocket):
367
487
  """WebSocket endpoint for real-time updates."""
@@ -430,6 +550,10 @@ class CitraScopeWebApp:
430
550
  self.status.ground_station_name = gs_name
431
551
  self.status.ground_station_url = f"{base_url}/ground-stations/{gs_id}" if gs_id else None
432
552
 
553
+ # Update task processing state
554
+ if hasattr(self.daemon, "task_manager") and self.daemon.task_manager:
555
+ self.status.processing_active = self.daemon.task_manager.is_processing_active()
556
+
433
557
  self.status.last_update = datetime.now().isoformat()
434
558
 
435
559
  except Exception as e:
citrascope/web/server.py CHANGED
@@ -7,13 +7,14 @@ import time
7
7
 
8
8
  import uvicorn
9
9
 
10
+ from citrascope.constants import DEFAULT_WEB_PORT
10
11
  from citrascope.logging import CITRASCOPE_LOGGER, WebLogHandler
11
12
 
12
13
 
13
14
  class CitraScopeWebServer:
14
15
  """Manages the web server and its configuration."""
15
16
 
16
- def __init__(self, daemon, host: str = "0.0.0.0", port: int = 24872):
17
+ def __init__(self, daemon, host: str = "0.0.0.0", port: int = DEFAULT_WEB_PORT):
17
18
  self.daemon = daemon
18
19
  self.host = host
19
20
  self.port = port
@@ -101,6 +102,14 @@ class CitraScopeWebServer:
101
102
  asyncio.create_task(self._status_broadcast_loop())
102
103
 
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)
104
113
  except Exception as e:
105
114
  CITRASCOPE_LOGGER.error(f"Web server error: {e}", exc_info=True)
106
115