Kstars

scheduler.cpp
1 /*
2  SPDX-FileCopyrightText: 2015 Jasem Mutlaq <[email protected]>
3 
4  DBus calls from GSoC 2015 Ekos Scheduler project:
5  SPDX-FileCopyrightText: 2015 Daniel Leu <[email protected]>
6 
7  SPDX-License-Identifier: GPL-2.0-or-later
8 */
9 
10 #include "scheduler.h"
11 
12 #include "ksalmanac.h"
13 #include "ksnotification.h"
14 #include "kstars.h"
15 #include "kstarsdata.h"
16 #include "ksutils.h"
17 #include "skymap.h"
18 #include "mosaic.h"
19 #include "Options.h"
20 #include "scheduleradaptor.h"
21 #include "schedulerjob.h"
22 #include "skymapcomposite.h"
23 #include "skycomponents/mosaiccomponent.h"
24 #include "skyobjects/mosaictiles.h"
25 #include "auxiliary/QProgressIndicator.h"
26 #include "dialogs/finddialog.h"
27 #include "ekos/manager.h"
28 #include "ekos/capture/sequencejob.h"
29 #include "ekos/capture/placeholderpath.h"
30 #include "skyobjects/starobject.h"
31 #include "greedyscheduler.h"
32 
33 #include <KNotifications/KNotification>
34 #include <KConfigDialog>
35 #include <KActionCollection>
36 
37 #include <fitsio.h>
38 #include <ekos_scheduler_debug.h>
39 #include <indicom.h>
40 
41 // Qt version calming
42 #include <qtendl.h>
43 
44 #define BAD_SCORE -1000
45 #define MAX_FAILURE_ATTEMPTS 5
46 #define RESTART_GUIDING_DELAY_MS 5000
47 
48 #define DEFAULT_CULMINATION_TIME -60
49 #define DEFAULT_MIN_ALTITUDE 15
50 #define DEFAULT_MIN_MOON_SEPARATION 0
51 
52 // This is a temporary debugging printout introduced while gaining experience developing
53 // the unit tests in test_ekos_scheduler_ops.cpp.
54 // All these printouts should be eventually removed.
55 #define TEST_PRINT if (false) fprintf
56 
57 namespace
58 {
59 
60 // This needs to match the definition order for the QueueTable in scheduler.ui
61 enum QueueTableColumns
62 {
63  NAME_COLUMN = 0,
64  STATUS_COLUMN,
65  CAPTURES_COLUMN,
66  ALTITUDE_COLUMN,
67  SCORE_COLUMN,
68  START_TIME_COLUMN,
69  END_TIME_COLUMN,
70  ESTIMATED_DURATION_COLUMN,
71  LEAD_TIME_COLUMN
72 };
73 }
74 
75 namespace Ekos
76 {
77 
78 // Functions to make human-readable debug messages for the various enums.
79 
81 {
82  switch (state)
83  {
84  case Scheduler::RUN_WAKEUP:
85  return QString("RUN_WAKEUP");
86  case Scheduler::RUN_SCHEDULER:
87  return QString("RUN_SCHEDULER");
88  case Scheduler::RUN_JOBCHECK:
89  return QString("RUN_JOBCHECK");
90  case Scheduler::RUN_SHUTDOWN:
91  return QString("RUN_SHUTDOWN");
92  case Scheduler::RUN_NOTHING:
93  return QString("RUN_NOTHING");
94  }
95  return QString("????");
96 }
97 
98 QString ekosStateString(Scheduler::EkosState state)
99 {
100  switch(state)
101  {
102  case Scheduler::EKOS_IDLE:
103  return "EKOS_IDLE";
104  case Scheduler::EKOS_STARTING:
105  return "EKOS_STARTING";
106  case Scheduler::EKOS_STOPPING:
107  return "EKOS_STOPPING";
108  case Scheduler::EKOS_READY:
109  return "EKOS_READY";
110  }
111  return QString("????");
112 }
113 
114 QString indiStateString(Scheduler::INDIState state)
115 {
116  switch(state)
117  {
118  case Scheduler::INDI_IDLE:
119  return "INDI_IDLE";
120  case Scheduler::INDI_PROPERTY_CHECK:
121  return "INDI_PROPERTY_CHECK";
122  case Scheduler::INDI_CONNECTING:
123  return "INDI_CONNECTING";
124  case Scheduler::INDI_DISCONNECTING:
125  return "INDI_DISCONNECTING";
126  case Scheduler::INDI_READY:
127  return "INDI_READY";
128  }
129  return QString("????");
130 }
131 
132 QString startupStateString(Scheduler::StartupState state)
133 {
134  switch(state)
135  {
136  case Scheduler::STARTUP_IDLE:
137  return "STARTUP_IDLE";
138  case Scheduler::STARTUP_SCRIPT:
139  return "STARTUP_SCRIPT";
140  case Scheduler::STARTUP_UNPARK_DOME:
141  return "STARTUP_UNPARK_DOME";
142  case Scheduler::STARTUP_UNPARKING_DOME:
143  return "STARTUP_UNPARKING_DOME";
144  case Scheduler::STARTUP_UNPARK_MOUNT:
145  return "STARTUP_UNPARK_MOUNT";
146  case Scheduler::STARTUP_UNPARKING_MOUNT:
147  return "STARTUP_UNPARKING_MOUNT";
148  case Scheduler::STARTUP_UNPARK_CAP:
149  return "STARTUP_UNPARK_CAP";
150  case Scheduler::STARTUP_UNPARKING_CAP:
151  return "STARTUP_UNPARKING_CAP";
152  case Scheduler::STARTUP_ERROR:
153  return "STARTUP_ERROR";
154  case Scheduler::STARTUP_COMPLETE:
155  return "STARTUP_COMPLETE";
156  }
157  return QString("????");
158 }
159 
160 QString shutdownStateString(Scheduler::ShutdownState state)
161 {
162  switch(state)
163  {
164  case Scheduler::SHUTDOWN_IDLE:
165  return "SHUTDOWN_IDLE";
166  case Scheduler::SHUTDOWN_PARK_CAP:
167  return "SHUTDOWN_PARK_CAP";
168  case Scheduler::SHUTDOWN_PARKING_CAP:
169  return "SHUTDOWN_PARKING_CAP";
170  case Scheduler::SHUTDOWN_PARK_MOUNT:
171  return "SHUTDOWN_PARK_MOUNT";
172  case Scheduler::SHUTDOWN_PARKING_MOUNT:
173  return "SHUTDOWN_PARKING_MOUNT";
174  case Scheduler::SHUTDOWN_PARK_DOME:
175  return "SHUTDOWN_PARK_DOME";
176  case Scheduler::SHUTDOWN_PARKING_DOME:
177  return "SHUTDOWN_PARKING_DOME";
178  case Scheduler::SHUTDOWN_SCRIPT:
179  return "SHUTDOWN_SCRIPT";
180  case Scheduler::SHUTDOWN_SCRIPT_RUNNING:
181  return "SHUTDOWN_SCRIPT_RUNNING";
182  case Scheduler::SHUTDOWN_ERROR:
183  return "SHUTDOWN_ERROR";
184  case Scheduler::SHUTDOWN_COMPLETE:
185  return "SHUTDOWN_COMPLETE";
186  }
187  return QString("????");
188 }
189 
190 QString parkWaitStateString(Scheduler::ParkWaitStatus state)
191 {
192  switch(state)
193  {
194  case Scheduler::PARKWAIT_IDLE:
195  return "PARKWAIT_IDLE";
196  case Scheduler::PARKWAIT_PARK:
197  return "PARKWAIT_PARK";
198  case Scheduler::PARKWAIT_PARKING:
199  return "PARKWAIT_PARKING";
200  case Scheduler::PARKWAIT_PARKED:
201  return "PARKWAIT_PARKED";
202  case Scheduler::PARKWAIT_UNPARK:
203  return "PARKWAIT_UNPARK";
204  case Scheduler::PARKWAIT_UNPARKING:
205  return "PARKWAIT_UNPARKING";
206  case Scheduler::PARKWAIT_UNPARKED:
207  return "PARKWAIT_UNPARKED";
208  case Scheduler::PARKWAIT_ERROR:
209  return "PARKWAIT_ERROR";
210  }
211  return QString("????");
212 }
213 
214 QString commStatusString(Ekos::CommunicationStatus state)
215 {
216  switch(state)
217  {
218  case Ekos::Idle:
219  return "Idle";
220  case Ekos::Pending:
221  return "Pending";
222  case Ekos::Success:
223  return "Success";
224  case Ekos::Error:
225  return "Error";
226  }
227  return QString("????");
228 }
229 
230 QString schedulerStateString(Ekos::SchedulerState state)
231 {
232  switch(state)
233  {
234  case Ekos::SCHEDULER_IDLE:
235  return "SCHEDULER_IDLE";
236  case Ekos::SCHEDULER_STARTUP:
237  return "SCHEDULER_STARTUP";
238  case Ekos::SCHEDULER_RUNNING:
239  return "SCHEDULER_RUNNING";
240  case Ekos::SCHEDULER_PAUSED:
241  return "SCHEDULER_PAUSED";
242  case Ekos::SCHEDULER_SHUTDOWN:
243  return "SCHEDULER_SHUTDOWN";
244  case Ekos::SCHEDULER_ABORTED:
245  return "SCHEDULER_ABORTED";
246  case Ekos::SCHEDULER_LOADING:
247  return "SCHEDULER_LOADING";
248  }
249  return QString("????");
250 }
251 
252 void Scheduler::printStates(const QString &label)
253 {
254  TEST_PRINT(stderr, "%s",
255  QString("%1 %2 %3%4 %5 %6 %7 %8 %9\n")
256  .arg(label)
257  .arg(timerStr(timerState))
258  .arg(schedulerStateString(state))
259  .arg((timerState == Scheduler::RUN_JOBCHECK && currentJob != nullptr) ?
260  QString("(%1 %2)").arg(SchedulerJob::jobStatusString(currentJob->getState()))
261  .arg(SchedulerJob::jobStageString(currentJob->getStage())) : "")
262  .arg(ekosStateString(ekosState))
263  .arg(indiStateString(indiState))
264  .arg(startupStateString(startupState))
265  .arg(shutdownStateString(shutdownState))
266  .arg(parkWaitStateString(parkWaitState)).toLatin1().data());
267 }
268 
269 QDateTime Scheduler::Dawn, Scheduler::Dusk, Scheduler::preDawnDateTime;
270 
271 // Allows for unit testing of static Scheduler methods,
272 // as can't call KStarsData::Instance() during unit testing.
273 KStarsDateTime *Scheduler::storedLocalTime = nullptr;
275 {
276  if (hasLocalTime())
277  return *storedLocalTime;
278  return KStarsData::Instance()->geo()->UTtoLT(KStarsData::Instance()->clock()->utc());
279 }
280 
281 // This is the initial conditions that need to be set before starting.
282 void Scheduler::init()
283 {
284  // This is needed to get wakeupScheduler() to call start() and startup,
285  // instead of assuming it is already initialized (if preemptiveShutdown was not set).
286  // The time itself is not used.
287  enablePreemptiveShutdown(getLocalTime());
288 
289  iterationSetup = false;
290  setupNextIteration(RUN_WAKEUP, 10);
291 }
292 
293 // Setup the main loop and start.
295 {
296  // New scheduler session shouldn't inherit ABORT or ERROR states from the last one.
297  foreach (auto j, jobs)
298  j->setState(SchedulerJob::JOB_IDLE);
299  init();
300  iterate();
301 }
302 
303 // This is the main scheduler loop.
304 // Run an iteration, get the sleep time, sleep for that interval, and repeat.
305 void Scheduler::iterate()
306 {
307  const int msSleep = runSchedulerIteration();
308  if (msSleep < 0)
309  return;
310 
311  connect(&iterationTimer, &QTimer::timeout, this, &Scheduler::iterate, Qt::UniqueConnection);
312  iterationTimer.setSingleShot(true);
313  iterationTimer.start(msSleep);
314 }
315 
316 bool Scheduler::currentlySleeping()
317 {
318  return iterationTimer.isActive() && timerState == RUN_WAKEUP;
319 }
320 
321 int Scheduler::runSchedulerIteration()
322 {
323  qint64 now = QDateTime::currentMSecsSinceEpoch();
324  if (startMSecs == 0)
325  startMSecs = now;
326 
327  printStates(QString("\nrunScheduler Iteration %1 @ %2")
328  .arg(++schedulerIteration)
329  .arg((now - startMSecs) / 1000.0, 1, 'f', 3));
330 
331  SchedulerTimerState keepTimerState = timerState;
332 
333  // TODO: At some point we should require that timerState and timerInterval
334  // be explicitly set in all iterations. Not there yet, would require too much
335  // refactoring of the scheduler. When we get there, we'd exectute the following here:
336  // timerState = RUN_NOTHING; // don't like this comment, it should always set a state and interval!
337  // timerInterval = -1;
338  iterationSetup = false;
339  switch (keepTimerState)
340  {
341  case RUN_WAKEUP:
342  wakeUpScheduler();
343  break;
344  case RUN_SCHEDULER:
345  checkStatus();
346  break;
347  case RUN_JOBCHECK:
348  checkJobStage();
349  break;
350  case RUN_SHUTDOWN:
351  checkShutdownState();
352  break;
353  case RUN_NOTHING:
354  timerInterval = -1;
355  break;
356  }
357  if (!iterationSetup)
358  {
359  // See the above TODO.
360  // Since iterations aren't yet always set up, we repeat the current
361  // iteration type if one wasn't set up in the current iteration.
362  // qCDebug(KSTARS_EKOS_SCHEDULER) << "Scheduler iteration never set up.";
363  timerInterval = m_UpdatePeriodMs;
364  TEST_PRINT(stderr, "Scheduler iteration never set up--repeating %s with %d...\n",
365  timerStr(timerState).toLatin1().data(), timerInterval);
366  }
367  printStates(QString("End iteration, sleep %1: ").arg(timerInterval));
368  return timerInterval;
369 }
370 void Scheduler::setupNextIteration(SchedulerTimerState nextState)
371 {
372  setupNextIteration(nextState, m_UpdatePeriodMs);
373 }
374 
375 void Scheduler::setupNextIteration(SchedulerTimerState nextState, int milliseconds)
376 {
377  if (iterationSetup)
378  {
379  qCDebug(KSTARS_EKOS_SCHEDULER)
380  << QString("Multiple setupNextIteration calls: current %1 %2, previous %3 %4")
381  .arg(nextState).arg(milliseconds).arg(timerState).arg(timerInterval);
382  TEST_PRINT(stderr, "Multiple setupNextIteration calls: current %s %d, previous %s %d.\n",
383  timerStr(nextState).toLatin1().data(), milliseconds,
384  timerStr(timerState).toLatin1().data(), timerInterval);
385  }
386  timerState = nextState;
387  // check if setup is called from a thread outside of the iteration timer thread
388  if (iterationTimer.isActive())
389  {
390  // restart the timer to ensure the correct startup delay
391  int remaining = iterationTimer.remainingTime();
392  iterationTimer.stop();
393  timerInterval = std::max(0, milliseconds - remaining);
394  iterationTimer.start(timerInterval);
395  }
396  else
397  {
398  // setup called from inside the iteration timer thread
399  timerInterval = milliseconds;
400  }
401  iterationSetup = true;
402 }
403 
405 {
406  // Use the default path and interface when running the scheduler.
407  setupScheduler(ekosPathString, ekosInterfaceString);
408 }
409 
410 Scheduler::Scheduler(const QString path, const QString interface,
411  const QString &ekosPathStr, const QString &ekosInterfaceStr)
412 {
413  // During testing, when mocking ekos, use a special purpose path and interface.
414  schedulerPathString = path;
415  kstarsInterfaceString = interface;
416  setupScheduler(ekosPathStr, ekosInterfaceStr);
417 }
418 
419 void Scheduler::setupScheduler(const QString &ekosPathStr, const QString &ekosInterfaceStr)
420 {
421  setupUi(this);
422 
423  qRegisterMetaType<Ekos::SchedulerState>("Ekos::SchedulerState");
424  qDBusRegisterMetaType<Ekos::SchedulerState>();
425 
426  dirPath = QUrl::fromLocalFile(QDir::homePath());
427 
428  // Get current KStars time and set seconds to zero
429  QDateTime currentDateTime = getLocalTime();
430  QTime currentTime = currentDateTime.time();
431  currentTime.setHMS(currentTime.hour(), currentTime.minute(), 0);
432  currentDateTime.setTime(currentTime);
433 
434  // Set initial time for startup and completion times
435  startupTimeEdit->setDateTime(currentDateTime);
436  completionTimeEdit->setDateTime(currentDateTime);
437 
438  m_GreedyScheduler = new GreedyScheduler();
439 
440  // Set up DBus interfaces
441  new SchedulerAdaptor(this);
442  QDBusConnection::sessionBus().unregisterObject(schedulerPathString);
443  if (!QDBusConnection::sessionBus().registerObject(schedulerPathString, this))
444  qCDebug(KSTARS_EKOS_SCHEDULER) << QString("Scheduler failed to register with dbus");
445  ekosInterface = new QDBusInterface(kstarsInterfaceString, ekosPathStr, ekosInterfaceStr,
447 
448  // Example of connecting DBus signals
449  //connect(ekosInterface, SIGNAL(indiStatusChanged(Ekos::CommunicationStatus)), this, SLOT(setINDICommunicationStatus(Ekos::CommunicationStatus)));
450  //connect(ekosInterface, SIGNAL(ekosStatusChanged(Ekos::CommunicationStatus)), this, SLOT(setEkosCommunicationStatus(Ekos::CommunicationStatus)));
451  //connect(ekosInterface, SIGNAL(newModule(QString)), this, SLOT(registerNewModule(QString)));
452  QDBusConnection::sessionBus().connect(kstarsInterfaceString, ekosPathStr, ekosInterfaceStr, "newModule", this,
453  SLOT(registerNewModule(QString)));
454  QDBusConnection::sessionBus().connect(kstarsInterfaceString, ekosPathStr, ekosInterfaceStr, "newDevice", this,
455  SLOT(registerNewDevice(QString, int)));
456  QDBusConnection::sessionBus().connect(kstarsInterfaceString, ekosPathStr, ekosInterfaceStr, "indiStatusChanged",
457  this, SLOT(setINDICommunicationStatus(Ekos::CommunicationStatus)));
458  QDBusConnection::sessionBus().connect(kstarsInterfaceString, ekosPathStr, ekosInterfaceStr, "ekosStatusChanged",
459  this, SLOT(setEkosCommunicationStatus(Ekos::CommunicationStatus)));
460 
461  sleepLabel->setPixmap(
462  QIcon::fromTheme("chronometer").pixmap(QSize(32, 32)));
463  sleepLabel->hide();
464 
465  pi = new QProgressIndicator(this);
466  bottomLayout->addWidget(pi, 0);
467 
468  geo = KStarsData::Instance()->geo();
469 
470  //RA box should be HMS-style
471  raBox->setUnits(dmsBox::HOURS);
472 
473  /* FIXME: Find a way to have multi-line tooltips in the .ui file, then move the widget configuration there - what about i18n? */
474 
475  queueTable->setToolTip(
476  i18n("Job scheduler list.\nClick to select a job in the list.\nDouble click to edit a job with the left-hand fields."));
477 
478  /* Set first button mode to add observation job from left-hand fields */
479  setJobAddApply(true);
480 
481  removeFromQueueB->setIcon(QIcon::fromTheme("list-remove"));
482  removeFromQueueB->setToolTip(
483  i18n("Remove selected job from the observation list.\nJob properties are copied in the edition fields before removal."));
484  removeFromQueueB->setAttribute(Qt::WA_LayoutUsesWidgetRect);
485 
486  queueUpB->setIcon(QIcon::fromTheme("go-up"));
487  queueUpB->setToolTip(i18n("Move selected job one line up in the list.\n"
488  "Order only affect observation jobs that are scheduled to start at the same time.\n"
489  "Not available if option \"Sort jobs by Altitude and Priority\" is set."));
490  queueUpB->setAttribute(Qt::WA_LayoutUsesWidgetRect);
491  queueDownB->setIcon(QIcon::fromTheme("go-down"));
492  queueDownB->setToolTip(i18n("Move selected job one line down in the list.\n"
493  "Order only affect observation jobs that are scheduled to start at the same time.\n"
494  "Not available if option \"Sort jobs by Altitude and Priority\" is set."));
495  queueDownB->setAttribute(Qt::WA_LayoutUsesWidgetRect);
496 
497  evaluateOnlyB->setIcon(QIcon::fromTheme("system-reboot"));
498  evaluateOnlyB->setToolTip(i18n("Reset state and force reevaluation of all observation jobs."));
499  evaluateOnlyB->setAttribute(Qt::WA_LayoutUsesWidgetRect);
500  sortJobsB->setIcon(QIcon::fromTheme("transform-move-vertical"));
501  sortJobsB->setToolTip(
502  i18n("Reset state and sort observation jobs per altitude and movement in sky, using the start time of the first job.\n"
503  "This action sorts setting targets before rising targets, and may help scheduling when starting your observation.\n"
504  "Option \"Sort Jobs by Altitude and Priority\" keeps the job list sorted this way, but with current time as reference.\n"
505  "Note the algorithm first calculates all altitudes using the same time, then evaluates jobs."));
506  sortJobsB->setAttribute(Qt::WA_LayoutUsesWidgetRect);
507  mosaicB->setIcon(QIcon::fromTheme("zoom-draw"));
508  mosaicB->setAttribute(Qt::WA_LayoutUsesWidgetRect);
509 
510  positionAngleSpin->setSpecialValueText("--");
511 
512  queueSaveAsB->setIcon(QIcon::fromTheme("document-save-as"));
513  queueSaveAsB->setAttribute(Qt::WA_LayoutUsesWidgetRect);
514  queueSaveB->setIcon(QIcon::fromTheme("document-save"));
515  queueSaveB->setAttribute(Qt::WA_LayoutUsesWidgetRect);
516  queueLoadB->setIcon(QIcon::fromTheme("document-open"));
517  queueLoadB->setAttribute(Qt::WA_LayoutUsesWidgetRect);
518  queueAppendB->setIcon(QIcon::fromTheme("document-import"));
519  queueAppendB->setAttribute(Qt::WA_LayoutUsesWidgetRect);
520 
521  loadSequenceB->setIcon(QIcon::fromTheme("document-open"));
522  loadSequenceB->setAttribute(Qt::WA_LayoutUsesWidgetRect);
523  selectStartupScriptB->setIcon(QIcon::fromTheme("document-open"));
524  selectStartupScriptB->setAttribute(Qt::WA_LayoutUsesWidgetRect);
525  selectShutdownScriptB->setIcon(
526  QIcon::fromTheme("document-open"));
527  selectShutdownScriptB->setAttribute(Qt::WA_LayoutUsesWidgetRect);
528  selectFITSB->setIcon(QIcon::fromTheme("document-open"));
529  selectFITSB->setAttribute(Qt::WA_LayoutUsesWidgetRect);
530 
531  startupB->setIcon(
532  QIcon::fromTheme("media-playback-start"));
533  startupB->setAttribute(Qt::WA_LayoutUsesWidgetRect);
534  shutdownB->setIcon(
535  QIcon::fromTheme("media-playback-start"));
536  shutdownB->setAttribute(Qt::WA_LayoutUsesWidgetRect);
537 
538  connect(startupB, &QPushButton::clicked, this, &Scheduler::runStartupProcedure);
539  connect(shutdownB, &QPushButton::clicked, this, &Scheduler::runShutdownProcedure);
540 
541  connect(selectObjectB, &QPushButton::clicked, this, &Scheduler::selectObject);
542  connect(selectFITSB, &QPushButton::clicked, this, &Scheduler::selectFITS);
543  connect(loadSequenceB, &QPushButton::clicked, this, &Scheduler::selectSequence);
544  connect(selectStartupScriptB, &QPushButton::clicked, this, &Scheduler::selectStartupScript);
545  connect(selectShutdownScriptB, &QPushButton::clicked, this, &Scheduler::selectShutdownScript);
546 
547  connect(KStars::Instance()->actionCollection()->action("show_mosaic_panel"), &QAction::triggered, this, [this](bool checked)
548  {
549  mosaicB->setDown(checked);
550  });
551  connect(mosaicB, &QPushButton::clicked, this, []()
552  {
553  KStars::Instance()->actionCollection()->action("show_mosaic_panel")->trigger();
554  });
555  connect(addToQueueB, &QPushButton::clicked, this, &Scheduler::addJob);
556  connect(removeFromQueueB, &QPushButton::clicked, this, &Scheduler::removeJob);
561  connect(queueTable->selectionModel(), &QItemSelectionModel::currentRowChanged, this,
565 
566  startB->setIcon(QIcon::fromTheme("media-playback-start"));
567  startB->setAttribute(Qt::WA_LayoutUsesWidgetRect);
568  pauseB->setIcon(QIcon::fromTheme("media-playback-pause"));
569  pauseB->setAttribute(Qt::WA_LayoutUsesWidgetRect);
570  pauseB->setCheckable(false);
571 
572  connect(startB, &QPushButton::clicked, this, &Scheduler::toggleScheduler);
573  connect(pauseB, &QPushButton::clicked, this, &Scheduler::pause);
574 
575  connect(queueSaveAsB, &QPushButton::clicked, this, &Scheduler::saveAs);
576  connect(queueSaveB, &QPushButton::clicked, this, &Scheduler::save);
577  connect(queueLoadB, &QPushButton::clicked, this, [&]()
578  {
579  load(true);
580  });
581  connect(queueAppendB, &QPushButton::clicked, this, [&]()
582  {
583  load(false);
584  });
585 
587 
588  // Connect simulation clock scale
589  connect(KStarsData::Instance()->clock(), &SimClock::scaleChanged, this, &Scheduler::simClockScaleChanged);
590  connect(KStarsData::Instance()->clock(), &SimClock::timeChanged, this, &Scheduler::simClockTimeChanged);
591 
592  // Connect geographical location - when it is available
593  //connect(KStarsData::Instance()..., &LocationDialog::locationChanged..., this, &Scheduler::simClockTimeChanged);
594 
595  // Restore values for scheduler checkboxes.
596  parkDomeCheck->setChecked(Options::schedulerParkDome());
597  parkMountCheck->setChecked(Options::schedulerParkMount());
598  capCheck->setChecked(Options::schedulerCloseDustCover());
599  warmCCDCheck->setChecked(Options::schedulerWarmCCD());
600  unparkDomeCheck->setChecked(Options::schedulerUnparkDome());
601  unparkMountCheck->setChecked(Options::schedulerUnparkMount());
602  uncapCheck->setChecked(Options::schedulerOpenDustCover());
603  trackStepCheck->setChecked(Options::schedulerTrackStep());
604  focusStepCheck->setChecked(Options::schedulerFocusStep());
605  guideStepCheck->setChecked(Options::schedulerGuideStep());
606  alignStepCheck->setChecked(Options::schedulerAlignStep());
607  altConstraintCheck->setChecked(Options::schedulerAltitude());
608  artificialHorizonCheck->setChecked(Options::schedulerHorizon());
609  moonSeparationCheck->setChecked(Options::schedulerMoonSeparation());
610  weatherCheck->setChecked(Options::schedulerWeather());
611  twilightCheck->setChecked(Options::schedulerTwilight());
612  minMoonSeparation->setValue(Options::schedulerMoonSeparationValue());
613  minAltitude->setValue(Options::schedulerAltitudeValue());
614 
615  // Save new default values for scheduler checkboxes.
616  connect(parkDomeCheck, &QPushButton::clicked, [](bool checked)
617  {
618  Options::setSchedulerParkDome(checked);
619  });
620  connect(parkMountCheck, &QPushButton::clicked, [](bool checked)
621  {
622  Options::setSchedulerParkMount(checked);
623  });
624  connect(capCheck, &QPushButton::clicked, [](bool checked)
625  {
626  Options::setSchedulerCloseDustCover(checked);
627  });
628  connect(warmCCDCheck, &QPushButton::clicked, [](bool checked)
629  {
630  Options::setSchedulerWarmCCD(checked);
631  });
632  connect(unparkDomeCheck, &QPushButton::clicked, [](bool checked)
633  {
634  Options::setSchedulerUnparkDome(checked);
635  });
636  connect(unparkMountCheck, &QPushButton::clicked, [](bool checked)
637  {
638  Options::setSchedulerUnparkMount(checked);
639  });
640  connect(uncapCheck, &QPushButton::clicked, [](bool checked)
641  {
642  Options::setSchedulerOpenDustCover(checked);
643  });
644  connect(trackStepCheck, &QPushButton::clicked, [](bool checked)
645  {
646  Options::setSchedulerTrackStep(checked);
647  });
648  connect(focusStepCheck, &QPushButton::clicked, [](bool checked)
649  {
650  Options::setSchedulerFocusStep(checked);
651  });
652  connect(guideStepCheck, &QPushButton::clicked, [](bool checked)
653  {
654  Options::setSchedulerGuideStep(checked);
655  });
656  connect(alignStepCheck, &QPushButton::clicked, [](bool checked)
657  {
658  Options::setSchedulerAlignStep(checked);
659  });
660  connect(altConstraintCheck, &QPushButton::clicked, [](bool checked)
661  {
662  Options::setSchedulerAltitude(checked);
663  });
664  connect(artificialHorizonCheck, &QPushButton::clicked, [](bool checked)
665  {
666  Options::setSchedulerHorizon(checked);
667  });
668  connect(moonSeparationCheck, &QPushButton::clicked, [](bool checked)
669  {
670  Options::setSchedulerMoonSeparation(checked);
671  });
672  connect(weatherCheck, &QPushButton::clicked, [](bool checked)
673  {
674  Options::setSchedulerWeather(checked);
675  });
676  connect(twilightCheck, &QPushButton::clicked, [](bool checked)
677  {
678  Options::setSchedulerTwilight(checked);
679  });
680  connect(minMoonSeparation, &QDoubleSpinBox::editingFinished, this, [this]()
681  {
682  Options::setSchedulerMoonSeparationValue(minMoonSeparation->value());
683  });
684  connect(minAltitude, &QDoubleSpinBox::editingFinished, this, [this]()
685  {
686  Options::setSchedulerAltitudeValue(minAltitude->value());
687  });
688 
689  // restore default values for error handling strategy
690  setErrorHandlingStrategy(static_cast<ErrorHandlingStrategy>(Options::errorHandlingStrategy()));
691  errorHandlingRescheduleErrorsCB->setChecked(Options::rescheduleErrors());
692  errorHandlingDelaySB->setValue(Options::errorHandlingStrategyDelay());
693 
694  // save new default values for error handling strategy
695 
696  connect(errorHandlingRescheduleErrorsCB, &QPushButton::clicked, [](bool checked)
697  {
698  Options::setRescheduleErrors(checked);
699  });
700  connect(errorHandlingButtonGroup, static_cast<void (QButtonGroup::*)(QAbstractButton *)>
701  (&QButtonGroup::buttonClicked), [this](QAbstractButton * button)
702  {
703  Q_UNUSED(button)
704  Options::setErrorHandlingStrategy(getErrorHandlingStrategy());
705  });
706  connect(errorHandlingDelaySB, static_cast<void (QSpinBox::*)(int)>(&QSpinBox::valueChanged), [](int value)
707  {
708  Options::setErrorHandlingStrategyDelay(value);
709  });
710 
711  // restore default values for scheduler algorithm
712  schedulerAlgorithmCombo->setCurrentIndex(Options::schedulerAlgorithm());
713  setAlgorithm(Options::schedulerAlgorithm());
714  connect(schedulerAlgorithmCombo, static_cast<void(QComboBox::*)(int)>(&QComboBox::activated),
715  this, &Scheduler::setAlgorithm);
716 
717  connect(copySkyCenterB, &QPushButton::clicked, this, [this]()
718  {
719  SkyPoint center = SkyMap::Instance()->getCenterPoint();
720  //center.deprecess(KStarsData::Instance()->updateNum());
721  center.catalogueCoord(KStarsData::Instance()->updateNum()->julianDay());
722  raBox->show(center.ra0());
723  decBox->show(center.dec0());
724  });
725 
726  connect(KConfigDialog::exists("settings"), &KConfigDialog::settingsChanged, this, &Scheduler::applyConfig);
727 
728  calculateDawnDusk();
729  updateNightTime();
730 
731  loadProfiles();
732 
733  watchJobChanges(true);
734 }
735 
736 QString Scheduler::getCurrentJobName()
737 {
738  return (currentJob != nullptr ? currentJob->getName() : "");
739 }
740 
741 void Scheduler::watchJobChanges(bool enable)
742 {
743  /* Don't double watch, this will cause multiple signals to be connected */
744  if (enable == jobChangesAreWatched)
745  return;
746 
747  /* These are the widgets we want to connect, per signal function, to listen for modifications */
748  QLineEdit * const lineEdits[] =
749  {
750  nameEdit,
751  raBox,
752  decBox,
753  fitsEdit,
754  sequenceEdit,
755  startupScript,
756  shutdownScript
757  };
758 
759  QDateTimeEdit * const dateEdits[] =
760  {
761  startupTimeEdit,
762  completionTimeEdit
763  };
764 
765  QComboBox * const comboBoxes[] =
766  {
767  schedulerProfileCombo,
768  schedulerAlgorithmCombo
769  };
770 
771  QButtonGroup * const buttonGroups[] =
772  {
773  stepsButtonGroup,
774  errorHandlingButtonGroup,
775  startupButtonGroup,
776  constraintButtonGroup,
777  completionButtonGroup,
778  startupProcedureButtonGroup,
779  shutdownProcedureGroup
780  };
781 
782  QAbstractButton * const buttons[] =
783  {
784  errorHandlingRescheduleErrorsCB
785  };
786 
787  QSpinBox * const spinBoxes[] =
788  {
789  culminationOffset,
790  repeatsSpin,
791  prioritySpin,
792  errorHandlingDelaySB
793  };
794 
795  QDoubleSpinBox * const dspinBoxes[] =
796  {
797  minMoonSeparation,
798  minAltitude
799  };
800 
801  if (enable)
802  {
803  /* Connect the relevant signal to setDirty. Note that we are not keeping the connection object: we will
804  * only use that signal once, and there will be no leaks. If we were connecting multiple receiver functions
805  * to the same signal, we would have to be selective when disconnecting. We also use a lambda to absorb the
806  * excess arguments which cannot be passed to setDirty, and limit captured arguments to 'this'.
807  * The main problem with this implementation compared to the macro method is that it is now possible to
808  * stack signal connections. That is, multiple calls to WatchJobChanges will cause multiple signal-to-slot
809  * instances to be registered. As a result, one click will produce N signals, with N*=2 for each call to
810  * WatchJobChanges(true) missing its WatchJobChanges(false) counterpart.
811  */
812  for (auto * const control : lineEdits)
813  connect(control, &QLineEdit::editingFinished, this, [this]()
814  {
815  setDirty();
816  });
817  for (auto * const control : dateEdits)
818  connect(control, &QDateTimeEdit::editingFinished, this, [this]()
819  {
820  setDirty();
821  });
822  for (auto * const control : comboBoxes)
823  connect(control, static_cast<void (QComboBox::*)(int)>(&QComboBox::currentIndexChanged), this, [this]()
824  {
825  setDirty();
826  });
827  for (auto * const control : buttonGroups)
828 #if QT_VERSION < QT_VERSION_CHECK(5, 15, 0)
829  connect(control, static_cast<void (QButtonGroup::*)(int, bool)>(&QButtonGroup::buttonToggled), this, [this](int, bool)
830 #else
831  connect(control, static_cast<void (QButtonGroup::*)(int, bool)>(&QButtonGroup::idToggled), this, [this](int, bool)
832 #endif
833  {
834  setDirty();
835  });
836  for (auto * const control : buttons)
837  connect(control, static_cast<void (QAbstractButton::*)(bool)>(&QAbstractButton::clicked), this, [this](bool)
838  {
839  setDirty();
840  });
841  for (auto * const control : spinBoxes)
842  connect(control, static_cast<void (QSpinBox::*)(int)>(&QSpinBox::valueChanged), this, [this]()
843  {
844  setDirty();
845  });
846  for (auto * const control : dspinBoxes)
847  connect(control, static_cast<void (QDoubleSpinBox::*)(double)>(&QDoubleSpinBox::valueChanged), this, [this](double)
848  {
849  setDirty();
850  });
851  }
852  else
853  {
854  /* Disconnect the relevant signal from each widget. Actually, this method removes all signals from the widgets,
855  * because we did not take care to keep the connection object when connecting. No problem in our case, we do not
856  * expect other signals to be connected. Because we used a lambda, we cannot use the same function object to
857  * disconnect selectively.
858  */
859  for (auto * const control : lineEdits)
860  disconnect(control, &QLineEdit::editingFinished, this, nullptr);
861  for (auto * const control : dateEdits)
862  disconnect(control, &QDateTimeEdit::editingFinished, this, nullptr);
863  for (auto * const control : comboBoxes)
864  disconnect(control, static_cast<void (QComboBox::*)(int)>(&QComboBox::currentIndexChanged), this, nullptr);
865  for (auto * const control : buttons)
866  disconnect(control, static_cast<void (QAbstractButton::*)(bool)>(&QAbstractButton::clicked), this, nullptr);
867  for (auto * const control : buttonGroups)
868 #if QT_VERSION < QT_VERSION_CHECK(5, 15, 0)
869  disconnect(control, static_cast<void (QButtonGroup::*)(int, bool)>(&QButtonGroup::buttonToggled), this, nullptr);
870 #else
871  disconnect(control, static_cast<void (QButtonGroup::*)(int, bool)>(&QButtonGroup::idToggled), this, nullptr);
872 #endif
873  for (auto * const control : spinBoxes)
874  disconnect(control, static_cast<void (QSpinBox::*)(int)>(&QSpinBox::valueChanged), this, nullptr);
875  for (auto * const control : dspinBoxes)
876  disconnect(control, static_cast<void (QDoubleSpinBox::*)(double)>(&QDoubleSpinBox::valueChanged), this, nullptr);
877  }
878 
879  jobChangesAreWatched = enable;
880 }
881 
882 void Scheduler::appendLogText(const QString &text)
883 {
884  /* FIXME: user settings for log length */
885  int const max_log_count = 2000;
886  if (m_LogText.size() > max_log_count)
887  m_LogText.removeLast();
888 
889  m_LogText.prepend(i18nc("log entry; %1 is the date, %2 is the text", "%1 %2",
890  getLocalTime().toString("yyyy-MM-ddThh:mm:ss"), text));
891 
892  qCInfo(KSTARS_EKOS_SCHEDULER) << text;
893 
894  emit newLog(text);
895 }
896 
897 void Scheduler::clearLog()
898 {
899  m_LogText.clear();
900  emit newLog(QString());
901 }
902 
903 void Scheduler::applyConfig()
904 {
905  calculateDawnDusk();
906  updateNightTime();
907 
908  if (SCHEDULER_RUNNING != state)
909  {
910  evaluateJobs(true);
911  }
912 }
913 
915 {
916  if (FindDialog::Instance()->execWithParent(Ekos::Manager::Instance()) == QDialog::Accepted)
917  {
918  SkyObject *object = FindDialog::Instance()->targetObject();
919  addObject(object);
920  }
921 }
922 
923 void Scheduler::addObject(SkyObject *object)
924 {
925  if (object != nullptr)
926  {
927  QString finalObjectName(object->name());
928 
929  if (object->name() == "star")
930  {
931  StarObject *s = dynamic_cast<StarObject *>(object);
932 
933  if (s->getHDIndex() != 0)
934  finalObjectName = QString("HD %1").arg(s->getHDIndex());
935  }
936 
937  nameEdit->setText(finalObjectName);
938  raBox->show(object->ra0());
939  decBox->show(object->dec0());
940 
941  addToQueueB->setEnabled(sequenceEdit->text().isEmpty() == false);
942  //mosaicB->setEnabled(sequenceEdit->text().isEmpty() == false);
943 
944  setDirty();
945  }
946 }
947 
949 {
950  fitsURL = QFileDialog::getOpenFileUrl(Ekos::Manager::Instance(), i18nc("@title:window", "Select FITS Image"), dirPath,
951  "FITS (*.fits *.fit)");
952  if (fitsURL.isEmpty())
953  return;
954 
955  dirPath = QUrl(fitsURL.url(QUrl::RemoveFilename));
956 
957  fitsEdit->setText(fitsURL.toLocalFile());
958 
959  if (nameEdit->text().isEmpty())
960  nameEdit->setText(fitsURL.fileName());
961 
962  addToQueueB->setEnabled(sequenceEdit->text().isEmpty() == false);
963  //mosaicB->setEnabled(sequenceEdit->text().isEmpty() == false);
964 
965  processFITSSelection();
966 
967  setDirty();
968 }
969 
970 void Scheduler::processFITSSelection()
971 {
972  const QString filename = fitsEdit->text();
973  int status = 0;
974  double ra = 0, dec = 0;
975  dms raDMS, deDMS;
976  char comment[128], error_status[512];
977  fitsfile *fptr = nullptr;
978 
979  if (fits_open_diskfile(&fptr, filename.toLatin1(), READONLY, &status))
980  {
981  fits_report_error(stderr, status);
982  fits_get_errstatus(status, error_status);
983  qCCritical(KSTARS_EKOS_SCHEDULER) << QString::fromUtf8(error_status);
984  return;
985  }
986 
987  status = 0;
988  if (fits_movabs_hdu(fptr, 1, IMAGE_HDU, &status))
989  {
990  fits_report_error(stderr, status);
991  fits_get_errstatus(status, error_status);
992  qCCritical(KSTARS_EKOS_SCHEDULER) << QString::fromUtf8(error_status);
993  return;
994  }
995 
996  status = 0;
997  char objectra_str[32] = {0};
998  if (fits_read_key(fptr, TSTRING, "OBJCTRA", objectra_str, comment, &status))
999  {
1000  if (fits_read_key(fptr, TDOUBLE, "RA", &ra, comment, &status))
1001  {
1002  fits_report_error(stderr, status);
1003  fits_get_errstatus(status, error_status);
1004  appendLogText(i18n("FITS header: cannot find OBJCTRA (%1).", QString(error_status)));
1005  return;
1006  }
1007 
1008  raDMS.setD(ra);
1009  }
1010  else
1011  {
1012  raDMS = dms::fromString(objectra_str, false);
1013  }
1014 
1015  status = 0;
1016  char objectde_str[32] = {0};
1017  if (fits_read_key(fptr, TSTRING, "OBJCTDEC", objectde_str, comment, &status))
1018  {
1019  if (fits_read_key(fptr, TDOUBLE, "DEC", &dec, comment, &status))
1020  {
1021  fits_report_error(stderr, status);
1022  fits_get_errstatus(status, error_status);
1023  appendLogText(i18n("FITS header: cannot find OBJCTDEC (%1).", QString(error_status)));
1024  return;
1025  }
1026 
1027  deDMS.setD(dec);
1028  }
1029  else
1030  {
1031  deDMS = dms::fromString(objectde_str, true);
1032  }
1033 
1034  raBox->show(raDMS);
1035  decBox->show(deDMS);
1036 
1037  char object_str[256] = {0};
1038  if (fits_read_key(fptr, TSTRING, "OBJECT", object_str, comment, &status))
1039  {
1040  QFileInfo info(filename);
1041  nameEdit->setText(info.completeBaseName());
1042  }
1043  else
1044  {
1045  nameEdit->setText(object_str);
1046  }
1047 }
1048 
1049 void Scheduler::setSequence(const QString &sequenceFileURL)
1050 {
1051  sequenceURL = QUrl::fromLocalFile(sequenceFileURL);
1052 
1053  if (sequenceFileURL.isEmpty())
1054  return;
1055  dirPath = QUrl(sequenceURL.url(QUrl::RemoveFilename));
1056 
1057  sequenceEdit->setText(sequenceURL.toLocalFile());
1058 
1059  // For object selection, all fields must be filled
1060  if ((raBox->isEmpty() == false && decBox->isEmpty() == false && nameEdit->text().isEmpty() == false)
1061  // For FITS selection, only the name and fits URL should be filled.
1062  || (nameEdit->text().isEmpty() == false && fitsURL.isEmpty() == false))
1063  {
1064  addToQueueB->setEnabled(true);
1065  //mosaicB->setEnabled(true);
1066  }
1067 
1068  setDirty();
1069 }
1070 
1072 {
1073  QString file = QFileDialog::getOpenFileName(Ekos::Manager::Instance(), i18nc("@title:window", "Select Sequence Queue"),
1074  dirPath.toLocalFile(),
1075  i18n("Ekos Sequence Queue (*.esq)"));
1076 
1077  setSequence(file);
1078 }
1079 
1081 {
1082  startupScriptURL = QFileDialog::getOpenFileUrl(Ekos::Manager::Instance(), i18nc("@title:window", "Select Startup Script"),
1083  dirPath,
1084  i18n("Script (*)"));
1085  if (startupScriptURL.isEmpty())
1086  return;
1087 
1088  dirPath = QUrl(startupScriptURL.url(QUrl::RemoveFilename));
1089 
1090  mDirty = true;
1091  startupScript->setText(startupScriptURL.toLocalFile());
1092 }
1093 
1095 {
1096  shutdownScriptURL = QFileDialog::getOpenFileUrl(Ekos::Manager::Instance(), i18nc("@title:window", "Select Shutdown Script"),
1097  dirPath,
1098  i18n("Script (*)"));
1099  if (shutdownScriptURL.isEmpty())
1100  return;
1101 
1102  dirPath = QUrl(shutdownScriptURL.url(QUrl::RemoveFilename));
1103 
1104  mDirty = true;
1105  shutdownScript->setText(shutdownScriptURL.toLocalFile());
1106 }
1107 
1109 {
1110  if (0 <= jobUnderEdit)
1111  {
1112  /* If a job is being edited, reset edition mode as all fields are already transferred to the job */
1113  resetJobEdit();
1114  }
1115  else
1116  {
1117  /* If a job is being added, save fields into a new job */
1118  saveJob();
1119  /* There is now an evaluation for each change, so don't duplicate the evaluation now */
1120  // jobEvaluationOnly = true;
1121  // evaluateJobs();
1122  }
1123  emit jobsUpdated(getJSONJobs());
1124 }
1125 
1127  SchedulerJob &job, const QString &name, int priority, const dms &ra,
1128  const dms &dec, double djd, double rotation, const QUrl &sequenceUrl, const QUrl &fitsUrl,
1129  SchedulerJob::StartupCondition startup, const QDateTime &startupTime,
1130  int16_t startupOffset,
1131  SchedulerJob::CompletionCondition completion,
1132  const QDateTime &completionTime, int completionRepeats,
1133  double minimumAltitude, double minimumMoonSeparation,
1134  bool enforceWeather, bool enforceTwilight, bool enforceArtificialHorizon,
1135  bool track, bool focus, bool align, bool guide)
1136 {
1137  /* Configure or reconfigure the observation job */
1138 
1139  job.setName(name);
1140  job.setPriority(priority);
1141  // djd should be ut.djd
1142  job.setTargetCoords(ra, dec, djd);
1143  job.setPositionAngle(rotation);
1144 
1145  /* Consider sequence file is new, and clear captured frames map */
1146  job.setCapturedFramesMap(SchedulerJob::CapturedFramesMap());
1147  job.setSequenceFile(sequenceUrl);
1148  job.setFITSFile(fitsUrl);
1149  // #1 Startup conditions
1150 
1151  job.setStartupCondition(startup);
1152  if (startup == SchedulerJob::START_CULMINATION)
1153  {
1154  job.setCulminationOffset(startupOffset);
1155  }
1156  else if (startup == SchedulerJob::START_AT)
1157  {
1158  job.setStartupTime(startupTime);
1159  }
1160  /* Store the original startup condition */
1161  job.setFileStartupCondition(job.getStartupCondition());
1162  job.setFileStartupTime(job.getStartupTime());
1163 
1164  // #2 Constraints
1165 
1166  job.setMinAltitude(minimumAltitude);
1167  job.setMinMoonSeparation(minimumMoonSeparation);
1168 
1169  // Check enforce weather constraints
1170  job.setEnforceWeather(enforceWeather);
1171  // twilight constraints
1172  job.setEnforceTwilight(enforceTwilight);
1173  job.setEnforceArtificialHorizon(enforceArtificialHorizon);
1174 
1175  job.setCompletionCondition(completion);
1176  if (completion == SchedulerJob::FINISH_AT)
1177  job.setCompletionTime(completionTime);
1178  else if (completion == SchedulerJob::FINISH_REPEAT)
1179  {
1180  job.setRepeatsRequired(completionRepeats);
1181  job.setRepeatsRemaining(completionRepeats);
1182  }
1183  // Job steps
1184  job.setStepPipeline(SchedulerJob::USE_NONE);
1185  if (track)
1186  job.setStepPipeline(static_cast<SchedulerJob::StepPipeline>(job.getStepPipeline() | SchedulerJob::USE_TRACK));
1187  if (focus)
1188  job.setStepPipeline(static_cast<SchedulerJob::StepPipeline>(job.getStepPipeline() | SchedulerJob::USE_FOCUS));
1189  if (align)
1190  job.setStepPipeline(static_cast<SchedulerJob::StepPipeline>(job.getStepPipeline() | SchedulerJob::USE_ALIGN));
1191  if (guide)
1192  job.setStepPipeline(static_cast<SchedulerJob::StepPipeline>(job.getStepPipeline() | SchedulerJob::USE_GUIDE));
1193 
1194  /* Store the original startup condition */
1195  job.setFileStartupCondition(job.getStartupCondition());
1196  job.setFileStartupTime(job.getStartupTime());
1197 
1198  /* Reset job state to evaluate the changes */
1199  job.reset();
1200 }
1201 
1203 {
1204  if (state == SCHEDULER_RUNNING)
1205  {
1206  appendLogText(i18n("Warning: You cannot add or modify a job while the scheduler is running."));
1207  return;
1208  }
1209 
1210  if (nameEdit->text().isEmpty())
1211  {
1212  appendLogText(i18n("Warning: Target name is required."));
1213  return;
1214  }
1215 
1216  if (sequenceEdit->text().isEmpty())
1217  {
1218  appendLogText(i18n("Warning: Sequence file is required."));
1219  return;
1220  }
1221 
1222  // Coordinates are required unless it is a FITS file
1223  if ((raBox->isEmpty() || decBox->isEmpty()) && fitsURL.isEmpty())
1224  {
1225  appendLogText(i18n("Warning: Target coordinates are required."));
1226  return;
1227  }
1228 
1229  bool raOk = false, decOk = false;
1230  dms /*const*/ ra(raBox->createDms(&raOk));
1231  dms /*const*/ dec(decBox->createDms(&decOk));
1232 
1233  if (raOk == false)
1234  {
1235  appendLogText(i18n("Warning: RA value %1 is invalid.", raBox->text()));
1236  return;
1237  }
1238 
1239  if (decOk == false)
1240  {
1241  appendLogText(i18n("Warning: DEC value %1 is invalid.", decBox->text()));
1242  return;
1243  }
1244 
1245  watchJobChanges(false);
1246 
1247  /* Create or Update a scheduler job */
1248  int currentRow = queueTable->currentRow();
1249  SchedulerJob * job = nullptr;
1250 
1251  /* If no row is selected for insertion, append at end of list. */
1252  if (currentRow < 0)
1253  currentRow = queueTable->rowCount();
1254 
1255  /* Add job to queue only if it is new, else reuse current row.
1256  * Make sure job is added at the right index, now that queueTable may have a line selected without being edited.
1257  */
1258  if (0 <= jobUnderEdit)
1259  {
1260  /* FIXME: jobUnderEdit is a parallel variable that may cause issues if it desyncs from queueTable->currentRow(). */
1261  if (jobUnderEdit != currentRow)
1262  qCWarning(KSTARS_EKOS_SCHEDULER) << "BUG: the observation job under edit does not match the selected row in the job table.";
1263 
1264  /* Use the job in the row currently edited */
1265  job = jobs.at(currentRow);
1266  }
1267  else
1268  {
1269  /* Instantiate a new job, insert it in the job list and add a row in the table for it just after the row currently selected. */
1270  job = new SchedulerJob();
1271  jobs.insert(currentRow, job);
1272  queueTable->insertRow(currentRow);
1273  }
1274 
1275  /* Configure or reconfigure the observation job */
1276 
1277  job->setDateTimeDisplayFormat(startupTimeEdit->displayFormat());
1278  fitsURL = QUrl::fromLocalFile(fitsEdit->text());
1279 
1280  // Get several job values depending on the state of the UI.
1281 
1282  SchedulerJob::StartupCondition startCondition = SchedulerJob::START_AT;
1283  if (asapConditionR->isChecked())
1284  startCondition = SchedulerJob::START_ASAP;
1285  else if (culminationConditionR->isChecked())
1286  startCondition = SchedulerJob::START_CULMINATION;
1287 
1288  SchedulerJob::CompletionCondition stopCondition = SchedulerJob::FINISH_AT;
1289  if (sequenceCompletionR->isChecked())
1290  stopCondition = SchedulerJob::FINISH_SEQUENCE;
1291  else if (repeatCompletionR->isChecked())
1292  stopCondition = SchedulerJob::FINISH_REPEAT;
1293  else if (loopCompletionR->isChecked())
1294  stopCondition = SchedulerJob::FINISH_LOOP;
1295 
1296  double altConstraint = SchedulerJob::UNDEFINED_ALTITUDE;
1297  if (altConstraintCheck->isChecked())
1298  altConstraint = minAltitude->value();
1299 
1300  double moonConstraint = -1;
1301  if (moonSeparationCheck->isChecked())
1302  moonConstraint = minMoonSeparation->value();
1303 
1304  // The reason for this kitchen-sink function is to separate the UI from the
1305  // job setup, to allow for testing.
1306  setupJob(
1307  *job, nameEdit->text(), prioritySpin->value(), ra, dec,
1308  KStarsData::Instance()->ut().djd(),
1309  positionAngleSpin->value(), sequenceURL, fitsURL,
1310 
1311  startCondition, startupTimeEdit->dateTime(), culminationOffset->value(),
1312  stopCondition, completionTimeEdit->dateTime(), repeatsSpin->value(),
1313 
1314  altConstraint,
1315  moonConstraint,
1316  weatherCheck->isChecked(),
1317  twilightCheck->isChecked(),
1318  artificialHorizonCheck->isChecked(),
1319 
1320  trackStepCheck->isChecked(),
1321  focusStepCheck->isChecked(),
1322  alignStepCheck->isChecked(),
1323  guideStepCheck->isChecked()
1324  );
1325 
1326 
1327  /* Verifications */
1328  /* FIXME: perhaps use a method more visible to the end-user */
1329  if (SchedulerJob::START_AT == job->getFileStartupCondition())
1330  {
1331  /* Warn if appending a job which startup time doesn't allow proper score */
1332  if (calculateJobScore(job, Dawn, Dusk, job->getStartupTime()) < 0)
1333  appendLogText(
1334  i18n("Warning: job '%1' has startup time %2 resulting in a negative score, and will be marked invalid when processed.",
1335  job->getName(), job->getStartupTime().toString(job->getDateTimeDisplayFormat())));
1336 
1337  }
1338 
1339  // Warn user if a duplicated job is in the list - same target, same sequence
1340  // FIXME: Those duplicated jobs are not necessarily processed in the order they appear in the list!
1341  foreach (SchedulerJob *a_job, jobs)
1342  {
1343  if (a_job == job)
1344  {
1345  break;
1346  }
1347  else if (a_job->getName() == job->getName())
1348  {
1349  int const a_job_row = a_job->getNameCell() ? a_job->getNameCell()->row() + 1 : 0;
1350 
1351  /* FIXME: Warning about duplicate jobs only checks the target name, doing it properly would require checking storage for each sequence job of each scheduler job. */
1352  appendLogText(i18n("Warning: job '%1' at row %2 has a duplicate target at row %3, "
1353  "the scheduler may consider the same storage for captures.",
1354  job->getName(), currentRow, a_job_row));
1355 
1356  /* Warn the user in case the two jobs are really identical */
1357  if (a_job->getSequenceFile() == job->getSequenceFile())
1358  {
1359  if (a_job->getRepeatsRequired() == job->getRepeatsRequired() && Options::rememberJobProgress())
1360  appendLogText(i18n("Warning: jobs '%1' at row %2 and %3 probably require a different repeat count "
1361  "as currently they will complete simultaneously after %4 batches (or disable option 'Remember job progress')",
1362  job->getName(), currentRow, a_job_row, job->getRepeatsRequired()));
1363 
1364  if (a_job->getStartupTime() == a_job->getStartupTime() && a_job->getPriority() == job->getPriority())
1365  appendLogText(i18n("Warning: job '%1' at row %2 might require a specific startup time or a different priority, "
1366  "as currently they will start in order of insertion in the table",
1367  job->getName(), currentRow));
1368  }
1369  }
1370  }
1371 
1372  if (-1 == jobUnderEdit)
1373  {
1374  QTableWidgetItem *nameCell = new QTableWidgetItem();
1375  queueTable->setItem(currentRow, static_cast<int>(SCHEDCOL_NAME), nameCell);
1378 
1379  QTableWidgetItem *statusCell = new QTableWidgetItem();
1380  queueTable->setItem(currentRow, static_cast<int>(SCHEDCOL_STATUS), statusCell);
1383 
1384  QTableWidgetItem *captureCount = new QTableWidgetItem();
1385  queueTable->setItem(currentRow, static_cast<int>(SCHEDCOL_CAPTURES), captureCount);
1388 
1389  QTableWidgetItem *scoreValue = new QTableWidgetItem();
1390  queueTable->setItem(currentRow, static_cast<int>(SCHEDCOL_SCORE), scoreValue);
1393 
1394  QTableWidgetItem *startupCell = new QTableWidgetItem();
1395  queueTable->setItem(currentRow, static_cast<int>(SCHEDCOL_STARTTIME), startupCell);
1398 
1399  QTableWidgetItem *altitudeCell = new QTableWidgetItem();
1400  queueTable->setItem(currentRow, static_cast<int>(SCHEDCOL_ALTITUDE), altitudeCell);
1403 
1404  QTableWidgetItem *completionCell = new QTableWidgetItem();
1405  queueTable->setItem(currentRow, static_cast<int>(SCHEDCOL_ENDTIME), completionCell);
1407  completionCell->setFlags(Qt::ItemIsSelectable | Qt::ItemIsEnabled);
1408 
1409  QTableWidgetItem *estimatedTimeCell = new QTableWidgetItem();
1410  queueTable->setItem(currentRow, static_cast<int>(SCHEDCOL_DURATION), estimatedTimeCell);
1411  estimatedTimeCell->setTextAlignment(Qt::AlignHCenter | Qt::AlignVCenter);
1412  estimatedTimeCell->setFlags(Qt::ItemIsSelectable | Qt::ItemIsEnabled);
1413 
1414  QTableWidgetItem *leadTimeCell = new QTableWidgetItem();
1415  queueTable->setItem(currentRow, static_cast<int>(SCHEDCOL_LEADTIME), leadTimeCell);
1418  }
1419 
1420  setJobStatusCells(currentRow);
1421 
1422  /* We just added or saved a job, so we have a job in the list - enable relevant buttons */
1423  queueSaveAsB->setEnabled(true);
1424  queueSaveB->setEnabled(true);
1425  startB->setEnabled(true);
1426  evaluateOnlyB->setEnabled(true);
1427  setJobManipulation(!Options::sortSchedulerJobs(), true);
1428 
1429  qCDebug(KSTARS_EKOS_SCHEDULER) << QString("Job '%1' at row #%2 was saved.").arg(job->getName()).arg(currentRow + 1);
1430 
1431  watchJobChanges(true);
1432 
1433  if (SCHEDULER_LOADING != state)
1434  {
1435  evaluateJobs(true);
1436  }
1437 }
1438 
1439 void Scheduler::syncGUIToJob(SchedulerJob *job)
1440 {
1441  nameEdit->setText(job->getName());
1442 
1443  prioritySpin->setValue(job->getPriority());
1444 
1445  raBox->show(job->getTargetCoords().ra0());
1446  decBox->show(job->getTargetCoords().dec0());
1447 
1448  // fitsURL/sequenceURL are not part of UI, but the UI serves as model, so keep them here for now
1449  fitsURL = job->getFITSFile().isEmpty() ? QUrl() : job->getFITSFile();
1450  sequenceURL = job->getSequenceFile();
1451  fitsEdit->setText(fitsURL.toLocalFile());
1452  sequenceEdit->setText(sequenceURL.toLocalFile());
1453 
1454  positionAngleSpin->setValue(job->getPositionAngle());
1455 
1456  trackStepCheck->setChecked(job->getStepPipeline() & SchedulerJob::USE_TRACK);
1457  focusStepCheck->setChecked(job->getStepPipeline() & SchedulerJob::USE_FOCUS);
1458  alignStepCheck->setChecked(job->getStepPipeline() & SchedulerJob::USE_ALIGN);
1459  guideStepCheck->setChecked(job->getStepPipeline() & SchedulerJob::USE_GUIDE);
1460 
1461  switch (job->getFileStartupCondition())
1462  {
1463  case SchedulerJob::START_ASAP:
1464  asapConditionR->setChecked(true);
1465  culminationOffset->setValue(DEFAULT_CULMINATION_TIME);
1466  break;
1467 
1468  case SchedulerJob::START_CULMINATION:
1469  culminationConditionR->setChecked(true);
1470  culminationOffset->setValue(job->getCulminationOffset());
1471  break;
1472 
1473  case SchedulerJob::START_AT:
1474  startupTimeConditionR->setChecked(true);
1475  startupTimeEdit->setDateTime(job->getStartupTime());
1476  culminationOffset->setValue(DEFAULT_CULMINATION_TIME);
1477  break;
1478  }
1479 
1480  if (job->hasMinAltitude())
1481  {
1482  altConstraintCheck->setChecked(true);
1483  minAltitude->setValue(job->getMinAltitude());
1484  }
1485  else
1486  {
1487  altConstraintCheck->setChecked(false);
1488  minAltitude->setValue(DEFAULT_MIN_ALTITUDE);
1489  }
1490 
1491  if (job->getMinMoonSeparation() >= 0)
1492  {
1493  moonSeparationCheck->setChecked(true);
1494  minMoonSeparation->setValue(job->getMinMoonSeparation());
1495  }
1496  else
1497  {
1498  moonSeparationCheck->setChecked(false);
1499  minMoonSeparation->setValue(DEFAULT_MIN_MOON_SEPARATION);
1500  }
1501 
1502  weatherCheck->setChecked(job->getEnforceWeather());
1503 
1504  twilightCheck->blockSignals(true);
1505  twilightCheck->setChecked(job->getEnforceTwilight());
1506  twilightCheck->blockSignals(false);
1507 
1508  artificialHorizonCheck->blockSignals(true);
1509  artificialHorizonCheck->setChecked(job->getEnforceArtificialHorizon());
1510  artificialHorizonCheck->blockSignals(false);
1511 
1512  switch (job->getCompletionCondition())
1513  {
1514  case SchedulerJob::FINISH_SEQUENCE:
1515  sequenceCompletionR->setChecked(true);
1516  break;
1517 
1518  case SchedulerJob::FINISH_REPEAT:
1519  repeatCompletionR->setChecked(true);
1520  repeatsSpin->setValue(job->getRepeatsRequired());
1521  break;
1522 
1523  case SchedulerJob::FINISH_LOOP:
1524  loopCompletionR->setChecked(true);
1525  break;
1526 
1527  case SchedulerJob::FINISH_AT:
1528  timeCompletionR->setChecked(true);
1529  completionTimeEdit->setDateTime(job->getCompletionTime());
1530  break;
1531  }
1532 
1533  updateNightTime(job);
1534 
1535  setJobManipulation(!Options::sortSchedulerJobs(), true);
1536 }
1537 
1538 void Scheduler::updateNightTime(SchedulerJob const *job)
1539 {
1540  if (job == nullptr)
1541  {
1542  int const currentRow = queueTable->currentRow();
1543  if (0 < currentRow)
1544  job = jobs.at(currentRow);
1545  }
1546 
1547  QDateTime const dawn = job ? job->getDawnAstronomicalTwilight() : Dawn;
1548  QDateTime const dusk = job ? job->getDuskAstronomicalTwilight() : Dusk;
1549 
1550  QChar const warning(dawn == dusk ? 0x26A0 : '-');
1551  nightTime->setText(i18n("%1 %2 %3", dusk.toString("hh:mm"), warning, dawn.toString("hh:mm")));
1552 }
1553 
1555 {
1556  if (jobUnderEdit == i.row())
1557  return;
1558 
1559  if (state == SCHEDULER_RUNNING)
1560  {
1561  appendLogText(i18n("Warning: you cannot add or modify a job while the scheduler is running."));
1562  return;
1563  }
1564 
1565  SchedulerJob * const job = jobs.at(i.row());
1566 
1567  if (job == nullptr)
1568  return;
1569 
1570  watchJobChanges(false);
1571 
1572  //job->setState(SchedulerJob::JOB_IDLE);
1573  //job->setStage(SchedulerJob::STAGE_IDLE);
1574  syncGUIToJob(job);
1575 
1576  /* Turn the add button into an apply button */
1577  setJobAddApply(false);
1578 
1579  /* Disable scheduler start/evaluate buttons */
1580  startB->setEnabled(false);
1581  evaluateOnlyB->setEnabled(false);
1582 
1583  /* Don't let the end-user remove a job being edited */
1584  setJobManipulation(false, false);
1585 
1586  jobUnderEdit = i.row();
1587  qCDebug(KSTARS_EKOS_SCHEDULER) << QString("Job '%1' at row #%2 is currently edited.").arg(job->getName()).arg(
1588  jobUnderEdit + 1);
1589 
1590  watchJobChanges(true);
1591 }
1592 
1594 {
1595  Q_UNUSED(previous)
1596 
1597  // prevent selection when not idle
1598  if (state != SCHEDULER_IDLE)
1599  return;
1600 
1601  if (current.row() < 0 || (current.row() + 1) > jobs.size())
1602  return;
1603 
1604  SchedulerJob * const job = jobs.at(current.row());
1605 
1606  if (job != nullptr)
1607  {
1608  resetJobEdit();
1609  syncGUIToJob(job);
1610  }
1611  else nightTime->setText("-");
1612 }
1613 
1615 {
1616  setJobManipulation(!Options::sortSchedulerJobs() && index.isValid(), index.isValid());
1617 }
1618 
1619 void Scheduler::setJobAddApply(bool add_mode)
1620 {
1621  if (add_mode)
1622  {
1623  addToQueueB->setIcon(QIcon::fromTheme("list-add"));
1624  addToQueueB->setToolTip(i18n("Use edition fields to create a new job in the observation list."));
1625  //addToQueueB->setStyleSheet(QString());
1626  addToQueueB->setAttribute(Qt::WA_LayoutUsesWidgetRect);
1627  }
1628  else
1629  {
1630  addToQueueB->setIcon(QIcon::fromTheme("dialog-ok-apply"));
1631  addToQueueB->setToolTip(i18n("Apply job changes."));
1632  //addToQueueB->setStyleSheet("background-color:orange;}");
1633  addToQueueB->setEnabled(true);
1634  }
1635 }
1636 
1637 void Scheduler::setJobManipulation(bool can_reorder, bool can_delete)
1638 {
1639  bool can_edit = (state == SCHEDULER_IDLE);
1640 
1641  if (can_reorder)
1642  {
1643  int const currentRow = queueTable->currentRow();
1644  queueUpB->setEnabled(can_edit && 0 < currentRow);
1645  queueDownB->setEnabled(can_edit && currentRow < queueTable->rowCount() - 1);
1646  }
1647  else
1648  {
1649  queueUpB->setEnabled(false);
1650  queueDownB->setEnabled(false);
1651  }
1652  sortJobsB->setEnabled(can_edit && can_reorder);
1653  removeFromQueueB->setEnabled(can_edit && can_delete);
1654 }
1655 
1657 {
1658  /* Add jobs not reordered at the end of the list, in initial order */
1659  foreach (SchedulerJob* job, jobs)
1660  if (!reordered_sublist.contains(job))
1661  reordered_sublist.append(job);
1662 
1663  if (jobs != reordered_sublist)
1664  {
1665  /* Remember job currently selected */
1666  int const selectedRow = queueTable->currentRow();
1667  SchedulerJob * const selectedJob = 0 <= selectedRow ? jobs.at(selectedRow) : nullptr;
1668 
1669  /* Reassign list */
1670  jobs = reordered_sublist;
1671 
1672  /* Reassign status cells for all jobs, and reset them */
1673  for (int row = 0; row < jobs.size(); row++)
1674  setJobStatusCells(row);
1675 
1676  /* Reselect previously selected job */
1677  if (nullptr != selectedJob)
1678  queueTable->selectRow(jobs.indexOf(selectedJob));
1679 
1680  return true;
1681  }
1682  else return false;
1683 }
1684 
1686 {
1687  /* No move if jobs are sorted automatically */
1688  if (Options::sortSchedulerJobs())
1689  return;
1690 
1691  int const rowCount = queueTable->rowCount();
1692  int const currentRow = queueTable->currentRow();
1693  int const destinationRow = currentRow - 1;
1694 
1695  /* No move if no job selected, if table has one line or less or if destination is out of table */
1696  if (currentRow < 0 || rowCount <= 1 || destinationRow < 0)
1697  return;
1698 
1699  /* Swap jobs in the list */
1700 #if QT_VERSION >= QT_VERSION_CHECK(5,13,0)
1701  jobs.swapItemsAt(currentRow, destinationRow);
1702 #else
1703  jobs.swap(currentRow, destinationRow);
1704 #endif
1705 
1706  /* Reassign status cells */
1707  setJobStatusCells(currentRow);
1708  setJobStatusCells(destinationRow);
1709 
1710  /* Move selection to destination row */
1711  queueTable->selectRow(destinationRow);
1712  setJobManipulation(!Options::sortSchedulerJobs(), true);
1713 
1714  /* Jobs are now sorted, so reset all later jobs */
1715  for (int row = destinationRow; row < jobs.size(); row++)
1716  jobs.at(row)->reset();
1717 
1718  /* Make list modified and evaluate jobs */
1719  mDirty = true;
1720  evaluateJobs(true);
1721 }
1722 
1724 {
1725  /* No move if jobs are sorted automatically */
1726  if (Options::sortSchedulerJobs())
1727  return;
1728 
1729  int const rowCount = queueTable->rowCount();
1730  int const currentRow = queueTable->currentRow();
1731  int const destinationRow = currentRow + 1;
1732 
1733  /* No move if no job selected, if table has one line or less or if destination is out of table */
1734  if (currentRow < 0 || rowCount <= 1 || destinationRow == rowCount)
1735  return;
1736 
1737  /* Swap jobs in the list */
1738 #if QT_VERSION >= QT_VERSION_CHECK(5,13,0)
1739  jobs.swapItemsAt(currentRow, destinationRow);
1740 #else
1741  jobs.swap(currentRow, destinationRow);
1742 #endif
1743 
1744  /* Reassign status cells */
1745  setJobStatusCells(currentRow);
1746  setJobStatusCells(destinationRow);
1747 
1748  /* Move selection to destination row */
1749  queueTable->selectRow(destinationRow);
1750  setJobManipulation(!Options::sortSchedulerJobs(), true);
1751 
1752  /* Jobs are now sorted, so reset all later jobs */
1753  for (int row = currentRow; row < jobs.size(); row++)
1754  jobs.at(row)->reset();
1755 
1756  /* Make list modified and evaluate jobs */
1757  mDirty = true;
1758  evaluateJobs(true);
1759 }
1760 
1762 {
1763  if (row < 0 || jobs.size() <= row)
1764  return;
1765 
1766  SchedulerJob * const job = jobs.at(row);
1767 
1768  job->setNameCell(queueTable->item(row, static_cast<int>(SCHEDCOL_NAME)));
1769  job->setStatusCell(queueTable->item(row, static_cast<int>(SCHEDCOL_STATUS)));
1770  job->setCaptureCountCell(queueTable->item(row, static_cast<int>(SCHEDCOL_CAPTURES)));
1771  job->setScoreCell(queueTable->item(row, static_cast<int>(SCHEDCOL_SCORE)));
1772  job->setAltitudeCell(queueTable->item(row, static_cast<int>(SCHEDCOL_ALTITUDE)));
1773  job->setStartupCell(queueTable->item(row, static_cast<int>(SCHEDCOL_STARTTIME)));
1774  job->setCompletionCell(queueTable->item(row, static_cast<int>(SCHEDCOL_ENDTIME)));
1775  job->setEstimatedTimeCell(queueTable->item(row, static_cast<int>(SCHEDCOL_DURATION)));
1776  job->setLeadTimeCell(queueTable->item(row, static_cast<int>(SCHEDCOL_LEADTIME)));
1777  job->updateJobCells();
1778  emit jobsUpdated(getJSONJobs());
1779 }
1780 
1781 void Scheduler::resetJobEdit()
1782 {
1783  if (jobUnderEdit < 0)
1784  return;
1785 
1786  SchedulerJob * const job = jobs.at(jobUnderEdit);
1787  Q_ASSERT_X(job != nullptr, __FUNCTION__, "Edited job must be valid");
1788 
1789  qCDebug(KSTARS_EKOS_SCHEDULER) << QString("Job '%1' at row #%2 is not longer edited.").arg(job->getName()).arg(
1790  jobUnderEdit + 1);
1791 
1792  jobUnderEdit = -1;
1793 
1794  watchJobChanges(false);
1795 
1796  /* Revert apply button to add */
1797  setJobAddApply(true);
1798 
1799  /* Refresh state of job manipulation buttons */
1800  setJobManipulation(!Options::sortSchedulerJobs(), true);
1801 
1802  /* Restore scheduler operation buttons */
1803  evaluateOnlyB->setEnabled(true);
1804  startB->setEnabled(true);
1805 
1806  Q_ASSERT_X(jobUnderEdit == -1, __FUNCTION__, "No more edited/selected job after exiting edit mode");
1807 }
1808 
1810 {
1811  int currentRow = queueTable->currentRow();
1812 
1813  /* Don't remove a row that is not selected */
1814  if (currentRow < 0)
1815  return;
1816 
1817  /* Grab the job currently selected */
1818  SchedulerJob * const job = jobs.at(currentRow);
1819  qCDebug(KSTARS_EKOS_SCHEDULER) << QString("Job '%1' at row #%2 is being deleted.").arg(job->getName()).arg(currentRow + 1);
1820 
1821  /* Remove the job from the table */
1822  queueTable->removeRow(currentRow);
1823 
1824  /* If there are no job rows left, update UI buttons */
1825  if (queueTable->rowCount() == 0)
1826  {
1827  setJobManipulation(false, false);
1828  evaluateOnlyB->setEnabled(false);
1829  queueSaveAsB->setEnabled(false);
1830  queueSaveB->setEnabled(false);
1831  startB->setEnabled(false);
1832  pauseB->setEnabled(false);
1833  }
1834 
1835  /* Else update the selection */
1836  else
1837  {
1838  if (currentRow > queueTable->rowCount())
1839  currentRow = queueTable->rowCount() - 1;
1840 
1841  loadJob(queueTable->currentIndex());
1842  queueTable->selectRow(currentRow);
1843  }
1844 
1845  /* If needed, reset edit mode to clean up UI */
1846  if (jobUnderEdit >= 0)
1847  resetJobEdit();
1848 
1849  /* And remove the job object */
1850  jobs.removeOne(job);
1851  delete (job);
1852 
1853  mDirty = true;
1854  evaluateJobs(true);
1855  emit jobsUpdated(getJSONJobs());
1856 }
1857 
1859 {
1860  queueTable->selectRow(index);
1861  removeJob();
1862 }
1863 void Scheduler::toggleScheduler()
1864 {
1865  if (state == SCHEDULER_RUNNING)
1866  {
1867  disablePreemptiveShutdown();
1868  stop();
1869  }
1870  else
1871  start();
1872 }
1873 
1875 {
1876  if (state != SCHEDULER_RUNNING)
1877  return;
1878 
1879  qCInfo(KSTARS_EKOS_SCHEDULER) << "Scheduler is stopping...";
1880 
1881  // Stop running job and abort all others
1882  // in case of soft shutdown we skip this
1883  if (!preemptiveShutdown())
1884  {
1885  bool wasAborted = false;
1886  foreach (SchedulerJob *job, jobs)
1887  {
1888  if (job == currentJob)
1890 
1891  if (job->getState() <= SchedulerJob::JOB_BUSY)
1892  {
1893  appendLogText(i18n("Job '%1' has not been processed upon scheduler stop, marking aborted.", job->getName()));
1894  job->setState(SchedulerJob::JOB_ABORTED);
1895  wasAborted = true;
1896  }
1897  }
1898 
1899  if (wasAborted)
1900  KNotification::event(QLatin1String("SchedulerAborted"), i18n("Scheduler aborted."));
1901  }
1902 
1903  TEST_PRINT(stderr, "%d Setting %s\n", __LINE__, timerStr(RUN_NOTHING).toLatin1().data());
1904  setupNextIteration(RUN_NOTHING);
1905  cancelGuidingTimer();
1906 
1907  state = SCHEDULER_IDLE;
1908  emit newStatus(state);
1909  ekosState = EKOS_IDLE;
1910  indiState = INDI_IDLE;
1911 
1912  parkWaitState = PARKWAIT_IDLE;
1913 
1914  // Only reset startup state to idle if the startup procedure was interrupted before it had the chance to complete.
1915  // Or if we're doing a soft shutdown
1916  if (startupState != STARTUP_COMPLETE || preemptiveShutdown())
1917  {
1918  if (startupState == STARTUP_SCRIPT)
1919  {
1920  scriptProcess.disconnect();
1921  scriptProcess.terminate();
1922  }
1923 
1924  startupState = STARTUP_IDLE;
1925  }
1926  // Reset startup state to unparking phase (dome -> mount -> cap)
1927  // We do not want to run the startup script again but unparking should be checked
1928  // whenever the scheduler is running again.
1929  else if (startupState == STARTUP_COMPLETE)
1930  {
1931  if (unparkDomeCheck->isChecked())
1932  startupState = STARTUP_UNPARK_DOME;
1933  else if (unparkMountCheck->isChecked())
1934  startupState = STARTUP_UNPARK_MOUNT;
1935  else if (uncapCheck->isChecked())
1936  startupState = STARTUP_UNPARK_CAP;
1937  }
1938 
1939  shutdownState = SHUTDOWN_IDLE;
1940 
1941  setCurrentJob(nullptr);
1942  captureBatch = 0;
1943  indiConnectFailureCount = 0;
1944  ekosConnectFailureCount = 0;
1945  focusFailureCount = 0;
1946  guideFailureCount = 0;
1947  alignFailureCount = 0;
1948  captureFailureCount = 0;
1949  loadAndSlewProgress = false;
1950  autofocusCompleted = false;
1951 
1952  startupB->setEnabled(true);
1953  shutdownB->setEnabled(true);
1954 
1955  // If soft shutdown, we return for now
1956  if (preemptiveShutdown())
1957  {
1958  sleepLabel->setToolTip(i18n("Scheduler is in shutdown until next job is ready"));
1959  sleepLabel->show();
1960 
1961  QDateTime const now = getLocalTime();
1962  int const nextObservationTime = now.secsTo(getPreemptiveShutdownWakeupTime());
1963  setupNextIteration(RUN_WAKEUP,
1964  std::lround(((nextObservationTime + 1) * 1000)
1965  / KStarsData::Instance()->clock()->scale()));
1966  return;
1967 
1968  }
1969 
1970  // Clear target name in capture interface upon stopping
1971  if (captureInterface.isNull() == false)
1972  {
1973  TEST_PRINT(stderr, "sch%d @@@dbus(%s): %s\n", __LINE__, "captureInterface:setProperty", "targetName=\"\"");
1974  captureInterface->setProperty("targetName", QString());
1975  }
1976 
1977  if (scriptProcess.state() == QProcess::Running)
1978  scriptProcess.terminate();
1979 
1980  sleepLabel->hide();
1981  pi->stopAnimation();
1982 
1983  startB->setIcon(QIcon::fromTheme("media-playback-start"));
1984  startB->setToolTip(i18n("Start Scheduler"));
1985  pauseB->setEnabled(false);
1986  //startB->setText("Start Scheduler");
1987 
1988  queueLoadB->setEnabled(true);
1989  queueAppendB->setEnabled(true);
1990  addToQueueB->setEnabled(true);
1991  setJobManipulation(false, false);
1992  //mosaicB->setEnabled(true);
1993  evaluateOnlyB->setEnabled(true);
1994 }
1995 
1996 void Scheduler::execute()
1997 {
1998  switch (state)
1999  {
2000  case SCHEDULER_IDLE:
2001  /* FIXME: Manage the non-validity of the startup script earlier, and make it a warning only when the scheduler starts */
2002  startupScriptURL = QUrl::fromUserInput(startupScript->text());
2003  if (!startupScript->text().isEmpty() && !startupScriptURL.isValid())
2004  {
2005  appendLogText(i18n("Warning: startup script URL %1 is not valid.", startupScript->text()));
2006  return;
2007  }
2008 
2009  /* FIXME: Manage the non-validity of the shutdown script earlier, and make it a warning only when the scheduler starts */
2010  shutdownScriptURL = QUrl::fromUserInput(shutdownScript->text());
2011  if (!shutdownScript->text().isEmpty() && !shutdownScriptURL.isValid())
2012  {
2013  appendLogText(i18n("Warning: shutdown script URL %1 is not valid.", shutdownScript->text()));
2014  return;
2015  }
2016 
2017  qCInfo(KSTARS_EKOS_SCHEDULER) << "Scheduler is starting...";
2018 
2019  /* Update UI to reflect startup */
2020  pi->startAnimation();
2021  sleepLabel->hide();
2022  startB->setIcon(QIcon::fromTheme("media-playback-stop"));
2023  startB->setToolTip(i18n("Stop Scheduler"));
2024  pauseB->setEnabled(true);
2025  pauseB->setChecked(false);
2026 
2027  /* Disable edit-related buttons */
2028  queueLoadB->setEnabled(false);
2029  queueAppendB->setEnabled(false);
2030  addToQueueB->setEnabled(false);
2031  setJobManipulation(false, false);
2032  //mosaicB->setEnabled(false);
2033  evaluateOnlyB->setEnabled(false);
2034  startupB->setEnabled(false);
2035  shutdownB->setEnabled(false);
2036 
2037  state = SCHEDULER_RUNNING;
2038  emit newStatus(state);
2039  TEST_PRINT(stderr, "%d Setting %s\n", __LINE__, timerStr(RUN_SCHEDULER).toLatin1().data());
2040  setupNextIteration(RUN_SCHEDULER);
2041 
2042  appendLogText(i18n("Scheduler started."));
2043  qCDebug(KSTARS_EKOS_SCHEDULER) << "Scheduler started.";
2044  break;
2045 
2046  case SCHEDULER_PAUSED:
2047  /* Update UI to reflect resume */
2048  startB->setIcon(QIcon::fromTheme("media-playback-stop"));
2049  startB->setToolTip(i18n("Stop Scheduler"));
2050  pauseB->setEnabled(true);
2051  pauseB->setCheckable(false);
2052  pauseB->setChecked(false);
2053 
2054  /* Edit-related buttons are still disabled */
2055 
2056  /* The end-user cannot update the schedule, don't re-evaluate jobs. Timer schedulerTimer is already running. */
2057  state = SCHEDULER_RUNNING;
2058  emit newStatus(state);
2059  TEST_PRINT(stderr, "%d Setting %s\n", __LINE__, timerStr(RUN_SCHEDULER).toLatin1().data());
2060  setupNextIteration(RUN_SCHEDULER);
2061 
2062  appendLogText(i18n("Scheduler resuming."));
2063  qCDebug(KSTARS_EKOS_SCHEDULER) << "Scheduler resuming.";
2064  break;
2065 
2066  default:
2067  break;
2068  }
2069 }
2070 
2071 void Scheduler::pause()
2072 {
2073  state = SCHEDULER_PAUSED;
2074  emit newStatus(state);
2075  appendLogText(i18n("Scheduler pause planned..."));
2076  pauseB->setEnabled(false);
2077 
2078  startB->setIcon(QIcon::fromTheme("media-playback-start"));
2079  startB->setToolTip(i18n("Resume Scheduler"));
2080 }
2081 
2082 void Scheduler::setPaused()
2083 {
2084  pauseB->setCheckable(true);
2085  pauseB->setChecked(true);
2086  TEST_PRINT(stderr, "%d Setting %s\n", __LINE__, timerStr(RUN_NOTHING).toLatin1().data());
2087  setupNextIteration(RUN_NOTHING);
2088  appendLogText(i18n("Scheduler paused."));
2089 }
2090 
2091 void Scheduler::setCurrentJob(SchedulerJob *job)
2092 {
2093  /* Reset job widgets */
2094  if (currentJob)
2095  {
2096  currentJob->setStageLabel(nullptr);
2097  }
2098 
2099  /* Set current job */
2100  currentJob = job;
2101 
2102  /* Reassign job widgets, or reset to defaults */
2103  if (currentJob)
2104  {
2105  currentJob->setStageLabel(jobStatus);
2106  queueTable->selectRow(jobs.indexOf(currentJob));
2107  }
2108  else
2109  {
2110  jobStatus->setText(i18n("No job running"));
2111  //queueTable->clearSelection();
2112  }
2113 }
2114 
2115 void Scheduler::syncGreedyParams()
2116 {
2117  constexpr int abortQueueSeconds = 3600;
2118  m_GreedyScheduler->setParams(
2119  errorHandlingRestartImmediatelyButton->isChecked(),
2120  errorHandlingRestartQueueButton->isChecked(),
2121  errorHandlingRescheduleErrorsCB->isChecked(),
2122  abortQueueSeconds,
2123  errorHandlingDelaySB->value());
2124 }
2125 
2126 void Scheduler::evaluateJobs(bool evaluateOnly)
2127 {
2128  for (auto job : jobs)
2129  job->clearCache();
2130 
2131  /* Don't evaluate if list is empty */
2132  if (jobs.isEmpty())
2133  return;
2134  /* Start by refreshing the number of captures already present - unneeded if not remembering job progress */
2135  if (Options::rememberJobProgress())
2136  updateCompletedJobsCount();
2137 
2138  calculateDawnDusk();
2139 
2140  QList<SchedulerJob *> jobsToProcess;
2141 
2142  if (ALGORITHM_GREEDY == getAlgorithm())
2143  {
2144  syncGreedyParams();
2145  jobsToProcess = m_GreedyScheduler->scheduleJobs(jobs, getLocalTime(), m_CapturedFramesCount, this);
2146  }
2147  else
2148  {
2149  SchedulerJob::enableGraphicsUpdates(false);
2150 
2151  bool possiblyDelay = false;
2152  auto rescheduleErrors = errorHandlingRescheduleErrorsCB->isChecked();
2153  auto restartJobs = errorHandlingDontRestartButton->isChecked() == false;
2154  QList<SchedulerJob *> sortedJobs = prepareJobsForEvaluation(jobs, state, m_CapturedFramesCount, rescheduleErrors,
2155  restartJobs, &possiblyDelay, this);
2156 
2157  jobsToProcess = evaluateJobs(jobs, m_CapturedFramesCount, Dawn, Dusk, this);
2158  if (sortedJobs.empty())
2159  {
2160  setCurrentJob(nullptr);
2161  return;
2162  }
2163  if (possiblyDelay && errorHandlingRestartQueueButton->isChecked())
2164  {
2165  TEST_PRINT(stderr, "%d Setting %s\n", __LINE__, timerStr(RUN_WAKEUP).toLatin1().data());
2166  setupNextIteration(RUN_WAKEUP, std::lround((errorHandlingDelaySB->value() * 1000) /
2167  KStarsData::Instance()->clock()->scale()));
2168  // but before we restart them, we wait for the given delay.
2169  appendLogText(i18n("All jobs aborted. Waiting %1 seconds to re-schedule.", errorHandlingDelaySB->value()));
2170 
2171  sleepLabel->setToolTip(i18n("Scheduler waits for a retry."));
2172  sleepLabel->show();
2173  // we continue to determine which job should be running, when the delay is over
2174  }
2175  SchedulerJob::enableGraphicsUpdates(true);
2176  for (auto job : jobsToProcess)
2177  job->updateJobCells();
2178  }
2179  processJobs(jobsToProcess, evaluateOnly);
2180 
2181  emit jobsUpdated(getJSONJobs());
2182 }
2183 
2184 // Decides which jobs should be considered for scheduling (that is, have their status set to JOB_EVALUATION).
2185 // Evaluates all except if the state is INVALID, COMPLETE, BUSY, ERROR, ABORTED (the last two if the scheduler is running)
2186 // It also looks at expired FINISH_AT and FINISH_REPEAT with enough reps.
2187 // It may trigger estimate time (why not always do this?) for some
2188 // Finally may sort by altitude/priority.
2189 //
2191  const QMap<QString, uint16_t> &capturedFramesCount,
2192  bool rescheduleErrors, bool restartJobs, bool *possiblyDelay, Scheduler *scheduler)
2193 {
2194  /* FIXME: it is possible to evaluate jobs while KStars has a time offset, so warn the user about this */
2195  QDateTime const now = getLocalTime();
2196 
2197  /* First, filter out non-schedulable jobs */
2198  /* FIXME: jobs in state JOB_ERROR should not be in the list, reorder states */
2199  QList<SchedulerJob *> sortedJobs = jobs;
2200 
2201  if (sortedJobs.isEmpty())
2202  return sortedJobs;
2203 
2204  /* Then enumerate SchedulerJobs to consolidate imaging time */
2205  foreach (SchedulerJob *job, sortedJobs)
2206  {
2207  /* Let aborted jobs be rescheduled later instead of forgetting them */
2208  switch (job->getState())
2209  {
2210  case SchedulerJob::JOB_SCHEDULED:
2211  /* If job is scheduled, keep it for evaluation against others */
2212  break;
2213 
2214  case SchedulerJob::JOB_INVALID:
2215  case SchedulerJob::JOB_COMPLETE:
2216  /* If job is invalid or complete, bypass evaluation */
2217  continue;
2218 
2219  case SchedulerJob::JOB_BUSY:
2220  /* If job is busy, edge case, bypass evaluation */
2221  continue;
2222 
2223  case SchedulerJob::JOB_ERROR:
2224  case SchedulerJob::JOB_ABORTED:
2225  /* If job is in error or aborted and we're running, keep its evaluation until there is nothing else to do */
2226  if (state == SCHEDULER_RUNNING)
2227  continue;
2228  /* Fall through */
2229  case SchedulerJob::JOB_IDLE:
2230  case SchedulerJob::JOB_EVALUATION:
2231  default:
2232  /* If job is idle, re-evaluate completely */
2233  job->setEstimatedTime(-1);
2234  break;
2235  }
2236 
2237  switch (job->getCompletionCondition())
2238  {
2239  case SchedulerJob::FINISH_AT:
2240  /* If planned finishing time has passed, the job is set to IDLE waiting for a next chance to run */
2241  if (job->getCompletionTime().isValid() && job->getCompletionTime() < now)
2242  {
2243  job->setState(SchedulerJob::JOB_IDLE);
2244  continue;
2245  }
2246  break;
2247 
2248  case SchedulerJob::FINISH_REPEAT:
2249  // In case of a repeating jobs, let's make sure we have more runs left to go
2250  // If we don't, re-estimate imaging time for the scheduler job before concluding
2251  if (job->getRepeatsRemaining() == 0)
2252  {
2253  if (scheduler != nullptr) scheduler->appendLogText(i18n("Job '%1' has no more batches remaining.", job->getName()));
2254  if (Options::rememberJobProgress())
2255  {
2256  job->setEstimatedTime(-1);
2257  }
2258  else
2259  {
2260  job->setState(SchedulerJob::JOB_COMPLETE);
2261  job->setEstimatedTime(0);
2262  continue;
2263  }
2264  }
2265  break;
2266 
2267  default:
2268  break;
2269  }
2270 
2271  // -1 = Job is not estimated yet
2272  // -2 = Job is estimated but time is unknown
2273  // > 0 Job is estimated and time is known
2274  if (job->getEstimatedTime() == -1)
2275  {
2276  if (estimateJobTime(job, capturedFramesCount, scheduler) == false)
2277  {
2278  job->setState(SchedulerJob::JOB_INVALID);
2279  continue;
2280  }
2281  }
2282 
2283  if (job->getEstimatedTime() == 0)
2284  {
2285  job->setRepeatsRemaining(0);
2286  job->setState(SchedulerJob::JOB_COMPLETE);
2287  continue;
2288  }
2289 
2290  // In any other case, evaluate
2291  job->setState(SchedulerJob::JOB_EVALUATION);
2292  }
2293 
2294  /*
2295  * At this step, we prepare scheduling of jobs.
2296  * We filter out jobs that won't run now, and make sure jobs are not all starting at the same time.
2297  */
2298 
2299  /* This predicate matches jobs not being evaluated and not aborted */
2300  auto neither_evaluated_nor_aborted = [](SchedulerJob const * const job)
2301  {
2302  SchedulerJob::JOBStatus const s = job->getState();
2303  return SchedulerJob::JOB_EVALUATION != s && SchedulerJob::JOB_ABORTED != s;
2304  };
2305 
2306  /* This predicate matches jobs neither being evaluated nor aborted nor in error state */
2307  auto neither_evaluated_nor_aborted_nor_error = [](SchedulerJob const * const job)
2308  {
2309  SchedulerJob::JOBStatus const s = job->getState();
2310  return SchedulerJob::JOB_EVALUATION != s && SchedulerJob::JOB_ABORTED != s && SchedulerJob::JOB_ERROR != s;
2311  };
2312 
2313  /* This predicate matches jobs that aborted, or completed for whatever reason */
2314  auto finished_or_aborted = [](SchedulerJob const * const job)
2315  {
2316  SchedulerJob::JOBStatus const s = job->getState();
2317  return SchedulerJob::JOB_ERROR <= s || SchedulerJob::JOB_ABORTED == s;
2318  };
2319 
2320  bool nea = std::all_of(sortedJobs.begin(), sortedJobs.end(), neither_evaluated_nor_aborted);
2321  bool neae = std::all_of(sortedJobs.begin(), sortedJobs.end(), neither_evaluated_nor_aborted_nor_error);
2322 
2323  /* If there are no jobs left to run in the filtered list, stop evaluation */
2324  if (sortedJobs.isEmpty() || (!rescheduleErrors && nea)
2325  || (rescheduleErrors && neae))
2326  {
2327  if (scheduler != nullptr) scheduler->appendLogText(i18n("No jobs left in the scheduler queue."));
2328  QList<SchedulerJob *> noJobs;
2329  return noJobs;
2330  }
2331 
2332  /* If there are only aborted jobs that can run, reschedule those */
2333  if (std::all_of(sortedJobs.begin(), sortedJobs.end(), finished_or_aborted) && restartJobs)
2334  {
2335  if (scheduler != nullptr) scheduler->appendLogText(i18n("Only %1 jobs left in the scheduler queue, rescheduling those.",
2336  rescheduleErrors ? "aborted or error" : "aborted"));
2337 
2338  // set aborted and error jobs to evaluation state
2339  for (int index = 0; index < sortedJobs.size(); index++)
2340  {
2341  SchedulerJob * const job = sortedJobs.at(index);
2342  if (SchedulerJob::JOB_ABORTED == job->getState() ||
2343  (rescheduleErrors && SchedulerJob::JOB_ERROR == job->getState()))
2344  job->setState(SchedulerJob::JOB_EVALUATION);
2345  }
2346  *possiblyDelay = true;
2347  }
2348 
2349  /* If option says so, reorder by altitude and priority before sequencing */
2350  /* FIXME: refactor so all sorts are using the same predicates */
2351  /* FIXME: use std::stable_sort as qStableSort is deprecated */
2352  /* FIXME: dissociate altitude and priority, it's difficult to choose which predicate to use first */
2353  qCInfo(KSTARS_EKOS_SCHEDULER) << "Option to sort jobs based on priority and altitude is" << Options::sortSchedulerJobs();
2354  if (Options::sortSchedulerJobs())
2355  {
2356  using namespace std::placeholders;
2357  std::stable_sort(sortedJobs.begin(), sortedJobs.end(),
2358  std::bind(SchedulerJob::decreasingAltitudeOrder, _1, _2, getLocalTime()));
2359  std::stable_sort(sortedJobs.begin(), sortedJobs.end(), SchedulerJob::increasingPriorityOrder);
2360  }
2361  return sortedJobs;
2362 }
2363 
2365  const QMap<QString, uint16_t> &capturedFramesCount,
2366  QDateTime const &Dawn, QDateTime const &Dusk, Scheduler *scheduler)
2367 {
2368  if (sortedJobs.isEmpty())
2369  return sortedJobs;
2370 
2371  /* FIXME: it is possible to evaluate jobs while KStars has a time offset, so warn the user about this */
2372  QDateTime const now = getLocalTime();
2373 
2374  /* The first reordered job has no lead time - this could also be the delay from now to startup */
2375  sortedJobs.first()->setLeadTime(0);
2376 
2377  /* The objective of the following block is to make sure jobs are sequential in the list filtered previously.
2378  *
2379  * The algorithm manages overlap between jobs by stating that scheduled jobs that start sooner are non-movable.
2380  * If the completion time of the previous job overlaps the current job, we offset the startup of the current job.
2381  * Jobs that have no valid startup time when evaluated (ASAP jobs) are assigned an immediate startup time.
2382  * The lead time from the Options registry is used as a buffer between jobs.
2383  *
2384  * Note about the situation where the current job overlaps the next job, and the next job is not movable:
2385  * - If we mark the current job invalid, it will not be processed at all. Dropping is not satisfactory.
2386  * - If we move the current job after the fixed job, we need to restart evaluation with a new list, and risk an
2387  * infinite loop eventually. This means swapping schedules, and is incompatible with altitude/priority sort.
2388  * - If we mark the current job aborted, it will be re-evaluated each time a job is complete to see if it can fit.
2389  * Although puzzling for the end-user, this solution is dynamic: the aborted job might or might not be scheduled
2390  * at the planned time slot. But as the end-user did not enforce the start time, this is acceptable. Moreover, the
2391  * schedule will be altered by external events during the execution.
2392  *
2393  * Here are the constraints that have an effect on the job being examined, and indirectly on all subsequent jobs:
2394  * - Twilight constraint moves jobs to the next dark sky interval.
2395  * - Altitude constraint, currently linked with Moon separation, moves jobs to the next acceptable altitude time.
2396  * - Culmination constraint moves jobs to the next transit time, with arbitrary offset.
2397  * - Fixed startup time moves jobs to a fixed time, essentially making them non-movable, or invalid if in the past.
2398  *
2399  * Here are the constraints that have an effect on jobs following the job being examined:
2400  * - Repeats requirement increases the duration of the current job, pushing subsequent jobs.
2401  * - Looping requirement causes subsequent jobs to become invalid (until dynamic priority is implemented).
2402  * - Fixed completion makes subsequent jobs start after that boundary time.
2403  *
2404  * However, we need a way to inform the end-user about failed schedules clearly in the UI.
2405  * The message to get through is that if jobs are not sorted by altitude/priority, the aborted or invalid jobs
2406  * should be modified or manually moved to a better position. If jobs are sorted automatically, aborted jobs will
2407  * be processed when possible, probably not at the expected moment.
2408  */
2409 
2410  // Make sure no two jobs have the same scheduled time or overlap with other jobs
2411  for (int index = 0; index < sortedJobs.size(); index++)
2412  {
2413  SchedulerJob * const currentJob = sortedJobs.at(index);
2414 
2415  // Bypass jobs that are not marked for evaluation - we did not remove them to preserve schedule order
2416  if (SchedulerJob::JOB_EVALUATION != currentJob->getState())
2417  continue;
2418 
2419  // At this point, a job with no valid start date is a problem, so consider invalid startup time is now
2420  if (!currentJob->getStartupTime().isValid())
2421  currentJob->setStartupTime(now);
2422 
2423  // Locate the previous scheduled job, so that a full schedule plan may be actually consolidated
2424  SchedulerJob const * previousJob = nullptr;
2425  for (int i = index - 1; 0 <= i; i--)
2426  {
2427  SchedulerJob const * const a_job = sortedJobs.at(i);
2428 
2429  if (SchedulerJob::JOB_SCHEDULED == a_job->getState())
2430  {
2431  previousJob = a_job;
2432  break;
2433  }
2434  }
2435 
2436  Q_ASSERT_X(nullptr == previousJob
2437  || previousJob != currentJob, __FUNCTION__,
2438  "Previous job considered for schedule is either undefined or not equal to current.");
2439 
2440  // Locate the next job - nothing special required except end of list check
2441  SchedulerJob const * const nextJob = index + 1 < sortedJobs.size() ? sortedJobs.at(index + 1) : nullptr;
2442 
2443  Q_ASSERT_X(nullptr == nextJob
2444  || nextJob != currentJob, __FUNCTION__, "Next job considered for schedule is either undefined or not equal to current.");
2445 
2446  // We're attempting to schedule the job 10 times before making it invalid
2447  for (int attempt = 1; attempt < 11; attempt++)
2448  {
2449  qCDebug(KSTARS_EKOS_SCHEDULER) <<
2450  QString("Schedule attempt #%1 for %2-second job '%3' on row #%4 starting at %5, completing at %6.")
2451  .arg(attempt)
2452  .arg(static_cast<int>(currentJob->getEstimatedTime()))
2453  .arg(currentJob->getName())
2454  .arg(index + 1)
2455  .arg(currentJob->getStartupTime().toString(currentJob->getDateTimeDisplayFormat()))
2456  .arg(currentJob->getCompletionTime().toString(currentJob->getDateTimeDisplayFormat()));
2457 
2458 
2459  // ----- #1 Should we reject the current job because of its fixed startup time?
2460  //
2461  // A job with fixed startup time must be processed at the time of startup, and may be late up to leadTime.
2462  // When such a job repeats, its startup time is reinitialized to prevent abort - see completion algorithm.
2463  // If such a job requires night time, minimum altitude or Moon separation, the consolidated startup time is checked for errors.
2464  // If all restrictions are complied with, we bypass the rest of the verifications as the job cannot be moved.
2465 
2466  if (SchedulerJob::START_AT == currentJob->getFileStartupCondition())
2467  {
2468  // Check whether the current job is too far in the past to be processed - if job is repeating, its startup time is already now
2469  if (currentJob->getStartupTime().addSecs(static_cast <int> (ceil(Options::leadTime() * 60))) < now)
2470  {
2471  currentJob->setState(SchedulerJob::JOB_INVALID);
2472 
2473 
2474  if (scheduler != nullptr) scheduler->appendLogText(
2475  i18n("Warning: job '%1' has fixed startup time %2 set in the past, marking invalid.",
2476  currentJob->getName(), currentJob->getStartupTime().toString(currentJob->getDateTimeDisplayFormat())));
2477 
2478  break;
2479  }
2480  // Check whether the current job has a positive dark sky score at the time of startup
2481  else if (true == currentJob->getEnforceTwilight() && !currentJob->runsDuringAstronomicalNightTime())
2482  {
2483  currentJob->setState(SchedulerJob::JOB_INVALID);
2484 
2485  if (scheduler != nullptr) scheduler->appendLogText(
2486  i18n("Warning: job '%1' has a fixed start time incompatible with its twilight restriction, marking invalid.",
2487  currentJob->getName()));
2488 
2489  break;
2490  }
2491  // Check whether the current job has a positive altitude score at the time of startup
2492  else if (currentJob->hasAltitudeConstraint() && currentJob->getAltitudeScore(currentJob->getStartupTime()) < 0)
2493  {
2494  currentJob->setState(SchedulerJob::JOB_INVALID);
2495 
2496  if (scheduler != nullptr) scheduler->appendLogText(
2497  i18n("Warning: job '%1' has a fixed start time incompatible with its altitude restriction, marking invalid.",
2498  currentJob->getName()));
2499 
2500  break;
2501  }
2502  // Check whether the current job has a positive Moon separation score at the time of startup
2503  else if (0 < currentJob->getMinMoonSeparation() && currentJob->getMoonSeparationScore(currentJob->getStartupTime()) < 0)
2504  {
2505  currentJob->setState(SchedulerJob::JOB_INVALID);
2506 
2507  if (scheduler != nullptr) scheduler->appendLogText(
2508  i18n("Warning: job '%1' has a fixed start time incompatible with its Moon separation restriction, marking invalid.",
2509  currentJob->getName()));
2510 
2511  break;
2512  }
2513 
2514  // Check whether a previous job overlaps the current job
2515  if (nullptr != previousJob && previousJob->getCompletionTime().isValid())
2516  {
2517  // Calculate time we should be at after finishing the previous job
2518  QDateTime const previousCompletionTime = previousJob->getCompletionTime().addSecs(static_cast <int> (ceil(
2519  Options::leadTime() * 60.0)));
2520 
2521  // Make this job invalid if startup time is not achievable because a START_AT job is non-movable
2522  if (currentJob->getStartupTime() < previousCompletionTime)
2523  {
2524  currentJob->setState(SchedulerJob::JOB_INVALID);
2525 
2526  if (scheduler != nullptr) scheduler->appendLogText(
2527  i18n("Warning: job '%1' has fixed startup time %2 unachievable due to the completion time of its previous sibling, marking invalid.",
2528  currentJob->getName(), currentJob->getStartupTime().toString(currentJob->getDateTimeDisplayFormat())));
2529 
2530  break;
2531  }
2532 
2533  currentJob->setLeadTime(previousJob->getCompletionTime().secsTo(currentJob->getStartupTime()));
2534  }
2535 
2536  // This job is non-movable, we're done
2537  currentJob->setScore(calculateJobScore(currentJob, Dawn, Dusk, now));
2538  currentJob->setState(SchedulerJob::JOB_SCHEDULED);
2539  qCDebug(KSTARS_EKOS_SCHEDULER) <<
2540  QString("Job '%1' is scheduled to start at %2, in compliance with fixed startup time requirement.")
2541  .arg(currentJob->getName())
2542  .arg(currentJob->getStartupTime().toString(currentJob->getDateTimeDisplayFormat()));
2543 
2544  break;
2545  }
2546 
2547  // ----- #2 Should we delay the current job because it overlaps the previous job?
2548  //
2549  // The previous job is considered non-movable, and its completion, plus lead time, is the origin for the current job.
2550  // If no previous job exists, or if all prior jobs in the list are rejected, there is no overlap.
2551  // If there is a previous job, the current job is simply delayed to avoid an eventual overlap.
2552  // IF there is a previous job but it never finishes, the current job is rejected.
2553  // This scheduling obviously relies on imaging time estimation: because errors stack up, future startup times are less and less reliable.
2554 
2555  if (nullptr != previousJob)
2556  {
2557  if (previousJob->getCompletionTime().isValid())
2558  {
2559  // Calculate time we should be at after finishing the previous job
2560  QDateTime const previousCompletionTime = previousJob->getCompletionTime().addSecs(static_cast <int> (ceil(
2561  Options::leadTime() * 60.0)));
2562 
2563  // Delay the current job to completion of its previous sibling if needed - this updates the completion time automatically
2564  if (currentJob->getStartupTime() < previousCompletionTime)
2565  {
2566  currentJob->setStartupTime(previousCompletionTime);
2567 
2568  qCDebug(KSTARS_EKOS_SCHEDULER) <<
2569  QString("Job '%1' is scheduled to start at %2, %3 seconds after %4, in compliance with previous job completion requirement.")
2570  .arg(currentJob->getName())
2571  .arg(currentJob->getStartupTime().toString(currentJob->getDateTimeDisplayFormat()))
2572  .arg(previousJob->getCompletionTime().secsTo(currentJob->getStartupTime()))
2573  .arg(previousJob->getCompletionTime().toString(previousJob->getDateTimeDisplayFormat()));
2574 
2575  // If the job is repeating or looping, re-estimate imaging duration - error case may be a bug
2576  if (SchedulerJob::FINISH_SEQUENCE != currentJob->getCompletionCondition())
2577  if (false == estimateJobTime(currentJob, capturedFramesCount, scheduler))
2578  currentJob->setState(SchedulerJob::JOB_INVALID);
2579 
2580  continue;
2581  }
2582  }
2583  else
2584  {
2585  currentJob->setState(SchedulerJob::JOB_INVALID);
2586 
2587  if (scheduler != nullptr) scheduler->appendLogText(
2588  i18n("Warning: Job '%1' cannot start because its previous sibling has no completion time, marking invalid.",
2589  currentJob->getName()));
2590 
2591  break;
2592  }
2593 
2594  currentJob->setLeadTime(previousJob->getCompletionTime().secsTo(currentJob->getStartupTime()));
2595 
2596  // Lead time can be zero, so completion may equal startup
2597  Q_ASSERT_X(previousJob->getCompletionTime() <= currentJob->getStartupTime(), __FUNCTION__,
2598  "Previous and current jobs do not overlap.");
2599  }
2600 
2601 
2602  // ----- #3 Should we delay the current job because it overlaps daylight?
2603  //
2604  // Pre-dawn time rules whether a job may be started before dawn, or delayed to next night.
2605  // Note that the case of START_AT jobs is considered earlier in the algorithm, thus may be omitted here.
2606  // In addition to be hardcoded currently, the imaging duration is not reliable enough to start a short job during pre-dawn.
2607  // However, completion time during daylight only causes a warning, as this case will be processed as the job runs.
2608 
2609  if (currentJob->getEnforceTwilight())
2610  {
2611  // During that check, we don't verify the current job can actually complete before dawn.
2612  // If the job is interrupted while running, it will be aborted and rescheduled at a later time.
2613 
2614  // If the job does not run during the astronomical night time, delay it to the next dusk
2615  // This function takes care of Ekos offsets, dawn/dusk and pre-dawn
2616  if (!currentJob->runsDuringAstronomicalNightTime())
2617  {
2618  // Delay job to next dusk - we will check other requirements later on
2619  currentJob->setStartupTime(currentJob->getDuskAstronomicalTwilight());
2620 
2621  qCDebug(KSTARS_EKOS_SCHEDULER) <<
2622  QString("Job '%1' is scheduled to start at %2, in compliance with night time requirement.")
2623  .arg(currentJob->getName())
2624  .arg(currentJob->getStartupTime().toString(currentJob->getDateTimeDisplayFormat()));
2625 
2626  continue;
2627  }
2628 
2629  // Check if the completion date overlaps the next dawn, and issue a warning if so
2630  if (currentJob->getDawnAstronomicalTwilight() < currentJob->getCompletionTime())
2631  {
2632  if (scheduler != nullptr) scheduler->appendLogText(
2633  i18n("Warning: job '%1' execution overlaps daylight, it will be interrupted at dawn and rescheduled on next night time.",
2634  currentJob->getName()));
2635  }
2636  }
2637 
2638 
2639  // ----- #4 Should we delay the current job because of its target culmination?
2640  //
2641  // Culmination uses the transit time, and fixes the startup time of the job to a particular offset around this transit time.
2642  // This restriction may be used to start a job at the least air mass, or after a meridian flip.
2643  // Culmination is scheduled before altitude restriction because it is normally more restrictive for the resulting startup time.
2644  // It may happen that a target cannot rise enough to comply with the altitude restriction, but a culmination time is always valid.
2645 
2646  if (SchedulerJob::START_CULMINATION == currentJob->getFileStartupCondition())
2647  {
2648  // Consolidate the culmination time, with offset, of the current job
2649  QDateTime const nextCulminationTime = currentJob->calculateCulmination(currentJob->getStartupTime());
2650 
2651  if (nextCulminationTime.isValid()) // Guaranteed
2652  {
2653  if (currentJob->getStartupTime() < nextCulminationTime)
2654  {
2655  currentJob->setStartupTime(nextCulminationTime);
2656 
2657  qCDebug(KSTARS_EKOS_SCHEDULER) <<
2658  QString("Job '%1' is scheduled to start at %2, in compliance with culmination requirements.")
2659  .arg(currentJob->getName())
2660  .arg(currentJob->getStartupTime().toString(currentJob->getDateTimeDisplayFormat()));
2661 
2662  continue;
2663  }
2664  }
2665  else
2666  {
2667  currentJob->setState(SchedulerJob::JOB_INVALID);
2668 
2669  if (scheduler != nullptr) scheduler->appendLogText(
2670  i18n("Warning: job '%1' requires culmination offset of %2 minutes, not achievable, marking invalid.",
2671  currentJob->getName(),
2672  QString("%L1").arg(currentJob->getCulminationOffset())));
2673 
2674  break;
2675  }
2676 
2677  // Don't test altitude here, because we will push the job during the next check step
2678  // Q_ASSERT_X(0 <= getAltitudeScore(currentJob, currentJob->getStartupTime()), __FUNCTION__, "Consolidated altitude time results in a positive altitude score.");
2679  }
2680 
2681 
2682  // ----- #5 Should we delay the current job because its altitude is incorrect?
2683  //
2684  // Altitude time ensures the job is assigned a startup time when its target is high enough.
2685  // As other restrictions, the altitude is only considered for startup time, completion time is managed while the job is running.
2686  // Because a target setting down is a problem for the schedule, a cutoff altitude is added in the case the job target is past the meridian at startup time.
2687  // FIXME: though arguable, Moon separation is also considered in that restriction check - move it to a separate case.
2688 
2689  if (currentJob->hasAltitudeConstraint())
2690  {
2691  // Consolidate a new altitude time from the startup time of the current job
2692  QDateTime const nextAltitudeTime = currentJob->calculateNextTime(currentJob->getStartupTime());
2693 
2694  if (nextAltitudeTime.isValid())
2695  {
2696  if (currentJob->getStartupTime() < nextAltitudeTime)
2697  {
2698  currentJob->setStartupTime(nextAltitudeTime);
2699 
2700  qCDebug(KSTARS_EKOS_SCHEDULER) <<
2701  QString("Job '%1' is scheduled to start at %2, in compliance with altitude and Moon separation requirements.")
2702  .arg(currentJob->getName())
2703  .arg(currentJob->getStartupTime().toString(currentJob->getDateTimeDisplayFormat()));
2704 
2705  continue;
2706  }
2707  }
2708  else
2709  {
2710  currentJob->setState(SchedulerJob::JOB_INVALID);
2711 
2712  if (scheduler != nullptr) scheduler->appendLogText(
2713  i18n("Warning: job '%1' requires minimum altitude %2 and Moon separation %3, not achievable, marking invalid.",
2714  currentJob->getName(),
2715  QString("%L1").arg(static_cast<double>(currentJob->getMinAltitude()), 0, 'f', 2),
2716  0.0 < currentJob->getMinMoonSeparation() ?
2717  QString("%L1").arg(static_cast<double>(currentJob->getMinMoonSeparation()), 0, 'f', 2) :
2718  QString("-")));
2719 
2720  break;
2721  }
2722 
2723  Q_ASSERT_X(0 <= currentJob->getAltitudeScore(currentJob->getStartupTime()), __FUNCTION__,
2724  "Consolidated altitude time results in a positive altitude score.");
2725  }
2726 
2727 
2728  // ----- #6 Should we reject the current job because it overlaps the next job and that next job is not movable?
2729  //
2730  // If we have a blocker next to the current job, we compare the completion time of the current job and the startup time of this next job, taking lead time into account.
2731  // This verification obviously relies on the imaging time to be reliable, but there's not much we can do at this stage of the implementation.
2732 
2733  if (nullptr != nextJob && SchedulerJob::START_AT == nextJob->getFileStartupCondition())
2734  {
2735  // In the current implementation, it is not possible to abort a running job when the next job is supposed to start.
2736  // Movable jobs after this one will be delayed, but non-movable jobs are considered blockers.
2737 
2738  // Calculate time we have between the end of the current job and the next job
2739  double const timeToNext = static_cast<double> (currentJob->getCompletionTime().secsTo(nextJob->getStartupTime()));
2740 
2741  // If that time is overlapping the next job, abort the current job
2742  if (timeToNext < Options::leadTime() * 60)
2743  {
2744  currentJob->setState(SchedulerJob::JOB_ABORTED);
2745 
2746  if (scheduler != nullptr) scheduler->appendLogText(
2747  i18n("Warning: job '%1' is constrained by the start time of the next job, and cannot finish in time, marking aborted.",
2748  currentJob->getName()));
2749 
2750  break;
2751  }
2752 
2753  Q_ASSERT_X(currentJob->getCompletionTime().addSecs(Options::leadTime() * 60) < nextJob->getStartupTime(), __FUNCTION__,
2754  "No overlap ");
2755  }
2756 
2757 
2758  // ----- #7 Should we reject the current job because it exceeded its fixed completion time?
2759  //
2760  // This verification simply checks that because of previous jobs, the startup time of the current job doesn't exceed its fixed completion time.
2761  // Its main objective is to catch wrong dates in the FINISH_AT configuration.
2762 
2763  if (SchedulerJob::FINISH_AT == currentJob->getCompletionCondition())
2764  {
2765  if (currentJob->getCompletionTime() < currentJob->getStartupTime())
2766  {
2767  if (scheduler != nullptr) scheduler->appendLogText(
2768  i18n("Job '%1' completion time (%2) could not be achieved before start up time (%3)",
2769  currentJob->getName(),
2770  currentJob->getCompletionTime().toString(currentJob->getDateTimeDisplayFormat()),
2771  currentJob->getStartupTime().toString(currentJob->getDateTimeDisplayFormat())));
2772 
2773  currentJob->setState(SchedulerJob::JOB_INVALID);
2774 
2775  break;
2776  }
2777  }
2778 
2779 
2780  // ----- #8 Should we reject the current job because of weather?
2781  //
2782  // That verification is left for runtime
2783  //
2784  // if (false == isWeatherOK(currentJob))
2785  //{
2786  // currentJob->setState(SchedulerJob::JOB_ABORTED);
2787  //
2788  // appendLogText(i18n("Job '%1' cannot run now because of bad weather, marking aborted.", currentJob->getName()));
2789  //}
2790 
2791 
2792  // ----- #9 Update score for current time and mark evaluating jobs as scheduled
2793 
2794  currentJob->setScore(calculateJobScore(currentJob, Dawn, Dusk, now));
2795  currentJob->setState(SchedulerJob::JOB_SCHEDULED);
2796 
2797  qCDebug(KSTARS_EKOS_SCHEDULER) <<
2798  QString("Job '%1' on row #%2 passed all checks after %3 attempts, will proceed at %4 for approximately %5 seconds, marking scheduled")
2799  .arg(currentJob->getName())
2800  .arg(index + 1)
2801  .arg(attempt)
2802  .arg(currentJob->getStartupTime().toString(currentJob->getDateTimeDisplayFormat()))
2803  .arg(currentJob->getEstimatedTime());
2804 
2805  break;
2806  }
2807 
2808  // Check if job was successfully scheduled, else reject it
2809  if (SchedulerJob::JOB_EVALUATION == currentJob->getState())
2810  {
2811  currentJob->setState(SchedulerJob::JOB_INVALID);
2812 
2813  //appendLogText(i18n("Warning: job '%1' on row #%2 could not be scheduled during evaluation and is marked invalid, please review your plan.",
2814  // currentJob->getName(),
2815  // index + 1));
2816 
2817  }
2818  }
2819  return sortedJobs;
2820 }
2821 
2822 void Scheduler::processJobs(QList<SchedulerJob *> sortedJobs, bool jobEvaluationOnly)
2823 {
2824  /* Apply sorting to queue table, and mark it for saving if it changes */
2825  mDirty = reorderJobs(sortedJobs) | mDirty;
2826 
2827  if (jobEvaluationOnly || state != SCHEDULER_RUNNING)
2828  {
2829  qCInfo(KSTARS_EKOS_SCHEDULER) << "Ekos finished evaluating jobs, no job selection required.";
2830  return;
2831  }
2832 
2833  /*
2834  * At this step, we finished evaluating jobs.
2835  * We select the first job that has to be run, per schedule.
2836  */
2837  auto finished_or_aborted = [](SchedulerJob const * const job)
2838  {
2839  SchedulerJob::JOBStatus const s = job->getState();
2840  return SchedulerJob::JOB_ERROR <= s || SchedulerJob::JOB_ABORTED == s;
2841  };
2842 
2843  /* This predicate matches jobs that are neither scheduled to run nor aborted */
2844  auto neither_scheduled_nor_aborted = [](SchedulerJob const * const job)
2845  {
2846  SchedulerJob::JOBStatus const s = job->getState();
2847  return SchedulerJob::JOB_SCHEDULED != s && SchedulerJob::JOB_ABORTED != s;
2848  };
2849 
2850  /* If there are no jobs left to run in the filtered list, stop evaluation */
2851  if (sortedJobs.isEmpty() || std::all_of(sortedJobs.begin(), sortedJobs.end(), neither_scheduled_nor_aborted))
2852  {
2853  appendLogText(i18n("No jobs left in the scheduler queue after evaluating."));
2854  setCurrentJob(nullptr);
2855  return;
2856  }
2857  /* If there are only aborted jobs that can run, reschedule those and let Scheduler restart one loop */
2858  else if (std::all_of(sortedJobs.begin(), sortedJobs.end(), finished_or_aborted) &&
2859  errorHandlingDontRestartButton->isChecked() == false)
2860  {
2861  appendLogText(i18n("Only aborted jobs left in the scheduler queue after evaluating, rescheduling those."));
2862  std::for_each(sortedJobs.begin(), sortedJobs.end(), [](SchedulerJob * job)
2863  {
2864  if (SchedulerJob::JOB_ABORTED == job->getState())
2865  job->setState(SchedulerJob::JOB_EVALUATION);
2866  });
2867 
2868  return;
2869  }
2870 
2871  if (getAlgorithm() == ALGORITHM_GREEDY)
2872  {
2873  // GreedyScheduler::scheduleJobs() must be called first.
2874  SchedulerJob *scheduledJob = m_GreedyScheduler->getScheduledJob();
2875  if (!scheduledJob)
2876  {
2877  appendLogText(i18n("No jobs scheduled."));
2878  setCurrentJob(nullptr);
2879  return;
2880  }
2881  setCurrentJob(scheduledJob);
2882  return;
2883  }
2884  else
2885  {
2886  /* FIXME: it is possible to evaluate jobs while KStars has a time offset, so warn the user about this */
2887  QDateTime const now = getLocalTime();
2888  SchedulerJob * job_to_execute = nullptr;
2889 
2890  /* The job to run is the first scheduled, locate it in the list */
2891  QList<SchedulerJob*>::iterator job_to_execute_iterator = std::find_if(sortedJobs.begin(),
2892  sortedJobs.end(), [](SchedulerJob * const job)
2893  {
2894  return SchedulerJob::JOB_SCHEDULED == job->getState();
2895  });
2896 
2897  /* If there is no scheduled job anymore (because the restriction loop made them invalid, for instance), bail out */
2898  if (sortedJobs.end() == job_to_execute_iterator)
2899  {
2900  appendLogText(i18n("No jobs left in the scheduler queue after schedule cleanup."));
2901  setCurrentJob(nullptr);
2902  return;
2903  }
2904 
2905  job_to_execute = *job_to_execute_iterator;
2906  /* Check if job can be processed right now */
2907  if (job_to_execute->getFileStartupCondition() == SchedulerJob::START_ASAP)
2908  if( 0 <= calculateJobScore(job_to_execute, Dawn, Dusk, now))
2909  job_to_execute->setStartupTime(now);
2910 
2911 
2912  qCDebug(KSTARS_EKOS_SCHEDULER) << QString("Job '%1' is selected for next observation with priority #%2 and score %3.")
2913  .arg(job_to_execute->getName())
2914  .arg(job_to_execute->getPriority())
2915  .arg(job_to_execute->getScore());
2916 
2917  // Set the current job, and let the status timer execute it when ready
2918  setCurrentJob(job_to_execute);
2919  }
2920 }
2921 
2923 {
2924  sleepLabel->hide();
2925 
2926  if (preemptiveShutdown())
2927  {
2928  disablePreemptiveShutdown();
2929  appendLogText(i18n("Scheduler is awake."));
2930  execute();
2931  }
2932  else
2933  {
2934  if (state == SCHEDULER_RUNNING)
2935  appendLogText(i18n("Scheduler is awake. Jobs shall be started when ready..."));
2936  else
2937  appendLogText(i18n("Scheduler is awake. Jobs shall be started when scheduler is resumed."));
2938 
2939  TEST_PRINT(stderr, "%d Setting %s\n", __LINE__, timerStr(RUN_SCHEDULER).toLatin1().data());
2940  setupNextIteration(RUN_SCHEDULER);
2941  }
2942 }
2943 
2944 int16_t Scheduler::getWeatherScore() const
2945 {
2946  if (weatherCheck->isEnabled() == false || weatherCheck->isChecked() == false)
2947  return 0;
2948 
2949  if (weatherStatus == ISD::Weather::WEATHER_WARNING)
2950  return BAD_SCORE / 2;
2951  else if (weatherStatus == ISD::Weather::WEATHER_ALERT)
2952  return BAD_SCORE;
2953 
2954  return 0;
2955 }
2956 
2957 int16_t Scheduler::getDarkSkyScore(QDateTime const &dawn, QDateTime const &dusk, QDateTime const &when)
2958 {
2959  double const secsPerDay = 24.0 * 3600.0;
2960 
2961  // Dark sky score is calculated based on distance to today's next dawn and dusk.
2962  // Option "Pre-dawn Time" avoids executing a job when dawn is approaching, and is a value in minutes.
2963  // - If observation is between option "Pre-dawn Time" and dawn, score is BAD_SCORE/50.
2964  // - If observation is before next dawn, which arrives first, score is fraction of the day from beginning of observation to dawn time, as percentage.
2965  // - If observation is before next dusk, which arrives first, score is BAD_SCORE.
2966  //
2967  // If observation time is invalid, the score is calculated for the current day time.
2968  // Note exact dusk time is considered valid in terms of night time, and will return a positive, albeit null, score.
2969 
2970  // FIXME: Current algorithm uses the dawn and dusk of today, instead of the day of the observation.
2971 
2972  // If both dawn and dusk are in the past, (incorrectly) readjust the dawn and dusk to the next day
2973  // This was OK for next-day calculations, but Scheduler should now drop dark sky scores and rely on SchedulerJob dawn and dusk
2974  QDateTime const now = when.isValid() ? when : getLocalTime();
2975  int const earlyDawnSecs = now.secsTo(dawn.addDays(dawn < now ? dawn.daysTo(now) + 1 : 0).addSecs(
2976  -60.0 * Options::preDawnTime()));
2977  int const dawnSecs = now.secsTo(dawn.addDays(dawn < now ? dawn.daysTo(now) + 1 : 0));
2978  int const duskSecs = now.secsTo(dusk.addDays(dawn < now ? dusk.daysTo(now) + 1 : 0));
2979  int const obsSecs = now.secsTo(when);
2980 
2981  Q_ASSERT_X(dawnSecs >= 0, __FUNCTION__, "Scheduler computes the next dawn after the considered event.");
2982  Q_ASSERT_X(duskSecs >= 0, __FUNCTION__, "Scheduler computes the next dusk after the considered event.");
2983 
2984  // If dawn is future and the next event is dusk, it is day time
2985  if (obsSecs < duskSecs && duskSecs <= dawnSecs)
2986  return BAD_SCORE;
2987 
2988  // If dawn is past and the next event is dusk, it is still day time
2989  if (dawnSecs <= obsSecs && obsSecs < duskSecs)
2990  return BAD_SCORE;
2991 
2992  // If early dawn is past and the next event is dawn, it could be OK but nope
2993  if (earlyDawnSecs <= obsSecs && obsSecs < dawnSecs && dawnSecs <= duskSecs)
2994  return BAD_SCORE / 50;
2995 
2996  // If the next event is early dawn, then it is night time
2997  if (obsSecs < earlyDawnSecs && earlyDawnSecs <= dawnSecs && earlyDawnSecs <= duskSecs)
2998  return static_cast <int16_t> ((100 * (earlyDawnSecs - obsSecs)) / secsPerDay);
2999 
3000  return BAD_SCORE;
3001 }
3002 
3003 int16_t Scheduler::calculateJobScore(SchedulerJob const *job, QDateTime const &dawn, QDateTime const &dusk,
3004  QDateTime const &when)
3005 {
3006  if (nullptr == job)
3007  return BAD_SCORE;
3008 
3009  /* Only consolidate the score if light frames are required, calibration frames can run whenever needed */
3010  if (!job->getLightFramesRequired())
3011  return 1000;
3012 
3013  int16_t total = 0;
3014 
3015  /* As soon as one score is negative, it's a no-go and other scores are unneeded */
3016 
3017  if (job->getEnforceTwilight())
3018  {
3019  int16_t const darkSkyScore = getDarkSkyScore(dawn, dusk, when);
3020 
3021  qCDebug(KSTARS_EKOS_SCHEDULER) << QString("Job '%1' dark sky score is %2 at %3")
3022  .arg(job->getName())
3023  .arg(QString::asprintf("%+d", darkSkyScore))
3024  .arg(when.toString(job->getDateTimeDisplayFormat()));
3025 
3026  total += darkSkyScore;
3027  }
3028 
3029  /* We still enforce altitude if the job is neither required to track nor guide, because this is too confusing for the end-user.
3030  * If we bypass calculation here, it must also be bypassed when checking job constraints in checkJobStage.
3031  */
3032  if (0 <= total /*&& ((job->getStepPipeline() & SchedulerJob::USE_TRACK) || (job->getStepPipeline() & SchedulerJob::USE_GUIDE))*/)
3033  {
3034  int16_t const altitudeScore = job->getAltitudeScore(when);
3035 
3036  qCDebug(KSTARS_EKOS_SCHEDULER) << QString("Job '%1' altitude score is %2 at %3")
3037  .arg(job->getName())
3038  .arg(QString::asprintf("%+d", altitudeScore))
3039  .arg(when.toString(job->getDateTimeDisplayFormat()));
3040 
3041  total += altitudeScore;
3042  }
3043 
3044  if (0 <= total)
3045  {
3046  int16_t const moonSeparationScore = job->getMoonSeparationScore(when);
3047 
3048  qCDebug(KSTARS_EKOS_SCHEDULER) << QString("Job '%1' Moon separation score is %2 at %3")
3049  .arg(job->getName())
3050  .arg(QString::asprintf("%+d", moonSeparationScore))
3051  .arg(when.toString(job->getDateTimeDisplayFormat()));
3052 
3053  total += moonSeparationScore;
3054  }
3055 
3056  qCInfo(KSTARS_EKOS_SCHEDULER) << QString("Job '%1' has a total score of %2 at %3.")
3057  .arg(job->getName())
3058  .arg(QString::asprintf("%+d", total))
3059  .arg(when.toString(job->getDateTimeDisplayFormat()));
3060 
3061  return total;
3062 }
3063 
3064 void Scheduler::calculateDawnDusk()
3065 {
3066  SchedulerJob::calculateDawnDusk(QDateTime(), Dawn, Dusk);
3067 
3068  preDawnDateTime = Dawn.addSecs(-60.0 * abs(Options::preDawnTime()));
3069 }
3070 
3071 bool Scheduler::executeJob(SchedulerJob *job)
3072 {
3073  // Some states have executeJob called after current job is cancelled - checkStatus does this
3074  if (job == nullptr)
3075  return false;
3076 
3077  // Don't execute the current job if it is already busy
3078  if (currentJob == job && SchedulerJob::JOB_BUSY == currentJob->getState())
3079  return false;
3080 
3081  setCurrentJob(job);
3082  int index = jobs.indexOf(job);
3083  if (index >= 0)
3084  queueTable->selectRow(index);
3085 
3086  // If we already started, we check when the next object is scheduled at.
3087  // If it is more than 30 minutes in the future, we park the mount if that is supported
3088  // and we unpark when it is due to start.
3089  //int const nextObservationTime = now.secsTo(currentJob->getStartupTime());
3090 
3091  // If the time to wait is greater than the lead time (5 minutes by default)
3092  // then we sleep, otherwise we wait. It's the same thing, just different labels.
3093  if (shouldSchedulerSleep(currentJob))
3094  return false;
3095  // If job schedule isn't now, wait - continuing to execute would cancel a parking attempt
3096  else if (0 < getLocalTime().secsTo(currentJob->getStartupTime()))
3097  return false;
3098 
3099  // From this point job can be executed now
3100 
3101  if (job->getCompletionCondition() == SchedulerJob::FINISH_SEQUENCE && Options::rememberJobProgress())
3102  {
3103  QString sanitized = job->getName();
3104  sanitized = sanitized.replace( QRegularExpression("\\s|/|\\(|\\)|:|\\*|~|\"" ), "_" )
3105  // Remove any two or more __
3106  .replace( QRegularExpression("_{2,}"), "_")
3107  // Remove any _ at the end
3108  .replace( QRegularExpression("_$"), "");
3109  TEST_PRINT(stderr, "sch%d @@@dbus(%s): %s%s\n", __LINE__, "captureInterface:setProperty", "targetName=",
3110  sanitized.toLatin1().data());
3111  captureInterface->setProperty("targetName", sanitized);
3112  }
3113 
3114  calculateDawnDusk();
3115  updateNightTime();
3116 
3117  // Reset autofocus so that focus step is applied properly when checked
3118  // When the focus step is not checked, the capture module will eventually run focus periodically
3119  autofocusCompleted = false;
3120 
3121  qCInfo(KSTARS_EKOS_SCHEDULER) << "Executing Job " << currentJob->getName();
3122 
3123  currentJob->setState(SchedulerJob::JOB_BUSY);
3124  emit jobsUpdated(getJSONJobs());
3125 
3126  KNotification::event(QLatin1String("EkosSchedulerJobStart"),
3127  i18n("Ekos job started (%1)", currentJob->getName()));
3128 
3129  // No need to continue evaluating jobs as we already have one.
3130  TEST_PRINT(stderr, "%d Setting %s\n", __LINE__, timerStr(RUN_JOBCHECK).toLatin1().data());
3131  setupNextIteration(RUN_JOBCHECK);
3132  return true;
3133 }
3134 
3135 bool Scheduler::checkEkosState()
3136 {
3137  if (state == SCHEDULER_PAUSED)
3138  return false;
3139 
3140  switch (ekosState)
3141  {
3142  case EKOS_IDLE:
3143  {
3144  if (m_EkosCommunicationStatus == Ekos::Success)
3145  {
3146  ekosState = EKOS_READY;
3147  return true;
3148  }
3149  else
3150  {
3151  TEST_PRINT(stderr, "sch%d @@@dbus(%s): sending %s\n", __LINE__, "ekosInterface", "start");
3152  ekosInterface->call(QDBus::AutoDetect, "start");
3153  ekosState = EKOS_STARTING;
3154  startCurrentOperationTimer();
3155 
3156  qCInfo(KSTARS_EKOS_SCHEDULER) << "Ekos communication status is" << m_EkosCommunicationStatus << "Starting Ekos...";
3157 
3158  return false;
3159  }
3160  }
3161 
3162  case EKOS_STARTING:
3163  {
3164  if (m_EkosCommunicationStatus == Ekos::Success)
3165  {
3166  appendLogText(i18n("Ekos started."));
3167  ekosConnectFailureCount = 0;
3168  ekosState = EKOS_READY;
3169  return true;
3170  }
3171  else if (m_EkosCommunicationStatus == Ekos::Error)
3172  {
3173  if (ekosConnectFailureCount++ < MAX_FAILURE_ATTEMPTS)
3174  {
3175  appendLogText(i18n("Starting Ekos failed. Retrying..."));
3176  TEST_PRINT(stderr, "sch%d @@@dbus(%s): sending %s\n", __LINE__, "ekosInterface", "start");
3177  ekosInterface->call(QDBus::AutoDetect, "start");
3178  return false;
3179  }
3180 
3181  appendLogText(i18n("Starting Ekos failed."));
3182  stop();
3183  return false;
3184  }
3185  else if (m_EkosCommunicationStatus == Ekos::Idle)
3186  return false;
3187  // If a minute passed, give up
3188  else if (getCurrentOperationMsec() > (60 * 1000))
3189  {
3190  if (ekosConnectFailureCount++ < MAX_FAILURE_ATTEMPTS)
3191  {
3192  appendLogText(i18n("Starting Ekos timed out. Retrying..."));
3193  TEST_PRINT(stderr, "sch%d @@@dbus(%s): sending %s\n", __LINE__, "ekosInterface", "stop");
3194  ekosInterface->call(QDBus::AutoDetect, "stop");
3195  QTimer::singleShot(1000, this, [&]()
3196  {
3197  TEST_PRINT(stderr, "sch%d @@@dbus(%s): sending %s\n", __LINE__, "ekosInterface", "start");
3198  ekosInterface->call(QDBus::AutoDetect, "start");
3199  startCurrentOperationTimer();
3200  });
3201  return false;
3202  }
3203 
3204  appendLogText(i18n("Starting Ekos timed out."));
3205  stop();
3206  return false;
3207  }
3208  }
3209  break;
3210 
3211  case EKOS_STOPPING:
3212  {
3213  if (m_EkosCommunicationStatus == Ekos::Idle)
3214  {
3215  appendLogText(i18n("Ekos stopped."));
3216  ekosState = EKOS_IDLE;
3217  return true;
3218  }
3219  }
3220  break;
3221 
3222  case EKOS_READY:
3223  return true;
3224  }
3225  return false;
3226 }
3227 
3228 bool Scheduler::isINDIConnected()
3229 {
3230  return (m_INDICommunicationStatus == Ekos::Success);
3231 }
3232 
3233 bool Scheduler::checkINDIState()
3234 {
3235  if (state == SCHEDULER_PAUSED)
3236  return false;
3237 
3238  //qCDebug(KSTARS_EKOS_SCHEDULER) << "Checking INDI State" << indiState;
3239 
3240  switch (indiState)
3241  {
3242  case INDI_IDLE:
3243  {
3244  if (m_INDICommunicationStatus == Ekos::Success)
3245  {
3246  indiState = INDI_PROPERTY_CHECK;
3247  indiConnectFailureCount = 0;
3248  qCDebug(KSTARS_EKOS_SCHEDULER) << "Checking INDI Properties...";
3249  }
3250  else
3251  {
3252  qCDebug(KSTARS_EKOS_SCHEDULER) << "Connecting INDI devices...";
3253  TEST_PRINT(stderr, "sch%d @@@dbus(%s): sending %s\n", __LINE__, "ekosInterface", "connectDevices");
3254  ekosInterface->call(QDBus::AutoDetect, "connectDevices");
3255  indiState = INDI_CONNECTING;
3256 
3257  startCurrentOperationTimer();
3258  }
3259  }
3260  break;
3261 
3262  case INDI_CONNECTING:
3263  {
3264  if (m_INDICommunicationStatus == Ekos::Success)
3265  {
3266  appendLogText(i18n("INDI devices connected."));
3267  indiState = INDI_PROPERTY_CHECK;
3268  }
3269  else if (m_INDICommunicationStatus == Ekos::Error)
3270  {
3271  if (indiConnectFailureCount++ < MAX_FAILURE_ATTEMPTS)
3272  {
3273  appendLogText(i18n("One or more INDI devices failed to connect. Retrying..."));
3274  TEST_PRINT(stderr, "sch%d @@@dbus(%s): sending %s\n", __LINE__, "ekosInterface", "connectDevices");
3275  ekosInterface->call(QDBus::AutoDetect, "connectDevices");
3276  }
3277  else
3278  {
3279  appendLogText(i18n("One or more INDI devices failed to connect. Check INDI control panel for details."));
3280  stop();
3281  }
3282  }
3283  // If 30 seconds passed, we retry
3284  else if (getCurrentOperationMsec() > (30 * 1000))
3285  {
3286  if (indiConnectFailureCount++ < MAX_FAILURE_ATTEMPTS)
3287  {
3288  appendLogText(i18n("One or more INDI devices timed out. Retrying..."));
3289  TEST_PRINT(stderr, "sch%d @@@dbus(%s): sending %s\n", __LINE__, "ekosInterface", "connectDevices");
3290  ekosInterface->call(QDBus::AutoDetect, "connectDevices");
3291  startCurrentOperationTimer();
3292  }
3293  else
3294  {
3295  appendLogText(i18n("One or more INDI devices timed out. Check INDI control panel for details."));
3296  stop();
3297  }
3298  }
3299  }
3300  break;
3301 
3302  case INDI_DISCONNECTING:
3303  {
3304  if (m_INDICommunicationStatus == Ekos::Idle)
3305  {
3306  appendLogText(i18n("INDI devices disconnected."));
3307  indiState = INDI_IDLE;
3308  return true;
3309  }
3310  }
3311  break;
3312 
3313  case INDI_PROPERTY_CHECK:
3314  {
3315  qCDebug(KSTARS_EKOS_SCHEDULER) << "Checking INDI properties.";
3316  // If dome unparking is required then we wait for dome interface
3317  if (unparkDomeCheck->isChecked() && m_DomeReady == false)
3318  {
3319  if (getCurrentOperationMsec() > (30 * 1000))
3320  {
3321  startCurrentOperationTimer();
3322  appendLogText(i18n("Warning: dome device not ready after timeout, attempting to recover..."));
3323  disconnectINDI();
3324  stopEkos();
3325  }
3326 
3327  qCDebug(KSTARS_EKOS_SCHEDULER) << "Dome unpark required but dome is not yet ready.";
3328  return false;
3329  }
3330 
3331  // If mount unparking is required then we wait for mount interface
3332  if (unparkMountCheck->isChecked() && m_MountReady == false)
3333  {
3334  if (getCurrentOperationMsec() > (30 * 1000))
3335  {
3336  startCurrentOperationTimer();
3337  appendLogText(i18n("Warning: mount device not ready after timeout, attempting to recover..."));
3338  disconnectINDI();
3339  stopEkos();
3340  }
3341 
3342  qCDebug(KSTARS_EKOS_SCHEDULER) << "Mount unpark required but mount is not yet ready.";
3343  return false;
3344  }
3345 
3346  // If cap unparking is required then we wait for cap interface
3347  if (uncapCheck->isChecked() && m_CapReady == false)
3348  {
3349  if (getCurrentOperationMsec() > (30 * 1000))
3350  {
3351  startCurrentOperationTimer();
3352  appendLogText(i18n("Warning: cap device not ready after timeout, attempting to recover..."));
3353  disconnectINDI();
3354  stopEkos();
3355  }
3356 
3357  qCDebug(KSTARS_EKOS_SCHEDULER) << "Cap unpark required but cap is not yet ready.";
3358  return false;
3359  }
3360 
3361  // capture interface is required at all times to proceed.
3362  if (captureInterface.isNull())
3363  return false;
3364 
3365  if (m_CaptureReady == false)
3366  {
3367  TEST_PRINT(stderr, "sch%d @@@dbus(%s): %s\n", __LINE__, "captureInterface:property", "coolerControl");
3368  QVariant hasCoolerControl = captureInterface->property("coolerControl");
3369  TEST_PRINT(stderr, " @@@dbus received %s\n",
3370  !hasCoolerControl.isValid() ? "invalid" : (hasCoolerControl.toBool() ? "T" : "F"));
3371  if (hasCoolerControl.isValid())
3372  {
3373  warmCCDCheck->setEnabled(hasCoolerControl.toBool());
3374  m_CaptureReady = true;
3375  }
3376  else
3377  qCWarning(KSTARS_EKOS_SCHEDULER) << "Capture module is not ready yet...";
3378  }
3379 
3380  indiState = INDI_READY;
3381  indiConnectFailureCount = 0;
3382  return true;
3383  }
3384 
3385  case INDI_READY:
3386  return true;
3387  }
3388 
3389  return false;
3390 }
3391 
3392 bool Scheduler::checkStartupState()
3393 {
3394  if (state == SCHEDULER_PAUSED)
3395  return false;
3396 
3397  qCDebug(KSTARS_EKOS_SCHEDULER) << QString("Checking Startup State (%1)...").arg(startupState);
3398 
3399  switch (startupState)
3400  {
3401  case STARTUP_IDLE:
3402  {
3403  KNotification::event(QLatin1String("ObservatoryStartup"), i18n("Observatory is in the startup process"));
3404 
3405  qCDebug(KSTARS_EKOS_SCHEDULER) << "Startup Idle. Starting startup process...";
3406 
3407  // If Ekos is already started, we skip the script and move on to dome unpark step
3408  // unless we do not have light frames, then we skip all
3409  //QDBusReply<int> isEkosStarted;
3410  //isEkosStarted = ekosInterface->call(QDBus::AutoDetect, "getEkosStartingStatus");
3411  //if (isEkosStarted.value() == Ekos::Success)
3412  if (m_EkosCommunicationStatus == Ekos::Success)
3413  {
3414  if (startupScriptURL.isEmpty() == false)
3415  appendLogText(i18n("Ekos is already started, skipping startup script..."));
3416 
3417  if (currentJob->getLightFramesRequired())
3418  startupState = STARTUP_UNPARK_DOME;
3419  else
3420  startupState = STARTUP_COMPLETE;
3421  return true;
3422  }
3423 
3424  if (schedulerProfileCombo->currentText() != i18n("Default"))
3425  {
3426  QList<QVariant> profile;
3427  profile.append(schedulerProfileCombo->currentText());
3428  TEST_PRINT(stderr, "sch%d @@@dbus(%s): %s\n", __LINE__, "ekosInterface:callWithArgs", "setProfile");
3429  ekosInterface->callWithArgumentList(QDBus::AutoDetect, "setProfile", profile);
3430  }
3431 
3432  if (startupScriptURL.isEmpty() == false)
3433  {
3434  startupState = STARTUP_SCRIPT;
3435  executeScript(startupScriptURL.toString(QUrl::PreferLocalFile));
3436  return false;
3437  }
3438 
3439  startupState = STARTUP_UNPARK_DOME;
3440  return false;
3441  }
3442 
3443  case STARTUP_SCRIPT:
3444  return false;
3445 
3446  case STARTUP_UNPARK_DOME:
3447  // If there is no job in case of manual startup procedure,
3448  // or if the job requires light frames, let's proceed with
3449  // unparking the dome, otherwise startup process is complete.
3450  if (currentJob == nullptr || currentJob->getLightFramesRequired())
3451  {
3452  if (unparkDomeCheck->isEnabled() && unparkDomeCheck->isChecked())
3453  unParkDome();
3454  else
3455  startupState = STARTUP_UNPARK_MOUNT;
3456  }
3457  else
3458  {
3459  startupState = STARTUP_COMPLETE;
3460  return true;
3461  }
3462 
3463  break;
3464 
3465  case STARTUP_UNPARKING_DOME:
3466  checkDomeParkingStatus();
3467  break;
3468 
3469  case STARTUP_UNPARK_MOUNT:
3470  if (unparkMountCheck->isEnabled() && unparkMountCheck->isChecked())
3471  unParkMount();
3472  else
3473  startupState = STARTUP_UNPARK_CAP;
3474  break;
3475 
3476  case STARTUP_UNPARKING_MOUNT:
3477  checkMountParkingStatus();
3478  break;
3479 
3480  case STARTUP_UNPARK_CAP:
3481  if (uncapCheck->isEnabled() && uncapCheck->isChecked())
3482  unParkCap();
3483  else
3484  startupState = STARTUP_COMPLETE;
3485  break;
3486 
3487  case STARTUP_UNPARKING_CAP:
3488  checkCapParkingStatus();
3489  break;
3490 
3491  case STARTUP_COMPLETE:
3492  return true;
3493 
3494  case STARTUP_ERROR:
3495  stop();
3496  return true;
3497  }
3498 
3499  return false;
3500 }
3501 
3502 bool Scheduler::checkShutdownState()
3503 {
3504  if (state == SCHEDULER_PAUSED)
3505  return false;
3506 
3507  qCDebug(KSTARS_EKOS_SCHEDULER) << "Checking shutdown state...";
3508 
3509  switch (shutdownState)
3510  {
3511  case SHUTDOWN_IDLE:
3512  KNotification::event(QLatin1String("ObservatoryShutdown"), i18n("Observatory is in the shutdown process"));
3513 
3514  qCInfo(KSTARS_EKOS_SCHEDULER) << "Starting shutdown process...";
3515 
3516  // weatherTimer.stop();
3517  // weatherTimer.disconnect();
3518  weatherLabel->hide();
3519 
3520  setCurrentJob(nullptr);
3521 
3522  TEST_PRINT(stderr, "%d Setting %s\n", __LINE__, timerStr(RUN_SHUTDOWN).toLatin1().data());
3523  setupNextIteration(RUN_SHUTDOWN);
3524 
3525  if (warmCCDCheck->isEnabled() && warmCCDCheck->isChecked())
3526  {
3527  appendLogText(i18n("Warming up CCD..."));
3528 
3529  // Turn it off
3530  //QVariant arg(false);
3531  //captureInterface->call(QDBus::AutoDetect, "setCoolerControl", arg);
3532  TEST_PRINT(stderr, "sch%d @@@dbus(%s): %s%s\n", __LINE__, "captureInterface:setProperty", "coolerControl=", "false");
3533  captureInterface->setProperty("coolerControl", false);
3534  }
3535 
3536  // The following steps require a connection to the INDI server
3537  if (isINDIConnected())
3538  {
3539  if (capCheck->isEnabled() && capCheck->isChecked())
3540  {
3541  shutdownState = SHUTDOWN_PARK_CAP;
3542  return false;
3543  }
3544 
3545  if (parkMountCheck->isEnabled() && parkMountCheck->isChecked())
3546  {
3547  shutdownState = SHUTDOWN_PARK_MOUNT;
3548  return false;
3549  }
3550 
3551  if (parkDomeCheck->isEnabled() && parkDomeCheck->isChecked())
3552  {
3553  shutdownState = SHUTDOWN_PARK_DOME;
3554  return false;
3555  }
3556  }
3557  else appendLogText(i18n("Warning: Bypassing parking procedures, no INDI connection."));
3558 
3559  if (shutdownScriptURL.isEmpty() == false)
3560  {
3561  shutdownState = SHUTDOWN_SCRIPT;
3562  return false;
3563  }
3564 
3565  shutdownState = SHUTDOWN_COMPLETE;
3566  return true;
3567 
3568  case SHUTDOWN_PARK_CAP:
3569  if (!isINDIConnected())
3570  {
3571  qCInfo(KSTARS_EKOS_SCHEDULER) << "Bypassing shutdown step 'park cap', no INDI connection.";
3572  shutdownState = SHUTDOWN_SCRIPT;
3573  }
3574  else if (capCheck->isEnabled() && capCheck->isChecked())
3575  parkCap();
3576  else
3577  shutdownState = SHUTDOWN_PARK_MOUNT;
3578  break;
3579 
3580  case SHUTDOWN_PARKING_CAP:
3581  checkCapParkingStatus();
3582  break;
3583 
3584  case SHUTDOWN_PARK_MOUNT:
3585  if (!isINDIConnected())
3586  {
3587  qCInfo(KSTARS_EKOS_SCHEDULER) << "Bypassing shutdown step 'park cap', no INDI connection.";
3588  shutdownState = SHUTDOWN_SCRIPT;
3589  }
3590  else if (parkMountCheck->isEnabled() && parkMountCheck->isChecked())
3591  parkMount();
3592  else
3593  shutdownState = SHUTDOWN_PARK_DOME;
3594  break;
3595 
3596  case SHUTDOWN_PARKING_MOUNT:
3597  checkMountParkingStatus();
3598  break;
3599 
3600  case SHUTDOWN_PARK_DOME:
3601  if (!isINDIConnected())
3602  {
3603  qCInfo(KSTARS_EKOS_SCHEDULER) << "Bypassing shutdown step 'park cap', no INDI connection.";
3604  shutdownState = SHUTDOWN_SCRIPT;
3605  }
3606  else if (parkDomeCheck->isEnabled() && parkDomeCheck->isChecked())
3607  parkDome();
3608  else
3609  shutdownState = SHUTDOWN_SCRIPT;
3610  break;
3611 
3612  case SHUTDOWN_PARKING_DOME:
3613  checkDomeParkingStatus();
3614  break;
3615 
3616  case SHUTDOWN_SCRIPT:
3617  if (shutdownScriptURL.isEmpty() == false)
3618  {
3619  // Need to stop Ekos now before executing script if it happens to stop INDI
3620  if (ekosState != EKOS_IDLE && Options::shutdownScriptTerminatesINDI())
3621  {
3622  stopEkos();
3623  return false;
3624  }
3625 
3626  shutdownState = SHUTDOWN_SCRIPT_RUNNING;
3627  executeScript(shutdownScriptURL.toString(QUrl::PreferLocalFile));
3628  }
3629  else
3630  shutdownState = SHUTDOWN_COMPLETE;
3631  break;
3632 
3633  case SHUTDOWN_SCRIPT_RUNNING:
3634  return false;
3635 
3636  case SHUTDOWN_COMPLETE:
3637  return completeShutdown();
3638 
3639  case SHUTDOWN_ERROR:
3640  stop();
3641  return true;
3642  }
3643 
3644  return false;
3645 }
3646 
3647 bool Scheduler::checkParkWaitState()
3648 {
3649  if (state == SCHEDULER_PAUSED)
3650  return false;
3651 
3652  if (parkWaitState == PARKWAIT_IDLE)
3653  return true;
3654 
3655  // qCDebug(KSTARS_EKOS_SCHEDULER) << "Checking Park Wait State...";
3656 
3657  switch (parkWaitState)
3658  {
3659  case PARKWAIT_PARK:
3660  parkMount();
3661  break;
3662 
3663  case PARKWAIT_PARKING:
3664  checkMountParkingStatus();
3665  break;
3666 
3667  case PARKWAIT_UNPARK:
3668  unParkMount();
3669  break;
3670 
3671  case PARKWAIT_UNPARKING:
3672  checkMountParkingStatus();
3673  break;
3674 
3675  case PARKWAIT_IDLE:
3676  case PARKWAIT_PARKED:
3677  case PARKWAIT_UNPARKED:
3678  return true;
3679 
3680  case PARKWAIT_ERROR:
3681  appendLogText(i18n("park/unpark wait procedure failed, aborting..."));
3682  stop();
3683  return true;
3684 
3685  }
3686 
3687  return false;
3688 }
3689 
3690 void Scheduler::executeScript(const QString &filename)
3691 {
3692  appendLogText(i18n("Executing script %1...", filename));
3693 
3695 
3696  connect(&scriptProcess, static_cast<void (QProcess::*)(int, QProcess::ExitStatus)>(&QProcess::finished),
3697  this, [this](int exitCode, QProcess::ExitStatus)
3698  {
3699  checkProcessExit(exitCode);
3700  });
3701 
3702  QStringList arguments;
3703  scriptProcess.start(filename, arguments);
3704 }
3705 
3707 {
3708  appendLogText(scriptProcess.readAllStandardOutput().simplified());
3709 }
3710 
3712 {
3713  scriptProcess.disconnect();
3714 
3715  if (exitCode == 0)
3716  {
3717  if (startupState == STARTUP_SCRIPT)
3718  startupState = STARTUP_UNPARK_DOME;
3719  else if (shutdownState == SHUTDOWN_SCRIPT_RUNNING)
3720  shutdownState = SHUTDOWN_COMPLETE;
3721 
3722  return;
3723  }
3724 
3725  if (startupState == STARTUP_SCRIPT)
3726  {
3727  appendLogText(i18n("Startup script failed, aborting..."));
3728  startupState = STARTUP_ERROR;
3729  }
3730  else if (shutdownState == SHUTDOWN_SCRIPT_RUNNING)
3731  {
3732  appendLogText(i18n("Shutdown script failed, aborting..."));
3733  shutdownState = SHUTDOWN_ERROR;
3734  }
3735 }
3736 
3737 bool Scheduler::completeShutdown()
3738 {
3739  // If INDI is not done disconnecting, try again later
3740  if (indiState == INDI_DISCONNECTING && checkINDIState() == false)
3741  return false;
3742 
3743  // Disconnect INDI if required first
3744  if (indiState != INDI_IDLE && Options::stopEkosAfterShutdown())
3745  {
3746  disconnectINDI();
3747  return false;
3748  }
3749 
3750  // If Ekos is not done stopping, try again later
3751  if (ekosState == EKOS_STOPPING && checkEkosState() == false)
3752  return false;
3753 
3754  // Stop Ekos if required.
3755  if (ekosState != EKOS_IDLE && Options::stopEkosAfterShutdown())
3756  {
3757  stopEkos();
3758  return false;
3759  }
3760 
3761  if (shutdownState == SHUTDOWN_COMPLETE)
3762  appendLogText(i18n("Shutdown complete."));
3763  else
3764  appendLogText(i18n("Shutdown procedure failed, aborting..."));
3765 
3766  // Stop Scheduler
3767  stop();
3768 
3769  return true;
3770 }
3771 
3773 {
3774  for (auto job : jobs)
3775  job->updateJobCells();
3776 
3777  if (state == SCHEDULER_PAUSED)
3778  {
3779  if (currentJob == nullptr)
3780  {
3781  setPaused();
3782  return false;
3783  }
3784  switch (currentJob->getState())
3785  {
3786  case SchedulerJob::JOB_BUSY:
3787  // do nothing
3788  break;
3789  case SchedulerJob::JOB_COMPLETE:
3790  // start finding next job before pausing
3791  break;
3792  default:
3793  // in all other cases pause
3794  setPaused();
3795  break;
3796  }
3797  }
3798 
3799  // #1 If no current job selected, let's check if we need to shutdown or evaluate jobs
3800  if (currentJob == nullptr)
3801  {
3802  // #2.1 If shutdown is already complete or in error, we need to stop
3803  if (shutdownState == SHUTDOWN_COMPLETE || shutdownState == SHUTDOWN_ERROR)
3804  {
3805  return completeShutdown();
3806  }
3807 
3808  // #2.2 Check if shutdown is in progress
3809  if (shutdownState > SHUTDOWN_IDLE)
3810  {
3811  // If Ekos is not done stopping, try again later
3812  if (ekosState == EKOS_STOPPING && checkEkosState() == false)
3813  return false;
3814 
3815  checkShutdownState();
3816  return false;
3817  }
3818 
3819  // #2.3 Check if park wait procedure is in progress
3820  if (checkParkWaitState() == false)
3821  return false;
3822 
3823  // #2.4 If not in shutdown state, evaluate the jobs
3824  evaluateJobs(false);
3825 
3826  // #2.5 If there is no current job after evaluation, shutdown
3827  if (nullptr == currentJob)
3828  {
3829  checkShutdownState();
3830  return false;
3831  }
3832  }
3833  // JM 2018-12-07: Check if we need to sleep
3834  else if (shouldSchedulerSleep(currentJob) == false)
3835  {
3836  // #3 Check if startup procedure has failed.
3837  if (startupState == STARTUP_ERROR)
3838  {
3839  // Stop Scheduler
3840  stop();
3841  return true;
3842  }
3843 
3844  // #4 Check if startup procedure Phase #1 is complete (Startup script)
3845  if ((startupState == STARTUP_IDLE && checkStartupState() == false) || startupState == STARTUP_SCRIPT)
3846  return false;
3847 
3848  // #5 Check if Ekos is started
3849  if (checkEkosState() == false)
3850  return false;
3851 
3852  // #6 Check if INDI devices are connected.
3853  if (checkINDIState() == false)
3854  return false;
3855 
3856  // #6.1 Check if park wait procedure is in progress - in the case we're waiting for a distant job
3857  if (checkParkWaitState() == false)
3858  return false;
3859 
3860  // #7 Check if startup procedure Phase #2 is complete (Unparking phase)
3861  if (startupState > STARTUP_SCRIPT && startupState < STARTUP_ERROR && checkStartupState() == false)
3862  return false;
3863 
3864  // #8 Check it it already completed (should only happen starting a paused job)
3865  // Find the next job in this case, otherwise execute the current one
3866  if (currentJob->getState() == SchedulerJob::JOB_COMPLETE)
3867  findNextJob();
3868 
3869  // N.B. We explicitly do not check for return result here because regardless of execution result
3870  // we do not have any pending tasks further down.
3871  executeJob(currentJob);
3872  }
3873 
3874  return true;
3875 }
3876 
3878 {
3879  Q_ASSERT_X(currentJob, __FUNCTION__, "Actual current job is required to check job stage");
3880  if (!currentJob)
3881  return;
3882 
3883  if (checkJobStageCounter == 0)
3884  {
3885  qCDebug(KSTARS_EKOS_SCHEDULER) << "Checking job stage for" << currentJob->getName() << "startup" <<
3886  currentJob->getStartupCondition() << currentJob->getStartupTime().toString(currentJob->getDateTimeDisplayFormat()) <<
3887  "state" << currentJob->getState();
3888  if (checkJobStageCounter++ == 30)
3889  checkJobStageCounter = 0;
3890  }
3891 
3892 
3893  if (ALGORITHM_GREEDY == getAlgorithm())
3894  {
3895  syncGreedyParams();
3896  if (!m_GreedyScheduler->checkJob(jobs, getLocalTime(), currentJob))
3897  {
3898  currentJob->setState(SchedulerJob::JOB_IDLE);
3900  findNextJob();
3901  return;
3902  }
3903  }
3904  else
3905  {
3906  if (!checkJobStageClassic())
3907  return;
3908  }
3909  checkJobStageEplogue();
3910 }
3911 
3912 bool Scheduler::checkJobStageClassic()
3913 {
3914  QDateTime const now = getLocalTime();
3915  /* Refresh the score of the current job */
3916  /* currentJob->setScore(calculateJobScore(currentJob, now)); */
3917 
3918  /* If current job is scheduled and has not started yet, wait */
3919  if (SchedulerJob::JOB_SCHEDULED == currentJob->getState())
3920  if (now < currentJob->getStartupTime())
3921  return false;
3922 
3923  // #1 Check if we need to stop at some point
3924  if (currentJob->getCompletionCondition() == SchedulerJob::FINISH_AT &&
3925  currentJob->getState() == SchedulerJob::JOB_BUSY)
3926  {
3927  // If the job reached it COMPLETION time, we stop it.
3928  if (now.secsTo(currentJob->getCompletionTime()) <= 0)
3929  {
3930  appendLogText(i18n("Job '%1' reached completion time %2, stopping.", currentJob->getName(),
3931  currentJob->getCompletionTime().toString(currentJob->getDateTimeDisplayFormat())));
3932  currentJob->setState(SchedulerJob::JOB_COMPLETE);
3934  findNextJob();
3935  return false;
3936  }
3937  }
3938 
3939  // #2 Check if altitude restriction still holds true
3940  if (currentJob->hasAltitudeConstraint())
3941  {
3942  SkyPoint p = currentJob->getTargetCoords();
3943 
3944  p.EquatorialToHorizontal(KStarsData::Instance()->lst(), geo->lat());
3945 
3946  /* FIXME: find a way to use altitude cutoff here, because the job can be scheduled when evaluating, then aborted when running */
3947  const double altitudeConstraint = currentJob->getMinAltitudeConstraint(p.az().Degrees());
3948  if (p.alt().Degrees() < altitudeConstraint)
3949  {
3950  // Only terminate job due to altitude limitation if mount is NOT parked.
3951  if (isMountParked() == false)
3952  {
3953  appendLogText(i18n("Job '%1' current altitude (%2 degrees) crossed minimum constraint altitude (%3 degrees), "
3954  "marking idle.", currentJob->getName(),
3955  QString("%L1").arg(p.alt().Degrees(), 0, 'f', minAltitude->decimals()),
3956  QString("%L1").arg(altitudeConstraint, 0, 'f', minAltitude->decimals())));
3957  currentJob->setState(SchedulerJob::JOB_IDLE);
3959  findNextJob();
3960  return false;
3961  }
3962  }
3963  }
3964 
3965  // #3 Check if moon separation is still valid
3966  if (currentJob->getMinMoonSeparation() > 0)
3967  {
3968  SkyPoint p = currentJob->getTargetCoords();
3969  p.EquatorialToHorizontal(KStarsData::Instance()->lst(), geo->lat());
3970 
3971  double moonSeparation = currentJob->getCurrentMoonSeparation();
3972 
3973  if (moonSeparation < currentJob->getMinMoonSeparation())
3974  {
3975  // Only terminate job due to moon separation limitation if mount is NOT parked.
3976  if (isMountParked() == false)
3977  {
3978  appendLogText(i18n("Job '%2' current moon separation (%1 degrees) is lower than minimum constraint (%3 "
3979  "degrees), marking idle.",
3980  moonSeparation, currentJob->getName(), currentJob->getMinMoonSeparation()));
3981  currentJob->setState(SchedulerJob::JOB_IDLE);
3983  findNextJob();
3984  return false;
3985  }
3986  }
3987  }
3988 
3989  // #4 Check if we're not at dawn - dawn is still next event before dusk, and early dawn is past
3990  if (currentJob->getEnforceTwilight() && ((Dawn < Dusk && preDawnDateTime < now) || (Dusk < Dawn)))
3991  {
3992  // If either mount or dome are not parked, we shutdown if we approach dawn
3993  if (isMountParked() == false || (parkDomeCheck->isEnabled() && isDomeParked() == false))
3994  {
3995  // Minute is a DOUBLE value, do not use i18np
3996  appendLogText(i18n(
3997  "Job '%3' is now approaching astronomical twilight rise limit at %1 (%2 minutes safety margin), marking idle.",
3998  preDawnDateTime.toString(), abs(Options::preDawnTime()), currentJob->getName()));
3999  currentJob->setState(SchedulerJob::JOB_IDLE);
4001  findNextJob();
4002  return false;
4003  }
4004  }
4005  return true;
4006 }
4007 
4008 void Scheduler::checkJobStageEplogue()
4009 {
4010  // #5 Check system status to improve robustness
4011  // This handles external events such as disconnections or end-user manipulating INDI panel
4012  if (!checkStatus())
4013  return;
4014 
4015  // #5b Check the guiding timer, and possibly restart guiding.
4016  processGuidingTimer();
4017 
4018  // #6 Check each stage is processing properly
4019  // FIXME: Vanishing property should trigger a call to its event callback
4020  switch (currentJob->getStage())
4021  {
4022  case SchedulerJob::STAGE_IDLE:
4023  // Job is just starting.
4024  emit jobStarted(currentJob->getName());
4025  getNextAction();
4026  break;
4027 
4028  case SchedulerJob::STAGE_ALIGNING:
4029  // Let's make sure align module does not become unresponsive
4030  if (getCurrentOperationMsec() > static_cast<int>(ALIGN_INACTIVITY_TIMEOUT))
4031  {
4032  TEST_PRINT(stderr, "sch%d @@@dbus(%s): %s\n", __LINE__, "alignInterface:property", "status");
4033  QVariant const status = alignInterface->property("status");
4034  TEST_PRINT(stderr, " @@@dbus received %d\n", !status.isValid() ? -1 : status.toInt());
4035  Ekos::AlignState alignStatus = static_cast<Ekos::AlignState>(status.toInt());
4036 
4037  if (alignStatus == Ekos::ALIGN_IDLE)
4038  {
4039  if (alignFailureCount++ < MAX_FAILURE_ATTEMPTS)
4040  {
4041  qCDebug(KSTARS_EKOS_SCHEDULER) << "Align module timed out. Restarting request...";
4042  startAstrometry();
4043  }
4044  else
4045  {
4046  appendLogText(i18n("Warning: job '%1' alignment procedure failed, marking aborted.", currentJob->getName()));
4047  currentJob->setState(SchedulerJob::JOB_ABORTED);
4048  findNextJob();
4049  }
4050  }
4051  else
4052  startCurrentOperationTimer();
4053  }
4054  break;
4055 
4056  case SchedulerJob::STAGE_CAPTURING:
4057  // Let's make sure capture module does not become unresponsive
4058  if (getCurrentOperationMsec() > static_cast<int>(CAPTURE_INACTIVITY_TIMEOUT))
4059  {
4060  TEST_PRINT(stderr, "sch%d @@@dbus(%s): %s\n", __LINE__, "captureInterface:property", "status");
4061  QVariant const status = captureInterface->property("status");
4062  TEST_PRINT(stderr, " @@@dbus received %d\n", !status.isValid() ? -1 : status.toInt());
4063  Ekos::CaptureState captureStatus = static_cast<Ekos::CaptureState>(status.toInt());
4064 
4065  if (captureStatus == Ekos::CAPTURE_IDLE)
4066  {
4067  if (captureFailureCount++ < MAX_FAILURE_ATTEMPTS)
4068  {
4069  qCDebug(KSTARS_EKOS_SCHEDULER) << "capture module timed out. Restarting request...";
4070  startCapture();
4071  }
4072  else
4073  {
4074  appendLogText(i18n("Warning: job '%1' capture procedure failed, marking aborted.", currentJob->getName()));
4075  currentJob->setState(SchedulerJob::JOB_ABORTED);
4076  findNextJob();
4077  }
4078  }
4079  else startCurrentOperationTimer();
4080  }
4081  break;
4082 
4083  case SchedulerJob::STAGE_FOCUSING:
4084  // Let's make sure focus module does not become unresponsive
4085  if (getCurrentOperationMsec() > static_cast<int>(FOCUS_INACTIVITY_TIMEOUT))
4086  {
4087  TEST_PRINT(stderr, "sch%d @@@dbus(%s): %s\n", __LINE__, "focusInterface:property", "status");
4088  QVariant const status = focusInterface->property("status");
4089  TEST_PRINT(stderr, " @@@dbus received %d\n", !status.isValid() ? -1 : status.toInt());
4090  Ekos::FocusState focusStatus = static_cast<Ekos::FocusState>(status.toInt());
4091 
4092  if (focusStatus == Ekos::FOCUS_IDLE || focusStatus == Ekos::FOCUS_WAITING)
4093  {
4094  if (focusFailureCount++ < MAX_FAILURE_ATTEMPTS)
4095  {
4096  qCDebug(KSTARS_EKOS_SCHEDULER) << "Focus module timed out. Restarting request...";
4097  startFocusing();
4098  }
4099  else
4100  {
4101  appendLogText(i18n("Warning: job '%1' focusing procedure failed, marking aborted.", currentJob->getName()));
4102  currentJob->setState(SchedulerJob::JOB_ABORTED);
4103  findNextJob();
4104  }
4105  }
4106  else startCurrentOperationTimer();
4107  }
4108  break;
4109 
4110  case SchedulerJob::STAGE_GUIDING:
4111  // Let's make sure guide module does not become unresponsive
4112  if (getCurrentOperationMsec() > GUIDE_INACTIVITY_TIMEOUT)
4113  {
4114  GuideState guideStatus = getGuidingStatus();
4115 
4116  if (guideStatus == Ekos::GUIDE_IDLE || guideStatus == Ekos::GUIDE_CONNECTED || guideStatus == Ekos::GUIDE_DISCONNECTED)
4117  {
4118  if (guideFailureCount++ < MAX_FAILURE_ATTEMPTS)
4119  {
4120  qCDebug(KSTARS_EKOS_SCHEDULER) << "guide module timed out. Restarting request...";
4121  startGuiding();
4122  }
4123  else
4124  {
4125  appendLogText(i18n("Warning: job '%1' guiding procedure failed, marking aborted.", currentJob->getName()));
4126  currentJob->setState(SchedulerJob::JOB_ABORTED);
4127  findNextJob();
4128  }
4129  }
4130  else startCurrentOperationTimer();
4131  }
4132  break;
4133 
4134  case SchedulerJob::STAGE_SLEWING:
4135  case SchedulerJob::STAGE_RESLEWING:
4136  // While slewing or re-slewing, check slew status can still be obtained
4137  {
4138  TEST_PRINT(stderr, "sch%d @@@dbus(%s): %s\n", __LINE__, "mountInterface:property", "status");
4139  QVariant const slewStatus = mountInterface->property("status");
4140  TEST_PRINT(stderr, " @@@dbus received %d\n", !slewStatus.isValid() ? -1 : slewStatus.toInt());
4141 
4142  if (slewStatus.isValid())
4143  {
4144  // Send the slew status periodically to avoid the situation where the mount is already at location and does not send any event
4145  // FIXME: in that case, filter TRACKING events only?
4146  ISD::Mount::Status const status = static_cast<ISD::Mount::Status>(slewStatus.toInt());
4147  setMountStatus(status);
4148  }
4149  else
4150  {
4151  appendLogText(i18n("Warning: job '%1' lost connection to the mount, attempting to reconnect.", currentJob->getName()));
4152  if (!manageConnectionLoss())
4153  currentJob->setState(SchedulerJob::JOB_ERROR);
4154  return;
4155  }
4156  }
4157  break;
4158 
4159  case SchedulerJob::STAGE_SLEW_COMPLETE:
4160  case SchedulerJob::STAGE_RESLEWING_COMPLETE:
4161  // When done slewing or re-slewing and we use a dome, only shift to the next action when the dome is done moving
4162  if (m_DomeReady)
4163  {
4164  TEST_PRINT(stderr, "sch%d @@@dbus(%s): %s\n", __LINE__, "domeInterface:property", "isMoving");
4165  QVariant const isDomeMoving = domeInterface->property("isMoving");
4166  TEST_PRINT(stderr, " @@@dbus received %s\n",
4167  !isDomeMoving.isValid() ? "invalid" : (isDomeMoving.value<bool>() ? "T" : "F"));
4168 
4169  if (!isDomeMoving.isValid())
4170  {
4171  appendLogText(i18n("Warning: job '%1' lost connection to the dome, attempting to reconnect.", currentJob->getName()));
4172  if (!manageConnectionLoss())
4173  currentJob->setState(SchedulerJob::JOB_ERROR);
4174  return;
4175  }
4176 
4177  if (!isDomeMoving.value<bool>())
4178  getNextAction();
4179  }
4180  else getNextAction();
4181  break;
4182 
4183  default:
4184  break;
4185  }
4186 }
4187 
4189 {
4190  qCDebug(KSTARS_EKOS_SCHEDULER) << "Get next action...";
4191 
4192  switch (currentJob->getStage())
4193  {
4194  case SchedulerJob::STAGE_IDLE:
4195  if (currentJob->getLightFramesRequired())
4196  {
4197  if (currentJob->getStepPipeline() & SchedulerJob::USE_TRACK)
4198  startSlew();
4199  else if (currentJob->getStepPipeline() & SchedulerJob::USE_FOCUS && autofocusCompleted == false)
4200  {
4201  qCDebug(KSTARS_EKOS_SCHEDULER) << "startFocusing on 3485";
4202  startFocusing();
4203  }
4204  else if (currentJob->getStepPipeline() & SchedulerJob::USE_ALIGN)
4205  startAstrometry();
4206  else if (currentJob->getStepPipeline() & SchedulerJob::USE_GUIDE)
4207  if (getGuidingStatus() == GUIDE_GUIDING)
4208  {
4209  appendLogText(i18n("Guiding already running, directly start capturing."));
4210  startCapture();
4211  }
4212  else
4213  startGuiding();
4214  else
4215  startCapture();
4216  }
4217  else
4218  {
4219  if (currentJob->getStepPipeline())
4220  appendLogText(
4221  i18n("Job '%1' is proceeding directly to capture stage because only calibration frames are pending.",
4222  currentJob->getName()));
4223  startCapture();
4224  }
4225 
4226  break;
4227 
4228  case SchedulerJob::STAGE_SLEW_COMPLETE:
4229  if (currentJob->getStepPipeline() & SchedulerJob::USE_FOCUS && autofocusCompleted == false)
4230  {
4231  qCDebug(KSTARS_EKOS_SCHEDULER) << "startFocusing on 3514";
4232  startFocusing();
4233  }
4234  else if (currentJob->getStepPipeline() & SchedulerJob::USE_ALIGN)
4235  startAstrometry();
4236  else if (currentJob->getStepPipeline() & SchedulerJob::USE_GUIDE)
4237  startGuiding();
4238  else
4239  startCapture();
4240  break;
4241 
4242  case SchedulerJob::STAGE_FOCUS_COMPLETE:
4243  if (currentJob->getStepPipeline() & SchedulerJob::USE_ALIGN)
4244  startAstrometry();
4245  else if (currentJob->getStepPipeline() & SchedulerJob::USE_GUIDE)
4246  startGuiding();
4247  else
4248  startCapture();
4249  break;
4250 
4251  case SchedulerJob::STAGE_ALIGN_COMPLETE:
4252  currentJob->setStage(SchedulerJob::STAGE_RESLEWING);
4253  break;
4254 
4255  case SchedulerJob::STAGE_RESLEWING_COMPLETE:
4256  // If we have in-sequence-focus in the sequence file then we perform post alignment focusing so that the focus
4257  // frame is ready for the capture module in-sequence-focus procedure.
4258  if ((currentJob->getStepPipeline() & SchedulerJob::USE_FOCUS) && currentJob->getInSequenceFocus())
4259  // Post alignment re-focusing
4260  {
4261  qCDebug(KSTARS_EKOS_SCHEDULER) << "startFocusing on 3544";
4262  startFocusing();
4263  }
4264  else if (currentJob->getStepPipeline() & SchedulerJob::USE_GUIDE)
4265  startGuiding();
4266  else
4267  startCapture();
4268  break;
4269 
4270  case SchedulerJob::STAGE_POSTALIGN_FOCUSING_COMPLETE:
4271  if (currentJob->getStepPipeline() & SchedulerJob::USE_GUIDE)
4272  startGuiding();
4273  else
4274  startCapture();
4275  break;
4276 
4277  case SchedulerJob::STAGE_GUIDING_COMPLETE:
4278  startCapture();
4279  break;
4280 
4281  default:
4282  break;
4283  }
4284 }
4285 
4287 {
4288  if (nullptr != currentJob)
4289  {
4290  qCDebug(KSTARS_EKOS_SCHEDULER) << "Job '" << currentJob->getName() << "' is stopping current action..." <<
4291  currentJob->getStage();
4292 
4293  switch (currentJob->getStage())
4294  {
4295  case SchedulerJob::STAGE_IDLE:
4296  break;
4297 
4298  case SchedulerJob::STAGE_SLEWING:
4299  TEST_PRINT(stderr, "sch%d @@@dbus(%s): %s\n", __LINE__, "mountInterface:call", "abort");
4300  mountInterface->call(QDBus::AutoDetect, "abort");
4301  break;
4302 
4303  case SchedulerJob::STAGE_FOCUSING:
4304  TEST_PRINT(stderr, "sch%d @@@dbus(%s): %s\n", __LINE__, "focusInterface:call", "abort");
4305  focusInterface->call(QDBus::AutoDetect, "abort");
4306  break;
4307 
4308  case SchedulerJob::STAGE_ALIGNING:
4309  TEST_PRINT(stderr, "sch%d @@@dbus(%s): %s\n", __LINE__, "alignInterface:call", "abort");
4310  alignInterface->call(QDBus::AutoDetect, "abort");
4311  break;
4312 
4313  // N.B. Need to use BlockWithGui as proposed by Wolfgang
4314  // to ensure capture is properly aborted before taking any further actions.
4315  case SchedulerJob::STAGE_CAPTURING:
4316  TEST_PRINT(stderr, "sch%d @@@dbus(%s): %s\n", __LINE__, "captureInterface:call", "abort");
4317  captureInterface->call(QDBus::BlockWithGui, "abort");
4318  break;
4319 
4320  default:
4321  break;
4322  }
4323 
4324  /* Reset interrupted job stage */
4325  currentJob->setStage(SchedulerJob::STAGE_IDLE);
4326  }
4327 
4328  /* Guiding being a parallel process, check to stop it */
4329  stopGuiding();
4330 }
4331 
4333 {
4334  if (SCHEDULER_RUNNING != state)
4335  return false;
4336 
4337  // Don't manage loss if Ekos is actually down in the state machine
4338  switch (ekosState)
4339  {
4340  case EKOS_IDLE:
4341  case EKOS_STOPPING:
4342  return false;
4343 
4344  default:
4345  break;
4346  }
4347 
4348  // Don't manage loss if INDI is actually down in the state machine
4349  switch (indiState)
4350  {
4351  case INDI_IDLE:
4352  case INDI_DISCONNECTING:
4353  return false;
4354 
4355  default:
4356  break;
4357  }
4358 
4359  // If Ekos is assumed to be up, check its state
4360  //QDBusReply<int> const isEkosStarted = ekosInterface->call(QDBus::AutoDetect, "getEkosStartingStatus");
4361  if (m_EkosCommunicationStatus == Ekos::Success)
4362  {
4363  qCDebug(KSTARS_EKOS_SCHEDULER) << QString("Ekos is currently connected, checking INDI before mitigating connection loss.");
4364 
4365  // If INDI is assumed to be up, check its state
4366  if (isINDIConnected())
4367  {
4368  // If both Ekos and INDI are assumed up, and are actually up, no mitigation needed, this is a DBus interface error
4369  qCDebug(KSTARS_EKOS_SCHEDULER) << QString("INDI is currently connected, no connection loss mitigation needed.");
4370  return false;
4371  }
4372  }
4373 
4374  // Stop actions of the current job
4376 
4377  // Acknowledge INDI and Ekos disconnections
4378  disconnectINDI();
4379  stopEkos();
4380 
4381  // Let the Scheduler attempt to connect INDI again
4382  return true;
4383 }
4384 
4385 void Scheduler::load(bool clearQueue, const QString &filename)
4386 {
4387  QUrl fileURL;
4388 
4389  if (filename.isEmpty())
4390  fileURL = QFileDialog::getOpenFileUrl(Ekos::Manager::Instance(), i18nc("@title:window", "Open Ekos Scheduler List"),
4391  dirPath,
4392  "Ekos Scheduler List (*.esl)");
4393  else fileURL.setUrl(filename);
4394 
4395  if (fileURL.isEmpty())
4396  return;
4397 
4398  if (fileURL.isValid() == false)
4399  {
4400  QString message = i18n("Invalid URL: %1", fileURL.toLocalFile());
4401  KSNotification::sorry(message, i18n("Invalid URL"));
4402  return;
4403  }
4404 
4405  dirPath = QUrl(fileURL.url(QUrl::RemoveFilename));
4406 
4407  if (clearQueue)
4408  removeAllJobs();
4409 
4410  /* Run a job idle evaluation after a successful load */
4411  if (appendEkosScheduleList(fileURL.toLocalFile()))
4413 }
4414 
4416 {
4417  if (jobUnderEdit >= 0)
4418  resetJobEdit();
4419 
4420  while (queueTable->rowCount() > 0)
4421  queueTable->removeRow(0);
4422 
4423  qDeleteAll(jobs);
4424  jobs.clear();
4425 }
4426 
4427 bool Scheduler::loadScheduler(const QString &fileURL)
4428 {
4429  removeAllJobs();
4430  return appendEkosScheduleList(fileURL);
4431 }
4432 
4434 {
4435  SchedulerState const old_state = state;
4436  state = SCHEDULER_LOADING;
4437 
4438  QFile sFile;
4439  sFile.setFileName(fileURL);
4440 
4441  if (!sFile.open(QIODevice::ReadOnly))
4442  {
4443  QString message = i18n("Unable to open file %1", fileURL);
4444  KSNotification::sorry(message, i18n("Could Not Open File"));
4445  state = old_state;
4446  return false;
4447  }
4448 
4449  LilXML *xmlParser = newLilXML();
4450  char errmsg[MAXRBUF];
4451  XMLEle *root = nullptr;
4452  XMLEle *ep = nullptr;
4453  XMLEle *subEP = nullptr;
4454  char c;
4455 
4456  // We expect all data read from the XML to be in the C locale - QLocale::c()
4457  QLocale cLocale = QLocale::c();
4458 
4459  while (sFile.getChar(&c))
4460  {
4461  root = readXMLEle(xmlParser, c, errmsg);
4462 
4463  if (root)
4464  {
4465  for (ep = nextXMLEle(root, 1); ep != nullptr; ep = nextXMLEle(root, 0))
4466  {
4467  const char *tag = tagXMLEle(ep);
4468  if (!strcmp(tag, "Job"))
4469  processJobInfo(ep);
4470  else if (!strcmp(tag, "Mosaic"))
4471  {
4472  // If we have mosaic info, load it up.
4473  auto tiles = KStarsData::Instance()->skyComposite()->mosaicComponent()->tiles();
4474  tiles->fromXML(fileURL);
4475  }
4476  else if (!strcmp(tag, "Profile"))
4477  {
4478  schedulerProfileCombo->setCurrentText(pcdataXMLEle(ep));
4479  }
4480  else if (!strcmp(tag, "SchedulerAlgorithm"))
4481  {
4482  setAlgorithm(static_cast<SchedulerAlgorithm>(cLocale.toInt(findXMLAttValu(ep, "value"))));
4483  }
4484  else if (!strcmp(tag, "ErrorHandlingStrategy"))
4485  {
4486  setErrorHandlingStrategy(static_cast<ErrorHandlingStrategy>(cLocale.toInt(findXMLAttValu(ep, "value"))));
4487 
4488  subEP = findXMLEle(ep, "delay");
4489  if (subEP)
4490  {
4491  errorHandlingDelaySB->setValue(cLocale.toInt(pcdataXMLEle(subEP)));
4492  }
4493  subEP = findXMLEle(ep, "RescheduleErrors");
4494  errorHandlingRescheduleErrorsCB->setChecked(subEP != nullptr);
4495  }
4496  else if (!strcmp(tag, "StartupProcedure"))
4497  {
4498  XMLEle *procedure;
4499  startupScript->clear();
4500  unparkDomeCheck->setChecked(false);
4501  unparkMountCheck->setChecked(false);
4502  uncapCheck->setChecked(false);
4503 
4504  for (procedure = nextXMLEle(ep, 1); procedure != nullptr; procedure = nextXMLEle(ep, 0))
4505  {
4506  const char *proc = pcdataXMLEle(procedure);
4507 
4508  if (!strcmp(proc, "StartupScript"))
4509  {
4510  startupScript->setText(findXMLAttValu(procedure, "value"));
4511  startupScriptURL = QUrl::fromUserInput(startupScript->text());
4512  }
4513  else if (!strcmp(proc, "UnparkDome"))
4514  unparkDomeCheck->setChecked(true);
4515  else if (!strcmp(proc, "UnparkMount"))
4516  unparkMountCheck->setChecked(true);
4517  else if (!strcmp(proc, "UnparkCap"))
4518  uncapCheck->setChecked(true);
4519  }
4520  }
4521  else if (!strcmp(tag, "ShutdownProcedure"))
4522  {
4523  XMLEle *procedure;
4524  shutdownScript->clear();
4525  warmCCDCheck->setChecked(false);
4526  parkDomeCheck->setChecked(false);
4527  parkMountCheck->setChecked(false);
4528  capCheck->setChecked(false);
4529 
4530  for (procedure = nextXMLEle(ep, 1); procedure != nullptr; procedure = nextXMLEle(ep, 0))
4531  {
4532  const char *proc = pcdataXMLEle(procedure);
4533 
4534  if (!strcmp(proc, "ShutdownScript"))
4535  {
4536  shutdownScript->setText(findXMLAttValu(procedure, "value"));
4537  shutdownScriptURL = QUrl::fromUserInput(shutdownScript->text());
4538  }
4539  else if (!strcmp(proc, "ParkDome"))
4540  parkDomeCheck->setChecked(true);
4541  else if (!strcmp(proc, "ParkMount"))
4542  parkMountCheck->setChecked(true);
4543  else if (!strcmp(proc, "ParkCap"))
4544  capCheck->setChecked(true);
4545  else if (!strcmp(proc, "WarmCCD"))
4546  warmCCDCheck->setChecked(true);
4547  }
4548  }
4549  }
4550  delXMLEle(root);
4551  }
4552  else if (errmsg[0])
4553  {
4554  appendLogText(QString(errmsg));
4555  delLilXML(xmlParser);
4556  state = old_state;
4557  return false;
4558  }
4559  }
4560 
4561  schedulerURL = QUrl::fromLocalFile(fileURL);
4562  //mosaicB->setEnabled(true);
4563  mDirty = false;
4564  delLilXML(xmlParser);
4565  // update save button tool tip
4566  queueSaveB->setToolTip("Save schedule to " + schedulerURL.fileName());
4567 
4568 
4569  state = old_state;
4570  return true;
4571 }
4572 
4573 bool Scheduler::processJobInfo(XMLEle *root)
4574 {
4575  XMLEle *ep;
4576  XMLEle *subEP;
4577 
4578  altConstraintCheck->setChecked(false);
4579  moonSeparationCheck->setChecked(false);
4580  weatherCheck->setChecked(false);
4581 
4582  twilightCheck->blockSignals(true);
4583  twilightCheck->setChecked(false);
4584  twilightCheck->blockSignals(false);
4585 
4586  artificialHorizonCheck->blockSignals(true);
4587  artificialHorizonCheck->setChecked(false);
4588  artificialHorizonCheck->blockSignals(false);
4589 
4590  minAltitude->setValue(minAltitude->minimum());
4591  minMoonSeparation->setValue(minMoonSeparation->minimum());
4592  positionAngleSpin->setValue(0);
4593 
4594  // We expect all data read from the XML to be in the C locale - QLocale::c()
4595  QLocale cLocale = QLocale::c();
4596 
4597  for (ep = nextXMLEle(root, 1); ep != nullptr; ep = nextXMLEle(root, 0))
4598  {
4599  if (!strcmp(tagXMLEle(ep), "Name"))
4600  nameEdit->setText(pcdataXMLEle(ep));
4601  else if (!strcmp(tagXMLEle(ep), "Priority"))
4602  prioritySpin->setValue(atoi(pcdataXMLEle(ep)));
4603  else if (!strcmp(tagXMLEle(ep), "Coordinates"))
4604  {
4605  subEP = findXMLEle(ep, "J2000RA");
4606  if (subEP)
4607  {
4608  dms ra;
4609  ra.setH(cLocale.toDouble(pcdataXMLEle(subEP)));
4610  raBox->show(ra);
4611  }
4612  subEP = findXMLEle(ep, "J2000DE");
4613  if (subEP)
4614  {
4615  dms de;
4616  de.setD(cLocale.toDouble(pcdataXMLEle(subEP)));
4617  decBox->show(de);
4618  }
4619  }
4620  else if (!strcmp(tagXMLEle(ep), "Sequence"))
4621  {
4622  sequenceEdit->setText(pcdataXMLEle(ep));
4623  sequenceURL = QUrl::fromUserInput(sequenceEdit->text());
4624  }
4625  else if (!strcmp(tagXMLEle(ep), "FITS"))
4626  {
4627  fitsEdit->setText(pcdataXMLEle(ep));
4628  fitsURL.setPath(fitsEdit->text());
4629  }
4630  else if (!strcmp(tagXMLEle(ep), "PositionAngle"))
4631  {
4632  positionAngleSpin->setValue(cLocale.toDouble(pcdataXMLEle(ep)));
4633  }
4634  else if (!strcmp(tagXMLEle(ep), "StartupCondition"))
4635  {
4636  for (subEP = nextXMLEle(ep, 1); subEP != nullptr; subEP = nextXMLEle(ep, 0))
4637  {
4638  if (!strcmp("ASAP", pcdataXMLEle(subEP)))
4639  asapConditionR->setChecked(true);
4640  else if (!strcmp("Culmination", pcdataXMLEle(subEP)))
4641  {
4642  culminationConditionR->setChecked(true);
4643  culminationOffset->setValue(cLocale.toDouble(findXMLAttValu(subEP, "value")));
4644  }
4645  else if (!strcmp("At", pcdataXMLEle(subEP)))
4646  {
4647  startupTimeConditionR->setChecked(true);
4648  startupTimeEdit->setDateTime(QDateTime::fromString(findXMLAttValu(subEP, "value"), Qt::ISODate));
4649  }
4650  }
4651  }
4652  else if (!strcmp(tagXMLEle(ep), "Constraints"))
4653  {
4654  for (subEP = nextXMLEle(ep, 1); subEP != nullptr; subEP = nextXMLEle(ep, 0))
4655  {
4656  if (!strcmp("MinimumAltitude", pcdataXMLEle(subEP)))
4657  {
4658  altConstraintCheck->setChecked(true);
4659  minAltitude->setValue(cLocale.toDouble(findXMLAttValu(subEP, "value")));
4660  }
4661  else if (!strcmp("MoonSeparation", pcdataXMLEle(subEP)))
4662  {
4663  moonSeparationCheck->setChecked(true);
4664  minMoonSeparation->setValue(cLocale.toDouble(findXMLAttValu(subEP, "value")));
4665  }
4666  else if (!strcmp("EnforceWeather", pcdataXMLEle(subEP)))
4667  weatherCheck->setChecked(true);
4668  else if (!strcmp("EnforceTwilight", pcdataXMLEle(subEP)))
4669  twilightCheck->setChecked(true);
4670  else if (!strcmp("EnforceArtificialHorizon", pcdataXMLEle(subEP)))
4671  artificialHorizonCheck->setChecked(true);
4672  }
4673  }
4674  else if (!strcmp(tagXMLEle(ep), "CompletionCondition"))
4675  {
4676  for (subEP = nextXMLEle(ep, 1); subEP != nullptr; subEP = nextXMLEle(ep, 0))
4677  {
4678  if (!strcmp("Sequence", pcdataXMLEle(subEP)))
4679  sequenceCompletionR->setChecked(true);
4680  else if (!strcmp("Repeat", pcdataXMLEle(subEP)))
4681  {
4682  repeatCompletionR->setChecked(true);
4683  repeatsSpin->setValue(cLocale.toInt(findXMLAttValu(subEP, "value")));
4684  }
4685  else if (!strcmp("Loop", pcdataXMLEle(subEP)))
4686  loopCompletionR->setChecked(true);
4687  else if (!strcmp("At", pcdataXMLEle(subEP)))
4688  {
4689  timeCompletionR->setChecked(true);
4690  completionTimeEdit->setDateTime(QDateTime::fromString(findXMLAttValu(subEP, "value"), Qt::ISODate));
4691  }
4692  }
4693  }
4694  else if (!strcmp(tagXMLEle(ep), "Steps"))
4695  {
4696  XMLEle *module;
4697  trackStepCheck->setChecked(false);
4698  focusStepCheck->setChecked(false);
4699  alignStepCheck->setChecked(false);
4700  guideStepCheck->setChecked(false);
4701 
4702  for (module = nextXMLEle(ep, 1); module != nullptr; module = nextXMLEle(ep, 0))
4703  {
4704  const char *proc = pcdataXMLEle(module);
4705 
4706  if (!strcmp(proc, "Track"))
4707  trackStepCheck->setChecked(true);
4708  else if (!strcmp(proc, "Focus"))
4709  focusStepCheck->setChecked(true);
4710  else if (!strcmp(proc, "Align"))
4711  alignStepCheck->setChecked(true);
4712  else if (!strcmp(proc, "Guide"))
4713  guideStepCheck->setChecked(true);
4714  }
4715  }
4716  }
4717 
4718  addToQueueB->setEnabled(true);
4719  saveJob();
4720 
4721  return true;
4722 }
4723 
4724 void Scheduler::saveAs()
4725 {
4726  schedulerURL.clear();
4727  save();
4728 }
4729 
4730 void Scheduler::save()
4731 {
4732  QUrl backupCurrent = schedulerURL;
4733 
4734  if (schedulerURL.toLocalFile().startsWith(QLatin1String("/tmp/")) || schedulerURL.toLocalFile().contains("/Temp"))
4735  schedulerURL.clear();
4736 
4737  // If no changes made, return.
4738  if (mDirty == false && !schedulerURL.isEmpty())
4739  return;
4740 
4741  if (schedulerURL.isEmpty())
4742  {
4743  schedulerURL =
4744  QFileDialog::getSaveFileUrl(Ekos::Manager::Instance(), i18nc("@title:window", "Save Ekos Scheduler List"), dirPath,
4745  "Ekos Scheduler List (*.esl)");
4746  // if user presses cancel
4747  if (schedulerURL.isEmpty())
4748  {
4749  schedulerURL = backupCurrent;
4750  return;
4751  }
4752 
4753  dirPath = QUrl(schedulerURL.url(QUrl::RemoveFilename));
4754 
4755  if (schedulerURL.toLocalFile().contains('.') == 0)
4756  schedulerURL.setPath(schedulerURL.toLocalFile() + ".esl");
4757  }
4758 
4759  if (schedulerURL.isValid())
4760  {
4761  if ((saveScheduler(schedulerURL)) == false)
4762  {
4763  KSNotification::error(i18n("Failed to save scheduler list"), i18n("Save"));
4764  return;
4765  }
4766 
4767  // update save button tool tip
4768  queueSaveB->setToolTip("Save schedule to " + schedulerURL.fileName());
4769  }
4770  else
4771  {
4772  QString message = i18n("Invalid URL: %1", schedulerURL.url());
4773  KSNotification::sorry(message, i18n("Invalid URL"));
4774  }
4775 }
4776 
4777 bool Scheduler::saveScheduler(const QUrl &fileURL)
4778 {
4779  QFile file;
4780  file.setFileName(fileURL.toLocalFile());
4781 
4782  if (!file.open(QIODevice::WriteOnly))
4783  {
4784  QString message = i18n("Unable to write to file %1", fileURL.toLocalFile());
4785  KSNotification::sorry(message, i18n("Could Not Open File"));
4786  return false;
4787  }
4788 
4789  QTextStream outstream(&file);
4790 
4791  // We serialize sequence data to XML using the C locale
4792  QLocale cLocale = QLocale::c();
4793 
4794  outstream << "<?xml version=\"1.0\" encoding=\"UTF-8\"?>" << Qt::endl;
4795  outstream << "<SchedulerList version='1.5'>" << Qt::endl;
4796  // ensure to escape special XML characters
4797  outstream << "<Profile>" << QString(entityXML(strdup(schedulerProfileCombo->currentText().toStdString().c_str()))) <<
4798  "</Profile>" << Qt::endl;
4799 
4800  auto tiles = KStarsData::Instance()->skyComposite()->mosaicComponent()->tiles();
4801  bool useMosaicInfo = !tiles->sequenceFile().isEmpty();
4802 
4803  if (useMosaicInfo)
4804  {
4805  outstream << "<Mosaic>" << Qt::endl;
4806  outstream << "<Target>" << tiles->targetName() << "</Target>" << Qt::endl;
4807  outstream << "<Sequence>" << tiles->sequenceFile() << "</Sequence>" << Qt::endl;
4808  outstream << "<Directory>" << tiles->outputDirectory() << "</Directory>" << Qt::endl;
4809  outstream << "<FocusEveryN>" << tiles->focusEveryN() << "</FocusEveryN>" << Qt::endl;
4810  outstream << "<AlignEveryN>" << tiles->alignEveryN() << "</AlignEveryN>" << Qt::endl;
4811  if (tiles->isTrackChecked())
4812  outstream << "<TrackChecked/>" << Qt::endl;
4813  if (tiles->isFocusChecked())
4814  outstream << "<FocusChecked/>" << Qt::endl;
4815  if (tiles->isAlignChecked())
4816  outstream << "<AlignChecked/>" << Qt::endl;
4817  if (tiles->isGuideChecked())
4818  outstream << "<GuideChecked/>" << Qt::endl;
4819  outstream << "<Overlap>" << cLocale.toString(tiles->overlap()) << "</Overlap>" << Qt::endl;
4820  outstream << "<CenterRA>" << cLocale.toString(tiles->ra0().Hours()) << "</CenterRA>" << Qt::endl;
4821  outstream << "<CenterDE>" << cLocale.toString(tiles->dec0().Degrees()) << "</CenterDE>" << Qt::endl;
4822  outstream << "<GridW>" << tiles->gridSize().width() << "</GridW>" << Qt::endl;
4823  outstream << "<GridH>" << tiles->gridSize().height() << "</GridH>" << Qt::endl;
4824  outstream << "<FOVW>" << cLocale.toString(tiles->mosaicFOV().width()) << "</FOVW>" << Qt::endl;
4825  outstream << "<FOVH>" << cLocale.toString(tiles->mosaicFOV().height()) << "</FOVH>" << Qt::endl;
4826  outstream << "<CameraFOVW>" << cLocale.toString(tiles->cameraFOV().width()) << "</CameraFOVW>" << Qt::endl;
4827  outstream << "<CameraFOVH>" << cLocale.toString(tiles->cameraFOV().height()) << "</CameraFOVH>" << Qt::endl;
4828  outstream << "</Mosaic>" << Qt::endl;
4829  }
4830 
4831  int index = 0;
4832  foreach (SchedulerJob *job, jobs)
4833  {
4834  outstream << "<Job>" << Qt::endl;
4835 
4836  // ensure to escape special XML characters
4837  outstream << "<Name>" << QString(entityXML(strdup(job->getName().toStdString().c_str()))) << "</Name>" << Qt::endl;
4838  outstream << "<Priority>" << job->getPriority() << "</Priority>" << Qt::endl;
4839  outstream << "<Coordinates>" << Qt::endl;
4840  outstream << "<J2000RA>" << cLocale.toString(job->getTargetCoords().ra0().Hours()) << "</J2000RA>" << Qt::endl;
4841  outstream << "<J2000DE>" << cLocale.toString(job->getTargetCoords().dec0().Degrees()) << "</J2000DE>" << Qt::endl;
4842  outstream << "</Coordinates>" << Qt::endl;
4843 
4844  if (job->getFITSFile().isValid() && job->getFITSFile().isEmpty() == false)
4845  outstream << "<FITS>" << job->getFITSFile().toLocalFile() << "</FITS>" << Qt::endl;
4846  else
4847  outstream << "<PositionAngle>" << job->getPositionAngle() << "</PositionAngle>" << Qt::endl;
4848 
4849  outstream << "<Sequence>" << job->getSequenceFile().toLocalFile() << "</Sequence>" << Qt::endl;
4850 
4851  if (useMosaicInfo)
4852  {
4853  auto oneTile = tiles->tiles().at(index++);
4854  outstream << "<TileCenter>" << Qt::endl;
4855  outstream << "<X>" << cLocale.toString(oneTile->center.x()) << "</X>" << Qt::endl;
4856  outstream << "<Y>" << cLocale.toString(oneTile->center.y()) << "</Y>" << Qt::endl;
4857  outstream << "<Rotation>" << cLocale.toString(oneTile->rotation) << "</Rotation>" << Qt::endl;
4858  outstream << "</TileCenter>" << Qt::endl;
4859  }
4860 
4861  outstream << "<StartupCondition>" << Qt::endl;
4862  if (job->getFileStartupCondition() == SchedulerJob::START_ASAP)
4863  outstream << "<Condition>ASAP</Condition>" << Qt::endl;
4864  else if (job->getFileStartupCondition() == SchedulerJob::START_CULMINATION)
4865  outstream << "<Condition value='" << cLocale.toString(job->getCulminationOffset()) << "'>Culmination</Condition>" <<
4866  Qt::endl;
4867  else if (job->getFileStartupCondition() == SchedulerJob::START_AT)
4868  outstream << "<Condition value='" << job->getFileStartupTime().toString(Qt::ISODate) << "'>At</Condition>"
4869  << Qt::endl;
4870  outstream << "</StartupCondition>" << Qt::endl;
4871 
4872  outstream << "<Constraints>" << Qt::endl;
4873  if (job->hasMinAltitude())
4874  outstream << "<Constraint value='" << cLocale.toString(job->getMinAltitude()) << "'>MinimumAltitude</Constraint>" <<
4875  Qt::endl;
4876  if (job->getMinMoonSeparation() > 0)
4877  outstream << "<Constraint value='" << cLocale.toString(job->getMinMoonSeparation()) << "'>MoonSeparation</Constraint>"
4878  << Qt::endl;
4879  if (job->getEnforceWeather())
4880  outstream << "<Constraint>EnforceWeather</Constraint>" << Qt::endl;
4881  if (job->getEnforceTwilight())
4882  outstream << "<Constraint>EnforceTwilight</Constraint>" << Qt::endl;
4883  if (job->getEnforceArtificialHorizon())
4884  outstream << "<Constraint>EnforceArtificialHorizon</Constraint>" << Qt::endl;
4885  outstream << "</Constraints>" << Qt::endl;
4886 
4887  outstream << "<CompletionCondition>" << Qt::endl;
4888  if (job->getCompletionCondition() == SchedulerJob::FINISH_SEQUENCE)
4889  outstream << "<Condition>Sequence</Condition>" << Qt::endl;
4890  else if (job->getCompletionCondition() == SchedulerJob::FINISH_REPEAT)
4891  outstream << "<Condition value='" << cLocale.toString(job->getRepeatsRequired()) << "'>Repeat</Condition>" << Qt::endl;
4892  else if (job->getCompletionCondition() == SchedulerJob::FINISH_LOOP)
4893  outstream << "<Condition>Loop</Condition>" << Qt::endl;
4894  else if (job->getCompletionCondition() == SchedulerJob::FINISH_AT)
4895  outstream << "<Condition value='" << job->getCompletionTime().toString(Qt::ISODate) << "'>At</Condition>"
4896  << Qt::endl;
4897  outstream << "</CompletionCondition>" << Qt::endl;
4898 
4899  outstream << "<Steps>" << Qt::endl;
4900  if (job->getStepPipeline() & SchedulerJob::USE_TRACK)
4901  outstream << "<Step>Track</Step>" << Qt::endl;
4902  if (job->getStepPipeline() & SchedulerJob::USE_FOCUS)
4903  outstream << "<Step>Focus</Step>" << Qt::endl;
4904  if (job->getStepPipeline() & SchedulerJob::USE_ALIGN)
4905  outstream << "<Step>Align</Step>" << Qt::endl;
4906  if (job->getStepPipeline() & SchedulerJob::USE_GUIDE)
4907  outstream << "<Step>Guide</Step>" << Qt::endl;
4908  outstream << "</Steps>" << Qt::endl;
4909 
4910  outstream << "</Job>" << Qt::endl;
4911  }
4912 
4913  outstream << "<SchedulerAlgorithm value='" << static_cast<int>(getAlgorithm()) << "'/>" << Qt::endl;
4914  outstream << "<ErrorHandlingStrategy value='" << getErrorHandlingStrategy() << "'>" << Qt::endl;
4915  if (errorHandlingRescheduleErrorsCB->isChecked())
4916  outstream << "<RescheduleErrors />" << Qt::endl;
4917  outstream << "<delay>" << errorHandlingDelaySB->value() << "</delay>" << Qt::endl;
4918  outstream << "</ErrorHandlingStrategy>" << Qt::endl;
4919 
4920  outstream << "<StartupProcedure>" << Qt::endl;
4921  if (startupScript->text().isEmpty() == false)
4922  outstream << "<Procedure value='" << startupScript->text() << "'>StartupScript</Procedure>" << Qt::endl;
4923  if (unparkDomeCheck->isChecked())
4924  outstream << "<Procedure>UnparkDome</Procedure>" << Qt::endl;
4925  if (unparkMountCheck->isChecked())
4926  outstream << "<Procedure>UnparkMount</Procedure>" << Qt::endl;
4927  if (uncapCheck->isChecked())
4928  outstream << "<Procedure>UnparkCap</Procedure>" << Qt::endl;
4929  outstream << "</StartupProcedure>" << Qt::endl;
4930 
4931  outstream << "<ShutdownProcedure>" << Qt::endl;
4932  if (warmCCDCheck->isChecked())
4933  outstream << "<Procedure>WarmCCD</Procedure>" << Qt::endl;
4934  if (capCheck->isChecked())
4935  outstream << "<Procedure>ParkCap</Procedure>" << Qt::endl;
4936  if (parkMountCheck->isChecked())
4937  outstream << "<Procedure>ParkMount</Procedure>" << Qt::endl;
4938  if (parkDomeCheck->isChecked())
4939  outstream << "<Procedure>ParkDome</Procedure>" << Qt::endl;
4940  if (shutdownScript->text().isEmpty() == false)
4941  outstream << "<Procedure value='" << shutdownScript->text() << "'>ShutdownScript</Procedure>" << Qt::endl;
4942  outstream << "</ShutdownProcedure>" << Qt::endl;
4943 
4944  outstream << "</SchedulerList>" << Qt::endl;
4945 
4946  appendLogText(i18n("Scheduler list saved to %1", fileURL.toLocalFile()));
4947  file.close();
4948  mDirty = false;
4949  return true;
4950 }
4951 
4953 {
4954  Q_ASSERT_X(nullptr != currentJob, __FUNCTION__, "Job starting slewing must be valid");
4955 
4956  // If the mount was parked by a pause or the end-user, unpark
4957  if (isMountParked())
4958  {
4959  parkWaitState = PARKWAIT_UNPARK;
4960  return;
4961  }
4962 
4963  if (Options::resetMountModelBeforeJob())
4964  {
4965  TEST_PRINT(stderr, "sch%d @@@dbus(%s): %s\n", __LINE__, "mountInterface:call", "resetModel");
4966  mountInterface->call(QDBus::AutoDetect, "resetModel");
4967  }
4968 
4969  SkyPoint target = currentJob->getTargetCoords();
4970  QList<QVariant> telescopeSlew;
4971  telescopeSlew.append(target.ra().Hours());
4972  telescopeSlew.append(target.dec().Degrees());
4973 
4974  TEST_PRINT(stderr, "sch%d @@@dbus(%s): %s%s,%s\n", __LINE__, "mountInterface:callWithArgs", "slew: ",
4975  target.ra().toHMSString().toLatin1().data(), target.dec().toDMSString().toLatin1().data());
4976  QDBusReply<bool> const slewModeReply = mountInterface->callWithArgumentList(QDBus::AutoDetect, "slew", telescopeSlew);
4977  TEST_PRINT(stderr, " @@@dbus received %s\n", slewModeReply.error().type() == QDBusError::NoError ? "no error" : "error");
4978 
4979  if (slewModeReply.error().type() != QDBusError::NoError)
4980  {
4981  qCCritical(KSTARS_EKOS_SCHEDULER) << QString("Warning: job '%1' slew request received DBUS error: %2").arg(
4982  currentJob->getName(), QDBusError::errorString(slewModeReply.error().type()));
4983  if (!manageConnectionLoss())
4984  currentJob->setState(SchedulerJob::JOB_ERROR);
4985  }
4986  else
4987  {
4988  currentJob->setStage(SchedulerJob::STAGE_SLEWING);
4989  appendLogText(i18n("Job '%1' is slewing to target.", currentJob->getName()));
4990  }
4991 }
4992 
4994 {
4995  Q_ASSERT_X(nullptr != currentJob, __FUNCTION__, "Job starting focusing must be valid");
4996 
4997  // 2017-09-30 Jasem: We're skipping post align focusing now as it can be performed
4998  // when first focus request is made in capture module
4999  if (currentJob->getStage() == SchedulerJob::STAGE_RESLEWING_COMPLETE ||
5000  currentJob->getStage() == SchedulerJob::STAGE_POSTALIGN_FOCUSING)
5001  {
5002  // Clear the HFR limit value set in the capture module
5003  TEST_PRINT(stderr, "sch%d @@@dbus(%s): sending %s\n", __LINE__, "captureInterface", "clearAutoFocusHFR");
5004  captureInterface->call(QDBus::AutoDetect, "clearAutoFocusHFR");
5005  // Reset Focus frame so that next frame take a full-resolution capture first.
5006  TEST_PRINT(stderr, "sch%d @@@dbus(%s): sending %s\n", __LINE__, "focusInterface", "resetFrame");
5007  focusInterface->call(QDBus::AutoDetect, "resetFrame");
5008  currentJob->setStage(SchedulerJob::STAGE_POSTALIGN_FOCUSING_COMPLETE);
5009  getNextAction();
5010  return;
5011  }
5012 
5013  // Check if autofocus is supported
5014  QDBusReply<bool> focusModeReply;
5015  TEST_PRINT(stderr, "sch%d @@@dbus(%s): sending %s\n", __LINE__, "focusInterface", "canAutoFocus");
5016  focusModeReply = focusInterface->call(QDBus::AutoDetect, "canAutoFocus");
5017 
5018  if (focusModeReply.error().type() != QDBusError::NoError)
5019  {
5020  qCCritical(KSTARS_EKOS_SCHEDULER) << QString("Warning: job '%1' canAutoFocus request received DBUS error: %2").arg(
5021  currentJob->getName(), QDBusError::errorString(focusModeReply.error().type()));
5022  if (!manageConnectionLoss())
5023  currentJob->setState(SchedulerJob::JOB_ERROR);
5024  return;
5025  }
5026 
5027  if (focusModeReply.value() == false)
5028  {
5029  appendLogText(i18n("Warning: job '%1' is unable to proceed with autofocus, not supported.", currentJob->getName()));
5030  currentJob->setStepPipeline(
5031  static_cast<SchedulerJob::StepPipeline>(currentJob->getStepPipeline() & ~SchedulerJob::USE_FOCUS));
5032  currentJob->setStage(SchedulerJob::STAGE_FOCUS_COMPLETE);
5033  getNextAction();
5034  return;
5035  }
5036 
5037  // Clear the HFR limit value set in the capture module
5038  TEST_PRINT(stderr, "sch%d @@@dbus(%s): sending %s\n", __LINE__, "captureInterface", "clearAutoFocusHFR");
5039  captureInterface->call(QDBus::AutoDetect, "clearAutoFocusHFR");
5040 
5041  QDBusMessage reply;
5042 
5043  // We always need to reset frame first
5044  TEST_PRINT(stderr, "sch%d @@@dbus(%s): sending %s\n", __LINE__, "focusInterface", "resetFrame");
5045  if ((reply = focusInterface->call(QDBus::AutoDetect, "resetFrame")).type() == QDBusMessage::ErrorMessage)
5046  {
5047  qCCritical(KSTARS_EKOS_SCHEDULER) << QString("Warning: job '%1' resetFrame request received DBUS error: %2").arg(
5048  currentJob->getName(), reply.errorMessage());
5049  if (!manageConnectionLoss())
5050  currentJob->setState(SchedulerJob::JOB_ERROR);
5051  return;
5052  }
5053 
5054 
5055  // If we have a LIGHT filter set, let's set it.
5056  if (!currentJob->getInitialFilter().isEmpty())
5057  {
5058  TEST_PRINT(stderr, "sch%d @@@dbus(%s): sending %s\n", __LINE__, "focusInterface", "focusInterface:setProperty");
5059  focusInterface->setProperty("filter", currentJob->getInitialFilter());
5060  }
5061 
5062  // Set autostar if full field option is false
5063  if (Options::focusUseFullField() == false)
5064  {
5065  QList<QVariant> autoStar;
5066  autoStar.append(true);
5067  TEST_PRINT(stderr, "sch%d @@@dbus(%s): sending %s\n", __LINE__, "focusInterface", "setAutoStarEnabled");
5068  if ((reply = focusInterface->callWithArgumentList(QDBus::AutoDetect, "setAutoStarEnabled", autoStar)).type() ==
5070  {
5071  qCCritical(KSTARS_EKOS_SCHEDULER) << QString("Warning: job '%1' setAutoFocusStar request received DBUS error: %1").arg(
5072  currentJob->getName(), reply.errorMessage());
5073  if (!manageConnectionLoss())
5074  currentJob->setState(SchedulerJob::JOB_ERROR);
5075  return;
5076  }
5077  }
5078 
5079  // Start auto-focus
5080  TEST_PRINT(stderr, "sch%d @@@dbus(%s): sending %s\n", __LINE__, "focusInterface", "start");
5081  if ((reply = focusInterface->call(QDBus::AutoDetect, "start")).type() == QDBusMessage::ErrorMessage)
5082  {
5083  qCCritical(KSTARS_EKOS_SCHEDULER) << QString("Warning: job '%1' startFocus request received DBUS error: %2").arg(
5084  currentJob->getName(), reply.errorMessage());
5085  if (!manageConnectionLoss())
5086  currentJob->setState(SchedulerJob::JOB_ERROR);
5087  return;
5088  }
5089 
5090  /*if (currentJob->getStage() == SchedulerJob::STAGE_RESLEWING_COMPLETE ||
5091  currentJob->getStage() == SchedulerJob::STAGE_POSTALIGN_FOCUSING)
5092  {
5093  currentJob->setStage(SchedulerJob::STAGE_POSTALIGN_FOCUSING);
5094  appendLogText(i18n("Post-alignment focusing for %1 ...", currentJob->getName()));
5095  }
5096  else
5097  {
5098  currentJob->setStage(SchedulerJob::STAGE_FOCUSING);
5099  appendLogText(i18n("Focusing %1 ...", currentJob->getName()));
5100  }*/
5101 
5102  currentJob->setStage(SchedulerJob::STAGE_FOCUSING);
5103  appendLogText(i18n("Job '%1' is focusing.", currentJob->getName()));
5104  startCurrentOperationTimer();
5105 }
5106 
5107 bool Scheduler::canCountCaptures(const SchedulerJob &job)
5108 {
5109  QList<SequenceJob*> seqjobs;
5110  bool hasAutoFocus = false;
5111  SchedulerJob tempJob = job;
5112  if (loadSequenceQueue(tempJob.getSequenceFile().toLocalFile(), &tempJob, seqjobs, hasAutoFocus, nullptr) == false)
5113  return false;
5114 
5115  for (const SequenceJob *oneSeqJob : seqjobs)
5116  {
5117  if (oneSeqJob->getUploadMode() == ISD::Camera::UPLOAD_LOCAL)
5118  return false;
5119  }
5120  return true;
5121 }
5122 
5123 // FindNextJob (probably misnamed) deals with what to do when jobs end.
5124 // For instance, if they complete their capture sequence, they may
5125 // (a) be done, (b) be part of a repeat N times, or (c) be part of a loop forever.
5126 // Similarly, if jobs are aborted they may (a) restart right away, (b) restart after a delay, (c) be ended.
5128 {
5129  if (state == SCHEDULER_PAUSED)
5130  {
5131  // everything finished, we can pause
5132  setPaused();
5133  return;
5134  }
5135 
5136  Q_ASSERT_X(currentJob->getState() == SchedulerJob::JOB_ERROR ||
5137  currentJob->getState() == SchedulerJob::JOB_ABORTED ||
5138  currentJob->getState() == SchedulerJob::JOB_COMPLETE ||
5139  currentJob->getState() == SchedulerJob::JOB_IDLE,
5140  __FUNCTION__, "Finding next job requires current to be in error, aborted, idle or complete");
5141 
5142  // Reset failed count
5143  alignFailureCount = guideFailureCount = focusFailureCount = captureFailureCount = 0;
5144 
5145  if (currentJob->getState() == SchedulerJob::JOB_ERROR || currentJob->getState() == SchedulerJob::JOB_ABORTED)
5146  {
5147  emit jobEnded(currentJob->getName(), currentJob->getStopReason());
5148  captureBatch = 0;
5149  // Stop Guiding if it was used
5150  stopGuiding();
5151 
5152  if (currentJob->getState() == SchedulerJob::JOB_ERROR)
5153  appendLogText(i18n("Job '%1' is terminated due to errors.", currentJob->getName()));
5154  else
5155  appendLogText(i18n("Job '%1' is aborted.", currentJob->getName()));
5156 
5157  // Always reset job stage
5158  currentJob->setStage(SchedulerJob::STAGE_IDLE);
5159 
5160  // restart aborted jobs immediately, if error handling strategy is set to "restart immediately"
5161  if (errorHandlingRestartImmediatelyButton->isChecked() &&
5162  (currentJob->getState() == SchedulerJob::JOB_ABORTED ||
5163  (currentJob->getState() == SchedulerJob::JOB_ERROR && errorHandlingRescheduleErrorsCB->isChecked())))
5164  {
5165  // reset the state so that it will be restarted
5166  currentJob->setState(SchedulerJob::JOB_SCHEDULED);
5167 
5168  appendLogText(i18n("Waiting %1 seconds to restart job '%2'.", errorHandlingDelaySB->value(), currentJob->getName()));
5169 
5170  // wait the given delay until the jobs will be evaluated again
5171  TEST_PRINT(stderr, "%d Setting %s\n", __LINE__, timerStr(RUN_WAKEUP).toLatin1().data());
5172  setupNextIteration(RUN_WAKEUP, std::lround((errorHandlingDelaySB->value() * 1000) /
5173  KStarsData::Instance()->clock()->scale()));
5174  sleepLabel->setToolTip(i18n("Scheduler waits for a retry."));
5175  sleepLabel->show();
5176  return;
5177  }
5178 
5179  // otherwise start re-evaluation
5180  setCurrentJob(nullptr);
5181  TEST_PRINT(stderr, "%d Setting %s\n", __LINE__, timerStr(RUN_SCHEDULER).toLatin1().data());
5182  setupNextIteration(RUN_SCHEDULER);
5183  }
5184  else if (currentJob->getState() == SchedulerJob::JOB_IDLE)
5185  {
5186  emit jobEnded(currentJob->getName(), currentJob->getStopReason());
5187 
5188  // job constraints no longer valid, start re-evaluation
5189  setCurrentJob(nullptr);
5190  TEST_PRINT(stderr, "%d Setting %s\n", __LINE__, timerStr(RUN_SCHEDULER).toLatin1().data());
5191  setupNextIteration(RUN_SCHEDULER);
5192  }
5193  // Job is complete, so check completion criteria to optimize processing
5194  // In any case, we're done whether the job completed successfully or not.
5195  else if (currentJob->getCompletionCondition() == SchedulerJob::FINISH_SEQUENCE)
5196  {
5197  emit jobEnded(currentJob->getName(), currentJob->getStopReason());
5198 
5199  /* If we remember job progress, mark the job idle as well as all its duplicates for re-evaluation */
5200  if (Options::rememberJobProgress())
5201  {
5202  foreach(SchedulerJob *a_job, jobs)
5203  if (a_job == currentJob || a_job->isDuplicateOf(currentJob))
5204  a_job->setState(SchedulerJob::JOB_IDLE);
5205  }
5206 
5207  captureBatch = 0;
5208  // Stop Guiding if it was used
5209  stopGuiding();
5210 
5211  appendLogText(i18n("Job '%1' is complete.", currentJob->getName()));
5212 
5213  // Always reset job stage
5214  currentJob->setStage(SchedulerJob::STAGE_IDLE);
5215 
5216  // If saving remotely, then can't tell later that the job has been completed.
5217  // Set it complete now.
5218  if (!canCountCaptures(*currentJob))
5219  currentJob->setState(SchedulerJob::JOB_COMPLETE);
5220 
5221  setCurrentJob(nullptr);
5222  TEST_PRINT(stderr, "%d Setting %s\n", __LINE__, timerStr(RUN_SCHEDULER).toLatin1().data());
5223  setupNextIteration(RUN_SCHEDULER);
5224  }
5225  else if (currentJob->getCompletionCondition() == SchedulerJob::FINISH_REPEAT)
5226  {
5227  /* If the job is about to repeat, decrease its repeat count and reset its start time */
5228  if (0 < currentJob->getRepeatsRemaining())
5229  {
5230  currentJob->setRepeatsRemaining(currentJob->getRepeatsRemaining() - 1);
5231  currentJob->setStartupTime(QDateTime());
5232  }
5233 
5234  /* Mark the job idle as well as all its duplicates for re-evaluation */
5235  foreach(SchedulerJob *a_job, jobs)
5236  if (a_job == currentJob || a_job->isDuplicateOf(currentJob))
5237  a_job->setState(SchedulerJob::JOB_IDLE);
5238 
5239  /* Re-evaluate all jobs, without selecting a new job */
5240  evaluateJobs(true);
5241 
5242  /* If current job is actually complete because of previous duplicates, prepare for next job */
5243  if (currentJob == nullptr || currentJob->getRepeatsRemaining() == 0)
5244  {
5246 
5247  if (currentJob != nullptr)
5248  {
5249  emit jobEnded(currentJob->getName(), currentJob->getStopReason());
5250  appendLogText(i18np("Job '%1' is complete after #%2 batch.",
5251  "Job '%1' is complete after #%2 batches.",
5252  currentJob->getName(), currentJob->getRepeatsRequired()));
5253  if (!canCountCaptures(*currentJob))
5254  currentJob->setState(SchedulerJob::JOB_COMPLETE);
5255  setCurrentJob(nullptr);
5256  }
5257  TEST_PRINT(stderr, "%d Setting %s\n", __LINE__, timerStr(RUN_SCHEDULER).toLatin1().data());
5258  setupNextIteration(RUN_SCHEDULER);
5259  }
5260  /* If job requires more work, continue current observation */
5261  else
5262  {
5263  /* FIXME: raise priority to allow other jobs to schedule in-between */
5264  if (executeJob(currentJob) == false)
5265  return;
5266 
5267  /* JM 2020-08-23: If user opts to force realign instead of for each job then we force this FIRST */
5268  if (currentJob->getStepPipeline() & SchedulerJob::USE_ALIGN && Options::forceAlignmentBeforeJob())
5269  {
5270  stopGuiding();
5271  currentJob->setStage(SchedulerJob::STAGE_ALIGNING);
5272  startAstrometry();
5273  }
5274  /* If we are guiding, continue capturing */
5275  else if ( (currentJob->getStepPipeline() & SchedulerJob::USE_GUIDE) )
5276  {
5277  currentJob->setStage(SchedulerJob::STAGE_CAPTURING);
5278  startCapture();
5279  }
5280  /* If we are not guiding, but using alignment, realign */
5281  else if (currentJob->getStepPipeline() & SchedulerJob::USE_ALIGN)
5282  {
5283  currentJob->setStage(SchedulerJob::STAGE_ALIGNING);
5284  startAstrometry();
5285  }
5286  /* Else if we are neither guiding nor using alignment, slew back to target */
5287  else if (currentJob->getStepPipeline() & SchedulerJob::USE_TRACK)
5288  {
5289  currentJob->setStage(SchedulerJob::STAGE_SLEWING);
5290  startSlew();
5291  }
5292  /* Else just start capturing */
5293  else
5294  {
5295  currentJob->setStage(SchedulerJob::STAGE_CAPTURING);
5296  startCapture();
5297  }
5298 
5299  appendLogText(i18np("Job '%1' is repeating, #%2 batch remaining.",
5300  "Job '%1' is repeating, #%2 batches remaining.",
5301  currentJob->getName(), currentJob->getRepeatsRemaining()));
5302  /* currentJob remains the same */
5303  TEST_PRINT(stderr, "%d Setting %s\n", __LINE__, timerStr(RUN_JOBCHECK).toLatin1().data());
5304  setupNextIteration(RUN_JOBCHECK);
5305  }
5306  }
5307  else if (currentJob->getCompletionCondition() == SchedulerJob::FINISH_LOOP)
5308  {
5309  if (executeJob(currentJob) == false)
5310  return;
5311 
5312  if (currentJob->getStepPipeline() & SchedulerJob::USE_ALIGN && Options::forceAlignmentBeforeJob())
5313  {
5314  stopGuiding();
5315  currentJob->setStage(SchedulerJob::STAGE_ALIGNING);
5316  startAstrometry();
5317  }
5318  else
5319  {
5320  currentJob->setStage(SchedulerJob::STAGE_CAPTURING);
5321  startCapture();
5322  }
5323 
5324  captureBatch++;
5325 
5326  appendLogText(i18n("Job '%1' is repeating, looping indefinitely.", currentJob->getName()));
5327  /* currentJob remains the same */
5328  TEST_PRINT(stderr, "%d Setting %s\n", __LINE__, timerStr(RUN_JOBCHECK).toLatin1().data());
5329  setupNextIteration(RUN_JOBCHECK);
5330  }
5331  else if (currentJob->getCompletionCondition() == SchedulerJob::FINISH_AT)
5332  {
5333  if (getLocalTime().secsTo(currentJob->getCompletionTime()) <= 0)
5334  {
5335  emit jobEnded(currentJob->getName(), currentJob->getStopReason());
5336 
5337  /* Mark the job idle as well as all its duplicates for re-evaluation */
5338  foreach(SchedulerJob *a_job, jobs)
5339  if (a_job == currentJob || a_job->isDuplicateOf(currentJob))
5340  a_job->setState(SchedulerJob::JOB_IDLE);
5342 
5343  captureBatch = 0;
5344 
5345  appendLogText(i18np("Job '%1' stopping, reached completion time with #%2 batch done.",
5346  "Job '%1' stopping, reached completion time with #%2 batches done.",
5347  currentJob->getName(), captureBatch + 1));
5348 
5349  // Always reset job stage
5350  currentJob->setStage(SchedulerJob::STAGE_IDLE);
5351 
5352  setCurrentJob(nullptr);
5353  TEST_PRINT(stderr, "%d Setting %s\n", __LINE__, timerStr(RUN_SCHEDULER).toLatin1().data());
5354  setupNextIteration(RUN_SCHEDULER);
5355  }
5356  else
5357  {
5358  if (executeJob(currentJob) == false)
5359  return;
5360 
5361  if (currentJob->getStepPipeline() & SchedulerJob::USE_ALIGN && Options::forceAlignmentBeforeJob())
5362  {
5363  stopGuiding();
5364  currentJob->setStage(SchedulerJob::STAGE_ALIGNING);
5365  startAstrometry();
5366  }
5367  else
5368  {
5369  currentJob->setStage(SchedulerJob::STAGE_CAPTURING);
5370  startCapture();
5371  }
5372 
5373  captureBatch++;
5374 
5375  appendLogText(i18np("Job '%1' completed #%2 batch before completion time, restarted.",
5376  "Job '%1' completed #%2 batches before completion time, restarted.",
5377  currentJob->getName(), captureBatch));
5378  /* currentJob remains the same */
5379  TEST_PRINT(stderr, "%d Setting %s\n", __LINE__, timerStr(RUN_JOBCHECK).toLatin1().data());
5380  setupNextIteration(RUN_JOBCHECK);
5381  }
5382  }
5383  else
5384  {
5385  /* Unexpected situation, mitigate by resetting the job and restarting the scheduler timer */
5386  qCDebug(KSTARS_EKOS_SCHEDULER) << "BUGBUG! Job '" << currentJob->getName() << "' timer elapsed, but no action to be taken.";
5387 
5388  // Always reset job stage
5389  currentJob->setStage(SchedulerJob::STAGE_IDLE);
5390 
5391  setCurrentJob(nullptr);
5392  TEST_PRINT(stderr, "%d Setting %s\n", __LINE__, timerStr(RUN_SCHEDULER).toLatin1().data());
5393  setupNextIteration(RUN_SCHEDULER);
5394  }
5395 }
5396 
5398 {
5399  Q_ASSERT_X(nullptr != currentJob, __FUNCTION__, "Job starting aligning must be valid");
5400 
5401  QDBusMessage reply;
5402  setSolverAction(Align::GOTO_SLEW);
5403 
5404  // Always turn update coords on
5405  //QVariant arg(true);
5406  //alignInterface->call(QDBus::AutoDetect, "setUpdateCoords", arg);
5407 
5408  // If FITS file is specified, then we use load and slew
5409  if (currentJob->getFITSFile().isEmpty() == false)
5410  {
5411  QList<QVariant> solveArgs;
5412  solveArgs.append(currentJob->getFITSFile().toString(QUrl::PreferLocalFile));
5413 
5414  TEST_PRINT(stderr, "sch%d @@@dbus(%s): sending %s\n", __LINE__, "alignInterface", "loadAndSlew");
5415  if ((reply = alignInterface->callWithArgumentList(QDBus::AutoDetect, "loadAndSlew", solveArgs)).type() ==
5417  {
5418  qCCritical(KSTARS_EKOS_SCHEDULER) << QString("Warning: job '%1' loadAndSlew request received DBUS error: %2").arg(
5419  currentJob->getName(), reply.errorMessage());
5420  if (!manageConnectionLoss())
5421  currentJob->setState(SchedulerJob::JOB_ERROR);
5422  return;
5423  }
5424 
5425  loadAndSlewProgress = true;
5426  appendLogText(i18n("Job '%1' is plate solving %2.", currentJob->getName(), currentJob->getFITSFile().fileName()));
5427  }
5428  else
5429  {
5430  // JM 2020.08.20: Send J2000 TargetCoords to Align module so that we always resort back to the
5431  // target original targets even if we drifted away due to any reason like guiding calibration failures.
5432  const SkyPoint targetCoords = currentJob->getTargetCoords();
5433  QList<QVariant> targetArgs, rotationArgs;
5434  targetArgs << targetCoords.ra0().Hours() << targetCoords.dec0().Degrees();
5435  rotationArgs << currentJob->getPositionAngle();
5436 
5437  TEST_PRINT(stderr, "sch%d @@@dbus(%s): sending %s\n", __LINE__, "alignInterface", "setTargetCoords");
5438  if ((reply = alignInterface->callWithArgumentList(QDBus::AutoDetect, "setTargetCoords",
5439  targetArgs)).type() == QDBusMessage::ErrorMessage)
5440  {
5441  qCCritical(KSTARS_EKOS_SCHEDULER) << QString("Warning: job '%1' setTargetCoords request received DBUS error: %2").arg(
5442  currentJob->getName(), reply.errorMessage());
5443  if (!manageConnectionLoss())
5444  currentJob->setState(SchedulerJob::JOB_ERROR);
5445  return;
5446  }
5447 
5448  // Only send if it has valid value.
5449  if (currentJob->getPositionAngle() >= -180)
5450  {
5451  TEST_PRINT(stderr, "sch%d @@@dbus(%s): sending %s\n", __LINE__, "alignInterface", "setTargetPositionAngle");
5452  if ((reply = alignInterface->callWithArgumentList(QDBus::AutoDetect, "setTargetPositionAngle",
5453  rotationArgs)).type() == QDBusMessage::ErrorMessage)
5454  {
5455  qCCritical(KSTARS_EKOS_SCHEDULER) <<
5456  QString("Warning: job '%1' setTargetPositionAngle request received DBUS error: %2").arg(
5457  currentJob->getName(), reply.errorMessage());
5458  if (!manageConnectionLoss())
5459  currentJob->setState(SchedulerJob::JOB_ERROR);
5460  return;
5461  }
5462  }
5463 
5464  TEST_PRINT(stderr, "sch%d @@@dbus(%s): sending %s\n", __LINE__, "alignInterface", "captureAndSolve");
5465  if ((reply = alignInterface->call(QDBus::AutoDetect, "captureAndSolve")).type() == QDBusMessage::ErrorMessage)
5466  {
5467  qCCritical(KSTARS_EKOS_SCHEDULER) << QString("Warning: job '%1' captureAndSolve request received DBUS error: %2").arg(
5468  currentJob->getName(), reply.errorMessage());
5469  if (!manageConnectionLoss())
5470  currentJob->setState(SchedulerJob::JOB_ERROR);
5471  return;
5472  }
5473 
5474  appendLogText(i18n("Job '%1' is capturing and plate solving.", currentJob->getName()));
5475  }
5476 
5477  /* FIXME: not supposed to modify the job */
5478  currentJob->setStage(SchedulerJob::STAGE_ALIGNING);
5479  startCurrentOperationTimer();
5480 }
5481 
5482 void Scheduler::startGuiding(bool resetCalibration)
5483 {
5484  Q_ASSERT_X(nullptr != currentJob, __FUNCTION__, "Job starting guiding must be valid");
5485 
5486  // avoid starting the guider twice
5487  if (resetCalibration == false && getGuidingStatus() == GUIDE_GUIDING)
5488  {
5489  appendLogText(i18n("Guiding already running for %1 ...", currentJob->getName()));
5490  currentJob->setStage(SchedulerJob::STAGE_GUIDING);
5491  startCurrentOperationTimer();
5492  return;
5493  }
5494 
5495  // Connect Guider
5496  TEST_PRINT(stderr, "sch%d @@@dbus(%s): %s\n", __LINE__, "guideInterface:call", "connectGuider");
5497  guideInterface->call(QDBus::AutoDetect, "connectGuider");
5498 
5499  // Set Auto Star to true
5500  QVariant arg(true);
5501  TEST_PRINT(stderr, "sch%d @@@dbus(%s): %s\n", __LINE__, "guideInterface:call", "setCalibrationAutoStar");
5502  guideInterface->call(QDBus::AutoDetect, "setCalibrationAutoStar", arg);
5503 
5504  // Only reset calibration on trouble
5505  // and if we are allowed to reset calibration (true by default)
5506  if (resetCalibration && Options::resetGuideCalibration())
5507  {
5508  TEST_PRINT(stderr, "sch%d @@@dbus(%s): %s\n", __LINE__, "guideInterface:call", "clearCalibration");
5509  guideInterface->call(QDBus::AutoDetect, "clearCalibration");
5510  }
5511 
5512  TEST_PRINT(stderr, "sch%d @@@dbus(%s): %s\n", __LINE__, "guideInterface:call", "guide");
5513  guideInterface->call(QDBus::AutoDetect, "guide");
5514 
5515  currentJob->setStage(SchedulerJob::STAGE_GUIDING);
5516 
5517  appendLogText(i18n("Starting guiding procedure for %1 ...", currentJob->getName()));
5518 
5519  startCurrentOperationTimer();
5520 }
5521 
5522 void Scheduler::startCapture(bool restart)
5523 {
5524  Q_ASSERT_X(nullptr != currentJob, __FUNCTION__, "Job starting capturing must be valid");
5525 
5526  // ensure that guiding is running before we start capturing
5527  if (currentJob->getStepPipeline() & SchedulerJob::USE_GUIDE && getGuidingStatus() != GUIDE_GUIDING)
5528  {
5529  // guiding should run, but it doesn't. So start guiding first
5530  currentJob->setStage(SchedulerJob::STAGE_GUIDING);
5531  startGuiding();
5532  return;
5533  }
5534 
5535  QString sanitized = currentJob->getName();
5536  sanitized = sanitized.replace( QRegularExpression("\\s|/|\\(|\\)|:|\\*|~|\"" ), "_" )
5537  // Remove any two or more __
5538  .replace( QRegularExpression("_{2,}"), "_")
5539  // Remove any _ at the end
5540  .replace( QRegularExpression("_$"), "");
5541  TEST_PRINT(stderr, "sch%d @@@dbus(%s): %s%s\n", __LINE__, "captureInterface:setProperty", "targetName=",
5542  sanitized.toLatin1().data());
5543  captureInterface->setProperty("targetName", sanitized);
5544 
5545  QString url = currentJob->getSequenceFile().toLocalFile();
5546 
5547  if (restart == false)
5548  {
5549  QList<QVariant> dbusargs;
5550  dbusargs.append(url);
5551  TEST_PRINT(stderr, "sch%d @@@dbus(%s): %s\n", __LINE__, "captureInterface:callWithArgs", "loadSequenceQueue");
5552  QDBusReply<bool> const captureReply = captureInterface->callWithArgumentList(QDBus::AutoDetect, "loadSequenceQueue",
5553  dbusargs);
5554  if (captureReply.error().type() != QDBusError::NoError)
5555  {
5556  qCCritical(KSTARS_EKOS_SCHEDULER) <<
5557  QString("Warning: job '%1' loadSequenceQueue request received DBUS error: %1").arg(currentJob->getName()).arg(
5558  captureReply.error().message());
5559  if (!manageConnectionLoss())
5560  currentJob->setState(SchedulerJob::JOB_ERROR);
5561  return;
5562  }
5563  // Check if loading sequence fails for whatever reason
5564  else if (captureReply.value() == false)
5565  {
5566  qCCritical(KSTARS_EKOS_SCHEDULER) <<
5567  QString("Warning: job '%1' loadSequenceQueue request failed").arg(currentJob->getName());
5568  if (!manageConnectionLoss())
5569  currentJob->setState(SchedulerJob::JOB_ERROR);
5570  return;
5571  }
5572  }
5573 
5574 
5575  SchedulerJob::CapturedFramesMap fMap = currentJob->getCapturedFramesMap();
5576 
5577  for (auto &e : fMap.keys())
5578  {
5579  QList<QVariant> dbusargs;
5580  QDBusMessage reply;
5581 
5582  dbusargs.append(e);
5583  dbusargs.append(fMap.value(e));
5584  TEST_PRINT(stderr, "sch%d @@@dbus(%s): %s\n", __LINE__, "captureInterface:callWithArgs", "setCapturedFramesMap");
5585  if ((reply = captureInterface->callWithArgumentList(QDBus::AutoDetect, "setCapturedFramesMap", dbusargs)).type() ==
5587  {
5588  qCCritical(KSTARS_EKOS_SCHEDULER) <<
5589  QString("Warning: job '%1' setCapturedFramesCount request received DBUS error: %1").arg(currentJob->getName()).arg(
5590  reply.errorMessage());
5591  if (!manageConnectionLoss())
5592  currentJob->setState(SchedulerJob::JOB_ERROR);
5593  return;
5594  }
5595  }
5596 
5597  // Start capture process
5598  TEST_PRINT(stderr, "sch%d @@@dbus(%s): %s\n", __LINE__, "captureInterface:call", "start");
5599  captureInterface->call(QDBus::AutoDetect, "start");
5600 
5601  currentJob->setStage(SchedulerJob::STAGE_CAPTURING);
5602 
5603  KNotification::event(QLatin1String("EkosScheduledImagingStart"),
5604  i18n("Ekos job (%1) - Capture started", currentJob->getName()));
5605 
5606  if (captureBatch > 0)
5607  appendLogText(i18n("Job '%1' capture is in progress (batch #%2)...", currentJob->getName(), captureBatch + 1));
5608  else
5609  appendLogText(i18n("Job '%1' capture is in progress...", currentJob->getName()));
5610 
5611  startCurrentOperationTimer();
5612 }
5613 
5615 {
5616  if (!guideInterface)
5617  return;
5618 
5619  // Tell guider to abort if the current job requires guiding - end-user may enable guiding manually before observation
5620  if (nullptr != currentJob && (currentJob->getStepPipeline() & SchedulerJob::USE_GUIDE))
5621  {
5622  qCInfo(KSTARS_EKOS_SCHEDULER) << QString("Job '%1' is stopping guiding...").arg(currentJob->getName());
5623  TEST_PRINT(stderr, "sch%d @@@dbus(%s): %s\n", __LINE__, "guideInterface:call", "abort");
5624  guideInterface->call(QDBus::AutoDetect, "abort");
5625  guideFailureCount = 0;
5626  }
5627 
5628  // In any case, stop the automatic guider restart
5629  if (isGuidingTimerActive())
5630  cancelGuidingTimer();
5631 }
5632 
5633 void Scheduler::setSolverAction(Align::GotoMode mode)
5634 {
5635  QVariant gotoMode(static_cast<int>(mode));
5636  TEST_PRINT(stderr, "sch%d @@@dbus(%s): %s\n", __LINE__, "alignInterface:call", "setSolverAction");
5637  alignInterface->call(QDBus::AutoDetect, "setSolverAction", gotoMode);
5638 }
5639 
5641 {
5642  qCInfo(KSTARS_EKOS_SCHEDULER) << "Disconnecting INDI...";
5643  indiState = INDI_DISCONNECTING;
5644  TEST_PRINT(stderr, "sch%d @@@dbus(%s): %s\n", __LINE__, "ekosInterface:call", "disconnectDevices");
5645  ekosInterface->call(QDBus::AutoDetect, "disconnectDevices");
5646 }
5647 
5649 {
5650  qCInfo(KSTARS_EKOS_SCHEDULER) << "Stopping Ekos...";
5651  ekosState = EKOS_STOPPING;
5652  ekosConnectFailureCount = 0;
5653  TEST_PRINT(stderr, "sch%d @@@dbus(%s): %s\n", __LINE__, "ekosInterface:call", "stop");
5654  ekosInterface->call(QDBus::AutoDetect, "stop");
5655  m_MountReady = m_CapReady = m_CaptureReady = m_DomeReady = false;
5656 }
5657 
5659 {
5660  mDirty = true;
5661 
5662  if (sender() == startupProcedureButtonGroup || sender() == shutdownProcedureGroup)
5663  return;
5664 
5665  if (0 <= jobUnderEdit && state != SCHEDULER_RUNNING && 0 <= queueTable->currentRow())
5666  {
5667  // Now that jobs are sorted, reset jobs that are later than the edited one for re-evaluation
5668  for (int row = jobUnderEdit; row < jobs.size(); row++)
5669  jobs.at(row)->reset();
5670 
5671  saveJob();
5672  }
5673 
5674  // For object selection, all fields must be filled
5675  bool const nameSelectionOK = !raBox->isEmpty() && !decBox->isEmpty() && !nameEdit->text().isEmpty();
5676 
5677  // For FITS selection, only the name and fits URL should be filled.
5678  bool const fitsSelectionOK = !nameEdit->text().isEmpty() && !fitsURL.isEmpty();
5679 
5680  // Sequence selection is required
5681  bool const seqSelectionOK = !sequenceEdit->text().isEmpty();
5682 
5683  // Finally, adding is allowed upon object/FITS and sequence selection
5684  bool const addingOK = (nameSelectionOK || fitsSelectionOK) && seqSelectionOK;
5685 
5686  addToQueueB->setEnabled(addingOK);
5687  //mosaicB->setEnabled(addingOK);
5688 }
5689 
5690 void Scheduler::updateLightFramesRequired(SchedulerJob *oneJob, const QList<SequenceJob*> &seqjobs,
5691  const SchedulerJob::CapturedFramesMap &framesCount)
5692 {
5693 
5694  bool lightFramesRequired = false;
5695  QMap<QString, uint16_t> expected;
5696  switch (oneJob->getCompletionCondition())
5697  {
5698  case SchedulerJob::FINISH_SEQUENCE:
5699  case SchedulerJob::FINISH_REPEAT:
5700  // Step 1: determine expected frames
5701  calculateExpectedCapturesMap(seqjobs, expected);
5702  // Step 2: compare with already captured frames
5703  for (SequenceJob *oneSeqJob : seqjobs)
5704  {
5705  QString const signature = oneSeqJob->getSignature();
5706  /* If frame is LIGHT, how many do we have left? */
5707  if (oneSeqJob->getFrameType() == FRAME_LIGHT && expected[signature] * oneJob->getRepeatsRequired() > framesCount[signature])
5708  {
5709  lightFramesRequired = true;
5710  // exit the loop, one found is sufficient
5711  break;
5712  }
5713  }
5714  break;
5715  default:
5716  // in all other cases it does not depend on the number of captured frames
5717  lightFramesRequired = true;
5718  }
5719  oneJob->setLightFramesRequired(lightFramesRequired);
5720 }
5721 
5722 uint16_t Scheduler::calculateExpectedCapturesMap(const QList<SequenceJob *> &seqJobs, QMap<QString, uint16_t> &expected)
5723 {
5724  uint16_t capturesPerRepeat = 0;
5725  for (auto &seqJob : seqJobs)
5726  {
5727  capturesPerRepeat += seqJob->getCoreProperty(SequenceJob::SJ_Count).toInt();
5728  QString const signature = seqJob->getSignature();
5729  expected[signature] = static_cast<uint16_t>(seqJob->getCoreProperty(SequenceJob::SJ_Count).toInt()) + (expected.contains(
5730  signature) ? expected[signature] : 0);
5731  }
5732  return capturesPerRepeat;
5733 }
5734 
5735 uint16_t Scheduler::fillCapturedFramesMap(const QMap<QString, uint16_t> &expected,
5736  const SchedulerJob::CapturedFramesMap &capturedFramesCount,
5737  SchedulerJob &schedJob, SchedulerJob::CapturedFramesMap &capture_map)
5738 {
5739  uint16_t totalCompletedCount = 0;
5740 
5741  // Figure out which repeat this is for the key with the least progress.
5742  int minIterationsCompleted = -1, currentIteration = 0;
5743  if (Options::rememberJobProgress())
5744  {
5745  for (const QString &key : expected.keys())
5746  {
5747  const int iterationsCompleted = capturedFramesCount[key] / expected[key];
5748  if (minIterationsCompleted == -1 || iterationsCompleted < minIterationsCompleted)
5749  minIterationsCompleted = iterationsCompleted;
5750  }
5751  if (schedJob.getCompletionCondition() == SchedulerJob::FINISH_REPEAT
5752  && minIterationsCompleted >= schedJob.getRepeatsRequired())
5753  currentIteration = schedJob.getRepeatsRequired() + 1;
5754  else
5755  currentIteration = minIterationsCompleted + 1;
5756  }
5757 
5758  for (const QString &key : expected.keys())
5759  {
5760  if (Options::rememberJobProgress())
5761  {
5762  const int diff = expected[key] * currentIteration - capturedFramesCount[key];
5763 
5764  // captured more than required?
5765  if (diff <= 0)
5766  capture_map[key] = expected[key];
5767  // need more frames than one cycle could capture?
5768  else if (diff >= expected[key])
5769  capture_map[key] = 0;
5770  // else we know that 0 < diff < expected[key]
5771  else
5772  capture_map[key] = expected[key] - diff;
5773  }
5774  else
5775  capture_map[key] = 0;
5776 
5777  // collect all captured frames counts
5778  if (schedJob.getCompletionCondition() == SchedulerJob::FINISH_LOOP)
5779  totalCompletedCount += capturedFramesCount[key];
5780  else
5781  totalCompletedCount += std::min(capturedFramesCount[key],
5782  static_cast<uint16_t>(expected[key] * schedJob.getRepeatsRequired()));
5783  }
5784  return totalCompletedCount;
5785 }
5786 
5787 void Scheduler::updateCompletedJobsCount(bool forced)
5788 {
5789  /* Use a temporary map in order to limit the number of file searches */
5790  SchedulerJob::CapturedFramesMap newFramesCount;
5791 
5792  /* FIXME: Capture storage cache is refreshed too often, feature requires rework. */
5793 
5794  /* Check if one job is idle or requires evaluation - if so, force refresh */
5795  forced |= std::any_of(jobs.begin(), jobs.end(), [](SchedulerJob * oneJob) -> bool
5796  {
5797  SchedulerJob::JOBStatus const state = oneJob->getState();
5798  return state == SchedulerJob::JOB_IDLE || state == SchedulerJob::JOB_EVALUATION;});
5799 
5800  /* If update is forced, clear the frame map */
5801  if (forced)
5802  m_CapturedFramesCount.clear();
5803 
5804  /* Enumerate SchedulerJobs to count captures that are already stored */
5805  for (SchedulerJob *oneJob : jobs)
5806  {
5807  QList<SequenceJob*> seqjobs;
5808  bool hasAutoFocus = false;
5809 
5810  //oneJob->setLightFramesRequired(false);
5811  /* Look into the sequence requirements, bypass if invalid */
5812  if (loadSequenceQueue(oneJob->getSequenceFile().toLocalFile(), oneJob, seqjobs, hasAutoFocus,
5813  this) == false)
5814  {
5815  appendLogText(i18n("Warning: job '%1' has inaccessible sequence '%2', marking invalid.", oneJob->getName(),
5816  oneJob->getSequenceFile().toLocalFile()));
5817  oneJob->setState(SchedulerJob::JOB_INVALID);
5818  continue;
5819  }
5820 
5821  /* Enumerate the SchedulerJob's SequenceJobs to count captures stored for each */
5822  for (SequenceJob *oneSeqJob : seqjobs)
5823  {
5824  /* Only consider captures stored on client (Ekos) side */
5825  /* FIXME: ask the remote for the file count */
5826  if (oneSeqJob->getUploadMode() == ISD::Camera::UPLOAD_LOCAL)
5827  continue;
5828 
5829  /* FIXME: this signature path is incoherent when there is no filter wheel on the setup - bugfix should be elsewhere though */
5830  QString const signature = oneSeqJob->getSignature();
5831 
5832  /* If signature was processed during this run, keep it */
5833  if (newFramesCount.constEnd() != newFramesCount.constFind(signature))
5834  continue;
5835 
5836  /* If signature was processed during an earlier run, use the earlier count */
5837  QMap<QString, uint16_t>::const_iterator const earlierRunIterator = m_CapturedFramesCount.constFind(signature);
5838  if (m_CapturedFramesCount.constEnd() != earlierRunIterator)
5839  {
5840  newFramesCount[signature] = earlierRunIterator.value();
5841  continue;
5842  }
5843 
5844  /* Else recount captures already stored */
5845  newFramesCount[signature] = getCompletedFiles(signature, oneSeqJob->getCoreProperty(SequenceJob::SJ_FullPrefix).toString());
5846  }
5847 
5848  // determine whether we need to continue capturing, depending on captured frames
5849  updateLightFramesRequired(oneJob, seqjobs, newFramesCount);
5850  }
5851 
5852  m_CapturedFramesCount = newFramesCount;
5853 
5854  //if (forced)
5855  {
5856  qCDebug(KSTARS_EKOS_SCHEDULER) << "Frame map summary:";