citrascope 0.8.0__py3-none-any.whl → 0.9.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.
@@ -1,11 +1,14 @@
1
1
  """Time source implementations for CitraScope."""
2
2
 
3
+ import subprocess
3
4
  import time
4
5
  from abc import ABC, abstractmethod
5
6
  from typing import Optional
6
7
 
7
8
  import ntplib
8
9
 
10
+ from citrascope.logging import CITRASCOPE_LOGGER
11
+
9
12
 
10
13
  class AbstractTimeSource(ABC):
11
14
  """Abstract base class for time sources."""
@@ -25,6 +28,15 @@ class AbstractTimeSource(ABC):
25
28
  """Get the name of this time source."""
26
29
  pass
27
30
 
31
+ def get_metadata(self) -> Optional[dict]:
32
+ """
33
+ Get optional metadata about the time source.
34
+
35
+ Returns:
36
+ Dictionary with metadata, or None if not applicable.
37
+ """
38
+ return None
39
+
28
40
 
29
41
  class NTPTimeSource(AbstractTimeSource):
30
42
  """NTP-based time source using pool.ntp.org."""
@@ -60,3 +72,190 @@ class NTPTimeSource(AbstractTimeSource):
60
72
  def get_source_name(self) -> str:
61
73
  """Get the name of this time source."""
62
74
  return "ntp"
75
+
76
+
77
+ def _get_gpsd_metadata() -> Optional[dict]:
78
+ """
79
+ Query gpsd for satellite count and fix quality using gpspipe command.
80
+
81
+ Returns:
82
+ Dictionary with 'satellites' and 'fix_mode' keys, or None if unavailable.
83
+ """
84
+ try:
85
+ import json
86
+
87
+ # Use gpspipe to get JSON output from gpsd (cleaner than sockets)
88
+ # Request 10 messages to ensure we get both TPV (fix mode) and SKY (satellite count)
89
+ result = subprocess.run(
90
+ ["gpspipe", "-w", "-n", "10"],
91
+ capture_output=True,
92
+ timeout=3,
93
+ text=True,
94
+ )
95
+
96
+ if result.returncode != 0:
97
+ return None
98
+
99
+ # Parse JSON lines to extract TPV (fix mode) and SKY (satellite count)
100
+ fix_mode = 0
101
+ satellites = 0
102
+
103
+ for line in result.stdout.strip().split("\n"):
104
+ if not line:
105
+ continue
106
+
107
+ try:
108
+ data = json.loads(line)
109
+ msg_class = data.get("class")
110
+
111
+ # Extract fix mode from TPV message
112
+ if msg_class == "TPV" and "mode" in data:
113
+ fix_mode = data["mode"]
114
+
115
+ # Extract satellite count from SKY message
116
+ if msg_class == "SKY":
117
+ # Prefer uSat (used satellites) if available
118
+ if "uSat" in data:
119
+ satellites = data["uSat"]
120
+ # Fall back to counting satellites array
121
+ elif "satellites" in data:
122
+ satellites = len([s for s in data["satellites"] if s.get("used", False)])
123
+
124
+ except json.JSONDecodeError:
125
+ continue
126
+
127
+ return {"satellites": satellites, "fix_mode": fix_mode}
128
+
129
+ except (FileNotFoundError, OSError):
130
+ # gpspipe not available or gpsd not running
131
+ return None
132
+ except Exception as e:
133
+ CITRASCOPE_LOGGER.debug(f"Could not query gpsd: {e}")
134
+ return None
135
+
136
+
137
+ class ChronyTimeSource(AbstractTimeSource):
138
+ """Chrony-based time source that detects GPS references."""
139
+
140
+ def __init__(self, timeout: int = 5):
141
+ """
142
+ Initialize Chrony time source.
143
+
144
+ Args:
145
+ timeout: Command timeout in seconds
146
+ """
147
+ self.timeout = timeout
148
+ self._gps_metadata: Optional[dict] = None
149
+ self._source_name: str = "chrony"
150
+
151
+ def is_available(self) -> bool:
152
+ """
153
+ Check if chrony is available and running.
154
+
155
+ Returns:
156
+ True if chronyc command succeeds, False otherwise.
157
+ """
158
+ try:
159
+ result = subprocess.run(
160
+ ["chronyc", "-c", "tracking"],
161
+ capture_output=True,
162
+ timeout=self.timeout,
163
+ text=True,
164
+ )
165
+ return result.returncode == 0
166
+ except Exception:
167
+ return False
168
+
169
+ def get_offset_ms(self) -> Optional[float]:
170
+ """
171
+ Query chrony for clock offset and detect GPS reference.
172
+
173
+ Returns:
174
+ Clock offset in milliseconds, or None if query fails.
175
+ """
176
+ try:
177
+ # Get tracking info for offset
178
+ tracking_result = subprocess.run(
179
+ ["chronyc", "-c", "tracking"],
180
+ capture_output=True,
181
+ timeout=self.timeout,
182
+ text=True,
183
+ check=True,
184
+ )
185
+
186
+ # Parse CSV output: field index 4 is "System time" in seconds
187
+ tracking_fields = tracking_result.stdout.strip().split(",")
188
+ if len(tracking_fields) > 4:
189
+ offset_seconds = float(tracking_fields[4])
190
+ offset_ms = offset_seconds * 1000.0
191
+ else:
192
+ return None
193
+
194
+ # Get sources to detect GPS reference
195
+ sources_result = subprocess.run(
196
+ ["chronyc", "-c", "sources"],
197
+ capture_output=True,
198
+ timeout=self.timeout,
199
+ text=True,
200
+ check=True,
201
+ )
202
+
203
+ # Parse sources to detect GPS
204
+ gps_detected = False
205
+ for line in sources_result.stdout.strip().split("\n"):
206
+ if not line:
207
+ continue
208
+
209
+ fields = line.split(",")
210
+ if len(fields) < 3:
211
+ continue
212
+
213
+ mode = fields[0] # '#' = local reference
214
+ state = fields[1] # '*' = currently selected
215
+ name = fields[2].upper()
216
+
217
+ # Check if this is a selected GPS reference
218
+ if "*" in state and "#" in mode:
219
+ # Check for GPS-related names
220
+ gps_keywords = ["GPS", "SHM", "PPS", "SOCK", "NMEA"]
221
+ if any(keyword in name for keyword in gps_keywords):
222
+ gps_detected = True
223
+ break
224
+
225
+ # If GPS detected, query gpsd for metadata
226
+ if gps_detected:
227
+ self._source_name = "gps"
228
+ CITRASCOPE_LOGGER.info("GPS reference detected in chrony sources")
229
+ self._gps_metadata = _get_gpsd_metadata()
230
+ if self._gps_metadata:
231
+ CITRASCOPE_LOGGER.info(
232
+ f"GPS lock acquired: {self._gps_metadata['satellites']} satellites, "
233
+ f"fix mode {self._gps_metadata['fix_mode']}"
234
+ )
235
+ else:
236
+ CITRASCOPE_LOGGER.warning(
237
+ "GPS reference active in chrony but gpsd metadata unavailable "
238
+ "(gpsd/gpspipe may not be available)"
239
+ )
240
+ else:
241
+ self._source_name = "chrony"
242
+ self._gps_metadata = None
243
+ CITRASCOPE_LOGGER.warning("No GPS reference detected in chrony sources - using NTP/other time source")
244
+
245
+ return offset_ms
246
+
247
+ except Exception:
248
+ return None
249
+
250
+ def get_source_name(self) -> str:
251
+ """Get the name of this time source."""
252
+ return self._source_name
253
+
254
+ def get_metadata(self) -> Optional[dict]:
255
+ """
256
+ Get GPS metadata if available.
257
+
258
+ Returns:
259
+ Dictionary with GPS metadata, or None.
260
+ """
261
+ return self._gps_metadata
citrascope/web/app.py CHANGED
@@ -8,10 +8,11 @@ from importlib.metadata import PackageNotFoundError, version
8
8
  from pathlib import Path
9
9
  from typing import Any, Dict, List, Optional
10
10
 
11
- from fastapi import FastAPI, WebSocket, WebSocketDisconnect
11
+ from fastapi import FastAPI, Request, WebSocket, WebSocketDisconnect
12
12
  from fastapi.middleware.cors import CORSMiddleware
13
13
  from fastapi.responses import HTMLResponse, JSONResponse
14
14
  from fastapi.staticfiles import StaticFiles
15
+ from fastapi.templating import Jinja2Templates
15
16
  from pydantic import BaseModel
16
17
 
17
18
  from citrascope.constants import (
@@ -28,6 +29,7 @@ class SystemStatus(BaseModel):
28
29
 
29
30
  telescope_connected: bool = False
30
31
  camera_connected: bool = False
32
+ supports_direct_camera_control: bool = False
31
33
  current_task: Optional[str] = None
32
34
  tasks_pending: int = 0
33
35
  processing_active: bool = True
@@ -126,6 +128,16 @@ class CitraScopeWebApp:
126
128
  if static_dir.exists():
127
129
  self.app.mount("/static", StaticFiles(directory=str(static_dir)), name="static")
128
130
 
131
+ # Mount images directory for camera captures (read-only access)
132
+ if daemon and hasattr(daemon, "settings"):
133
+ images_dir = daemon.settings.get_images_dir()
134
+ if images_dir.exists():
135
+ self.app.mount("/images", StaticFiles(directory=str(images_dir)), name="images")
136
+
137
+ # Initialize Jinja2 templates
138
+ templates_dir = Path(__file__).parent / "templates"
139
+ self.templates = Jinja2Templates(directory=str(templates_dir))
140
+
129
141
  # Register routes
130
142
  self._setup_routes()
131
143
 
@@ -137,15 +149,9 @@ class CitraScopeWebApp:
137
149
  """Setup all API routes."""
138
150
 
139
151
  @self.app.get("/", response_class=HTMLResponse)
140
- async def root():
152
+ async def root(request: Request):
141
153
  """Serve the main dashboard page."""
142
- template_path = Path(__file__).parent / "templates" / "dashboard.html"
143
- if template_path.exists():
144
- return template_path.read_text()
145
- else:
146
- return HTMLResponse(
147
- content="<h1>CitraScope Dashboard</h1><p>Template file not found</p>", status_code=500
148
- )
154
+ return self.templates.TemplateResponse("dashboard.html", {"request": request})
149
155
 
150
156
  @self.app.get("/api/status")
151
157
  async def get_status():
@@ -185,6 +191,8 @@ class CitraScopeWebApp:
185
191
  "adapter_settings": settings._all_adapter_settings,
186
192
  "log_level": settings.log_level,
187
193
  "keep_images": settings.keep_images,
194
+ "file_logging_enabled": settings.file_logging_enabled,
195
+ "log_retention_days": settings.log_retention_days,
188
196
  "max_task_retries": settings.max_task_retries,
189
197
  "initial_retry_delay_seconds": settings.initial_retry_delay_seconds,
190
198
  "max_retry_delay_seconds": settings.max_retry_delay_seconds,
@@ -633,6 +641,12 @@ class CitraScopeWebApp:
633
641
  if not self.daemon.hardware_adapter:
634
642
  return JSONResponse({"error": "Hardware adapter not available"}, status_code=503)
635
643
 
644
+ # Check if adapter supports direct camera control
645
+ if not self.daemon.hardware_adapter.supports_direct_camera_control():
646
+ return JSONResponse(
647
+ {"error": "Hardware adapter does not support direct camera control"}, status_code=400
648
+ )
649
+
636
650
  try:
637
651
  duration = request.get("duration", 0.1)
638
652
 
@@ -713,6 +727,14 @@ class CitraScopeWebApp:
713
727
  except Exception:
714
728
  self.status.camera_connected = False
715
729
 
730
+ # Check adapter capabilities
731
+ try:
732
+ self.status.supports_direct_camera_control = (
733
+ self.daemon.hardware_adapter.supports_direct_camera_control()
734
+ )
735
+ except Exception:
736
+ self.status.supports_direct_camera_control = False
737
+
716
738
  if hasattr(self.daemon, "task_manager") and self.daemon.task_manager:
717
739
  task_manager = self.daemon.task_manager
718
740
  self.status.current_task = task_manager.current_task_id