citrascope 0.5.2__py3-none-any.whl → 0.6.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.
@@ -1,7 +1,12 @@
1
- import base64
1
+ import json
2
2
  import logging
3
+ import shutil
3
4
  import time
4
5
  from pathlib import Path
6
+ from typing import Any, Dict, Optional
7
+
8
+ import dbus
9
+ from platformdirs import user_cache_dir, user_data_dir
5
10
 
6
11
  from citrascope.hardware.abstract_astro_hardware_adapter import (
7
12
  AbstractAstroHardwareAdapter,
@@ -11,7 +16,42 @@ from citrascope.hardware.abstract_astro_hardware_adapter import (
11
16
 
12
17
 
13
18
  class KStarsDBusAdapter(AbstractAstroHardwareAdapter):
14
- """Adapter for controlling astronomical equipment through KStars via DBus."""
19
+ """
20
+ Adapter for controlling astronomical equipment through KStars via DBus.
21
+
22
+ DBus Interface Documentation (from introspection):
23
+
24
+ Mount Interface (org.kde.kstars.Ekos.Mount):
25
+ Methods:
26
+ - slew(double RA, double DEC) -> bool: Slew telescope to coordinates
27
+ - sync(double RA, double DEC) -> bool: Sync telescope at coordinates
28
+ - abort() -> bool: Abort current slew
29
+ - park() -> bool: Park telescope
30
+ - unpark() -> bool: Unpark telescope
31
+
32
+ Properties:
33
+ - equatorialCoords (ad): Current RA/Dec as list of doubles [RA, Dec]
34
+ - slewStatus (i): Current slew status (0=idle, others=slewing)
35
+ - status (i): Mount status enumeration
36
+ - canPark (b): Whether mount supports parking
37
+
38
+ Scheduler Interface (org.kde.kstars.Ekos.Scheduler):
39
+ Methods:
40
+ - loadScheduler(string fileURL) -> bool: Load ESL scheduler file
41
+ - setSequence(string sequenceFileURL): Set sequence file (ESQ)
42
+ - start(): Start scheduler execution
43
+ - stop(): Stop scheduler
44
+ - removeAllJobs(): Clear all jobs
45
+ - resetAllJobs(): Reset job states
46
+
47
+ Properties:
48
+ - status (i): Scheduler state enumeration
49
+ - currentJobName (s): Name of currently executing job
50
+ - jsonJobs (s): JSON representation of all jobs
51
+
52
+ Signals:
53
+ - newStatus(int status): Emitted when scheduler state changes
54
+ """
15
55
 
16
56
  def __init__(self, logger: logging.Logger, images_dir: Path, **kwargs):
17
57
  """
@@ -20,17 +60,41 @@ class KStarsDBusAdapter(AbstractAstroHardwareAdapter):
20
60
  Args:
21
61
  logger: Logger instance for logging messages
22
62
  images_dir: Path to the images directory
23
- **kwargs: Configuration including bus_name
63
+ **kwargs: Configuration including bus_name, ccd_name, filter_wheel_name
24
64
  """
25
65
  super().__init__(images_dir=images_dir)
26
66
  self.logger: logging.Logger = logger
27
- self.bus_name = kwargs.get("bus_name", "org.kde.kstars")
28
- self.bus = None
29
- self.kstars = None
30
- self.ekos = None
31
- self.mount = None
32
- self.camera = None
33
- self.scheduler = None
67
+ self.bus_name = kwargs.get("bus_name") or "org.kde.kstars"
68
+ self.ccd_name = kwargs.get("ccd_name") or "CCD Simulator"
69
+ self.filter_wheel_name = kwargs.get("filter_wheel_name") or ""
70
+ self.optical_train_name = kwargs.get("optical_train_name") or "Primary"
71
+
72
+ # Capture parameters
73
+ self.exposure_time = kwargs.get("exposure_time", 5.0)
74
+ self.frame_count = kwargs.get("frame_count", 1)
75
+ self.binning_x = kwargs.get("binning_x", 1)
76
+ self.binning_y = kwargs.get("binning_y", 1)
77
+ self.image_format = kwargs.get("image_format", "Mono")
78
+
79
+ # Filter management
80
+ self.filter_map: Dict[int, Dict[str, Any]] = {}
81
+
82
+ # Pre-populate filter_map from saved settings (if any)
83
+ # This will be merged with discovered filters in discover_filters()
84
+ saved_filters = kwargs.get("filters", {})
85
+ for filter_id, filter_data in saved_filters.items():
86
+ # Convert string keys back to int for internal use
87
+ try:
88
+ self.filter_map[int(filter_id)] = filter_data
89
+ except (ValueError, TypeError) as e:
90
+ self.logger.warning(f"Invalid filter ID '{filter_id}' in settings, skipping: {e}")
91
+
92
+ self.bus: dbus.SessionBus | None = None
93
+ self.kstars: dbus.Interface | None = None
94
+ self.ekos: dbus.Interface | None = None
95
+ self.mount: dbus.Interface | None = None
96
+ self.camera: dbus.Interface | None = None
97
+ self.scheduler: dbus.Interface | None = None
34
98
 
35
99
  @classmethod
36
100
  def get_settings_schema(cls) -> list[SettingSchemaEntry]:
@@ -43,20 +107,599 @@ class KStarsDBusAdapter(AbstractAstroHardwareAdapter):
43
107
  "friendly_name": "D-Bus Service Name",
44
108
  "type": "str",
45
109
  "default": "org.kde.kstars",
46
- "description": "D-Bus service name for KStars",
47
- "required": True,
110
+ "description": "D-Bus service name for KStars (default: org.kde.kstars)",
111
+ "required": False,
48
112
  "placeholder": "org.kde.kstars",
49
- }
113
+ },
114
+ {
115
+ "name": "ccd_name",
116
+ "friendly_name": "Camera/CCD Device Name",
117
+ "type": "str",
118
+ "default": "CCD Simulator",
119
+ "description": "Name of the camera device in your Ekos profile (check Ekos logs on connect for available devices)",
120
+ "required": False,
121
+ "placeholder": "CCD Simulator",
122
+ },
123
+ {
124
+ "name": "filter_wheel_name",
125
+ "friendly_name": "Filter Wheel Device Name",
126
+ "type": "str",
127
+ "default": "",
128
+ "description": "Name of the filter wheel device (leave empty if no filter wheel)",
129
+ "required": False,
130
+ "placeholder": "Filter Simulator",
131
+ },
132
+ {
133
+ "name": "optical_train_name",
134
+ "friendly_name": "Optical Train Name",
135
+ "type": "str",
136
+ "default": "Primary",
137
+ "description": "Name of the optical train in your Ekos profile (check Ekos logs on connect for available trains)",
138
+ "required": False,
139
+ "placeholder": "Primary",
140
+ },
141
+ {
142
+ "name": "exposure_time",
143
+ "friendly_name": "Exposure Time (seconds)",
144
+ "type": "float",
145
+ "default": 1.0,
146
+ "description": "Exposure duration in seconds for each frame",
147
+ "required": False,
148
+ "placeholder": "1.0",
149
+ "min": 0.001,
150
+ "max": 300.0,
151
+ },
152
+ {
153
+ "name": "frame_count",
154
+ "friendly_name": "Frame Count",
155
+ "type": "int",
156
+ "default": 1,
157
+ "description": "Number of frames to capture per observation",
158
+ "required": False,
159
+ "placeholder": "1",
160
+ "min": 1,
161
+ "max": 100,
162
+ },
163
+ {
164
+ "name": "binning_x",
165
+ "friendly_name": "Binning X",
166
+ "type": "int",
167
+ "default": 1,
168
+ "description": "Horizontal pixel binning (1=no binning, 2=2x2, etc.)",
169
+ "required": False,
170
+ "placeholder": "1",
171
+ "min": 1,
172
+ "max": 4,
173
+ },
174
+ {
175
+ "name": "binning_y",
176
+ "friendly_name": "Binning Y",
177
+ "type": "int",
178
+ "default": 1,
179
+ "description": "Vertical pixel binning (1=no binning, 2=2x2, etc.)",
180
+ "required": False,
181
+ "placeholder": "1",
182
+ "min": 1,
183
+ "max": 4,
184
+ },
185
+ {
186
+ "name": "image_format",
187
+ "friendly_name": "Image Format",
188
+ "type": "str",
189
+ "default": "Mono",
190
+ "description": "Camera image format (Mono for monochrome, RGGB/RGB for color cameras)",
191
+ "required": False,
192
+ "placeholder": "Mono",
193
+ "options": ["Mono", "RGGB", "RGB"],
194
+ },
50
195
  ]
51
196
 
52
197
  def _do_point_telescope(self, ra: float, dec: float):
53
- raise NotImplementedError
198
+ """
199
+ Point the telescope to the specified RA/Dec coordinates.
200
+
201
+ Args:
202
+ ra: Right Ascension in degrees
203
+ dec: Declination in degrees
204
+
205
+ Raises:
206
+ RuntimeError: If mount is not connected or slew fails
207
+ """
208
+ if not self.mount:
209
+ raise RuntimeError("Mount interface not connected. Call connect() first.")
210
+
211
+ try:
212
+ # Convert RA from degrees to hours for KStars (KStars expects RA in hours)
213
+ ra_hours = ra / 15.0
214
+
215
+ self.logger.info(f"Slewing telescope to RA={ra_hours:.4f}h ({ra:.4f}°), Dec={dec:.4f}°")
216
+
217
+ # Call the slew method via DBus
218
+ success = self.mount.slew(ra_hours, dec)
219
+
220
+ if not success:
221
+ raise RuntimeError(f"Mount slew command failed for RA={ra_hours}h, Dec={dec}°")
222
+
223
+ self.logger.info("Slew command sent successfully")
224
+
225
+ except Exception as e:
226
+ self.logger.error(f"Failed to slew telescope: {e}")
227
+ raise RuntimeError(f"Telescope slew failed: {e}")
54
228
 
55
229
  def get_observation_strategy(self) -> ObservationStrategy:
56
230
  return ObservationStrategy.SEQUENCE_TO_CONTROLLER
57
231
 
58
- def perform_observation_sequence(self, task_id, satellite_data) -> str:
59
- raise NotImplementedError
232
+ def _load_template(self, template_name: str) -> str:
233
+ """Load a template file from the hardware directory."""
234
+ template_path = Path(__file__).parent / template_name
235
+ if not template_path.exists():
236
+ raise FileNotFoundError(f"Template not found: {template_path}")
237
+ return template_path.read_text()
238
+
239
+ def _create_sequence_file(self, task_id: str, satellite_data: dict, output_dir: Path) -> Path:
240
+ """
241
+ Create an ESQ sequence file from template.
242
+
243
+ Args:
244
+ task_id: Unique task identifier
245
+ satellite_data: Dictionary containing target information
246
+ output_dir: Base output directory for captures
247
+
248
+ Returns:
249
+ Path to the created sequence file
250
+ """
251
+ template = self._load_template("kstars_sequence_template.esq")
252
+
253
+ # Extract target info
254
+ target_name = satellite_data.get("name", "Unknown").replace(" ", "_")
255
+
256
+ # Generate job blocks based on filter configuration
257
+ jobs_xml = self._generate_job_blocks(output_dir)
258
+
259
+ # Replace placeholders
260
+ sequence_content = template.replace("{{JOBS}}", jobs_xml)
261
+ sequence_content = sequence_content.replace("{{OUTPUT_DIR}}", str(output_dir))
262
+ sequence_content = sequence_content.replace("{{TASK_ID}}", task_id)
263
+ sequence_content = sequence_content.replace("{{TARGET_NAME}}", target_name)
264
+ sequence_content = sequence_content.replace("{{CCD_NAME}}", self.ccd_name)
265
+ sequence_content = sequence_content.replace("{{FILTER_WHEEL_NAME}}", self.filter_wheel_name)
266
+ sequence_content = sequence_content.replace("{{OPTICAL_TRAIN}}", self.optical_train_name)
267
+
268
+ # Write to temporary file
269
+ temp_dir = Path(user_cache_dir("citrascope")) / "kstars"
270
+ temp_dir.mkdir(exist_ok=True, parents=True)
271
+ sequence_file = temp_dir / f"{task_id}_sequence.esq"
272
+ sequence_file.write_text(sequence_content)
273
+
274
+ self.logger.info(f"Created sequence file: {sequence_file}")
275
+ return sequence_file
276
+
277
+ def _generate_job_blocks(self, output_dir: Path) -> str:
278
+ """
279
+ Generate XML job blocks for each filter in filter_map.
280
+ If no filters discovered, generates single job with no filter.
281
+
282
+ Args:
283
+ output_dir: Base output directory for captures
284
+
285
+ Returns:
286
+ XML string containing one or more <Job> blocks
287
+ """
288
+ job_template = """ <Job>
289
+ <Exposure>{exposure}</Exposure>
290
+ <Format>{format}</Format>
291
+ <Encoding>FITS</Encoding>
292
+ <Binning>
293
+ <X>{binning_x}</X>
294
+ <Y>{binning_y}</Y>
295
+ </Binning>
296
+ <Frame>
297
+ <X>0</X>
298
+ <Y>0</Y>
299
+ <W>0</W>
300
+ <H>0</H>
301
+ </Frame>
302
+ <Temperature force='false'>0</Temperature>
303
+ <Filter>{filter_name}</Filter>
304
+ <Type>Light</Type>
305
+ <Count>{count}</Count>
306
+ <Delay>0</Delay>
307
+ <GuideDitherPerJob>0</GuideDitherPerJob>
308
+ <FITSDirectory>{output_dir}</FITSDirectory>
309
+ <PlaceholderFormat>%t_%F</PlaceholderFormat>
310
+ <PlaceholderSuffix>0</PlaceholderSuffix>
311
+ <UploadMode>0</UploadMode>
312
+ <Properties>
313
+ </Properties>
314
+ <Calibration>
315
+ <PreAction>
316
+ <Type>1</Type>
317
+ </PreAction>
318
+ <FlatDuration dark='false'>
319
+ <Type>Manual</Type>
320
+ </FlatDuration>
321
+ </Calibration>
322
+ </Job>
323
+ """
324
+
325
+ jobs = []
326
+
327
+ if self.filter_map:
328
+ # Multi-filter mode: create one job per discovered filter
329
+ self.logger.info(
330
+ f"Generating {len(self.filter_map)} jobs for filters: "
331
+ f"{[f['name'] for f in self.filter_map.values()]}"
332
+ )
333
+ for filter_idx in sorted(self.filter_map.keys()):
334
+ filter_info = self.filter_map[filter_idx]
335
+ filter_name = filter_info["name"]
336
+
337
+ job_xml = job_template.format(
338
+ exposure=self.exposure_time,
339
+ format=self.image_format,
340
+ binning_x=self.binning_x,
341
+ binning_y=self.binning_y,
342
+ filter_name=filter_name,
343
+ count=self.frame_count,
344
+ output_dir=str(output_dir),
345
+ )
346
+ jobs.append(job_xml)
347
+ else:
348
+ # Single-filter mode: use '--' for no filter
349
+ filter_name = "--" if not self.filter_wheel_name else "Luminance"
350
+ self.logger.info(f"Generating single job with filter: {filter_name}")
351
+
352
+ job_xml = job_template.format(
353
+ exposure=self.exposure_time,
354
+ format=self.image_format,
355
+ binning_x=self.binning_x,
356
+ binning_y=self.binning_y,
357
+ filter_name=filter_name,
358
+ count=self.frame_count,
359
+ output_dir=str(output_dir),
360
+ )
361
+ jobs.append(job_xml)
362
+
363
+ return "\n".join(jobs)
364
+
365
+ def _create_scheduler_job(self, task_id: str, satellite_data: dict, sequence_file: Path) -> Path:
366
+ """
367
+ Create an ESL scheduler job file from template.
368
+
369
+ Args:
370
+ task_id: Unique task identifier
371
+ satellite_data: Dictionary containing target coordinates
372
+ sequence_file: Path to the ESQ sequence file
373
+
374
+ Returns:
375
+ Path to the created scheduler job file
376
+ """
377
+ template = self._load_template("kstars_scheduler_template.esl")
378
+
379
+ # Extract target info
380
+ target_name = satellite_data.get("name", "Unknown")
381
+ ra_deg = satellite_data.get("ra", 0.0) # RA in degrees
382
+ dec_deg = satellite_data.get("dec", 0.0) # Dec in degrees
383
+
384
+ # Convert RA from degrees to hours for Ekos
385
+ ra_hours = ra_deg / 15.0
386
+
387
+ self.logger.info(f"Target: {target_name} at RA={ra_deg:.4f}° ({ra_hours:.4f}h), Dec={dec_deg:.4f}°")
388
+
389
+ # Replace placeholders
390
+ job_name = f"CitraScope: {target_name} (Task: {task_id})"
391
+ scheduler_content = template.replace("{{JOB_NAME}}", job_name)
392
+ scheduler_content = scheduler_content.replace("{{TARGET_RA}}", f"{ra_hours:.6f}")
393
+ scheduler_content = scheduler_content.replace("{{TARGET_DEC}}", f"{dec_deg:.6f}")
394
+ scheduler_content = scheduler_content.replace("{{SEQUENCE_FILE}}", str(sequence_file))
395
+ scheduler_content = scheduler_content.replace("{{MIN_ALTITUDE}}", "0") # 0° minimum altitude for satellites
396
+
397
+ # Write to temporary file
398
+ temp_dir = Path(user_cache_dir("citrascope")) / "kstars"
399
+ temp_dir.mkdir(exist_ok=True, parents=True)
400
+ job_file = temp_dir / f"{task_id}_job.esl"
401
+ job_file.write_text(scheduler_content)
402
+
403
+ self.logger.info(f"Created scheduler job: {job_file}")
404
+ return job_file
405
+
406
+ def _wait_for_job_completion(
407
+ self, timeout: int = 300, task_id: str = "", output_dir: Optional[Path] = None
408
+ ) -> bool:
409
+ """
410
+ Poll the scheduler status until job completes or times out.
411
+ With Loop completion, we poll for images and stop when we have all expected images.
412
+ For multi-filter sequences, waits until images from all filters are captured.
413
+
414
+ Args:
415
+ timeout: Maximum time to wait in seconds
416
+ task_id: Task identifier for image detection
417
+ output_dir: Output directory for image detection
418
+
419
+ Returns:
420
+ True if job completed successfully, False otherwise
421
+ """
422
+ if not self.scheduler:
423
+ raise RuntimeError("Scheduler interface not connected")
424
+
425
+ assert self.bus is not None
426
+
427
+ # Calculate expected number of images based on filters
428
+ expected_filter_count = len(self.filter_map) if self.filter_map else 1
429
+ expected_total_images = expected_filter_count * self.frame_count
430
+
431
+ self.logger.info(
432
+ f"Waiting for scheduler job completion (timeout: {timeout}s, "
433
+ f"expecting {expected_total_images} images across {expected_filter_count} filters)..."
434
+ )
435
+ start_time = time.time()
436
+
437
+ # Get scheduler object for property access
438
+ scheduler_obj = self.bus.get_object(self.bus_name, "/KStars/Ekos/Scheduler")
439
+ props = dbus.Interface(scheduler_obj, "org.freedesktop.DBus.Properties")
440
+
441
+ while time.time() - start_time < timeout:
442
+ try:
443
+ # Get scheduler status (0=Idle, 1=Running, 2=Paused, etc.)
444
+ status = int(props.Get("org.kde.kstars.Ekos.Scheduler", "status"))
445
+ current_job = props.Get("org.kde.kstars.Ekos.Scheduler", "currentJobName")
446
+
447
+ self.logger.debug(f"Scheduler status: {status}, Current job: {current_job}")
448
+
449
+ # Check for images if we're using Loop completion
450
+ if task_id and output_dir:
451
+ images = self._retrieve_captured_images(task_id, output_dir)
452
+ if len(images) >= expected_total_images:
453
+ self.logger.info(
454
+ f"Found {len(images)} images (expected {expected_total_images}), stopping scheduler"
455
+ )
456
+ self.scheduler.stop()
457
+ time.sleep(1) # Give it time to stop
458
+ return True
459
+ elif images:
460
+ self.logger.debug(f"Found {len(images)}/{expected_total_images} images so far, continuing...")
461
+
462
+ # Status 0 = Idle, meaning job finished or not started
463
+ # If we were running and now idle, job completed
464
+ if status == 0 and current_job == "":
465
+ self.logger.info("Scheduler job completed")
466
+ return True
467
+
468
+ time.sleep(5) # Poll every 5 seconds (slower since we're checking files)
469
+
470
+ except dbus.DBusException as e:
471
+ if "ServiceUnknown" in str(e) or "NoReply" in str(e):
472
+ self.logger.error("KStars appears to have crashed or disconnected")
473
+ return False
474
+ self.logger.warning(f"Error checking scheduler status: {e}")
475
+ time.sleep(2)
476
+ except Exception as e:
477
+ self.logger.warning(f"Error checking scheduler status: {e}")
478
+ time.sleep(2)
479
+
480
+ self.logger.error(f"Scheduler job did not complete within {timeout}s")
481
+ return False
482
+
483
+ def _retrieve_captured_images(self, task_id: str, output_dir: Path) -> list[str]:
484
+ """
485
+ Find and return paths to captured images for this task.
486
+
487
+ Args:
488
+ task_id: Unique task identifier
489
+ output_dir: Base output directory where images were saved
490
+
491
+ Returns:
492
+ List of absolute paths to captured FITS files
493
+ """
494
+ self.logger.debug(f"Looking for captured images in: {output_dir}")
495
+
496
+ # Check if base output directory exists
497
+ if not output_dir.exists():
498
+ self.logger.warning(f"Base output directory does not exist: {output_dir}")
499
+ # List parent directory to see what's there
500
+ parent = output_dir.parent
501
+ if parent.exists():
502
+ self.logger.debug(f"Parent directory contents: {list(parent.iterdir())}")
503
+ return []
504
+
505
+ # List what's in the base directory
506
+ self.logger.debug(f"Base directory contents: {list(output_dir.iterdir())}")
507
+
508
+ # Look for images in task-specific subdirectory
509
+ task_dir = output_dir / task_id
510
+
511
+ if not task_dir.exists():
512
+ self.logger.error(f"Task directory does not exist: {task_dir}")
513
+ self.logger.error(f"This likely indicates Ekos failed to create the capture directory")
514
+ self.logger.error(f"Expected directory structure: {output_dir}/{task_id}/")
515
+ raise RuntimeError(
516
+ f"Task-specific capture directory not found: {task_dir}. "
517
+ f"Ekos may have failed to start the capture sequence."
518
+ )
519
+
520
+ # Find all FITS files in task directory and subdirectories
521
+ fits_files = list(task_dir.rglob("*.fits")) + list(task_dir.rglob("*.fit"))
522
+
523
+ # Since files are in task-specific directory, we don't need to filter by filename
524
+ matching_files = [str(f.absolute()) for f in fits_files]
525
+
526
+ self.logger.info(f"Found {len(matching_files)} captured images for task {task_id}")
527
+ for img_path in matching_files:
528
+ self.logger.debug(f" - {img_path}")
529
+
530
+ return matching_files
531
+
532
+ def perform_observation_sequence(self, task_id: str, satellite_data: dict) -> list[str]:
533
+ """
534
+ Execute a complete observation sequence using Ekos Scheduler.
535
+
536
+ Args:
537
+ task_id: Unique task identifier
538
+ satellite_data: Dictionary with keys: 'name', and either 'ra'/'dec' or TLE data
539
+
540
+ Returns:
541
+ List of paths to captured FITS files
542
+
543
+ Raises:
544
+ RuntimeError: If scheduler not connected or job execution fails
545
+ """
546
+ if not self.scheduler:
547
+ raise RuntimeError("Scheduler interface not connected. Call connect() first.")
548
+
549
+ # Calculate current position if not already provided
550
+ if "ra" not in satellite_data or "dec" not in satellite_data:
551
+ # For now, require RA/Dec to be provided by caller
552
+ # TODO: Add TLE propagation capability to adapter for full autonomy
553
+ raise ValueError("satellite_data must include 'ra' and 'dec' keys (in degrees)")
554
+
555
+ try:
556
+ # Setup output directory
557
+ output_dir = Path(user_data_dir("citrascope")) / "kstars_captures"
558
+ output_dir.mkdir(exist_ok=True, parents=True)
559
+
560
+ # Clear task-specific directory to prevent Ekos from thinking job is already done
561
+ task_output_dir = output_dir / task_id
562
+ if task_output_dir.exists():
563
+ shutil.rmtree(task_output_dir)
564
+ self.logger.info(f"Cleared existing output directory: {task_output_dir}")
565
+
566
+ # Create task directory for this observation
567
+ task_output_dir.mkdir(exist_ok=True, parents=True)
568
+ self.logger.info(f"Output directory: {task_output_dir}")
569
+
570
+ # Create sequence and scheduler job files (use task-specific directory)
571
+ sequence_file = self._create_sequence_file(task_id, satellite_data, task_output_dir)
572
+ job_file = self._create_scheduler_job(task_id, satellite_data, sequence_file)
573
+
574
+ # Ensure temp files are cleaned up even on failure
575
+ try:
576
+ self._execute_observation(task_id, output_dir, sequence_file, job_file)
577
+ finally:
578
+ # Cleanup temp files
579
+ self._cleanup_temp_files(sequence_file, job_file)
580
+
581
+ # Retrieve and return captured images
582
+ image_paths = self._retrieve_captured_images(task_id, output_dir)
583
+ if not image_paths:
584
+ raise RuntimeError(f"No images captured for task {task_id}")
585
+
586
+ self.logger.info(f"Observation sequence complete: {len(image_paths)} images captured")
587
+ return image_paths
588
+
589
+ except Exception as e:
590
+ self.logger.error(f"Failed to execute observation sequence: {e}")
591
+ raise
592
+
593
+ def _execute_observation(self, task_id: str, output_dir: Path, sequence_file: Path, job_file: Path):
594
+ """Execute the observation by loading scheduler job and waiting for completion.
595
+
596
+ Args:
597
+ task_id: Task identifier
598
+ output_dir: Base output directory
599
+ sequence_file: Path to ESQ sequence file
600
+ job_file: Path to ESL scheduler job file
601
+ """
602
+ assert self.scheduler is not None
603
+ assert self.bus is not None
604
+
605
+ # Load scheduler job via DBus
606
+ self.logger.info(f"Loading scheduler job: {job_file}")
607
+
608
+ # Verify files exist and have content
609
+ if not job_file.exists():
610
+ raise RuntimeError(f"Scheduler job file does not exist: {job_file}")
611
+ if not sequence_file.exists():
612
+ raise RuntimeError(f"Sequence file does not exist: {sequence_file}")
613
+
614
+ self.logger.debug(f"Job file size: {job_file.stat().st_size} bytes")
615
+ self.logger.debug(f"Sequence file size: {sequence_file.stat().st_size} bytes")
616
+
617
+ # Load the scheduler job
618
+ try:
619
+ # Clear any existing jobs first to prevent state conflicts
620
+ try:
621
+ self.scheduler.removeAllJobs()
622
+ self.logger.info("Cleared existing scheduler jobs")
623
+ time.sleep(0.5) # Brief pause after clearing
624
+ except Exception as clear_error:
625
+ self.logger.warning(f"Could not clear jobs (might not exist): {clear_error}")
626
+
627
+ success = self.scheduler.loadScheduler(str(job_file))
628
+ self.logger.debug(f"loadScheduler() returned: {success}")
629
+ except Exception as dbus_error:
630
+ self.logger.error(f"DBus error calling loadScheduler: {dbus_error}")
631
+ raise RuntimeError(f"DBus error loading scheduler job: {dbus_error}")
632
+
633
+ if not success:
634
+ # Log file contents for debugging
635
+ self.logger.error(f"Scheduler rejected job file. Contents:")
636
+ self.logger.error(job_file.read_text()[:500]) # First 500 chars
637
+ raise RuntimeError(f"Ekos Scheduler rejected job file: {job_file}")
638
+
639
+ self.logger.info("Scheduler job loaded successfully")
640
+
641
+ # Verify what was loaded before starting
642
+ try:
643
+ scheduler_obj = self.bus.get_object(self.bus_name, "/KStars/Ekos/Scheduler")
644
+ props = dbus.Interface(scheduler_obj, "org.freedesktop.DBus.Properties")
645
+ json_jobs = props.Get("org.kde.kstars.Ekos.Scheduler", "jsonJobs")
646
+ self.logger.info(f"Loaded jobs: {json_jobs}")
647
+
648
+ # Parse and validate the job looks correct
649
+ jobs = json.loads(str(json_jobs))
650
+ if jobs:
651
+ job = jobs[0] # We only load one job at a time
652
+ self.logger.info(f"Loaded {len(jobs)} job(s):")
653
+ self.logger.info(f" Name: {job.get('name', 'Unknown')}")
654
+ self.logger.info(f" State: {job.get('state', 'Unknown')}")
655
+ self.logger.info(f" RA: {job.get('targetRA', 'N/A')}h, Dec: {job.get('targetDEC', 'N/A')}°")
656
+ self.logger.info(f" Altitude: {job.get('altitudeFormatted', 'N/A')}")
657
+ self.logger.info(f" Repeats: {job.get('repeatsRemaining', 0)}/{job.get('repeatsRequired', 0)}")
658
+ self.logger.info(f" Completed: {job.get('completedCount', 0)}")
659
+ else:
660
+ self.logger.warning("No jobs found in scheduler after loading!")
661
+ except Exception as e:
662
+ self.logger.warning(f"Could not validate loaded jobs: {e}")
663
+
664
+ # Start scheduler
665
+ self.logger.info("Starting scheduler execution...")
666
+ self.scheduler.start()
667
+
668
+ # Give it a moment to start
669
+ time.sleep(1)
670
+
671
+ # Check scheduler logs immediately after starting
672
+ try:
673
+ scheduler_obj = self.bus.get_object(self.bus_name, "/KStars/Ekos/Scheduler")
674
+ props = dbus.Interface(scheduler_obj, "org.freedesktop.DBus.Properties")
675
+ log_lines = props.Get("org.kde.kstars.Ekos.Scheduler", "logText")
676
+ if log_lines:
677
+ self.logger.info("Scheduler logs after start:")
678
+ for line in log_lines[-10:]: # Last 10 lines
679
+ self.logger.info(f" Ekos: {line}")
680
+ except Exception as e:
681
+ self.logger.debug(f"Could not read scheduler logs: {e}")
682
+
683
+ # Wait for completion (with Loop mode, this polls for images and stops when found)
684
+ if not self._wait_for_job_completion(timeout=300, task_id=task_id, output_dir=output_dir):
685
+ raise RuntimeError("Scheduler job did not complete in time")
686
+
687
+ def _cleanup_temp_files(self, sequence_file: Path, job_file: Path):
688
+ """Clean up temporary ESQ and ESL files.
689
+
690
+ Args:
691
+ sequence_file: Path to ESQ sequence file
692
+ job_file: Path to ESL scheduler job file
693
+ """
694
+ try:
695
+ if sequence_file.exists():
696
+ sequence_file.unlink()
697
+ self.logger.debug(f"Cleaned up sequence file: {sequence_file.name}")
698
+ if job_file.exists():
699
+ job_file.unlink()
700
+ self.logger.debug(f"Cleaned up job file: {job_file.name}")
701
+ except Exception as e:
702
+ self.logger.warning(f"Failed to cleanup temp files: {e}")
60
703
 
61
704
  def connect(self) -> bool:
62
705
  """
@@ -66,13 +709,6 @@ class KStarsDBusAdapter(AbstractAstroHardwareAdapter):
66
709
  bool: True if connection successful, False otherwise
67
710
  """
68
711
  try:
69
- # Import dbus here to make it an optional dependency
70
- try:
71
- import dbus
72
- except ImportError:
73
- self.logger.error("dbus-python is not installed. Install with: pip install dbus-python")
74
- return False
75
-
76
712
  # Connect to the session bus
77
713
  self.logger.info("Connecting to DBus session bus...")
78
714
  self.bus = dbus.SessionBus()
@@ -131,6 +767,12 @@ class KStarsDBusAdapter(AbstractAstroHardwareAdapter):
131
767
  except dbus.DBusException as e:
132
768
  self.logger.warning(f"Scheduler interface not available: {e}")
133
769
 
770
+ # Validate devices and imaging train
771
+ self._validate_devices()
772
+
773
+ # Discover available filters (non-fatal if fails)
774
+ self.discover_filters()
775
+
134
776
  self.logger.info("Successfully connected to KStars via DBus")
135
777
  return True
136
778
 
@@ -138,18 +780,191 @@ class KStarsDBusAdapter(AbstractAstroHardwareAdapter):
138
780
  self.logger.error(f"Failed to connect to KStars via DBus: {e}")
139
781
  return False
140
782
 
783
+ def _validate_devices(self):
784
+ """Check what optical train/devices are configured in Ekos."""
785
+ try:
786
+ assert self.bus is not None
787
+ # Use Capture module (not Camera)
788
+ capture_obj = self.bus.get_object(self.bus_name, "/KStars/Ekos/Capture")
789
+ props = dbus.Interface(capture_obj, "org.freedesktop.DBus.Properties")
790
+
791
+ optical_train = props.Get("org.kde.kstars.Ekos.Capture", "opticalTrain")
792
+ camera_name = props.Get("org.kde.kstars.Ekos.Capture", "camera")
793
+ filter_wheel = props.Get("org.kde.kstars.Ekos.Capture", "filterWheel")
794
+
795
+ self.logger.info(f"Ekos optical train: {optical_train}")
796
+ self.logger.info(f"Ekos camera device: {camera_name}")
797
+ self.logger.info(f"Ekos filter wheel: {filter_wheel}")
798
+
799
+ except Exception as e:
800
+ self.logger.warning(f"Could not read Ekos devices: {e}")
801
+ # Non-fatal - continue with defaults
802
+
803
+ def discover_filters(self):
804
+ """Discover available filters from Ekos filter wheel via INDI interface.
805
+
806
+ This is called during connect() to populate filter_map.
807
+ Uses INDI interface to query FILTER_NAME properties for each slot.
808
+ If no filter wheel is configured or discovery fails, filter_map remains empty
809
+ and adapter falls back to single-filter behavior.
810
+ """
811
+ try:
812
+ if not self.bus:
813
+ self.logger.debug("Cannot discover filters: DBus not connected")
814
+ return
815
+
816
+ self.logger.info("Attempting to discover filters...")
817
+
818
+ # Get filter wheel device name from Capture module
819
+ capture_obj = self.bus.get_object(self.bus_name, "/KStars/Ekos/Capture")
820
+ capture_props = dbus.Interface(capture_obj, "org.freedesktop.DBus.Properties")
821
+
822
+ try:
823
+ filter_wheel_name = capture_props.Get("org.kde.kstars.Ekos.Capture", "filterWheel")
824
+ if not filter_wheel_name or filter_wheel_name == "--":
825
+ self.logger.info("No filter wheel configured in Capture module")
826
+ return
827
+ self.logger.info(f"Filter wheel detected: {filter_wheel_name}")
828
+ except Exception as e:
829
+ self.logger.debug(f"Could not get filter wheel name: {e}")
830
+ return
831
+
832
+ # Use INDI interface to query filter properties
833
+ indi_obj = self.bus.get_object(self.bus_name, "/KStars/INDI")
834
+ indi_iface = dbus.Interface(indi_obj, "org.kde.kstars.INDI")
835
+
836
+ # Get all properties for the filter wheel device
837
+ properties = indi_iface.getProperties(filter_wheel_name)
838
+
839
+ # Find FILTER_NAME properties (FILTER_SLOT_NAME_1, FILTER_SLOT_NAME_2, etc.)
840
+ filter_slots = []
841
+ for prop in properties:
842
+ if "FILTER_NAME.FILTER_SLOT_NAME_" in prop:
843
+ slot_num = prop.split("_")[-1]
844
+ try:
845
+ filter_slots.append(int(slot_num))
846
+ except ValueError:
847
+ continue
848
+
849
+ if not filter_slots:
850
+ self.logger.warning(f"No FILTER_NAME properties found for {filter_wheel_name}")
851
+ return
852
+
853
+ # Query each filter slot name and merge with pre-populated filter_map
854
+ filter_slots.sort()
855
+ for slot_num in filter_slots:
856
+ try:
857
+ filter_name = indi_iface.getText(filter_wheel_name, "FILTER_NAME", f"FILTER_SLOT_NAME_{slot_num}")
858
+ # Use 0-based indexing for filter_map (slot 1 -> index 0)
859
+ filter_idx = slot_num - 1
860
+
861
+ # If filter already in map (from saved settings), preserve focus position
862
+ if filter_idx in self.filter_map:
863
+ focus_position = self.filter_map[filter_idx].get("focus_position", 0)
864
+ self.logger.debug(
865
+ f"Filter slot {slot_num} ({filter_name}): using saved focus position {focus_position}"
866
+ )
867
+ else:
868
+ focus_position = 0
869
+ self.logger.debug(
870
+ f"Filter slot {slot_num} ({filter_name}): new filter, using default focus position"
871
+ )
872
+
873
+ self.filter_map[filter_idx] = {
874
+ "name": filter_name,
875
+ "focus_position": focus_position,
876
+ }
877
+ except Exception as e:
878
+ self.logger.warning(f"Could not read filter slot {slot_num}: {e}")
879
+
880
+ if self.filter_map:
881
+ self.logger.info(
882
+ f"Discovered {len(self.filter_map)} filters: {[f['name'] for f in self.filter_map.values()]}"
883
+ )
884
+ else:
885
+ self.logger.warning("No filters discovered from filter wheel")
886
+
887
+ except Exception as e:
888
+ self.logger.info(f"Filter discovery failed (non-fatal): {e}")
889
+ # Leave filter_map empty, use single-filter mode
890
+
891
+ def supports_filter_management(self) -> bool:
892
+ """Indicates whether this adapter supports filter/focus management.
893
+
894
+ Returns:
895
+ bool: True if filters were discovered, False otherwise.
896
+ """
897
+ return bool(self.filter_map)
898
+
899
+ def get_filter_config(self) -> Dict[str, Dict[str, Any]]:
900
+ """Get the current filter configuration including focus positions.
901
+
902
+ Returns:
903
+ dict: Dictionary mapping filter IDs (as strings) to FilterConfig.
904
+ Each FilterConfig contains:
905
+ - name (str): Filter name
906
+ - focus_position (int): Focuser position for this filter
907
+
908
+ Example:
909
+ {
910
+ "0": {"name": "Red", "focus_position": 9000},
911
+ "1": {"name": "Green", "focus_position": 9050}
912
+ }
913
+ """
914
+ # Convert 0-based integer keys to strings for the web interface
915
+ return {str(k): v for k, v in self.filter_map.items()}
916
+
917
+ def update_filter_focus(self, filter_id: str, focus_position: int) -> bool:
918
+ """Update the focus position for a specific filter.
919
+
920
+ Args:
921
+ filter_id: Filter ID as string (0-based index)
922
+ focus_position: New focus position in steps
923
+
924
+ Returns:
925
+ bool: True if update was successful, False otherwise
926
+ """
927
+ try:
928
+ idx = int(filter_id)
929
+ if idx in self.filter_map:
930
+ self.filter_map[idx]["focus_position"] = focus_position
931
+ self.logger.info(f"Updated filter '{self.filter_map[idx]['name']}' focus position to {focus_position}")
932
+ return True
933
+ else:
934
+ self.logger.warning(f"Filter ID {filter_id} not found in filter_map")
935
+ return False
936
+ except (ValueError, KeyError) as e:
937
+ self.logger.error(f"Failed to update filter focus: {e}")
938
+ return False
939
+
141
940
  def disconnect(self):
142
941
  raise NotImplementedError
143
942
 
144
943
  def is_telescope_connected(self) -> bool:
145
944
  """Check if telescope is connected and responsive."""
146
- # KStars adapter is incomplete - return False for now
147
- return self.mount is not None
945
+ if not self.mount or not self.bus:
946
+ return False
947
+ try:
948
+ # Actually test the connection by reading a property
949
+ mount_obj = self.bus.get_object(self.bus_name, "/KStars/Ekos/Mount")
950
+ props = dbus.Interface(mount_obj, "org.freedesktop.DBus.Properties")
951
+ props.Get("org.kde.kstars.Ekos.Mount", "status")
952
+ return True
953
+ except (dbus.DBusException, Exception):
954
+ return False
148
955
 
149
956
  def is_camera_connected(self) -> bool:
150
957
  """Check if camera is connected and responsive."""
151
- # KStars adapter is incomplete - return False for now
152
- return self.camera is not None
958
+ if not self.camera or not self.bus:
959
+ return False
960
+ try:
961
+ # Actually test the connection by reading a property
962
+ capture_obj = self.bus.get_object(self.bus_name, "/KStars/Ekos/Capture")
963
+ props = dbus.Interface(capture_obj, "org.freedesktop.DBus.Properties")
964
+ props.Get("org.kde.kstars.Ekos.Capture", "status")
965
+ return True
966
+ except (dbus.DBusException, Exception):
967
+ return False
153
968
 
154
969
  def list_devices(self) -> list[str]:
155
970
  raise NotImplementedError
@@ -158,10 +973,78 @@ class KStarsDBusAdapter(AbstractAstroHardwareAdapter):
158
973
  raise NotImplementedError
159
974
 
160
975
  def get_telescope_direction(self) -> tuple[float, float]:
161
- raise NotImplementedError
976
+ """
977
+ Get the current telescope pointing direction.
978
+
979
+ Returns:
980
+ tuple[float, float]: Current (RA, Dec) in degrees
981
+
982
+ Raises:
983
+ RuntimeError: If mount is not connected or position query fails
984
+ """
985
+ if not self.mount:
986
+ raise RuntimeError("Mount interface not connected. Call connect() first.")
987
+
988
+ assert self.bus is not None
989
+
990
+ try:
991
+ # Get the mount object for property access
992
+ mount_obj = self.bus.get_object(self.bus_name, "/KStars/Ekos/Mount")
993
+ props = dbus.Interface(mount_obj, "org.freedesktop.DBus.Properties")
994
+
995
+ # Get equatorial coordinates property (returns list [RA in hours, Dec in degrees])
996
+ coords = props.Get("org.kde.kstars.Ekos.Mount", "equatorialCoords")
997
+
998
+ if not coords or len(coords) < 2:
999
+ raise RuntimeError("Failed to retrieve valid coordinates from mount")
1000
+
1001
+ # coords[0] is RA in hours, coords[1] is Dec in degrees
1002
+ ra_hours = float(coords[0])
1003
+ dec_deg = float(coords[1])
1004
+
1005
+ # Convert RA from hours to degrees
1006
+ ra_deg = ra_hours * 15.0
1007
+
1008
+ self.logger.debug(f"Current telescope position: RA={ra_deg:.4f}° ({ra_hours:.4f}h), Dec={dec_deg:.4f}°")
1009
+
1010
+ return (ra_deg, dec_deg)
1011
+
1012
+ except Exception as e:
1013
+ self.logger.error(f"Failed to get telescope position: {e}")
1014
+ raise RuntimeError(f"Failed to get telescope position: {e}")
162
1015
 
163
1016
  def telescope_is_moving(self) -> bool:
164
- raise NotImplementedError
1017
+ """
1018
+ Check if the telescope is currently slewing.
1019
+
1020
+ Returns:
1021
+ bool: True if telescope is slewing, False if idle or tracking
1022
+
1023
+ Raises:
1024
+ RuntimeError: If mount is not connected or status query fails
1025
+ """
1026
+ if not self.mount:
1027
+ raise RuntimeError("Mount interface not connected. Call connect() first.")
1028
+
1029
+ assert self.bus is not None
1030
+
1031
+ try:
1032
+ # Get the mount object for property access
1033
+ mount_obj = self.bus.get_object(self.bus_name, "/KStars/Ekos/Mount")
1034
+ props = dbus.Interface(mount_obj, "org.freedesktop.DBus.Properties")
1035
+
1036
+ # Get slewStatus property (0 = idle, non-zero = slewing)
1037
+ slew_status = props.Get("org.kde.kstars.Ekos.Mount", "slewStatus")
1038
+
1039
+ is_slewing = int(slew_status) != 0
1040
+
1041
+ self.logger.debug(f"Mount slew status: {slew_status} (is_slewing={is_slewing})")
1042
+
1043
+ return is_slewing
1044
+
1045
+ except Exception as e:
1046
+ self.logger.error(f"Failed to get telescope slew status: {e}")
1047
+ raise RuntimeError(f"Failed to get telescope slew status: {e}")
165
1048
 
166
1049
  def select_camera(self, device_name: str) -> bool:
167
1050
  raise NotImplementedError
@@ -0,0 +1,30 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <SchedulerList version="2.6">
3
+ <Job>
4
+ <Name>{{JOB_NAME}}</Name>
5
+ <Group></Group>
6
+ <Coordinates>
7
+ <J2000RA>{{TARGET_RA}}</J2000RA>
8
+ <J2000DE>{{TARGET_DEC}}</J2000DE>
9
+ </Coordinates>
10
+ <PositionAngle>-1</PositionAngle>
11
+ <Sequence>{{SEQUENCE_FILE}}</Sequence>
12
+ <FITSFILE></FITSFILE>
13
+ <StartupCondition>
14
+ <Condition>ASAP</Condition>
15
+ <CulminationOffset>0</CulminationOffset>
16
+ </StartupCondition>
17
+ <Constraints>
18
+ <Constraint value="{{MIN_ALTITUDE}}">MinimumAltitude</Constraint>
19
+ </Constraints>
20
+ <CompletionCondition>
21
+ <Condition>Repeat</Condition>
22
+ <Repeat>10</Repeat>
23
+ </CompletionCondition>
24
+ <Steps>
25
+ <Step>Track</Step>
26
+ </Steps>
27
+ <Priority>10</Priority>
28
+ <Enforced>true</Enforced>
29
+ </Job>
30
+ </SchedulerList>
@@ -0,0 +1,16 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <SequenceQueue version='2.6'>
3
+ <Observer>CitraScope</Observer>
4
+ <GuideDeviation enabled='false'>2</GuideDeviation>
5
+ <GuideStartDeviation enabled='false'>2</GuideStartDeviation>
6
+ <HFRCheck enabled='false'>
7
+ <HFRDeviation>0.5</HFRDeviation>
8
+ <HFRCheckAlgorithm>0</HFRCheckAlgorithm>
9
+ <HFRCheckThreshold>10</HFRCheckThreshold>
10
+ <HFRCheckFrames>1</HFRCheckFrames>
11
+ </HFRCheck>
12
+ <RefocusOnTemperatureDelta enabled='false'>1</RefocusOnTemperatureDelta>
13
+ <RefocusEveryN enabled='false'>60</RefocusEveryN>
14
+ <RefocusOnMeridianFlip enabled='false' />
15
+ {{JOBS}}
16
+ </SequenceQueue>
@@ -53,8 +53,8 @@ class CitraScopeSettings:
53
53
  self.log_level: str = config.get("log_level", "INFO")
54
54
  self.keep_images: bool = config.get("keep_images", False)
55
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)
56
+ # Web port: CLI-only, never loaded from or saved to config file
57
+ self.web_port: int = web_port
58
58
 
59
59
  # Task retry configuration
60
60
  self.max_task_retries: int = config.get("max_task_retries", 3)
@@ -90,7 +90,7 @@ class CitraScopeSettings:
90
90
  """Convert settings to dictionary for serialization.
91
91
 
92
92
  Returns:
93
- Dictionary of all settings.
93
+ Dictionary of all settings (excluding runtime-only settings like web_port).
94
94
  """
95
95
  return {
96
96
  "host": self.host,
@@ -102,7 +102,6 @@ class CitraScopeSettings:
102
102
  "adapter_settings": self._all_adapter_settings,
103
103
  "log_level": self.log_level,
104
104
  "keep_images": self.keep_images,
105
- "web_port": self.web_port,
106
105
  "max_task_retries": self.max_task_retries,
107
106
  "initial_retry_delay_seconds": self.initial_retry_delay_seconds,
108
107
  "max_retry_delay_seconds": self.max_retry_delay_seconds,
@@ -125,6 +124,9 @@ class CitraScopeSettings:
125
124
  Args:
126
125
  config: Configuration dict with flat adapter_settings for current adapter.
127
126
  """
127
+ # Remove runtime-only settings that should never be persisted
128
+ config.pop("web_port", None)
129
+
128
130
  # Nest incoming adapter_settings under hardware_adapter key
129
131
  adapter = config.get("hardware_adapter", self.hardware_adapter)
130
132
  if adapter:
@@ -17,7 +17,12 @@ class StaticTelescopeTask(AbstractBaseTelescopeTask):
17
17
  filepaths = self.hardware_adapter.take_image(self.task.id, 2.0) # 2 second exposure
18
18
 
19
19
  if self.hardware_adapter.get_observation_strategy() == ObservationStrategy.SEQUENCE_TO_CONTROLLER:
20
- # Assume the hardware adapter has already pointed the telescope and started tracking
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
21
26
  filepaths = self.hardware_adapter.perform_observation_sequence(self.task.id, satellite_data)
22
27
 
23
28
  # Take the image
@@ -1,10 +1,26 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: citrascope
3
- Version: 0.5.2
3
+ Version: 0.6.1
4
4
  Summary: Remotely control a telescope while it polls for tasks, collects and edge processes data, and delivers results and data for further processing.
5
+ Project-URL: Homepage, https://citra.space
6
+ Project-URL: Documentation, https://docs.citra.space/citrascope/
7
+ Project-URL: Repository, https://github.com/citra-space/citrascope
8
+ Project-URL: Issues, https://github.com/citra-space/citrascope/issues
9
+ Project-URL: Changelog, https://github.com/citra-space/citrascope/releases
5
10
  Author-email: Patrick McDavid <patrick@citra.space>
6
11
  License: MIT
7
12
  License-File: LICENSE
13
+ Keywords: INDI,KStars,NINA,astronomy,astrophotography,imaging,observatory,remote-telescope,telescope,telescope-control
14
+ Classifier: Development Status :: 4 - Beta
15
+ Classifier: Intended Audience :: Science/Research
16
+ Classifier: License :: OSI Approved :: MIT License
17
+ Classifier: Operating System :: OS Independent
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Programming Language :: Python :: 3.10
20
+ Classifier: Programming Language :: Python :: 3.11
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Topic :: Scientific/Engineering :: Astronomy
23
+ Classifier: Topic :: System :: Hardware :: Hardware Drivers
8
24
  Requires-Python: <3.13,>=3.10
9
25
  Requires-Dist: click
10
26
  Requires-Dist: fastapi>=0.104.0
@@ -51,6 +67,8 @@ Description-Content-Type: text/markdown
51
67
  # CitraScope
52
68
  [![Pytest](https://github.com/citra-space/citrascope/actions/workflows/pytest.yml/badge.svg)](https://github.com/citra-space/citrascope/actions/workflows/pytest.yml) [![Publish Python Package](https://github.com/citra-space/citrascope/actions/workflows/pypi-publish.yml/badge.svg)](https://github.com/citra-space/citrascope/actions/workflows/pypi-publish.yml) [![PyPI version](https://badge.fury.io/py/citrascope.svg)](https://pypi.org/project/citrascope/) [![PyPI - Downloads](https://img.shields.io/pypi/dm/citrascope)](https://pypi.org/project/citrascope/) [![License](https://img.shields.io/github/license/citra-space/citrascope)](https://github.com/citra-space/citrascope/blob/main/LICENSE)
53
69
 
70
+ **[GitHub Repository](https://github.com/citra-space/citrascope)** | **[Documentation](https://docs.citra.space/citrascope/)** | **[Citra.space](https://citra.space)**
71
+
54
72
  Remotely control a telescope while it polls for tasks, collects observations, and delivers data for further processing.
55
73
 
56
74
  ## Features
@@ -7,19 +7,21 @@ citrascope/api/citra_api_client.py,sha256=8rpz25Diy8YhuCiQ9HqMi4TIqxAc6BbrvqoFu8
7
7
  citrascope/hardware/abstract_astro_hardware_adapter.py,sha256=Xc1zNuvlyYapWto37dzFfaKM62pKDN7VC8r4oGF8Up4,8140
8
8
  citrascope/hardware/adapter_registry.py,sha256=fFIZhXYphZ_p480c6hICpcx9fNOeX-EG2tvLHm372dM,3170
9
9
  citrascope/hardware/indi_adapter.py,sha256=uNrjkfxD0zjOPfar6J-frb6A87VkEjsL7SD9N9bEsC8,29903
10
- citrascope/hardware/kstars_dbus_adapter.py,sha256=Nv6ijVDvgTCTZUmRFh3Wh-YS7ChiztiXF17OWlzJwoo,7001
10
+ citrascope/hardware/kstars_dbus_adapter.py,sha256=Rvv_skURngGIfNhtpVzykq1Bs1vIyIXAb4sBlBwd4YI,44643
11
+ citrascope/hardware/kstars_scheduler_template.esl,sha256=uHbS3Zs1S9_hz8kiMkrkZj5ye0z1T1zsy_8MO4N0D4Y,836
12
+ citrascope/hardware/kstars_sequence_template.esq,sha256=u1fzSGtyq7ybKvZcNRjDtQK7QnrQim-E6qiZyG9P7H0,626
11
13
  citrascope/hardware/nina_adv_http_adapter.py,sha256=Jzg9j74bEFdY77XX-O-UE-e3Q3Y8PQ-xL7-igXMqbwg,27637
12
14
  citrascope/hardware/nina_adv_http_survey_template.json,sha256=beg4H6Bzby-0x5uDc_eRJQ_rKs8VT64sDJyAzS_q1l4,14424
13
15
  citrascope/logging/__init__.py,sha256=YU38HLMWfbXh_H-s7W7Zx2pbCR4f_tRk7z0G8xqz4_o,179
14
16
  citrascope/logging/_citrascope_logger.py,sha256=GkqNpFJWiatqrBr8t4o2nHt7V9bBDJ8mysM0F4AXMa8,3479
15
17
  citrascope/logging/web_log_handler.py,sha256=d0XQzHJZ5M1v3H351tdkBYg7EOwFzXpp7PA9nYejIV0,2659
16
18
  citrascope/settings/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
17
- citrascope/settings/citrascope_settings.py,sha256=C9BgX3vKDnsbwZF1NskUmkczB5ESdOSWPaHrq4rA0aQ,5635
19
+ citrascope/settings/citrascope_settings.py,sha256=Env0L8sM0e9v7Os9CIqeRX3Nq0-DrgxmGLeNzbOm1To,5671
18
20
  citrascope/settings/settings_file_manager.py,sha256=Yijb-I9hbbVJ2thkr7OrfkNknSPt1RDpsE7VvqAs0a8,4193
19
21
  citrascope/tasks/runner.py,sha256=77rn8ML7Fpay7B0YbXag6rn70IbEYYVMNNFLLDaHA50,13903
20
22
  citrascope/tasks/task.py,sha256=0u0oN56E6KaNz19ba_7WuY43Sk4CTXc8UPT7sdUpRXo,1287
21
23
  citrascope/tasks/scope/base_telescope_task.py,sha256=wIdyUxplFNhf_YMdCXOK6pG7HF7tZn_id59TvYyWZAY,9674
22
- citrascope/tasks/scope/static_telescope_task.py,sha256=XP53zYVcyLHLvebDU06Jx0ghPK3tb0c_XmO60yj_XSA,1132
24
+ citrascope/tasks/scope/static_telescope_task.py,sha256=nrRV2M2bUfIwTtZacAmidwj1dlTxyZgTbFEVkpxzL7c,1389
23
25
  citrascope/tasks/scope/tracking_telescope_task.py,sha256=k5LEmEi_xnFHNjqPNYb8_tqDdCFD3YGe25Wh_brJXHk,1130
24
26
  citrascope/web/__init__.py,sha256=CgU36fyNSxGXjUy3hsHwx7UxF8UO4Qsb7PjC9-6tRmY,38
25
27
  citrascope/web/app.py,sha256=LGimBCHWmaPrLkh6JDpJ7IoC__423lhO7NldQGTFzKI,26181
@@ -32,8 +34,8 @@ citrascope/web/static/websocket.js,sha256=UITw1DDfehOKpjlltn5MXhewZYGKzPFmaTtMFt
32
34
  citrascope/web/static/img/citra.png,sha256=Bq8dPWB6fNz7a_H0FuEtNmZWcPHH2iV2OC-fMg4REbQ,205570
33
35
  citrascope/web/static/img/favicon.png,sha256=zrbUlpFXDB_zmsIdhhn8_klnc2Ma3N6Q8ouBMAxFjbM,24873
34
36
  citrascope/web/templates/dashboard.html,sha256=7N5JPlihK3WNDe8fnFMfIRfCgp4ZZJLbl2TVc_nY0SU,30119
35
- citrascope-0.5.2.dist-info/METADATA,sha256=R-caZxFgOqFFocrteqsYR8WHEAWPhsDNmyuY_tnYiA4,6828
36
- citrascope-0.5.2.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
37
- citrascope-0.5.2.dist-info/entry_points.txt,sha256=fP22Lt8bNZ_whBowDnOWSADf_FUrgAWnIhqqPf5Xo2g,55
38
- citrascope-0.5.2.dist-info/licenses/LICENSE,sha256=4B_Ug8tnhTwde7QywOV3HhQcweHJeI0QaGdZfJLxsV8,1068
39
- citrascope-0.5.2.dist-info/RECORD,,
37
+ citrascope-0.6.1.dist-info/METADATA,sha256=41ckB6zCtSPkVOBDayoKVIfF3boDtsGLkvvLvYc8Teg,7945
38
+ citrascope-0.6.1.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
39
+ citrascope-0.6.1.dist-info/entry_points.txt,sha256=fP22Lt8bNZ_whBowDnOWSADf_FUrgAWnIhqqPf5Xo2g,55
40
+ citrascope-0.6.1.dist-info/licenses/LICENSE,sha256=4B_Ug8tnhTwde7QywOV3HhQcweHJeI0QaGdZfJLxsV8,1068
41
+ citrascope-0.6.1.dist-info/RECORD,,