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.
- citrascope/__main__.py +8 -5
- citrascope/api/abstract_api_client.py +7 -0
- citrascope/api/citra_api_client.py +30 -1
- citrascope/citra_scope_daemon.py +214 -61
- citrascope/hardware/abstract_astro_hardware_adapter.py +70 -2
- citrascope/hardware/adapter_registry.py +94 -0
- citrascope/hardware/indi_adapter.py +456 -16
- citrascope/hardware/kstars_dbus_adapter.py +179 -0
- citrascope/hardware/nina_adv_http_adapter.py +593 -0
- citrascope/hardware/nina_adv_http_survey_template.json +328 -0
- citrascope/logging/__init__.py +2 -1
- citrascope/logging/_citrascope_logger.py +80 -1
- citrascope/logging/web_log_handler.py +74 -0
- citrascope/settings/citrascope_settings.py +145 -0
- citrascope/settings/settings_file_manager.py +126 -0
- citrascope/tasks/runner.py +124 -28
- citrascope/tasks/scope/base_telescope_task.py +25 -10
- citrascope/tasks/scope/static_telescope_task.py +11 -3
- citrascope/web/__init__.py +1 -0
- citrascope/web/app.py +470 -0
- citrascope/web/server.py +123 -0
- citrascope/web/static/api.js +82 -0
- citrascope/web/static/app.js +500 -0
- citrascope/web/static/config.js +362 -0
- citrascope/web/static/img/citra.png +0 -0
- citrascope/web/static/img/favicon.png +0 -0
- citrascope/web/static/style.css +120 -0
- citrascope/web/static/websocket.js +127 -0
- citrascope/web/templates/dashboard.html +354 -0
- {citrascope-0.1.0.dist-info → citrascope-0.3.0.dist-info}/METADATA +68 -36
- citrascope-0.3.0.dist-info/RECORD +38 -0
- {citrascope-0.1.0.dist-info → citrascope-0.3.0.dist-info}/WHEEL +1 -1
- citrascope/settings/_citrascope_settings.py +0 -42
- citrascope-0.1.0.dist-info/RECORD +0 -21
- {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
|
|
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,
|
|
32
|
-
|
|
33
|
-
self
|
|
34
|
-
self.logger.
|
|
35
|
-
self.
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
|