If you’ve read any of my recent posts, you’ll probably have gathered that I find single-purpose remote controls annoying. It won’t be a surprise, then, that when a string of twinkle lights controlled by an infrared remote (basically these) stopped turning on, I wasn’t all that upset.

Stolen from the linked Amazon listing

I had two problems with these lights:

  1. I hate remotes.
  2. The controller had no persistent storage, so the power and mode states reset every time the light string was unplugged. This was unsurprising and even perfectly reasonable, but it meant that I couldn’t replace the remote with a simple analog lamp timer.

Since I had ESP32 dev boards and recent experience with HomeKit left over from my last project, it was easy enough to replace the broken LED controller, trading infrared control for Wi-Fi and Siri/Home app support.

The light string consists of 100 LEDs wired in parallel between two thin enamelled wires (originally attached at L1 and L2 on the stock controller pictured above). In some two-wire light strips, each LED is controlled by a tiny addressable chip, but this light string is less complicated and probably much cheaper. There’s nothing special about the LEDs, but the polarity of every other one is reversed, so only half the string lights up at a time depending on which direction current is applied in. This enables some basic alternating patterns; to produce the illusion of lighting up the entire string at once, you just have to switch back and forth really fast.

According to the datasheet, the ESP32’s GPIO pins are rated for 40 mA source and 28 mA sink current. I found that at 3.3 V and with a 33 Ω resistor in series, the LED string drew 21 mA, safely within the acceptable range.

I soldered the LED string and resistor to the ESP32 and wrote a relatively simple Arduino + HomeSpan sketch to control the lights. It presents them to HomeKit as a single light bulb which can be on or off and have a brightness from 1 to 8, each step increasing the frequency of switching between the two sets of LEDs (the fastest/brightest setting appears solid).

It works pretty well, with a few potential areas for improvement (at least this time the ESP32 is easily accessible for reprogramming):

  1. The LEDs are a bit dimmer than they were with the stock controller. I might try to bump up the current by adding a couple transistors to drive the LEDs, rather than powering them directly from the GPIO pins.
  2. I didn’t implement any of the fancy brightness fading patterns that the stock controller supported, just simple flashing.
  3. Why not add support for the infrared remote? 🤷

led_string.ino

1
#include "HomeSpan.h"
2
#include <nvs_flash.h>
3
4
#include "DEV_LedString.h"
5
6
#define STATUS_LED_PIN 2
7
#define RESET_BUTTON_PIN 13
8
#define STATUS_AUTO_OFF 300
9
#define AP_AUTO_OFF 300
10
#define WEBLOG_ENTRIES 25
11
#define LOG_LEVEL 0
12
13
#define LED1 22
14
#define LED2 23
15
16
void setup() {
17
  Serial.begin(115200);
18
19
  configureResetButton();
20
  configureHomeSpan();
21
}
22
23
void loop() {
24
  pollResetButton();
25
  homeSpan.poll();
26
}
27
28
void configureHomeSpan() {
29
  homeSpan.setStatusPin(STATUS_LED_PIN);
30
  homeSpan.setStatusAutoOff(STATUS_AUTO_OFF);
31
32
  homeSpan.enableWebLog(WEBLOG_ENTRIES,"pool.ntp.org","UTC","log");
33
  homeSpan.setLogLevel(LOG_LEVEL);
34
35
  homeSpan.setApSSID("LEDString-Setup");
36
  homeSpan.setApTimeout(AP_AUTO_OFF);
37
  homeSpan.enableAutoStartAP();
38
39
  homeSpan.begin(Category::Lighting, "LED String", "LEDString", "LEDString");
40
41
  new SpanAccessory();
42
  
43
    new Service::AccessoryInformation();
44
      new Characteristic::Identify();
45
46
    new DEV_LedString(LED1, LED2);
47
}
48
49
void configureResetButton() {
50
  pinMode(RESET_BUTTON_PIN, INPUT_PULLUP);
51
}
52
53
void pollResetButton() {
54
  if (digitalRead(RESET_BUTTON_PIN) == LOW) {
55
    Serial.print("\n*** Clearing non-volatile storage and restarting...\n\n");
56
    nvs_flash_erase();
57
    ESP.restart();
58
  }
59
}

DEV_LedString.h

1
#define FLICKER_STEPS 8
2
3
const uint32_t steps[FLICKER_STEPS] = {
4
  6400,
5
  3200,
6
  1600,
7
  800,
8
  400,
9
  200,
10
  100,
11
  10,
12
};
13
14
struct DEV_LedString : Service::LightBulb {
15
  uint8_t pin1;
16
  uint8_t pin2;
17
  
18
  SpanCharacteristic *power;
19
  SpanCharacteristic *level;
20
21
  uint32_t wait;
22
  uint32_t timer;
23
24
  bool ledMode = false;
25
26
  DEV_LedString(uint8_t pin1, uint8_t pin2) : Service::LightBulb() {
27
    this->pin1 = pin1;
28
    this->pin2 = pin2;
29
30
    pinMode(pin1, OUTPUT);
31
    pinMode(pin2, OUTPUT);
32
    
33
    this->power = new Characteristic::On(false, true);
34
    this->level = new Characteristic::Brightness(FLICKER_STEPS, true);
35
    level->setRange(0, 100, 1);
36
37
    switchLeds();
38
  }
39
40
  bool update() {
41
    if (power->getNewVal()) {
42
      WEBLOG("Setting power to ON, level to %d", step(level->getNewVal()));
43
    } else {
44
      WEBLOG("Setting power to OFF");
45
    }
46
    switchLeds();
47
    return true;
48
  }
49
50
  void loop() {
51
    if (power->getVal()) {
52
      if (checkTimer()) {
53
        switchLeds();
54
      }
55
    } else {
56
      digitalWrite(pin1, LOW);
57
      digitalWrite(pin2, LOW);
58
    }
59
  }
60
61
  bool checkTimer() {
62
    return millis() - timer > wait;
63
  }
64
65
  void switchLeds() {
66
    ledMode = !ledMode;
67
    digitalWrite(pin1, ledMode);
68
    digitalWrite(pin2, !ledMode);
69
70
    wait = steps[step(level->getNewVal())];
71
    timer = millis();
72
  }
73
74
  uint32_t step(uint32_t percent) {
75
    return (percent - 1) * FLICKER_STEPS / 100;
76
  }
77
};