From 6074fdeb7191af3b60491c1a28808a615f4a93b8 Mon Sep 17 00:00:00 2001 From: tkpdx01 <99157325+tkpdx01@users.noreply.github.com> Date: Tue, 14 Apr 2026 17:02:00 +0800 Subject: [PATCH] feat(cardputer-adv): add TCA8418 keyboard and WiFi config UI (#1929) Add full keyboard support and keyboard-based WiFi configuration for M5Stack Cardputer Adv: - TCA8418 I2C keyboard driver with 56-key matrix, interrupt-driven key events, and debounce handling - Keyboard WiFi config UI: scan/select/input SSID and password directly on the device without needing a phone - Volume control (up/down arrows) and brightness control (left/right) via keyboard with fine-step adjustment near bounds - Enter key to toggle chat state - Display offset and backlight fixes for ST7789V2 - README with flash parameters and hardware specs Co-authored-by: bot --- main/boards/m5stack-cardputer-adv/README.md | 27 + main/boards/m5stack-cardputer-adv/config.h | 5 +- .../m5stack_cardputer_adv.cc | 226 +++++- .../m5stack-cardputer-adv/tca8418_keyboard.cc | 418 +++++++++++ .../m5stack-cardputer-adv/tca8418_keyboard.h | 155 ++++ .../m5stack-cardputer-adv/wifi_config_ui.cc | 705 ++++++++++++++++++ .../m5stack-cardputer-adv/wifi_config_ui.h | 133 ++++ 7 files changed, 1654 insertions(+), 15 deletions(-) create mode 100644 main/boards/m5stack-cardputer-adv/tca8418_keyboard.cc create mode 100644 main/boards/m5stack-cardputer-adv/tca8418_keyboard.h create mode 100644 main/boards/m5stack-cardputer-adv/wifi_config_ui.cc create mode 100644 main/boards/m5stack-cardputer-adv/wifi_config_ui.h diff --git a/main/boards/m5stack-cardputer-adv/README.md b/main/boards/m5stack-cardputer-adv/README.md index 89f70d1..137b029 100644 --- a/main/boards/m5stack-cardputer-adv/README.md +++ b/main/boards/m5stack-cardputer-adv/README.md @@ -43,6 +43,33 @@ M5Stack Cardputer Adv 是一款基于 ESP32-S3FN8 (Stamp-S3A) 的卡片式电脑 1. 按下 BOOT 按钮进入配网模式 2. 连接 WiFi 后即可使用语音助手功能 +## 烧录参数 + +芯片: ESP32-S3, Flash: 8MB, 模式: DIO, 频率: 80MHz + +| 地址 | 文件 | +|------|------| +| 0x0 | bootloader/bootloader.bin | +| 0x8000 | partition_table/partition-table.bin | +| 0xd000 | ota_data_initial.bin | +| 0x20000 | xiaozhi.bin | +| 0x600000 | generated_assets.bin | + +烧录命令 (build 目录为 `build-cardputer-adv`): + +```bash +python -m esptool --chip esp32s3 -b 460800 -p PORT \ + --before default_reset --after hard_reset \ + write_flash --flash_mode dio --flash_size 8MB --flash_freq 80m \ + 0x0 build-cardputer-adv/bootloader/bootloader.bin \ + 0x8000 build-cardputer-adv/partition_table/partition-table.bin \ + 0xd000 build-cardputer-adv/ota_data_initial.bin \ + 0x20000 build-cardputer-adv/xiaozhi.bin \ + 0x600000 build-cardputer-adv/generated_assets.bin +``` + +将 `PORT` 替换为实际串口设备路径(如 `/dev/cu.usbmodem21101`)。 + ## 参考链接 - [M5Stack Cardputer Adv 官方文档](https://docs.m5stack.com/en/core/Cardputer-Adv) diff --git a/main/boards/m5stack-cardputer-adv/config.h b/main/boards/m5stack-cardputer-adv/config.h index 67f75bd..bdd17b6 100644 --- a/main/boards/m5stack-cardputer-adv/config.h +++ b/main/boards/m5stack-cardputer-adv/config.h @@ -39,7 +39,7 @@ #define DISPLAY_SWAP_XY true #define DISPLAY_OFFSET_X 40 -#define DISPLAY_OFFSET_Y 52 +#define DISPLAY_OFFSET_Y 53 #define DISPLAY_SPI_MOSI_PIN GPIO_NUM_35 #define DISPLAY_SPI_SCLK_PIN GPIO_NUM_36 @@ -49,8 +49,9 @@ #define DISPLAY_BACKLIGHT_PIN GPIO_NUM_38 #define DISPLAY_BACKLIGHT_OUTPUT_INVERT false -// Keyboard TCA8418 I2C address +// Keyboard TCA8418 I2C address and interrupt pin #define KEYBOARD_TCA8418_ADDR 0x34 +#define KEYBOARD_INT_PIN GPIO_NUM_11 // IMU BMI270 I2C address #define IMU_BMI270_ADDR 0x68 diff --git a/main/boards/m5stack-cardputer-adv/m5stack_cardputer_adv.cc b/main/boards/m5stack-cardputer-adv/m5stack_cardputer_adv.cc index 5f9c266..d30c76f 100644 --- a/main/boards/m5stack-cardputer-adv/m5stack_cardputer_adv.cc +++ b/main/boards/m5stack-cardputer-adv/m5stack_cardputer_adv.cc @@ -1,20 +1,30 @@ #include "wifi_board.h" +#include "wifi_config_ui.h" #include "codecs/es8311_audio_codec.h" #include "display/lcd_display.h" #include "application.h" #include "button.h" #include "config.h" #include "i2c_device.h" +#include "tca8418_keyboard.h" #include #include +#include #include #include #include #include +#include +#include +#include +#include #define TAG "CardputerAdv" +// Backlight uses percentage scale (0-100). Keep a minimum of 30% to avoid a too-dim screen. +#define MIN_BRIGHTNESS 30 + class M5StackCardputerAdvBoard : public WifiBoard { private: i2c_master_bus_handle_t i2c_bus_; @@ -22,6 +32,9 @@ private: Button boot_button_; esp_lcd_panel_io_handle_t panel_io_ = nullptr; esp_lcd_panel_handle_t panel_ = nullptr; + Tca8418Keyboard* keyboard_ = nullptr; + std::unique_ptr wifi_config_ui_; + bool wifi_config_mode_ = false; void InitializeI2c() { ESP_LOGI(TAG, "Initialize I2C bus"); @@ -117,6 +130,185 @@ private: }); } + void InitializeKeyboard() { + ESP_LOGI(TAG, "Initialize TCA8418 keyboard"); + keyboard_ = new Tca8418Keyboard(i2c_bus_, KEYBOARD_TCA8418_ADDR, KEYBOARD_INT_PIN); + keyboard_->Initialize(); + + // Set legacy callback for volume/brightness control + keyboard_->SetKeyCallback([this](LegacyKeyCode key) { + HandleLegacyKeyPress(key); + }); + + // Set full key event callback for WiFi config and text input + keyboard_->SetKeyEventCallback([this](const KeyEvent& event) { + HandleKeyEvent(event); + }); + } + + void HandleKeyEvent(const KeyEvent& event) { + // Handle WiFi config mode + if (wifi_config_mode_ && wifi_config_ui_) { + auto result = wifi_config_ui_->HandleKeyEvent(event); + if (result == WifiConfigResult::Connected) { + ESP_LOGI(TAG, "WiFi connected via keyboard config"); + ExitWifiConfigMode(); + } else if (result == WifiConfigResult::Cancelled) { + ESP_LOGI(TAG, "WiFi config cancelled"); + ExitWifiConfigMode(); + } + return; + } + + // Handle W and S keys during WiFi configuring state (scanning screen) + auto& app = Application::GetInstance(); + if (app.GetDeviceState() == kDeviceStateWifiConfiguring && event.pressed) { + if (event.key_code == KC_W) { + ESP_LOGI(TAG, "W key pressed - entering keyboard WiFi config"); + StartKeyboardWifiConfig(); + } else if (event.key_code == KC_S) { + ESP_LOGI(TAG, "S key pressed - showing saved WiFi list"); + StartKeyboardWifiConfigSaved(); + } + } + } + + void HandleLegacyKeyPress(LegacyKeyCode key) { + // Skip if in WiFi config mode + if (wifi_config_mode_) { + return; + } + + auto& app = Application::GetInstance(); + auto* codec = GetAudioCodec(); + auto* backlight = GetBacklight(); + + switch (key) { + case KEY_UP: { + // Volume up + int current_vol = codec->output_volume(); + int step = (current_vol <= 20 || current_vol >= 80) ? 1 : 10; + int new_vol = std::min(100, current_vol + step); + codec->SetOutputVolume(new_vol); + char msg[32]; + snprintf(msg, sizeof(msg), "Volume: %d%%", new_vol); + display_->ShowNotification(msg, 1500); + ESP_LOGI(TAG, "Volume up: %d%%", new_vol); + break; + } + case KEY_DOWN: { + // Volume down + int current_vol = codec->output_volume(); + int step = (current_vol <= 20 || current_vol >= 80) ? 1 : 10; + int new_vol = std::max(0, current_vol - step); + codec->SetOutputVolume(new_vol); + char msg[32]; + snprintf(msg, sizeof(msg), "Volume: %d%%", new_vol); + display_->ShowNotification(msg, 1500); + ESP_LOGI(TAG, "Volume down: %d%%", new_vol); + break; + } + case KEY_RIGHT: { + // Brightness up + uint8_t current_br = backlight->brightness(); + int step = (current_br <= (MIN_BRIGHTNESS + 20) || current_br >= 80) ? 1 : 10; + int new_br = std::min(100, (int)current_br + step); + backlight->SetBrightness(new_br, true); + char msg[32]; + snprintf(msg, sizeof(msg), "Brightness: %d%%", new_br); + display_->ShowNotification(msg, 1500); + ESP_LOGI(TAG, "Brightness up: %d%%", new_br); + break; + } + case KEY_LEFT: { + // Brightness down (minimum 30%) + uint8_t current_br = backlight->brightness(); + int step = (current_br <= (MIN_BRIGHTNESS + 20) || current_br >= 80) ? 1 : 10; + int new_br = std::max((int)MIN_BRIGHTNESS, (int)current_br - step); + backlight->SetBrightness(new_br, true); + char msg[32]; + snprintf(msg, sizeof(msg), "Brightness: %d%%", new_br); + display_->ShowNotification(msg, 1500); + ESP_LOGI(TAG, "Brightness down: %d%%", new_br); + break; + } + case KEY_ENTER: { + // Match boot button behavior (start/stop chat depending on current state). + if (app.GetDeviceState() != kDeviceStateStarting) { + app.ToggleChatState(); + ESP_LOGI(TAG, "Enter key: Toggle chat state"); + } + break; + } + default: + break; + } + } + + void StartKeyboardWifiConfig() { + ESP_LOGI(TAG, "Starting keyboard WiFi config UI"); + wifi_config_mode_ = true; + wifi_config_ui_ = std::make_unique(display_); + wifi_config_ui_->SetConnectCallback([this](const std::string& ssid, const std::string& password) { + AttemptWifiConnection(ssid, password); + }); + wifi_config_ui_->Start(); + } + + void StartKeyboardWifiConfigSaved() { + ESP_LOGI(TAG, "Starting keyboard WiFi config UI (saved list)"); + wifi_config_mode_ = true; + wifi_config_ui_ = std::make_unique(display_); + wifi_config_ui_->SetConnectCallback([this](const std::string& ssid, const std::string& password) { + AttemptWifiConnection(ssid, password); + }); + wifi_config_ui_->StartWithSavedList(); + } + + void AttemptWifiConnection(const std::string& ssid, const std::string& password) { + ESP_LOGI(TAG, "Attempting WiFi connection to: %s", ssid.c_str()); + + // Add to SSID manager (will be saved and used for connection) + auto& ssid_manager = SsidManager::GetInstance(); + ssid_manager.AddSsid(ssid, password); + + // Stop config AP mode and trigger reconnection with new credentials + auto& wifi_manager = WifiManager::GetInstance(); + if (wifi_manager.IsConfigMode()) { + wifi_manager.StopConfigAp(); + } + + // Start station mode to connect + wifi_manager.StartStation(); + + // Wait for connection result (with timeout) + bool connected = false; + for (int i = 0; i < 100; i++) { // 10 second timeout + vTaskDelay(pdMS_TO_TICKS(100)); + if (wifi_manager.IsConnected()) { + connected = true; + break; + } + } + + if (wifi_config_ui_) { + wifi_config_ui_->OnConnectResult(connected); + } + } + + void ExitWifiConfigMode() { + ESP_LOGI(TAG, "Exiting keyboard WiFi config mode"); + wifi_config_mode_ = false; + wifi_config_ui_.reset(); + + // Restart normal WiFi connection flow + auto& app = Application::GetInstance(); + if (app.GetDeviceState() == kDeviceStateWifiConfiguring) { + // Try to connect with saved credentials + TryWifiConnect(); + } + } + public: M5StackCardputerAdvBoard() : boot_button_(BOOT_BUTTON_GPIO) { InitializeI2c(); @@ -124,23 +316,31 @@ public: InitializeSpi(); InitializeSt7789Display(); InitializeButtons(); + InitializeKeyboard(); GetBacklight()->RestoreBrightness(); } virtual AudioCodec* GetAudioCodec() override { - static Es8311AudioCodec audio_codec( - i2c_bus_, - I2C_NUM_0, - AUDIO_INPUT_SAMPLE_RATE, - AUDIO_OUTPUT_SAMPLE_RATE, - AUDIO_I2S_GPIO_MCLK, - AUDIO_I2S_GPIO_BCLK, - AUDIO_I2S_GPIO_WS, - AUDIO_I2S_GPIO_DOUT, - AUDIO_I2S_GPIO_DIN, - AUDIO_CODEC_PA_PIN, - AUDIO_CODEC_ES8311_ADDR, - false); // use_mclk = false, Cardputer Adv has no MCLK pin + // Cardputer Adv (no MCLK, internal clocking) needs I2S channels + // disabled after construction so esp_codec_dev_open can configure + // the ES8311 codec before channels start running. + static struct CardputerAdvEs8311 : public Es8311AudioCodec { + CardputerAdvEs8311(void* i2c, i2c_port_t port, int in_rate, int out_rate, + gpio_num_t mclk, gpio_num_t bclk, gpio_num_t ws, + gpio_num_t dout, gpio_num_t din, gpio_num_t pa, + uint8_t addr, bool use_mclk) + : Es8311AudioCodec(i2c, port, in_rate, out_rate, + mclk, bclk, ws, dout, din, pa, addr, use_mclk) { + i2s_channel_disable(tx_handle_); + i2s_channel_disable(rx_handle_); + } + } audio_codec( + i2c_bus_, I2C_NUM_0, + AUDIO_INPUT_SAMPLE_RATE, AUDIO_OUTPUT_SAMPLE_RATE, + AUDIO_I2S_GPIO_MCLK, AUDIO_I2S_GPIO_BCLK, AUDIO_I2S_GPIO_WS, + AUDIO_I2S_GPIO_DOUT, AUDIO_I2S_GPIO_DIN, + AUDIO_CODEC_PA_PIN, AUDIO_CODEC_ES8311_ADDR, + false); // use_mclk = false return &audio_codec; } diff --git a/main/boards/m5stack-cardputer-adv/tca8418_keyboard.cc b/main/boards/m5stack-cardputer-adv/tca8418_keyboard.cc new file mode 100644 index 0000000..2b4511a --- /dev/null +++ b/main/boards/m5stack-cardputer-adv/tca8418_keyboard.cc @@ -0,0 +1,418 @@ +#include "tca8418_keyboard.h" +#include + +#define TAG "TCA8418" + +// TCA8418 additional registers +#define TCA8418_REG_GPIO_INT_EN_1 0x1A +#define TCA8418_REG_GPIO_INT_EN_2 0x1B +#define TCA8418_REG_GPIO_INT_EN_3 0x1C +#define TCA8418_REG_GPIO_DAT_STAT_1 0x14 +#define TCA8418_REG_GPIO_DAT_STAT_2 0x15 +#define TCA8418_REG_GPIO_DAT_STAT_3 0x16 +#define TCA8418_REG_GPIO_DAT_OUT_1 0x17 +#define TCA8418_REG_GPIO_DAT_OUT_2 0x18 +#define TCA8418_REG_GPIO_DAT_OUT_3 0x19 +#define TCA8418_REG_GPIO_INT_LVL_1 0x20 +#define TCA8418_REG_GPIO_INT_LVL_2 0x21 +#define TCA8418_REG_GPIO_INT_LVL_3 0x22 +#define TCA8418_REG_DEBOUNCE_DIS_1 0x29 +#define TCA8418_REG_DEBOUNCE_DIS_2 0x2A +#define TCA8418_REG_DEBOUNCE_DIS_3 0x2B +#define TCA8418_REG_GPIO_PULL_1 0x2C +#define TCA8418_REG_GPIO_PULL_2 0x2D +#define TCA8418_REG_GPIO_PULL_3 0x2E + +// Config register bits +#define TCA8418_CFG_AI 0x80 // Auto-increment for read/write +#define TCA8418_CFG_GPI_E_CFG 0x40 // GPI event mode config +#define TCA8418_CFG_OVR_FLOW_M 0x20 // Overflow mode +#define TCA8418_CFG_INT_CFG 0x10 // Interrupt config +#define TCA8418_CFG_OVR_FLOW_IEN 0x08 // Overflow interrupt enable +#define TCA8418_CFG_K_LCK_IEN 0x04 // Keypad lock interrupt enable +#define TCA8418_CFG_GPI_IEN 0x02 // GPI interrupt enable + +// Interrupt status bits +#define TCA8418_INT_STAT_CAD_INT 0x10 // CTRL-ALT-DEL interrupt +#define TCA8418_INT_STAT_OVR_FLOW 0x08 // Overflow interrupt +#define TCA8418_INT_STAT_K_LCK_INT 0x04 // Key lock interrupt +#define TCA8418_INT_STAT_GPI_INT 0x02 // GPI interrupt +#define TCA8418_INT_STAT_K_INT 0x01 // Key event interrupt + +// Key value structure for mapping +struct KeyValue { + const char* normal; // Normal character + uint8_t normal_code; // Normal key code + const char* shifted; // Shifted character + uint8_t shifted_code; // Shifted key code (same as normal for letters) +}; + +// 4x14 keyboard matrix mapping (based on M5Cardputer-UserDemo) +// Row 0: ` 1 2 3 4 5 6 7 8 9 0 - = Del +// Row 1: Tab Q W E R T Y U I O P [ ] Backslash +// Row 2: Shift CapsLk A S D F G H J K L ; ' Enter +// Row 3: Ctrl Opt Alt Z X C V B N M , . / Space +static const KeyValue KEY_MAP[4][14] = { + // Row 0 + {{"`", KC_GRAVE, "~", KC_GRAVE}, + {"1", KC_1, "!", KC_1}, + {"2", KC_2, "@", KC_2}, + {"3", KC_3, "#", KC_3}, + {"4", KC_4, "$", KC_4}, + {"5", KC_5, "%", KC_5}, + {"6", KC_6, "^", KC_6}, + {"7", KC_7, "&", KC_7}, + {"8", KC_8, "*", KC_8}, + {"9", KC_9, "(", KC_9}, + {"0", KC_0, ")", KC_0}, + {"-", KC_MINUS, "_", KC_MINUS}, + {"=", KC_EQUAL, "+", KC_EQUAL}, + {"", KC_BACKSPACE, "", KC_BACKSPACE}}, // Del/Backspace + // Row 1 + {{"", KC_TAB, "", KC_TAB}, // Tab + {"q", KC_Q, "Q", KC_Q}, + {"w", KC_W, "W", KC_W}, + {"e", KC_E, "E", KC_E}, + {"r", KC_R, "R", KC_R}, + {"t", KC_T, "T", KC_T}, + {"y", KC_Y, "Y", KC_Y}, + {"u", KC_U, "U", KC_U}, + {"i", KC_I, "I", KC_I}, + {"o", KC_O, "O", KC_O}, + {"p", KC_P, "P", KC_P}, + {"[", KC_LBRACKET, "{", KC_LBRACKET}, + {"]", KC_RBRACKET, "}", KC_RBRACKET}, + {"\\", KC_BACKSLASH, "|", KC_BACKSLASH}}, + // Row 2 + {{"", KC_LSHIFT, "", KC_LSHIFT}, // Shift + {"", KC_CAPSLOCK, "", KC_CAPSLOCK}, // CapsLock + {"a", KC_A, "A", KC_A}, + {"s", KC_S, "S", KC_S}, + {"d", KC_D, "D", KC_D}, + {"f", KC_F, "F", KC_F}, + {"g", KC_G, "G", KC_G}, + {"h", KC_H, "H", KC_H}, + {"j", KC_J, "J", KC_J}, + {"k", KC_K, "K", KC_K}, + {"l", KC_L, "L", KC_L}, + {";", KC_SEMICOLON, ":", KC_SEMICOLON}, + {"'", KC_APOSTROPHE, "\"", KC_APOSTROPHE}, + {"", KC_ENTER, "", KC_ENTER}}, // Enter + // Row 3 + {{"", KC_LCTRL, "", KC_LCTRL}, // Ctrl + {"", KC_LOPT, "", KC_LOPT}, // Opt + {"", KC_LALT, "", KC_LALT}, // Alt + {"z", KC_Z, "Z", KC_Z}, + {"x", KC_X, "X", KC_X}, + {"c", KC_C, "C", KC_C}, + {"v", KC_V, "V", KC_V}, + {"b", KC_B, "B", KC_B}, + {"n", KC_N, "N", KC_N}, + {"m", KC_M, "M", KC_M}, + {",", KC_COMMA, "<", KC_COMMA}, + {".", KC_DOT, ">", KC_DOT}, + {"/", KC_SLASH, "?", KC_SLASH}, + {" ", KC_SPACE, " ", KC_SPACE}} +}; + +// Cardputer Adv uses TCA8418 in a 7x8 matrix, but the physical keyboard layout +// matches Cardputer's 4x14 mapping. Remap raw (row,col) from the 7x8 scan into +// the 4x14 logical layout (based on M5Cardputer-UserDemo CardputerADV branch). +static inline bool RemapRawKeyToLogical(uint8_t& row, uint8_t& col) { + // Raw scan: row 0..6, col 0..7 + if (row >= 7 || col >= 8) { + return false; + } + + // Col: every raw row contributes two logical columns (left/right half) + uint8_t mapped_col = (row * 2) + ((col > 3) ? 1 : 0); // 0..13 + // Row: derived from raw col (wrap every 4) + uint8_t mapped_row = (col + 4) % 4; // 0..3 + + row = mapped_row; + col = mapped_col; + return true; +} + +static inline uint64_t LogicalKeyMask(uint8_t row, uint8_t col) { + // 4x14 = 56 keys, fits in 64-bit + uint8_t idx = (row * 14) + col; + if (idx >= 64) { + return 0; + } + return 1ULL << idx; +} + +Tca8418Keyboard::Tca8418Keyboard(i2c_master_bus_handle_t i2c_bus, uint8_t addr, gpio_num_t int_pin) + : I2cDevice(i2c_bus, addr), int_pin_(int_pin) { +} + +Tca8418Keyboard::~Tca8418Keyboard() { + if (task_handle_) { + vTaskDelete(task_handle_); + task_handle_ = nullptr; + } + gpio_isr_handler_remove(int_pin_); +} + +void Tca8418Keyboard::Initialize() { + ESP_LOGI(TAG, "Initializing TCA8418 keyboard"); + + // Configure keyboard matrix + ConfigureMatrix(); + + // Flush any pending events + FlushEvents(); + + // Enable interrupts + EnableInterrupts(); + + // Configure GPIO interrupt pin + gpio_config_t io_conf = {}; + // IRQ is active-low and can stay low while events are pending, so use ANYEDGE. + io_conf.intr_type = GPIO_INTR_ANYEDGE; + io_conf.mode = GPIO_MODE_INPUT; + io_conf.pin_bit_mask = (1ULL << int_pin_); + // Cardputer Adv board provides external pull-ups; keep internal pulls disabled. + io_conf.pull_up_en = GPIO_PULLUP_DISABLE; + io_conf.pull_down_en = GPIO_PULLDOWN_DISABLE; + gpio_config(&io_conf); + + // Install GPIO ISR service if not already installed + gpio_install_isr_service(0); + gpio_isr_handler_add(int_pin_, GpioIsrHandler, this); + + // Create keyboard task + xTaskCreate(KeyboardTask, "keyboard_task", 4096, this, 5, &task_handle_); + + ESP_LOGI(TAG, "TCA8418 keyboard initialized"); +} + +void Tca8418Keyboard::ConfigureMatrix() { + // Cardputer Adv keyboard is wired as a 7x8 matrix (rows: R0-R6, cols: C0-C7). + // KP_GPIO1: R0-R7 (bits 0-7) + // KP_GPIO2: C0-C7 (bits 0-7) + // KP_GPIO3: C8-C9 + GPIO (unused here) + WriteReg(TCA8418_REG_KP_GPIO_1, 0x7F); // R0-R6 + WriteReg(TCA8418_REG_KP_GPIO_2, 0xFF); // C0-C7 + WriteReg(TCA8418_REG_KP_GPIO_3, 0x00); // no extended cols +} + +void Tca8418Keyboard::EnableInterrupts() { + // Enable key event interrupt + uint8_t cfg = TCA8418_CFG_KE_IEN | TCA8418_CFG_OVR_FLOW_M | TCA8418_CFG_INT_CFG; + WriteReg(TCA8418_REG_CFG, cfg); +} + +void Tca8418Keyboard::FlushEvents() { + // Read and discard all pending key events + uint8_t event; + int count = 0; + while ((event = GetEvent()) != 0 && count < 10) { + count++; + } + + // Clear interrupt status + WriteReg(TCA8418_REG_INT_STAT, 0x1F); +} + +uint8_t Tca8418Keyboard::GetEvent() { + return ReadReg(TCA8418_REG_KEY_EVENT_A); +} + +void Tca8418Keyboard::UpdateModifierState(uint8_t row, uint8_t col, bool pressed) { + // Shift key: row 2, col 0 + if (row == 2 && col == 0) { + if (pressed) { + modifier_mask_ |= KEY_MOD_SHIFT; + } else { + modifier_mask_ &= ~KEY_MOD_SHIFT; + } + } + // Ctrl key: row 3, col 0 + else if (row == 3 && col == 0) { + if (pressed) { + modifier_mask_ |= KEY_MOD_CTRL; + } else { + modifier_mask_ &= ~KEY_MOD_CTRL; + } + } + // Alt key: row 3, col 2 + else if (row == 3 && col == 2) { + if (pressed) { + modifier_mask_ |= KEY_MOD_ALT; + } else { + modifier_mask_ &= ~KEY_MOD_ALT; + } + } + // Opt key: row 3, col 1 + else if (row == 3 && col == 1) { + if (pressed) { + modifier_mask_ |= KEY_MOD_OPT; + } else { + modifier_mask_ &= ~KEY_MOD_OPT; + } + } + // CapsLock key: row 2, col 1 (toggle on press) + else if (row == 2 && col == 1 && pressed) { + caps_lock_on_ = !caps_lock_on_; + ESP_LOGD(TAG, "CapsLock toggled: %s", caps_lock_on_ ? "ON" : "OFF"); + } +} + +LegacyKeyCode Tca8418Keyboard::MapLegacyKeyCode(uint8_t row, uint8_t col) { + // Arrow keys mapping based on M5Cardputer layout: + // UP: ; key - row 2, col 11 + // DOWN: . key - row 3, col 11 + // LEFT: , key - row 3, col 10 + // RIGHT: / key - row 3, col 12 + // ENTER: enter key - row 2, col 13 + + if (row == 2 && col == 11) return KEY_UP; // ; key + if (row == 3 && col == 11) return KEY_DOWN; // . key + if (row == 3 && col == 10) return KEY_LEFT; // , key + if (row == 3 && col == 12) return KEY_RIGHT; // / key + if (row == 2 && col == 13) return KEY_ENTER; // Enter key + + return KEY_OTHER; +} + +KeyEvent Tca8418Keyboard::MapKeyEvent(uint8_t row, uint8_t col, bool pressed) { + KeyEvent event; + event.pressed = pressed; + event.is_modifier = false; + event.key_code = KC_NONE; + event.key_char = ""; + + if (row >= 4 || col >= 14) { + return event; + } + + const KeyValue& kv = KEY_MAP[row][col]; + event.key_code = kv.normal_code; + + // Check if this is a modifier key + if (event.key_code == KC_LSHIFT || event.key_code == KC_LCTRL || + event.key_code == KC_LALT || event.key_code == KC_LOPT || + event.key_code == KC_CAPSLOCK) { + event.is_modifier = true; + event.key_char = ""; + return event; + } + + // Determine if we should use shifted version + bool use_shifted = false; + + // Check if this is a letter key (a-z) + bool is_letter = (event.key_code >= KC_A && event.key_code <= KC_Z); + + if (is_letter) { + // For letters, use XOR of shift and caps lock (Shift reverses CapsLock) + bool shift_pressed = (modifier_mask_ & KEY_MOD_SHIFT) != 0; + use_shifted = shift_pressed != caps_lock_on_; // XOR semantics + } else { + // For non-letters (numbers, symbols), only use shift + use_shifted = (modifier_mask_ & KEY_MOD_SHIFT) != 0; + } + + if (use_shifted) { + event.key_char = kv.shifted; + } else { + event.key_char = kv.normal; + } + + return event; +} + +void IRAM_ATTR Tca8418Keyboard::GpioIsrHandler(void* arg) { + Tca8418Keyboard* keyboard = static_cast(arg); + keyboard->isr_flag_ = true; + + // Wake up the keyboard task + BaseType_t xHigherPriorityTaskWoken = pdFALSE; + if (keyboard->task_handle_) { + vTaskNotifyGiveFromISR(keyboard->task_handle_, &xHigherPriorityTaskWoken); + portYIELD_FROM_ISR(xHigherPriorityTaskWoken); + } +} + +void Tca8418Keyboard::KeyboardTask(void* arg) { + Tca8418Keyboard* keyboard = static_cast(arg); + + while (true) { + // Wait for interrupt notification + ulTaskNotifyTake(pdTRUE, portMAX_DELAY); + + // Small delay for debounce / allow event FIFO to fill + vTaskDelay(pdMS_TO_TICKS(5)); + + // Drain pending key events until the IRQ condition clears. + for (int guard = 0; guard < 128; guard++) { + uint8_t int_stat = keyboard->ReadReg(TCA8418_REG_INT_STAT); + if ((int_stat & TCA8418_INT_STAT_K_INT) == 0) { + keyboard->isr_flag_ = false; + break; + } + + uint8_t event = keyboard->GetEvent(); + if (event == 0) { + // No event available, try clearing and re-checking. + keyboard->WriteReg(TCA8418_REG_INT_STAT, 0x1F); + vTaskDelay(pdMS_TO_TICKS(1)); + continue; + } + + bool pressed = (event & 0x80) != 0; + uint8_t key_code = event & 0x7F; + if (key_code == 0) { + continue; + } + + // Raw decode: TCA8418 key code = (row * 10) + col + 1 + uint8_t raw_row = (key_code - 1) / 10; + uint8_t raw_col = (key_code - 1) % 10; + + // Cardputer Adv uses 7x8, so ignore events outside 0..6/0..7. + uint8_t row = raw_row; + uint8_t col = raw_col; + if (!RemapRawKeyToLogical(row, col)) { + ESP_LOGD(TAG, "Ignored key: code=%d raw_row=%d raw_col=%d", key_code, raw_row, raw_col); + continue; + } + + // De-duplicate spurious repeated press/release events (debounce/IRQ quirks). + const uint64_t mask = LogicalKeyMask(row, col); + if (mask != 0) { + const bool was_pressed = (keyboard->key_state_mask_ & mask) != 0; + if (pressed == was_pressed) { + continue; + } + if (pressed) { + keyboard->key_state_mask_ |= mask; + } else { + keyboard->key_state_mask_ &= ~mask; + } + } + + ESP_LOGD(TAG, "Key %s: code=%d raw=(%d,%d) mapped=(%d,%d)", + pressed ? "pressed" : "released", key_code, raw_row, raw_col, row, col); + + keyboard->UpdateModifierState(row, col, pressed); + + if (keyboard->key_event_callback_) { + KeyEvent key_event = keyboard->MapKeyEvent(row, col, pressed); + keyboard->key_event_callback_(key_event); + } + + if (pressed && keyboard->key_callback_) { + LegacyKeyCode mapped_key = keyboard->MapLegacyKeyCode(row, col); + if (mapped_key != KEY_OTHER && mapped_key != KEY_NONE) { + keyboard->key_callback_(mapped_key); + } + } + } + + // Clear all interrupt status bits (K_INT, GPI, overflow, etc.) + keyboard->WriteReg(TCA8418_REG_INT_STAT, 0x1F); + } +} diff --git a/main/boards/m5stack-cardputer-adv/tca8418_keyboard.h b/main/boards/m5stack-cardputer-adv/tca8418_keyboard.h new file mode 100644 index 0000000..dbcc6a0 --- /dev/null +++ b/main/boards/m5stack-cardputer-adv/tca8418_keyboard.h @@ -0,0 +1,155 @@ +#ifndef TCA8418_KEYBOARD_H +#define TCA8418_KEYBOARD_H + +#include "i2c_device.h" +#include +#include +#include +#include + +// TCA8418 Register definitions +#define TCA8418_REG_CFG 0x01 +#define TCA8418_REG_INT_STAT 0x02 +#define TCA8418_REG_KEY_LCK_EC 0x03 +#define TCA8418_REG_KEY_EVENT_A 0x04 +#define TCA8418_REG_KP_GPIO_1 0x1D +#define TCA8418_REG_KP_GPIO_2 0x1E +#define TCA8418_REG_KP_GPIO_3 0x1F + +// Config register bits +#define TCA8418_CFG_KE_IEN 0x01 // Key events interrupt enable + +// Modifier key masks +enum KeyModifier { + KEY_MOD_NONE = 0x00, + KEY_MOD_SHIFT = 0x01, + KEY_MOD_CTRL = 0x02, + KEY_MOD_ALT = 0x04, + KEY_MOD_OPT = 0x08, +}; + +// HID-compatible key codes +enum KeyCode { + KC_NONE = 0x00, + KC_A = 0x04, + KC_B = 0x05, + KC_C = 0x06, + KC_D = 0x07, + KC_E = 0x08, + KC_F = 0x09, + KC_G = 0x0A, + KC_H = 0x0B, + KC_I = 0x0C, + KC_J = 0x0D, + KC_K = 0x0E, + KC_L = 0x0F, + KC_M = 0x10, + KC_N = 0x11, + KC_O = 0x12, + KC_P = 0x13, + KC_Q = 0x14, + KC_R = 0x15, + KC_S = 0x16, + KC_T = 0x17, + KC_U = 0x18, + KC_V = 0x19, + KC_W = 0x1A, + KC_X = 0x1B, + KC_Y = 0x1C, + KC_Z = 0x1D, + KC_1 = 0x1E, + KC_2 = 0x1F, + KC_3 = 0x20, + KC_4 = 0x21, + KC_5 = 0x22, + KC_6 = 0x23, + KC_7 = 0x24, + KC_8 = 0x25, + KC_9 = 0x26, + KC_0 = 0x27, + KC_ENTER = 0x28, + KC_ESC = 0x29, + KC_BACKSPACE = 0x2A, + KC_TAB = 0x2B, + KC_SPACE = 0x2C, + KC_MINUS = 0x2D, + KC_EQUAL = 0x2E, + KC_LBRACKET = 0x2F, + KC_RBRACKET = 0x30, + KC_BACKSLASH = 0x31, + KC_SEMICOLON = 0x33, + KC_APOSTROPHE = 0x34, + KC_GRAVE = 0x35, + KC_COMMA = 0x36, + KC_DOT = 0x37, + KC_SLASH = 0x38, + KC_CAPSLOCK = 0x39, + KC_RIGHT = 0x4F, + KC_LEFT = 0x50, + KC_DOWN = 0x51, + KC_UP = 0x52, + KC_LSHIFT = 0xE1, + KC_LCTRL = 0xE0, + KC_LALT = 0xE2, + KC_LOPT = 0xE3, +}; + +// Key event structure with full information +struct KeyEvent { + bool pressed; // true = pressed, false = released + bool is_modifier; // true if this is a modifier key + uint8_t key_code; // HID key code (KeyCode enum) + const char* key_char; // Character representation (e.g., "a", "A", "1", "!") +}; + +// Legacy key codes for backward compatibility +enum LegacyKeyCode { + KEY_NONE = 0, + KEY_UP, + KEY_DOWN, + KEY_LEFT, + KEY_RIGHT, + KEY_ENTER, + KEY_OTHER +}; + +class Tca8418Keyboard : public I2cDevice { +public: + using KeyCallback = std::function; + using KeyEventCallback = std::function; + + Tca8418Keyboard(i2c_master_bus_handle_t i2c_bus, uint8_t addr, gpio_num_t int_pin); + ~Tca8418Keyboard(); + + void Initialize(); + void SetKeyCallback(KeyCallback callback) { key_callback_ = callback; } + void SetKeyEventCallback(KeyEventCallback callback) { key_event_callback_ = callback; } + + // Get current modifier state + uint8_t GetModifierMask() const { return modifier_mask_; } + bool IsShiftPressed() const { return (modifier_mask_ & KEY_MOD_SHIFT) != 0; } + bool IsCapsLockOn() const { return caps_lock_on_; } + +private: + gpio_num_t int_pin_; + KeyCallback key_callback_; + KeyEventCallback key_event_callback_; + TaskHandle_t task_handle_ = nullptr; + volatile bool isr_flag_ = false; + uint8_t modifier_mask_ = 0; + bool caps_lock_on_ = false; + uint64_t key_state_mask_ = 0; // 4x14 logical keys, bit=1 means pressed + + void ConfigureMatrix(); + void EnableInterrupts(); + void FlushEvents(); + uint8_t GetEvent(); + LegacyKeyCode MapLegacyKeyCode(uint8_t row, uint8_t col); + KeyEvent MapKeyEvent(uint8_t row, uint8_t col, bool pressed); + void UpdateModifierState(uint8_t row, uint8_t col, bool pressed); + + static void IRAM_ATTR GpioIsrHandler(void* arg); + static void KeyboardTask(void* arg); +}; + +#endif // TCA8418_KEYBOARD_H diff --git a/main/boards/m5stack-cardputer-adv/wifi_config_ui.cc b/main/boards/m5stack-cardputer-adv/wifi_config_ui.cc new file mode 100644 index 0000000..73c399b --- /dev/null +++ b/main/boards/m5stack-cardputer-adv/wifi_config_ui.cc @@ -0,0 +1,705 @@ +#include "wifi_config_ui.h" +#include +#include +#include +#include +#include + +#define TAG "WifiConfigUI" + +WifiConfigUI::WifiConfigUI(LcdDisplay* display) + : display_(display), + state_(WifiConfigState::Scanning), + is_active_(false), + selected_index_(0), + scroll_offset_(0), + saved_selected_index_(0), + saved_scroll_offset_(0), + input_focus_on_password_(false), + cursor_visible_(true), + last_cursor_toggle_(0) { +} + +WifiConfigUI::~WifiConfigUI() { +} + +void WifiConfigUI::Start() { + ESP_LOGI(TAG, "Starting WiFi config UI"); + is_active_ = true; + state_ = WifiConfigState::Scanning; + selected_index_ = 0; + scroll_offset_ = 0; + input_ssid_.clear(); + input_password_.clear(); + selected_ssid_.clear(); + + // Load saved WiFi list + LoadSavedWifiList(); + + // Start scanning + StartScanning(); +} + +void WifiConfigUI::StartWithSavedList() { + ESP_LOGI(TAG, "Starting WiFi config UI with saved list"); + is_active_ = true; + selected_index_ = 0; + scroll_offset_ = 0; + input_ssid_.clear(); + input_password_.clear(); + selected_ssid_.clear(); + + // Show saved list directly (ShowSavedList will load the list) + ShowSavedList(); +} + +void WifiConfigUI::StartScanning() { + state_ = WifiConfigState::Scanning; + + lv_obj_t* canvas = lv_scr_act(); + lv_obj_clean(canvas); + DrawHeader("扫描 WiFi 中..."); + DrawFooter("请稍候..."); + + // Perform WiFi scan + DoWifiScan(); + + // Show results + if (scan_results_.empty()) { + lv_obj_clean(canvas); + DrawHeader("未找到 WiFi"); + DrawFooter("W:手动输入 Esc:退出"); + } else { + state_ = WifiConfigState::SelectWifi; + ShowScanResults(); + } +} + +void WifiConfigUI::DoWifiScan() { + scan_results_.clear(); + + // Use WifiManager's scan capability if available, otherwise do direct scan + // Note: We need to be careful not to disrupt existing WiFi state + + // Configure scan + wifi_scan_config_t scan_config = {}; + scan_config.show_hidden = false; + scan_config.scan_type = WIFI_SCAN_TYPE_ACTIVE; + scan_config.scan_time.active.min = 100; + scan_config.scan_time.active.max = 300; + + // Start scan (blocking) - WiFi should already be initialized by WifiManager + esp_err_t err = esp_wifi_scan_start(&scan_config, true); + if (err != ESP_OK) { + ESP_LOGE(TAG, "WiFi scan failed: %s", esp_err_to_name(err)); + return; + } + + // Get scan results + uint16_t ap_count = 0; + esp_wifi_scan_get_ap_num(&ap_count); + + if (ap_count > 0) { + wifi_ap_record_t* ap_records = new wifi_ap_record_t[ap_count]; + esp_wifi_scan_get_ap_records(&ap_count, ap_records); + + for (int i = 0; i < ap_count && i < 20; i++) { + WifiScanResult result; + result.ssid = std::string(reinterpret_cast(ap_records[i].ssid)); + result.rssi = ap_records[i].rssi; + result.is_encrypted = (ap_records[i].authmode != WIFI_AUTH_OPEN); + + // Skip empty SSIDs + if (!result.ssid.empty()) { + scan_results_.push_back(result); + } + } + + delete[] ap_records; + } + + ESP_LOGI(TAG, "Found %d WiFi networks", (int)scan_results_.size()); +} + +void WifiConfigUI::ShowScanResults() { + DrawWifiList(scan_results_, selected_index_, scroll_offset_); +} + +void WifiConfigUI::ShowPasswordInput() { + // Only clear password and set state on first entry (not on redraw) + if (state_ != WifiConfigState::InputPassword) { + state_ = WifiConfigState::InputPassword; + input_password_.clear(); + } + + RedrawPasswordInput(); +} + +void WifiConfigUI::RedrawPasswordInput() { + lv_obj_t* canvas = lv_scr_act(); + lv_obj_clean(canvas); + + DrawHeader("输入密码"); + + // Show selected SSID + lv_obj_t* label = lv_label_create(canvas); + lv_label_set_text_fmt(label, "连接: %s", selected_ssid_.c_str()); + lv_obj_set_style_text_color(label, lv_color_hex(0x00FF00), 0); + lv_obj_align(label, LV_ALIGN_TOP_LEFT, 5, 5); + + lv_obj_t* pwd_label = lv_label_create(canvas); + lv_label_set_text(pwd_label, "请输入密码:"); + lv_obj_set_style_text_color(pwd_label, lv_color_hex(0xFFFFFF), 0); + lv_obj_align(pwd_label, LV_ALIGN_TOP_LEFT, 5, 30); + + lv_obj_t* input_label = lv_label_create(canvas); + std::string display_pwd(input_password_.length(), '*'); + display_pwd += cursor_visible_ ? "_" : " "; + lv_label_set_text_fmt(input_label, ">>> %s", display_pwd.c_str()); + lv_obj_set_style_text_color(input_label, lv_color_hex(0xFFFF00), 0); + lv_obj_align(input_label, LV_ALIGN_TOP_LEFT, 5, 55); + + DrawFooter("Enter:确认 Esc:返回"); +} + +void WifiConfigUI::ShowManualInput() { + // Only clear inputs and set state on first entry (not on redraw) + if (state_ != WifiConfigState::InputSsid && state_ != WifiConfigState::InputManualPwd) { + state_ = WifiConfigState::InputSsid; + input_ssid_.clear(); + input_password_.clear(); + input_focus_on_password_ = false; + } + + RedrawManualInput(); +} + +void WifiConfigUI::RedrawManualInput() { + lv_obj_t* canvas = lv_scr_act(); + lv_obj_clean(canvas); + + DrawHeader("手动设置 WiFi"); + + lv_obj_t* ssid_label = lv_label_create(canvas); + lv_label_set_text(ssid_label, "SSID:"); + lv_obj_set_style_text_color(ssid_label, lv_color_hex(0xFFFFFF), 0); + lv_obj_align(ssid_label, LV_ALIGN_TOP_LEFT, 5, 25); + + lv_obj_t* ssid_input = lv_label_create(canvas); + std::string ssid_display = ">>> " + input_ssid_; + if (!input_focus_on_password_) { + ssid_display += cursor_visible_ ? "_" : " "; + } + lv_label_set_text(ssid_input, ssid_display.c_str()); + lv_obj_set_style_text_color(ssid_input, input_focus_on_password_ ? lv_color_hex(0x888888) : lv_color_hex(0xFFFF00), 0); + lv_obj_align(ssid_input, LV_ALIGN_TOP_LEFT, 5, 45); + + lv_obj_t* pwd_label = lv_label_create(canvas); + lv_label_set_text(pwd_label, "密码:"); + lv_obj_set_style_text_color(pwd_label, lv_color_hex(0xFFFFFF), 0); + lv_obj_align(pwd_label, LV_ALIGN_TOP_LEFT, 5, 70); + + lv_obj_t* pwd_input = lv_label_create(canvas); + std::string pwd_display = ">>> " + std::string(input_password_.length(), '*'); + if (input_focus_on_password_) { + pwd_display += cursor_visible_ ? "_" : " "; + } + lv_label_set_text(pwd_input, pwd_display.c_str()); + lv_obj_set_style_text_color(pwd_input, input_focus_on_password_ ? lv_color_hex(0xFFFF00) : lv_color_hex(0x888888), 0); + lv_obj_align(pwd_input, LV_ALIGN_TOP_LEFT, 5, 90); + + DrawFooter("Tab:切换 Enter:确认 Esc:返回"); +} + +void WifiConfigUI::ShowSavedList() { + state_ = WifiConfigState::SavedList; + saved_selected_index_ = 0; + saved_scroll_offset_ = 0; + + LoadSavedWifiList(); + DrawSavedWifiList(); +} + +void WifiConfigUI::DrawSavedWifiList() { + lv_obj_t* canvas = lv_scr_act(); + lv_obj_clean(canvas); + + char title[48]; + snprintf(title, sizeof(title), "已保存的 WiFi (%d/10)", (int)saved_wifi_list_.size()); + DrawHeader(title); + + if (saved_wifi_list_.empty()) { + lv_obj_t* empty_label = lv_label_create(canvas); + lv_label_set_text(empty_label, "没有已保存的 WiFi"); + lv_obj_set_style_text_color(empty_label, lv_color_hex(0x888888), 0); + lv_obj_align(empty_label, LV_ALIGN_CENTER, 0, 0); + DrawFooter("Esc:返回"); + return; + } + + int y_offset = 25; + int visible_count = std::min((int)saved_wifi_list_.size() - saved_scroll_offset_, MAX_VISIBLE_ITEMS); + + for (int i = 0; i < visible_count; i++) { + int idx = saved_scroll_offset_ + i; + bool is_selected = (idx == saved_selected_index_); + + lv_obj_t* item_label = lv_label_create(canvas); + char item_text[48]; + snprintf(item_text, sizeof(item_text), "%s %d. %s", + is_selected ? ">" : " ", + idx + 1, + saved_wifi_list_[idx].first.c_str()); + lv_label_set_text(item_label, item_text); + lv_obj_set_style_text_color(item_label, is_selected ? lv_color_hex(0x00FF00) : lv_color_hex(0xFFFFFF), 0); + lv_obj_align(item_label, LV_ALIGN_TOP_LEFT, 5, y_offset); + y_offset += 20; + } + + DrawFooter("↑↓:选择 Enter:连接 Del:删除 Esc:返回"); +} + +void WifiConfigUI::ShowConnecting() { + state_ = WifiConfigState::Connecting; + + lv_obj_t* canvas = lv_scr_act(); + lv_obj_clean(canvas); + + DrawHeader("连接中..."); + + lv_obj_t* ssid_label = lv_label_create(canvas); + lv_label_set_text_fmt(ssid_label, "正在连接: %s", selected_ssid_.c_str()); + lv_obj_set_style_text_color(ssid_label, lv_color_hex(0xFFFF00), 0); + lv_obj_align(ssid_label, LV_ALIGN_CENTER, 0, 0); + + DrawFooter("请稍候..."); +} + +void WifiConfigUI::ShowSuccess() { + state_ = WifiConfigState::Success; + + lv_obj_t* canvas = lv_scr_act(); + lv_obj_clean(canvas); + + DrawHeader("连接成功!"); + + lv_obj_t* ssid_label = lv_label_create(canvas); + lv_label_set_text_fmt(ssid_label, "已连接: %s", selected_ssid_.c_str()); + lv_obj_set_style_text_color(ssid_label, lv_color_hex(0x00FF00), 0); + lv_obj_align(ssid_label, LV_ALIGN_CENTER, 0, -10); + + lv_obj_t* saved_label = lv_label_create(canvas); + lv_label_set_text(saved_label, "WiFi 配置已保存"); + lv_obj_set_style_text_color(saved_label, lv_color_hex(0x00FFFF), 0); + lv_obj_align(saved_label, LV_ALIGN_CENTER, 0, 15); + + DrawFooter("Enter:继续"); +} + +void WifiConfigUI::ShowFailed() { + state_ = WifiConfigState::Failed; + + lv_obj_t* canvas = lv_scr_act(); + lv_obj_clean(canvas); + + DrawHeader("连接失败"); + + lv_obj_t* ssid_label = lv_label_create(canvas); + lv_label_set_text_fmt(ssid_label, "无法连接: %s", selected_ssid_.c_str()); + lv_obj_set_style_text_color(ssid_label, lv_color_hex(0xFF0000), 0); + lv_obj_align(ssid_label, LV_ALIGN_CENTER, 0, 0); + + DrawFooter("Enter:重试 Esc:返回"); +} + +void WifiConfigUI::DrawHeader(const char* title) { + lv_obj_t* canvas = lv_scr_act(); + + lv_obj_t* header = lv_label_create(canvas); + lv_label_set_text(header, title); + lv_obj_set_style_text_color(header, lv_color_hex(0x00FFFF), 0); + lv_obj_align(header, LV_ALIGN_TOP_LEFT, 5, 2); +} + +void WifiConfigUI::DrawFooter(const char* hint) { + lv_obj_t* canvas = lv_scr_act(); + + lv_obj_t* footer = lv_label_create(canvas); + lv_label_set_text(footer, hint); + lv_obj_set_style_text_color(footer, lv_color_hex(0x888888), 0); + lv_obj_set_style_text_font(footer, &lv_font_montserrat_14, 0); + lv_obj_align(footer, LV_ALIGN_BOTTOM_LEFT, 5, -2); +} + +void WifiConfigUI::DrawWifiList(const std::vector& list, int selected, int scroll) { + lv_obj_t* canvas = lv_scr_act(); + lv_obj_clean(canvas); + + DrawHeader("选择 WiFi"); + + int y_offset = 25; + int visible_count = std::min((int)list.size() - scroll, MAX_VISIBLE_ITEMS); + + for (int i = 0; i < visible_count; i++) { + int idx = scroll + i; + bool is_selected = (idx == selected); + const WifiScanResult& wifi = list[idx]; + + lv_obj_t* item_label = lv_label_create(canvas); + std::string signal = GetSignalBars(wifi.rssi); + char item_text[64]; + snprintf(item_text, sizeof(item_text), "%s%d.%-12s %4ddBm %s", + is_selected ? ">" : " ", + idx + 1, + wifi.ssid.substr(0, 12).c_str(), + wifi.rssi, + signal.c_str()); + lv_label_set_text(item_label, item_text); + lv_obj_set_style_text_color(item_label, is_selected ? lv_color_hex(0x00FF00) : lv_color_hex(0xFFFFFF), 0); + lv_obj_align(item_label, LV_ALIGN_TOP_LEFT, 2, y_offset); + y_offset += 20; + } + + DrawFooter("↑↓:选择 Enter:连接 W:手动 S:已保存"); +} + +std::string WifiConfigUI::GetSignalBars(int8_t rssi) { + if (rssi >= -50) return "████"; + if (rssi >= -60) return "███░"; + if (rssi >= -70) return "██░░"; + if (rssi >= -80) return "█░░░"; + return "░░░░"; +} + +void WifiConfigUI::LoadSavedWifiList() { + saved_wifi_list_.clear(); + auto& ssid_manager = SsidManager::GetInstance(); + const auto& ssid_list = ssid_manager.GetSsidList(); + + for (const auto& item : ssid_list) { + saved_wifi_list_.push_back({item.ssid, item.password}); + } +} + +void WifiConfigUI::SaveWifiCredentials(const std::string& ssid, const std::string& password) { + auto& ssid_manager = SsidManager::GetInstance(); + ssid_manager.AddSsid(ssid, password); + ESP_LOGI(TAG, "Saved WiFi credentials for: %s", ssid.c_str()); +} + +void WifiConfigUI::DeleteSavedWifi(int index) { + if (index >= 0 && index < (int)saved_wifi_list_.size()) { + auto& ssid_manager = SsidManager::GetInstance(); + ssid_manager.RemoveSsid(index); + ESP_LOGI(TAG, "Deleted saved WiFi at index: %d", index); + LoadSavedWifiList(); + } +} + +void WifiConfigUI::AttemptConnection() { + ShowConnecting(); + + if (connect_callback_) { + connect_callback_(selected_ssid_, input_password_); + } +} + +void WifiConfigUI::OnConnectResult(bool success) { + if (success) { + SaveWifiCredentials(selected_ssid_, input_password_); + ShowSuccess(); + } else { + ShowFailed(); + } +} + +WifiConfigResult WifiConfigUI::HandleKeyEvent(const KeyEvent& event) { + // Only handle key press events, skip modifiers + if (!event.pressed || event.is_modifier) { + return WifiConfigResult::None; + } + + // Check for ESC to cancel from Scanning or SelectWifi states + // (other states handle ESC in their own handlers to navigate back) + if (event.key_code == KC_ESC) { + if (state_ == WifiConfigState::Scanning || + state_ == WifiConfigState::SelectWifi) { + is_active_ = false; + return WifiConfigResult::Cancelled; + } + } + + // Check if not active (was cancelled in a handler) + if (!is_active_) { + return WifiConfigResult::Cancelled; + } + + switch (state_) { + case WifiConfigState::Scanning: + HandleScanningKey(event); + break; + case WifiConfigState::SelectWifi: + HandleSelectWifiKey(event); + break; + case WifiConfigState::InputPassword: + HandlePasswordInputKey(event); + break; + case WifiConfigState::InputSsid: + case WifiConfigState::InputManualPwd: + HandleManualInputKey(event); + break; + case WifiConfigState::SavedList: + HandleSavedListKey(event); + break; + case WifiConfigState::Connecting: + HandleConnectingKey(event); + break; + case WifiConfigState::Success: + HandleResultKey(event); + if (event.key_code == KC_ENTER) { + is_active_ = false; + return WifiConfigResult::Connected; + } + break; + case WifiConfigState::Failed: + HandleResultKey(event); + break; + } + + // Check if cancelled by a handler + if (!is_active_) { + return WifiConfigResult::Cancelled; + } + + return WifiConfigResult::None; +} + +void WifiConfigUI::HandleScanningKey(const KeyEvent& event) { + if (event.key_code == KC_W) { + ShowManualInput(); + } else if (event.key_code == KC_S) { + ShowSavedList(); + } + // ESC is handled in HandleKeyEvent +} + +void WifiConfigUI::HandleSelectWifiKey(const KeyEvent& event) { + switch (event.key_code) { + case KC_UP: + case KC_SEMICOLON: // ; key as UP + if (selected_index_ > 0) { + selected_index_--; + if (selected_index_ < scroll_offset_) { + scroll_offset_ = selected_index_; + } + ShowScanResults(); + } + break; + + case KC_DOWN: + case KC_DOT: // . key as DOWN + if (selected_index_ < (int)scan_results_.size() - 1) { + selected_index_++; + if (selected_index_ >= scroll_offset_ + MAX_VISIBLE_ITEMS) { + scroll_offset_ = selected_index_ - MAX_VISIBLE_ITEMS + 1; + } + ShowScanResults(); + } + break; + + case KC_ENTER: + if (!scan_results_.empty()) { + selected_ssid_ = scan_results_[selected_index_].ssid; + ShowPasswordInput(); + } + break; + + case KC_W: + ShowManualInput(); + break; + + case KC_S: + ShowSavedList(); + break; + + default: + break; + } + // ESC is handled in HandleKeyEvent +} + +void WifiConfigUI::HandlePasswordInputKey(const KeyEvent& event) { + switch (event.key_code) { + case KC_ENTER: + if (!input_password_.empty()) { + AttemptConnection(); + } + break; + + case KC_ESC: + state_ = WifiConfigState::SelectWifi; + ShowScanResults(); + break; + + case KC_BACKSPACE: + if (!input_password_.empty()) { + input_password_.pop_back(); + RedrawPasswordInput(); + } + break; + + case KC_SPACE: + if (input_password_.length() < MAX_INPUT_LENGTH) { + input_password_ += ' '; + RedrawPasswordInput(); + } + break; + + default: + // Add character if it's a printable key + if (event.key_char && strlen(event.key_char) > 0 && input_password_.length() < MAX_INPUT_LENGTH) { + input_password_ += event.key_char; + RedrawPasswordInput(); + } + break; + } +} + +void WifiConfigUI::HandleManualInputKey(const KeyEvent& event) { + std::string* current_input = input_focus_on_password_ ? &input_password_ : &input_ssid_; + + switch (event.key_code) { + case KC_TAB: + input_focus_on_password_ = !input_focus_on_password_; + if (input_focus_on_password_) { + state_ = WifiConfigState::InputManualPwd; + } else { + state_ = WifiConfigState::InputSsid; + } + RedrawManualInput(); + break; + + case KC_ENTER: + if (!input_ssid_.empty()) { + selected_ssid_ = input_ssid_; + AttemptConnection(); + } + break; + + case KC_ESC: + state_ = WifiConfigState::SelectWifi; + ShowScanResults(); + break; + + case KC_BACKSPACE: + if (!current_input->empty()) { + current_input->pop_back(); + RedrawManualInput(); + } + break; + + case KC_SPACE: + if (current_input->length() < MAX_INPUT_LENGTH) { + *current_input += ' '; + RedrawManualInput(); + } + break; + + default: + // Add character if it's a printable key + if (event.key_char && strlen(event.key_char) > 0 && current_input->length() < MAX_INPUT_LENGTH) { + *current_input += event.key_char; + RedrawManualInput(); + } + break; + } +} + +void WifiConfigUI::HandleSavedListKey(const KeyEvent& event) { + switch (event.key_code) { + case KC_UP: + case KC_SEMICOLON: + if (saved_selected_index_ > 0) { + saved_selected_index_--; + if (saved_selected_index_ < saved_scroll_offset_) { + saved_scroll_offset_ = saved_selected_index_; + } + DrawSavedWifiList(); + } + break; + + case KC_DOWN: + case KC_DOT: + if (saved_selected_index_ < (int)saved_wifi_list_.size() - 1) { + saved_selected_index_++; + if (saved_selected_index_ >= saved_scroll_offset_ + MAX_VISIBLE_ITEMS) { + saved_scroll_offset_ = saved_selected_index_ - MAX_VISIBLE_ITEMS + 1; + } + DrawSavedWifiList(); + } + break; + + case KC_ENTER: + if (!saved_wifi_list_.empty()) { + selected_ssid_ = saved_wifi_list_[saved_selected_index_].first; + input_password_ = saved_wifi_list_[saved_selected_index_].second; + AttemptConnection(); + } + break; + + case KC_BACKSPACE: // Del key for delete + if (!saved_wifi_list_.empty()) { + DeleteSavedWifi(saved_selected_index_); + if (saved_selected_index_ >= (int)saved_wifi_list_.size() && saved_selected_index_ > 0) { + saved_selected_index_--; + } + DrawSavedWifiList(); + } + break; + + case KC_ESC: + state_ = WifiConfigState::SelectWifi; + ShowScanResults(); + break; + + default: + break; + } +} + +void WifiConfigUI::HandleConnectingKey(const KeyEvent& event) { + // No key handling during connection + (void)event; +} + +void WifiConfigUI::HandleResultKey(const KeyEvent& event) { + if (state_ == WifiConfigState::Success) { + if (event.key_code == KC_ENTER) { + // Will be handled in HandleKeyEvent to return Connected + } + } else if (state_ == WifiConfigState::Failed) { + if (event.key_code == KC_ENTER) { + // Retry - go back to password input (keep password for retry) + state_ = WifiConfigState::InputPassword; + RedrawPasswordInput(); + } else if (event.key_code == KC_ESC) { + state_ = WifiConfigState::SelectWifi; + ShowScanResults(); + } + } +} + +void WifiConfigUI::UpdateCursor() { + uint32_t now = esp_log_timestamp(); + if (now - last_cursor_toggle_ >= CURSOR_BLINK_MS) { + cursor_visible_ = !cursor_visible_; + last_cursor_toggle_ = now; + + // Refresh display for input states (use Redraw functions to avoid clearing input) + if (state_ == WifiConfigState::InputPassword) { + RedrawPasswordInput(); + } else if (state_ == WifiConfigState::InputSsid || state_ == WifiConfigState::InputManualPwd) { + RedrawManualInput(); + } + } +} diff --git a/main/boards/m5stack-cardputer-adv/wifi_config_ui.h b/main/boards/m5stack-cardputer-adv/wifi_config_ui.h new file mode 100644 index 0000000..7963975 --- /dev/null +++ b/main/boards/m5stack-cardputer-adv/wifi_config_ui.h @@ -0,0 +1,133 @@ +#ifndef WIFI_CONFIG_UI_H +#define WIFI_CONFIG_UI_H + +#include "tca8418_keyboard.h" +#include "display/lcd_display.h" +#include +#include +#include + +// WiFi scan result structure +struct WifiScanResult { + std::string ssid; + int8_t rssi; + bool is_encrypted; +}; + +// WiFi configuration UI state machine +enum class WifiConfigState { + Scanning, // Scanning for WiFi networks + SelectWifi, // Selecting from WiFi list + InputPassword, // Entering password for selected WiFi + InputSsid, // Manual SSID input + InputManualPwd, // Manual password input (after SSID) + SavedList, // Viewing saved WiFi list + Connecting, // Connecting to WiFi + Success, // Connection successful + Failed // Connection failed +}; + +// Result of WiFi configuration +enum class WifiConfigResult { + None, // Still in progress + Connected, // Successfully connected + Cancelled // User cancelled +}; + +class WifiConfigUI { +public: + using ConnectCallback = std::function; + + WifiConfigUI(LcdDisplay* display); + ~WifiConfigUI(); + + // Start the WiFi configuration UI + void Start(); + + // Start directly with saved WiFi list + void StartWithSavedList(); + + // Handle keyboard events, returns result + WifiConfigResult HandleKeyEvent(const KeyEvent& event); + + // Set callback for when connection should be attempted + void SetConnectCallback(ConnectCallback callback) { connect_callback_ = callback; } + + // Notify connection result + void OnConnectResult(bool success); + + // Check if UI is active + bool IsActive() const { return is_active_; } + + // Update cursor blink state (call periodically from main loop) + void UpdateCursor(); + +private: + LcdDisplay* display_; + WifiConfigState state_; + bool is_active_; + ConnectCallback connect_callback_; + + // WiFi scan results + std::vector scan_results_; + int selected_index_; + int scroll_offset_; + + // Saved WiFi list + std::vector> saved_wifi_list_; + int saved_selected_index_; + int saved_scroll_offset_; + + // Input buffers + std::string input_ssid_; + std::string input_password_; + std::string selected_ssid_; + bool input_focus_on_password_; // For manual input: true = password field, false = ssid field + + // Cursor blinking + bool cursor_visible_; + uint32_t last_cursor_toggle_; + static constexpr uint32_t CURSOR_BLINK_MS = 500; + + // Display constants + static constexpr int MAX_VISIBLE_ITEMS = 4; + static constexpr int MAX_INPUT_LENGTH = 64; + + // State handlers + void StartScanning(); + void ShowScanResults(); + void ShowPasswordInput(); + void ShowManualInput(); + void ShowSavedList(); + void ShowConnecting(); + void ShowSuccess(); + void ShowFailed(); + + // Redraw functions (don't reset state/input) + void RedrawPasswordInput(); + void RedrawManualInput(); + + // Input handlers + void HandleScanningKey(const KeyEvent& event); + void HandleSelectWifiKey(const KeyEvent& event); + void HandlePasswordInputKey(const KeyEvent& event); + void HandleManualInputKey(const KeyEvent& event); + void HandleSavedListKey(const KeyEvent& event); + void HandleConnectingKey(const KeyEvent& event); + void HandleResultKey(const KeyEvent& event); + + // Helper functions + void DrawHeader(const char* title); + void DrawFooter(const char* hint); + void DrawInputField(const char* label, const std::string& value, bool is_password, bool is_active); + void DrawWifiList(const std::vector& list, int selected, int scroll_offset); + void DrawSavedWifiList(); + std::string GetSignalBars(int8_t rssi); + void LoadSavedWifiList(); + void SaveWifiCredentials(const std::string& ssid, const std::string& password); + void DeleteSavedWifi(int index); + void DoWifiScan(); + void AttemptConnection(); +}; + +#endif // WIFI_CONFIG_UI_H