citrascope 0.1.0__py3-none-any.whl → 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. citrascope/__main__.py +8 -5
  2. citrascope/api/abstract_api_client.py +7 -0
  3. citrascope/api/citra_api_client.py +30 -1
  4. citrascope/citra_scope_daemon.py +214 -61
  5. citrascope/hardware/abstract_astro_hardware_adapter.py +70 -2
  6. citrascope/hardware/adapter_registry.py +94 -0
  7. citrascope/hardware/indi_adapter.py +456 -16
  8. citrascope/hardware/kstars_dbus_adapter.py +179 -0
  9. citrascope/hardware/nina_adv_http_adapter.py +593 -0
  10. citrascope/hardware/nina_adv_http_survey_template.json +328 -0
  11. citrascope/logging/__init__.py +2 -1
  12. citrascope/logging/_citrascope_logger.py +80 -1
  13. citrascope/logging/web_log_handler.py +74 -0
  14. citrascope/settings/citrascope_settings.py +145 -0
  15. citrascope/settings/settings_file_manager.py +126 -0
  16. citrascope/tasks/runner.py +124 -28
  17. citrascope/tasks/scope/base_telescope_task.py +25 -10
  18. citrascope/tasks/scope/static_telescope_task.py +11 -3
  19. citrascope/web/__init__.py +1 -0
  20. citrascope/web/app.py +470 -0
  21. citrascope/web/server.py +123 -0
  22. citrascope/web/static/api.js +82 -0
  23. citrascope/web/static/app.js +500 -0
  24. citrascope/web/static/config.js +362 -0
  25. citrascope/web/static/img/citra.png +0 -0
  26. citrascope/web/static/img/favicon.png +0 -0
  27. citrascope/web/static/style.css +120 -0
  28. citrascope/web/static/websocket.js +127 -0
  29. citrascope/web/templates/dashboard.html +354 -0
  30. {citrascope-0.1.0.dist-info → citrascope-0.3.0.dist-info}/METADATA +68 -36
  31. citrascope-0.3.0.dist-info/RECORD +38 -0
  32. {citrascope-0.1.0.dist-info → citrascope-0.3.0.dist-info}/WHEEL +1 -1
  33. citrascope/settings/_citrascope_settings.py +0 -42
  34. citrascope-0.1.0.dist-info/RECORD +0 -21
  35. {citrascope-0.1.0.dist-info → citrascope-0.3.0.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,593 @@
1
+ import base64
2
+ import json
3
+ import logging
4
+ import os
5
+ import sys
6
+ import time
7
+ from pathlib import Path
8
+
9
+ import platformdirs
10
+ import requests
11
+
12
+ from citrascope.hardware.abstract_astro_hardware_adapter import (
13
+ AbstractAstroHardwareAdapter,
14
+ ObservationStrategy,
15
+ SettingSchemaEntry,
16
+ )
17
+ from citrascope.settings.citrascope_settings import APP_AUTHOR, APP_NAME
18
+
19
+
20
+ class NinaAdvancedHttpAdapter(AbstractAstroHardwareAdapter):
21
+ """HTTP adapter for controlling astronomical equipment through N.I.N.A. (Nighttime Imaging 'N' Astronomy) Advanced API.
22
+ https://bump.sh/christian-photo/doc/advanced-api/"""
23
+
24
+ DEFAULT_FOCUS_POSITION = 9000
25
+ CAM_URL = "/equipment/camera/"
26
+ FILTERWHEEL_URL = "/equipment/filterwheel/"
27
+ FOCUSER_URL = "/equipment/focuser/"
28
+ MOUNT_URL = "/equipment/mount/"
29
+ SAFETYMON_URL = "/equipment/safetymonitor/"
30
+ SEQUENCE_URL = "/sequence/"
31
+
32
+ def __init__(self, logger: logging.Logger, images_dir: Path, **kwargs):
33
+ super().__init__(images_dir=images_dir)
34
+ self.logger: logging.Logger = logger
35
+ self._data_dir = Path(platformdirs.user_data_dir(APP_NAME, appauthor=APP_AUTHOR))
36
+ self._focus_positions_file = self._data_dir / "nina_focus_positions.json"
37
+ self.nina_api_path = kwargs.get("nina_api_path", "http://nina:1888/v2/api")
38
+ self.bypass_autofocus = kwargs.get("bypass_autofocus", False)
39
+
40
+ self.filter_map = {}
41
+ self._load_focus_positions()
42
+
43
+ def _load_focus_positions(self):
44
+ """Load focus positions from file if available."""
45
+ try:
46
+ if self._focus_positions_file.exists():
47
+ with open(self._focus_positions_file, "r") as f:
48
+ focus_data = json.load(f)
49
+ if self.logger:
50
+ self.logger.info(f"Loaded focus positions from {self._focus_positions_file}")
51
+ self._focus_positions_cache = focus_data
52
+ else:
53
+ self._focus_positions_cache = {}
54
+ except Exception as e:
55
+ if self.logger:
56
+ self.logger.warning(f"Could not load focus positions file: {e}")
57
+ self._focus_positions_cache = {}
58
+
59
+ def _save_focus_positions(self):
60
+ """Save current filter_map focus positions to file."""
61
+ try:
62
+ # Ensure data directory exists
63
+ self._data_dir.mkdir(parents=True, exist_ok=True)
64
+
65
+ focus_data = {
66
+ str(fid): {"name": fdata["name"], "focus_position": fdata["focus_position"]}
67
+ for fid, fdata in self.filter_map.items()
68
+ }
69
+ with open(self._focus_positions_file, "w") as f:
70
+ json.dump(focus_data, f, indent=2)
71
+ if self.logger:
72
+ self.logger.info(f"Saved focus positions to {self._focus_positions_file}")
73
+ except Exception as e:
74
+ if self.logger:
75
+ self.logger.warning(f"Could not save focus positions file: {e}")
76
+
77
+ @classmethod
78
+ def get_settings_schema(cls) -> list[SettingSchemaEntry]:
79
+ """
80
+ Return a schema describing configurable settings for the NINA Advanced HTTP adapter.
81
+ """
82
+ return [
83
+ {
84
+ "name": "nina_api_path",
85
+ "friendly_name": "N.I.N.A. API URL",
86
+ "type": "str",
87
+ "default": "http://nina:1888/v2/api",
88
+ "description": "Base URL for the NINA Advanced HTTP API",
89
+ "required": True,
90
+ "placeholder": "http://localhost:1888/v2/api",
91
+ "pattern": r"^https?://.*",
92
+ },
93
+ {
94
+ "name": "bypass_autofocus",
95
+ "friendly_name": "Bypass Autofocus",
96
+ "type": "bool",
97
+ "default": False,
98
+ "description": "Skip autofocus routine when initializing, will use cached focus positions if available",
99
+ "required": False,
100
+ },
101
+ ]
102
+
103
+ def do_autofocus(self):
104
+ self.logger.info("Performing autofocus routine ...")
105
+ # move telescope to bright star and start autofocus
106
+ # Mirach ra=(1+9/60.+47.45/3600.)*15 dec=(35+37/60.+11.1/3600.)
107
+ ra = (1 + 9 / 60.0 + 47.45 / 3600.0) * 15
108
+ dec = 35 + 37 / 60.0 + 11.1 / 3600.0
109
+
110
+ self.logger.info("Slewing to Mirach ...")
111
+ mount_status = requests.get(
112
+ self.nina_api_path + self.MOUNT_URL + "slew?ra=" + str(ra) + "&dec=" + str(dec)
113
+ ).json()
114
+ self.logger.info(f"Mount {mount_status['Response']}")
115
+
116
+ # wait for slew to complete
117
+ while self.telescope_is_moving():
118
+ self.logger.info("Waiting for mount to finish slewing...")
119
+ time.sleep(5)
120
+
121
+ for id, filter in self.filter_map.items():
122
+ self.logger.info(f"Focusing Filter ID: {id}, Name: {filter['name']}")
123
+ focus_value = self._auto_focus_one_filter(id, filter["name"])
124
+ self.filter_map[id]["focus_position"] = focus_value
125
+
126
+ # Save focus positions after autofocus
127
+ self._save_focus_positions()
128
+
129
+ # autofocus routine
130
+ def _auto_focus_one_filter(self, filter_id, filter_name) -> int:
131
+
132
+ # change to the requested filter
133
+ correct_filter_in_place = False
134
+ while not correct_filter_in_place:
135
+ requests.get(self.nina_api_path + self.FILTERWHEEL_URL + "change-filter?filterId=" + str(filter_id))
136
+ filterwheel_status = requests.get(self.nina_api_path + self.FILTERWHEEL_URL + "info").json()
137
+ current_filter_id = filterwheel_status["Response"]["SelectedFilter"]["Id"]
138
+ if current_filter_id == filter_id:
139
+ correct_filter_in_place = True
140
+ else:
141
+ self.logger.info(f"Waiting for filterwheel to change to filter ID {filter_id} ...")
142
+ time.sleep(5)
143
+
144
+ # move to starting focus position
145
+ self.logger.info("Moving focus to autofocus starting position ...")
146
+ starting_focus_position = self.DEFAULT_FOCUS_POSITION
147
+ is_in_starting_position = False
148
+ while not is_in_starting_position:
149
+ focuser_status = requests.get(
150
+ self.nina_api_path + self.FOCUSER_URL + "move?position=" + str(starting_focus_position)
151
+ ).json()
152
+ focuser_status = requests.get(self.nina_api_path + self.FOCUSER_URL + "info").json()
153
+ if int(focuser_status["Response"]["Position"]) == starting_focus_position:
154
+ is_in_starting_position = True
155
+ else:
156
+ self.logger.info("Waiting for focuser to reach starting position ...")
157
+ time.sleep(5)
158
+
159
+ # start autofocus
160
+ self.logger.info("Starting autofocus ...")
161
+ focuser_status = requests.get(self.nina_api_path + self.FOCUSER_URL + "auto-focus").json()
162
+ self.logger.info(f"Focuser {focuser_status['Response']}")
163
+
164
+ last_completed_autofocus = requests.get(self.nina_api_path + self.FOCUSER_URL + "last-af").json()
165
+
166
+ if not last_completed_autofocus.get("Success"):
167
+ self.logger.error(f"Failed to start autofocus: {last_completed_autofocus.get('Error')}")
168
+ self.logger.warning("Using default focus position")
169
+ return starting_focus_position
170
+
171
+ while (
172
+ last_completed_autofocus["Response"]["Filter"] != filter_name
173
+ or last_completed_autofocus["Response"]["InitialFocusPoint"]["Position"] != starting_focus_position
174
+ ):
175
+ self.logger.info("Waiting autofocus")
176
+ last_completed_autofocus = requests.get(self.nina_api_path + self.FOCUSER_URL + "last-af").json()
177
+ time.sleep(15)
178
+
179
+ autofocused_position = last_completed_autofocus["Response"]["CalculatedFocusPoint"]["Position"]
180
+ autofocused_value = last_completed_autofocus["Response"]["CalculatedFocusPoint"]["Value"]
181
+
182
+ self.logger.info(
183
+ f"Autofocus complete for filter {filter_name}: Position {autofocused_position}, HFR {autofocused_value}"
184
+ )
185
+ return autofocused_position
186
+
187
+ def _do_point_telescope(self, ra: float, dec: float):
188
+ self.logger.info(f"Slewing to RA: {ra}, Dec: {dec}")
189
+ slew_response = requests.get(f"{self.nina_api_path}{self.MOUNT_URL}slew?ra={ra}&dec={dec}").json()
190
+
191
+ if slew_response.get("Success"):
192
+ self.logger.info(f"Mount slew initiated: {slew_response['Response']}")
193
+ return True
194
+ else:
195
+ self.logger.error(f"Failed to slew mount: {slew_response.get('Error')}")
196
+ return False
197
+
198
+ def connect(self) -> bool:
199
+ try:
200
+ # start connection to all equipments
201
+ self.logger.info("Connecting camera ...")
202
+ cam_status = requests.get(self.nina_api_path + self.CAM_URL + "connect").json()
203
+ if not cam_status["Success"]:
204
+ self.logger.error(f"Failed to connect camera: {cam_status.get('Error')}")
205
+ return False
206
+ self.logger.info(f"Camera Connected!")
207
+
208
+ self.logger.info("Starting camera cooling ...")
209
+ cool_status = requests.get(self.nina_api_path + self.CAM_URL + "cool").json()
210
+ if not cool_status["Success"]:
211
+ self.logger.warning(f"Failed to start camera cooling: {cool_status.get('Error')}")
212
+ else:
213
+ self.logger.info("Cooler started!")
214
+
215
+ self.logger.info("Connecting filterwheel ...")
216
+ filterwheel_status = requests.get(self.nina_api_path + self.FILTERWHEEL_URL + "connect").json()
217
+ if not filterwheel_status["Success"]:
218
+ self.logger.warning(f"Failed to connect filterwheel: {filterwheel_status.get('Error')}")
219
+ else:
220
+ self.logger.info(f"Filterwheel Connected!")
221
+
222
+ self.logger.info("Connecting focuser ...")
223
+ focuser_status = requests.get(self.nina_api_path + self.FOCUSER_URL + "connect").json()
224
+ if not focuser_status["Success"]:
225
+ self.logger.warning(f"Failed to connect focuser: {focuser_status.get('Error')}")
226
+ else:
227
+ self.logger.info(f"Focuser Connected!")
228
+
229
+ self.logger.info("Connecting mount ...")
230
+ mount_status = requests.get(self.nina_api_path + self.MOUNT_URL + "connect").json()
231
+ if not mount_status["Success"]:
232
+ self.logger.error(f"Failed to connect mount: {mount_status.get('Error')}")
233
+ return False
234
+ self.logger.info(f"Mount Connected!")
235
+
236
+ self.logger.info("Unparking mount ...")
237
+ mount_status = requests.get(self.nina_api_path + self.MOUNT_URL + "unpark").json()
238
+ if not mount_status["Success"]:
239
+ self.logger.error(f"Failed to unpark mount: {mount_status.get('Error')}")
240
+ return False
241
+ self.logger.info(f"Mount Unparked!")
242
+
243
+ # make a map of available filters and their focus positions
244
+ self.discover_filters()
245
+ if not self.bypass_autofocus:
246
+ self.do_autofocus()
247
+ else:
248
+ self.logger.info("Bypassing autofocus routine as requested")
249
+
250
+ return True
251
+ except Exception as e:
252
+ self.logger.error(f"Failed to connect to NINA Advanced API: {e}")
253
+ return False
254
+
255
+ def discover_filters(self):
256
+ self.logger.info("Discovering filters ...")
257
+ filterwheel_info = requests.get(self.nina_api_path + self.FILTERWHEEL_URL + "info").json()
258
+ if not filterwheel_info.get("Success"):
259
+ self.logger.error(f"Failed to get filterwheel info: {filterwheel_info.get('Error')}")
260
+ raise RuntimeError("Failed to get filterwheel info")
261
+
262
+ filters = filterwheel_info["Response"]["AvailableFilters"]
263
+ for filter in filters:
264
+ filter_id = filter["Id"]
265
+ filter_name = filter["Name"]
266
+ # Try to load focus position from cache, fallback to default
267
+ focus_position = self._focus_positions_cache.get(str(filter_id), {}).get(
268
+ "focus_position", self.DEFAULT_FOCUS_POSITION
269
+ )
270
+ self.filter_map[filter_id] = {"name": filter_name, "focus_position": focus_position}
271
+ self.logger.info(f"Discovered filter: {filter_name} with ID: {filter_id}, focus position: {focus_position}")
272
+
273
+ def disconnect(self):
274
+ pass
275
+
276
+ def is_telescope_connected(self) -> bool:
277
+ """Check if telescope is connected and responsive."""
278
+ try:
279
+ mount_info = requests.get(f"{self.nina_api_path}{self.MOUNT_URL}info", timeout=2).json()
280
+ return mount_info.get("Success", False) and mount_info.get("Response", {}).get("Connected", False)
281
+ except Exception:
282
+ return False
283
+
284
+ def is_camera_connected(self) -> bool:
285
+ """Check if camera is connected and responsive."""
286
+ try:
287
+ cam_info = requests.get(f"{self.nina_api_path}{self.CAM_URL}info", timeout=2).json()
288
+ return cam_info.get("Success", False) and cam_info.get("Response", {}).get("Connected", False)
289
+ except Exception:
290
+ return False
291
+
292
+ def list_devices(self) -> list[str]:
293
+ return []
294
+
295
+ def select_telescope(self, device_name: str) -> bool:
296
+ return True
297
+
298
+ def get_telescope_direction(self) -> tuple[float, float]:
299
+ mount_info = requests.get(self.nina_api_path + self.MOUNT_URL + "info").json()
300
+ if mount_info.get("Success"):
301
+ ra_degrees = mount_info["Response"]["Coordinates"]["RADegrees"]
302
+ dec_degrees = mount_info["Response"]["Coordinates"]["Dec"]
303
+ return (ra_degrees, dec_degrees)
304
+ else:
305
+ self.logger.error(f"Failed to get telescope direction: {mount_info.get('Error')}")
306
+ raise RuntimeError(f"Failed to get mount info: {mount_info.get('Error')}")
307
+
308
+ def telescope_is_moving(self) -> bool:
309
+ mount_info = requests.get(self.nina_api_path + self.MOUNT_URL + "info").json()
310
+ if mount_info.get("Success"):
311
+ return mount_info["Response"]["Slewing"]
312
+ else:
313
+ self.logger.error(f"Failed to get telescope status: {mount_info.get('Error')}")
314
+ return False
315
+
316
+ def select_camera(self, device_name: str) -> bool:
317
+ return True
318
+
319
+ def take_image(self, task_id: str, exposure_duration_seconds=1) -> str:
320
+ raise NotImplementedError
321
+
322
+ def set_custom_tracking_rate(self, ra_rate: float, dec_rate: float):
323
+ pass # TODO: make real
324
+
325
+ def get_tracking_rate(self) -> tuple[float, float]:
326
+ return (0, 0) # TODO: make real
327
+
328
+ def perform_alignment(self, target_ra: float, target_dec: float) -> bool:
329
+ return True # TODO: make real
330
+
331
+ def _get_sequence_template(self) -> str:
332
+ """Load the sequence template as a string for placeholder replacement."""
333
+ template_path = os.path.join(os.path.dirname(__file__), "nina_adv_http_survey_template.json")
334
+ with open(template_path, "r") as f:
335
+ return f.read()
336
+
337
+ def get_observation_strategy(self) -> ObservationStrategy:
338
+ return ObservationStrategy.SEQUENCE_TO_CONTROLLER
339
+
340
+ def _find_by_id(self, data, target_id):
341
+ """Recursively search for an item with a specific $id in the JSON structure.
342
+
343
+ Args:
344
+ data: The JSON data structure to search (dict, list, or primitive)
345
+ target_id: The $id value to search for (as string)
346
+
347
+ Returns:
348
+ The item with the matching $id, or None if not found
349
+ """
350
+ if isinstance(data, dict):
351
+ if data.get("$id") == target_id:
352
+ return data
353
+ for value in data.values():
354
+ result = self._find_by_id(value, target_id)
355
+ if result is not None:
356
+ return result
357
+ elif isinstance(data, list):
358
+ for item in data:
359
+ result = self._find_by_id(item, target_id)
360
+ if result is not None:
361
+ return result
362
+ return None
363
+
364
+ def _get_max_id(self, data):
365
+ """Recursively find the maximum $id value in the JSON structure.
366
+
367
+ Args:
368
+ data: The JSON data structure to search (dict, list, or primitive)
369
+
370
+ Returns:
371
+ The maximum numeric $id value found, or 0 if none found
372
+ """
373
+ max_id = 0
374
+ if isinstance(data, dict):
375
+ if "$id" in data:
376
+ try:
377
+ max_id = max(max_id, int(data["$id"]))
378
+ except (ValueError, TypeError):
379
+ pass
380
+ for value in data.values():
381
+ max_id = max(max_id, self._get_max_id(value))
382
+ elif isinstance(data, list):
383
+ for item in data:
384
+ max_id = max(max_id, self._get_max_id(item))
385
+ return max_id
386
+
387
+ def _update_all_ids(self, data, id_counter):
388
+ """Recursively update all $id values in a data structure.
389
+
390
+ Args:
391
+ data: The JSON data structure to update (dict, list, or primitive)
392
+ id_counter: A list with a single integer element [current_id] that gets incremented
393
+
394
+ Returns:
395
+ None (modifies data in place)
396
+ """
397
+ if isinstance(data, dict):
398
+ if "$id" in data:
399
+ data["$id"] = str(id_counter[0])
400
+ id_counter[0] += 1
401
+ for value in data.values():
402
+ self._update_all_ids(value, id_counter)
403
+ elif isinstance(data, list):
404
+ for item in data:
405
+ self._update_all_ids(item, id_counter)
406
+
407
+ def perform_observation_sequence(self, task_id, satellite_data) -> str | list[str]:
408
+ """Create and execute a NINA sequence for the given satellite.
409
+
410
+ Args:
411
+ task_id: Unique identifier for this observation task
412
+ satellite_data: Satellite data including TLE information
413
+
414
+ Returns:
415
+ str: Path to the captured image
416
+ """
417
+ elset = satellite_data["most_recent_elset"]
418
+
419
+ # Load template as JSON
420
+ template_str = self._get_sequence_template()
421
+ sequence_json = json.loads(template_str)
422
+
423
+ nina_sequence_name = f"Citra Target: {satellite_data['name']}, Task: {task_id}"
424
+
425
+ # Replace basic placeholders
426
+ tle_data = f"{elset['tle'][0]}\n{elset['tle'][1]}"
427
+ sequence_json["Name"] = nina_sequence_name
428
+
429
+ # Navigate to the TLE container (ID 20 in the template)
430
+ target_container = self._find_by_id(sequence_json, "20")
431
+ if not target_container:
432
+ raise RuntimeError("Could not find TLE container (ID 20) in sequence template")
433
+
434
+ target_container["TLEData"] = tle_data
435
+ target_container["Name"] = satellite_data["name"]
436
+ target_container["Target"]["TargetName"] = satellite_data["name"]
437
+
438
+ # Find the TLE control item and update it
439
+ tle_items = target_container["Items"]["$values"]
440
+ for item in tle_items:
441
+ if item.get("$type") == "DaleGhent.NINA.PlaneWaveTools.TLE.TLEControl, PlaneWave Tools":
442
+ item["Line1"] = elset["tle"][0]
443
+ item["Line2"] = elset["tle"][1]
444
+ break
445
+
446
+ # Find the template triplet (filter/focus/expose) - should be items 1, 2, 3
447
+ # (item 0 is TLE control)
448
+ template_triplet = tle_items[1:4] # SwitchFilter, MoveFocuserAbsolute, TakeExposure
449
+
450
+ # Clear the items list and rebuild with TLE control + triplets for each filter
451
+ new_items = [tle_items[0]] # Keep TLE control as first item
452
+
453
+ # Generate triplet for each discovered filter
454
+ # Find the maximum ID in use and start after it to avoid collisions
455
+ base_id = self._get_max_id(sequence_json) + 1
456
+ self.logger.debug(f"Starting dynamic ID generation at {base_id}")
457
+
458
+ id_counter = [base_id] # Use list so it can be modified in nested function
459
+
460
+ for filter_id, filter_info in self.filter_map.items():
461
+ filter_name = filter_info["name"]
462
+ focus_position = filter_info["focus_position"]
463
+
464
+ # Create a deep copy of the triplet and update ALL nested IDs
465
+ filter_triplet = json.loads(json.dumps(template_triplet))
466
+ self._update_all_ids(filter_triplet, id_counter)
467
+
468
+ # Update filter switch (first item in triplet)
469
+ filter_triplet[0]["Filter"]["_name"] = filter_name
470
+ filter_triplet[0]["Filter"]["_position"] = filter_id
471
+
472
+ # Update focus position (second item in triplet)
473
+ filter_triplet[1]["Position"] = focus_position
474
+
475
+ # Exposure settings (third item) are already set from template
476
+
477
+ # Add this triplet to the sequence
478
+ new_items.extend(filter_triplet)
479
+
480
+ self.logger.info(f"Added filter {filter_name} (ID: {filter_id}) with focus position {focus_position}")
481
+
482
+ # Update the items list
483
+ tle_items.clear()
484
+ tle_items.extend(new_items)
485
+
486
+ # Convert back to JSON string
487
+ template_str = json.dumps(sequence_json, indent=2)
488
+
489
+ # POST the sequence
490
+
491
+ self.logger.info(f"Posting NINA sequence")
492
+ post_response = requests.post(f"{self.nina_api_path}{self.SEQUENCE_URL}load", json=sequence_json).json()
493
+ if not post_response.get("Success"):
494
+ self.logger.error(f"Failed to post sequence: {post_response.get('Error')}")
495
+ raise RuntimeError("Failed to post NINA sequence")
496
+
497
+ self.logger.info(f"Loaded sequence to NINA, starting sequence...")
498
+
499
+ start_response = requests.get(
500
+ f"{self.nina_api_path}{self.SEQUENCE_URL}start?skipValidation=true"
501
+ ).json() # TODO: try and fix validation issues
502
+ if not start_response.get("Success"):
503
+ self.logger.error(f"Failed to start sequence: {start_response.get('Error')}")
504
+ raise RuntimeError("Failed to start NINA sequence")
505
+
506
+ timeout_minutes = 60
507
+ poll_interval_seconds = 10
508
+ elapsed_time = 0
509
+ status_response = None
510
+ while elapsed_time < timeout_minutes * 60:
511
+ status_response = requests.get(f"{self.nina_api_path}{self.SEQUENCE_URL}json").json()
512
+
513
+ start_status = status_response["Response"][1][
514
+ "Status"
515
+ ] # these are also based on the hardcoded template sections for now...
516
+ targets_status = status_response["Response"][2]["Status"]
517
+ end_status = status_response["Response"][3]["Status"]
518
+ self.logger.debug(f"Sequence status - Start: {start_status}, Targets: {targets_status}, End: {end_status}")
519
+
520
+ if start_status == "FINISHED" and targets_status == "FINISHED" and end_status == "FINISHED":
521
+ self.logger.info(f"NINA sequence completed")
522
+ break
523
+
524
+ self.logger.info(f"NINA sequence still running, waiting {poll_interval_seconds} seconds...")
525
+ time.sleep(poll_interval_seconds)
526
+ elapsed_time += poll_interval_seconds
527
+ else:
528
+ self.logger.error(f"NINA sequence did not complete within timeout of {timeout_minutes} minutes")
529
+ raise RuntimeError("NINA sequence timeout")
530
+
531
+ # get a list of images taken in the sequence
532
+ self.logger.info(f"Retrieving list of images taken in sequence...")
533
+ images_response = requests.get(f"{self.nina_api_path}/image-history?all=true").json()
534
+ if not images_response.get("Success"):
535
+ self.logger.error(f"Failed to get images list: {images_response.get('Error')}")
536
+ raise RuntimeError("Failed to get images list from NINA")
537
+
538
+ images_to_download = []
539
+ expected_image_count = len(self.filter_map) # One image per filter
540
+ images_found = len(images_response["Response"])
541
+ self.logger.info(
542
+ f"Found {images_found} images in NINA image history, considering the last {expected_image_count}"
543
+ )
544
+ start_index = max(0, images_found - expected_image_count)
545
+ end_index = images_found
546
+ if images_found < expected_image_count:
547
+ self.logger.warning(f"Fewer images found ({images_found}) than expected ({expected_image_count})")
548
+ for i in range(start_index, end_index):
549
+ possible_image = images_response["Response"][i]
550
+ if "Filename" not in possible_image:
551
+ self.logger.warning(f"Image {i} has no filename in response, skipping")
552
+ continue
553
+
554
+ if task_id in possible_image["Filename"]:
555
+ self.logger.info(f"Image {i} {possible_image['Filename']} matches task id")
556
+ images_to_download.append(i)
557
+ else:
558
+ self.logger.warning(
559
+ f"Image {i} {possible_image['Filename']} does not match task id, skipping. Please make sure NINA is configured to include Sequence Title in image filenames under Options > Imaging > Image File Pattern."
560
+ )
561
+
562
+ # Get the most recent image from NINA (index 0) in raw FITS format
563
+ filepaths = []
564
+ for image_index in images_to_download:
565
+ self.logger.debug(f"Retrieving image from NINA...")
566
+ image_response = requests.get(
567
+ f"{self.nina_api_path}/image/{image_index}",
568
+ params={"raw_fits": "true"},
569
+ )
570
+
571
+ if image_response.status_code != 200:
572
+ self.logger.error(f"Failed to retrieve image: HTTP {image_response.status_code}")
573
+ raise RuntimeError("Failed to retrieve image from NINA")
574
+
575
+ image_data = image_response.json()
576
+ if not image_data.get("Success"):
577
+ self.logger.error(f"Failed to get image: {image_data.get('Error')}")
578
+ raise RuntimeError(f"Failed to get image from NINA: {image_data.get('Error')}")
579
+
580
+ # Decode base64 FITS data and save to file
581
+ fits_base64 = image_data["Response"]
582
+ fits_bytes = base64.b64decode(fits_base64)
583
+
584
+ # Save the FITS file
585
+ filepath = str(self.images_dir / f"citra_task_{task_id}_image_{image_index}.fits")
586
+ filepaths.append(filepath)
587
+
588
+ with open(filepath, "wb") as f:
589
+ f.write(fits_bytes)
590
+
591
+ self.logger.info(f"Saved FITS image to {filepath}")
592
+
593
+ return filepaths