Files
rotaryThermo/main/drivers/encoder/encoder.c
T
mattandClaude Opus 5 4c7b120473 Fix TWDT starvation, vsync race, and UI responsiveness from review
Three bugs and two UX defects found on review, plus hardening:

- The control loop fed the task watchdog then slept exactly the watchdog
  period (both 10s), tripping the TWDT fractionally late on every single
  iteration. Sleep is now sliced 1s with a reset per slice. Also set
  CONFIG_ESP_TASK_WDT_PANIC=y - the sdkconfig comment claimed a hung task
  panics the chip, but nothing actually made that true.

- The LVGL flush called draw_bitmap (which only queues the framebuffer
  switch) and released LVGL immediately, letting it render the next frame
  into the buffer still being scanned out - the exact PRD 8.2 race the
  double-buffer design exists to prevent. display_present_frame() now
  blocks on a vsync handshake until the swap has latched.

- WiFi reconnect slept up to 30s inside the system event handler, stalling
  all queued WiFi/IP events. Moved to an esp_timer.

- The setpoint label, arc, and mode/room lines rendered the control loop's
  copy of state, which refreshes every 10s - every knob detent looked dead
  for up to 10 seconds. The UI now reads settings live; when the fallback
  ladder clamps the setpoint it shows both values. Room-select focus now
  shows which room is being selected.

- z2m ISO_8601_local timestamps carry a UTC offset the parser ignored,
  which would shift apparent reading age by the local offset (5-6h in the
  US) and permanently trip the fallback ladder. Offset is now honoured.

- Suppressed the cosmetic "control loop stopped" fault in the window
  between relay_init and the control task's first call-in at boot.

- Explicit pullups on the encoder A/B lines.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 20:05:51 -07:00

188 lines
5.7 KiB
C

#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;
}