Initial framework: command language, flow layer, host build

A C framework for commanding a Pico WH robot over USB, WiFi or BLE with a
small text DSL. No hardware attached yet, so the whole stack runs on the
host against a simulated HAL and is covered by tests.

Two specifications drive the code:

  docs/grammar.md  the language — one namespace of callables (native,
                   firmware-resident, and defined over the wire), procedures
                   with named arguments and defaults, REPEAT, and the rules
                   that keep it safe on a microcontroller.
  docs/flow.md     transport, bracket-balance framing, the envelope,
                   admission, execution, abort and failsafe.

Three properties the design leans on:

  - Bodies may only reference callables that already exist, so the call
    graph is acyclic by construction, recursion is unrepresentable, and
    interpreter depth is checked at definition time rather than discovered
    at runtime.
  - Nothing blocks. The interpreter is a resumable state machine over an
    explicit frame stack, so ABORT is honoured within one tick even in the
    middle of a long move, and no program can overflow the MCU stack.
  - No queue. One outstanding statement at a time, which keeps abort to a
    single unambiguous victim and avoids inventing an answer to "the
    running program failed, does the queued one still go?".

Named arguments are resolved to the callee's parameter order at definition
time, so they cost nothing at execution.

rubo_core is target-independent. Porting to the Pico means providing a
non-blocking rubo_transport_t and a rubo_hal_t, and nothing else.

Not built yet: the Pico transport and HAL, the camera DAT/DEND bulk path,
and IF/ELIF, which is reserved in the grammar and lands with the sensors.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hfu1EFVbpee92zvzQunsKb
This commit is contained in:
khannurien
2026-08-21 14:49:29 +00:00
commit 51c4fb1089
27 changed files with 4081 additions and 0 deletions

129
host/main.c Normal file
View File

@@ -0,0 +1,129 @@
/* main.c — the host robot: the whole stack with simulated hardware.
*
* This is the main loop from docs/flow.md §8.3, unmodified. Only the
* transport and the HAL differ from the Pico build.
*/
#include "rubo/hal_sim.h"
#include "rubo/rubo.h"
#include <stdio.h>
#include <string.h>
#include <time.h>
rubo_transport_t *rubo_transport_stdio(void);
static rubo_transport_t *g_tx;
static uint32_t now_ms(void)
{
static struct timespec t0;
struct timespec t;
clock_gettime(CLOCK_MONOTONIC, &t);
if (t0.tv_sec == 0) t0 = t;
return (uint32_t)((t.tv_sec - t0.tv_sec) * 1000 +
(t.tv_nsec - t0.tv_nsec) / 1000000);
}
static void sink(void *ctx, const char *line)
{
(void)ctx;
printf("< %s\n", line);
fflush(stdout);
}
int main(int argc, char **argv)
{
bool trace = false, script = false;
for (int i = 1; i < argc; i++) {
if (strcmp(argv[i], "--trace") == 0) trace = true;
if (strcmp(argv[i], "--script") == 0) script = true;
}
rubo_hal_sim_set_trace(trace);
rubo_t *r = rubo_create(rubo_hal_sim(), sink, NULL);
if (!r) { fprintf(stderr, "out of memory\n"); return 1; }
if (rubo_load_stdlib(r, rubo_stdlib) != RUBO_OK) {
fprintf(stderr, "standard library failed to load\n");
rubo_destroy(r);
return 1;
}
g_tx = rubo_transport_stdio();
if (g_tx->open(g_tx) < 0) {
fprintf(stderr, "cannot open stdin\n");
rubo_destroy(r);
return 1;
}
rubo_framer_t fr;
rubo_framer_reset(&fr);
rubo_note_traffic(r, now_ms());
printf("rubo host — try: PING | STAT | SQUARE side=400 | ABORT\n");
printf(" multi-line DEF works; end with a newline at depth 0\n");
fflush(stdout);
bool input_done = false;
for (;;) {
uint32_t t = now_ms();
uint8_t buf[128];
/* --script is the minimal correct client (docs/flow.md §9): one
* outstanding statement at a time. It stops reading while a program
* runs, and keeps the link alive meanwhile — which is exactly what
* a real client must do, since the deadman does not care that the
* operator is merely being quiet. */
size_t want = sizeof buf;
if (script && rubo_state(r) != RUBO_IDLE) {
rubo_note_traffic(r, t);
want = 0;
} else if (script) {
want = 1;
}
int n = (input_done || want == 0) ? 0 : g_tx->read(g_tx, buf, want);
if (n < 0) {
/* End of stdin. On a real transport a disconnect means the
* §10 guard fires; here it just means a script ran out, so the
* running program is allowed to finish. */
input_done = true;
n = 0;
}
/* Do not exit with the motors still turning: a `dur=0` move is left
* running deliberately, and the thing that ends it is the deadman
* (docs/flow.md §10). Leaving early would hide that. */
bool engaged = rubo_hal_sim_state()->left != 0 ||
rubo_hal_sim_state()->right != 0;
if (input_done && rubo_state(r) == RUBO_IDLE && !engaged) break;
for (int i = 0; i < n; i++) {
int st = rubo_framer_push(&fr, (char)buf[i], t);
if (st == 1) {
rubo_submit(r, fr.buf, t);
} else if (st < 0) {
rubo_err_t e = (rubo_err_t)(-st);
printf("< ERR %d %s\n", (int)e, rubo_strerror(e));
fflush(stdout);
}
}
int to = rubo_framer_timeout(&fr, t);
if (to < 0) {
rubo_err_t e = (rubo_err_t)(-to);
printf("< ERR %d incomplete statement\n", (int)e);
fflush(stdout);
}
rubo_tick(r, t);
/* The Pico build spins; here, yield so the host is not pegged. */
struct timespec nap = { .tv_sec = 0, .tv_nsec = 2 * 1000 * 1000 };
nanosleep(&nap, NULL);
}
rubo_destroy(r);
return 0;
}