#include "settings.h" #include #include "esp_log.h" #include "esp_timer.h" #include "freertos/FreeRTOS.h" #include "freertos/semphr.h" #include "freertos/task.h" #include "nvs.h" #include "nvs_flash.h" #include "zigbee_client.h" static const char *TAG = "settings"; #define NVS_NAMESPACE "thermo" #define KEY_SETPOINT "setpoint" #define KEY_MODE "mode" #define KEY_ROOM "room" #define KEY_SCHEDULE "sched" /* Wait for the value to settle before committing. Comfortably longer than a * knob twiddle, short enough that a power cut rarely loses a change. */ #define COMMIT_DEBOUNCE_MS 5000 static settings_t s_settings; static SemaphoreHandle_t s_lock; static volatile int64_t s_dirty_since_us; /* 0 = clean */ static void commit_task(void *arg); static void defaults(settings_t *s) { memset(s, 0, sizeof(*s)); s->setpoint_c = CONTROL_SETPOINT_DEFAULT_C; s->mode = ROOM_MODE_FORMULA; /* coldest room wins — PRD 4.2 recommends */ s->selected_room = 0; /* * A plausible starting schedule matching the PRD's example: bedroom * overnight, TV room during the evening. Rooms are indices into * APP_ROOM_TABLE, so this only makes sense with at least two rooms. */ if (ROOM_COUNT >= 2) { s->schedule[0] = (schedule_rule_t){ .start_hour = 22, .start_minute = 0, .room_index = 1, .enabled = true }; s->schedule[1] = (schedule_rule_t){ .start_hour = 7, .start_minute = 0, .room_index = 0, .enabled = true }; } } static void mark_dirty(void) { s_dirty_since_us = esp_timer_get_time(); } /* --------------------------------------------------------------- load --- */ esp_err_t settings_init(void) { if (!s_lock) { s_lock = xSemaphoreCreateMutex(); if (!s_lock) { return ESP_ERR_NO_MEM; } } defaults(&s_settings); esp_err_t err = nvs_flash_init(); if (err == ESP_ERR_NVS_NO_FREE_PAGES || err == ESP_ERR_NVS_NEW_VERSION_FOUND) { ESP_LOGW(TAG, "NVS needs erasing, doing that"); ESP_ERROR_CHECK(nvs_flash_erase()); err = nvs_flash_init(); } if (err != ESP_OK) { ESP_LOGE(TAG, "nvs init failed: %s — running on defaults", esp_err_to_name(err)); return err; } nvs_handle_t h; err = nvs_open(NVS_NAMESPACE, NVS_READWRITE, &h); if (err != ESP_OK) { ESP_LOGW(TAG, "nvs_open failed: %s — running on defaults", esp_err_to_name(err)); return ESP_OK; } /* Each key is optional and independently validated: a corrupt or * out-of-range value falls back to its default rather than poisoning the * whole settings blob. */ size_t len = sizeof(float); float sp; if (nvs_get_blob(h, KEY_SETPOINT, &sp, &len) == ESP_OK && sp >= CONTROL_SETPOINT_MIN_C && sp <= CONTROL_SETPOINT_MAX_C) { s_settings.setpoint_c = sp; } uint8_t u8; if (nvs_get_u8(h, KEY_MODE, &u8) == ESP_OK && u8 < ROOM_MODE_COUNT) { s_settings.mode = u8; } if (nvs_get_u8(h, KEY_ROOM, &u8) == ESP_OK && u8 < ROOM_COUNT) { s_settings.selected_room = u8; } schedule_rule_t sched[SCHEDULE_MAX_RULES]; len = sizeof(sched); if (nvs_get_blob(h, KEY_SCHEDULE, sched, &len) == ESP_OK && len == sizeof(sched)) { bool ok = true; for (int i = 0; i < SCHEDULE_MAX_RULES; i++) { if (sched[i].enabled && (sched[i].start_hour > 23 || sched[i].start_minute > 59 || sched[i].room_index >= ROOM_COUNT)) { ok = false; break; } } if (ok) { memcpy(s_settings.schedule, sched, sizeof(sched)); } else { ESP_LOGW(TAG, "stored schedule is out of range, using defaults"); } } nvs_close(h); ESP_LOGI(TAG, "loaded: setpoint %.1fC, mode %u, room %u", s_settings.setpoint_c, s_settings.mode, s_settings.selected_room); if (xTaskCreate(commit_task, "nvs_commit", 3072, NULL, 2, NULL) != pdPASS) { /* Not fatal: settings still work for this session, they just will not * be written back. Better than refusing to run the thermostat. */ ESP_LOGE(TAG, "commit task create failed — changes will not persist"); } return ESP_OK; } /* ------------------------------------------------------------- commit --- */ esp_err_t settings_commit_now(void) { if (!s_lock) { return ESP_ERR_INVALID_STATE; } settings_t snapshot; xSemaphoreTake(s_lock, portMAX_DELAY); snapshot = s_settings; s_dirty_since_us = 0; xSemaphoreGive(s_lock); nvs_handle_t h; esp_err_t err = nvs_open(NVS_NAMESPACE, NVS_READWRITE, &h); if (err != ESP_OK) { ESP_LOGE(TAG, "nvs_open for write failed: %s", esp_err_to_name(err)); return err; } nvs_set_blob(h, KEY_SETPOINT, &snapshot.setpoint_c, sizeof(float)); nvs_set_u8(h, KEY_MODE, snapshot.mode); nvs_set_u8(h, KEY_ROOM, snapshot.selected_room); nvs_set_blob(h, KEY_SCHEDULE, snapshot.schedule, sizeof(snapshot.schedule)); err = nvs_commit(h); nvs_close(h); if (err == ESP_OK) { ESP_LOGI(TAG, "committed: setpoint %.1fC, mode %u, room %u", snapshot.setpoint_c, snapshot.mode, snapshot.selected_room); } else { ESP_LOGE(TAG, "commit failed: %s", esp_err_to_name(err)); } return err; } static void commit_task(void *arg) { (void)arg; while (1) { int64_t dirty = s_dirty_since_us; if (dirty != 0 && (esp_timer_get_time() - dirty) > (int64_t)COMMIT_DEBOUNCE_MS * 1000) { settings_commit_now(); } vTaskDelay(pdMS_TO_TICKS(1000)); } } /* ---------------------------------------------------------------- api --- */ void settings_get(settings_t *out) { if (!out) { return; } if (!s_lock) { defaults(out); return; } xSemaphoreTake(s_lock, portMAX_DELAY); *out = s_settings; xSemaphoreGive(s_lock); } void settings_set_setpoint(float celsius) { if (!s_lock) { return; } xSemaphoreTake(s_lock, portMAX_DELAY); if (s_settings.setpoint_c != celsius) { s_settings.setpoint_c = celsius; mark_dirty(); } xSemaphoreGive(s_lock); } void settings_set_mode(uint8_t mode) { if (!s_lock || mode >= ROOM_MODE_COUNT) { return; } xSemaphoreTake(s_lock, portMAX_DELAY); if (s_settings.mode != mode) { s_settings.mode = mode; mark_dirty(); } xSemaphoreGive(s_lock); } void settings_set_selected_room(uint8_t room) { if (!s_lock || room >= ROOM_COUNT) { return; } xSemaphoreTake(s_lock, portMAX_DELAY); if (s_settings.selected_room != room) { s_settings.selected_room = room; mark_dirty(); } xSemaphoreGive(s_lock); } void settings_set_schedule(const schedule_rule_t *rules, int count) { if (!s_lock || !rules || count < 0 || count > SCHEDULE_MAX_RULES) { return; } xSemaphoreTake(s_lock, portMAX_DELAY); memset(s_settings.schedule, 0, sizeof(s_settings.schedule)); memcpy(s_settings.schedule, rules, sizeof(schedule_rule_t) * (size_t)count); mark_dirty(); xSemaphoreGive(s_lock); } int settings_schedule_room_for_time(int hour, int minute) { if (hour < 0 || hour > 23 || minute < 0 || minute > 59) { return -1; } int now = hour * 60 + minute; int best_room = -1; int best_delta = -1; xSemaphoreTake(s_lock, portMAX_DELAY); /* * A rule owns the time from its start until the next enabled rule starts. * Finding the rule with the smallest non-negative "minutes since it * started", wrapping around midnight, is the same thing without needing * the rules to be sorted. */ for (int i = 0; i < SCHEDULE_MAX_RULES; i++) { const schedule_rule_t *r = &s_settings.schedule[i]; if (!r->enabled) { continue; } int start = r->start_hour * 60 + r->start_minute; int delta = now - start; if (delta < 0) { delta += 24 * 60; } if (best_delta < 0 || delta < best_delta) { best_delta = delta; best_room = r->room_index; } } xSemaphoreGive(s_lock); return best_room; }