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)
+
+[](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
+
+
+| Peripheral | Hardware | API |
+| Display | MIPI-DSI panel (JD9365 / ILI9881C 10.1" 800x1280 or EK79007 7" 1024x600, selected via Kconfig) | initialize_lcd(), initialize_display() |
+| Touch | GT911 capacitive multi-touch (polled; INT/RST not routed) | initialize_touch() |
+| Camera | MIPI-CSI (OV5647 by default) via esp_video / V4L2, RGB565 frames | initialize_camera(), stop_camera() |
+| Audio out | ES8311 codec + NS4150B amplifier over I2S | initialize_audio(), play_audio(), volume() |
+| Microphone | Onboard analog mic through the ES8311 ADC (full-duplex) | initialize_microphone() |
+| uSD / TF card | 4-bit SDMMC, powered by the on-chip LDO | initialize_sdcard() |
+| Ethernet | 10/100 internal EMAC + IP101GRI RMII PHY (DHCP client/server); RJ45 with optional external PoE-module header | initialize_ethernet() |
+| Wi-Fi 6 / BT5 | Onboard 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):
+///
+///
+/// | Signal | GPIO |
+/// | REF_CLK | 50 |
+/// | TX_EN | 49 |
+/// | TXD0 | 34 |
+/// | TXD1 | 35 |
+/// | CRS_DV | 28 |
+/// | RXD0 | 29 |
+/// | RXD1 | 30 |
+/// | MDC | 31 |
+/// | MDIO | 52 |
+/// | PHY_RST | 51 |
+///
+///
+/// ESP32-P4 ↔ ESP32-C6 SDIO link (ESP-Hosted; matches the esp_hosted
+/// SDIO defaults for the ESP32-P4):
+///
+///
+/// | Signal | GPIO |
+/// | SDIO CLK | 18 |
+/// | SDIO CMD | 19 |
+/// | SDIO D0 | 14 |
+/// | SDIO D1 | 15 |
+/// | SDIO D2 | 16 |
+/// | SDIO D3 | 17 |
+/// | 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