diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e6629aef..2ab23acc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -68,7 +68,9 @@ jobs: with: node-version: 22 cache: npm - cache-dependency-path: ui/package-lock.json + cache-dependency-path: | + ui/package-lock.json + packages/tiny-engineer-cli/package-lock.json - name: Check OLED expression assets run: | node scripts/expressions/generate.js --check @@ -79,7 +81,9 @@ jobs: npm run lint --prefix ui npm test --prefix ui - name: Unified CLI package tests - run: npm test --prefix packages/tiny-engineer-cli + run: | + npm ci --prefix packages/tiny-engineer-cli + npm test --prefix packages/tiny-engineer-cli - name: Cursor package tests run: npm test --prefix packages/tiny-engineer-cursor - name: Antigravity package tests diff --git a/.gitignore b/.gitignore index 9fce2b4a..5e5521c6 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,8 @@ data/*.* data/*/ artifacts/ +node_modules/ + # Cursor hook event log cursor_events.log diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0022119f..f2f97c54 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -98,7 +98,7 @@ Format: `type(scope): summary` | `feat!:` / `fix!:` or footer `BREAKING CHANGE:` | MAJOR | | `docs`, `style`, `test`, `chore`, `ci`, `refactor` (unless breaking) | no bump | -Scopes: `firmware`, `http`, `settings`, `anim`, `servos`, `wifi`, `integrations`, `cad`, `mods`, `pcb`, `docs`, `scripts`, `ci`, `web`. +Scopes: `firmware`, `http`, `serial`, `settings`, `anim`, `servos`, `wifi`, `integrations`, `cad`, `mods`, `pcb`, `docs`, `scripts`, `ci`, `web`. `integrations` is anything under `packages/` (Cursor, Antigravity, Claude Code, later agent CLIs). Do not add a new scope per package. diff --git a/docs/README.md b/docs/README.md index 950e1f19..ec05f5a1 100644 --- a/docs/README.md +++ b/docs/README.md @@ -26,7 +26,7 @@ Pick a path. Depth lives in the linked pages. | You want to… | Start here | | --- | --- | | **Contribute a PCB** (KiCad) | [pcb.md](pcb.md) → [`hardware/`](../hardware/README.md) | -| **HTTP API / settings** | [api.md](api.md); add a setting: [settings.md](settings.md) | +| **HTTP API / settings** | [api.md](api.md); serial CDC: [serial-api.md](serial-api.md); add a setting: [settings.md](settings.md) | | **CLI setup / REST for any IDE** | [integration.md](integration.md) | | **Publish npm CLI** (`tiny-engineer`) | [cli-release.md](cli-release.md) | | **Firmware / package tests** | [testing.md](testing.md) | diff --git a/docs/api.md b/docs/api.md index 3ffc8f7e..c44b83cf 100644 --- a/docs/api.md +++ b/docs/api.md @@ -2,13 +2,15 @@ JSON HTTP server on the ESP32-C3. Source: [`src/http/http_server.cpp`](../src/http/http_server.cpp) and handlers under [`src/http/`](../src/http/), test routes in [`src/http/test_handlers.cpp`](../src/http/test_handlers.cpp). +**Serial transport:** the same JSON control ops are also available over USB CDC (always on, no Bearer). Framing and supported ops: [`serial-api.md`](serial-api.md). + Listens on **port 80** after STA Wi-Fi connects, or during setup AP mode at `http://192.168.4.1/`. Base URL is the board IP (OLED / serial) or `http://tiny-engineer.local` (mDNS, 2.4 GHz only). Optional auth: when an `access_token` is configured in settings, all JSON API routes require `Authorization: Bearer `. Empty token (default) means no auth. `GET /auth` is always public and reports whether auth is required. Missing/wrong token → **401** `{"ok":false,"error":"unauthorized"}`. HTML panel routes stay public (the UI prompts for the token). `Content-Type: application/json`. CORS: `Access-Control-Allow-Origin: *`, `Access-Control-Allow-Headers: Authorization`. -When WiFi credentials are not saved yet, control APIs (`/anim`, `/test/*`) return **503** `{"ok":false,"error":"wifi not configured"}`. Setup routes (`/`, `/config`, `/auth`, `/health`, `/settings`, `/setup/servo`, `/setup/led`, `/setup/audio`, `/setup/oled`) stay available on the setup AP. +Setting `wifi_enabled=0` (reboot required) boots with the Wi-Fi radio off: no STA, no setup AP, no HTTP/OTA. Control is USB CDC only — see [`serial-api.md`](serial-api.md). Setup calibration ops (`/setup/*` and setup-only settings fields) are allowed in that mode over serial. -If boot WiFi credentials are missing, the device opens setup AP mode (`TinyEngineer-XXXX`) and serves a five-step setup wizard at `/config` (servo calibration, OLED orientation, RGB LED, speaker test, then Wi-Fi). If saved credentials fail, it reopens setup AP mode. The setup AP acts as a captive portal: DHCP sends the portal URL (option 114) and DNS answers every name with the AP IP, so a phone that joins the network opens the wizard on its own. Hardware tests: [`docs/hardware/testing.md`](hardware/testing.md). How to wire this API into AI tools: [`integration.md`](integration.md). +If `wifi_enabled` is on (default) and boot WiFi credentials are missing, the device opens setup AP mode (`TinyEngineer-XXXX`) and serves a five-step setup wizard at `/config` (servo calibration, OLED orientation, RGB LED, speaker test, then Wi-Fi). If saved credentials fail, it reopens setup AP mode. The setup AP acts as a captive portal: DHCP sends the portal URL (option 114) and DNS answers every name with the AP IP, so a phone that joins the network opens the wizard on its own. Hardware tests: [`docs/hardware/testing.md`](hardware/testing.md). How to wire this API into AI tools: [`integration.md`](integration.md). Firmware answers mDNS A and AAAA (IPv6 link-local) so macOS does not wait 2–3s on a missing AAAA. If a client still pauses on the hostname, force IPv4 (`curl -4`). The IP on the OLED skips DNS entirely. @@ -16,7 +18,7 @@ Firmware answers mDNS A and AAAA (IPv6 link-local) so macOS does not wait 2–3s ### `GET /` -HTML endpoint index. Lists all routes plus supported parameters for `/anim`, `/settings`, `/test/servo`, `/setup/servo`, `/setup/led`, `/setup/audio`, and `/setup/oled`. Safe, no hardware side effects. Always public (no Bearer required). +HTML endpoint index. Lists all routes plus supported parameters for `/anim`, `/settings`, `/test/servo`, `/setup/servo`, `/setup/led`, `/setup/audio`, `/setup/oled`, and `/servos/park`. Safe, no hardware side effects. Always public (no Bearer required). Open in a browser: @@ -35,7 +37,7 @@ curl http://tiny-engineer.local/auth ``` ```json -{ "ok": true, "required": false, "wifi_configured": true, "provisioning": false } +{ "ok": true, "required": false, "wifi_configured": true, "wifi_enabled": true, "provisioning": false } ``` | Field | Meaning | @@ -43,6 +45,7 @@ curl http://tiny-engineer.local/auth | `ok` | Always `true` on this route | | `required` | `true` when a non-empty access token is configured | | `wifi_configured` | `true` when WiFi credentials are saved in NVS | +| `wifi_enabled` | `true` when Wi-Fi is enabled in settings (radio used at next boot) | | `provisioning` | `true` when the setup AP is active | ### `GET /health` @@ -69,6 +72,7 @@ curl -H "Authorization: Bearer YOUR_TOKEN" http://tiny-engineer.local/health "hostname": "tiny-engineer.local" }, "wifi_configured": true, + "wifi_enabled": true, "provisioning": false, "setup_ap_ssid": "", "setup_ap_ip": "", @@ -89,6 +93,7 @@ curl -H "Authorization: Bearer YOUR_TOKEN" http://tiny-engineer.local/health | `wifi.rssi` | dBm, or `0` if down | | `wifi.hostname` | mDNS name from settings (`{hostname}.local`, default `tiny-engineer.local`) | | `wifi_configured` | `true` when WiFi credentials are saved in NVS | +| `wifi_enabled` | Current settings value; boot uses the frozen boot copy (`settingsBootWifiEnabled`) | | `provisioning` | `true` when setup AP mode is active | | `setup_ap_ssid` | Setup AP SSID when `provisioning` is true, else `""` | | `setup_ap_ip` | Setup AP IP when `provisioning` is true (usually `192.168.4.1`), else `""` | @@ -110,6 +115,8 @@ curl http://tiny-engineer.local/settings "volume": 70, "welcome": true, "serial_log": false, + "servo_hold": false, + "wifi_enabled": true, "continuous_timeout": 5, "loading": "progress", "eyes_style": "classic", @@ -128,8 +135,10 @@ curl http://tiny-engineer.local/settings | `sleep_timeout` | Minutes of continuous animation `none` before OLED blanks (default **10**). Any non-idle animation keeps the device awake and restarts the idle clock when returning to `none`. Other APIs do not affect the timer. | | `hostname` | DHCP/mDNS label without `.local` (default **`tiny-engineer`**) | | `volume` | Speaker gain percent for tones and WAV playback (default **70**) | -| `welcome` | Play welcome animation on boot when Wi-Fi connects (default **true**). Also enables head/neck motion during `sleep_inertia` loading | +| `welcome` | Play welcome animation on boot when STA connects or when Wi-Fi is disabled (serial-only) (default **true**). Also enables head/neck motion during `sleep_inertia` loading | | `serial_log` | USB serial debug logging (default **false**). When off, diagnostic `Serial` output is suppressed | +| `servo_hold` | Keep PWM after idle/sleep park instead of limp full-off (default **false**). Reduces droop before flash; use with [`POST /servos/park`](#post-servospark) for flash-safe centering | +| `wifi_enabled` | When **false**, next boot turns the Wi-Fi radio off (no STA/AP/HTTP/OTA); USB serial is the only control path (default **true**). Applies on **next reboot** | | `continuous_timeout` | Minutes a continuous animation (`typing` / `reading` / `thinking` / `talking`) may run before the firmware switches to `attention` then idle (default **5**) | | `loading` | Boot loading screen: `progress` (bar + large IP for 3 s) or `sleep_inertia` (eyes wake; no bar/IP). Default **`progress`**. Applies on **next reboot** | | `eyes_style` | OLED eyes look: `classic` (procedural rounded eyes), `kaomoji` (animated faces), `cover` (full-half bars for an eye-hole mask), or `dots` (12×12 circles). Default **`classic`**. Applies **immediately**. Classic, cover, and dots sequences are described under [`POST /anim`](#post-anim); kaomoji face mapping is under [Kaomoji eyes](#kaomoji-eyes); cover drawing is under [Cover eyes](#cover-eyes); dots drawing is under [Dots eyes](#dots-eyes) | @@ -143,7 +152,7 @@ curl http://tiny-engineer.local/settings ### `POST /settings` -Update one or more settings. Query params. Values are written to NVS. `sleep_timeout`, `volume`, `welcome`, `serial_log`, `continuous_timeout`, `eyes_style`, and `access_token` apply immediately; a changed `hostname` or `loading` takes effect on the **next reboot**. WiFi networks are set with [`PUT /settings/wifi`](#put-settingswifi). Servo ranges (`servo_mins` + `servo_maxs`) are also **setup AP only**; they apply immediately to motion clamps. Outside setup AP → **400** `servo setup only in AP mode`. RGB LED mapping (`rgb_order`) is **setup AP only** and applies immediately to the onboard LED. Outside setup AP → **400** `rgb setup only in AP mode`. OLED rotation (`oled_rotate_180`) is **setup AP only** and applies immediately to the display. Outside setup AP → **400** `oled setup only in AP mode`. Requires Bearer when auth is enabled. +Update one or more settings. Query params. Values are written to NVS. `sleep_timeout`, `volume`, `welcome`, `serial_log`, `servo_hold`, `continuous_timeout`, `eyes_style`, and `access_token` apply immediately; a changed `hostname`, `loading`, or `wifi_enabled` takes effect on the **next reboot**. WiFi networks are set with [`PUT /settings/wifi`](#put-settingswifi). Servo ranges (`servo_mins` + `servo_maxs`) are **setup AP only** (or allowed when booted with `wifi_enabled=0`); they apply immediately to motion clamps. Outside those modes → **400** `servo setup only in AP mode`. RGB LED mapping (`rgb_order`) and OLED rotation (`oled_rotate_180`) follow the same rule. Requires Bearer when auth is enabled. ```bash curl -X POST "http://tiny-engineer.local/settings?sleep_timeout=2" @@ -151,6 +160,8 @@ curl -X POST "http://tiny-engineer.local/settings?hostname=desk-bot" curl -X POST "http://tiny-engineer.local/settings?volume=40" curl -X POST "http://tiny-engineer.local/settings?welcome=0" curl -X POST "http://tiny-engineer.local/settings?serial_log=1" +curl -X POST "http://tiny-engineer.local/settings?servo_hold=1" +curl -X POST "http://tiny-engineer.local/settings?wifi_enabled=0" curl -X POST "http://tiny-engineer.local/settings?continuous_timeout=10" curl -X POST "http://tiny-engineer.local/settings?loading=sleep_inertia" curl -X POST "http://tiny-engineer.local/settings?eyes_style=kaomoji" @@ -170,6 +181,8 @@ curl -X POST "http://tiny-engineer.local/settings?sleep_timeout=10&hostname=tiny "volume": 70, "welcome": true, "serial_log": false, + "servo_hold": false, + "wifi_enabled": true, "continuous_timeout": 5, "loading": "progress", "eyes_style": "classic", @@ -191,16 +204,18 @@ curl -X POST "http://tiny-engineer.local/settings?sleep_timeout=10&hostname=tiny | `volume` | integer | 0–100 percent | | `welcome` | integer | `0` or `1` (boot welcome animation) | | `serial_log` | integer | `0` or `1` (USB serial debug logging) | +| `servo_hold` | integer | `0` or `1` (skip idle/sleep PWM limp) | +| `wifi_enabled` | integer | `0` or `1` (Wi-Fi radio; applies on next reboot) | | `continuous_timeout` | integer | 1–1440 minutes (positive) | | `loading` | string | `progress` or `sleep_inertia` | | `eyes_style` | string | `classic`, `kaomoji`, `cover`, or `dots` | | `access_token` | string | 0–64 printable ASCII; empty string clears the token and disables auth | -| `servo_mins` | string | Five comma-separated integers `0`–`180` (`head,neck,left,right,body`); setup AP only; must be sent with `servo_maxs` | -| `servo_maxs` | string | Five comma-separated integers `0`–`180`; setup AP only; each value must be greater than the matching min | -| `rgb_order` | string | `RGB`, `RBG`, `GRB`, `GBR`, `BRG`, or `BGR`; setup AP only; default `GRB` | -| `oled_rotate_180` | integer | `0` or `1`; setup AP only; default `0` | +| `servo_mins` | string | Five comma-separated integers `0`–`180` (`head,neck,left,right,body`); setup AP or serial-only boot; must be sent with `servo_maxs` | +| `servo_maxs` | string | Five comma-separated integers `0`–`180`; setup AP or serial-only boot; each value must be greater than the matching min | +| `rgb_order` | string | `RGB`, `RBG`, `GRB`, `GBR`, `BRG`, or `BGR`; setup AP or serial-only boot; default `GRB` | +| `oled_rotate_180` | integer | `0` or `1`; setup AP or serial-only boot; default `0` | -At least one param required. Invalid or missing-all → **400**. Servo range params outside setup AP → **400** `servo setup only in AP mode`. RGB order outside setup AP → **400** `rgb setup only in AP mode`. OLED rotation outside setup AP → **400** `oled setup only in AP mode`. `reboot_required` is present and `true` only when the saved hostname differs from the one used at this boot. +At least one param required. Invalid or missing-all → **400**. Servo/RGB/OLED setup params outside setup AP (and not in serial-only boot) → **400**. `reboot_required` is present and `true` when the saved hostname or `wifi_enabled` differs from the value used at this boot. ### `PUT /settings/wifi` @@ -223,7 +238,7 @@ The list must hold 1 to 5 networks, so the robot always has one to try; factory ### `POST /settings/reset` -Factory reset: clears the NVS `te` namespace and restores settings to compile-time defaults and clears every saved WiFi network. **Servo min/max, RGB LED mapping (`rgb_order`), and OLED rotation (`oled_rotate_180`) are kept.** After reset, power-cycle (or press reset) so the device reopens setup AP mode and WiFi can be configured again; the servo, screen, and LED steps are pre-filled with the saved values and can be retuned. Until reboot, credentials are cleared in NVS but the radio may still be on the previous STA network. No query params. Requires Bearer when auth is enabled. Same JSON shape as `GET /settings`; includes `reboot_required` when the boot hostname, loading screen, or saved WiFi differed from defaults before reset. +Factory reset: clears the NVS `te` namespace and restores settings to compile-time defaults and clears every saved WiFi network. **Servo min/max, RGB LED mapping (`rgb_order`), and OLED rotation (`oled_rotate_180`) are kept.** After reset, power-cycle (or press reset) so the device reopens setup AP mode and WiFi can be configured again; the servo, screen, and LED steps are pre-filled with the saved values and can be retuned. Until reboot, credentials are cleared in NVS but the radio may still be on the previous STA network. No query params. Requires Bearer when auth is enabled. Same JSON shape as `GET /settings`; includes `reboot_required` when the boot hostname, loading screen, `wifi_enabled`, or saved WiFi differed from defaults before reset. The Config web UI clears the browser-stored Bearer token and shows a one-shot gate: power-cycle the device, then join the robot WiFi shown on the OLED and open the setup wizard. It does not poll for reconnect. @@ -239,6 +254,8 @@ curl -X POST "http://tiny-engineer.local/settings/reset" "volume": 70, "welcome": true, "serial_log": false, + "servo_hold": false, + "wifi_enabled": true, "continuous_timeout": 5, "loading": "progress", "eyes_style": "classic", @@ -386,7 +403,7 @@ Wrong params return **400** and do not change hold state except `off` / omitted | `invalid byte` | `byte` present but not an integer and not `off` | | `byte out of range` | Integer `byte` outside `0`–`2` | -Save the labeled permutation with `POST /settings?rgb_order=GRB` (setup AP only). The saved order applies immediately to animation colors, boot status, halt blinks, and `POST /test/led`. +Save the labeled permutation with `POST /settings?rgb_order=GRB` (setup AP or serial-only boot). The saved order applies immediately to animation colors, boot status, halt blinks, and `POST /test/led`. ### `POST /setup/audio` @@ -428,7 +445,23 @@ Wrong params return **400**: | `setup oled only in AP mode` | Not in setup AP | | `invalid rotate_180` | `rotate_180` present but not `0` or `1` | -Save with `POST /settings?oled_rotate_180=1` (setup AP only). The saved value applies immediately and on the next boot. +Save with `POST /settings?oled_rotate_180=1` (setup AP or serial-only boot). The saved value applies immediately and on the next boot. + +### `POST /servos/park` + +Flash-safe park. Smoothly moves all five joints to mid of the saved min/max at boot speed (`SERVO_BOOT_SPEED_DEG_S`), persists those angles to NVS (`spose`), and keeps PWM on until the next reboot (RAM hold; also skips idle limp for this boot). Does **not** require Wi-Fi credentials. No query params. Requires Bearer when auth is enabled. Used by PlatformIO `pre:upload` ([`scripts/park_servos_before_upload.py`](../scripts/park_servos_before_upload.py)). + +```bash +curl -X POST "http://tiny-engineer.local/servos/park" +``` + +```json +{ "ok": true, "park": "servos", "hold": true } +``` + +NVS write failure → **400** `{"ok":false,"error":"pose save failed"}`. Servos stay parked and held either way. + +Handler blocks until the move finishes. For absolute mechanical safety when pose is unknown, unplug servo **5 V / V+** before flashing — see [hardware/servos.md](hardware/servos.md). ### `POST /test/servo` @@ -509,7 +542,7 @@ Durations and quoted lines below are the stock clips in [`assets/`](../assets/). | `name` | Behavior | | --- | --- | -| `none` | Head/neck/body → mid; hands down (right `min`, left `max` — inverted scales). After all joints still for 2 s, PCA9685 PWM is full-off (servos limp; head may droop). Setup AP does not release PWM. | +| `none` | Head/neck/body → mid; hands down (right `min`, left `max` — inverted scales). After all joints still for 2 s, PCA9685 PWM is full-off (servos limp; head may droop), unless `servo_hold` is on or `POST /servos/park` armed a until-reboot hold. Setup AP does not release PWM. | | `typing` | **Continuous.** Alternating hands with randomness (15° band from hand limits). Head nods slowly on lowest 10° of head limits. Body sways ±5° around mid; neck counters opposite so head stays put. Runs until replaced, or until `continuous_timeout` triggers `attention`. Same-name re-`POST` refreshes that timeout without restarting motion. | | `reading` | **Continuous.** Hands/body park as in `none`. Head nods on same lowest 10° band as typing, but slower. Neck sweeps ±10° around mid; right (angle down) slower than left (angle up). Occasional right-hand down-arrow bursts (1–3 presses, same 15° band as typing). Runs until replaced, or until `continuous_timeout` triggers `attention`. Same-name re-`POST` refreshes that timeout without restarting motion. | | `thinking` | **Continuous.** Hands/body park as in `none`. Head (pitch) and neck (yaw) ease from the current pose into thinking poses (up + slight left/right). Move → pause → optional micro-adjust (sometimes chained) → pause; nearby pose drift with occasional larger shifts after ~2.2 s, more often over time. Axes stagger start/duration; no periodic sway. Runs until replaced, or until `continuous_timeout` triggers `attention`. Same-name re-`POST` refreshes that timeout without restarting motion. | @@ -577,7 +610,7 @@ curl -X POST http://tiny-engineer.local/play/thinking -H 'Content-Type: audio/wa { "ok": true, "played_ms": 3000 } ``` -The unified CLI wraps this for agents: `tiny-engineer play clip.wav [--name thinking]` ([integration.md](integration.md#1-unified-cli-default)). +The unified CLI wraps this for agents: `tiny-engineer play clip.wav [--name thinking]` over HTTP or serial ([integration.md](integration.md#1-unified-cli-default)). On serial, the same WAV rules apply; the body is framed with a JSON begin line plus `len` (see [serial-api.md](serial-api.md)). Errors arrive before any audio plays: @@ -591,11 +624,11 @@ Errors arrive before any audio plays: | Status | Body | When | | --- | --- | --- | -| `400` | `{"ok":false,"error":"..."}` | Bad `/test/servo`, `/setup/servo`, `/setup/led`, `/setup/audio`, `/setup/oled`, `/anim`, or `/play` params (see tables above) | +| `400` | `{"ok":false,"error":"..."}` | Bad `/test/servo`, `/setup/servo`, `/setup/led`, `/setup/audio`, `/setup/oled`, `/anim`, or `/play` params (see tables above); `/servos/park` pose NVS write failure (`pose save failed`) | | `401` | `{"ok":false,"error":"unauthorized"}` | Access token configured and `Authorization: Bearer` missing or wrong | | `404` | `{"ok":false,"error":"not found"}` | Unknown path | | `415` | `{"ok":false,"error":"..."}` | `/play` body is not a supported WAV | -| `405` | `{"ok":false,"error":"method not allowed"}` | Wrong method on a `/test/*`, `/setup/servo`, `/setup/led`, `/setup/audio`, `/setup/oled`, `/settings`, `/settings/reset`, `/settings/wifi`, `/anim`, `/play`, or `/auth` path | +| `405` | `{"ok":false,"error":"method not allowed"}` | Wrong method on a `/test/*`, `/setup/servo`, `/setup/led`, `/setup/audio`, `/setup/oled`, `/servos/park`, `/settings`, `/settings/reset`, `/settings/wifi`, `/anim`, `/play`, or `/auth` path | Test routes are **POST**. GET/prefetch would move hardware. `/anim` allows **GET** (read) and **POST** (set). `/auth` is **GET** only and always public. diff --git a/docs/flash.md b/docs/flash.md index 20c71db7..efa798d9 100644 --- a/docs/flash.md +++ b/docs/flash.md @@ -55,6 +55,8 @@ pio run -t upload # flash firmware + LittleFS pio device monitor # serial (115200) ``` +Before upload, a pre-script tries **`POST /servos/park`** over serial ([`scripts/park_servos_before_upload.py`](../scripts/park_servos_before_upload.py)) so joints sit at mid with PWM held — fewer snaps into the printed parts. If the board is cold or CDC is busy, the script warns and upload continues. For absolute safety, unplug servo **5 V / V+** first. Details: [hardware/servos.md](hardware/servos.md#flash-safe-park). + After firmware upload, a post-script also uploads **LittleFS** ([`scripts/upload_fs_after_upload.py`](../scripts/upload_fs_after_upload.py)) so WAV assets (`welcome`, `bell`, and friends) land on the board. If animations move but stay silent, run `pio run -t uploadfs` once. Several serial ports: diff --git a/docs/hardware/servos.md b/docs/hardware/servos.md index b606fdf8..c83b6e04 100644 --- a/docs/hardware/servos.md +++ b/docs/hardware/servos.md @@ -48,15 +48,29 @@ Verified in [`include/pins.h`](../../include/pins.h) and [`include/servos.h`](.. On every boot (and after each flash reset): -1. **OE** — GP5 drives PCA9685 **OE** (active LOW). Firmware holds OE **HIGH** (outputs off) from early `setup`, then **LOW** after neutral PWM is written (`PCA9685_OE_WIRED = true` in [`include/pins.h`](../../include/pins.h)). -2. **Early init** — PCA9685 is probed and configured immediately after `Wire.begin`, before OLED, settings, or Wi-Fi. -3. **Neutral park** — all channels receive mid-pulse PWM while OE is still HIGH; then OE goes LOW. +1. **OE** — GP5 drives PCA9685 **OE** (active LOW). Firmware holds OE **HIGH** (outputs off) from early `setup`, then **LOW** after PWM is written (`PCA9685_OE_WIRED = true` in [`include/pins.h`](../../include/pins.h)). +2. **Early init** — PCA9685 is probed and configured immediately after `Wire.begin`, before OLED / Wi-Fi (settings already loaded). +3. **Last-pose restore** — NVS key `spose` (namespace `te`) holds the last commanded angles (5× degrees). While OE is still HIGH, firmware writes those pulses (mid fallback if missing), then OE goes LOW. That cuts the common flash snap when the robot was already near the saved pose. 4. **Smooth boot moves** — `centerAllServos()` and sleep-inertia pose use `servoMoveAllSmoothTo()` at `SERVO_BOOT_SPEED_DEG_S` (35°/s), not instant snaps. +### Flash-safe park + +Rapid flash/upload can still yank joints if the body drooped after PWM limp (software pose ≠ physical). Mitigations: + +| Tool | Role | +| --- | --- | +| `POST /servos/park` | Smooth mid, flush `spose`, keep PWM until reboot | +| PlatformIO `pre:upload` | [`scripts/park_servos_before_upload.py`](../../scripts/park_servos_before_upload.py) calls park over serial; warns and continues if the robot is offline | +| Setting `servo_hold` | Skip idle/sleep PWM full-off so the pose does not limp between flashes | +| `welcome=0` | Quieter post-boot motion while iterating (optional) | +| Unplug servo **5 V / V+** | Absolute safety when pose is unknown | + ## Idle and sleep PWM release Analog servos hunt while a 50 Hz pulse is present. Firmware does **not** use OE for this: after animation `none` parks (awake rest or device-sleep chin-down) and every joint is still for `SERVO_PWM_RELEASE_DELAY_MS` (**2000** ms), it writes PCA9685 full-off (`setPWM(channel, 0, 4096)`) on channels 0–4. SIG stays low; the servo goes **limp** (no holding torque). Head pitch can droop; hands-down usually rests on the desk. +Skipped when `servo_hold` is on, when `POST /servos/park` armed a until-reboot hold, or in setup AP. + The next animation, `/test/servo`, or `/setup/servo` rewrite the last commanded pulse before moving. Leaving `none` restores all five channels at their last angles first. **Setup AP** (`wifiProvisioningMode`) never releases PWM, so “Move all to 90°” / horn install still holds. diff --git a/docs/hardware/testing.md b/docs/hardware/testing.md index 256a5bc0..51a5f058 100644 --- a/docs/hardware/testing.md +++ b/docs/hardware/testing.md @@ -19,7 +19,7 @@ Stock flash: **[Web flash](https://jamro.github.io/tiny-engineer/flash/)**. Buil | --- | --- | | Built-in WS2812 | Green ready (GPIO10) | | I2C init | `Wire.begin` on GPIO0/GPIO1 | -| PCA9685 | Probe `0x40` early; OE HIGH during init, park neutral, then OE LOW (`PCA9685_OE_WIRED`) | +| PCA9685 | Probe `0x40` early; OE HIGH during init, restore last pose (`spose`) or mid, then OE LOW (`PCA9685_OE_WIRED`) | | OLED | Probe `0x3C`, init (optional) | | Wi-Fi | STA connect from saved NVS credentials, or setup AP `TinyEngineer-XXXX` when unset/failed; mDNS `{hostname}.local` after STA connect | | MAX98357A / I2S | `I2S.begin` 22.05 kHz 16-bit stereo | @@ -106,15 +106,15 @@ curl -X POST "http://tiny-engineer.local/test/servo?index=0&angle=90" | Method | Path | Body | | --- | --- | --- | | `GET` | `/` | HTML endpoint index | -| `GET` | `/auth` | Auth status (`ok`, `required`, `wifi_configured`, `provisioning`) — always public | +| `GET` | `/auth` | Auth status (`ok`, `required`, `wifi_configured`, `wifi_enabled`, `provisioning`) — always public | | `GET` | `/health` | Health JSON — full field list in [api.md](../api.md#get-health) | | `GET` / `POST` | `/anim` | Current animation / start one (`name`, optional `interrupt`) — full params in [api.md](../api.md#post-anim) | | `POST` | `/play` | Stream a WAV while `talking` (or `/play/{name}`) runs; answers when the clip ends — full params in [api.md](../api.md#post-play) | -| `GET` | `/settings` | Persistent settings (`sleep_timeout`, `hostname`, `volume`, `welcome`, `serial_log`, `continuous_timeout`, `loading`, `eyes_style`, `access_token_set`, `wifi_configured`, `servo_mins`, `servo_maxs`, `rgb_order`, `oled_rotate_180`, `wifi_networks`) | -| `POST` | `/settings?...` | Update NVS settings; `reboot_required` if hostname changed | -| `POST` | `/settings?...&servo_mins=&servo_maxs=` | Servo min/max comma lists; setup-AP-only | -| `POST` | `/settings?...&rgb_order=` | WS2812 byte order (`RGB`/`RBG`/`GRB`/`GBR`/`BRG`/`BGR`); setup-AP-only; default `GRB` | -| `POST` | `/settings?...&oled_rotate_180=` | OLED 180° rotation (`0`/`1`); setup-AP-only; default `0` | +| `GET` | `/settings` | Persistent settings (`sleep_timeout`, `hostname`, `volume`, `welcome`, `serial_log`, `servo_hold`, `wifi_enabled`, `continuous_timeout`, `loading`, `eyes_style`, `access_token_set`, `wifi_configured`, `servo_mins`, `servo_maxs`, `rgb_order`, `oled_rotate_180`, `wifi_networks`) | +| `POST` | `/settings?...` | Update NVS settings; `reboot_required` if hostname or `wifi_enabled` changed vs boot | +| `POST` | `/settings?...&servo_mins=&servo_maxs=` | Servo min/max comma lists; setup-AP or serial-only boot | +| `POST` | `/settings?...&rgb_order=` | WS2812 byte order (`RGB`/`RBG`/`GRB`/`GBR`/`BRG`/`BGR`); setup-AP or serial-only; default `GRB` | +| `POST` | `/settings?...&oled_rotate_180=` | OLED 180° rotation (`0`/`1`); setup-AP or serial-only; default `0` | | `PUT` | `/settings/wifi` | Replace the saved WiFi networks (JSON array of 1 to 5); first one tested in setup AP mode | | `POST` | `/settings/reset` | Factory reset settings to defaults (clears WiFi, keeps servo ranges, RGB mapping, and screen rotation); power-cycle to reopen setup AP | | `POST` | `/test/audio` | `{"ok":true,"test":"audio"}` after `runSoundTest()` | @@ -127,8 +127,9 @@ curl -X POST "http://tiny-engineer.local/test/servo?index=0&angle=90" | `POST` | `/setup/led?color=R` / `G` / `B` or `?byte=0` / `1` / `2` / `off` | Setup AP only; light logical RGB or one WS2812 wire byte, or release hold | | `POST` | `/setup/audio` | Setup AP only; play `welcome.wav` (`playWelcome()`) | | `POST` | `/setup/oled?rotate_180=0` / `1` | Setup AP only; preview OLED 180° rotation (`THIS WAY UP`); omit param to restore provisioning text | +| `POST` | `/servos/park` | Smooth mid park, persist `spose`, hold PWM until reboot (no Wi-Fi gate) | -GET on a test path returns `405`. Bad `/test/servo`, `/setup/servo`, `/setup/led`, `/setup/audio`, `/setup/oled`, or `/settings` params return `400`. Missing/wrong Bearer when auth enabled returns `401`. Control APIs return `503` when WiFi credentials are not saved (`/setup/servo`, `/setup/led`, `/setup/audio`, and `/setup/oled` stay available). Unknown path returns `404`. JSON `Content-Type`. Handlers block until the test finishes; the OLED returns to `ROBOT READY` after. +GET on a test path returns `405`. Bad `/test/servo`, `/setup/servo`, `/setup/led`, `/setup/audio`, `/setup/oled`, or `/settings` params return `400`. Missing/wrong Bearer when auth enabled returns `401`. Control APIs return `503` when WiFi credentials are not saved (`/setup/servo`, `/setup/led`, `/setup/audio`, `/setup/oled`, and `/servos/park` stay available). Unknown path returns `404`. JSON `Content-Type`. Handlers block until the test finishes; the OLED returns to `ROBOT READY` after. HTTP runs on STA when connected, or on setup AP at `192.168.4.1` during provisioning. diff --git a/docs/integration.md b/docs/integration.md index 213ea5fd..8ee28552 100644 --- a/docs/integration.md +++ b/docs/integration.md @@ -1,13 +1,14 @@ # Integrating Tiny Engineer -Tiny Engineer is a Wi-Fi desk robot. Prefer the unified **`tiny-engineer` CLI** for Cursor, Claude Code, and Antigravity hooks. For custom agents, other IDEs, or scripts, call the **REST API** directly. +Tiny Engineer is a Wi-Fi desk robot. Prefer the unified **`tiny-engineer` CLI** for Cursor, Claude Code, and Antigravity hooks. For custom agents, other IDEs, or scripts, call the **REST API** directly (or the [serial CDC API](serial-api.md)). -Robot must be on the same network. Base URL: `http://tiny-engineer.local` (or the IP shown on the OLED). Full HTTP reference: [`api.md`](api.md). +Robot must be on the same network for HTTP. Base URL: `http://tiny-engineer.local` (or the IP shown on the OLED). Full HTTP reference: [`api.md`](api.md). USB serial is an always-on extra transport for the same JSON control ops. | Path | Best for | How | |---|---|---| -| **`tiny-engineer` CLI** (default) | Cursor, Claude Code, Antigravity hooks | `tiny-engineer setup` → `hook ` → `POST /anim` | +| **`tiny-engineer` CLI** (default) | Cursor, Claude Code, Antigravity hooks | `tiny-engineer setup` → `hook ` → `/anim` (HTTP or serial) | | **REST API** (advanced) | Custom agents, other IDEs, CI, one-off scripts | `POST /anim?name=…` ([`api.md`](api.md)) | +| **Serial API** | USB cable, flaky mDNS, bench | NDJSON over CDC ([`serial-api.md`](serial-api.md)); CLI `--serial` / `TINY_ENGINEER_SERIAL` | ```mermaid flowchart TB @@ -19,12 +20,14 @@ flowchart TB end subgraph advanced [Advanced / custom] Script[Any script or IDE] --> Rest["POST /anim REST"] + Script --> Serial["serial NDJSON"] end - Anim --> Robot[Tiny Engineer on Wi-Fi] + Anim --> Robot[Tiny Engineer] Rest --> Robot + Serial --> Robot ``` -Prerequisites: flash firmware, join 2.4 GHz Wi-Fi, confirm `http://tiny-engineer.local/health` (or the OLED IP) responds. +Prerequisites: flash firmware, join 2.4 GHz Wi-Fi, confirm `http://tiny-engineer.local/health` (or the OLED IP) responds. Serial works whenever CDC is up (including setup AP), but Wi-Fi credentials are still required for `/anim` and `/test/*`. --- @@ -48,8 +51,10 @@ npx -y tiny-engineer doctor ``` - Cold `npx` can be slow: `setup` writes hook **`timeout` ≥ 30**. -- Optional: `--url http://192.168.x.x` (default `TINY_ENGINEER_URL`, else `http://tiny-engineer.local`). -- Auth: if the device has an `access_token`, set `TINY_ENGINEER_TOKEN` in the process env or a project-root `.env`. The CLI sends `Authorization: Bearer …`. +- Transport: interactive `setup` asks **HTTP (Wi-Fi)** vs **Serial (USB)** and writes one of `TINY_ENGINEER_URL` or `TINY_ENGINEER_SERIAL` into `.env` (clears the other). Non-interactive: `--url http://192.168.x.x` or `--serial` / `--serial=auto` / `--serial ` (not both). Runtime flags override the same way. List paths with `tiny-engineer ports`. Protocol: [`serial-api.md`](serial-api.md). +- Auto-detect caches the last good port under `~/.cache/tiny-engineer/`; cache clears when the port stops responding. +- `play` uses the same transport as `anim` (HTTP or serial). Serial `/play` sends a JSON begin line with `len` then raw WAV bytes — see [`serial-api.md`](serial-api.md). +- Auth (HTTP): if the device has an `access_token`, set `TINY_ENGINEER_TOKEN` in the process env or a project-root `.env`. The CLI sends `Authorization: Bearer …`. Serial skips Bearer (trusted USB). - Prefer registry `npx` over `npx github:…` (SSH often fails in IDE hooks). **This firmware repo (local CLI development):** @@ -62,14 +67,15 @@ node packages/tiny-engineer-cli/bin/tiny-engineer.js doctor ### Setup wizard ```bash -npx -y tiny-engineer setup # interactive: pick IDEs +npx -y tiny-engineer setup # interactive: pick IDEs + HTTP vs serial npx -y tiny-engineer setup cursor --yes npx -y tiny-engineer setup --all --yes --url http://192.168.1.10 +npx -y tiny-engineer setup cursor --yes --serial=auto npx -y tiny-engineer doctor npx -y tiny-engineer uninstall cursor --yes # remove only Tiny Engineer hook entries ``` -`setup` merges hook commands into the project (Cursor `.cursor/hooks.json`, Claude Code `.claude/settings.json`, Antigravity `.agents/hooks.json`) using `npx -y tiny-engineer hook `, and can write `TINY_ENGINEER_URL` into `.env`. It does not delete foreign hooks. +`setup` merges hook commands into the project (Cursor `.cursor/hooks.json`, Claude Code `.claude/settings.json`, Antigravity `.agents/hooks.json`) using `npx -y tiny-engineer hook `, and writes **either** `TINY_ENGINEER_URL` or `TINY_ENGINEER_SERIAL` into `.env` (exclusive). It does not delete foreign hooks. This firmware repo already ships configs pointing at the **local** CLI bin for developing the package. @@ -87,9 +93,10 @@ Cursor deep dive (event table, sample `hooks.json`): [`hooks.md`](hooks.md). tiny-engineer anim ring tiny-engineer play /tmp/clip.wav tiny-engineer play /tmp/clip.wav --name thinking +tiny-engineer play /tmp/clip.wav --serial=auto ``` -WAV for `/play`: 16-bit mono PCM at 22050 Hz. Details: [`api.md#post-play`](api.md#post-play). +WAV for `/play`: 16-bit mono PCM at 22050 Hz. Details: [`api.md#post-play`](api.md#post-play). Serial framing: [`serial-api.md`](serial-api.md). ### Speaking through the robot (skill) diff --git a/docs/serial-api.md b/docs/serial-api.md new file mode 100644 index 00000000..3873d4be --- /dev/null +++ b/docs/serial-api.md @@ -0,0 +1,134 @@ +# Serial API + +USB CDC transport for the same JSON control ops as the [HTTP API](api.md). Always on (trusted — no Bearer auth). With `wifi_enabled=0` after reboot, serial is the **only** control path (Wi-Fi radio off, no HTTP). With Wi-Fi on, serial is an additional channel beside HTTP. + +Source: [`src/serial/serial_api.cpp`](../src/serial/serial_api.cpp), shared dispatch in [`src/api/`](../src/api/). Baud **115200** (same as `monitor_speed` / `Serial.begin`). + +The unified CLI can use this transport via `TINY_ENGINEER_SERIAL` or `--serial` — see [integration.md](integration.md). + +## Framing + +Newline-delimited UTF-8. One request or reply per line. + +### Host → device + +One JSON object per line (max **1024** bytes): + +```json +{"v":1,"id":42,"op":"POST /anim","q":{"name":"thinking"}} +``` + +| Field | Rule | +| --- | --- | +| `v` | Protocol version; must be `1` | +| `id` | Unsigned integer; echoed in the reply | +| `op` | `"METHOD /path"` (single space), paths match HTTP | +| `q` | Object of **string** query params (same names/values as HTTP). Omit or `{}` if none | +| `len` | Required for `POST /play` / `POST /play/` only: byte length of the WAV that follows (1…32 MiB) | + +Most ops are JSON-only. **`POST /play`** is hybrid: after the begin line, the host sends exactly `len` raw WAV bytes (no base64), then waits for one `R` reply. On error the device still drains remaining bytes so the stream stays in sync. `serial_log` `L` lines are suppressed while those bytes are received. + +**Body idle abort:** if no `/play` body byte arrives for **30 seconds** while `len` is still outstanding, the device ends the play session and sends one `R` reply with status **408** and `error` `play body incomplete` (or the earlier reject status if the session was already rejected). While declared bytes remain outstanding, the device stays in body-drain mode: further serial input is consumed as body bytes (discarded), not as line requests. A second 30-second idle with bytes still outstanding drops the remainder and returns to line mode. After an abort reply, the host must not send the rest of that body and must not send another request until that second idle returns the device to line mode. Reopening the host port does not clear drain mode on the device. + +Oversized line, invalid JSON, or `v` ≠ 1 → reply with `status` **400** and no dispatch. + +### Device → host + +**API reply** (always, even when `serial_log` is off): + +```text +R{"v":1,"id":42,"status":200,"body":{"ok":true,"animation":"thinking"}} +``` + +Literal prefix `R`, then one JSON object, then `\n`. + +| Field | Meaning | +| --- | --- | +| `v` | `1` | +| `id` | Echo of the request id | +| `status` | Same codes as HTTP (`200`, `400`, `404`, `405`, `503`, …) | +| `body` | Same JSON object the HTTP route would return | + +Serial never requires Bearer; it does not emit **401** for a missing token. + +**Log lines** (only when settings `serial_log` is on): + +```text +L WIFI SETUP +``` + +Prefix `L` + space + message + `\n`. Multi-part log prints share one `L ` at the start of the line. Host RPC clients should ignore every line that does not start with `R`. + +## Supported ops + +| `op` | Notes | +| --- | --- | +| `GET /auth` | Public on HTTP; same JSON here | +| `GET /health` | | +| `GET /anim` | | +| `POST /anim` | Query `name` | +| `GET /settings` | | +| `POST /settings` | Query params as HTTP (incl. `wifi_enabled`) | +| `POST /settings/reset` | | +| `POST /test/audio` | | +| `POST /test/audio/bell` | | +| `POST /test/screen` | | +| `POST /test/movement` | | +| `POST /test/led` | | +| `POST /test/servo` | Query `index`, `angle` | +| `POST /setup/servo` | Setup AP or serial-only boot (else **400**) | +| `POST /setup/led` | Setup AP or serial-only boot | +| `POST /setup/audio` | Setup AP or serial-only boot | +| `POST /setup/oled` | Setup AP or serial-only boot | +| `POST /servos/park` | Smooth mid + pose persist + hold PWM until reboot | +| `POST /play` | Begin JSON must include `len`; then raw WAV (same format as HTTP) | +| `POST /play/` | Same; `name` = `talking`\|`typing`\|`reading`\|`thinking`\|`none` | + +Unknown `op` → **404** `{"ok":false,"error":"not found"}`. Wrong method for a known path → **405** `method not allowed`. + +**Not on serial:** HTML / `/ui/*`, CORS, `PUT /settings/wifi` (JSON body). Use HTTP for those. + +### `POST /play` framing + +```text +{"v":1,"id":42,"op":"POST /play","len":123456}\n +<123456 bytes of WAV> +R{"v":1,"id":42,"status":200,"body":{"ok":true,"played_ms":3000}}\n +``` + +Incomplete body (host stops or stalls longer than the 30-second idle limit): + +```text +R{"v":1,"id":42,"status":408,"body":{"ok":false,"error":"play body incomplete"}}\n +``` + +Pauses between body bytes are allowed up to that idle limit; there is no shorter undocumented timeout. + +## Example + +```bash +# Replace with your CDC device path (tiny-engineer ports) +printf '%s\n' '{"v":1,"id":1,"op":"GET /health","q":{}}' > /dev/cu.usbmodemXXXX +``` + +Or with the CLI: + +```bash +TINY_ENGINEER_SERIAL=/dev/cu.usbmodemXXXX tiny-engineer anim thinking +tiny-engineer doctor --serial /dev/cu.usbmodemXXXX +tiny-engineer anim thinking --serial # auto-detect (VID + health probe) +tiny-engineer play clip.wav --serial=auto +TINY_ENGINEER_SERIAL=auto tiny-engineer doctor +``` + +Auto-detect prefers Espressif USB devices (VID `303A`, manufacturer / Windows friendly name, PID `1001` USB-Serial/JTAG ranked first). Path norms: macOS `tty.*` → `cu.*` for open; Linux `/dev/ttyACM*`; Windows `COMn`. Candidates without a clear Espressif ID fall back to CDC-like paths only. Last good path (`~/.cache/tiny-engineer/serial-port.json`) is reused when still listed and `GET /health` succeeds (cleared when serial RPC is unreachable). Otherwise probes candidates in parallel with `GET /health` (~800 ms each) and requires exactly one match. + +## Host tips + +1. Open the port at 115200. +2. Write one request line ending in `\n`. +3. For `/play`, write exactly `len` WAV bytes next (no extra framing). Pace about realtime (~44 KB/s for 16-bit mono 22050 Hz). Much faster overflows CDC during `I2S.write`; much slower stretches speech with gaps. Do not pause longer than **30 seconds** between body bytes (device idle abort). +4. Read lines until an `R` line whose JSON `id` matches; ignore `L` and other noise. +5. Parse `status` and `body` like an HTTP response. On **408** `play body incomplete`, stop sending that body and wait for the second 30-second idle (device back in line mode) before the next request. Reopening the port does not clear body-drain mode. + +Blocking test/setup/`/play` ops hold the device until motion/audio finishes (same as HTTP). diff --git a/docs/settings.md b/docs/settings.md index 5583b23c..1091ea83 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -2,9 +2,9 @@ Persistent settings live in NVS (namespace `te`) under [`src/settings/`](../src/settings/). They are exposed on `GET`/`POST /settings`, the Config web UI, and must stay in sync with API docs. -**HTTP / JSON** (query params and `GET /settings` fields — see [api.md](api.md)): `sleep_timeout`, `hostname`, `volume`, `welcome`, `serial_log`, `continuous_timeout`, `loading`, `eyes_style`, `access_token`, `servo_mins` / `servo_maxs`, `rgb_order`, `oled_rotate_180`. WiFi networks have their own endpoint, `PUT /settings/wifi`, and show as `wifi_networks`. +**HTTP / JSON** (query params and `GET /settings` fields — see [api.md](api.md)): `sleep_timeout`, `hostname`, `volume`, `welcome`, `serial_log`, `servo_hold`, `wifi_enabled`, `continuous_timeout`, `loading`, `eyes_style`, `access_token`, `servo_mins` / `servo_maxs`, `rgb_order`, `oled_rotate_180`. WiFi networks have their own endpoint, `PUT /settings/wifi`, and show as `wifi_networks`. -**NVS keys** (≤15 chars; defined in [`nvs.h`](../src/settings/nvs.h)): `sleep_m`, `host`, `vol`, `welcome`, `serial_log`, `cont_to`, `loading`, `eyes_sty`, `access_tok`, `wifi_nets` (blob of up to 5 networks; replaces the older single-network `wifi_ssid` / `wifi_pass`, which are migrated on load), `sranges`, `rgb_ord`, `oled_rot`. Add new short keys there — do not reuse HTTP names as NVS keys unless they already fit the length limit. +**NVS keys** (≤15 chars; defined in [`nvs.h`](../src/settings/nvs.h)): `sleep_m`, `host`, `vol`, `welcome`, `serial_log`, `srv_hold`, `wifi_en`, `cont_to`, `loading`, `eyes_sty`, `access_tok`, `wifi_nets` (blob of up to 5 networks; replaces the older single-network `wifi_ssid` / `wifi_pass`, which are migrated on load), `sranges`, `rgb_ord`, `oled_rot`. Last commanded servo angles live in the same namespace as key `spose` (internal; not a settings API field). Add new short keys there — do not reuse HTTP names as NVS keys unless they already fit the length limit. Follow the same pattern for a new setting. @@ -16,7 +16,7 @@ Follow the same pattern for a new setting. | RAM | Cached in a module-static after `initSettings()`; callers use getters | | Update API | Nullable pointer args to `saveSettings(...)` — only non-null fields change | | HTTP | Query params on `POST /settings`; at least one param required | -| Apply timing | Prefer immediate apply. If boot-only (like hostname), set `reboot_required` and document it. WiFi networks live in a `WifiNetworkList` saved with `saveWifiNetworks()`, outside `saveSettings(...)`; they are tested before save only in setup AP mode. Servo min/max (`servo_mins` / `servo_maxs`), RGB LED mapping (`rgb_order`), and OLED rotation (`oled_rotate_180`) are setup-AP-only and apply immediately | +| Apply timing | Prefer immediate apply. If boot-only (like hostname / `wifi_enabled`), set `reboot_required` and document it. WiFi networks live in a `WifiNetworkList` saved with `saveWifiNetworks()`, outside `saveSettings(...)`; they are tested before save only in setup AP mode. Servo min/max (`servo_mins` / `servo_maxs`), RGB LED mapping (`rgb_order`), and OLED rotation (`oled_rotate_180`) are setup-AP-only (or serial-only when `wifi_enabled=0` at boot) and apply immediately | | Types | Prefer small integers / short strings; validate before any NVS write | ## Checklist diff --git a/docs/testing.md b/docs/testing.md index a35aa7e1..9f42e418 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -4,7 +4,7 @@ Host checks. These commands do not flash the board. GitHub Actions on `main` and ## Firmware -Unity on the host. Compiles `src/settings/validate.cpp` plus header-only helpers such as [`include/servos.h`](../include/servos.h). +Unity on the host. Compiles `src/settings/validate.cpp`, `src/api/request.cpp` (serial/HTTP op parsing), plus header-only helpers such as [`include/servos.h`](../include/servos.h). ```bash pio test -e native @@ -41,9 +41,10 @@ npm run format --prefix ui # apply Prettier ## Packages -Node 18+. No robot. +Node 18+. No robot. `tiny-engineer-cli` needs a local install (`serialport`); other packages have no deps. ```bash +npm ci --prefix packages/tiny-engineer-cli npm test --prefix packages/tiny-engineer-cli npm test --prefix packages/tiny-engineer-cursor npm test --prefix packages/tiny-engineer-antigravity diff --git a/packages/tiny-engineer-cli/README.md b/packages/tiny-engineer-cli/README.md index a65cca40..00596a9f 100644 --- a/packages/tiny-engineer-cli/README.md +++ b/packages/tiny-engineer-cli/README.md @@ -1,22 +1,40 @@ # tiny-engineer -Unified CLI for the [Tiny Engineer](https://github.com/jamro/tiny-engineer) desk robot: install IDE hooks, map agent events to poses, and drive animations over HTTP. +Unified CLI for the [Tiny Engineer](https://github.com/jamro/tiny-engineer) desk robot: install IDE hooks, map agent events to poses, and drive animations over **HTTP** or **USB serial**. ## Install / run ```bash npx -y tiny-engineer setup -npx -y tiny-engineer setup cursor --yes +npx -y tiny-engineer setup cursor --yes --url http://192.168.1.10 +npx -y tiny-engineer setup cursor --yes --serial=auto npx -y tiny-engineer doctor npx -y tiny-engineer anim ring +npx -y tiny-engineer ports +npx -y tiny-engineer anim ring --serial /dev/cu.usbmodem14101 +npx -y tiny-engineer play clip.wav --serial=auto ``` Supported IDEs: **Cursor**, **Claude Code**, **Antigravity**. +Interactive `setup` asks **HTTP vs Serial**, then writes one key to project `.env` (and clears the other). + +### Transport + +| Mode | How | +| --- | --- | +| HTTP (default) | `--url` / `TINY_ENGINEER_URL` / `http://tiny-engineer.local` | +| Serial | `--serial `, bare `--serial` / `--serial=auto`, or `TINY_ENGINEER_SERIAL` (when `--url` absent) | + +Auto-detect: Espressif USB filter (VID / manufacturer / PID `1001`) on macOS `cu.*`, Linux `ttyACM*`, Windows `COMn`. Reuses last good path in `~/.cache/tiny-engineer/serial-port.json` when still listed and `GET /health` succeeds (cleared when serial becomes unreachable); otherwise parallel health probe and requires exactly one match. + +Do not pass both `--url` and `--serial`. `anim` and `play` both work on HTTP or serial. Serial framing: [serial-api.md](https://github.com/jamro/tiny-engineer/blob/main/docs/serial-api.md). + ## Docs -- [Integration guide](https://github.com/jamro/tiny-engineer/blob/main/docs/integration.md) (`setup` / hooks) -- [HTTP API](https://github.com/jamro/tiny-engineer/blob/main/docs/api.md) (custom agents / advanced) +- [Integration guide](https://github.com/jamro/tiny-engineer/blob/main/docs/integration.md) (`setup` / hooks / transport) +- [HTTP API](https://github.com/jamro/tiny-engineer/blob/main/docs/api.md) +- [Serial API](https://github.com/jamro/tiny-engineer/blob/main/docs/serial-api.md) - [CLI release process](https://github.com/jamro/tiny-engineer/blob/main/docs/cli-release.md) (maintainers: npm publish, separate from firmware `v*` tags) ## License diff --git a/packages/tiny-engineer-cli/package-lock.json b/packages/tiny-engineer-cli/package-lock.json index 5676cc03..f0613018 100644 --- a/packages/tiny-engineer-cli/package-lock.json +++ b/packages/tiny-engineer-cli/package-lock.json @@ -8,12 +8,291 @@ "name": "tiny-engineer", "version": "0.1.1", "license": "MIT", + "dependencies": { + "serialport": "^13.0.0" + }, "bin": { "tiny-engineer": "bin/tiny-engineer.js" }, "engines": { "node": ">=18" } + }, + "node_modules/@serialport/binding-mock": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@serialport/binding-mock/-/binding-mock-10.2.2.tgz", + "integrity": "sha512-HAFzGhk9OuFMpuor7aT5G1ChPgn5qSsklTFOTUX72Rl6p0xwcSVsRtG/xaGp6bxpN7fI9D/S8THLBWbBgS6ldw==", + "license": "MIT", + "dependencies": { + "@serialport/bindings-interface": "^1.2.1", + "debug": "^4.3.3" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/@serialport/bindings-cpp": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/@serialport/bindings-cpp/-/bindings-cpp-13.0.0.tgz", + "integrity": "sha512-r25o4Bk/vaO1LyUfY/ulR6hCg/aWiN6Wo2ljVlb4Pj5bqWGcSRC4Vse4a9AcapuAu/FeBzHCbKMvRQeCuKjzIQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@serialport/bindings-interface": "1.2.2", + "@serialport/parser-readline": "12.0.0", + "debug": "4.4.0", + "node-addon-api": "8.3.0", + "node-gyp-build": "4.8.4" + }, + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@serialport/bindings-cpp/node_modules/@serialport/parser-delimiter": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@serialport/parser-delimiter/-/parser-delimiter-12.0.0.tgz", + "integrity": "sha512-gu26tVt5lQoybhorLTPsH2j2LnX3AOP2x/34+DUSTNaUTzu2fBXw+isVjQJpUBFWu6aeQRZw5bJol5X9Gxjblw==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@serialport/bindings-cpp/node_modules/@serialport/parser-readline": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@serialport/parser-readline/-/parser-readline-12.0.0.tgz", + "integrity": "sha512-O7cywCWC8PiOMvo/gglEBfAkLjp/SENEML46BXDykfKP5mTPM46XMaX1L0waWU6DXJpBgjaL7+yX6VriVPbN4w==", + "license": "MIT", + "dependencies": { + "@serialport/parser-delimiter": "12.0.0" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@serialport/bindings-interface": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@serialport/bindings-interface/-/bindings-interface-1.2.2.tgz", + "integrity": "sha512-CJaUd5bLvtM9c5dmO9rPBHPXTa9R2UwpkJ0wdh9JCYcbrPWsKz+ErvR0hBLeo7NPeiFdjFO4sonRljiw4d2XiA==", + "license": "MIT", + "engines": { + "node": "^12.22 || ^14.13 || >=16" + } + }, + "node_modules/@serialport/parser-byte-length": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/@serialport/parser-byte-length/-/parser-byte-length-13.0.0.tgz", + "integrity": "sha512-32yvqeTAqJzAEtX5zCrN1Mej56GJ5h/cVFsCDPbF9S1ZSC9FWjOqNAgtByseHfFTSTs/4ZBQZZcZBpolt8sUng==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@serialport/parser-cctalk": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/@serialport/parser-cctalk/-/parser-cctalk-13.0.0.tgz", + "integrity": "sha512-RErAe57g9gvnlieVYGIn1xymb1bzNXb2QtUQd14FpmbQQYlcrmuRnJwKa1BgTCujoCkhtaTtgHlbBWOxm8U2uA==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@serialport/parser-delimiter": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/@serialport/parser-delimiter/-/parser-delimiter-13.0.0.tgz", + "integrity": "sha512-Qqyb0FX1avs3XabQqNaZSivyVbl/yl0jywImp7ePvfZKLwx7jBZjvL+Hawt9wIG6tfq6zbFM24vzCCK7REMUig==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@serialport/parser-inter-byte-timeout": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/@serialport/parser-inter-byte-timeout/-/parser-inter-byte-timeout-13.0.0.tgz", + "integrity": "sha512-a0w0WecTW7bD2YHWrpTz1uyiWA2fDNym0kjmPeNSwZ2XCP+JbirZt31l43m2ey6qXItTYVuQBthm75sPVeHnGA==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@serialport/parser-packet-length": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/@serialport/parser-packet-length/-/parser-packet-length-13.0.0.tgz", + "integrity": "sha512-60ZDDIqYRi0Xs2SPZUo4Jr5LLIjtb+rvzPKMJCohrO6tAqSDponcNpcB1O4W21mKTxYjqInSz+eMrtk0LLfZIg==", + "license": "MIT", + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/@serialport/parser-readline": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/@serialport/parser-readline/-/parser-readline-13.0.0.tgz", + "integrity": "sha512-dov3zYoyf0dt1Sudd1q42VVYQ4WlliF0MYvAMA3MOyiU1IeG4hl0J6buBA2w4gl3DOCC05tGgLDN/3yIL81gsA==", + "license": "MIT", + "dependencies": { + "@serialport/parser-delimiter": "13.0.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@serialport/parser-ready": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/@serialport/parser-ready/-/parser-ready-13.0.0.tgz", + "integrity": "sha512-JNUQA+y2Rfs4bU+cGYNqOPnNMAcayhhW+XJZihSLQXOHcZsFnOa2F9YtMg9VXRWIcnHldHYtisp62Etjlw24bw==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@serialport/parser-regex": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/@serialport/parser-regex/-/parser-regex-13.0.0.tgz", + "integrity": "sha512-m7HpIf56G5XcuDdA3DB34Z0pJiwxNRakThEHjSa4mG05OnWYv0IG8l2oUyYfuGMowQWaVnQ+8r+brlPxGVH+eA==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@serialport/parser-slip-encoder": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/@serialport/parser-slip-encoder/-/parser-slip-encoder-13.0.0.tgz", + "integrity": "sha512-fUHZEExm6izJ7rg0A1yjXwu4sOzeBkPAjDZPfb+XQoqgtKAk+s+HfICiYn7N2QU9gyaeCO8VKgWwi+b/DowYOg==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@serialport/parser-spacepacket": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/@serialport/parser-spacepacket/-/parser-spacepacket-13.0.0.tgz", + "integrity": "sha512-DoXJ3mFYmyD8X/8931agJvrBPxqTaYDsPoly9/cwQSeh/q4EjQND9ySXBxpWz5WcpyCU4jOuusqCSAPsbB30Eg==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@serialport/stream": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/@serialport/stream/-/stream-13.0.0.tgz", + "integrity": "sha512-F7xLJKsjGo2WuEWMSEO1SimRcOA+WtWICsY13r0ahx8s2SecPQH06338g28OT7cW7uRXI7oEQAk62qh5gHJW3g==", + "license": "MIT", + "dependencies": { + "@serialport/bindings-interface": "1.2.2", + "debug": "4.4.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/debug": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", + "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/node-addon-api": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.3.0.tgz", + "integrity": "sha512-8VOpLHFrOQlAH+qA0ZzuGRlALRA6/LVh8QJldbrC4DY0hXoMP0l4Acq8TzFC018HztWiRqyCEj2aTWY2UvnJUg==", + "license": "MIT", + "engines": { + "node": "^18 || ^20 || >= 21" + } + }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "license": "MIT", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, + "node_modules/serialport": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/serialport/-/serialport-13.0.0.tgz", + "integrity": "sha512-PHpnTd8isMGPfFTZNCzOZp9m4mAJSNWle9Jxu6BPTcWq7YXl5qN7tp8Sgn0h+WIGcD6JFz5QDgixC2s4VW7vzg==", + "license": "MIT", + "dependencies": { + "@serialport/binding-mock": "10.2.2", + "@serialport/bindings-cpp": "13.0.0", + "@serialport/parser-byte-length": "13.0.0", + "@serialport/parser-cctalk": "13.0.0", + "@serialport/parser-delimiter": "13.0.0", + "@serialport/parser-inter-byte-timeout": "13.0.0", + "@serialport/parser-packet-length": "13.0.0", + "@serialport/parser-readline": "13.0.0", + "@serialport/parser-ready": "13.0.0", + "@serialport/parser-regex": "13.0.0", + "@serialport/parser-slip-encoder": "13.0.0", + "@serialport/parser-spacepacket": "13.0.0", + "@serialport/stream": "13.0.0", + "debug": "4.4.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } } } } diff --git a/packages/tiny-engineer-cli/package.json b/packages/tiny-engineer-cli/package.json index 47fccdab..a3c1586e 100644 --- a/packages/tiny-engineer-cli/package.json +++ b/packages/tiny-engineer-cli/package.json @@ -1,7 +1,7 @@ { "name": "tiny-engineer", "version": "0.1.1", - "description": "Default Tiny Engineer integration: setup/uninstall IDE hooks, map agent events to poses, anim/play over HTTP. For custom agents use the REST API (docs/api.md).", + "description": "Default Tiny Engineer integration: setup/uninstall IDE hooks, map agent events to poses, anim/play over HTTP or USB serial. For custom agents use the REST or serial API (docs/api.md, docs/serial-api.md).", "type": "module", "bin": { "tiny-engineer": "bin/tiny-engineer.js" @@ -39,5 +39,8 @@ "publishConfig": { "access": "public" }, - "license": "MIT" + "license": "MIT", + "dependencies": { + "serialport": "^13.0.0" + } } diff --git a/packages/tiny-engineer-cli/src/cli.js b/packages/tiny-engineer-cli/src/cli.js index 90a8daa1..2371e613 100644 --- a/packages/tiny-engineer-cli/src/cli.js +++ b/packages/tiny-engineer-cli/src/cli.js @@ -1,7 +1,7 @@ import { readFileSync } from "node:fs"; import { basename } from "node:path"; -import { DEFAULT_URL, getBaseUrl, getToken, loadDotEnv } from "./config/env.js"; +import { DEFAULT_URL, getToken, loadDotEnv } from "./config/env.js"; import { get as getIntegration, list as listIntegrations } from "./integrations/registry.js"; import { formatRobotError, @@ -10,6 +10,8 @@ import { validateAnimName, validatePlayAnimName, } from "./robot/client.js"; +import { listSerialPorts, serialPlay } from "./robot/serial.js"; +import { resolveTransport, transportLabel } from "./robot/transport.js"; import { parseAnimArgs, parseCommonArgs, @@ -32,19 +34,27 @@ Unified CLI for the Tiny Engineer desk robot. Commands: setup [ide...] Merge IDE hook configs into the current project uninstall [ide...] Remove Tiny Engineer hook entries (keeps other hooks) - doctor Check URL, token, hook wiring, and /health + doctor Check URL/serial, token, hook wiring, and /health + ports List serial port paths (for --serial / TINY_ENGINEER_SERIAL) hook Read hook JSON from stdin, map to a pose, POST /anim - anim [--url] POST /anim?name= - play [opts] POST /play (16-bit mono PCM WAV) + anim [--url|--serial] POST /anim?name= + play [opts] POST /play (16-bit mono PCM WAV; HTTP or serial) Options (most commands): --url Robot base URL (default: TINY_ENGINEER_URL, else ${DEFAULT_URL}) + --serial [path|auto] USB CDC serial API (bare/--serial=auto = detect; caches under ~/.cache/tiny-engineer/) -q, --quiet, --silent Suppress success/info lines; errors still print -h, --help Show this help -Auth: +Transport: + Precedence: --serial → --url → TINY_ENGINEER_SERIAL → HTTP. + TINY_ENGINEER_SERIAL=auto also triggers detection. + Do not pass both --url and --serial. + +Auth (HTTP only): If TINY_ENGINEER_TOKEN is set (process env or project-root .env), requests send Authorization: Bearer . Must match the device access_token. + Serial is a trusted local channel (no Bearer). Output: Colors/icons when stdout/stderr is a TTY (disabled by NO_COLOR or TERM=dumb). @@ -60,11 +70,29 @@ Examples: tiny-engineer uninstall cursor --yes tiny-engineer uninstall --all --yes --dry-run tiny-engineer doctor + tiny-engineer ports tiny-engineer anim ring + tiny-engineer anim ring --serial + tiny-engineer anim ring --serial /dev/cu.usbmodem14101 + tiny-engineer play clip.wav --serial=auto echo '{"hook_event_name":"stop"}' | tiny-engineer hook cursor `); } +/** + * @param {import("./util/output.js").Output} out + * @param {{ url?: string, serial?: string }} opts + */ +async function resolveOrFail(out, opts) { + try { + return await resolveTransport(opts); + } catch (err) { + out.error(err instanceof Error ? err.message : String(err)); + process.exitCode = 1; + return null; + } +} + /** * @param {import("./util/output.js").Output} out * @param {string[]} argv @@ -109,8 +137,14 @@ async function runHook(out, argv, quietGlobal) { const anim = integration.mapToAnim(input); if (anim) { - // Fail-open: ignore result so an offline robot never stalls the agent. - await postAnim(opts.url ?? getBaseUrl(), anim, getToken()); + const transport = await resolveOrFail(localOut, { + url: opts.url, + serial: opts.serial, + }); + if (transport) { + // Fail-open: ignore result so an offline robot never stalls the agent. + await postAnim(transport, anim, getToken()); + } } const fileLines = integration.describeFiles?.(input, localOut.style) ?? []; @@ -156,14 +190,19 @@ async function runAnim(out, argv, quietGlobal) { return; } - const baseUrl = opts.url ?? getBaseUrl(); - const result = await postAnim(baseUrl, name, getToken()); + const transport = await resolveOrFail(localOut, { + url: opts.url, + serial: opts.serial, + }); + if (!transport) return; + + const result = await postAnim(transport, name, getToken()); if (!result.ok) { localOut.error(formatRobotError(result)); process.exitCode = 1; return; } - localOut.ok(`animation ${name} → ${localOut.style.dim(baseUrl)}`); + localOut.ok(`animation ${name} → ${localOut.style.dim(transportLabel(transport))}`); process.exitCode = 0; } @@ -183,6 +222,12 @@ async function runPlay(out, argv, quietGlobal) { return; } + const transport = await resolveOrFail(localOut, { + url: opts.url, + serial: opts.serial, + }); + if (!transport) return; + if (opts.name) { const invalid = validatePlayAnimName(opts.name); if (invalid) { @@ -201,25 +246,67 @@ async function runPlay(out, argv, quietGlobal) { return; } - const baseUrl = opts.url ?? getBaseUrl(); const pose = opts.name ?? "talking"; - const result = await postPlay({ - baseUrl, - wav, - name: opts.name, - token: getToken(), - }); + /** @type {import("./robot/client.js").RobotResult} */ + let result; + if (transport.type === "serial") { + result = await serialPlay(transport.path, { wav, name: opts.name }); + } else { + result = await postPlay({ + baseUrl: transport.baseUrl, + wav, + name: opts.name, + token: getToken(), + }); + } if (!result.ok) { localOut.error(formatRobotError(result)); process.exitCode = 1; return; } localOut.ok( - `played ${basename(/** @type {string} */ (opts.wavPath))} (${pose}) → ${localOut.style.dim(baseUrl)}`, + `played ${basename(/** @type {string} */ (opts.wavPath))} (${pose}) → ${localOut.style.dim(transportLabel(transport))}`, ); process.exitCode = 0; } +/** + * @param {import("./util/output.js").Output} out + * @param {string[]} argv + */ +async function runPorts(out, argv) { + for (const arg of argv) { + if (arg === "-h" || arg === "--help") { + console.log(`Usage: tiny-engineer ports + +List USB serial device paths for --serial / TINY_ENGINEER_SERIAL. +`); + return; + } + if (arg.startsWith("-")) { + out.error(`Unknown argument: ${arg}`); + process.exitCode = 1; + return; + } + } + + const ports = await listSerialPorts(); + if (ports.length === 0) { + out.info("no serial ports found"); + process.exitCode = 0; + return; + } + + for (const p of ports) { + const ids = [p.vendorId && `VID ${p.vendorId}`, p.productId && `PID ${p.productId}`] + .filter(Boolean) + .join(" "); + const extra = [p.manufacturer, p.friendlyName, ids].filter(Boolean).join(" · "); + out.ok(extra ? `${p.path} ${out.style.dim(extra)}` : p.path); + } + process.exitCode = 0; +} + /** * @param {string[]} argv */ @@ -250,6 +337,9 @@ export async function run(argv) { case "doctor": await runDoctor(out, rest); return; + case "ports": + await runPorts(out, rest); + return; case "hook": await runHook(out, rest, global.quiet); return; diff --git a/packages/tiny-engineer-cli/src/config/env.js b/packages/tiny-engineer-cli/src/config/env.js index b4e0f60c..a2828fe9 100644 --- a/packages/tiny-engineer-cli/src/config/env.js +++ b/packages/tiny-engineer-cli/src/config/env.js @@ -4,6 +4,7 @@ import { join, resolve } from "node:path"; const TOKEN_KEY = "TINY_ENGINEER_TOKEN"; const URL_KEY = "TINY_ENGINEER_URL"; +const SERIAL_KEY = "TINY_ENGINEER_SERIAL"; export const DEFAULT_URL = "http://tiny-engineer.local"; /** @@ -103,3 +104,13 @@ export function getBaseUrl() { const trimmed = value.trim().replace(/\/+$/, ""); return trimmed || DEFAULT_URL; } + +/** + * @returns {string | null} trimmed TINY_ENGINEER_SERIAL path, or null if unset/empty + */ +export function getSerialPath() { + const value = process.env[SERIAL_KEY]; + if (value === undefined) return null; + const trimmed = value.trim(); + return trimmed ? trimmed : null; +} diff --git a/packages/tiny-engineer-cli/src/robot/client.js b/packages/tiny-engineer-cli/src/robot/client.js index 5c95a73a..dde111fc 100644 --- a/packages/tiny-engineer-cli/src/robot/client.js +++ b/packages/tiny-engineer-cli/src/robot/client.js @@ -1,3 +1,8 @@ +import { robotFetch } from "./http.js"; +import { request } from "./transport.js"; + +export { robotFetch }; + /** Allowed POST /anim?name= values (firmware allow-list). */ export const ANIM_NAMES = new Set([ "none", @@ -89,47 +94,25 @@ function unreachableMessage(err) { return `robot unreachable: ${detail}`; } -/** - * fetch() against the robot: joins base URL and path and adds the Bearer token when set. - * @param {string} baseUrl - * @param {string} path - * @param {RequestInit} init - * @param {string | null} [token] - */ -export function robotFetch(baseUrl, path, init, token = null) { - const headers = { ...init.headers }; - if (token) headers.Authorization = `Bearer ${token}`; - return fetch(`${baseUrl.replace(/\/+$/, "")}${path}`, { ...init, headers }); -} - /** * POST /anim?name=…. Never throws — returns a RobotResult. - * @param {string} baseUrl + * @param {string | import("./transport.js").Transport} baseUrlOrTransport * @param {string} animName * @param {string | null} [token] * @returns {Promise} */ -export async function postAnim(baseUrl, animName, token = null) { - try { - const response = await robotFetch( - baseUrl, - `/anim?name=${encodeURIComponent(animName)}`, - { method: "POST", signal: AbortSignal.timeout(2000) }, - token, - ); - const body = await response.text(); - if (response.ok) { - return { ok: true, status: response.status, body }; - } - return { - ok: false, - status: response.status, - body, - error: errorFromBody(body, response.status), - }; - } catch (err) { - return { ok: false, error: unreachableMessage(err) }; - } +export async function postAnim(baseUrlOrTransport, animName, token = null) { + const transport = + typeof baseUrlOrTransport === "string" + ? { type: /** @type {const} */ ("http"), baseUrl: baseUrlOrTransport } + : baseUrlOrTransport; + return request(transport, { + method: "POST", + path: "/anim", + query: { name: animName }, + token, + timeoutMs: 2000, + }); } // The robot answers once the clip has finished playing. diff --git a/packages/tiny-engineer-cli/src/robot/http.js b/packages/tiny-engineer-cli/src/robot/http.js new file mode 100644 index 00000000..75d0ed65 --- /dev/null +++ b/packages/tiny-engineer-cli/src/robot/http.js @@ -0,0 +1,12 @@ +/** + * fetch() against the robot: joins base URL and path and adds the Bearer token when set. + * @param {string} baseUrl + * @param {string} path + * @param {RequestInit} init + * @param {string | null} [token] + */ +export function robotFetch(baseUrl, path, init, token = null) { + const headers = { ...init.headers }; + if (token) headers.Authorization = `Bearer ${token}`; + return fetch(`${baseUrl.replace(/\/+$/, "")}${path}`, { ...init, headers }); +} diff --git a/packages/tiny-engineer-cli/src/robot/serial-cache.js b/packages/tiny-engineer-cli/src/robot/serial-cache.js new file mode 100644 index 00000000..d0d39740 --- /dev/null +++ b/packages/tiny-engineer-cli/src/robot/serial-cache.js @@ -0,0 +1,83 @@ +import { mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { dirname, join } from "node:path"; + +/** @type {string | null | undefined} */ +let memoryPath; + +/** @type {string | undefined} */ +let cacheFileOverride; + +/** + * Override cache file path (tests). Pass undefined to restore default. + * @param {string | undefined} filePath + */ +export function setSerialCacheFileForTests(filePath) { + cacheFileOverride = filePath; + memoryPath = undefined; +} + +/** + * @returns {string} + */ +export function defaultSerialCacheFile() { + return join(homedir(), ".cache", "tiny-engineer", "serial-port.json"); +} + +/** + * @returns {string} + */ +function cacheFile() { + return cacheFileOverride ?? defaultSerialCacheFile(); +} + +/** + * @returns {string | null} + */ +export function readSerialCache() { + if (memoryPath !== undefined) { + return memoryPath; + } + + try { + const raw = readFileSync(cacheFile(), "utf8"); + const json = JSON.parse(raw); + if (typeof json?.path === "string" && json.path.trim()) { + memoryPath = json.path.trim(); + return memoryPath; + } + } catch { + // missing / invalid + } + + memoryPath = null; + return null; +} + +/** + * @param {string} path + */ +export function writeSerialCache(path) { + const trimmed = path.trim(); + memoryPath = trimmed; + const file = cacheFile(); + try { + mkdirSync(dirname(file), { recursive: true }); + writeFileSync( + file, + `${JSON.stringify({ path: trimmed, updatedAt: Date.now() })}\n`, + "utf8", + ); + } catch { + // best-effort; memory still holds path for this process + } +} + +export function clearSerialCache() { + memoryPath = null; + try { + unlinkSync(cacheFile()); + } catch { + // already gone + } +} diff --git a/packages/tiny-engineer-cli/src/robot/serial-detect.js b/packages/tiny-engineer-cli/src/robot/serial-detect.js new file mode 100644 index 00000000..b8ca1121 --- /dev/null +++ b/packages/tiny-engineer-cli/src/robot/serial-detect.js @@ -0,0 +1,292 @@ +import { SerialPort } from "serialport"; +import { + clearSerialCache, + readSerialCache, + writeSerialCache, +} from "./serial-cache.js"; +import { serialRequest } from "./serial.js"; + +/** Espressif USB VID (native USB-Serial/JTAG on C3/S3/…); serialport may return hex with/without 0x. */ +export const ESPRESSIF_VID = "303A"; +/** USB-Serial/JTAG function PID (ESP32-C3 / S3 / C6 / …). */ +export const USB_SERIAL_JTAG_PID = "1001"; +/** Health probe budget per candidate (Windows USB open can be slow). */ +export const PROBE_TIMEOUT_MS = 800; + +/** + * @typedef {{ + * path: string, + * manufacturer?: string, + * friendlyName?: string, + * vendorId?: string, + * productId?: string, + * }} SerialPortInfo + */ + +/** + * @param {string | undefined} id + * @returns {string} + */ +export function normalizeUsbId(id) { + if (!id) return ""; + return id.replace(/^0x/i, "").toUpperCase(); +} + +/** + * @param {string | undefined} vendorId + * @returns {boolean} + */ +export function isEspressifVid(vendorId) { + return normalizeUsbId(vendorId) === ESPRESSIF_VID; +} + +/** + * @param {SerialPortInfo} port + * @returns {boolean} + */ +export function isEspressifDevice(port) { + if (isEspressifVid(port.vendorId)) return true; + const mfg = (port.manufacturer ?? "").toLowerCase(); + if (mfg.includes("espressif")) return true; + const name = (port.friendlyName ?? "").toLowerCase(); + // Windows Device Manager style names + if (name.includes("usb jtag") || name.includes("usb-serial/jtag")) return true; + return false; +} + +/** + * macOS: call-out devices use cu.*; serialport often only lists tty.*. + * Map tty.foo → cu.foo for opening. Linux /dev/ttyACM* is untouched (no "tty."). + * @param {string} path + * @returns {string} + */ +export function preferCalloutPath(path) { + if (path.startsWith("/dev/tty.")) { + return `/dev/cu.${path.slice("/dev/tty.".length)}`; + } + return path; +} + +/** + * Inverse of preferCalloutPath (macOS only). + * @param {string} path + * @returns {string | null} + */ +export function dialinTwinPath(path) { + if (path.startsWith("/dev/cu.")) { + return `/dev/tty.${path.slice("/dev/cu.".length)}`; + } + return null; +} + +/** + * @param {string} a + * @param {string} b + * @returns {boolean} + */ +export function serialPathsEqual(a, b) { + return normalizePortPath(a) === normalizePortPath(b); +} + +/** + * Normalize Windows COM casing for stable cache keys. + * @param {string} path + * @returns {string} + */ +export function normalizePortPath(path) { + const callout = preferCalloutPath(path); + if (/^com\d+$/i.test(callout)) { + return callout.toUpperCase(); + } + return callout; +} + +/** + * Path looks like a USB CDC / ACM serial device (not Bluetooth / macOS debug). + * @param {string} path + * @returns {boolean} + */ +export function looksLikeCdcPath(path) { + const p = path.toLowerCase(); + if (p.includes("bluetooth")) return false; + if (p.includes("debug-console") || p.includes("wlan-debug")) return false; + if (p.includes("bose") || p.includes("airpod") || p.includes("headset")) return false; + // Windows: COM3, com12 + if (/^com\d+$/i.test(path)) return true; + // macOS native CDC + if (p.includes("usbmodem")) return true; + // Linux CDC-ACM (ESP32-C3 native USB) + if (/\/ttyacm\d*$/i.test(path) || p.includes("ttyacm")) return true; + return false; +} + +/** + * Sort: Espressif USB-Serial/JTAG PID first, then other Espressif, then rest. + * @param {SerialPortInfo[]} ports + * @returns {SerialPortInfo[]} + */ +export function rankSerialCandidates(ports) { + return [...ports].sort((a, b) => scorePort(b) - scorePort(a)); +} + +/** + * @param {SerialPortInfo} port + * @returns {number} + */ +function scorePort(port) { + let score = 0; + if (isEspressifDevice(port)) score += 100; + if (normalizeUsbId(port.productId) === USB_SERIAL_JTAG_PID) score += 50; + if (looksLikeCdcPath(port.path)) score += 10; + return score; +} + +/** + * Prefer Espressif devices; else CDC-like paths. + * Normalize macOS tty→cu, Windows COM casing; dedupe. + * @param {SerialPortInfo[]} ports + * @returns {SerialPortInfo[]} + */ +export function filterSerialCandidates(ports) { + /** @type {Map} */ + const byPath = new Map(); + for (const p of ports) { + const path = normalizePortPath(p.path); + const prev = byPath.get(path); + if (!prev || scorePort(p) > scorePort(prev)) { + byPath.set(path, { ...p, path }); + } + } + const normalized = [...byPath.values()]; + const espressif = normalized.filter((p) => isEspressifDevice(p)); + const pool = espressif.length > 0 ? espressif : normalized.filter((p) => looksLikeCdcPath(p.path)); + return rankSerialCandidates(pool); +} + +/** + * @param {string} body + * @returns {boolean} + */ +function healthBodyOk(body) { + try { + const json = JSON.parse(body); + return json?.ok === true; + } catch { + return false; + } +} + +/** + * @param {string} path + * @param {typeof serialRequest} requestFn + * @returns {Promise} + */ +async function probeOnce(path, requestFn) { + const result = await requestFn(path, { + method: "GET", + path: "/health", + timeoutMs: PROBE_TIMEOUT_MS, + clearCacheOnUnreachable: false, + }); + return Boolean(result.ok && result.body && healthBodyOk(result.body)); +} + +/** + * Probe preferred open path; on macOS fall back to tty twin if cu fails. + * @param {string} path + * @param {typeof serialRequest} [requestFn] + * @returns {Promise} matching open path, or null + */ +export async function probeTinyEngineerHealth(path, requestFn = serialRequest) { + const primary = normalizePortPath(path); + if (await probeOnce(primary, requestFn)) return primary; + const twin = dialinTwinPath(primary); + if (twin && (await probeOnce(twin, requestFn))) return twin; + return null; +} + +/** + * @param {SerialPortInfo[]} ports + * @param {typeof serialRequest} [requestFn] + * @returns {Promise} + */ +export async function probeMatchingPaths(ports, requestFn = serialRequest) { + const results = await Promise.all( + ports.map(async (p) => probeTinyEngineerHealth(p.path, requestFn)), + ); + /** @type {string[]} */ + const matches = []; + for (const path of results) { + if (path && !matches.some((m) => serialPathsEqual(m, path))) { + matches.push(path); + } + } + return matches; +} + +/** + * @returns {Promise} + */ +export async function listSerialPortsDetailed() { + const ports = await SerialPort.list(); + return ports.map((p) => ({ + path: p.path, + manufacturer: p.manufacturer, + friendlyName: p.friendlyName, + vendorId: p.vendorId, + productId: p.productId, + })); +} + +/** + * Auto-detect a single Tiny Engineer serial port (cache-aware). + * @param {{ + * listFn?: () => Promise, + * requestFn?: typeof serialRequest, + * skipCache?: boolean, + * }} [opts] + * @returns {Promise} + */ +export async function detectSerialPort(opts = {}) { + const listFn = opts.listFn ?? listSerialPortsDetailed; + const requestFn = opts.requestFn ?? serialRequest; + + if (!opts.skipCache) { + const cached = readSerialCache(); + if (cached) { + const ports = await listFn(); + const stillPresent = ports.some((p) => serialPathsEqual(p.path, cached)); + if (stillPresent) { + const openPath = await probeTinyEngineerHealth(cached, requestFn); + if (openPath) { + writeSerialCache(openPath); + return openPath; + } + } + clearSerialCache(); + } + } + + const ports = await listFn(); + const candidates = filterSerialCandidates(ports); + const matches = await probeMatchingPaths(candidates, requestFn); + + if (matches.length === 1) { + writeSerialCache(matches[0]); + return matches[0]; + } + + if (matches.length === 0) { + if (candidates.length > 0) { + const listed = candidates.map((c) => c.path).join(", "); + throw new Error( + `no Tiny Engineer reply on ${listed} (close pio monitor / other serial apps, wait for boot, or pass --serial )`, + ); + } + throw new Error( + "no Tiny Engineer serial port found (try `tiny-engineer ports` or pass --serial )", + ); + } + + throw new Error(`multiple Tiny Engineer ports: ${matches.join(", ")}`); +} diff --git a/packages/tiny-engineer-cli/src/robot/serial.js b/packages/tiny-engineer-cli/src/robot/serial.js new file mode 100644 index 00000000..2f5528f1 --- /dev/null +++ b/packages/tiny-engineer-cli/src/robot/serial.js @@ -0,0 +1,412 @@ +import { SerialPort } from "serialport"; +import { clearSerialCache } from "./serial-cache.js"; + +/** @typedef {import("./client.js").RobotResult} RobotResult */ + +/** + * @param {RobotResult} result + * @returns {boolean} + */ +function isUnreachable(result) { + return Boolean(result.error?.startsWith("robot unreachable:")); +} + +let nextId = 1; + +/** + * @param {Record} query + * @returns {Record} + */ +function stringQuery(query) { + /** @type {Record} */ + const out = {}; + for (const [k, v] of Object.entries(query ?? {})) { + if (v !== undefined) out[k] = String(v); + } + return out; +} + +/** + * Encode one serial API request line (no trailing newline). + * @param {{ id?: number, method: string, path: string, query?: Record }} opts + */ +export function encodeSerialRequest({ id, method, path, query }) { + const reqId = id ?? nextId++; + return { + id: reqId, + line: JSON.stringify({ + v: 1, + id: reqId, + op: `${method} ${path}`, + q: stringQuery(query), + }), + }; +} + +/** + * Encode serial POST /play begin line (JSON + len; body follows as raw bytes). + * @param {{ id?: number, name?: string, len: number }} opts + */ +export function encodeSerialPlayBegin({ id, name, len }) { + const reqId = id ?? nextId++; + const path = name ? `/play/${name}` : "/play"; + return { + id: reqId, + line: JSON.stringify({ + v: 1, + id: reqId, + op: `POST ${path}`, + len, + }), + }; +} + +/** Same ceiling as HTTP play; robot answers when the clip finishes. */ +export const SERIAL_PLAY_TIMEOUT_MS = 10 * 60 * 1000; + +/** 16-bit mono @ 22050 Hz ≈ bytes/sec of PCM. Match realtime; device has + * 8 KiB CDC RX + 8 KiB software ring to absorb I2S.write blocking. */ +const SERIAL_PLAY_BYTES_PER_SEC = 22050 * 2; +const SERIAL_PLAY_PACE = 1.0; +const SERIAL_PLAY_CHUNK = 2048; + +/** + * Write WAV body paced near realtime so ESP32 USB CDC RX does not overflow. + * @param {import("serialport").SerialPort} port + * @param {Uint8Array | Buffer} wav + * @returns {Promise} + */ +export function writeSerialPlayBody(port, wav) { + const buf = Buffer.from(wav); + const started = Date.now(); + + /** + * @param {number} offset + * @returns {Promise} + */ + const writeNext = (offset) => { + if (offset >= buf.length) return Promise.resolve(); + const end = Math.min(offset + SERIAL_PLAY_CHUNK, buf.length); + const chunk = buf.subarray(offset, end); + + return new Promise((resolve, reject) => { + port.write(chunk, (err) => { + if (err) { + reject(err); + return; + } + port.drain((drainErr) => { + if (drainErr) { + reject(drainErr); + return; + } + const elapsed = Date.now() - started; + const targetMs = (end / (SERIAL_PLAY_BYTES_PER_SEC * SERIAL_PLAY_PACE)) * 1000; + const wait = Math.max(0, Math.ceil(targetMs - elapsed)); + if (wait === 0) { + writeNext(end).then(resolve, reject); + return; + } + setTimeout(() => { + writeNext(end).then(resolve, reject); + }, wait); + }); + }); + }); + }; + + return writeNext(0); +} + +/** + * Parse an `R…` reply line into status + body. + * @param {string} line + * @returns {{ id?: number, status: number, body: unknown } | null} + */ +export function parseSerialReply(line) { + const trimmed = line.trim(); + if (!trimmed.startsWith("R{")) return null; + try { + const json = JSON.parse(trimmed.slice(1)); + if (typeof json?.status !== "number") return null; + return { + id: typeof json.id === "number" ? json.id : undefined, + status: json.status, + body: json.body, + }; + } catch { + return null; + } +} + +/** + * @param {unknown} body + * @param {number} status + * @returns {string} + */ +function errorFromBody(body, status) { + if (body && typeof body === "object" && typeof body.error === "string" && body.error) { + return body.error; + } + if (typeof body === "string" && body.trim()) return body.trim(); + return `HTTP ${status}`; +} + +/** + * @param {string} path + * @param {{ + * method: string, + * path: string, + * query?: Record, + * timeoutMs?: number, + * clearCacheOnUnreachable?: boolean, + * }} opts + * @returns {Promise} + */ +export async function serialRequest(path, opts) { + const timeoutMs = opts.timeoutMs ?? 2000; + const clearOnFail = opts.clearCacheOnUnreachable !== false; + const { id, line } = encodeSerialRequest({ + method: opts.method, + path: opts.path, + query: opts.query, + }); + + return new Promise((resolve) => { + /** @type {SerialPort | null} */ + let port = null; + let settled = false; + let buffer = ""; + + const finish = (result) => { + if (settled) return; + settled = true; + clearTimeout(timer); + if (clearOnFail && isUnreachable(result)) { + clearSerialCache(); + } + if (port?.isOpen) { + port.close(() => resolve(result)); + } else { + resolve(result); + } + }; + + const timer = setTimeout(() => { + finish({ ok: false, error: `robot unreachable: serial timeout after ${timeoutMs}ms` }); + }, timeoutMs); + + try { + port = new SerialPort({ + path, + baudRate: 115200, + autoOpen: false, + }); + } catch (err) { + finish({ + ok: false, + error: `robot unreachable: ${err instanceof Error ? err.message : String(err)}`, + }); + return; + } + + port.on("error", (err) => { + finish({ + ok: false, + error: `robot unreachable: ${err instanceof Error ? err.message : String(err)}`, + }); + }); + + port.on("data", (chunk) => { + buffer += chunk.toString("utf8"); + let nl; + while ((nl = buffer.indexOf("\n")) >= 0) { + const raw = buffer.slice(0, nl); + buffer = buffer.slice(nl + 1); + const reply = parseSerialReply(raw); + if (!reply) continue; + if (reply.id !== undefined && reply.id !== id) continue; + + const bodyText = + reply.body === undefined || reply.body === null + ? "" + : typeof reply.body === "string" + ? reply.body + : JSON.stringify(reply.body); + + if (reply.status >= 200 && reply.status < 300) { + finish({ ok: true, status: reply.status, body: bodyText }); + } else { + finish({ + ok: false, + status: reply.status, + body: bodyText, + error: errorFromBody(reply.body, reply.status), + }); + } + return; + } + }); + + port.open((err) => { + if (err) { + finish({ + ok: false, + error: `robot unreachable: ${err.message}`, + }); + return; + } + port.write(`${line}\n`, (writeErr) => { + if (writeErr) { + finish({ + ok: false, + error: `robot unreachable: ${writeErr.message}`, + }); + } + }); + }); + }); +} + +/** + * Stream a WAV to POST /play over serial (begin JSON + raw bytes + R reply). + * @param {string} path + * @param {{ + * wav: Uint8Array | Buffer, + * name?: string, + * timeoutMs?: number, + * clearCacheOnUnreachable?: boolean, + * }} opts + * @returns {Promise} + */ +export async function serialPlay(path, opts) { + const timeoutMs = opts.timeoutMs ?? SERIAL_PLAY_TIMEOUT_MS; + const clearOnFail = opts.clearCacheOnUnreachable !== false; + const wav = opts.wav; + const { id, line } = encodeSerialPlayBegin({ + name: opts.name, + len: wav.byteLength, + }); + + return new Promise((resolve) => { + /** @type {SerialPort | null} */ + let port = null; + let settled = false; + let buffer = ""; + + const finish = (result) => { + if (settled) return; + settled = true; + clearTimeout(timer); + if (clearOnFail && isUnreachable(result)) { + clearSerialCache(); + } + if (port?.isOpen) { + port.close(() => resolve(result)); + } else { + resolve(result); + } + }; + + const timer = setTimeout(() => { + finish({ ok: false, error: `robot unreachable: serial timeout after ${timeoutMs}ms` }); + }, timeoutMs); + + try { + port = new SerialPort({ + path, + baudRate: 115200, + autoOpen: false, + }); + } catch (err) { + finish({ + ok: false, + error: `robot unreachable: ${err instanceof Error ? err.message : String(err)}`, + }); + return; + } + + port.on("error", (err) => { + finish({ + ok: false, + error: `robot unreachable: ${err instanceof Error ? err.message : String(err)}`, + }); + }); + + port.on("data", (chunk) => { + buffer += chunk.toString("utf8"); + let nl; + while ((nl = buffer.indexOf("\n")) >= 0) { + const raw = buffer.slice(0, nl); + buffer = buffer.slice(nl + 1); + const reply = parseSerialReply(raw); + if (!reply) continue; + if (reply.id !== undefined && reply.id !== id) continue; + + const bodyText = + reply.body === undefined || reply.body === null + ? "" + : typeof reply.body === "string" + ? reply.body + : JSON.stringify(reply.body); + + if (reply.status >= 200 && reply.status < 300) { + finish({ ok: true, status: reply.status, body: bodyText }); + } else { + finish({ + ok: false, + status: reply.status, + body: bodyText, + error: errorFromBody(reply.body, reply.status), + }); + } + return; + } + }); + + port.open((err) => { + if (err) { + finish({ + ok: false, + error: `robot unreachable: ${err.message}`, + }); + return; + } + port.write(`${line}\n`, (writeErr) => { + if (writeErr) { + finish({ + ok: false, + error: `robot unreachable: ${writeErr.message}`, + }); + return; + } + writeSerialPlayBody(port, wav).catch((bodyErr) => { + finish({ + ok: false, + error: `robot unreachable: ${bodyErr instanceof Error ? bodyErr.message : String(bodyErr)}`, + }); + }); + }); + }); + }); +} + +/** + * @returns {Promise<{ + * path: string, + * manufacturer?: string, + * friendlyName?: string, + * vendorId?: string, + * productId?: string, + * }[]>} + */ +export async function listSerialPorts() { + const ports = await SerialPort.list(); + return ports.map((p) => ({ + path: p.path, + manufacturer: p.manufacturer, + friendlyName: p.friendlyName, + vendorId: p.vendorId, + productId: p.productId, + })); +} diff --git a/packages/tiny-engineer-cli/src/robot/transport.js b/packages/tiny-engineer-cli/src/robot/transport.js new file mode 100644 index 00000000..52624329 --- /dev/null +++ b/packages/tiny-engineer-cli/src/robot/transport.js @@ -0,0 +1,115 @@ +import { getBaseUrl, getSerialPath } from "../config/env.js"; +import { robotFetch } from "./http.js"; +import { detectSerialPort } from "./serial-detect.js"; +import { serialRequest } from "./serial.js"; + +/** @typedef {import("./client.js").RobotResult} RobotResult */ + +/** + * @typedef {{ type: "http", baseUrl: string } | { type: "serial", path: string }} Transport + */ + +/** + * Resolve CLI transport. + * Passing both `--url` and `--serial` is an error (caller checks flags). + * + * Precedence: explicit serial (including `"auto"`) → explicit url → + * TINY_ENGINEER_SERIAL → HTTP (TINY_ENGINEER_URL / default). + * + * @param {{ url?: string, serial?: string }} opts + * @returns {Promise} + */ +export async function resolveTransport({ url, serial } = {}) { + const serialSpec = serial ?? (url ? undefined : getSerialPath()) ?? undefined; + + if (serialSpec) { + if (serialSpec === "auto") { + const path = await detectSerialPort(); + return { type: "serial", path }; + } + return { type: "serial", path: serialSpec }; + } + + return { type: "http", baseUrl: url ?? getBaseUrl() }; +} + +/** + * @param {unknown} err + * @returns {string} + */ +function unreachableMessage(err) { + const detail = + err && typeof err === "object" && "cause" in err && err.cause instanceof Error + ? err.cause.message + : err instanceof Error + ? err.message + : String(err); + return `robot unreachable: ${detail}`; +} + +/** + * @param {unknown} body + * @param {number} status + * @returns {string} + */ +function errorFromBody(body, status) { + try { + const json = typeof body === "string" ? JSON.parse(body) : body; + if (typeof json?.error === "string" && json.error) return json.error; + } catch { + // not JSON + } + const trimmed = typeof body === "string" ? body.trim() : ""; + if (trimmed) return trimmed; + return `HTTP ${status}`; +} + +/** + * @param {Transport} transport + * @param {{ method: string, path: string, query?: Record, token?: string | null, timeoutMs?: number }} opts + * @returns {Promise} + */ +export async function request(transport, opts) { + if (transport.type === "serial") { + return serialRequest(transport.path, opts); + } + + const params = new URLSearchParams(); + for (const [k, v] of Object.entries(opts.query ?? {})) { + if (v !== undefined) params.set(k, v); + } + const qs = params.toString(); + const path = qs ? `${opts.path}?${qs}` : opts.path; + + try { + const response = await robotFetch( + transport.baseUrl, + path, + { + method: opts.method, + signal: AbortSignal.timeout(opts.timeoutMs ?? 2000), + }, + opts.token ?? null, + ); + const body = await response.text(); + if (response.ok) { + return { ok: true, status: response.status, body }; + } + return { + ok: false, + status: response.status, + body, + error: errorFromBody(body, response.status), + }; + } catch (err) { + return { ok: false, error: unreachableMessage(err) }; + } +} + +/** + * @param {Transport} transport + * @returns {string} + */ +export function transportLabel(transport) { + return transport.type === "serial" ? transport.path : transport.baseUrl; +} diff --git a/packages/tiny-engineer-cli/src/util/args.js b/packages/tiny-engineer-cli/src/util/args.js index 64c90fe7..14791b6b 100644 --- a/packages/tiny-engineer-cli/src/util/args.js +++ b/packages/tiny-engineer-cli/src/util/args.js @@ -28,17 +28,61 @@ export function stripGlobalFlags(argv) { return { quiet, rest: argv.slice(i) }; } +/** + * True if token looks like a serial device path (not a pose name). + * @param {string} value + * @returns {boolean} + */ +export function looksLikeSerialPath(value) { + if (!value || value.startsWith("-")) return false; + if (value === "auto") return false; + const lower = value.toLowerCase(); + if (/^com\d+$/i.test(value)) return true; + if (value.startsWith("/")) return true; + if (lower.includes("cu.") || lower.includes("tty.")) return true; + if (lower.includes("ttyacm") || lower.includes("ttyusb")) return true; + return false; +} + +/** + * Parse `--serial` / `--serial=` into a path or the `"auto"` sentinel. + * @param {string[]} argv + * @param {number} i index of `--serial` or `--serial=…` + * @returns {{ serial: string, nextIndex: number } | { error: string }} + */ +export function takeSerialFlag(argv, i) { + const arg = argv[i]; + if (arg.startsWith("--serial=")) { + const value = arg.slice("--serial=".length); + if (!value || value === "auto") return { serial: "auto", nextIndex: i }; + return { serial: value, nextIndex: i }; + } + + // bare --serial + const next = argv[i + 1]; + if (!next || next.startsWith("-")) { + return { serial: "auto", nextIndex: i }; + } + if (looksLikeSerialPath(next) || next === "auto") { + return { serial: next === "auto" ? "auto" : next, nextIndex: i + 1 }; + } + // Pose name or other positional — leave it for the caller. + return { serial: "auto", nextIndex: i }; +} + /** * Parse shared flags from argv. Flags may appear before or after positionals * (e.g. `hook cursor -q`). Non-flag tokens go to `rest` in order. * @param {string[]} argv - * @returns {{ help: boolean, quiet: boolean, url?: string, rest: string[], error?: string }} + * @returns {{ help: boolean, quiet: boolean, url?: string, serial?: string, rest: string[], error?: string }} */ export function parseCommonArgs(argv) { let help = false; let quiet = false; /** @type {string | undefined} */ let url; + /** @type {string | undefined} */ + let serial; /** @type {string[]} */ const rest = []; @@ -54,30 +98,50 @@ export function parseCommonArgs(argv) { } if (arg === "--url") { const next = argv[++i]; - if (!next) return { help, quiet, url, rest, error: "--url requires a value" }; + if (!next) return { help, quiet, url, serial, rest, error: "--url requires a value" }; url = next; continue; } if (arg.startsWith("--url=")) { url = arg.slice("--url=".length); - if (!url) return { help, quiet, url, rest, error: "--url requires a value" }; + if (!url) return { help, quiet, url, serial, rest, error: "--url requires a value" }; + continue; + } + if (arg === "--serial" || arg.startsWith("--serial=")) { + const taken = takeSerialFlag(argv, i); + if ("error" in taken) { + return { help, quiet, url, serial, rest, error: taken.error }; + } + serial = taken.serial; + i = taken.nextIndex; continue; } if (arg.startsWith("-")) { - return { help, quiet, url, rest, error: `Unknown argument: ${arg}` }; + return { help, quiet, url, serial, rest, error: `Unknown argument: ${arg}` }; } rest.push(arg); } - return { help, quiet, url, rest }; + if (url && serial) { + return { + help, + quiet, + url, + serial, + rest, + error: "use either --url or --serial, not both", + }; + } + + return { help, quiet, url, serial, rest }; } /** * @param {string[]} argv arguments after `play` - * @returns {{ wavPath?: string, name?: string, url?: string, quiet: boolean, error?: string }} + * @returns {{ wavPath?: string, name?: string, url?: string, serial?: string, quiet: boolean, error?: string }} */ export function parsePlayArgs(argv) { - /** @type {{ wavPath?: string, name?: string, url?: string, quiet: boolean }} */ + /** @type {{ wavPath?: string, name?: string, url?: string, serial?: string, quiet: boolean }} */ const opts = { quiet: false }; for (let i = 0; i < argv.length; i++) { @@ -87,10 +151,28 @@ export function parsePlayArgs(argv) { opts.quiet = true; continue; } - if (arg === "--name" || arg === "--url") { + if (arg === "--name") { + const value = argv[++i]; + if (!value || value.startsWith("-")) return { quiet: opts.quiet, error: "--name requires a value" }; + opts.name = value; + continue; + } + if (arg === "--url") { const value = argv[++i]; - if (!value || value.startsWith("-")) return { quiet: opts.quiet, error: `${arg} requires a value` }; - opts[arg.slice(2)] = value; + if (!value || value.startsWith("-")) return { quiet: opts.quiet, error: "--url requires a value" }; + opts.url = value; + continue; + } + if (arg.startsWith("--url=")) { + opts.url = arg.slice("--url=".length); + if (!opts.url) return { quiet: opts.quiet, error: "--url requires a value" }; + continue; + } + if (arg === "--serial" || arg.startsWith("--serial=")) { + const taken = takeSerialFlag(argv, i); + if ("error" in taken) return { quiet: opts.quiet, error: taken.error }; + opts.serial = taken.serial; + i = taken.nextIndex; continue; } if (arg.startsWith("-") || opts.wavPath) { @@ -99,13 +181,16 @@ export function parsePlayArgs(argv) { opts.wavPath = arg; } + if (opts.url && opts.serial) { + return { quiet: opts.quiet, error: "use either --url or --serial, not both" }; + } if (!opts.wavPath) return { quiet: opts.quiet, error: "play needs a WAV file" }; return opts; } /** * @param {string[]} argv arguments after `anim` - * @returns {{ name?: string, url?: string, help: boolean, quiet: boolean, error?: string }} + * @returns {{ name?: string, url?: string, serial?: string, help: boolean, quiet: boolean, error?: string }} */ export function parseAnimArgs(argv) { let help = false; @@ -113,6 +198,8 @@ export function parseAnimArgs(argv) { /** @type {string | undefined} */ let url; /** @type {string | undefined} */ + let serial; + /** @type {string | undefined} */ let name; for (let i = 0; i < argv.length; i++) { @@ -136,11 +223,21 @@ export function parseAnimArgs(argv) { if (!url) return { help, quiet, error: "--url requires a value" }; continue; } + if (arg === "--serial" || arg.startsWith("--serial=")) { + const taken = takeSerialFlag(argv, i); + if ("error" in taken) return { help, quiet, error: taken.error }; + serial = taken.serial; + i = taken.nextIndex; + continue; + } if (arg.startsWith("-")) return { help, quiet, error: `Unknown argument: ${arg}` }; if (name) return { help, quiet, error: `Unexpected argument: ${arg}` }; name = arg; } + if (url && serial) { + return { help, quiet, error: "use either --url or --serial, not both" }; + } if (!help && !name) return { help, quiet, error: "anim needs a pose name" }; - return { help, quiet, name, url }; + return { help, quiet, name, url, serial }; } diff --git a/packages/tiny-engineer-cli/src/wizard/doctor.js b/packages/tiny-engineer-cli/src/wizard/doctor.js index 64380ecd..c5056745 100644 --- a/packages/tiny-engineer-cli/src/wizard/doctor.js +++ b/packages/tiny-engineer-cli/src/wizard/doctor.js @@ -1,9 +1,10 @@ import { readFileSync } from "node:fs"; import { join } from "node:path"; -import { DEFAULT_URL, getBaseUrl, getToken } from "../config/env.js"; +import { DEFAULT_URL, getBaseUrl, getSerialPath, getToken } from "../config/env.js"; import { all as allIntegrations } from "../integrations/registry.js"; -import { robotFetch } from "../robot/client.js"; +import { request, resolveTransport, transportLabel } from "../robot/transport.js"; +import { parseCommonArgs } from "../util/args.js"; import { readJsonFile } from "./merge.js"; /** @@ -33,25 +34,20 @@ export function configHasHook(projectRoot, ideId, relativePath) { } /** - * @param {string} baseUrl + * @param {import("../robot/transport.js").Transport} transport * @returns {Promise<{ ok: boolean, detail: string }>} */ -export async function checkHealth(baseUrl) { - try { - const res = await robotFetch( - baseUrl, - "/health", - { method: "GET", signal: AbortSignal.timeout(2000) }, - getToken(), - ); - const body = await res.text(); - if (res.ok) return { ok: true, detail: body.trim() || `HTTP ${res.status}` }; - return { ok: false, detail: body.trim() || `HTTP ${res.status}` }; - } catch (err) { - const detail = - err instanceof Error ? err.message : String(err); - return { ok: false, detail }; +export async function checkHealth(transport) { + const result = await request(transport, { + method: "GET", + path: "/health", + token: getToken(), + timeoutMs: 2000, + }); + if (result.ok) { + return { ok: true, detail: (result.body ?? "").trim() || `HTTP ${result.status}` }; } + return { ok: false, detail: result.error || result.body || "health failed" }; } /** @@ -60,26 +56,46 @@ export async function checkHealth(baseUrl) { * @param {{ projectRoot?: string }} [opts] */ export async function runDoctor(out, argv, { projectRoot = process.cwd() } = {}) { - for (const arg of argv) { - if (arg === "-h" || arg === "--help") { - console.log(`Usage: tiny-engineer doctor + const opts = parseCommonArgs(argv); + if (opts.error) { + out.error(opts.error); + process.exitCode = 1; + return; + } + if (opts.help) { + console.log(`Usage: tiny-engineer doctor [--url |--serial ] -Report robot URL/token, detected IDE configs, and /health. +Report robot transport, token, detected IDE configs, and /health. `); - return; - } - if (arg.startsWith("-")) { - out.error(`Unknown argument: ${arg}`); - process.exitCode = 1; - return; - } + return; + } + if (opts.rest.length > 0) { + out.error(`Unknown argument: ${opts.rest[0]}`); + process.exitCode = 1; + return; } - const url = getBaseUrl() || DEFAULT_URL; + /** @type {import("../robot/transport.js").Transport} */ + let transport; + try { + transport = await resolveTransport({ url: opts.url, serial: opts.serial }); + } catch (err) { + out.error(err instanceof Error ? err.message : String(err)); + process.exitCode = 1; + return; + } const token = getToken(); - out.ok(`url: ${url}`); - out.info(`token: ${token ? "set" : "not set"}`); + out.ok(`transport: ${transport.type} (${transportLabel(transport)})`); + if (transport.type === "http") { + out.info(`url default: ${getBaseUrl() || DEFAULT_URL}`); + } else { + const envSerial = getSerialPath(); + out.info( + `serial env: ${envSerial ? envSerial : "not set"} (auto cache: ~/.cache/tiny-engineer/serial-port.json)`, + ); + } + out.info(`token: ${token ? "set" : "not set"} (HTTP Bearer; unused on serial)`); for (const integration of allIntegrations()) { const detected = Boolean(integration.detect?.(projectRoot)); @@ -97,13 +113,11 @@ Report robot URL/token, detected IDE configs, and /health. out.info(".env: absent"); } - const health = await checkHealth(url); + const health = await checkHealth(transport); if (health.ok) { out.ok(`health: ok (${health.detail.slice(0, 80)})`); } else { out.error(`health: ${health.detail}`); - // Non-fatal for doctor — still exit 0 unless user expects otherwise. - // Plan: print ok/fail (not fatal). } process.exitCode = 0; } diff --git a/packages/tiny-engineer-cli/src/wizard/merge.js b/packages/tiny-engineer-cli/src/wizard/merge.js index 7bf596b8..afc9cbc2 100644 --- a/packages/tiny-engineer-cli/src/wizard/merge.js +++ b/packages/tiny-engineer-cli/src/wizard/merge.js @@ -30,33 +30,34 @@ export function writeJsonFile(path, data) { } /** - * Merge TINY_ENGINEER_URL into a `.env` file. * @param {string} envPath - * @param {string} url + * @param {string} key + * @param {string} value * @param {{ force?: boolean }} [opts] * @returns {"created" | "updated" | "skipped"} */ -export function mergeEnvUrl(envPath, url, { force = false } = {}) { +function mergeEnvAssignment(envPath, key, value, { force = false } = {}) { mkdirSync(dirname(envPath), { recursive: true }); let raw = ""; if (existsSync(envPath)) { raw = readFileSync(envPath, "utf8"); } + const keyRe = new RegExp(`^\\s*${key}\\s*=`); const lines = raw ? raw.split(/\r?\n/) : []; let found = false; const next = lines.map((line) => { - if (!/^\s*TINY_ENGINEER_URL\s*=/.test(line)) return line; + if (!keyRe.test(line)) return line; found = true; if (!force) return line; - return `TINY_ENGINEER_URL=${url}`; + return `${key}=${value}`; }); if (found && !force) return "skipped"; if (!found) { if (next.length && next[next.length - 1] !== "") next.push(""); - next.push(`TINY_ENGINEER_URL=${url}`); + next.push(`${key}=${value}`); writeFileSync(envPath, `${next.join("\n").replace(/\n+$/, "\n")}`, "utf8"); return raw ? "updated" : "created"; } @@ -65,10 +66,55 @@ export function mergeEnvUrl(envPath, url, { force = false } = {}) { return "updated"; } -const ENV_TE_LINE = /^\s*TINY_ENGINEER_(URL|TOKEN)\s*=/; +/** + * Merge TINY_ENGINEER_URL into a `.env` file. + * @param {string} envPath + * @param {string} url + * @param {{ force?: boolean }} [opts] + * @returns {"created" | "updated" | "skipped"} + */ +export function mergeEnvUrl(envPath, url, { force = false } = {}) { + return mergeEnvAssignment(envPath, "TINY_ENGINEER_URL", url, { force }); +} + +/** + * Merge TINY_ENGINEER_SERIAL into a `.env` file. + * @param {string} envPath + * @param {string} serialPath path or `auto` + * @param {{ force?: boolean }} [opts] + * @returns {"created" | "updated" | "skipped"} + */ +export function mergeEnvSerial(envPath, serialPath, { force = false } = {}) { + return mergeEnvAssignment(envPath, "TINY_ENGINEER_SERIAL", serialPath, { force }); +} + +/** + * Remove one KEY=… line from `.env` (exact key name). + * @param {string} envPath + * @param {string} key + * @returns {"removed" | "absent" | "unchanged"} + */ +export function stripEnvKey(envPath, key) { + if (!existsSync(envPath)) return "absent"; + const keyRe = new RegExp(`^\\s*${key}\\s*=`); + const raw = readFileSync(envPath, "utf8"); + const lines = raw.split(/\r?\n/); + const kept = lines.filter((line) => !keyRe.test(line)); + if (kept.length === lines.length) return "unchanged"; + + while (kept.length > 0 && kept[kept.length - 1] === "") kept.pop(); + if (kept.length === 0) { + writeFileSync(envPath, "", "utf8"); + } else { + writeFileSync(envPath, `${kept.join("\n")}\n`, "utf8"); + } + return "removed"; +} + +const ENV_TE_LINE = /^\s*TINY_ENGINEER_(URL|TOKEN|SERIAL)\s*=/; /** - * Remove TINY_ENGINEER_URL / TINY_ENGINEER_TOKEN lines from `.env`. + * Remove TINY_ENGINEER_URL / TOKEN / SERIAL lines from `.env`. * Leaves the file if other content remains; does not delete a non-empty file. * @param {string} envPath * @returns {"removed" | "absent" | "unchanged"} diff --git a/packages/tiny-engineer-cli/src/wizard/prompt.js b/packages/tiny-engineer-cli/src/wizard/prompt.js index 43eef6a2..c01f32bd 100644 --- a/packages/tiny-engineer-cli/src/wizard/prompt.js +++ b/packages/tiny-engineer-cli/src/wizard/prompt.js @@ -29,6 +29,31 @@ export async function confirm(question, defaultYes = true) { return answer === "y" || answer === "yes"; } +/** + * Numbered single-select. Empty enter keeps defaultId (or first option). + * @param {string} title + * @param {{ id: string, label: string }[]} options + * @param {string} [defaultId] + * @returns {Promise} selected id + */ +export async function choose(title, options, defaultId) { + if (options.length === 0) return defaultId ?? ""; + const fallback = + defaultId && options.some((o) => o.id === defaultId) ? defaultId : options[0].id; + console.log(title); + options.forEach((opt, i) => { + const mark = opt.id === fallback ? " (default)" : ""; + console.log(` ${i + 1}) ${opt.label}${mark}`); + }); + const raw = await ask("Select number", ""); + if (!raw) return fallback; + const n = Number(raw); + if (Number.isInteger(n) && n >= 1 && n <= options.length) { + return options[n - 1].id; + } + return fallback; +} + /** * Numbered multi-select. Empty enter selects all. * @param {string} title diff --git a/packages/tiny-engineer-cli/src/wizard/setup.js b/packages/tiny-engineer-cli/src/wizard/setup.js index eaeef932..8880336f 100644 --- a/packages/tiny-engineer-cli/src/wizard/setup.js +++ b/packages/tiny-engineer-cli/src/wizard/setup.js @@ -1,11 +1,18 @@ import { existsSync } from "node:fs"; import { join } from "node:path"; -import { DEFAULT_URL, getBaseUrl } from "../config/env.js"; +import { DEFAULT_URL, getBaseUrl, getSerialPath } from "../config/env.js"; import { all as allIntegrations, get as getIntegration } from "../integrations/registry.js"; +import { takeSerialFlag } from "../util/args.js"; import { hookCommandFor } from "./cliPath.js"; -import { mergeEnvUrl, readJsonFile, writeJsonFile } from "./merge.js"; -import { ask, confirm, multiSelect } from "./prompt.js"; +import { + mergeEnvSerial, + mergeEnvUrl, + readJsonFile, + stripEnvKey, + writeJsonFile, +} from "./merge.js"; +import { ask, choose, confirm, multiSelect } from "./prompt.js"; /** Cold `npx` can exceed a few seconds; keep hook timeout comfortable. */ const DEFAULT_TIMEOUT = 30; @@ -18,7 +25,9 @@ const DEFAULT_TIMEOUT = 30; * all: boolean, * dryRun: boolean, * forceUrl: boolean, + * forceSerial: boolean, * url?: string, + * serial?: string, * command?: string, * ides: string[], * error?: string, @@ -30,9 +39,12 @@ export function parseSetupArgs(argv) { let all = false; let dryRun = false; let forceUrl = false; + let forceSerial = false; /** @type {string | undefined} */ let url; /** @type {string | undefined} */ + let serial; + /** @type {string | undefined} */ let command; /** @type {string[]} */ const ides = []; @@ -59,35 +71,99 @@ export function parseSetupArgs(argv) { forceUrl = true; continue; } + if (arg === "--force-serial") { + forceSerial = true; + continue; + } if (arg === "--url") { const next = argv[++i]; - if (!next) return { help, yes, all, dryRun, forceUrl, ides, error: "--url requires a value" }; + if (!next) { + return { help, yes, all, dryRun, forceUrl, forceSerial, ides, error: "--url requires a value" }; + } url = next; continue; } if (arg.startsWith("--url=")) { url = arg.slice("--url=".length); - if (!url) return { help, yes, all, dryRun, forceUrl, ides, error: "--url requires a value" }; + if (!url) { + return { help, yes, all, dryRun, forceUrl, forceSerial, ides, error: "--url requires a value" }; + } + continue; + } + if (arg === "--serial" || arg.startsWith("--serial=")) { + const taken = takeSerialFlag(argv, i); + if ("error" in taken) { + return { help, yes, all, dryRun, forceUrl, forceSerial, ides, error: taken.error }; + } + serial = taken.serial; + i = taken.nextIndex; continue; } if (arg === "--command") { const next = argv[++i]; - if (!next) return { help, yes, all, dryRun, forceUrl, ides, error: "--command requires a value" }; + if (!next) { + return { + help, + yes, + all, + dryRun, + forceUrl, + forceSerial, + ides, + error: "--command requires a value", + }; + } command = next; continue; } if (arg.startsWith("--command=")) { command = arg.slice("--command=".length); - if (!command) return { help, yes, all, dryRun, forceUrl, ides, error: "--command requires a value" }; + if (!command) { + return { + help, + yes, + all, + dryRun, + forceUrl, + forceSerial, + ides, + error: "--command requires a value", + }; + } continue; } if (arg.startsWith("-")) { - return { help, yes, all, dryRun, forceUrl, ides, error: `Unknown argument: ${arg}` }; + return { + help, + yes, + all, + dryRun, + forceUrl, + forceSerial, + ides, + error: `Unknown argument: ${arg}`, + }; } ides.push(arg); } - return { help, yes, all, dryRun, forceUrl, url, command, ides }; + if (url && serial) { + return { + help, + yes, + all, + dryRun, + forceUrl, + forceSerial, + url, + serial, + command, + ides, + error: "pass --url or --serial, not both", + }; + } + + return { help, yes, all, dryRun, forceUrl, forceSerial, url, serial, command, ides }; } /** @@ -102,6 +178,49 @@ export function detectIntegrations(projectRoot) { })); } +/** + * @param {import("../util/output.js").Output} out + * @param {string} envPath + * @param {"http" | "serial"} mode + * @param {string} value + * @param {{ forceUrl: boolean, forceSerial: boolean, dryRun?: boolean }} flags + */ +function applyTransportEnv(out, envPath, mode, value, flags) { + if (flags.dryRun) { + if (mode === "http") { + out.info( + `.env TINY_ENGINEER_URL=${value}${flags.forceUrl ? " (force)" : " (if unset)"}; strip TINY_ENGINEER_SERIAL`, + ); + } else { + out.info( + `.env TINY_ENGINEER_SERIAL=${value}${flags.forceSerial ? " (force)" : " (if unset)"}; strip TINY_ENGINEER_URL`, + ); + } + return; + } + + if (mode === "http") { + const result = mergeEnvUrl(envPath, value, { force: flags.forceUrl }); + if (result === "skipped") { + out.info(".env TINY_ENGINEER_URL already set (use --force-url to replace)"); + } else { + out.ok(`.env TINY_ENGINEER_URL ${result}`); + } + const stripped = stripEnvKey(envPath, "TINY_ENGINEER_SERIAL"); + if (stripped === "removed") out.info(".env cleared TINY_ENGINEER_SERIAL"); + return; + } + + const result = mergeEnvSerial(envPath, value, { force: flags.forceSerial }); + if (result === "skipped") { + out.info(".env TINY_ENGINEER_SERIAL already set (use --force-serial to replace)"); + } else { + out.ok(`.env TINY_ENGINEER_SERIAL ${result}`); + } + const stripped = stripEnvKey(envPath, "TINY_ENGINEER_URL"); + if (stripped === "removed") out.info(".env cleared TINY_ENGINEER_URL"); +} + /** * @param {import("../util/output.js").Output} out * @param {string[]} argv @@ -158,11 +277,63 @@ export async function runSetup(out, argv, { projectRoot = process.cwd() } = {}) return; } - let url = args.url; - if (!args.yes) { - url = await ask("Robot base URL", url || getBaseUrl() || DEFAULT_URL); + /** @type {"http" | "serial"} */ + let mode; + /** @type {string} */ + let transportValue; + /** @type {string | undefined} */ + let urlForCtx; + + if (args.yes) { + if (args.serial) { + mode = "serial"; + transportValue = args.serial; + urlForCtx = undefined; + } else { + mode = "http"; + transportValue = args.url || getBaseUrl(); + urlForCtx = transportValue; + } + } else if (args.serial) { + mode = "serial"; + transportValue = await ask( + "Serial port (path or auto; list with: tiny-engineer ports)", + args.serial, + ); + urlForCtx = undefined; + } else if (args.url) { + mode = "http"; + transportValue = await ask("Robot base URL", args.url); + urlForCtx = transportValue; } else { - url = url || getBaseUrl(); + const defaultMode = getSerialPath() ? "serial" : "http"; + mode = /** @type {"http" | "serial"} */ ( + await choose( + "Robot connection:", + [ + { id: "http", label: "HTTP (Wi-Fi)" }, + { id: "serial", label: "Serial (USB)" }, + ], + defaultMode, + ) + ); + if (mode === "serial") { + out.info("Tip: tiny-engineer ports lists CDC devices; auto probes Espressif USB."); + transportValue = await ask( + "Serial port (path or auto)", + getSerialPath() || "auto", + ); + urlForCtx = undefined; + } else { + transportValue = await ask("Robot base URL", getBaseUrl() || DEFAULT_URL); + urlForCtx = transportValue; + } + } + + if (!transportValue) { + out.error(mode === "serial" ? "serial port required" : "robot URL required"); + process.exitCode = 1; + return; } if (!args.yes) { @@ -193,11 +364,18 @@ export async function runSetup(out, argv, { projectRoot = process.cwd() } = {}) projectRoot, cliCommand, timeout: DEFAULT_TIMEOUT, - url, + url: urlForCtx, }; plans.push(integration.planInstall(ctx)); } + const envPath = join(projectRoot, ".env"); + const envFlags = { + forceUrl: args.forceUrl, + forceSerial: args.forceSerial, + dryRun: args.dryRun, + }; + if (args.dryRun) { out.ok("dry-run — no files written"); for (const plan of plans) { @@ -205,9 +383,7 @@ export async function runSetup(out, argv, { projectRoot = process.cwd() } = {}) out.info(` events: ${plan.events.join(", ")}`); out.info(` command: ${plan.commandPreview}`); } - if (args.url || url) { - out.info(`.env TINY_ENGINEER_URL=${url}${args.forceUrl ? " (force)" : " (if unset)"}`); - } + applyTransportEnv(out, envPath, mode, transportValue, envFlags); process.exitCode = 0; return; } @@ -221,7 +397,7 @@ export async function runSetup(out, argv, { projectRoot = process.cwd() } = {}) projectRoot, cliCommand, timeout: DEFAULT_TIMEOUT, - url, + url: urlForCtx, }; const rel = integration.configRelativePath(); const abs = join(projectRoot, rel); @@ -236,15 +412,7 @@ export async function runSetup(out, argv, { projectRoot = process.cwd() } = {}) out.ok(`wrote ${rel}`); } - if (url) { - const envPath = join(projectRoot, ".env"); - const result = mergeEnvUrl(envPath, url, { force: args.forceUrl }); - if (result === "skipped") { - out.info(".env TINY_ENGINEER_URL already set (use --force-url to replace)"); - } else { - out.ok(`.env TINY_ENGINEER_URL ${result}`); - } - } + applyTransportEnv(out, envPath, mode, transportValue, envFlags); out.ok("setup complete — run: tiny-engineer doctor"); process.exitCode = 0; @@ -254,13 +422,16 @@ function printSetupHelp() { console.log(`Usage: tiny-engineer setup [ide...] [options] Merge Tiny Engineer hook commands into the current project. +Interactive setup asks HTTP (Wi-Fi) vs Serial (USB), then writes one transport key to .env. Options: --all Install all integrations (cursor, claude-code, antigravity) --yes, -y Non-interactive (requires --all or ide args) --dry-run Print plan without writing files - --url Robot URL; also merge into project .env when unset + --url HTTP mode; merge TINY_ENGINEER_URL into .env (clears SERIAL) --force-url Overwrite existing TINY_ENGINEER_URL in .env + --serial [path] Serial mode; path or auto (bare --serial = auto); clears URL + --force-serial Overwrite existing TINY_ENGINEER_SERIAL in .env --command Override CLI launcher (default: npx -y tiny-engineer) -h, --help Show this help @@ -268,6 +439,7 @@ Examples: npx -y tiny-engineer setup npx -y tiny-engineer setup cursor --yes npx -y tiny-engineer setup --all --yes --url http://192.168.1.10 + npx -y tiny-engineer setup cursor --yes --serial=auto tiny-engineer setup --dry-run tiny-engineer setup cursor --yes --command 'node packages/tiny-engineer-cli/bin/tiny-engineer.js' `); diff --git a/packages/tiny-engineer-cli/src/wizard/uninstall.js b/packages/tiny-engineer-cli/src/wizard/uninstall.js index 4c2cf935..54ab5f77 100644 --- a/packages/tiny-engineer-cli/src/wizard/uninstall.js +++ b/packages/tiny-engineer-cli/src/wizard/uninstall.js @@ -144,7 +144,7 @@ export async function runUninstall(out, argv, { projectRoot = process.cwd() } = out.info(`${plan.relativePath}: remove ${plan.detail}`); } if (args.purgeEnv) { - out.info(".env: strip TINY_ENGINEER_URL / TINY_ENGINEER_TOKEN"); + out.info(".env: strip TINY_ENGINEER_URL / TINY_ENGINEER_TOKEN / TINY_ENGINEER_SERIAL"); } process.exitCode = 0; return; @@ -205,7 +205,7 @@ Options: --all Target all integrations --yes, -y Non-interactive (requires --all or ide args) --dry-run Print plan without writing files - --purge-env Also remove TINY_ENGINEER_URL / TINY_ENGINEER_TOKEN from .env + --purge-env Also remove TINY_ENGINEER_URL / TOKEN / SERIAL from .env -h, --help Show this help Examples: diff --git a/packages/tiny-engineer-cli/test/args.test.js b/packages/tiny-engineer-cli/test/args.test.js index fdc91ee2..09084192 100644 --- a/packages/tiny-engineer-cli/test/args.test.js +++ b/packages/tiny-engineer-cli/test/args.test.js @@ -1,27 +1,68 @@ import test from "node:test"; import assert from "node:assert/strict"; import { + looksLikeSerialPath, parseAnimArgs, parseCommonArgs, parsePlayArgs, stripGlobalFlags, } from "../src/util/args.js"; +test("looksLikeSerialPath matches COM ports but not COM-prefixed poses", () => { + assert.equal(looksLikeSerialPath("COM3"), true); + assert.equal(looksLikeSerialPath("com12"), true); + assert.equal(looksLikeSerialPath("com1x"), false); + assert.equal(looksLikeSerialPath("comedy"), false); + assert.equal(looksLikeSerialPath("/dev/cu.usbmodem1"), true); +}); + test("parseCommonArgs extracts --url and rest", () => { assert.deepEqual(parseCommonArgs(["--url", "http://x", "cursor"]), { help: false, quiet: false, url: "http://x", + serial: undefined, rest: ["cursor"], }); assert.deepEqual(parseCommonArgs(["--url=http://y", "cursor", "extra"]), { help: false, quiet: false, url: "http://y", + serial: undefined, rest: ["cursor", "extra"], }); }); +test("parseCommonArgs extracts --serial", () => { + assert.deepEqual(parseCommonArgs(["--serial", "/dev/cu.usbmodem1", "cursor"]), { + help: false, + quiet: false, + url: undefined, + serial: "/dev/cu.usbmodem1", + rest: ["cursor"], + }); +}); + +test("parseCommonArgs bare --serial is auto", () => { + assert.deepEqual(parseCommonArgs(["--serial", "cursor"]), { + help: false, + quiet: false, + url: undefined, + serial: "auto", + rest: ["cursor"], + }); + assert.equal(parseCommonArgs(["--serial"]).serial, "auto"); + assert.equal(parseCommonArgs(["--serial=auto"]).serial, "auto"); + assert.equal(parseCommonArgs(["--serial="]).serial, "auto"); +}); + +test("parseCommonArgs rejects --url and --serial together", () => { + assert.equal( + parseCommonArgs(["--url", "http://x", "--serial", "/dev/tty"]).error, + "use either --url or --serial, not both", + ); +}); + test("parseCommonArgs help and missing --url value", () => { assert.equal(parseCommonArgs(["-h"]).help, true); assert.equal(parseCommonArgs(["--url"]).error, "--url requires a value"); @@ -42,6 +83,17 @@ test("parseAnimArgs requires name", () => { quiet: false, name: "ring", url: "http://x", + serial: undefined, + }); +}); + +test("parseAnimArgs --serial before name is auto", () => { + assert.deepEqual(parseAnimArgs(["--serial", "ring"]), { + help: false, + quiet: false, + name: "ring", + url: undefined, + serial: "auto", }); }); diff --git a/packages/tiny-engineer-cli/test/doctor.test.js b/packages/tiny-engineer-cli/test/doctor.test.js index 8508f249..d2b3a81f 100644 --- a/packages/tiny-engineer-cli/test/doctor.test.js +++ b/packages/tiny-engineer-cli/test/doctor.test.js @@ -34,7 +34,7 @@ test("checkHealth returns ok on 200", async () => { text: async () => '{"ok":true}', }); try { - const result = await checkHealth("http://robot.test"); + const result = await checkHealth({ type: "http", baseUrl: "http://robot.test" }); assert.equal(result.ok, true); } finally { globalThis.fetch = original; @@ -47,7 +47,7 @@ test("checkHealth returns fail on throw", async () => { throw new Error("offline"); }; try { - const result = await checkHealth("http://robot.test"); + const result = await checkHealth({ type: "http", baseUrl: "http://robot.test" }); assert.equal(result.ok, false); assert.ok(result.detail.includes("offline")); } finally { @@ -58,6 +58,8 @@ test("checkHealth returns fail on throw", async () => { test("runDoctor prints without throwing", async () => { const dir = mkdtempSync(join(tmpdir(), "te-doc-")); const original = globalThis.fetch; + const prevSerial = process.env.TINY_ENGINEER_SERIAL; + delete process.env.TINY_ENGINEER_SERIAL; globalThis.fetch = async () => { throw new Error("offline"); }; @@ -71,10 +73,12 @@ test("runDoctor prints without throwing", async () => { }); try { await runDoctor(out, [], { projectRoot: dir }); - assert.ok(lines.some((l) => l.includes("url:"))); + assert.ok(lines.some((l) => l.includes("transport:"))); assert.ok(lines.some((l) => l.includes("cursor:"))); } finally { globalThis.fetch = original; + if (prevSerial === undefined) delete process.env.TINY_ENGINEER_SERIAL; + else process.env.TINY_ENGINEER_SERIAL = prevSerial; rmSync(dir, { recursive: true, force: true }); process.exitCode = 0; } diff --git a/packages/tiny-engineer-cli/test/play-serial.test.js b/packages/tiny-engineer-cli/test/play-serial.test.js new file mode 100644 index 00000000..120b9267 --- /dev/null +++ b/packages/tiny-engineer-cli/test/play-serial.test.js @@ -0,0 +1,80 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, writeFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { run } from "../src/cli.js"; +import { parsePlayArgs } from "../src/util/args.js"; +import { encodeSerialPlayBegin, writeSerialPlayBody } from "../src/robot/serial.js"; + +test("encodeSerialPlayBegin includes op and len", () => { + const { id, line } = encodeSerialPlayBegin({ id: 7, len: 123456 }); + assert.equal(id, 7); + const json = JSON.parse(line); + assert.equal(json.v, 1); + assert.equal(json.id, 7); + assert.equal(json.op, "POST /play"); + assert.equal(json.len, 123456); +}); + +test("encodeSerialPlayBegin with name uses /play/", () => { + const { line } = encodeSerialPlayBegin({ id: 1, name: "thinking", len: 44 }); + const json = JSON.parse(line); + assert.equal(json.op, "POST /play/thinking"); + assert.equal(json.len, 44); +}); + +test("writeSerialPlayBody writes paced chunks then finishes", async () => { + /** @type {Buffer[]} */ + const written = []; + const fakePort = { + write(chunk, cb) { + written.push(Buffer.from(chunk)); + queueMicrotask(() => cb(null)); + }, + drain(cb) { + queueMicrotask(() => cb(null)); + }, + }; + const wav = Buffer.alloc(5000, 1); + await writeSerialPlayBody(fakePort, wav); + const total = written.reduce((n, b) => n + b.length, 0); + assert.equal(total, wav.length); + assert.ok(written.length >= 2); +}); + +test("parsePlayArgs accepts --serial", () => { + const opts = parsePlayArgs(["clip.wav", "--serial", "/dev/cu.test"]); + assert.equal(opts.wavPath, "clip.wav"); + assert.equal(opts.serial, "/dev/cu.test"); + assert.equal(opts.error, undefined); +}); + +test("play with --serial does not reject as HTTP-only", async () => { + const dir = mkdtempSync(join(tmpdir(), "te-play-")); + const wav = join(dir, "clip.wav"); + writeFileSync(wav, Buffer.alloc(44)); + const prev = process.env.TINY_ENGINEER_SERIAL; + delete process.env.TINY_ENGINEER_SERIAL; + + /** @type {string[]} */ + const errors = []; + const originalError = console.error; + console.error = (...args) => errors.push(args.map(String).join(" ")); + + try { + process.exitCode = 0; + // Port does not exist → unreachable, not "play requires HTTP". + await run(["play", wav, "--serial", "/dev/cu.tiny-engineer-missing-port"]); + assert.equal(process.exitCode, 1); + const joined = errors.join("\n"); + assert.ok(!joined.includes("play requires HTTP")); + assert.match(joined, /robot unreachable/); + } finally { + console.error = originalError; + if (prev === undefined) delete process.env.TINY_ENGINEER_SERIAL; + else process.env.TINY_ENGINEER_SERIAL = prev; + rmSync(dir, { recursive: true, force: true }); + process.exitCode = 0; + } +}); diff --git a/packages/tiny-engineer-cli/test/serial-detect.test.js b/packages/tiny-engineer-cli/test/serial-detect.test.js new file mode 100644 index 00000000..67e0def7 --- /dev/null +++ b/packages/tiny-engineer-cli/test/serial-detect.test.js @@ -0,0 +1,266 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + clearSerialCache, + readSerialCache, + setSerialCacheFileForTests, + writeSerialCache, +} from "../src/robot/serial-cache.js"; +import { + detectSerialPort, + filterSerialCandidates, + looksLikeCdcPath, + normalizePortPath, + preferCalloutPath, +} from "../src/robot/serial-detect.js"; + +test("preferCalloutPath maps macOS tty. to cu.; leaves Linux ACM alone", () => { + assert.equal(preferCalloutPath("/dev/tty.usbmodem1"), "/dev/cu.usbmodem1"); + assert.equal(preferCalloutPath("/dev/cu.usbmodem1"), "/dev/cu.usbmodem1"); + assert.equal(preferCalloutPath("/dev/ttyACM0"), "/dev/ttyACM0"); + assert.equal(preferCalloutPath("COM3"), "COM3"); +}); + +test("normalizePortPath uppercases Windows COM", () => { + assert.equal(normalizePortPath("com12"), "COM12"); + assert.equal(normalizePortPath("/dev/tty.usbmodem1"), "/dev/cu.usbmodem1"); +}); + +test("looksLikeCdcPath accepts COM / usbmodem / ttyACM; rejects bluetooth", () => { + assert.equal(looksLikeCdcPath("COM3"), true); + assert.equal(looksLikeCdcPath("com12"), true); + assert.equal(looksLikeCdcPath("/dev/cu.usbmodem1142201"), true); + assert.equal(looksLikeCdcPath("/dev/ttyACM0"), true); + assert.equal(looksLikeCdcPath("/dev/ttyACM12"), true); + assert.equal(looksLikeCdcPath("/dev/cu.Bluetooth-Incoming-Port"), false); + assert.equal(looksLikeCdcPath("/dev/tty.debug-console"), false); + assert.equal(looksLikeCdcPath("/dev/ttyUSB0"), false); +}); + +test("filterSerialCandidates prefers Espressif VID", () => { + const ports = [ + { path: "/dev/cu.Bluetooth-Incoming-Port" }, + { path: "/dev/cu.usbmodem1", vendorId: "303A", productId: "1001" }, + { path: "/dev/tty.usbmodem1", vendorId: "303A", productId: "1001" }, + { path: "/dev/cu.usbserial-xyz", vendorId: "10C4" }, + ]; + const filtered = filterSerialCandidates(ports); + assert.deepEqual( + filtered.map((p) => p.path), + ["/dev/cu.usbmodem1"], + ); +}); + +test("filterSerialCandidates maps macOS tty.usbmodem to cu.", () => { + const filtered = filterSerialCandidates([ + { path: "/dev/tty.debug-console" }, + { path: "/dev/tty.usbmodem1142201", vendorId: "303a", productId: "1001" }, + ]); + assert.deepEqual( + filtered.map((p) => p.path), + ["/dev/cu.usbmodem1142201"], + ); +}); + +test("filterSerialCandidates keeps Linux ttyACM Espressif", () => { + const filtered = filterSerialCandidates([ + { path: "/dev/ttyS0" }, + { path: "/dev/ttyUSB0", vendorId: "10C4" }, + { path: "/dev/ttyACM0", vendorId: "303A", productId: "1001" }, + ]); + assert.deepEqual( + filtered.map((p) => p.path), + ["/dev/ttyACM0"], + ); +}); + +test("filterSerialCandidates keeps Windows COM Espressif", () => { + const filtered = filterSerialCandidates([ + { path: "COM1" }, + { path: "com5", vendorId: "0x303A", productId: "0x1001" }, + { path: "COM3", manufacturer: "Bluetooth" }, + ]); + assert.deepEqual( + filtered.map((p) => p.path), + ["COM5"], + ); +}); + +test("filterSerialCandidates matches manufacturer when VID missing", () => { + const filtered = filterSerialCandidates([ + { path: "COM8", manufacturer: "Espressif" }, + { path: "COM9", friendlyName: "USB JTAG/serial debug unit (COM9)" }, + { path: "COM1" }, + ]); + assert.deepEqual( + filtered.map((p) => p.path).sort(), + ["COM8", "COM9"], + ); +}); + +test("filterSerialCandidates falls back to CDC paths", () => { + const ports = [ + { path: "/dev/cu.usbmodem99" }, + { path: "/dev/cu.Bluetooth-Incoming-Port" }, + { path: "COM3" }, + { path: "/dev/ttyACM1" }, + ]; + const filtered = filterSerialCandidates(ports); + assert.deepEqual( + filtered.map((p) => p.path).sort(), + ["/dev/cu.usbmodem99", "/dev/ttyACM1", "COM3"].sort(), + ); +}); + +test("filterSerialCandidates ranks USB-Serial/JTAG PID ahead of other Espressif", () => { + const filtered = filterSerialCandidates([ + { path: "/dev/ttyACM1", vendorId: "303A", productId: "0002" }, + { path: "/dev/ttyACM0", vendorId: "303A", productId: "1001" }, + ]); + assert.equal(filtered[0].path, "/dev/ttyACM0"); +}); + +test("detectSerialPort returns unique health match and writes cache", async () => { + const dir = mkdtempSync(join(tmpdir(), "te-serial-cache-")); + const cacheFile = join(dir, "serial-port.json"); + setSerialCacheFileForTests(cacheFile); + clearSerialCache(); + + try { + const path = await detectSerialPort({ + skipCache: true, + listFn: async () => [ + { path: "/dev/cu.usbmodem-a", vendorId: "303A" }, + { path: "/dev/cu.usbmodem-b", vendorId: "303A" }, + ], + requestFn: async (p) => { + if (p === "/dev/cu.usbmodem-b") { + return { ok: true, status: 200, body: '{"ok":true,"version":"v1"}' }; + } + return { ok: false, error: "robot unreachable: timeout" }; + }, + }); + assert.equal(path, "/dev/cu.usbmodem-b"); + assert.equal(readSerialCache(), "/dev/cu.usbmodem-b"); + const disk = JSON.parse(readFileSync(cacheFile, "utf8")); + assert.equal(disk.path, "/dev/cu.usbmodem-b"); + } finally { + setSerialCacheFileForTests(undefined); + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("detectSerialPort uses cache hit without scanning others", async () => { + const dir = mkdtempSync(join(tmpdir(), "te-serial-cache-")); + setSerialCacheFileForTests(join(dir, "serial-port.json")); + writeSerialCache("/dev/cu.cached"); + + let listCalls = 0; + let probed = []; + try { + const path = await detectSerialPort({ + listFn: async () => { + listCalls++; + return [{ path: "/dev/cu.cached", vendorId: "303A" }]; + }, + requestFn: async (p) => { + probed.push(p); + return { ok: true, status: 200, body: '{"ok":true}' }; + }, + }); + assert.equal(path, "/dev/cu.cached"); + assert.equal(listCalls, 1); + assert.deepEqual(probed, ["/dev/cu.cached"]); + } finally { + setSerialCacheFileForTests(undefined); + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("detectSerialPort cache hit via macOS tty list path", async () => { + const dir = mkdtempSync(join(tmpdir(), "te-serial-cache-")); + setSerialCacheFileForTests(join(dir, "serial-port.json")); + writeSerialCache("/dev/cu.usbmodem1142201"); + + try { + const path = await detectSerialPort({ + listFn: async () => [ + { path: "/dev/tty.usbmodem1142201", vendorId: "303A", productId: "1001" }, + ], + requestFn: async (p) => { + if (p === "/dev/cu.usbmodem1142201") { + return { ok: true, status: 200, body: '{"ok":true}' }; + } + return { ok: false, error: "robot unreachable: wrong path" }; + }, + }); + assert.equal(path, "/dev/cu.usbmodem1142201"); + } finally { + setSerialCacheFileForTests(undefined); + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("detectSerialPort clears bad cache and rediscovers", async () => { + const dir = mkdtempSync(join(tmpdir(), "te-serial-cache-")); + setSerialCacheFileForTests(join(dir, "serial-port.json")); + writeSerialCache("/dev/cu.stale"); + + try { + const path = await detectSerialPort({ + listFn: async () => [ + { path: "/dev/cu.stale", vendorId: "303A" }, + { path: "/dev/cu.fresh", vendorId: "303A" }, + ], + requestFn: async (p) => { + if (p === "/dev/cu.fresh") { + return { ok: true, status: 200, body: '{"ok":true}' }; + } + return { ok: false, error: "robot unreachable: gone" }; + }, + }); + assert.equal(path, "/dev/cu.fresh"); + assert.equal(readSerialCache(), "/dev/cu.fresh"); + } finally { + setSerialCacheFileForTests(undefined); + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("detectSerialPort errors on zero or multiple matches", async () => { + await assert.rejects( + () => + detectSerialPort({ + skipCache: true, + listFn: async () => [{ path: "/dev/cu.usbmodem-x", vendorId: "303A" }], + requestFn: async () => ({ ok: false, error: "robot unreachable: x" }), + }), + /no Tiny Engineer reply on/, + ); + + await assert.rejects( + () => + detectSerialPort({ + skipCache: true, + listFn: async () => [], + requestFn: async () => ({ ok: false, error: "x" }), + }), + /no Tiny Engineer serial port found/, + ); + + await assert.rejects( + () => + detectSerialPort({ + skipCache: true, + listFn: async () => [ + { path: "/dev/cu.a", vendorId: "303A" }, + { path: "/dev/cu.b", vendorId: "303A" }, + ], + requestFn: async () => ({ ok: true, status: 200, body: '{"ok":true}' }), + }), + /multiple Tiny Engineer ports/, + ); +}); diff --git a/packages/tiny-engineer-cli/test/setup-args.test.js b/packages/tiny-engineer-cli/test/setup-args.test.js index 26a7d3a1..ad4bd1a5 100644 --- a/packages/tiny-engineer-cli/test/setup-args.test.js +++ b/packages/tiny-engineer-cli/test/setup-args.test.js @@ -5,7 +5,12 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { createOutput } from "../src/util/output.js"; import { createStyle } from "../src/util/style.js"; -import { mergeEnvUrl } from "../src/wizard/merge.js"; +import { + mergeEnvSerial, + mergeEnvUrl, + stripEnvKey, + stripEnvTinyEngineer, +} from "../src/wizard/merge.js"; import { parseSetupArgs, runSetup } from "../src/wizard/setup.js"; test("parseSetupArgs --yes without ide fails at run time; parser ok", () => { @@ -30,6 +35,22 @@ test("parseSetupArgs reads flags", () => { assert.equal(args.forceUrl, true); }); +test("parseSetupArgs reads --serial and --force-serial", () => { + const bare = parseSetupArgs(["cursor", "--yes", "--serial"]); + assert.equal(bare.serial, "auto"); + const auto = parseSetupArgs(["cursor", "--serial=auto", "--force-serial"]); + assert.equal(auto.serial, "auto"); + assert.equal(auto.forceSerial, true); + const path = parseSetupArgs(["--serial", "/dev/cu.usbmodem1", "cursor"]); + assert.equal(path.serial, "/dev/cu.usbmodem1"); + assert.deepEqual(path.ides, ["cursor"]); +}); + +test("parseSetupArgs rejects --url and --serial together", () => { + const args = parseSetupArgs(["cursor", "--url", "http://x", "--serial=auto"]); + assert.ok(args.error?.includes("not both")); +}); + test("runSetup --yes without ide exits 1", async () => { /** @type {string[]} */ const errs = []; @@ -84,6 +105,57 @@ test("runSetup --yes writes cursor hooks", async () => { assert.equal(hooks.hooks.stop[0].timeout, 30); const env = readFileSync(join(dir, ".env"), "utf8"); assert.ok(env.includes("TINY_ENGINEER_URL=http://robot.test")); + assert.ok(!env.includes("TINY_ENGINEER_SERIAL")); + } finally { + rmSync(dir, { recursive: true, force: true }); + process.exitCode = 0; + } +}); + +test("runSetup --yes --serial=auto writes SERIAL and clears URL", async () => { + const dir = mkdtempSync(join(tmpdir(), "te-setup-")); + mkdirSync(join(dir, ".cursor")); + writeFileSync(join(dir, ".env"), "TINY_ENGINEER_URL=http://old\nFOO=1\n", "utf8"); + const out = createOutput({ + quiet: true, + style: createStyle({ color: false }), + log: () => {}, + error: () => {}, + }); + try { + await runSetup(out, ["cursor", "--yes", "--serial=auto", "--force-serial"], { + projectRoot: dir, + }); + assert.equal(process.exitCode, 0); + const env = readFileSync(join(dir, ".env"), "utf8"); + assert.ok(env.includes("TINY_ENGINEER_SERIAL=auto")); + assert.ok(!env.includes("TINY_ENGINEER_URL")); + assert.ok(env.includes("FOO=1")); + const hooks = JSON.parse(readFileSync(join(dir, ".cursor", "hooks.json"), "utf8")); + assert.equal(hooks.hooks.stop[0].command, "npx -y tiny-engineer hook cursor"); + } finally { + rmSync(dir, { recursive: true, force: true }); + process.exitCode = 0; + } +}); + +test("runSetup HTTP mode clears existing SERIAL", async () => { + const dir = mkdtempSync(join(tmpdir(), "te-setup-")); + mkdirSync(join(dir, ".cursor")); + writeFileSync(join(dir, ".env"), "TINY_ENGINEER_SERIAL=auto\n", "utf8"); + const out = createOutput({ + quiet: true, + style: createStyle({ color: false }), + log: () => {}, + error: () => {}, + }); + try { + await runSetup(out, ["cursor", "--yes", "--url", "http://robot.test", "--force-url"], { + projectRoot: dir, + }); + const env = readFileSync(join(dir, ".env"), "utf8"); + assert.ok(env.includes("TINY_ENGINEER_URL=http://robot.test")); + assert.ok(!env.includes("TINY_ENGINEER_SERIAL")); } finally { rmSync(dir, { recursive: true, force: true }); process.exitCode = 0; @@ -127,3 +199,54 @@ test("mergeEnvUrl respects force flag", () => { rmSync(dir, { recursive: true, force: true }); } }); + +test("mergeEnvSerial respects force flag", () => { + const dir = mkdtempSync(join(tmpdir(), "te-env-")); + const envPath = join(dir, ".env"); + try { + writeFileSync(envPath, "TINY_ENGINEER_SERIAL=/dev/old\n", "utf8"); + assert.equal(mergeEnvSerial(envPath, "auto"), "skipped"); + assert.ok(readFileSync(envPath, "utf8").includes("/dev/old")); + assert.equal(mergeEnvSerial(envPath, "auto", { force: true }), "updated"); + assert.ok(readFileSync(envPath, "utf8").includes("TINY_ENGINEER_SERIAL=auto")); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("stripEnvKey removes one assignment", () => { + const dir = mkdtempSync(join(tmpdir(), "te-env-")); + const envPath = join(dir, ".env"); + try { + writeFileSync( + envPath, + "TINY_ENGINEER_URL=http://x\nTINY_ENGINEER_SERIAL=auto\n", + "utf8", + ); + assert.equal(stripEnvKey(envPath, "TINY_ENGINEER_URL"), "removed"); + const next = readFileSync(envPath, "utf8"); + assert.ok(!next.includes("TINY_ENGINEER_URL")); + assert.ok(next.includes("TINY_ENGINEER_SERIAL=auto")); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("stripEnvTinyEngineer removes SERIAL too", () => { + const dir = mkdtempSync(join(tmpdir(), "te-env-")); + const envPath = join(dir, ".env"); + try { + writeFileSync( + envPath, + "FOO=1\nTINY_ENGINEER_URL=http://x\nTINY_ENGINEER_SERIAL=auto\nTINY_ENGINEER_TOKEN=secret\nBAR=2\n", + "utf8", + ); + assert.equal(stripEnvTinyEngineer(envPath), "removed"); + const next = readFileSync(envPath, "utf8"); + assert.ok(next.includes("FOO=1")); + assert.ok(next.includes("BAR=2")); + assert.ok(!next.includes("TINY_ENGINEER")); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/packages/tiny-engineer-cli/test/transport.test.js b/packages/tiny-engineer-cli/test/transport.test.js new file mode 100644 index 00000000..4d5fea72 --- /dev/null +++ b/packages/tiny-engineer-cli/test/transport.test.js @@ -0,0 +1,91 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { resolveTransport } from "../src/robot/transport.js"; +import { encodeSerialRequest, parseSerialReply } from "../src/robot/serial.js"; + +test("resolveTransport prefers --serial over env URL", async () => { + const prevSerial = process.env.TINY_ENGINEER_SERIAL; + const prevUrl = process.env.TINY_ENGINEER_URL; + delete process.env.TINY_ENGINEER_SERIAL; + process.env.TINY_ENGINEER_URL = "http://from-env"; + try { + assert.deepEqual(await resolveTransport({ serial: "/dev/cu.test" }), { + type: "serial", + path: "/dev/cu.test", + }); + } finally { + if (prevSerial === undefined) delete process.env.TINY_ENGINEER_SERIAL; + else process.env.TINY_ENGINEER_SERIAL = prevSerial; + if (prevUrl === undefined) delete process.env.TINY_ENGINEER_URL; + else process.env.TINY_ENGINEER_URL = prevUrl; + } +}); + +test("resolveTransport uses TINY_ENGINEER_SERIAL when url absent", async () => { + const prevSerial = process.env.TINY_ENGINEER_SERIAL; + process.env.TINY_ENGINEER_SERIAL = "/dev/cu.env"; + try { + assert.deepEqual(await resolveTransport({}), { + type: "serial", + path: "/dev/cu.env", + }); + } finally { + if (prevSerial === undefined) delete process.env.TINY_ENGINEER_SERIAL; + else process.env.TINY_ENGINEER_SERIAL = prevSerial; + } +}); + +test("resolveTransport prefers explicit url over TINY_ENGINEER_SERIAL", async () => { + const prevSerial = process.env.TINY_ENGINEER_SERIAL; + process.env.TINY_ENGINEER_SERIAL = "/dev/cu.env"; + try { + assert.deepEqual(await resolveTransport({ url: "http://robot.test" }), { + type: "http", + baseUrl: "http://robot.test", + }); + } finally { + if (prevSerial === undefined) delete process.env.TINY_ENGINEER_SERIAL; + else process.env.TINY_ENGINEER_SERIAL = prevSerial; + } +}); + +test("resolveTransport falls back to HTTP", async () => { + const prevSerial = process.env.TINY_ENGINEER_SERIAL; + delete process.env.TINY_ENGINEER_SERIAL; + try { + assert.deepEqual(await resolveTransport({ url: "http://robot.test" }), { + type: "http", + baseUrl: "http://robot.test", + }); + } finally { + if (prevSerial === undefined) delete process.env.TINY_ENGINEER_SERIAL; + else process.env.TINY_ENGINEER_SERIAL = prevSerial; + } +}); + +test("encodeSerialRequest builds NDJSON op line", () => { + const { id, line } = encodeSerialRequest({ + id: 7, + method: "POST", + path: "/anim", + query: { name: "thinking" }, + }); + assert.equal(id, 7); + const parsed = JSON.parse(line); + assert.equal(parsed.v, 1); + assert.equal(parsed.id, 7); + assert.equal(parsed.op, "POST /anim"); + assert.deepEqual(parsed.q, { name: "thinking" }); +}); + +test("parseSerialReply reads R envelope", () => { + const reply = parseSerialReply( + 'R{"v":1,"id":7,"status":200,"body":{"ok":true,"animation":"thinking"}}\n', + ); + assert.deepEqual(reply, { + id: 7, + status: 200, + body: { ok: true, animation: "thinking" }, + }); + assert.equal(parseSerialReply("L noise"), null); +}); diff --git a/packages/tiny-engineer-cli/test/uninstall-args.test.js b/packages/tiny-engineer-cli/test/uninstall-args.test.js index 6d7bf810..f1bd04fb 100644 --- a/packages/tiny-engineer-cli/test/uninstall-args.test.js +++ b/packages/tiny-engineer-cli/test/uninstall-args.test.js @@ -100,7 +100,7 @@ test("stripEnvTinyEngineer removes only TE keys", () => { try { writeFileSync( envPath, - "FOO=1\nTINY_ENGINEER_URL=http://x\nTINY_ENGINEER_TOKEN=secret\nBAR=2\n", + "FOO=1\nTINY_ENGINEER_URL=http://x\nTINY_ENGINEER_SERIAL=auto\nTINY_ENGINEER_TOKEN=secret\nBAR=2\n", "utf8", ); assert.equal(stripEnvTinyEngineer(envPath), "removed"); diff --git a/platformio.ini b/platformio.ini index 41ae60d0..b3464ff5 100644 --- a/platformio.ini +++ b/platformio.ini @@ -41,6 +41,7 @@ extra_scripts = pre:scripts/firmware_version.py pre:scripts/copy_assets.py pre:scripts/embed_ui.py + pre:scripts/park_servos_before_upload.py post:scripts/upload_fs_after_upload.py ; Host Unity tests — never flash. Run: pio test -e native @@ -68,6 +69,7 @@ build_src_filter = + +