grist
Generator-based robot control. Write sequences, not state machines.
The idea
Robot code is sequential. Move here. Wait for contact. Close gripper. Lift. But the languages we use to write it force us into callbacks, state machines, or event loops that scatter the sequence across the file. You end up with thirty states in an enum and a switch block you are afraid to touch.
grist uses JavaScript generators to put the sequence back in order.
A behavior is a function*. It yields commands
to the hardware and receives sensor data back. The runtime ticks
behaviors at a fixed rate, handles timing, and manages cancellation.
Your code reads top to bottom, the way the robot actually moves.
Install
npm install axial-grist
Works with Node 18+, Bun, and Deno. Zero dependencies. 18kb total.
A first behavior
import { run, move, waitFor, grip } from 'axial-grist';
function* pickAndPlace(from, to) {
// move to the pickup position
yield move(from, { speed: 0.4 });
// lower until the force sensor detects contact
yield move({ z: 0 }, { speed: 0.1 });
yield waitFor('force', (f) => f.z > 2.0);
// grasp the part
yield grip('close', { force: 15 });
// lift and move to the place position
yield move({ z: 100 });
yield move(to, { speed: 0.6 });
// lower and release
yield move({ z: 5 }, { speed: 0.15 });
yield grip('open');
yield move({ z: 100 });
}
run(pickAndPlace, { from: TRAY_A, to: TRAY_B });
Every line is a step. Every step runs in order. The generator pauses
at each yield and resumes when the command completes.
There is no ambiguity about what the robot does next.
Composition
Behaviors compose by yielding into each other with yield*.
This is not a framework feature — it is how JavaScript generators already
work.
function* loadPallet(tray, pallet, count) {
for (let i = 0; i < count; i++) {
const slot = pallet.slot(i);
yield* pickAndPlace(tray, slot);
}
}
function* main() {
yield* home();
yield* loadPallet(TRAY_A, PALLET_1, 24);
yield* loadPallet(TRAY_B, PALLET_2, 24);
yield* home();
}
The call stack is a behavior tree. main calls
loadPallet which calls pickAndPlace. If you
cancel main, the entire tree unwinds. If a sensor trips
inside pickAndPlace, the exception propagates up through
loadPallet to main using ordinary
try/catch.
Parallel behaviors
Sometimes two things must happen at once. grist provides
parallel and race:
import { parallel, race, waitFor, move } from 'axial-grist';
// move arm and conveyor at the same time
yield* parallel(
move(armTarget),
move(conveyorTarget)
);
// move until either the target is reached or
// the limit switch fires — whichever comes first
const result = yield* race(
move({ x: 500 }),
waitFor('limit-switch', (v) => v === true)
);
parallel ticks all sub-behaviors on every cycle and
completes when all of them complete. race completes when the
first one does and cancels the rest. Both are generators themselves —
composable the same way as everything else.
Hardware transports
grist does not talk to hardware directly. It talks to a
Transport — a thin adapter that maps yielded
commands to physical I/O. You pick the one that matches your setup:
SerialTransport— RS-232/RS-485 devices, Modbus RTU, GRBL controllersGpioTransport— Raspberry Pi GPIO pins, digital I/O boardsCanTransport— CAN bus networks (via SocketCAN) v1.2WebSocketTransport— browser-based simulation and remote dashboardsMockTransport— unit testing without hardware
import { Runtime, SerialTransport } from 'axial-grist';
const transport = new SerialTransport('/dev/ttyUSB0', {
baudRate: 115200,
protocol: 'modbus-rtu',
});
const rt = new Runtime(transport, { hz: 100 });
rt.run(main);
hz parameter sets the tick rate. The runtime calls
your generator once per tick, blocking if the previous tick has not
finished. If a tick overruns, grist logs a warning but does not skip
steps — determinism over throughput.
Timing
Time in grist is measured in ticks, not wall-clock milliseconds. This makes behaviors deterministic and testable. Built-in timing primitives:
import { wait, timeout, every } from 'axial-grist';
// pause for 200 ticks (2 seconds at 100hz)
yield wait(200);
// abort if the move takes longer than 500 ticks
yield* timeout(500, move(target));
// read a sensor every 10 ticks, forever
yield* every(10, function* () {
const temp = yield read('thermocouple');
if (temp > 80) throw new Error('overtemp');
});
Testing
Because behaviors are generators that yield plain objects, you can test them without hardware, without mocks, and without the runtime:
import { pickAndPlace } from './behaviors.js';
const gen = pickAndPlace(TRAY_A, TRAY_B);
const step1 = gen.next();
assert.equal(step1.value.type, 'move');
assert.deepEqual(step1.value.target, TRAY_A);
const step2 = gen.next();
assert.equal(step2.value.type, 'move');
assert.deepEqual(step2.value.target, { z: 0 });
// simulate the force sensor triggering
const step3 = gen.next({ z: 2.5 });
assert.equal(step3.value.type, 'grip');
Each yield produces a command object. Each
.next() feeds back sensor data. The behavior is a pure
function from inputs to commands. You can replay it, snapshot it, diff
it. No hardware required.