Skip to content

Add labrador-cli with a triggered full-rate capture command - #462

Merged
EspoTek merged 14 commits into
espotek-org:unified-appfrom
AronRubin:feature/labrador-cli-triggered-capture
Oct 19, 2026
Merged

EspoTek merged 14 commits into
espotek-org:unified-appfrom
AronRubin:feature/labrador-cli-triggered-capture

Conversation

@AronRubin

Copy link
Copy Markdown

Summary

Adds a headless labrador-cli with a first command, capture, built on a new librador_capture_around_trigger() API. It records both scope channels at the full 375 kS/s around a trigger edge and writes t,CH1,CH2 CSV plus a JSON sidecar.

Stacked on #461 (the first commit here is that fix). Without it, repeated one-shot CLI runs degrade; I will rebase once it merges.

Why

I am reverse engineering a wired RS-485 camera-head protocol with a Labrador as the logic-level scope. Each experiment is a short burst, and I need hundreds of them with host timestamps, scripted rather than clicked.

What exists today does not fit:

  • Unified_App DAQ "Record to File". Full rate and CH1+CH2, but it is started by a human click after the event and before the 10 s buffer rolls over. No armed trigger, and the text output has no time column.
  • Unified_App trigger plus scope export. Exports the plotted vectors, decimated to 2048 samples, as separate two-column rows. A 9600 baud burst needs the full rate.
  • Desktop_Interface DAQ. Manual, separate untimestamped per-channel files, with averaging.
  • Desktop UART decoder. A decode console, not a waveform.

What changed

  • librador: librador_get_analog_sample_count() and librador_copy_analog_window() give absolute-indexed access to the existing 10 s full-rate buffers. librador_capture_around_trigger() scans the stream for an edge with hysteresis and returns CH1/CH2 over [trigger - pre, trigger + post) with a shared time origin. Errors are distinct return codes (no stream, bad request, timeout, stream restarted by a mode change).
  • Unified_App/cli: one executable with subcommands, so more can be added later (add a Command, register it in main.cpp). Split into Command, OptionParser, DeviceSession and CaptureCommand. It has --help, strict option parsing, separate usage and runtime exit statuses, librador logging routed to stderr, and an output-path check before it waits for a trigger.
  • capture options: --out, --mode scope1|scope2, --gain, --trigger CH:EDGE:VOLTS, --hysteresis, --pre, --post, --timeout, --settle, --transport auto|iso6|iso1, --verbose.
labrador-cli capture --gain 4 --trigger ch1:rising:3.0 --pre 0.2 --post 0.3 --out capture.csv

Nothing in the GUI is touched; add_subdirectory(cli) sits next to the other librador tools and is skipped on Android.

Why --transport defaults to ISO1

ISO1 validates every frame exactly. ISO6 pairs each meta-packet checksum with a data frame approximately, so it reports about 0.6% bad and about 12% unvalidated frames on healthy runs. Per-frame continuity checks on a triangle wave found no corrupted samples alongside those flags, so they look like false alarms in the pairing, not corruption. A bad-frame warning from a CLI should mean something, hence ISO1. ISO6 and auto remain selectable. I did not change the pairing logic here; that is a separate question.

My first reason for the default was wrong. I blamed ISO6 for degrading across repeated sessions, which turned out to be the teardown leak fixed in #461. The history is squashed so the commit message carries the correct reason.

Alternatives explored

  • Wrapper over the GUI's DAQ or export. Rejected for the reasons above (human timing, decimation).
  • A mostly-GUI "export full-rate buffer around trigger" action. The librador API is that feature; the CLI is a thin client of it, so a GUI action or other front end can reuse it.
  • MCP server instead of or on top of a CLI. Deferred. A CLI is easier to script and test, and an MCP wrapper can be layered on it later.

Testing

Windows, MinGW build, firmware 0x000C. Used to capture CH1 and CH2 of a 0 to 6.2 V differential pair at gain 4, triggering rising at 2.5 V with 50 ms pre-trigger, and cross-checked against an independent serial logger on the same signal; the --help text and rejection of bad option values were also checked. This branch carries the same code as the one I tested, plus the teardown fix and without the separate Windows libusb change (#459). Frame warnings: frames_bad_checksum and frames_dropped are counted over the whole wait, so they cannot say whether a bad frame fell inside the returned window. The header comment says so, and says that on the iso transports a bad-checksum frame stays as received (a dropped frame is filled with a repeat of the last good one).

Not tested

macOS and Linux builds were not run. Only the scope buffers are used, so DAQ and logic-analyser modes are not covered.

Librador had no destructor, so ~usbCallHandler never ran: the board kept
its streaming alt setting and libusb was never shut down. The next
session then degraded (URB timeouts) or failed to switch transport until
a physical replug. The destructor now runs teardown_connection(), which
also stops the ingest thread and selects alt setting 0.
…ture CLI

Adds absolute-indexed access to the 10 s scope buffers, a
librador_capture_around_trigger() that scans the stream for an edge with
hysteresis and returns CH1/CH2 over [trigger - pre, trigger + post) at
native rate, and a labrador-capture CLI that writes it as t,CH1,CH2 CSV
with a JSON sidecar.
… CaptureCommand

One executable with subcommands; capture is the first. Adds --help / help,
strict option parsing, usage vs runtime exit statuses, librador logging
routed to stderr, and fail-fast output-path check.
ISO1 validates every frame exactly. ISO6 pairs each meta-packet checksum
with a data frame approximately, so it reports about 0.6% bad and about
12% unvalidated frames that turn out to be false alarms: sample
continuity showed no corruption alongside them. A bad-frame warning from
the CLI should mean something, so default to ISO1 and let --transport
auto|iso6|iso1 select the others.
@EspoTek

EspoTek commented Oct 7, 2026

Copy link
Copy Markdown
Collaborator

This is a big commit, thanks!

Have you thoroughly tested it?

@AronRubin

Copy link
Copy Markdown
Author

I have used it a lot. I have not measured test coverage.

@EspoTek

EspoTek commented Oct 13, 2026

Copy link
Copy Markdown
Collaborator

Nice, seems to work on macos with bulk as the transport.

EspoTek added a commit to AronRubin/Labrador that referenced this pull request Oct 13, 2026
On iso6 the ingest thread scored each meta packet the moment it was
processed, but assembled iso6 frames only after the whole batch
(drain_iso6_queues at the end of ingest_thread_function).  Whenever a
block's meta URB landed in the same batch as its six data URBs, all 33
metas were matched against a checksum ring that was a full block behind:
one ok, two bad, thirty unvalidated per such block.  That is the ~0.6%
bad / ~12% unvalidated reported on healthy iso6 streams (espotek-org#462), and the
reason iso1 (which dispatches inline) never showed it.

Metas now wait in pending_meta until the candidate frames around their
slot (lag -3..+1) have been noted, or until the ring is about to lap
them, and are scored then.  iso6 frames are also drained as soon as
their six slices are queued rather than at batch end, so they reach the
buffers without a batch-sized lag.  note_seq no longer adds 65535 to
frames_dropped when the device repeats a header (negative delta).

Validated with a host-only simulation that drives these functions with
the firmware's lag-1 meta model: every batch-collision and
meta-before-data scenario drops from 0.76% bad / 12% unvalidated to
0 / 0 with no change to frame assembly.  A lost data URB (six-queue
lockstep skew) now reads as ~93% bad instead of mostly unvalidated.
Not yet verified on iso6 hardware: this machine is macOS, where only
the bulk transport may be opened.
…auto

- DeviceSession treated librador_connect() == 1 (board open but on a
  firmware librador will not stream from) as success, so capture armed
  and waited forever on an empty buffer.  It now fails with the board's
  version against the expected one.  librador.h documents the
  0 / 1 / negative contract of librador_connect().
- New `labrador-cli update-firmware [--hex FILE] [--force]`: flashes the
  bundled labrafirm hex (assets/firmware next to the binary, as the app
  lays it out; cli/CMakeLists copies it for dev builds) when the board's
  firmware differs, launches and if needed reflashes a board stuck in
  bootloader mode, and exits 0 when the board ends up on the expected
  firmware.  A Gobindar-state board is reported, not handled.
- --transport defaults to auto (librador's per-platform choice: iso6 on
  Windows and Linux, bulk on macOS); the iso6 false-alarm fix that makes
  that default trustworthy is espotek-org#467.  `bulk` is selectable.
- Command list column sized to the longest command name.
Builds only the labrador_cli target, runs its help commands as a
board-free smoke test, checks the bundled firmware hex is beside the
binary, and uploads each binary with the hex.  Windows is checked for
no non-system DLL dependencies, macOS is a universal binary against the
same static libusb the app uses.
SDL3 is configured for the whole tree and fails hard when X11 is present
without Xcursor; use the same package set as the main Linux workflow.
@EspoTek
EspoTek force-pushed the feature/labrador-cli-triggered-capture branch from 1988117 to 078321c Compare October 13, 2026 06:04
@EspoTek

EspoTek commented Oct 13, 2026

Copy link
Copy Markdown
Collaborator

Had the agent go over it and add a few fixes (including a github release!). Overall this is super nifty, though.

The default transport was changed to auto because #467 fixes the iso6 issue.

Thoughts before merging?

… on macOS

SDL_GetBasePath() goes through NSBundle on macOS, which trusts the path the
process was invoked by: labrador-cli run via a symlink from outside
Labrador.app (how it goes on PATH) looked for assets/firmware beside the
link. Resolve the real executable too and try Contents/Resources from
there; the error now lists every location tried.
….deb and AppImage

Windows: labrador-cli.exe (x64 or x86 to match the host) installs beside
labrador.exe and shares its assets\firmware; the win32 cross job uploads
the 32-bit CLI alongside the GUI exe.
macOS: Contents/MacOS/labrador-cli, signed with the hardened runtime
before the bundle and notarized with it.
.deb: /usr/lib/labrador/labrador-cli with a /usr/bin symlink on PATH.
AppImage: usr/bin/labrador-cli plus an AppRun hook so
'<AppImage> cli ...' (or an ARGV0 of labrador-cli) runs it.
Android: untouched.

Each job smoke-tests the packaged CLI (help, and update-firmware with no
board, which must get as far as looking for one: proof it found the
bundled hex in that layout). Static-link and deployment-floor checks now
cover both binaries.
cli/README.md documents both commands with real output, the CSV and JSON
formats, exit codes and where each package puts the binary; the app README
links to it and the rolling release notes mention the tool per platform.
@AronRubin

Copy link
Copy Markdown
Author

Sorry for the radio silence. I am working too many simultaneous projects. I like the changes and the iso6 issues make more sense in light of the checksum issues you found. That will help the graphical tool remove a bunch of blips. We only want the bleeps, sweeps, and creeps that are coming from the line ☺️

@EspoTek

EspoTek commented Oct 19, 2026

Copy link
Copy Markdown
Collaborator

Haha, awesome! Merging this one now.

Resolve the release-notes conflict in continuous.yml: keep the
Labrador-for-Linux-<arch> asset names from espotek-org#449 and add the CLI
usage sentences from this branch.
EspoTek added a commit to AronRubin/Labrador that referenced this pull request Oct 19, 2026
On iso6 the ingest thread scored each meta packet the moment it was
processed, but assembled iso6 frames only after the whole batch
(drain_iso6_queues at the end of ingest_thread_function).  Whenever a
block's meta URB landed in the same batch as its six data URBs, all 33
metas were matched against a checksum ring that was a full block behind:
one ok, two bad, thirty unvalidated per such block.  That is the ~0.6%
bad / ~12% unvalidated reported on healthy iso6 streams (espotek-org#462), and the
reason iso1 (which dispatches inline) never showed it.

Metas now wait in pending_meta until the candidate frames around their
slot (lag -3..+1) have been noted, or until the ring is about to lap
them, and are scored then.  iso6 frames are also drained as soon as
their six slices are queued rather than at batch end, so they reach the
buffers without a batch-sized lag.  note_seq no longer adds 65535 to
frames_dropped when the device repeats a header (negative delta).

Validated with a host-only simulation that drives these functions with
the firmware's lag-1 meta model: every batch-collision and
meta-before-data scenario drops from 0.76% bad / 12% unvalidated to
0 / 0 with no change to frame assembly.  A lost data URB (six-queue
lockstep skew) now reads as ~93% bad instead of mostly unvalidated.
Not yet verified on iso6 hardware: this machine is macOS, where only
the bulk transport may be opened.
@EspoTek
EspoTek merged commit 55967b5 into espotek-org:unified-app Oct 19, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants