Skip to content

KN-86 Deckline Firmware Update — Research Brief

Hardware specifications are canonical in the repo root CLAUDE.md — this doc does not restate them.


The Pi Zero 2 W has two micro-USB ports: only the one closest to HDMI is data-capable (OTG), and it can simultaneously deliver power and data. This is the port the flasher would use. The other port is power-only and irrelevant here.

There are three realistic firmware delivery paths:

  1. g_mass_storage (USB MSC) — kernel module that exposes a backing file or block device as a USB mass storage device. The host OS sees the Pi as a USB drive; the user drags a .img/.kn86fw onto it. Works on Zero 2 W with dtoverlay=dwc2,dr_mode=peripheral in config.txt and modules-load=dwc2,g_mass_storage in cmdline.txt. Gotcha: MSC is block-level. If the Pi is using the backing filesystem while the host writes to it, you corrupt it — so MSC mode requires the Pi to not be running nOSh. This points to a dedicated rescue/updater image.
  2. g_serial (USB CDC-ACM) — the Pi appears as a serial port. The desktop app speaks a custom protocol over it, streams firmware bytes, the Pi writes them. This is what Playdate does (vendor 0x1331, product 0x5740, 115200 baud, ASCII-newline commands — fwup [bundle_path] is the documented firmware command).
  3. g_ether (USB RNDIS/ECM) — Pi shows as a network adapter, desktop app talks HTTP/TFTP to it. Works but fragile on Windows (RNDIS is port-bound; moving USB ports re-triggers driver install).

Recommended: a dedicated “Updater” boot image that starts g_mass_storage on a firmware-slot partition, not a runtime gadget-mode toggle from nOSh. Rationale:

  • The user-facing mental model — “plug into computer, drag file, done” — is exactly USB MSC. CDC-ACM requires a desktop app doing protocol work; MSC works with no app at all as a fallback, and the desktop app is pure polish.
  • Doing gadget-mode switching inside live nOSh is risky (filesystem race, need to unmount the slot under the running process).
  • A key combo at boot (e.g. SYS+EVAL held during power-on) is detected by a tiny first-stage updater in an otherwise normal initramfs / early-userspace hook. If the combo is held, kexec or switch into the updater image. If not, boot normally. Same SD card, two entry points.
  • Power flows through the data USB cable during the update — the user does not need battery charge. Recommend the desktop app warn if it sees low battery via a status query (CDC side channel) but not require it.

DeviceUpdate transportUX patternLesson for KN-86
PlaydateWiFi primary; USB CDC serial (fwup command) as recovery/dev path. pd-usb proves WebSerial works from a browser.WiFi OTA is the default user flow. Mirror app uses USB only for screen mirroring.Custom serial protocol is workable but invisible to the user — they expect OTA. For our prototype with no WiFi OTA infrastructure, the USB-cable flow becomes the primary path, and we should treat it as the UX, not a fallback.
Analogue PocketSD card file copy; USB cable exposes SD as MSC as an alternative.Root-of-SD firmware file, reboot, auto-applies.The “drop file on exposed SD” pattern is the most universally-understood flow. Community tools (Pupdate, Pocket_Updater) wrap it in a friendlier GUI — we’d be shipping the friendly GUI on day one.
Miyoo Mini / RG35XXSD card swap only — no USB update path.Image on SD root, cold boot, power LED → update.Users accept SD swap but hate it; Miyoo’s “insert USB cable, it updates automatically on boot” step is widely praised. Our flow should be at least as good.
Steam DeckSteamOS atomic A/B over network.Invisible. Update + automatic rollback on failed boot.The A/B + auto-rollback pattern is what makes it feel “console-grade” vs “Linux handheld.” Worth emulating.

UX pattern that works: plug cable → desktop app auto-detects device by VID/PID → one-click update button → progress bar → “you can unplug now.” What frustrates users: having to hold obscure key combos, format SD cards, or deal with driver installation dialogs. The Playdate “metal button twice to unlock USB” gesture is a fine model for the physical attention gesture.


Recommendation: Tauri 2.x (Rust backend + webview frontend).

Rationale:

  • Disk I/O lives in Rust, not Node/JS. Crates like sysinfo, block-utils, and udev (Linux) give cross-platform device enumeration. Raw writes use standard std::fs::File opened with O_DIRECT or platform equivalents. This is where Electron forces you into a multi-hop IPC dance to call into Node native modules — Tauri just calls a Rust function.
  • Elevation handling is honest — no framework pretends this is easy. Tauri’s idiomatic answer is a separate privileged CLI helper invoked via runas (Windows UAC) or pkexec (Linux polkit) or macOS’s SMJobBless/Authorization Services. The elevated-command crate wraps this cross-platform. This is actually what rpi-imager does internally (Qt) and what balena-etcher does (Electron + a bundled helper). Plan: ship the flasher as two binaries — the Tauri UI, and a kn86-flasher-helper CLI that does the actual write. The UI re-invokes itself elevated via the helper for the write step.
  • Bundle size and feel matter for a boutique device. A Tauri app is ~10MB vs Electron’s ~100MB. For a Kinoshita-branded installer, the native feel (native webview, no Chromium bootloader window flashing) is on-brand. Startup is faster.
  • Rust also lets us share code with any future kn86-cli — the write logic, image verification (SHA-256), and protocol client can all live in a workspace crate usable by both the Tauri app and headless tooling.

Alternatives considered:

  • rpi-imager fork — tempting because Apache-2.0 (main) + LGPL (Qt) is permissive and the SD-write core is proven. Viable as a reference for buffer sizes, sync behavior, and the drivelist/mountutils libraries (these are MIT-licensed standalone projects we can pull into any framework), but forking the whole thing locks us into Qt/QML and generic “write any disk” framing when we want a narrow Kinoshita-branded experience.
  • Electron — pure overhead for this use case; nothing we need requires Node.
  • Raw Qt — works fine, but the team’s skillset leans web/Rust, and QML is a learning tax.

┌──────────────────────────────────────────────────────────────┐
│ DESKTOP: KN-86 Deckline Flasher (Tauri) │
│ ├─ UI (webview): Kinoshita-branded, amber/black palette │
│ │ States: IDLE → DEVICE_DETECTED → CONFIRM → FLASHING │
│ │ → VERIFY → COMPLETE / ERROR │
│ ├─ Rust backend: │
│ │ - Device detection (USB VID/PID + MSC volume label) │
│ │ - Firmware download + SHA-256 verify │
│ │ - Invokes elevated helper for writes │
│ │ - Status query over CDC-ACM (optional side channel) │
│ └─ kn86-flasher-helper (elevated CLI) │
│ - Opens raw block device │
│ - Streams firmware image with progress callback │
│ - fsync + verify-read pass │
└──────────────────────────────────────────────────────────────┘
│ USB-C cable (data-capable)
┌──────────────────────────────────────────────────────────────┐
│ DEVICE: Pi Zero 2 W │
│ SD layout (A/B): │
│ p1: FAT32, 64MB — autoboot.txt + bootcode + common │
│ p2: FAT32, 256MB — bootfs A (kernel, cmdline, config) │
│ p3: FAT32, 256MB — bootfs B (kernel, cmdline, config) │
│ p4: ext4, rest/2 — rootfs A (nOSh + Pi OS Lite) │
│ p5: ext4, rest/2 — rootfs B │
│ p6: ext4, small — /home/shared (deckstate.bin, saves) │
│ │
│ Boot entry points (single SD, two modes): │
│ - Normal: stage-1 init checks SYS+EVAL hold (GPIO matrix │
│ scan before X/SDL). If not held → exec nOSh. │
│ - Updater: if held → kexec into updater kernel that loads │
│ g_mass_storage with backing = /dev/mmcblk0pN of the │
│ *inactive* slot (A if booted from B, vice versa). │
│ Displays "UPDATER MODE — CONNECT TO HOST" on the screen. │
│ │
│ A/B switch: updater writes autoboot.txt with tryboot flag; │
│ first boot attempt runs from the new slot; if nOSh doesn't │
│ hit a "boot-success" sentinel within 60s (heartbeat to the │
│ bootloader EEPROM watchdog / tryboot commit), next reboot │
│ reverts to the old slot automatically. │
└──────────────────────────────────────────────────────────────┘

Single .kn86fw file = a gzipped ext4/FAT image of a full slot (bootfs + rootfs concatenated), plus a small header (magic, version, SHA-256, nOSh version string, min-bootloader-version). The flasher validates the header, writes bootfs to the inactive bootfs partition, writes rootfs to the inactive rootfs partition, updates autoboot.txt with tryboot_a_b=1 plus the new slot, triggers reboot '0 tryboot' via the serial side channel (if present) or tells the user to unplug and power-cycle.

A/B is worth doing on day one specifically because the dev prototype is the thing we can’t afford to brick. Pi firmware’s native tryboot support makes this cheap: you already need two boot partitions for Pi’s stage-1 loader to understand A/B, the rootfs split is a couple of extra parted calls in the SD provisioning script, and autoboot.txt is 3 lines. Cost is +~500MB of SD (duplicated rootfs). Benefit: one bad update never bricks, only degrades. Given the hardware ship target is Q4 2027 and we’ll iterate firmware hundreds of times in the interim, this pays for itself in the first bad update. The interaction with g_mass_storage is natural — MSC exposes one slot at a time (always the inactive one), which is exactly what we want.


  1. Pi Zero 2 W g_mass_storage stability — known forum reports of regressions vs. the original Zero W on recent kernels. Need to pin to a validated kernel version and lock it in the updater image. Testing plan: write/read/verify 100MB image 50 times across macOS/Win/Linux hosts before trusting in prod.
  2. Windows driver experienceg_mass_storage is class-compliant and should need no driver, but Windows occasionally caches stale device IDs per USB port. Mitigation: ship the flasher signed, set a stable VID/PID (request a PID from pid.codes or use the Pi Foundation’s allocated range), and test re-plugging into every USB port on a test Windows box.
  3. Key-combo-at-boot detection before full Linux userspace — we need a minimal GPIO matrix scan in early boot (custom script in initramfs or a small C binary called from rcS). The Pro Micro keyboard is USB HID, so it only becomes readable once USB stack and evdev are up. This means the “hold SYS+EVAL to enter updater” combo runs after systemd starts but before nOSh — a small early-boot unit that reads /dev/input/event* for ~2 seconds and signals via a tmpfile whether to start nOSh or the updater. Feasible, but needs explicit design in an ADR.
  4. Battery behavior during update — the PowerBoost 1000C should let USB power flow through to both the Pi and the battery. Need to confirm there’s no weirdness where the Pi sees USB power but also tries to negotiate OTG role in a way that confuses the charger. Bench test with a current meter before trusting.
  5. Slot-inactive MSC exposure means saves partition is separate — intentional and good, but we need to make sure migrations (e.g., deckstate schema changes between firmware versions) are handled by nOSh on first boot of the new slot, not by the flasher. The flasher must never touch /home/shared.
  6. Signing and rollback keys — decision pending whether firmware images are signed (and whether the Pi verifies signatures on boot, or just SHA-256 integrity). For prototype phase, SHA-256 integrity is probably sufficient; signing is a production concern.
  7. Pi RAUC backend is still unmerged — we can use tryboot + autoboot.txt directly without RAUC. RAUC is a nice-to-have for production, overkill for prototype. Proposed: ship homegrown A/B logic now (~200 LOC of shell + Rust), evaluate RAUC migration when production hardware lands.