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
@@ -1,3 +1,4 @@
1
+ import logging
1
2
  import os
2
3
  import time
3
4
  from pathlib import Path
@@ -10,7 +11,11 @@ from pixelemon.optics._base_optical_assembly import BaseOpticalAssembly
10
11
  from pixelemon.sensors import IMX174
11
12
  from pixelemon.sensors._base_sensor import BaseSensor
12
13
 
13
- from citrascope.hardware.abstract_astro_hardware_adapter import AbstractAstroHardwareAdapter
14
+ from citrascope.hardware.abstract_astro_hardware_adapter import (
15
+ AbstractAstroHardwareAdapter,
16
+ ObservationStrategy,
17
+ SettingSchemaEntry,
18
+ )
14
19
 
15
20
 
16
21
  # The IndiClient class which inherits from the module PyIndi.BaseClient class
@@ -28,14 +33,64 @@ class IndiAdapter(PyIndi.BaseClient, AbstractAstroHardwareAdapter):
28
33
  _alignment_offset_ra: float = 0.0
29
34
  _alignment_offset_dec: float = 0.0
30
35
 
31
- def __init__(self, CITRA_LOGGER, host: str, port: int):
32
- super(IndiAdapter, self).__init__()
33
- self.logger = CITRA_LOGGER
34
- self.logger.debug("creating an instance of IndiClient")
35
- self.host = host
36
- self.port = port
37
-
38
- TetraSolver.high_memory()
36
+ def __init__(self, logger: logging.Logger, images_dir: Path, **kwargs):
37
+ PyIndi.BaseClient.__init__(self)
38
+ AbstractAstroHardwareAdapter.__init__(self, images_dir=images_dir)
39
+ self.logger: logging.Logger = logger
40
+ if self.logger:
41
+ self.logger.debug("creating an instance of IndiClient")
42
+ self.host = kwargs.get("host", "localhost")
43
+ self.port = int(kwargs.get("port", 7624))
44
+ self.telescope_name = kwargs.get("telescope_name", "")
45
+ self.camera_name = kwargs.get("camera_name", "")
46
+
47
+ # TetraSolver.high_memory()
48
+
49
+ @classmethod
50
+ def get_settings_schema(cls) -> list[SettingSchemaEntry]:
51
+ """
52
+ Return a schema describing configurable settings for the INDI adapter.
53
+ """
54
+ return [
55
+ {
56
+ "name": "host",
57
+ "friendly_name": "INDI Server Host",
58
+ "type": "str",
59
+ "default": "localhost",
60
+ "description": "INDI server hostname or IP address",
61
+ "required": True,
62
+ "placeholder": "localhost or 192.168.1.100",
63
+ },
64
+ {
65
+ "name": "port",
66
+ "friendly_name": "INDI Server Port",
67
+ "type": "int",
68
+ "default": 7624,
69
+ "description": "INDI server port",
70
+ "required": True,
71
+ "placeholder": "7624",
72
+ "min": 1,
73
+ "max": 65535,
74
+ },
75
+ {
76
+ "name": "telescope_name",
77
+ "friendly_name": "Telescope Device Name",
78
+ "type": "str",
79
+ "default": "",
80
+ "description": "Name of the telescope device (leave empty to auto-detect)",
81
+ "required": False,
82
+ "placeholder": "Telescope Simulator",
83
+ },
84
+ {
85
+ "name": "camera_name",
86
+ "friendly_name": "Camera Device Name",
87
+ "type": "str",
88
+ "default": "",
89
+ "description": "Name of the camera device (leave empty to auto-detect)",
90
+ "required": False,
91
+ "placeholder": "CCD Simulator",
92
+ },
93
+ ]
39
94
 
40
95
  def newDevice(self, d):
41
96
  """Emmited when a new device is created from INDI server."""
@@ -80,8 +135,7 @@ class IndiAdapter(PyIndi.BaseClient, AbstractAstroHardwareAdapter):
80
135
 
81
136
  # if there's a task underway, save the image to a file
82
137
  if self._current_task_id != "":
83
- os.makedirs("images", exist_ok=True)
84
- self._last_saved_filename = f"images/citra_task_{self._current_task_id}_image.fits"
138
+ self._last_saved_filename = str(self.images_dir / f"citra_task_{self._current_task_id}_image.fits")
85
139
  for b in blobProperty:
86
140
  with open(self._last_saved_filename, "wb") as f:
87
141
  f.write(b.getblobdata())
@@ -120,7 +174,57 @@ class IndiAdapter(PyIndi.BaseClient, AbstractAstroHardwareAdapter):
120
174
 
121
175
  def connect(self) -> bool:
122
176
  self.setServer(self.host, self.port)
123
- return self.connectServer()
177
+ connected = self.connectServer()
178
+
179
+ if not connected:
180
+ self.logger.error("Failed to connect to INDI server")
181
+ return False
182
+
183
+ time.sleep(1) # Give server time to enumerate devices
184
+
185
+ # Auto-select telescope if name provided, or auto-detect first telescope
186
+ if self.telescope_name:
187
+ device_list = self.list_devices()
188
+ if self.telescope_name not in device_list:
189
+ self.logger.error(f"Could not find configured telescope ({self.telescope_name}) on INDI server.")
190
+ return False
191
+
192
+ if not self.select_telescope(self.telescope_name):
193
+ self.logger.error(f"Failed to select telescope: {self.telescope_name}")
194
+ return False
195
+ self.logger.info(f"Found and connected to telescope: {self.telescope_name}")
196
+ else:
197
+ # Auto-detect: try to find and select first telescope device
198
+ telescope_found = self._auto_detect_telescope()
199
+ if not telescope_found:
200
+ self.logger.warning(
201
+ "No telescope_name configured and auto-detection failed. "
202
+ "Please configure telescope_name in adapter_settings."
203
+ )
204
+ # Don't return False here - allow operation to continue for cameras-only setups
205
+
206
+ # Auto-select camera if name provided, or auto-detect first camera
207
+ if self.camera_name:
208
+ device_list = self.list_devices()
209
+ if self.camera_name not in device_list:
210
+ self.logger.error(f"Could not find configured camera ({self.camera_name}) on INDI server.")
211
+ return False
212
+
213
+ if not self.select_camera(self.camera_name):
214
+ self.logger.error(f"Failed to select camera: {self.camera_name}")
215
+ return False
216
+ self.logger.info(f"Found and connected to camera: {self.camera_name}")
217
+ else:
218
+ # Auto-detect: try to find and select first camera device
219
+ camera_found = self._auto_detect_camera()
220
+ if not camera_found:
221
+ self.logger.warning(
222
+ "No camera_name configured and auto-detection failed. "
223
+ "Please configure camera_name in adapter_settings."
224
+ )
225
+ # Don't return False here - allow operation to continue
226
+
227
+ return True
124
228
 
125
229
  def list_devices(self):
126
230
  names = []
@@ -128,33 +232,203 @@ class IndiAdapter(PyIndi.BaseClient, AbstractAstroHardwareAdapter):
128
232
  names.append(device.getDeviceName())
129
233
  return names
130
234
 
235
+ def _auto_detect_telescope(self) -> bool:
236
+ """Auto-detect and select the first available telescope device.
237
+
238
+ Returns:
239
+ True if a telescope was found and selected, False otherwise.
240
+ """
241
+ devices = self.getDevices()
242
+ for device in devices:
243
+ device_name = device.getDeviceName()
244
+ # Check if device has EQUATORIAL_EOD_COORD property (indicates it's a telescope)
245
+ # We need to wait a bit for properties to be available
246
+ for attempt in range(5):
247
+ telescope_radec = device.getNumber("EQUATORIAL_EOD_COORD")
248
+ if telescope_radec:
249
+ self.logger.info(f"Auto-detected telescope device: {device_name}")
250
+ if self.select_telescope(device_name):
251
+ return True
252
+ break
253
+ time.sleep(0.5)
254
+
255
+ self.logger.debug("No telescope device auto-detected")
256
+ return False
257
+
258
+ def _auto_detect_camera(self) -> bool:
259
+ """Auto-detect and select the first available camera device.
260
+
261
+ Returns:
262
+ True if a camera was found and selected, False otherwise.
263
+ """
264
+ devices = self.getDevices()
265
+ for device in devices:
266
+ device_name = device.getDeviceName()
267
+ # Check if device has CCD_EXPOSURE property (indicates it's a camera)
268
+ # We need to wait a bit for properties to be available
269
+ for attempt in range(5):
270
+ ccd_exposure = device.getNumber("CCD_EXPOSURE")
271
+ if ccd_exposure:
272
+ self.logger.info(f"Auto-detected camera device: {device_name}")
273
+ if self.select_camera(device_name):
274
+ return True
275
+ break
276
+ time.sleep(0.5)
277
+
278
+ self.logger.debug("No camera device auto-detected")
279
+ return False
280
+
131
281
  def select_telescope(self, device_name: str) -> bool:
282
+ """Select a specific telescope by name and wait for it to be ready.
283
+
284
+ Args:
285
+ device_name: Name of the telescope device to select
286
+
287
+ Returns:
288
+ True if telescope was found and is ready, False otherwise
289
+ """
132
290
  devices = self.getDevices()
133
291
  for device in devices:
134
292
  if device.getDeviceName() == device_name:
135
293
  self.our_scope = device
136
- return True
294
+ self.logger.debug(f"Found telescope device: {device_name}")
295
+
296
+ # Connect the telescope device if not already connected
297
+ connect_prop = device.getSwitch("CONNECTION")
298
+ if connect_prop:
299
+ if not device.isConnected():
300
+ self.logger.info(f"Connecting telescope device: {device_name}")
301
+ connect_prop[0].setState(PyIndi.ISS_ON) # CONNECT
302
+ connect_prop[1].setState(PyIndi.ISS_OFF) # DISCONNECT
303
+ self.sendNewSwitch(connect_prop)
304
+ time.sleep(1) # Give device time to connect
305
+ else:
306
+ self.logger.debug(f"Telescope device {device_name} already connected")
307
+ else:
308
+ self.logger.warning(f"Telescope device {device_name} has no CONNECTION property")
309
+
310
+ # Wait for EQUATORIAL_EOD_COORD property to be available
311
+ for attempt in range(10):
312
+ telescope_radec = device.getNumber("EQUATORIAL_EOD_COORD")
313
+ if telescope_radec and len(telescope_radec) >= 2:
314
+ self.logger.info(f"Telescope {device_name} is ready (EQUATORIAL_EOD_COORD available)")
315
+ return True
316
+ self.logger.debug(f"Waiting for EQUATORIAL_EOD_COORD property... ({attempt + 1}/10)")
317
+ time.sleep(0.5)
318
+
319
+ self.logger.error(
320
+ f"Telescope {device_name} selected but EQUATORIAL_EOD_COORD property not available. "
321
+ f"Device may not be fully initialized."
322
+ )
323
+ return False
324
+
325
+ self.logger.error(f"Telescope device '{device_name}' not found in INDI device list")
137
326
  return False
138
327
 
139
328
  def disconnect(self):
140
329
  self.disconnectServer()
141
330
 
331
+ def is_telescope_connected(self) -> bool:
332
+ """Check if telescope is connected and responsive."""
333
+ if not self.isServerConnected():
334
+ return False
335
+ if not hasattr(self, "our_scope") or self.our_scope is None:
336
+ return False
337
+ # Check if we can read the telescope position (indicates it's working)
338
+ try:
339
+ telescope_radec = self.our_scope.getNumber("EQUATORIAL_EOD_COORD")
340
+ return telescope_radec is not None and len(telescope_radec) >= 2
341
+ except Exception:
342
+ return False
343
+
344
+ def is_camera_connected(self) -> bool:
345
+ """Check if camera is connected and responsive."""
346
+ if not self.isServerConnected():
347
+ return False
348
+ if not hasattr(self, "our_camera") or self.our_camera is None:
349
+ return False
350
+ # Check if camera is connected via INDI
351
+ try:
352
+ if not self.our_camera.isConnected():
353
+ return False
354
+ # Check if we can read the exposure property (indicates it's working)
355
+ ccd_exposure = self.our_camera.getNumber("CCD_EXPOSURE")
356
+ return ccd_exposure is not None
357
+ except Exception:
358
+ return False
359
+
142
360
  def _do_point_telescope(self, ra: float, dec: float):
143
361
  """Hardware-specific implementation to point the telescope to the specified RA/Dec coordinates."""
362
+ # Check if connected to INDI server first
363
+ if not self.isServerConnected():
364
+ self.logger.debug("Not connected to INDI server")
365
+ return
366
+
367
+ # Check if telescope is selected
368
+ if not hasattr(self, "our_scope") or self.our_scope is None:
369
+ self.logger.error(
370
+ "No telescope selected. Please configure 'telescope_name' in adapter_settings, "
371
+ "or ensure your INDI server has a telescope device with EQUATORIAL_EOD_COORD property."
372
+ )
373
+ return
374
+
375
+ # Get the property
144
376
  telescope_radec = self.our_scope.getNumber("EQUATORIAL_EOD_COORD")
377
+
378
+ # Check if property exists and is valid
379
+ if not telescope_radec:
380
+ self.logger.error("EQUATORIAL_EOD_COORD property not found on telescope")
381
+ return
382
+
383
+ # Check if property is ready (not busy)
384
+ if telescope_radec.getState() == PyIndi.IPS_BUSY:
385
+ self.logger.warning("Telescope is currently busy, waiting for it to be ready...")
386
+ # Could add a wait loop here if needed
387
+
388
+ # Check if property has the expected number of elements
389
+ if len(telescope_radec) < 2:
390
+ self.logger.error(f"EQUATORIAL_EOD_COORD has {len(telescope_radec)} elements, expected 2")
391
+ return
392
+
145
393
  new_ra = float(ra)
146
394
  new_dec = float(dec)
147
395
  telescope_radec[0].setValue(new_ra) # RA in hours
148
396
  telescope_radec[1].setValue(new_dec) # DEC in degrees
397
+
149
398
  try:
150
399
  self.sendNewNumber(telescope_radec)
400
+ self.logger.info(f"Sent telescope coordinates: RA={new_ra}h, DEC={new_dec}°")
151
401
  except Exception as e:
152
402
  self.logger.error(f"Error sending new RA/DEC to telescope: {e}")
153
403
  return
154
404
 
155
405
  def get_telescope_direction(self) -> tuple[float, float]:
156
406
  """Read the current telescope direction (RA degrees, DEC degrees)."""
407
+ # Check if connected to INDI server first
408
+ if not self.isServerConnected():
409
+ self.logger.debug("Not connected to INDI server")
410
+ return (0.0, 0.0)
411
+
412
+ # Check if telescope is selected
413
+ if not hasattr(self, "our_scope") or self.our_scope is None:
414
+ self.logger.error(
415
+ "No telescope selected. Please configure 'telescope_name' in adapter_settings, "
416
+ "or ensure your INDI server has a telescope device with EQUATORIAL_EOD_COORD property."
417
+ )
418
+ return (0.0, 0.0)
419
+
157
420
  telescope_radec = self.our_scope.getNumber("EQUATORIAL_EOD_COORD")
421
+
422
+ if not telescope_radec:
423
+ self.logger.error("EQUATORIAL_EOD_COORD property not found on telescope")
424
+ self.logger.error("Could not read telescope coordinates")
425
+ return (0.0, 0.0)
426
+
427
+ if len(telescope_radec) < 2:
428
+ self.logger.error(f"EQUATORIAL_EOD_COORD has {len(telescope_radec)} elements, expected 2")
429
+ self.logger.error("Could not read telescope coordinates")
430
+ return (0.0, 0.0)
431
+
158
432
  self.logger.debug(
159
433
  f"Telescope currently pointed to RA: {telescope_radec[0].value * 15.0} degrees, DEC: {telescope_radec[1].value} degrees"
160
434
  )
@@ -162,7 +436,18 @@ class IndiAdapter(PyIndi.BaseClient, AbstractAstroHardwareAdapter):
162
436
 
163
437
  def telescope_is_moving(self) -> bool:
164
438
  """Check if the telescope is currently moving."""
439
+ # Check if connected to INDI server first
440
+ if not self.isServerConnected():
441
+ return False
442
+
443
+ if not hasattr(self, "our_scope") or self.our_scope is None:
444
+ return False
445
+
165
446
  telescope_radec = self.our_scope.getNumber("EQUATORIAL_EOD_COORD")
447
+
448
+ if not telescope_radec:
449
+ return False
450
+
166
451
  return telescope_radec.getState() == PyIndi.IPS_BUSY
167
452
 
168
453
  def select_camera(self, device_name: str) -> bool:
@@ -172,17 +457,153 @@ class IndiAdapter(PyIndi.BaseClient, AbstractAstroHardwareAdapter):
172
457
  if device.getDeviceName() == device_name:
173
458
  self.our_camera = device
174
459
  self.setBLOBMode(PyIndi.B_ALSO, device_name, None)
460
+
461
+ # Connect the camera device if not already connected
462
+ connect_prop = device.getSwitch("CONNECTION")
463
+ if connect_prop:
464
+ if not device.isConnected():
465
+ self.logger.info(f"Connecting camera device: {device_name}")
466
+ self._set_switch(device, "CONNECTION", "CONNECT")
467
+ time.sleep(2) # Give device time to connect
468
+ else:
469
+ self.logger.debug(f"Camera device {device_name} already connected")
470
+
471
+ # Configure camera parameters
472
+ self._configure_camera_params()
473
+
175
474
  return True
176
475
  return False
177
476
 
477
+ def _configure_camera_params(self):
478
+ """Initialize camera parameters to match EKOS behavior."""
479
+ if not hasattr(self, "our_camera") or self.our_camera is None:
480
+ return
481
+
482
+ self._set_switch(self.our_camera, "CONNECTION", "CONNECT")
483
+ self._set_switch(self.our_camera, "CCD_FRAME_TYPE", "FRAME_LIGHT")
484
+ self._set_switch(self.our_camera, "UPLOAD_MODE", "UPLOAD_CLIENT")
485
+ self._set_switch(
486
+ self.our_camera, "CCD_TRANSFER_FORMAT", "FORMAT_FITS"
487
+ ) # No ISwitch '' in CCD Simulator.CCD_TRANSFER_FORMAT..?
488
+ self._set_switch(self.our_camera, "CCD_COMPRESSION", "INDI_DISABLED")
489
+
490
+ self._set_numbers(
491
+ self.our_camera,
492
+ "CCD_BINNING",
493
+ {
494
+ "HOR_BIN": 1,
495
+ "VER_BIN": 1,
496
+ },
497
+ )
498
+
499
+ # Get CCD_INFO to find max dimensions
500
+ ccd_info = self.our_camera.getNumber("CCD_INFO")
501
+ max_x = None
502
+ max_y = None
503
+ if ccd_info:
504
+ for item in ccd_info:
505
+ if item.getName() == "CCD_MAX_X":
506
+ max_x = item.value
507
+ elif item.getName() == "CCD_MAX_Y":
508
+ max_y = item.value
509
+
510
+ if max_x and max_y:
511
+ self._set_numbers(
512
+ self.our_camera,
513
+ "CCD_FRAME",
514
+ {
515
+ "X": 0,
516
+ "Y": 0,
517
+ "WIDTH": max_x,
518
+ "HEIGHT": max_y,
519
+ },
520
+ )
521
+
522
+ def _set_switch(self, device, property_name: str, switch_name: str) -> bool:
523
+ svp = device.getSwitch(property_name)
524
+ if svp is None:
525
+ return False
526
+
527
+ available_names = [item.getName() for item in svp]
528
+ if switch_name not in available_names:
529
+ self.logger.warning(f"INDI Switch '{property_name}' only supports {available_names}, not '{switch_name}'")
530
+ return False
531
+
532
+ # Turn everything OFF
533
+ for item in svp:
534
+ item.setState(PyIndi.ISS_OFF)
535
+
536
+ # Turn the desired one ON
537
+ matched = False
538
+ for item in svp:
539
+ item_name = item.getName()
540
+ if item_name == switch_name:
541
+ item.setState(PyIndi.ISS_ON)
542
+ matched = True
543
+ break
544
+
545
+ if not matched:
546
+ return False
547
+
548
+ # Send updated vector
549
+ result = self.sendNewSwitch(svp)
550
+ return True
551
+
552
+ def _set_numbers(self, device, property_name: str, values: dict) -> bool:
553
+ """
554
+ values: { "ELEMENT_NAME": value, ... }
555
+ """
556
+ nvp = device.getNumber(property_name)
557
+ if nvp is None:
558
+ return False
559
+
560
+ # Map element names → items
561
+ items = {item.getName(): item for item in nvp}
562
+
563
+ # Ensure all requested elements exist
564
+ for name in values.keys():
565
+ if name not in items:
566
+ return False # or raise
567
+
568
+ # Set values
569
+ for name, val in values.items():
570
+ items[name].setValue(float(val))
571
+
572
+ # Send once
573
+ result = self.sendNewNumber(nvp)
574
+ return True
575
+
178
576
  def take_image(self, task_id: str, exposure_duration_seconds=1.0):
179
577
  """Capture an image with the currently selected camera."""
180
578
 
579
+ # Check if camera is selected
580
+ if not hasattr(self, "our_camera") or self.our_camera is None:
581
+ self.logger.error("No camera selected. Call select_camera() first.")
582
+ return None
583
+
584
+ # Get the CCD_EXPOSURE property
585
+ ccd_exposure = self.our_camera.getNumber("CCD_EXPOSURE")
586
+
587
+ # Check if property exists and is valid
588
+ if not ccd_exposure:
589
+ self.logger.error("CCD_EXPOSURE property not found on camera")
590
+ return None
591
+
592
+ # Check if property has at least one element
593
+ if len(ccd_exposure) < 1:
594
+ self.logger.error(f"CCD_EXPOSURE has {len(ccd_exposure)} elements, expected at least 1")
595
+ return None
596
+
181
597
  self.logger.info(f"Taking {exposure_duration_seconds} second exposure...")
182
598
  self._current_task_id = task_id
183
- ccd_exposure = self.our_camera.getNumber("CCD_EXPOSURE")
184
- ccd_exposure[0].setValue(exposure_duration_seconds)
185
- self.sendNewNumber(ccd_exposure)
599
+
600
+ try:
601
+ ccd_exposure[0].setValue(exposure_duration_seconds)
602
+ self.sendNewNumber(ccd_exposure)
603
+ except Exception as e:
604
+ self.logger.error(f"Error sending exposure command to camera: {e}")
605
+ self._current_task_id = ""
606
+ return None
186
607
 
187
608
  while self.is_camera_busy() and self._current_task_id != "":
188
609
  self.logger.debug("Waiting for camera to finish exposure...")
@@ -194,7 +615,16 @@ class IndiAdapter(PyIndi.BaseClient, AbstractAstroHardwareAdapter):
194
615
 
195
616
  def is_camera_busy(self) -> bool:
196
617
  """Check if the camera is currently busy taking an image."""
618
+ # Check if camera is selected
619
+ if not hasattr(self, "our_camera") or self.our_camera is None:
620
+ return False
621
+
197
622
  ccd_exposure = self.our_camera.getNumber("CCD_EXPOSURE")
623
+
624
+ # Check if property exists
625
+ if not ccd_exposure:
626
+ return False
627
+
198
628
  return ccd_exposure.getState() == PyIndi.IPS_BUSY
199
629
 
200
630
  def set_custom_tracking_rate(self, ra_rate: float, dec_rate: float):
@@ -251,6 +681,10 @@ class IndiAdapter(PyIndi.BaseClient, AbstractAstroHardwareAdapter):
251
681
  # take alignment exposure
252
682
  alignment_filename = self.take_image("alignment", 5.0)
253
683
 
684
+ if alignment_filename is None:
685
+ self.logger.error("Failed to take alignment image.")
686
+ return False
687
+
254
688
  # this needs to be made configurable
255
689
  sim_ccd = BaseSensor(
256
690
  x_pixel_count=1280,
@@ -288,3 +722,9 @@ class IndiAdapter(PyIndi.BaseClient, AbstractAstroHardwareAdapter):
288
722
  except Exception as e:
289
723
  self.logger.error(f"Error during alignment: {e}")
290
724
  return False
725
+
726
+ def get_observation_strategy(self) -> ObservationStrategy:
727
+ return ObservationStrategy.MANUAL
728
+
729
+ def perform_observation_sequence(self, task_id, satellite_data) -> str:
730
+ raise NotImplementedError