Chapter 225: Daylight Harvesting Systems
Chapter Objectives
By the end of this chapter, you will be able to:
- Understand the principle of daylight harvesting for energy efficiency.
- Interface a digital ambient light sensor (BH1750) with an ESP32 using the I2C protocol.
- Control the brightness of an LED using the ESP32’s hardware LEDC (PWM) peripheral.
- Implement a control loop to automatically adjust artificial lighting based on ambient light levels.
- Maintain a constant total illuminance (in Lux) at a target setpoint.
- Integrate the daylighting system with an occupancy sensor to maximize energy savings.
- Remotely monitor sensor data and adjust the target brightness via MQTT.
Introduction
Daylight harvesting is one of the most intelligent and effective strategies in modern building automation. The concept is simple yet powerful: why pay for artificial light when natural light from the sun is available for free? A daylight harvesting system continuously measures the amount of natural light entering a space and automatically dims the artificial lights to maintain a consistent, desired brightness level.
This not only leads to significant energy savings—often reducing lighting energy costs by 25-60%—but also improves the well-being and productivity of the building’s occupants by providing a more natural and comfortable lighting environment.
In this chapter, we will build a complete daylight harvesting system. We will learn how to precisely measure ambient light and, using the ESP32’s hardware PWM controller, finely tune the output of an LED light fixture. We will combine these elements with the occupancy data from the previous chapter to create a truly smart, efficient, and responsive lighting solution.
Theory
1. Measuring Light: The BH1750 Sensor
To properly adjust artificial lights, we must first accurately measure the ambient light. While simple photoresistors can indicate relative brightness, professional systems require a quantifiable measurement in a standard unit. This unit is Lux (lx), the SI unit of illuminance, which measures luminous flux per unit area.
For this task, we will use a dedicated digital ambient light sensor, the BH1750. This sensor is ideal for our application because:
- It communicates via I2C, making it easy to interface.
- It provides a direct digital output measured in Lux, saving us from complex manual calibration and conversion.
- It has a spectral response similar to the human eye, ensuring its readings correspond well to perceived brightness.
The workflow is straightforward: the ESP32 sends a command to the BH1750 via I2C to start a measurement. After a short integration time, the ESP32 reads the result back from the sensor—a 16-bit number that represents the illuminance in Lux.

2. Dimming Lights: The LEDC Peripheral
Dimming an LED is not as simple as reducing the voltage. The correct and most efficient method is Pulse Width Modulation (PWM). PWM works by switching the LED on and off at a very high frequency (thousands of times per second). The perceived brightness of the LED is determined by the duty cycle—the percentage of time the signal is ON versus OFF within one cycle. A 100% duty cycle means the LED is fully on, a 25% duty cycle means it’s on for a quarter of the time (appearing dim), and a 0% duty cycle means it’s off.
The ESP32 has a dedicated hardware peripheral for this task: the LEDC (LED Control). Using the LEDC peripheral is far superior to implementing PWM in software (“bit-banging”) because:
- Hardware-Accelerated: It generates stable, flicker-free PWM signals without consuming any CPU time.
- Flexible: It offers multiple independent timers and channels, with configurable frequency and duty cycle resolution.
Configuring the LEDC involves two steps:
- Configure a Timer: You set the PWM frequency (e.g., 5 kHz) and the duty resolution (e.g., 10-bit, which gives 1024 steps of brightness).
- Configure a Channel: You associate a GPIO pin with the configured timer and can then set its duty cycle on command.
3. The Control Loop
The core of our system is a continuous control loop that strives to maintain a constant level of light at a worksurface.
- Define a Setpoint: We decide on a target total illuminance, for example, 500 Lux, which is a common recommendation for office work.
- Measure Ambient Light: We use the BH1750 to measure the current ambient light (
ambient_lux
) from windows. - Calculate the Deficit: We find the difference between our target and the available natural light:
deficit = setpoint_lux - ambient_lux
. - Adjust Artificial Light:
- If
ambient_lux
is greater than or equal tosetpoint_lux
, the deficit is zero or less. We don’t need any artificial light, so we set the LEDC duty cycle to 0%. - If
ambient_lux
is less thansetpoint_lux
, we need to make up the deficit. We calculate the required duty cycle for our LED to fill this gap.
- If
- Repeat: The loop runs continuously, adapting to changing conditions like passing clouds or the setting sun.
flowchart TD subgraph Control Loop A[Start] --> B{Room Occupied?}; B -- Yes --> C["Read Ambient Light<br>(BH1750 Sensor)"]; B -- No --> D["Turn Off Light<br>(Duty Cycle = 0%)"]; D --> E[Wait for Interval]; E --> B; C --> F{Ambient >= Setpoint?}; F -- Yes --> D; F -- No --> G[Calculate Light Deficit]; G --> H[Calculate Required<br>PWM Duty Cycle]; H --> I[Set LEDC Duty Cycle]; I --> E; end %% Styling classDef start-end fill:#D1FAE5,stroke:#059669,stroke-width:2px,color:#065F46; classDef decision fill:#FEF3C7,stroke:#D97706,stroke-width:2px,color:#92400E; classDef process fill:#DBEAFE,stroke:#2563EB,stroke-width:1px,color:#1E40AF; classDef check fill:#FEE2E2,stroke:#DC2626,stroke-width:1px,color:#991B1B; class A,I,D start-end; class B,F decision; class C,G,H,E process;
A crucial final step is to combine this with occupancy data. The entire daylight harvesting system should only be active when the room is OCCUPIED
. When VACANT
, the lights should be off regardless of the ambient light level.
Practical Example: Smart Daylight Harvesting Controller
Hardware Required
- An ESP32 development board.
- A BH1750 ambient light sensor module.
- A logic-level N-Channel MOSFET (e.g., IRLZ44N) to control a 12V LED strip, or a simple 5mm LED for basic testing.
- A 12V LED strip and a 12V power supply (if using a MOSFET).
- A 10kΩ resistor (for the MOSFET).
- Breadboard and jumper wires.
Wiring
Warning: The LEDC peripheral outputs a logic-level signal. To control a high-power load like a 12V LED strip, a driver like a MOSFET is required.
- BH1750 Sensor (I2C):
VCC
->3V3
on ESP32.GND
->GND
on ESP32.SCL
->GPIO22
.SDA
->GPIO21
.
- LED Control (with MOSFET for 12V Strip):
- ESP32
GPIO18
-> 10kΩ Resistor -> MOSFETGate
pin. - ESP32
GND
-> MOSFETSource
pin. - MOSFET
Drain
pin -> Negative (-
) terminal of the LED strip. - Positive (
+
) terminal of the LED strip ->+12V
of the external power supply. - MOSFET
Source
pin ->-
terminal of the external power supply. - Crucially, also connect ESP32
GND
to the-
terminal of the external power supply to create a common ground.
- ESP32
Project Configuration
- Create a new ESP-IDF project.
- Add a driver for the BH1750. A good option is available on the IDF Component Registry. Run
idf.py add-dependency "espressif/bh1750"
. - Update
main/CMakeLists.txt
to require the necessary components:REQUIRES
idf::nvs_flash
idf::esp_wifi
idf::esp_event
idf::esp_netif
idf::esp_log
idf::mqtt
idf::ledc
espressif/bh1750
- Run
idf.py menuconfig
and configure your Wi-Fi and MQTT details.
Code Implementation
The code will initialize the sensor and LEDC peripheral, then run a main task that executes the control loop and responds to MQTT commands for occupancy and setpoint changes.
/* main/main.c */
#include <stdio.h>
#include <string.h>
#include <math.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "esp_log.h"
#include "esp_wifi.h"
#include "nvs_flash.h"
#include "esp_event.h"
#include "esp_netif.h"
#include "mqtt_client.h"
#include "driver/ledc.h"
#include "bh1750.h"
static const char *TAG = "DAYLIGHT_HARVESTING";
// --- Hardware & System Configuration ---
#define LEDC_TIMER LEDC_TIMER_0
#define LEDC_MODE LEDC_LOW_SPEED_MODE
#define LEDC_OUTPUT_IO (18) // GPIO 18
#define LEDC_CHANNEL LEDC_CHANNEL_0
#define LEDC_DUTY_RES LEDC_TIMER_10_BIT // Resolution of 10 bits (0-1023)
#define LEDC_FREQUENCY (5000) // Frequency in Hertz
#define I2C_MASTER_SCL_IO GPIO_NUM_22
#define I2C_MASTER_SDA_IO GPIO_NUM_21
// --- Control Loop Configuration ---
#define TARGET_LUX_SETPOINT 500.0f // Target brightness at the worksurface
#define MAX_ARTIFICIAL_LUX 1000.0f // The max brightness the LED provides at 100% duty
#define CONTROL_LOOP_INTERVAL_S 2
// --- MQTT Topics ---
#define MQTT_OCCUPANCY_STATE_TOPIC "office/room101/occupancy/state"
#define MQTT_SETPOINT_CMD_TOPIC "office/room101/lighting/setpoint/set"
#define MQTT_SETPOINT_STATE_TOPIC "office/room101/lighting/setpoint/state"
#define MQTT_AMBIENT_LUX_TOPIC "office/room101/lighting/ambient_lux"
#define MQTT_SYSTEM_STATE_TOPIC "office/room101/lighting/system_state"
// --- Global State ---
static float current_setpoint_lux = TARGET_LUX_SETPOINT;
static bool is_occupied = false;
static esp_mqtt_client_handle_t mqtt_client;
static i2c_dev_t bh1750_dev;
// --- Function Prototypes ---
static void configure_ledc(void);
static void init_i2c_sensor(void);
// ... standard WiFi and MQTT init functions
void light_control_task(void *pvParameters) {
char payload_buffer[16];
uint16_t lux;
while (1) {
if (!is_occupied) {
// If room is vacant, ensure light is off
ledc_set_duty(LEDC_MODE, LEDC_CHANNEL, 0);
ledc_update_duty(LEDC_MODE, LEDC_CHANNEL);
esp_mqtt_client_publish(mqtt_client, MQTT_SYSTEM_STATE_TOPIC, "OFF (Vacant)", 0, 1, 0);
} else {
// Room is occupied, run control loop
if (bh1750_read(&bh1750_dev, &lux) == ESP_OK) {
float ambient_lux = (float)lux;
snprintf(payload_buffer, sizeof(payload_buffer), "%.0f", ambient_lux);
esp_mqtt_client_publish(mqtt_client, MQTT_AMBIENT_LUX_TOPIC, payload_buffer, 0, 1, 0);
float lux_deficit = current_setpoint_lux - ambient_lux;
uint32_t duty_cycle = 0;
if (lux_deficit > 0) {
// We need to add artificial light
float required_pct = lux_deficit / MAX_ARTIFICIAL_LUX;
if (required_pct > 1.0) required_pct = 1.0; // Clamp at 100%
duty_cycle = (uint32_t)(required_pct * (float)((1 << LEDC_DUTY_RES) - 1));
}
ledc_set_duty(LEDC_MODE, LEDC_CHANNEL, duty_cycle);
ledc_update_duty(LEDC_MODE, LEDC_CHANNEL);
ESP_LOGI(TAG, "Setpoint: %.0f, Ambient: %.0f, Deficit: %.0f, Duty: %ld",
current_setpoint_lux, ambient_lux, lux_deficit, duty_cycle);
esp_mqtt_client_publish(mqtt_client, MQTT_SYSTEM_STATE_TOPIC, "ON (Active)", 0, 1, 0);
} else {
ESP_LOGE(TAG, "Failed to read from BH1750 sensor.");
}
}
vTaskDelay(pdMS_TO_TICKS(CONTROL_LOOP_INTERVAL_S * 1000));
}
}
// --- MQTT Event Handler ---
static void mqtt_event_handler(void *handler_args, esp_event_base_t base, int32_t event_id, void *event_data) {
esp_mqtt_event_handle_t event = event_data;
char payload_buffer[10];
switch ((esp_mqtt_event_id_t)event_id) {
case MQTT_EVENT_CONNECTED:
ESP_LOGI(TAG, "MQTT_EVENT_CONNECTED");
// Subscribe to occupancy and setpoint topics
esp_mqtt_client_subscribe(mqtt_client, MQTT_OCCUPANCY_STATE_TOPIC, 1);
esp_mqtt_client_subscribe(mqtt_client, MQTT_SETPOINT_CMD_TOPIC, 0);
// Publish current setpoint on connection
snprintf(payload_buffer, sizeof(payload_buffer), "%.0f", current_setpoint_lux);
esp_mqtt_client_publish(mqtt_client, MQTT_SETPOINT_STATE_TOPIC, payload_buffer, 0, 1, 1);
break;
case MQTT_EVENT_DATA:
ESP_LOGI(TAG, "MQTT_EVENT_DATA");
// Null-terminate the topic and data for string comparison
event->topic[event->topic_len] = '\0';
event->data[event->data_len] = '\0';
if (strcmp(event->topic, MQTT_OCCUPANCY_STATE_TOPIC) == 0) {
if (strcmp(event->data, "OCCUPIED") == 0) {
is_occupied = true;
ESP_LOGI(TAG, "Occupancy state: OCCUPIED");
} else {
is_occupied = false;
ESP_LOGI(TAG, "Occupancy state: VACANT");
}
} else if (strcmp(event->topic, MQTT_SETPOINT_CMD_TOPIC) == 0) {
float new_setpoint = atof(event->data);
if (new_setpoint >= 100 && new_setpoint <= 1000) { // Sanity check
current_setpoint_lux = new_setpoint;
ESP_LOGI(TAG, "New setpoint received: %.0f Lux", current_setpoint_lux);
// Publish back the new state
snprintf(payload_buffer, sizeof(payload_buffer), "%.0f", current_setpoint_lux);
esp_mqtt_client_publish(mqtt_client, MQTT_SETPOINT_STATE_TOPIC, payload_buffer, 0, 1, 1);
}
}
break;
default: break;
}
}
void app_main(void) {
// Standard init functions (NVS, WiFi) here...
// ...
configure_ledc();
init_i2c_sensor();
// WiFi and MQTT start functions here...
// ... (Remember to start the MQTT client after WiFi connects)
xTaskCreate(light_control_task, "light_control_task", 4096, NULL, 5, NULL);
}
// --- Initialization Functions ---
static void configure_ledc(void) {
ledc_timer_config_t ledc_timer = {
.speed_mode = LEDC_MODE,
.timer_num = LEDC_TIMER,
.duty_resolution = LEDC_DUTY_RES,
.freq_hz = LEDC_FREQUENCY,
.clk_cfg = LEDC_AUTO_CLK
};
ESP_ERROR_CHECK(ledc_timer_config(&ledc_timer));
ledc_channel_config_t ledc_channel = {
.speed_mode = LEDC_MODE,
.channel = LEDC_CHANNEL,
.timer_sel = LEDC_TIMER,
.intr_type = LEDC_INTR_DISABLE,
.gpio_num = LEDC_OUTPUT_IO,
.duty = 0, // Set duty to 0% initially
.hpoint = 0
};
ESP_ERROR_CHECK(ledc_channel_config(&ledc_channel));
}
void init_i2c_sensor() {
ESP_ERROR_CHECK(i2cdev_init());
memset(&bh1750_dev, 0, sizeof(i2c_dev_t)); // Clear the descriptor
ESP_ERROR_CHECK(bh1750_init_desc(&bh1750_dev, BH1750_I2C_ADDR_LO, 0, I2C_MASTER_SDA_IO, I2C_MASTER_SCL_IO));
ESP_LOGI(TAG, "BH1750 initialized successfully");
}
sequenceDiagram participant UserClient as User Client participant Broker as MQTT Broker participant ESP32 Note over ESP32: On connect, subscribes to topics. ESP32->>Broker: SUBSCRIBE "office/.../occupancy/state" ESP32->>Broker: SUBSCRIBE "office/.../lighting/setpoint/set" par Occupancy Update UserClient->>Broker: PUBLISH "office/.../occupancy/state"<br>Payload: "OCCUPIED" Broker-->>ESP32: ON_MESSAGE "OCCUPIED" Note over ESP32: is_occupied = true<br>Activates control loop and Remote Setpoint Change UserClient->>Broker: PUBLISH "office/.../lighting/setpoint/set"<br>Payload: "350" Broker-->>ESP32: ON_MESSAGE "350" Note over ESP32: Updates current_setpoint_lux ESP32->>Broker: PUBLISH "office/.../lighting/setpoint/state"<br>Payload: "350" (Retained) end loop Control Loop Active Note over ESP32: Measures ambient light ESP32->>Broker: PUBLISH "office/.../lighting/ambient_lux"<br>Payload: "150" ESP32->>Broker: PUBLISH "office/.../lighting/system_state"<br>Payload: "ON (Active)" end
Build, Flash, and Observe
- After flashing, use your MQTT client to publish
OCCUPIED
tooffice/room101/occupancy/state
. The system is now active. - Observe the logs. You will see the system measuring ambient light and calculating the required duty cycle.
- Cover the BH1750 sensor with your hand. The
ambient_lux
will drop, and the LED will brighten. Uncover it, and the LED will dim. - Publish a new setpoint (e.g.,
300
) tooffice/room101/lighting/setpoint/set
. The system will now target this new brightness level. - Publish
VACANT
to the occupancy topic. The LED will turn off completely.
Variant Notes
- LEDC & I2C Peripherals: The LEDC and I2C master peripherals are available on all ESP32 variants (ESP32, S2, S3, C3, C6). The fundamental code for configuring and using them is portable. You only need to be mindful of which GPIO pins are available and suitable for I2C (SDA/SCL) and LEDC output on your specific board.
- Number of Channels: The original ESP32 has 16 LEDC channels (8 low-speed, 8 high-speed). The ESP32-S2/S3 have 8 channels, and the ESP32-C6 has 6 channels. For controlling a single light fixture, any variant is more than sufficient.
- ESP32-H2 (No Wi-Fi): As with previous chapters, this Wi-Fi/MQTT-based example would need to be adapted for the ESP32-H2. The core logic of the
light_control_task
would remain identical, but the communication would be handled via a Zigbee Light Link (ZLL) profile or a custom Thread implementation to receive occupancy and setpoint commands from a network gateway.
Common Mistakes & Troubleshooting Tips
Mistake / Issue | Symptom(s) | Troubleshooting / Solution |
---|---|---|
LED Flickering or Not Changing Brightness | The LED flickers visibly, especially at low brightness, or stays at one brightness level regardless of sensor readings. |
Check LEDC Configuration: 1. Ensure speed_mode is the same in both the timer and channel configs.2. Set a higher frequency. LEDC_FREQUENCY should be at least 1000 (1kHz). 5000 (5kHz) is a good default.3. Verify the correct GPIO pin is set in .gpio_num .
|
System Oscillates (Light Turns On/Off) | The LED turns on, the sensor reads high ambient light, the LED dims/turns off, the sensor reads low ambient light, and the cycle repeats. |
Fix Sensor Placement: This is a physical feedback loop. The sensor is “seeing” its own light.
|
Failed to Read from BH1750 Sensor | The log shows errors like "Failed to read from BH1750" or I2C communication errors (e.g., timeout, NACK). The system does not react to light changes. |
Check I2C Connection: 1. Wiring: Double-check that SDA and SCL lines are not swapped between the ESP32 and sensor. 2. Address: The BH1750 has two possible I2C addresses (0x23 or 0x5C). Verify the address of your module. The code uses BH1750_I2C_ADDR_LO (0x23). Change this if yours is different.3. Power: Ensure the sensor’s VCC and GND are correctly connected to the ESP32’s 3V3 and GND pins. |
Incorrect Brightness Calculation | The LED is always too bright or too dim for the ambient conditions. It responds, but not correctly. |
Calibrate MAX_ARTIFICIAL_LUX: The constant MAX_ARTIFICIAL_LUX is an estimate. For accurate results, measure it:
|
Exercises
- Implement Smooth Fading: The current code changes the brightness instantly. Modify the
light_control_task
to fade smoothly to the new brightness level over 1-2 seconds. You can do this by calculating the target duty cycle and then creating a small loop that callsledc_set_duty
repeatedly with incremental values and a short delay (vTaskDelay
). - Add Manual Override: Implement a physical push button. When pressed, the system should enter a “MANUAL” mode for a set period (e.g., 1 hour), where the light is set to 100% brightness, ignoring sensor readings. After the timeout, it should revert to “AUTO” mode. This requires adding another state to your control logic.
- Calibrate
MAX_ARTIFICIAL_LUX
: TheMAX_ARTIFICIAL_LUX
constant is a crucial assumption. Create a “calibration mode” (triggered via MQTT or a button). In this mode, the system turns the LED to 100%, turns off all other lights, and measures the resulting Lux at the worksurface with the BH1750. It then saves this value to NVS for use in the control loop, making the system’s calculations much more accurate.
Summary
- Daylight harvesting uses ambient light sensors to dim artificial lights, saving energy and improving the lighting environment.
- The BH1750 is an excellent sensor for this task, providing direct illuminance readings in Lux via the I2C protocol.
- The ESP32’s hardware LEDC peripheral is the proper tool for generating stable, flicker-free PWM signals to control LED brightness.
- A robust control loop continuously compares ambient light to a setpoint and adjusts the artificial light to make up for any deficit.
- The system’s effectiveness is maximized when combined with occupancy sensing, ensuring lights are only on and adjusted when the space is in use.
- Proper physical placement of the ambient light sensor is critical to avoid feedback loops and ensure accurate operation.
Further Reading
- ESP-IDF LEDC (PWM) Driver: LEDC API Reference
- ESP-IDF I2C Master Driver: I2C Driver API Reference
- BH1750 Component by Espressif: ESP-IDF BH1750 Component Registry
- Illuminating Engineering Society (IES) Lighting Handbook: The definitive guide on recommended light levels for various tasks and environments.