Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 38 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,11 +125,13 @@ curl -X POST "http://<chip>/api/pstop_peer?ip=<machine-ip>&port=8890"
```

To use a unit over its USB cable, the host needs a one-time tether
setup first (an interface-naming rule plus a shared-mode NetworkManager
profile — the chip does not serve DHCP, so without this the USB link
sits in "connecting…" forever and the unit falls back to WiFi). See
["USB tether — one-time host setup"](host/README.md#usb-tether--one-time-host-setup-read-this-first)
in `host/README.md`.
setup first — the chip serves no DHCP, so without it the USB link sits
in "connecting…" forever and the unit falls back to WiFi. The full
procedure is in the **USB-NCM setup guide,
[`docs/USB_NCM_SETUP.md`](docs/USB_NCM_SETUP.md)**: verified Linux
(NetworkManager) steps, plus notes on the equivalent Windows and macOS
setup and the subnet gotcha that keeps the default machine bond from
forming on those hosts.

Arm by pressing and holding the switch for at least 0.5 s, then
releasing. The runner logs `ARMED` and the ring turns green.
Expand Down Expand Up @@ -220,7 +222,7 @@ including the 128-peer cap rule that governs whether a cross-site remote can
reach a machine — is documented in
[`docs/MULTI_REMOTE_MULTI_MACHINE.md`](docs/MULTI_REMOTE_MULTI_MACHINE.md).**

## Current status and open items (2026-07-22)
## Current status and open items (2026-08-07)

Units self-provision (per-unit ID, auto-join Tailscale) and check in on
boot and at least every 5 minutes. The check-in and OTA tooling is
Expand All @@ -230,6 +232,21 @@ reachability from such a backend required a DERP-home fix so a NAT'd or
tethered unit homes on the backend's region (`docs/TROUBLESHOOTING.md`).
Bulk USB provisioning goes through `tools/flash_pstop.sh`.

Recently closed (post-2026-07-22 hardening, deployed and bench-verified):

- Outbound cold-bond region routing — a remote now homes DERP on the
machine's region and self-initiates, so a cross-region unit bonds
without a manual `tailscale ping` from the robot side.
- Half-open / black-holed direct path — a wedged direct WireGuard path
is detected and demoted to DERP with no lasting bond loss.
- OTA lineage safety — an HTTP-upload OTA of a wrong-project image is
rejected at the first chunk (no needless reboot/rollback), symmetric
on remote and machine builds.
- USB-NCM tether stability verified under a running safety bond
(`docs/USB_NCM_STABILITY.md`; host setup in `docs/USB_NCM_SETUP.md`).
- `max_missed_heartbeats` default raised 3 → 5 (about 2.0 s stop-on-
silence) consistently across firmware, machine, and the ROS 2 node.

Open items:

- WiFi is the weakest transport, at roughly 98 % soak reply rate with a
Expand All @@ -241,9 +258,10 @@ Open items:
- Tailscale subnet-route caveat: a subnet router advertising the
robot's LAN hijacks operator-laptop traffic to the chip's LAN IP.
There is an `ip rule` workaround that needs documenting.
- USB-tether units behind symmetric NAT are relay-only (no direct
path) and depend on the DERP re-home. Reachable in testing after
recovery, but worth a dedicated soak.
- Under a heavy synthetic DISCO storm on a full 128-peer tailnet the
safety bond holds (zero stops), but a small reply-deficit and rebond
churn appears at load transitions — a peer-scaling budget tuning
follow-up (`docs/safety/OPEN_ITEMS.md`), not a safety failure.
- Long-duration (24 h+) per-transport soaks before certification runs.

## Certification and licensing
Expand All @@ -252,8 +270,17 @@ Open items:
this repo. Every policy the shell adds (arming veto, status latch,
transport binding, loop debounce) uses only public library API and is
designed so a shell bug can cost availability but never cause a
spurious arm. The SIL2 design record is `docs/PSTOP_SAFETY_DESIGN.md`;
the option of moving the arming policy into the library is analyzed in
spurious arm.

The integrity target is **SIL 3 (IEC 61508)** with an equivalent
**PL e (ISO 13849)** track — the project is pursuing both standards. The
safety case lives in [`docs/safety/`](docs/safety/): system definition,
HARA, safety requirements, FMEA/FMEDA, the diagnostic-coverage and
common-cause arguments, and the traceability matrix, with residual gaps
toward a full quantified claim tracked in
[`docs/safety/OPEN_ITEMS.md`](docs/safety/OPEN_ITEMS.md). The earlier
design record is `docs/PSTOP_SAFETY_DESIGN.md`, and the option of moving
the arming policy into the library is analyzed in
`docs/PSTOP_C_MIN_STOP_OPTION.md`. Deployment rule: a `pstop_c` bump
that changes the CRC is a wire break, so update chip and machine
together.
Expand Down
7 changes: 7 additions & 0 deletions docs/CRYPTO_SPEEDUP_REPORT.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,13 @@ reference built); it ships applied.

## Recommendation

> **STATUS: MERGED + DEPLOYED.** The `-O3`-on-primitives change shipped in
> PR #74 (`7867d4b`, on `main`/`dev`): `set_source_files_properties(... -O3)`
> on the vetted AEAD/X25519 primitives in `components/microlink/CMakeLists.txt`
> and `components/microlink/components/wireguard_lwip/CMakeLists.txt`, with
> KATs guarding byte-identical output. The text below is the original
> pre-merge recommendation, retained for rationale.

Merge the **`-O3`-on-primitives** change (the 2.5×, zero-risk part) after
CI + a HIL soak — it is a pure compile-flag change to already-certified
code with byte-identical output. Drop donna and Monocypher from the tree
Expand Down
66 changes: 54 additions & 12 deletions docs/MISRA_COMPLIANCE_2026-07-21.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,15 +52,22 @@ re-verified on the bench (bond, verdicts, policy veto) after the sweep.
`snprintf` offset could underflow `sizeof - rp` (size_t) and write out
of bounds with corrupt NVS values; buffer also undersized for
worst-case entries. Clamped per append + resized.
2. **FLAGGED** — `dcs_eth.c` bring-up error paths leak the driver/SPI
bus/semaphore on partial failure (behavior-affecting to fix; needs its
own change + test).
3. **FLAGGED** — `dcs_telemetry.c`: >MAX_TASKS(40) tasks makes
`uxTaskGetSystemState` return 0 → load silently reports 0 %.
4. **FLAGGED** — `dcs_wifi_status()` reads list state unlocked from the
httpd task (diagnostics-only data race).
5. **FLAGGED** — `api_iface_usb` reports the requested state even if the
toggle failed (inconsistent with the eth/wifi twins).
2. **FIXED** (since 2026-07-21) — `dcs_eth.c` now unwinds a full
`goto fail_netif → fail_driver → fail_spi → fail_sem` cleanup ladder,
freeing exactly what the call created in reverse order (netif, driver
handle, phy/mac, SPI bus if owned, semaphore); no handle/semaphore
leak on partial bring-up failure.
3. **FIXED** (since 2026-07-21) — `dcs_telemetry.c` raised `MAX_TASKS`
40 → 64 **and** guards the overflow: when `uxTaskGetSystemState`
returns 0 the sampler skips the sample (keeps `prev_*`) with a warning
instead of computing a bogus 0 % load.
4. **FIXED** (2026-08-07) — `dcs_wifi_status()` cross-task shared reads
are now atomic: `s_disc_reason`, `s_connected`, and `s_idx` are
`atomic_int` (load/store/fetch_add); `s_list` is malloc'd once and
never freed, so the pointer read is stable. No unlocked data race.
5. **FIXED** (since 2026-07-21) — `api_iface_usb` now reports the ACTUAL
resulting state on failure (`iface_toggle_reply(req, r, (r==ESP_OK) ? on
: dcs_usb_is_enabled())`), matching the eth/wifi twins.

## Deviation register (residual findings, with rationale)

Expand All @@ -77,9 +84,44 @@ re-verified on the bench (bond, verdicts, policy veto) after the sweep.
| 18.8 (main.c ×3) | Required | 3 | False positives: `uint8_t buf[PSTOP_MESSAGE_SIZE]` — macro constant unresolved in the standalone scan; arrays are fixed-size. |

## Follow-ups recommended
- Sweep `components/microlink` (large; same method).
- Fix the three flagged defects (eth error paths, telemetry cap, usb
toggle reply).
- ~~Sweep `components/microlink`~~ **DONE 2026-08-07** — see below.
- ~~Fix the flagged defects~~ **DONE** — all five real defects above are
now FIXED (dcs_eth cleanup ladder, telemetry cap+guard, wifi_status
atomics, usb toggle reply); the earlier reset-history snprintf was
already fixed.
- For certification evidence, re-run with a licensed MISRA checker
(rule texts + official compliance report format); this review is the
engineering pass that precedes it.

## `components/microlink` sweep (2026-08-07)

Swept all 17 `components/microlink/src/*.c` with the same free-cppcheck
method (`--addon=misra` + `--enable=warning,style`).

**Style/rule profile matches the existing deviation register** — no new
deviation categories. Top rules are the same platform/idiom-inherited
findings already disapplied for dcs: 2.5 unused-macros (~1087, header
constants), 10.4/10.1 essential-type (macro expansions), 15.5
multiple-returns (~368, codebase idiom), 17.7 unused-return (void-cast
candidates), 21.3 malloc (non-safety HTTP/PSRAM paths), 11.5 void\*
(FreeRTOS/IDF API). See the §"Deviation register" rationale — all apply.

**Genuine-defect classes (leak / null / uninit / buffer / race): none in
the network logic.** The only two warning-level findings are in the
vetted, KAT-verified crypto primitives, and both are confirmed
**false positives**:
- `nacl_box.c:454` `final_block[len]=1` (`arrayIndexOutOfBoundsCond`) —
`len` is the post-`len -= (len & ~15UL)` remainder, so `len ∈ [0,15]`;
writes indices 0–15, all in-bounds. Standard poly1305 final block;
cppcheck can't prove the mask bound.
- `x25519.c:220` `mul(z2,x2,a24,nb=1)` (`argumentSize`) — the `nb`
argument bounds `mul` to read only the single limb `a24` holds;
curve25519-donna pattern, cppcheck's size heuristic ignores `nb`.

Byte-identical KAT output already proves both primitives correct.

**httpd URI-handler budget (was flagged at ceiling 16 in
`MULTI_REMOTE_VALIDATION_2026-07-22.md`) is resolved:** the app httpd is
now `16 + max_user_uri_handlers`, with `cfg.max_user_uri_handlers = 30`
(`dcs_support.c`) → 46 slots for microlink's ~20 + dcs's 21, with
headroom.
3 changes: 3 additions & 0 deletions docs/MULTI_REMOTE_VALIDATION_2026-07-22.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,3 +113,6 @@ No unsafe behavior. All STOP/timeout/reject paths are fail-safe. Observations
- httpd user-handler budget (`dcs_support.c max_user_uri_handlers=16`) was at
its limit; adding one more handler (the stub) silently failed to register
until bumped. Worth watching if more `/api` routes are ever added in prod.
**[Reconciled 2026-08-07]** Raised to `max_user_uri_handlers=30` (46 total
slots for microlink's ~20 + dcs's 21); comment in `dcs_support.c` documents
the arithmetic to re-check on any new route. No longer at the limit.
179 changes: 179 additions & 0 deletions docs/USB_NCM_SETUP.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
<!--
SPDX-FileCopyrightText: 2026 Polymath Robotics
SPDX-License-Identifier: CC-BY-4.0
-->

# USB-NCM tether — host setup

This is the one-time host setup for operating a pstop remote over its USB
cable (the "USB-NCM tether"). USB-NCM is the recommended transport for
**bench work and field service** — no LAN, no WiFi credentials, no Tailscale
round-trip — and it is the third automatic uplink after Ethernet and WiFi.
Firmware stability of the tether itself is characterized separately in
[`USB_NCM_STABILITY.md`](USB_NCM_STABILITY.md); this guide is only about
preparing the host.

## Why a host setup is needed at all

Plugging a pstop into a host does **not** give you a working link out of the
box, and the failure mode is misleading: the host's new "wired" connection
sits in *connecting…* forever while the unit silently falls back to its
provisioned WiFi.

By design the chip's USB-NCM interface runs **no DHCP server**. The chip
presents itself as a standard USB CDC-NCM network adapter and expects the
**host** to own the link — assign itself an address, serve DHCP to the chip,
and NAT the chip's traffic onward. Once the host does that:

- the host takes **`10.42.0.1`**,
- the chip DHCPs an address in **`10.42.0.0/24`** (typically `10.42.0.x`),
- the chip switches its active uplink to USB, and
- because the chip's factory-default machine peer is **`10.42.0.1:8890`**, it
bonds to a `machine_app_runner` on this host with zero further config.

The chip enumerates with USB **VID:PID `303a:4001`** in application mode
(`303a:1001` is ROM download mode — a different device, ignored by the naming
rule below).

The subnet matters: the chip's baked-in default peer is `10.42.0.1`. Any host
setup that hands out a *different* subnet (notably Windows ICS, see below)
will bring the link up but the default machine bond will not form until you
either match the subnet or repoint the chip with
`POST /api/pstop_peer?ip=<host-ip>&port=8890`.

---

## Linux — verified

**Easiest: run the installer.** It auto-detects the host's network manager
(NetworkManager *or* systemd-networkd) and installs the matching variant,
plus the manager-agnostic interface-naming rule:

```sh
host/setup/install.sh
```

Replug the unit afterward so the `esp-pstop0` rename takes effect. The manual
steps for each manager, if you prefer them:

### NetworkManager

**1. Pin every pstop to one predictable interface name (`esp-pstop0`).**
Keyed on the USB VID:PID so it holds across units and across reboots, and
sorts before the default `73-usb-net-by-mac.link` (first match wins), so the
interface is never the per-unit `enx<mac>` name:

```sh
sudo cp host/setup/70-esp-pstop.link /etc/systemd/network/
sudo udevadm control --reload
```

**2. Bind a shared-mode NetworkManager profile to that name.** `shared` mode
is what makes NetworkManager assign `10.42.0.1`, start a dnsmasq DHCP server,
and NAT the link outward:

```sh
sudo nmcli con add type ethernet ifname esp-pstop0 con-name esp-pstop \
ipv4.method shared connection.autoconnect yes
```

Replug the unit. Within a few seconds the host takes `10.42.0.1`, the chip
DHCPs `10.42.0.x`, and its active uplink switches to USB.

### systemd-networkd (no NetworkManager)

Hosts running systemd-networkd (common on HIL / fleet hosts) need the
`nmcli`-free equivalent — a NetworkManager "shared" profile has no effect
there. Install the `70-esp-pstop.link` naming rule as above, then:

```sh
sudo cp host/setup/80-esp-pstop.network /etc/systemd/network/
sudo cp host/setup/esp-pstop-flush-networkd /etc/networkd-dispatcher/routable.d/esp-pstop-flush
sudo cp host/setup/esp-pstop-flush-networkd /etc/networkd-dispatcher/degraded.d/esp-pstop-flush
sudo chmod +x /etc/networkd-dispatcher/routable.d/esp-pstop-flush \
/etc/networkd-dispatcher/degraded.d/esp-pstop-flush
sudo systemctl enable --now networkd-dispatcher # GOTCHA below
sudo networkctl reload
```

`80-esp-pstop.network` gives the host `10.42.0.1/24`, runs the built-in DHCP
server, and NATs the link out (`ConfigureWithoutCarrier=yes`, so it is ready
before the chip even enumerates). **Gotcha:** the `networkd-dispatcher`
package is often *installed but its service disabled* — then the neighbor-cache
flush hook never fires and the tether can need a manual `ip neigh flush` or a
power-cycle after a re-enumeration; the `systemctl enable --now` above fixes
it (or replace the dispatcher hook with a udev-triggered oneshot to drop the
dependency). Verified on a systemd-networkd HIL host 2026-08-08: the tether
self-heals unattended across the chip's re-enumerations (interface, neighbor
table, DHCP lease, ping, and HTTP all recover).

**Caveat (both managers):** only the *first* unit plugged in gets
`esp-pstop0`; simultaneous extra units fall back to `enx<mac>` names and each
needs its own profile/match.

Background on why the naming rule (not just a MAC-keyed profile) is required
is in [`USB_NCM_STABILITY.md`](USB_NCM_STABILITY.md).

---

## Windows — notes (similar idea, not bench-verified here)

The concept is identical — the host must own the link and serve DHCP — but
the mechanism differs.

- **Driver:** Windows 10 (1809+) and Windows 11 bind the chip to the built-in
USB NCM class driver automatically; it appears as an ordinary Ethernet
adapter. No vendor driver is needed. On older Windows a CDC-NCM/RNDIS
driver may have to be supplied.
- **Serving the link:** enable **Internet Connection Sharing (ICS)** on an
Internet-facing adapter and share it *to* the pstop adapter. ICS then runs
a DHCP server and NATs the link.
- **Subnet gotcha:** ICS hands out **`192.168.137.0/24`** with the host at
`192.168.137.1` — **not** `10.42.0.1`. The chip will get an address and the
link will be up, but its factory-default machine peer (`10.42.0.1:8890`)
will not match. Either repoint the chip
(`POST /api/pstop_peer?ip=192.168.137.1&port=8890`) or change the ICS
subnet to `10.42.0.0/24` via the registry
(`HKLM\SYSTEM\CurrentControlSet\Services\SharedAccess\Parameters`,
`ScopeAddress` / `ScopeAddressBackup`) and restart the service.
- **Static alternative:** instead of ICS, give the pstop adapter a static
`10.42.0.1/24` and run any small DHCP server bound to it. The chip needs a
DHCP lease; it will not self-assign.
- Reach the admin UI at the chip's leased address, or configure the machine
runner to listen on the host's tether address.

## macOS — notes (similar idea, not bench-verified here)

- **Driver:** macOS binds CDC-NCM natively; the pstop shows up as a network
service (often "USB 10/100/1000 LAN" or similar) in *System Settings →
Network*.
- **Serving the link:** *System Settings → General → Sharing → Internet
Sharing*, share your internet connection **to** the pstop's USB network
service. macOS Internet Sharing runs `bootpd` (DHCP) and NATs the link.
- **Subnet gotcha:** macOS Internet Sharing also defaults to a
`192.168.x.0/24` range, not `10.42.0.1`. As on Windows, either repoint the
chip's peer with `POST /api/pstop_peer?...` or override the `bootpd` scope
(`/etc/bootpd.plist`) to `10.42.0.0/24`.
- **Static alternative:** manually set the pstop network service to
`10.42.0.1/255.255.255.0` and run a DHCP server (e.g. `dnsmasq` from
Homebrew) bound to that interface. The chip still needs a lease.

---

## Verify (any host)

```sh
# host has the tether address
ip addr show esp-pstop0 # Linux: expect 10.42.0.1/24
# chip answers on its leased address (Linux shared mode → 10.42.0.x)
curl -s http://10.42.0.1/state.json | grep -o '"active_iface":[0-9]' # from the chip's side use its IP
```

On the chip, `state.json` reports `active_iface` (`2` = USB-NCM) and
`usbncm_en`/`usb_ip`. A healthy tether shows the active uplink on USB and,
if a `machine_app_runner` is listening at the host's tether address, a bonded
machine session.

If the link comes up but never bonds, it is almost always the **subnet
gotcha** above: the host handed out a range other than `10.42.0.0/24` while
the chip is still pointed at its `10.42.0.1:8890` default.
6 changes: 5 additions & 1 deletion firmware/components/dcs_support/src/dcs_admin_pages.c
Original file line number Diff line number Diff line change
Expand Up @@ -216,7 +216,7 @@ static esp_err_t page_state(httpd_req_t * req)
"\"gpio_cfg_fault\":%lu,"
"\"load0\":%lu,\"load1\":%lu,"
"\"e_hi0\":%lu,\"e_lo0\":%lu,\"e_hi1\":%lu,\"e_lo1\":%lu,"
"\"active_iface\":%d,\"eth_link\":%d,"
"\"active_iface\":%d,\"net_sup_kicks\":%lu,\"eth_link\":%d,"
"\"eth_recoveries\":%lu,\"eth_rec_r1\":%lu,\"eth_rec_r2\":%lu,\"eth_rec_r3\":%lu,"
"\"eth_rec_reason\":%lu,\"eth_spi_err\":%lu,"
"\"eth_en\":%d,\"wifi_en\":%d,\"usbncm_en\":%d,"
Expand Down Expand Up @@ -256,6 +256,7 @@ static esp_err_t page_state(httpd_req_t * req)
(unsigned long)atomic_load(&g_dcs_estop_high_ok[1]),
(unsigned long)atomic_load(&g_dcs_estop_low_ok[1]),
(int)atomic_load(&g_dcs_active_iface),
(unsigned long)dcs_net_supervisor_kicks(),
dcs_eth_link_up() ? 1 : 0,
(unsigned long)atomic_load(&g_dcs_eth_recoveries),
(unsigned long)atomic_load(&g_dcs_eth_rec_r1),
Expand Down Expand Up @@ -517,6 +518,9 @@ static esp_err_t api_wifi_tx_power(httpd_req_t * req)
(void)httpd_resp_set_type(req, "application/json");
return httpd_resp_sendstr(req, "{\"ok\":false,\"error\":\"esp_wifi_set_max_tx_power failed\"}");
}
/* Persist so the override survives power loss; re-applied at WIFI_EVENT_STA_START
* (dcs_wifi.c). Best-effort: a failed NVS write must not fail an applied change. */
(void)dcs_nvs_write_wifi_tx_power((uint8_t)q);
int8_t now_q = 0;
(void)esp_wifi_get_max_tx_power(&now_q);
char buf[48];
Expand Down
Loading
Loading