# 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.