From 06b37e671e977a6640d1d8d0d24dcbaf1206c973 Mon Sep 17 00:00:00 2001 From: Cameron Brooks <95648474+CameronBrooks11@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:11:29 -0400 Subject: [PATCH 1/2] feat: add the e-stop, watchdog, status LED and parsing modules The e-stop sampling and command watchdog are the logic Slice_DCMT and Slice_RLHT share by copy, minus each family's safe-state policy: the core owns the state interrupt handlers read, sets it with interrupts off, and returns an edge for the Slice to act on. bootWait() restamps the watchdog when the wait ends, so a boot timeout under 1 s no longer trips on the first loop pass. The status LED driver is the one benched in feastorg/Slice_DCMT#55. parseLong() and parseFloat() accept only a whole decimal token in range. Each module has host tests. A sweep of 18 mutations, one per checked behaviour, turned its target test red every time. The example builds for nanoatmega328new and nano_every with no library warnings. --- CHANGELOG.md | 15 +++ README.md | 70 +++++++++- examples/slice_skeleton/slice_skeleton.ino | 60 +++++++++ library.json | 1 + src/bread_slice_core.h | 11 ++ src/bsc_atomic.h | 14 ++ src/bsc_estop.cpp | 72 ++++++++++ src/bsc_estop.h | 67 +++++++++ src/bsc_parse.cpp | 59 ++++++++ src/bsc_parse.h | 16 +++ src/bsc_status_led.h | 131 ++++++++++++++++++ src/bsc_watchdog.cpp | 64 +++++++++ src/bsc_watchdog.h | 51 +++++++ tests/CMakeLists.txt | 22 +++ tests/check.h | 22 +++ tests/fakes/Arduino.cpp | 6 + tests/fakes/Arduino.h | 30 +++++ tests/test_estop.cpp | 150 +++++++++++++++++++++ tests/test_parse.cpp | 57 ++++++++ tests/test_status_led.cpp | 36 +++++ tests/test_watchdog.cpp | 90 +++++++++++++ 21 files changed, 1037 insertions(+), 7 deletions(-) create mode 100644 examples/slice_skeleton/slice_skeleton.ino create mode 100644 src/bread_slice_core.h create mode 100644 src/bsc_atomic.h create mode 100644 src/bsc_estop.cpp create mode 100644 src/bsc_estop.h create mode 100644 src/bsc_parse.cpp create mode 100644 src/bsc_parse.h create mode 100644 src/bsc_status_led.h create mode 100644 src/bsc_watchdog.cpp create mode 100644 src/bsc_watchdog.h create mode 100644 tests/CMakeLists.txt create mode 100644 tests/check.h create mode 100644 tests/fakes/Arduino.cpp create mode 100644 tests/fakes/Arduino.h create mode 100644 tests/test_estop.cpp create mode 100644 tests/test_parse.cpp create mode 100644 tests/test_status_led.cpp create mode 100644 tests/test_watchdog.cpp diff --git a/CHANGELOG.md b/CHANGELOG.md index 018d270..d7b97d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,3 +8,18 @@ Format: [Keep a Changelog](https://keepachangelog.com/). Versions follow ### Added - Repository skeleton: README, scope rules (linked to the BREADS spec), MPL-2.0 licence, and the PlatformIO manifest. +- `bsc::EStop`: e-stop sampling with assert on one active sample, a + 25 ms release debounce, a press count, and `bsc::bootWait()`, which samples + through the boot wait and restamps the command watchdog when it ends. + Taken from Slice_DCMT and Slice_RLHT, which share the same logic; the + restamp is new. +- `bsc::CommandWatchdog`: the timer and trip latch from both Slices. The + timeout comparison now runs in the same interrupt-masked window as the + latch. +- `bsc::StatusLed`: the single-pixel WS2812B driver from + feastorg/Slice_DCMT#55, benched on a gen2 Nano and Nano Every on + 2026-10-20. +- `bsc::parseLong()` and `bsc::parseFloat()`: strict whole-token parsing for + serial consoles. +- Host tests for every module, and CI that runs them and builds the example + for the Nano and the Nano Every. diff --git a/README.md b/README.md index 2fcedcb..782d2c3 100644 --- a/README.md +++ b/README.md @@ -3,15 +3,71 @@ Shared firmware mechanisms for BREADS Slices: the code every Slice runs the same way, written once and host-tested. -Nothing is released yet. v0.1.0 is planned to hold: +Nothing is released yet. Progress is tracked in +[feastorg/.github#4](https://github.com/feastorg/.github/issues/4). -- the status LED driver (one WS2812B on D5, ATmega328P and ATmega4809 at 16 MHz); -- e-stop sampling, release debounce, press count and the boot wait; -- the command-watchdog timer and trip latch, with a per-family trip hook; -- strict number parsing for serial consoles. +## Use -Progress is tracked in -[feastorg/.github#4](https://github.com/feastorg/.github/issues/4). +```cpp +#include // e-stop, command watchdog, parsing +#include // only on a board with the status LED +``` + +[`examples/slice_skeleton`](examples/slice_skeleton/slice_skeleton.ino) shows +the calls in the order a Slice makes them. + +### E-stop (`bsc::EStop`) + +`EStop(pin, activeLow = true, releaseDebounceMs = 25)`. `begin()` sets the +pin's mode: the internal pull-up when `activeLow`. + +`poll(millis())` once per main-loop pass returns `None`, `Pressed` or +`Released`. One active sample is a press. A release needs the line inactive +for the debounce time without a break, and always at least two samples. + +`asserted()` and `count()` are what interrupt handlers read. `poll()` sets +them, with interrupts off, before it returns `Pressed`, so commands are +already refused while the Slice drives its outputs safe. The count wraps at +255. + +`bsc::bootWait(estop, 1000, onEvent, &watchdog)` samples the line for the +boot wait, passes each edge to `onEvent`, and restamps the watchdog at the end. + +### Command watchdog (`bsc::CommandWatchdog`) + +- `arm(timeoutMs, millis())` sets the timeout and restarts the timer; 0 + disarms. +- `stamp(millis())` records bus traffic. +- `check(millis())` from the main loop returns true once, on the pass that + trips. It sets the latch, with interrupts off, before returning. +- `clear()` lifts the latch and nothing else. + +Arming, disarming and re-arming leave a trip set. `arm()`, `stamp()` and +`clear()` are safe from an ISR. `tripped()`, `timeoutMs()`, `armed()` and +`tripCount()` (wraps at 255) report its state. + +### Status LED (`bsc::StatusLed`) + +One WS2812B on D5, on an ATmega328P or ATmega4809 at 16 MHz; anything else +fails to build. `begin()`, then `set(Boot)` (blue), `set(Running)` (green) or +`set(EStop)` (red). A frame goes out only when the state changes; interrupts +are off for about 32 us while it does. + +### Strict parsing + +`bsc::parseLong(s, lo, hi, &out)` and `bsc::parseFloat(s, lo, hi, &out)` +accept a whole base-10 token, finite and in `[lo, hi]`. Anything else, such as +`"12x"`, `" 1"`, `"0x10"` or `"nan"`, returns false and leaves `out` unchanged. + +## Test + +```sh +cmake -S tests -B build/tests +cmake --build build/tests +ctest --test-dir build/tests --output-on-failure +``` + +CI runs these and builds the examples for the Nano and the Nano Every. ## Scope diff --git a/examples/slice_skeleton/slice_skeleton.ino b/examples/slice_skeleton/slice_skeleton.ino new file mode 100644 index 0000000..0b17665 --- /dev/null +++ b/examples/slice_skeleton/slice_skeleton.ino @@ -0,0 +1,60 @@ +// The core's calls in the order a Slice makes them. The safe-state actions +// are placeholders: what a stop or a trip does is each Slice's own. +#include +#include +#include + +static bsc::EStop estop(2); +static bsc::CommandWatchdog watchdog; +static bsc::StatusLed led; + +static void driveOutputsSafe() {} + +static void onEStop(bsc::EStop::Event e) +{ + if (e == bsc::EStop::Pressed) + driveOutputsSafe(); + // Released: nothing resumes until the controller sends new commands. +} + +void setup() +{ + wdt_disable(); + estop.begin(); + led.begin(); + led.set(bsc::StatusLed::Boot); + Serial.begin(115200); + // A bus receive handler would call watchdog.stamp(millis()) per frame, + // and SET_WATCHDOG would call watchdog.arm(ms, millis()). + bsc::bootWait(estop, 1000, onEStop, &watchdog); + wdt_enable(WDTO_1S); +} + +void loop() +{ + wdt_reset(); + onEStop(estop.poll(millis())); + led.set(estop.asserted() ? bsc::StatusLed::EStop : bsc::StatusLed::Running); + if (watchdog.check(millis())) + driveOutputsSafe(); + + static char line[16]; + static uint8_t n; + while (Serial.available()) + { + const char c = (char)Serial.read(); + if (c != '\n' && n < sizeof line - 1) + { + line[n++] = c; + continue; + } + line[n] = '\0'; + n = 0; + long ms; + if (bsc::parseLong(line, 0, 65535, &ms)) + watchdog.arm((uint16_t)ms, millis()); + else + Serial.println(F("Ignored: not a number in 0..65535")); + break; // one line per loop pass + } +} diff --git a/library.json b/library.json index d036479..59fa7d3 100644 --- a/library.json +++ b/library.json @@ -22,6 +22,7 @@ "export": { "include": [ "src", + "examples", "README.md", "CHANGELOG.md", "LICENSE" diff --git a/src/bread_slice_core.h b/src/bread_slice_core.h new file mode 100644 index 0000000..4cf5868 --- /dev/null +++ b/src/bread_slice_core.h @@ -0,0 +1,11 @@ +// bread-slice-core: the e-stop input, command watchdog and strict parsing. +// The status LED is a separate header, bsc_status_led.h, since only boards +// with the LED may include it. +#ifndef BREAD_SLICE_CORE_H +#define BREAD_SLICE_CORE_H + +#include "bsc_estop.h" +#include "bsc_parse.h" +#include "bsc_watchdog.h" + +#endif diff --git a/src/bsc_atomic.h b/src/bsc_atomic.h new file mode 100644 index 0000000..625625e --- /dev/null +++ b/src/bsc_atomic.h @@ -0,0 +1,14 @@ +// Internal: a block that runs with interrupts off and restores the previous +// state, so a call is safe both from the main loop and from an ISR. +#ifndef BSC_ATOMIC_H +#define BSC_ATOMIC_H + +#if defined(__AVR__) +#include +#define BSC_ATOMIC ATOMIC_BLOCK(ATOMIC_RESTORESTATE) +#else +// Host builds run single-threaded tests. +#define BSC_ATOMIC for (int bsc_once_ = 1; bsc_once_; bsc_once_ = 0) +#endif + +#endif diff --git a/src/bsc_estop.cpp b/src/bsc_estop.cpp new file mode 100644 index 0000000..758af93 --- /dev/null +++ b/src/bsc_estop.cpp @@ -0,0 +1,72 @@ +#include "bsc_estop.h" + +#include + +#include "bsc_atomic.h" +#include "bsc_watchdog.h" + +namespace bsc +{ + +EStop::EStop(uint8_t pin, bool activeLow, uint16_t releaseDebounceMs) + : pin_(pin), activeLow_(activeLow), debounceMs_(releaseDebounceMs) +{ +} + +void EStop::begin() +{ + pinMode(pin_, activeLow_ ? INPUT_PULLUP : INPUT); +} + +EStop::Event EStop::poll(unsigned long nowMs) +{ + const bool low = digitalRead(pin_) == LOW; + return update(activeLow_ ? low : !low, nowMs); +} + +EStop::Event EStop::update(bool active, unsigned long nowMs) +{ + if (active) + { + releasePending_ = false; + if (asserted_) + return None; + BSC_ATOMIC + { + asserted_ = true; + count_++; + } + return Pressed; + } + if (!asserted_) + return None; + if (!releasePending_) + { + releasePending_ = true; + releaseStartMs_ = nowMs; + return None; + } + if ((uint32_t)(nowMs - releaseStartMs_) < debounceMs_) + return None; + releasePending_ = false; + asserted_ = false; + return Released; +} + +void bootWait(EStop &estop, uint16_t durationMs, void (*onEvent)(EStop::Event), + CommandWatchdog *watchdog) +{ + const unsigned long startMs = millis(); + unsigned long nowMs = startMs; + while ((uint32_t)(nowMs - startMs) < durationMs) + { + const EStop::Event e = estop.poll(nowMs); + if (e != EStop::None && onEvent != nullptr) + onEvent(e); + nowMs = millis(); + } + if (watchdog != nullptr) + watchdog->stamp(millis()); +} + +} // namespace bsc diff --git a/src/bsc_estop.h b/src/bsc_estop.h new file mode 100644 index 0000000..de5414a --- /dev/null +++ b/src/bsc_estop.h @@ -0,0 +1,67 @@ +// E-stop input: sampled from the main loop, never by interrupt. +// +// One active sample asserts the stop. Release needs the line inactive +// continuously for the debounce time: the first inactive sample starts the +// timer and any active sample cancels it. The count goes up once per stop. +// +// asserted() and count() are what interrupt handlers read. poll() sets them, +// with interrupts off, before it returns Pressed, so a command that arrives +// while the Slice is still driving its outputs safe is already refused. +// What a stop does is the Slice's: it acts on the returned event. +#ifndef BSC_ESTOP_H +#define BSC_ESTOP_H + +#include + +namespace bsc +{ + +class EStop +{ +public: + enum Event : uint8_t + { + None, + Pressed, + Released, + }; + + // activeLow: the input uses the internal pull-up and LOW is a stop, as on + // the BREADS Slices today. + explicit EStop(uint8_t pin, bool activeLow = true, uint16_t releaseDebounceMs = 25); + + // Configures the pin. Call before the first poll(). + void begin(); + + // Reads the pin once and returns the edge, if any. + Event poll(unsigned long nowMs); + + // As poll(), with the line's state supplied: true is a stop. + Event update(bool active, unsigned long nowMs); + + bool asserted() const { return asserted_; } + // Wraps at 255. + uint8_t count() const { return count_; } + +private: + uint8_t pin_; + bool activeLow_; + uint16_t debounceMs_; + bool releasePending_ = false; + uint32_t releaseStartMs_ = 0; + volatile bool asserted_ = false; + volatile uint8_t count_ = 0; +}; + +class CommandWatchdog; + +// The boot wait: samples the e-stop continuously for durationMs, so a line +// held at power-up is asserted before the main loop runs, and passes every +// edge to onEvent. Then restamps the watchdog, if one is given, so a boot +// timeout shorter than the wait does not trip on the first loop pass. +void bootWait(EStop &estop, uint16_t durationMs, void (*onEvent)(EStop::Event), + CommandWatchdog *watchdog = nullptr); + +} // namespace bsc + +#endif diff --git a/src/bsc_parse.cpp b/src/bsc_parse.cpp new file mode 100644 index 0000000..f27c004 --- /dev/null +++ b/src/bsc_parse.cpp @@ -0,0 +1,59 @@ +#include "bsc_parse.h" + +#include +#include +#include + +namespace bsc +{ + +// strtol and strtod skip leading whitespace; a token must not have any. +// strtod also takes hex floats on some C libraries but not avr-libc, so a +// token is limited to the characters of a decimal number. "inf" and "nan" +// fail on their letters. +static bool decimalToken(const char *s, bool allowPoint) +{ + if (s == nullptr || *s == '\0') + return false; + for (const char *p = s; *p != '\0'; p++) + { + const char c = *p; + if ((c >= '0' && c <= '9') || c == '-' || c == '+') + continue; + if (allowPoint && (c == '.' || c == 'e' || c == 'E')) + continue; + return false; + } + return true; +} + +bool parseLong(const char *s, long lo, long hi, long *out) +{ + if (!decimalToken(s, false)) + return false; + char *end; + errno = 0; + const long v = strtol(s, &end, 10); + if (end == s || *end != '\0' || errno == ERANGE || v < lo || v > hi) + return false; + *out = v; + return true; +} + +bool parseFloat(const char *s, float lo, float hi, float *out) +{ + if (!decimalToken(s, true)) + return false; + char *end; + errno = 0; + const double d = strtod(s, &end); + if (end == s || *end != '\0' || errno == ERANGE || !isfinite(d)) + return false; + const float v = (float)d; + if (!isfinite(v) || v < lo || v > hi) + return false; + *out = v; + return true; +} + +} // namespace bsc diff --git a/src/bsc_parse.h b/src/bsc_parse.h new file mode 100644 index 0000000..4d2503b --- /dev/null +++ b/src/bsc_parse.h @@ -0,0 +1,16 @@ +// Strict number parsing for serial consoles. The whole token must parse as a +// base-10 number, with no leading or trailing characters, and the value must +// be finite and in [lo, hi]. On failure the functions return false and leave +// *out unchanged, so a typo changes nothing. +#ifndef BSC_PARSE_H +#define BSC_PARSE_H + +namespace bsc +{ + +bool parseLong(const char *s, long lo, long hi, long *out); +bool parseFloat(const char *s, float lo, float hi, float *out); + +} // namespace bsc + +#endif diff --git a/src/bsc_status_led.h b/src/bsc_status_led.h new file mode 100644 index 0000000..9a3126a --- /dev/null +++ b/src/bsc_status_led.h @@ -0,0 +1,131 @@ +// The BREADS status LED: one WS2812B on D5, GRB order, 800 kHz. Include this +// header only on a board that has the LED: it drives D5 as an output, and on +// some boards D5 is another signal (Slice_DCMT gen1 uses it for motor 1's +// direction). +// +// The bit loop is cycle-counted at 16 MHz. Per bit: 20 cycles (1.25 us), +// high for 5 cycles (312 ns) for a 0 and 12 cycles (750 ns) for a 1, inside +// the WS2812B windows of 250-550 ns and 650-950 ns. out, nop, sbrs, lsl, dec +// and brne take the same cycles on the ATmega328P (AVRe) and the ATmega4809 +// (AVRxt), so one loop serves both; st would not, which is why the port is +// written with out. +// +// Interrupts are off for a frame, about 32 us. StatusLed writes only when the +// state changes. +#ifndef BSC_STATUS_LED_H +#define BSC_STATUS_LED_H + +#include + +#if defined(BSC_HOST_TEST) +// Host tests: frames are recorded instead of sent. +inline uint8_t bsc_host_led_frame[3]; +inline int bsc_host_led_writes; +#else +#include + +#if F_CPU != 16000000L +#error "bsc_status_led.h is cycle-counted for 16 MHz" +#endif + +#if defined(__AVR_ATmega4809__) +#define BSC_STATUS_LED_OUT VPORTB_OUT // D5 is PB2 on the Nano Every +#define BSC_STATUS_LED_DIR VPORTB_DIR +#define BSC_STATUS_LED_BIT 2 +#elif defined(__AVR_ATmega328P__) +#define BSC_STATUS_LED_OUT PORTD // D5 is PD5 on the Nano +#define BSC_STATUS_LED_DIR DDRD +#define BSC_STATUS_LED_BIT 5 +#else +#error "bsc_status_led.h supports the ATmega328P and ATmega4809 only" +#endif +#endif + +namespace bsc +{ + +class StatusLed +{ +public: + enum State : uint8_t + { + Boot, // blue, through the boot wait + Running, // green + EStop, // red, while the e-stop is held + }; + + // Drives D5 low as an output. + void begin() + { +#if !defined(BSC_HOST_TEST) + BSC_STATUS_LED_OUT &= (uint8_t)~_BV(BSC_STATUS_LED_BIT); + BSC_STATUS_LED_DIR |= _BV(BSC_STATUS_LED_BIT); +#endif + } + + // Shows the state's colour, if it differs from the last one shown. + void set(State s) + { + if (s == last_) + return; + last_ = s; + switch (s) + { + case Boot: + show(0, 0, 50); + break; + case Running: + show(0, 25, 0); + break; + case EStop: + show(50, 0, 0); + break; + } + } + + // Sends one colour, unconditionally. + static void show(uint8_t r, uint8_t g, uint8_t b) + { + const uint8_t frame[3] = {g, r, b}; +#if defined(BSC_HOST_TEST) + for (uint8_t i = 0; i < 3; i++) + bsc_host_led_frame[i] = frame[i]; + bsc_host_led_writes++; +#else + const uint8_t sreg = SREG; + cli(); + // Read the port with interrupts off, so the frame rewrites the other + // pins on it with the values they already hold. + const uint8_t hi = BSC_STATUS_LED_OUT | _BV(BSC_STATUS_LED_BIT); + const uint8_t lo = BSC_STATUS_LED_OUT & (uint8_t)~_BV(BSC_STATUS_LED_BIT); + for (uint8_t i = 0; i < 3; i++) + { + uint8_t v = frame[i]; + uint8_t n; + asm volatile( + "ldi %[n], 8\n" + "1:\n\t" + "out %[port], %[hi]\n\t" // 0: high + "nop\n\tnop\n\tnop\n\t" + "sbrs %[v], 7\n\t" + "out %[port], %[lo]\n\t" // 5: low for a 0 bit + "lsl %[v]\n\t" + "nop\n\tnop\n\tnop\n\tnop\n\tnop\n\t" + "out %[port], %[lo]\n\t" // 12: low for a 1 bit + "nop\n\tnop\n\tnop\n\tnop\n\t" + "dec %[n]\n\t" + "brne 1b\n\t" // 20: next bit + : [v] "+r"(v), [n] "=&d"(n) + : [port] "I"(_SFR_IO_ADDR(BSC_STATUS_LED_OUT)), [hi] "r"(hi), [lo] "r"(lo)); + } + SREG = sreg; +#endif + } + +private: + uint8_t last_ = 0xFF; +}; + +} // namespace bsc + +#endif diff --git a/src/bsc_watchdog.cpp b/src/bsc_watchdog.cpp new file mode 100644 index 0000000..7137139 --- /dev/null +++ b/src/bsc_watchdog.cpp @@ -0,0 +1,64 @@ +#include "bsc_watchdog.h" + +#include "bsc_atomic.h" + +namespace bsc +{ + +void CommandWatchdog::arm(uint16_t timeoutMs, unsigned long nowMs) +{ + BSC_ATOMIC + { + timeoutMs_ = timeoutMs; + lastStampMs_ = nowMs; + } +} + +void CommandWatchdog::stamp(unsigned long nowMs) +{ + BSC_ATOMIC + { + lastStampMs_ = nowMs; + } +} + +bool CommandWatchdog::check(unsigned long nowMs) +{ + bool trip = false; + // The comparison runs in the same window as the latch, so a stamp from + // an ISR can't land between the two. + BSC_ATOMIC + { + // Elapsed time in 32 bits, the width of millis() on AVR, so it stays + // right across the 49-day wrap on any host. + if (timeoutMs_ != 0 && !tripped_ && (uint32_t)(nowMs - lastStampMs_) >= timeoutMs_) + { + tripped_ = true; + tripCount_++; + trip = true; + } + } + return trip; +} + +void CommandWatchdog::clear() +{ + tripped_ = false; +} + +bool CommandWatchdog::armed() const +{ + return timeoutMs() != 0; +} + +uint16_t CommandWatchdog::timeoutMs() const +{ + uint16_t t; + BSC_ATOMIC + { + t = timeoutMs_; + } + return t; +} + +} // namespace bsc diff --git a/src/bsc_watchdog.h b/src/bsc_watchdog.h new file mode 100644 index 0000000..9d5e940 --- /dev/null +++ b/src/bsc_watchdog.h @@ -0,0 +1,51 @@ +// Command watchdog: a timer the controller arms, refreshed by bus traffic, +// that trips when the bus goes silent. A trip latches until clear(); arming, +// disarming and re-arming leave it set, and clearing resumes nothing. +// +// What refreshes it is defined in bread-crumbs-contracts (bread_watchdog.h), +// and what a trip does is the Slice's: it acts when check() returns true. +// +// arm(), stamp() and clear() are safe from an ISR and from the main loop. +// check() is for the main loop. It sets the latch, with interrupts off, +// before it returns true, so a command arriving while the Slice drives its +// outputs safe is already refused. +#ifndef BSC_WATCHDOG_H +#define BSC_WATCHDOG_H + +#include + +namespace bsc +{ + +class CommandWatchdog +{ +public: + // Sets the timeout and restarts the timer. 0 disarms. Does not clear a + // trip. + void arm(uint16_t timeoutMs, unsigned long nowMs); + + // Records bus traffic. + void stamp(unsigned long nowMs); + + // True once, on the pass that trips: armed, not already tripped, and at + // least the timeout since the last stamp. + bool check(unsigned long nowMs); + + void clear(); + + bool armed() const; + uint16_t timeoutMs() const; + bool tripped() const { return tripped_; } + // Wraps at 255. + uint8_t tripCount() const { return tripCount_; } + +private: + volatile uint16_t timeoutMs_ = 0; + volatile uint32_t lastStampMs_ = 0; + volatile bool tripped_ = false; + volatile uint8_t tripCount_ = 0; +}; + +} // namespace bsc + +#endif diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt new file mode 100644 index 0000000..f5a5ee1 --- /dev/null +++ b/tests/CMakeLists.txt @@ -0,0 +1,22 @@ +cmake_minimum_required(VERSION 3.16) +project(BreadSliceCoreTests CXX) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) + +add_library(bread_slice_core STATIC + ../src/bsc_estop.cpp + ../src/bsc_parse.cpp + ../src/bsc_watchdog.cpp + fakes/Arduino.cpp) +target_include_directories(bread_slice_core PUBLIC ../src fakes) +target_compile_definitions(bread_slice_core PUBLIC BSC_HOST_TEST) +target_compile_options(bread_slice_core PRIVATE -Wall -Wextra -Werror) + +enable_testing() +foreach(name test_estop test_watchdog test_parse test_status_led) + add_executable(${name} ${name}.cpp) + target_link_libraries(${name} PRIVATE bread_slice_core) + target_compile_options(${name} PRIVATE -Wall -Wextra) + add_test(NAME ${name} COMMAND ${name}) +endforeach() diff --git a/tests/check.h b/tests/check.h new file mode 100644 index 0000000..0e93878 --- /dev/null +++ b/tests/check.h @@ -0,0 +1,22 @@ +// CHECK() prints the failing expression and counts it; main() returns the +// count's status. +#ifndef CHECK_H +#define CHECK_H + +#include + +static int check_failures = 0; + +#define CHECK(cond) \ + do \ + { \ + if (!(cond)) \ + { \ + fprintf(stderr, "%s:%d: CHECK(%s) failed\n", __FILE__, __LINE__, #cond); \ + check_failures++; \ + } \ + } while (0) + +#define CHECK_DONE() return check_failures ? 1 : 0 + +#endif diff --git a/tests/fakes/Arduino.cpp b/tests/fakes/Arduino.cpp new file mode 100644 index 0000000..7fd6cbb --- /dev/null +++ b/tests/fakes/Arduino.cpp @@ -0,0 +1,6 @@ +#include "Arduino.h" + +int fake_level = HIGH; +int fake_pin_mode = -1; +unsigned long fake_now = 0; +unsigned long fake_step = 0; diff --git a/tests/fakes/Arduino.h b/tests/fakes/Arduino.h new file mode 100644 index 0000000..80c65ad --- /dev/null +++ b/tests/fakes/Arduino.h @@ -0,0 +1,30 @@ +// Host stand-in for the parts of Arduino.h the library uses. +#ifndef FAKE_ARDUINO_H +#define FAKE_ARDUINO_H + +#include + +#define LOW 0 +#define HIGH 1 +#define INPUT 0 +#define INPUT_PULLUP 2 + +// The level digitalRead() returns for every pin, and the last pinMode(). +extern int fake_level; +extern int fake_pin_mode; + +// millis() returns fake_now, then adds fake_step, so a loop that polls the +// clock advances it. +extern unsigned long fake_now; +extern unsigned long fake_step; + +inline unsigned long millis() +{ + const unsigned long t = fake_now; + fake_now += fake_step; + return t; +} +inline int digitalRead(uint8_t) { return fake_level; } +inline void pinMode(uint8_t, uint8_t mode) { fake_pin_mode = mode; } + +#endif diff --git a/tests/test_estop.cpp b/tests/test_estop.cpp new file mode 100644 index 0000000..54273a9 --- /dev/null +++ b/tests/test_estop.cpp @@ -0,0 +1,150 @@ +// E-stop sampling: assert on one active sample, release after the debounce +// time inactive without a break, one count per stop, and the boot wait. +#include +#include + +#include "check.h" + +using bsc::EStop; + +static int events[8]; +static int eventCount; +static void record(EStop::Event e) +{ + if (eventCount < 8) + events[eventCount++] = e; +} + +int main() +{ + // begin(): pull-up for active-low, plain input otherwise. + { + EStop e(2); + e.begin(); + CHECK(fake_pin_mode == INPUT_PULLUP); + EStop h(2, false); + h.begin(); + CHECK(fake_pin_mode == INPUT); + } + + // One active sample asserts and counts. + { + EStop e(2); + CHECK(e.update(true, 0) == EStop::Pressed); + CHECK(e.asserted()); + CHECK(e.count() == 1); + CHECK(e.update(true, 1) == EStop::None); + CHECK(e.count() == 1); + } + + // Release: the first inactive sample starts the timer; 25 ms later it + // releases. Nothing resumes the count. + { + EStop e(2); + e.update(true, 0); + CHECK(e.update(false, 100) == EStop::None); + CHECK(e.update(false, 124) == EStop::None); + CHECK(e.asserted()); + CHECK(e.update(false, 125) == EStop::Released); + CHECK(!e.asserted()); + CHECK(e.count() == 1); + } + + // An active sample during the debounce restarts it, without a new count. + { + EStop e(2); + e.update(true, 0); + e.update(false, 10); + CHECK(e.update(true, 20) == EStop::None); + CHECK(e.update(false, 30) == EStop::None); + CHECK(e.update(false, 50) == EStop::None); + CHECK(e.update(false, 55) == EStop::Released); + CHECK(e.count() == 1); + CHECK(e.update(true, 60) == EStop::Pressed); + CHECK(e.count() == 2); + } + + // The debounce time is configurable. + { + EStop e(2, true, 5); + e.update(true, 0); + e.update(false, 10); + CHECK(e.update(false, 15) == EStop::Released); + } + + // Release always takes two inactive samples, even with no debounce time. + { + EStop e(2, true, 0); + e.update(true, 0); + CHECK(e.update(false, 0) == EStop::None); + CHECK(e.update(false, 0) == EStop::Released); + } + + // The debounce runs across the millis() wrap. + { + EStop e(2); + e.update(true, 0xFFFFFFF0UL); + e.update(false, 0xFFFFFFF0UL); + CHECK(e.update(false, 0x00000008UL) == EStop::None); // 24 ms later + CHECK(e.update(false, 0x00000009UL) == EStop::Released); // 25 ms later + } + + // poll() reads the pin: LOW is a stop when active-low, HIGH otherwise. + { + EStop e(2); + fake_level = HIGH; + CHECK(e.poll(0) == EStop::None); + fake_level = LOW; + CHECK(e.poll(1) == EStop::Pressed); + EStop h(2, false); + fake_level = LOW; + CHECK(h.poll(0) == EStop::None); + fake_level = HIGH; + CHECK(h.poll(1) == EStop::Pressed); + } + + // The count wraps at 255. + { + EStop e(2, true, 0); + for (int i = 0; i < 256; i++) + { + e.update(true, 2 * i); + e.update(false, 2 * i); + e.update(false, 2 * i + 1); + } + CHECK(e.count() == 0); + } + + // Boot wait: a line held at power-up is pressed during the wait, which + // lasts its full duration and restamps the watchdog at its end. + { + EStop e(2); + bsc::CommandWatchdog wd; + wd.arm(500, 0); + fake_level = LOW; + fake_now = 0; + fake_step = 1; + eventCount = 0; + bsc::bootWait(e, 1000, record, &wd); + CHECK(eventCount == 1 && events[0] == EStop::Pressed); + CHECK(e.asserted()); + CHECK(fake_now >= 1000); + // Stamped by the wait's last millis() read, one step before fake_now: + // no trip until 500 ms after that. + const unsigned long stampedAt = fake_now - 1; + CHECK(stampedAt >= 1000); + CHECK(!wd.check(stampedAt + 499)); + CHECK(wd.check(stampedAt + 500)); + } + + // Boot wait with the line released: no events, no callback needed. + { + EStop e(2); + fake_level = HIGH; + fake_now = 0; + bsc::bootWait(e, 1000, nullptr); + CHECK(!e.asserted()); + } + + CHECK_DONE(); +} diff --git a/tests/test_parse.cpp b/tests/test_parse.cpp new file mode 100644 index 0000000..79299dd --- /dev/null +++ b/tests/test_parse.cpp @@ -0,0 +1,57 @@ +// Strict parsing: whole decimal token, finite, in range; *out untouched on +// failure. +#include + +#include "check.h" + +int main() +{ + long v; + v = 7; + CHECK(bsc::parseLong("42", 0, 100, &v) && v == 42); + CHECK(bsc::parseLong("-255", -255, 255, &v) && v == -255); + CHECK(bsc::parseLong("+3", 0, 10, &v) && v == 3); + + // Each of these fails and leaves v as it was. + const char *badLong[] = { + "", " 1", "1 ", "abc", "12x", "x12", "1.5", "0x10", "--1", "-", "1e3", "1-2", + "99999999999999999999", + }; + for (const char *s : badLong) + { + v = 7; + CHECK(!bsc::parseLong(s, -1000000, 1000000, &v)); + CHECK(v == 7); + } + v = 7; + CHECK(!bsc::parseLong(nullptr, 0, 10, &v) && v == 7); + + // Range is inclusive at both ends. + CHECK(bsc::parseLong("65535", 0, 65535, &v) && v == 65535); + v = 7; + CHECK(!bsc::parseLong("65536", 0, 65535, &v) && v == 7); + CHECK(!bsc::parseLong("-1", 0, 65535, &v) && v == 7); + + float f; + f = 7; + CHECK(bsc::parseFloat("25.5", 0, 500, &f) && f == 25.5f); + CHECK(bsc::parseFloat("-0.25", -1, 1, &f) && f == -0.25f); + CHECK(bsc::parseFloat("1e2", 0, 500, &f) && f == 100.0f); + CHECK(bsc::parseFloat("3", 0, 500, &f) && f == 3.0f); + + const char *badFloat[] = { + "", " 1", "1 ", "abc", "1.5x", "nan", "inf", "-inf", "0x1p3", "1e", ".", "1e999", + "1.2.3", "1-2", + }; + for (const char *s : badFloat) + { + f = 7; + CHECK(!bsc::parseFloat(s, -1e30f, 1e30f, &f)); + CHECK(f == 7); + } + f = 7; + CHECK(!bsc::parseFloat("500.1", 0, 500, &f) && f == 7); + CHECK(!bsc::parseFloat("-0.1", 0, 500, &f) && f == 7); + + CHECK_DONE(); +} diff --git a/tests/test_status_led.cpp b/tests/test_status_led.cpp new file mode 100644 index 0000000..a4fe1a3 --- /dev/null +++ b/tests/test_status_led.cpp @@ -0,0 +1,36 @@ +// Status LED: each state's colour in GRB order, written only on a change. +#include + +#include "check.h" + +using bsc::StatusLed; + +static bool frame(uint8_t g, uint8_t r, uint8_t b) +{ + return bsc_host_led_frame[0] == g && bsc_host_led_frame[1] == r && bsc_host_led_frame[2] == b; +} + +int main() +{ + StatusLed led; + led.begin(); + CHECK(bsc_host_led_writes == 0); + + led.set(StatusLed::Boot); + CHECK(bsc_host_led_writes == 1 && frame(0, 0, 50)); + + led.set(StatusLed::Running); + CHECK(bsc_host_led_writes == 2 && frame(25, 0, 0)); + led.set(StatusLed::Running); + CHECK(bsc_host_led_writes == 2); + + led.set(StatusLed::EStop); + CHECK(bsc_host_led_writes == 3 && frame(0, 50, 0)); + led.set(StatusLed::EStop); + CHECK(bsc_host_led_writes == 3); + + led.set(StatusLed::Running); + CHECK(bsc_host_led_writes == 4 && frame(25, 0, 0)); + + CHECK_DONE(); +} diff --git a/tests/test_watchdog.cpp b/tests/test_watchdog.cpp new file mode 100644 index 0000000..b28cc19 --- /dev/null +++ b/tests/test_watchdog.cpp @@ -0,0 +1,90 @@ +// Command watchdog: disarmed by default, trips once at the timeout, latches +// through arm and disarm, and clear() resumes nothing. +#include + +#include "check.h" + +int main() +{ + // Disarmed by default: never trips. + { + bsc::CommandWatchdog wd; + CHECK(!wd.armed()); + CHECK(!wd.check(100000)); + CHECK(!wd.tripped()); + } + + // Trips at the timeout, not before, and only once. + { + bsc::CommandWatchdog wd; + wd.arm(500, 1000); + CHECK(wd.armed()); + CHECK(wd.timeoutMs() == 500); + CHECK(!wd.check(1499)); + CHECK(wd.check(1500)); + CHECK(wd.tripped()); + CHECK(wd.tripCount() == 1); + CHECK(!wd.check(5000)); + CHECK(wd.tripCount() == 1); + } + + // A stamp restarts the timer. + { + bsc::CommandWatchdog wd; + wd.arm(500, 0); + wd.stamp(400); + CHECK(!wd.check(899)); + CHECK(wd.check(900)); + } + + // The trip latches: re-arming, disarming and stamping leave it set. + { + bsc::CommandWatchdog wd; + wd.arm(100, 0); + wd.check(100); + wd.arm(0, 200); + CHECK(wd.tripped()); + wd.arm(100, 300); + wd.stamp(350); + CHECK(wd.tripped()); + } + + // clear() lifts the latch only: the timeout and count stay, and the next + // trip needs the full timeout from the last stamp. + { + bsc::CommandWatchdog wd; + wd.arm(100, 0); + wd.check(100); + wd.stamp(150); + wd.clear(); + CHECK(!wd.tripped()); + CHECK(wd.armed()); + CHECK(wd.tripCount() == 1); + CHECK(!wd.check(249)); + CHECK(wd.check(250)); + CHECK(wd.tripCount() == 2); + } + + // millis() wrap: elapsed time is computed unsigned. + { + bsc::CommandWatchdog wd; + wd.arm(100, 0xFFFFFFC0UL); + CHECK(!wd.check(0x00000020UL)); // 96 ms later + CHECK(wd.check(0x00000024UL)); // 100 ms later + } + + // The count wraps at 255. + { + bsc::CommandWatchdog wd; + wd.arm(1, 0); + for (int i = 0; i < 256; i++) + { + wd.check(1000UL * (i + 1)); + wd.clear(); + wd.stamp(1000UL * (i + 1)); + } + CHECK(wd.tripCount() == 0); + } + + CHECK_DONE(); +} From e5289cf825b4e39c2049299fa9e77bbb505d7e41 Mon Sep 17 00:00:00 2001 From: Cameron Brooks <95648474+CameronBrooks11@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:11:29 -0400 Subject: [PATCH 2/2] ci: run host tests and build the example for both Nanos --- .github/workflows/ci.yml | 65 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..310e729 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,65 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + host-tests: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Configure + run: cmake -S tests -B build/tests + + - name: Build + run: cmake --build build/tests --parallel + + - name: Test + run: ctest --test-dir build/tests --output-on-failure + + examples: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Setup Python + uses: actions/setup-python@v7 + with: + python-version: "3.11" + + - name: Cache PlatformIO + uses: actions/cache@v6 + with: + path: | + ~/.cache/pip + ~/.platformio + key: pio-${{ runner.os }}-${{ hashFiles('library.json') }} + restore-keys: | + pio-${{ runner.os }}- + + - name: Install PlatformIO + run: python -m pip install -U platformio + + # Each example builds against this checkout for both Nano boards. + - name: Build examples + run: | + for dir in $(find examples -name '*.ino' -exec dirname {} \; | sort); do + echo "::group::$dir" + pio ci "$dir" --lib=. --board=nanoatmega328new --board=nano_every + echo "::endgroup::" + done