#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "freertos/event_groups.h"
#include "freertos/semphr.h"
#include "esp_http_server.h"
static const char *TAG = "thermostat";
/* ================================================================
* ================================================================ */
#define WIFI_SSID CONFIG_WIFI_SSID
#define WIFI_PASS CONFIG_WIFI_PASSWORD
#define WIFI_MAX_RETRY 10
#define DHT_GPIO GPIO_NUM_4
#define RELAY_GPIO GPIO_NUM_5
#define DHT_TIMEOUT_US 1000
#define DEFAULT_SETPOINT 23.0f
#define SENSOR_READ_MS 2000
/* ================================================================
* ================================================================ */
static EventGroupHandle_t wifi_event_group;
#define WIFI_CONNECTED_BIT BIT0
#define WIFI_FAIL_BIT BIT1
static int wifi_retry_count = 0;
static void wifi_event_handler(void *arg, esp_event_base_t base,
int32_t event_id, void *event_data)
if (base == WIFI_EVENT && event_id == WIFI_EVENT_STA_START) {
} else if (base == WIFI_EVENT &&
event_id == WIFI_EVENT_STA_DISCONNECTED) {
if (wifi_retry_count < WIFI_MAX_RETRY) {
ESP_LOGI(TAG, "Retrying Wi-Fi connection (%d/%d)",
wifi_retry_count, WIFI_MAX_RETRY);
xEventGroupSetBits(wifi_event_group, WIFI_FAIL_BIT);
} else if (base == IP_EVENT && event_id == IP_EVENT_STA_GOT_IP) {
ip_event_got_ip_t *event = (ip_event_got_ip_t *)event_data;
ESP_LOGI(TAG, "Connected. IP: " IPSTR,
IP2STR(&event->ip_info.ip));
xEventGroupSetBits(wifi_event_group, WIFI_CONNECTED_BIT);
static void wifi_init_sta(void)
wifi_event_group = xEventGroupCreate();
ESP_ERROR_CHECK(esp_netif_init());
ESP_ERROR_CHECK(esp_event_loop_create_default());
esp_netif_create_default_wifi_sta();
wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT();
ESP_ERROR_CHECK(esp_wifi_init(&cfg));
esp_event_handler_instance_t instance_any_id;
esp_event_handler_instance_t instance_got_ip;
ESP_ERROR_CHECK(esp_event_handler_instance_register(
WIFI_EVENT, ESP_EVENT_ANY_ID,
&wifi_event_handler, NULL, &instance_any_id));
ESP_ERROR_CHECK(esp_event_handler_instance_register(
IP_EVENT, IP_EVENT_STA_GOT_IP,
&wifi_event_handler, NULL, &instance_got_ip));
wifi_config_t wifi_config = {
.threshold.authmode = WIFI_AUTH_WPA2_PSK,
ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_STA));
ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_STA, &wifi_config));
ESP_ERROR_CHECK(esp_wifi_start());
ESP_LOGI(TAG, "Connecting to %s ...", WIFI_SSID);
EventBits_t bits = xEventGroupWaitBits(
WIFI_CONNECTED_BIT | WIFI_FAIL_BIT,
pdFALSE, pdFALSE, portMAX_DELAY);
if (bits & WIFI_CONNECTED_BIT) {
ESP_LOGI(TAG, "Wi-Fi connected");
ESP_LOGE(TAG, "Wi-Fi connection failed");
/* ================================================================
* ================================================================ */
static esp_err_t init_spiffs(void)
esp_vfs_spiffs_conf_t conf = {
.partition_label = "storage",
.format_if_mount_failed = true,
esp_err_t ret = esp_vfs_spiffs_register(&conf);
ESP_LOGE(TAG, "SPIFFS mount failed: %s", esp_err_to_name(ret));
size_t total = 0, used = 0;
esp_spiffs_info("storage", &total, &used);
ESP_LOGI(TAG, "SPIFFS: total=%d, used=%d", total, used);
/* ================================================================
* ================================================================ */
static void init_mdns(void)
mdns_hostname_set("thermostat");
mdns_instance_name_set("ESP32 Thermostat");
mdns_service_add(NULL, "_http", "_tcp", 80, NULL, 0);
ESP_LOGI(TAG, "mDNS: thermostat.local");
/* ================================================================
* ================================================================ */
static int dht_wait_for_level(int level, int timeout_us)
int64_t start = esp_timer_get_time();
while (gpio_get_level(DHT_GPIO) != level) {
if ((esp_timer_get_time() - start) > timeout_us) {
return (int)(esp_timer_get_time() - start);
static dht_reading_t dht_read(void)
dht_reading_t result = { .valid = false };
/* Host start signal: pull low for 2 ms, then release */
gpio_set_direction(DHT_GPIO, GPIO_MODE_OUTPUT);
gpio_set_level(DHT_GPIO, 0);
vTaskDelay(pdMS_TO_TICKS(2));
gpio_set_level(DHT_GPIO, 1);
gpio_set_direction(DHT_GPIO, GPIO_MODE_INPUT);
/* Sensor response: low 80 us, high 80 us */
if (dht_wait_for_level(0, DHT_TIMEOUT_US) < 0) return result;
if (dht_wait_for_level(1, DHT_TIMEOUT_US) < 0) return result;
if (dht_wait_for_level(0, DHT_TIMEOUT_US) < 0) return result;
for (int i = 0; i < 40; i++) {
if (dht_wait_for_level(1, DHT_TIMEOUT_US) < 0) return result;
int high_us = dht_wait_for_level(0, DHT_TIMEOUT_US);
if (high_us < 0) return result;
uint8_t checksum = data[0] + data[1] + data[2] + data[3];
if ((checksum & 0xFF) != data[4]) {
ESP_LOGW(TAG, "DHT checksum error");
result.humidity = ((data[0] << 8) | data[1]) / 10.0f;
int16_t raw_temp = ((data[2] & 0x7F) << 8) | data[3];
result.temperature = raw_temp / 10.0f;
/* ================================================================
* ================================================================ */
static void relay_init(void)
gpio_config_t io_conf = {
.pin_bit_mask = (1ULL << RELAY_GPIO),
.mode = GPIO_MODE_OUTPUT,
.pull_up_en = GPIO_PULLUP_DISABLE,
.pull_down_en = GPIO_PULLDOWN_DISABLE,
.intr_type = GPIO_INTR_DISABLE,
gpio_set_level(RELAY_GPIO, 1); /* OFF (active low) */
static void relay_set(bool on)
gpio_set_level(RELAY_GPIO, on ? 0 : 1);
/* ================================================================
* ================================================================ */
static const char *mode_to_str(thermostat_mode_t m)
case MODE_AUTO: return "auto";
case MODE_MANUAL_ON: return "manual_on";
case MODE_MANUAL_OFF: return "manual_off";
static thermostat_mode_t str_to_mode(const char *s)
if (strcmp(s, "manual_on") == 0) return MODE_MANUAL_ON;
if (strcmp(s, "manual_off") == 0) return MODE_MANUAL_OFF;
static thermostat_state_t state;
static void state_init(void)
state.temperature = 0.0f;
state.setpoint = DEFAULT_SETPOINT;
state.mutex = xSemaphoreCreateMutex();
static void thermostat_update(void)
xSemaphoreTake(state.mutex, portMAX_DELAY);
if (state.temperature < (state.setpoint - HYSTERESIS)) {
} else if (state.temperature > (state.setpoint + HYSTERESIS)) {
relay_set(state.relay_on);
xSemaphoreGive(state.mutex);
/* ================================================================
* HTTP Server: REST API Handlers
* ================================================================ */
static esp_err_t api_status_handler(httpd_req_t *req)
xSemaphoreTake(state.mutex, portMAX_DELAY);
cJSON *root = cJSON_CreateObject();
cJSON_AddNumberToObject(root, "temp", state.temperature);
cJSON_AddNumberToObject(root, "humidity", state.humidity);
cJSON_AddNumberToObject(root, "setpoint", state.setpoint);
cJSON_AddBoolToObject(root, "relay", state.relay_on);
cJSON_AddStringToObject(root, "mode", mode_to_str(state.mode));
xSemaphoreGive(state.mutex);
char *json = cJSON_PrintUnformatted(root);
httpd_resp_set_type(req, "application/json");
httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*");
httpd_resp_sendstr(req, json);
static esp_err_t api_setpoint_handler(httpd_req_t *req)
int received = httpd_req_recv(req, buf, sizeof(buf) - 1);
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Empty body");
cJSON *root = cJSON_Parse(buf);
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Invalid JSON");
cJSON *sp = cJSON_GetObjectItem(root, "setpoint");
if (!cJSON_IsNumber(sp)) {
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST,
"Missing setpoint field");
float new_sp = (float)sp->valuedouble;
/* Clamp to a reasonable range */
if (new_sp < 10.0f) new_sp = 10.0f;
if (new_sp > 35.0f) new_sp = 35.0f;
xSemaphoreTake(state.mutex, portMAX_DELAY);
xSemaphoreGive(state.mutex);
ESP_LOGI(TAG, "Setpoint changed to %.1f C", new_sp);
/* Respond with confirmation */
cJSON *resp = cJSON_CreateObject();
cJSON_AddStringToObject(resp, "status", "ok");
cJSON_AddNumberToObject(resp, "setpoint", new_sp);
char *json = cJSON_PrintUnformatted(resp);
httpd_resp_set_type(req, "application/json");
httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*");
httpd_resp_sendstr(req, json);
static esp_err_t api_mode_handler(httpd_req_t *req)
int received = httpd_req_recv(req, buf, sizeof(buf) - 1);
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Empty body");
cJSON *root = cJSON_Parse(buf);
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Invalid JSON");
cJSON *mode_item = cJSON_GetObjectItem(root, "mode");
if (!cJSON_IsString(mode_item)) {
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST,
thermostat_mode_t new_mode = str_to_mode(mode_item->valuestring);
xSemaphoreTake(state.mutex, portMAX_DELAY);
xSemaphoreGive(state.mutex);
ESP_LOGI(TAG, "Mode changed to %s", mode_to_str(new_mode));
/* Immediately apply the new mode */
cJSON *resp = cJSON_CreateObject();
cJSON_AddStringToObject(resp, "status", "ok");
cJSON_AddStringToObject(resp, "mode", mode_to_str(new_mode));
char *json = cJSON_PrintUnformatted(resp);
httpd_resp_set_type(req, "application/json");
httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*");
httpd_resp_sendstr(req, json);
/* ================================================================
* HTTP Server: Static File Handler
* ================================================================ */
static esp_err_t static_file_handler(httpd_req_t *req)
const char *uri = req->uri;
if (strcmp(uri, "/") == 0) {
snprintf(filepath, sizeof(filepath), "/spiffs%s", uri);
FILE *f = fopen(filepath, "r");
httpd_resp_send_err(req, HTTPD_404_NOT_FOUND, "File not found");
if (strstr(uri, ".html")) {
httpd_resp_set_type(req, "text/html");
} else if (strstr(uri, ".css")) {
httpd_resp_set_type(req, "text/css");
} else if (strstr(uri, ".js")) {
httpd_resp_set_type(req, "application/javascript");
httpd_resp_set_type(req, "text/plain");
httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*");
while ((read_bytes = fread(chunk, 1, sizeof(chunk), f)) > 0) {
httpd_resp_send_chunk(req, chunk, read_bytes);
httpd_resp_send_chunk(req, NULL, 0);
/* ================================================================
* HTTP Server: Start and Register Handlers
* ================================================================ */
static httpd_handle_t start_webserver(void)
httpd_config_t config = HTTPD_DEFAULT_CONFIG();
config.uri_match_fn = httpd_uri_match_wildcard;
config.stack_size = 8192;
httpd_handle_t server = NULL;
if (httpd_start(&server, &config) != ESP_OK) {
ESP_LOGE(TAG, "Failed to start HTTP server");
/* API endpoints (register before the wildcard catch-all) */
httpd_uri_t status_uri = {
.handler = api_status_handler,
httpd_register_uri_handler(server, &status_uri);
httpd_uri_t setpoint_uri = {
.handler = api_setpoint_handler,
httpd_register_uri_handler(server, &setpoint_uri);
.handler = api_mode_handler,
httpd_register_uri_handler(server, &mode_uri);
/* Static file handler (wildcard, must be last) */
httpd_uri_t static_uri = {
.handler = static_file_handler,
httpd_register_uri_handler(server, &static_uri);
ESP_LOGI(TAG, "HTTP server started on port 80");
/* ================================================================
* ================================================================ */
static void sensor_task(void *pvParameters)
ESP_LOGI(TAG, "Sensor task started on core %d", xPortGetCoreID());
dht_reading_t reading = dht_read();
xSemaphoreTake(state.mutex, portMAX_DELAY);
state.temperature = reading.temperature;
state.humidity = reading.humidity;
xSemaphoreGive(state.mutex);
ESP_LOGI(TAG, "Temp=%.1f C Humidity=%.1f %% "
"Setpoint=%.1f Relay=%s Mode=%s",
reading.temperature, reading.humidity,
state.relay_on ? "ON" : "OFF",
mode_to_str(state.mode));
ESP_LOGW(TAG, "DHT read failed, skipping cycle");
vTaskDelay(pdMS_TO_TICKS(SENSOR_READ_MS));
/* ================================================================
* ================================================================ */
ESP_LOGI(TAG, "ESP32 Thermostat starting");
/* Initialize NVS (required by Wi-Fi) */
esp_err_t ret = nvs_flash_init();
if (ret == ESP_ERR_NVS_NO_FREE_PAGES ||
ret == ESP_ERR_NVS_NEW_VERSION_FOUND) {
/* Initialize thermostat state */
/* Initialize relay (off by default) */
/* Mount SPIFFS filesystem */
/* Start the HTTP server */
/* Start the sensor reading task */
ESP_LOGI(TAG, "System ready. Open http://thermostat.local");
Comments