citrascope 0.6.1__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 +29 -67
- citrascope/hardware/nina_adv_http_adapter.py +74 -59
- citrascope/hardware/nina_adv_http_survey_template.json +4 -4
- citrascope/settings/citrascope_settings.py +19 -0
- citrascope/tasks/runner.py +103 -0
- 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.6.1.dist-info → citrascope-0.7.0.dist-info}/METADATA +1 -1
- {citrascope-0.6.1.dist-info → citrascope-0.7.0.dist-info}/RECORD +16 -16
- {citrascope-0.6.1.dist-info → citrascope-0.7.0.dist-info}/WHEEL +0 -0
- {citrascope-0.6.1.dist-info → citrascope-0.7.0.dist-info}/entry_points.txt +0 -0
- {citrascope-0.6.1.dist-info → citrascope-0.7.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -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
|
|
|
@@ -107,6 +123,9 @@ class CitraScopeSettings:
|
|
|
107
123
|
"max_retry_delay_seconds": self.max_retry_delay_seconds,
|
|
108
124
|
"file_logging_enabled": self.file_logging_enabled,
|
|
109
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,
|
|
110
129
|
}
|
|
111
130
|
|
|
112
131
|
def save(self) -> None:
|
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)
|
citrascope/web/app.py
CHANGED
|
@@ -37,6 +37,9 @@ class SystemStatus(BaseModel):
|
|
|
37
37
|
ground_station_id: Optional[str] = None
|
|
38
38
|
ground_station_name: Optional[str] = None
|
|
39
39
|
ground_station_url: Optional[str] = None
|
|
40
|
+
autofocus_requested: bool = False
|
|
41
|
+
last_autofocus_timestamp: Optional[int] = None
|
|
42
|
+
next_autofocus_minutes: Optional[int] = None
|
|
40
43
|
last_update: str = ""
|
|
41
44
|
|
|
42
45
|
|
|
@@ -182,6 +185,9 @@ class CitraScopeWebApp:
|
|
|
182
185
|
"max_task_retries": settings.max_task_retries,
|
|
183
186
|
"initial_retry_delay_seconds": settings.initial_retry_delay_seconds,
|
|
184
187
|
"max_retry_delay_seconds": settings.max_retry_delay_seconds,
|
|
188
|
+
"scheduled_autofocus_enabled": settings.scheduled_autofocus_enabled,
|
|
189
|
+
"autofocus_interval_minutes": settings.autofocus_interval_minutes,
|
|
190
|
+
"last_autofocus_timestamp": settings.last_autofocus_timestamp,
|
|
185
191
|
"app_url": app_url,
|
|
186
192
|
"config_file_path": config_path,
|
|
187
193
|
"log_file_path": log_file_path,
|
|
@@ -241,13 +247,6 @@ class CitraScopeWebApp:
|
|
|
241
247
|
if not self.daemon:
|
|
242
248
|
return JSONResponse({"error": "Daemon not available"}, status_code=503)
|
|
243
249
|
|
|
244
|
-
# Block config changes during autofocus
|
|
245
|
-
if self.daemon.is_autofocus_in_progress():
|
|
246
|
-
return JSONResponse(
|
|
247
|
-
{"error": "Cannot save configuration while autofocus is running. Check logs for progress."},
|
|
248
|
-
status_code=409,
|
|
249
|
-
)
|
|
250
|
-
|
|
251
250
|
# Validate required fields
|
|
252
251
|
required_fields = ["personal_access_token", "telescope_id", "hardware_adapter"]
|
|
253
252
|
for field in required_fields:
|
|
@@ -392,13 +391,6 @@ class CitraScopeWebApp:
|
|
|
392
391
|
if not self.daemon or not self.daemon.task_manager:
|
|
393
392
|
return JSONResponse({"error": "Task manager not available"}, status_code=503)
|
|
394
393
|
|
|
395
|
-
# Block resume during autofocus
|
|
396
|
-
if self.daemon.is_autofocus_in_progress():
|
|
397
|
-
return JSONResponse(
|
|
398
|
-
{"error": "Cannot resume task processing during autofocus. Check logs for progress."},
|
|
399
|
-
status_code=409,
|
|
400
|
-
)
|
|
401
|
-
|
|
402
394
|
self.daemon.task_manager.resume()
|
|
403
395
|
await self.broadcast_status()
|
|
404
396
|
|
|
@@ -422,7 +414,8 @@ class CitraScopeWebApp:
|
|
|
422
414
|
|
|
423
415
|
@self.app.patch("/api/adapter/filters/{filter_id}")
|
|
424
416
|
async def update_filter(filter_id: str, update: dict):
|
|
425
|
-
"""Update focus position for a specific filter."""
|
|
417
|
+
"""Update focus position and/or enabled state for a specific filter."""
|
|
418
|
+
|
|
426
419
|
if not self.daemon or not self.daemon.hardware_adapter:
|
|
427
420
|
return JSONResponse({"error": "Hardware adapter not available"}, status_code=503)
|
|
428
421
|
|
|
@@ -430,31 +423,50 @@ class CitraScopeWebApp:
|
|
|
430
423
|
return JSONResponse({"error": "Adapter does not support filter management"}, status_code=404)
|
|
431
424
|
|
|
432
425
|
try:
|
|
433
|
-
# Validate focus_position is provided and is a valid integer within hardware limits
|
|
434
|
-
if "focus_position" not in update:
|
|
435
|
-
return JSONResponse({"error": "focus_position is required"}, status_code=400)
|
|
436
|
-
|
|
437
|
-
focus_position = update["focus_position"]
|
|
438
|
-
if not isinstance(focus_position, int):
|
|
439
|
-
return JSONResponse({"error": "focus_position must be an integer"}, status_code=400)
|
|
440
|
-
|
|
441
|
-
# Typical focuser range is 0-65535 (16-bit unsigned)
|
|
442
|
-
if focus_position < 0 or focus_position > 65535:
|
|
443
|
-
return JSONResponse({"error": "focus_position must be between 0 and 65535"}, status_code=400)
|
|
444
|
-
|
|
445
426
|
# Get current filter config
|
|
446
427
|
filter_config = self.daemon.hardware_adapter.get_filter_config()
|
|
447
428
|
if filter_id not in filter_config:
|
|
448
429
|
return JSONResponse({"error": f"Filter {filter_id} not found"}, status_code=404)
|
|
449
430
|
|
|
450
|
-
#
|
|
451
|
-
if
|
|
452
|
-
|
|
431
|
+
# Validate focus_position if provided
|
|
432
|
+
if "focus_position" in update:
|
|
433
|
+
focus_position = update["focus_position"]
|
|
434
|
+
if not isinstance(focus_position, int):
|
|
435
|
+
return JSONResponse({"error": "focus_position must be an integer"}, status_code=400)
|
|
436
|
+
|
|
437
|
+
# Typical focuser range is 0-65535 (16-bit unsigned)
|
|
438
|
+
if focus_position < 0 or focus_position > 65535:
|
|
439
|
+
return JSONResponse({"error": "focus_position must be between 0 and 65535"}, status_code=400)
|
|
440
|
+
|
|
441
|
+
# Update focus position via adapter interface
|
|
442
|
+
if not self.daemon.hardware_adapter.update_filter_focus(filter_id, focus_position):
|
|
443
|
+
return JSONResponse({"error": "Failed to update filter focus in adapter"}, status_code=500)
|
|
444
|
+
|
|
445
|
+
# Validate and update enabled state if provided
|
|
446
|
+
if "enabled" in update:
|
|
447
|
+
enabled = update["enabled"]
|
|
448
|
+
if not isinstance(enabled, bool):
|
|
449
|
+
return JSONResponse({"error": "enabled must be a boolean"}, status_code=400)
|
|
450
|
+
|
|
451
|
+
# Belt and suspenders: Ensure at least one filter remains enabled
|
|
452
|
+
if not enabled:
|
|
453
|
+
enabled_count = sum(1 for f in filter_config.values() if f.get("enabled", True))
|
|
454
|
+
if enabled_count <= 1:
|
|
455
|
+
return JSONResponse(
|
|
456
|
+
{
|
|
457
|
+
"error": "Cannot disable last enabled filter. At least one filter must remain enabled."
|
|
458
|
+
},
|
|
459
|
+
status_code=400,
|
|
460
|
+
)
|
|
461
|
+
|
|
462
|
+
# Update enabled state via adapter interface
|
|
463
|
+
if not self.daemon.hardware_adapter.update_filter_enabled(filter_id, enabled):
|
|
464
|
+
return JSONResponse({"error": "Failed to update filter enabled state"}, status_code=500)
|
|
453
465
|
|
|
454
466
|
# Save to settings
|
|
455
467
|
self.daemon._save_filter_config()
|
|
456
468
|
|
|
457
|
-
return {"success": True, "filter_id": filter_id, "
|
|
469
|
+
return {"success": True, "filter_id": filter_id, "updated": update}
|
|
458
470
|
except ValueError:
|
|
459
471
|
return JSONResponse({"error": "Invalid filter_id format"}, status_code=400)
|
|
460
472
|
except Exception as e:
|
|
@@ -463,7 +475,7 @@ class CitraScopeWebApp:
|
|
|
463
475
|
|
|
464
476
|
@self.app.post("/api/adapter/autofocus")
|
|
465
477
|
async def trigger_autofocus():
|
|
466
|
-
"""
|
|
478
|
+
"""Request autofocus to run between tasks."""
|
|
467
479
|
if not self.daemon:
|
|
468
480
|
return JSONResponse({"error": "Daemon not available"}, status_code=503)
|
|
469
481
|
|
|
@@ -471,15 +483,26 @@ class CitraScopeWebApp:
|
|
|
471
483
|
return JSONResponse({"error": "Filter management not supported"}, status_code=404)
|
|
472
484
|
|
|
473
485
|
try:
|
|
474
|
-
|
|
475
|
-
# Autofocus can take several minutes (slewing + focusing multiple filters)
|
|
476
|
-
success, error = await asyncio.to_thread(self.daemon.trigger_autofocus)
|
|
486
|
+
success, error = self.daemon.trigger_autofocus()
|
|
477
487
|
if success:
|
|
478
|
-
return {"success": True, "message": "Autofocus
|
|
488
|
+
return {"success": True, "message": "Autofocus queued - will run between tasks"}
|
|
479
489
|
else:
|
|
480
490
|
return JSONResponse({"error": error}, status_code=500)
|
|
481
491
|
except Exception as e:
|
|
482
|
-
CITRASCOPE_LOGGER.error(f"Error
|
|
492
|
+
CITRASCOPE_LOGGER.error(f"Error queueing autofocus: {e}", exc_info=True)
|
|
493
|
+
return JSONResponse({"error": str(e)}, status_code=500)
|
|
494
|
+
|
|
495
|
+
@self.app.post("/api/adapter/autofocus/cancel")
|
|
496
|
+
async def cancel_autofocus():
|
|
497
|
+
"""Cancel pending autofocus request."""
|
|
498
|
+
if not self.daemon:
|
|
499
|
+
return JSONResponse({"error": "Daemon not available"}, status_code=503)
|
|
500
|
+
|
|
501
|
+
try:
|
|
502
|
+
was_cancelled = self.daemon.cancel_autofocus()
|
|
503
|
+
return {"success": was_cancelled}
|
|
504
|
+
except Exception as e:
|
|
505
|
+
CITRASCOPE_LOGGER.error(f"Error cancelling autofocus: {e}", exc_info=True)
|
|
483
506
|
return JSONResponse({"error": str(e)}, status_code=500)
|
|
484
507
|
|
|
485
508
|
@self.app.websocket("/ws")
|
|
@@ -533,9 +556,31 @@ class CitraScopeWebApp:
|
|
|
533
556
|
if hasattr(self.daemon, "task_manager") and self.daemon.task_manager:
|
|
534
557
|
task_manager = self.daemon.task_manager
|
|
535
558
|
self.status.current_task = task_manager.current_task_id
|
|
559
|
+
self.status.autofocus_requested = task_manager.is_autofocus_requested()
|
|
536
560
|
with task_manager.heap_lock:
|
|
537
561
|
self.status.tasks_pending = len(task_manager.task_heap)
|
|
538
562
|
|
|
563
|
+
# Get autofocus timing information
|
|
564
|
+
if self.daemon.settings:
|
|
565
|
+
settings = self.daemon.settings
|
|
566
|
+
self.status.last_autofocus_timestamp = settings.last_autofocus_timestamp
|
|
567
|
+
|
|
568
|
+
# Calculate next autofocus time if scheduled is enabled
|
|
569
|
+
if settings.scheduled_autofocus_enabled:
|
|
570
|
+
last_ts = settings.last_autofocus_timestamp
|
|
571
|
+
interval_minutes = settings.autofocus_interval_minutes
|
|
572
|
+
if last_ts is not None:
|
|
573
|
+
import time
|
|
574
|
+
|
|
575
|
+
elapsed_minutes = (int(time.time()) - last_ts) / 60
|
|
576
|
+
remaining = max(0, interval_minutes - elapsed_minutes)
|
|
577
|
+
self.status.next_autofocus_minutes = int(remaining)
|
|
578
|
+
else:
|
|
579
|
+
# Never run - will trigger immediately
|
|
580
|
+
self.status.next_autofocus_minutes = 0
|
|
581
|
+
else:
|
|
582
|
+
self.status.next_autofocus_minutes = None
|
|
583
|
+
|
|
539
584
|
# Get ground station information from daemon (available after API validation)
|
|
540
585
|
if hasattr(self.daemon, "ground_station") and self.daemon.ground_station:
|
|
541
586
|
gs_record = self.daemon.ground_station
|
citrascope/web/static/app.js
CHANGED
|
@@ -393,6 +393,89 @@ function updateStatus(status) {
|
|
|
393
393
|
if (status.processing_active !== undefined) {
|
|
394
394
|
updateProcessingState(status.processing_active);
|
|
395
395
|
}
|
|
396
|
+
|
|
397
|
+
// Update autofocus status
|
|
398
|
+
updateAutofocusStatus(status);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function updateAutofocusStatus(status) {
|
|
402
|
+
// Update autofocus button state
|
|
403
|
+
const button = document.getElementById('runAutofocusButton');
|
|
404
|
+
const buttonText = document.getElementById('autofocusButtonText');
|
|
405
|
+
|
|
406
|
+
if (button && buttonText && status.autofocus_requested !== undefined) {
|
|
407
|
+
if (status.autofocus_requested) {
|
|
408
|
+
buttonText.textContent = 'Cancel Autofocus';
|
|
409
|
+
button.dataset.action = 'cancel';
|
|
410
|
+
button.classList.remove('btn-outline-primary');
|
|
411
|
+
button.classList.add('btn-outline-warning');
|
|
412
|
+
} else {
|
|
413
|
+
buttonText.textContent = 'Run Autofocus';
|
|
414
|
+
button.dataset.action = 'request';
|
|
415
|
+
button.classList.remove('btn-outline-warning');
|
|
416
|
+
button.classList.add('btn-outline-primary');
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// Update last autofocus display
|
|
421
|
+
const lastAutofocusDisplay = document.getElementById('lastAutofocusDisplay');
|
|
422
|
+
if (lastAutofocusDisplay) {
|
|
423
|
+
if (status.last_autofocus_timestamp) {
|
|
424
|
+
const timestamp = status.last_autofocus_timestamp * 1000; // Convert to milliseconds
|
|
425
|
+
const now = Date.now();
|
|
426
|
+
const elapsed = now - timestamp;
|
|
427
|
+
lastAutofocusDisplay.textContent = formatElapsedTime(elapsed);
|
|
428
|
+
} else {
|
|
429
|
+
lastAutofocusDisplay.textContent = 'Never';
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// Update next autofocus display
|
|
434
|
+
const nextAutofocusDisplay = document.getElementById('nextAutofocusDisplay');
|
|
435
|
+
const nextAutofocusTime = document.getElementById('nextAutofocusTime');
|
|
436
|
+
|
|
437
|
+
if (nextAutofocusDisplay && nextAutofocusTime) {
|
|
438
|
+
if (status.next_autofocus_minutes !== null && status.next_autofocus_minutes !== undefined) {
|
|
439
|
+
nextAutofocusDisplay.style.display = 'block';
|
|
440
|
+
if (status.next_autofocus_minutes === 0) {
|
|
441
|
+
nextAutofocusTime.textContent = 'now (overdue)';
|
|
442
|
+
} else {
|
|
443
|
+
nextAutofocusTime.textContent = formatMinutes(status.next_autofocus_minutes);
|
|
444
|
+
}
|
|
445
|
+
} else {
|
|
446
|
+
nextAutofocusDisplay.style.display = 'none';
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function formatElapsedTime(milliseconds) {
|
|
452
|
+
const seconds = Math.floor(milliseconds / 1000);
|
|
453
|
+
const minutes = Math.floor(seconds / 60);
|
|
454
|
+
const hours = Math.floor(minutes / 60);
|
|
455
|
+
const days = Math.floor(hours / 24);
|
|
456
|
+
|
|
457
|
+
if (days > 0) {
|
|
458
|
+
return `${days} day${days !== 1 ? 's' : ''} ago`;
|
|
459
|
+
} else if (hours > 0) {
|
|
460
|
+
return `${hours} hour${hours !== 1 ? 's' : ''} ago`;
|
|
461
|
+
} else if (minutes > 0) {
|
|
462
|
+
return `${minutes} minute${minutes !== 1 ? 's' : ''} ago`;
|
|
463
|
+
} else {
|
|
464
|
+
return 'just now';
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function formatMinutes(minutes) {
|
|
469
|
+
const hours = Math.floor(minutes / 60);
|
|
470
|
+
const mins = Math.floor(minutes % 60);
|
|
471
|
+
|
|
472
|
+
if (hours > 0) {
|
|
473
|
+
if (mins > 0) {
|
|
474
|
+
return `${hours}h ${mins}m`;
|
|
475
|
+
}
|
|
476
|
+
return `${hours}h`;
|
|
477
|
+
}
|
|
478
|
+
return `${mins}m`;
|
|
396
479
|
}
|
|
397
480
|
|
|
398
481
|
function updateProcessingState(isActive) {
|