| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330 |
- /**
- * Hardware button driver — interrupt-driven with timer-based debounce.
- *
- * Each button GPIO triggers an ISR on any edge. The ISR resets a
- * FreeRTOS software timer (the debounce window). When the timer
- * expires — meaning the signal has been stable for DEBOUNCE_MS —
- * the callback reads the GPIO and acts on the new state.
- *
- * Volume buttons support auto-repeat: after REPEAT_DELAY_MS held,
- * the action repeats every REPEAT_INTERVAL_MS.
- *
- * Button actions are dispatched to a dedicated task via a queue so that
- * playback_control functions (which may do mDNS + HTTP) never block
- * the FreeRTOS timer daemon.
- */
- #include "buttons.h"
- #include "audio_output.h"
- #include "playback_control.h"
- #include "spiram_task.h"
- #include "board_common.h"
- #include "driver/gpio.h"
- #include "esp_log.h"
- #include "esp_sleep.h"
- #include "freertos/FreeRTOS.h"
- #include "freertos/queue.h"
- #include "freertos/timers.h"
- static const char *TAG = "buttons";
- #define DEBOUNCE_MS 50 // Stable period before accepting state change
- #define REPEAT_DELAY_MS 500 // Hold duration before auto-repeat starts
- #define REPEAT_INTERVAL 200 // Auto-repeat interval for volume buttons
- #define LONG_PRESS_MS 3000 // Play/pause long press for deep sleep
- #define DOUBLE_CLICK_MS 350 // Window to detect a second play/pause press
- #define ACTION_QUEUE_LEN 8
- typedef enum {
- BTN_PLAY_PAUSE,
- BTN_VOLUME_UP,
- BTN_VOLUME_DOWN,
- BTN_NEXT,
- BTN_PREV,
- BTN_LONG_PRESS,
- // Actions below are dispatched directly (not 1:1 with a GPIO button)
- BTN_CHANNEL_CYCLE,
- BTN_COUNT
- } button_id_t;
- typedef struct {
- int gpio;
- bool pressed; // Debounced state
- bool repeatable; // Supports auto-repeat (volume buttons)
- TimerHandle_t debounce_timer;
- TimerHandle_t repeat_timer; // Only created for repeatable buttons
- TimerHandle_t long_press_timer; // Only created for play/pause button
- TimerHandle_t click_timer; // Double-click window (play/pause only)
- int click_count; // Presses seen within the double-click window
- } button_state_t;
- static button_state_t buttons[BTN_COUNT];
- static QueueHandle_t s_action_queue;
- // Post a button action to the dedicated task (safe from timer callbacks)
- static void post_button_action(button_id_t id) {
- int action = (int)id;
- // Non-blocking: drop if queue is full (better than blocking the timer task)
- xQueueSend(s_action_queue, &action, 0);
- }
- // Dedicated task that processes button actions — has enough stack for
- // mDNS discovery + HTTP requests that DACP requires.
- static void button_action_task(void *pvParameters) {
- (void)pvParameters;
- int action;
- while (1) {
- if (xQueueReceive(s_action_queue, &action, portMAX_DELAY) == pdTRUE) {
- switch ((button_id_t)action) {
- case BTN_PLAY_PAUSE:
- playback_control_play_pause();
- break;
- case BTN_VOLUME_UP:
- playback_control_volume_up();
- break;
- case BTN_VOLUME_DOWN:
- playback_control_volume_down();
- break;
- case BTN_NEXT:
- playback_control_next();
- break;
- case BTN_PREV:
- playback_control_prev();
- break;
- case BTN_LONG_PRESS:
- // Deep sleep — this is handled in the timer callback; just log
- // in case the action fires (shouldn't normally happen since
- // long_press_timer_cb calls esp_deep_sleep_start immediately)
- ESP_LOGW(TAG,
- "Long press action received (deep sleep already attempted)");
- break;
- case BTN_CHANNEL_CYCLE:
- audio_output_cycle_channel_mode();
- break;
- default:
- break;
- }
- }
- }
- }
- // Called when repeat timer fires (runs in timer daemon task)
- static void repeat_timer_cb(TimerHandle_t timer) {
- int id = (int)(intptr_t)pvTimerGetTimerID(timer);
- button_state_t *btn = &buttons[id];
- if (!btn->pressed) {
- return;
- }
- post_button_action((button_id_t)id);
- // After the initial REPEAT_DELAY_MS, switch to the faster interval
- xTimerChangePeriod(btn->repeat_timer, pdMS_TO_TICKS(REPEAT_INTERVAL), 0);
- }
- // Called when debounce timer expires (runs in timer daemon task)
- static void debounce_timer_cb(TimerHandle_t timer) {
- int id = (int)(intptr_t)pvTimerGetTimerID(timer);
- button_state_t *btn = &buttons[id];
- // Read settled GPIO state (active low)
- bool now_pressed = (gpio_get_level(btn->gpio) == 0);
- if (now_pressed == btn->pressed) {
- return; // No actual state change after debounce
- }
- btn->pressed = now_pressed;
- if (now_pressed) {
- if (btn->click_timer) {
- // Play/pause button: defer the action so we can tell a single click
- // (play/pause) from a double click (cycle channel mode). Count the
- // press and (re)start the double-click window; the click_timer_cb
- // dispatches the right action when the window expires.
- btn->click_count++;
- xTimerChangePeriod(btn->click_timer, pdMS_TO_TICKS(DOUBLE_CLICK_MS), 0);
- } else {
- // Other buttons — fire immediately via the dedicated task
- post_button_action((button_id_t)id);
- }
- // Start repeat timer for volume buttons (initial delay)
- if (btn->repeatable && btn->repeat_timer) {
- xTimerChangePeriod(btn->repeat_timer, pdMS_TO_TICKS(REPEAT_DELAY_MS), 0);
- }
- // Start long-press timer for play/pause button
- if (btn->long_press_timer) {
- xTimerStart(btn->long_press_timer, 0);
- }
- } else {
- // Button just released — stop repeat and long-press timers
- if (btn->repeat_timer) {
- xTimerStop(btn->repeat_timer, 0);
- }
- if (btn->long_press_timer) {
- xTimerStop(btn->long_press_timer, 0);
- }
- #if CONFIG_BTN_PLAY_PAUSE_DOUBLE_CLICK
- // If the click window expired while the button was still held, dispatch
- // play/pause on release (not on the timer — that would fire mid-hold).
- if (btn->click_timer && btn->click_count == 1 &&
- xTimerIsTimerActive(btn->click_timer) == pdFALSE) {
- btn->click_count = 0;
- post_button_action(BTN_PLAY_PAUSE);
- }
- #endif
- }
- }
- // Long-press timer callback — powers the board off on play/pause.
- // On boards with a battery power latch (Waveshare ESP32-S3-Touch-LCD-1.54)
- // board_power_off() releases the latch and enters deep sleep so the rail
- // collapses cleanly. On boards without a latch the weak default just enters
- // deep sleep; arm the play/pause button as the wakeup source for those.
- static void long_press_timer_cb(TimerHandle_t timer) {
- int id = (int)(intptr_t)pvTimerGetTimerID(timer);
- button_state_t *btn = &buttons[id];
- // Cancel any pending click so we don't also play/pause or cycle channels.
- if (btn->click_timer) {
- xTimerStop(btn->click_timer, 0);
- btn->click_count = 0;
- }
- ESP_LOGI(TAG, "Long press detected — powering off");
- gpio_wakeup_enable(CONFIG_BTN_PLAY_PAUSE_GPIO, GPIO_INTR_LOW_LEVEL);
- esp_sleep_enable_gpio_wakeup();
- board_power_off();
- }
- // Double-click window expired — dispatch based on how many presses we saw.
- // 1 press -> play/pause; 2 presses -> cycle channel mode (L/R/stereo).
- static void click_timer_cb(TimerHandle_t timer) {
- int id = (int)(intptr_t)pvTimerGetTimerID(timer);
- button_state_t *btn = &buttons[id];
- int count = btn->click_count;
- if (count >= 2) {
- btn->click_count = 0;
- post_button_action(BTN_CHANNEL_CYCLE);
- } else if (count == 1 && !btn->pressed) {
- btn->click_count = 0;
- post_button_action(BTN_PLAY_PAUSE);
- }
- // count == 1 && still pressed: long-press hold — defer play/pause to release
- }
- // GPIO ISR — just resets the debounce timer. Each new edge restarts the
- // debounce window so the callback only fires once bouncing stops.
- static void IRAM_ATTR gpio_isr_handler(void *arg) {
- int id = (int)(intptr_t)arg;
- BaseType_t woken = pdFALSE;
- xTimerResetFromISR(buttons[id].debounce_timer, &woken);
- if (woken) {
- portYIELD_FROM_ISR();
- }
- }
- static void configure_button(button_id_t id, int gpio, bool repeatable) {
- buttons[id].gpio = gpio;
- buttons[id].repeatable = repeatable;
- buttons[id].pressed = false;
- buttons[id].debounce_timer = NULL;
- buttons[id].repeat_timer = NULL;
- buttons[id].click_timer = NULL;
- buttons[id].click_count = 0;
- if (gpio < 0) {
- return;
- }
- // Create one-shot debounce timer
- buttons[id].debounce_timer =
- xTimerCreate("btn_db", pdMS_TO_TICKS(DEBOUNCE_MS), pdFALSE, // one-shot
- (void *)(intptr_t)id, debounce_timer_cb);
- // Create one-shot repeat timer for volume buttons (manually restarted)
- if (repeatable) {
- buttons[id].repeat_timer = xTimerCreate(
- "btn_rpt", pdMS_TO_TICKS(REPEAT_DELAY_MS), pdFALSE, // one-shot
- (void *)(intptr_t)id, repeat_timer_cb);
- }
- // Create one-shot long-press and double-click timers for play/pause button
- if (id == BTN_PLAY_PAUSE) {
- buttons[id].long_press_timer = xTimerCreate(
- "btn_lp", pdMS_TO_TICKS(LONG_PRESS_MS), pdFALSE, // one-shot
- (void *)(intptr_t)id, long_press_timer_cb);
- #if CONFIG_BTN_PLAY_PAUSE_DOUBLE_CLICK
- buttons[id].click_timer = xTimerCreate(
- "btn_clk", pdMS_TO_TICKS(DOUBLE_CLICK_MS), pdFALSE, // one-shot
- (void *)(intptr_t)id, click_timer_cb);
- #endif
- }
- // GPIOs 34-39 on ESP32 are input-only and lack internal pull-ups.
- // An external pull-up resistor is required for those pins.
- bool has_internal_pullup = (gpio < 34);
- gpio_config_t io_conf = {
- .pin_bit_mask = (1ULL << gpio),
- .mode = GPIO_MODE_INPUT,
- .pull_up_en =
- has_internal_pullup ? GPIO_PULLUP_ENABLE : GPIO_PULLUP_DISABLE,
- .pull_down_en = GPIO_PULLDOWN_DISABLE,
- .intr_type = GPIO_INTR_ANYEDGE,
- };
- gpio_config(&io_conf);
- gpio_isr_handler_add(gpio, gpio_isr_handler, (void *)(intptr_t)id);
- if (!has_internal_pullup) {
- ESP_LOGW(TAG, "Button %d on GPIO %d: no internal pull-up, needs external",
- id, gpio);
- }
- ESP_LOGI(TAG, "Button %d on GPIO %d (interrupt)", id, gpio);
- }
- esp_err_t buttons_init(void) {
- // Action-only IDs — not backed by GPIO
- buttons[BTN_LONG_PRESS].gpio = -1;
- buttons[BTN_CHANNEL_CYCLE].gpio = -1;
- // Ensure the shared GPIO ISR service is installed (idempotent)
- esp_err_t err = board_gpio_isr_init();
- if (err != ESP_OK) {
- return err;
- }
- // Configure each button from Kconfig (adds ISR handlers)
- configure_button(BTN_PLAY_PAUSE, CONFIG_BTN_PLAY_PAUSE_GPIO, false);
- configure_button(BTN_VOLUME_UP, CONFIG_BTN_VOLUME_UP_GPIO, true);
- configure_button(BTN_VOLUME_DOWN, CONFIG_BTN_VOLUME_DOWN_GPIO, true);
- configure_button(BTN_NEXT, CONFIG_BTN_NEXT_GPIO, false);
- configure_button(BTN_PREV, CONFIG_BTN_PREV_GPIO, false);
- bool any_configured = false;
- for (int i = BTN_PLAY_PAUSE; i <= BTN_PREV; i++) {
- if (buttons[i].gpio >= 0) {
- any_configured = true;
- break;
- }
- }
- if (!any_configured) {
- ESP_LOGI(TAG, "No buttons configured");
- return ESP_OK;
- }
- // Queue + task for dispatching actions off the timer daemon task.
- // Stack 4096 is enough for mDNS + HTTP operations in DACP.
- s_action_queue = xQueueCreate(ACTION_QUEUE_LEN, sizeof(int));
- task_create_spiram(button_action_task, "btn_act", 4096, NULL, 5, NULL, NULL);
- ESP_LOGI(TAG, "Buttons initialized (interrupt-driven)");
- return ESP_OK;
- }
|