File indexing completed on 2024-04-28 03:43:44

0001 /*
0002     SPDX-FileCopyrightText: 2015 Jasem Mutlaq <mutlaqja@ikarustech.com>
0003 
0004     DBus calls from GSoC 2015 Ekos Scheduler project:
0005     SPDX-FileCopyrightText: 2015 Daniel Leu <daniel_mihai.leu@cti.pub.ro>
0006 
0007     SPDX-License-Identifier: GPL-2.0-or-later
0008 */
0009 
0010 #include "scheduler.h"
0011 
0012 #include "ekos/scheduler/framingassistantui.h"
0013 #include "ksnotification.h"
0014 #include "ksmessagebox.h"
0015 #include "kstars.h"
0016 #include "kstarsdata.h"
0017 #include "skymap.h"
0018 #include "Options.h"
0019 #include "scheduleradaptor.h"
0020 #include "schedulerjob.h"
0021 #include "schedulerprocess.h"
0022 #include "schedulermodulestate.h"
0023 #include "schedulerutils.h"
0024 #include "skymapcomposite.h"
0025 #include "skycomponents/mosaiccomponent.h"
0026 #include "skyobjects/mosaictiles.h"
0027 #include "auxiliary/QProgressIndicator.h"
0028 #include "dialogs/finddialog.h"
0029 #include "ekos/manager.h"
0030 #include "ekos/capture/sequencejob.h"
0031 #include "ekos/capture/placeholderpath.h"
0032 #include "skyobjects/starobject.h"
0033 #include "greedyscheduler.h"
0034 #include "ekos/auxiliary/solverutils.h"
0035 #include "ekos/auxiliary/stellarsolverprofile.h"
0036 
0037 #include <KConfigDialog>
0038 #include <KActionCollection>
0039 
0040 #include <fitsio.h>
0041 #include <ekos_scheduler_debug.h>
0042 #include <indicom.h>
0043 #include "ekos/capture/sequenceeditor.h"
0044 
0045 // Qt version calming
0046 #include <qtendl.h>
0047 
0048 #define BAD_SCORE                -1000
0049 #define RESTART_GUIDING_DELAY_MS  5000
0050 
0051 #define DEFAULT_MIN_ALTITUDE        15
0052 #define DEFAULT_MIN_MOON_SEPARATION 0
0053 
0054 // This is a temporary debugging printout introduced while gaining experience developing
0055 // the unit tests in test_ekos_scheduler_ops.cpp.
0056 // All these printouts should be eventually removed.
0057 #define TEST_PRINT if (false) fprintf
0058 
0059 namespace
0060 {
0061 
0062 // This needs to match the definition order for the QueueTable in scheduler.ui
0063 enum QueueTableColumns
0064 {
0065     NAME_COLUMN = 0,
0066     STATUS_COLUMN,
0067     CAPTURES_COLUMN,
0068     ALTITUDE_COLUMN,
0069     START_TIME_COLUMN,
0070     END_TIME_COLUMN,
0071 };
0072 }
0073 
0074 namespace Ekos
0075 {
0076 
0077 // Setup the main loop and start.
0078 void Scheduler::start()
0079 {
0080     process()->startScheduler();
0081 }
0082 
0083 
0084 Scheduler::Scheduler()
0085 {
0086     // Use the default path and interface when running the scheduler.
0087     setupScheduler(ekosPathString, ekosInterfaceString);
0088 }
0089 
0090 Scheduler::Scheduler(const QString path, const QString interface,
0091                      const QString &ekosPathStr, const QString &ekosInterfaceStr)
0092 {
0093     // During testing, when mocking ekos, use a special purpose path and interface.
0094     schedulerPathString = path;
0095     kstarsInterfaceString = interface;
0096     setupScheduler(ekosPathStr, ekosInterfaceStr);
0097 }
0098 
0099 void Scheduler::setupScheduler(const QString &ekosPathStr, const QString &ekosInterfaceStr)
0100 {
0101     setupUi(this);
0102 
0103     qRegisterMetaType<Ekos::SchedulerState>("Ekos::SchedulerState");
0104     qDBusRegisterMetaType<Ekos::SchedulerState>();
0105 
0106     m_moduleState.reset(new SchedulerModuleState());
0107     m_process.reset(new SchedulerProcess(moduleState()));
0108 
0109     dirPath = QUrl::fromLocalFile(QDir::homePath());
0110 
0111     // Get current KStars time and set seconds to zero
0112     QDateTime currentDateTime = SchedulerModuleState::getLocalTime();
0113     QTime currentTime         = currentDateTime.time();
0114     currentTime.setHMS(currentTime.hour(), currentTime.minute(), 0);
0115     currentDateTime.setTime(currentTime);
0116 
0117     // Set initial time for startup and completion times
0118     startupTimeEdit->setDateTime(currentDateTime);
0119     schedulerUntilValue->setDateTime(currentDateTime);
0120 
0121     // Set up DBus interfaces
0122     new SchedulerAdaptor(this);
0123     QDBusConnection::sessionBus().unregisterObject(schedulerPathString);
0124     if (!QDBusConnection::sessionBus().registerObject(schedulerPathString, this))
0125         qCDebug(KSTARS_EKOS_SCHEDULER) << QString("Scheduler failed to register with dbus");
0126     process()->setEkosInterface(new QDBusInterface(kstarsInterfaceString, ekosPathStr, ekosInterfaceStr,
0127                                 QDBusConnection::sessionBus(), this));
0128 
0129     process()->setIndiInterface(new QDBusInterface(kstarsInterfaceString, INDIPathString, INDIInterfaceString,
0130                                 QDBusConnection::sessionBus(), this));
0131 
0132     // Example of connecting DBus signals
0133     //connect(ekosInterface, SIGNAL(indiStatusChanged(Ekos::CommunicationStatus)), this, SLOT(setINDICommunicationStatus(Ekos::CommunicationStatus)));
0134     //connect(ekosInterface, SIGNAL(ekosStatusChanged(Ekos::CommunicationStatus)), this, SLOT(setEkosCommunicationStatus(Ekos::CommunicationStatus)));
0135     //connect(ekosInterface, SIGNAL(newModule(QString)), this, SLOT(registerNewModule(QString)));
0136     QDBusConnection::sessionBus().connect(kstarsInterfaceString, ekosPathStr, ekosInterfaceStr, "newModule", this,
0137                                           SLOT(registerNewModule(QString)));
0138     QDBusConnection::sessionBus().connect(kstarsInterfaceString, ekosPathStr, ekosInterfaceStr, "newDevice", this,
0139                                           SLOT(registerNewDevice(QString, int)));
0140     QDBusConnection::sessionBus().connect(kstarsInterfaceString, ekosPathStr, ekosInterfaceStr, "indiStatusChanged",
0141                                           this, SLOT(setINDICommunicationStatus(Ekos::CommunicationStatus)));
0142     QDBusConnection::sessionBus().connect(kstarsInterfaceString, ekosPathStr, ekosInterfaceStr, "ekosStatusChanged",
0143                                           this, SLOT(setEkosCommunicationStatus(Ekos::CommunicationStatus)));
0144 
0145     sleepLabel->setPixmap(
0146         QIcon::fromTheme("chronometer").pixmap(QSize(32, 32)));
0147     changeSleepLabel("", false);
0148 
0149     pi = new QProgressIndicator(this);
0150     bottomLayout->addWidget(pi, 0);
0151 
0152     geo = KStarsData::Instance()->geo();
0153 
0154     //RA box should be HMS-style
0155     raBox->setUnits(dmsBox::HOURS);
0156 
0157     /* FIXME: Find a way to have multi-line tooltips in the .ui file, then move the widget configuration there - what about i18n? */
0158 
0159     queueTable->setToolTip(
0160         i18n("Job scheduler list.\nClick to select a job in the list.\nDouble click to edit a job with the left-hand fields."));
0161     QTableWidgetItem *statusHeader       = queueTable->horizontalHeaderItem(SCHEDCOL_STATUS);
0162     QTableWidgetItem *altitudeHeader     = queueTable->horizontalHeaderItem(SCHEDCOL_ALTITUDE);
0163     QTableWidgetItem *startupHeader      = queueTable->horizontalHeaderItem(SCHEDCOL_STARTTIME);
0164     QTableWidgetItem *completionHeader   = queueTable->horizontalHeaderItem(SCHEDCOL_ENDTIME);
0165     QTableWidgetItem *captureCountHeader = queueTable->horizontalHeaderItem(SCHEDCOL_CAPTURES);
0166 
0167     if (statusHeader != nullptr)
0168         statusHeader->setToolTip(i18n("Current status of the job, managed by the Scheduler.\n"
0169                                       "If invalid, the Scheduler was not able to find a proper observation time for the target.\n"
0170                                       "If aborted, the Scheduler missed the scheduled time or encountered transitory issues and will reschedule the job.\n"
0171                                       "If complete, the Scheduler verified that all sequence captures requested were stored, including repeats."));
0172     if (altitudeHeader != nullptr)
0173         altitudeHeader->setToolTip(i18n("Current altitude of the target of the job.\n"
0174                                         "A rising target is indicated with an arrow going up.\n"
0175                                         "A setting target is indicated with an arrow going down."));
0176     if (startupHeader != nullptr)
0177         startupHeader->setToolTip(i18n("Startup time of the job, as estimated by the Scheduler.\n"
0178                                        "The altitude at startup, if available, is displayed too.\n"
0179                                        "Fixed time from user or culmination time is marked with a chronometer symbol."));
0180     if (completionHeader != nullptr)
0181         completionHeader->setToolTip(i18n("Completion time for the job, as estimated by the Scheduler.\n"
0182                                           "You may specify a fixed time to limit duration of looping jobs. "
0183                                           "A warning symbol indicates the altitude at completion may cause the job to abort before completion.\n"));
0184     if (captureCountHeader != nullptr)
0185         captureCountHeader->setToolTip(i18n("Count of captures stored for the job, based on its sequence job.\n"
0186                                             "This is a summary, additional specific frame types may be required to complete the job."));
0187 
0188     /* Set first button mode to add observation job from left-hand fields */
0189     setJobAddApply(true);
0190 
0191     removeFromQueueB->setIcon(QIcon::fromTheme("list-remove"));
0192     removeFromQueueB->setToolTip(
0193         i18n("Remove selected job from the observation list.\nJob properties are copied in the edition fields before removal."));
0194     removeFromQueueB->setAttribute(Qt::WA_LayoutUsesWidgetRect);
0195 
0196     queueUpB->setIcon(QIcon::fromTheme("go-up"));
0197     queueUpB->setToolTip(i18n("Move selected job one line up in the list.\n"));
0198     queueUpB->setAttribute(Qt::WA_LayoutUsesWidgetRect);
0199     queueDownB->setIcon(QIcon::fromTheme("go-down"));
0200     queueDownB->setToolTip(i18n("Move selected job one line down in the list.\n"));
0201     queueDownB->setAttribute(Qt::WA_LayoutUsesWidgetRect);
0202 
0203     evaluateOnlyB->setIcon(QIcon::fromTheme("system-reboot"));
0204     evaluateOnlyB->setToolTip(i18n("Reset state and force reevaluation of all observation jobs."));
0205     evaluateOnlyB->setAttribute(Qt::WA_LayoutUsesWidgetRect);
0206     sortJobsB->setIcon(QIcon::fromTheme("transform-move-vertical"));
0207     sortJobsB->setToolTip(
0208         i18n("Reset state and sort observation jobs per altitude and movement in sky, using the start time of the first job.\n"
0209              "This action sorts setting targets before rising targets, and may help scheduling when starting your observation.\n"
0210              "Note the algorithm first calculates all altitudes using the same time, then evaluates jobs."));
0211     sortJobsB->setAttribute(Qt::WA_LayoutUsesWidgetRect);
0212     mosaicB->setIcon(QIcon::fromTheme("zoom-draw"));
0213     mosaicB->setAttribute(Qt::WA_LayoutUsesWidgetRect);
0214 
0215     positionAngleSpin->setSpecialValueText("--");
0216 
0217     queueSaveAsB->setIcon(QIcon::fromTheme("document-save-as"));
0218     queueSaveAsB->setAttribute(Qt::WA_LayoutUsesWidgetRect);
0219     queueSaveB->setIcon(QIcon::fromTheme("document-save"));
0220     queueSaveB->setAttribute(Qt::WA_LayoutUsesWidgetRect);
0221     queueLoadB->setIcon(QIcon::fromTheme("document-open"));
0222     queueLoadB->setAttribute(Qt::WA_LayoutUsesWidgetRect);
0223     queueAppendB->setIcon(QIcon::fromTheme("document-import"));
0224     queueAppendB->setAttribute(Qt::WA_LayoutUsesWidgetRect);
0225 
0226     loadSequenceB->setIcon(QIcon::fromTheme("document-open"));
0227     loadSequenceB->setAttribute(Qt::WA_LayoutUsesWidgetRect);
0228     selectStartupScriptB->setIcon(QIcon::fromTheme("document-open"));
0229     selectStartupScriptB->setAttribute(Qt::WA_LayoutUsesWidgetRect);
0230     selectShutdownScriptB->setIcon(
0231         QIcon::fromTheme("document-open"));
0232     selectShutdownScriptB->setAttribute(Qt::WA_LayoutUsesWidgetRect);
0233     selectFITSB->setIcon(QIcon::fromTheme("document-open"));
0234     selectFITSB->setAttribute(Qt::WA_LayoutUsesWidgetRect);
0235 
0236     startupB->setIcon(
0237         QIcon::fromTheme("media-playback-start"));
0238     startupB->setAttribute(Qt::WA_LayoutUsesWidgetRect);
0239     shutdownB->setIcon(
0240         QIcon::fromTheme("media-playback-start"));
0241     shutdownB->setAttribute(Qt::WA_LayoutUsesWidgetRect);
0242 
0243     // 2023-06-27 sterne-jaeger: For simplicity reasons, the repeat option
0244     // for all sequences is only active of we do consider the past
0245     repeatSequenceCB->setEnabled(Options::rememberJobProgress() == false);
0246     executionSequenceLimit->setEnabled(Options::rememberJobProgress() == false);
0247     repeatSequenceCB->setChecked(Options::schedulerRepeatSequences());
0248     executionSequenceLimit->setValue(Options::schedulerExecutionSequencesLimit());
0249 
0250     connect(startupB, &QPushButton::clicked, process().data(), &SchedulerProcess::runStartupProcedure);
0251     connect(shutdownB, &QPushButton::clicked, process().data(), &SchedulerProcess::runShutdownProcedure);
0252 
0253     connect(selectObjectB, &QPushButton::clicked, this, &Scheduler::selectObject);
0254     connect(selectFITSB, &QPushButton::clicked, this, &Scheduler::selectFITS);
0255     connect(loadSequenceB, &QPushButton::clicked, this, &Scheduler::selectSequence);
0256     connect(selectStartupScriptB, &QPushButton::clicked, this, &Scheduler::selectStartupScript);
0257     connect(selectShutdownScriptB, &QPushButton::clicked, this, &Scheduler::selectShutdownScript);
0258 
0259     connect(KStars::Instance()->actionCollection()->action("show_mosaic_panel"), &QAction::triggered, this, [this](bool checked)
0260     {
0261         mosaicB->setDown(checked);
0262     });
0263     connect(mosaicB, &QPushButton::clicked, this, []()
0264     {
0265         KStars::Instance()->actionCollection()->action("show_mosaic_panel")->trigger();
0266     });
0267     connect(addToQueueB, &QPushButton::clicked, [this]()
0268     {
0269         // add job from UI
0270         addJob();
0271     });
0272     connect(removeFromQueueB, &QPushButton::clicked, this, &Scheduler::removeJob);
0273     connect(queueUpB, &QPushButton::clicked, this, &Scheduler::moveJobUp);
0274     connect(queueDownB, &QPushButton::clicked, this, &Scheduler::moveJobDown);
0275     connect(evaluateOnlyB, &QPushButton::clicked, this, &Scheduler::startJobEvaluation);
0276     connect(sortJobsB, &QPushButton::clicked, this, &Scheduler::sortJobsPerAltitude);
0277     connect(queueTable->selectionModel(), &QItemSelectionModel::selectionChanged, this, &Scheduler::queueTableSelectionChanged);
0278     connect(queueTable, &QAbstractItemView::clicked, this, &Scheduler::clickQueueTable);
0279     connect(queueTable, &QAbstractItemView::doubleClicked, this, &Scheduler::loadJob);
0280 
0281 
0282     // These connections are looking for changes in the rows queueTable is displaying.
0283     connect(queueTable->verticalScrollBar(), &QScrollBar::valueChanged, [this]()
0284     {
0285         updateJobTable();
0286     });
0287     connect(queueTable->verticalScrollBar(), &QAbstractSlider::rangeChanged, [this]()
0288     {
0289         updateJobTable();
0290     });
0291 
0292     startB->setIcon(QIcon::fromTheme("media-playback-start"));
0293     startB->setAttribute(Qt::WA_LayoutUsesWidgetRect);
0294     pauseB->setIcon(QIcon::fromTheme("media-playback-pause"));
0295     pauseB->setAttribute(Qt::WA_LayoutUsesWidgetRect);
0296     pauseB->setCheckable(false);
0297 
0298     connect(startB, &QPushButton::clicked, this, &Scheduler::toggleScheduler);
0299     connect(pauseB, &QPushButton::clicked, this, &Scheduler::pause);
0300 
0301     connect(queueSaveAsB, &QPushButton::clicked, this, &Scheduler::saveAs);
0302     connect(queueSaveB, &QPushButton::clicked, this, &Scheduler::save);
0303     connect(queueLoadB, &QPushButton::clicked, this, [&]()
0304     {
0305         load(true);
0306     });
0307     connect(queueAppendB, &QPushButton::clicked, this, [&]()
0308     {
0309         load(false);
0310     });
0311 
0312     connect(schedulerTwilight, &QCheckBox::toggled, this, &Scheduler::checkTwilightWarning);
0313 
0314     // Connect simulation clock scale
0315     connect(KStarsData::Instance()->clock(), &SimClock::scaleChanged, this, &Scheduler::simClockScaleChanged);
0316     connect(KStarsData::Instance()->clock(), &SimClock::timeChanged, this, &Scheduler::simClockTimeChanged);
0317 
0318     // Connect to the state machine
0319     connect(moduleState().data(), &SchedulerModuleState::newLog, this, &Scheduler::appendLogText);
0320     connect(moduleState().data(), &SchedulerModuleState::ekosStateChanged, this, &Scheduler::ekosStateChanged);
0321     connect(moduleState().data(), &SchedulerModuleState::indiStateChanged, this, &Scheduler::indiStateChanged);
0322     connect(moduleState().data(), &SchedulerModuleState::schedulerStateChanged, this, &Scheduler::handleSchedulerStateChanged);
0323     connect(moduleState().data(), &SchedulerModuleState::startupStateChanged, this, &Scheduler::startupStateChanged);
0324     connect(moduleState().data(), &SchedulerModuleState::shutdownStateChanged, this, &Scheduler::shutdownStateChanged);
0325     connect(moduleState().data(), &SchedulerModuleState::parkWaitStateChanged, this, &Scheduler::parkWaitStateChanged);
0326     connect(moduleState().data(), &SchedulerModuleState::profilesChanged, this, &Scheduler::updateProfiles);
0327     connect(moduleState().data(), &SchedulerModuleState::currentPositionChanged, queueTable, &QTableWidget::selectRow);
0328     connect(moduleState().data(), &SchedulerModuleState::jobStageChanged, this, &Scheduler::updateJobStageUI);
0329     connect(moduleState().data(), &SchedulerModuleState::updateNightTime, this, &Scheduler::updateNightTime);
0330     connect(moduleState().data(), &SchedulerModuleState::currentProfileChanged, this, [&]()
0331     {
0332         schedulerProfileCombo->setCurrentText(moduleState()->currentProfile());
0333     });
0334     // Connect to process engine
0335     connect(process().data(), &SchedulerProcess::newLog, this, &Scheduler::appendLogText);
0336     connect(process().data(), &SchedulerProcess::schedulerStopped, this, &Scheduler::schedulerStopped);
0337     connect(process().data(), &SchedulerProcess::schedulerPaused, this, &Scheduler::handleSetPaused);
0338     connect(process().data(), &SchedulerProcess::shutdownStarted, this, &Scheduler::handleShutdownStarted);
0339     connect(process().data(), &SchedulerProcess::schedulerSleeping, this, &Scheduler::handleSchedulerSleeping);
0340     connect(process().data(), &SchedulerProcess::jobsUpdated, this, &Scheduler::handleJobsUpdated);
0341     connect(process().data(), &SchedulerProcess::updateJobTable, this, &Scheduler::updateJobTable);
0342     connect(process().data(), &SchedulerProcess::addJob, this, &Scheduler::addJob);
0343     connect(process().data(), &SchedulerProcess::jobStarted, this, &Scheduler::jobStarted);
0344     connect(process().data(), &SchedulerProcess::jobEnded, this, &Scheduler::jobEnded);
0345     connect(process().data(), &SchedulerProcess::syncGreedyParams, this, &Scheduler::syncGreedyParams);
0346     connect(process().data(), &SchedulerProcess::syncGUIToGeneralSettings, this, &Scheduler::syncGUIToGeneralSettings);
0347     connect(process().data(), &SchedulerProcess::changeSleepLabel, this, &Scheduler::changeSleepLabel);
0348     connect(process().data(), &SchedulerProcess::updateSchedulerURL, this, &Scheduler::updateSchedulerURL);
0349     // Connect geographical location - when it is available
0350     //connect(KStarsData::Instance()..., &LocationDialog::locationChanged..., this, &Scheduler::simClockTimeChanged);
0351 
0352     // Restore values for general settings.
0353     syncGUIToGeneralSettings();
0354 
0355 
0356     connect(errorHandlingButtonGroup, static_cast<void (QButtonGroup::*)(QAbstractButton *)>
0357             (&QButtonGroup::buttonClicked), [this](QAbstractButton * button)
0358     {
0359         Q_UNUSED(button)
0360         ErrorHandlingStrategy strategy = getErrorHandlingStrategy();
0361         Options::setErrorHandlingStrategy(strategy);
0362         errorHandlingStrategyDelay->setEnabled(strategy != ERROR_DONT_RESTART);
0363     });
0364     connect(errorHandlingStrategyDelay, static_cast<void (QSpinBox::*)(int)>(&QSpinBox::valueChanged), [](int value)
0365     {
0366         Options::setErrorHandlingStrategyDelay(value);
0367     });
0368 
0369     // Retiring the Classic algorithm.
0370     if (Options::schedulerAlgorithm() != ALGORITHM_GREEDY)
0371     {
0372         appendLogText(i18n("Warning: The Classic scheduler algorithm has been retired. Switching you to the Greedy algorithm."));
0373         Options::setSchedulerAlgorithm(ALGORITHM_GREEDY);
0374     }
0375 
0376     // restore default values for scheduler algorithm
0377     setAlgorithm(Options::schedulerAlgorithm());
0378 
0379     connect(copySkyCenterB, &QPushButton::clicked, this, [this]()
0380     {
0381         SkyPoint center = SkyMap::Instance()->getCenterPoint();
0382         //center.deprecess(KStarsData::Instance()->updateNum());
0383         center.catalogueCoord(KStarsData::Instance()->updateNum()->julianDay());
0384         raBox->show(center.ra0());
0385         decBox->show(center.dec0());
0386     });
0387 
0388     connect(KConfigDialog::exists("settings"), &KConfigDialog::settingsChanged, this, &Scheduler::applyConfig);
0389 
0390     connect(editSequenceB, &QPushButton::clicked, this, [this]()
0391     {
0392         if (!m_SequenceEditor)
0393             m_SequenceEditor.reset(new SequenceEditor(this));
0394 
0395         m_SequenceEditor->show();
0396         m_SequenceEditor->raise();
0397     });
0398 
0399     moduleState()->calculateDawnDusk();
0400     updateNightTime();
0401 
0402     process()->loadProfiles();
0403 
0404     watchJobChanges(true);
0405 
0406     loadGlobalSettings();
0407     connectSettings();
0408 }
0409 
0410 QString Scheduler::getCurrentJobName()
0411 {
0412     return (activeJob() != nullptr ? activeJob()->getName() : "");
0413 }
0414 
0415 void Scheduler::watchJobChanges(bool enable)
0416 {
0417     /* Don't double watch, this will cause multiple signals to be connected */
0418     if (enable == jobChangesAreWatched)
0419         return;
0420 
0421     /* These are the widgets we want to connect, per signal function, to listen for modifications */
0422     QLineEdit * const lineEdits[] =
0423     {
0424         nameEdit,
0425         groupEdit,
0426         raBox,
0427         decBox,
0428         fitsEdit,
0429         sequenceEdit,
0430         schedulerStartupScript,
0431         schedulerShutdownScript
0432     };
0433 
0434     QDateTimeEdit * const dateEdits[] =
0435     {
0436         startupTimeEdit,
0437         schedulerUntilValue
0438     };
0439 
0440     QComboBox * const comboBoxes[] =
0441     {
0442         schedulerProfileCombo,
0443     };
0444 
0445     QButtonGroup * const buttonGroups[] =
0446     {
0447         stepsButtonGroup,
0448         errorHandlingButtonGroup,
0449         startupButtonGroup,
0450         constraintButtonGroup,
0451         completionButtonGroup,
0452         startupProcedureButtonGroup,
0453         shutdownProcedureGroup
0454     };
0455 
0456     QAbstractButton * const buttons[] =
0457     {
0458         errorHandlingRescheduleErrorsCB
0459     };
0460 
0461     QSpinBox * const spinBoxes[] =
0462     {
0463         schedulerExecutionSequencesLimit,
0464         errorHandlingStrategyDelay
0465     };
0466 
0467     QDoubleSpinBox * const dspinBoxes[] =
0468     {
0469         schedulerMoonSeparationValue,
0470         schedulerAltitudeValue,
0471         positionAngleSpin,
0472     };
0473 
0474     if (enable)
0475     {
0476         /* Connect the relevant signal to setDirty. Note that we are not keeping the connection object: we will
0477          * only use that signal once, and there will be no leaks. If we were connecting multiple receiver functions
0478          * to the same signal, we would have to be selective when disconnecting. We also use a lambda to absorb the
0479          * excess arguments which cannot be passed to setDirty, and limit captured arguments to 'this'.
0480          * The main problem with this implementation compared to the macro method is that it is now possible to
0481          * stack signal connections. That is, multiple calls to WatchJobChanges will cause multiple signal-to-slot
0482          * instances to be registered. As a result, one click will produce N signals, with N*=2 for each call to
0483          * WatchJobChanges(true) missing its WatchJobChanges(false) counterpart.
0484          */
0485         for (auto * const control : lineEdits)
0486             connect(control, &QLineEdit::editingFinished, this, [this]()
0487         {
0488             setDirty();
0489         });
0490         for (auto * const control : dateEdits)
0491             connect(control, &QDateTimeEdit::editingFinished, this, [this]()
0492         {
0493             setDirty();
0494         });
0495         for (auto * const control : comboBoxes)
0496             connect(control, static_cast<void (QComboBox::*)(int)>(&QComboBox::currentIndexChanged), this, [this]()
0497         {
0498             setDirty();
0499         });
0500         for (auto * const control : buttonGroups)
0501 #if QT_VERSION < QT_VERSION_CHECK(5, 15, 0)
0502             connect(control, static_cast<void (QButtonGroup::*)(int, bool)>(&QButtonGroup::buttonToggled), this, [this](int, bool)
0503 #else
0504             connect(control, static_cast<void (QButtonGroup::*)(int, bool)>(&QButtonGroup::idToggled), this, [this](int, bool)
0505 #endif
0506         {
0507             setDirty();
0508         });
0509         for (auto * const control : buttons)
0510             connect(control, static_cast<void (QAbstractButton::*)(bool)>(&QAbstractButton::clicked), this, [this](bool)
0511         {
0512             setDirty();
0513         });
0514         for (auto * const control : spinBoxes)
0515             connect(control, static_cast<void (QSpinBox::*)(int)>(&QSpinBox::valueChanged), this, [this]()
0516         {
0517             setDirty();
0518         });
0519         for (auto * const control : dspinBoxes)
0520             connect(control, static_cast<void (QDoubleSpinBox::*)(double)>(&QDoubleSpinBox::valueChanged), this, [this](double)
0521         {
0522             setDirty();
0523         });
0524     }
0525     else
0526     {
0527         /* Disconnect the relevant signal from each widget. Actually, this method removes all signals from the widgets,
0528          * because we did not take care to keep the connection object when connecting. No problem in our case, we do not
0529          * expect other signals to be connected. Because we used a lambda, we cannot use the same function object to
0530          * disconnect selectively.
0531          */
0532         for (auto * const control : lineEdits)
0533             disconnect(control, &QLineEdit::editingFinished, this, nullptr);
0534         for (auto * const control : dateEdits)
0535             disconnect(control, &QDateTimeEdit::editingFinished, this, nullptr);
0536         for (auto * const control : comboBoxes)
0537             disconnect(control, static_cast<void (QComboBox::*)(int)>(&QComboBox::currentIndexChanged), this, nullptr);
0538         for (auto * const control : buttons)
0539             disconnect(control, static_cast<void (QAbstractButton::*)(bool)>(&QAbstractButton::clicked), this, nullptr);
0540         for (auto * const control : buttonGroups)
0541 #if QT_VERSION < QT_VERSION_CHECK(5, 15, 0)
0542             disconnect(control, static_cast<void (QButtonGroup::*)(int, bool)>(&QButtonGroup::buttonToggled), this, nullptr);
0543 #else
0544             disconnect(control, static_cast<void (QButtonGroup::*)(int, bool)>(&QButtonGroup::idToggled), this, nullptr);
0545 #endif
0546         for (auto * const control : spinBoxes)
0547             disconnect(control, static_cast<void (QSpinBox::*)(int)>(&QSpinBox::valueChanged), this, nullptr);
0548         for (auto * const control : dspinBoxes)
0549             disconnect(control, static_cast<void (QDoubleSpinBox::*)(double)>(&QDoubleSpinBox::valueChanged), this, nullptr);
0550     }
0551 
0552     jobChangesAreWatched = enable;
0553 }
0554 
0555 void Scheduler::appendLogText(const QString &text)
0556 {
0557     /* FIXME: user settings for log length */
0558     int const max_log_count = 2000;
0559     if (m_LogText.size() > max_log_count)
0560         m_LogText.removeLast();
0561 
0562     m_LogText.prepend(i18nc("log entry; %1 is the date, %2 is the text", "%1 %2",
0563                             SchedulerModuleState::getLocalTime().toString("yyyy-MM-ddThh:mm:ss"), text));
0564 
0565     qCInfo(KSTARS_EKOS_SCHEDULER) << text;
0566 
0567     emit newLog(text);
0568 }
0569 
0570 void Scheduler::clearLog()
0571 {
0572     m_LogText.clear();
0573     emit newLog(QString());
0574 }
0575 
0576 void Scheduler::applyConfig()
0577 {
0578     moduleState()->calculateDawnDusk();
0579     updateNightTime();
0580     repeatSequenceCB->setEnabled(Options::rememberJobProgress() == false);
0581     executionSequenceLimit->setEnabled(Options::rememberJobProgress() == false);
0582 
0583     if (SCHEDULER_RUNNING != moduleState()->schedulerState())
0584     {
0585         process()->evaluateJobs(true);
0586     }
0587 }
0588 
0589 void Scheduler::selectObject()
0590 {
0591     if (FindDialog::Instance()->execWithParent(Ekos::Manager::Instance()) == QDialog::Accepted)
0592     {
0593         SkyObject *object = FindDialog::Instance()->targetObject();
0594         addObject(object);
0595     }
0596 }
0597 
0598 void Scheduler::addObject(SkyObject *object)
0599 {
0600     if (object != nullptr)
0601     {
0602         QString finalObjectName(object->name());
0603 
0604         if (object->name() == "star")
0605         {
0606             StarObject *s = dynamic_cast<StarObject *>(object);
0607 
0608             if (s->getHDIndex() != 0)
0609                 finalObjectName = QString("HD %1").arg(s->getHDIndex());
0610         }
0611 
0612         nameEdit->setText(finalObjectName);
0613         raBox->show(object->ra0());
0614         decBox->show(object->dec0());
0615 
0616         setDirty();
0617     }
0618 }
0619 
0620 void Scheduler::selectFITS()
0621 {
0622     auto url = QFileDialog::getOpenFileUrl(Ekos::Manager::Instance(), i18nc("@title:window", "Select FITS/XISF Image"), dirPath,
0623                                            "FITS (*.fits *.fit);;XISF (*.xisf)");
0624     if (url.isEmpty())
0625         return;
0626 
0627     processFITSSelection(url);
0628 }
0629 
0630 void Scheduler::processFITSSelection(const QUrl &url)
0631 {
0632     if (url.isEmpty())
0633         return;
0634 
0635     fitsURL = url;
0636     dirPath = QUrl(fitsURL.url(QUrl::RemoveFilename));
0637     fitsEdit->setText(fitsURL.toLocalFile());
0638     setDirty();
0639 
0640     const QString filename = fitsEdit->text();
0641     int status = 0;
0642     double ra = 0, dec = 0;
0643     dms raDMS, deDMS;
0644     char comment[128], error_status[512];
0645     fitsfile *fptr = nullptr;
0646 
0647     if (fits_open_diskfile(&fptr, filename.toLatin1(), READONLY, &status))
0648     {
0649         fits_report_error(stderr, status);
0650         fits_get_errstatus(status, error_status);
0651         qCCritical(KSTARS_EKOS_SCHEDULER) << QString::fromUtf8(error_status);
0652         return;
0653     }
0654 
0655     status = 0;
0656     if (fits_movabs_hdu(fptr, 1, IMAGE_HDU, &status))
0657     {
0658         fits_report_error(stderr, status);
0659         fits_get_errstatus(status, error_status);
0660         qCCritical(KSTARS_EKOS_SCHEDULER) << QString::fromUtf8(error_status);
0661         return;
0662     }
0663 
0664     status = 0;
0665     char objectra_str[32] = {0};
0666     if (fits_read_key(fptr, TSTRING, "OBJCTRA", objectra_str, comment, &status))
0667     {
0668         if (fits_read_key(fptr, TDOUBLE, "RA", &ra, comment, &status))
0669         {
0670             fits_report_error(stderr, status);
0671             fits_get_errstatus(status, error_status);
0672             appendLogText(i18n("FITS header: cannot find OBJCTRA (%1).", QString(error_status)));
0673             return;
0674         }
0675 
0676         raDMS.setD(ra);
0677     }
0678     else
0679     {
0680         raDMS = dms::fromString(objectra_str, false);
0681     }
0682 
0683     status = 0;
0684     char objectde_str[32] = {0};
0685     if (fits_read_key(fptr, TSTRING, "OBJCTDEC", objectde_str, comment, &status))
0686     {
0687         if (fits_read_key(fptr, TDOUBLE, "DEC", &dec, comment, &status))
0688         {
0689             fits_report_error(stderr, status);
0690             fits_get_errstatus(status, error_status);
0691             appendLogText(i18n("FITS header: cannot find OBJCTDEC (%1).", QString(error_status)));
0692             return;
0693         }
0694 
0695         deDMS.setD(dec);
0696     }
0697     else
0698     {
0699         deDMS = dms::fromString(objectde_str, true);
0700     }
0701 
0702     raBox->show(raDMS);
0703     decBox->show(deDMS);
0704 
0705     char object_str[256] = {0};
0706     if (fits_read_key(fptr, TSTRING, "OBJECT", object_str, comment, &status))
0707     {
0708         QFileInfo info(filename);
0709         nameEdit->setText(info.completeBaseName());
0710     }
0711     else
0712     {
0713         nameEdit->setText(object_str);
0714     }
0715 }
0716 
0717 void Scheduler::setSequence(const QString &sequenceFileURL)
0718 {
0719     sequenceURL = QUrl::fromLocalFile(sequenceFileURL);
0720 
0721     if (sequenceFileURL.isEmpty())
0722         return;
0723     dirPath = QUrl(sequenceURL.url(QUrl::RemoveFilename));
0724 
0725     sequenceEdit->setText(sequenceURL.toLocalFile());
0726 
0727     setDirty();
0728 }
0729 
0730 void Scheduler::selectSequence()
0731 {
0732     QString file = QFileDialog::getOpenFileName(Ekos::Manager::Instance(), i18nc("@title:window", "Select Sequence Queue"),
0733                    dirPath.toLocalFile(),
0734                    i18n("Ekos Sequence Queue (*.esq)"));
0735 
0736     setSequence(file);
0737 }
0738 
0739 void Scheduler::selectStartupScript()
0740 {
0741     moduleState()->setStartupScriptURL(QFileDialog::getOpenFileUrl(Ekos::Manager::Instance(), i18nc("@title:window",
0742                                        "Select Startup Script"),
0743                                        dirPath,
0744                                        i18n("Script (*)")));
0745     if (moduleState()->startupScriptURL().isEmpty())
0746         return;
0747 
0748     dirPath = QUrl(moduleState()->startupScriptURL().url(QUrl::RemoveFilename));
0749 
0750     moduleState()->setDirty(true);
0751     schedulerStartupScript->setText(moduleState()->startupScriptURL().toLocalFile());
0752 }
0753 
0754 void Scheduler::selectShutdownScript()
0755 {
0756     moduleState()->setShutdownScriptURL(QFileDialog::getOpenFileUrl(Ekos::Manager::Instance(), i18nc("@title:window",
0757                                         "Select Shutdown Script"),
0758                                         dirPath,
0759                                         i18n("Script (*)")));
0760     if (moduleState()->shutdownScriptURL().isEmpty())
0761         return;
0762 
0763     dirPath = QUrl(moduleState()->shutdownScriptURL().url(QUrl::RemoveFilename));
0764 
0765     moduleState()->setDirty(true);
0766     schedulerShutdownScript->setText(moduleState()->shutdownScriptURL().toLocalFile());
0767 }
0768 
0769 void Scheduler::addJob(SchedulerJob *job)
0770 {
0771     if (0 <= jobUnderEdit)
0772     {
0773         // select the job currently being edited
0774         job = moduleState()->jobs().at(jobUnderEdit);
0775         // if existing, save it
0776         if (job != nullptr)
0777             saveJob(job);
0778         // in any case, reset editing
0779         resetJobEdit();
0780     }
0781     else
0782     {
0783         // remember the number of rows to select the first one appended
0784         int currentRow = moduleState()->currentPosition();
0785 
0786         //If no row is selected, the job will be appended at the end of the list, otherwise below the current selection
0787         if (currentRow < 0)
0788             currentRow = queueTable->rowCount();
0789         else
0790             currentRow++;
0791 
0792         /* If a job is being added, save fields into a new job */
0793         saveJob(job);
0794 
0795         // select the first appended row (if any was added)
0796         if (moduleState()->jobs().count() > currentRow)
0797             moduleState()->setCurrentPosition(currentRow);
0798     }
0799 
0800     emit jobsUpdated(moduleState()->getJSONJobs());
0801 }
0802 
0803 bool Scheduler::fillJobFromUI(SchedulerJob *job)
0804 {
0805     if (nameEdit->text().isEmpty())
0806     {
0807         appendLogText(i18n("Warning: Target name is required."));
0808         return false;
0809     }
0810 
0811     if (sequenceEdit->text().isEmpty())
0812     {
0813         appendLogText(i18n("Warning: Sequence file is required."));
0814         return false;
0815     }
0816 
0817     // Coordinates are required unless it is a FITS file
0818     if ((raBox->isEmpty() || decBox->isEmpty()) && fitsURL.isEmpty())
0819     {
0820         appendLogText(i18n("Warning: Target coordinates are required."));
0821         return false;
0822     }
0823 
0824     bool raOk = false, decOk = false;
0825     dms /*const*/ ra(raBox->createDms(&raOk));
0826     dms /*const*/ dec(decBox->createDms(&decOk));
0827 
0828     if (raOk == false)
0829     {
0830         appendLogText(i18n("Warning: RA value %1 is invalid.", raBox->text()));
0831         return false;
0832     }
0833 
0834     if (decOk == false)
0835     {
0836         appendLogText(i18n("Warning: DEC value %1 is invalid.", decBox->text()));
0837         return false;
0838     }
0839 
0840     /* Configure or reconfigure the observation job */
0841     fitsURL = QUrl::fromLocalFile(fitsEdit->text());
0842 
0843     // Get several job values depending on the state of the UI.
0844 
0845     StartupCondition startCondition = START_AT;
0846     if (asapConditionR->isChecked())
0847         startCondition = START_ASAP;
0848 
0849     CompletionCondition stopCondition = FINISH_AT;
0850     if (schedulerCompleteSequences->isChecked())
0851         stopCondition = FINISH_SEQUENCE;
0852     else if (schedulerRepeatSequences->isChecked())
0853         stopCondition = FINISH_REPEAT;
0854     else if (schedulerUntilTerminated->isChecked())
0855         stopCondition = FINISH_LOOP;
0856 
0857     double altConstraint = SchedulerJob::UNDEFINED_ALTITUDE;
0858     if (schedulerAltitude->isChecked())
0859         altConstraint = schedulerAltitudeValue->value();
0860 
0861     double moonConstraint = -1;
0862     if (schedulerMoonSeparation->isChecked())
0863         moonConstraint = schedulerMoonSeparationValue->value();
0864 
0865     // The reason for this kitchen-sink function is to separate the UI from the
0866     // job setup, to allow for testing.
0867     SchedulerUtils::setupJob(*job, nameEdit->text(), groupEdit->text(), ra, dec,
0868                              KStarsData::Instance()->ut().djd(),
0869                              positionAngleSpin->value(), sequenceURL, fitsURL,
0870 
0871                              startCondition, startupTimeEdit->dateTime(),
0872                              stopCondition, schedulerUntilValue->dateTime(), schedulerExecutionSequencesLimit->value(),
0873 
0874                              altConstraint,
0875                              moonConstraint,
0876                              schedulerWeather->isChecked(),
0877                              schedulerTwilight->isChecked(),
0878                              schedulerHorizon->isChecked(),
0879 
0880                              schedulerTrackStep->isChecked(),
0881                              schedulerFocusStep->isChecked(),
0882                              schedulerAlignStep->isChecked(),
0883                              schedulerGuideStep->isChecked());
0884 
0885     // success
0886     updateJobTable(job);
0887     return true;
0888 }
0889 
0890 void Scheduler::saveJob(SchedulerJob *job)
0891 {
0892     watchJobChanges(false);
0893 
0894     /* Create or Update a scheduler job, append below current selection */
0895     int currentRow = moduleState()->currentPosition() + 1;
0896 
0897     /* Add job to queue only if it is new, else reuse current row.
0898      * Make sure job is added at the right index, now that queueTable may have a line selected without being edited.
0899      */
0900     if (0 <= jobUnderEdit)
0901     {
0902         /* FIXME: jobUnderEdit is a parallel variable that may cause issues if it desyncs from moduleState()->currentPosition(). */
0903         if (jobUnderEdit != currentRow - 1)
0904         {
0905             qCWarning(KSTARS_EKOS_SCHEDULER) << "BUG: the observation job under edit does not match the selected row in the job table.";
0906         }
0907 
0908         /* Use the job in the row currently edited */
0909         job = moduleState()->jobs().at(jobUnderEdit);
0910         // try to fill the job from the UI and exit if it fails
0911         if (fillJobFromUI(job) == false)
0912         {
0913             watchJobChanges(true);
0914             return;
0915         }
0916     }
0917     else
0918     {
0919         if (job == nullptr)
0920         {
0921             /* 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. */
0922             job = new SchedulerJob();
0923             // try to fill the job from the UI and exit if it fails
0924             if (fillJobFromUI(job) == false)
0925             {
0926                 delete(job);
0927                 watchJobChanges(true);
0928                 return;
0929             }
0930         }
0931         /* Insert the job in the job list and add a row in the table for it just after the row currently selected. */
0932         moduleState()->mutlableJobs().insert(currentRow, job);
0933         insertJobTableRow(currentRow);
0934     }
0935 
0936     /* Verifications */
0937     // Warn user if a duplicated job is in the list - same target, same sequence
0938     // FIXME: Those duplicated jobs are not necessarily processed in the order they appear in the list!
0939     int numWarnings = 0;
0940     foreach (SchedulerJob *a_job, moduleState()->jobs())
0941     {
0942         if (a_job == job)
0943         {
0944             break;
0945         }
0946         else if (a_job->getName() == job->getName())
0947         {
0948             int const a_job_row = moduleState()->jobs().indexOf(a_job);
0949 
0950             /* 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. */
0951             appendLogText(i18n("Warning: job '%1' at row %2 has a duplicate target at row %3, "
0952                                "the scheduler may consider the same storage for captures.",
0953                                job->getName(), currentRow, a_job_row));
0954 
0955             /* Warn the user in case the two jobs are really identical */
0956             if (a_job->getSequenceFile() == job->getSequenceFile())
0957             {
0958                 if (a_job->getRepeatsRequired() == job->getRepeatsRequired() && Options::rememberJobProgress())
0959                     appendLogText(i18n("Warning: jobs '%1' at row %2 and %3 probably require a different repeat count "
0960                                        "as currently they will complete simultaneously after %4 batches (or disable option 'Remember job progress')",
0961                                        job->getName(), currentRow, a_job_row, job->getRepeatsRequired()));
0962             }
0963 
0964             // Don't need to warn over and over.
0965             if (++numWarnings >= 1)
0966             {
0967                 appendLogText(i18n("Skipped checking for duplicates."));
0968                 break;
0969             }
0970         }
0971     }
0972 
0973     updateJobTable(job);
0974 
0975     /* We just added or saved a job, so we have a job in the list - enable relevant buttons */
0976     queueSaveAsB->setEnabled(true);
0977     queueSaveB->setEnabled(true);
0978     startB->setEnabled(true);
0979     evaluateOnlyB->setEnabled(true);
0980     setJobManipulation(true, true);
0981     checkJobInputComplete();
0982 
0983     qCDebug(KSTARS_EKOS_SCHEDULER) << QString("Job '%1' at row #%2 was saved.").arg(job->getName()).arg(currentRow + 1);
0984 
0985     watchJobChanges(true);
0986 
0987     if (SCHEDULER_LOADING != moduleState()->schedulerState())
0988     {
0989         process()->evaluateJobs(true);
0990     }
0991 }
0992 
0993 void Scheduler::syncGUIToJob(SchedulerJob *job)
0994 {
0995     nameEdit->setText(job->getName());
0996     groupEdit->setText(job->getGroup());
0997 
0998     raBox->show(job->getTargetCoords().ra0());
0999     decBox->show(job->getTargetCoords().dec0());
1000 
1001     // fitsURL/sequenceURL are not part of UI, but the UI serves as model, so keep them here for now
1002     fitsURL = job->getFITSFile().isEmpty() ? QUrl() : job->getFITSFile();
1003     sequenceURL = job->getSequenceFile();
1004     fitsEdit->setText(fitsURL.toLocalFile());
1005     sequenceEdit->setText(sequenceURL.toLocalFile());
1006 
1007     positionAngleSpin->setValue(job->getPositionAngle());
1008 
1009     schedulerTrackStep->setChecked(job->getStepPipeline() & SchedulerJob::USE_TRACK);
1010     schedulerFocusStep->setChecked(job->getStepPipeline() & SchedulerJob::USE_FOCUS);
1011     schedulerAlignStep->setChecked(job->getStepPipeline() & SchedulerJob::USE_ALIGN);
1012     schedulerGuideStep->setChecked(job->getStepPipeline() & SchedulerJob::USE_GUIDE);
1013 
1014     switch (job->getFileStartupCondition())
1015     {
1016         case START_ASAP:
1017             asapConditionR->setChecked(true);
1018             break;
1019 
1020         case START_AT:
1021             startupTimeConditionR->setChecked(true);
1022             startupTimeEdit->setDateTime(job->getStartupTime());
1023             break;
1024     }
1025 
1026     if (job->getMinAltitude())
1027     {
1028         schedulerAltitude->setChecked(true);
1029         schedulerAltitudeValue->setValue(job->getMinAltitude());
1030     }
1031     else
1032     {
1033         schedulerAltitude->setChecked(false);
1034         schedulerAltitudeValue->setValue(DEFAULT_MIN_ALTITUDE);
1035     }
1036 
1037     if (job->getMinMoonSeparation() >= 0)
1038     {
1039         schedulerMoonSeparation->setChecked(true);
1040         schedulerMoonSeparationValue->setValue(job->getMinMoonSeparation());
1041     }
1042     else
1043     {
1044         schedulerMoonSeparation->setChecked(false);
1045         schedulerMoonSeparationValue->setValue(DEFAULT_MIN_MOON_SEPARATION);
1046     }
1047 
1048     schedulerWeather->setChecked(job->getEnforceWeather());
1049 
1050     schedulerTwilight->blockSignals(true);
1051     schedulerTwilight->setChecked(job->getEnforceTwilight());
1052     schedulerTwilight->blockSignals(false);
1053 
1054     schedulerHorizon->blockSignals(true);
1055     schedulerHorizon->setChecked(job->getEnforceArtificialHorizon());
1056     schedulerHorizon->blockSignals(false);
1057 
1058     switch (job->getCompletionCondition())
1059     {
1060         case FINISH_SEQUENCE:
1061             schedulerCompleteSequences->setChecked(true);
1062             break;
1063 
1064         case FINISH_REPEAT:
1065             schedulerRepeatSequences->setChecked(true);
1066             schedulerExecutionSequencesLimit->setValue(job->getRepeatsRequired());
1067             break;
1068 
1069         case FINISH_LOOP:
1070             schedulerUntilTerminated->setChecked(true);
1071             break;
1072 
1073         case FINISH_AT:
1074             schedulerUntil->setChecked(true);
1075             schedulerUntilValue->setDateTime(job->getCompletionTime());
1076             break;
1077     }
1078 
1079     updateNightTime(job);
1080 
1081     setJobManipulation(true, true);
1082 }
1083 
1084 void Scheduler::syncGUIToGeneralSettings()
1085 {
1086     schedulerParkDome->setChecked(Options::schedulerParkDome());
1087     schedulerParkMount->setChecked(Options::schedulerParkMount());
1088     schedulerCloseDustCover->setChecked(Options::schedulerCloseDustCover());
1089     schedulerWarmCCD->setChecked(Options::schedulerWarmCCD());
1090     schedulerUnparkDome->setChecked(Options::schedulerUnparkDome());
1091     schedulerUnparkMount->setChecked(Options::schedulerUnparkMount());
1092     schedulerOpenDustCover->setChecked(Options::schedulerOpenDustCover());
1093     setErrorHandlingStrategy(static_cast<ErrorHandlingStrategy>(Options::errorHandlingStrategy()));
1094     errorHandlingStrategyDelay->setValue(Options::errorHandlingStrategyDelay());
1095     errorHandlingRescheduleErrorsCB->setChecked(Options::rescheduleErrors());
1096     schedulerStartupScript->setText(moduleState()->startupScriptURL().toString(QUrl::PreferLocalFile));
1097     schedulerShutdownScript->setText(moduleState()->shutdownScriptURL().toString(QUrl::PreferLocalFile));
1098 
1099     if (process()->captureInterface() != nullptr)
1100     {
1101         QVariant hasCoolerControl = process()->captureInterface()->property("coolerControl");
1102         if (hasCoolerControl.isValid())
1103         {
1104             schedulerWarmCCD->setEnabled(hasCoolerControl.toBool());
1105             moduleState()->setCaptureReady(true);
1106         }
1107     }
1108 }
1109 
1110 void Scheduler::updateNightTime(SchedulerJob const *job)
1111 {
1112     if (job == nullptr)
1113     {
1114         int const currentRow = moduleState()->currentPosition();
1115         if (0 < currentRow && currentRow < moduleState()->jobs().size())
1116             job = moduleState()->jobs().at(currentRow);
1117 
1118         if (job == nullptr)
1119         {
1120             qCWarning(KSTARS_EKOS_SCHEDULER()) << "Cannot update night time, no matching job found at line" << currentRow;
1121             return;
1122         }
1123     }
1124 
1125     QDateTime const dawn = job ? job->getDawnAstronomicalTwilight() : moduleState()->Dawn();
1126     QDateTime const dusk = job ? job->getDuskAstronomicalTwilight() : moduleState()->Dusk();
1127 
1128     QChar const warning(dawn == dusk ? 0x26A0 : '-');
1129     nightTime->setText(i18n("%1 %2 %3", dusk.toString("hh:mm"), warning, dawn.toString("hh:mm")));
1130 }
1131 
1132 void Scheduler::loadJob(QModelIndex i)
1133 {
1134     if (jobUnderEdit == i.row())
1135         return;
1136 
1137     SchedulerJob * const job = moduleState()->jobs().at(i.row());
1138 
1139     if (job == nullptr)
1140         return;
1141 
1142     watchJobChanges(false);
1143 
1144     //job->setState(SCHEDJOB_IDLE);
1145     //job->setStage(SCHEDSTAGE_IDLE);
1146     syncGUIToJob(job);
1147 
1148     /* Turn the add button into an apply button */
1149     setJobAddApply(false);
1150 
1151     /* Disable scheduler start/evaluate buttons */
1152     startB->setEnabled(false);
1153     evaluateOnlyB->setEnabled(false);
1154 
1155     /* Don't let the end-user remove a job being edited */
1156     setJobManipulation(false, false);
1157 
1158     jobUnderEdit = i.row();
1159     qCDebug(KSTARS_EKOS_SCHEDULER) << QString("Job '%1' at row #%2 is currently edited.").arg(job->getName()).arg(
1160                                        jobUnderEdit + 1);
1161 
1162     watchJobChanges(true);
1163 }
1164 
1165 void Scheduler::updateSchedulerURL(const QString &fileURL)
1166 {
1167     schedulerURL = QUrl::fromLocalFile(fileURL);
1168     // update save button tool tip
1169     queueSaveB->setToolTip("Save schedule to " + schedulerURL.fileName());
1170 }
1171 
1172 void Scheduler::queueTableSelectionChanged(const QItemSelection &selected, const QItemSelection &deselected)
1173 {
1174     Q_UNUSED(deselected)
1175 
1176 
1177     if (jobChangesAreWatched == false || selected.empty())
1178         // || (current.row() + 1) > moduleState()->jobs().size())
1179         return;
1180 
1181     const QModelIndex current = selected.indexes().first();
1182     // this should not happen, but avoids crashes
1183     if ((current.row() + 1) > moduleState()->jobs().size())
1184     {
1185         qCWarning(KSTARS_EKOS_SCHEDULER()) << "Unexpected row number" << current.row() << "- ignoring.";
1186         return;
1187     }
1188     moduleState()->setCurrentPosition(current.row());
1189     SchedulerJob * const job = moduleState()->jobs().at(current.row());
1190 
1191     if (job != nullptr)
1192     {
1193         if (jobUnderEdit < 0)
1194             syncGUIToJob(job);
1195         else if (jobUnderEdit != current.row())
1196         {
1197             // avoid changing the UI values for the currently edited job
1198             appendLogText(i18n("Stop editing of job #%1, resetting to original value.", jobUnderEdit + 1));
1199             resetJobEdit();
1200             syncGUIToJob(job);
1201         }
1202     }
1203     else nightTime->setText("-");
1204 }
1205 
1206 void Scheduler::clickQueueTable(QModelIndex index)
1207 {
1208     setJobManipulation(index.isValid(), index.isValid());
1209 }
1210 
1211 void Scheduler::setJobAddApply(bool add_mode)
1212 {
1213     if (add_mode)
1214     {
1215         addToQueueB->setIcon(QIcon::fromTheme("list-add"));
1216         addToQueueB->setToolTip(i18n("Use edition fields to create a new job in the observation list."));
1217         addToQueueB->setAttribute(Qt::WA_LayoutUsesWidgetRect);
1218     }
1219     else
1220     {
1221         addToQueueB->setIcon(QIcon::fromTheme("dialog-ok-apply"));
1222         addToQueueB->setToolTip(i18n("Apply job changes."));
1223     }
1224     // check if the button should be enabled
1225     checkJobInputComplete();
1226 }
1227 
1228 void Scheduler::setJobManipulation(bool can_reorder, bool can_delete)
1229 {
1230     if (can_reorder)
1231     {
1232         int const currentRow = moduleState()->currentPosition();
1233         queueUpB->setEnabled(0 < currentRow);
1234         queueDownB->setEnabled(currentRow < queueTable->rowCount() - 1);
1235     }
1236     else
1237     {
1238         queueUpB->setEnabled(false);
1239         queueDownB->setEnabled(false);
1240     }
1241     sortJobsB->setEnabled(can_reorder);
1242     removeFromQueueB->setEnabled(can_delete);
1243 }
1244 
1245 bool Scheduler::reorderJobs(QList<SchedulerJob*> reordered_sublist)
1246 {
1247     /* Add jobs not reordered at the end of the list, in initial order */
1248     foreach (SchedulerJob* job, moduleState()->jobs())
1249         if (!reordered_sublist.contains(job))
1250             reordered_sublist.append(job);
1251 
1252     if (moduleState()->jobs() != reordered_sublist)
1253     {
1254         /* Remember job currently selected */
1255         int const selectedRow = moduleState()->currentPosition();
1256         SchedulerJob * const selectedJob = 0 <= selectedRow ? moduleState()->jobs().at(selectedRow) : nullptr;
1257 
1258         /* Reassign list */
1259         moduleState()->setJobs(reordered_sublist);
1260 
1261         /* Refresh the table */
1262         for (SchedulerJob *job : moduleState()->jobs())
1263             updateJobTable(job);
1264 
1265         /* Reselect previously selected job */
1266         if (nullptr != selectedJob)
1267             moduleState()->setCurrentPosition(moduleState()->jobs().indexOf(selectedJob));
1268 
1269         return true;
1270     }
1271     else return false;
1272 }
1273 
1274 void Scheduler::moveJobUp()
1275 {
1276     int const rowCount = queueTable->rowCount();
1277     int const currentRow = queueTable->currentRow();
1278     int const destinationRow = currentRow - 1;
1279 
1280     /* No move if no job selected, if table has one line or less or if destination is out of table */
1281     if (currentRow < 0 || rowCount <= 1 || destinationRow < 0)
1282         return;
1283 
1284     /* Swap jobs in the list */
1285 #if QT_VERSION >= QT_VERSION_CHECK(5,13,0)
1286     moduleState()->mutlableJobs().swapItemsAt(currentRow, destinationRow);
1287 #else
1288     moduleState()->jobs().swap(currentRow, destinationRow);
1289 #endif
1290 
1291     //Update the two table rows
1292     updateJobTable(moduleState()->jobs().at(currentRow));
1293     updateJobTable(moduleState()->jobs().at(destinationRow));
1294 
1295     /* Move selection to destination row */
1296     moduleState()->setCurrentPosition(destinationRow);
1297     setJobManipulation(true, true);
1298 
1299     /* Make list modified and evaluate jobs */
1300     moduleState()->setDirty(true);
1301     process()->evaluateJobs(true);
1302 }
1303 
1304 void Scheduler::moveJobDown()
1305 {
1306     int const rowCount = queueTable->rowCount();
1307     int const currentRow = queueTable->currentRow();
1308     int const destinationRow = currentRow + 1;
1309 
1310     /* No move if no job selected, if table has one line or less or if destination is out of table */
1311     if (currentRow < 0 || rowCount <= 1 || destinationRow >= rowCount)
1312         return;
1313 
1314     /* Swap jobs in the list */
1315 #if QT_VERSION >= QT_VERSION_CHECK(5,13,0)
1316     moduleState()->mutlableJobs().swapItemsAt(currentRow, destinationRow);
1317 #else
1318     moduleState()->mutlableJobs().swap(currentRow, destinationRow);
1319 #endif
1320 
1321     //Update the two table rows
1322     updateJobTable(moduleState()->jobs().at(currentRow));
1323     updateJobTable(moduleState()->jobs().at(destinationRow));
1324 
1325     /* Move selection to destination row */
1326     moduleState()->setCurrentPosition(destinationRow);
1327     setJobManipulation(true, true);
1328 
1329     /* Make list modified and evaluate jobs */
1330     moduleState()->setDirty(true);
1331     process()->evaluateJobs(true);
1332 }
1333 
1334 void Scheduler::updateJobTable(SchedulerJob *job)
1335 {
1336     // handle full table update
1337     if (job == nullptr)
1338     {
1339         for (auto onejob : moduleState()->jobs())
1340             updateJobTable(onejob);
1341 
1342         return;
1343     }
1344 
1345     const int row = moduleState()->jobs().indexOf(job);
1346     // Ignore unknown jobs
1347     if (row < 0)
1348         return;
1349     // ensure that the row in the table exists
1350     if (row >= queueTable->rowCount())
1351         insertJobTableRow(row - 1, false);
1352 
1353     QTableWidgetItem *nameCell = queueTable->item(row, static_cast<int>(SCHEDCOL_NAME));
1354     QTableWidgetItem *statusCell = queueTable->item(row, static_cast<int>(SCHEDCOL_STATUS));
1355     QTableWidgetItem *altitudeCell = queueTable->item(row, static_cast<int>(SCHEDCOL_ALTITUDE));
1356     QTableWidgetItem *startupCell = queueTable->item(row, static_cast<int>(SCHEDCOL_STARTTIME));
1357     QTableWidgetItem *completionCell = queueTable->item(row, static_cast<int>(SCHEDCOL_ENDTIME));
1358     QTableWidgetItem *captureCountCell = queueTable->item(row, static_cast<int>(SCHEDCOL_CAPTURES));
1359 
1360     // Only in testing.
1361     if (!nameCell) return;
1362 
1363     if (nullptr != nameCell)
1364     {
1365         nameCell->setText(job->getName());
1366         updateCellStyle(job, nameCell);
1367         if (nullptr != nameCell->tableWidget())
1368             nameCell->tableWidget()->resizeColumnToContents(nameCell->column());
1369     }
1370 
1371     if (nullptr != statusCell)
1372     {
1373         static QMap<SchedulerJobStatus, QString> stateStrings;
1374         static QString stateStringUnknown;
1375         if (stateStrings.isEmpty())
1376         {
1377             stateStrings[SCHEDJOB_IDLE] = i18n("Idle");
1378             stateStrings[SCHEDJOB_EVALUATION] = i18n("Evaluating");
1379             stateStrings[SCHEDJOB_SCHEDULED] = i18n("Scheduled");
1380             stateStrings[SCHEDJOB_BUSY] = i18n("Running");
1381             stateStrings[SCHEDJOB_INVALID] = i18n("Invalid");
1382             stateStrings[SCHEDJOB_COMPLETE] = i18n("Complete");
1383             stateStrings[SCHEDJOB_ABORTED] = i18n("Aborted");
1384             stateStrings[SCHEDJOB_ERROR] =  i18n("Error");
1385             stateStringUnknown = i18n("Unknown");
1386         }
1387         statusCell->setText(stateStrings.value(job->getState(), stateStringUnknown));
1388         updateCellStyle(job, statusCell);
1389 
1390         if (nullptr != statusCell->tableWidget())
1391             statusCell->tableWidget()->resizeColumnToContents(statusCell->column());
1392     }
1393 
1394     if (nullptr != startupCell)
1395     {
1396         auto time = (job->getState() == SCHEDJOB_BUSY) ? job->getStateTime() : job->getStartupTime();
1397         /* Display startup time if it is valid */
1398         if (time.isValid())
1399         {
1400             startupCell->setText(QString("%1%2%L3° %4")
1401                                  .arg(job->getAltitudeAtStartup() < job->getMinAltitude() ? QString(QChar(0x26A0)) : "")
1402                                  .arg(QChar(job->isSettingAtStartup() ? 0x2193 : 0x2191))
1403                                  .arg(job->getAltitudeAtStartup(), 0, 'f', 1)
1404                                  .arg(time.toString(startupTimeEdit->displayFormat())));
1405 
1406             switch (job->getFileStartupCondition())
1407             {
1408                 /* If the original condition is START_AT/START_CULMINATION, startup time is fixed */
1409                 case START_AT:
1410                     startupCell->setIcon(QIcon::fromTheme("chronometer"));
1411                     break;
1412 
1413                 /* If the original condition is START_ASAP, startup time is informational */
1414                 case START_ASAP:
1415                     startupCell->setIcon(QIcon());
1416                     break;
1417 
1418                 default:
1419                     break;
1420             }
1421         }
1422         /* Else do not display any startup time */
1423         else
1424         {
1425             startupCell->setText("-");
1426             startupCell->setIcon(QIcon());
1427         }
1428 
1429         updateCellStyle(job, startupCell);
1430 
1431         if (nullptr != startupCell->tableWidget())
1432             startupCell->tableWidget()->resizeColumnToContents(startupCell->column());
1433     }
1434 
1435     if (nullptr != altitudeCell)
1436     {
1437         // FIXME: Cache altitude calculations
1438         bool is_setting = false;
1439         double const alt = SchedulerUtils::findAltitude(job->getTargetCoords(), QDateTime(), &is_setting);
1440 
1441         altitudeCell->setText(QString("%1%L2°")
1442                               .arg(QChar(is_setting ? 0x2193 : 0x2191))
1443                               .arg(alt, 0, 'f', 1));
1444         updateCellStyle(job, altitudeCell);
1445 
1446         if (nullptr != altitudeCell->tableWidget())
1447             altitudeCell->tableWidget()->resizeColumnToContents(altitudeCell->column());
1448     }
1449 
1450     if (nullptr != completionCell)
1451     {
1452         if (job->getGreedyCompletionTime().isValid())
1453         {
1454             completionCell->setText(QString("%1")
1455                                     .arg(job->getGreedyCompletionTime().toString("hh:mm")));
1456         }
1457         else
1458             /* Display completion time if it is valid and job is not looping */
1459             if (FINISH_LOOP != job->getCompletionCondition() && job->getCompletionTime().isValid())
1460             {
1461                 completionCell->setText(QString("%1%2%L3° %4")
1462                                         .arg(job->getAltitudeAtCompletion() < job->getMinAltitude() ? QString(QChar(0x26A0)) : "")
1463                                         .arg(QChar(job->isSettingAtCompletion() ? 0x2193 : 0x2191))
1464                                         .arg(job->getAltitudeAtCompletion(), 0, 'f', 1)
1465                                         .arg(job->getCompletionTime().toString(startupTimeEdit->displayFormat())));
1466 
1467                 switch (job->getCompletionCondition())
1468                 {
1469                     case FINISH_AT:
1470                         completionCell->setIcon(QIcon::fromTheme("chronometer"));
1471                         break;
1472 
1473                     case FINISH_SEQUENCE:
1474                     case FINISH_REPEAT:
1475                     default:
1476                         completionCell->setIcon(QIcon());
1477                         break;
1478                 }
1479             }
1480         /* Else do not display any completion time */
1481             else
1482             {
1483                 completionCell->setText("-");
1484                 completionCell->setIcon(QIcon());
1485             }
1486 
1487         updateCellStyle(job, completionCell);
1488         if (nullptr != completionCell->tableWidget())
1489             completionCell->tableWidget()->resizeColumnToContents(completionCell->column());
1490     }
1491 
1492     if (nullptr != captureCountCell)
1493     {
1494         switch (job->getCompletionCondition())
1495         {
1496             case FINISH_AT:
1497             // FIXME: Attempt to calculate the number of frames until end - requires detailed imaging time
1498 
1499             case FINISH_LOOP:
1500                 // If looping, display the count of completed frames
1501                 captureCountCell->setText(QString("%L1/-").arg(job->getCompletedCount()));
1502                 break;
1503 
1504             case FINISH_SEQUENCE:
1505             case FINISH_REPEAT:
1506             default:
1507                 // If repeating, display the count of completed frames to the count of requested frames
1508                 captureCountCell->setText(QString("%L1/%L2").arg(job->getCompletedCount()).arg(job->getSequenceCount()));
1509                 break;
1510         }
1511 
1512         updateCellStyle(job, captureCountCell);
1513         if (nullptr != captureCountCell->tableWidget())
1514             captureCountCell->tableWidget()->resizeColumnToContents(captureCountCell->column());
1515     }
1516 
1517     emit jobsUpdated(moduleState()->getJSONJobs());
1518 }
1519 
1520 void Scheduler::insertJobTableRow(int row, bool above)
1521 {
1522     const int pos = above ? row : row + 1;
1523 
1524     // ensure that there are no gaps
1525     if (row > queueTable->rowCount())
1526         insertJobTableRow(row - 1, above);
1527 
1528     queueTable->insertRow(pos);
1529 
1530     QTableWidgetItem *nameCell = new QTableWidgetItem();
1531     queueTable->setItem(row, static_cast<int>(SCHEDCOL_NAME), nameCell);
1532     nameCell->setTextAlignment(Qt::AlignHCenter | Qt::AlignVCenter);
1533     nameCell->setFlags(Qt::ItemIsSelectable | Qt::ItemIsEnabled);
1534 
1535     QTableWidgetItem *statusCell = new QTableWidgetItem();
1536     queueTable->setItem(row, static_cast<int>(SCHEDCOL_STATUS), statusCell);
1537     statusCell->setTextAlignment(Qt::AlignHCenter | Qt::AlignVCenter);
1538     statusCell->setFlags(Qt::ItemIsSelectable | Qt::ItemIsEnabled);
1539 
1540     QTableWidgetItem *captureCount = new QTableWidgetItem();
1541     queueTable->setItem(row, static_cast<int>(SCHEDCOL_CAPTURES), captureCount);
1542     captureCount->setTextAlignment(Qt::AlignHCenter | Qt::AlignVCenter);
1543     captureCount->setFlags(Qt::ItemIsSelectable | Qt::ItemIsEnabled);
1544 
1545     QTableWidgetItem *startupCell = new QTableWidgetItem();
1546     queueTable->setItem(row, static_cast<int>(SCHEDCOL_STARTTIME), startupCell);
1547     startupCell->setTextAlignment(Qt::AlignHCenter | Qt::AlignVCenter);
1548     startupCell->setFlags(Qt::ItemIsSelectable | Qt::ItemIsEnabled);
1549 
1550     QTableWidgetItem *altitudeCell = new QTableWidgetItem();
1551     queueTable->setItem(row, static_cast<int>(SCHEDCOL_ALTITUDE), altitudeCell);
1552     altitudeCell->setTextAlignment(Qt::AlignHCenter | Qt::AlignVCenter);
1553     altitudeCell->setFlags(Qt::ItemIsSelectable | Qt::ItemIsEnabled);
1554 
1555     QTableWidgetItem *completionCell = new QTableWidgetItem();
1556     queueTable->setItem(row, static_cast<int>(SCHEDCOL_ENDTIME), completionCell);
1557     completionCell->setTextAlignment(Qt::AlignHCenter | Qt::AlignVCenter);
1558     completionCell->setFlags(Qt::ItemIsSelectable | Qt::ItemIsEnabled);
1559 }
1560 
1561 void Scheduler::updateCellStyle(SchedulerJob *job, QTableWidgetItem *cell)
1562 {
1563     QFont font(cell->font());
1564     font.setBold(job->getState() == SCHEDJOB_BUSY);
1565     font.setItalic(job->getState() == SCHEDJOB_BUSY);
1566     cell->setFont(font);
1567 }
1568 
1569 void Scheduler::resetJobEdit()
1570 {
1571     if (jobUnderEdit < 0)
1572         return;
1573 
1574     SchedulerJob * const job = moduleState()->jobs().at(jobUnderEdit);
1575     Q_ASSERT_X(job != nullptr, __FUNCTION__, "Edited job must be valid");
1576 
1577     qCDebug(KSTARS_EKOS_SCHEDULER) << QString("Job '%1' at row #%2 is not longer edited.").arg(job->getName()).arg(
1578                                        jobUnderEdit + 1);
1579 
1580     jobUnderEdit = -1;
1581 
1582     watchJobChanges(false);
1583 
1584     /* Revert apply button to add */
1585     setJobAddApply(true);
1586 
1587     /* Refresh state of job manipulation buttons */
1588     setJobManipulation(true, true);
1589 
1590     /* Restore scheduler operation buttons */
1591     evaluateOnlyB->setEnabled(true);
1592     startB->setEnabled(true);
1593 
1594     watchJobChanges(true);
1595     Q_ASSERT_X(jobUnderEdit == -1, __FUNCTION__, "No more edited/selected job after exiting edit mode");
1596 }
1597 
1598 void Scheduler::removeJob()
1599 {
1600     int currentRow = moduleState()->currentPosition();
1601 
1602     watchJobChanges(false);
1603     if (moduleState()->removeJob(currentRow) == false)
1604         return;
1605 
1606     /* removing the job succeeded, update UI */
1607     /* Remove the job from the table */
1608     queueTable->removeRow(currentRow);
1609 
1610     /* If there are no job rows left, update UI buttons */
1611     if (queueTable->rowCount() == 0)
1612     {
1613         setJobManipulation(false, false);
1614         evaluateOnlyB->setEnabled(false);
1615         queueSaveAsB->setEnabled(false);
1616         queueSaveB->setEnabled(false);
1617         startB->setEnabled(false);
1618         pauseB->setEnabled(false);
1619     }
1620 
1621     // Otherwise, clear the selection, leave the UI values holding the values of the removed job.
1622     // The position in the job list, where the job has been removed from, is still held in the module state.
1623     // This leaves the option directly adding the old values reverting the deletion.
1624     else
1625         queueTable->clearSelection();
1626 
1627     /* If needed, reset edit mode to clean up UI */
1628     if (jobUnderEdit >= 0)
1629         resetJobEdit();
1630 
1631     watchJobChanges(true);
1632     process()->evaluateJobs(true);
1633     emit jobsUpdated(moduleState()->getJSONJobs());
1634     updateJobTable();
1635     // disable moving and deleting, since selection is cleared
1636     setJobManipulation(false, false);
1637 }
1638 
1639 void Scheduler::removeOneJob(int index)
1640 {
1641     moduleState()->setCurrentPosition(index);
1642     removeJob();
1643 }
1644 void Scheduler::toggleScheduler()
1645 {
1646     if (moduleState()->schedulerState() == SCHEDULER_RUNNING)
1647     {
1648         moduleState()->disablePreemptiveShutdown();
1649         stop();
1650     }
1651     else
1652         start();
1653 }
1654 
1655 void Scheduler::stop()
1656 {
1657     process()->stopScheduler();
1658 }
1659 
1660 void Scheduler::pause()
1661 {
1662     moduleState()->setSchedulerState(SCHEDULER_PAUSED);
1663     emit newStatus(moduleState()->schedulerState());
1664     appendLogText(i18n("Scheduler pause planned..."));
1665     pauseB->setEnabled(false);
1666 
1667     startB->setIcon(QIcon::fromTheme("media-playback-start"));
1668     startB->setToolTip(i18n("Resume Scheduler"));
1669 }
1670 
1671 void Scheduler::syncGreedyParams()
1672 {
1673     process()->getGreedyScheduler()->setParams(
1674         errorHandlingRestartImmediatelyButton->isChecked(),
1675         errorHandlingRestartQueueButton->isChecked(),
1676         errorHandlingRescheduleErrorsCB->isChecked(),
1677         errorHandlingStrategyDelay->value(),
1678         errorHandlingStrategyDelay->value());
1679 }
1680 
1681 void Scheduler::handleShutdownStarted()
1682 {
1683     KSNotification::event(QLatin1String("ObservatoryShutdown"), i18n("Observatory is in the shutdown process"),
1684                           KSNotification::Scheduler);
1685     weatherLabel->hide();
1686 }
1687 
1688 void Ekos::Scheduler::changeSleepLabel(QString text, bool show)
1689 {
1690     sleepLabel->setToolTip(text);
1691     if (show)
1692         sleepLabel->show();
1693     else
1694         sleepLabel->hide();
1695 }
1696 
1697 void Scheduler::schedulerStopped()
1698 {
1699     TEST_PRINT(stderr, "%d Setting %s\n", __LINE__, timerStr(RUN_NOTHING).toLatin1().data());
1700 
1701     // Update job table rows for aborted ones (the others remain unchanged in their state)
1702     bool wasAborted = false;
1703     for (auto &oneJob : moduleState()->jobs())
1704     {
1705         if (oneJob->getState() == SCHEDJOB_ABORTED)
1706         {
1707             updateJobTable(oneJob);
1708             wasAborted = true;
1709         }
1710     }
1711 
1712     if (wasAborted)
1713         KSNotification::event(QLatin1String("SchedulerAborted"), i18n("Scheduler aborted."), KSNotification::Scheduler,
1714                               KSNotification::Alert);
1715 
1716     startupB->setEnabled(true);
1717     shutdownB->setEnabled(true);
1718 
1719     // If soft shutdown, we return for now
1720     if (moduleState()->preemptiveShutdown())
1721     {
1722         changeSleepLabel(i18n("Scheduler is in shutdown until next job is ready"));
1723         pi->stopAnimation();
1724         return;
1725     }
1726 
1727     changeSleepLabel("", false);
1728 
1729     startB->setIcon(QIcon::fromTheme("media-playback-start"));
1730     startB->setToolTip(i18n("Start Scheduler"));
1731     pauseB->setEnabled(false);
1732     //startB->setText("Start Scheduler");
1733 
1734     queueLoadB->setEnabled(true);
1735     queueAppendB->setEnabled(true);
1736     addToQueueB->setEnabled(true);
1737     setJobManipulation(false, false);
1738     //mosaicB->setEnabled(true);
1739     evaluateOnlyB->setEnabled(true);
1740 }
1741 
1742 
1743 void Scheduler::load(bool clearQueue, const QString &filename)
1744 {
1745     QUrl fileURL;
1746 
1747     if (filename.isEmpty())
1748         fileURL = QFileDialog::getOpenFileUrl(Ekos::Manager::Instance(), i18nc("@title:window", "Open Ekos Scheduler List"),
1749                                               dirPath,
1750                                               "Ekos Scheduler List (*.esl)");
1751     else fileURL.setUrl(filename);
1752 
1753     if (fileURL.isEmpty())
1754         return;
1755 
1756     if (fileURL.isValid() == false)
1757     {
1758         QString message = i18n("Invalid URL: %1", fileURL.toLocalFile());
1759         KSNotification::sorry(message, i18n("Invalid URL"));
1760         return;
1761     }
1762 
1763     dirPath = QUrl(fileURL.url(QUrl::RemoveFilename));
1764 
1765     if (clearQueue)
1766         removeAllJobs();
1767     // remember toe number of rows to select the first one appended
1768     const int row = moduleState()->jobs().count();
1769 
1770     // do not update while appending
1771     watchJobChanges(false);
1772     // try appending the jobs from the file to the job list
1773     const bool success = process()->appendEkosScheduleList(fileURL.toLocalFile());
1774     // turn on whatching
1775     watchJobChanges(true);
1776 
1777     if (success)
1778     {
1779         // select the first appended row (if any was added)
1780         if (moduleState()->jobs().count() > row)
1781             moduleState()->setCurrentPosition(row);
1782 
1783         /* Run a job idle evaluation after a successful load */
1784         startJobEvaluation();
1785     }
1786 }
1787 
1788 void Scheduler::removeAllJobs()
1789 {
1790     if (jobUnderEdit >= 0)
1791         resetJobEdit();
1792 
1793     while (queueTable->rowCount() > 0)
1794         queueTable->removeRow(0);
1795 
1796     qDeleteAll(moduleState()->jobs());
1797     moduleState()->mutlableJobs().clear();
1798 }
1799 
1800 bool Scheduler::loadScheduler(const QString &fileURL)
1801 {
1802     removeAllJobs();
1803     return process()->appendEkosScheduleList(fileURL);
1804 }
1805 
1806 void Scheduler::saveAs()
1807 {
1808     schedulerURL.clear();
1809     save();
1810 }
1811 
1812 void Scheduler::save()
1813 {
1814     QUrl backupCurrent = schedulerURL;
1815 
1816     if (schedulerURL.toLocalFile().startsWith(QLatin1String("/tmp/")) || schedulerURL.toLocalFile().contains("/Temp"))
1817         schedulerURL.clear();
1818 
1819     // If no changes made, return.
1820     if (moduleState()->dirty() == false && !schedulerURL.isEmpty())
1821         return;
1822 
1823     if (schedulerURL.isEmpty())
1824     {
1825         schedulerURL =
1826             QFileDialog::getSaveFileUrl(Ekos::Manager::Instance(), i18nc("@title:window", "Save Ekos Scheduler List"), dirPath,
1827                                         "Ekos Scheduler List (*.esl)");
1828         // if user presses cancel
1829         if (schedulerURL.isEmpty())
1830         {
1831             schedulerURL = backupCurrent;
1832             return;
1833         }
1834 
1835         dirPath = QUrl(schedulerURL.url(QUrl::RemoveFilename));
1836 
1837         if (schedulerURL.toLocalFile().contains('.') == 0)
1838             schedulerURL.setPath(schedulerURL.toLocalFile() + ".esl");
1839     }
1840 
1841     if (schedulerURL.isValid())
1842     {
1843         if ((saveScheduler(schedulerURL)) == false)
1844         {
1845             KSNotification::error(i18n("Failed to save scheduler list"), i18n("Save"));
1846             return;
1847         }
1848 
1849         // update save button tool tip
1850         queueSaveB->setToolTip("Save schedule to " + schedulerURL.fileName());
1851     }
1852     else
1853     {
1854         QString message = i18n("Invalid URL: %1", schedulerURL.url());
1855         KSNotification::sorry(message, i18n("Invalid URL"));
1856     }
1857 }
1858 
1859 void Scheduler::checkJobInputComplete()
1860 {
1861     // For object selection, all fields must be filled
1862     bool const nameSelectionOK = !raBox->isEmpty()  && !decBox->isEmpty() && !nameEdit->text().isEmpty();
1863 
1864     // For FITS selection, only the name and fits URL should be filled.
1865     bool const fitsSelectionOK = !nameEdit->text().isEmpty() && !fitsURL.isEmpty();
1866 
1867     // Sequence selection is required
1868     bool const seqSelectionOK = !sequenceEdit->text().isEmpty();
1869 
1870     // Finally, adding is allowed upon object/FITS and sequence selection
1871     bool const addingOK = (nameSelectionOK || fitsSelectionOK) && seqSelectionOK;
1872 
1873     addToQueueB->setEnabled(addingOK);
1874 }
1875 
1876 void Scheduler::setDirty()
1877 {
1878     // check if all fields are filled to allow adding a job
1879     checkJobInputComplete();
1880 
1881     // ignore changes that are a result of syncGUIToJob() or syncGUIToGeneralSettings()
1882     if (jobUnderEdit < 0)
1883         return;
1884 
1885     moduleState()->setDirty(true);
1886 
1887     if (sender() == startupProcedureButtonGroup || sender() == shutdownProcedureGroup)
1888         return;
1889 
1890     // update state
1891     if (sender() == schedulerStartupScript)
1892         moduleState()->setStartupScriptURL(QUrl::fromUserInput(schedulerStartupScript->text()));
1893     else if (sender() == schedulerShutdownScript)
1894         moduleState()->setShutdownScriptURL(QUrl::fromUserInput(schedulerShutdownScript->text()));
1895 }
1896 
1897 void Scheduler::startJobEvaluation()
1898 {
1899     // Reset all jobs
1900     // other states too?
1901     if (SCHEDULER_RUNNING != moduleState()->schedulerState())
1902         process()->resetJobs();
1903 
1904     // reset the iterations counter
1905     moduleState()->resetSequenceExecutionCounter();
1906 
1907     // And evaluate all pending jobs per the conditions set in each
1908     process()->evaluateJobs(true);
1909 }
1910 
1911 void Scheduler::sortJobsPerAltitude()
1912 {
1913     // We require a first job to sort, so bail out if list is empty
1914     if (moduleState()->jobs().isEmpty())
1915         return;
1916 
1917     // Don't reset current job
1918     // setCurrentJob(nullptr);
1919 
1920     // Don't reset scheduler jobs startup times before sorting - we need the first job startup time
1921 
1922     // Sort by startup time, using the first job time as reference for altitude calculations
1923     using namespace std::placeholders;
1924     QList<SchedulerJob*> sortedJobs = moduleState()->jobs();
1925     std::stable_sort(sortedJobs.begin() + 1, sortedJobs.end(),
1926                      std::bind(SchedulerJob::decreasingAltitudeOrder, _1, _2, moduleState()->jobs().first()->getStartupTime()));
1927 
1928     // If order changed, reset and re-evaluate
1929     if (reorderJobs(sortedJobs))
1930     {
1931         for (SchedulerJob * job : moduleState()->jobs())
1932             job->reset();
1933 
1934         process()->evaluateJobs(true);
1935     }
1936 }
1937 
1938 void Scheduler::resumeCheckStatus()
1939 {
1940     disconnect(this, &Scheduler::weatherChanged, this, &Scheduler::resumeCheckStatus);
1941     TEST_PRINT(stderr, "%d Setting %s\n", __LINE__, timerStr(RUN_SCHEDULER).toLatin1().data());
1942     moduleState()->setupNextIteration(RUN_SCHEDULER);
1943 }
1944 
1945 ErrorHandlingStrategy Scheduler::getErrorHandlingStrategy()
1946 {
1947     // The UI holds the state
1948     if (errorHandlingRestartQueueButton->isChecked())
1949         return ERROR_RESTART_AFTER_TERMINATION;
1950     else if (errorHandlingRestartImmediatelyButton->isChecked())
1951         return ERROR_RESTART_IMMEDIATELY;
1952     else
1953         return ERROR_DONT_RESTART;
1954 }
1955 
1956 void Scheduler::setErrorHandlingStrategy(ErrorHandlingStrategy strategy)
1957 {
1958     errorHandlingStrategyDelay->setEnabled(strategy != ERROR_DONT_RESTART);
1959 
1960     switch (strategy)
1961     {
1962         case ERROR_RESTART_AFTER_TERMINATION:
1963             errorHandlingRestartQueueButton->setChecked(true);
1964             break;
1965         case ERROR_RESTART_IMMEDIATELY:
1966             errorHandlingRestartImmediatelyButton->setChecked(true);
1967             break;
1968         default:
1969             errorHandlingDontRestartButton->setChecked(true);
1970             break;
1971     }
1972 }
1973 
1974 // Can't use a SchedulerAlgorithm type for the arg here
1975 // as the compiler is unhappy connecting the signals currentIndexChanged(int)
1976 // or activated(int) to an enum.
1977 void Scheduler::setAlgorithm(int algIndex)
1978 {
1979     if (algIndex != ALGORITHM_GREEDY)
1980     {
1981         appendLogText(i18n("Warning: The Classic scheduler algorithm has been retired. Switching you to the Greedy algorithm."));
1982         algIndex = ALGORITHM_GREEDY;
1983     }
1984     Options::setSchedulerAlgorithm(algIndex);
1985 
1986     groupLabel->setDisabled(false);
1987     groupEdit->setDisabled(false);
1988     queueTable->model()->setHeaderData(START_TIME_COLUMN, Qt::Horizontal, tr("Next Start"));
1989     queueTable->model()->setHeaderData(END_TIME_COLUMN, Qt::Horizontal, tr("Next End"));
1990     queueTable->horizontalHeader()->setSectionResizeMode(QHeaderView::ResizeToContents);
1991 }
1992 
1993 void Scheduler::resetAllJobs()
1994 {
1995     if (moduleState()->schedulerState() == SCHEDULER_RUNNING)
1996         return;
1997 
1998     // Reset capture count of all jobs before re-evaluating
1999     foreach (SchedulerJob *job, moduleState()->jobs())
2000         job->setCompletedCount(0);
2001 
2002     // Evaluate all jobs, this refreshes storage and resets job states
2003     startJobEvaluation();
2004 }
2005 
2006 void Scheduler::checkTwilightWarning(bool enabled)
2007 {
2008     if (enabled)
2009         return;
2010 
2011     if (KMessageBox::warningContinueCancel(
2012                 nullptr,
2013                 i18n("Turning off astronomial twilight check may cause the observatory "
2014                      "to run during daylight. This can cause irreversible damage to your equipment!"),
2015                 i18n("Astronomial Twilight Warning"), KStandardGuiItem::cont(), KStandardGuiItem::cancel(),
2016                 "astronomical_twilight_warning") == KMessageBox::Cancel)
2017     {
2018         schedulerTwilight->setChecked(true);
2019     }
2020 }
2021 
2022 
2023 void Scheduler::updateProfiles()
2024 {
2025     schedulerProfileCombo->blockSignals(true);
2026     schedulerProfileCombo->clear();
2027     schedulerProfileCombo->addItems(moduleState()->profiles());
2028     schedulerProfileCombo->setCurrentText(moduleState()->currentProfile());
2029     schedulerProfileCombo->blockSignals(false);
2030 }
2031 
2032 void Scheduler::updateJobStageUI(SchedulerJobStage stage)
2033 {
2034     /* Translated string cache - overkill, probably, and doesn't warn about missing enums like switch/case should ; also, not thread-safe */
2035     /* FIXME: this should work with a static initializer in C++11, but QT versions are touchy on this, and perhaps i18n can't be used? */
2036     static QMap<SchedulerJobStage, QString> stageStrings;
2037     static QString stageStringUnknown;
2038     if (stageStrings.isEmpty())
2039     {
2040         stageStrings[SCHEDSTAGE_IDLE] = i18n("Idle");
2041         stageStrings[SCHEDSTAGE_SLEWING] = i18n("Slewing");
2042         stageStrings[SCHEDSTAGE_SLEW_COMPLETE] = i18n("Slew complete");
2043         stageStrings[SCHEDSTAGE_FOCUSING] =
2044             stageStrings[SCHEDSTAGE_POSTALIGN_FOCUSING] = i18n("Focusing");
2045         stageStrings[SCHEDSTAGE_FOCUS_COMPLETE] =
2046             stageStrings[SCHEDSTAGE_POSTALIGN_FOCUSING_COMPLETE ] = i18n("Focus complete");
2047         stageStrings[SCHEDSTAGE_ALIGNING] = i18n("Aligning");
2048         stageStrings[SCHEDSTAGE_ALIGN_COMPLETE] = i18n("Align complete");
2049         stageStrings[SCHEDSTAGE_RESLEWING] = i18n("Repositioning");
2050         stageStrings[SCHEDSTAGE_RESLEWING_COMPLETE] = i18n("Repositioning complete");
2051         /*stageStrings[SCHEDSTAGE_CALIBRATING] = i18n("Calibrating");*/
2052         stageStrings[SCHEDSTAGE_GUIDING] = i18n("Guiding");
2053         stageStrings[SCHEDSTAGE_GUIDING_COMPLETE] = i18n("Guiding complete");
2054         stageStrings[SCHEDSTAGE_CAPTURING] = i18n("Capturing");
2055         stageStringUnknown = i18n("Unknown");
2056     }
2057 
2058     if (activeJob() == nullptr)
2059         jobStatus->setText(stageStrings[SCHEDSTAGE_IDLE]);
2060     else
2061         jobStatus->setText(QString("%1: %2").arg(activeJob()->getName(),
2062                            stageStrings.value(stage, stageStringUnknown)));
2063 
2064 }
2065 
2066 
2067 void Scheduler::setINDICommunicationStatus(Ekos::CommunicationStatus status)
2068 {
2069     TEST_PRINT(stderr, "sch%d @@@dbus(%s): %d\n", __LINE__, "ekosInterface:indiStatusChanged", status);
2070     qCDebug(KSTARS_EKOS_SCHEDULER) << "Scheduler INDI status is" << status;
2071 
2072     moduleState()->setIndiCommunicationStatus(status);
2073 }
2074 
2075 void Scheduler::setEkosCommunicationStatus(Ekos::CommunicationStatus status)
2076 {
2077     TEST_PRINT(stderr, "sch%d @@@dbus(%s): %d\n", __LINE__, "ekosInterface:ekosStatusChanged", status);
2078     qCDebug(KSTARS_EKOS_SCHEDULER) << "Scheduler Ekos status is" << status;
2079 
2080     moduleState()->setEkosCommunicationStatus(status);
2081 }
2082 
2083 void Scheduler::simClockScaleChanged(float newScale)
2084 {
2085     if (moduleState()->currentlySleeping())
2086     {
2087         QTime const remainingTimeMs = QTime::fromMSecsSinceStartOfDay(std::lround(static_cast<double>
2088                                       (moduleState()->iterationTimer().remainingTime())
2089                                       * KStarsData::Instance()->clock()->scale()
2090                                       / newScale));
2091         appendLogText(i18n("Sleeping for %1 on simulation clock update until next observation job is ready...",
2092                            remainingTimeMs.toString("hh:mm:ss")));
2093         moduleState()->iterationTimer().stop();
2094         moduleState()->iterationTimer().start(remainingTimeMs.msecsSinceStartOfDay());
2095     }
2096 }
2097 
2098 void Scheduler::simClockTimeChanged()
2099 {
2100     moduleState()->calculateDawnDusk();
2101     updateNightTime();
2102 
2103     // If the Scheduler is not running, reset all jobs and re-evaluate from a new current start point
2104     if (SCHEDULER_RUNNING != moduleState()->schedulerState())
2105     {
2106         startJobEvaluation();
2107     }
2108 }
2109 
2110 void Scheduler::registerNewDevice(const QString &name, int interface)
2111 {
2112     Q_UNUSED(name)
2113 
2114     if (interface & INDI::BaseDevice::DOME_INTERFACE)
2115     {
2116         QList<QVariant> dbusargs;
2117         dbusargs.append(INDI::BaseDevice::DOME_INTERFACE);
2118         QDBusReply<QStringList> paths = process()->indiInterface()->callWithArgumentList(QDBus::AutoDetect, "getDevicesPaths",
2119                                         dbusargs);
2120         if (paths.error().type() == QDBusError::NoError)
2121         {
2122             // Select last device in case a restarted caused multiple instances in the tree
2123             setDomePathString(paths.value().last());
2124             delete process()->domeInterface();
2125             process()->setDomeInterface(new QDBusInterface(kstarsInterfaceString, domePathString, domeInterfaceString,
2126                                         QDBusConnection::sessionBus(), this));
2127             connect(process()->domeInterface(), SIGNAL(ready()), this, SLOT(syncProperties()));
2128             checkInterfaceReady(process()->domeInterface());
2129         }
2130     }
2131 
2132     if (interface & INDI::BaseDevice::WEATHER_INTERFACE)
2133     {
2134         QList<QVariant> dbusargs;
2135         dbusargs.append(INDI::BaseDevice::WEATHER_INTERFACE);
2136         QDBusReply<QStringList> paths = process()->indiInterface()->callWithArgumentList(QDBus::AutoDetect, "getDevicesPaths",
2137                                         dbusargs);
2138         if (paths.error().type() == QDBusError::NoError)
2139         {
2140             // Select last device in case a restarted caused multiple instances in the tree
2141             setWeatherPathString(paths.value().last());
2142             delete process()->weatherInterface();
2143             process()->setWeatherInterface(new QDBusInterface(kstarsInterfaceString, weatherPathString, weatherInterfaceString,
2144                                            QDBusConnection::sessionBus(), this));
2145             connect(process()->weatherInterface(), SIGNAL(ready()), this, SLOT(syncProperties()));
2146             connect(process()->weatherInterface(), SIGNAL(newStatus(ISD::Weather::Status)), this,
2147                     SLOT(setWeatherStatus(ISD::Weather::Status)));
2148             checkInterfaceReady(process()->weatherInterface());
2149         }
2150     }
2151 
2152     if (interface & INDI::BaseDevice::DUSTCAP_INTERFACE)
2153     {
2154         QList<QVariant> dbusargs;
2155         dbusargs.append(INDI::BaseDevice::DUSTCAP_INTERFACE);
2156         QDBusReply<QStringList> paths = process()->indiInterface()->callWithArgumentList(QDBus::AutoDetect, "getDevicesPaths",
2157                                         dbusargs);
2158         if (paths.error().type() == QDBusError::NoError)
2159         {
2160             // Select last device in case a restarted caused multiple instances in the tree
2161             setDustCapPathString(paths.value().last());
2162             delete process()->capInterface();
2163             process()->setCapInterface(new QDBusInterface(kstarsInterfaceString, dustCapPathString, dustCapInterfaceString,
2164                                        QDBusConnection::sessionBus(), this));
2165             connect(process()->capInterface(), SIGNAL(ready()), this, SLOT(syncProperties()));
2166             checkInterfaceReady(process()->capInterface());
2167         }
2168     }
2169 }
2170 
2171 void Scheduler::registerNewModule(const QString &name)
2172 {
2173     qCDebug(KSTARS_EKOS_SCHEDULER) << "Registering new Module (" << name << ")";
2174 
2175     if (name == "Focus")
2176     {
2177         delete process()->focusInterface();
2178         process()->setFocusInterface(new QDBusInterface(kstarsInterfaceString, focusPathString, focusInterfaceString,
2179                                      QDBusConnection::sessionBus(), this));
2180         connect(process()->focusInterface(), SIGNAL(newStatus(Ekos::FocusState)), this,
2181                 SLOT(setFocusStatus(Ekos::FocusState)), Qt::UniqueConnection);
2182     }
2183     else if (name == "Capture")
2184     {
2185         delete process()->captureInterface();
2186         process()->setCaptureInterface(new QDBusInterface(kstarsInterfaceString, capturePathString, captureInterfaceString,
2187                                        QDBusConnection::sessionBus(), this));
2188 
2189         connect(process()->captureInterface(), SIGNAL(ready()), this, SLOT(syncProperties()));
2190         connect(process()->captureInterface(), SIGNAL(newStatus(Ekos::CaptureState)), this,
2191                 SLOT(setCaptureStatus(Ekos::CaptureState)),
2192                 Qt::UniqueConnection);
2193         connect(process()->captureInterface(), SIGNAL(captureComplete(QVariantMap)), this, SLOT(setCaptureComplete(QVariantMap)),
2194                 Qt::UniqueConnection);
2195         checkInterfaceReady(process()->captureInterface());
2196     }
2197     else if (name == "Mount")
2198     {
2199         delete process()->mountInterface();
2200         process()->setMountInterface(new QDBusInterface(kstarsInterfaceString, mountPathString, mountInterfaceString,
2201                                      QDBusConnection::sessionBus(), this));
2202 
2203         connect(process()->mountInterface(), SIGNAL(ready()), this, SLOT(syncProperties()));
2204         connect(process()->mountInterface(), SIGNAL(newStatus(ISD::Mount::Status)), this, SLOT(setMountStatus(ISD::Mount::Status)),
2205                 Qt::UniqueConnection);
2206 
2207         checkInterfaceReady(process()->mountInterface());
2208     }
2209     else if (name == "Align")
2210     {
2211         delete process()->alignInterface();
2212         process()->setAlignInterface(new QDBusInterface(kstarsInterfaceString, alignPathString, alignInterfaceString,
2213                                      QDBusConnection::sessionBus(), this));
2214         connect(process()->alignInterface(), SIGNAL(newStatus(Ekos::AlignState)), this, SLOT(setAlignStatus(Ekos::AlignState)),
2215                 Qt::UniqueConnection);
2216     }
2217     else if (name == "Guide")
2218     {
2219         delete process()->guideInterface();
2220         process()->setGuideInterface(new QDBusInterface(kstarsInterfaceString, guidePathString, guideInterfaceString,
2221                                      QDBusConnection::sessionBus(), this));
2222         connect(process()->guideInterface(), SIGNAL(newStatus(Ekos::GuideState)), this,
2223                 SLOT(setGuideStatus(Ekos::GuideState)), Qt::UniqueConnection);
2224     }
2225 }
2226 
2227 void Scheduler::syncProperties()
2228 {
2229     QDBusInterface *iface = qobject_cast<QDBusInterface*>(sender());
2230 
2231     if (iface == process()->mountInterface())
2232     {
2233         TEST_PRINT(stderr, "sch%d @@@dbus(%s): %s\n", __LINE__, "mountInterface:property", "canPark");
2234         QVariant canMountPark = process()->mountInterface()->property("canPark");
2235         TEST_PRINT(stderr, "  @@@dbus received %s\n", !canMountPark.isValid() ? "invalid" : (canMountPark.toBool() ? "T" : "F"));
2236 
2237         schedulerUnparkMount->setEnabled(canMountPark.toBool());
2238         schedulerParkMount->setEnabled(canMountPark.toBool());
2239         moduleState()->setMountReady(true);
2240     }
2241     else if (iface == process()->capInterface())
2242     {
2243         TEST_PRINT(stderr, "sch%d @@@dbus(%s): %s\n", __LINE__, "dustCapInterface:property", "canPark");
2244         QVariant canCapPark = process()->capInterface()->property("canPark");
2245         TEST_PRINT(stderr, "  @@@dbus received %s\n", !canCapPark.isValid() ? "invalid" : (canCapPark.toBool() ? "T" : "F"));
2246 
2247         if (canCapPark.isValid())
2248         {
2249             schedulerCloseDustCover->setEnabled(canCapPark.toBool());
2250             schedulerOpenDustCover->setEnabled(canCapPark.toBool());
2251             moduleState()->setCapReady(true);
2252         }
2253         else
2254         {
2255             schedulerCloseDustCover->setEnabled(false);
2256             schedulerOpenDustCover->setEnabled(false);
2257         }
2258     }
2259     else if (iface == process()->domeInterface())
2260     {
2261         TEST_PRINT(stderr, "sch%d @@@dbus(%s): %s\n", __LINE__, "domeInterface:property", "canPark");
2262         QVariant canDomePark = process()->domeInterface()->property("canPark");
2263         TEST_PRINT(stderr, "  @@@dbus received %s\n", !canDomePark.isValid() ? "invalid" : (canDomePark.toBool() ? "T" : "F"));
2264 
2265         if (canDomePark.isValid())
2266         {
2267             schedulerParkDome->setEnabled(canDomePark.toBool());
2268             schedulerUnparkDome->setEnabled(canDomePark.toBool());
2269             moduleState()->setDomeReady(true);
2270         }
2271         else
2272         {
2273             schedulerParkDome->setEnabled(false);
2274             schedulerUnparkDome->setEnabled(false);
2275         }
2276     }
2277     else if (iface == process()->weatherInterface())
2278     {
2279         QVariant status = process()->weatherInterface()->property("status");
2280         if (status.isValid())
2281         {
2282             schedulerWeather->setEnabled(true);
2283             setWeatherStatus(static_cast<ISD::Weather::Status>(status.toInt()));
2284         }
2285         else
2286             schedulerWeather->setEnabled(false);
2287     }
2288     else if (iface == process()->captureInterface())
2289     {
2290         TEST_PRINT(stderr, "sch%d @@@dbus(%s): %s\n", __LINE__, "captureInterface:property", "coolerControl");
2291         QVariant hasCoolerControl = process()->captureInterface()->property("coolerControl");
2292         TEST_PRINT(stderr, "  @@@dbus received %s\n",
2293                    !hasCoolerControl.isValid() ? "invalid" : (hasCoolerControl.toBool() ? "T" : "F"));
2294         schedulerWarmCCD->setEnabled(hasCoolerControl.toBool());
2295         moduleState()->setCaptureReady(true);
2296     }
2297 }
2298 
2299 void Scheduler::checkInterfaceReady(QDBusInterface *iface)
2300 {
2301     if (iface == process()->mountInterface())
2302     {
2303         QVariant canMountPark = process()->mountInterface()->property("canPark");
2304         if (canMountPark.isValid())
2305         {
2306             schedulerUnparkMount->setEnabled(canMountPark.toBool());
2307             schedulerParkMount->setEnabled(canMountPark.toBool());
2308             moduleState()->setMountReady(true);
2309         }
2310     }
2311     else if (iface == process()->capInterface())
2312     {
2313         QVariant canCapPark = process()->capInterface()->property("canPark");
2314         if (canCapPark.isValid())
2315         {
2316             schedulerCloseDustCover->setEnabled(canCapPark.toBool());
2317             schedulerOpenDustCover->setEnabled(canCapPark.toBool());
2318             moduleState()->setCapReady(true);
2319         }
2320         else
2321         {
2322             schedulerCloseDustCover->setEnabled(false);
2323             schedulerOpenDustCover->setEnabled(false);
2324         }
2325     }
2326     else if (iface == process()->weatherInterface())
2327     {
2328         QVariant status = process()->weatherInterface()->property("status");
2329         if (status.isValid())
2330         {
2331             schedulerWeather->setEnabled(true);
2332             setWeatherStatus(static_cast<ISD::Weather::Status>(status.toInt()));
2333         }
2334         else
2335             schedulerWeather->setEnabled(false);
2336     }
2337     else if (iface == process()->domeInterface())
2338     {
2339         QVariant canDomePark = process()->domeInterface()->property("canPark");
2340         if (canDomePark.isValid())
2341         {
2342             schedulerUnparkDome->setEnabled(canDomePark.toBool());
2343             schedulerParkDome->setEnabled(canDomePark.toBool());
2344             moduleState()->setDomeReady(true);
2345         }
2346     }
2347     else if (iface == process()->captureInterface())
2348     {
2349         QVariant hasCoolerControl = process()->captureInterface()->property("coolerControl");
2350         if (hasCoolerControl.isValid())
2351         {
2352             schedulerWarmCCD->setEnabled(hasCoolerControl.toBool());
2353             moduleState()->setCaptureReady(true);
2354         }
2355     }
2356 }
2357 
2358 void Scheduler::setAlignStatus(AlignState status)
2359 {
2360     process()->setAlignStatus(status);
2361 }
2362 
2363 void Scheduler::setGuideStatus(GuideState status)
2364 {
2365     process()->setGuideStatus(status);
2366 }
2367 
2368 void Scheduler::setCaptureStatus(Ekos::CaptureState status)
2369 {
2370     TEST_PRINT(stderr, "sch%d @@@setCaptureStatus(%d) %s\n", __LINE__, static_cast<int>(status),
2371                (activeJob() == nullptr) ? "IGNORED" : "OK");
2372     if (activeJob() == nullptr)
2373         return;
2374 
2375     qCDebug(KSTARS_EKOS_SCHEDULER) << "Capture State" << Ekos::getCaptureStatusString(status);
2376 
2377     /* If current job is scheduled and has not started yet, wait */
2378     if (SCHEDJOB_SCHEDULED == activeJob()->getState())
2379     {
2380         QDateTime const now = SchedulerModuleState::getLocalTime();
2381         if (now < activeJob()->getStartupTime())
2382             return;
2383     }
2384 
2385     if (activeJob()->getStage() == SCHEDSTAGE_CAPTURING)
2386     {
2387         if (status == Ekos::CAPTURE_PROGRESS && (activeJob()->getStepPipeline() & SchedulerJob::USE_ALIGN))
2388         {
2389             // JM 2021.09.20
2390             // Re-set target coords in align module
2391             // When capture starts, alignment module automatically rests target coords to mount coords.
2392             // However, we want to keep align module target synced with the scheduler target and not
2393             // the mount coord
2394             const SkyPoint targetCoords = activeJob()->getTargetCoords();
2395             QList<QVariant> targetArgs;
2396             targetArgs << targetCoords.ra0().Hours() << targetCoords.dec0().Degrees();
2397             process()->alignInterface()->callWithArgumentList(QDBus::AutoDetect, "setTargetCoords", targetArgs);
2398         }
2399         else if (status == Ekos::CAPTURE_ABORTED)
2400         {
2401             appendLogText(i18n("Warning: job '%1' failed to capture target.", activeJob()->getName()));
2402 
2403             if (moduleState()->increaseCaptureFailureCount())
2404             {
2405                 // If capture failed due to guiding error, let's try to restart that
2406                 if (activeJob()->getStepPipeline() & SchedulerJob::USE_GUIDE)
2407                 {
2408                     // Check if it is guiding related.
2409                     Ekos::GuideState gStatus = process()->getGuidingStatus();
2410                     if (gStatus == Ekos::GUIDE_ABORTED ||
2411                             gStatus == Ekos::GUIDE_CALIBRATION_ERROR ||
2412                             gStatus == GUIDE_DITHERING_ERROR)
2413                     {
2414                         appendLogText(i18n("Job '%1' is capturing, is restarting its guiding procedure (attempt #%2 of %3).",
2415                                            activeJob()->getName(),
2416                                            moduleState()->captureFailureCount(), moduleState()->maxFailureAttempts()));
2417                         process()->startGuiding(true);
2418                         return;
2419                     }
2420                 }
2421 
2422                 /* FIXME: it's not clear whether it is actually possible to continue capturing when capture fails this way */
2423                 appendLogText(i18n("Warning: job '%1' failed its capture procedure, restarting capture.", activeJob()->getName()));
2424                 process()->startCapture(true);
2425             }
2426             else
2427             {
2428                 /* FIXME: it's not clear whether this situation can be recovered at all */
2429                 appendLogText(i18n("Warning: job '%1' failed its capture procedure, marking aborted.", activeJob()->getName()));
2430                 activeJob()->setState(SCHEDJOB_ABORTED);
2431 
2432                 process()->findNextJob();
2433             }
2434         }
2435         else if (status == Ekos::CAPTURE_COMPLETE)
2436         {
2437             KSNotification::event(QLatin1String("EkosScheduledImagingFinished"),
2438                                   i18n("Ekos job (%1) - Capture finished", activeJob()->getName()), KSNotification::Scheduler);
2439 
2440             activeJob()->setState(SCHEDJOB_COMPLETE);
2441             process()->findNextJob();
2442         }
2443         else if (status == Ekos::CAPTURE_IMAGE_RECEIVED)
2444         {
2445             // We received a new image, but we don't know precisely where so update the storage map and re-estimate job times.
2446             // FIXME: rework this once capture storage is reworked
2447             if (Options::rememberJobProgress())
2448             {
2449                 process()->updateCompletedJobsCount(true);
2450 
2451                 for (const auto &job : moduleState()->jobs())
2452                     SchedulerUtils::estimateJobTime(job, moduleState()->capturedFramesCount(), this);
2453             }
2454             // Else if we don't remember the progress on jobs, increase the completed count for the current job only - no cross-checks
2455             else
2456                 activeJob()->setCompletedCount(activeJob()->getCompletedCount() + 1);
2457 
2458             moduleState()->resetCaptureFailureCount();
2459         }
2460     }
2461 }
2462 
2463 void Scheduler::setFocusStatus(FocusState status)
2464 {
2465     process()->setFocusStatus(status);
2466 }
2467 
2468 void Scheduler::setMountStatus(ISD::Mount::Status status)
2469 {
2470     process()->setMountStatus(status);
2471 }
2472 
2473 void Scheduler::setWeatherStatus(ISD::Weather::Status status)
2474 {
2475     TEST_PRINT(stderr, "sch%d @@@setWeatherStatus(%d)\n", __LINE__, static_cast<int>(status));
2476     ISD::Weather::Status newStatus = status;
2477     QString statusString;
2478 
2479     switch (newStatus)
2480     {
2481         case ISD::Weather::WEATHER_OK:
2482             statusString = i18n("Weather conditions are OK.");
2483             break;
2484 
2485         case ISD::Weather::WEATHER_WARNING:
2486             statusString = i18n("Warning: weather conditions are in the WARNING zone.");
2487             break;
2488 
2489         case ISD::Weather::WEATHER_ALERT:
2490             statusString = i18n("Caution: weather conditions are in the DANGER zone!");
2491             break;
2492 
2493         default:
2494             break;
2495     }
2496 
2497     if (newStatus != moduleState()->weatherStatus())
2498     {
2499         moduleState()->setWeatherStatus(newStatus);
2500 
2501         qCDebug(KSTARS_EKOS_SCHEDULER) << statusString;
2502 
2503         if (moduleState()->weatherStatus() == ISD::Weather::WEATHER_OK)
2504             weatherLabel->setPixmap(
2505                 QIcon::fromTheme("security-high")
2506                 .pixmap(QSize(32, 32)));
2507         else if (moduleState()->weatherStatus() == ISD::Weather::WEATHER_WARNING)
2508         {
2509             weatherLabel->setPixmap(
2510                 QIcon::fromTheme("security-medium")
2511                 .pixmap(QSize(32, 32)));
2512             KSNotification::event(QLatin1String("WeatherWarning"), i18n("Weather conditions in warning zone"),
2513                                   KSNotification::Scheduler, KSNotification::Warn);
2514         }
2515         else if (moduleState()->weatherStatus() == ISD::Weather::WEATHER_ALERT)
2516         {
2517             weatherLabel->setPixmap(
2518                 QIcon::fromTheme("security-low")
2519                 .pixmap(QSize(32, 32)));
2520             KSNotification::event(QLatin1String("WeatherAlert"),
2521                                   i18n("Weather conditions are critical. Observatory shutdown is imminent"), KSNotification::Scheduler,
2522                                   KSNotification::Alert);
2523         }
2524         else
2525             weatherLabel->setPixmap(QIcon::fromTheme("chronometer")
2526                                     .pixmap(QSize(32, 32)));
2527 
2528         weatherLabel->show();
2529         weatherLabel->setToolTip(statusString);
2530 
2531         appendLogText(statusString);
2532 
2533         emit weatherChanged(moduleState()->weatherStatus());
2534     }
2535 
2536     // Shutdown scheduler if it was started and not already in shutdown
2537     // and if weather checkbox is checked.
2538     if (schedulerWeather->isChecked() && moduleState()->weatherStatus() == ISD::Weather::WEATHER_ALERT
2539             && moduleState()->schedulerState() != Ekos::SCHEDULER_IDLE
2540             && moduleState()->schedulerState() != Ekos::SCHEDULER_SHUTDOWN)
2541     {
2542         appendLogText(i18n("Starting shutdown procedure due to severe weather."));
2543         if (activeJob())
2544         {
2545             activeJob()->setState(SCHEDJOB_ABORTED);
2546             process()->stopCurrentJobAction();
2547         }
2548         process()->checkShutdownState();
2549     }
2550 }
2551 
2552 void Scheduler::handleSchedulerSleeping(bool shutdown, bool sleep)
2553 {
2554     if (shutdown)
2555     {
2556         schedulerWeather->setEnabled(false);
2557         weatherLabel->hide();
2558     }
2559     if (sleep)
2560         changeSleepLabel(i18n("Scheduler is in sleep mode"));
2561 }
2562 
2563 void Scheduler::handleSchedulerStateChanged(SchedulerState newState)
2564 {
2565     switch (newState)
2566     {
2567         case SCHEDULER_RUNNING:
2568             /* Update UI to reflect startup */
2569             pi->startAnimation();
2570             sleepLabel->hide();
2571             startB->setIcon(QIcon::fromTheme("media-playback-stop"));
2572             startB->setToolTip(i18n("Stop Scheduler"));
2573             pauseB->setEnabled(true);
2574             pauseB->setChecked(false);
2575 
2576             /* Disable edit-related buttons */
2577             queueLoadB->setEnabled(false);
2578             setJobManipulation(true, false);
2579             //mosaicB->setEnabled(false);
2580             evaluateOnlyB->setEnabled(false);
2581             startupB->setEnabled(false);
2582             shutdownB->setEnabled(false);
2583             break;
2584 
2585         default:
2586             break;
2587     }
2588     // forward the state chqnge
2589     emit newStatus(newState);
2590 }
2591 
2592 void Scheduler::handleSetPaused()
2593 {
2594     pauseB->setCheckable(true);
2595     pauseB->setChecked(true);
2596 }
2597 
2598 void Scheduler::setCaptureComplete(const QVariantMap &metadata)
2599 {
2600     if (activeJob() &&
2601             activeJob()->getStepPipeline() & SchedulerJob::USE_ALIGN &&
2602             metadata["type"].toInt() == FRAME_LIGHT &&
2603             Options::alignCheckFrequency() > 0 &&
2604             ++m_SolverIteration >= Options::alignCheckFrequency())
2605     {
2606         m_SolverIteration = 0;
2607 
2608         auto filename = metadata["filename"].toString();
2609         auto exposure = metadata["exposure"].toDouble();
2610 
2611         constexpr double minSolverSeconds = 5.0;
2612         double solverTimeout = std::max(exposure - 2, minSolverSeconds);
2613         if (solverTimeout >= minSolverSeconds)
2614         {
2615             auto profiles = getDefaultAlignOptionsProfiles();
2616             auto parameters = profiles.at(Options::solveOptionsProfile());
2617             // Double search radius
2618             parameters.search_radius = parameters.search_radius * 2;
2619             m_Solver.reset(new SolverUtils(parameters, solverTimeout),  &QObject::deleteLater);
2620             connect(m_Solver.get(), &SolverUtils::done, this, &Ekos::Scheduler::solverDone, Qt::UniqueConnection);
2621             //connect(m_Solver.get(), &SolverUtils::newLog, this, &Ekos::Scheduler::appendLogText, Qt::UniqueConnection);
2622 
2623             auto width = metadata["width"].toUInt();
2624             auto height = metadata["height"].toUInt();
2625 
2626             auto lowScale = Options::astrometryImageScaleLow();
2627             auto highScale = Options::astrometryImageScaleHigh();
2628 
2629             // solver utils uses arcsecs per pixel only
2630             if (Options::astrometryImageScaleUnits() == SSolver::DEG_WIDTH)
2631             {
2632                 lowScale = (lowScale * 3600) / std::max(width, height);
2633                 highScale = (highScale * 3600) / std::min(width, height);
2634             }
2635             else if (Options::astrometryImageScaleUnits() == SSolver::ARCMIN_WIDTH)
2636             {
2637                 lowScale = (lowScale * 60) / std::max(width, height);
2638                 highScale = (highScale * 60) / std::min(width, height);
2639             }
2640 
2641             m_Solver->useScale(Options::astrometryUseImageScale(), lowScale, highScale);
2642             m_Solver->usePosition(Options::astrometryUsePosition(), activeJob()->getTargetCoords().ra().Degrees(),
2643                                   activeJob()->getTargetCoords().dec().Degrees());
2644             m_Solver->setHealpix(moduleState()->indexToUse(), moduleState()->healpixToUse());
2645             m_Solver->runSolver(filename);
2646         }
2647     }
2648 }
2649 
2650 void Scheduler::handleJobsUpdated(QJsonArray jobsList)
2651 {
2652     syncGreedyParams();
2653     updateJobTable();
2654 
2655     emit jobsUpdated(jobsList);
2656 }
2657 
2658 void Scheduler::solverDone(bool timedOut, bool success, const FITSImage::Solution &solution, double elapsedSeconds)
2659 {
2660     disconnect(m_Solver.get(), &SolverUtils::done, this, &Ekos::Scheduler::solverDone);
2661 
2662     if (!activeJob())
2663         return;
2664 
2665     QString healpixString = "";
2666     if (moduleState()->indexToUse() != -1 || moduleState()->healpixToUse() != -1)
2667         healpixString = QString("Healpix %1 Index %2").arg(moduleState()->healpixToUse()).arg(moduleState()->indexToUse());
2668 
2669     if (timedOut || !success)
2670     {
2671         // Don't use the previous index and healpix next time we solve.
2672         moduleState()->setIndexToUse(-1);
2673         moduleState()->setHealpixToUse(-1);
2674     }
2675     else
2676     {
2677         int index, healpix;
2678         // Get the index and healpix from the successful solve.
2679         m_Solver->getSolutionHealpix(&index, &healpix);
2680         moduleState()->setIndexToUse(index);
2681         moduleState()->setHealpixToUse(healpix);
2682     }
2683 
2684     if (timedOut)
2685         appendLogText(i18n("Solver timed out: %1s %2", QString("%L1").arg(elapsedSeconds, 0, 'f', 1), healpixString));
2686     else if (!success)
2687         appendLogText(i18n("Solver failed: %1s %2", QString("%L1").arg(elapsedSeconds, 0, 'f', 1), healpixString));
2688     else
2689     {
2690         const double ra = solution.ra;
2691         const double dec = solution.dec;
2692 
2693         const auto target = activeJob()->getTargetCoords();
2694 
2695         SkyPoint alignCoord;
2696         alignCoord.setRA0(ra / 15.0);
2697         alignCoord.setDec0(dec);
2698         alignCoord.apparentCoord(static_cast<long double>(J2000), KStars::Instance()->data()->ut().djd());
2699         alignCoord.EquatorialToHorizontal(KStarsData::Instance()->lst(), KStarsData::Instance()->geo()->lat());
2700         const double diffRa = (alignCoord.ra().deltaAngle(target.ra())).Degrees() * 3600;
2701         const double diffDec = (alignCoord.dec().deltaAngle(target.dec())).Degrees() * 3600;
2702 
2703         // This is an approximation, probably ok for small angles.
2704         const double diffTotal = hypot(diffRa, diffDec);
2705 
2706         // Note--the RA output is in DMS. This is because we're looking at differences in arcseconds
2707         // and HMS coordinates are misleading (one HMS second is really 6 arc-seconds).
2708         qCDebug(KSTARS_EKOS_SCHEDULER) <<
2709                                        QString("Target Distance: %1\" Target (RA: %2 DE: %3) Current (RA: %4 DE: %5) %6 solved in %7s")
2710                                        .arg(QString("%L1").arg(diffTotal, 0, 'f', 0),
2711                                             target.ra().toDMSString(),
2712                                             target.dec().toDMSString(),
2713                                             alignCoord.ra().toDMSString(),
2714                                             alignCoord.dec().toDMSString(),
2715                                             healpixString,
2716                                             QString("%L1").arg(elapsedSeconds, 0, 'f', 2));
2717         emit targetDistance(diffTotal);
2718 
2719         // If we exceed align check threshold, we abort and re-align.
2720         if (diffTotal / 60 > Options::alignCheckThreshold())
2721         {
2722             appendLogText(i18n("Captured frame is %1 arcminutes away from target, re-aligning...", QString::number(diffTotal / 60.0,
2723                                'f', 1)));
2724             process()->stopCurrentJobAction();
2725             process()->startAstrometry();
2726         }
2727     }
2728 }
2729 
2730 
2731 
2732 bool Scheduler::createJobSequence(XMLEle * root, const QString &prefix, const QString &outputDir)
2733 {
2734     return process()->createJobSequence(root, prefix, outputDir);
2735 }
2736 
2737 XMLEle *Scheduler::getSequenceJobRoot(const QString &filename)
2738 {
2739     return process()->getSequenceJobRoot(filename);
2740 }
2741 
2742 bool Scheduler::importMosaic(const QJsonObject &payload)
2743 {
2744     QScopedPointer<FramingAssistantUI> assistant(new FramingAssistantUI());
2745     return assistant->importMosaic(payload);
2746 }
2747 
2748 void Scheduler::startupStateChanged(StartupState state)
2749 {
2750     jobStatus->setText(startupStateString(state));
2751 
2752     switch (moduleState()->startupState())
2753     {
2754         case STARTUP_IDLE:
2755             startupB->setIcon(QIcon::fromTheme("media-playback-start"));
2756             break;
2757         case STARTUP_COMPLETE:
2758             startupB->setIcon(QIcon::fromTheme("media-playback-start"));
2759             appendLogText(i18n("Manual startup procedure completed successfully."));
2760             break;
2761         case STARTUP_ERROR:
2762             startupB->setIcon(QIcon::fromTheme("media-playback-start"));
2763             appendLogText(i18n("Manual startup procedure terminated due to errors."));
2764             break;
2765         default:
2766             // in all other cases startup is running
2767             startupB->setIcon(QIcon::fromTheme("media-playback-stop"));
2768             break;
2769     }
2770 }
2771 void Scheduler::shutdownStateChanged(ShutdownState state)
2772 {
2773     jobStatus->setText(shutdownStateString(state));
2774     if (state == SHUTDOWN_COMPLETE || state == SHUTDOWN_IDLE
2775             || state == SHUTDOWN_ERROR)
2776         shutdownB->setIcon(QIcon::fromTheme("media-playback-start"));
2777     else
2778         shutdownB->setIcon(QIcon::fromTheme("media-playback-stop"));
2779 }
2780 void Scheduler::ekosStateChanged(EkosState state)
2781 {
2782     jobStatus->setText(ekosStateString(state));
2783 }
2784 void Scheduler::indiStateChanged(INDIState state)
2785 {
2786     jobStatus->setText(indiStateString(state));
2787 }
2788 void Scheduler::parkWaitStateChanged(ParkWaitState state)
2789 {
2790     jobStatus->setText(parkWaitStateString(state));
2791 }
2792 
2793 SchedulerJob *Scheduler::activeJob()
2794 {
2795     return moduleState()->activeJob();
2796 }
2797 
2798 Ekos::SchedulerState Scheduler::status()
2799 {
2800     return moduleState()->schedulerState();
2801 }
2802 
2803 bool Scheduler::saveScheduler(const QUrl &fileURL)
2804 {
2805     return process()->saveScheduler(fileURL);
2806 }
2807 
2808 void Scheduler::loadGlobalSettings()
2809 {
2810     QString key;
2811     QVariant value;
2812 
2813     QVariantMap settings;
2814     // All Combo Boxes
2815     for (auto &oneWidget : findChildren<QComboBox*>())
2816     {
2817         key = oneWidget->objectName();
2818         value = Options::self()->property(key.toLatin1());
2819         if (value.isValid())
2820         {
2821             oneWidget->setCurrentText(value.toString());
2822             settings[key] = value;
2823         }
2824         else
2825             qCDebug(KSTARS_EKOS_FOCUS) << "Option" << key << "not found!";
2826     }
2827 
2828     // All Double Spin Boxes
2829     for (auto &oneWidget : findChildren<QDoubleSpinBox*>())
2830     {
2831         key = oneWidget->objectName();
2832         value = Options::self()->property(key.toLatin1());
2833         if (value.isValid())
2834         {
2835             oneWidget->setValue(value.toDouble());
2836             settings[key] = value;
2837         }
2838         else
2839             qCDebug(KSTARS_EKOS_FOCUS) << "Option" << key << "not found!";
2840     }
2841 
2842     // All Spin Boxes
2843     for (auto &oneWidget : findChildren<QSpinBox*>())
2844     {
2845         key = oneWidget->objectName();
2846         value = Options::self()->property(key.toLatin1());
2847         if (value.isValid())
2848         {
2849             oneWidget->setValue(value.toInt());
2850             settings[key] = value;
2851         }
2852         else
2853             qCDebug(KSTARS_EKOS_FOCUS) << "Option" << key << "not found!";
2854     }
2855 
2856     // All Checkboxes
2857     for (auto &oneWidget : findChildren<QCheckBox*>())
2858     {
2859         key = oneWidget->objectName();
2860         value = Options::self()->property(key.toLatin1());
2861         if (value.isValid())
2862         {
2863             oneWidget->setChecked(value.toBool());
2864             settings[key] = value;
2865         }
2866         else
2867             qCDebug(KSTARS_EKOS_FOCUS) << "Option" << key << "not found!";
2868     }
2869 
2870     // All Line Edits
2871     for (auto &oneWidget : findChildren<QLineEdit*>())
2872     {
2873         key = oneWidget->objectName();
2874         value = Options::self()->property(key.toLatin1());
2875         if (value.isValid())
2876         {
2877             oneWidget->setText(value.toString());
2878             settings[key] = value;
2879 
2880             if (key == "sequenceEdit")
2881                 setSequence(value.toString());
2882             else if (key == "schedulerStartupScript")
2883                 moduleState()->setStartupScriptURL(QUrl::fromUserInput(value.toString()));
2884             else if (key == "schedulerShutdownScript")
2885                 moduleState()->setShutdownScriptURL(QUrl::fromUserInput(value.toString()));
2886         }
2887         else
2888             qCDebug(KSTARS_EKOS_FOCUS) << "Option" << key << "not found!";
2889     }
2890 
2891     // All Radio buttons
2892     for (auto &oneWidget : findChildren<QRadioButton*>())
2893     {
2894         key = oneWidget->objectName();
2895         value = Options::self()->property(key.toLatin1());
2896         if (value.isValid())
2897         {
2898             oneWidget->setChecked(value.toBool());
2899             settings[key] = value;
2900         }
2901     }
2902 
2903     // All QDateTime edits
2904     for (auto &oneWidget : findChildren<QDateTimeEdit*>())
2905     {
2906         key = oneWidget->objectName();
2907         value = Options::self()->property(key.toLatin1());
2908         if (value.isValid())
2909         {
2910             oneWidget->setDateTime(QDateTime::fromString(value.toString(), Qt::ISODate));
2911             settings[key] = value;
2912         }
2913     }
2914 
2915     setErrorHandlingStrategy(static_cast<ErrorHandlingStrategy>(Options::errorHandlingStrategy()));
2916 
2917     m_GlobalSettings = m_Settings = settings;
2918 }
2919 
2920 void Scheduler::syncSettings()
2921 {
2922     QDoubleSpinBox *dsb = nullptr;
2923     QSpinBox *sb = nullptr;
2924     QCheckBox *cb = nullptr;
2925     QRadioButton *rb = nullptr;
2926     QComboBox *cbox = nullptr;
2927     QLineEdit *lineedit = nullptr;
2928     QDateTimeEdit *datetimeedit = nullptr;
2929 
2930     QString key;
2931     QVariant value;
2932 
2933     if ( (dsb = qobject_cast<QDoubleSpinBox*>(sender())))
2934     {
2935         key = dsb->objectName();
2936         value = dsb->value();
2937 
2938     }
2939     else if ( (sb = qobject_cast<QSpinBox*>(sender())))
2940     {
2941         key = sb->objectName();
2942         value = sb->value();
2943     }
2944     else if ( (cb = qobject_cast<QCheckBox*>(sender())))
2945     {
2946         key = cb->objectName();
2947         value = cb->isChecked();
2948     }
2949     else if ( (rb = qobject_cast<QRadioButton*>(sender())))
2950     {
2951         key = rb->objectName();
2952         value = rb->isChecked();
2953     }
2954     else if ( (cbox = qobject_cast<QComboBox*>(sender())))
2955     {
2956         key = cbox->objectName();
2957         value = cbox->currentText();
2958     }
2959     else if ( (lineedit = qobject_cast<QLineEdit*>(sender())))
2960     {
2961         key = lineedit->objectName();
2962         value = lineedit->text();
2963     }
2964     else if ( (datetimeedit = qobject_cast<QDateTimeEdit*>(sender())))
2965     {
2966         key = datetimeedit->objectName();
2967         value = datetimeedit->dateTime().toString(Qt::ISODate);
2968     }
2969 
2970     // Save immediately
2971     Options::self()->setProperty(key.toLatin1(), value);
2972 
2973     m_Settings[key] = value;
2974     m_GlobalSettings[key] = value;
2975 
2976     emit settingsUpdated(getAllSettings());
2977 }
2978 
2979 ///////////////////////////////////////////////////////////////////////////////////////////
2980 ///
2981 ///////////////////////////////////////////////////////////////////////////////////////////
2982 QVariantMap Scheduler::getAllSettings() const
2983 {
2984     QVariantMap settings;
2985 
2986     // All Combo Boxes
2987     for (auto &oneWidget : findChildren<QComboBox*>())
2988         settings.insert(oneWidget->objectName(), oneWidget->currentText());
2989 
2990     // All Double Spin Boxes
2991     for (auto &oneWidget : findChildren<QDoubleSpinBox*>())
2992         settings.insert(oneWidget->objectName(), oneWidget->value());
2993 
2994     // All Spin Boxes
2995     for (auto &oneWidget : findChildren<QSpinBox*>())
2996         settings.insert(oneWidget->objectName(), oneWidget->value());
2997 
2998     // All Checkboxes
2999     for (auto &oneWidget : findChildren<QCheckBox*>())
3000         settings.insert(oneWidget->objectName(), oneWidget->isChecked());
3001 
3002     // All Line Edits
3003     for (auto &oneWidget : findChildren<QLineEdit*>())
3004     {
3005         // Many other widget types (e.g. spinboxes) apparently have QLineEdit inside them so we want to skip those
3006         if (!oneWidget->objectName().startsWith("qt_"))
3007             settings.insert(oneWidget->objectName(), oneWidget->text());
3008     }
3009 
3010     // All Radio Buttons
3011     for (auto &oneWidget : findChildren<QRadioButton*>())
3012         settings.insert(oneWidget->objectName(), oneWidget->isChecked());
3013 
3014     // All QDateTime
3015     for (auto &oneWidget : findChildren<QDateTimeEdit*>())
3016     {
3017         settings.insert(oneWidget->objectName(), oneWidget->dateTime().toString(Qt::ISODate));
3018     }
3019 
3020     return settings;
3021 }
3022 
3023 ///////////////////////////////////////////////////////////////////////////////////////////
3024 ///
3025 ///////////////////////////////////////////////////////////////////////////////////////////
3026 void Scheduler::setAllSettings(const QVariantMap &settings)
3027 {
3028     // Disconnect settings that we don't end up calling syncSettings while
3029     // performing the changes.
3030     disconnectSettings();
3031 
3032     for (auto &name : settings.keys())
3033     {
3034         // Combo
3035         auto comboBox = findChild<QComboBox*>(name);
3036         if (comboBox)
3037         {
3038             syncControl(settings, name, comboBox);
3039             continue;
3040         }
3041 
3042         // Double spinbox
3043         auto doubleSpinBox = findChild<QDoubleSpinBox*>(name);
3044         if (doubleSpinBox)
3045         {
3046             syncControl(settings, name, doubleSpinBox);
3047             continue;
3048         }
3049 
3050         // spinbox
3051         auto spinBox = findChild<QSpinBox*>(name);
3052         if (spinBox)
3053         {
3054             syncControl(settings, name, spinBox);
3055             continue;
3056         }
3057 
3058         // checkbox
3059         auto checkbox = findChild<QCheckBox*>(name);
3060         if (checkbox)
3061         {
3062             syncControl(settings, name, checkbox);
3063             continue;
3064         }
3065 
3066         // Line Edits
3067         auto lineedit = findChild<QLineEdit*>(name);
3068         if (lineedit)
3069         {
3070             syncControl(settings, name, lineedit);
3071 
3072             if (name == "sequenceEdit")
3073                 setSequence(lineedit->text());
3074             else if (name == "fitsEdit")
3075                 processFITSSelection(QUrl::fromLocalFile(lineedit->text()));
3076             else if (name == "schedulerStartupScript")
3077                 moduleState()->setStartupScriptURL(QUrl::fromUserInput(lineedit->text()));
3078             else if (name == "schedulerShutdownScript")
3079                 moduleState()->setShutdownScriptURL(QUrl::fromUserInput(lineedit->text()));
3080 
3081             continue;
3082         }
3083 
3084         // Radio button
3085         auto radioButton = findChild<QRadioButton*>(name);
3086         if (radioButton)
3087         {
3088             syncControl(settings, name, radioButton);
3089             continue;
3090         }
3091 
3092         auto datetimeedit = findChild<QDateTimeEdit*>(name);
3093         if (datetimeedit)
3094         {
3095             syncControl(settings, name, datetimeedit);
3096             continue;
3097         }
3098     }
3099 
3100     m_Settings = settings;
3101 
3102     // Restablish connections
3103     connectSettings();
3104 }
3105 
3106 ///////////////////////////////////////////////////////////////////////////////////////////
3107 ///
3108 ///////////////////////////////////////////////////////////////////////////////////////////
3109 bool Scheduler::syncControl(const QVariantMap &settings, const QString &key, QWidget * widget)
3110 {
3111     QSpinBox *pSB = nullptr;
3112     QDoubleSpinBox *pDSB = nullptr;
3113     QCheckBox *pCB = nullptr;
3114     QComboBox *pComboBox = nullptr;
3115     QLineEdit *pLineEdit = nullptr;
3116     QRadioButton *pRadioButton = nullptr;
3117     QDateTimeEdit *pDateTimeEdit = nullptr;
3118     bool ok = true;
3119 
3120     if ((pSB = qobject_cast<QSpinBox *>(widget)))
3121     {
3122         const int value = settings[key].toInt(&ok);
3123         if (ok)
3124         {
3125             pSB->setValue(value);
3126             return true;
3127         }
3128     }
3129     else if ((pDSB = qobject_cast<QDoubleSpinBox *>(widget)))
3130     {
3131         const double value = settings[key].toDouble(&ok);
3132         if (ok)
3133         {
3134             pDSB->setValue(value);
3135             return true;
3136         }
3137     }
3138     else if ((pCB = qobject_cast<QCheckBox *>(widget)))
3139     {
3140         const bool value = settings[key].toBool();
3141         pCB->setChecked(value);
3142         return true;
3143     }
3144     // ONLY FOR STRINGS, not INDEX
3145     else if ((pComboBox = qobject_cast<QComboBox *>(widget)))
3146     {
3147         const QString value = settings[key].toString();
3148         pComboBox->setCurrentText(value);
3149         return true;
3150     }
3151     else if ((pLineEdit = qobject_cast<QLineEdit *>(widget)))
3152     {
3153         const auto value = settings[key].toString();
3154         pLineEdit->setText(value);
3155         return true;
3156     }
3157     else if ((pRadioButton = qobject_cast<QRadioButton *>(widget)))
3158     {
3159         const bool value = settings[key].toBool();
3160         pRadioButton->setChecked(value);
3161         return true;
3162     }
3163     else if ((pDateTimeEdit = qobject_cast<QDateTimeEdit *>(widget)))
3164     {
3165         const auto value = QDateTime::fromString(settings[key].toString(), Qt::ISODate);
3166         pDateTimeEdit->setDateTime(value);
3167         return true;
3168     }
3169 
3170     return false;
3171 };
3172 
3173 void Scheduler::connectSettings()
3174 {
3175     // All Combo Boxes
3176     for (auto &oneWidget : findChildren<QComboBox*>())
3177         connect(oneWidget, QOverload<int>::of(&QComboBox::activated), this, &Ekos::Scheduler::syncSettings);
3178 
3179     // All Double Spin Boxes
3180     for (auto &oneWidget : findChildren<QDoubleSpinBox*>())
3181         connect(oneWidget, QOverload<double>::of(&QDoubleSpinBox::valueChanged), this, &Ekos::Scheduler::syncSettings);
3182 
3183     // All Spin Boxes
3184     for (auto &oneWidget : findChildren<QSpinBox*>())
3185         connect(oneWidget, QOverload<int>::of(&QSpinBox::valueChanged), this, &Ekos::Scheduler::syncSettings);
3186 
3187     // All Checkboxes
3188     for (auto &oneWidget : findChildren<QCheckBox*>())
3189         connect(oneWidget, &QCheckBox::toggled, this, &Ekos::Scheduler::syncSettings);
3190 
3191     // All Radio Butgtons
3192     for (auto &oneWidget : findChildren<QRadioButton*>())
3193         connect(oneWidget, &QRadioButton::toggled, this, &Ekos::Scheduler::syncSettings);
3194 
3195     // All QLineEdits
3196     for (auto &oneWidget : findChildren<QLineEdit*>())
3197     {
3198         // Many other widget types (e.g. spinboxes) apparently have QLineEdit inside them so we want to skip those
3199         if (!oneWidget->objectName().startsWith("qt_"))
3200             connect(oneWidget, &QLineEdit::textChanged, this, &Ekos::Scheduler::syncSettings);
3201     }
3202 
3203     // All QDateTimeEdit
3204     for (auto &oneWidget : findChildren<QDateTimeEdit*>())
3205         connect(oneWidget, &QDateTimeEdit::dateTimeChanged, this, &Ekos::Scheduler::syncSettings);
3206 }
3207 
3208 void Scheduler::disconnectSettings()
3209 {
3210     // All Combo Boxes
3211     for (auto &oneWidget : findChildren<QComboBox*>())
3212         disconnect(oneWidget, QOverload<int>::of(&QComboBox::activated), this, &Ekos::Scheduler::syncSettings);
3213 
3214     // All Double Spin Boxes
3215     for (auto &oneWidget : findChildren<QDoubleSpinBox*>())
3216         disconnect(oneWidget, QOverload<double>::of(&QDoubleSpinBox::valueChanged), this, &Ekos::Scheduler::syncSettings);
3217 
3218     // All Spin Boxes
3219     for (auto &oneWidget : findChildren<QSpinBox*>())
3220         disconnect(oneWidget, QOverload<int>::of(&QSpinBox::valueChanged), this, &Ekos::Scheduler::syncSettings);
3221 
3222     // All Checkboxes
3223     for (auto &oneWidget : findChildren<QCheckBox*>())
3224         disconnect(oneWidget, &QCheckBox::toggled, this, &Ekos::Scheduler::syncSettings);
3225 
3226     // All Radio Butgtons
3227     for (auto &oneWidget : findChildren<QRadioButton*>())
3228         disconnect(oneWidget, &QRadioButton::toggled, this, &Ekos::Scheduler::syncSettings);
3229 
3230     // All QLineEdits
3231     for (auto &oneWidget : findChildren<QLineEdit*>())
3232         disconnect(oneWidget, &QLineEdit::editingFinished, this, &Ekos::Scheduler::syncSettings);
3233 
3234     // All QDateTimeEdit
3235     for (auto &oneWidget : findChildren<QDateTimeEdit*>())
3236         disconnect(oneWidget, &QDateTimeEdit::editingFinished, this, &Ekos::Scheduler::syncSettings);
3237 }
3238 
3239 }