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,176 @@
|
||||
#include "encoder.h"
|
||||
|
||||
#include "driver/pulse_cnt.h"
|
||||
#include "esp_check.h"
|
||||
#include "esp_log.h"
|
||||
#include "esp_timer.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/task.h"
|
||||
#include "pcf8574.h"
|
||||
|
||||
static const char *TAG = "encoder";
|
||||
|
||||
/*
|
||||
* Full quadrature decode counts 4 edges per mechanical detent on an EC28A.
|
||||
* PCNT counts in raw edges; we divide.
|
||||
*/
|
||||
#define COUNTS_PER_DETENT 4
|
||||
|
||||
#define PCNT_HIGH_LIMIT 1000
|
||||
#define PCNT_LOW_LIMIT (-1000)
|
||||
#define GLITCH_FILTER_NS 1000
|
||||
|
||||
#define BUTTON_POLL_MS 20
|
||||
#define BUTTON_DEBOUNCE_N 3
|
||||
#define LONG_PRESS_MS 800
|
||||
|
||||
static pcnt_unit_handle_t s_unit;
|
||||
static int s_residual; /* raw counts not yet worth a detent */
|
||||
|
||||
static volatile encoder_button_event_t s_btn_event;
|
||||
static volatile bool s_btn_down;
|
||||
|
||||
static void button_task(void *arg)
|
||||
{
|
||||
(void)arg;
|
||||
|
||||
int stable = 0;
|
||||
bool debounced = false; /* true = pressed */
|
||||
int64_t pressed_at = 0;
|
||||
bool long_fired = false;
|
||||
|
||||
while (1) {
|
||||
bool level = true;
|
||||
/* P5 is INPUT_PULLUP: low means pressed. */
|
||||
bool pressed = (pcf8574_read(PCF_PIN_ENCODER_SW, &level) == ESP_OK)
|
||||
&& !level;
|
||||
|
||||
if (pressed == debounced) {
|
||||
stable = 0;
|
||||
} else if (++stable >= BUTTON_DEBOUNCE_N) {
|
||||
stable = 0;
|
||||
debounced = pressed;
|
||||
s_btn_down = pressed;
|
||||
|
||||
if (pressed) {
|
||||
pressed_at = esp_timer_get_time();
|
||||
long_fired = false;
|
||||
} else if (!long_fired) {
|
||||
/* Click reported on release, so a long press never also
|
||||
* produces a click. */
|
||||
s_btn_event = ENCODER_BTN_CLICK;
|
||||
}
|
||||
}
|
||||
|
||||
if (debounced && !long_fired &&
|
||||
(esp_timer_get_time() - pressed_at) > (LONG_PRESS_MS * 1000)) {
|
||||
long_fired = true;
|
||||
s_btn_event = ENCODER_BTN_LONG_PRESS;
|
||||
}
|
||||
|
||||
vTaskDelay(pdMS_TO_TICKS(BUTTON_POLL_MS));
|
||||
}
|
||||
}
|
||||
|
||||
esp_err_t encoder_init(void)
|
||||
{
|
||||
pcnt_unit_config_t unit_cfg = {
|
||||
.high_limit = PCNT_HIGH_LIMIT,
|
||||
.low_limit = PCNT_LOW_LIMIT,
|
||||
};
|
||||
ESP_RETURN_ON_ERROR(pcnt_new_unit(&unit_cfg, &s_unit), TAG, "pcnt unit");
|
||||
|
||||
pcnt_glitch_filter_config_t filter = {
|
||||
.max_glitch_ns = GLITCH_FILTER_NS,
|
||||
};
|
||||
ESP_RETURN_ON_ERROR(pcnt_unit_set_glitch_filter(s_unit, &filter),
|
||||
TAG, "glitch filter");
|
||||
|
||||
/* Two channels, each watching one edge and using the other line as the
|
||||
* direction control — the standard full-quadrature setup. */
|
||||
pcnt_chan_config_t chan_a_cfg = {
|
||||
.edge_gpio_num = ENCODER_PIN_A,
|
||||
.level_gpio_num = ENCODER_PIN_B,
|
||||
};
|
||||
pcnt_channel_handle_t chan_a = NULL;
|
||||
ESP_RETURN_ON_ERROR(pcnt_new_channel(s_unit, &chan_a_cfg, &chan_a),
|
||||
TAG, "chan a");
|
||||
|
||||
pcnt_chan_config_t chan_b_cfg = {
|
||||
.edge_gpio_num = ENCODER_PIN_B,
|
||||
.level_gpio_num = ENCODER_PIN_A,
|
||||
};
|
||||
pcnt_channel_handle_t chan_b = NULL;
|
||||
ESP_RETURN_ON_ERROR(pcnt_new_channel(s_unit, &chan_b_cfg, &chan_b),
|
||||
TAG, "chan b");
|
||||
|
||||
ESP_RETURN_ON_ERROR(
|
||||
pcnt_channel_set_edge_action(chan_a,
|
||||
PCNT_CHANNEL_EDGE_ACTION_DECREASE,
|
||||
PCNT_CHANNEL_EDGE_ACTION_INCREASE),
|
||||
TAG, "chan a edge");
|
||||
ESP_RETURN_ON_ERROR(
|
||||
pcnt_channel_set_level_action(chan_a,
|
||||
PCNT_CHANNEL_LEVEL_ACTION_KEEP,
|
||||
PCNT_CHANNEL_LEVEL_ACTION_INVERSE),
|
||||
TAG, "chan a level");
|
||||
ESP_RETURN_ON_ERROR(
|
||||
pcnt_channel_set_edge_action(chan_b,
|
||||
PCNT_CHANNEL_EDGE_ACTION_INCREASE,
|
||||
PCNT_CHANNEL_EDGE_ACTION_DECREASE),
|
||||
TAG, "chan b edge");
|
||||
ESP_RETURN_ON_ERROR(
|
||||
pcnt_channel_set_level_action(chan_b,
|
||||
PCNT_CHANNEL_LEVEL_ACTION_KEEP,
|
||||
PCNT_CHANNEL_LEVEL_ACTION_INVERSE),
|
||||
TAG, "chan b level");
|
||||
|
||||
ESP_RETURN_ON_ERROR(pcnt_unit_enable(s_unit), TAG, "enable");
|
||||
ESP_RETURN_ON_ERROR(pcnt_unit_clear_count(s_unit), TAG, "clear");
|
||||
ESP_RETURN_ON_ERROR(pcnt_unit_start(s_unit), TAG, "start");
|
||||
|
||||
if (xTaskCreate(button_task, "enc_btn", 3072, NULL, 4, NULL) != pdPASS) {
|
||||
ESP_LOGE(TAG, "button task create failed");
|
||||
return ESP_ERR_NO_MEM;
|
||||
}
|
||||
|
||||
ESP_LOGI(TAG, "ready: A=%d B=%d, button on expander P%d",
|
||||
ENCODER_PIN_A, ENCODER_PIN_B, PCF_PIN_ENCODER_SW);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
int encoder_take_delta(void)
|
||||
{
|
||||
if (!s_unit) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
int raw = 0;
|
||||
if (pcnt_unit_get_count(s_unit, &raw) != ESP_OK) {
|
||||
return 0;
|
||||
}
|
||||
pcnt_unit_clear_count(s_unit);
|
||||
|
||||
/*
|
||||
* Carry the remainder rather than discarding it. Dropping it would lose a
|
||||
* detent whenever a turn straddles two polls, which shows up as a knob
|
||||
* that occasionally ignores you.
|
||||
*/
|
||||
s_residual += raw;
|
||||
int detents = s_residual / COUNTS_PER_DETENT;
|
||||
s_residual -= detents * COUNTS_PER_DETENT;
|
||||
|
||||
return detents;
|
||||
}
|
||||
|
||||
encoder_button_event_t encoder_take_button_event(void)
|
||||
{
|
||||
encoder_button_event_t e = s_btn_event;
|
||||
s_btn_event = ENCODER_BTN_NONE;
|
||||
return e;
|
||||
}
|
||||
|
||||
bool encoder_button_is_down(void)
|
||||
{
|
||||
return s_btn_down;
|
||||
}
|
||||
Reference in new issue
Block a user