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;
}
};