From 52c7f9457b0027104aad0e8915db3b659b7bc602 Mon Sep 17 00:00:00 2001 From: Krzysztof Jamroz Date: Mon, 12 Oct 2026 19:53:12 +0200 Subject: [PATCH 01/12] feat(serail): add USB serial port communication --- .gitignore | 2 + docs/README.md | 2 +- docs/api.md | 2 + docs/integration.md | 17 +- docs/serial-api.md | 106 ++++ docs/testing.md | 2 +- packages/tiny-engineer-cli/README.md | 18 +- packages/tiny-engineer-cli/package-lock.json | 279 ++++++++++ packages/tiny-engineer-cli/package.json | 7 +- packages/tiny-engineer-cli/src/cli.js | 76 ++- packages/tiny-engineer-cli/src/config/env.js | 11 + .../tiny-engineer-cli/src/robot/client.js | 53 +- packages/tiny-engineer-cli/src/robot/http.js | 12 + .../tiny-engineer-cli/src/robot/serial.js | 188 +++++++ .../tiny-engineer-cli/src/robot/transport.js | 110 ++++ packages/tiny-engineer-cli/src/util/args.js | 73 ++- .../tiny-engineer-cli/src/wizard/doctor.js | 73 +-- packages/tiny-engineer-cli/test/args.test.js | 20 + .../tiny-engineer-cli/test/doctor.test.js | 10 +- .../test/play-serial.test.js | 32 ++ .../tiny-engineer-cli/test/transport.test.js | 77 +++ platformio.ini | 1 + src/api/dispatch.cpp | 194 +++++++ src/api/dispatch.h | 7 + src/api/http_bridge.cpp | 51 ++ src/api/http_bridge.h | 14 + src/api/ops.h | 32 ++ src/api/ops_anim.cpp | 56 +++ src/api/ops_health.cpp | 81 +++ src/api/ops_settings.cpp | 368 ++++++++++++++ src/api/ops_setup.cpp | 261 ++++++++++ src/api/ops_test.cpp | 177 +++++++ src/api/request.cpp | 122 +++++ src/api/request.h | 47 ++ src/http/anim_handlers.cpp | 48 +- src/http/health_handlers.cpp | 81 +-- src/http/settings_handlers.cpp | 475 +----------------- src/http/setup_handlers.cpp | 370 +------------- src/http/test_handlers.cpp | 195 +------ src/main.cpp | 2 + src/serial/serial_api.cpp | 155 ++++++ src/serial/serial_api.h | 4 + src/serial_log.h | 24 + test/test_api_request/test_api_request.cpp | 64 +++ 44 files changed, 2751 insertions(+), 1248 deletions(-) create mode 100644 docs/serial-api.md create mode 100644 packages/tiny-engineer-cli/src/robot/http.js create mode 100644 packages/tiny-engineer-cli/src/robot/serial.js create mode 100644 packages/tiny-engineer-cli/src/robot/transport.js create mode 100644 packages/tiny-engineer-cli/test/play-serial.test.js create mode 100644 packages/tiny-engineer-cli/test/transport.test.js create mode 100644 src/api/dispatch.cpp create mode 100644 src/api/dispatch.h create mode 100644 src/api/http_bridge.cpp create mode 100644 src/api/http_bridge.h create mode 100644 src/api/ops.h create mode 100644 src/api/ops_anim.cpp create mode 100644 src/api/ops_health.cpp create mode 100644 src/api/ops_settings.cpp create mode 100644 src/api/ops_setup.cpp create mode 100644 src/api/ops_test.cpp create mode 100644 src/api/request.cpp create mode 100644 src/api/request.h create mode 100644 src/serial/serial_api.cpp create mode 100644 src/serial/serial_api.h create mode 100644 test/test_api_request/test_api_request.cpp 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/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..27d453d7 100644 --- a/docs/api.md +++ b/docs/api.md @@ -2,6 +2,8 @@ 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`. diff --git a/docs/integration.md b/docs/integration.md index 213ea5fd..fd30bb0a 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/*`. --- @@ -49,7 +52,9 @@ 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 …`. +- Optional serial: `--serial /dev/cu.usbmodem…` or `TINY_ENGINEER_SERIAL` (takes precedence over URL). List paths with `tiny-engineer ports`. Protocol: [`serial-api.md`](serial-api.md). +- Do not pass both `--url` and `--serial`. `play` is HTTP-only (unset serial for WAV upload). +- 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):** diff --git a/docs/serial-api.md b/docs/serial-api.md new file mode 100644 index 00000000..84282d09 --- /dev/null +++ b/docs/serial-api.md @@ -0,0 +1,106 @@ +# Serial API + +USB CDC transport for the same JSON control ops as the [HTTP API](api.md). Wi-Fi remains mandatory for a configured robot; serial is an **additional** always-on channel (trusted — no Bearer auth). + +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 | + +No request body in this protocol version (no `/play` WAV upload over serial). + +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` | Requires Wi-Fi credentials saved (else **503**) | +| `POST /anim` | Query `name`; requires Wi-Fi credentials saved | +| `GET /settings` | | +| `POST /settings` | Query params as HTTP | +| `POST /settings/reset` | | +| `POST /test/audio` | Requires Wi-Fi credentials saved | +| `POST /test/audio/bell` | Requires Wi-Fi credentials saved | +| `POST /test/screen` | Requires Wi-Fi credentials saved | +| `POST /test/movement` | Requires Wi-Fi credentials saved | +| `POST /test/led` | Requires Wi-Fi credentials saved | +| `POST /test/servo` | Query `index`, `angle`; requires Wi-Fi credentials saved | +| `POST /setup/servo` | Setup AP only (else **400**) | +| `POST /setup/led` | Setup AP only | +| `POST /setup/audio` | Setup AP only | +| `POST /setup/oled` | Setup AP only | + +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), `POST /play` (binary WAV). Use HTTP for those. WAV-over-serial is a possible later extension (length-prefixed binary after a JSON begin line). + +## 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 +``` + +## Host tips + +1. Open the port at 115200. +2. Write one request line ending in `\n`. +3. Read lines until an `R` line whose JSON `id` matches; ignore `L` and other noise. +4. Parse `status` and `body` like an HTTP response. + +Blocking test/setup ops hold the device until motion/audio finishes (same as HTTP). diff --git a/docs/testing.md b/docs/testing.md index a35aa7e1..82d306b0 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 diff --git a/packages/tiny-engineer-cli/README.md b/packages/tiny-engineer-cli/README.md index a65cca40..a9b4813c 100644 --- a/packages/tiny-engineer-cli/README.md +++ b/packages/tiny-engineer-cli/README.md @@ -1,6 +1,6 @@ # 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 @@ -9,14 +9,26 @@ npx -y tiny-engineer setup npx -y tiny-engineer setup cursor --yes 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 ``` Supported IDEs: **Cursor**, **Claude Code**, **Antigravity**. +### Transport + +| Mode | How | +| --- | --- | +| HTTP (default) | `--url` / `TINY_ENGINEER_URL` / `http://tiny-engineer.local` | +| Serial | `--serial ` or `TINY_ENGINEER_SERIAL` (wins over URL when set) | + +Do not pass both `--url` and `--serial`. `play` requires HTTP (WAV body). 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..162ae9f3 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 } from "./robot/serial.js"; +import { resolveTransport, transportLabel } from "./robot/transport.js"; import { parseAnimArgs, parseCommonArgs, @@ -32,19 +34,26 @@ 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 only) Options (most commands): --url Robot base URL (default: TINY_ENGINEER_URL, else ${DEFAULT_URL}) + --serial USB CDC serial API (default: TINY_ENGINEER_SERIAL if set) -q, --quiet, --silent Suppress success/info lines; errors still print -h, --help Show this help -Auth: +Transport: + Prefer --serial / TINY_ENGINEER_SERIAL when set; otherwise HTTP. + Do not pass both --url and --serial. play requires HTTP (unset 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,7 +69,9 @@ 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 /dev/cu.usbmodem14101 echo '{"hook_event_name":"stop"}' | tiny-engineer hook cursor `); } @@ -109,8 +120,9 @@ async function runHook(out, argv, quietGlobal) { const anim = integration.mapToAnim(input); if (anim) { + const transport = resolveTransport({ url: opts.url, serial: opts.serial }); // Fail-open: ignore result so an offline robot never stalls the agent. - await postAnim(opts.url ?? getBaseUrl(), anim, getToken()); + await postAnim(transport, anim, getToken()); } const fileLines = integration.describeFiles?.(input, localOut.style) ?? []; @@ -156,14 +168,14 @@ async function runAnim(out, argv, quietGlobal) { return; } - const baseUrl = opts.url ?? getBaseUrl(); - const result = await postAnim(baseUrl, name, getToken()); + const transport = resolveTransport({ url: opts.url, serial: opts.serial }); + 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 +195,13 @@ async function runPlay(out, argv, quietGlobal) { return; } + const transport = resolveTransport({ url: opts.url, serial: opts.serial }); + if (transport.type === "serial") { + localOut.error("play requires HTTP; unset TINY_ENGINEER_SERIAL and do not pass --serial"); + process.exitCode = 1; + return; + } + if (opts.name) { const invalid = validatePlayAnimName(opts.name); if (invalid) { @@ -201,7 +220,7 @@ async function runPlay(out, argv, quietGlobal) { return; } - const baseUrl = opts.url ?? getBaseUrl(); + const baseUrl = transport.baseUrl; const pose = opts.name ?? "talking"; const result = await postPlay({ baseUrl, @@ -220,6 +239,40 @@ async function runPlay(out, argv, quietGlobal) { 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 extra = [p.manufacturer, p.friendlyName].filter(Boolean).join(" · "); + out.ok(extra ? `${p.path} ${out.style.dim(extra)}` : p.path); + } + process.exitCode = 0; +} + /** * @param {string[]} argv */ @@ -250,6 +303,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.js b/packages/tiny-engineer-cli/src/robot/serial.js new file mode 100644 index 00000000..46bd51e4 --- /dev/null +++ b/packages/tiny-engineer-cli/src/robot/serial.js @@ -0,0 +1,188 @@ +import { SerialPort } from "serialport"; + +/** @typedef {import("./client.js").RobotResult} RobotResult */ + +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), + }), + }; +} + +/** + * 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 }} opts + * @returns {Promise} + */ +export async function serialRequest(path, opts) { + const timeoutMs = opts.timeoutMs ?? 2000; + 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 (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) { + clearTimeout(timer); + resolve({ + 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}`, + }); + } + }); + }); + }); +} + +/** + * @returns {Promise<{ path: string, manufacturer?: string, friendlyName?: string }[]>} + */ +export async function listSerialPorts() { + const ports = await SerialPort.list(); + return ports.map((p) => ({ + path: p.path, + manufacturer: p.manufacturer, + friendlyName: p.friendlyName, + })); +} 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..868d9f1b --- /dev/null +++ b/packages/tiny-engineer-cli/src/robot/transport.js @@ -0,0 +1,110 @@ +import { getBaseUrl, getSerialPath } from "../config/env.js"; +import { robotFetch } from "./http.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. Flag `--serial` / env serial beat HTTP unless only `--url` is set + * without serial. Passing both `--url` and `--serial` is an error (caller checks flags). + * + * Precedence: explicit serialPath → TINY_ENGINEER_SERIAL → HTTP (url / env / default). + * + * @param {{ url?: string, serial?: string }} opts + * @returns {Transport} + */ +export function resolveTransport({ url, serial } = {}) { + if (serial) { + return { type: "serial", path: serial }; + } + const envSerial = getSerialPath(); + if (envSerial) { + return { type: "serial", path: envSerial }; + } + 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..f2701cc9 100644 --- a/packages/tiny-engineer-cli/src/util/args.js +++ b/packages/tiny-engineer-cli/src/util/args.js @@ -32,13 +32,15 @@ export function stripGlobalFlags(argv) { * 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 +56,52 @@ 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") { + const next = argv[++i]; + if (!next) return { help, quiet, url, serial, rest, error: "--serial requires a value" }; + serial = next; + continue; + } + if (arg.startsWith("--serial=")) { + serial = arg.slice("--serial=".length); + if (!serial) return { help, quiet, url, serial, rest, error: "--serial requires a value" }; 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,25 +111,38 @@ export function parsePlayArgs(argv) { opts.quiet = true; continue; } - if (arg === "--name" || arg === "--url") { + if (arg === "--name" || arg === "--url" || arg === "--serial") { const value = argv[++i]; if (!value || value.startsWith("-")) return { quiet: opts.quiet, error: `${arg} requires a value` }; opts[arg.slice(2)] = 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.startsWith("--serial=")) { + opts.serial = arg.slice("--serial=".length); + if (!opts.serial) return { quiet: opts.quiet, error: "--serial requires a value" }; + continue; + } if (arg.startsWith("-") || opts.wavPath) { return { quiet: opts.quiet, error: `Unknown argument: ${arg}` }; } 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 +150,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 +175,25 @@ export function parseAnimArgs(argv) { if (!url) return { help, quiet, error: "--url requires a value" }; continue; } + if (arg === "--serial") { + const next = argv[++i]; + if (!next) return { help, quiet, error: "--serial requires a value" }; + serial = next; + continue; + } + if (arg.startsWith("--serial=")) { + serial = arg.slice("--serial=".length); + if (!serial) return { help, quiet, error: "--serial requires a value" }; + 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..c18bbc09 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,35 @@ 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; + const transport = resolveTransport({ url: opts.url, serial: opts.serial }); 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 { + out.info(`serial env: ${getSerialPath() ? "set" : "not set"}`); + } + 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 +102,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/test/args.test.js b/packages/tiny-engineer-cli/test/args.test.js index fdc91ee2..e709b7ec 100644 --- a/packages/tiny-engineer-cli/test/args.test.js +++ b/packages/tiny-engineer-cli/test/args.test.js @@ -12,16 +12,35 @@ test("parseCommonArgs extracts --url and rest", () => { 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 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 +61,7 @@ test("parseAnimArgs requires name", () => { quiet: false, name: "ring", url: "http://x", + serial: undefined, }); }); 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..693dbe26 --- /dev/null +++ b/packages/tiny-engineer-cli/test/play-serial.test.js @@ -0,0 +1,32 @@ +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"; + +test("play rejects serial transport", 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; + await run(["play", wav, "--serial", "/dev/cu.test"]); + assert.equal(process.exitCode, 1); + assert.ok(errors.some((e) => e.includes("play requires HTTP"))); + } 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/transport.test.js b/packages/tiny-engineer-cli/test/transport.test.js new file mode 100644 index 00000000..ea0dc579 --- /dev/null +++ b/packages/tiny-engineer-cli/test/transport.test.js @@ -0,0 +1,77 @@ +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", () => { + 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(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 before HTTP", () => { + const prevSerial = process.env.TINY_ENGINEER_SERIAL; + process.env.TINY_ENGINEER_SERIAL = "/dev/cu.env"; + try { + assert.deepEqual(resolveTransport({ url: "http://ignored" }), { + 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 falls back to HTTP", () => { + const prevSerial = process.env.TINY_ENGINEER_SERIAL; + delete process.env.TINY_ENGINEER_SERIAL; + try { + assert.deepEqual(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/platformio.ini b/platformio.ini index 41ae60d0..588fa81c 100644 --- a/platformio.ini +++ b/platformio.ini @@ -68,6 +68,7 @@ build_src_filter = + +