Clay Tech

"clay-works make things real"

translated from clazytech.com

Blinking an LED in the age of AI still teaches you something

An M5Paper board — an ESP32 board with a 4.7-inch e-paper display — had been sitting on my desk. What makes e-paper interesting is that it holds an image with no power draw, so you can build a device that wakes once a day, updates, and powers off again. It doesn’t need to stay on all the time.

I went back and forth on what to show, and ended up settling on football results. Matches only happen on weekends and midweek, so a screen that updates once a day fits well. The idea is just to glance at “what happened yesterday” while making coffee in the morning. I decided not to use a server: the M5Paper alone wakes up, hits the API, draws the screen, and goes back to sleep.

Updated 08/30 07:05    25.1℃ 69%  100%    Refresh: side button
────────────────────────────────────────────────────────────
■ Premier League
  Crystal Palace     1 - 4  Man City      Won all 2
■ Bundesliga
  Leverkusen         2 - 1  Hoffenheim    Top of the table
■ LaLiga
  Alavés             0 - 2  Girona        Down into Relegation
                                              +6 more

It lists matches finished in the last 72 hours, and next to each score adds a short note like “three in a row,” “top of the table,” or “dropped into the relegation zone.” A small bit of playfulness. The device does the calculation mechanically; no LLM is involved. Touch isn’t used, incidentally. Or rather, it can’t be used if you care about power consumption — the device is normally powered off, so the touch IC sleeps along with everything else. Drawing a button on the screen does nothing if a finger can’t trigger it, so I assigned the update action to the side power button instead.

So, it worked. The full-screen draw came out clean. Satisfied, I went to bed.

The next morning, only the match list had disappeared. The clock and temperature at the top had updated fine. Just the body was blank white.

I had no idea why it had vanished.

To be honest, similar things had been happening all along the way here. Let me go through them one at a time.

The first thing I tripped over was the data source. I’d originally built this with API-FOOTBALL, but running it on the actual device, every competition came back as 0 matches.

fetch: 8 req, 0 http err, 0 parse err, 0 matches

Everything succeeded. There was just nothing there.

Hitting the API directly from my PC, I finally understood.

"errors": {
  "plan": "Free plans do not have access to this season, try from 2022 to 2024."
}

The free plan only covered the 2022–2024 seasons. Not exactly suited to a board that’s supposed to show yesterday’s match. Well, that happens. The bigger problem was in my own implementation: this API returns HTTP 200 with the error embedded in the body, and I had only been checking the status code. So I couldn’t tell “zero results due to plan restrictions” apart from “zero results because there were no matches.” After switching to football-data.org, whose free tier had no daily cap, the whole elaborate scheme I’d built — reserving tomorrow’s quota so a manual refresh wouldn’t burn through the day’s allowance — became unnecessary, and I deleted the code along with 9 tests. There’s something satisfying about deleting something you built.

Next, all the Japanese text disappeared. Flashing it to the device, ASCII showed up fine but no Japanese appeared at all. I suspected I’d mishandled the font, but what caught my attention was that even ℃ was missing from the 25.1℃ 69% display. Only ASCII survived; everything else was wiped out.

I inspected the generated VLW font.

glyphs: 391
point where ascending order breaks: index 320
  U+2192 '→' -> U+2103 '℃'
71 glyphs after that point become unsearchable

M5GFX looked up the VLW glyph table using std::lower_bound. In other words, binary search — which assumes the table is sorted in ascending order of code point. My generation script, though, had simply concatenated character sets in the order “ASCII → Latin-1 → Latin Extended-A → …→℃ → Japanese,” without sorting them. The order broke down at …(U+2026) → →(U+2192) → ℃(U+2103), and every glyph after that point became unlookupable. Everything before the break point was fine, so the symptom looked like “only Japanese doesn’t show.”

No error appears. Characters just quietly vanish.

The fix was adding a single sorted() call. While I was at it, I added an assert before the write-out too. If I’d just looked at the generated output once, this would have been over on day one. Incidentally, the Japanese font contains neither ş nor č. Team names in European football — like Beşiktaş, Kraków, Crvena zvezda — basically never fit within plain Latin, so I set it up to pull the missing characters from a different font, with an option that fails outright if even a single character is missing. I’d learned my lesson about things silently disappearing.

Now, about the body text that vanished into white.

My first suspicion was the order of the white flash. E-paper flashes white once before redrawing, so I figured the sequencing must be off. Wrong. Next I tried limiting the pushed region by coordinates. If I specified only the clock area at the top, the body shouldn’t be touched at all. Still no good. Only on the third attempt did I finally start doubting the premise rather than the symptom.

The M5Paper doesn’t use ESP32 deep sleep — it actually cuts power via a power-latch circuit. The RTC then powers it back on. Which means waking up is a cold boot, and nothing remains in RAM.

I already knew that much, and I was properly writing settings and cache to the SD card. What I’d missed was that this applies to the EPD frame buffer too.

e-paper panel : the previous image is physically still there
RAM here      : starts empty (blank white)

E-paper keeps its display even without power. That’s the whole selling point. Meanwhile, my buffer starts fresh every single time, and with that mismatch, drawing “just the clock area at the top” and pushing it out also pushes the still-blank body region along with it, painting the match list white. Limiting by coordinates couldn’t save it, because the source being pushed was empty to begin with.

If nothing persists, there’s no choice but to rebuild everything from scratch every time.

On waking, I now first restore the previous screen from the SD card, rebuild the entire frame buffer, and only then push it out. That’s the point where RAM and panel finally agree. Network communication can happen afterward. I’d apparently assumed “state doesn’t persist” applied only to data I’d written myself. The same thing naturally happens to whatever buffer the library holds internally. The spec sheet itself said, in my own words, “the power latch is the biggest trap” — and the person who wrote that walked straight into it twice.

There were a few other small things I tripped over too. One communication failure broke everything downstream, because the keep-alive client, on a read failure, returned false while leaving an unread response sitting in the socket, and the next request ended up reading the stale response. There was also the time the line height of “the 28px font” turned out to actually be 42px, so text laid out with a fixed 40px kept punching through the rule below it. I also struggled with an SD card for a while without realizing ESP32’s FatFs can’t read exFAT.

And this one made me laugh: I had two serial logging scripts running at the same time, and since both toggled DTR/RTS, they ended up resetting the terminal mid-communication.

11:17:50Z INFO  matches bundesliga: 1 in 45d window
[  1468][I][M5GFX.cpp] autodetect(): M5Paper     ← reset happens here
11:18:05Z INFO  boot: sd=1 ...

The classic case of observation disturbing the thing observed. I suspect a good chunk of the “something’s off” feeling I had partway through came down to this.

Lining these up, there’s one thing they all have in common.

The API’s plan restriction was HTTP 200 with zero errors; the VLW sort omission just quietly dropped characters; the keep-alive mismatch had zero parse errors with empty contents; and the frame buffer loss just painted things white. No exception thrown, nothing in the logs. It looked like it was working, only the contents were wrong.

I’d written 57 test cases on the PC, and all of them passed. Those tests earned their keep — being able to run layout-packing checks and rank-band boundary conditions dozens of times without flashing the device was a real help. But all four of the issues above sit outside that boundary. They live on the far side of hardware and network, so in a way, of course they did.

What actually helped instead was looking, once, with my own eyes at what was generated and what was received. The VLW sort issue took one line of assert; the API error just needed the body read. Both should have been settled on day one.

There’s a wider gap than I expected between “it worked” and “it’s correct.”

Right now, one wake cycle takes about 56 seconds, of which 37 seconds is spent waiting on rate limits. It makes 7 requests, and I haven’t seen a single 429. Battery use is roughly 1.2mAh per cycle, so with 1150mAh it should last quite a while.

If there’s a better way to catch these things that break without ever throwing an error, I’d like to know about it.

This piece: concept and direction by Kuzuryu, writing by AI.


Originally published in Japanese at https://clazytech.com/2026/09/1786/. Translated with LLM assistance and reviewed before publication.