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.
- citrascope/hardware/kstars_dbus_adapter.py +912 -29
- citrascope/hardware/kstars_scheduler_template.esl +30 -0
- citrascope/hardware/kstars_sequence_template.esq +16 -0
- citrascope/settings/citrascope_settings.py +6 -4
- citrascope/tasks/scope/static_telescope_task.py +6 -1
- {citrascope-0.5.2.dist-info → citrascope-0.6.1.dist-info}/METADATA +19 -1
- {citrascope-0.5.2.dist-info → citrascope-0.6.1.dist-info}/RECORD +10 -8
- {citrascope-0.5.2.dist-info → citrascope-0.6.1.dist-info}/WHEEL +0 -0
- {citrascope-0.5.2.dist-info → citrascope-0.6.1.dist-info}/entry_points.txt +0 -0
- {citrascope-0.5.2.dist-info → citrascope-0.6.1.dist-info}/licenses/LICENSE +0 -0
|
@@ -1,7 +1,12 @@
|
|
|
1
|
-
import
|
|
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
|
-
"""
|
|
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"
|
|
28
|
-
self.
|
|
29
|
-
self.
|
|
30
|
-
self.
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
self.
|
|
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":
|
|
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
|
-
|
|
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
|
|
59
|
-
|
|
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
|
-
|
|
147
|
-
|
|
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
|
-
|
|
152
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
57
|
-
self.web_port: int = 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
|
-
#
|
|
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.
|
|
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
|
[](https://github.com/citra-space/citrascope/actions/workflows/pytest.yml) [](https://github.com/citra-space/citrascope/actions/workflows/pypi-publish.yml) [](https://pypi.org/project/citrascope/) [](https://pypi.org/project/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=
|
|
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=
|
|
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=
|
|
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.
|
|
36
|
-
citrascope-0.
|
|
37
|
-
citrascope-0.
|
|
38
|
-
citrascope-0.
|
|
39
|
-
citrascope-0.
|
|
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,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|