-
Day 2: Web UI, 22 lives, and a Dremel
03/04/2026 at 05:25 • 0 commentsGoal: stop entering book data on a 32-key touchscreen keyboard. Also make the firmware work for every hobby simultaneously. Also put it in a box.
Spoiler: all three happened. One required power tools.
---------- more ----------
The Web UI
2.8 inches of touchscreen is fine for a WiFi password you type once. It is not fine for entering titles, dates, and star ratings across 50 books a year.
The ESP32 runs a web server. Connect to WiFi → settings screen shows a QR code → scan it → you’re in a glassmorphism web UI served directly from PROGMEM.
The background actually comes from the device.
/api/bgstreams the current RGB565 frame raw. The browser decodes it via the Canvas API and sets it as the page background. It matches the display exactly — because it is the display.Features:
- Year tabs
- Add / delete entries
- Live card titles
- Star ratings
- Started / finished dates
API surface (minimal and boring on purpose):
GET /api/data — item list for year GET /api/years — available years POST /api/item — add or update POST /api/deleteitem — removeData lives in
/books_2026.jsonon LittleFS. Previous years remain untouched.NTP sync sets the year automatically on WiFi connect. If offline, there’s a manual year screen with big ± buttons.
22 Variants, One File
At some point I wrote down every metric I’ve ever wanted to track. It was 22.
Options:
- 22 copies of
main.cpp(no) - One codebase + per-variant config (yes)
Each variant defines six macros:
#define VARIANT_ID "gym" #define COUNTER_LABEL "GYM VISITS" #define WEB_TITLE "Gym" #define WEB_HEADING "★ Gym Visits" #define WEB_ADD_BTN "+ Log Visit" #define WEB_CFG_JS "var CFG={pfx:'Session',ph:'Notes (optional)',d1:'Date',d2:'',showD2:false,showRating:false};"VARIANT_IDsets the LittleFS filename prefix, so each device’s data is isolated.WEB_CFG_JSis injected into the PROGMEM HTML at build time. The web UI reads it and decides:- Show second date field?
- Show star rating?
A gym session doesn’t need a “Finished” date. A book does.
There are 22
[env:cyd-*]entries inplatformio.ini, each pointing to its own config via:-I firmware/cyd/variants/<name>Flash any variant:
pio run -e cyd-yoga --target uploadOne codebase. One
platformio.ini. 22 different devices.![]()
Port Detection Detour
upload_port = COM15was fragile because Windows reassigns COM numbers on reconnect.Tried the “correct” fix:
HWGREP://1A86:7523(PySerial pattern matching CH340 VID:PID.)
Result:
A fatal error occurred: Could not open HWGREP://1A86:7523 (OSError(22, 'The filename, directory name, or volume label syntax is incorrect.'))When
upload_portis set explicitly, PlatformIO skips its resolution phase and hands the string directly toesptool.Esptool sees
HWGREP://...and (correctly) concludes that is not a serial port.Fix: delete
upload_portentirely.Auto-detect works perfectly when only one ESP32 is connected. Time spent: longer than I’d like to admit.
The Case
Found a 3D model for the CYD. Printed it. It looked great.
The board has two connectors:
- USB-C (power)
- micro-USB (flashing)
The model had one cutout.
Got out the Dremel.
Opened the second port manually. It is not a perfect rectangle. Nobody will ever see it.
The front looks like a finished product. The back looks like a finished product that had a brief disagreement with a rotary tool and moved on.
Both ports are accessible. Board doesn’t rattle. Case clicks shut. Calling it done.
End of Day 2 State
- Web UI in PROGMEM, backgrounds fetched live from the device
- Multi-year storage, NTP sync, manual year fallback when offline
- 22 build variants: books, movies, TV series, anime, video games, gym, yoga, hiking, fishing, board games, cities, countries, restaurants, craft, resin, art, miniatures, videos, designs, orders, clients, products
- Port detection: auto-detect, no hardcoded COM
- Physical hardware in a case, both ports usable, Dremel retired
Next
- Per-variant background image sets (currently all share
data/) - Laser Cut Acrylic Case
-
CYD Bring-Up: SPI, Backlight, and Other Lies
03/03/2026 at 04:18 • 0 commentsGoal: make the ESP32-2432S028 (aka Cheap Yellow Display) draw pixels and respond to touch without lying to me.
Spoiler: it draws pixels. Eventually.
---------- more ----------
SPI situation (why are there two?)
Display and touch are not on the same SPI bus. Display = HSPI. Touch = VSPI. Nowhere obvious in the listing. No schematic in the box. TFT_eSPI assumes one bus. That was a dead end.
Switched to LovyanGFX. It lets you assign SPI hosts directly. That solved 80% of the pain.
// display on HSPI cfg.spi_host = HSPI_HOST; // touch on VSPI SPI.begin(25, 39, 32, 33); ts.begin();
After that: at least I had a black screen instead of nothing.
Black screen, but technically alive
Serial looked fine. init() returned. Still black. Backlight is on GPIO21. Defaults LOW. You must pull it HIGH before display.init().
pinMode(21, OUTPUT); digitalWrite(21, HIGH); display.init();
Once that went high: pixels. Very wrong pixels, but pixels.
Diagonal tearing / half-screen offset
Image split diagonally like a broken framebuffer. Turned out to be panel vs memory dimensions mismatch. ILI9341 RAM is 240x320. Board is mounted 320x240. LovyanGFX calculates offsets from those values. If they don’t match physical layout → chaos.
cfg.panel_width = 320; cfg.memory_width = 320; cfg.panel_height = 240; cfg.memory_height= 240; display.setRotation(4);
That fixed the split. Rotation 4 = rotation 0 + mirror. Feels wrong. Works.
RGB backwards
White text rendered blue. Red looked cyan. ILI9341 variant swaps color order.
cfg.rgb_order = true;
One boolean. 20 minutes of confusion.
Touch mapping (XPT2046 fun)
Touch panel mounted portrait inside landscape enclosure. Raw axes swapped and inverted. So p.x is basically screen Y (inverted). p.y is screen X.
TS_Point p = ts.getPoint(); int16_t sx = map(p.y, 300, 3800, 0, 320); int16_t sy = map(p.x, 3900, 200, 0, 240); sx = constrain(sx, 0, 319); sy = constrain(sy, 0, 239);
After calibration it behaves. Mostly.
End of Day 1 state
- Full-screen RGB565 backgrounds from LittleFS
- Theme JSON per background (accent + hero color)
- Swipe left/right to switch backgrounds
- On-screen touch keyboard for WiFi password
- WiFi connects + fallback on bad credentials
- Hero book count rendered on top
- OTA enabled over LAN
SD card shares VSPI with touch. CS juggling was annoying. Dropped SD. Using LittleFS in flash instead. ~2.5MB available with custom partition table.
app0, app, factory, 0x10000, 0x180000 spiffs, data, spiffs, 0x190000, 0x270000
Background frames: 320x240 RGB565 → 153,600 bytes each. About 16 fit comfortably.
Tooling
Wrote a small Python converter to batch PNG → raw .bin + .theme skeletons. No runtime decoding. Just stream to display.
python tools/convert_image.py img/ data/ pio run -e cyd --target uploadfs
Next
- Actual book list UI
- books.json schema in LittleFS
- Basic suggestion logic
Hardware is stable enough to build UI on top. Finally feels like a platform instead of a demo sketch.
Julia M