Skip to main content

Quickstart

Where you end up: a Protective Stop remote on your desk, on your own Tailscale network, bonded to a ROS 2 machine node running on your laptop. Press the button and the node reports STOP; hold and release and it arms.

The remote reaches the laptop either over Ethernet (plug it into the same LAN, or a PoE port) or over USB (the cable is both power and network). Pick one; each step says what differs. No firmware toolchain is needed: you flash a prebuilt image and configure it from a web page. Allow about 20 minutes plus the ROS 2 install.

This guide uses the dev profile, which has no protection: with the unit and a USB cable anyone can copy its Tailscale identity. Once section 7 passes, move every remote you keep to secure-fe (secure_boot_guide.md).

0. What you need​

ItemNotes
Assembled remoteWaveshare ESP32-S3-ETH + NKK FF01 switch + LED ring: hardware guide, assembly guide. A bare board works for everything except the button steps.
USB-C data cablePower, and the network if you use USB.
Ethernet cable and a DHCP LAN with internetOnly for the Ethernet path. A PoE switch port powers the remote too.
LaptopUbuntu 24.04 with internet (22.04 works with ROS 2 Humble).
uvRuns every Python tool in this repo, esptool included, from one locked environment (tools/README.md). It fetches Python itself.
ROS 2 JazzyInstall guide; ros-jazzy-ros-base is enough.
Tailscale accountFree tier is fine.

Everything below is copy-paste. Lines marked expect: show what success looks like.

1. Tailscale: account, laptop, auth key​

curl -fsSL https://tailscale.com/install.sh | sh
sudo tailscale up
tailscale ip -4 # expect: 100.x.y.z

Keep that address; it is $LAPTOP_TS below.

Then create an auth key for the remote at https://login.tailscale.com/admin/settings/keys → Generate auth key: Reusable off (one key per remote), Ephemeral off, Pre-approved on if your tailnet uses device approval. Copy the tskey-auth-… value; you paste it into the remote in step 4. (Why these settings, and tags for a locked-down setup: Appendix B.)

2. Clone the repo and prepare the laptop​

git clone https://github.com/polymathrobotics/protective-stop.git
cd protective-stop

Set up the Python tooling once (install uv with curl -LsSf https://astral.sh/uv/install.sh | sh and open a new shell first):

(cd tools && uv sync)

USB path only — make the laptop own the USB link (address, DHCP for the remote, internet sharing), once per laptop:

host/setup/install.sh

Ethernet path: nothing to prepare; the remote gets its address from your LAN. (WiFi, or moving a remote between uplinks later: Appendix A.)

3. Flash the remote​

Into an empty folder, whose path you set as FW_PATH below, download three files from the latest release: pstop_remote-<version>-public-fullflash.bin (factory image, used now), pstop_remote-<version>-public.bin (app image, for updates later) and SHA256SUMS. Connect the remote by USB (a blank board is already in download mode) and, using the exact filename you downloaded:

FW_PATH=~/Downloads/pstop-firmware         # the folder you downloaded into
(cd "$FW_PATH" && sha256sum -c SHA256SUMS --ignore-missing) # expect: two lines ending in OK
cd tools # the uv environment from step 2
ls /dev/ttyACM* # expect: /dev/ttyACM0
uv run esptool --chip esp32s3 -p /dev/ttyACM0 -b 460800 write_flash 0x0 "$FW_PATH"/pstop_remote-<version>-public-fullflash.bin
# expect: … Hash of data verified. … Hard resetting via RTS pin…

If esptool cannot connect: hold BOOT, tap RESET, release BOOT, retry.

This is a factory image: everything on the chip is replaced, there is no Tailscale key in it, and the admin password is microlink. Set it as a variable for the commands that follow:

ADMIN_PW=microlink

Provision on a network you control. Until step 4 is done the remote sits on your LAN with a public password and no key; anyone on that LAN could paste their own key first. Do step 4 on the USB tether, or on a LAN with only your own machines on it (a switch on the desk, not the office network). If several people provision at once (a workshop), use the USB path or one switch per person, and build images with your own password (Appendix E) before any remote goes on a shared network for good.

(Updating a remote that already runs this firmware, or one you have already configured: Appendix C — the factory image would erase its settings.)

4. First boot: find the remote and give it the key​

Unplug and replug the remote (Ethernet path: connect the Ethernet cable now; the board is powered by USB or PoE). Within ~10 s the small onboard LED blinks the last number of the remote's IP address, digit by digit — blue on Ethernet, green on USB. Find the full address:

PathAddress
EthernetYour LAN's subnet plus the blinked digits, e.g. 192.168.1. + 47. Missed the blink? Power-cycle the remote and watch again, or look in your router's DHCP table for the lease that appeared when you plugged it in (the remote sends no hostname).
USBip neigh show dev esp-pstop0 → 10.42.0.X; your laptop is 10.42.0.1.
DEV=<that address>

Open http://$DEV/admin/ (user admin, password microlink) → Settings → paste the auth key into Tailscale Auth Key → Save → Restart. Or do the same from the shell:

curl -u "admin:$ADMIN_PW" -H 'Content-Type: application/json' \
-d '{"auth_key":"tskey-auth-…"}' -X POST "http://$DEV/admin/api/settings" # expect: {"ok":true…}
curl -u "admin:$ADMIN_PW" -X POST "http://$DEV/admin/api/restart"

About a minute after the restart:

tailscale status | grep pstop-       # expect: 100.a.b.c  pstop-01xxxxxx  …
REMOTE_HOST=$(tailscale status | awk '/pstop-01/{print $2; exit}')
REMOTE=$(tailscale ip -4 "$REMOTE_HOST") # the remote's tailnet address
REMOTE_ID=$(printf '%d' 0x${REMOTE_HOST#pstop-}) # its device id, decimal, for the ROS node
curl -s "http://$REMOTE/state.json" | grep -o '"ml_state":[0-9]' # expect: "ml_state":4

The LED ring plays a dim purple sweep and settles on solid white (no machine configured). From here on use $REMOTE; it works over either path.

Two things to do now in the Tailscale console (https://login.tailscale.com/admin/machines): on the new pstop-… machine, ⋯ → Disable key expiry (otherwise it drops off the tailnet in 180 days), and if device approval is on and you did not pre-approve the key, approve it.

5. Laptop: run the ROS 2 machine node​

source /opt/ros/jazzy/setup.bash
rosdep update && rosdep install -iy --from-paths ros2/
colcon build --packages-up-to protective_stop_machine
source install/setup.bash
ros2 run protective_stop_machine machine_bridge_node

The defaults are all you need: the node listens on UDP 8890 on every interface and admits every remote. In a second terminal (source both setup files again):

ros2 lifecycle get /machine_bridge   # expect: active [3]

If ufw is on: sudo ufw allow in on tailscale0 to any port 8890 proto udp. Restricting which remotes may bond, or running without ROS: Appendix D.

6. Pair the remote to the laptop​

The remote initiates the bond; tell it where the machine is, then promote it from stop-only to operator (a new remote can stop the machine but not arm it). The role change is an admin call: with the release image's public password it only works on $DEV (USB or LAN), not over Tailscale.

curl -X POST "http://$REMOTE/api/pstop_peer?ip=$LAPTOP_TS&port=8890"
# expect: {"ok":true,...} ring: white -> blue within a few seconds
curl -u "admin:$ADMIN_PW" -X POST "http://$DEV/api/role?role=operator"
# expect: {"ok":true,"role":"operator","message":"applied"}

Both settings persist on the remote.

7. Test it​

Third terminal, node topics:

ros2 topic echo /machine_bridge/remotes       # expect: device_id "01xxxxxx", bond_state 2, stop_only false
ros2 topic echo /machine_bridge/machine_state # expect: status 1, "need_stop (awaiting arming gesture)"
ros2 topic echo /pstop_hb # expect: stop: true at ~10 Hz
DoExpect
Press the button, hold ≥ 0.5 s, twist to releasemachine_state → status: 0, "armed (cleared to run)"; /pstop_hb → stop: false; ring green
Press the buttonstatus: 1; stop: true within one heartbeat (~200 ms); ring red
Release (twist)Stays stopped until the next hold-and-release
Pull the network cable while armed (USB or Ethernet)status: 1 within ~2 s; remote disappears from /remotes
ReconnectRing blue; hold-and-release to re-arm

/pstop_hb is the signal for the rest of your robot stack: stop: false at 10 Hz means cleared to run; anything else, including silence, means stop.

That is the whole setup. If a step did not match, see section 8; everything else lives in the appendices.

Now move to secure-fe. Before this remote goes anywhere it could be lost, re-provision it with the default secure-fe profile (secure_boot_guide.md). It is one-way and erases the remote, so repeat step 4 with a new key, then step 6.

8. Troubleshooting​

SymptomCheck
esptool cannot connectHold BOOT, tap RESET, release BOOT, retry. lsusb | grep 303a: 303a:1001 is download mode (good), 303a:4001 is a running remote.
LED does not blink an addressEthernet: no DHCP lease — cable, switch port, DHCP server. USB: lsusb | grep 303a shows 303a:4001? nmcli con show esp-pstop exists? Re-run host/setup/install.sh, replug.
Admin page does not load on $DEVSame LAN? A laptop on a different subnet or on WiFi with client isolation cannot reach it. USB: ip addr show esp-pstop0 must show 10.42.0.1.
LED strobes redLocal link up but no internet: the remote cannot reach Tailscale. Ethernet: LAN has no internet. USB: laptop has no internet or sharing is off (nmcli con show esp-pstop | grep ipv4.method → shared).
No pstop- in tailscale status after 2 minKey wrong, already used or expired: curl -u "admin:$ADMIN_PW" http://$DEV/admin/api/status → state. Device approval on and key not pre-approved → approve in the console.
ml_state stuck below 4Same as above (no internet, or key not accepted).
Ring stays white after pstop_peerThe POST failed; re-run and read the JSON.
Ring blue, never greenRole still stop-only (curl -u "admin:$ADMIN_PW" http://$DEV/api/role), node not running, or ufw. /machine_bridge/remotes shows stop_only: true while the remote announces stop-only.
Ring red pulsing slowlyPeer configured but unreachable: node down, wrong $LAPTOP_TS, ufw. tailscale ping $REMOTE from the laptop.
Ring purpleOne switch loop open while the other is closed: wiring fault (hardware guide).

Remote-side counters: curl -s "http://$REMOTE/state.json" | python3 -m json.tool | grep -E 'pstop_(sent|replies)|ml_state|role' — pstop_sent and pstop_replies climbing together means the bond is healthy. More: troubleshooting.md, http_api.md.


The remote tries uplinks in order: Ethernet (6 s DHCP wait), USB, WiFi. Ethernet wins over USB whenever both are connected, including hot-plug on a running unit; pulling it falls back to USB with one ~5 s Tailscale re-register that a bonded machine rides through — provided the laptop has run host/setup/install.sh (step 2), which is what gives the USB link an address and internet. On the Ethernet path that script is optional; run it if you want the USB cable to be a working fallback rather than power only.

WiFi: add the network under WiFi Networks on the admin page (its own section, above Device Settings), or bake it in (Appendix E). A remote that boots with neither Ethernet nor USB and cannot join WiFi within 60 s opens its own access point microlink-XXYYZZ (password microlink) with the admin page at http://192.168.4.1/admin. This is a boot-time fallback only: a unit that loses its wired/USB link while running falls back to the configured WiFi but does not open the access point; power-cycle it to get there.

The admin page listens on every network the remote joins (tether, LAN, WiFi, and the tailnet if the image has its own password). Before putting a remote on a shared LAN or WiFi, build the image with your own password (Appendix E); the release image's microlink is public, so the remote refuses it over Tailscale.

Two remotes on one laptop by USB: only the first gets esp-pstop0; see usb_ncm_tether_setup.md, which also has Windows/macOS notes.

Appendix B: Tailscale key settings and fleet isolation​

SettingValueWhy
ReusableoffThe key is used once, to enroll; an enrolled remote re-registers without it. A reusable key would let anyone who copies it off one remote enroll more devices. Firmware v1.3.1 and older re-send the key on every re-registration and need on.
EphemeraloffThe remote keeps its node identity in flash; ephemeral nodes get deleted.
Pre-approvedon, if device approval is enabledThe remote has no browser to approve itself with.
Tagsoptionaltailscale_isolation.md has a locked-down fleet policy. Not needed for this guide.

The key is stored in the remote's settings area and survives OTA updates and app-image flashes (not the factory image, Appendix C). A key saved through the admin page takes priority over one baked into the image, and makes the remote enroll again on its next boot. A remote deleted from the Tailscale console stays off the tailnet until it is given a new key.

Appendix C: updating, reflashing, and what persists​

Update a running unit (keeps every setting): send the app image (pstop_remote-<version>-public.bin, downloaded to tools/ in step 3 or from the new release) over the USB or LAN link; the remote reboots into it.

curl -u "admin:$ADMIN_PW" --data-binary @pstop_remote-<version>-public.bin -X POST "http://$DEV/admin/api/ota"

Reflash by cable: a running unit has no serial port. Hold BOOT, tap RESET (or curl -u "admin:$ADMIN_PW" -X POST "http://$DEV/api/enter_download?confirm=1" and start flashing within 60 s), then flash. The full-flash image is the factory image (step 3) and erases the settings; to keep them, use the OTA command above instead.

Start over: flash the full-flash image, or cd tools && uv run esptool --chip esp32s3 -p /dev/ttyACM0 erase_flash (then flash the full-flash image; an erased chip has no firmware). Either wipes the Tailscale identity, key, peer, role and health counters. The remote does not come back on the tailnet by itself: repeat step 4 (new key paste), then step 6; it registers as a new machine.

DataLives inSurvives OTA / app-image flashSurvives full-flash imageSurvives erase_flash
Tailscale auth key, WiFiNVS if set via /admin/ (or the firmware image when built with credentials)yes (NVS wins over image)nono
Admin passwordfirmware image only (microlink in release builds)whatever the new image bakes in (a release image resets it to microlink)whatever the new image bakes inno (no firmware left)
Tailscale node identityNVSyesno (new machine on tailnet)no
Machine peer, self-roleNVSyesnono
Lifetime health counters (/api/health)NVSyesnono
Admission lists (allowlist/denylist)laptop: pstop_machine.yaml / machine.tomln/a (not on the remote)n/an/a

Appendix D: machine-side options​

Restrict which remotes may bond (the default admits every remote). Run this in the terminal where REMOTE_ID was set in step 4, or set it again first; if it is empty the list becomes [], which admits everyone:

echo "${REMOTE_ID:?set REMOTE_ID first (step 4)}"
cat > pstop_machine.yaml <<EOF
/machine_bridge:
ros__parameters:
software:
allowlist: [$REMOTE_ID] # only these may bond (empty = everyone)
denylist: [] # never these
EOF
ros2 run protective_stop_machine machine_bridge_node --ros-args --params-file pstop_machine.yaml

A refused remote shows REJECTED in /machine_bridge/remotes; fix the list, then press Rebond on the remote (or POST /api/pstop_peers?slot=0&rebond=1).

Demote a remote back to stop-only at any time: role=stop_only on /api/role. An armed machine keeps running, but refuses the next re-arm until an operator remote does STOP → OK.

Without ROS: the plain-C runner prints every state change, which the ROS node does not:

cd host && make
./machine_app_runner machine.toml # admits every remote; [policy] allowlist/denylist to restrict
# expect: machine_app_runner listening on 0.0.0.0:8890
# pstop 0x01XXXXXX -> BOND
# ARMED by 0x01XXXXXX: STOP held 804 ms (policy minimum 500 ms)

Same pairing steps as section 6. Details: host guide.

No hardware yet: cd tools && uv run python pstop_test_remote.py --port 8890 is a software remote that runs the same arming sequence against the node (testing.md, tools guide).

Appendix E: build the firmware yourself​

Needed only to bake your own credentials into the image (a different admin password, WiFi, a Tailscale key without the admin-page step) or to change the firmware. Install ESP-IDF v5.5.4 or a later 5.5.x once (older IDF will not build, the USB tether needs a 5.5 fix; on Ubuntu first sudo apt install git wget flex bison gperf python3-venv cmake ninja-build ccache libffi-dev libssl-dev dfu-util libusb-1.0-0, full prerequisites):

git clone -b v5.5.4 --recursive https://github.com/espressif/esp-idf ~/esp/esp-idf
~/esp/esp-idf/install.sh esp32s3

ESP-IDF keeps its own Python environment, separate from the uv one in tools/. Source it in each new shell before idf.py. Then build:

cp firmware/sdkconfig.credentials.example firmware/sdkconfig.credentials
$EDITOR firmware/sdkconfig.credentials # set CONFIG_ML_TAILSCALE_AUTH_KEY and CONFIG_ML_ADMIN_PASSWORD, leave the rest
cd firmware
. ~/esp/esp-idf/export.sh # in every new shell
idf.py -DPSTOP_PROFILE=dev build # the quickstart uses the dev profile
grep CONFIG_ML_TAILSCALE_AUTH_KEY sdkconfig # expect: your key, not the XXXXX placeholder
idf.py -p /dev/ttyACM0 flash
cd .. # back to the repo root for step 5

If the grep shows the placeholder, the build reused a stale sdkconfig: rm -rf sdkconfig build && idf.py -DPSTOP_PROFILE=dev build. Do not run idf.py monitor: the USB port becomes the network tether a few seconds into boot and the serial console goes quiet by design. With the key baked in, skip the admin-page part of step 4 and set ADMIN_PW to your password. Never publish an image built with a credentials file: every value in it is compiled in as plain text (tools/release_guard.sh refuses such images).