diff --git a/.github/workflows/continuous.yml b/.github/workflows/continuous.yml index 32f94c80..8aaced34 100644 --- a/.github/workflows/continuous.yml +++ b/.github/workflows/continuous.yml @@ -79,12 +79,15 @@ jobs: `Labrador-for-macOS.dmg` — signed + notarized universal build. Needs macOS 10.13 High Sierra or newer on Intel, macOS 11 Big Sur or newer on Apple Silicon. Drag Labrador into `/Applications`. + The command-line tool is inside the bundle: + `/Applications/Labrador.app/Contents/MacOS/labrador-cli`. ## Windows `Labrador-for-Windows.exe` — installer bundling the app (64-bit and - 32-bit; it installs the one matching your Windows), firmware, and - USB driver. Run it and accept the driver-install prompt at the end. + 32-bit; it installs the one matching your Windows), the + `labrador-cli.exe` command-line tool beside it, firmware, and USB + driver. Run it and accept the driver-install prompt at the end. Tested on Windows 10 and 11; Windows 7 SP1 and 8.1 should work (the app has no newer OS dependencies) but are untested — reports welcome. @@ -94,8 +97,11 @@ jobs: - `Labrador-for-Linux-x86_64.AppImage` / `Labrador-for-Linux-arm64.AppImage` — `chmod +x` the file, then run it. Installs the udev rules on first launch. + `./Labrador-for-Linux-.AppImage cli ...` runs the + command-line tool. - `Labrador-for-Linux-x86_64.deb` / `Labrador-for-Linux-arm64.deb` — - `sudo dpkg -i `. + `sudo dpkg -i `. Installs `labrador` and `labrador-cli` on + `PATH`. Needs a distro of Ubuntu 20.04 / Debian 11 / Pi OS bullseye vintage or newer (glibc 2.31+). @@ -103,7 +109,14 @@ jobs: ## Android `Labrador-for-Android.apk` — sideload on Android 7.1 "Nougat" or later - (API level 25). + (API level 25). No command-line tool on Android. + + ## Command-line tool + + `labrador-cli` captures full-rate scope samples around a trigger to + CSV (`capture`) and flashes the bundled firmware (`update-firmware`) + without a window; see `Unified_App/cli/README.md` for the options + and examples. EOF ) # Recreate the release each run so stale assets never linger, mirroring diff --git a/.github/workflows/labrador-cli.yml b/.github/workflows/labrador-cli.yml new file mode 100644 index 00000000..3991522b --- /dev/null +++ b/.github/workflows/labrador-cli.yml @@ -0,0 +1,171 @@ +# Builds the headless labrador-cli (Unified_App/cli) on Linux, macOS and +# Windows and uploads each binary together with the bundled firmware it needs +# for `labrador-cli update-firmware`. The GUI workflows compile the same +# target as part of their default build; this one exists so the CLI has its +# own artifact and a fast signal when only cli/ or librador/ changed. +name: Build labrador-cli + +on: + push: + branches: [unified-app] + paths: + - 'Unified_App/cli/**' + - 'Unified_App/librador/**' + - 'Unified_App/firmware/**' + - 'Unified_App/CMakeLists.txt' + - '.github/workflows/labrador-cli.yml' + pull_request: + branches: [unified-app] + paths: + - 'Unified_App/cli/**' + - 'Unified_App/librador/**' + - 'Unified_App/firmware/**' + - 'Unified_App/CMakeLists.txt' + - '.github/workflows/labrador-cli.yml' + workflow_dispatch: + +jobs: + linux: + runs-on: ubuntu-22.04 + defaults: + run: + working-directory: Unified_App + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Install dependencies + run: | + sudo apt-get -o Acquire::Retries=3 update + # SDL3 is configured as part of the tree (librador links it) and + # insists on the X11/Wayland dev packages once it finds X11. + sudo apt-get -o Acquire::Retries=3 install -y \ + build-essential cmake ninja-build pkg-config libusb-1.0-0-dev \ + libgl1-mesa-dev libegl1-mesa-dev \ + libx11-dev libxext-dev libxrandr-dev libxcursor-dev libxfixes-dev \ + libxi-dev libxss-dev libxtst-dev libxkbcommon-dev \ + libwayland-dev wayland-protocols libdbus-1-dev + + - name: Build + run: | + cmake --preset linux + cmake --build --preset linux --target labrador_cli + + - name: Smoke test (no board needed) + run: | + build/linux/cli/labrador-cli --help + build/linux/cli/labrador-cli help capture + build/linux/cli/labrador-cli help update-firmware + test -f build/linux/cli/assets/firmware/labrafirm_000C_03.hex + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: labrador-cli-linux-x86_64 + path: | + Unified_App/build/linux/cli/labrador-cli + Unified_App/build/linux/cli/assets/firmware/*.hex + + macos: + runs-on: macos-latest + defaults: + run: + working-directory: Unified_App + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Dependencies + run: brew install ninja pkg-config + + # Same static universal libusb as the app build, so the binary runs on + # any Mac without a Homebrew libusb. + - name: Build universal libusb + run: | + set -euxo pipefail + curl -sSL -o libusb.tar.bz2 \ + https://github.com/libusb/libusb/releases/download/v1.0.29/libusb-1.0.29.tar.bz2 + tar xjf libusb.tar.bz2 + cd libusb-1.0.29 + ./configure --prefix="$PWD/../libusb-univ" --enable-static --disable-shared \ + CFLAGS="-arch arm64 -arch x86_64 -mmacosx-version-min=10.13" \ + LDFLAGS="-arch arm64 -arch x86_64" + make -j$(sysctl -n hw.ncpu) + make install + + - name: Build + run: | + export PKG_CONFIG_PATH="$PWD/libusb-univ/lib/pkgconfig" + cmake -S . -B build/macos -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DLABRADOR_STATIC_LIBUSB=ON \ + -DCMAKE_OSX_ARCHITECTURES="arm64;x86_64" \ + -DCMAKE_OSX_DEPLOYMENT_TARGET=10.13 + cmake --build build/macos --target labrador_cli -j$(sysctl -n hw.ncpu) + + - name: Smoke test (no board needed) + run: | + lipo -info build/macos/cli/labrador-cli + build/macos/cli/labrador-cli --help + build/macos/cli/labrador-cli help capture + build/macos/cli/labrador-cli help update-firmware + test -f build/macos/cli/assets/firmware/labrafirm_000C_03.hex + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: labrador-cli-macos-universal + path: | + Unified_App/build/macos/cli/labrador-cli + Unified_App/build/macos/cli/assets/firmware/*.hex + + windows: + runs-on: windows-latest + defaults: + run: + shell: msys2 {0} + working-directory: Unified_App + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + submodules: recursive + + - uses: msys2/setup-msys2@v2 + with: + msystem: MINGW64 + update: false + install: >- + mingw-w64-x86_64-toolchain + mingw-w64-x86_64-cmake + mingw-w64-x86_64-ninja + mingw-w64-x86_64-pkgconf + mingw-w64-x86_64-libusb + + - name: Build + run: | + cmake --preset windows + cmake --build --preset windows --target labrador_cli + + - name: Smoke test (no board needed) + run: | + ./build/windows/cli/labrador-cli.exe --help + ./build/windows/cli/labrador-cli.exe help capture + ./build/windows/cli/labrador-cli.exe help update-firmware + test -f build/windows/cli/assets/firmware/labrafirm_000C_03.hex + # Static like the app: no MinGW or libusb DLLs beside the exe. + if objdump -x build/windows/cli/labrador-cli.exe | grep "DLL Name" | grep -iE "libusb|libstdc|libgcc|libwinpthread"; then + echo "labrador-cli.exe depends on non-system DLLs"; exit 1 + fi + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: labrador-cli-windows-x86_64 + path: | + Unified_App/build/windows/cli/labrador-cli.exe + Unified_App/build/windows/cli/assets/firmware/*.hex diff --git a/.github/workflows/linux.yml b/.github/workflows/linux.yml index 6cc07347..5d221afd 100644 --- a/.github/workflows/linux.yml +++ b/.github/workflows/linux.yml @@ -1,6 +1,7 @@ # Build + package the Unified_App (SDL3/ImGui) Linux artifacts: AppImage and -# .deb for x86_64 and arm64 (Raspberry Pi), each carrying the firmware hex, udev -# rules, icon and .desktop entry. Also a Raspberry-Pi GLES2 compile check. +# .deb for x86_64 and arm64 (Raspberry Pi), each carrying the GUI, the headless +# labrador-cli, the firmware hex, udev rules, icon and .desktop entry. Also a +# Raspberry-Pi GLES2 compile check. # # Both package jobs build inside an ubuntu:20.04 container so the binaries' # glibc floor is 2.31 (Ubuntu 20.04+ / Debian 11+ / Pi OS bullseye+). 20.04 @@ -119,17 +120,20 @@ jobs: - name: Verify portability run: | set -euxo pipefail - BIN=build/appimage/labrador - # glibc floor must stay at the container's 2.31. - objdump -p "$BIN" | grep NEEDED - MAX=$(objdump -T "$BIN" | grep -oE 'GLIBC_[0-9]+\.[0-9]+(\.[0-9]+)?' | sort -Vu | tail -1) - echo "max glibc symbol: $MAX" - test "$(printf '%s\nGLIBC_2.31\n' "$MAX" | sort -V | tail -1)" = "GLIBC_2.31" - # C++ runtime and libusb are baked in, not dynamic dependencies. - if objdump -p "$BIN" | grep -E 'NEEDED.*(libstdc\+\+|libgcc_s|libusb)'; then - echo "ERROR: unexpected dynamic dependency" >&2 - exit 1 - fi + # The GUI and labrador-cli ship in the same AppImage, so both must + # meet the floors. + for BIN in build/appimage/labrador build/appimage/cli/labrador-cli; do + # glibc floor must stay at the container's 2.31. + objdump -p "$BIN" | grep NEEDED + MAX=$(objdump -T "$BIN" | grep -oE 'GLIBC_[0-9]+\.[0-9]+(\.[0-9]+)?' | sort -Vu | tail -1) + echo "$BIN max glibc symbol: $MAX" + test "$(printf '%s\nGLIBC_2.31\n' "$MAX" | sort -V | tail -1)" = "GLIBC_2.31" + # C++ runtime and libusb are baked in, not dynamic dependencies. + if objdump -p "$BIN" | grep -E 'NEEDED.*(libstdc\+\+|libgcc_s|libusb)'; then + echo "ERROR: unexpected dynamic dependency in $BIN" >&2 + exit 1 + fi + done # Both video backends (and libdecor CSD support) must be compiled in. CFG=$(find build/appimage -name SDL_build_config.h | head -1) grep -q "#define SDL_VIDEO_DRIVER_WAYLAND 1" "$CFG" @@ -147,15 +151,20 @@ jobs: "$APPDIR/usr/share/icons/hicolor/256x256/apps" \ "$APPDIR/apprun-hooks" cp build/appimage/labrador "$APPDIR/usr/bin/labrador" - # SDL_GetBasePath() resolves /proc/self/exe -> usr/bin, so assets live there. + # labrador-cli sits beside the GUI; the zz-labrador-cli.sh AppRun hook + # routes ` cli ...` (or an ARGV0 of labrador-cli) to it. + cp build/appimage/cli/labrador-cli "$APPDIR/usr/bin/labrador-cli" + # SDL_GetBasePath() resolves /proc/self/exe -> usr/bin, so assets live + # there, shared by both binaries (the CLI's update-firmware flashes + # the same hex the GUI does). cp -r assets/. "$APPDIR/usr/bin/assets/" mkdir -p "$APPDIR/usr/bin/assets/firmware" cp firmware/*.hex "$APPDIR/usr/bin/assets/firmware/" cp packaging/69-labrador.rules "$APPDIR/usr/lib/udev/rules.d/" cp packaging/espotek-labrador.desktop "$APPDIR/usr/share/applications/" cp packaging/icon256.png "$APPDIR/usr/share/icons/hicolor/256x256/apps/espotek-labrador.png" - cp packaging/linux/apprun-hooks/udev-helper.sh "$APPDIR/apprun-hooks/" - chmod +x "$APPDIR/apprun-hooks/udev-helper.sh" + cp packaging/linux/apprun-hooks/*.sh "$APPDIR/apprun-hooks/" + chmod +x "$APPDIR/apprun-hooks/"*.sh - name: Build AppImage run: | @@ -172,6 +181,28 @@ jobs: --output=appimage mv Labrador*.AppImage "Labrador-for-Linux-${{ matrix.arch }}.AppImage" + # The CLI needs no display, so it can exercise the finished AppImage here + # (APPIMAGE_EXTRACT_AND_RUN: no FUSE in the container). update-firmware + # with no board must get as far as looking for one, which proves it found + # the bundled hex under usr/bin/assets. + - name: Smoke test labrador-cli in the AppImage + run: | + set -euxo pipefail + IMG="./Labrador_${{ matrix.arch }}.AppImage" + "$IMG" cli --help + "$IMG" cli help capture + "$IMG" cli help update-firmware + if "$IMG" cli update-firmware >cli.log 2>&1; then + echo "ERROR: update-firmware succeeded with no board attached" >&2 + exit 1 + fi + cat cli.log + grep -q "no Labrador board found" cli.log + # Invoked through a symlink named labrador-cli the AppImage runtime + # sets ARGV0 to that name and the hook routes to the CLI as well. + ln -sf "$IMG" labrador-cli + ./labrador-cli --help | grep -q "command-line tools for the EspoTek Labrador" + - name: Upload artifacts uses: actions/upload-artifact@v4 with: @@ -257,10 +288,15 @@ jobs: # SDL_GetBasePath() resolves /proc/self/exe to the real dir, so assets # are found next to the binary at /usr/lib/labrador/assets. install -m 0755 build/linux/labrador "$PKG/usr/lib/labrador/labrador" + # labrador-cli lives beside the GUI so both find the same assets + # (its update-firmware flashes the bundled hex); the /usr/bin + # symlink puts it on PATH. + install -m 0755 build/linux/cli/labrador-cli "$PKG/usr/lib/labrador/labrador-cli" cp -r assets/. "$PKG/usr/lib/labrador/assets/" mkdir -p "$PKG/usr/lib/labrador/assets/firmware" cp firmware/*.hex "$PKG/usr/lib/labrador/assets/firmware/" ln -s ../lib/labrador/labrador "$PKG/usr/bin/labrador" + ln -s ../lib/labrador/labrador-cli "$PKG/usr/bin/labrador-cli" cp packaging/69-labrador.rules "$PKG/lib/udev/rules.d/" cp packaging/espotek-labrador.desktop "$PKG/usr/share/applications/" cp packaging/icon256.png "$PKG/usr/share/icons/hicolor/256x256/apps/espotek-labrador.png" @@ -281,8 +317,9 @@ jobs: Pi, or Android smartphone into a full-featured electronics lab bench, complete with oscilloscope, signal generator, and more. . - This package contains the SDL3/ImGui unified GUI plus the firmware images - it flashes to the board. + This package contains the SDL3/ImGui unified GUI, the headless + labrador-cli tool (triggered captures to CSV, firmware updates) and the + firmware images both flash to the board. EOF cat > "$PKG/DEBIAN/postinst" <<'EOF' @@ -298,6 +335,27 @@ jobs: dpkg-deb --root-owner-group --build "$PKG" "Labrador-for-Linux-${{ matrix.name }}.deb" dpkg-deb --info "Labrador-for-Linux-${{ matrix.name }}.deb" + # Install the package into the build container (root, no udev, so the + # postinst's udevadm calls are skipped) and run the CLI from PATH. + # update-firmware with no board must get as far as looking for one, + # which proves /usr/bin/labrador-cli -> /usr/lib/labrador resolves the + # bundled hex. + - name: Smoke test labrador-cli from the installed .deb + run: | + set -euxo pipefail + dpkg -i "Labrador_${{ matrix.name }}.deb" + command -v labrador-cli + labrador-cli --help + labrador-cli help capture + labrador-cli help update-firmware + if labrador-cli update-firmware >cli.log 2>&1; then + echo "ERROR: update-firmware succeeded with no board attached" >&2 + exit 1 + fi + cat cli.log + grep -q "no Labrador board found" cli.log + test -f /usr/lib/labrador/assets/firmware/labrafirm_000C_03.hex + - name: Upload artifacts uses: actions/upload-artifact@v4 with: diff --git a/.github/workflows/mac.yml b/.github/workflows/mac.yml index 7151cb83..de41231b 100644 --- a/.github/workflows/mac.yml +++ b/.github/workflows/mac.yml @@ -1,8 +1,9 @@ # Build + package the Unified_App (SDL3/ImGui) macOS app: a signed + notarized -# universal (arm64 + x86_64) .app inside a DMG. Floors: macOS 10.13 on Intel -# (SDL3's minimum and the oldest target current Xcode accepts), 11.0 on Apple -# Silicon. Reusable (called by continuous.yml) and directly dispatchable. -# Uploads asset-dmg. +# universal (arm64 + x86_64) .app inside a DMG. The headless labrador-cli ships +# inside the bundle as Contents/MacOS/labrador-cli, signed and notarized along +# with the GUI. Floors: macOS 10.13 on Intel (SDL3's minimum and the oldest +# target current Xcode accepts), 11.0 on Apple Silicon. Reusable (called by +# continuous.yml) and directly dispatchable. Uploads asset-dmg. name: Build macOS permissions: @@ -78,8 +79,12 @@ jobs: rm -rf "$APP" mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources" - # Binary + resources (bundle the firmware hex the librador flashes). + # Binaries + resources (bundle the firmware hex the librador flashes). + # labrador-cli goes in beside the GUI: SDL_GetBasePath() inside a + # bundle is Contents/Resources, so its update-firmware finds the + # same assets/firmware hex the app uses. cp build/macos/labrador "$APP/Contents/MacOS/labrador" + cp build/macos/cli/labrador-cli "$APP/Contents/MacOS/labrador-cli" cp -R assets "$APP/Contents/Resources/assets" rm -rf "$APP/Contents/Resources/assets/firmware" cp -R firmware "$APP/Contents/Resources/assets/firmware" @@ -94,17 +99,45 @@ jobs: packaging/macos/Info.plist > "$APP/Contents/Info.plist" # libusb is statically linked; the only load commands left should be - # Apple system libraries and frameworks. - otool -L "$APP/Contents/MacOS/labrador" - if otool -L "$APP/Contents/MacOS/labrador" | grep -v "^Labrador" | grep -vE "/usr/lib/|/System/Library/"; then - echo "ERROR: unexpected non-system dynamic dependency" >&2 + # Apple system libraries and frameworks. Same floors for both + # binaries: x86_64 slice 10.13 (High Sierra), arm64 11.0. + for BIN in "$APP/Contents/MacOS/labrador" "$APP/Contents/MacOS/labrador-cli"; do + lipo -info "$BIN" | grep -E "arm64.*x86_64|x86_64.*arm64" + otool -L "$BIN" + if otool -L "$BIN" | grep -v "^Labrador" | grep -vE "/usr/lib/|/System/Library/"; then + echo "ERROR: unexpected non-system dynamic dependency in $BIN" >&2 + exit 1 + fi + otool -arch x86_64 -l "$BIN" | grep -E "minos 10.13|version 10.13" + otool -arch arm64 -l "$BIN" | grep -E "minos 11.0|version 11.0" | head -1 + done + + # The CLI needs no display, so it can exercise the assembled bundle. + # update-firmware with no board must get as far as looking for one, which + # proves it resolved the hex under Contents/Resources/assets/firmware. + - name: Smoke test labrador-cli in the bundle + run: | + set -euxo pipefail + CLI=Labrador.app/Contents/MacOS/labrador-cli + "$CLI" --help + "$CLI" help capture + "$CLI" help update-firmware + if "$CLI" update-firmware >cli.log 2>&1; then + echo "ERROR: update-firmware succeeded with no board attached" >&2 + exit 1 + fi + cat cli.log + grep -q "no Labrador board found" cli.log + # Users are told to symlink it onto PATH; the bundle lookup must + # still find Contents/Resources through the link. + mkdir -p "$RUNNER_TEMP/bin" + ln -sf "$PWD/$CLI" "$RUNNER_TEMP/bin/labrador-cli" + if PATH="$RUNNER_TEMP/bin:$PATH" labrador-cli update-firmware >cli-link.log 2>&1; then + echo "ERROR: update-firmware succeeded with no board attached" >&2 exit 1 fi - # Deployment floors: x86_64 slice 10.13 (High Sierra), arm64 11.0. - otool -arch x86_64 -l "$APP/Contents/MacOS/labrador" \ - | grep -E "minos 10.13|version 10.13" - otool -arch arm64 -l "$APP/Contents/MacOS/labrador" \ - | grep -E "minos 11.0|version 11.0" | head -1 + cat cli-link.log + grep -q "no Labrador board found" cli-link.log - name: Import code signing certificate if: env.MACOS_CERTIFICATE != '' && env.MACOS_CERTIFICATE_PWD != '' @@ -125,8 +158,15 @@ jobs: run: | IDENTITY=$(security find-identity -v -p codesigning | grep "Developer ID Application" | head -1 | awk -F'"' '{print $2}') echo "Using signing identity: $IDENTITY" + # Inside out: the helper executable first (notarization requires + # every Mach-O in the bundle to carry a hardened-runtime signature + # of its own), then the bundle. + codesign --force --timestamp --options runtime --sign "$IDENTITY" \ + Labrador.app/Contents/MacOS/labrador-cli codesign --force --deep --timestamp --options runtime --sign "$IDENTITY" Labrador.app codesign -vv --deep --strict Labrador.app + codesign -vv --strict Labrador.app/Contents/MacOS/labrador-cli + codesign -d --verbose=2 Labrador.app/Contents/MacOS/labrador-cli 2>&1 | grep -E "flags=.*runtime" echo "App bundle signed successfully" - name: Create DMG diff --git a/.github/workflows/windows.yml b/.github/workflows/windows.yml index 06fd5394..224337a6 100644 --- a/.github/workflows/windows.yml +++ b/.github/workflows/windows.yml @@ -7,7 +7,8 @@ # static (MinGW runtime + libusb baked in; only OS DLLs remain dynamic); the # installer bundles them with the assets + firmware and the unpacked USB # driver packages (whose dpinst32/x86 libusbK.sys already covered 32-bit), -# installed silently during setup. +# installed silently during setup. The headless labrador-cli.exe (same +# static build, both arches) installs beside labrador.exe. # Reusable (called by continuous.yml) and directly dispatchable. Uploads # asset-windows-installer. name: Build Windows @@ -52,19 +53,24 @@ jobs: - name: Verify static link run: | set -euxo pipefail - EXE=build/windows32/labrador.exe - i686-w64-mingw32-objdump -x "$EXE" | grep "DLL Name" - if i686-w64-mingw32-objdump -x "$EXE" | grep "DLL Name" | grep -iE "libusb|libstdc|libgcc|libwinpthread"; then - echo "ERROR: unexpected non-system DLL import in $EXE" >&2 - exit 1 - fi - i686-w64-mingw32-objdump -f "$EXE" | grep "pei-i386" + for EXE in build/windows32/labrador.exe build/windows32/cli/labrador-cli.exe; do + i686-w64-mingw32-objdump -x "$EXE" | grep "DLL Name" + if i686-w64-mingw32-objdump -x "$EXE" | grep "DLL Name" | grep -iE "libusb|libstdc|libgcc|libwinpthread"; then + echo "ERROR: unexpected non-system DLL import in $EXE" >&2 + exit 1 + fi + i686-w64-mingw32-objdump -f "$EXE" | grep "pei-i386" + done - - name: Upload x86 exe + # Both exes; upload-artifact keeps their paths relative to the common + # build/windows32 root, so they land as labrador.exe and cli/labrador-cli.exe. + - name: Upload x86 exes uses: actions/upload-artifact@v4 with: name: win32-exe - path: Unified_App/build/windows32/labrador.exe + path: | + Unified_App/build/windows32/labrador.exe + Unified_App/build/windows32/cli/labrador-cli.exe if-no-files-found: error build-windows: @@ -90,7 +96,7 @@ jobs: mingw-w64-x86_64-ninja mingw-w64-x86_64-pkgconf - - name: Download x86 exe + - name: Download x86 exes uses: actions/download-artifact@v4 with: name: win32-exe @@ -106,14 +112,16 @@ jobs: set -euxo pipefail # Import table must hold only Windows system DLLs - no MinGW # runtime, no libusb. - objdump -x build/windows/labrador.exe | grep "DLL Name" - if objdump -x build/windows/labrador.exe | grep "DLL Name" | grep -iE "libusb|libstdc|libgcc|libwinpthread"; then - echo "ERROR: unexpected non-system DLL import" >&2 - exit 1 - fi - objdump -f build/windows/labrador.exe | grep "pei-x86-64" - # Loader-level check (resolves every import). - ldd build/windows/labrador.exe + for EXE in build/windows/labrador.exe build/windows/cli/labrador-cli.exe; do + objdump -x "$EXE" | grep "DLL Name" + if objdump -x "$EXE" | grep "DLL Name" | grep -iE "libusb|libstdc|libgcc|libwinpthread"; then + echo "ERROR: unexpected non-system DLL import in $EXE" >&2 + exit 1 + fi + objdump -f "$EXE" | grep "pei-x86-64" + # Loader-level check (resolves every import). + ldd "$EXE" + done - name: Assemble staging tree run: | @@ -124,6 +132,10 @@ jobs: # labrador.exe. cp build/windows/labrador.exe "$STAGE/labrador64.exe" cp win32-exe/labrador.exe "$STAGE/labrador32.exe" + # labrador-cli.exe installs beside labrador.exe and shares its + # assets\firmware (SDL_GetBasePath() = exe dir for both). + cp build/windows/cli/labrador-cli.exe "$STAGE/labrador-cli64.exe" + cp win32-exe/cli/labrador-cli.exe "$STAGE/labrador-cli32.exe" # Assets (+ firmware) next to the exe; SDL_GetBasePath() = exe dir. cp -r assets/. "$STAGE/assets/" mkdir -p "$STAGE/assets/firmware" @@ -142,6 +154,26 @@ jobs: cp ../Desktop_Interface/build_win/appicon.ico packaging/windows/appicon.ico ls -R "$STAGE" | head -60 + # The CLI needs no display, so it can exercise the staged layout the + # installer reproduces under {app}. update-firmware with no board must + # get as far as looking for one, which proves it found assets\firmware + # next to the exe. + - name: Smoke test labrador-cli from the staging tree + run: | + set -euxo pipefail + STAGE=packaging/windows/staging + cp "$STAGE/labrador-cli64.exe" "$STAGE/labrador-cli.exe" + "$STAGE/labrador-cli.exe" --help + "$STAGE/labrador-cli.exe" help capture + "$STAGE/labrador-cli.exe" help update-firmware + if "$STAGE/labrador-cli.exe" update-firmware >cli.log 2>&1; then + echo "ERROR: update-firmware succeeded with no board attached" >&2 + exit 1 + fi + cat cli.log + grep -q "no Labrador board found" cli.log + rm "$STAGE/labrador-cli.exe" + - name: Install Inno Setup shell: pwsh run: choco install innosetup -y --no-progress diff --git a/Unified_App/CMakeLists.txt b/Unified_App/CMakeLists.txt index 1e9d8c59..e54e12e1 100644 --- a/Unified_App/CMakeLists.txt +++ b/Unified_App/CMakeLists.txt @@ -181,6 +181,8 @@ if(NOT ANDROID) # counters. Expected on firmware 0x000C: 100.00% pass, 0 dropped. add_executable(librador_bulk_test tools/librador_bulk_test.cpp) target_link_libraries(librador_bulk_test PRIVATE librador) + + add_subdirectory(cli) endif() if(APPLE AND NOT ANDROID) diff --git a/Unified_App/README.md b/Unified_App/README.md index 0fe59258..cf008abf 100644 --- a/Unified_App/README.md +++ b/Unified_App/README.md @@ -80,6 +80,22 @@ APK lands in `android/app/build/outputs/apk/debug/`. - Settings persist to `settings.ini` in the per-user pref dir (macOS: `~/Library/Application Support/EspoTek/Labrador/`). +## Command-line tool + +`labrador-cli` is a headless companion built from the same tree +(`cli/`, target `labrador_cli`, lands in `build//cli/`). It captures +full-rate scope samples around a trigger to CSV and flashes the bundled +firmware, for scripts and headless boxes. The installers ship it: beside +`labrador.exe` on Windows, inside `Labrador.app/Contents/MacOS` on macOS, on +`PATH` from the `.deb`, and as ` cli ...` from the AppImage. + +```sh +labrador-cli capture --trigger ch1:rising:1.5 --pre 0.05 --post 0.1 --out edge.csv +labrador-cli update-firmware +``` + +Options, output format and more examples: [`cli/README.md`](cli/README.md). + ## UI tests (QA builds) ```sh @@ -128,6 +144,7 @@ src/ui/ One Frontend per form factor (desktop, android, lowres) src/instruments/ Instrument widgets shared by the desktop/compact layouts src/qa/ --qa test suite (QA builds only) src/platform/ File dialogs, paths, Android glue +cli/ labrador-cli: headless capture / firmware tool over librador librador/ Merged librador (USB protocol, decoding, DAQ) deps/ Vendored SDL3/ImGui/ImPlot forks, test engine, stb, exprtk assets/ Fonts (with licenses), pinout images, help text diff --git a/Unified_App/cli/CMakeLists.txt b/Unified_App/cli/CMakeLists.txt new file mode 100644 index 00000000..b65f390e --- /dev/null +++ b/Unified_App/cli/CMakeLists.txt @@ -0,0 +1,17 @@ +# Headless command-line tools over librador. To add a command: implement Command, +# register it in main.cpp, and list its source here. +add_executable(labrador_cli + main.cpp + CaptureCommand.cpp + DeviceSession.cpp + OptionParser.cpp + UpdateFirmwareCommand.cpp) +set_target_properties(labrador_cli PROPERTIES OUTPUT_NAME labrador-cli) +target_link_libraries(labrador_cli PRIVATE librador) + +# update-firmware flashes the hex the Labrador app bundles; dev builds get the +# same assets/firmware layout next to the binary (packaging replaces this). +add_custom_command(TARGET labrador_cli POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_directory + ${CMAKE_CURRENT_SOURCE_DIR}/../firmware + $/assets/firmware) diff --git a/Unified_App/cli/CaptureCommand.cpp b/Unified_App/cli/CaptureCommand.cpp new file mode 100644 index 00000000..460aee70 --- /dev/null +++ b/Unified_App/cli/CaptureCommand.cpp @@ -0,0 +1,295 @@ +#include "CaptureCommand.h" + +#include "DeviceSession.h" +#include "OptionParser.h" + +#include "librador.h" + +#include +#include +#include +#include +#include + +namespace +{ +struct Options +{ + std::string out; + ScopeMode mode = ScopeMode::Ch1Ch2; + double gain = 4; + double settle_s = 0.5; + UsbTransport transport = UsbTransport::Auto; // librador's per-platform choice + bool verbose = false; + librador_capture_request request; // trigger, hysteresis, pre/post, timeout +}; + +std::string formatNumber(double value) +{ + std::ostringstream text; + text << value; + return text.str(); +} + +double parseSeconds(const std::string& option, const std::string& text) +{ + const double value = OptionParser::toDouble(option, text); + if (value < 0) + throw UsageError("option '" + option + "' must not be negative"); + return value; +} + +ScopeMode parseMode(const std::string& text) +{ + if (text == "scope1") + return ScopeMode::Ch1; + if (text == "scope2") + return ScopeMode::Ch1Ch2; + throw UsageError("option '--mode' must be scope1 or scope2, got '" + text + "'"); +} + +UsbTransport parseTransport(const std::string& text) +{ + if (text == "auto") + return UsbTransport::Auto; + if (text == "iso6") + return UsbTransport::Iso6; + if (text == "iso1") + return UsbTransport::Iso1; + if (text == "bulk") + return UsbTransport::Bulk; + throw UsageError("option '--transport' must be auto, iso6, iso1 or bulk, got '" + text + + "'"); +} + +void parseTrigger(const std::string& spec, librador_capture_request& request) +{ + std::vector parts; + std::istringstream in(spec); + for (std::string part; std::getline(in, part, ':');) + parts.push_back(part); + + if (parts.size() != 3 || (parts[0] != "ch1" && parts[0] != "ch2") || + (parts[1] != "rising" && parts[1] != "falling")) + throw UsageError("option '--trigger' expects CH:EDGE:VOLTS such as " + "ch1:rising:3.0, got '" + + spec + "'"); + + request.trigger_channel = parts[0] == "ch1" ? 1 : 2; + request.rising = parts[1] == "rising"; + request.level_v = OptionParser::toDouble("--trigger", parts[2]); +} + +OptionParser makeParser(Options& options) +{ + const Options defaults; + const librador_capture_request& d = defaults.request; + OptionParser parser; + + parser.addOption("--out", "FILE", + "CSV file to write (required); FILE.json gets the metadata", + [&](const std::string& v) { options.out = v; }); + parser.addOption("--mode", "MODE", + "scope1 (CH1 only) or scope2 (CH1 and CH2); default scope2", + [&](const std::string& v) { options.mode = parseMode(v); }); + parser.addOption("--gain", "G", + "hardware gain 0.5, 1, 2, 4, 8, 16, 32 or 64; default " + + formatNumber(defaults.gain), + [&](const std::string& v) + { + const double gain = OptionParser::toDouble("--gain", v); + if (!DeviceSession::isValidScopeGain(gain)) + throw UsageError("option '--gain' must be one of 0.5, 1, " + "2, 4, 8, 16, 32, 64, got '" + + v + "'"); + options.gain = gain; + }); + parser.addOption("--trigger", "CH:EDGE:VOLTS", + "channel (ch1|ch2), edge (rising|falling), level; default " + "ch1:rising:" + formatNumber(d.level_v), + [&](const std::string& v) { parseTrigger(v, options.request); }); + parser.addOption("--hysteresis", "VOLTS", + "signal must leave the level by this much to re-arm; default " + + formatNumber(d.hysteresis_v), + [&](const std::string& v) + { options.request.hysteresis_v = parseSeconds("--hysteresis", v); }); + parser.addOption("--pre", "SECONDS", + "keep this long before the trigger; default " + + formatNumber(d.pre_s), + [&](const std::string& v) + { options.request.pre_s = parseSeconds("--pre", v); }); + parser.addOption("--post", "SECONDS", + "keep this long after the trigger; default " + + formatNumber(d.post_s), + [&](const std::string& v) + { options.request.post_s = parseSeconds("--post", v); }); + parser.addOption("--timeout", "SECONDS", + "give up if no trigger; 0 waits forever (default)", + [&](const std::string& v) + { options.request.timeout_s = parseSeconds("--timeout", v); }); + parser.addOption("--settle", "SECONDS", + "pause after configuring the scope; default " + + formatNumber(defaults.settle_s), + [&](const std::string& v) + { options.settle_s = parseSeconds("--settle", v); }); + parser.addOption("--transport", "T", + "USB transport: auto (the platform default: iso6 on Windows and " + "Linux, bulk on macOS), iso6, iso1 or bulk; default auto", + [&](const std::string& v) { options.transport = parseTransport(v); }); + parser.addFlag("--verbose", "also show librador's debug messages on stderr", + [&]() { options.verbose = true; }); + return parser; +} + +void validate(const Options& options) +{ + if (options.out.empty()) + throw UsageError("option '--out' is required"); + if (options.request.trigger_channel == 2 && options.mode == ScopeMode::Ch1) + throw UsageError("triggering on ch2 needs '--mode scope2'"); + if (options.request.pre_s + options.request.post_s <= 0) + throw UsageError("'--pre' and '--post' cannot both be zero"); +} + +std::string describeCaptureError(int rc) +{ + switch (rc) + { + case -3: + return "the trigger channel is not streaming in this scope mode"; + case -4: + return "invalid window: '--pre' plus '--post' must fit in the 10 s sample " + "buffer with 0.5 s to spare"; + case -5: + return "timed out waiting for the trigger"; + case -6: + return "the sample stream restarted during the capture"; + default: + return "librador error " + std::to_string(rc); + } +} + +void writeCsv(std::ostream& out, const librador_capture_result& result) +{ + const bool two_channels = !result.ch2.empty(); + out << (two_channels ? "t,CH1,CH2\n" : "t,CH1\n"); + + char line[96]; + for (size_t i = 0; i < result.ch1.size(); i++) + { + const double t = (static_cast(i) - result.pre_samples) / + result.sample_rate_hz; + const int length = + two_channels + ? std::snprintf(line, sizeof line, "%.7f,%.4f,%.4f\n", t, + result.ch1[i], result.ch2[i]) + : std::snprintf(line, sizeof line, "%.7f,%.4f\n", t, result.ch1[i]); + out.write(line, length); + } + out.flush(); + if (!out) + throw CommandError("writing the CSV failed"); +} + +void writeMetadata(const std::string& path, const Options& options, + const librador_capture_result& result) +{ + std::ofstream json(path, std::ios::binary | std::ios::trunc); + json << "{\n" + << " \"sample_rate_hz\": " << result.sample_rate_hz << ",\n" + << " \"samples\": " << result.ch1.size() << ",\n" + << " \"pre_samples\": " << result.pre_samples << ",\n" + << " \"trigger\": {\"channel\": " << options.request.trigger_channel + << ", \"edge\": \"" << (options.request.rising ? "rising" : "falling") + << "\", \"level_v\": " << options.request.level_v + << ", \"hysteresis_v\": " << options.request.hysteresis_v << "},\n" + << " \"gain\": " << options.gain << ",\n" + << " \"mode\": \"" + << (options.mode == ScopeMode::Ch1 ? "scope1" : "scope2") << "\",\n" + << " \"frames_bad_checksum\": " << result.frames_bad_checksum << ",\n" + << " \"frames_dropped\": " << result.frames_dropped << "\n" + << "}\n"; + json.flush(); + if (!json) + throw CommandError("writing '" + path + "' failed"); +} +} // namespace + +std::string CaptureCommand::name() const +{ + return "capture"; +} + +std::string CaptureCommand::summary() const +{ + return "Save full-rate scope samples around a trigger edge to a CSV file"; +} + +void CaptureCommand::printHelp(std::ostream& out, const std::string& program) const +{ + Options unused; + out << program << " capture - " << summary() << "\n\n" + << "Arms an edge trigger on one scope channel, waits for it to fire, and writes\n" + << "the samples from --pre seconds before to --post seconds after the trigger at\n" + << "the board's full rate (375 kS/s per channel). \"armed: ...\" appears on\n" + << "stderr once the trigger is waiting; press Ctrl-C to abort.\n\n" + << "Usage:\n" + << " " << program << " capture --out FILE [options]\n\n" + << "Options:\n"; + makeParser(unused).printOptions(out); + out << "\nOutput:\n" + << " FILE is CSV with header \"t,CH1,CH2\" (\"t,CH1\" in scope1 mode): t is in\n" + << " seconds with 0 at the trigger sample, the channels are volts at the input\n" + << " pins. FILE.json records the sample rate, trigger, gain and any bad or\n" + << " dropped USB frames seen while waiting.\n\n" + << "Notes:\n" + << " Full scale is about 1.65 V +/- 23.65 V / gain; larger signals clip, so\n" + << " pick the lowest gain that keeps the signal in range.\n" + << " The board can only be open in one program: close the Labrador app first.\n\n" + << "Example:\n" + << " " << program << " capture --gain 4 --trigger ch1:rising:3.0 \\\n" + << " --pre 0.2 --post 0.3 --out capture.csv\n"; +} + +int CaptureCommand::run(const std::vector& args) +{ + Options options; + makeParser(options).parse(args); + validate(options); + + // Opened before arming so an unwritable path fails immediately, not after the wait. + std::ofstream csv(options.out, std::ios::binary | std::ios::trunc); + if (!csv) + throw CommandError("cannot open '" + options.out + "' for writing"); + + DeviceSession session(options.verbose, options.transport); + session.configureScope(options.mode, options.gain, options.settle_s); + + std::cerr << "armed: ch" << options.request.trigger_channel << ' ' + << (options.request.rising ? "rising" : "falling") << " through " + << options.request.level_v << " V, pre " << options.request.pre_s + << " s, post " << options.request.post_s << " s" << std::endl; + + librador_capture_result result; + const int rc = librador_capture_around_trigger(&options.request, &result); + if (rc != 0) + { + csv.close(); + std::remove(options.out.c_str()); + throw CommandError("capture failed: " + describeCaptureError(rc)); + } + + writeCsv(csv, result); + writeMetadata(options.out + ".json", options, result); + + std::cout << "wrote " << options.out << ": " << result.ch1.size() + << " samples at " << result.sample_rate_hz << " Hz (" + << (result.ch2.empty() ? "CH1" : "CH1+CH2") << "), trigger at t=0 (sample " + << result.pre_samples << ")" << std::endl; + if (result.frames_bad_checksum || result.frames_dropped) + std::cerr << "warning: " << result.frames_bad_checksum + << " bad-checksum and " << result.frames_dropped + << " dropped USB frames while waiting; the window may contain " + "corrupt or repeated samples\n"; + return EXIT_SUCCESS; +} diff --git a/Unified_App/cli/CaptureCommand.h b/Unified_App/cli/CaptureCommand.h new file mode 100644 index 00000000..5f020fc4 --- /dev/null +++ b/Unified_App/cli/CaptureCommand.h @@ -0,0 +1,14 @@ +#pragma once + +#include "Command.h" + +// `labrador-cli capture`: arm an edge trigger on a scope channel and save the +// full-rate samples around it as CSV. +class CaptureCommand : public Command +{ + public: + std::string name() const override; + std::string summary() const override; + void printHelp(std::ostream& out, const std::string& program) const override; + int run(const std::vector& args) override; +}; diff --git a/Unified_App/cli/Command.h b/Unified_App/cli/Command.h new file mode 100644 index 00000000..3910182a --- /dev/null +++ b/Unified_App/cli/Command.h @@ -0,0 +1,51 @@ +#pragma once + +#include +#include +#include +#include +#include + +// Exit statuses shared by every command: EXIT_SUCCESS (0) plus these two. +constexpr int EXIT_RUNTIME_ERROR = 1; // the command line was fine but the work failed +constexpr int EXIT_USAGE_ERROR = 2; // the command line itself was wrong + +// Malformed command line. main() prints it with a pointer to --help. +class UsageError : public std::runtime_error +{ + public: + using std::runtime_error::runtime_error; +}; + +// A well-formed command that failed at runtime (no board, capture timed out, ...). +class CommandError : public std::runtime_error +{ + public: + using std::runtime_error::runtime_error; +}; + +// One subcommand of labrador-cli. To add one: implement this interface, then list +// it in main.cpp and cli/CMakeLists.txt. +class Command +{ + public: + virtual ~Command() = default; + + // Word typed after the program name, e.g. "capture". + virtual std::string name() const = 0; + + // One line for the top-level command list. + virtual std::string summary() const = 0; + + virtual void printHelp(std::ostream& out, const std::string& program) const = 0; + + // `args` excludes the program and command names. Throws UsageError or + // CommandError; returns the exit status otherwise. + virtual int run(const std::vector& args) = 0; +}; + +inline bool wantsHelp(const std::vector& args) +{ + return std::any_of(args.begin(), args.end(), [](const std::string& arg) + { return arg == "-h" || arg == "--help"; }); +} diff --git a/Unified_App/cli/DeviceSession.cpp b/Unified_App/cli/DeviceSession.cpp new file mode 100644 index 00000000..9b401318 --- /dev/null +++ b/Unified_App/cli/DeviceSession.cpp @@ -0,0 +1,99 @@ +#include "DeviceSession.h" + +#include "Command.h" + +#include "librador.h" + +#include +#include +#include +#include +#include +#include + +namespace +{ +constexpr std::array SCOPE_GAINS = {0.5, 1, 2, 4, 8, 16, 32, 64}; + +// librador device modes: 0 = CH1 scope, 2 = CH1 + CH2 scope. +constexpr int DEVICE_MODE_CH1 = 0; +constexpr int DEVICE_MODE_CH1_CH2 = 2; + +void logToStderr(void* userdata, const int level, const char* format, va_list args) +{ + const bool verbose = *static_cast(userdata); + if (level == LOG_DEBUG && !verbose) + return; + std::vfprintf(stderr, format, args); +} +} // namespace + +DeviceSession::DeviceSession(bool verbose, UsbTransport transport) : m_verbose(verbose) +{ + routeLogsToStderr(&m_verbose); + + const int librador_transport = transport == UsbTransport::Iso6 ? LABRADOR_TRANSPORT_ISO6 + : transport == UsbTransport::Iso1 ? LABRADOR_TRANSPORT_ISO1 + : transport == UsbTransport::Bulk ? LABRADOR_TRANSPORT_BULK + : LABRADOR_TRANSPORT_AUTO; + if (librador_init(librador_transport) < 0) + { + librador_logger_set(nullptr, nullptr); + throw CommandError("could not initialise librador"); + } + const int rc = librador_connect(); + if (rc < 0 || !librador_is_connected()) + { + librador_exit(); + librador_logger_set(nullptr, nullptr); + throw CommandError("could not connect to a Labrador board (code " + + std::to_string(rc) + + "); is it plugged in and not open in another program?"); + } + if (rc > 0) + { + // The board is open but on a firmware librador will not stream from. The + // GUI handles this through its flash hook; the CLI has none, so it must stop + // here or wait forever on an empty sample buffer. + char message[160]; + std::snprintf(message, sizeof message, + "the board is running firmware 0x%04x variant %u but this build " + "needs 0x%04x variant %u; run 'labrador-cli update-firmware' first", + librador_get_device_firmware_version(), + librador_get_device_firmware_variant(), EXPECTED_FIRMWARE_VERSION, + DEFINED_EXPECTED_VARIANT); + librador_exit(); + librador_logger_set(nullptr, nullptr); + throw CommandError(message); + } +} + +DeviceSession::~DeviceSession() +{ + librador_exit(); + librador_logger_set(nullptr, nullptr); +} + +void DeviceSession::configureScope(ScopeMode mode, double gain, double settle_s) +{ + if (librador_set_oscilloscope_gain(gain) < 0) + throw CommandError("the board rejected gain " + std::to_string(gain)); + librador_set_device_mode(mode == ScopeMode::Ch1 ? DEVICE_MODE_CH1 + : DEVICE_MODE_CH1_CH2); + std::this_thread::sleep_for(std::chrono::duration(settle_s)); +} + +bool DeviceSession::isValidScopeGain(double gain) +{ + return std::find(SCOPE_GAINS.begin(), SCOPE_GAINS.end(), gain) != SCOPE_GAINS.end(); +} + +void DeviceSession::routeLogsToStderr(const bool* verbose) +{ + librador_logger_set(const_cast(verbose), logToStderr); +} + +void DeviceSession::resetLogs() +{ + librador_logger_set(nullptr, nullptr); +} diff --git a/Unified_App/cli/DeviceSession.h b/Unified_App/cli/DeviceSession.h new file mode 100644 index 00000000..a26f4e0b --- /dev/null +++ b/Unified_App/cli/DeviceSession.h @@ -0,0 +1,48 @@ +#pragma once + +#include + +enum class ScopeMode +{ + Ch1, // one channel at 375 kS/s + Ch1Ch2 // both channels at 375 kS/s each +}; + +// How USB frames reach the host; see librador's LABRADOR_TRANSPORT_* values. +enum class UsbTransport +{ + Auto, // librador's per-platform choice: iso6 on Windows/Linux, bulk on macOS + Iso6, // six isochronous endpoints of 125 bytes per frame + Iso1, // one isochronous endpoint of 750 bytes per frame + Bulk // one bulk endpoint; the only transport librador allows on macOS +}; + +// RAII connection to the Labrador board: connects on construction, disconnects on +// destruction. Only one process can own the board, so the Labrador app must be closed. +// librador's diagnostic output is routed to stderr (debug messages only if `verbose`) +// so stdout stays for command results. +class DeviceSession +{ + public: + // Throws CommandError if no board can be opened. + explicit DeviceSession(bool verbose, UsbTransport transport); + ~DeviceSession(); + + DeviceSession(const DeviceSession&) = delete; + DeviceSession& operator=(const DeviceSession&) = delete; + + // Selects the scope mode and hardware gain, then waits `settle_s` for the analog + // front end to settle. Throws CommandError if the board rejects the gain. + void configureScope(ScopeMode mode, double gain, double settle_s); + + // Gains the hardware supports: 0.5, 1, 2, 4, 8, 16, 32, 64. + static bool isValidScopeGain(double gain); + + // Route librador's log to stderr (debug lines only while *verbose is true); + // resetLogs() detaches it. `verbose` must outlive the routing. + static void routeLogsToStderr(const bool* verbose); + static void resetLogs(); + + private: + bool m_verbose; // read by the log sink through librador's userdata pointer +}; diff --git a/Unified_App/cli/OptionParser.cpp b/Unified_App/cli/OptionParser.cpp new file mode 100644 index 00000000..fc87a2bd --- /dev/null +++ b/Unified_App/cli/OptionParser.cpp @@ -0,0 +1,92 @@ +#include "OptionParser.h" + +#include "Command.h" + +#include +#include +#include +#include +#include +#include + +void OptionParser::addOption(std::string name, std::string metavar, + std::string description, ValueHandler handler) +{ + m_specs.push_back({std::move(name), std::move(metavar), std::move(description), + std::move(handler), nullptr}); +} + +void OptionParser::addFlag(std::string name, std::string description, + FlagHandler handler) +{ + m_specs.push_back( + {std::move(name), "", std::move(description), nullptr, std::move(handler)}); +} + +const OptionParser::Spec* OptionParser::find(const std::string& name) const +{ + const auto it = std::find_if(m_specs.begin(), m_specs.end(), + [&](const Spec& spec) { return spec.name == name; }); + return it == m_specs.end() ? nullptr : &*it; +} + +void OptionParser::parse(const std::vector& args) const +{ + for (size_t i = 0; i < args.size(); i++) + { + const std::string& arg = args[i]; + if (arg.rfind("--", 0) != 0) + throw UsageError("unexpected argument '" + arg + "'"); + + const size_t eq = arg.find('='); + const std::string name = arg.substr(0, eq); + const Spec* spec = find(name); + if (!spec) + throw UsageError("unknown option '" + name + "'"); + + if (spec->onFlag) + { + if (eq != std::string::npos) + throw UsageError("option '" + name + "' does not take a value"); + spec->onFlag(); + continue; + } + + if (eq != std::string::npos) + spec->onValue(arg.substr(eq + 1)); + else if (i + 1 < args.size()) + spec->onValue(args[++i]); + else + throw UsageError("option '" + name + "' needs a value"); + } +} + +void OptionParser::printOptions(std::ostream& out) const +{ + std::vector heads; + for (const Spec& spec : m_specs) + heads.push_back(spec.metavar.empty() ? spec.name + : spec.name + " " + spec.metavar); + heads.push_back("-h, --help"); + + size_t width = 0; + for (const std::string& head : heads) + width = std::max(width, head.size()); + + for (size_t i = 0; i < m_specs.size(); i++) + out << " " << std::left << std::setw(static_cast(width)) << heads[i] + << " " << m_specs[i].description << '\n'; + out << " " << std::left << std::setw(static_cast(width)) << heads.back() + << " Show this help\n"; +} + +double OptionParser::toDouble(const std::string& option, const std::string& text) +{ + char* end = nullptr; + errno = 0; + const double value = std::strtod(text.c_str(), &end); + if (text.empty() || end != text.c_str() + text.size() || errno == ERANGE || + !std::isfinite(value)) + throw UsageError("option '" + option + "' expects a number, got '" + text + "'"); + return value; +} diff --git a/Unified_App/cli/OptionParser.h b/Unified_App/cli/OptionParser.h new file mode 100644 index 00000000..07903139 --- /dev/null +++ b/Unified_App/cli/OptionParser.h @@ -0,0 +1,43 @@ +#pragma once + +#include +#include +#include +#include + +// Declarative `--name value` / `--name=value` / `--flag` parser. Commands register +// their options once; the same registration drives parsing and the help text, so the +// two cannot drift apart. Every problem is reported as a UsageError naming the option. +class OptionParser +{ + public: + using ValueHandler = std::function; + using FlagHandler = std::function; + + void addOption(std::string name, std::string metavar, std::string description, + ValueHandler handler); + void addFlag(std::string name, std::string description, FlagHandler handler); + + // Options only; a stray positional argument is an error. + void parse(const std::vector& args) const; + + // Aligned option list, ending with the -h/--help entry that main() handles. + void printOptions(std::ostream& out) const; + + // Strict number parsing (rejects "", "3x", "nan"); `option` names the culprit. + static double toDouble(const std::string& option, const std::string& text); + + private: + struct Spec + { + std::string name; + std::string metavar; // empty for flags + std::string description; + ValueHandler onValue; + FlagHandler onFlag; + }; + + const Spec* find(const std::string& name) const; + + std::vector m_specs; +}; diff --git a/Unified_App/cli/README.md b/Unified_App/cli/README.md new file mode 100644 index 00000000..13bd60a6 --- /dev/null +++ b/Unified_App/cli/README.md @@ -0,0 +1,200 @@ +# labrador-cli + +Headless command-line companion to the Labrador app. It drives the board +through the same `librador` the GUI uses and ships with the same firmware +image, so it can run where no window is wanted: shell scripts, cron jobs, a +headless Raspberry Pi, or a quick capture from a terminal. + +``` +labrador-cli [options] +labrador-cli help [command] + +Commands: + capture Save full-rate scope samples around a trigger edge to a CSV file + update-firmware Flash the board with the firmware this build expects +``` + +Only one program can hold the board: close the Labrador app before running +the CLI, and vice versa. + +## Where it is installed + +| Platform | Location | +| --- | --- | +| Windows installer | `C:\Program Files\EspoTek Labrador\labrador-cli.exe`, next to `labrador.exe`. Not on `PATH`; run it with the full path or `cd` there first. The installer also installs the USB driver the CLI needs. | +| macOS DMG | Inside the bundle: `/Applications/Labrador.app/Contents/MacOS/labrador-cli` (signed and notarized with the app). Put it on `PATH` with `sudo mkdir -p /usr/local/bin && sudo ln -s /Applications/Labrador.app/Contents/MacOS/labrador-cli /usr/local/bin/labrador-cli`. | +| Linux `.deb` | `labrador-cli` on `PATH` (`/usr/bin/labrador-cli` → `/usr/lib/labrador/labrador-cli`). | +| Linux AppImage | `./Labrador-for-Linux-x86_64.AppImage cli [options]`. A symlink named `labrador-cli` to the AppImage also works: `ln -s ~/Labrador-for-Linux-x86_64.AppImage ~/.local/bin/labrador-cli`. | +| Android | Not available. | + +The Linux packages need the udev rule the GUI installs (the `.deb` installs +it; the AppImage offers to on first run) so the board is reachable without +root. + +From a source checkout, every desktop preset builds the `labrador_cli` target +into `build//cli/labrador-cli` (`.exe` on Windows), with a copy of the +firmware beside it: + +```sh +cmake --preset macos && cmake --build --preset macos --target labrador_cli +./build/macos/cli/labrador-cli --help +``` + +## `capture`: triggered scope capture to CSV + +Arms an edge trigger on one scope channel, waits for it to fire, and writes +the samples from `--pre` seconds before to `--post` seconds after the trigger +at the board's full rate, 375 kS/s per channel. `armed: ...` on stderr means +the trigger is waiting; Ctrl-C aborts. + +``` +labrador-cli capture --out FILE [options] + + --out FILE CSV file to write (required); FILE.json gets the metadata + --mode MODE scope1 (CH1 only) or scope2 (CH1 and CH2); default scope2 + --gain G hardware gain 0.5, 1, 2, 4, 8, 16, 32 or 64; default 4 + --trigger CH:EDGE:VOLTS channel (ch1|ch2), edge (rising|falling), level; default ch1:rising:3 + --hysteresis VOLTS signal must leave the level by this much to re-arm; default 0.25 + --pre SECONDS keep this long before the trigger; default 0.2 + --post SECONDS keep this long after the trigger; default 0.3 + --timeout SECONDS give up if no trigger; 0 waits forever (default) + --settle SECONDS pause after configuring the scope; default 0.5 + --transport T USB transport: auto (the platform default: iso6 on Windows + and Linux, bulk on macOS), iso6, iso1 or bulk; default auto + --verbose also show librador's debug messages on stderr +``` + +`--pre` plus `--post` must fit in the board's 10 s sample buffer with 0.5 s +to spare. Full scale is about 1.65 V ± 23.65 V / gain, so pick the lowest gain +that keeps the signal in range; larger signals clip. + +### Examples + +Both channels, 50 ms before and 100 ms after CH1 rising through 1.5 V: + +```sh +labrador-cli capture --trigger ch1:rising:1.5 --pre 0.05 --post 0.1 --out edge.csv +``` + +``` +armed: ch1 rising through 1.5 V, pre 0.05 s, post 0.1 s +wrote edge.csv: 56250 samples at 375000 Hz (CH1+CH2), trigger at t=0 (sample 18750) +``` + +CH1 only at gain 16 (about ±1.5 V full scale), falling edge through 0 V: + +```sh +labrador-cli capture --mode scope1 --gain 16 --trigger ch1:falling:0 --out pulse.csv +``` + +Trigger on CH2 (needs `--mode scope2`, the default) and give up after a +minute, so a script can tell "no event" from "captured": + +```sh +if labrador-cli capture --trigger ch2:rising:2 --timeout 60 --out event.csv; then + echo captured +else + echo "no trigger in 60 s (exit $?)" +fi +``` + +Ten captures in a row, one file each: + +```sh +for i in $(seq 1 10); do + labrador-cli capture --trigger ch1:rising:1.5 --pre 0.01 --post 0.01 \ + --out "burst_$i.csv" || break +done +``` + +Force the bulk transport (what macOS uses by default) on Linux for an A/B +check, with librador's own log on stderr: + +```sh +labrador-cli capture --transport bulk --verbose --out bulk.csv +``` + +### Output + +`FILE` is CSV with the header `t,CH1,CH2` (`t,CH1` in `scope1` mode). `t` is +seconds with 0 at the trigger sample; the channels are volts at the input +pins. The trigger sample is the first one at or past the level: + +``` +t,CH1,CH2 +-0.0500000,2.9896,-0.0591 +... +-0.0000027,0.2643,0.2643 +0.0000000,1.5576,0.2181 +0.0000027,2.9896,-0.0129 +``` + +`FILE.json` records how the capture was taken and whether the USB stream was +clean while waiting: + +```json +{ + "sample_rate_hz": 375000, + "samples": 56250, + "pre_samples": 18750, + "trigger": {"channel": 1, "edge": "rising", "level_v": 1.5, "hysteresis_v": 0.25}, + "gain": 4, + "mode": "scope2", + "frames_bad_checksum": 0, + "frames_dropped": 0 +} +``` + +Non-zero `frames_bad_checksum` or `frames_dropped` also prints a warning: +the window may contain corrupt or repeated samples. + +## `update-firmware`: flash the bundled firmware + +Checks the attached board's firmware against the version this build streams +from and reflashes it when they differ. A board stuck in bootloader mode is +launched first and reflashed only if that fails. Flashing takes about ten +seconds; do not unplug the board meanwhile. + +``` +labrador-cli update-firmware [options] + + --hex FILE Intel hex image to flash; default is the bundled labrafirm_000C_03.hex + --force reflash even if the board already runs the expected firmware + --verbose also show librador's debug messages on stderr +``` + +The bundled image is the one the Labrador app flashes, found in the +`assets/firmware` directory the packages install beside the binaries +(`Contents/Resources/assets/firmware` inside the macOS bundle). + +```sh +labrador-cli update-firmware +``` + +``` +firmware 0x000c variant 3 is already current; use --force to reflash +``` + +Reflash regardless, or flash a different image: + +```sh +labrador-cli update-firmware --force +labrador-cli update-firmware --hex ~/labrafirm_000C_03.hex +``` + +`capture` refuses to run on a board with other firmware and points here: + +``` +labrador-cli capture: error: the board is running firmware 0x0007 variant 2 but +this build needs 0x000c variant 3; run 'labrador-cli update-firmware' first +``` + +## Exit status + +| Code | Meaning | +| --- | --- | +| 0 | Success. For `update-firmware`: the board ends up on the expected firmware, flashed or not. | +| 1 | The work failed: no board, board open elsewhere, wrong firmware, trigger timeout, write error, flash error. | +| 2 | Bad command line. | + +Errors go to stderr as `labrador-cli : error: ...`. diff --git a/Unified_App/cli/UpdateFirmwareCommand.cpp b/Unified_App/cli/UpdateFirmwareCommand.cpp new file mode 100644 index 00000000..c19c1140 --- /dev/null +++ b/Unified_App/cli/UpdateFirmwareCommand.cpp @@ -0,0 +1,222 @@ +#include "UpdateFirmwareCommand.h" + +#include "DeviceSession.h" +#include "OptionParser.h" + +#include "librador.h" + +#include + +#include +#include +#include +#include +#include +#include + +#ifdef __APPLE__ +#include +#include +#endif + +namespace +{ +struct Options +{ + std::string hex; // empty = the hex bundled next to the binary + bool force = false; + bool verbose = false; +}; + +std::string expectedFirmwareName() +{ + char name[48]; + std::snprintf(name, sizeof name, "labrafirm_%04X_%02u.hex", + static_cast(EXPECTED_FIRMWARE_VERSION), + static_cast(DEFINED_EXPECTED_VARIANT)); + return name; +} + +std::string describeVersion(unsigned version, unsigned variant) +{ + char text[48]; + std::snprintf(text, sizeof text, "0x%04x variant %u", version, variant); + return text; +} + +std::string boardVersion() +{ + return describeVersion(librador_get_device_firmware_version(), + librador_get_device_firmware_variant()); +} + +std::string expectedVersion() +{ + return describeVersion(EXPECTED_FIRMWARE_VERSION, DEFINED_EXPECTED_VARIANT); +} + +OptionParser makeParser(Options& options) +{ + OptionParser parser; + parser.addOption("--hex", "FILE", + "Intel hex image to flash; default is the bundled " + + expectedFirmwareName(), + [&](const std::string& v) { options.hex = v; }); + parser.addFlag("--force", "reflash even if the board already runs the expected firmware", + [&]() { options.force = true; }); + parser.addFlag("--verbose", "also show librador's debug messages on stderr", + [&]() { options.verbose = true; }); + return parser; +} + +// The same place the Labrador app keeps its firmware: assets/firmware next to +// the executable (Contents/Resources inside a macOS bundle), which is what +// SDL_GetBasePath() answers on every platform. On macOS, though, SDL asks +// NSBundle, which trusts the path the CLI was invoked by: run through a +// symlink from outside Labrador.app (how it is put on PATH) the lookup lands +// beside the link. So the real executable location is tried too. +std::string bundledHexPath() +{ + const std::string name = expectedFirmwareName(); + std::vector candidates; + if (const char* base = SDL_GetBasePath()) + candidates.push_back(std::string(base) + "assets/firmware/" + name); +#ifdef __APPLE__ + uint32_t size = 0; + _NSGetExecutablePath(nullptr, &size); + std::string exe(size, '\0'); + char real[PATH_MAX]; + if (_NSGetExecutablePath(&exe[0], &size) == 0 && realpath(exe.c_str(), real) != nullptr) + { + std::string dir(real); + dir.erase(dir.find_last_of('/')); + candidates.push_back(dir + "/../Resources/assets/firmware/" + name); // in the bundle + const std::string bare = dir + "/assets/firmware/" + name; // bare binary + if (candidates.empty() || candidates.front() != bare) + candidates.push_back(bare); + } +#endif + for (const std::string& path : candidates) + if (std::ifstream(path, std::ios::binary)) + return path; + + std::string looked; + for (const std::string& path : candidates) + looked += (looked.empty() ? "" : ", ") + path; + throw CommandError("cannot find the bundled firmware image " + name + " (looked for " + + (looked.empty() ? std::string("it next to the executable") : looked) + + "); pass --hex FILE"); +} + +// RAII around librador so every exit path tears the USB session down. +class LibradorScope +{ + public: + explicit LibradorScope(const bool* verbose) + { + DeviceSession::routeLogsToStderr(verbose); + if (librador_init(LABRADOR_TRANSPORT_AUTO) < 0) + { + DeviceSession::resetLogs(); + throw CommandError("could not initialise librador"); + } + } + ~LibradorScope() + { + librador_exit(); + DeviceSession::resetLogs(); + } + LibradorScope(const LibradorScope&) = delete; + LibradorScope& operator=(const LibradorScope&) = delete; +}; +} // namespace + +std::string UpdateFirmwareCommand::name() const +{ + return "update-firmware"; +} + +std::string UpdateFirmwareCommand::summary() const +{ + return "Flash the board with the firmware this build expects"; +} + +void UpdateFirmwareCommand::printHelp(std::ostream& out, const std::string& program) const +{ + Options unused; + out << program << " update-firmware - " << summary() << "\n\n" + << "Checks the attached board's firmware against the version librador streams\n" + << "from (" << expectedVersion() << ") and reflashes it when they differ. A board\n" + << "stuck in bootloader mode is launched first and reflashed only if that fails.\n" + << "Flashing takes about ten seconds; do not unplug the board meanwhile.\n\n" + << "Usage:\n" + << " " << program << " update-firmware [options]\n\n" + << "Options:\n"; + makeParser(unused).printOptions(out); + out << "\nExit status is 0 when the board ends up on the expected firmware, whether\n" + << "or not a flash was needed.\n"; +} + +int UpdateFirmwareCommand::run(const std::vector& args) +{ + Options options; + makeParser(options).parse(args); + + // Resolved and checked before any USB work so a missing image fails + // without touching the board. + const std::string hex = options.hex.empty() ? bundledHexPath() : options.hex; + if (!std::ifstream(hex, std::ios::binary)) + throw CommandError("cannot read firmware image '" + hex + "'"); + + LibradorScope librador(&options.verbose); + + bool bootloader = false; + bool gobindar = false; + if (!librador_device_present_ex(&bootloader, &gobindar)) + throw CommandError("no Labrador board found; is it plugged in?"); + if (gobindar) + throw CommandError("the board enumerates in its misconfigured (Gobindar) state, " + "which needs the manual recovery in the Labrador app"); + + int rc; + if (bootloader) + { + std::cerr << "board is in bootloader mode: launching its firmware, reflashing " + "from " << hex << " if that fails" << std::endl; + rc = librador_bootloader_recover(hex.c_str()); + if (rc == 1 && !options.force) + { + // Launched, but into a firmware librador will not stream from. + std::cerr << "launched firmware " << boardVersion() << ", expected " + << expectedVersion() << ": reflashing" << std::endl; + rc = librador_flash_firmware(hex.c_str()); + } + } + else + { + rc = librador_connect(); + if (rc < 0) + throw CommandError("could not open the board (code " + std::to_string(rc) + + "); is it open in another program?"); + const std::string current = boardVersion(); + if (rc == 0 && !options.force) + { + std::cout << "firmware " << current << " is already current; use --force to reflash" + << std::endl; + return EXIT_SUCCESS; + } + std::cerr << "board reports " << current << ", flashing " << expectedVersion() + << " from " << hex << " (about ten seconds, do not unplug)" << std::endl; + rc = librador_flash_firmware(hex.c_str()); + } + + if (rc < 0) + throw CommandError("flashing failed (code " + std::to_string(rc) + + "); the board may be left in bootloader mode, in which case " + "running this command again recovers it"); + if (rc == 1) + throw CommandError("flashing finished but the board reports " + boardVersion() + + ", expected " + expectedVersion()); + std::cout << "done: board now runs " << boardVersion() << std::endl; + return EXIT_SUCCESS; +} diff --git a/Unified_App/cli/UpdateFirmwareCommand.h b/Unified_App/cli/UpdateFirmwareCommand.h new file mode 100644 index 00000000..e60a9048 --- /dev/null +++ b/Unified_App/cli/UpdateFirmwareCommand.h @@ -0,0 +1,14 @@ +#pragma once + +#include "Command.h" + +// `labrador-cli update-firmware`: bring the board to the firmware this build of +// librador streams from (reflashing from the bundled hex, or one given with --hex). +class UpdateFirmwareCommand : public Command +{ + public: + std::string name() const override; + std::string summary() const override; + void printHelp(std::ostream& out, const std::string& program) const override; + int run(const std::vector& args) override; +}; diff --git a/Unified_App/cli/main.cpp b/Unified_App/cli/main.cpp new file mode 100644 index 00000000..25c20af7 --- /dev/null +++ b/Unified_App/cli/main.cpp @@ -0,0 +1,115 @@ +#include "CaptureCommand.h" +#include "Command.h" +#include "UpdateFirmwareCommand.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +const std::string PROGRAM = "labrador-cli"; + +using CommandList = std::vector>; + +void printUsage(std::ostream& out, const CommandList& commands) +{ + out << PROGRAM << " - command-line tools for the EspoTek Labrador board\n\n" + << "Usage:\n" + << " " << PROGRAM << " [options]\n" + << " " << PROGRAM << " help [command]\n\n" + << "Commands:\n"; + size_t width = 0; + for (const auto& command : commands) + width = std::max(width, command->name().size()); + for (const auto& command : commands) + out << " " << std::left << std::setw(static_cast(width) + 2) + << command->name() << command->summary() << '\n'; + out << "\nRun '" << PROGRAM << " --help' for a command's options.\n" + << "Only one program can use the board at a time: close the Labrador app first.\n" + << "Exit status: 0 success, " << EXIT_RUNTIME_ERROR + << " the work failed (no board, timeout, ...), " << EXIT_USAGE_ERROR + << " bad command line.\n"; +} + +Command* findCommand(const CommandList& commands, const std::string& name) +{ + for (const auto& command : commands) + if (command->name() == name) + return command.get(); + return nullptr; +} +} // namespace + +int main(int argc, char** argv) +{ + CommandList commands; + commands.push_back(std::make_unique()); + commands.push_back(std::make_unique()); + + const std::vector args(argv + 1, argv + argc); + if (args.empty()) + { + printUsage(std::cerr, commands); + return EXIT_USAGE_ERROR; + } + + const std::string& first = args[0]; + if (first == "-h" || first == "--help") + { + printUsage(std::cout, commands); + return EXIT_SUCCESS; + } + + if (first == "help") + { + if (args.size() == 1) + { + printUsage(std::cout, commands); + return EXIT_SUCCESS; + } + const Command* target = findCommand(commands, args[1]); + if (!target) + { + std::cerr << PROGRAM << ": unknown command '" << args[1] << "'\n"; + return EXIT_USAGE_ERROR; + } + target->printHelp(std::cout, PROGRAM); + return EXIT_SUCCESS; + } + + Command* command = findCommand(commands, first); + if (!command) + { + std::cerr << PROGRAM << ": unknown command '" << first << "'\n" + << "Try '" << PROGRAM << " --help'.\n"; + return EXIT_USAGE_ERROR; + } + + const std::vector commandArgs(args.begin() + 1, args.end()); + if (wantsHelp(commandArgs)) + { + command->printHelp(std::cout, PROGRAM); + return EXIT_SUCCESS; + } + + try + { + return command->run(commandArgs); + } + catch (const UsageError& e) + { + std::cerr << PROGRAM << " " << first << ": " << e.what() << "\n" + << "Try '" << PROGRAM << " " << first << " --help'.\n"; + return EXIT_USAGE_ERROR; + } + catch (const std::exception& e) + { + std::cerr << PROGRAM << " " << first << ": error: " << e.what() << '\n'; + return EXIT_RUNTIME_ERROR; + } +} diff --git a/Unified_App/librador/librador.cpp b/Unified_App/librador/librador.cpp index 736a896b..f5faed39 100644 --- a/Unified_App/librador/librador.cpp +++ b/Unified_App/librador/librador.cpp @@ -5,6 +5,9 @@ #define _USE_MATH_DEFINES #include +#include +#include +#include #include #include #include @@ -119,6 +122,107 @@ int librador_daq(int channel, int numToGet, int interval_samples, usbCallHandler return 1; } +int librador_get_analog_sample_count(int channel, uint64_t* count) +{ + CHECK_API_INITIALISED + CHECK_USB_INITIALISED + return internal_librador_object->usb_driver->get_analog_sample_count(channel, count); +} + +int librador_copy_analog_window(int channel, uint64_t start, int num, double* volts_out) +{ + CHECK_API_INITIALISED + CHECK_USB_INITIALISED + return internal_librador_object->usb_driver->get_analog_window(channel, start, num, volts_out); +} + +int librador_capture_around_trigger(const librador_capture_request* req, librador_capture_result* res) +{ + CHECK_API_INITIALISED + CHECK_USB_INITIALISED + usbCallHandler* usb = internal_librador_object->usb_driver; + using clock = std::chrono::steady_clock; + + const double sps = usb->get_samples_per_second(); + const int ch = req->trigger_channel; + if(sps <= 0 || (ch != 1 && ch != 2) || req->pre_s < 0 || req->post_s < 0) return -4; + const int64_t pre_n = llround(req->pre_s * sps); + const int64_t post_n = llround(req->post_s * sps); + // Keep half a second of slack so the window start is never overwritten while we wait for post-trigger samples. + if(pre_n + post_n <= 0 || pre_n + post_n > NUM_SAMPLES_PER_CHANNEL - (int64_t)(sps / 2)) return -4; + + uint64_t count; + if(usb->get_analog_sample_count(ch, &count) != 0) return -3; + + uint64_t frames_bad0, frames_dropped0; + usb->get_frame_stats(nullptr, &frames_bad0, &frames_dropped0, nullptr); + + // Never scan from before pre_n so the pre-trigger history is guaranteed to exist. + uint64_t scan = std::max(count, (uint64_t) pre_n); + uint64_t last_count = count; + bool armed = false; + bool found = false; + uint64_t trigger_index = 0; + const auto t0 = clock::now(); + std::vector chunk; + + while(!found){ + if(req->timeout_s > 0 && std::chrono::duration(clock::now() - t0).count() > req->timeout_s) return -5; + int rc = usb->get_analog_sample_count(ch, &count); + if(rc != 0) return -6; + if(count < last_count) return -6; + last_count = count; + if(count <= scan){ + std::this_thread::sleep_for(std::chrono::milliseconds(2)); + continue; + } + int n = (int) std::min(count - scan, 100000); + chunk.resize(n); + rc = usb->get_analog_window(ch, scan, n, chunk.data()); + if(rc != 0) return rc; + for(int i = 0; i < n && !found; i++){ + const double v = chunk[i]; + if(!armed){ + armed = req->rising ? (v < req->level_v - req->hysteresis_v) : (v > req->level_v + req->hysteresis_v); + } else if(req->rising ? (v >= req->level_v) : (v <= req->level_v)){ + found = true; + trigger_index = scan + i; + } + } + scan += n; + } + + // Wait for the post-trigger samples (bounded: a stalled stream must not hang the caller). + const uint64_t need = trigger_index + post_n; + const auto t1 = clock::now(); + while(true){ + int rc = usb->get_analog_sample_count(ch, &count); + if(rc != 0 || count < last_count) return -6; + last_count = count; + if(count >= need) break; + if(std::chrono::duration(clock::now() - t1).count() > req->post_s + 2.0) return -5; + std::this_thread::sleep_for(std::chrono::milliseconds(2)); + } + + const uint64_t start = trigger_index - pre_n; + const int total = (int)(pre_n + post_n); + res->sample_rate_hz = sps; + res->trigger_index = trigger_index; + res->pre_samples = (int) pre_n; + res->ch1.assign(total, 0.0); + res->ch2.clear(); + int rc = usb->get_analog_window(1, start, total, res->ch1.data()); + if(rc != 0) return rc; + std::vector ch2(total); + if(usb->get_analog_window(2, start, total, ch2.data()) == 0) res->ch2 = std::move(ch2); + + uint64_t frames_bad1, frames_dropped1; + usb->get_frame_stats(nullptr, &frames_bad1, &frames_dropped1, nullptr); + res->frames_bad_checksum = frames_bad1 - frames_bad0; + res->frames_dropped = frames_dropped1 - frames_dropped0; + return 0; +} + double librador_get_samples_per_second() { // 0 = "not streaming yet"; callers already branch on it, and it is also diff --git a/Unified_App/librador/librador.h b/Unified_App/librador/librador.h index b1cfa19a..73d6a82f 100644 --- a/Unified_App/librador/librador.h +++ b/Unified_App/librador/librador.h @@ -116,6 +116,43 @@ LIBRADORSHARED_EXPORT std::vector * librador_get_analog_data_by_rate(int LIBRADORSHARED_EXPORT std::vector * librador_get_analog_data_sincelast(int channel, double timeWindow_max_seconds, double sample_rate_hz, double delay_seconds, int filter_mode); LIBRADORSHARED_EXPORT std::vector * librador_get_digital_data(int channel, double timeWindow_seconds, int numToGet, double delay_seconds, bool daq = false); +// Absolute-indexed access to the live full-rate scope buffers (channel 1 or +// 2). Sample 0 is the first sample since the last device-mode change; the +// buffers hold the most recent 10 s. Returns 0 on success, -1 if the channel +// is not a scope stream in the current mode, -2 if the requested range was +// overwritten or has not arrived yet. +LIBRADORSHARED_EXPORT int librador_get_analog_sample_count(int channel, uint64_t* count); +LIBRADORSHARED_EXPORT int librador_copy_analog_window(int channel, uint64_t start, int num, double* volts_out); + +struct librador_capture_request { + int trigger_channel = 1; // 1 or 2 + bool rising = true; + double level_v = 3.0; + double hysteresis_v = 0.25; // the signal must leave level +/- this before the trigger re-arms + double pre_s = 0.2; + double post_s = 0.3; + double timeout_s = 0; // wait for the trigger; <= 0 waits forever +}; +struct librador_capture_result { + double sample_rate_hz = 0; + uint64_t trigger_index = 0; // absolute sample index of the first sample past the level + int pre_samples = 0; // trigger sample is ch1[pre_samples] + std::vector ch1; // volts, chronological + std::vector ch2; // empty unless CH2 streams in the current mode + // Counted over the whole wait, so they cannot say whether a bad frame fell inside the + // window. A dropped frame is filled with a repeat of the last good one; on the iso + // transports a bad-checksum frame is only detected after its samples were stored, + // so it stays as received. + uint64_t frames_bad_checksum = 0; + uint64_t frames_dropped = 0; +}; +// Blocks until the trigger fires and the post-trigger samples have arrived, +// then returns both channels over [trigger - pre, trigger + post) with a +// shared time origin. Returns 0, -3 no scope stream on the trigger channel, +// -4 bad request, -5 timed out, -6 stream restarted (mode change) meanwhile, +// or a negative error from the buffer access. +LIBRADORSHARED_EXPORT int librador_capture_around_trigger(const librador_capture_request* request, librador_capture_result* result); + LIBRADORSHARED_EXPORT int librador_daq(int channel, int numToGet, int interval_samples, usbCallHandler::daqUnitOptions units_sel[2], const char* filename); LIBRADORSHARED_EXPORT bool librador_poll_daq_status(); @@ -165,6 +202,11 @@ LIBRADORSHARED_EXPORT bool librador_device_present(bool* bootloader_mode); // *bootloader_mode / *gobindar_mode flag the two needs-recovery states. // librador_device_present() is unchanged and never reports Gobindar boards. LIBRADORSHARED_EXPORT bool librador_device_present_ex(bool* bootloader_mode, bool* gobindar_mode); +// Opens the board and starts streaming. Returns 0 when streaming, a negative +// value when no board could be opened, and 1 when the board is open but runs +// a firmware other than EXPECTED_FIRMWARE_VERSION/DEFINED_EXPECTED_VARIANT: no +// stream is started, librador_is_connected() is true (the handle stays open so +// a host can flash), and the request_firmware_flash host hook is invoked if set. LIBRADORSHARED_EXPORT int librador_connect(); LIBRADORSHARED_EXPORT int librador_disconnect(); // Blocking, takes several seconds — call from a worker thread. diff --git a/Unified_App/librador/o1buffer.cpp b/Unified_App/librador/o1buffer.cpp index d030f8d2..af045362 100644 --- a/Unified_App/librador/o1buffer.cpp +++ b/Unified_App/librador/o1buffer.cpp @@ -5,6 +5,7 @@ #include #include #include +#include #include "uartstyledecoder.h" @@ -35,6 +36,7 @@ o1buffer::~o1buffer(){ int o1buffer::reset(bool hard){ mostRecentAddress = 0; + total_samples_added = 0; stream_index_at_last_call = 0; if(hard){ for (int i=0; i total_samples_added) return false; + if(total_samples_added - start > (uint64_t) NUM_SAMPLES_PER_CHANNEL) return false; + + int64_t behind = (int64_t)(total_samples_added - 1 - start); + int64_t first = ((int64_t) mostRecentAddress - behind) % NUM_SAMPLES_PER_CHANNEL; + if(first < 0) first += NUM_SAMPLES_PER_CHANNEL; + + int head = (int) std::min(num, NUM_SAMPLES_PER_CHANNEL - first); + memcpy(out, buffer + first, sizeof(int) * head); + if(head < num) memcpy(out + head, buffer, sizeof(int) * (num - head)); + return true; +} + bool o1buffer::getPaused(){ return m_virtual_transform_settings.is_paused; } diff --git a/Unified_App/librador/o1buffer.h b/Unified_App/librador/o1buffer.h index ca91346b..e69afd80 100644 --- a/Unified_App/librador/o1buffer.h +++ b/Unified_App/librador/o1buffer.h @@ -43,6 +43,9 @@ class o1buffer int addVector(short *firstElement, int numElements); int get(int address, bool daq = false); int mostRecentAddress = 0; + // Samples written since the last reset(); sample k lives at the address + // (mostRecentAddress - (total_samples_added - 1 - k)) mod buffer length. + uint64_t total_samples_added = 0; int mostRecentAddressPaused = 0; int mostRecentAddressDAQ = 0; int stream_index_at_last_call = 0; @@ -61,6 +64,11 @@ class o1buffer double voltage_ref = 1.65; int setPaused(bool is_paused, int mostRecentAddressDelta = 0, bool hard = false); void copy_to_daq(); + // Copies raw samples [start, start+num) by absolute index into out. + // False if any of them was overwritten or has not arrived yet. + // Caller must hold the buffer mutex (as for copy_to_daq). + bool copyWindow(uint64_t start, int num, int *out) const; + double toVolts(int raw_sample, double scope_gain) const { return sampleConvert(raw_sample, scope_gain, false); } bool getPaused(); bool setTriggerSettings(trigger_settings new_trigger_settings); bool setVirtualTransformSettings(virtual_transform_settings new_virtual_transform_settings); diff --git a/Unified_App/librador/usbcallhandler.cpp b/Unified_App/librador/usbcallhandler.cpp index 77443f65..5e99c82b 100644 --- a/Unified_App/librador/usbcallhandler.cpp +++ b/Unified_App/librador/usbcallhandler.cpp @@ -1218,6 +1218,37 @@ double usbCallHandler::get_scope_gain(){ return current_scope_gain; } +// Caller must hold buffer_read_write_mutex. +static o1buffer* scope_buffer_for_channel(int channel, int mode, o1buffer* cha, o1buffer* chb, o1buffer* b750){ + if(channel == 1){ + if(mode == 6) return b750; + if(mode == 0 || mode == 1 || mode == 2) return cha; + } else if(channel == 2 && mode == 2){ + return chb; + } + return nullptr; +} + +int usbCallHandler::get_analog_sample_count(int channel, uint64_t *count){ + std::lock_guard lock(buffer_read_write_mutex); + o1buffer *buf = scope_buffer_for_channel(channel, deviceMode, internal_o1_buffer_375_CHA, internal_o1_buffer_375_CHB, internal_o1_buffer_750); + if(!buf) return -1; + *count = buf->total_samples_added; + return 0; +} + +int usbCallHandler::get_analog_window(int channel, uint64_t start, int num, double *volts_out){ + std::vector raw(num > 0 ? num : 0); + std::lock_guard lock(buffer_read_write_mutex); + o1buffer *buf = scope_buffer_for_channel(channel, deviceMode, internal_o1_buffer_375_CHA, internal_o1_buffer_375_CHB, internal_o1_buffer_750); + if(!buf) return -1; + if(!buf->copyWindow(start, num, raw.data())) return -2; + for(int i = 0; i < num; i++){ + volts_out[i] = buf->toVolts(raw[i], current_scope_gain); + } + return 0; +} + int usbCallHandler::set_gain(double newGain){ LIBRADOR_LOG(LOG_DEBUG, "set_gain(%f) -> 0xa5 DMA rebuild\n", newGain); //See XMEGA_AU Manual, page 359. ADC.CTRL.GAIN. diff --git a/Unified_App/librador/usbcallhandler.h b/Unified_App/librador/usbcallhandler.h index d668da52..2dee87e4 100644 --- a/Unified_App/librador/usbcallhandler.h +++ b/Unified_App/librador/usbcallhandler.h @@ -171,6 +171,13 @@ class usbCallHandler int load_calibration_from_device(double *vref_ch1, double *gain_scale_ch1, double *vref_ch2, double *gain_scale_ch2, double *psu_offset); double get_scope_gain(); + // Absolute-indexed access to the live scope buffers (channel 1 or 2). + // Index 0 is the first sample since the last device-mode change. + // get_analog_sample_count: 0 ok, -1 channel not a scope stream in this mode. + // get_analog_window: volts for samples [start, start+num); 0 ok, -1 bad + // channel, -2 range overwritten or not yet received. + int get_analog_sample_count(int channel, uint64_t *count); + int get_analog_window(int channel, uint64_t start, int num, double *volts_out); int set_digital_state(uint8_t digState); int reset_device(bool goToBootloader); uint16_t get_firmware_version(); diff --git a/Unified_App/packaging/linux/apprun-hooks/zz-labrador-cli.sh b/Unified_App/packaging/linux/apprun-hooks/zz-labrador-cli.sh new file mode 100755 index 00000000..e3e89191 --- /dev/null +++ b/Unified_App/packaging/linux/apprun-hooks/zz-labrador-cli.sh @@ -0,0 +1,25 @@ +#!/bin/sh +# AppImage AppRun hook: run the headless labrador-cli instead of the GUI when +# the AppImage is started as +# +# ./Labrador-for-Linux-x86_64.AppImage cli [options] +# +# or through a symlink (or copy) named labrador-cli, e.g. +# +# ln -s ~/Labrador-for-Linux-x86_64.AppImage ~/.local/bin/labrador-cli +# +# (the AppImage runtime passes the name it was invoked by in ARGV0). Hooks +# are sourced in name order, so the zz- prefix keeps this after the udev +# hook: CLI users get the udev rules offered too. exec replaces AppRun, so +# the GUI never starts. + +if [ "$1" = "cli" ]; then + shift + exec "$this_dir/usr/bin/labrador-cli" "$@" +fi + +case "$(basename "${ARGV0:-}")" in + labrador-cli|labrador-cli.AppImage) + exec "$this_dir/usr/bin/labrador-cli" "$@" + ;; +esac diff --git a/Unified_App/packaging/windows/labrador.iss b/Unified_App/packaging/windows/labrador.iss index 93ee56dd..c84674aa 100644 --- a/Unified_App/packaging/windows/labrador.iss +++ b/Unified_App/packaging/windows/labrador.iss @@ -4,14 +4,17 @@ ; single self-contained "Labrador-for-Windows.exe" that installs the app (a ; fully static exe: MinGW runtime + libusb baked in; the x64 or x86 build ; matching the host - the Qt app always shipped 32-bit, so 32-bit Windows -; stays supported), its bundled assets + firmware hex, and the three unpacked -; USB driver packages, which it installs silently during setup. +; stays supported), the headless labrador-cli.exe beside it (same arch +; choice; see Unified_App/cli/README.md), its bundled assets + firmware hex, +; and the three unpacked USB driver packages, which it installs silently +; during setup. ; ; Built in CI with: ; ISCC.exe /DMyAppVersion=... /DStagingDir=... /DOutputDir=... labrador.iss -; The staging dir must contain: labrador64.exe, labrador32.exe, assets\, -; driver\\ (unpacked dpinst packages). appicon.ico must sit next to this script (the workflow copies it -; in). +; The staging dir must contain: labrador64.exe, labrador32.exe, +; labrador-cli64.exe, labrador-cli32.exe, assets\, driver\\ +; (unpacked dpinst packages). appicon.ico must sit next to this script (the +; workflow copies it in). #ifndef MyAppVersion #define MyAppVersion "2.0.0" @@ -63,8 +66,14 @@ Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{ Type: files; Name: "{app}\*.dll" [Files] +; labrador-cli.exe is the headless CLI (console app), run from a terminal as +; "{app}\labrador-cli.exe"; it shares {app}\assets\firmware with the GUI for +; update-firmware. Each arch's pair sits in its own solid block so a 64-bit +; host never decompresses the 32-bit exes (and vice versa). Source: "{#StagingDir}\labrador64.exe"; DestDir: "{app}"; DestName: "labrador.exe"; Flags: ignoreversion; Check: Is64BitInstallMode +Source: "{#StagingDir}\labrador-cli64.exe"; DestDir: "{app}"; DestName: "labrador-cli.exe"; Flags: ignoreversion; Check: Is64BitInstallMode Source: "{#StagingDir}\labrador32.exe"; DestDir: "{app}"; DestName: "labrador.exe"; Flags: ignoreversion solidbreak; Check: not Is64BitInstallMode +Source: "{#StagingDir}\labrador-cli32.exe"; DestDir: "{app}"; DestName: "labrador-cli.exe"; Flags: ignoreversion; Check: not Is64BitInstallMode Source: "{#StagingDir}\assets\*"; DestDir: "{app}\assets"; Flags: ignoreversion recursesubdirs createallsubdirs Source: "{#StagingDir}\driver\*"; DestDir: "{app}\driver"; Flags: ignoreversion recursesubdirs createallsubdirs