- 9
- Apps behind the menu
- 128 ×64
- Display
- 16 ms
- Frame budget
- 3,899
- Lines of C++
What it is
A small hardware dashboard that sits on a desk and does the handful of things I kept reaching for a phone to do. Nine apps — clock, stopwatch, alarm, pomodoro timer, weather, settings, an animation toy, an OTA updater and a system monitor — live behind one scrolling menu, on a 128×64 monochrome OLED, driven entirely by a rotary encoder and two buttons.
Why I built it
I wanted a pomodoro timer that wasn’t a browser tab. Once the encoder and the screen were wired, the interesting problem stopped being the timer and became the interface: how do you make nine applications feel like one device when the only inputs are a knob, a confirm button and a back button, and the only output is 8,192 monochrome pixels?
Most of the work in this repository is the answer to that question.
The hardware
An ESP32-S2 Mini, an SH1106 OLED on I²C, one rotary encoder with a push switch, two tactile buttons and a passive buzzer.
| Function | Pin |
|---|---|
| Encoder A / B | GPIO1 / GPIO2 |
| Encoder switch | GPIO3 |
| Confirm | GPIO5 |
| Back | GPIO4 |
| Buzzer | GPIO6 |
| I²C SDA / SCL | GPIO8 / GPIO9 |
The encoder is configured for 4 pulses per detent, with 20 ms of debounce and a 380 ms window for detecting multi-presses, so a double-click is a distinct gesture from two single clicks.
The part worth explaining
The device targets a 16 ms frame — roughly 60 fps — and every app is written against that budget rather than drawing only when something changes.
That decision is what makes it feel like a product instead of a sketch. Menu
scrolling, screen transitions and the notification bar all interpolate toward a
target each frame using one shared constant (LERP_SPEED = 0.13), so nothing
snaps and everything decelerates the same way. The menu, the app screens and the
notification bar are separate modules but they share a single easing feel,
because they share the literal same constant.
The menu itself is a carousel: items sit 18 pixels apart, the selection lerps toward the chosen index, and the whole wheel slides horizontally when you enter an app. Entering and leaving an app is one transition, not nine bespoke ones.
The notification system is deliberately global — any app can raise a message, it occupies a fixed 14-pixel bar for 2600 ms, and the app underneath does not have to know it happened.
Structure
Seventeen headers and one sketch, split by responsibility rather than by convenience:
esphub.ino state machine, menu registry, main loop
ui_renderer.h the wheel, transitions, shared drawing primitives
input_handler.h encoder decode, debounce, multi-press detection
notification_sys.h the global notification bar
app_*.h one file per app (clock, alarm, weather, …)
buzzer.h tones and startup sound
qrcode_local.h on-device QR generation
config.h every pin and tuning constant, in one place
Every pin and every timing constant lives in config.h. Re-pinning the board
means editing one file.
Limitations
- Bluetooth is stubbed out.
app_bluetooth.hexists and is disabled by an#ifdef, because the ESP32-S2 has no Bluetooth radio at all. Building it for an original ESP32 or an S3 would enable it; on this board it can never work. - WiFi credentials and the OpenWeatherMap key are compiled in via
config.h. There is no provisioning flow — reflashing is the way to change networks. That is fine for a device that lives on one desk and wrong for anything else.
More Embedded