-
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathNINAIntegration.py
More file actions
1472 lines (1303 loc) · 58 KB
/
Copy pathNINAIntegration.py
File metadata and controls
1472 lines (1303 loc) · 58 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env python3
"""
NINA Integration Module for Cosmos Collection
Provides reusable NINA Advanced API integration functionality
https://github.com/christian-photo/ninaAPI
"""
import json
import logging
import urllib.request
import urllib.parse
import urllib.error
from PySide6.QtCore import QSettings
from PySide6.QtWidgets import QMessageBox
# Set up logging
logger = logging.getLogger(__name__)
class NINAIntegration:
"""
Static class providing NINA Advanced API integration functionality.
Settings keys used:
- nina_integration_enabled (bool, default: False)
- nina_api_host (str, default: "localhost")
- nina_api_port (int, default: 1888)
"""
@staticmethod
def is_enabled():
"""
Check if NINA integration is enabled in settings.
Returns:
bool: True if NINA integration is enabled, False otherwise
"""
settings = QSettings("CosmosCollection", "CosmosCollection")
return settings.value("nina_integration_enabled", False, type=bool)
@staticmethod
def get_settings():
"""
Get NINA API host and port from QSettings.
Returns:
tuple: (host: str, port: int)
"""
settings = QSettings("CosmosCollection", "CosmosCollection")
host = settings.value("nina_api_host", "localhost", type=str)
port = settings.value("nina_api_port", 1888, type=int)
return host, port
@staticmethod
def test_connection(host, port):
"""
Test the connection to NINA's Advanced API.
Args:
host: The hostname or IP address of the NINA instance
port: The API port number
Returns:
tuple: (success: bool, message: str, version: str or None)
"""
url = f"http://{host}:{port}/v2/api/version"
try:
request = urllib.request.Request(url)
with urllib.request.urlopen(request, timeout=5) as response:
result = json.loads(response.read().decode('utf-8'))
if result.get('Success'):
version_info = result.get('Response', 'Unknown')
return True, f"Successfully connected to NINA!\n\nAPI Version: {version_info}", version_info
else:
error_msg = result.get('Error', 'Unknown error')
return False, f"NINA returned an error: {error_msg}", None
except urllib.error.URLError as e:
return False, (
f"Could not connect to NINA at {host}:{port}\n\n"
"Please ensure:\n"
"- NINA is running\n"
"- The Advanced API plugin is installed and enabled\n"
f"- The host ({host}) and port ({port}) are correct"
), None
except Exception as e:
logger.error(f"Error testing NINA connection: {e}")
return False, f"Connection test failed: {str(e)}", None
@staticmethod
def send_to_framing_assistant(ra_deg, dec_deg, target_name, parent_widget=None):
"""
Send coordinates to NINA Framing Assistant and switch to the framing tab.
Args:
ra_deg: Right Ascension in degrees
dec_deg: Declination in degrees
target_name: Name of the target (for logging)
parent_widget: Parent widget for message boxes (optional)
Returns:
bool: True if successful, False otherwise
"""
if ra_deg is None or dec_deg is None:
if parent_widget:
QMessageBox.warning(parent_widget, "Error", "Target coordinates not available")
logger.warning(f"Cannot send {target_name} to NINA: coordinates not available")
return False
host, port = NINAIntegration.get_settings()
# Build NINA API URL for setting coordinates
base_url = f"http://{host}:{port}/v2/api/framing/set-coordinates"
params = urllib.parse.urlencode({
'RAangle': ra_deg,
'DecAngle': dec_deg
})
url = f"{base_url}?{params}"
logger.debug(f"Sending {target_name} to NINA: RA={ra_deg}, Dec={dec_deg}")
try:
# Send coordinates to framing assistant
request = urllib.request.Request(url)
with urllib.request.urlopen(request, timeout=5) as response:
result = json.loads(response.read().decode('utf-8'))
if result.get('Success'):
logger.info(f"Sent {target_name} to NINA Framing Assistant")
# Switch to the Framing tab
switch_url = f"http://{host}:{port}/v2/api/application/switch-tab?tab=framing"
try:
switch_request = urllib.request.Request(switch_url)
with urllib.request.urlopen(switch_request, timeout=5) as switch_response:
switch_result = json.loads(switch_response.read().decode('utf-8'))
if switch_result.get('Success'):
logger.debug("Switched NINA to Framing tab")
else:
logger.warning(f"Could not switch to Framing tab: {switch_result.get('Error', 'Unknown')}")
except Exception as e:
logger.warning(f"Could not switch to Framing tab: {e}")
return True
else:
error_msg = result.get('Error', 'Unknown error')
if parent_widget:
QMessageBox.warning(parent_widget, "NINA Error", f"NINA returned an error: {error_msg}")
logger.warning(f"NINA error when sending {target_name}: {error_msg}")
return False
except urllib.error.URLError as e:
logger.warning(f"Could not connect to NINA: {e}")
if parent_widget:
QMessageBox.warning(
parent_widget, "Connection Error",
"Could not connect to NINA.\n\n"
"Please ensure:\n"
"- NINA is running\n"
"- The Advanced API plugin is enabled\n"
"- The Framing Assistant tab has been opened at least once"
)
return False
except Exception as e:
logger.error(f"Error sending to NINA: {e}")
if parent_widget:
QMessageBox.warning(parent_widget, "Error", f"Failed to send to NINA: {str(e)}")
return False
@staticmethod
def slew_to_coordinates(ra_deg, dec_deg, target_name, parent_widget=None):
"""
Slew mount to specified coordinates with confirmation dialog.
Args:
ra_deg: Right Ascension in degrees
dec_deg: Declination in degrees
target_name: Name of the target (for display)
parent_widget: Parent widget for message boxes (optional)
Returns:
bool: True if successful, False otherwise
"""
if ra_deg is None or dec_deg is None:
if parent_widget:
QMessageBox.warning(parent_widget, "Error", "Target coordinates not available")
logger.warning(f"Cannot slew to {target_name}: coordinates not available")
return False
host, port = NINAIntegration.get_settings()
# Format coordinates for display
ra_h = ra_deg / 15.0
ra_hours = int(ra_h)
ra_min = int((ra_h - ra_hours) * 60)
ra_sec = ((ra_h - ra_hours) * 60 - ra_min) * 60
dec_sign = '+' if dec_deg >= 0 else '-'
dec_abs = abs(dec_deg)
dec_d = int(dec_abs)
dec_m = int((dec_abs - dec_d) * 60)
dec_s = ((dec_abs - dec_d) * 60 - dec_m) * 60
coord_str = f"RA: {ra_hours:02d}h {ra_min:02d}m {ra_sec:05.2f}s\nDec: {dec_sign}{dec_d:02d}° {dec_m:02d}' {dec_s:04.1f}\""
# Show confirmation dialog
if parent_widget:
reply = QMessageBox.question(
parent_widget,
"Confirm Slew",
f"Slew mount to {target_name}?\n\n{coord_str}",
QMessageBox.Yes | QMessageBox.No,
QMessageBox.No
)
if reply != QMessageBox.Yes:
return False
logger.info(f"Slewing mount to {target_name}: RA={ra_deg}, Dec={dec_deg}")
try:
success = NINAIntegration.slew_mount(host, port, ra_deg, dec_deg, wait_for_result=True)
if success:
logger.info(f"Slew to {target_name} completed successfully")
if parent_widget:
QMessageBox.information(
parent_widget,
"Slew Complete",
f"Mount slew to {target_name} completed."
)
return True
else:
if parent_widget:
QMessageBox.warning(
parent_widget,
"Slew Failed",
f"Failed to slew to {target_name}.\n\n"
"Please check:\n"
"- Mount is connected in NINA\n"
"- Mount is not parked\n"
"- No other slew operation is in progress"
)
return False
except urllib.error.HTTPError as e:
if e.code == 409:
if parent_widget:
QMessageBox.warning(
parent_widget,
"Slew Failed",
"Mount is not available for slewing.\n\n"
"Please check:\n"
"- Mount is connected in NINA\n"
"- Mount is not parked"
)
else:
if parent_widget:
QMessageBox.warning(
parent_widget,
"Slew Failed",
f"HTTP error {e.code} when slewing.\n\n"
f"Error: {e.reason}"
)
logger.error(f"HTTP error slewing to {target_name}: {e.code} {e.reason}")
return False
except urllib.error.URLError as e:
logger.warning(f"Could not connect to NINA: {e}")
if parent_widget:
QMessageBox.warning(
parent_widget,
"Connection Error",
"Could not connect to NINA.\n\n"
"Please ensure:\n"
"- NINA is running\n"
"- The Advanced API plugin is enabled"
)
return False
except Exception as e:
logger.error(f"Error slewing to {target_name}: {e}")
if parent_widget:
QMessageBox.warning(
parent_widget,
"Error",
f"Failed to slew to target: {str(e)}"
)
return False
# -------------------------------------------------------------------------
# Dashboard API Methods
# -------------------------------------------------------------------------
@staticmethod
def get_camera_info(host, port):
"""
Get camera equipment information from NINA.
Args:
host: The hostname or IP address of the NINA instance
port: The API port number
Returns:
dict: Camera info dict on success, None on failure
"""
url = f"http://{host}:{port}/v2/api/equipment/camera/info"
#logger.debug(f"API Request: {url}")
try:
request = urllib.request.Request(url)
with urllib.request.urlopen(request, timeout=5) as response:
result = json.loads(response.read().decode('utf-8'))
#logger.debug(f"API Response: {result}")
if result.get('Success'):
resp = result.get('Response')
return resp if isinstance(resp, dict) else None
return None
except Exception as e:
logger.debug(f"Error getting camera info: {e}")
return None
@staticmethod
def set_camera_cooling(host, port, enabled, temperature=None):
"""
Enable or disable camera cooling.
Args:
host: The hostname or IP address of the NINA instance
port: The API port number
enabled: True to enable cooling, False to disable
temperature: Target temperature in Celsius (optional, only used when enabling)
Returns:
bool: True on success, False on failure
"""
if enabled:
if temperature is not None:
# Use minutes=-1 for default duration
url = f"http://{host}:{port}/v2/api/equipment/camera/cool?temperature={temperature}&minutes=-1"
else:
# Cancel cooling
url = f"http://{host}:{port}/v2/api/equipment/camera/cool?cancel=true"
logger.debug(f"API Request: {url}")
try:
request = urllib.request.Request(url)
with urllib.request.urlopen(request, timeout=5) as response:
result = json.loads(response.read().decode('utf-8'))
logger.debug(f"API Response: {result}")
success = result.get('Success', False)
if not success:
logger.warning(f"Failed to set camera cooling: {result.get('Error', 'Unknown error')}")
return success
except urllib.error.HTTPError as e:
error_body = e.read().decode('utf-8') if e.fp else ''
logger.error(f"HTTP Error setting camera cooling: {e.code} {e.reason} - {error_body}")
return False
except Exception as e:
logger.error(f"Error setting camera cooling: {e}")
return False
@staticmethod
def set_camera_dew_heater(host, port, enabled):
"""
Enable or disable camera dew heater.
Args:
host: The hostname or IP address of the NINA instance
port: The API port number
enabled: True to enable dew heater, False to disable
Returns:
bool: True on success, False on failure
"""
url = f"http://{host}:{port}/v2/api/equipment/camera/dew-heater?power={'true' if enabled else 'false'}"
logger.debug(f"API Request: {url}")
try:
request = urllib.request.Request(url)
with urllib.request.urlopen(request, timeout=5) as response:
result = json.loads(response.read().decode('utf-8'))
logger.debug(f"API Response: {result}")
success = result.get('Success', False)
if not success:
logger.warning(f"Failed to set dew heater: {result.get('Error', 'Unknown error')}")
return success
except Exception as e:
logger.error(f"Error setting dew heater: {e}")
return False
@staticmethod
def home_mount(host, port):
"""
Home the mount.
Args:
host: The hostname or IP address of the NINA instance
port: The API port number
Returns:
bool: True on success, False on failure
"""
url = f"http://{host}:{port}/v2/api/equipment/mount/home"
logger.debug(f"API Request: {url}")
try:
request = urllib.request.Request(url)
with urllib.request.urlopen(request, timeout=60) as response:
result = json.loads(response.read().decode('utf-8'))
logger.debug(f"API Response: {result}")
success = result.get('Success', False)
if not success:
logger.warning(f"Failed to home mount: {result.get('Error', 'Unknown error')}")
return success
except urllib.error.HTTPError as e:
error_body = e.read().decode('utf-8') if e.fp else ''
logger.error(f"HTTP Error homing mount: {e.code} {e.reason} - {error_body}")
return False
except Exception as e:
logger.error(f"Error homing mount: {e}")
return False
@staticmethod
def park_mount(host, port):
"""
Park the mount.
Args:
host: The hostname or IP address of the NINA instance
port: The API port number
Returns:
bool: True on success, False on failure
"""
url = f"http://{host}:{port}/v2/api/equipment/mount/park"
logger.debug(f"API Request: {url}")
try:
request = urllib.request.Request(url)
with urllib.request.urlopen(request, timeout=30) as response:
result = json.loads(response.read().decode('utf-8'))
logger.debug(f"API Response: {result}")
success = result.get('Success', False)
if not success:
logger.warning(f"Failed to park mount: {result.get('Error', 'Unknown error')}")
return success
except urllib.error.HTTPError as e:
error_body = e.read().decode('utf-8') if e.fp else ''
logger.error(f"HTTP Error parking mount: {e.code} {e.reason} - {error_body}")
return False
except Exception as e:
logger.error(f"Error parking mount: {e}")
return False
@staticmethod
def slew_mount(host, port, ra_deg, dec_deg, wait_for_result=False):
"""
Slew the mount to specified coordinates.
Args:
host: The hostname or IP address of the NINA instance
port: The API port number
ra_deg: Right Ascension in degrees
dec_deg: Declination in degrees
wait_for_result: Whether to wait for slew to complete
Returns:
bool: True on success, False on failure
"""
params = [f"ra={ra_deg}", f"dec={dec_deg}"]
if wait_for_result:
params.append("waitForResult=true")
url = f"http://{host}:{port}/v2/api/equipment/mount/slew?{'&'.join(params)}"
logger.debug(f"API Request: {url}")
try:
request = urllib.request.Request(url)
# Longer timeout if waiting for result
timeout = 120 if wait_for_result else 10
with urllib.request.urlopen(request, timeout=timeout) as response:
result = json.loads(response.read().decode('utf-8'))
logger.debug(f"API Response: {result}")
success = result.get('Success', False)
if not success:
logger.warning(f"Failed to slew mount: {result.get('Error', 'Unknown error')}")
return success
except urllib.error.HTTPError as e:
error_body = e.read().decode('utf-8') if e.fp else ''
logger.error(f"HTTP Error slewing mount: {e.code} {e.reason} - {error_body}")
return False
except Exception as e:
logger.error(f"Error slewing mount: {e}")
return False
@staticmethod
def unpark_mount(host, port):
"""
Unpark the mount.
Args:
host: The hostname or IP address of the NINA instance
port: The API port number
Returns:
bool: True on success, False on failure
"""
url = f"http://{host}:{port}/v2/api/equipment/mount/unpark"
logger.debug(f"API Request: {url}")
try:
request = urllib.request.Request(url)
with urllib.request.urlopen(request, timeout=30) as response:
result = json.loads(response.read().decode('utf-8'))
logger.debug(f"API Response: {result}")
success = result.get('Success', False)
if not success:
logger.warning(f"Failed to unpark mount: {result.get('Error', 'Unknown error')}")
return success
except urllib.error.HTTPError as e:
error_body = e.read().decode('utf-8') if e.fp else ''
logger.error(f"HTTP Error unparking mount: {e.code} {e.reason} - {error_body}")
return False
except Exception as e:
logger.error(f"Error unparking mount: {e}")
return False
@staticmethod
def get_mount_info(host, port):
"""
Get mount equipment information from NINA.
Args:
host: The hostname or IP address of the NINA instance
port: The API port number
Returns:
dict: Mount info dict on success, None on failure
"""
url = f"http://{host}:{port}/v2/api/equipment/mount/info"
#logger.debug(f"API Request: {url}")
try:
request = urllib.request.Request(url)
with urllib.request.urlopen(request, timeout=5) as response:
result = json.loads(response.read().decode('utf-8'))
#logger.debug(f"API Response: {result}")
if result.get('Success'):
resp = result.get('Response')
return resp if isinstance(resp, dict) else None
return None
except Exception as e:
logger.debug(f"Error getting mount info: {e}")
return None
@staticmethod
def start_guiding(host, port, calibrate=False):
"""
Start guiding.
Args:
host: The hostname or IP address of the NINA instance
port: The API port number
calibrate: Whether to force calibration before guiding
Returns:
bool: True on success, False on failure
"""
url = f"http://{host}:{port}/v2/api/equipment/guider/start"
if calibrate:
url += "?calibrate=true"
logger.debug(f"API Request: {url}")
try:
request = urllib.request.Request(url)
with urllib.request.urlopen(request, timeout=30) as response:
result = json.loads(response.read().decode('utf-8'))
logger.debug(f"API Response: {result}")
success = result.get('Success', False)
if not success:
logger.warning(f"Failed to start guiding: {result.get('Error', 'Unknown error')}")
return success
except urllib.error.HTTPError as e:
error_body = e.read().decode('utf-8') if e.fp else ''
logger.error(f"HTTP Error starting guiding: {e.code} {e.reason} - {error_body}")
return False
except Exception as e:
logger.error(f"Error starting guiding: {e}")
return False
@staticmethod
def stop_guiding(host, port):
"""
Stop guiding.
Args:
host: The hostname or IP address of the NINA instance
port: The API port number
Returns:
bool: True on success, False on failure
"""
url = f"http://{host}:{port}/v2/api/equipment/guider/stop"
logger.debug(f"API Request: {url}")
try:
request = urllib.request.Request(url)
with urllib.request.urlopen(request, timeout=10) as response:
result = json.loads(response.read().decode('utf-8'))
logger.debug(f"API Response: {result}")
success = result.get('Success', False)
if not success:
logger.warning(f"Failed to stop guiding: {result.get('Error', 'Unknown error')}")
return success
except urllib.error.HTTPError as e:
error_body = e.read().decode('utf-8') if e.fp else ''
logger.error(f"HTTP Error stopping guiding: {e.code} {e.reason} - {error_body}")
return False
except Exception as e:
logger.error(f"Error stopping guiding: {e}")
return False
@staticmethod
def get_guider_info(host, port):
"""
Get guider equipment information from NINA.
Args:
host: The hostname or IP address of the NINA instance
port: The API port number
Returns:
dict: Guider info dict on success, None on failure
"""
url = f"http://{host}:{port}/v2/api/equipment/guider/info"
#logger.debug(f"API Request: {url}")
try:
request = urllib.request.Request(url)
with urllib.request.urlopen(request, timeout=5) as response:
result = json.loads(response.read().decode('utf-8'))
#logger.debug(f"API Response: {result}")
if result.get('Success'):
resp = result.get('Response')
return resp if isinstance(resp, dict) else None
return None
except Exception as e:
logger.debug(f"Error getting guider info: {e}")
return None
@staticmethod
def get_filterwheel_info(host, port):
"""
Get filter wheel equipment information from NINA.
Args:
host: The hostname or IP address of the NINA instance
port: The API port number
Returns:
dict: Filter wheel info dict on success, None on failure
"""
url = f"http://{host}:{port}/v2/api/equipment/filterwheel/info"
#logger.debug(f"API Request: {url}")
try:
request = urllib.request.Request(url)
with urllib.request.urlopen(request, timeout=5) as response:
result = json.loads(response.read().decode('utf-8'))
#logger.debug(f"API Response: {result}")
if result.get('Success'):
resp = result.get('Response')
return resp if isinstance(resp, dict) else None
return None
except Exception as e:
logger.debug(f"Error getting filter wheel info: {e}")
return None
@staticmethod
def change_filter(host, port, filter_id):
"""
Change the active filter on the filter wheel.
Args:
host: The hostname or IP address of the NINA instance
port: The API port number
filter_id: The ID of the filter to change to
Returns:
bool: True on success, False on failure
"""
url = f"http://{host}:{port}/v2/api/equipment/filterwheel/change-filter?filterId={filter_id}"
logger.debug(f"API Request: {url}")
try:
request = urllib.request.Request(url)
with urllib.request.urlopen(request, timeout=30) as response:
result = json.loads(response.read().decode('utf-8'))
logger.debug(f"API Response: {result}")
success = result.get('Success', False)
if not success:
logger.warning(f"Failed to change filter: {result.get('Error', 'Unknown error')}")
return success
except urllib.error.HTTPError as e:
error_body = e.read().decode('utf-8') if e.fp else ''
logger.error(f"HTTP Error changing filter: {e.code} {e.reason} - {error_body}")
return False
except Exception as e:
logger.error(f"Error changing filter: {e}")
return False
@staticmethod
def capture_image(host, port, duration=None, gain=None, save=True, image_type="SNAPSHOT"):
"""
Start a camera capture.
Args:
host: NINA host
port: NINA port
duration: Exposure duration in seconds (optional)
gain: Camera gain (optional)
save: Save image to disk (default True)
image_type: LIGHT, DARK, BIAS, FLAT, or SNAPSHOT
Returns:
bool: True on success, False on failure
"""
params = [f"imageType={image_type}", f"save={'true' if save else 'false'}"]
if duration is not None:
params.append(f"duration={duration}")
if gain is not None:
params.append(f"gain={gain}")
url = f"http://{host}:{port}/v2/api/equipment/camera/capture?{'&'.join(params)}"
logger.debug(f"API Request: {url}")
try:
request = urllib.request.Request(url)
with urllib.request.urlopen(request, timeout=10) as response:
result = json.loads(response.read().decode('utf-8'))
logger.debug(f"API Response: {result}")
success = result.get('Success', False)
if not success:
logger.warning(f"Failed to capture image: {result.get('Error', 'Unknown error')}")
return success
except urllib.error.HTTPError as e:
error_body = e.read().decode('utf-8') if e.fp else ''
logger.error(f"HTTP Error capturing image: {e.code} {e.reason} - {error_body}")
return False
except Exception as e:
logger.error(f"Error capturing image: {e}")
return False
@staticmethod
def abort_exposure(host, port):
"""
Abort the current camera exposure.
Args:
host: The hostname or IP address of the NINA instance
port: The API port number
Returns:
bool: True on success, False on failure
"""
url = f"http://{host}:{port}/v2/api/equipment/camera/abort-exposure"
logger.debug(f"API Request: {url}")
try:
request = urllib.request.Request(url)
with urllib.request.urlopen(request, timeout=5) as response:
result = json.loads(response.read().decode('utf-8'))
logger.debug(f"API Response: {result}")
success = result.get('Success', False)
if not success:
logger.warning(f"Failed to abort exposure: {result.get('Error', 'Unknown error')}")
return success
except urllib.error.HTTPError as e:
error_body = e.read().decode('utf-8') if e.fp else ''
logger.error(f"HTTP Error aborting exposure: {e.code} {e.reason} - {error_body}")
return False
except Exception as e:
logger.error(f"Error aborting exposure: {e}")
return False
@staticmethod
def start_autofocus(host, port):
"""
Start an autofocus run.
Args:
host: The hostname or IP address of the NINA instance
port: The API port number
Returns:
bool: True on success, False on failure
"""
url = f"http://{host}:{port}/v2/api/equipment/focuser/auto-focus"
logger.debug(f"API Request: {url}")
try:
request = urllib.request.Request(url)
with urllib.request.urlopen(request, timeout=10) as response:
result = json.loads(response.read().decode('utf-8'))
logger.debug(f"API Response: {result}")
success = result.get('Success', False)
if not success:
logger.warning(f"Failed to start autofocus: {result.get('Error', 'Unknown error')}")
return success
except urllib.error.HTTPError as e:
error_body = e.read().decode('utf-8') if e.fp else ''
logger.error(f"HTTP Error starting autofocus: {e.code} {e.reason} - {error_body}")
return False
except Exception as e:
logger.error(f"Error starting autofocus: {e}")
return False
@staticmethod
def cancel_autofocus(host, port):
"""
Cancel a running autofocus.
Args:
host: The hostname or IP address of the NINA instance
port: The API port number
Returns:
bool: True on success, False on failure
"""
url = f"http://{host}:{port}/v2/api/equipment/focuser/auto-focus?cancel=true"
logger.debug(f"API Request: {url}")
try:
request = urllib.request.Request(url)
with urllib.request.urlopen(request, timeout=5) as response:
result = json.loads(response.read().decode('utf-8'))
logger.debug(f"API Response: {result}")
success = result.get('Success', False)
if not success:
logger.warning(f"Failed to cancel autofocus: {result.get('Error', 'Unknown error')}")
return success
except urllib.error.HTTPError as e:
error_body = e.read().decode('utf-8') if e.fp else ''
logger.error(f"HTTP Error canceling autofocus: {e.code} {e.reason} - {error_body}")
return False
except Exception as e:
logger.error(f"Error canceling autofocus: {e}")
return False
@staticmethod
def get_focuser_info(host, port):
"""
Get focuser equipment information from NINA.
Args:
host: The hostname or IP address of the NINA instance
port: The API port number
Returns:
dict: Focuser info dict on success, None on failure
"""
url = f"http://{host}:{port}/v2/api/equipment/focuser/info"
#logger.debug(f"API Request: {url}")
try:
request = urllib.request.Request(url)
with urllib.request.urlopen(request, timeout=5) as response:
result = json.loads(response.read().decode('utf-8'))
#logger.debug(f"API Response: {result}")
if result.get('Success'):
resp = result.get('Response')
return resp if isinstance(resp, dict) else None
return None
except Exception as e:
logger.debug(f"Error getting focuser info: {e}")
return None
@staticmethod
def get_capture_statistics(host, port):
"""
Get capture statistics from NINA.
Args:
host: The hostname or IP address of the NINA instance
port: The API port number
Returns:
dict: Capture statistics dict on success, None on failure
"""
url = f"http://{host}:{port}/v2/api/equipment/camera/capture/statistics"
try:
request = urllib.request.Request(url)
with urllib.request.urlopen(request, timeout=5) as response:
result = json.loads(response.read().decode('utf-8'))
if result.get('Success'):
resp = result.get('Response')
return resp if isinstance(resp, dict) else None
return None
except Exception as e:
logger.debug(f"Error getting capture statistics: {e}")
return None
@staticmethod
def get_all_image_history(host, port):
"""
Get the full image history list from NINA (all LIGHT frames).
Returns:
list: All image history entries, each with ExposureTime, TargetName, etc.
Returns an empty list on failure.
"""
url = f"http://{host}:{port}/v2/api/image-history?all=true&imageType=LIGHT"
try:
request = urllib.request.Request(url)
with urllib.request.urlopen(request, timeout=10) as response:
result = json.loads(response.read().decode('utf-8'))
resp = result.get('Response')
if isinstance(resp, list):
return resp
except Exception as e:
logger.debug(f"Error getting all image history: {e}")
return []
@staticmethod
def calculate_integration_from_history(history, target, stack_count):
"""
Sum ExposureTime for the most recent stack_count LIGHT frames matching target.
Args:
history: list returned by get_all_image_history
target: target name string to filter by
stack_count: number of frames in the current stack
Returns:
float total seconds, or None if insufficient data
"""
frames = [
img for img in history
if img.get('TargetName') == target
and isinstance(img.get('ExposureTime'), (int, float))
and img['ExposureTime'] > 0
]
if not frames:
return None
# Take the most recent stack_count frames (history is oldest-first)
recent = frames[-stack_count:] if len(frames) >= stack_count else frames
return sum(img['ExposureTime'] for img in recent)
@staticmethod
def get_image_statistics(host, port, index=None):
"""
Get image history/statistics from NINA using the image-history endpoint.
Args:
host: The hostname or IP address of the NINA instance
port: The API port number
index: Optional image index to get stats for a specific image.
If None, gets the latest image (all=false).
Returns:
dict: Image history entry on success, None on failure.
Keys include: Stars, HFR, HFRStDev, Median, Mean, StDev, Min, Max,
ExposureTime, Filter, Gain, Offset, Temperature, TargetName,
ImageType, Filename, Date, CameraName, TelescopeName, etc.
"""
params = []
if index is not None:
params.append(f"index={index}")
else:
params.append("all=false")
params.append("imageType=LIGHT")
query = "&".join(params)
url = f"http://{host}:{port}/v2/api/image-history?{query}"
try:
request = urllib.request.Request(url)
with urllib.request.urlopen(request, timeout=5) as response:
result = json.loads(response.read().decode('utf-8'))
resp = result.get('Response')
if isinstance(resp, list) and len(resp) > 0:
return resp[-1] # Return the last (most recent) entry
elif isinstance(resp, dict):
return resp
return None
except Exception as e:
logger.debug(f"Error getting image history: {e}")
return None
@staticmethod
def get_image_count(host, port):
"""
Get the count of images by probing for the highest valid index.
Args:
host: The hostname or IP address of the NINA instance
port: The API port number
Returns:
int: Number of images (highest index + 1), or 0 if no images
"""
# The /v2/api/image/count endpoint is unreliable (returns 500)
# Instead, probe for images by trying indices until we get a 404
# Use binary search for efficiency