Phase 7: EC11 encoder, life counter views, WS2812B RMT + LEDC buzzer feedback, E2E UI state machine; document build/hardware notes
This commit is contained in:
@@ -8,5 +8,9 @@ idf_component_register(SRCS
|
||||
"wifi_manager.c"
|
||||
"scryfall_client.c"
|
||||
"web_server.c"
|
||||
"encoder.c"
|
||||
"led_manager.c"
|
||||
"buzzer.c"
|
||||
"app_state.c"
|
||||
INCLUDE_DIRS "."
|
||||
)
|
||||
91
main/app_state.c
Normal file
91
main/app_state.c
Normal file
@@ -0,0 +1,91 @@
|
||||
/*
|
||||
* MTG RFID Companion - System state machine
|
||||
*/
|
||||
|
||||
#include <string.h>
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/semphr.h"
|
||||
#include "app_state.h"
|
||||
|
||||
static SemaphoreHandle_t s_lock;
|
||||
static app_state_t s_view = APP_STATE_SCANNER;
|
||||
static int32_t s_life = 20;
|
||||
static int32_t s_life_preset = 20;
|
||||
|
||||
const char *app_state_name(app_state_t state)
|
||||
{
|
||||
switch (state) {
|
||||
case APP_STATE_BOOT: return "BOOT";
|
||||
case APP_STATE_SCANNER: return "SCANNER";
|
||||
case APP_STATE_CARD_VIEW: return "CARD_VIEW";
|
||||
case APP_STATE_LIFE_COUNTER: return "LIFE_COUNTER";
|
||||
case APP_STATE_REGISTRATION: return "REGISTRATION";
|
||||
}
|
||||
return "UNKNOWN";
|
||||
}
|
||||
|
||||
static void ensure_lock(void)
|
||||
{
|
||||
if (s_lock == NULL) {
|
||||
s_lock = xSemaphoreCreateMutex();
|
||||
}
|
||||
}
|
||||
|
||||
void app_set_life(int32_t life)
|
||||
{
|
||||
ensure_lock();
|
||||
xSemaphoreTake(s_lock, portMAX_DELAY);
|
||||
s_life = life;
|
||||
xSemaphoreGive(s_lock);
|
||||
}
|
||||
|
||||
int32_t app_get_life(void)
|
||||
{
|
||||
ensure_lock();
|
||||
xSemaphoreTake(s_lock, portMAX_DELAY);
|
||||
int32_t v = s_life;
|
||||
xSemaphoreGive(s_lock);
|
||||
return v;
|
||||
}
|
||||
|
||||
void app_set_life_preset(int32_t preset)
|
||||
{
|
||||
ensure_lock();
|
||||
xSemaphoreTake(s_lock, portMAX_DELAY);
|
||||
s_life_preset = preset;
|
||||
s_life = preset;
|
||||
xSemaphoreGive(s_lock);
|
||||
}
|
||||
|
||||
int32_t app_get_life_preset(void)
|
||||
{
|
||||
ensure_lock();
|
||||
xSemaphoreTake(s_lock, portMAX_DELAY);
|
||||
int32_t v = s_life_preset;
|
||||
xSemaphoreGive(s_lock);
|
||||
return v;
|
||||
}
|
||||
|
||||
void app_set_view(app_state_t view)
|
||||
{
|
||||
ensure_lock();
|
||||
xSemaphoreTake(s_lock, portMAX_DELAY);
|
||||
s_view = view;
|
||||
xSemaphoreGive(s_lock);
|
||||
}
|
||||
|
||||
app_state_t app_get_view(void)
|
||||
{
|
||||
ensure_lock();
|
||||
xSemaphoreTake(s_lock, portMAX_DELAY);
|
||||
app_state_t v = s_view;
|
||||
xSemaphoreGive(s_lock);
|
||||
return v;
|
||||
}
|
||||
|
||||
void app_enter_keyed_view(app_state_t view)
|
||||
{
|
||||
if (view == APP_STATE_LIFE_COUNTER || view == APP_STATE_CARD_VIEW) {
|
||||
app_set_view(view);
|
||||
}
|
||||
}
|
||||
40
main/app_state.h
Normal file
40
main/app_state.h
Normal file
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* MTG RFID Companion - System state machine
|
||||
*
|
||||
* Phase 7 Step 7.2: State transitions CARD_VIEW <-> LIFE_COUNTER, plus the
|
||||
* boot/scanner/registration states. Driven by the UI task on Core 1.
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
typedef enum {
|
||||
APP_STATE_BOOT = 0,
|
||||
APP_STATE_SCANNER,
|
||||
APP_STATE_CARD_VIEW,
|
||||
APP_STATE_LIFE_COUNTER,
|
||||
APP_STATE_REGISTRATION,
|
||||
} app_state_t;
|
||||
|
||||
const char *app_state_name(app_state_t state);
|
||||
|
||||
/* Life counter state */
|
||||
void app_set_life(int32_t life);
|
||||
int32_t app_get_life(void);
|
||||
void app_set_life_preset(int32_t preset);
|
||||
int32_t app_get_life_preset(void);
|
||||
|
||||
/* Which "screen" is active - CARD_VIEW vs LIFE_COUNTER. */
|
||||
void app_set_view(app_state_t view);
|
||||
app_state_t app_get_view(void);
|
||||
void app_enter_keyed_view(app_state_t view);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
84
main/buzzer.c
Normal file
84
main/buzzer.c
Normal file
@@ -0,0 +1,84 @@
|
||||
/*
|
||||
* MTG RFID Companion - Piezo buzzer driver (LEDC PWM)
|
||||
*/
|
||||
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/task.h"
|
||||
#include "esp_log.h"
|
||||
#include "driver/ledc.h"
|
||||
#include "hw_pins.h"
|
||||
#include "buzzer.h"
|
||||
|
||||
static const char *TAG = "buzzer";
|
||||
|
||||
#define BUZZER_SPEED_MODE LEDC_LOW_SPEED_MODE
|
||||
#define BUZZER_TIMER_NUM LEDC_TIMER_0
|
||||
#define BUZZER_CHANNEL LEDC_CHANNEL_0
|
||||
|
||||
esp_err_t buzzer_init(void)
|
||||
{
|
||||
ledc_timer_config_t timer_cfg = {
|
||||
.speed_mode = BUZZER_SPEED_MODE,
|
||||
.timer_num = BUZZER_TIMER_NUM,
|
||||
.duty_resolution = LEDC_TIMER_13_BIT,
|
||||
.freq_hz = 2000,
|
||||
.clk_cfg = LEDC_AUTO_CLK,
|
||||
};
|
||||
esp_err_t ret = ledc_timer_config(&timer_cfg);
|
||||
if (ret != ESP_OK) {
|
||||
return ret;
|
||||
}
|
||||
|
||||
ledc_channel_config_t chan_cfg = {
|
||||
.gpio_num = HW_BUZZER_GPIO,
|
||||
.speed_mode = BUZZER_SPEED_MODE,
|
||||
.channel = BUZZER_CHANNEL,
|
||||
.timer_sel = BUZZER_TIMER_NUM,
|
||||
.duty = 0,
|
||||
.hpoint = 0,
|
||||
};
|
||||
ret = ledc_channel_config(&chan_cfg);
|
||||
if (ret != ESP_OK) {
|
||||
return ret;
|
||||
}
|
||||
|
||||
ESP_LOGI(TAG, "buzzer ready on GPIO %d", HW_BUZZER_GPIO);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t buzzer_start(uint32_t freq_hz, uint32_t duty_percent)
|
||||
{
|
||||
if (freq_hz == 0) {
|
||||
return buzzer_stop();
|
||||
}
|
||||
uint32_t duty = ((uint32_t)1 << 13) * duty_percent / 100u;
|
||||
if (duty_percent >= 100) {
|
||||
duty = ((uint32_t)1 << 13);
|
||||
}
|
||||
|
||||
esp_err_t ret = ledc_set_freq(BUZZER_SPEED_MODE, BUZZER_TIMER_NUM, freq_hz);
|
||||
if (ret != ESP_OK) {
|
||||
return ret;
|
||||
}
|
||||
ret = ledc_set_duty(BUZZER_SPEED_MODE, BUZZER_CHANNEL, duty);
|
||||
if (ret != ESP_OK) {
|
||||
return ret;
|
||||
}
|
||||
return ledc_update_duty(BUZZER_SPEED_MODE, BUZZER_CHANNEL);
|
||||
}
|
||||
|
||||
esp_err_t buzzer_stop(void)
|
||||
{
|
||||
ledc_set_duty(BUZZER_SPEED_MODE, BUZZER_CHANNEL, 0);
|
||||
return ledc_update_duty(BUZZER_SPEED_MODE, BUZZER_CHANNEL);
|
||||
}
|
||||
|
||||
esp_err_t buzzer_chirp(uint32_t freq_hz, uint32_t duration_ms)
|
||||
{
|
||||
esp_err_t ret = buzzer_start(freq_hz, 30);
|
||||
if (ret != ESP_OK) {
|
||||
return ret;
|
||||
}
|
||||
vTaskDelay(pdMS_TO_TICKS(duration_ms));
|
||||
return buzzer_stop();
|
||||
}
|
||||
39
main/buzzer.h
Normal file
39
main/buzzer.h
Normal file
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
* MTG RFID Companion - Piezo buzzer driver (LEDC PWM)
|
||||
*
|
||||
* Phase 7 Step 7.4: Subtle audio cues on GPIO 14.
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "esp_err.h"
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/**
|
||||
* @brief Initialize LEDC PWM channel for the buzzer.
|
||||
*/
|
||||
esp_err_t buzzer_init(void);
|
||||
|
||||
/**
|
||||
* @brief Start a tone at the given frequency.
|
||||
*/
|
||||
esp_err_t buzzer_start(uint32_t freq_hz, uint32_t duty_percent);
|
||||
|
||||
/**
|
||||
* @brief Silence the buzzer.
|
||||
*/
|
||||
esp_err_t buzzer_stop(void);
|
||||
|
||||
/**
|
||||
* @brief Play a short chirp (blocking).
|
||||
* @param freq_hz Tone frequency
|
||||
* @param duration_ms Tone length in ms
|
||||
*/
|
||||
esp_err_t buzzer_chirp(uint32_t freq_hz, uint32_t duration_ms);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
@@ -155,6 +155,48 @@ void display_put_text_center(int y, const char *str, uint16_t fg, uint16_t bg)
|
||||
display_put_text(x, y, str, fg, bg);
|
||||
}
|
||||
|
||||
void display_draw_big_text(int x, int y, const char *str, int scale, uint16_t fg, uint16_t bg)
|
||||
{
|
||||
int cell_w = FONT5X7_W * scale;
|
||||
int cell_h = FONT5X7_H * scale;
|
||||
int num = (int)strlen(str);
|
||||
if (num <= 0) {
|
||||
return;
|
||||
}
|
||||
if (x + num * (cell_w + scale) - scale > DISP_W || y + cell_h > DISP_H) {
|
||||
return;
|
||||
}
|
||||
|
||||
/* Staging buffer for one glyph, scaled */
|
||||
uint16_t *glyph_buf = malloc((size_t)cell_w * cell_h * sizeof(uint16_t));
|
||||
if (glyph_buf == NULL) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (int i = 0; i < num; i++) {
|
||||
char c = str[i];
|
||||
if (c < FONT5X7_FIRST_CHAR || c > FONT5X7_LAST_CHAR) {
|
||||
x += cell_w + scale;
|
||||
continue;
|
||||
}
|
||||
const uint8_t *g = font5x7[c - FONT5X7_FIRST_CHAR];
|
||||
|
||||
for (int gy = 0; gy < FONT5X7_H; gy++) {
|
||||
for (int gx = 0; gx < FONT5X7_W; gx++) {
|
||||
uint16_t color = ((g[gy] >> gx) & 1) ? fg : bg;
|
||||
for (int sy = 0; sy < scale; sy++) {
|
||||
for (int sx = 0; sx < scale; sx++) {
|
||||
glyph_buf[(gy * scale + sy) * cell_w + (gx * scale + sx)] = color;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
display_draw_bitmap(x, y, x + cell_w, y + cell_h, glyph_buf);
|
||||
x += cell_w + scale;
|
||||
}
|
||||
free(glyph_buf);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* tjpgd streaming decode */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
@@ -60,6 +60,12 @@ int display_put_text(int x, int y, const char *str, uint16_t fg, uint16_t bg);
|
||||
*/
|
||||
void display_put_text_center(int y, const char *str, uint16_t fg, uint16_t bg);
|
||||
|
||||
/**
|
||||
* @brief Draw large digits (life counter style) using a scaled 5x7 pattern.
|
||||
* @param x,y Top-left corner; scale = pixel size of one 5x7 cell.
|
||||
*/
|
||||
void display_draw_big_text(int x, int y, const char *str, int scale, uint16_t fg, uint16_t bg);
|
||||
|
||||
/**
|
||||
* @brief Stream-decode a JPEG from LittleFS and draw it at the top-left.
|
||||
* Uses tjpgd with a line-by-line output into the panel DMA path.
|
||||
|
||||
103
main/encoder.c
Normal file
103
main/encoder.c
Normal file
@@ -0,0 +1,103 @@
|
||||
/*
|
||||
* MTG RFID Companion - EC11 rotary encoder driver
|
||||
*/
|
||||
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/task.h"
|
||||
#include "esp_log.h"
|
||||
#include "driver/gpio.h"
|
||||
#include "hw_pins.h"
|
||||
#include "encoder.h"
|
||||
|
||||
static const char *TAG = "encoder";
|
||||
|
||||
static volatile int32_t s_delta = 0;
|
||||
static volatile bool s_button_pressed = false;
|
||||
static volatile uint32_t s_last_sw_tick = 0;
|
||||
|
||||
#define SW_DEBOUNCE_MS (35)
|
||||
|
||||
/* Quadrature decode: on each CLK edge, sample DT.
|
||||
* DT == CLK level => one direction, != => other.
|
||||
* Physical wiring is corrected here if a knob feels inverted. */
|
||||
#define DIR_CLK_HIGH (1)
|
||||
/*
|
||||
* If the direction comes out reversed, flip this to -1.
|
||||
*/
|
||||
#define ENC_DIRECTION (1)
|
||||
|
||||
static void IRAM_ATTR encoder_isr(void *arg)
|
||||
{
|
||||
uint32_t gpio = (uint32_t)(uintptr_t)arg;
|
||||
|
||||
if (gpio == HW_ENC_CLK_GPIO) {
|
||||
int dt = gpio_get_level(HW_ENC_DT_GPIO);
|
||||
int clk = gpio_get_level(HW_ENC_CLK_GPIO);
|
||||
/* Increment on one edge per detent; using level comparison on both edges
|
||||
* doubles the count, so we only act when CLK transitions are detected as
|
||||
* ticks. To keep this robust we count on every edge and divide the
|
||||
* sense: signed direction * 1. */
|
||||
s_delta += (dt == clk) ? (ENC_DIRECTION) : (-ENC_DIRECTION);
|
||||
} else if (gpio == HW_ENC_SW_GPIO) {
|
||||
unsigned int now = (unsigned int)xTaskGetTickCountFromISR();
|
||||
if ((now - s_last_sw_tick) > pdMS_TO_TICKS(SW_DEBOUNCE_MS)) {
|
||||
s_last_sw_tick = now;
|
||||
s_button_pressed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static esp_err_t config_pin(gpio_num_t pin, gpio_int_type_t intr_type)
|
||||
{
|
||||
gpio_config_t cfg = {
|
||||
.pin_bit_mask = 1ULL << pin,
|
||||
.mode = GPIO_MODE_INPUT,
|
||||
.pull_up_en = GPIO_PULLUP_ENABLE,
|
||||
.pull_down_en = GPIO_PULLDOWN_DISABLE,
|
||||
.intr_type = intr_type,
|
||||
};
|
||||
return gpio_config(&cfg);
|
||||
}
|
||||
|
||||
esp_err_t encoder_init(void)
|
||||
{
|
||||
gpio_install_isr_service(ESP_INTR_FLAG_LEVEL1);
|
||||
|
||||
ESP_ERROR_CHECK(config_pin(HW_ENC_CLK_GPIO, GPIO_INTR_POSEDGE));
|
||||
ESP_ERROR_CHECK(config_pin(HW_ENC_DT_GPIO, GPIO_INTR_DISABLE));
|
||||
ESP_ERROR_CHECK(config_pin(HW_ENC_SW_GPIO, GPIO_INTR_NEGEDGE));
|
||||
|
||||
ESP_ERROR_CHECK(gpio_isr_handler_add(HW_ENC_CLK_GPIO, encoder_isr, (void *)(uintptr_t)HW_ENC_CLK_GPIO));
|
||||
ESP_ERROR_CHECK(gpio_isr_handler_add(HW_ENC_SW_GPIO, encoder_isr, (void *)(uintptr_t)HW_ENC_SW_GPIO));
|
||||
|
||||
ESP_LOGI(TAG, "EC11 encoder ready (CLK=%d DT=%d SW=%d)",
|
||||
HW_ENC_CLK_GPIO, HW_ENC_DT_GPIO, HW_ENC_SW_GPIO);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
bool encoder_has_delta(void)
|
||||
{
|
||||
return s_delta != 0;
|
||||
}
|
||||
|
||||
int32_t encoder_get_delta(void)
|
||||
{
|
||||
return (int32_t)s_delta;
|
||||
}
|
||||
|
||||
int32_t encoder_get_delta_and_reset(void)
|
||||
{
|
||||
int32_t v = (int32_t)s_delta;
|
||||
s_delta = 0;
|
||||
return v;
|
||||
}
|
||||
|
||||
bool encoder_button_pressed(void)
|
||||
{
|
||||
return s_button_pressed;
|
||||
}
|
||||
|
||||
void encoder_clear_button(void)
|
||||
{
|
||||
s_button_pressed = false;
|
||||
}
|
||||
51
main/encoder.h
Normal file
51
main/encoder.h
Normal file
@@ -0,0 +1,51 @@
|
||||
/*
|
||||
* MTG RFID Companion - EC11 rotary encoder driver
|
||||
*
|
||||
* Phase 7 Step 7.1: Interrupt-driven quadrature decode on GPIO CLK/DT with
|
||||
* a debounced push button on SW (active low).
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "esp_err.h"
|
||||
#include <stdbool.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/**
|
||||
* @brief Install GPIO ISR service and configure encoder pins.
|
||||
*/
|
||||
esp_err_t encoder_init(void);
|
||||
|
||||
/**
|
||||
* @brief True if the encoder has rotated since the last check.
|
||||
*/
|
||||
bool encoder_has_delta(void);
|
||||
|
||||
/**
|
||||
* @brief Return the accumulated rotation steps and reset it.
|
||||
* Positive = clockwise, negative = counter-clockwise (configurable).
|
||||
*/
|
||||
int32_t encoder_get_delta_and_reset(void);
|
||||
|
||||
/**
|
||||
* @brief Return current accumulator without resetting.
|
||||
*/
|
||||
int32_t encoder_get_delta(void);
|
||||
|
||||
/**
|
||||
* @brief True if the push button was pressed since last poll.
|
||||
*/
|
||||
bool encoder_button_pressed(void);
|
||||
|
||||
/**
|
||||
* @brief Clear the button-pressed flag.
|
||||
*/
|
||||
void encoder_clear_button(void);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
90
main/led_manager.c
Normal file
90
main/led_manager.c
Normal file
@@ -0,0 +1,90 @@
|
||||
/*
|
||||
* MTG RFID Companion - WS2812B Neopixel LED manager (RMT)
|
||||
*/
|
||||
|
||||
#include "esp_log.h"
|
||||
#include "driver/rmt_tx.h"
|
||||
#include "driver/rmt_encoder.h"
|
||||
#include "hw_pins.h"
|
||||
#include "led_manager.h"
|
||||
|
||||
static const char *TAG = "led";
|
||||
|
||||
static rmt_channel_handle_t s_tx_chan = NULL;
|
||||
static rmt_encoder_handle_t s_enc = NULL;
|
||||
|
||||
esp_err_t led_manager_init(void)
|
||||
{
|
||||
if (s_tx_chan != NULL) {
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
rmt_tx_channel_config_t chan_cfg = {
|
||||
.gpio_num = HW_NEOPIXEL_GPIO,
|
||||
.clk_src = RMT_CLK_SRC_DEFAULT,
|
||||
.resolution_hz = 10 * 1000 * 1000, /* 100 ns per tick */
|
||||
.mem_block_symbols = 64,
|
||||
.trans_queue_depth = 4,
|
||||
};
|
||||
esp_err_t ret = rmt_new_tx_channel(&chan_cfg, &s_tx_chan);
|
||||
if (ret != ESP_OK) {
|
||||
ESP_LOGE(TAG, "rmt_new_tx_channel failed: %s", esp_err_to_name(ret));
|
||||
return ret;
|
||||
}
|
||||
|
||||
rmt_bytes_encoder_config_t enc_cfg = {
|
||||
.bit0 = {
|
||||
.duration0 = 4, /* T0H ~ 400 ns */
|
||||
.level0 = 1,
|
||||
.duration1 = 8, /* T0L ~ 800 ns */
|
||||
.level1 = 0,
|
||||
},
|
||||
.bit1 = {
|
||||
.duration0 = 9, /* T1H ~ 900 ns */
|
||||
.level0 = 1,
|
||||
.duration1 = 4, /* T1L ~ 400 ns */
|
||||
.level1 = 0,
|
||||
},
|
||||
};
|
||||
ret = rmt_new_bytes_encoder(&enc_cfg, &s_enc);
|
||||
if (ret != ESP_OK) {
|
||||
ESP_LOGE(TAG, "rmt_new_bytes_encoder failed: %s", esp_err_to_name(ret));
|
||||
return ret;
|
||||
}
|
||||
|
||||
ret = rmt_enable(s_tx_chan);
|
||||
if (ret != ESP_OK) {
|
||||
ESP_LOGE(TAG, "rmt_enable failed: %s", esp_err_to_name(ret));
|
||||
return ret;
|
||||
}
|
||||
|
||||
ESP_LOGI(TAG, "WS2812B LED ready on GPIO %d", HW_NEOPIXEL_GPIO);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t led_manager_set_color(uint8_t r, uint8_t g, uint8_t b)
|
||||
{
|
||||
if (s_tx_chan == NULL) {
|
||||
esp_err_t ret = led_manager_init();
|
||||
if (ret != ESP_OK) {
|
||||
return ret;
|
||||
}
|
||||
}
|
||||
|
||||
/* WS2812B expects GRB byte order */
|
||||
uint8_t txbuf[3] = { g, r, b };
|
||||
|
||||
rmt_transmit_config_t tx_cfg = {
|
||||
.loop_count = 0, /* one-shot; LED latches and holds the level */
|
||||
};
|
||||
esp_err_t ret = rmt_transmit(s_tx_chan, s_enc, txbuf, sizeof(txbuf), &tx_cfg);
|
||||
if (ret != ESP_OK) {
|
||||
return ret;
|
||||
}
|
||||
return rmt_tx_wait_all_done(s_tx_chan, pdMS_TO_TICKS(50));
|
||||
}
|
||||
|
||||
esp_err_t led_manager_off(void)
|
||||
{
|
||||
return led_manager_set_color(0, 0, 0);
|
||||
}
|
||||
33
main/led_manager.h
Normal file
33
main/led_manager.h
Normal file
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* MTG RFID Companion - WS2812B Neopixel LED manager (RMT)
|
||||
*
|
||||
* Phase 7 Step 7.3: Single WS2812B driver on GPIO 27. LED holds the last
|
||||
* transmitted color until it is overwritten.
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "esp_err.h"
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/**
|
||||
* @brief Initialize RMT TX channel for the Neopixel.
|
||||
*/
|
||||
esp_err_t led_manager_init(void);
|
||||
|
||||
/**
|
||||
* @brief Set the LED color immediately (GRB order internally).
|
||||
*/
|
||||
esp_err_t led_manager_set_color(uint8_t r, uint8_t g, uint8_t b);
|
||||
|
||||
/**
|
||||
* @brief Turn the LED off.
|
||||
*/
|
||||
esp_err_t led_manager_off(void);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
326
main/ui_task.c
326
main/ui_task.c
@@ -1,23 +1,264 @@
|
||||
/*
|
||||
* MTG RFID Companion - UI task (Core 1)
|
||||
*
|
||||
* Phase 4/7: renders the boot demo, the scanner idle screen, the card view
|
||||
* (cover art + metadata) and the interactive life counter, reacting to RFID,
|
||||
* Scryfall cache results, and the rotary encoder.
|
||||
*/
|
||||
|
||||
#include <string.h>
|
||||
#include <stdlib.h>
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/task.h"
|
||||
#include "esp_log.h"
|
||||
#include "cJSON.h"
|
||||
#include "display_manager.h"
|
||||
#include "ui_font.h"
|
||||
#include "rfid_manager.h"
|
||||
#include "storage_manager.h"
|
||||
#include "scryfall_client.h"
|
||||
#include "app_state.h"
|
||||
#include "encoder.h"
|
||||
#include "led_manager.h"
|
||||
#include "buzzer.h"
|
||||
#include "ui_task.h"
|
||||
|
||||
static const char *TAG = "ui";
|
||||
|
||||
#define UI_TASK_STACK (8 * 1024)
|
||||
#define UI_TASK_STACK (10 * 1024)
|
||||
#define UI_TASK_PRIO (5)
|
||||
#define UI_CORE (1)
|
||||
|
||||
#define LED_HOLD_MS (2500)
|
||||
#define FETCH_POLL_MS (100)
|
||||
|
||||
static TaskHandle_t s_ui_task = NULL;
|
||||
|
||||
static void draw_color_bars(void)
|
||||
static char s_uid[RFID_UID_STR_MAX_LEN] = {0};
|
||||
static char s_card_name[128] = {0};
|
||||
static bool s_card_loaded = false; /* card view currently holds a card */
|
||||
static bool s_waiting_fetch = false;
|
||||
static app_state_t s_prev_view = APP_STATE_SCANNER;
|
||||
static uint32_t s_led_off_tick = 0;
|
||||
|
||||
static const char *get_json_str(cJSON *root, const char *key, const char *fallback);
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Views */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
static void view_scanner(void)
|
||||
{
|
||||
display_fill(COLOR_BLACK);
|
||||
display_put_text_center(40, "SCANNER", COLOR_GRAY, COLOR_BLACK);
|
||||
display_put_text_center(70, "PLACE A CARD", COLOR_WHITE, COLOR_BLACK);
|
||||
if (s_uid[0] != '\0') {
|
||||
char line[96];
|
||||
snprintf(line, sizeof(line), "LAST UID %s", s_uid);
|
||||
display_put_text_center(100, line, COLOR_CYAN, COLOR_BLACK);
|
||||
}
|
||||
display_put_text_center(250, "PRESS ENC: LIFETOTAL", COLOR_GRAY, COLOR_BLACK);
|
||||
display_put_text_center(264, "UNMAPPED: WEB PAIRING", COLOR_GRAY, COLOR_BLACK);
|
||||
}
|
||||
|
||||
static void view_card(void)
|
||||
{
|
||||
char json_path[72];
|
||||
snprintf(json_path, sizeof(json_path), STORAGE_CARDS_DIR "/%s.json", s_uid);
|
||||
|
||||
char *buf = NULL;
|
||||
size_t len = 0;
|
||||
if (storage_read_text(json_path, &buf, &len) != ESP_OK || buf == NULL) {
|
||||
free(buf);
|
||||
display_fill(COLOR_BLACK);
|
||||
display_put_text_center(80, "CARD DATA MISSING", COLOR_RED, COLOR_BLACK);
|
||||
return;
|
||||
}
|
||||
cJSON *root = cJSON_Parse(buf);
|
||||
free(buf);
|
||||
if (root == NULL) {
|
||||
display_fill(COLOR_BLACK);
|
||||
display_put_text_center(80, "PARSE FAILED", COLOR_RED, COLOR_BLACK);
|
||||
return;
|
||||
}
|
||||
|
||||
const char *name = get_json_str(root, "name", s_card_name[0] ? s_card_name : "?");
|
||||
const char *mana = get_json_str(root, "mana_cost", "");
|
||||
const char *type = get_json_str(root, "type", "");
|
||||
const char *power = get_json_str(root, "power", "");
|
||||
const char *tough = get_json_str(root, "toughness", "");
|
||||
const char *oracle = get_json_str(root, "oracle", "");
|
||||
const char *price = get_json_str(root, "price_usd", "");
|
||||
|
||||
/* Cover art fullscreen if present, else plain black */
|
||||
char jpg_path[72];
|
||||
snprintf(jpg_path, sizeof(jpg_path), STORAGE_CARDS_DIR "/%s.jpg", s_uid);
|
||||
if (storage_file_exists(jpg_path)) {
|
||||
display_show_jpeg_file(jpg_path);
|
||||
} else {
|
||||
display_fill(COLOR_BLACK);
|
||||
}
|
||||
|
||||
/* Metadata strip at the bottom */
|
||||
display_fill_rect(0, DISP_H - 86, DISP_W, DISP_H, RGB565(0, 0, 0));
|
||||
display_fill_rect(0, DISP_H - 88, DISP_W, DISP_H - 86, COLOR_YELLOW);
|
||||
display_put_text(6, DISP_H - 80, name, COLOR_YELLOW, COLOR_BLACK);
|
||||
|
||||
char line[128];
|
||||
snprintf(line, sizeof(line), "%s", type);
|
||||
display_put_text(6, DISP_H - 66, line, COLOR_WHITE, COLOR_BLACK);
|
||||
|
||||
if (power[0] || tough[0]) {
|
||||
snprintf(line, sizeof(line), "P/T %s / %s", power, tough);
|
||||
} else {
|
||||
snprintf(line, sizeof(line), "CARD");
|
||||
}
|
||||
display_put_text(6, DISP_H - 52, line, COLOR_CYAN, COLOR_BLACK);
|
||||
|
||||
if (price[0]) {
|
||||
snprintf(line, sizeof(line), "PRICE $%s", price);
|
||||
display_put_text(6, DISP_H - 38, line, COLOR_MAGENTA, COLOR_BLACK);
|
||||
}
|
||||
if (mana[0]) {
|
||||
char m[64];
|
||||
snprintf(m, sizeof(m), "MANA %s", mana);
|
||||
display_put_text(6, DISP_H - 24, m, COLOR_GREEN, COLOR_BLACK);
|
||||
}
|
||||
|
||||
(void)oracle;
|
||||
cJSON_Delete(root);
|
||||
}
|
||||
|
||||
static const char *get_json_str(cJSON *root, const char *key, const char *fallback)
|
||||
{
|
||||
cJSON *item = cJSON_GetObjectItem(root, key);
|
||||
if (item != NULL && cJSON_IsString(item) && item->valuestring != NULL) {
|
||||
return item->valuestring;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
static void view_life(void)
|
||||
{
|
||||
display_fill(COLOR_BLACK);
|
||||
display_fill_rect(0, 0, DISP_W, 4, COLOR_YELLOW);
|
||||
display_put_text_center(10, "LIFETOTAL", COLOR_GRAY, COLOR_BLACK);
|
||||
|
||||
int32_t life = app_get_life();
|
||||
char buf[16];
|
||||
snprintf(buf, sizeof(buf), "%ld", (long)(life < 0 ? 0 : life));
|
||||
|
||||
int scale = 6;
|
||||
int text_w = ((int)strlen(buf) * (FONT5X7_W + 1) - 1) * scale;
|
||||
int x = (DISP_W - text_w) / 2;
|
||||
if (x < 0) x = 0;
|
||||
uint16_t fg = life < 10 ? COLOR_RED : life <= 15 ? COLOR_YELLOW : COLOR_GREEN;
|
||||
display_draw_big_text(x, 90, buf, scale, fg, COLOR_BLACK);
|
||||
|
||||
display_put_text_center(240, "ROTATE: ADJUST", COLOR_GRAY, COLOR_BLACK);
|
||||
char preset[48];
|
||||
snprintf(preset, sizeof(preset), "PRESET %ld (PRESS RESETS)", (long)app_get_life_preset());
|
||||
display_put_text_center(256, preset, COLOR_GRAY, COLOR_BLACK);
|
||||
display_put_text_center(272, "PRESS AGAIN: CARD VIEW", COLOR_GRAY, COLOR_BLACK);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Card events */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
static void led_for_card(const char *mana)
|
||||
{
|
||||
uint8_t r = 120, g = 120, b = 120; /* colorless fallback */
|
||||
if (mana != NULL) {
|
||||
if (strstr(mana, "{W}")) { r = 255; g = 255; b = 255; }
|
||||
else if (strstr(mana, "{U}")) { r = 40; g = 100; b = 255; }
|
||||
else if (strstr(mana, "{B}")) { r = 90; g = 20; b = 150; }
|
||||
else if (strstr(mana, "{R}")) { r = 255; g = 60; b = 20; }
|
||||
else if (strstr(mana, "{G}")) { r = 20; g = 200; b = 60; }
|
||||
}
|
||||
led_manager_set_color(r, g, b);
|
||||
s_led_off_tick = xTaskGetTickCount() + pdMS_TO_TICKS(LED_HOLD_MS);
|
||||
}
|
||||
|
||||
static void read_cached_color(const char *uid)
|
||||
{
|
||||
char json_path[72];
|
||||
snprintf(json_path, sizeof(json_path), STORAGE_CARDS_DIR "/%s.json", uid);
|
||||
char *buf = NULL;
|
||||
size_t len = 0;
|
||||
if (storage_read_text(json_path, &buf, &len) == ESP_OK && buf != NULL) {
|
||||
cJSON *root = cJSON_Parse(buf);
|
||||
if (root != NULL) {
|
||||
led_for_card(get_json_str(root, "mana_cost", ""));
|
||||
cJSON_Delete(root);
|
||||
}
|
||||
free(buf);
|
||||
}
|
||||
}
|
||||
|
||||
static void handle_new_card(const char *uid)
|
||||
{
|
||||
snprintf(s_uid, sizeof(s_uid), "%s", uid);
|
||||
rfid_manager_consume_card();
|
||||
|
||||
char *name = NULL;
|
||||
esp_err_t ret = storage_manager_lookup_name(uid, &name);
|
||||
if (ret != ESP_OK || name == NULL) {
|
||||
app_set_view(APP_STATE_REGISTRATION);
|
||||
display_fill(COLOR_BLACK);
|
||||
display_put_text_center(60, "UNMAPPED CARD", COLOR_YELLOW, COLOR_BLACK);
|
||||
char u[40];
|
||||
snprintf(u, sizeof(u), "UID %s", uid);
|
||||
display_put_text_center(80, u, COLOR_CYAN, COLOR_BLACK);
|
||||
display_put_text_center(140, "PAIR IT ON THE WEB:", COLOR_WHITE, COLOR_BLACK);
|
||||
display_put_text_center(160, "SSID: MTG-Companion", COLOR_GREEN, COLOR_BLACK);
|
||||
display_put_text_center(176, "http://192.168.4.1", COLOR_GREEN, COLOR_BLACK);
|
||||
led_manager_set_color(60, 60, 200);
|
||||
s_led_off_tick = xTaskGetTickCount() + pdMS_TO_TICKS(LED_HOLD_MS);
|
||||
buzzer_chirp(392, 80);
|
||||
free(name);
|
||||
return;
|
||||
}
|
||||
|
||||
snprintf(s_card_name, sizeof(s_card_name), "%s", name);
|
||||
s_card_loaded = true;
|
||||
s_prev_view = APP_STATE_CARD_VIEW;
|
||||
app_set_view(APP_STATE_CARD_VIEW);
|
||||
|
||||
buzzer_chirp(880, 60);
|
||||
read_cached_color(uid);
|
||||
|
||||
char json_path[72];
|
||||
snprintf(json_path, sizeof(json_path), STORAGE_CARDS_DIR "/%s.json", uid);
|
||||
if (storage_file_exists(json_path)) {
|
||||
view_card();
|
||||
} else {
|
||||
s_waiting_fetch = true;
|
||||
display_fill(COLOR_BLACK);
|
||||
char line[160];
|
||||
snprintf(line, sizeof(line), "FETCHING %s...", name);
|
||||
display_put_text_center(140, line, COLOR_YELLOW, COLOR_BLACK);
|
||||
scryfall_fetch_request(name, uid);
|
||||
}
|
||||
free(name);
|
||||
}
|
||||
|
||||
static void handle_fetch_ready(void)
|
||||
{
|
||||
const char *ready_uid = scryfall_last_result_uid();
|
||||
if (ready_uid != NULL && strcmp(ready_uid, s_uid) == 0) {
|
||||
scryfall_consume_result();
|
||||
s_waiting_fetch = false;
|
||||
read_cached_color(s_uid);
|
||||
view_card();
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Task loop */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
static void ui_boot_demo(void)
|
||||
{
|
||||
static const uint16_t bars[] = {
|
||||
COLOR_WHITE, COLOR_YELLOW, COLOR_CYAN, COLOR_GREEN,
|
||||
@@ -27,39 +268,75 @@ static void draw_color_bars(void)
|
||||
for (int i = 0; i < 8; i++) {
|
||||
display_fill_rect(i * band, 0, (i + 1) * band, 64, bars[i]);
|
||||
}
|
||||
}
|
||||
vTaskDelay(pdMS_TO_TICKS(800));
|
||||
|
||||
static void ui_boot_demo(void)
|
||||
{
|
||||
ESP_LOGI(TAG, "UI demo: color bars");
|
||||
draw_color_bars();
|
||||
vTaskDelay(pdMS_TO_TICKS(1500));
|
||||
|
||||
ESP_LOGI(TAG, "UI demo: text rendering");
|
||||
display_fill(COLOR_BLACK);
|
||||
display_put_text_center(8, "MTG RFID COMPANION", COLOR_YELLOW, COLOR_BLACK);
|
||||
display_put_text(16, 40, "UID: 04A2B3C4", COLOR_GREEN, COLOR_BLACK);
|
||||
display_put_text(16, 56, "SOL RING", COLOR_WHITE, COLOR_BLACK);
|
||||
display_put_text(16, 72, "MANA {1}", COLOR_CYAN, COLOR_BLACK);
|
||||
display_put_text(16, 88, "PRICE $1.25", COLOR_MAGENTA, COLOR_BLACK);
|
||||
vTaskDelay(pdMS_TO_TICKS(2000));
|
||||
|
||||
ESP_LOGI(TAG, "UI demo: JPEG decode from LittleFS");
|
||||
display_show_jpeg_file("/spiffs/cards/sample.jpg");
|
||||
vTaskDelay(pdMS_TO_TICKS(3000));
|
||||
|
||||
ESP_LOGI(TAG, "UI demo complete");
|
||||
display_put_text_center(90, "TAP A CARD", COLOR_WHITE, COLOR_BLACK);
|
||||
vTaskDelay(pdMS_TO_TICKS(1200));
|
||||
view_scanner();
|
||||
}
|
||||
|
||||
static void ui_task(void *arg)
|
||||
{
|
||||
(void)arg;
|
||||
ESP_LOGI(TAG, "UI task on core %d", xPortGetCoreID());
|
||||
|
||||
encoder_init();
|
||||
led_manager_init();
|
||||
buzzer_init();
|
||||
|
||||
ui_boot_demo();
|
||||
ESP_LOGI(TAG, "UI task entering idle render loop");
|
||||
app_set_view(APP_STATE_SCANNER);
|
||||
|
||||
while (1) {
|
||||
/* Placeholder idle loop; state-driven rendering lands in Phase 7. */
|
||||
vTaskDelay(pdMS_TO_TICKS(100));
|
||||
/* 1. New RFID card */
|
||||
if (rfid_manager_has_pending_card()) {
|
||||
handle_new_card(rfid_manager_last_uid());
|
||||
}
|
||||
|
||||
/* 2. Async Scryfall cache result */
|
||||
if (s_waiting_fetch && scryfall_has_pending_result()) {
|
||||
handle_fetch_ready();
|
||||
}
|
||||
|
||||
/* 3. Encoder button: toggle card/life view */
|
||||
if (encoder_button_pressed()) {
|
||||
encoder_clear_button();
|
||||
app_state_t cur = app_get_view();
|
||||
if (cur == APP_STATE_LIFE_COUNTER) {
|
||||
app_enter_keyed_view(s_prev_view == APP_STATE_CARD_VIEW ? APP_STATE_CARD_VIEW : APP_STATE_SCANNER);
|
||||
if (app_get_view() == APP_STATE_CARD_VIEW) {
|
||||
view_card();
|
||||
} else {
|
||||
s_prev_view = APP_STATE_SCANNER;
|
||||
view_scanner();
|
||||
}
|
||||
} else {
|
||||
s_prev_view = cur;
|
||||
app_set_view(APP_STATE_LIFE_COUNTER);
|
||||
app_set_life(app_get_life_preset());
|
||||
view_life();
|
||||
}
|
||||
}
|
||||
|
||||
/* 4. Encoder rotation in life counter */
|
||||
if (app_get_view() == APP_STATE_LIFE_COUNTER && encoder_has_delta()) {
|
||||
int32_t delta = encoder_get_delta_and_reset();
|
||||
app_set_life(app_get_life() - delta);
|
||||
if (app_get_life() < 0) {
|
||||
app_set_life(0);
|
||||
}
|
||||
view_life();
|
||||
}
|
||||
|
||||
/* 5. Auto-dim LED */
|
||||
if (s_led_off_tick != 0 && xTaskGetTickCount() >= s_led_off_tick) {
|
||||
s_led_off_tick = 0;
|
||||
led_manager_off();
|
||||
}
|
||||
|
||||
vTaskDelay(pdMS_TO_TICKS(25));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,5 +348,4 @@ esp_err_t ui_task_start(void)
|
||||
|
||||
void ui_task_wait_boot(void)
|
||||
{
|
||||
/* Expose for later phases; currently no-op before state machine lands. */
|
||||
}
|
||||
@@ -18,8 +18,6 @@ static const char *TAG = "web";
|
||||
|
||||
#define AP_SSID "MTG-Companion"
|
||||
#define AP_PASS ""
|
||||
#define AP_IP "192.168.4.1"
|
||||
#define AP_MASK "255.255.255.0"
|
||||
|
||||
#define HTTPD_BODY_MAX (2048)
|
||||
|
||||
@@ -294,7 +292,7 @@ esp_err_t web_server_start(void)
|
||||
ESP_ERROR_CHECK(httpd_register_uri_handler(s_server, &URI_LAST_UID));
|
||||
ESP_ERROR_CHECK(httpd_register_uri_handler(s_server, &URI_BIND));
|
||||
|
||||
ESP_LOGI(TAG, "SoftAP '%s' at %s, web server ready", AP_SSID, AP_IP);
|
||||
ESP_LOGI(TAG, "SoftAP '%s' at 192.168.4.1, web server ready", AP_SSID);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user