diff --git a/README.md b/README.md index be207344..8adfb3b2 100644 --- a/README.md +++ b/README.md @@ -125,11 +125,13 @@ curl -X POST "http:///api/pstop_peer?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. @@ -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 @@ -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 @@ -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 @@ -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. diff --git a/docs/CRYPTO_SPEEDUP_REPORT.md b/docs/CRYPTO_SPEEDUP_REPORT.md index 54929ec6..23673f74 100644 --- a/docs/CRYPTO_SPEEDUP_REPORT.md +++ b/docs/CRYPTO_SPEEDUP_REPORT.md @@ -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 diff --git a/docs/MISRA_COMPLIANCE_2026-07-21.md b/docs/MISRA_COMPLIANCE_2026-07-21.md index 1b802653..65592ed6 100644 --- a/docs/MISRA_COMPLIANCE_2026-07-21.md +++ b/docs/MISRA_COMPLIANCE_2026-07-21.md @@ -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) @@ -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. diff --git a/docs/MULTI_REMOTE_VALIDATION_2026-07-22.md b/docs/MULTI_REMOTE_VALIDATION_2026-07-22.md index 75c5e5de..e41bcb92 100644 --- a/docs/MULTI_REMOTE_VALIDATION_2026-07-22.md +++ b/docs/MULTI_REMOTE_VALIDATION_2026-07-22.md @@ -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. diff --git a/docs/USB_NCM_SETUP.md b/docs/USB_NCM_SETUP.md new file mode 100644 index 00000000..9726ac0e --- /dev/null +++ b/docs/USB_NCM_SETUP.md @@ -0,0 +1,179 @@ + + +# 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=&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` 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` 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. diff --git a/firmware/components/dcs_support/src/dcs_admin_pages.c b/firmware/components/dcs_support/src/dcs_admin_pages.c index a804fe1d..754f0b57 100644 --- a/firmware/components/dcs_support/src/dcs_admin_pages.c +++ b/firmware/components/dcs_support/src/dcs_admin_pages.c @@ -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," @@ -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), @@ -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]; diff --git a/firmware/components/dcs_support/src/dcs_internal.h b/firmware/components/dcs_support/src/dcs_internal.h index 3f5352ca..84ae8225 100644 --- a/firmware/components/dcs_support/src/dcs_internal.h +++ b/firmware/components/dcs_support/src/dcs_internal.h @@ -37,6 +37,7 @@ extern "C" #define DCS_NVS_KEY_RING_OFF "ring_off" /* ring rotation: physical index of LED 1 */ #define DCS_NVS_KEY_PSTOP_PEERS "ps_peers" /* multi-machine peer table (blob, see dcs_nvs.c) */ #define DCS_NVS_KEY_OPERATORS "operators" /* operator allowlist (blob: count byte + u32 ids) */ +#define DCS_NVS_KEY_WIFI_TXP "wifi_txp" /* WiFi max TX power, quarter-dBm (8..84); 0/absent = config default */ #define DCS_RST_HIST_LEN 16 @@ -273,6 +274,12 @@ extern "C" * assembled unit, 2026-07-24). Absent -> 0. */ uint8_t dcs_nvs_read_ring_offset(void); esp_err_t dcs_nvs_write_ring_offset(uint8_t off); + /* WiFi max TX power persisted across power loss, in quarter-dBm (matches + * esp_wifi_set_max_tx_power units; valid 8..84). Read returns 0 when unset, + * meaning "leave the microlink config default (20 dBm) in force". Applied at + * WIFI_EVENT_STA_START so a runtime override survives reboot. */ + uint8_t dcs_nvs_read_wifi_tx_power(void); + esp_err_t dcs_nvs_write_wifi_tx_power(uint8_t quarter_dbm); /* Multi-machine peer table (ps_peers blob). One record per slot. Read * falls back to migrating the legacy ps_ip/ps_port pair into slot 0 when @@ -328,6 +335,7 @@ extern "C" /* dcs_net_supervisor.c */ void dcs_net_supervisor_start(void); /* 1 Hz interface-priority enforcer */ + unsigned dcs_net_supervisor_kicks(void); /* count of link-down event-driven wakes (telemetry) */ /* dcs_rgb.c — onboard WS2812 (GPIO21): colour=active iface, blink=IP last octet. */ void dcs_rgb_start(void); diff --git a/firmware/components/dcs_support/src/dcs_net_supervisor.c b/firmware/components/dcs_support/src/dcs_net_supervisor.c index 5e69d9ff..388f1f02 100644 --- a/firmware/components/dcs_support/src/dcs_net_supervisor.c +++ b/firmware/components/dcs_support/src/dcs_net_supervisor.c @@ -28,14 +28,25 @@ #include #include "dcs_internal.h" +#include "esp_eth.h" +#include "esp_event.h" #include "esp_log.h" #include "esp_netif.h" +#include "esp_timer.h" +#include "esp_wifi.h" #include "freertos/FreeRTOS.h" #include "freertos/task.h" #include "ml_app.h" static const char * TAG = "dcs_netsup"; +/* Event-driven demote: a link-DOWN event notifies the supervisor task so it + * re-arbitrates the default route immediately, instead of waiting up to one + * poll period. Only DOWN transitions kick — UP/promote stays on the periodic + * loop for natural anti-flap debounce (asymmetric by design). */ +static TaskHandle_t s_sup_task = NULL; +static atomic_uint s_link_down_kicks = 0; + #define SUPERVISOR_PERIOD_MS 1000 /* Failover-ladder timing (supervisor ticks ≈ seconds). The supervisor drives @@ -83,6 +94,26 @@ static esp_netif_t * netif_for(dcs_iface_t code) return esp_netif_get_handle_from_ifkey(dcs_iface_ifkey(code)); } +/* Link-DOWN event handler (runs in the esp_event task). Wakes the supervisor + * so it demotes off the dead link this instant rather than up to one period + * later. Route arbitration is idempotent, so a spurious kick is harmless. */ +static void link_down_kick(void * arg, esp_event_base_t base, int32_t id, void * data) +{ + (void)arg; + (void)base; + (void)id; + (void)data; + atomic_fetch_add(&s_link_down_kicks, 1u); + if (s_sup_task != NULL) { + (void)xTaskNotifyGive(s_sup_task); /* task-context give (not ISR) */ + } +} + +unsigned dcs_net_supervisor_kicks(void) +{ + return atomic_load(&s_link_down_kicks); +} + static void supervisor_task(void * arg) { (void)arg; @@ -90,6 +121,11 @@ static void supervisor_task(void * arg) bool wifi_auto = false; /* WiFi tier is up because WE enabled it */ uint32_t eth_bad_s = 0; /* Eth-unusable streak (gates USB bring-up) */ uint32_t high_bad_s = 0, high_ok_s = 0; /* (Eth&&USB) down / (Eth||USB) up */ + /* The ladder streaks below count in *seconds*. Decouple that cadence from the + * wake frequency (link-down events can wake us mid-period) by advancing the + * ladder only when a real period has elapsed — route arbitration still runs + * on every wake, which is the whole point of the instant demote. */ + int64_t last_ladder_us = esp_timer_get_time(); for (;;) { esp_netif_t * eth = netif_for(DCS_IFACE_ETH); @@ -130,22 +166,29 @@ static void supervisor_task(void * arg) /* no uplink and no SoftAP: stays DCS_IFACE_NONE */ } - /* --- Streaks driving the ladder. --- */ - if (eth_ok) { - eth_bad_s = 0u; - } else { - eth_bad_s++; - } - bool high_ok = eth_ok || usb_ok; - if (high_ok) { - high_ok_s++; - high_bad_s = 0u; - } else { - high_bad_s++; - high_ok_s = 0u; - } + /* Advance the seconds-based ladder only when a real period has elapsed, so + * event-driven wakes (the instant demote) don't over-count the streaks. + * Route arbitration above already ran on this (possibly event-driven) wake. */ + const int64_t now_us = esp_timer_get_time(); + if ((now_us - last_ladder_us) >= ((int64_t)SUPERVISOR_PERIOD_MS * 1000)) { + last_ladder_us = now_us; + + /* --- Streaks driving the ladder. --- */ + if (eth_ok) { + eth_bad_s = 0u; + } else { + eth_bad_s++; + } + bool high_ok = eth_ok || usb_ok; + if (high_ok) { + high_ok_s++; + high_bad_s = 0u; + } else { + high_bad_s++; + high_ok_s = 0u; + } - /* --- USB-NCM tier: bring up when Ethernet is gone, then LEAVE IT UP. --- + /* --- USB-NCM tier: bring up when Ethernet is gone, then LEAVE IT UP. --- * We deliberately never auto-drop USB-NCM. Runtime TinyUSB teardown * (ml_dev_tether_stop -> destroy netif -> next bring-up re-runs * tinyusb_net_init) leaked internal heap and raced on_usb_rx against @@ -155,29 +198,30 @@ static void supervisor_task(void * arg) * arbitration above already demotes it to a dormant backup the instant * Ethernet returns (Eth route_prio 128 > USB-NCM 110). Robustness over a * few reclaimed KB on a safety device. */ - if ((!eth_ok) && (eth_bad_s >= USB_FAILOVER_AFTER_S) && (!dcs_usb_is_enabled())) { - ESP_LOGW(TAG, "no Ethernet for %us — bringing up USB-NCM (stays resident)", (unsigned)eth_bad_s); - (void)dcs_usb_set_enabled(true); - } + if ((!eth_ok) && (eth_bad_s >= USB_FAILOVER_AFTER_S) && (!dcs_usb_is_enabled())) { + ESP_LOGW(TAG, "no Ethernet for %us — bringing up USB-NCM (stays resident)", (unsigned)eth_bad_s); + (void)dcs_usb_set_enabled(true); + } - /* --- WiFi tier — only when dcs_wifi owns the WiFi lifecycle (an + /* --- WiFi tier — only when dcs_wifi owns the WiFi lifecycle (an * alt-network won at boot). When ml_app owns WiFi (boot fell through to * it) ml_app also drives the SoftAP fallback, so we never touch it. --- */ - if (dcs_boot_alt_network_won()) { - if ((!high_ok) && (high_bad_s >= WIFI_FAILOVER_AFTER_S) && (!dcs_wifi_is_enabled())) { - ESP_LOGW(TAG, "no Eth/USB for %us — bringing up WiFi", (unsigned)high_bad_s); - if (dcs_wifi_set_enabled(true) == ESP_OK) { - wifi_auto = true; - } - } else if (high_ok && (high_ok_s >= FAILOVER_DROP_HOLD_S) && wifi_auto && dcs_wifi_is_enabled()) { - ESP_LOGW(TAG, "Eth/USB stable %us — dropping WiFi", (unsigned)high_ok_s); - if (dcs_wifi_set_enabled(false) == ESP_OK) { - wifi_auto = false; + if (dcs_boot_alt_network_won()) { + if ((!high_ok) && (high_bad_s >= WIFI_FAILOVER_AFTER_S) && (!dcs_wifi_is_enabled())) { + ESP_LOGW(TAG, "no Eth/USB for %us — bringing up WiFi", (unsigned)high_bad_s); + if (dcs_wifi_set_enabled(true) == ESP_OK) { + wifi_auto = true; + } + } else if (high_ok && (high_ok_s >= FAILOVER_DROP_HOLD_S) && wifi_auto && dcs_wifi_is_enabled()) { + ESP_LOGW(TAG, "Eth/USB stable %us — dropping WiFi", (unsigned)high_ok_s); + if (dcs_wifi_set_enabled(false) == ESP_OK) { + wifi_auto = false; + } + } else { + /* ladder idle this tick */ } - } else { - /* ladder idle this tick */ } - } + } /* end elapsed-gated ladder */ if (best_code != last_logged) { ESP_LOGI(TAG, "active interface: %s", best_lbl); @@ -185,7 +229,10 @@ static void supervisor_task(void * arg) } atomic_store(&g_dcs_active_iface, (int)best_code); - vTaskDelay(pdMS_TO_TICKS(SUPERVISOR_PERIOD_MS)); + /* Wake on a link-down kick (instant re-arbitrate / demote) OR the periodic + * poll, whichever comes first. pdTRUE clears the notification count so a + * burst of events collapses into one re-arbitration pass. */ + (void)ulTaskNotifyTake(pdTRUE, pdMS_TO_TICKS(SUPERVISOR_PERIOD_MS)); } } @@ -196,5 +243,14 @@ void dcs_net_supervisor_start(void) * esp_netif create/destroy) on auto-failover, which needs more stack than * the bare route-arbitration loop. */ /* PSRAM stack: route supervisor is non-safety, does no flash/NVS. */ - (void)dcs_task_spawn_psram(supervisor_task, "net_sup", 4608, NULL, 4, tskNO_AFFINITY); + s_sup_task = dcs_task_spawn_psram(supervisor_task, "net_sup", 4608, NULL, 4, tskNO_AFFINITY); + + /* Event-driven demote: wake the supervisor the instant a link drops so it + * re-arbitrates off the dead link now, not up to one poll period later. + * DOWN transitions only — promote/UP stays on the periodic loop (anti-flap). + * Extra handlers coexist with dcs_eth / dcs_wifi's own; esp_event fans out. */ + (void)esp_event_handler_instance_register(ETH_EVENT, ETHERNET_EVENT_DISCONNECTED, link_down_kick, NULL, NULL); + (void)esp_event_handler_instance_register(WIFI_EVENT, WIFI_EVENT_STA_DISCONNECTED, link_down_kick, NULL, NULL); + (void)esp_event_handler_instance_register(IP_EVENT, IP_EVENT_ETH_LOST_IP, link_down_kick, NULL, NULL); + (void)esp_event_handler_instance_register(IP_EVENT, IP_EVENT_STA_LOST_IP, link_down_kick, NULL, NULL); } diff --git a/firmware/components/dcs_support/src/dcs_nvs.c b/firmware/components/dcs_support/src/dcs_nvs.c index 1d9ccec4..e00d3e94 100644 --- a/firmware/components/dcs_support/src/dcs_nvs.c +++ b/firmware/components/dcs_support/src/dcs_nvs.c @@ -14,6 +14,8 @@ * ring_off u8 LED-ring rotation: physical pixel index of LED 1 (default 0) * ps_peers blob multi-machine peer table: version byte + per-slot records * (absent -> migrate legacy ps_ip/ps_port into slot 0) + * operators blob operator allowlist: count byte + u32 ids + * wifi_txp u8 WiFi max TX power, quarter-dBm (8..84); 0/absent = config default */ #include @@ -197,6 +199,33 @@ esp_err_t dcs_nvs_write_ring_offset(uint8_t off) return r; } +uint8_t dcs_nvs_read_wifi_tx_power(void) +{ + nvs_handle_t h; + if (nvs_open(DCS_NVS_NS, NVS_READONLY, &h) != ESP_OK) return 0; + uint8_t v = 0; /* absent -> 0 = "use config default" */ + (void)nvs_get_u8(h, DCS_NVS_KEY_WIFI_TXP, &v); + nvs_close(h); + /* Reject out-of-range persisted values (corruption / older schema) rather + * than pushing an invalid level into esp_wifi_set_max_tx_power. */ + if ((v < 8u) || (v > 84u)) return 0; + return v; +} + +esp_err_t dcs_nvs_write_wifi_tx_power(uint8_t quarter_dbm) +{ + if ((quarter_dbm < 8u) || (quarter_dbm > 84u)) return ESP_ERR_INVALID_ARG; + nvs_handle_t h; + esp_err_t r = nvs_open(DCS_NVS_NS, NVS_READWRITE, &h); + if (r != ESP_OK) return r; + r = nvs_set_u8(h, DCS_NVS_KEY_WIFI_TXP, quarter_dbm); + if (r == ESP_OK) { + r = nvs_commit(h); + } + nvs_close(h); + return r; +} + /* ps_peers blob layout (byte-serialized, no struct padding on the wire): * [0] format version (1) * per slot, DCS_PSTOP_MAX_MACHINES records of 11 bytes: diff --git a/firmware/components/dcs_support/src/dcs_wifi.c b/firmware/components/dcs_support/src/dcs_wifi.c index a43ef1de..ba95318f 100644 --- a/firmware/components/dcs_support/src/dcs_wifi.c +++ b/firmware/components/dcs_support/src/dcs_wifi.c @@ -51,7 +51,7 @@ static atomic_bool s_want_up = false; /* admin intent — survives a NO_MEM */ static bool s_inited = false; /* driver + netif created by us */ static esp_netif_t * s_sta = NULL; static ml_config_wifi_list_t * s_list = NULL; /* configured networks (PSRAM) */ -static int s_idx = 0; /* which list entry we're trying */ +static atomic_int s_idx = 0; /* which list entry we're trying (atomic: read by dcs_wifi_status from the httpd task) */ static atomic_int s_disc_reason = 0; /* last STA_DISCONNECTED reason */ static atomic_int s_connected = 0; /* associated (pre-DHCP) flag */ static esp_timer_handle_t s_reconnect_timer = NULL; /* one-shot paced retry */ @@ -71,7 +71,7 @@ void dcs_wifi_status(int * reason, int * connected, int * idx, int * count) *count = (s_list != NULL) ? s_list->count : 0; } if (idx != NULL) { - *idx = ((s_list != NULL) && (s_list->count != 0)) ? (s_idx % s_list->count) : -1; + *idx = ((s_list != NULL) && (s_list->count != 0)) ? (atomic_load(&s_idx) % s_list->count) : -1; } } @@ -81,7 +81,7 @@ static void apply_creds_and_connect(void) { char ssid[33] = {0}, pass[65] = {0}; if ((s_list != NULL) && (s_list->count > 0)) { - int i = s_idx % s_list->count; + int i = atomic_load(&s_idx) % s_list->count; (void)snprintf(ssid, sizeof(ssid), "%s", s_list->entries[i].ssid); (void)snprintf(pass, sizeof(pass), "%s", s_list->entries[i].pass); } else { @@ -159,6 +159,13 @@ static void on_wifi_evt(void * arg, esp_event_base_t base, int32_t id, void * da } if (id == (int32_t)WIFI_EVENT_STA_START) { s_backoff_ms = WIFI_BACKOFF_MIN_MS; + /* Re-apply a persisted WiFi TX-power override (survives power loss). 0 = + * unset -> leave the microlink config default (applied at ml start) alone. + * Valid only after WiFi start, which STA_START guarantees. */ + uint8_t txp_q = dcs_nvs_read_wifi_tx_power(); + if (txp_q != 0u) { + (void)esp_wifi_set_max_tx_power((int8_t)txp_q); + } apply_creds_and_connect(); } else if (id == (int32_t)WIFI_EVENT_STA_DISCONNECTED) { atomic_store(&s_connected, 0); @@ -166,7 +173,7 @@ static void on_wifi_evt(void * arg, esp_event_base_t base, int32_t id, void * da atomic_store(&s_disc_reason, ((wifi_event_sta_disconnected_t *)data)->reason); } if ((s_list != NULL) && (s_list->count > 1)) { - s_idx++; /* try the next network */ + (void)atomic_fetch_add(&s_idx, 1); /* try the next network */ } schedule_reconnect(); /* paced retry, not thrash */ } else { @@ -248,7 +255,7 @@ static esp_err_t wifi_set_enabled_locked(bool on) if ((s_list != NULL) && (!ml_config_get_wifi_list(s_list))) { s_list->count = 0; } - s_idx = 0; + atomic_store(&s_idx, 0); esp_err_t merr = esp_wifi_set_mode(WIFI_MODE_STA); if (merr != ESP_OK) { diff --git a/firmware/test/test_estop_verdict.c b/firmware/test/test_estop_verdict.c index 4db58d6a..45ec5c46 100644 --- a/firmware/test/test_estop_verdict.c +++ b/firmware/test/test_estop_verdict.c @@ -151,6 +151,43 @@ int main(void) #undef ALLTRUE } + // 7. EMC-blip / spurious-transient-close resistance — the software mitigation + // for the WiFi-TX auto-arm anomaly (a WiFi transmit burst momentarily + // coupling into the open E-stop loop harness and being read as CLOSED). + // Such a transient must NEVER manufacture an OK/arm while the operator is + // holding STOP; only a genuine sustained close (>= debounce) may arm. + // Envelope: this defends transients shorter than LOOP_RECLOSE_DEBOUNCE_TICKS + // (release debounce). A hypothetical coupling sustained for the full + // debounce window is out of scope here and is covered by the machine-side + // min-STOP re-arm gesture (a separate layer, pstop_c). + for (int core = 0; core < 2; core++) { + // (a) Every sub-threshold blip length, bracketed by opens, stays STOP. + for (int blip = 1; blip < (int)LOOP_RECLOSE_DEBOUNCE_TICKS; blip++) { + estop_state_t s = fresh(); + CHECK(estop_decide(&s, core, 0, 0) == PSTOP_MESSAGE_STOP, "blip: baseline open STOP (not armed)"); + for (int t = 0; t < blip; t++) { + CHECK(estop_decide(&s, core, 1, 0) == PSTOP_MESSAGE_STOP, "sub-threshold spurious close never OK"); + } + CHECK(estop_decide(&s, core, 0, 0) == PSTOP_MESSAGE_STOP, "post-blip open STOP"); + } + // (b) A flapping burst (repeated (debounce-1)-close then open) never arms — + // each open resets the closed streak, so the threshold is never reached. + estop_state_t f = fresh(); + for (int i = 0; i < 50; i++) { + for (int t = 0; t < (int)LOOP_RECLOSE_DEBOUNCE_TICKS - 1; t++) { + CHECK(estop_decide(&f, core, 1, 0) == PSTOP_MESSAGE_STOP, "flap close never OK"); + } + CHECK(estop_decide(&f, core, 0, 0) == PSTOP_MESSAGE_STOP, "flap open STOP"); + } + // (c) The mitigation doesn't wedge legitimate arming: after all that + // flapping, a genuine sustained close still arms exactly at threshold. + estop_state_t g = fresh(); + for (int t = 0; t < (int)LOOP_RECLOSE_DEBOUNCE_TICKS - 1; t++) { + CHECK(estop_decide(&g, core, 1, 0) == PSTOP_MESSAGE_STOP, "genuine close pre-threshold STOP"); + } + CHECK(estop_decide(&g, core, 1, 0) == PSTOP_MESSAGE_OK, "genuine sustained close arms at threshold"); + } + printf("estop_verdict host tests: %d checks, %d failures\n", g_checks, g_fails); return g_fails ? 1 : 0; } diff --git a/host/README.md b/host/README.md index 2884b6f6..6b49a7d3 100644 --- a/host/README.md +++ b/host/README.md @@ -71,6 +71,11 @@ 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 +> [`docs/USB_NCM_SETUP.md`](../docs/USB_NCM_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 diff --git a/host/setup/80-esp-pstop.network b/host/setup/80-esp-pstop.network new file mode 100644 index 00000000..a6bfa83a --- /dev/null +++ b/host/setup/80-esp-pstop.network @@ -0,0 +1,33 @@ +# SPDX-FileCopyrightText: 2026 Polymath Robotics +# SPDX-License-Identifier: Apache-2.0 +# +# systemd-networkd equivalent of the NetworkManager "shared" profile for the +# pstop USB-NCM tether: the HOST owns the link (static address + built-in DHCP +# server + NAT the chip's traffic out). Use this on hosts running +# systemd-networkd (no NetworkManager). Install to /etc/systemd/network/. +# +# Matches the stable name that 70-esp-pstop.link assigns; that rename applies on +# the tether's next re-enumeration (power-cycle/replug), so until then the chip +# still appears as enx — replug once after install. +# +# Subnet note: 10.42.0.0/24 matches the chip's factory-default machine peer +# (10.42.0.1:8890), so a machine_app_runner ON THIS HOST auto-bonds with no +# config. When the host is only an uplink (the chip bonds to a *remote* machine +# over Tailscale, e.g. a HIL/fleet host), the subnet is arbitrary — any /24 +# works because the link is just NAT'd out. +[Match] +Name=esp-pstop0 + +[Network] +Address=10.42.0.1/24 +DHCPServer=yes +IPMasquerade=ipv4 +# Bring the address/DHCP up even before the chip has enumerated/carrier is up, +# so the tether is ready the instant the chip re-appears (self-heal across the +# chip's re-enumerations). +ConfigureWithoutCarrier=yes + +[DHCPServer] +PoolOffset=100 +PoolSize=50 +EmitDNS=yes diff --git a/host/setup/esp-pstop-flush-networkd b/host/setup/esp-pstop-flush-networkd new file mode 100755 index 00000000..ee38969b --- /dev/null +++ b/host/setup/esp-pstop-flush-networkd @@ -0,0 +1,19 @@ +#!/bin/sh +# SPDX-FileCopyrightText: 2026 Polymath Robotics +# SPDX-License-Identifier: Apache-2.0 +# +# networkd-dispatcher port of 90-esp-pstop-flush (the NetworkManager version): +# clear the stale ARP/neighbor cache when the pstop tether changes state, so the +# host doesn't sit on an INCOMPLETE neighbor from a previous boot (one half of +# the "needs a power cycle" symptom). +# +# networkd-dispatcher runs per-state hooks and passes the interface in $IFACE +# (not $1/$2 like the NM dispatcher). Install this SAME script into BOTH +# /etc/networkd-dispatcher/routable.d/ and .../degraded.d/ so it fires on the +# tether coming up and on the chip's re-enumeration. +# +# GOTCHA: the networkd-dispatcher package can be installed but its service +# DISABLED — then a correctly-placed hook never fires. install.sh runs +# `systemctl enable --now networkd-dispatcher`; verify it is active. +[ "$IFACE" = "esp-pstop0" ] && ip neigh flush dev esp-pstop0 +exit 0 diff --git a/host/setup/install.sh b/host/setup/install.sh index 6d52843b..53d9489a 100755 --- a/host/setup/install.sh +++ b/host/setup/install.sh @@ -2,24 +2,67 @@ # SPDX-FileCopyrightText: 2026 Polymath Robotics # SPDX-License-Identifier: Apache-2.0 # Install the host-side USB-NCM stability config for the pstop tether. -# Pure host config — no device reflash. Idempotent. +# Pure host config — no device reflash. Idempotent. Auto-detects the host's +# network manager and installs the matching variant: +# - NetworkManager -> shared-mode nmcli profile + NM dispatcher flush hook +# - systemd-networkd -> .network (DHCPServer+NAT) + networkd-dispatcher flush +# The 70-esp-pstop.link interface-naming rule is manager-agnostic (always). set -e D="$(cd "$(dirname "$0")" && pwd)" + +# --- manager-agnostic: stable interface name ------------------------------- sudo cp "$D/70-esp-pstop.link" /etc/systemd/network/70-esp-pstop.link -sudo cp "$D/90-esp-pstop-flush" /etc/NetworkManager/dispatcher.d/90-esp-pstop-flush -sudo chmod +x /etc/NetworkManager/dispatcher.d/90-esp-pstop-flush -sudo mkdir -p /etc/NetworkManager/dnsmasq-shared.d -echo "dhcp-authoritative" | sudo tee /etc/NetworkManager/dnsmasq-shared.d/esp-pstop.conf >/dev/null sudo udevadm control --reload -# NM profile bound to the STABLE name, autoconnect, shared IPv4. -if ! nmcli -g NAME connection show | grep -qx esp-pstop; then - sudo nmcli connection add type ethernet con-name esp-pstop ifname esp-pstop0 \ - ipv4.method shared ipv6.method ignore connection.autoconnect yes +nm_active() { command -v nmcli >/dev/null 2>&1 && systemctl is-active --quiet NetworkManager 2>/dev/null; } +networkd_active() { systemctl is-active --quiet systemd-networkd 2>/dev/null; } + +if nm_active; then + echo "== Detected NetworkManager — installing the NM variant ==" + sudo cp "$D/90-esp-pstop-flush" /etc/NetworkManager/dispatcher.d/90-esp-pstop-flush + sudo chmod +x /etc/NetworkManager/dispatcher.d/90-esp-pstop-flush + sudo mkdir -p /etc/NetworkManager/dnsmasq-shared.d + echo "dhcp-authoritative" | sudo tee /etc/NetworkManager/dnsmasq-shared.d/esp-pstop.conf >/dev/null + # NM profile bound to the STABLE name, autoconnect, shared IPv4. + if ! nmcli -g NAME connection show | grep -qx esp-pstop; then + sudo nmcli connection add type ethernet con-name esp-pstop ifname esp-pstop0 \ + ipv4.method shared ipv6.method ignore connection.autoconnect yes + else + sudo nmcli connection modify esp-pstop connection.interface-name esp-pstop0 \ + ipv4.method shared ipv6.method ignore connection.autoconnect yes + fi + VARIANT="NetworkManager (shared profile + dispatcher flush)" + +elif networkd_active; then + echo "== Detected systemd-networkd — installing the networkd variant ==" + sudo cp "$D/80-esp-pstop.network" /etc/systemd/network/80-esp-pstop.network + # Neighbor-cache flush hook in BOTH state dirs (fires on up + re-enumerate). + for st in routable degraded; do + sudo mkdir -p "/etc/networkd-dispatcher/$st.d" + sudo cp "$D/esp-pstop-flush-networkd" "/etc/networkd-dispatcher/$st.d/esp-pstop-flush" + sudo chmod +x "/etc/networkd-dispatcher/$st.d/esp-pstop-flush" + done + # GOTCHA: the package can be present but the service DISABLED -> the hook + # never fires. Enable it (best-effort; warn if the package is missing). + if systemctl list-unit-files 2>/dev/null | grep -q '^networkd-dispatcher'; then + sudo systemctl enable --now networkd-dispatcher || true + else + echo " WARN: networkd-dispatcher not installed — the neighbor-cache flush" + echo " hook will NOT run (one half of the 'needs a power cycle' symptom)." + echo " Install it (e.g. apt-get install networkd-dispatcher) and re-run." + fi + sudo networkctl reload 2>/dev/null || sudo systemctl restart systemd-networkd || true + VARIANT="systemd-networkd (.network DHCPServer+NAT + networkd-dispatcher flush)" + else - sudo nmcli connection modify esp-pstop connection.interface-name esp-pstop0 \ - ipv4.method shared ipv6.method ignore connection.autoconnect yes + echo "ERROR: neither NetworkManager nor systemd-networkd is active — cannot" + echo "install the tether's shared-link config automatically. The 70-*.link" + echo "naming rule was installed; configure a shared/DHCP link on esp-pstop0" + echo "manually (static 10.42.0.1/24 + a DHCP server + NAT). See docs/USB_NCM_SETUP.md." + exit 1 fi -echo "Installed. Renaming applies on the tether's next re-enumeration" + +echo "Installed variant: $VARIANT" +echo "Renaming/config applies on the tether's next re-enumeration" echo "(power-cycle or replug the chip). The old enx binding is left" echo "untouched so the current connection is not disrupted."