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.
Files changed (51) hide show
  1. citrascope/api/abstract_api_client.py +14 -0
  2. citrascope/api/citra_api_client.py +41 -0
  3. citrascope/citra_scope_daemon.py +75 -0
  4. citrascope/hardware/abstract_astro_hardware_adapter.py +97 -2
  5. citrascope/hardware/adapter_registry.py +15 -3
  6. citrascope/hardware/devices/__init__.py +17 -0
  7. citrascope/hardware/devices/abstract_hardware_device.py +79 -0
  8. citrascope/hardware/devices/camera/__init__.py +13 -0
  9. citrascope/hardware/devices/camera/abstract_camera.py +114 -0
  10. citrascope/hardware/devices/camera/rpi_hq_camera.py +353 -0
  11. citrascope/hardware/devices/camera/usb_camera.py +407 -0
  12. citrascope/hardware/devices/camera/ximea_camera.py +756 -0
  13. citrascope/hardware/devices/device_registry.py +273 -0
  14. citrascope/hardware/devices/filter_wheel/__init__.py +7 -0
  15. citrascope/hardware/devices/filter_wheel/abstract_filter_wheel.py +73 -0
  16. citrascope/hardware/devices/focuser/__init__.py +7 -0
  17. citrascope/hardware/devices/focuser/abstract_focuser.py +78 -0
  18. citrascope/hardware/devices/mount/__init__.py +7 -0
  19. citrascope/hardware/devices/mount/abstract_mount.py +115 -0
  20. citrascope/hardware/direct_hardware_adapter.py +805 -0
  21. citrascope/hardware/dummy_adapter.py +202 -0
  22. citrascope/hardware/filter_sync.py +94 -0
  23. citrascope/hardware/indi_adapter.py +6 -2
  24. citrascope/hardware/kstars_dbus_adapter.py +46 -37
  25. citrascope/hardware/nina_adv_http_adapter.py +13 -11
  26. citrascope/settings/citrascope_settings.py +6 -0
  27. citrascope/tasks/runner.py +2 -0
  28. citrascope/tasks/scope/static_telescope_task.py +17 -12
  29. citrascope/tasks/task.py +3 -0
  30. citrascope/time/__init__.py +14 -0
  31. citrascope/time/time_health.py +103 -0
  32. citrascope/time/time_monitor.py +186 -0
  33. citrascope/time/time_sources.py +261 -0
  34. citrascope/web/app.py +260 -60
  35. citrascope/web/static/app.js +121 -731
  36. citrascope/web/static/components.js +136 -0
  37. citrascope/web/static/config.js +259 -420
  38. citrascope/web/static/filters.js +55 -0
  39. citrascope/web/static/formatters.js +129 -0
  40. citrascope/web/static/store-init.js +204 -0
  41. citrascope/web/static/style.css +44 -0
  42. citrascope/web/templates/_config.html +175 -0
  43. citrascope/web/templates/_config_hardware.html +208 -0
  44. citrascope/web/templates/_monitoring.html +242 -0
  45. citrascope/web/templates/dashboard.html +109 -377
  46. {citrascope-0.7.0.dist-info → citrascope-0.9.0.dist-info}/METADATA +18 -1
  47. citrascope-0.9.0.dist-info/RECORD +69 -0
  48. citrascope-0.7.0.dist-info/RECORD +0 -41
  49. {citrascope-0.7.0.dist-info → citrascope-0.9.0.dist-info}/WHEEL +0 -0
  50. {citrascope-0.7.0.dist-info → citrascope-0.9.0.dist-info}/entry_points.txt +0 -0
  51. {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:
@@ -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
- if self.hardware_adapter.get_observation_strategy() == ObservationStrategy.MANUAL:
16
- self.point_to_lead_position(satellite_data)
17
- filepaths = self.hardware_adapter.take_image(self.task.id, 2.0) # 2 second exposure
18
-
19
- if self.hardware_adapter.get_observation_strategy() == ObservationStrategy.SEQUENCE_TO_CONTROLLER:
20
- # Calculate current satellite position and add to satellite_data
21
- target_ra, target_dec, _, _ = self.get_target_radec_and_rates(satellite_data)
22
- satellite_data["ra"] = target_ra.degrees
23
- satellite_data["dec"] = target_dec.degrees
24
-
25
- # Sequence-based adapters handle pointing and tracking themselves
26
- filepaths = self.hardware_adapter.perform_observation_sequence(self.task.id, satellite_data)
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