Implement rotary thermostat firmware from PRD
ESP-IDF v5.3.2 firmware for the CrowPanel 2.1" rotary display. Builds clean;
nothing has been run on hardware yet.
Hardware verified against Elecrow's own source for this board and
cross-checked against their ESPHome config, per PRD 8.1. Three corrections to
the PRD's pinout, all documented in docs/HARDWARE.md:
- touch is CST826 at 0x15, not CST816 (chip ID reads 0x11, not 0xB4)
- the ST7701S needs a 3-wire SPI bus on GPIO 16/2/1 that the PRD omitted;
without it the init sequence cannot be sent and the panel stays black
- the backlight is GPIO6
Two further departures from the PRD, both forced by facts on the ground:
- Transport is MQTT, not HTTP. zigbee2mqtt has no REST endpoint for live
sensor state. Subscribing to the broker also gets retained topics, so
every room's last reading arrives on connect rather than after a poll.
- The furnace is 2-wire R/W with no C, not 3-wire. The board needs its own
5V supply. The thermostat's 5 CPH setting is enforced as a minimum
12-minute period between heat-call starts, which a minimum off-time alone
does not achieve.
All of PRD section 5 is implemented in the relay layer itself: fail-open,
watchdog-gated output, max on-time, min off-time, cycle-rate limit, startup
lockout, and the stale-data fallback ladder down to open-loop freeze
protection. The one gap that cannot be closed in software — the PCF8574 not
resetting when the ESP32 does — is documented rather than papered over.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
393592e327
commit
d2e4c974dd
35 files changed
+4685
No files matched your search
@@ -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);
|
||||
Reference in new issue
Block a user