# Hexapod: project memory Hexapod walking robot plus a custom handheld remote, both Teensy-based and linked one-way over nRF24L01 (remote → robot, with hardware auto-ack only). A Unity simulation runs the *unchanged* robot firmware as a DLL. This file is the persistent context for working on any part of it. Read it fully before changing anything. ## Layout and toolchain ``` robot/ Teensy 3.6 firmware (PlatformIO env robot) remote/ Teensy 4.1 firmware (PlatformIO env remote) shared/HexapodProtocol/ wire protocol, used by robot, remote and sim sim/ robot firmware -> hexapod_sim.dll (CMake + g++), headless smoke test Hexapod_Simulation_Unity/ Unity 6000.6 project that loads the DLL (Assets/HexapodSim/) scripts/ PlatformIO helper ``` This folder is `2026-current/` inside the `hexapod-robot` repo. The older generations sit next to it (paths below are relative to this folder) and are the owner's reference material: ``` ../2019-teensy-nrf24/Hexapod_Teensy/ old robot sketch: servo passthrough only, no IK - see "Old robot sketch" below ../2019-teensy-nrf24/remote/ old remote sketch ../2019-teensy-nrf24/remote-nextion/ remote.HMI = old Nextion UI, 6 pages ../2016-ssc32-processing-port/Hexapod_NEW/ older generations with their own IK, driving an external ../2014-ik-core-task-queue/HexapodRobot/ SSC-32-style servo controller ../README.md timeline of every generation since 2009 ``` PlatformIO, one `platformio.ini`, two environments: | Env | Board | Sketch dir | Purpose | |-----|-------|------------|---------| | `robot` | `teensy36` | `robot/` | walking robot | | `remote` | `teensy41` | `remote/` | handheld transmitter | - `shared/` is PlatformIO's `lib_dir`. `shared/HexapodProtocol/` is a header-only library both sketches `#include `. - PlatformIO has a single global `src_dir` and only converts `.ino` files at its root, so `scripts/select_src_dir.py` (a `pre:` extra script) swaps `PROJECT_SRC_DIR` to each env's `custom_src_dir`. That keeps `robot/` and `remote/` plain Arduino sketch folders: one `.ino` plus paired `.h/.cpp` per concern. **Keep that structure**. No giant files, no `src/` folders. - RF24 (nrf24/RF24, resolved 1.6.x) comes from `lib_deps`. Servo, Wire, SPI and EEPROM ship with the Teensy framework. - Build: `pio run` (both), `pio run -e robot`, `pio run -e remote`. On this machine pio lives at `C:\Users\patri\.platformio\penv\Scripts\pio.exe`. Both envs build clean. Build both after touching `shared/`. ## Communication contract (`shared/HexapodProtocol/HexapodProtocol.h`) `CmdPacket`, 22 bytes (static_asserted; protocol v3), sent remote → robot at 100 Hz (robot `CONTROL_HZ` is 60): | Field | Range | Meaning | |-------|-------|---------| | `vx` | ±1000 | + forward | | `vy` | ±1000 | + strafe **left** | | `yaw_rate` | ±1000 | + turn left (CCW). Ignored by the robot in mode B | | `rx, ry, rz` | ±1000 | right stick: roll/pitch/yaw, or X/Y/Z offset when `FLAG_RSTICK_XLATE` | | `remote_roll, remote_pitch` | ±1000 | handset tilt feedforward, used only when `FLAG_REMOTE_GYRO` | | `body_height` | ±1000 | standing height setting: `STANCE_HEIGHT_MM` ± `STANCE_HEIGHT_RANGE_MM` (150 ± 50), 0 = default; the robot rate-limits it (`STANCE_HEIGHT_RATE_MM_S`) and everything built on `Kinematics::neutralStance()` follows | | `speed_scale` | 0–255 | multiplies vx/vy on the robot | | `flags` | bits | `ARMED, STAND, WALK, MODE_B, RSTICK_XLATE, GAIT_WAVE, REMOTE_GYRO, ROBOT_GYRO` (bits 0–7) | | `seq` | 0–255 | increments per packet (robot doesn't check it yet) | - **Axis convention** (ROS REP-103 style): body +X forward, +Y left, +Z up. +roll = right side down, +pitch = nose down, +yaw = CCW. - **Normalized fields.** The remote never knows robot geometry. The robot's `InputMapper` owns the scaling: `MAX_LINEAR_MM_S` 150, `MAX_TURN_RATE_DEG_S` 90, `MAX_TILT_DEG` 20, `MAX_POSE_OFFSET_MM` 40. - **Radio settings** live in the same header: address `"HXPD1"`, channel 96, 250 kbps, PA LOW, **static payload size = sizeof(CmdPacket)** on both radios. That makes version mismatches fail closed: packets are not received, the robot times out, and it disarms. - **Changing the protocol means reflashing both boards.** Keep it ≤ 32 bytes and update the `static_assert`. - **Failsafes:** the robot sets `linkOk()` false after `LINK_TIMEOUT_SEC` (0.3 s) without packets and then uses a default `CmdPacket` (flags 0 = disarmed, servos detached). The remote disarms itself after `LINK_LOST_MS` (300 ms) without acks, so the robot can't re-arm itself when the link returns. - **Division of labor:** the remote only fills fields per this contract. Mode A/B interpretation, heading-hold, pose scaling and all smoothing of pose happen on the robot. Don't duplicate `InputMapper` logic in the remote. ## Robot architecture (`robot/`, Teensy 3.6) Loop at `CONTROL_HZ` (60): comms → InputMapper → IMU → heading integrate → StateMachine → gait/sit-stand targets → BodyPose → Limits clamp → IK → joint-rate clamp → servos. | Module | Role | |--------|------| | `robot.ino` | setup/loop wiring, open-loop heading estimate, sit/stand blend, `TEST_SERIAL_INPUT` harness | | `Config.h` | **all placeholder geometry/pins/rates** | | `SmoothedValue.h` | rate-limited setpoint smoother used for every discrete command | | `Kinematics` | per-leg 3-DOF analytic IK, `neutralStance()` | | `GaitEngine` | tripod/wave patterns, per-leg phase, pattern swap only at touchdown. `limitTwist()` (called in robot.ino right after InputMapper, so the heading estimate integrates the *achieved* ω) scales the walk command until its stride envelope (`strideEnvelope()`: even/odd legs at opposite stride ends, plus a lifted set) is feasible, so full-rate turning can't make neighbouring legs collide. Stance feet are *integrated* each tick from the current twist (v + ω×r at the foot); swing feet arc from lift-off to the next leading edge. `resetFeet()` on entering WALKING restarts the cycle with grounded legs at mid-stance | | `BodyPose` | 6-axis body pose (smoothed command + IMU roll/pitch correction), applied to foot targets as the *inverse* rotation, so the body moves as commanded. `fitToReach()` scales the *whole* pose by one factor (binary search on `Limits::feetFeasible`; drops at once, recovers at 1/s) so combined poses never bend individual legs out of a rigid foot pattern or into each other. While walking it fits against the stride envelope, not the current feet, so it doesn't saw with the step cycle | | `RobotIMU` | MPU6050 complementary filter (α 0.98), gated by `FLAG_ROBOT_GYRO` | | `StateMachine` | DISARMED → SIT → STAND → WALKING, sit/stand blend at 1/s. Disarm is instant | | `InputMapper` | CmdPacket → Twist + PoseCommand + mode flags + stance height. Mode B = steering **relative to where the body faces right now**: the forward/back stick component sets walking direction (vx sign), the sideways component steers (target = current heading + atan2(vy, |vx|) every tick). So W = straight on, S = straight **back** (not turn around; owner's request), W/S+A = walk while turning left, A alone = turn on the spot while held. On release the target freezes at the current heading (hold). An earlier version froze the reference at the first push, so heading only changed after releasing W; the owner rejected that. Mode A keeps the target = current heading so switching to B never jumps. Forward speed × max(0, cos(heading error)), so big corrections turn in place first. Adds remote tilt on top of stick pose when `FLAG_REMOTE_GYRO`, clamped to `MAX_TILT_DEG` | | `Limits` | workspace clamp (70 mm horiz / 60 mm vert), joint rate clamp, ≥3-legs-in-stance check (not acted on yet). `feetFeasible()` = every foot in its workspace with valid IK **and** neighbouring legs' segments (coxa/femur/tibia, via `Kinematics::jointPositions`) ≥ `LEG_CLEARANCE_MM` apart. This is the single "physically possible" test used by gait and pose limiting | | `Comms` | nRF24 RX, link timeout, `injectPacket()` for the harness | | `ServoDriver` | 18 servos on hardware PWM (50 Hz, 16-bit), per-servo calibration from `Config.h` `SERVOS`, duty 0 = relaxed, skips invalid IK solutions | ## Remote architecture (`remote/`, Teensy 4.1) Loop: drain Nextion + USB serial input every pass. On each `TX_HZ` (100) tick: sticks → IMU → radio link status → RemoteState (smoothers, link-loss disarm) → PacketBuilder → `radio.write` → Nextion render. | Module | Role | |--------|------| | `remote.ino` | loop wiring, status print, packet dump | | `Config.h` | **all placeholder pins, calibration defaults, deadzones, rates** | | `UiTypes.h` | `UiCmd`/`UiEvent` (operator requests), `UiView` (what the display shows) | | `Sticks` | 6 analog axes: ADC averaging + IIR, piecewise calibration around center, invert, deadzone with rescale, expo → ±1000. Boot-time center capture, sweep calibration saved to EEPROM (magic/version/checksum) | | `RemoteIMU` | MPU6050, same complementary filter as the robot, axis remap for mounting, boot gyro-bias capture, "level" zero | | `RemoteState` | authoritative operator state and interlocks: arm needs centered sticks, disarm clears stand, stand needs armed, calibrate needs disarmed, link-loss auto-disarm. Smooths speed scale and fades remote tilt in/out | | `PacketBuilder` | the inverse of InputMapper: stick frame → body frame per the contract, per-submode right-stick mapping, `FLAG_WALK` with 250 ms linger (twist ignored in mode B), tilt deadzone/scale × fade, zeros motion when disarmed | | `RadioLink` | nRF24 TX, short retries (1, 3), ack-based link status and 32-packet quality % | | `NextionUI` | library-free Nextion driver on `Serial1`. HMI sends `'#' len cmd [val]` frames via `printh`. Remote pushes state by component name, as `control.` (the owner adds a `control` page with vscope-global components to the old HMI), with diffing, a 2 s full refresh, and a redraw on the Nextion 0x88 "ready" frame. Also writes the old HMI's `t1` status texts (`NEXTION_STATUS_TEXTS`). The old page-1 `b2` sends a standard 0x65 touch frame, which triggers `runUploadPassthrough()`: USB ↔ display bridge for Nextion Editor, following `Serial.baud()`, never returns. Serial key `U` does the same | | `SerialConsole` | USB keystrokes that mirror every UI command plus diagnostics | | `SmoothedValue.h` | copy of the robot's (kept separate so robot/ stays untouched) | | `NEXTION.md` | HMI build guide: component names, txt_maxl, touch event code | Remote stick-frame convention: logical X + = right, Y + = forward, twist + = CCW. The mapping into the packet is in `PacketBuilder.cpp` (e.g. `vy = -L_X`, translate mode `ry = -R_X`). ## Simulation (`sim/` + `Hexapod_Simulation_Unity/`), details in `sim/README.md` - **Principle:** the robot firmware is compiled unmodified for the PC. No `#ifdef SIM` in `robot/`, ever. Hardware is replaced beneath it by `sim/shim/` stand-ins (`Arduino.h`, `Servo.h`, `Wire.h` with a register-level MPU6050, `RF24.h` with a matching-config radio, `SPI.h`, `EEPROM.h`). - **Build:** `.\sim\build.ps1` (configure + build + smoke test; uses g++/cmake/ninja from `C:\Strawberry\c\bin` if they're not on PATH). Rebuild after any `robot/` or `shared/` change. - **The dev PC is Windows, and the owner uses Windows PowerShell 5.1.** Every documented command must work there: no `&&`, one command per line, or a `.ps1` script. - **Gotchas (already solved; don't undo them):** - `robot/` must never be on any include path, not even `-iquote`. On Windows' case-insensitive filesystem GCC's `` chain would pick up `robot/Limits.h`. So each `robot/*.cpp` is compiled through a generated wrapper in `sim/build/robot_src/`, and sim sources include `"../robot/..."` directly. - The shim's `min`/`max` return `std::common_type` by value; a `decltype` return type gave dangling references. - `robot_ino.cpp` compiles `robot.ino` and exposes read-only views of its globals (`SimRobotAccess.h`). The API is in `HexapodSimApi.h` (`HXSIM_API_VERSION`, mirrored by `HexapodSimNative.ExpectedApiVersion` in C#). The FK in `HexapodSimApi.cpp` is deliberately independent of `robot/Kinematics.cpp`. - **Joint convention the sim (and Config.h limits) assume:** coxa + = CCW from the mount angle; femur = elevation, + = up; tibia relative to the femur, + = up (knee-down stance is about −98°). Rig mapping: `hip1.y = −(coxa + mount offset)`, `Hip2.x = −femur`, `Knie.x = −(tibia + 90)`. - **Unity (`Assets/HexapodSim/`):** - `HexapodSimBootstrap` auto-adds `HexapodSimulation` in any scene and disables the old scripts (`Walk`, `RotationControl`, `Leg`, `HandleHoystick`). Those are kept for reference only. - `HexapodSimNative` loads a temp copy of the DLL via LoadLibrary/GetProcAddress, so there's no file lock and each Play is a fresh power-on. - `SimRemote` stands in for the remote (Joystick axes "1"–"6" as in the old project, Keyboard, or the robot's own serial harness) and mirrors `remote/PacketBuilder.cpp` + `RemoteState.cpp` rules. Keep them in sync. - `SimTestPanel` is the IMGUI panel (F1). - Body motion is kinematic and lives **in the DLL** (`sim/SimBody.cpp`, `hxsim_get_body`), so Unity shows exactly what `sim_smoke` checks. The ground is the support plane (a plane all feet are on or above; prefer most contacts, then continuity with the last ground), and 2D Kabsch on the feet that stayed grounded gives planar motion. The simulated MPU6050 is fed from it after every control tick. Ground tilt comes from `hxsim_set_ground_tilt`. The old C# "lowest feet + plane fit" model flipped the body as soon as it rolled (only one side counted as grounded); don't reintroduce it. - Robot forward = Unity −X, 1 Unity unit = 10 mm. - The Unity project grew out of the 2018-2023 Unity simulation, which is in `../2018-unity-sim/` with its full history. ## Testing without the other board - **Robot without remote:** `#define TEST_SERIAL_INPUT` in `robot.ino` (on by default). Serial 115200, single keys: `a` arm, `s` sit/stand, `w/x` vx ±200, `q/e` vy ±200 (left/right), `z/c` yaw_rate ±200 (left/right), `g` gait, `m` mode, space = zero velocity. Keys inject through `Comms::injectPacket()`, the same path radio packets take. Order: arm → stand → walk. The harness does not exercise the right stick or remote tilt. Add keys if needed. - **Robot firmware in simulation:** `sim/build/sim_smoke.exe` (headless, scripted checks), or Unity Play mode with Keyboard / Joystick / Robot serial input. - **Remote without robot or display:** the USB serial console (see `remote/README.md`). `p` shows raw/mapped sticks, IMU, link and state. `d` dumps packets at 10 Hz. With no robot, the link shows DOWN and arming still works; the link-loss disarm only fires after the link was up once. ## Placeholders and bench-check items (not yet verified on hardware) Robot (`robot/README.md` has detail): - `Config.h` geometry now comes from the Unity model (owner's request), 1 unit = 10 mm: - legs at X ±100 / 0, Y ±50 / ±65 mm, - mount angles ±90° (coxa straight sideways at servo center), - coxa/femur/tibia 55/100/150 mm, - stance radius 75 (so 130 mm out from the coxa pivot), height 150 mm. The old scene's `Leg.translateX` = ±5 was only the old code's rotation pivot; the rig hips are at x ±10. Still to confirm on the real chassis: joint limits, servo pins, µs/deg calibration, `MAX_JOINT_DEG_S` (in robot.ino), the Limits envelope, InputMapper maxima. - **Fixed (owner-approved):** the `Kinematics.cpp` femur elevation sign (`atan2(local_z, horiz)`; it used to solve for a foot above the hip, so every solution was invalid). Stance pose is now femur −1.2°, tibia −98.3°, feet exactly at −150 mm. `sim_smoke` guards this. - **Servos (owner-approved):** - The robot board is a **Teensy 3.6** (owner confirmed), and `platformio.ini` targets `teensy36`. - `Config.h` `SERVOS[6][3]` is a per-servo table: pin, `us_at_0deg`, signed `us_per_deg` (the sign is the direction), and `us_min`/`us_max`. It's built by `oldServo()` from the old sketch's numbers (range, trim, zero, direction, offset) so they stay readable. - `ServoDriver` drives all 18 servos with hardware PWM: 50 Hz, 16-bit `analogWrite`, and duty 0 = relaxed. The Teensy 3.6 `Servo` library stops at 12 servos, which is why the old sketch used PWMServo. - Radio: CE 39 / CSN 15, with SPI SCK moved to pin 14 (`SPI.setSCK(RADIO_SCK_PIN)` in `Comms::begin`). - The sim's shims implement `analogWrite`/`analogWriteFrequency`/ `analogWriteResolution`, and decode pulses through the same table (API version 6). - The old ±90° offsets on the rear-left/front-left coxas left those servos at the end of their travel, and the sim showed those legs clamping (8 failing checks). The owner chose to **re-horn both by 90°**, so the offsets are 0. Must be done on the robot before running this firmware. - BENCH: every direction, plus the femur's ≈6.7 µs/° (vs ≈10.4 for coxa/tibia). - IK knee direction (`tibia_rad` sign), BodyPose roll/pitch sign, IMU register map, and the sit pose assuming feet stay grounded. - **Fixed after the owner saw them in Unity (sim_smoke now guards each):** - Right-stick rotations were inverted: `BodyPose::apply` rotated the feet by the pose instead of by its inverse. This also made the robot-gyro correction positive feedback. - Heading mode was off. The body turned at only 41% of the commanded ω (the gait used the hip as the yaw lever arm instead of the foot). The heading estimate also integrated while standing. Stance feet were interpolated between endpoints recomputed each tick, so a changing command undid part of the motion. And walking started with the feet at neutral but at arbitrary phases, so grounded legs overran the workspace clamp. - Now: mode A turns at about 95% of command, and mode B turns 83° of a commanded 90°. The rest is open-loop estimate error; fusing IMU yaw is the next step. - Combined right-stick poses (e.g. full roll+pitch+yaw) used to clamp each foot on its own, distorting the planted feet by up to 29 mm. Now the whole pose is reach-scaled (`BodyPose::fitToReach`). sim_smoke checks that four full-deflection combos keep the feet rigid within 3 mm with no frozen servos. The Unity panel shows the scale ("body pose 63% (leg reach limit)"), via `HxStatus.pose_reach_scale` (API version 2). - Legs could collide (full-rate turning needs ~60 mm half-strides, and neighbouring feet are ~100 mm apart), and walking while posing made the pose scale saw with the step cycle. Both are now handled by leg clearance in `Limits::feetFeasible` plus `GaitEngine::limitTwist` and envelope-based `fitToReach`. sim_smoke checks clearance with its own FK, and checks rigidity and steadiness while walking and posing. The panel shows "walk NN%" (`HxStatus.twist_scale`, API version 3). - Walking + rotate keys still freaked out. Changing the command mid-step ran planted feet out of reach, and the pose scale *snapped* down, which the per-joint rate clamp then tore out of formation. Now: - `GaitEngine::update` moves all planted feet by one common factor that keeps them feasible (with the pose applied), and the heading integrates `appliedTwist()`. - The pose scale moves at a bounded rate both ways (`REACH_DROP_PER_SEC` 3/s, recover 1/s). - While walking, the pose fits the requested stride and the current feet (walking has priority), and the walk is limited again against the current pose (`limitTwist(..., pose, chained)`), so it slows during the handover. - sim_smoke's "toggling rotate keys" check mimics a keyboard. - Robot-gyro self-leveling was `correction = -measured tilt`, which only halves a slope because the measurement includes the correction. It's now an integrator (`ROBOT_GYRO_KI` 3/s, clamped to `ROBOT_GYRO_MAX_CORR_DEG`) on (measured tilt − operator's commanded tilt). sim_smoke: an 8° ground is levelled to < 1° without oscillation. - **Fixed (owner-approved):** the `GaitEngine::update` stance direction. Stance now runs from `neutral + half` to `neutral - half`, so the feet push opposite to the commanded motion. The harness `z`/`c` keys were corrected to `z` = `yaw_rate` + (turn left). `sim_smoke` checks that +vx goes forward and +yaw_rate turns left. - **Old robot sketch (`../2019-teensy-nrf24/Hexapod_Teensy/`)**: real hardware facts. It received 18 servo angles (0–180) over RF24Network (channel 90) from the old remote/Unity and wrote them with PWMServo. No IK, no gait. - **Board:** Teensy 3.6 (pins 36–39 and `SPI.setSCK(14)`, confirmed by the owner). - **Radio:** CE 39, CSN 15, SPI SCK 14 / MOSI 11 / MISO 12. Now in `robot/Config.h`. - **Servo pins** (servo index 0–17 = 6 × coxa, femur, tibia): `4,3,2, 7,6,5, 10,9,8, 23,22,21, 20,36,37, 17,16,38`. No 11/12 conflict with SPI. - **Index → leg** (from the old Unity `Leg.startServo`): - 0 = rear-right, 3 = mid-right, 6 = front-right (firmware legs 2, 1, 0); - 9 = rear-left, 12 = mid-left, 15 = front-left (legs 3, 4, 5). - **Pulse ranges** (PWMServo maps 0–180° linearly onto min..max): - coxa and tibia 553–2425 µs (≈10.4 µs/°); - femur 840–2050 µs (≈6.7 µs/°); - plus a per-servo µs trim `servoCallibration[]` (`0,100,0, 0,100,-30, 0,80,-50, -120,0,0, -10,20,0, 0,20,-80`). - **Angle offsets:** `servoOffset[]` is +90° on index 9 (RL coxa) and −90° on index 15 (FL coxa), so those two horns are mounted rotated. The owner will re-horn them; the new firmware uses offset 0. - **Direction mapping** (derived from the old Unity `Leg.cs`, in this repo's joint convention): - coxa servo = 90 − coxa; - femur servo = 90 + femur (right), 90 − femur (left); - tibia servo = −tibia (right), 180 + tibia (left), so 90 = knee bent 90°. - All of this is now in `Config.h` `SERVOS` (see "Servos" above). - **Older sketches (`../2016-ssc32-processing-port/Hexapod_NEW/`, `../2014-ik-core-task-queue/HexapodRobot/`, 2014–2016 era)**: an earlier electronics generation. They did IK on the board and sent `#n Pxxxx … T0` text commands over `Serial1` to an external SSC-32-style servo controller (channels 0–9 and 16–24). - **Femur range:** it was already narrower there (600–2400 µs coxa/tibia, 960–2100 µs femur over 0–180°), which corroborates the ≈6.7 µs/° femur in `SERVOS`. - **Links:** femur 10, tibia 15 (cm), with no coxa length in that IK. - **Hip layout comment in `../2016-ssc32-processing-port/Hexapod_NEW/HexapodClass.h`:** width 9.5 cm (front/rear) and 14 cm (middle), 10 cm front-to-middle-to-rear. The Unity model has 10 / 13 / 10. Whichever chassis is current, measure before trusting `LEG_MOUNT_*`. - Nothing else from them applies to the current Teensy 3.6 hardware. - `RobotIMU.cpp` reads `(Wire.read() << 8) | Wire.read()`, and C++ leaves the order of those two calls unspecified (fixed in the remote's copy). - Heading for mode B is open-loop (integrated commanded ω). Fusing IMU yaw is a planned upgrade. - `Limits::stancePolygonOk()` is computed but not acted on. Remote (`remote/README.md` has detail): - Pins in `remote/Config.h`, taken from the old remote sketch: radio CE 9 / CSN 10, sticks A0–A5, Nextion on Serial1 (0/1). The MPU6050 is on **Wire2 (25/24)**, since A4/A5 = Wire's SDA/SCL are stick pins; that's the owner's choice and needs rewiring the MPU. Which stick axis is on which pin is still unknown. - `STICK_INVERT[]`, calibration defaults, deadzones, expo. - `IMU_AXIS_FROM_CHIP` / `IMU_AXIS_SIGN` depend on how the MPU6050 is mounted in the handset. - The Nextion must have `bauds=115200` in Program.s, and the HMI must match `NEXTION.md`. The owner's old 6-page HMI is in `../2019-teensy-nrf24/remote-nextion/remote.HMI`; its component names aren't readable outside Nextion Editor. The compiled `.tft` is gitignored. - What the old remote sketch (`../2019-teensy-nrf24/remote/`, last ran on a Teensy) tells us about the real hardware: - **Nextion on `Serial1` (pins 0/1)**, driven by the ITEAD Nextion library, whose `nexInit()` runs at 9600. `remote/Config.h` now uses Serial1 and expects 115200 via `bauds=` in the HMI. - The old HMI is a *servo monitor*, not the control UI in `NEXTION.md`: - page 1: `t1` "connected"/"not connected", and `b2` = enter upload mode; - page 3: sliders `h0`–`h17` showing the 18 servo positions, plus `t1` status; - page 4: `uploadscreen`. None of the arm/stand/gait/mode/gyro/speed/height controls exist yet. The owner chose to add a `control` page per `NEXTION.md`. - `b2` → **Nextion upload passthrough**: shows `uploadscreen`, then bridges USB ↔ Serial1 forever, so Nextion Editor can upload a `.tft` through the remote's USB port. The new firmware keeps this. - Sticks on **A0–A5**, contiguous (`analogInPin + i`). On the Teensy 4.1, A4/A5 are pins 18/19 = Wire SDA/SCL, which the MPU6050 needs. The old code had the MPU code disabled (`if (false)`). Owner's decision: keep the sticks, move the MPU to Wire2. - nRF24 on CE 9 / CSN 10, the same as `remote/Config.h`. It used RF24Network on channel 90, which the new protocol doesn't. - Stick calibration was a 10 s min/max sweep saved to EEPROM, output 0–255, with no center or deadzone. - USB serial at 250000. It received 18 servo bytes + checksum + `0xFE` from the old Unity `Serial.cs` (servo passthrough, forwarded over RF to the robot) and printed `i_value,...` stick lines. - The Unity remote stand-in's joystick axis assignment (which of axes 4/6 is forward) couldn't be recovered from the old code. Check it with the panel's raw-axis readout. - Radio retry timing (`RADIO_RETRY_*`) is untested under real link loss. ## Working rules for this repo - The **robot side is owner-verified code**. Don't edit `robot/` unless something is factually wrong relative to the protocol, and ask the owner first even then. Flag suspected robot bugs instead of fixing them. To try a robot fix, copy `robot/ shared/ sim/` to a scratch dir, patch the copy and run `sim_smoke` there. - The older generations (`../20xx-*/`, `../original-frame/`) are reference material from the owner. Don't modify them. - Protocol changes go in `shared/HexapodProtocol/` only. Update the size `static_assert`, this file's contract table, and both READMEs. - Match the existing style: config constants at the top of `Config.h` with BENCH comments for anything hardware-dependent, `SmoothedValue` rate limiting on any setpoint that can jump, and paired `.h/.cpp` per concern. - Planned or possible next steps: an ack-payload telemetry channel from robot → remote (state, battery, link) to show real robot state on the Nextion, battery monitoring on both sides, a `.HMI` file checked into `remote/`.