Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <query>` - 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.
Expand Down
176 changes: 128 additions & 48 deletions src/mainwindow.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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: ") +
Expand Down Expand Up @@ -287,37 +287,66 @@ 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.
* @return A vector of found stream_infos
*/
std::vector<lsl::stream_info> MainWindow::refreshStreams() {
const std::vector<lsl::stream_info> 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<QListWidgetItem *> 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()) {
Expand All @@ -326,7 +355,7 @@ std::vector<lsl::stream_info> 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) {
Expand All @@ -339,31 +368,13 @@ std::vector<lsl::stream_info> 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<lsl::stream_info> 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;
}
Expand All @@ -372,9 +383,8 @@ std::vector<lsl::stream_info> MainWindow::refreshStreams() {
return requestedAndAvailableStreams;
}

void MainWindow::startRecording() {
MainWindow::StartResult MainWindow::startRecording() {
if (!currentRecording) {

// automatically refresh streams
const std::vector<lsl::stream_info> requestedAndAvailableStreams = refreshStreams();

Expand All @@ -387,29 +397,29 @@ 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) {
QMessageBox msgBox(QMessageBox::Warning, "No available streams selected",
"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;
}
}

// don't hide critical errors.
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()) + '/');

Expand All @@ -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();
Expand All @@ -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();
Expand All @@ -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<std::string> watchfor;
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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<lsl::stream_info> 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

Expand Down Expand Up @@ -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);
Expand All @@ -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 <query>` 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) {
Expand Down
Loading