diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 2b71a939f..d378de5da 100755 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -133,6 +133,8 @@ jobs: target: esp32p4 - path: 'components/esp32-p4-nano/example' target: esp32p4 + - path: 'components/esp32-p4-wifi6-dev-kit/example' + target: esp32p4 - path: 'components/esp32-p4-wifi6-poe-eth/example' target: esp32p4 - path: 'components/esp32-timer-cam/example' diff --git a/.github/workflows/upload_components.yml b/.github/workflows/upload_components.yml index 4ff15d6c9..ac7dc093a 100755 --- a/.github/workflows/upload_components.yml +++ b/.github/workflows/upload_components.yml @@ -29,8 +29,9 @@ jobs: # Note: ethernet is intentionally listed ahead of the esp32-p4-* board # BSPs (esp32-p4-eth / esp32-p4-function-ev-board / # esp32-p4-module-dev-kit / esp32-p4-nano / - # esp32-p4-wifi6-poe-eth), which depend on - # espp/ethernet, so it is uploaded to the registry first. + # esp32-p4-wifi6-dev-kit / esp32-p4-wifi6-poe-eth), + # which depend on espp/ethernet, so + # ethernet is uploaded to the registry ahead of them. # # Note: magnetic_encoder is intentionally listed ahead of as5600 and # mt6701, which depend on espp/magnetic_encoder, so it is @@ -84,6 +85,7 @@ jobs: components/esp32-p4-function-ev-board components/esp32-p4-module-dev-kit components/esp32-p4-nano + components/esp32-p4-wifi6-dev-kit components/esp32-p4-wifi6-poe-eth components/esp32-timer-cam components/event_manager diff --git a/components/esp32-p4-wifi6-dev-kit/CMakeLists.txt b/components/esp32-p4-wifi6-dev-kit/CMakeLists.txt new file mode 100644 index 000000000..49b2ba644 --- /dev/null +++ b/components/esp32-p4-wifi6-dev-kit/CMakeLists.txt @@ -0,0 +1,24 @@ +idf_component_register( + INCLUDE_DIRS "include" + SRC_DIRS "src" + REQUIRES + "base_component" + "ethernet" + "esp_netif" + "fatfs" + "esp_driver_sdmmc" + "sdmmc" + "codec" + "i2c" + "task" + "esp_driver_i2s" + "display" + "display_drivers" + "gt911" + "input_drivers" + "interrupt" + "esp_lcd" + "esp_video" + "esp_cam_sensor" + REQUIRED_IDF_TARGETS "esp32p4" + ) diff --git a/components/esp32-p4-wifi6-dev-kit/Kconfig.projbuild b/components/esp32-p4-wifi6-dev-kit/Kconfig.projbuild new file mode 100644 index 000000000..b96ae42de --- /dev/null +++ b/components/esp32-p4-wifi6-dev-kit/Kconfig.projbuild @@ -0,0 +1,84 @@ +menu "ESP32-P4-WIFI6-DEV-KIT Configuration" + + choice ESP32_P4_WIFI6_DEV_KIT_DISPLAY + prompt "MIPI-DSI display panel" + default ESP32_P4_WIFI6_DEV_KIT_DISPLAY_JD9365_10_1 + help + Select the MIPI-DSI panel attached to the Waveshare ESP32-P4-WIFI6-DEV-KIT. The BSP + does not auto-detect the panel (runtime DSI-ID probing hangs the boot + watchdog on some panels), so set this to the panel you have. + + config ESP32_P4_WIFI6_DEV_KIT_DISPLAY_JD9365_10_1 + bool "10.1\" 800x1280 (JD9365) - the panel Waveshare sells for this board" + help + The Waveshare 10.1-inch 800x1280 DSI panel, which uses a JD9365 + controller, driven by the espp::Jd9365 display driver (vendor init + over DSI/DBI + DPI panel). Reset is handled over DSI (no reset GPIO). + The backlight is driven by an on-board I2C controller (addr 0x45), so + there is no backlight GPIO; brightness() writes the controller. + + config ESP32_P4_WIFI6_DEV_KIT_DISPLAY_ILI9881C + bool "ILI9881C (10.1-inch, 800x1280)" + help + A 10.1-inch 800x1280 ILI9881C panel. EXPERIMENTAL on this board: the + panel Waveshare sells for this board is a JD9365, and the ILI9881C + init sequence wedges the DSI link on it (task-watchdog hang). Only + select this if you have actually attached an ILI9881C panel. Reset is + handled over DSI (no reset GPIO). The backlight is driven by an + on-board I2C controller, so there is no backlight GPIO. + + config ESP32_P4_WIFI6_DEV_KIT_DISPLAY_EK79007 + bool "EK79007 (7-inch, 1024x600)" + help + The 7-inch 1024x600 EK79007 panel. Reset is handled over DSI (no reset + GPIO). The backlight is driven by an on-board I2C controller, so there is + no backlight GPIO. + endchoice + + config ESP32_P4_WIFI6_DEV_KIT_INTERRUPT_STACK_SIZE + int "Interrupt task stack size (bytes)" + default 8192 + help + Size of the stack used for the GPIO interrupt handler task. + + config ESP32_P4_WIFI6_DEV_KIT_INTERRUPT_PRIORITY + int "Interrupt task priority" + default 0 + 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 ESP32_P4_WIFI6_DEV_KIT_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). + + config ESP32_P4_WIFI6_DEV_KIT_TOUCH_TASK_STACK_SIZE + int "Touch polling task stack size (bytes)" + default 8192 + help + Size of the stack used for the GT911 touch polling task. Used only in + polling mode (see ESP32_P4_WIFI6_DEV_KIT_TOUCH_INTERRUPT). + + config ESP32_P4_WIFI6_DEV_KIT_TOUCH_INTERRUPT + bool "Use interrupt-driven touch instead of polling" + default n + help + By default the ESP32-P4-WIFI6-DEV-KIT does not route the GT911 touch INT pin to the + ESP32-P4, so touch is polled in a task. If you wire the touch INT pin to a + free ESP32-P4 GPIO, enable this to read the GT911 from a GPIO interrupt + instead of polling (lower CPU usage and latency). The GPIO is set below and + can also be overridden at runtime via initialize_touch(). + + config ESP32_P4_WIFI6_DEV_KIT_TOUCH_INTERRUPT_GPIO + int "Touch interrupt GPIO" + depends on ESP32_P4_WIFI6_DEV_KIT_TOUCH_INTERRUPT + default 33 + help + GPIO that the GT911 touch INT pin is wired to. Must be a free GPIO not used + by another on-board peripheral. + +endmenu diff --git a/components/esp32-p4-wifi6-dev-kit/README.md b/components/esp32-p4-wifi6-dev-kit/README.md new file mode 100644 index 000000000..20382e9d6 --- /dev/null +++ b/components/esp32-p4-wifi6-dev-kit/README.md @@ -0,0 +1,58 @@ +# ESP32-P4-WIFI6-DEV-KIT Board Support Package (BSP) + +[![Badge](https://components.espressif.com/components/espp/esp32-p4-wifi6-dev-kit/badge.svg)](https://components.espressif.com/components/espp/esp32-p4-wifi6-dev-kit) + +Board Support Package for the [Waveshare ESP32-P4-WIFI6-DEV-KIT](https://www.waveshare.com/wiki/ESP32-P4-WIFI6-DEV-KIT) +board — an ESP32-P4NRW32 (32 MB stacked PSRAM; 16 MB NOR flash per the product +page, though the wiki self-contradicts on 16 vs 32 MB — unverified on +hardware) with an onboard +ESP32-C6 co-processor for Wi-Fi 6 / Bluetooth 5 (LE) over SDIO (ESP-Hosted). +The `espp::Esp32P4Wifi6DevKit` class is a singleton hardware abstraction that +brings up the board's peripherals with a small, uniform API. + +## Supported peripherals + + + + + + + + + + + +
PeripheralHardwareAPI
DisplayMIPI-DSI panel (JD9365 / ILI9881C 10.1" 800x1280 or EK79007 7" 1024x600, selected via Kconfig)initialize_lcd(), initialize_display()
TouchGT911 capacitive multi-touch (polled; INT/RST not routed)initialize_touch()
CameraMIPI-CSI (OV5647 by default) via esp_video / V4L2, RGB565 framesinitialize_camera(), stop_camera()
Audio outES8311 codec + NS4150B amplifier over I2Sinitialize_audio(), play_audio(), volume()
MicrophoneOnboard analog mic through the ES8311 ADC (full-duplex)initialize_microphone()
uSD / TF card4-bit SDMMC, powered by the on-chip LDOinitialize_sdcard()
Ethernet10/100 internal EMAC + IP101GRI RMII PHY (DHCP client/server); RJ45 with optional external PoE-module headerinitialize_ethernet()
Wi-Fi 6 / BT5Onboard ESP32-C6 over SDIO (ESP-Hosted)Not part of the BSP — use espressif/esp_hosted + espressif/esp_wifi_remote and the standard esp_wifi API (see below)
+ +The ES8311 codec, GT911 touch controller, and camera SCCB share a single +internal I2C bus (`internal_i2c()`, SDA=GPIO7 / SCL=GPIO8). The Ethernet +bring-up is delegated to the reusable `espp::Ethernet` component; this BSP +supplies the board-specific RMII pin mapping. + +The MIPI-CSI camera pipeline (esp_video + esp_cam_sensor) is fetched by the IDF +component manager. PSRAM must be enabled (the camera capture buffers and display +framebuffers live in PSRAM). See the example's `sdkconfig.defaults` for the +required Kconfig options (PSRAM, the MIPI-CSI video device, and the sensor). + +## Wi-Fi 6 / Bluetooth via the onboard ESP32-C6 + +The ESP32-P4 has no radio of its own; the board pairs it with an ESP32-C6 +running the ESP-Hosted slave firmware, connected over SDIO (CLK=GPIO18, +CMD=GPIO19, D0-D3=GPIO14-17, C6 reset=GPIO54 — the ESP-Hosted defaults for the +ESP32-P4). To use Wi-Fi from your application, add the two managed components: + +``` +idf.py add-dependency "espressif/esp_wifi_remote" +idf.py add-dependency "espressif/esp_hosted" +``` + +then select `esp32c6` as the slave target (menuconfig: `Component config -> +Wi-Fi Remote -> choose slave target`) and use the standard `esp_wifi` API +unchanged. The BSP intentionally does not wrap this — ESP-Hosted hooks in below +`esp_wifi`, so there is no board-specific code to add beyond the defaults. + +## Example + +The [example](./example) brings up the display + touch, audio (record / +playback), the camera (live feed), the uSD card, and Ethernet, and drives an +LVGL GUI with Status / Audio / Camera tabs. diff --git a/components/esp32-p4-wifi6-dev-kit/example/CMakeLists.txt b/components/esp32-p4-wifi6-dev-kit/example/CMakeLists.txt new file mode 100644 index 000000000..d8c002c43 --- /dev/null +++ b/components/esp32-p4-wifi6-dev-kit/example/CMakeLists.txt @@ -0,0 +1,49 @@ +cmake_minimum_required(VERSION 3.20) + +# Set the C++ standard before project()/project.cmake so the IDF build system +# picks it up when it configures the toolchain. +set(CMAKE_CXX_STANDARD 23) + +# The component manager is needed to pull the managed camera components +# (espressif/esp_video + espressif/esp_cam_sensor). They are declared as +# dependencies of the esp32-p4-wifi6-dev-kit BSP (components/esp32-p4-wifi6-dev-kit/idf_component.yml) +# and fetched transitively. Enable it explicitly so this example configures even +# if the build environment defaults it off (it is on by default in ESP-IDF). +set(ENV{IDF_COMPONENT_MANAGER} "1") +include($ENV{IDF_PATH}/tools/cmake/project.cmake) + +# Enumerate the specific espp component dirs (rather than globbing the whole +# components/ tree): with the component manager enabled, every discovered +# component's manifest is target-checked, and some espp components (e.g. byte90) +# declare targets that exclude esp32p4. Listing only what this BSP needs avoids +# those false incompatibilities. +set(EXTRA_COMPONENT_DIRS + "../" + "../../../components/base_component" + "../../../components/base_peripheral" + "../../../components/cli" + "../../../components/codec" + "../../../components/display" + "../../../components/display_drivers" + "../../../components/ethernet" + "../../../components/format" + "../../../components/gt911" + "../../../components/i2c" + "../../../components/input_drivers" + "../../../components/interrupt" + "../../../components/led" + "../../../components/logger" + "../../../components/lvgl" + "../../../components/spi" + "../../../components/task" + "../../../components/touch" +) + +set( + COMPONENTS + "main esptool_py esp32-p4-wifi6-dev-kit display lvgl" + CACHE STRING + "List of components to include" + ) + +project(esp32_p4_wifi6_dev_kit_example) diff --git a/components/esp32-p4-wifi6-dev-kit/example/README.md b/components/esp32-p4-wifi6-dev-kit/example/README.md new file mode 100644 index 000000000..741253df2 --- /dev/null +++ b/components/esp32-p4-wifi6-dev-kit/example/README.md @@ -0,0 +1,63 @@ +# ESP32-P4-WIFI6-DEV-KIT Example + +Brings up the Waveshare ESP32-P4-WIFI6-DEV-KIT board through the `espp::Esp32P4Wifi6DevKit` BSP +and drives an LVGL GUI. It exercises the display, touch, audio, microphone, +camera, uSD card and Ethernet: + +- **Display + touch (MIPI-DSI + GT911):** a tabview GUI. On the **Status** tab, + touch the screen to draw circles and play a click; it also shows live + subsystem state (panel, touch, SD, Ethernet, camera size, memory / uptime). + The rotate / clear buttons rotate the display and clear the drawing. +- **Audio (ES8311 + NS4150B):** the **Audio** tab has record / play buttons and + speaker / microphone volume controls. Recording captures from the onboard + microphone (full-duplex through the codec) into a PSRAM buffer; play streams it + back to the speaker. +- **Camera (MIPI-CSI, OV5647):** the **Camera** tab shows the live RGB565 feed. + Each frame from the BSP capture task is copied into an LVGL canvas. +- **uSD / TF card (4-bit SDMMC):** mounted at boot (if a card is present); its + size is logged and shown on the Status tab. +- **Ethernet (internal EMAC + IP101GRI RMII PHY):** started as a DHCP client; the + acquired IP is logged and shown on the Status tab. + +Wi-Fi 6 (via the onboard ESP32-C6) is not exercised by this example — see +below for how to enable it in your own project. + +## Wi-Fi 6 via the onboard ESP32-C6 (ESP-Hosted) + +The ESP32-P4 has no radio; the board's ESP32-C6 (running the ESP-Hosted slave +firmware it ships with) provides Wi-Fi 6 / BT5 over SDIO (CLK=GPIO18, +CMD=GPIO19, D0-D3=GPIO14-17, C6 reset=GPIO54 — the ESP-Hosted defaults for the +ESP32-P4, so no pin configuration is needed). To add Wi-Fi to a project using +this BSP: + +``` +idf.py add-dependency "espressif/esp_wifi_remote" +idf.py add-dependency "espressif/esp_hosted" +``` + +then in `menuconfig` set `Component config -> Wi-Fi Remote -> choose slave +target` to `esp32c6`, and use the standard `esp_wifi` / `esp_netif` APIs +unchanged (e.g. ESP-IDF's `wifi/getting_started/station` example works as-is). +Note the C6 is 2.4 GHz-only. To (re)flash the C6's ESP-Hosted slave firmware, +see the [Waveshare wiki FAQ](https://www.waveshare.com/wiki/ESP32-P4-WIFI6-DEV-KIT) +(hold C6_IO9 low at power-on and flash via the C6 UART pads). + +## How the camera feed reaches the GUI + +`initialize_camera()` runs a capture task in the BSP that hands each RGB565 frame +to a callback. The example forwards it to the thread-safe +`Gui::set_camera_frame(data, w, h)`, which copies the frame into a PSRAM canvas +buffer (allocated lazily on the first frame / a size change) and invalidates the +LVGL canvas. The GUI's LVGL task then renders it on the Camera tab. + +## Build and flash + +``` +idf.py set-target esp32p4 +idf.py build flash monitor +``` + +The example uses a custom 16 MB flash partition table (`partitions.csv`) because +the display + LVGL + camera + audio application no longer fits the default 1 MB +app partition. PSRAM and the MIPI-CSI camera pipeline are enabled via +`sdkconfig.defaults`. diff --git a/components/esp32-p4-wifi6-dev-kit/example/main/CMakeLists.txt b/components/esp32-p4-wifi6-dev-kit/example/main/CMakeLists.txt new file mode 100644 index 000000000..c781ab9d8 --- /dev/null +++ b/components/esp32-p4-wifi6-dev-kit/example/main/CMakeLists.txt @@ -0,0 +1,5 @@ +idf_component_register( + SRC_DIRS "." + INCLUDE_DIRS "." + EMBED_FILES click.wav + ) diff --git a/components/esp32-p4-wifi6-dev-kit/example/main/click.wav b/components/esp32-p4-wifi6-dev-kit/example/main/click.wav new file mode 100644 index 000000000..2183344a2 Binary files /dev/null and b/components/esp32-p4-wifi6-dev-kit/example/main/click.wav differ diff --git a/components/esp32-p4-wifi6-dev-kit/example/main/esp32_p4_wifi6_dev_kit_example.cpp b/components/esp32-p4-wifi6-dev-kit/example/main/esp32_p4_wifi6_dev_kit_example.cpp new file mode 100644 index 000000000..a9ef7cda9 --- /dev/null +++ b/components/esp32-p4-wifi6-dev-kit/example/main/esp32_p4_wifi6_dev_kit_example.cpp @@ -0,0 +1,486 @@ +/** + * @file esp32_p4_wifi6_dev_kit_example.cpp + * @brief Waveshare ESP32-P4-WIFI6-DEV-KIT BSP example + * + * Demonstrates the BSP: MIPI-DSI display + GT911 touch (draw circles and play a + * click sound wherever you touch), microSD, audio (ES8311) record / playback, + * the MIPI-CSI camera (live feed on the Camera tab), and Ethernet (IP101GRI) + * as a DHCP client. Shows a live on-screen status read-out (panel, touch, SD, + * Ethernet, and system memory/uptime). + */ + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "format.hpp" + +#include +#include +#include + +#include "esp32-p4-wifi6-dev-kit.hpp" +#include "logger.hpp" + +#include "gui.hpp" + +using namespace std::chrono_literals; +using Board = espp::Esp32P4Wifi6DevKit; + +static std::vector audio_bytes; +static bool load_audio(size_t &out_size, size_t &out_sample_rate); + +// Audio recording state (written by the microphone callback, read/controlled +// from the GUI button callbacks and main loop). The recorded data is 16-bit +// mono at the audio sample rate. +static constexpr size_t MAX_RECORDING_SECONDS = 30; // when PSRAM is available +static constexpr size_t FALLBACK_RECORDING_SECONDS = 2; // internal RAM fallback +static uint8_t *recording_buffer = nullptr; +static size_t recording_capacity = 0; +static std::atomic recording{false}; +static std::atomic recording_len{0}; +static std::atomic playing{false}; +// Guards recording_buffer writes against the state flips: the microphone +// callback holds this mutex while it checks `recording` and copies into the +// buffer, and the GUI button callbacks flip `recording` under the same mutex. +// The atomic flag alone is not enough - a callback that already observed +// `recording == true` could still be mid-memcpy when the GUI starts playback / +// resets recording_len. Taking the mutex to flip the state waits for (drains) +// any in-flight callback, so after the flip the buffer is stable. +static std::mutex recording_mutex; + +extern "C" void app_main(void) { + espp::Logger logger( + {.tag = "ESP32-P4-WIFI6-DEV-KIT Example", .level = espp::Logger::Verbosity::INFO}); + logger.info("Starting example!"); + + //! [esp32 p4 wifi6 dev kit example] + auto &board = Board::get(); + board.set_log_level(espp::Logger::Verbosity::INFO); + logger.info("Display panel: {}", board.get_display_controller_name()); + + // Probe the internal I2C bus (shared by the ES8311 codec, the GT911 touch + // controller, and the camera SCCB). + auto &i2c = board.internal_i2c(); + std::vector found; + for (uint8_t addr = 1; addr < 128; addr++) { + if (i2c.probe_device(addr)) { + found.push_back(addr); + } + } + logger.info("Found {} I2C device(s)", found.size()); + + // Display (MIPI-DSI + configured panel driver) + if (!board.initialize_lcd()) { + logger.error("Failed to initialize LCD!"); + return; + } + size_t pixel_buffer_size = board.display_width() * 50; + if (!board.initialize_display(pixel_buffer_size)) { + logger.error("Failed to initialize display!"); + return; + } + + // Build the GUI: a tabview with a Status tab (live subsystem state + rotate / + // clear buttons; touch to draw circles), an Audio tab (record / play + + // volume), and a Camera tab (live MIPI-CSI feed). All of its public methods + // are thread-safe, so the touch, status and camera tasks below call them + // directly. + static Gui gui({.log_level = espp::Logger::Verbosity::INFO}); + + // On-screen status state. These are filled in as each subsystem initializes + // below, and rendered immediately by the status task, so the display shows SD + // / Ethernet coming online live instead of staying blank until the whole + // bring-up finishes. + static std::atomic touch_x{0}, touch_y{0}, touch_n{0}; + static std::atomic sd_card_mounted{false}; + static std::atomic sd_card_size_mb{0}; + static int64_t status_start_us = esp_timer_get_time(); + + // Status updater: starts now (right after the display is up) and refreshes the + // on-screen status ~10x/s. Ethernet state is read live from the board; SD + // state is published into the atomics above as that subsystem comes up. + espp::Task status_task(espp::Task::Config{ + .callback = [&board](std::mutex &m, std::condition_variable &cv, + bool &task_notified) -> bool { + const size_t free_internal = heap_caps_get_free_size(MALLOC_CAP_INTERNAL) / 1024; + const size_t free_psram = heap_caps_get_free_size(MALLOC_CAP_SPIRAM) / 1024; + const int uptime_s = static_cast((esp_timer_get_time() - status_start_us) / 1'000'000); + std::string eth_text = "(no link)"; + if (board.is_ethernet_connected()) { + auto ip = board.ethernet_ip(); + eth_text = std::to_string(esp_ip4_addr1_16(&ip)) + "." + + std::to_string(esp_ip4_addr2_16(&ip)) + "." + + std::to_string(esp_ip4_addr3_16(&ip)) + "." + + std::to_string(esp_ip4_addr4_16(&ip)); + } + // Format into a fixed stack buffer (no per-cycle heap churn from many + // std::string temporaries at 10 Hz) and skip the LVGL update when the + // text has not changed since the last cycle. + char status[320]; + std::snprintf( + status, sizeof(status), + "Panel: %s (%ux%u)\n" + "Touch: %d pts (%d, %d)\n" + "SD card: %s\n" + "Ethernet: %s\n" + "Camera: %ux%u\n" + "System: %u KB int, %u KB psram free, up %u s", + board.get_display_controller_name(), static_cast(board.display_width()), + static_cast(board.display_height()), touch_n.load(), touch_x.load(), + touch_y.load(), + sd_card_mounted ? (std::to_string(sd_card_size_mb.load()) + " MB").c_str() : "none", + eth_text.c_str(), static_cast(board.camera_width()), + static_cast(board.camera_height()), static_cast(free_internal), + static_cast(free_psram), static_cast(uptime_s)); + static std::string last_status; + if (last_status != status) { + last_status = status; + gui.set_status_text(status); + } + std::unique_lock lock(m); + // Wait with the notified flag as the predicate so a spurious + // condition-variable wake does not stop the status task; per the Task + // contract the flag is checked and cleared under m. A true predicate + // means Task::stop() was requested, so return true so stop() joins + // promptly instead of waiting out the refresh interval. + if (cv.wait_for(lock, 100ms, [&task_notified] { return task_notified; })) { + task_notified = false; + return true; // stop the task + } + return false; // timed out: refresh again + }, + .task_config = {.name = "p4-wifi6 status", .stack_size_bytes = 6144}}); + status_task.start(); + + // Touch: draw a circle wherever the screen is touched, and play a click on + // each new touch-down. play_audio() is non-blocking, and the click is gated to + // the touch-down edge so it doesn't retrigger every poll while held/dragging. + static constexpr int kCircleRadius = 10; + // Construct the touch input (TouchpadInput's ctor calls lv_indev_create/ + // lv_indev_set_*) under the GUI's LVGL lock: the Gui has already started its + // update task, and with LV_USE_OS == LV_OS_NONE these mutations would + // otherwise race lv_task_handler() in that task. + gui.with_lvgl_locked([&] { + board.initialize_touch([&](const auto &data) { + auto td = board.touchpad_convert(data); + static Board::TouchpadData prev_td = {}; + touch_n = td.num_touch_points; + touch_x = td.x; + touch_y = td.y; + if (td.num_touch_points > 0) { + const bool new_touch = (prev_td != td); + const bool touch_down_edge = (prev_td.num_touch_points == 0); + // Touch feedback (click + circle) only applies on the draw/status page; + // touches on the other tabs (buttons, sliders) stay silent. + if (gui.draw_page_active()) { + // Click feedback: instant on the touch-DOWN edge, and retriggered while + // dragging - each retrigger restarts (clips) the click so drawing gives + // a stream of overlapping-feel clicks. The retrigger interval keeps a + // fast drag from restarting the click every poll (16 ms), which would + // reduce it to a buzz of its first few milliseconds. + static constexpr auto kClickRetriggerInterval = std::chrono::milliseconds(100); + static auto last_click_time = std::chrono::steady_clock::time_point{}; + const auto now = std::chrono::steady_clock::now(); + const bool click_due = + touch_down_edge || (now - last_click_time >= kClickRetriggerInterval); + // No clicks while the recorded playback is streaming: the click path + // restarts the shared audio stream with clear_audio(), which would + // discard queued playback bytes the main loop has already accounted + // for in play_offset (skipping audio) and interleave two producers + // into one stream. (playing is a best-effort atomic check; this is + // feedback audio, not a hard mutual exclusion.) + if (new_touch && click_due && !playing && !audio_bytes.empty()) { + board.clear_audio(); // drop any queued tail (restart) + board.play_audio(audio_bytes); // non-blocking + last_click_time = now; + } + if (new_touch) { + gui.draw_circle(td.x, td.y, kCircleRadius); + } + } + } + prev_td = td; + }); + }); // with_lvgl_locked + + // microSD (optional — only present if a card is inserted) + bool sd_ok = board.initialize_sdcard({.format_if_mount_failed = false}); + uint32_t sd_size_mb = 0, sd_free_mb = 0; + if (sd_ok) { + board.get_sd_card_info(&sd_size_mb, &sd_free_mb); + logger.info("SD card: {} MB total, {} MB free", sd_size_mb, sd_free_mb); + } else { + logger.warn("No SD card mounted"); + } + sd_card_mounted = sd_ok; + sd_card_size_mb = sd_size_mb; // published to the status task + + // Audio (ES8311) — load the embedded click sound first so we can initialize + // the codec directly at the clip's sample rate (changing the sample rate after + // the audio task is running is racy, so we avoid it here). + size_t wav_size = 0, wav_sample_rate = 0; + bool have_audio = load_audio(wav_size, wav_sample_rate); + uint32_t audio_rate = have_audio ? static_cast(wav_sample_rate) : 48000; + if (board.initialize_audio(audio_rate)) { + board.mute(false); + board.volume(60.0f); + if (have_audio) { + logger.info("Loaded {} bytes of click audio @ {} Hz", wav_size, wav_sample_rate); + } + + // Microphone: the ES8311 is full duplex, so the onboard microphone records + // at the speaker's sample rate. Buffer the recorded mono samples and + // auto-stop when the buffer is full (the main loop notices and updates the + // GUI). + auto mic_callback = [](const uint8_t *data, size_t num_bytes) { + // hold recording_mutex across the check + copy so a GUI state flip + // (which takes the same mutex) can never race an in-flight copy + std::lock_guard lock(recording_mutex); + if (!recording) { + return; + } + size_t offset = recording_len; + size_t to_copy = std::min(num_bytes, recording_capacity - offset); + if (to_copy > 0) { + memcpy(recording_buffer + offset, data, to_copy); + recording_len = offset + to_copy; + } + if (recording_len >= recording_capacity) { + recording = false; + } + }; + if (board.initialize_microphone(mic_callback)) { + // allocate the recording buffer (16-bit mono at the current sample rate): + // prefer PSRAM, fall back to a couple of seconds in internal RAM + size_t bytes_per_second = board.audio_sample_rate() * sizeof(int16_t); + recording_capacity = MAX_RECORDING_SECONDS * bytes_per_second; + recording_buffer = static_cast( + heap_caps_malloc(recording_capacity, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT)); + if (recording_buffer == nullptr) { + recording_capacity = FALLBACK_RECORDING_SECONDS * bytes_per_second; + recording_buffer = + static_cast(heap_caps_malloc(recording_capacity, MALLOC_CAP_8BIT)); + } + if (recording_buffer == nullptr) { + logger.warn("Could not allocate a recording buffer; recording disabled"); + gui.set_audio_status("No recording buffer"); + recording_capacity = 0; + } else { + logger.info("Recording buffer: {} KB ({} s at {} Hz mono)", recording_capacity / 1024, + recording_capacity / bytes_per_second, board.audio_sample_rate()); + } + } else { + logger.warn("Could not initialize the microphone!"); + gui.set_audio_status("Mic unavailable (see log)"); + } + } + + // The record button toggles recording; the play button toggles playback of + // the recording (streamed to the speaker by the main loop). + gui.set_record_callback([&]() { + if (recording_capacity == 0) { + logger.warn("Recording unavailable (no microphone / no buffer)"); + gui.set_audio_status("Mic unavailable (see log)"); + return; + } + if (recording) { + // flip under recording_mutex so any in-flight microphone callback has + // finished writing before anyone treats the buffer as stable + std::lock_guard lock(recording_mutex); + recording = false; // the main loop notices and logs the summary + } else { + playing = false; + gui.set_play_active(false); + // Stop the sound, not just the producer: samples already queued in the + // BSP stream would keep playing into the new recording, and the ES8311 + // is full duplex, so the microphone can pick them up. + board.clear_audio(); + { + // reset under recording_mutex: a stale in-flight microphone callback + // must not append at the old offset after the reset + std::lock_guard lock(recording_mutex); + recording_len = 0; + recording = true; + } + gui.set_record_active(true); + gui.set_audio_status("Recording..."); + } + }); + gui.set_play_callback([&]() { + if (playing) { + playing = false; + gui.set_play_active(false); + // Also drop the already-queued tail so the sound stops now rather than + // when the stream drains (the UI says "stopped", so make it true). + board.clear_audio(); + gui.set_audio_status("Playback stopped"); + } else if (recording_len > 0) { + { + // stop recording under recording_mutex so an in-flight microphone + // callback finishes writing before playback starts reading the buffer + std::lock_guard lock(recording_mutex); + recording = false; + } + playing = true; + gui.set_play_active(true); + gui.set_audio_status("Playing..."); + } else { + logger.info("Nothing recorded yet; press the record button first"); + gui.set_audio_status("Nothing recorded yet"); + } + }); + + // Ethernet (IP101GRI) — DHCP client; the callback fires once an IP is acquired + board.initialize_ethernet({ + .on_link_up = [&]() { logger.info("Ethernet link up"); }, + .on_link_down = [&]() { logger.warn("Ethernet link down"); }, + .on_got_ip = + [&](esp_ip4_addr_t ip) { + char buf[16] = {0}; + esp_ip4addr_ntoa(&ip, buf, sizeof(buf)); + logger.info("Ethernet IP: {}", buf); + }, + }); + + // Camera (MIPI-CSI) — stream each RGB565 frame to the Camera tab. The BSP runs + // a capture task that hands each frame to this callback; forward it to the + // thread-safe GUI. Non-fatal: the rest of the example still runs if the camera + // is unavailable. + logger.info("Initializing camera..."); + if (!board.initialize_camera([&](const uint8_t *data, int w, int h, size_t len) { + gui.set_camera_frame(data, w, h, len); + })) { + logger.warn("Failed to initialize camera; the Camera tab will stay blank"); + } + //! [esp32 p4 wifi6 dev kit example] + + // Main loop: stream any active playback to the speaker in chunks and notice + // when a recording stops (either button press or the buffer filling up). + size_t play_offset = 0; + bool was_recording = false; + bool play_prev = false; + std::chrono::steady_clock::time_point play_start{}; + while (true) { + if (playing) { + size_t len = recording_len; + if (!play_prev) { + play_start = std::chrono::steady_clock::now(); // playback just started + } + if (play_offset < len) { + play_offset += board.play_audio(recording_buffer + play_offset, + std::min(len - play_offset, 16384)); + } else { + // All bytes are QUEUED, but play_audio() is asynchronous - the stream + // buffer still holds several periods. Only declare completion once the + // full clip's worth of wall-clock time has elapsed, so the buffered + // tail is actually played out instead of being cut off / re-triggered. + const float clip_seconds = + static_cast(len) / (board.audio_sample_rate() * sizeof(int16_t)); + const float elapsed = + std::chrono::duration(std::chrono::steady_clock::now() - play_start).count(); + if (elapsed >= clip_seconds) { + playing = false; + play_offset = 0; + gui.set_play_active(false); + gui.set_audio_status("Playback done"); + logger.info("Playback done"); + } + } + } else { + play_offset = 0; + } + play_prev = playing; + // notice when the recording stopped (button press or buffer full) + bool now_recording = recording; + if (was_recording && !now_recording) { + gui.set_record_active(false); + gui.set_audio_status(fmt::format("Recorded {:.1f}s ({} plays)", + static_cast(recording_len) / + (board.audio_sample_rate() * sizeof(int16_t)), + LV_SYMBOL_PLAY)); + logger.info("Recorded {} bytes", recording_len.load()); + } + was_recording = now_recording; + + std::this_thread::sleep_for(20ms); + } +} + +////////////////////////////////////////////////////////////////////////////// +// Load the embedded click.wav (stripping the 44-byte WAV header) and report its +// size and sample rate. +////////////////////////////////////////////////////////////////////////////// +static bool load_audio(size_t &out_size, size_t &out_sample_rate) { + // Cache the parsed sample rate alongside audio_bytes so the cached path can + // report it too (both outputs are always set when returning true). + static size_t cached_sample_rate = 0; + if (!audio_bytes.empty()) { + out_size = audio_bytes.size(); + out_sample_rate = cached_sample_rate; + return true; + } + extern const uint8_t click_wav_start[] asm("_binary_click_wav_start"); + extern const uint8_t click_wav_end[] asm("_binary_click_wav_end"); + audio_bytes = std::vector(click_wav_start, click_wav_end); + if (audio_bytes.size() < 44) { + audio_bytes.clear(); + return false; + } + // Walk the RIFF chunks to find the 'fmt ' and 'data' chunks rather than + // assuming the canonical 44-byte layout. For 'data', keep exactly its + // payload: a fixed 44-byte strip is wrong for files with trailing metadata + // chunks (cue/LIST/bext), whose bytes would be played as audio, producing a + // pop at the end of playback. For 'fmt ', parse the actual format: + // play_audio() consumes 16-bit mono PCM, so anything else must be converted + // (stereo is downmixed below) or rejected instead of played as-is. + uint32_t sample_rate = 0; + uint16_t audio_format = 0, num_channels = 0, bits_per_sample = 0; + size_t data_off = 0, data_len = 0; + for (size_t off = 12; off + 8 <= audio_bytes.size();) { + uint32_t chunk_size = 0; + std::memcpy(&chunk_size, &audio_bytes[off + 4], sizeof(chunk_size)); + const size_t body = off + 8; + if (std::memcmp(&audio_bytes[off], "fmt ", 4) == 0 && chunk_size >= 16 && + body + 16 <= audio_bytes.size()) { + std::memcpy(&audio_format, &audio_bytes[body + 0], sizeof(audio_format)); + std::memcpy(&num_channels, &audio_bytes[body + 2], sizeof(num_channels)); + std::memcpy(&sample_rate, &audio_bytes[body + 4], sizeof(sample_rate)); + std::memcpy(&bits_per_sample, &audio_bytes[body + 14], sizeof(bits_per_sample)); + } else if (std::memcmp(&audio_bytes[off], "data", 4) == 0) { + data_off = body; + data_len = std::min(chunk_size, audio_bytes.size() - data_off); + break; // 'fmt ' precedes 'data' in a valid WAV; format fields stay 0 otherwise + } + off += 8 + chunk_size + (chunk_size & 1); // chunks are word-aligned + } + if (data_len == 0 || audio_format != 1 /* PCM */ || bits_per_sample != 16 || + (num_channels != 1 && num_channels != 2)) { + audio_bytes.clear(); + return false; + } + audio_bytes.erase(audio_bytes.begin() + data_off + data_len, audio_bytes.end()); + audio_bytes.erase(audio_bytes.begin(), audio_bytes.begin() + data_off); + if (num_channels == 2) { + // The embedded click.wav is stereo, but play_audio() takes mono samples: + // downmix in place by averaging each interleaved L/R pair. (Playing the + // interleaved stereo words as mono would emit each frame twice - half + // speed and distorted.) + auto *samples = reinterpret_cast(audio_bytes.data()); + const size_t num_frames = audio_bytes.size() / (2 * sizeof(int16_t)); + for (size_t i = 0; i < num_frames; i++) { + samples[i] = static_cast( + (static_cast(samples[2 * i]) + static_cast(samples[2 * i + 1])) / 2); + } + audio_bytes.resize(num_frames * sizeof(int16_t)); + } + out_size = audio_bytes.size(); + cached_sample_rate = sample_rate; + out_sample_rate = sample_rate; + return true; +} diff --git a/components/esp32-p4-wifi6-dev-kit/example/main/gui.cpp b/components/esp32-p4-wifi6-dev-kit/example/main/gui.cpp new file mode 100644 index 000000000..559e1d4d0 --- /dev/null +++ b/components/esp32-p4-wifi6-dev-kit/example/main/gui.cpp @@ -0,0 +1,538 @@ +#include +#include +#include +#include + +#include + +#include "gui.hpp" + +using Board = espp::Esp32P4Wifi6DevKit; + +void Gui::init_ui() { + std::lock_guard lock(mutex_); + init_tabview(); + init_labels(); + init_buttons(); + init_audio_controls(); + init_camera_tab(); + init_circle_layer(); + ui_ready_ = true; +} + +void Gui::init_tabview() { + // the tabview gives each subsystem its own uncrowded page; the tab bar + // (top) is the page switcher + tabview_ = lv_tabview_create(lv_screen_active()); + lv_tabview_set_tab_bar_position(tabview_, LV_DIR_TOP); + lv_tabview_set_tab_bar_size(tabview_, TAB_BAR_HEIGHT); + lv_obj_set_size(tabview_, lv_display_get_horizontal_resolution(lv_display_get_default()), + lv_display_get_vertical_resolution(lv_display_get_default())); + status_tab_ = lv_tabview_add_tab(tabview_, "Status"); + audio_tab_ = lv_tabview_add_tab(tabview_, "Audio"); + camera_tab_ = lv_tabview_add_tab(tabview_, "Camera"); + // The tab pages themselves are scrollable by default; disable that so drags + // inside a page don't rubber-band/scroll the content (matches the other BSP + // example GUIs). + lv_obj_clear_flag(status_tab_, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_clear_flag(audio_tab_, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_clear_flag(camera_tab_, LV_OBJ_FLAG_SCROLLABLE); + // switching tabs is done with the tab buttons only: disable swipe + // scrolling of the content so drawing on the Status tab cannot accidentally + // change pages + lv_obj_clear_flag(lv_tabview_get_content(tabview_), LV_OBJ_FLAG_SCROLLABLE); + // hide the touch-trail overlay whenever a non-drawing tab is shown + lv_obj_add_event_cb(tabview_, event_callback, LV_EVENT_VALUE_CHANGED, this); +} + +void Gui::deinit_ui() { + std::lock_guard lock(mutex_); + // Mark the UI down first so a camera frame arriving mid-teardown (the camera + // task runs independently) is dropped rather than touching freed objects. + ui_ready_ = false; + lv_obj_clean(lv_screen_active()); + // lv_obj_clean() deleted every object under the screen; null the pointers we + // hold so nothing dereferences a freed object. It does not free the canvas's + // external PSRAM buffer (a member we own), so release that too. + tabview_ = nullptr; + status_tab_ = nullptr; + audio_tab_ = nullptr; + camera_tab_ = nullptr; + camera_canvas_ = nullptr; + camera_label_ = nullptr; + if (camera_buf_) { + heap_caps_free(camera_buf_); + camera_buf_ = nullptr; + } + camera_w_ = 0; + camera_h_ = 0; +} + +void Gui::init_labels() { + // a title label at the top of the Status tab + title_label_ = lv_label_create(status_tab_); + lv_label_set_long_mode(title_label_, LV_LABEL_LONG_WRAP); + lv_obj_set_width(title_label_, lv_pct(100)); + lv_label_set_text(title_label_, "ESP32-P4-WIFI6-DEV-KIT - touch to draw!"); + lv_obj_align(title_label_, LV_ALIGN_TOP_MID, 0, 0); + + // a status label showing the live subsystem state, updated via + // set_status_text() + status_label_ = lv_label_create(status_tab_); + lv_label_set_long_mode(status_label_, LV_LABEL_LONG_WRAP); + lv_obj_set_width(status_label_, lv_pct(100)); + lv_label_set_text(status_label_, ""); + lv_obj_set_style_text_align(status_label_, LV_TEXT_ALIGN_LEFT, 0); + lv_obj_align(status_label_, LV_ALIGN_TOP_LEFT, 0, 32); +} + +void Gui::init_buttons() { + // a button in the top right of the Status tab which rotates the display + // through 0/90/180/270 degrees + rotate_button_ = lv_btn_create(status_tab_); + lv_obj_set_size(rotate_button_, 50, 50); + lv_obj_align(rotate_button_, LV_ALIGN_TOP_RIGHT, 0, 0); + lv_obj_t *rotate_label = lv_label_create(rotate_button_); + lv_label_set_text(rotate_label, LV_SYMBOL_REFRESH); + lv_obj_align(rotate_label, LV_ALIGN_CENTER, 0, 0); + lv_obj_add_event_cb(rotate_button_, event_callback, LV_EVENT_CLICKED, this); + + // a button next to it which clears the circles + clear_button_ = lv_btn_create(status_tab_); + lv_obj_set_size(clear_button_, 50, 50); + lv_obj_align(clear_button_, LV_ALIGN_TOP_RIGHT, -58, 0); + lv_obj_t *clear_label = lv_label_create(clear_button_); + lv_label_set_text(clear_label, LV_SYMBOL_TRASH); + lv_obj_align(clear_label, LV_ALIGN_CENTER, 0, 0); + lv_obj_add_event_cb(clear_button_, event_callback, LV_EVENT_CLICKED, this); +} + +void Gui::init_audio_controls() { + // the Audio tab: a column with a status line, the volume line, then the + // buttons in a wrapping row + lv_obj_set_flex_flow(audio_tab_, LV_FLEX_FLOW_COLUMN); + lv_obj_set_style_pad_row(audio_tab_, 12, 0); + + audio_status_label_ = lv_label_create(audio_tab_); + lv_label_set_long_mode(audio_status_label_, LV_LABEL_LONG_WRAP); + lv_obj_set_width(audio_status_label_, lv_pct(100)); + lv_label_set_text(audio_status_label_, "Idle"); + + audio_label_ = lv_label_create(audio_tab_); + lv_label_set_long_mode(audio_label_, LV_LABEL_LONG_WRAP); + lv_obj_set_width(audio_label_, lv_pct(100)); + update_audio_label(); + + lv_obj_t *row = lv_obj_create(audio_tab_); + lv_obj_remove_style_all(row); + lv_obj_set_size(row, lv_pct(100), LV_SIZE_CONTENT); + lv_obj_set_flex_flow(row, LV_FLEX_FLOW_ROW_WRAP); + lv_obj_set_style_pad_column(row, 12, 0); + lv_obj_set_style_pad_row(row, 12, 0); + + struct ButtonSpec { + lv_obj_t **button; + const char *symbol; + }; + const ButtonSpec buttons[] = { + {&record_button_, LV_SYMBOL_AUDIO}, {&play_button_, LV_SYMBOL_PLAY}, + {&volume_down_button_, LV_SYMBOL_VOLUME_MID}, {&volume_up_button_, LV_SYMBOL_VOLUME_MAX}, + {&mic_down_button_, LV_SYMBOL_MINUS}, {&mic_up_button_, LV_SYMBOL_PLUS}, + }; + for (const auto &spec : buttons) { + *spec.button = lv_btn_create(row); + lv_obj_set_size(*spec.button, 50, 50); + lv_obj_t *label = lv_label_create(*spec.button); + lv_label_set_text(label, spec.symbol); + lv_obj_align(label, LV_ALIGN_CENTER, 0, 0); + lv_obj_add_event_cb(*spec.button, event_callback, LV_EVENT_CLICKED, this); + } + // remember the record / play button labels so set_record_active / + // set_play_active can swap their symbols + record_button_label_ = lv_obj_get_child(record_button_, 0); + play_button_label_ = lv_obj_get_child(play_button_, 0); + // color the volume buttons so the speaker and microphone pairs are + // distinguishable from each other + lv_obj_set_style_bg_color(mic_down_button_, lv_palette_main(LV_PALETTE_TEAL), 0); + lv_obj_set_style_bg_color(mic_up_button_, lv_palette_main(LV_PALETTE_TEAL), 0); +} + +void Gui::init_camera_tab() { + // Center the camera feed; show a placeholder label until the first frame + // arrives. The canvas that displays the live feed is created lazily in + // set_camera_frame() once the true frame size is known. + lv_obj_set_flex_flow(camera_tab_, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(camera_tab_, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, + LV_FLEX_ALIGN_CENTER); + lv_obj_set_style_pad_all(camera_tab_, 0, 0); + camera_label_ = lv_label_create(camera_tab_); + lv_label_set_text(camera_label_, "Waiting for camera..."); +} + +void Gui::update_audio_label() { + auto &board = Board::get(); + int speaker_volume = static_cast(board.volume()); + int mic_volume = static_cast(board.microphone_volume()); + // this is called every GUI tick; only reformat the label when a value + // actually changes (lv_label_set_text_fmt formats a new string each call) + if (speaker_volume == last_speaker_volume_ && mic_volume == last_mic_volume_) { + return; + } + last_speaker_volume_ = speaker_volume; + last_mic_volume_ = mic_volume; + // Pass the LVGL symbols as %s arguments rather than concatenating them into + // the format-string literal: cppcheck cannot expand the LVGL symbol macros + // and flags the literal concatenation as an unknown macro. + lv_label_set_text_fmt(audio_label_, "Speaker %d%% (%s/%s)\nMic %d%% (teal %s/%s)", speaker_volume, + LV_SYMBOL_VOLUME_MID, LV_SYMBOL_VOLUME_MAX, mic_volume, LV_SYMBOL_MINUS, + LV_SYMBOL_PLUS); +} + +void Gui::set_record_active(bool active) { + std::lock_guard lock(mutex_); + lv_label_set_text(record_button_label_, active ? LV_SYMBOL_STOP : LV_SYMBOL_AUDIO); + lv_obj_set_style_bg_color( + record_button_, active ? lv_palette_main(LV_PALETTE_RED) : lv_palette_main(LV_PALETTE_BLUE), + 0); +} + +void Gui::set_play_active(bool active) { + std::lock_guard lock(mutex_); + lv_label_set_text(play_button_label_, active ? LV_SYMBOL_STOP : LV_SYMBOL_PLAY); + lv_obj_set_style_bg_color( + play_button_, active ? lv_palette_main(LV_PALETTE_GREEN) : lv_palette_main(LV_PALETTE_BLUE), + 0); +} + +void Gui::set_camera_frame(const uint8_t *rgb565, int w, int h, size_t length) { + if (!rgb565 || w <= 0 || h <= 0 || length == 0) { + return; + } + std::lock_guard lock(mutex_); + // The camera task may still deliver a frame after the UI is torn down; drop it + // rather than create objects under a freed camera_tab_. + if (!ui_ready_ || !camera_tab_) { + return; + } + // (Re)allocate the canvas buffer and (re)create the canvas on the first frame + // or whenever the frame size changes. The buffer must outlive the canvas, so + // it is a member kept in PSRAM. + if (camera_buf_ == nullptr || w != camera_w_ || h != camera_h_) { + // Allocate the new buffer BEFORE freeing the old one / deleting the canvas, + // so that on OOM we keep showing the previous frame instead of blanking the + // tab. + const size_t bytes = static_cast(w) * static_cast(h) * 2; + auto *new_buf = + static_cast(heap_caps_malloc(bytes, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT)); + if (!new_buf) { + return; // out of memory; keep the current canvas / preview + } + if (camera_canvas_) { + lv_obj_del(camera_canvas_); + camera_canvas_ = nullptr; + } + if (camera_buf_) { + heap_caps_free(camera_buf_); + } + camera_buf_ = new_buf; + camera_w_ = w; + camera_h_ = h; + camera_canvas_ = lv_canvas_create(camera_tab_); + lv_canvas_set_buffer(camera_canvas_, camera_buf_, w, h, LV_COLOR_FORMAT_RGB565); + // Frame the feed (a border + rounded corners so it reads as a distinct + // element when it is smaller than the tab). + lv_obj_set_style_border_width(camera_canvas_, 2, 0); + lv_obj_set_style_border_color(camera_canvas_, lv_palette_main(LV_PALETTE_GREY), 0); + lv_obj_set_style_radius(camera_canvas_, 6, 0); + if (camera_label_) { + lv_obj_add_flag(camera_label_, LV_OBJ_FLAG_HIDDEN); + } + } + // Honor the driver-reported payload length rather than assuming a full + // w*h*2 frame: clamp the copy so a short payload is never over-read (a + // short frame just leaves the rest of the canvas unchanged). + std::memcpy(camera_buf_, rgb565, + std::min(length, static_cast(w) * static_cast(h) * 2)); + lv_obj_invalidate(camera_canvas_); +} + +void Gui::init_circle_layer() { + // a transparent, click-through overlay above the tabview which shows the + // touch trail (only populated while the Status tab is active) + circle_layer_ = lv_obj_create(lv_screen_active()); + lv_obj_remove_style_all(circle_layer_); + lv_obj_set_size(circle_layer_, lv_display_get_horizontal_resolution(lv_display_get_default()), + lv_display_get_vertical_resolution(lv_display_get_default())); + 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); + lv_obj_move_foreground(circle_layer_); +} + +bool Gui::update(std::mutex &m, std::condition_variable &cv, bool &task_notified) { + { + std::lock_guard lock(mutex_); + // drain any touch points queued since the last cycle + { + std::vector points; + { + std::lock_guard plock(pending_points_mutex_); + points.swap(pending_points_); + } + for (const auto &c : points) { + draw_circle_pending(c); + } + } + lv_task_handler(); + // keep the audio volume label in sync with the live BSP state, so the + // first press of a volume button doesn't appear to jump from a stale + // default (the values are set by app_main after the Gui is constructed) + update_audio_label(); + } + std::unique_lock lock(m); + // Wait with the notified flag as the predicate so a spurious + // condition-variable wake does not stop the GUI task; per the Task contract + // the flag is checked and cleared under m. A true predicate means + // Task::stop() notified us, so stop promptly (otherwise ~Gui can hang + // joining the update thread); a plain timeout means keep running. + if (cv.wait_for(lock, std::chrono::milliseconds(16), + [&task_notified] { return task_notified; })) { + task_notified = false; + return true; // stop the task + } + return false; // keep running +} + +void Gui::event_callback(lv_event_t *e) { + auto *gui = static_cast(lv_event_get_user_data(e)); + if (!gui) { + return; + } + switch (lv_event_get_code(e)) { + case LV_EVENT_CLICKED: + gui->on_clicked(e); + break; + case LV_EVENT_VALUE_CHANGED: + gui->on_tab_changed(e); + break; + default: + break; + } +} + +void Gui::on_tab_changed(lv_event_t *e) { + const auto *target = static_cast(lv_event_get_target(e)); + if (target != tabview_) { + return; + } + // the touch trail only belongs to the Status tab; hide it (and its + // circles) everywhere else + if (lv_tabview_get_tab_active(tabview_) == 0) { + lv_obj_clear_flag(circle_layer_, LV_OBJ_FLAG_HIDDEN); + } else { + lv_obj_add_flag(circle_layer_, LV_OBJ_FLAG_HIDDEN); + } +} + +void Gui::on_clicked(lv_event_t *e) { + const auto *target = static_cast(lv_event_get_target(e)); + auto &board = Board::get(); + if (target == record_button_) { + logger_.info("Record button clicked"); + if (record_callback_) { + record_callback_(); + } + return; + } + if (target == play_button_) { + logger_.info("Play button clicked"); + if (play_callback_) { + play_callback_(); + } + return; + } + if (target == volume_down_button_ || target == volume_up_button_) { + float delta = target == volume_down_button_ ? -10.0f : 10.0f; + board.volume(board.volume() + delta); + logger_.info("Speaker volume: {:.0f}%", board.volume()); + update_audio_label(); + return; + } + if (target == mic_down_button_ || target == mic_up_button_) { + float delta = target == mic_down_button_ ? -10.0f : 10.0f; + board.microphone_volume(board.microphone_volume() + delta); + logger_.info("Microphone volume: {:.0f}%", board.microphone_volume()); + update_audio_label(); + return; + } + if (target == rotate_button_) { + logger_.info("Rotate button clicked"); + next_rotation(); + } else if (target == clear_button_) { + logger_.info("Clear button clicked"); + clear_circles(); + } +} + +// Set a label's text from a string_view without a per-call heap allocation: +// NUL-terminate into a fixed stack buffer (LVGL needs a C string and copies +// it), and skip the LVGL call entirely when the text is unchanged. +// set_status_text() is called at ~10 Hz by the status task, and +// lv_label_set_text reallocates and invalidates the label even for identical +// text. The buffer comfortably fits the multi-line status text; anything +// longer is truncated. +static void set_label_text(lv_obj_t *label, std::string_view text) { + char buf[384]; + snprintf(buf, sizeof(buf), "%.*s", static_cast(text.size()), text.data()); + if (strcmp(lv_label_get_text(label), buf) == 0) { + return; + } + lv_label_set_text(label, buf); +} + +void Gui::set_status_text(std::string_view text) { + std::lock_guard lock(mutex_); + set_label_text(status_label_, text); +} + +void Gui::set_audio_status(std::string_view text) { + std::lock_guard lock(mutex_); + set_label_text(audio_status_label_, text); +} + +bool Gui::draw_page_active() { + std::lock_guard lock(mutex_); + return lv_tabview_get_tab_active(tabview_) == 0; +} + +void Gui::next_rotation() { + std::lock_guard lock(mutex_); + auto &board = Board::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(tabview_, board.rotated_display_width(), board.rotated_display_height()); + lv_obj_set_size(circle_layer_, board.rotated_display_width(), board.rotated_display_height()); + lv_obj_align(circle_layer_, LV_ALIGN_CENTER, 0, 0); + lv_obj_move_foreground(circle_layer_); + lv_obj_invalidate(circle_layer_); +} + +void Gui::draw_circle(int x, int y, int radius) { + // Only queue the point here; the GUI update task drains the queue under the + // LVGL mutex. Taking mutex_ directly would block the caller (the touch poll + // task) for the duration of lv_task_handler() rendering, collapsing the + // touch sample rate. + std::lock_guard lock(pending_points_mutex_); + // Bound the queue so it cannot grow without limit if the GUI task stalls or + // the producers outpace the drain: drop the oldest point to keep the newest, + // and log drops at most once a second (from this producer context a per-drop + // log would itself become the bottleneck). + if (pending_points_.size() >= MAX_PENDING_POINTS) { + pending_points_.erase(pending_points_.begin()); + ++dropped_points_; + auto now = std::chrono::steady_clock::now(); + if (now - last_drop_log_ >= std::chrono::seconds(1)) { + last_drop_log_ = now; + logger_.warn("Pending touch-point queue full; dropped {} oldest point(s)", dropped_points_); + dropped_points_ = 0; + } + } + pending_points_.push_back({.x = x, .y = y, .radius = radius, .visible = true}); +} + +void Gui::draw_circle_pending(const Circle &c) { + // caller holds mutex_ + lv_obj_move_foreground(circle_layer_); + int x = c.x, y = c.y, radius = c.radius; + 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/esp32-p4-wifi6-dev-kit/example/main/gui.hpp b/components/esp32-p4-wifi6-dev-kit/example/main/gui.hpp new file mode 100644 index 000000000..6d1a38ec0 --- /dev/null +++ b/components/esp32-p4-wifi6-dev-kit/example/main/gui.hpp @@ -0,0 +1,247 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +#include "esp32-p4-wifi6-dev-kit.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 (touch callbacks, status tasks, the camera task, etc.) can +/// safely call them. +/// * LVGL event callbacks are registered with `this` as the user-data and +/// dispatched through a single static trampoline (event_callback) into +/// member functions, keeping all UI logic inside the class. +/// +/// The UI is organized as a tabview so each subsystem gets its own uncrowded +/// page: +/// * "Status" tab: a title + live subsystem state (panel, touch, SD, Ethernet, +/// memory/uptime), plus the rotate / clear buttons. Touching the screen while +/// this tab is active draws circles (on a transparent overlay) and plays a +/// click. +/// * "Audio" tab: record / play buttons (wired to the example via callbacks) +/// and speaker / microphone volume buttons (acting directly on the BSP), with +/// labels showing the audio state and current volumes. +/// * "Camera" tab: a live view of the MIPI-CSI camera. Each RGB565 frame handed +/// to set_camera_frame() is copied into a PSRAM canvas buffer and shown. +class Gui { +public: + /// Callback invoked when the record / play buttons are pressed + using audio_button_callback_t = std::function; + /// 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 status label. Thread-safe. + /// @param text The text to display + void set_status_text(std::string_view text); + + /// Show a camera frame on the Camera tab. Thread-safe. + /// @param rgb565 The frame pixel data (RGB565) + /// @param w The frame width in pixels + /// @param h The frame height in pixels + /// @param length The number of valid bytes at rgb565 (as reported by the + /// capture driver); the copy is clamped to min(length, w*h*2) + /// @note The data is copied, so it need not outlive the call. The canvas that + /// displays the feed is (re)created on the first frame (or a size + /// change) once the true frame size is known. + void set_camera_frame(const uint8_t *rgb565, int w, int h, size_t length); + + /// Draw a circle at the given screen coordinates, replacing the oldest + /// circle if the maximum number are already visible. Thread-safe. + /// @param x The x coordinate (screen space) + /// @param y The y coordinate (screen space) + /// @param radius The radius of the circle + /// Run a callable while holding the LVGL lock. LVGL is built with + /// LV_USE_OS == LV_OS_NONE, so any LVGL mutation made outside the GUI task + /// (e.g. lv_indev_create() in a TouchpadInput constructed after this Gui has + /// started its update task) must be serialized against lv_task_handler(). + template auto with_lvgl_locked(F &&f) -> decltype(f()) { + std::lock_guard lock(mutex_); + return std::forward(f)(); + } + + /// Queue a circle to draw at (x, y). Thread-safe and non-blocking: the point + /// is queued under a small lock and rendered by the GUI update task on its + /// next cycle, so callers (e.g. the touch poll task) never wait on LVGL + /// rendering. + void draw_circle(int x, int y, int radius); + + /// 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(); + + /// Whether the Status tab is currently active (used by the example to only + /// draw circles for touches on that tab). Thread-safe. + /// @return True if the Status tab is the active tab + bool draw_page_active(); + + /// Set the status line on the Audio tab (e.g. "Recording...", "Mic + /// unavailable"). Thread-safe. + /// @param text The text to display + void set_audio_status(std::string_view text); + + /// Set the callback invoked when the record button is pressed. Thread-safe: + /// the GUI task may already be dispatching button events (which read this + /// callback under the GUI mutex), so the write is taken under the same lock. + /// @param callback The callback to invoke + void set_record_callback(audio_button_callback_t callback) { + std::lock_guard lock(mutex_); + record_callback_ = std::move(callback); + } + + /// Set the callback invoked when the play button is pressed. Thread-safe (see + /// set_record_callback()). + /// @param callback The callback to invoke + void set_play_callback(audio_button_callback_t callback) { + std::lock_guard lock(mutex_); + play_callback_ = std::move(callback); + } + + /// Show whether a recording is in progress (turns the record button red + /// and changes its symbol to stop). Thread-safe. + /// @param active True while recording + void set_record_active(bool active); + + /// Show whether a playback is in progress (turns the play button green and + /// changes its symbol to stop). Thread-safe. + /// @param active True while playing + void set_play_active(bool active); + +protected: + static constexpr size_t MAX_CIRCLES = 100; + // Cap on the pending draw_circle() queue. The GUI task drains it every ~16 ms + // and the touch poll produces at most ~one point per 16 ms, so hitting this + // means the GUI task has stalled; drop the oldest points rather than growing + // without bound. + static constexpr size_t MAX_PENDING_POINTS = 16; + static constexpr int TAB_BAR_HEIGHT = 50; + + 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_tabview(); + void init_labels(); + void init_buttons(); + void init_audio_controls(); + void init_camera_tab(); + void init_circle_layer(); + + // update the audio volume label from the BSP's current volumes; called + // with the mutex held + void update_audio_label(); + + // the LVGL update task: calls lv_task_handler() under the mutex + bool update(std::mutex &m, std::condition_variable &cv, bool &task_notified); + + // single trampoline for all LVGL events; dispatches to the member + // functions below based on the event target + static void event_callback(lv_event_t *e); + void on_clicked(lv_event_t *e); + void on_tab_changed(lv_event_t *e); + + // 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 clear_circles_impl(); + void draw_circle_pending(const Circle &c); + + // LVGL objects + lv_obj_t *tabview_{nullptr}; + lv_obj_t *status_tab_{nullptr}; + lv_obj_t *audio_tab_{nullptr}; + lv_obj_t *camera_tab_{nullptr}; + lv_obj_t *title_label_{nullptr}; + lv_obj_t *status_label_{nullptr}; + lv_obj_t *rotate_button_{nullptr}; + lv_obj_t *clear_button_{nullptr}; + lv_obj_t *record_button_{nullptr}; + lv_obj_t *record_button_label_{nullptr}; + lv_obj_t *play_button_{nullptr}; + lv_obj_t *play_button_label_{nullptr}; + lv_obj_t *volume_down_button_{nullptr}; + lv_obj_t *volume_up_button_{nullptr}; + lv_obj_t *mic_down_button_{nullptr}; + lv_obj_t *mic_up_button_{nullptr}; + lv_obj_t *audio_label_{nullptr}; + // last values shown on audio_label_, so update_audio_label() (called every + // GUI tick) only reformats the text when a value actually changes + int last_speaker_volume_{-1}; + int last_mic_volume_{-1}; + lv_obj_t *audio_status_label_{nullptr}; + lv_obj_t *circle_layer_{nullptr}; + + // Camera-feed widgets: a canvas bound to a PSRAM RGB565 buffer, (re)allocated + // on the first frame (or a size change) once the true frame size is known. + lv_obj_t *camera_canvas_{nullptr}; + lv_obj_t *camera_label_{nullptr}; + uint8_t *camera_buf_{nullptr}; + int camera_w_{0}; + int camera_h_{0}; + + audio_button_callback_t record_callback_{nullptr}; + audio_button_callback_t play_callback_{nullptr}; + + std::array circles_; + size_t next_circle_index_{0}; + size_t visible_circle_count_{0}; + + espp::Task update_task_{{.callback = [this](std::mutex &m, std::condition_variable &cv, + bool ¬ified) { return update(m, cv, notified); }, + // NOTE: rendering the tabview (nested containers + flex layout) uses + // noticeably more stack than a flat UI; 6 KB overflows + .task_config = {.name = "gui", .stack_size_bytes = 12 * 1024}}}; + espp::Logger logger_; + std::recursive_mutex mutex_; + // Pending draw_circle() points, queued by (fast) producers and drained under + // mutex_ by the GUI update task; keeps the touch poll task from blocking on + // LVGL rendering. + std::mutex pending_points_mutex_; + std::vector pending_points_; + // Drop accounting for the bounded pending_points_ queue (rate-limited log) + size_t dropped_points_{0}; + std::chrono::steady_clock::time_point last_drop_log_{}; + // True between init_ui() and deinit_ui(). Guards set_camera_frame() (called + // from the camera task) against touching the LVGL tree after teardown. + bool ui_ready_{false}; +}; diff --git a/components/esp32-p4-wifi6-dev-kit/example/partitions.csv b/components/esp32-p4-wifi6-dev-kit/example/partitions.csv new file mode 100644 index 000000000..ff7429511 --- /dev/null +++ b/components/esp32-p4-wifi6-dev-kit/example/partitions.csv @@ -0,0 +1,5 @@ +# Name, Type, SubType, Offset, Size +nvs, data, nvs, 0xa000, 0x6000 +phy_init, data, phy, , 0x1000 +factory, app, factory, , 4M +littlefs, data, littlefs, , 4M diff --git a/components/esp32-p4-wifi6-dev-kit/example/sdkconfig.defaults b/components/esp32-p4-wifi6-dev-kit/example/sdkconfig.defaults new file mode 100644 index 000000000..c48cd00e2 --- /dev/null +++ b/components/esp32-p4-wifi6-dev-kit/example/sdkconfig.defaults @@ -0,0 +1,49 @@ +CONFIG_IDF_TARGET="esp32p4" + +# for ESP-IDF >= v6.0, we have to set this to allow older boards (e.g. +# esp32-p4-wifi6-dev-kit) to work with the newer ESP-IDF since it's using an older p4 +# chip revision +CONFIG_ESP32P4_SELECTS_REV_LESS_V3=y + +# Ethernet (internal EMAC + RMII) +CONFIG_ETH_ENABLED=y +CONFIG_ETH_USE_ESP32_EMAC=y + +CONFIG_FREERTOS_HZ=1000 + +# Flash / partition table. The example now brings up the display + LVGL GUI, the +# camera pipeline and audio, so the app no longer fits the default 1 MB factory +# partition; use a 16 MB flash and a custom table with a 4 MB app partition. +CONFIG_ESPTOOLPY_FLASHSIZE_16MB=y +CONFIG_ESPTOOLPY_FLASHSIZE="16MB" +CONFIG_PARTITION_TABLE_CUSTOM=y +CONFIG_PARTITION_TABLE_OFFSET=0x9000 +CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions.csv" + +# Give app_main a larger stack: it drives the full bring-up (display, touch, +# audio, camera, Ethernet) plus the playback loop. +CONFIG_ESP_MAIN_TASK_STACK_SIZE=16384 + +# espp I2C: use the new ESP-IDF master/slave bus API. Required for +# internal_i2c_.native_bus_handle(), which the camera SCCB shares (this is the +# Kconfig default, but set explicitly so the camera bring-up always compiles). +CONFIG_ESPP_I2C_USE_NEW_API=y + +# PSRAM: the camera capture buffers and the display framebuffers live in PSRAM. +CONFIG_SPIRAM=y +CONFIG_SPIRAM_SPEED_200M=y +CONFIG_SPIRAM_XIP_FROM_PSRAM=y +CONFIG_CACHE_L2_CACHE_256KB=y +CONFIG_CACHE_L2_CACHE_LINE_128B=y + +# On-board MIPI-CSI camera: esp_video (V4L2) capture pipeline + OV5647 sensor +# (Raspberry Pi Camera v1.3). Enabling the MIPI-CSI video device auto-selects +# the ISP (RAW8 -> RGB565). The sensor is Kconfig-selectable: if a different +# sensor is fitted to the CSI connector, disable OV5647 below and enable that +# sensor (e.g. CONFIG_CAMERA_SC202CS) instead. +CONFIG_ESP_VIDEO_ENABLE_MIPI_CSI_VIDEO_DEVICE=y +CONFIG_CAMERA_OV5647=y +CONFIG_CAMERA_OV5647_AUTO_DETECT_MIPI_INTERFACE_SENSOR=y +# Enable the ISP pipeline controller (isp_task): auto exposure / white balance. +# Without it the ISP runs with static gains and the feed has a heavy color cast. +CONFIG_ESP_VIDEO_ENABLE_ISP_PIPELINE_CONTROLLER=y diff --git a/components/esp32-p4-wifi6-dev-kit/idf_component.yml b/components/esp32-p4-wifi6-dev-kit/idf_component.yml new file mode 100644 index 000000000..c7984e052 --- /dev/null +++ b/components/esp32-p4-wifi6-dev-kit/idf_component.yml @@ -0,0 +1,44 @@ +## IDF Component Manager Manifest File +license: "MIT" +description: "Waveshare ESP32-P4-WIFI6-DEV-KIT Board Support Package (BSP) component in C++ (Wi-Fi 6 via onboard ESP32-C6/ESP-Hosted, Ethernet, MIPI-DSI display + GT911 touch, MIPI-CSI camera, uSD, ES8311 audio in/out)" +url: "https://github.com/esp-cpp/espp/tree/main/components/esp32-p4-wifi6-dev-kit" +repository: "https://github.com/esp-cpp/espp.git" +maintainers: + - William Emfinger +documentation: "https://esp-cpp.github.io/espp/dev_boards/waveshare/esp32_p4_wifi6_dev_kit.html" +examples: + - path: example +tags: + - cpp + - Component + - BSP + - ESP32P4 + - WiFi6 + - Ethernet + - Display + - Camera + - Audio + - Waveshare +dependencies: + idf: + # The camera pipeline hands esp_video the espp::I2c native bus handle + # (I2c::native_bus_handle()), which requires the new ESP-IDF I2C master API + # (ESP-IDF >= 5.4 per components/i2c/Kconfig). + version: ">=5.4" + espp/base_component: ">=1.0" + espp/ethernet: ">=1.0" + espp/codec: ">=1.0" + espp/i2c: ">=1.0" + espp/task: ">=1.0" + espp/display: ">=1.0" + espp/display_drivers: ">=1.0" + espp/gt911: ">=1.0" + espp/input_drivers: ">=1.0" + espp/interrupt: ">=1.0" + # MIPI-CSI camera pipeline: esp_video provides the V4L2 capture framework + # (CSI + ISP) and esp_cam_sensor provides the OV5647 sensor driver. + espressif/esp_video: ">=2.0,<2.4" # 2.4.0 fails to compile against the CI IDF (ISP_LL_EVENT_ERROR_MASK undeclared) + espressif/esp_ipa: ">=2.0,<2.3" # prebuilt 2.3.0 illegal-instructions at runtime on P4 + espressif/esp_cam_sensor: ">=2.0,<2.4" +targets: + - esp32p4 diff --git a/components/esp32-p4-wifi6-dev-kit/include/esp32-p4-wifi6-dev-kit.hpp b/components/esp32-p4-wifi6-dev-kit/include/esp32-p4-wifi6-dev-kit.hpp new file mode 100644 index 000000000..da0a6c3bb --- /dev/null +++ b/components/esp32-p4-wifi6-dev-kit/include/esp32-p4-wifi6-dev-kit.hpp @@ -0,0 +1,825 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +#include + +#include +#include +#include +#include +#include + +#include +#include +#include + +#include +#include + +#include "base_component.hpp" +#include "display.hpp" +#include "display_drivers.hpp" +#include "ek79007.hpp" +#include "es8311.hpp" +#include "ethernet.hpp" +#include "gt911.hpp" +#include "i2c.hpp" +#include "ili9881.hpp" +#include "interrupt.hpp" +#include "jd9365.hpp" +#include "task.hpp" +#include "touchpad_input.hpp" + +namespace espp { +/// @brief Board Support Package (BSP) for the Waveshare ESP32-P4-WIFI6-DEV-KIT board. +/// +/// The ESP32-P4-WIFI6-DEV-KIT is an ESP32-P4NRW32 (32 MB stacked PSRAM; 16 MB +/// NOR flash per the product page, though the wiki self-contradicts on 16 vs +/// 32 MB - unverified on hardware) multimedia development board with an +/// onboard ESP32-C6 +/// co-processor that provides Wi-Fi 6 / Bluetooth 5 (LE) over SDIO +/// (ESP-Hosted). This class provides a singleton interface to the board's +/// peripherals: +/// - 10/100 Ethernet via the ESP32-P4 internal EMAC and an IP101GRI RMII PHY +/// (RJ45 with an optional external PoE-module header). +/// - MIPI-DSI display (JD9365 10.1" by default, or ILI9881C 10.1" / EK79007 7", +/// selected via Kconfig) with a GT911 capacitive-touch controller. +/// - MIPI-CSI camera (esp_video / V4L2 capture pipeline; OV5647 by default). +/// - microSD / TF card over 4-bit SDMMC. +/// - ES8311 audio codec (+ NS4150B amplifier) for speaker output and microphone +/// input over I2S, plus a 3.5 mm headphone jack. +/// +/// The ES8311 codec, GT911 touch, and camera SCCB share a single internal I2C +/// bus (\ref internal_i2c()). The Ethernet bring-up is delegated to the reusable +/// espp::Ethernet component; this BSP supplies the board-specific pin mappings. +/// +/// Wi-Fi / Bluetooth are NOT initialized by this BSP: the ESP32-C6 runs the +/// ESP-Hosted slave firmware and is used from the ESP32-P4 through the +/// `espressif/esp_hosted` + `espressif/esp_wifi_remote` managed components, +/// after which the standard `esp_wifi` API works unchanged (see the example +/// README). The C6 connects to the P4 over SDIO (the espp c6_sdio_* pin +/// constants below, which match the ESP-Hosted defaults for the ESP32-P4). +/// +/// \note The board's RTC is the ESP32-P4's internal RTC (the on-board connector +/// is only a coin-cell backup for it); use the standard C/ESP-IDF time +/// APIs rather than a dedicated RTC driver. +/// +/// \note The headphone jack has a hardware detect switch: inserting headphones +/// gates off the NS4150B speaker amplifier (the PA-enable GPIO is ANDed +/// with the headphone-detect signal on the board), so no software action +/// is needed to switch between speaker and headphones. +/// +/// \note The BOOT button is on GPIO35, which is also RMII TXD1, so it cannot be +/// used as a user button while Ethernet is active (and is therefore not +/// exposed by this BSP). +/// +/// RMII pin mapping (ESP32-P4 routable EMAC pins). REF_CLK carries a 50 MHz +/// reference clock (25 MHz crystal ×2): +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +///
SignalGPIO
REF_CLK50
TX_EN49
TXD034
TXD135
CRS_DV28
RXD029
RXD130
MDC31
MDIO52
PHY_RST51
+/// +/// ESP32-P4 ↔ ESP32-C6 SDIO link (ESP-Hosted; matches the esp_hosted +/// SDIO defaults for the ESP32-P4): +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +///
SignalGPIO
SDIO CLK18
SDIO CMD19
SDIO D014
SDIO D115
SDIO D216
SDIO D317
C6 CHIP_PU (reset)54
+/// +/// The class is a singleton and can be accessed via get(). +/// +/// \section esp32_p4_wifi6_dev_kit_example Example +/// \snippet esp32_p4_wifi6_dev_kit_example.cpp esp32 p4 wifi6 dev kit example +class Esp32P4Wifi6DevKit : public BaseComponent { +public: + /// Callback invoked (SERVER mode only) each time the DHCP server assigns an + /// IP address to a connected client. + using client_ip_callback_t = std::function mac)>; + + /// Callback invoked when the Ethernet link state changes or the IP is lost. + /// \note Runs in the ESP-IDF event-loop task context — return quickly, do not block. + using EthernetLinkCallback = std::function; + + /// Callback invoked when the interface obtains an IPv4 address. + /// \note Runs in the ESP-IDF event-loop task context — return quickly, do not block. + using EthernetIpCallback = std::function; + + /// DHCP operating mode for the Ethernet interface. + enum class DhcpMode { + CLIENT, ///< DHCP client — acquire an IP from an upstream server (default). + SERVER, ///< DHCP server — assign IPs to hosts connected to this interface. + }; + + /// Static IP configuration used when operating as a DHCP server. + /// Leave \c ip_info zero-initialised to use the built-in defaults + /// (192.168.4.1 / 255.255.255.0 / gw 192.168.4.1). + struct ServerConfig { + esp_netif_ip_info_t ip_info{}; ///< zero-initialised → 192.168.4.1/24 + client_ip_callback_t on_client_assigned{nullptr}; ///< Called for each assigned client IP. + }; + + /// Configuration for the Ethernet interface. + struct EthernetConfig { + DhcpMode mode{DhcpMode::CLIENT}; ///< DHCP operating mode. + ServerConfig server_config{}; ///< Only used when mode == SERVER. + EthernetLinkCallback on_link_up{nullptr}; ///< Physical link came up. + EthernetLinkCallback on_link_down{nullptr}; ///< Physical link went down. + EthernetIpCallback on_got_ip{nullptr}; ///< Interface obtained an IPv4 address. + EthernetLinkCallback on_lost_ip{nullptr}; ///< Interface lost its IPv4 address. + }; + + /// @brief Access the singleton instance. + static Esp32P4Wifi6DevKit &get() { + static Esp32P4Wifi6DevKit instance; + return instance; + } + + Esp32P4Wifi6DevKit(const Esp32P4Wifi6DevKit &) = delete; + Esp32P4Wifi6DevKit &operator=(const Esp32P4Wifi6DevKit &) = delete; + Esp32P4Wifi6DevKit(Esp32P4Wifi6DevKit &&) = delete; + Esp32P4Wifi6DevKit &operator=(Esp32P4Wifi6DevKit &&) = delete; + + /// Alias for the pixel type used by the display + using Pixel = lv_color16_t; + + /// Alias for the low-level display driver interface + using DisplayDriver = espp::display_drivers::Controller; + + /// Alias for the GT911 touch controller + using TouchDriver = espp::Gt911; + + /// Alias for the touchpad data + using TouchpadData = espp::TouchpadData; + + /// Alias for the touch callback when touch events are received + using touch_callback_t = std::function; + + /// Enum for the display controller type (selected via Kconfig) + enum class DisplayController { UNKNOWN, EK79007, ILI9881C, JD9365 }; + + /// Default touch INT GPIO used by initialize_touch(). GPIO_NUM_NC means the + /// GT911 is polled; if interrupt-driven touch is enabled via Kconfig this is + /// the configured GPIO (CONFIG_ESP32_P4_WIFI6_DEV_KIT_TOUCH_INTERRUPT_GPIO). +#if CONFIG_ESP32_P4_WIFI6_DEV_KIT_TOUCH_INTERRUPT + static constexpr gpio_num_t touch_interrupt_default = + static_cast(CONFIG_ESP32_P4_WIFI6_DEV_KIT_TOUCH_INTERRUPT_GPIO); +#else + static constexpr gpio_num_t touch_interrupt_default = GPIO_NUM_NC; +#endif + + ///////////////////////////////////////////////////////////////////////////// + // ESP32-C6 (Wi-Fi 6 / BT5 co-processor, ESP-Hosted over SDIO) + ///////////////////////////////////////////////////////////////////////////// + // The BSP does not bring up Wi-Fi itself; add the espressif/esp_hosted and + // espressif/esp_wifi_remote managed components and use the standard esp_wifi + // API (see the example README). These constants document the board wiring and + // match the esp_hosted SDIO defaults for the ESP32-P4. + static constexpr gpio_num_t c6_sdio_clk_io = GPIO_NUM_18; ///< SDIO CLK to the ESP32-C6 + static constexpr gpio_num_t c6_sdio_cmd_io = GPIO_NUM_19; ///< SDIO CMD to the ESP32-C6 + static constexpr gpio_num_t c6_sdio_d0_io = GPIO_NUM_14; ///< SDIO D0 to the ESP32-C6 + static constexpr gpio_num_t c6_sdio_d1_io = GPIO_NUM_15; ///< SDIO D1 to the ESP32-C6 + static constexpr gpio_num_t c6_sdio_d2_io = GPIO_NUM_16; ///< SDIO D2 to the ESP32-C6 + static constexpr gpio_num_t c6_sdio_d3_io = GPIO_NUM_17; ///< SDIO D3 to the ESP32-C6 + static constexpr gpio_num_t c6_reset_io = GPIO_NUM_54; ///< ESP32-C6 CHIP_PU (reset) + + /// Get a reference to the internal I2C bus + /// \return A reference to the internal I2C bus + /// \note Shared by the ES8311 audio codec, the GT911 touch controller, and + /// (on this pinout) the camera SCCB + I2c &internal_i2c() { return internal_i2c_; } + + /// Get a reference to the interrupts + /// \return A reference to the interrupts + espp::Interrupt &interrupts() { return interrupts_; } + + /// Get the display controller type for the configured panel + /// \return The display controller type + DisplayController get_display_controller() const { return display_controller_; } + + /// Get a string name for the configured display controller + /// \return String name of the controller + const char *get_display_controller_name() const { + switch (display_controller_) { + case DisplayController::EK79007: + return "EK79007"; + case DisplayController::ILI9881C: + return "ILI9881C"; + case DisplayController::JD9365: + return "JD9365"; + default: + return "Unknown"; + } + } + + ///////////////////////////////////////////////////////////////////////////// + // Display & Touchpad + ///////////////////////////////////////////////////////////////////////////// + + /// Initialize the LCD (MIPI-DSI + configured panel driver) + /// \return true if the LCD was successfully initialized, false otherwise + bool initialize_lcd(); + + /// Initialize the LVGL display + /// \param pixel_buffer_size The size of the pixel buffer, in pixels. If 0, a + /// default based on the configured panel width is used. + /// \return true if the display was successfully initialized, false otherwise + /// \note The LVGL display (and its draw / rotation buffers) is created + /// exactly once. Once this has succeeded, a GUI task may be calling + /// the flush callback concurrently, so subsequent calls do not resize + /// or reallocate anything: they warn and return true. To change the + /// pixel buffer size, do so before (i.e. on) the first call. + bool initialize_display(size_t pixel_buffer_size = 0); + + /// Initialize the GT911 multi-touch controller + /// \param callback The touchpad callback + /// \param interrupt_pin GPIO wired to the GT911 touch INT pin. If GPIO_NUM_NC + /// (the default), the GT911 is polled in a task. If a valid GPIO is + /// provided, touch is read from a GPIO interrupt on that pin instead. + /// \return true if the touchpad was successfully initialized, false otherwise + /// \note On the ESP32-P4-WIFI6-DEV-KIT the GT911 reset and INT pins are not routed to + /// the ESP32-P4, so touch is polled by default. + bool initialize_touch(const touch_callback_t &callback = nullptr, + gpio_num_t interrupt_pin = touch_interrupt_default); + + /// Get the number of bytes per pixel for the display + /// \return The number of bytes per pixel + size_t bytes_per_pixel() const { return sizeof(Pixel); } + + /// Get the touchpad input + /// \return A shared pointer to the touchpad input + std::shared_ptr touchpad_input() const { return touchpad_input_; } + + /// Get the most recent touchpad data + /// \return The touchpad data + TouchpadData touchpad_data() const { + std::lock_guard lock(touchpad_data_mutex_); + return touchpad_data_; + } + + /// Get the touchpad data for LVGL integration + /// \param num_touch_points The number of touch points + /// \param x The x coordinate + /// \param y The y coordinate + /// \param btn_state The button state (0 = released, 1 = pressed) + void touchpad_read(uint8_t *num_touch_points, uint16_t *x, uint16_t *y, uint8_t *btn_state); + + /// Convert touchpad data from raw reading to display coordinates + /// \param data The touchpad data to convert + /// \return The converted touchpad data + TouchpadData touchpad_convert(const TouchpadData &data) const; + + /// Set the display brightness + /// \param brightness The brightness as a percentage (0-100) + /// \note The ESP32-P4-WIFI6-DEV-KIT has no backlight GPIO. On the 10.1" JD9365 panel + /// the backlight is driven by an on-board I2C controller (addr 0x45) + /// and this call writes it; on other panels the value is stored but + /// not applied to hardware (see the source for details). + void brightness(float brightness); + + /// Get the display brightness + /// \return The brightness as a percentage (0-100) + float brightness() const; + + /// Get the display width in pixels (of the configured panel) + /// \return The display width in pixels + size_t display_width() const { return display_width_; } + + /// Get the display height in pixels (of the configured panel) + /// \return The display height in pixels + size_t display_height() const { return display_height_; } + + /// Get 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 + size_t rotated_display_height() 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_; } + + /// Write lines to the LCD + /// \note This method queues the panel transfer asynchronously. + void write_lcd_lines(int xs, int ys, int xe, int ye, const uint8_t *data, uint32_t user_data); + + /// Initialize the Ethernet interface (EMAC + IP101GRI RMII PHY). + /// \param config Ethernet configuration (DHCP mode, callbacks). All fields + /// have defaults, so \c EthernetConfig{} gives a plain DHCP client. + /// \return True if Ethernet was successfully initialized and started. + bool initialize_ethernet(const EthernetConfig &config); + + /// Initialize Ethernet with default configuration (DHCP client). + /// \return True if Ethernet was successfully initialized and started. + bool initialize_ethernet(); + + /// \return True if the interface is connected with a valid IP. + bool is_ethernet_connected() const { return ethernet_ && ethernet_->is_connected(); } + + /// \return The most recently acquired IPv4 address (0 if none). + esp_ip4_addr_t ethernet_ip() const { return ethernet_ ? ethernet_->ip() : esp_ip4_addr_t{}; } + + ///////////////////////////////////////////////////////////////////////////// + // Audio System (ES8311 + NS4150B) + ///////////////////////////////////////////////////////////////////////////// + + /// Initialize the audio system (ES8311 codec) + /// \param sample_rate The audio sample rate in Hz (default 48kHz) + /// \param task_config The task configuration for the audio task + /// \return true if the audio system was successfully initialized + bool initialize_audio(uint32_t sample_rate = 48000, + const espp::Task::BaseConfig &task_config = {.name = "p4_wifi6_audio", + .stack_size_bytes = 8192, + .priority = 20, + .core_id = 0}); + + /// Enable or disable the speaker amplifier (NS4150B PA on GPIO53) + /// \param enable True to enable the amplifier, false to disable + void set_speaker_enabled(bool enable); + + /// Set the audio volume + /// \param volume The volume as a percentage (0-100) + /// \note Safe to call before initialize_audio(): the value is cached and + /// applied to the codec during initialization. + void volume(float volume); + + /// Get the audio volume + /// \return The volume as a percentage (0-100) + float volume() const; + + /// Mute or unmute the audio + /// \param mute True to mute, false to unmute + /// \note Safe to call before initialize_audio(): the value is cached and + /// applied to the codec during initialization. + void mute(bool mute); + + /// Check if audio is muted + /// \return True if muted, false otherwise + bool is_muted() const; + + /// 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, in bytes + /// \return The audio buffer size, in bytes + size_t audio_buffer_size() const; + + /// Play audio data + /// \param data The audio data to play (16-bit signed mono samples) + /// \param num_bytes The number of bytes to play + /// \return The number of bytes actually queued (may be less than \p + /// num_bytes if the internal stream buffer is full) + /// \note This function is non-blocking and queues the data for the audio + /// task to play; to stream data larger than the internal buffer, + /// call it repeatedly, advancing by the returned number of bytes + /// \note Must be called from task context, not from an ISR. + size_t play_audio(const uint8_t *data, uint32_t num_bytes); + + /// Play audio data + /// \param data The audio data to play (16-bit signed mono samples) + /// \return The number of bytes actually queued (may be less than the data + /// size if the internal stream buffer is full) + /// \note This function is non-blocking and queues the data for the audio + /// task to play; to stream data larger than the internal buffer, + /// call it repeatedly, advancing by the returned number of bytes + /// \note Must be called from task context, not from an ISR. + size_t play_audio(std::span data); + + /// Drop any queued (not yet played) audio so a subsequent play_audio() starts + /// immediately instead of waiting behind previously queued sound. Useful for + /// UI sounds where a new event should restart the sound for maximum + /// responsiveness. + void clear_audio(); + + ///////////////////////////////////////////////////////////////////////////// + // Microphone + ///////////////////////////////////////////////////////////////////////////// + + /// Alias for the microphone callback, called with recorded audio data + using microphone_callback_t = std::function; + + /// Initialize the microphone (the onboard analog microphone through the + /// ES8311 codec's ADC) and start delivering audio data to the provided + /// callback + /// \param callback The callback to call with recorded audio data (16-bit + /// signed mono samples at audio_sample_rate()) + /// \param task_config The configuration for the microphone task + /// \return true if the microphone was successfully initialized, false + /// otherwise + /// \note The audio subsystem must be initialized first (the ES8311 is a + /// full-duplex codec on a single I2S bus, so the microphone records + /// at the speaker's sample rate) + /// \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 + bool initialize_microphone(const microphone_callback_t &callback, + const espp::Task::BaseConfig &task_config = {.name = "microphone", + .stack_size_bytes = 4096, + .priority = 10, + .core_id = 1}); + + /// Set the microphone volume + /// \param volume The volume as a percentage (0 - 100), mapped onto the + /// ES8311 analog microphone gain range (0 dB - +42 dB) + void microphone_volume(float volume); + + /// Get the microphone volume + /// \return The microphone volume as a percentage (0 - 100) + float microphone_volume() const; + + ///////////////////////////////////////////////////////////////////////////// + // Camera (MIPI-CSI, OV5647) + ///////////////////////////////////////////////////////////////////////////// + + /// Alias for the camera frame callback. Called from the camera task with each + /// captured frame: \p data is the pixel buffer (RGB565, \p width x \p height), + /// valid only for the duration of the callback, and \p length is its size in + /// bytes. Copy the data if it needs to outlive the call. + using camera_frame_callback_t = + std::function; + + /// Initialize the on-board MIPI-CSI camera and start streaming frames. + /// + /// Brings up the ESP32-P4 camera pipeline (MIPI-CSI receiver + ISP + sensor) + /// through esp_video (V4L2) and starts a task that delivers each captured + /// RGB565 frame to \p callback. The camera sensor's SCCB shares the internal + /// I2C bus (SDA=7/SCL=8), so no second I2C master is created on those pins. + /// + /// \param callback Function called from the camera task with each RGB565 + /// frame (see camera_frame_callback_t). Keep it quick and non-blocking. + /// \param task_config The configuration for the camera task + /// \return true if the camera was successfully initialized and streaming + /// \note The camera reset / power-down lines are not routed to the ESP32-P4 on + /// this board (RPi-style CSI connector); the sensor free-runs (esp_video + /// handles CSI/ISP/LDO). Unlike the M5Stack Tab5 there is no IO expander + /// to pulse the camera reset. The callback runs in the camera task's + /// context. + bool initialize_camera(const camera_frame_callback_t &callback, + const espp::Task::BaseConfig &task_config = {.name = "p4_wifi6_camera", + .stack_size_bytes = 6144, + .priority = 5, + .core_id = 0}); + + /// Stop the camera stream and release the camera pipeline. + /// \note Safe to call from any context, including from within the camera + /// frame callback itself. In that case the pipeline is torn down + /// immediately - so the frame's \c data pointer must not be touched + /// after this returns - and the camera task exits on its own right + /// after the callback returns instead of being joined (a join from + /// the task itself would deadlock). + void stop_camera(); + + /// Get the width of the captured camera frames, in pixels + /// \return The camera frame width (0 if the camera is not initialized) + uint16_t camera_width() const; + + /// Get the height of the captured camera frames, in pixels + /// \return The camera frame height (0 if the camera is not initialized) + uint16_t camera_height() const; + + ///////////////////////////////////////////////////////////////////////////// + // uSD / TF Card (4-bit SDMMC) + ///////////////////////////////////////////////////////////////////////////// + + /// Mount point for the uSD card filesystem. + static constexpr char mount_point[] = "/sdcard"; + + /// Configuration for the uSD card. + struct SdCardConfig { + bool format_if_mount_failed = false; ///< Format the card if the mount fails. + int max_files = 5; ///< Maximum number of open files. + size_t allocation_unit_size = 2 * 1024; ///< FAT allocation unit size in bytes. + }; + + /// Initialize the microSD / TF card (4-bit SDMMC, powered by the on-chip LDO). + /// \param config Configuration for the uSD card. + /// \return True if the card was successfully mounted at \c mount_point. + bool initialize_sdcard(const SdCardConfig &config); + + /// \return True if the SD card is present and mounted. + bool is_sd_card_available() const { return sd_card_initialized_; } + + /// \return The SDMMC card handle, or nullptr if not initialized. + sdmmc_card_t *sdcard() const { return sdcard_; } + + /// Get total/free space of the mounted card. + /// \param size_mb Optional out: total size in MB. + /// \param free_mb Optional out: free space in MB. + /// \return True if the info was retrieved. + bool get_sd_card_info(uint32_t *size_mb, uint32_t *free_mb) const; + +protected: + Esp32P4Wifi6DevKit(); + + 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 update_touch(); + + ///////////////////////////////////////////////////////////////////////////// + // Display geometry / per-panel parameters + ///////////////////////////////////////////////////////////////////////////// + // Per-panel parameters (geometry, DPI clock, and the DPI video timing porches). + // The active panel is selected via Kconfig. On the ESP32-P4-WIFI6-DEV-KIT all + // supported panels are reset over DSI (no reset GPIO) and none has a backlight + // GPIO (the backlight is driven by an on-board I2C controller), so + // backlight_io and reset_io are GPIO_NUM_NC for all of them. + struct PanelParams { + size_t width; + size_t height; + int dpi_clock_freq_mhz; + int lane_bitrate_mbps; + gpio_num_t backlight_io; + gpio_num_t reset_io; + int hsync_pulse_width, hsync_back_porch, hsync_front_porch; + int vsync_pulse_width, vsync_back_porch, vsync_front_porch; + }; + // EK79007 7" 1024x600 (reset over DSI, no backlight GPIO) + static constexpr PanelParams EK79007_PARAMS{1024, 600, 52, 900, GPIO_NUM_NC, GPIO_NUM_NC, + 10, 160, 160, 1, 23, 12}; + // ILI9881C 10.1" 800x1280 (hsync: pulse=40, back=140, front=40; reset over DSI) + static constexpr PanelParams ILI9881C_PARAMS{800, 1280, 80, 1500, GPIO_NUM_NC, GPIO_NUM_NC, + 40, 140, 40, 4, 16, 16}; + // JD9365 10.1" 800x1280 (the panel Waveshare sells for this board; reset over + // DSI, no backlight GPIO). Timing matches Waveshare's + // JD9365_800_1280_PANEL_60HZ_DPI_CONFIG vendor timing. + static constexpr PanelParams JD9365_PARAMS{800, 1280, 80, 1500, GPIO_NUM_NC, GPIO_NUM_NC, + 20, 20, 40, 4, 10, 30}; + +#if CONFIG_ESP32_P4_WIFI6_DEV_KIT_DISPLAY_EK79007 + static constexpr DisplayController default_controller_ = DisplayController::EK79007; +#elif CONFIG_ESP32_P4_WIFI6_DEV_KIT_DISPLAY_ILI9881C + static constexpr DisplayController default_controller_ = DisplayController::ILI9881C; +#else + static constexpr DisplayController default_controller_ = DisplayController::JD9365; +#endif + + // Runtime display geometry, set from the configured panel. + PanelParams panel_params_{default_controller_ == DisplayController::ILI9881C ? ILI9881C_PARAMS + : default_controller_ == DisplayController::EK79007 ? EK79007_PARAMS + : JD9365_PARAMS}; + size_t display_width_{panel_params_.width}; + size_t display_height_{panel_params_.height}; + + /// Apply the parameters (geometry/timing) for the given controller. + void apply_panel_params(DisplayController controller); + + // MIPI-DSI common parameters. The ESP32-P4-WIFI6-DEV-KIT wires 2 DSI data lanes and + // powers the DSI PHY from the on-chip LDO channel 3 (VDD_MIPI_DPHY) at 2500 mV. + static constexpr int mipi_dsi_lanes = 2; + // DSI HS lane bit rate (Mbps/lane). 900 Mbps matches Espressif's official + // panel bus configs; using the wrong rate mis-packs the pixel bits on the link. + static constexpr int mipi_dsi_phy_ldo_channel = 3; // on-chip LDO_VO3 -> VDD_MIPI_DPHY + static constexpr int mipi_dsi_phy_ldo_voltage_mv = 2500; + + static constexpr bool invert_colors = false; + static constexpr auto rotation = espp::DisplayRotation::LANDSCAPE; + static constexpr bool swap_color_order = false; + // Panel is used in its native orientation; no display mirror/swap. + static constexpr bool mirror_x = false; + static constexpr bool mirror_y = false; + static constexpr bool swap_xy = false; + // touch -> display coordinate conversion. May need tuning per panel. + static constexpr bool touch_swap_xy = false; + static constexpr bool touch_invert_x = false; + static constexpr bool touch_invert_y = false; + + // Touch (GT911) - interrupt/reset are NOT connected on this board + static constexpr uint8_t gt911_default_address = 0x5D; + static constexpr uint8_t gt911_backup_address = 0x14; + + // RMII pin mapping for the ESP32-P4-WIFI6-DEV-KIT (IP101GRI PHY). + static constexpr int eth_mdc_io = 31; + static constexpr int eth_mdio_io = 52; + static constexpr int eth_ref_clk_io = 50; + static constexpr int eth_phy_reset_gpio = 51; + static constexpr int eth_phy_addr = 1; + static constexpr int eth_tx_en_io = 49; + static constexpr int eth_txd0_io = 34; + static constexpr int eth_txd1_io = 35; + static constexpr int eth_crs_dv_io = 28; + static constexpr int eth_rxd0_io = 29; + static constexpr int eth_rxd1_io = 30; + + // The board's RMII Ethernet is driven by the reusable espp::Ethernet component. + std::unique_ptr ethernet_; + + ///////////////////////////////////////////////////////////////////////////// + // Internal I2C bus (ES8311 codec 0x18; shared with display touch / camera SCCB) + ///////////////////////////////////////////////////////////////////////////// + static constexpr auto internal_i2c_port = I2C_NUM_0; + static constexpr auto internal_i2c_clock_speed = 400 * 1000; + static constexpr gpio_num_t internal_i2c_sda = GPIO_NUM_7; + static constexpr gpio_num_t internal_i2c_scl = GPIO_NUM_8; + + ///////////////////////////////////////////////////////////////////////////// + // Audio (ES8311 + NS4150B), I2S peripheral + ///////////////////////////////////////////////////////////////////////////// + static constexpr uint8_t es8311_i2c_address = 0x18; + static constexpr auto audio_i2s_port = I2S_NUM_0; + static constexpr gpio_num_t audio_mclk_io = GPIO_NUM_13; // MCLK + static constexpr gpio_num_t audio_sclk_io = GPIO_NUM_12; // BCLK + static constexpr gpio_num_t audio_lrck_io = GPIO_NUM_10; // WS/LRCK + static constexpr gpio_num_t audio_dout_io = GPIO_NUM_9; // P4 -> codec DSDIN + static constexpr gpio_num_t audio_din_io = GPIO_NUM_11; // codec ASDOUT -> P4 + static constexpr gpio_num_t audio_pa_enable_io = GPIO_NUM_53; // NS4150B enable + + // Audio buffer sizing: one UPDATE_FREQUENCY period's worth of 16-bit frames. + // The TX (speaker) path is configured mono (I2S_SLOT_MODE_MONO in + // initialize_audio()) while the RX (microphone) path captures both slots as + // stereo (see initialize_microphone()), so the channel count of the path + // being sized is passed explicitly rather than hard-coded. + static constexpr int TX_NUM_CHANNELS = 1; // TX slot mode is mono + static constexpr int RX_NUM_CHANNELS = 2; // RX captures both (L,R) slots + static constexpr int NUM_BYTES_PER_CHANNEL = 2; // 16-bit samples + static constexpr int UPDATE_FREQUENCY = 60; + static constexpr int calc_audio_buffer_size(int sample_rate, int num_channels) { + // NOTE: divide the rate by the update frequency FIRST so the result is + // always a whole number of frames. Multiplying first yields a partial + // frame for rates that are not a multiple of the update frequency (e.g. + // 22.05 kHz mono -> 735 bytes, half a sample), and reading/writing partial + // samples shifts the I2S sample framing on every transfer - heard as loud + // static. + return (sample_rate / UPDATE_FREQUENCY) * num_channels * NUM_BYTES_PER_CHANNEL; + } + + // Internal I2C bus (shared by the ES8311 codec) + I2c internal_i2c_{{.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}}; + + // Audio + std::atomic audio_initialized_{false}; + std::atomic volume_{50.0f}; + std::atomic mute_{false}; + std::shared_ptr> es8311_i2c_device_; + std::unique_ptr audio_task_{nullptr}; + i2s_chan_handle_t audio_tx_handle{nullptr}; + // Serializes TX-channel access: audio_task_callback() writes continuously + // while audio_sample_rate(rate) may disable/reconfigure the channel. + std::mutex audio_tx_mutex_; + i2s_std_config_t audio_std_cfg{}; + i2s_event_callbacks_t audio_tx_callbacks_{}; + std::vector audio_tx_buffer; + StreamBufferHandle_t audio_tx_stream{nullptr}; + std::atomic has_sound{false}; + + // Microphone (ES8311 ADC, full duplex with the speaker) + 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}; + // True once i2s_channel_init_std_mode() has run on the RX channel. It may + // only be called once per channel, so a failed initialize_microphone() + // attempt (enable / task start) must not re-run it on retry. + bool audio_rx_std_configured_{false}; + std::vector audio_rx_buffer; + // microphone volume (percent), mapped onto the ES8311 analog gain range + std::atomic mic_volume_{70.0f}; + + // microSD (4-bit SDMMC, slot 0, fixed IO-MUX pins). Powered via on-chip LDO_VO4. + static constexpr int sd_ldo_channel = 4; + static constexpr gpio_num_t sd_clk_io = GPIO_NUM_43; + static constexpr gpio_num_t sd_cmd_io = GPIO_NUM_44; + static constexpr gpio_num_t sd_d0_io = GPIO_NUM_39; + static constexpr gpio_num_t sd_d1_io = GPIO_NUM_40; + static constexpr gpio_num_t sd_d2_io = GPIO_NUM_41; + static constexpr gpio_num_t sd_d3_io = GPIO_NUM_42; + + std::atomic sd_card_initialized_{false}; + sdmmc_card_t *sdcard_{nullptr}; + // SD power-control driver (on-chip LDO). Owned by this class: created in + // initialize_sdcard() and deleted there (sd_pwr_ctrl_del_on_chip_ldo) if the + // mount fails; a successful mount keeps it alive for the life of the card. + sd_pwr_ctrl_handle_t sd_pwr_ctrl_handle_{nullptr}; + + ///////////////////////////////////////////////////////////////////////////// + // Interrupts (used by the optional interrupt-driven touch path) + ///////////////////////////////////////////////////////////////////////////// + espp::Interrupt interrupts_{ + {.interrupts = {}, + .task_config = {.name = "p4-wifi6 interrupts", + .stack_size_bytes = CONFIG_ESP32_P4_WIFI6_DEV_KIT_INTERRUPT_STACK_SIZE, + .priority = CONFIG_ESP32_P4_WIFI6_DEV_KIT_INTERRUPT_PRIORITY, + .core_id = CONFIG_ESP32_P4_WIFI6_DEV_KIT_INTERRUPT_CORE_ID}}}; + + ///////////////////////////////////////////////////////////////////////////// + // Touch (GT911) - shares internal_i2c_ + ///////////////////////////////////////////////////////////////////////////// + std::shared_ptr> touch_i2c_device_; + std::shared_ptr touch_driver_; + std::shared_ptr touchpad_input_; + mutable std::recursive_mutex touchpad_data_mutex_; + TouchpadData touchpad_data_; + touch_callback_t touch_callback_{nullptr}; + std::unique_ptr touch_task_{nullptr}; + + ///////////////////////////////////////////////////////////////////////////// + // Display state (MIPI-DSI). NOTE: there is no backlight GPIO / espp::Led on + // this board; the backlight is driven by an on-board I2C controller. On the + // 10.1" JD9365 panel brightness() writes that controller (addr 0x45, reg + // 0x96); on other panels the stored brightness is best-effort only (see + // src/video.cpp). + ///////////////////////////////////////////////////////////////////////////// + // On-board I2C backlight controller (10.1" JD9365 panel) + static constexpr uint8_t backlight_i2c_address = 0x45; + std::shared_ptr> backlight_i2c_device_; + std::atomic brightness_{100.0f}; + std::shared_ptr> display_; + // Rotation scratch buffer used by flush(). Allocated exactly once, in + // initialize_display() BEFORE display_ is created (and therefore before any + // GUI/LVGL task can invoke flush()), and never freed or reallocated after + // that, so flush() may read it without locking (see initialize_display()'s + // re-initialization contract). + uint16_t *rotation_buffer_{nullptr}; + size_t rotation_buffer_px_{0}; // allocated capacity of rotation_buffer_, in pixels + std::shared_ptr display_driver_{static_cast(nullptr)}; + struct LcdHandles { + esp_lcd_dsi_bus_handle_t mipi_dsi_bus{nullptr}; + esp_lcd_panel_io_handle_t io{nullptr}; + esp_lcd_panel_handle_t panel{nullptr}; + } lcd_handles_{}; + // Guards against a second initialize_lcd(): once the DPI panel is + // streaming, the vendor-command channel can no longer drain (see the + // note in video.cpp), so re-running init would hang. Idempotent. + bool lcd_initialized_{false}; + // The configured controller is known from Kconfig at construction, so start + // from default_controller_ (not UNKNOWN): get_display_controller()/ + // get_display_controller_name() honor their "configured panel" contract even + // before initialize_lcd() runs apply_panel_params(). + DisplayController display_controller_{default_controller_}; + + ///////////////////////////////////////////////////////////////////////////// + // Camera (MIPI-CSI via esp_video / V4L2). Sensor SCCB shares internal_i2c_. + ///////////////////////////////////////////////////////////////////////////// + bool camera_task_callback(std::mutex &m, std::condition_variable &cv, bool &task_notified); + // Tear down the capture pipeline (STREAMOFF, munmap, close, esp_video_deinit) + // and reset the camera state. Idempotent; does NOT touch camera_task_, so it + // is safe to call from the camera task itself on a fatal capture error + // (Task::stop() there would self-join). + void teardown_camera_pipeline(); + std::atomic camera_initialized_{false}; + camera_frame_callback_t camera_callback_{nullptr}; + std::unique_ptr camera_task_{nullptr}; + // Set by stop_camera() when it is invoked from the camera task itself (from + // the frame callback): the task must then exit on its own (checked and + // cleared in camera_task_callback right after the callback returns) instead + // of being joined, which would self-join. + std::atomic camera_stop_requested_{false}; + int camera_fd_{-1}; // MIPI-CSI capture device (/dev/video0) + bool camera_video_inited_{false}; // esp_video_init() succeeded (needs deinit) + // Frame dimensions are atomic: they are written by the owner thread in + // initialize_camera() and cleared by teardown_camera_pipeline() (which can + // run on the camera task after a fatal capture error) while other threads + // read them via camera_width()/camera_height(). + std::atomic camera_width_{0}; + std::atomic camera_height_{0}; + static constexpr int CAMERA_BUFFER_COUNT = 2; + void *camera_buffers_[CAMERA_BUFFER_COUNT]{nullptr, nullptr}; + size_t camera_buffer_sizes_[CAMERA_BUFFER_COUNT]{0, 0}; + int camera_buffer_count_{0}; // buffers VIDIOC_REQBUFS actually allocated + + void flush(lv_display_t *disp, const lv_area_t *area, uint8_t *px_map); + // Rotation of the BSP-managed LVGL display (NOT lv_display_get_default(), + // which may be a different display if the app created its own); falls back to + // the configured default rotation before initialize_display(). + lv_display_rotation_t current_display_rotation() const; + static bool notify_lvgl_flush_ready(esp_lcd_panel_handle_t panel, + esp_lcd_dpi_panel_event_data_t *edata, void *user_ctx); + + // DSI command helpers (used by the panel drivers) + void dsi_write_command(uint8_t cmd, std::span params, uint32_t flags); + void dsi_read_command(uint8_t cmd, std::span data, uint32_t flags); +}; // class Esp32P4Wifi6DevKit +} // namespace espp diff --git a/components/esp32-p4-wifi6-dev-kit/src/audio.cpp b/components/esp32-p4-wifi6-dev-kit/src/audio.cpp new file mode 100644 index 000000000..1d410ae28 --- /dev/null +++ b/components/esp32-p4-wifi6-dev-kit/src/audio.cpp @@ -0,0 +1,488 @@ +#include "esp32-p4-wifi6-dev-kit.hpp" + +#include +#include +#include + +#include + +#include "es8311.hpp" + +namespace espp { + +// Map a requested sample rate onto the ES8311 HAL's supported-rates enum. +// Returns false for rates the codec driver does not support. +static bool es8311_samples_from_rate(uint32_t sample_rate, audio_hal_iface_samples_t &out_samples) { + switch (sample_rate) { + case 8000: + out_samples = AUDIO_HAL_08K_SAMPLES; + return true; + case 11025: + out_samples = AUDIO_HAL_11K_SAMPLES; + return true; + case 16000: + out_samples = AUDIO_HAL_16K_SAMPLES; + return true; + case 22050: + out_samples = AUDIO_HAL_22K_SAMPLES; + return true; + case 24000: + out_samples = AUDIO_HAL_24K_SAMPLES; + return true; + case 32000: + out_samples = AUDIO_HAL_32K_SAMPLES; + return true; + case 44100: + out_samples = AUDIO_HAL_44K_SAMPLES; + return true; + case 48000: + out_samples = AUDIO_HAL_48K_SAMPLES; + return true; + default: + return false; + } +} + +bool Esp32P4Wifi6DevKit::initialize_audio(uint32_t sample_rate, + const espp::Task::BaseConfig &task_config) { + logger_.info("Initializing audio (ES8311) at {} Hz", sample_rate); + + if (audio_initialized_) { + logger_.warn("Audio already initialized"); + return true; + } + + // Validate the requested rate up front so the codec's initial clock config + // (below) matches the I2S clock instead of silently assuming 48 kHz. + audio_hal_iface_samples_t es8311_samples; + if (!es8311_samples_from_rate(sample_rate, es8311_samples)) { + logger_.error("Unsupported audio sample rate {} Hz; supported rates: 8000, 11025, 16000, " + "22050, 24000, 32000, 44100, 48000", + sample_rate); + return false; + } + + // Configure the speaker-amplifier (NS4150B) enable GPIO + gpio_config_t pa_cfg{}; + pa_cfg.pin_bit_mask = 1ULL << static_cast(audio_pa_enable_io); + pa_cfg.mode = GPIO_MODE_OUTPUT; + gpio_config(&pa_cfg); + set_speaker_enabled(false); + + std::error_code ec; + es8311_i2c_device_ = internal_i2c_.add_device( + { + .device_address = es8311_i2c_address, + .timeout_ms = static_cast(internal_i2c_.config().timeout_ms), + .scl_speed_hz = internal_i2c_.config().clk_speed, + .log_level = espp::Logger::Verbosity::WARN, + }, + ec); + if (!es8311_i2c_device_) { + logger_.error("Could not initialize ES8311 I2C device: {}", ec.message()); + return false; + } + + // Wire codec register access over the internal I2C bus + set_es8311_write(espp::make_i2c_addressed_write(es8311_i2c_device_)); + set_es8311_read(espp::make_i2c_addressed_read_register(es8311_i2c_device_)); + + // Create the I2S standard channels: TX for playback, RX for the ES8311's + // ADC (initialized on demand by initialize_microphone(); the codec is full + // duplex on this single bus, sharing the clock). MCLK = 256 * fs (default). + i2s_chan_config_t chan_cfg = I2S_CHANNEL_DEFAULT_CONFIG(audio_i2s_port, I2S_ROLE_MASTER); + chan_cfg.auto_clear = true; + if (i2s_new_channel(&chan_cfg, &audio_tx_handle, &audio_rx_handle) != ESP_OK) { + logger_.error("Failed to create I2S channel"); + return false; + } + + // Consolidated teardown for the failure paths below: i2s_new_channel() creates + // both channels together, so on any later failure delete both (and the stream + // buffer if created) and reset state rather than leaking channels/clocks. + auto fail_audio_init = [&](const char *msg) -> bool { + logger_.error("{}", msg); + if (audio_tx_handle) { + i2s_channel_disable(audio_tx_handle); + i2s_del_channel(audio_tx_handle); + audio_tx_handle = nullptr; + } + if (audio_rx_handle) { + i2s_channel_disable(audio_rx_handle); + i2s_del_channel(audio_rx_handle); + audio_rx_handle = nullptr; + } + if (audio_tx_stream) { + vStreamBufferDelete(audio_tx_stream); + audio_tx_stream = nullptr; + } + return false; + }; + + audio_std_cfg = { + .clk_cfg = I2S_STD_CLK_DEFAULT_CONFIG(sample_rate), + .slot_cfg = I2S_STD_PHILIP_SLOT_DEFAULT_CONFIG(I2S_DATA_BIT_WIDTH_16BIT, I2S_SLOT_MODE_MONO), + .gpio_cfg = {.mclk = audio_mclk_io, + .bclk = audio_sclk_io, + .ws = audio_lrck_io, + .dout = audio_dout_io, + .din = audio_din_io, + .invert_flags = {.mclk_inv = false, .bclk_inv = false, .ws_inv = false}}, + }; + if (i2s_channel_init_std_mode(audio_tx_handle, &audio_std_cfg) != ESP_OK) { + return fail_audio_init("Failed to init I2S std mode"); + } + + // Initialize the ES8311 codec for playback (codec is an I2S slave) + audio_hal_codec_config_t es8311_cfg{}; + es8311_cfg.codec_mode = AUDIO_HAL_CODEC_MODE_DECODE; + es8311_cfg.dac_output = AUDIO_HAL_DAC_OUTPUT_ALL; + es8311_cfg.i2s_iface.bits = AUDIO_HAL_BIT_LENGTH_16BITS; + es8311_cfg.i2s_iface.fmt = AUDIO_HAL_I2S_NORMAL; + es8311_cfg.i2s_iface.mode = AUDIO_HAL_MODE_SLAVE; + es8311_cfg.i2s_iface.samples = es8311_samples; + if (es8311_codec_init(&es8311_cfg) != ESP_OK) { + return fail_audio_init("ES8311 init failed"); + } + // The rate was validated above, but propagate a codec failure anyway: if the + // codec clock config is not applied, playback would be unusable even though + // initialization "succeeded". + if (es8311_codec_set_sample_rate(sample_rate) != ESP_OK) { + return fail_audio_init("ES8311 sample-rate configuration failed"); + } + // Apply the cached volume/mute state so values set via volume()/mute() + // before initialization (which only cache) take effect now. + es8311_codec_set_voice_volume(static_cast(volume_)); + es8311_set_voice_mute(mute_); + es8311_codec_ctrl_state(AUDIO_HAL_CODEC_MODE_DECODE, AUDIO_HAL_CTRL_START); + + if (i2s_channel_enable(audio_tx_handle) != ESP_OK) { + return fail_audio_init("Failed to enable I2S channel"); + } + + // The audio task drains this stream buffer to I2S one tx_buf_size chunk + // (one update period of mono frames) at a time, so four periods of producer + // headroom is plenty - the same sizing the esp-box / t-deck / m5stack-tab5 + // BSPs use - with a small floor so very low sample rates keep a workable + // buffer. This memory comes from the FreeRTOS heap (internal RAM), so avoid + // a large fixed floor; play_audio() clamps to the free space and reports + // how much it queued, so callers stream clips longer than the buffer in + // chunks (see the example's playback loop). + auto tx_buf_size = calc_audio_buffer_size(sample_rate, TX_NUM_CHANNELS); + audio_tx_buffer.resize(tx_buf_size); + audio_tx_stream = xStreamBufferCreate(std::max(tx_buf_size * 4, 4 * 1024), 0); + if (audio_tx_stream == nullptr) { + return fail_audio_init("Failed to allocate the audio TX stream buffer"); + } + xStreamBufferReset(audio_tx_stream); + + using namespace std::placeholders; + audio_task_ = espp::Task::make_unique( + {.callback = std::bind(&Esp32P4Wifi6DevKit::audio_task_callback, this, _1, _2, _3), + .task_config = task_config}); + + if (!audio_task_->start()) { + audio_task_.reset(); + return fail_audio_init("Failed to start the audio task"); + } + set_speaker_enabled(true); + audio_initialized_ = true; + return true; +} + +void Esp32P4Wifi6DevKit::set_speaker_enabled(bool enable) { + gpio_set_level(audio_pa_enable_io, enable ? 1 : 0); +} + +void Esp32P4Wifi6DevKit::volume(float volume) { + volume = std::clamp(volume, 0.0f, 100.0f); + volume_ = volume; + // Before initialize_audio() the ES8311 driver has no I2C read/write + // callbacks installed, so touching the codec would invoke null + // std::functions and terminate. Just cache the value; initialize_audio() + // applies it to the codec. + if (!audio_initialized_) { + return; + } + es8311_codec_set_voice_volume(static_cast(volume_)); +} + +float Esp32P4Wifi6DevKit::volume() const { return volume_; } + +void Esp32P4Wifi6DevKit::mute(bool mute) { + mute_ = mute; + // Same pre-initialization guard as volume(): cache only, applied on init. + if (!audio_initialized_) { + return; + } + es8311_set_voice_mute(mute_); +} + +bool Esp32P4Wifi6DevKit::is_muted() const { return mute_; } + +size_t Esp32P4Wifi6DevKit::play_audio(const uint8_t *data, uint32_t num_bytes) { + if (!audio_initialized_ || !data || num_bytes == 0) { + return 0; + } + // Enqueue only whole 16-bit samples (2 bytes; the TX slot is mono) that + // actually fit: xStreamBufferSend can accept fewer bytes than requested when + // the buffer is nearly full, and a partial (odd) send would strand a byte and + // shift framing on subsequent appends. Cap the request to the free space + // rounded down to a whole sample. This runs in task context, so the non-ISR + // send with a 0 timeout never blocks; the number of bytes actually queued is + // returned so callers can stream data larger than the buffer. + size_t sendable = std::min(num_bytes, xStreamBufferSpacesAvailable(audio_tx_stream)); + sendable -= sendable % 2; + if (sendable == 0) { + return 0; + } + return xStreamBufferSend(audio_tx_stream, data, sendable, 0); +} + +void Esp32P4Wifi6DevKit::clear_audio() { + if (!audio_initialized_ || audio_tx_stream == nullptr) { + return; + } + // Drop everything queued but not yet handed to the I2S DMA. The drain task's + // current (at most one) frame still finishes, so this cuts over on the next + // ~16 ms frame boundary. + xStreamBufferReset(audio_tx_stream); +} + +size_t Esp32P4Wifi6DevKit::play_audio(std::span data) { + return play_audio(data.data(), data.size()); +} + +////////////////////////// +// Microphone Functions // +////////////////////////// + +// Map a 0-100% microphone volume onto the ES8311's analog microphone gain +// steps (ES8311_MIC_GAIN_0DB .. ES8311_MIC_GAIN_42DB, 6 dB apart) +static es8311_mic_gain_t microphone_gain_from_volume(float volume) { + int step = static_cast(std::lround(volume / 100.0f * 7.0f)); + step = std::clamp(step, static_cast(ES8311_MIC_GAIN_0DB), + static_cast(ES8311_MIC_GAIN_42DB)); + return static_cast(step); +} + +bool Esp32P4Wifi6DevKit::initialize_microphone(const microphone_callback_t &callback, + const espp::Task::BaseConfig &task_config) { + logger_.info("Initializing microphone"); + if (microphone_initialized_) { + // Idempotent, matching the other initialize_* methods: calling again is + // harmless, so warn and report success. + logger_.warn("Microphone already initialized, not initializing again!"); + return true; + } + if (!audio_initialized_) { + logger_.error("The audio subsystem must be initialized first: the ES8311 is a full-duplex " + "codec on a single I2S bus"); + return false; + } + if (!callback) { + logger_.error("A callback is required to receive the recorded audio data"); + return false; + } + microphone_callback_ = callback; + + // The RX channel shares the TX BCLK/WS in full-duplex mode. Receive in + // stereo (both 16-bit slots of every frame) even though the codec's ADC is + // mono: a mono RX slot configuration in this full-duplex setup does not + // deliver one sample per frame (each sample comes through twice, i.e. at + // half speed); capturing both slots gives a deterministic L,R word layout + // and the microphone task keeps only the left slot, where the ES8311 + // drives its ADC data. + i2s_std_config_t rx_cfg = audio_std_cfg; + rx_cfg.slot_cfg = + I2S_STD_PHILIP_SLOT_DEFAULT_CONFIG(I2S_DATA_BIT_WIDTH_16BIT, I2S_SLOT_MODE_STEREO); + // i2s_channel_init_std_mode() may only be called once per channel (it fails + // on a READY channel), so track whether the RX channel has already been + // configured by an earlier attempt that failed later (enable / task start). + // On such a retry, skip the init and just refresh the clock config in case + // the sample rate changed via audio_sample_rate() in between. + if (!audio_rx_std_configured_) { + if (i2s_channel_init_std_mode(audio_rx_handle, &rx_cfg) != ESP_OK) { + logger_.error("Failed to init I2S RX std mode"); + return false; + } + audio_rx_std_configured_ = true; + } else if (i2s_channel_reconfig_std_clock(audio_rx_handle, &rx_cfg.clk_cfg) != ESP_OK) { + logger_.error("Failed to reconfigure the I2S RX clock"); + return false; + } + // one update period's worth of stereo (L,R) frames + audio_rx_buffer.resize(calc_audio_buffer_size(audio_sample_rate(), RX_NUM_CHANNELS)); + if (i2s_channel_enable(audio_rx_handle) != ESP_OK) { + logger_.error("Failed to enable I2S RX channel"); + return false; + } + + // Enable the codec's ADC path alongside the running DAC and apply the + // stored microphone gain + es8311_codec_ctrl_state(AUDIO_HAL_CODEC_MODE_BOTH, AUDIO_HAL_CTRL_START); + es8311_set_mic_gain(microphone_gain_from_volume(mic_volume_)); + + using namespace std::placeholders; + microphone_task_ = espp::Task::make_unique({ + .callback = std::bind(&Esp32P4Wifi6DevKit::microphone_task_callback, this, _1, _2, _3), + .task_config = task_config, + }); + + if (!microphone_task_->start()) { + // Leave the RX channel configured (audio_rx_std_configured_ stays true): + // disabling it returns it to READY so a later initialize_microphone() + // retry can enable it again. + logger_.error("Could not start the microphone task"); + i2s_channel_disable(audio_rx_handle); + microphone_task_.reset(); + return false; + } + + microphone_initialized_ = true; + return true; +} + +bool Esp32P4Wifi6DevKit::microphone_task_callback(std::mutex &m, std::condition_variable &cv, + bool &task_notified) { + (void)cv; // unused: this task paces itself on the finite-timeout I2S read below + size_t bytes_read = 0; + // Use a finite read timeout (not portMAX_DELAY) so this task returns + // periodically and can observe a stop request; an infinite read would block + // Task::stop() from joining during teardown. + auto err = i2s_channel_read(audio_rx_handle, audio_rx_buffer.data(), audio_rx_buffer.size(), + &bytes_read, pdMS_TO_TICKS(100)); + if (err == ESP_OK && bytes_read > 0 && microphone_callback_) { + // compact the L,R word pairs down to mono in place, keeping the left + // slot (the ES8311's ADC data) + auto *samples = reinterpret_cast(audio_rx_buffer.data()); + size_t num_frames = bytes_read / (2 * sizeof(int16_t)); + for (size_t i = 0; i < num_frames; i++) { + samples[i] = samples[2 * i]; + } + microphone_callback_(audio_rx_buffer.data(), num_frames * sizeof(int16_t)); + } + // honor a stop request per the Task contract: check/clear notified under m + std::unique_lock lock(m); + if (task_notified) { + task_notified = false; + return true; // stop the task + } + return false; // keep running +} + +void Esp32P4Wifi6DevKit::microphone_volume(float volume) { + mic_volume_ = std::clamp(volume, 0.0f, 100.0f); + if (microphone_initialized_) { + es8311_set_mic_gain(microphone_gain_from_volume(mic_volume_)); + } +} + +float Esp32P4Wifi6DevKit::microphone_volume() const { return mic_volume_; } + +bool Esp32P4Wifi6DevKit::audio_task_callback(std::mutex &m, std::condition_variable &cv, + bool &task_notified) { + (void)cv; // unused: this task paces itself on the finite-timeout I2S write below + size_t available = xStreamBufferBytesAvailable(audio_tx_stream); + size_t buffer_size = audio_tx_buffer.size(); + available = std::min(available, buffer_size); + // only ever hand whole 16-bit samples to I2S; a partial sample would shift + // the framing of everything after it + available &= ~static_cast(1); + uint8_t *tx_buf = audio_tx_buffer.data(); + memset(tx_buf, 0, buffer_size); + if (available > 0) { + xStreamBufferReceive(audio_tx_stream, tx_buf, available, 0); + } + // Always write a full, frame-aligned buffer (queued samples zero-padded to + // buffer_size) so the I2S DMA is fed at a constant cadence - matching the + // esp-box / t-deck / m5stack-tab5 playback path. Writing only `available` + // bytes makes the drain cadence variable and interleaves whole frames of + // silence into bursty streams, which sounds choppy/glitchy. + // + // Use a finite write timeout (not portMAX_DELAY) so this task returns + // periodically and can observe a stop request; an infinite write would block + // Task::stop() from joining during teardown if the I2S sink ever stalls. + size_t bytes_written = 0; + esp_err_t err; + { + // Serialize with audio_sample_rate(), which may disable/reconfigure the + // channel; writing to a disabled channel would race the reconfig. + std::lock_guard lk(audio_tx_mutex_); + err = + i2s_channel_write(audio_tx_handle, tx_buf, buffer_size, &bytes_written, pdMS_TO_TICKS(100)); + } + if (err != ESP_OK || bytes_written != buffer_size) { + // Rate-limit: a stalled I2S sink would otherwise emit this every ~100 ms. + static uint32_t write_warn_count = 0; + if ((write_warn_count++ % 100) == 0) { + logger_.warn("i2s_channel_write: {} ({} of {} bytes written, occurrence {})", + esp_err_to_name(err), bytes_written, buffer_size, write_warn_count); + } + } + // honor a stop request per the Task contract: check/clear notified under m + std::unique_lock lock(m); + if (task_notified) { + task_notified = false; + return true; // stop the task + } + return false; // keep running +} + +uint32_t Esp32P4Wifi6DevKit::audio_sample_rate() const { + return audio_std_cfg.clk_cfg.sample_rate_hz; +} + +size_t Esp32P4Wifi6DevKit::audio_buffer_size() const { return audio_tx_buffer.size(); } + +void Esp32P4Wifi6DevKit::audio_sample_rate(uint32_t sample_rate) { + if (!audio_initialized_) { + logger_.warn("audio_sample_rate() called before initialize_audio(); ignoring"); + return; + } + if (microphone_initialized_) { + logger_.warn("Refusing to change the sample rate while the microphone is running: TX and RX " + "share the full-duplex I2S clock, and there is no runtime microphone-stop path. " + "Pass the desired rate to initialize_audio() before starting the microphone."); + return; + } + // NOTE: this reconfigures the running I2S channel. It is best called when the + // audio task is not actively streaming (e.g. right after initialize_audio, or + // while no audio is playing). To avoid a runtime change entirely, pass the + // desired sample rate to initialize_audio(). The ES8311 is an I2S slave and + // follows the I2S clock, so it does not need a separate codec reconfigure. + logger_.info("Setting audio sample rate to {} Hz", sample_rate); + // Serialized with audio_task_callback() via audio_tx_mutex_ for the whole + // disable/reconfig/enable so it cannot race a concurrent i2s_channel_write(). + std::lock_guard lk(audio_tx_mutex_); + esp_err_t err = i2s_channel_disable(audio_tx_handle); + if (err != ESP_OK) { + logger_.error("Failed to disable I2S channel for reconfig: {}", esp_err_to_name(err)); + return; + } + // Reconfigure from a local copy and commit it to audio_std_cfg only on + // success: audio_sample_rate() (the getter) reports the cached value, which + // must not claim a rate the hardware rejected. + i2s_std_clk_config_t clk_cfg = audio_std_cfg.clk_cfg; + clk_cfg.sample_rate_hz = sample_rate; + err = i2s_channel_reconfig_std_clock(audio_tx_handle, &clk_cfg); + if (err != ESP_OK) { + logger_.error("Failed to reconfigure I2S clock: {}", esp_err_to_name(err)); + // Leave the cached config and the queued stream untouched and re-enable + // the channel so playback continues at the old (still-configured) rate. + err = i2s_channel_enable(audio_tx_handle); + if (err != ESP_OK) { + logger_.error("Failed to re-enable I2S channel after failed reconfig: {}", + esp_err_to_name(err)); + } + return; + } + audio_std_cfg.clk_cfg = clk_cfg; + xStreamBufferReset(audio_tx_stream); + err = i2s_channel_enable(audio_tx_handle); + if (err != ESP_OK) { + logger_.error("Failed to re-enable I2S channel after reconfig: {}", esp_err_to_name(err)); + } +} + +} // namespace espp diff --git a/components/esp32-p4-wifi6-dev-kit/src/camera.cpp b/components/esp32-p4-wifi6-dev-kit/src/camera.cpp new file mode 100644 index 000000000..e1f0ad7b1 --- /dev/null +++ b/components/esp32-p4-wifi6-dev-kit/src/camera.cpp @@ -0,0 +1,366 @@ +#include +#include +#include + +#include +#include +#include +#include + +#include +#include + +#include +#include + +#include "linux/videodev2.h" + +#include "esp_video_device.h" +#include "esp_video_init.h" + +#include "esp32-p4-wifi6-dev-kit.hpp" + +namespace espp { + +//////////////////////// +// Camera Functions // +//////////////////////// + +bool Esp32P4Wifi6DevKit::initialize_camera(const camera_frame_callback_t &callback, + const espp::Task::BaseConfig &task_config) { + logger_.info("Initializing camera (MIPI-CSI, OV5647)"); + if (camera_initialized_) { + // Idempotent, matching the other initialize_* methods: calling again is + // harmless, so warn and report success. + logger_.warn("Camera already initialized"); + return true; + } + if (!callback) { + logger_.error("A callback is required to receive camera frames"); + return false; + } + camera_callback_ = callback; + + // Bring up the CSI receiver + ISP + sensor. The OV5647's SCCB shares the + // internal I2C bus (SDA=7/SCL=8), so hand esp_video that existing bus handle + // (via internal_i2c_.native_bus_handle()) rather than letting it create a + // second master on the same pins. + // + // reset/pwdn are not routed on this board (RPi-style CSI connector); the + // sensor free-runs (esp_video handles CSI/ISP/LDO) - hardware-verify. Unlike + // the M5Stack Tab5 there is no IO expander here to pulse the camera reset, so + // no expander-reset step is performed; the sensor comes up on power. + esp_video_init_csi_config_t csi_config = {}; + csi_config.sccb_config.init_sccb = false; + csi_config.sccb_config.i2c_handle = internal_i2c_.native_bus_handle(); + // Run the sensor SCCB at 100 kHz, matching Waveshare's own camera demos for + // these boards (their Kconfig floor is 100 kHz). Some OV sensors are + // unreliable at higher SCCB rates during probe, and esp_video applies this + // freq to the SCCB device even when reusing an external I2C bus handle. + csi_config.sccb_config.freq = 100000; + csi_config.reset_pin = GPIO_NUM_NC; + csi_config.pwdn_pin = GPIO_NUM_NC; + csi_config.dont_init_ldo = false; + + esp_video_init_config_t video_config = {}; + video_config.csi = &csi_config; + + esp_err_t err = esp_video_init(&video_config); + if (err != ESP_OK) { + logger_.error("esp_video_init failed: {}", esp_err_to_name(err)); + camera_callback_ = nullptr; + return false; + } + camera_video_inited_ = true; + + // Open the MIPI-CSI capture device. Non-blocking so the capture task can + // observe a stop request even when no frame is ready. From here on, failures + // go through stop_camera() so the esp_video pipeline is torn down (otherwise a + // later initialize_camera() retry would fail). + camera_fd_ = open(ESP_VIDEO_MIPI_CSI_DEVICE_NAME, O_RDWR | O_NONBLOCK); + if (camera_fd_ < 0) { + logger_.error("Could not open camera device {} (errno {}: {})", ESP_VIDEO_MIPI_CSI_DEVICE_NAME, + errno, strerror(errno)); + stop_camera(); + return false; + } + + struct v4l2_capability capability = {}; + if (ioctl(camera_fd_, VIDIOC_QUERYCAP, &capability) != 0) { + logger_.error("VIDIOC_QUERYCAP failed (errno {}: {})", errno, strerror(errno)); + stop_camera(); + return false; + } + + // Choose a capture resolution: enumerate the RGB565 discrete frame sizes and + // pick the largest that fits <= 1280 wide, falling back to 1280x720. S_FMT + // below adjusts to the sensor's actual size and we read the real values back, + // so this is only a hint. + uint32_t want_w = 1280, want_h = 720; + { + struct v4l2_frmsizeenum frmsize = {}; + frmsize.pixel_format = V4L2_PIX_FMT_RGB565; + uint32_t best_w = 0, best_h = 0; + for (frmsize.index = 0; ioctl(camera_fd_, VIDIOC_ENUM_FRAMESIZES, &frmsize) == 0; + ++frmsize.index) { + if (frmsize.type != V4L2_FRMSIZE_TYPE_DISCRETE) { + break; + } + uint32_t w = frmsize.discrete.width; + uint32_t h = frmsize.discrete.height; + if (w <= 1280 && (w > best_w || (w == best_w && h > best_h))) { + best_w = w; + best_h = h; + } + } + if (best_w > 0) { + want_w = best_w; + want_h = best_h; + } + } + + struct v4l2_format format = {}; + format.type = V4L2_BUF_TYPE_VIDEO_CAPTURE; + format.fmt.pix.width = want_w; + format.fmt.pix.height = want_h; + format.fmt.pix.pixelformat = V4L2_PIX_FMT_RGB565; + if (ioctl(camera_fd_, VIDIOC_S_FMT, &format) != 0) { + logger_.error("VIDIOC_S_FMT (RGB565 {}x{}) failed (errno {}: {})", want_w, want_h, errno, + strerror(errno)); + stop_camera(); + return false; + } + // VIDIOC_S_FMT may "succeed" after adjusting the request (that is why the + // width/height are read back below), and that includes the pixel format. + // Everything downstream (the frame-size math and the consumers of the + // RGB565 contract in camera_frame_callback_t) assumes RGB565, so a driver + // that negotiated a different format must fail initialization rather than + // deliver mislabeled frames. + if (format.fmt.pix.pixelformat != V4L2_PIX_FMT_RGB565) { + logger_.error("Camera driver selected pixel format 0x{:08X} instead of RGB565 (0x{:08X})", + format.fmt.pix.pixelformat, static_cast(V4L2_PIX_FMT_RGB565)); + stop_camera(); + return false; + } + camera_width_ = static_cast(format.fmt.pix.width); + camera_height_ = static_cast(format.fmt.pix.height); + logger_.info("Camera format: {}x{} RGB565", camera_width_.load(), camera_height_.load()); + + // Request and memory-map the capture buffers. REQBUFS may hand back fewer + // buffers than requested; use the count it actually allocated (and require at + // least one, capped at our array size). + struct v4l2_requestbuffers req = {}; + req.count = CAMERA_BUFFER_COUNT; + req.type = V4L2_BUF_TYPE_VIDEO_CAPTURE; + req.memory = V4L2_MEMORY_MMAP; + if (ioctl(camera_fd_, VIDIOC_REQBUFS, &req) != 0) { + logger_.error("VIDIOC_REQBUFS failed (errno {}: {})", errno, strerror(errno)); + stop_camera(); + return false; + } + if (req.count < 1) { + logger_.error("VIDIOC_REQBUFS allocated 0 buffers"); + stop_camera(); + return false; + } + camera_buffer_count_ = std::min(req.count, CAMERA_BUFFER_COUNT); + if (req.count < CAMERA_BUFFER_COUNT) { + logger_.warn("VIDIOC_REQBUFS allocated {} of {} requested buffers", req.count, + CAMERA_BUFFER_COUNT); + } + for (int i = 0; i < camera_buffer_count_; ++i) { + struct v4l2_buffer buf = {}; + buf.type = V4L2_BUF_TYPE_VIDEO_CAPTURE; + buf.memory = V4L2_MEMORY_MMAP; + buf.index = i; + if (ioctl(camera_fd_, VIDIOC_QUERYBUF, &buf) != 0) { + logger_.error("VIDIOC_QUERYBUF {} failed (errno {}: {})", i, errno, strerror(errno)); + stop_camera(); + return false; + } + camera_buffer_sizes_[i] = buf.length; + camera_buffers_[i] = + mmap(NULL, buf.length, PROT_READ | PROT_WRITE, MAP_SHARED, camera_fd_, buf.m.offset); + if (camera_buffers_[i] == MAP_FAILED) { + camera_buffers_[i] = nullptr; + logger_.error("mmap of camera buffer {} failed (errno {}: {})", i, errno, strerror(errno)); + stop_camera(); + return false; + } + if (ioctl(camera_fd_, VIDIOC_QBUF, &buf) != 0) { + logger_.error("VIDIOC_QBUF {} failed (errno {}: {})", i, errno, strerror(errno)); + stop_camera(); + return false; + } + } + + int type = V4L2_BUF_TYPE_VIDEO_CAPTURE; + if (ioctl(camera_fd_, VIDIOC_STREAMON, &type) != 0) { + logger_.error("VIDIOC_STREAMON failed (errno {}: {})", errno, strerror(errno)); + stop_camera(); + return false; + } + + using namespace std::placeholders; + camera_task_ = espp::Task::make_unique({ + .callback = std::bind(&Esp32P4Wifi6DevKit::camera_task_callback, this, _1, _2, _3), + .task_config = task_config, + }); + // Mark the camera initialized BEFORE starting the task: on a fatal capture + // error the task tears the pipeline down itself (clearing this flag), and if + // the flag were only set after start() a very early failure could be + // overwritten by this assignment, leaving stale "initialized" state. + camera_stop_requested_ = false; + camera_initialized_ = true; + if (!camera_task_->start()) { + logger_.error("Could not start the camera task"); + stop_camera(); + return false; + } + return true; +} + +bool Esp32P4Wifi6DevKit::camera_task_callback(std::mutex &m, std::condition_variable &cv, + bool &task_notified) { + (void)cv; // unused: this task polls the non-blocking fd instead of waiting + // Dequeue a filled frame, hand it to the callback (valid only for the call), + // then requeue the buffer for reuse. The fd is non-blocking, so if no frame + // is ready yet the DQBUF fails and we wait briefly - this keeps the task + // returning regularly so a stop request is observed promptly. + struct v4l2_buffer buf = {}; + buf.type = V4L2_BUF_TYPE_VIDEO_CAPTURE; + buf.memory = V4L2_MEMORY_MMAP; + if (ioctl(camera_fd_, VIDIOC_DQBUF, &buf) == 0) { + // Validate the dequeued index against the RUNTIME buffer count before it + // indexes the mmap arrays: VIDIOC_REQBUFS may have granted fewer buffers + // than CAMERA_BUFFER_COUNT, so entries past camera_buffer_count_ were + // never mapped. An out-of-range index means the driver and our + // bookkeeping disagree - treat it as a fatal capture error (there is no + // valid mapping to hand to the callback or to requeue). + if (buf.index >= static_cast(camera_buffer_count_)) { + logger_.error("VIDIOC_DQBUF returned out-of-range buffer index {} (only {} buffers " + "allocated); stopping the camera", + buf.index, camera_buffer_count_); + teardown_camera_pipeline(); + return true; // stop the task; the owner can re-init via initialize_camera() + } + if (camera_callback_ && camera_buffers_[buf.index]) { + // Prefer the driver-reported payload size; fall back to the computed + // RGB565 size only if the driver does not report bytesused. Either value + // is then clamped to the mmap'd buffer size so a bogus driver-reported + // length can never send the callback past the end of the mapping. + const uint16_t w = camera_width_.load(); + const uint16_t h = camera_height_.load(); + size_t len = + buf.bytesused ? static_cast(buf.bytesused) : static_cast(w) * h * 2; + const size_t buf_size = camera_buffer_sizes_[buf.index]; + if (len > buf_size) { + static uint32_t clamp_warn_count = 0; + if ((clamp_warn_count++ % 100) == 0) { + logger_.warn("Camera frame length {} exceeds the mmap'd buffer size {}; clamping " + "(occurrence {})", + len, buf_size, clamp_warn_count); + } + len = buf_size; + } + camera_callback_(static_cast(camera_buffers_[buf.index]), w, h, len); + // If the callback itself called stop_camera(), that call already tore + // the pipeline down (fd closed, buffers unmapped) and set this flag + // instead of joining this task (which would self-join). Exit without + // touching the torn-down fd. + if (camera_stop_requested_.exchange(false)) { + return true; // stop the task + } + } + // Requeue the buffer. If this fails the capture queue drains and the stream + // stalls, so treat it as fatal to the task rather than spinning silently. + if (ioctl(camera_fd_, VIDIOC_QBUF, &buf) != 0) { + logger_.error("VIDIOC_QBUF failed (errno {}); stopping the camera", errno); + // Tear down the pipeline here (from the camera task itself) so the driver + // is not left wedged (STREAMON + mmaps + esp_video init active) until + // someone calls stop_camera(); this also lets initialize_camera() be + // called again to recover. Only the pipeline is torn down - calling + // stop_camera()/Task::stop() here would self-join the task, so the task + // exits via `return true` instead. A later stop_camera() is still safe: + // the teardown is idempotent and stop() on an exited task just joins it. + teardown_camera_pipeline(); + return true; // stop the task; the owner can re-init via initialize_camera() + } + } else if (errno == EAGAIN) { + // No frame ready yet on the non-blocking fd; wait briefly and retry. + vTaskDelay(pdMS_TO_TICKS(5)); + } else { + // A real capture error (device/stream/driver): surface it and stop the task + // instead of spinning forever with no diagnostics, tearing down the + // pipeline (see above) so the device is released for a later re-init. + logger_.error("VIDIOC_DQBUF failed (errno {}); stopping the camera", errno); + teardown_camera_pipeline(); + return true; + } + // honor a stop request per the Task contract: check/clear notified under m + std::unique_lock lock(m); + if (task_notified) { + task_notified = false; + return true; // stop the task + } + return false; // keep running +} + +void Esp32P4Wifi6DevKit::stop_camera() { + // Called from within the camera task itself (i.e. from the frame callback)? + // Task::stop() would then self-join and deadlock/abort, so mirror the + // fatal-capture-error path instead: tear down only the pipeline here and + // signal the task to exit on its own right after the callback returns (see + // camera_task_callback). The exited-but-not-joined task object is reaped by + // the next stop_camera() or initialize_camera() from another context; both + // are safe (stop()/destruction of an exited task just joins it). + if (camera_task_ && espp::Task::get_current_id() == camera_task_->get_id()) { + camera_stop_requested_ = true; + teardown_camera_pipeline(); + return; + } + // Normal (cross-task) path: stop the task first so nothing is using the + // fd/buffers during teardown. If the task already exited on a fatal capture + // error (and tore the pipeline down itself), stop() just joins the exited + // task and the teardown below is a no-op. + if (camera_task_) { + camera_task_->stop(); + camera_task_.reset(); + } + teardown_camera_pipeline(); +} + +void Esp32P4Wifi6DevKit::teardown_camera_pipeline() { + if (camera_fd_ >= 0) { + int type = V4L2_BUF_TYPE_VIDEO_CAPTURE; + ioctl(camera_fd_, VIDIOC_STREAMOFF, &type); + } + for (int i = 0; i < CAMERA_BUFFER_COUNT; ++i) { + if (camera_buffers_[i]) { + munmap(camera_buffers_[i], camera_buffer_sizes_[i]); + camera_buffers_[i] = nullptr; + camera_buffer_sizes_[i] = 0; + } + } + if (camera_fd_ >= 0) { + close(camera_fd_); + camera_fd_ = -1; + } + if (camera_video_inited_) { + esp_video_deinit(); + camera_video_inited_ = false; + } + // Reset the reported dimensions so camera_width()/height() honor their + // documented "0 when not initialized" contract after a stop or a failure. + camera_buffer_count_ = 0; + camera_width_ = 0; + camera_height_ = 0; + camera_initialized_ = false; + camera_callback_ = nullptr; +} + +uint16_t Esp32P4Wifi6DevKit::camera_width() const { return camera_width_; } + +uint16_t Esp32P4Wifi6DevKit::camera_height() const { return camera_height_; } + +} // namespace espp diff --git a/components/esp32-p4-wifi6-dev-kit/src/esp32-p4-wifi6-dev-kit.cpp b/components/esp32-p4-wifi6-dev-kit/src/esp32-p4-wifi6-dev-kit.cpp new file mode 100644 index 000000000..e79d24048 --- /dev/null +++ b/components/esp32-p4-wifi6-dev-kit/src/esp32-p4-wifi6-dev-kit.cpp @@ -0,0 +1,49 @@ +#include "esp32-p4-wifi6-dev-kit.hpp" + +namespace espp { + +Esp32P4Wifi6DevKit::Esp32P4Wifi6DevKit() + : BaseComponent("Esp32P4Wifi6DevKit") {} + +bool Esp32P4Wifi6DevKit::initialize_ethernet() { return initialize_ethernet(EthernetConfig{}); } + +bool Esp32P4Wifi6DevKit::initialize_ethernet(const EthernetConfig &config) { + if (ethernet_ && ethernet_->is_initialized()) { + logger_.warn("Ethernet already initialized"); + return true; + } + + logger_.info("Initializing Ethernet (EMAC + IP101GRI RMII, DHCP {})", + config.mode == DhcpMode::SERVER ? "server" : "client"); + + espp::Ethernet::Config eth_config{}; + eth_config.interface = espp::Ethernet::RmiiConfig{ + .mdc_gpio = eth_mdc_io, + .mdio_gpio = eth_mdio_io, + .phy_addr = eth_phy_addr, + .phy_reset_gpio = eth_phy_reset_gpio, + .clock_ext_in = true, + .clock_gpio = eth_ref_clk_io, + .data_pins = espp::Ethernet::RmiiConfig::DataPins{.tx_en = eth_tx_en_io, + .txd0 = eth_txd0_io, + .txd1 = eth_txd1_io, + .crs_dv = eth_crs_dv_io, + .rxd0 = eth_rxd0_io, + .rxd1 = eth_rxd1_io}, + }; + eth_config.mode = (config.mode == DhcpMode::SERVER) ? espp::Ethernet::DhcpMode::SERVER + : espp::Ethernet::DhcpMode::CLIENT; + if (config.mode == DhcpMode::SERVER) { + eth_config.ip_info = config.server_config.ip_info; + eth_config.on_client_assigned = config.server_config.on_client_assigned; + } + eth_config.on_link_up = config.on_link_up; + eth_config.on_link_down = config.on_link_down; + eth_config.on_got_ip = config.on_got_ip; + eth_config.on_lost_ip = config.on_lost_ip; + + ethernet_ = std::make_unique(eth_config); + return ethernet_->initialize(); +} + +} // namespace espp diff --git a/components/esp32-p4-wifi6-dev-kit/src/sdcard.cpp b/components/esp32-p4-wifi6-dev-kit/src/sdcard.cpp new file mode 100644 index 000000000..f413dde0b --- /dev/null +++ b/components/esp32-p4-wifi6-dev-kit/src/sdcard.cpp @@ -0,0 +1,91 @@ +#include "esp32-p4-wifi6-dev-kit.hpp" + +#include +#include +#include + +namespace espp { + +bool Esp32P4Wifi6DevKit::initialize_sdcard(const SdCardConfig &config) { + if (sdcard_) { + // Idempotent, matching the other initialize_* methods: calling again is + // harmless, so warn and report success. + logger_.warn("SD card already initialized"); + return true; + } + + logger_.info("Initializing SD card (4-bit SDMMC)"); + + esp_vfs_fat_sdmmc_mount_config_t 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; + + sdmmc_host_t host = SDMMC_HOST_DEFAULT(); + host.max_freq_khz = SDMMC_FREQ_HIGHSPEED; + host.slot = SDMMC_HOST_SLOT_0; + + // The ESP32-P4 powers the SD card via an internal LDO (LDO_VO4). Configure the + // power control handle so the host can enable that rail. + sd_pwr_ctrl_ldo_config_t ldo_config{}; + ldo_config.ldo_chan_id = sd_ldo_channel; + sd_pwr_ctrl_handle_t pwr_ctrl_handle = nullptr; + esp_err_t ret = sd_pwr_ctrl_new_on_chip_ldo(&ldo_config, &pwr_ctrl_handle); + if (ret != ESP_OK) { + logger_.error("Failed to create SD power control driver: {}", esp_err_to_name(ret)); + return false; + } + host.pwr_ctrl_handle = pwr_ctrl_handle; + sd_pwr_ctrl_handle_ = pwr_ctrl_handle; + + sdmmc_slot_config_t slot_config = SDMMC_SLOT_CONFIG_DEFAULT(); + slot_config.width = 4; + slot_config.clk = sd_clk_io; + slot_config.cmd = sd_cmd_io; + slot_config.d0 = sd_d0_io; + slot_config.d1 = sd_d1_io; + slot_config.d2 = sd_d2_io; + slot_config.d3 = sd_d3_io; + + 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_.warn("Failed to initialize the card ({}). " + "Make sure an SD card is inserted.", + esp_err_to_name(ret)); + } + sd_pwr_ctrl_del_on_chip_ldo(pwr_ctrl_handle); + sd_pwr_ctrl_handle_ = nullptr; + return false; + } + + logger_.info("Filesystem mounted"); + sdmmc_card_print_info(stdout, sdcard_); + sd_card_initialized_ = true; + return true; +} + +bool Esp32P4Wifi6DevKit::get_sd_card_info(uint32_t *size_mb, uint32_t *free_mb) const { + if (!sd_card_initialized_) { + return false; + } + uint64_t total_bytes = 0, free_bytes = 0; + esp_err_t ret = esp_vfs_fat_info(mount_point, &total_bytes, &free_bytes); + if (ret != ESP_OK) { + logger_.error("Failed to get SD card information ({})", esp_err_to_name(ret)); + return false; + } + if (size_mb) { + *size_mb = total_bytes / (1024 * 1024); + } + if (free_mb) { + *free_mb = free_bytes / (1024 * 1024); + } + return true; +} + +} // namespace espp diff --git a/components/esp32-p4-wifi6-dev-kit/src/touchpad.cpp b/components/esp32-p4-wifi6-dev-kit/src/touchpad.cpp new file mode 100644 index 000000000..568f9b871 --- /dev/null +++ b/components/esp32-p4-wifi6-dev-kit/src/touchpad.cpp @@ -0,0 +1,194 @@ +#include "esp32-p4-wifi6-dev-kit.hpp" + +using namespace std::chrono_literals; + +namespace espp { + +bool Esp32P4Wifi6DevKit::initialize_touch(const touch_callback_t &callback, + gpio_num_t interrupt_pin) { + if (touch_driver_) { + logger_.warn("Touch driver already initialized"); + return true; + } + + logger_.info("Initializing GT911 multi-touch controller"); + + // The board does not route the GT911 reset; the address is fixed at power-on. + // Probe the primary (0x5D) then the backup (0x14) address. If the controller + // answers at neither, fail cleanly: creating the device/driver (and, in + // polling mode, a task) anyway would just error on every update_touch(), + // spamming the log and wasting CPU on a panel with no touch controller. + uint8_t address = gt911_default_address; + if (!internal_i2c_.probe_device(gt911_default_address)) { + if (internal_i2c_.probe_device(gt911_backup_address)) { + address = gt911_backup_address; + } else { + logger_.error("GT911 not found at 0x{:02X} or 0x{:02X}; touch is unavailable", + gt911_default_address, gt911_backup_address); + return false; + } + } + logger_.info("Using GT911 at address 0x{:02X}", address); + touch_callback_ = callback; + + std::error_code ec; + touch_i2c_device_ = internal_i2c_.add_device( + { + .device_address = address, + .timeout_ms = static_cast(internal_i2c_.config().timeout_ms), + .scl_speed_hz = internal_i2c_.config().clk_speed, + .log_level = espp::Logger::Verbosity::WARN, + }, + ec); + if (!touch_i2c_device_) { + logger_.error("Could not initialize touch I2C device: {}", ec.message()); + return false; + } + + touch_driver_ = std::make_shared( + TouchDriver::Config{.write = espp::make_i2c_addressed_write(touch_i2c_device_), + .read = espp::make_i2c_addressed_read(touch_i2c_device_), + .address = address, + .log_level = espp::Logger::Verbosity::WARN}); + + touchpad_input_ = std::make_shared(TouchpadInput::Config{ + .touchpad_read = std::bind_front(&Esp32P4Wifi6DevKit::touchpad_read, this), + .swap_xy = touch_swap_xy, + .invert_x = touch_invert_x, + .invert_y = touch_invert_y, + .log_level = espp::Logger::Verbosity::WARN}); + + if (interrupt_pin != GPIO_NUM_NC) { + // Interrupt-driven: read the GT911 only when its INT pin signals new data, + // instead of polling. update_touch() reads the touch point(s) and clears the + // GT911's data-ready flag (which de-asserts INT). + logger_.info("Touch in interrupt mode (GT911 INT on GPIO{})", static_cast(interrupt_pin)); + interrupts_.add_interrupt( + espp::Interrupt::PinConfig{.gpio_num = interrupt_pin, + .callback = + [this](const auto &) { + if (update_touch() && touch_callback_) { + touch_callback_(touchpad_data()); + } + }, + .active_level = espp::Interrupt::ActiveLevel::LOW, + .interrupt_type = espp::Interrupt::Type::FALLING_EDGE, + .pullup_enabled = true}); + } else { + // The touch INT pin is not wired to a GPIO on the ESP32-P4-WIFI6-DEV-KIT, so poll the + // GT911 in a task and invoke the user callback on new data. + logger_.info("Touch in polling mode (GT911 INT not wired)"); + touch_task_ = std::make_unique(espp::Task::Config{ + .callback = [this](std::mutex &m, std::condition_variable &cv, + bool &task_notified) -> bool { + if (update_touch()) { + if (touch_callback_) { + touch_callback_(touchpad_data()); + } + } + std::unique_lock lock(m); + // Wait with the notified flag as the predicate so a spurious + // condition-variable wake does not stop the polling task; per the + // Task contract the flag is checked and cleared under m. A true + // predicate means Task::stop() was requested, so return true so + // stop() joins promptly instead of waiting out the poll interval. + if (cv.wait_for(lock, 16ms, [&task_notified] { return task_notified; })) { + task_notified = false; + return true; // stop the task + } + return false; // timed out: keep polling + }, + .task_config = {.name = "p4-wifi6 touch", + .stack_size_bytes = CONFIG_ESP32_P4_WIFI6_DEV_KIT_TOUCH_TASK_STACK_SIZE}}); + if (!touch_task_->start()) { + // Roll back the partial initialization: without the polling task no touch + // data would ever be delivered, and leaving touch_driver_ set would make + // a later retry take the "already initialized" path above and falsely + // report success. + logger_.error("Could not start the touch polling task"); + touch_task_.reset(); + touchpad_input_.reset(); + touch_driver_.reset(); + touch_i2c_device_.reset(); + touch_callback_ = nullptr; + return false; + } + } + + logger_.info("Touch controller initialized"); + return true; +} + +bool Esp32P4Wifi6DevKit::update_touch() { + if (!touch_driver_) { + return false; + } + std::error_code ec; + bool new_data = touch_driver_->update(ec); + if (ec) { + logger_.error("Could not update touch driver: {}", ec.message()); + std::lock_guard lock(touchpad_data_mutex_); + touchpad_data_ = {}; + return false; + } + if (!new_data) { + return false; + } + TouchpadData temp_data; + touch_driver_->get_touch_point(&temp_data.num_touch_points, &temp_data.x, &temp_data.y); + temp_data.btn_state = touch_driver_->get_home_button_state(); + std::lock_guard lock(touchpad_data_mutex_); + touchpad_data_ = temp_data; + return true; +} + +void Esp32P4Wifi6DevKit::touchpad_read(uint8_t *num_touch_points, uint16_t *x, uint16_t *y, + uint8_t *btn_state) { + std::lock_guard lock(touchpad_data_mutex_); + *num_touch_points = touchpad_data_.num_touch_points; + *x = touchpad_data_.x; + *y = touchpad_data_.y; + *btn_state = touchpad_data_.btn_state; +} + +Esp32P4Wifi6DevKit::TouchpadData +Esp32P4Wifi6DevKit::touchpad_convert(const TouchpadData &data) const { + TouchpadData temp_data = data; + if (temp_data.num_touch_points == 0) { + return temp_data; + } + if (touch_swap_xy) { + std::swap(temp_data.x, temp_data.y); + } + if (touch_invert_x) { + temp_data.x = display_width_ - (temp_data.x + 1); + } + if (touch_invert_y) { + temp_data.y = display_height_ - (temp_data.y + 1); + } + // Map the (panel-native) touch point into the current LVGL display rotation so + // it lines up with what is drawn on screen. Query the BSP-managed display, + // not lv_display_get_default(): if the app creates additional LVGL displays + // the default may not be this panel. + auto rotation = current_display_rotation(); + switch (rotation) { + case LV_DISPLAY_ROTATION_90: + temp_data.y = display_height_ - (temp_data.y + 1); + std::swap(temp_data.x, temp_data.y); + break; + case LV_DISPLAY_ROTATION_180: + temp_data.x = display_width_ - (temp_data.x + 1); + temp_data.y = display_height_ - (temp_data.y + 1); + break; + case LV_DISPLAY_ROTATION_270: + temp_data.x = display_width_ - (temp_data.x + 1); + std::swap(temp_data.x, temp_data.y); + break; + case LV_DISPLAY_ROTATION_0: + default: + break; + } + return temp_data; +} + +} // namespace espp diff --git a/components/esp32-p4-wifi6-dev-kit/src/video.cpp b/components/esp32-p4-wifi6-dev-kit/src/video.cpp new file mode 100644 index 000000000..469318f93 --- /dev/null +++ b/components/esp32-p4-wifi6-dev-kit/src/video.cpp @@ -0,0 +1,587 @@ +#include "esp32-p4-wifi6-dev-kit.hpp" + +#include "esp_idf_version.h" +#ifndef ESP_IDF_VERSION_VAL +#define ESP_IDF_VERSION_VAL(major, minor, patch) (((major) << 16) | ((minor) << 8) | (patch)) +#endif +#ifndef ESP_IDF_VERSION +#define ESP_IDF_VERSION ESP_IDF_VERSION_VAL(0, 0, 0) +#endif + +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +using namespace std::chrono_literals; + +namespace espp { + +bool Esp32P4Wifi6DevKit::initialize_lcd() { + // Idempotent: once the DPI panel is streaming, the vendor-command channel can + // no longer drain, so re-entering the init/vendor-command path would hang. + if (lcd_initialized_) { + logger_.warn("LCD already initialized; ignoring repeat initialize_lcd()"); + return true; + } + logger_.info("Initializing LCD (MIPI-DSI)"); + + // Select the panel params first: the DSI lane bit rate is per-panel. + apply_panel_params(default_controller_); + + esp_err_t ret = ESP_OK; + + // Enable the MIPI DSI PHY power LDO (on-chip LDO_VO3 -> VDD_MIPI_DPHY, + // channel 3 @ 2500 mV on the ESP32-P4-WIFI6-DEV-KIT). + static esp_ldo_channel_handle_t phy_pwr_chan = nullptr; + if (phy_pwr_chan == nullptr) { + esp_ldo_channel_config_t phy_pwr_cfg{}; + phy_pwr_cfg.chan_id = mipi_dsi_phy_ldo_channel; + phy_pwr_cfg.voltage_mv = mipi_dsi_phy_ldo_voltage_mv; + ret = esp_ldo_acquire_channel(&phy_pwr_cfg, &phy_pwr_chan); + if (ret != ESP_OK) { + logger_.error("Failed to acquire MIPI DSI PHY power LDO channel: {}", esp_err_to_name(ret)); + return false; + } + } + + // NOTE: The ESP32-P4-WIFI6-DEV-KIT does not route a panel reset GPIO for either panel; + // the panel is reset over DSI during its vendor init sequence, so there is no + // hardware GPIO reset step here (unlike the ESP32-P4-Function-EV-Board). + + // The 10.1" JD9365 panel is powered/reset and backlit by an on-board I2C + // controller (addr 0x45) on the BSP's internal I2C bus: register 0x95 is the + // panel power/reset control and 0x96 the backlight level (0-255) on this + // panel's controller. This power-on sequence (values and delays) matches + // Waveshare's vendor panel component and must run before any DSI traffic + // so the panel is powered and out of reset when the vendor init sequence is + // sent. + if (display_controller_ == DisplayController::JD9365) { + // Lazily create the backlight/panel-power I2C device on the internal bus + // (shared with brightness()). + if (!backlight_i2c_device_) { + std::error_code ec; + backlight_i2c_device_ = internal_i2c_.add_device( + { + .device_address = backlight_i2c_address, + .timeout_ms = static_cast(internal_i2c_.config().timeout_ms), + .scl_speed_hz = internal_i2c_.config().clk_speed, + .log_level = espp::Logger::Verbosity::WARN, + }, + ec); + if (!backlight_i2c_device_) { + logger_.error("Could not initialize panel power/backlight I2C device (0x{:02X}): {}", + backlight_i2c_address, ec.message()); + return false; + } + } + auto write_panel_reg = [this](uint8_t reg, uint8_t value) -> bool { + const uint8_t data[2] = {reg, value}; + std::error_code ec; + if (!backlight_i2c_device_->write(data, sizeof(data), ec)) { + logger_.error("Failed to write panel power controller reg 0x{:02X}: {}", reg, ec.message()); + return false; + } + return true; + }; + // Every write in this sequence is required: if the power/reset writes fail + // the panel may still be unpowered/in reset when the DSI vendor init + // sequence is sent, which fails or hangs non-deterministically later. Abort + // LCD init instead of proceeding blind. + if (!write_panel_reg(0x95, 0x11) || // panel power/reset control + !write_panel_reg(0x95, 0x17) || // panel power/reset control + !write_panel_reg(0x96, 0x00)) { // backlight off while powering up + logger_.error("Failed to power on the JD9365 panel via its I2C power controller"); + return false; + } + std::this_thread::sleep_for(100ms); + if (!write_panel_reg(0x96, 0xFF)) { // backlight full on + logger_.error("Failed to enable the JD9365 panel backlight via its I2C power controller"); + return false; + } + std::this_thread::sleep_for(1000ms); + } + + // Create the MIPI DSI bus (also initializes the DSI PHY) + if (lcd_handles_.mipi_dsi_bus == nullptr) { + logger_.info("Creating MIPI DSI bus ({} lanes, {} Mbps/lane)", mipi_dsi_lanes, + panel_params_.lane_bitrate_mbps); + esp_lcd_dsi_bus_config_t bus_config = {}; + bus_config.bus_id = 0; + bus_config.num_data_lanes = mipi_dsi_lanes; + bus_config.phy_clk_src = MIPI_DSI_PHY_CLK_SRC_DEFAULT; + bus_config.lane_bit_rate_mbps = panel_params_.lane_bitrate_mbps; + ret = esp_lcd_new_dsi_bus(&bus_config, &lcd_handles_.mipi_dsi_bus); + if (ret != ESP_OK) { + logger_.error("New DSI bus init failed: {}", esp_err_to_name(ret)); + return false; + } + } + + // Install the DBI panel IO (used to send DCS commands/parameters) + if (lcd_handles_.io == nullptr) { + logger_.info("Installing MIPI DSI DBI panel IO"); + esp_lcd_dbi_io_config_t dbi_config = {}; + dbi_config.virtual_channel = 0; + dbi_config.lcd_cmd_bits = 8; + dbi_config.lcd_param_bits = 8; + ret = esp_lcd_new_panel_io_dbi(lcd_handles_.mipi_dsi_bus, &dbi_config, &lcd_handles_.io); + if (ret != ESP_OK) { + logger_.error("New panel IO failed: {}", esp_err_to_name(ret)); + return false; + } + } + + // Select the panel (Kconfig-driven) and apply its parameters (geometry, DPI + // timing). + logger_.info("Using display panel: {} ({}x{})", get_display_controller_name(), display_width_, + display_height_); + + // NOTE: The ESP32-P4-WIFI6-DEV-KIT has no backlight GPIO. The backlight is driven by an + // on-board I2C controller (addr 0x45). On the 10.1" JD9365 panel brightness() + // writes that controller (reg 0x96); on the other panels the panel powers up + // with the backlight on and brightness() only stores the value. No espp::Led / + // PWM backlight is instantiated here. + brightness(100.0f); + + // espp-driver path (JD9365 / ILI9881C / EK79007): send the panel + // controller's vendor init sequence over DBI (command mode), before starting + // the DPI video stream. + espp::display_drivers::Config display_config{ + .panel_io = nullptr, + .write_command = std::bind_front(&Esp32P4Wifi6DevKit::dsi_write_command, this), + // NOTE: the Waveshare ESP32-P4 panels do not reliably support MIPI-DSI DCS + // reads (bus turn-around); the ESP-IDF HAL busy-waits on the read, which + // hangs panel init and trips the task watchdog. Do not provide a + // read_command so the driver skips the optional panel-ID read. + .read_command = nullptr, + .lcd_send_lines = nullptr, + .reset_pin = GPIO_NUM_NC, + .data_command_pin = GPIO_NUM_NC, + .reset_value = false, + .invert_colors = invert_colors, + .swap_color_order = swap_color_order, + .offset_x = 0, + .offset_y = 0, + .swap_xy = swap_xy, + .mirror_x = mirror_x, + .mirror_y = mirror_y, + .mirror_portrait = false, + }; + + display_driver_.reset(); + if (display_controller_ == DisplayController::JD9365) { + // The Waveshare 10.1" 800x1280 panel is a JD9365. espp::Jd9365 performs + // the DCS software reset and sends the vendor init sequence (taken from + // Waveshare's vendor panel component; see jd9365.hpp) over the DBI IO. + auto driver = std::make_shared(display_config); + if (driver->initialize()) { + display_driver_ = std::move(driver); + } + } else if (display_controller_ == DisplayController::ILI9881C) { + auto driver = std::make_shared(display_config); + if (driver->initialize()) { + display_driver_ = std::move(driver); + } + } else { + auto driver = std::make_shared(display_config); + if (driver->initialize()) { + display_driver_ = std::move(driver); + } + } + if (!display_driver_) { + logger_.error("Failed to initialize {} display controller", get_display_controller_name()); + return false; + } + + // Create the DPI (video) panel with the configured panel's timing. This must + // come AFTER the vendor init sequence above: esp_lcd_new_panel_dpi() starts the + // HS video stream, and once it is running the DSI cannot drain the low-power + // command FIFO, so a long init sequence (e.g. ILI9881C's 202 commands) would + // overflow it and hang. + if (lcd_handles_.panel == nullptr) { + esp_lcd_dpi_panel_config_t dpi_cfg{}; + memset(&dpi_cfg, 0, sizeof(dpi_cfg)); + dpi_cfg.virtual_channel = 0; + dpi_cfg.dpi_clk_src = MIPI_DSI_DPI_CLK_SRC_DEFAULT; + dpi_cfg.dpi_clock_freq_mhz = panel_params_.dpi_clock_freq_mhz; +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0) + dpi_cfg.in_color_format = LCD_COLOR_FMT_RGB565; + dpi_cfg.out_color_format = LCD_COLOR_FMT_RGB565; +#else + dpi_cfg.pixel_format = LCD_COLOR_PIXEL_FORMAT_RGB565; + // DMA2D only on the JD9365 - same conditioning as the IDF >= 6.0 path + // below (see the NOTE there: DMA2D corrupts the RGB565 channel order on + // the ILI9881C / EK79007 panels). + dpi_cfg.flags.use_dma2d = (display_controller_ == DisplayController::JD9365); +#endif + dpi_cfg.num_fbs = 1; + dpi_cfg.video_timing.h_size = display_width_; + dpi_cfg.video_timing.v_size = display_height_; + dpi_cfg.video_timing.hsync_pulse_width = panel_params_.hsync_pulse_width; + dpi_cfg.video_timing.hsync_back_porch = panel_params_.hsync_back_porch; + dpi_cfg.video_timing.hsync_front_porch = panel_params_.hsync_front_porch; + dpi_cfg.video_timing.vsync_pulse_width = panel_params_.vsync_pulse_width; + dpi_cfg.video_timing.vsync_back_porch = panel_params_.vsync_back_porch; + dpi_cfg.video_timing.vsync_front_porch = panel_params_.vsync_front_porch; + logger_.info("Creating DPI panel ({}x{} @ {} MHz)", dpi_cfg.video_timing.h_size, + dpi_cfg.video_timing.v_size, dpi_cfg.dpi_clock_freq_mhz); + ret = esp_lcd_new_panel_dpi(lcd_handles_.mipi_dsi_bus, &dpi_cfg, &lcd_handles_.panel); + if (ret != ESP_OK) { + logger_.error("Failed to create MIPI DSI DPI panel: {}", esp_err_to_name(ret)); + return false; + } + // NOTE: for the ILI9881C / EK79007 panels we deliberately do NOT enable + // DMA2D for the DPI panel. DMA2D is a color-processing engine, not a plain + // copy: routing the LVGL flush (esp_lcd_panel_draw_bitmap) through it + // corrupts the RGB565 channel order on those panels, while the plain CPU + // copy path renders correctly. The JD9365 panel renders correctly WITH + // DMA2D (and Waveshare's vendor panel component enables it), so keep it + // enabled on that path. +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0) + if (display_controller_ == DisplayController::JD9365) { + ret = esp_lcd_dpi_panel_enable_dma2d(lcd_handles_.panel); + if (ret != ESP_OK) { + logger_.error("Failed to enable DMA2D for the DPI panel: {}", esp_err_to_name(ret)); + return false; + } + } +#endif + } + + // Low-level panel init (starts the DPI video stream) + ret = lcd_handles_.panel->init(lcd_handles_.panel); + if (ret != ESP_OK) { + logger_.error("Low-level panel init failed: {}", esp_err_to_name(ret)); + return false; + } + + // Note: the raw MIPI-DSI DPI panel does not implement disp_on_off (the panel + // is driven on by its vendor init + the DPI video stream), so we don't call + // esp_lcd_panel_disp_on_off() here. + + // Register the DPI "color transfer done" callback so LVGL flush completes + esp_lcd_dpi_panel_event_callbacks_t cbs = { + .on_color_trans_done = &Esp32P4Wifi6DevKit::notify_lvgl_flush_ready, + .on_refresh_done = nullptr, + }; + ret = esp_lcd_dpi_panel_register_event_callbacks(lcd_handles_.panel, &cbs, this); + if (ret != ESP_OK) { + logger_.error("Failed to register panel event callback: {}", esp_err_to_name(ret)); + return false; + } + + logger_.info("LCD initialization completed ({})", get_display_controller_name()); + lcd_initialized_ = true; + return true; +} + +void Esp32P4Wifi6DevKit::apply_panel_params(DisplayController controller) { + display_controller_ = + (controller == DisplayController::UNKNOWN) ? default_controller_ : controller; + switch (display_controller_) { + case DisplayController::JD9365: + panel_params_ = JD9365_PARAMS; + break; + case DisplayController::ILI9881C: + panel_params_ = ILI9881C_PARAMS; + break; + default: + panel_params_ = EK79007_PARAMS; + break; + } + display_width_ = panel_params_.width; + display_height_ = panel_params_.height; +} + +bool Esp32P4Wifi6DevKit::initialize_display(size_t pixel_buffer_size) { + if (pixel_buffer_size == 0) { + pixel_buffer_size = display_width_ * 50; + } + // Re-initialization contract: the LVGL display, its draw buffers, and the + // rotation scratch buffer are created exactly once. Once display_ exists a + // GUI/LVGL task may already be calling flush(), which reads + // rotation_buffer_ without locking, so nothing may be freed / reallocated / + // resized here after that point - refuse re-init instead. (LVGL's draw + // buffers are also fixed at first init, so a different pixel_buffer_size + // could not take effect anyway.) + if (display_) { + logger_.warn("Display already initialized; ignoring re-initialization request (the pixel " + "buffer size cannot be changed after the first call)"); + return true; + } + logger_.info("Initializing LVGL display with pixel buffer size: {} pixels", pixel_buffer_size); + + // Rotation scratch buffer (only used when the display is rotated). Allocate + // it BEFORE creating display_: flush() can only run once display_ exists, so + // this ordering (plus the re-init guard above) guarantees flush() never + // observes the buffer changing. An allocation failure fails display + // initialization outright: rotation can be enabled at any time later via + // lv_display_set_rotation() (there is no way to lock it off at the LVGL + // level), and without this buffer flush() would have to either send + // unrotated pixels at rotated coordinates (garbage on screen) or silently + // drop every flush - neither is an acceptable "working" display. + if (rotation_buffer_ == nullptr) { + rotation_buffer_ = (uint16_t *)heap_caps_malloc(pixel_buffer_size * sizeof(uint16_t), + MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); + if (rotation_buffer_ == nullptr) { + logger_.error("Could not allocate the {}-byte display rotation buffer; failing display " + "initialization", + pixel_buffer_size * sizeof(uint16_t)); + return false; + } + rotation_buffer_px_ = pixel_buffer_size; + } + + display_ = std::make_shared>( + Display::LvglConfig{.width = display_width_, + .height = display_height_, + .flush_callback = + std::bind_front(&Esp32P4Wifi6DevKit::flush, this), + .rotation_callback = nullptr, + .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, + // Allocate the LVGL draw buffers in PSRAM to keep these large buffers + // out of internal SRAM. The CPU-copy flush reads them coherently. + .allocation_flags = MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT, + }, + Logger::Verbosity::WARN); + + logger_.info("LVGL display initialized"); + return true; +} + +lv_display_rotation_t Esp32P4Wifi6DevKit::current_display_rotation() const { + // Query the BSP-managed display, not lv_display_get_default(): if the app + // creates additional LVGL displays the default may not be this panel. + if (display_) { + return lv_display_get_rotation(display_->get_lvgl_display()); + } + // Before initialize_display() there is no LVGL display yet; report the + // rotation it will be created with. + return static_cast(rotation); +} + +size_t Esp32P4Wifi6DevKit::rotated_display_width() const { + auto rot = current_display_rotation(); + switch (rot) { + case LV_DISPLAY_ROTATION_90: + case LV_DISPLAY_ROTATION_270: + return display_height_; + default: + return display_width_; + } +} + +size_t Esp32P4Wifi6DevKit::rotated_display_height() const { + auto rot = current_display_rotation(); + switch (rot) { + case LV_DISPLAY_ROTATION_90: + case LV_DISPLAY_ROTATION_270: + return display_width_; + default: + return display_height_; + } +} + +void Esp32P4Wifi6DevKit::write_lcd_lines(int xs, int ys, int xe, int ye, const uint8_t *data, + uint32_t user_data) { + (void)user_data; + if (lcd_handles_.panel == nullptr || data == nullptr) { + return; + } + if (xs < 0 || ys < 0 || xe < xs || ye < ys) { + logger_.error("write_lcd_lines: Bad region: ({},{}) to ({},{})", xs, ys, xe, ye); + return; + } + esp_err_t err = esp_lcd_panel_draw_bitmap(lcd_handles_.panel, xs, ys, xe + 1, ye + 1, data); + if (err != ESP_OK) { + logger_.error("write_lcd_lines: esp_lcd_panel_draw_bitmap failed: {}", esp_err_to_name(err)); + } +} + +void Esp32P4Wifi6DevKit::brightness(float brightness) { + // The ESP32-P4-WIFI6-DEV-KIT has NO backlight GPIO. The backlight is driven by an + // on-board I2C controller at address 0x45. On the 10.1" JD9365 panel the + // brightness register is 0x96 (value 0-255); Waveshare's own BSP writes 0x86 + // instead for some panel revisions. For panels other than the JD9365 the + // chip/protocol has not been verified, so this remains best-effort: store + // the requested value so brightness() reads back what was set, and log it. + brightness = std::clamp(brightness, 0.0f, 100.0f); + brightness_ = brightness; + if (display_controller_ != DisplayController::JD9365) { + logger_.debug("brightness({}) requested; no backlight GPIO on this board (on-board I2C " + "backlight protocol not verified for this panel), value stored only", + brightness); + return; + } + // Lazily create the backlight I2C device on the internal bus. + if (!backlight_i2c_device_) { + std::error_code ec; + backlight_i2c_device_ = internal_i2c_.add_device( + { + .device_address = backlight_i2c_address, + .timeout_ms = static_cast(internal_i2c_.config().timeout_ms), + .scl_speed_hz = internal_i2c_.config().clk_speed, + .log_level = espp::Logger::Verbosity::WARN, + }, + ec); + if (!backlight_i2c_device_) { + logger_.error("Could not initialize backlight I2C device (0x{:02X}): {}", + backlight_i2c_address, ec.message()); + return; + } + } + // Register 0x96 is the brightness register (0-255) on the 10.1" JD9365 + // panel's power/backlight controller (Waveshare's BSP uses 0x86 for some + // panel revisions). + const uint8_t data[2] = {0x96, static_cast(255.0f * brightness / 100.0f)}; + std::error_code ec; + if (!backlight_i2c_device_->write(data, sizeof(data), ec)) { + logger_.error("Failed to write backlight brightness: {}", ec.message()); + } +} + +float Esp32P4Wifi6DevKit::brightness() const { return brightness_.load(); } + +// NOTE: this is the LVGL flush_cb; LVGL only ever invokes it from the task that +// runs lv_timer_handler() (the espp::Display update task), never from an ISR, +// so it is deliberately NOT IRAM_ATTR and is free to call LVGL / esp_lcd +// task-context APIs (lv_draw_sw_rotate, esp_lcd_panel_draw_bitmap, logging). +void Esp32P4Wifi6DevKit::flush(lv_display_t *disp, const lv_area_t *area, uint8_t *px_map) { + if (lcd_handles_.panel == nullptr) { + lv_display_flush_ready(disp); + return; + } + + int offsetx1 = area->x1; + int offsetx2 = area->x2; + int offsety1 = area->y1; + int offsety2 = area->y2; + + // Use the display being flushed (the BSP-managed one), not + // lv_display_get_default(): another LVGL display created by the app could be + // the default, and its rotation must not leak into this panel's flush. + auto rot = lv_display_get_rotation(disp); + int32_t ww = lv_area_get_width(area); + int32_t hh = lv_area_get_height(area); + if (rot > LV_DISPLAY_ROTATION_0) { + // The scratch buffer was sized to the LVGL draw-buffer pixel count (and + // initialize_display() fails if it cannot be allocated), so any flushed + // area fits; this check is defense-in-depth. If rotation is ever + // impossible, DROP the flush instead of falling back to unrotated data: + // the area is transformed to rotated (physical) coordinates below, and + // unrotated pixels at rotated coordinates would draw garbage. + if (rotation_buffer_ == nullptr || + static_cast(ww) * static_cast(hh) > rotation_buffer_px_) { + static uint32_t rotation_drop_count = 0; + if ((rotation_drop_count++ % 100) == 0) { + logger_.error( + "flush: cannot rotate a {}x{} area (scratch buffer holds {} px); dropping the " + "flush (occurrence {})", + ww, hh, rotation_buffer_px_, rotation_drop_count); + } + lv_display_flush_ready(disp); + return; + } + lv_color_format_t cf = lv_display_get_color_format(disp); + uint32_t w_stride = lv_draw_buf_width_to_stride(ww, cf); + uint32_t h_stride = lv_draw_buf_width_to_stride(hh, cf); + if (rot == LV_DISPLAY_ROTATION_180) { + // 180° keeps the source dimensions (unlike 90/270), so pass ww/hh and the + // width-based stride for both source and destination. + lv_draw_sw_rotate(px_map, rotation_buffer_, ww, hh, w_stride, w_stride, + LV_DISPLAY_ROTATION_180, cf); + } else if (rot == LV_DISPLAY_ROTATION_90) { + lv_draw_sw_rotate(px_map, rotation_buffer_, ww, hh, w_stride, h_stride, + LV_DISPLAY_ROTATION_90, cf); + } else if (rot == LV_DISPLAY_ROTATION_270) { + lv_draw_sw_rotate(px_map, rotation_buffer_, ww, hh, w_stride, h_stride, + LV_DISPLAY_ROTATION_270, cf); + } + px_map = reinterpret_cast(rotation_buffer_); + // Rotate a local copy of the area; LVGL provides a const area* and mutating + // it via const_cast would be undefined behavior. + lv_area_t rotated = *area; + lv_display_rotate_area(disp, &rotated); + offsetx1 = rotated.x1; + offsetx2 = rotated.x2; + offsety1 = rotated.y1; + offsety2 = rotated.y2; + } + + esp_err_t err = esp_lcd_panel_draw_bitmap(lcd_handles_.panel, offsetx1, offsety1, offsetx2 + 1, + offsety2 + 1, px_map); + if (err != ESP_OK) { + logger_.error("flush: esp_lcd_panel_draw_bitmap failed: {}", esp_err_to_name(err)); + // A failed draw never generates the on_color_trans_done callback, so mark + // the flush ready here; otherwise LVGL would wait on this flush forever + // and rendering would stall. + lv_display_flush_ready(disp); + } +} + +// NOTE: this DPI on_color_trans_done callback runs in one of two contexts +// (see esp_lcd_panel_dpi.c): directly from esp_lcd_panel_draw_bitmap() in the +// calling task (CPU-copy path, ILI9881C/EK79007), or from the DMA2D +// transfer-done ISR (JD9365, where DMA2D is enabled). It must therefore be +// ISR-safe: the only work done here is a couple of null checks and +// Display::notify_flush_ready() -> lv_display_flush_ready(), which just clears +// the display's `flushing` flag (no locking/allocation/logging; LVGL provides +// LV_ATTRIBUTE_FLUSH_READY for exactly this use). It is kept IRAM_ATTR so it +// still satisfies the esp_ptr_in_iram() check esp_lcd performs on registration +// when CONFIG_LCD_DSI_ISR_CACHE_SAFE is enabled. No task is woken here, so the +// correct "yield required" return value is always false. +bool IRAM_ATTR Esp32P4Wifi6DevKit::notify_lvgl_flush_ready(esp_lcd_panel_handle_t panel, + esp_lcd_dpi_panel_event_data_t *edata, + void *user_ctx) { + (void)panel; + (void)edata; + auto *board = static_cast(user_ctx); + if (board && board->display_) { + board->display_->notify_flush_ready(); + } + return false; // no high-priority task was woken +} + +void Esp32P4Wifi6DevKit::dsi_write_command(uint8_t cmd, std::span params, + uint32_t /*flags*/) { + if (!lcd_handles_.io) { + logger_.error("DSI write_command does not have a valid IO handle"); + return; + } + esp_err_t err = + esp_lcd_panel_io_tx_param(lcd_handles_.io, (int)cmd, params.data(), params.size()); + if (err != ESP_OK) { + logger_.error("DSI write_command 0x{:02X} failed: {}", cmd, esp_err_to_name(err)); + } +} + +void Esp32P4Wifi6DevKit::dsi_read_command(uint8_t cmd, std::span data, + uint32_t /*flags*/) { + if (!lcd_handles_.io) { + logger_.error("DSI read_command does not have a valid IO handle"); + return; + } + esp_err_t err = esp_lcd_panel_io_rx_param(lcd_handles_.io, (int)cmd, data.data(), data.size()); + if (err != ESP_OK) { + logger_.error("DSI read_command 0x{:02X} failed: {}", cmd, esp_err_to_name(err)); + } +} + +} // namespace espp diff --git a/doc/Doxyfile b/doc/Doxyfile index 7d8ab8fd7..986c97a34 100755 --- a/doc/Doxyfile +++ b/doc/Doxyfile @@ -111,6 +111,7 @@ EXAMPLE_PATH = \ $(PROJECT_PATH)/components/esp32-p4-function-ev-board/example/main/esp32_p4_function_ev_board_example.cpp \ $(PROJECT_PATH)/components/esp32-p4-module-dev-kit/example/main/esp32_p4_module_dev_kit_example.cpp \ $(PROJECT_PATH)/components/esp32-p4-nano/example/main/esp32_p4_nano_example.cpp \ + $(PROJECT_PATH)/components/esp32-p4-wifi6-dev-kit/example/main/esp32_p4_wifi6_dev_kit_example.cpp \ $(PROJECT_PATH)/components/esp32-p4-wifi6-poe-eth/example/main/esp32_p4_wifi6_poe_eth_example.cpp \ $(PROJECT_PATH)/components/esp32-timer-cam/example/main/esp_timer_cam_example.cpp \ $(PROJECT_PATH)/components/esp-box/example/main/esp_box_example.cpp \ @@ -286,6 +287,7 @@ INPUT = \ $(PROJECT_PATH)/components/esp32-p4-function-ev-board/include/esp32-p4-function-ev-board.hpp \ $(PROJECT_PATH)/components/esp32-p4-module-dev-kit/include/esp32-p4-module-dev-kit.hpp \ $(PROJECT_PATH)/components/esp32-p4-nano/include/esp32-p4-nano.hpp \ + $(PROJECT_PATH)/components/esp32-p4-wifi6-dev-kit/include/esp32-p4-wifi6-dev-kit.hpp \ $(PROJECT_PATH)/components/esp32-p4-wifi6-poe-eth/include/esp32-p4-wifi6-poe-eth.hpp \ $(PROJECT_PATH)/components/esp32-timer-cam/include/esp32-timer-cam.hpp \ $(PROJECT_PATH)/components/esp-box/include/esp-box.hpp \ diff --git a/doc/en/dev_boards/waveshare/esp32_p4_wifi6_dev_kit.rst b/doc/en/dev_boards/waveshare/esp32_p4_wifi6_dev_kit.rst new file mode 100644 index 000000000..50422028f --- /dev/null +++ b/doc/en/dev_boards/waveshare/esp32_p4_wifi6_dev_kit.rst @@ -0,0 +1,53 @@ +Waveshare ESP32-P4-WIFI6-DEV-KIT +******************************** + +ESP32-P4-WIFI6-DEV-KIT +---------------------- + +The Waveshare ESP32-P4-WIFI6-DEV-KIT is an ESP32-P4 development board with an +onboard ESP32-C6 co-processor (Wi-Fi 6 / Bluetooth 5 LE over SDIO via +ESP-Hosted), a MIPI-DSI display connector, capacitive touch, a MIPI-CSI camera +connector, audio in/out with a headphone jack, a microSD slot, 10/100 Ethernet +(with an optional external PoE-module header), and a Raspberry-Pi-compatible +40-pin GPIO header. + +The ``espp::Esp32P4Wifi6DevKit`` component provides a singleton hardware +abstraction for bringing up the board's peripherals: + +- **Display:** a MIPI-DSI panel (JD9365 or ILI9881C 10.1" 800x1280, or EK79007 + 7" 1024x600, selected via Kconfig), with an LVGL display driver. +- **Touch:** a GT911 capacitive multi-touch controller (polled — the INT / RST + lines are not routed to the ESP32-P4 on this board). +- **Camera:** a MIPI-CSI camera (OV5647 by default) captured through esp_video + (V4L2), delivering RGB565 frames. +- **Audio:** an ES8311 codec with an NS4150B amplifier for speaker output, and + the onboard analog microphone through the codec's ADC (full-duplex over I2S). + Inserting headphones into the 3.5 mm jack mutes the speaker amplifier in + hardware. +- **microSD / TF card:** a 4-bit SDMMC slot powered by the on-chip LDO. +- **Ethernet:** 10/100 via the internal EMAC and an IP101GRI RMII PHY, + delegating to the reusable :doc:`espp::Ethernet <../../network/ethernet>` + component and supplying the board-specific RMII pins (DHCP client / server). + +The ES8311 codec, GT911 touch controller, and camera SCCB share a single +internal I2C bus. The camera pipeline requires PSRAM and the MIPI-CSI Kconfig +options enabled (see the example's ``sdkconfig.defaults``). + +Wi-Fi / Bluetooth are not wrapped by the BSP: the onboard ESP32-C6 runs the +ESP-Hosted slave firmware and is used through the ``espressif/esp_hosted`` + +``espressif/esp_wifi_remote`` managed components, after which the standard +``esp_wifi`` API works unchanged (the board wiring matches the ESP-Hosted SDIO +defaults for the ESP32-P4). See the example README for the exact steps. + +.. ------------------------------- Example ------------------------------------- + +.. toctree:: + + esp32_p4_wifi6_dev_kit_example + +.. ---------------------------- API Reference ---------------------------------- + +API Reference +------------- + +.. include-build-file:: inc/esp32-p4-wifi6-dev-kit.inc diff --git a/doc/en/dev_boards/waveshare/esp32_p4_wifi6_dev_kit_example.md b/doc/en/dev_boards/waveshare/esp32_p4_wifi6_dev_kit_example.md new file mode 100644 index 000000000..b8a07fa86 --- /dev/null +++ b/doc/en/dev_boards/waveshare/esp32_p4_wifi6_dev_kit_example.md @@ -0,0 +1,2 @@ +```{include} ../../../../components/esp32-p4-wifi6-dev-kit/example/README.md +``` diff --git a/doc/en/dev_boards/waveshare/index.rst b/doc/en/dev_boards/waveshare/index.rst index 58e923e95..21d70c891 100644 --- a/doc/en/dev_boards/waveshare/index.rst +++ b/doc/en/dev_boards/waveshare/index.rst @@ -7,6 +7,7 @@ Waveshare Boards esp32_p4_eth esp32_p4_module_dev_kit esp32_p4_nano + esp32_p4_wifi6_dev_kit esp32_p4_wifi6_poe_eth ws_s3_geek ws_s3_lcd_1_47