diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
old mode 100644
new mode 100755
index 4b03432f0..c1bb607c4
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -73,6 +73,8 @@ jobs:
target: esp32s3
- path: 'components/event_manager/example'
target: esp32
+ - path: 'components/expressive_eyes/example'
+ target: esp32s3
- path: 'components/file_system/example'
target: esp32
- path: 'components/filters/example'
diff --git a/.github/workflows/upload_components.yml b/.github/workflows/upload_components.yml
old mode 100644
new mode 100755
index a84c47a80..f0d27c8e9
--- a/.github/workflows/upload_components.yml
+++ b/.github/workflows/upload_components.yml
@@ -59,6 +59,7 @@ jobs:
components/esp-box
components/esp32-timer-cam
components/event_manager
+ components/expressive_eyes
components/file_system
components/filters
components/format
diff --git a/components/expressive_eyes/CMakeLists.txt b/components/expressive_eyes/CMakeLists.txt
new file mode 100644
index 000000000..f8bef1050
--- /dev/null
+++ b/components/expressive_eyes/CMakeLists.txt
@@ -0,0 +1,5 @@
+idf_component_register(
+ INCLUDE_DIRS "include"
+ SRC_DIRS "src"
+ REQUIRES base_component
+)
diff --git a/components/expressive_eyes/README.md b/components/expressive_eyes/README.md
new file mode 100755
index 000000000..9e872920e
--- /dev/null
+++ b/components/expressive_eyes/README.md
@@ -0,0 +1,56 @@
+# Expressive Eyes Component
+
+[](https://components.espressif.com/components/espp/expressive_eyes)
+
+The `ExpressiveEyes` component provides animated expressive eyes for displays
+using simple blob shapes. Eyes can blink, look around, change expression, and
+display various emotions with smooth animations.
+
+The component uses a callback-based drawing system, allowing you to implement
+custom renderers for different display types and visual styles.
+
+https://github.com/user-attachments/assets/966f5e63-aeb8-4fc3-b915-c4936fee7a1f
+
+## Features
+
+- Multiple expressions (happy, sad, angry, surprised, neutral, sleepy, bored, wink_left, wink_right)
+- Smooth eye movement with look_at positioning
+- Automatic blinking with configurable intervals
+- Optional pupils with physics-based movement
+- Eyebrows and cheeks for enhanced expressions
+- Smooth expression transitions with blending
+- Customizable colors and sizes
+- Frame-based animation system
+
+
+
+ happy  |
+ sad  |
+
+
+ angry  |
+ surprised  |
+
+
+ sleepy  |
+ bored  |
+
+
+ wink left  |
+ wink right  |
+
+
+ looking left  |
+ looking right  |
+
+
+ looking up  |
+ looking down  |
+
+
+
+## Example
+
+The [example](./example) demonstrates the expressive eyes component with two
+different drawer implementations (full-featured realistic eyes and monochrome
+blue eyes) running on various ESP32 display boards.
diff --git a/components/expressive_eyes/example/CMakeLists.txt b/components/expressive_eyes/example/CMakeLists.txt
new file mode 100644
index 000000000..e27295bc2
--- /dev/null
+++ b/components/expressive_eyes/example/CMakeLists.txt
@@ -0,0 +1,22 @@
+# The following lines of boilerplate have to be in your project's CMakeLists
+# in this exact order for cmake to work correctly
+cmake_minimum_required(VERSION 3.20)
+
+set(ENV{IDF_COMPONENT_MANAGER} "0")
+include($ENV{IDF_PATH}/tools/cmake/project.cmake)
+
+# add the component directories that we want to use
+set(EXTRA_COMPONENT_DIRS
+ "../../../components/"
+)
+
+set(
+ COMPONENTS
+ "main esptool_py expressive_eyes display task esp-box matouch-rotary-display ws-s3-touch"
+ CACHE STRING
+ "List of components to include"
+ )
+
+project(expressive_eyes_example)
+
+set(CMAKE_CXX_STANDARD 20)
diff --git a/components/expressive_eyes/example/README.md b/components/expressive_eyes/example/README.md
new file mode 100755
index 000000000..bce2f13fc
--- /dev/null
+++ b/components/expressive_eyes/example/README.md
@@ -0,0 +1,184 @@
+# Expressive Eyes Example
+
+This example demonstrates animated expressive eyes on various ESP32 display
+boards. It showcases different expressions, eye movements, and includes a
+continuous random demo mode perfect for a desk display.
+
+
+
+ happy  |
+ sad  |
+
+
+ angry  |
+ surprised  |
+
+
+ sleepy  |
+ bored  |
+
+
+ wink left  |
+ wink right  |
+
+
+ looking left  |
+ looking right  |
+
+
+ looking up  |
+ looking down  |
+
+
+
+Videos:
+
+https://github.com/user-attachments/assets/966f5e63-aeb8-4fc3-b915-c4936fee7a1f
+
+## How to use example
+
+### Hardware Required
+
+This example can be configured to run on the following dev boards:
+- ESP32-S3-BOX / ESP32-S3-BOX-3
+- MaTouch Rotary Display
+- WS-S3-Touch
+
+### Configure the project
+
+```
+idf.py menuconfig
+```
+
+Navigate to `Expressive Eyes Example Configuration` to:
+- Select your board
+- Choose drawing method (Full Featured or Monochrome Blue)
+
+### Build and Flash
+
+Build the project and flash it to the board, then run monitor tool to view serial output:
+
+```
+idf.py -p PORT flash monitor
+```
+
+(Replace PORT with the name of the serial port to use.)
+
+(To exit the serial monitor, type ``Ctrl-]``.)
+
+See the Getting Started Guide for full steps to configure and use ESP-IDF to build projects.
+
+## Example Breakdown
+
+The example has three phases:
+
+### 1. Expression Showcase
+
+Cycles through all available expressions (3 seconds each):
+- Neutral
+- Happy
+- Sad
+- Angry
+- Surprised
+
+### 2. Look Direction Demo
+
+Demonstrates the look_at functionality by looking in different directions (1.5 seconds each):
+- Left
+- Right
+- Up
+- Down
+- Center
+
+### 3. Random Demo Mode
+
+Enters continuous random demo mode for engaging desk display:
+- **Natural behavior**: Mostly stays neutral with occasional emotional expressions
+- **Random looking**: Eyes look in random directions every 2-6 seconds
+- **Expression changes**: Randomly changes expression every 5-15 seconds
+ - 50% neutral (most common)
+ - 20% happy
+ - 10% surprised
+ - 10% sad
+ - 10% angry
+- **Automatic blinking**: Eyes blink naturally at random intervals
+
+The random demo mode runs indefinitely, making it perfect for a continuous desk display.
+
+## Drawer Implementations
+
+The example includes two drawer implementations:
+
+### Full Featured Drawer
+- White eye background with black pupils
+- Eyebrows drawn as rotated lines with rounded caps
+- Cheeks for emotional expressions
+- Respects LVGL theme background color
+
+### Monochrome Blue Drawer
+- Electric blue (#00BFFF) eyes on black background
+- No pupils (solid colored eyes)
+- Minimalist aesthetic perfect for sci-fi interfaces
+- Black eyebrows and cheeks
+
+See [README_DRAWERS.md](./main/README_DRAWERS.md) for details on creating custom drawer implementations.
+
+## Example Output
+
+
+
+ happy  |
+ sad  |
+
+
+ angry  |
+ surprised  |
+
+
+ sleepy  |
+ bored  |
+
+
+ wink left  |
+ wink right  |
+
+
+ looking left  |
+ looking right  |
+
+
+ looking up  |
+ looking down  |
+
+
+
+
+
+```console
+I (886) main_task: Calling app_main()
+[Expressive Eyes Example/I][0.889]: Starting Expressive Eyes Example
+[WsS3Touch/I][0.899]: Initializing LCD...
+[WsS3Touch/I][0.900]: Initializing SPI...
+[WsS3Touch/I][0.906]: Adding device to SPI bus...
+[WsS3Touch/I][0.911]: Initializing the display driver...
+[WsS3Touch/I][1.321]: Display driver initialized successfully!
+W (1322) ledc: the binded timer can't keep alive in sleep
+[WsS3Touch/I][1.324]: Initializing display with pixel buffer size: 12000
+[WsS3Touch/I][1.334]: Display initialized successfully!
+[Expressive Eyes Example/I][1.335]: Display size: 240x280
+[Expressive Eyes Example/I][1.342]: Using Monochrome Blue drawer
+[Expressive Eyes Example/I][1.392]: Expressive eyes initialized
+[Expressive Eyes Example/I][1.392]: Testing different expressions...
+[Expressive Eyes Example/I][1.395]: Expression: Neutral
+[Expressive Eyes Example/I][11.928]: Expression: Happy
+[Expressive Eyes Example/I][22.548]: Expression: Sad
+[Expressive Eyes Example/I][33.168]: Expression: Angry
+[Expressive Eyes Example/I][43.788]: Expression: Surprised
+[Expressive Eyes Example/I][54.407]: Testing look_at functionality
+[Expressive Eyes Example/I][54.408]: Looking left
+[Expressive Eyes Example/I][59.717]: Looking right
+[Expressive Eyes Example/I][65.028]: Looking up
+[Expressive Eyes Example/I][70.337]: Looking down
+[Expressive Eyes Example/I][75.648]: Looking center
+[Expressive Eyes Example/I][80.957]: Starting random demo mode - will run continuously
+```
diff --git a/components/expressive_eyes/example/main/CMakeLists.txt b/components/expressive_eyes/example/main/CMakeLists.txt
new file mode 100644
index 000000000..a941e22ba
--- /dev/null
+++ b/components/expressive_eyes/example/main/CMakeLists.txt
@@ -0,0 +1,2 @@
+idf_component_register(SRC_DIRS "."
+ INCLUDE_DIRS ".")
diff --git a/components/expressive_eyes/example/main/Kconfig.projbuild b/components/expressive_eyes/example/main/Kconfig.projbuild
new file mode 100755
index 000000000..a5cdd75c9
--- /dev/null
+++ b/components/expressive_eyes/example/main/Kconfig.projbuild
@@ -0,0 +1,45 @@
+menu "Expressive Eyes Example Configuration"
+
+ choice EXPRESSIVE_EYES_BOARD
+ prompt "Select Board"
+ default EXPRESSIVE_EYES_BOARD_ESP_BOX
+ help
+ Select the hardware board to run the example on.
+
+ config EXPRESSIVE_EYES_BOARD_ESP_BOX
+ bool "ESP-BOX"
+ help
+ Use ESP32-S3-BOX or ESP32-S3-BOX-3 development board.
+
+ config EXPRESSIVE_EYES_BOARD_MATOUCH_ROTARY
+ bool "MaTouch Rotary Display"
+ help
+ Use MaTouch Rotary Display board.
+
+ config EXPRESSIVE_EYES_BOARD_WS_S3_TOUCH
+ bool "WS-S3-Touch BSP"
+ help
+ Use WS-S3-Touch development board.
+
+ endchoice
+
+ choice EXPRESSIVE_EYES_DRAWING_METHOD
+ prompt "Select Drawing Method"
+ default EXPRESSIVE_EYES_MONOCHROME_BLUE if EXPRESSIVE_EYES_BOARD_WS_S3_TOUCH
+ default EXPRESSIVE_EYES_FULL_FEATURED
+ help
+ Select the eye drawing implementation to use.
+
+ config EXPRESSIVE_EYES_FULL_FEATURED
+ bool "Full Featured (White eyes with black pupils)"
+ help
+ Draw realistic eyes with white background, black pupils, eyebrows, and cheeks.
+
+ config EXPRESSIVE_EYES_MONOCHROME_BLUE
+ bool "Monochrome Blue (Electric blue on black)"
+ help
+ Draw simple monochrome eyes in electric blue on black background without pupils.
+
+ endchoice
+
+endmenu
diff --git a/components/expressive_eyes/example/main/expressive_eyes_example.cpp b/components/expressive_eyes/example/main/expressive_eyes_example.cpp
new file mode 100644
index 000000000..763bee1cc
--- /dev/null
+++ b/components/expressive_eyes/example/main/expressive_eyes_example.cpp
@@ -0,0 +1,265 @@
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include "expressive_eyes.hpp"
+#include "logger.hpp"
+#include "task.hpp"
+
+// Include drawer implementations
+#include "full_featured_drawer.hpp"
+#include "monochrome_blue_drawer.hpp"
+
+// Board-specific includes based on menuconfig selection
+#if CONFIG_EXPRESSIVE_EYES_BOARD_ESP_BOX
+#include "esp-box.hpp"
+using Board = espp::EspBox;
+#elif CONFIG_EXPRESSIVE_EYES_BOARD_MATOUCH_ROTARY
+#include "matouch-rotary-display.hpp"
+using Board = espp::MatouchRotaryDisplay;
+#elif CONFIG_EXPRESSIVE_EYES_BOARD_WS_S3_TOUCH
+#include "ws-s3-touch.hpp"
+using Board = espp::WsS3Touch;
+#else
+#error "No board selected in menuconfig!"
+#endif
+
+using namespace std::chrono_literals;
+
+static std::recursive_mutex lvgl_mutex;
+
+extern "C" void app_main(void) {
+ espp::Logger logger({.tag = "Expressive Eyes Example", .level = espp::Logger::Verbosity::INFO});
+
+ logger.info("Starting Expressive Eyes Example");
+
+ //! [expressive eyes example]
+ // Initialize the board
+ Board &board = Board::get();
+ board.set_log_level(espp::Logger::Verbosity::INFO);
+
+ // Initialize the LCD
+ if (!board.initialize_lcd()) {
+ logger.error("Failed to initialize LCD!");
+ return;
+ }
+
+ // Set up pixel buffer (50 lines)
+ static constexpr size_t pixel_buffer_size = board.lcd_width() * 50;
+
+ // Initialize the display
+ if (!board.initialize_display(pixel_buffer_size)) {
+ logger.error("Failed to initialize display!");
+ return;
+ }
+
+ // Get screen dimensions
+ int screen_width = board.lcd_width();
+ int screen_height = board.lcd_height();
+
+ logger.info("Display size: {}x{}", screen_width, screen_height);
+
+ // Disable scrollbars on screen
+ lv_obj_clear_flag(lv_screen_active(), LV_OBJ_FLAG_SCROLLABLE);
+
+ // Create main canvas for drawing everything
+ lv_obj_t *canvas = lv_canvas_create(lv_screen_active());
+
+ // Allocate buffer for main canvas (RGB565 = 2 bytes per pixel)
+ static lv_color_t *canvas_buffer = nullptr;
+ canvas_buffer = (lv_color_t *)heap_caps_malloc(screen_width * screen_height * sizeof(lv_color_t),
+ MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
+ if (!canvas_buffer) {
+ logger.error("Failed to allocate canvas buffer!");
+ return;
+ }
+
+ lv_canvas_set_buffer(canvas, canvas_buffer, screen_width, screen_height, LV_COLOR_FORMAT_RGB565);
+ lv_obj_center(canvas);
+
+ // Create the drawer based on menuconfig selection
+ std::unique_ptr drawer;
+
+#if CONFIG_EXPRESSIVE_EYES_FULL_FEATURED
+ logger.info("Using Full Featured drawer");
+ drawer = std::make_unique(
+ eye_drawer::FullFeaturedDrawer::Config{.screen_width = screen_width,
+ .screen_height = screen_height,
+ .canvas = canvas,
+ .canvas_buffer = canvas_buffer,
+ .lvgl_mutex = lvgl_mutex});
+#elif CONFIG_EXPRESSIVE_EYES_MONOCHROME_BLUE
+ logger.info("Using Monochrome Blue drawer");
+ drawer = std::make_unique(
+ eye_drawer::MonochromeBlueDrawer::Config{.screen_width = screen_width,
+ .screen_height = screen_height,
+ .canvas = canvas,
+ .canvas_buffer = canvas_buffer,
+ .lvgl_mutex = lvgl_mutex});
+#else
+#error "No drawing method selected in menuconfig!"
+#endif
+
+ // Get the draw callback from the drawer
+ auto draw_eyes = drawer->get_draw_callback();
+
+ // Configure expressive eyes with adaptive sizing - make eyes larger
+ int large_eye_width = screen_width * 0.35f; // 35% of screen width each
+ int large_eye_height = screen_height * 0.55f; // 55% of screen height
+ int large_spacing = screen_width * 0.55f; // Space between eye centers
+
+ espp::ExpressiveEyes::Config config{.screen_width = screen_width,
+ .screen_height = screen_height,
+ .eye_spacing = large_spacing,
+ .eye_width = large_eye_width,
+ .eye_height = large_eye_height,
+ .blink_duration = 0.12f,
+ .blink_interval = 4.0f,
+ .enable_auto_blink = true,
+ .enable_pupil_physics = true,
+ .on_draw = draw_eyes,
+ .log_level = espp::Logger::Verbosity::WARN};
+
+ espp::ExpressiveEyes eyes(config);
+
+ // start a simple thread to do the lv_task_handler every 16ms
+ espp::Task lv_task({.callback = [](std::mutex &m, std::condition_variable &cv) -> bool {
+ {
+ std::lock_guard lock(lvgl_mutex);
+ lv_task_handler();
+ }
+ std::unique_lock lock(m);
+ cv.wait_for(lock, 16ms);
+ return false;
+ },
+ .task_config = {
+ .name = "lv_task",
+ .stack_size_bytes = 16 * 1024,
+ }});
+ lv_task.start();
+
+ logger.info("Expressive eyes initialized");
+
+ // Test different expressions using array iteration
+ logger.info("Testing different expressions...");
+
+ // Cycle through each expression preset to demonstrate different emotional states
+ const espp::ExpressiveEyes::Expression expressions[] = {
+ espp::ExpressiveEyes::Expression::NEUTRAL, espp::ExpressiveEyes::Expression::HAPPY,
+ espp::ExpressiveEyes::Expression::SAD, espp::ExpressiveEyes::Expression::ANGRY,
+ espp::ExpressiveEyes::Expression::SURPRISED, espp::ExpressiveEyes::Expression::SLEEPY,
+ espp::ExpressiveEyes::Expression::BORED, espp::ExpressiveEyes::Expression::WINK_LEFT,
+ espp::ExpressiveEyes::Expression::WINK_RIGHT};
+
+ for (const auto &expr : expressions) {
+ logger.info("Expression: {}", expr);
+ eyes.set_expression(expr);
+ auto start = std::chrono::steady_clock::now();
+ while (std::chrono::duration(std::chrono::steady_clock::now() - start).count() < 3.0f) {
+ eyes.update(0.016f); // 16ms update at ~60fps
+ std::this_thread::sleep_for(16ms);
+ }
+ }
+
+ // Test look_at functionality using array iteration
+ logger.info("Testing look_at functionality");
+ eyes.set_expression(espp::ExpressiveEyes::Expression::NEUTRAL);
+
+ // Demonstrate directional looking in the 4 cardinal directions plus center
+ struct LookDirection {
+ const char *name;
+ float x; ///< Horizontal direction: -1.0 = left, 1.0 = right, 0 = center
+ float y; ///< Vertical direction: -1.0 = up, 1.0 = down, 0 = center
+ };
+
+ const LookDirection look_directions[] = {{"left", -1.0f, 0.0f},
+ {"right", 1.0f, 0.0f},
+ {"up", 0.0f, -1.0f},
+ {"down", 0.0f, 1.0f},
+ {"center", 0.0f, 0.0f}};
+
+ for (const auto &dir : look_directions) {
+ logger.info("Looking {}", dir.name);
+ eyes.look_at(dir.x, dir.y);
+ auto start = std::chrono::steady_clock::now();
+ while (std::chrono::duration(std::chrono::steady_clock::now() - start).count() < 1.5f) {
+ eyes.update(0.016f); // 16ms update at ~60fps
+ std::this_thread::sleep_for(16ms);
+ }
+ }
+
+ // Random demo mode - continuously looks around and changes expressions
+ logger.info("Starting random demo mode - will run continuously");
+
+ // Seed random number generator with current time
+ srand(time(nullptr));
+
+ // Reset to neutral expression and center gaze
+ eyes.set_expression(espp::ExpressiveEyes::Expression::NEUTRAL);
+ eyes.look_at(0.0f, 0.0f);
+
+ // Random mode state - tracks when to trigger next action
+ float time_until_next_look = 2.0f + (rand() % 4000) / 1000.0f; // 2-6 seconds
+ float time_until_next_expression = 5.0f + (rand() % 10000) / 1000.0f; // 5-15 seconds
+ float look_timer = 0.0f;
+ float expression_timer = 0.0f;
+
+ // Animation loop - runs indefinitely for continuous desk display
+ auto last_time = std::chrono::steady_clock::now();
+ while (true) {
+ auto now = std::chrono::steady_clock::now();
+ float dt = std::chrono::duration(now - last_time).count();
+ last_time = now;
+
+ // Update timers
+ look_timer += dt;
+ expression_timer += dt;
+
+ // Randomly look around - gives eyes natural movement
+ if (look_timer >= time_until_next_look) {
+ float look_x = ((rand() % 2000) - 1000) / 1000.0f; // -1.0 to 1.0
+ float look_y = ((rand() % 2000) - 1000) / 1000.0f; // -1.0 to 1.0
+ eyes.look_at(look_x, look_y);
+ look_timer = 0.0f;
+ time_until_next_look = 2.0f + (rand() % 4000) / 1000.0f; // 2-6 seconds
+ }
+
+ // Randomly change expression (weighted toward neutral for natural behavior)
+ if (expression_timer >= time_until_next_expression) {
+ int expr_choice = rand() % 10;
+ if (expr_choice < 5) {
+ // 50% chance: stay neutral
+ eyes.set_expression(espp::ExpressiveEyes::Expression::NEUTRAL);
+ } else if (expr_choice < 7) {
+ // 20% chance: happy
+ eyes.set_expression(espp::ExpressiveEyes::Expression::HAPPY);
+ } else if (expr_choice < 8) {
+ // 10% chance: surprised
+ eyes.set_expression(espp::ExpressiveEyes::Expression::SURPRISED);
+ } else if (expr_choice < 9) {
+ // 10% chance: sad
+ eyes.set_expression(espp::ExpressiveEyes::Expression::SAD);
+ } else {
+ // 10% chance: angry
+ eyes.set_expression(espp::ExpressiveEyes::Expression::ANGRY);
+ }
+ expression_timer = 0.0f;
+ time_until_next_expression = 5.0f + (rand() % 10000) / 1000.0f; // 5-15 seconds
+ }
+
+ // Update and render eyes (calls draw callback)
+ eyes.update(dt);
+
+ std::this_thread::sleep_for(16ms); // ~60 FPS
+ }
+ //! [expressive eyes example]
+
+ logger.info("Expressive Eyes example complete!");
+
+ while (true) {
+ std::this_thread::sleep_for(1s);
+ }
+}
diff --git a/components/expressive_eyes/example/main/eye_drawer.hpp b/components/expressive_eyes/example/main/eye_drawer.hpp
new file mode 100644
index 000000000..2ca411fb1
--- /dev/null
+++ b/components/expressive_eyes/example/main/eye_drawer.hpp
@@ -0,0 +1,39 @@
+#pragma once
+
+#include "expressive_eyes.hpp"
+#include
+
+#include
+
+namespace eye_drawer {
+
+/**
+ * @brief Base interface for eye drawing implementations
+ *
+ * Defines the interface that all eye drawer implementations must follow.
+ * Drawer implementations receive eye state data from ExpressiveEyes and
+ * are responsible for rendering the eyes using their chosen graphics API.
+ *
+ * @note Implementations should use thread-safe operations when drawing
+ * (e.g., lock LVGL mutex before LVGL calls).
+ */
+struct EyeDrawer {
+ virtual ~EyeDrawer() = default;
+
+ /**
+ * @brief Callback function type for drawing eyes
+ * @param left_eye Left eye state data
+ * @param right_eye Right eye state data
+ */
+ typedef std::function
+ DrawCallback;
+
+ /**
+ * @brief Get the draw callback function
+ * @return The callback function to be used with ExpressiveEyes
+ */
+ virtual DrawCallback get_draw_callback() = 0;
+};
+
+} // namespace eye_drawer
diff --git a/components/expressive_eyes/example/main/full_featured_drawer.hpp b/components/expressive_eyes/example/main/full_featured_drawer.hpp
new file mode 100644
index 000000000..25cf6b6df
--- /dev/null
+++ b/components/expressive_eyes/example/main/full_featured_drawer.hpp
@@ -0,0 +1,217 @@
+#pragma once
+
+#include "eye_drawer.hpp"
+#include
+#include
+
+#include
+
+namespace eye_drawer {
+
+/// \brief Full-featured eye drawer with white eyes, black pupils, and eyebrows
+/// \details Draws realistic eyes with:
+/// - White eye background
+/// - Black pupils with position control
+/// - Eyebrows with rotation (cut out from the eye)
+/// - Cheeks (cut out from the eye)
+class FullFeaturedDrawer : public EyeDrawer {
+public:
+ struct Config {
+ int screen_width;
+ int screen_height;
+ lv_obj_t *canvas;
+ lv_color_t *canvas_buffer;
+ std::recursive_mutex &lvgl_mutex;
+ };
+
+ /**
+ * @brief Construct full-featured drawer
+ * @param config Configuration structure
+ */
+ explicit FullFeaturedDrawer(const Config &config)
+ : screen_width_(config.screen_width)
+ , screen_height_(config.screen_height)
+ , canvas_(config.canvas)
+ , canvas_buffer_(config.canvas_buffer)
+ , lvgl_mutex_(config.lvgl_mutex) {
+ // Calculate eye dimensions
+ original_eye_height_ = screen_height_ * 0.55f;
+ eye_base_width_ = screen_width_ * 0.35f;
+ pupil_size_ = static_cast(std::min(eye_base_width_, original_eye_height_) * 0.3f);
+ }
+
+ virtual ~FullFeaturedDrawer() override {
+ // nothing to clean up
+ }
+
+ virtual DrawCallback get_draw_callback() override {
+ return [this](const espp::ExpressiveEyes::EyeState &left,
+ const espp::ExpressiveEyes::EyeState &right) {
+ std::lock_guard lock(lvgl_mutex_);
+
+ // Get background color
+ lv_color_t bg_color = lv_obj_get_style_bg_color(lv_screen_active(), LV_PART_MAIN);
+
+ // Clear canvas with background color
+ lv_canvas_fill_bg(canvas_, bg_color, LV_OPA_COVER);
+
+ // Draw both eyes
+ draw_single_eye(left, true, bg_color);
+ draw_single_eye(right, false, bg_color);
+ };
+ }
+
+private:
+ /**
+ * @brief Draw a filled ellipse using LVGL layer API
+ * @param cx Center X coordinate
+ * @param cy Center Y coordinate
+ * @param width Ellipse width
+ * @param height Ellipse height
+ * @param color Fill color
+ */
+ void draw_ellipse(int cx, int cy, int width, int height, lv_color_t color) {
+ lv_layer_t layer;
+ lv_canvas_init_layer(canvas_, &layer);
+
+ lv_draw_rect_dsc_t rect_dsc;
+ lv_draw_rect_dsc_init(&rect_dsc);
+ rect_dsc.bg_color = color;
+ rect_dsc.bg_opa = LV_OPA_COVER;
+ rect_dsc.radius = LV_RADIUS_CIRCLE;
+ rect_dsc.border_width = 0;
+
+ lv_area_t area;
+ area.x1 = cx - width / 2;
+ area.y1 = cy - height / 2;
+ area.x2 = cx + width / 2;
+ area.y2 = cy + height / 2;
+
+ lv_draw_rect(&layer, &rect_dsc, &area);
+ lv_canvas_finish_layer(canvas_, &layer);
+ }
+
+ /**
+ * @brief Draw a single eye with all components
+ * @param eye_state Eye state data from ExpressiveEyes
+ * @param is_left True if left eye, false if right eye (affects eyebrow mirroring)
+ * @param bg_color Background color for eyebrows and cheeks
+ */
+ void draw_single_eye(const espp::ExpressiveEyes::EyeState &eye_state, bool is_left,
+ lv_color_t bg_color) {
+ // Draw eye white
+ draw_ellipse(eye_state.x, eye_state.y, eye_state.width, eye_state.height, lv_color_white());
+
+ // Draw pupil (use original height so blink doesn't move pupil)
+ // Only draw if eye is open enough (not blinking)
+ float openness = eye_state.height / static_cast(original_eye_height_);
+ if (eye_state.expression.pupil.enabled && openness > 0.3f) {
+ int px_offset = static_cast(eye_state.expression.pupil.x * eye_state.width * 0.3f);
+ int py_offset = static_cast(eye_state.expression.pupil.y * original_eye_height_ * 0.3f);
+ int pupil_x = eye_state.x + px_offset;
+ int pupil_y = eye_state.y + py_offset;
+
+ lv_layer_t layer;
+ lv_canvas_init_layer(canvas_, &layer);
+
+ lv_draw_rect_dsc_t rect_dsc;
+ lv_draw_rect_dsc_init(&rect_dsc);
+ rect_dsc.bg_color = lv_color_black();
+ rect_dsc.bg_opa = LV_OPA_COVER;
+ rect_dsc.radius = LV_RADIUS_CIRCLE;
+ rect_dsc.border_width = 0;
+
+ lv_area_t area;
+ int r = pupil_size_ / 2;
+ area.x1 = pupil_x - r;
+ area.y1 = pupil_y - r;
+ area.x2 = pupil_x + r;
+ area.y2 = pupil_y + r;
+
+ lv_draw_rect(&layer, &rect_dsc, &area);
+ lv_canvas_finish_layer(canvas_, &layer);
+ }
+
+ // Draw eyebrow as rotated line
+ if (eye_state.expression.eyebrow.enabled) {
+ int brow_width =
+ static_cast(eye_base_width_ * eye_state.expression.eyebrow.width * 1.5f);
+ int brow_height =
+ static_cast(eye_base_width_ * eye_state.expression.eyebrow.thickness * 4.0f);
+ int brow_y = eye_state.y - static_cast(original_eye_height_ * 0.4f);
+
+ // For left eye, positive angle tilts left side down (clockwise rotation)
+ // For right eye, positive angle tilts right side down (counter-clockwise rotation)
+ float angle_rad = eye_state.expression.eyebrow.angle; // Already in radians
+ if (!is_left)
+ angle_rad = -angle_rad; // Mirror for right eye
+
+ // Calculate the two endpoints of the line
+ float half_w = brow_width / 2.0f;
+ float cos_a = std::cos(angle_rad);
+ float sin_a = std::sin(angle_rad);
+
+ lv_point_precise_t p1, p2;
+ p1.x = eye_state.x - half_w * cos_a;
+ p1.y = brow_y - half_w * sin_a;
+ p2.x = eye_state.x + half_w * cos_a;
+ p2.y = brow_y + half_w * sin_a;
+
+ lv_layer_t layer;
+ lv_canvas_init_layer(canvas_, &layer);
+
+ lv_draw_line_dsc_t line_dsc;
+ lv_draw_line_dsc_init(&line_dsc);
+ line_dsc.color = bg_color;
+ line_dsc.width = brow_height;
+ line_dsc.opa = LV_OPA_COVER;
+ line_dsc.round_start = 1;
+ line_dsc.round_end = 1;
+ line_dsc.p1 = p1;
+ line_dsc.p2 = p2;
+
+ lv_draw_line(&layer, &line_dsc);
+
+ lv_canvas_finish_layer(canvas_, &layer);
+ }
+
+ // Draw cheek
+ if (eye_state.expression.cheek.enabled) {
+ int cheek_width = static_cast(eye_base_width_ * eye_state.expression.cheek.size * 2.5f);
+ int cheek_height = static_cast(eye_base_width_ * eye_state.expression.cheek.size * 1.5f);
+ int cheek_y = eye_state.y + static_cast(original_eye_height_ * 0.4f) +
+ static_cast(eye_state.expression.cheek_offset_y * screen_height_);
+
+ lv_layer_t layer;
+ lv_canvas_init_layer(canvas_, &layer);
+
+ lv_draw_rect_dsc_t rect_dsc;
+ lv_draw_rect_dsc_init(&rect_dsc);
+ rect_dsc.bg_color = bg_color;
+ rect_dsc.bg_opa = LV_OPA_COVER;
+ rect_dsc.radius = LV_RADIUS_CIRCLE;
+ rect_dsc.border_width = 0;
+
+ lv_area_t area;
+ area.x1 = eye_state.x - cheek_width / 2;
+ area.y1 = cheek_y - cheek_height / 2;
+ area.x2 = eye_state.x + cheek_width / 2;
+ area.y2 = cheek_y + cheek_height / 2;
+
+ lv_draw_rect(&layer, &rect_dsc, &area);
+ lv_canvas_finish_layer(canvas_, &layer);
+ }
+ }
+
+ int screen_width_;
+ int screen_height_;
+ lv_obj_t *canvas_;
+ lv_color_t *canvas_buffer_;
+ std::recursive_mutex &lvgl_mutex_;
+
+ int original_eye_height_;
+ int eye_base_width_;
+ int pupil_size_;
+};
+
+} // namespace eye_drawer
diff --git a/components/expressive_eyes/example/main/monochrome_blue_drawer.hpp b/components/expressive_eyes/example/main/monochrome_blue_drawer.hpp
new file mode 100644
index 000000000..a867c735c
--- /dev/null
+++ b/components/expressive_eyes/example/main/monochrome_blue_drawer.hpp
@@ -0,0 +1,183 @@
+#pragma once
+
+#include "eye_drawer.hpp"
+#include
+#include
+
+#include
+
+namespace eye_drawer {
+
+/// \brief Monochrome eye drawer with electric blue eyes on black background
+/// \details Draws simple eyes with:
+/// - Electric blue eye shapes (no pupils)
+/// - Black background
+/// - Eyebrows in black (cut out from the eye)
+/// - Cheeks in black (cut out from eye)
+class MonochromeBlueDrawer : public EyeDrawer {
+public:
+ struct Config {
+ int screen_width;
+ int screen_height;
+ lv_color_t color = lv_color_hex(0x00BFFF); // Electric blue color
+ lv_obj_t *canvas;
+ lv_color_t *canvas_buffer;
+ std::recursive_mutex &lvgl_mutex;
+ };
+
+ /**
+ * @brief Construct monochrome blue drawer
+ * @param config Configuration structure
+ */
+ explicit MonochromeBlueDrawer(const Config &config)
+ : screen_width_(config.screen_width)
+ , screen_height_(config.screen_height)
+ , canvas_(config.canvas)
+ , canvas_buffer_(config.canvas_buffer)
+ , lvgl_mutex_(config.lvgl_mutex)
+ , electric_blue_(config.color) {
+ // Calculate eye dimensions
+ original_eye_height_ = screen_height_ * 0.55f;
+ eye_base_width_ = screen_width_ * 0.35f;
+ }
+
+ virtual ~MonochromeBlueDrawer() override {
+ // nothing to clean up
+ }
+
+ virtual DrawCallback get_draw_callback() override {
+ return [this](const espp::ExpressiveEyes::EyeState &left,
+ const espp::ExpressiveEyes::EyeState &right) {
+ std::lock_guard lock(lvgl_mutex_);
+
+ // Clear canvas with black background
+ lv_canvas_fill_bg(canvas_, lv_color_black(), LV_OPA_COVER);
+
+ // Draw both eyes
+ draw_single_eye(left, true);
+ draw_single_eye(right, false);
+ };
+ }
+
+private:
+ /**
+ * @brief Draw a filled ellipse using LVGL layer API
+ * @param cx Center X coordinate
+ * @param cy Center Y coordinate
+ * @param width Ellipse width
+ * @param height Ellipse height
+ * @param color Fill color
+ */
+ void draw_ellipse(int cx, int cy, int width, int height, lv_color_t color) {
+ lv_layer_t layer;
+ lv_canvas_init_layer(canvas_, &layer);
+
+ lv_draw_rect_dsc_t rect_dsc;
+ lv_draw_rect_dsc_init(&rect_dsc);
+ rect_dsc.bg_color = color;
+ rect_dsc.bg_opa = LV_OPA_COVER;
+ rect_dsc.radius = LV_RADIUS_CIRCLE;
+ rect_dsc.border_width = 0;
+
+ lv_area_t area;
+ area.x1 = cx - width / 2;
+ area.y1 = cy - height / 2;
+ area.x2 = cx + width / 2;
+ area.y2 = cy + height / 2;
+
+ lv_draw_rect(&layer, &rect_dsc, &area);
+ lv_canvas_finish_layer(canvas_, &layer);
+ }
+
+ /**
+ * @brief Draw a single eye with all components
+ * @param eye_state Eye state data from ExpressiveEyes
+ * @param is_left True if left eye, false if right eye (affects eyebrow mirroring)
+ */
+ void draw_single_eye(const espp::ExpressiveEyes::EyeState &eye_state, bool is_left) {
+ // Draw eye in electric blue (no pupil)
+ draw_ellipse(eye_state.x, eye_state.y, eye_state.width, eye_state.height, electric_blue_);
+
+ // Draw eyebrow as rotated line
+ if (eye_state.expression.eyebrow.enabled) {
+ int brow_width =
+ static_cast(eye_base_width_ * eye_state.expression.eyebrow.width * 1.5f);
+ int brow_height =
+ static_cast(eye_base_width_ * eye_state.expression.eyebrow.thickness * 4.0f);
+ int brow_y = eye_state.y - static_cast(original_eye_height_ * 0.4f);
+
+ // For left eye, positive angle tilts left side down (clockwise rotation)
+ // For right eye, positive angle tilts right side down (counter-clockwise rotation)
+ float angle_rad = eye_state.expression.eyebrow.angle; // Already in radians
+ if (!is_left)
+ angle_rad = -angle_rad; // Mirror for right eye
+
+ // Calculate the two endpoints of the line
+ float half_w = brow_width / 2.0f;
+ float cos_a = std::cos(angle_rad);
+ float sin_a = std::sin(angle_rad);
+
+ lv_point_precise_t p1, p2;
+ p1.x = eye_state.x - half_w * cos_a;
+ p1.y = brow_y - half_w * sin_a;
+ p2.x = eye_state.x + half_w * cos_a;
+ p2.y = brow_y + half_w * sin_a;
+
+ lv_layer_t layer;
+ lv_canvas_init_layer(canvas_, &layer);
+
+ lv_draw_line_dsc_t line_dsc;
+ lv_draw_line_dsc_init(&line_dsc);
+ line_dsc.color = lv_color_black();
+ line_dsc.width = brow_height;
+ line_dsc.opa = LV_OPA_COVER;
+ line_dsc.round_start = 1;
+ line_dsc.round_end = 1;
+ line_dsc.p1 = p1;
+ line_dsc.p2 = p2;
+
+ lv_draw_line(&layer, &line_dsc);
+
+ lv_canvas_finish_layer(canvas_, &layer);
+ }
+
+ // Draw cheek in electric blue
+ if (eye_state.expression.cheek.enabled) {
+ int cheek_width = static_cast(eye_base_width_ * eye_state.expression.cheek.size * 2.5f);
+ int cheek_height = static_cast(eye_base_width_ * eye_state.expression.cheek.size * 1.5f);
+ int cheek_y = eye_state.y + static_cast(original_eye_height_ * 0.4f) +
+ static_cast(eye_state.expression.cheek_offset_y * screen_height_);
+
+ lv_layer_t layer;
+ lv_canvas_init_layer(canvas_, &layer);
+
+ lv_draw_rect_dsc_t rect_dsc;
+ lv_draw_rect_dsc_init(&rect_dsc);
+ rect_dsc.bg_color = lv_color_black();
+ rect_dsc.bg_opa = LV_OPA_COVER;
+ rect_dsc.radius = LV_RADIUS_CIRCLE;
+ rect_dsc.border_width = 0;
+
+ lv_area_t area;
+ area.x1 = eye_state.x - cheek_width / 2;
+ area.y1 = cheek_y - cheek_height / 2;
+ area.x2 = eye_state.x + cheek_width / 2;
+ area.y2 = cheek_y + cheek_height / 2;
+
+ lv_draw_rect(&layer, &rect_dsc, &area);
+ lv_canvas_finish_layer(canvas_, &layer);
+ }
+ }
+
+ int screen_width_;
+ int screen_height_;
+ lv_obj_t *canvas_;
+ lv_color_t *canvas_buffer_;
+ std::recursive_mutex &lvgl_mutex_;
+
+ int original_eye_height_;
+ int eye_base_width_;
+ lv_color_t electric_blue_;
+};
+
+} // namespace eye_drawer
diff --git a/components/expressive_eyes/example/sdkconfig.defaults b/components/expressive_eyes/example/sdkconfig.defaults
new file mode 100644
index 000000000..13501caba
--- /dev/null
+++ b/components/expressive_eyes/example/sdkconfig.defaults
@@ -0,0 +1,23 @@
+# ESP32-specific
+CONFIG_IDF_TARGET="esp32s3"
+CONFIG_ESP32S3_DEFAULT_CPU_FREQ_240=y
+
+# Common ESP-related
+#
+CONFIG_ESP_SYSTEM_EVENT_TASK_STACK_SIZE=4096
+CONFIG_ESP_MAIN_TASK_STACK_SIZE=8192
+
+# Flash size
+CONFIG_ESPTOOLPY_FLASHSIZE_4MB=y
+CONFIG_ESPTOOLPY_FLASHSIZE="4MB"
+
+# FreeRTOS
+CONFIG_FREERTOS_HZ=1000
+
+# PSRAM
+CONFIG_SPIRAM=y
+CONFIG_SPIRAM_MODE_OCT=y
+CONFIG_SPIRAM_SPEED_80M=y
+
+# Default board selection
+CONFIG_EXPRESSIVE_EYES_BOARD_ESP_BOX=y
diff --git a/components/expressive_eyes/idf_component.yml b/components/expressive_eyes/idf_component.yml
new file mode 100644
index 000000000..93f9e1434
--- /dev/null
+++ b/components/expressive_eyes/idf_component.yml
@@ -0,0 +1,21 @@
+## IDF Component Manager Manifest File
+license: "MIT"
+description: "Animated expressive eyes for displays"
+url: "https://github.com/esp-cpp/espp/tree/main/components/expressive_eyes"
+repository: "git://github.com/esp-cpp/espp.git"
+maintainers:
+ - William Emfinger
+documentation: "https://esp-cpp.github.io/espp/display/expressive_eyes.html"
+tags:
+ - cpp
+ - Animation
+ - Display
+ - Expressive
+ - Eyes
+ - Face
+dependencies:
+ idf:
+ version: '>=5.0'
+ espp/base_component: '>=1.0'
+examples:
+ - path: example
diff --git a/components/expressive_eyes/include/expressive_eyes.hpp b/components/expressive_eyes/include/expressive_eyes.hpp
new file mode 100644
index 000000000..c5eda8977
--- /dev/null
+++ b/components/expressive_eyes/include/expressive_eyes.hpp
@@ -0,0 +1,252 @@
+#pragma once
+
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include "base_component.hpp"
+
+namespace espp {
+/**
+ * @brief Expressive Eyes Animation Component
+ *
+ * Renders animated expressive eyes using simple blob shapes. Eyes can blink, look around,
+ * change expression, and display various emotions.
+ *
+ * Features:
+ * - Smooth eye movement and blinking
+ * - Multiple expressions (happy, sad, angry, surprised, etc.)
+ * - Optional pupils with physics-based movement
+ * - Eyebrows and cheeks for enhanced expressions
+ * - Customizable colors and sizes
+ * - Frame-based animation system
+ *
+ * \section eyes_ex1 Expressive Eyes Example
+ * \snippet expressive_eyes_example.cpp expressive eyes example
+ */
+class ExpressiveEyes : public BaseComponent {
+public:
+ /**
+ * @brief Pupil configuration
+ */
+ struct Pupil {
+ bool enabled{true}; ///< Whether to draw pupils
+ float size{0.3f}; ///< Pupil size relative to eye (0.0-1.0)
+ uint16_t color{0x0000}; ///< Pupil color (black)
+ float x{0.0f}; ///< Pupil X position (-1.0 to 1.0)
+ float y{0.0f}; ///< Pupil Y position (-1.0 to 1.0)
+ };
+
+ /**
+ * @brief Eyebrow configuration
+ */
+ struct Eyebrow {
+ bool enabled{false}; ///< Whether to draw eyebrows
+ float angle{0.0f}; ///< Eyebrow angle in degrees
+ float height{0.0f}; ///< Vertical offset relative to eye (-1.0 to 1.0)
+ float thickness{0.1f}; ///< Eyebrow thickness relative to eye
+ float width{1.0f}; ///< Eyebrow width relative to eye
+ uint16_t color{0x0000}; ///< Eyebrow color (black)
+ };
+
+ /**
+ * @brief Cheek configuration (for shaping eye bottom)
+ */
+ struct Cheek {
+ bool enabled{false}; ///< Whether to draw cheeks
+ float size{0.3f}; ///< Cheek size relative to eye
+ };
+
+ /**
+ * @brief Complete expression state
+ */
+ struct ExpressionState {
+ float eye_width_scale{1.0f}; ///< Eye width multiplier
+ float eye_height_scale{1.0f}; ///< Eye height multiplier
+ float eye_rotation{0.0f}; ///< Eye rotation in radians
+ float top_curve{0.5f}; ///< Top eyelid curve (0=flat, 1=round)
+ float bottom_curve{0.5f}; ///< Bottom eyelid curve (0=flat, 1=round)
+ float eye_offset_y{0.0f}; ///< Vertical offset for eye position
+ float cheek_offset_y{0.0f}; ///< Vertical offset for cheek position
+ Pupil pupil; ///< Pupil configuration
+ Eyebrow eyebrow; ///< Eyebrow configuration
+ Cheek cheek; ///< Cheek configuration
+ };
+
+ /**
+ * @brief Single eye render data
+ */
+ struct EyeState {
+ int x; ///< X coordinate of eye center
+ int y; ///< Y coordinate of eye center
+ int width; ///< Current eye width
+ int height; ///< Current eye height
+ ExpressionState expression; ///< Expression state for this eye
+ };
+
+ /**
+ * @brief Draw callback function
+ * @param left_eye Left eye render data
+ * @param right_eye Right eye render data
+ */
+ typedef std::function draw_callback;
+
+ /**
+ * @brief Eye expression presets
+ */
+ enum class Expression {
+ NEUTRAL, ///< Normal open eyes
+ HAPPY, ///< Squinted happy eyes with raised cheeks
+ SAD, ///< Droopy sad eyes with angled eyebrows
+ ANGRY, ///< Angled angry eyes with furrowed eyebrows
+ SURPRISED, ///< Wide open eyes with raised eyebrows
+ SLEEPY, ///< Droopy half-closed eyes with low eyebrows
+ BORED, ///< Half-closed eyes with neutral expression
+ WINK_LEFT, ///< Left eye closed
+ WINK_RIGHT, ///< Right eye closed
+ };
+
+ /**
+ * @brief Configuration for expressive eyes
+ */
+ struct Config {
+ int screen_width{320}; ///< Screen width in pixels
+ int screen_height{240}; ///< Screen height in pixels
+ int eye_spacing{100}; ///< Distance between eye centers
+ int eye_width{60}; ///< Base eye width
+ int eye_height{80}; ///< Base eye height
+ float blink_duration{0.12f}; ///< Blink duration in seconds
+ float blink_interval{4.0f}; ///< Average time between blinks
+ bool enable_auto_blink{true}; ///< Automatic random blinking
+ bool enable_pupil_physics{true}; ///< Smooth pupil movement
+ draw_callback on_draw{nullptr}; ///< Drawing callback
+ Logger::Verbosity log_level{Logger::Verbosity::WARN}; ///< Log verbosity
+ };
+
+ /**
+ * @brief Construct expressive eyes
+ * @param config Configuration structure
+ */
+ explicit ExpressiveEyes(const Config &config);
+
+ /**
+ * @brief Update animation (call this every frame)
+ * @param dt Delta time since last update in seconds
+ */
+ void update(float dt);
+
+ /**
+ * @brief Set target look direction
+ * @param x Horizontal look direction (-1.0 to 1.0, 0=center)
+ * @param y Vertical look direction (-1.0 to 1.0, 0=center)
+ */
+ void look_at(float x, float y);
+
+ /**
+ * @brief Set expression
+ * @param expr Expression to display
+ */
+ void set_expression(Expression expr);
+
+ /**
+ * @brief Trigger a blink
+ */
+ void blink();
+
+ /**
+ * @brief Get current expression
+ * @return Current expression
+ */
+ Expression get_expression() const { return current_expression_; }
+
+ /**
+ * @brief Get preset expression state
+ * @param expr Expression preset
+ * @return Expression state configuration
+ */
+ static ExpressionState get_preset_expression(Expression expr);
+
+protected:
+ void init(const Config &config);
+ void update_blink(float dt);
+ void update_pupils(float dt);
+ void update_expression(float dt);
+ void draw_eyes();
+ void blend_expression_states(ExpressionState &result, const ExpressionState &from,
+ const ExpressionState &to, float t);
+
+ float lerp(float a, float b, float t) { return a + (b - a) * t; }
+
+ Config config_;
+ Expression source_expression_{Expression::NEUTRAL}; ///< Source expression for blending
+ Expression current_expression_{Expression::NEUTRAL};
+ Expression target_expression_{Expression::NEUTRAL};
+ ExpressionState current_state_;
+ ExpressionState target_state_;
+ float expression_blend_{0.0f};
+
+ // Eye state
+ float blink_state_{0.0f}; // 0=open, 1=closed
+ float blink_timer_{0.0f};
+ float next_blink_time_{4.0f};
+ bool is_blinking_{false};
+
+ // Look direction
+ float target_look_x_{0.0f};
+ float target_look_y_{0.0f};
+ float current_look_x_{0.0f};
+ float current_look_y_{0.0f};
+
+ // Pupil physics
+ float pupil_velocity_x_{0.0f};
+ float pupil_velocity_y_{0.0f};
+
+ // Redraw throttling
+ float redraw_timer_{0.0f};
+};
+} // namespace espp
+
+// for libfmt formatting of ExpressiveEyes::Expression
+template <> struct fmt::formatter {
+ constexpr auto parse(format_parse_context &ctx) { return ctx.begin(); }
+ template
+ auto format(const espp::ExpressiveEyes::Expression &expr, FormatContext &ctx) const {
+ std::string name;
+ switch (expr) {
+ case espp::ExpressiveEyes::Expression::NEUTRAL:
+ name = "NEUTRAL";
+ break;
+ case espp::ExpressiveEyes::Expression::HAPPY:
+ name = "HAPPY";
+ break;
+ case espp::ExpressiveEyes::Expression::SAD:
+ name = "SAD";
+ break;
+ case espp::ExpressiveEyes::Expression::ANGRY:
+ name = "ANGRY";
+ break;
+ case espp::ExpressiveEyes::Expression::SURPRISED:
+ name = "SURPRISED";
+ break;
+ case espp::ExpressiveEyes::Expression::SLEEPY:
+ name = "SLEEPY";
+ break;
+ case espp::ExpressiveEyes::Expression::BORED:
+ name = "BORED";
+ break;
+ case espp::ExpressiveEyes::Expression::WINK_LEFT:
+ name = "WINK_LEFT";
+ break;
+ case espp::ExpressiveEyes::Expression::WINK_RIGHT:
+ name = "WINK_RIGHT";
+ break;
+ default:
+ name = "UNKNOWN";
+ break;
+ }
+ return fmt::format_to(ctx.out(), "{}", name);
+ }
+};
diff --git a/components/expressive_eyes/src/expressive_eyes.cpp b/components/expressive_eyes/src/expressive_eyes.cpp
new file mode 100644
index 000000000..536be08e0
--- /dev/null
+++ b/components/expressive_eyes/src/expressive_eyes.cpp
@@ -0,0 +1,440 @@
+#include "expressive_eyes.hpp"
+
+#include
+#include
+
+using namespace espp;
+
+ExpressiveEyes::ExpressiveEyes(const Config &config)
+ : BaseComponent("ExpressiveEyes", config.log_level) {
+ init(config);
+}
+
+void ExpressiveEyes::init(const Config &config) {
+ config_ = config;
+
+ // Initialize with neutral expression
+ current_state_ = get_preset_expression(Expression::NEUTRAL);
+ target_state_ = current_state_;
+
+ // Randomize initial blink time
+ static std::random_device rd;
+ static std::mt19937 gen(rd());
+ std::uniform_real_distribution dist(2.0f, config_.blink_interval * 1.5f);
+ next_blink_time_ = dist(gen);
+
+ logger_.info("Initialized: {}x{}, eye_size={}x{}", config_.screen_width, config_.screen_height,
+ config_.eye_width, config_.eye_height);
+}
+
+void ExpressiveEyes::update(float dt) {
+ update_blink(dt);
+ update_pupils(dt);
+ update_expression(dt);
+
+ // Limit redraw rate to avoid watchdog
+ static constexpr float MIN_REDRAW_INTERVAL = 0.033f; // ~30 FPS max
+ redraw_timer_ += dt;
+
+ if (redraw_timer_ >= MIN_REDRAW_INTERVAL) {
+ redraw_timer_ = 0.0f;
+ draw_eyes();
+ }
+}
+
+void ExpressiveEyes::update_blink(float dt) {
+ if (config_.enable_auto_blink) {
+ blink_timer_ += dt;
+
+ if (!is_blinking_ && blink_timer_ >= next_blink_time_) {
+ blink();
+ }
+ }
+
+ if (is_blinking_) {
+ blink_state_ += dt / config_.blink_duration;
+
+ if (blink_state_ >= 2.0f) {
+ // Blink complete
+ blink_state_ = 0.0f;
+ is_blinking_ = false;
+ blink_timer_ = 0.0f;
+
+ // Random next blink time
+ static std::random_device rd;
+ static std::mt19937 gen(rd());
+ std::uniform_real_distribution dist(config_.blink_interval * 0.5f,
+ config_.blink_interval * 1.5f);
+ next_blink_time_ = dist(gen);
+ }
+ }
+}
+
+void ExpressiveEyes::update_pupils(float dt) {
+ if (!current_state_.pupil.enabled)
+ return;
+
+ if (config_.enable_pupil_physics) {
+ // Spring physics for smooth pupil movement with critical damping to prevent overshoot
+ const float spring_strength = 40.0f; // Much stronger for faster response
+ const float damping = 15.0f; // Higher damping to prevent bounce
+
+ float dx = target_look_x_ - current_look_x_;
+ float dy = target_look_y_ - current_look_y_;
+
+ // Stop motion if very close to target
+ float distance = std::sqrt(dx * dx + dy * dy);
+ if (distance < 0.005f) { // Tighter threshold
+ current_look_x_ = target_look_x_;
+ current_look_y_ = target_look_y_;
+ pupil_velocity_x_ = 0.0f;
+ pupil_velocity_y_ = 0.0f;
+ } else {
+ pupil_velocity_x_ += dx * spring_strength * dt - pupil_velocity_x_ * damping * dt;
+ pupil_velocity_y_ += dy * spring_strength * dt - pupil_velocity_y_ * damping * dt;
+
+ current_look_x_ += pupil_velocity_x_ * dt;
+ current_look_y_ += pupil_velocity_y_ * dt;
+
+ // Clamp to valid range
+ current_look_x_ = std::clamp(current_look_x_, -1.0f, 1.0f);
+ current_look_y_ = std::clamp(current_look_y_, -1.0f, 1.0f);
+ }
+ } else {
+ // Direct movement
+ current_look_x_ = target_look_x_;
+ current_look_y_ = target_look_y_;
+ }
+
+ // Update pupil position in state
+ current_state_.pupil.x = current_look_x_;
+ current_state_.pupil.y = current_look_y_;
+}
+
+void ExpressiveEyes::update_expression(float dt) {
+ if (current_expression_ != target_expression_) {
+ // Start new blend - save current blended state as source
+ source_expression_ = current_expression_;
+ target_state_ = get_preset_expression(target_expression_);
+ expression_blend_ = 0.0f;
+ current_expression_ = target_expression_; // Update tracking immediately
+ }
+
+ // Blend towards target
+ if (expression_blend_ < 1.0f) {
+ expression_blend_ += dt * 8.0f; // Blend over ~0.125 seconds (fast, smooth transitions)
+ if (expression_blend_ > 1.0f) {
+ expression_blend_ = 1.0f;
+ }
+
+ ExpressionState from = get_preset_expression(source_expression_);
+ // Save current pupil offset before blending
+ float current_pupil_x = current_state_.pupil.x;
+ float current_pupil_y = current_state_.pupil.y;
+ blend_expression_states(current_state_, from, target_state_, expression_blend_);
+ // Restore pupil offset (controlled by update_pupils, not expression blend)
+ current_state_.pupil.x = current_pupil_x;
+ current_state_.pupil.y = current_pupil_y;
+ } else {
+ // Blend complete, use target state but preserve pupil offset
+ float current_pupil_x = current_state_.pupil.x;
+ float current_pupil_y = current_state_.pupil.y;
+ current_state_ = target_state_;
+ current_state_.pupil.x = current_pupil_x;
+ current_state_.pupil.y = current_pupil_y;
+ }
+}
+
+void ExpressiveEyes::blend_expression_states(ExpressionState &result, const ExpressionState &from,
+ const ExpressionState &to, float t) {
+ result.eye_width_scale = lerp(from.eye_width_scale, to.eye_width_scale, t);
+ result.eye_height_scale = lerp(from.eye_height_scale, to.eye_height_scale, t);
+ result.eye_rotation = lerp(from.eye_rotation, to.eye_rotation, t);
+ result.top_curve = lerp(from.top_curve, to.top_curve, t);
+ result.bottom_curve = lerp(from.bottom_curve, to.bottom_curve, t);
+ result.eye_offset_y = lerp(from.eye_offset_y, to.eye_offset_y, t);
+
+ // Eyebrow blending - animate sliding in/out
+ if (from.eyebrow.enabled && to.eyebrow.enabled) {
+ // Both enabled - blend normally
+ result.eyebrow.enabled = true;
+ result.eyebrow.angle = lerp(from.eyebrow.angle, to.eyebrow.angle, t);
+ result.eyebrow.height = lerp(from.eyebrow.height, to.eyebrow.height, t);
+ result.eyebrow.thickness = lerp(from.eyebrow.thickness, to.eyebrow.thickness, t);
+ result.eyebrow.width = lerp(from.eyebrow.width, to.eyebrow.width, t);
+ } else if (!from.eyebrow.enabled && to.eyebrow.enabled) {
+ // Fading in - slide down from above
+ result.eyebrow.enabled = true;
+ result.eyebrow.angle = to.eyebrow.angle;
+ result.eyebrow.height = lerp(-1.2f, to.eyebrow.height, t); // Start above screen
+ result.eyebrow.thickness = to.eyebrow.thickness;
+ result.eyebrow.width = to.eyebrow.width;
+ } else if (from.eyebrow.enabled && !to.eyebrow.enabled) {
+ // Fading out - slide up above screen
+ result.eyebrow.enabled = (t < 1.0f);
+ result.eyebrow.angle = from.eyebrow.angle;
+ result.eyebrow.height = lerp(from.eyebrow.height, -1.2f, t); // Slide up
+ result.eyebrow.thickness = from.eyebrow.thickness;
+ result.eyebrow.width = from.eyebrow.width;
+ } else {
+ // Both disabled
+ result.eyebrow.enabled = false;
+ }
+
+ // Cheek blending - animate sliding in/out
+ if (from.cheek.enabled && to.cheek.enabled) {
+ // Both enabled - blend normally
+ result.cheek.enabled = true;
+ result.cheek.size = lerp(from.cheek.size, to.cheek.size, t);
+ result.cheek_offset_y = lerp(from.cheek_offset_y, to.cheek_offset_y, t);
+ } else if (!from.cheek.enabled && to.cheek.enabled) {
+ // Fading in - slide up from below
+ result.cheek.enabled = true;
+ result.cheek.size = to.cheek.size;
+ result.cheek_offset_y = lerp(1.2f, to.cheek_offset_y, t); // Start below screen
+ } else if (from.cheek.enabled && !to.cheek.enabled) {
+ // Fading out - slide down below screen
+ result.cheek.enabled = (t < 1.0f);
+ result.cheek.size = from.cheek.size;
+ result.cheek_offset_y = lerp(from.cheek_offset_y, 1.2f, t); // Slide down
+ } else {
+ // Both disabled
+ result.cheek.enabled = false;
+ }
+
+ // Keep pupil settings from target but use current look position
+ result.pupil = to.pupil;
+ result.pupil.x = current_look_x_;
+ result.pupil.y = current_look_y_;
+}
+
+void ExpressiveEyes::draw_eyes() {
+ if (!config_.on_draw)
+ return;
+
+ int center_x = config_.screen_width / 2;
+ int center_y = config_.screen_height / 2;
+
+ int left_x = center_x - config_.eye_spacing / 2;
+ int right_x = center_x + config_.eye_spacing / 2;
+
+ // Apply vertical offset from expression
+ int eye_y = center_y + static_cast(current_state_.eye_offset_y * config_.screen_height);
+
+ // Apply eye position offset based on look direction
+ // Scale the offset to be smaller than pupil movement (about 15% of eye width/height)
+ int eye_offset_x = static_cast(current_look_x_ * config_.eye_width * 0.15f);
+ int eye_offset_y = static_cast(current_look_y_ * config_.eye_height * 0.15f);
+
+ left_x += eye_offset_x;
+ right_x += eye_offset_x;
+ eye_y += eye_offset_y;
+
+ // Calculate blink effect (0=open, 1=closed)
+ float blink_amount = 0.0f;
+ if (is_blinking_) {
+ if (blink_state_ <= 1.0f) {
+ // Closing (0 -> 1)
+ blink_amount = blink_state_;
+ } else {
+ // Opening (1 -> 0)
+ blink_amount = 2.0f - blink_state_;
+ }
+ }
+
+ // Compute final eye dimensions
+ int eye_width = static_cast(config_.eye_width * current_state_.eye_width_scale);
+ int base_eye_height = static_cast(config_.eye_height * current_state_.eye_height_scale);
+
+ // Handle wink expressions - blend between wink states
+ float target_left_blink = blink_amount;
+ float target_right_blink = blink_amount;
+
+ if (target_expression_ == Expression::WINK_LEFT) {
+ target_left_blink = 0.9f; // Almost closed
+ target_right_blink = 0.0f; // Open
+ } else if (target_expression_ == Expression::WINK_RIGHT) {
+ target_left_blink = 0.0f; // Open
+ target_right_blink = 0.9f; // Almost closed
+ }
+
+ // If blending between expressions, lerp the wink amounts
+ float left_blink = target_left_blink;
+ float right_blink = target_right_blink;
+
+ if (expression_blend_ < 1.0f) {
+ float source_left_blink = blink_amount;
+ float source_right_blink = blink_amount;
+
+ if (source_expression_ == Expression::WINK_LEFT) {
+ source_left_blink = 0.9f;
+ source_right_blink = 0.0f;
+ } else if (source_expression_ == Expression::WINK_RIGHT) {
+ source_left_blink = 0.0f;
+ source_right_blink = 0.9f;
+ }
+
+ left_blink = lerp(source_left_blink, target_left_blink, expression_blend_);
+ right_blink = lerp(source_right_blink, target_right_blink, expression_blend_);
+ }
+
+ int left_eye_height = static_cast(base_eye_height * (1.0f - left_blink));
+ int right_eye_height = static_cast(base_eye_height * (1.0f - right_blink));
+
+ // Create state for each eye
+ EyeState left_eye;
+ left_eye.x = left_x;
+ left_eye.y = eye_y;
+ left_eye.width = eye_width;
+ left_eye.height = left_eye_height;
+ left_eye.expression = current_state_;
+
+ EyeState right_eye;
+ right_eye.x = right_x;
+ right_eye.y = eye_y;
+ right_eye.width = eye_width;
+ right_eye.height = right_eye_height;
+ right_eye.expression = current_state_;
+ right_eye.expression.eye_rotation = -current_state_.eye_rotation; // Mirror rotation
+
+ // Draw both eyes in single call
+ config_.on_draw(left_eye, right_eye);
+}
+
+void ExpressiveEyes::look_at(float x, float y) {
+ target_look_x_ = std::clamp(x, -1.0f, 1.0f);
+ target_look_y_ = std::clamp(y, -1.0f, 1.0f);
+}
+
+void ExpressiveEyes::set_expression(Expression expr) {
+ target_expression_ = expr;
+ logger_.debug("Expression changed to: {}", static_cast(expr));
+}
+
+void ExpressiveEyes::blink() {
+ if (!is_blinking_) {
+ is_blinking_ = true;
+ blink_state_ = 0.0f;
+ logger_.debug("Blinking");
+ }
+}
+
+ExpressiveEyes::ExpressionState ExpressiveEyes::get_preset_expression(Expression expr) {
+ ExpressionState state;
+
+ // Default pupil - keep size constant across all expressions
+ state.pupil.enabled = true;
+ state.pupil.size = 0.3f; // Constant size
+ state.pupil.color = 0x0000;
+
+ switch (expr) {
+ case Expression::NEUTRAL:
+ state.eye_width_scale = 1.0f;
+ state.eye_height_scale = 1.0f;
+ state.eye_rotation = 0.0f;
+ state.top_curve = 0.5f;
+ state.bottom_curve = 0.5f;
+ break;
+
+ case Expression::HAPPY:
+ state.eye_width_scale = 1.0f; // Same size as normal
+ state.eye_height_scale = 1.0f; // Same size as normal
+ state.eye_rotation = 0.0f;
+ state.top_curve = 0.5f;
+ state.bottom_curve = 0.5f;
+ state.eye_offset_y = -0.08f; // Move eyes up slightly on face
+ state.cheek_offset_y = 0.18f; // Move cheeks much lower (positive is down)
+ // No eyebrows for happy (hidden by default)
+ state.cheek.enabled = true; // Shape bottom with cheeks
+ state.cheek.size = 0.8f; // Wider, more prominent cheeks
+ break;
+
+ case Expression::SAD:
+ state.eye_width_scale = 0.9f;
+ state.eye_height_scale = 0.85f; // Slightly drooped
+ state.eye_rotation = -0.25f; // Downward curve
+ state.top_curve = 0.3f; // Flatter top
+ state.bottom_curve = 0.7f; // Rounder bottom
+ state.eyebrow.enabled = true;
+ state.eyebrow.angle = -0.436f; // Angled UP toward center (sad brows) - ~25 degrees in radians
+ state.eyebrow.height = -0.35f;
+ state.eyebrow.thickness = 0.1f;
+ state.eyebrow.width = 1.0f;
+ state.eyebrow.color = 0x0000;
+ state.cheek.enabled = true; // Shape bottom with cheeks
+ state.cheek.size = 0.6f;
+ state.cheek_offset_y = 0.15f; // Position below eye
+ break;
+
+ case Expression::ANGRY:
+ state.eye_width_scale = 0.85f; // Narrower
+ state.eye_height_scale = 0.75f;
+ state.eye_rotation = 0.4f; // Sharp inward angle
+ state.top_curve = 0.2f; // Angular top
+ state.bottom_curve = 0.3f;
+ state.eyebrow.enabled = true;
+ state.eyebrow.angle =
+ 0.524f; // Angled DOWN toward center (angry furrowed brows) - ~30 degrees in radians
+ state.eyebrow.height = -0.15f; // Close to eyes
+ state.eyebrow.thickness = 0.12f; // Thicker
+ state.eyebrow.width = 0.9f;
+ state.eyebrow.color = 0x0000;
+ // Don't override pupil size
+ break;
+
+ case Expression::SURPRISED:
+ state.eye_width_scale = 1.3f; // Wide open
+ state.eye_height_scale = 1.4f;
+ state.eye_rotation = 0.0f;
+ state.top_curve = 0.7f;
+ state.bottom_curve = 0.7f;
+ state.eyebrow.enabled = false; // No eyebrows for surprised
+ // Don't override pupil size
+ break;
+
+ case Expression::SLEEPY:
+ state.eye_width_scale = 1.0f;
+ state.eye_height_scale = 0.35f; // Eyes mostly closed, droopy
+ state.eye_rotation = -0.1f; // Slight downward droop
+ state.top_curve = 0.3f;
+ state.bottom_curve = 0.3f;
+ state.pupil.enabled = true; // Keep pupils visible
+ state.eyebrow.enabled = true;
+ state.eyebrow.angle = 0.0f; // Straight across
+ state.eyebrow.height = -0.15f; // Lower, almost touching eyes
+ state.eyebrow.thickness = 0.18f; // Very thick, heavy looking
+ state.eyebrow.width = 1.0f;
+ state.eyebrow.color = 0x0000;
+ break;
+
+ case Expression::BORED:
+ state.eye_width_scale = 1.0f;
+ state.eye_height_scale = 0.5f; // Half-closed
+ state.eye_rotation = 0.0f;
+ state.top_curve = 0.3f;
+ state.bottom_curve = 0.3f;
+ state.pupil.enabled = true;
+ // No eyebrows for bored - just half-closed neutral eyes
+ break;
+
+ case Expression::WINK_LEFT:
+ // Base on neutral, blink applied per-eye in draw_eyes()
+ state.eye_width_scale = 1.0f;
+ state.eye_height_scale = 1.0f;
+ state.eye_rotation = 0.0f;
+ state.top_curve = 0.5f;
+ state.bottom_curve = 0.5f;
+ break;
+
+ case Expression::WINK_RIGHT:
+ // Base on neutral, blink applied per-eye in draw_eyes()
+ state.eye_width_scale = 1.0f;
+ state.eye_height_scale = 1.0f;
+ state.eye_rotation = 0.0f;
+ state.top_curve = 0.5f;
+ state.bottom_curve = 0.5f;
+ break;
+ }
+
+ return state;
+}
diff --git a/doc/Doxyfile b/doc/Doxyfile
old mode 100644
new mode 100755
index 78d398bc3..6c00a5180
--- a/doc/Doxyfile
+++ b/doc/Doxyfile
@@ -96,6 +96,7 @@ EXAMPLE_PATH = \
$(PROJECT_PATH)/components/esp32-timer-cam/example/main/esp_timer_cam_example.cpp \
$(PROJECT_PATH)/components/esp-box/example/main/esp_box_example.cpp \
$(PROJECT_PATH)/components/event_manager/example/main/event_manager_example.cpp \
+ $(PROJECT_PATH)/components/expressive_eyes/example/main/expressive_eyes_example.cpp \
$(PROJECT_PATH)/components/file_system/example/main/file_system_example.cpp \
$(PROJECT_PATH)/components/filters/example/main/filters_example.cpp \
$(PROJECT_PATH)/components/ftp/example/main/ftp_example.cpp \
@@ -220,6 +221,7 @@ INPUT = \
$(PROJECT_PATH)/components/esp32-timer-cam/include/esp32-timer-cam.hpp \
$(PROJECT_PATH)/components/esp-box/include/esp-box.hpp \
$(PROJECT_PATH)/components/event_manager/include/event_manager.hpp \
+ $(PROJECT_PATH)/components/expressive_eyes/include/expressive_eyes.hpp \
$(PROJECT_PATH)/components/file_system/include/file_system.hpp \
$(PROJECT_PATH)/components/filters/include/biquad_filter.hpp \
$(PROJECT_PATH)/components/filters/include/butterworth_filter.hpp \
diff --git a/doc/en/display/expressive_eyes.rst b/doc/en/display/expressive_eyes.rst
new file mode 100755
index 000000000..796028a8e
--- /dev/null
+++ b/doc/en/display/expressive_eyes.rst
@@ -0,0 +1,39 @@
+Expressive Eyes APIs
+********************
+
+The `ExpressiveEyes` class provides an animated expressive eyes system for
+displays using simple blob shapes. It supports multiple expressions, smooth
+eye movement, blinking, and optional physics-based pupil movement.
+
+The component uses a callback-based drawing system, allowing you to implement
+custom renderers for different display types and visual styles. Example drawer
+implementations are provided showing both realistic eyes with pupils and
+minimalist monochrome designs.
+
+Features include:
+
+- Multiple expressions (happy, sad, angry, surprised, neutral, sleepy, bored, wink_left, wink_right)
+- Smooth eye movement with look_at positioning
+- Automatic blinking with configurable intervals
+- Optional pupils with physics-based movement
+- Eyebrows and cheeks for enhanced expressions
+- Smooth expression transitions with blending
+- Customizable colors and sizes
+- Frame-based animation system
+
+The drawing callback receives complete eye state information including
+position, size, expression parameters, pupil position, eyebrow configuration,
+and more, giving you full control over the rendering.
+
+.. ------------------------------- Example -------------------------------------
+
+.. toctree::
+
+ expressive_eyes_example
+
+.. ---------------------------- API Reference ----------------------------------
+
+API Reference
+-------------
+
+.. include-build-file:: inc/expressive_eyes.inc
diff --git a/doc/en/display/expressive_eyes_example.md b/doc/en/display/expressive_eyes_example.md
new file mode 100755
index 000000000..afd9b8596
--- /dev/null
+++ b/doc/en/display/expressive_eyes_example.md
@@ -0,0 +1,2 @@
+```{include} ../../../components/expressive_eyes/example/README.md
+```
diff --git a/doc/en/display/index.rst b/doc/en/display/index.rst
old mode 100644
new mode 100755
index 919c1a8bd..80b9fbdb5
--- a/doc/en/display/index.rst
+++ b/doc/en/display/index.rst
@@ -6,6 +6,7 @@ Display APIs
display
display_drivers
+ expressive_eyes
Code examples for the display API are provided in the `display_drivers` example
folder..