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:
429
docs/flow.md
Normal file
429
docs/flow.md
Normal file
@@ -0,0 +1,429 @@
|
||||
# Rubo Command Flow — Transport, Framing and Execution
|
||||
|
||||
Status: **draft, v1**. Companion to `docs/grammar.md`, which specifies the
|
||||
language. This document specifies everything around it: how bytes become
|
||||
statements, how statements become motion, and how the robot is stopped.
|
||||
|
||||
## 1. Principles
|
||||
|
||||
1. **Nothing blocks.** The interpreter is a resumable state machine driven from
|
||||
the main loop. A `MOVE` lasting ten seconds must not prevent `ABORT` from
|
||||
being received in second three. This constraint shapes the entire design.
|
||||
2. **The envelope is not the language.** Correlation tags and control words are
|
||||
handled by the session layer and never reach the parser. `docs/grammar.md`
|
||||
stays a specification of a language, not of a wire protocol.
|
||||
3. **One framer for every transport.** USB, WiFi, BLE and the host test harness
|
||||
all reduce to a byte stream. Only MTU and connection semantics differ.
|
||||
4. **A dropped link stops the robot.** Every path that engages a motor is
|
||||
covered by a deadman timer. This is the one requirement that is not
|
||||
negotiable for convenience.
|
||||
|
||||
## 2. Layer stack
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────┐
|
||||
│ transport usb_cdc │ wifi_tcp │ ble_nus │ host_stdio
|
||||
│ byte stream in/out, MTU-aware
|
||||
├──────────────────────────────────────────────┤
|
||||
│ framer bracket-balance statement extraction (§4)
|
||||
├──────────────────────────────────────────────┤
|
||||
│ session tags, control words, ACK/DONE/ERR, backpressure (§5–§7)
|
||||
├──────────────────────────────────────────────┤
|
||||
│ parser → validator → arena (docs/grammar.md)
|
||||
├──────────────────────────────────────────────┤
|
||||
│ scheduler resumable interpreter, explicit stack, ticks (§8)
|
||||
├──────────────────────────────────────────────┤
|
||||
│ HAL motor │ camera │ battery sim | pico
|
||||
└──────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Layers below the parser are shared; only `transport` and `HAL` have per-target
|
||||
implementations. The host build swaps exactly those two and runs everything
|
||||
else unmodified, which is what makes the language testable with no hardware.
|
||||
|
||||
## 3. Transport interface
|
||||
|
||||
```c
|
||||
typedef struct {
|
||||
int (*open)(void *cfg);
|
||||
int (*read)(uint8_t *buf, size_t len); /* non-blocking; 0 = no data */
|
||||
int (*write)(const uint8_t *buf, size_t len);
|
||||
bool (*connected)(void);
|
||||
void (*close)(void);
|
||||
size_t mtu; /* max bytes per write */
|
||||
} rubo_transport_t;
|
||||
```
|
||||
|
||||
`read` must never block. Everything above this line is polled from the main
|
||||
loop; a blocking read anywhere forfeits §1.1.
|
||||
|
||||
| Transport | Implementation | MTU | Notes |
|
||||
|-------------|----------------|-----|-------|
|
||||
| `usb_cdc` | TinyUSB CDC (pico-sdk) | 64 | the development default; enumerate as ACM |
|
||||
| `wifi_tcp` | lwIP listener, one client | ~1460 | CYW43; port configurable, default 3141 |
|
||||
| `ble_nus` | Nordic UART Service | 20–244 | notify for TX, write-without-response for RX; chunking mandatory |
|
||||
| `host_stdio`| stdin/stdout | — | host build; drives the whole stack under a test harness |
|
||||
| `host_tcp` | POSIX socket | — | host build; lets a real client speak to a simulated robot |
|
||||
|
||||
BLE's small MTU is not a special case: the framer is byte-oriented, so a
|
||||
statement split across five notifications reassembles exactly as one arriving
|
||||
in a single USB read.
|
||||
|
||||
## 4. Framing
|
||||
|
||||
`docs/grammar.md` §3.3 established that statements end at **bracket balance**,
|
||||
not at newlines. The framer is therefore a small state machine, not a line
|
||||
reader.
|
||||
|
||||
### 4.1 Rule
|
||||
|
||||
> A top-level statement is complete at the first newline encountered while
|
||||
> bracket depth is zero and the buffer is non-empty.
|
||||
|
||||
Inside brackets, newlines are ordinary whitespace. A multi-line `DEF` pasted
|
||||
into a terminal arrives as one statement; a one-liner arrives as one statement.
|
||||
Interactive typing and bulk paste need no mode switch.
|
||||
|
||||
### 4.2 State machine
|
||||
|
||||
| State | Input | Action |
|
||||
|-----------|------------------|--------|
|
||||
| `STMT` | `#` | → `COMMENT` |
|
||||
| `STMT` | `[` | `depth++`; append |
|
||||
| `STMT` | `]` | `depth--`; append; if `depth < 0` → error `E_SYNTAX`, → `DISCARD` |
|
||||
| `STMT` | newline | if `depth == 0 && len > 0` → **emit**; else treat as space |
|
||||
| `STMT` | other | append |
|
||||
| `COMMENT` | newline | → `STMT` (apply the newline rule above) |
|
||||
| `COMMENT` | other | discard |
|
||||
| `DISCARD` | newline | reset `depth`/`len`, → `STMT` |
|
||||
| `DISCARD` | other | discard |
|
||||
|
||||
The framer must be **comment-aware**: a `[` inside a comment must not affect
|
||||
depth, or a single `#` in a comment would desynchronise the stream permanently.
|
||||
There are no string literals in the language, so no quote handling is needed.
|
||||
|
||||
### 4.3 Failure and resynchronisation
|
||||
|
||||
| Condition | Response | Recovery |
|
||||
|-----------|----------|----------|
|
||||
| `len > RUBO_MAX_STMT` | `ERR 10 statement too long` | → `DISCARD` |
|
||||
| unbalanced `]` | `ERR 1 unexpected ]` | → `DISCARD` |
|
||||
| partial statement idle > `RUBO_FRAME_TIMEOUT_MS` | `ERR 1 incomplete statement` | reset |
|
||||
| transport disconnect mid-statement | — | reset buffer; §10 deadman applies |
|
||||
|
||||
The idle timeout matters most on BLE, where a client can vanish mid-frame
|
||||
leaving an unterminated `[` that would otherwise wedge the framer forever.
|
||||
|
||||
### 4.4 Top-level `;`
|
||||
|
||||
A bare `;` at depth zero is `E_SYNTAX`. One frame carries one statement; to
|
||||
submit several as a unit, wrap them: `[ A; B; C ]`. This keeps "one statement,
|
||||
one tag, one ACK, one DONE" exact.
|
||||
|
||||
## 5. Envelope
|
||||
|
||||
Two things wrap a statement and are stripped by the session layer before
|
||||
parsing. Neither appears in `docs/grammar.md`, because neither is part of the
|
||||
language.
|
||||
|
||||
### 5.1 Correlation tags
|
||||
|
||||
An optional `@<uint16>` prefix. Responses echo it.
|
||||
|
||||
```
|
||||
> @7 SQUARE side=1000
|
||||
< @7 ACK
|
||||
< @7 DONE
|
||||
```
|
||||
|
||||
Untagged statements are legal and produce untagged responses — convenient when
|
||||
a human is typing. Tags are the client's to allocate; the robot never
|
||||
interprets them beyond echoing.
|
||||
|
||||
### 5.2 Control words
|
||||
|
||||
Recognised before parsing, executed immediately, never queued, never subject to
|
||||
`E_BUSY`. They bypass the interpreter entirely.
|
||||
|
||||
| Word | Effect |
|
||||
|---------|--------|
|
||||
| `ABORT` | unwind the interpreter stack, `hal_stop_all()`, end the running program (§10) |
|
||||
| `PING` | `PONG` — liveness, and it feeds the deadman timer |
|
||||
| `STAT` | one-line status: running callable, tag, depth, arena free, link age |
|
||||
|
||||
Control words are deliberately few. `ABORT` in particular cannot be a native
|
||||
callable, because a callable can only run when the interpreter is free to run
|
||||
it — which is precisely when you do not need it.
|
||||
|
||||
## 6. Response vocabulary
|
||||
|
||||
```
|
||||
@7 ACK statement admitted, parsed, validated; now executing
|
||||
@7 DONE execution finished normally
|
||||
@7 ERR <code> <message> rejected or failed; codes in grammar.md §10
|
||||
EVT <name> [args...] asynchronous, untagged, broadcast
|
||||
DAT <id> <seq> <total> <payload> bulk data chunk (§11)
|
||||
DEND <id> bulk data complete
|
||||
PONG reply to PING
|
||||
STAT <k>=<v> ... reply to STAT
|
||||
```
|
||||
|
||||
**ACK and DONE are always both sent**, including for instantaneous statements
|
||||
like `DEF` and `DEL`. Collapsing them for non-executing statements would save a
|
||||
few bytes and cost every client a special case; a terminal client is free to
|
||||
hide `ACK` when `DONE` follows immediately.
|
||||
|
||||
`ERR` may arrive instead of `ACK` (rejected at parse or validation) or after
|
||||
`ACK` (failed during execution). A client waits for exactly one of `DONE` or
|
||||
`ERR` per tag.
|
||||
|
||||
## 7. Statement lifecycle
|
||||
|
||||
```
|
||||
bytes ─► RECEIVING ─► ADMISSION ─► PARSING ─► VALIDATING ─► RUNNING ─► DONE
|
||||
│ │ │ │ │
|
||||
│ │ │ │ ├─► ERR (runtime: E_RANGE, E_FAULT)
|
||||
│ │ │ │ └─► ABORTED (E_ABORT)
|
||||
│ │ │ └─► ERR (E_UNKNOWN, E_ARITY, E_ARGNAME,
|
||||
│ │ │ E_UNDEFINED, E_CYCLE, E_NOMEM…)
|
||||
│ │ └─► ERR (E_SYNTAX, E_LIMIT)
|
||||
│ └─► ERR 12 busy
|
||||
└─► discarded (§4.3)
|
||||
```
|
||||
|
||||
**Admission** comes before parsing: if a program is already running, the
|
||||
statement is rejected immediately without being parsed at all (§9). There is
|
||||
no point spending cycles validating something that cannot run.
|
||||
|
||||
Validation is the last point at which rejection is free. Past `ACK`, a failure
|
||||
means the robot has already moved, so the majority of `docs/grammar.md` §10 is
|
||||
deliberately checkable before execution begins.
|
||||
|
||||
## 8. Execution model
|
||||
|
||||
### 8.1 Explicit stack, no C recursion
|
||||
|
||||
The interpreter holds a statically allocated array of frames:
|
||||
|
||||
```c
|
||||
typedef struct {
|
||||
const rubo_node_t *node; /* current statement in the body */
|
||||
uint16_t pc; /* index within the enclosing sequence */
|
||||
uint16_t iter; /* REPEAT counter */
|
||||
rubo_value_t args[RUBO_MAX_PARAMS];
|
||||
uint32_t deadline; /* native scratch: ms, 0 = none */
|
||||
} rubo_frame_t;
|
||||
|
||||
static rubo_frame_t stack[RUBO_MAX_DEPTH];
|
||||
```
|
||||
|
||||
`docs/grammar.md` §5.1 guarantees the call graph is acyclic, so `RUBO_MAX_DEPTH`
|
||||
frames are provably sufficient and depth is checked at definition time. Nothing
|
||||
here recurses in C, so the MCU stack is not at risk regardless of what the user
|
||||
sends.
|
||||
|
||||
The explicit stack buys three things beyond safety: `ABORT` is a matter of
|
||||
resetting an index, `STAT` can report exactly what is executing, and the whole
|
||||
interpreter is trivially unit-testable on the host.
|
||||
|
||||
### 8.2 Natives are resumable
|
||||
|
||||
```c
|
||||
typedef enum { RUBO_STEP_DONE, RUBO_STEP_PENDING, RUBO_STEP_ERR } rubo_step_t;
|
||||
|
||||
typedef rubo_step_t (*rubo_native_fn)(rubo_frame_t *f, uint32_t now_ms);
|
||||
```
|
||||
|
||||
A native with a duration sets `f->deadline` on first entry and returns
|
||||
`RUBO_STEP_PENDING` until `now_ms >= f->deadline`. `MOVE dir=FWD speed=50
|
||||
dur=1000` engages the motors, returns immediately, and is re-entered each tick
|
||||
until its deadline passes — during which the transport is polled normally and
|
||||
`ABORT` is honoured within one tick.
|
||||
|
||||
### 8.3 Main loop
|
||||
|
||||
```c
|
||||
for (;;) {
|
||||
uint32_t now = rubo_millis();
|
||||
rubo_transport_poll(now); /* read bytes, feed framers */
|
||||
rubo_session_poll(now); /* frame → envelope → admit → parse → run */
|
||||
rubo_exec_tick(now); /* advance the interpreter, bounded work */
|
||||
rubo_hal_poll(now); /* sensors, deadman, event generation */
|
||||
}
|
||||
```
|
||||
|
||||
Cooperative and single-core; no RTOS, and the second RP2040 core stays free for
|
||||
the camera pipeline later. `rubo_exec_tick` runs at most `RUBO_STEPS_PER_TICK`
|
||||
interpreter steps before yielding, so a deeply nested `REPEAT` of instantaneous
|
||||
statements cannot starve the transport.
|
||||
|
||||
## 9. Concurrency and backpressure
|
||||
|
||||
**One interpreter, one running program, no queue.**
|
||||
|
||||
| Situation | Behaviour |
|
||||
|-----------|-----------|
|
||||
| statement arrives, nothing running | `ACK`, execute |
|
||||
| statement arrives, program running | `ERR 12 busy` — not parsed, not stored |
|
||||
| `DEF`/`DEL` arrives while a program is running | `ERR 12 busy` |
|
||||
| `ABORT` arrives | immediate, always (§5.2) |
|
||||
|
||||
The client contract is therefore exactly one sentence: **one outstanding
|
||||
statement at a time — wait for `DONE` or `ERR` before sending the next.**
|
||||
|
||||
### 9.1 Why no queue
|
||||
|
||||
Queueing looks friendlier and is not, for three reasons.
|
||||
|
||||
**The language already solves it.** Chaining work is what sequences are for:
|
||||
`[ SQUARE side=1000; ZIGZAG deg=30; STOP ]` is one statement, one tag, one
|
||||
`ACK`, one `DONE`. It is validated as a whole before anything moves, runs with
|
||||
no inter-statement gaps, and aborts as a whole. A queue is a worse duplicate of
|
||||
a feature that already exists, reachable only by giving up whole-program
|
||||
validation.
|
||||
|
||||
**A queue invents a question with no good answer.** If `A` is running, `B` is
|
||||
queued, and `A` fails with `E_FAULT` — does `B` run? Yes means the robot keeps
|
||||
driving after a fault. No means an `ACK` was a promise the robot silently
|
||||
broke. With `[A; B]` the question never arises: the sequence stops. Every
|
||||
queued design has to pick a side and surprise somebody.
|
||||
|
||||
**A queue is wrong for the case people actually want it for.** Live driving —
|
||||
arrow keys, a joystick app — wants *preemption*: press left while moving
|
||||
forward and left should happen now. A queue delivers the opposite, turning
|
||||
steering input into a laggy backlog. Batch work wants sequences; interactive
|
||||
work wants preemption; the queue serves neither.
|
||||
|
||||
`DEF` and `DEL` are refused during execution for a separate reason: mutating
|
||||
the arena while a callable may be live on the interpreter stack is not worth
|
||||
making safe for a convenience nobody needs.
|
||||
|
||||
### 9.2 If preemption is needed later
|
||||
|
||||
Should live driving materialise, the addition is explicit and small: a `!`
|
||||
prefix on the envelope meaning *abort whatever is running and execute this
|
||||
instead* — `!DRIVE left=40 right=-40`. It reuses the `ABORT` path wholesale and
|
||||
requires no queue, no reordering, and no change to §9.1's guarantees. It is
|
||||
deliberately not in v1 because a preempting command is a loaded gun and should
|
||||
be designed against a real teleop client rather than an imagined one.
|
||||
|
||||
## 10. Abort and failsafe
|
||||
|
||||
`ABORT` unwinds the stack, calls `hal_stop_all()`, and emits `EVT ABORT`
|
||||
followed by `ERR 13` for the tag of the program it killed. With no queue there
|
||||
is nothing else to flush — which is the second benefit of §9: abort has exactly
|
||||
one victim, and its identity is never in doubt.
|
||||
|
||||
Three independent guards cover the case where nobody is left to send `ABORT`:
|
||||
|
||||
| Guard | Trigger | Action |
|
||||
|-------|---------|--------|
|
||||
| **Deadman** | no inbound bytes on the owning session for `RUBO_LINK_TIMEOUT_MS` **while motors are engaged** | abort + stop, `EVT LINKLOST` |
|
||||
| **Disconnect** | transport reports `!connected()` while motors are engaged | abort + stop |
|
||||
| **Motor cap** | any single motor-engaging native exceeds `RUBO_MOTOR_MAX_MS` | stop that native, `ERR 6` |
|
||||
|
||||
The deadman is armed only while motors are engaged, so an idle robot on a quiet
|
||||
link is not perpetually resetting itself. `PING` is sufficient to feed it, which
|
||||
is why it is a control word rather than a native.
|
||||
|
||||
### 10.1 A client must ping while it waits
|
||||
|
||||
This falls out of §9 and is worth stating plainly, because it is the one place
|
||||
where two rules interact in a way that surprises people:
|
||||
|
||||
> A program that engages the motors for longer than `RUBO_LINK_TIMEOUT_MS`
|
||||
> will be killed by the deadman unless the client sends something during it.
|
||||
|
||||
`SQUARE side=1000` runs for about six seconds. A client that submits it and
|
||||
then waits quietly for `DONE` — the obvious reading of §9's "wait for `DONE`
|
||||
or `ERR`" — gets `EVT LINKLOST` instead. That is the deadman working
|
||||
correctly: it cannot distinguish an attentive operator from a crashed one, and
|
||||
guessing in the robot's favour is how robots drive into walls.
|
||||
|
||||
So the client contract has a second sentence: **`PING` on an interval
|
||||
(≈500 ms) whenever a program is running.** The reference client in
|
||||
`host/main.c --script` does exactly this.
|
||||
|
||||
## 11. Bulk data
|
||||
|
||||
The protocol is text; camera frames are not. Frames leave as `DAT` records,
|
||||
chunked to the transport MTU, base64-encoded:
|
||||
|
||||
```
|
||||
< DAT 3 0 8 <base64…>
|
||||
< DAT 3 1 8 <base64…>
|
||||
…
|
||||
< DEND 3
|
||||
```
|
||||
|
||||
`<id>` correlates chunks to the `CAM action=SNAP` that produced them; `<seq>`
|
||||
and `<total>` let a client detect loss and reassemble. Base64 costs 33%
|
||||
overhead, which for a B&W QQVGA snapshot (160×120×1bpp ≈ 2.4 KB → ≈ 3.2 KB) is
|
||||
acceptable for stills and firmly not acceptable for video. A binary side
|
||||
channel can be added later without disturbing the text protocol; streaming
|
||||
video was never in scope for the command path.
|
||||
|
||||
## 12. Multiple transports
|
||||
|
||||
All configured transports may be open simultaneously. Each holds its own framer
|
||||
and buffer. Statements from any transport compete for the same single execution
|
||||
slot, `E_BUSY` is global, and `EVT`/`DAT` are broadcast to every connected
|
||||
transport.
|
||||
|
||||
This is honest rather than clever: two clients can interleave commands and
|
||||
confuse each other. Exclusive session ownership — first-come with explicit
|
||||
release or timeout takeover — is the obvious v2 refinement, but it needs a
|
||||
policy decision about who may seize control of a moving robot, and that
|
||||
decision is better made with the hardware in front of us.
|
||||
|
||||
The deadman (§10) tracks the session that submitted the running program, not
|
||||
the aggregate of all links.
|
||||
|
||||
## 13. Constants
|
||||
|
||||
| Constant | Suggested | Meaning |
|
||||
|----------------------------|-----------|---------|
|
||||
| `RUBO_FRAME_TIMEOUT_MS` | 2000 | partial statement idle timeout |
|
||||
| `RUBO_LINK_TIMEOUT_MS` | 2000 | deadman, while motors engaged |
|
||||
| `RUBO_MOTOR_MAX_MS` | 10000 | hard cap on one motor-engaging native |
|
||||
| `RUBO_STEPS_PER_TICK` | 32 | interpreter steps before yielding |
|
||||
| `RUBO_RX_BUF` | 1088 | ≥ `RUBO_MAX_STMT` + envelope |
|
||||
| `RUBO_TCP_PORT` | 3141 | `wifi_tcp` default |
|
||||
|
||||
## 14. Worked exchange
|
||||
|
||||
```
|
||||
> @1 DEF ZIGZAG $deg=45 $reps=3 [
|
||||
> REPEAT $reps [
|
||||
> TURN dir=LEFT deg=$deg; MOVE dir=FWD speed=40 dur=300;
|
||||
> TURN dir=RIGHT deg=$deg; MOVE dir=FWD speed=40 dur=300
|
||||
> ]
|
||||
> ]
|
||||
< @1 ACK
|
||||
< @1 DONE
|
||||
|
||||
> @2 [ SQUARE side=1000; ZIGZAG deg=30 reps=5 ]
|
||||
< @2 ACK
|
||||
> @3 SQUARE side=200
|
||||
< @3 ERR 12 busy # one outstanding statement at a time
|
||||
> @4 DEF FOO [ STOP ]
|
||||
< @4 ERR 12 busy # definitions are not an exception
|
||||
> ABORT
|
||||
< EVT ABORT
|
||||
< @2 ERR 13 aborted # exactly one victim
|
||||
> @3 SQUARE side=200
|
||||
< @3 ACK
|
||||
< @3 DONE
|
||||
```
|
||||
|
||||
Note that the multi-line `DEF` at `@1` is a single statement: the framer emits
|
||||
it only at the newline following the closing bracket.
|
||||
|
||||
## 15. Open questions
|
||||
|
||||
1. **Should `EVT` be subscribable?** Broadcasting every event to every
|
||||
transport is fine at this scale and wasteful over BLE if telemetry gets
|
||||
chatty. An `EVT` mask per session is cheap to add later.
|
||||
2. **`STAT` output format.** Currently ad-hoc `k=v` pairs. If it grows, it
|
||||
wants a schema.
|
||||
3. **Exclusive session ownership** (§12) — deferred pending hardware.
|
||||
343
docs/grammar.md
Normal file
343
docs/grammar.md
Normal file
@@ -0,0 +1,343 @@
|
||||
# Rubo Command Language — Grammar
|
||||
|
||||
Status: **draft, v1**. Covers the language only. Wire framing, session flow,
|
||||
acknowledgements and events are specified separately in `docs/flow.md` (TBD).
|
||||
|
||||
## 1. Design principles
|
||||
|
||||
1. **One namespace of callables.** A call site cannot tell whether it is
|
||||
invoking a C function, a procedure baked into firmware, or a procedure
|
||||
defined over the wire five seconds ago. Primitives are simply callables
|
||||
with a native body.
|
||||
2. **Bounded execution.** Every program's call depth and iteration count are
|
||||
statically bounded. Recursion is impossible by construction (§5.1), and the
|
||||
only loop takes an explicit, capped count.
|
||||
3. **No dynamic allocation at execution time.** Definitions are parsed once
|
||||
into a compact form in a fixed arena. Execution allocates nothing.
|
||||
4. **One parser.** Firmware-resident procedures are written in this same
|
||||
language and parsed at boot, so there is no second representation to keep in
|
||||
sync and the standard library is testable on the host.
|
||||
5. **Two value types only:** integers and words. No strings, no floats, no
|
||||
collections.
|
||||
|
||||
## 2. Kinds of callable
|
||||
|
||||
| Kind | Body lives in | Defined at | Deletable | Shadowable |
|
||||
|-----------|---------------|-------------------|-----------|------------|
|
||||
| `NATIVE` | C function | compile time | no | no |
|
||||
| `STATIC` | flash (const) | compile time, in this language | no | no |
|
||||
| `DYNAMIC` | RAM arena | runtime, over the wire | yes | yes (redefinition) |
|
||||
|
||||
`STATIC` procedures exist so a useful vocabulary survives a power cycle without
|
||||
a filesystem. A `DYNAMIC` procedure is promoted to `STATIC` by pasting its text
|
||||
into the firmware's standard library — no translation step.
|
||||
|
||||
## 3. Lexical structure
|
||||
|
||||
### 3.1 Tokens
|
||||
|
||||
| Token | Form | Notes |
|
||||
|-------------|---------------------------------------|-------|
|
||||
| `IDENT` | `letter (letter \| digit \| "_")*` | ≤ 15 chars, canonicalised to uppercase |
|
||||
| `NUMBER` | `["-"] digit+` | signed 32-bit |
|
||||
| `LBRACKET` | `[` | |
|
||||
| `RBRACKET` | `]` | |
|
||||
| `SEMI` | `;` | statement separator |
|
||||
| `EQ` | `=` | named argument / default value |
|
||||
| `DOLLAR` | `$` | parameter reference |
|
||||
| `RELOP` | `<` `>` `<=` `>=` `==` `!=` | reserved, §7 |
|
||||
|
||||
### 3.2 Keywords
|
||||
|
||||
`DEF` `DEL` `REPEAT` `IF` `ELIF` `ELSE`
|
||||
|
||||
Keywords are case-insensitive and reserved: they may not be used as callable
|
||||
or parameter names. `IF`/`ELIF`/`ELSE` are reserved but not yet implemented.
|
||||
|
||||
### 3.3 Whitespace and comments
|
||||
|
||||
Whitespace (including newlines) separates tokens and is otherwise
|
||||
insignificant — a definition may span as many lines as it likes. `#` begins a
|
||||
comment that runs to the end of the line.
|
||||
|
||||
Statement boundaries are therefore determined by **bracket balance**, not by
|
||||
newlines. This has a direct consequence for the wire protocol, addressed in
|
||||
`docs/flow.md`.
|
||||
|
||||
### 3.4 Case
|
||||
|
||||
All identifiers — callable names, parameter names, and word values — are
|
||||
case-insensitive and canonicalised to uppercase on ingest. `move fwd`,
|
||||
`MOVE FWD` and `Move Fwd` are the same statement.
|
||||
|
||||
## 4. Syntax
|
||||
|
||||
```ebnf
|
||||
statement ::= def | del | repeat | if | sequence | call
|
||||
|
||||
def ::= "DEF" name paramdecl* sequence
|
||||
paramdecl ::= "$" name [ "=" literal ]
|
||||
del ::= "DEL" name
|
||||
|
||||
repeat ::= "REPEAT" arg sequence
|
||||
|
||||
(* reserved — see §7, not implemented in v1 *)
|
||||
if ::= "IF" cond sequence { "ELIF" cond sequence } [ "ELSE" sequence ]
|
||||
cond ::= operand relop operand
|
||||
operand ::= literal | param | query
|
||||
relop ::= "<" | ">" | "<=" | ">=" | "==" | "!="
|
||||
|
||||
sequence ::= "[" [ statement { ";" statement } [";"] ] "]"
|
||||
|
||||
call ::= name { positional } { named }
|
||||
positional ::= arg
|
||||
named ::= name "=" arg
|
||||
|
||||
arg ::= literal | param
|
||||
literal ::= number | word
|
||||
param ::= "$" name
|
||||
|
||||
name ::= IDENT
|
||||
word ::= IDENT
|
||||
number ::= NUMBER
|
||||
```
|
||||
|
||||
The grammar is LL(1): one token of lookahead distinguishes every production,
|
||||
including `positional` from `named`. A hand-written recursive-descent parser is
|
||||
sufficient; no parser generator is needed.
|
||||
|
||||
### 4.1 The `$` invariant
|
||||
|
||||
`$name` means exactly one thing everywhere it appears: **the value of a
|
||||
parameter of the enclosing procedure**. It never appears at a call site as a
|
||||
binding target.
|
||||
|
||||
```
|
||||
DEF WIGGLE $deg [ TURN dir=LEFT deg=$deg; TURN dir=RIGHT deg=$deg ]
|
||||
# ^^^^ binding target: bare
|
||||
# ^^^^ parameter read: $
|
||||
```
|
||||
|
||||
Parameter scope is strictly the body of the procedure that declares it. There
|
||||
are no globals, no closures, and no inheritance of a caller's parameters.
|
||||
|
||||
### 4.2 Arguments
|
||||
|
||||
Arguments may be given positionally, by name, or both — with all positional
|
||||
arguments preceding all named ones, which is the familiar rule. Named form is
|
||||
the documented style; positional is shorthand for short, frequent commands.
|
||||
|
||||
```
|
||||
MOVE FWD 50 1000 # positional
|
||||
MOVE dir=FWD speed=50 dur=1000 # named
|
||||
MOVE FWD speed=50 dur=1000 # mixed
|
||||
MOVE dur=1000 speed=50 dir=FWD # named args are order-independent
|
||||
```
|
||||
|
||||
This requires every callable — natives included — to declare parameter *names*,
|
||||
not just an arity. See §8.
|
||||
|
||||
### 4.3 Defaults
|
||||
|
||||
A parameter may declare a default, which must be a literal. Parameters with
|
||||
defaults are optional at the call site; those without are required.
|
||||
|
||||
```
|
||||
DEF SQUARE $side $speed=50 [ ... ]
|
||||
|
||||
SQUARE side=1000 # speed defaults to 50
|
||||
SQUARE side=1000 speed=80
|
||||
SQUARE # E_ARITY: required parameter 'side' missing
|
||||
```
|
||||
|
||||
Defaults are evaluated at definition time and stored as literals; they cannot
|
||||
reference other parameters.
|
||||
|
||||
**Parameters with defaults must come last.** Without that rule
|
||||
`DEF F $a=1 $b` would accept `F 5`, bind `a`, and then fail on the missing
|
||||
`b` — behaviour nobody can predict from reading the signature. Violation is
|
||||
`E_SYNTAX`.
|
||||
|
||||
## 5. Static semantics
|
||||
|
||||
Checks performed at **definition** time, so that a malformed procedure is
|
||||
rejected once at `DEF` rather than repeatedly at each call.
|
||||
|
||||
### 5.1 Forward references are forbidden
|
||||
|
||||
> A body may only reference callables that already exist.
|
||||
|
||||
This single rule is what makes the language safe to run on a microcontroller
|
||||
with no supervision. Because a callable can only reference callables defined
|
||||
strictly before it, the call graph is acyclic by construction, recursion is
|
||||
unrepresentable, and maximum interpreter stack depth is computable at
|
||||
definition time (and checked against `RUBO_MAX_DEPTH`).
|
||||
|
||||
Violation: `E_UNDEFINED`.
|
||||
|
||||
### 5.2 Redefinition
|
||||
|
||||
Redefining a `DYNAMIC` name is allowed and may change its arity and parameter
|
||||
names. It is rejected if the new body transitively references the name being
|
||||
redefined, which is the only way a cycle could otherwise reappear:
|
||||
|
||||
```
|
||||
DEF A [ MOVE FWD 50 100 ]
|
||||
DEF B [ A ]
|
||||
DEF A [ B ] # E_CYCLE
|
||||
```
|
||||
|
||||
The check is a depth-first walk of the new body's reference set. On success,
|
||||
every existing caller is re-validated against the new signature; if any caller
|
||||
would break, the redefinition is rejected atomically (`E_ARITY` / `E_ARGNAME`)
|
||||
and the old body is retained.
|
||||
|
||||
`NATIVE` and `STATIC` names cannot be redefined or deleted: `E_READONLY`.
|
||||
|
||||
### 5.3 Argument checking
|
||||
|
||||
Resolved at definition time for calls inside a body, and at parse time for
|
||||
top-level calls: unknown parameter name (`E_ARGNAME`), duplicate parameter name
|
||||
(`E_ARGNAME`), a named argument colliding with one already given positionally
|
||||
(`E_ARGNAME`), missing required parameter (`E_ARITY`), too many positionals
|
||||
(`E_ARITY`), and value type mismatch where the callable declares a type
|
||||
(`E_ARGTYPE`).
|
||||
|
||||
Range checks that depend on a runtime value (`E_RANGE`) are necessarily
|
||||
deferred to execution. A word argument is one of these: `MOVE dir=SIDEWAYS`
|
||||
is well-typed — `dir` takes a word and `SIDEWAYS` is a word — and is caught
|
||||
as `E_RANGE` when `MOVE` runs and finds it is neither `FWD` nor `BACK`.
|
||||
|
||||
### 5.4 Deletion
|
||||
|
||||
`DEL` is refused with `E_READONLY` if any other callable references the name,
|
||||
because deleting it would leave a dangling callee in a body that already
|
||||
passed validation. Deletion is refused rather than cascaded: removing
|
||||
procedures the operator did not ask about is a worse surprise than being told
|
||||
to delete the caller first.
|
||||
|
||||
## 6. `REPEAT`
|
||||
|
||||
```
|
||||
REPEAT 4 [ MOVE FWD 50 500; TURN RIGHT 90 ]
|
||||
REPEAT $n [ ... ]
|
||||
```
|
||||
|
||||
The count is an integer literal or parameter. `n <= 0` executes the body zero
|
||||
times and is not an error; `n < 0` is `E_RANGE`. The count is clamped to
|
||||
`RUBO_MAX_ITER`, exceeding which is `E_RANGE` rather than a silent truncation.
|
||||
|
||||
`REPEAT` is the only loop. There is deliberately no `WHILE`: an unbounded loop
|
||||
would forfeit the termination guarantee of §5.1, and the failure mode of a
|
||||
non-terminating program on a robot with motors is a physical one.
|
||||
|
||||
## 7. Reserved: conditionals
|
||||
|
||||
Not implemented in v1. Specified here so that adding it later is not a breaking
|
||||
change to the grammar.
|
||||
|
||||
```
|
||||
IF DIST < 30 [ STOP ]
|
||||
ELIF DIST < 100 [ MOVE FWD 20 500 ]
|
||||
ELSE [ MOVE FWD 50 500 ]
|
||||
```
|
||||
|
||||
A condition is a **flat triple** — `operand relop operand`. There is no
|
||||
nesting, no parenthesisation, and no `AND`/`OR`. This keeps it a fixed-size
|
||||
struct rather than an expression tree, consistent with the decision to omit
|
||||
arithmetic entirely.
|
||||
|
||||
`IF` is bounded and so does not threaten §5.1 — it selects among branches and
|
||||
never loops.
|
||||
|
||||
The blocking dependency is not syntactic. Conditions require **queries**:
|
||||
callables that yield a value rather than a status. Introducing them changes the
|
||||
value model, the dispatch table and the HAL contract, and there is nothing
|
||||
meaningful to branch on until real sensors exist. `IF` therefore lands with the
|
||||
sensor layer.
|
||||
|
||||
## 8. Native signatures
|
||||
|
||||
Illustrative, to be finalised alongside the HAL. Listed here because named
|
||||
arguments require natives to declare parameter names.
|
||||
|
||||
| Callable | Parameters | Notes |
|
||||
|----------|-----------------------------------------|-------|
|
||||
| `MOVE` | `$dir` `$speed` `$dur=0` | `dir` ∈ `FWD`,`BACK`; `speed` 0..100; `dur` ms, 0 = until countermanded |
|
||||
| `TURN` | `$dir` `$deg` | `dir` ∈ `LEFT`,`RIGHT`. Open-loop until odometry exists |
|
||||
| `DRIVE` | `$left` `$right` `$dur=0` | tank-style, −100..100 per side |
|
||||
| `STOP` | — | |
|
||||
| `WAIT` | `$dur` | ms |
|
||||
| `CAM` | `$action` | `action` ∈ `SNAP` |
|
||||
|
||||
Units are open until the motion semantics are settled: `TURN deg=90` implies
|
||||
odometry or an IMU we do not have. v1 is open-loop and the unit slot is
|
||||
reserved for when encoders arrive.
|
||||
|
||||
## 9. Limits
|
||||
|
||||
All limits are compile-time constants. Exceeding any is `E_LIMIT` unless a more
|
||||
specific code applies.
|
||||
|
||||
| Constant | Suggested | Meaning |
|
||||
|----------------------|-----------|---------|
|
||||
| `RUBO_MAX_NAME` | 15 | identifier length, excluding terminator |
|
||||
| `RUBO_MAX_PARAMS` | 4 | parameters per callable |
|
||||
| `RUBO_MAX_DEPTH` | 8 | interpreter call depth |
|
||||
| `RUBO_MAX_NEST` | 8 | syntactic nesting of sequences |
|
||||
| `RUBO_MAX_ITER` | 65535 | `REPEAT` count |
|
||||
| `RUBO_MAX_CALLABLES` | 32 | `DYNAMIC` procedures resident |
|
||||
| `RUBO_ARENA_INSTRS` | 256 | `STATIC` + `DYNAMIC` procedure storage, in fixed-width instructions |
|
||||
| `RUBO_MAX_STMT` | 1024 | source bytes in one top-level statement |
|
||||
|
||||
## 10. Error codes
|
||||
|
||||
| Code | Name | Meaning |
|
||||
|-----:|---------------|---------|
|
||||
| 0 | `OK` | success |
|
||||
| 1 | `E_SYNTAX` | malformed input |
|
||||
| 2 | `E_UNKNOWN` | no such callable |
|
||||
| 3 | `E_ARITY` | missing required argument, or too many positionals |
|
||||
| 4 | `E_ARGNAME` | unknown, duplicate, or conflicting parameter name |
|
||||
| 5 | `E_ARGTYPE` | wrong value type |
|
||||
| 6 | `E_RANGE` | value outside accepted range |
|
||||
| 7 | `E_UNDEFINED` | body references a callable that does not exist |
|
||||
| 8 | `E_CYCLE` | redefinition would create a cycle |
|
||||
| 9 | `E_NOMEM` | procedure arena exhausted |
|
||||
| 10 | `E_LIMIT` | a static limit exceeded (§9) |
|
||||
| 11 | `E_READONLY` | attempt to delete or redefine a `NATIVE`/`STATIC` callable |
|
||||
| 12 | `E_BUSY` | reserved — flow layer |
|
||||
| 13 | `E_ABORT` | execution interrupted |
|
||||
| 14 | `E_FAULT` | HAL or hardware error |
|
||||
|
||||
## 11. Worked example
|
||||
|
||||
```
|
||||
# --- firmware standard library, parsed at boot -------------------
|
||||
DEF SQUARE $side $speed=50 [
|
||||
REPEAT 4 [
|
||||
MOVE dir=FWD speed=$speed dur=$side;
|
||||
TURN dir=RIGHT deg=90
|
||||
]
|
||||
]
|
||||
|
||||
DEF PATROL [
|
||||
SQUARE side=1000;
|
||||
STOP
|
||||
]
|
||||
|
||||
# --- sent over the wire at runtime -------------------------------
|
||||
DEF ZIGZAG $deg=45 $reps=3 [
|
||||
REPEAT $reps [
|
||||
TURN dir=LEFT deg=$deg;
|
||||
MOVE dir=FWD speed=40 dur=300;
|
||||
TURN dir=RIGHT deg=$deg;
|
||||
MOVE dir=FWD speed=40 dur=300
|
||||
]
|
||||
]
|
||||
|
||||
[ PATROL; ZIGZAG deg=30 reps=5; SQUARE side=500 speed=30; STOP ]
|
||||
```
|
||||
|
||||
The last line is a top-level sequence: it is submitted, validated and executed
|
||||
as a single abortable unit.
|
||||
Reference in New Issue
Block a user