From 1e3396c28840b4ce15074318ec1894819e81ebad Mon Sep 17 00:00:00 2001 From: Emanuele Barban Date: Thu, 15 Oct 2026 13:55:17 +0200 Subject: [PATCH] feat(servos): add persistent servo calibration and hand mapping --- README.md | 2 + docs/api.md | 41 ++++- docs/hardware/servos.md | 18 +- docs/hardware/testing.md | 10 +- docs/robot-movement.md | 48 ++++- docs/settings.md | 14 +- include/servos.h | 34 +++- src/hardware/pca9685_servos.cpp | 2 +- src/hardware/servo_wrapper.cpp | 19 +- src/http/settings_handlers.cpp | 30 ++- src/settings/getters.cpp | 20 ++ src/settings/nvs.cpp | 24 ++- src/settings/nvs.h | 2 + src/settings/settings.h | 31 +++- src/settings/validate.cpp | 32 +++- src/settings/values.cpp | 14 +- src/settings/values.h | 6 +- test/test_servo_norm/test_servo_norm.cpp | 49 +++++ .../test_settings_validate.cpp | 59 +++++- ui/src/api/types.ts | 2 + ui/src/components/HandAssignment.tsx | 49 +++++ ui/src/components/ServoLimits.tsx | 104 +++++++++++ ui/src/config/ConfigPage.tsx | 2 + ui/src/config/FactoryReset.tsx | 8 +- ui/src/servos.ts | 3 + ui/src/setup/FindRanges.tsx | 38 ++-- ui/src/setup/Wizard.tsx | 5 +- ui/src/setup/useCalibration.ts | 7 +- ui/src/style.css | 10 + ui/src/views/ApiReference.tsx | 36 +++- ui/src/views/Home.tsx | 4 +- ui/src/views/ServoControl.tsx | 61 ++++++- ui/test/config.test.tsx | 33 ++++ ui/test/robot.tsx | 2 + ui/test/servo.test.tsx | 171 ++++++++++++++++++ ui/test/wizard.test.tsx | 43 +++++ 36 files changed, 936 insertions(+), 97 deletions(-) create mode 100644 ui/src/components/HandAssignment.tsx create mode 100644 ui/src/components/ServoLimits.tsx diff --git a/README.md b/README.md index 04a8b9d3..c41b1020 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,8 @@ Your agent (or a hook script) sends poses over HTTP. The **ESP32-C3** is a small Details: [Integrations](docs/integration.md) · [HTTP API](docs/api.md) · [Cursor hooks](docs/hooks.md) +If the body or hands are off-center after assembly, open **Servo control** at `http://tiny-engineer.local/servo` to save each joint’s min/max limits and calibrated center. If the hand servos are connected the other way around, open **Config** at `http://tiny-engineer.local/config`, select their channel assignment, and restart the robot. Calibration and hand assignment persist after power-off and factory reset; movements stay within the saved limits. See [center calibration](docs/robot-movement.md#center-calibration). + ## Documentation | You want to… | Start here | diff --git a/docs/api.md b/docs/api.md index 0413a1a1..6f3bbd4f 100644 --- a/docs/api.md +++ b/docs/api.md @@ -118,6 +118,8 @@ curl http://tiny-engineer.local/settings "wifi_configured": true, "servo_mins": [60, 40, 45, 35, 40], "servo_maxs": [130, 130, 135, 125, 130], + "servo_centers": [95, 85, 90, 80, 85], + "swap_hands": false, "rgb_order": "GRB", "oled_rotate_180": false, "wifi_networks": ["MyNetwork", "Office"] @@ -139,13 +141,15 @@ curl http://tiny-engineer.local/settings | `wifi_networks` | Saved network names (up to 5), in saved order. Passwords are never returned. At boot the robot scans and tries the visible saved networks strongest first, then the rest in this order (hidden SSIDs do not show in a scan). Change the list with [`PUT /settings/wifi`](#put-settingswifi) | | `servo_mins` | Per-joint safe minimum degrees `[head, neck, left, right, body]` (stock defaults **60, 40, 45, 35, 40**) | | `servo_maxs` | Per-joint safe maximum degrees (stock defaults **130, 130, 135, 125, 130**). Each max must be greater than the matching min | +| `swap_hands` | `false`: left hand on PCA9685 channel 2, right on 3 (default). `true`: left on 3, right on 2. Left/right are from the robot’s perspective. Applies after reboot; survives factory reset | +| `servo_centers` | Per-joint calibrated center degrees in the same order (stock defaults **95, 85, 90, 80, 85**). Each center must be strictly between its min/max. Survives factory reset | | `rgb_order` | Onboard WS2812 wire-byte order (`RGB`, `RBG`, `GRB`, `GBR`, `BRG`, `BGR`). Default **`GRB`**. Survives factory reset | | `oled_rotate_180` | OLED 180° rotation (`true` → `setRotation(2)`). Default **`false`**. Setup AP only. Survives factory reset | -| `reboot_required` | `true` once the saved `hostname`, `loading` or WiFi networks differ from what this boot started with; those apply only after a reboot | +| `reboot_required` | `true` once the saved `hostname`, `loading`, `swap_hands` or WiFi networks differ from what this boot started with; those apply only after a reboot | ### `POST /settings` -Update one or more settings from a JSON object body. 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`), RGB LED mapping (`rgb_order`) and OLED rotation (`oled_rotate_180`) are **setup AP only** and apply immediately. Requires Bearer when auth is enabled. +Update one or more settings from a JSON object body. Values are written to NVS. `sleep_timeout`, `volume`, `welcome`, `serial_log`, `continuous_timeout`, `eyes_style`, and `access_token` apply immediately; a changed `hostname`, `loading`, or `swap_hands` takes effect on the **next reboot**. WiFi networks are set with [`PUT /settings/wifi`](#put-settingswifi). Servo ranges (`servo_mins` + `servo_maxs`) apply immediately, including outside setup AP. RGB LED mapping (`rgb_order`) and OLED rotation (`oled_rotate_180`) remain **setup AP only** and apply immediately. Servo centers (`servo_centers`) can also be changed outside setup AP and take effect on subsequent normalized motion commands without reboot. Saving centers does not move a joint; `/test/servo` still takes electrical degrees. Requires Bearer when auth is enabled. ```bash curl -X POST "http://tiny-engineer.local/settings" \ @@ -154,6 +158,15 @@ curl -X POST "http://tiny-engineer.local/settings" \ curl -X POST "http://tiny-engineer.local/settings" \ -H "Content-Type: application/json" \ -d '{"access_token":""}' +curl -X POST "http://tiny-engineer.local/settings" \ + -H "Content-Type: application/json" \ + -d '{"servo_centers":[95,85,92,78,90]}' +curl -X POST "http://tiny-engineer.local/settings" \ + -H "Content-Type: application/json" \ + -d '{"swap_hands":true}' +curl -X POST "http://tiny-engineer.local/settings" \ + -H "Content-Type: application/json" \ + -d '{"servo_mins":[65,40,45,35,40],"servo_maxs":[125,130,135,125,130]}' curl -X POST "http://192.168.4.1/settings" \ -H "Content-Type: application/json" \ -d '{"servo_mins":[60,40,45,35,40],"servo_maxs":[130,130,135,125,130],"rgb_order":"GRB","oled_rotate_180":true}' @@ -174,6 +187,8 @@ curl -X POST "http://192.168.4.1/settings" \ "wifi_configured": true, "servo_mins": [60, 40, 45, 35, 40], "servo_maxs": [130, 130, 135, 125, 130], + "servo_centers": [95, 85, 90, 80, 85], + "swap_hands": false, "rgb_order": "GRB", "oled_rotate_180": false, "wifi_networks": ["MyNetwork"], @@ -192,12 +207,18 @@ curl -X POST "http://192.168.4.1/settings" \ | `loading` | string | `progress` or `sleep_inertia` | | `eyes_style` | string | `classic`, `kaomoji`, `cover`, or `dots` | | `access_token` | string | 0–64 printable ASCII, no space at either end; empty string clears the token and disables auth | -| `servo_mins` | integer array | Five integers `0`–`180` (`head, neck, left, right, body`); setup AP only; must be sent with `servo_maxs` | -| `servo_maxs` | integer array | Five integers `0`–`180`; setup AP only; each value must be greater than the matching min | +| `servo_mins` | integer array | Five integers `0`–`180` (`head, neck, left, right, body`); immediate; must be sent with `servo_maxs` | +| `servo_maxs` | integer array | Five integers `0`–`180`; immediate; each value must be greater than the matching min | +| `swap_hands` | boolean | Swap hand channels 2 and 3; default `false`; next reboot | +| `servo_centers` | number array | Five finite numbers (`head, neck, left, right, body`); each min < center < max; immediate, including outside setup AP | | `rgb_order` | string | `RGB`, `RBG`, `GRB`, `GBR`, `BRG`, or `BGR`; setup AP only; default `GRB` | | `oled_rotate_180` | boolean | Setup AP only; default `false` | -Leave out a field to keep its saved value. An empty object → **400** `send at least one setting`. A field this route does not know → **400** `unknown field `. A field with the wrong type or out of range → **400** `invalid ` (servo ranges that overlap or exceed 180: `invalid servo_mins or servo_maxs`). Only one of `servo_mins` / `servo_maxs` → **400** `servo_mins and servo_maxs go together`. On any 400, nothing is saved. Outside setup AP mode, servo ranges → **400** `servo setup only in AP mode`, `rgb_order` → **400** `rgb setup only in AP mode`, `oled_rotate_180` → **400** `oled setup only in AP mode`. Replies carry `reboot_required` as in `GET /settings`. +Leave out a field to keep its saved value. An empty object → **400** `send at least one setting`. A field this route does not know → **400** `unknown field `. A field with the wrong type or out of range → **400** `invalid ` (servo ranges that overlap or exceed 180: `invalid servo_mins or servo_maxs`). A wrong length/type, non-finite value, or center not strictly inside the resulting safe limits → **400** `invalid servo_centers`. Only one of `servo_mins` / `servo_maxs` → **400** `servo_mins and servo_maxs go together`. On any 400, nothing is saved. Outside setup AP mode, `rgb_order` → **400** `rgb setup only in AP mode`, `oled_rotate_180` → **400** `oled setup only in AP mode`. Replies carry `reboot_required` as in `GET /settings`. + +Hand channel assignment affects all PWM writes after restart, including setup moves, animations, and output release. Limits and centers remain attached to logical joints; verify both hands’ calibration after changing their assignment. + +When changing safe limits without sending `servo_centers`, existing centers inside the new limits are kept; an excluded center falls back to the new midpoint. The Servo control page exposes Min/Max fields and **Save limits** for each joint; the slider follows the saved limits only after a successful save. Active motion targets are clamped to new limits so an out-of-range destination can still finish. On upgrades from firmware without centers, each center defaults to the midpoint of the saved limits. Poses map `−1` to min, `0` to center, and `+1` to max, with linear interpolation on each side. Hand rest poses remain at their safe endpoints. See [center calibration](robot-movement.md#center-calibration). ### `PUT /settings/wifi` @@ -220,7 +241,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 body. Requires Bearer when auth is enabled. Same JSON shape as `GET /settings`. +Factory reset: clears the NVS `te` namespace and restores settings to compile-time defaults and clears every saved WiFi network. **Servo min/center/max, hand channel assignment (`swap_hands`), 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 body. Requires Bearer when auth is enabled. Same JSON shape as `GET /settings`. 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. @@ -243,6 +264,8 @@ curl -X POST "http://tiny-engineer.local/settings/reset" "wifi_configured": false, "servo_mins": [60, 40, 45, 35, 40], "servo_maxs": [130, 130, 135, 125, 130], + "servo_centers": [95, 85, 90, 80, 85], + "swap_hands": false, "rgb_order": "GRB", "oled_rotate_180": false, "wifi_networks": [], @@ -292,7 +315,7 @@ curl -X POST http://tiny-engineer.local/test/screen ### `POST /test/movement` -All five servos: per-joint mid → +0.5 → −0.5 → mid of the saved min/max (`runServoTest()`). Needs a strong 5 V supply — see [power.md](hardware/power.md). +All five servos: per-joint calibrated center → +0.5 → −0.5 → center within the saved min/max (`runServoTest()`). Needs a strong 5 V supply — see [power.md](hardware/power.md). ```bash curl -X POST http://tiny-engineer.local/test/movement @@ -446,7 +469,7 @@ Wrong fields return **400** and do not move any servo: | `invalid index` | `index` missing, not an integer, or outside `0`–`4` | | `invalid angle` | `angle` missing, not a number, or outside `0`–`180` | -Assembled robot: firmware clamps to the NVS min/max from setup (stock defaults in [robot-movement.md](robot-movement.md)); this route still accepts 0–180 and clamps. For unclamped assembly moves, use setup AP `POST /setup/servo`. +Assembled robot: firmware clamps to the NVS min/max saved from Servo control or setup (stock defaults in [robot-movement.md](robot-movement.md)); this route still accepts 0–180 and clamps. For unclamped assembly moves, use setup AP `POST /setup/servo`. ### `GET /anim` @@ -498,7 +521,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 → calibrated center; 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. | | `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. | diff --git a/docs/hardware/servos.md b/docs/hardware/servos.md index b606fdf8..d9285583 100644 --- a/docs/hardware/servos.md +++ b/docs/hardware/servos.md @@ -67,19 +67,19 @@ Firmware uses two complementary control paths: | Mode | API | Use | | --- | --- | --- | -| **Choreographed** | `setNormPosition()` + time easing (`anim::easedLerp`, `anim::EasedMove`) | Welcome raise/wiggle, thinking head/neck — pose in −1..1, mapped to saved min/max each frame | +| **Choreographed** | `setNormPosition()` + time easing (`anim::easedLerp`, `anim::EasedMove`) | Welcome raise/wiggle, thinking head/neck — pose in −1..1, mapped to saved min/center/max each frame | | **Discrete** | `setNormTarget()` + `update()` slew | Typing, reading, ring, transitions — rate-limited chase to a mapped pose | Helpers live in [`src/animation/util.cpp`](../../src/animation/util.cpp). Blocking test moves (`moveTo`) also use cubic easing. `SERVO_MAX_SPEED_DEG_S` (140°/s) is ~28% of PowerHD HD-1370A unloaded max (~500°/s @ 4.8 V) — smoother under load while staying responsive for hand taps. Other presets (SG90, FS0307, ES9051) still use this firmware cap. -Bring-up motion (`runServoTest`): each joint uses its **saved** min/max (`n` in −1..1): +Bring-up motion (`runServoTest`): each joint uses its **saved** min/center/max (`n` in −1..1): -1. All channels → mid (`n = 0`) -2. Smooth mid → +0.5 (75% of span) -3. Smooth +0.5 → −0.5 (25% of span) -4. Smooth −0.5 → mid +1. All channels → calibrated center (`n = 0`) +2. Smooth center → +0.5 (halfway from center to max) +3. Smooth +0.5 → −0.5 (halfway from center to min) +4. Smooth −0.5 → calibrated center This is a wiring/power test, not a pose library. @@ -89,11 +89,11 @@ This is a wiring/power test, not a pose library. | --- | --- | --- | --- | | 1 | Electrical PWM | Pulse widths the servo electronics accept (~800–2200 µs @ 50 Hz) | In firmware | | 2 | Nominal manufacturer angle | Marketing / datasheet travel (0–180° **or** ~130° over full pulse — sources disagree) | Do not trust for installed mechanics | -| 3 | Mechanical safe range | Per-joint min/max after horns and linkages | Stock defaults in `SERVO_SPECS` ([`include/servos.h`](../../include/servos.h)); saved to NVS in the setup AP wizard. See [robot-movement.md](../robot-movement.md) | +| 3 | Mechanical safe range | Per-joint min/max after horns and linkages | Stock defaults in `SERVO_SPECS` ([`include/servos.h`](../../include/servos.h)); saved to NVS from Servo control or the setup AP wizard. See [robot-movement.md](../robot-movement.md) | -Animations author poses in **−1..1** (min / mid / max of range 3) and map through `servoNormToDeg`. `POST /test/servo` still takes electrical degrees and clamps to range 3. Setup AP `POST /setup/servo` uses range 1 (0–180°) to find limits. +Animations author poses in **−1..1** (min / calibrated center / max of range 3) and map through `servoNormToDeg`. `POST /test/servo` still takes electrical degrees and clamps to range 3. Setup AP `POST /setup/servo` uses range 1 (0–180°) to find limits. -Blind 0–180° on the assembled robot can stall gears, tear horns, or brown out the 5 V rail. Stay inside the saved min/max (stock `SERVO_SPECS` until you calibrate in setup AP). +Blind 0–180° on the assembled robot can stall gears, tear horns, or brown out the 5 V rail. Stay inside the saved min/max (stock `SERVO_SPECS` until you calibrate). ## Channel-to-mechanism mapping diff --git a/docs/hardware/testing.md b/docs/hardware/testing.md index 91af3ca2..2f207f10 100644 --- a/docs/hardware/testing.md +++ b/docs/hardware/testing.md @@ -9,7 +9,7 @@ Firmware is [`src/main.cpp`](../../src/main.cpp). Boot **inits** hardware and st - [`src/hardware/pca9685_servos.cpp`](../../src/hardware/pca9685_servos.cpp) - [`src/http/routes.cpp`](../../src/http/routes.cpp), [`src/http/test_handlers.cpp`](../../src/http/test_handlers.cpp) -Constants: [`include/pins.h`](../../include/pins.h). Wi-Fi credentials are saved in NVS and configured only in setup AP mode (first boot, or after factory reset + power-cycle). Servo min/max, RGB LED mapping (`rgb_order`), and OLED rotation (`oled_rotate_180`) are also setup-AP-only; factory reset keeps them. +Constants: [`include/pins.h`](../../include/pins.h). Wi-Fi credentials are saved in NVS and configured only in setup AP mode (first boot, or after factory reset + power-cycle). RGB LED mapping (`rgb_order`) and OLED rotation (`oled_rotate_180`) are also setup-AP-only; factory reset keeps them. Servo min/max can be saved on the regular Servo control page and apply immediately. Hand channel assignment (`swap_hands`) swaps channels 2 and 3 after reboot and survives factory reset. Servo centers (`servo_centers`) can also be calibrated on the regular Servo control page and survive factory reset. Stock flash: **[Web flash](https://jamro.github.io/tiny-engineer/flash/)**. Build from source / serial (115200): [flash.md](../flash.md) (Advanced: PlatformIO — `pio run`, `pio run -t upload`). Physical board is **Waveshare ESP32-C3-Zero**; PlatformIO env name is `esp32-c3-devkitm-1`. @@ -54,7 +54,7 @@ silent, which makes the LED the only signal a first-boot board gives you. 7. **Sleep inertia loading:** wakeup frame 0 during init (classic: closed lids; cover: black halves; kaomoji: Sleepy; dots: no circles). Smooth move to sleep pose if `welcome` is on. After init, wake plays from t=0: slow eye open + blinks (~5.5 s). Head/neck wave only if `welcome` is on; otherwise eyes only 8. `WIFI SETUP` on serial — connect OK + IP, open setup AP, or saved credentials failed (setup AP for reconfiguration) 9. `Starting MAX98357A` → `I2S OK` -10. `Centering servos` — smooth move to per-channel mid (progress path only; skipped if sleep pose already applied) +10. `Centering servos` — smooth move to each saved center (progress path only; skipped if sleep pose already applied) 11. `ROBOT READY` 12. RGB fades to white over 1 s if `welcome` runs (Wi-Fi OK and setting enabled), or fades off if idle 13. If Wi-Fi OK: `HTTP: http:///`, `HTTP: http://tiny-engineer.local/`, and `/health` URLs on serial @@ -110,10 +110,10 @@ curl -X POST "http://tiny-engineer.local/test/servo" -H "Content-Type: applicati | `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 from a JSON object; the reply carries `reboot_required`, true once `hostname`, `loading` or the WiFi networks differ from what the robot booted with. `servo_mins` / `servo_maxs` (integer arrays), `rgb_order` and `oled_rotate_180` (boolean) are setup-AP-only | +| `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`, `servo_centers`, `swap_hands`, `rgb_order`, `oled_rotate_180`, `wifi_networks`) | +| `POST` | `/settings` | Update NVS settings from a JSON object; the reply carries `reboot_required`, true once `hostname`, `loading`, `swap_hands` or the WiFi networks differ from what the robot booted with. `servo_centers` (five finite numbers strictly between min/max) apply to subsequent normalized moves, including outside setup AP. `servo_mins` / `servo_maxs` (integer arrays) apply immediately, including outside setup AP. Only `rgb_order` and `oled_rotate_180` (boolean) are setup-AP-only | | `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` | `/settings/reset` | Factory reset settings to defaults (clears WiFi, keeps servo ranges and centers, hand assignment, RGB mapping, and screen rotation); power-cycle to reopen setup AP | | `POST` | `/test/audio` | `{"ok":true,"test":"audio"}` after `runSoundTest()` | | `POST` | `/test/audio/bell` | `{"ok":true,"test":"bell"}` after `playBell()` | | `POST` | `/test/screen` | `{"ok":true,"test":"screen"}` after `runOledTest()` | diff --git a/docs/robot-movement.md b/docs/robot-movement.md index 4ac33043..a1398e11 100644 --- a/docs/robot-movement.md +++ b/docs/robot-movement.md @@ -10,12 +10,56 @@ The robot sits in a chair beside a desk. A miniature laptop and a service bell s | 3 | **Right hand** | Hand up / down (inverted scale) | up | down | 35 – 125 | | 4 | **Body** | Whole torso left / right | right | left | 40 – 130 | -Ranges match stock `SERVO_SPECS` in [`include/servos.h`](../include/servos.h). Those values are **defaults**. After setup AP calibration they are stored in NVS. Factory reset keeps the saved ranges. Retune them in the setup wizard when AP mode is open. +Ranges match stock `SERVO_SPECS` in [`include/servos.h`](../include/servos.h). Those values are **defaults**. Calibration is stored in NVS. Factory reset keeps the saved ranges and centers. Retune limits and centers from the regular Servo control page or the setup wizard. -Animations author poses in **−1..1** relative to each joint’s saved min/max (`−1` = min, `0` = mid, `+1` = max). Horn offset after assembly is absorbed by calibration; firmware maps those poses with `servoNormToDeg`. Right-hand rest is still electrical min (`−1`); left-hand rest is electrical max (`+1`). +Animations author poses in **−1..1** relative to each joint’s saved min/center/max (`−1` = min, `0` = calibrated center, `+1` = max). `servoNormToDeg` interpolates separately from min to center and from center to max, so correcting the center never widens the safe band. Without a saved center, the firmware uses `(min + max) / 2`, preserving older calibrations. Right-hand rest is still electrical min (`−1`); left-hand rest is electrical max (`+1`). **Head** tilts the face toward or away from the laptop. **Neck** pans the head side to side. **Hands** lift and lower over the keyboard; the forearms have no elbow servo — the arm linkage is fixed, so only the hand joint moves. **Left** and **right** hand servos use opposite scales: on the left, higher angle is up; on the right, higher angle is down (rest pose for typing is left at `max`, right at `min`). **Body** rotates the whole upper body in the chair while the base stays put. +## Hand servo assignment + +If selecting Left hand moves the right hand, open **Config** and use **Hand servo assignment**. Choose the channel connected to the robot’s left hand (from the robot’s perspective); the other hand automatically uses the remaining channel: + +| Left hand channel | Right hand channel | `swap_hands` | +| --- | --- | --- | +| 2 (default) | 3 | `false` | +| 3 (swapped) | 2 | `true` | + +Press **Save hand assignment**, then restart the robot before testing either hand or calibrating its range. This selection is also available at the start of the setup wizard. The saved mapping survives power-off and factory reset. Existing limits and centers still belong to the logical left/right joints; verify both hands’ calibration after changing the mapping. All PWM commands, including setup moves and output release, use the chosen channels after reboot. + +```bash +curl -X POST "http://tiny-engineer.local/settings" -H "Content-Type: application/json" \ + -d '{"swap_hands":true}' +``` + +## Movement limits + +1. Open **Servo control** and select the joint. +2. Under **Servo limits**, enter **Min (°)** and **Max (°)** as whole degrees from 0 to 180, with min less than max. Use limits you have checked on the assembled robot. +3. Press **Save limits**. The slider and subsequent movements use the saved range immediately, without reboot. Saving does not start a test move. + +Only the selected joint's limits change. Its center stays where it is if it fits strictly inside the new range; otherwise the UI shows the new midpoint before saving it as the center. Other joints keep their saved calibration. Limits survive power-off and factory reset. + +Changing the fields alone does not move the servo or change its active limits. Save first, then use **Move servo** to test within the new range. To explore the full electrical range during assembly, use the setup wizard. + +## Center calibration + +1. Open **Servo control** (`http://tiny-engineer.local/servo`) and select Body, Left hand, Right hand, Head, or Neck. +2. Adjust Angle within the saved safe range and press **Move servo** to inspect alignment. For the body, face the torso toward the laptop; for a hand, choose the middle of its intended movement. +3. Press **Save angle as center**. The chosen joint's center is saved to NVS without changing its min/max. Repeat for other joints. +4. **Center** moves to the saved center; **Reset center to midpoint** saves the midpoint of the current limits again. + +Centers take effect on subsequent normalized motion commands without reboot. Saving a center does not itself move a joint. Calibration survives power-off and factory reset. Centers must be strictly between min and max; fractional degrees are supported. The setup wizard also offers **Set center** after finding each joint's limits. + +Hand rest poses use the safe endpoints, so center calibration changes intermediate hand poses, not where the hands rest. If a hand is misaligned at rest, retune the corresponding min or max under **Servo limits**. + +The API accepts all five centers in `[head, neck, left, right, body]` order; keep the other saved values from `GET /settings` when changing one joint: + +```bash +curl -X POST "http://tiny-engineer.local/settings" -H "Content-Type: application/json" \ + -d '{"servo_centers":[95,85,92,78,90]}' +``` + Command a single joint for bench checks: ```bash diff --git a/docs/settings.md b/docs/settings.md index 6cc212d8..0bad4ba0 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** (`POST /settings` body 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** (`POST /settings` body 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` / `servo_centers`, `swap_hands`, `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`, `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`, `scenters` (five float center angles), `swap_hands`, `rgb_ord`, `oled_rot`. 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 | One `SettingsValues` struct (`g_settings`) filled by `initSettings()`; callers use getters | | Update API | `saveSettings(const SettingsPatch&)`: only the fields the patch sets change, and only changed keys are written to NVS | | HTTP | JSON object body on `POST /settings`; at least one field required | -| Apply timing | Prefer immediate apply. If boot-only (like hostname), add it to `settingsRebootRequired()` so replies report `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 or hand channel assignment), add it to `settingsRebootRequired()` so replies report `reboot_required`, and document it. Servo centers (`servo_centers`) apply to subsequent normalized moves without reboot and can be set outside setup AP. 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`) can also be saved outside setup AP and apply immediately. RGB LED mapping (`rgb_order`) and OLED rotation (`oled_rotate_180`) are setup-AP-only and apply immediately | | Types | Prefer small integers / short strings; validate before any NVS write | ## Checklist @@ -31,7 +31,7 @@ Public API: [`settings.h`](../src/settings/settings.h). Values struct: [`values. 2. Add the field to `SettingsValues` in [`values.h`](../src/settings/values.h), set its default in `SettingsValues::defaults()` and log it in `SettingsValues::log()` ([`values.cpp`](../src/settings/values.cpp)). For secrets like `access_token`, log set/unset only, never the raw value. 3. Add the NVS key in [`nvs.h`](../src/settings/nvs.h) (e.g. `kKeyFoo = "foo"`). Read it in `SettingsValues::load()` (keep the default on bad or missing data) and write it in `writeValues()` ([`nvs.cpp`](../src/settings/nvs.cpp)). 4. Add `settingsValidateFoo(...)` in [`validate.cpp`](../src/settings/validate.cpp) and the getter `settingsFoo()` in [`getters.cpp`](../src/settings/getters.cpp). -5. Add the field to `SettingsPatch` in [`settings.h`](../src/settings/settings.h) (`std::optional`, or `const char*` for text), check it in `SettingsPatch::invalidField()` and copy it in `SettingsValues::apply()`. `saveSettings()` rejects the whole patch when a field is invalid and writes only the keys whose value changed. +5. Add the field to `SettingsPatch` in [`settings.h`](../src/settings/settings.h) (`std::optional`, or `const char*` for text), check it in `SettingsPatch::invalidField()` and copy it in `SettingsValues::apply()`. `saveSettings()` rejects the whole patch when a field is invalid (centers are checked against the resulting safe ranges) and writes only the keys whose value changed. ### 2. Consumers @@ -47,7 +47,7 @@ Call the getter wherever the value affects behavior (e.g. `settingsVolume()` in 1. Add the field to `Settings` in [`ui/src/api/types.ts`](../ui/src/api/types.ts). 2. Add a control to `ConfigForm` in [`ui/src/config/ConfigForm.tsx`](../ui/src/config/ConfigForm.tsx): read it in `draftFrom()` and send it from `save()`. Use `.range-row` + range/number for 0–N scales (see volume). 3. API reference table under POST `/settings` in [`ui/src/views/ApiReference.tsx`](../ui/src/views/ApiReference.tsx): param, type, range. -4. Home Config card blurb if the setting is user-facing. +4. Home card blurb if the setting is user-facing. Servo calibration lives on Servo control and in the setup wizard instead of ConfigForm. Hand channel assignment lives on Config and in the setup wizard. ### 5. Docs (required with the same change) @@ -78,7 +78,9 @@ Flash only when you want to try it on hardware (`pio run -t upload`). ## Pitfalls - **Partial writes:** validate first; never write NVS then fail validation mid-way. -- **Factory reset:** `factoryResetSettings()` in [`nvs.cpp`](../src/settings/nvs.cpp) clears NVS namespace `te` and writes defaults, which clears every saved WiFi network. Servo min/max (`sranges`), RGB LED mapping (`rgb_ord`), and OLED rotation (`oled_rot`) are written back unchanged. Exposed as `POST /settings/reset`. After reset, power-cycle into setup AP mode to configure WiFi again. Servo ranges, LED mapping, and screen rotation can be retuned in that wizard. +- **Factory reset:** `factoryResetSettings()` in [`nvs.cpp`](../src/settings/nvs.cpp) clears NVS namespace `te` and writes defaults, which clears every saved WiFi network. Servo min/max (`sranges`), centers (`scenters`), hand channel assignment (`swap_hands`), RGB LED mapping (`rgb_ord`), and OLED rotation (`oled_rot`) are written back unchanged. Exposed as `POST /settings/reset`. After reset, power-cycle into setup AP mode to configure WiFi again. Servo ranges and centers, LED mapping, and screen rotation can be retuned in that wizard. Limits and centers can also be retuned on Servo control outside setup AP. +- **Hand assignment:** `swap_hands` selects left=3/right=2 instead of the default left=2/right=3. PWM uses the mapping from `g_bootSettings` until the next restart; changing the saved value sets `reboot_required`. Keep this mapping in factory reset. +- **Servo center migration:** missing or invalid `scenters` defaults to the midpoint of the loaded safe ranges. If a range-only patch excludes an existing center, that center falls back to the new midpoint. Explicit centers must be finite and strictly inside the resulting ranges; reject before writing NVS. - **Hostname-style settings:** freeze the boot value separately if live change cannot apply (see `g_bootSettings` and `settingsRebootRequired()`). - **UI size:** the panel is embedded gzipped in the firmware; keep controls compact. - **Doc drift:** HTML param tables must match `api.md` exactly. diff --git a/include/servos.h b/include/servos.h index a89107ad..0eb68674 100644 --- a/include/servos.h +++ b/include/servos.h @@ -33,7 +33,21 @@ constexpr float servoMid(const ServoSpec& spec) { return (spec.min + spec.max) * 0.5f; } -// Pose space: -1 = saved min, 0 = mid, +1 = saved max. +// Swap only the hand outputs; pose limits and centers belong to logical joints. +constexpr int servoChannelFor(int index, bool swapHands) { + if (index < 0 || index >= SERVO_COUNT) { + return -1; + } + if (swapHands && index == SERVO_HAND_LEFT) { + return SERVO_SPECS[SERVO_HAND_RIGHT].channel; + } + if (swapHands && index == SERVO_HAND_RIGHT) { + return SERVO_SPECS[SERVO_HAND_LEFT].channel; + } + return SERVO_SPECS[index].channel; +} + +// Pose space: -1 = saved min, 0 = calibrated center, +1 = saved max. inline float servoSaturateNorm(float n) { if (n < -1.0f) { return -1.0f; @@ -44,17 +58,25 @@ inline float servoSaturateNorm(float n) { return n; } -inline float servoNormToDeg(float n, float savedMin, float savedMax) { +inline float servoNormToDeg(float n, float savedMin, float savedCenter, float savedMax) { n = servoSaturateNorm(n); - return (savedMin + savedMax) * 0.5f + n * (savedMax - savedMin) * 0.5f; + return savedCenter + n * (n < 0.0f ? savedCenter - savedMin : savedMax - savedCenter); } -inline float servoDegToNorm(float deg, float savedMin, float savedMax) { - const float span = savedMax - savedMin; +inline float servoNormToDeg(float n, float savedMin, float savedMax) { + return servoNormToDeg(n, savedMin, (savedMin + savedMax) * 0.5f, savedMax); +} + +inline float servoDegToNorm(float deg, float savedMin, float savedCenter, float savedMax) { + const float span = deg < savedCenter ? savedCenter - savedMin : savedMax - savedCenter; if (span <= 0.0f) { return 0.0f; } - return servoSaturateNorm(2.0f * (deg - savedMin) / span - 1.0f); + return servoSaturateNorm((deg - savedCenter) / span); +} + +inline float servoDegToNorm(float deg, float savedMin, float savedMax) { + return servoDegToNorm(deg, savedMin, (savedMin + savedMax) * 0.5f, savedMax); } // Max commanded slew rate for all smooth moves diff --git a/src/hardware/pca9685_servos.cpp b/src/hardware/pca9685_servos.cpp index fdcaaf2e..af96f069 100644 --- a/src/hardware/pca9685_servos.cpp +++ b/src/hardware/pca9685_servos.cpp @@ -60,7 +60,7 @@ void servoMoveAllToElectricalAngle(float angle) { } void centerAllServos() { - serialLogPrintln("Centering servos to mid (min+max)/2"); + serialLogPrintln("Centering servos to calibrated centers"); float targets[SERVO_COUNT]; diff --git a/src/hardware/servo_wrapper.cpp b/src/hardware/servo_wrapper.cpp index cdf369f4..12796dba 100644 --- a/src/hardware/servo_wrapper.cpp +++ b/src/hardware/servo_wrapper.cpp @@ -72,6 +72,7 @@ float servoNormToDeg(int index, float n) { return servoNormToDeg( n, settingsServoMin(index), + settingsServoCenter(index), settingsServoMax(index) ); } @@ -84,6 +85,7 @@ float servoDegToNorm(int index, float deg) { return servoDegToNorm( deg, settingsServoMin(index), + settingsServoCenter(index), settingsServoMax(index) ); } @@ -183,6 +185,15 @@ void ServoWrapper::update() { return; } + // Limits can change while a move is in progress. Keep its destination + // reachable so a target outside the new band cannot leave it moving forever. + target_ = clampServoAngle(index_, target_); + + if (!isMoving()) { + lastUpdateMs_ = 0; + return; + } + const uint32_t now = millis(); if (lastUpdateMs_ == 0) { @@ -260,7 +271,7 @@ void ServoWrapper::ensureServoOutput() { const uint16_t pulse = angleToPulse(angle_); pwm.setPWM( - SERVO_SPECS[index_].channel, + settingsServoChannel(index_), 0, pulse ); @@ -269,7 +280,7 @@ void ServoWrapper::ensureServoOutput() { serialLogPrint("[servo] pwm on "); serialLogPrint(SERVO_SPECS[index_].name); serialLogPrint(" ch="); - serialLogPrintln(SERVO_SPECS[index_].channel); + serialLogPrintln(settingsServoChannel(index_)); } void ServoWrapper::releaseServoOutput() { @@ -278,7 +289,7 @@ void ServoWrapper::releaseServoOutput() { } pwm.setPWM( - SERVO_SPECS[index_].channel, + settingsServoChannel(index_), 0, kPca9685FullOff ); @@ -303,7 +314,7 @@ void ServoWrapper::writeAngle(float angle, bool log, bool electrical) { if (pulse != lastPulse_) { pwm.setPWM( - SERVO_SPECS[index_].channel, + settingsServoChannel(index_), 0, pulse ); diff --git a/src/http/settings_handlers.cpp b/src/http/settings_handlers.cpp index 66ea7b8b..35fc439f 100644 --- a/src/http/settings_handlers.cpp +++ b/src/http/settings_handlers.cpp @@ -43,6 +43,25 @@ bool readField(JsonVariantConst list, uint8_t (&out)[SETTINGS_SERVO_COUNT]) { return true; } +bool readField(JsonVariantConst list, std::optional& out) { + if (!list.is() || list.size() != SETTINGS_SERVO_COUNT) { + return false; + } + + ServoCenters centers{}; + + for (size_t i = 0; i < SETTINGS_SERVO_COUNT; i++) { + if (!list[i].is()) { + return false; + } + + centers.angles[i] = list[i].as(); + } + + out = centers; + return true; +} + // Fills the patch from the body. Returns the 400 message, or an empty string // when every field is known and has the right JSON type. String readPatch(JsonObjectConst body, SettingsPatch& patch) { @@ -74,10 +93,12 @@ String readPatch(JsonObjectConst body, SettingsPatch& patch) { read("access_token", patch.accessToken); read("rgb_order", patch.rgbOrder); read("oled_rotate_180", patch.oledRotate180); + read("swap_hands", patch.swapHands); ServoRanges ranges{}; read("servo_mins", ranges.mins); read("servo_maxs", ranges.maxs); + read("servo_centers", patch.servoCenters); for (const JsonPairConst field : body) { const auto isKnown = [&](const char* key) { return field.key() == key; }; @@ -121,14 +142,17 @@ JsonDocument settingsJson() { JsonArray servoMins = doc["servo_mins"].to(); JsonArray servoMaxs = doc["servo_maxs"].to(); + JsonArray servoCenters = doc["servo_centers"].to(); for (size_t i = 0; i < SETTINGS_SERVO_COUNT; i++) { servoMins.add(static_cast(settingsServoMin(i))); servoMaxs.add(static_cast(settingsServoMax(i))); + servoCenters.add(settingsServoCenter(i)); } doc["rgb_order"] = settingsRgbOrder(); doc["oled_rotate_180"] = settingsOledRotate180(); + doc["swap_hands"] = settingsSwapHands(); const WifiNetworkList& networks = settingsWifiNetworks(); JsonArray ssids = doc["wifi_networks"].to(); @@ -162,8 +186,10 @@ void handleSettingsPost(WebServer& server) { SettingsPatch patch; String error = readPatch(body.as(), patch); - if (error.isEmpty() && patch.invalidField() != nullptr) { - error = String("invalid ") + patch.invalidField(); + const char* invalid = patch.invalidField(&settingsServoRanges()); + + if (error.isEmpty() && invalid != nullptr) { + error = String("invalid ") + invalid; } if (!error.isEmpty()) { diff --git a/src/settings/getters.cpp b/src/settings/getters.cpp index 463c3ddd..05bcc697 100644 --- a/src/settings/getters.cpp +++ b/src/settings/getters.cpp @@ -75,6 +75,26 @@ float settingsServoMax(int index) { return static_cast(g_settings.servoRanges.maxs[index]); } +float settingsServoCenter(int index) { + if (index < 0 || index >= static_cast(SETTINGS_SERVO_COUNT)) { + return SETTINGS_SERVO_ANGLE_MAX * 0.5f; + } + + return g_settings.servoCenters.angles[index]; +} + +const ServoRanges& settingsServoRanges() { + return g_settings.servoRanges; +} + +bool settingsSwapHands() { + return g_settings.swapHands; +} + +int settingsServoChannel(int index) { + return servoChannelFor(index, g_bootSettings.swapHands); +} + const char* settingsRgbOrder() { return g_settings.rgbOrder; } diff --git a/src/settings/nvs.cpp b/src/settings/nvs.cpp index 647118bc..2630bc4c 100644 --- a/src/settings/nvs.cpp +++ b/src/settings/nvs.cpp @@ -61,8 +61,21 @@ void SettingsValues::load() { } } + // Older firmware saved only ranges: retain its exact midpoint behavior. + for (size_t i = 0; i < SETTINGS_SERVO_COUNT; i++) { + servoCenters.angles[i] = (servoRanges.mins[i] + servoRanges.maxs[i]) * 0.5f; + } + + ServoCenters storedCenters{}; + + if (g_settingsPrefs.getBytes(kKeyServoCenters, &storedCenters, sizeof(storedCenters)) == + sizeof(storedCenters) && storedCenters.valid(&servoRanges)) { + servoCenters = storedCenters; + } + loadText(rgbOrder, kKeyRgbOrder, settingsValidateRgbOrder); oledRotate180 = g_settingsPrefs.getBool(kKeyOledRot, oledRotate180); + swapHands = g_settingsPrefs.getBool(kKeySwapHands, swapHands); } namespace { @@ -108,6 +121,7 @@ bool writeValues(const SettingsValues& values, const SettingsValues& before, boo putText(kKeyAccessTok, values.accessToken, before.accessToken); putText(kKeyRgbOrder, values.rgbOrder, before.rgbOrder); putBool(kKeyOledRot, values.oledRotate180, before.oledRotate180); + putBool(kKeySwapHands, values.swapHands, before.swapHands); if (all || memcmp(&values.servoRanges, &before.servoRanges, sizeof(ServoRanges)) != 0) { uint8_t blob[kServoRangesLen]; @@ -120,6 +134,11 @@ bool writeValues(const SettingsValues& values, const SettingsValues& before, boo ok &= g_settingsPrefs.putBytes(kKeyServoRanges, blob, kServoRangesLen) == kServoRangesLen; } + if (all || memcmp(&values.servoCenters, &before.servoCenters, sizeof(ServoCenters)) != 0) { + ok &= g_settingsPrefs.putBytes(kKeyServoCenters, &values.servoCenters, sizeof(ServoCenters)) == + sizeof(ServoCenters); + } + return ok; } @@ -147,7 +166,7 @@ bool SettingsValues::replaceAll() const { } bool saveSettings(const SettingsPatch& patch) { - if (patch.invalidField() != nullptr) { + if (patch.invalidField(&g_settings.servoRanges) != nullptr) { return false; } @@ -191,6 +210,8 @@ bool factoryResetSettings() { // The hardware calibration survives a reset. SettingsValues next = SettingsValues::defaults(); next.servoRanges = g_settings.servoRanges; + next.servoCenters = g_settings.servoCenters; + next.swapHands = g_settings.swapHands; strlcpy(next.rgbOrder, g_settings.rgbOrder, sizeof(next.rgbOrder)); next.oledRotate180 = g_settings.oledRotate180; @@ -209,5 +230,6 @@ bool factoryResetSettings() { bool settingsRebootRequired() { return strcmp(g_settings.hostname, g_bootSettings.hostname) != 0 || strcmp(g_settings.loading, g_bootSettings.loading) != 0 || + g_settings.swapHands != g_bootSettings.swapHands || g_wifiChangedSinceBoot; } diff --git a/src/settings/nvs.h b/src/settings/nvs.h index 1fc3d5c0..f2daee0e 100644 --- a/src/settings/nvs.h +++ b/src/settings/nvs.h @@ -18,6 +18,8 @@ constexpr const char* kKeyWifiSsid = "wifi_ssid"; constexpr const char* kKeyWifiPass = "wifi_pass"; constexpr const char* kKeyWifiNets = "wifi_nets"; constexpr const char* kKeyServoRanges = "sranges"; +constexpr const char* kKeyServoCenters = "scenters"; +constexpr const char* kKeySwapHands = "swap_hands"; constexpr const char* kKeyRgbOrder = "rgb_ord"; constexpr const char* kKeyOledRot = "oled_rot"; diff --git a/src/settings/settings.h b/src/settings/settings.h index aa27fe27..eaf0b5fd 100644 --- a/src/settings/settings.h +++ b/src/settings/settings.h @@ -33,6 +33,7 @@ constexpr uint8_t SETTINGS_SERVO_ANGLE_MAX = 180; constexpr size_t SETTINGS_RGB_ORDER_MAX_LEN = 3; constexpr const char* SETTINGS_DEFAULT_RGB_ORDER = "GRB"; constexpr bool SETTINGS_DEFAULT_OLED_ROTATE_180 = false; +constexpr bool SETTINGS_DEFAULT_SWAP_HANDS = false; struct ServoRanges { uint8_t mins[SETTINGS_SERVO_COUNT]; @@ -42,6 +43,16 @@ struct ServoRanges { bool valid() const; }; +struct ServoCenters { + float angles[SETTINGS_SERVO_COUNT]; + + // Finite angles strictly between the matching safe limits (or 0..180). + bool valid(const ServoRanges* ranges = nullptr) const; + + // Keep an existing center where possible when the safe limits change. + void fitTo(const ServoRanges& ranges); +}; + // Fields left empty, or nullptr for text, keep their saved value. struct SettingsPatch { std::optional sleepTimeoutMin; @@ -54,14 +65,17 @@ struct SettingsPatch { const char* eyesStyle = nullptr; const char* accessToken = nullptr; std::optional servoRanges; + std::optional servoCenters; + std::optional swapHands; const char* rgbOrder = nullptr; std::optional oledRotate180; - // API name of the first field set to an invalid value, or nullptr. - const char* invalidField() const; + // API name of the first invalid field, or nullptr. Supplied ranges take + // precedence over savedRanges when validating centers. + const char* invalidField(const ServoRanges* savedRanges = nullptr) const; - // Name of the setup wizard step that owns a field the patch sets (servo - // ranges, RGB order, screen rotation), or nullptr. Those save only in setup AP mode. + // Name of the setup wizard step that owns a field the patch sets (RGB order + // or screen rotation), or nullptr. Those save only in setup AP mode. const char* setupOnlyStep() const; }; @@ -82,10 +96,15 @@ const WifiNetworkList& settingsWifiNetworks(); bool settingsWifiConfigured(); float settingsServoMin(int index); float settingsServoMax(int index); +float settingsServoCenter(int index); +const ServoRanges& settingsServoRanges(); +bool settingsSwapHands(); +// Output channel from the mapping captured at boot; invalid index returns -1. +int settingsServoChannel(int index); const char* settingsRgbOrder(); bool settingsOledRotate180(); -// True once the saved hostname, loading screen or WiFi networks differ from +// True once the saved hostname, loading screen, hand mapping or WiFi networks differ from // what this boot started with; those apply only after a reboot. bool settingsRebootRequired(); @@ -108,5 +127,5 @@ bool saveSettings(const SettingsPatch& patch); bool saveWifiNetworks(const WifiNetworkList& networks); // Clears the NVS namespace and restores the defaults, WiFi networks included, -// but keeps the servo min/max, RGB LED byte order and OLED 180° rotation. +// but keeps servo calibration and hand mapping, RGB LED byte order and OLED 180° rotation. bool factoryResetSettings(); diff --git a/src/settings/validate.cpp b/src/settings/validate.cpp index 94b33042..3e0ad00d 100644 --- a/src/settings/validate.cpp +++ b/src/settings/validate.cpp @@ -1,5 +1,6 @@ #include "settings/settings.h" +#include #include #include "display/eyes/styles/eye_style.h" @@ -119,6 +120,27 @@ bool ServoRanges::valid() const { return true; } +bool ServoCenters::valid(const ServoRanges* ranges) const { + for (size_t i = 0; i < SETTINGS_SERVO_COUNT; i++) { + const float min = ranges != nullptr ? ranges->mins[i] : 0.0f; + const float max = ranges != nullptr ? ranges->maxs[i] : SETTINGS_SERVO_ANGLE_MAX; + + if (!std::isfinite(angles[i]) || angles[i] <= min || angles[i] >= max) { + return false; + } + } + + return true; +} + +void ServoCenters::fitTo(const ServoRanges& ranges) { + for (size_t i = 0; i < SETTINGS_SERVO_COUNT; i++) { + if (!std::isfinite(angles[i]) || angles[i] <= ranges.mins[i] || angles[i] >= ranges.maxs[i]) { + angles[i] = (ranges.mins[i] + ranges.maxs[i]) * 0.5f; + } + } +} + bool settingsValidateRgbOrder(const char* rgbOrder) { if (rgbOrder == nullptr) { return false; @@ -132,7 +154,7 @@ bool settingsValidateRgbOrder(const char* rgbOrder) { strcmp(rgbOrder, "BGR") == 0; } -const char* SettingsPatch::invalidField() const { +const char* SettingsPatch::invalidField(const ServoRanges* savedRanges) const { if (sleepTimeoutMin && !settingsValidateSleepTimeout(*sleepTimeoutMin)) { return "sleep_timeout"; } @@ -165,6 +187,10 @@ const char* SettingsPatch::invalidField() const { return "servo_mins or servo_maxs"; } + if (servoCenters && !servoCenters->valid(servoRanges ? &*servoRanges : savedRanges)) { + return "servo_centers"; + } + if (rgbOrder != nullptr && !settingsValidateRgbOrder(rgbOrder)) { return "rgb_order"; } @@ -173,10 +199,6 @@ const char* SettingsPatch::invalidField() const { } const char* SettingsPatch::setupOnlyStep() const { - if (servoRanges) { - return "servo"; - } - if (rgbOrder != nullptr) { return "rgb"; } diff --git a/src/settings/values.cpp b/src/settings/values.cpp index 0f69882e..23240442 100644 --- a/src/settings/values.cpp +++ b/src/settings/values.cpp @@ -24,10 +24,12 @@ SettingsValues SettingsValues::defaults() { for (size_t i = 0; i < SETTINGS_SERVO_COUNT; i++) { values.servoRanges.mins[i] = static_cast(SERVO_SPECS[i].min); values.servoRanges.maxs[i] = static_cast(SERVO_SPECS[i].max); + values.servoCenters.angles[i] = servoMid(SERVO_SPECS[i]); } strlcpy(values.rgbOrder, SETTINGS_DEFAULT_RGB_ORDER, sizeof(values.rgbOrder)); values.oledRotate180 = SETTINGS_DEFAULT_OLED_ROTATE_180; + values.swapHands = SETTINGS_DEFAULT_SWAP_HANDS; return values; } @@ -38,6 +40,12 @@ void SettingsValues::apply(const SettingsPatch& patch) { serialLog = patch.serialLog.value_or(serialLog); continuousTimeoutMin = patch.continuousTimeoutMin.value_or(continuousTimeoutMin); servoRanges = patch.servoRanges.value_or(servoRanges); + servoCenters = patch.servoCenters.value_or(servoCenters); + swapHands = patch.swapHands.value_or(swapHands); + + if (patch.servoRanges && !patch.servoCenters) { + servoCenters.fitTo(servoRanges); + } oledRotate180 = patch.oledRotate180.value_or(oledRotate180); if (patch.hostname != nullptr) { @@ -71,7 +79,8 @@ void SettingsValues::log(const char* prefix) const { Serial.printf( "%s sleep_timeout=%umin hostname=%s volume=%u welcome=%s serial_log=on " "continuous_timeout=%umin loading=%s eyes_style=%s access_token=%s wifi_networks=%u " - "servos=%u-%u;%u-%u;%u-%u;%u-%u;%u-%u rgb_order=%s oled_rotate_180=%s\n", + "servos=%u-%u;%u-%u;%u-%u;%u-%u;%u-%u " + "servo_centers=%.2f;%.2f;%.2f;%.2f;%.2f swap_hands=%s rgb_order=%s oled_rotate_180=%s\n", prefix, static_cast(sleepTimeoutMin), hostname, @@ -84,6 +93,9 @@ void SettingsValues::log(const char* prefix) const { static_cast(g_wifiNetworks.count()), r.mins[0], r.maxs[0], r.mins[1], r.maxs[1], r.mins[2], r.maxs[2], r.mins[3], r.maxs[3], r.mins[4], r.maxs[4], + servoCenters.angles[0], servoCenters.angles[1], servoCenters.angles[2], + servoCenters.angles[3], servoCenters.angles[4], + swapHands ? "on" : "off", rgbOrder, oledRotate180 ? "on" : "off" ); diff --git a/src/settings/values.h b/src/settings/values.h index ba61a27f..5504c746 100644 --- a/src/settings/values.h +++ b/src/settings/values.h @@ -19,12 +19,14 @@ struct SettingsValues { char eyesStyle[SETTINGS_EYES_STYLE_MAX_LEN + 1]; char accessToken[SETTINGS_ACCESS_TOKEN_MAX_LEN + 1]; ServoRanges servoRanges; + ServoCenters servoCenters; + bool swapHands; char rgbOrder[SETTINGS_RGB_ORDER_MAX_LEN + 1]; bool oledRotate180; static SettingsValues defaults(); - // The patch must be valid (SettingsPatch::invalidField() == nullptr). + // The patch must be valid (SettingsPatch::invalidField(&servoRanges) == nullptr). void apply(const SettingsPatch& patch); // Reads NVS over the current values; keys that are missing or invalid keep theirs. @@ -41,6 +43,6 @@ struct SettingsValues { extern SettingsValues g_settings; extern WifiNetworkList g_wifiNetworks; -// Hostname and loading screen are read at boot only; a change waits for a reboot. +// Hostname, loading screen and hand mapping are read at boot; changes wait for a reboot. extern SettingsValues g_bootSettings; extern bool g_wifiChangedSinceBoot; diff --git a/test/test_servo_norm/test_servo_norm.cpp b/test/test_servo_norm/test_servo_norm.cpp index 980953e2..cb6b0bd3 100644 --- a/test/test_servo_norm/test_servo_norm.cpp +++ b/test/test_servo_norm/test_servo_norm.cpp @@ -40,6 +40,51 @@ void test_stock_head_identity() { ); } +void test_calibrated_center_preserves_safe_limits() { + TEST_ASSERT_FLOAT_WITHIN(0.001f, 82.5f, servoNormToDeg(0.0f, 70.0f, 82.5f, 100.0f)); + TEST_ASSERT_FLOAT_WITHIN(0.001f, 76.25f, servoNormToDeg(-0.5f, 70.0f, 82.5f, 100.0f)); + TEST_ASSERT_FLOAT_WITHIN(0.001f, 91.25f, servoNormToDeg(0.5f, 70.0f, 82.5f, 100.0f)); + TEST_ASSERT_FLOAT_WITHIN(0.001f, 70.0f, servoNormToDeg(-1.0f, 70.0f, 82.5f, 100.0f)); + TEST_ASSERT_FLOAT_WITHIN(0.001f, 100.0f, servoNormToDeg(1.0f, 70.0f, 82.5f, 100.0f)); + TEST_ASSERT_FLOAT_WITHIN(0.001f, 70.0f, servoNormToDeg(-2.0f, 70.0f, 82.5f, 100.0f)); + TEST_ASSERT_FLOAT_WITHIN(0.001f, 100.0f, servoNormToDeg(2.0f, 70.0f, 82.5f, 100.0f)); +} + +void test_calibrated_center_inverse_roundtrip_and_saturation() { + for (int step = -10; step <= 10; step++) { + const float n = step / 10.0f; + const float deg = servoNormToDeg(n, 35.0f, 110.5f, 125.0f); + TEST_ASSERT_FLOAT_WITHIN(0.001f, n, servoDegToNorm(deg, 35.0f, 110.5f, 125.0f)); + } + + TEST_ASSERT_FLOAT_WITHIN(0.001f, -1.0f, servoDegToNorm(0.0f, 35.0f, 110.5f, 125.0f)); + TEST_ASSERT_FLOAT_WITHIN(0.001f, 1.0f, servoDegToNorm(180.0f, 35.0f, 110.5f, 125.0f)); +} + +void test_default_centers_keep_legacy_mapping() { + for (const ServoSpec& spec : SERVO_SPECS) { + for (int step = -10; step <= 10; step++) { + const float n = step / 10.0f; + const float expected = servoMid(spec) + n * (spec.max - spec.min) * 0.5f; + TEST_ASSERT_FLOAT_WITHIN(0.001f, expected, servoNormToDeg(n, spec.min, servoMid(spec), spec.max)); + } + } +} + +void test_hand_assignment_swaps_only_hand_channels() { + for (int i = 0; i < SERVO_COUNT; i++) { + TEST_ASSERT_EQUAL_INT(SERVO_SPECS[i].channel, servoChannelFor(i, false)); + } + + TEST_ASSERT_EQUAL_INT(3, servoChannelFor(SERVO_HAND_LEFT, true)); + TEST_ASSERT_EQUAL_INT(2, servoChannelFor(SERVO_HAND_RIGHT, true)); + TEST_ASSERT_EQUAL_INT(0, servoChannelFor(SERVO_HEAD, true)); + TEST_ASSERT_EQUAL_INT(1, servoChannelFor(SERVO_NECK, true)); + TEST_ASSERT_EQUAL_INT(4, servoChannelFor(SERVO_BODY, true)); + TEST_ASSERT_EQUAL_INT(-1, servoChannelFor(-1, true)); + TEST_ASSERT_EQUAL_INT(-1, servoChannelFor(SERVO_COUNT, false)); +} + int main() { UNITY_BEGIN(); RUN_TEST(test_norm_mid_is_average); @@ -47,5 +92,9 @@ int main() { RUN_TEST(test_norm_saturates); RUN_TEST(test_deg_to_norm_roundtrip); RUN_TEST(test_stock_head_identity); + RUN_TEST(test_calibrated_center_preserves_safe_limits); + RUN_TEST(test_calibrated_center_inverse_roundtrip_and_saturation); + RUN_TEST(test_default_centers_keep_legacy_mapping); + RUN_TEST(test_hand_assignment_swaps_only_hand_channels); return UNITY_END(); } diff --git a/test/test_settings_validate/test_settings_validate.cpp b/test/test_settings_validate/test_settings_validate.cpp index aae355f5..3f87df10 100644 --- a/test/test_settings_validate/test_settings_validate.cpp +++ b/test/test_settings_validate/test_settings_validate.cpp @@ -1,5 +1,6 @@ #include #include +#include #include "settings/settings.h" @@ -118,6 +119,53 @@ void test_servo_ranges() { TEST_ASSERT_FALSE(over.valid()); } +void test_servo_centers_reject_invalid_angles() { + const ServoRanges ranges = {{60, 40, 45, 35, 40}, {130, 130, 135, 125, 130}}; + ServoCenters centers = {{95, 85, 90, 80, 85}}; + + TEST_ASSERT_TRUE(centers.valid(&ranges)); + centers.angles[4] = 85.5f; + TEST_ASSERT_TRUE(centers.valid(&ranges)); + + for (const float invalid : {40.0f, 130.0f, -1.0f, 181.0f, + std::numeric_limits::quiet_NaN(), + std::numeric_limits::infinity()}) { + centers.angles[4] = invalid; + TEST_ASSERT_FALSE(centers.valid(&ranges)); + } +} + +void test_range_changes_keep_centers_or_fall_back_to_midpoint() { + const ServoRanges ranges = {{60, 40, 45, 35, 40}, {131, 130, 135, 125, 130}}; + ServoCenters centers = {{60, 85, 135, 80, std::numeric_limits::quiet_NaN()}}; + + centers.fitTo(ranges); + + TEST_ASSERT_TRUE(centers.valid(&ranges)); + TEST_ASSERT_FLOAT_WITHIN(0.001f, 95.5f, centers.angles[0]); + TEST_ASSERT_FLOAT_WITHIN(0.001f, 85.0f, centers.angles[1]); + TEST_ASSERT_FLOAT_WITHIN(0.001f, 90.0f, centers.angles[2]); + TEST_ASSERT_FLOAT_WITHIN(0.001f, 80.0f, centers.angles[3]); + TEST_ASSERT_FLOAT_WITHIN(0.001f, 85.0f, centers.angles[4]); +} + +void test_patch_validates_centers_against_resulting_ranges() { + const ServoRanges saved = {{60, 40, 45, 35, 40}, {130, 130, 135, 125, 130}}; + SettingsPatch patch; + patch.servoCenters = ServoCenters{{95, 85, 90, 80, 35}}; + + TEST_ASSERT_EQUAL_STRING("servo_centers", patch.invalidField(&saved)); + TEST_ASSERT_NULL(patch.setupOnlyStep()); + + // A center can move outside the old band only when the new band allows it. + patch.servoRanges = ServoRanges{{60, 40, 45, 35, 30}, {130, 130, 135, 125, 130}}; + TEST_ASSERT_NULL(patch.invalidField(&saved)); + TEST_ASSERT_NULL(patch.setupOnlyStep()); + + patch.servoCenters->angles[4] = 30; + TEST_ASSERT_EQUAL_STRING("servo_centers", patch.invalidField(&saved)); +} + void test_patch_names_the_first_invalid_field() { SettingsPatch patch; @@ -140,13 +188,17 @@ void test_patch_names_its_setup_only_step() { patch.volume = 50; patch.hostname = "tiny-engineer"; + patch.swapHands = true; + TEST_ASSERT_NULL(patch.setupOnlyStep()); + + patch.servoRanges = ServoRanges{{10, 20, 30, 40, 50}, {20, 30, 40, 50, 60}}; TEST_ASSERT_NULL(patch.setupOnlyStep()); patch.oledRotate180 = false; TEST_ASSERT_EQUAL_STRING("oled", patch.setupOnlyStep()); - patch.servoRanges = ServoRanges{{10, 20, 30, 40, 50}, {20, 30, 40, 50, 60}}; - TEST_ASSERT_EQUAL_STRING("servo", patch.setupOnlyStep()); + patch.rgbOrder = "GRB"; + TEST_ASSERT_EQUAL_STRING("rgb", patch.setupOnlyStep()); } void test_rgb_order() { @@ -174,6 +226,9 @@ int main() { RUN_TEST(test_wifi_ssid); RUN_TEST(test_wifi_password); RUN_TEST(test_servo_ranges); + RUN_TEST(test_servo_centers_reject_invalid_angles); + RUN_TEST(test_range_changes_keep_centers_or_fall_back_to_midpoint); + RUN_TEST(test_patch_validates_centers_against_resulting_ranges); RUN_TEST(test_patch_names_the_first_invalid_field); RUN_TEST(test_patch_names_its_setup_only_step); RUN_TEST(test_rgb_order); diff --git a/ui/src/api/types.ts b/ui/src/api/types.ts index ab7de11d..e7c24233 100644 --- a/ui/src/api/types.ts +++ b/ui/src/api/types.ts @@ -39,6 +39,8 @@ export interface Settings { wifi_configured: boolean; servo_mins: number[]; servo_maxs: number[]; + servo_centers: number[]; + swap_hands: boolean; rgb_order: string; oled_rotate_180: boolean; wifi_networks: string[]; diff --git a/ui/src/components/HandAssignment.tsx b/ui/src/components/HandAssignment.tsx new file mode 100644 index 00000000..96a28f35 --- /dev/null +++ b/ui/src/components/HandAssignment.tsx @@ -0,0 +1,49 @@ +import type { Settings } from "../api/types"; +import { useRobot } from "../robot"; +import { useStatus } from "../status/status"; +import { useResettableState } from "../useResettableState"; +import { Button } from "./Button"; +import { ConfigSection } from "./ConfigSection"; + +export function HandAssignment({ settings }: { settings: Settings }) { + const { applySettings } = useRobot(); + const { perform } = useStatus(); + const [swapped, setSwapped] = useResettableState(settings.swap_hands, Boolean); + + async function save() { + await perform({ + path: "/settings", + request: { method: "POST", json: { swap_hands: swapped } }, + pending: "Saving hand assignment…", + failure: "Hand assignment save failed", + done: "Hand assignment saved. Restart the robot to apply it before moving the hands.", + onSuccess: applySettings, + }); + } + + return ( + +

+ If the hands move on the wrong side, swap their channels here. Left and right are from the + robot’s perspective. Restart after saving, then check each hand and its calibration. +

+
+ + +

+ {`Saved assignment: left on ${settings.swap_hands ? 3 : 2}, right on ${settings.swap_hands ? 2 : 3}. Applies after restart.`} +

+
+ +
+ ); +} diff --git a/ui/src/components/ServoLimits.tsx b/ui/src/components/ServoLimits.tsx new file mode 100644 index 00000000..877f3cdc --- /dev/null +++ b/ui/src/components/ServoLimits.tsx @@ -0,0 +1,104 @@ +import type { TargetedSubmitEvent } from "preact"; +import type { Settings } from "../api/types"; +import { useRobot } from "../robot"; +import { isCenter, SERVO_NAMES } from "../servos"; +import { useStatus } from "../status/status"; +import { useResettableState } from "../useResettableState"; +import { Button } from "./Button"; +import { ConfigSection } from "./ConfigSection"; + +export function ServoLimits({ settings, index }: { settings: Settings; index: number }) { + const { applySettings } = useRobot(); + const { perform } = useStatus(); + const [draft, setDraft] = useResettableState(settings, (saved) => ({ + min: String(saved.servo_mins[index]), + max: String(saved.servo_maxs[index]), + })); + const min = Number(draft.min); + const max = Number(draft.max); + const valid = + draft.min !== "" && + draft.max !== "" && + Number.isInteger(min) && + Number.isInteger(max) && + min >= 0 && + max <= 180 && + min < max; + const changed = min !== settings.servo_mins[index] || max !== settings.servo_maxs[index]; + const keepsCenter = isCenter(settings.servo_centers[index], [min, max]); + const center = keepsCenter ? settings.servo_centers[index] : (min + max) / 2; + + async function save(event: TargetedSubmitEvent) { + event.preventDefault(); + + if (!valid || !changed) { + return; + } + + await perform({ + path: "/settings", + request: { + method: "POST", + json: { + servo_mins: settings.servo_mins.map((each, i) => (i === index ? min : each)), + servo_maxs: settings.servo_maxs.map((each, i) => (i === index ? max : each)), + servo_centers: settings.servo_centers.map((each, i) => (i === index ? center : each)), + }, + }, + pending: "Saving servo limits…", + failure: "Limits save failed", + done: `${SERVO_NAMES[index]} limits saved: ${min}–${max}°.`, + onSuccess: applySettings, + }); + } + + return ( + +

+ {`Set the safe movement limits for ${SERVO_NAMES[index]}. Save before moving beyond the current range. Use limits you have checked on the assembled robot.`} +

+
+
+
+ + setDraft({ ...draft, min: event.currentTarget.value })} + /> +
+
+ + setDraft({ ...draft, max: event.currentTarget.value })} + /> +
+
+

+ {!valid + ? "Use whole degrees from 0 to 180, with min less than max." + : keepsCenter + ? `Center stays at ${center}°. Limits survive power-off.` + : `Center will reset to ${center}° to stay between the new limits.`} +

+ +
+
+ ); +} diff --git a/ui/src/config/ConfigPage.tsx b/ui/src/config/ConfigPage.tsx index 22289757..0a3db778 100644 --- a/ui/src/config/ConfigPage.tsx +++ b/ui/src/config/ConfigPage.tsx @@ -1,3 +1,4 @@ +import { HandAssignment } from "../components/HandAssignment"; import { useRobot } from "../robot"; import { Wizard } from "../setup/Wizard"; import { ConfigForm } from "./ConfigForm"; @@ -17,6 +18,7 @@ export function ConfigPage() { {settings && ( <> + )} diff --git a/ui/src/config/FactoryReset.tsx b/ui/src/config/FactoryReset.tsx index b0c62e35..4e3d411f 100644 --- a/ui/src/config/FactoryReset.tsx +++ b/ui/src/config/FactoryReset.tsx @@ -10,7 +10,7 @@ export function FactoryReset() { async function reset() { const confirmed = confirm( - "Reset settings to factory defaults? WiFi credentials will be cleared. Servo ranges, RGB LED mapping, and screen rotation stay. Power-cycle the device to reopen setup AP mode and configure WiFi again.", + "Reset settings to factory defaults? WiFi credentials will be cleared. Servo ranges and centers, hand assignment, RGB LED mapping, and screen rotation stay. Power-cycle the device to reopen setup AP mode and configure WiFi again.", ); if (!confirmed) { @@ -31,9 +31,9 @@ export function FactoryReset() { return (

- Erases saved settings and restores defaults. WiFi credentials are cleared. Servo ranges, RGB - LED mapping, and screen rotation stay. Power-cycle the device to reopen setup AP mode and - configure WiFi again. + Erases saved settings and restores defaults. WiFi credentials are cleared. Servo ranges and + centers, hand assignment, RGB LED mapping, and screen rotation stay. Power-cycle the device + to reopen setup AP mode and configure WiFi again.

- +
+ + +
); } diff --git a/ui/src/setup/Wizard.tsx b/ui/src/setup/Wizard.tsx index e5addc4c..d8659391 100644 --- a/ui/src/setup/Wizard.tsx +++ b/ui/src/setup/Wizard.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from "preact/hooks"; import type { Settings } from "../api/types"; import { useRobot } from "../robot"; import { Button } from "../components/Button"; +import { HandAssignment } from "../components/HandAssignment"; import { useStatus } from "../status/status"; import { useResettableState } from "../useResettableState"; import { FindRanges } from "./FindRanges"; @@ -59,8 +60,9 @@ export function Wizard({ settings }: { settings: Settings }) { { servo_mins: calibration.ranges.map(([min]) => min), servo_maxs: calibration.ranges.map(([, max]) => max), + servo_centers: calibration.centers, }, - "Saving servo ranges…", + "Saving servo calibration…", ), oled: () => save({ oled_rotate_180: oledRotate180 }, "Saving screen rotation…"), led: () => save({ rgb_order: led.order }, "Saving LED mapping…"), @@ -98,6 +100,7 @@ export function Wizard({ settings }: { settings: Settings }) { style={{ width: `${((stepIndex + 1) / STEPS.length) * 100}%` }} > + {step.id === "servos" && } {step.id === "servos" && (placingParts ? ( diff --git a/ui/src/setup/useCalibration.ts b/ui/src/setup/useCalibration.ts index c3a4fe8e..7531d631 100644 --- a/ui/src/setup/useCalibration.ts +++ b/ui/src/setup/useCalibration.ts @@ -1,6 +1,6 @@ import { useState } from "preact/hooks"; import type { Settings } from "../api/types"; -import { savedRanges, SERVO_NAMES } from "../servos"; +import { isCenter, savedRanges, SERVO_NAMES } from "../servos"; import { useResettableState } from "../useResettableState"; export const CENTER = 90; @@ -11,6 +11,7 @@ export function useCalibration(settings: Settings) { const [joint, setJoint] = useState(0); const [angles, setAngles] = useState(() => SERVO_NAMES.map(() => CENTER)); const [ranges, setRanges] = useResettableState(settings, savedRanges); + const [centers, setCenters] = useResettableState(settings.servo_centers, (saved) => saved); return { phase, @@ -21,7 +22,9 @@ export function useCalibration(settings: Settings) { setAngles, ranges, setRanges, - valid: ranges.every(([min, max]) => min < max), + centers, + setCenters, + valid: ranges.every(([min, max], i) => min < max && isCenter(centers[i], [min, max])), placePartsDone: () => { setPhase("ranges"); setJoint(0); diff --git a/ui/src/style.css b/ui/src/style.css index 427e1c4d..50d7d91c 100644 --- a/ui/src/style.css +++ b/ui/src/style.css @@ -366,6 +366,16 @@ nav a.active { margin-top: 1rem; } +.servo-limits { + margin-top: 1rem; +} + +.servo-limits-fields { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 1rem; +} + .servo-actions .btn { text-align: center; font-weight: 600; diff --git a/ui/src/views/ApiReference.tsx b/ui/src/views/ApiReference.tsx index 0fa59049..50f2ff44 100644 --- a/ui/src/views/ApiReference.tsx +++ b/ui/src/views/ApiReference.tsx @@ -151,7 +151,7 @@ export function ApiReference() { integer array - 5 integers 0–180; setup AP only; requires servo_maxs + 5 integers 0–180; immediate; requires servo_maxs @@ -159,7 +159,23 @@ export function ApiReference() { servo_maxs integer array - 5 integers 0–180; setup AP only; each max > min + 5 integers 0–180; immediate; each max > min + + + + servo_centers + + number array + + 5 finite numbers; each min < center < max; immediate, including outside setup AP + + + + + swap_hands + + boolean + Swap hand channels 2 and 3; default false; next reboot @@ -183,15 +199,15 @@ export function ApiReference() {

- Servo min/max, RGB LED mapping, and OLED rotation can only be set in setup AP mode.{" "} - PUT /settings/wifi takes a JSON array of 1–5{" "} - {'{"ssid", "password"}'} objects; leave out password to keep a - saved one. In setup AP mode the robot tests the first network before saving. During setup - (credentials not saved), control APIs return 503{" "} - wifi not configured; POST /setup/servo,{" "} + Servo min/max and centers can also be saved outside setup AP. RGB LED mapping and OLED + rotation can only be set in setup AP mode. PUT /settings/wifi takes a JSON + array of 1–5 {'{"ssid", "password"}'} objects; leave out{" "} + password to keep a saved one. In setup AP mode the robot tests the first + network before saving. During setup (credentials not saved), control APIs return{" "} + 503 wifi not configured; POST /setup/servo,{" "} POST /setup/led, POST /setup/audio, and{" "} - POST /setup/oled stay available. Servo ranges, RGB mapping, and screen rotation - survive factory reset and can be retuned in the setup wizard. + POST /setup/oled stay available. Servo ranges and centers, RGB mapping, and + screen rotation survive factory reset and can be retuned in the setup wizard.

POST /anim — query param name: diff --git a/ui/src/views/Home.tsx b/ui/src/views/Home.tsx index 2e96f236..eac43a30 100644 --- a/ui/src/views/Home.tsx +++ b/ui/src/views/Home.tsx @@ -3,12 +3,12 @@ import { HealthStrip } from "./HealthStrip"; const CARDS = [ ["/animations", "Animations", "Pick a gesture — typing, reading, thinking, talking, and more."], - ["/servo", "Servo control", "Move individual servos to any angle."], + ["/servo", "Servo control", "Move servos and save limits and centers."], ["/tests", "Hardware tests", "Try the speaker, screen, LEDs, and servo sweep."], [ "/config", "Config", - "Device name, timeouts, volume, eyes style, boot behavior, serial logging, API token, and saved WiFi networks. Servo ranges, screen orientation, and RGB mapping are retuned in setup mode after factory reset.", + "Device name, timeouts, volume, eyes style, boot behavior, serial logging, API token, saved WiFi networks, and hand servo assignment. Servo limits and centers live on Servo control. Screen orientation and RGB mapping are retuned in setup mode after factory reset.", ], ["/api", "API reference", "Full endpoint list, parameters, and curl-friendly docs."], ] as const; diff --git a/ui/src/views/ServoControl.tsx b/ui/src/views/ServoControl.tsx index 24dc5570..c5aa95dc 100644 --- a/ui/src/views/ServoControl.tsx +++ b/ui/src/views/ServoControl.tsx @@ -1,12 +1,15 @@ import { useState } from "preact/hooks"; import { useRobot } from "../robot"; +import type { Settings } from "../api/types"; import { Button } from "../components/Button"; +import { ConfigSection } from "../components/ConfigSection"; +import { ServoLimits } from "../components/ServoLimits"; import { ServoTrack } from "../components/ServoTrack"; -import { DEFAULT_SERVO_RANGES, inRange, SERVO_NAMES, savedRanges } from "../servos"; +import { DEFAULT_SERVO_RANGES, inRange, isCenter, SERVO_NAMES, savedRanges } from "../servos"; import { useStatus } from "../status/status"; export function ServoControl() { - const { settings } = useRobot(); + const { settings, applySettings } = useRobot(); const { perform } = useStatus(); // Until the settings load, the stock ranges stand in. const ranges = settings ? savedRanges(settings) : DEFAULT_SERVO_RANGES; @@ -14,6 +17,7 @@ export function ServoControl() { const [angle, setAngle] = useState("90"); const [min, max] = ranges[index]; const mid = (min + max) / 2; + const center = settings?.servo_centers[index] ?? mid; const value = parseFloat(angle); const safe = inRange(value, ranges[index]); @@ -23,7 +27,7 @@ export function ServoControl() { setIndex(next); if (!inRange(value, ranges[next])) { - setAngle(String((nextMin + nextMax) / 2)); + setAngle(String(settings?.servo_centers[next] ?? (nextMin + nextMax) / 2)); } } @@ -37,6 +41,26 @@ export function ServoControl() { }); } + async function saveCenter(target: number) { + if (!settings || !isCenter(target, ranges[index])) { + return; + } + + await perform({ + path: "/settings", + request: { + method: "POST", + json: { + servo_centers: settings.servo_centers.map((each, i) => (i === index ? target : each)), + }, + }, + pending: "Saving servo center…", + failure: "Center save failed", + done: `${SERVO_NAMES[index]} center saved at ${target}°.`, + onSuccess: applySettings, + }); + } + return (

Servo control

@@ -71,7 +95,7 @@ export function ServoControl() {
} - scale={[min, Math.round(mid), max]} + scale={[min, mid, max]} > setAngle(event.currentTarget.value)} /> @@ -90,6 +115,7 @@ export function ServoControl() { required min={min} max={max} + step="any" value={angle} onInput={(event) => setAngle(event.currentTarget.value)} /> @@ -98,8 +124,8 @@ export function ServoControl() {
+ {settings && } + +

+ Move the selected joint until it is aligned, then save that angle as its center. + Animations use this center within the safe range. The saved center survives power-off. +

+

{`Saved center for ${SERVO_NAMES[index]}: ${center}°`}

+ +
+ + +
+
); } diff --git a/ui/test/config.test.tsx b/ui/test/config.test.tsx index 3a48c6eb..be2c902b 100644 --- a/ui/test/config.test.tsx +++ b/ui/test/config.test.tsx @@ -86,4 +86,37 @@ describe("config page", () => { expect(robot.lastPost("/settings")?.json).toHaveProperty("access_token", "new-token"); expect(sessionStorage.getItem("te_access_token")).toBe("new-token"); }); + + it("saves swapped hand channels without moving servos", async () => { + const { robot, user, click } = await openConfig(); + + await user.selectOptions(await screen.findByLabelText("Left hand servo channel"), "3"); + await click("Save hand assignment"); + + await waitFor(() => expect(robot.lastPost("/settings")?.json).toEqual({ swap_hands: true })); + expect(robot.sent("POST", "/test/servo")).toHaveLength(0); + expect(robot.settings.servo_centers).toEqual([95, 85, 90, 80, 85]); + expect(screen.getByRole("status").textContent).toContain("Restart the robot"); + }); + + it("can restore the default hand assignment", async () => { + const { robot, user, click } = await openConfig({ swap_hands: true }); + + await user.selectOptions(await screen.findByLabelText("Left hand servo channel"), "2"); + await click("Save hand assignment"); + + await waitFor(() => expect(robot.lastPost("/settings")?.json).toEqual({ swap_hands: false })); + }); + + it("keeps the saved hand assignment when saving fails", async () => { + const { robot, user, click } = await openConfig(); + + await user.selectOptions(await screen.findByLabelText("Left hand servo channel"), "3"); + robot.failing.add("/settings"); + await click("Save hand assignment"); + + await waitFor(() => expect(screen.getByRole("status").textContent).toBe("robot said no")); + expect(robot.settings.swap_hands).toBe(false); + expect(screen.getByText(/Saved assignment: left on 2, right on 3/)).toBeTruthy(); + }); }); diff --git a/ui/test/robot.tsx b/ui/test/robot.tsx index aa8247b1..10eb2f95 100644 --- a/ui/test/robot.tsx +++ b/ui/test/robot.tsx @@ -19,6 +19,8 @@ const DEFAULT_SETTINGS: Settings = { wifi_networks: ["home"], servo_mins: [60, 40, 45, 35, 40], servo_maxs: [130, 130, 135, 125, 130], + servo_centers: [95, 85, 90, 80, 85], + swap_hands: false, rgb_order: "GRB", oled_rotate_180: false, }; diff --git a/ui/test/servo.test.tsx b/ui/test/servo.test.tsx index d71a3585..89e95824 100644 --- a/ui/test/servo.test.tsx +++ b/ui/test/servo.test.tsx @@ -9,6 +9,16 @@ async function typeAngle(user: ReturnType["user"], value: stri await user.type(angle, value); } +async function typeLimit(user: ReturnType["user"], name: string, value: string) { + const field = await screen.findByRole("spinbutton", { name }); + + await user.clear(field); + + if (value) { + await user.type(field, value); + } +} + describe("servo page", () => { it("bounds the slider by the selected joint's saved range", async () => { fakeRobot({ @@ -45,4 +55,165 @@ describe("servo page", () => { expect(robot.lastPost("/test/servo")?.json).toEqual({ index: 0, angle: 100 }), ); }); + + it("moves to the saved center instead of the range midpoint", async () => { + const robot = fakeRobot({ settings: { servo_centers: [102.5, 85, 90, 80, 85] } }); + const { click } = renderApp("/servo"); + + await click("Center"); + + await waitFor(() => + expect(robot.lastPost("/test/servo")?.json).toEqual({ index: 0, angle: 102.5 }), + ); + }); + + it("saves only the selected joint's center without moving it or changing limits", async () => { + const robot = fakeRobot(); + const { user, click } = renderApp("/servo"); + + await user.selectOptions(await screen.findByLabelText("Servo"), "4"); + await typeAngle(user, "92.5"); + await click("Save angle as center"); + + await waitFor(() => + expect(robot.lastPost("/settings")?.json).toEqual({ + servo_centers: [95, 85, 90, 80, 92.5], + }), + ); + expect(robot.sent("POST", "/test/servo")).toHaveLength(0); + expect(screen.getByText("Saved center for Body: 92.5°")).toBeTruthy(); + }); + + it("disables saving a center on or outside a limit", async () => { + fakeRobot(); + const { user } = renderApp("/servo"); + + for (const angle of ["60", "130", "150"]) { + await typeAngle(user, angle); + expect( + screen.getByRole("button", { name: "Save angle as center" }).disabled, + ).toBe(true); + } + }); + + it("keeps the saved center when saving fails", async () => { + const robot = fakeRobot(); + const { user, click } = renderApp("/servo"); + + await typeAngle(user, "100"); + robot.failing.add("/settings"); + await click("Save angle as center"); + + await waitFor(() => expect(screen.getByRole("status").textContent).toBe("robot said no")); + expect(screen.getByText("Saved center for Head: 95°")).toBeTruthy(); + }); + + it("resets only the selected center to the midpoint", async () => { + const robot = fakeRobot({ settings: { servo_centers: [102.5, 85, 90, 80, 92] } }); + const { click } = renderApp("/servo"); + + await click("Reset center to midpoint"); + + await waitFor(() => expect(robot.settings.servo_centers).toEqual([95, 85, 90, 80, 92])); + }); +}); + +describe("servo limits", () => { + it("saves the selected joint's limits and preserves the other calibration", async () => { + const robot = fakeRobot(); + const { user, click } = renderApp("/servo"); + + await user.selectOptions(await screen.findByLabelText("Servo"), "4"); + await typeLimit(user, "Min (°)", "50"); + await typeLimit(user, "Max (°)", "120"); + + expect(robot.sent("POST", "/settings")).toHaveLength(0); + + await click("Save limits"); + + await waitFor(() => + expect(robot.lastPost("/settings")?.json).toEqual({ + servo_mins: [60, 40, 45, 35, 50], + servo_maxs: [130, 130, 135, 125, 120], + servo_centers: [95, 85, 90, 80, 85], + }), + ); + expect(robot.sent("POST", "/test/servo")).toHaveLength(0); + expect(robot.settings.swap_hands).toBe(false); + }); + + it("updates the slider only after saving and previews a recentered midpoint", async () => { + const robot = fakeRobot(); + const { user, click } = renderApp("/servo"); + + await typeLimit(user, "Min (°)", "100"); + await typeLimit(user, "Max (°)", "121"); + + expect( + screen.getByText("Center will reset to 110.5° to stay between the new limits."), + ).toBeTruthy(); + expect(screen.getByLabelText("Angle slider").getAttribute("min")).toBe("60"); + + await click("Save limits"); + + await waitFor(() => + expect(screen.getByLabelText("Angle slider").getAttribute("min")).toBe("100"), + ); + expect(screen.getByLabelText("Angle slider").getAttribute("max")).toBe("121"); + expect(robot.settings.servo_centers).toEqual([110.5, 85, 90, 80, 85]); + expect(screen.getByText("Saved center for Head: 110.5°")).toBeTruthy(); + }); + + it("blocks empty, fractional, reversed, equal, and out-of-range limits", async () => { + const robot = fakeRobot(); + const { user } = renderApp("/servo"); + + for (const min of ["", "70.5", "130", "140"]) { + await typeLimit(user, "Min (°)", min); + expect(screen.getByRole("button", { name: "Save limits" }).disabled).toBe( + true, + ); + } + + await typeLimit(user, "Min (°)", "70"); + await typeLimit(user, "Max (°)", "181"); + expect(screen.getByRole("button", { name: "Save limits" }).disabled).toBe( + true, + ); + expect(robot.sent("POST", "/settings")).toHaveLength(0); + }); + + it("keeps the active limits when saving fails and allows retry", async () => { + const robot = fakeRobot(); + const { user, click } = renderApp("/servo"); + + await typeLimit(user, "Min (°)", "70"); + robot.failing.add("/settings"); + await click("Save limits"); + + await waitFor(() => expect(screen.getByRole("status").textContent).toBe("robot said no")); + expect(screen.getByLabelText("Angle slider").getAttribute("min")).toBe("60"); + expect(screen.getByLabelText("Min (°)").value).toBe("70"); + + robot.failing.delete("/settings"); + await click("Save limits"); + + await waitFor(() => + expect(screen.getByLabelText("Angle slider").getAttribute("min")).toBe("70"), + ); + }); + + it("loads the selected joint's limits when switching servos", async () => { + fakeRobot(); + const { user } = renderApp("/servo"); + + await typeLimit(user, "Min (°)", "70"); + await user.selectOptions(screen.getByLabelText("Servo"), "3"); + + expect(screen.getByLabelText("Min (°)").value).toBe("35"); + expect(screen.getByLabelText("Max (°)").value).toBe("125"); + expect(screen.getByRole("button", { name: "Save limits" }).disabled).toBe( + true, + ); + }); }); diff --git a/ui/test/wizard.test.tsx b/ui/test/wizard.test.tsx index 37aa74e4..2c46e423 100644 --- a/ui/test/wizard.test.tsx +++ b/ui/test/wizard.test.tsx @@ -34,6 +34,17 @@ describe("setup wizard", () => { expect(guide?.querySelector("input, button")).toBeNull(); }); + it("lets the hand assignment be saved before attaching parts", async () => { + const robot = fakeRobot({ provisioning: true }); + const { user, click } = renderApp("/"); + + await user.selectOptions(await screen.findByLabelText("Left hand servo channel"), "3"); + await click("Save hand assignment"); + + await waitFor(() => expect(robot.lastPost("/settings")?.json).toEqual({ swap_hands: true })); + expect(robot.sent("POST", "/setup/servo")).toHaveLength(0); + }); + it("moves the selected joint in steps and stops at 180°", async () => { const { robot, click } = await startCalibration(); @@ -102,6 +113,7 @@ describe("setup wizard", () => { expect(robot.lastPost("/settings")?.json).toEqual({ servo_mins: [80, 40, 45, 35, 40], servo_maxs: [130, 130, 135, 125, 130], + servo_centers: [95, 85, 90, 80, 85], }); await waitFor(() => expect(robot.lastPost("/setup/oled")?.json).toEqual({ rotate_180: false })); }); @@ -117,6 +129,37 @@ describe("setup wizard", () => { expect(stepLabel()).toBe("Step 1 of 5 · Servos"); }); + it("saves a center for the selected joint alongside its limits", async () => { + const { robot, click } = await startCalibration(); + + await click("Body"); + await click("+5°"); + await click("Set center (85°)"); + await click("Next: Screen"); + + await waitFor(() => expect(robot.settings.servo_centers).toEqual([95, 85, 90, 80, 95])); + expect(robot.settings.servo_mins).toEqual([60, 40, 45, 35, 40]); + }); + + it("refuses a center on a safe limit", async () => { + const { click } = await startCalibration(); + + await click("Set min (60°)"); + await click("Set center (95°)"); + + expect(statusText()).toBe("Center must be between min and max."); + }); + + it("recenters within new limits when the old center is excluded", async () => { + const { robot, click } = await startCalibration(); + + await click("+10°"); + await click("Set min (60°)"); + await click("Next: Screen"); + + await waitFor(() => expect(robot.settings.servo_centers[0]).toBe(115)); + }); + it("saves the hostname, then tests the new network ahead of the saved ones", async () => { const { robot, user, click } = await startCalibration({ wifi_networks: ["old"] });