diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..480709a --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,286 @@ +# ============================================================================= +# MCCOutlet Build Workflow +# ============================================================================= +# Builds, packages, and optionally signs/notarizes MCCOutlet for +# Linux and macOS. Windows is not supported (uldaq requires libusb + POSIX). +# +# Features: +# - Multi-platform builds (Linux, macOS) +# - Qt6 integration +# - Automatic liblsl and uldaq fetch +# - CPack packaging +# - macOS code signing and notarization (on release) +# ============================================================================= + +name: Build + +on: + push: + branches: [main, master, dev] + tags: ['v*'] + pull_request: + branches: [main, master] + release: + types: [published] + workflow_dispatch: + +env: + BUILD_TYPE: Release + +jobs: + # =========================================================================== + # Build Job - Multi-platform builds + # =========================================================================== + build: + name: ${{ matrix.config.name }} + runs-on: ${{ matrix.config.os }} + strategy: + fail-fast: false + matrix: + config: + - { name: "Ubuntu 22.04", os: ubuntu-22.04 } + - { name: "Ubuntu 24.04", os: ubuntu-24.04 } + - { name: "macOS", os: macos-14 } + + steps: + - name: Checkout + uses: actions/checkout@v4 + + # ----------------------------------------------------------------------- + # Install CMake 3.28+ (Ubuntu 22.04 ships with 3.22) + # ----------------------------------------------------------------------- + - name: Install CMake + if: runner.os == 'Linux' + uses: lukka/get-cmake@latest + + # ----------------------------------------------------------------------- + # Install system dependencies + # ----------------------------------------------------------------------- + - name: Install Linux dependencies + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y \ + autoconf automake libtool \ + libusb-1.0-0-dev \ + libgl1-mesa-dev libxkbcommon-dev libxcb-cursor0 + + - name: Install macOS dependencies + if: runner.os == 'macOS' + run: | + brew install autoconf automake libtool libusb + + # ----------------------------------------------------------------------- + # Install Qt6 + # ----------------------------------------------------------------------- + - name: Install Qt + uses: jurplel/install-qt-action@v4 + with: + version: '6.8.*' + cache: true + + # ----------------------------------------------------------------------- + # Configure + # ----------------------------------------------------------------------- + - name: Configure CMake + run: > + cmake -S . -B build + -DCMAKE_BUILD_TYPE=${{ env.BUILD_TYPE }} + -DCMAKE_INSTALL_PREFIX=${{ github.workspace }}/install + ${{ matrix.config.cmake_extra }} + + # ----------------------------------------------------------------------- + # Build + # ----------------------------------------------------------------------- + - name: Build + run: cmake --build build --config ${{ env.BUILD_TYPE }} --parallel + + # ----------------------------------------------------------------------- + # Install + # ----------------------------------------------------------------------- + - name: Install + run: cmake --install build --config ${{ env.BUILD_TYPE }} + + # ----------------------------------------------------------------------- + # Test CLI + # ----------------------------------------------------------------------- + - name: Test CLI (Linux) + if: runner.os == 'Linux' + run: ./install/bin/MCCOutletCLI --help + + - name: Test CLI (macOS) + if: runner.os == 'macOS' + run: ./install/MCCOutletCLI --help + + # ----------------------------------------------------------------------- + # Package + # ----------------------------------------------------------------------- + - name: Package + run: cpack -C ${{ env.BUILD_TYPE }} + working-directory: build + + # ----------------------------------------------------------------------- + # Upload Artifacts + # ----------------------------------------------------------------------- + - name: Upload Artifacts + uses: actions/upload-artifact@v4 + with: + name: package-${{ matrix.config.os }} + path: | + build/*.tar.gz + build/*.deb + if-no-files-found: ignore + + # =========================================================================== + # macOS Signing and Notarization (Release only) + # =========================================================================== + sign-macos: + name: Sign & Notarize (macOS) + needs: build + if: github.event_name == 'release' + runs-on: macos-14 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Download macOS Artifact + uses: actions/download-artifact@v4 + with: + name: package-macos-14 + path: packages + + - name: Extract Package + run: | + cd packages + tar -xzf *.tar.gz + # Move contents out of versioned subdirectory to packages/ + SUBDIR=$(ls -d MCCOutlet-*/ | head -1) + mv "$SUBDIR"/* . + rmdir "$SUBDIR" + ls -la + + # ----------------------------------------------------------------------- + # Install Apple Certificates + # ----------------------------------------------------------------------- + - name: Install Apple Certificates + env: + MACOS_CERTIFICATE: ${{ secrets.PROD_MACOS_CERTIFICATE }} + MACOS_CERTIFICATE_PWD: ${{ secrets.PROD_MACOS_CERTIFICATE_PWD }} + run: | + # Create temporary keychain with random password + KEYCHAIN_PATH=$RUNNER_TEMP/build.keychain + MACOS_CI_KEYCHAIN_PWD=$(openssl rand -base64 32) + security create-keychain -p "$MACOS_CI_KEYCHAIN_PWD" $KEYCHAIN_PATH + security default-keychain -s $KEYCHAIN_PATH + security set-keychain-settings -lut 21600 $KEYCHAIN_PATH + security unlock-keychain -p "$MACOS_CI_KEYCHAIN_PWD" $KEYCHAIN_PATH + + # Import certificate + CERTIFICATE_PATH=$RUNNER_TEMP/build_certificate.p12 + echo -n "$MACOS_CERTIFICATE" | base64 --decode -o $CERTIFICATE_PATH + security import $CERTIFICATE_PATH -P "$MACOS_CERTIFICATE_PWD" -k $KEYCHAIN_PATH -A -t cert -f pkcs12 + rm $CERTIFICATE_PATH + + # Allow codesign to access keychain + security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$MACOS_CI_KEYCHAIN_PWD" $KEYCHAIN_PATH + security list-keychain -d user -s $KEYCHAIN_PATH + + # Extract identity name and export to environment + IDENTITY=$(security find-identity -v -p codesigning $KEYCHAIN_PATH | grep "Developer ID Application" | head -1 | awk -F'"' '{print $2}') + echo "APPLE_CODE_SIGN_IDENTITY_APP=$IDENTITY" >> $GITHUB_ENV + + # ----------------------------------------------------------------------- + # Setup Notarization Credentials + # ----------------------------------------------------------------------- + - name: Setup Notarization + env: + NOTARIZATION_APPLE_ID: ${{ secrets.PROD_MACOS_NOTARIZATION_APPLE_ID }} + NOTARIZATION_PWD: ${{ secrets.PROD_MACOS_NOTARIZATION_PWD }} + NOTARIZATION_TEAM_ID: ${{ secrets.PROD_MACOS_NOTARIZATION_TEAM_ID }} + run: | + xcrun notarytool store-credentials "notarize-profile" \ + --apple-id "$NOTARIZATION_APPLE_ID" \ + --password "$NOTARIZATION_PWD" \ + --team-id "$NOTARIZATION_TEAM_ID" + echo "APPLE_NOTARIZE_KEYCHAIN_PROFILE=notarize-profile" >> $GITHUB_ENV + + # ----------------------------------------------------------------------- + # Sign and Notarize + # ----------------------------------------------------------------------- + - name: Sign and Notarize + env: + ENTITLEMENTS_FILE: ${{ github.workspace }}/app.entitlements + run: | + # Sign GUI app bundle (--deep handles all nested code including lsl.framework) + APP_PATH=$(find packages -name "*.app" -type d | head -1) + if [[ -n "$APP_PATH" ]]; then + ./scripts/sign_and_notarize.sh "$APP_PATH" --notarize + fi + + # Sign CLI and its bundled lsl.framework + CLI_PATH=$(find packages -name "MCCOutletCLI" -type f | head -1) + if [[ -n "$CLI_PATH" ]]; then + CLI_DIR=$(dirname "$CLI_PATH") + # Sign framework first (dependency must be signed before dependent) + if [[ -d "$CLI_DIR/Frameworks/lsl.framework" ]]; then + codesign --force --sign "$APPLE_CODE_SIGN_IDENTITY_APP" --options runtime \ + "$CLI_DIR/Frameworks/lsl.framework" + fi + ./scripts/sign_and_notarize.sh "$CLI_PATH" --notarize + fi + + # ----------------------------------------------------------------------- + # Repackage + # ----------------------------------------------------------------------- + - name: Repackage + run: | + cd packages + + # Remove original unsigned package + rm -f *.tar.gz + + # Get project version from CMakeLists.txt + VERSION=$(grep 'VERSION [0-9]' ../CMakeLists.txt | head -1 | sed 's/.*VERSION \([0-9.]*\).*/\1/') + echo "Detected version: $VERSION" + + # Create signed package (universal binary) + tar -cvzf "MCCOutlet-${VERSION}-macOS_universal-signed.tar.gz" \ + MCCOutlet.app MCCOutletCLI Frameworks + + echo "Created package:" + ls -la *.tar.gz + + - name: Upload Signed Package + uses: actions/upload-artifact@v4 + with: + name: package-macos-signed + path: packages/*-signed.tar.gz + + - name: Upload to Release + if: github.event_name == 'release' + uses: softprops/action-gh-release@v2 + with: + files: packages/*-signed.tar.gz + + # =========================================================================== + # Upload unsigned packages to release + # =========================================================================== + release: + name: Upload to Release + needs: build + if: github.event_name == 'release' + runs-on: ubuntu-latest + + steps: + - name: Download All Artifacts + uses: actions/download-artifact@v4 + with: + path: artifacts + + - name: Upload to Release + uses: softprops/action-gh-release@v2 + with: + files: | + artifacts/package-ubuntu-*/*.tar.gz + artifacts/**/*.deb diff --git a/.gitignore b/.gitignore index 0b84fa9..749a5d9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,34 +1,10 @@ -build*/ - -# Prerequisites -*.d - -# Compiled Object files -*.slo -*.lo -*.o -*.obj - -# Precompiled Headers -*.gch -*.pch - -# Compiled Dynamic libraries -*.so -*.dylib -*.dll - -# Fortran module files -*.mod -*.smod - -# Compiled Static libraries -*.lai -*.la -*.a -*.lib - -# Executables -*.exe -*.out -*.app +ui_*.h +/build*/ +/CMakeLists.txt.user +/CMakeSettings.json +/.vs/ +/out/ +.DS_Store +.idea +.cmake-build-*/ +.cache/ diff --git a/.gitmodules b/.gitmodules deleted file mode 100644 index c8c0f05..0000000 --- a/.gitmodules +++ /dev/null @@ -1,3 +0,0 @@ -[submodule "MCCDaq-libusb-driver"] - path = MCCDaq-libusb-driver - url = https://github.com/SachsLab/MCCDaq-libusb-driver.git diff --git a/CMakeLists.txt b/CMakeLists.txt index 657c3b7..202498e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,46 +1,236 @@ -cmake_minimum_required(VERSION 3.5) +# ============================================================================= +# MCCOutlet - Measurement Computing DAQ to Lab Streaming Layer +# ============================================================================= +# Streams analog input data from MCC DAQ devices into LSL. +# Based on the LSL AppTemplate_cpp_qt template. +# +# Dependencies fetched automatically: +# - liblsl (via FetchContent) +# - uldaq (via ExternalProject, built from Autotools source) +# ============================================================================= + +cmake_minimum_required(VERSION 3.28) + +if(POLICY CMP0177) + cmake_policy(SET CMP0177 NEW) +endif() project(MCCOutlet - LANGUAGES CXX - VERSION 0.1) - -# set up LSL if not done already -if(NOT TARGET LSL::lsl) - # when building out of tree LSL_ROOT needs to be specified on the cmd line - file(TO_CMAKE_PATH "${LSL_INSTALL_ROOT}" LSL_INSTALL_ROOT) - list(APPEND LSL_INSTALL_ROOT "${CMAKE_CURRENT_LIST_DIR}/../../LSL/liblsl/build/install") - list(APPEND LSL_INSTALL_ROOT "${CMAKE_CURRENT_LIST_DIR}/../../build/install") - find_package(LSL HINTS ${LSL_INSTALL_ROOT}/share/LSL/ ${LSL_INSTALL_ROOT}/LSL/share/LSL QUIET) - if(NOT LSL_FOUND) - message(FATAL_ERROR "Precompiled LSL was not found. See https://github.com/labstreaminglayer/labstreaminglayer/blob/master/doc/BUILD.md#lsl_install_root for more information.") - endif() - list(APPEND CMAKE_MODULE_PATH ${LSL_DIR}) - message(STATUS "Looking for LSLCMake in ${LSL_DIR}") - include(LSLCMake) -endif() - -# mccdaq -add_subdirectory(MCCDaq-libusb-driver) - -# Target executable -add_executable(${PROJECT_NAME} MACOSX_BUNDLE WIN32 - MCCOutlet.cpp + VERSION 3.0.0 + DESCRIPTION "Measurement Computing DAQ to LSL" + HOMEPAGE_URL "https://github.com/labstreaminglayer/App-MeasurementComputing" + LANGUAGES CXX C ) -target_include_directories(${PROJECT_NAME} PRIVATE - mccdaq -) +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_EXPORT_COMPILE_COMMANDS ON) -target_link_libraries(${PROJECT_NAME} - mccdaq - LSL::lsl -) +# ============================================================================= +# Build Options +# ============================================================================= +option(MCCOUTLET_BUILD_GUI "Build the GUI application (requires Qt6)" ON) +option(MCCOUTLET_BUILD_CLI "Build the CLI application" ON) -set_property(TARGET ${PROJECT_NAME} PROPERTY CXX_STANDARD 14) -# target_compile_features(${PROJECT_NAME} PRIVATE cxx_auto_type cxx_lambda_init_captures) +# ============================================================================= +# liblsl Dependency +# ============================================================================= +set(LSL_INSTALL_ROOT "" CACHE PATH "Path to installed liblsl (optional)") +set(LSL_FETCH_REF "v1.17.4" CACHE STRING "liblsl version to fetch from GitHub") -installLSLApp(${PROJECT_NAME}) -installLSLAuxFiles(${PROJECT_NAME} - ${PROJECT_NAME}.cfg -) -LSLGenerateCPackConfig() +if(LSL_INSTALL_ROOT) + find_package(LSL REQUIRED + HINTS "${LSL_INSTALL_ROOT}" + PATH_SUFFIXES share/LSL lib/cmake/LSL Frameworks/lsl.framework/Resources/CMake + ) + message(STATUS "Using installed liblsl: ${LSL_DIR}") + include("${LSL_DIR}/LSLCMake.cmake") +else() + message(STATUS "Fetching liblsl ${LSL_FETCH_REF} from GitHub...") + include(FetchContent) + set(LSL_BUILD_EXAMPLES OFF CACHE BOOL "" FORCE) + set(LSL_BUILD_TESTING OFF CACHE BOOL "" FORCE) + FetchContent_Declare(liblsl + GIT_REPOSITORY https://github.com/sccn/liblsl.git + GIT_TAG ${LSL_FETCH_REF} + GIT_SHALLOW ON + EXCLUDE_FROM_ALL + ) + FetchContent_MakeAvailable(liblsl) + if(NOT TARGET LSL::lsl) + add_library(LSL::lsl ALIAS lsl) + endif() + include("${liblsl_SOURCE_DIR}/cmake/LSLCMake.cmake") +endif() + +# ============================================================================= +# uldaq Dependency (Measurement Computing driver library) +# ============================================================================= +include(${CMAKE_CURRENT_SOURCE_DIR}/cmake/Uldaq.cmake) + +# ============================================================================= +# Qt6 (for GUI build only) +# ============================================================================= +if(MCCOUTLET_BUILD_GUI) + set(CMAKE_AUTOMOC ON) + set(CMAKE_AUTORCC ON) + set(CMAKE_AUTOUIC ON) + + find_package(Qt6 REQUIRED COMPONENTS Core Widgets) + + if(APPLE AND TARGET WrapOpenGL::WrapOpenGL) + get_target_property(_wrap_gl_libs WrapOpenGL::WrapOpenGL INTERFACE_LINK_LIBRARIES) + if(_wrap_gl_libs) + list(FILTER _wrap_gl_libs EXCLUDE REGEX ".*AGL.*") + set_target_properties(WrapOpenGL::WrapOpenGL PROPERTIES INTERFACE_LINK_LIBRARIES "${_wrap_gl_libs}") + endif() + endif() +endif() + +# ============================================================================= +# Common Dependencies +# ============================================================================= +find_package(Threads REQUIRED) + +# ============================================================================= +# RPATH Configuration +# ============================================================================= +LSL_configure_rpath() + +# ============================================================================= +# Targets +# ============================================================================= + +add_subdirectory(src/core) + +if(MCCOUTLET_BUILD_CLI) + add_subdirectory(src/cli) +endif() + +if(MCCOUTLET_BUILD_GUI) + add_subdirectory(src/gui) +endif() + +# ============================================================================= +# Installation +# ============================================================================= +include(GNUInstallDirs) + +if(WIN32) + set(INSTALL_BINDIR ".") + set(INSTALL_LIBDIR ".") + set(INSTALL_DATADIR ".") +elseif(APPLE) + set(INSTALL_BINDIR ".") + set(INSTALL_LIBDIR ".") + set(INSTALL_DATADIR ".") +else() + set(INSTALL_BINDIR "${CMAKE_INSTALL_BINDIR}") + set(INSTALL_LIBDIR "${CMAKE_INSTALL_LIBDIR}") + set(INSTALL_DATADIR "${CMAKE_INSTALL_DATADIR}/${PROJECT_NAME}") +endif() + +if(MCCOUTLET_BUILD_CLI) + install(TARGETS ${PROJECT_NAME}CLI + RUNTIME DESTINATION "${INSTALL_BINDIR}" + ) +endif() + +if(MCCOUTLET_BUILD_GUI) + install(TARGETS ${PROJECT_NAME} + RUNTIME DESTINATION "${INSTALL_BINDIR}" + BUNDLE DESTINATION "${INSTALL_BINDIR}" + ) +endif() + +set(_config_dest "${INSTALL_DATADIR}") +if(APPLE AND MCCOUTLET_BUILD_GUI) + set(_config_dest "${INSTALL_BINDIR}/${PROJECT_NAME}.app/Contents/MacOS") +endif() +install(FILES ${PROJECT_NAME}.cfg DESTINATION "${_config_dest}") + +# ============================================================================= +# Bundle liblsl +# ============================================================================= +if(APPLE) + if(MCCOUTLET_BUILD_GUI) + LSL_install_liblsl( + FRAMEWORK_DESTINATION "${INSTALL_BINDIR}/${PROJECT_NAME}.app/Contents/Frameworks" + ) + endif() + if(MCCOUTLET_BUILD_CLI) + LSL_install_liblsl(FRAMEWORK_DESTINATION "Frameworks") + endif() +else() + LSL_install_liblsl(DESTINATION "${INSTALL_LIBDIR}") +endif() + +# ============================================================================= +# Qt Deployment +# ============================================================================= +if(MCCOUTLET_BUILD_GUI) + LSL_deploy_qt(TARGET "${PROJECT_NAME}" DESTINATION "${INSTALL_BINDIR}") +endif() + +# ============================================================================= +# MinGW Runtime Deployment +# ============================================================================= +LSL_install_mingw_runtime(DESTINATION "${INSTALL_BINDIR}") + +# ============================================================================= +# macOS: Code Sign +# ============================================================================= +if(APPLE) + set(MACOSX_BUNDLE_GUI_IDENTIFIER "org.labstreaminglayer.${PROJECT_NAME}") + set(MACOSX_BUNDLE_BUNDLE_VERSION "${PROJECT_VERSION}") + set(MACOSX_BUNDLE_SHORT_VERSION_STRING "${PROJECT_VERSION}") + + if(MCCOUTLET_BUILD_GUI) + LSL_codesign( + TARGET "${PROJECT_NAME}" + DESTINATION "${INSTALL_BINDIR}" + ENTITLEMENTS "${CMAKE_CURRENT_SOURCE_DIR}/app.entitlements" + BUNDLE + ) + endif() + + if(MCCOUTLET_BUILD_CLI) + LSL_codesign( + TARGET "${PROJECT_NAME}CLI" + DESTINATION "${INSTALL_BINDIR}" + ENTITLEMENTS "${CMAKE_CURRENT_SOURCE_DIR}/app.entitlements" + FRAMEWORK "Frameworks/lsl.framework" + ) + endif() +endif() + +# ============================================================================= +# CPack +# ============================================================================= +LSL_get_target_arch() +LSL_get_os_name() + +set(CPACK_PACKAGE_NAME "${PROJECT_NAME}") +set(CPACK_PACKAGE_VERSION "${PROJECT_VERSION}") +set(CPACK_PACKAGE_VENDOR "Labstreaminglayer") +set(CPACK_PACKAGE_DESCRIPTION_SUMMARY "${PROJECT_DESCRIPTION}") +set(CPACK_PACKAGE_HOMEPAGE_URL "${PROJECT_HOMEPAGE_URL}") +set(CPACK_PACKAGE_FILE_NAME "${PROJECT_NAME}-${PROJECT_VERSION}-${LSL_OS}_${LSL_ARCH}") +set(CPACK_STRIP_FILES ON) + +if(WIN32) + set(CPACK_GENERATOR ZIP) +elseif(APPLE) + set(CPACK_GENERATOR TGZ) +else() + set(CPACK_GENERATOR DEB TGZ) + set(CPACK_DEBIAN_PACKAGE_MAINTAINER "LabStreamingLayer Developers") + set(CPACK_DEBIAN_PACKAGE_SECTION "science") + set(CPACK_DEBIAN_FILE_NAME "${CPACK_PACKAGE_FILE_NAME}.deb") + set(CPACK_DEBIAN_PACKAGE_SHLIBDEPS OFF) + if(MCCOUTLET_BUILD_GUI) + set(CPACK_DEBIAN_PACKAGE_DEPENDS "libqt6widgets6") + endif() +endif() + +include(CPack) diff --git a/LICENSE b/LICENSE index c73d2d4..2594fe2 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2018 Adam Sachs Laboratory +Copyright (c) 2018-2026 Adam Bhatt Sachs Laboratory Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/MCCDaq-libusb-driver b/MCCDaq-libusb-driver deleted file mode 160000 index fd9365e..0000000 --- a/MCCDaq-libusb-driver +++ /dev/null @@ -1 +0,0 @@ -Subproject commit fd9365eb06c9d154e4e64c2d95ab8dbc1f875784 diff --git a/MCCOutlet.cfg b/MCCOutlet.cfg new file mode 100644 index 0000000..e9531d9 --- /dev/null +++ b/MCCOutlet.cfg @@ -0,0 +1,14 @@ +# MCCOutlet Configuration +# Measurement Computing DAQ to Lab Streaming Layer + +[Stream] +name=MCCDaq +type=RawBrainSignal + +[Device] +device_index=0 +low_channel=0 +high_channel=5 +sample_rate=16384 +# range=6 +# scaled=1 diff --git a/MCCOutlet.cpp b/MCCOutlet.cpp deleted file mode 100644 index b2fcd58..0000000 --- a/MCCOutlet.cpp +++ /dev/null @@ -1,83 +0,0 @@ -#include "lsl_cpp.h" -#include -#include -#include -#include "mccdevice.h" -using namespace std; - -MCCDevice* device; -const char *channels[] = {"RAW1","SPK1","RAW2","SPK2","RAW3","SPK3","NC1","NC2"}; -unsigned short * data; - -int main(int argc, char* argv[]) -{ - string name = "MCCDaq"; - string type = "RawBrainSignal"; - try { - - device = new MCCDevice(USB_1608_FS_PLUS); - device->sendMessage("AISCAN:STOP"); - device->flushInputData(); // Flush out any old data from the buffer - device->sendMessage("AISCAN:XFRMODE=BLOCKIO"); // Good for fast acquisitions. - //device->sendMessage("AISCAN:XFRMODE=SINGLEIO"); // Good for slow acquisitions - device->sendMessage("AISCAN:SAMPLES=0"); // Set to continuous scan. - device->sendMessage("AISCAN:RANGE=BIP5V");//Set the voltage range on the device - device->sendMessage("AISCAN:LOWCHAN=0"); - device->sendMessage("AISCAN:HIGHCHAN=5"); - device->sendMessage("AISCAN:RATE=16384"); - //device->mSamplesPerBlock = 512; - //device->mScanParams.samplesPerBlock = 32/8; // nSamples*nChannels must be integer multiple of 32. - device->reconfigure(); - - - lsl::stream_info info(name, type, 6, 16384, lsl::cf_float32, string(name) += type); - - // add some description fields - lsl::xml_element info_xml = info.desc(); - lsl::xml_element manufac_xml = info_xml.append_child_value("manufacturer", "MeasurementComputing"); - lsl::xml_element channels_xml = info.desc().append_child("channels"); - int k; - for (k = 0; k < 6; k++) - { - lsl::xml_element chn = channels_xml.append_child("channel"); - chn.append_child_value("label", channels[k]) - .append_child_value("unit", "V") - .append_child_value("type", "LFP"); - } - - // make a new outlet - lsl::stream_outlet outlet(info); - - int dataLengthSamples = 512 * 6; - std::vector > chunk(512, std::vector(6)); // Used by LSL - unsigned short *data = new unsigned short[dataLengthSamples]; // Pulled from the device. - - device->sendMessage("AISCAN:START"); // Start the scan on the device - cout << "Now sending data..."; - - unsigned t; - int c, s; - //double timestamp = lsl::local_clock(); - for (t=0; ; t++) { - //device->getBlock(); - device->readScanData(data, dataLengthSamples); - for(c=0; c<6; c++) - { - for(s=0; s<512; s++) - { - chunk[s][c] = device->scaleAndCalibrateData( data[(s*6)+c], c); - } - } - outlet.push_chunk(chunk);// , timestamp); - //timestamp += 512.0/16384.0; - //cout << "Pushed chunk at timestamp " << timestamp << " (diff=" << lsl::local_clock()-timestamp << ")." << endl; - } - - } catch(std::exception &e) { - cerr << "Got an exception: " << e.what() << endl; - } - cout << "Press any key to exit. " << endl; cin.get(); - device->sendMessage("AISCAN:STOP"); - delete device; - return 0; -} diff --git a/README.md b/README.md index 3544649..74191cd 100644 --- a/README.md +++ b/README.md @@ -1,30 +1,249 @@ -# LSL-MeasurementComputing -(Unsupported) Labstreaminglayer application for Measurement Computing device(s). +# MCCOutlet -This application was pulled out of another private repository (ExperimentSystem). -It is no longer in use and only very limited support is available. +Streams analog input data from [Measurement Computing](https://www.mccdaq.com/) (MCC) USB DAQ devices into [Lab Streaming Layer](https://labstreaminglayer.org/) (LSL). Supports both a Qt6 GUI application and a headless CLI for servers and automated workflows. -## Build +## Features -`mkdir build` +- Continuous hardware-paced acquisition via `ulAInScan` (no dropped samples) +- **Scaled mode**: calibrated voltage output as `cf_float32` +- **Raw mode**: integer ADC counts matching device resolution (`cf_int16` for 12/16-bit, `cf_int32` for 18/24-bit) +- LSL stream metadata includes voltage range, resolution, and scaling coefficients for offline reconstruction +- Automatic FIFO overrun recovery (restarts scan transparently on USB scheduling delays) +- Per-device capability queries: supported voltage ranges, resolution, max scan rate +- Configuration file support (`.cfg`) for persistent settings +- macOS code signing and notarization for distribution -`cd build` +## Supported Devices -`cmake ..` or, in Windows, `cmake .. -G "Visual Studio 12 Win64"` +Any MCC USB DAQ device supported by [uldaq](https://github.com/mccdaq/uldaq) that provides analog input scanning. Tested with: -`make` +- USB-1608FS-Plus (16-bit, 8 SE channels, 400 kHz aggregate) -In linux, it will be further necessary to modify the udev rules so that the program can access the device without root permissions. (See Q7 [here](ftp://lx10.tx.ncsu.edu/pub/Linux/drivers/README)) +Use `--list-devices` (CLI) or the device dropdown (GUI) to see connected devices, and `--list-ranges` to query capabilities. -`sudo cp ../61-mcc.rules /etc/udev/rules.d` +## Project Structure -`sudo udevadm trigger` +``` +App-MeasurementComputing/ +├── CMakeLists.txt # Root build configuration +├── MCCOutlet.cfg # Default configuration file +├── app.entitlements # macOS network/USB capabilities +├── cmake/ +│ └── Uldaq.cmake # uldaq ExternalProject build +├── src/ +│ ├── core/ # Qt-independent core library +│ │ ├── include/mccoutlet/ +│ │ │ ├── Device.hpp # MCC device interface (uldaq wrapper) +│ │ │ ├── LSLOutlet.hpp # LSL outlet with format selection +│ │ │ ├── Config.hpp # Configuration management +│ │ │ └── StreamThread.hpp # Background streaming thread +│ │ └── src/ +│ ├── cli/ # Headless CLI application +│ │ └── main.cpp +│ └── gui/ # Qt6 GUI application +│ ├── MainWindow.hpp/cpp +│ ├── MainWindow.ui +│ └── main.cpp +├── scripts/ +│ └── sign_and_notarize.sh # macOS signing script +└── .github/workflows/ + └── build.yml # CI/CD workflow +``` -## Use +## Building -Run the application (e.g. `./MCCOutlet`) +### Prerequisites -This will create a LSL stream called MCCDaq. -It has 6 channels. -It is sampling at 16384 Hz. -This stream will push 512 float samples on every frame at 32 frames per second. +- CMake 3.28+ +- C++20 compiler (Clang 15+, GCC 12+) +- **Autotools**: `autoconf`, `automake`, `libtool` (for building uldaq) +- **libusb-1.0** +- Qt 6.8 (for GUI build; optional) + +#### macOS + +```bash +brew install autoconf automake libtool libusb cmake +# For GUI: +brew install qt@6 +``` + +#### Ubuntu/Debian + +```bash +sudo apt-get install build-essential autoconf automake libtool libusb-1.0-0-dev cmake +# For GUI: +# Qt 6.8 is not in default repos — use aqtinstall: +pip install aqtinstall +aqt install-qt linux desktop 6.8.3 gcc_64 -O ~/Qt +export CMAKE_PREFIX_PATH=~/Qt/6.8.3/gcc_64 +sudo apt-get install libgl1-mesa-dev libxkbcommon-dev libxcb-cursor0 +``` + +### Quick Start + +```bash +git clone https://github.com/labstreaminglayer/App-MeasurementComputing.git +cd App-MeasurementComputing + +cmake -S . -B build -DCMAKE_BUILD_TYPE=Release +cmake --build build --parallel + +cmake --install build --prefix build/install +``` + +uldaq is fetched and built automatically from source (via `ExternalProject`). No system-wide uldaq installation is needed. + +### Build Options + +| Option | Default | Description | +|------------------------|---------|--------------------------------------------------| +| `MCCOUTLET_BUILD_GUI` | ON | Build the GUI application (requires Qt6) | +| `MCCOUTLET_BUILD_CLI` | ON | Build the CLI application | +| `ULDAQ_GIT_TAG` | v1.2.1 | uldaq version to fetch from GitHub | +| `LSL_INSTALL_ROOT` | - | Path to installed liblsl (skips FetchContent) | +| `LSL_FETCH_REF` | v1.17.4 | liblsl git ref to fetch | + +### CLI-Only Build + +For headless systems or servers: + +```bash +cmake -S . -B build -DMCCOUTLET_BUILD_GUI=OFF +cmake --build build --parallel +``` + +## Usage + +### GUI Application + +```bash +./MCCOutlet # Use default config +./MCCOutlet myconfig.cfg # Use custom config +``` + +Select the device from the dropdown, configure channels, sample rate, voltage range, and data format (Scaled or Raw), then click **Link** to start streaming. + +### CLI Application + +```bash +MCCOutletCLI [options] + +Options: + -h, --help Show help + -l, --list-devices List connected MCC devices and exit + --list-ranges Show device capabilities (ranges, resolution, rate limits) + -c, --config FILE Load configuration from FILE + -n, --name NAME Stream name (default: MCCDaq) + -t, --type TYPE Stream type (default: RawBrainSignal) + -d, --device INDEX Device index (default: 0) + --device-name NAME Select device by product name (substring match) + --low-chan N Low channel (default: 0) + --high-chan N High channel (default: 5) + -r, --rate RATE Sample rate in Hz (default: 16384) + --range VALUE Voltage range (uldaq Range enum value, default: auto) + --raw Output raw integer ADC counts instead of scaled voltage +``` + +Examples: + +```bash +# List devices +MCCOutletCLI --list-devices + +# Query capabilities +MCCOutletCLI --list-ranges -d 0 + +# Stream 6 channels at 16384 Hz (scaled voltage) +MCCOutletCLI -d 0 --low-chan 0 --high-chan 5 --rate 16384 + +# Stream raw ADC counts +MCCOutletCLI --raw --device-name USB-1608FS + +# Use a config file +MCCOutletCLI -c MCCOutlet.cfg +``` + +### Configuration File + +```ini +# MCCOutlet.cfg +[Stream] +name=MCCDaq +type=RawBrainSignal + +[Device] +device_index=0 +low_channel=0 +high_channel=5 +sample_rate=16384 +# range=6 +# scaled=1 +``` + +## Data Formats and LSL Stream Metadata + +### Scaled Mode (default) + +Outputs calibrated voltage as `cf_float32`. Channel units are volts (`V`). + +### Raw Mode (`--raw`) + +Outputs uncalibrated ADC counts. The LSL channel format is selected based on the device's ADC resolution: + +| ADC Resolution | LSL Format | Value Range | +|----------------|-------------|---------------------| +| 12-bit | `cf_int16` | 0 - 4095 | +| 16-bit | `cf_int16` | 0 - 65535 | +| 18-bit | `cf_int32` | 0 - 262143 | +| 24-bit | `cf_int32` | 0 - 16777215 | + +### Stream Metadata + +The LSL stream's XML description always includes an `` block with scaling metadata, regardless of mode: + +```xml + + 16 + false + -10 + 10 + +/-10V + 0.000305176 + -10 + +``` + +To convert raw counts to voltage: `voltage = count * scaling_slope + scaling_offset` + +## Technical Notes + +### Sample Rate Quantization + +MCC USB devices derive their sample clock from a fixed crystal (e.g., 40 MHz on the USB-1608FS-Plus). The actual rate is `clock / integer_divisor`, so requesting 16384 Hz may yield 16386.73 Hz. The LSL stream's nominal rate reflects the device's actual rate. + +### FIFO Overrun Recovery + +USB Full Speed devices have limited hardware FIFOs (e.g., 64 KB on the USB-1608FS-Plus). If macOS USB scheduling delays cause the FIFO to overflow, MCCOutlet automatically stops and restarts the scan, logging a warning. No user intervention is needed. + +### Channel Count + +The number of available channels depends on the device and its input mode. The USB-1608FS-Plus provides 8 single-ended channels (0-7). If `high_channel` exceeds the device's maximum, it is silently clamped. + +## macOS Code Signing + +For local development, the build applies ad-hoc signing with USB and network entitlements. + +For distribution, see `scripts/sign_and_notarize.sh`. The GitHub Actions workflow handles signing and notarization automatically on release using organization secrets: + +| Secret | Description | +|------------------------------------|------------------------------------------------------------| +| `PROD_MACOS_CERTIFICATE` | Base64-encoded Developer ID Application certificate (.p12) | +| `PROD_MACOS_CERTIFICATE_PWD` | Certificate password | +| `PROD_MACOS_NOTARIZATION_APPLE_ID` | Apple ID email for notarization | +| `PROD_MACOS_NOTARIZATION_PWD` | App-specific password for notarytool | +| `PROD_MACOS_NOTARIZATION_TEAM_ID` | Apple Developer Team ID | + +## License + +MIT License - see [LICENSE](LICENSE) diff --git a/app.entitlements b/app.entitlements new file mode 100644 index 0000000..99d0921 --- /dev/null +++ b/app.entitlements @@ -0,0 +1,17 @@ + + + + + + com.apple.security.network.client + + + + com.apple.security.network.server + + + + com.apple.security.network.multicast + + + diff --git a/cmake/FindLSL.cmake b/cmake/FindLSL.cmake deleted file mode 100644 index c244ebd..0000000 --- a/cmake/FindLSL.cmake +++ /dev/null @@ -1,74 +0,0 @@ -# - Try to find the labstreaminglayer library -# -# LSL_FOUND - system has lsl -# LSL_INCLUDE_DIRS - the lsl include directory -# LSL_LIBRARIES - Link these to use lsl -# LSL_BINARIES - -set(LSL_ROOT_DIR - "${LSL_ROOT_DIR}" - CACHE - PATH - "Directory to search for LabStreamingLayer API") - - -IF (LSL_INCLUDE_DIRS AND LSL_LIBRARIES) - - # in cache already - set(LSL_FOUND TRUE) - -ELSE (LSL_INCLUDE_DIRS AND LSL_LIBRARIES) - IF(NOT LSL_ROOT_DIR) - message(STATUS "LSL_ROOT_DIR not set. Use `cmake [...] -DLSL_ROOT_DIR=") - message("\tDefaulting to ${CMAKE_CURRENT_LIST_DIR}/../../../LSL/liblsl") - SET(LSL_ROOT_DIR ${CMAKE_CURRENT_LIST_DIR}/../../../LSL/liblsl CACHE PATH "Path to local liblsl") - ENDIF() - - FIND_PATH(LSL_INCLUDE_DIRS - NAMES - lsl_cpp.h - PATHS - ${LSL_ROOT_DIR}/include - ) - - IF (${CMAKE_C_SIZEOF_DATA_PTR} EQUAL 8) - SET(_arch 64) - ELSE() - SET(_arch 32) - ENDIF() - - # LSL prefixes all libs with 'lib'. - # This is expected in UNIX. - SET(MY_PREFIX "") - # But is unusual in Windows. - IF(${CMAKE_SYSTEM_NAME} MATCHES "Windows") - SET(MY_PREFIX lib) - ENDIF() - - find_library(LSL_LIBRARY_RELEASE - NAMES ${MY_PREFIX}lsl${_arch} - PATHS ${LSL_ROOT_DIR}/bin - ) - - find_library(LSL_LIBRARY_DEBUG - NAMES ${MY_PREFIX}lsl${_arch}-debug - PATHS ${LSL_ROOT_DIR}/bin - ) - SET(LSL_LIBRARIES - debug ${LSL_LIBRARY_DEBUG} - optimized ${LSL_LIBRARY_RELEASE}) - - find_file(LSL_BINARY_RELEASE - NAMES liblsl${_arch}${CMAKE_SHARED_LIBRARY_SUFFIX} - PATHS ${LSL_ROOT_DIR}/bin - ) - find_file(LSL_BINARY_DEBUG - NAMES liblsl${_arch}-debug${CMAKE_SHARED_LIBRARY_SUFFIX} - PATHS ${LSL_ROOT_DIR}/bin - ) - - INCLUDE(FindPackageHandleStandardArgs) - FIND_PACKAGE_HANDLE_STANDARD_ARGS(LSL DEFAULT_MSG LSL_INCLUDE_DIRS LSL_LIBRARIES LSL_BINARY_DEBUG LSL_BINARY_RELEASE) - MARK_AS_ADVANCED(LSL_INCLUDE_DIRS LSL_LIBRARIES LSL_BINARY_DEBUG LSL_BINARY_RELEASE) - -ENDIF (LSL_INCLUDE_DIRS AND LSL_LIBRARIES) \ No newline at end of file diff --git a/cmake/Uldaq.cmake b/cmake/Uldaq.cmake new file mode 100644 index 0000000..ba4536c --- /dev/null +++ b/cmake/Uldaq.cmake @@ -0,0 +1,89 @@ +# ============================================================================= +# Uldaq.cmake - Measurement Computing uldaq library via ExternalProject +# ============================================================================= +# Builds uldaq from its Autotools source tree and creates an imported +# uldaq::uldaq static library target. +# +# Prerequisites (host): +# - autoconf, automake, libtool (for autoreconf) +# - libusb-1.0 +# - macOS: IOKit + CoreFoundation frameworks +# +# Provides: +# uldaq::uldaq - imported static library target +# ============================================================================= + +include(ExternalProject) +include(ProcessorCount) +ProcessorCount(NPROC) +if(NOT NPROC OR NPROC EQUAL 0) + set(NPROC 1) +endif() + +set(ULDAQ_INSTALL_DIR "${CMAKE_BINARY_DIR}/uldaq-install") +set(ULDAQ_GIT_TAG "v1.2.1" CACHE STRING "uldaq version to fetch from GitHub") + +# Platform-specific configure environment (e.g., Homebrew paths on macOS) +set(_uldaq_env "") +if(APPLE) + execute_process( + COMMAND brew --prefix + OUTPUT_VARIABLE HOMEBREW_PREFIX + OUTPUT_STRIP_TRAILING_WHITESPACE + ERROR_QUIET + ) + if(HOMEBREW_PREFIX) + list(APPEND _uldaq_env + "LDFLAGS=-L${HOMEBREW_PREFIX}/lib" + "CPPFLAGS=-I${HOMEBREW_PREFIX}/include" + ) + endif() +endif() + +ExternalProject_Add(uldaq_external + GIT_REPOSITORY https://github.com/mccdaq/uldaq.git + GIT_TAG ${ULDAQ_GIT_TAG} + GIT_SHALLOW ON + + CONFIGURE_COMMAND sh -c "cd && autoreconf --install --force" + COMMAND ${CMAKE_COMMAND} -E env ${_uldaq_env} + /configure + --prefix=${ULDAQ_INSTALL_DIR} + --disable-examples + --enable-static + --disable-shared + + BUILD_COMMAND make -j${NPROC} + # Install only from src/ to get the library and header. + # The top-level install tries to write udev rules to /lib and run ldconfig, + # both of which require root and are unnecessary for a static library build. + INSTALL_COMMAND make -C src install + BUILD_IN_SOURCE OFF + BUILD_BYPRODUCTS "${ULDAQ_INSTALL_DIR}/lib/libuldaq.a" +) + +# Create imported target for uldaq +file(MAKE_DIRECTORY "${ULDAQ_INSTALL_DIR}/include") + +add_library(uldaq::uldaq STATIC IMPORTED GLOBAL) +set_target_properties(uldaq::uldaq PROPERTIES + IMPORTED_LOCATION "${ULDAQ_INSTALL_DIR}/lib/libuldaq.a" + INTERFACE_INCLUDE_DIRECTORIES "${ULDAQ_INSTALL_DIR}/include" +) +add_dependencies(uldaq::uldaq uldaq_external) + +# uldaq's transitive dependencies +find_library(LIBUSB_LIBRARY NAMES usb-1.0 + HINTS ${HOMEBREW_PREFIX}/lib +) +if(NOT LIBUSB_LIBRARY) + message(FATAL_ERROR "libusb-1.0 not found. Install it (e.g., brew install libusb)") +endif() + +set(_uldaq_link_libs ${LIBUSB_LIBRARY}) +if(APPLE) + list(APPEND _uldaq_link_libs "-framework IOKit" "-framework CoreFoundation") +endif() +set_property(TARGET uldaq::uldaq APPEND PROPERTY + INTERFACE_LINK_LIBRARIES ${_uldaq_link_libs} +) diff --git a/scripts/sign_and_notarize.sh b/scripts/sign_and_notarize.sh new file mode 100755 index 0000000..d8073be --- /dev/null +++ b/scripts/sign_and_notarize.sh @@ -0,0 +1,119 @@ +#!/bin/bash +# ============================================================================= +# Apple Code Signing and Notarization Script +# ============================================================================= +# This script handles identity-based signing and notarization for macOS apps. +# It's designed to be called from CI (GitHub Actions) after the build. +# +# Usage: +# ./scripts/sign_and_notarize.sh [--notarize] +# +# Environment Variables (set in CI): +# APPLE_CODE_SIGN_IDENTITY_APP - Developer ID Application certificate name +# APPLE_NOTARIZE_KEYCHAIN_PROFILE - notarytool credential profile name +# ENTITLEMENTS_FILE - Path to entitlements file (optional) +# +# Examples: +# # Sign only (for testing) +# ./scripts/sign_and_notarize.sh build/install/MCCOutlet.app +# +# # Sign and notarize (for release) +# ./scripts/sign_and_notarize.sh build/install/MCCOutlet.app --notarize +# ============================================================================= + +set -e + +# Parse arguments +APP_PATH="$1" +DO_NOTARIZE=false + +if [[ "$2" == "--notarize" ]]; then + DO_NOTARIZE=true +fi + +if [[ -z "$APP_PATH" ]]; then + echo "Usage: $0 [--notarize]" + exit 1 +fi + +if [[ ! -e "$APP_PATH" ]]; then + echo "Error: $APP_PATH does not exist" + exit 1 +fi + +# Default to ad-hoc signing if no identity specified +SIGN_IDENTITY="${APPLE_CODE_SIGN_IDENTITY_APP:--}" +ENTITLEMENTS_ARG="" + +# Use entitlements if specified and exists +if [[ -n "${ENTITLEMENTS_FILE}" && -f "${ENTITLEMENTS_FILE}" ]]; then + ENTITLEMENTS_ARG="--entitlements ${ENTITLEMENTS_FILE}" +elif [[ -f "$(dirname "$0")/../app.entitlements" ]]; then + ENTITLEMENTS_ARG="--entitlements $(dirname "$0")/../app.entitlements" +fi + +echo "=== Code Signing ===" +echo "Target: $APP_PATH" +echo "Identity: $SIGN_IDENTITY" +echo "Entitlements: ${ENTITLEMENTS_ARG:-none}" + +if [[ -d "$APP_PATH" ]]; then + # App bundle - sign with deep and hardened runtime + codesign --force --deep --sign "$SIGN_IDENTITY" \ + --options runtime \ + $ENTITLEMENTS_ARG \ + "$APP_PATH" +else + # Single binary + codesign --force --sign "$SIGN_IDENTITY" \ + --options runtime \ + $ENTITLEMENTS_ARG \ + "$APP_PATH" +fi + +echo "Verifying signature..." +codesign --verify --verbose "$APP_PATH" + +# Check if we should notarize +if [[ "$DO_NOTARIZE" == true ]]; then + if [[ "$SIGN_IDENTITY" == "-" ]]; then + echo "Warning: Cannot notarize with ad-hoc signature. Skipping notarization." + exit 0 + fi + + if [[ -z "$APPLE_NOTARIZE_KEYCHAIN_PROFILE" ]]; then + echo "Error: APPLE_NOTARIZE_KEYCHAIN_PROFILE not set" + exit 1 + fi + + echo "" + echo "=== Notarizing ===" + + # Create zip for notarization submission + BASENAME=$(basename "$APP_PATH") + ZIP_PATH="/tmp/${BASENAME%.*}_notarize.zip" + + echo "Creating zip for submission: $ZIP_PATH" + ditto -c -k --keepParent "$APP_PATH" "$ZIP_PATH" + + echo "Submitting to Apple notarization service..." + xcrun notarytool submit "$ZIP_PATH" \ + --keychain-profile "$APPLE_NOTARIZE_KEYCHAIN_PROFILE" \ + --wait + + # Staple the notarization ticket + if [[ -d "$APP_PATH" ]]; then + echo "Stapling notarization ticket..." + xcrun stapler staple "$APP_PATH" + xcrun stapler validate "$APP_PATH" + fi + + # Clean up + rm -f "$ZIP_PATH" + + echo "" + echo "=== Notarization Complete ===" +fi + +echo "" +echo "Done!" diff --git a/src/cli/CMakeLists.txt b/src/cli/CMakeLists.txt new file mode 100644 index 0000000..5e107a8 --- /dev/null +++ b/src/cli/CMakeLists.txt @@ -0,0 +1,20 @@ +# CLI Application +add_executable(${PROJECT_NAME}CLI + main.cpp +) + +target_link_libraries(${PROJECT_NAME}CLI + PRIVATE + MCCOutlet::core +) + +# Windows: Copy DLLs to build directory for debugging +if(WIN32) + add_custom_command(TARGET ${PROJECT_NAME}CLI POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_if_different + $ + $ + COMMAND_EXPAND_LISTS + COMMENT "Copying runtime DLLs for ${PROJECT_NAME}CLI" + ) +endif() diff --git a/src/cli/main.cpp b/src/cli/main.cpp new file mode 100644 index 0000000..fcf3f73 --- /dev/null +++ b/src/cli/main.cpp @@ -0,0 +1,229 @@ +/** + * @file main.cpp + * @brief CLI entry point for MCCOutlet + * + * Headless version for servers, embedded systems, or automated use. + */ + +#include +#include +#include + +#include +#include +#include +#include + +namespace { + +std::atomic g_shutdown{false}; + +void signalHandler(int /*signum*/) { + std::cout << "\nShutdown requested..." << std::endl; + g_shutdown = true; +} + +void printUsage(const char* program_name) { + std::cout << "Usage: " << program_name << " [options]\n" + << "\n" + << "Options:\n" + << " -h, --help Show this help message\n" + << " -l, --list-devices List connected MCC devices and exit\n" + << " --list-ranges Show device capabilities (ranges, resolution, rate limits)\n" + << " -c, --config FILE Load configuration from FILE\n" + << " -n, --name NAME Stream name (default: MCCDaq)\n" + << " -t, --type TYPE Stream type (default: RawBrainSignal)\n" + << " -d, --device INDEX Device index (default: 0)\n" + << " --device-name NAME Select device by product name (substring match)\n" + << " --low-chan N Low channel (default: 0)\n" + << " --high-chan N High channel (default: 5)\n" + << " -r, --rate RATE Sample rate in Hz (default: 16384)\n" + << " --range VALUE Voltage range (uldaq Range enum value, default: auto)\n" + << " --raw Output raw integer ADC counts instead of scaled voltage\n" + << "\n" + << "Examples:\n" + << " " << program_name << " --list-devices\n" + << " " << program_name << " --list-ranges -d 0\n" + << " " << program_name << " --device-name USB-1608FS --rate 16384\n" + << " " << program_name << " -d 0 --low-chan 0 --high-chan 5 --range 6\n" + << std::endl; +} + +void statusCallback(const std::string& message, bool is_error) { + if (is_error) { + std::cerr << "[ERROR] " << message << std::endl; + } else { + std::cout << "[INFO] " << message << std::endl; + } +} + +} // anonymous namespace + +int main(int argc, char* argv[]) { + mccoutlet::AppConfig config; + std::string config_file; + std::string device_name; + bool list_devices = false; + bool list_ranges = false; + + for (int i = 1; i < argc; ++i) { + std::string arg = argv[i]; + + if (arg == "-h" || arg == "--help") { + printUsage(argv[0]); + return 0; + } else if (arg == "-l" || arg == "--list-devices") { + list_devices = true; + } else if (arg == "--list-ranges") { + list_ranges = true; + } else if ((arg == "-c" || arg == "--config") && i + 1 < argc) { + config_file = argv[++i]; + } else if ((arg == "-n" || arg == "--name") && i + 1 < argc) { + config.stream_name = argv[++i]; + } else if ((arg == "-t" || arg == "--type") && i + 1 < argc) { + config.stream_type = argv[++i]; + } else if ((arg == "-d" || arg == "--device") && i + 1 < argc) { + config.device_index = std::stoi(argv[++i]); + } else if (arg == "--device-name" && i + 1 < argc) { + device_name = argv[++i]; + } else if (arg == "--low-chan" && i + 1 < argc) { + config.low_channel = std::stoi(argv[++i]); + } else if (arg == "--high-chan" && i + 1 < argc) { + config.high_channel = std::stoi(argv[++i]); + } else if ((arg == "-r" || arg == "--rate") && i + 1 < argc) { + config.sample_rate = std::stod(argv[++i]); + } else if (arg == "--range" && i + 1 < argc) { + config.range = std::stoi(argv[++i]); + } else if (arg == "--raw") { + config.scaled = false; + } else { + std::cerr << "Unknown option: " << arg << std::endl; + printUsage(argv[0]); + return 1; + } + } + + // Handle --list-devices + if (list_devices) { + auto devices = mccoutlet::MCCDevice::discover(); + if (devices.empty()) { + std::cout << "No MCC DAQ devices found." << std::endl; + } else { + std::cout << "Found " << devices.size() << " device(s):" << std::endl; + for (const auto& dev : devices) { + std::cout << " [" << dev.index << "] " + << dev.product_name << " (" << dev.unique_id << ") " + << "[" << dev.interface_name << "]" << std::endl; + } + } + return 0; + } + + if (!config_file.empty()) { + auto loaded = mccoutlet::ConfigManager::load(config_file); + if (loaded) { + config = *loaded; + std::cout << "Loaded configuration from: " << config_file << std::endl; + } else { + std::cerr << "Failed to load config file: " << config_file << std::endl; + return 1; + } + } + + // Resolve --device-name to a device index + if (!device_name.empty()) { + auto devices = mccoutlet::MCCDevice::discover(); + bool found = false; + for (const auto& dev : devices) { + if (dev.product_name.find(device_name) != std::string::npos) { + config.device_index = dev.index; + std::cout << "Matched device: " << dev.product_name + << " (" << dev.unique_id << ") at index " << dev.index << std::endl; + found = true; + break; + } + } + if (!found) { + std::cerr << "No device matching '" << device_name << "' found." << std::endl; + std::cerr << "Use --list-devices to see available devices." << std::endl; + return 1; + } + } + + // Handle --list-ranges + if (list_ranges) { + try { + mccoutlet::MCCDevice::Config device_cfg; + device_cfg.device_index = config.device_index; + + mccoutlet::MCCDevice device(device_cfg); + device.connect(); + + auto caps = device.getCapabilities(); + + std::cout << "Device capabilities (index " << config.device_index << "):" << std::endl; + std::cout << " Input mode: " << caps.input_mode_name << std::endl; + std::cout << " Max channels: " << caps.max_channels << std::endl; + std::cout << " Resolution: " << caps.resolution_bits << " bits" << std::endl; + std::cout << " Scan rate: " << caps.min_scan_rate + << " - " << caps.max_scan_rate << " Hz" << std::endl; + std::cout << " Voltage ranges:" << std::endl; + for (const auto& r : caps.available_ranges) { + std::cout << " [" << r.id << "] " << r.label << std::endl; + } + + device.disconnect(); + } catch (const std::exception& e) { + std::cerr << "Error querying device: " << e.what() << std::endl; + return 1; + } + return 0; + } + + std::signal(SIGINT, signalHandler); + std::signal(SIGTERM, signalHandler); + + int channelCount = config.high_channel - config.low_channel + 1; + std::cout << "MCCOutlet CLI" << std::endl; + std::cout << "Stream: " << config.stream_name << " (" << config.stream_type << ")" << std::endl; + std::cout << "Device index: " << config.device_index << std::endl; + std::cout << "Channels: " << config.low_channel << "-" << config.high_channel + << " (" << channelCount << " ch) @ " << config.sample_rate << " Hz" << std::endl; + std::cout << "Data format: " << (config.scaled ? "Scaled (Voltage)" : "Raw (Integer Counts)") << std::endl; + + mccoutlet::MCCDevice::Config device_config{ + .stream_name = config.stream_name, + .stream_type = config.stream_type, + .device_index = config.device_index, + .low_channel = config.low_channel, + .high_channel = config.high_channel, + .sample_rate = config.sample_rate, + .range = config.range, + .scaled = config.scaled + }; + auto device = std::make_unique(device_config, statusCallback); + + mccoutlet::StreamThread stream(std::move(device), statusCallback); + + if (!stream.start()) { + std::cerr << "Failed to start streaming" << std::endl; + return 1; + } + + // Display resolution info after successful connection + auto info = stream.getDeviceInfo(); + if (info.resolution_bits > 0) { + std::cout << "ADC Resolution: " << info.resolution_bits << " bits" << std::endl; + } + + std::cout << "Press Ctrl+C to stop..." << std::endl; + + while (!g_shutdown && stream.isRunning()) { + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + } + + stream.stop(); + + std::cout << "Shutdown complete." << std::endl; + return 0; +} diff --git a/src/core/CMakeLists.txt b/src/core/CMakeLists.txt new file mode 100644 index 0000000..2596707 --- /dev/null +++ b/src/core/CMakeLists.txt @@ -0,0 +1,22 @@ +# Core library - Qt-independent, shared between CLI and GUI +add_library(mccoutlet_core STATIC + src/Device.cpp + src/LSLOutlet.cpp + src/Config.cpp + src/StreamThread.cpp +) + +target_include_directories(mccoutlet_core + PUBLIC + $ + $ +) + +target_link_libraries(mccoutlet_core + PUBLIC + LSL::lsl + Threads::Threads + uldaq::uldaq +) + +add_library(MCCOutlet::core ALIAS mccoutlet_core) diff --git a/src/core/include/mccoutlet/Config.hpp b/src/core/include/mccoutlet/Config.hpp new file mode 100644 index 0000000..d7f6cf0 --- /dev/null +++ b/src/core/include/mccoutlet/Config.hpp @@ -0,0 +1,37 @@ +#pragma once +/** + * @file Config.hpp + * @brief Configuration management for MCCOutlet + */ + +#include +#include +#include + +namespace mccoutlet { + +struct AppConfig { + // Stream settings + std::string stream_name = "MCCDaq"; + std::string stream_type = "RawBrainSignal"; + + // Device settings + int device_index = 0; + int low_channel = 0; + int high_channel = 5; + double sample_rate = 16384.0; + int range = -1; ///< uldaq Range enum value, -1 = auto-select first + bool scaled = true; ///< true = calibrated voltage, false = raw ADC counts +}; + +class ConfigManager { +public: + static std::optional load(const std::filesystem::path& path); + static bool save(const AppConfig& config, const std::filesystem::path& path); + static std::filesystem::path findConfigFile( + const std::string& filename, + const std::optional& hint = std::nullopt + ); +}; + +} // namespace mccoutlet diff --git a/src/core/include/mccoutlet/Device.hpp b/src/core/include/mccoutlet/Device.hpp new file mode 100644 index 0000000..2611d07 --- /dev/null +++ b/src/core/include/mccoutlet/Device.hpp @@ -0,0 +1,162 @@ +#pragma once +/** + * @file Device.hpp + * @brief MCC DAQ device interface using the uldaq library + * + * Implements IDevice for Measurement Computing USB DAQ devices. + * Uses ulAInScan for continuous background acquisition. + */ + +#include +#include +#include +#include +#include +#include + +// Forward-declare uldaq types to avoid exposing the C header in this interface. +// The actual uldaq.h is included only in Device.cpp. +using DaqDeviceHandle = long long; + +namespace mccoutlet { + +struct VoltageRange { + int id; ///< uldaq Range enum value + std::string label; ///< Human-readable label, e.g. "+/-5V" +}; + +struct DeviceCapabilities { + std::string input_mode_name; ///< "Single-Ended" or "Differential" + int max_channels = 0; + int resolution_bits = 0; + double min_scan_rate = 0.0; + double max_scan_rate = 0.0; + std::vector available_ranges; +}; + +struct DeviceInfo { + std::string name; + std::string type; + int channel_count = 1; + double sample_rate = 0.0; + std::string source_id; + int resolution_bits = 0; + bool scaled = true; + double range_min = 0.0; + double range_max = 0.0; + std::string range_label; +}; + +/** + * @brief Abstract base class for device implementations + */ +class IDevice { +public: + virtual ~IDevice() = default; + virtual bool connect() = 0; + virtual bool startAcquisition() = 0; + virtual void disconnect() = 0; + virtual bool isConnected() const = 0; + virtual DeviceInfo getInfo() const = 0; + virtual DeviceCapabilities getCapabilities() const = 0; + + /** + * @brief Retrieve all available data from the device + * @param buffer Resized to fit all available channel-interleaved samples + * @param timestamp Output: LSL timestamp of the most recent sample + * @return true if data was retrieved, false on error or shutdown + * + * Blocks until at least one scan is available, then drains everything + * the device has buffered. The timestamp is captured at the moment + * data availability is detected. + */ + virtual bool getData(std::vector& buffer, double& timestamp) = 0; + + /** @brief Retrieve all available raw data as int32 (for >16-bit ADC in raw mode) */ + virtual bool getDataInt32(std::vector& buffer, double& timestamp) = 0; + + /** @brief Retrieve all available raw data as int16 (for <=16-bit ADC in raw mode) */ + virtual bool getDataInt16(std::vector& buffer, double& timestamp) = 0; +}; + +/** + * @brief Description of a discovered MCC DAQ device + */ +struct DiscoveredDevice { + int index; ///< Index in the inventory (for Config::device_index) + std::string product_name; ///< e.g. "USB-1608FS-Plus" + std::string unique_id; ///< Serial number or MAC address + std::string interface_name; ///< "USB", "Bluetooth", "Ethernet" +}; + +/** + * @brief MCC DAQ device using the uldaq C library + * + * Wraps ulAInScan for continuous analog input acquisition. + * Usage: construct, call connect() to query capabilities, then + * startAcquisition() to begin scanning. + */ +class MCCDevice : public IDevice { +public: + /** + * @brief Discover all connected MCC DAQ devices + * @return List of discovered devices (may be empty) + */ + static std::vector discover(); + + struct Config { + std::string stream_name = "MCCDaq"; + std::string stream_type = "RawBrainSignal"; + int device_index = 0; ///< Index of device in discovered inventory + int low_channel = 0; + int high_channel = 5; + double sample_rate = 16384.0; + int range = -1; ///< uldaq Range enum value, -1 = auto-select first + bool scaled = true; ///< true = calibrated voltage (float), false = raw ADC counts (int) + }; + + using StatusCallback = std::function; + + explicit MCCDevice(const Config& config, StatusCallback callback = nullptr); + ~MCCDevice() override; + + bool connect() override; + bool startAcquisition() override; + void disconnect() override; + bool isConnected() const override; + DeviceInfo getInfo() const override; + DeviceCapabilities getCapabilities() const override; + bool getData(std::vector& buffer, double& timestamp) override; + bool getDataInt32(std::vector& buffer, double& timestamp) override; + bool getDataInt16(std::vector& buffer, double& timestamp) override; + +private: + bool restartScan(); + + Config config_; + StatusCallback statusCallback_; + DaqDeviceHandle handle_ = 0; + bool connected_ = false; + bool scanning_ = false; + std::atomic disconnecting_{false}; + int overrun_count_ = 0; + + // ulAInScan circular buffer (double precision, as required by uldaq) + std::vector scan_buffer_; + int scan_buffer_samples_per_chan_ = 0; + + // Track read position (cumulative scan count consumed) + unsigned long long scans_read_ = 0; + + // Detected device capabilities + int input_mode_ = 0; // AiInputMode (stored as int to avoid uldaq.h in header) + int range_ = 0; // Range (stored as int) + double actual_rate_ = 0.0; + DeviceCapabilities capabilities_; + + // Device identification + std::string product_name_; + std::string unique_id_; +}; + +} // namespace mccoutlet diff --git a/src/core/include/mccoutlet/LSLOutlet.hpp b/src/core/include/mccoutlet/LSLOutlet.hpp new file mode 100644 index 0000000..be551ae --- /dev/null +++ b/src/core/include/mccoutlet/LSLOutlet.hpp @@ -0,0 +1,37 @@ +#pragma once +/** + * @file LSLOutlet.hpp + * @brief LSL stream outlet wrapper + */ + +#include "Device.hpp" +#include +#include +#include +#include + +namespace mccoutlet { + +class LSLOutlet { +public: + explicit LSLOutlet(const DeviceInfo& info); + ~LSLOutlet(); + + LSLOutlet(const LSLOutlet&) = delete; + LSLOutlet& operator=(const LSLOutlet&) = delete; + LSLOutlet(LSLOutlet&&) noexcept = default; + LSLOutlet& operator=(LSLOutlet&&) noexcept = default; + + void pushChunk(const std::vector& data, double timestamp = 0.0); + void pushChunk(const std::vector& data, double timestamp = 0.0); + void pushChunk(const std::vector& data, double timestamp = 0.0); + void pushSample(const std::vector& sample); + std::string getStreamName() const; + bool hasConsumers() const; + +private: + std::unique_ptr outlet_; + DeviceInfo info_; +}; + +} // namespace mccoutlet diff --git a/src/core/include/mccoutlet/StreamThread.hpp b/src/core/include/mccoutlet/StreamThread.hpp new file mode 100644 index 0000000..4278afc --- /dev/null +++ b/src/core/include/mccoutlet/StreamThread.hpp @@ -0,0 +1,45 @@ +#pragma once +/** + * @file StreamThread.hpp + * @brief Background thread for LSL streaming + */ + +#include "Device.hpp" +#include "LSLOutlet.hpp" +#include +#include +#include +#include + +namespace mccoutlet { + +using StatusCallback = std::function; + +class StreamThread { +public: + explicit StreamThread( + std::unique_ptr device, + StatusCallback callback = nullptr + ); + + ~StreamThread(); + + StreamThread(const StreamThread&) = delete; + StreamThread& operator=(const StreamThread&) = delete; + + bool start(); + void stop(); + bool isRunning() const; + DeviceInfo getDeviceInfo() const; + +private: + void threadFunction(); + + std::unique_ptr device_; + std::unique_ptr thread_; + std::atomic running_{false}; + std::atomic shutdown_{false}; + StatusCallback statusCallback_; +}; + +} // namespace mccoutlet diff --git a/src/core/src/Config.cpp b/src/core/src/Config.cpp new file mode 100644 index 0000000..100b96e --- /dev/null +++ b/src/core/src/Config.cpp @@ -0,0 +1,192 @@ +#include "mccoutlet/Config.hpp" +#include +#include +#include +#include +#include + +#ifdef _WIN32 +#include +#include +#else +#include +#include +#ifdef __APPLE__ +#include +#else +#include +#endif +#endif + +namespace mccoutlet { + +namespace { + +std::string trim(const std::string& str) { + auto start = std::find_if_not(str.begin(), str.end(), + [](unsigned char c) { return std::isspace(c); }); + auto end = std::find_if_not(str.rbegin(), str.rend(), + [](unsigned char c) { return std::isspace(c); }).base(); + return (start < end) ? std::string(start, end) : std::string(); +} + +std::filesystem::path getExecutablePath() { +#ifdef _WIN32 + char buffer[MAX_PATH]; + GetModuleFileNameA(nullptr, buffer, MAX_PATH); + return std::filesystem::path(buffer).parent_path(); +#elif defined(__APPLE__) + char buffer[PATH_MAX]; + uint32_t size = sizeof(buffer); + if (_NSGetExecutablePath(buffer, &size) == 0) { + return std::filesystem::path(buffer).parent_path(); + } + return {}; +#else + char buffer[PATH_MAX]; + ssize_t len = readlink("/proc/self/exe", buffer, sizeof(buffer) - 1); + if (len != -1) { + buffer[len] = '\0'; + return std::filesystem::path(buffer).parent_path(); + } + return {}; +#endif +} + +std::filesystem::path getConfigDirectory() { +#ifdef _WIN32 + char buffer[MAX_PATH]; + if (SUCCEEDED(SHGetFolderPathA(nullptr, CSIDL_APPDATA, nullptr, 0, buffer))) { + return std::filesystem::path(buffer); + } + return {}; +#elif defined(__APPLE__) + const char* home = getenv("HOME"); + if (home) { + return std::filesystem::path(home) / "Library" / "Preferences"; + } + return {}; +#else + const char* xdg_config = getenv("XDG_CONFIG_HOME"); + if (xdg_config && *xdg_config) { + return std::filesystem::path(xdg_config); + } + const char* home = getenv("HOME"); + if (home) { + return std::filesystem::path(home) / ".config"; + } + return {}; +#endif +} + +} // anonymous namespace + +std::optional ConfigManager::load(const std::filesystem::path& path) { + std::ifstream file(path); + if (!file.is_open()) { + return std::nullopt; + } + + AppConfig config; + std::string line; + + while (std::getline(file, line)) { + line = trim(line); + + if (line.empty() || line[0] == '#' || line[0] == ';') { + continue; + } + + if (line.front() == '[' && line.back() == ']') { + continue; // Section headers are informational only + } + + auto eq_pos = line.find('='); + if (eq_pos != std::string::npos) { + std::string key = trim(line.substr(0, eq_pos)); + std::string value = trim(line.substr(eq_pos + 1)); + + if (value.size() >= 2 && value.front() == '"' && value.back() == '"') { + value = value.substr(1, value.size() - 2); + } + + if (key == "name" || key == "stream_name") { + config.stream_name = value; + } else if (key == "type" || key == "stream_type") { + config.stream_type = value; + } else if (key == "device_index" || key == "device") { + config.device_index = std::stoi(value); + } else if (key == "low_channel" || key == "low_chan") { + config.low_channel = std::stoi(value); + } else if (key == "high_channel" || key == "high_chan") { + config.high_channel = std::stoi(value); + } else if (key == "sample_rate" || key == "srate") { + config.sample_rate = std::stod(value); + } else if (key == "range" || key == "voltage_range") { + config.range = std::stoi(value); + } else if (key == "scaled") { + config.scaled = (value != "0" && value != "false"); + } + } + } + + return config; +} + +bool ConfigManager::save(const AppConfig& config, const std::filesystem::path& path) { + std::ofstream file(path); + if (!file.is_open()) { + return false; + } + + file << "# MCCOutlet Configuration\n"; + file << "# Measurement Computing DAQ to Lab Streaming Layer\n\n"; + file << "[Stream]\n"; + file << "name=" << config.stream_name << "\n"; + file << "type=" << config.stream_type << "\n"; + file << "\n"; + file << "[Device]\n"; + file << "device_index=" << config.device_index << "\n"; + file << "low_channel=" << config.low_channel << "\n"; + file << "high_channel=" << config.high_channel << "\n"; + file << "sample_rate=" << config.sample_rate << "\n"; + if (config.range >= 0) { + file << "range=" << config.range << "\n"; + } + file << "scaled=" << (config.scaled ? "1" : "0") << "\n"; + + return file.good(); +} + +std::filesystem::path ConfigManager::findConfigFile( + const std::string& filename, + const std::optional& hint +) { + if (hint && std::filesystem::exists(*hint)) { + return *hint; + } + + std::vector search_paths; + search_paths.push_back(std::filesystem::current_path()); + + auto exe_path = getExecutablePath(); + if (!exe_path.empty()) { + search_paths.push_back(exe_path); + } + + auto config_dir = getConfigDirectory(); + if (!config_dir.empty()) { + search_paths.push_back(config_dir); + } + + for (const auto& dir : search_paths) { + auto full_path = dir / filename; + if (std::filesystem::exists(full_path)) { + return full_path; + } + } + + return {}; +} + +} // namespace mccoutlet diff --git a/src/core/src/Device.cpp b/src/core/src/Device.cpp new file mode 100644 index 0000000..4ce096e --- /dev/null +++ b/src/core/src/Device.cpp @@ -0,0 +1,599 @@ +#include "mccoutlet/Device.hpp" + +#include +#include + +#include +#include +#include +#include + +namespace mccoutlet { + +namespace { + +constexpr int MAX_DEV_COUNT = 100; + +// Seconds of data to buffer in the circular scan buffer. +// Larger values tolerate more jitter but use more memory. +constexpr int SCAN_BUFFER_SECONDS = 10; + +std::string ulErrorString(UlError err) { + char msg[ERR_MSG_LEN]{}; + ulGetErrMsg(err, msg); + return std::string(msg); +} + +std::string rangeToString(int range_id) { + switch (static_cast(range_id)) { + case BIP60VOLTS: return "+/-60V"; + case BIP30VOLTS: return "+/-30V"; + case BIP15VOLTS: return "+/-15V"; + case BIP20VOLTS: return "+/-20V"; + case BIP10VOLTS: return "+/-10V"; + case BIP5VOLTS: return "+/-5V"; + case BIP4VOLTS: return "+/-4V"; + case BIP2PT5VOLTS: return "+/-2.5V"; + case BIP2VOLTS: return "+/-2V"; + case BIP1PT25VOLTS: return "+/-1.25V"; + case BIP1VOLTS: return "+/-1V"; + case BIPPT625VOLTS: return "+/-0.625V"; + case BIPPT5VOLTS: return "+/-0.5V"; + case BIPPT25VOLTS: return "+/-0.25V"; + case BIPPT125VOLTS: return "+/-0.125V"; + case BIPPT2VOLTS: return "+/-0.2V"; + case BIPPT1VOLTS: return "+/-0.1V"; + case BIPPT078VOLTS: return "+/-0.078V"; + case BIPPT05VOLTS: return "+/-0.05V"; + case BIPPT01VOLTS: return "+/-0.01V"; + case BIPPT005VOLTS: return "+/-0.005V"; + case BIP3VOLTS: return "+/-3V"; + case BIPPT312VOLTS: return "+/-0.312V"; + case BIPPT156VOLTS: return "+/-0.156V"; + case UNI60VOLTS: return "0-60V"; + case UNI30VOLTS: return "0-30V"; + case UNI15VOLTS: return "0-15V"; + case UNI20VOLTS: return "0-20V"; + case UNI10VOLTS: return "0-10V"; + case UNI5VOLTS: return "0-5V"; + case UNI4VOLTS: return "0-4V"; + case UNI2PT5VOLTS: return "0-2.5V"; + case UNI2VOLTS: return "0-2V"; + case UNI1PT25VOLTS: return "0-1.25V"; + case UNI1VOLTS: return "0-1V"; + case UNIPT625VOLTS: return "0-0.625V"; + case UNIPT5VOLTS: return "0-0.5V"; + case UNIPT25VOLTS: return "0-0.25V"; + case UNIPT125VOLTS: return "0-0.125V"; + case UNIPT2VOLTS: return "0-0.2V"; + case UNIPT1VOLTS: return "0-0.1V"; + case UNIPT078VOLTS: return "0-0.078V"; + case UNIPT05VOLTS: return "0-0.05V"; + case UNIPT01VOLTS: return "0-0.01V"; + case UNIPT005VOLTS: return "0-0.005V"; + case MA0TO20: return "0-20mA"; + default: return "Range(" + std::to_string(range_id) + ")"; + } +} + +std::pair rangeToVoltage(int range_id) { + switch (static_cast(range_id)) { + case BIP60VOLTS: return {-60.0, 60.0}; + case BIP30VOLTS: return {-30.0, 30.0}; + case BIP15VOLTS: return {-15.0, 15.0}; + case BIP20VOLTS: return {-20.0, 20.0}; + case BIP10VOLTS: return {-10.0, 10.0}; + case BIP5VOLTS: return {-5.0, 5.0}; + case BIP4VOLTS: return {-4.0, 4.0}; + case BIP2PT5VOLTS: return {-2.5, 2.5}; + case BIP2VOLTS: return {-2.0, 2.0}; + case BIP1PT25VOLTS: return {-1.25, 1.25}; + case BIP1VOLTS: return {-1.0, 1.0}; + case BIPPT625VOLTS: return {-0.625, 0.625}; + case BIPPT5VOLTS: return {-0.5, 0.5}; + case BIPPT25VOLTS: return {-0.25, 0.25}; + case BIPPT125VOLTS: return {-0.125, 0.125}; + case BIPPT2VOLTS: return {-0.2, 0.2}; + case BIPPT1VOLTS: return {-0.1, 0.1}; + case BIPPT078VOLTS: return {-0.078, 0.078}; + case BIPPT05VOLTS: return {-0.05, 0.05}; + case BIPPT01VOLTS: return {-0.01, 0.01}; + case BIPPT005VOLTS: return {-0.005, 0.005}; + case BIP3VOLTS: return {-3.0, 3.0}; + case BIPPT312VOLTS: return {-0.312, 0.312}; + case BIPPT156VOLTS: return {-0.156, 0.156}; + case UNI60VOLTS: return {0.0, 60.0}; + case UNI30VOLTS: return {0.0, 30.0}; + case UNI15VOLTS: return {0.0, 15.0}; + case UNI20VOLTS: return {0.0, 20.0}; + case UNI10VOLTS: return {0.0, 10.0}; + case UNI5VOLTS: return {0.0, 5.0}; + case UNI4VOLTS: return {0.0, 4.0}; + case UNI2PT5VOLTS: return {0.0, 2.5}; + case UNI2VOLTS: return {0.0, 2.0}; + case UNI1PT25VOLTS: return {0.0, 1.25}; + case UNI1VOLTS: return {0.0, 1.0}; + case UNIPT625VOLTS: return {0.0, 0.625}; + case UNIPT5VOLTS: return {0.0, 0.5}; + case UNIPT25VOLTS: return {0.0, 0.25}; + case UNIPT125VOLTS: return {0.0, 0.125}; + case UNIPT2VOLTS: return {0.0, 0.2}; + case UNIPT1VOLTS: return {0.0, 0.1}; + case UNIPT078VOLTS: return {0.0, 0.078}; + case UNIPT05VOLTS: return {0.0, 0.05}; + case UNIPT01VOLTS: return {0.0, 0.01}; + case UNIPT005VOLTS: return {0.0, 0.005}; + case MA0TO20: return {0.0, 20.0}; + default: return {0.0, 0.0}; + } +} + +} // anonymous namespace + +// ============================================================================ +// Discovery +// ============================================================================ + +std::vector MCCDevice::discover() { + DaqDeviceDescriptor descriptors[MAX_DEV_COUNT]; + unsigned int numDevs = MAX_DEV_COUNT; + + UlError err = ulGetDaqDeviceInventory(ANY_IFC, descriptors, &numDevs); + if (err != ERR_NO_ERROR) { + return {}; + } + + std::vector result; + result.reserve(numDevs); + + for (unsigned int i = 0; i < numDevs; ++i) { + std::string ifc; + switch (descriptors[i].devInterface) { + case USB_IFC: ifc = "USB"; break; + case BLUETOOTH_IFC: ifc = "Bluetooth"; break; + case ETHERNET_IFC: ifc = "Ethernet"; break; + default: ifc = "Unknown"; break; + } + result.push_back({ + .index = static_cast(i), + .product_name = descriptors[i].productName, + .unique_id = descriptors[i].uniqueId, + .interface_name = ifc + }); + } + + return result; +} + +// ============================================================================ +// MCCDevice +// ============================================================================ + +MCCDevice::MCCDevice(const Config& config, StatusCallback callback) + : config_(config) + , statusCallback_(std::move(callback)) +{ +} + +MCCDevice::~MCCDevice() { + disconnect(); +} + +bool MCCDevice::connect() { + if (connected_) return true; + disconnecting_ = false; + + // --- Discover devices --- + DaqDeviceDescriptor descriptors[MAX_DEV_COUNT]; + unsigned int numDevs = MAX_DEV_COUNT; + + UlError err = ulGetDaqDeviceInventory(ANY_IFC, descriptors, &numDevs); + if (err != ERR_NO_ERROR) { + throw std::runtime_error("ulGetDaqDeviceInventory failed: " + ulErrorString(err)); + } + if (numDevs == 0) { + throw std::runtime_error("No MCC DAQ devices found"); + } + if (config_.device_index < 0 || config_.device_index >= static_cast(numDevs)) { + throw std::runtime_error( + "Device index " + std::to_string(config_.device_index) + + " out of range (found " + std::to_string(numDevs) + " devices)"); + } + + auto& desc = descriptors[config_.device_index]; + product_name_ = desc.productName; + unique_id_ = desc.uniqueId; + + // --- Create and connect --- + handle_ = ulCreateDaqDevice(desc); + if (handle_ == 0) { + throw std::runtime_error("Failed to create DAQ device handle"); + } + + err = ulConnectDaqDevice(handle_); + if (err != ERR_NO_ERROR) { + ulReleaseDaqDevice(handle_); + handle_ = 0; + throw std::runtime_error("ulConnectDaqDevice failed: " + ulErrorString(err)); + } + + // --- Query device capabilities --- + long long numChans = 0; + long long hasPacer = 0; + + // Try single-ended first, then differential + err = ulAIGetInfo(handle_, AI_INFO_NUM_CHANS_BY_MODE, AI_SINGLE_ENDED, &numChans); + if (err == ERR_NO_ERROR && numChans > 0) { + input_mode_ = AI_SINGLE_ENDED; + capabilities_.input_mode_name = "Single-Ended"; + } else { + err = ulAIGetInfo(handle_, AI_INFO_NUM_CHANS_BY_MODE, AI_DIFFERENTIAL, &numChans); + if (err == ERR_NO_ERROR && numChans > 0) { + input_mode_ = AI_DIFFERENTIAL; + capabilities_.input_mode_name = "Differential"; + } else { + disconnect(); + throw std::runtime_error("Device has no supported analog input mode"); + } + } + + capabilities_.max_channels = static_cast(numChans); + + // Clamp high_channel to available channels + if (config_.high_channel >= static_cast(numChans)) { + config_.high_channel = static_cast(numChans) - 1; + } + + // Verify hardware pacer + ulAIGetInfo(handle_, AI_INFO_HAS_PACER, 0, &hasPacer); + if (!hasPacer) { + disconnect(); + throw std::runtime_error("Device does not support hardware-paced analog input"); + } + + // Query resolution + long long resolution = 0; + err = ulAIGetInfo(handle_, AI_INFO_RESOLUTION, 0, &resolution); + if (err == ERR_NO_ERROR) { + capabilities_.resolution_bits = static_cast(resolution); + } + + // Query scan rate limits + double minRate = 0.0, maxRate = 0.0; + err = ulAIGetInfoDbl(handle_, AI_INFO_MIN_SCAN_RATE, 0, &minRate); + if (err == ERR_NO_ERROR) { + capabilities_.min_scan_rate = minRate; + } + err = ulAIGetInfoDbl(handle_, AI_INFO_MAX_SCAN_RATE, 0, &maxRate); + if (err == ERR_NO_ERROR) { + capabilities_.max_scan_rate = maxRate; + } + + // Query ALL supported ranges for the detected input mode + long long numRanges = 0; + AiInfoItem rangeCountItem = (input_mode_ == AI_SINGLE_ENDED) + ? AI_INFO_NUM_SE_RANGES : AI_INFO_NUM_DIFF_RANGES; + AiInfoItem rangeItem = (input_mode_ == AI_SINGLE_ENDED) + ? AI_INFO_SE_RANGE : AI_INFO_DIFF_RANGE; + + ulAIGetInfo(handle_, rangeCountItem, 0, &numRanges); + if (numRanges <= 0) { + disconnect(); + throw std::runtime_error("Device reports no supported voltage ranges"); + } + + capabilities_.available_ranges.clear(); + for (long long i = 0; i < numRanges; ++i) { + long long rangeVal = 0; + err = ulAIGetInfo(handle_, rangeItem, static_cast(i), &rangeVal); + if (err == ERR_NO_ERROR) { + capabilities_.available_ranges.push_back({ + .id = static_cast(rangeVal), + .label = rangeToString(static_cast(rangeVal)) + }); + } + } + + // Select range: user-specified or first available + if (config_.range >= 0) { + // Validate that the requested range is available + bool found = false; + for (const auto& r : capabilities_.available_ranges) { + if (r.id == config_.range) { + found = true; + break; + } + } + if (!found) { + disconnect(); + throw std::runtime_error( + "Requested range " + rangeToString(config_.range) + + " is not supported by this device"); + } + range_ = config_.range; + } else if (!capabilities_.available_ranges.empty()) { + range_ = capabilities_.available_ranges.front().id; + } + + connected_ = true; + return true; +} + +bool MCCDevice::startAcquisition() { + if (!connected_ || handle_ == 0) { + throw std::runtime_error("Device not connected"); + } + if (scanning_) return true; + + // Validate sample rate against device limits + if (capabilities_.min_scan_rate > 0 && config_.sample_rate < capabilities_.min_scan_rate) { + throw std::runtime_error( + "Sample rate " + std::to_string(config_.sample_rate) + + " Hz is below device minimum of " + std::to_string(capabilities_.min_scan_rate) + " Hz"); + } + if (capabilities_.max_scan_rate > 0 && config_.sample_rate > capabilities_.max_scan_rate) { + throw std::runtime_error( + "Sample rate " + std::to_string(config_.sample_rate) + + " Hz exceeds device maximum of " + std::to_string(capabilities_.max_scan_rate) + " Hz"); + } + + // Allocate scan buffer and start acquisition + int channelCount = config_.high_channel - config_.low_channel + 1; + scan_buffer_samples_per_chan_ = static_cast(config_.sample_rate * SCAN_BUFFER_SECONDS); + scan_buffer_.resize( + static_cast(channelCount) * scan_buffer_samples_per_chan_, 0.0); + + actual_rate_ = config_.sample_rate; + ScanOption options = static_cast(SO_DEFAULTIO | SO_CONTINUOUS); + + AInScanFlag scan_flags = AINSCAN_FF_DEFAULT; + if (!config_.scaled) { + scan_flags = static_cast( + AINSCAN_FF_NOSCALEDATA | AINSCAN_FF_NOCALIBRATEDATA); + } + + UlError err = ulAInScan( + handle_, + config_.low_channel, + config_.high_channel, + static_cast(input_mode_), + static_cast(range_), + scan_buffer_samples_per_chan_, + &actual_rate_, + options, + scan_flags, + scan_buffer_.data() + ); + + if (err != ERR_NO_ERROR) { + scan_buffer_.clear(); + throw std::runtime_error("ulAInScan failed: " + ulErrorString(err)); + } + + scans_read_ = 0; + overrun_count_ = 0; + scanning_ = true; + return true; +} + +bool MCCDevice::restartScan() { + // Stop the current (failed) scan + ulAInScanStop(handle_); + + // Brief pause to let USB transfers settle + std::this_thread::sleep_for(std::chrono::milliseconds(50)); + + // Restart the scan into the same buffer + actual_rate_ = config_.sample_rate; + ScanOption options = static_cast(SO_DEFAULTIO | SO_CONTINUOUS); + + AInScanFlag scan_flags = AINSCAN_FF_DEFAULT; + if (!config_.scaled) { + scan_flags = static_cast( + AINSCAN_FF_NOSCALEDATA | AINSCAN_FF_NOCALIBRATEDATA); + } + + UlError err = ulAInScan( + handle_, + config_.low_channel, + config_.high_channel, + static_cast(input_mode_), + static_cast(range_), + scan_buffer_samples_per_chan_, + &actual_rate_, + options, + scan_flags, + scan_buffer_.data() + ); + + if (err != ERR_NO_ERROR) { + return false; + } + + scans_read_ = 0; + overrun_count_++; + + if (statusCallback_) { + statusCallback_( + "Device FIFO overrun detected, scan restarted (overrun #" + + std::to_string(overrun_count_) + ")", true); + } + + return true; +} + +void MCCDevice::disconnect() { + disconnecting_ = true; + + if (handle_ != 0) { + if (scanning_) { + ulAInScanStop(handle_); + scanning_ = false; + } + ulDisconnectDaqDevice(handle_); + ulReleaseDaqDevice(handle_); + handle_ = 0; + } + + scan_buffer_.clear(); + connected_ = false; +} + +bool MCCDevice::isConnected() const { + return connected_; +} + +DeviceInfo MCCDevice::getInfo() const { + int channelCount = config_.high_channel - config_.low_channel + 1; + auto [rmin, rmax] = rangeToVoltage(range_); + return { + .name = config_.stream_name, + .type = config_.stream_type, + .channel_count = channelCount, + .sample_rate = actual_rate_ > 0 ? actual_rate_ : config_.sample_rate, + .source_id = product_name_ + "_" + unique_id_, + .resolution_bits = capabilities_.resolution_bits, + .scaled = config_.scaled, + .range_min = rmin, + .range_max = rmax, + .range_label = rangeToString(range_) + }; +} + +DeviceCapabilities MCCDevice::getCapabilities() const { + return capabilities_; +} + +bool MCCDevice::getData(std::vector& buffer, double& timestamp) { + if (!connected_ || handle_ == 0) return false; + + const int channelCount = config_.high_channel - config_.low_channel + 1; + const size_t total_buffer_elements = scan_buffer_.size(); + + while (!disconnecting_) { + ScanStatus status{}; + TransferStatus xfer{}; + + UlError err = ulAInScanStatus(handle_, &status, &xfer); + if (err != ERR_NO_ERROR || status != SS_RUNNING) { + if (disconnecting_) return false; + if (!restartScan()) return false; + continue; + } + + long long available = static_cast(xfer.currentScanCount) - + static_cast(scans_read_); + + if (available > 0) { + timestamp = lsl::local_clock(); + + size_t num_elements = static_cast(available) * channelCount; + buffer.resize(num_elements); + + size_t read_offset = + (static_cast(scans_read_) * channelCount) % total_buffer_elements; + + for (size_t i = 0; i < num_elements; ++i) { + buffer[i] = static_cast( + scan_buffer_[(read_offset + i) % total_buffer_elements]); + } + + scans_read_ += available; + return true; + } + + std::this_thread::sleep_for(std::chrono::milliseconds(1)); + } + + return false; +} + +bool MCCDevice::getDataInt32(std::vector& buffer, double& timestamp) { + if (!connected_ || handle_ == 0) return false; + + const int channelCount = config_.high_channel - config_.low_channel + 1; + const size_t total_buffer_elements = scan_buffer_.size(); + + while (!disconnecting_) { + ScanStatus status{}; + TransferStatus xfer{}; + + UlError err = ulAInScanStatus(handle_, &status, &xfer); + if (err != ERR_NO_ERROR || status != SS_RUNNING) { + if (disconnecting_) return false; + if (!restartScan()) return false; + continue; + } + + long long available = static_cast(xfer.currentScanCount) - + static_cast(scans_read_); + + if (available > 0) { + timestamp = lsl::local_clock(); + + size_t num_elements = static_cast(available) * channelCount; + buffer.resize(num_elements); + + size_t read_offset = + (static_cast(scans_read_) * channelCount) % total_buffer_elements; + + for (size_t i = 0; i < num_elements; ++i) { + buffer[i] = static_cast( + scan_buffer_[(read_offset + i) % total_buffer_elements]); + } + + scans_read_ += available; + return true; + } + + std::this_thread::sleep_for(std::chrono::milliseconds(1)); + } + + return false; +} + +bool MCCDevice::getDataInt16(std::vector& buffer, double& timestamp) { + if (!connected_ || handle_ == 0) return false; + + const int channelCount = config_.high_channel - config_.low_channel + 1; + const size_t total_buffer_elements = scan_buffer_.size(); + + while (!disconnecting_) { + ScanStatus status{}; + TransferStatus xfer{}; + + UlError err = ulAInScanStatus(handle_, &status, &xfer); + if (err != ERR_NO_ERROR || status != SS_RUNNING) { + if (disconnecting_) return false; + if (!restartScan()) return false; + continue; + } + + long long available = static_cast(xfer.currentScanCount) - + static_cast(scans_read_); + + if (available > 0) { + timestamp = lsl::local_clock(); + + size_t num_elements = static_cast(available) * channelCount; + buffer.resize(num_elements); + + size_t read_offset = + (static_cast(scans_read_) * channelCount) % total_buffer_elements; + + for (size_t i = 0; i < num_elements; ++i) { + buffer[i] = static_cast( + scan_buffer_[(read_offset + i) % total_buffer_elements]); + } + + scans_read_ += available; + return true; + } + + std::this_thread::sleep_for(std::chrono::milliseconds(1)); + } + + return false; +} + +} // namespace mccoutlet diff --git a/src/core/src/LSLOutlet.cpp b/src/core/src/LSLOutlet.cpp new file mode 100644 index 0000000..966b860 --- /dev/null +++ b/src/core/src/LSLOutlet.cpp @@ -0,0 +1,95 @@ +#include "mccoutlet/LSLOutlet.hpp" + +#include + +namespace mccoutlet { + +LSLOutlet::LSLOutlet(const DeviceInfo& info) + : info_(info) +{ + lsl::channel_format_t format; + if (!info.scaled) { + format = (info.resolution_bits <= 16) ? lsl::cf_int16 : lsl::cf_int32; + } else { + format = lsl::cf_float32; + } + + lsl::stream_info stream_info( + info.name, + info.type, + info.channel_count, + info.sample_rate, + format, + info.source_id + ); + + lsl::xml_element desc = stream_info.desc(); + desc.append_child_value("manufacturer", "MeasurementComputing"); + + // Acquisition metadata (always present) + lsl::xml_element acq = desc.append_child("acquisition"); + if (info.resolution_bits > 0) { + acq.append_child_value("resolution", std::to_string(info.resolution_bits)); + } + acq.append_child_value("scaled", info.scaled ? "true" : "false"); + acq.append_child_value("range_min", std::to_string(info.range_min)); + acq.append_child_value("range_max", std::to_string(info.range_max)); + acq.append_child_value("range_label", info.range_label); + + if (!info.scaled && info.resolution_bits > 0) { + double full_scale = std::pow(2.0, info.resolution_bits); + double span = info.range_max - info.range_min; + double slope = span / full_scale; + double offset = info.range_min; + acq.append_child_value("scaling_slope", std::to_string(slope)); + acq.append_child_value("scaling_offset", std::to_string(offset)); + } + + // Channel metadata + std::string unit = info.scaled ? "V" : "count"; + lsl::xml_element channels = desc.append_child("channels"); + for (int i = 0; i < info.channel_count; ++i) { + lsl::xml_element ch = channels.append_child("channel"); + ch.append_child_value("label", "Ch" + std::to_string(i + 1)); + ch.append_child_value("unit", unit); + ch.append_child_value("type", info.type); + } + + outlet_ = std::make_unique(stream_info); +} + +LSLOutlet::~LSLOutlet() = default; + +void LSLOutlet::pushChunk(const std::vector& data, double timestamp) { + if (outlet_ && !data.empty()) { + outlet_->push_chunk_multiplexed(data, timestamp); + } +} + +void LSLOutlet::pushChunk(const std::vector& data, double timestamp) { + if (outlet_ && !data.empty()) { + outlet_->push_chunk_multiplexed(data, timestamp); + } +} + +void LSLOutlet::pushChunk(const std::vector& data, double timestamp) { + if (outlet_ && !data.empty()) { + outlet_->push_chunk_multiplexed(data, timestamp); + } +} + +void LSLOutlet::pushSample(const std::vector& sample) { + if (outlet_ && !sample.empty()) { + outlet_->push_sample(sample); + } +} + +std::string LSLOutlet::getStreamName() const { + return info_.name; +} + +bool LSLOutlet::hasConsumers() const { + return outlet_ && outlet_->have_consumers(); +} + +} // namespace mccoutlet diff --git a/src/core/src/StreamThread.cpp b/src/core/src/StreamThread.cpp new file mode 100644 index 0000000..f7e27bb --- /dev/null +++ b/src/core/src/StreamThread.cpp @@ -0,0 +1,149 @@ +#include "mccoutlet/StreamThread.hpp" +#include + +namespace mccoutlet { + +StreamThread::StreamThread( + std::unique_ptr device, + StatusCallback callback +) + : device_(std::move(device)) + , statusCallback_(std::move(callback)) +{ +} + +StreamThread::~StreamThread() { + stop(); +} + +bool StreamThread::start() { + if (running_) { + return false; + } + + if (!device_) { + if (statusCallback_) { + statusCallback_("No device configured", true); + } + return false; + } + + if (!device_->connect()) { + if (statusCallback_) { + statusCallback_("Failed to connect to device", true); + } + return false; + } + + if (!device_->startAcquisition()) { + if (statusCallback_) { + statusCallback_("Failed to start device acquisition", true); + } + device_->disconnect(); + return false; + } + + shutdown_ = false; + running_ = true; + thread_ = std::make_unique(&StreamThread::threadFunction, this); + + if (statusCallback_) { + statusCallback_("Streaming started", false); + } + + return true; +} + +void StreamThread::stop() { + if (!running_) { + return; + } + + shutdown_ = true; + + if (thread_ && thread_->joinable()) { + thread_->join(); + } + thread_.reset(); + + if (device_) { + device_->disconnect(); + } + + running_ = false; + + if (statusCallback_) { + statusCallback_("Streaming stopped", false); + } +} + +bool StreamThread::isRunning() const { + return running_; +} + +DeviceInfo StreamThread::getDeviceInfo() const { + if (device_) { + return device_->getInfo(); + } + return {}; +} + +void StreamThread::threadFunction() { + try { + auto info = device_->getInfo(); + LSLOutlet outlet(info); + + if (statusCallback_) { + statusCallback_("LSL outlet created: " + info.name, false); + } + + // Reserve ~1 second of capacity; getData resizes to actual available data + size_t reserve_elements = + static_cast(info.sample_rate) * info.channel_count; + double timestamp = 0.0; + + if (info.scaled) { + std::vector buffer; + buffer.reserve(reserve_elements); + while (!shutdown_) { + if (device_->getData(buffer, timestamp)) { + outlet.pushChunk(buffer, timestamp); + } else if (!shutdown_) { + if (statusCallback_) statusCallback_("Device acquisition error", true); + break; + } + } + } else if (info.resolution_bits <= 16) { + std::vector buffer; + buffer.reserve(reserve_elements); + while (!shutdown_) { + if (device_->getDataInt16(buffer, timestamp)) { + outlet.pushChunk(buffer, timestamp); + } else if (!shutdown_) { + if (statusCallback_) statusCallback_("Device acquisition error", true); + break; + } + } + } else { + std::vector buffer; + buffer.reserve(reserve_elements); + while (!shutdown_) { + if (device_->getDataInt32(buffer, timestamp)) { + outlet.pushChunk(buffer, timestamp); + } else if (!shutdown_) { + if (statusCallback_) statusCallback_("Device acquisition error", true); + break; + } + } + } + + } catch (const std::exception& e) { + if (statusCallback_) { + statusCallback_(std::string("Streaming error: ") + e.what(), true); + } + } + + running_ = false; +} + +} // namespace mccoutlet diff --git a/src/gui/CMakeLists.txt b/src/gui/CMakeLists.txt new file mode 100644 index 0000000..59b59c8 --- /dev/null +++ b/src/gui/CMakeLists.txt @@ -0,0 +1,51 @@ +# GUI Application +add_executable(${PROJECT_NAME} MACOSX_BUNDLE WIN32 + main.cpp + MainWindow.cpp + MainWindow.hpp + MainWindow.ui +) + +target_link_libraries(${PROJECT_NAME} + PRIVATE + MCCOutlet::core + Qt6::Core + Qt6::Widgets +) + +# macOS bundle properties +if(APPLE) + set_target_properties(${PROJECT_NAME} PROPERTIES + MACOSX_BUNDLE_GUI_IDENTIFIER "org.labstreaminglayer.${PROJECT_NAME}" + MACOSX_BUNDLE_BUNDLE_VERSION "${PROJECT_VERSION}" + MACOSX_BUNDLE_SHORT_VERSION_STRING "${PROJECT_VERSION_MAJOR}.${PROJECT_VERSION_MINOR}" + MACOSX_BUNDLE_INFO_PLIST "${CMAKE_CURRENT_SOURCE_DIR}/Info.plist.in" + ) +endif() + +# Windows: Copy DLLs to build directory for debugging +if(WIN32) + add_custom_command(TARGET ${PROJECT_NAME} POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_if_different + $ + $ + COMMAND_EXPAND_LISTS + COMMENT "Copying runtime DLLs for ${PROJECT_NAME}" + ) + get_target_property(_qmake_executable Qt6::qmake IMPORTED_LOCATION) + execute_process( + COMMAND ${_qmake_executable} -query QT_INSTALL_PLUGINS + OUTPUT_VARIABLE QT_PLUGINS_DIR + OUTPUT_STRIP_TRAILING_WHITESPACE + ) + add_custom_command(TARGET ${PROJECT_NAME} POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_directory + "${QT_PLUGINS_DIR}/platforms" + "$/platforms" + ) + add_custom_command(TARGET ${PROJECT_NAME} POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_directory + "${QT_PLUGINS_DIR}/styles" + "$/styles" + ) +endif() diff --git a/src/gui/Info.plist.in b/src/gui/Info.plist.in new file mode 100644 index 0000000..6014027 --- /dev/null +++ b/src/gui/Info.plist.in @@ -0,0 +1,30 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + ${MACOSX_BUNDLE_EXECUTABLE_NAME} + CFBundleIconFile + + CFBundleIdentifier + ${MACOSX_BUNDLE_GUI_IDENTIFIER} + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + ${MACOSX_BUNDLE_BUNDLE_NAME} + CFBundlePackageType + APPL + CFBundleShortVersionString + ${MACOSX_BUNDLE_SHORT_VERSION_STRING} + CFBundleVersion + ${MACOSX_BUNDLE_BUNDLE_VERSION} + LSMinimumSystemVersion + ${CMAKE_OSX_DEPLOYMENT_TARGET} + NSHighResolutionCapable + + NSPrincipalClass + NSApplication + + diff --git a/src/gui/MainWindow.cpp b/src/gui/MainWindow.cpp new file mode 100644 index 0000000..9827837 --- /dev/null +++ b/src/gui/MainWindow.cpp @@ -0,0 +1,330 @@ +/** + * @file MainWindow.cpp + * @brief Main window implementation for MCCOutlet GUI application + */ + +#include "MainWindow.hpp" +#include "ui_MainWindow.h" + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +MainWindow::MainWindow(const QString& config_file, QWidget* parent) + : QMainWindow(parent) + , ui_(std::make_unique()) +{ + ui_->setupUi(this); + + connect(ui_->linkButton, &QPushButton::clicked, this, &MainWindow::onLinkButtonClicked); + connect(ui_->refreshDevicesButton, &QPushButton::clicked, this, &MainWindow::onRefreshDevices); + connect(ui_->input_device, QOverload::of(&QComboBox::currentIndexChanged), + this, &MainWindow::onDeviceChanged); + connect(ui_->actionLoad_Configuration, &QAction::triggered, this, &MainWindow::onLoadConfig); + connect(ui_->actionSave_Configuration, &QAction::triggered, this, &MainWindow::onSaveConfig); + connect(ui_->actionQuit, &QAction::triggered, this, &QMainWindow::close); + connect(ui_->actionAbout, &QAction::triggered, this, &MainWindow::onAbout); + + refreshDeviceList(); + + QString cfg_path = config_file.isEmpty() ? findDefaultConfigFile() : config_file; + if (!cfg_path.isEmpty()) { + loadConfig(cfg_path); + } +} + +MainWindow::~MainWindow() { + if (stream_) { + stream_->stop(); + } +} + +void MainWindow::closeEvent(QCloseEvent* event) { + if (stream_ && stream_->isRunning()) { + auto result = QMessageBox::question( + this, + "Streaming Active", + "Streaming is still active. Stop and quit?", + QMessageBox::Yes | QMessageBox::No + ); + + if (result == QMessageBox::No) { + event->ignore(); + return; + } + + stream_->stop(); + } + + event->accept(); +} + +void MainWindow::onLinkButtonClicked() { + if (stream_ && stream_->isRunning()) { + // Stop streaming + stream_->stop(); + stream_.reset(); + setStreaming(false); + } else { + // Start streaming + int deviceIndex = ui_->input_device->currentData().toInt(); + mccoutlet::MCCDevice::Config device_config{ + .stream_name = ui_->input_name->text().toStdString(), + .stream_type = ui_->input_type->text().toStdString(), + .device_index = deviceIndex, + .low_channel = ui_->input_low_chan->value(), + .high_channel = ui_->input_high_chan->value(), + .sample_rate = ui_->input_srate->value(), + .range = ui_->input_range->currentData().toInt(), + .scaled = (ui_->input_data_format->currentIndex() == 0) + }; + + auto callback = [this](const std::string& message, bool is_error) { + QMetaObject::invokeMethod(this, [this, message, is_error]() { + updateStatus(QString::fromStdString(message), is_error); + }); + }; + + auto device = std::make_unique(device_config, callback); + + stream_ = std::make_unique(std::move(device), callback); + + if (stream_->start()) { + setStreaming(true); + } else { + stream_.reset(); + QMessageBox::warning(this, "Error", "Failed to start streaming. Check device connection."); + } + } +} + +void MainWindow::onLoadConfig() { + QString filename = QFileDialog::getOpenFileName( + this, + "Load Configuration", + last_config_path_, + "Configuration Files (*.cfg);;All Files (*)" + ); + + if (!filename.isEmpty()) { + loadConfig(filename); + } +} + +void MainWindow::onSaveConfig() { + QString filename = QFileDialog::getSaveFileName( + this, + "Save Configuration", + last_config_path_, + "Configuration Files (*.cfg);;All Files (*)" + ); + + if (!filename.isEmpty()) { + saveConfig(filename); + } +} + +void MainWindow::onRefreshDevices() { + refreshDeviceList(); +} + +void MainWindow::onDeviceChanged(int index) { + ui_->input_range->clear(); + ui_->label_resolution_value->setText("--"); + current_caps_ = {}; + + if (index < 0) return; + + int deviceIndex = ui_->input_device->currentData().toInt(); + if (deviceIndex < 0) return; + + try { + mccoutlet::MCCDevice::Config cfg; + cfg.device_index = deviceIndex; + + mccoutlet::MCCDevice tempDevice(cfg); + tempDevice.connect(); + + current_caps_ = tempDevice.getCapabilities(); + + for (const auto& r : current_caps_.available_ranges) { + ui_->input_range->addItem(QString::fromStdString(r.label), r.id); + } + + if (current_caps_.min_scan_rate > 0) { + ui_->input_srate->setMinimum(current_caps_.min_scan_rate); + } + if (current_caps_.max_scan_rate > 0) { + ui_->input_srate->setMaximum(current_caps_.max_scan_rate); + } + + if (current_caps_.resolution_bits > 0) { + ui_->label_resolution_value->setText( + QString::number(current_caps_.resolution_bits) + " bits"); + } + + tempDevice.disconnect(); + } catch (const std::exception& e) { + updateStatus(QString("Could not query device: %1").arg(e.what()), true); + } +} + +void MainWindow::refreshDeviceList() { + { + QSignalBlocker blocker(ui_->input_device); + int previousIndex = ui_->input_device->currentData().toInt(); + ui_->input_device->clear(); + + auto devices = mccoutlet::MCCDevice::discover(); + if (devices.empty()) { + ui_->input_device->addItem("(no devices found)", -1); + updateStatus("No MCC devices found. Connect a device and click Refresh.", true); + } else { + for (const auto& dev : devices) { + QString label = QString::fromStdString(dev.product_name) + + " (" + QString::fromStdString(dev.unique_id) + ")" + + " [" + QString::fromStdString(dev.interface_name) + "]"; + ui_->input_device->addItem(label, dev.index); + } + + // Try to restore previous selection + int restoreIdx = ui_->input_device->findData(previousIndex); + if (restoreIdx >= 0) { + ui_->input_device->setCurrentIndex(restoreIdx); + } + + updateStatus(QString("Found %1 device(s)").arg(devices.size()), false); + } + } + + // Manually trigger capability query for the current device + onDeviceChanged(ui_->input_device->currentIndex()); +} + +void MainWindow::onAbout() { + QString info = QString( + "

MCCOutlet

" + "

Version 2.0.0

" + "

Streams analog input data from Measurement Computing " + "DAQ devices into Lab Streaming Layer.

" + "
" + "

LSL Library: %1

" + "

Protocol: %2

" + ).arg(QString::number(lsl::library_version()), + QString::fromStdString(lsl::library_info())); + + QMessageBox::about(this, "About MCCOutlet", info); +} + +void MainWindow::loadConfig(const QString& filename) { + auto config = mccoutlet::ConfigManager::load(filename.toStdString()); + + if (config) { + ui_->input_name->setText(QString::fromStdString(config->stream_name)); + ui_->input_type->setText(QString::fromStdString(config->stream_type)); + int comboIdx = ui_->input_device->findData(config->device_index); + if (comboIdx >= 0) { + ui_->input_device->setCurrentIndex(comboIdx); + } + ui_->input_low_chan->setValue(config->low_channel); + ui_->input_high_chan->setValue(config->high_channel); + ui_->input_srate->setValue(config->sample_rate); + + if (config->range >= 0) { + int rangeIdx = ui_->input_range->findData(config->range); + if (rangeIdx >= 0) { + ui_->input_range->setCurrentIndex(rangeIdx); + } + } + + ui_->input_data_format->setCurrentIndex(config->scaled ? 0 : 1); + + last_config_path_ = filename; + updateStatus("Loaded: " + filename, false); + } else { + QMessageBox::warning( + this, + "Load Failed", + QString("Failed to load configuration from:\n%1").arg(filename) + ); + } +} + +void MainWindow::saveConfig(const QString& filename) { + mccoutlet::AppConfig config{ + .stream_name = ui_->input_name->text().toStdString(), + .stream_type = ui_->input_type->text().toStdString(), + .device_index = ui_->input_device->currentData().toInt(), + .low_channel = ui_->input_low_chan->value(), + .high_channel = ui_->input_high_chan->value(), + .sample_rate = ui_->input_srate->value(), + .range = ui_->input_range->currentData().toInt(), + .scaled = (ui_->input_data_format->currentIndex() == 0) + }; + + if (mccoutlet::ConfigManager::save(config, filename.toStdString())) { + last_config_path_ = filename; + updateStatus("Saved: " + filename, false); + } else { + QMessageBox::warning( + this, + "Save Failed", + QString("Failed to save configuration to:\n%1").arg(filename) + ); + } +} + +QString MainWindow::findDefaultConfigFile() { + QFileInfo exe_info(QCoreApplication::applicationFilePath()); + QString default_name = exe_info.completeBaseName() + ".cfg"; + + QStringList search_paths = { + QDir::currentPath(), + exe_info.absolutePath() + }; + search_paths.append(QStandardPaths::standardLocations(QStandardPaths::ConfigLocation)); + + for (const auto& path : search_paths) { + QString full_path = path + QDir::separator() + default_name; + if (QFileInfo::exists(full_path)) { + return full_path; + } + } + + return QString(); +} + +void MainWindow::updateStatus(const QString& message, bool is_error) { + ui_->statusbar->showMessage(message, is_error ? 0 : 5000); + + if (is_error) { + ui_->statusbar->setStyleSheet("color: red;"); + } else { + ui_->statusbar->setStyleSheet(""); + } +} + +void MainWindow::setStreaming(bool streaming) { + ui_->linkButton->setText(streaming ? "Unlink" : "Link"); + + ui_->input_name->setEnabled(!streaming); + ui_->input_type->setEnabled(!streaming); + ui_->input_device->setEnabled(!streaming); + ui_->refreshDevicesButton->setEnabled(!streaming); + ui_->input_low_chan->setEnabled(!streaming); + ui_->input_high_chan->setEnabled(!streaming); + ui_->input_srate->setEnabled(!streaming); + ui_->input_range->setEnabled(!streaming); + ui_->input_data_format->setEnabled(!streaming); +} diff --git a/src/gui/MainWindow.hpp b/src/gui/MainWindow.hpp new file mode 100644 index 0000000..a58fa73 --- /dev/null +++ b/src/gui/MainWindow.hpp @@ -0,0 +1,50 @@ +#pragma once +/** + * @file MainWindow.hpp + * @brief Main window for MCCOutlet GUI application + */ + +#include + +#include +#include + +namespace Ui { +class MainWindow; +} + +namespace mccoutlet { +class StreamThread; +} + +class MainWindow : public QMainWindow { + Q_OBJECT + +public: + explicit MainWindow(const QString& config_file = QString(), QWidget* parent = nullptr); + ~MainWindow() override; + +protected: + void closeEvent(QCloseEvent* event) override; + +private slots: + void onLinkButtonClicked(); + void onRefreshDevices(); + void onDeviceChanged(int index); + void onLoadConfig(); + void onSaveConfig(); + void onAbout(); + +private: + void loadConfig(const QString& filename); + void saveConfig(const QString& filename); + void refreshDeviceList(); + QString findDefaultConfigFile(); + void updateStatus(const QString& message, bool is_error); + void setStreaming(bool streaming); + + std::unique_ptr ui_; + std::unique_ptr stream_; + QString last_config_path_; + mccoutlet::DeviceCapabilities current_caps_; +}; diff --git a/src/gui/MainWindow.ui b/src/gui/MainWindow.ui new file mode 100644 index 0000000..923b71a --- /dev/null +++ b/src/gui/MainWindow.ui @@ -0,0 +1,276 @@ + + + MainWindow + + + MCCOutlet + + + + 380 + 400 + + + + + + + + + + Stream Name + + + + + + + MCCDaq + + + + + + + Stream Type + + + + + + + RawBrainSignal + + + + + + + Device + + + + + + + + + + 1 + 0 + + + + + + + + Refresh + + + + 80 + 16777215 + + + + + + + + + + Low Channel + + + + + + + 0 + + + 127 + + + 0 + + + + + + + High Channel + + + + + + + 0 + + + 127 + + + 5 + + + + + + + Sample Rate (Hz) + + + + + + + 1 + + + 1.000000000000000 + + + 500000.000000000000000 + + + 16384.000000000000000 + + + + + + + Voltage Range + + + + + + + + + + ADC Resolution + + + + + + + -- + + + + + + + Data Format + + + + + + + + Scaled (Voltage) + + + + + Raw (Integer Counts) + + + + + + + + + + Qt::Orientation::Vertical + + + + 20 + 40 + + + + + + + + + 0 + 0 + + + + + 0 + 40 + + + + Link + + + + + + + + + &File + + + + + + + + + &Help + + + + + + + + + + &Load Configuration... + + + Ctrl+L + + + + + &Save Configuration... + + + Ctrl+S + + + + + &Quit + + + Ctrl+Q + + + + + &About... + + + + + + diff --git a/src/gui/main.cpp b/src/gui/main.cpp new file mode 100644 index 0000000..9e189ab --- /dev/null +++ b/src/gui/main.cpp @@ -0,0 +1,26 @@ +/** + * @file main.cpp + * @brief GUI entry point for MCCOutlet + */ + +#include "MainWindow.hpp" +#include + +int main(int argc, char* argv[]) { + QApplication app(argc, argv); + + app.setApplicationName("MCCOutlet"); + app.setApplicationVersion("2.0.0"); + app.setOrganizationName("LabStreamingLayer"); + app.setOrganizationDomain("labstreaminglayer.org"); + + QString config_file; + if (argc > 1) { + config_file = QString::fromLocal8Bit(argv[1]); + } + + MainWindow window(config_file); + window.show(); + + return app.exec(); +}