LED Ticker Build Log · 03
[IoT DIY Part 3] Multi-Sensor Fusion for Reducing Night-Time Visual Fatigue: CDS, Doppler Radar, FSM Design, and False-Trigger Filtering
![[IoT DIY Part 3] Multi-Sensor Fusion for Reducing Night-Time Visual Fatigue: CDS, Doppler Radar, FSM Design, and False-Trigger Filtering](/iot-lab/assets/led-ticker/hero-en-3.jpg)
In embedded hardware, physically connecting sensors and reading raw values is usually the easy part. The real engineering work is combining raw data and designing the state rules(State Machine) that decide how the device reacts to the environment and user behavior.
This article documents the serious night-time glare problem I faced after completing the LED ticker, the CDS light sensor and microwave Doppler radar(RCWL-0516) I added to solve it, the optical limits I encountered, the false triggers that appeared at dawn, and the non-blocking control algorithm used to handle those issues.
1. Optical limits of CDS light sensing and PWM dimming
The ticker was highly readable during the day, but it became a serious problem after the room lights were turned off. The red 8×16 LED matrix, with 128 pixels total, became a strong light source that painted the whole room red.
I first added a CDS light sensor and PWM dimming logic to reduce brightness according to ambient light. But the human eye and the optics of red LEDs had limits that were not obvious from datasheets.
- Nonlinear visual sensitivity: Human eyes are much more sensitive to light changes in darkness, roughly in a logarithmic way.
- Minimum PWM limit: Even when 8-bit PWM brightness was reduced to 1, the direct red wavelength still felt visually distracting in a completely dark sleeping environment.
In the end, simply lowering brightness when it was dark was not enough. The device needed an active sleep algorithm that turned the screen off completely when no person was present.
2. Microwave Doppler sensor(RCWL-0516) and dawn false-trigger troubleshooting
To detect presence, I used a microwave Doppler radar sensor(RCWL-0516) instead of a normal PIR sensor. PIR sensing becomes unreliable when hidden inside an acrylic case, while Doppler sensing in the 3.2GHz~5.8GHz range can pass through non-metal cases and preserve a clean exterior.
However, the high sensitivity of the Doppler sensor created an unexpected failure.
The 3 a.m. ghost phenomenon and frequency reflections
On the first night after applying the rule, the ticker repeatedly turned on at full brightness around 3 a.m. even though nobody moved in the room. Log analysis showed that the cause was the high-frequency nature of the sensor.
- Cause: Microwave frequencies can detect tiny movement and temperature-driven airflow as reflected waves. A slightly moving curtain or warm air turbulence from heating was being mistaken for human motion.
- Software filtering: A High(1) signal from the sensor must not turn the display on immediately. I added debounce and time-window filtering to ignore momentary noise.
- Minimum duration check: Motion must remain high for at least 1,000ms(1 second) to be accepted as valid.
- Hysteresis timeout: The screen enters sleep only after 60 seconds without movement, preventing flickering on/off behavior(Flattering).
3. Multi-condition rules and FSM design
A display device should not behave like a simple streetlight that turns on when a person approaches and off when they leave. I defined three operating states based on actual usage patterns.
| State | CDS condition | Doppler condition | Display behavior |
|---|---|---|---|
| Day mode | Bright | Ignored | Full ticker + clock at PWM 255 |
| Night clock mode | Dark | Detected for 1s+ | Turn off info area, show clock only at PWM 5 |
| Night sleep mode | Dark | No motion for 60s+ | Fully turn off the display(PWM 0) |
Instead of nesting many if-else statements, I rebuilt the logic as a finite state machine(FSM) so each device state and transition rule remained explicit.
4. [Practical code block] CDS + Doppler sensor-fusion FSM boilerplate
The following C++ boilerplate combines a millis()-based non-blocking timer with software noise filtering.
#include <Arduino.h> #define PIN_CDS 34 // Light sensor analog input #define PIN_DOPPLER 35 // Doppler radar digital input #define PIN_LED_EN 5 // LED matrix display enable // System state definition (FSM) enum DisplayState { STATE_DAY_ALL_ON, STATE_NIGHT_CLOCK_ONLY, STATE_NIGHT_SLEEP }; DisplayState currentState = STATE_DAY_ALL_ON; unsigned long lastMotionDetectedTime = 0; unsigned long motionPulseStartTime = 0; bool isMotionValid = false; const int CDS_THRESHOLD_DARK = 500; const unsigned long MOTION_DEBOUNCE = 1000; const unsigned long SLEEP_TIMEOUT = 60000; // ============================================================================== // 1. Doppler sensor software debounce filter // ============================================================================== bool readDopplerWithFilter() { int rawDoppler = digitalRead(PIN_DOPPLER); unsigned long currentMillis = millis(); if (rawDoppler == HIGH) { if (motionPulseStartTime == 0) { motionPulseStartTime = currentMillis; } if (currentMillis - motionPulseStartTime >= MOTION_DEBOUNCE) { lastMotionDetectedTime = currentMillis; isMotionValid = true; } } else { motionPulseStartTime = 0; } if (currentMillis - lastMotionDetectedTime < SLEEP_TIMEOUT) { return true; } else { isMotionValid = false; return false; } } // ============================================================================== // 2. Finite State Machine transition logic // ============================================================================== void updateDisplayState() { int cdsValue = analogRead(PIN_CDS); bool isDark = (cdsValue < CDS_THRESHOLD_DARK); bool isPersonPresent = readDopplerWithFilter(); if (!isDark) { currentState = STATE_DAY_ALL_ON; } else { if (isPersonPresent) currentState = STATE_NIGHT_CLOCK_ONLY; else currentState = STATE_NIGHT_SLEEP; } } // ============================================================================== // 3. Display rendering controller per state // ============================================================================== void executeDisplayOutput() { switch (currentState) { case STATE_DAY_ALL_ON: // Matrix.setBrightness(255); // renderFullInfo(); break; case STATE_NIGHT_CLOCK_ONLY: // Matrix.setBrightness(5); // renderClockOnly(); break; case STATE_NIGHT_SLEEP: // Matrix.clear(); break; } } void setup() { Serial.begin(115200); pinMode(PIN_CDS, INPUT); pinMode(PIN_DOPPLER, INPUT); pinMode(PIN_LED_EN, OUTPUT); } void loop() { updateDisplayState(); executeDisplayOutput(); }
5. DHT22 self-heating compensation
After integrating the Doppler and light sensors, I added a DHT22 to measure the indoor temperature and humidity on my desk rather than relying on outdoor weather API values.
When the DHT22 was placed inside the sealed acrylic case, heat from the ESP32 board and 128 LED matrix pixels made the measured temperature about 2~3°C higher than the real room temperature.
- Hardware placement: The sensor was moved away from heat sources and placed near the lower external intake area, where convection influence was lower.
- Software offset: After measuring thermal equilibrium over time, I subtracted -2.2°C from the collected temperature value to improve reliability.
6. Conclusion: the value of behavior algorithms beyond simple assembly
Connecting a few sensors to a microcontroller is something many tutorials can teach. Making the device avoid false triggers at dawn, respect visual fatigue, and compensate for heat requires verification and software filtering.
Those three nights spent adjusting the sleep timer and filtering radar reflections from moving curtains were the work that turned a DIY device into a usable finished product. That sensor-fusion experience became the technical base for the NeoPixel mood lamp in Part 4.