various bug fixes

This commit is contained in:
2026-09-11 19:10:31 +02:00
parent e8b6ee2dcc
commit 029ceee69a
23 changed files with 2861 additions and 991 deletions

View File

@@ -16,9 +16,15 @@ const char *app_state_name(app_state_t state)
{
switch (state) {
case APP_STATE_BOOT: return "BOOT";
case APP_STATE_MENU: return "MENU";
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_DICE_ROLLER: return "DICE_ROLLER";
case APP_STATE_COMMANDER_TRACKER: return "COMMANDER_TRACKER";
case APP_STATE_TOKEN_SPAWNER: return "TOKEN_SPAWNER";
case APP_STATE_STORAGE_MANAGER: return "STORAGE_MANAGER";
case APP_STATE_DEVICE_INFO: return "DEVICE_INFO";
case APP_STATE_REGISTRATION: return "REGISTRATION";
}
return "UNKNOWN";

View File

@@ -16,9 +16,15 @@ extern "C" {
typedef enum {
APP_STATE_BOOT = 0,
APP_STATE_MENU,
APP_STATE_SCANNER,
APP_STATE_CARD_VIEW,
APP_STATE_LIFE_COUNTER,
APP_STATE_DICE_ROLLER,
APP_STATE_COMMANDER_TRACKER,
APP_STATE_TOKEN_SPAWNER,
APP_STATE_STORAGE_MANAGER,
APP_STATE_DEVICE_INFO,
APP_STATE_REGISTRATION,
} app_state_t;

View File

@@ -9,12 +9,71 @@
#include "hw_pins.h"
#include "buzzer.h"
#include "freertos/queue.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
static QueueHandle_t s_sound_queue = NULL;
static const buzzer_note_t MELODY_VICTORY[] = {
{NOTE_C5, 90}, {NOTE_C5, 90}, {NOTE_C5, 90}, {NOTE_C5, 220},
{NOTE_GS4, 220}, {NOTE_AS4, 220}, {NOTE_C5, 140}, {NOTE_REST, 35},
{NOTE_AS4, 90}, {NOTE_C5, 400}
};
static const buzzer_note_t MELODY_LEGENDARY[] = {
{NOTE_G4, 65}, {NOTE_C5, 65}, {NOTE_E5, 65}, {NOTE_G5, 90}, {NOTE_C6, 280}
};
static const buzzer_note_t MELODY_LOSS[] = {
{NOTE_D4, 130}, {NOTE_CS4, 130}, {NOTE_C4, 130}, {NOTE_B3, 260},
{NOTE_REST, 35}, {NOTE_AS3, 400}
};
esp_err_t buzzer_play_notes(const buzzer_note_t *notes, size_t count)
{
if (notes == NULL || count == 0) return ESP_OK;
for (size_t i = 0; i < count; i++) {
if (notes[i].freq_hz > 0) {
buzzer_start(notes[i].freq_hz, 30);
vTaskDelay(pdMS_TO_TICKS(notes[i].duration_ms));
buzzer_stop();
} else {
vTaskDelay(pdMS_TO_TICKS(notes[i].duration_ms));
}
/* 12ms articulation gap between notes for distinct 8-bit chiptune feel */
vTaskDelay(pdMS_TO_TICKS(12));
}
return ESP_OK;
}
static void buzzer_task(void *arg)
{
(void)arg;
buzzer_sound_t sound;
while (1) {
if (xQueueReceive(s_sound_queue, &sound, portMAX_DELAY) == pdTRUE) {
switch (sound) {
case BUZZER_SOUND_VICTORY:
buzzer_play_notes(MELODY_VICTORY, sizeof(MELODY_VICTORY) / sizeof(MELODY_VICTORY[0]));
break;
case BUZZER_SOUND_LEGENDARY:
buzzer_play_notes(MELODY_LEGENDARY, sizeof(MELODY_LEGENDARY) / sizeof(MELODY_LEGENDARY[0]));
break;
case BUZZER_SOUND_LOSS:
buzzer_play_notes(MELODY_LOSS, sizeof(MELODY_LOSS) / sizeof(MELODY_LOSS[0]));
break;
default:
break;
}
}
}
}
esp_err_t buzzer_init(void)
{
ledc_timer_config_t timer_cfg = {
@@ -42,7 +101,12 @@ esp_err_t buzzer_init(void)
return ret;
}
ESP_LOGI(TAG, "buzzer ready on GPIO %d", HW_BUZZER_GPIO);
if (s_sound_queue == NULL) {
s_sound_queue = xQueueCreate(4, sizeof(buzzer_sound_t));
xTaskCreate(buzzer_task, "buzzer_task", 2048, NULL, 3, NULL);
}
ESP_LOGI(TAG, "buzzer ready on GPIO %d (chiptune synth active)", HW_BUZZER_GPIO);
return ESP_OK;
}
@@ -81,4 +145,12 @@ esp_err_t buzzer_chirp(uint32_t freq_hz, uint32_t duration_ms)
}
vTaskDelay(pdMS_TO_TICKS(duration_ms));
return buzzer_stop();
}
esp_err_t buzzer_play_sound(buzzer_sound_t sound)
{
if (s_sound_queue != NULL) {
xQueueSend(s_sound_queue, &sound, 0);
}
return ESP_OK;
}

View File

@@ -12,8 +12,56 @@
extern "C" {
#endif
/* Musical note frequencies (Hz) */
#define NOTE_REST 0
#define NOTE_G3 196
#define NOTE_GS3 208
#define NOTE_A3 220
#define NOTE_AS3 233
#define NOTE_B3 247
#define NOTE_C4 262
#define NOTE_CS4 277
#define NOTE_D4 294
#define NOTE_DS4 311
#define NOTE_E4 330
#define NOTE_F4 349
#define NOTE_FS4 370
#define NOTE_G4 392
#define NOTE_GS4 415
#define NOTE_A4 440
#define NOTE_AS4 466
#define NOTE_B4 494
#define NOTE_C5 523
#define NOTE_CS5 554
#define NOTE_D5 587
#define NOTE_DS5 622
#define NOTE_E5 659
#define NOTE_F5 698
#define NOTE_FS5 740
#define NOTE_G5 784
#define NOTE_GS5 831
#define NOTE_A5 880
#define NOTE_AS5 932
#define NOTE_B5 988
#define NOTE_C6 1047
#define NOTE_D6 1175
#define NOTE_E6 1319
#define NOTE_G6 1568
typedef struct {
uint16_t freq_hz;
uint16_t duration_ms;
} buzzer_note_t;
typedef enum {
BUZZER_SOUND_NONE = 0,
BUZZER_SOUND_VICTORY,
BUZZER_SOUND_LEGENDARY,
BUZZER_SOUND_LOSS,
} buzzer_sound_t;
/**
* @brief Initialize LEDC PWM channel for the buzzer.
* @brief Initialize LEDC PWM channel for the buzzer and starts worker task.
*/
esp_err_t buzzer_init(void);
@@ -34,6 +82,16 @@ esp_err_t buzzer_stop(void);
*/
esp_err_t buzzer_chirp(uint32_t freq_hz, uint32_t duration_ms);
/**
* @brief Play an 8-bit melody asynchronously (non-blocking).
*/
esp_err_t buzzer_play_sound(buzzer_sound_t sound);
/**
* @brief Play a custom sequence of notes synchronously.
*/
esp_err_t buzzer_play_notes(const buzzer_note_t *notes, size_t count);
#ifdef __cplusplus
}
#endif

View File

@@ -1,38 +1,34 @@
/*
* MTG RFID Companion - ST7789 display manager (Core 1)
* MTG RFID Companion - ST7789 display manager with LVGL (ponytail: native library)
*/
#include <stdlib.h>
#include <string.h>
#include <stdio.h>
#include "esp_log.h"
#include "esp_lcd_panel_io.h"
#include "esp_lcd_panel_ops.h"
#include "esp_lcd_panel_vendor.h"
#include "esp_lcd_panel_st7789.h"
#include "driver/spi_master.h"
#include "tjpgd.h"
#include "hw_pins.h"
#include "spi_bus_manager.h"
#include "display_manager.h"
#include "ui_font.h"
static const char *TAG = "display";
#define LCD_HOST HW_SPI_HOST
#define LCD_PIXEL_CLOCK_HZ (20 * 1000 * 1000)
#define LCD_PIXEL_CLOCK_HZ (16 * 1000 * 1000)
#define LCD_CMD_BITS 8
#define LCD_PARAM_BITS 8
#define LCD_TRANS_QUEUE 10
#define JPEG_POOL_SIZE (10 * 1024)
#define JPEG_SBUF (512) /* must match CONFIG_JD_SZBUF */
static esp_lcd_panel_handle_t s_panel = NULL;
static lv_disp_t *s_disp = NULL;
static esp_lcd_panel_handle_t s_panel;
static uint16_t *s_staging = NULL; /* 240 px line staging buffer for JPEG */
static esp_err_t panel_io_init(esp_lcd_panel_io_handle_t *io)
esp_err_t display_manager_init(void)
{
if (s_disp != NULL) {
return ESP_OK;
}
/* 1. Initialize ST7789 panel via esp_lcd */
esp_lcd_panel_io_handle_t io = NULL;
esp_lcd_panel_io_spi_config_t io_config = {
.dc_gpio_num = HW_LCD_DC_GPIO,
.cs_gpio_num = HW_LCD_CS_GPIO,
@@ -40,19 +36,9 @@ static esp_err_t panel_io_init(esp_lcd_panel_io_handle_t *io)
.lcd_cmd_bits = LCD_CMD_BITS,
.lcd_param_bits = LCD_PARAM_BITS,
.spi_mode = 0,
.trans_queue_depth = LCD_TRANS_QUEUE,
.trans_queue_depth = 10,
};
return esp_lcd_new_panel_io_spi((esp_lcd_spi_bus_handle_t)LCD_HOST, &io_config, io);
}
esp_err_t display_manager_init(void)
{
if (s_panel != NULL) {
return ESP_OK;
}
esp_lcd_panel_io_handle_t io = NULL;
esp_err_t ret = panel_io_init(&io);
esp_err_t ret = esp_lcd_new_panel_io_spi((esp_lcd_spi_bus_handle_t)LCD_HOST, &io_config, &io);
if (ret != ESP_OK) {
ESP_LOGE(TAG, "panel io init failed: %s", esp_err_to_name(ret));
return ret;
@@ -72,214 +58,48 @@ esp_err_t display_manager_init(void)
ESP_ERROR_CHECK(esp_lcd_panel_reset(s_panel));
ESP_ERROR_CHECK(esp_lcd_panel_init(s_panel));
ESP_ERROR_CHECK(esp_lcd_panel_invert_color(s_panel, true));
/* No panel mirror: the font bit-order fix in display_put_text already
* yields correct (non-mirrored) glyphs. Mirroring here would flip the
* whole frame and re-break the text. */
ESP_ERROR_CHECK(esp_lcd_panel_mirror(s_panel, false, false));
ESP_ERROR_CHECK(esp_lcd_panel_disp_on_off(s_panel, true));
s_staging = malloc(DISP_W * sizeof(uint16_t));
if (s_staging == NULL) {
ESP_LOGE(TAG, "staging buffer alloc failed");
return ESP_ERR_NO_MEM;
/* 2. Initialize LVGL port on Core 1 */
lvgl_port_cfg_t port_cfg = ESP_LVGL_PORT_INIT_CONFIG();
port_cfg.task_affinity = 1;
ret = lvgl_port_init(&port_cfg);
if (ret != ESP_OK) {
ESP_LOGE(TAG, "lvgl port init failed: %s", esp_err_to_name(ret));
return ret;
}
ESP_LOGI(TAG, "ST7789 %dx%d ready (CS=%d DC=%d RST=%d)",
/* 20 lines buffer (240 * 20 * 2 = 9.6 KB) - lean SRAM footprint */
lvgl_port_display_cfg_t disp_cfg = {
.io_handle = io,
.panel_handle = s_panel,
.buffer_size = DISP_W * 20,
.double_buffer = false,
.hres = DISP_W,
.vres = DISP_H,
.monochrome = false,
.rotation = {
.swap_xy = false,
.mirror_x = false,
.mirror_y = false,
},
.flags = {
.buff_dma = true,
},
};
s_disp = lvgl_port_add_disp(&disp_cfg);
if (s_disp == NULL) {
ESP_LOGE(TAG, "lvgl_port_add_disp failed");
return ESP_FAIL;
}
ESP_LOGI(TAG, "ST7789 %dx%d with LVGL ready (CS=%d DC=%d RST=%d)",
DISP_W, DISP_H, HW_LCD_CS_GPIO, HW_LCD_DC_GPIO, HW_LCD_RST_GPIO);
return ESP_OK;
}
void display_draw_bitmap(int x0, int y0, int x1, int y1, const uint16_t *pixels)
lv_disp_t *display_get_disp(void)
{
if (s_panel == NULL || pixels == NULL) {
return;
}
esp_lcd_panel_draw_bitmap(s_panel, x0, y0, x1, y1, pixels);
}
void display_fill_rect(int x0, int y0, int x1, int y1, uint16_t color)
{
if (x0 < 0) x0 = 0;
if (y0 < 0) y0 = 0;
if (x1 > DISP_W) x1 = DISP_W;
if (y1 > DISP_H) y1 = DISP_H;
if (x1 <= x0 || y1 <= y0) {
return;
}
int width = x1 - x0;
int height = y1 - y0;
static uint16_t line[DISP_W];
for (int i = 0; i < width; i++) {
line[i] = color;
}
for (int r = 0; r < height; r++) {
display_draw_bitmap(x0, y0 + r, x1, y0 + r + 1, line);
}
}
void display_fill(uint16_t color)
{
display_fill_rect(0, 0, DISP_W, DISP_H, color);
}
int display_put_text(int x, int y, const char *str, uint16_t fg, uint16_t bg)
{
int x_start = x;
while (str != NULL && *str != '\0') {
char c = *str++;
if (c >= FONT5X7_FIRST_CHAR && c <= FONT5X7_LAST_CHAR) {
const uint8_t *glyph = font5x7[c - FONT5X7_FIRST_CHAR];
/* Row-major [row][col]. The font table stores each row with the
* MSB as the LEFT column, so read bit (W-1-gx) for column gx. */
uint16_t cell[FONT5X7_H][FONT5X7_W];
for (int gy = 0; gy < FONT5X7_H; gy++) {
for (int gx = 0; gx < FONT5X7_W; gx++) {
cell[gy][gx] = (glyph[gy] >> (FONT5X7_W - 1 - gx)) & 1 ? fg : bg;
}
}
if (x + FONT5X7_W <= DISP_W && y + FONT5X7_H <= DISP_H) {
display_draw_bitmap(x, y, x + FONT5X7_W, y + FONT5X7_H, &cell[0][0]);
}
x += FONT5X7_W + 1;
} else if (c == ' ') {
x += FONT5X7_W + 1;
}
}
return x - x_start;
}
void display_put_text_center(int y, const char *str, uint16_t fg, uint16_t bg)
{
int len = (int)strlen(str);
int width = len * (FONT5X7_W + 1) - 1;
int x = (DISP_W - width) / 2;
if (x < 0) x = 0;
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 */
/* ------------------------------------------------------------------ */
typedef struct {
FILE *f;
} jpeg_io_t;
static size_t jpeg_input_cb(JDEC *jd, uint8_t *buf, size_t ndata)
{
jpeg_io_t *io = (jpeg_io_t *)jd->device;
if (io == NULL || io->f == NULL) {
return 0;
}
if (buf == NULL) {
/* tjpgd requests skipping bytes during header scan */
if (fseek(io->f, (long)ndata, SEEK_CUR) == 0) {
return ndata;
}
return 0;
}
return fread(buf, 1, ndata, io->f);
}
static int jpeg_output_cb(JDEC *jd, void *bitmap, JRECT *rect)
{
(void)jd;
uint16_t *pix = (uint16_t *)bitmap;
int w = rect->right - rect->left + 1;
int h = rect->bottom - rect->top + 1;
for (int row = 0; row < h; row++) {
/* Convert little-endian RGB565 words (tjpgd) to big-endian wire order */
for (int col = 0; col < w; col++) {
uint16_t v = pix[row * w + col];
s_staging[col] = (uint16_t)((v << 8) | (v >> 8));
}
display_draw_bitmap(rect->left, rect->top + row, rect->left + w, rect->top + row + 1, s_staging);
}
return 1; /* continue decoding */
}
esp_err_t display_show_jpeg_file(const char *path)
{
esp_err_t ret = ESP_FAIL;
FILE *f = fopen(path, "rb");
if (f == NULL) {
ESP_LOGE(TAG, "cannot open %s", path);
return ESP_ERR_NOT_FOUND;
}
jpeg_io_t io = { .f = f };
JDEC jdec;
void *pool = malloc(JPEG_POOL_SIZE);
if (pool == NULL) {
ESP_LOGE(TAG, "jpeg pool alloc failed");
fclose(f);
return ESP_ERR_NO_MEM;
}
JRESULT jr = jd_prepare(&jdec, jpeg_input_cb, pool, JPEG_POOL_SIZE, &io);
if (jr != JDR_OK) {
ESP_LOGE(TAG, "jd_prepare failed (%d)", jr);
goto done;
}
ESP_LOGI(TAG, "decoding %s: %ux%u", path, (unsigned)jdec.width, (unsigned)jdec.height);
jr = jd_decomp(&jdec, jpeg_output_cb, 0);
if (jr != JDR_OK) {
ESP_LOGE(TAG, "jd_decomp failed (%d)", jr);
goto done;
}
ESP_LOGI(TAG, "JPEG displayed: %s", path);
ret = ESP_OK;
done:
free(pool);
fclose(f);
return ret;
return s_disp;
}

View File

@@ -1,15 +1,12 @@
/*
* MTG RFID Companion - ST7789 display manager (Core 1)
*
* Phase 4: Initializes the ST7789 240x320 panel via esp_lcd as a device on
* the shared SPI2_HOST bus, provides RGB565 rendering primitives, glyph text,
* and streaming tjpgd JPEG decode into the panel.
* MTG RFID Companion - ST7789 display manager with LVGL (ponytail: native library)
*/
#pragma once
#include "esp_err.h"
#include <stdint.h>
#include "esp_lvgl_port.h"
#include "lvgl.h"
#ifdef __cplusplus
extern "C" {
@@ -18,59 +15,8 @@ extern "C" {
#define DISP_W (240)
#define DISP_H (320)
#define RGB565(r, g, b) ((uint16_t)((((r)&0xF8) << 8) | (((g)&0xFC) << 3) | (((b)&0xF8) >> 3)))
#define COLOR_BLACK RGB565(0, 0, 0)
#define COLOR_WHITE RGB565(255, 255, 255)
#define COLOR_RED RGB565(255, 0, 0)
#define COLOR_GREEN RGB565(0, 255, 0)
#define COLOR_BLUE RGB565(0, 0, 255)
#define COLOR_YELLOW RGB565(255, 255, 0)
#define COLOR_CYAN RGB565(0, 255, 255)
#define COLOR_MAGENTA RGB565(255, 0, 255)
#define COLOR_GRAY RGB565(128, 128, 128)
/**
* @brief Initialize the ST7789 panel. Requires the shared SPI bus to exist.
*/
esp_err_t display_manager_init(void);
/**
* @brief Fill the whole screen with a color.
*/
void display_fill(uint16_t color);
/**
* @brief Fill a rectangle. x1/y1 are exclusive.
*/
void display_fill_rect(int x0, int y0, int x1, int y1, uint16_t color);
/**
* @brief Draw pixels. x1/y1 exclusive, buffer length = (x1-x0)*(y1-y0) RGB565 words.
*/
void display_draw_bitmap(int x0, int y0, int x1, int y1, const uint16_t *pixels);
/**
* @brief Draw text with a 5x7 font. Returns pixel width consumed.
*/
int display_put_text(int x, int y, const char *str, uint16_t fg, uint16_t bg);
/**
* @brief Draw text centered horizontally on the given row.
*/
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.
*/
esp_err_t display_show_jpeg_file(const char *path);
lv_disp_t *display_get_disp(void);
#ifdef __cplusplus
}

View File

@@ -1,5 +1,11 @@
/*
* MTG RFID Companion - EC11 rotary encoder driver
*
* Implements a true Gray Code quadrature state machine.
* Both CLK and DT pins generate interrupts on ANYEDGE.
* Contact bounce is inherently rejected by valid transition filtering.
* Each mechanical detent ("tick") advances through a fixed number of valid
* quadrature transitions (default 4 for standard full-step EC11, or 2 for half-step).
*/
#include "freertos/FreeRTOS.h"
@@ -12,38 +18,87 @@
static const char *TAG = "encoder";
static volatile int32_t s_delta = 0;
static volatile int8_t s_accum = 0;
static volatile uint8_t s_prev_state = 0x03;
static volatile int8_t s_steps_per_detent = 4; /* 4 = full-step, 2 = half-step */
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.
* Quadrature Gray-Code Transition Table
* Index: (old_state << 2) | new_state (4-bit, 0..15)
* State bits: bit 1 = CLK (GPIO 32), bit 0 = DT (GPIO 33)
*
* Clockwise sequence: 11 (3) -> 01 (1) -> 00 (0) -> 10 (2) -> 11 (3)
* 11->01 (13): +1
* 01->00 (4): +1
* 00->10 (2): +1
* 10->11 (11): +1
*
* Counter-Clockwise sequence: 11 (3) -> 10 (2) -> 00 (0) -> 01 (1) -> 11 (3)
* 11->10 (14): -1
* 10->00 (8): -1
* 00->01 (1): -1
* 01->11 (7): -1
*
* All bounces (e.g. 11 <-> 01) cancel out: +1 + (-1) = 0.
* Invalid diagonal skips (00 <-> 11, 01 <-> 10): 0.
*/
#define ENC_DIRECTION (1)
static const int8_t s_quad_table[16] = {
[0] = 0,
[1] = -1, // 00 -> 01 (CCW)
[2] = 1, // 00 -> 10 (CW)
[3] = 0, // 00 -> 11 (invalid)
[4] = 1, // 01 -> 00 (CW)
[5] = 0,
[6] = 0, // 01 -> 10 (invalid)
[7] = -1, // 01 -> 11 (CCW)
[8] = -1, // 10 -> 00 (CCW)
[9] = 0, // 10 -> 01 (invalid)
[10] = 0,
[11] = 1, // 10 -> 11 (CW)
[12] = 0, // 11 -> 00 (invalid)
[13] = 1, // 11 -> 01 (CW)
[14] = -1, // 11 -> 10 (CCW)
[15] = 0,
};
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) {
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;
}
return;
}
/* Read current levels of both encoder channels */
uint8_t clk = (uint8_t)gpio_get_level(HW_ENC_CLK_GPIO);
uint8_t dt = (uint8_t)gpio_get_level(HW_ENC_DT_GPIO);
uint8_t curr = (clk << 1) | dt;
if (curr != s_prev_state) {
uint8_t idx = ((s_prev_state & 0x03) << 2) | (curr & 0x03);
int8_t step = s_quad_table[idx];
s_prev_state = curr;
if (step != 0) {
s_accum += step;
if (s_accum >= s_steps_per_detent) {
s_delta += 1;
s_accum = 0;
} else if (s_accum <= -s_steps_per_detent) {
s_delta -= 1;
s_accum = 0;
}
}
}
}
@@ -61,20 +116,46 @@ static esp_err_t config_pin(gpio_num_t pin, gpio_int_type_t intr_type)
esp_err_t encoder_init(void)
{
gpio_install_isr_service(ESP_INTR_FLAG_LEVEL1);
esp_err_t err = gpio_install_isr_service(ESP_INTR_FLAG_LEVEL1);
if (err != ESP_OK && err != ESP_ERR_INVALID_STATE) {
ESP_ERROR_CHECK(err);
}
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_CLK_GPIO, GPIO_INTR_ANYEDGE));
ESP_ERROR_CHECK(config_pin(HW_ENC_DT_GPIO, GPIO_INTR_ANYEDGE));
ESP_ERROR_CHECK(config_pin(HW_ENC_SW_GPIO, GPIO_INTR_NEGEDGE));
/* Initialize initial state */
uint8_t clk = (uint8_t)gpio_get_level(HW_ENC_CLK_GPIO);
uint8_t dt = (uint8_t)gpio_get_level(HW_ENC_DT_GPIO);
s_prev_state = (clk << 1) | dt;
s_accum = 0;
s_delta = 0;
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_DT_GPIO, encoder_isr, (void *)(uintptr_t)HW_ENC_DT_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);
ESP_LOGI(TAG, "EC11 quadrature encoder ready (CLK=%d DT=%d SW=%d steps/tick=%d)",
HW_ENC_CLK_GPIO, HW_ENC_DT_GPIO, HW_ENC_SW_GPIO, s_steps_per_detent);
return ESP_OK;
}
int8_t encoder_get_steps_per_detent(void)
{
return s_steps_per_detent;
}
void encoder_set_steps_per_detent(int8_t steps)
{
if (steps != 2 && steps != 4) {
steps = 4;
}
s_steps_per_detent = steps;
s_accum = 0;
ESP_LOGI(TAG, "Encoder resolution set to %d steps/detent", steps);
}
bool encoder_has_delta(void)
{
return s_delta != 0;

View File

@@ -46,6 +46,16 @@ bool encoder_button_pressed(void);
*/
void encoder_clear_button(void);
/**
* @brief Get current quadrature steps per mechanical detent tick (2 or 4).
*/
int8_t encoder_get_steps_per_detent(void);
/**
* @brief Set quadrature steps per mechanical detent tick (2 or 4).
*/
void encoder_set_steps_per_detent(int8_t steps);
#ifdef __cplusplus
}
#endif

View File

@@ -2,6 +2,7 @@
## NOTE: espressif/esp_lcd is a built-in IDF component - do NOT declare it here.
dependencies:
idf: ">=5.0"
abobija/rc522: "*"
joltwallet/littlefs: "*"
esp_jpeg: ">=1.0.2"
esp_jpeg: ">=1.0.2"
lvgl/lvgl: "^8.3.11"
espressif/esp_lvgl_port: "^2.0.0"

View File

@@ -20,9 +20,9 @@ void app_main(void)
ESP_ERROR_CHECK(spi_bus_manager_init());
ESP_ERROR_CHECK(storage_manager_init());
/* RFID comes BEFORE the display: the reader lives on its own SPI3 bus now
/* RFID comes BEFORE the display: the reader lives on its own SPI3 bus
* (display owns SPI2), but initializing it before the panel's init traffic
* still gives the self-test the quietest possible bus timing. */
* gives the startup the quietest possible bus timing. */
esp_err_t ret = rfid_manager_init();
if (ret != ESP_OK || rfid_manager_start() != ESP_OK) {
ESP_LOGW(TAG, "RFID unavailable (%s) - continuing without card scanning", esp_err_to_name(ret));

View File

@@ -23,11 +23,11 @@ static const char *TAG = "rfid";
#define RC522_TASK_NAME "rc522_poll"
#define RC522_POLL_INTERVAL_MS (100)
#define RC522_TASK_STACK_SIZE (6 * 1024)
#define RC522_TASK_PRIORITY (4)
#define RC522_TASK_PRIORITY (1)
/* Conservative clock for MFRC522 clones. If the FIFO self-test still
* complains, lower further or check power/wiring. */
#define RC522_SPI_CLK_HZ (1000000)
#define RC522_SPI_CLK_HZ (5000000)
#define RC522_START_ATTEMPTS (3)
/* Some clone MFRC522 modules (esp. those wired with RST left floating and
@@ -76,40 +76,12 @@ static void on_picc_state_changed(void *arg, esp_event_base_t base, int32_t even
* hardware-CS device. Any plausible value (0x88/0x91/0x92) means the chip is
* alive and MISO works; 0x00/0xFF means MISO/power/wiring is dead. This is a
* pure diagnostic and does not depend on the rc522 driver's reset path. */
static void rfid_probe_version(void)
{
spi_device_handle_t probe = NULL;
spi_device_interface_config_t devcfg = {
.clock_speed_hz = RC522_SPI_CLK_HZ,
.mode = 0,
.spics_io_num = HW_RC522_CS_GPIO,
.queue_size = 1,
.command_bits = 1, /* read/write select bit */
.address_bits = 6, /* register address */
.dummy_bits = 1,
.flags = SPI_DEVICE_HALFDUPLEX, /* RX-only reads like the rc522 driver */
};
if (spi_bus_add_device(HW_RFID_SPI_HOST, &devcfg, &probe) != ESP_OK || probe == NULL) {
ESP_LOGW(TAG, "probe: could not add temp SPI device on SPI3");
return;
}
uint8_t version = 0;
spi_transaction_t t = {
.cmd = 1, /* read */
.addr = 0x37, /* VersionReg */
.rxlength = 8,
.rx_buffer = &version,
};
esp_err_t ret = spi_device_polling_transmit(probe, &t);
ESP_LOGI(TAG, "MFRC522 VersionReg=0x%02X (%s). 0x88/0x91/0x92=alive; 0x00/0xFF=MISO/power/wiring",
version, esp_err_to_name(ret));
spi_bus_remove_device(probe);
}
esp_err_t rfid_manager_init(void)
{
esp_log_level_set("rc522", ESP_LOG_INFO);
/* Dedicated SPI3_HOST (HSPI) bus for the reader - independent controller
* so the display (SPI2_HOST) and the RFID never share bus timing. We
* initialize SPI3 here (with a real MISO) and hand the already-initialized
@@ -124,14 +96,12 @@ esp_err_t rfid_manager_init(void)
ESP_LOGI(TAG, "initializing MFRC522 on SPI3_HOST (SCK=%d MOSI=%d MISO=%d CS=%d)",
HW_RFID_SCK_GPIO, HW_RFID_MOSI_GPIO, HW_RFID_MISO_GPIO, HW_RC522_CS_GPIO);
esp_err_t ret = spi_bus_initialize(HW_RFID_SPI_HOST, &rfid_bus_cfg, SPI_DMA_CH_AUTO);
esp_err_t ret = spi_bus_initialize(HW_RFID_SPI_HOST, &rfid_bus_cfg, SPI_DMA_DISABLED);
if (ret != ESP_OK) {
ESP_LOGE(TAG, "spi_bus_initialize(SPI3) failed: %s", esp_err_to_name(ret));
return ret;
}
rfid_probe_version();
rc522_spi_config_t driver_config = {
.host_id = HW_RFID_SPI_HOST,
.bus_config = NULL, /* SPI3 already initialized above */
@@ -139,11 +109,8 @@ esp_err_t rfid_manager_init(void)
.spics_io_num = HW_RC522_CS_GPIO,
.clock_speed_hz = RC522_SPI_CLK_HZ,
},
.dma_chan = SPI_DMA_CH_AUTO,
/* RST left unconnected (-1): the module comes up on its own power-on
* reset and uses the library soft-reset. Wire RST=GPIO22 and set this
* to HW_RC522_RST_GPIO if your module needs the hard-reset line. */
.rst_io_num = -1,
.dma_chan = SPI_DMA_DISABLED,
.rst_io_num = HW_RC522_RST_GPIO,
};
ret = rc522_spi_create(&driver_config, &s_driver);

View File

@@ -29,7 +29,7 @@ static const char *TAG = "scryfall";
#define MAX_ATTEMPTS (2)
#define JSON_BUF_MAX (128 * 1024)
#define NAME_MAX (128)
#define CARD_NAME_MAX (128)
#define UID_MAX (24)
#define FIELD_STR_MAX (256)
#define ORACLE_MAX (768)
@@ -38,7 +38,7 @@ static const char *TAG = "scryfall";
#define EVT_CARD_CACHED (1U << 0)
typedef struct {
char name[NAME_MAX];
char name[CARD_NAME_MAX];
char uid[UID_MAX];
} fetch_request_t;
@@ -163,13 +163,20 @@ static esp_err_t http_request(const char *url, http_sink_t *sink)
}
esp_http_client_set_method(client, HTTP_METHOD_GET);
esp_http_client_set_header(client, "User-Agent", "MTGCompanion/1.0");
esp_http_client_set_header(client, "Accept", "*/*");
esp_err_t ret = esp_http_client_perform(client);
int status = esp_http_client_get_status_code(client);
esp_http_client_cleanup(client);
ESP_LOGD(TAG, "GET %s -> %s", url, esp_err_to_name(ret));
if (ret == ESP_OK) {
ESP_LOGD(TAG, "GET %s -> %s (status %d)", url, esp_err_to_name(ret), status);
if (ret == ESP_OK && status >= 200 && status < 300) {
return ESP_OK;
}
if (ret == ESP_OK && (status < 200 || status >= 300)) {
ESP_LOGW(TAG, "HTTP GET returned status %d for %s", status, url);
}
if (sink->err != ESP_OK) {
return sink->err;
}
@@ -227,14 +234,48 @@ esp_err_t scryfall_ensure_cached(const char *card_name, const char *uid)
{
char json_path[64];
snprintf(json_path, sizeof(json_path), STORAGE_CARDS_DIR "/%s.json", uid);
char jpg_path[64];
snprintf(jpg_path, sizeof(jpg_path), STORAGE_CARDS_DIR "/%s.jpg", uid);
if (storage_file_exists(json_path)) {
ESP_LOGI(TAG, "cache hit %s", json_path);
return ESP_OK;
char *chk = NULL;
size_t chk_len = 0;
bool valid = false;
if (storage_read_text(json_path, &chk, &chk_len) == ESP_OK && chk != NULL) {
cJSON *cj = cJSON_Parse(chk);
if (cj) {
cJSON *n = cJSON_GetObjectItem(cj, "name");
cJSON *t = cJSON_GetObjectItem(cj, "type");
cJSON *o = cJSON_GetObjectItem(cj, "oracle");
/* Verify name matches the requested card name */
if (n && n->valuestring && strcasecmp(n->valuestring, card_name) == 0) {
if ((t && t->valuestring && strlen(t->valuestring) > 0) ||
(o && o->valuestring && strlen(o->valuestring) > 0)) {
valid = true;
}
}
cJSON_Delete(cj);
}
free(chk);
}
if (valid) {
if (storage_file_exists(jpg_path)) {
ESP_LOGI(TAG, "cache hit %s and %s", json_path, jpg_path);
s_result_bits |= EVT_CARD_CACHED;
snprintf(s_last_uid, sizeof(s_last_uid), "%s", uid);
return ESP_OK;
}
ESP_LOGI(TAG, "json valid but %s missing, fetching art...", jpg_path);
} else {
ESP_LOGW(TAG, "cache entry %s is invalid or card mismatch (expected '%s'), re-fetching...", json_path, card_name);
remove(json_path);
remove(jpg_path);
}
}
/* 1. GET card JSON */
char url[512];
char enc[NAME_MAX * 3];
char enc[CARD_NAME_MAX * 3];
url_encode(card_name, enc, sizeof(enc));
snprintf(url, sizeof(url), SCRYFALL_API SCRYFALL_CARD_ARTS_URL "%s", enc);
@@ -253,6 +294,14 @@ esp_err_t scryfall_ensure_cached(const char *card_name, const char *uid)
return ESP_FAIL;
}
cJSON *obj = cJSON_GetObjectItem(root, "object");
if (obj && cJSON_IsString(obj) && strcmp(obj->valuestring, "error") == 0) {
ESP_LOGE(TAG, "Scryfall API error: %s",
get_string(cJSON_GetObjectItem(root, "details"), "unknown error"));
cJSON_Delete(root);
return ESP_FAIL;
}
/* 2. Extract fields */
const char *name = get_string(cJSON_GetObjectItem(root, "name"), card_name);
const char *mana = NULL;
@@ -265,40 +314,79 @@ esp_err_t scryfall_ensure_cached(const char *card_name, const char *uid)
const char *type_line = get_string(cJSON_GetObjectItem(root, "type_line"), "");
const char *power = get_string(cJSON_GetObjectItem(root, "power"), "");
const char *toughness = get_string(cJSON_GetObjectItem(root, "toughness"), "");
const char *loyalty = get_string(cJSON_GetObjectItem(root, "loyalty"), "");
const char *oracle = get_string(cJSON_GetObjectItem(root, "oracle_text"), "");
const char *image_url = NULL;
cJSON *uris = cJSON_GetObjectItem(root, "image_uris");
cJSON *faces = cJSON_GetObjectItem(root, "card_faces");
if (faces && cJSON_IsArray(faces) && cJSON_GetArraySize(faces) > 0) {
cJSON *face0 = cJSON_GetArrayItem(faces, 0);
if (face0) {
if (uris == NULL) {
uris = cJSON_GetObjectItem(face0, "image_uris");
}
if (strlen(oracle) == 0) {
oracle = get_string(cJSON_GetObjectItem(face0, "oracle_text"), "");
}
if (strlen(mana) == 0) {
mana = get_string(cJSON_GetObjectItem(face0, "mana_cost"), "");
}
if (strlen(type_line) == 0) {
type_line = get_string(cJSON_GetObjectItem(face0, "type_line"), "");
}
}
}
if (uris) {
image_url = get_string(cJSON_GetObjectItem(uris, "normal"), "");
image_url = get_string(cJSON_GetObjectItem(uris, "art_crop"), "");
if (strlen(image_url) == 0) {
image_url = get_string(cJSON_GetObjectItem(uris, "normal"), "");
}
}
const char *price_usd = NULL;
cJSON *prices = cJSON_GetObjectItem(root, "prices");
if (prices) {
price_usd = get_string(cJSON_GetObjectItem(prices, "usd"), "");
}
const char *rarity = get_string(cJSON_GetObjectItem(root, "rarity"), "");
/* 3. Persist metadata */
char meta[1536];
snprintf(meta, sizeof(meta),
"{\n"
" \"name\": \"%s\",\n"
" \"mana_cost\": \"%s\",\n"
" \"type\": \"%s\",\n"
" \"power\": \"%s\",\n"
" \"toughness\": \"%s\",\n"
" \"oracle\": \"%s\",\n"
" \"price_usd\": \"%s\"\n"
"}\n",
name, mana, type_line, power, toughness, oracle,
price_usd ? price_usd : "");
/* 3. Persist metadata with proper JSON escaping */
cJSON *card_obj = cJSON_CreateObject();
if (card_obj) {
cJSON_AddStringToObject(card_obj, "name", name);
cJSON_AddStringToObject(card_obj, "mana_cost", mana);
cJSON_AddStringToObject(card_obj, "type", type_line);
cJSON_AddStringToObject(card_obj, "rarity", rarity);
cJSON_AddStringToObject(card_obj, "power", power);
cJSON_AddStringToObject(card_obj, "toughness", toughness);
cJSON_AddStringToObject(card_obj, "loyalty", loyalty);
cJSON_AddStringToObject(card_obj, "oracle", oracle);
cJSON_AddStringToObject(card_obj, "price_usd", price_usd ? price_usd : "");
ret = storage_write_text(json_path, meta);
char *meta_str = cJSON_PrintUnformatted(card_obj);
cJSON_Delete(card_obj);
if (meta_str != NULL) {
ret = storage_write_text(json_path, meta_str);
free(meta_str);
} else {
ret = ESP_ERR_NO_MEM;
}
} else {
ret = ESP_ERR_NO_MEM;
}
/* 4. Download cover art (best effort) */
if (image_url && strlen(image_url) > 0) {
char jpg_path[64];
snprintf(jpg_path, sizeof(jpg_path), STORAGE_CARDS_DIR "/%s.jpg", uid);
esp_err_t dl = http_download_file(image_url, jpg_path);
char proxy_url[600];
/* wsrv.nl converts Scryfall's progressive JPEG to a lightweight 200x110 baseline JPEG */
snprintf(proxy_url, sizeof(proxy_url), "https://wsrv.nl/?url=%s&w=200&h=110&fit=cover&output=jpg", image_url);
esp_err_t dl = http_download_file(proxy_url, jpg_path);
if (dl != ESP_OK) {
ESP_LOGW(TAG, "proxied image download failed, trying direct: %s", image_url);
dl = http_download_file(image_url, jpg_path);
}
if (dl != ESP_OK) {
ESP_LOGW(TAG, "image download failed: %s", image_url);
} else {
@@ -341,13 +429,8 @@ static void prefetch_mappings(void)
cJSON *item = NULL;
cJSON_ArrayForEach(item, root) {
uid = item->string;
if (uid == NULL) continue;
char path[64];
snprintf(path, sizeof(path), STORAGE_CARDS_DIR "/%s.json", uid);
if (!storage_file_exists(path)) {
ESP_LOGI(TAG, "prefetching '%s' for %s", item->valuestring, uid);
scryfall_ensure_cached(item->valuestring, uid);
}
if (uid == NULL || item->valuestring == NULL) continue;
scryfall_ensure_cached(item->valuestring, uid);
}
cJSON_Delete(root);
}
@@ -397,7 +480,7 @@ esp_err_t scryfall_fetch_request(const char *card_name, const char *uid)
if (s_fetch_queue == NULL) {
return ESP_ERR_INVALID_STATE;
}
if (strlen(card_name) >= NAME_MAX || strlen(uid) >= UID_MAX || strlen(card_name) == 0) {
if (strlen(card_name) >= CARD_NAME_MAX || strlen(uid) >= UID_MAX || strlen(card_name) == 0) {
return ESP_ERR_INVALID_ARG;
}
fetch_request_t req;

View File

@@ -7,6 +7,7 @@
#include <stdlib.h>
#include <sys/stat.h>
#include <sys/unistd.h>
#include <dirent.h>
#include "esp_log.h"
#include "esp_littlefs.h"
#include "cJSON.h"
@@ -129,6 +130,18 @@ esp_err_t storage_manager_bind_uid(const char *uid, const char *card_name)
return ESP_ERR_NO_MEM;
}
/* If previous mapping was for a different card, purge cached files so new card fetches clean */
cJSON *prev = cJSON_GetObjectItem(root, uid);
if (prev == NULL || prev->valuestring == NULL || strcasecmp(prev->valuestring, card_name) != 0) {
char old_json[64];
char old_jpg[64];
snprintf(old_json, sizeof(old_json), STORAGE_CARDS_DIR "/%s.json", uid);
snprintf(old_jpg, sizeof(old_jpg), STORAGE_CARDS_DIR "/%s.jpg", uid);
remove(old_json);
remove(old_jpg);
ESP_LOGI(TAG, "purged old card cache for %s due to new binding -> '%s'", uid, card_name);
}
cJSON_DeleteItemFromObject(root, uid);
cJSON_AddItemToObject(root, uid, cJSON_CreateString(card_name));
@@ -139,6 +152,47 @@ esp_err_t storage_manager_bind_uid(const char *uid, const char *card_name)
return ret;
}
esp_err_t storage_manager_unbind_uid(const char *uid)
{
if (uid == NULL || uid[0] == '\0') {
return ESP_ERR_INVALID_ARG;
}
char *existing = NULL;
size_t existing_len = 0;
cJSON *root = NULL;
if (storage_read_text(STORAGE_MAPPINGS, &existing, &existing_len) == ESP_OK && existing != NULL) {
root = cJSON_Parse(existing);
}
free(existing);
if (root == NULL || !cJSON_IsObject(root)) {
if (root) cJSON_Delete(root);
return ESP_ERR_NOT_FOUND;
}
if (!cJSON_HasObjectItem(root, uid)) {
cJSON_Delete(root);
return ESP_ERR_NOT_FOUND;
}
cJSON_DeleteItemFromObject(root, uid);
char old_json[64];
char old_jpg[64];
snprintf(old_json, sizeof(old_json), STORAGE_CARDS_DIR "/%s.json", uid);
snprintf(old_jpg, sizeof(old_jpg), STORAGE_CARDS_DIR "/%s.jpg", uid);
remove(old_json);
remove(old_jpg);
ESP_LOGI(TAG, "unbound UID %s and removed cached files", uid);
char *out = cJSON_Print(root);
esp_err_t ret = out ? storage_write_text(STORAGE_MAPPINGS, out) : ESP_ERR_NO_MEM;
free(out);
cJSON_Delete(root);
return ret;
}
esp_err_t storage_manager_lookup_name(const char *uid, char **out)
{
*out = NULL;
@@ -157,6 +211,17 @@ esp_err_t storage_manager_lookup_name(const char *uid, char **out)
}
cJSON *item = cJSON_GetObjectItem(root, uid);
if (item == NULL) {
char prefix[48];
snprintf(prefix, sizeof(prefix), "%s&name=", uid);
cJSON *child = NULL;
cJSON_ArrayForEach(child, root) {
if (child->string && strncmp(child->string, prefix, strlen(prefix)) == 0) {
item = child;
break;
}
}
}
esp_err_t ret = (item && cJSON_IsString(item) && item->valuestring) ? ESP_OK : ESP_ERR_NOT_FOUND;
if (ret == ESP_OK) {
*out = strdup(item->valuestring);
@@ -256,4 +321,63 @@ esp_err_t storage_manager_init(void)
cross_reboot_persistence_test();
return ESP_OK;
}
esp_err_t storage_manager_get_info(size_t *total_bytes, size_t *used_bytes)
{
if (total_bytes == NULL || used_bytes == NULL) {
return ESP_ERR_INVALID_ARG;
}
return esp_littlefs_info(STORAGE_PARTITION_LABEL, total_bytes, used_bytes);
}
esp_err_t storage_manager_count_cached(int *json_count, int *jpg_count)
{
if (json_count == NULL || jpg_count == NULL) {
return ESP_ERR_INVALID_ARG;
}
*json_count = 0;
*jpg_count = 0;
DIR *d = opendir(STORAGE_CARDS_DIR);
if (d == NULL) {
return ESP_OK;
}
struct dirent *entry;
while ((entry = readdir(d)) != NULL) {
if (entry->d_type == DT_REG || entry->d_type == DT_UNKNOWN) {
size_t nlen = strlen(entry->d_name);
if (nlen > 5 && strcmp(entry->d_name + nlen - 5, ".json") == 0) {
(*json_count)++;
} else if (nlen > 4 && strcmp(entry->d_name + nlen - 4, ".jpg") == 0) {
(*jpg_count)++;
}
}
}
closedir(d);
return ESP_OK;
}
esp_err_t storage_manager_clear_all_cards(bool reset_mappings)
{
DIR *d = opendir(STORAGE_CARDS_DIR);
if (d != NULL) {
struct dirent *entry;
while ((entry = readdir(d)) != NULL) {
if (entry->d_name[0] == '.') {
continue;
}
char path[300];
snprintf(path, sizeof(path), STORAGE_CARDS_DIR "/%s", entry->d_name);
unlink(path);
}
closedir(d);
ESP_LOGI(TAG, "Cleared all files in %s", STORAGE_CARDS_DIR);
}
if (reset_mappings) {
storage_write_text(STORAGE_MAPPINGS, "{\n}\n");
ESP_LOGI(TAG, "Reset %s to empty", STORAGE_MAPPINGS);
}
return ESP_OK;
}

View File

@@ -45,12 +45,32 @@ esp_err_t storage_write_text(const char *path, const char *text);
*/
esp_err_t storage_manager_bind_uid(const char *uid, const char *card_name);
/**
* @brief Remove a UID mapping and its cached card JSON/JPG files.
*/
esp_err_t storage_manager_unbind_uid(const char *uid);
/**
* @brief Lookup a card name for a UID in mappings.json. Caller frees *out.
* @return ESP_OK and non-NULL *out on success; ESP_ERR_NOT_FOUND otherwise.
*/
esp_err_t storage_manager_lookup_name(const char *uid, char **out);
/**
* @brief Get partition usage information.
*/
esp_err_t storage_manager_get_info(size_t *total_bytes, size_t *used_bytes);
/**
* @brief Count number of cached cards (.json and .jpg) in /spiffs/cards.
*/
esp_err_t storage_manager_count_cached(int *json_count, int *jpg_count);
/**
* @brief Wipe all cached cards and optionally reset mappings.json for a new deck.
*/
esp_err_t storage_manager_clear_all_cards(bool reset_mappings);
#ifdef __cplusplus
}
#endif

View File

@@ -1,114 +0,0 @@
/*
* MTG RFID Companion - 5x7 bitmap font (uppercase, digits, punctuation)
*
* Row-major glyph table. Each glyph is 7 bytes; one byte per pixel row,
* low 5 bits map columns 0..4 from left to right (bit0 = left column).
* Index = ASCII code - 32.
*/
#pragma once
#define FONT5X7_W (5)
#define FONT5X7_H (7)
#define FONT5X7_FIRST_CHAR (32)
#define FONT5X7_LAST_CHAR (126)
#define FONT5X7_NUM_CHARS (FONT5X7_LAST_CHAR - FONT5X7_FIRST_CHAR + 1)
/* (char - 32) -> glyph rows */
static const uint8_t font5x7[FONT5X7_NUM_CHARS][FONT5X7_H] = {
/* 0x20 ' ' */ { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 },
/* 0x21 '!' */ { 0x04, 0x04, 0x04, 0x04, 0x00, 0x04, 0x04 },
/* 0x22 '"' */ { 0x0A, 0x0A, 0x00, 0x00, 0x00, 0x00, 0x00 },
/* 0x23 '#' */ { 0x0A, 0x15, 0x1F, 0x15, 0x1F, 0x0A, 0x0A },
/* 0x24 '$' */ { 0x0E, 0x11, 0x0F, 0x14, 0x0F, 0x11, 0x0E },
/* 0x25 '%' */ { 0x19, 0x1A, 0x02, 0x04, 0x08, 0x0B, 0x13 },
/* 0x26 '&' */ { 0x0E, 0x11, 0x09, 0x06, 0x13, 0x11, 0x0E },
/* 0x27 '\'' */ { 0x06, 0x04, 0x08, 0x00, 0x00, 0x00, 0x00 },
/* 0x28 '(' */ { 0x02, 0x04, 0x08, 0x08, 0x08, 0x04, 0x02 },
/* 0x29 ')' */ { 0x08, 0x04, 0x02, 0x02, 0x02, 0x04, 0x08 },
/* 0x2A '*' */ { 0x04, 0x15, 0x1F, 0x04, 0x04, 0x00, 0x00 },
/* 0x2B '+' */ { 0x00, 0x04, 0x04, 0x1F, 0x04, 0x04, 0x00 },
/* 0x2C ',' */ { 0x00, 0x00, 0x00, 0x00, 0x0C, 0x04, 0x08 },
/* 0x2D '-' */ { 0x00, 0x00, 0x00, 0x1F, 0x00, 0x00, 0x00 },
/* 0x2E '.' */ { 0x00, 0x00, 0x00, 0x00, 0x00, 0x0C, 0x0C },
/* 0x2F '/' */ { 0x01, 0x01, 0x02, 0x04, 0x08, 0x10, 0x10 },
/* 0x30 '0' */ { 0x0E, 0x11, 0x11, 0x11, 0x11, 0x11, 0x0E },
/* 0x31 '1' */ { 0x04, 0x0C, 0x04, 0x04, 0x04, 0x04, 0x1F },
/* 0x32 '2' */ { 0x0E, 0x11, 0x01, 0x02, 0x04, 0x08, 0x1F },
/* 0x33 '3' */ { 0x0E, 0x01, 0x01, 0x0E, 0x01, 0x01, 0x0E },
/* 0x34 '4' */ { 0x11, 0x11, 0x11, 0x1F, 0x01, 0x01, 0x01 },
/* 0x35 '5' */ { 0x1F, 0x10, 0x10, 0x1E, 0x01, 0x01, 0x0E },
/* 0x36 '6' */ { 0x0E, 0x11, 0x10, 0x1E, 0x11, 0x11, 0x0E },
/* 0x37 '7' */ { 0x1F, 0x01, 0x02, 0x04, 0x04, 0x04, 0x04 },
/* 0x38 '8' */ { 0x0E, 0x11, 0x11, 0x0E, 0x11, 0x11, 0x0E },
/* 0x39 '9' */ { 0x0E, 0x11, 0x11, 0x0F, 0x01, 0x01, 0x0E },
/* 0x3A ':' */ { 0x00, 0x06, 0x06, 0x00, 0x06, 0x06, 0x00 },
/* 0x3B ';' */ { 0x00, 0x06, 0x06, 0x00, 0x06, 0x02, 0x04 },
/* 0x3C '<' */ { 0x02, 0x06, 0x0A, 0x1E, 0x08, 0x08, 0x00 },
/* 0x3D '=' */ { 0x00, 0x1F, 0x00, 0x1F, 0x00, 0x00, 0x00 },
/* 0x3E '>' */ { 0x08, 0x0C, 0x0A, 0x0F, 0x02, 0x02, 0x00 },
/* 0x3F '?' */ { 0x0E, 0x11, 0x01, 0x06, 0x04, 0x00, 0x04 },
/* 0x40 '@' */ { 0x0E, 0x11, 0x15, 0x1F, 0x10, 0x10, 0x0F },
/* 0x41 'A' */ { 0x0E, 0x11, 0x11, 0x1F, 0x11, 0x11, 0x11 },
/* 0x42 'B' */ { 0x1E, 0x11, 0x11, 0x1E, 0x11, 0x11, 0x1E },
/* 0x43 'C' */ { 0x0E, 0x11, 0x10, 0x10, 0x10, 0x11, 0x0E },
/* 0x44 'D' */ { 0x18, 0x11, 0x11, 0x11, 0x11, 0x11, 0x18 },
/* 0x45 'E' */ { 0x1F, 0x10, 0x10, 0x1F, 0x10, 0x10, 0x1F },
/* 0x46 'F' */ { 0x1F, 0x10, 0x10, 0x1E, 0x10, 0x10, 0x10 },
/* 0x47 'G' */ { 0x0E, 0x11, 0x10, 0x13, 0x11, 0x11, 0x0F },
/* 0x48 'H' */ { 0x11, 0x11, 0x11, 0x1F, 0x11, 0x11, 0x11 },
/* 0x49 'I' */ { 0x1F, 0x04, 0x04, 0x04, 0x04, 0x04, 0x1F },
/* 0x4A 'J' */ { 0x01, 0x01, 0x01, 0x01, 0x11, 0x11, 0x0E },
/* 0x4B 'K' */ { 0x11, 0x12, 0x14, 0x18, 0x14, 0x12, 0x11 },
/* 0x4C 'L' */ { 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x1F },
/* 0x4D 'M' */ { 0x11, 0x19, 0x15, 0x13, 0x11, 0x11, 0x11 },
/* 0x4E 'N' */ { 0x11, 0x19, 0x15, 0x13, 0x11, 0x11, 0x11 },
/* 0x4F 'O' */ { 0x0E, 0x11, 0x11, 0x11, 0x11, 0x11, 0x0E },
/* 0x50 'P' */ { 0x1E, 0x11, 0x11, 0x1E, 0x10, 0x10, 0x10 },
/* 0x51 'Q' */ { 0x0E, 0x11, 0x11, 0x11, 0x15, 0x12, 0x0D },
/* 0x52 'R' */ { 0x1E, 0x11, 0x11, 0x1E, 0x14, 0x12, 0x11 },
/* 0x53 'S' */ { 0x0E, 0x11, 0x10, 0x0E, 0x01, 0x11, 0x0E },
/* 0x54 'T' */ { 0x1F, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04 },
/* 0x55 'U' */ { 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x0E },
/* 0x56 'V' */ { 0x11, 0x11, 0x11, 0x11, 0x11, 0x0A, 0x04 },
/* 0x57 'W' */ { 0x11, 0x11, 0x11, 0x13, 0x15, 0x19, 0x11 },
/* 0x58 'X' */ { 0x11, 0x11, 0x0A, 0x04, 0x0A, 0x11, 0x11 },
/* 0x59 'Y' */ { 0x11, 0x11, 0x0A, 0x04, 0x04, 0x04, 0x04 },
/* 0x5A 'Z' */ { 0x1F, 0x01, 0x02, 0x04, 0x08, 0x10, 0x1F },
/* 0x5B '[' */ { 0x08, 0x0C, 0x08, 0x08, 0x08, 0x08, 0x0E },
/* 0x5C '\\' */ { 0x10, 0x10, 0x08, 0x04, 0x02, 0x01, 0x01 },
/* 0x5D ']' */ { 0x0E, 0x04, 0x04, 0x04, 0x04, 0x0C, 0x04 },
/* 0x5E '^' */ { 0x04, 0x0A, 0x15, 0x04, 0x04, 0x00, 0x00 },
/* 0x5F '_' */ { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x1F },
/* 0x60 '`' */ { 0x06, 0x08, 0x04, 0x00, 0x00, 0x00, 0x00 },
/* 0x61 'a' */ { 0x0E, 0x11, 0x11, 0x1F, 0x11, 0x11, 0x11 },
/* 0x62 'b' */ { 0x1E, 0x11, 0x11, 0x1E, 0x11, 0x11, 0x1E },
/* 0x63 'c' */ { 0x0E, 0x11, 0x10, 0x10, 0x10, 0x11, 0x0E },
/* 0x64 'd' */ { 0x18, 0x11, 0x11, 0x11, 0x11, 0x11, 0x18 },
/* 0x65 'e' */ { 0x1F, 0x10, 0x10, 0x1F, 0x10, 0x10, 0x1F },
/* 0x66 'f' */ { 0x1F, 0x10, 0x10, 0x1E, 0x10, 0x10, 0x10 },
/* 0x67 'g' */ { 0x0E, 0x11, 0x10, 0x13, 0x11, 0x11, 0x0F },
/* 0x68 'h' */ { 0x11, 0x11, 0x11, 0x1F, 0x11, 0x11, 0x11 },
/* 0x69 'i' */ { 0x1F, 0x04, 0x04, 0x04, 0x04, 0x04, 0x1F },
/* 0x6A 'j' */ { 0x01, 0x01, 0x01, 0x01, 0x11, 0x11, 0x0E },
/* 0x6B 'k' */ { 0x11, 0x12, 0x14, 0x18, 0x14, 0x12, 0x11 },
/* 0x6C 'l' */ { 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x1F },
/* 0x6D 'm' */ { 0x11, 0x19, 0x15, 0x13, 0x11, 0x11, 0x11 },
/* 0x6E 'n' */ { 0x11, 0x19, 0x15, 0x13, 0x11, 0x11, 0x11 },
/* 0x6F 'o' */ { 0x0E, 0x11, 0x11, 0x11, 0x11, 0x11, 0x0E },
/* 0x70 'p' */ { 0x1E, 0x11, 0x11, 0x1E, 0x10, 0x10, 0x10 },
/* 0x71 'q' */ { 0x0E, 0x11, 0x11, 0x11, 0x15, 0x12, 0x0D },
/* 0x72 'r' */ { 0x1E, 0x11, 0x11, 0x1E, 0x14, 0x12, 0x11 },
/* 0x73 's' */ { 0x0E, 0x11, 0x10, 0x0E, 0x01, 0x11, 0x0E },
/* 0x74 't' */ { 0x1F, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04 },
/* 0x75 'u' */ { 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x0E },
/* 0x76 'v' */ { 0x11, 0x11, 0x11, 0x11, 0x11, 0x0A, 0x04 },
/* 0x77 'w' */ { 0x11, 0x11, 0x11, 0x13, 0x15, 0x19, 0x11 },
/* 0x78 'x' */ { 0x11, 0x11, 0x0A, 0x04, 0x0A, 0x11, 0x11 },
/* 0x79 'y' */ { 0x11, 0x11, 0x0A, 0x04, 0x04, 0x04, 0x04 },
/* 0x7A 'z' */ { 0x1F, 0x01, 0x02, 0x04, 0x08, 0x10, 0x1F },
/* 0x7B '{' */ { 0x06, 0x04, 0x08, 0x0C, 0x08, 0x04, 0x18 },
/* 0x7C '|' */ { 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04 },
/* 0x7D '}' */ { 0x0C, 0x04, 0x02, 0x06, 0x02, 0x04, 0x06 },
/* 0x7E '~' */ { 0x00, 0x1B, 0x16, 0x00, 0x00, 0x00, 0x00 },
};

File diff suppressed because it is too large Load Diff

View File

@@ -2,6 +2,7 @@
* MTG RFID Companion - Registration web server (SoftAP)
*/
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "esp_log.h"
@@ -25,28 +26,52 @@ static const char SPA_PAGE[] =
"<!DOCTYPE html><html><head><meta charset=\"utf-8\">"
"<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">"
"<title>MTG RFID Companion</title><style>"
"body{font-family:system-ui;background:#1a1a2e;color:#eee;max-width:560px;margin:2em auto;padding:1em}"
"h1{color:#ffd75a}.uid{font-size:1.6em;letter-spacing:2px;color:#7fd77f;word-break:break-all}"
"input,.btn{font-size:1em;padding:.5em;border-radius:6px;border:1px solid #555;background:#222;color:#eee}"
"button{margin:.4em}.sug{list-style:none;padding:0}.sug li{cursor:pointer;padding:.5em;border-bottom:1px solid #444}"
"body{font-family:system-ui,-apple-system,sans-serif;background:#141424;color:#eee;max-width:600px;margin:1.5em auto;padding:1em}"
"h1{color:#ffd75a;margin-bottom:0.2em}h2{color:#ffd75a;font-size:1.15em;margin-top:1.2em}"
".uid{font-size:1.5em;font-family:monospace;letter-spacing:2px;color:#7fd77f;word-break:break-all;background:#1e1e32;padding:.4em .6em;border-radius:6px;border:1px solid #333}"
"input{font-size:1em;padding:.5em .8em;border-radius:6px;border:1px solid #555;background:#222;color:#eee;width:calc(100% - 1.6em);box-sizing:border-box}"
"button,.btn{font-size:.95em;padding:.5em 1em;border-radius:6px;border:1px solid #555;background:#2a2a44;color:#eee;cursor:pointer;margin:.3em 0}"
"button:hover,.btn:hover{background:#3a3a5c}"
".btn-primary{background:#ffd75a;color:#111;border:none;font-weight:bold}"
".btn-primary:hover{background:#ffe380}"
".btn-del{background:#4a1e1e;color:#ff9999;border:1px solid #772222;font-size:.85em;padding:.4em .8em;cursor:pointer;border-radius:6px}"
".btn-del:hover{background:#772222;color:#fff}"
".sug{list-style:none;padding:0;margin:.4em 0;background:#1f1f33;border-radius:6px;border:1px solid #333}"
".sug li{cursor:pointer;padding:.5em .8em;border-bottom:1px solid #333}"
".sug li:hover{background:#2a2a48;color:#ffd75a}"
".ok{color:#7fd77f}.bad{color:#ff6b6b}"
".card-item{display:flex;align-items:center;gap:12px;background:#1e1e32;border:1px solid #33334c;border-radius:8px;padding:8px 12px;margin-bottom:8px}"
".card-thumb{width:52px;height:52px;object-fit:cover;border-radius:6px;border:1px solid #444;background:#111;flex-shrink:0}"
".card-info{flex:1;min-width:0}"
".card-title{font-weight:bold;color:#ffd75a;font-size:1.02em;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}"
".card-uid{font-family:monospace;color:#7fd77f;font-size:.85em;margin-top:2px}"
".card-none{color:#777;font-style:italic;padding:.8em 0}"
".header-row{display:flex;align-items:center;justify-content:space-between}"
"</style></head><body>"
"<h1> MTG RFID Companion</h1>"
"<p>Pair a scanned card with its Scryfall name.</p>"
"<h2>Last scanned UID</h2><p class=\"uid\" id=\"uid\">waiting...</p>"
"<h2>Card name</h2>"
"<input id=\"name\" placeholder=\"Type a card name\" autocomplete=\"off\">"
"<button onclick=\"bindCard()\">Pair UID with card</button>"
"<h1>MTG RFID Companion</h1>"
"<p style=\"color:#aaa;margin-top:0\">Pair card sleeves and manage your scanned deck.</p>"
"<div style=\"background:#1a1a2e;padding:1em;border-radius:8px;border:1px solid #2d2d48;margin-bottom:1.5em\">"
"<h2 style=\"margin-top:0\">Last Scanned Tag</h2>"
"<p class=\"uid\" id=\"uid\">waiting...</p>"
"<h2>Pair With Card Name</h2>"
"<input id=\"name\" placeholder=\"Type card name (e.g. Black Lotus)\" autocomplete=\"off\">"
"<div style=\"margin-top:.6em\"><button class=\"btn-primary\" onclick=\"bindCard()\">Pair Card</button></div>"
"<ul class=\"sug\" id=\"res\"></ul>"
"<p id=\"status\"></p>"
"</div>"
"<div class=\"header-row\">"
"<h2>Paired Cards (<span id=\"count\">0</span>)</h2>"
"<button class=\"btn\" style=\"font-size:.8em\" onclick=\"loadCards()\">Refresh</button>"
"</div>"
"<div id=\"cards-box\"><p class=\"card-none\">Loading paired cards...</p></div>"
"<script>"
"var lastUid='';var suggestions=[];"
"function poll(){fetch('/api/last_uid').then(function(r){return r.json();}).then(function(j){"
" if(j.uid&&j.uid!==lastUid){lastUid=j.uid;document.getElementById('uid').textContent=j.uid;"
" suggestions=[];document.getElementById('res').innerHTML='';show('','');}})"
" .catch(function(){});setTimeout(poll,1000);}"
"function esc(s){return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}"
"function show(cls,msg){document.getElementById('status').className=cls;document.getElementById('status').textContent=msg;}"
"function poll(){fetch('/api/last_uid').then(function(r){return r.json();}).then(function(j){"
" if(j.uid&&j.uid!==lastUid){lastUid=j.uid;document.getElementById('uid').textContent=j.uid;"
" suggestions=[];document.getElementById('res').innerHTML='';show('','');}"
"}).catch(function(){});setTimeout(poll,1000);}"
"function autocomplete(){var q=document.getElementById('name').value;"
" if(q.length<3){suggestions=[];document.getElementById('res').innerHTML='';return;}"
" fetch('https://api.scryfall.com/cards/autocomplete?q='+encodeURIComponent(q))"
@@ -58,13 +83,38 @@ static const char SPA_PAGE[] =
"document.getElementById('name').oninput=autocomplete;"
"function bindCard(){var n=document.getElementById('name').value.trim();"
" if(!lastUid||!n){show('bad','Scan a card and enter a name first');return;}"
" show('','Pairing...');"
" fetch('/api/bind',{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded'},"
" body:'uid='+encodeURIComponent(lastUid)+'&name='+encodeURIComponent(n)})"
" .then(function(r){return r.json();}).then(function(j){"
" if(j.ok){show('ok','Paired '+j.uid+' -> '+j.name);document.getElementById('name').value='';"
" document.getElementById('res').innerHTML='';}"
" document.getElementById('res').innerHTML='';loadCards();}"
" else{show('bad','Error: '+j.error);}}).catch(function(e){show('bad','Network: '+e);});}"
"poll();"
"function unbindCard(u){if(!confirm('Unbind card '+u+'?'))return;"
" fetch('/api/unbind',{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded'},"
" body:'uid='+encodeURIComponent(u)})"
" .then(function(r){return r.json();}).then(function(j){"
" if(j.ok){loadCards();show('ok','Unbound '+u);}"
" else{show('bad','Error unbinding '+u);}}).catch(function(e){show('bad','Network: '+e);});}"
"function loadCards(){"
" fetch('/api/cards').then(function(r){return r.json();}).then(function(data){"
" var box=document.getElementById('cards-box');var uids=Object.keys(data);"
" document.getElementById('count').textContent=uids.length;"
" if(uids.length===0){box.innerHTML='<p class=\"card-none\">No cards paired yet. Scan a card above to begin!</p>';return;}"
" uids.sort(function(a,b){return data[a].localeCompare(data[b]);});"
" var html='';"
" uids.forEach(function(u){"
" var nm=esc(data[u]);"
" html+='<div class=\"card-item\">';"
" html+='<img class=\"card-thumb\" src=\"/api/art?uid='+u+'\" alt=\"art\" onerror=\"this.style.display=\\'none\\'\">';"
" html+='<div class=\"card-info\"><div class=\"card-title\">'+nm+'</div><div class=\"card-uid\">'+u+'</div></div>';"
" html+='<button class=\"btn-del\" onclick=\"unbindCard(\\''+u+'\\')\">Unbind</button>';"
" html+='</div>';"
" });"
" box.innerHTML=html;"
" }).catch(function(e){document.getElementById('cards-box').innerHTML='<p class=\"bad\">Failed to load cards: '+e+'</p>';});"
"}"
"poll();loadCards();"
"</script></body></html>";
static httpd_handle_t s_server = NULL;
@@ -72,6 +122,9 @@ static httpd_handle_t s_server = NULL;
static esp_err_t handle_root(httpd_req_t *req);
static esp_err_t handle_last_uid(httpd_req_t *req);
static esp_err_t handle_bind(httpd_req_t *req);
static esp_err_t handle_cards(httpd_req_t *req);
static esp_err_t handle_art(httpd_req_t *req);
static esp_err_t handle_unbind(httpd_req_t *req);
static const httpd_uri_t URI_ROOT = {
.uri = "/", .method = HTTP_GET, .handler = handle_root, .user_ctx = NULL
@@ -82,6 +135,15 @@ static const httpd_uri_t URI_LAST_UID = {
static const httpd_uri_t URI_BIND = {
.uri = "/api/bind", .method = HTTP_POST, .handler = handle_bind, .user_ctx = NULL
};
static const httpd_uri_t URI_CARDS = {
.uri = "/api/cards", .method = HTTP_GET, .handler = handle_cards, .user_ctx = NULL
};
static const httpd_uri_t URI_ART = {
.uri = "/api/art", .method = HTTP_GET, .handler = handle_art, .user_ctx = NULL
};
static const httpd_uri_t URI_UNBIND = {
.uri = "/api/unbind", .method = HTTP_POST, .handler = handle_unbind, .user_ctx = NULL
};
static void send_json(httpd_req_t *req, int status, const char *body)
{
@@ -174,18 +236,29 @@ static esp_err_t handle_bind(httpd_req_t *req)
char *uid = NULL;
char *name = NULL;
const char *u = strstr(form, "uid=");
const char *n = strstr(form, "name=");
char *u = strstr(form, "uid=");
char *n = strstr(form, "name=");
if (u) {
char *end = strchr(u, '&');
(void)end;
if (end) *end = '\0';
uid = url_decode(u + 4);
if (end) *end = '&';
}
if (n) {
char *end = strchr(n, '&');
if (end) *end = '\0';
name = url_decode(n + 5);
if (end) *end = '&';
}
free(form);
if (uid) {
size_t ulen = strlen(uid);
while (ulen > 0 && (uid[ulen - 1] == ' ' || uid[ulen - 1] == '\r' || uid[ulen - 1] == '\n')) {
uid[--ulen] = '\0';
}
}
if (uid == NULL || name == NULL || strlen(uid) == 0 || strlen(name) == 0) {
free(uid);
free(name);
@@ -212,6 +285,114 @@ static esp_err_t handle_bind(httpd_req_t *req)
return ESP_OK;
}
static esp_err_t handle_cards(httpd_req_t *req)
{
char *text = NULL;
size_t len = 0;
if (storage_read_text(STORAGE_MAPPINGS, &text, &len) == ESP_OK && text != NULL) {
httpd_resp_set_type(req, "application/json");
httpd_resp_send(req, text, (ssize_t)len);
free(text);
} else {
send_json(req, 200, "{}\n");
}
return ESP_OK;
}
static esp_err_t handle_art(httpd_req_t *req)
{
char query[64] = {0};
char uid[32] = {0};
if (httpd_req_get_url_query_str(req, query, sizeof(query)) == ESP_OK) {
httpd_query_key_value(query, "uid", uid, sizeof(uid));
}
if (uid[0] == '\0') {
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Missing uid");
return ESP_OK;
}
char path[64];
snprintf(path, sizeof(path), STORAGE_CARDS_DIR "/%s.jpg", uid);
FILE *f = fopen(path, "rb");
if (!f) {
httpd_resp_send_err(req, HTTPD_404_NOT_FOUND, "Art not found");
return ESP_OK;
}
httpd_resp_set_type(req, "image/jpeg");
httpd_resp_set_hdr(req, "Cache-Control", "public, max-age=86400");
char chunk[1024];
size_t n = 0;
while ((n = fread(chunk, 1, sizeof(chunk), f)) > 0) {
if (httpd_resp_send_chunk(req, chunk, (ssize_t)n) != ESP_OK) {
fclose(f);
return ESP_FAIL;
}
}
fclose(f);
httpd_resp_send_chunk(req, NULL, 0);
return ESP_OK;
}
static esp_err_t handle_unbind(httpd_req_t *req)
{
if (req->content_len <= 0 || req->content_len >= HTTPD_BODY_MAX) {
send_json(req, 400, "{\"ok\":false,\"error\":\"bad body\"}");
return ESP_OK;
}
char *form = malloc((size_t)req->content_len + 1);
if (form == NULL) {
send_json(req, 500, "{\"ok\":false,\"error\":\"no mem\"}");
return ESP_OK;
}
int got = httpd_req_recv(req, form, (size_t)req->content_len);
if (got != req->content_len) {
free(form);
send_json(req, 400, "{\"ok\":false,\"error\":\"short body\"}");
return ESP_OK;
}
form[got] = '\0';
char *uid = NULL;
char *u = strstr(form, "uid=");
if (u) {
char *end = strchr(u, '&');
if (end) *end = '\0';
uid = url_decode(u + 4);
if (end) *end = '&';
}
free(form);
if (uid) {
size_t ulen = strlen(uid);
while (ulen > 0 && (uid[ulen - 1] == ' ' || uid[ulen - 1] == '\r' || uid[ulen - 1] == '\n')) {
uid[--ulen] = '\0';
}
}
if (uid == NULL || strlen(uid) == 0) {
free(uid);
send_json(req, 400, "{\"ok\":false,\"error\":\"missing uid\"}");
return ESP_OK;
}
esp_err_t ret = storage_manager_unbind_uid(uid);
if (ret != ESP_OK) {
free(uid);
send_json(req, 404, "{\"ok\":false,\"error\":\"uid not found\"}");
return ESP_OK;
}
char ok[128];
snprintf(ok, sizeof(ok), "{\"ok\":true,\"uid\":\"%s\"}\n", uid);
ESP_LOGI(TAG, "unbound UID %s via web", uid);
send_json(req, 200, ok);
free(uid);
return ESP_OK;
}
/* ------------------------------------------------------------------ */
/* SoftAP + server */
/* ------------------------------------------------------------------ */
@@ -286,7 +467,7 @@ esp_err_t web_server_start(void)
httpd_config_t config = HTTPD_DEFAULT_CONFIG();
config.stack_size = 6 * 1024;
config.max_uri_handlers = 8;
config.max_uri_handlers = 12;
config.lru_purge_enable = true;
config.keep_alive_enable = true;
config.max_open_sockets = 4;
@@ -300,6 +481,9 @@ esp_err_t web_server_start(void)
ESP_ERROR_CHECK(httpd_register_uri_handler(s_server, &URI_ROOT));
ESP_ERROR_CHECK(httpd_register_uri_handler(s_server, &URI_LAST_UID));
ESP_ERROR_CHECK(httpd_register_uri_handler(s_server, &URI_BIND));
ESP_ERROR_CHECK(httpd_register_uri_handler(s_server, &URI_CARDS));
ESP_ERROR_CHECK(httpd_register_uri_handler(s_server, &URI_ART));
ESP_ERROR_CHECK(httpd_register_uri_handler(s_server, &URI_UNBIND));
ESP_LOGI(TAG, "SoftAP '%s' at 192.168.4.1, web server ready", AP_SSID);
return ESP_OK;

View File

@@ -21,6 +21,7 @@ static EventGroupHandle_t s_wifi_group = NULL;
#define WIFI_CONNECTED_BIT BIT0
static int s_retry_count = 0;
static char s_sta_ip[20] = "No Wi-Fi";
static void wifi_event_handler(void *arg, esp_event_base_t base, int32_t id, void *data)
{
@@ -30,6 +31,7 @@ static void wifi_event_handler(void *arg, esp_event_base_t base, int32_t id, voi
ESP_LOGI(TAG, "station started, connecting...");
esp_wifi_connect();
} else if (base == WIFI_EVENT && id == WIFI_EVENT_STA_DISCONNECTED) {
strncpy(s_sta_ip, "No Wi-Fi", sizeof(s_sta_ip));
if (s_retry_count < WIFI_RETRY_MAX) {
ESP_LOGW(TAG, "disconnected (%d), retrying in %d ms", s_retry_count, WIFI_RECONNECT_DELAY_MS);
xEventGroupClearBits(s_wifi_group, WIFI_CONNECTED_BIT);
@@ -39,12 +41,21 @@ static void wifi_event_handler(void *arg, esp_event_base_t base, int32_t id, voi
}
} else if (base == IP_EVENT && id == IP_EVENT_STA_GOT_IP) {
ip_event_got_ip_t *event = (ip_event_got_ip_t *)data;
ESP_LOGI(TAG, "connected, IP: " IPSTR, IP2STR(&event->ip_info.ip));
snprintf(s_sta_ip, sizeof(s_sta_ip), IPSTR, IP2STR(&event->ip_info.ip));
ESP_LOGI(TAG, "connected, IP: %s", s_sta_ip);
s_retry_count = 0;
xEventGroupSetBits(s_wifi_group, WIFI_CONNECTED_BIT);
}
}
const char *wifi_manager_get_ip(void)
{
if (!wifi_manager_connected()) {
return "No Wi-Fi";
}
return s_sta_ip;
}
esp_err_t wifi_manager_init(void)
{
esp_err_t ret = nvs_flash_init();

View File

@@ -41,6 +41,11 @@ bool wifi_manager_connected(void);
*/
esp_err_t wifi_manager_wait_connected(TickType_t timeout_ticks);
/**
* @brief Get the STA IP address as a string (or "No Wi-Fi" if disconnected).
*/
const char *wifi_manager_get_ip(void);
#ifdef __cplusplus
}
#endif