ESP-IDF v5.3.2 firmware for the CrowPanel 2.1" rotary display. Builds clean;
nothing has been run on hardware yet.
Hardware verified against Elecrow's own source for this board and
cross-checked against their ESPHome config, per PRD 8.1. Three corrections to
the PRD's pinout, all documented in docs/HARDWARE.md:
- touch is CST826 at 0x15, not CST816 (chip ID reads 0x11, not 0xB4)
- the ST7701S needs a 3-wire SPI bus on GPIO 16/2/1 that the PRD omitted;
without it the init sequence cannot be sent and the panel stays black
- the backlight is GPIO6
Two further departures from the PRD, both forced by facts on the ground:
- Transport is MQTT, not HTTP. zigbee2mqtt has no REST endpoint for live
sensor state. Subscribing to the broker also gets retained topics, so
every room's last reading arrives on connect rather than after a poll.
- The furnace is 2-wire R/W with no C, not 3-wire. The board needs its own
5V supply. The thermostat's 5 CPH setting is enforced as a minimum
12-minute period between heat-call starts, which a minimum off-time alone
does not achieve.
All of PRD section 5 is implemented in the relay layer itself: fail-open,
watchdog-gated output, max on-time, min off-time, cycle-rate limit, startup
lockout, and the stale-data fallback ladder down to open-loop freeze
protection. The one gap that cannot be closed in software — the PCF8574 not
resetting when the ESP32 does — is documented rather than papered over.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
301 lines
8.3 KiB
C
301 lines
8.3 KiB
C
#include "settings.h"
|
|
|
|
#include <string.h>
|
|
|
|
#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;
|
|
}
|
|
|