From 393592e3272e72b45e03d496f904d0d05a0a58e4 Mon Sep 17 00:00:00 2001 From: matt_ed Date: Mon, 3 Aug 2026 17:47:33 -0700 Subject: [PATCH] first commit --- PRD.md | 405 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 405 insertions(+) create mode 100644 PRD.md diff --git a/PRD.md b/PRD.md new file mode 100644 index 0000000..6a76aa1 --- /dev/null +++ b/PRD.md @@ -0,0 +1,405 @@ +# Rotary Thermostat — Product Requirements + +ESP-IDF firmware turning an Elecrow CrowPanel 2.1" Rotary Display into a +multi-room thermostat for a gas furnace, reading Zigbee room sensors over HTTP +and driving the furnace's heat call via a relay. + +**Status:** not started. This document is the brief for the implementing agent. + +--- + +## 1. Goal + +Replace a conventional 24VAC thermostat with a device that: + +- reads temperature from Zigbee sensors in multiple rooms, via an existing + Zigbee hub's HTTP API +- decides which room's reading governs (manual / scheduled / formula) +- calls for heat by closing a dry contact between `R` and `W` +- presents setpoint, current temperature and status on a 480×480 round display +- takes setpoint input from the rotary encoder + +Control logic runs **on the ESP32**, not in Home Assistant. The house must keep +heating when the hub or HA is down, degrading to a safe fallback. + +--- + +## 2. Hardware + +### 2.1 Board — Elecrow CrowPanel 2.1" HMI Rotary Display + +Verified on the actual unit (2026-08-03) with `esptool`: + +| Property | Value | +|---|---| +| SoC | ESP32-S3 (QFN56) rev v0.2 | +| Flash | 16MB (GigaDevice `c8:4018`) | +| PSRAM | 8MB embedded (AP_3v3) | +| USB | native USB-Serial/JTAG (`303a:1001`) — **no CH340** | +| Port | `/dev/ttyACM0` | +| MAC | `1c:db:d4:b3:5c:74` | +| Power | 5V / 1A | + +Currently running stock Arduino firmware (arduino-lib-builder, IDF v4.4.6, +Oct 2023). Nothing has been written to it. + +### 2.2 Peripherals — FROM VENDOR DOCS, NOT YET VERIFIED ON HARDWARE + +> **Read section 8.1 before trusting any of these numbers.** The previous +> project in this family lost most of a session to a pinout taken from the +> wrong board variant. Confirm every value against Elecrow's own source before +> writing driver code. + +- **Panel:** ST7701S, 480×480 round IPS. Requires a **~30-command SPI + initialisation sequence** before the RGB bus produces anything. A bare RGB + configuration displays nothing. +- **Touch:** CST816 capacitive (*not* GT911 — different register map and + protocol from the 7" project). +- **I/O expander:** PCF8574 @ I2C `0x21`. +- **Rotary encoder:** A = GPIO42, B = GPIO4; button on expander P5 + (INPUT_PULLUP). + +| Signal | GPIO | +|---|---| +| CS | 16 | +| DE | 40 | +| HSYNC | 15 | +| VSYNC | 7 | +| PCLK | 41 (18 MHz) | +| R0–R4 | 46, 3, 8, 18, 17 | +| G0–G5 | 14, 13, 12, 11, 10, 9 | +| B0–B4 | 5, 45, 48, 47, 21 | +| I2C SDA / SCL | 38 / 39 | + +**PCF8574 allocation:** + +| Pin | Use | +|---|---| +| P0 | Touch reset | +| P1 | **free** | +| P2 | Touch interrupt | +| P3 | LCD power | +| P4 | LCD reset | +| P5 | Encoder button | +| P6 | **free** | +| P7 | **free** | + +### 2.3 Available I/O + +The RGB panel consumes ~21 GPIOs, so almost nothing native is exposed. +Externally available: + +- **I2C connector** (SDA 38 / SCL 39), ZX-MX 1.25-4P +- **UART0 and UART1** connectors +- **12P FPC** (power + programming) +- **PCF8574 P1, P6, P7** + +I2C is the expansion path, not GPIO. Three spare expander pins is enough for +this project; a second expander or I2C relay board can be added on the +connector if more are needed. + +PCF8574 outputs **sink** well (~25 mA) but barely source — use **active-low +triggered** relay modules. + +### 2.4 HVAC system + +- Gas/oil forced-air furnace, 24VAC control. **Heat only, no cooling.** +- **3-wire** system. +- Presence of a `C` (common) wire **unconfirmed** — see section 7. + +Third wire is most likely `G` (fan). On a heat-only system independent fan +control is rarely needed — the furnace board runs the blower automatically +during a heat call — so `G` can usually be repurposed as `C` by moving it to +the `C` terminal at the furnace control board. This powers the thermostat with +no new cable. **Confirm terminal labels at the furnace and photograph the +existing wiring before changing anything.** + +If `C` is unavailable, power the board from a separate 5V USB supply. Do **not** +attempt 24VAC "power stealing" — this board needs ~5W and power stealing +supplies roughly a tenth of that; it will cause furnace chatter or +short-cycling. + +### 2.5 Output wiring + +``` +PCF8574 P1 ──► relay module IN (active low) + +R (24VAC) ──► relay COM +W (heat) ◄── relay NO closes to call for heat +``` + +Use an **opto-isolated** relay module with a separate JD-VCC jumper so the +24VAC side is genuinely isolated. Relay must be **normally open**. + +**Never connect `R` to `C`** — dead short across the transformer; blows the +fuse on the furnace control board. + +--- + +## 3. Architecture + +``` +Zigbee sensors ──► Zigbee hub (HTTP/JSON) ──► ESP32 polls over WiFi + │ + setpoint + control loop + │ + PCF8574 P1 ──► relay ──► R/W +``` + +Suggested component layout (mirroring the sibling project, section 9): + +``` +main/ +├── main.c +├── app_config.h gitignored; credentials + hub URL +├── app_config.h.example tracked template +├── drivers/ +│ ├── i2c_bus.{c,h} shared bus: PCF8574, touch, any added sensor +│ ├── expander/ PCF8574 — touch reset, LCD power/reset, relay +│ ├── display/ ST7701S init + RGB panel +│ ├── touch/ CST816 +│ └── encoder/ rotary + button +├── hvac/ +│ ├── relay.{c,h} heat call, with the safety interlocks of section 5 +│ └── control.{c,h} setpoint, hysteresis, room selection +├── net/ +│ ├── wifi_manager.{c,h} +│ └── zigbee_client.{c,h} polls hub, exposes readings with timestamps +├── ui/ +│ ├── lvgl_port.{c,h} tick, task, locking +│ └── ui_manager.{c,h} +└── storage/ + └── settings.{c,h} NVS persistence +``` + +--- + +## 4. Functional requirements + +### 4.1 Room temperature acquisition + +- Poll the Zigbee hub's HTTP endpoint on a fixed interval (default 60s; + configurable). +- Parse JSON with cJSON. +- Every reading carries **a value and a timestamp** (`last_seen` from the hub + if provided, otherwise local receipt time). +- Expose battery level per sensor if the hub reports it, and surface a low + battery warning on screen. +- A failed poll must not clear previously good readings — only age them. + +### 4.2 Room selection + +Three modes, selectable from the UI: + +1. **Manual** — encoder picks the governing room. +2. **Schedule** — time-of-day rules (e.g. bedroom overnight, TV room evenings). +3. **Formula** — `min` across rooms ("coldest room wins") is the recommended + default; `average` also acceptable. Min-based control means nobody is cold, + at the cost of overheating elsewhere. + +Manual override must always be reachable regardless of active mode. + +### 4.3 Control loop + +- Setpoint adjustable by rotary encoder, 0.5°C steps, sane bounds (e.g. 5–30°C). +- **Hysteresis** (deadband) to prevent chatter — default ±0.5°C. Call for heat + below `setpoint − deadband`, stop at `setpoint + deadband`. +- Evaluate at a fixed cadence; do not react to every incoming reading. + +### 4.4 Display / UI + +- Circular layout suited to a 480×480 round panel — nothing important near the + edges. +- Show: current governing temperature, setpoint, active room, mode, heat-call + state, and any fault (stale sensor, hub unreachable, low battery). +- Encoder rotation adjusts setpoint; button press cycles screens or confirms. +- LVGL 9.x. + +### 4.5 Persistence + +Survive reboot via NVS: setpoint, mode, selected room, schedule. + +--- + +## 5. Safety requirements + +**These are not optional and must be implemented in the relay layer itself, not +left to UI code.** + +1. **Fail-open.** Normally-open relay. Crash, reset, brownout or power loss + must leave heat **off**. +2. **Watchdog-gated output.** The relay is held closed only while a control + task refreshes a timer. If the task stops, the relay drops automatically. A + hung firmware must not be able to hold a heat call. +3. **Maximum on-time cap.** Absolute limit per heat call (suggest 60 min). + Exceeding it drops the relay and raises a fault. +4. **Minimum off-time.** At least 5 minutes between heat calls (anti + short-cycle). +5. **Stale-data policy.** Define explicitly for "selected room hasn't reported + in N minutes" (suggest N = 20): + - stale data must **never** hold the relay closed + - it must **never** silently mean no heat either (frozen pipes) + - fall back in order: another room's fresh reading → a local sensor if + fitted → a fixed conservative setpoint (suggest 16°C), showing a clear + fault on screen throughout +6. **Bounded startup state.** Relay off until the control loop has at least one + fresh reading or has entered the documented fallback. + +### 5.1 Risk calibration + +For accuracy rather than alarm: the furnace's **high-limit and rollout switches +are independent of the thermostat** and remain in circuit. A stuck heat call +results in an overheated house and a large gas bill, with the furnace cycling +on its own limit — not a fire. The 24VAC control side is genuinely low voltage. +This does not remove the need for the interlocks above, but it does mean the +realistic failure mode is discomfort and cost. + +--- + +## 6. Non-functional + +- Must keep heating with the hub, MQTT broker or Home Assistant down, per the + fallback ladder in 5.5. +- WiFi reconnect must be automatic and must not block the control loop. +- All network I/O off the LVGL task. +- Boot to a usable, safe state in under ~10 seconds. + +--- + +## 7. Blocking inputs still needed from the user + +The implementing agent should request these before writing the relevant module. +**Do not guess at any of them.** + +1. **Zigbee hub HTTP API shape.** A sample response is ideal: + ```bash + curl -s 'http:///' + ``` + Needed: URL pattern, how a sensor is identified (friendly name? IEEE + address?), the JSON field holding temperature, whether a timestamp / + `last_seen` is included, whether auth is required, and battery reporting if + available. +2. **Confirmation of the 3 thermostat wires** — specifically whether the third + is `G` (repurposable as `C`) or already `C`. +3. **Room names / sensor IDs** for the TV room and bedroom. +4. **Units** — °C or °F for display. + +--- + +## 8. Implementation notes and known pitfalls + +### 8.1 Verify the pinout against vendor source first — non-negotiable + +The sibling 7" project lost most of a session to a pinout taken from the wrong +board variant (CrowPanel 7" *Basic* vs *Advance* — same brand, same wiki, and +not one pin in common). Symptoms were a black screen and an apparently empty +I2C bus, and every software hypothesis tested came back negative because the +code was correct and the hardware description was wrong. + +Before writing the display driver: + +- Clone Elecrow's repo for **this** board and read the actual example source — + not the wiki prose, not a forum post, not a search summary. +- Lift the **ST7701S init sequence verbatim.** It is ~30 hex commands; one + wrong byte gives garbage or nothing, with no useful diagnostic. +- Cross-check against a second independent source (Elecrow's ESPHome example + is good) before trusting it. +- If the panel is black, **re-verify the pinout before debugging the software.** + +A cheap early sanity check: bring up I2C first and scan the bus. Finding the +PCF8574 at `0x21` and the CST816 confirms the board is alive and the I2C pins +are right, independently of the display. + +### 8.2 ESP-IDF / LVGL pitfalls already paid for on the sibling project + +- **`lv_init()` must run before creating any LVGL object.** Calling + `lv_display_create()` first faults with `LoadProhibited` inside the TLSF + allocator — a confusing way to discover an ordering bug. Guard it with + `lv_is_initialized()`. +- **USB-Serial-JTAG occupies GPIO19/20** on the ESP32-S3. IDF enables it as a + secondary console by default. On the sibling board that silently blocked the + touch I2C bus. **On this board USB-JTAG is the only console link**, so do + *not* set `CONFIG_ESP_CONSOLE_SECONDARY_NONE=y` — you will lose all serial + output. +- **Do not enable `CONFIG_SPIRAM_FETCH_INSTRUCTIONS` / `CONFIG_SPIRAM_RODATA`** + with an RGB panel. They contend with the LCD for PSRAM bandwidth: black + screen and stalled TLS handshakes. +- **Bounce-buffer ISR vs flash writes.** The refill ISR reads PSRAM, which + shares the cache with flash on the S3. Any NVS/flash write while the panel is + running faults it with *"Cache disabled but cached memory region accessed"*. + Bring WiFi up **before** `display_init()`, and set + `CONFIG_ESP_WIFI_NVS_ENABLED=n`. Note this project writes settings to NVS at + runtime — either pause the panel around those writes or avoid bounce buffers. +- **Never write into the framebuffer being scanned.** Copying a partial + rectangle into the live framebuffer races the LCD's reads; the corruption + scales with bytes written and *persists*, because LVGL does not know those + pixels are wrong and never reinvalidates them. Use two framebuffers and swap. +- **LVGL does not bubble events to parents.** An event handler on the screen + object never sees taps landing on a child widget — which looks exactly like + dead touch zones. Poll the input device from an `lv_timer` for anything + screen-wide. +- **`lv_label_set_text()` invalidates unconditionally**, even for identical + text, forcing a repaint. Compare before setting. +- **PSRAM at 80MHz is marginal for an RGB panel.** The vendor's Arduino builds + use 120MHz, but on ESP-IDF 5.3 selecting `CONFIG_SPIRAM_SPEED_120M` raises + the shared MSPI core clock and forces flash to 120MHz too, which needs flash + High Performance Mode and a DC-aware bootloader. 480×480 needs roughly 40% of + the bandwidth 800×480 did, so this is likely to be a non-issue here — but + that is the lever if artefacts appear. + +### 8.3 Toolchain + +ESP-IDF **v5.3.2** at `~/esp/esp-idf`. `cmake` and `ninja` live in the IDF +virtualenv, not system-wide, so they are only on `PATH` after sourcing +`export.sh`. The Bash tool does not persist shell state between calls — source +it in the same compound command: + +```bash +. ~/esp/esp-idf/export.sh && idf.py build +``` + +Flash and monitor on `/dev/ttyACM0`. Rootless podman on this machine is broken +(stale `/etc/subuid` after a user rename) — do not plan on containers. + +--- + +## 9. Prior art + +`~/workspace/thermostat-hw` — the sibling project on the CrowPanel 7" Advance. +Working, verified on hardware. Directly reusable patterns: + +- `main/api/ambient_weather.c` — HTTP + cJSON polling with timeouts, fixed + response buffer and a chunked-encoding-safe event handler. **The Zigbee hub + client is the same shape.** +- `main/ui/lvgl_port.c` — LVGL tick, service task, recursive lock, and + pause/resume. +- `main/drivers/i2c_bus.c` — shared bus ownership with a diagnostic scan. +- `main/wifi/wifi_manager.c` — STA bring-up with bounded retries. + +Its `README.md` documents the display architecture and the pitfalls above in +context. + +`~/workspace/displayTest/FluidTouch` — third-party project supporting several +CrowPanel variants; useful reference for board-specific configuration and a +worked LovyanGFX setup. + +--- + +## 10. Suggested phasing + +Each phase should end in something verifiable on hardware. + +| Phase | Deliverable | Verification | +|---|---|---| +| 1 | Project skeleton, WiFi, I2C bus + scan | PCF8574 at `0x21` and CST816 found in the log | +| 2 | ST7701S init + RGB panel | Colour bars on the round panel | +| 3 | LVGL port + basic UI | Setpoint renders, stable, no artefacts | +| 4 | Encoder + CST816 touch | Knob changes setpoint; taps register | +| 5 | Zigbee hub client | Room temps logged with timestamps and staleness | +| 6 | Control loop **+ all of section 5** | Relay logic exercised with a **bench LED/relay, not the furnace** | +| 7 | NVS persistence | Settings survive reboot | +| 8 | Schedule / formula modes | Room selection behaves per 4.2 | +| 9 | Install | Only after phase 6 has been proven on the bench | + +**Do not connect to furnace wiring until the safety interlocks in section 5 are +implemented and demonstrated on the bench.**