citrascope 0.5.2__py3-none-any.whl → 0.7.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/citra_scope_daemon.py +22 -38
- citrascope/hardware/abstract_astro_hardware_adapter.py +64 -6
- citrascope/hardware/kstars_dbus_adapter.py +875 -30
- citrascope/hardware/kstars_scheduler_template.esl +30 -0
- citrascope/hardware/kstars_sequence_template.esq +16 -0
- citrascope/hardware/nina_adv_http_adapter.py +74 -59
- citrascope/hardware/nina_adv_http_survey_template.json +4 -4
- citrascope/settings/citrascope_settings.py +25 -4
- citrascope/tasks/runner.py +103 -0
- citrascope/tasks/scope/static_telescope_task.py +6 -1
- citrascope/web/app.py +82 -37
- citrascope/web/static/app.js +83 -0
- citrascope/web/static/config.js +244 -39
- citrascope/web/templates/dashboard.html +62 -27
- {citrascope-0.5.2.dist-info → citrascope-0.7.0.dist-info}/METADATA +19 -1
- {citrascope-0.5.2.dist-info → citrascope-0.7.0.dist-info}/RECORD +19 -17
- {citrascope-0.5.2.dist-info → citrascope-0.7.0.dist-info}/WHEEL +0 -0
- {citrascope-0.5.2.dist-info → citrascope-0.7.0.dist-info}/entry_points.txt +0 -0
- {citrascope-0.5.2.dist-info → citrascope-0.7.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
+
<SchedulerList version="2.6">
|
|
3
|
+
<Job>
|
|
4
|
+
<Name>{{JOB_NAME}}</Name>
|
|
5
|
+
<Group></Group>
|
|
6
|
+
<Coordinates>
|
|
7
|
+
<J2000RA>{{TARGET_RA}}</J2000RA>
|
|
8
|
+
<J2000DE>{{TARGET_DEC}}</J2000DE>
|
|
9
|
+
</Coordinates>
|
|
10
|
+
<PositionAngle>-1</PositionAngle>
|
|
11
|
+
<Sequence>{{SEQUENCE_FILE}}</Sequence>
|
|
12
|
+
<FITSFILE></FITSFILE>
|
|
13
|
+
<StartupCondition>
|
|
14
|
+
<Condition>ASAP</Condition>
|
|
15
|
+
<CulminationOffset>0</CulminationOffset>
|
|
16
|
+
</StartupCondition>
|
|
17
|
+
<Constraints>
|
|
18
|
+
<Constraint value="{{MIN_ALTITUDE}}">MinimumAltitude</Constraint>
|
|
19
|
+
</Constraints>
|
|
20
|
+
<CompletionCondition>
|
|
21
|
+
<Condition>Repeat</Condition>
|
|
22
|
+
<Repeat>10</Repeat>
|
|
23
|
+
</CompletionCondition>
|
|
24
|
+
<Steps>
|
|
25
|
+
<Step>Track</Step>
|
|
26
|
+
</Steps>
|
|
27
|
+
<Priority>10</Priority>
|
|
28
|
+
<Enforced>true</Enforced>
|
|
29
|
+
</Job>
|
|
30
|
+
</SchedulerList>
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
+
<SequenceQueue version='2.6'>
|
|
3
|
+
<Observer>CitraScope</Observer>
|
|
4
|
+
<GuideDeviation enabled='false'>2</GuideDeviation>
|
|
5
|
+
<GuideStartDeviation enabled='false'>2</GuideStartDeviation>
|
|
6
|
+
<HFRCheck enabled='false'>
|
|
7
|
+
<HFRDeviation>0.5</HFRDeviation>
|
|
8
|
+
<HFRCheckAlgorithm>0</HFRCheckAlgorithm>
|
|
9
|
+
<HFRCheckThreshold>10</HFRCheckThreshold>
|
|
10
|
+
<HFRCheckFrames>1</HFRCheckFrames>
|
|
11
|
+
</HFRCheck>
|
|
12
|
+
<RefocusOnTemperatureDelta enabled='false'>1</RefocusOnTemperatureDelta>
|
|
13
|
+
<RefocusEveryN enabled='false'>60</RefocusEveryN>
|
|
14
|
+
<RefocusOnMeridianFlip enabled='false' />
|
|
15
|
+
{{JOBS}}
|
|
16
|
+
</SequenceQueue>
|
|
@@ -30,19 +30,13 @@ class NinaAdvancedHttpAdapter(AbstractAstroHardwareAdapter):
|
|
|
30
30
|
SEQUENCE_URL = "/sequence/"
|
|
31
31
|
|
|
32
32
|
def __init__(self, logger: logging.Logger, images_dir: Path, **kwargs):
|
|
33
|
-
super().__init__(images_dir=images_dir)
|
|
33
|
+
super().__init__(images_dir=images_dir, **kwargs)
|
|
34
34
|
self.logger: logging.Logger = logger
|
|
35
35
|
self.nina_api_path = kwargs.get("nina_api_path", "http://nina:1888/v2/api")
|
|
36
36
|
|
|
37
|
-
self.
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
for filter_id, filter_data in saved_filters.items():
|
|
41
|
-
# Convert string keys back to int for internal use
|
|
42
|
-
try:
|
|
43
|
-
self.filter_map[int(filter_id)] = filter_data
|
|
44
|
-
except (ValueError, TypeError) as e:
|
|
45
|
-
self.logger.warning(f"Invalid filter ID '{filter_id}' in settings, skipping: {e}")
|
|
37
|
+
self.binning_x = kwargs.get("binning_x", 1)
|
|
38
|
+
self.binning_y = kwargs.get("binning_y", 1)
|
|
39
|
+
self.autofocus_binning = kwargs.get("autofocus_binning", 1)
|
|
46
40
|
|
|
47
41
|
@classmethod
|
|
48
42
|
def get_settings_schema(cls) -> list[SettingSchemaEntry]:
|
|
@@ -60,21 +54,59 @@ class NinaAdvancedHttpAdapter(AbstractAstroHardwareAdapter):
|
|
|
60
54
|
"placeholder": "http://localhost:1888/v2/api",
|
|
61
55
|
"pattern": r"^https?://.*",
|
|
62
56
|
},
|
|
57
|
+
{
|
|
58
|
+
"name": "autofocus_binning",
|
|
59
|
+
"friendly_name": "Autofocus Binning",
|
|
60
|
+
"type": "int",
|
|
61
|
+
"default": 1,
|
|
62
|
+
"description": "Pixel binning for autofocus (1=no binning, 2=2x2, etc.)",
|
|
63
|
+
"required": False,
|
|
64
|
+
"placeholder": "1",
|
|
65
|
+
"min": 1,
|
|
66
|
+
"max": 4,
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
"name": "binning_x",
|
|
70
|
+
"friendly_name": "Binning X",
|
|
71
|
+
"type": "int",
|
|
72
|
+
"default": 1,
|
|
73
|
+
"description": "Horizontal pixel binning for observations (1=no binning, 2=2x2, etc.)",
|
|
74
|
+
"required": False,
|
|
75
|
+
"placeholder": "1",
|
|
76
|
+
"min": 1,
|
|
77
|
+
"max": 4,
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
"name": "binning_y",
|
|
81
|
+
"friendly_name": "Binning Y",
|
|
82
|
+
"type": "int",
|
|
83
|
+
"default": 1,
|
|
84
|
+
"description": "Vertical pixel binning for observations (1=no binning, 2=2x2, etc.)",
|
|
85
|
+
"required": False,
|
|
86
|
+
"placeholder": "1",
|
|
87
|
+
"min": 1,
|
|
88
|
+
"max": 4,
|
|
89
|
+
},
|
|
63
90
|
]
|
|
64
91
|
|
|
65
92
|
def do_autofocus(self):
|
|
66
|
-
"""Perform autofocus routine for all filters.
|
|
93
|
+
"""Perform autofocus routine for all enabled filters.
|
|
67
94
|
|
|
68
95
|
Slews telescope to Mirach (bright reference star) and runs autofocus
|
|
69
|
-
for each filter in the filter map, updating focus positions.
|
|
96
|
+
for each enabled filter in the filter map, updating focus positions.
|
|
70
97
|
|
|
71
98
|
Raises:
|
|
72
|
-
RuntimeError: If no filters discovered or
|
|
99
|
+
RuntimeError: If no filters discovered or no enabled filters
|
|
73
100
|
"""
|
|
74
101
|
if not self.filter_map:
|
|
75
102
|
raise RuntimeError("No filters discovered. Cannot perform autofocus.")
|
|
76
103
|
|
|
77
|
-
|
|
104
|
+
# Filter to only enabled filters
|
|
105
|
+
enabled_filters = {fid: fdata for fid, fdata in self.filter_map.items() if fdata.get("enabled", True)}
|
|
106
|
+
if not enabled_filters:
|
|
107
|
+
raise RuntimeError("No enabled filters. Cannot perform autofocus.")
|
|
108
|
+
|
|
109
|
+
self.logger.info(f"Performing autofocus routine on {len(enabled_filters)} enabled filter(s) ...")
|
|
78
110
|
# move telescope to bright star and start autofocus
|
|
79
111
|
# Mirach ra=(1+9/60.+47.45/3600.)*15 dec=(35+37/60.+11.1/3600.)
|
|
80
112
|
ra = (1 + 9 / 60.0 + 47.45 / 3600.0) * 15
|
|
@@ -96,7 +128,7 @@ class NinaAdvancedHttpAdapter(AbstractAstroHardwareAdapter):
|
|
|
96
128
|
self.logger.info("Waiting for mount to finish slewing...")
|
|
97
129
|
time.sleep(5)
|
|
98
130
|
|
|
99
|
-
for id, filter in
|
|
131
|
+
for id, filter in enabled_filters.items():
|
|
100
132
|
self.logger.info(f"Focusing Filter ID: {id}, Name: {filter['name']}")
|
|
101
133
|
# Pass existing focus position to preserve it if autofocus fails
|
|
102
134
|
existing_focus = filter.get("focus_position", self.DEFAULT_FOCUS_POSITION)
|
|
@@ -190,42 +222,42 @@ class NinaAdvancedHttpAdapter(AbstractAstroHardwareAdapter):
|
|
|
190
222
|
try:
|
|
191
223
|
# start connection to all equipments
|
|
192
224
|
self.logger.info("Connecting camera ...")
|
|
193
|
-
cam_status = requests.get(self.nina_api_path + self.CAM_URL + "connect").json()
|
|
225
|
+
cam_status = requests.get(self.nina_api_path + self.CAM_URL + "connect", timeout=5).json()
|
|
194
226
|
if not cam_status["Success"]:
|
|
195
227
|
self.logger.error(f"Failed to connect camera: {cam_status.get('Error')}")
|
|
196
228
|
return False
|
|
197
229
|
self.logger.info(f"Camera Connected!")
|
|
198
230
|
|
|
199
231
|
self.logger.info("Starting camera cooling ...")
|
|
200
|
-
cool_status = requests.get(self.nina_api_path + self.CAM_URL + "cool").json()
|
|
232
|
+
cool_status = requests.get(self.nina_api_path + self.CAM_URL + "cool", timeout=5).json()
|
|
201
233
|
if not cool_status["Success"]:
|
|
202
234
|
self.logger.warning(f"Failed to start camera cooling: {cool_status.get('Error')}")
|
|
203
235
|
else:
|
|
204
236
|
self.logger.info("Cooler started!")
|
|
205
237
|
|
|
206
238
|
self.logger.info("Connecting filterwheel ...")
|
|
207
|
-
filterwheel_status = requests.get(self.nina_api_path + self.FILTERWHEEL_URL + "connect").json()
|
|
239
|
+
filterwheel_status = requests.get(self.nina_api_path + self.FILTERWHEEL_URL + "connect", timeout=5).json()
|
|
208
240
|
if not filterwheel_status["Success"]:
|
|
209
241
|
self.logger.warning(f"Failed to connect filterwheel: {filterwheel_status.get('Error')}")
|
|
210
242
|
else:
|
|
211
243
|
self.logger.info(f"Filterwheel Connected!")
|
|
212
244
|
|
|
213
245
|
self.logger.info("Connecting focuser ...")
|
|
214
|
-
focuser_status = requests.get(self.nina_api_path + self.FOCUSER_URL + "connect").json()
|
|
246
|
+
focuser_status = requests.get(self.nina_api_path + self.FOCUSER_URL + "connect", timeout=5).json()
|
|
215
247
|
if not focuser_status["Success"]:
|
|
216
248
|
self.logger.warning(f"Failed to connect focuser: {focuser_status.get('Error')}")
|
|
217
249
|
else:
|
|
218
250
|
self.logger.info(f"Focuser Connected!")
|
|
219
251
|
|
|
220
252
|
self.logger.info("Connecting mount ...")
|
|
221
|
-
mount_status = requests.get(self.nina_api_path + self.MOUNT_URL + "connect").json()
|
|
253
|
+
mount_status = requests.get(self.nina_api_path + self.MOUNT_URL + "connect", timeout=5).json()
|
|
222
254
|
if not mount_status["Success"]:
|
|
223
255
|
self.logger.error(f"Failed to connect mount: {mount_status.get('Error')}")
|
|
224
256
|
return False
|
|
225
257
|
self.logger.info(f"Mount Connected!")
|
|
226
258
|
|
|
227
259
|
self.logger.info("Unparking mount ...")
|
|
228
|
-
mount_status = requests.get(self.nina_api_path + self.MOUNT_URL + "unpark").json()
|
|
260
|
+
mount_status = requests.get(self.nina_api_path + self.MOUNT_URL + "unpark", timeout=5).json()
|
|
229
261
|
if not mount_status["Success"]:
|
|
230
262
|
self.logger.error(f"Failed to unpark mount: {mount_status.get('Error')}")
|
|
231
263
|
return False
|
|
@@ -241,7 +273,7 @@ class NinaAdvancedHttpAdapter(AbstractAstroHardwareAdapter):
|
|
|
241
273
|
|
|
242
274
|
def discover_filters(self):
|
|
243
275
|
self.logger.info("Discovering filters ...")
|
|
244
|
-
filterwheel_info = requests.get(self.nina_api_path + self.FILTERWHEEL_URL + "info").json()
|
|
276
|
+
filterwheel_info = requests.get(self.nina_api_path + self.FILTERWHEEL_URL + "info", timeout=5).json()
|
|
245
277
|
if not filterwheel_info.get("Success"):
|
|
246
278
|
self.logger.error(f"Failed to get filterwheel info: {filterwheel_info.get('Error')}")
|
|
247
279
|
raise RuntimeError("Failed to get filterwheel info")
|
|
@@ -250,58 +282,33 @@ class NinaAdvancedHttpAdapter(AbstractAstroHardwareAdapter):
|
|
|
250
282
|
for filter in filters:
|
|
251
283
|
filter_id = filter["Id"]
|
|
252
284
|
filter_name = filter["Name"]
|
|
253
|
-
# Use existing focus position if filter already in map
|
|
285
|
+
# Use existing focus position and enabled state if filter already in map
|
|
254
286
|
if filter_id in self.filter_map:
|
|
255
287
|
focus_position = self.filter_map[filter_id].get("focus_position", self.DEFAULT_FOCUS_POSITION)
|
|
288
|
+
enabled = self.filter_map[filter_id].get("enabled", True)
|
|
256
289
|
self.logger.info(
|
|
257
|
-
f"Discovered filter: {filter_name} with ID: {filter_id}, using saved focus position: {focus_position}"
|
|
290
|
+
f"Discovered filter: {filter_name} with ID: {filter_id}, using saved focus position: {focus_position}, enabled: {enabled}"
|
|
258
291
|
)
|
|
259
292
|
else:
|
|
260
293
|
focus_position = self.DEFAULT_FOCUS_POSITION
|
|
294
|
+
enabled = True # Default new filters to enabled
|
|
261
295
|
self.logger.info(
|
|
262
296
|
f"Discovered new filter: {filter_name} with ID: {filter_id}, using default focus position: {focus_position}"
|
|
263
297
|
)
|
|
264
298
|
|
|
265
|
-
self.filter_map[filter_id] = {"name": filter_name, "focus_position": focus_position}
|
|
299
|
+
self.filter_map[filter_id] = {"name": filter_name, "focus_position": focus_position, "enabled": enabled}
|
|
266
300
|
|
|
267
301
|
def disconnect(self):
|
|
268
302
|
pass
|
|
269
303
|
|
|
304
|
+
def supports_autofocus(self) -> bool:
|
|
305
|
+
"""Indicates that NINA adapter supports autofocus."""
|
|
306
|
+
return True
|
|
307
|
+
|
|
270
308
|
def supports_filter_management(self) -> bool:
|
|
271
309
|
"""Indicates that NINA adapter supports filter/focus management."""
|
|
272
310
|
return True
|
|
273
311
|
|
|
274
|
-
def get_filter_config(self) -> dict[str, FilterConfig]:
|
|
275
|
-
"""Get current filter configuration with focus positions.
|
|
276
|
-
|
|
277
|
-
Returns:
|
|
278
|
-
dict: Filter configuration mapping filter ID strings to FilterConfig
|
|
279
|
-
"""
|
|
280
|
-
return {
|
|
281
|
-
str(filter_id): {"name": filter_data["name"], "focus_position": filter_data["focus_position"]}
|
|
282
|
-
for filter_id, filter_data in self.filter_map.items()
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
def update_filter_focus(self, filter_id: str, focus_position: int) -> bool:
|
|
286
|
-
"""Update the focus position for a specific filter.
|
|
287
|
-
|
|
288
|
-
Args:
|
|
289
|
-
filter_id: Filter ID as string
|
|
290
|
-
focus_position: New focus position in steps
|
|
291
|
-
|
|
292
|
-
Returns:
|
|
293
|
-
bool: True if update was successful, False otherwise
|
|
294
|
-
"""
|
|
295
|
-
try:
|
|
296
|
-
filter_id_int = int(filter_id)
|
|
297
|
-
if filter_id_int in self.filter_map:
|
|
298
|
-
self.filter_map[filter_id_int]["focus_position"] = focus_position
|
|
299
|
-
self.logger.info(f"Updated filter {filter_id} focus position to {focus_position}")
|
|
300
|
-
return True
|
|
301
|
-
return False
|
|
302
|
-
except (ValueError, KeyError):
|
|
303
|
-
return False
|
|
304
|
-
|
|
305
312
|
def is_telescope_connected(self) -> bool:
|
|
306
313
|
"""Check if telescope is connected and responsive."""
|
|
307
314
|
try:
|
|
@@ -445,8 +452,11 @@ class NinaAdvancedHttpAdapter(AbstractAstroHardwareAdapter):
|
|
|
445
452
|
"""
|
|
446
453
|
elset = satellite_data["most_recent_elset"]
|
|
447
454
|
|
|
448
|
-
# Load template as JSON
|
|
455
|
+
# Load template as JSON and replace binning placeholders
|
|
449
456
|
template_str = self._get_sequence_template()
|
|
457
|
+
template_str = template_str.replace("{binning_x}", str(self.binning_x))
|
|
458
|
+
template_str = template_str.replace("{binning_y}", str(self.binning_y))
|
|
459
|
+
template_str = template_str.replace("{autofocus_binning}", str(self.autofocus_binning))
|
|
450
460
|
sequence_json = json.loads(template_str)
|
|
451
461
|
|
|
452
462
|
nina_sequence_name = f"Citra Target: {satellite_data['name']}, Task: {task_id}"
|
|
@@ -486,7 +496,12 @@ class NinaAdvancedHttpAdapter(AbstractAstroHardwareAdapter):
|
|
|
486
496
|
|
|
487
497
|
id_counter = [base_id] # Use list so it can be modified in nested function
|
|
488
498
|
|
|
489
|
-
|
|
499
|
+
# Filter to only enabled filters for observation
|
|
500
|
+
enabled_filters = {fid: fdata for fid, fdata in self.filter_map.items() if fdata.get("enabled", True)}
|
|
501
|
+
if not enabled_filters:
|
|
502
|
+
raise RuntimeError("No enabled filters available for observation sequence")
|
|
503
|
+
|
|
504
|
+
for filter_id, filter_info in enabled_filters.items():
|
|
490
505
|
filter_name = filter_info["name"]
|
|
491
506
|
focus_position = filter_info["focus_position"]
|
|
492
507
|
|
|
@@ -565,7 +580,7 @@ class NinaAdvancedHttpAdapter(AbstractAstroHardwareAdapter):
|
|
|
565
580
|
raise RuntimeError("Failed to get images list from NINA")
|
|
566
581
|
|
|
567
582
|
images_to_download = []
|
|
568
|
-
expected_image_count = len(
|
|
583
|
+
expected_image_count = len(enabled_filters) # One image per enabled filter
|
|
569
584
|
images_found = len(images_response["Response"])
|
|
570
585
|
self.logger.info(
|
|
571
586
|
f"Found {images_found} images in NINA image history, considering the last {expected_image_count}"
|
|
@@ -174,8 +174,8 @@
|
|
|
174
174
|
"_autoFocusBinning": {
|
|
175
175
|
"$id": "31",
|
|
176
176
|
"$type": "NINA.Core.Model.Equipment.BinningMode, NINA.Core",
|
|
177
|
-
"X":
|
|
178
|
-
"Y":
|
|
177
|
+
"X": {autofocus_binning},
|
|
178
|
+
"Y": {autofocus_binning}
|
|
179
179
|
},
|
|
180
180
|
"_autoFocusGain": -1,
|
|
181
181
|
"_autoFocusOffset": -1
|
|
@@ -205,8 +205,8 @@
|
|
|
205
205
|
"Binning": {
|
|
206
206
|
"$id": "34",
|
|
207
207
|
"$type": "NINA.Core.Model.Equipment.BinningMode, NINA.Core",
|
|
208
|
-
"X":
|
|
209
|
-
"Y":
|
|
208
|
+
"X": {binning_x},
|
|
209
|
+
"Y": {binning_y}
|
|
210
210
|
},
|
|
211
211
|
"ImageType": "LIGHT",
|
|
212
212
|
"ExposureCount": 130,
|
|
@@ -53,8 +53,8 @@ class CitraScopeSettings:
|
|
|
53
53
|
self.log_level: str = config.get("log_level", "INFO")
|
|
54
54
|
self.keep_images: bool = config.get("keep_images", False)
|
|
55
55
|
|
|
56
|
-
# Web port: CLI
|
|
57
|
-
self.web_port: int = web_port
|
|
56
|
+
# Web port: CLI-only, never loaded from or saved to config file
|
|
57
|
+
self.web_port: int = web_port
|
|
58
58
|
|
|
59
59
|
# Task retry configuration
|
|
60
60
|
self.max_task_retries: int = config.get("max_task_retries", 3)
|
|
@@ -65,6 +65,22 @@ class CitraScopeSettings:
|
|
|
65
65
|
self.file_logging_enabled: bool = config.get("file_logging_enabled", True)
|
|
66
66
|
self.log_retention_days: int = config.get("log_retention_days", 30)
|
|
67
67
|
|
|
68
|
+
# Autofocus configuration (top-level/global settings)
|
|
69
|
+
self.scheduled_autofocus_enabled: bool = config.get("scheduled_autofocus_enabled", False)
|
|
70
|
+
self.autofocus_interval_minutes: int = config.get("autofocus_interval_minutes", 60)
|
|
71
|
+
self.last_autofocus_timestamp: Optional[int] = config.get("last_autofocus_timestamp")
|
|
72
|
+
|
|
73
|
+
# Validate autofocus interval
|
|
74
|
+
if (
|
|
75
|
+
not isinstance(self.autofocus_interval_minutes, int)
|
|
76
|
+
or self.autofocus_interval_minutes < 1
|
|
77
|
+
or self.autofocus_interval_minutes > 1439
|
|
78
|
+
):
|
|
79
|
+
CITRASCOPE_LOGGER.warning(
|
|
80
|
+
f"Invalid autofocus_interval_minutes ({self.autofocus_interval_minutes}). Setting to default 60 minutes."
|
|
81
|
+
)
|
|
82
|
+
self.autofocus_interval_minutes = 60
|
|
83
|
+
|
|
68
84
|
def get_images_dir(self) -> Path:
|
|
69
85
|
"""Get the path to the images directory.
|
|
70
86
|
|
|
@@ -90,7 +106,7 @@ class CitraScopeSettings:
|
|
|
90
106
|
"""Convert settings to dictionary for serialization.
|
|
91
107
|
|
|
92
108
|
Returns:
|
|
93
|
-
Dictionary of all settings.
|
|
109
|
+
Dictionary of all settings (excluding runtime-only settings like web_port).
|
|
94
110
|
"""
|
|
95
111
|
return {
|
|
96
112
|
"host": self.host,
|
|
@@ -102,12 +118,14 @@ class CitraScopeSettings:
|
|
|
102
118
|
"adapter_settings": self._all_adapter_settings,
|
|
103
119
|
"log_level": self.log_level,
|
|
104
120
|
"keep_images": self.keep_images,
|
|
105
|
-
"web_port": self.web_port,
|
|
106
121
|
"max_task_retries": self.max_task_retries,
|
|
107
122
|
"initial_retry_delay_seconds": self.initial_retry_delay_seconds,
|
|
108
123
|
"max_retry_delay_seconds": self.max_retry_delay_seconds,
|
|
109
124
|
"file_logging_enabled": self.file_logging_enabled,
|
|
110
125
|
"log_retention_days": self.log_retention_days,
|
|
126
|
+
"scheduled_autofocus_enabled": self.scheduled_autofocus_enabled,
|
|
127
|
+
"autofocus_interval_minutes": self.autofocus_interval_minutes,
|
|
128
|
+
"last_autofocus_timestamp": self.last_autofocus_timestamp,
|
|
111
129
|
}
|
|
112
130
|
|
|
113
131
|
def save(self) -> None:
|
|
@@ -125,6 +143,9 @@ class CitraScopeSettings:
|
|
|
125
143
|
Args:
|
|
126
144
|
config: Configuration dict with flat adapter_settings for current adapter.
|
|
127
145
|
"""
|
|
146
|
+
# Remove runtime-only settings that should never be persisted
|
|
147
|
+
config.pop("web_port", None)
|
|
148
|
+
|
|
128
149
|
# Nest incoming adapter_settings under hardware_adapter key
|
|
129
150
|
adapter = config.get("hardware_adapter", self.hardware_adapter)
|
|
130
151
|
if adapter:
|
citrascope/tasks/runner.py
CHANGED
|
@@ -43,6 +43,9 @@ class TaskManager:
|
|
|
43
43
|
# Task processing control (always starts active)
|
|
44
44
|
self._processing_active = True
|
|
45
45
|
self._processing_lock = threading.Lock()
|
|
46
|
+
# Autofocus request flag (set by manual or scheduled triggers)
|
|
47
|
+
self._autofocus_requested = False
|
|
48
|
+
self._autofocus_lock = threading.Lock()
|
|
46
49
|
|
|
47
50
|
def poll_tasks(self):
|
|
48
51
|
while not self._stop_event.is_set():
|
|
@@ -219,6 +222,20 @@ class TaskManager:
|
|
|
219
222
|
except Exception as e:
|
|
220
223
|
self.logger.error(f"Exception in task_runner loop: {e}", exc_info=True)
|
|
221
224
|
time.sleep(5) # avoid tight error loop
|
|
225
|
+
|
|
226
|
+
# Check for autofocus requests between tasks
|
|
227
|
+
with self._autofocus_lock:
|
|
228
|
+
should_autofocus = self._autofocus_requested
|
|
229
|
+
if should_autofocus:
|
|
230
|
+
self._autofocus_requested = False # Clear flag before execution
|
|
231
|
+
# Also check if scheduled autofocus should run (inside lock to prevent race condition)
|
|
232
|
+
elif self._should_run_scheduled_autofocus():
|
|
233
|
+
should_autofocus = True
|
|
234
|
+
self._autofocus_requested = False # Ensure flag is clear
|
|
235
|
+
|
|
236
|
+
if should_autofocus:
|
|
237
|
+
self._execute_autofocus()
|
|
238
|
+
|
|
222
239
|
self._stop_event.wait(1)
|
|
223
240
|
|
|
224
241
|
def _observe_satellite(self, task: Task):
|
|
@@ -278,6 +295,92 @@ class TaskManager:
|
|
|
278
295
|
with self._processing_lock:
|
|
279
296
|
return self._processing_active
|
|
280
297
|
|
|
298
|
+
def request_autofocus(self) -> bool:
|
|
299
|
+
"""Request autofocus to run at next safe point between tasks.
|
|
300
|
+
|
|
301
|
+
Returns:
|
|
302
|
+
bool: True indicating request was queued.
|
|
303
|
+
"""
|
|
304
|
+
with self._autofocus_lock:
|
|
305
|
+
self._autofocus_requested = True
|
|
306
|
+
self.logger.info("Autofocus requested - will run between tasks")
|
|
307
|
+
return True
|
|
308
|
+
|
|
309
|
+
def cancel_autofocus(self) -> bool:
|
|
310
|
+
"""Cancel pending autofocus request if still queued.
|
|
311
|
+
|
|
312
|
+
Returns:
|
|
313
|
+
bool: True if autofocus was cancelled, False if nothing to cancel.
|
|
314
|
+
"""
|
|
315
|
+
with self._autofocus_lock:
|
|
316
|
+
was_requested = self._autofocus_requested
|
|
317
|
+
self._autofocus_requested = False
|
|
318
|
+
if was_requested:
|
|
319
|
+
self.logger.info("Autofocus request cancelled")
|
|
320
|
+
return was_requested
|
|
321
|
+
|
|
322
|
+
def is_autofocus_requested(self) -> bool:
|
|
323
|
+
"""Check if autofocus is currently requested/queued.
|
|
324
|
+
|
|
325
|
+
Returns:
|
|
326
|
+
bool: True if autofocus is queued, False otherwise.
|
|
327
|
+
"""
|
|
328
|
+
with self._autofocus_lock:
|
|
329
|
+
return self._autofocus_requested
|
|
330
|
+
|
|
331
|
+
def _should_run_scheduled_autofocus(self) -> bool:
|
|
332
|
+
"""Check if scheduled autofocus should run based on settings.
|
|
333
|
+
|
|
334
|
+
Returns:
|
|
335
|
+
bool: True if autofocus is enabled and interval has elapsed.
|
|
336
|
+
"""
|
|
337
|
+
if not self.settings:
|
|
338
|
+
return False
|
|
339
|
+
|
|
340
|
+
# Check if scheduled autofocus is enabled (top-level setting)
|
|
341
|
+
if not self.settings.scheduled_autofocus_enabled:
|
|
342
|
+
return False
|
|
343
|
+
|
|
344
|
+
# Check if adapter supports autofocus
|
|
345
|
+
if not self.hardware_adapter.supports_autofocus():
|
|
346
|
+
return False
|
|
347
|
+
|
|
348
|
+
interval_minutes = self.settings.autofocus_interval_minutes
|
|
349
|
+
last_timestamp = self.settings.last_autofocus_timestamp
|
|
350
|
+
|
|
351
|
+
# If never run (None), treat as overdue and run immediately
|
|
352
|
+
if last_timestamp is None:
|
|
353
|
+
return True
|
|
354
|
+
|
|
355
|
+
# Check if interval has elapsed
|
|
356
|
+
elapsed_minutes = (int(time.time()) - last_timestamp) / 60
|
|
357
|
+
return elapsed_minutes >= interval_minutes
|
|
358
|
+
|
|
359
|
+
def _execute_autofocus(self) -> None:
|
|
360
|
+
"""Execute autofocus routine and update timestamp on both success and failure."""
|
|
361
|
+
try:
|
|
362
|
+
self.logger.info("Starting autofocus routine...")
|
|
363
|
+
self.hardware_adapter.do_autofocus()
|
|
364
|
+
|
|
365
|
+
# Save updated filter configuration after autofocus
|
|
366
|
+
if self.hardware_adapter.supports_filter_management():
|
|
367
|
+
try:
|
|
368
|
+
filter_config = self.hardware_adapter.get_filter_config()
|
|
369
|
+
if filter_config and self.settings:
|
|
370
|
+
self.settings.adapter_settings["filters"] = filter_config
|
|
371
|
+
self.logger.info(f"Saved filter configuration with {len(filter_config)} filters")
|
|
372
|
+
except Exception as e:
|
|
373
|
+
self.logger.warning(f"Failed to save filter configuration after autofocus: {e}")
|
|
374
|
+
|
|
375
|
+
self.logger.info("Autofocus routine completed successfully")
|
|
376
|
+
except Exception as e:
|
|
377
|
+
self.logger.error(f"Autofocus failed: {str(e)}", exc_info=True)
|
|
378
|
+
finally:
|
|
379
|
+
# Always update timestamp to prevent retry spam
|
|
380
|
+
if self.settings:
|
|
381
|
+
self.settings.last_autofocus_timestamp = int(time.time())
|
|
382
|
+
self.settings.save()
|
|
383
|
+
|
|
281
384
|
def start(self):
|
|
282
385
|
self._stop_event.clear()
|
|
283
386
|
self.poll_thread = threading.Thread(target=self.poll_tasks, daemon=True)
|
|
@@ -17,7 +17,12 @@ class StaticTelescopeTask(AbstractBaseTelescopeTask):
|
|
|
17
17
|
filepaths = self.hardware_adapter.take_image(self.task.id, 2.0) # 2 second exposure
|
|
18
18
|
|
|
19
19
|
if self.hardware_adapter.get_observation_strategy() == ObservationStrategy.SEQUENCE_TO_CONTROLLER:
|
|
20
|
-
#
|
|
20
|
+
# Calculate current satellite position and add to satellite_data
|
|
21
|
+
target_ra, target_dec, _, _ = self.get_target_radec_and_rates(satellite_data)
|
|
22
|
+
satellite_data["ra"] = target_ra.degrees
|
|
23
|
+
satellite_data["dec"] = target_dec.degrees
|
|
24
|
+
|
|
25
|
+
# Sequence-based adapters handle pointing and tracking themselves
|
|
21
26
|
filepaths = self.hardware_adapter.perform_observation_sequence(self.task.id, satellite_data)
|
|
22
27
|
|
|
23
28
|
# Take the image
|