From 4305861463f1a440cd40e1c44a82e641a55a6546 Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Mon, 13 Jul 2026 22:55:10 -0500 Subject: [PATCH 01/13] feat(m5stack-cardputer): Add BSP component for M5Stack Cardputer --- .github/workflows/build.yml | 2 + .github/workflows/upload_components.yml | 1 + components/m5stack-cardputer/CMakeLists.txt | 7 + components/m5stack-cardputer/Kconfig | 91 ++ components/m5stack-cardputer/README.md | 50 ++ .../m5stack-cardputer/example/CMakeLists.txt | 22 + .../m5stack-cardputer/example/README.md | 56 ++ .../example/main/CMakeLists.txt | 2 + .../m5stack-cardputer/example/main/gui.cpp | 109 +++ .../m5stack-cardputer/example/main/gui.hpp | 88 ++ .../main/m5stack_cardputer_example.cpp | 133 +++ .../example/sdkconfig.defaults | 52 ++ .../m5stack-cardputer/idf_component.yml | 31 + .../include/m5stack-cardputer.hpp | 799 ++++++++++++++++++ components/m5stack-cardputer/src/audio.cpp | 147 ++++ components/m5stack-cardputer/src/display.cpp | 229 +++++ components/m5stack-cardputer/src/keyboard.cpp | 378 +++++++++ .../src/m5stack-cardputer.cpp | 132 +++ .../m5stack-cardputer/src/microphone.cpp | 102 +++ components/m5stack-cardputer/src/sdcard.cpp | 71 ++ doc/Doxyfile | 2 + doc/en/dev_boards/m5stack/index.rst | 1 + .../dev_boards/m5stack/m5stack_cardputer.rst | 35 + .../m5stack/m5stack_cardputer_example.md | 2 + 24 files changed, 2542 insertions(+) create mode 100644 components/m5stack-cardputer/CMakeLists.txt create mode 100644 components/m5stack-cardputer/Kconfig create mode 100644 components/m5stack-cardputer/README.md create mode 100644 components/m5stack-cardputer/example/CMakeLists.txt create mode 100644 components/m5stack-cardputer/example/README.md create mode 100644 components/m5stack-cardputer/example/main/CMakeLists.txt create mode 100644 components/m5stack-cardputer/example/main/gui.cpp create mode 100644 components/m5stack-cardputer/example/main/gui.hpp create mode 100644 components/m5stack-cardputer/example/main/m5stack_cardputer_example.cpp create mode 100644 components/m5stack-cardputer/example/sdkconfig.defaults create mode 100644 components/m5stack-cardputer/idf_component.yml create mode 100644 components/m5stack-cardputer/include/m5stack-cardputer.hpp create mode 100644 components/m5stack-cardputer/src/audio.cpp create mode 100644 components/m5stack-cardputer/src/display.cpp create mode 100644 components/m5stack-cardputer/src/keyboard.cpp create mode 100644 components/m5stack-cardputer/src/m5stack-cardputer.cpp create mode 100644 components/m5stack-cardputer/src/microphone.cpp create mode 100644 components/m5stack-cardputer/src/sdcard.cpp create mode 100644 doc/en/dev_boards/m5stack/m5stack_cardputer.rst create mode 100644 doc/en/dev_boards/m5stack/m5stack_cardputer_example.md diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1630cdc538..c9662794d9 100755 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -139,6 +139,8 @@ jobs: target: esp32 - path: 'components/lsm6dso/example' target: esp32s3 + - path: 'components/m5stack-cardputer/example' + target: esp32s3 - path: 'components/m5stack-tab5/example' target: esp32p4 - path: 'components/math/example' diff --git a/.github/workflows/upload_components.yml b/.github/workflows/upload_components.yml index c9e9c7a0b6..f2f749d35a 100755 --- a/.github/workflows/upload_components.yml +++ b/.github/workflows/upload_components.yml @@ -91,6 +91,7 @@ jobs: components/logger components/lp5817 components/lsm6dso + components/m5stack-cardputer components/m5stack-tab5 components/math components/matouch-rotary-display diff --git a/components/m5stack-cardputer/CMakeLists.txt b/components/m5stack-cardputer/CMakeLists.txt new file mode 100644 index 0000000000..5be6144eab --- /dev/null +++ b/components/m5stack-cardputer/CMakeLists.txt @@ -0,0 +1,7 @@ +# only register the component if the target is esp32s3 +idf_component_register( + INCLUDE_DIRS "include" + SRC_DIRS "src" + REQUIRES driver esp_adc esp_driver_i2s fatfs base_component adc display display_drivers i2c interrupt led neopixel spi task + REQUIRED_IDF_TARGETS "esp32s3" + ) diff --git a/components/m5stack-cardputer/Kconfig b/components/m5stack-cardputer/Kconfig new file mode 100644 index 0000000000..0d073826f5 --- /dev/null +++ b/components/m5stack-cardputer/Kconfig @@ -0,0 +1,91 @@ +menu "M5Stack Cardputer Configuration" + + config M5STACK_CARDPUTER_INTERRUPT_STACK_SIZE + int "Interrupt stack size" + default 4096 + help + Size of the stack allocated for the interrupt task. + + config M5STACK_CARDPUTER_INTERRUPT_PRIORITY + int "Interrupt task priority" + default 20 + range 0 25 + help + Priority of the interrupt task. + + config M5STACK_CARDPUTER_INTERRUPT_CORE_ID + int "Interrupt task core ID" + default -1 + range -1 1 + help + Core ID of the interrupt task. -1 means it is not pinned to any core. + + config M5STACK_CARDPUTER_KEYBOARD_TASK_STACK_SIZE + int "Keyboard task stack size" + default 8192 + help + Size of the stack allocated for the keyboard scanner task. Note + that the keypress callback runs in this task's context, so the + stack must be large enough for whatever the callback calls into + (e.g. LVGL). + + config M5STACK_CARDPUTER_KEYBOARD_TASK_PRIORITY + int "Keyboard task priority" + default 0 + range 0 25 + help + Priority of the keyboard scanner task. + + config M5STACK_CARDPUTER_KEYBOARD_TASK_CORE_ID + int "Keyboard task core ID" + default -1 + range -1 1 + help + Core ID of the keyboard scanner task. -1 means it is not pinned to + any core. + + config M5STACK_CARDPUTER_AUDIO_TASK_STACK_SIZE + int "Audio task stack size" + default 4096 + help + Size of the stack allocated for the audio (speaker) task. + + config M5STACK_CARDPUTER_AUDIO_TASK_PRIORITY + int "Audio task priority" + default 19 + range 0 25 + help + Priority of the audio (speaker) task. + + config M5STACK_CARDPUTER_AUDIO_TASK_CORE_ID + int "Audio task core ID" + default 1 + range -1 1 + help + Core ID of the audio (speaker) task. -1 means it is not pinned to + any core. + + config M5STACK_CARDPUTER_MICROPHONE_TASK_STACK_SIZE + int "Microphone task stack size" + default 4096 + help + Size of the stack allocated for the microphone task. Note that the + microphone callback runs in this task's context, so the stack must + be large enough for whatever the callback does with the audio data. + + config M5STACK_CARDPUTER_MICROPHONE_TASK_PRIORITY + int "Microphone task priority" + default 19 + range 0 25 + help + Priority of the microphone task. + + config M5STACK_CARDPUTER_MICROPHONE_TASK_CORE_ID + int "Microphone task core ID" + default 1 + range -1 1 + help + Core ID of the microphone task. -1 means it is not pinned to any + core. + +endmenu diff --git a/components/m5stack-cardputer/README.md b/components/m5stack-cardputer/README.md new file mode 100644 index 0000000000..3da1729a20 --- /dev/null +++ b/components/m5stack-cardputer/README.md @@ -0,0 +1,50 @@ +# M5Stack Cardputer Board Support Package (BSP) Component + +[![Badge](https://components.espressif.com/components/espp/m5stack-cardputer/badge.svg)](https://components.espressif.com/components/espp/m5stack-cardputer) + +The M5Stack Cardputer (K132) and Cardputer ADV are card-sized computers based +on the ESP32-S3 StampS3 module, with a 56-key QWERTY keyboard, a 1.14" 240x135 +IPS display, a mono speaker, a microphone, a micro-SD card slot, an IR +transmitter, a Grove port, and a WS2812 RGB LED. + +The `espp::M5StackCardputer` component supports **both variants with the same +API**, detecting the board at runtime (see `variant()`), and provides a +singleton hardware abstraction for initializing and using the subsystems: + +- **Display**: 1.14" 240x135 IPS TFT (ST7789V2) over SPI @ 40 MHz, with LVGL + integration via `espp::Display` and PWM backlight brightness control. +- **Keyboard**: on the original, the 56-key matrix (scanned through a 74HC138 + 3-to-8 demultiplexer) is polled by a scanner task; on the ADV the same 56 + keys are read from the TCA8418 I2C keyboard controller's event FIFO. Either + way, a callback is invoked once per key state change with the key's matrix + position, resolved character (shift-aware), fn-layer special key (F1-F12, + arrows, esc, delete), and modifier state (fn / shift / ctrl / opt / alt). +- **Audio output**: mono speaker with `play_audio()`, runtime sample-rate + control, and software volume / mute. On the original the I2S stream drives + an NS4168 amplifier directly; on the ADV it goes through an ES8311 codec + (initialized automatically) into an NS4150B amplifier. +- **Microphone**: a recording task delivers 16-bit mono samples to a + callback. Original: SPM1423 PDM microphone; ADV: analog MEMS microphone via + the ES8311 codec's ADC. +- **uSD card**: SPI-mode micro-SD mounted at `/sdcard`. +- **RGB LED**: the StampS3's WS2812 via `espp::Neopixel`. +- **Battery**: battery voltage measurement (2:1 divider into ADC1). +- **G0 (BOOT) button**: via `espp::Interrupt` with a callback. +- **IR transmitter / Grove port**: pin accessors for use with your own RMT / + I2C drivers. +- **Internal I2C bus** (ADV only): accessor for the bus hosting the TCA8418 + (0x34), ES8311 (0x18), and the ADV's BMI270 IMU (0x68) - the IMU can be + used with the espp `bmi270` component. + +> [!NOTE] +> The speaker and the microphone share I2S pins (GPIO 43 word-select / PDM +> clock on the original; the BCK/WS pair on the ADV), so they cannot be used +> at the same time; initializing one while the other is active will fail. +> This matches the boards' hardware design. + +## Example + +The [example](./example) shows how to use the `espp::M5StackCardputer` hardware +abstraction component to initialize the display (with a `Gui` class built on +LVGL), type text with the keyboard, play key-click sounds on the speaker, cycle +the RGB LED color, and monitor the battery voltage. diff --git a/components/m5stack-cardputer/example/CMakeLists.txt b/components/m5stack-cardputer/example/CMakeLists.txt new file mode 100644 index 0000000000..e56e8f341b --- /dev/null +++ b/components/m5stack-cardputer/example/CMakeLists.txt @@ -0,0 +1,22 @@ +# The following lines of boilerplate have to be in your project's CMakeLists +# in this exact order for cmake to work correctly +cmake_minimum_required(VERSION 3.20) + +set(ENV{IDF_COMPONENT_MANAGER} "0") +include($ENV{IDF_PATH}/tools/cmake/project.cmake) + +# add the component directories that we want to use +set(EXTRA_COMPONENT_DIRS + "../../../components/" +) + +set( + COMPONENTS + "main esptool_py m5stack-cardputer" + CACHE STRING + "List of components to include" + ) + +project(m5stack_cardputer_example) + +set(CMAKE_CXX_STANDARD 20) diff --git a/components/m5stack-cardputer/example/README.md b/components/m5stack-cardputer/example/README.md new file mode 100644 index 0000000000..e92ed3fcce --- /dev/null +++ b/components/m5stack-cardputer/example/README.md @@ -0,0 +1,56 @@ +# M5Stack Cardputer Example + +This example shows how to use the `espp::M5StackCardputer` hardware abstraction +component to initialize and use the components on the M5Stack Cardputer: + +- The display (with LVGL, via a small `Gui` class that implements a + keyboard-driven text editor) +- The 56-key QWERTY keyboard (typing, shift layer, and the fn layer's arrow / + delete / esc keys) +- The speaker (a key-click beep for every keypress) +- The RGB LED (the G0 / BOOT button cycles its color) +- The uSD card (mounted at startup if inserted) +- The battery voltage measurement (shown in the status bar) + +## How to use example + +### Controls + +| Input | Action | +|-------|--------| +| Printable keys (with shift layer) | Type into the text area | +| Backspace / Enter / Tab | Edit the text area | +| Fn + `;` / `,` / `.` / `/` | Move the cursor (up / left / down / right) | +| Fn + backspace | Delete forward | +| Fn + `` ` `` | Clear the text area | +| Fn + number row | Show F1-F12 in the status bar | +| G0 (BOOT) button | Cycle the RGB LED color | + +The status bar at the bottom of the screen shows the most recent key / +modifier activity and is updated with the battery voltage every few seconds. + +### Hardware Required + +This example is designed to run on the M5Stack Cardputer (K132), with an +optional uSD card inserted. + +### Build and Flash + +Build the project and flash it to the board, then run monitor tool to view +serial output: + +``` +idf.py -p PORT flash monitor +``` + +(Replace PORT with the name of the serial port to use.) + +(To exit the serial monitor, type ``Ctrl-]``.) + +See the [Getting Started Guide](https://docs.espressif.com/projects/esp-idf/en/latest/get-started/index.html) for full steps to configure and use ESP-IDF to build projects. + +## Example Output + +Serial output showing initialization of the subsystems, followed by log lines +for keyboard / button activity. The display shows the text editor with the +status bar along the bottom. diff --git a/components/m5stack-cardputer/example/main/CMakeLists.txt b/components/m5stack-cardputer/example/main/CMakeLists.txt new file mode 100644 index 0000000000..a941e22ba7 --- /dev/null +++ b/components/m5stack-cardputer/example/main/CMakeLists.txt @@ -0,0 +1,2 @@ +idf_component_register(SRC_DIRS "." + INCLUDE_DIRS ".") diff --git a/components/m5stack-cardputer/example/main/gui.cpp b/components/m5stack-cardputer/example/main/gui.cpp new file mode 100644 index 0000000000..4384f7699d --- /dev/null +++ b/components/m5stack-cardputer/example/main/gui.cpp @@ -0,0 +1,109 @@ +#include "gui.hpp" + +void Gui::init_ui() { + std::lock_guard lock(mutex_); + init_background(); + init_textarea(); + init_status_bar(); +} + +void Gui::deinit_ui() { + std::lock_guard lock(mutex_); + lv_obj_clean(lv_screen_active()); + background_ = nullptr; + textarea_ = nullptr; + status_label_ = nullptr; +} + +void Gui::init_background() { + background_ = lv_obj_create(lv_screen_active()); + lv_obj_set_size(background_, lv_pct(100), lv_pct(100)); + lv_obj_center(background_); + lv_obj_set_style_border_width(background_, 0, LV_PART_MAIN); + lv_obj_set_style_radius(background_, 0, LV_PART_MAIN); + lv_obj_set_style_pad_all(background_, 0, LV_PART_MAIN); +} + +void Gui::init_textarea() { + textarea_ = lv_textarea_create(background_); + lv_obj_set_size(textarea_, lv_pct(100), lv_pct(80)); + lv_obj_align(textarea_, LV_ALIGN_TOP_MID, 0, 0); + lv_textarea_set_placeholder_text(textarea_, "Type on the keyboard..."); + // the keyboard scanner callback drives this text area; there is no + // touchscreen so it never needs to be clickable + lv_obj_remove_flag(textarea_, LV_OBJ_FLAG_CLICKABLE); + // make sure the cursor is visible + lv_obj_add_state(textarea_, LV_STATE_FOCUSED); +} + +void Gui::init_status_bar() { + status_label_ = lv_label_create(background_); + lv_obj_set_width(status_label_, lv_pct(100)); + lv_obj_align(status_label_, LV_ALIGN_BOTTOM_MID, 0, 0); + lv_label_set_long_mode(status_label_, LV_LABEL_LONG_CLIP); + lv_label_set_text(status_label_, "Ready"); +} + +void Gui::add_char(char c) { + std::lock_guard lock(mutex_); + if (!textarea_) { + return; + } + if (c == '\b') { + lv_textarea_delete_char(textarea_); + } else { + lv_textarea_add_char(textarea_, c); + } +} + +void Gui::handle_special_key(SpecialKey key) { + std::lock_guard lock(mutex_); + if (!textarea_) { + return; + } + switch (key) { + case SpecialKey::LEFT: + lv_textarea_cursor_left(textarea_); + break; + case SpecialKey::RIGHT: + lv_textarea_cursor_right(textarea_); + break; + case SpecialKey::UP: + lv_textarea_cursor_up(textarea_); + break; + case SpecialKey::DOWN: + lv_textarea_cursor_down(textarea_); + break; + case SpecialKey::DELETE: + lv_textarea_delete_char_forward(textarea_); + break; + case SpecialKey::ESC: + lv_textarea_set_text(textarea_, ""); + break; + default: + // the other special keys (F1-F12) are just shown in the status bar by + // the main loop + break; + } +} + +void Gui::set_status_text(std::string_view text) { + std::lock_guard lock(mutex_); + if (!status_label_) { + return; + } + // string_view is not guaranteed to be null-terminated + const std::string text_str(text); + lv_label_set_text(status_label_, text_str.c_str()); +} + +bool Gui::update(std::mutex &m, std::condition_variable &cv) { + { + std::lock_guard lock(mutex_); + lv_task_handler(); + } + using namespace std::chrono_literals; + std::unique_lock lock(m); + cv.wait_for(lock, 16ms); + return false; // don't stop the task +} diff --git a/components/m5stack-cardputer/example/main/gui.hpp b/components/m5stack-cardputer/example/main/gui.hpp new file mode 100644 index 0000000000..b44cb3da6e --- /dev/null +++ b/components/m5stack-cardputer/example/main/gui.hpp @@ -0,0 +1,88 @@ +#pragma once + +#include +#include +#include + +#include "m5stack-cardputer.hpp" + +#include "logger.hpp" +#include "task.hpp" + +/// The Gui class encapsulates all of the LVGL UI for this example. It follows +/// the recommended pattern for building UIs with espp + LVGL in C++: +/// +/// * All LVGL objects are created in init_ui(), which is broken into small +/// member functions - one per logical piece of the UI. +/// * The class owns the task which calls lv_task_handler(), and a recursive +/// mutex which guards every LVGL call. Public methods lock that mutex, so +/// other tasks (the keyboard scanner callback, the main loop, etc.) can +/// safely call them. +/// +/// For this example the Gui is a tiny text editor driven by the Cardputer's +/// keyboard: +/// * A text area fills most of the screen; typed characters are appended to +/// it and backspace / enter / the fn-layer arrow keys edit it. +/// * A status bar at the bottom shows the battery voltage and the most recent +/// key / modifier activity. +class Gui { +public: + /// Alias for the special (fn layer) keys of the Cardputer keyboard + using SpecialKey = espp::M5StackCardputer::SpecialKey; + + /// Configuration for the Gui + struct Config { + espp::Logger::Verbosity log_level{espp::Logger::Verbosity::WARN}; ///< Log verbosity + }; + + /// Construct the Gui: builds the UI and starts the LVGL update task. + /// @param config The configuration for the Gui + explicit Gui(const Config &config) + : logger_({.tag = "Gui", .level = config.log_level}) { + init_ui(); + update_task_.start(); + } + + ~Gui() { + update_task_.stop(); + deinit_ui(); + } + + /// Add a character to the text area. Handles backspace ('\b') by deleting + /// the character before the cursor; other characters (including '\n' and + /// '\t') are inserted at the cursor. Thread-safe. + /// @param c The character to add + void add_char(char c); + + /// Handle a special (fn layer) key: arrows move the cursor, delete removes + /// the character after the cursor, and esc clears the text area. + /// Thread-safe. + /// @param key The special key to handle + void handle_special_key(SpecialKey key); + + /// Set the text of the status bar. Thread-safe. + /// @param text The text to display + void set_status_text(std::string_view text); + +protected: + void init_ui(); + void deinit_ui(); + + // the individual pieces of the UI, called from init_ui() + void init_background(); + void init_textarea(); + void init_status_bar(); + + // the LVGL update task: calls lv_task_handler() under the mutex + bool update(std::mutex &m, std::condition_variable &cv); + + // LVGL objects + lv_obj_t *background_{nullptr}; + lv_obj_t *textarea_{nullptr}; + lv_obj_t *status_label_{nullptr}; + + espp::Task update_task_{{.callback = [this](auto &m, auto &cv) { return update(m, cv); }, + .task_config = {.name = "gui", .stack_size_bytes = 6 * 1024}}}; + espp::Logger logger_; + std::recursive_mutex mutex_; +}; diff --git a/components/m5stack-cardputer/example/main/m5stack_cardputer_example.cpp b/components/m5stack-cardputer/example/main/m5stack_cardputer_example.cpp new file mode 100644 index 0000000000..81fbdb5e95 --- /dev/null +++ b/components/m5stack-cardputer/example/main/m5stack_cardputer_example.cpp @@ -0,0 +1,133 @@ +#include +#include +#include +#include +#include + +#include "m5stack-cardputer.hpp" + +#include "gui.hpp" + +using namespace std::chrono_literals; + +static constexpr auto TAG = "cardputer_example"; + +// Synthesize a short fading sine beep and queue it on the speaker +static void play_beep(espp::M5StackCardputer &cardputer, float frequency_hz) { + static constexpr float duration_s = 0.05f; + const uint32_t sample_rate = cardputer.audio_sample_rate(); + const size_t num_samples = static_cast(duration_s * sample_rate); + std::vector data(num_samples * sizeof(int16_t)); + auto *samples = reinterpret_cast(data.data()); + for (size_t i = 0; i < num_samples; i++) { + float t = static_cast(i) / sample_rate; + // fade the beep out over its duration to avoid a click at the end + float envelope = 1.0f - (static_cast(i) / num_samples); + samples[i] = + static_cast(INT16_MAX * 0.5f * envelope * + std::sin(2.0f * std::numbers::pi_v * frequency_hz * t)); + } + cardputer.play_audio(data); +} + +extern "C" void app_main(void) { + espp::Logger logger({.tag = TAG, .level = espp::Logger::Verbosity::INFO}); + + //! [m5stack-cardputer example] + espp::M5StackCardputer &cardputer = espp::M5StackCardputer::get(); + cardputer.set_log_level(espp::Logger::Verbosity::INFO); + + // initialize the LCD + if (!cardputer.initialize_lcd()) { + logger.error("Failed to initialize LCD!"); + return; + } + // initialize the display, using a pixel buffer of 50 lines + static constexpr size_t pixel_buffer_size = cardputer.lcd_width() * 50; + if (!cardputer.initialize_display(pixel_buffer_size)) { + logger.error("Failed to initialize display!"); + return; + } + + // initialize the RGB LED and the sound subsystem (the microphone shares a + // pin with the speaker, so this example only uses the speaker) + if (!cardputer.initialize_led()) { + logger.error("Failed to initialize RGB LED!"); + return; + } + if (!cardputer.initialize_sound()) { + logger.error("Failed to initialize sound!"); + return; + } + cardputer.volume(60.0f); + + // try to mount the uSD card (warn and continue if it's not inserted) + if (!cardputer.initialize_sdcard({})) { + logger.warn("Could not mount the uSD card; is one inserted?"); + } + + // create the GUI (a small keyboard-driven text editor) + static Gui gui({}); + + // the keyboard scanner delivers one event per key state change; use it to + // drive the text editor, play key-click sounds, and show what's happening + // in the status bar + auto keypress_callback = [&](const espp::M5StackCardputer::KeyEvent &event) { + if (!event.pressed) { + return; + } + if (event.special != espp::M5StackCardputer::SpecialKey::NONE) { + gui.handle_special_key(event.special); + gui.set_status_text(espp::M5StackCardputer::special_key_name(event.special)); + play_beep(cardputer, 660.0f); + } else if (event.value != 0) { + gui.add_char(event.value); + play_beep(cardputer, 880.0f); + } else { + // a modifier key by itself + std::string status; + if (event.modifiers.fn) + status += "fn "; + if (event.modifiers.shift) + status += "shift "; + if (event.modifiers.ctrl) + status += "ctrl "; + if (event.modifiers.opt) + status += "opt "; + if (event.modifiers.alt) + status += "alt "; + gui.set_status_text(status.empty() ? "Ready" : status); + } + }; + // the keyboard scanner auto-detects the board variant: the original's + // 74HC138 GPIO matrix or the ADV's TCA8418 I2C keyboard controller + if (!cardputer.initialize_keyboard(keypress_callback)) { + logger.error("Failed to initialize keyboard!"); + return; + } + logger.info("Board variant: {}", espp::M5StackCardputer::variant_name(cardputer.variant())); + + // the G0 (BOOT) button cycles the RGB LED color + static std::atomic led_hue{0}; + auto button_callback = [&](const espp::Interrupt::Event &event) { + if (event.active) { + led_hue = (led_hue + 60) % 360; + cardputer.led(espp::Hsv(static_cast(led_hue), 1.0f, 0.2f)); + play_beep(cardputer, 440.0f); + } + }; + if (!cardputer.initialize_button(button_callback)) { + logger.error("Failed to initialize button!"); + return; + } + + // set the initial LED color + cardputer.led(espp::Hsv(static_cast(led_hue), 1.0f, 0.2f)); + + // periodically update the status bar with the battery voltage + while (true) { + gui.set_status_text(fmt::format("Battery: {:.2f} V", cardputer.battery_voltage())); + std::this_thread::sleep_for(5s); + } + //! [m5stack-cardputer example] +} diff --git a/components/m5stack-cardputer/example/sdkconfig.defaults b/components/m5stack-cardputer/example/sdkconfig.defaults new file mode 100644 index 0000000000..01003bc915 --- /dev/null +++ b/components/m5stack-cardputer/example/sdkconfig.defaults @@ -0,0 +1,52 @@ +CONFIG_IDF_TARGET="esp32s3" + +CONFIG_FREERTOS_HZ=1000 + +# set compiler optimization level to -O2 (compile for performance) +CONFIG_COMPILER_OPTIMIZATION_PERF=y + +CONFIG_ESPTOOLPY_FLASHSIZE_8MB=y +CONFIG_ESPTOOLPY_FLASHSIZE="8MB" +# over twice as fast as DIO +CONFIG_ESPTOOLPY_FLASHMODE_QIO=y + +# ESP32-specific +# +CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ_240=y +CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ=240 + +# Common ESP-related +# +CONFIG_ESP_SYSTEM_EVENT_TASK_STACK_SIZE=4096 +CONFIG_ESP_MAIN_TASK_STACK_SIZE=16384 + +# Set esp-timer task stack size to 6KB +CONFIG_ESP_TIMER_TASK_STACK_SIZE=6144 + +# set the functions into IRAM +CONFIG_SPI_MASTER_IN_IRAM=y + +# +# LVGL configuration - # Color settings +# +# CONFIG_LV_COLOR_DEPTH_32 is not set +CONFIG_LV_COLOR_DEPTH_16=y +# CONFIG_LV_COLOR_DEPTH_8 is not set +# CONFIG_LV_COLOR_DEPTH_1 is not set +CONFIG_LV_COLOR_DEPTH=16 +CONFIG_LV_COLOR_16_SWAP=y +CONFIG_LV_COLOR_MIX_ROUND_OFS=128 +CONFIG_LV_COLOR_CHROMA_KEY_HEX=0x00FF00 + +# +# LVGL configuration - # Themes +# +CONFIG_LV_USE_THEME_DEFAULT=y +CONFIG_LV_THEME_DEFAULT_DARK=y +CONFIG_LV_THEME_DEFAULT_GROW=y +CONFIG_LV_THEME_DEFAULT_TRANSITION_TIME=80 + +# The Cardputer's UART0 pins (GPIO43/44) are used by the speaker I2S and the +# IR transmitter, so the console must use the USB-Serial-JTAG interface (the +# board's USB-C port). +CONFIG_ESP_CONSOLE_USB_SERIAL_JTAG=y diff --git a/components/m5stack-cardputer/idf_component.yml b/components/m5stack-cardputer/idf_component.yml new file mode 100644 index 0000000000..3e0f58fe96 --- /dev/null +++ b/components/m5stack-cardputer/idf_component.yml @@ -0,0 +1,31 @@ +## IDF Component Manager Manifest File +license: "MIT" +description: "M5Stack Cardputer Board Support Package (BSP) component in C++ for ESP32-S3" +url: "https://github.com/esp-cpp/espp/tree/main/components/m5stack-cardputer" +repository: "git://github.com/esp-cpp/espp.git" +maintainers: + - William Emfinger +documentation: "https://esp-cpp.github.io/espp/dev_boards/m5stack/m5stack_cardputer.html" +examples: + - path: example +tags: + - cpp + - Component + - M5Stack + - Cardputer + - BSP +dependencies: + idf: + version: '>=5.0' + espp/adc: '>=1.0' + espp/base_component: '>=1.0' + espp/display: '>=1.0' + espp/display_drivers: '>=1.0' + espp/i2c: '>=1.0' + espp/interrupt: '>=1.0' + espp/led: '>=1.0' + espp/neopixel: '>=1.0' + espp/spi: '>=1.0' + espp/task: '>=1.0' +targets: + - esp32s3 diff --git a/components/m5stack-cardputer/include/m5stack-cardputer.hpp b/components/m5stack-cardputer/include/m5stack-cardputer.hpp new file mode 100644 index 0000000000..acc67071e0 --- /dev/null +++ b/components/m5stack-cardputer/include/m5stack-cardputer.hpp @@ -0,0 +1,799 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +#include "base_component.hpp" +#include "i2c.hpp" +#include "interrupt.hpp" +#include "led.hpp" +#include "neopixel.hpp" +#include "oneshot_adc.hpp" +#include "spi.hpp" +#include "st7789.hpp" +#include "task.hpp" + +namespace espp { +/// The M5StackCardputer class provides an interface to the M5Stack Cardputer +/// (K132) and Cardputer ADV, ESP32-S3 (StampS3) based card-sized computers +/// with a 56-key QWERTY keyboard. The variant is detected automatically at +/// runtime (see variant()); both share the same 56-key layout and the same +/// API. +/// +/// The class provides access to the following features: +/// - Display (1.14" 240x135 IPS TFT, ST7789V2) +/// - Keyboard (original: 56-key matrix scanned through a 74HC138 +/// demultiplexer; ADV: TCA8418 I2C keyboard controller) +/// - Audio output (mono speaker; original: NS4168 I2S amplifier; ADV: ES8311 +/// codec + NS4150B amplifier, initialized automatically) +/// - Microphone (original: SPM1423 PDM; ADV: analog MEMS mic via the ES8311 +/// codec) +/// - micro-SD (uSD) card (SPI mode) +/// - RGB LED (WS2812, on the StampS3 module) +/// - Battery voltage measurement +/// - G0 (BOOT) button +/// - IR transmitter and Grove port pin definitions +/// - Internal I2C bus accessor (ADV only; also hosts a BMI270 IMU at 0x68 +/// which can be used with the espp bmi270 component) +/// +/// The class is a singleton and can be accessed using the get() method. +/// +/// \note The speaker and the microphone share I2S pins (GPIO 43 word-select / +/// PDM clock on the original; GPIO 41/43 bit-clock and word-select on +/// the ADV), so they cannot be used at the same time. Initializing one +/// while the other is active will fail. +/// +/// \section m5stack_cardputer_example Example +/// \snippet m5stack_cardputer_example.cpp m5stack-cardputer example +class M5StackCardputer : public BaseComponent { +public: + /// Alias for the pixel type used by the display + using Pixel = lv_color16_t; + + /// Alias for the display driver + using DisplayDriver = espp::St7789; + + /// Alias for the button callback function + using button_callback_t = std::function; + + /// Number of rows in the keyboard matrix + static constexpr size_t KEYBOARD_ROWS = 4; + /// Number of columns in the keyboard matrix + static constexpr size_t KEYBOARD_COLS = 14; + + /// Special (non-printable) keys, produced by the Fn layer of the keyboard + enum class SpecialKey : uint8_t { + NONE = 0, ///< Not a special key + ESC, ///< Escape (fn + `) + F1, ///< F1 (fn + 1) + F2, ///< F2 (fn + 2) + F3, ///< F3 (fn + 3) + F4, ///< F4 (fn + 4) + F5, ///< F5 (fn + 5) + F6, ///< F6 (fn + 6) + F7, ///< F7 (fn + 7) + F8, ///< F8 (fn + 8) + F9, ///< F9 (fn + 9) + F10, ///< F10 (fn + 0) + F11, ///< F11 (fn + -) + F12, ///< F12 (fn + =) + DELETE, ///< Delete (fn + backspace) + UP, ///< Up arrow (fn + ;) + DOWN, ///< Down arrow (fn + .) + LEFT, ///< Left arrow (fn + ,) + RIGHT, ///< Right arrow (fn + /) + }; + + /// The state of the keyboard modifier keys + struct Modifiers { + bool fn{false}; ///< Fn key + bool shift{false}; ///< Left shift key + bool ctrl{false}; ///< Left ctrl key + bool opt{false}; ///< Opt key + bool alt{false}; ///< Left alt key + }; + + /// A single key state change reported by the keyboard scanner + struct KeyEvent { + uint8_t row; ///< Row of the key in the matrix (0 = top / esc row) + uint8_t col; ///< Column of the key in the matrix (0 = leftmost) + bool pressed; ///< True if the key is now pressed, false if released + char value; ///< The character for the key, with the shift layer + /// applied (0 if the key has no character, e.g. a + /// modifier or an fn-layer special key). Backspace, + /// tab, enter, and space are reported as '\b', '\t', + /// '\n', and ' '. + SpecialKey special; ///< The special key (fn layer) if fn was held and the + /// key has one, SpecialKey::NONE otherwise + Modifiers modifiers; ///< The modifier state when the event was generated + }; + + /// Alias for the keypress callback function. Called once for each key that + /// changes state during a keyboard scan. + using keypress_callback_t = std::function; + + /// Alias for the microphone data callback. Called with 16-bit signed mono + /// samples read from the PDM microphone. + using microphone_callback_t = std::function; + + /// Maximum number of bytes that can be transferred in a single SPI + /// transaction to the display. 32k on the ESP32-S3. + static constexpr size_t SPI_MAX_TRANSFER_BYTES = SPI_LL_DMA_MAX_BIT_LEN / 8; + + /// Mount point for the uSD card. + static constexpr char mount_point[] = "/sdcard"; + + /// @brief Access the singleton instance of the M5StackCardputer class + /// @return Reference to the singleton instance of the M5StackCardputer class + static M5StackCardputer &get() { + static M5StackCardputer instance; + return instance; + } + + M5StackCardputer(const M5StackCardputer &) = delete; + M5StackCardputer &operator=(const M5StackCardputer &) = delete; + M5StackCardputer(M5StackCardputer &&) = delete; + M5StackCardputer &operator=(M5StackCardputer &&) = delete; + + /// Get a reference to the interrupts + /// \return A reference to the interrupts + espp::Interrupt &interrupts(); + + ///////////////////////////////////////////////////////////////////////////// + // Variant (original Cardputer vs Cardputer ADV) + ///////////////////////////////////////////////////////////////////////////// + + /// The hardware variant of the board + enum class Variant : uint8_t { + ORIGINAL, ///< Original Cardputer (K132): 74HC138 matrix keyboard, NS4168 + /// I2S amplifier, SPM1423 PDM microphone + ADV, ///< Cardputer ADV: TCA8418 I2C keyboard controller, ES8311 codec + /// (speaker + microphone), BMI270 IMU on the internal I2C bus + }; + + /// Get the hardware variant of the board. + /// \return The hardware variant of the board + /// \note The first call detects the variant by probing for the ADV's + /// TCA8418 keyboard controller on the internal I2C bus (GPIO 8/9); + /// on the original those pins are then returned to plain GPIO for + /// the 74HC138 matrix. + Variant variant(); + + /// Get the name of a variant + /// \param variant The variant to get the name of + /// \return The name of the variant + static const char *variant_name(Variant variant) { + return variant == Variant::ADV ? "Cardputer ADV" : "Cardputer"; + } + + /// Get a pointer to the internal I2C bus (ADV only) + /// \return A pointer to the internal I2C bus, or nullptr on the original + /// Cardputer (which has no internal I2C bus) + /// \note On the ADV the internal bus hosts the TCA8418 keyboard controller + /// (0x34), the ES8311 codec (0x18), and a BMI270 IMU (0x68) + I2c *internal_i2c(); + + ///////////////////////////////////////////////////////////////////////////// + // Keyboard + ///////////////////////////////////////////////////////////////////////////// + + /// Initialize the keyboard + /// \param callback The callback function to call when a key changes state + /// \param poll_interval The interval at which to scan the keyboard matrix + /// \param task_config The configuration for the keyboard scanner task. The + /// defaults come from the M5STACK_CARDPUTER_KEYBOARD_TASK_* Kconfig + /// options. + /// \return true if the keyboard was successfully initialized, false + /// otherwise + /// \note The keyboard is a 4x14 matrix (a 74HC138-scanned GPIO matrix on + /// the original, a TCA8418 I2C controller on the ADV); a scanner task + /// owned by this class polls it at the given interval and calls the + /// callback once per key state change. + /// \note The callback runs in the scanner task's context, so the task's + /// stack must be large enough for whatever the callback calls into + /// (e.g. LVGL). + bool + initialize_keyboard(const keypress_callback_t &callback = nullptr, + std::chrono::milliseconds poll_interval = std::chrono::milliseconds(10), + const espp::Task::BaseConfig &task_config = { + .name = "keyboard", + .stack_size_bytes = CONFIG_M5STACK_CARDPUTER_KEYBOARD_TASK_STACK_SIZE, + .priority = CONFIG_M5STACK_CARDPUTER_KEYBOARD_TASK_PRIORITY, + .core_id = CONFIG_M5STACK_CARDPUTER_KEYBOARD_TASK_CORE_ID}); + + /// Get whether a key is currently pressed + /// \param row The row of the key in the matrix + /// \param col The column of the key in the matrix + /// \return true if the key is currently pressed, false otherwise + bool is_key_pressed(uint8_t row, uint8_t col) const; + + /// Get the current state of the modifier keys + /// \return The current state of the modifier keys + Modifiers modifiers() const; + + /// Get the raw state of the keyboard matrix + /// \return One entry per row; in each entry bit N is set if the key in + /// column N is currently pressed + std::array keyboard_state() const; + + /// Get the character for a key, given a modifier state + /// \param row The row of the key in the matrix + /// \param col The column of the key in the matrix + /// \param modifiers The modifier state to apply + /// \return The character for the key (with the shift layer applied), or 0 + /// if the key has no character + static char key_value(uint8_t row, uint8_t col, const Modifiers &modifiers); + + /// Get the special key (fn layer) for a key + /// \param row The row of the key in the matrix + /// \param col The column of the key in the matrix + /// \return The special key for the key, or SpecialKey::NONE if it has none + static SpecialKey special_key(uint8_t row, uint8_t col); + + /// Get the name of a special key + /// \param key The special key to get the name of + /// \return The name of the special key + static const char *special_key_name(SpecialKey key); + + ///////////////////////////////////////////////////////////////////////////// + // Button (G0 / BOOT) + ///////////////////////////////////////////////////////////////////////////// + + /// Initialize the G0 (BOOT) button + /// \param callback The callback function to call when the button changes + /// state + /// \return true if the button was successfully initialized, false otherwise + bool initialize_button(const button_callback_t &callback = nullptr); + + /// Get the state of the G0 (BOOT) button + /// \return true if the button is pressed, false otherwise + bool button_state() const; + + ///////////////////////////////////////////////////////////////////////////// + // Display + ///////////////////////////////////////////////////////////////////////////// + + /// Initialize the LCD (low level display driver) + /// \return true if the LCD was successfully initialized, false otherwise + bool initialize_lcd(); + + /// Initialize the display (lvgl display driver) + /// \param pixel_buffer_size The size of the pixel buffer + /// \return true if the display was successfully initialized, false otherwise + bool initialize_display(size_t pixel_buffer_size); + + /// Get the width of the LCD in pixels + /// \return The width of the LCD in pixels + static constexpr size_t lcd_width() { return lcd_width_; } + + /// Get the height of the LCD in pixels + /// \return The height of the LCD in pixels + static constexpr size_t lcd_height() { return lcd_height_; } + + /// Get the GPIO pin for the LCD data/command signal + /// \return The GPIO pin for the LCD data/command signal + static constexpr auto get_lcd_dc_gpio() { return lcd_dc_io; } + + /// Get a shared pointer to the display + /// \return A shared pointer to the display + std::shared_ptr> display() const; + + /// Get a shared pointer to the low-level display driver + /// \return A shared pointer to the display driver + const std::shared_ptr &display_driver() const { return display_driver_; } + + /// Set the brightness of the backlight + /// \param brightness The brightness of the backlight as a percentage (0 - 100) + /// \note This function will only work after initialize_lcd() has been called + void brightness(float brightness); + + /// Get the brightness of the backlight + /// \return The brightness of the backlight as a percentage (0 - 100) + /// \note This function will only work after initialize_lcd() has been called + float brightness() const; + + /// Get the VRAM 0 pointer (DMA memory used by LVGL) + /// \return The VRAM 0 pointer + /// \note This is the memory used by LVGL for rendering + /// \note This is null unless initialize_display() has been called + Pixel *vram0() const; + + /// Get the VRAM 1 pointer (DMA memory used by LVGL) + /// \return The VRAM 1 pointer + /// \note This is the memory used by LVGL for rendering + /// \note This is null unless initialize_display() has been called + Pixel *vram1() const; + + /// Write a frame to the LCD + /// \param x The x coordinate + /// \param y The y coordinate + /// \param width The width of the frame, in pixels + /// \param height The height of the frame, in pixels + /// \param data The data to write + /// \note This method queues the data to be written to the LCD, only blocking + /// if there is an ongoing SPI transaction + void write_lcd_frame(const uint16_t x, const uint16_t y, const uint16_t width, + const uint16_t height, uint8_t *data); + + /// Write lines to the LCD + /// \param xs The x start coordinate + /// \param ys The y start coordinate + /// \param xe The x end coordinate + /// \param ye The y end coordinate + /// \param data The data to write + /// \param user_data User data to pass to the SPI transaction callback + /// \note This method queues the panel transfer asynchronously and may return + /// before the write has completed. + void write_lcd_lines(int xs, int ys, int xe, int ye, const uint8_t *data, uint32_t user_data); + + ///////////////////////////////////////////////////////////////////////////// + // Audio (speaker) + ///////////////////////////////////////////////////////////////////////////// + + /// Initialize the sound subsystem (mono speaker; NS4168 I2S amplifier on + /// the original, ES8311 codec + NS4150B amplifier on the ADV) + /// \param default_audio_rate The default sample rate for the audio, in Hz + /// \param task_config The configuration for the audio task. The defaults + /// come from the M5STACK_CARDPUTER_AUDIO_TASK_* Kconfig options. + /// \return true if the sound subsystem was successfully initialized, false + /// otherwise + /// \note The speaker shares I2S pins with the microphone, so this will + /// fail if the microphone has been initialized. + bool initialize_sound(uint32_t default_audio_rate = 44100, + const espp::Task::BaseConfig &task_config = { + .name = "audio", + .stack_size_bytes = CONFIG_M5STACK_CARDPUTER_AUDIO_TASK_STACK_SIZE, + .priority = CONFIG_M5STACK_CARDPUTER_AUDIO_TASK_PRIORITY, + .core_id = CONFIG_M5STACK_CARDPUTER_AUDIO_TASK_CORE_ID}); + + /// Get the audio sample rate + /// \return The audio sample rate, in Hz + uint32_t audio_sample_rate() const; + + /// Set the audio sample rate + /// \param sample_rate The audio sample rate, in Hz + void audio_sample_rate(uint32_t sample_rate); + + /// Get the audio buffer size + /// \return The audio buffer size, in bytes + size_t audio_buffer_size() const; + + /// Mute or unmute the audio + /// \param mute true to mute the audio, false to unmute it + void mute(bool mute); + + /// Check if the audio is muted + /// \return true if the audio is muted, false otherwise + bool is_muted() const; + + /// Set the volume of the audio + /// \param volume The volume as a percentage (0 - 100) + /// \note The NS4168 has no volume control, so the volume is applied in + /// software when the samples are written to the I2S peripheral + void volume(float volume); + + /// Get the volume of the audio + /// \return The volume as a percentage (0 - 100) + float volume() const; + + /// Play the audio data + /// \param data The audio data to play (16-bit signed mono samples) + /// \note This function is non-blocking and queues the data for the audio + /// task to play + void play_audio(const std::vector &data); + + /// Play the audio data + /// \param data The audio data to play (16-bit signed mono samples) + /// \param num_bytes The number of bytes to play + /// \note This function is non-blocking and queues the data for the audio + /// task to play + void play_audio(const uint8_t *data, uint32_t num_bytes); + + ///////////////////////////////////////////////////////////////////////////// + // Microphone + ///////////////////////////////////////////////////////////////////////////// + + /// Initialize the microphone (SPM1423 PDM on the original, analog MEMS via + /// the ES8311 codec on the ADV) and start delivering audio data to the + /// provided callback + /// \param callback The callback to call with recorded audio data (16-bit + /// signed mono samples) + /// \param sample_rate The sample rate for the microphone, in Hz + /// \param task_config The configuration for the microphone task. The + /// defaults come from the M5STACK_CARDPUTER_MICROPHONE_TASK_* + /// Kconfig options. + /// \return true if the microphone was successfully initialized, false + /// otherwise + /// \note The callback runs in the microphone task's context, so the task's + /// stack must be large enough for whatever the callback does with + /// the audio data. + /// \note The microphone shares I2S pins with the speaker, so this will fail + /// if the sound subsystem has been initialized. + bool + initialize_microphone(const microphone_callback_t &callback, uint32_t sample_rate = 16000, + const espp::Task::BaseConfig &task_config = { + .name = "microphone", + .stack_size_bytes = CONFIG_M5STACK_CARDPUTER_MICROPHONE_TASK_STACK_SIZE, + .priority = CONFIG_M5STACK_CARDPUTER_MICROPHONE_TASK_PRIORITY, + .core_id = CONFIG_M5STACK_CARDPUTER_MICROPHONE_TASK_CORE_ID}); + + /// Get the microphone sample rate + /// \return The microphone sample rate, in Hz + uint32_t microphone_sample_rate() const; + + ///////////////////////////////////////////////////////////////////////////// + // uSD Card + ///////////////////////////////////////////////////////////////////////////// + + /// Configuration for the uSD card + struct SdCardConfig { + bool format_if_mount_failed = false; ///< Format the uSD card if mount failed + int max_files = 5; ///< The maximum number of files to open at once + size_t allocation_unit_size = 2 * 1024; ///< The allocation unit size in bytes + }; + + /// Initialize the uSD card (SPI mode) + /// \param config The configuration for the uSD card + /// \return True if the uSD card was initialized properly. + bool initialize_sdcard(const SdCardConfig &config); + + /// Get the uSD card + /// \return A pointer to the uSD card + /// \note The uSD card is only available if it was successfully initialized + /// and the mount point is valid + sdmmc_card_t *sdcard() const { return sdcard_; } + + ///////////////////////////////////////////////////////////////////////////// + // RGB LED + ///////////////////////////////////////////////////////////////////////////// + + /// Initialize the RGB LED (WS2812 on the StampS3 module) + /// \return true if the LED was successfully initialized, false otherwise + bool initialize_led(); + + /// Set the color of the LED + /// \param hsv The color of the LED in HSV format + /// \return true if the color was successfully set, false otherwise + bool led(const Hsv &hsv); + + /// Set the color of the LED + /// \param rgb The color of the LED in RGB format + /// \return true if the color was successfully set, false otherwise + bool led(const Rgb &rgb); + + ///////////////////////////////////////////////////////////////////////////// + // Battery + ///////////////////////////////////////////////////////////////////////////// + + /// Get the battery voltage + /// \return The battery voltage, in volts + /// \note The battery voltage is measured through a 2:1 divider on GPIO 10 + float battery_voltage(); + + ///////////////////////////////////////////////////////////////////////////// + // Misc. pins (IR transmitter, Grove port) + ///////////////////////////////////////////////////////////////////////////// + + /// Get the GPIO pin for the IR transmitter + /// \return The GPIO pin for the IR transmitter + static constexpr gpio_num_t ir_tx_gpio() { return ir_tx_io; } + + /// Get the GPIO pin for the Grove port SCL / G1 signal + /// \return The GPIO pin for the Grove port SCL / G1 signal + static constexpr gpio_num_t grove_scl_gpio() { return grove_scl_io; } + + /// Get the GPIO pin for the Grove port SDA / G2 signal + /// \return The GPIO pin for the Grove port SDA / G2 signal + static constexpr gpio_num_t grove_sda_gpio() { return grove_sda_io; } + +protected: + M5StackCardputer(); + void lcd_wait_lines(); + bool initialize_i2s(uint32_t default_audio_rate); + bool audio_task_callback(std::mutex &m, std::condition_variable &cv, bool &task_notified); + bool microphone_task_callback(std::mutex &m, std::condition_variable &cv, bool &task_notified); + bool keyboard_task_callback(std::mutex &m, std::condition_variable &cv, bool &task_notified); + void detect_variant(); + bool initialize_keyboard_matrix(); + bool initialize_keyboard_tca8418(); + void scan_keyboard_matrix(); + void process_tca8418_events(); + void emit_key_event(uint8_t row, uint8_t col, bool pressed, const Modifiers &modifiers); + bool es8311_write(uint8_t reg, uint8_t value); + bool initialize_es8311_speaker(); + bool initialize_es8311_microphone(); + + // Entry in the keyboard key map: the normal / shifted characters (0 if the + // position is a modifier or has no character) and the fn-layer special key. + struct KeyMapEntry { + char value; + char shifted; + SpecialKey special; + }; + + // LCD (1.14" 240x135 IPS, ST7789V2). The panel is a 240x135 window into the + // controller's 320x240 (landscape) RAM, centered: x offset (320-240)/2=40, + // y offset (240-135)/2=52. + static constexpr size_t lcd_width_ = 240; + static constexpr size_t lcd_height_ = 135; + static constexpr size_t lcd_bytes_per_pixel = 2; + static constexpr size_t frame_buffer_size = (((lcd_width_)*lcd_bytes_per_pixel) * lcd_height_); + static constexpr int lcd_clock_speed = 40 * 1000 * 1000; + static constexpr auto lcd_spi_num = SPI2_HOST; + static constexpr gpio_num_t lcd_cs_io = GPIO_NUM_37; + static constexpr gpio_num_t lcd_mosi_io = GPIO_NUM_35; + static constexpr gpio_num_t lcd_sclk_io = GPIO_NUM_36; + static constexpr gpio_num_t lcd_dc_io = GPIO_NUM_34; + static constexpr gpio_num_t lcd_reset_io = GPIO_NUM_33; + static constexpr gpio_num_t backlight_io = GPIO_NUM_38; + static constexpr int lcd_offset_x = 40; + static constexpr int lcd_offset_y = 52; + static constexpr bool backlight_value = true; + static constexpr bool reset_value = false; + static constexpr bool invert_colors = true; + static constexpr bool swap_color_order = false; + static constexpr auto rotation = espp::DisplayRotation::LANDSCAPE; + static constexpr bool swap_xy = false; + static constexpr bool mirror_x = false; + static constexpr bool mirror_y = false; + + // Keyboard matrix (original Cardputer). The three output pins are the + // address lines of a 74HC138 3-to-8 demultiplexer whose (active low) + // outputs select one of 8 scan lines; the 7 input pins (with pullups, + // active low) each sense one key on the selected line. 8 scan lines x 7 + // inputs = 56 keys = 4 rows x 14 columns. + static constexpr gpio_num_t keyboard_a0_io = GPIO_NUM_8; + static constexpr gpio_num_t keyboard_a1_io = GPIO_NUM_9; + static constexpr gpio_num_t keyboard_a2_io = GPIO_NUM_11; + static constexpr std::array keyboard_input_ios = { + GPIO_NUM_13, GPIO_NUM_15, GPIO_NUM_3, GPIO_NUM_4, GPIO_NUM_5, GPIO_NUM_6, GPIO_NUM_7}; + + // Internal I2C bus (Cardputer ADV only; the original repurposes GPIO 8/9 + // as the 74HC138 address lines above). Hosts the TCA8418 keyboard + // controller, the ES8311 codec, and a BMI270 IMU. + static constexpr auto internal_i2c_port = I2C_NUM_0; + static constexpr int internal_i2c_clock_speed = 400 * 1000; + static constexpr gpio_num_t internal_i2c_sda = GPIO_NUM_8; + static constexpr gpio_num_t internal_i2c_scl = GPIO_NUM_9; + + // TCA8418 keyboard controller (Cardputer ADV). Configured as a 7-row x + // 8-column keypad; key events are read from its FIFO. Key number k (bits + // 6:0 of an event; bit 7 = pressed) maps to the logical 4x14 grid as: + // n = k - 1; col = (n / 10) * 2 + ((n % 10) > 3 ? 1 : 0); row = (n % 10) % 4 + static constexpr uint8_t tca8418_address = 0x34; + static constexpr gpio_num_t tca8418_int_io = GPIO_NUM_11; + static constexpr uint8_t TCA8418_REG_CFG = 0x01; + static constexpr uint8_t TCA8418_REG_INT_STAT = 0x02; + static constexpr uint8_t TCA8418_REG_KEY_LCK_EC = 0x03; + static constexpr uint8_t TCA8418_REG_KEY_EVENT_A = 0x04; + static constexpr uint8_t TCA8418_REG_KP_GPIO_1 = 0x1D; + static constexpr uint8_t TCA8418_REG_KP_GPIO_2 = 0x1E; + static constexpr uint8_t TCA8418_REG_KP_GPIO_3 = 0x1F; + + // ES8311 audio codec (Cardputer ADV): DAC to the NS4150B speaker + // amplifier, ADC from the analog MEMS microphone. Clocked from BCLK (no + // MCLK pin). + static constexpr uint8_t es8311_address = 0x18; + + // Positions of the modifier keys in the matrix (row, col) + static constexpr uint8_t fn_row = 2, fn_col = 0; + static constexpr uint8_t shift_row = 2, shift_col = 1; + static constexpr uint8_t ctrl_row = 3, ctrl_col = 0; + static constexpr uint8_t opt_row = 3, opt_col = 1; + static constexpr uint8_t alt_row = 3, alt_col = 2; + + // The 4x14 key map. Row 0 is the top (esc / number) row; column 0 is the + // leftmost key. Backspace, tab, enter, and space are the control characters + // '\b', '\t', '\n', and ' '; modifiers have no character. + static constexpr KeyMapEntry key_map_[KEYBOARD_ROWS][KEYBOARD_COLS] = { + // row 0: ` 1 2 3 4 5 6 7 8 9 0 - = backspace + {{'`', '~', SpecialKey::ESC}, + {'1', '!', SpecialKey::F1}, + {'2', '@', SpecialKey::F2}, + {'3', '#', SpecialKey::F3}, + {'4', '$', SpecialKey::F4}, + {'5', '%', SpecialKey::F5}, + {'6', '^', SpecialKey::F6}, + {'7', '&', SpecialKey::F7}, + {'8', '*', SpecialKey::F8}, + {'9', '(', SpecialKey::F9}, + {'0', ')', SpecialKey::F10}, + {'-', '_', SpecialKey::F11}, + {'=', '+', SpecialKey::F12}, + {'\b', '\b', SpecialKey::DELETE}}, + // row 1: tab q w e r t y u i o p [ ] backslash + {{'\t', '\t', SpecialKey::NONE}, + {'q', 'Q', SpecialKey::NONE}, + {'w', 'W', SpecialKey::NONE}, + {'e', 'E', SpecialKey::NONE}, + {'r', 'R', SpecialKey::NONE}, + {'t', 'T', SpecialKey::NONE}, + {'y', 'Y', SpecialKey::NONE}, + {'u', 'U', SpecialKey::NONE}, + {'i', 'I', SpecialKey::NONE}, + {'o', 'O', SpecialKey::NONE}, + {'p', 'P', SpecialKey::NONE}, + {'[', '{', SpecialKey::NONE}, + {']', '}', SpecialKey::NONE}, + {'\\', '|', SpecialKey::NONE}}, + // row 2: fn shift a s d f g h j k l ; ' enter + {{0, 0, SpecialKey::NONE}, + {0, 0, SpecialKey::NONE}, + {'a', 'A', SpecialKey::NONE}, + {'s', 'S', SpecialKey::NONE}, + {'d', 'D', SpecialKey::NONE}, + {'f', 'F', SpecialKey::NONE}, + {'g', 'G', SpecialKey::NONE}, + {'h', 'H', SpecialKey::NONE}, + {'j', 'J', SpecialKey::NONE}, + {'k', 'K', SpecialKey::NONE}, + {'l', 'L', SpecialKey::NONE}, + {';', ':', SpecialKey::UP}, + {'\'', '"', SpecialKey::NONE}, + {'\n', '\n', SpecialKey::NONE}}, + // row 3: ctrl opt alt z x c v b n m , . / space + {{0, 0, SpecialKey::NONE}, + {0, 0, SpecialKey::NONE}, + {0, 0, SpecialKey::NONE}, + {'z', 'Z', SpecialKey::NONE}, + {'x', 'X', SpecialKey::NONE}, + {'c', 'C', SpecialKey::NONE}, + {'v', 'V', SpecialKey::NONE}, + {'b', 'B', SpecialKey::NONE}, + {'n', 'N', SpecialKey::NONE}, + {'m', 'M', SpecialKey::NONE}, + {',', '<', SpecialKey::LEFT}, + {'.', '>', SpecialKey::DOWN}, + {'/', '?', SpecialKey::RIGHT}, + {' ', ' ', SpecialKey::NONE}}, + }; + + // button (G0 / BOOT) + static constexpr gpio_num_t button_io = GPIO_NUM_0; // active low + + // Audio out (NS4168 mono I2S amplifier). GPIO 43 (WS) is shared with the + // PDM microphone clock, so speaker and microphone are mutually exclusive. + static constexpr auto i2s_port = I2S_NUM_1; + static constexpr gpio_num_t i2s_bck_io = GPIO_NUM_41; + static constexpr gpio_num_t i2s_ws_io = GPIO_NUM_43; + static constexpr gpio_num_t i2s_do_io = GPIO_NUM_42; + + static constexpr int NUM_CHANNELS = 1; + static constexpr int NUM_BYTES_PER_CHANNEL = 2; + static constexpr int UPDATE_FREQUENCY = 60; + + static constexpr int calc_audio_buffer_size(int sample_rate) { + return sample_rate * NUM_CHANNELS * NUM_BYTES_PER_CHANNEL / UPDATE_FREQUENCY; + } + + // Microphone. Original: SPM1423 PDM (clk = GPIO 43, shared with the + // speaker WS). ADV: standard I2S from the ES8311 ADC (ASDOUT = GPIO 46, + // sharing the speaker's BCK/WS). + static constexpr auto mic_i2s_port = I2S_NUM_0; + static constexpr gpio_num_t mic_clk_io = GPIO_NUM_43; // shared with speaker WS + static constexpr gpio_num_t mic_data_io = GPIO_NUM_46; + + // uSD card (SPI mode, dedicated bus) + static constexpr auto sdcard_spi_num = SPI3_HOST; + static constexpr gpio_num_t sdcard_sclk = GPIO_NUM_40; + static constexpr gpio_num_t sdcard_mosi = GPIO_NUM_14; + static constexpr gpio_num_t sdcard_miso = GPIO_NUM_39; + static constexpr gpio_num_t sdcard_cs = GPIO_NUM_12; + + // RGB LED (WS2812 on the StampS3 module) + static constexpr gpio_num_t rgb_led_io = GPIO_NUM_21; + + // IR transmitter + static constexpr gpio_num_t ir_tx_io = GPIO_NUM_44; + + // Grove port (HY2.0-4P): G1 / G2 (+5V, GND) + static constexpr gpio_num_t grove_scl_io = GPIO_NUM_1; + static constexpr gpio_num_t grove_sda_io = GPIO_NUM_2; + + // Battery voltage measurement (2:1 divider into ADC1 on GPIO 10) + static constexpr float BATTERY_VOLTAGE_SCALE = 2.0f / 1000.0f; // divider ratio, mV -> V + espp::AdcConfig battery_channel_{.unit = ADC_UNIT_1, + .channel = ADC_CHANNEL_9, // GPIO 10 on ESP32-S3 + .attenuation = ADC_ATTEN_DB_12}; + espp::OneshotAdc adc_{{.unit = battery_channel_.unit, + .channels = {battery_channel_}, + .log_level = espp::Logger::Verbosity::WARN}}; + + // sdcard + sdmmc_card_t *sdcard_{nullptr}; + + // RGB LED + std::shared_ptr rgb_led_{nullptr}; + + // Interrupts + espp::Interrupt::PinConfig button_interrupt_pin_{ + .gpio_num = button_io, + .callback = + [this](const auto &event) { + if (button_callback_) { + button_callback_(event); + } + }, + .active_level = espp::Interrupt::ActiveLevel::LOW, + .interrupt_type = espp::Interrupt::Type::ANY_EDGE, + .pullup_enabled = true}; + + // we'll only add the interrupt pin if the initialize method is called + espp::Interrupt interrupts_{ + {.interrupts = {}, + .task_config = {.name = "m5stack-cardputer interrupts", + .stack_size_bytes = CONFIG_M5STACK_CARDPUTER_INTERRUPT_STACK_SIZE, + .priority = CONFIG_M5STACK_CARDPUTER_INTERRUPT_PRIORITY, + .core_id = CONFIG_M5STACK_CARDPUTER_INTERRUPT_CORE_ID}}}; + + // button + std::atomic button_initialized_{false}; + button_callback_t button_callback_{nullptr}; + + // variant / internal I2C + std::atomic variant_detected_{false}; + Variant variant_{Variant::ORIGINAL}; + std::unique_ptr internal_i2c_{nullptr}; + + // keyboard + std::atomic keyboard_initialized_{false}; + keypress_callback_t keypress_callback_{nullptr}; + std::chrono::milliseconds keyboard_poll_interval_{10}; + std::unique_ptr keyboard_task_{nullptr}; + mutable std::mutex keyboard_state_mutex_; + // one bit per column (bit set = pressed), one entry per row + std::array keyboard_state_{}; + + // microphone sample rate (Hz), set by initialize_microphone() + std::atomic mic_sample_rate_{0}; + + // display + std::shared_ptr> display_; + std::shared_ptr display_driver_{static_cast(nullptr)}; + std::vector backlight_channel_configs_{}; + std::shared_ptr backlight_{}; + static constexpr int spi_queue_size = 6; + std::unique_ptr lcd_spi_; + std::unique_ptr lcd_; + + // sound + std::atomic sound_initialized_{false}; + std::atomic mute_{false}; + std::atomic volume_{50.0f}; + std::unique_ptr audio_task_{nullptr}; + // i2s / low-level audio + i2s_chan_handle_t audio_tx_handle{nullptr}; + std::vector audio_tx_buffer; + StreamBufferHandle_t audio_tx_stream{nullptr}; + i2s_std_config_t audio_std_cfg; + + // microphone + std::atomic microphone_initialized_{false}; + microphone_callback_t microphone_callback_{nullptr}; + std::unique_ptr microphone_task_{nullptr}; + i2s_chan_handle_t audio_rx_handle{nullptr}; + std::vector audio_rx_buffer; + i2s_pdm_rx_config_t mic_pdm_cfg; +}; // class M5StackCardputer +} // namespace espp diff --git a/components/m5stack-cardputer/src/audio.cpp b/components/m5stack-cardputer/src/audio.cpp new file mode 100644 index 0000000000..7d84cd5586 --- /dev/null +++ b/components/m5stack-cardputer/src/audio.cpp @@ -0,0 +1,147 @@ +#include "m5stack-cardputer.hpp" + +#include + +using namespace espp; + +//////////////////////// +// Audio Functions // +//////////////////////// + +bool M5StackCardputer::initialize_i2s(uint32_t default_audio_rate) { + logger_.info("initializing i2s driver"); + + i2s_chan_config_t chan_cfg = I2S_CHANNEL_DEFAULT_CONFIG(i2s_port, I2S_ROLE_MASTER); + chan_cfg.auto_clear = true; // Auto clear the legacy data in the DMA buffer + ESP_ERROR_CHECK(i2s_new_channel(&chan_cfg, &audio_tx_handle, nullptr)); + + audio_std_cfg = { + .clk_cfg = I2S_STD_CLK_DEFAULT_CONFIG(default_audio_rate), + .slot_cfg = I2S_STD_PHILIPS_SLOT_DEFAULT_CONFIG(I2S_DATA_BIT_WIDTH_16BIT, I2S_SLOT_MODE_MONO), + .gpio_cfg = {.mclk = GPIO_NUM_NC, + .bclk = i2s_bck_io, + .ws = i2s_ws_io, + .dout = i2s_do_io, + .din = GPIO_NUM_NC, + .invert_flags = {.mclk_inv = false, .bclk_inv = false, .ws_inv = false}}, + }; + + ESP_ERROR_CHECK(i2s_channel_init_std_mode(audio_tx_handle, &audio_std_cfg)); + + auto buffer_size = calc_audio_buffer_size(default_audio_rate); + audio_tx_buffer.resize(buffer_size); + + audio_tx_stream = xStreamBufferCreate(buffer_size * 4, 0); + + xStreamBufferReset(audio_tx_stream); + + ESP_ERROR_CHECK(i2s_channel_enable(audio_tx_handle)); + + return true; +} + +bool M5StackCardputer::initialize_sound(uint32_t default_audio_rate, + const espp::Task::BaseConfig &task_config) { + if (sound_initialized_) { + logger_.warn("Sound already initialized"); + return true; + } + if (microphone_initialized_) { + logger_.error("Cannot initialize sound: the microphone is active and shares GPIO {} with the " + "speaker (WS / PDM clock)", + static_cast(i2s_ws_io)); + return false; + } + if (!initialize_i2s(default_audio_rate)) { + logger_.error("Could not initialize I2S driver"); + return false; + } + + // On the ADV the I2S signals go through an ES8311 codec (then an NS4150B + // amplifier) instead of directly into an NS4168 amplifier, so the codec + // must be configured. Do this after the I2S channel is enabled since the + // codec derives its clock from BCLK. + if (variant() == Variant::ADV) { + if (!initialize_es8311_speaker()) { + logger_.error("Could not initialize the ES8311 codec"); + return false; + } + } + + using namespace std::placeholders; + audio_task_ = espp::Task::make_unique({ + .callback = std::bind(&M5StackCardputer::audio_task_callback, this, _1, _2, _3), + .task_config = task_config, + }); + + sound_initialized_ = true; + + return audio_task_->start(); +} + +bool M5StackCardputer::audio_task_callback(std::mutex &m, std::condition_variable &cv, + bool &task_notified) { + // Queue the next I2S out frame to write + uint16_t available = xStreamBufferBytesAvailable(audio_tx_stream); + int buffer_size = audio_tx_buffer.size(); + available = std::min(available, buffer_size); + uint8_t *buffer = &audio_tx_buffer[0]; + memset(buffer, 0, buffer_size); + + if (available > 0) { + xStreamBufferReceive(audio_tx_stream, buffer, available, 0); + // The NS4168 has no volume control, so scale the samples in software + // according to the current volume / mute state + float scale = mute_ ? 0.0f : (volume_ / 100.0f); + auto *samples = reinterpret_cast(buffer); + size_t num_samples = available / sizeof(int16_t); + for (size_t i = 0; i < num_samples; i++) { + samples[i] = static_cast(samples[i] * scale); + } + } + i2s_channel_write(audio_tx_handle, buffer, buffer_size, NULL, portMAX_DELAY); + return false; // don't stop the task +} + +void M5StackCardputer::mute(bool mute) { mute_ = mute; } + +bool M5StackCardputer::is_muted() const { return mute_; } + +void M5StackCardputer::volume(float volume) { volume_ = std::clamp(volume, 0.0f, 100.0f); } + +float M5StackCardputer::volume() const { return volume_; } + +uint32_t M5StackCardputer::audio_sample_rate() const { + return audio_std_cfg.clk_cfg.sample_rate_hz; +} + +size_t M5StackCardputer::audio_buffer_size() const { return audio_tx_buffer.size(); } + +void M5StackCardputer::audio_sample_rate(uint32_t sample_rate) { + if (!sound_initialized_) { + logger_.error("Sound not initialized, cannot set sample rate"); + return; + } + logger_.info("Setting audio sample rate to {} Hz", sample_rate); + // stop the channel + i2s_channel_disable(audio_tx_handle); + // update the sample rate + audio_std_cfg.clk_cfg.sample_rate_hz = sample_rate; + i2s_channel_reconfig_std_clock(audio_tx_handle, &audio_std_cfg.clk_cfg); + // clear the buffer + xStreamBufferReset(audio_tx_stream); + // restart the channel + i2s_channel_enable(audio_tx_handle); +} + +void M5StackCardputer::play_audio(const std::vector &data) { + play_audio(data.data(), data.size()); +} + +void M5StackCardputer::play_audio(const uint8_t *data, uint32_t num_bytes) { + if (!sound_initialized_) { + return; + } + // don't block here + xStreamBufferSendFromISR(audio_tx_stream, data, num_bytes, NULL); +} diff --git a/components/m5stack-cardputer/src/display.cpp b/components/m5stack-cardputer/src/display.cpp new file mode 100644 index 0000000000..b19112ee1f --- /dev/null +++ b/components/m5stack-cardputer/src/display.cpp @@ -0,0 +1,229 @@ +#include "m5stack-cardputer.hpp" + +using namespace espp; + +//////////////////////// +// Display Functions // +//////////////////////// + +// the user flag for the callbacks does two things: +// 1. Provides the GPIO level for the data/command pin, and +// 2. Sets some bits for other signaling (such as LVGL FLUSH) +static constexpr int FLUSH_BIT = (1 << (int)espp::display_drivers::Flags::FLUSH_BIT); +static constexpr int DC_LEVEL_BIT = (1 << (int)espp::display_drivers::Flags::DC_LEVEL_BIT); + +static void IRAM_ATTR lcd_spi_flush_ready(uint32_t) { + lv_display_t *disp = lv_display_get_default(); + lv_display_flush_ready(disp); +} + +bool M5StackCardputer::initialize_lcd() { + if (lcd_ || backlight_) { + logger_.warn("LCD already initialized, not initializing again!"); + return false; + } + + logger_.info("Initializing LCD..."); + + // Initialize the backlight PWM + backlight_channel_configs_.push_back({.gpio = static_cast(backlight_io), + .channel = LEDC_CHANNEL_0, + .timer = LEDC_TIMER_0, + .output_invert = !backlight_value}); + + backlight_ = std::make_shared((Led::Config{.timer = LEDC_TIMER_0, + .frequency_hz = 5000, + .channels = backlight_channel_configs_, + .duty_resolution = LEDC_TIMER_10_BIT})); + // default 100% + brightness(100.0f); + + lcd_spi_ = std::make_unique(Spi::Config{ + .host = lcd_spi_num, + .sclk_io_num = lcd_sclk_io, + .mosi_io_num = lcd_mosi_io, + .miso_io_num = GPIO_NUM_NC, + .max_transfer_sz = SPI_MAX_TRANSFER_BYTES, + .log_level = get_log_level(), + }); + lcd_ = std::make_unique(SpiPanelIo::Config{ + .spi = lcd_spi_.get(), + .device_config = + { + .mode = 0, + .clock_speed_hz = lcd_clock_speed, + .input_delay_ns = 0, + .cs_io_num = lcd_cs_io, + .queue_size = spi_queue_size, + }, + .data_command_io = lcd_dc_io, + .data_command_bit_mask = DC_LEVEL_BIT, + .post_transaction_callback_bit_mask = FLUSH_BIT, + .post_transaction_callback = lcd_spi_flush_ready, + .log_level = get_log_level(), + }); + if (!lcd_->initialized()) { + lcd_.reset(); + lcd_spi_.reset(); + return false; + } + display_driver_ = std::make_shared( + espp::display_drivers::Config{.panel_io = lcd_.get(), + .write_command = nullptr, + .read_command = nullptr, + .lcd_send_lines = nullptr, + .reset_pin = lcd_reset_io, + .data_command_pin = lcd_dc_io, + .reset_value = reset_value, + .invert_colors = invert_colors, + .swap_color_order = swap_color_order, + .offset_x = lcd_offset_x, + .offset_y = lcd_offset_y, + .swap_xy = swap_xy, + .mirror_x = mirror_x, + .mirror_y = mirror_y}); + if (!display_driver_ || !display_driver_->initialize()) { + display_driver_.reset(); + lcd_.reset(); + lcd_spi_.reset(); + return false; + } + return true; +} + +bool M5StackCardputer::initialize_display(size_t pixel_buffer_size) { + if (!lcd_) { + logger_.error( + "LCD not initialized, you must call initialize_lcd() before initialize_display()!"); + return false; + } + if (display_) { + logger_.warn("Display already initialized, not initializing again!"); + return false; + } + logger_.info("Initializing display with pixel buffer size: {} bytes", pixel_buffer_size); + // initialize the display / lvgl + using namespace std::chrono_literals; + display_ = std::make_shared>( + Display::LvglConfig{ + .width = lcd_width_, + .height = lcd_height_, + .flush_callback = + [this](lv_display_t *disp, const lv_area_t *area, uint8_t *color_map) { + if (display_driver_) { + display_driver_->flush(disp, area, color_map); + } + }, + .rotation_callback = + [this](const DisplayRotation &new_rotation) { + if (display_driver_) { + display_driver_->set_rotation(new_rotation); + } + }, + .rotation = rotation}, + Display::OledConfig{ + .set_brightness_callback = + [this](float brightness) { this->brightness(brightness * 100.0f); }, + .get_brightness_callback = [this]() { return this->brightness() / 100.0f; }}, + Display::DynamicMemoryConfig{ + .pixel_buffer_size = pixel_buffer_size, + .double_buffered = true, + .allocation_flags = MALLOC_CAP_8BIT | MALLOC_CAP_DMA, + }); + return true; +} + +std::shared_ptr> M5StackCardputer::display() const { + return display_; +} + +M5StackCardputer::Pixel *M5StackCardputer::vram0() const { + if (!display_) { + return nullptr; + } + return display_->vram0(); +} + +M5StackCardputer::Pixel *M5StackCardputer::vram1() const { + if (!display_) { + return nullptr; + } + return display_->vram1(); +} + +void M5StackCardputer::brightness(float brightness) { + if (!backlight_) { + return; + } + brightness = std::clamp(brightness, 0.0f, 100.0f); + backlight_->set_duty(backlight_channel_configs_[0].channel, brightness); +} + +float M5StackCardputer::brightness() const { + if (!backlight_) { + return 0.0f; + } + auto maybe_duty = backlight_->get_duty(backlight_channel_configs_[0].channel); + return maybe_duty.value_or(0.0f); +} + +void IRAM_ATTR M5StackCardputer::lcd_wait_lines() { + if (lcd_) { + lcd_->wait(); + } +} + +void M5StackCardputer::write_lcd_frame(const uint16_t xs, const uint16_t ys, const uint16_t width, + const uint16_t height, uint8_t *data) { + if (!display_driver_) { + return; + } + if (data) { + // have data, fill the area with the color data + lv_area_t area{.x1 = (lv_coord_t)(xs), + .y1 = (lv_coord_t)(ys), + .x2 = (lv_coord_t)(xs + width - 1), + .y2 = (lv_coord_t)(ys + height - 1)}; + display_driver_->fill(nullptr, &area, data); + } else { + // don't have data, so clear the area (set to 0) + display_driver_->clear(xs, ys, width, height); + } +} + +void IRAM_ATTR M5StackCardputer::write_lcd_lines(int xs, int ys, int xe, int ye, + const uint8_t *data, uint32_t user_data) { + if (!lcd_) { + return; + } + if (data == nullptr) { + logger_.error("lcd_send_lines: Null data for ({},{}) to ({},{})", xs, ys, xe, ye); + return; + } + if (xs < 0 || ys < 0 || xe < xs || ye < ys) { + logger_.error("lcd_send_lines: Bad region: ({},{}) to ({},{})", xs, ys, xe, ye); + return; + } + size_t width = static_cast(xe - xs + 1); + size_t height = static_cast(ye - ys + 1); + size_t length = width * height * lcd_bytes_per_pixel; + lcd_->wait(); + std::array window = { + static_cast((xs >> 8) & 0xff), + static_cast(xs & 0xff), + static_cast((xe >> 8) & 0xff), + static_cast(xe & 0xff), + }; + lcd_->queue_command(static_cast(DisplayDriver::Command::caset)); + lcd_->queue_data(window); + window = { + static_cast((ys >> 8) & 0xff), + static_cast(ys & 0xff), + static_cast((ye >> 8) & 0xff), + static_cast(ye & 0xff), + }; + lcd_->queue_command(static_cast(DisplayDriver::Command::raset)); + lcd_->queue_data(window); + lcd_->queue_command(static_cast(DisplayDriver::Command::ramwr)); + lcd_->queue_pixels(data, length, user_data); +} diff --git a/components/m5stack-cardputer/src/keyboard.cpp b/components/m5stack-cardputer/src/keyboard.cpp new file mode 100644 index 0000000000..f61050089e --- /dev/null +++ b/components/m5stack-cardputer/src/keyboard.cpp @@ -0,0 +1,378 @@ +#include "m5stack-cardputer.hpp" + +#include + +using namespace espp; + +////////////////////////////// +// Variant / internal I2C // +////////////////////////////// + +M5StackCardputer::Variant M5StackCardputer::variant() { + if (!variant_detected_) { + detect_variant(); + } + return variant_; +} + +I2c *M5StackCardputer::internal_i2c() { + if (!variant_detected_) { + detect_variant(); + } + return internal_i2c_.get(); +} + +void M5StackCardputer::detect_variant() { + if (variant_detected_) { + return; + } + // The ADV has an internal I2C bus on GPIO 8/9 hosting its TCA8418 keyboard + // controller; the original repurposes those pins as 74HC138 address lines. + // Probe for the TCA8418 to tell them apart. + internal_i2c_ = std::make_unique(I2c::Config{ + .port = internal_i2c_port, + .sda_io_num = internal_i2c_sda, + .scl_io_num = internal_i2c_scl, + .sda_pullup_en = GPIO_PULLUP_ENABLE, + .scl_pullup_en = GPIO_PULLUP_ENABLE, + .clk_speed = internal_i2c_clock_speed, + .log_level = get_log_level(), + }); + if (internal_i2c_->probe_device(tca8418_address)) { + variant_ = Variant::ADV; + logger_.info("Detected Cardputer ADV (TCA8418 keyboard controller found)"); + } else { + variant_ = Variant::ORIGINAL; + logger_.info("Detected original Cardputer (no TCA8418 on GPIO 8/9)"); + // release the bus and return GPIO 8/9 to plain GPIO so the keyboard + // matrix scanner can drive them as 74HC138 address lines + internal_i2c_.reset(); + gpio_reset_pin(internal_i2c_sda); + gpio_reset_pin(internal_i2c_scl); + } + variant_detected_ = true; +} + +//////////////////////// +// Keyboard Functions // +//////////////////////// + +bool M5StackCardputer::initialize_keyboard(const keypress_callback_t &callback, + std::chrono::milliseconds poll_interval, + const espp::Task::BaseConfig &task_config) { + logger_.info("Initializing keyboard"); + if (keyboard_initialized_) { + logger_.warn("Keyboard already initialized, not initializing again!"); + return false; + } + + keypress_callback_ = callback; + keyboard_poll_interval_ = poll_interval; + + bool ok = + (variant() == Variant::ADV) ? initialize_keyboard_tca8418() : initialize_keyboard_matrix(); + if (!ok) { + return false; + } + + using namespace std::placeholders; + keyboard_task_ = espp::Task::make_unique({ + .callback = std::bind(&M5StackCardputer::keyboard_task_callback, this, _1, _2, _3), + .task_config = task_config, + }); + + keyboard_initialized_ = true; + + return keyboard_task_->start(); +} + +bool M5StackCardputer::initialize_keyboard_matrix() { + // Reset the pins first to detach them from any peripheral signals and + // select the plain-GPIO IOMUX function, then configure them with + // gpio_config(). The demultiplexer address lines are outputs; the sense + // lines are inputs with pullups (keys read active low). + uint64_t output_mask = 0; + for (auto gpio : {keyboard_a0_io, keyboard_a1_io, keyboard_a2_io}) { + gpio_reset_pin(gpio); + output_mask |= (1ULL << gpio); + } + gpio_config_t output_config{ + .pin_bit_mask = output_mask, + .mode = GPIO_MODE_OUTPUT, + .pull_up_en = GPIO_PULLUP_DISABLE, + .pull_down_en = GPIO_PULLDOWN_DISABLE, + .intr_type = GPIO_INTR_DISABLE, + }; + gpio_config(&output_config); + for (auto gpio : {keyboard_a0_io, keyboard_a1_io, keyboard_a2_io}) { + gpio_set_level(gpio, 0); + } + + uint64_t input_mask = 0; + for (auto gpio : keyboard_input_ios) { + gpio_reset_pin(gpio); + input_mask |= (1ULL << gpio); + } + gpio_config_t input_config{ + .pin_bit_mask = input_mask, + .mode = GPIO_MODE_INPUT, + .pull_up_en = GPIO_PULLUP_ENABLE, + .pull_down_en = GPIO_PULLDOWN_DISABLE, + .intr_type = GPIO_INTR_DISABLE, + }; + gpio_config(&input_config); + return true; +} + +bool M5StackCardputer::initialize_keyboard_tca8418() { + if (!internal_i2c_) { + logger_.error("Internal I2C bus not available, cannot initialize TCA8418"); + return false; + } + auto write_reg = [this](uint8_t reg, uint8_t value) { + uint8_t buffer[2] = {reg, value}; + return internal_i2c_->write(tca8418_address, buffer, 2); + }; + // configure the keypad matrix: rows 0-6 and columns 0-7 (7x8 = 56 keys) + if (!write_reg(TCA8418_REG_KP_GPIO_1, 0x7F) || !write_reg(TCA8418_REG_KP_GPIO_2, 0xFF) || + !write_reg(TCA8418_REG_KP_GPIO_3, 0x00)) { + logger_.error("Failed to configure the TCA8418 keypad matrix"); + return false; + } + // drain any stale events from the FIFO (it holds at most 10), then clear + // all pending interrupt flags + uint8_t event = 0; + int guard = 0; + do { + if (!internal_i2c_->read_at_register(tca8418_address, TCA8418_REG_KEY_EVENT_A, &event, 1)) { + break; + } + } while (event != 0 && ++guard < 16); + write_reg(TCA8418_REG_INT_STAT, 0xFF); + // enable key event processing / interrupt generation + write_reg(TCA8418_REG_CFG, 0x01); + return true; +} + +bool M5StackCardputer::keyboard_task_callback(std::mutex &m, std::condition_variable &cv, + bool &task_notified) { + if (variant_ == Variant::ADV) { + process_tca8418_events(); + } else { + scan_keyboard_matrix(); + } + + // sleep for the poll interval, unless we're notified to stop + { + std::unique_lock lock(m); + cv.wait_for(lock, keyboard_poll_interval_, [&task_notified] { return task_notified; }); + } + return false; // don't stop the task +} + +void M5StackCardputer::scan_keyboard_matrix() { + // Scan the matrix: for each of the 8 demultiplexer states, one (active low) + // 74HC138 output selects a scan line and each of the 7 sense inputs reads + // one key on it. Demux state i and input index j map to matrix coordinates: + // col = 2*j when i > 3, else 2*j + 1 + // row = 3 - (i > 3 ? i - 4 : i) + std::array new_state{}; + for (int i = 0; i < 8; i++) { + gpio_set_level(keyboard_a0_io, (i >> 0) & 1); + gpio_set_level(keyboard_a1_io, (i >> 1) & 1); + gpio_set_level(keyboard_a2_io, (i >> 2) & 1); + // let the demultiplexer output and the (pullup-only) sense lines settle + esp_rom_delay_us(20); + int row = 3 - ((i > 3) ? (i - 4) : i); + for (size_t j = 0; j < keyboard_input_ios.size(); j++) { + bool pressed = gpio_get_level(keyboard_input_ios[j]) == 0; + if (pressed) { + int col = (i > 3) ? (2 * j) : (2 * j + 1); + new_state[row] |= (1 << col); + } + } + } + + std::array old_state; + { + std::lock_guard lock(keyboard_state_mutex_); + old_state = keyboard_state_; + keyboard_state_ = new_state; + } + + if (new_state != old_state) { + logger_.debug("keyboard state: {:#06x} {:#06x} {:#06x} {:#06x}", new_state[0], new_state[1], + new_state[2], new_state[3]); + } + + if (!keypress_callback_) { + return; + } + // resolve the modifier state from the new scan, then report each key that + // changed state + Modifiers mods{.fn = (new_state[fn_row] & (1 << fn_col)) != 0, + .shift = (new_state[shift_row] & (1 << shift_col)) != 0, + .ctrl = (new_state[ctrl_row] & (1 << ctrl_col)) != 0, + .opt = (new_state[opt_row] & (1 << opt_col)) != 0, + .alt = (new_state[alt_row] & (1 << alt_col)) != 0}; + for (uint8_t row = 0; row < KEYBOARD_ROWS; row++) { + uint16_t changed = new_state[row] ^ old_state[row]; + if (!changed) { + continue; + } + for (uint8_t col = 0; col < KEYBOARD_COLS; col++) { + if (!(changed & (1 << col))) { + continue; + } + bool pressed = (new_state[row] & (1 << col)) != 0; + emit_key_event(row, col, pressed, mods); + } + } +} + +void M5StackCardputer::process_tca8418_events() { + // Drain the TCA8418 key event FIFO. Each event byte: bit 7 = 1 for press / + // 0 for release; bits 6:0 = key number k. The controller numbers keys by + // its own 7x8 (row Tr, col Tc) matrix as k = 10*Tr + Tc + 1, which maps to + // the logical 4x14 grid (same layout as the original) as: + // col = Tr * 2 + (Tc > 3 ? 1 : 0); row = Tc % 4 + bool any_events = false; + while (true) { + uint8_t event = 0; + if (!internal_i2c_->read_at_register(tca8418_address, TCA8418_REG_KEY_EVENT_A, &event, 1)) { + logger_.error("Failed to read the TCA8418 key event FIFO"); + break; + } + if (event == 0) { + break; + } + any_events = true; + bool pressed = (event & 0x80) != 0; + uint8_t n = (event & 0x7F) - 1; + uint8_t tr = n / 10; + uint8_t tc = n % 10; + uint8_t col = tr * 2 + ((tc > 3) ? 1 : 0); + uint8_t row = tc % 4; + if (row >= KEYBOARD_ROWS || col >= KEYBOARD_COLS) { + logger_.warn("Ignoring TCA8418 event with out-of-range key number {}", n + 1); + continue; + } + Modifiers mods; + { + std::lock_guard lock(keyboard_state_mutex_); + if (pressed) { + keyboard_state_[row] |= (1 << col); + } else { + keyboard_state_[row] &= ~(1 << col); + } + mods = {.fn = (keyboard_state_[fn_row] & (1 << fn_col)) != 0, + .shift = (keyboard_state_[shift_row] & (1 << shift_col)) != 0, + .ctrl = (keyboard_state_[ctrl_row] & (1 << ctrl_col)) != 0, + .opt = (keyboard_state_[opt_row] & (1 << opt_col)) != 0, + .alt = (keyboard_state_[alt_row] & (1 << alt_col)) != 0}; + } + emit_key_event(row, col, pressed, mods); + } + if (any_events) { + // clear the key-event interrupt flag so the INT line releases + uint8_t buffer[2] = {TCA8418_REG_INT_STAT, 0x01}; + internal_i2c_->write(tca8418_address, buffer, 2); + } +} + +void M5StackCardputer::emit_key_event(uint8_t row, uint8_t col, bool pressed, + const Modifiers &modifiers) { + logger_.debug("key ({}, {}) {}", row, col, pressed ? "pressed" : "released"); + if (!keypress_callback_) { + return; + } + KeyEvent event{.row = row, + .col = col, + .pressed = pressed, + .value = key_value(row, col, modifiers), + .special = modifiers.fn ? special_key(row, col) : SpecialKey::NONE, + .modifiers = modifiers}; + keypress_callback_(event); +} + +std::array M5StackCardputer::keyboard_state() const { + std::lock_guard lock(keyboard_state_mutex_); + return keyboard_state_; +} + +bool M5StackCardputer::is_key_pressed(uint8_t row, uint8_t col) const { + if (row >= KEYBOARD_ROWS || col >= KEYBOARD_COLS) { + return false; + } + std::lock_guard lock(keyboard_state_mutex_); + return (keyboard_state_[row] & (1 << col)) != 0; +} + +M5StackCardputer::Modifiers M5StackCardputer::modifiers() const { + std::lock_guard lock(keyboard_state_mutex_); + return {.fn = (keyboard_state_[fn_row] & (1 << fn_col)) != 0, + .shift = (keyboard_state_[shift_row] & (1 << shift_col)) != 0, + .ctrl = (keyboard_state_[ctrl_row] & (1 << ctrl_col)) != 0, + .opt = (keyboard_state_[opt_row] & (1 << opt_col)) != 0, + .alt = (keyboard_state_[alt_row] & (1 << alt_col)) != 0}; +} + +char M5StackCardputer::key_value(uint8_t row, uint8_t col, const Modifiers &modifiers) { + if (row >= KEYBOARD_ROWS || col >= KEYBOARD_COLS) { + return 0; + } + const auto &entry = key_map_[row][col]; + return modifiers.shift ? entry.shifted : entry.value; +} + +M5StackCardputer::SpecialKey M5StackCardputer::special_key(uint8_t row, uint8_t col) { + if (row >= KEYBOARD_ROWS || col >= KEYBOARD_COLS) { + return SpecialKey::NONE; + } + return key_map_[row][col].special; +} + +const char *M5StackCardputer::special_key_name(SpecialKey key) { + switch (key) { + case SpecialKey::NONE: + return "None"; + case SpecialKey::ESC: + return "Esc"; + case SpecialKey::F1: + return "F1"; + case SpecialKey::F2: + return "F2"; + case SpecialKey::F3: + return "F3"; + case SpecialKey::F4: + return "F4"; + case SpecialKey::F5: + return "F5"; + case SpecialKey::F6: + return "F6"; + case SpecialKey::F7: + return "F7"; + case SpecialKey::F8: + return "F8"; + case SpecialKey::F9: + return "F9"; + case SpecialKey::F10: + return "F10"; + case SpecialKey::F11: + return "F11"; + case SpecialKey::F12: + return "F12"; + case SpecialKey::DELETE: + return "Delete"; + case SpecialKey::UP: + return "Up"; + case SpecialKey::DOWN: + return "Down"; + case SpecialKey::LEFT: + return "Left"; + case SpecialKey::RIGHT: + return "Right"; + default: + return "Unknown"; + } +} diff --git a/components/m5stack-cardputer/src/m5stack-cardputer.cpp b/components/m5stack-cardputer/src/m5stack-cardputer.cpp new file mode 100644 index 0000000000..e9883e6c7a --- /dev/null +++ b/components/m5stack-cardputer/src/m5stack-cardputer.cpp @@ -0,0 +1,132 @@ +#include "m5stack-cardputer.hpp" + +using namespace espp; + +M5StackCardputer::M5StackCardputer() + : BaseComponent("M5StackCardputer") {} + +espp::Interrupt &M5StackCardputer::interrupts() { return interrupts_; } + +//////////////////////// +// Button Functions // +//////////////////////// + +bool M5StackCardputer::initialize_button(const button_callback_t &callback) { + logger_.info("Initializing button"); + if (button_initialized_) { + logger_.warn("Button already initialized, not initializing again!"); + return false; + } + button_callback_ = callback; + interrupts_.add_interrupt(button_interrupt_pin_); + button_initialized_ = true; + return true; +} + +bool M5StackCardputer::button_state() const { + if (!button_initialized_) { + return false; + } + return interrupts_.is_active(button_interrupt_pin_); +} + +//////////////////////// +// LED Functions // +//////////////////////// + +bool M5StackCardputer::initialize_led() { + logger_.info("Initializing RGB LED"); + if (rgb_led_) { + logger_.warn("LED already initialized, not initializing again!"); + return false; + } + rgb_led_ = std::make_shared(espp::Neopixel::Config{ + .data_gpio = rgb_led_io, + .num_leds = 1, + }); + return true; +} + +bool M5StackCardputer::led(const Hsv &hsv) { return led(hsv.rgb()); } + +bool M5StackCardputer::led(const Rgb &rgb) { + if (!rgb_led_) { + logger_.error("LED not initialized, you must call initialize_led() first!"); + return false; + } + rgb_led_->set_color(rgb); + rgb_led_->show(); + return true; +} + +//////////////////////// +// ES8311 (ADV) codec // +//////////////////////// + +bool M5StackCardputer::es8311_write(uint8_t reg, uint8_t value) { + if (!internal_i2c_) { + return false; + } + uint8_t buffer[2] = {reg, value}; + return internal_i2c_->write(es8311_address, buffer, 2); +} + +bool M5StackCardputer::initialize_es8311_speaker() { + logger_.info("Initializing ES8311 codec (speaker path)"); + // Minimal DAC bring-up, clocked from BCLK (there is no MCLK pin); matches + // the M5Unified Cardputer ADV speaker-enable sequence. + const uint8_t init[][2] = { + {0x00, 0x80}, // RESET: power on, CSM enabled + {0x01, 0xB5}, // CLOCK_MANAGER: MCLK from BCLK + {0x02, 0x18}, // CLOCK_MANAGER: MULT_PRE = 3 + {0x0D, 0x01}, // SYSTEM: power up analog circuits + {0x12, 0x00}, // SYSTEM: power up DAC + {0x13, 0x10}, // SYSTEM: enable output to HP drive + {0x32, 0xBF}, // DAC: volume 0 dB + {0x37, 0x08}, // DAC: bypass DAC equalizer + }; + for (const auto &entry : init) { + if (!es8311_write(entry[0], entry[1])) { + logger_.error("Failed to write ES8311 register {:#04x}", entry[0]); + return false; + } + } + return true; +} + +bool M5StackCardputer::initialize_es8311_microphone() { + logger_.info("Initializing ES8311 codec (microphone path)"); + // Minimal ADC bring-up for the analog MEMS microphone on MIC1, clocked + // from BCLK; matches the M5Unified Cardputer ADV microphone-enable + // sequence. + const uint8_t init[][2] = { + {0x00, 0x80}, // RESET: power on, CSM enabled + {0x01, 0xBA}, // CLOCK_MANAGER: MCLK from BCLK, ADC clocks on + {0x02, 0x18}, // CLOCK_MANAGER: MULT_PRE = 3 + {0x0D, 0x01}, // SYSTEM: power up analog circuits + {0x0E, 0x02}, // SYSTEM: enable analog PGA / ADC modulator + {0x14, 0x10}, // SYSTEM: select Mic1p-Mic1n, minimum PGA gain + {0x17, 0xBF}, // ADC: volume 0 dB + {0x1C, 0x6A}, // ADC: equalizer bypass + }; + for (const auto &entry : init) { + if (!es8311_write(entry[0], entry[1])) { + logger_.error("Failed to write ES8311 register {:#04x}", entry[0]); + return false; + } + } + return true; +} + +//////////////////////// +// Battery Functions // +//////////////////////// + +float M5StackCardputer::battery_voltage() { + auto maybe_mv = adc_.read_mv(battery_channel_); + float voltage = 0; + if (maybe_mv.has_value()) { + voltage = maybe_mv.value() * BATTERY_VOLTAGE_SCALE; + } + return voltage; +} diff --git a/components/m5stack-cardputer/src/microphone.cpp b/components/m5stack-cardputer/src/microphone.cpp new file mode 100644 index 0000000000..f8bf7d0439 --- /dev/null +++ b/components/m5stack-cardputer/src/microphone.cpp @@ -0,0 +1,102 @@ +#include "m5stack-cardputer.hpp" + +using namespace espp; + +////////////////////////// +// Microphone Functions // +////////////////////////// + +bool M5StackCardputer::initialize_microphone(const microphone_callback_t &callback, + uint32_t sample_rate, + const espp::Task::BaseConfig &task_config) { + logger_.info("Initializing microphone"); + if (microphone_initialized_) { + logger_.warn("Microphone already initialized, not initializing again!"); + return false; + } + if (sound_initialized_) { + logger_.error("Cannot initialize microphone: the sound subsystem is active and shares I2S " + "pins with the microphone"); + return false; + } + if (!callback) { + logger_.error("A callback is required to receive the recorded audio data"); + return false; + } + + microphone_callback_ = callback; + + i2s_chan_config_t chan_cfg = I2S_CHANNEL_DEFAULT_CONFIG(mic_i2s_port, I2S_ROLE_MASTER); + ESP_ERROR_CHECK(i2s_new_channel(&chan_cfg, nullptr, &audio_rx_handle)); + + if (variant() == Variant::ADV) { + // The ADV's analog MEMS microphone goes through the ES8311 codec's ADC, + // which streams standard I2S on the same BCK/WS as the speaker with its + // data out (ASDOUT) on GPIO 46 + i2s_std_config_t std_cfg = { + .clk_cfg = I2S_STD_CLK_DEFAULT_CONFIG(sample_rate), + .slot_cfg = + I2S_STD_PHILIPS_SLOT_DEFAULT_CONFIG(I2S_DATA_BIT_WIDTH_16BIT, I2S_SLOT_MODE_MONO), + .gpio_cfg = {.mclk = GPIO_NUM_NC, + .bclk = i2s_bck_io, + .ws = i2s_ws_io, + .dout = GPIO_NUM_NC, + .din = mic_data_io, + .invert_flags = {.mclk_inv = false, .bclk_inv = false, .ws_inv = false}}, + }; + ESP_ERROR_CHECK(i2s_channel_init_std_mode(audio_rx_handle, &std_cfg)); + } else { + // The original's SPM1423 is a PDM microphone (clock on GPIO 43, data on + // GPIO 46) + mic_pdm_cfg = { + .clk_cfg = I2S_PDM_RX_CLK_DEFAULT_CONFIG(sample_rate), + .slot_cfg = I2S_PDM_RX_SLOT_DEFAULT_CONFIG(I2S_DATA_BIT_WIDTH_16BIT, I2S_SLOT_MODE_MONO), + .gpio_cfg = + { + .clk = mic_clk_io, + .din = mic_data_io, + .invert_flags = {.clk_inv = false}, + }, + }; + ESP_ERROR_CHECK(i2s_channel_init_pdm_rx_mode(audio_rx_handle, &mic_pdm_cfg)); + } + + mic_sample_rate_ = sample_rate; + + // buffer roughly one update period's worth of samples + audio_rx_buffer.resize(sample_rate * NUM_BYTES_PER_CHANNEL / UPDATE_FREQUENCY); + + ESP_ERROR_CHECK(i2s_channel_enable(audio_rx_handle)); + + // On the ADV, configure the codec's ADC path now that BCLK is running (the + // codec derives its clock from it) + if (variant() == Variant::ADV) { + if (!initialize_es8311_microphone()) { + logger_.error("Could not initialize the ES8311 codec"); + return false; + } + } + + using namespace std::placeholders; + microphone_task_ = espp::Task::make_unique({ + .callback = std::bind(&M5StackCardputer::microphone_task_callback, this, _1, _2, _3), + .task_config = task_config, + }); + + microphone_initialized_ = true; + + return microphone_task_->start(); +} + +uint32_t M5StackCardputer::microphone_sample_rate() const { return mic_sample_rate_; } + +bool M5StackCardputer::microphone_task_callback(std::mutex &m, std::condition_variable &cv, + bool &task_notified) { + size_t bytes_read = 0; + auto err = i2s_channel_read(audio_rx_handle, audio_rx_buffer.data(), audio_rx_buffer.size(), + &bytes_read, portMAX_DELAY); + if (err == ESP_OK && bytes_read > 0 && microphone_callback_) { + microphone_callback_(audio_rx_buffer.data(), bytes_read); + } + return false; // don't stop the task +} diff --git a/components/m5stack-cardputer/src/sdcard.cpp b/components/m5stack-cardputer/src/sdcard.cpp new file mode 100644 index 0000000000..b6326e5dc2 --- /dev/null +++ b/components/m5stack-cardputer/src/sdcard.cpp @@ -0,0 +1,71 @@ +#include "m5stack-cardputer.hpp" + +#include + +using namespace espp; + +//////////////////////// +// uSD Card Functions // +//////////////////////// + +bool M5StackCardputer::initialize_sdcard(const SdCardConfig &config) { + if (sdcard_) { + logger_.error("SD card already initialized!"); + return false; + } + + logger_.info("Initializing SD card"); + + // The uSD card is on its own SPI bus (not shared with the LCD) + spi_bus_config_t bus_cfg; + memset(&bus_cfg, 0, sizeof(bus_cfg)); + bus_cfg.mosi_io_num = sdcard_mosi; + bus_cfg.miso_io_num = sdcard_miso; + bus_cfg.sclk_io_num = sdcard_sclk; + bus_cfg.quadwp_io_num = -1; + bus_cfg.quadhd_io_num = -1; + bus_cfg.max_transfer_sz = 4092; + + auto ret = spi_bus_initialize(sdcard_spi_num, &bus_cfg, SDSPI_DEFAULT_DMA); + if (ret != ESP_OK) { + logger_.error("Failed to initialize SPI bus for SD card: {}", esp_err_to_name(ret)); + return false; + } + + sdmmc_host_t host = SDSPI_HOST_DEFAULT(); + host.slot = sdcard_spi_num; + + sdspi_device_config_t slot_config = SDSPI_DEVICE_CONFIG_DEFAULT(); + slot_config.gpio_cs = sdcard_cs; + slot_config.host_id = static_cast(host.slot); + + esp_vfs_fat_sdmmc_mount_config_t mount_config; + memset(&mount_config, 0, sizeof(mount_config)); + mount_config.format_if_mount_failed = config.format_if_mount_failed; + mount_config.max_files = config.max_files; + mount_config.allocation_unit_size = config.allocation_unit_size; + + logger_.debug("Mounting filesystem"); + ret = esp_vfs_fat_sdspi_mount(mount_point, &host, &slot_config, &mount_config, &sdcard_); + + if (ret != ESP_OK) { + if (ret == ESP_FAIL) { + logger_.error("Failed to mount filesystem. If you want the card to be formatted, set the " + "format_if_mount_failed field in the SdCardConfig."); + } else { + logger_.error("Failed to initialize the card ({}). Make sure SD card lines have pull-up " + "resistors in place.", + esp_err_to_name(ret)); + } + spi_bus_free(sdcard_spi_num); + sdcard_ = nullptr; + return false; + } + + logger_.info("Filesystem mounted"); + + // Card has been initialized, print its properties + sdmmc_card_print_info(stdout, sdcard_); + + return true; +} diff --git a/doc/Doxyfile b/doc/Doxyfile index a7e4a809ff..63fd3069a6 100755 --- a/doc/Doxyfile +++ b/doc/Doxyfile @@ -129,6 +129,7 @@ EXAMPLE_PATH = \ $(PROJECT_PATH)/components/led_strip/example/main/led_strip_example.cpp \ $(PROJECT_PATH)/components/logger/example/main/logger_example.cpp \ $(PROJECT_PATH)/components/lsm6dso/example/main/lsm6dso_example.cpp \ + $(PROJECT_PATH)/components/m5stack-cardputer/example/main/m5stack_cardputer_example.cpp \ $(PROJECT_PATH)/components/m5stack-tab5/example/main/m5stack_tab5_example.cpp \ $(PROJECT_PATH)/components/math/example/main/math_example.cpp \ $(PROJECT_PATH)/components/matouch-rotary-display/example/main/matouch_rotary_display_example.cpp \ @@ -310,6 +311,7 @@ INPUT = \ $(PROJECT_PATH)/components/lp5817/include/lp5817.hpp \ $(PROJECT_PATH)/components/lsm6dso/include/lsm6dso.hpp \ $(PROJECT_PATH)/components/lsm6dso/include/lsm6dso_detail.hpp \ + $(PROJECT_PATH)/components/m5stack-cardputer/include/m5stack-cardputer.hpp \ $(PROJECT_PATH)/components/m5stack-tab5/include/m5stack-tab5.hpp \ $(PROJECT_PATH)/components/math/include/bezier.hpp \ $(PROJECT_PATH)/components/math/include/fast_math.hpp \ diff --git a/doc/en/dev_boards/m5stack/index.rst b/doc/en/dev_boards/m5stack/index.rst index d581a09efa..0228adc2cd 100644 --- a/doc/en/dev_boards/m5stack/index.rst +++ b/doc/en/dev_boards/m5stack/index.rst @@ -4,4 +4,5 @@ M5Stack Boards .. toctree:: :maxdepth: 1 + m5stack_cardputer m5stack_tab5 diff --git a/doc/en/dev_boards/m5stack/m5stack_cardputer.rst b/doc/en/dev_boards/m5stack/m5stack_cardputer.rst new file mode 100644 index 0000000000..0c33170ebb --- /dev/null +++ b/doc/en/dev_boards/m5stack/m5stack_cardputer.rst @@ -0,0 +1,35 @@ +M5Stack Cardputer +***************** + +M5Stack-Cardputer +----------------- + +The M5Stack Cardputer (K132) and Cardputer ADV are card-sized computers based +on the ESP32-S3 StampS3 module. They feature a 56-key QWERTY keyboard, a 1.14" +240x135 IPS display, a mono speaker, a microphone, a micro-SD card slot, an IR +transmitter, a Grove port, and a WS2812 RGB LED. + +The `espp::M5StackCardputer` component supports both variants with the same +API - the board is detected at runtime - and provides a singleton hardware +abstraction for initializing the display, keyboard (74HC138 GPIO matrix on the +original, TCA8418 I2C controller on the ADV), audio (NS4168 amplifier on the +original, ES8311 codec on the ADV), microphone, uSD card, RGB LED, battery +measurement, and button subsystems. + +.. note:: + + The speaker and the microphone share I2S pins, so they cannot be used at + the same time; initializing one while the other is active will fail. + +.. ------------------------------- Example ------------------------------------- + +.. toctree:: + + m5stack_cardputer_example + +.. ---------------------------- API Reference ---------------------------------- + +API Reference +------------- + +.. include-build-file:: inc/m5stack-cardputer.inc diff --git a/doc/en/dev_boards/m5stack/m5stack_cardputer_example.md b/doc/en/dev_boards/m5stack/m5stack_cardputer_example.md new file mode 100644 index 0000000000..60a30706dd --- /dev/null +++ b/doc/en/dev_boards/m5stack/m5stack_cardputer_example.md @@ -0,0 +1,2 @@ +```{include} ../../components/m5stack-cardputer/example/README.md +``` From c4df83c9ca69ecedff426c00b35fa82fffb100ef Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Mon, 13 Jul 2026 23:07:23 -0500 Subject: [PATCH 02/13] address sa --- .../m5stack-cardputer/src/m5stack-cardputer.cpp | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/components/m5stack-cardputer/src/m5stack-cardputer.cpp b/components/m5stack-cardputer/src/m5stack-cardputer.cpp index e9883e6c7a..3942f697bd 100644 --- a/components/m5stack-cardputer/src/m5stack-cardputer.cpp +++ b/components/m5stack-cardputer/src/m5stack-cardputer.cpp @@ -85,13 +85,13 @@ bool M5StackCardputer::initialize_es8311_speaker() { {0x32, 0xBF}, // DAC: volume 0 dB {0x37, 0x08}, // DAC: bypass DAC equalizer }; - for (const auto &entry : init) { + return std::all_of(std::begin(init), std::end(init), [this](const auto &entry) { if (!es8311_write(entry[0], entry[1])) { logger_.error("Failed to write ES8311 register {:#04x}", entry[0]); return false; } - } - return true; + return true; + }); } bool M5StackCardputer::initialize_es8311_microphone() { @@ -109,13 +109,13 @@ bool M5StackCardputer::initialize_es8311_microphone() { {0x17, 0xBF}, // ADC: volume 0 dB {0x1C, 0x6A}, // ADC: equalizer bypass }; - for (const auto &entry : init) { + return std::all_of(std::begin(init), std::end(init), [this](const auto &entry) { if (!es8311_write(entry[0], entry[1])) { logger_.error("Failed to write ES8311 register {:#04x}", entry[0]); return false; } - } - return true; + return true; + }); } //////////////////////// From 0bee54410a32aac6684994387f9eea87d31f93da Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Mon, 13 Jul 2026 23:09:11 -0500 Subject: [PATCH 03/13] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- components/m5stack-cardputer/src/audio.cpp | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/components/m5stack-cardputer/src/audio.cpp b/components/m5stack-cardputer/src/audio.cpp index 7d84cd5586..fcb9856c5a 100644 --- a/components/m5stack-cardputer/src/audio.cpp +++ b/components/m5stack-cardputer/src/audio.cpp @@ -64,6 +64,14 @@ bool M5StackCardputer::initialize_sound(uint32_t default_audio_rate, if (variant() == Variant::ADV) { if (!initialize_es8311_speaker()) { logger_.error("Could not initialize the ES8311 codec"); + i2s_channel_disable(audio_tx_handle); + i2s_del_channel(audio_tx_handle); + audio_tx_handle = nullptr; + if (audio_tx_stream) { + vStreamBufferDelete(audio_tx_stream); + audio_tx_stream = nullptr; + } + audio_tx_buffer.clear(); return false; } } From 6eca759d03fddb41edbd8212b139dcd665ed218b Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Mon, 13 Jul 2026 23:09:21 -0500 Subject: [PATCH 04/13] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- components/m5stack-cardputer/src/microphone.cpp | 3 +++ 1 file changed, 3 insertions(+) diff --git a/components/m5stack-cardputer/src/microphone.cpp b/components/m5stack-cardputer/src/microphone.cpp index f8bf7d0439..c34001dfb2 100644 --- a/components/m5stack-cardputer/src/microphone.cpp +++ b/components/m5stack-cardputer/src/microphone.cpp @@ -73,6 +73,9 @@ bool M5StackCardputer::initialize_microphone(const microphone_callback_t &callba if (variant() == Variant::ADV) { if (!initialize_es8311_microphone()) { logger_.error("Could not initialize the ES8311 codec"); + i2s_channel_disable(audio_rx_handle); + i2s_del_channel(audio_rx_handle); + audio_rx_handle = nullptr; return false; } } From b4d513b054f5b4c699a7ce61af2abc7d6bb1574d Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Mon, 13 Jul 2026 23:09:39 -0500 Subject: [PATCH 05/13] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- components/m5stack-cardputer/src/keyboard.cpp | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/components/m5stack-cardputer/src/keyboard.cpp b/components/m5stack-cardputer/src/keyboard.cpp index f61050089e..37efb74bf3 100644 --- a/components/m5stack-cardputer/src/keyboard.cpp +++ b/components/m5stack-cardputer/src/keyboard.cpp @@ -148,9 +148,15 @@ bool M5StackCardputer::initialize_keyboard_tca8418() { break; } } while (event != 0 && ++guard < 16); - write_reg(TCA8418_REG_INT_STAT, 0xFF); + if (!write_reg(TCA8418_REG_INT_STAT, 0xFF)) { + logger_.error("Failed to clear the TCA8418 interrupt status"); + return false; + } // enable key event processing / interrupt generation - write_reg(TCA8418_REG_CFG, 0x01); + if (!write_reg(TCA8418_REG_CFG, 0x01)) { + logger_.error("Failed to enable TCA8418 key event processing"); + return false; + } return true; } From 658c41f6cce47b7226b7da27eee6b51b33cc8f50 Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Mon, 13 Jul 2026 23:10:51 -0500 Subject: [PATCH 06/13] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- components/m5stack-cardputer/src/keyboard.cpp | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/components/m5stack-cardputer/src/keyboard.cpp b/components/m5stack-cardputer/src/keyboard.cpp index 37efb74bf3..dbf83018bb 100644 --- a/components/m5stack-cardputer/src/keyboard.cpp +++ b/components/m5stack-cardputer/src/keyboard.cpp @@ -254,13 +254,18 @@ void M5StackCardputer::process_tca8418_events() { } any_events = true; bool pressed = (event & 0x80) != 0; - uint8_t n = (event & 0x7F) - 1; + uint8_t key_number = (event & 0x7F); + if (key_number == 0) { + logger_.warn("Ignoring TCA8418 event with invalid key number 0"); + continue; + } + uint8_t n = key_number - 1; uint8_t tr = n / 10; uint8_t tc = n % 10; uint8_t col = tr * 2 + ((tc > 3) ? 1 : 0); uint8_t row = tc % 4; if (row >= KEYBOARD_ROWS || col >= KEYBOARD_COLS) { - logger_.warn("Ignoring TCA8418 event with out-of-range key number {}", n + 1); + logger_.warn("Ignoring TCA8418 event with out-of-range key number {}", key_number); continue; } Modifiers mods; From 3c28431cebb89242c022375c0e3c0e789896d55c Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Mon, 13 Jul 2026 23:18:02 -0500 Subject: [PATCH 07/13] fix(m5stack-cardputer): Make variant detection thread-safe with std::call_once variant() and internal_i2c() lazily detect the board variant; the previous atomic-flag double-check let two concurrent first callers both probe the I2C bus. Funnel both through a shared std::once_flag so the detection runs exactly once and concurrent callers block until it completes. Co-Authored-By: Claude Fable 5 --- .../include/m5stack-cardputer.hpp | 5 +++-- components/m5stack-cardputer/src/keyboard.cpp | 14 ++++---------- 2 files changed, 7 insertions(+), 12 deletions(-) diff --git a/components/m5stack-cardputer/include/m5stack-cardputer.hpp b/components/m5stack-cardputer/include/m5stack-cardputer.hpp index acc67071e0..6b2437d4bf 100644 --- a/components/m5stack-cardputer/include/m5stack-cardputer.hpp +++ b/components/m5stack-cardputer/include/m5stack-cardputer.hpp @@ -751,8 +751,9 @@ class M5StackCardputer : public BaseComponent { std::atomic button_initialized_{false}; button_callback_t button_callback_{nullptr}; - // variant / internal I2C - std::atomic variant_detected_{false}; + // variant / internal I2C. The once_flag makes the lazy detection (see + // variant() / internal_i2c()) thread-safe. + std::once_flag variant_detect_once_; Variant variant_{Variant::ORIGINAL}; std::unique_ptr internal_i2c_{nullptr}; diff --git a/components/m5stack-cardputer/src/keyboard.cpp b/components/m5stack-cardputer/src/keyboard.cpp index dbf83018bb..1c9349a055 100644 --- a/components/m5stack-cardputer/src/keyboard.cpp +++ b/components/m5stack-cardputer/src/keyboard.cpp @@ -9,23 +9,18 @@ using namespace espp; ////////////////////////////// M5StackCardputer::Variant M5StackCardputer::variant() { - if (!variant_detected_) { - detect_variant(); - } + std::call_once(variant_detect_once_, [this] { detect_variant(); }); return variant_; } I2c *M5StackCardputer::internal_i2c() { - if (!variant_detected_) { - detect_variant(); - } + std::call_once(variant_detect_once_, [this] { detect_variant(); }); return internal_i2c_.get(); } void M5StackCardputer::detect_variant() { - if (variant_detected_) { - return; - } + // NOTE: only ever called (once) through the std::call_once in variant() / + // internal_i2c(), so it needs no guard of its own. // The ADV has an internal I2C bus on GPIO 8/9 hosting its TCA8418 keyboard // controller; the original repurposes those pins as 74HC138 address lines. // Probe for the TCA8418 to tell them apart. @@ -50,7 +45,6 @@ void M5StackCardputer::detect_variant() { gpio_reset_pin(internal_i2c_sda); gpio_reset_pin(internal_i2c_scl); } - variant_detected_ = true; } //////////////////////// From 900a6a88d4752166bc4e0265942dd859604b76b9 Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Mon, 13 Jul 2026 23:35:54 -0500 Subject: [PATCH 08/13] minor update --- components/m5stack-cardputer/example/sdkconfig.defaults | 5 ----- 1 file changed, 5 deletions(-) diff --git a/components/m5stack-cardputer/example/sdkconfig.defaults b/components/m5stack-cardputer/example/sdkconfig.defaults index 01003bc915..477a010cec 100644 --- a/components/m5stack-cardputer/example/sdkconfig.defaults +++ b/components/m5stack-cardputer/example/sdkconfig.defaults @@ -45,8 +45,3 @@ CONFIG_LV_USE_THEME_DEFAULT=y CONFIG_LV_THEME_DEFAULT_DARK=y CONFIG_LV_THEME_DEFAULT_GROW=y CONFIG_LV_THEME_DEFAULT_TRANSITION_TIME=80 - -# The Cardputer's UART0 pins (GPIO43/44) are used by the speaker I2S and the -# IR transmitter, so the console must use the USB-Serial-JTAG interface (the -# board's USB-C port). -CONFIG_ESP_CONSOLE_USB_SERIAL_JTAG=y From 36910861c66fef298484a8edd4a2784cb163fa91 Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Tue, 14 Jul 2026 10:39:51 -0500 Subject: [PATCH 09/13] feat(m5stack-cardputer): Add battery state of charge and IMU (ADV) support - battery_soc(): estimate the battery state of charge (0-100%) from the measured voltage via a piecewise-linear 1S lithium-ion discharge curve (documented as an approximation since the voltage sags under load) - initialize_imu() / imu(): bring up the Cardputer ADV's BMI270 on the internal I2C bus using the espp bmi270 driver (with optional orientation filter and configurable ImuConfig); fails with a clear error on the original Cardputer, which has no IMU - Example: status bar now shows voltage and state of charge; on the ADV a top-right overlay shows live accelerometer / gyroscope readings updated at 10 Hz Clean-built with ESP-IDF for esp32s3. Co-Authored-By: Claude Fable 5 --- components/m5stack-cardputer/CMakeLists.txt | 2 +- components/m5stack-cardputer/README.md | 9 ++- .../m5stack-cardputer/example/README.md | 4 +- .../m5stack-cardputer/example/main/gui.cpp | 20 +++++++ .../m5stack-cardputer/example/main/gui.hpp | 7 +++ .../main/m5stack_cardputer_example.cpp | 35 ++++++++++- .../m5stack-cardputer/idf_component.yml | 1 + .../include/m5stack-cardputer.hpp | 59 +++++++++++++++++++ components/m5stack-cardputer/src/imu.cpp | 43 ++++++++++++++ .../src/m5stack-cardputer.cpp | 21 +++++++ 10 files changed, 193 insertions(+), 8 deletions(-) create mode 100644 components/m5stack-cardputer/src/imu.cpp diff --git a/components/m5stack-cardputer/CMakeLists.txt b/components/m5stack-cardputer/CMakeLists.txt index 5be6144eab..72f7dd869c 100644 --- a/components/m5stack-cardputer/CMakeLists.txt +++ b/components/m5stack-cardputer/CMakeLists.txt @@ -2,6 +2,6 @@ idf_component_register( INCLUDE_DIRS "include" SRC_DIRS "src" - REQUIRES driver esp_adc esp_driver_i2s fatfs base_component adc display display_drivers i2c interrupt led neopixel spi task + REQUIRES driver esp_adc esp_driver_i2s fatfs base_component adc bmi270 display display_drivers i2c interrupt led neopixel spi task REQUIRED_IDF_TARGETS "esp32s3" ) diff --git a/components/m5stack-cardputer/README.md b/components/m5stack-cardputer/README.md index 3da1729a20..be25395614 100644 --- a/components/m5stack-cardputer/README.md +++ b/components/m5stack-cardputer/README.md @@ -28,13 +28,16 @@ singleton hardware abstraction for initializing and using the subsystems: the ES8311 codec's ADC. - **uSD card**: SPI-mode micro-SD mounted at `/sdcard`. - **RGB LED**: the StampS3's WS2812 via `espp::Neopixel`. -- **Battery**: battery voltage measurement (2:1 divider into ADC1). +- **Battery**: battery voltage measurement (2:1 divider into ADC1) and an + estimated state of charge from a 1S lithium-ion discharge curve. - **G0 (BOOT) button**: via `espp::Interrupt` with a callback. - **IR transmitter / Grove port**: pin accessors for use with your own RMT / I2C drivers. +- **IMU** (ADV only): the BMI270 on the internal I2C bus via + `initialize_imu()` / `imu()` (espp `bmi270` driver, with optional + orientation filter). - **Internal I2C bus** (ADV only): accessor for the bus hosting the TCA8418 - (0x34), ES8311 (0x18), and the ADV's BMI270 IMU (0x68) - the IMU can be - used with the espp `bmi270` component. + (0x34), ES8311 (0x18), and BMI270 (0x68). > [!NOTE] > The speaker and the microphone share I2S pins (GPIO 43 word-select / PDM diff --git a/components/m5stack-cardputer/example/README.md b/components/m5stack-cardputer/example/README.md index e92ed3fcce..1f9199db8f 100644 --- a/components/m5stack-cardputer/example/README.md +++ b/components/m5stack-cardputer/example/README.md @@ -10,7 +10,9 @@ component to initialize and use the components on the M5Stack Cardputer: - The speaker (a key-click beep for every keypress) - The RGB LED (the G0 / BOOT button cycles its color) - The uSD card (mounted at startup if inserted) -- The battery voltage measurement (shown in the status bar) +- The battery voltage / state-of-charge measurement (shown in the status bar) +- The IMU on the Cardputer ADV (live accelerometer / gyroscope readings shown + in the top-right corner) ## How to use example diff --git a/components/m5stack-cardputer/example/main/gui.cpp b/components/m5stack-cardputer/example/main/gui.cpp index 4384f7699d..301b31569e 100644 --- a/components/m5stack-cardputer/example/main/gui.cpp +++ b/components/m5stack-cardputer/example/main/gui.cpp @@ -5,6 +5,7 @@ void Gui::init_ui() { init_background(); init_textarea(); init_status_bar(); + init_imu_label(); } void Gui::deinit_ui() { @@ -13,6 +14,7 @@ void Gui::deinit_ui() { background_ = nullptr; textarea_ = nullptr; status_label_ = nullptr; + imu_label_ = nullptr; } void Gui::init_background() { @@ -44,6 +46,14 @@ void Gui::init_status_bar() { lv_label_set_text(status_label_, "Ready"); } +void Gui::init_imu_label() { + // floats over the top-right corner of the text area; empty (and therefore + // invisible) until the first set_imu_text() call + imu_label_ = lv_label_create(background_); + lv_obj_align(imu_label_, LV_ALIGN_TOP_RIGHT, 0, 0); + lv_label_set_text(imu_label_, ""); +} + void Gui::add_char(char c) { std::lock_guard lock(mutex_); if (!textarea_) { @@ -97,6 +107,16 @@ void Gui::set_status_text(std::string_view text) { lv_label_set_text(status_label_, text_str.c_str()); } +void Gui::set_imu_text(std::string_view text) { + std::lock_guard lock(mutex_); + if (!imu_label_) { + return; + } + // string_view is not guaranteed to be null-terminated + const std::string text_str(text); + lv_label_set_text(imu_label_, text_str.c_str()); +} + bool Gui::update(std::mutex &m, std::condition_variable &cv) { { std::lock_guard lock(mutex_); diff --git a/components/m5stack-cardputer/example/main/gui.hpp b/components/m5stack-cardputer/example/main/gui.hpp index b44cb3da6e..ff58267c54 100644 --- a/components/m5stack-cardputer/example/main/gui.hpp +++ b/components/m5stack-cardputer/example/main/gui.hpp @@ -64,6 +64,11 @@ class Gui { /// @param text The text to display void set_status_text(std::string_view text); + /// Set the text of the IMU overlay label (top-right corner; empty and + /// invisible until first set). Thread-safe. + /// @param text The text to display + void set_imu_text(std::string_view text); + protected: void init_ui(); void deinit_ui(); @@ -72,6 +77,7 @@ class Gui { void init_background(); void init_textarea(); void init_status_bar(); + void init_imu_label(); // the LVGL update task: calls lv_task_handler() under the mutex bool update(std::mutex &m, std::condition_variable &cv); @@ -80,6 +86,7 @@ class Gui { lv_obj_t *background_{nullptr}; lv_obj_t *textarea_{nullptr}; lv_obj_t *status_label_{nullptr}; + lv_obj_t *imu_label_{nullptr}; espp::Task update_task_{{.callback = [this](auto &m, auto &cv) { return update(m, cv); }, .task_config = {.name = "gui", .stack_size_bytes = 6 * 1024}}}; diff --git a/components/m5stack-cardputer/example/main/m5stack_cardputer_example.cpp b/components/m5stack-cardputer/example/main/m5stack_cardputer_example.cpp index 81fbdb5e95..71e20af70d 100644 --- a/components/m5stack-cardputer/example/main/m5stack_cardputer_example.cpp +++ b/components/m5stack-cardputer/example/main/m5stack_cardputer_example.cpp @@ -107,6 +107,16 @@ extern "C" void app_main(void) { } logger.info("Board variant: {}", espp::M5StackCardputer::variant_name(cardputer.variant())); + // the ADV has a BMI270 IMU on the internal I2C bus; initialize it if we're + // on one (warn and continue otherwise - the original has no IMU) + bool have_imu = false; + if (cardputer.variant() == espp::M5StackCardputer::Variant::ADV) { + have_imu = cardputer.initialize_imu(); + if (!have_imu) { + logger.warn("Could not initialize the IMU!"); + } + } + // the G0 (BOOT) button cycles the RGB LED color static std::atomic led_hue{0}; auto button_callback = [&](const espp::Interrupt::Event &event) { @@ -124,10 +134,29 @@ extern "C" void app_main(void) { // set the initial LED color cardputer.led(espp::Hsv(static_cast(led_hue), 1.0f, 0.2f)); - // periodically update the status bar with the battery voltage + // periodically update the status bar with the battery voltage / state of + // charge, and (on the ADV) the IMU overlay with the latest accelerometer + // and gyroscope readings + static constexpr auto imu_period = 100ms; + int loops_per_battery_update = std::chrono::seconds(5) / imu_period; + int loop_count = 0; while (true) { - gui.set_status_text(fmt::format("Battery: {:.2f} V", cardputer.battery_voltage())); - std::this_thread::sleep_for(5s); + if (have_imu) { + auto imu = cardputer.imu(); + std::error_code ec; + if (imu->update(std::chrono::duration(imu_period).count(), ec)) { + auto accel = imu->get_accelerometer(); + auto gyro = imu->get_gyroscope(); + gui.set_imu_text(fmt::format("a {:+.1f} {:+.1f} {:+.1f}\ng {:+5.0f} {:+5.0f} {:+5.0f}", + accel.x, accel.y, accel.z, gyro.x, gyro.y, gyro.z)); + } + } + if ((loop_count % loops_per_battery_update) == 0) { + gui.set_status_text(fmt::format("Battery: {:.2f} V ({:.0f}%)", cardputer.battery_voltage(), + cardputer.battery_soc())); + } + loop_count++; + std::this_thread::sleep_for(imu_period); } //! [m5stack-cardputer example] } diff --git a/components/m5stack-cardputer/idf_component.yml b/components/m5stack-cardputer/idf_component.yml index 3e0f58fe96..ff10527fcd 100644 --- a/components/m5stack-cardputer/idf_component.yml +++ b/components/m5stack-cardputer/idf_component.yml @@ -19,6 +19,7 @@ dependencies: version: '>=5.0' espp/adc: '>=1.0' espp/base_component: '>=1.0' + espp/bmi270: '>=1.0' espp/display: '>=1.0' espp/display_drivers: '>=1.0' espp/i2c: '>=1.0' diff --git a/components/m5stack-cardputer/include/m5stack-cardputer.hpp b/components/m5stack-cardputer/include/m5stack-cardputer.hpp index 6b2437d4bf..b78230fab6 100644 --- a/components/m5stack-cardputer/include/m5stack-cardputer.hpp +++ b/components/m5stack-cardputer/include/m5stack-cardputer.hpp @@ -7,6 +7,7 @@ #include #include #include +#include #include #include @@ -25,6 +26,7 @@ #include #include "base_component.hpp" +#include "bmi270.hpp" #include "i2c.hpp" #include "interrupt.hpp" #include "led.hpp" @@ -491,6 +493,44 @@ class M5StackCardputer : public BaseComponent { /// \note The battery voltage is measured through a 2:1 divider on GPIO 10 float battery_voltage(); + /// Get the battery state of charge + /// \return The battery state of charge as a percentage (0 - 100) + /// \note This is estimated from the battery voltage using a typical 1S + /// lithium-ion discharge curve, so it is only an approximation - the + /// voltage sags under load (e.g. with the backlight at full + /// brightness or the speaker playing) which will lower the estimate. + float battery_soc(); + + ///////////////////////////////////////////////////////////////////////////// + // IMU (Cardputer ADV only) + ///////////////////////////////////////////////////////////////////////////// + + /// Alias for the IMU (BMI270) on the Cardputer ADV's internal I2C bus + using Imu = espp::Bmi270; + + /// Initialize the IMU (BMI270; Cardputer ADV only) + /// \param orientation_filter Optional filter function for orientation + /// (e.g. a kalman or madgwick filter); called by Imu::update() + /// \param imu_config The IMU configuration + /// \return true if the IMU was successfully initialized, false otherwise + /// \note The original Cardputer has no IMU, so this fails (with an error + /// log) unless the board is a Cardputer ADV. + bool initialize_imu( + const Imu::filter_fn &orientation_filter = nullptr, + const Imu::ImuConfig &imu_config = { + .accelerometer_range = Imu::AccelerometerRange::RANGE_4G, + .accelerometer_odr = Imu::AccelerometerODR::ODR_100_HZ, + .accelerometer_bandwidth = Imu::AccelerometerBandwidth::NORMAL_AVG4, + .gyroscope_range = Imu::GyroscopeRange::RANGE_1000DPS, + .gyroscope_odr = Imu::GyroscopeODR::ODR_100_HZ, + .gyroscope_bandwidth = Imu::GyroscopeBandwidth::NORMAL_MODE, + .gyroscope_performance_mode = Imu::GyroscopePerformanceMode::PERFORMANCE_OPTIMIZED}); + + /// Get a shared pointer to the IMU + /// \return A shared pointer to the IMU, or nullptr if it has not been + /// (successfully) initialized + std::shared_ptr imu() const { return imu_; } + ///////////////////////////////////////////////////////////////////////////// // Misc. pins (IR transmitter, Grove port) ///////////////////////////////////////////////////////////////////////////// @@ -713,6 +753,22 @@ class M5StackCardputer : public BaseComponent { // Battery voltage measurement (2:1 divider into ADC1 on GPIO 10) static constexpr float BATTERY_VOLTAGE_SCALE = 2.0f / 1000.0f; // divider ratio, mV -> V + + // Approximate resting discharge curve for a 1S lithium-ion cell, used to + // estimate the state of charge from the battery voltage. Entries are + // {voltage (V), state of charge (%)}, in descending voltage order. + static constexpr std::array, 10> BATTERY_SOC_CURVE{{ + {4.20f, 100.0f}, + {4.10f, 93.0f}, + {4.00f, 81.0f}, + {3.90f, 66.0f}, + {3.80f, 49.0f}, + {3.70f, 32.0f}, + {3.60f, 17.0f}, + {3.50f, 7.0f}, + {3.40f, 2.0f}, + {3.30f, 0.0f}, + }}; espp::AdcConfig battery_channel_{.unit = ADC_UNIT_1, .channel = ADC_CHANNEL_9, // GPIO 10 on ESP32-S3 .attenuation = ADC_ATTEN_DB_12}; @@ -726,6 +782,9 @@ class M5StackCardputer : public BaseComponent { // RGB LED std::shared_ptr rgb_led_{nullptr}; + // IMU (Cardputer ADV only) + std::shared_ptr imu_{nullptr}; + // Interrupts espp::Interrupt::PinConfig button_interrupt_pin_{ .gpio_num = button_io, diff --git a/components/m5stack-cardputer/src/imu.cpp b/components/m5stack-cardputer/src/imu.cpp new file mode 100644 index 0000000000..60f09ea694 --- /dev/null +++ b/components/m5stack-cardputer/src/imu.cpp @@ -0,0 +1,43 @@ +#include "m5stack-cardputer.hpp" + +using namespace espp; + +//////////////////////// +// IMU Functions // +//////////////////////// + +bool M5StackCardputer::initialize_imu(const Imu::filter_fn &orientation_filter, + const Imu::ImuConfig &imu_config) { + logger_.info("Initializing IMU"); + if (imu_) { + logger_.warn("IMU already initialized, not initializing again!"); + return false; + } + if (variant() != Variant::ADV) { + logger_.error("The IMU (BMI270) is only present on the Cardputer ADV"); + return false; + } + if (!internal_i2c_) { + logger_.error("Internal I2C bus not available, cannot initialize the IMU"); + return false; + } + + using namespace std::placeholders; + imu_ = std::make_shared(Imu::Config{ + .device_address = Imu::DEFAULT_ADDRESS, + .write = std::bind(&I2c::write, internal_i2c_.get(), _1, _2, _3), + .read = std::bind(&I2c::read, internal_i2c_.get(), _1, _2, _3), + .imu_config = imu_config, + .orientation_filter = orientation_filter, + .auto_init = false, + .log_level = get_log_level(), + }); + + std::error_code ec; + if (!imu_->init(ec)) { + logger_.error("Failed to initialize the IMU: {}", ec.message()); + imu_.reset(); + return false; + } + return true; +} diff --git a/components/m5stack-cardputer/src/m5stack-cardputer.cpp b/components/m5stack-cardputer/src/m5stack-cardputer.cpp index 3942f697bd..a0de07e8e1 100644 --- a/components/m5stack-cardputer/src/m5stack-cardputer.cpp +++ b/components/m5stack-cardputer/src/m5stack-cardputer.cpp @@ -130,3 +130,24 @@ float M5StackCardputer::battery_voltage() { } return voltage; } + +float M5StackCardputer::battery_soc() { + float voltage = battery_voltage(); + const auto &curve = BATTERY_SOC_CURVE; + if (voltage >= curve.front().first) { + return curve.front().second; + } + if (voltage <= curve.back().first) { + return curve.back().second; + } + // linearly interpolate between the two surrounding curve points + for (size_t i = 1; i < curve.size(); i++) { + if (voltage >= curve[i].first) { + const auto &[v_high, soc_high] = curve[i - 1]; + const auto &[v_low, soc_low] = curve[i]; + float t = (voltage - v_low) / (v_high - v_low); + return soc_low + t * (soc_high - soc_low); + } + } + return 0.0f; +} From bc8d0c1741271e90dbcd647e1acedc0fc00bafd9 Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Tue, 14 Jul 2026 10:57:08 -0500 Subject: [PATCH 10/13] fix(m5stack-cardputer): Fix IMU initialization (I2C address probe, chunked config upload) The BMI270 init failed on hardware: the driver uploads its 8kB config blob, which by default is written in a single I2C transaction that exceeds the internal I2C bus timeout. Upload it in 128-byte bursts instead, and probe both possible I2C addresses (0x68/0x69) to find the device rather than assuming the SDO strap. Co-Authored-By: Claude Fable 5 --- components/m5stack-cardputer/src/imu.cpp | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/components/m5stack-cardputer/src/imu.cpp b/components/m5stack-cardputer/src/imu.cpp index 60f09ea694..7c522f7efd 100644 --- a/components/m5stack-cardputer/src/imu.cpp +++ b/components/m5stack-cardputer/src/imu.cpp @@ -22,13 +22,29 @@ bool M5StackCardputer::initialize_imu(const Imu::filter_fn &orientation_filter, return false; } + // the BMI270 can be strapped to either address; probe to find it + uint8_t address = Imu::DEFAULT_ADDRESS; + if (!internal_i2c_->probe_device(address)) { + address = Imu::DEFAULT_ADDRESS_SDO_HIGH; + if (!internal_i2c_->probe_device(address)) { + logger_.error("No BMI270 found on the internal I2C bus (probed {:#04x} and {:#04x})", + Imu::DEFAULT_ADDRESS, Imu::DEFAULT_ADDRESS_SDO_HIGH); + return false; + } + } + logger_.info("Found BMI270 at {:#04x}", address); + using namespace std::placeholders; imu_ = std::make_shared(Imu::Config{ - .device_address = Imu::DEFAULT_ADDRESS, + .device_address = address, .write = std::bind(&I2c::write, internal_i2c_.get(), _1, _2, _3), .read = std::bind(&I2c::read, internal_i2c_.get(), _1, _2, _3), .imu_config = imu_config, .orientation_filter = orientation_filter, + // upload the BMI270's 8kB config file in small chunks: a single + // full-size write would exceed the internal I2C bus timeout (and use a + // large stack buffer) + .burst_write_size = 128, .auto_init = false, .log_level = get_log_level(), }); From 2438e34beb689b31dcccd3f851a13e587a9a7bc5 Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Tue, 14 Jul 2026 11:20:10 -0500 Subject: [PATCH 11/13] feat(m5stack-cardputer): Add help popup and IMU overlay toggle to the example - fn+1 (F1) toggles a centered popup listing the controls; fn+2 (F2) toggles the IMU overlay visibility (IMU updates pause while hidden) - The controls are defined once (Gui::HELP_TEXT), shown in the popup, and printed to the log at startup - Document the new controls in the example README Co-Authored-By: Claude Fable 5 --- .../m5stack-cardputer/example/README.md | 4 ++ .../m5stack-cardputer/example/main/gui.cpp | 41 +++++++++++++++++++ .../m5stack-cardputer/example/main/gui.hpp | 28 +++++++++++++ .../main/m5stack_cardputer_example.cpp | 17 +++++++- 4 files changed, 88 insertions(+), 2 deletions(-) diff --git a/components/m5stack-cardputer/example/README.md b/components/m5stack-cardputer/example/README.md index 1f9199db8f..82550dd8f4 100644 --- a/components/m5stack-cardputer/example/README.md +++ b/components/m5stack-cardputer/example/README.md @@ -25,9 +25,13 @@ component to initialize and use the components on the M5Stack Cardputer: | Fn + `;` / `,` / `.` / `/` | Move the cursor (up / left / down / right) | | Fn + backspace | Delete forward | | Fn + `` ` `` | Clear the text area | +| Fn + 1 (F1) | Show / hide the controls help popup | +| Fn + 2 (F2) | Show / hide the IMU overlay (ADV) | | Fn + number row | Show F1-F12 in the status bar | | G0 (BOOT) button | Cycle the RGB LED color | +The controls are also printed to the log at startup. + The status bar at the bottom of the screen shows the most recent key / modifier activity and is updated with the battery voltage every few seconds. diff --git a/components/m5stack-cardputer/example/main/gui.cpp b/components/m5stack-cardputer/example/main/gui.cpp index 301b31569e..3f076d233d 100644 --- a/components/m5stack-cardputer/example/main/gui.cpp +++ b/components/m5stack-cardputer/example/main/gui.cpp @@ -6,6 +6,7 @@ void Gui::init_ui() { init_textarea(); init_status_bar(); init_imu_label(); + init_help_panel(); } void Gui::deinit_ui() { @@ -15,6 +16,7 @@ void Gui::deinit_ui() { textarea_ = nullptr; status_label_ = nullptr; imu_label_ = nullptr; + help_panel_ = nullptr; } void Gui::init_background() { @@ -54,6 +56,45 @@ void Gui::init_imu_label() { lv_label_set_text(imu_label_, ""); } +void Gui::init_help_panel() { + // a centered popup listing the controls; hidden until toggled via fn+1 + help_panel_ = lv_obj_create(background_); + lv_obj_set_size(help_panel_, lv_pct(92), lv_pct(92)); + lv_obj_center(help_panel_); + lv_obj_add_flag(help_panel_, LV_OBJ_FLAG_HIDDEN); + auto *label = lv_label_create(help_panel_); + lv_label_set_text(label, HELP_TEXT); + lv_obj_align(label, LV_ALIGN_TOP_LEFT, 0, 0); +} + +bool Gui::toggle_imu_visible() { + std::lock_guard lock(mutex_); + imu_visible_ = !imu_visible_; + if (imu_label_) { + if (imu_visible_) { + lv_obj_remove_flag(imu_label_, LV_OBJ_FLAG_HIDDEN); + } else { + lv_obj_add_flag(imu_label_, LV_OBJ_FLAG_HIDDEN); + } + } + return imu_visible_; +} + +bool Gui::toggle_help() { + std::lock_guard lock(mutex_); + help_visible_ = !help_visible_; + if (help_panel_) { + if (help_visible_) { + lv_obj_remove_flag(help_panel_, LV_OBJ_FLAG_HIDDEN); + // make sure the popup is on top of the other UI elements + lv_obj_move_foreground(help_panel_); + } else { + lv_obj_add_flag(help_panel_, LV_OBJ_FLAG_HIDDEN); + } + } + return help_visible_; +} + void Gui::add_char(char c) { std::lock_guard lock(mutex_); if (!textarea_) { diff --git a/components/m5stack-cardputer/example/main/gui.hpp b/components/m5stack-cardputer/example/main/gui.hpp index ff58267c54..cd06fe24d5 100644 --- a/components/m5stack-cardputer/example/main/gui.hpp +++ b/components/m5stack-cardputer/example/main/gui.hpp @@ -1,5 +1,6 @@ #pragma once +#include #include #include #include @@ -30,6 +31,16 @@ class Gui { /// Alias for the special (fn layer) keys of the Cardputer keyboard using SpecialKey = espp::M5StackCardputer::SpecialKey; + /// The controls for this example; shown in the help popup (fn+1) and + /// intended to also be printed to the log at startup. + static constexpr const char *HELP_TEXT = "Type keys to enter text\n" + "fn+; , . / move cursor\n" + "fn+` clear text\n" + "fn+bksp delete forward\n" + "fn+1 (F1) show/hide help\n" + "fn+2 (F2) show/hide IMU\n" + "G0 button cycle LED color"; + /// Configuration for the Gui struct Config { espp::Logger::Verbosity log_level{espp::Logger::Verbosity::WARN}; ///< Log verbosity @@ -69,6 +80,18 @@ class Gui { /// @param text The text to display void set_imu_text(std::string_view text); + /// Toggle the visibility of the IMU overlay label. Thread-safe. + /// @return true if the overlay is now visible + bool toggle_imu_visible(); + + /// Get whether the IMU overlay is currently visible + /// @return true if the overlay is visible + bool imu_visible() const { return imu_visible_; } + + /// Toggle the help popup (which lists the controls). Thread-safe. + /// @return true if the popup is now shown + bool toggle_help(); + protected: void init_ui(); void deinit_ui(); @@ -78,6 +101,7 @@ class Gui { void init_textarea(); void init_status_bar(); void init_imu_label(); + void init_help_panel(); // the LVGL update task: calls lv_task_handler() under the mutex bool update(std::mutex &m, std::condition_variable &cv); @@ -87,6 +111,10 @@ class Gui { lv_obj_t *textarea_{nullptr}; lv_obj_t *status_label_{nullptr}; lv_obj_t *imu_label_{nullptr}; + lv_obj_t *help_panel_{nullptr}; + + std::atomic imu_visible_{true}; + std::atomic help_visible_{false}; espp::Task update_task_{{.callback = [this](auto &m, auto &cv) { return update(m, cv); }, .task_config = {.name = "gui", .stack_size_bytes = 6 * 1024}}}; diff --git a/components/m5stack-cardputer/example/main/m5stack_cardputer_example.cpp b/components/m5stack-cardputer/example/main/m5stack_cardputer_example.cpp index 71e20af70d..75e048001a 100644 --- a/components/m5stack-cardputer/example/main/m5stack_cardputer_example.cpp +++ b/components/m5stack-cardputer/example/main/m5stack_cardputer_example.cpp @@ -69,6 +69,9 @@ extern "C" void app_main(void) { // create the GUI (a small keyboard-driven text editor) static Gui gui({}); + // print the controls (also available on-screen via the fn+1 help popup) + logger.info("Controls:\n{}", Gui::HELP_TEXT); + // the keyboard scanner delivers one event per key state change; use it to // drive the text editor, play key-click sounds, and show what's happening // in the status bar @@ -76,7 +79,17 @@ extern "C" void app_main(void) { if (!event.pressed) { return; } - if (event.special != espp::M5StackCardputer::SpecialKey::NONE) { + if (event.special == espp::M5StackCardputer::SpecialKey::F1) { + // toggle the help popup + bool shown = gui.toggle_help(); + gui.set_status_text(shown ? "Help (fn+1 to close)" : "Ready"); + play_beep(cardputer, 660.0f); + } else if (event.special == espp::M5StackCardputer::SpecialKey::F2) { + // toggle the IMU overlay + bool visible = gui.toggle_imu_visible(); + gui.set_status_text(visible ? "IMU overlay shown" : "IMU overlay hidden"); + play_beep(cardputer, 660.0f); + } else if (event.special != espp::M5StackCardputer::SpecialKey::NONE) { gui.handle_special_key(event.special); gui.set_status_text(espp::M5StackCardputer::special_key_name(event.special)); play_beep(cardputer, 660.0f); @@ -141,7 +154,7 @@ extern "C" void app_main(void) { int loops_per_battery_update = std::chrono::seconds(5) / imu_period; int loop_count = 0; while (true) { - if (have_imu) { + if (have_imu && gui.imu_visible()) { auto imu = cardputer.imu(); std::error_code ec; if (imu->update(std::chrono::duration(imu_period).count(), ec)) { From 25238b5f3f8949b6b856940154cbf65e56362746 Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Tue, 14 Jul 2026 13:41:33 -0500 Subject: [PATCH 12/13] feat(m5stack-cardputer): Make the IMU display a popup; make the help popup scrollable - The IMU readout is now a small self-documenting popup (top-right, with an 'fn+2 to close' hint); it starts hidden and is shown automatically once the IMU initializes, so the original Cardputer never shows an empty popup (fn+2 there reports 'No IMU on this board') - The help popup's label now wraps to the panel width (it previously ran off screen), and while the popup is open the fn+arrow keys scroll it and fn+esc / fn+1 close it; the help text documents this Co-Authored-By: Claude Fable 5 --- .../m5stack-cardputer/example/main/gui.cpp | 62 +++++++++++++++---- .../m5stack-cardputer/example/main/gui.hpp | 25 +++++--- .../main/m5stack_cardputer_example.cpp | 20 ++++-- 3 files changed, 80 insertions(+), 27 deletions(-) diff --git a/components/m5stack-cardputer/example/main/gui.cpp b/components/m5stack-cardputer/example/main/gui.cpp index 3f076d233d..7f5ed448f9 100644 --- a/components/m5stack-cardputer/example/main/gui.cpp +++ b/components/m5stack-cardputer/example/main/gui.cpp @@ -5,7 +5,7 @@ void Gui::init_ui() { init_background(); init_textarea(); init_status_bar(); - init_imu_label(); + init_imu_popup(); init_help_panel(); } @@ -15,6 +15,7 @@ void Gui::deinit_ui() { background_ = nullptr; textarea_ = nullptr; status_label_ = nullptr; + imu_popup_ = nullptr; imu_label_ = nullptr; help_panel_ = nullptr; } @@ -48,21 +49,33 @@ void Gui::init_status_bar() { lv_label_set_text(status_label_, "Ready"); } -void Gui::init_imu_label() { - // floats over the top-right corner of the text area; empty (and therefore - // invisible) until the first set_imu_text() call - imu_label_ = lv_label_create(background_); - lv_obj_align(imu_label_, LV_ALIGN_TOP_RIGHT, 0, 0); - lv_label_set_text(imu_label_, ""); +void Gui::init_imu_popup() { + // a small popup in the top-right corner holding the live IMU readings and + // a hint for how to close it + imu_popup_ = lv_obj_create(background_); + lv_obj_set_size(imu_popup_, LV_SIZE_CONTENT, LV_SIZE_CONTENT); + lv_obj_align(imu_popup_, LV_ALIGN_TOP_RIGHT, -2, 2); + lv_obj_set_style_pad_all(imu_popup_, 4, LV_PART_MAIN); + lv_obj_add_flag(imu_popup_, LV_OBJ_FLAG_HIDDEN); + imu_label_ = lv_label_create(imu_popup_); + lv_obj_align(imu_label_, LV_ALIGN_TOP_LEFT, 0, 0); + lv_label_set_text(imu_label_, "IMU..."); } void Gui::init_help_panel() { - // a centered popup listing the controls; hidden until toggled via fn+1 + // a centered popup listing the controls; hidden until toggled via fn+1. + // The content is taller than the panel, so the panel is scrollable (see + // handle_special_key(): the fn+arrow keys scroll it while it is open) help_panel_ = lv_obj_create(background_); lv_obj_set_size(help_panel_, lv_pct(92), lv_pct(92)); lv_obj_center(help_panel_); + lv_obj_set_style_pad_all(help_panel_, 6, LV_PART_MAIN); lv_obj_add_flag(help_panel_, LV_OBJ_FLAG_HIDDEN); auto *label = lv_label_create(help_panel_); + // constrain the label to the panel's content width so long lines wrap + // instead of running off screen + lv_obj_set_width(label, lv_pct(100)); + lv_label_set_long_mode(label, LV_LABEL_LONG_WRAP); lv_label_set_text(label, HELP_TEXT); lv_obj_align(label, LV_ALIGN_TOP_LEFT, 0, 0); } @@ -70,11 +83,12 @@ void Gui::init_help_panel() { bool Gui::toggle_imu_visible() { std::lock_guard lock(mutex_); imu_visible_ = !imu_visible_; - if (imu_label_) { + if (imu_popup_) { if (imu_visible_) { - lv_obj_remove_flag(imu_label_, LV_OBJ_FLAG_HIDDEN); + lv_obj_remove_flag(imu_popup_, LV_OBJ_FLAG_HIDDEN); + lv_obj_move_foreground(imu_popup_); } else { - lv_obj_add_flag(imu_label_, LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(imu_popup_, LV_OBJ_FLAG_HIDDEN); } } return imu_visible_; @@ -109,6 +123,27 @@ void Gui::add_char(char c) { void Gui::handle_special_key(SpecialKey key) { std::lock_guard lock(mutex_); + if (help_visible_ && help_panel_) { + // while the help popup is open, the arrow keys scroll it and esc closes + // it instead of acting on the text area + switch (key) { + case SpecialKey::UP: + lv_obj_scroll_to_y(help_panel_, lv_obj_get_scroll_y(help_panel_) - 20, LV_ANIM_ON); + return; + case SpecialKey::DOWN: + lv_obj_scroll_to_y(help_panel_, lv_obj_get_scroll_y(help_panel_) + 20, LV_ANIM_ON); + return; + case SpecialKey::LEFT: + case SpecialKey::RIGHT: + // ignored while the help popup is open + return; + case SpecialKey::ESC: + toggle_help(); + return; + default: + break; + } + } if (!textarea_) { return; } @@ -153,8 +188,9 @@ void Gui::set_imu_text(std::string_view text) { if (!imu_label_) { return; } - // string_view is not guaranteed to be null-terminated - const std::string text_str(text); + // append the close hint so the popup is self-documenting (and copy since + // string_view is not guaranteed to be null-terminated) + const std::string text_str = std::string(text) + "\nfn+2 to close"; lv_label_set_text(imu_label_, text_str.c_str()); } diff --git a/components/m5stack-cardputer/example/main/gui.hpp b/components/m5stack-cardputer/example/main/gui.hpp index cd06fe24d5..b57add8769 100644 --- a/components/m5stack-cardputer/example/main/gui.hpp +++ b/components/m5stack-cardputer/example/main/gui.hpp @@ -39,7 +39,11 @@ class Gui { "fn+bksp delete forward\n" "fn+1 (F1) show/hide help\n" "fn+2 (F2) show/hide IMU\n" - "G0 button cycle LED color"; + "G0 button cycle LED color\n" + "\n" + "While help is open:\n" + "fn+; / fn+. scroll\n" + "fn+` or fn+1 close"; /// Configuration for the Gui struct Config { @@ -75,17 +79,17 @@ class Gui { /// @param text The text to display void set_status_text(std::string_view text); - /// Set the text of the IMU overlay label (top-right corner; empty and - /// invisible until first set). Thread-safe. + /// Set the text of the IMU popup (top-right corner). The popup also shows + /// how to close it (fn+2). Thread-safe. /// @param text The text to display void set_imu_text(std::string_view text); - /// Toggle the visibility of the IMU overlay label. Thread-safe. - /// @return true if the overlay is now visible + /// Toggle the visibility of the IMU popup. Thread-safe. + /// @return true if the popup is now visible bool toggle_imu_visible(); - /// Get whether the IMU overlay is currently visible - /// @return true if the overlay is visible + /// Get whether the IMU popup is currently visible + /// @return true if the popup is visible bool imu_visible() const { return imu_visible_; } /// Toggle the help popup (which lists the controls). Thread-safe. @@ -100,7 +104,7 @@ class Gui { void init_background(); void init_textarea(); void init_status_bar(); - void init_imu_label(); + void init_imu_popup(); void init_help_panel(); // the LVGL update task: calls lv_task_handler() under the mutex @@ -110,10 +114,13 @@ class Gui { lv_obj_t *background_{nullptr}; lv_obj_t *textarea_{nullptr}; lv_obj_t *status_label_{nullptr}; + lv_obj_t *imu_popup_{nullptr}; lv_obj_t *imu_label_{nullptr}; lv_obj_t *help_panel_{nullptr}; - std::atomic imu_visible_{true}; + // the IMU popup starts hidden; the app shows it (via toggle_imu_visible()) + // once the IMU has been successfully initialized + std::atomic imu_visible_{false}; std::atomic help_visible_{false}; espp::Task update_task_{{.callback = [this](auto &m, auto &cv) { return update(m, cv); }, diff --git a/components/m5stack-cardputer/example/main/m5stack_cardputer_example.cpp b/components/m5stack-cardputer/example/main/m5stack_cardputer_example.cpp index 75e048001a..5d775c9786 100644 --- a/components/m5stack-cardputer/example/main/m5stack_cardputer_example.cpp +++ b/components/m5stack-cardputer/example/main/m5stack_cardputer_example.cpp @@ -72,6 +72,10 @@ extern "C" void app_main(void) { // print the controls (also available on-screen via the fn+1 help popup) logger.info("Controls:\n{}", Gui::HELP_TEXT); + // whether the board has a working IMU; set after keyboard / variant + // detection below, referenced by the keypress callback + static bool have_imu = false; + // the keyboard scanner delivers one event per key state change; use it to // drive the text editor, play key-click sounds, and show what's happening // in the status bar @@ -85,9 +89,13 @@ extern "C" void app_main(void) { gui.set_status_text(shown ? "Help (fn+1 to close)" : "Ready"); play_beep(cardputer, 660.0f); } else if (event.special == espp::M5StackCardputer::SpecialKey::F2) { - // toggle the IMU overlay - bool visible = gui.toggle_imu_visible(); - gui.set_status_text(visible ? "IMU overlay shown" : "IMU overlay hidden"); + // toggle the IMU popup + if (have_imu) { + bool visible = gui.toggle_imu_visible(); + gui.set_status_text(visible ? "IMU popup shown" : "IMU popup hidden"); + } else { + gui.set_status_text("No IMU on this board"); + } play_beep(cardputer, 660.0f); } else if (event.special != espp::M5StackCardputer::SpecialKey::NONE) { gui.handle_special_key(event.special); @@ -122,10 +130,12 @@ extern "C" void app_main(void) { // the ADV has a BMI270 IMU on the internal I2C bus; initialize it if we're // on one (warn and continue otherwise - the original has no IMU) - bool have_imu = false; if (cardputer.variant() == espp::M5StackCardputer::Variant::ADV) { have_imu = cardputer.initialize_imu(); - if (!have_imu) { + if (have_imu) { + // show the IMU popup by default (fn+2 toggles it) + gui.toggle_imu_visible(); + } else { logger.warn("Could not initialize the IMU!"); } } From f0897bd25c2fa45655702bc1d2a2ef4fcdc99c81 Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Wed, 15 Jul 2026 08:57:57 -0500 Subject: [PATCH 13/13] use espp math fast_math.hpp piecewise_linear for soc interpolation --- components/m5stack-cardputer/CMakeLists.txt | 2 +- .../m5stack-cardputer/idf_component.yml | 1 + .../include/m5stack-cardputer.hpp | 21 ++++++++++--------- .../src/m5stack-cardputer.cpp | 17 +-------------- 4 files changed, 14 insertions(+), 27 deletions(-) diff --git a/components/m5stack-cardputer/CMakeLists.txt b/components/m5stack-cardputer/CMakeLists.txt index 72f7dd869c..90519bdd9d 100644 --- a/components/m5stack-cardputer/CMakeLists.txt +++ b/components/m5stack-cardputer/CMakeLists.txt @@ -2,6 +2,6 @@ idf_component_register( INCLUDE_DIRS "include" SRC_DIRS "src" - REQUIRES driver esp_adc esp_driver_i2s fatfs base_component adc bmi270 display display_drivers i2c interrupt led neopixel spi task + REQUIRES driver esp_adc esp_driver_i2s fatfs base_component adc bmi270 display display_drivers i2c interrupt led math neopixel spi task REQUIRED_IDF_TARGETS "esp32s3" ) diff --git a/components/m5stack-cardputer/idf_component.yml b/components/m5stack-cardputer/idf_component.yml index ff10527fcd..72b45d7cd3 100644 --- a/components/m5stack-cardputer/idf_component.yml +++ b/components/m5stack-cardputer/idf_component.yml @@ -25,6 +25,7 @@ dependencies: espp/i2c: '>=1.0' espp/interrupt: '>=1.0' espp/led: '>=1.0' + espp/math: '>=1.0' espp/neopixel: '>=1.0' espp/spi: '>=1.0' espp/task: '>=1.0' diff --git a/components/m5stack-cardputer/include/m5stack-cardputer.hpp b/components/m5stack-cardputer/include/m5stack-cardputer.hpp index b78230fab6..936f8cfb06 100644 --- a/components/m5stack-cardputer/include/m5stack-cardputer.hpp +++ b/components/m5stack-cardputer/include/m5stack-cardputer.hpp @@ -27,6 +27,7 @@ #include "base_component.hpp" #include "bmi270.hpp" +#include "fast_math.hpp" #include "i2c.hpp" #include "interrupt.hpp" #include "led.hpp" @@ -756,18 +757,18 @@ class M5StackCardputer : public BaseComponent { // Approximate resting discharge curve for a 1S lithium-ion cell, used to // estimate the state of charge from the battery voltage. Entries are - // {voltage (V), state of charge (%)}, in descending voltage order. + // {voltage (V), state of charge (%)}, in ascending voltage order static constexpr std::array, 10> BATTERY_SOC_CURVE{{ - {4.20f, 100.0f}, - {4.10f, 93.0f}, - {4.00f, 81.0f}, - {3.90f, 66.0f}, - {3.80f, 49.0f}, - {3.70f, 32.0f}, - {3.60f, 17.0f}, - {3.50f, 7.0f}, - {3.40f, 2.0f}, {3.30f, 0.0f}, + {3.40f, 2.0f}, + {3.50f, 7.0f}, + {3.60f, 17.0f}, + {3.70f, 32.0f}, + {3.80f, 49.0f}, + {3.90f, 66.0f}, + {4.00f, 81.0f}, + {4.10f, 93.0f}, + {4.20f, 100.0f}, }}; espp::AdcConfig battery_channel_{.unit = ADC_UNIT_1, .channel = ADC_CHANNEL_9, // GPIO 10 on ESP32-S3 diff --git a/components/m5stack-cardputer/src/m5stack-cardputer.cpp b/components/m5stack-cardputer/src/m5stack-cardputer.cpp index a0de07e8e1..9c9aa3e42e 100644 --- a/components/m5stack-cardputer/src/m5stack-cardputer.cpp +++ b/components/m5stack-cardputer/src/m5stack-cardputer.cpp @@ -134,20 +134,5 @@ float M5StackCardputer::battery_voltage() { float M5StackCardputer::battery_soc() { float voltage = battery_voltage(); const auto &curve = BATTERY_SOC_CURVE; - if (voltage >= curve.front().first) { - return curve.front().second; - } - if (voltage <= curve.back().first) { - return curve.back().second; - } - // linearly interpolate between the two surrounding curve points - for (size_t i = 1; i < curve.size(); i++) { - if (voltage >= curve[i].first) { - const auto &[v_high, soc_high] = curve[i - 1]; - const auto &[v_low, soc_low] = curve[i]; - float t = (voltage - v_low) / (v_high - v_low); - return soc_low + t * (soc_high - soc_low); - } - } - return 0.0f; + return piecewise_linear(curve, voltage); }