citrascope 0.8.0__py3-none-any.whl → 0.9.1__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.
- citrascope/hardware/abstract_astro_hardware_adapter.py +17 -0
- citrascope/hardware/adapter_registry.py +5 -0
- citrascope/hardware/devices/camera/abstract_camera.py +12 -0
- citrascope/hardware/devices/camera/usb_camera.py +20 -15
- citrascope/hardware/devices/camera/ximea_camera.py +22 -10
- citrascope/hardware/direct_hardware_adapter.py +21 -3
- citrascope/hardware/dummy_adapter.py +202 -0
- citrascope/time/__init__.py +2 -1
- citrascope/time/time_health.py +8 -1
- citrascope/time/time_monitor.py +27 -5
- citrascope/time/time_sources.py +199 -0
- citrascope/web/app.py +31 -9
- citrascope/web/static/app.js +118 -988
- citrascope/web/static/components.js +136 -0
- citrascope/web/static/config.js +212 -508
- citrascope/web/static/formatters.js +129 -0
- citrascope/web/static/store-init.js +216 -0
- citrascope/web/static/style.css +5 -0
- citrascope/web/templates/_config.html +175 -0
- citrascope/web/templates/_config_hardware.html +208 -0
- citrascope/web/templates/_monitoring.html +242 -0
- citrascope/web/templates/dashboard.html +72 -444
- {citrascope-0.8.0.dist-info → citrascope-0.9.1.dist-info}/METADATA +3 -2
- {citrascope-0.8.0.dist-info → citrascope-0.9.1.dist-info}/RECORD +27 -20
- {citrascope-0.8.0.dist-info → citrascope-0.9.1.dist-info}/WHEEL +0 -0
- {citrascope-0.8.0.dist-info → citrascope-0.9.1.dist-info}/entry_points.txt +0 -0
- {citrascope-0.8.0.dist-info → citrascope-0.9.1.dist-info}/licenses/LICENSE +0 -0
citrascope/time/time_sources.py
CHANGED
|
@@ -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
|
-
|
|
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
|