citrascope 0.7.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.
- citrascope/api/abstract_api_client.py +14 -0
- citrascope/api/citra_api_client.py +41 -0
- citrascope/citra_scope_daemon.py +75 -0
- citrascope/hardware/abstract_astro_hardware_adapter.py +97 -2
- citrascope/hardware/adapter_registry.py +15 -3
- citrascope/hardware/devices/__init__.py +17 -0
- citrascope/hardware/devices/abstract_hardware_device.py +79 -0
- citrascope/hardware/devices/camera/__init__.py +13 -0
- citrascope/hardware/devices/camera/abstract_camera.py +114 -0
- citrascope/hardware/devices/camera/rpi_hq_camera.py +353 -0
- citrascope/hardware/devices/camera/usb_camera.py +407 -0
- citrascope/hardware/devices/camera/ximea_camera.py +756 -0
- citrascope/hardware/devices/device_registry.py +273 -0
- citrascope/hardware/devices/filter_wheel/__init__.py +7 -0
- citrascope/hardware/devices/filter_wheel/abstract_filter_wheel.py +73 -0
- citrascope/hardware/devices/focuser/__init__.py +7 -0
- citrascope/hardware/devices/focuser/abstract_focuser.py +78 -0
- citrascope/hardware/devices/mount/__init__.py +7 -0
- citrascope/hardware/devices/mount/abstract_mount.py +115 -0
- citrascope/hardware/direct_hardware_adapter.py +805 -0
- citrascope/hardware/dummy_adapter.py +202 -0
- citrascope/hardware/filter_sync.py +94 -0
- citrascope/hardware/indi_adapter.py +6 -2
- citrascope/hardware/kstars_dbus_adapter.py +46 -37
- citrascope/hardware/nina_adv_http_adapter.py +13 -11
- citrascope/settings/citrascope_settings.py +6 -0
- citrascope/tasks/runner.py +2 -0
- citrascope/tasks/scope/static_telescope_task.py +17 -12
- citrascope/tasks/task.py +3 -0
- citrascope/time/__init__.py +14 -0
- citrascope/time/time_health.py +103 -0
- citrascope/time/time_monitor.py +186 -0
- citrascope/time/time_sources.py +261 -0
- citrascope/web/app.py +260 -60
- citrascope/web/static/app.js +121 -731
- citrascope/web/static/components.js +136 -0
- citrascope/web/static/config.js +259 -420
- citrascope/web/static/filters.js +55 -0
- citrascope/web/static/formatters.js +129 -0
- citrascope/web/static/store-init.js +204 -0
- citrascope/web/static/style.css +44 -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 +109 -377
- {citrascope-0.7.0.dist-info → citrascope-0.9.0.dist-info}/METADATA +18 -1
- citrascope-0.9.0.dist-info/RECORD +69 -0
- citrascope-0.7.0.dist-info/RECORD +0 -41
- {citrascope-0.7.0.dist-info → citrascope-0.9.0.dist-info}/WHEEL +0 -0
- {citrascope-0.7.0.dist-info → citrascope-0.9.0.dist-info}/entry_points.txt +0 -0
- {citrascope-0.7.0.dist-info → citrascope-0.9.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -81,6 +81,10 @@ class CitraScopeSettings:
|
|
|
81
81
|
)
|
|
82
82
|
self.autofocus_interval_minutes = 60
|
|
83
83
|
|
|
84
|
+
# Time synchronization monitoring configuration (always enabled)
|
|
85
|
+
self.time_check_interval_minutes: int = config.get("time_check_interval_minutes", 5)
|
|
86
|
+
self.time_offset_pause_ms: float = config.get("time_offset_pause_ms", 500.0)
|
|
87
|
+
|
|
84
88
|
def get_images_dir(self) -> Path:
|
|
85
89
|
"""Get the path to the images directory.
|
|
86
90
|
|
|
@@ -126,6 +130,8 @@ class CitraScopeSettings:
|
|
|
126
130
|
"scheduled_autofocus_enabled": self.scheduled_autofocus_enabled,
|
|
127
131
|
"autofocus_interval_minutes": self.autofocus_interval_minutes,
|
|
128
132
|
"last_autofocus_timestamp": self.last_autofocus_timestamp,
|
|
133
|
+
"time_check_interval_minutes": self.time_check_interval_minutes,
|
|
134
|
+
"time_offset_pause_ms": self.time_offset_pause_ms,
|
|
129
135
|
}
|
|
130
136
|
|
|
131
137
|
def save(self) -> None:
|
citrascope/tasks/runner.py
CHANGED
|
@@ -46,6 +46,8 @@ class TaskManager:
|
|
|
46
46
|
# Autofocus request flag (set by manual or scheduled triggers)
|
|
47
47
|
self._autofocus_requested = False
|
|
48
48
|
self._autofocus_lock = threading.Lock()
|
|
49
|
+
# Automated scheduling state (initialized from server on startup)
|
|
50
|
+
self._automated_scheduling = telescope_record.get("automatedScheduling", False) if telescope_record else False
|
|
49
51
|
|
|
50
52
|
def poll_tasks(self):
|
|
51
53
|
while not self._stop_event.is_set():
|
|
@@ -12,18 +12,23 @@ class StaticTelescopeTask(AbstractBaseTelescopeTask):
|
|
|
12
12
|
raise ValueError("Could not fetch valid satellite data or TLE.")
|
|
13
13
|
|
|
14
14
|
filepath = None
|
|
15
|
-
|
|
16
|
-
self.
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
15
|
+
try:
|
|
16
|
+
if self.hardware_adapter.get_observation_strategy() == ObservationStrategy.MANUAL:
|
|
17
|
+
self.point_to_lead_position(satellite_data)
|
|
18
|
+
filepaths = self.hardware_adapter.take_image(self.task.id, 2.0) # 2 second exposure
|
|
19
|
+
|
|
20
|
+
if self.hardware_adapter.get_observation_strategy() == ObservationStrategy.SEQUENCE_TO_CONTROLLER:
|
|
21
|
+
# Calculate current satellite position and add to satellite_data
|
|
22
|
+
target_ra, target_dec, _, _ = self.get_target_radec_and_rates(satellite_data)
|
|
23
|
+
satellite_data["ra"] = target_ra.degrees
|
|
24
|
+
satellite_data["dec"] = target_dec.degrees
|
|
25
|
+
|
|
26
|
+
# Sequence-based adapters handle pointing and tracking themselves
|
|
27
|
+
filepaths = self.hardware_adapter.perform_observation_sequence(self.task, satellite_data)
|
|
28
|
+
except RuntimeError as e:
|
|
29
|
+
# Filter errors and other hardware errors
|
|
30
|
+
self.logger.error(f"Observation failed for task {self.task.id}: {e}")
|
|
31
|
+
raise
|
|
27
32
|
|
|
28
33
|
# Take the image
|
|
29
34
|
return self.upload_image_and_mark_complete(filepaths)
|
citrascope/tasks/task.py
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
from dataclasses import dataclass
|
|
2
|
+
from typing import Optional
|
|
2
3
|
|
|
3
4
|
|
|
4
5
|
@dataclass
|
|
@@ -18,6 +19,7 @@ class Task:
|
|
|
18
19
|
telescopeName: str
|
|
19
20
|
groundStationId: str
|
|
20
21
|
groundStationName: str
|
|
22
|
+
assigned_filter_name: Optional[str] = None
|
|
21
23
|
|
|
22
24
|
@classmethod
|
|
23
25
|
def from_dict(cls, data: dict) -> "Task":
|
|
@@ -37,6 +39,7 @@ class Task:
|
|
|
37
39
|
telescopeName=data.get("telescopeName", ""),
|
|
38
40
|
groundStationId=data.get("groundStationId", ""),
|
|
39
41
|
groundStationName=data.get("groundStationName", ""),
|
|
42
|
+
assigned_filter_name=data.get("assigned_filter_name"),
|
|
40
43
|
)
|
|
41
44
|
|
|
42
45
|
def __repr__(self):
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Time synchronization monitoring for CitraScope."""
|
|
2
|
+
|
|
3
|
+
from citrascope.time.time_health import TimeHealth, TimeStatus
|
|
4
|
+
from citrascope.time.time_monitor import TimeMonitor
|
|
5
|
+
from citrascope.time.time_sources import AbstractTimeSource, ChronyTimeSource, NTPTimeSource
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"TimeHealth",
|
|
9
|
+
"TimeStatus",
|
|
10
|
+
"TimeMonitor",
|
|
11
|
+
"AbstractTimeSource",
|
|
12
|
+
"ChronyTimeSource",
|
|
13
|
+
"NTPTimeSource",
|
|
14
|
+
]
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Time health status calculation and monitoring."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from enum import Enum
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class TimeStatus(str, Enum):
|
|
9
|
+
"""Time synchronization status levels."""
|
|
10
|
+
|
|
11
|
+
OK = "ok"
|
|
12
|
+
CRITICAL = "critical"
|
|
13
|
+
UNKNOWN = "unknown"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class TimeHealth:
|
|
18
|
+
"""Time synchronization health status."""
|
|
19
|
+
|
|
20
|
+
offset_ms: Optional[float]
|
|
21
|
+
"""Clock offset in milliseconds (positive = system clock ahead)."""
|
|
22
|
+
|
|
23
|
+
status: TimeStatus
|
|
24
|
+
"""Current time sync status level."""
|
|
25
|
+
|
|
26
|
+
source: str
|
|
27
|
+
"""Time source used (ntp, gps, chrony, unknown)."""
|
|
28
|
+
|
|
29
|
+
message: Optional[str] = None
|
|
30
|
+
"""Optional status message or error description."""
|
|
31
|
+
|
|
32
|
+
metadata: Optional[dict] = None
|
|
33
|
+
"""Optional metadata (e.g., GPS satellite count, fix mode)."""
|
|
34
|
+
|
|
35
|
+
@staticmethod
|
|
36
|
+
def calculate_status(
|
|
37
|
+
offset_ms: Optional[float],
|
|
38
|
+
pause_threshold: float,
|
|
39
|
+
) -> TimeStatus:
|
|
40
|
+
"""
|
|
41
|
+
Calculate time status based on offset and pause threshold.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
offset_ms: Clock offset in milliseconds (None if check failed)
|
|
45
|
+
pause_threshold: Threshold in milliseconds that triggers task pause
|
|
46
|
+
|
|
47
|
+
Returns:
|
|
48
|
+
TimeStatus level (OK, CRITICAL, or UNKNOWN)
|
|
49
|
+
"""
|
|
50
|
+
if offset_ms is None:
|
|
51
|
+
return TimeStatus.UNKNOWN
|
|
52
|
+
|
|
53
|
+
abs_offset = abs(offset_ms)
|
|
54
|
+
|
|
55
|
+
if abs_offset < pause_threshold:
|
|
56
|
+
return TimeStatus.OK
|
|
57
|
+
else:
|
|
58
|
+
return TimeStatus.CRITICAL
|
|
59
|
+
|
|
60
|
+
@classmethod
|
|
61
|
+
def from_offset(
|
|
62
|
+
cls,
|
|
63
|
+
offset_ms: Optional[float],
|
|
64
|
+
source: str,
|
|
65
|
+
pause_threshold: float,
|
|
66
|
+
message: Optional[str] = None,
|
|
67
|
+
metadata: Optional[dict] = None,
|
|
68
|
+
) -> "TimeHealth":
|
|
69
|
+
"""
|
|
70
|
+
Create TimeHealth from offset and pause threshold.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
offset_ms: Clock offset in milliseconds
|
|
74
|
+
source: Time source identifier
|
|
75
|
+
pause_threshold: Threshold that triggers task pause
|
|
76
|
+
message: Optional status message
|
|
77
|
+
metadata: Optional metadata dict
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
TimeHealth instance
|
|
81
|
+
"""
|
|
82
|
+
status = cls.calculate_status(offset_ms, pause_threshold)
|
|
83
|
+
return cls(
|
|
84
|
+
offset_ms=offset_ms,
|
|
85
|
+
status=status,
|
|
86
|
+
source=source,
|
|
87
|
+
message=message,
|
|
88
|
+
metadata=metadata,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
def should_pause_observations(self) -> bool:
|
|
92
|
+
"""Check if observations should be paused due to time sync issues."""
|
|
93
|
+
return self.status == TimeStatus.CRITICAL
|
|
94
|
+
|
|
95
|
+
def to_dict(self) -> dict:
|
|
96
|
+
"""Convert to dictionary for JSON serialization."""
|
|
97
|
+
return {
|
|
98
|
+
"offset_ms": self.offset_ms,
|
|
99
|
+
"status": self.status.value,
|
|
100
|
+
"source": self.source,
|
|
101
|
+
"message": self.message,
|
|
102
|
+
"metadata": self.metadata,
|
|
103
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""Time synchronization monitoring thread for CitraScope."""
|
|
2
|
+
|
|
3
|
+
import threading
|
|
4
|
+
import time
|
|
5
|
+
from typing import Callable, Optional
|
|
6
|
+
|
|
7
|
+
from citrascope.logging import CITRASCOPE_LOGGER
|
|
8
|
+
from citrascope.time.time_health import TimeHealth, TimeStatus
|
|
9
|
+
from citrascope.time.time_sources import AbstractTimeSource, ChronyTimeSource, NTPTimeSource
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class TimeMonitor:
|
|
13
|
+
"""
|
|
14
|
+
Background thread that monitors system clock synchronization.
|
|
15
|
+
|
|
16
|
+
Periodically checks clock offset against NTP servers,
|
|
17
|
+
logs warnings/errors based on drift severity, and notifies callback
|
|
18
|
+
when critical drift requires pausing observations.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
check_interval_minutes: int = 5,
|
|
24
|
+
pause_threshold_ms: float = 500.0,
|
|
25
|
+
pause_callback: Optional[Callable[[TimeHealth], None]] = None,
|
|
26
|
+
):
|
|
27
|
+
"""
|
|
28
|
+
Initialize time monitor.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
check_interval_minutes: Minutes between time sync checks
|
|
32
|
+
pause_threshold_ms: Threshold in ms that triggers task pause
|
|
33
|
+
pause_callback: Callback function when threshold exceeded
|
|
34
|
+
"""
|
|
35
|
+
self.check_interval_minutes = check_interval_minutes
|
|
36
|
+
self.pause_threshold_ms = pause_threshold_ms
|
|
37
|
+
self.pause_callback = pause_callback
|
|
38
|
+
|
|
39
|
+
# Detect and initialize best available time source
|
|
40
|
+
self.time_source: AbstractTimeSource = self._detect_best_source()
|
|
41
|
+
|
|
42
|
+
# Thread control
|
|
43
|
+
self._stop_event = threading.Event()
|
|
44
|
+
self._thread: Optional[threading.Thread] = None
|
|
45
|
+
self._lock = threading.Lock()
|
|
46
|
+
|
|
47
|
+
# Current health status
|
|
48
|
+
self._current_health: Optional[TimeHealth] = None
|
|
49
|
+
self._last_critical_notification = 0.0
|
|
50
|
+
|
|
51
|
+
def start(self) -> None:
|
|
52
|
+
"""Start the time monitoring thread."""
|
|
53
|
+
if self._thread is not None and self._thread.is_alive():
|
|
54
|
+
CITRASCOPE_LOGGER.warning("Time monitor already running")
|
|
55
|
+
return
|
|
56
|
+
|
|
57
|
+
self._stop_event.clear()
|
|
58
|
+
self._thread = threading.Thread(target=self._monitor_loop, daemon=True)
|
|
59
|
+
self._thread.start()
|
|
60
|
+
CITRASCOPE_LOGGER.info(f"Time monitor started (check interval: {self.check_interval_minutes} minutes)")
|
|
61
|
+
|
|
62
|
+
def stop(self) -> None:
|
|
63
|
+
"""Stop the time monitoring thread."""
|
|
64
|
+
if self._thread is None:
|
|
65
|
+
return
|
|
66
|
+
|
|
67
|
+
CITRASCOPE_LOGGER.info("Stopping time monitor...")
|
|
68
|
+
self._stop_event.set()
|
|
69
|
+
self._thread.join(timeout=5.0)
|
|
70
|
+
self._thread = None
|
|
71
|
+
CITRASCOPE_LOGGER.info("Time monitor stopped")
|
|
72
|
+
|
|
73
|
+
def get_current_health(self) -> Optional[TimeHealth]:
|
|
74
|
+
"""Get the current time health status (thread-safe)."""
|
|
75
|
+
with self._lock:
|
|
76
|
+
return self._current_health
|
|
77
|
+
|
|
78
|
+
def _detect_best_source(self) -> AbstractTimeSource:
|
|
79
|
+
"""
|
|
80
|
+
Detect and return the best available time source.
|
|
81
|
+
|
|
82
|
+
Priority order: Chrony > NTP
|
|
83
|
+
|
|
84
|
+
Returns:
|
|
85
|
+
The best available time source instance.
|
|
86
|
+
"""
|
|
87
|
+
# Try ChronyTimeSource
|
|
88
|
+
chrony_source = ChronyTimeSource()
|
|
89
|
+
if chrony_source.is_available():
|
|
90
|
+
CITRASCOPE_LOGGER.info("Time monitor initialized with Chrony source")
|
|
91
|
+
return chrony_source
|
|
92
|
+
|
|
93
|
+
# Fall back to NTP
|
|
94
|
+
CITRASCOPE_LOGGER.info("Time monitor initialized with NTP source")
|
|
95
|
+
return NTPTimeSource()
|
|
96
|
+
|
|
97
|
+
def _monitor_loop(self) -> None:
|
|
98
|
+
"""Main monitoring loop (runs in background thread)."""
|
|
99
|
+
# Perform initial check immediately
|
|
100
|
+
self._check_time_sync()
|
|
101
|
+
|
|
102
|
+
# Then check periodically
|
|
103
|
+
interval_seconds = self.check_interval_minutes * 60
|
|
104
|
+
|
|
105
|
+
while not self._stop_event.is_set():
|
|
106
|
+
# Wait for interval or stop signal
|
|
107
|
+
if self._stop_event.wait(timeout=interval_seconds):
|
|
108
|
+
break
|
|
109
|
+
|
|
110
|
+
self._check_time_sync()
|
|
111
|
+
|
|
112
|
+
def _check_time_sync(self) -> None:
|
|
113
|
+
"""Perform a single time synchronization check."""
|
|
114
|
+
try:
|
|
115
|
+
# Query time source for offset
|
|
116
|
+
offset_ms = self.time_source.get_offset_ms()
|
|
117
|
+
|
|
118
|
+
# Get metadata if available (e.g., GPS satellite info)
|
|
119
|
+
metadata = self.time_source.get_metadata()
|
|
120
|
+
|
|
121
|
+
# Calculate health status
|
|
122
|
+
health = TimeHealth.from_offset(
|
|
123
|
+
offset_ms=offset_ms,
|
|
124
|
+
source=self.time_source.get_source_name(),
|
|
125
|
+
pause_threshold=self.pause_threshold_ms,
|
|
126
|
+
metadata=metadata,
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
# Store current health (thread-safe)
|
|
130
|
+
with self._lock:
|
|
131
|
+
self._current_health = health
|
|
132
|
+
|
|
133
|
+
# Log based on status
|
|
134
|
+
self._log_health_status(health)
|
|
135
|
+
|
|
136
|
+
# Notify callback if critical
|
|
137
|
+
if health.should_pause_observations():
|
|
138
|
+
self._handle_critical_drift(health)
|
|
139
|
+
|
|
140
|
+
except Exception as e:
|
|
141
|
+
CITRASCOPE_LOGGER.error(f"Time sync check failed: {e}", exc_info=True)
|
|
142
|
+
# Create unknown status on error
|
|
143
|
+
health = TimeHealth.from_offset(
|
|
144
|
+
offset_ms=None,
|
|
145
|
+
source="unknown",
|
|
146
|
+
pause_threshold=self.pause_threshold_ms,
|
|
147
|
+
message=f"Check failed: {e}",
|
|
148
|
+
)
|
|
149
|
+
with self._lock:
|
|
150
|
+
self._current_health = health
|
|
151
|
+
|
|
152
|
+
def _log_health_status(self, health: TimeHealth) -> None:
|
|
153
|
+
"""Log time health status at appropriate level."""
|
|
154
|
+
if health.offset_ms is None:
|
|
155
|
+
CITRASCOPE_LOGGER.warning("Time sync check failed - offset unknown")
|
|
156
|
+
return
|
|
157
|
+
|
|
158
|
+
offset_str = f"{health.offset_ms:+.1f}ms"
|
|
159
|
+
|
|
160
|
+
if health.status == TimeStatus.OK:
|
|
161
|
+
CITRASCOPE_LOGGER.info(f"Time sync OK: {offset_str}")
|
|
162
|
+
elif health.status == TimeStatus.CRITICAL:
|
|
163
|
+
CITRASCOPE_LOGGER.critical(
|
|
164
|
+
f"CRITICAL time drift: offset {offset_str} exceeds {self.pause_threshold_ms}ms threshold. "
|
|
165
|
+
"Task processing will be paused."
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
def _handle_critical_drift(self, health: TimeHealth) -> None:
|
|
169
|
+
"""
|
|
170
|
+
Handle critical time drift by notifying callback.
|
|
171
|
+
|
|
172
|
+
Args:
|
|
173
|
+
health: Current time health status
|
|
174
|
+
"""
|
|
175
|
+
# Rate-limit notifications (max once per 5 minutes)
|
|
176
|
+
now = time.time()
|
|
177
|
+
if now - self._last_critical_notification < 300:
|
|
178
|
+
return
|
|
179
|
+
|
|
180
|
+
self._last_critical_notification = now
|
|
181
|
+
|
|
182
|
+
if self.pause_callback is not None:
|
|
183
|
+
try:
|
|
184
|
+
self.pause_callback(health)
|
|
185
|
+
except Exception as e:
|
|
186
|
+
CITRASCOPE_LOGGER.error(f"Pause callback failed: {e}", exc_info=True)
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
"""Time source implementations for CitraScope."""
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
import time
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
import ntplib
|
|
9
|
+
|
|
10
|
+
from citrascope.logging import CITRASCOPE_LOGGER
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class AbstractTimeSource(ABC):
|
|
14
|
+
"""Abstract base class for time sources."""
|
|
15
|
+
|
|
16
|
+
@abstractmethod
|
|
17
|
+
def get_offset_ms(self) -> Optional[float]:
|
|
18
|
+
"""
|
|
19
|
+
Get the clock offset in milliseconds.
|
|
20
|
+
|
|
21
|
+
Returns:
|
|
22
|
+
Clock offset in milliseconds (positive = system ahead), or None if unavailable.
|
|
23
|
+
"""
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
@abstractmethod
|
|
27
|
+
def get_source_name(self) -> str:
|
|
28
|
+
"""Get the name of this time source."""
|
|
29
|
+
pass
|
|
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
|
+
|
|
40
|
+
|
|
41
|
+
class NTPTimeSource(AbstractTimeSource):
|
|
42
|
+
"""NTP-based time source using pool.ntp.org."""
|
|
43
|
+
|
|
44
|
+
def __init__(self, ntp_server: str = "pool.ntp.org", timeout: int = 5):
|
|
45
|
+
"""
|
|
46
|
+
Initialize NTP time source.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
ntp_server: NTP server hostname (default: pool.ntp.org)
|
|
50
|
+
timeout: Query timeout in seconds
|
|
51
|
+
"""
|
|
52
|
+
self.ntp_server = ntp_server
|
|
53
|
+
self.timeout = timeout
|
|
54
|
+
self.client = ntplib.NTPClient()
|
|
55
|
+
|
|
56
|
+
def get_offset_ms(self) -> Optional[float]:
|
|
57
|
+
"""
|
|
58
|
+
Query NTP server for clock offset.
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
Clock offset in milliseconds, or None if query fails.
|
|
62
|
+
"""
|
|
63
|
+
try:
|
|
64
|
+
response = self.client.request(self.ntp_server, version=3, timeout=self.timeout)
|
|
65
|
+
# NTP offset is in seconds, convert to milliseconds
|
|
66
|
+
offset_ms = response.offset * 1000.0
|
|
67
|
+
return offset_ms
|
|
68
|
+
except Exception:
|
|
69
|
+
# Query failed - network issue, timeout, etc.
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
def get_source_name(self) -> str:
|
|
73
|
+
"""Get the name of this time source."""
|
|
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
|