From 4671f694ced868aad2e51613557702545d158bc9 Mon Sep 17 00:00:00 2001 From: Aron Rubin Date: Wed, 30 Sep 2026 11:52:20 -0400 Subject: [PATCH 01/13] librador: destroy the USB handler on librador_exit 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. --- Unified_App/librador/librador.cpp | 6 ++++++ Unified_App/librador/librador_internal.h | 1 + Unified_App/librador/usbcallhandler.cpp | 24 +++--------------------- 3 files changed, 10 insertions(+), 21 deletions(-) diff --git a/Unified_App/librador/librador.cpp b/Unified_App/librador/librador.cpp index c9dc133c3..736a896be 100644 --- a/Unified_App/librador/librador.cpp +++ b/Unified_App/librador/librador.cpp @@ -32,6 +32,12 @@ Librador::Librador() usb_driver = new usbCallHandler(LABRADOR_VID, LABRADOR_PID); } +// Without this the board keeps its streaming alt setting after librador_exit. +Librador::~Librador() +{ + delete usb_driver; +} + int librador_init(int transport_type){ if(internal_librador_object){ //Object already initialised; still honour a transport change ahead diff --git a/Unified_App/librador/librador_internal.h b/Unified_App/librador/librador_internal.h index 29dad6741..b4c1f8272 100644 --- a/Unified_App/librador/librador_internal.h +++ b/Unified_App/librador/librador_internal.h @@ -20,6 +20,7 @@ class Librador public: Librador(); + ~Librador(); usbCallHandler *usb_driver = nullptr; }; diff --git a/Unified_App/librador/usbcallhandler.cpp b/Unified_App/librador/usbcallhandler.cpp index 29182a1d6..30aad462f 100644 --- a/Unified_App/librador/usbcallhandler.cpp +++ b/Unified_App/librador/usbcallhandler.cpp @@ -455,32 +455,14 @@ usbCallHandler::~usbCallHandler(){ //Kill off iso_polling_thread. Maybe join then get it to detect its own timeout condition. LIBRADOR_LOG(LOG_DEBUG, "Calling destructor for librador USB call handler\n"); - if(iso_polling_thread) - { - iso_thread_shutdown_requested = true; - LIBRADOR_LOG(LOG_DEBUG, "Shutting down USB polling thread...\n"); - iso_polling_thread->join(); - LIBRADOR_LOG(LOG_DEBUG, "USB polling thread stopped.\n"); - delete iso_polling_thread; - - free_transfers(); - } + // Also stops streaming (alt setting 0); without it the board keeps its ISO + // alt setting selected after exit and the next session degrades or fails to connect. + teardown_connection(); if(daq_thread && daq_thread->joinable()){ daq_thread->join(); } - if(handle){ - if(claimed_iface > 0){ - libusb_release_interface(handle, claimed_iface); - } - if(iface0_claimed){ - libusb_release_interface(handle, 0); - } - LIBRADOR_LOG(LOG_DEBUG, "Interface released\n"); - libusb_close(handle); - LIBRADOR_LOG(LOG_DEBUG, "Device Closed\n"); - } if(ctx){ libusb_exit(ctx); LIBRADOR_LOG(LOG_DEBUG, "Libusb exited\n"); From e86ad98b638bb74c1d221e21f5e399bd878fb0a8 Mon Sep 17 00:00:00 2001 From: Aron Rubin Date: Wed, 30 Sep 2026 10:34:09 -0400 Subject: [PATCH 02/13] librador: armed full-rate capture around a trigger, plus labrador-capture 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. --- Unified_App/CMakeLists.txt | 5 + Unified_App/librador/librador.cpp | 104 ++++++++++++++++++++ Unified_App/librador/librador.h | 33 +++++++ Unified_App/librador/o1buffer.cpp | 17 ++++ Unified_App/librador/o1buffer.h | 8 ++ Unified_App/librador/usbcallhandler.cpp | 31 ++++++ Unified_App/librador/usbcallhandler.h | 7 ++ Unified_App/tools/labrador_capture.cpp | 123 ++++++++++++++++++++++++ 8 files changed, 328 insertions(+) create mode 100644 Unified_App/tools/labrador_capture.cpp diff --git a/Unified_App/CMakeLists.txt b/Unified_App/CMakeLists.txt index 49c8e9710..e2ff38665 100644 --- a/Unified_App/CMakeLists.txt +++ b/Unified_App/CMakeLists.txt @@ -169,6 +169,11 @@ 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) + + # Headless triggered capture of the full-rate scope buffers to CSV. + add_executable(labrador_capture tools/labrador_capture.cpp) + set_target_properties(labrador_capture PROPERTIES OUTPUT_NAME labrador-capture) + target_link_libraries(labrador_capture PRIVATE librador) endif() if(APPLE AND NOT ANDROID) diff --git a/Unified_App/librador/librador.cpp b/Unified_App/librador/librador.cpp index 736a896be..f5faed399 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 b1cfa19a3..6aec63c6e 100644 --- a/Unified_App/librador/librador.h +++ b/Unified_App/librador/librador.h @@ -116,6 +116,39 @@ 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 + uint64_t frames_bad_checksum = 0; // during the capture; such frames were replaced by a repeat of the last good one + 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(); diff --git a/Unified_App/librador/o1buffer.cpp b/Unified_App/librador/o1buffer.cpp index d030f8d2b..af045362e 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 ca91346b0..e69afd80a 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 30aad462f..902e2bb5f 100644 --- a/Unified_App/librador/usbcallhandler.cpp +++ b/Unified_App/librador/usbcallhandler.cpp @@ -1191,6 +1191,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 b698aea2b..1035df29c 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/tools/labrador_capture.cpp b/Unified_App/tools/labrador_capture.cpp new file mode 100644 index 000000000..ea4b20917 --- /dev/null +++ b/Unified_App/tools/labrador_capture.cpp @@ -0,0 +1,123 @@ +// Headless triggered capture: configure the scope, arm a trigger, wait, and +// write the full-rate buffer around the trigger as CSV (t,CH1[,CH2]; t in +// seconds, 0 = trigger sample) plus a JSON sidecar. +// +// labrador-capture --mode scope2 --gain 4 --trigger ch1:rising:3.0 \ +// --pre 0.2 --post 0.3 --out capture.csv +#include +#include +#include +#include +#include +#include +#include "librador.h" + +static void usage(const char *argv0) { + fprintf(stderr, + "usage: %s --out FILE [options]\n" + " --mode scope1|scope2 scope1 = CH1 only, scope2 = CH1+CH2 (default scope2)\n" + " --gain G 0.5|1|2|4|8|16|32|64 (default 4; 6 V signals need <= 4)\n" + " --trigger CH:EDGE:VOLTS e.g. ch1:rising:3.0 (default ch1:rising:3.0)\n" + " --hysteresis V re-arm distance from the level (default 0.25)\n" + " --pre S / --post S seconds kept before / after the trigger (default 0.2 / 0.3)\n" + " --timeout S give up waiting for the trigger (default 0 = forever)\n" + " --settle S wait after configuring the scope (default 0.5)\n" + " --out FILE CSV path; FILE.json gets the metadata\n", argv0); +} + +int main(int argc, char **argv) { + std::string out, mode = "scope2"; + double gain = 4, pre = 0.2, post = 0.3, timeout = 0, settle = 0.5, hyst = 0.25; + librador_capture_request req; + + for (int i = 1; i < argc; i++) { + const char *a = argv[i]; + auto next = [&]() -> const char * { + if (i + 1 >= argc) { usage(argv[0]); exit(2); } + return argv[++i]; + }; + if (!strcmp(a, "--out")) out = next(); + else if (!strcmp(a, "--mode")) mode = next(); + else if (!strcmp(a, "--gain")) gain = atof(next()); + else if (!strcmp(a, "--pre")) pre = atof(next()); + else if (!strcmp(a, "--post")) post = atof(next()); + else if (!strcmp(a, "--timeout")) timeout = atof(next()); + else if (!strcmp(a, "--settle")) settle = atof(next()); + else if (!strcmp(a, "--hysteresis")) hyst = atof(next()); + else if (!strcmp(a, "--trigger")) { + char edge[16]; + int ch; + double level; + if (sscanf(next(), "ch%d:%15[a-z]:%lf", &ch, edge, &level) != 3 || + (ch != 1 && ch != 2) || (strcmp(edge, "rising") && strcmp(edge, "falling"))) { + fprintf(stderr, "bad --trigger, expected ch1|ch2:rising|falling:VOLTS\n"); + return 2; + } + req.trigger_channel = ch; + req.rising = !strcmp(edge, "rising"); + req.level_v = level; + } else { usage(argv[0]); return 2; } + } + if (out.empty() || (mode != "scope1" && mode != "scope2")) { usage(argv[0]); return 2; } + req.pre_s = pre; + req.post_s = post; + req.timeout_s = timeout; + req.hysteresis_v = hyst; + if (mode == "scope1" && req.trigger_channel == 2) { + fprintf(stderr, "trigger on ch2 needs --mode scope2\n"); + return 2; + } + + if (librador_init(LABRADOR_TRANSPORT_AUTO) < 0) { fprintf(stderr, "librador_init failed\n"); return 1; } + int rc = librador_connect(); + if (rc < 0 || !librador_is_connected()) { fprintf(stderr, "librador_connect failed: %d\n", rc); return 1; } + + if (librador_set_oscilloscope_gain(gain) < 0) { fprintf(stderr, "invalid gain %g\n", gain); librador_exit(); return 2; } + librador_set_device_mode(mode == "scope1" ? 0 : 2); + std::this_thread::sleep_for(std::chrono::duration(settle)); + + fprintf(stderr, "armed: ch%d %s through %.3f V, pre %.3f s, post %.3f s\n", + req.trigger_channel, req.rising ? "rising" : "falling", req.level_v, pre, post); + fflush(stderr); + + librador_capture_result res; + rc = librador_capture_around_trigger(&req, &res); + if (rc != 0) { + fprintf(stderr, "capture failed: %d (-3 no stream, -4 bad request, -5 timeout, -6 stream restarted)\n", rc); + librador_exit(); + return 1; + } + + FILE *f = fopen(out.c_str(), "w"); + if (!f) { perror(out.c_str()); librador_exit(); return 1; } + const bool two = !res.ch2.empty(); + fprintf(f, two ? "t,CH1,CH2\n" : "t,CH1\n"); + for (size_t i = 0; i < res.ch1.size(); i++) { + double t = ((double)i - res.pre_samples) / res.sample_rate_hz; + if (two) fprintf(f, "%.7f,%.4f,%.4f\n", t, res.ch1[i], res.ch2[i]); + else fprintf(f, "%.7f,%.4f\n", t, res.ch1[i]); + } + fclose(f); + + FILE *j = fopen((out + ".json").c_str(), "w"); + if (j) { + fprintf(j, + "{\n \"sample_rate_hz\": %.0f,\n \"samples\": %zu,\n \"pre_samples\": %d,\n" + " \"trigger\": {\"channel\": %d, \"edge\": \"%s\", \"level_v\": %g, \"hysteresis_v\": %g},\n" + " \"gain\": %g,\n \"mode\": \"%s\",\n" + " \"frames_bad_checksum\": %llu,\n \"frames_dropped\": %llu\n}\n", + res.sample_rate_hz, res.ch1.size(), res.pre_samples, req.trigger_channel, + req.rising ? "rising" : "falling", req.level_v, req.hysteresis_v, gain, mode.c_str(), + (unsigned long long)res.frames_bad_checksum, (unsigned long long)res.frames_dropped); + fclose(j); + } + + printf("wrote %s: %zu samples at %.0f Hz, trigger at t=0 (sample %d)%s\n", out.c_str(), res.ch1.size(), + res.sample_rate_hz, res.pre_samples, two ? ", CH1+CH2" : ", CH1 only"); + if (res.frames_bad_checksum || res.frames_dropped) + printf("warning: %llu bad-checksum and %llu dropped frames during the capture (repeated samples in the window)\n", + (unsigned long long)res.frames_bad_checksum, (unsigned long long)res.frames_dropped); + + librador_exit(); + return 0; +} From e85300740d8225879257a2b4efa6070fe60a6a81 Mon Sep 17 00:00:00 2001 From: Aron Rubin Date: Wed, 30 Sep 2026 10:39:15 -0400 Subject: [PATCH 03/13] Move labrador-capture into Unified_App/cli with its own CMakeLists --- Unified_App/CMakeLists.txt | 5 +---- Unified_App/cli/CMakeLists.txt | 4 ++++ Unified_App/{tools => cli}/labrador_capture.cpp | 0 3 files changed, 5 insertions(+), 4 deletions(-) create mode 100644 Unified_App/cli/CMakeLists.txt rename Unified_App/{tools => cli}/labrador_capture.cpp (100%) diff --git a/Unified_App/CMakeLists.txt b/Unified_App/CMakeLists.txt index e2ff38665..7cbdd560b 100644 --- a/Unified_App/CMakeLists.txt +++ b/Unified_App/CMakeLists.txt @@ -170,10 +170,7 @@ if(NOT ANDROID) add_executable(librador_bulk_test tools/librador_bulk_test.cpp) target_link_libraries(librador_bulk_test PRIVATE librador) - # Headless triggered capture of the full-rate scope buffers to CSV. - add_executable(labrador_capture tools/labrador_capture.cpp) - set_target_properties(labrador_capture PROPERTIES OUTPUT_NAME labrador-capture) - target_link_libraries(labrador_capture PRIVATE librador) + add_subdirectory(cli) endif() if(APPLE AND NOT ANDROID) diff --git a/Unified_App/cli/CMakeLists.txt b/Unified_App/cli/CMakeLists.txt new file mode 100644 index 000000000..5a8406255 --- /dev/null +++ b/Unified_App/cli/CMakeLists.txt @@ -0,0 +1,4 @@ +# Headless command-line tools over librador; add one add_executable per tool. +add_executable(labrador_capture labrador_capture.cpp) +set_target_properties(labrador_capture PROPERTIES OUTPUT_NAME labrador-capture) +target_link_libraries(labrador_capture PRIVATE librador) diff --git a/Unified_App/tools/labrador_capture.cpp b/Unified_App/cli/labrador_capture.cpp similarity index 100% rename from Unified_App/tools/labrador_capture.cpp rename to Unified_App/cli/labrador_capture.cpp From c6efdc7b6b76f9e7e346aae6e5c2e3f2107fef44 Mon Sep 17 00:00:00 2001 From: Aron Rubin Date: Wed, 30 Sep 2026 10:47:18 -0400 Subject: [PATCH 04/13] cli: split labrador-cli into Command, OptionParser, DeviceSession and 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. --- Unified_App/cli/CMakeLists.txt | 13 +- Unified_App/cli/CaptureCommand.cpp | 276 +++++++++++++++++++++++++++ Unified_App/cli/CaptureCommand.h | 14 ++ Unified_App/cli/Command.h | 51 +++++ Unified_App/cli/DeviceSession.cpp | 69 +++++++ Unified_App/cli/DeviceSession.h | 34 ++++ Unified_App/cli/OptionParser.cpp | 92 +++++++++ Unified_App/cli/OptionParser.h | 43 +++++ Unified_App/cli/labrador_capture.cpp | 123 ------------ Unified_App/cli/main.cpp | 109 +++++++++++ 10 files changed, 697 insertions(+), 127 deletions(-) create mode 100644 Unified_App/cli/CaptureCommand.cpp create mode 100644 Unified_App/cli/CaptureCommand.h create mode 100644 Unified_App/cli/Command.h create mode 100644 Unified_App/cli/DeviceSession.cpp create mode 100644 Unified_App/cli/DeviceSession.h create mode 100644 Unified_App/cli/OptionParser.cpp create mode 100644 Unified_App/cli/OptionParser.h delete mode 100644 Unified_App/cli/labrador_capture.cpp create mode 100644 Unified_App/cli/main.cpp diff --git a/Unified_App/cli/CMakeLists.txt b/Unified_App/cli/CMakeLists.txt index 5a8406255..86d5eb688 100644 --- a/Unified_App/cli/CMakeLists.txt +++ b/Unified_App/cli/CMakeLists.txt @@ -1,4 +1,9 @@ -# Headless command-line tools over librador; add one add_executable per tool. -add_executable(labrador_capture labrador_capture.cpp) -set_target_properties(labrador_capture PROPERTIES OUTPUT_NAME labrador-capture) -target_link_libraries(labrador_capture PRIVATE librador) +# 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) +set_target_properties(labrador_cli PROPERTIES OUTPUT_NAME labrador-cli) +target_link_libraries(labrador_cli PRIVATE librador) diff --git a/Unified_App/cli/CaptureCommand.cpp b/Unified_App/cli/CaptureCommand.cpp new file mode 100644 index 000000000..4e5d067ae --- /dev/null +++ b/Unified_App/cli/CaptureCommand.cpp @@ -0,0 +1,276 @@ +#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; + 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 + "'"); +} + +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.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); + 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 " + "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 000000000..5f020fc4f --- /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 000000000..3910182ae --- /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 000000000..65e0ab97f --- /dev/null +++ b/Unified_App/cli/DeviceSession.cpp @@ -0,0 +1,69 @@ +#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) : m_verbose(verbose) +{ + librador_logger_set(&m_verbose, logToStderr); + + if (librador_init(LABRADOR_TRANSPORT_AUTO) < 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?"); + } +} + +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(); +} diff --git a/Unified_App/cli/DeviceSession.h b/Unified_App/cli/DeviceSession.h new file mode 100644 index 000000000..2072fd606 --- /dev/null +++ b/Unified_App/cli/DeviceSession.h @@ -0,0 +1,34 @@ +#pragma once + +#include + +enum class ScopeMode +{ + Ch1, // one channel at 375 kS/s + Ch1Ch2 // both channels at 375 kS/s each +}; + +// 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); + ~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); + + 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 000000000..fc87a2bdb --- /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 000000000..07903139d --- /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/labrador_capture.cpp b/Unified_App/cli/labrador_capture.cpp deleted file mode 100644 index ea4b20917..000000000 --- a/Unified_App/cli/labrador_capture.cpp +++ /dev/null @@ -1,123 +0,0 @@ -// Headless triggered capture: configure the scope, arm a trigger, wait, and -// write the full-rate buffer around the trigger as CSV (t,CH1[,CH2]; t in -// seconds, 0 = trigger sample) plus a JSON sidecar. -// -// labrador-capture --mode scope2 --gain 4 --trigger ch1:rising:3.0 \ -// --pre 0.2 --post 0.3 --out capture.csv -#include -#include -#include -#include -#include -#include -#include "librador.h" - -static void usage(const char *argv0) { - fprintf(stderr, - "usage: %s --out FILE [options]\n" - " --mode scope1|scope2 scope1 = CH1 only, scope2 = CH1+CH2 (default scope2)\n" - " --gain G 0.5|1|2|4|8|16|32|64 (default 4; 6 V signals need <= 4)\n" - " --trigger CH:EDGE:VOLTS e.g. ch1:rising:3.0 (default ch1:rising:3.0)\n" - " --hysteresis V re-arm distance from the level (default 0.25)\n" - " --pre S / --post S seconds kept before / after the trigger (default 0.2 / 0.3)\n" - " --timeout S give up waiting for the trigger (default 0 = forever)\n" - " --settle S wait after configuring the scope (default 0.5)\n" - " --out FILE CSV path; FILE.json gets the metadata\n", argv0); -} - -int main(int argc, char **argv) { - std::string out, mode = "scope2"; - double gain = 4, pre = 0.2, post = 0.3, timeout = 0, settle = 0.5, hyst = 0.25; - librador_capture_request req; - - for (int i = 1; i < argc; i++) { - const char *a = argv[i]; - auto next = [&]() -> const char * { - if (i + 1 >= argc) { usage(argv[0]); exit(2); } - return argv[++i]; - }; - if (!strcmp(a, "--out")) out = next(); - else if (!strcmp(a, "--mode")) mode = next(); - else if (!strcmp(a, "--gain")) gain = atof(next()); - else if (!strcmp(a, "--pre")) pre = atof(next()); - else if (!strcmp(a, "--post")) post = atof(next()); - else if (!strcmp(a, "--timeout")) timeout = atof(next()); - else if (!strcmp(a, "--settle")) settle = atof(next()); - else if (!strcmp(a, "--hysteresis")) hyst = atof(next()); - else if (!strcmp(a, "--trigger")) { - char edge[16]; - int ch; - double level; - if (sscanf(next(), "ch%d:%15[a-z]:%lf", &ch, edge, &level) != 3 || - (ch != 1 && ch != 2) || (strcmp(edge, "rising") && strcmp(edge, "falling"))) { - fprintf(stderr, "bad --trigger, expected ch1|ch2:rising|falling:VOLTS\n"); - return 2; - } - req.trigger_channel = ch; - req.rising = !strcmp(edge, "rising"); - req.level_v = level; - } else { usage(argv[0]); return 2; } - } - if (out.empty() || (mode != "scope1" && mode != "scope2")) { usage(argv[0]); return 2; } - req.pre_s = pre; - req.post_s = post; - req.timeout_s = timeout; - req.hysteresis_v = hyst; - if (mode == "scope1" && req.trigger_channel == 2) { - fprintf(stderr, "trigger on ch2 needs --mode scope2\n"); - return 2; - } - - if (librador_init(LABRADOR_TRANSPORT_AUTO) < 0) { fprintf(stderr, "librador_init failed\n"); return 1; } - int rc = librador_connect(); - if (rc < 0 || !librador_is_connected()) { fprintf(stderr, "librador_connect failed: %d\n", rc); return 1; } - - if (librador_set_oscilloscope_gain(gain) < 0) { fprintf(stderr, "invalid gain %g\n", gain); librador_exit(); return 2; } - librador_set_device_mode(mode == "scope1" ? 0 : 2); - std::this_thread::sleep_for(std::chrono::duration(settle)); - - fprintf(stderr, "armed: ch%d %s through %.3f V, pre %.3f s, post %.3f s\n", - req.trigger_channel, req.rising ? "rising" : "falling", req.level_v, pre, post); - fflush(stderr); - - librador_capture_result res; - rc = librador_capture_around_trigger(&req, &res); - if (rc != 0) { - fprintf(stderr, "capture failed: %d (-3 no stream, -4 bad request, -5 timeout, -6 stream restarted)\n", rc); - librador_exit(); - return 1; - } - - FILE *f = fopen(out.c_str(), "w"); - if (!f) { perror(out.c_str()); librador_exit(); return 1; } - const bool two = !res.ch2.empty(); - fprintf(f, two ? "t,CH1,CH2\n" : "t,CH1\n"); - for (size_t i = 0; i < res.ch1.size(); i++) { - double t = ((double)i - res.pre_samples) / res.sample_rate_hz; - if (two) fprintf(f, "%.7f,%.4f,%.4f\n", t, res.ch1[i], res.ch2[i]); - else fprintf(f, "%.7f,%.4f\n", t, res.ch1[i]); - } - fclose(f); - - FILE *j = fopen((out + ".json").c_str(), "w"); - if (j) { - fprintf(j, - "{\n \"sample_rate_hz\": %.0f,\n \"samples\": %zu,\n \"pre_samples\": %d,\n" - " \"trigger\": {\"channel\": %d, \"edge\": \"%s\", \"level_v\": %g, \"hysteresis_v\": %g},\n" - " \"gain\": %g,\n \"mode\": \"%s\",\n" - " \"frames_bad_checksum\": %llu,\n \"frames_dropped\": %llu\n}\n", - res.sample_rate_hz, res.ch1.size(), res.pre_samples, req.trigger_channel, - req.rising ? "rising" : "falling", req.level_v, req.hysteresis_v, gain, mode.c_str(), - (unsigned long long)res.frames_bad_checksum, (unsigned long long)res.frames_dropped); - fclose(j); - } - - printf("wrote %s: %zu samples at %.0f Hz, trigger at t=0 (sample %d)%s\n", out.c_str(), res.ch1.size(), - res.sample_rate_hz, res.pre_samples, two ? ", CH1+CH2" : ", CH1 only"); - if (res.frames_bad_checksum || res.frames_dropped) - printf("warning: %llu bad-checksum and %llu dropped frames during the capture (repeated samples in the window)\n", - (unsigned long long)res.frames_bad_checksum, (unsigned long long)res.frames_dropped); - - librador_exit(); - return 0; -} diff --git a/Unified_App/cli/main.cpp b/Unified_App/cli/main.cpp new file mode 100644 index 000000000..5e2d1de83 --- /dev/null +++ b/Unified_App/cli/main.cpp @@ -0,0 +1,109 @@ +#include "CaptureCommand.h" +#include "Command.h" + +#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"; + for (const auto& command : commands) + out << " " << std::left << std::setw(10) << 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()); + + 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; + } +} From b850ca5ea04e8e84c67919c3edf154d561d7270c Mon Sep 17 00:00:00 2001 From: Aron Rubin Date: Wed, 30 Sep 2026 10:59:58 -0400 Subject: [PATCH 05/13] Correct docs: iso bad-checksum frames are kept as received, not replaced --- Unified_App/cli/CaptureCommand.cpp | 2 +- Unified_App/librador/librador.h | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/Unified_App/cli/CaptureCommand.cpp b/Unified_App/cli/CaptureCommand.cpp index 4e5d067ae..05cf12458 100644 --- a/Unified_App/cli/CaptureCommand.cpp +++ b/Unified_App/cli/CaptureCommand.cpp @@ -271,6 +271,6 @@ int CaptureCommand::run(const std::vector& args) std::cerr << "warning: " << result.frames_bad_checksum << " bad-checksum and " << result.frames_dropped << " dropped USB frames while waiting; the window may contain " - "repeated samples\n"; + "corrupt or repeated samples\n"; return EXIT_SUCCESS; } diff --git a/Unified_App/librador/librador.h b/Unified_App/librador/librador.h index 6aec63c6e..ff2b8224b 100644 --- a/Unified_App/librador/librador.h +++ b/Unified_App/librador/librador.h @@ -139,7 +139,11 @@ struct librador_capture_result { 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 - uint64_t frames_bad_checksum = 0; // during the capture; such frames were replaced by a repeat of the last good one + // 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, From d0295a3bec508710cf7d3b1a4b5fd571f05a8275 Mon Sep 17 00:00:00 2001 From: Aron Rubin Date: Thu, 1 Oct 2026 23:06:14 -0400 Subject: [PATCH 06/13] capture: add --transport and default 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 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. --- Unified_App/cli/CaptureCommand.cpp | 18 +++++++++++++++++- Unified_App/cli/DeviceSession.cpp | 7 +++++-- Unified_App/cli/DeviceSession.h | 10 +++++++++- 3 files changed, 31 insertions(+), 4 deletions(-) diff --git a/Unified_App/cli/CaptureCommand.cpp b/Unified_App/cli/CaptureCommand.cpp index 05cf12458..bbafa3ef4 100644 --- a/Unified_App/cli/CaptureCommand.cpp +++ b/Unified_App/cli/CaptureCommand.cpp @@ -19,6 +19,8 @@ struct Options ScopeMode mode = ScopeMode::Ch1Ch2; double gain = 4; double settle_s = 0.5; + // ISO1 validates every frame; ISO6 pairs checksums approximately and reports false alarms. + UsbTransport transport = UsbTransport::Iso1; bool verbose = false; librador_capture_request request; // trigger, hysteresis, pre/post, timeout }; @@ -47,6 +49,17 @@ ScopeMode parseMode(const std::string& text) 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; + throw UsageError("option '--transport' must be auto, iso6 or iso1, got '" + text + "'"); +} + void parseTrigger(const std::string& spec, librador_capture_request& request) { std::vector parts; @@ -117,6 +130,9 @@ OptionParser makeParser(Options& options) formatNumber(defaults.settle_s), [&](const std::string& v) { options.settle_s = parseSeconds("--settle", v); }); + parser.addOption("--transport", "T", + "USB transport: iso1, iso6 or auto (the platform default); default iso1", + [&](const std::string& v) { options.transport = parseTransport(v); }); parser.addFlag("--verbose", "also show librador's debug messages on stderr", [&]() { options.verbose = true; }); return parser; @@ -243,7 +259,7 @@ int CaptureCommand::run(const std::vector& args) if (!csv) throw CommandError("cannot open '" + options.out + "' for writing"); - DeviceSession session(options.verbose); + DeviceSession session(options.verbose, options.transport); session.configureScope(options.mode, options.gain, options.settle_s); std::cerr << "armed: ch" << options.request.trigger_channel << ' ' diff --git a/Unified_App/cli/DeviceSession.cpp b/Unified_App/cli/DeviceSession.cpp index 65e0ab97f..e58544562 100644 --- a/Unified_App/cli/DeviceSession.cpp +++ b/Unified_App/cli/DeviceSession.cpp @@ -28,11 +28,14 @@ void logToStderr(void* userdata, const int level, const char* format, va_list ar } } // namespace -DeviceSession::DeviceSession(bool verbose) : m_verbose(verbose) +DeviceSession::DeviceSession(bool verbose, UsbTransport transport) : m_verbose(verbose) { librador_logger_set(&m_verbose, logToStderr); - if (librador_init(LABRADOR_TRANSPORT_AUTO) < 0) + const int librador_transport = transport == UsbTransport::Iso6 ? LABRADOR_TRANSPORT_ISO6 + : transport == UsbTransport::Iso1 ? LABRADOR_TRANSPORT_ISO1 + : LABRADOR_TRANSPORT_AUTO; + if (librador_init(librador_transport) < 0) { librador_logger_set(nullptr, nullptr); throw CommandError("could not initialise librador"); diff --git a/Unified_App/cli/DeviceSession.h b/Unified_App/cli/DeviceSession.h index 2072fd606..ed5056d2f 100644 --- a/Unified_App/cli/DeviceSession.h +++ b/Unified_App/cli/DeviceSession.h @@ -8,6 +8,14 @@ enum class ScopeMode 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 (six isochronous endpoints on Windows/Linux) + Iso6, // six isochronous endpoints of 125 bytes per frame + Iso1 // one isochronous endpoint of 750 bytes per frame +}; + // 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`) @@ -16,7 +24,7 @@ class DeviceSession { public: // Throws CommandError if no board can be opened. - explicit DeviceSession(bool verbose); + explicit DeviceSession(bool verbose, UsbTransport transport); ~DeviceSession(); DeviceSession(const DeviceSession&) = delete; From 1feac2477b038e1b675f215ac4515f3a71ce783d Mon Sep 17 00:00:00 2001 From: Chris Esposito Date: Tue, 13 Oct 2026 16:53:17 +1100 Subject: [PATCH 07/13] cli: refuse wrong firmware, add update-firmware, default --transport 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 #467. `bulk` is selectable. - Command list column sized to the longest command name. --- Unified_App/cli/CMakeLists.txt | 10 +- Unified_App/cli/CaptureCommand.cpp | 11 +- Unified_App/cli/DeviceSession.cpp | 29 +++- Unified_App/cli/DeviceSession.h | 10 +- Unified_App/cli/UpdateFirmwareCommand.cpp | 188 ++++++++++++++++++++++ Unified_App/cli/UpdateFirmwareCommand.h | 14 ++ Unified_App/cli/main.cpp | 10 +- Unified_App/librador/librador.h | 5 + 8 files changed, 267 insertions(+), 10 deletions(-) create mode 100644 Unified_App/cli/UpdateFirmwareCommand.cpp create mode 100644 Unified_App/cli/UpdateFirmwareCommand.h diff --git a/Unified_App/cli/CMakeLists.txt b/Unified_App/cli/CMakeLists.txt index 86d5eb688..b65f390e1 100644 --- a/Unified_App/cli/CMakeLists.txt +++ b/Unified_App/cli/CMakeLists.txt @@ -4,6 +4,14 @@ add_executable(labrador_cli main.cpp CaptureCommand.cpp DeviceSession.cpp - OptionParser.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 index bbafa3ef4..460aee701 100644 --- a/Unified_App/cli/CaptureCommand.cpp +++ b/Unified_App/cli/CaptureCommand.cpp @@ -19,8 +19,7 @@ struct Options ScopeMode mode = ScopeMode::Ch1Ch2; double gain = 4; double settle_s = 0.5; - // ISO1 validates every frame; ISO6 pairs checksums approximately and reports false alarms. - UsbTransport transport = UsbTransport::Iso1; + UsbTransport transport = UsbTransport::Auto; // librador's per-platform choice bool verbose = false; librador_capture_request request; // trigger, hysteresis, pre/post, timeout }; @@ -57,7 +56,10 @@ UsbTransport parseTransport(const std::string& text) return UsbTransport::Iso6; if (text == "iso1") return UsbTransport::Iso1; - throw UsageError("option '--transport' must be auto, iso6 or iso1, got '" + text + "'"); + 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) @@ -131,7 +133,8 @@ OptionParser makeParser(Options& options) [&](const std::string& v) { options.settle_s = parseSeconds("--settle", v); }); parser.addOption("--transport", "T", - "USB transport: iso1, iso6 or auto (the platform default); default iso1", + "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; }); diff --git a/Unified_App/cli/DeviceSession.cpp b/Unified_App/cli/DeviceSession.cpp index e58544562..9b4013182 100644 --- a/Unified_App/cli/DeviceSession.cpp +++ b/Unified_App/cli/DeviceSession.cpp @@ -30,10 +30,11 @@ void logToStderr(void* userdata, const int level, const char* format, va_list ar DeviceSession::DeviceSession(bool verbose, UsbTransport transport) : m_verbose(verbose) { - librador_logger_set(&m_verbose, logToStderr); + 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) { @@ -49,6 +50,22 @@ DeviceSession::DeviceSession(bool verbose, UsbTransport transport) : m_verbose(v 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() @@ -70,3 +87,13 @@ 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 index ed5056d2f..a26f4e0b1 100644 --- a/Unified_App/cli/DeviceSession.h +++ b/Unified_App/cli/DeviceSession.h @@ -11,9 +11,10 @@ enum class ScopeMode // How USB frames reach the host; see librador's LABRADOR_TRANSPORT_* values. enum class UsbTransport { - Auto, // librador's per-platform choice (six isochronous endpoints on Windows/Linux) + 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 + 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 @@ -37,6 +38,11 @@ class DeviceSession // 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/UpdateFirmwareCommand.cpp b/Unified_App/cli/UpdateFirmwareCommand.cpp new file mode 100644 index 000000000..cb726059e --- /dev/null +++ b/Unified_App/cli/UpdateFirmwareCommand.cpp @@ -0,0 +1,188 @@ +#include "UpdateFirmwareCommand.h" + +#include "DeviceSession.h" +#include "OptionParser.h" + +#include "librador.h" + +#include + +#include +#include +#include +#include +#include + +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). +std::string bundledHexPath() +{ + const char* base = SDL_GetBasePath(); + if (base == nullptr) + throw CommandError(std::string("cannot locate the executable directory: ") + + SDL_GetError()); + return std::string(base) + "assets/firmware/" + expectedFirmwareName(); +} + +// 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); + + const std::string hex = options.hex.empty() ? bundledHexPath() : options.hex; + // Checked before any USB work so a missing image fails without touching the board. + if (!std::ifstream(hex, std::ios::binary)) + throw CommandError("cannot read firmware image '" + hex + "'" + + (options.hex.empty() ? "; pass --hex FILE" : "")); + + 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 000000000..e60a9048b --- /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 index 5e2d1de83..25c20af7b 100644 --- a/Unified_App/cli/main.cpp +++ b/Unified_App/cli/main.cpp @@ -1,6 +1,8 @@ #include "CaptureCommand.h" #include "Command.h" +#include "UpdateFirmwareCommand.h" +#include #include #include #include @@ -21,9 +23,12 @@ void printUsage(std::ostream& out, const CommandList& commands) << " " << PROGRAM << " [options]\n" << " " << PROGRAM << " help [command]\n\n" << "Commands:\n"; + size_t width = 0; for (const auto& command : commands) - out << " " << std::left << std::setw(10) << command->name() - << command->summary() << '\n'; + 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 @@ -44,6 +49,7 @@ 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()) diff --git a/Unified_App/librador/librador.h b/Unified_App/librador/librador.h index ff2b8224b..73d6a82f4 100644 --- a/Unified_App/librador/librador.h +++ b/Unified_App/librador/librador.h @@ -202,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. From dee17239340f55775bda25b5bc3329504c6cf163 Mon Sep 17 00:00:00 2001 From: Chris Esposito Date: Tue, 13 Oct 2026 16:53:17 +1100 Subject: [PATCH 08/13] CI: build labrador-cli on Linux, macOS and Windows 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. --- .github/workflows/labrador-cli.yml | 165 +++++++++++++++++++++++++++++ 1 file changed, 165 insertions(+) create mode 100644 .github/workflows/labrador-cli.yml diff --git a/.github/workflows/labrador-cli.yml b/.github/workflows/labrador-cli.yml new file mode 100644 index 000000000..28a4dba2b --- /dev/null +++ b/.github/workflows/labrador-cli.yml @@ -0,0 +1,165 @@ +# 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 + sudo apt-get -o Acquire::Retries=3 install -y \ + build-essential cmake ninja-build pkg-config libusb-1.0-0-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 From 078321c789c0d9263b01bdd671b17c7ef0984213 Mon Sep 17 00:00:00 2001 From: Chris Esposito Date: Tue, 13 Oct 2026 16:55:17 +1100 Subject: [PATCH 09/13] CI: labrador-cli Linux job installs SDL's X11/Wayland dev packages 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. --- .github/workflows/labrador-cli.yml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.github/workflows/labrador-cli.yml b/.github/workflows/labrador-cli.yml index 28a4dba2b..3991522bd 100644 --- a/.github/workflows/labrador-cli.yml +++ b/.github/workflows/labrador-cli.yml @@ -39,8 +39,14 @@ jobs: - 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 + 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: | From 6daecb9bc1b5f6d869ab04774b7e60acf5aba115 Mon Sep 17 00:00:00 2001 From: Chris Esposito Date: Thu, 15 Oct 2026 16:19:41 +1100 Subject: [PATCH 10/13] cli: update-firmware finds the bundled hex when run through a symlink 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. --- Unified_App/cli/UpdateFirmwareCommand.cpp | 52 +++++++++++++++++++---- 1 file changed, 43 insertions(+), 9 deletions(-) diff --git a/Unified_App/cli/UpdateFirmwareCommand.cpp b/Unified_App/cli/UpdateFirmwareCommand.cpp index cb726059e..c19c11406 100644 --- a/Unified_App/cli/UpdateFirmwareCommand.cpp +++ b/Unified_App/cli/UpdateFirmwareCommand.cpp @@ -12,6 +12,12 @@ #include #include #include +#include + +#ifdef __APPLE__ +#include +#include +#endif namespace { @@ -64,14 +70,42 @@ OptionParser makeParser(Options& options) } // The same place the Labrador app keeps its firmware: assets/firmware next to -// the executable (Contents/Resources inside a macOS bundle). +// 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 char* base = SDL_GetBasePath(); - if (base == nullptr) - throw CommandError(std::string("cannot locate the executable directory: ") + - SDL_GetError()); - return std::string(base) + "assets/firmware/" + expectedFirmwareName(); + 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. @@ -128,11 +162,11 @@ 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; - // Checked before any USB work so a missing image fails without touching the board. if (!std::ifstream(hex, std::ios::binary)) - throw CommandError("cannot read firmware image '" + hex + "'" + - (options.hex.empty() ? "; pass --hex FILE" : "")); + throw CommandError("cannot read firmware image '" + hex + "'"); LibradorScope librador(&options.verbose); From 68375f9f36386cdfdb821cd5ab6e8528d02370ce Mon Sep 17 00:00:00 2001 From: Chris Esposito Date: Thu, 15 Oct 2026 16:19:41 +1100 Subject: [PATCH 11/13] packaging: ship labrador-cli in the Windows installer, macOS bundle, .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 ' 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. --- .github/workflows/linux.yml | 94 +++++++++++++++---- .github/workflows/mac.yml | 68 +++++++++++--- .github/workflows/windows.yml | 70 ++++++++++---- .../linux/apprun-hooks/zz-labrador-cli.sh | 25 +++++ Unified_App/packaging/windows/labrador.iss | 19 +++- 5 files changed, 220 insertions(+), 56 deletions(-) create mode 100755 Unified_App/packaging/linux/apprun-hooks/zz-labrador-cli.sh diff --git a/.github/workflows/linux.yml b/.github/workflows/linux.yml index caeadc01b..410815971 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_${{ 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_${{ matrix.name }}.deb" dpkg-deb --info "Labrador_${{ 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 7151cb83c..de41231b9 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 728f58ebb..5ce9b119c 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 @@ -67,19 +68,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: @@ -106,7 +112,7 @@ jobs: mingw-w64-x86_64-pkgconf mingw-w64-x86_64-libusb - - name: Download x86 exe + - name: Download x86 exes uses: actions/download-artifact@v4 with: name: win32-exe @@ -122,14 +128,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: | @@ -140,6 +148,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" @@ -158,6 +170,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/packaging/linux/apprun-hooks/zz-labrador-cli.sh b/Unified_App/packaging/linux/apprun-hooks/zz-labrador-cli.sh new file mode 100755 index 000000000..e85fa5654 --- /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_x86_64.AppImage cli [options] +# +# or through a symlink (or copy) named labrador-cli, e.g. +# +# ln -s ~/Labrador_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 93ee56dd8..c84674aa2 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 From dd61de43191b0aa66b1d1a9afe161921892f6a3e Mon Sep 17 00:00:00 2001 From: Chris Esposito Date: Thu, 15 Oct 2026 16:19:41 +1100 Subject: [PATCH 12/13] docs: labrador-cli usage, install locations and examples 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. --- .github/workflows/continuous.yml | 18 ++- Unified_App/README.md | 17 +++ Unified_App/cli/README.md | 200 +++++++++++++++++++++++++++++++ 3 files changed, 232 insertions(+), 3 deletions(-) create mode 100644 Unified_App/cli/README.md diff --git a/.github/workflows/continuous.yml b/.github/workflows/continuous.yml index b6c8f94e0..41ddcacdc 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. @@ -93,7 +96,9 @@ jobs: - `Labrador_x86_64.AppImage` / `Labrador_arm64.AppImage` — `chmod +x` the file, then run it. Installs the udev rules on first launch. + `./Labrador_.AppImage cli ...` runs the command-line tool. - `Labrador_x86_64.deb` / `Labrador_arm64.deb` — `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+). @@ -101,7 +106,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/Unified_App/README.md b/Unified_App/README.md index cb76b31f7..7e6372c2e 100644 --- a/Unified_App/README.md +++ b/Unified_App/README.md @@ -73,6 +73,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 @@ -121,6 +137,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/README.md b/Unified_App/cli/README.md new file mode 100644 index 000000000..bed9e4882 --- /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_x86_64.AppImage cli [options]`. A symlink named `labrador-cli` to the AppImage also works: `ln -s ~/Labrador_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: ...`. From 40d774dec581de39b8aa5c63c8b18a347589d7a7 Mon Sep 17 00:00:00 2001 From: Chris Esposito Date: Mon, 19 Oct 2026 15:16:42 +1100 Subject: [PATCH 13/13] docs: use Labrador-for-Linux- AppImage name in CLI README and AppRun hook --- Unified_App/cli/README.md | 2 +- Unified_App/packaging/linux/apprun-hooks/zz-labrador-cli.sh | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Unified_App/cli/README.md b/Unified_App/cli/README.md index bed9e4882..13bd60a62 100644 --- a/Unified_App/cli/README.md +++ b/Unified_App/cli/README.md @@ -24,7 +24,7 @@ the CLI, and vice versa. | 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_x86_64.AppImage cli [options]`. A symlink named `labrador-cli` to the AppImage also works: `ln -s ~/Labrador_x86_64.AppImage ~/.local/bin/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 diff --git a/Unified_App/packaging/linux/apprun-hooks/zz-labrador-cli.sh b/Unified_App/packaging/linux/apprun-hooks/zz-labrador-cli.sh index e85fa5654..e3e89191c 100755 --- a/Unified_App/packaging/linux/apprun-hooks/zz-labrador-cli.sh +++ b/Unified_App/packaging/linux/apprun-hooks/zz-labrador-cli.sh @@ -2,11 +2,11 @@ # AppImage AppRun hook: run the headless labrador-cli instead of the GUI when # the AppImage is started as # -# ./Labrador_x86_64.AppImage cli [options] +# ./Labrador-for-Linux-x86_64.AppImage cli [options] # # or through a symlink (or copy) named labrador-cli, e.g. # -# ln -s ~/Labrador_x86_64.AppImage ~/.local/bin/labrador-cli +# 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