# 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 — FIXED 2026-09-18.** `docs/deployment.md` said "LattePanda Sigma"; it is a 3 Delta. The page now names both boards the fleet actually runs (3 Delta on 111, IOTA on 121) and notes that nothing in that guide is board-specific — the distinction belongs to `hardware.ini`, not to the deploy steps. --- # 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.** Latest, 2026-09-18, with the config handshake: **iota_rp2040** 51716 B flash (0.6 % of 8 MB), 9108 B RAM (3.5 %); **leonardo** 22646 B flash (**79 % of 28 KB**), 939 B RAM (36.7 %). Both from `./deploy.sh --compile-only` against towers 121 and 111; 33 native tests pass (`pio test -e native`) | | 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 | | 3 | Yaw homing on the real drivetrain | **done** — the endstop was being sheared through at full torque; homing at 189 mA holds. See below | | 1 | Everything else | blocked on [hardware-config-plan.md](hardware-config-plan.md) | > **Where the firmware gets built.** PlatformIO is not installed on the dev box; the firmware is built > *remotely on a tower* by `firmware/deploy.sh --compile-only`. Locally, the Arduino-free sources > (`command_parse.cpp` and its native tests) compile and run under plain g++, and the rest > syntax-checks against stub headers on both branches — the AVR under `gnu++11` and the RP2040 under > `gnu++17`, with an unrecognised architecture failing on the `#error` rather than compiling silently. > That catches typos and signatures; it says nothing about size or behaviour, so a remote build is > still the gate. > > **Flash headroom is now the constraint on the Delta fleet.** The ATmega32U4 build sits at **79 % of > its 28 KB** (6026 B left), up from 19254 B at the phase 2 gate — that growth is everything since: > `BOOTSEL`, `CURRENT`, the reduced homing current, `STOP` ending a homing, and the config handshake. > RP2040 flash is a non-issue at 0.6 % of 8 MB. Deleting the drivetrain fields from `MOTOR_CONFIG` > (config plan §14) now buys space as well as removing a duplicate definition. > > 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. ### Phase 3, first attempt — 2026-09-16 Three homing runs at VMAX 120, 1200 and 6000. What came out of them: | Established | Value | |---|---| | Speed model | **~0.73 counts/s per unit of VMAX** (87 c/s at 120, 880 at 1200, 4400 at 6000 — predictions held to within 0.5 %) | | Geometry | **~3,400 counts/degree**, so ~1.23 M counts per turn | | `steps_per_rev` | The inherited Delta value of 177,000 is **wrong by ~7x** for this drivetrain. It is not a search limit (`motor.cpp` uses it only as a minimum-travel check once both limits are found), so it slowed nothing — but it made the axis look broken, and the check is meaningless until corrected | **The axis turned ~2,157,000 counts — about 630 degrees, 1.8 full turns — in one direction without the encoder deviation ever exceeding ~100 counts** against the 1500 that marks a jam. The motor met no resistance at any speed. Homing detects an endstop by the shaft falling behind the commanded position, so it cannot work if nothing stops the shaft. Two candidates were considered, indistinguishable over the wire because the encoder sits on the motor side: no hard stop at all, or an output not following the motor. ### RESOLVED, 2026-09-17 — the stop was there, and losing Neither. **The endstop is a screw head meeting another screw head, and the raised gear ratio gave the motor enough torque to shear one past the other.** The stop was resisting; it simply lost, every time, and a stop that yields is invisible to a detector watching for the shaft to fall behind. The fix is to make the search weak enough that the screw wins: | | | |---|---| | Homing current | **189 mA** (IRUN 7 at GLOBALSCALER 80, 75 mΩ sense resistor) | | Run current | 756 mA — unchanged, because the gear ratio exists for wind stability | | Measured travel | **1,210,778 counts**, repeatable to 0.27 % across three homes | | Free-running encoder gap | ~200 counts against the 1500 latch, and it does **not** scale with speed | | Full homing | ~2 min at VMAX 30000 | Declared per tower in `hardware.ini` as `[Motor.yaw] homing_current_ma`, converted to registers by the host and sent as `CURRENT` before every home — so retuning it is a restart, not a reflash. See [configuration.md](configuration.md) for the keys and `firmware/docs/hardware-notes.md` for the mechanism. Three things this exposed, all since fixed: - **`steps_per_rev` was inert**, still carrying the Delta's 177,000 against a real turn of ~1.2 M, so it passed at 14 % of a turn. It is the only guard against a *phantom* endstop, which reducing the current makes reachable. - **`STOP` did not end a homing** — it halted the motor but left the state machine waiting for a stall that could no longer come, until the timeout. - **The host abandoned homing after a hard-coded 65 s**, which this drivetrain needs more than. Now `[Motor] homing_timeout_s`.
What the investigation looked like before it was resolved **Resolving it needs a physical observation**: did the gimbal itself rotate ~1.8 turns, or did the motor spin while the platform stayed put? Check any cabling through that joint before moving the axis further.
--- # 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.