diff --git a/README.md b/README.md index 91de73b..7188a98 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,57 @@ idf.py build flash monitor Requires ESP-IDF **v5.3 or newer** (the code uses the `i2c_master` driver API and `dma_burst_size`). +## Thermostat control + +Single-stage gas furnace, no A/C. The control loop is in +`main/control/thermostat.c` and is **pure logic** — no ESP-IDF, no network, no +clock of its own. The caller supplies a temperature and a millisecond +timestamp; that is what lets the desktop simulator and the test harness run +the identical decision code. + +| Protection | Value | Why | +|---|---|---| +| Deadband | 1.0 °F | stops the burner chattering at the setpoint | +| Minimum on | 3 min | a shorter call delivers almost no heat for a full ignition's wear | +| Minimum off | 5 min | back-to-back ignitions are what kill igniters | +| Cycles/hour | 5 | standard gas default; catches a flapping sensor | +| Boot lockout | 60 s | a boot loop must not machine-gun the gas valve | +| Sensor staleness | 15 min | see below | + +Verify after any change to that file — it switches a gas valve: + +```bash +gcc -Wall -Wextra -I main/control -o /tmp/therm_test \ + tools/thermostat_test.c main/control/thermostat.c -lm && /tmp/therm_test +``` + +Two decisions worth knowing: + +- **G is never energized during a heat call.** The furnace runs its own + blower with a fan-on delay that waits for the heat exchanger and a fan-off + delay that scavenges residual heat. Driving G ourselves would blow cold air + at people and throw that heat away. G is manual circulation only. +- **The control loop starts before any of app_main's early returns.** Every + one of those is about WiFi or HA being unusable, and none of them is a + reason to leave a house unheated. + +### The sensor is the weak link + +Indoor temperature arrives over the network from Home Assistant. If HA stops +answering, the reading ages; after 15 minutes the state becomes `NO_SENSOR`, +heat is forced off and the card turns red. Running blind on an old number +could overheat the house or let it freeze, and neither is acceptable. + +This is a deliberate compromise, not a design. **The real fix is a local +sensor on the I2C bus**, which removes the failure mode entirely — an SHT30 +or BME280 at a free address. Note the panel dissipates ~5 W, so it must be +remote from the enclosure or it will read several degrees high. + +**Setpoint does not survive a reboot** (defaults to 68 °F). Persisting it +would mean an NVS write, and a flash write while the panel is scanning faults +the bounce-buffer ISR — see Memory notes. Storing it in an HA `input_number` +would work and needs no flash. + ## Home Assistant Everything on screen except the forecast comes from **one** POST to @@ -141,6 +192,36 @@ then `0x10` and `0x18` to configure and release the GT911. Brightness is `0x00`–`0xF5` **inverted** (`0x00` = full) on v1.3; v1.2 uses a narrower `0x05`–`0x10` range instead. +### UART1-OUT plug — the only free GPIO on this board + +Nearly every pin is spoken for: 20 on the RGB bus, 2 on I2C, IO4/5/6 on +I2S+SD, IO8 buzzer, IO43/44 the console. The UART1-OUT plug (PH2.0-4P) is +what's left, and it is where the furnace relays connect. + +| Wire | Pin | | +|------|-----|---| +| black | GND | | +| red | 3.3 V | 1 A budget; two relay coils ≈ 260 mA | +| white | **IO20** | measured, not documented by Elecrow | +| yellow | **IO19** | measured, not documented by Elecrow | + +Elecrow publishes the GPIO numbers but **not the pin order**, so the +white/yellow mapping above was measured on this board (2026-08-05) by driving +each pin at a distinct blink rate and metering the plug. Static levels cannot +work for this: a pin held high is indistinguishable from the 3.3 V rail, and +one held low from GND. + +Both pins were also confirmed electrically free — each follows an internal +pulldown to 0 and pullup to 1, so nothing else is driving them. + +> **IO19/IO20 are shared** with the onboard I2S mic (MIC_CLK/MIC_SD) and are +> the ESP32-S3's native USB D−/D+. DIP switches select the mic away: +> S1=0 S0=1 is *wireless module* mode, which leaves these pins to us. Native +> USB is unused — the board flashes over UART0 through a CH340 +> (`/dev/ttyUSB0`), so `CONFIG_USJ_ENABLE_USB_SERIAL_JTAG` and +> `CONFIG_ESP_PHY_ENABLE_USB` can both be turned off to release the pins +> fully. + ## Display architecture Two 800×480×2 framebuffers in PSRAM, LVGL in `RENDER_MODE_DIRECT`. LVGL draws diff --git a/TODO.md b/TODO.md index f7c5c18..01a791c 100644 --- a/TODO.md +++ b/TODO.md @@ -1,5 +1,21 @@ # TODO +## Thermostat: next steps + +1. **Fit the relay board.** Wiring is in README "UART1-OUT plug". Until then + the GPIOs are the only output — meter them (idle reads 3.3 V, energized + 0 V) to confirm the control loop end to end. +2. **Local temperature sensor.** The single biggest weakness: indoor temp + comes from HA over the network, so an HA outage eventually forces heat off. + An SHT30/BME280 on the I2C bus fixes it. Mount it away from the panel, + which self-heats by several degrees. +3. **Persist the setpoint.** Resets to 68 °F on every boot. NVS is not an + option while the panel scans (cache fault); an HA `input_number` is. +4. **Watchdog.** If the firmware hangs mid-call the relay stays latched until + someone notices. The furnace's own high limit makes that uncomfortable + rather than dangerous, but the task watchdog should reboot into the safe + state. + ## Not yet verified on hardware Flashed and booting clean (two-phase display init, icons on, first poll ~9s, diff --git a/main/CMakeLists.txt b/main/CMakeLists.txt index f0f791b..e3bf7ec 100644 --- a/main/CMakeLists.txt +++ b/main/CMakeLists.txt @@ -6,6 +6,8 @@ idf_component_register( "drivers/backlight/backlight.c" "drivers/display/display_rgb.c" "drivers/touch/touch_gt911.c" + "drivers/relay/relay.c" + "control/thermostat.c" "ui/lvgl_port.c" "ui/ui_manager.c" "ui/icon_font.c" @@ -19,6 +21,8 @@ idf_component_register( "drivers/backlight" "drivers/display" "drivers/touch" + "drivers/relay" + "control" "ui" "api" REQUIRES diff --git a/main/control/thermostat.c b/main/control/thermostat.c new file mode 100644 index 0000000..092d36c --- /dev/null +++ b/main/control/thermostat.c @@ -0,0 +1,308 @@ +/* + * Thermostat control logic. See thermostat.h for the reasoning behind each + * protection; this file is the mechanism. + * + * Pure logic by design -- compiled into the desktop simulator as well as the + * firmware, so the whole control loop can be exercised against a fake furnace + * before it ever switches a gas valve. + */ + +#include +#include + +#include "thermostat.h" + +/* Room for the CPH ceiling plus a couple, so the rolling-hour scan always has + * the full picture even at the limit. */ +#define CYCLE_SLOTS (THERM_MAX_CYCLES_PER_HOUR + 3) +#define ONE_HOUR_MS (60u * 60u * 1000u) + +static struct { + therm_mode_t mode; + therm_fan_t fan; + float setpoint_f; + + bool heat_on; + uint32_t heat_since_ms; /* when the burner last turned on */ + uint32_t heat_off_ms; /* when it last turned off */ + bool have_off_stamp; + + float last_temp_f; + bool have_temp; + uint32_t last_temp_ms; + + uint32_t boot_ms; + uint32_t last_tick_ms; /* so get_status() can age the cycle ring */ + + /* Rolling-hour ring of burner start times. */ + uint32_t cycle_ms[CYCLE_SLOTS]; + bool cycle_used[CYCLE_SLOTS]; + int cycle_head; + + therm_state_t state; + uint32_t lockout_remaining_s; +} s; + +/* Wrap-safe: unsigned subtraction gives the correct elapsed time across the + * 32-bit rollover at ~49 days. */ +static uint32_t elapsed(uint32_t now_ms, uint32_t then_ms) +{ + return now_ms - then_ms; +} + +static void cycle_record(uint32_t now_ms) +{ + s.cycle_ms[s.cycle_head] = now_ms; + s.cycle_used[s.cycle_head] = true; + s.cycle_head = (s.cycle_head + 1) % CYCLE_SLOTS; +} + +static uint8_t cycles_in_last_hour(uint32_t now_ms) +{ + uint8_t n = 0; + for (int i = 0; i < CYCLE_SLOTS; i++) { + if (s.cycle_used[i] && elapsed(now_ms, s.cycle_ms[i]) < ONE_HOUR_MS) { + n++; + } + } + return n; +} + +/* When the oldest cycle inside the window ages out, freeing a slot. */ +static uint32_t ms_until_cycle_slot(uint32_t now_ms) +{ + uint32_t soonest = UINT32_MAX; + for (int i = 0; i < CYCLE_SLOTS; i++) { + if (!s.cycle_used[i]) { + continue; + } + const uint32_t age = elapsed(now_ms, s.cycle_ms[i]); + if (age < ONE_HOUR_MS) { + const uint32_t remaining = ONE_HOUR_MS - age; + if (remaining < soonest) { + soonest = remaining; + } + } + } + return (soonest == UINT32_MAX) ? 0 : soonest; +} + +void thermostat_init(uint32_t now_ms) +{ + memset(&s, 0, sizeof(s)); + s.mode = THERM_MODE_HEAT; + s.fan = THERM_FAN_AUTO; + s.setpoint_f = THERM_SETPOINT_DEFAULT_F; + s.boot_ms = now_ms; + s.state = THERM_STATE_IDLE; +} + +void thermostat_set_mode(therm_mode_t mode) +{ + s.mode = mode; +} + +void thermostat_set_fan(therm_fan_t fan) +{ + s.fan = fan; +} + +therm_mode_t thermostat_get_mode(void) +{ + return s.mode; +} + +therm_fan_t thermostat_get_fan(void) +{ + return s.fan; +} + +static void clamp_setpoint(void) +{ + if (s.setpoint_f < THERM_SETPOINT_MIN_F) { + s.setpoint_f = THERM_SETPOINT_MIN_F; + } else if (s.setpoint_f > THERM_SETPOINT_MAX_F) { + s.setpoint_f = THERM_SETPOINT_MAX_F; + } +} + +void thermostat_adjust_setpoint(float delta_f) +{ + s.setpoint_f += delta_f; + clamp_setpoint(); +} + +void thermostat_set_setpoint(float value_f) +{ + s.setpoint_f = value_f; + clamp_setpoint(); +} + +float thermostat_get_setpoint(void) +{ + return s.setpoint_f; +} + +static void heat_start(uint32_t now_ms) +{ + s.heat_on = true; + s.heat_since_ms = now_ms; + cycle_record(now_ms); +} + +static void heat_stop(uint32_t now_ms) +{ + s.heat_on = false; + s.heat_off_ms = now_ms; + s.have_off_stamp = true; +} + +void thermostat_tick(float indoor_f, bool temp_valid, uint32_t now_ms) +{ + s.last_tick_ms = now_ms; + + if (temp_valid) { + s.last_temp_f = indoor_f; + s.last_temp_ms = now_ms; + s.have_temp = true; + } + + s.lockout_remaining_s = 0; + + /* + * Mode off, or the reading has gone stale enough that acting on it is + * guesswork. Either way the burner stops -- and note this bypasses the + * minimum-on timer, which only exists to stop rapid restarts. Stopping + * heat is never bad for the furnace. + */ + const bool sensor_dead = + !s.have_temp || elapsed(now_ms, s.last_temp_ms) > THERM_SENSOR_STALE_MS; + + if (s.mode == THERM_MODE_OFF || sensor_dead) { + if (s.heat_on) { + heat_stop(now_ms); + } + s.state = (s.mode == THERM_MODE_OFF) ? THERM_STATE_OFF : THERM_STATE_NO_SENSOR; + return; + } + + const float half = THERM_DEADBAND_F / 2.0f; + const float temp = s.last_temp_f; + + if (s.heat_on) { + /* Running: stop once we are above the top of the deadband, but not + * before the minimum on time is served. */ + const bool satisfied = (temp >= s.setpoint_f + half); + const bool min_on_served = elapsed(now_ms, s.heat_since_ms) >= THERM_MIN_ON_MS; + + if (satisfied && min_on_served) { + heat_stop(now_ms); + s.state = THERM_STATE_IDLE; + } else { + s.state = THERM_STATE_HEATING; + } + return; + } + + /* Idle: does the house want heat at all? */ + if (temp > s.setpoint_f - half) { + s.state = THERM_STATE_IDLE; + return; + } + + /* It does. Every protection gets a veto, and whichever has the longest + * remaining time is the one the display should report. */ + uint32_t wait_ms = 0; + + const uint32_t since_boot = elapsed(now_ms, s.boot_ms); + if (since_boot < THERM_BOOT_LOCKOUT_MS) { + wait_ms = THERM_BOOT_LOCKOUT_MS - since_boot; + } + + if (s.have_off_stamp) { + const uint32_t off_for = elapsed(now_ms, s.heat_off_ms); + if (off_for < THERM_MIN_OFF_MS) { + const uint32_t remaining = THERM_MIN_OFF_MS - off_for; + if (remaining > wait_ms) { + wait_ms = remaining; + } + } + } + + if (cycles_in_last_hour(now_ms) >= THERM_MAX_CYCLES_PER_HOUR) { + const uint32_t remaining = ms_until_cycle_slot(now_ms); + if (remaining > wait_ms) { + wait_ms = remaining; + } + } + + if (wait_ms > 0) { + s.state = THERM_STATE_LOCKOUT; + /* Round up, so a display counting down never shows 0 while still + * locked out. */ + s.lockout_remaining_s = (wait_ms + 999u) / 1000u; + return; + } + + heat_start(now_ms); + s.state = THERM_STATE_HEATING; +} + +void thermostat_get_status(therm_status_t *out) +{ + if (out == NULL) { + return; + } + + out->mode = s.mode; + out->fan = s.fan; + out->setpoint_f = s.setpoint_f; + out->state = s.state; + out->heat_relay = s.heat_on; + out->lockout_remaining_s = s.lockout_remaining_s; + /* Aged against the last tick, not the last temperature: a stale sensor + * must not freeze the cycle count at whatever it was when the reading + * stopped arriving. */ + out->cycles_last_hour = cycles_in_last_hour(s.last_tick_ms); + + /* + * G is NOT energized during a heat call. + * + * A gas furnace runs its own blower from its control board, with a + * fan-on delay that waits for the heat exchanger to warm and a fan-off + * delay that scavenges the residual heat afterwards. Driving G ourselves + * during heat would start the blower early and blow cold air at people -- + * and throw away the post-cycle heat. G is for manual circulation only. + */ + out->fan_relay = (s.fan == THERM_FAN_ON); +} + +void thermostat_state_text(const therm_status_t *st, char *buf, size_t len) +{ + if (st == NULL || buf == NULL || len == 0) { + return; + } + + switch (st->state) { + case THERM_STATE_OFF: + snprintf(buf, len, "Off"); + break; + case THERM_STATE_HEATING: + snprintf(buf, len, "Heating"); + break; + case THERM_STATE_LOCKOUT: + if (st->lockout_remaining_s >= 60) { + snprintf(buf, len, "Hold %lum", (unsigned long)(st->lockout_remaining_s / 60)); + } else { + snprintf(buf, len, "Hold %lus", (unsigned long)st->lockout_remaining_s); + } + break; + case THERM_STATE_NO_SENSOR: + snprintf(buf, len, "No sensor"); + break; + case THERM_STATE_IDLE: + default: + snprintf(buf, len, "Idle"); + break; + } +} diff --git a/main/control/thermostat.h b/main/control/thermostat.h new file mode 100644 index 0000000..34c5f5f --- /dev/null +++ b/main/control/thermostat.h @@ -0,0 +1,144 @@ +/* + * Thermostat control logic for a single-stage gas furnace with no A/C. + * + * Pure logic: no ESP-IDF, no LVGL, no network, no clock of its own. The + * caller supplies the temperature, a monotonic millisecond timestamp, and + * applies the relay outputs. That is what lets the desktop simulator run the + * real control loop against a fake furnace. + * + * Everything here exists to protect the equipment and the occupants: + * + * - hysteresis, so the burner does not chatter around the setpoint + * - minimum on and off times, so a cycle is long enough to be useful and + * the furnace gets a rest between them + * - a cycles-per-hour ceiling, the standard HVAC guard against short + * cycling wearing out the igniter and heat exchanger + * - a sensor-staleness failsafe, because the indoor temperature arrives + * over the network and the network can go away + */ +#ifndef THERMOSTAT_H +#define THERMOSTAT_H + +#include +#include +#include + +/* --- Tunables ----------------------------------------------------------- */ + +/* + * Total deadband in °F, split either side of the setpoint. Heat starts below + * (setpoint - half) and stops above (setpoint + half). 1.0°F is a normal + * residential value; smaller feels more precise and cycles far more. + */ +#define THERM_DEADBAND_F 1.0f + +/* + * Once the burner lights it runs at least this long. A gas furnace spends the + * first minutes bringing the heat exchanger up before the blower even starts, + * so a shorter call delivers almost no heat to the house for a full ignition + * cycle's worth of wear. + */ +#define THERM_MIN_ON_MS (3u * 60u * 1000u) + +/* + * And stays off at least this long afterwards. This is the single most + * important protection here -- back-to-back ignitions are what destroy + * igniters. + */ +#define THERM_MIN_OFF_MS (5u * 60u * 1000u) + +/* + * Ceiling on burner starts in any rolling hour. 5 is the usual default for + * gas (Honeywell ships 5 CPH for gas, 3 for oil). Min-on plus min-off already + * implies a floor of 8 minutes per cycle, so this mostly catches pathological + * cases -- a flapping sensor, or a setpoint being nudged repeatedly. + */ +#define THERM_MAX_CYCLES_PER_HOUR 5 + +/* + * How long a stale indoor reading may still be used before heat is forced + * off. The temperature comes from Home Assistant; if HA dies mid-winter, + * running blind on an old number could overheat the house or let it freeze. + * + * The honest fix is a local sensor on the I2C bus, which removes this failure + * mode entirely. Until then this is a deliberate compromise: keep heating on + * the last known value long enough to ride out a restart, then stop and say + * so loudly on screen. + */ +#define THERM_SENSOR_STALE_MS (15u * 60u * 1000u) + +/* + * No heat for the first minute after boot, whatever the temperature says. If + * the firmware ever gets into a boot loop, this stops it from machine-gunning + * the gas valve. + */ +#define THERM_BOOT_LOCKOUT_MS (60u * 1000u) + +#define THERM_SETPOINT_MIN_F 50.0f +#define THERM_SETPOINT_MAX_F 85.0f +#define THERM_SETPOINT_DEFAULT_F 68.0f + +/* --- Types -------------------------------------------------------------- */ + +typedef enum { + THERM_MODE_OFF = 0, + THERM_MODE_HEAT, +} therm_mode_t; + +typedef enum { + THERM_FAN_AUTO = 0, /* furnace runs its own blower on a heat call */ + THERM_FAN_ON, /* continuous circulation */ +} therm_fan_t; + +typedef enum { + THERM_STATE_OFF = 0, /* mode is off */ + THERM_STATE_IDLE, /* warm enough, nothing to do */ + THERM_STATE_HEATING, /* burner called */ + THERM_STATE_LOCKOUT, /* wants heat, held back by a protection timer */ + THERM_STATE_NO_SENSOR, /* reading too stale to act on; heat forced off */ +} therm_state_t; + +typedef struct { + therm_mode_t mode; + therm_fan_t fan; + float setpoint_f; + therm_state_t state; + bool heat_relay; + bool fan_relay; + /* Seconds until the protection timer clears, for the display. 0 unless + * state is THERM_STATE_LOCKOUT. */ + uint32_t lockout_remaining_s; + uint8_t cycles_last_hour; +} therm_status_t; + +/* --- API ---------------------------------------------------------------- */ + +/* now_ms seeds the boot lockout, so pass the same clock used for tick(). */ +void thermostat_init(uint32_t now_ms); + +void thermostat_set_mode(therm_mode_t mode); +void thermostat_set_fan(therm_fan_t fan); +therm_mode_t thermostat_get_mode(void); +therm_fan_t thermostat_get_fan(void); + +/* Clamped to THERM_SETPOINT_MIN_F..MAX_F. */ +void thermostat_adjust_setpoint(float delta_f); +void thermostat_set_setpoint(float value_f); +float thermostat_get_setpoint(void); + +/* + * Run the control loop. Call at roughly 1Hz -- more often is harmless, less + * often makes the lockout countdown coarse. + * + * `temp_valid` false means the reading is unavailable right now; the last + * good value keeps being used until THERM_SENSOR_STALE_MS has passed. + * `now_ms` must be monotonic; wrap is handled. + */ +void thermostat_tick(float indoor_f, bool temp_valid, uint32_t now_ms); + +void thermostat_get_status(therm_status_t *out); + +/* Human-readable state, for the status line ("Heating", "Locked out 4m"). */ +void thermostat_state_text(const therm_status_t *st, char *buf, size_t len); + +#endif diff --git a/main/drivers/relay/relay.c b/main/drivers/relay/relay.c new file mode 100644 index 0000000..c76144d --- /dev/null +++ b/main/drivers/relay/relay.c @@ -0,0 +1,135 @@ +/* + * Furnace call relays. See relay.h for the pin assignment and why heat is on + * IO20 rather than IO19. + */ + +#include "driver/gpio.h" +#include "esp_check.h" +#include "esp_log.h" + +#include "relay.h" + +static const char *TAG = "relay"; + +#define RELAY_HEAT_GPIO 20 /* white wire on the UART1-OUT plug */ +#define RELAY_FAN_GPIO 19 /* yellow wire */ + +/* + * Standard opto-isolated relay boards are active low: pulling IN to 0V lights + * the optocoupler and energizes the coil. Set to 0 if you fit an active-high + * module -- and re-check the boot state below, because the safe direction + * flips with it. + */ +#define RELAY_ACTIVE_LOW 1 + +#if RELAY_ACTIVE_LOW +#define RELAY_LEVEL_ON 0 +#define RELAY_LEVEL_OFF 1 +#else +#define RELAY_LEVEL_ON 1 +#define RELAY_LEVEL_OFF 0 +#endif + +static const int relay_gpio[RELAY_COUNT] = { + [RELAY_HEAT] = RELAY_HEAT_GPIO, + [RELAY_FAN] = RELAY_FAN_GPIO, +}; + +static bool relay_state[RELAY_COUNT]; + +esp_err_t relay_init(void) +{ + for (int i = 0; i < RELAY_COUNT; i++) { + const int pin = relay_gpio[i]; + + /* + * IO19/IO20 are the S3's native USB D-/D+ and are also wired to the + * onboard I2S mic. gpio_reset_pin() detaches whatever peripheral + * currently owns the pad; the DIP switches must be in wireless-module + * mode (S1=0 S0=1) so the mic is not driving them, and + * CONFIG_ESP_PHY_ENABLE_USB / CONFIG_USJ_ENABLE_USB_SERIAL_JTAG are + * off so the USB PHY is not either. + */ + gpio_reset_pin(pin); + + /* + * Drive the de-energized level BEFORE the pad becomes an output. + * Configuring the direction first would briefly present whatever was + * in the output register -- and on the heat channel that is a burst + * of gas. + */ + ESP_RETURN_ON_ERROR(gpio_set_level(pin, RELAY_LEVEL_OFF), TAG, + "gpio_set_level(%d) failed", pin); + + const gpio_config_t cfg = { + .pin_bit_mask = 1ULL << pin, + /* INPUT_OUTPUT so relay_get() can read the actual pad rather than + * the output latch -- a readback that disagrees means something + * else is driving the line. */ + .mode = GPIO_MODE_INPUT_OUTPUT, +#if RELAY_ACTIVE_LOW + /* Holds the de-energized level through reset and reflash, before + * any of our code runs. */ + .pull_up_en = GPIO_PULLUP_ENABLE, + .pull_down_en = GPIO_PULLDOWN_DISABLE, +#else + .pull_up_en = GPIO_PULLUP_DISABLE, + .pull_down_en = GPIO_PULLDOWN_ENABLE, +#endif + .intr_type = GPIO_INTR_DISABLE, + }; + ESP_RETURN_ON_ERROR(gpio_config(&cfg), TAG, "gpio_config(%d) failed", pin); + ESP_RETURN_ON_ERROR(gpio_set_level(pin, RELAY_LEVEL_OFF), TAG, + "gpio_set_level(%d) failed", pin); + + relay_state[i] = false; + } + + ESP_LOGI(TAG, "Relays idle. heat=IO%d fan=IO%d, active %s (idle reads %s)", + RELAY_HEAT_GPIO, RELAY_FAN_GPIO, + RELAY_ACTIVE_LOW ? "low" : "high", + RELAY_ACTIVE_LOW ? "3.3V" : "0V"); + + return ESP_OK; +} + +void relay_set(relay_id_t id, bool energized) +{ + if (id < 0 || id >= RELAY_COUNT) { + return; + } + + if (relay_state[id] == energized) { + return; + } + + relay_state[id] = energized; + gpio_set_level(relay_gpio[id], energized ? RELAY_LEVEL_ON : RELAY_LEVEL_OFF); + + /* + * Logged on every change, deliberately. With no relay board fitted yet + * this log plus a multimeter on the plug is the whole test rig, and once + * one is fitted it is the record of what the firmware asked the furnace + * to do. + */ + ESP_LOGI(TAG, "%s -> %s (IO%d now %s)", + relay_name(id), energized ? "ON" : "OFF", relay_gpio[id], + (energized ? RELAY_LEVEL_ON : RELAY_LEVEL_OFF) ? "3.3V" : "0V"); +} + +bool relay_get(relay_id_t id) +{ + if (id < 0 || id >= RELAY_COUNT) { + return false; + } + return relay_state[id]; +} + +const char *relay_name(relay_id_t id) +{ + switch (id) { + case RELAY_HEAT: return "heat(W)"; + case RELAY_FAN: return "fan(G)"; + default: return "?"; + } +} diff --git a/main/drivers/relay/relay.h b/main/drivers/relay/relay.h new file mode 100644 index 0000000..7aad777 --- /dev/null +++ b/main/drivers/relay/relay.h @@ -0,0 +1,40 @@ +/* + * Furnace call relays on the UART1-OUT plug. + * + * Pin assignment was measured on this board -- Elecrow documents the GPIO + * numbers but not the connector pin order. See README, "UART1-OUT plug". + * + * heat (W) IO20, white wire + * fan (G) IO19, yellow wire + * + * Heat is on IO20 deliberately. IO20 is the ESP32-S3's USB D+, the line with + * the hardware pull-up; with an active-low relay module a pull-up means + * de-energized, so if the USB PHY is ever switched back on this pin fails + * toward "no heat" rather than "heat stuck on". IO19 (D-) has no such pull-up. + * + * The simulator provides its own implementation of this interface, which is + * why the API mentions no GPIOs. + */ +#ifndef RELAY_H +#define RELAY_H + +#include + +#include "esp_err.h" + +typedef enum { + RELAY_HEAT = 0, /* W -- gas valve call */ + RELAY_FAN, /* G -- blower, manual circulation only */ + RELAY_COUNT, +} relay_id_t; + +/* Leaves both relays de-energized. Safe to call before anything else; it does + * not depend on the display or the network. */ +esp_err_t relay_init(void); + +void relay_set(relay_id_t id, bool energized); +bool relay_get(relay_id_t id); + +const char *relay_name(relay_id_t id); + +#endif diff --git a/main/main.c b/main/main.c index 4401ce6..da6b11b 100644 --- a/main/main.c +++ b/main/main.c @@ -5,6 +5,7 @@ #include "esp_log.h" #include "freertos/FreeRTOS.h" #include "freertos/queue.h" +#include "freertos/semphr.h" #include "freertos/task.h" #include "ambient_weather.h" @@ -15,6 +16,8 @@ #include "ha_data.h" #include "i2c_bus.h" #include "lvgl_port.h" +#include "relay.h" +#include "thermostat.h" #include "touch_gt911.h" #include "ui_manager.h" #include "wifi_manager.h" @@ -50,6 +53,12 @@ static const char *TAG = "main"; * be dropped, but a user cannot meaningfully get more than a few ahead. */ #define CMD_QUEUE_DEPTH 8 +/* The control loop runs at 1Hz -- fast enough for a live lockout countdown, + * and far faster than a house changes temperature. */ +#define THERM_TASK_STACK 4096 +#define THERM_TASK_PRIO 4 +#define THERM_TICK_MS 1000 + typedef struct { int index; bool on; @@ -61,6 +70,110 @@ static QueueHandle_t cmd_queue; /* The single copy of what is on screen. Only the HA task touches it. */ static ha_data_t ha_data; +/* + * Indoor temperature handed from the HA task to the control task. + * + * Guarded rather than assumed-atomic: a float store is a single word on this + * core, but the value and its timestamp must agree with each other, and the + * staleness failsafe depends on that pairing being consistent. + */ +static SemaphoreHandle_t temp_mutex; +static float indoor_temp_f; +static bool indoor_temp_valid; +static uint32_t indoor_temp_ms; + +static uint32_t uptime_ms(void) +{ + return (uint32_t)(xTaskGetTickCount() * portTICK_PERIOD_MS); +} + +static void publish_indoor_temp(const ha_value_t *v) +{ + if (temp_mutex == NULL || xSemaphoreTake(temp_mutex, pdMS_TO_TICKS(100)) != pdTRUE) { + return; + } + if (v->valid) { + indoor_temp_f = v->value; + indoor_temp_valid = true; + indoor_temp_ms = uptime_ms(); + } + xSemaphoreGive(temp_mutex); +} + +static bool take_indoor_temp(float *out_f) +{ + if (temp_mutex == NULL || xSemaphoreTake(temp_mutex, pdMS_TO_TICKS(100)) != pdTRUE) { + return false; + } + const bool valid = indoor_temp_valid; + *out_f = indoor_temp_f; + xSemaphoreGive(temp_mutex); + return valid; +} + +/* + * The control loop. Deliberately independent of Home Assistant: it needs only + * a temperature and a clock, so an HA outage cannot stop the house being + * heated -- the reading simply ages, and thermostat_tick() applies its own + * staleness failsafe once it is too old to trust. + */ +static void thermostat_task(void *arg) +{ + therm_status_t status; + + while (1) { + float temp = 0.0f; + const bool valid = take_indoor_temp(&temp); + + thermostat_tick(temp, valid, uptime_ms()); + thermostat_get_status(&status); + + relay_set(RELAY_HEAT, status.heat_relay); + relay_set(RELAY_FAN, status.fan_relay); + + ui_update_thermostat(&status); + + vTaskDelay(pdMS_TO_TICKS(THERM_TICK_MS)); + } +} + +/* + * Runs on the LVGL task with the lock held. No network here, so the change + * applies immediately and the display is refreshed straight back -- the + * button feels instant because it is. + */ +static void therm_cmd_from_ui(ui_therm_cmd_t cmd) +{ + switch (cmd) { + case UI_THERM_SETPOINT_UP: + thermostat_adjust_setpoint(1.0f); + break; + case UI_THERM_SETPOINT_DOWN: + thermostat_adjust_setpoint(-1.0f); + break; + case UI_THERM_MODE_TOGGLE: + thermostat_set_mode(thermostat_get_mode() == THERM_MODE_HEAT + ? THERM_MODE_OFF : THERM_MODE_HEAT); + break; + case UI_THERM_FAN_TOGGLE: + thermostat_set_fan(thermostat_get_fan() == THERM_FAN_ON + ? THERM_FAN_AUTO : THERM_FAN_ON); + break; + } + + /* + * Apply the fan immediately rather than waiting up to a second for the + * control task -- a button that lags its own relay feels broken. Heat is + * left to the control task, which is where the protection timers live; + * nothing here may light a burner. + */ + therm_status_t status; + thermostat_get_status(&status); + relay_set(RELAY_FAN, status.fan_relay); + + ui_update_thermostat(&status); +} + /* * Runs on the LVGL task with the LVGL lock held, so it must not block. * Queueing hands the HTTP request to the HA task; a request from here would @@ -221,6 +334,10 @@ static void ha_task(void *arg) ha_data.trend_pressure = ha_trend_compute(HA_TREND_CH_PRESSURE, TREND_PRESS_PER_HOUR_INHG, now_ms); + /* Hand the reading to the control loop, which runs on its own + * task and does not care where it came from. */ + publish_indoor_temp(&ha_data.indoor_temp_f); + ui_update(&ha_data); ui_set_stale(false); @@ -253,6 +370,35 @@ static void ha_task(void *arg) } } +/* + * The furnace must run whether or not anything else does. + * + * Every early return further down app_main() is about WiFi or Home Assistant + * being unusable -- no SSID, no token, no cloud fallback. None of those are + * reasons to leave a house unheated, so the control loop starts before any of + * them can bail out. It needs only a temperature and a clock; where the + * temperature comes from is somebody else's problem. + */ +static void start_thermostat(void) +{ + temp_mutex = xSemaphoreCreateMutex(); + if (temp_mutex == NULL) { + ESP_LOGE(TAG, "Could not create the temperature mutex"); + return; + } + + ui_set_therm_cb(therm_cmd_from_ui); + + xTaskCreate(thermostat_task, "therm", THERM_TASK_STACK, NULL, + THERM_TASK_PRIO, NULL); + + ESP_LOGI(TAG, "Thermostat running: setpoint %.0f F, %d min on / %d min off, " + "max %d cycles/hr", + thermostat_get_setpoint(), + (int)(THERM_MIN_ON_MS / 60000), (int)(THERM_MIN_OFF_MS / 60000), + THERM_MAX_CYCLES_PER_HOUR); +} + void app_main(void) { ESP_LOGI(TAG, "Thermostat HMI -- CrowPanel 7.0\" ESP32-S3 (800x480)"); @@ -275,6 +421,15 @@ void app_main(void) */ ESP_ERROR_CHECK(display_alloc()); + /* + * Relays before anything that can fail or take time. relay_init() only + * touches two GPIOs, and until it runs those pads are whatever reset left + * them -- so the sooner they are pinned to the de-energized level, the + * shorter the window in which a relay board could see an undefined input. + */ + ESP_ERROR_CHECK(relay_init()); + thermostat_init(uptime_ms()); + const esp_err_t wifi_err = wifi_init_sta(); if (wifi_err != ESP_OK) { ESP_LOGE(TAG, "WiFi not up yet -- polling starts when it connects"); @@ -310,6 +465,9 @@ void app_main(void) vTaskDelay(pdMS_TO_TICKS(100)); ESP_ERROR_CHECK(backlight_set(255)); + /* Before the WiFi/HA branches below, any of which may return. */ + start_thermostat(); + if (wifi_err == ESP_OK) { char status[96]; wifi_get_status(status, sizeof(status)); diff --git a/main/ui/ui_manager.c b/main/ui/ui_manager.c index bb75000..3b87885 100644 --- a/main/ui/ui_manager.c +++ b/main/ui/ui_manager.c @@ -111,13 +111,18 @@ static lv_obj_t *temp_unit_label; static lv_obj_t *indoor_detail_label; static lv_obj_t *trend_indoor_icon; -/* 3x2 reading grid: value and caption per cell. */ +/* + * 2x2 reading grid. + * + * Was 3x2. The thermostat controls need finger-sized targets and that space + * had to come from somewhere; wind was the right thing to cut, since the + * station anemometer has been dead since 2026-07-27 and the cell has never + * shown a number. Sunrise and sunset share one cell now. + */ enum { CELL_OUTDOOR = 0, - CELL_WIND, CELL_RAIN, - CELL_SUNRISE, - CELL_SUNSET, + CELL_SUN, CELL_PRESSURE, CELL_COUNT, }; @@ -138,7 +143,17 @@ static lv_obj_t *fc_temp[HA_FORECAST_DAYS]; static lv_obj_t *status_label; +/* Thermostat card */ +static lv_obj_t *therm_state_label; +static lv_obj_t *therm_state_dot; +static lv_obj_t *setpoint_label; +static lv_obj_t *mode_btn; +static lv_obj_t *mode_btn_label; +static lv_obj_t *fan_btn; +static lv_obj_t *fan_btn_label; + static ui_light_cmd_cb_t light_cb; +static ui_therm_cmd_cb_t therm_cb; /* Per-room icons, riding in HA_LIGHT_LIST so order can never drift from the * entity table. */ @@ -348,6 +363,55 @@ static void trend_render(lv_obj_t *icon, ha_trend_t t) } } +/* ------------------------------------------------------ thermostat controls */ + +/* + * A flat touch button with a centred label. `out_label` is optional -- pass + * it for buttons whose caption changes (mode, fan), omit for fixed ones. + * + * Sized by the caller rather than by content: these are finger targets on a + * capacitive panel, so the hit area is the specification, not the text. + */ +static lv_obj_t *button_create(lv_obj_t *parent, int32_t w, int32_t h, + const char *text, lv_obj_t **out_label) +{ + lv_obj_t *btn = lv_obj_create(parent); + lv_obj_set_size(btn, w, h); + lv_obj_set_style_radius(btn, 8, LV_PART_MAIN); + lv_obj_set_style_border_width(btn, 0, LV_PART_MAIN); + lv_obj_set_style_pad_all(btn, 0, LV_PART_MAIN); + lv_obj_set_style_bg_color(btn, lv_color_hex(COL_PANEL_HI), LV_PART_MAIN); + /* Visible press feedback matters more here than on the light tiles: these + * act instantly and locally, so the flash is the whole acknowledgement. */ + lv_obj_set_style_bg_color(btn, lv_color_hex(COL_ACCENT), LV_STATE_PRESSED); + lv_obj_remove_flag(btn, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_add_flag(btn, LV_OBJ_FLAG_CLICKABLE); + + lv_obj_t *lbl = label_create(btn, text, &lv_font_montserrat_16, COL_TEXT); + lv_obj_center(lbl); + + if (out_label != NULL) { + *out_label = lbl; + } + return btn; +} + +/* + * Runs on the LVGL task with the lock held. Unlike the light commands there + * is no network involved, so the callback applies the change and pushes the + * new state straight back -- the LVGL lock is recursive, so re-entering + * ui_update_thermostat() from here is fine. + */ +static void therm_button_clicked(lv_event_t *e) +{ + const ui_therm_cmd_t cmd = (ui_therm_cmd_t)(intptr_t)lv_event_get_user_data(e); + + if (therm_cb != NULL) { + therm_cb(cmd); + } + schedule_repair(); +} + /* ------------------------------------------------------------ light tiles */ static void light_tile_update(int idx, const ha_light_status_t *st); @@ -713,9 +777,9 @@ static void build_body(lv_obj_t *scr) lv_obj_set_flex_flow(row, LV_FLEX_FLOW_ROW); lv_obj_remove_flag(row, LV_OBJ_FLAG_SCROLLABLE); - /* --- indoor card: the number a thermostat exists to show --- */ - lv_obj_t *card = panel_create(row, 296, LV_PCT(100), COL_PANEL); - lv_obj_set_style_pad_all(card, 14, LV_PART_MAIN); + /* --- thermostat card: current temperature and what the furnace is doing --- */ + lv_obj_t *card = panel_create(row, 300, LV_PCT(100), COL_PANEL); + lv_obj_set_style_pad_all(card, 12, LV_PART_MAIN); lv_obj_t *heading = label_create(card, "INDOOR", &lv_font_montserrat_14, COL_TEXT_DIM); lv_obj_set_style_text_letter_space(heading, 2, LV_PART_MAIN); @@ -724,11 +788,8 @@ static void build_body(lv_obj_t *scr) lv_obj_t *card_icon = icon_create(card, ICON_HOME_THERMO, icon_font_26, COL_TEXT_FAINT); lv_obj_align(card_icon, LV_ALIGN_TOP_RIGHT, 0, -2); - /* Centred vertically rather than pinned under the heading: the card is - * 182px and a 48px number at the top leaves an obvious hole in the middle - * of the most important element on the screen. */ temp_label = label_create(card, "--", &lv_font_montserrat_48, COL_TEXT); - lv_obj_align(temp_label, LV_ALIGN_LEFT_MID, 0, 0); + lv_obj_align(temp_label, LV_ALIGN_TOP_LEFT, 0, 22); /* Degree sign as its own label so it can sit small and high beside a * 48px number instead of scaling with it. */ @@ -740,10 +801,56 @@ static void build_body(lv_obj_t *scr) trend_indoor_icon = icon_create(card, "", icon_font_26, COL_TEXT_DIM); lv_obj_align_to(trend_indoor_icon, temp_label, LV_ALIGN_OUT_RIGHT_BOTTOM, 8, -4); - indoor_detail_label = label_create(card, "--", &lv_font_montserrat_16, COL_TEXT_DIM); - lv_obj_align(indoor_detail_label, LV_ALIGN_BOTTOM_LEFT, 0, 0); + indoor_detail_label = label_create(card, "--", &lv_font_montserrat_14, COL_TEXT_DIM); + lv_obj_align(indoor_detail_label, LV_ALIGN_TOP_LEFT, 0, 78); - /* --- 3x2 reading grid --- */ + /* Status dot plus text: the dot carries the state as colour so it reads + * from across the room without parsing a word. */ + therm_state_dot = lv_obj_create(card); + lv_obj_set_size(therm_state_dot, 12, 12); + lv_obj_set_style_radius(therm_state_dot, 6, LV_PART_MAIN); + lv_obj_set_style_border_width(therm_state_dot, 0, LV_PART_MAIN); + lv_obj_set_style_bg_color(therm_state_dot, lv_color_hex(COL_TEXT_FAINT), LV_PART_MAIN); + lv_obj_remove_flag(therm_state_dot, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_align(therm_state_dot, LV_ALIGN_TOP_LEFT, 2, 104); + + therm_state_label = label_create(card, "--", &lv_font_montserrat_16, COL_TEXT_DIM); + lv_obj_align(therm_state_label, LV_ALIGN_TOP_LEFT, 22, 100); + + /* Two buttons across the bottom of the card. 134px each is a comfortable + * finger target; the row fills the card's 276px inner width. */ + mode_btn = button_create(card, 134, 40, "Heat", &mode_btn_label); + lv_obj_align(mode_btn, LV_ALIGN_BOTTOM_LEFT, 0, 0); + lv_obj_add_event_cb(mode_btn, therm_button_clicked, LV_EVENT_CLICKED, + (void *)(intptr_t)UI_THERM_MODE_TOGGLE); + + fan_btn = button_create(card, 134, 40, "Fan auto", &fan_btn_label); + lv_obj_align(fan_btn, LV_ALIGN_BOTTOM_RIGHT, 0, 0); + lv_obj_add_event_cb(fan_btn, therm_button_clicked, LV_EVENT_CLICKED, + (void *)(intptr_t)UI_THERM_FAN_TOGGLE); + + /* --- setpoint column: up, value, down --- */ + lv_obj_t *setcol = panel_create(row, 120, LV_PCT(100), COL_PANEL); + lv_obj_set_style_pad_all(setcol, 8, LV_PART_MAIN); + + lv_obj_t *up_btn = button_create(setcol, LV_PCT(100), 52, "+", NULL); + lv_obj_align(up_btn, LV_ALIGN_TOP_MID, 0, 0); + lv_obj_add_event_cb(up_btn, therm_button_clicked, LV_EVENT_CLICKED, + (void *)(intptr_t)UI_THERM_SETPOINT_UP); + + lv_obj_t *set_cap = label_create(setcol, "SET", &lv_font_montserrat_12, COL_TEXT_FAINT); + lv_obj_set_style_text_letter_space(set_cap, 1, LV_PART_MAIN); + lv_obj_align(set_cap, LV_ALIGN_CENTER, 0, -14); + + setpoint_label = label_create(setcol, "--", &lv_font_montserrat_32, COL_ACCENT_WARM); + lv_obj_align(setpoint_label, LV_ALIGN_CENTER, 0, 8); + + lv_obj_t *down_btn = button_create(setcol, LV_PCT(100), 52, "-", NULL); + lv_obj_align(down_btn, LV_ALIGN_BOTTOM_MID, 0, 0); + lv_obj_add_event_cb(down_btn, therm_button_clicked, LV_EVENT_CLICKED, + (void *)(intptr_t)UI_THERM_SETPOINT_DOWN); + + /* --- 2x2 reading grid --- */ lv_obj_t *grid = lv_obj_create(row); lv_obj_set_size(grid, LV_GRID_CONTENT, LV_PCT(100)); lv_obj_set_flex_grow(grid, 1); @@ -755,11 +862,10 @@ static void build_body(lv_obj_t *scr) lv_obj_remove_flag(grid, LV_OBJ_FLAG_SCROLLABLE); static const char *const captions[CELL_COUNT] = { - "OUTDOOR", "WIND", "RAIN TODAY", "SUNRISE", "SUNSET", "PRESSURE", + "OUTDOOR", "RAIN TODAY", "SUN", "PRESSURE", }; static const char *const cell_icons[CELL_COUNT] = { - ICON_THERMOMETER, ICON_W_WINDY, ICON_WATER, - ICON_SUNRISE, ICON_SUNSET, ICON_GAUGE, + ICON_THERMOMETER, ICON_WATER, ICON_SUNSET, ICON_GAUGE, }; /* Two rows of cells plus one gap must equal BAND_BODY_H exactly, or the @@ -767,7 +873,7 @@ static void build_body(lv_obj_t *scr) const int32_t cell_h = (BAND_BODY_H - BAND_GAP) / 2; for (int i = 0; i < CELL_COUNT; i++) { - lv_obj_t *cell = panel_create(grid, 156, cell_h, COL_PANEL); + lv_obj_t *cell = panel_create(grid, 172, cell_h, COL_PANEL); lv_obj_set_style_pad_all(cell, 10, LV_PART_MAIN); lv_obj_t *cap = label_create(cell, captions[i], &lv_font_montserrat_12, COL_TEXT_FAINT); @@ -877,8 +983,29 @@ static void update_body(const ha_data_t *d) lv_obj_align_to(cell_trend[CELL_OUTDOOR], cell_value[CELL_OUTDOOR], LV_ALIGN_OUT_RIGHT_MID, 6, 0); - if (d->outdoor_humidity.valid && d->dew_point_f.valid) { - snprintf(sub, sizeof(sub), "%.0f%% dew %.0f°", + /* + * Wind lives on the outdoor cell's sub-line now that it has no cell of + * its own -- the station anemometer has been offline since 2026-07-27 and + * a dedicated cell showing "--" was not worth the space the thermostat + * controls needed. It is still deliberately NOT backfilled from the + * met.no forecast: regional modelled wind under a heading that means "my + * yard" is worse than showing nothing. + * + * When the sensor comes back it displaces the dew point here, which is + * both a useful reading and a visible sign the hardware is alive again. + */ + if (d->wind_mph.valid) { + if (d->wind_dir_deg.valid) { + snprintf(sub, sizeof(sub), "%.0f%% %.0f mph %s", + d->outdoor_humidity.valid ? d->outdoor_humidity.value : 0.0f, + d->wind_mph.value, ha_wind_cardinal(d->wind_dir_deg.value)); + } else { + snprintf(sub, sizeof(sub), "%.0f%% %.0f mph", + d->outdoor_humidity.valid ? d->outdoor_humidity.value : 0.0f, + d->wind_mph.value); + } + } else if (d->outdoor_humidity.valid && d->dew_point_f.valid) { + snprintf(sub, sizeof(sub), "%.0f%% dew %.0f°", d->outdoor_humidity.value, d->dew_point_f.value); } else if (d->outdoor_humidity.valid) { snprintf(sub, sizeof(sub), "%.0f%% humidity", d->outdoor_humidity.value); @@ -887,30 +1014,6 @@ static void update_body(const ha_data_t *d) } label_set_if_changed(cell_sub[CELL_OUTDOOR], sub); - /* - * Wind. The station anemometer has been offline since 2026-07-27, so this - * cell is expected to read "--" until the hardware is fixed. It is - * deliberately NOT backfilled from the met.no forecast: showing regional - * modelled wind under a heading that means "my yard" is worse than - * showing nothing. - */ - format_value(&d->wind_mph, "%.0f", buf, sizeof(buf)); - if (d->wind_mph.valid) { - const size_t n = strlen(buf); - snprintf(buf + n, sizeof(buf) - n, " mph"); - } - label_set_if_changed(cell_value[CELL_WIND], buf); - - if (d->wind_dir_deg.valid && d->gust_mph.valid) { - snprintf(sub, sizeof(sub), "%s gust %.0f", - ha_wind_cardinal(d->wind_dir_deg.value), d->gust_mph.value); - } else if (d->wind_dir_deg.valid) { - snprintf(sub, sizeof(sub), "%s", ha_wind_cardinal(d->wind_dir_deg.value)); - } else { - snprintf(sub, sizeof(sub), "sensor offline"); - } - label_set_if_changed(cell_sub[CELL_WIND], sub); - /* Rain */ format_value(&d->rain_today_in, "%.2f\"", buf, sizeof(buf)); label_set_if_changed(cell_value[CELL_RAIN], buf); @@ -921,15 +1024,25 @@ static void update_body(const ha_data_t *d) } label_set_if_changed(cell_sub[CELL_RAIN], sub); - /* Sun. These strings are already local time from HA -- the board has no - * clock of its own. */ - label_set_if_changed(cell_value[CELL_SUNRISE], - d->sunrise[0] ? d->sunrise : NO_VALUE); - label_set_if_changed(cell_sub[CELL_SUNRISE], d->sun_up ? "up now" : "before dawn"); - - label_set_if_changed(cell_value[CELL_SUNSET], - d->sunset[0] ? d->sunset : NO_VALUE); - label_set_if_changed(cell_sub[CELL_SUNSET], d->sun_up ? "still up" : "down"); + /* + * Sun. One cell for both times now: the value line shows whichever is + * next, and the sub-line the other, so the useful number is the big one. + * These strings are already local time from HA -- the board has no clock. + */ + /* Plain hyphen as the separator, not a middot -- Montserrat has no + * U+00B7 and it renders as a tofu box. See the note on NO_VALUE. */ + if (d->sun_up) { + label_set_if_changed(cell_value[CELL_SUN], + d->sunset[0] ? d->sunset : NO_VALUE); + snprintf(sub, sizeof(sub), "sets - rose %s", + d->sunrise[0] ? d->sunrise : NO_VALUE); + } else { + label_set_if_changed(cell_value[CELL_SUN], + d->sunrise[0] ? d->sunrise : NO_VALUE); + snprintf(sub, sizeof(sub), "rises - set %s", + d->sunset[0] ? d->sunset : NO_VALUE); + } + label_set_if_changed(cell_sub[CELL_SUN], sub); /* Pressure */ format_value(&d->pressure_inhg, "%.2f", buf, sizeof(buf)); @@ -1062,6 +1175,83 @@ void ui_set_light_cb(ui_light_cmd_cb_t cb) light_cb = cb; } +void ui_set_therm_cb(ui_therm_cmd_cb_t cb) +{ + therm_cb = cb; +} + +void ui_update_thermostat(const therm_status_t *st) +{ + if (st == NULL) { + return; + } + + if (!lvgl_port_lock(UI_LOCK_TIMEOUT_MS)) { + ESP_LOGW(TAG, "Dropped thermostat update: LVGL lock timed out"); + return; + } + + char buf[32]; + + snprintf(buf, sizeof(buf), "%d°", (int)lroundf(st->setpoint_f)); + label_set_if_changed(setpoint_label, buf); + + thermostat_state_text(st, buf, sizeof(buf)); + label_set_if_changed(therm_state_label, buf); + + /* + * The dot colour is the at-a-glance channel: amber means the burner is + * lit, red means something is wrong and no heat is coming, dim means + * everything is fine and there is nothing to do. + */ + uint32_t dot = COL_TEXT_FAINT; + uint32_t text = COL_TEXT_DIM; + switch (st->state) { + case THERM_STATE_HEATING: + dot = COL_ON; + text = COL_ON; + break; + case THERM_STATE_LOCKOUT: + dot = COL_ACCENT_WARM; + text = COL_ACCENT_WARM; + break; + case THERM_STATE_NO_SENSOR: + dot = 0xE74C3C; + text = 0xE74C3C; + break; + default: + break; + } + lv_obj_set_style_bg_color(therm_state_dot, lv_color_hex(dot), LV_PART_MAIN); + lv_obj_set_style_text_color(therm_state_label, lv_color_hex(text), LV_PART_MAIN); + + const bool heat_mode = (st->mode == THERM_MODE_HEAT); + label_set_if_changed(mode_btn_label, heat_mode ? "Heat" : "Off"); + lv_obj_set_style_bg_color(mode_btn, + lv_color_hex(heat_mode ? COL_ACCENT_WARM : COL_PANEL_HI), + LV_PART_MAIN); + lv_obj_set_style_text_color(mode_btn_label, + lv_color_hex(heat_mode ? 0x2A1A05 : COL_TEXT_DIM), + LV_PART_MAIN); + + const bool fan_on = (st->fan == THERM_FAN_ON); + label_set_if_changed(fan_btn_label, fan_on ? "Fan on" : "Fan auto"); + lv_obj_set_style_bg_color(fan_btn, + lv_color_hex(fan_on ? COL_ACCENT : COL_PANEL_HI), + LV_PART_MAIN); + lv_obj_set_style_text_color(fan_btn_label, + lv_color_hex(fan_on ? 0x05131F : COL_TEXT), + LV_PART_MAIN); + + /* Setpoint greys out when the mode is off -- it still adjusts, it just + * is not controlling anything. */ + lv_obj_set_style_text_color(setpoint_label, + lv_color_hex(heat_mode ? COL_ACCENT_WARM : COL_TEXT_FAINT), + LV_PART_MAIN); + + lvgl_port_unlock(); +} + void ui_init(void) { /* ui_init() runs from app_main, not the LVGL task, so it needs the lock diff --git a/main/ui/ui_manager.h b/main/ui/ui_manager.h index 08f0f2c..c972f11 100644 --- a/main/ui/ui_manager.h +++ b/main/ui/ui_manager.h @@ -6,6 +6,7 @@ #include "ha_data.h" #include "lvgl.h" +#include "thermostat.h" /* * The screen layout. @@ -46,4 +47,25 @@ void ui_set_stale(bool stale); typedef void (*ui_light_cmd_cb_t)(int index, bool on, int16_t brightness); void ui_set_light_cb(ui_light_cmd_cb_t cb); +/* Redraw the thermostat card from `st`. Same cheapness rules as ui_update. */ +void ui_update_thermostat(const therm_status_t *st); + +typedef enum { + UI_THERM_SETPOINT_UP = 0, + UI_THERM_SETPOINT_DOWN, + UI_THERM_MODE_TOGGLE, + UI_THERM_FAN_TOGGLE, +} ui_therm_cmd_t; + +/* + * Called when the user operates a thermostat control. + * + * Runs on the LVGL task with the lock held, so it must not block -- but + * unlike the light commands there is no network here, so the handler can + * apply the change and call ui_update_thermostat() straight back. The LVGL + * lock is recursive, so that re-entry is fine. + */ +typedef void (*ui_therm_cmd_cb_t)(ui_therm_cmd_t cmd); +void ui_set_therm_cb(ui_therm_cmd_cb_t cb); + #endif diff --git a/sdkconfig.defaults b/sdkconfig.defaults index 4e646f5..caf31c0 100644 --- a/sdkconfig.defaults +++ b/sdkconfig.defaults @@ -94,3 +94,7 @@ CONFIG_LV_FONT_MONTSERRAT_48=y CONFIG_ESP_MAIN_TASK_STACK_SIZE=8192 CONFIG_LV_USE_TINY_TTF=y + +# CONFIG_USJ_ENABLE_USB_SERIAL_JTAG is not set + +# CONFIG_ESP_PHY_ENABLE_USB is not set diff --git a/sim/CMakeLists.txt b/sim/CMakeLists.txt index 0f094fb..65914a5 100644 --- a/sim/CMakeLists.txt +++ b/sim/CMakeLists.txt @@ -60,6 +60,7 @@ endif() add_executable(thermostat_sim sim_main.c lvgl_port_sim.c + relay_sim.c compat/esp_compat.c # The point of the exercise: real firmware sources, compiled unmodified. @@ -68,6 +69,7 @@ add_executable(thermostat_sim ${FW_ROOT}/main/ui/ui_manager.c ${FW_ROOT}/main/ui/icon_font.c ${FW_ROOT}/main/api/ha_data.c + ${FW_ROOT}/main/control/thermostat.c ${CJSON_DIR}/cJSON.c ) @@ -77,6 +79,8 @@ target_include_directories(thermostat_sim PRIVATE ${CMAKE_CURRENT_LIST_DIR}/compat # ESP-IDF header stand-ins ${FW_ROOT}/main/ui # ui_manager.h, lvgl_port.h ${FW_ROOT}/main/api # ha_data.h, ha_config.h, ha_template.h + ${FW_ROOT}/main/control # thermostat.h + ${FW_ROOT}/main/drivers/relay # relay.h, implemented by relay_sim.c ${CJSON_DIR} ${LVGL_DIR}/src/libs/lodepng # PNG encoder for --png mode ${SDL2_INCLUDE_DIRS} diff --git a/sim/relay_sim.c b/sim/relay_sim.c new file mode 100644 index 0000000..b0cbdff --- /dev/null +++ b/sim/relay_sim.c @@ -0,0 +1,64 @@ +/* + * Host implementation of relay.h -- a fake furnace. + * + * Lets the real control loop run on the desktop: hysteresis, minimum on and + * off times and the cycles-per-hour ceiling all behave exactly as they will on + * the device, with the relay changes printed instead of switching 24VAC. + * + * Timestamps are wall-clock relative to start-up so a log can be read against + * the protection timings in thermostat.h without arithmetic. + */ + +#include +#include +#include + +#include "relay.h" + +static bool relay_state[RELAY_COUNT]; +static struct timespec t0; + +static double seconds_since_start(void) +{ + struct timespec now; + clock_gettime(CLOCK_MONOTONIC, &now); + return (double)(now.tv_sec - t0.tv_sec) + + (double)(now.tv_nsec - t0.tv_nsec) / 1e9; +} + +esp_err_t relay_init(void) +{ + clock_gettime(CLOCK_MONOTONIC, &t0); + for (int i = 0; i < RELAY_COUNT; i++) { + relay_state[i] = false; + } + fprintf(stderr, "[relay-sim] both relays idle\n"); + return ESP_OK; +} + +void relay_set(relay_id_t id, bool energized) +{ + if (id < 0 || id >= RELAY_COUNT || relay_state[id] == energized) { + return; + } + relay_state[id] = energized; + fprintf(stderr, "[relay-sim] %7.1fs %-8s -> %s\n", + seconds_since_start(), relay_name(id), energized ? "ON" : "OFF"); +} + +bool relay_get(relay_id_t id) +{ + if (id < 0 || id >= RELAY_COUNT) { + return false; + } + return relay_state[id]; +} + +const char *relay_name(relay_id_t id) +{ + switch (id) { + case RELAY_HEAT: return "heat(W)"; + case RELAY_FAN: return "fan(G)"; + default: return "?"; + } +} diff --git a/sim/sim_main.c b/sim/sim_main.c index da37e1e..d127847 100644 --- a/sim/sim_main.c +++ b/sim/sim_main.c @@ -32,6 +32,8 @@ #include "ha_data.h" #include "ha_template.h" #include "lvgl_port.h" +#include "relay.h" +#include "thermostat.h" #include "ui_manager.h" /* Must match the physical panel: main/drivers/display/display_rgb.h. A @@ -273,6 +275,50 @@ static void apply_mock_data(void) * means tiles respond to clicks in the simulator; on the device the real state * comes back from Home Assistant on the next poll instead. */ +/* + * Runs the real control loop against the fake furnace in relay_sim.c. + * + * Called from the LVGL loop rather than a thread, which keeps the simulator + * single-threaded -- the point is to exercise the decision logic, not the + * firmware's task structure. + */ +static void sim_thermostat_tick(void) +{ + therm_status_t st; + + const bool have_temp = sim_data.indoor_temp_f.valid; + thermostat_tick(have_temp ? sim_data.indoor_temp_f.value : 0.0f, + have_temp, lv_tick_get()); + thermostat_get_status(&st); + + relay_set(RELAY_HEAT, st.heat_relay); + relay_set(RELAY_FAN, st.fan_relay); + + ui_update_thermostat(&st); +} + +/* Mirrors therm_cmd_from_ui() in main.c. */ +static void sim_therm_cmd(ui_therm_cmd_t cmd) +{ + switch (cmd) { + case UI_THERM_SETPOINT_UP: + thermostat_adjust_setpoint(1.0f); + break; + case UI_THERM_SETPOINT_DOWN: + thermostat_adjust_setpoint(-1.0f); + break; + case UI_THERM_MODE_TOGGLE: + thermostat_set_mode(thermostat_get_mode() == THERM_MODE_HEAT + ? THERM_MODE_OFF : THERM_MODE_HEAT); + break; + case UI_THERM_FAN_TOGGLE: + thermostat_set_fan(thermostat_get_fan() == THERM_FAN_ON + ? THERM_FAN_AUTO : THERM_FAN_ON); + break; + } + sim_thermostat_tick(); +} + static void sim_light_cmd(int index, bool on, int16_t brightness) { if (index < 0 || index >= HA_LIGHT_COUNT) { @@ -370,6 +416,10 @@ int main(int argc, char **argv) ESP_ERROR_CHECK(lvgl_port_start()); ui_set_light_cb(sim_light_cmd); + ESP_ERROR_CHECK(relay_init()); + thermostat_init(lv_tick_get()); + ui_set_therm_cb(sim_therm_cmd); + /* * A snapshot replaces the mock wholesale rather than layering onto it -- * mixing the two would show a screen that never occurs in reality. @@ -388,6 +438,7 @@ int main(int argc, char **argv) } ui_update(&sim_data); + sim_thermostat_tick(); ui_set_status(data_path != NULL ? data_path : "simulator - mock data"); if (png_path != NULL) { @@ -407,8 +458,18 @@ int main(int argc, char **argv) /* Interactive: LV_SDL_DIRECT_EXIT makes closing the window exit the * process, so this loop does not need its own termination condition. */ + uint32_t last_tick = 0; while (true) { lv_timer_handler(); + + /* Same 1Hz cadence as the firmware's control task, so the lockout + * countdown and cycle timing behave identically here. */ + const uint32_t now = lv_tick_get(); + if (now - last_tick >= 1000) { + last_tick = now; + sim_thermostat_tick(); + } + usleep(5000); } diff --git a/sim/snapshots/cold.json b/sim/snapshots/cold.json new file mode 100644 index 0000000..152f80c --- /dev/null +++ b/sim/snapshots/cold.json @@ -0,0 +1 @@ +{"out_t": 28.0, "out_h": 71.0, "in_t": 62.4, "in_h": 41.0, "dew": 21.0, "press": 29.899, "rain_d": 0.0, "rain_r": 0.0, "wind": null, "gust": null, "wdir": null, "cond": "snowy", "sunrise": "6:05 AM", "sunset": "7:45 PM", "sun_up": false, "clock": "6:12 AM", "date": "Tue Aug 4", "lights": [{"s": "off", "b": null}, {"s": "off", "b": null}, {"s": "off", "b": null}, {"s": "off", "b": null}, {"s": "unavailable", "b": null}, {"s": "off", "b": null}]} \ No newline at end of file diff --git a/tools/thermostat_test.c b/tools/thermostat_test.c new file mode 100644 index 0000000..9c6ad4a --- /dev/null +++ b/tools/thermostat_test.c @@ -0,0 +1,135 @@ +/* + * Drives thermostat.c with synthetic time to prove the protections actually + * fire. Nothing here touches hardware; time is a variable, so two hours of + * furnace behaviour runs in milliseconds. + * + * This logic controls a gas valve, so it gets verified before it gets flashed: + * + * gcc -Wall -Wextra -I main/control -o /tmp/therm_test \ + * tools/thermostat_test.c main/control/thermostat.c -lm && /tmp/therm_test + * + * Exits non-zero on failure. Worth re-running after any edit to thermostat.c. + */ +#include +#include +#include "thermostat.h" + +static therm_state_t last = (therm_state_t)-1; +static uint32_t t_ms; + +static const char *sname(therm_state_t s) +{ + switch (s) { + case THERM_STATE_OFF: return "OFF"; + case THERM_STATE_IDLE: return "IDLE"; + case THERM_STATE_HEATING: return "HEATING"; + case THERM_STATE_LOCKOUT: return "LOCKOUT"; + case THERM_STATE_NO_SENSOR: return "NO_SENSOR"; + } + return "?"; +} + +/* Advance `secs` seconds at 1Hz, printing only transitions. */ +static void run(float temp, int temp_valid, int secs) +{ + therm_status_t st; + for (int i = 0; i < secs; i++) { + t_ms += 1000; + thermostat_tick(temp, temp_valid, t_ms); + thermostat_get_status(&st); + if (st.state != last) { + char txt[32]; + thermostat_state_text(&st, txt, sizeof(txt)); + printf(" %6.1f min %-9s heat=%d fan=%d cycles=%u \"%s\"\n", + t_ms / 60000.0, sname(st.state), st.heat_relay, st.fan_relay, + st.cycles_last_hour, txt); + last = st.state; + } + } +} + +int main(void) +{ + therm_status_t st; + + printf("min_on=%us min_off=%us max_cph=%d deadband=%.1fF\n\n", + THERM_MIN_ON_MS / 1000, THERM_MIN_OFF_MS / 1000, + THERM_MAX_CYCLES_PER_HOUR, THERM_DEADBAND_F); + + thermostat_init(0); + thermostat_set_setpoint(68.0f); + + printf("1. cold house at 60F, boot lockout then heat\n"); + run(60.0f, 1, 120); + + printf("\n2. house reaches 69F -- must serve min-on before stopping\n"); + run(69.0f, 1, 300); + + printf("\n3. cold again immediately -- must serve min-off\n"); + run(60.0f, 1, 400); + + printf("\n4. hammer it: alternate hot/cold to trip the cycles-per-hour cap\n"); + for (int i = 0; i < 12; i++) { + run(69.0f, 1, 200); + run(60.0f, 1, 400); + } + thermostat_get_status(&st); + printf(" -> cycles in last hour: %u (cap %d)\n", + st.cycles_last_hour, THERM_MAX_CYCLES_PER_HOUR); + if (st.cycles_last_hour > THERM_MAX_CYCLES_PER_HOUR) { + printf(" *** FAIL: cap exceeded\n"); + return 1; + } + + printf("\n5. sensor goes away -- rides it out, then forces heat off\n"); + thermostat_init(t_ms); + last = (therm_state_t)-1; + thermostat_set_setpoint(68.0f); + run(60.0f, 1, 120); /* heating */ + run(0.0f, 0, 14 * 60); /* stale but within the grace window */ + run(0.0f, 0, 3 * 60); /* past it */ + thermostat_get_status(&st); + if (st.state != THERM_STATE_NO_SENSOR || st.heat_relay) { + printf(" *** FAIL: expected NO_SENSOR with heat off\n"); + return 1; + } + + printf("\n6. mode off stops heat immediately, ignoring min-on\n"); + thermostat_init(t_ms); + last = (therm_state_t)-1; + thermostat_set_setpoint(68.0f); + run(60.0f, 1, 120); + thermostat_get_status(&st); + if (!st.heat_relay) { + printf(" *** FAIL: expected to be heating first\n"); + return 1; + } + thermostat_set_mode(THERM_MODE_OFF); + run(60.0f, 1, 2); + thermostat_get_status(&st); + if (st.heat_relay) { + printf(" *** FAIL: heat still on after mode off\n"); + return 1; + } + + printf("\n7. fan is independent of heat, and never follows a heat call\n"); + thermostat_init(t_ms); + last = (therm_state_t)-1; + thermostat_set_setpoint(68.0f); + run(60.0f, 1, 120); + thermostat_get_status(&st); + if (st.fan_relay) { + printf(" *** FAIL: fan energized during heat (furnace owns the blower)\n"); + return 1; + } + thermostat_set_fan(THERM_FAN_ON); + run(60.0f, 1, 2); + thermostat_get_status(&st); + if (!st.fan_relay) { + printf(" *** FAIL: fan did not turn on\n"); + return 1; + } + + printf("\nall checks passed\n"); + return 0; +}