Skip to main content

← All posts

Deep Dive

Why Milepost Goes Dark: The Screen Timeout

· 5 min read

Milepost is a 7-inch touchscreen that lives on the wall of an RV. It is, on most rigs, mounted somewhere you also sleep. A glowing panel a few feet from your pillow at 2 a.m. is not a feature. So Milepost has a screen timeout, and as of this week it finally goes truly dark when it fires. Here is what the feature is for, and the small piece of hardware that nearly kept it from working.

RVs are bedrooms

The whole reason Milepost exists is that you should not have to walk back to a control panel to do anything. Tap a light, glance at battery state, check water levels, all from the wall as you walk by. That same convenience turns into a problem the moment the cabin lights go out. A bright touchscreen reads as a desk lamp in a dark space. It draws the eye, it ruins night vision, and on a quiet boondocking night it is by far the brightest thing in the trailer.

There is also the boring engineering side. Milepost runs on the same 12 V house battery that powers the fridge, the lights, the water pump, and everything else you actually need. A panel held at full brightness for eight hours of overnight drains is a small but real charge you would rather keep in the bank for the morning. And LCDs do not love being driven flat-out forever. Backlight LEDs dim with use, and the way to make them last is to not run them when nobody is looking.

Three reasons, one feature: turn the screen off when nobody is using it.

How the timeout works

The behavior is the one Fireside has been shipping for months, ported into Milepost. You set a timeout in the Settings screen, in minutes. Zero means never. Anything above that, the firmware watches LVGL's input-inactive timer, and the moment it crosses your threshold, the backlight cuts and a transparent overlay drops onto the top UI layer. The panel goes dark. The next touch anywhere on the screen lands on that overlay, which absorbs the tap, restores your chosen brightness, and removes itself.

The overlay matters more than it sounds. Without it, the first wake-tap would land on whatever widget happened to be under your finger, which on the Settings screen could be the brightness slider itself. Tapping a dark screen to wake it should not change settings. The click absorber turns wake into a deliberate two-step: one tap to bring the lights up, then interact normally.

The user-facing rules are the ones you would expect. The minimum brightness is clamped above black, so a stray slider event during a wake cannot leave you with an unusable panel. The wake path restores whatever brightness you last asked for, not some default. And the timeout countdown resets on any touch, anywhere on the UI.

The CH32V003 in the middle

Here is where it gets interesting. The Waveshare 7-inch ESP32-S3 panel that Milepost runs on does not give the SoC direct control of the backlight. Brightness goes through a small CH32V003 microcontroller acting as an I/O extender. The ESP32 talks to it over I2C. The CH32V003 owns two relevant lines: a PWM output that sets brightness, and an enable pin (called IO2 in the firmware) that gates the backlight power entirely.

The PWM register on this chip is inverted. Writing 0 means full bright. Writing 247 means as dark as PWM alone can take it. The chip caps at 247 out of 255, which works out to about 97% duty on the inverted driver, which means PWM by itself can never get the screen below roughly 3% brightness. In a lit room you cannot tell. In a dark trailer at night, that is a faint blue glow on the wall.

So the obvious answer is: drop IO2 low. That cuts power to the backlight LEDs entirely. The screen goes black. To wake, raise IO2 and write the PWM value back. Easy.

What broke

It was not easy. The first attempt at the timeout did exactly that. Drop IO2 low on timeout, raise IO2 and re-issue the PWM on wake. The screen went truly dark on timeout. The screen never came back. Touch worked, the firmware logged the wake event, the PWM write succeeded over I2C. The panel stayed black.

The CH32V003 was forgetting. Its internal PWM state did not survive an IO2 low-to-high transition cleanly, and a PWM register write that arrived too soon after the rising edge was lost. We tried writing PWM before the IO2 transition. We tried writing it twice after. Nothing was reliable.

The shipped workaround was to give up on IO2 entirely. PWM-only timeout, accept the 3% floor, take the small night-time glow as a known compromise. That is what was in the firmware until this week.

The fix

The thing that was missing was patience. The CH32V003 needs time to settle after IO2 goes high before it will accept a fresh PWM value. Not microseconds. Tens of milliseconds. Once we measured it, the recipe was simple:

  • On timeout, drop IO2 low. The panel goes truly dark, no glow, no compromise.
  • On wake, raise IO2. If it was previously low, wait 50 ms for the chip to come back. Then write the PWM value the user last asked for.
  • If IO2 was already high (the normal brightness-slider case), skip the delay entirely. No latency added to the common path.

50 ms is enough. Less is racy. More is wasted. The wake-tap takes a beat that you do not notice as a human, and the screen lights up at exactly the brightness you set.

It is a small change. The diff is 19 lines added, 16 lines removed, in main.c. The whole story is in the comment above the function. The result is that Milepost can now be mounted in a bunkroom, in a v-berth, or three feet from the bed in a Class B, and it will quietly go to black after the timeout you set, and come back the moment you reach for it.

Why this kind of thing belongs in the open

The reason this post exists is that the workaround was, briefly, the answer. The firmware shipped with a known compromise, and a comment in the code explaining what we had tried and why we gave up. That comment was the thing that let us come back to it later with one more idea, one more measurement, and a real fix. None of that is unique to Milepost. It is just what happens when you keep working on a thing.

It also belongs in the open because the next person who picks up a CH32V003-driven Waveshare panel is going to hit exactly this. The datasheet does not warn you. The demo code does not exercise the IO2 enable transition. Someone is going to spend an afternoon staring at a black screen wondering what went wrong, and now they can find this post, and the comment in the code, and skip that afternoon.

That is the whole point of building TrailCurrent in public. Your rig, your data, your screen, dark when you want it dark.