From dd5a0ed49a28243422b861b5007a593b0c445bbf Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Thu, 9 Jul 2026 23:13:40 -0500 Subject: [PATCH 1/2] feat(vmu-pro): Add BSP component for the 8BitMods VMU-Pro --- .github/workflows/build.yml | 2 + .github/workflows/upload_components.yml | 1 + components/vmu-pro/CMakeLists.txt | 7 + components/vmu-pro/Kconfig | 23 + components/vmu-pro/README.md | 31 ++ components/vmu-pro/example/CMakeLists.txt | 22 + components/vmu-pro/example/README.md | 41 ++ .../vmu-pro/example/main/CMakeLists.txt | 1 + components/vmu-pro/example/main/gui.cpp | 212 ++++++++ components/vmu-pro/example/main/gui.hpp | 127 +++++ .../vmu-pro/example/main/vmu_pro_example.cpp | 137 ++++++ components/vmu-pro/example/sdkconfig.defaults | 49 ++ components/vmu-pro/idf_component.yml | 28 ++ components/vmu-pro/include/vmu-pro.hpp | 453 ++++++++++++++++++ components/vmu-pro/src/audio.cpp | 129 +++++ components/vmu-pro/src/buttons.cpp | 37 ++ components/vmu-pro/src/display.cpp | 254 ++++++++++ components/vmu-pro/src/sdcard.cpp | 69 +++ components/vmu-pro/src/vmu-pro.cpp | 8 + doc/Doxyfile | 2 + doc/en/dev_boards/8bitmods/index.rst | 7 + doc/en/dev_boards/8bitmods/vmu_pro.rst | 37 ++ doc/en/dev_boards/8bitmods/vmu_pro_example.md | 2 + doc/en/dev_boards/index.rst | 1 + 24 files changed, 1680 insertions(+) create mode 100644 components/vmu-pro/CMakeLists.txt create mode 100644 components/vmu-pro/Kconfig create mode 100644 components/vmu-pro/README.md create mode 100644 components/vmu-pro/example/CMakeLists.txt create mode 100644 components/vmu-pro/example/README.md create mode 100644 components/vmu-pro/example/main/CMakeLists.txt create mode 100644 components/vmu-pro/example/main/gui.cpp create mode 100644 components/vmu-pro/example/main/gui.hpp create mode 100644 components/vmu-pro/example/main/vmu_pro_example.cpp create mode 100644 components/vmu-pro/example/sdkconfig.defaults create mode 100644 components/vmu-pro/idf_component.yml create mode 100644 components/vmu-pro/include/vmu-pro.hpp create mode 100644 components/vmu-pro/src/audio.cpp create mode 100644 components/vmu-pro/src/buttons.cpp create mode 100644 components/vmu-pro/src/display.cpp create mode 100644 components/vmu-pro/src/sdcard.cpp create mode 100644 components/vmu-pro/src/vmu-pro.cpp create mode 100644 doc/en/dev_boards/8bitmods/index.rst create mode 100644 doc/en/dev_boards/8bitmods/vmu_pro.rst create mode 100644 doc/en/dev_boards/8bitmods/vmu_pro_example.md diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1630cdc53..ae9e5661d 100755 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -233,6 +233,8 @@ jobs: target: esp32s3 - path: 'components/vl53l/example' target: esp32s3 + - path: 'components/vmu-pro/example' + target: esp32s3 - path: 'components/wifi/example' target: esp32 - path: 'components/wrover-kit/example' diff --git a/.github/workflows/upload_components.yml b/.github/workflows/upload_components.yml index c9e9c7a0b..65097783e 100755 --- a/.github/workflows/upload_components.yml +++ b/.github/workflows/upload_components.yml @@ -139,6 +139,7 @@ jobs: components/tt21100 components/utils components/vl53l + components/vmu-pro components/wifi components/wrover-kit components/ws-s3-geek diff --git a/components/vmu-pro/CMakeLists.txt b/components/vmu-pro/CMakeLists.txt new file mode 100644 index 000000000..d1ab26e76 --- /dev/null +++ b/components/vmu-pro/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_driver_i2s fatfs base_component display display_drivers interrupt led spi task + REQUIRED_IDF_TARGETS "esp32s3" + ) diff --git a/components/vmu-pro/Kconfig b/components/vmu-pro/Kconfig new file mode 100644 index 000000000..f893f1369 --- /dev/null +++ b/components/vmu-pro/Kconfig @@ -0,0 +1,23 @@ +menu "VMU Pro Configuration" + config VMU_PRO_INTERRUPT_STACK_SIZE + int "Interrupt stack size" + default 4096 + help + Size of the stack used for the interrupt handler. Shared by all of + the button callbacks. + + config VMU_PRO_INTERRUPT_PRIORITY + int "Interrupt task priority" + default 20 + range 0 25 + help + FreeRTOS priority of the GPIO interrupt handler task (0 = lowest). Raise it + if interrupt callbacks must run promptly relative to other tasks. + + config VMU_PRO_INTERRUPT_CORE_ID + int "Interrupt task core ID" + default -1 + range -1 1 + help + Core to pin the GPIO interrupt handler task to (-1 = not pinned to any core). +endmenu diff --git a/components/vmu-pro/README.md b/components/vmu-pro/README.md new file mode 100644 index 000000000..c314c3603 --- /dev/null +++ b/components/vmu-pro/README.md @@ -0,0 +1,31 @@ +# VMU Pro Board Support Package (BSP) Component + +[![Badge](https://components.espressif.com/components/espp/vmu-pro/badge.svg)](https://components.espressif.com/components/espp/vmu-pro) + +The [VMU Pro](https://8bitmods.com/vmupro-handheld-visual-memory-card-for-dreamcast-classic-white/) +by [8BitMods](https://8bitmods.com) is an ESP32-S3 based replacement for the +Sega Dreamcast Visual Memory Unit (VMU) which doubles as a standalone handheld +gaming device. It features: + +* 1.5" 240x240 IPS TFT color display +* D-pad, A, B, Mode, Power, and Bottom buttons +* Mono speaker (I2S amplifier) +* micro-SD card slot (mounted at `/sdcard`) +* Rechargeable battery with USB-C charging + +The `espp::VmuPro` component provides a singleton hardware abstraction for +initializing the display, buttons, audio, and uSD card subsystems. + +> **Warning:** The GPIO assignments in this component are UNVERIFIED +> placeholders. The VMU Pro's schematic is not publicly available, so the pin +> numbers in `include/vmu-pro.hpp` must be corrected against real hardware or +> vendor documentation before the component will function on the device. The +> peripheral set itself (display size and color format, button list, audio +> capabilities, uSD card) comes from the official +> [VMU Pro SDK](https://github.com/AppCakeLtd/vmupro-sdk) and is accurate. + +## Example + +The [example](./example) shows how to use the `espp::VmuPro` hardware +abstraction component to initialize the components on the VMU Pro and build a +simple LVGL UI (via a `Gui` class) driven by the buttons. diff --git a/components/vmu-pro/example/CMakeLists.txt b/components/vmu-pro/example/CMakeLists.txt new file mode 100644 index 000000000..465272dd0 --- /dev/null +++ b/components/vmu-pro/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 vmu-pro" + CACHE STRING + "List of components to include" + ) + +project(vmu_pro_example) + +set(CMAKE_CXX_STANDARD 20) diff --git a/components/vmu-pro/example/README.md b/components/vmu-pro/example/README.md new file mode 100644 index 000000000..2d8f01e51 --- /dev/null +++ b/components/vmu-pro/example/README.md @@ -0,0 +1,41 @@ +# VMU Pro Example + +This example shows how to use the `espp::VmuPro` hardware abstraction component +to initialize the components on the [8BitMods VMU +Pro](https://8bitmods.com/vmupro-handheld-visual-memory-card-for-dreamcast-classic-white/). + +It initializes the display, buttons, audio, and uSD card subsystems and builds +a simple LVGL UI using a `Gui` class: + +- The D-pad moves a cursor around the screen +- The A button draws a circle at the cursor (and plays a beep) +- The B button clears the circles (and plays a lower beep) +- The Mode button rotates the display through 0/90/180/270 degrees +- The Bottom button cycles the backlight brightness (25/50/75/100%) +- The Power button toggles the audio mute + +> **Warning:** The GPIO assignments in the `espp::VmuPro` component are +> UNVERIFIED placeholders (the VMU Pro's schematic is not publicly available), +> so this example will build but is not expected to function on real hardware +> until the pins in `components/vmu-pro/include/vmu-pro.hpp` are corrected. + +## How to use example + +### Hardware Required + +This example is designed to run on the 8BitMods VMU Pro. + +### 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 for full steps to configure and use ESP-IDF to build projects. diff --git a/components/vmu-pro/example/main/CMakeLists.txt b/components/vmu-pro/example/main/CMakeLists.txt new file mode 100644 index 000000000..ea4165e09 --- /dev/null +++ b/components/vmu-pro/example/main/CMakeLists.txt @@ -0,0 +1 @@ +idf_component_register(SRC_DIRS "." INCLUDE_DIRS ".") diff --git a/components/vmu-pro/example/main/gui.cpp b/components/vmu-pro/example/main/gui.cpp new file mode 100644 index 000000000..087a0bcb5 --- /dev/null +++ b/components/vmu-pro/example/main/gui.cpp @@ -0,0 +1,212 @@ +#include "gui.hpp" + +void Gui::init_ui() { + std::lock_guard lock(mutex_); + init_background(); + init_label(); + init_circle_layer(); + init_cursor(); + // start with the cursor in the center of the screen + auto &vmu = espp::VmuPro::get(); + cursor_x_ = vmu.rotated_display_width() / 2; + cursor_y_ = vmu.rotated_display_height() / 2; + update_cursor(); + // disable scrolling on the screen + lv_obj_set_scrollbar_mode(lv_screen_active(), LV_SCROLLBAR_MODE_OFF); + lv_obj_clear_flag(lv_screen_active(), LV_OBJ_FLAG_SCROLLABLE); +} + +void Gui::deinit_ui() { + std::lock_guard lock(mutex_); + lv_obj_clean(lv_screen_active()); +} + +void Gui::init_background() { + auto &vmu = espp::VmuPro::get(); + background_ = lv_obj_create(lv_screen_active()); + lv_obj_set_size(background_, vmu.lcd_width(), vmu.lcd_height()); + lv_obj_set_style_bg_color(background_, lv_color_make(0, 0, 0), 0); +} + +void Gui::init_label() { + label_ = lv_label_create(lv_screen_active()); + lv_label_set_text(label_, ""); + lv_obj_align(label_, LV_ALIGN_TOP_LEFT, 0, 0); + lv_obj_set_style_text_align(label_, LV_TEXT_ALIGN_LEFT, 0); +} + +void Gui::init_cursor() { + cursor_ = lv_obj_create(lv_screen_active()); + lv_obj_remove_style_all(cursor_); + lv_obj_set_size(cursor_, CURSOR_RADIUS * 2, CURSOR_RADIUS * 2); + lv_obj_set_style_radius(cursor_, LV_RADIUS_CIRCLE, 0); + lv_obj_set_style_border_width(cursor_, 2, 0); + lv_obj_set_style_border_color(cursor_, lv_palette_main(LV_PALETTE_YELLOW), 0); + lv_obj_set_style_bg_opa(cursor_, LV_OPA_TRANSP, 0); + lv_obj_clear_flag(cursor_, LV_OBJ_FLAG_CLICKABLE); + lv_obj_clear_flag(cursor_, LV_OBJ_FLAG_SCROLLABLE); +} + +void Gui::init_circle_layer() { + auto &vmu = espp::VmuPro::get(); + circle_layer_ = lv_obj_create(lv_screen_active()); + lv_obj_remove_style_all(circle_layer_); + lv_obj_set_size(circle_layer_, vmu.lcd_width(), vmu.lcd_height()); + lv_obj_align(circle_layer_, LV_ALIGN_CENTER, 0, 0); + lv_obj_clear_flag(circle_layer_, LV_OBJ_FLAG_CLICKABLE); + lv_obj_clear_flag(circle_layer_, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_bg_opa(circle_layer_, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(circle_layer_, 0, 0); + lv_obj_set_style_outline_width(circle_layer_, 0, 0); + lv_obj_set_style_shadow_width(circle_layer_, 0, 0); + lv_obj_add_event_cb(circle_layer_, draw_circle_layer, LV_EVENT_DRAW_MAIN, this); +} + +bool Gui::update(std::mutex &m, std::condition_variable &cv) { + { + std::lock_guard lock(mutex_); + lv_task_handler(); + } + std::unique_lock lock(m); + cv.wait_for(lock, std::chrono::milliseconds(16)); + return false; // don't stop the task +} + +void Gui::set_label_text(std::string_view text) { + std::lock_guard lock(mutex_); + lv_label_set_text(label_, std::string(text).c_str()); +} + +void Gui::move_cursor(int dx, int dy) { + std::lock_guard lock(mutex_); + auto &vmu = espp::VmuPro::get(); + int width = vmu.rotated_display_width(); + int height = vmu.rotated_display_height(); + cursor_x_ = std::clamp(cursor_x_ + dx, CURSOR_RADIUS, width - CURSOR_RADIUS); + cursor_y_ = std::clamp(cursor_y_ + dy, CURSOR_RADIUS, height - CURSOR_RADIUS); + update_cursor(); +} + +void Gui::update_cursor() { + lv_obj_set_pos(cursor_, cursor_x_ - CURSOR_RADIUS, cursor_y_ - CURSOR_RADIUS); + lv_obj_move_foreground(cursor_); +} + +void Gui::draw_at_cursor() { + std::lock_guard lock(mutex_); + draw_circle_impl(cursor_x_, cursor_y_, CIRCLE_RADIUS); +} + +void Gui::next_rotation() { + std::lock_guard lock(mutex_); + auto &vmu = espp::VmuPro::get(); + clear_circles_impl(); + auto rotation = lv_display_get_rotation(lv_display_get_default()); + rotation = static_cast((static_cast(rotation) + 1) % 4); + lv_display_set_rotation(lv_display_get_default(), rotation); + // update the size of the screen-filling objects + lv_obj_set_size(background_, vmu.rotated_display_width(), vmu.rotated_display_height()); + lv_obj_set_size(circle_layer_, vmu.rotated_display_width(), vmu.rotated_display_height()); + lv_obj_align(circle_layer_, LV_ALIGN_CENTER, 0, 0); + lv_obj_invalidate(circle_layer_); + // re-center the cursor within the (possibly differently-sized) screen + cursor_x_ = vmu.rotated_display_width() / 2; + cursor_y_ = vmu.rotated_display_height() / 2; + update_cursor(); +} + +void Gui::cycle_brightness() { + std::lock_guard lock(mutex_); + brightness_index_ = (brightness_index_ + 1) % BRIGHTNESS_LEVELS.size(); + float new_brightness = BRIGHTNESS_LEVELS[brightness_index_]; + espp::VmuPro::get().brightness(new_brightness); + logger_.info("Set brightness to {:.0f}%", new_brightness); +} + +void Gui::draw_circle_impl(int x, int y, int radius) { + lv_obj_move_foreground(circle_layer_); + lv_obj_move_foreground(cursor_); + Circle previous_circle = circles_[next_circle_index_]; + circles_[next_circle_index_] = {.x = x, .y = y, .radius = radius, .visible = true}; + next_circle_index_ = (next_circle_index_ + 1) % circles_.size(); + if (visible_circle_count_ < circles_.size()) { + visible_circle_count_++; + } + if (previous_circle.visible) { + invalidate_circle_area(previous_circle); + } + invalidate_circle_area(circles_[(next_circle_index_ + circles_.size() - 1) % circles_.size()]); +} + +void Gui::clear_circles() { + std::lock_guard lock(mutex_); + clear_circles_impl(); +} + +void Gui::clear_circles_impl() { + for (auto &circle : circles_) { + if (circle.visible) { + invalidate_circle_area(circle); + } + circle.visible = false; + } + next_circle_index_ = 0; + visible_circle_count_ = 0; +} + +void Gui::invalidate_circle_area(const Circle &circle) { + if (!circle_layer_ || circle.radius <= 0) { + return; + } + lv_area_t obj_coords; + lv_obj_get_coords(circle_layer_, &obj_coords); + lv_area_t coords = { + .x1 = static_cast(obj_coords.x1 + circle.x - circle.radius), + .y1 = static_cast(obj_coords.y1 + circle.y - circle.radius), + .x2 = static_cast(obj_coords.x1 + circle.x + circle.radius - 1), + .y2 = static_cast(obj_coords.y1 + circle.y + circle.radius - 1), + }; + lv_obj_invalidate_area(circle_layer_, &coords); +} + +void Gui::draw_circle_layer(lv_event_t *e) { + const auto *gui = static_cast(lv_event_get_user_data(e)); + if (!gui) { + return; + } + gui->draw_circles(e); +} + +void Gui::draw_circles(lv_event_t *e) const { + if (visible_circle_count_ == 0) { + return; + } + + auto *obj = static_cast(lv_event_get_current_target(e)); + auto *layer = lv_event_get_layer(e); + lv_area_t obj_coords; + lv_obj_get_coords(obj, &obj_coords); + + lv_draw_rect_dsc_t rect_dsc; + lv_draw_rect_dsc_init(&rect_dsc); + rect_dsc.base.layer = layer; + rect_dsc.radius = LV_RADIUS_CIRCLE; + rect_dsc.bg_opa = LV_OPA_70; + rect_dsc.bg_color = lv_color_make(0, 255, 255); + rect_dsc.border_width = 0; + rect_dsc.outline_width = 0; + rect_dsc.shadow_width = 0; + + for (const auto &circle : circles_) { + if (!circle.visible) { + continue; + } + lv_area_t coords = { + .x1 = static_cast(obj_coords.x1 + circle.x - circle.radius), + .y1 = static_cast(obj_coords.y1 + circle.y - circle.radius), + .x2 = static_cast(obj_coords.x1 + circle.x + circle.radius - 1), + .y2 = static_cast(obj_coords.y1 + circle.y + circle.radius - 1), + }; + lv_draw_rect(layer, &rect_dsc, &coords); + } +} diff --git a/components/vmu-pro/example/main/gui.hpp b/components/vmu-pro/example/main/gui.hpp new file mode 100644 index 000000000..d09994eb1 --- /dev/null +++ b/components/vmu-pro/example/main/gui.hpp @@ -0,0 +1,127 @@ +#pragma once + +#include +#include +#include +#include + +#include "vmu-pro.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 (button callbacks, etc.) can safely call them. +/// +/// Since the VMU Pro has no touch screen, the UI is driven entirely by the +/// buttons through the public methods below. For this example the Gui shows: +/// * A label with instructions and the most recent button event +/// * A cursor which is moved around the screen with the D-pad +/// * A custom-drawn layer of circles drawn at the cursor with the A button +class Gui { +public: + /// 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(); + } + + /// Set the text of the main label. Thread-safe. + /// @param text The text to display + void set_label_text(std::string_view text); + + /// Move the cursor by the given amount, clamping it to the screen bounds. + /// Thread-safe. + /// @param dx The change in x position, in pixels + /// @param dy The change in y position, in pixels + void move_cursor(int dx, int dy); + + /// Draw a circle at the current cursor position, replacing the oldest + /// circle if the maximum number are already visible. Thread-safe. + void draw_at_cursor(); + + /// Clear all circles from the screen. Thread-safe. + void clear_circles(); + + /// Rotate the display to the next of 0/90/180/270 degrees, resizing / + /// re-aligning the UI to match. Thread-safe. + void next_rotation(); + + /// Cycle the backlight brightness through 25/50/75/100%. Thread-safe. + void cycle_brightness(); + +protected: + static constexpr size_t MAX_CIRCLES = 100; + static constexpr int CURSOR_RADIUS = 8; + static constexpr int CIRCLE_RADIUS = 10; + static constexpr std::array BRIGHTNESS_LEVELS = {25.0f, 50.0f, 75.0f, 100.0f}; + + struct Circle { + int x{0}; + int y{0}; + int radius{0}; + bool visible{false}; + }; + + void init_ui(); + void deinit_ui(); + + // the individual pieces of the UI, called from init_ui() + void init_background(); + void init_label(); + void init_cursor(); + void init_circle_layer(); + + // re-position the cursor object to match cursor_x_/cursor_y_ + void update_cursor(); + + // the LVGL update task: calls lv_task_handler() under the mutex + bool update(std::mutex &m, std::condition_variable &cv); + + // custom drawing of the circle layer + static void draw_circle_layer(lv_event_t *e); + void draw_circles(lv_event_t *e) const; + void invalidate_circle_area(const Circle &circle); + + // unlocked implementations, called with the mutex held + void draw_circle_impl(int x, int y, int radius); + void clear_circles_impl(); + + // LVGL objects + lv_obj_t *background_{nullptr}; + lv_obj_t *label_{nullptr}; + lv_obj_t *cursor_{nullptr}; + lv_obj_t *circle_layer_{nullptr}; + + int cursor_x_{0}; + int cursor_y_{0}; + + size_t brightness_index_{2}; // start at 75% + + std::array circles_; + size_t next_circle_index_{0}; + size_t visible_circle_count_{0}; + + 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/vmu-pro/example/main/vmu_pro_example.cpp b/components/vmu-pro/example/main/vmu_pro_example.cpp new file mode 100644 index 000000000..2aa62eced --- /dev/null +++ b/components/vmu-pro/example/main/vmu_pro_example.cpp @@ -0,0 +1,137 @@ +#include +#include +#include +#include + +#include "vmu-pro.hpp" + +#include "gui.hpp" + +using namespace std::chrono_literals; + +static void play_beep(espp::VmuPro &vmu, float frequency_hz); + +extern "C" void app_main(void) { + espp::Logger logger({.tag = "VMU Pro Example", .level = espp::Logger::Verbosity::INFO}); + logger.info("Starting example!"); + + //! [vmu-pro example] + espp::VmuPro &vmu = espp::VmuPro::get(); + vmu.set_log_level(espp::Logger::Verbosity::INFO); + + // initialize the LCD + if (!vmu.initialize_lcd()) { + logger.error("Failed to initialize LCD!"); + return; + } + // set the pixel buffer to be 50 lines high + static constexpr size_t pixel_buffer_size = vmu.lcd_width() * 50; + // initialize the LVGL display + if (!vmu.initialize_display(pixel_buffer_size)) { + logger.error("Failed to initialize display!"); + return; + } + + // initialize the sound (mono speaker via I2S amplifier) + if (!vmu.initialize_sound()) { + logger.error("Failed to initialize sound!"); + return; + } + vmu.volume(50.0f); + + // initialize the uSD card; warn and continue if there is no card inserted + if (!vmu.initialize_sdcard({})) { + logger.warn("Failed to initialize uSD card, there may not be a uSD card inserted!"); + } + + // create the GUI: builds the UI (label, cursor, circle layer) and starts + // the task which updates LVGL. All of its public methods are thread-safe, + // so the button callback below can call them directly. + static Gui gui({.log_level = espp::Logger::Verbosity::INFO}); + static const std::string instructions = "D-pad: move cursor\nA: draw circle\nB: clear " + "circles\nMode: rotate display\nBottom: cycle " + "brightness\nPower: toggle mute"; + gui.set_label_text(instructions); + + // initialize the buttons; the callback runs in the interrupt task, and the + // Gui methods are thread-safe, so we can drive the UI directly from here + using Button = espp::VmuPro::Button; + static constexpr int cursor_step = 10; + auto button_callback = [&](Button button, bool pressed) { + logger.info("Button {}: {}", espp::VmuPro::button_name(button), + pressed ? "pressed" : "released"); + gui.set_label_text(fmt::format("{}\n\nButton {}: {}", instructions, + espp::VmuPro::button_name(button), + pressed ? "pressed" : "released")); + if (!pressed) { + return; + } + switch (button) { + case Button::DPAD_UP: + gui.move_cursor(0, -cursor_step); + break; + case Button::DPAD_DOWN: + gui.move_cursor(0, cursor_step); + break; + case Button::DPAD_LEFT: + gui.move_cursor(-cursor_step, 0); + break; + case Button::DPAD_RIGHT: + gui.move_cursor(cursor_step, 0); + break; + case Button::A: + gui.draw_at_cursor(); + play_beep(vmu, 880.0f); + break; + case Button::B: + gui.clear_circles(); + play_beep(vmu, 440.0f); + break; + case Button::MODE: + gui.next_rotation(); + break; + case Button::BOTTOM: + gui.cycle_brightness(); + break; + case Button::POWER: + vmu.mute(!vmu.is_muted()); + logger.info("Audio {}", vmu.is_muted() ? "muted" : "unmuted"); + break; + default: + break; + } + }; + if (!vmu.initialize_buttons(button_callback)) { + logger.error("Failed to initialize buttons!"); + return; + } + + // set the display brightness to be 75% + vmu.brightness(75.0f); + + // loop forever + while (true) { + std::this_thread::sleep_for(1s); + } + //! [vmu-pro example] +} + +// Synthesize a short sine-wave beep and queue it for playback. The audio +// subsystem expects 16-bit signed mono samples at the configured sample rate. +static void play_beep(espp::VmuPro &vmu, float frequency_hz) { + static constexpr float duration_s = 0.05f; + static constexpr float amplitude = 0.5f; + uint32_t sample_rate = vmu.audio_sample_rate(); + size_t num_samples = static_cast(duration_s * sample_rate); + std::vector samples(num_samples); + 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(amplitude * envelope * 32767.0f * + std::sin(2.0f * std::numbers::pi_v * frequency_hz * t)); + } + vmu.play_audio(reinterpret_cast(samples.data()), + samples.size() * sizeof(int16_t)); +} diff --git a/components/vmu-pro/example/sdkconfig.defaults b/components/vmu-pro/example/sdkconfig.defaults new file mode 100644 index 000000000..c02936efc --- /dev/null +++ b/components/vmu-pro/example/sdkconfig.defaults @@ -0,0 +1,49 @@ +CONFIG_IDF_TARGET="esp32s3" + +CONFIG_FREERTOS_HZ=1000 + +# set compiler optimization level to -O2 (compile for performance) +CONFIG_COMPILER_OPTIMIZATION_PERF=y + +# NOTE: the flash size / mode of the VMU Pro is not publicly documented; these +# are placeholder values which should be verified against real hardware. +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 diff --git a/components/vmu-pro/idf_component.yml b/components/vmu-pro/idf_component.yml new file mode 100644 index 000000000..ae88d2535 --- /dev/null +++ b/components/vmu-pro/idf_component.yml @@ -0,0 +1,28 @@ +## IDF Component Manager Manifest File +license: "MIT" +description: "8BitMods VMU Pro Board Support Package (BSP) component for ESPP" +url: "https://github.com/esp-cpp/espp/tree/main/components/vmu-pro" +repository: "git://github.com/esp-cpp/espp.git" +maintainers: + - William Emfinger +documentation: "https://esp-cpp.github.io/espp/dev_boards/8bitmods/vmu_pro.html" +examples: + - path: example +tags: + - cpp + - Component + - 8BitMods + - VmuPro + - BSP +dependencies: + idf: + version: '>=5.0' + espp/base_component: '>=1.0' + espp/display: '>=1.0' + espp/display_drivers: '>=1.0' + espp/interrupt: '>=1.0' + espp/led: '>=1.0' + espp/spi: '>=1.0' + espp/task: '>=1.0' +targets: + - esp32s3 diff --git a/components/vmu-pro/include/vmu-pro.hpp b/components/vmu-pro/include/vmu-pro.hpp new file mode 100644 index 000000000..c8a32ecee --- /dev/null +++ b/components/vmu-pro/include/vmu-pro.hpp @@ -0,0 +1,453 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +#include +#include + +#include "base_component.hpp" +#include "interrupt.hpp" +#include "led.hpp" +#include "spi.hpp" +#include "st7789.hpp" +#include "task.hpp" + +namespace espp { +/// The VmuPro class provides an interface to the 8BitMods VMU Pro, an +/// ESP32-S3 based replacement for the Sega Dreamcast Visual Memory Unit +/// (VMU) which doubles as a standalone handheld. +/// +/// The class provides access to the following features: +/// - Display (1.5" 240x240 IPS TFT) +/// - Buttons (D-pad, A, B, Mode, Power, Bottom) +/// - Audio (mono speaker via I2S amplifier) +/// - micro-SD (uSD) card +/// +/// The class is a singleton and can be accessed using the get() method. +/// +/// \warning The GPIO assignments in this BSP are UNVERIFIED placeholders. +/// The VMU Pro's schematic is not publicly available, so the pin +/// numbers below must be corrected against real hardware or vendor +/// documentation before use. The peripheral set (display size and +/// color format, button list, audio capabilities, uSD card) comes +/// from the official VMU Pro SDK and is accurate. +/// +/// \section vmu_pro_example Example +/// \snippet vmu_pro_example.cpp vmu-pro example +class VmuPro : public BaseComponent { +public: + /// The buttons on the VMU Pro + enum class Button : uint8_t { + DPAD_UP = 0, ///< D-pad up + DPAD_DOWN, ///< D-pad down + DPAD_LEFT, ///< D-pad left + DPAD_RIGHT, ///< D-pad right + A, ///< A button + B, ///< B button + MODE, ///< Mode button + POWER, ///< Power button + BOTTOM, ///< Bottom (sleep) button + }; + + /// Number of buttons on the VMU Pro + static constexpr size_t NUM_BUTTONS = 9; + + /// Alias for the button callback function. Called with the button that + /// changed state and whether it is now pressed. + using button_callback_t = std::function; + + /// Alias for the pixel type used by the display + using Pixel = lv_color16_t; + + /// Alias for the display driver + using DisplayDriver = espp::St7789; + + /// 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 VmuPro class + /// @return Reference to the singleton instance of the VmuPro class + static VmuPro &get() { + static VmuPro instance; + return instance; + } + + VmuPro(const VmuPro &) = delete; + VmuPro &operator=(const VmuPro &) = delete; + VmuPro(VmuPro &&) = delete; + VmuPro &operator=(VmuPro &&) = delete; + + /// Get a reference to the interrupts + /// \return A reference to the interrupts + espp::Interrupt &interrupts(); + + ///////////////////////////////////////////////////////////////////////////// + // Buttons + ///////////////////////////////////////////////////////////////////////////// + + /// Initialize the buttons + /// \param callback The callback function to call when a button changes + /// state + /// \return true if the buttons were successfully initialized, false + /// otherwise + bool initialize_buttons(const button_callback_t &callback = nullptr); + + /// Get the state of a button + /// \param button The button to get the state of + /// \return The button state (true = button pressed, false = button + /// released) + bool button_state(Button button) const; + + /// Get the name of a button + /// \param button The button to get the name of + /// \return The name of the button + static const char *button_name(Button button) { + switch (button) { + case Button::DPAD_UP: + return "Up"; + case Button::DPAD_DOWN: + return "Down"; + case Button::DPAD_LEFT: + return "Left"; + case Button::DPAD_RIGHT: + return "Right"; + case Button::A: + return "A"; + case Button::B: + return "B"; + case Button::MODE: + return "Mode"; + case Button::POWER: + return "Power"; + case Button::BOTTOM: + return "Bottom"; + default: + return "Unknown"; + } + } + + ///////////////////////////////////////////////////////////////////////////// + // 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 display width in pixels, according to the current orientation + /// \return The display width in pixels, according to the current orientation + size_t rotated_display_width() const; + + /// Get the display height in pixels, according to the current orientation + /// \return The display height in pixels, according to the current orientation + size_t rotated_display_height() const; + + /// 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 + ///////////////////////////////////////////////////////////////////////////// + + /// Initialize the sound subsystem (I2S amplifier driving the mono speaker) + /// \param default_audio_rate The default sample rate for the audio, in Hz + /// \param task_config The configuration for the audio task + /// \return true if the sound subsystem was successfully initialized, false + /// otherwise + bool + initialize_sound(uint32_t default_audio_rate = 44100, + const espp::Task::BaseConfig &task_config = { + .name = "audio", .stack_size_bytes = 4096, .priority = 19, .core_id = 1}); + + /// Enable or disable the audio amplifier + /// \param enable true to enable the amplifier, false to disable it + void enable_sound(bool enable); + + /// 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 VMU Pro has no hardware 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); + + ///////////////////////////////////////////////////////////////////////////// + // 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 + /// \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_; } + +protected: + VmuPro(); + 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); + + ////////////////////////////////////////////////////////////////////////// + // NOTE: the GPIO assignments below are UNVERIFIED placeholders! The VMU + // Pro's schematic is not publicly available; these values were chosen to + // be valid ESP32-S3 pins so the component compiles, and MUST be corrected + // against real hardware / vendor documentation. + ////////////////////////////////////////////////////////////////////////// + + // LCD (1.5" 240x240 IPS, ST7789-family controller) + static constexpr size_t lcd_width_ = 240; + static constexpr size_t lcd_height_ = 240; + 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 = 80 * 1000 * 1000; + static constexpr auto lcd_spi_num = SPI2_HOST; + static constexpr gpio_num_t lcd_cs_io = GPIO_NUM_10; // TODO: unverified + static constexpr gpio_num_t lcd_mosi_io = GPIO_NUM_11; // TODO: unverified + static constexpr gpio_num_t lcd_sclk_io = GPIO_NUM_12; // TODO: unverified + static constexpr gpio_num_t lcd_dc_io = GPIO_NUM_13; // TODO: unverified + static constexpr gpio_num_t lcd_reset_io = GPIO_NUM_14; // TODO: unverified + static constexpr gpio_num_t backlight_io = GPIO_NUM_15; // TODO: unverified + static constexpr int lcd_offset_x = 0; + static constexpr int lcd_offset_y = 0; + static constexpr bool backlight_value = true; + static constexpr bool reset_value = false; + // 240x240 IPS ST7789 panels typically require color inversion + static constexpr bool invert_colors = true; // TODO: unverified + // the VMU Pro SDK exposes big-endian (byte-swapped) RGB565 colors + static constexpr bool swap_color_order = true; // TODO: unverified + 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; + + // Buttons (all active low with internal pullups) + static constexpr gpio_num_t dpad_up_io = GPIO_NUM_1; // TODO: unverified + static constexpr gpio_num_t dpad_down_io = GPIO_NUM_2; // TODO: unverified + static constexpr gpio_num_t dpad_left_io = GPIO_NUM_3; // TODO: unverified + static constexpr gpio_num_t dpad_right_io = GPIO_NUM_4; // TODO: unverified + static constexpr gpio_num_t button_a_io = GPIO_NUM_5; // TODO: unverified + static constexpr gpio_num_t button_b_io = GPIO_NUM_6; // TODO: unverified + static constexpr gpio_num_t mode_io = GPIO_NUM_7; // TODO: unverified + static constexpr gpio_num_t power_io = GPIO_NUM_0; // TODO: unverified + static constexpr gpio_num_t bottom_io = GPIO_NUM_8; // TODO: unverified + + // Audio (mono speaker driven by an I2S amplifier) + static constexpr auto i2s_port = I2S_NUM_0; + static constexpr gpio_num_t i2s_bck_io = GPIO_NUM_16; // TODO: unverified + static constexpr gpio_num_t i2s_ws_io = GPIO_NUM_17; // TODO: unverified + static constexpr gpio_num_t i2s_do_io = GPIO_NUM_18; // TODO: unverified + static constexpr gpio_num_t sound_enable_pin = GPIO_NUM_21; // TODO: unverified + + 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; + } + + // uSD card (SDMMC, 4-bit) + static constexpr gpio_num_t sdcard_clk = GPIO_NUM_39; // TODO: unverified + static constexpr gpio_num_t sdcard_cmd = GPIO_NUM_38; // TODO: unverified + static constexpr gpio_num_t sdcard_d0 = GPIO_NUM_40; // TODO: unverified + static constexpr gpio_num_t sdcard_d1 = GPIO_NUM_41; // TODO: unverified + static constexpr gpio_num_t sdcard_d2 = GPIO_NUM_42; // TODO: unverified + static constexpr gpio_num_t sdcard_d3 = GPIO_NUM_47; // TODO: unverified + + // sdcard + sdmmc_card_t *sdcard_{nullptr}; + + // Interrupts. One PinConfig per button, in the same order as the Button + // enum so that button_state() can index by enum value. + espp::Interrupt::PinConfig make_button_pin_config(gpio_num_t gpio, Button button) { + return {.gpio_num = gpio, + .callback = + [this, button](const auto &event) { + if (button_callback_) { + button_callback_(button, event.active); + } + }, + .active_level = espp::Interrupt::ActiveLevel::LOW, + .interrupt_type = espp::Interrupt::Type::ANY_EDGE, + .pullup_enabled = true}; + } + + std::array button_interrupt_pins_{ + make_button_pin_config(dpad_up_io, Button::DPAD_UP), + make_button_pin_config(dpad_down_io, Button::DPAD_DOWN), + make_button_pin_config(dpad_left_io, Button::DPAD_LEFT), + make_button_pin_config(dpad_right_io, Button::DPAD_RIGHT), + make_button_pin_config(button_a_io, Button::A), + make_button_pin_config(button_b_io, Button::B), + make_button_pin_config(mode_io, Button::MODE), + make_button_pin_config(power_io, Button::POWER), + make_button_pin_config(bottom_io, Button::BOTTOM), + }; + + // we'll only add the interrupt pins if the initialize method is called + espp::Interrupt interrupts_{ + {.interrupts = {}, + .task_config = {.name = "vmu-pro interrupts", + .stack_size_bytes = CONFIG_VMU_PRO_INTERRUPT_STACK_SIZE, + .priority = CONFIG_VMU_PRO_INTERRUPT_PRIORITY, + .core_id = CONFIG_VMU_PRO_INTERRUPT_CORE_ID}}}; + + // buttons + std::atomic buttons_initialized_{false}; + button_callback_t button_callback_{nullptr}; + + // 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; +}; // class VmuPro +} // namespace espp diff --git a/components/vmu-pro/src/audio.cpp b/components/vmu-pro/src/audio.cpp new file mode 100644 index 000000000..b8876b224 --- /dev/null +++ b/components/vmu-pro/src/audio.cpp @@ -0,0 +1,129 @@ +#include "vmu-pro.hpp" + +using namespace espp; + +//////////////////////// +// Audio Functions // +//////////////////////// + +bool VmuPro::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 VmuPro::initialize_sound(uint32_t default_audio_rate, + const espp::Task::BaseConfig &task_config) { + if (sound_initialized_) { + logger_.warn("Sound already initialized"); + return true; + } + if (!initialize_i2s(default_audio_rate)) { + logger_.error("Could not initialize I2S driver"); + return false; + } + + // Configure the amplifier enable IO + gpio_set_direction(sound_enable_pin, GPIO_MODE_OUTPUT); + enable_sound(true); + + using namespace std::placeholders; + audio_task_ = espp::Task::make_unique({ + .callback = std::bind(&VmuPro::audio_task_callback, this, _1, _2, _3), + .task_config = task_config, + }); + + sound_initialized_ = true; + + return audio_task_->start(); +} + +void VmuPro::enable_sound(bool enable) { gpio_set_level(sound_enable_pin, enable); } + +bool VmuPro::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 VMU Pro has no hardware 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 VmuPro::mute(bool mute) { mute_ = mute; } + +bool VmuPro::is_muted() const { return mute_; } + +void VmuPro::volume(float volume) { volume_ = std::clamp(volume, 0.0f, 100.0f); } + +float VmuPro::volume() const { return volume_; } + +uint32_t VmuPro::audio_sample_rate() const { return audio_std_cfg.clk_cfg.sample_rate_hz; } + +size_t VmuPro::audio_buffer_size() const { return audio_tx_buffer.size(); } + +void VmuPro::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 VmuPro::play_audio(const std::vector &data) { play_audio(data.data(), data.size()); } + +void VmuPro::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/vmu-pro/src/buttons.cpp b/components/vmu-pro/src/buttons.cpp new file mode 100644 index 000000000..06fe8cc9e --- /dev/null +++ b/components/vmu-pro/src/buttons.cpp @@ -0,0 +1,37 @@ +#include "vmu-pro.hpp" + +using namespace espp; + +//////////////////////// +// Button Functions // +//////////////////////// + +bool VmuPro::initialize_buttons(const VmuPro::button_callback_t &callback) { + if (buttons_initialized_) { + logger_.warn("Buttons already initialized, not initializing again!"); + return false; + } + + logger_.info("Initializing buttons"); + + // save the callback + button_callback_ = callback; + + // configure the buttons + for (const auto &pin_config : button_interrupt_pins_) { + interrupts_.add_interrupt(pin_config); + } + buttons_initialized_ = true; + return true; +} + +bool VmuPro::button_state(VmuPro::Button button) const { + if (!buttons_initialized_) { + return false; + } + auto index = static_cast(button); + if (index >= button_interrupt_pins_.size()) { + return false; + } + return interrupts_.is_active(button_interrupt_pins_[index]); +} diff --git a/components/vmu-pro/src/display.cpp b/components/vmu-pro/src/display.cpp new file mode 100644 index 000000000..f35f74ac1 --- /dev/null +++ b/components/vmu-pro/src/display.cpp @@ -0,0 +1,254 @@ +#include "vmu-pro.hpp" + +#include + +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 VmuPro::initialize_lcd() { + if (lcd_ || backlight_) { + logger_.warn("LCD already initialized, not initializing again!"); + return false; + } + + logger_.info("Initializing LCD..."); + + // Initialize 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})); + 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 VmuPro::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> VmuPro::display() const { return display_; } + +void IRAM_ATTR VmuPro::lcd_wait_lines() { + if (lcd_) { + lcd_->wait(); + } +} + +void VmuPro::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 VmuPro::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); +} + +VmuPro::Pixel *VmuPro::vram0() const { + if (!display_) { + return nullptr; + } + return display_->vram0(); +} + +VmuPro::Pixel *VmuPro::vram1() const { + if (!display_) { + return nullptr; + } + return display_->vram1(); +} + +void VmuPro::brightness(float brightness) { + brightness = std::clamp(brightness, 0.0f, 100.0f); + if (backlight_) + backlight_->set_duty(backlight_channel_configs_[0].channel, brightness); +} + +float VmuPro::brightness() const { + if (backlight_) { + auto d = backlight_->get_duty(backlight_channel_configs_[0].channel); + if (d.has_value()) + return d.value(); + } + return 0.0f; +} + +size_t VmuPro::rotated_display_width() const { + auto *display = lv_display_get_default(); + auto rotation = display ? lv_display_get_rotation(display) : LV_DISPLAY_ROTATION_0; + switch (rotation) { + case LV_DISPLAY_ROTATION_90: + case LV_DISPLAY_ROTATION_270: + return lcd_height_; + case LV_DISPLAY_ROTATION_0: + case LV_DISPLAY_ROTATION_180: + default: + return lcd_width_; + } +} + +size_t VmuPro::rotated_display_height() const { + auto *display = lv_display_get_default(); + auto rotation = display ? lv_display_get_rotation(display) : LV_DISPLAY_ROTATION_0; + switch (rotation) { + case LV_DISPLAY_ROTATION_90: + case LV_DISPLAY_ROTATION_270: + return lcd_width_; + case LV_DISPLAY_ROTATION_0: + case LV_DISPLAY_ROTATION_180: + default: + return lcd_height_; + } +} diff --git a/components/vmu-pro/src/sdcard.cpp b/components/vmu-pro/src/sdcard.cpp new file mode 100644 index 000000000..e3dc7fbb8 --- /dev/null +++ b/components/vmu-pro/src/sdcard.cpp @@ -0,0 +1,69 @@ +#include "vmu-pro.hpp" + +#include + +using namespace espp; + +///////////////////////////////////////////////////////////////////////////// +// uSD Card +///////////////////////////////////////////////////////////////////////////// + +bool VmuPro::initialize_sdcard(const VmuPro::SdCardConfig &config) { + if (sdcard_) { + logger_.error("SD card already initialized!"); + return false; + } + + logger_.info("Initializing SD card"); + + esp_err_t ret; + // Options for mounting the filesystem. If format_if_mount_failed is set to + // true, SD card will be partitioned and formatted in case when mounting + // fails. + 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; + + // By default, SD card frequency is initialized to SDMMC_FREQ_DEFAULT (20MHz) + sdmmc_host_t host = SDMMC_HOST_DEFAULT(); + host.max_freq_khz = SDMMC_FREQ_HIGHSPEED; + + // This initializes the slot without card detect (CD) and write protect (WP) + // signals. Modify slot_config.gpio_cd and slot_config.gpio_wp if your board + // has these signals. + sdmmc_slot_config_t slot_config = SDMMC_SLOT_CONFIG_DEFAULT(); + slot_config.clk = sdcard_clk; + slot_config.cmd = sdcard_cmd; + slot_config.d0 = sdcard_d0; + slot_config.d1 = sdcard_d1; + slot_config.d2 = sdcard_d2; + slot_config.d3 = sdcard_d3; + slot_config.width = 4; + // Enable internal pullups on enabled pins. The internal pullups are strong + // enough for the SD card to work, however external pullups are still + // recommended. + slot_config.flags |= SDMMC_SLOT_FLAG_INTERNAL_PULLUP; + + logger_.debug("Mounting filesystem"); + ret = esp_vfs_fat_sdmmc_mount(mount_point, &host, &slot_config, &mount_config, &sdcard_); + + if (ret != ESP_OK) { + if (ret == ESP_FAIL) { + logger_.error("Failed to mount filesystem."); + } else { + logger_.error("Failed to initialize the card ({}). " + "Make sure SD card lines have pull-up resistors in place.", + esp_err_to_name(ret)); + } + return false; + } + + logger_.info("Filesystem mounted"); + + // Card has been initialized, print its properties + sdmmc_card_print_info(stdout, sdcard_); + + return true; +} diff --git a/components/vmu-pro/src/vmu-pro.cpp b/components/vmu-pro/src/vmu-pro.cpp new file mode 100644 index 000000000..bd4f853d2 --- /dev/null +++ b/components/vmu-pro/src/vmu-pro.cpp @@ -0,0 +1,8 @@ +#include "vmu-pro.hpp" + +using namespace espp; + +VmuPro::VmuPro() + : BaseComponent("VmuPro") {} + +espp::Interrupt &VmuPro::interrupts() { return interrupts_; } diff --git a/doc/Doxyfile b/doc/Doxyfile index a7e4a809f..74834a226 100755 --- a/doc/Doxyfile +++ b/doc/Doxyfile @@ -174,6 +174,7 @@ EXAMPLE_PATH = \ $(PROJECT_PATH)/components/tla2528/example/main/tla2528_example.cpp \ $(PROJECT_PATH)/components/tt21100/example/main/tt21100_example.cpp \ $(PROJECT_PATH)/components/vl53l/example/main/vl53l_example.cpp \ + $(PROJECT_PATH)/components/vmu-pro/example/main/vmu_pro_example.cpp \ $(PROJECT_PATH)/components/wifi/example/main/wifi_example.cpp \ $(PROJECT_PATH)/components/wrover-kit/example/main/wrover_kit_example.cpp \ $(PROJECT_PATH)/components/ws-s3-geek/example/main/ws_s3_geek_example.cpp \ @@ -387,6 +388,7 @@ INPUT = \ $(PROJECT_PATH)/components/tla2528/include/tla2528.hpp \ $(PROJECT_PATH)/components/tt21100/include/tt21100.hpp \ $(PROJECT_PATH)/components/vl53l/include/vl53l.hpp \ + $(PROJECT_PATH)/components/vmu-pro/include/vmu-pro.hpp \ $(PROJECT_PATH)/components/utils/include/bitmask_operators.hpp \ $(PROJECT_PATH)/components/wifi/include/wifi.hpp \ $(PROJECT_PATH)/components/wifi/include/wifi_base.hpp \ diff --git a/doc/en/dev_boards/8bitmods/index.rst b/doc/en/dev_boards/8bitmods/index.rst new file mode 100644 index 000000000..21ad70aa3 --- /dev/null +++ b/doc/en/dev_boards/8bitmods/index.rst @@ -0,0 +1,7 @@ +8BitMods Boards +*************** + +.. toctree:: + :maxdepth: 1 + + vmu_pro diff --git a/doc/en/dev_boards/8bitmods/vmu_pro.rst b/doc/en/dev_boards/8bitmods/vmu_pro.rst new file mode 100644 index 000000000..09d32b7e0 --- /dev/null +++ b/doc/en/dev_boards/8bitmods/vmu_pro.rst @@ -0,0 +1,37 @@ +8BitMods VMU Pro +**************** + +VMU Pro +------- + +The VMU Pro is an ESP32-S3 based replacement for the Sega Dreamcast Visual +Memory Unit (VMU) which doubles as a standalone handheld gaming device. It +features a 1.5" 240x240 IPS TFT color display, a D-pad and several buttons, a +mono speaker, a micro-SD card slot, and a rechargeable battery with USB-C +charging. + +See the `VMU Pro product page +`_ +for more information. + +The `espp::VmuPro` component provides a singleton hardware abstraction for +initializing the various subsystems. + +.. warning:: + + The GPIO assignments in this BSP are UNVERIFIED placeholders. The VMU Pro's + schematic is not publicly available, so the pin numbers in the component + must be corrected against real hardware or vendor documentation before use. + +.. ------------------------------ Example ------------------------------------- + +.. toctree:: + + vmu_pro_example.md + +.. ---------------------------- API Reference ---------------------------------- + +API Reference +------------- + +.. include-build-file:: inc/vmu-pro.inc diff --git a/doc/en/dev_boards/8bitmods/vmu_pro_example.md b/doc/en/dev_boards/8bitmods/vmu_pro_example.md new file mode 100644 index 000000000..15e9feb1c --- /dev/null +++ b/doc/en/dev_boards/8bitmods/vmu_pro_example.md @@ -0,0 +1,2 @@ +```{include} ../../components/vmu-pro/example/README.md +``` diff --git a/doc/en/dev_boards/index.rst b/doc/en/dev_boards/index.rst index e799d9748..6fa93889a 100644 --- a/doc/en/dev_boards/index.rst +++ b/doc/en/dev_boards/index.rst @@ -10,6 +10,7 @@ target a board without configuring each peripheral by hand. :maxdepth: 2 espressif/index + 8bitmods/index adafruit/index lilygo/index m5stack/index From c7a713786a174c6756d4db8deab66482cb7046cd Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Fri, 10 Jul 2026 09:06:49 -0500 Subject: [PATCH 2/2] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- doc/Doxyfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/Doxyfile b/doc/Doxyfile index 74834a226..bcb825842 100755 --- a/doc/Doxyfile +++ b/doc/Doxyfile @@ -387,9 +387,9 @@ INPUT = \ $(PROJECT_PATH)/components/touch/include/touch.hpp \ $(PROJECT_PATH)/components/tla2528/include/tla2528.hpp \ $(PROJECT_PATH)/components/tt21100/include/tt21100.hpp \ + $(PROJECT_PATH)/components/utils/include/bitmask_operators.hpp \ $(PROJECT_PATH)/components/vl53l/include/vl53l.hpp \ $(PROJECT_PATH)/components/vmu-pro/include/vmu-pro.hpp \ - $(PROJECT_PATH)/components/utils/include/bitmask_operators.hpp \ $(PROJECT_PATH)/components/wifi/include/wifi.hpp \ $(PROJECT_PATH)/components/wifi/include/wifi_base.hpp \ $(PROJECT_PATH)/components/wifi/include/wifi_ap.hpp \