Simple and functional
The concept is simple. Enable the user to control both left and right bedside lights from either side with a simple control box. The box has a dial (rotary encoder) that turns the lights off or on and dims them in an analog way. There is a selector button (momentary switch) that selects which of the two lights is the active light (indicated by green and yellow LEDs).
There is actually some level of complexity here as it uses an old ESP8266 (using up the last of them) with yaml code that integrates with Home Assistant via the homeassistant platform and lambda calls. Knowing the state of a lamp at time of control matters for a reasonably consistent user experience.
Inner workings
Here is a paste of the outline of functionality I have in the code comments - Actually I asked Claude Code to make sure were there :
HOW IT WORKS:
# - One “active light” is selected at a time (Light1 or Light2)
# - GreenLED lit = Light1 (guest room lamp) is active
# - YellowLED lit = Light2 (3D printer lamp) is active
# - SELECTOR button : toggles which light is active, LEDs update to show selection
# - ENCODER press : toggles the active light ON / OFF
# - ENCODER rotate : dims or brightens the active light (0-100%)
# - On boot : LEDs blink back and forth 3 times, then settle on active light
# - Sleep timer : after 60s of no user input, indicator LEDs go dark (night-friendly).
# Any user input (selector / encoder press / rotary turn) re-lights
# the active LED and restarts the 60s timer. HA-pushed brightness
# changes do NOT wake the LEDs (so a phone tweak at 3am stays dark).
The blinking is really just to add some drama LOL, but the sleep timer was important because I found the LED lights were a bit bright at night so I just assumed the user was finished playing with lights after 60 seconds and the LED can just go to sleep until any action on the controls wakes them again.
I made a fairly crude box on the 3d printer to house everything including the dimming dial. But it seems to work well although it’s a bit lite and could be more stable so it does not move around when you touch it.
Limitations
Only home assistant controlled lights with dimming capability can be assigned to the code. So Zigbee, Z-wave or even some sort of MOSFET controlled led light. The entity has to be existing and controllable from HA.
The fun of it
What’s kind of funny is I interact almost daily with this device as it has a basic use case and is easy and convenient. Possibly the stuff of good design. I do know I could iterate may times to get the perfect design but some times good enough is sufficient. This was also the first time I used Claude Code to help me. My code was, in a word - flawed - to begin with, so I learned much from the changes it recommended. Maybe it’s an age thing - but manually dimming has a nice simple feel to it.
Here is the code you can use as a base and refine to your liking:
################################################################################
# BEDSIDE CONTROLLER
#
# Hardware: Wemos D1 Mini (ESP8266)
# Purpose: Controls two Home Assistant lights from a single rotary encoder + 2 buttons
#
# HOW IT WORKS:
# - One "active light" is selected at a time (Light1 or Light2)
# - GreenLED lit = Light1 (guest room lamp) is active
# - YellowLED lit = Light2 (3D printer lamp) is active
# - SELECTOR button : toggles which light is active, LEDs update to show selection
# - ENCODER press : toggles the active light ON / OFF
# - ENCODER rotate : dims or brightens the active light (0-100%)
# - On boot : LEDs blink back and forth 3 times, then settle on active light
# - Sleep timer : after 60s of no user input, indicator LEDs go dark (night-friendly).
# Any user input (selector / encoder press / rotary turn) re-lights
# the active LED and restarts the 60s timer. HA-pushed brightness
# changes do NOT wake the LEDs (so a phone tweak at 3am stays dark).
#
# WIRING:
# GPIO04 — Selector momentary button (INPUT_PULLUP, active LOW)
# GPIO05 — Encoder push button (INPUT_PULLUP, active LOW)
# GPIO12 — Encoder CLK (pin A)
# GPIO14 — Encoder DT (pin B)
# GPIO13 — Green LED output (Light1 indicator)
# GPIO15 — Yellow LED output (Light2 indicator) — D8 on Wemos D1 Mini
# GPIO02 — Built-in status LED (active LOW — inverted: true)
#
# NOTE: Both target lights must be dimmable. Non-dimmable entities will fail.
# NOTE: Target lights are set in substitutions below.
################################################################################
################################################################################
# SUBSTITUTIONS — Change values here to reconfigure without editing the logic
################################################################################
substitutions:
device_internal_name: esphome_bedsider_config
device_wifi_name: esphome-bedsider-config-wifi
device_friendly_name: ESPHome bedsider_config
device_ip_address: 192.168.X.X
device_sampling_time: 30s
Active_Light1: light.guestroomlamp # Green LED = this light
Active_Light2: light.esphome_web_be7938_3dprinter_lamp # Yellow LED = this light
################################################################################
# GLOBALS
# active_light: 0 = Light1 (green), 1 = Light2 (yellow)
# Not restored on reboot — always starts on Light1
################################################################################
globals:
- id: active_light
type: int
initial_value: '0'
################################################################################
# BOARD
################################################################################
esphome:
name: ${device_internal_name}
friendly_name: ${device_friendly_name}
on_boot:
priority: -100 # Run last — after WiFi, API, and globals are all ready
then:
# Visual boot sequence: blink LEDs back and forth 3 times
- repeat:
count: 3
then:
- light.turn_on:
id: GrnLed
- light.turn_off:
id: YlwLed
- delay: 300ms
- light.turn_off:
id: GrnLed
- light.turn_on:
id: YlwLed
- delay: 300ms
# Settle LEDs to reflect the initial active_light value (0 = GrnLed),
# then start the 60s sleep timer so the room goes dark if nobody touches it.
- script.execute: wake_leds
esp8266:
board: d1_mini
################################################################################
# LOGGING — WARN only; change to DEBUG temporarily when troubleshooting
################################################################################
logger:
level: WARN
logs:
homeassistant.sensor: ERROR # Suppress "Can't convert 'None'" when lights are off
################################################################################
# HOME ASSISTANT API
################################################################################
api:
reboot_timeout: 0s # Don't reboot if HA goes offline — just wait patiently
encryption:
key: !secret api_encryption_key
################################################################################
# OTA + SAFE MODE
################################################################################
ota:
- platform: esphome
password: !secret web_server_password
# Safe mode: if device crashes 5 times in a row, boot into recovery state
safe_mode:
reboot_timeout: 10min
num_attempts: 5
################################################################################
# WIFI
################################################################################
wifi:
networks:
- ssid: !secret wifi_ssid
password: !secret wifi_password
min_auth_mode: WPA2
manual_ip:
static_ip: ${device_ip_address}
gateway: !secret gateway_address
subnet: !secret subnet_address
power_save_mode: NONE # More stable and faster to reconnect on ESP8266
fast_connect: true # Connect directly without scanning — faster boot
# Fallback hotspot: if WiFi fails, device creates its own network for recovery
ap:
ssid: ${device_wifi_name}
password: !secret web_server_password
# Captive portal shown when connected to the fallback hotspot
captive_portal:
################################################################################
# WEB SERVER — Local dashboard for diagnostics (D1 Mini has enough RAM)
################################################################################
web_server:
port: 80
version: 2
include_internal: true
auth:
username: !secret web_server_username
password: !secret web_server_password
local: true
################################################################################
# STATUS LED — Built-in LED blinks to show WiFi/API connection state
# GPIO2 is active-low on ESP8266, so inverted: true corrects the logic
################################################################################
status_led:
pin:
number: GPIO2
inverted: true
################################################################################
# TIME — Sourced from Home Assistant (used for any time-based logic)
################################################################################
time:
- platform: homeassistant
################################################################################
# SCRIPTS
# update_leds : sets the indicator LEDs to match active_light (immediate)
# wake_leds : called on every user input — lights the active LED and (re)starts
# the 60s sleep timer
# sleep_leds : restart-mode timer — after 60s of no wake_leds calls, both LEDs
# go dark for night-friendly operation. Each wake_leds call cancels
# the in-progress delay and starts a fresh 60s countdown.
################################################################################
script:
- id: update_leds
then:
- if:
condition:
lambda: 'return (id(active_light) == 0);'
then:
- light.turn_on:
id: GrnLed # Light1 active — green on
- light.turn_off:
id: YlwLed
else:
- light.turn_on:
id: YlwLed # Light2 active — yellow on
- light.turn_off:
id: GrnLed
- id: wake_leds
mode: restart
then:
- script.execute: update_leds
- script.execute: sleep_leds # (re)start the 60s sleep countdown
- id: sleep_leds
mode: restart # Each call cancels in-progress delay and starts fresh
then:
- delay: 60s
- light.turn_off:
id: GrnLed
- light.turn_off:
id: YlwLed
################################################################################
# BINARY SENSORS
################################################################################
binary_sensor:
# Connection status — reports online/offline to Home Assistant
- platform: status
name: "Status"
id: ${device_internal_name}_status
# ENCODER BUTTON — press to toggle the active light ON or OFF
- platform: gpio
pin:
number: GPIO05
inverted: true
mode:
input: true
pullup: true
id: button
filters:
- delayed_off: 50ms # Debounce — physical switches can "bounce" within a few ms
on_click:
then:
- if:
condition:
lambda: 'return (id(active_light) == 0);'
then:
- homeassistant.service:
service: light.toggle
data:
entity_id: ${Active_Light1}
else:
- homeassistant.service:
service: light.toggle
data:
entity_id: ${Active_Light2}
# User pressed the encoder — wake the indicator LEDs and restart sleep timer
- script.execute: wake_leds
# SELECTOR BUTTON — press to switch between Light1 and Light2
- platform: gpio
pin:
number: GPIO04
inverted: true
mode:
input: true
pullup: true
id: selector
filters:
- delayed_off: 50ms # Debounce — 50ms prevents a single press registering twice
on_press:
then:
# Toggle active_light between 0 (Light1) and 1 (Light2)
- lambda: |-
id(active_light) = (id(active_light) == 0) ? 1 : 0;
ESP_LOGD("selector", "Active light is now %d", id(active_light));
# Update the indicator LEDs to show the new selection AND restart sleep timer
- script.execute: wake_leds
################################################################################
# SENSORS
################################################################################
sensor:
# WiFi signal strength — reported to HA for monitoring connection quality
- platform: wifi_signal
name: "WiFi Signal Sensor"
id: ${device_internal_name}_wifi_signal_sensor
update_interval: ${device_sampling_time}
# Uptime in raw seconds — internal only, human-readable version sent to HA below
- platform: uptime
name: "Uptime Sensor"
id: ${device_internal_name}_uptime_sensor
update_interval: ${device_sampling_time}
internal: true
on_raw_value:
then:
- text_sensor.template.publish:
id: ${device_internal_name}_uptime_human
state: !lambda |-
int seconds = round(id(${device_internal_name}_uptime_sensor).raw_state);
int days = seconds / (24 * 3600);
seconds = seconds % (24 * 3600);
int hours = seconds / 3600;
seconds = seconds % 3600;
int minutes = seconds / 60;
seconds = seconds % 60;
return (
(days ? to_string(days) + "d " : "") +
(hours ? to_string(hours) + "h " : "") +
(minutes ? to_string(minutes) + "m " : "") +
(to_string(seconds) + "s")
).c_str();
# ROTARY ENCODER — turning adjusts brightness of the active light (0–100%)
# resolution: 4 = 4 electrical pulses per physical detent (common for KY-040 encoders)
# The filter fires on_value at most once per 100ms OR when the value jumps by 10+
- platform: rotary_encoder
id: myrotary_encoder
name: "Rotary Encoder"
min_value: 0
max_value: 100
resolution: 4
filters:
- or:
- debounce: 0.1s # Minimum 100ms between updates when dialing slowly
- delta: 10 # OR fire immediately if value jumps by 10 or more
pin_a:
number: GPIO12 # CLK on encoder module
inverted: true
mode:
input: true
pullup: true
pin_b:
number: GPIO14 # DT on encoder module
inverted: true
mode:
input: true
pullup: true
on_value:
then:
# Send new brightness to HA only if the encoder has actually moved away
# from the light's current brightness — avoids redundant HA service calls.
# wake_leds is called INSIDE each then: block so HA-pushed brightness syncs
# (which call set_value on the encoder and trigger on_value but pass the guard
# because encoder == HA value) don't wake the LEDs at night.
- if:
condition:
lambda: 'return (id(active_light) == 0 && (int)((id(bright1).state / 2.55) + 0.5) != x);'
then:
- homeassistant.service:
service: light.turn_on
data:
entity_id: ${Active_Light1}
brightness_pct: !lambda 'return x;'
- script.execute: wake_leds
- if:
condition:
lambda: 'return (id(active_light) == 1 && (int)((id(bright2).state / 2.55) + 0.5) != x);'
then:
- homeassistant.service:
service: light.turn_on
data:
entity_id: ${Active_Light2}
brightness_pct: !lambda 'return x;'
- script.execute: wake_leds
# BRIGHTNESS SYNC — HA pushes the active light's brightness back to us.
# We use this to keep the encoder position in sync with the actual light level,
# so that the first turn of the dial doesn't jump from a stale position.
#
# IMPORTANT: we only update the encoder when THIS light is the active one.
# Without the active_light guard, changes to the inactive light (e.g. from a
# phone or schedule) would silently snap the encoder to the wrong position.
- platform: homeassistant
id: bright1
entity_id: ${Active_Light1}
attribute: brightness # HA brightness is 0–255; we convert to 0–100 for the encoder
on_value:
then:
- if:
condition:
# Guard: only sync if Light1 is active AND brightness is valid AND encoder differs
lambda: 'return (id(active_light) == 0 && x >= 0 && x <= 255 && (int)((x / 2.55) + 0.5) != (int)id(myrotary_encoder).state);'
then:
- sensor.rotary_encoder.set_value:
id: myrotary_encoder
value: !lambda 'return (int)((x / 2.55) + 0.5);'
- logger.log: "Encoder synced to Light1 brightness"
- platform: homeassistant
id: bright2
entity_id: ${Active_Light2}
attribute: brightness
on_value:
then:
- if:
condition:
# Guard: only sync if Light2 is active AND brightness is valid AND encoder differs
lambda: 'return (id(active_light) == 1 && x >= 0 && x <= 255 && (int)((x / 2.55) + 0.5) != (int)id(myrotary_encoder).state);'
then:
- sensor.rotary_encoder.set_value:
id: myrotary_encoder
value: !lambda 'return (int)((x / 2.55) + 0.5);'
- logger.log: "Encoder synced to Light2 brightness"
################################################################################
# TEXT SENSORS
################################################################################
text_sensor:
- platform: wifi_info
ip_address:
name: IP Address
id: ${device_internal_name}_ip_address
ssid:
name: Connected SSID
id: ${device_internal_name}_connected_ssid
mac_address:
name: Mac Wifi Address
id: ${device_internal_name}_mac_address
- platform: version
name: "ESPHome Version"
hide_timestamp: true
- platform: template
name: Uptime Human Readable
id: ${device_internal_name}_uptime_human
icon: mdi:clock-start
################################################################################
# SWITCHES — Remote actions available from Home Assistant
################################################################################
switch:
- platform: restart
name: "Restart"
id: device_restart
- platform: safe_mode
name: Use Safe Mode
id: device_safe_mode
################################################################################
# OUTPUTS — PWM channels driving the indicator LEDs
################################################################################
output:
- platform: esp8266_pwm
id: ActiveLed1
pin: GPIO13 # Green LED — indicates Light1 is active
- platform: esp8266_pwm
id: ActiveLed2
pin: GPIO15 # Yellow LED — indicates Light2 is active (pin remapped from GPIO00 — failed on rebuild)
################################################################################
# LIGHTS — Monochromatic LED entities with pulse effects for the boot animation
################################################################################
light:
- platform: monochromatic
name: "GreenLed"
id: GrnLed
output: ActiveLed1
default_transition_length: 0s # Snap on/off — these are status indicators, not mood lighting
effects:
- pulse:
- pulse:
name: "Fast Pulse"
transition_length: 0.5s
update_interval: 0.5s
min_brightness: 0%
max_brightness: 100%
- platform: monochromatic
name: "YellowLed"
id: YlwLed
output: ActiveLed2
default_transition_length: 0s # Snap on/off — these are status indicators, not mood lighting
effects:
- pulse:
- pulse:
name: "Fast Pulse"
transition_length: 0.5s
update_interval: 0.5s
min_brightness: 0%
max_brightness: 100%
Enjoy and let me know if you have improvements!