Architecture Decision Records for mirroring / remote-controlling an
M5Stack Cardputer ADV (board_M5CardputerADV, enum 24) in a web browser.
| ADR | Title | Status |
|---|---|---|
| 0001 | Panel tee mirror (Panel_Mirror wraps Panel_ST7789) |
Proposed |
| 0002 | GRAM readback mirror (non-invasive) | Accepted — implemented |
| 0003 | Pure browser simulator (no device) | Proposed |
| 0004 | Mirror + full remote control | Proposed — goal achieved via 0002+0017+0037 instead |
| 0005 | Host-side MCP server for the firmware development loop | Accepted |
| 0006 | The 25% self-test was a byte-order bug, not unreliable readback | Accepted |
| 0007 | Multi-profile WiFi manager with explicit failure reporting | Accepted |
| 0008 | SD card manager: explicit formatting via the FATFS layer | Superseded by 0036 |
| 0009 | Hotspot rescan, and never show only the mDNS name | Accepted |
| 0010 | On-device menu | Accepted |
| 0011 | Scanning from SoftAP is not a trustworthy scan | Accepted |
| 0012 | Refuse to upload while the serial port is held | Accepted |
| 0013 | A draw function must not drive hardware | Accepted |
| 0014 | The scan screen was matching against the wrong SSID | Accepted |
| 0015 | Show the broadcast SSID, not the configured one | Accepted |
| 0016 | Open networks, auth modes, and the WPA2 association floor | Accepted |
| 0017 | Remote keyboard: generate the layout, send coordinates | Accepted |
| 0018 | A key that does nothing is not a key that failed | Accepted |
| 0019 | The ADV has dedicated arrow keys; I said it did not | Accepted |
| 0020 | Every legend is a key: dual-legend caps and a solved-for layout | Accepted |
| 0021 | The keyboard collapsed to one column: a self-referential CSS measure | Accepted |
| 0022 | The keyboard is two parts: printed case legend + rubber dome | Accepted |
| 0023 | The lowercase hotspot SSID was a real bug, but not the one we hit | Accepted |
| 0024 | A redacted credentials file builds and flashes cleanly, then fails as SoftAP | Accepted |
| 0025 | Test the page the device serves, not web/index.html | Accepted |
| 0026 | Remove the keystroke log; fix two false claims in the note | Accepted |
| 0027 | Move occasional controls behind a hamburger; keep modal state visible | Accepted |
| 0028 | Device mockup with inset display, zoom pops it out | Accepted |
| 0029 | BtnG0 and BtnRst move onto the case top edge, unlabelled | Accepted — position/width superseded by 0030 |
| 0030 | Top-face photo corrects the button sides; front-face detail measured | Accepted — supersedes 0029 position/width |
| 0031 | Paint order buried three correctly-positioned details | Accepted |
| 0032 | Note text removed; menu and keyboard toggle confirmed shipping | Accepted |
| 0033 | Stamp-S3A label is a photograph on its own route | Accepted |
| 0034 | CARDPUTER ADV label stays CSS, measured against the photo | Superseded by 0035 |
| 0035 | CARDPUTER ADV label becomes a photograph; CSS screws removed | Accepted — supersedes 0034 |
| 0036 | Remove the SD card feature entirely | Accepted — supersedes 0008 |
| 0037 | Why control appeared to need USB: modem sleep, and a counter that lied | Accepted — implemented |
This table covers only the four mirroring/control strategy options; ADRs 0005-0037 (wifi manager, on-device menu, keyboard layout, etc.) live in this directory but aren't part of that comparison.
| ADR | Title | Status |
|---|---|---|
| 0038 | Adapter-driven begin(): IInputSink / IHostAdapter — lets a second host firmware (see launcher-adv-mirror) supply its own frame source and input path without editing this library |
Proposed |
| 0039 | SpiReadbackFrameSource — GRAM readback for hosts without M5Unified/M5GFX |
Accepted — implemented, unverified on hardware |
| 0040 | esp32async/ESPAsyncWebServer instead of the esphome fork — the esphome fork can't build against an arduino-esp32 3.x core |
Accepted — implemented |
| 0041 | A frame source failure doesn't take down remote control — server/WS/key injection no longer depend on _src->begin() succeeding |
Accepted — implemented |
| 0044 | Two ways "is the device actually there?" gave a wrong answer during dev-loop USB checks — a shell-side timing artifact, and a genuinely wedged device that even system_profiler missed |
Accepted — documented, not yet automated |
| 0045 | Mobile-first pass: real sizing tiers, zoom pops above the case on touch, and typing via the phone's own keyboard through a hidden input | Accepted — implemented, not yet verified on a physical iPhone |
| 0046 | Landscape auto-zoom — an iPhone-only hamburger toggle that pops the display up and fills the screen at native 16:9 in landscape, for e.g. AirPlay-to-car-stereo wardriving | Accepted — implemented, not yet flashed |
| 0047 | 1262 HAT mockup drawn in CSS (GNSS+LoRa label centered on the device), with the top-edge buttons realigned to the ADV/S3A stickers and cut to 1/3 height | Accepted — implemented, not yet flashed |
All verified by reading M5GFX / M5Unified / M5Cardputer sources, not datasheets.
| Property | Value | Source |
|---|---|---|
| Board enum | board_M5CardputerADV = 24 |
M5GFX/src/lgfx/boards.hpp:35 |
| Panel | Panel_ST7789, 135x240 native |
M5GFX.cpp autodetect |
| Logical screen | 240x135 (rotation = 1), offset_x=52, offset_y=40 |
M5GFX.cpp |
| SPI | SPI3_HOST, write 40 MHz, read 16 MHz |
M5GFX.cpp |
| Pins | MOSI 35, SCLK 36, DC 34, CS 37, RST 33, BL 38 (PWM ch7) | M5GFX.cpp |
| MISO | not wired (-1), spi_3wire = true |
M5GFX.cpp |
| Readback | cfg.readable = true; _read_depth = rgb888_3Byte |
Panel_LCD.hpp:140 |
| Keyboard | TCA8418 I2C controller, matrix(7,8), INT GPIO11 |
TCA8418.cpp |
| Keyboard seam | Keyboard_Class::begin(std::unique_ptr<KeyboardReader>) |
Keyboard.h:153 |
Derived budget (see tools/verify_codec for the arithmetic):
- Shadow framebuffer, RGB565: 240x135x2 = 64,800 B (63.3 KiB)
- Full-frame readback at 3 B/px: 97,200 B -> 48.6 ms @ 16 MHz -> ~20.6 fps ceiling
- Tile 60x45: 8,100 B read -> 4.05 ms/tile, 12 tiles/screen
Yes — and so does starting with #2. This was the deciding factor in the sequencing, so it is worth stating precisely.
The system splits into five layers. Only the frame source differs between options:
+-----------------------------+
layer 5 | browser UI (canvas, keys) | shared by 1, 2, 4
layer 4 | wire protocol + RLE codec | shared by 1, 2, 4
layer 3 | dirty-tile scheduler | shared by 1, 2, 4
layer 2 | IFrameSource <---- THE ONLY DIFFERENCE
| ReadbackFrameSource (#2) |
| TeeFrameSource (#1) |
layer 1 | input injection | #4 only
+-----------------------------+
- #1 -> #4: #4 is #1 plus layer 1 (input injection) and a control channel. #1 builds layers 2-5; #4 adds layer 1. Nothing in #1 is thrown away.
- #2 -> #1: swapping
ReadbackFrameSourceforTeeFrameSourceis a one-line change at the call site. Layers 3-5 are untouched. - #2 -> #4: #2 builds layers 3-5, which is the majority of the work by volume. #4 then needs layer 2 (tee) + layer 1 (input).
So the chosen order #2 -> #1 -> #4 is strictly incremental: no layer is built twice, and #2 pays for the protocol/UI work that #1 and #4 both inherit. The reason to start at #2 anyway is risk: #2 answers the one question no amount of source reading can settle — does ST7789 GRAM readback actually work over 3-wire SIO on this panel? If it does not, #2 is dead and #1 becomes the only mirroring path; better to learn that from a 2-line integration than after building a full tee.
That question is why the firmware ships a boot self-test (selfTest()):
it draws a known pattern, reads it back, and reports percent match before you
trust a single frame.