Implement rotary thermostat firmware from PRD

ESP-IDF v5.3.2 firmware for the CrowPanel 2.1" rotary display. Builds clean;
nothing has been run on hardware yet.

Hardware verified against Elecrow's own source for this board and
cross-checked against their ESPHome config, per PRD 8.1. Three corrections to
the PRD's pinout, all documented in docs/HARDWARE.md:

  - touch is CST826 at 0x15, not CST816 (chip ID reads 0x11, not 0xB4)
  - the ST7701S needs a 3-wire SPI bus on GPIO 16/2/1 that the PRD omitted;
    without it the init sequence cannot be sent and the panel stays black
  - the backlight is GPIO6

Two further departures from the PRD, both forced by facts on the ground:

  - Transport is MQTT, not HTTP. zigbee2mqtt has no REST endpoint for live
    sensor state. Subscribing to the broker also gets retained topics, so
    every room's last reading arrives on connect rather than after a poll.
  - The furnace is 2-wire R/W with no C, not 3-wire. The board needs its own
    5V supply. The thermostat's 5 CPH setting is enforced as a minimum
    12-minute period between heat-call starts, which a minimum off-time alone
    does not achieve.

All of PRD section 5 is implemented in the relay layer itself: fail-open,
watchdog-gated output, max on-time, min off-time, cycle-rate limit, startup
lockout, and the stale-data fallback ladder down to open-loop freeze
protection. The one gap that cannot be closed in software — the PCF8574 not
resetting when the ESP32 does — is documented rather than papered over.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mattandClaude Opus 5 committed 2026-08-03 19:47:00 -07:00
1 parent 393592e327
commit d2e4c974dd
35 files changed
+4685

No files matched your search

+12
View File
@@ -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
+4
View File
@@ -0,0 +1,4 @@
cmake_minimum_required(VERSION 3.16)
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
project(rotary_thermostat)
+234
View File
@@ -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 <broker> -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.
+221
View File
@@ -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.
+38
View File
@@ -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
)
+78
View File
@@ -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 <base>/<friendly_name>. */
#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"
+326
View File
@@ -0,0 +1,326 @@
#include "display.h"
#include <string.h>
#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");
}
+55
View File
@@ -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 <stdbool.h>
#include <stdint.h>
#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);
+148
View File
@@ -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 <stdint.h>
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]))
+176
View File
@@ -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;
}
+44
View File
@@ -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 <stdbool.h>
#include <stdint.h>
#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);
+141
View File
@@ -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;
}
+49
View File
@@ -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 <stdbool.h>
#include <stdint.h>
#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);
+84
View File
@@ -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;
}
+30
View File
@@ -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);
+96
View File
@@ -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, &reg, 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;
}
+46
View File
@@ -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 <stdbool.h>
#include <stdint.h>
#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);
+444
View File
@@ -0,0 +1,444 @@
#include "control.h"
#include <math.h>
#include <time.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 "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 "?";
}
}
+107
View File
@@ -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 <stdbool.h>
#include <stdint.h>
#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);
+350
View File
@@ -0,0 +1,350 @@
#include "relay.h"
#include <inttypes.h>
#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";
}
}
+135
View File
@@ -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 <stdbool.h>
#include <stdint.h>
#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);
+9
View File
@@ -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"
+156
View File
@@ -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");
}
}
+139
View File
@@ -0,0 +1,139 @@
#include "wifi_manager.h"
#include <stdlib.h>
#include <string.h>
#include <time.h>
#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;
}
+27
View File
@@ -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 <stdbool.h>
#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);
+356
View File
@@ -0,0 +1,356 @@
#include "zigbee_client.h"
#include <stdio.h>
#include <string.h>
#include <time.h>
#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 <base>/<friendly_name>, and also to sub-topics such as
* <base>/<friendly_name>/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;
}
+79
View File
@@ -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 <stdbool.h>
#include <stdint.h>
#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);
+300
View File
@@ -0,0 +1,300 @@
#include "settings.h"
#include <string.h>
#include "esp_log.h"
#include "esp_timer.h"
#include "freertos/FreeRTOS.h"
#include "freertos/semphr.h"
#include "freertos/task.h"
#include "nvs.h"
#include "nvs_flash.h"
#include "zigbee_client.h"
static const char *TAG = "settings";
#define NVS_NAMESPACE "thermo"
#define KEY_SETPOINT "setpoint"
#define KEY_MODE "mode"
#define KEY_ROOM "room"
#define KEY_SCHEDULE "sched"
/* Wait for the value to settle before committing. Comfortably longer than a
* knob twiddle, short enough that a power cut rarely loses a change. */
#define COMMIT_DEBOUNCE_MS 5000
static settings_t s_settings;
static SemaphoreHandle_t s_lock;
static volatile int64_t s_dirty_since_us; /* 0 = clean */
static void commit_task(void *arg);
static void defaults(settings_t *s)
{
memset(s, 0, sizeof(*s));
s->setpoint_c = CONTROL_SETPOINT_DEFAULT_C;
s->mode = ROOM_MODE_FORMULA; /* coldest room wins — PRD 4.2 recommends */
s->selected_room = 0;
/*
* A plausible starting schedule matching the PRD's example: bedroom
* overnight, TV room during the evening. Rooms are indices into
* APP_ROOM_TABLE, so this only makes sense with at least two rooms.
*/
if (ROOM_COUNT >= 2) {
s->schedule[0] = (schedule_rule_t){
.start_hour = 22, .start_minute = 0, .room_index = 1, .enabled = true
};
s->schedule[1] = (schedule_rule_t){
.start_hour = 7, .start_minute = 0, .room_index = 0, .enabled = true
};
}
}
static void mark_dirty(void)
{
s_dirty_since_us = esp_timer_get_time();
}
/* --------------------------------------------------------------- load --- */
esp_err_t settings_init(void)
{
if (!s_lock) {
s_lock = xSemaphoreCreateMutex();
if (!s_lock) {
return ESP_ERR_NO_MEM;
}
}
defaults(&s_settings);
esp_err_t err = nvs_flash_init();
if (err == ESP_ERR_NVS_NO_FREE_PAGES || err == ESP_ERR_NVS_NEW_VERSION_FOUND) {
ESP_LOGW(TAG, "NVS needs erasing, doing that");
ESP_ERROR_CHECK(nvs_flash_erase());
err = nvs_flash_init();
}
if (err != ESP_OK) {
ESP_LOGE(TAG, "nvs init failed: %s — running on defaults",
esp_err_to_name(err));
return err;
}
nvs_handle_t h;
err = nvs_open(NVS_NAMESPACE, NVS_READWRITE, &h);
if (err != ESP_OK) {
ESP_LOGW(TAG, "nvs_open failed: %s — running on defaults",
esp_err_to_name(err));
return ESP_OK;
}
/* Each key is optional and independently validated: a corrupt or
* out-of-range value falls back to its default rather than poisoning the
* whole settings blob. */
size_t len = sizeof(float);
float sp;
if (nvs_get_blob(h, KEY_SETPOINT, &sp, &len) == ESP_OK &&
sp >= CONTROL_SETPOINT_MIN_C && sp <= CONTROL_SETPOINT_MAX_C) {
s_settings.setpoint_c = sp;
}
uint8_t u8;
if (nvs_get_u8(h, KEY_MODE, &u8) == ESP_OK && u8 < ROOM_MODE_COUNT) {
s_settings.mode = u8;
}
if (nvs_get_u8(h, KEY_ROOM, &u8) == ESP_OK && u8 < ROOM_COUNT) {
s_settings.selected_room = u8;
}
schedule_rule_t sched[SCHEDULE_MAX_RULES];
len = sizeof(sched);
if (nvs_get_blob(h, KEY_SCHEDULE, sched, &len) == ESP_OK &&
len == sizeof(sched)) {
bool ok = true;
for (int i = 0; i < SCHEDULE_MAX_RULES; i++) {
if (sched[i].enabled &&
(sched[i].start_hour > 23 || sched[i].start_minute > 59 ||
sched[i].room_index >= ROOM_COUNT)) {
ok = false;
break;
}
}
if (ok) {
memcpy(s_settings.schedule, sched, sizeof(sched));
} else {
ESP_LOGW(TAG, "stored schedule is out of range, using defaults");
}
}
nvs_close(h);
ESP_LOGI(TAG, "loaded: setpoint %.1fC, mode %u, room %u",
s_settings.setpoint_c, s_settings.mode, s_settings.selected_room);
if (xTaskCreate(commit_task, "nvs_commit", 3072, NULL, 2, NULL) != pdPASS) {
/* Not fatal: settings still work for this session, they just will not
* be written back. Better than refusing to run the thermostat. */
ESP_LOGE(TAG, "commit task create failed — changes will not persist");
}
return ESP_OK;
}
/* ------------------------------------------------------------- commit --- */
esp_err_t settings_commit_now(void)
{
if (!s_lock) {
return ESP_ERR_INVALID_STATE;
}
settings_t snapshot;
xSemaphoreTake(s_lock, portMAX_DELAY);
snapshot = s_settings;
s_dirty_since_us = 0;
xSemaphoreGive(s_lock);
nvs_handle_t h;
esp_err_t err = nvs_open(NVS_NAMESPACE, NVS_READWRITE, &h);
if (err != ESP_OK) {
ESP_LOGE(TAG, "nvs_open for write failed: %s", esp_err_to_name(err));
return err;
}
nvs_set_blob(h, KEY_SETPOINT, &snapshot.setpoint_c, sizeof(float));
nvs_set_u8(h, KEY_MODE, snapshot.mode);
nvs_set_u8(h, KEY_ROOM, snapshot.selected_room);
nvs_set_blob(h, KEY_SCHEDULE, snapshot.schedule, sizeof(snapshot.schedule));
err = nvs_commit(h);
nvs_close(h);
if (err == ESP_OK) {
ESP_LOGI(TAG, "committed: setpoint %.1fC, mode %u, room %u",
snapshot.setpoint_c, snapshot.mode, snapshot.selected_room);
} else {
ESP_LOGE(TAG, "commit failed: %s", esp_err_to_name(err));
}
return err;
}
static void commit_task(void *arg)
{
(void)arg;
while (1) {
int64_t dirty = s_dirty_since_us;
if (dirty != 0 &&
(esp_timer_get_time() - dirty) > (int64_t)COMMIT_DEBOUNCE_MS * 1000) {
settings_commit_now();
}
vTaskDelay(pdMS_TO_TICKS(1000));
}
}
/* ---------------------------------------------------------------- api --- */
void settings_get(settings_t *out)
{
if (!out) {
return;
}
if (!s_lock) {
defaults(out);
return;
}
xSemaphoreTake(s_lock, portMAX_DELAY);
*out = s_settings;
xSemaphoreGive(s_lock);
}
void settings_set_setpoint(float celsius)
{
if (!s_lock) {
return;
}
xSemaphoreTake(s_lock, portMAX_DELAY);
if (s_settings.setpoint_c != celsius) {
s_settings.setpoint_c = celsius;
mark_dirty();
}
xSemaphoreGive(s_lock);
}
void settings_set_mode(uint8_t mode)
{
if (!s_lock || mode >= ROOM_MODE_COUNT) {
return;
}
xSemaphoreTake(s_lock, portMAX_DELAY);
if (s_settings.mode != mode) {
s_settings.mode = mode;
mark_dirty();
}
xSemaphoreGive(s_lock);
}
void settings_set_selected_room(uint8_t room)
{
if (!s_lock || room >= ROOM_COUNT) {
return;
}
xSemaphoreTake(s_lock, portMAX_DELAY);
if (s_settings.selected_room != room) {
s_settings.selected_room = room;
mark_dirty();
}
xSemaphoreGive(s_lock);
}
void settings_set_schedule(const schedule_rule_t *rules, int count)
{
if (!s_lock || !rules || count < 0 || count > SCHEDULE_MAX_RULES) {
return;
}
xSemaphoreTake(s_lock, portMAX_DELAY);
memset(s_settings.schedule, 0, sizeof(s_settings.schedule));
memcpy(s_settings.schedule, rules, sizeof(schedule_rule_t) * (size_t)count);
mark_dirty();
xSemaphoreGive(s_lock);
}
int settings_schedule_room_for_time(int hour, int minute)
{
if (hour < 0 || hour > 23 || minute < 0 || minute > 59) {
return -1;
}
int now = hour * 60 + minute;
int best_room = -1;
int best_delta = -1;
xSemaphoreTake(s_lock, portMAX_DELAY);
/*
* A rule owns the time from its start until the next enabled rule starts.
* Finding the rule with the smallest non-negative "minutes since it
* started", wrapping around midnight, is the same thing without needing
* the rules to be sorted.
*/
for (int i = 0; i < SCHEDULE_MAX_RULES; i++) {
const schedule_rule_t *r = &s_settings.schedule[i];
if (!r->enabled) {
continue;
}
int start = r->start_hour * 60 + r->start_minute;
int delta = now - start;
if (delta < 0) {
delta += 24 * 60;
}
if (best_delta < 0 || delta < best_delta) {
best_delta = delta;
best_room = r->room_index;
}
}
xSemaphoreGive(s_lock);
return best_room;
}
+58
View File
@@ -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 <stdbool.h>
#include <stdint.h>
#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);
+187
View File
@@ -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;
}
+33
View File
@@ -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 <stdbool.h>
#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);
+406
View File
@@ -0,0 +1,406 @@
#include "ui_manager.h"
#include <math.h>
#include <stdio.h>
#include <string.h>
#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;
}
+19
View File
@@ -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);
+8
View File
@@ -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
1 # 16MB flash. Single app partition — the PRD does not ask for OTA, and adding
2 # it would mean an otadata partition plus rollback handling that nothing
3 # currently exercises. There is plenty of room to add it later.
4 # Name, Type, SubType, Offset, Size
5 nvs, data, nvs, 0x9000, 0x6000
6 phy_init, data, phy, 0xf000, 0x1000
7 factory, app, factory, 0x10000, 0x400000
8 storage, data, spiffs, , 0x100000
+90
View File
@@ -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