Machine Host Runner
Companion to the remote firmware in firmware/. The remote runs as a
pstop client (lockstep verdict from cores 0 + 1, byte-compared, sent
at the machine-controlled heartbeat rate); this runs the pstop machine that accepts those
heartbeats and tells the robot to STOP when they say so — or when they
stop coming.
The upstream pstop_c library (pstop_c/, submodule,
certification track) is linked unmodified. This wrapper adds:
- transport: binds
0.0.0.0(configurable) instead of127.0.0.1, so the remote can reach it over Tailscale, USB-NCM, or plain LAN; - config: every library knob + wrapper policy in
machine.toml(documented inline); - min-STOP-duration arming policy (
min_stop_ms, default 500 ms): an OK arriving sooner than this after the STOP that opened the arming cycle cannot arm the robot. Enforced natively by pstop_c (delay_between_stop_ms, upstream #59) — the runner forwards the value and logsARMED/arming DEFERRED. Defends against EMC-induced loop blips performing the arming gesture. Seedocs/design/failover_and_arming_design.md; - a status latch: OK is only propagated after the policy approves the arming — any real actuation must hang off the latched status, never the library's raw callback;
- sequence-anomaly logging (counter gaps/regressions, non-monotonic stamps, pstop rejections with direction disambiguation);
- optional console announce (
[announce]in machine.toml, orPSTOP_ANNOUNCE_URL/PSTOP_ANNOUNCE_KEY_FILEin the environment): a 60 s bearer-authenticated POST so a management dashboard can show this machine as RUNNING. Off by default, never on the safety path.
Liveness timeout — read this before tuning
The library declares a remote lost after heartbeat_ms × max_missed_heartbeats of silence (machine.c computes
missed = silence / heartbeat_ms and stops at missed >= max_missed_heartbeats). It is not × (max_missed + 1).
Remotes publish at heartbeat_ms / 2, and the dual-core remote
deliberately withholds one send whenever its lockstep comparator
disagrees for a tick. With max_missed_heartbeats = 1 the timeout equals
exactly two send slots, so a single withheld tick lands on the deadline
and races the 10 ms liveness poll — observed on the bench (2026-07-27
soak) as sporadic false MISSED_HEARTBEATS stops every ~15 min. Do not
use max_missed_heartbeats = 1 with lockstep remotes. The default is 5
(ten send slots of margin; about 2.0 s at heartbeat_ms = 400).
Build
make
No ESP-IDF needed — just cc (gcc or clang) and the vendored pstop_c
sources. Builds to ./machine_app_runner.
Run
./machine_app_runner # ./machine.toml, 0.0.0.0:8890
./machine_app_runner my.toml # different config
./machine_app_runner 9999 -v # port override + verbose RX/TX trace
Stderr prints per-pstop command changes and state transitions by default —
pstop 0x<id> -> STOP/OK/BOND/..., Robot Status = OK / STOP,
ARMED by 0x<id> ..., ANOMALY: ... — each line naming the remote by its
device id, so with several remotes bonded you can see exactly which one sent
what (and which one caused a stop or owns the arming cycle).
USB tether — one-time host setup (read this first)
The canonical, cross-platform version of this setup — including Windows and macOS notes and the subnet gotcha — is
usb_ncm_tether_setup.md. The Linux steps below are the same procedure kept here for convenience.
Plugging a pstop into a Linux host does NOT give you a working network
link out of the box, and the failure mode is misleading: the laptop's
new "wired" connection sits in connecting… forever while the unit
silently falls back to its provisioned WiFi. The chip's USB-NCM tether
deliberately runs no DHCP server — the HOST is expected to own the
link (serve DHCP, NAT it out), which NetworkManager calls shared
mode. Two files fix it permanently:
-
Pin every pstop to one predictable interface name (
esp-pstop0), keyed on the USB VID:PID so it holds across units and reboots:sudo cp 70-esp-pstop.link /etc/systemd/network/ # ships in this directory
sudo udevadm control --reload -
Bind a shared-mode NetworkManager profile to that name:
sudo nmcli con add type ethernet ifname esp-pstop0 con-name esp-pstop \
ipv4.method shared connection.autoconnect yes
Replug the unit. The host takes 10.42.0.1, the chip DHCPs to
10.42.0.x and switches its active uplink to USB. A fresh unit has no
machine peer configured (ring white); point it at this host with the
pstop_peer call below.
Caveat: only the first unit plugged in gets esp-pstop0; simultaneous
extra units fall back to enx<mac> names and need their own profiles.
Point the remote at this host
USB-NCM (host side of the tether is 10.42.0.1 after the one-time
setup above):
curl -X POST "http://$CHIP/api/pstop_peer?ip=10.42.0.1&port=8890"
Tailscale (the normal deployment path):
HOST_TS_IP=$(tailscale ip -4)
curl -X POST "http://$CHIP/api/pstop_peer?ip=$HOST_TS_IP&port=8890"
The change persists to NVS and the comparator picks it up within one tick (~100 ms), no reboot. For a Tailscale peer the chip source-binds its socket to the VPN IP — if the tunnel is down it holds the bond and the machine safely sees silence.
What you should see
machine_app_runner listening on 0.0.0.0:8890
Robot Status = STOP
pstop 0x01D7791C -> BOND
STATE robot=STOPPED restart=NEED_STOP owner=0x00000000 active=1 (from pstop 0x01D7791C BOND; ...)
pstop 0x01D7791C -> STOP
ARMED by 0x01D7791C: STOP held 804 ms (policy minimum 500 ms)
Robot Status = OK
pstop 0x01D778B4 -> STOP
STATE robot=STOPPED restart=NEED_STOP owner=0x01D7791C active=2 (from pstop 0x01D778B4 STOP; was robot=OK ...)
The machine starts disarmed (NEED_STOP). Arm it: press and hold the
E-stop switch ≥ min_stop_ms (500 ms), then release →
ARMED by 0x01020380, Robot Status = OK, remote's ring turns green.
On the remote, /state.json should show pstop_sent advancing at the
configured rate with pstop_mismatch flat:
curl -s http://$CHIP/state.json | jq '.pstop_sent, .pstop_replies, .pstop_mismatch'
If pstop_mismatch climbs, the two cores are producing different
encodings — see firmware/main/main.c::compute_verdict and the loop
diagnostics (e_hi*/e_lo*) in /state.json.
Testing the policy without a chip
tools/pstop_test_remote.py bonds over the real wire protocol and runs
timed STOP/OK sequences against a dedicated runner instance — see
docs/developing/testing/testing.md.
Stopping
Ctrl-C. SIGINT/SIGTERM are handled cleanly and the socket is closed.