Switching to CPU Ticks Did Not Eliminate Wobble Because Fan Itself Was Wobbling

In order to obey constraints imposed by ESP32 hardware timer peripheral, I switched my fan strobe LED code to use a regularly polling interrupt service routine (ISR). I worried that I would be overly demanding to schedule it once every 10 microseconds, but I saw no obvious problems from that front. What I did see, though, is an irregular wobble at longer LED pulse delays indicating my LED strobe timing is drifting in and out of sync with fan blade position.

Because I had calculated timing for LED pulses by counting number of times my ISR is executed, I thought this wobble might be caused by unpredictable delays in ISR overhead. A multi-millisecond strobe delay (where I notice the wobble) would require my ISR to count several hundred times, allowing tiny errors to add up. To test this hypothesis, I switched my code to use a more consistent timing mechanism: the ESP32 CPU tick counter. It is a highly precise (though not necessarily accurate) source of timing information, one that I used for performance metrics in ESP_8_BIT_Composoite. But it comes with limitations including:

  • Tick counter value is only valid on the same core it is queried from, it is not valid to compare tick count across the two cores of an ESP32. Not a problem here because Arduino subsystem is pinned to a single core, so I am guaranteed to always be on the same core.
  • It goes up by one for each clock cycle of the CPU core. At 240MHz, an unsigned 32-bit value would overflow two or three times every minute. For this quick experiment I’m ignoring overflows, resulting in a brief dark pulse two or three times per minute. (This limitation was why I didn’t use it in my first draft.)

Switching my timer counter to CPU ticks did not eliminate the wobble, disproving my hypothesis. What else could it be? Thinking through possible explanations, I wished I could see exact time relationship between tachometer signal and my LED strobe pulses. Then I remembered I could do exactly that because I have a real oscilloscope now! In fact, I should have put it on scope before embarking on my CPU clock tick counter experiment. I guess I’m still not used to having this powerful tool at hand.

I set up the oscilloscope so I could see the fan tachometer pulse that would trigger my tachometer interrupt handler, where I calculate the ticks for turning LED on and off. I set up the oscilloscope to trigger on my resulting LED output pulse. I increased the LED pulse delay to roughly 6 milliseconds, which is a roughly 180 degree rotation. Placing that pulse close to the next fan tachometer pulse allowed me to easily compare their timing on oscilloscope screen.

As it turned out, my ESP32 code is completely blameless. The timing held steady between tachometer signal triggering pulse and LED output pulse. What changed are timing between consecutive fan tachometer pulses. In other words: my strobe light visual is wobbling because fan speed is wobbling. This is exactly why strobe lights are useful to diagnose certain problems with high-speed machinery: it makes subtle problems visible to the human eye.

Conclusion: the wobble is not a bug, it is a feature!


Custom component code using CPU tick counter. (Does not account for 32-bit tick overflow):

#include "esphome.h"
#include "esp_system.h"

volatile int evenOdd;
volatile int strobeDuration;
volatile int strobeDelay;
volatile uint32_t ccountTachometer;
volatile uint32_t ccountLEDOn;
volatile uint32_t ccountLEDOff;

const int gpio_led = 23;
const int gpio_tach = 19;

// How many cycles per microsecond. 240 for typical ESP32 speed of 240MHz.
const int clockSpeed = 240;

hw_timer_t* pulse_timer = NULL;

IRAM_ATTR void pulse_timer_handler() {
  uint32_t ccountNow = xthal_get_ccount();

  if (ccountNow >= ccountLEDOn) {
    digitalWrite(gpio_led, HIGH);
    ccountLEDOn = 0;
  }

  if (ccountNow >= ccountLEDOff) {
    digitalWrite(gpio_led, LOW);
    ccountLEDOff = 0;
  }
}

IRAM_ATTR void tach_pulse_handler() {
  if (0 == evenOdd) {
    evenOdd = 1;
  } else {
    if (strobeDuration > 0 && ccountLEDOn == 0 && ccountLEDOff == 0) {
      ccountTachometer = xthal_get_ccount();

      // Calculate time for turning LED on and off
      ccountLEDOn = ccountTachometer + strobeDelay * clockSpeed;
      ccountLEDOff = ccountLEDOn + strobeDuration * clockSpeed;
    }
    evenOdd = 0;
  }
}

class FanStrobeLEDSetupComponent : public Component {
  public:
    void setup() override {
      // Initialize variables
      strobeDelay = 10;
      strobeDuration = 20;
      ccountLEDOn = 0;
      ccountLEDOff = 0;

      // LED power transistor starts OFF, which is LOW
      pinMode(gpio_led, OUTPUT);
      digitalWrite(gpio_led, LOW);

      // Attach interrupt to tachometer wire
      pinMode(gpio_tach, INPUT_PULLUP);
      evenOdd = 0;
      attachInterrupt(digitalPinToInterrupt(gpio_tach), tach_pulse_handler, RISING);

      // Configure hardware timer
      pulse_timer = timerBegin(0, 80, true);
      timerAttachInterrupt(pulse_timer, &pulse_timer_handler, true);
      timerAlarmWrite(pulse_timer, 10, true /* == autoreload */);
      timerAlarmEnable(pulse_timer);
    }
};

class FanStrobeLEDEvenOddToggleComponent: public Component, public Switch {
  public:
    void write_state(bool state) override {
      evenOdd = !evenOdd;
      publish_state(state);
    }
};

class FanStrobeLEDDelayComponent: public Component, public FloatOutput {
  public:
    void write_state(float state) override {
      strobeDelay = 11000*state;
    }
};

class FanStrobeLEDDurationComponent: public Component, public FloatOutput {
  public:
    void write_state(float state) override {
      strobeDuration = 1000*state;
    }
};

Polling ESP32 Timer Shows Wobble

I wanted to use my fan strobe LED as a learning project to work with ESP32 hardware timers, but my original idea required modifying timer configuration from inside a timer alarm ISR. I have learned that is not allowed, and I have the crash stacks to prove it. The ESP32 Arduino Core timer API is geared towards timer alarm interrupts firing at regular intervals to run the same code repeatedly, so I should change my code structure to match.

Since the tachometer signal timing, strobe delay, and strobe duration could all change at runtime, I couldn’t use any of those factors as my hardware timer interval. Switching to a regular timing interval meant I would have to poll current status to determine what to do about state of LED illumination.

For the first draft, I kept a counter of how many times my ISR has executed. This variable is only updated by the ISR. Based on this counter value, each tachometer ISR calculates the time for LED to be turned on and back off in terms of timer ISR counter. Timer ISR compares those values to turn the LED on, then back off. When a particular pulse is complete, the timer ISR resets all counters to zero and waits for the next tachometer pulse to occur.

I tried a timer ISR duration of polling once every 20 microseconds. This meant a worst-case scenario of LED pulse starting as much as 20 microseconds later than specified, and a pulse duration as much as 20 microseconds longer than specified. When the ideal duration is around 200-250 microseconds, this is nearly 10% variation and resulted in visible flickering. I then tried 10 microseconds, and the flickering became less bothersome. On an ESP32 running at 240MHz, it meant I demand my ISR run once every 2400 clock cycles. I’m worried this is too demanding, but I don’t have a better idea at the moment. At least it hasn’t crashed my ESP32 after several minutes of running.

This new mechanism also allowed me to increase my LED strobe delay beyond a millisecond, but I noticed an irregular wobbling for longer delays. Those strobes of light are not happening at my calculated location. They vary by only a few degrees but very definitely noticeable. Is this a sign that my ISR is too demanding? For a multi-millisecond LED strobe delay, a 10-microsecond timer ISR would execute hundreds of times. If there’s a tiny but unpredictable amount of delay on each, they might add up to cause a visible wobble. Perhaps I should use a different counter with better consistency.


Custom component code with timer counter:

#include "esphome.h"
#include "esp_system.h"

volatile int evenOdd;
volatile int strobeDuration;
volatile int strobeDelay;
volatile int timerCount;
volatile int timerLEDOn;
volatile int timerLEDOff;

const int gpio_led = 23;
const int gpio_tach = 19;

hw_timer_t* pulse_timer = NULL;

IRAM_ATTR void timerCountersReset() {
  timerCount = 0;
  timerLEDOn = 0;
  timerLEDOff = 0;
}

IRAM_ATTR void pulse_timer_handler() {
  if (timerCount >= timerLEDOn) {
    digitalWrite(gpio_led, HIGH);
  }

  if (timerCount >= timerLEDOff) {
    digitalWrite(gpio_led, LOW);
    timerCountersReset();
  } else {
    timerCount++;
  }
}

IRAM_ATTR void tach_pulse_handler() {
  if (0 == evenOdd) {
    evenOdd = 1;
  } else {
    if (timerLEDOn == 0 && strobeDuration > 0) {
      // Calculate time for turning LED on and off
      timerLEDOn = timerCount + strobeDelay;
      timerLEDOff = timerLEDOn + strobeDuration;
    }
    evenOdd = 0;
  }
}

class FanStrobeLEDSetupComponent : public Component {
  public:
    void setup() override {
      // Initialize variables
      strobeDelay = 10;
      strobeDuration = 20;
      timerCountersReset();

      // LED power transistor starts OFF, which is LOW
      pinMode(gpio_led, OUTPUT);
      digitalWrite(gpio_led, LOW);

      // Attach interrupt to tachometer wire
      pinMode(gpio_tach, INPUT_PULLUP);
      evenOdd = 0;
      attachInterrupt(digitalPinToInterrupt(gpio_tach), tach_pulse_handler, RISING);

      // Configure hardware timer
      pulse_timer = timerBegin(0, 80, true);
      timerAttachInterrupt(pulse_timer, &pulse_timer_handler, true);
      timerAlarmWrite(pulse_timer, 10, true /* == autoreload */);
      timerAlarmEnable(pulse_timer);
    }
};

class FanStrobeLEDEvenOddToggleComponent: public Component, public Switch {
  public:
    void write_state(bool state) override {
      evenOdd = !evenOdd;
      publish_state(state);
    }
};

class FanStrobeLEDDelayComponent: public Component, public FloatOutput {
  public:
    void write_state(float state) override {
      strobeDelay = 1100*state;
    }
};

class FanStrobeLEDDurationComponent: public Component, public FloatOutput {
  public:
    void write_state(float state) override {
      strobeDuration = 100*state;
    }
};

ESP32 Timer ISR Not Allowed to Call Timer API

Conceptually I knew using delayMicroseconds() inside an interrupt service routine (ISR) was a bad idea. But I tried it anyway on an ESP32 and now I have a crash stack to prove it was indeed a bad idea. A solid reason to switch over to using hardware timers. My fan strobe LED project has two needs: the first delay is between tachometer wire signal and turning LED on (this delay may be zero) followed by a second delay before turning LED back off. Duration of both of these delays vary in order to produce desired visual effects. Translating this concept directly into timers results in this pseudocode:

  1. Interrupt handler triggered by tachometer signal. Starts timer that calls step 2 when first it ends.
  2. Interrupt handler triggered by expiration of first delay. Turn on LED and start timer that calls step 3 when it ends.
  3. Interrupt handler triggered by expiration of second delay, turn off LED.

Unfortunately, that direct translation doesn’t work. At least not with ESP32 Arduino Core Timer APIs. The timer alarm callback (“call step 2” and “call step 3” above) is specified at the time an alarm timer is configured, and not something I can change in between steps 2 and 3 above. Furthermore, I had no luck changing the timer duration in between calls, either. Either the change has no effect (duration doesn’t change) or the timer alarm interrupt stops triggering entirely. The only way I got something resembling my initial pseudocode is to tear down and rebuild a timer on every call to step 1, then do it again on step 2, and repeat.

That code felt wrong, and it didn’t take long for me to see concrete evidence it was wrong. I could adjust my delay parameters a few times, but within 2-3 minutes of running this sketch my ESP32 would crash with the following stack:

WARNING Found stack trace! Trying to decode it
WARNING Decoded 0x40088dcc: invoke_abort at /home/runner/work/esp32-arduino-lib-builder/esp32-arduino-lib-builder/esp-idf/components/esp32/panic.c:715
WARNING Decoded 0x40089049: abort at /home/runner/work/esp32-arduino-lib-builder/esp32-arduino-lib-builder/esp-idf/components/esp32/panic.c:715
WARNING Decoded 0x40089dbf: xQueueGenericReceive at /home/runner/work/esp32-arduino-lib-builder/esp32-arduino-lib-builder/esp-idf/components/freertos/queue.c:2038
WARNING Decoded 0x400f1001: removeApbChangeCallback at /data/cache/platformio/packages/framework-arduinoespressif32/cores/esp32/esp32-hal-cpu.c:224
WARNING Decoded 0x400817f9: timerEnd at /data/cache/platformio/packages/framework-arduinoespressif32/cores/esp32/esp32-hal-timer.c:174
WARNING Decoded 0x40081095: pulse_off_handler() at /config/esphome/.esphome/build/fan-strobe-led/src/esphome/core/gpio.h:62
WARNING Decoded 0x400814c9: __timerISR at /data/cache/platformio/packages/framework-arduinoespressif32/cores/esp32/esp32-hal-timer.c:174
WARNING Decoded 0x400874cd: _xt_lowint1 at /home/runner/work/esp32-arduino-lib-builder/esp32-arduino-lib-builder/esp-idf/components/freertos/xtensa_vectors.S:1154
WARNING Decoded 0x4008568d: esp_dport_access_int_abort at /home/runner/work/esp32-arduino-lib-builder/esp32-arduino-lib-builder/esp-idf/components/esp32/dport_access.c:216
WARNING Decoded 0x40087e63: spi_flash_enable_interrupts_caches_and_other_cpu at /home/runner/work/esp32-arduino-lib-builder/esp32-arduino-lib-builder/esp-idf/components/spi_flash/cache_utils.c:203
WARNING Decoded 0x4008857a: spi_flash_guard_end at /home/runner/work/esp32-arduino-lib-builder/esp32-arduino-lib-builder/esp-idf/components/spi_flash/flash_ops.c:172
 (inlined by) spi_flash_read at /home/runner/work/esp32-arduino-lib-builder/esp32-arduino-lib-builder/esp-idf/components/spi_flash/flash_ops.c:664
WARNING Decoded 0x401659c9: nvs::nvs_flash_read(unsigned int, void*, unsigned int) at /home/runner/work/esp32-arduino-lib-builder/esp32-arduino-lib-builder/esp-idf/components/nvs_flash/src/nvs_ops.cpp:76
WARNING Decoded 0x40164289: nvs::Page::readEntry(unsigned int, nvs::Item&) const at /home/runner/work/esp32-arduino-lib-builder/esp32-arduino-lib-builder/esp-idf/components/nvs_flash/src/nvs_page.cpp:871
WARNING Decoded 0x401642fd: nvs::Page::eraseEntryAndSpan(unsigned int) at /home/runner/work/esp32-arduino-lib-builder/esp32-arduino-lib-builder/esp-idf/components/nvs_flash/src/nvs_page.cpp:871
WARNING Decoded 0x40164979: nvs::Page::eraseItem(unsigned char, nvs::ItemType, char const*, unsigned char, nvs::VerOffset) at /home/runner/work/esp32-arduino-lib-builder/esp32-arduino-lib-builder/esp-idf/components/nvs_flash/src/nvs_page.cpp:871
WARNING Decoded 0x4016337a: nvs::Storage::eraseMultiPageBlob(unsigned char, char const*, nvs::VerOffset) at /home/runner/work/esp32-arduino-lib-builder/esp32-arduino-lib-builder/esp-idf/components/nvs_flash/src/nvs_storage.cpp:579
WARNING Decoded 0x40163751: nvs::Storage::writeItem(unsigned char, nvs::ItemType, char const*, void const*, unsigned int) at /home/runner/work/esp32-arduino-lib-builder/esp32-arduino-lib-builder/esp-idf/components/nvs_flash/src/nvs_storage.cpp:579
WARNING Decoded 0x40162b3b: nvs_set_blob at /home/runner/work/esp32-arduino-lib-builder/esp32-arduino-lib-builder/esp-idf/components/nvs_flash/src/nvs_api.cpp:547
WARNING Decoded 0x400d8407: esphome::esp32::ESP32Preferences::sync() at /config/esphome/.esphome/build/fan-strobe-led/src/esphome/components/esp32/preferences.cpp:124
WARNING Decoded 0x400e35dd: esphome::preferences::IntervalSyncer::on_shutdown() at /config/esphome/.esphome/build/fan-strobe-led/src/esphome/core/gpio.h:62
WARNING Decoded 0x400e3665: std::_Function_handler<void (), esphome::preferences::IntervalSyncer::setup()::{lambda()#1}>::_M_invoke(std::_Any_data const&) at /config/esphome/.esphome/build/fan-strobe-led/src/esphome/core/gpio.h:62
WARNING Decoded 0x400e34e6: std::function<void ()>::operator()() const at /data/cache/platformio/packages/toolchain-xtensa32/xtensa-esp32-elf/include/c++/5.2.0/functional:1710
 (inlined by) esphome::Scheduler::call() at /config/esphome/.esphome/build/fan-strobe-led/src/esphome/core/scheduler.cpp:204
WARNING Decoded 0x400e1319: esphome::Application::loop() at /config/esphome/.esphome/build/fan-strobe-led/src/esphome/core/application.cpp:69
WARNING Decoded 0x400e391e: loop() at /config/esphome/.esphome/build/fan-strobe-led/src/esphome/core/gpio.h:62
WARNING Decoded 0x400f0e29: loopTask(void*) at /data/cache/platformio/packages/framework-arduinoespressif32/cores/esp32/main.cpp:23
WARNING Decoded 0x4008a05a: vPortTaskWrapper at /home/runner/work/esp32-arduino-lib-builder/esp32-arduino-lib-builder/esp-idf/components/freertos/port.c:355 (discriminator 1)

I’m not sure what removeApbChangeCallback() meant. I found the code, but there were no code comments explaining what “Apb” is. A general web search found one potential explanation of “Advanced Peripheral Bus“. Whatever it is, it was called as part of a FreeRTOS queue receive. This was a surprise. I know FreeRTOS queues can be used from inside an ISR to schedule work to be performed outside an ISR, but that would be an ISR-safe queue send, not a receive. Hitting a receive path inside an ISR feels wrong.

Looking further down the call stack, I see NVS (nonvolatile storage) calls like I saw when I failed to use delayMicroseconds() on the stack. Why are storage APIs being called? I didn’t look into it earlier, hoping I could avoid the problem by moving to timers, but it’s clear I need to better understand what’s going on. Poking around in ESPHome code, I believe this is trying to save state of my parameters so it could be restored in case of system reset or shutdown. If so, I should be able to deactivate such behavior by setting restore_mode parameter to ALWAYS_OFF.

WARNING Found stack trace! Trying to decode it
WARNING Decoded 0x400f0ea8: initApbChangeCallback at /data/cache/platformio/packages/framework-arduinoespressif32/cores/esp32/esp32-hal-cpu.c:224
WARNING Decoded 0x400817e2: timerBegin at /data/cache/platformio/packages/framework-arduinoespressif32/cores/esp32/esp32-hal-timer.c:174
WARNING Decoded 0x4008110c: tach_pulse_handler() at /config/esphome/.esphome/build/fan-strobe-led/src/esphome/core/gpio.h:62
WARNING Decoded 0x40081185: __onPinInterrupt at /data/cache/platformio/packages/framework-arduinoespressif32/cores/esp32/esp32-hal-gpio.c:220
WARNING Decoded 0x400874dd: _xt_lowint1 at /home/runner/work/esp32-arduino-lib-builder/esp32-arduino-lib-builder/esp-idf/components/freertos/xtensa_vectors.S:1154
WARNING Decoded 0x40087c65: spi_flash_op_block_func at /home/runner/work/esp32-arduino-lib-builder/esp32-arduino-lib-builder/esp-idf/components/spi_flash/cache_utils.c:203
WARNING Decoded 0x40084f17: ipc_task at /home/runner/work/esp32-arduino-lib-builder/esp32-arduino-lib-builder/esp-idf/components/esp32/ipc.c:62
WARNING Decoded 0x4008a06a: vPortTaskWrapper at /home/runner/work/esp32-arduino-lib-builder/esp32-arduino-lib-builder/esp-idf/components/freertos/port.c:355 (discriminator 1)

The good news is that did indeed stop writes to nonvolatile storage. The bad news is that did not avoid crashing my ESP32. It’s still crashing on an Apb change callback, though this time without any NVS calls on the stack. (There’s still one line of spi_flash_op_block_func, though.) What’s going on? I decided to look deeper. Reading documentation for hardware timers in ESP-IDF (one layer below ESP32 Arduino Core) I saw the explanation: “It [ESP32 timer ISR callback] cannot call other timer APIs.” Aha! My timer ISR is not allowed to adjust and kick off another timer. This makes my initial pseudocode impossible to implement, and I have to devise a different approach.

Migrating Fan Strobe LED Project to ESP32

My fan strobe LED project has a flaw where I used a time delay inside an interrupt service routine (ISR), a bad idea. Because that blocks other housekeeping task from occurring, which leads to other hard-to-diagnose problems. I didn’t know of a good way to resolve that problem with the ESP8266 microcontroller on board the Wemos D1 Mini module I had used, but I thought the more powerful ESP32 might do the job. Fortunately, I found an ESP32 dev board electrically compatible with the Wemos D1 Mini. Hardware-wise, I just had to solder a compatibility subset of 16 out of 40 pins to make a drop-in upgrade.

The software side needed more work, but thanks to ESPHome and the Arduino framework it is built upon, most of the changes are minor. Aside from updating GPIO pin numbers in ESPHome YAML and the custom component written in Arduino C, I switched fan control from ESP8266 software PWM to ESP32’s LEDC hardware PWM peripheral.

That leaves the star of the show: the ISR that turns the LED on and off triggered by fan tachometer signal. For this initial migration, I kept all the code as-is, including those problematic calls to delayMicroseconds(). Even though I intend to replace that code, I wanted to run it as-is at first to see any problems firsthand. I had thought that perhaps I would have more leeway for my ISR abuse with dual cores, because there’d a second core available to do work while I hoarded one.

Bad news: it did not. If anything, it appears that ESP32 is even more sensitive to my abuse. Or possibly I notice it more because the ESP32 had better crash information. It’s possible my ESP8266 sketch frequently crashed the controller and rebooted without my ever noticing something went wrong. With the ESP32, I get a lot more information with fully decoded stack trace, which takes nonzero time to generate and lets me notice the crashes. I love the fact that ESPHome incorporated all of this debugging infrastructure! It’s actually less effort for me to get a decoded crash stack via ESPHome than from within Arduino IDE. Sure, I had to connect a USB cable to see crash stack over serial, losing the wireless advantage of ESPHome, but that’s no worse than using Arduino IDE.

Judging by the crashing stack trace, my call to delayMicroseconds() was translated to esphome::delay_microseconds_safe(). And the fact it crashed told me that… well, it isn’t as safe as the author intended. Further down on the call stack, I see calls to ESP32 NVS subsystem, something I have learned was an ESP32 feature absent from the ESP8266. At first glance it implies delay_microseconds_safe() is not safe when called in the middle of a NVS write operation. No matter, the objective of this exercise is to get away from using delayMicroseconds() at all, so I should start working on that.

WARNING Found stack trace! Trying to decode it
WARNING Decoded 0x400e2cac: esphome::delay_microseconds_safe(unsigned int) at /data/cache/platformio/packages/toolchain-xtensa32/xtensa-esp32-elf/include/c++/5.2.0/bits/basic_string.h:195
WARNING Decoded 0x400810a0: pulse_timer_handler() at /config/esphome/.esphome/build/fan-strobe-led/src/esphome/core/gpio.h:62
WARNING Decoded 0x4008147d: __timerISR at /data/cache/platformio/packages/framework-arduinoespressif32/cores/esp32/esp32-hal-timer.c:174
WARNING Decoded 0x40087459: _xt_lowint1 at /home/runner/work/esp32-arduino-lib-builder/esp32-arduino-lib-builder/esp-idf/components/freertos/xtensa_vectors.S:1154
WARNING Decoded 0x4008f127: esp_rom_spiflash_read_status at /home/runner/work/esp32-arduino-lib-builder/esp32-arduino-lib-builder/esp-idf/components/spi_flash/spi_flash_rom_patch.c:662
WARNING Decoded 0x4008f162: esp_rom_spiflash_wait_idle at /home/runner/work/esp32-arduino-lib-builder/esp32-arduino-lib-builder/esp-idf/components/spi_flash/spi_flash_rom_patch.c:662
WARNING Decoded 0x4008f499: esp_rom_spiflash_program_page_internal at /home/runner/work/esp32-arduino-lib-builder/esp32-arduino-lib-builder/esp-idf/components/spi_flash/spi_flash_rom_patch.c:662
WARNING Decoded 0x4008f6c9: esp_rom_spiflash_write at /home/runner/work/esp32-arduino-lib-builder/esp32-arduino-lib-builder/esp-idf/components/spi_flash/spi_flash_rom_patch.c:662
WARNING Decoded 0x40087ec1: spi_flash_write_inner at /home/runner/work/esp32-arduino-lib-builder/esp32-arduino-lib-builder/esp-idf/components/spi_flash/flash_ops.c:155
WARNING Decoded 0x40088151: spi_flash_write at /home/runner/work/esp32-arduino-lib-builder/esp32-arduino-lib-builder/esp-idf/components/spi_flash/flash_ops.c:416
WARNING Decoded 0x40165a35: nvs::nvs_flash_write(unsigned int, void const*, unsigned int) at /home/runner/work/esp32-arduino-lib-builder/esp32-arduino-lib-builder/esp-idf/components/nvs_flash/src/nvs_ops.cpp:72
WARNING Decoded 0x40163e8e: nvs::Page::writeEntry(nvs::Item const&) at /home/runner/work/esp32-arduino-lib-builder/esp32-arduino-lib-builder/esp-idf/components/nvs_flash/src/nvs_page.cpp:871
WARNING Decoded 0x401641f9: nvs::Page::writeItem(unsigned char, nvs::ItemType, char const*, void const*, unsigned int, unsigned char) at /home/runner/work/esp32-arduino-lib-builder/esp32-arduino-lib-builder/esp-idf/components/nvs_flash/src/nvs_page.cpp:871
WARNING Decoded 0x40163220: nvs::Storage::writeMultiPageBlob(unsigned char, char const*, void const*, unsigned int, nvs::VerOffset) at /home/runner/work/esp32-arduino-lib-builder/esp32-arduino-lib-builder/esp-idf/components/nvs_flash/src/nvs_storage.cpp:579
WARNING Decoded 0x401637b6: nvs::Storage::writeItem(unsigned char, nvs::ItemType, char const*, void const*, unsigned int) at /home/runner/work/esp32-arduino-lib-builder/esp32-arduino-lib-builder/esp-idf/components/nvs_flash/src/nvs_storage.cpp:579
WARNING Decoded 0x40162bbb: nvs_set_blob at /home/runner/work/esp32-arduino-lib-builder/esp32-arduino-lib-builder/esp-idf/components/nvs_flash/src/nvs_api.cpp:547
WARNING Decoded 0x400d8413: esphome::esp32::ESP32Preferences::sync() at /config/esphome/.esphome/build/fan-strobe-led/src/esphome/components/esp32/preferences.cpp:124
WARNING Decoded 0x400e3621: esphome::preferences::IntervalSyncer::on_shutdown() at /config/esphome/.esphome/build/fan-strobe-led/src/esphome/core/gpio.h:62
WARNING Decoded 0x400e36a9: std::_Function_handler<void (), esphome::preferences::IntervalSyncer::setup()::{lambda()#1}>::_M_invoke(std::_Any_data const&) at /config/esphome/.esphome/build/fan-strobe-led/src/esphome/core/gpio.h:62
WARNING Decoded 0x400e352a: std::function<void ()>::operator()() const at /data/cache/platformio/packages/toolchain-xtensa32/xtensa-esp32-elf/include/c++/5.2.0/functional:1710
 (inlined by) esphome::Scheduler::call() at /config/esphome/.esphome/build/fan-strobe-led/src/esphome/core/scheduler.cpp:204
WARNING Decoded 0x400e1325: esphome::Application::loop() at /config/esphome/.esphome/build/fan-strobe-led/src/esphome/core/application.cpp:69
WARNING Decoded 0x400e399e: loop() at /config/esphome/.esphome/build/fan-strobe-led/src/esphome/core/gpio.h:62
WARNING Decoded 0x400f0ea9: loopTask(void*) at /data/cache/platformio/packages/framework-arduinoespressif32/cores/esp32/main.cpp:23
WARNING Decoded 0x40089ff6: vPortTaskWrapper at /home/runner/work/esp32-arduino-lib-builder/esp32-arduino-lib-builder/esp-idf/components/freertos/port.c:355 (discriminator 1)

Wemos D1 Mini ESP32 Derivative

It was fun to build a LED strobe light pulsing in sync with a cooling fan’s tachometer wire. After the initial bare-bones prototype I used ESPHome to add some bells and whistles. My prototype board is built around a Wemos D1 Mini module, but I think I’ve hit a limit of hardware timers available within its onboard ESP8266 processor. The good news is that I could upgrade to its more powerful sibling ESP32 with this dev board, and its hardware compatibility means I don’t have to change anything on my prototype board to use it.

The most puzzling thing about this particular ESP32 dev format is that I’m not exactly sure where it came from. I found it as “Wemos D1 Mini ESP32” on Amazon where I bought it. (*) I also see it listed by that name as well as “Wemos D1 Mini32” on its Platform.IO hardware board support page, which helpfully links to Wemos as “Vendor”. Except, if we follow that link, we don’t see this exact board listed. The Wemos S2 Mini is very close, but I see fewer pins (32 vs. 40) and their labels indicate a different layout. Did Wemos originate this design, but since removed it for some reason? Or did someone else design this board and didn’t get credit for it?

Whatever the history of this design, putting a unit (right) next to the Wemos D1 Mini design (left) shows it is a larger board. ESP32 has a much greater number of I/O pins, so this module has 40 through-holes versus 16.

Another contributing factor for larger size is the fact all components are on a single side of the circuit board, as opposed to having components on both sides of the board. It leaves the backside open for silkscreened information for pins. Some of the pins were labeled with abbreviations I don’t understand, but probing those lines found the following:

  • Pins connected to onboard flash and not recommended for GPIO use: CMD (IO11), CLK (IO6) SD0/SDD (IO7) SD1 (IO8) SD2 (IO9) and SD3 (IO10).
  • TDI is IO12, TDO is IO15, TCK is IO13, and TMS is IO34. When not used as GPIO, these can be used as JTAG interface pins for testing and debugging.
  • SVP is IO36, and SVN is IO39. I haven’t figured out what “SVP” and “SVN” might mean. These are two of four pins on an ESP32 that are input-only. “GPI” and not “GPIO”, so to speak. (IO34 and IO35 are the other input-only pins.)

Out of these 40 pins, two are labeled NC meaning not connected, leaving 38 connections. An Espressif ESP32 DevKitC has 38 pins and it appears the same 38 are present on this module, including the aforementioned onboard flash pins and three grounds. But physical arrangement has been scrambled relative to the DevKitC to arrive at this four-column format. What was the logic behind this rearrangement? The key insight for me was that a subset of 16 pins were highlighted with white. They were arranged to be physically and electrically compatible with the Wemos D1 Mini ESP8266:

  • Reset pin RST is in the same place.
  • All power pins are at the same places: 5V/VCC, 3.3V, and one of the ground pins.
  • Serial communication lines up with UART TX/RX at the same places.
  • ESP32 can perform I2C on most of its GPIO pins. I see many examples use the convention of pins 21 for SDA and pin 22 for SCL, and they line up here with ESP8266 D1 Mini’s I2C pins D2 and D1.
  • Same deal with ESP32 SPI support: many pins are supported, but convention has uses four pins (5, 18, 19, and 23) so they’ve been lined up to their counterparts on ESP8266 D1 Mini.
  • ESP8266 has only a single pin for analog-to-digital conversion. ESP32 has more flexibility and one of several ADC-supported pins was routed to the same place.
  • ESP8266 supported hardware sleep/wake with a single pin. Again ESP32 is more flexible and one of the supported pins was routed to its place.
  • There’s a LED module hard-wired to GPIO2 onboard the ESP8266MOD. ESP-WROOM-32 has no such onboard LED, so there’s an external LED wired to GPIO2 adjacent to an always-on power LED. Another difference from ESP8266: this LED illuminates when GPIO2 is high instead of ESP8266’s onboard LED which shines when GPIO2 is low.

This is a nice piece of backwards-compatibility work. It means I can physically plug this board’s white-highlighted subset of 16 pins into any hardware expecting the Wemos D1 Mini ESP8266 board, like its ecosystem of compatible shields. Physically it will hang out the sides, but electrically things should work. Software will still have to be adjusted and recompiled for ESP32, changing GPIO numbers to match their new places. But at least those pins are all capable of the digital and analog IO pins in those places.

The only downside I see with this design? It is no longer breadboard-friendly. When all pins are soldered, all horizontally adjacent pins would be shorted together and that’s not going to work. I’m specifically eyeing one corner where reset (RST) would be connected to ground (GND). I suppose we could solder pins to just the compatibility subset of 16 pins and plug that into a breadboard, but this module is too wide for a standard breadboard. A problem shared with another ESP32 dev board format I’ve used.

And finally, like my ESP8266 Wemos D1 Mini board, these came without any pins soldered to the board. Three different types were included in the bag: pins, or sockets, or passthrough. However, the bag only included enough 20 pins of each type, which isn’t enough for all 40 pins. Strange, but no matter. I have my own collection of pins and sockets and passthrough connectors if I want to use them. And my most recent ESP32 project didn’t need these pins at all. In fact, I had to unsolder the pins that module came with. An extra step I wouldn’t need to do again, now I have these “Wemos D1 Mini ESP32” modules on hand for my experiments.


(*) Disclosure: As an Amazon Associate I earn from qualifying purchases.

Fan Strobe LED Adjustments via ESPHome

After proving my fan blade LED strobe idea worked with a minimalist Arduino sketch, I ported that code over as an ESPHome custom component. I thought it would be good practice for writing ESPHome custom components and gain Home Assistant benefit of adding adjustments in software. For me it was easier to create an analog control with a few lines of YAML and C, than it would be to wire up a potentiometer. (I recognize it may be the reverse for people more comfortable with hardware than software.)

The first adjustment “Fan Speed Control” actually did not require custom component at all: making the fan speed adjustable utilized the built-in fan speed component. In my minimalist sketch the fan is always running at full speed, now I can adjust fan speed and verify that the strobe logic indeed stays in sync with the fan speed. Meaning the strobe keeps these fan blades visually frozen in the same place regardless of their rotational speed.

The first custom output component adjustment “Strobe Duration” changes the duration of LED strobe pulse, from zero to 1000 microseconds. For this LED array, I found values under 100 to be too dim to be useful. The dimmest usable value is around 100, and things seem to work well up to 300. I start detecting motion blur above 300, and things are pretty smeared out above 600.

The next addition is a toggle switch “Strobe Tachometer Toggle”. Because the tachometer signal pulses twice per revolution, I ignore every other pulse. But that means a 50% chance the code will choose to trigger on the wrong pulse, resulting in a visually upside-down image. When this happens, this toggle allows us to flip to trigger on the opposing pulse, flipping the frozen visual right-side up.

The final custom component adjustment “Strobe Delay” adds a delay between triggered by tachometer wire and illumination of LED strobe. This changes the point at which the fan is visually frozen by the strobe light. Dynamically adjusting this value makes it look like the fan blade is slowly rotating position even though it is actually rotating at ~1200RPM. I think it is a fun effect, but to fully take advantage of that effect I will need longer delays, which means finding how I could move that work outside of my interrupt service routine. Inside the ISR I should set up a hardware timer for this delay and turn on LED when timer expires. I can then use the same mechanism to set a timer for LED duration and turn off LED when that timer expires.

Unfortunately, there are only two hardware timers on an ESP8266, and they are both spoken for. One runs WiFi, the other runs Arduino core for things like millis(). To explore this idea further, I will need to move up to an ESP32 which has additional hardware timers exposed to its Arduino core. And if I choose to explore that path, I don’t even need to redo my circuit board: there exists a (mostly) drop-in ESP32 upgrade for anything that runs a Wemos D1 Mini.



YAML Excerpt:

esphome:
  includes:
    - led_strobe_component.h

output:
  - platform: esp8266_pwm
    pin: 14
    id: fan_pwm_output
    frequency: 1000 Hz
  - platform: custom
    type: float
    lambda: |-
      auto led_strobe = new LEDStrobeComponent();
      App.register_component(led_strobe);
      return {led_strobe};
    outputs:
      id: strobe_duration_output
  - platform: custom
    type: float
    lambda: |-
      auto led_strobe_delay = new LEDStrobeDelayComponent();
      App.register_component(led_strobe_delay);
      return {led_strobe_delay};
    outputs:
      id: strobe_delay_output

fan:
  - platform: speed
    output: fan_pwm_output
    id: fan_speed
    name: "Fan Speed Control"

light:
  - platform: monochromatic
    output: strobe_duration_output
    id: strobe_duration
    name: "Strobe Duration"
    gamma_correct: 1.0
  - platform: monochromatic
    output: strobe_delay_output
    id: strobe_delay
    name: "Strobe Delay"
    gamma_correct: 1.0

switch:
  - platform: custom
    lambda: |-
      auto even_odd_flip = new LEDStrobeEvenOddComponent();
      App.register_component(even_odd_flip);
      return {even_odd_flip};
    switches:
      name: "Strobe Tachometer Toggle"

ESPHome custom component file led_strobe_component.h:

#include "esphome.h"

volatile int evenOdd;
volatile int strobeDuration;
volatile int strobeDelay;

IRAM_ATTR void tach_pulse_handler() {
  if (0 == evenOdd) {
    evenOdd = 1;
  } else {
    delayMicroseconds(strobeDelay);
    digitalWrite(13, HIGH);
    delayMicroseconds(strobeDuration);
    digitalWrite(13, LOW);
    evenOdd = 0;
  }
}

class LEDStrobeComponent : public Component, public FloatOutput {
  public:
    void setup() override {
      // LED power transistor starts OFF, which is LOW
      pinMode(13, OUTPUT);
      digitalWrite(13, LOW);

      // Attach interrupt to tachometer wire
      pinMode(12, INPUT_PULLUP);
      evenOdd = 0;
      attachInterrupt(digitalPinToInterrupt(12), tach_pulse_handler, RISING);

      strobeDuration = 200;
    }

    void loop() override {
    }

    void write_state(float state) override {
      // Multiply by 1000 = strobe duration from 0 to 1ms.
      strobeDuration = 1000 * state;
    }
};

class LEDStrobeEvenOddComponent: public Component, public Switch {
  public:
    void write_state(bool state) override {
      evenOdd = !evenOdd;
      publish_state(state);
    }
};

class LEDStrobeDelayComponent: public Component, public FloatOutput {
  public:
    void write_state(float state) override {
      strobeDelay = 1000*state;
    }
};

LED Strobing to Fan Speed Signal

The reason I cared about power-on response time of a salvaged LED array is because I wanted to use it as a strobe light shining on a cooling fan pulsing once per revolution. Historically strobe lights used xenon bulbs for their fast response, as normal incandescent bulbs were too slow. This LED array used to be a battery-powered work light with no concern of reaction time, but LEDs are naturally faster than incandescent. Is it fast enough for the job? PC case fan specifications usually range from the hundreds to low thousands of RPM. Using 1200RPM as a convenient example, that means 1200/60 seconds per minute = 20 revolutions per second. Pulsing at 20Hz should be easy for any LED.

For the hardware side of controlling LED flashes, I used a 2N2222A transistor because I had a bulk bag of them. They are usually good for switching up to 0.8 Amps of current. I measured this LED array and it drew roughly 0.3 Amps at 11.3V, comfortably within limits. I just need to connect this transistor’s base to a microcontroller to toggle this light on and off. For this experiment I repurposed the board I had built for the first version of my bedstand fan project. I unsoldered the TMP36 sensor to free up space for 2N2222A and associated LED power wire connector.

This board also had the convenience of an already-connected fan tachometer wire. My earlier project used it for its original purpose of counting fan RPM, but now I will use those pulses to trigger a LED flash. Since timing is critical, I can’t just poll that signal wire and need a hardware interrupt instead. Within Arduino framework I could use attachInterrupt() for this purpose and run a small bit of code on every tachometer wire signal pulse. Using an ESP8266 for this job had an upside and a downside. The upside is that interrupts could be attached to any available GPIO pin, I’m not limited to specific pins like I would have been with an ATmega328P. The downside is that I have to use an architecture-specific keyword IRAM_ATTR to ensure this code lives in the correct part of memory, something not necessary for an ATmega328P.

Because it runs in a timing-critical state, ISR code is restricted in what it can call. ISR should do just what they absolutely need to do at that time, and exit allowing normal code to resume. So many time-related things like millis() and delay() won’t work as they normally would. Fortunately delayMicroseconds() can be used to control duration of each LED pulse, even though I’m not supposed to dawdle inside an ISR. Just for experiment’s sake, though, I’ll pause things just a bit. My understanding of documentation is as long as I keep the delay well under 1 millisecond (1000 microseconds) nothing else should be overly starved for CPU time. Which was enough for this quick experiment, because I started noticing motion blur if I keep the LED illuminated for more than ~750 microseconds. The ideal tradeoff between “too dim” and “motion blurred” seems to be around 250 microseconds for me. This tradeoff will be different for every different combination of fan, circuit, LED, and ambient light.

My minimalist Arduino sketch for this experiment (using delayMicroseconds() against best practices) is publicly available on GitHub, as fan_tach_led within my ESP8266Tests repository. Next step in this project is to move it over to ESPHome for bells and whistles.

NEXTEC Work Light LED Array

While experimenting with 5V power delivery over USB-C, I thought of an experiment that will utilize my new understanding of computer cooling fan tachometer wire. For this experiment I will need a light source in addition to the fan itself. I wanted a nice and bright array of many LEDs, and preferably something already set up to run at around 12V so I wouldn’t have to add current-limiting resistors. A few years ago, I took apart a Sears Craftsman NEXTEC work light for its battery compartment. Now it is the LEDs turn to shine. That battery pack used three lithium 18650 cells in series, so it is in the right voltage range.

I think there was only a single fastener involved in this LED array, and it was already gone from teardown earlier so now everything slid apart easily.

I like the LED housing and intend to use it, but I wanted to take a closer look at the LED array.

I confirm the 24 white LEDs visible before disassembly, and there’s nothing else hiding on this side of the board, just the power supply wires looping through for a bit of strain relief. We can also see that Chervon Group was the subcontractor who produced this device to be sold by Sears under their Craftsman branding.

Everything is on the backside of this circuit board. From here we can see the 24 LEDs are arranged in 12 parallel sets of 2 LEDs in series, each set with a 240 Ohm resistor between them. Beyond that, to lower left I see a cluster of components and I’m not sure what they do. My best guess is battery over-discharge protection. Perhaps the component marked ZD1 is a Zener diode to detect voltage threshold, working with power transistor Q1 to cut power if battery voltage drops too low.

The most important thing is that I don’t see a microcontroller that requires time to boot up. I will be pulsing this LED array rapidly and want minimal delay between power and illumination. If delay proves to be a problem, I’ll try bypassing those lower-left bits: Relocate the power supply wire (brown wire, connects between markings R1 and ZD1) so it connects directly to the LED supply plane. Either to the transistor tab adjacent to the Q1 marking, or directly to the high end of any of those 12 parallel LED strings. But I might not need to perform that bypass. I will try my experiment with this circuit board as-is.