Turning a Glitter Swirl Lamp into a Smart Lamp

A cheap glitter swirl lamp, an ESP32-C3 and one 2N2222 transistor doing the job of an electronic finger - and it now answers to the Zigbee button next to my daughter's bed.

Cover: Glitter Lamp to Smart Lamp, with badges for ESP32-C3, 2N2222, 6 LEDs and Zigbee button, above photos of the real lamp and its installed dev board

Weekend projects are the best projects. This one started with a cheapish glitter swirl lamp from Yokico — the kind that sits on a bedside table, swirls glitter around in water and glows in whatever colour you cycle it to. My daughter loves it. What I don’t love is that it’s completely dumb: one button on the front, no app, no automation, no nothing.

I didn’t buy it to make it smart, either. It was bought as a lamp or night light. What actually made my brain tick was noticing the screws on the base. Screws mean it opens. Most of these things are moulded shut or clipped together, and you normally discover which by breaking a plastic clip trying to get in — which is the point where the project ends before it starts. This one had visible screws, and the thought arrived fully formed: I can get inside this.

So I made it smart. An ESP32-C3 devkit and a single 2N2222 transistor later, the glitter twirler is a fully fledged Home Assistant device — and it now answers to the ZigBee button next to her bed (Since she does not have HA App or phone yet).

First, the lamp itself

It’s a Yokico Glitter Swirl Lamp — nothing exotic. The specs that mattered to this project:

  • Six LEDs in the base, which produce eight colour modes plus Off — nine states in total
  • One button on the front that cycles forward through them. Keep pressing and eventually you land on Off
  • Powered by 3 × AA batteries or a USB cable (the USB cable is included)
  • Inside, a propeller driven by a magnet swirls glitter around in the water — that’s the part my daughter actually cares about
  • 9 × 9 × 25cm, ABS plastic, not glass

That single button is the entire problem. It’s also, as it turns out, the entire solution — because if a human can press a button, a microcontroller can press it too.

The hardware: a transistor as an electronic finger

Everyone expects the hard part of a project like this to be the coding. It isn’t. The hard part is getting at the electronics without wrecking a lamp your daughter likes.

The approach I settled on is about as simple as it gets. I didn’t try to intercept the LEDs or reverse-engineer the lamp’s own controller. Instead, I treated the existing button as the interface it already is: a 2N2222 NPN transistor sits across the lamp’s button contacts and acts as an electronic finger, wired to GPIO2 on the ESP32-C3. Pulse that pin and the transistor conducts, and as far as the lamp is concerned, somebody pressed the button.

No soldering irons near the LED driver, no guessing at voltages the lamp wasn’t designed for. The ESP32 just presses the button, in the right order, at the right speed — an 80ms pulse followed by a 350ms gap, which turned out to be the timing the lamp reliably registers.

The transistor in question is a 2N2222 in a TO-92 package — emitter, base, collector, three legs, no ambiguity about which way round it goes. The pinout diagram is in the upgrade gallery below (diagram credit: componentsinfo.com).

Where do you hide a dev board in a glitter lamp?

In the battery compartment, obviously.

The lamp runs perfectly well from USB — I will never use it on batteries — so the cavity where the three AAs would have lived was dead space doing nothing. An ESP32-C3 devkit fits in there neatly, which means the lamp stays externally unchanged. There’s no extra box glued to the side, no dangling wires, no “what’s that?” from anyone walking past. From the outside it’s still just a glitter lamp.

While it’s in there doing nothing, it also runs as a Bluetooth proxy for Home Assistant. The sketch enables esp32_ble_tracker with bluetooth_proxy: active: true, which makes this lamp one more BLE proxy in the house — and there are a good few of them now, all running on ESP32-C3 boards.

Those proxies exist for one reason: room presence. Between them they track my phone from room to room, and when I open the Home Assistant app the dashboard is already showing the room I’m standing in. In practice the room I’m in is the room I see when the app opens — which sounds like a small thing until you’ve scrolled past the wrong dashboard one time too many. This lamp just improved that coverage in one room, for the cost of a board that was already powered and already positioned there.

What the ESPHome sketch exposes to Home Assistant

The sketch publishes five controls:

  • Glitter Lamp Toggle — a plain on/off switch that mirrors whether the lamp is lit. Flip it off and the sketch drives the lamp to Off for you; flip it on while it’s off and it walks back to Rainbow. It’s an ordinary switch entity, so it drops into a Home Assistant helper group or a dashboard button the same as any other switch, and the sketch pushes its state outward on every single change so the group can’t drift out of sync with the lamp
  • Lamp Current Color — a selector with all nine states: Rainbow, Red, Green, Blue, Green & Red, Green & Blue, Purple, White and Off. Pick one and the lamp goes to it
  • Lamp Next Color — steps forward one colour, exactly like pressing the physical button. From Off it wraps back around to Rainbow
  • Lamp Turn Off — sends the lamp to Off automatically, so you don’t sit there pressing a button eight times yourself
  • Reset Sync to Off State — the calibration button, explained below, and the one that saves this build

The catch: physical buttons and digital state don’t mix

Here’s the honest weakness of any build like this. The lamp has no idea what colour it’s on. It has no state to report and nothing to read back. It has a button, and it moves to the next colour when you press it. That’s the whole interface.

So the sketch keeps its own score, with a pair of global variables: one for where the lamp thinks it is, and one for where you’ve just asked it to go. Every command works out how many clicks are needed to get from one to the other — using modular arithmetic, so asking for White from Rainbow and asking for Rainbow from White both produce the right number of presses:

int clicks_needed = (target - current + 9) % 9;

That works perfectly — right up until someone presses the physical button on the lamp. Now Home Assistant’s idea of the current colour is wrong, and every command afterwards is off by however many presses happened. You press “Red” in the app and get something else entirely.

Hence two recovery paths:

  • Reset Sync to Off State. This does no clicking at all — it simply sets the tracked index to Off and publishes that state. You use it when the lamp is genuinely off but Home Assistant thinks otherwise, so the two agree again without a single wasted press.
  • Unplug it and plug it back in. This is the bit I’m most pleased with. The lamp has an unavoidable quirk: on power-up it “twitches” straight into Rainbow mode before anything can stop it. So the sketch waits 1.5 seconds for that twitch to settle, then drives the lamp all the way to Off by itself. Both the hardware and Home Assistant start from a known state every single time — no manual button-stabbing.

It’s not elegant, but it’s honest. A complete fix would mean reading the lamp’s own LED driver, which is a much bigger job than a weekend.

The best part: her bedside button now runs it

My daughter already had a Sonoff ZigBee button next to her bed for her lights. Making the glitter lamp part of that was the moment this went from “neat hack” to something she actually uses every night. Three gestures on one button:

  • Single press — toggles her bedside lamp
  • Double press — turns the glitter lamp on, and every double press after that behaves exactly like pressing the button on the lamp: next colour, and next colour again
  • Long press — toggles the main bedroom light

So the lamp is no longer a thing she has to walk over and press. It’s on her bedside button with everything else, and it does the one thing a glitter lamp is for: being pretty at bedtime without her having to get up.

The ESPHome sketch

Here’s the YAML, in full. Swap the secrets for your own Wi-Fi credentials and it should drop straight into ESPHome:

esphome:
  name: ava-glitter-lamp
  name_add_mac_suffix: false
  friendly_name: Ava Glitter Lamp
  on_boot:
    priority: -10
    then:
      - delay: 1.5s 
      - logger.log: "Correcting hardware boot twitch. Turning lamp off..."
      - select.set:
          id: color_selector
          option: "Off"

esp32:
  board: esp32-c3-devkitc-02
  variant: esp32c3
  framework: 
    type: arduino
  
logger:
api:
ota:
  - platform: esphome
    
wifi:
  ssid: !secret wifi_ssid
  password: !secret wifi_password

esp32_ble_tracker:
bluetooth_proxy:
  active: true

# ==============================================================================
# 1. CORE TRACKERS
# ==============================================================================
globals:
  - id: current_color_index
    type: int
    restore_value: no
    initial_value: '0' # Tracks that hardware twitch defaults to Rainbow
    
  - id: target_color_index
    type: int
    restore_value: no
    initial_value: '0'

# ==============================================================================
# 2. HARDWARE OUTPUT SWITCH & EXPORTED TOGGLE SWITCH
# ==============================================================================
switch:
  - platform: gpio
    pin: GPIO2
    id: transistor_switch
    inverted: false

  - platform: template
    name: "Glitter Lamp Toggle"
    id: glitter_lamp_light
    icon: "mdi:lightbulb-glitter"
    lambda: |-
      return id(current_color_index) != 8;
    turn_on_action:
      - lambda: |-
          if (id(current_color_index) == 8) {
            ESP_LOGI("custom", "Light flipped ON -> Targeting Rainbow");
            id(target_color_index) = 0; 
            id(jump_to_color_index).execute();
          }
    turn_off_action:
      - lambda: |-
          if (id(current_color_index) != 8) {
            ESP_LOGI("custom", "Light flipped OFF -> Targeting Off State");
            id(target_color_index) = 8; 
            id(jump_to_color_index).execute();
          }

# ==============================================================================
# 3. INTERACTIVE HOME ASSISTANT SELECTION ENTITY
# ==============================================================================
select:
  - platform: template
    name: "Lamp Current Color"
    id: color_selector
    options:
      - "Rainbow"
      - "Red"
      - "Green"
      - "Blue"
      - "Green & Red"
      - "Green & Blue"
      - "Purple"
      - "White"
      - "Off"
    initial_option: "Rainbow" 
    optimistic: true 
    set_action:
      - lambda: |-
          const auto &options = id(color_selector).traits.get_options();
          int target_idx = 8; 
          for (size_t i = 0; i < options.size(); i++) {
            if (options[i] == x) {
              target_idx = i;
              break;
            }
          }
          id(target_color_index) = target_idx;
          id(jump_to_color_index).execute();

# ==============================================================================
# 4. CORE AUTOMATION SCRIPTS
# ==============================================================================
script:
  # Master navigation script
  - id: jump_to_color_index
    mode: single
    then:
      - lambda: |-
          int current = id(current_color_index);
          int target = id(target_color_index);
          
          if (current != target) {
            int clicks_needed = (target - current + 9) % 9;
            
            for(int i = 0; i < clicks_needed; i++) {
              id(transistor_switch).turn_on();
              delay(80);
              id(transistor_switch).turn_off();
              delay(350); 
            }
            id(current_color_index) = target;
          }
          // Safely broadcast updates outward to UI components
          id(color_selector).publish_state(id(color_selector).traits.get_options()[target]);
          
          // Instantly pushes the state changes up to your helper groups
          id(glitter_lamp_light).publish_state(target != 8);
# Manual Next-Step Cycle Button Handler
  - id: single_click
    mode: queued
    then:
      - switch.turn_on: transistor_switch
      - delay: 80ms
      - switch.turn_off: transistor_switch
      - delay: 350ms
      - lambda: |-
          id(current_color_index) += 1;
          if (id(current_color_index) > 8) {
            id(current_color_index) = 0;
          }
          int updated_idx = id(current_color_index);
          id(color_selector).publish_state(id(color_selector).traits.get_options()[updated_idx]);
          
          // Forces toggle visibility updates when clicking color by color
          id(glitter_lamp_light).publish_state(updated_idx != 8);

# ==============================================================================
# 5. EXPOSED UTILITY CONTROL BUTTONS
# ==============================================================================
button:
  # Next Color Manual Step
  - platform: template
    name: "Lamp Next Color"
    icon: "mdi:palette"
    on_press:
      - script.execute: single_click

  # Master Absolute Off
  - platform: template
    name: "Lamp Turn Off"
    icon: "mdi:power"
    on_press:
      - lambda: |-
          id(target_color_index) = 8;
          id(jump_to_color_index).execute();

  # Maintenance Re-Calibration Reset
  - platform: template
    name: "Reset Sync to Off State"
    icon: "mdi:refresh"
    on_press:
      - lambda: |-
          id(current_color_index) = 8; 
          id(color_selector).publish_state("Off"); 
          id(glitter_lamp_light).publish_state(false);

What I’d tell anyone doing this

  • Don’t fight the original controller. Using the existing button as the interface is the least invasive path, and it survives you getting the electronics wrong. Worst case, the lamp still works as a lamp.
  • Find the dead space. Battery compartments are free real estate when a device lives on USB. Add a Bluetooth proxy while you’re in there.
  • Plan for the state to be wrong eventually. Anything you drive by counting button presses will drift, because the world has other fingers in it. Build the reset button before you need it.
  • Expect a boot twitch. If the device has its own power-on behaviour, your sketch has to wait for it and then correct it — a short delay on boot beats fighting the hardware.

First, the lamp as it comes — an off-the-shelf glitter swirl unit with a single button and no smarts whatsoever. Retail box specs: six LEDs, eight colours plus off, powered by USB or 3 × AA, and glitter suspended in liquid inside ABS plastic that very much wants you to leave it sealed.

0:00
/0:00
The glitter swirling in Rainbow mode

The upgrade

Ordered as the job went: the empty battery bay and the LED board first, then the guts and the ESP32 going in, and the finished lamp at the end — including the desk shot with the rainbow gradient — and, at the end, the 2N2222 pinout itself.

0:00
/0:00
The finished lamp in her room — glitter swirling in Rainbow mode

The whole thing cost almost nothing beyond the lamp, the dev board I already had, took a couple hours, and turned a gadget that did one thing into a device my daughter controls from bed without opening her eyes properly. That’s a good weekend.

The finished lamp, in her room

Same corner shelf above her bed, in three of the colours — green, blue and red — and two clips of it running through the modes. This is the view she has from bed when she double-taps the Zigbee button on her bedside table.

0:00
/0:00
Colour cycling in her room — the glitter catching the light as the lamp steps through modes
0:00
/0:00
A shorter run through the colours, from red to green to blue