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 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(); +}