diff --git a/README.md b/README.md index c08c04c..71ce334 100644 --- a/README.md +++ b/README.md @@ -240,6 +240,9 @@ libjxl-dev`, plus the Vimba X SDK under `/opt/VimbaX` for `-DWITH_VIMBA=ON`. | [docs/modules-reference.md](docs/modules-reference.md) | Per-file reference and data structures | | [docs/known-issues.md](docs/known-issues.md) | Status of past issues + remaining caveats | | [docs/roadmap.md](docs/roadmap.md) | Forward-looking project roadmap | +| [docs/hardware-config-plan.md](docs/hardware-config-plan.md) | Splitting `hardware.ini` out of `config.ini`, and the firmware config handshake | +| [docs/hardware-porting-plan.md](docs/hardware-porting-plan.md) | Porting the tower to the LattePanda IOTA / RP2040 | +| [docs/lattepanda-iota.md](docs/lattepanda-iota.md) | Hardware facts for the IOTA board | | [docs/test-roadmap.md](docs/test-roadmap.md) | Planned tests blocked on not-yet-integrated components | ## Repository layout diff --git a/docs/hardware-config-plan.md b/docs/hardware-config-plan.md new file mode 100644 index 0000000..caf8e18 --- /dev/null +++ b/docs/hardware-config-plan.md @@ -0,0 +1,459 @@ +# Hardware Configuration Plan + +Splitting `config.ini` by lifetime, introducing an assembly-time `hardware.ini`, and letting the firmware +ask the host for the parameters it needs instead of carrying its own copies. + +Draft for review, 2026-09-11. Covers `software/` and `firmware/`. + +**This work stands on its own.** It fixes a live bug (§5), removes a class of silent degradation (§10) +and is worth doing whether or not the LattePanda IOTA port goes ahead. It is also a **prerequisite** for +that port — see [hardware-porting-plan.md](hardware-porting-plan.md). + +## 0. The bindings this has to capture + +Read live from the tower (`ggs@10.11.12.111`), not from the docs — the board is a **LattePanda 3 Delta**, +not the Sigma `docs/deployment.md:3` claims: + +| Function | Binding | Backing hardware | +|---|---|---| +| Motor MCU | `/dev/serial/by-id/usb-Arduino_LLC_Arduino_Leonardo-if00` | USB `2341:8036`, onboard ATmega32U4 | +| IMU (Xsens MTi) | `/dev/ttyS0` | onboard RS-232 header | +| Ambient (SHT41) | `/dev/i2c-2` @ `0x44` | Synopsys DesignWare LPSS I2C | +| Camera | `DEV_1AB22C049CD1` | USB3 `1ab2:0001` Alvium 1800 U-2040c | +| Modem | `/dev/ttyUSB0-3` | USB `2c7c:0801` Quectel RM520N-GL, M.2 B-key | + +Multiple rigs are in production, **mostly without an IMU**, so every change here has to be invisible on +rollout across a live mixed fleet. + +--- + + +## 1. Why split at all + +The organising principle is **who writes this, and how often**: + +| | Author | Changes | Goes to | +|---|---|---|---| +| **Assembly** | a human, once | only if someone physically changes the machine | `hardware.ini` | +| **Operation** | a human, continuously | whenever you tune | `config.ini` | +| **Calibration** | the program, on operator confirmation | from time to time — it assigns real-world orientation and drifts out of true | `config.ini` | + +Three things it buys: + +1. **Building a new tower is writing one file.** +2. **It survives deploys structurally.** `deploy.sh:234` keeps `config.ini` out of rsync by an exclude + rule someone has to maintain; `deploy.sh:227` records that `--delete` already destroyed a freshly + exported camera XML once. A file outside the deploy tree cannot be lost that way. +3. **It gives every fact exactly one home** — which is A.5 and A.6, and the largest part of this work. + +## 2. Two files + +``` +/etc/fire_gimbal_control/hardware.ini # assembly — human, frozen, never deployed +/config.ini # operation + calibration +``` + +Calibration stays in `config.ini`: it changes over time, so it is not assembly data. `--push-config` +stays as it is — a dev convenience for the tower under development. + +## 3. What goes in `hardware.ini` + +```ini +[Board] +model = lattepanda-3-delta ; or: lattepanda-iota +firmware_env = leonardo ; or: iota_rp2040 + +[Hardware] +axes = yaw, pitch ; or: yaw +imu = none ; or: mti — most of the fleet is 'none' +env = sht41 ; or: none +cameras = rgb + +[Serial] +device = /dev/serial/by-id/usb-Arduino_LLC_Arduino_Leonardo-if00 +[IMU] +device = /dev/ttyS0 +[Env] +i2c_device = /dev/i2c-2 +i2c_addr = 0x44 +[Camera.rgb] +id = DEV_1AB22C049CD1 + +; ── Drivetrain. A different motor or gear ratio changes all of these. ── +[Motor.yaw] +steps_per_rev = 177000 ; full mechanical travel reference +global_scaler = 80 ; TMC GLOBALSCALER — motor current +enc_deviation = 1500 ; yaw homing stall threshold (see A.5) +sw_mode = 0x08A0 ; hardware stop switches off (no physical endstops) +normal_speed = 30000 +homing_speed = 12000 +center_speed = 25000 +soft_margin = 5000 + +[Motor.pitch] +steps_per_rev = 500000 +global_scaler = 50 +sw_mode = 0x08A3 ; endstops enabled +normal_speed = 60000 +homing_speed = 150000 +center_speed = 60000 +soft_margin = 5000 +``` + +`tower_name` belongs here too — assigned once, at assembly. + +## 4. How the files find each other + +**Recommendation: a fixed location, not a pointer in `config.ini`.** + +``` +--hardware # explicit, for dev and testing +$FGC_HARDWARE # service environment +/etc/fire_gimbal_control/hardware.ini # the normal answer on a tower +/hardware.ini # fallback, so a dev checkout just works +``` + +with the resolved path logged at startup. **`/etc/fire_gimbal_control/` is already your convention** — +`deployment.md §4` puts `credentials.env` there. + +## 5. The duplication, and the bug it is already causing + +> ### ⚠️ The host is silently undoing the firmware's speed reduction +> `Application.cpp:1040–1041`, at the end of every `--init`, unconditionally sends: +> ``` +> SPEED Y 50000 +> SPEED P 150000 +> ``` +> Hardcoded in host source. Meanwhile `firmware/src/config.h` sets `normal_speed` to **30000** for yaw — +> commented *"gentler top speed (was 50000)"* — and **60000** for pitch — *"(was 150000)"*. +> +> **The host sends exactly the old values the firmware deliberately moved away from.** The reduction has +> been clobbered on every init since it was made. Live on the fleet today. + +Today the same fact exists in up to three places: `hardware.ini` (proposed), the firmware's compile-time +`MOTOR_CONFIG` table, and host source. The fix is not to synchronise them but to **delete two of them**. + +### The gear ratio reaches further than speeds + +Three more values change with the new yaw drivetrain, and none is a speed: + +- **`enc_deviation` is the one that bears on damage.** Yaw homing detects the endstop when + `|XACTUAL − XENC|` exceeds it. `firmware/docs/hardware-notes.md` records the free-running gap as ~500 + against a limit of 1500, and that *"lower = snappier (less grind)"*. **A different gear ratio changes + that free-running gap**, so it must be re-measured on the new tower — too low and homing false-triggers + mid-travel, too high and it grinds longer before noticing. Tuning homing speed alone solves half of it. +- **`steps_per_rev`** is what `HOMING_MIN_TRAVEL_SLACK` validates each home against. +- **`yaw_counts_per_deg` must be recalibrated.** The 983.33 default is derived from + `steps_per_rev = 177000`; on a different ratio it is wrong, so `gimbal heading setup` is mandatory. + +## 6. The config handshake — the firmware asks + +**The firmware should declare what it needs and refuse to move until it has it.** That removes the +firmware's parameter table as a competing definition, rather than trying to keep it in sync. + +### Why this is a small change, not a new architecture + +Both halves already exist: + +- **The firmware already treats some parameters as host-owned.** `motor.h:70` reads *"host-owned homing + parameters (config.ini, sent as CENTER / MARGIN)"*. +- **The host already verifies the firmware adopted them.** `Application.cpp:1099` errors with *"the + firmware did not adopt CENTER (is it older than this host?) … Refusing to derive travel limits; + headings would be wrong"*, and `:1108` warns *"firmware reported no soft_margin (update firmware)"*. + +So the pattern, the direction of ownership, and even the version-skew handling are established. What is +missing is that the firmware **guesses** when the host says nothing, instead of asking. + +### The sequence + +1. **Boot: pins and SPI up, then stop the motors.** Unconditional, no config needed — see the safety + note below. +2. **State `NEEDCFG`.** Motors stay disabled; `HOME` / `MOVE` / `ENABLE` are rejected with + `ERR need config`. Reported in `ST` and `DUMP`, so the dashboard shows *why* nothing moves. +3. **On host connection, ask.** `main.cpp` already reprints the boot banner whenever the host opens the + port — the natural hook. Emit alongside it: + ``` + BOOT build=abc123 + NEED Y steps_per_rev global_scaler sw_mode enc_deviation normal_speed homing_speed center_speed center margin + NEED P ... + ``` +4. **Host answers from `hardware.ini`** with a generic `SET `. `SPEED`, `CENTER` and + `MARGIN` stay as aliases so old hosts and hand-typed sessions keep working. +5. **When an axis's required set is complete**, the firmware applies the parameterised init block and + moves that axis to `ST_RESET`. `DUMP` reports every value it is holding, so the existing adoption + check generalises from "did CENTER land?" to "does the firmware hold what `hardware.ini` says?" — + which is `test hardware motor` in A.9. + +### ⚠️ The safety constraint this must not break + +`applyInitBlock` ends with, and `hardware-notes.md` explains why: + +```cpp +tmc_.write(TMC_VMAX, 0); +tmc_.write(TMC_RAMPMODE, RAMP_VELOCITY_POS); // stop residual motion +tmc_.write(TMC_GSTAT, 0x07); // clear power-up latches +``` + +*"The TMC has its own supply and keeps running across an MCU-only reset (e.g. a re-flash), so it may +still be executing a velocity command from the previous session."* If the firmware waited for host +config before touching the driver, **a re-flash could leave the motor grinding until the host connects.** + +So split the init block in two: + +| Phase | Contents | When | +|---|---|---| +| **Safety init** | `VMAX=0` + velocity mode, `GSTAT` clear, and the constant register writes | at boot, unconditionally | +| **Parameterised init** | `GLOBALSCALER`, `XACTUAL = center`, `SW_MODE`, `ENC_DEVIATION` | once `NEEDCFG` is satisfied | + +Those four are exactly the writes in `applyInitBlock` that read from `cfg_` or a tunable constant; the +rest are already fixed values. + +### Standalone / bench operation + +`hardware-notes.md`'s bring-up checklist drives the firmware by hand over `deploy.sh --monitor`, so a +firmware that refuses to move without a host would break it. Add an explicit +**`USE DEFAULTS`** command that loads a built-in **limp-home** set and logs loudly. + +The distinction matters: limp-home values are **deliberately conservative — slow speeds, generous +margins — and never intended to match any real tower.** They are not a second definition of the +drivetrain, which is what today's `normal_speed = 30000` is. Nothing reaches production on them, and +`test hardware motor` fails if a tower is running them. + +**Prefer this to a timeout fallback.** A firmware that waits *n* seconds and then adopts defaults means a +slow host silently produces a tower running on the wrong values — precisely the silent-degradation class +this whole design is removing. Make it an explicit act. + +### What remains compile-time + +Only **pin assignments** and the **axis count**: pins because SPI must be up before anything can be +asked or answered, the axis count because it sizes the axis array. Both are still defined once in +`hardware.ini` and *generated* into `firmware/src/config.h` at build — one definition, realised at build +time instead of over the wire. Everything else becomes runtime, which shrinks the build-time set far +below what I proposed last turn. + +### Rollout order matters + +A new firmware sitting in `NEEDCFG` in front of an old host would never be configured. So: **host first, +then firmware.** The host should treat a firmware that never sends `NEED` as an old one and fall back to +today's push-and-verify path, logging that it did. + +## 7. Build identity in the TUI + +No version negotiation — firmware and software are deployed in tandem. This section is display only. + +### What already exists + +Almost all of it. Both halves stop one step short. + +**Firmware build id — travels end to end and is already on screen:** + +- `firmware/platformio.ini` injects `GIT_REV` at build time, *"so the flashed firmware carries a build + identity"*, from `git describe --always --dirty`. +- The firmware reports it in `BOOT build=` (`main.cpp:39`) and `DUMP BEGIN build=` (`:103`). +- The host parses it into `DumpData.build` (`DumpParser.h:46`) and **already renders it in the gimbal + pane** — `TuiUi.cpp:999` draws `build up reset:` on that overlay's header row. + +Nothing to do here. It works today. + +**Software build id — the slot exists and is filled with a placeholder:** + +- `HeaderView` has a `build` field (`UiSnapshot.h:185`), and `TuiUi.cpp:1324` already renders + `text("build " + s.header.build) | dim` in the main header, right after the tower name — exactly where + you suggested putting it. +- `deploy.sh:212` computes `GIT_REV="$(git describe --always --dirty)"` and `:288` passes it into the + remote CMake invocation. +- But **`CMakeLists.txt` never reads `$ENV{GIT_REV}`**, so the value is passed and dropped, and + `Application.cpp:610` hardcodes `s.header.build = "dev"`. + +Two edits close it: + +1. `CMakeLists.txt` reads `$ENV{GIT_REV}` into a compile definition, defaulting to `"unknown"` — exactly + as `firmware/src/main.cpp:14` already does. +2. `Application.cpp:610` uses it instead of the `"dev"` literal. + +``` + FIREWATCH TOWER NorthRidge build 1a572cb LIVE up 412s +``` + +### One log line at startup + +So an unattended tower records what it is running, where `journalctl` will find it. There is already a +house pattern — `Application.cpp:526` logs the IMU's identity the same way: + +``` +INFO Firmware: build a1b2c3d (from the BOOT banner, once, at connection) +``` + +Identity only. Nothing compares it to anything. + +> **Two small notes.** The repo has **no tags**, so `git describe --always --dirty` currently yields a +> bare hash (`1a572cb-dirty`) — an honest build identity, but not a version. Tagging releases would make +> the same line read `v1.4.0-2-g1a572cb` at no extra cost, if you ever want it. And `$ENV{GIT_REV}` is +> read at CMake **configure** time, so a plain `cmake --build` after edits keeps the configure-time +> value; `deploy.sh:288` reconfigures on every deploy, so the deployed path stays fresh — and this +> matches how the firmware already behaves. + +### Why no compatibility check + +A maintained protocol number is one more thing to remember to bump, and a **forgotten bump is worse than +no number** — it asserts a compatibility that was never verified. The cases it would have caught are +already covered, behaviourally: + +- **The handshake describes its own generation.** A firmware that never sends `NEED` (§6) is an old + one; the host falls back to today's push-and-verify path on that evidence alone. No number needed. +- **`test hardware motor`** (§11) asks the firmware to `DUMP` and confirms it is actually holding the + `hardware.ini` values — what the firmware *did*, not what it *claimed*. +- **`Application.cpp:1101` and `:1108`** already detect the two concrete incompatibilities anyone has hit + so far, and keep doing so unchanged. + +## 8. Generated at install, not hand-written from scratch + +``` +hardware/ + lattepanda-3-delta/ + packages.txt # apt packages + preconditions/ # udev rules, sysctl.d, kernel-cmdline snippet + install.sh # apply preconditions; PROBE the device and emit hardware.ini + NOTES.md # quirks, verified-on date + lattepanda-iota/ + ... +``` + +`install.sh` fills in what it can detect — board model from DMI, the serial `by-id` path, the working +I2C bus (probed with a real SHT41 measurement, since `i2cdetect` false-negatives on the SHT4x), the +camera's `DEV_` id — and leaves a human to enter `axes`, `imu`, `env` and the drivetrain block, which no +probe can distinguish from a *fault*. Which is the whole of §10. + +## 9. Calibration write-back + +### Already implemented — the confirmation prompt + +Nothing is written without a yes: + +- `Application.cpp:944` raises the activity-strip prompt *"Save the new [Motor] calibration to config as + the default? (y / n)"* whenever `calib_save_pending_` is set. +- `Application.cpp:1260` / `:1726` set it after `gimbal heading setup` / `heading fit` / `pitch setup`. +- `Application.cpp:1847` implements `save` and `discard` as **commands**, so the answer works over the + TUI keypress (`TuiUi.cpp:1599`), stdin, or MQTT. + +Worth checking on the rig: the headless/systemd path should log the values and expire, not hang. + +### To add — keep the previous value as a comment + +`updateIniSectionKeys` replaces a matching line **in place**, so the old value is lost. Keep exactly one +generation: + +```ini +[Motor] +;prev yaw_heading_at_center = 0.0 ; 2026-09-11T14:32Z, gimbal heading setup +yaw_heading_at_center = 127.4 +``` + +Two rules keep it tidy: **only record a key whose value actually changed** (`saveMotorGeometry` rewrites +all eight every save, so without this the file gains eight lines of noise per calibration), and +**replace, don't accumulate** — an existing `;prev` for that key is overwritten, so the file grows once +and then holds steady. Reverting is uncommenting; `;prev` lines are comments and inih ignores them. + +This lands in `updateIniSectionKeys` + `saveMotorGeometry`, and `saveCalibration()` is the only caller. + +## 10. Declaring presence + +The existing code degrades gracefully everywhere — `TestRunner` skips the pitch and IMU leaves, CamEvent +falls back to encoder pitch, `HeadingFit` is IMU-free by design, `CaptureScheduler` sends the single-axis +`MOVE Y …` form. It adapts to *what it finds* and never states *what it should have found*, so **a +failure is indistinguishable from a configuration**: a 2-axis tower whose pitch stops enumerating becomes +a yaw-only tower, captures one elevation band, and reports itself healthy. + +| Declared | Found | Result | +|---|---|---| +| present | present | OK | +| **present** | **absent** | **refuse to start**, naming the subsystem; `--degraded` overrides, loudly | +| absent | present | warn — the file is stale | + +## 11. `test hardware` + +| Leaf | Checks | +|---|---| +| `test hardware axes` | every axis in `axes` appears in telemetry, and no others | +| `test hardware motor` | `DUMP` reports the drivetrain values `hardware.ini` specifies — catches a stale flash, an unanswered `NEED`, or a tower left on limp-home defaults | +| `test hardware imu` | declared → device opens, `0xFA`-framed stream arrives | +| `test hardware env` | declared → a **real I2C** adapter (`i2cdetect -F` says `I2C = yes`, not SMBus-only) and a CRC-valid SHT41 measurement — known-issues trap #2, documented but never automated | +| `test hardware camera` | every camera in `cameras` is found; `usbfs_memory_mb ≥ 1000`; USB3 LPM off on the `1ab2` port | + +## 12. What stops being declared twice + +| Today | After | +|---|---| +| **Production speeds hardcoded in `Application.cpp:1040` contradict `firmware/src/config.h`** — and win | one value in `hardware.ini`, requested by the firmware | +| Firmware `MOTOR_CONFIG` duplicates every drivetrain value the host also knows | firmware holds none of them until told | +| Firmware `NUM_AXES` hardcoded; host infers axes from telemetry | generated from `[Hardware] axes` | +| `[Features] enable_imu` / `enable_env` | derived from `imu` / `env`; the flags retire | + +`mock_camera` / `mock_serial` / `mock_imu` / `mock_env` stay in `config.ini` — dev-box substitution is +operational, and it is what lets all of this be exercised with no hardware. + +## 13. Migration + +With no `hardware.ini`, synthesise one in memory from the current `config.ini` plus today's firmware +defaults, and log the file it *would* have written. Ship `install.sh --extract` to split an existing +device config. Reconciliation starts **warn-only**. + +Deploy host-before-firmware (A.6). Fix the `Application.cpp:1040` literals in the same change — on the +existing fleet that drops yaw 50000 → 30000, a real behaviour change that wants a deliberate rig check +rather than a silent correction. + +## 14. Firmware changes, collected + +- **Per board** → `platform_leonardo.h` / `platform_iota.h`: pin map plus the three AVR-isms + (`MCUSR`, `__heap_start`/`__brkval`) behind `platformResetCause()` / `platformFreeRam()`. +- **Generated** → pins and `NUM_AXES` from `hardware.ini`. +- **Deleted** → the drivetrain fields of `MOTOR_CONFIG`; `config.h` keeps only mechanism-wide thresholds. +- **New** → `NEEDCFG` state, `NEED` request line, generic `SET `, `USE DEFAULTS`, + split init block, drivetrain values reported in `DUMP`. The existing `GIT_REV` in `BOOT`/`DUMP` is + unchanged — no version numbers are added (§7). + +--- + +# Sequencing + +| Phase | Work | Gate | +|---|---|---| +| **1** | **Host**: split `hardware.ini` out of `config.ini` (§3), resolution chain (§4), presence reconciliation (§10), `test hardware` (§11), calibration `;prev` comments (§9), build identity (§7), `install.sh --extract` (§8). **Delete the `Application.cpp:1040` speed literals** (§5). Roll out across the live fleet, warn-only | `test hardware` green on every tower; header shows a real build id; an unplugged pitch axis *fails* instead of degrading silently | +| **2** | **Firmware**: `NEEDCFG` + `NEED` / `SET` / `USE DEFAULTS`, split init block, drivetrain values out of `MOTOR_CONFIG` and into `DUMP` (§6). **Host first, then firmware** | A firmware with no host sits in `NEEDCFG` and moves nothing; with the host it reaches `READY` holding exactly the `hardware.ini` values; an old firmware that never sends `NEED` is detected as such and falls back | + +Phase 1 is deployable on its own. Phase 2 requires it. + +## Progress + +Branch `worktree-hardware-config` (worktree at `.claude/worktrees/hardware-config`). +216 tests pass, up from a 204 baseline. + +| § | Item | State | +|----|------|-------| +| 7 | Build identity — CMake reads `$ENV{GIT_REV}`; `Application.cpp` stops hardcoding `"dev"` | **done**, verified embedded in the binary | +| 9 | Calibration `;prev` comments, one generation, changed keys only, stamped with the command | **done**, 7 tests | +| 3 | `hardware.ini`: `[Board]` / `[Hardware]` typed, layered over `config.ini`, shadowed keys warned | **done**, 5 tests | +| 4 | Resolution chain `--hardware` / `$FGC_HARDWARE` / `/etc/...` / beside `config.ini` | **done**, 2 tests | +| 8 | `config/hardware.example.ini` documenting the shape | **done** (the `hardware//` setup kit is not started) | +| 10 | Presence reconciliation (declared vs found) | **not started** — `hasAxis()` is in place and returns true for an undeclared rig, so callers can adopt it before reconciliation exists | +| 11 | `test hardware` | **not started** | +| 5 | Delete the `Application.cpp:1040` speed literals | **not started** — blocked on decision 2 | + +Nothing yet changes behaviour on a tower without a `hardware.ini`: absent is silent, and +`hardware.declared` stays false. Verified by running the binary both ways. + +--- + +# Decisions needed before work starts + +1. **How do the files find each other?** Recommendation: a fixed `/etc/fire_gimbal_control/hardware.ini`, + matching the existing `credentials.env` convention, rather than a pointer in `config.ini` (§4). +2. **The `SPEED Y 50000` / `SPEED P 150000` literals in `Application.cpp:1040` contradict the firmware's + tuned-down defaults and win.** Is the intended yaw production speed 30000 or 50000? Live on the fleet + now, and worth answering before the refactor (§5). +3. **`NEEDCFG` — refuse to move, or fall back after a timeout?** Recommendation: refuse, with an explicit + `USE DEFAULTS` for bench work. A timeout lets a slow host produce a silently mis-tuned tower (§6). +4. **How strict is reconciliation, and when?** Recommendation: warn-only through the fleet rollout, then + fatal, with `--degraded` as the field-triage escape (§10). +5. **Do any live rigs differ in axis count**, or is the variation only IMU/env? If every tower is 2-axis + today, `axes` guards against future drift rather than fixing something current, and phase 1 shrinks. diff --git a/docs/hardware-porting-plan.md b/docs/hardware-porting-plan.md new file mode 100644 index 0000000..23aec66 --- /dev/null +++ b/docs/hardware-porting-plan.md @@ -0,0 +1,334 @@ +# LattePanda IOTA Porting Plan + +Draft for review, 2026-09-11. Covers both repos (`software/`, `firmware/`) plus the harness. +Hardware facts for the new board are in [`docs/lattepanda-iota.md`](lattepanda-iota.md). + +> **Companion document:** [hardware-config-plan.md](hardware-config-plan.md) covers splitting `config.ini`, +> the `hardware.ini` assembly profile and the firmware config handshake. That work stands on its own and +> is a **prerequisite** for this port. + +--- + +## 0. Baseline — what is actually deployed today + +Read live from the tower (`ggs@10.11.12.111`) rather than from the docs: + +| | Value | +|---|---| +| Board | **LattePanda 3 Delta** (DMI `product_name`) — *not* the Sigma | +| CPU | Intel Celeron N5105, 4C/4T | +| OS | Ubuntu 22.04.5 LTS, kernel 6.8.0-138 | +| RAM / disk | 8 GB / 57 GB eMMC, **67 % full** | + +Device bindings in the live `config.ini`: + +| Function | Binding | Backing hardware | +|---|---|---| +| Motor MCU | `/dev/serial/by-id/usb-Arduino_LLC_Arduino_Leonardo-if00` | USB `2341:8036`, onboard ATmega32U4 | +| IMU (Xsens MTi) | `/dev/ttyS0` | onboard RS-232 header | +| Ambient (SHT41) | `/dev/i2c-2` @ `0x44` | Synopsys DesignWare LPSS I2C | +| Camera | `DEV_1AB22C049CD1` | USB3 `1ab2:0001` Alvium 1800 U-2040c | +| Modem | `/dev/ttyUSB0-3` | USB `2c7c:0801` Quectel RM520N-GL, **M.2 B-key** | +| Extra NIC | — | USB `0b95:1790` ASIX AX88179 GbE | + +> **Doc bug:** `docs/deployment.md:3` says "LattePanda Sigma". It is a 3 Delta. Fix as part of this work. + +--- + +# The port + +> **What the configuration work changes for this port:** the IOTA is one new setup kit at `hardware/lattepanda-iota/` +> ([config plan §8](hardware-config-plan.md)) whose `install.sh` emits a `hardware.ini` with `[Board] model = lattepanda-iota` and the new +> device paths. +> +> **This is not only a board swap.** The new tower also has a **different yaw motor and gear ratio**, so +> its `[Motor.yaw]` block differs too: `steps_per_rev`, `global_scaler`, all three speeds, and +> `enc_deviation` ([config plan §5](hardware-config-plan.md)). Two consequences worth planning for: +> - **Calibration is mandatory, not inherited.** `yaw_counts_per_deg` is derived from `steps_per_rev`, +> so `gimbal heading setup` must be run on this tower before its headings mean anything. +> - **Yaw homing must be re-tuned before it is trusted.** It finds the endstop by driving into it, so +> both `homing_speed` and `enc_deviation_limit` want measuring on the real drivetrain — the free-running +> encoder gap that the limit is set against is a property of the gear ratio. + +## 1. Verdict up front + +The IOTA is a **performance and lifecycle upgrade** (N150 Gracemont vs N5105 Tremont, LPDDR5 with ECC, +3× USB 3.2 Gen 2, TPM, RTC), and the 10–15 V DC input matches the tower's 12 V rail. The compute story +is good. + +The cost is concentrated in three places, in increasing order of pain: + +1. **Firmware port** — real but small and well-bounded. +2. **Logic-level change, 5 V → 3.3 V** — turns out to be nearly free (see B.3). +3. **Three orphaned peripherals** — the IMU, the ambient sensor, and the 5G modem all lose the port they + currently use. The modem is the one with no clean answer. + +## 2. Firmware port — ATmega32U4 → RP2040 + +**Pin budget is a non-issue.** The firmware needs 7 GPIO: 4 SPI + 2 chip-selects + 2 enables (the pitch +endstops go to the TMC's own `REFL`/`REFR`, not to the MCU). The IOTA breaks out 14 clean RP2040 pins. + +**AS BUILT** on tower `10.11.12.121` (wired 2026-09-15). One TMC5160-BOB, yaw +only — the proposed map below it was never wired, and `src/platform_iota.h` +carries these numbers: + +| Signal | RP2040 | TMC5160-BOB | Was (Leonardo) | +|---|---|---|---| +| Logic supply 3.3 V | 3.3V | pin 1 `VCC_IO` | 5 V | +| Signal ground | GND | pin 2 `GND` | GND | +| SPI CSN (yaw) | **GP1** | pin 3 `CSN` | D8 | +| SPI SCK | **GP2** | pin 4 `SCK` | ICSP | +| SPI MOSI | **GP3** | pin 5 `SDI` | ICSP | +| SPI MISO | **GP0** | pin 6 `SDO` | ICSP | +| Yaw DRV_ENN | **GP4** | pin 9 `DRV_ENN` | D7 | + +These are a legal SPI0 group, which is not automatic: SPI0 takes SCK only from +{2,6,18,22}, TX from {3,7,19,23} and RX from {0,4,16,20}. GP1 is SPI0's hardware +CSn (the driver toggles it in software regardless) and GP4 is a plain output. + +
The originally proposed map, kept for the record + +| Signal | RP2040 | Header pin | Was (Leonardo) | +|---|---|---|---| +| SPI SCK | GP2 | 9 | ICSP | +| SPI MOSI | GP3 | 10 | ICSP | +| SPI MISO | GP4 | 11 | ICSP | +| Yaw CSN | GP5 | 12 | D8 | +| Yaw DRV_ENN | GP6 | 13 | D7 | +| Pitch CSN | GP7 | 25 | D1 | +| Pitch DRV_ENN | GP8 | 26 | D0 | +| *(spare, I2C0 for a rehomed SHT41)* | GP12 / GP13 | 30 / 31 | — | + +
+ +**Toolchain:** +```ini +[env:iota_rp2040] +platform = https://github.com/maxgerhardt/platform-raspberrypi.git +board = pico +framework = arduino +board_build.core = earlephilhower +monitor_speed = 115200 +``` +Note the IOTA's RP2040 has **8 MB flash** (Pico has 2 MB) — use a board definition with the right flash +size or override it, or the filesystem/OTA layout will be wrong. + +**Code changes:** small and enumerable. +- `tmc5160.cpp`: call `SPI.setSCK/setTX/setRX(...)` *before* `SPI.begin()`. `SPISettings`, + `beginTransaction`, `transfer` are API-identical — the TMC driver logic is untouched. +- `main.cpp`: `MCUSR` → `rp2040.getResetReason()`; `freeRam()` → `rp2040.getFreeHeap()`. +- `F()` is a no-op on RP2040 — harmless, leave it. +- `Serial` is USB CDC on both — the host protocol and `command.cpp` are unchanged. + +> ### ✅ Remote flashing — MEASURED on the board, 2026-09-15. Neither documented path works yet +> +> Tested end to end on tower `10.11.12.121`. Results first, the original research below: +> +> | Path | Board running MicroPython (factory) | Board running our firmware | +> |---|---|---| +> | **1200-baud touch** (`pio run --target upload`) | no reset — CDC does not implement it | **resets, but reboots into the APPLICATION, not BOOTSEL** — picotool waits and times out | +> | **`picotool reboot -f -u`** | refused — no picotool reset interface | refused — `Unable to locate reset interface on the device` | +> | **`BOOTSEL` serial command** (added 2026-09-15) | n/a | **works** — this is the answer | +> | **`machine.bootloader()` over the MicroPython REPL** | **works** — re-enumerates as `2e8a:0003 RP2 Boot` | n/a, MicroPython is gone once flashed | +> +> So the **first** flash of a factory board needs the REPL trick or a physical +> BOOTSEL press — one time per board, and worth knowing before someone drives to +> a mast. The **update** case is the one that still has no answer: our firmware +> honours the 1200-baud reset but returns to the application, so a tower cannot +> currently be re-flashed over the network at all. +> +> **Fixed by a `BOOTSEL` serial command** calling arduino-pico's +> `rp2040.rebootToBootloader()` — implemented and proven on tower 121 the same +> day. The full loop now runs with nobody at the board: +> +> ``` +> firmware running --BOOTSEL over serial--> 2e8a:0003 RP2 Boot +> --deploy.sh over ssh---> firmware running +> ``` +> +> It disables every axis first, and near misses (`BOOT`, `BOOTSE`) are rejected +> as unknown commands rather than acted on. The remaining exposure is accepted +> deliberately: anything that can write to the port — including the host's +> `gimbal raw` passthrough — can take the gimbal out of service until it is +> re-flashed. That is judged the lesser risk against a guaranteed site visit for +> every firmware update, because entering the bootrom erases nothing and the +> board is recoverable remotely. +> +> **One physical BOOTSEL press is still needed once per board**, to install the +> first image that carries the command. +> +> One precondition was also missing entirely and is now in the setup kit: a udev +> rule for `2e8a`, without which picotool finds the board and refuses it with +> *"unable to connect. Maybe try 'sudo'"*. +> +>
Original research, 2026-09-11 +> +> ### ⚠️ Remote flashing — researched 2026-09-11; workable, still to be proven on the board +> The Leonardo is flashed **over USB from the tower host** (`firmware/deploy.sh`) via the 1200-baud-touch +> reset. The RP2040's documented flow is **hold BOOTSEL, tap RST, drag a `.uf2`** — a person standing at +> the board. On a remote fire tower that is a serious regression. +> +> **Two independent paths exist, and the first is the same mechanism the Leonardo already uses:** +> +> 1. **1200-baud touch** — the Arduino standard (1200 bps on the CDC port = reset to bootloader). +> Arduino-Pico's upload tool issues the kick, waits for the board to re-enumerate as the `RPI-RP2` +> mass-storage device, and copies the `.uf2` across. Requires the running firmware to present a USB +> CDC port — which ours does, since that *is* the host link. +> 2. **`picotool reboot -f -u`** — forces a running board into BOOTSEL over USB. Also requires USB-CDC +> enabled in the running image. +> +> **What still has to be checked on the real board**, because the docs do not settle it: +> - Arduino-Pico's PlatformIO integration documents neither its default upload protocol nor whether it +> performs the 1200-baud touch automatically, so `firmware/deploy.sh` may need an explicit +> `upload_protocol`, or a `picotool reboot -f -u` step before `pio run --target upload`. +> - There are user reports of *"No accessible RP2040 devices in BOOTSEL mode were found"*, so the +> auto-reset is not bulletproof out of the box. +> +> **The failure modes are asymmetric, and that shapes the firmware:** +> - An **interrupted or failed flash self-recovers** — the RP2040 bootrom falls back to USB mass storage +> when flash holds no valid image, so the board returns as `RPI-RP2` by itself. +> - A **valid but broken image that never brings up USB CDC is unrecoverable remotely.** Neither path can +> reach it; only the physical `BOOTSEL` button can. That is the one that costs a site visit. +> +> Hence the design rule for the port: **bring `Serial` (USB CDC) up as early and unconditionally as +> possible** — before any hardware init that could hang, in particular before the SPI/TMC bring-up. A +> watchdog that resets the MCU if the main loop stalls earns its place for the same reason. +> +>
+ +## 3. Electrical — the 5 V → 3.3 V move is nearly free + +The RP2040 is **not 5 V tolerant**, which sounds expensive. It isn't here, because the MCU's only +external connection is SPI + enable into the TMC5160-BOB, and: + +- **`TMC5160-BOB` pin 1 `VCC_IO` accepts 3.3 V or 5 V** (`COMPONENTS.md` §3). Move it from the 5 V rail + to the IOTA's 3.3 V (header pin 17, 1.2 A budget). Every BOB logic pin then references 3.3 V. +- **`AS5047P` has a documented 3.3 V mode** (tie `VDD`/`VDD3V3`, supply 3.0–3.6 V — `COMPONENTS.md` § + encoder). It feeds the TMC's `ENCA/ENCB/ENCN`, not the MCU, so it only needs to match `VCC_IO`. +- **Pitch endstops** go to `REFL`/`REFR` on the BOB and follow `VCC_IO` automatically. + +So: **move the whole logic domain to 3.3 V; no level shifters anywhere.** Motor supply `VS` (9–36 V) is +untouched. Budget the 3.3 V rail: 2 × BOB `VCC_IO` + 2 × AS5047P is tens of mA against 1.2 A — ample. + +## 4. The three orphaned peripherals + +### (a) IMU — Xsens MTi, today on `/dev/ttyS0` +The IOTA header does expose a PCH serial on pins 3/4, documented as **COM1 / `ttyS0`, 3.3 V**. If that +enumerates as `ttyS0` the config value doesn't even change. + +| Option | Notes | +|---|---| +| **Header COM1 + MAX3232 transceiver** *(preferred)* | Keeps `[IMU] device = /dev/ttyS0`, costs no USB port. Needed **if** the MTi is true RS-232 levels; not needed if it is 3.3 V TTL | +| USB↔RS-232 adapter | Zero doubt, works today, but burns one of three USB ports and changes the device path to a `by-id` node | + +**Action:** confirm the MTi model's signalling levels, and confirm the header enumerates as `ttyS0` (BIOS may gate it). + +### (b) Ambient sensor — SHT41, today on `/dev/i2c-2` +**The published IOTA pinout exposes no PCH-side I2C** — the header's only PCH serial is COM1; all I2C is +RP2040-side. **[VERIFY against the schematic]**, because this determines the work. + +| Option | Cost | +|---|---| +| **USB-I2C bridge (MCP2221A)** *(preferred for first deployment)* | Mainline `hid-mcp2221` registers a real `/dev/i2c-*` adapter. **Zero code change** — just repoint `[Env] i2c_device`. Costs a USB port | +| Rehome to the RP2040 (I2C0 on GP12/GP13) | Most integrated, frees the USB port, but needs firmware + a serial-protocol field + a new `IEnvSensor` implementation. Good follow-up, wrong thing to do during a board bring-up | + +Either way, `test hardware env` ([config plan §11](hardware-config-plan.md)) should assert the bus is a real I2C adapter, not SMBus-only. + +### (c) 5G modem — Quectel RM520N-GL, M.2 B-key +**The IOTA has no M.2 B-key slot.** This is the least-solved item. + +| Option | Notes | +|---|---| +| USB-attached M.2 B-key enclosure | RM520N-GL supports USB 3.0 mode; keeps the existing modem and SIM. Costs a USB port and adds a box | +| External 5G/LTE router, IOTA on Ethernet | Cleanest separation, modem no longer the host's problem; changes the tower's network design and BOM | +| PCIe FPC | **Not viable** — the only FPC adapter is M-key, and that lane is already the NVMe path. One PCIe 3.0 ×1 lane, one device | + +**USB port arithmetic:** 3 ports. Camera (1) + modem enclosure (1) + USB-I2C bridge (1) = full, with the +ASIX USB-NIC and any USB-RS232 adapter unplaceable. **An external router plus the header COM1 for the IMU +is the combination that fits.** Decide this before ordering. + +## 5. Camera, USB and performance — re-measure, don't assume + +Known-issue #19 and the acquisition work were characterised on the Delta's Jasper Lake xHCI. The N150 is +a different controller; the 20 MB-frame ceiling is a **host** finding and must be re-measured. + +- Re-apply the preconditions: `usbfs_memory_mb=1000` (kernel cmdline) and USB3 LPM off via a udev rule on + `idVendor 1ab2`. Fold both into `hardware/lattepanda-iota/preconditions/` ([config plan §8](hardware-config-plan.md)) so they survive reboots by construction. +- Give the Alvium a port not shared with the modem or storage; re-run the frame-size/pacing sweep. +- Benchmark JPEG XL encode at the production frame size. The N150 should beat the N5105 comfortably, but + encode throughput sets the capture rate — measure it, don't infer it. + +## 6. Storage, power, enclosure + +- **Storage:** the Delta is at 67 % of 57 GB. Take the **128 GB** IOTA, and budget the **DFR1250 FPC→M.2 + M-key adapter + NVMe** (~810 MB/s measured) if images are retained on the tower. Note this consumes the + PCIe lane — see B.4(c). +- **Power:** PH2.0 4-pin, 10–15 V DC — matches the existing 12 V rail. USB-C is **power input only**. +- **Thermal:** ~4 W idle, ~36 W peak. Settles ~70 °C at 2.2–2.3 GHz under sustained all-core load with the + active cooler. Consider the 6 W TDP mode for a sealed enclosure; the stock cooler is loud but that is + irrelevant on a tower. +- **Mounting:** 88 × 70 mm vs the Delta's 125 × 78 mm — new bracket and harness lengths. + +--- + +# Sequencing + +| Phase | Work | Gate | +|---|---|---| +| **0** | Buy one IOTA. Answer the 7 open questions in the datasheet §8 — especially **PCH I2C**, **COM1 levels/enumeration**, and **remote RP2040 flashing** | Any "no" here changes the BOM or the schedule | +| **1** | **Prerequisite:** [hardware-config-plan.md](hardware-config-plan.md) phases 1–2 — `hardware.ini`, the firmware config handshake, presence reconciliation and `test hardware`. Delivered against the Delta fleet first | `test hardware` green on every existing tower | +| **2** | Firmware port: `platform_*.h`, pins + `NUM_AXES` generated from `hardware.ini`, `[env:iota_rp2040]`; `native` tests stay green | Bench RP2040 + one TMC5160-BOB at 3.3 V: `IOIN` reads `version=0x30`, `ENABLE`/`MOVE` work under `USE DEFAULTS` | +| **3** | Bench-integrate the full gimbal on the IOTA: both axes, `HOME`, `DIAG` → PASS. **Re-measure the yaw free-running encoder gap and set `enc_deviation` from it; walk `homing_speed` down from a deliberately gentle value** | Firmware bring-up checklist in `firmware/docs/hardware-notes.md` passes; homing repeatable with no audible grind; `gimbal heading setup` run and saved | +| **4** | Host: Ubuntu 24.04, Vimba X, preconditions, peripherals rehomed, `test hardware` green | Full `--init --start` sweep with real camera + motors | +| **5** | Performance: frame-size/pacing sweep, JPEG XL encode benchmark vs the Delta | Meets or beats the Delta's rate | +| **6** | Field swap on one tower, Delta kept as rollback | 72 h clean run | + +Phases 1 and 2 are independent and can run in parallel. Phase 1 has standalone value even if the IOTA is +rejected — it fixes the `deployment.md` drift and automates known-issue #19. + +--- + +# Progress + +Firmware branch `worktree-rp2040-platform` (worktree at `firmware/.claude/worktrees/rp2040-platform`), +commit `aab1925`. + +| Phase | Item | State | +|---|---|---| +| 0 | Remote-flash question researched — two viable paths, design rule derived (§2) | **done, and now measured on the board: neither works** (§2). First flash needs the MicroPython REPL or BOOTSEL; remote *update* is unsolved pending a `BOOTSEL` command | +| 2 | **Phase 2 gate: RP2040 + TMC5160-BOB at 3.3 V** | **PASSED** on tower 121, 2026-09-15: `IOIN Y=0x3000001E version=0x30`, encoder reading (`X_ENC`), one axis, `speed=3000` | +| 2 | `src/platform.h` — reset cause and free RAM behind one interface per MCU family | **done** | +| 2 | `Serial.begin()` moved ahead of all hardware init, so a wedged image stays flashable | **done** | +| 2 | `[env:iota_rp2040]` PlatformIO target (arduino-pico core, 8 MB flash) | **done and built** on tower 121, 2026-09-15: 49452 B flash (0.6 % of 8 MB), 9068 B RAM. `leonardo` still builds (19254 B) and the 22 native tests still pass | +| 2 | Per-board pin map + `NUM_AXES` in `platform_leonardo.h` / `platform_iota.h`, as-built wiring | **done** — compile-time, not yet generated from `hardware.ini` | +| 2 | Pins + `NUM_AXES` generated from `hardware.ini` | **not started** — needs the config plan's phase 2 first | +| 1 | Everything else | blocked on [hardware-config-plan.md](hardware-config-plan.md) | + +> **Not compile-verified.** PlatformIO is not installed on the dev box and the firmware is built +> *remotely on the tower* by `firmware/deploy.sh`. What was verified locally: both branches of +> `platform.h` syntax-check against stub headers — the AVR branch under `gnu++11` (what the Arduino AVR +> core uses) and the RP2040 branch under `gnu++17` (what arduino-pico uses) — and an unrecognised +> architecture fails on the `#error` rather than compiling silently. +> +> To actually build it: `cd firmware && ./deploy.sh --compile-only` compiles on the device without +> flashing. That rsyncs over the tower's firmware source tree, so it is a deliberate step, not one to +> take by surprise. + +--- + +# Decisions needed before work starts + +Configuration decisions live in [hardware-config-plan.md](hardware-config-plan.md); these are the ones +specific to the hardware. + +1. **Is the IOTA replacing the Delta fleet-wide, or becoming a second supported platform?** Either way + the configuration work stands alone and is worth doing first. +2. **Modem strategy** — external router (recommended; it also resolves the USB-port squeeze) vs USB M.2 + enclosure. A BOM/network decision, not a software one. +3. **SHT41** — USB-I2C bridge now (zero code) vs rehome to the RP2040 (cleaner, later). Recommendation: + bridge now, rehome as a follow-up. +4. **Remote firmware flashing** — researched: two viable paths, both requiring the running image to + present USB CDC (§2). Phase 0 still has to prove one end-to-end on the board, and the firmware must + bring `Serial` up before anything that can hang. +5. **Confirm the new tower's drivetrain figures** — `steps_per_rev`, `global_scaler` and the rated motor + current for the new yaw motor and gear ratio. Everything in §2 and phase 3 depends on them. diff --git a/docs/lattepanda-iota.md b/docs/lattepanda-iota.md new file mode 100644 index 0000000..6257e46 --- /dev/null +++ b/docs/lattepanda-iota.md @@ -0,0 +1,199 @@ +# LattePanda IOTA — Hardware Notes + +Collected 2026-09-11 from vendor documentation and independent reviews (sources at the bottom). +Companion to `COMPONENTS.md §1` (LattePanda 3 Delta, the board currently deployed on the tower). + +> **Status: desk research.** Nothing here has been measured on a physical IOTA. Items marked +> **[VERIFY]** must be confirmed against the board or its schematic before anyone cuts a harness. + +--- + +## 1. Why this board is different + +The IOTA is not a drop-in successor to the 3 Delta. Two changes break existing assumptions: + +1. **The co-processor changed from ATmega32U4 (8-bit AVR, 5 V) to RP2040 (32-bit Cortex-M0+, 3.3 V).** + The gimbal firmware is an AVR/Leonardo PlatformIO project; it must be ported, and **the RP2040 I/O + pins are not 5 V tolerant.** +2. **The expansion layout changed.** No M.2 B-key (the 5G modem's slot), no M.2 M-key (NVMe moves to a + 16-pin FPC PCIe adapter board), and the header exposes only one PCH-side UART — no PCH I2C. + +--- + +## 2. Core specifications + +| Item | LattePanda IOTA | LattePanda 3 Delta (current tower board) | +|------|-----------------|------------------------------------------| +| CPU | Intel Processor **N150** "Twin Lake", 4C/4T, up to 3.60 GHz, 6 MB cache | Intel Celeron **N5105** "Jasper Lake", 4C/4T, 2.0–2.9 GHz | +| GPU | Intel Graphics, 24 EU, up to 1.0 GHz | Intel UHD, 450–800 MHz | +| TDP | Configurable **6 W – 15 W** | ~12 W typical | +| RAM | 8 GB / 16 GB LPDDR5 4800 MT/s, **in-band ECC** | 8 GB LPDDR4 2933 MT/s | +| Storage | 64 GB / 128 GB eMMC 5.1 + microSD | 64 GB eMMC 5.1 + microSD | +| TPM | TPM 2.0 (in CPU) | — | +| RTC | **Yes**, 2-pin 1.25 mm, CR2032 included | (V1 lacked one; Delta has one) | +| Dimensions | 88 × 70 × 19 mm (vendor page; CNX lists 88 × 77 × 19) | 125 × 78 mm | +| Power in | USB-C **PD 15 V** *(power input only)*, or PH2.0 4-pin **10–15 V DC** | 12 V DC (7.4–15 V) | +| OS support | Windows 10/11, Ubuntu 22.04 / 24.04 | Windows 10/11, Ubuntu, Android | +| Price | ~$129 board only; kits from ~$166 | — | + +**Relevance to the fire-watch tower:** the N150 is a straight upgrade over the N5105 — newer Gracemont +cores, ~1.24× the clock ceiling, LPDDR5 vs LPDDR4. The 10–15 V DC input covers the tower's 12 V rail. + +--- + +## 3. Co-processor: RP2040 + +| Item | RP2040 (IOTA) | ATmega32U4 (Delta) | +|------|---------------|--------------------| +| Core | Dual-core Arm Cortex-M0+ @ 133 MHz | Single-core 8-bit AVR @ 16 MHz | +| SRAM | 264 KB | 2.5 KB | +| Flash | 8 MB | 32 KB | +| **I/O logic level** | **3.3 V — NOT 5 V tolerant** | 5 V | +| Host link | **USB 2.0 CDC** (appears as a serial device) | USB 2.0 CDC (native USB) | +| Toolchains | Arduino C++, MicroPython, Firmata | Arduino C++ | +| Flashing | **UF2 bootloader**: hold `BOOTSEL`, tap `RST`, release → mounts as mass storage `RPI-RP2`, drag the `.uf2` | avrdude over the CDC port (Leonardo 1200-baud touch) | + +**Internal RP2040 connections (not on the header):** GPIO20 = SW button, GPIO21 = RST, GPIO25 = blue LED. + +### Host-side enumeration +The Delta's Leonardo enumerates as USB `2341:8036` → `/dev/serial/by-id/usb-Arduino_LLC_Arduino_Leonardo-if00`. +An RP2040 running the Arduino-Pico core enumerates as `2e8a:000a` (CDC) and `2e8a:0003` in BOOTSEL mode. +**[VERIFY]** the exact VID:PID and `by-id` string on the real board — the `[Serial] device` config value +and any udev rule key off it. + +--- + +## 4. The 36-pin GPIO header (2×18, 2.54 mm) + +| Pin | Signal | Dir | Level | Notes | +|-----|--------|-----|-------|-------| +| 1 | SPK+ | Out | — | Speaker, up to 2 W RMS / 4 Ω | +| 2 | SPK− | Out | — | | +| **3** | **TXD** | Out | **3.3 V** | **PCH serial, COM1 / `ttyS0`** | +| **4** | **RXD** | In | **3.3 V** | **PCH serial, COM1 / `ttyS0`** | +| 5 | GND | — | — | | +| 6 | 5V | Pwr | 5 V | Power Output 1, **0.5 A max** | +| 7–13 | GP0 … GP6 | I/O | 3.3 V | RP2040 GPIO | +| 14–16 | GP26 … GP28 | I/O | 3.3 V | ADC-capable, **100 nF to GND — unusable for I2C/SPI** | +| 17 | 3.3V | Pwr | 3.3 V | RP2040 rail, 1.2 A max | +| 18 | RST | In (pull-up) | 3.3 V | System reset, pull low | +| 19 | 5V | Pwr | 5 V | Power Output 2, **1.2 A max** | +| 20 | D− | I/O | — | USB 2.0, native PCH | +| 21 | D+ | I/O | — | USB 2.0, native PCH | +| 22 | GND | — | — | | +| 23 | S0 | Out | 3.3 V | OS-running indicator (200 Ω series, drives an LED) | +| 24 | S3 | Out | 3.3 V | OS-sleeping indicator (200 Ω series) | +| 25–31 | GP7 … GP13 | I/O | 3.3 V | RP2040 GPIO | +| 32 | RUN | In | 3.3 V | RP2040 reset, active low | +| 33 | SCLK | I/O | 3.3 V | RP2040 SWCLK (SWD debug) | +| 34 | SDIO | I/O | 3.3 V | RP2040 SWDIO (SWD debug) | +| 35 | GND | — | — | | +| 36 | SW | In (pull-up) | 3.3 V | System power button, pull low | + +**Warnings from the vendor docs** +- Do not draw from 5 V Power Output 1 and Output 2 simultaneously. +- GP26–GP28 carry 100 nF to ground for ADC stability; avoid them for I2C or any fast edge. +- Do not apply >3.3 V to any RP2040 pin. + +### Usable RP2040 GPIO budget +**GP0–GP13 (14 clean pins)** plus GP26–GP28 (analog-only in practice). GP14–GP25 are not broken out. + +RP2040 peripheral options **within GP0–GP13**: + +| Peripheral | Available pins in GP0–GP13 | +|------------|----------------------------| +| SPI0 | SCK GP2/GP6, TX(MOSI) GP3/GP7, RX(MISO) GP0/GP4, CSn GP1/GP5 | +| SPI1 | SCK GP10, TX(MOSI) GP11, RX(MISO) GP8/GP12, CSn GP9/GP13 | +| I2C0 | SDA GP0/GP4/GP8/GP12, SCL GP1/GP5/GP9/GP13 | +| I2C1 | SDA GP2/GP6/GP10, SCL GP3/GP7/GP11 | +| UART0 | TX GP0/GP12, RX GP1/GP13 | +| UART1 | TX GP4/GP8, RX GP5/GP9 | + +Any RP2040 pin can also be PWM or PIO. **[VERIFY]** against the published pinout — the peripheral map +above is from the RP2040 datasheet, not from LattePanda. + +--- + +## 5. I/O and expansion + +| Interface | IOTA | Delta (for comparison) | +|-----------|------|------------------------| +| USB Type-A | **3 × USB 3.2 Gen 2 (10 Gbps)** | 1 × 3.2 Gen2 + 2 × 3.2 Gen1 | +| USB header | USB 2.0 pin header (pins 20/21) | — | +| USB-C | **Power input only (PD 15 V)** — no data, no DP-alt | USB 2.0 Type-C + DP 1.4 alt | +| Ethernet | 1 × GbE RJ45, WoL (**Realtek, `r8169`**) | Intel I225-V GbE | +| Video | HDMI 2.1 (4K@60), eDP 1.4b 2-lane (1080p60) | HDMI 2.0b, DP via USB-C, eDP 30-pin | +| **M.2 E-key 2230** | PCIe 3.0 ×1 / USB 2.0 / CNVio2 — Wi-Fi/BT | present | +| **M.2 B-key** | **ABSENT** | present (2242/2252/2280, SATA/USB2/USB3/SIM) | +| **M.2 M-key** | **ABSENT** — use the FPC PCIe adapter | present (2280, PCIe 3.0 ×2) | +| PCIe FPC | 16-pin, 0.5 mm, **PCIe 3.0 ×1**, 2.2 A shared across two 5 V pins | — | +| microSD | TF slot | Micro-SD + Micro-SIM combo | +| Audio | 3.5 mm jack, speaker via header, HDMI digital | 3.5 mm combo | +| Fan | 4-pin 1.25 mm, PWM, 5 V | — | +| UPS | 10-pin 1.25 mm USB 2.0 connector | — | + +### Known expansion boards +- **DFR1250 — M.2 M-key expansion**: converts the 16-pin FPC to an M.2 M-key slot, PCIe 3.0 ×1 (8 GT/s), + 2230 and 2280, bus-powered, no drivers. **NVMe only — SATA M.2 SSDs are not supported.** + Measured ~810 MB/s in an independent review. +- PoE expansion board — works as a standalone gigabit interface. +- UPS board — **reported working only on Windows 10/11; Linux NUT did not see a USB device.** + +--- + +## 6. Linux behaviour (independent report, Nov 2025) + +- Fedora Server 43 / kernel 6.17.7 installed cleanly, "no quirks or oddities". Ubuntu 22.04/24.04 are + the vendor-supported targets. +- Ethernet is Realtek via `r8169`. +- eMMC: ~316 MB/s sequential read, ~175 MB/s write, ~0.15 ms read latency. +- NVMe over the FPC adapter: ~810 MB/s. +- Power: ~4.0 W idle; 24.4 W under disk stress; 36.3 W peak under combined CPU+disk, dropping as the CPU throttles. +- Thermals with the active cooler: ~80 °C initially under sustained all-core load, settling ~70 °C at + 2.2–2.3 GHz; ~2.9 GHz on brief single-core bursts. **The stock active cooler is loud** ("very strong + high-pitched whine") — relevant only if anyone has to sit next to it. +- 24 h stress with the thermal-pad film accidentally left on produced no throttling. +- 3 USB storage devices ran ~24 h / 72 TB of reads with no kernel instability. +- RTC present, so the systemd timer problems seen on the V1 are gone. + +--- + +## 7. Migration notes (vendor "V1 → IOTA" guide) + +- **Voltage:** 5 V peripherals now need level shifting. Explicit warning against >3.3 V on any pin. +- **Sketches:** existing Arduino code "needs porting" — library support differs, port-manipulation code + differs, timing and interrupt handling diverge from 8-bit AVR. +- **Display:** MIPI-DSI → eDP 1.4b; the 7-inch MIPI display (FIT0477) is incompatible (replacement FIT1030). +- **Power:** micro-USB 5 V → USB-C PD 15 V; internal connector now 10–15 V DC. +- The guide does **not** cover bootloader details, Arduino IDE board selection, upload-method changes, or + a library compatibility matrix. + +--- + +## 8. Open questions — confirm on real hardware + +| # | Question | Why it matters | +|---|----------|----------------| +| 1 | Is there **any** PCH-side I2C/SMBus reachable on the IOTA? The published header shows only COM1. | The SHT41 ambient sensor lives on the Delta's `/dev/i2c-2` (Synopsys DesignWare LPSS). With no PCH I2C it must move to the RP2040 or to a USB-I2C bridge. | +| 2 | Is header COM1 (pins 3/4) **3.3 V TTL or true RS-232 levels**? Docs say 3.3 V. | The Xsens MTi is on the Delta's RS-232 header at `/dev/ttyS0`. A level mismatch needs a transceiver or a USB-serial adapter. | +| 3 | Exact RP2040 VID:PID and `/dev/serial/by-id/` string. | `[Serial] device` and the udev rule. | +| 4 | Does the header COM1 enumerate as `ttyS0` under Linux, and is it enabled in BIOS? | IMU device path. | +| 5 | Can the Quectel RM520N-GL (M.2 B-key) be rehomed? | No B-key slot on the IOTA. | +| 6 | USB3 LPM and `usbfs_memory_mb` behaviour on the N150 xHCI. | Known issue #19 — the Alvium stalls without these. | +| 7 | Sustained JPEG XL encode throughput at the production frame size. | Sets the achievable capture rate. | + +--- + +## Sources + +- [LattePanda IOTA product page](https://www.lattepanda.com/lattepanda-iota) +- [LattePanda Docs — IOTA Internal Interface (36-pin pinout)](https://docs.lattepanda.com/content/iota_edition/io_playability_Internal/) +- [LattePanda Docs — IOTA RP2040 Programming](https://docs.lattepanda.com/content/iota_edition/rp2040_programming/) +- [LattePanda Docs — Migration Guide (V1 to IOTA)](https://docs.lattepanda.com/content/iota_edition/migration_guide_v1_to_iota/) +- [CNX Software — LattePanda IOTA SBC combines Intel N150 with RP2040](https://www.cnx-software.com/2025/09/18/lattepanda-iota-sbc-combines-intel-n150-soc-with-raspberry-pi-rp2040-for-gpio-and-power-control/) +- [LinuxGizmos — LattePanda IOTA brings Intel N150 and RP2040 co-processor](https://linuxgizmos.com/lattepanda-iota-brings-intel-n150-and-rp2040-co-processor-in-palm-sized-sbc/) +- [DFRobot — LattePanda IOTA 8GB/64GB](https://www.dfrobot.com/product-2989.html) +- [DFRobot Wiki — DFR1250 M.2 M-Key Expansion Board](https://wiki.dfrobot.com/SKU_DFR1250_M2_M_Key_Expansion_Board) +- [techtipsy — LattePanda IOTA review: how does it perform as a home server?](https://ounapuu.ee/posts/2025/11/18/lattepanda-iota/) +- [CNX Software — LattePanda Sigma (ATmega32U4 reference)](https://www.cnx-software.com/2023/04/26/lattepanda-sigma-intel-core-i5-1340p-raptor-lake-sbc-atmega32u4-arduino-mcu/) +- [Arduino-Pico (earlephilhower) — PlatformIO integration](https://arduino-pico.readthedocs.io/en/latest/platformio.html)