Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
70 changes: 63 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <bread_slice_core.h> // e-stop, command watchdog, parsing
#include <bsc_status_led.h> // 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

Expand Down
60 changes: 60 additions & 0 deletions examples/slice_skeleton/slice_skeleton.ino
Original file line number Diff line number Diff line change
@@ -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 <avr/wdt.h>
#include <bread_slice_core.h>
#include <bsc_status_led.h>

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
}
}
1 change: 1 addition & 0 deletions library.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
"export": {
"include": [
"src",
"examples",
"README.md",
"CHANGELOG.md",
"LICENSE"
Expand Down
11 changes: 11 additions & 0 deletions src/bread_slice_core.h
Original file line number Diff line number Diff line change
@@ -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
14 changes: 14 additions & 0 deletions src/bsc_atomic.h
Original file line number Diff line number Diff line change
@@ -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 <util/atomic.h>
#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
72 changes: 72 additions & 0 deletions src/bsc_estop.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
#include "bsc_estop.h"

#include <Arduino.h>

#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
Loading
Loading