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 controllers
  • GpioTransport — Raspberry Pi GPIO pins, digital I/O boards
  • CanTransport — CAN bus networks (via SocketCAN) v1.2
  • WebSocketTransport — browser-based simulation and remote dashboards
  • MockTransport — 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);
The 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.