Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4c7b120473 | ||
|
|
d2e4c974dd |
No files matched your search
+12
@@ -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
|
||||
@@ -0,0 +1,4 @@
|
||||
cmake_minimum_required(VERSION 3.16)
|
||||
|
||||
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
|
||||
project(rotary_thermostat)
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
)
|
||||
@@ -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"
|
||||
@@ -0,0 +1,395 @@
|
||||
#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;
|
||||
|
||||
/*
|
||||
* Vsync handshake for tear-free buffer swaps (the pattern from Espressif's
|
||||
* own RGB-panel LVGL example). present_frame gives s_sem_present_pending after
|
||||
* queueing the swap; the vsync ISR converts that into s_sem_swap_done only
|
||||
* when a present is actually waiting, so a stale vsync can never satisfy a
|
||||
* later wait.
|
||||
*/
|
||||
static SemaphoreHandle_t s_sem_present_pending;
|
||||
static SemaphoreHandle_t s_sem_swap_done;
|
||||
|
||||
/* ------------------------------------------------------------ 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;
|
||||
}
|
||||
|
||||
/*
|
||||
* Runs from the RGB panel's frame ISR. CONFIG_LCD_RGB_ISR_IRAM_SAFE=y requires
|
||||
* this to live in IRAM.
|
||||
*/
|
||||
IRAM_ATTR static bool on_vsync_cb(esp_lcd_panel_handle_t panel,
|
||||
const esp_lcd_rgb_panel_event_data_t *edata,
|
||||
void *user_ctx)
|
||||
{
|
||||
(void)panel;
|
||||
(void)edata;
|
||||
(void)user_ctx;
|
||||
|
||||
BaseType_t hpw = pdFALSE;
|
||||
if (xSemaphoreTakeFromISR(s_sem_present_pending, &hpw) == pdTRUE) {
|
||||
xSemaphoreGiveFromISR(s_sem_swap_done, &hpw);
|
||||
}
|
||||
return hpw == pdTRUE;
|
||||
}
|
||||
|
||||
esp_err_t display_present_frame(void *fb)
|
||||
{
|
||||
if (!s_panel || !fb) {
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
|
||||
esp_err_t err = esp_lcd_panel_draw_bitmap(s_panel, 0, 0,
|
||||
DISPLAY_H_RES, DISPLAY_V_RES, fb);
|
||||
if (err != ESP_OK) {
|
||||
return err;
|
||||
}
|
||||
|
||||
/* Wait for the vsync at which the queued switch latches. The timeout is a
|
||||
* guard so a stalled panel degrades to tearing instead of wedging the
|
||||
* LVGL task; one frame at these timings is ~15ms. */
|
||||
xSemaphoreGive(s_sem_present_pending);
|
||||
if (xSemaphoreTake(s_sem_swap_done, pdMS_TO_TICKS(100)) != pdTRUE) {
|
||||
static bool warned;
|
||||
if (!warned) {
|
||||
warned = true;
|
||||
ESP_LOGW(TAG, "vsync wait timed out — panel not producing frames?");
|
||||
}
|
||||
}
|
||||
|
||||
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");
|
||||
|
||||
s_sem_present_pending = xSemaphoreCreateBinary();
|
||||
s_sem_swap_done = xSemaphoreCreateBinary();
|
||||
if (!s_sem_present_pending || !s_sem_swap_done) {
|
||||
return ESP_ERR_NO_MEM;
|
||||
}
|
||||
|
||||
esp_lcd_rgb_panel_event_callbacks_t cbs = {
|
||||
.on_vsync = on_vsync_cb,
|
||||
};
|
||||
ESP_RETURN_ON_ERROR(
|
||||
esp_lcd_rgb_panel_register_event_callbacks(s_panel, &cbs, NULL),
|
||||
TAG, "vsync callback");
|
||||
|
||||
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];
|
||||
}
|
||||
}
|
||||
|
||||
display_present_frame(fb);
|
||||
ESP_LOGI(TAG, "colour bars drawn — this is the PRD phase-2 check");
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/*
|
||||
* 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);
|
||||
|
||||
/*
|
||||
* Hand a completed frame (one of the two framebuffers) to the panel and block
|
||||
* until the swap has latched at vsync.
|
||||
*
|
||||
* esp_lcd_panel_draw_bitmap() only *queues* the framebuffer switch — the RGB
|
||||
* driver latches it at the next frame boundary. Returning before that point
|
||||
* lets the caller start rendering into the other buffer while it is still
|
||||
* being scanned out, which is exactly the persistent-corruption race PRD 8.2
|
||||
* warns about. All frame presentation goes through here so that cannot happen.
|
||||
*/
|
||||
esp_err_t display_present_frame(void *fb);
|
||||
|
||||
/* 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);
|
||||
@@ -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]))
|
||||
@@ -0,0 +1,187 @@
|
||||
#include "encoder.h"
|
||||
|
||||
#include "driver/gpio.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");
|
||||
|
||||
/*
|
||||
* The vendor's Arduino example uses bare INPUT, implying external pullups
|
||||
* on the board, but their ESPHome config enables internal ones as well.
|
||||
* Enabling them here costs nothing and guards against a floating line
|
||||
* reading as phantom rotation. (Done after channel creation so the PCNT
|
||||
* driver's own GPIO setup cannot undo it.)
|
||||
*/
|
||||
gpio_pullup_en(ENCODER_PIN_A);
|
||||
gpio_pullup_en(ENCODER_PIN_B);
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
@@ -0,0 +1,96 @@
|
||||
#include "cst826.h"
|
||||
|
||||
#include "display.h"
|
||||
#include "esp_log.h"
|
||||
#include "i2c_bus.h"
|
||||
|
||||
static const char *TAG = "cst826";
|
||||
|
||||
#define REG_NUM_TOUCHES 0x02
|
||||
#define REG_TOUCH_DATA 0x03
|
||||
#define REG_CHIP_TYPE 0xAA
|
||||
|
||||
static i2c_master_dev_handle_t s_dev;
|
||||
static bool s_ready;
|
||||
|
||||
static esp_err_t read_reg(uint8_t reg, uint8_t *buf, size_t len)
|
||||
{
|
||||
return i2c_master_transmit_receive(s_dev, ®, 1, buf, len, 100);
|
||||
}
|
||||
|
||||
esp_err_t cst826_init(void)
|
||||
{
|
||||
if (s_ready) {
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t err = i2c_bus_add_device(CST826_I2C_ADDR, &s_dev);
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGE(TAG, "add device 0x%02X failed: %s",
|
||||
CST826_I2C_ADDR, esp_err_to_name(err));
|
||||
return err;
|
||||
}
|
||||
|
||||
uint8_t id = 0;
|
||||
err = read_reg(REG_CHIP_TYPE, &id, 1);
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGE(TAG, "no response at 0x%02X: %s",
|
||||
CST826_I2C_ADDR, esp_err_to_name(err));
|
||||
return err;
|
||||
}
|
||||
|
||||
if (id != CST826_CHIP_ID) {
|
||||
/* Not fatal — the thermostat is fully usable from the encoder alone,
|
||||
* and refusing to boot over a touch panel would be a poor trade for
|
||||
* something that controls heat. */
|
||||
ESP_LOGW(TAG, "unexpected chip id 0x%02X (expected 0x%02X) — "
|
||||
"continuing with touch disabled", id, CST826_CHIP_ID);
|
||||
return ESP_ERR_NOT_FOUND;
|
||||
}
|
||||
|
||||
s_ready = true;
|
||||
ESP_LOGI(TAG, "ready at 0x%02X, chip id 0x%02X", CST826_I2C_ADDR, id);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
bool cst826_is_ready(void)
|
||||
{
|
||||
return s_ready;
|
||||
}
|
||||
|
||||
void cst826_read(cst826_touch_t *out)
|
||||
{
|
||||
if (!out) {
|
||||
return;
|
||||
}
|
||||
out->pressed = false;
|
||||
out->x = 0;
|
||||
out->y = 0;
|
||||
|
||||
if (!s_ready) {
|
||||
return;
|
||||
}
|
||||
|
||||
uint8_t n = 0;
|
||||
if (read_reg(REG_NUM_TOUCHES, &n, 1) != ESP_OK || n == 0 || n > 2) {
|
||||
return;
|
||||
}
|
||||
|
||||
uint8_t d[6];
|
||||
if (read_reg(REG_TOUCH_DATA, d, sizeof(d)) != ESP_OK) {
|
||||
return;
|
||||
}
|
||||
|
||||
uint16_t x = (uint16_t)((d[0] & 0x0F) << 8 | d[1]);
|
||||
uint16_t y = (uint16_t)((d[2] & 0x0F) << 8 | d[3]);
|
||||
|
||||
/* A garbage read past the panel edge means a bad transfer, not a touch at
|
||||
* the corner. Drop it rather than sending LVGL a bogus press. */
|
||||
if (x >= DISPLAY_H_RES || y >= DISPLAY_V_RES) {
|
||||
return;
|
||||
}
|
||||
|
||||
out->x = x;
|
||||
out->y = y;
|
||||
out->pressed = true;
|
||||
}
|
||||
@@ -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);
|
||||
@@ -0,0 +1,453 @@
|
||||
#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;
|
||||
}
|
||||
|
||||
/*
|
||||
* Sleep in 1s slices, feeding the task watchdog each one. A single
|
||||
* CONTROL_INTERVAL_S delay would finish fractionally *after* the TWDT
|
||||
* deadline every iteration (both are 10s) and trip it spuriously —
|
||||
* while still keeping the evaluation cadence at CONTROL_INTERVAL_S.
|
||||
*/
|
||||
for (int i = 0; i < CONTROL_INTERVAL_S; i++) {
|
||||
esp_task_wdt_reset();
|
||||
vTaskDelay(pdMS_TO_TICKS(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 "?";
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -0,0 +1,361 @@
|
||||
#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 bool s_ever_requested; /* control loop has called in at least once */
|
||||
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_ever_requested) {
|
||||
/*
|
||||
* Only fault once the control loop has *stopped* talking.
|
||||
* Before its first-ever call this is just boot ordering — the
|
||||
* relay is held off regardless (the seeded-stale watchdog is
|
||||
* the bounded-startup interlock), and flashing "control loop
|
||||
* stopped" on every power-up would teach the user to ignore
|
||||
* the one fault that matters most.
|
||||
*/
|
||||
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_ever_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_ever_requested = true;
|
||||
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";
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
#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_timer.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 esp_timer_handle_t s_reconnect_timer;
|
||||
|
||||
static void reconnect_timer_cb(void *arg)
|
||||
{
|
||||
(void)arg;
|
||||
esp_wifi_connect();
|
||||
}
|
||||
|
||||
/*
|
||||
* Schedule the retry on a timer rather than sleeping here. This runs on the
|
||||
* shared system event task — a vTaskDelay would stall every queued WiFi/IP
|
||||
* event behind it (up to 30s at full backoff) and can overflow the default
|
||||
* event queue during an outage.
|
||||
*/
|
||||
static void schedule_reconnect(void)
|
||||
{
|
||||
ESP_LOGW(TAG, "reconnecting in %d ms", s_retry_ms);
|
||||
|
||||
esp_timer_stop(s_reconnect_timer); /* no-op if not running */
|
||||
esp_timer_start_once(s_reconnect_timer, (uint64_t)s_retry_ms * 1000);
|
||||
|
||||
s_retry_ms *= 2;
|
||||
if (s_retry_ms > RETRY_MAX_MS) {
|
||||
s_retry_ms = RETRY_MAX_MS;
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
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();
|
||||
|
||||
const esp_timer_create_args_t timer_args = {
|
||||
.callback = reconnect_timer_cb,
|
||||
.name = "wifi_retry",
|
||||
};
|
||||
ESP_ERROR_CHECK(esp_timer_create(&timer_args, &s_reconnect_timer));
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -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);
|
||||
@@ -0,0 +1,383 @@
|
||||
#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};
|
||||
const char *rest = strptime(ls->valuestring, "%Y-%m-%dT%H:%M:%S", &tm);
|
||||
if (!rest) {
|
||||
return false;
|
||||
}
|
||||
int64_t epoch = tm_to_epoch_utc(&tm);
|
||||
|
||||
/*
|
||||
* Honour the offset suffix. z2m's ISO_8601 setting emits UTC ("Z"),
|
||||
* but ISO_8601_local emits local time with an offset ("-05:00").
|
||||
* Ignoring that suffix would shift every reading's apparent age by
|
||||
* the local offset — on a US install that is five or six hours, which
|
||||
* makes live sensors look stale and trips the fallback ladder
|
||||
* permanently. local = UTC + offset, so subtract it.
|
||||
*/
|
||||
if (*rest == '.') { /* fractional seconds */
|
||||
rest++;
|
||||
while (*rest >= '0' && *rest <= '9') {
|
||||
rest++;
|
||||
}
|
||||
}
|
||||
if (*rest == '+' || *rest == '-') {
|
||||
int sign = (*rest == '-') ? -1 : 1;
|
||||
int oh = 0, om = 0;
|
||||
if (sscanf(rest + 1, "%2d:%2d", &oh, &om) < 1) {
|
||||
return false;
|
||||
}
|
||||
epoch -= (int64_t)sign * (oh * 3600 + om * 60);
|
||||
}
|
||||
/* 'Z' or nothing: already UTC. */
|
||||
|
||||
seen = (time_t)epoch;
|
||||
} 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;
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
@@ -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 no copy happens — the
|
||||
* RGB driver switches which buffer is scanned out. present_frame blocks
|
||||
* until that switch has latched at vsync; releasing LVGL any earlier would
|
||||
* let it render the next frame into the buffer still on screen.
|
||||
*/
|
||||
display_present_frame(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;
|
||||
}
|
||||
@@ -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);
|
||||
@@ -0,0 +1,426 @@
|
||||
#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[40];
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
/*
|
||||
* The setpoint shown must come from settings, live — not from control
|
||||
* state, which only refreshes every CONTROL_INTERVAL_S. Rendering the
|
||||
* control loop's copy makes the knob feel dead for up to 10 seconds per
|
||||
* detent. When the fallback ladder has clamped the working setpoint, show
|
||||
* both: what the user asked for and what the thermostat is actually
|
||||
* holding — the fault text below explains why they differ.
|
||||
*/
|
||||
float live_sp = control_get_setpoint();
|
||||
bool capped = st->effective_setpoint_c < live_sp - 0.05f;
|
||||
|
||||
if (capped) {
|
||||
snprintf(buf, sizeof(buf), "Set %.0f%s (cap %.0f)",
|
||||
c_to_display(live_sp), unit_suffix(),
|
||||
c_to_display(st->effective_setpoint_c));
|
||||
} else {
|
||||
snprintf(buf, sizeof(buf), "Set %.0f%s",
|
||||
c_to_display(live_sp), unit_suffix());
|
||||
}
|
||||
set_text_if_changed(s_lbl_setpoint, buf);
|
||||
|
||||
int pct = (int)((live_sp - 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);
|
||||
|
||||
/*
|
||||
* While a selection is in progress, read the *settings* value, not control
|
||||
* state: the control loop only re-evaluates every CONTROL_INTERVAL_S, and
|
||||
* showing its lagging copy would make the knob appear to do nothing.
|
||||
* control_get_mode()/control_get_selected_room() go straight to settings.
|
||||
*/
|
||||
char buf[48];
|
||||
switch (s_focus) {
|
||||
case FOCUS_ROOM:
|
||||
snprintf(buf, sizeof(buf), "> ROOM: %s <",
|
||||
zigbee_client_room_name(control_get_selected_room()));
|
||||
break;
|
||||
case FOCUS_MODE:
|
||||
snprintf(buf, sizeof(buf), "> MODE: %s <",
|
||||
control_mode_str(control_get_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;
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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
|
||||
|
@@ -0,0 +1,95 @@
|
||||
# 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.
|
||||
# ESP_TASK_WDT_PANIC is what makes that true — without it the TWDT only logs a
|
||||
# warning and a hung task keeps hanging. (The relay layer's own 30s watchdog
|
||||
# has already dropped the heat call by then; this reboots the chip so the
|
||||
# thermostat comes back instead of staying wedged.)
|
||||
CONFIG_ESP_TASK_WDT_INIT=y
|
||||
CONFIG_ESP_TASK_WDT_TIMEOUT_S=10
|
||||
CONFIG_ESP_TASK_WDT_PANIC=y
|
||||
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
|
||||
Reference in new issue
Block a user