Projects Embedded

ESP Hub

A desk hub on an ESP32-S2: nine apps behind a wheel menu, driven by one rotary encoder and two buttons, drawn to a 128x64 OLED at a 60 fps frame budget.

Shipped since May 2026 #esp32-s2#oled#sh1106#u8g2
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.

FunctionPin
Encoder A / BGPIO1 / GPIO2
Encoder switchGPIO3
ConfirmGPIO5
BackGPIO4
BuzzerGPIO6
I²C SDA / SCLGPIO8 / 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.h exists 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.