diff --git a/README.md b/README.md index 6a4574d..2342216 100644 --- a/README.md +++ b/README.md @@ -87,11 +87,14 @@ If you check the box to EnableRCS then LabRecorder exposes some rudimentary cont Currently supported commands include: * `select all` * `select none` -* `start` +* `select ` - checks streams matching an LSL resolver predicate such as `name='BioSemi'`, `type='EEG'`, or `name='BioSemi' and hostname='LabPC1'`. +* `start` - starts recording the current stream selection; returns an error if no streams are selected. * `stop` * `update` * `filename ...` +Commands respond with `OK`, `WARNING ...`, or `ERROR ...`. + `filename` is followed by a series of space-delimited options enclosed in curly braces. e.g. {root:C:\root_data_dir} * `root` - Sets the root data directory. * `template` - sets the File Name / Template. Will unselect BIDS option. May contain wildcards. diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index be82724..53ee375 100644 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -40,7 +40,7 @@ MainWindow::MainWindow(QWidget *parent, const char *config_file) connect(ui->refreshButton, &QPushButton::clicked, this, &MainWindow::refreshStreams); connect(ui->selectAllButton, &QPushButton::clicked, this, &MainWindow::selectAllStreams); connect(ui->selectNoneButton, &QPushButton::clicked, this, &MainWindow::selectNoStreams); - connect(ui->startButton, &QPushButton::clicked, this, &MainWindow::startRecording); + connect(ui->startButton, &QPushButton::clicked, this, [this]() { startRecording(); }); connect(ui->stopButton, &QPushButton::clicked, this, &MainWindow::stopRecording); connect(ui->actionAbout, &QAction::triggered, this, [this]() { QString infostr = QStringLiteral("LSL library version: ") + @@ -287,6 +287,40 @@ QString info_to_listName(const lsl::stream_info& info) { return QString::fromStdString(info.name() + " (" + info.hostname() + ")"); } +void MainWindow::updateKnownStreamSelectionFromUi() { + for (int i = 0; i < ui->streamList->count(); i++) { + QListWidgetItem *item = ui->streamList->item(i); + bool ok = false; + int knownIndex = item->data(Qt::UserRole).toInt(&ok); + if (ok && knownIndex >= 0 && knownIndex < knownStreams.count()) + knownStreams[knownIndex].checked = item->checkState() == Qt::Checked; + } +} + +void MainWindow::rebuildStreamList() { + const QBrush good_brush(QColor(0, 128, 0)), bad_brush(QColor(255, 0, 0)); + ui->streamList->clear(); + for (auto& m : std::as_const(missingStreams)) { + auto *item = new QListWidgetItem(m, ui->streamList); + item->setCheckState(Qt::Checked); + item->setForeground(bad_brush); + ui->streamList->addItem(item); + } + for (int i = 0; i < knownStreams.count(); i++) { + const auto &k = knownStreams[i]; + auto *item = new QListWidgetItem(k.listName(), ui->streamList); + item->setData(Qt::UserRole, i); + item->setCheckState(k.checked ? Qt::Checked : Qt::Unchecked); + item->setForeground(good_brush); + item->setToolTip(QString("Name: %1\nType: %2\nSource ID: %3\nHostname: %4") + .arg(QString::fromStdString(k.name), + QString::fromStdString(k.type), + QString::fromStdString(k.id), + QString::fromStdString(k.host))); + ui->streamList->addItem(item); + } +} + /** * @brief MainWindow::refreshStreams Find streams, generate a list of missing streams * and fill the UI streamlist. @@ -294,30 +328,25 @@ QString info_to_listName(const lsl::stream_info& info) { */ std::vector MainWindow::refreshStreams() { const std::vector resolvedStreams = lsl::resolve_streams(1.0); + updateKnownStreamSelectionFromUi(); // For each item in resolvedStreams, ignore if already in knownStreams, otherwise add to knownStreams. // if in missingStreams then also mark it as required (--> checked by default) and remove from missingStreams. for (const auto& s : resolvedStreams) { bool known = false; for (auto &k : knownStreams) { - known |= s.name() == k.name && s.type() == k.type && s.source_id() == k.id; + if (k.matches(s)) { + k.updateInfo(s); + known = true; + break; + } } if (!known) { bool found = missingStreams.contains(info_to_listName(s)); - knownStreams << StreamItem(s.name(), s.type(), s.source_id(), s.hostname(), found); + knownStreams << StreamItem(s, found); if (found) { missingStreams.remove(info_to_listName(s)); } } } - // For each item in knownStreams, update its checked status from GUI. (only works for streams found on a previous refresh) - // Because we search by name + host, entries aren't guaranteed to be unique, so checking one entry with matching name and host checks them all. - for (auto &k : knownStreams) { - QList foundItems = ui->streamList->findItems(k.listName(), Qt::MatchCaseSensitive); - if (foundItems.count() > 0) { - bool checked = false; - for (auto &fi : foundItems) { checked |= fi->checkState() == Qt::Checked; } - k.checked = checked; - } - } // For each item in knownStreams; if it is not resolved then drop it. If it was checked then add back to missingStreams. int k_ind = 0; while (k_ind < knownStreams.count()) { @@ -326,7 +355,7 @@ std::vector MainWindow::refreshStreams() { size_t r_ind = 0; while (!resolved && r_ind < resolvedStreams.size()) { const lsl::stream_info r = resolvedStreams[r_ind]; - resolved |= (r.name() == k.name) && (r.type() == k.type) && (r.source_id() == k.id); + resolved |= k.matches(r); r_ind++; } if (!resolved) { @@ -339,31 +368,13 @@ std::vector MainWindow::refreshStreams() { // Clear the streamList // Add missing items first. // Then add knownStreams (only in list if resolved). - const QBrush good_brush(QColor(0, 128, 0)), bad_brush(QColor(255, 0, 0)); - ui->streamList->clear(); - for (auto& m : std::as_const(missingStreams)) { - auto *item = new QListWidgetItem(m, ui->streamList); - item->setCheckState(Qt::Checked); - item->setForeground(bad_brush); - ui->streamList->addItem(item); - } - for (auto& k : knownStreams) { - auto *item = new QListWidgetItem(k.listName(), ui->streamList); - item->setCheckState(k.checked ? Qt::Checked : Qt::Unchecked); - item->setForeground(good_brush); - item->setToolTip(QString("Name: %1\nType: %2\nSource ID: %3\nHostname: %4") - .arg(QString::fromStdString(k.name), - QString::fromStdString(k.type), - QString::fromStdString(k.id), - QString::fromStdString(k.host))); - ui->streamList->addItem(item); - } + rebuildStreamList(); // return a std::vector of streams of checked and not missing streams. std::vector requestedAndAvailableStreams; for (const auto &r : resolvedStreams) { for (auto &k : knownStreams) { - if ((r.name() == k.name) && (r.type() == k.type) && (r.source_id() == k.id)) { + if (k.matches(r)) { if (k.checked) { requestedAndAvailableStreams.push_back(r); } break; } @@ -372,9 +383,8 @@ std::vector MainWindow::refreshStreams() { return requestedAndAvailableStreams; } -void MainWindow::startRecording() { +MainWindow::StartResult MainWindow::startRecording() { if (!currentRecording) { - // automatically refresh streams const std::vector requestedAndAvailableStreams = refreshStreams(); @@ -387,7 +397,7 @@ void MainWindow::startRecording() { QMessageBox::Yes | QMessageBox::No, this); msgBox.setInformativeText("Do you want to start recording anyway?"); msgBox.setDefaultButton(QMessageBox::No); - if (msgBox.exec() != QMessageBox::Yes) return; + if (msgBox.exec() != QMessageBox::Yes) return StartResult::Failed; } if (requestedAndAvailableStreams.size() == 0) { @@ -395,7 +405,7 @@ void MainWindow::startRecording() { "You have selected no streams", QMessageBox::Yes | QMessageBox::No, this); msgBox.setInformativeText("Do you want to start recording anyway?"); msgBox.setDefaultButton(QMessageBox::No); - if (msgBox.exec() != QMessageBox::Yes) return; + if (msgBox.exec() != QMessageBox::Yes) return StartResult::Failed; } } @@ -403,13 +413,13 @@ void MainWindow::startRecording() { QString recFilename = replaceFilename(QDir::cleanPath(ui->lineEdit_template->text())); if (recFilename.isEmpty()) { QMessageBox::critical(this, "Filename empty", "Can not record without a file name"); - return; + return StartResult::Failed; } if (ui->rootEdit->text().trimmed().isEmpty()) { QMessageBox::critical(this, "Study Root empty", "Can not record without a Study Root folder. " "Please set a Study Root before recording."); - return; + return StartResult::Failed; } recFilename.prepend(QDir::cleanPath(ui->rootEdit->text()) + '/'); @@ -418,7 +428,7 @@ void MainWindow::startRecording() { if (recFileInfo.isDir()) { QMessageBox::warning( this, "Error", "Recording path already exists and is a directory"); - return; + return StartResult::Failed; } QString rename_to = recFileInfo.absolutePath() + '/' + recFileInfo.baseName() + "_old%1." + recFileInfo.suffix(); @@ -429,7 +439,7 @@ void MainWindow::startRecording() { if (!QFile::rename(recFileInfo.absoluteFilePath(), newname)) { QMessageBox::warning(this, "Permissions issue", "Cannot rename the file " + recFilename + " to " + newname); - return; + return StartResult::Failed; } qInfo() << "Moved existing file to " << newname; recFileInfo.refresh(); @@ -440,7 +450,7 @@ void MainWindow::startRecording() { QMessageBox::warning(this, "Permissions issue", "Can not create the directory " + recFileInfo.dir().path() + ". Please check your permissions."); - return; + return StartResult::Failed; } std::vector watchfor; @@ -471,11 +481,13 @@ void MainWindow::startRecording() { ui->stopButton->setEnabled(true); ui->startButton->setEnabled(false); startTime = (int)lsl::local_clock(); + return StartResult::Started; } else if (!hideWarnings) { QMessageBox::information( this, "Already recording", "The recording is already running", QMessageBox::Ok); } + return StartResult::AlreadyRecording; } void MainWindow::stopRecording() { @@ -507,6 +519,42 @@ void MainWindow::selectNoStreams() { } } +bool MainWindow::hasSelectedStreams() const { + for (int i = 0; i < ui->streamList->count(); i++) { + const QListWidgetItem *item = ui->streamList->item(i); + if (item->checkState() == Qt::Checked) return true; + } + return false; +} + +MainWindow::SelectResult MainWindow::selectStreams(const QString &query) { + updateKnownStreamSelectionFromUi(); + std::vector matchedStreams; + try { + matchedStreams = lsl::resolve_stream(query.toStdString(), 0, 1.0); + } catch (std::exception &e) { + qWarning() << "Invalid stream selection query" << query << ":" << e.what(); + return SelectResult::InvalidQuery; + } + if (matchedStreams.empty()) return SelectResult::NoMatches; + + for (const auto &stream : matchedStreams) { + bool known = false; + for (auto &k : knownStreams) { + if (k.matches(stream)) { + k.updateInfo(stream); + k.checked = true; + known = true; + break; + } + } + if (!known) knownStreams << StreamItem(stream, true); + missingStreams.remove(info_to_listName(stream)); + } + rebuildStreamList(); + return SelectResult::Selected; +} + void MainWindow::buildBidsTemplate() { // path/to/CurrentStudy/sub-%p/ses-%s/eeg/sub-%p_ses-%s_task-%b[_acq-%a]_run-%r_eeg.xdf @@ -647,6 +695,7 @@ void MainWindow::enableRcs(bool bEnable) { connect(rcs.get(), &RemoteControlSocket::filename, this, &MainWindow::rcsUpdateFilename); connect(rcs.get(), &RemoteControlSocket::select_all, this, &MainWindow::selectAllStreams); connect(rcs.get(), &RemoteControlSocket::select_none, this, &MainWindow::selectNoStreams); + connect(rcs.get(), &RemoteControlSocket::select_stream, this, &MainWindow::rcsSelectStreams); } bool oldState = ui->rcsCheckBox->blockSignals(true); ui->rcsCheckBox->setChecked(bEnable); @@ -660,17 +709,48 @@ void MainWindow::rcsportValueChangedInt(int value) { } } -void MainWindow::rcsStartRecording() { - // since we want to avoid a pop-up window when streams are missing or unchecked, - // we'll check all the streams and start recording +void MainWindow::rcsStartRecording(QTcpSocket *sock) { + // Remote start should record the current stream selection. Do not call + // selectAllStreams() here; doing so would override TCP `select ` commands. + // hideWarnings suppresses non-critical confirmation dialogs for remote control. + if (currentRecording) { + if (sock) sock->write("WARNING already recording"); + return; + } + if (!hasSelectedStreams()) { + qWarning() << "Remote start rejected: no streams selected"; + if (sock) sock->write("ERROR no streams selected"); + return; + } + const bool oldHideWarnings = hideWarnings; hideWarnings = true; - selectAllStreams(); - startRecording(); + const StartResult result = startRecording(); + hideWarnings = oldHideWarnings; + if (!sock) return; + if (result == StartResult::Started) + sock->write("OK"); + else if (result == StartResult::AlreadyRecording) + sock->write("WARNING already recording"); + else + sock->write("ERROR failed to start recording"); +} + +void MainWindow::rcsSelectStreams(const QString &query, QTcpSocket *sock) { + const SelectResult result = selectStreams(query); + if (!sock) return; + if (result == SelectResult::Selected) + sock->write("OK"); + else if (result == SelectResult::NoMatches) + sock->write("WARNING no streams matched"); + else + sock->write("ERROR invalid select query"); } void MainWindow::rcsStopRecording() { + const bool oldHideWarnings = hideWarnings; hideWarnings = true; stopRecording(); + hideWarnings = oldHideWarnings; } void MainWindow::rcsUpdateFilename(QString s) { diff --git a/src/mainwindow.h b/src/mainwindow.h index abeaad4..477b5ee 100644 --- a/src/mainwindow.h +++ b/src/mainwindow.h @@ -17,19 +17,34 @@ class MainWindow; class recording; class RemoteControlSocket; +class QTcpSocket; class StreamItem { public: - StreamItem(std::string stream_name, std::string stream_type, std::string source_id, - std::string hostname, bool required) - : name(stream_name), type(stream_type), id(source_id), host(hostname), checked(required) {} + StreamItem(const lsl::stream_info &info, bool required) + : name(info.name()), type(info.type()), id(info.source_id()), host(info.hostname()), + sessionId(info.session_id()), checked(required) {} - QString listName() { return QString::fromStdString(name + " (" + host + ")"); } + QString listName() const { return QString::fromStdString(name + " (" + host + ")"); } + bool matches(const lsl::stream_info &info) const { + if (!id.empty() && !info.source_id().empty()) + return id == info.source_id() && name == info.name() && type == info.type(); + return name == info.name() && type == info.type() && host == info.hostname() && + sessionId == info.session_id(); + } + void updateInfo(const lsl::stream_info &info) { + name = info.name(); + type = info.type(); + id = info.source_id(); + host = info.hostname(); + sessionId = info.session_id(); + } std::string name; std::string type; std::string id; std::string host; + std::string sessionId; bool checked; }; @@ -46,7 +61,6 @@ private slots: void closeEvent(QCloseEvent *ev) override; void blockSelected(const QString &block); std::vector refreshStreams(void); - void startRecording(void); void stopRecording(void); void selectAllStreams(); void selectNoStreams(); @@ -56,15 +70,24 @@ private slots: void enableRcs(bool bEnable); void rcsCheckBoxChanged(bool checked); void rcsUpdateFilename(QString s); - void rcsStartRecording(); + void rcsStartRecording(QTcpSocket *sock); + void rcsSelectStreams(const QString &query, QTcpSocket *sock); void rcsStopRecording(); void rcsportValueChangedInt(int value); private: + enum class StartResult { Started, AlreadyRecording, Failed }; + enum class SelectResult { Selected, NoMatches, InvalidQuery }; + QString replaceFilename(QString fullfile) const; // function for loading / saving the config file QString find_config_file(const char *filename); QString counterPlaceholder() const; + StartResult startRecording(); + SelectResult selectStreams(const QString &query); + bool hasSelectedStreams() const; + void updateKnownStreamSelectionFromUi(); + void rebuildStreamList(); void load_config(QString filename); void save_config(QString filename); diff --git a/src/tcpinterface.cpp b/src/tcpinterface.cpp index 044486b..6ff3c37 100644 --- a/src/tcpinterface.cpp +++ b/src/tcpinterface.cpp @@ -17,20 +17,25 @@ void RemoteControlSocket::addClient() { void RemoteControlSocket::handleLine(QString s, QTcpSocket *sock) { qInfo() << s; - if (s == "start") - emit start(); - else if (s == "stop") + if (s == "start") { + emit start(sock); + return; + } else if (s == "stop") emit stop(); else if (s == "update") emit refresh_streams(); - else if (s.contains("filename")) { + else if (s == "filename" || s.startsWith("filename ")) { emit filename(s); - } else if (s.contains("select")) { - if (s.contains("all")) { - emit select_all(); - } else if (s.contains("none")) { - emit select_none(); - } + } else if (s == "select all") { + emit select_all(); + } else if (s == "select none") { + emit select_none(); + } else if (s.startsWith("select ")) { + emit select_stream(s.mid(QStringLiteral("select ").size()), sock); + return; + } else { + sock->write("ERROR unknown command"); + return; } sock->write("OK"); // TODO: select /deselect streams diff --git a/src/tcpinterface.h b/src/tcpinterface.h index 3eeef1e..53a2f36 100644 --- a/src/tcpinterface.h +++ b/src/tcpinterface.h @@ -16,11 +16,12 @@ class RemoteControlSocket : public QObject { signals: void refresh_streams(); - void start(); + void start(QTcpSocket *sock); void stop(); void filename(QString s); void select_all(); void select_none(); + void select_stream(QString query, QTcpSocket *sock); public slots: void addClient();