diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..91207da --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +build/ +sdkconfig +sdkconfig.old +managed_components/ +dependencies.lock + +# Credentials — broker address, WiFi password, MQTT user/pass. +# The tracked template is main/app_config.h.example. +main/app_config.h + +.vscode/ +*.swp diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..315b997 --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,4 @@ +cmake_minimum_required(VERSION 3.16) + +include($ENV{IDF_PATH}/tools/cmake/project.cmake) +project(rotary_thermostat) diff --git a/README.md b/README.md new file mode 100644 index 0000000..1e51099 --- /dev/null +++ b/README.md @@ -0,0 +1,234 @@ +# Rotary Thermostat + +ESP-IDF firmware turning an Elecrow CrowPanel 2.1" HMI Rotary Display into a +multi-room thermostat for a gas furnace. Room temperatures come from Zigbee +sensors via zigbee2mqtt; the heat call is a dry contact between `R` and `W`. + +Control logic runs on the ESP32. The house keeps heating when the broker, +zigbee2mqtt or Home Assistant is down — it degrades through a documented +fallback ladder rather than stopping. + +**Status: builds clean against ESP-IDF v5.3.2. Nothing has been run on +hardware.** The board was not connected when this was written. See +[Bench bring-up](#bench-bring-up) for the order to verify things in. + +--- + +## Build + +Requires ESP-IDF v5.3.2 at `~/esp/esp-idf`. `cmake` and `ninja` live in the IDF +virtualenv, so they are only on `PATH` after sourcing `export.sh` — and the +shell does not persist between commands, so source it in the same line: + +```bash +cp main/app_config.h.example main/app_config.h +``` + +Fill in `main/app_config.h` (WiFi, broker, sensor friendly_names), then: + +```bash +. ~/esp/esp-idf/export.sh && idf.py build +``` + +Flash and watch the log: + +```bash +. ~/esp/esp-idf/export.sh && idf.py -p /dev/ttyACM0 flash monitor +``` + +`main/app_config.h` is gitignored. `main/app_config.h.example` is the tracked +template. + +--- + +## What still needs your input + +Everything in the PRD's section 7 is resolved except the sensor identities: + +- **`APP_ROOM_TABLE` in `main/app_config.h`** is a placeholder. It needs the + exact zigbee2mqtt `friendly_name` for each room — the last path element of + the MQTT topic. Check with: + + ```bash + mosquitto_sub -h -t 'zigbee2mqtt/#' -v + ``` + +- **`APP_MQTT_URI`, `APP_MQTT_USER`, `APP_MQTT_PASS`** — broker address and + credentials. +- **`APP_WIFI_SSID` / `APP_WIFI_PASS`**. +- **`APP_TIMEZONE`** defaults to US Central. Only matters for schedule mode. + +--- + +## Where this departs from the PRD + +Three places, all documented in full where they occur: + +**Transport is MQTT, not HTTP.** zigbee2mqtt has no REST endpoint that returns +live sensor state — its frontend streams state over a WebSocket, and +`/api/devices` returns definitions rather than readings. The ESP32 subscribes +to the broker z2m publishes to instead. Sensor topics are retained, so every +room's last known reading arrives within milliseconds of connecting, including +after a reboot; and it removes z2m's frontend from the failure chain entirely. +See the header comment in [`main/net/zigbee_client.c`](main/net/zigbee_client.c). + +**The furnace is 2-wire, not 3-wire.** Confirmed as `R` and `W` only, thermostat +set to Gas/Oil Warm Air at 5 CPH. There is no third wire to repurpose as `C`, +so the board runs from its own 5V USB supply — do not attempt power stealing. +5 CPH is enforced in firmware as a minimum 12-minute period between heat-call +*starts*, which a minimum off-time alone does not achieve. + +**Three pinout corrections.** Touch is CST826 (not CST816), the panel needs a +3-wire SPI bus on GPIO 16/2/1 that the PRD did not list, and the backlight is +on GPIO6. All verified against Elecrow's own source for this board and +cross-checked against a second independent source, per PRD 8.1. Details and +sources in [`docs/HARDWARE.md`](docs/HARDWARE.md). + +--- + +## Layout + +``` +main/ +├── main.c boot ordering — read the comment at the top +├── app_config.h.example credentials, broker, room table +├── drivers/ +│ ├── i2c_bus.{c,h} shared bus + diagnostic scan +│ ├── expander/pcf8574 shadow-register I/O expander +│ ├── display/ ST7701S init sequence + RGB panel +│ ├── touch/cst826 capacitive touch +│ └── encoder/ PCNT quadrature + expander button +├── hvac/ +│ ├── relay.{c,h} heat call + every interlock in PRD section 5 +│ └── control.{c,h} setpoint, hysteresis, room selection, fallbacks +├── net/ +│ ├── wifi_manager.{c,h} STA with backoff, non-blocking +│ └── zigbee_client.{c,h} MQTT subscriber, timestamped readings +├── ui/ +│ ├── lvgl_port.{c,h} tick, task, recursive lock +│ └── ui_manager.{c,h} round-panel UI +└── storage/settings.{c,h} NVS, debounced writes +``` + +--- + +## Safety design + +The interlocks live in `hvac/relay.c`, not in the control loop and not in UI +code. Callers express intent; the relay layer decides whether the contact +actually closes, and nothing above it is given the means to override a limit. + +| Interlock | Value | Constant | +|---|---|---| +| Watchdog — contact drops if the control loop stops calling | 30 s | `RELAY_WATCHDOG_TIMEOUT_S` | +| Maximum on-time per call | 60 min | `RELAY_MAX_ON_TIME_S` | +| Minimum off-time | 5 min | `RELAY_MIN_OFF_TIME_S` | +| Minimum period between call starts (5 CPH) | 12 min | `RELAY_MIN_CYCLE_PERIOD_S` | +| Startup lockout | 60 s | `RELAY_STARTUP_LOCKOUT_S` | + +Stale-data ladder, in `hvac/control.c`, when the governing room has not +reported in 20 minutes: + +1. the selected room, if fresh +2. another room's fresh reading — coldest wins +3. *(a local sensor, if one were fitted — none is)* +4. the least-stale reading available, with the setpoint clamped to 16 °C +5. no reading at all since boot: open-loop freeze protection, 6 min on / + 24 min off + +Rungs 2–5 all raise a visible fault on screen. None of them can hold the relay +closed on its own; the interlocks above still apply. + +### The honest limitation + +A PCF8574 does not reset when the ESP32 does. If the firmware panics while the +contact is closed, the expander holds that latch until firmware comes back and +clears it. `app_main()` de-asserts the relay as its first real action, which +bounds the window to roughly **one second — not zero**. Closing that gap +properly needs external hardware (a retriggerable monostable in series with the +relay coil) which this board does not have. + +Per PRD 5.1, for calibration rather than alarm: the furnace's high-limit and +rollout switches are independent of the thermostat and stay in circuit. A stuck +heat call means an overheated house and a large gas bill, with the furnace +cycling on its own limit — not a fire. + +--- + +## Bench bring-up + +Do these in order. Each step's failure mode is only unambiguous if the previous +one passed. + +**1 — I2C.** Flash and watch the log. Expect: + +``` +I (xxx) i2c: 0x21 PCF8574 expander +I (xxx) i2c: 0x15 CST826 touch +``` + +If the bus looks empty, stop. Re-check SDA 38 / SCL 39 before touching +anything else — a wrong pinout produces exactly the same symptoms as broken +software, and PRD 8.1 exists because a sibling project lost most of a session +to that. + +**2 — Panel.** Set `BRINGUP_TEST_PATTERN` to `1` in +[`main/main.c`](main/main.c) and reflash. Expect eight vertical colour bars. + +If black, in this order: +1. `ST7701_SPI_MODE` in `drivers/display/display.c` — try 3 +2. the init sequence in `drivers/display/st7701_init.h` +3. **re-verify the pinout before writing any new software hypothesis** + +If red and blue are swapped, swap the R and B pin blocks in +`display.c`'s `data_gpio_nums`. If the image rolls or shears, try the ESPHome +timings noted in `docs/HARDWARE.md`. + +**3 — UI.** Set `BRINGUP_TEST_PATTERN` back to `0`. Expect the setpoint arc, +a large temperature, room and mode. Watch for artefacts that persist rather +than flicker — those mean a framebuffer race, not a drawing bug. + +**4 — Encoder and touch.** Turning the knob changes the setpoint. A short +press cycles focus setpoint → room → mode; focus reverts to setpoint after 8 s. +A long press returns to setpoint immediately. + +**5 — Sensors.** Expect one line per room as retained messages arrive: + +``` +I (xxx) zigbee: TV Room: 20.5C, battery 87% +``` + +**6 — Relay, on the bench. Not on the furnace.** + +Put an LED (with a resistor) or a spare relay module on PCF8574 P1 and exercise +every interlock before this goes anywhere near 24 VAC: + +| Test | How | Expect | +|---|---|---| +| Startup lockout | Watch the first 60 s after boot | No call, even if cold | +| Normal call | Set the setpoint well above room temp | Call after the lockout | +| Hysteresis | Setpoint just above, then just below current temp | No chatter within ±0.5 °C | +| Max on-time | Setpoint at maximum, wait 60 min | Drops, `FAULT: max on-time exceeded` | +| Min off-time | After a call ends, demand heat again | `Waiting 5m` on screen | +| Cycle rate | Force several short calls | Never more than 5 starts/hour | +| **Watchdog** | Suspend the control task from a debugger, or add a temporary `vTaskDelay(60000)` in `control_task` | Contact drops within 30 s, `FAULT: control loop stopped` | +| Stale data | Stop the broker, wait 20 min | Falls to rung 4, setpoint clamps to 16 °C, fault on screen | +| No data | Boot with the broker down | Freeze-protection duty cycle, fault on screen | +| Power loss | Pull USB mid-call | Contact opens | +| Panic | Trigger a crash mid-call | Contact reopens within ~1 s of reboot | + +**7 — Install.** Only after step 6 has been proven. The board needs its own 5V +USB supply; there is no `C` wire on this system. + +``` +PCF8574 P1 ──► relay module IN (active low) + +R (24VAC) ──► relay COM +W (heat) ◄── relay NO closes to call for heat +``` + +Opto-isolated module, separate JD-VCC jumper, normally open. Photograph the +existing thermostat wiring before disconnecting anything. + +**Never connect `R` to `C`** — dead short across the transformer, blows the +fuse on the furnace control board. diff --git a/docs/HARDWARE.md b/docs/HARDWARE.md new file mode 100644 index 0000000..fbb846b --- /dev/null +++ b/docs/HARDWARE.md @@ -0,0 +1,221 @@ +# Hardware reference — CrowPanel 2.1" HMI Rotary Display + +Everything below was taken from Elecrow's own source for **this** board and +cross-checked against a second independent source, as PRD section 8.1 requires. +Nothing here is from wiki prose or a search summary. + +**Sources** + +1. `Elecrow-RD/CrowPanel-2.1inch-HMI-ESP32-Rotary-Display-480-480-IPS-Round-Touch-Knob-Screen` + — `example/RotaryScreen_2_1/RotaryScreen_2_1.ino`, the vendor's shipping + Arduino example, plus the bundled `GFX_Library_for_Arduino`. +2. The same repo's `example/esphome/esphome2.1.yaml` — an ESP-IDF-framework + config for the same board, written independently of the Arduino example. + +The two agree on every pin. Where they disagree on anything else, it is called +out below. + +**Not yet verified on hardware.** The board was not connected when this was +written. Section "First bring-up" at the end is the order to check things in. + +--- + +## Corrections to the PRD + +The PRD's section 2.2 was close but incomplete in three ways that matter: + +| PRD said | Actually | Why it matters | +|---|---|---| +| Touch is **CST816** | Touch is **CST826**, I2C `0x15` | Different chip ID register value (`0xAA` reads `0x11`, not `0xB4`). A CST816 driver's probe fails and the PRD's own phase-1 sanity check would look like a hardware fault. | +| `CS 16` listed alone | The panel needs a **3-wire SPI** bus: **CS 16, SCK 2, MOSI/SDA 1** | The ~30-command ST7701S init sequence goes over this bus. Without SCK and MOSI there is no way to send it, and the panel stays black — exactly the failure mode section 8.1 warns about. | +| No backlight mentioned | **Backlight is GPIO6**, PWM | Panel can be fully initialised and still appear dead with the backlight off. | + +Two smaller additions: there is a **breathing LED on GPIO43**, and expander +**P3 ("LCD power") is driven HIGH during init and LOW at the end** of the +vendor's setup — see the note under PCF8574 below. + +The PRD's claim that expander **P1, P6 and P7 are free is correct** — the +vendor example configures P0, P2, P3, P4, P5 and never touches the other +three. P1 is what this project uses for the relay. + +--- + +## Pinout + +### RGB panel (matches the PRD exactly) + +| Signal | GPIO | +|---|---| +| DE | 40 | +| HSYNC | 15 | +| VSYNC | 7 | +| PCLK | 41 | +| R0–R4 | 46, 3, 8, 18, 17 | +| G0–G5 | 14, 13, 12, 11, 10, 9 | +| B0–B4 | 5, 45, 48, 47, 21 | + +### Panel control SPI (missing from the PRD) + +| Signal | GPIO | +|---|---| +| CS | 16 | +| SCK | 2 | +| SDA (MOSI) | 1 | + +9-bit writes, MSB first, **SPI mode 0**. Arduino_GFX's `Arduino_SWSPI::begin()` +drives SCK low at init and shifts on the rising edge, which is mode 0. +Elecrow's ESPHome config says `spi_mode: MODE3` — that config also carries the +comment that its colours are wrong, so mode 0 is the one to trust. If the panel +is black, `ST7701_SPI_MODE` in `drivers/display/display.c` is a one-line change. + +### Other + +| Signal | GPIO | +|---|---| +| I2C SDA / SCL | 38 / 39 | +| Backlight (LEDC PWM) | 6 | +| Encoder A / B | 42 / 4 | +| Breathing LED | 43 | + +PCLK **18 MHz**, and `pclk_inverted` (negative edge) per the ESPHome config. + +### Panel timings + +The two sources disagree, which is normal — these have slack. + +| | hsync fp / pw / bp | vsync fp / pw / bp | +|---|---|---| +| Arduino example (**used here**) | 10 / 4 / 20 | 10 / 4 / 20 | +| ESPHome example | 20 / 10 / 10 | 8 / 10 / 10 | + +The Arduino numbers are from the vendor's shipping firmware, so those are the +defaults in `display.c`. Either set should work; swap if the image rolls or +shears. + +--- + +## I2C bus (SDA 38 / SCL 39, 400 kHz) + +| Address | Device | +|---|---| +| `0x21` | PCF8574 I/O expander | +| `0x15` | CST826 capacitive touch | + +Finding both in the boot scan proves the board is alive and the I2C pins are +right, independently of the display. That is the cheapest first check. + +### PCF8574 allocation + +| Pin | Use | Direction | +|---|---|---| +| P0 | Touch reset | out | +| P1 | **Relay — heat call** (this project) | out, active low | +| P2 | Touch interrupt | out (driven high by vendor init) | +| P3 | LCD power | out | +| P4 | LCD reset | out | +| P5 | Encoder button | in, pull-up | +| P6 | free | — | +| P7 | free | — | + +Two things about this part that are easy to get wrong: + +**Power-on state is all-high.** A PCF8574's quasi-bidirectional pins come up +weakly pulled high. With an active-low relay module that means the relay is +**off** at power-on, which is what we want. The driver keeps a shadow byte +initialised to `0xFF` so a read-modify-write on any other pin can never +accidentally pull P1 low. + +**The expander does not reset when the ESP32 does.** A software reset, panic +reboot or `esp_restart()` leaves the PCF8574's output latch exactly as it was. +If P1 was low (heat on) when the firmware crashed, the relay stays closed until +firmware comes back and clears it. `app_main()` therefore de-asserts P1 before +doing anything else — before WiFi, before the display — which bounds that +window to roughly a second. This is documented in `hvac/relay.h` too. A true +hardware fail-safe would need an external watchdog or an RC on the relay coil; +this firmware does not have one, and the honest statement is that a crash gives +you about one second of unwanted heat call, not zero. + +**P3 is driven HIGH before the LCD reset pulse and LOW at the end of setup** +in the vendor example, after the backlight PWM is running. Despite the "LCD +power" label the sequence only makes sense as a blanking control — it hides +init garbage. `display.c` reproduces the vendor's ordering exactly rather than +reasoning about what the net is really called. + +--- + +## ST7701S initialisation + +`drivers/display/st7701_init.h` carries the sequence verbatim from +`st7701_type5_init_operations` in the bundled `Arduino_GFX` +(`display/Arduino_ST7701_RGBPanel.h`), which is what the vendor example passes +to the panel constructor. + +The ESPHome config's `init_sequence` is the same sequence with three commands +missing from bank `0x10` — `0xC0 3B 00`, `0xC1 0B 02` (VBP) and `0xC2 00 02` — +and an extra leading `0x01` software reset. Since that same file admits its +colours are wrong, the Arduino/type5 version is the one reproduced here, with +the software reset added at the front because it is harmless and makes the +state deterministic. + +Pixel format is set to **RGB666** (`0x3A 0x60`) by the sequence, with `0x36 0x00`. +The Arduino example additionally passes `bgr = true` to the panel constructor; +that is reflected here by setting the RGB panel's BGR flag. If red and blue are +swapped on first light, that flag is the thing to toggle. + +--- + +## Furnace side + +**Confirmed by the user, and it differs from the PRD:** the system is +**2-wire, R and W only**. Existing thermostat is set to Gas/Oil Warm Air, +**5 CPH**. + +Consequences: + +- **There is no C wire and no third wire to repurpose.** The PRD's section 2.4 + discussion of moving `G` to `C` does not apply. The board must run from its + own **5V USB supply**. Do not attempt power stealing — this board needs ~5W + and power stealing supplies roughly a tenth of that. +- **5 CPH is a real constraint, not a display preference.** Five cycles per + hour means at most one heat-call *start* every 12 minutes. A minimum + off-time alone does not enforce that, because a short call followed by a + 5-minute gap would give 8 CPH. `hvac/relay.c` enforces a minimum period + between successive heat-call starts (720 s) in addition to the minimum + off-time. + +Wiring: + +``` +PCF8574 P1 ──► relay module IN (active low) + +R (24VAC) ──► relay COM +W (heat) ◄── relay NO closes to call for heat +``` + +Opto-isolated relay module with a separate JD-VCC jumper, normally open. +The PCF8574 sinks ~25 mA but barely sources, which is why the module must be +active-low triggered. + +**Never connect R to C** — dead short across the transformer, blows the fuse on +the furnace control board. On this 2-wire system there is no C at the +thermostat anyway. + +--- + +## First bring-up + +In this order, because each step's failure mode is unambiguous only if the +previous one passed: + +1. **I2C scan.** Expect `0x21` and `0x15`. If the bus looks empty, stop and + re-check SDA 38 / SCL 39 before touching anything else. +2. **Backlight.** GPIO6 PWM up. Screen should glow even with no image. +3. **Panel.** Colour bars. If black: check SPI mode, then the init sequence, + then re-verify the pinout — in that order, and re-verify the pinout before + writing any new software hypothesis. +4. **Touch.** Chip ID register `0xAA` should read `0x11`. +5. **Encoder.** Detents count in both directions; button on P5 reads low when + pressed. +6. **Relay — on the bench, with an LED or a spare relay module, not the + furnace.** Exercise every interlock in PRD section 5 before the unit goes + anywhere near 24VAC. diff --git a/main/CMakeLists.txt b/main/CMakeLists.txt new file mode 100644 index 0000000..c02f53c --- /dev/null +++ b/main/CMakeLists.txt @@ -0,0 +1,38 @@ +idf_component_register( + SRCS + "main.c" + "drivers/i2c_bus.c" + "drivers/expander/pcf8574.c" + "drivers/display/display.c" + "drivers/touch/cst826.c" + "drivers/encoder/encoder.c" + "hvac/relay.c" + "hvac/control.c" + "net/wifi_manager.c" + "net/zigbee_client.c" + "ui/lvgl_port.c" + "ui/ui_manager.c" + "storage/settings.c" + INCLUDE_DIRS + "." + "drivers" + "drivers/expander" + "drivers/display" + "drivers/touch" + "drivers/encoder" + "hvac" + "net" + "ui" + "storage" + REQUIRES + driver + esp_lcd + esp_wifi + esp_event + esp_netif + esp_timer + mqtt + nvs_flash + json + esp_hw_support +) diff --git a/main/app_config.h.example b/main/app_config.h.example new file mode 100644 index 0000000..ee3b49b --- /dev/null +++ b/main/app_config.h.example @@ -0,0 +1,78 @@ +/* + * app_config.h — site-specific configuration and credentials. + * + * Copy this file to main/app_config.h and fill it in. app_config.h is + * gitignored; this template is tracked. + * + * cp main/app_config.h.example main/app_config.h + * + * Everything here is specific to one installation. Behavioural tuning + * (hysteresis, timings, safety limits) lives in hvac/control.h and + * hvac/relay.h instead, because those are properties of the firmware rather + * than of the house. + */ +#pragma once + +/* ---------------------------------------------------------------- WiFi -- */ + +#define APP_WIFI_SSID "your-ssid" +#define APP_WIFI_PASS "your-password" + +/* --------------------------------------------------------------- MQTT --- */ +/* + * The ESP32 talks to the MQTT broker that zigbee2mqtt publishes to, not to + * zigbee2mqtt itself. z2m has no REST endpoint for live sensor state — the + * frontend streams it over a WebSocket — and subscribing to the broker + * directly is both simpler and more robust: the sensor topics are retained, + * so the last known reading for every room arrives within milliseconds of + * connecting, including after a reboot. + * + * URI forms: mqtt://host:1883, mqtts://host:8883, ws://host:9001/mqtt + */ +#define APP_MQTT_URI "mqtt://192.168.1.10:1883" +#define APP_MQTT_USER "" /* leave empty for anonymous */ +#define APP_MQTT_PASS "" + +/* z2m's configured base topic. Sensor topics are /. */ +#define APP_MQTT_BASE_TOPIC "zigbee2mqtt" + +/* --------------------------------------------------------------- Rooms -- */ +/* + * One row per room the thermostat knows about. + * + * X(display_name, zigbee2mqtt_friendly_name) + * + * display_name — what appears on the round screen. Keep it short; there is + * not much horizontal room at 480x480 with a circular mask. + * friendly_name — exactly as it appears in zigbee2mqtt, i.e. the last path + * element of the MQTT topic. Case and spaces matter. If a + * name contains a space the topic will too, which is legal + * MQTT and handled correctly here. + * + * The order is the order the encoder cycles through in manual mode, and the + * index is what gets persisted to NVS — so inserting a row in the middle + * will shift a previously saved selection. Append rather than insert. + * + * Room 0 is the power-on default before anything is loaded from NVS. + */ +#define APP_ROOM_TABLE(X) \ + X("TV Room", "tv_room_sensor") \ + X("Bedroom", "bedroom_sensor") + +/* --------------------------------------------------------------- Units -- */ +/* + * Display units. All internal control math is in degrees Celsius regardless; + * this only affects what is rendered and how big an encoder detent is. + * APP_UNITS_FAHRENHEIT: 1 detent = 1 F. Celsius: 1 detent = 0.5 C. + */ +#define APP_UNITS_FAHRENHEIT 1 + +/* ---------------------------------------------------------------- Time -- */ +/* + * POSIX TZ string, used by schedule mode. SNTP keeps the clock honest; if it + * never syncs, schedule mode falls back to the formula rule and says so on + * screen rather than acting on a wrong clock. + * US Central: "CST6CDT,M3.2.0,M11.1.0" US Eastern: "EST5EDT,M3.2.0,M11.1.0" + */ +#define APP_TIMEZONE "CST6CDT,M3.2.0,M11.1.0" +#define APP_SNTP_SERVER "pool.ntp.org" diff --git a/main/drivers/display/display.c b/main/drivers/display/display.c new file mode 100644 index 0000000..de7f1dc --- /dev/null +++ b/main/drivers/display/display.c @@ -0,0 +1,326 @@ +#include "display.h" + +#include + +#include "driver/ledc.h" +#include "esp_check.h" +#include "esp_lcd_panel_io.h" +#include "esp_lcd_panel_io_additions.h" +#include "esp_lcd_panel_rgb.h" +#include "esp_log.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "pcf8574.h" +#include "st7701_init.h" + +static const char *TAG = "display"; + +/* ---- Pinout: docs/HARDWARE.md, verified against Elecrow's own example ---- */ + +#define PIN_LCD_CS 16 +#define PIN_LCD_SCK 2 +#define PIN_LCD_SDA 1 + +#define PIN_LCD_DE 40 +#define PIN_LCD_HSYNC 15 +#define PIN_LCD_VSYNC 7 +#define PIN_LCD_PCLK 41 + +#define PIN_BACKLIGHT 6 + +/* + * SPI mode 0. Arduino_GFX's Arduino_SWSPI::begin() drives SCK low at init and + * shifts on the rising edge. Elecrow's ESPHome config says MODE3, but that + * file also documents its own colours as wrong. + * + * If the panel is black on first bring-up, this is the first thing to try + * changing — before touching the init sequence, and long before rewriting + * anything else. + */ +#define ST7701_SPI_MODE 0 + +/* + * Timings from the vendor's Arduino example. The ESPHome example uses + * 20/10/10 and 8/10/10 instead; either set should work. Swap if the image + * rolls or shears. + */ +#define LCD_PCLK_HZ (18 * 1000 * 1000) +#define LCD_HSYNC_FRONT 10 +#define LCD_HSYNC_PULSE 4 +#define LCD_HSYNC_BACK 20 +#define LCD_VSYNC_FRONT 10 +#define LCD_VSYNC_PULSE 4 +#define LCD_VSYNC_BACK 20 + +#define BACKLIGHT_LEDC_TIMER LEDC_TIMER_0 +#define BACKLIGHT_LEDC_CHANNEL LEDC_CHANNEL_0 +#define BACKLIGHT_LEDC_MODE LEDC_LOW_SPEED_MODE +#define BACKLIGHT_RES LEDC_TIMER_10_BIT + +static esp_lcd_panel_handle_t s_panel; +static void *s_fb0, *s_fb1; + +/* ------------------------------------------------------------ backlight -- */ + +static esp_err_t backlight_init(void) +{ + ledc_timer_config_t timer = { + .speed_mode = BACKLIGHT_LEDC_MODE, + .timer_num = BACKLIGHT_LEDC_TIMER, + .duty_resolution = BACKLIGHT_RES, + .freq_hz = 5000, + .clk_cfg = LEDC_AUTO_CLK, + }; + ESP_RETURN_ON_ERROR(ledc_timer_config(&timer), TAG, "ledc timer"); + + ledc_channel_config_t ch = { + .gpio_num = PIN_BACKLIGHT, + .speed_mode = BACKLIGHT_LEDC_MODE, + .channel = BACKLIGHT_LEDC_CHANNEL, + .timer_sel = BACKLIGHT_LEDC_TIMER, + .duty = 0, + .hpoint = 0, + }; + return ledc_channel_config(&ch); +} + +void display_set_backlight(uint8_t percent) +{ + if (percent > 100) { + percent = 100; + } + uint32_t max = (1u << BACKLIGHT_RES) - 1; + uint32_t duty = (max * percent) / 100; + + ledc_set_duty(BACKLIGHT_LEDC_MODE, BACKLIGHT_LEDC_CHANNEL, duty); + ledc_update_duty(BACKLIGHT_LEDC_MODE, BACKLIGHT_LEDC_CHANNEL); +} + +/* --------------------------------------------------------- reset dance --- */ + +/* + * Reproduces the vendor example's setup() ordering exactly, including the P3 + * ("LCD power") pulse. Despite the label, P3 goes high before the reset and + * low again once the backlight is running, which only makes sense as blanking + * to hide init garbage. Copied rather than reasoned about — see + * docs/HARDWARE.md. + * + * P1 is deliberately untouched throughout: it is the relay. + */ +static esp_err_t panel_power_and_reset(void) +{ + ESP_RETURN_ON_ERROR(pcf8574_write(PCF_PIN_LCD_POWER, true), TAG, "p3"); + vTaskDelay(pdMS_TO_TICKS(100)); + + ESP_RETURN_ON_ERROR(pcf8574_write(PCF_PIN_LCD_RST, true), TAG, "p4 hi"); + vTaskDelay(pdMS_TO_TICKS(100)); + ESP_RETURN_ON_ERROR(pcf8574_write(PCF_PIN_LCD_RST, false), TAG, "p4 lo"); + vTaskDelay(pdMS_TO_TICKS(120)); + ESP_RETURN_ON_ERROR(pcf8574_write(PCF_PIN_LCD_RST, true), TAG, "p4 hi2"); + vTaskDelay(pdMS_TO_TICKS(120)); + + return ESP_OK; +} + +/* --------------------------------------------------------------- init ---- */ + +static esp_err_t send_init_sequence(void) +{ + esp_lcd_panel_io_handle_t io = NULL; + + esp_lcd_panel_io_3wire_spi_config_t io_cfg = { + .line_config = { + .cs_io_type = IO_TYPE_GPIO, + .cs_gpio_num = PIN_LCD_CS, + .scl_io_type = IO_TYPE_GPIO, + .scl_gpio_num = PIN_LCD_SCK, + .sda_io_type = IO_TYPE_GPIO, + .sda_gpio_num = PIN_LCD_SDA, + .io_expander = NULL, + }, + .expect_clk_speed = PANEL_IO_3WIRE_SPI_CLK_MAX, + .spi_mode = ST7701_SPI_MODE, + .lcd_cmd_bytes = 1, + .lcd_param_bytes = 1, + .flags = { + .use_dc_bit = 1, /* 9-bit frames: DC bit then 8 data bits */ + .dc_zero_on_data = 0, /* DC=0 command, DC=1 data (ST7701 order) */ + .lsb_first = 0, + .cs_high_active = 0, + /* + * Keep CS inactive after we delete this IO. The RGB bus takes over + * the panel from here and must not see CS wander. + */ + .del_keep_cs_inactive = 1, + }, + }; + + ESP_RETURN_ON_ERROR(esp_lcd_new_panel_io_3wire_spi(&io_cfg, &io), + TAG, "3wire spi io"); + + esp_err_t err = ESP_OK; + for (size_t i = 0; i < ST7701_INIT_SEQ_LEN; i++) { + const st7701_cmd_t *c = &st7701_init_seq[i]; + + err = esp_lcd_panel_io_tx_param(io, c->cmd, + c->data_len ? c->data : NULL, + c->data_len); + if (err != ESP_OK) { + ESP_LOGE(TAG, "init cmd %u (0x%02X) failed: %s", + (unsigned)i, c->cmd, esp_err_to_name(err)); + break; + } + if (c->delay_ms) { + vTaskDelay(pdMS_TO_TICKS(c->delay_ms)); + } + } + + /* The init bus is single-use; the RGB peripheral owns the panel now. */ + esp_lcd_panel_io_del(io); + + if (err == ESP_OK) { + ESP_LOGI(TAG, "ST7701S init sequence sent (%u commands)", + (unsigned)ST7701_INIT_SEQ_LEN); + } + return err; +} + +static esp_err_t rgb_panel_init(void) +{ + esp_lcd_rgb_panel_config_t cfg = { + .clk_src = LCD_CLK_SRC_PLL160M, + .timings = { + .pclk_hz = LCD_PCLK_HZ, + .h_res = DISPLAY_H_RES, + .v_res = DISPLAY_V_RES, + .hsync_pulse_width = LCD_HSYNC_PULSE, + .hsync_back_porch = LCD_HSYNC_BACK, + .hsync_front_porch = LCD_HSYNC_FRONT, + .vsync_pulse_width = LCD_VSYNC_PULSE, + .vsync_back_porch = LCD_VSYNC_BACK, + .vsync_front_porch = LCD_VSYNC_FRONT, + .flags = { + /* Elecrow's ESPHome config sets pclk_inverted, i.e. data is + * latched on the falling edge. */ + .pclk_active_neg = 1, + }, + }, + .data_width = 16, + .bits_per_pixel = 16, + .num_fbs = 2, + /* + * No bounce buffers — this is what makes runtime NVS writes safe. + * See the header comment. Do not "optimise" this to a non-zero value + * without re-reading PRD 8.2. + */ + .bounce_buffer_size_px = 0, + .psram_trans_align = 64, + .hsync_gpio_num = PIN_LCD_HSYNC, + .vsync_gpio_num = PIN_LCD_VSYNC, + .de_gpio_num = PIN_LCD_DE, + .pclk_gpio_num = PIN_LCD_PCLK, + .disp_gpio_num = -1, + /* + * RGB565 bit order: data[0..4] = blue, [5..10] = green, [11..15] = red. + * If red and blue come out swapped, swap these two blocks — the panel's + * own MADCTL is already 0x00 (RGB) from the init sequence. + */ + .data_gpio_nums = { + 5, 45, 48, 47, 21, /* B0..B4 */ + 14, 13, 12, 11, 10, 9, /* G0..G5 */ + 46, 3, 8, 18, 17, /* R0..R4 */ + }, + .flags = { + .fb_in_psram = 1, + }, + }; + + ESP_RETURN_ON_ERROR(esp_lcd_new_rgb_panel(&cfg, &s_panel), TAG, "rgb panel"); + ESP_RETURN_ON_ERROR(esp_lcd_panel_reset(s_panel), TAG, "panel reset"); + ESP_RETURN_ON_ERROR(esp_lcd_panel_init(s_panel), TAG, "panel init"); + + ESP_RETURN_ON_ERROR( + esp_lcd_rgb_panel_get_frame_buffer(s_panel, 2, &s_fb0, &s_fb1), + TAG, "get framebuffers"); + + ESP_LOGI(TAG, "RGB panel up: %dx%d @ %d MHz, fb0=%p fb1=%p", + DISPLAY_H_RES, DISPLAY_V_RES, LCD_PCLK_HZ / 1000000, s_fb0, s_fb1); + return ESP_OK; +} + +esp_err_t display_init(void) +{ + if (!pcf8574_is_ready()) { + ESP_LOGE(TAG, "expander not ready — cannot sequence panel power"); + return ESP_ERR_INVALID_STATE; + } + + ESP_RETURN_ON_ERROR(panel_power_and_reset(), TAG, "power/reset"); + ESP_RETURN_ON_ERROR(send_init_sequence(), TAG, "init sequence"); + ESP_RETURN_ON_ERROR(rgb_panel_init(), TAG, "rgb"); + ESP_RETURN_ON_ERROR(backlight_init(), TAG, "backlight"); + + /* Clear both buffers before the backlight comes up, so the first thing on + * screen is black rather than whatever was in PSRAM. */ + size_t fb_bytes = (size_t)DISPLAY_H_RES * DISPLAY_V_RES * 2; + memset(s_fb0, 0, fb_bytes); + memset(s_fb1, 0, fb_bytes); + + display_set_backlight(80); + + /* Vendor ordering: P3 low once the backlight is running. */ + pcf8574_write(PCF_PIN_LCD_POWER, false); + + return ESP_OK; +} + +esp_lcd_panel_handle_t display_panel(void) +{ + return s_panel; +} + +esp_err_t display_get_framebuffers(void **fb0, void **fb1) +{ + if (!s_panel) { + return ESP_ERR_INVALID_STATE; + } + if (fb0) { + *fb0 = s_fb0; + } + if (fb1) { + *fb1 = s_fb1; + } + return ESP_OK; +} + +void display_test_pattern(void) +{ + static const uint16_t bars[] = { + 0xF800, /* red */ + 0x07E0, /* green */ + 0x001F, /* blue */ + 0xFFFF, /* white */ + 0xFFE0, /* yellow */ + 0x07FF, /* cyan */ + 0xF81F, /* magenta*/ + 0x0000, /* black */ + }; + const int n = sizeof(bars) / sizeof(bars[0]); + const int bar_w = DISPLAY_H_RES / n; + + uint16_t *fb = (uint16_t *)s_fb0; + if (!fb) { + ESP_LOGE(TAG, "no framebuffer"); + return; + } + + for (int y = 0; y < DISPLAY_V_RES; y++) { + for (int x = 0; x < DISPLAY_H_RES; x++) { + int i = x / bar_w; + fb[y * DISPLAY_H_RES + x] = bars[i < n ? i : n - 1]; + } + } + + esp_lcd_panel_draw_bitmap(s_panel, 0, 0, DISPLAY_H_RES, DISPLAY_V_RES, fb); + ESP_LOGI(TAG, "colour bars drawn — this is the PRD phase-2 check"); +} diff --git a/main/drivers/display/display.h b/main/drivers/display/display.h new file mode 100644 index 0000000..d2dd69d --- /dev/null +++ b/main/drivers/display/display.h @@ -0,0 +1,55 @@ +/* + * ST7701S + RGB panel, 480x480 round. + * + * Two things about the configuration here are deliberate and load-bearing: + * + * No bounce buffers. The bounce-buffer refill ISR reads PSRAM, which shares + * the cache with flash on the S3, so any NVS write while the panel runs faults + * with "Cache disabled but cached memory region accessed" (PRD 8.2). This + * project writes settings to NVS at runtime, so instead of pausing the panel + * around every write we simply do not use bounce buffers: the LCD DMA reads + * the framebuffer out of PSRAM directly and never touches the cache. + * + * Two framebuffers. Writing into the framebuffer currently being scanned + * races the LCD's reads, and the corruption persists because LVGL does not + * know those pixels are wrong and never reinvalidates them (PRD 8.2). With + * num_fbs=2 the panel driver swaps whole buffers instead. + * + * 480x480x2 bytes x 2 buffers is ~920KB of the 8MB PSRAM. + */ +#pragma once + +#include +#include + +#include "esp_err.h" +#include "esp_lcd_panel_ops.h" + +#define DISPLAY_H_RES 480 +#define DISPLAY_V_RES 480 + +/* + * Bring up the panel: expander power/reset sequencing, 3-wire SPI init, + * RGB bus, backlight. + * + * Requires i2c_bus_init() and pcf8574_init() to have run. PRD 8.2 also wants + * WiFi up before this point. + */ +esp_err_t display_init(void); + +/* The esp_lcd panel handle, for the LVGL port. */ +esp_lcd_panel_handle_t display_panel(void); + +/* Both framebuffers, for LVGL to render into directly. */ +esp_err_t display_get_framebuffers(void **fb0, void **fb1); + +/* Backlight, 0-100. */ +void display_set_backlight(uint8_t percent); + +/* + * Fill the screen with vertical colour bars, bypassing LVGL. + * + * This is the PRD phase-2 check: it proves the init sequence, the RGB bus and + * the timings independently of anything LVGL does. + */ +void display_test_pattern(void); diff --git a/main/drivers/display/st7701_init.h b/main/drivers/display/st7701_init.h new file mode 100644 index 0000000..0a87523 --- /dev/null +++ b/main/drivers/display/st7701_init.h @@ -0,0 +1,148 @@ +/* + * ST7701S initialisation sequence for the CrowPanel 2.1" round panel. + * + * Transcribed verbatim from `st7701_type5_init_operations` in the + * Arduino_GFX bundled with Elecrow's own example for this board + * (example/libraries/GFX_Library_for_Arduino/src/display/Arduino_ST7701_RGBPanel.h), + * which is what example/RotaryScreen_2_1/RotaryScreen_2_1.ino passes to the + * panel constructor. + * + * PRD 8.1: one wrong byte here gives garbage or nothing, with no useful + * diagnostic. Do not "tidy" these values. Do not reorder them. The bank + * selects (0xFF ...) change the meaning of every register that follows, so a + * command moved across one is a different command. + * + * Cross-checked against example/esphome/esphome2.1.yaml, which carries the + * same sequence minus three commands in bank 0x10 (0xC0, 0xC1, 0xC2) — and + * which admits in its own comments that its colours come out wrong. The three + * extra commands are kept. + * + * The leading software reset (0x01) is from the ESPHome version. It is not in + * the Arduino one, but it costs 5ms and makes the starting state deterministic + * rather than "whatever the previous firmware left behind". + */ +#pragma once + +#include + +typedef struct { + uint8_t cmd; + uint8_t data[16]; + uint8_t data_len; + uint16_t delay_ms; +} st7701_cmd_t; + +static const st7701_cmd_t st7701_init_seq[] = { + /* Software reset. */ + {0x01, {0}, 0, 5}, + + /* ---- Bank 0x10 ---- */ + {0xFF, {0x77, 0x01, 0x00, 0x00, 0x10}, 5, 0}, + + {0xC0, {0x3B, 0x00}, 2, 0}, + {0xC1, {0x0B, 0x02}, 2, 0}, /* VBP */ + {0xC2, {0x00, 0x02}, 2, 0}, + + {0xCC, {0x10}, 1, 0}, + {0xCD, {0x08}, 1, 0}, + + /* + * Not part of st7701_type5_init_operations, but Arduino_GFX's + * begin() calls setRotation(0) straight afterwards, and that path sends + * 0xC7 0x00 here (Y direction) before dropping back to bank 0x00 to write + * MADCTL. The vendor example therefore does send it, so it belongs in the + * sequence. + * + * That same setRotation(0) call is where the constructor's `bgr = true` + * argument lands: with bgr set it writes MADCTL 0x00, which is what this + * sequence already writes below. So bgr=true is a no-op at rotation 0 and + * the RGB panel does not need its BGR flag set. + */ + {0xC7, {0x00}, 1, 0}, + + /* Positive voltage gamma control. */ + {0xB0, {0x02, 0x13, 0x1B, 0x0D, + 0x10, 0x05, 0x08, 0x07, + 0x07, 0x24, 0x04, 0x11, + 0x0E, 0x2C, 0x33, 0x1D}, 16, 0}, + + /* Negative voltage gamma control. */ + {0xB1, {0x05, 0x13, 0x1B, 0x0D, + 0x11, 0x05, 0x08, 0x07, + 0x07, 0x24, 0x04, 0x11, + 0x0E, 0x2C, 0x33, 0x1D}, 16, 0}, + + /* ---- Bank 0x11 ---- */ + {0xFF, {0x77, 0x01, 0x00, 0x00, 0x11}, 5, 0}, + + {0xB0, {0x5D}, 1, 0}, + {0xB1, {0x43}, 1, 0}, /* VCOM amplitude */ + {0xB2, {0x81}, 1, 0}, /* VGH, 12V */ + {0xB3, {0x80}, 1, 0}, + + {0xB5, {0x43}, 1, 0}, /* VGL, -8.3V */ + + {0xB7, {0x85}, 1, 0}, + {0xB8, {0x20}, 1, 0}, + + {0xC1, {0x78}, 1, 0}, + {0xC2, {0x78}, 1, 0}, + + {0xD0, {0x88}, 1, 0}, + + {0xE0, {0x00, 0x00, 0x02}, 3, 0}, + + {0xE1, {0x03, 0xA0, 0x00, 0x00, + 0x04, 0xA0, 0x00, 0x00, + 0x00, 0x20, 0x20}, 11, 0}, + + {0xE2, {0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, + 0x00}, 13, 0}, + + {0xE3, {0x00, 0x00, 0x11, 0x00}, 4, 0}, + + {0xE4, {0x22, 0x00}, 2, 0}, + + {0xE5, {0x05, 0xEC, 0xA0, 0xA0, + 0x07, 0xEE, 0xA0, 0xA0, + 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00}, 16, 0}, + + {0xE6, {0x00, 0x00, 0x11, 0x00}, 4, 0}, + + {0xE7, {0x22, 0x00}, 2, 0}, + + {0xE8, {0x06, 0xED, 0xA0, 0xA0, + 0x08, 0xEF, 0xA0, 0xA0, + 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00}, 16, 0}, + + {0xEB, {0x00, 0x00, 0x40, 0x40, + 0x00, 0x00, 0x00}, 7, 0}, + + {0xED, {0xFF, 0xFF, 0xFF, 0xBA, + 0x0A, 0xBF, 0x45, 0xFF, + 0xFF, 0x54, 0xFB, 0xA0, + 0xAB, 0xFF, 0xFF, 0xFF}, 16, 0}, + + {0xEF, {0x10, 0x0D, 0x04, 0x08, + 0x3F, 0x1F}, 6, 0}, + + /* ---- Bank 0x13 ---- */ + {0xFF, {0x77, 0x01, 0x00, 0x00, 0x13}, 5, 0}, + {0xEF, {0x08}, 1, 0}, + + /* ---- Back to the command bank ---- */ + {0xFF, {0x77, 0x01, 0x00, 0x00, 0x00}, 5, 0}, + + {0x36, {0x00}, 1, 0}, /* MADCTL */ + {0x3A, {0x60}, 1, 0}, /* 0x70=RGB888, 0x60=RGB666, 0x50=RGB565 */ + + {0x11, {0}, 0, 120}, /* sleep out */ + {0x29, {0}, 0, 50}, /* display on */ +}; + +#define ST7701_INIT_SEQ_LEN \ + (sizeof(st7701_init_seq) / sizeof(st7701_init_seq[0])) diff --git a/main/drivers/encoder/encoder.c b/main/drivers/encoder/encoder.c new file mode 100644 index 0000000..bb24a5a --- /dev/null +++ b/main/drivers/encoder/encoder.c @@ -0,0 +1,176 @@ +#include "encoder.h" + +#include "driver/pulse_cnt.h" +#include "esp_check.h" +#include "esp_log.h" +#include "esp_timer.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "pcf8574.h" + +static const char *TAG = "encoder"; + +/* + * Full quadrature decode counts 4 edges per mechanical detent on an EC28A. + * PCNT counts in raw edges; we divide. + */ +#define COUNTS_PER_DETENT 4 + +#define PCNT_HIGH_LIMIT 1000 +#define PCNT_LOW_LIMIT (-1000) +#define GLITCH_FILTER_NS 1000 + +#define BUTTON_POLL_MS 20 +#define BUTTON_DEBOUNCE_N 3 +#define LONG_PRESS_MS 800 + +static pcnt_unit_handle_t s_unit; +static int s_residual; /* raw counts not yet worth a detent */ + +static volatile encoder_button_event_t s_btn_event; +static volatile bool s_btn_down; + +static void button_task(void *arg) +{ + (void)arg; + + int stable = 0; + bool debounced = false; /* true = pressed */ + int64_t pressed_at = 0; + bool long_fired = false; + + while (1) { + bool level = true; + /* P5 is INPUT_PULLUP: low means pressed. */ + bool pressed = (pcf8574_read(PCF_PIN_ENCODER_SW, &level) == ESP_OK) + && !level; + + if (pressed == debounced) { + stable = 0; + } else if (++stable >= BUTTON_DEBOUNCE_N) { + stable = 0; + debounced = pressed; + s_btn_down = pressed; + + if (pressed) { + pressed_at = esp_timer_get_time(); + long_fired = false; + } else if (!long_fired) { + /* Click reported on release, so a long press never also + * produces a click. */ + s_btn_event = ENCODER_BTN_CLICK; + } + } + + if (debounced && !long_fired && + (esp_timer_get_time() - pressed_at) > (LONG_PRESS_MS * 1000)) { + long_fired = true; + s_btn_event = ENCODER_BTN_LONG_PRESS; + } + + vTaskDelay(pdMS_TO_TICKS(BUTTON_POLL_MS)); + } +} + +esp_err_t encoder_init(void) +{ + pcnt_unit_config_t unit_cfg = { + .high_limit = PCNT_HIGH_LIMIT, + .low_limit = PCNT_LOW_LIMIT, + }; + ESP_RETURN_ON_ERROR(pcnt_new_unit(&unit_cfg, &s_unit), TAG, "pcnt unit"); + + pcnt_glitch_filter_config_t filter = { + .max_glitch_ns = GLITCH_FILTER_NS, + }; + ESP_RETURN_ON_ERROR(pcnt_unit_set_glitch_filter(s_unit, &filter), + TAG, "glitch filter"); + + /* Two channels, each watching one edge and using the other line as the + * direction control — the standard full-quadrature setup. */ + pcnt_chan_config_t chan_a_cfg = { + .edge_gpio_num = ENCODER_PIN_A, + .level_gpio_num = ENCODER_PIN_B, + }; + pcnt_channel_handle_t chan_a = NULL; + ESP_RETURN_ON_ERROR(pcnt_new_channel(s_unit, &chan_a_cfg, &chan_a), + TAG, "chan a"); + + pcnt_chan_config_t chan_b_cfg = { + .edge_gpio_num = ENCODER_PIN_B, + .level_gpio_num = ENCODER_PIN_A, + }; + pcnt_channel_handle_t chan_b = NULL; + ESP_RETURN_ON_ERROR(pcnt_new_channel(s_unit, &chan_b_cfg, &chan_b), + TAG, "chan b"); + + ESP_RETURN_ON_ERROR( + pcnt_channel_set_edge_action(chan_a, + PCNT_CHANNEL_EDGE_ACTION_DECREASE, + PCNT_CHANNEL_EDGE_ACTION_INCREASE), + TAG, "chan a edge"); + ESP_RETURN_ON_ERROR( + pcnt_channel_set_level_action(chan_a, + PCNT_CHANNEL_LEVEL_ACTION_KEEP, + PCNT_CHANNEL_LEVEL_ACTION_INVERSE), + TAG, "chan a level"); + ESP_RETURN_ON_ERROR( + pcnt_channel_set_edge_action(chan_b, + PCNT_CHANNEL_EDGE_ACTION_INCREASE, + PCNT_CHANNEL_EDGE_ACTION_DECREASE), + TAG, "chan b edge"); + ESP_RETURN_ON_ERROR( + pcnt_channel_set_level_action(chan_b, + PCNT_CHANNEL_LEVEL_ACTION_KEEP, + PCNT_CHANNEL_LEVEL_ACTION_INVERSE), + TAG, "chan b level"); + + ESP_RETURN_ON_ERROR(pcnt_unit_enable(s_unit), TAG, "enable"); + ESP_RETURN_ON_ERROR(pcnt_unit_clear_count(s_unit), TAG, "clear"); + ESP_RETURN_ON_ERROR(pcnt_unit_start(s_unit), TAG, "start"); + + if (xTaskCreate(button_task, "enc_btn", 3072, NULL, 4, NULL) != pdPASS) { + ESP_LOGE(TAG, "button task create failed"); + return ESP_ERR_NO_MEM; + } + + ESP_LOGI(TAG, "ready: A=%d B=%d, button on expander P%d", + ENCODER_PIN_A, ENCODER_PIN_B, PCF_PIN_ENCODER_SW); + return ESP_OK; +} + +int encoder_take_delta(void) +{ + if (!s_unit) { + return 0; + } + + int raw = 0; + if (pcnt_unit_get_count(s_unit, &raw) != ESP_OK) { + return 0; + } + pcnt_unit_clear_count(s_unit); + + /* + * Carry the remainder rather than discarding it. Dropping it would lose a + * detent whenever a turn straddles two polls, which shows up as a knob + * that occasionally ignores you. + */ + s_residual += raw; + int detents = s_residual / COUNTS_PER_DETENT; + s_residual -= detents * COUNTS_PER_DETENT; + + return detents; +} + +encoder_button_event_t encoder_take_button_event(void) +{ + encoder_button_event_t e = s_btn_event; + s_btn_event = ENCODER_BTN_NONE; + return e; +} + +bool encoder_button_is_down(void) +{ + return s_btn_down; +} diff --git a/main/drivers/encoder/encoder.h b/main/drivers/encoder/encoder.h new file mode 100644 index 0000000..e459c1b --- /dev/null +++ b/main/drivers/encoder/encoder.h @@ -0,0 +1,44 @@ +/* + * Rotary encoder: A = GPIO42, B = GPIO4, button on expander P5. + * + * Quadrature decoding uses the PCNT peripheral with a glitch filter rather + * than a polling task. The vendor example polls A in a task and infers + * direction from B, which drops detents when the UI is busy — not acceptable + * for a knob that sets a heating setpoint. + * + * The button is on the I2C expander, so it has to be polled. 20ms with a + * 3-sample debounce is well inside human reaction time and costs one short + * I2C transaction per poll. + */ +#pragma once + +#include +#include + +#include "esp_err.h" + +#define ENCODER_PIN_A 42 +#define ENCODER_PIN_B 4 + +typedef enum { + ENCODER_BTN_NONE = 0, + ENCODER_BTN_CLICK, + ENCODER_BTN_LONG_PRESS, +} encoder_button_event_t; + +esp_err_t encoder_init(void); + +/* + * Detents accumulated since the last call, positive clockwise. Reading + * clears the accumulator, so exactly one consumer should call this — the UI. + */ +int encoder_take_delta(void); + +/* + * Pending button event, cleared by reading. Long press is 800ms; the click is + * reported on release so the two never both fire. + */ +encoder_button_event_t encoder_take_button_event(void); + +/* Instantaneous button state, for diagnostics. */ +bool encoder_button_is_down(void); diff --git a/main/drivers/expander/pcf8574.c b/main/drivers/expander/pcf8574.c new file mode 100644 index 0000000..8574894 --- /dev/null +++ b/main/drivers/expander/pcf8574.c @@ -0,0 +1,141 @@ +#include "pcf8574.h" + +#include "esp_log.h" +#include "freertos/FreeRTOS.h" +#include "freertos/semphr.h" +#include "i2c_bus.h" + +static const char *TAG = "pcf8574"; + +static i2c_master_dev_handle_t s_dev; +static SemaphoreHandle_t s_lock; +static bool s_ready; + +/* + * Power-on state: all pins released high. Critically this is also + * relay-off for the active-low module on P1, so the shadow is safe from the + * first instruction, before init has even run. + */ +static uint8_t s_shadow = 0xFF; + +#define LOCK() xSemaphoreTakeRecursive(s_lock, portMAX_DELAY) +#define UNLOCK() xSemaphoreGiveRecursive(s_lock) + +static esp_err_t flush_locked(void) +{ + return i2c_master_transmit(s_dev, &s_shadow, 1, 100); +} + +esp_err_t pcf8574_init(void) +{ + if (s_ready) { + return ESP_OK; + } + + if (!s_lock) { + s_lock = xSemaphoreCreateRecursiveMutex(); + if (!s_lock) { + return ESP_ERR_NO_MEM; + } + } + + esp_err_t err = i2c_bus_add_device(PCF8574_I2C_ADDR, &s_dev); + if (err != ESP_OK) { + ESP_LOGE(TAG, "add device 0x%02X failed: %s", + PCF8574_I2C_ADDR, esp_err_to_name(err)); + return err; + } + + /* Push the known-safe shadow out before anything else can ask for a pin + * change. If the ESP32 rebooted while the relay was closed, the expander + * still has that old latch — this is the write that clears it. */ + LOCK(); + err = flush_locked(); + UNLOCK(); + + if (err != ESP_OK) { + ESP_LOGE(TAG, "not responding at 0x%02X: %s", + PCF8574_I2C_ADDR, esp_err_to_name(err)); + return err; + } + + s_ready = true; + ESP_LOGI(TAG, "ready at 0x%02X, port=0x%02X", PCF8574_I2C_ADDR, s_shadow); + return ESP_OK; +} + +bool pcf8574_is_ready(void) +{ + return s_ready; +} + +esp_err_t pcf8574_write(uint8_t pin, bool level) +{ + if (pin > 7) { + return ESP_ERR_INVALID_ARG; + } + if (!s_lock) { + return ESP_ERR_INVALID_STATE; + } + + LOCK(); + uint8_t before = s_shadow; + if (level) { + s_shadow |= (uint8_t)(1u << pin); + } else { + s_shadow &= (uint8_t)~(1u << pin); + } + + esp_err_t err = flush_locked(); + if (err != ESP_OK) { + /* Roll the shadow back so it keeps describing the chip rather than + * our intent — the relay layer reasons about it. */ + s_shadow = before; + } + UNLOCK(); + + return err; +} + +esp_err_t pcf8574_read(uint8_t pin, bool *level) +{ + if (pin > 7 || !level) { + return ESP_ERR_INVALID_ARG; + } + if (!s_lock) { + return ESP_ERR_INVALID_STATE; + } + + uint8_t port = 0; + LOCK(); + esp_err_t err = i2c_master_receive(s_dev, &port, 1, 100); + UNLOCK(); + + if (err == ESP_OK) { + *level = (port & (1u << pin)) != 0; + } + return err; +} + +esp_err_t pcf8574_write_port(uint8_t value) +{ + if (!s_lock) { + return ESP_ERR_INVALID_STATE; + } + + LOCK(); + uint8_t before = s_shadow; + s_shadow = value; + esp_err_t err = flush_locked(); + if (err != ESP_OK) { + s_shadow = before; + } + UNLOCK(); + + return err; +} + +uint8_t pcf8574_shadow(void) +{ + return s_shadow; +} diff --git a/main/drivers/expander/pcf8574.h b/main/drivers/expander/pcf8574.h new file mode 100644 index 0000000..074c4eb --- /dev/null +++ b/main/drivers/expander/pcf8574.h @@ -0,0 +1,49 @@ +/* + * PCF8574 I/O expander @ 0x21. + * + * The PCF8574 has no direction register. Every pin is quasi-bidirectional: + * writing 1 releases the pin to a weak pull-up (and lets it be read as an + * input), writing 0 drives it hard low. So "output high" and "input" are the + * same electrical state, and there is no way to read back what you last wrote + * — reading returns the actual pin level. + * + * That is why this driver keeps a shadow byte. Without it, a read-modify-write + * to set the touch reset line would sample the encoder button and the relay + * line and write those levels back, which on the relay is a genuine hazard. + * The shadow starts at 0xFF, matching the chip's power-on state (all pins + * released high) — which is also the relay-off state for an active-low module. + */ +#pragma once + +#include +#include + +#include "esp_err.h" + +#define PCF8574_I2C_ADDR 0x21 + +/* Named per docs/HARDWARE.md. */ +#define PCF_PIN_TOUCH_RST 0 +#define PCF_PIN_RELAY 1 /* this project — heat call, active low */ +#define PCF_PIN_TOUCH_INT 2 +#define PCF_PIN_LCD_POWER 3 +#define PCF_PIN_LCD_RST 4 +#define PCF_PIN_ENCODER_SW 5 +/* P6, P7 free */ + +esp_err_t pcf8574_init(void); + +/* True once init has succeeded. relay.c checks this before trusting writes. */ +bool pcf8574_is_ready(void); + +/* Drive one pin. level=false pulls low; level=true releases to the pull-up. */ +esp_err_t pcf8574_write(uint8_t pin, bool level); + +/* Read the actual level of one pin. */ +esp_err_t pcf8574_read(uint8_t pin, bool *level); + +/* Write the whole port at once. Updates the shadow. */ +esp_err_t pcf8574_write_port(uint8_t value); + +/* Last value written, not the pin levels. */ +uint8_t pcf8574_shadow(void); diff --git a/main/drivers/i2c_bus.c b/main/drivers/i2c_bus.c new file mode 100644 index 0000000..7181db6 --- /dev/null +++ b/main/drivers/i2c_bus.c @@ -0,0 +1,84 @@ +#include "i2c_bus.h" + +#include "esp_log.h" + +static const char *TAG = "i2c"; + +static i2c_master_bus_handle_t s_bus; + +esp_err_t i2c_bus_init(void) +{ + if (s_bus) { + return ESP_OK; + } + + i2c_master_bus_config_t cfg = { + .i2c_port = I2C_NUM_0, + .sda_io_num = I2C_BUS_SDA_GPIO, + .scl_io_num = I2C_BUS_SCL_GPIO, + .clk_source = I2C_CLK_SRC_DEFAULT, + .glitch_ignore_cnt = 7, + .flags.enable_internal_pullup = true, + }; + + esp_err_t err = i2c_new_master_bus(&cfg, &s_bus); + if (err != ESP_OK) { + ESP_LOGE(TAG, "bus init failed: %s", esp_err_to_name(err)); + return err; + } + + ESP_LOGI(TAG, "bus up on SDA=%d SCL=%d @ %d Hz", + I2C_BUS_SDA_GPIO, I2C_BUS_SCL_GPIO, I2C_BUS_FREQ_HZ); + return ESP_OK; +} + +esp_err_t i2c_bus_add_device(uint8_t addr, i2c_master_dev_handle_t *out) +{ + if (!s_bus) { + return ESP_ERR_INVALID_STATE; + } + + i2c_device_config_t cfg = { + .dev_addr_length = I2C_ADDR_BIT_LEN_7, + .device_address = addr, + .scl_speed_hz = I2C_BUS_FREQ_HZ, + }; + + return i2c_master_bus_add_device(s_bus, &cfg, out); +} + +int i2c_bus_scan(void) +{ + if (!s_bus) { + ESP_LOGE(TAG, "scan before init"); + return -1; + } + + int found = 0; + ESP_LOGI(TAG, "scanning..."); + + for (uint8_t addr = 0x08; addr < 0x78; addr++) { + if (i2c_master_probe(s_bus, addr, 50) == ESP_OK) { + const char *who = "?"; + if (addr == 0x21) { + who = "PCF8574 expander"; + } else if (addr == 0x15) { + who = "CST826 touch"; + } + ESP_LOGI(TAG, " 0x%02X %s", addr, who); + found++; + } + } + + if (found == 0) { + /* Loud, because this means the board or the pinout is wrong, and no + * amount of display debugging past this point is worth doing. */ + ESP_LOGE(TAG, "no I2C devices found — check SDA=%d / SCL=%d wiring " + "before debugging anything else", + I2C_BUS_SDA_GPIO, I2C_BUS_SCL_GPIO); + } else { + ESP_LOGI(TAG, "scan done, %d device(s)", found); + } + + return found; +} diff --git a/main/drivers/i2c_bus.h b/main/drivers/i2c_bus.h new file mode 100644 index 0000000..fe3119a --- /dev/null +++ b/main/drivers/i2c_bus.h @@ -0,0 +1,30 @@ +/* + * i2c_bus — single owner of the board's I2C bus. + * + * SDA 38 / SCL 39, shared by the PCF8574 expander (0x21) and the CST826 touch + * controller (0x15). The I2C connector on the case brings the same bus out, so + * anything added later lands here too. + */ +#pragma once + +#include "driver/i2c_master.h" +#include "esp_err.h" + +#define I2C_BUS_SDA_GPIO 38 +#define I2C_BUS_SCL_GPIO 39 +#define I2C_BUS_FREQ_HZ 400000 + +/* Initialise the bus. Safe to call more than once. */ +esp_err_t i2c_bus_init(void); + +/* Add a device to the shared bus. */ +esp_err_t i2c_bus_add_device(uint8_t addr, i2c_master_dev_handle_t *out); + +/* + * Probe every address and log what answers. + * + * PRD 8.1: this is the cheap sanity check that tells you the board is alive + * and the I2C pins are right, independently of the display. Expect 0x21 and + * 0x15. Returns the number of devices found. + */ +int i2c_bus_scan(void); diff --git a/main/drivers/touch/cst826.c b/main/drivers/touch/cst826.c new file mode 100644 index 0000000..d3fbb45 --- /dev/null +++ b/main/drivers/touch/cst826.c @@ -0,0 +1,96 @@ +#include "cst826.h" + +#include "display.h" +#include "esp_log.h" +#include "i2c_bus.h" + +static const char *TAG = "cst826"; + +#define REG_NUM_TOUCHES 0x02 +#define REG_TOUCH_DATA 0x03 +#define REG_CHIP_TYPE 0xAA + +static i2c_master_dev_handle_t s_dev; +static bool s_ready; + +static esp_err_t read_reg(uint8_t reg, uint8_t *buf, size_t len) +{ + return i2c_master_transmit_receive(s_dev, ®, 1, buf, len, 100); +} + +esp_err_t cst826_init(void) +{ + if (s_ready) { + return ESP_OK; + } + + esp_err_t err = i2c_bus_add_device(CST826_I2C_ADDR, &s_dev); + if (err != ESP_OK) { + ESP_LOGE(TAG, "add device 0x%02X failed: %s", + CST826_I2C_ADDR, esp_err_to_name(err)); + return err; + } + + uint8_t id = 0; + err = read_reg(REG_CHIP_TYPE, &id, 1); + if (err != ESP_OK) { + ESP_LOGE(TAG, "no response at 0x%02X: %s", + CST826_I2C_ADDR, esp_err_to_name(err)); + return err; + } + + if (id != CST826_CHIP_ID) { + /* Not fatal — the thermostat is fully usable from the encoder alone, + * and refusing to boot over a touch panel would be a poor trade for + * something that controls heat. */ + ESP_LOGW(TAG, "unexpected chip id 0x%02X (expected 0x%02X) — " + "continuing with touch disabled", id, CST826_CHIP_ID); + return ESP_ERR_NOT_FOUND; + } + + s_ready = true; + ESP_LOGI(TAG, "ready at 0x%02X, chip id 0x%02X", CST826_I2C_ADDR, id); + return ESP_OK; +} + +bool cst826_is_ready(void) +{ + return s_ready; +} + +void cst826_read(cst826_touch_t *out) +{ + if (!out) { + return; + } + out->pressed = false; + out->x = 0; + out->y = 0; + + if (!s_ready) { + return; + } + + uint8_t n = 0; + if (read_reg(REG_NUM_TOUCHES, &n, 1) != ESP_OK || n == 0 || n > 2) { + return; + } + + uint8_t d[6]; + if (read_reg(REG_TOUCH_DATA, d, sizeof(d)) != ESP_OK) { + return; + } + + uint16_t x = (uint16_t)((d[0] & 0x0F) << 8 | d[1]); + uint16_t y = (uint16_t)((d[2] & 0x0F) << 8 | d[3]); + + /* A garbage read past the panel edge means a bad transfer, not a touch at + * the corner. Drop it rather than sending LVGL a bogus press. */ + if (x >= DISPLAY_H_RES || y >= DISPLAY_V_RES) { + return; + } + + out->x = x; + out->y = y; + out->pressed = true; +} diff --git a/main/drivers/touch/cst826.h b/main/drivers/touch/cst826.h new file mode 100644 index 0000000..0e8391f --- /dev/null +++ b/main/drivers/touch/cst826.h @@ -0,0 +1,46 @@ +/* + * CST826 capacitive touch controller @ I2C 0x15. + * + * Note this is CST826, not the CST816 the PRD names. Same family, but the chip + * ID register reads 0x11 rather than 0xB4, so a CST816 driver's probe rejects + * it and the board looks broken. Register map below is taken from the + * Adafruit_CST8XX library bundled with Elecrow's example for this board. + * + * Protocol: read register 0x02 for the touch count, then a burst from 0x03 of + * 6 bytes per touch: + * + * byte 0: [7:6] event, [3:0] X high nibble + * byte 1: X low byte + * byte 2: [7:4] touch id, [3:0] Y high nibble + * byte 3: Y low byte + * bytes 4,5: unused + */ +#pragma once + +#include +#include + +#include "esp_err.h" + +#define CST826_I2C_ADDR 0x15 +#define CST826_CHIP_ID 0x11 + +typedef struct { + uint16_t x; + uint16_t y; + bool pressed; +} cst826_touch_t; + +/* + * Initialise and verify the chip ID. + * + * Requires i2c_bus_init() and the reset pulse that display.c's power-up + * sequence performs on expander P0. + */ +esp_err_t cst826_init(void); + +bool cst826_is_ready(void); + +/* Latest touch state. Never fails hard — an I2C error reports "not pressed" + * so a flaky touch controller cannot wedge the UI task. */ +void cst826_read(cst826_touch_t *out); diff --git a/main/hvac/control.c b/main/hvac/control.c new file mode 100644 index 0000000..ffc18e8 --- /dev/null +++ b/main/hvac/control.c @@ -0,0 +1,444 @@ +#include "control.h" + +#include +#include + +#include "esp_log.h" +#include "esp_task_wdt.h" +#include "esp_timer.h" +#include "freertos/FreeRTOS.h" +#include "freertos/semphr.h" +#include "freertos/task.h" +#include "relay.h" +#include "settings.h" +#include "zigbee_client.h" + +static const char *TAG = "control"; + +static SemaphoreHandle_t s_lock; +static control_state_t s_state; + +/* Freeze-protection duty cycle bookkeeping, used only in CONTROL_SRC_NONE. */ +static int64_t s_freeze_phase_since_us; +static bool s_freeze_heating; + +static inline float clampf(float v, float lo, float hi) +{ + return v < lo ? lo : (v > hi ? hi : v); +} + +/* -------------------------------------------------------- room picking -- */ + +/* Freshest-first: is this room usable as a governing source right now? */ +static bool room_is_fresh(int idx) +{ + return zigbee_client_reading_age_s(idx) <= CONTROL_STALE_AFTER_S; +} + +/* + * Coldest fresh room. PRD 4.2 recommends min across rooms as the default + * formula: nobody is cold, at the cost of overheating elsewhere. + */ +static int coldest_fresh_room(float *temp_out) +{ + int best = -1; + float coldest = 0; + + for (int i = 0; i < zigbee_client_room_count(); i++) { + room_reading_t r; + if (!zigbee_client_get_reading(i, &r) || !r.valid || !room_is_fresh(i)) { + continue; + } + if (best < 0 || r.temperature_c < coldest) { + best = i; + coldest = r.temperature_c; + } + } + + if (best >= 0 && temp_out) { + *temp_out = coldest; + } + return best; +} + +/* Least-stale room with any reading at all, for the aged-data rung. */ +static int freshest_known_room(float *temp_out) +{ + int best = -1; + uint32_t best_age = UINT32_MAX; + + for (int i = 0; i < zigbee_client_room_count(); i++) { + room_reading_t r; + if (!zigbee_client_get_reading(i, &r) || !r.valid) { + continue; + } + uint32_t age = zigbee_client_reading_age_s(i); + if (best < 0 || age < best_age) { + best = i; + best_age = age; + if (temp_out) { + *temp_out = r.temperature_c; + } + } + } + return best; +} + +/* + * Which room the active mode wants to govern, ignoring freshness. + * + * Returns -1 for formula mode, where no single room is "the" room until we + * have looked at the readings. + */ +static int mode_preferred_room(const settings_t *cfg, bool *clock_valid_out) +{ + *clock_valid_out = true; + + switch ((room_mode_t)cfg->mode) { + case ROOM_MODE_MANUAL: + return cfg->selected_room; + + case ROOM_MODE_SCHEDULE: { + time_t now = time(NULL); + /* + * Before SNTP syncs, the clock reads 1970 and every schedule rule + * would resolve to whatever covers midnight. Acting on that is worse + * than not acting: fall through to the formula and say so on screen. + */ + if (now < 1600000000) { + *clock_valid_out = false; + return -1; + } + struct tm tm; + localtime_r(&now, &tm); + return settings_schedule_room_for_time(tm.tm_hour, tm.tm_min); + } + + case ROOM_MODE_FORMULA: + default: + return -1; + } +} + +/* --------------------------------------------------- the fallback ladder -- */ + +/* + * PRD 5.5, in order: + * 1. the selected room, if fresh + * 2. another room's fresh reading + * 3. (a local sensor, if one were fitted — none is) + * 4. aged data with the setpoint clamped conservatively + * 5. no data at all: open-loop freeze protection + * + * Rungs 4 and 5 always raise a visible fault. Neither ever holds the relay + * closed on its own; the relay layer's interlocks still apply on top. + */ +static void evaluate_source(const settings_t *cfg, control_state_t *st) +{ + bool clock_valid = true; + int preferred = mode_preferred_room(cfg, &clock_valid); + st->schedule_clock_valid = clock_valid; + + /* Rung 1: the mode's chosen room, fresh. */ + if (preferred >= 0 && preferred < zigbee_client_room_count() && + room_is_fresh(preferred)) { + room_reading_t r; + zigbee_client_get_reading(preferred, &r); + st->governing_room = preferred; + st->governing_temp_c = r.temperature_c; + st->source = CONTROL_SRC_SELECTED; + st->effective_setpoint_c = st->setpoint_c; + return; + } + + /* + * Rung 2: any fresh room, coldest wins. + * + * In formula mode this is the normal path rather than a fallback, so it + * is only reported as CONTROL_SRC_OTHER_ROOM — which the UI shows as a + * fault — when a specific room was actually asked for and failed. + */ + float temp = 0; + int fresh = coldest_fresh_room(&temp); + if (fresh >= 0) { + st->governing_room = fresh; + st->governing_temp_c = temp; + st->source = (preferred < 0 && clock_valid) + ? CONTROL_SRC_SELECTED + : CONTROL_SRC_OTHER_ROOM; + st->effective_setpoint_c = st->setpoint_c; + return; + } + + /* Rung 4: nothing fresh anywhere. Run on the least-stale reading we have, + * with the setpoint clamped down so a wrong number cannot cook the house. */ + int known = freshest_known_room(&temp); + if (known >= 0) { + st->governing_room = known; + st->governing_temp_c = temp; + st->source = CONTROL_SRC_STALE; + st->effective_setpoint_c = + fminf(st->setpoint_c, CONTROL_FALLBACK_SETPOINT_C); + return; + } + + /* Rung 5: no reading since boot. */ + st->governing_room = -1; + st->governing_temp_c = NAN; + st->source = CONTROL_SRC_NONE; + st->effective_setpoint_c = CONTROL_FALLBACK_SETPOINT_C; +} + +/* ----------------------------------------------------------- decision --- */ + +/* + * Open-loop freeze protection. Only reachable with no reading at all. + * + * Deliberately a low duty cycle rather than "off": PRD 5.5 says stale or + * missing data must not silently mean no heat, because frozen pipes are the + * failure this is guarding against. + */ +static bool freeze_protection_wants_heat(void) +{ + int64_t now = esp_timer_get_time(); + if (s_freeze_phase_since_us == 0) { + s_freeze_phase_since_us = now; + s_freeze_heating = false; + } + + uint32_t phase_s = (uint32_t)((now - s_freeze_phase_since_us) / 1000000); + uint32_t limit = s_freeze_heating ? CONTROL_FREEZE_DUTY_ON_S + : CONTROL_FREEZE_DUTY_OFF_S; + + if (phase_s >= limit) { + s_freeze_heating = !s_freeze_heating; + s_freeze_phase_since_us = now; + } + + return s_freeze_heating; +} + +/* Hysteresis. Below setpoint-deadband call for heat, above setpoint+deadband + * stop, in between hold whatever we were doing. */ +static bool decide_heat(const control_state_t *st, bool currently_on) +{ + if (st->source == CONTROL_SRC_NONE) { + return freeze_protection_wants_heat(); + } + + float t = st->governing_temp_c; + if (isnan(t)) { + return false; + } + + if (t < st->effective_setpoint_c - CONTROL_DEADBAND_C) { + return true; + } + if (t > st->effective_setpoint_c + CONTROL_DEADBAND_C) { + return false; + } + return currently_on; +} + +/* ---------------------------------------------------------- main task --- */ + +static void control_task(void *arg) +{ + (void)arg; + + esp_task_wdt_add(NULL); + + control_source_t last_source = CONTROL_SRC_SELECTED; + bool last_call = false; + + while (1) { + esp_task_wdt_reset(); + + settings_t cfg; + settings_get(&cfg); + + control_state_t st; + xSemaphoreTake(s_lock, portMAX_DELAY); + st = s_state; + xSemaphoreGive(s_lock); + + st.setpoint_c = clampf(cfg.setpoint_c, + CONTROL_SETPOINT_MIN_C, CONTROL_SETPOINT_MAX_C); + st.mode = (room_mode_t)cfg.mode; + + evaluate_source(&cfg, &st); + + /* Reset the freeze-protection phase whenever real data returns, so a + * brief outage does not leave us mid-duty-cycle. */ + if (st.source != CONTROL_SRC_NONE) { + s_freeze_phase_since_us = 0; + s_freeze_heating = false; + } + + st.calling_for_heat = decide_heat(&st, relay_is_on()); + + /* + * Always call, every cycle, including when asking for off. This is the + * relay watchdog refresh: silence is what tells the relay layer the + * control loop has died. + */ + relay_request_heat(st.calling_for_heat); + + xSemaphoreTake(s_lock, portMAX_DELAY); + s_state = st; + xSemaphoreGive(s_lock); + + if (st.source != last_source) { + if (st.source == CONTROL_SRC_SELECTED) { + ESP_LOGI(TAG, "source ok: %s", + zigbee_client_room_name(st.governing_room)); + } else { + ESP_LOGW(TAG, "degraded to %s", control_source_str(st.source)); + } + last_source = st.source; + } + + if (st.calling_for_heat != last_call) { + if (isnan(st.governing_temp_c)) { + ESP_LOGI(TAG, "heat %s (no reading, freeze protection)", + st.calling_for_heat ? "requested" : "released"); + } else { + ESP_LOGI(TAG, "heat %s: %.1fC vs setpoint %.1fC", + st.calling_for_heat ? "requested" : "released", + st.governing_temp_c, st.effective_setpoint_c); + } + last_call = st.calling_for_heat; + } + + vTaskDelay(pdMS_TO_TICKS(CONTROL_INTERVAL_S * 1000)); + } +} + +/* ---------------------------------------------------------------- api --- */ + +esp_err_t control_init(void) +{ + if (s_lock) { + return ESP_OK; + } + + s_lock = xSemaphoreCreateMutex(); + if (!s_lock) { + return ESP_ERR_NO_MEM; + } + + settings_t cfg; + settings_get(&cfg); + + s_state = (control_state_t){ + .setpoint_c = cfg.setpoint_c, + .effective_setpoint_c = cfg.setpoint_c, + .governing_temp_c = NAN, + .governing_room = -1, + .mode = (room_mode_t)cfg.mode, + .source = CONTROL_SRC_NONE, + .calling_for_heat = false, + .schedule_clock_valid = false, + }; + + if (xTaskCreate(control_task, "control", 4096, NULL, 5, NULL) != pdPASS) { + ESP_LOGE(TAG, "task create failed"); + return ESP_ERR_NO_MEM; + } + + ESP_LOGI(TAG, "started: %ds cadence, deadband +/-%.1fC, stale after %ds", + CONTROL_INTERVAL_S, CONTROL_DEADBAND_C, CONTROL_STALE_AFTER_S); + return ESP_OK; +} + +void control_get_state(control_state_t *out) +{ + if (!out) { + return; + } + if (!s_lock) { + *out = (control_state_t){ + .governing_temp_c = NAN, + .governing_room = -1, + .source = CONTROL_SRC_NONE, + }; + return; + } + + xSemaphoreTake(s_lock, portMAX_DELAY); + *out = s_state; + xSemaphoreGive(s_lock); +} + +void control_set_setpoint(float celsius) +{ + settings_set_setpoint(clampf(celsius, + CONTROL_SETPOINT_MIN_C, CONTROL_SETPOINT_MAX_C)); +} + +void control_adjust_setpoint(float delta_c) +{ + settings_t cfg; + settings_get(&cfg); + control_set_setpoint(cfg.setpoint_c + delta_c); +} + +float control_get_setpoint(void) +{ + settings_t cfg; + settings_get(&cfg); + return clampf(cfg.setpoint_c, CONTROL_SETPOINT_MIN_C, CONTROL_SETPOINT_MAX_C); +} + +void control_set_mode(room_mode_t mode) +{ + if (mode < ROOM_MODE_COUNT) { + settings_set_mode((uint8_t)mode); + } +} + +room_mode_t control_get_mode(void) +{ + settings_t cfg; + settings_get(&cfg); + return (room_mode_t)cfg.mode; +} + +void control_select_room(int room_index) +{ + if (room_index < 0 || room_index >= zigbee_client_room_count()) { + return; + } + settings_set_selected_room((uint8_t)room_index); + /* PRD 4.2: manual override must always be reachable regardless of the + * active mode. Picking a room *is* that override. */ + settings_set_mode(ROOM_MODE_MANUAL); +} + +int control_get_selected_room(void) +{ + settings_t cfg; + settings_get(&cfg); + return cfg.selected_room; +} + +const char *control_source_str(control_source_t src) +{ + switch (src) { + case CONTROL_SRC_SELECTED: return "ok"; + case CONTROL_SRC_OTHER_ROOM: return "sensor stale, using another room"; + case CONTROL_SRC_STALE: return "all sensors stale, setpoint reduced"; + case CONTROL_SRC_NONE: return "no sensor data, freeze protection"; + default: return "unknown"; + } +} + +const char *control_mode_str(room_mode_t mode) +{ + switch (mode) { + case ROOM_MODE_MANUAL: return "Manual"; + case ROOM_MODE_SCHEDULE: return "Schedule"; + case ROOM_MODE_FORMULA: return "Coldest"; + default: return "?"; + } +} diff --git a/main/hvac/control.h b/main/hvac/control.h new file mode 100644 index 0000000..8956e8f --- /dev/null +++ b/main/hvac/control.h @@ -0,0 +1,107 @@ +/* + * control — setpoint, hysteresis, room selection, and the stale-data ladder. + * + * Runs on the ESP32, not in Home Assistant. The house keeps heating when the + * broker, zigbee2mqtt or HA is down; it degrades through the documented + * fallback ladder rather than simply stopping. + * + * All temperatures in this module are degrees Celsius. Fahrenheit exists only + * in the UI layer. + */ +#pragma once + +#include +#include + +#include "esp_err.h" + +/* -------------------------------------------------------------- tuning -- */ + +/* Evaluate on a fixed cadence, not on every incoming reading (PRD 4.3). */ +#define CONTROL_INTERVAL_S 10 + +/* Hysteresis. Call for heat below setpoint-deadband, stop at setpoint+deadband. */ +#define CONTROL_DEADBAND_C 0.5f + +#define CONTROL_SETPOINT_MIN_C 5.0f +#define CONTROL_SETPOINT_MAX_C 30.0f +#define CONTROL_SETPOINT_DEFAULT_C 20.0f /* exactly 68F */ + +/* A reading older than this no longer governs anything (PRD 5.5, N=20). */ +#define CONTROL_STALE_AFTER_S (20 * 60) + +/* + * Conservative setpoint used once we are running on stale or absent data. + * Low enough that a wrong reading cannot cook the house, high enough to keep + * pipes safe. + */ +#define CONTROL_FALLBACK_SETPOINT_C 16.0f + +/* + * Last-resort freeze protection, used only when there is no reading at all — + * not stale, not old, none since boot. + * + * With no temperature there is no closed loop to run, so this is an open-loop + * duty cycle: heat for ON, then wait at least OFF. PRD 5.5 requires that + * stale data must never hold the relay closed and must never silently mean no + * heat either; with zero data those are the only two failure modes available, + * and a low duty cycle sits between them. Every relay interlock still applies + * on top, so this cannot short-cycle the furnace. + */ +#define CONTROL_FREEZE_DUTY_ON_S (6 * 60) +#define CONTROL_FREEZE_DUTY_OFF_S (24 * 60) + +/* --------------------------------------------------------------- modes -- */ + +typedef enum { + ROOM_MODE_MANUAL = 0, /* encoder picks the governing room */ + ROOM_MODE_SCHEDULE, /* time-of-day rules */ + ROOM_MODE_FORMULA, /* min across rooms — coldest room wins */ + ROOM_MODE_COUNT, +} room_mode_t; + +/* + * How the governing temperature is currently being derived. This is the + * fallback ladder of PRD 5.5, in order of preference, and it is what the UI + * renders as a fault. + */ +typedef enum { + CONTROL_SRC_SELECTED = 0, /* the chosen room, fresh */ + CONTROL_SRC_OTHER_ROOM, /* chosen room stale, another is fresh */ + CONTROL_SRC_STALE, /* nothing fresh; running aged data, clamped */ + CONTROL_SRC_NONE, /* no reading at all; freeze-protection duty */ +} control_source_t; + +typedef struct { + float setpoint_c; /* what the user asked for */ + float effective_setpoint_c; /* after any fallback clamp */ + float governing_temp_c; /* NaN when source is CONTROL_SRC_NONE */ + int governing_room; /* index into the room table, -1 if none */ + room_mode_t mode; + control_source_t source; + bool calling_for_heat; /* what control asked the relay for */ + bool schedule_clock_valid; +} control_state_t; + +/* Start the control task. Requires relay_init() and zigbee_client_init(). */ +esp_err_t control_init(void); + +void control_get_state(control_state_t *out); + +/* Setpoint, clamped to bounds and persisted to NVS. */ +void control_set_setpoint(float celsius); +void control_adjust_setpoint(float delta_c); +float control_get_setpoint(void); + +void control_set_mode(room_mode_t mode); +room_mode_t control_get_mode(void); + +/* + * Manual room selection. Also switches to ROOM_MODE_MANUAL — PRD 4.2 requires + * manual override to be reachable regardless of the active mode. + */ +void control_select_room(int room_index); +int control_get_selected_room(void); + +const char *control_source_str(control_source_t src); +const char *control_mode_str(room_mode_t mode); diff --git a/main/hvac/relay.c b/main/hvac/relay.c new file mode 100644 index 0000000..46f9704 --- /dev/null +++ b/main/hvac/relay.c @@ -0,0 +1,350 @@ +#include "relay.h" + +#include + +#include "esp_check.h" +#include "esp_log.h" +#include "esp_task_wdt.h" +#include "esp_timer.h" +#include "freertos/FreeRTOS.h" +#include "freertos/semphr.h" +#include "freertos/task.h" +#include "pcf8574.h" + +static const char *TAG = "relay"; + +/* The module is active low: pin low closes the contact. */ +#define RELAY_PIN_ASSERTED false +#define RELAY_PIN_DEASSERTED true + +/* + * 250ms. Fast enough that max-on-time and the watchdog are enforced with + * sub-second precision, slow enough to be free. + */ +#define SAFETY_TICK_MS 250 + +/* Ring of call-start timestamps, for the trailing-hour cycle count. */ +#define CYCLE_HISTORY_LEN 8 + +static SemaphoreHandle_t s_lock; +static bool s_initialised; + +static bool s_heat_on; +static bool s_requested; +static relay_fault_t s_fault; + +static int64_t s_boot_us; +static int64_t s_last_request_us; +static int64_t s_on_since_us; /* valid while s_heat_on */ +static int64_t s_off_since_us; /* when the last call ended, or boot */ +static int64_t s_last_start_us; /* start of the most recent call, 0=never */ + +static int64_t s_cycle_starts[CYCLE_HISTORY_LEN]; +static uint8_t s_cycle_next; + +static inline int64_t now_us(void) +{ + return esp_timer_get_time(); +} + +static inline uint32_t secs_since(int64_t then_us) +{ + if (then_us == 0) { + return UINT32_MAX; + } + int64_t d = now_us() - then_us; + return (d < 0) ? 0 : (uint32_t)(d / 1000000); +} + +/* ------------------------------------------------------------- output --- */ + +/* + * The one place that touches the pin. + * + * A failure to close the contact is a comfort problem. A failure to open it is + * the problem this whole module exists to prevent, so opening retries hard and + * escalates the log level. + */ +static esp_err_t drive_pin(bool asserted) +{ + const int attempts = asserted ? 1 : 5; + esp_err_t err = ESP_FAIL; + + for (int i = 0; i < attempts; i++) { + err = pcf8574_write(PCF_PIN_RELAY, + asserted ? RELAY_PIN_ASSERTED : RELAY_PIN_DEASSERTED); + if (err == ESP_OK) { + return ESP_OK; + } + if (!asserted) { + ESP_LOGE(TAG, "FAILED TO OPEN CONTACT (attempt %d/%d): %s", + i + 1, attempts, esp_err_to_name(err)); + vTaskDelay(pdMS_TO_TICKS(20)); + } + } + + return err; +} + +esp_err_t relay_emergency_off(void) +{ + esp_err_t err = drive_pin(false); + + if (s_lock) { + xSemaphoreTake(s_lock, portMAX_DELAY); + } + s_heat_on = false; + s_requested = false; + if (err != ESP_OK) { + s_fault = RELAY_FAULT_IO; + } + if (s_lock) { + xSemaphoreGive(s_lock); + } + + return err; +} + +/* -------------------------------------------------------------- gating -- */ + +/* Seconds still to wait before a new call may start. Caller holds the lock. */ +static uint32_t hold_off_remaining_locked(void) +{ + uint32_t worst = 0; + + uint32_t since_boot = secs_since(s_boot_us); + if (since_boot < RELAY_STARTUP_LOCKOUT_S) { + worst = RELAY_STARTUP_LOCKOUT_S - since_boot; + } + + uint32_t off_for = secs_since(s_off_since_us); + if (off_for < RELAY_MIN_OFF_TIME_S) { + uint32_t r = RELAY_MIN_OFF_TIME_S - off_for; + if (r > worst) { + worst = r; + } + } + + if (s_last_start_us != 0) { + uint32_t since_start = secs_since(s_last_start_us); + if (since_start < RELAY_MIN_CYCLE_PERIOD_S) { + uint32_t r = RELAY_MIN_CYCLE_PERIOD_S - since_start; + if (r > worst) { + worst = r; + } + } + } + + return worst; +} + +static uint8_t cycles_last_hour_locked(void) +{ + int64_t cutoff = now_us() - (int64_t)3600 * 1000000; + uint8_t n = 0; + + for (int i = 0; i < CYCLE_HISTORY_LEN; i++) { + if (s_cycle_starts[i] != 0 && s_cycle_starts[i] >= cutoff) { + n++; + } + } + return n; +} + +/* --------------------------------------------------------- transitions -- */ + +static void turn_on_locked(void) +{ + if (drive_pin(true) != ESP_OK) { + s_fault = RELAY_FAULT_IO; + ESP_LOGE(TAG, "could not close contact"); + return; + } + + s_heat_on = true; + s_on_since_us = now_us(); + s_last_start_us = s_on_since_us; + + s_cycle_starts[s_cycle_next] = s_on_since_us; + s_cycle_next = (uint8_t)((s_cycle_next + 1) % CYCLE_HISTORY_LEN); + + /* A successful start means whatever held us off has cleared. */ + if (s_fault != RELAY_FAULT_IO) { + s_fault = RELAY_FAULT_NONE; + } + + ESP_LOGI(TAG, "HEAT ON (cycles in last hour: %u)", + (unsigned)cycles_last_hour_locked()); +} + +static void turn_off_locked(relay_fault_t reason) +{ + if (drive_pin(false) != ESP_OK) { + s_fault = RELAY_FAULT_IO; + return; + } + + uint32_t ran = secs_since(s_on_since_us); + s_heat_on = false; + s_off_since_us = now_us(); + + if (reason != RELAY_FAULT_NONE) { + s_fault = reason; + ESP_LOGW(TAG, "HEAT OFF after %" PRIu32 "s — %s", + ran, relay_fault_str(reason)); + } else { + ESP_LOGI(TAG, "HEAT OFF after %" PRIu32 "s", ran); + } +} + +/* ---------------------------------------------------------- safety task -- */ + +static void safety_task(void *arg) +{ + (void)arg; + + esp_task_wdt_add(NULL); + + while (1) { + esp_task_wdt_reset(); + + xSemaphoreTake(s_lock, portMAX_DELAY); + + /* + * Interlock 2 — watchdog. Checked before anything else, and it applies + * whether or not heat is currently wanted: if the control loop has + * gone silent we do not trust s_requested at all. + */ + bool control_alive = + secs_since(s_last_request_us) <= RELAY_WATCHDOG_TIMEOUT_S; + + if (!control_alive) { + if (s_heat_on) { + turn_off_locked(RELAY_FAULT_WATCHDOG); + } else if (s_fault == RELAY_FAULT_NONE) { + s_fault = RELAY_FAULT_WATCHDOG; + } + s_requested = false; + + } else if (s_heat_on) { + /* Interlock 3 — maximum on-time. */ + if (secs_since(s_on_since_us) >= RELAY_MAX_ON_TIME_S) { + turn_off_locked(RELAY_FAULT_MAX_ON_TIME); + } else if (!s_requested) { + turn_off_locked(RELAY_FAULT_NONE); + } + + } else { + /* Off, and the control loop is alive. May we start? */ + if (s_requested && hold_off_remaining_locked() == 0) { + turn_on_locked(); + } + } + + xSemaphoreGive(s_lock); + vTaskDelay(pdMS_TO_TICKS(SAFETY_TICK_MS)); + } +} + +/* ----------------------------------------------------------------- api -- */ + +esp_err_t relay_init(void) +{ + if (s_initialised) { + return ESP_OK; + } + + if (!pcf8574_is_ready()) { + ESP_LOGE(TAG, "expander not ready — refusing to arm"); + return ESP_ERR_INVALID_STATE; + } + + s_lock = xSemaphoreCreateMutex(); + if (!s_lock) { + return ESP_ERR_NO_MEM; + } + + s_boot_us = now_us(); + s_off_since_us = s_boot_us; + s_last_start_us = 0; + s_heat_on = false; + s_requested = false; + s_fault = RELAY_FAULT_NONE; + + /* + * Seed the watchdog as already stale. The relay therefore cannot close + * until the control loop has actually called in once — PRD 5.6's bounded + * startup state, enforced rather than assumed. + */ + s_last_request_us = 0; + + ESP_RETURN_ON_ERROR(drive_pin(false), TAG, "initial de-assert"); + + /* Priority above the UI: this task decides whether the house catches an + * hour of unwanted heat, the UI decides whether a label repaints. */ + if (xTaskCreate(safety_task, "relay_safety", 3072, NULL, 6, NULL) != pdPASS) { + ESP_LOGE(TAG, "safety task create failed"); + return ESP_ERR_NO_MEM; + } + + s_initialised = true; + ESP_LOGI(TAG, "armed — max on %us, min off %us, min cycle period %us, " + "startup lockout %us", + RELAY_MAX_ON_TIME_S, RELAY_MIN_OFF_TIME_S, + RELAY_MIN_CYCLE_PERIOD_S, RELAY_STARTUP_LOCKOUT_S); + return ESP_OK; +} + +void relay_request_heat(bool want_heat) +{ + if (!s_lock) { + return; + } + + xSemaphoreTake(s_lock, portMAX_DELAY); + s_requested = want_heat; + s_last_request_us = now_us(); + + /* Recovering from a watchdog trip: the control loop is talking again. */ + if (s_fault == RELAY_FAULT_WATCHDOG) { + s_fault = RELAY_FAULT_NONE; + } + xSemaphoreGive(s_lock); +} + +bool relay_is_on(void) +{ + return s_heat_on; +} + +void relay_status(relay_status_t *out) +{ + if (!out) { + return; + } + if (!s_lock) { + *out = (relay_status_t){0}; + return; + } + + xSemaphoreTake(s_lock, portMAX_DELAY); + out->heat_on = s_heat_on; + out->heat_requested = s_requested; + out->fault = s_fault; + out->on_seconds = s_heat_on ? secs_since(s_on_since_us) : 0; + out->off_seconds = s_heat_on ? 0 : secs_since(s_off_since_us); + out->hold_off_seconds = s_heat_on ? 0 : hold_off_remaining_locked(); + out->cycles_last_hour = cycles_last_hour_locked(); + xSemaphoreGive(s_lock); +} + +const char *relay_fault_str(relay_fault_t fault) +{ + switch (fault) { + case RELAY_FAULT_NONE: return "none"; + case RELAY_FAULT_WATCHDOG: return "control loop stopped"; + case RELAY_FAULT_MAX_ON_TIME: return "max on-time exceeded"; + case RELAY_FAULT_IO: return "expander I/O error"; + default: return "unknown"; + } +} diff --git a/main/hvac/relay.h b/main/hvac/relay.h new file mode 100644 index 0000000..6cb5bc2 --- /dev/null +++ b/main/hvac/relay.h @@ -0,0 +1,135 @@ +/* + * relay — the heat call, and every safety interlock in PRD section 5. + * + * The interlocks live here, not in the control loop and certainly not in UI + * code. Callers express *intent* by calling relay_request_heat(); this layer + * decides whether the contact actually closes. Nothing above it can override + * a limit, because nothing above it is given the means to. + * + * Hardware: PCF8574 P1 -> opto-isolated relay module IN, active low. + * Relay COM to R (24VAC), NO to W. Normally open, so no power means no heat. + * + * What this layer guarantees + * -------------------------- + * 1. Fail-open. Normally-open relay; loss of power opens the contact. + * 2. Watchdog-gated. The contact stays closed only while a control task keeps + * calling relay_request_heat(). Stop calling and it drops within + * RELAY_WATCHDOG_TIMEOUT_S. A hung control task cannot hold a heat call. + * 3. Maximum on-time. Absolute cap per call. + * 4. Minimum off-time, and a minimum period between call *starts* — see the + * note on cycle rate below. + * 5. Bounded startup. Off at boot, and held off for a lockout period. + * + * What it does not guarantee, stated plainly + * ------------------------------------------ + * A PCF8574 does not reset when the ESP32 does. If the firmware panics while + * the contact is closed, the expander holds that latch until firmware comes + * back and clears it. app_main() de-asserts P1 as its first real action, which + * bounds the window to roughly a second — not to zero. Closing that gap + * properly needs external hardware (a retriggerable monostable in series with + * the relay coil), which this board does not have. + * + * Risk calibration, per PRD 5.1: the furnace's high-limit and rollout switches + * are independent of the thermostat and stay in circuit. The realistic failure + * mode of a stuck call is an overheated house and a large gas bill, with the + * furnace cycling on its own limit — not a fire. + */ +#pragma once + +#include +#include + +#include "esp_err.h" + +/* -------------------------------------------------------------- limits -- */ + +/* + * How long the contact may stay closed without a fresh request. The control + * loop evaluates every CONTROL_INTERVAL_S (10s), so this is three missed + * evaluations — long enough not to trip on a scheduling hiccup, short enough + * that a wedged control task is caught quickly. + */ +#define RELAY_WATCHDOG_TIMEOUT_S 30 + +/* Absolute cap on a single heat call. PRD 5.3 suggests 60 minutes. */ +#define RELAY_MAX_ON_TIME_S 3600 + +/* Anti short-cycle: minimum gap between a call ending and the next starting. */ +#define RELAY_MIN_OFF_TIME_S 300 + +/* + * Minimum period between successive call *starts*. + * + * The existing thermostat is set to Gas/Oil Warm Air, 5 CPH — five cycles per + * hour, i.e. one cycle per 12 minutes. A minimum off-time alone does not + * enforce that: a 2-minute call followed by the 5-minute minimum gap would + * give about 8 CPH. Rate-limiting the starts is what actually holds the + * furnace to its configured cycle rate. + */ +#define RELAY_MIN_CYCLE_PERIOD_S 720 + +/* + * Startup lockout. Off at boot regardless of what the control loop wants. + * + * Deliberately shorter than RELAY_MIN_OFF_TIME_S. A full 5-minute lockout on + * every boot is a real comfort cost for a normal restart, while the case worth + * protecting against — a brownout that resets the board mid-call — is covered + * by a minute. The furnace's own control board also has anti-short-cycle + * logic behind this. + */ +#define RELAY_STARTUP_LOCKOUT_S 60 + +/* -------------------------------------------------------------- status -- */ + +typedef enum { + RELAY_FAULT_NONE = 0, + RELAY_FAULT_WATCHDOG, /* control task stopped refreshing */ + RELAY_FAULT_MAX_ON_TIME, /* hit the per-call cap */ + RELAY_FAULT_IO, /* expander write failed */ +} relay_fault_t; + +typedef struct { + bool heat_on; /* contact actually closed */ + bool heat_requested; /* what the control loop last asked for */ + relay_fault_t fault; + uint32_t on_seconds; /* duration of the current call, 0 if off */ + uint32_t off_seconds; /* since the last call ended */ + uint32_t hold_off_seconds; /* until a new call is permitted, 0 if now */ + uint8_t cycles_last_hour; /* call starts in the trailing hour */ +} relay_status_t; + +/* + * Set up the expander pin and start the safety task. + * + * Assumes app_main() has already called relay_emergency_off(). Requires + * pcf8574_init(). + */ +esp_err_t relay_init(void); + +/* + * De-assert the contact, synchronously, with no dependency on the safety task + * or on relay_init() having run. + * + * app_main() calls this first thing so a panic-reboot mid-call clears the + * expander latch as early as possible. + */ +esp_err_t relay_emergency_off(void); + +/* + * Express intent, and refresh the watchdog. + * + * Must be called at least every RELAY_WATCHDOG_TIMEOUT_S while heat is wanted. + * Calling with false is how the control loop says "off" — it also refreshes + * the watchdog, so an idle-but-healthy control loop is distinguishable from a + * dead one. + * + * The return value is intent accepted, not contact closed. Ask relay_status() + * what actually happened. + */ +void relay_request_heat(bool want_heat); + +bool relay_is_on(void); + +void relay_status(relay_status_t *out); + +const char *relay_fault_str(relay_fault_t fault); diff --git a/main/idf_component.yml b/main/idf_component.yml new file mode 100644 index 0000000..6179a68 --- /dev/null +++ b/main/idf_component.yml @@ -0,0 +1,9 @@ +dependencies: + idf: + version: ">=5.3.0" + lvgl/lvgl: + version: "~9.2.0" + # IDF 5.3 has no built-in 3-wire SPI panel IO. The ST7701S needs one for its + # ~30-command init sequence before the RGB bus produces anything. + espressif/esp_lcd_panel_io_additions: + version: "^1.0.1" diff --git a/main/main.c b/main/main.c new file mode 100644 index 0000000..0945a98 --- /dev/null +++ b/main/main.c @@ -0,0 +1,156 @@ +/* + * Rotary Thermostat — CrowPanel 2.1" HMI Rotary Display. + * + * Boot ordering here is not arbitrary. Reading top to bottom: + * + * 1. Relay off, first, before anything can fail. A PCF8574 does not reset + * when the ESP32 does, so if we got here from a panic while the contact + * was closed, the furnace is still running right now. Nothing else in + * this function matters as much as the next few milliseconds. + * 2. WiFi before the display (PRD 8.2). The radio's PSRAM allocations and + * the RGB panel's bandwidth demands do not mix well the other way round. + * 3. Display, then LVGL, then UI. + * 4. Control loop last, once everything it reports on exists. + */ + +#include "control.h" +#include "cst826.h" +#include "display.h" +#include "encoder.h" +#include "esp_log.h" +#include "esp_system.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "i2c_bus.h" +#include "lvgl_port.h" +#include "pcf8574.h" +#include "relay.h" +#include "settings.h" +#include "ui_manager.h" +#include "wifi_manager.h" +#include "zigbee_client.h" + +static const char *TAG = "main"; + +/* + * Set to 1 to draw colour bars and stop before LVGL. + * + * This is the PRD phase-2 check. It proves the ST7701S init sequence, the RGB + * bus and the timings on their own, with no LVGL in the picture to blame. + */ +#define BRINGUP_TEST_PATTERN 0 + +void app_main(void) +{ + ESP_LOGI(TAG, "Rotary Thermostat starting"); + + /* ---- 1. Make the furnace safe ------------------------------------- */ + + ESP_ERROR_CHECK(i2c_bus_init()); + + /* + * The scan is the cheap sanity check from PRD 8.1: finding the PCF8574 at + * 0x21 and the CST826 at 0x15 proves the board is alive and the I2C pins + * are right, independently of the display. + */ + i2c_bus_scan(); + + if (pcf8574_init() != ESP_OK) { + /* + * Without the expander there is no way to guarantee the relay is open, + * and no way to open it if it is closed. Continuing would mean running + * a heating controller that cannot turn the heat off. Reboot and try + * again rather than pretend. + */ + ESP_LOGE(TAG, "expander init failed — cannot control the relay, " + "restarting in 5s"); + vTaskDelay(pdMS_TO_TICKS(5000)); + esp_restart(); + } + + /* pcf8574_init() already pushed the all-high shadow, which de-asserts the + * relay. This is belt and braces, and it is cheap. */ + relay_emergency_off(); + ESP_LOGI(TAG, "relay de-asserted"); + + /* ---- 2. Settings and network -------------------------------------- */ + + settings_init(); + + /* Non-blocking: returns as soon as the driver is started. */ + wifi_manager_init(); + + /* + * Give the association a few seconds before bringing the panel up. Not + * required for correctness — everything downstream tolerates no network — + * but PRD 8.2 wants WiFi up first, and it means the first frame drawn + * usually already has real readings on it. + */ + if (wifi_manager_wait_connected(6000)) { + ESP_LOGI(TAG, "network up before display init"); + } else { + ESP_LOGW(TAG, "no network yet — continuing, it will connect in the " + "background"); + } + + zigbee_client_init(); + + /* ---- 3. Display ---------------------------------------------------- */ + + if (display_init() != ESP_OK) { + /* + * A blind thermostat is still a thermostat. It has a setpoint from + * NVS, sensors over MQTT and a relay; losing the screen should not + * lose the heat. + */ + ESP_LOGE(TAG, "display init failed — continuing headless"); + } else { +#if BRINGUP_TEST_PATTERN + display_test_pattern(); + ESP_LOGW(TAG, "BRINGUP_TEST_PATTERN is set — stopping here"); + while (1) { + vTaskDelay(pdMS_TO_TICKS(1000)); + } +#endif + cst826_init(); + + if (lvgl_port_init() == ESP_OK) { + ui_manager_init(); + } else { + ESP_LOGE(TAG, "LVGL port failed — continuing headless"); + } + } + + if (encoder_init() != ESP_OK) { + ESP_LOGE(TAG, "encoder init failed — setpoint fixed at stored value"); + } + + /* ---- 4. Arm the heat ----------------------------------------------- */ + + ESP_ERROR_CHECK(relay_init()); + ESP_ERROR_CHECK(control_init()); + + ESP_LOGI(TAG, "running"); + + /* + * Everything from here is task-driven. This loop only reports, at a + * cadence slow enough to be readable in a terminal left open on the bench. + */ + while (1) { + vTaskDelay(pdMS_TO_TICKS(60000)); + + control_state_t st; + relay_status_t rs; + control_get_state(&st); + relay_status(&rs); + + ESP_LOGI(TAG, "temp %.1fC set %.1fC | %s | heat %s | %s | wifi %s mqtt %s", + st.governing_temp_c, st.effective_setpoint_c, + (st.governing_room >= 0) + ? zigbee_client_room_name(st.governing_room) : "none", + rs.heat_on ? "ON" : "off", + control_source_str(st.source), + wifi_manager_is_connected() ? "up" : "down", + zigbee_client_is_connected() ? "up" : "down"); + } +} diff --git a/main/net/wifi_manager.c b/main/net/wifi_manager.c new file mode 100644 index 0000000..8d93fda --- /dev/null +++ b/main/net/wifi_manager.c @@ -0,0 +1,139 @@ +#include "wifi_manager.h" + +#include +#include +#include + +#include "app_config.h" +#include "esp_event.h" +#include "esp_log.h" +#include "esp_netif.h" +#include "esp_netif_sntp.h" +#include "esp_wifi.h" +#include "freertos/FreeRTOS.h" +#include "freertos/event_groups.h" + +static const char *TAG = "wifi"; + +#define CONNECTED_BIT BIT0 + +#define RETRY_MIN_MS 1000 +#define RETRY_MAX_MS 30000 + +static EventGroupHandle_t s_events; +static volatile bool s_connected; +static int s_retry_ms = RETRY_MIN_MS; +static bool s_sntp_started; + +static void schedule_reconnect(void) +{ + ESP_LOGW(TAG, "reconnecting in %d ms", s_retry_ms); + vTaskDelay(pdMS_TO_TICKS(s_retry_ms)); + + s_retry_ms *= 2; + if (s_retry_ms > RETRY_MAX_MS) { + s_retry_ms = RETRY_MAX_MS; + } + + esp_wifi_connect(); +} + +static void event_handler(void *arg, esp_event_base_t base, + int32_t id, void *data) +{ + (void)arg; + + if (base == WIFI_EVENT && id == WIFI_EVENT_STA_START) { + esp_wifi_connect(); + + } else if (base == WIFI_EVENT && id == WIFI_EVENT_STA_DISCONNECTED) { + s_connected = false; + xEventGroupClearBits(s_events, CONNECTED_BIT); + /* + * Runs on the event task, so the delay in schedule_reconnect() blocks + * only WiFi event processing — never the control loop, which lives on + * its own task and does not consult connection state anyway. + */ + schedule_reconnect(); + + } else if (base == IP_EVENT && id == IP_EVENT_STA_GOT_IP) { + ip_event_got_ip_t *e = data; + ESP_LOGI(TAG, "connected, ip " IPSTR, IP2STR(&e->ip_info.ip)); + + s_connected = true; + s_retry_ms = RETRY_MIN_MS; + xEventGroupSetBits(s_events, CONNECTED_BIT); + + /* Start SNTP once we first have an address. Schedule mode needs a + * real clock, and control.c refuses to act on one that looks like + * 1970. */ + if (!s_sntp_started) { + esp_sntp_config_t cfg = + ESP_NETIF_SNTP_DEFAULT_CONFIG(APP_SNTP_SERVER); + if (esp_netif_sntp_init(&cfg) == ESP_OK) { + s_sntp_started = true; + ESP_LOGI(TAG, "sntp started (%s)", APP_SNTP_SERVER); + } + } + } +} + +esp_err_t wifi_manager_init(void) +{ + if (s_events) { + return ESP_OK; + } + + s_events = xEventGroupCreate(); + if (!s_events) { + return ESP_ERR_NO_MEM; + } + + setenv("TZ", APP_TIMEZONE, 1); + tzset(); + + ESP_ERROR_CHECK(esp_netif_init()); + ESP_ERROR_CHECK(esp_event_loop_create_default()); + esp_netif_create_default_wifi_sta(); + + wifi_init_config_t init_cfg = WIFI_INIT_CONFIG_DEFAULT(); + ESP_ERROR_CHECK(esp_wifi_init(&init_cfg)); + + ESP_ERROR_CHECK(esp_event_handler_instance_register( + WIFI_EVENT, ESP_EVENT_ANY_ID, event_handler, NULL, NULL)); + ESP_ERROR_CHECK(esp_event_handler_instance_register( + IP_EVENT, IP_EVENT_STA_GOT_IP, event_handler, NULL, NULL)); + + wifi_config_t wifi_cfg = {0}; + strlcpy((char *)wifi_cfg.sta.ssid, APP_WIFI_SSID, sizeof(wifi_cfg.sta.ssid)); + strlcpy((char *)wifi_cfg.sta.password, APP_WIFI_PASS, + sizeof(wifi_cfg.sta.password)); + wifi_cfg.sta.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_cfg)); + + /* CONFIG_ESP_WIFI_NVS_ENABLED=n, so nothing here touches flash. */ + ESP_ERROR_CHECK(esp_wifi_set_storage(WIFI_STORAGE_RAM)); + ESP_ERROR_CHECK(esp_wifi_start()); + + ESP_LOGI(TAG, "started, joining \"%s\"", APP_WIFI_SSID); + return ESP_OK; +} + +bool wifi_manager_is_connected(void) +{ + return s_connected; +} + +bool wifi_manager_wait_connected(int timeout_ms) +{ + if (!s_events) { + return false; + } + + EventBits_t bits = xEventGroupWaitBits(s_events, CONNECTED_BIT, + pdFALSE, pdFALSE, + pdMS_TO_TICKS(timeout_ms)); + return (bits & CONNECTED_BIT) != 0; +} diff --git a/main/net/wifi_manager.h b/main/net/wifi_manager.h new file mode 100644 index 0000000..a4cc48e --- /dev/null +++ b/main/net/wifi_manager.h @@ -0,0 +1,27 @@ +/* + * wifi_manager — STA bring-up with automatic reconnect. + * + * Never blocks the control loop. wifi_manager_init() returns as soon as the + * driver is started; association happens in the background and reconnection + * is driven entirely by events. A house with no WiFi still heats — it just + * runs on the fallback ladder in control.c. + * + * Retries back off from 1s to 30s so an outage does not hammer the radio, and + * they never stop: the thermostat must recover unattended. + */ +#pragma once + +#include + +#include "esp_err.h" + +esp_err_t wifi_manager_init(void); + +bool wifi_manager_is_connected(void); + +/* + * Block until associated or until timeout. Optional — nothing in the control + * path calls this. main() uses it only to give the MQTT client a head start + * before the display comes up. + */ +bool wifi_manager_wait_connected(int timeout_ms); diff --git a/main/net/zigbee_client.c b/main/net/zigbee_client.c new file mode 100644 index 0000000..8c418f7 --- /dev/null +++ b/main/net/zigbee_client.c @@ -0,0 +1,356 @@ +#include "zigbee_client.h" + +#include +#include +#include + +#include "cJSON.h" +#include "esp_log.h" +#include "esp_timer.h" +#include "freertos/FreeRTOS.h" +#include "freertos/semphr.h" +#include "mqtt_client.h" + +static const char *TAG = "zigbee"; + +typedef struct { + const char *name; + const char *friendly; +} room_def_t; + +#define ROOM_DEF_ENTRY(display, friendly_name) { display, friendly_name }, +static const room_def_t s_rooms[] = { + APP_ROOM_TABLE(ROOM_DEF_ENTRY) +}; + +static room_reading_t s_readings[ROOM_COUNT]; +static SemaphoreHandle_t s_lock; +static esp_mqtt_client_handle_t s_client; +static volatile bool s_connected; + +/* ------------------------------------------------------------ parsing --- */ + +/* + * Match an incoming topic to a room. + * + * z2m publishes to /, and also to sub-topics such as + * //availability. Only the exact device topic carries + * the state payload, so this compares the whole string rather than a prefix. + */ +static int room_for_topic(const char *topic, int topic_len) +{ + char expected[160]; + + for (int i = 0; i < ROOM_COUNT; i++) { + int n = snprintf(expected, sizeof(expected), "%s/%s", + APP_MQTT_BASE_TOPIC, s_rooms[i].friendly); + if (n == topic_len && strncmp(topic, expected, (size_t)topic_len) == 0) { + return i; + } + } + return -1; +} + +/* + * struct tm -> epoch seconds, interpreting the fields as UTC. + * + * timegm() is not in ESP-IDF's newlib, and mktime() is not a substitute: it + * would apply APP_TIMEZONE to a timestamp that z2m already emitted in UTC, + * shifting every reading's apparent age by the local offset. On a US Central + * install that is six hours, which would make live sensors look stale and trip + * the fallback ladder permanently. + * + * Days-from-civil (Howard Hinnant) avoids both problems and has no state. + */ +static int64_t tm_to_epoch_utc(const struct tm *t) +{ + int y = t->tm_year + 1900; + unsigned m = (unsigned)t->tm_mon + 1; + unsigned d = (unsigned)t->tm_mday; + + y -= (m <= 2); + const int era = (y >= 0 ? y : y - 399) / 400; + const unsigned yoe = (unsigned)(y - era * 400); + const unsigned doy = (153u * (m + (m > 2 ? -3u : 9u)) + 2u) / 5u + d - 1u; + const unsigned doe = yoe * 365u + yoe / 4u - yoe / 100u + doy; + const int64_t days = (int64_t)era * 146097 + (int64_t)doe - 719468; + + return days * 86400 + t->tm_hour * 3600 + t->tm_min * 60 + t->tm_sec; +} + +/* + * z2m reports last_seen either as an ISO-8601 string or as epoch milliseconds, + * depending on the `last_seen` advanced setting. Handle both, and treat + * anything unparseable as absent rather than as zero age. + */ +static bool parse_sensor_age(const cJSON *root, uint32_t *age_s) +{ + const cJSON *ls = cJSON_GetObjectItemCaseSensitive(root, "last_seen"); + if (!ls) { + return false; + } + + time_t now = time(NULL); + /* Before SNTP has synced, wall-clock time is meaningless and any age we + * computed would be nonsense. Report absent. */ + if (now < 1600000000) { + return false; + } + + time_t seen = 0; + + if (cJSON_IsNumber(ls)) { + seen = (time_t)(ls->valuedouble / 1000.0); + } else if (cJSON_IsString(ls) && ls->valuestring) { + struct tm tm = {0}; + if (!strptime(ls->valuestring, "%Y-%m-%dT%H:%M:%S", &tm)) { + return false; + } + seen = (time_t)tm_to_epoch_utc(&tm); + } else { + return false; + } + + if (seen <= 0 || seen > now) { + *age_s = 0; + return true; + } + + *age_s = (uint32_t)(now - seen); + return true; +} + +static void handle_payload(int room, const char *data, int len) +{ + cJSON *root = cJSON_ParseWithLength(data, (size_t)len); + if (!root) { + ESP_LOGW(TAG, "%s: unparseable payload", s_rooms[room].name); + return; + } + + const cJSON *temp = cJSON_GetObjectItemCaseSensitive(root, "temperature"); + if (!cJSON_IsNumber(temp)) { + /* + * Not an error. z2m publishes on any attribute change, so a humidity + * or link-quality update legitimately arrives with no temperature. + * Leaving the previous reading untouched is the point — a partial + * message must not age or clear good data. + */ + cJSON_Delete(root); + return; + } + + room_reading_t r = { + .valid = true, + .temperature_c = (float)temp->valuedouble, + .received_us = esp_timer_get_time(), + }; + + const cJSON *batt = cJSON_GetObjectItemCaseSensitive(root, "battery"); + if (cJSON_IsNumber(batt)) { + r.has_battery = true; + r.battery_percent = (int)batt->valuedouble; + } + + uint32_t age = 0; + if (parse_sensor_age(root, &age)) { + r.has_sensor_age = true; + r.sensor_age_at_receipt_s = age; + } + + xSemaphoreTake(s_lock, portMAX_DELAY); + /* Carry battery forward if this message did not include it. */ + if (!r.has_battery && s_readings[room].has_battery) { + r.has_battery = true; + r.battery_percent = s_readings[room].battery_percent; + } + s_readings[room] = r; + xSemaphoreGive(s_lock); + + if (r.has_battery) { + ESP_LOGI(TAG, "%s: %.1fC, battery %d%%", + s_rooms[room].name, r.temperature_c, r.battery_percent); + } else { + ESP_LOGI(TAG, "%s: %.1fC", s_rooms[room].name, r.temperature_c); + } + + cJSON_Delete(root); +} + +/* -------------------------------------------------------------- events -- */ + +static void mqtt_event_handler(void *args, esp_event_base_t base, + int32_t event_id, void *event_data) +{ + (void)args; + (void)base; + esp_mqtt_event_handle_t event = event_data; + + switch ((esp_mqtt_event_id_t)event_id) { + case MQTT_EVENT_CONNECTED: { + s_connected = true; + ESP_LOGI(TAG, "broker connected"); + + char topic[160]; + for (int i = 0; i < ROOM_COUNT; i++) { + snprintf(topic, sizeof(topic), "%s/%s", + APP_MQTT_BASE_TOPIC, s_rooms[i].friendly); + /* QoS 0 is right here: readings are periodic and a missed one + * simply ages, which the control loop already handles. */ + esp_mqtt_client_subscribe(s_client, topic, 0); + ESP_LOGI(TAG, "subscribed %s -> %s", topic, s_rooms[i].name); + } + break; + } + + case MQTT_EVENT_DISCONNECTED: + s_connected = false; + /* Readings are deliberately left alone. They age; they are not + * cleared. PRD 4.1. */ + ESP_LOGW(TAG, "broker disconnected — readings will age"); + break; + + case MQTT_EVENT_DATA: { + int room = room_for_topic(event->topic, event->topic_len); + if (room < 0) { + break; + } + /* + * Long payloads arrive in fragments. z2m sensor JSON is small enough + * that this should not happen, but a silently truncated parse would be + * a nasty way to find out otherwise. + */ + if (event->data_len != event->total_data_len) { + ESP_LOGW(TAG, "%s: fragmented payload (%d of %d), ignoring", + s_rooms[room].name, event->data_len, event->total_data_len); + break; + } + handle_payload(room, event->data, event->data_len); + break; + } + + case MQTT_EVENT_ERROR: + ESP_LOGW(TAG, "mqtt error"); + break; + + default: + break; + } +} + +/* ----------------------------------------------------------------- api -- */ + +esp_err_t zigbee_client_init(void) +{ + if (s_client) { + return ESP_OK; + } + + s_lock = xSemaphoreCreateMutex(); + if (!s_lock) { + return ESP_ERR_NO_MEM; + } + + esp_mqtt_client_config_t cfg = { + .broker.address.uri = APP_MQTT_URI, + .credentials = { + .username = (sizeof(APP_MQTT_USER) > 1) ? APP_MQTT_USER : NULL, + .authentication.password = + (sizeof(APP_MQTT_PASS) > 1) ? APP_MQTT_PASS : NULL, + }, + .session.keepalive = 60, + .network.reconnect_timeout_ms = 10000, + .network.disable_auto_reconnect = false, + }; + + s_client = esp_mqtt_client_init(&cfg); + if (!s_client) { + ESP_LOGE(TAG, "client init failed"); + return ESP_FAIL; + } + + ESP_ERROR_CHECK(esp_mqtt_client_register_event( + s_client, ESP_EVENT_ANY_ID, mqtt_event_handler, NULL)); + + /* + * Start unconditionally, even with no WiFi yet. The client retries on its + * own and must never block the control loop — PRD section 6. + */ + esp_err_t err = esp_mqtt_client_start(s_client); + if (err != ESP_OK) { + ESP_LOGE(TAG, "client start failed: %s", esp_err_to_name(err)); + return err; + } + + ESP_LOGI(TAG, "started, broker %s, %d room(s)", APP_MQTT_URI, ROOM_COUNT); + return ESP_OK; +} + +int zigbee_client_room_count(void) +{ + return ROOM_COUNT; +} + +const char *zigbee_client_room_name(int idx) +{ + if (idx < 0 || idx >= ROOM_COUNT) { + return "?"; + } + return s_rooms[idx].name; +} + +bool zigbee_client_get_reading(int idx, room_reading_t *out) +{ + if (idx < 0 || idx >= ROOM_COUNT || !out) { + return false; + } + /* The UI and control loop both call this. If init failed we report "no + * reading" and let the fallback ladder handle it, rather than taking a + * NULL semaphore and crashing a device that controls heat. */ + if (!s_lock) { + *out = (room_reading_t){0}; + return false; + } + + xSemaphoreTake(s_lock, portMAX_DELAY); + *out = s_readings[idx]; + xSemaphoreGive(s_lock); + return true; +} + +uint32_t zigbee_client_reading_age_s(int idx) +{ + room_reading_t r; + if (!zigbee_client_get_reading(idx, &r) || !r.valid) { + return UINT32_MAX; + } + + uint32_t local = (uint32_t)((esp_timer_get_time() - r.received_us) / 1000000); + return r.has_sensor_age ? local + r.sensor_age_at_receipt_s : local; +} + +bool zigbee_client_is_connected(void) +{ + return s_connected; +} + +int zigbee_client_lowest_battery(void) +{ + int lowest = -1; + + if (!s_lock) { + return -1; + } + + xSemaphoreTake(s_lock, portMAX_DELAY); + for (int i = 0; i < ROOM_COUNT; i++) { + if (s_readings[i].valid && s_readings[i].has_battery) { + if (lowest < 0 || s_readings[i].battery_percent < lowest) { + lowest = s_readings[i].battery_percent; + } + } + } + xSemaphoreGive(s_lock); + + return lowest; +} diff --git a/main/net/zigbee_client.h b/main/net/zigbee_client.h new file mode 100644 index 0000000..4ef953e --- /dev/null +++ b/main/net/zigbee_client.h @@ -0,0 +1,79 @@ +/* + * zigbee_client — room temperatures from the Zigbee sensors. + * + * Named for its role rather than its transport. The PRD assumed HTTP polling + * of the Zigbee hub, but zigbee2mqtt has no REST endpoint that returns live + * sensor state — its frontend streams state over a WebSocket, and /api/devices + * returns definitions, not readings. So this subscribes to the MQTT broker + * that z2m publishes to instead. + * + * That turns out to be the better shape for this application anyway: + * + * - z2m publishes sensor topics *retained*, so every room's last known + * reading arrives within milliseconds of connecting, including after a + * reboot. An HTTP poller would have had the same property; a WebSocket + * client would not, and could have waited an hour for a battery sensor to + * report in. + * - It bypasses z2m's frontend entirely. Readings keep flowing if the + * frontend is down, and there is no HTTP shim to become a new single point + * of failure. + * - Updates are pushed, so readings are as fresh as the sensors allow rather + * than as fresh as a poll interval. + * + * A failed connection never clears previously good readings — it only lets + * them age, per PRD 4.1. Staleness is the control loop's business, not this + * module's. + */ +#pragma once + +#include +#include + +#include "app_config.h" +#include "esp_err.h" + +/* Room count, derived from APP_ROOM_TABLE in app_config.h. */ +#define ZB_ROOM_COUNT_HELPER(name, topic) +1 +#define ROOM_COUNT (0 APP_ROOM_TABLE(ZB_ROOM_COUNT_HELPER)) + +typedef struct { + bool valid; /* a reading has been received since boot */ + float temperature_c; + int64_t received_us; /* local receipt time, esp_timer clock */ + + bool has_battery; + int battery_percent; + + /* + * z2m's own last_seen, when the sensor reports it, as seconds before + * receipt. Sensor-side age is more honest than our receipt time for a + * retained message replayed on connect: a retained reading can be hours + * old and still arrive "just now". + */ + bool has_sensor_age; + uint32_t sensor_age_at_receipt_s; +} room_reading_t; + +esp_err_t zigbee_client_init(void); + +int zigbee_client_room_count(void); +const char *zigbee_client_room_name(int idx); + +/* Copy out a room's latest reading. False if idx is out of range. */ +bool zigbee_client_get_reading(int idx, room_reading_t *out); + +/* + * Age of a room's reading in seconds, UINT32_MAX if never received. + * + * Includes the sensor-side age at receipt when z2m provided last_seen, so a + * retained message replayed on connect is correctly reported as old rather + * than as brand new. + */ +uint32_t zigbee_client_reading_age_s(int idx); + +/* True while the MQTT session is up. Purely informational — the control loop + * reasons about reading age, not about connection state. */ +bool zigbee_client_is_connected(void); + +/* Lowest battery percentage across rooms that report one, or -1. */ +int zigbee_client_lowest_battery(void); diff --git a/main/storage/settings.c b/main/storage/settings.c new file mode 100644 index 0000000..2518333 --- /dev/null +++ b/main/storage/settings.c @@ -0,0 +1,300 @@ +#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; +} + diff --git a/main/storage/settings.h b/main/storage/settings.h new file mode 100644 index 0000000..e037aca --- /dev/null +++ b/main/storage/settings.h @@ -0,0 +1,58 @@ +/* + * settings — NVS persistence for everything that must survive a reboot + * (PRD 4.5): setpoint, mode, selected room, schedule. + * + * Writes are debounced. Turning the knob generates a setpoint change every + * detent, and committing each one would put thousands of writes a year onto a + * flash sector for no benefit. settings_mark_dirty() records intent and a + * background task commits once the value has been still for a few seconds. + * + * Runtime flash writes are safe here only because the RGB panel is configured + * with no bounce buffers (see drivers/display/display.c). If that ever + * changes, this module becomes a source of "Cache disabled but cached memory + * region accessed" panics and the panel must be paused around the commit. + */ +#pragma once + +#include +#include + +#include "control.h" +#include "esp_err.h" + +/* Schedule: time-of-day rules picking a governing room (PRD 4.2 mode 2). */ +#define SCHEDULE_MAX_RULES 6 + +typedef struct { + uint8_t start_hour; /* rule applies from this hour... */ + uint8_t start_minute; + uint8_t room_index; /* ...until the next rule's start */ + bool enabled; +} schedule_rule_t; + +typedef struct { + float setpoint_c; + uint8_t mode; /* room_mode_t */ + uint8_t selected_room; + schedule_rule_t schedule[SCHEDULE_MAX_RULES]; +} settings_t; + +/* Load from NVS, falling back to defaults for anything absent or invalid. */ +esp_err_t settings_init(void); + +/* Current settings. Never fails; returns defaults before settings_init(). */ +void settings_get(settings_t *out); + +void settings_set_setpoint(float celsius); +void settings_set_mode(uint8_t mode); +void settings_set_selected_room(uint8_t room); +void settings_set_schedule(const schedule_rule_t *rules, int count); + +/* + * Which room the schedule says should govern at the given local time, or -1 + * if no rule matches. + */ +int settings_schedule_room_for_time(int hour, int minute); + +/* Force an immediate commit, bypassing the debounce. */ +esp_err_t settings_commit_now(void); diff --git a/main/ui/lvgl_port.c b/main/ui/lvgl_port.c new file mode 100644 index 0000000..8c81db8 --- /dev/null +++ b/main/ui/lvgl_port.c @@ -0,0 +1,187 @@ +#include "lvgl_port.h" + +#include "cst826.h" +#include "display.h" +#include "esp_check.h" +#include "esp_log.h" +#include "esp_timer.h" +#include "freertos/FreeRTOS.h" +#include "freertos/semphr.h" +#include "freertos/task.h" + +static const char *TAG = "lvgl"; + +#define TICK_PERIOD_MS 2 +#define TASK_STACK 8192 +#define TASK_PRIO 3 +#define TASK_MAX_DELAY_MS 20 +#define TASK_MIN_DELAY_MS 2 + +static SemaphoreHandle_t s_lock; +static lv_display_t *s_disp; +static lv_indev_t *s_touch_indev; + +/* ---------------------------------------------------------------- tick -- */ + +static void tick_cb(void *arg) +{ + (void)arg; + lv_tick_inc(TICK_PERIOD_MS); +} + +/* --------------------------------------------------------------- flush -- */ + +static void flush_cb(lv_display_t *disp, const lv_area_t *area, uint8_t *px_map) +{ + (void)area; + + /* + * px_map is one of the panel's own framebuffers, so this does not copy — + * the RGB driver just switches which buffer is being scanned out and waits + * for the switch to take effect. + */ + esp_lcd_panel_handle_t panel = display_panel(); + esp_lcd_panel_draw_bitmap(panel, 0, 0, DISPLAY_H_RES, DISPLAY_V_RES, px_map); + + lv_display_flush_ready(disp); +} + +/* --------------------------------------------------------------- input -- */ + +static void touch_read_cb(lv_indev_t *indev, lv_indev_data_t *data) +{ + (void)indev; + + cst826_touch_t t; + cst826_read(&t); + + if (t.pressed) { + data->point.x = t.x; + data->point.y = t.y; + data->state = LV_INDEV_STATE_PRESSED; + } else { + data->state = LV_INDEV_STATE_RELEASED; + } +} + +/* ---------------------------------------------------------------- task -- */ + +static void lvgl_task(void *arg) +{ + (void)arg; + + ESP_LOGI(TAG, "service task running"); + + while (1) { + uint32_t next = TASK_MAX_DELAY_MS; + + if (lvgl_port_lock(-1)) { + next = lv_timer_handler(); + lvgl_port_unlock(); + } + + if (next > TASK_MAX_DELAY_MS) { + next = TASK_MAX_DELAY_MS; + } else if (next < TASK_MIN_DELAY_MS) { + next = TASK_MIN_DELAY_MS; + } + + vTaskDelay(pdMS_TO_TICKS(next)); + } +} + +/* ---------------------------------------------------------------- init -- */ + +esp_err_t lvgl_port_init(void) +{ + if (s_disp) { + return ESP_OK; + } + + void *fb0 = NULL, *fb1 = NULL; + esp_err_t err = display_get_framebuffers(&fb0, &fb1); + if (err != ESP_OK || !fb0 || !fb1) { + ESP_LOGE(TAG, "no framebuffers — display_init() must run first"); + return ESP_ERR_INVALID_STATE; + } + + s_lock = xSemaphoreCreateRecursiveMutex(); + if (!s_lock) { + return ESP_ERR_NO_MEM; + } + + /* + * PRD 8.2: lv_init() before creating any LVGL object. Calling + * lv_display_create() first faults with LoadProhibited inside the TLSF + * allocator, which is a confusing way to discover an ordering bug. + */ + if (!lv_is_initialized()) { + lv_init(); + } + + s_disp = lv_display_create(DISPLAY_H_RES, DISPLAY_V_RES); + if (!s_disp) { + return ESP_ERR_NO_MEM; + } + + lv_display_set_color_format(s_disp, LV_COLOR_FORMAT_RGB565); + lv_display_set_flush_cb(s_disp, flush_cb); + + size_t fb_bytes = (size_t)DISPLAY_H_RES * DISPLAY_V_RES * 2; + lv_display_set_buffers(s_disp, fb0, fb1, fb_bytes, + LV_DISPLAY_RENDER_MODE_FULL); + + /* Touch, if the controller answered. The thermostat is fully usable from + * the encoder alone, so a missing touch panel is not fatal. */ + if (cst826_is_ready()) { + s_touch_indev = lv_indev_create(); + lv_indev_set_type(s_touch_indev, LV_INDEV_TYPE_POINTER); + lv_indev_set_read_cb(s_touch_indev, touch_read_cb); + lv_indev_set_display(s_touch_indev, s_disp); + } else { + ESP_LOGW(TAG, "touch unavailable — encoder only"); + } + + const esp_timer_create_args_t tick_args = { + .callback = tick_cb, + .name = "lv_tick", + }; + esp_timer_handle_t tick_timer; + ESP_RETURN_ON_ERROR(esp_timer_create(&tick_args, &tick_timer), + TAG, "tick timer"); + ESP_RETURN_ON_ERROR( + esp_timer_start_periodic(tick_timer, TICK_PERIOD_MS * 1000), + TAG, "tick start"); + + if (xTaskCreate(lvgl_task, "lvgl", TASK_STACK, NULL, TASK_PRIO, NULL) + != pdPASS) { + ESP_LOGE(TAG, "task create failed"); + return ESP_ERR_NO_MEM; + } + + ESP_LOGI(TAG, "up: %dx%d, full-refresh double buffered", + DISPLAY_H_RES, DISPLAY_V_RES); + return ESP_OK; +} + +bool lvgl_port_lock(int timeout_ms) +{ + if (!s_lock) { + return false; + } + TickType_t wait = (timeout_ms < 0) ? portMAX_DELAY + : pdMS_TO_TICKS(timeout_ms); + return xSemaphoreTakeRecursive(s_lock, wait) == pdTRUE; +} + +void lvgl_port_unlock(void) +{ + if (s_lock) { + xSemaphoreGiveRecursive(s_lock); + } +} + +lv_display_t *lvgl_port_display(void) +{ + return s_disp; +} diff --git a/main/ui/lvgl_port.h b/main/ui/lvgl_port.h new file mode 100644 index 0000000..9eda25c --- /dev/null +++ b/main/ui/lvgl_port.h @@ -0,0 +1,33 @@ +/* + * lvgl_port — tick, service task, and locking. + * + * LVGL is not thread-safe. Everything that touches an lv_* object must hold + * the lock, including code called from timers and event handlers that runs on + * other tasks. The lock is recursive so a UI callback can re-enter without + * deadlocking itself. + * + * Rendering uses the panel's two framebuffers directly in LVGL's FULL refresh + * mode: LVGL draws a whole frame into the back buffer, flush hands it to + * esp_lcd_panel_draw_bitmap(), and the RGB driver swaps buffers rather than + * copying. Nothing ever writes into the buffer being scanned out, which is + * what PRD 8.2's warning about persistent framebuffer corruption is about. + */ +#pragma once + +#include + +#include "esp_err.h" +#include "lvgl.h" + +/* Requires display_init(). Creates the display, input devices and task. */ +esp_err_t lvgl_port_init(void); + +/* + * Take the LVGL lock. timeout_ms < 0 waits forever. + * Every lv_* call from outside a UI callback must be wrapped in this. + */ +bool lvgl_port_lock(int timeout_ms); +void lvgl_port_unlock(void); + +/* The display LVGL is driving. */ +lv_display_t *lvgl_port_display(void); diff --git a/main/ui/ui_manager.c b/main/ui/ui_manager.c new file mode 100644 index 0000000..52ef25e --- /dev/null +++ b/main/ui/ui_manager.c @@ -0,0 +1,406 @@ +#include "ui_manager.h" + +#include +#include +#include + +#include "app_config.h" +#include "control.h" +#include "encoder.h" +#include "esp_log.h" +#include "lvgl_port.h" +#include "relay.h" +#include "wifi_manager.h" +#include "zigbee_client.h" + +static const char *TAG = "ui"; + +#define REFRESH_PERIOD_MS 250 +#define FOCUS_TIMEOUT_MS 8000 + +#define DEG "\xC2\xB0" /* UTF-8 degree sign */ + +/* Colours. Amber for an active heat call — it reads as "warm" without the + * alarm connotation of red, which is reserved for faults. */ +#define COL_BG 0x101014 +#define COL_TEXT 0xF0F0F0 +#define COL_DIM 0x808088 +#define COL_HEAT 0xFF9500 +#define COL_FAULT 0xFF453A +#define COL_ARC_BG 0x2C2C30 +#define COL_ARC_FG 0x0A84FF + +typedef enum { + FOCUS_SETPOINT = 0, + FOCUS_ROOM, + FOCUS_MODE, + FOCUS_COUNT, +} focus_t; + +static lv_obj_t *s_arc; +static lv_obj_t *s_lbl_temp; +static lv_obj_t *s_lbl_setpoint; +static lv_obj_t *s_lbl_room; +static lv_obj_t *s_lbl_mode; +static lv_obj_t *s_lbl_status; +static lv_obj_t *s_lbl_heat; + +static focus_t s_focus; +static uint32_t s_focus_idle_ms; + +/* ------------------------------------------------------------- helpers -- */ + +static float c_to_display(float c) +{ +#if APP_UNITS_FAHRENHEIT + return c * 9.0f / 5.0f + 32.0f; +#else + return c; +#endif +} + +/* + * One detent. + * + * In Fahrenheit this is 5/9 C, which is exactly 1F. The default setpoint of + * 20.0C is exactly 68F, so stepping by 5/9 from there keeps every displayed + * value a whole number of degrees rather than drifting onto 67.9. + */ +static float detent_step_c(void) +{ +#if APP_UNITS_FAHRENHEIT + return 5.0f / 9.0f; +#else + return 0.5f; +#endif +} + +static const char *unit_suffix(void) +{ +#if APP_UNITS_FAHRENHEIT + return DEG "F"; +#else + return DEG "C"; +#endif +} + +/* + * PRD 8.2: lv_label_set_text() invalidates unconditionally, even when the text + * is identical, forcing a full repaint. At full-refresh that is an entire + * 480x480 frame redrawn to change nothing, four times a second. + */ +static void set_text_if_changed(lv_obj_t *label, const char *text) +{ + const char *current = lv_label_get_text(label); + if (!current || strcmp(current, text) != 0) { + lv_label_set_text(label, text); + } +} + +static void set_color_if_changed(lv_obj_t *obj, uint32_t hex) +{ + lv_color_t want = lv_color_hex(hex); + lv_color_t have = lv_obj_get_style_text_color(obj, LV_PART_MAIN); + if (lv_color_to_u32(want) != lv_color_to_u32(have)) { + lv_obj_set_style_text_color(obj, want, 0); + } +} + +/* --------------------------------------------------------------- build -- */ + +static lv_obj_t *make_label(lv_obj_t *parent, const lv_font_t *font, + uint32_t color, lv_align_t align, int x, int y) +{ + lv_obj_t *l = lv_label_create(parent); + lv_obj_set_style_text_font(l, font, 0); + lv_obj_set_style_text_color(l, lv_color_hex(color), 0); + lv_obj_set_style_text_align(l, LV_TEXT_ALIGN_CENTER, 0); + lv_obj_align(l, align, x, y); + lv_label_set_text(l, ""); + return l; +} + +static void build_screen(void) +{ + lv_obj_t *scr = lv_screen_active(); + lv_obj_set_style_bg_color(scr, lv_color_hex(COL_BG), 0); + lv_obj_set_style_bg_opa(scr, LV_OPA_COVER, 0); + lv_obj_remove_flag(scr, LV_OBJ_FLAG_SCROLLABLE); + + /* + * Setpoint arc, just inside the panel edge. Its gap sits at the bottom so + * the scale reads left-to-right over the top, and the bottom of the circle + * stays free for the status line. + */ + s_arc = lv_arc_create(scr); + lv_obj_set_size(s_arc, 440, 440); + lv_obj_center(s_arc); + lv_arc_set_rotation(s_arc, 135); + lv_arc_set_bg_angles(s_arc, 0, 270); + lv_arc_set_range(s_arc, 0, 100); + lv_obj_remove_style(s_arc, NULL, LV_PART_KNOB); + lv_obj_remove_flag(s_arc, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_style_arc_width(s_arc, 14, LV_PART_MAIN); + lv_obj_set_style_arc_width(s_arc, 14, LV_PART_INDICATOR); + lv_obj_set_style_arc_color(s_arc, lv_color_hex(COL_ARC_BG), LV_PART_MAIN); + lv_obj_set_style_arc_color(s_arc, lv_color_hex(COL_ARC_FG), + LV_PART_INDICATOR); + + /* Room and mode above centre. */ + s_lbl_room = make_label(scr, &lv_font_montserrat_28, COL_TEXT, + LV_ALIGN_CENTER, 0, -120); + s_lbl_mode = make_label(scr, &lv_font_montserrat_16, COL_DIM, + LV_ALIGN_CENTER, 0, -88); + + /* The number that matters. */ + s_lbl_temp = make_label(scr, &lv_font_montserrat_48, COL_TEXT, + LV_ALIGN_CENTER, 0, -20); + + s_lbl_setpoint = make_label(scr, &lv_font_montserrat_28, COL_DIM, + LV_ALIGN_CENTER, 0, 40); + + /* Heat-call indicator. */ + s_lbl_heat = make_label(scr, &lv_font_montserrat_20, COL_DIM, + LV_ALIGN_CENTER, 0, 88); + + /* + * Status and faults. Kept at y=+140 rather than nearer the rim: on a + * circular panel the horizontal room at the bottom shrinks fast, and a + * long fault string clipped by the bezel is worse than no fault string. + */ + s_lbl_status = make_label(scr, &lv_font_montserrat_16, COL_DIM, + LV_ALIGN_CENTER, 0, 140); + lv_obj_set_width(s_lbl_status, 300); + lv_label_set_long_mode(s_lbl_status, LV_LABEL_LONG_WRAP); + lv_obj_align(s_lbl_status, LV_ALIGN_CENTER, 0, 140); +} + +/* ------------------------------------------------------------- refresh -- */ + +static void refresh_temperatures(const control_state_t *st) +{ + char buf[32]; + + if (isnan(st->governing_temp_c)) { + set_text_if_changed(s_lbl_temp, "--" DEG); + } else { + snprintf(buf, sizeof(buf), "%.0f" DEG, c_to_display(st->governing_temp_c)); + set_text_if_changed(s_lbl_temp, buf); + } + + /* + * Show the effective setpoint, not the requested one. When the fallback + * ladder has clamped it, the screen must say what the thermostat is + * actually doing — the fault text below explains why. + */ + snprintf(buf, sizeof(buf), "Set %.0f%s", + c_to_display(st->effective_setpoint_c), unit_suffix()); + set_text_if_changed(s_lbl_setpoint, buf); + + int pct = (int)((st->effective_setpoint_c - CONTROL_SETPOINT_MIN_C) * 100.0f / + (CONTROL_SETPOINT_MAX_C - CONTROL_SETPOINT_MIN_C)); + if (pct < 0) { + pct = 0; + } else if (pct > 100) { + pct = 100; + } + if (lv_arc_get_value(s_arc) != pct) { + lv_arc_set_value(s_arc, pct); + } +} + +static void refresh_room_and_mode(const control_state_t *st) +{ + const char *room = (st->governing_room >= 0) + ? zigbee_client_room_name(st->governing_room) + : "No sensor"; + set_text_if_changed(s_lbl_room, room); + + char buf[48]; + switch (s_focus) { + case FOCUS_ROOM: + snprintf(buf, sizeof(buf), "> SELECT ROOM <"); + break; + case FOCUS_MODE: + snprintf(buf, sizeof(buf), "> MODE: %s <", control_mode_str(st->mode)); + break; + default: + snprintf(buf, sizeof(buf), "%s", control_mode_str(st->mode)); + break; + } + set_text_if_changed(s_lbl_mode, buf); + set_color_if_changed(s_lbl_mode, + (s_focus == FOCUS_SETPOINT) ? COL_DIM : COL_ARC_FG); +} + +static void refresh_heat(void) +{ + relay_status_t rs; + relay_status(&rs); + + char buf[48]; + + if (rs.heat_on) { + snprintf(buf, sizeof(buf), LV_SYMBOL_CHARGE " HEAT %lu:%02lu", + (unsigned long)(rs.on_seconds / 60), + (unsigned long)(rs.on_seconds % 60)); + set_color_if_changed(s_lbl_heat, COL_HEAT); + } else if (rs.heat_requested && rs.hold_off_seconds > 0) { + /* Wanted but rate-limited. Saying so beats looking broken. */ + snprintf(buf, sizeof(buf), "Waiting %lum", + (unsigned long)((rs.hold_off_seconds + 59) / 60)); + set_color_if_changed(s_lbl_heat, COL_DIM); + } else { + snprintf(buf, sizeof(buf), "Idle"); + set_color_if_changed(s_lbl_heat, COL_DIM); + } + + set_text_if_changed(s_lbl_heat, buf); +} + +static void refresh_status(const control_state_t *st) +{ + relay_status_t rs; + relay_status(&rs); + + char buf[96]; + uint32_t color = COL_DIM; + + /* + * Most severe first — one line, so it has to be the one that matters. + * + * The clock check has to come before the source check. A schedule with no + * clock yet falls through to the coldest fresh room, which sets the source + * to CONTROL_SRC_OTHER_ROOM; reporting that as "sensor stale" would send + * you looking at a sensor when the actual problem is that SNTP has not + * synced. + */ + if (rs.fault != RELAY_FAULT_NONE) { + snprintf(buf, sizeof(buf), "FAULT: %s", relay_fault_str(rs.fault)); + color = COL_FAULT; + + } else if (st->mode == ROOM_MODE_SCHEDULE && !st->schedule_clock_valid) { + snprintf(buf, sizeof(buf), "No clock yet - using coldest room"); + color = COL_FAULT; + + } else if (st->source != CONTROL_SRC_SELECTED) { + snprintf(buf, sizeof(buf), "%s", control_source_str(st->source)); + color = COL_FAULT; + + } else if (!wifi_manager_is_connected()) { + snprintf(buf, sizeof(buf), LV_SYMBOL_WARNING " No WiFi"); + color = COL_FAULT; + + } else if (!zigbee_client_is_connected()) { + snprintf(buf, sizeof(buf), LV_SYMBOL_WARNING " No broker"); + color = COL_FAULT; + + } else { + int batt = zigbee_client_lowest_battery(); + if (batt >= 0 && batt < 20) { + snprintf(buf, sizeof(buf), LV_SYMBOL_BATTERY_1 " Sensor battery %d%%", + batt); + color = COL_FAULT; + } else { + snprintf(buf, sizeof(buf), "%u cycles/hr", rs.cycles_last_hour); + } + } + + set_text_if_changed(s_lbl_status, buf); + set_color_if_changed(s_lbl_status, color); +} + +/* --------------------------------------------------------------- input -- */ + +/* + * PRD 8.2: LVGL does not bubble events to parents, so a handler on the screen + * object never sees taps landing on a child widget. The encoder is not an + * LVGL input device here at all — it is polled from this timer, which sidesteps + * the whole question. + */ +static void handle_encoder(void) +{ + int delta = encoder_take_delta(); + encoder_button_event_t btn = encoder_take_button_event(); + + if (delta != 0) { + s_focus_idle_ms = 0; + + switch (s_focus) { + case FOCUS_ROOM: { + int n = zigbee_client_room_count(); + int room = control_get_selected_room() + delta; + /* Wrap rather than clamp: with two rooms, clamping makes the knob + * feel dead half the time. */ + room = ((room % n) + n) % n; + control_select_room(room); + break; + } + + case FOCUS_MODE: { + int mode = (int)control_get_mode() + delta; + mode = ((mode % ROOM_MODE_COUNT) + ROOM_MODE_COUNT) % ROOM_MODE_COUNT; + control_set_mode((room_mode_t)mode); + break; + } + + case FOCUS_SETPOINT: + default: + control_adjust_setpoint(detent_step_c() * (float)delta); + break; + } + } + + if (btn == ENCODER_BTN_CLICK) { + s_focus = (focus_t)((s_focus + 1) % FOCUS_COUNT); + s_focus_idle_ms = 0; + } else if (btn == ENCODER_BTN_LONG_PRESS) { + s_focus = FOCUS_SETPOINT; + s_focus_idle_ms = 0; + } + + /* Fall back to the setpoint so the knob's default meaning is always the + * temperature, however the user left it. */ + if (s_focus != FOCUS_SETPOINT) { + s_focus_idle_ms += REFRESH_PERIOD_MS; + if (s_focus_idle_ms >= FOCUS_TIMEOUT_MS) { + s_focus = FOCUS_SETPOINT; + s_focus_idle_ms = 0; + } + } +} + +/* ---------------------------------------------------------------- tick -- */ + +static void refresh_timer_cb(lv_timer_t *timer) +{ + (void)timer; + + handle_encoder(); + + control_state_t st; + control_get_state(&st); + + refresh_temperatures(&st); + refresh_room_and_mode(&st); + refresh_heat(); + refresh_status(&st); +} + +/* ---------------------------------------------------------------- init -- */ + +esp_err_t ui_manager_init(void) +{ + if (!lvgl_port_lock(1000)) { + ESP_LOGE(TAG, "could not take LVGL lock"); + return ESP_ERR_TIMEOUT; + } + + build_screen(); + lv_timer_create(refresh_timer_cb, REFRESH_PERIOD_MS, NULL); + + lvgl_port_unlock(); + + ESP_LOGI(TAG, "ready (%s)", unit_suffix()); + return ESP_OK; +} diff --git a/main/ui/ui_manager.h b/main/ui/ui_manager.h new file mode 100644 index 0000000..f4eb62a --- /dev/null +++ b/main/ui/ui_manager.h @@ -0,0 +1,19 @@ +/* + * ui_manager — the round-panel UI. + * + * Layout is built for a 480x480 circular panel: everything meaningful stays + * inside a radius of about 190px from centre, so nothing important is lost to + * the bezel or the corner mask. + * + * One knob drives everything. Rotation acts on whatever is currently focused; + * a short press moves focus on; focus falls back to the setpoint after a few + * seconds of stillness, so the knob is always "the temperature knob" unless + * you have just deliberately made it something else. + * + * Requires lvgl_port_init(). + */ +#pragma once + +#include "esp_err.h" + +esp_err_t ui_manager_init(void); diff --git a/partitions.csv b/partitions.csv new file mode 100644 index 0000000..0ff30ae --- /dev/null +++ b/partitions.csv @@ -0,0 +1,8 @@ +# 16MB flash. Single app partition — the PRD does not ask for OTA, and adding +# it would mean an otadata partition plus rollback handling that nothing +# currently exercises. There is plenty of room to add it later. +# Name, Type, SubType, Offset, Size +nvs, data, nvs, 0x9000, 0x6000 +phy_init, data, phy, 0xf000, 0x1000 +factory, app, factory, 0x10000, 0x400000 +storage, data, spiffs, , 0x100000 diff --git a/sdkconfig.defaults b/sdkconfig.defaults new file mode 100644 index 0000000..981ce0d --- /dev/null +++ b/sdkconfig.defaults @@ -0,0 +1,90 @@ +# Rotary Thermostat — ESP32-S3, 16MB flash, 8MB octal PSRAM +# +# Several entries here are load-bearing for the RGB panel. Read the comments +# before changing any of them; PRD section 8.2 documents what each one costs. + +CONFIG_IDF_TARGET="esp32s3" + +# --- Flash ------------------------------------------------------------------- +CONFIG_ESPTOOLPY_FLASHSIZE_16MB=y +CONFIG_ESPTOOLPY_FLASHMODE_QIO=y +CONFIG_ESPTOOLPY_FLASHFREQ_80M=y +CONFIG_PARTITION_TABLE_CUSTOM=y +CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions.csv" + +# --- PSRAM ------------------------------------------------------------------- +# Octal (OPI) PSRAM. The vendor's Arduino build uses qio_opi; same thing. +CONFIG_SPIRAM=y +CONFIG_SPIRAM_MODE_OCT=y +CONFIG_SPIRAM_SPEED_80M=y +CONFIG_SPIRAM_USE_MALLOC=y +CONFIG_SPIRAM_MALLOC_ALWAYSINTERNAL=16384 +CONFIG_SPIRAM_TRY_ALLOCATE_WIFI_LWIP=y +# +# PRD 8.2: these two contend with the LCD for PSRAM bandwidth and produce a +# black screen plus stalled TLS handshakes. Elecrow's own ESPHome example sets +# both to 'y' — do not copy it. Left explicitly unset: +# CONFIG_SPIRAM_FETCH_INSTRUCTIONS is not set +# CONFIG_SPIRAM_RODATA is not set +# +# 120MHz is the lever if artefacts appear at 480x480, but it drags flash to +# 120MHz with it and needs flash HPM plus a DC-aware bootloader. Not needed at +# this resolution — 480x480 is ~40% of the bandwidth 800x480 required. + +# --- Cache ------------------------------------------------------------------- +# Bigger data cache and 64-byte lines measurably help RGB panel DMA. +CONFIG_ESP32S3_INSTRUCTION_CACHE_32KB=y +CONFIG_ESP32S3_INSTRUCTION_CACHE_LINE_32B=y +CONFIG_ESP32S3_DATA_CACHE_64KB=y +CONFIG_ESP32S3_DATA_CACHE_LINE_64B=y + +# --- CPU / RTOS -------------------------------------------------------------- +CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ_240=y +CONFIG_FREERTOS_HZ=1000 +CONFIG_ESP_MAIN_TASK_STACK_SIZE=8192 + +# --- Console ----------------------------------------------------------------- +# PRD 8.2: USB-Serial-JTAG is the ONLY console link on this board (native USB, +# no CH340), so it is made the PRIMARY console rather than the secondary one. +# +# Note the generated sdkconfig ends up with CONFIG_ESP_CONSOLE_SECONDARY_NONE=y, +# which looks like it contradicts PRD 8.2's warning. It does not. "Secondary +# console" only exists when the primary is UART; with USB-JTAG selected as +# primary there is no secondary slot to fill, and +# CONFIG_ESP_CONSOLE_USB_SERIAL_JTAG_ENABLED=y confirms output still goes to +# the USB link. The warning was about the sibling board, where UART was primary +# and the secondary USB-JTAG console silently occupied GPIO19/20. +CONFIG_ESP_CONSOLE_USB_SERIAL_JTAG=y + +# --- WiFi -------------------------------------------------------------------- +# PRD 8.2: keep WiFi out of NVS. This project writes its own settings to NVS at +# runtime and we do not want WiFi adding uncontrolled flash writes on top. +CONFIG_ESP_WIFI_NVS_ENABLED=n + +# --- LCD --------------------------------------------------------------------- +# RGB panel ISRs and callbacks run from IRAM so a flash operation cannot stall +# them. We use no bounce buffers (see drivers/display/display.c) which is what +# actually makes runtime NVS writes safe. +CONFIG_LCD_RGB_ISR_IRAM_SAFE=y +CONFIG_GDMA_CTRL_FUNC_IN_IRAM=y + +# --- Watchdogs --------------------------------------------------------------- +# The task watchdog is part of the safety story: a hung control task panics the +# chip, and the reboot path de-asserts the relay within about a second. +CONFIG_ESP_TASK_WDT_INIT=y +CONFIG_ESP_TASK_WDT_TIMEOUT_S=10 +CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU0=n +CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU1=n +CONFIG_ESP_SYSTEM_PANIC_PRINT_REBOOT=y + +# --- Logging ----------------------------------------------------------------- +CONFIG_LOG_DEFAULT_LEVEL_INFO=y + +# --- LVGL -------------------------------------------------------------------- +CONFIG_LV_COLOR_DEPTH_16=y +CONFIG_LV_USE_PERF_MONITOR=n +CONFIG_LV_FONT_MONTSERRAT_16=y +CONFIG_LV_FONT_MONTSERRAT_20=y +CONFIG_LV_FONT_MONTSERRAT_28=y +CONFIG_LV_FONT_MONTSERRAT_48=y +CONFIG_LV_MEM_SIZE_KILOBYTES=64