# ZelPi protocols

ZelPi speaks two wire protocols on the robot side. Pick whichever matches
your stack — you don't need both.

- **HAL** — a minimal, versioned protocol for robots with no ROS stack at
  all. Three files on the robot: `robot-agent.mjs`, `lib/hal/protocol.mjs`,
  and the `ws` npm package.
- **babyros** — a built-in broker that speaks the same wire format as
  `rosbridge_suite`, so ROS-ecosystem tooling (roslibjs, `zelros`, rosbridge
  clients) works against it unmodified, with no ROS install.

Both are dependency-free ESM (`ws` only) so they run directly with `node`,
no build step.

## HAL — native protocol (`lib/hal/protocol.mjs`)

Single source of truth for the wire format, imported by both sides:
`cli/hal.mjs` (controller / embedded backend) and `server/robot-agent.mjs`
(runs on the robot). Every frame is JSON:

```json
{ "v": 1, "op": "<name>", ... }
```

`v` is `PROTOCOL_VERSION` (currently `1`) — bumped on any breaking shape
change. `decode()` validates every inbound frame at the boundary and never
throws; malformed input is dropped, not crashed on.

### Handshake

| op | direction | payload |
|---|---|---|
| `hello` | robot → ctrl | `{ caps: Capabilities }` — sent immediately on connect |
| `welcome` | ctrl → robot | `{ accept, controller, reason? }` |

`Capabilities`:

```ts
{
  protocol: number,             // protocol version the robot speaks
  robotKind: string,             // free-form label, e.g. "go2", "xarm6"
  drive: "diff" | "omni" | "holonomic" | "arm",
  dof: number,                   // 0 for pure mobile bases
  skills: string[],               // skill names the agent can run
  units: { len: string, ang: string },
  frame: string,                  // odometry frame name, e.g. "odom"
  limits: { vMax: number, wMax: number },
  rateHz: number,                 // telemetry publish rate
}
```

### Control (ctrl → robot)

| op | payload | meaning |
|---|---|---|
| `pose` | `{ id, x, y, theta }` | seed/reset a robot's pose |
| `cmd` | `{ id, V, w, seq }` | unicycle velocity command (linear, angular) |
| `skill` | `{ id, skill, args, reqId }` | invoke a high-level skill |
| `estop` | `{ reason }` | latch emergency stop — robot refuses motion |
| `release` | `{}` | clear the e-stop latch |
| `ping` | `{ t }` | heartbeat |

### Feedback (robot → ctrl)

| op | payload | meaning |
|---|---|---|
| `telemetry` | `{ id, x, y, theta, vx?, w?, battery?, joints?, faults?, estop?, ts }` | live pose/state |
| `skillResult` | `{ id, reqId, status, progress?, error? }` | `status` ∈ accepted / running / done / failed |
| `fault` | `{ id, code, message, severity }` | out-of-band fault, `severity` ∈ info/warning/error/critical |
| `pong` | `{ t }` | heartbeat reply |

### Safety semantics

- **Watchdog**: `robot-agent.mjs` zeros all velocities if no control frame
  arrives within `WATCHDOG_MS` (500 ms default) — a dead controller link
  fails safe.
- **E-stop is defense-in-depth**: enforced both in the connection handler
  (refuses further `cmd` frames, replies with a `fault`) and inside the
  driver's `applyCmd` (clamps to zero even if a stray command slips through).
- **One controller at a time**: a second WebSocket connection is rejected
  (code 1008) — two independent e-stop states on one robot is a safety bug
  waiting to happen.
- **Unauthenticated by design**: the agent binds `127.0.0.1` unless you
  explicitly set `PIOS_ROBOT_HOST` to something else, in which case it warns
  loudly. Only expose it on a trusted, firewalled control network.

### `setJoints` — continuous joint-space control

The native `cmd` op is unicycle-only (`{v, w}`). For a VLA or learned policy
emitting continuous joint targets, use the `setJoints` skill:

```
skill: "setJoints", args: { joints: number[], gripper?: number }
```

The driver validates shape (finite numbers, array length vs. advertised
`dof`) but applies **no magnitude/velocity/torque clamp** — your robot's own
motor controller must be the real safety boundary, the same as any other
skill. `sim/scripts/hal_client.py` is a minimal Python client for driving
this from a Python inference loop.

## babyros — rosbridge-compatible middleware (`lib/babyros/`)

A self-contained pub/sub broker (`broker.mjs`) speaking the **rosbridge v2
JSON protocol**: `advertise` / `subscribe` / `publish` / `call_service`, plus
built-in `/rosapi/*` services (`/rosapi/topics`, `/rosapi/nodes`,
`/rosapi/topic_type`) mirroring `rosbridge_suite`'s own. No message-type
registry beyond the advertised type string, no parameter server, no actions
— the minimal subset that makes real rosbridge clients work.

Default port **9091** (rosbridge_suite's own default is 9090, so you can run
both side by side). Two extensions beyond the base rosbridge_suite protocol,
both opt-in and additive:

- `{ op: "hello", node: "<name>" }` — register a node name so it shows up in
  `/rosapi/nodes`.
- `advertise(topic, type, { latch: true })` — the broker replays the last
  message on that topic to any late subscriber, like a ROS latched topic.

`BabyNode` (`node.mjs`) is the client class used by the bundled SLAM/fusion
nodes — but it's a generic rosbridge-v2 client, so it (and any real
roslibjs/rosbridge client) connects to a live `babyros` broker exactly as it
would to `rosbridge_suite`, and vice versa: `zelpi slam attach --url
ws://<robot>:9090` points the same fusion/SLAM nodes at a real rosbridge
server, no code change.

### SLAM + fusion as babyros nodes

- `world.mjs` — 2D differential-drive simulator (180-beam lidar raycast, 3%
  odometry slip + noise, gyro bias, deterministic seed) — publishes `/odom`,
  `/imu`, `/scan`, `/ground_truth`.
- `fusion.mjs` — EKF over state `[x, y, θ, b_g]` (gyro bias observable from
  odom-vs-gyro disagreement), consumes `/imu` + `/odom` + absolute corrections
  from `/scan_match_pose`, publishes latched `/fused_pose`.
- `slam.mjs` — log-odds occupancy grid + coarse-to-fine correlative scan
  matcher, consumes `/scan` + `/fused_pose`, publishes `/scan_match_pose`
  (feeds back into the EKF) and a latched `/map`.
- `launch.mjs` — `launchStack()` runs the full demo (own broker + world);
  `attachStack()` runs only fusion + SLAM against any external rosbridge URL
  with topic remaps — this is the real-robot path.

## Building a driver

See [`EXTENDING.md`](EXTENDING.md) for the `RobotDriver` interface and a
worked example.
