ESP32-based wireless remote with a 128×64 OLED display. Controls AC units, lights, covers, and automations via Home Assistant. All display and interaction logic lives in C++ header files under src/; ESPHome YAML handles connectivity, sensors, and button wiring.
- Full project write-up: tech.lugowski.dev/guides/smart-oled-remote
- 3D-printed case: MakerWorld
- ESPHome docs: esphome.io
| AC | Lights | Covers | Automations | Settings |
|---|---|---|---|---|
![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
esphome_remote/
├── README.md ← this file
├── tools/
│ └── screenshot.py ← OLED framebuffer → PNG converter
└── devices/
├── multi_function_remote/ ← current device (v2 hardware, 6-button pad)
│ ├── remote.yaml ← production firmware (edit substitutions at the top)
│ ├── remote_demo.yaml ← offline demo firmware (no HA/WiFi required)
│ ├── secrets.yaml ← WiFi credentials
│ ├── Makefile ← CLI build targets
│ ├── fonts/
│ │ └── arial_bold.ttf
│ ├── packages/ ← ESPHome YAML sub-packages (one per controller mode)
│ │ ├── menu.yaml
│ │ ├── mode_ac.yaml
│ │ ├── mode_lights.yaml
│ │ ├── mode_covers.yaml
│ │ ├── mode_automations.yaml
│ │ ├── mode_settings.yaml
│ │ ├── buttons.yaml ← abstract btn_* dispatcher (v2 layout)
│ │ └── layouts/
│ │ ├── layout_v1.yaml ← original AC board pinout (self-contained)
│ │ └── layout_v2.yaml ← 6-button generic layout (default)
│ └── src/ ← C++ controller classes and entity lists
│ ├── menu_entities.h ← ★ configure menu order / enabled controllers
│ ├── ac_entities.h ← ★ configure AC units
│ ├── light_entities.h ← ★ configure lights
│ ├── cover_entities.h ← ★ configure covers
│ ├── automation_entities.h ← ★ configure automations
│ ├── *_controller.h ← logic for each mode (no need to be modified!)
│ ├── display_utils.h
│ └── remote_core.h
└── oled_remote/ ← legacy device (v1 hardware, original AC board)
└── README.md ← legacy notes
Files marked ★ are the ones you edit to match your Home Assistant setup.
| Component | Spec |
|---|---|
| MCU | ESP32 (esp32dev) |
| Display | SH1106 128×64 OLED, I2C |
| I2C pins | SDA GPIO27 · SCL GPIO25 |
| Wake pin | GPIO0 |
| Button pad | 6-button directional pad (v2) or original layout (v1) |
| Battery | LiPo with 100 kΩ/100 kΩ voltage divider on GPIO34 |
| v1 (left) | v2 (right) | |
|---|---|---|
| Buttons | AC, Power, +, − | ↑ ↓ ← → OK ⏸ |
| Use case | AC-only | Multi-mode (AC, lights, covers, automations) |
| Layout file | layouts/layout_v1.yaml |
layouts/layout_v2.yaml (default) |
Swap the layout: package line in remote.yaml to match your hardware revision.
Fill in devices/multi_function_remote/secrets.yaml:
wifi_ssid: "YourSSID"
wifi_password: "YourPassword"ESPHome uses a 32-byte base64 key (not a HA long-lived access token) to secure the native API. Each device should have its own unique key.
Generate one:
python3 -c "import os, base64; print(base64.b64encode(os.urandom(32)).decode())"Paste it into the substitutions block at the top of remote.yaml:
substitutions:
API_KEY: "paste_your_base64_key_here"After flashing, HA will auto-discover the device. Go to Settings → Devices & Services → ESPHome → Configure and enter the same key — HA stores it for all future connections.
Edit the src/*_entities.h files to match your Home Assistant setup (see Entity configuration below).
See Building and flashing below.
# Python 3.10+ required
python3 -m venv esphome-venv
source esphome-venv/bin/activate
pip install esphome
# Verify
esphome versionIf esphome is not on your $PATH, edit the ESPHOME variable at the top of Makefile.
All make targets run from the devices/multi_function_remote/ directory.
| Command | What it does |
|---|---|
make |
Compile production firmware |
make demo |
Compile demo firmware (no HA needed) |
make flash |
Compile + flash over USB (auto-detects port) |
make flash PORT=/dev/cu.usbserial-XXXX |
Flash to a specific USB port |
make ota IP=192.168.1.42 |
Compile + flash over WiFi (OTA) |
make logs |
Open serial monitor |
make logs-ota IP=192.168.1.42 |
Stream logs over WiFi |
make clean |
Remove build artefacts |
Hold the BOOT button while pressing RESET to enter flash mode, then:
cd devices/multi_function_remote
make flash
# or with an explicit port:
make flash PORT=/dev/cu.usbserial-0001After the first flash, subsequent updates can be done wirelessly:
make ota IP=<device-ip>- Copy the
multi_function_remote/folder into/config/esphome/on your HA instance:rsync -av devices/multi_function_remote/ ha:/config/esphome/multi_function_remote/
- In the ESPHome dashboard click + New device → Import from file and point it at
remote.yaml. - Set
API_KEYin thesubstitutionsblock and fill insecrets.yaml. - Hit Install.
The demo firmware can dump the current OLED framebuffer as a monochromatic PNG via the serial port.
While the device is connected over USB, navigate to the screen you want to capture and hold the Pause/Stop button for 3 seconds. The device logs the raw framebuffer (1024 bytes, 128×64) as hex lines and immediately returns to normal operation.
# Install dependencies (one-time)
pip install pyserial pillow
# Live mode — stays connected, saves screenshot_<timestamp>.png on every capture
python3 tools/screenshot.py /dev/tty.usbserial-XXXX
# Or parse a previously saved serial log (single capture, then exit)
python3 tools/screenshot.py --file dump.log
# Custom upscale factor (default: 4× → 512×256 px)
python3 tools/screenshot.py /dev/tty.usbserial-XXXX --scale 6| Flag | Default | Description |
|---|---|---|
--file / -f |
— | Parse a saved log file instead of opening a serial port (single capture, then exit) |
--baud / -b |
115200 |
Serial baud rate |
--output / -o |
screenshot_<ts>.png |
Output path (file mode only; live mode always uses the timestamp name) |
--scale / -s |
4 |
Pixel upscale factor (4 → 512×256 px) |
Open src/menu_entities.h. The MENU_LIST array controls which controllers appear in the carousel and in what order:
static const MenuEntry MENU_LIST[] = {
{ APP_AC, "\ueb3b", "AC" },
{ APP_COVERS, "\uec12", "COVERS" },
{ APP_LIGHTS, "\ue0f0", "LIGHTS" },
{ APP_AUTOMATIONS, "\uea0b", "AUTOMATIONS" },
{ APP_SETTINGS, "\ue8b8", "SETTINGS" },
};- Remove a row — that controller disappears from the carousel. Its button handlers stay silent (
app_mode == APP_Xcan never be reached). - Reorder rows — carousel order changes; routing follows automatically.
- Single controller — the menu button is suppressed (nothing to switch to).
The APP_* constants are stable — YAML conditions never need changing regardless of which controllers are enabled.
One entry per physical air-conditioner unit.
static const char* AC_MODES_0[] = { "cool", "fan_only" };
static const char* AC_FAN_0[] = { "low", "high" };
static const ACEntity AC_LIST[] = {
{
"Living", // display name (≤8 chars)
"climate.living_room_ac", // HA entity_id
"living_room_ac_temp", // ESPHome sensor id — current temperature
"living_room_ac_mode", // ESPHome text_sensor id — hvac_mode
"living_room_fan_mode", // ESPHome text_sensor id — fan_mode
AC_MODES_0, 2, // modes array + count
AC_FAN_0, 2 // fan_modes array + count
},
// add more units…
};The sensor id: values must match entries in packages/mode_ac.yaml.
Supported modes: cool, heat, dry, fan_only, heat_cool, off.
One entry per light or light group.
static const LightEntity LIGHTS_LIST[] = {
// name entity_id state_sensor_id brightness_sensor_id dimmable
{ "Sofa", "light.sofa", "sofa_light", "sofa_brightness", true },
{ "Table", "light.table", "table_light", nullptr, false },
};Set dimmable: true and provide a brightness_sensor_id for lights that support brightness. Use nullptr for on/off-only lights.
One entry per cover (blind, curtain, roller shutter, etc.).
static const CoverEntity COVER_LIST[] = {
// name entity_id state_sensor_id position_sensor_id
{ "Living", "cover.living_room_curtain", "living_curtain_state", "living_curtain_pos" },
};state_sensor_id— text_sensor:open,closed,opening,closing, orstoppedposition_sensor_id— sensor: 0–100 % (0 = fully closed, 100 = fully open)
Both must match entries in packages/mode_covers.yaml.
One entry per automation, scene, script, or any triggerable HA entity.
static const AutomationEntity AUTOMATION_LIST[] = {
// name short entity_id service slot
{ "Good Night", "NIGHT", "scene.good_night", "scene.turn_on", "L" },
{ "Movie Time", "MOVIE", "scene.movie_time", "scene.turn_on", "R" },
{ "Away Mode", "AWAY", "automation.away_mode", "automation.trigger", nullptr },
};service— HA service to call (scene.turn_on,automation.trigger,script.turn_on, etc.)slot—"L"or"R"pins the entry as a one-press bottom-bar shortcut;nullptrfor list-onlyshort_name— bottom-bar label for slotted entries (≤5 chars)
Device won't connect to WiFi
- Verify credentials in
secrets.yaml - Try increasing
output_powerinremote.yaml
Display not working
- Check SDA/SCL wiring (GPIO27/GPIO25)
- Verify the display model (
SH1106vsSSD1306) and I2C address (0x3C)
Buttons not responding
- Check pin assignments match your hardware revision (v1 vs v2 layout)
- Pins use internal pull-ups — button should connect GPIO to GND
API connection fails after flashing
- Make sure the
API_KEYinremote.yamlmatches what you entered in HA - HA must be reachable from the ESP32 subnet
The original single-purpose AC remote (devices/oled_remote/) uses a different board layout and an older single-file YAML approach. It is no longer actively developed.
→ See devices/oled_remote/README.md for notes on that configuration.










