23 KiB
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.
Companion document: hardware-config-plan.md covers splitting
config.ini, thehardware.iniassembly 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:3says "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) whoseinstall.shemits ahardware.iniwith[Board] model = lattepanda-iotaand 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, andenc_deviation(config plan §5). Two consequences worth planning for:
- Calibration is mandatory, not inherited.
yaw_counts_per_degis derived fromsteps_per_rev, sogimbal heading setupmust 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_speedandenc_deviation_limitwant 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:
- Firmware port — real but small and well-bounded.
- Logic-level change, 5 V → 3.3 V — turns out to be nearly free (see B.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:
[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: callSPI.setSCK/setTX/setRX(...)beforeSPI.begin().SPISettings,beginTransaction,transferare 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.Serialis USB CDC on both — the host protocol andcommand.cppare 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 -urefused — no picotool reset interface refused — Unable to locate reset interface on the deviceBOOTSELserial command (added 2026-09-15)n/a works — this is the answer machine.bootloader()over the MicroPython REPLworks — re-enumerates as 2e8a:0003 RP2 Bootn/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
BOOTSELserial command calling arduino-pico'srp2040.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 runningIt 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'sgimbal rawpassthrough — 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:
- 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-RP2mass-storage device, and copies the.uf2across. Requires the running firmware to present a USB CDC port — which ours does, since that is the host link.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.shmay need an explicitupload_protocol, or apicotool reboot -f -ustep beforepio 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-RP2by itself.- A valid but broken image that never brings up USB CDC is unrecoverable remotely. Neither path can reach it; only the physical
BOOTSELbutton 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-BOBpin 1VCC_IOaccepts 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.AS5047Phas a documented 3.3 V mode (tieVDD/VDD3V3, supply 3.0–3.6 V —COMPONENTS.md§ encoder). It feeds the TMC'sENCA/ENCB/ENCN, not the MCU, so it only needs to matchVCC_IO.- Pitch endstops go to
REFL/REFRon the BOB and followVCC_IOautomatically.
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) 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 onidVendor 1ab2. Fold both intohardware/lattepanda-iota/preconditions/(config plan §8) 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 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 |
| 3 | Yaw homing on the real drivetrain | done — the endstop was being sheared through at full torque; homing at 189 mA holds. See below |
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 for the keys
and firmware/docs/hardware-notes.md for the mechanism.
Three things this exposed, all since fixed:
steps_per_revwas 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.STOPdid 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.
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 ofplatform.hsyntax-check against stub headers — the AVR branch undergnu++11(what the Arduino AVR core uses) and the RP2040 branch undergnu++17(what arduino-pico uses) — and an unrecognised architecture fails on the#errorrather than compiling silently.To actually build it:
cd firmware && ./deploy.sh --compile-onlycompiles 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; these are the ones specific to the hardware.
- 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.
- 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.
- SHT41 — USB-I2C bridge now (zero code) vs rehome to the RP2040 (cleaner, later). Recommendation: bridge now, rehome as a follow-up.
- 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
Serialup before anything that can hang. - Confirm the new tower's drivetrain figures —
steps_per_rev,global_scalerand the rated motor current for the new yaw motor and gear ratio. Everything in §2 and phase 3 depends on them.