diff --git a/Devices/xteink-x4/CMakeLists.txt b/Devices/xteink-x4/CMakeLists.txt new file mode 100644 index 000000000..38c007833 --- /dev/null +++ b/Devices/xteink-x4/CMakeLists.txt @@ -0,0 +1,7 @@ +file(GLOB_RECURSE SOURCE_FILES source/*.c*) + +idf_component_register( + SRCS ${SOURCE_FILES} + INCLUDE_DIRS "source" + REQUIRES TactilityKernel driver +) diff --git a/Devices/xteink-x4/bindings/xteink,x4-power.yaml b/Devices/xteink-x4/bindings/xteink,x4-power.yaml new file mode 100644 index 000000000..ae054d3d3 --- /dev/null +++ b/Devices/xteink-x4/bindings/xteink,x4-power.yaml @@ -0,0 +1,36 @@ +description: > + Xteink X4 charge/power control: USB VBUS detection, battery-latch power off and the + GPIO3 power button (deep-sleep wake source and long-press sleep, mirroring the + M5Paper's power button in the reference firmware). + Battery voltage/capacity are handled separately by a generic battery-sense node. + +compatible: "xteink,x4-power" + +properties: + pin-usb-detect: + type: phandles + required: true + description: USB VBUS detect pin (1 = USB connected). Doubles as the charge indicator as the charge IC exposes no status pin. + pin-power-off: + type: phandles + required: true + description: Battery MOSFET latch pin, is driven HIGH at boot to keep the rail on, driven LOW and held to power off on battery + pin-power-button: + type: phandles + required: true + description: Power button pin (active-low, pulled up). Arms the deep-sleep GPIO wake source and triggers sleep when held. + power-button-hold-ms: + type: int + default: 400 + description: How long the power button must be held (ms) before the device sleeps. Matches the reference firmware's default long-press duration. + wake-hold-ms: + type: int + default: 0 + description: > + Battery cold-boot wake verification. On battery the only boot path is the power + button re-engaging the latch, so the driver polls the button for this many ms after + it starts: a boot that never shows a press is treated as a stray tap and the device + returns to sleep. USB-powered boots (flash, plug-in) and deep-sleep GPIO wakes are + always accepted regardless of this value. 0 (default) disables the check - enable + only once the firmware is confirmed stable, since a boot that fails verification + powers back off immediately. diff --git a/Devices/xteink-x4/device.properties b/Devices/xteink-x4/device.properties new file mode 100644 index 000000000..1d5c0caa6 --- /dev/null +++ b/Devices/xteink-x4/device.properties @@ -0,0 +1,22 @@ +general.vendor=Xteink +general.name=X4 + +apps.launcherAppId=Launcher + +hardware.target=ESP32C3 +hardware.flashSize=16MB +hardware.flashMode=DIO +hardware.spiRam=false +hardware.tinyUsb=true +hardware.bluetooth=true + +storage.userDataLocation=SD + +display.size=4.26" +display.shape=rectangle +display.dpi=230 + +lvgl.colorDepth=8 +lvgl.theme=Mono + +sdkconfig.CONFIG_BOOTLOADER_LOG_LEVEL_INFO=y diff --git a/Devices/xteink-x4/devicetree.yaml b/Devices/xteink-x4/devicetree.yaml new file mode 100644 index 000000000..3ad5056e6 --- /dev/null +++ b/Devices/xteink-x4/devicetree.yaml @@ -0,0 +1,6 @@ +dependencies: + - Platforms/platform-esp32 + - Drivers/button-adc-control-module + - Drivers/esp-epaper-module +bindings: bindings +dts: xteink,x4.dts diff --git a/Devices/xteink-x4/source/bindings/xteink_x4_power.h b/Devices/xteink-x4/source/bindings/xteink_x4_power.h new file mode 100644 index 000000000..66029d127 --- /dev/null +++ b/Devices/xteink-x4/source/bindings/xteink_x4_power.h @@ -0,0 +1,14 @@ +#pragma once + +#ifdef __cplusplus +extern "C" { +#endif + +#include +#include + +DEFINE_DEVICETREE(x4_power, struct XteinkX4PowerConfig) + +#ifdef __cplusplus +} +#endif diff --git a/Devices/xteink-x4/source/drivers/xteink_x4_power.cpp b/Devices/xteink-x4/source/drivers/xteink_x4_power.cpp new file mode 100644 index 000000000..301bb206b --- /dev/null +++ b/Devices/xteink-x4/source/drivers/xteink_x4_power.cpp @@ -0,0 +1,464 @@ +// SPDX-License-Identifier: Apache-2.0 +#include "xteink_x4_power.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +#include +#include + +#include + +constexpr auto* TAG = "XteinkX4Power"; +#define GET_CONFIG(device) (static_cast((device)->config)) + +// How long to wait after dropping the battery latch before concluding the device +// is still alive (e.g. because USB VBUS is keeping the rail up). +static constexpr TickType_t POWER_OFF_WAIT = pdMS_TO_TICKS(1000); + +static constexpr uint32_t POWER_BUTTON_POLL_MS = 20; +static constexpr uint32_t POWER_BUTTON_DEBOUNCE_MS = 30; +static constexpr configSTACK_DEPTH_TYPE POWER_BUTTON_THREAD_STACK_SIZE = 4096; + +extern "C" { + +extern Module xteink_x4_module; + +struct XteinkX4PowerInternal { + GpioDescriptor* usb_detect_descriptor = nullptr; + GpioDescriptor* power_off_descriptor = nullptr; + GpioDescriptor* power_button_descriptor = nullptr; + gpio_num_t power_off_native_pin = GPIO_NUM_NC; + gpio_num_t power_button_native_pin = GPIO_NUM_NC; + Device* power_supply_device = nullptr; + Device* device = nullptr; + uint32_t power_button_hold_ms = 0; + uint32_t wake_hold_ms = 0; + Thread* power_button_thread = nullptr; + bool stop_requested = false; +}; + +error_t xteink_x4_power_is_usb_connected(Device* device, bool* connected) { + auto* internal = static_cast(device_get_driver_data(device)); + return gpio_descriptor_get_level(internal->usb_detect_descriptor, connected); +} + +error_t xteink_x4_power_is_power_button_pressed(Device* device, bool* pressed) { + auto* internal = static_cast(device_get_driver_data(device)); + return gpio_descriptor_get_level(internal->power_button_descriptor, pressed); +} + +error_t xteink_x4_power_off(Device* device) { + LOG_W(TAG, "Power-off requested"); + // Note: callers are responsible for stopping the display (e.g. EPD refresh) before calling + // this. GPIO13 gates the battery MOSFET; pulling it LOW and holding it powers the MCU off on + // battery (mirrors the reference firmware's deep-sleep path). On self-latching units the pull + // re-engages after a button press; the hold guarantees the pin stays LOW through the loss of + // the digital domain so the latch doesn't float back on. + + auto* internal = static_cast(device_get_driver_data(device)); + + gpio_descriptor_set_level(internal->power_off_descriptor, false); + if (gpio_hold_en(internal->power_off_native_pin) != ESP_OK) { + LOG_E(TAG, "Failed to hold power-off pin low"); + return ERROR_RESOURCE; + } + // Retain the held state across deep sleep so an RTC wake can't re-engage the + // battery latch before the driver re-asserts it on boot. + gpio_deep_sleep_hold_en(); + + LOG_W(TAG, "Battery latch released. Waiting for power-off..."); + vTaskDelay(POWER_OFF_WAIT); + LOG_W(TAG, "Device did not power off as expected (USB power present?)"); + return ERROR_NONE; +} + +// region Power button (deep-sleep wake + long-press sleep) + +/** + * @brief Waits for the power button to be released, dropping the battery latch and + * entering deep sleep once it is. + * @note On battery the latch cut powers the MCU off before the deep sleep completes and + * the button physically re-engages the latch to boot again. While USB VBUS is present + * the MCU stays powered and the GPIO wake source below is what actually resumes it. + */ +error_t xteink_x4_power_enter_sleep(Device* device) { + auto* internal = static_cast(device_get_driver_data(device)); + + // Wait for the button to be released so the armed wake source doesn't fire + // immediately and wake the device right back up. + bool pressed = true; + while (pressed) { + if (gpio_descriptor_get_level(internal->power_button_descriptor, &pressed) != ERROR_NONE) { + vTaskDelay(pdMS_TO_TICKS(POWER_BUTTON_POLL_MS)); + continue; + } + if (pressed) { + vTaskDelay(pdMS_TO_TICKS(POWER_BUTTON_POLL_MS)); + } + } + + gpio_descriptor_set_level(internal->power_off_descriptor, false); + if (gpio_hold_en(internal->power_off_native_pin) != ESP_OK) { + LOG_E(TAG, "Failed to hold power-off pin low"); + gpio_descriptor_set_level(internal->power_off_descriptor, true); + return ERROR_RESOURCE; + } + + if (esp_deep_sleep_enable_gpio_wakeup(1ULL << internal->power_button_native_pin, ESP_GPIO_WAKEUP_GPIO_LOW) != ESP_OK) { + LOG_E(TAG, "Failed to arm power-button wakeup"); + gpio_hold_dis(internal->power_off_native_pin); + gpio_descriptor_set_level(internal->power_off_descriptor, true); + return ERROR_RESOURCE; + } + + // Keep the latch cut and the power-button pull-up through deep sleep. + gpio_deep_sleep_hold_en(); + esp_sleep_config_gpio_isolate(); + + LOG_W(TAG, "Entering deep sleep"); + esp_deep_sleep_start(); + + // Only reached if the deep sleep was aborted. + gpio_hold_dis(internal->power_off_native_pin); + gpio_descriptor_set_level(internal->power_off_descriptor, true); + return ERROR_NONE; +} + +// Whether this boot was caused by the power button: a GPIO wake from a USB-powered deep +// sleep, or a battery cold boot (with the latch dropped, the button is the only way to +// re-engage the battery rail). USB-powered cold boots (flash, plug-in) are excluded. +static bool boot_was_power_button_initiated(const XteinkX4PowerInternal* internal) { + if (esp_reset_reason() == ESP_RST_DEEPSLEEP) { + return esp_sleep_get_wakeup_cause() == ESP_SLEEP_WAKEUP_GPIO; + } + if (esp_reset_reason() == ESP_RST_POWERON) { + bool usb_connected = true; + return gpio_descriptor_get_level(internal->usb_detect_descriptor, &usb_connected) == ERROR_NONE && !usb_connected; + } + return false; +} + +// Battery cold boots are verified so a stray tap (e.g. in a bag) doesn't power the device +// on and drain the battery: the button must register as pressed within wake_hold_ms of the +// driver starting, or the device returns to sleep. Deliberate power-on holds (typically a +// second or more) are still down by the time the driver starts. Deep-sleep GPIO wakes are +// always accepted - the button was physically pressed to wake, and a glitch merely boots a +// USB-powered device once. +static void verify_boot_wake(XteinkX4PowerInternal* internal) { + if (internal->wake_hold_ms == 0 || !boot_was_power_button_initiated(internal)) { + return; + } + + const uint32_t start = get_millis(); + do { + bool pressed = false; + if (gpio_descriptor_get_level(internal->power_button_descriptor, &pressed) == ERROR_NONE && pressed) { + return; + } + vTaskDelay(pdMS_TO_TICKS(POWER_BUTTON_POLL_MS)); + } while ((get_millis() - start) < internal->wake_hold_ms); + + LOG_W(TAG, "Power button not held within %lu ms of boot; returning to sleep", static_cast(internal->wake_hold_ms)); + if (xteink_x4_power_enter_sleep(internal->device) != ERROR_NONE) { + LOG_E(TAG, "Failed to return to sleep"); + } +} + +static int32_t power_button_monitor(void* context) { + auto* internal = static_cast(context); + + // The press that powered the device on is still held; ignore it and wait for release + // so it doesn't count towards the long-press sleep trigger. + while (!internal->stop_requested) { + bool pressed = false; + if (gpio_descriptor_get_level(internal->power_button_descriptor, &pressed) != ERROR_NONE) { + vTaskDelay(pdMS_TO_TICKS(50)); + continue; + } + if (!pressed) { + break; + } + vTaskDelay(pdMS_TO_TICKS(POWER_BUTTON_POLL_MS)); + } + + bool debounced_pressed = false; + uint32_t last_change_time = 0; + uint32_t press_start_ticks = 0; + + while (!internal->stop_requested) { + bool raw_pressed = false; + if (gpio_descriptor_get_level(internal->power_button_descriptor, &raw_pressed) != ERROR_NONE) { + vTaskDelay(pdMS_TO_TICKS(50)); + continue; + } + + const uint32_t now = get_millis(); + if ((now - last_change_time) >= POWER_BUTTON_DEBOUNCE_MS && raw_pressed != debounced_pressed) { + last_change_time = now; + debounced_pressed = raw_pressed; + if (debounced_pressed) { + press_start_ticks = xTaskGetTickCount(); + } + } + + if (debounced_pressed && + (xTaskGetTickCount() - press_start_ticks) >= pdMS_TO_TICKS(internal->power_button_hold_ms)) { + LOG_I(TAG, "Power button held %lu ms, entering sleep", static_cast(internal->power_button_hold_ms)); + xteink_x4_power_enter_sleep(internal->device); + // Reached only if the deep sleep was aborted; require a fresh release before + // re-arming the trigger. + debounced_pressed = false; + last_change_time = get_millis(); + } + + vTaskDelay(pdMS_TO_TICKS(POWER_BUTTON_POLL_MS)); + } + + return 0; +} + +// endregion + +// region Power supply child device + +static bool ps_supports_property(Device*, PowerSupplyProperty property) { + return property == POWER_SUPPLY_PROP_IS_CHARGING; +} + +static error_t ps_get_property(Device* device, PowerSupplyProperty property, PowerSupplyPropertyValue* out_value) { + if (property != POWER_SUPPLY_PROP_IS_CHARGING) { + return ERROR_NOT_SUPPORTED; + } + // The X4's charge IC has no status pin; "charging" is inferred from VBUS presence, matching + // the reference firmware (see xteink_x4_power_is_usb_connected()). + bool connected; + error_t error = xteink_x4_power_is_usb_connected(device_get_parent(device), &connected); + if (error != ERROR_NONE) { + return error; + } + out_value->int_value = connected ? 1 : 0; + return ERROR_NONE; +} + +static bool ps_supports_charge_control(Device*) { return false; } +static bool ps_is_allowed_to_charge(Device*) { return false; } +static error_t ps_set_allowed_to_charge(Device*, bool) { return ERROR_NOT_SUPPORTED; } +static bool ps_supports_quick_charge(Device*) { return false; } +static bool ps_is_quick_charge_enabled(Device*) { return false; } +static error_t ps_set_quick_charge_enabled(Device*, bool) { return ERROR_NOT_SUPPORTED; } +static bool ps_supports_power_off(Device*) { return true; } +static error_t ps_power_off(Device* device) { return xteink_x4_power_off(device_get_parent(device)); } + +static constexpr PowerSupplyApi XTEINK_X4_POWER_SUPPLY_API = { + .supports_property = ps_supports_property, + .get_property = ps_get_property, + .supports_charge_control = ps_supports_charge_control, + .is_allowed_to_charge = ps_is_allowed_to_charge, + .set_allowed_to_charge = ps_set_allowed_to_charge, + .supports_quick_charge = ps_supports_quick_charge, + .is_quick_charge_enabled = ps_is_quick_charge_enabled, + .set_quick_charge_enabled = ps_set_quick_charge_enabled, + .supports_power_off = ps_supports_power_off, + .power_off = ps_power_off, +}; + +// Registered (driver_construct_add() in module.cpp) so driver_bind() has a valid ->internal, but +// never matched against a devicetree node: xteink_x4_power_driver wires it up directly by pointer. +Driver xteink_x4_power_supply_driver = { + .name = "xteink-x4-power-supply", + .compatible = (const char*[]) { "xteink-x4-power-supply", nullptr }, + .start_device = nullptr, + .stop_device = nullptr, + .api = &XTEINK_X4_POWER_SUPPLY_API, + .device_type = &POWER_SUPPLY_TYPE, + .owner = &xteink_x4_module, + .internal = nullptr +}; + +static error_t create_power_supply_child(Device* parent, Device*& out_child) { + auto* child = new(std::nothrow) Device { .address = 0, .name = "xteink-x4-power-supply", .config = nullptr, .parent = nullptr, .internal = nullptr }; + if (child == nullptr) { + return ERROR_OUT_OF_MEMORY; + } + + error_t error = device_construct(child); + if (error != ERROR_NONE) { + delete child; + return error; + } + + device_set_parent(child, parent); + device_set_driver(child, &xteink_x4_power_supply_driver); + + error = device_add(child); + if (error != ERROR_NONE) { + device_destruct(child); + delete child; + return error; + } + + error = device_start(child); + if (error != ERROR_NONE) { + device_remove(child); + device_destruct(child); + delete child; + return error; + } + + out_child = child; + return ERROR_NONE; +} + +static void destroy_power_supply_child(Device* child) { + check(device_stop(child) == ERROR_NONE); + check(device_remove(child) == ERROR_NONE); + check(device_destruct(child) == ERROR_NONE); + delete child; +} + +// endregion + +// region Driver lifecycle + +static error_t start(Device* device) { + const auto* config = GET_CONFIG(device); + + auto* internal = new(std::nothrow) XteinkX4PowerInternal(); + if (internal == nullptr) { + return ERROR_OUT_OF_MEMORY; + } + + internal->usb_detect_descriptor = gpio_descriptor_acquire(config->pin_usb_detect.gpio_controller, config->pin_usb_detect.pin, config->pin_usb_detect.flags | GPIO_FLAG_DIRECTION_INPUT, GPIO_OWNER_GPIO); + if (internal->usb_detect_descriptor == nullptr) { + LOG_E(TAG, "Failed to configure usb-detect pin"); + delete internal; + return ERROR_RESOURCE; + } + + internal->power_off_descriptor = gpio_descriptor_acquire(config->pin_power_off.gpio_controller, config->pin_power_off.pin, config->pin_power_off.flags | GPIO_FLAG_DIRECTION_OUTPUT, GPIO_OWNER_GPIO); + if (internal->power_off_descriptor == nullptr) { + LOG_E(TAG, "Failed to configure power-off pin"); + gpio_descriptor_release(internal->usb_detect_descriptor); + delete internal; + return ERROR_RESOURCE; + } + + if (gpio_descriptor_get_native_pin_number(internal->power_off_descriptor, &internal->power_off_native_pin) != ERROR_NONE) { + LOG_E(TAG, "Power-off pin has no native pin number"); + gpio_descriptor_release(internal->power_off_descriptor); + gpio_descriptor_release(internal->usb_detect_descriptor); + delete internal; + return ERROR_NOT_SUPPORTED; + } + + internal->power_button_descriptor = gpio_descriptor_acquire(config->pin_power_button.gpio_controller, config->pin_power_button.pin, config->pin_power_button.flags | GPIO_FLAG_DIRECTION_INPUT, GPIO_OWNER_GPIO); + if (internal->power_button_descriptor == nullptr) { + LOG_E(TAG, "Failed to configure power-button pin"); + gpio_descriptor_release(internal->power_off_descriptor); + gpio_descriptor_release(internal->usb_detect_descriptor); + delete internal; + return ERROR_RESOURCE; + } + + if (gpio_descriptor_get_native_pin_number(internal->power_button_descriptor, &internal->power_button_native_pin) != ERROR_NONE) { + LOG_E(TAG, "Power-button pin has no native pin number"); + gpio_descriptor_release(internal->power_button_descriptor); + gpio_descriptor_release(internal->power_off_descriptor); + gpio_descriptor_release(internal->usb_detect_descriptor); + delete internal; + return ERROR_NOT_SUPPORTED; + } + + // A previous power-off held this pin LOW; that state survives a reset, so release it + // before asserting the latch or the battery rail stays disconnected on non-self-latching + // units (see the reference firmware's holdPowerRails()). + gpio_hold_dis(internal->power_off_native_pin); + gpio_descriptor_set_level(internal->power_off_descriptor, true); + + error_t error = create_power_supply_child(device, internal->power_supply_device); + if (error != ERROR_NONE) { + gpio_descriptor_release(internal->power_button_descriptor); + gpio_descriptor_release(internal->power_off_descriptor); + gpio_descriptor_release(internal->usb_detect_descriptor); + delete internal; + return error; + } + + internal->device = device; + internal->power_button_hold_ms = config->power_button_hold_ms; + internal->wake_hold_ms = config->wake_hold_ms; + device_set_driver_data(device, internal); + + // On a battery cold boot a stray tap would otherwise leave the device running on + // battery until the next power-off; reject it before anything is started. + verify_boot_wake(internal); + + if (internal->power_button_hold_ms > 0) { + internal->power_button_thread = thread_alloc_full( + "x4_power_button", + POWER_BUTTON_THREAD_STACK_SIZE, + power_button_monitor, + internal, + tskNO_AFFINITY + ); + if (internal->power_button_thread == nullptr || thread_start(internal->power_button_thread) != ERROR_NONE) { + LOG_E(TAG, "Failed to start power-button monitor"); + if (internal->power_button_thread != nullptr) { + thread_free(internal->power_button_thread); + internal->power_button_thread = nullptr; + } + device_set_driver_data(device, nullptr); + destroy_power_supply_child(internal->power_supply_device); + gpio_descriptor_release(internal->power_button_descriptor); + gpio_descriptor_release(internal->power_off_descriptor); + gpio_descriptor_release(internal->usb_detect_descriptor); + delete internal; + return ERROR_RESOURCE; + } + } + + return ERROR_NONE; +} + +static error_t stop(Device* device) { + auto* internal = static_cast(device_get_driver_data(device)); + if (internal->power_button_thread != nullptr) { + internal->stop_requested = true; + thread_join(internal->power_button_thread, portMAX_DELAY, POWER_BUTTON_POLL_MS); + thread_free(internal->power_button_thread); + internal->power_button_thread = nullptr; + } + destroy_power_supply_child(internal->power_supply_device); + gpio_descriptor_release(internal->power_button_descriptor); + gpio_descriptor_release(internal->power_off_descriptor); + gpio_descriptor_release(internal->usb_detect_descriptor); + device_set_driver_data(device, nullptr); + delete internal; + return ERROR_NONE; +} + +// endregion + +Driver xteink_x4_power_driver = { + .name = "xteink-x4-power", + .compatible = (const char*[]) { "xteink,x4-power", nullptr }, + .start_device = start, + .stop_device = stop, + .api = nullptr, + .device_type = nullptr, + .owner = &xteink_x4_module, + .internal = nullptr +}; + +} diff --git a/Devices/xteink-x4/source/drivers/xteink_x4_power.h b/Devices/xteink-x4/source/drivers/xteink_x4_power.h new file mode 100644 index 000000000..67ca01ca8 --- /dev/null +++ b/Devices/xteink-x4/source/drivers/xteink_x4_power.h @@ -0,0 +1,60 @@ +#pragma once + +#ifdef __cplusplus +extern "C" { +#endif + +#include +#include + +#include +#include +#include + +struct XteinkX4PowerConfig { + /** USB VBUS detect pin: 1 = USB connected (charging), 0 = battery only */ + struct GpioPinSpec pin_usb_detect; + /** Battery MOSFET latch pin: driven HIGH to keep the battery rail on, LOW to power off */ + struct GpioPinSpec pin_power_off; + /** Power button pin (active-low, pulled up) */ + struct GpioPinSpec pin_power_button; + /** Hold duration (ms) of the power button before the device enters sleep */ + uint32_t power_button_hold_ms; + /** Minimum boot hold (ms); 0 disables battery cold-boot wake verification */ + uint32_t wake_hold_ms; +}; + +/** + * @brief Whether USB VBUS is currently present. + * @note This is the reference firmware's charge indicator as well: the X4's charge + * IC exposes no status pin, so "charging" is inferred from VBUS presence (see the + * sunwoods schematic thread: U0RXD reads ~1.0V idle and ~3.3V with USB plugged in). + */ +error_t xteink_x4_power_is_usb_connected(struct Device* device, bool* connected); + +/** + * @brief Whether the power button is currently pressed. + */ +error_t xteink_x4_power_is_power_button_pressed(struct Device* device, bool* pressed); + +/** + * @brief Drives the battery latch LOW and holds it, powering the board off on battery. + * @warning This only cuts the battery rail. While USB VBUS is present the board stays + * powered from USB (the LDO is disabled but the MCU keeps running), so callers should + * gate this on the USB-detect state when a full shutdown is required. + * @note Does not return on a successful battery power-off. + */ +error_t xteink_x4_power_off(struct Device* device); + +/** + * @brief Enters deep sleep. Drops the battery latch first (powering the MCU off on + * battery, so the power button physically re-engages the latch to boot again) and arms + * the power button as the deep-sleep GPIO wake source for the USB-powered case. + * @warning Callers are responsible for parking the display before calling this. + * @note Does not return on a successful deep sleep. + */ +error_t xteink_x4_power_enter_sleep(struct Device* device); + +#ifdef __cplusplus +} +#endif diff --git a/Devices/xteink-x4/source/module.cpp b/Devices/xteink-x4/source/module.cpp new file mode 100644 index 000000000..4979785b7 --- /dev/null +++ b/Devices/xteink-x4/source/module.cpp @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: Apache-2.0 +#include +#include + +extern "C" { + +extern Driver xteink_x4_power_driver; +extern Driver xteink_x4_power_supply_driver; + +static Driver* const xteink_x4_drivers[] = { + &xteink_x4_power_driver, + &xteink_x4_power_supply_driver, + nullptr +}; + +Module xteink_x4_module = { + .name = "xteink-x4", + .drivers = xteink_x4_drivers +}; + +} diff --git a/Devices/xteink-x4/xteink,x4.dts b/Devices/xteink-x4/xteink,x4.dts new file mode 100644 index 000000000..f842143e3 --- /dev/null +++ b/Devices/xteink-x4/xteink,x4.dts @@ -0,0 +1,90 @@ +/dts-v1/; + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +#include + +/ { + compatible = "root"; + model = "Xteink X4"; + + wifi0 { + compatible = "espressif,esp32-wifi-pinned"; + status = "disabled"; + }; + + adc0 { + compatible = "espressif,esp32-adc-oneshot"; + unit-id = ; + channels = , + , + ; + }; + + gpio0 { + compatible = "espressif,esp32-gpio"; + gpio-count = <22>; + }; + + buttons { + compatible = "tactility,button-adc-control"; + debounce-ms = <20>; + buttons = <&adc0 1 3803 3103 LV_KEY_ESC>, + <&adc0 1 3103 2093 LV_KEY_ENTER>, + <&adc0 1 2093 749 LV_KEY_PREV>, + <&adc0 1 749 -2147483648 LV_KEY_NEXT>, + <&adc0 2 3168 1123 LV_KEY_UP>, + <&adc0 2 1123 -2147483648 LV_KEY_DOWN>; + }; + + battery-sense { + compatible = "battery-sense"; + io-channel = <&adc0 0>; + reference-voltage-mv = <3300>; + multiplier = <2000>; + }; + + spi0 { + compatible = "espressif,esp32-spi"; + host = ; + cs-gpios = <&gpio0 12 GPIO_FLAG_NONE>; + pin-mosi = <&gpio0 10 GPIO_FLAG_NONE>; + pin-miso = <&gpio0 7 GPIO_FLAG_NONE>; + pin-sclk = <&gpio0 8 GPIO_FLAG_NONE>; + max-transfer-size = <4096>; + + sdcard@0 { + compatible = "espressif,esp32-sdspi"; + frequency-khz = <20000>; + }; + + epd@1 { + compatible = "tuanpmt,esp-epaper"; + pin-dc = <&gpio0 4 GPIO_FLAG_NONE>; + pin-reset = <&gpio0 5 GPIO_FLAG_NONE>; + pin-busy = <&gpio0 6 GPIO_FLAG_NONE>; + pin-cs = <&gpio0 21 GPIO_FLAG_NONE>; + clock-speed-hz = <20000000>; + panel-type = "gdeq0426t82"; + update-mode = <0>; + rotation = <1>; + }; + }; + + power { + compatible = "xteink,x4-power"; + pin-usb-detect = <&gpio0 20 GPIO_FLAG_NONE>; + pin-power-off = <&gpio0 13 GPIO_FLAG_NONE>; + pin-power-button = <&gpio0 3 GPIO_FLAG_PULL_UP>; + }; +}; diff --git a/Drivers/button-adc-control-module/CMakeLists.txt b/Drivers/button-adc-control-module/CMakeLists.txt new file mode 100644 index 000000000..61ab99b7a --- /dev/null +++ b/Drivers/button-adc-control-module/CMakeLists.txt @@ -0,0 +1,11 @@ +cmake_minimum_required(VERSION 3.20) + +include("${CMAKE_CURRENT_LIST_DIR}/../../Buildscripts/module.cmake") + +file(GLOB_RECURSE SOURCE_FILES "source/*.c*") + +tactility_add_module(button-adc-control-module + SRCS ${SOURCE_FILES} + INCLUDE_DIRS include/ + REQUIRES TactilityKernel platform-esp32 lvgl driver +) diff --git a/Drivers/button-adc-control-module/LICENSE-Apache-2.0.md b/Drivers/button-adc-control-module/LICENSE-Apache-2.0.md new file mode 100644 index 000000000..f5f4b8b5e --- /dev/null +++ b/Drivers/button-adc-control-module/LICENSE-Apache-2.0.md @@ -0,0 +1,195 @@ +Apache License +============== + +_Version 2.0, January 2004_ +_<>_ + +### Terms and Conditions for use, reproduction, and distribution + +#### 1. Definitions + +“License” shall mean the terms and conditions for use, reproduction, and +distribution as defined by Sections 1 through 9 of this document. + +“Licensor” shall mean the copyright owner or entity authorized by the copyright +owner that is granting the License. + +“Legal Entity” shall mean the union of the acting entity and all other entities +that control, are controlled by, or are under common control with that entity. +For the purposes of this definition, “control” means **(i)** the power, direct or +indirect, to cause the direction or management of such entity, whether by +contract or otherwise, or **(ii)** ownership of fifty percent (50%) or more of the +outstanding shares, or **(iii)** beneficial ownership of such entity. + +“You” (or “Your”) shall mean an individual or Legal Entity exercising +permissions granted by this License. + +“Source” form shall mean the preferred form for making modifications, including +but not limited to software source code, documentation source, and configuration +files. + +“Object” form shall mean any form resulting from mechanical transformation or +translation of a Source form, including but not limited to compiled object code, +generated documentation, and conversions to other media types. + +“Work” shall mean the work of authorship, whether in Source or Object form, made +available under the License, as indicated by a copyright notice that is included +in or attached to the work (an example is provided in the Appendix below). + +“Derivative Works” shall mean any work, whether in Source or Object form, that +is based on (or derived from) the Work and for which the editorial revisions, +annotations, elaborations, or other modifications represent, as a whole, an +original work of authorship. For the purposes of this License, Derivative Works +shall not include works that remain separable from, or merely link (or bind by +name) to the interfaces of, the Work and Derivative Works thereof. + +“Contribution” shall mean any work of authorship, including the original version +of the Work and any modifications or additions to that Work or Derivative Works +thereof, that is intentionally submitted to Licensor for inclusion in the Work +by the copyright owner or by an individual or Legal Entity authorized to submit +on behalf of the copyright owner. For the purposes of this definition, +“submitted” means any form of electronic, verbal, or written communication sent +to the Licensor or its representatives, including but not limited to +communication on electronic mailing lists, source code control systems, and +issue tracking systems that are managed by, or on behalf of, the Licensor for +the purpose of discussing and improving the Work, but excluding communication +that is conspicuously marked or otherwise designated in writing by the copyright +owner as “Not a Contribution.” + +“Contributor” shall mean Licensor and any individual or Legal Entity on behalf +of whom a Contribution has been received by Licensor and subsequently +incorporated within the Work. + +#### 2. Grant of Copyright License + +Subject to the terms and conditions of this License, each Contributor hereby +grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, +irrevocable copyright license to reproduce, prepare Derivative Works of, +publicly display, publicly perform, sublicense, and distribute the Work and such +Derivative Works in Source or Object form. + +#### 3. Grant of Patent License + +Subject to the terms and conditions of this License, each Contributor hereby +grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, +irrevocable (except as stated in this section) patent license to make, have +made, use, offer to sell, sell, import, and otherwise transfer the Work, where +such license applies only to those patent claims licensable by such Contributor +that are necessarily infringed by their Contribution(s) alone or by combination +of their Contribution(s) with the Work to which such Contribution(s) was +submitted. If You institute patent litigation against any entity (including a +cross-claim or counterclaim in a lawsuit) alleging that the Work or a +Contribution incorporated within the Work constitutes direct or contributory +patent infringement, then any patent licenses granted to You under this License +for that Work shall terminate as of the date such litigation is filed. + +#### 4. Redistribution + +You may reproduce and distribute copies of the Work or Derivative Works thereof +in any medium, with or without modifications, and in Source or Object form, +provided that You meet the following conditions: + +* **(a)** You must give any other recipients of the Work or Derivative Works a copy of +this License; and +* **(b)** You must cause any modified files to carry prominent notices stating that You +changed the files; and +* **(c)** You must retain, in the Source form of any Derivative Works that You distribute, +all copyright, patent, trademark, and attribution notices from the Source form +of the Work, excluding those notices that do not pertain to any part of the +Derivative Works; and +* **(d)** If the Work includes a “NOTICE” text file as part of its distribution, then any +Derivative Works that You distribute must include a readable copy of the +attribution notices contained within such NOTICE file, excluding those notices +that do not pertain to any part of the Derivative Works, in at least one of the +following places: within a NOTICE text file distributed as part of the +Derivative Works; within the Source form or documentation, if provided along +with the Derivative Works; or, within a display generated by the Derivative +Works, if and wherever such third-party notices normally appear. The contents of +the NOTICE file are for informational purposes only and do not modify the +License. You may add Your own attribution notices within Derivative Works that +You distribute, alongside or as an addendum to the NOTICE text from the Work, +provided that such additional attribution notices cannot be construed as +modifying the License. + +You may add Your own copyright statement to Your modifications and may provide +additional or different license terms and conditions for use, reproduction, or +distribution of Your modifications, or for any such Derivative Works as a whole, +provided Your use, reproduction, and distribution of the Work otherwise complies +with the conditions stated in this License. + +#### 5. Submission of Contributions + +Unless You explicitly state otherwise, any Contribution intentionally submitted +for inclusion in the Work by You to the Licensor shall be under the terms and +conditions of this License, without any additional terms or conditions. +Notwithstanding the above, nothing herein shall supersede or modify the terms of +any separate license agreement you may have executed with Licensor regarding +such Contributions. + +#### 6. Trademarks + +This License does not grant permission to use the trade names, trademarks, +service marks, or product names of the Licensor, except as required for +reasonable and customary use in describing the origin of the Work and +reproducing the content of the NOTICE file. + +#### 7. Disclaimer of Warranty + +Unless required by applicable law or agreed to in writing, Licensor provides the +Work (and each Contributor provides its Contributions) on an “AS IS” BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, +including, without limitation, any warranties or conditions of TITLE, +NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are +solely responsible for determining the appropriateness of using or +redistributing the Work and assume any risks associated with Your exercise of +permissions under this License. + +#### 8. Limitation of Liability + +In no event and under no legal theory, whether in tort (including negligence), +contract, or otherwise, unless required by applicable law (such as deliberate +and grossly negligent acts) or agreed to in writing, shall any Contributor be +liable to You for damages, including any direct, indirect, special, incidental, +or consequential damages of any character arising as a result of this License or +out of the use or inability to use the Work (including but not limited to +damages for loss of goodwill, work stoppage, computer failure or malfunction, or +any and all other commercial damages or losses), even if such Contributor has +been advised of the possibility of such damages. + +#### 9. Accepting Warranty or Additional Liability + +While redistributing the Work or Derivative Works thereof, You may choose to +offer, and charge a fee for, acceptance of support, warranty, indemnity, or +other liability obligations and/or rights consistent with this License. However, +in accepting such obligations, You may act only on Your own behalf and on Your +sole responsibility, not on behalf of any other Contributor, and only if You +agree to indemnify, defend, and hold each Contributor harmless for any liability +incurred by, or claims asserted against, such Contributor by reason of your +accepting any such warranty or additional liability. + +_END OF TERMS AND CONDITIONS_ + +### APPENDIX: How to apply the Apache License to your work + +To apply the Apache License to your work, attach the following boilerplate +notice, with the fields enclosed by brackets `[]` replaced with your own +identifying information. (Don't include the brackets!) The text should be +enclosed in the appropriate comment syntax for the file format. We also +recommend that a file or class name and description of purpose be included on +the same “printed page” as the copyright notice for easier identification within +third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + diff --git a/Drivers/button-adc-control-module/README.md b/Drivers/button-adc-control-module/README.md new file mode 100644 index 000000000..95ef085c5 --- /dev/null +++ b/Drivers/button-adc-control-module/README.md @@ -0,0 +1,36 @@ +# Button ADC Control + +Kernel driver for buttons behind an ADC resistor ladder (e.g. the Xteink X4's six side +buttons on two ADC pins). Exposes a `KEYBOARD_TYPE` device (`tactility/drivers/keyboard.h`) +that translates presses into LVGL navigation keys. + +Devicetree binding: `tactility,button-adc-control` (see `bindings/tactility,button-adc-control.yaml`). + +Each button is defined by an ADC channel and the raw-value band it occupies; a button is +pressed while `band_low < raw <= band_high`. Each entry is +``; the bottom-most band uses `-2147483648` +(INT32_MIN) as its `band_low` so the ladder's near-ground rung is still inside the band. +Bands come from the values recorded on real hardware (e.g. the X4's Back 3512 / +Confirm 2694 / Left 1493 / Right ~5 and Up 2242 / Down ~5 readings), split at the +midpoints between neighbouring readings. The ADC must be configured with the same +attenuation the ranges were recorded at (X4: 11 dB, `ADC_ATTEN_DB_12` on this IDF, its +non-deprecated alias). + +## Example + +```dts +buttons { + compatible = "tactility,button-adc-control"; + debounce-ms = <20>; + buttons = <&adc0 1 3803 3103 LV_KEY_ESC>, // Back, presses at 3512 + <&adc0 1 3103 2093 LV_KEY_ENTER>, // Confirm, presses at 2694 + <&adc0 1 2093 749 LV_KEY_LEFT>, // Left, presses at 1493 + <&adc0 1 749 -2147483648 LV_KEY_RIGHT>, // Right, presses at ~5 + <&adc0 2 3168 1123 LV_KEY_UP>, // Up, presses at 2242 + <&adc0 2 1123 -2147483648 LV_KEY_DOWN>; // Down, presses at ~5 +}; +``` + +## License + +[Apache License Version 2.0](LICENSE-Apache-2.0.md) diff --git a/Drivers/button-adc-control-module/bindings/tactility,button-adc-control.yaml b/Drivers/button-adc-control-module/bindings/tactility,button-adc-control.yaml new file mode 100644 index 000000000..33f1742f3 --- /dev/null +++ b/Drivers/button-adc-control-module/bindings/tactility,button-adc-control.yaml @@ -0,0 +1,22 @@ +description: > + Buttons behind an ADC resistor ladder, where each button occupies a distinct raw-value + band on a shared ADC channel. Exposes a KEYBOARD_TYPE device: presses and releases + translate to LVGL navigation keys, so apps can be driven without a touchscreen. + +compatible: "tactility,button-adc-control" + +properties: + buttons: + type: phandle-array + required: true + element-type: "struct AdcButtonControlConfig" + description: > + One entry per button: . A button is + pressed while band_low < raw <= band_high, where raw is the 12-bit ADC reading on + the referenced channel. band_high/band_low are the exclusive/inclusive edges of the + button's band as recorded on the hardware ladder; the bottom-most band uses INT32_MIN + as its band_low. The key is an LVGL key code (e.g. LV_KEY_ESC). + debounce-ms: + type: int + default: 20 + description: Minimum time between recognized state changes, for software debouncing diff --git a/Drivers/button-adc-control-module/devicetree.yaml b/Drivers/button-adc-control-module/devicetree.yaml new file mode 100644 index 000000000..a07d6f334 --- /dev/null +++ b/Drivers/button-adc-control-module/devicetree.yaml @@ -0,0 +1,3 @@ +dependencies: + - TactilityKernel +bindings: bindings diff --git a/Drivers/button-adc-control-module/include/bindings/button_adc_control.h b/Drivers/button-adc-control-module/include/bindings/button_adc_control.h new file mode 100644 index 000000000..a023cb7f5 --- /dev/null +++ b/Drivers/button-adc-control-module/include/bindings/button_adc_control.h @@ -0,0 +1,7 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include + +DEFINE_DEVICETREE(button_adc_control, struct ButtonAdcControlConfig) diff --git a/Drivers/button-adc-control-module/include/button_adc_control_module.h b/Drivers/button-adc-control-module/include/button_adc_control_module.h new file mode 100644 index 000000000..f7583a508 --- /dev/null +++ b/Drivers/button-adc-control-module/include/button_adc_control_module.h @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +extern struct Module button_adc_control_module; + +#ifdef __cplusplus +} +#endif diff --git a/Drivers/button-adc-control-module/include/drivers/button_adc_control.h b/Drivers/button-adc-control-module/include/drivers/button_adc_control.h new file mode 100644 index 000000000..4bc23e86e --- /dev/null +++ b/Drivers/button-adc-control-module/include/drivers/button_adc_control.h @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#ifdef __cplusplus +extern "C" { +#endif + +#include + +#include + +/** + * @brief A single ladder button: an ADC channel plus the raw-value band that selects it. + */ +struct AdcButtonControlConfig { + /** ADC device controlling the channel */ + struct Device* adc_controller; + /** The channel's index on the device */ + uint8_t channel; + /** Upper (inclusive) edge of the raw-value band; no button above this value */ + int band_high; + /** Lower (exclusive) edge of the raw-value band; INT32_MIN for the bottom band */ + int band_low; + /** LVGL key code emitted when the button is pressed */ + uint32_t key; +}; + +struct ButtonAdcControlConfig { + /** One entry per button, in declared order */ + struct AdcButtonControlConfig* buttons; + /** The item count of buttons */ + uint32_t buttons_count; + /** Minimum time between recognized state changes, for software debouncing */ + uint32_t debounce_ms; +}; + +#ifdef __cplusplus +} +#endif diff --git a/Drivers/button-adc-control-module/source/button_adc_control.cpp b/Drivers/button-adc-control-module/source/button_adc_control.cpp new file mode 100644 index 000000000..62cdbf4b1 --- /dev/null +++ b/Drivers/button-adc-control-module/source/button_adc_control.cpp @@ -0,0 +1,165 @@ +// SPDX-License-Identifier: Apache-2.0 +#include + +#include + +#include +#include +#include +#include +#include +#include +#include + +#include + +#define TAG "ButtonAdcControl" +#define GET_CONFIG(device) (static_cast((device)->config)) + +// Worst case: every button on the ladder completes a press+release pair within one +// read_key() poll interval. Ladders cap at a handful of buttons, so 16 never binds. +constexpr auto BUTTON_ADC_PENDING_CAPACITY = 16; + +struct ButtonAdcPendingEvent { + uint32_t key; + bool pressed; +}; + +struct ButtonAdcButtonState { + bool in_use; + bool debounced_pressed; + uint32_t last_change_time; +}; + +struct ButtonAdcInternal { + ButtonAdcButtonState* button_states; + ButtonAdcPendingEvent pending[BUTTON_ADC_PENDING_CAPACITY]; + uint8_t pending_head; + uint8_t pending_count; +}; + +static void push_pending(ButtonAdcInternal* internal, uint32_t key, bool pressed) { + if (internal->pending_count >= BUTTON_ADC_PENDING_CAPACITY) { + LOG_W(TAG, "Pending event queue full, dropping event"); + return; + } + uint8_t tail = (internal->pending_head + internal->pending_count) % BUTTON_ADC_PENDING_CAPACITY; + internal->pending[tail] = { .key = key, .pressed = pressed }; + internal->pending_count++; +} + +static bool pop_pending(ButtonAdcInternal* internal, ButtonAdcPendingEvent* out_event) { + if (internal->pending_count == 0) { + return false; + } + *out_event = internal->pending[internal->pending_head]; + internal->pending_head = (internal->pending_head + 1) % BUTTON_ADC_PENDING_CAPACITY; + internal->pending_count--; + return true; +} + +// region Driver lifecycle + +static error_t start(Device* device) { + const auto* config = GET_CONFIG(device); + + auto* internal = static_cast(malloc(sizeof(ButtonAdcInternal))); + if (internal == nullptr) { + return ERROR_OUT_OF_MEMORY; + } + *internal = {}; + + internal->button_states = static_cast(calloc(config->buttons_count, sizeof(ButtonAdcButtonState))); + if (internal->button_states == nullptr) { + free(internal); + return ERROR_OUT_OF_MEMORY; + } + + for (size_t i = 0; i < config->buttons_count; ++i) { + internal->button_states[i].in_use = config->buttons[i].adc_controller != nullptr; + } + + device_set_driver_data(device, internal); + return ERROR_NONE; +} + +static error_t stop(Device* device) { + auto* internal = static_cast(device_get_driver_data(device)); + + free(internal->button_states); + free(internal); + return ERROR_NONE; +} + +// endregion + +// region KeyboardApi + +static void poll_button(const ButtonAdcControlConfig* config, ButtonAdcInternal* internal, size_t button_index) { + auto& state = internal->button_states[button_index]; + if (!state.in_use) { + return; + } + + const auto& button = config->buttons[button_index]; + + int raw; + AdcChannelSpec channel_spec = { button.adc_controller, button.channel }; + if (adc_channel_read_raw(&channel_spec, &raw, portMAX_DELAY) != ERROR_NONE) { + return; + } + + bool raw_pressed = raw > button.band_low && raw <= button.band_high; + + uint32_t now = get_millis(); + if ((now - state.last_change_time) < config->debounce_ms) { + return; + } + + if (raw_pressed == state.debounced_pressed) { + return; + } + state.last_change_time = now; + state.debounced_pressed = raw_pressed; + + push_pending(internal, button.key, raw_pressed); +} + +static error_t button_adc_control_read_key(Device* device, KeyboardKeyData* data) { + const auto* config = GET_CONFIG(device); + auto* internal = static_cast(device_get_driver_data(device)); + + for (size_t i = 0; i < config->buttons_count; ++i) { + poll_button(config, internal, i); + } + + ButtonAdcPendingEvent event; + if (pop_pending(internal, &event)) { + data->key = event.key; + data->pressed = event.pressed; + data->continue_reading = internal->pending_count > 0; + } else { + data->key = 0; + data->pressed = false; + data->continue_reading = false; + } + + return ERROR_NONE; +} + +// endregion + +static constexpr KeyboardApi button_adc_control_api = { + .read_key = button_adc_control_read_key, +}; + +Driver button_adc_control_driver = { + .name = "button_adc_control", + .compatible = (const char*[]) { "tactility,button-adc-control", nullptr }, + .start_device = start, + .stop_device = stop, + .api = &button_adc_control_api, + .device_type = &KEYBOARD_TYPE, + .owner = &button_adc_control_module, + .internal = nullptr +}; diff --git a/Drivers/button-adc-control-module/source/module.cpp b/Drivers/button-adc-control-module/source/module.cpp new file mode 100644 index 000000000..38cf40954 --- /dev/null +++ b/Drivers/button-adc-control-module/source/module.cpp @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: Apache-2.0 +#include +#include + +extern "C" { + +extern Driver button_adc_control_driver; + +static Driver* const button_adc_control_drivers[] = { + &button_adc_control_driver, + nullptr +}; + +Module button_adc_control_module = { + .name = "button-adc-control", + .drivers = button_adc_control_drivers +}; + +} // extern "C" diff --git a/Drivers/esp-epaper-module/bindings/tuanpmt,esp-epaper.yaml b/Drivers/esp-epaper-module/bindings/tuanpmt,esp-epaper.yaml index 8b44b5e4a..55493bf37 100644 --- a/Drivers/esp-epaper-module/bindings/tuanpmt,esp-epaper.yaml +++ b/Drivers/esp-epaper-module/bindings/tuanpmt,esp-epaper.yaml @@ -36,8 +36,8 @@ properties: required: true description: > esp_epaper panel registry name. One of "gdey0154d67", "gdep073e01", - "gdey037f51", "gdey029t71h", "ssd16xx-154", "ssd16xx-213", "ssd16xx-266", - "ssd16xx-270", "ssd16xx-290", "ssd16xx-370", "ssd16xx-420". + "gdey037f51", "gdey029t71h", "gdeq0426t82", "ssd16xx-154", "ssd16xx-213", + "ssd16xx-266", "ssd16xx-270", "ssd16xx-290", "ssd16xx-370", "ssd16xx-420". width: type: int default: 0 diff --git a/Drivers/esp-epaper-module/source/esp_epaper.cpp b/Drivers/esp-epaper-module/source/esp_epaper.cpp index 8bff9eea6..757d57164 100644 --- a/Drivers/esp-epaper-module/source/esp_epaper.cpp +++ b/Drivers/esp-epaper-module/source/esp_epaper.cpp @@ -26,16 +26,67 @@ constexpr auto* TAG = "esp_epaper"; /** Width/height overrides above this are rejected as nonsense. */ constexpr uint16_t MAX_PANEL_DIMENSION = 2048; +// Windowed partial refresh memory budget: the replay buffer holds every window +// written to 0x24 in one refresh cycle, so commit_base() can mirror them into +// the base plane (0x26) once the panel finished driving. A cycle whose tiles +// exceed this budget escalates to a full refresh instead. Together with LVGL's +// ~4-5KB draw buffer this replaces the old 48KB full-frame path on the X4. +constexpr uint32_t ESP_EPAPER_REPLAY_BYTES = 8192; +// The replay buffer is stored as back-to-back tiles; this bounds the tile +// metadata array. Escalates to a full refresh when a cycle needs more tiles +// than fit, which only happens for pathological many-window cycles. +constexpr uint16_t ESP_EPAPER_REPLAY_MAX_TILES = 64; +// Repeated differential (0xFC) refreshes against the same base image accumulate +// ghosting, so every Nth partial cycle the whole panel is refreshed instead. +constexpr uint32_t ESP_EPAPER_PARTIALS_BEFORE_FULL = 10; + +// One windowed tile stored in the replay buffer, in the order draw_bitmap() +// streamed it to 0x24 during the current refresh cycle. +struct EspEpaperReplayTile { + uint16_t x; + uint16_t y; + uint16_t w; + uint16_t h; + uint32_t data_offset; +}; + struct EspEpaperInternal { /** Opaque esp_epaper device, owns the panel's pins and SPI device. */ epd_handle_t epd; epd_panel_info_t info; - /** Scratch buffer in native panel layout, for rotated frames (rotation != 0). */ + /** + * Native-layout shadow frame (rotation != 0): holds the accumulated pixels + * of every tile drawn into GDDRAM since the last refresh, in the panel's + * native orientation. In rotated partial mode it is the data source for + * both the per-window 0x24 streams and commit_base()'s 0x26 mirrors, and + * the whole frame for full-refresh escalations. In full-frame mode it is a + * one-shot scratch that esp_epaper_draw_bitmap() rotates into. + */ uint8_t* rotate_buffer; /** Serializes panel/SPI access between draw_bitmap and power state changes. */ SemaphoreHandle_t panel_mutex; /** disp_on_off state; the panel is in deep sleep while false. */ bool display_on; + // Windowed partial refresh state (use_partial only): LVGL streams byte-aligned + // tiles into the panel RAM (0x24) during a cycle, then refresh() triggers the + // panel drive, and commit_base() replays the cycle's tiles into 0x26 so the + // differential refresh sequence keeps diffling against the frame on screen. + // At rotation 0 the tiles are stored verbatim in the replay buffer; at + // rotation != 0 the shadow (rotate_buffer) is the data source and the replay + // buffer only stages one window's rows at a time for the SPI stream (GDDRAM + // windows are not contiguous inside a full native frame). + bool use_partial; + bool cycle_has_tiles; + bool cycle_overflowed; + // First cycle after start/wake must be full: deep sleep clears GDDRAM while + // the retained image stays on screen, so a differential refresh would be + // meaningless until the panel has driven the new frame once. + bool force_full; + uint32_t partial_count; + uint8_t* replay; + uint32_t replay_len; + uint16_t replay_tile_count; + EspEpaperReplayTile replay_tiles[ESP_EPAPER_REPLAY_MAX_TILES]; }; static uint16_t esp_epaper_get_display_width(const EspEpaperInternal* internal, uint8_t rotation) { @@ -56,6 +107,7 @@ static bool resolve_panel_type(const char* name, epd_panel_type_t* out_type) { { "gdep073e01", EPD_PANEL_GDEP073E01 }, { "gdey037f51", EPD_PANEL_GDEY037F51 }, { "gdey029t71h", EPD_PANEL_GDEY029T71H }, + { "gdeq0426t82", EPD_PANEL_GDEQ0426T82 }, { "ssd16xx-154", EPD_PANEL_SSD16XX_154 }, { "ssd16xx-213", EPD_PANEL_SSD16XX_213 }, { "ssd16xx-266", EPD_PANEL_SSD16XX_266 }, @@ -81,6 +133,8 @@ static error_t esp_epaper_reset(Device* device) { // epd_wake() re-inits the panel, so it is awake (and drawable) again. if (ret == ESP_OK) { internal->display_on = true; + // The re-init cleared GDDRAM, so the first refresh after reset must be full. + internal->force_full = true; } xSemaphoreGive(internal->panel_mutex); return ret == ESP_OK ? ERROR_NONE : ERROR_RESOURCE; @@ -92,21 +146,179 @@ static error_t esp_epaper_init(Device* device) { const esp_err_t ret = epd_wake(internal->epd); if (ret == ESP_OK) { internal->display_on = true; + internal->force_full = true; } xSemaphoreGive(internal->panel_mutex); return ret == ESP_OK ? ERROR_NONE : ERROR_RESOURCE; } -// LVGL only ever calls this with the full frame: DISPLAY_COLOR_FORMAT_MONOCHROME forces -// LV_DISPLAY_RENDER_MODE_FULL in the generic kernel LVGL bridge (lvgl_display.c), and FULL mode -// only presents (calls draw_bitmap) once per render cycle, with the complete 0,0..hres,vres rect. +// Windowed partial draw (use_partial only): streams one byte-aligned tile into +// the display RAM (0x24) and records it for the base-plane commit. LVGL's I1 +// areas are always byte-aligned in X (lv_refr.c rounds them), so the tile maps +// 1:1 onto a 0x24 window. Tiles accumulate into the replay buffer; a cycle that +// would exceed the replay budget is escalated to a full refresh instead - this +// is what keeps the partial path's extra RAM fixed at ESP_EPAPER_REPLAY_BYTES +// regardless of how much of the panel a cycle touches. +// +// Rotated variant (rotation != 0): the display-space tile is rotated into the +// persistent native shadow frame first, then the tile's native window (expanded +// to byte-aligned X) is streamed to 0x24. The shadow is the accumulated frame - +// it replaces the rotation-0 replay byte copy as the data source for the 0x24 +// stream, commit_base()'s 0x26 mirror, and the full-refresh escalation. The +// replay buffer only stages one window's rows for the SPI transfer, since a +// window is not contiguous inside the full native frame. A cycle that overruns +// the tile array escalates to full: refresh() then streams the whole shadow +// into both planes before the drive, so no per-window 0x26 replay is needed. +static error_t esp_epaper_draw_bitmap_partial_rotated(Device* device, EspEpaperInternal* internal, + const uint8_t* data, uint16_t x, uint16_t y, + uint16_t w, uint16_t h) { + const auto* config = GET_CONFIG(device); + uint8_t* const shadow = internal->rotate_buffer; + + esp_epaper_rotate_tile(data, shadow, internal->info.width, internal->info.height, + x, y, (uint16_t)(x + w), (uint16_t)(y + h), config->rotation); + + uint16_t native_x; + uint16_t native_y; + uint16_t native_w; + uint16_t native_h; + esp_epaper_rotate_tile_rect(internal->info.width, internal->info.height, + x, y, (uint16_t)(x + w), (uint16_t)(y + h), config->rotation, + &native_x, &native_y, &native_w, &native_h); + // GDDRAM windows are byte-aligned in X. The shadow holds correct pixels for + // the whole expanded region (it mirrors GDDRAM), so the enlarged window is + // streamed as-is without exposing the panel to mid-byte boundaries. + const uint16_t native_x_end = (uint16_t)((native_x + native_w + 7) & ~7u); + native_x &= (uint16_t)~7u; + native_w = (uint16_t)(native_x_end - native_x); + const uint32_t window_bytes = (uint32_t)(native_w / 8) * native_h; + + if (internal->cycle_overflowed) { + // refresh() streams the whole shadow into both planes for this cycle; + // only the shadow needs to stay in sync with the accumulated frame. + return ERROR_NONE; + } + if (internal->replay_tile_count >= ESP_EPAPER_REPLAY_MAX_TILES || + window_bytes > ESP_EPAPER_REPLAY_BYTES) { + internal->cycle_overflowed = true; + internal->replay_len = 0; + internal->replay_tile_count = 0; + LOG_I(TAG, "Partial cycle exceeds rotated refresh budget, escalating to full refresh"); + return ERROR_NONE; + } + + // Pack the window's rows from the shadow into the staging area, then stream + // it to 0x24. The window is recorded without a data copy: commit_base() + // re-packs it from the shadow, which by then holds the whole cycle. + for (uint16_t row = 0; row < native_h; ++row) { + memcpy(internal->replay + (uint32_t)row * (native_w / 8), + shadow + (uint32_t)(native_y + row) * ((internal->info.width + 7) / 8) + native_x / 8, + native_w / 8); + } + const esp_err_t ret = epd_write_partial(internal->epd, native_x, native_y, native_w, native_h, internal->replay); + if (ret != ESP_OK) { + return ret; + } + + auto& tile = internal->replay_tiles[internal->replay_tile_count]; + tile.x = native_x; + tile.y = native_y; + tile.w = native_w; + tile.h = native_h; + tile.data_offset = 0; // data source is the shadow, not the staging area + internal->replay_tile_count++; + return ESP_OK; +} + +static error_t esp_epaper_draw_bitmap_partial(Device* device, int32_t x_start, int32_t y_start, int32_t x_end, int32_t y_end, const void* color_data) { + auto* internal = static_cast(device_get_driver_data(device)); + + if ((x_start & 7) != 0 || (x_end & 7) != 0) { + LOG_W(TAG, "partial draw_bitmap: x range %ld..%ld is not byte-aligned", (long)x_start, (long)x_end); + return ERROR_NOT_SUPPORTED; + } + const uint32_t width_bytes = (uint32_t)(x_end - x_start) / 8; + const uint32_t height = (uint32_t)(y_end - y_start); + const uint32_t tile_bytes = width_bytes * height; + if (tile_bytes == 0) { + return ERROR_NONE; + } + + const auto* data = static_cast(color_data); + const uint16_t x = (uint16_t)x_start; + const uint16_t y = (uint16_t)y_start; + const uint16_t w = (uint16_t)(x_end - x_start); + const uint16_t h = (uint16_t)(y_end - y_start); + + xSemaphoreTake(internal->panel_mutex, portMAX_DELAY); + + if (!internal->display_on) { + // Display is off (deep sleep). Drop the tile; the next power-on forces a + // full refresh of the current render anyway. + xSemaphoreGive(internal->panel_mutex); + return ERROR_NONE; + } + + esp_err_t ret; + if (internal->rotate_buffer != nullptr) { + ret = esp_epaper_draw_bitmap_partial_rotated(device, internal, data, x, y, w, h); + } else { + const bool overflow = internal->cycle_overflowed || + internal->replay_tile_count >= ESP_EPAPER_REPLAY_MAX_TILES || + internal->replay_len + tile_bytes > ESP_EPAPER_REPLAY_BYTES; + + if (overflow) { + // Escalate this and every remaining tile of the cycle to a full refresh: + // stream each window straight into both planes, so 0x26 needs no replay + // later and 0xF7 drives the accumulated frame. + if (!internal->cycle_overflowed) { + internal->cycle_overflowed = true; + LOG_I(TAG, "Partial cycle exceeds replay budget (%u bytes), escalating to full refresh", (unsigned)ESP_EPAPER_REPLAY_BYTES); + } + ret = epd_write_partial(internal->epd, x, y, w, h, data); + if (ret == ESP_OK) { + ret = epd_write_base_partial(internal->epd, x, y, w, h, data); + } + } else { + auto& tile = internal->replay_tiles[internal->replay_tile_count]; + tile.x = x; + tile.y = y; + tile.w = w; + tile.h = h; + tile.data_offset = internal->replay_len; + memcpy(internal->replay + internal->replay_len, data, tile_bytes); + internal->replay_len += tile_bytes; + internal->replay_tile_count++; + ret = epd_write_partial(internal->epd, x, y, w, h, data); + } + } + + xSemaphoreGive(internal->panel_mutex); + if (ret != ESP_OK) { + LOG_E(TAG, "epd_write_partial failed: %s", esp_err_to_name(ret)); + return ERROR_RESOURCE; + } + internal->cycle_has_tiles = true; + return ERROR_NONE; +} + +// Full-frame path (use_partial only for SSD1677 windowed refresh; other panels fall +// through to this). The bridge calls this with the complete 0,0..hres,vres rect: // hres/vres are the rotated (display) resolution reported by get_resolution_*; color_data is // row-major, MSB-first 1bpp (LVGL's LV_COLOR_FORMAT_I1 with the palette header already stripped by -// the caller); bit 1 = white, bit 0 = black. epd_update() expects exactly that layout and polarity. +// the caller); bit 1 = white, bit 0 = black. epd_update_async() expects exactly that layout and +// polarity. It streams color_data into GDDRAM and fires the master activation, then returns while +// the panel keeps driving; the busy wait for the refresh moved to esp_epaper_wait_sync() so the +// render thread no longer blocks for the whole refresh. color_data is only read during the +// synchronous SPI transfer, so LVGL's buffer is free the moment this returns. static error_t esp_epaper_draw_bitmap(Device* device, int32_t x_start, int32_t y_start, int32_t x_end, int32_t y_end, const void* color_data) { auto* internal = static_cast(device_get_driver_data(device)); const auto* config = GET_CONFIG(device); + if (internal->use_partial) { + return esp_epaper_draw_bitmap_partial(device, x_start, y_start, x_end, y_end, color_data); + } + const uint16_t display_width = esp_epaper_get_display_width(internal, config->rotation); const uint16_t display_height = esp_epaper_get_display_height(internal, config->rotation); if (x_start != 0 || y_start != 0 || x_end != display_width || y_end != display_height) { @@ -133,15 +345,144 @@ static error_t esp_epaper_draw_bitmap(Device* device, int32_t x_start, int32_t y source = internal->rotate_buffer; } - const esp_err_t ret = epd_update(internal->epd, source, config->update_mode); + const esp_err_t ret = epd_update_async(internal->epd, source, config->update_mode); xSemaphoreGive(internal->panel_mutex); if (ret != ESP_OK) { - LOG_E(TAG, "epd_update failed: %s", esp_err_to_name(ret)); + LOG_E(TAG, "epd_update_async failed: %s", esp_err_to_name(ret)); return ERROR_RESOURCE; } return ERROR_NONE; } +// Trigger the panel drive for the tiles streamed during this refresh cycle +// (use_partial only). Partial unless the caller asked for full, the first cycle +// after a wake/reset (force_full), the cycle overran the replay budget, or the +// periodic ghosting-cleanup counter is due. Returns immediately - the panel +// keeps driving in the background until wait_sync(). +static error_t esp_epaper_refresh(Device* device, bool full_frame) { + auto* internal = static_cast(device_get_driver_data(device)); + + xSemaphoreTake(internal->panel_mutex, portMAX_DELAY); + if (!internal->cycle_has_tiles) { + // Nothing was drawn this cycle (dropped frame or display off); nothing to drive. + xSemaphoreGive(internal->panel_mutex); + return ERROR_NONE; + } + + bool do_full = full_frame || internal->force_full || internal->cycle_overflowed; + if (do_full) { + internal->partial_count = 0; + } else { + internal->partial_count++; + if (internal->partial_count >= ESP_EPAPER_PARTIALS_BEFORE_FULL) { + internal->partial_count = 0; + do_full = true; + } + } + internal->force_full = false; + + const epd_update_mode_t mode = do_full ? EPD_UPDATE_FULL : EPD_UPDATE_PARTIAL; + esp_err_t ret = ESP_OK; + if (internal->cycle_overflowed && internal->rotate_buffer != nullptr) { + // The cycle overran the rotated tile budget, so draw_bitmap() only + // blitted into the shadow and streamed nothing. Write the whole + // accumulated shadow into both RAM planes before the full drive applies + // it: 0x26 then equals the frame on screen, so no base commit is needed + // for this cycle. + ret = epd_write_partial(internal->epd, 0, 0, internal->info.width, internal->info.height, internal->rotate_buffer); + if (ret == ESP_OK) { + ret = epd_write_base_partial(internal->epd, 0, 0, internal->info.width, internal->info.height, internal->rotate_buffer); + } + } + if (ret == ESP_OK) { + ret = epd_update_partial_async(internal->epd, mode); + } + + // The cycle is consumed. The replay tiles are kept for commit_base() - which + // runs after the panel finishes driving - unless the trigger failed, in which + // case the panel stays idle and the next cycle must start from a clean slate. + internal->cycle_has_tiles = false; + internal->cycle_overflowed = false; + if (ret != ESP_OK) { + internal->replay_len = 0; + internal->replay_tile_count = 0; + } + + xSemaphoreGive(internal->panel_mutex); + if (ret != ESP_OK) { + LOG_E(TAG, "refresh (%s) failed: %s", do_full ? "full" : "partial", esp_err_to_name(ret)); + return ERROR_RESOURCE; + } + return ERROR_NONE; +} + +// Mirror the last refresh cycle's windows into the base image RAM (0x26) +// (use_partial only). Must run after the panel finished driving (wait_sync()): +// 0x26 is the frame the differential 0xFC refresh diffs against, so it must +// always equal what is actually on screen. Runs on the bridge's refresh task, +// never on the render thread. +static error_t esp_epaper_commit_base(Device* device) { + auto* internal = static_cast(device_get_driver_data(device)); + + xSemaphoreTake(internal->panel_mutex, portMAX_DELAY); + if (internal->replay_tile_count == 0) { + xSemaphoreGive(internal->panel_mutex); + return ERROR_NONE; + } + + esp_err_t ret = ESP_OK; + for (uint16_t i = 0; i < internal->replay_tile_count; ++i) { + const auto& tile = internal->replay_tiles[i]; + const uint8_t* source; + if (internal->rotate_buffer != nullptr) { + // The shadow holds the cycle's data; pack its window rows into the + // staging area first, since the window is not contiguous inside the + // full native frame. Same size bound as the draw path, so the pack + // always fits ESP_EPAPER_REPLAY_BYTES. + const uint16_t row_bytes = tile.w / 8; + const uint32_t stride = ((uint32_t)internal->info.width + 7) / 8; + for (uint16_t row = 0; row < tile.h; ++row) { + memcpy(internal->replay + (uint32_t)row * row_bytes, + internal->rotate_buffer + (uint32_t)(tile.y + row) * stride + tile.x / 8, + row_bytes); + } + source = internal->replay; + } else { + source = internal->replay + tile.data_offset; + } + ret = epd_write_base_partial(internal->epd, tile.x, tile.y, tile.w, tile.h, source); + if (ret != ESP_OK) { + LOG_E(TAG, "epd_write_base_partial (%u,%u %ux%u) failed: %s", + (unsigned)tile.x, (unsigned)tile.y, (unsigned)tile.w, (unsigned)tile.h, esp_err_to_name(ret)); + break; + } + } + + internal->replay_len = 0; + internal->replay_tile_count = 0; + + xSemaphoreGive(internal->panel_mutex); + return ret == ESP_OK ? ERROR_NONE : ERROR_RESOURCE; +} + +// Blocks until the panel finished the refresh triggered by the last +// esp_epaper_draw_bitmap(). Only polls the BUSY GPIO - no SPI traffic - so it +// runs without the panel mutex and cannot conflict with a concurrent stream. +static error_t esp_epaper_wait_sync(Device* device, uint32_t timeout_ms) { + auto* internal = static_cast(device_get_driver_data(device)); + // epd_wait_busy() returns ESP_OK even when it gave up on the timeout, so + // the still-busy state after it returns is what signals ERROR_TIMEOUT. + const esp_err_t ret = epd_wait_busy(internal->epd, timeout_ms); + if (ret != ESP_OK) { + return ERROR_RESOURCE; + } + if (epd_is_busy(internal->epd)) { + LOG_W(TAG, "wait_sync timeout: panel still busy after %lu ms", (unsigned long)timeout_ms); + return ERROR_TIMEOUT; + } + return ERROR_NONE; +} + static error_t esp_epaper_disp_on_off(Device* device, bool on_off) { auto* internal = static_cast(device_get_driver_data(device)); @@ -166,6 +507,14 @@ static error_t esp_epaper_disp_on_off(Device* device, bool on_off) { if (ok) { internal->display_on = on_off; + if (on_off) { + // Deep sleep cleared GDDRAM; the first refresh after wake must be full. + internal->force_full = true; + if (internal->rotate_buffer != nullptr) { + // The shadow mirrors GDDRAM, which deep sleep cleared to white. + memset(internal->rotate_buffer, 0xFF, internal->info.buffer_size); + } + } } xSemaphoreGive(internal->panel_mutex); return ok ? ERROR_NONE : ERROR_RESOURCE; @@ -193,11 +542,17 @@ static uint8_t esp_epaper_get_frame_buffer_count(Device*) { return 0; } +static bool esp_epaper_has_capability(Device* device, uint32_t capability); + static const DisplayApi esp_epaper_display_api = { - .capabilities = DISPLAY_CAPABILITY_ON_OFF | DISPLAY_CAPABILITY_SLOW_REFRESH, + .capabilities = DISPLAY_CAPABILITY_ON_OFF | DISPLAY_CAPABILITY_SLOW_REFRESH | + DISPLAY_CAPABILITY_PARTIAL_REFRESH, .reset = esp_epaper_reset, .init = esp_epaper_init, .draw_bitmap = esp_epaper_draw_bitmap, + .wait_sync = esp_epaper_wait_sync, + .refresh = esp_epaper_refresh, + .commit_base = esp_epaper_commit_base, .mirror = nullptr, .swap_xy = nullptr, .get_swap_xy = nullptr, @@ -215,9 +570,22 @@ static const DisplayApi esp_epaper_display_api = { .get_frame_buffer = esp_epaper_get_frame_buffer, .get_frame_buffer_count = esp_epaper_get_frame_buffer_count, .get_backlight = nullptr, - .has_capability = nullptr, + .has_capability = esp_epaper_has_capability, }; +static bool esp_epaper_has_capability(Device* device, uint32_t capability) { + auto* internal = static_cast(device_get_driver_data(device)); + if ((capability & DISPLAY_CAPABILITY_PARTIAL_REFRESH) != 0) { + // Windowed refresh works in any fixed rotation: the rotated path rotates + // each tile into the native shadow frame and streams the shadow's + // windows. LVGL rotation (a runtime rotation change) is unsupported - the + // bridge drops partial cycles while rotating, but the driver's fixed + // rotation is applied in the panel, never via LVGL. + return internal->use_partial; + } + return (esp_epaper_display_api.capabilities & capability) == capability; +} + static void free_internal(EspEpaperInternal* internal) { if (internal->epd != nullptr) { epd_deinit(internal->epd); @@ -225,6 +593,9 @@ static void free_internal(EspEpaperInternal* internal) { if (internal->rotate_buffer != nullptr) { free(internal->rotate_buffer); } + if (internal->replay != nullptr) { + free(internal->replay); + } if (internal->panel_mutex != nullptr) { vSemaphoreDelete(internal->panel_mutex); } @@ -271,6 +642,10 @@ static error_t start(Device* device) { epd_config.panel.type = panel_type; epd_config.panel.width = config->width; epd_config.panel.height = config->height; + // The kernel bridge owns the frame buffer and hands it to epd_update() as the + // source buffer, so the component's internal copy is redundant. Skipping it + // saves buffer_size bytes of RAM (48KB on the 800x480 GDEQ0426T82). + epd_config.framebuffer_enable = false; epd_handle_t epd = nullptr; if (epd_init(&epd_config, &epd) != ESP_OK) { @@ -318,10 +693,39 @@ static error_t start(Device* device) { } } + // Windowed partial refresh needs both a windowed 0x24 writer and a base image + // (0x26) writer; only the SSD1677 controller provides the latter, so other + // panels fall through to the full-frame path regardless of their EPD_CAP_PARTIAL. + // Rotation is not a blocker: the rotated path rotates each tile into a native + // shadow frame and streams the shadow's windows, so rotated panels get the + // same windowed refresh as rotation 0. + internal->use_partial = epd_supports_partial(epd) && epd_supports_base_partial(epd); + if (internal->use_partial) { + internal->replay = static_cast(malloc(ESP_EPAPER_REPLAY_BYTES)); + if (internal->replay == nullptr) { + LOG_E(TAG, "Failed to allocate %lu-byte partial replay buffer", (unsigned long)ESP_EPAPER_REPLAY_BYTES); + free_internal(internal); + return ERROR_OUT_OF_MEMORY; + } + if (internal->rotate_buffer != nullptr) { + // GDDRAM was just cleared to white while the retained image is still on + // screen. The shadow mirrors GDDRAM, so seed it to white too; the first + // refresh must drive the new frame (full) before any differential + // refresh is meaningful. + memset(internal->rotate_buffer, 0xFF, internal->info.buffer_size); + } + // GDDRAM was just cleared to white while the retained image is still on + // screen; the first refresh must drive the new frame (full) before any + // differential refresh is meaningful. + internal->force_full = true; + } + internal->display_on = true; device_set_driver_data(device, internal); - LOG_I(TAG, "Started %ux%u panel (buffer %lu bytes, rotation %u)", internal->info.width, internal->info.height, internal->info.buffer_size, config->rotation); + LOG_I(TAG, "Started %ux%u panel (buffer %lu bytes, rotation %u, partial refresh %s)", + internal->info.width, internal->info.height, internal->info.buffer_size, config->rotation, + internal->use_partial ? "enabled" : "disabled"); return ERROR_NONE; } diff --git a/Drivers/esp-epaper-module/source/esp_epaper_rotate.h b/Drivers/esp-epaper-module/source/esp_epaper_rotate.h index 1cd6ae48f..77dac1eb8 100644 --- a/Drivers/esp-epaper-module/source/esp_epaper_rotate.h +++ b/Drivers/esp-epaper-module/source/esp_epaper_rotate.h @@ -64,6 +64,111 @@ static inline void esp_epaper_rotate_frame(const uint8_t* src, uint8_t* dst, uin } } +/** + * Rotates one display-space tile into the native shadow frame at its rotated + * position, leaving all pixels outside the tile untouched. The pixel mapping is + * identical to esp_epaper_rotate_frame() restricted to the tile, so a shadow + * accumulated tile-by-tile equals the full-frame rotation of the same content. + * + * Unlike esp_epaper_rotate_frame() - which seeds a scratch buffer black and + * only ORs in set (white) bits - this writes every pixel of the tile with both + * polarities, because the shadow persists across cycles: it mirrors the cleared + * (all-white) GDDRAM, so white pixels keep the seed while black pixels must + * clear their bit. A tile never overlaps itself, so unconditional writes are + * safe; outside the tile the shadow is untouched. + * + * width/height are the panel's native dimensions. (x1,y1,x2,y2) is the + * display-space tile rectangle with exclusive x2/y2. src holds the tile's + * pixels (stride = ceil((x2 - x1) / 8) bytes, MSB-first bits). dst is the + * native full frame (stride = ceil(width / 8) bytes); it may not alias src. + */ +static inline void esp_epaper_rotate_tile(const uint8_t* src, uint8_t* dst, uint16_t width, uint16_t height, + uint16_t x1, uint16_t y1, uint16_t x2, uint16_t y2, uint8_t rotation) { + const uint32_t src_stride = ((uint32_t)(x2 - x1) + 7) / 8; + const uint32_t dst_stride = ((uint32_t)width + 7) / 8; + + for (uint16_t v = y1; v < y2; v++) { + for (uint16_t u = x1; u < x2; u++) { + uint16_t x; + uint16_t y; + switch (rotation) { + case 0: + x = u; + y = v; + break; + case 1: + x = v; + y = height - 1 - u; + break; + case 2: + x = width - 1 - u; + y = height - 1 - v; + break; + case 3: + default: + x = width - 1 - v; + y = u; + break; + } + const uint8_t src_bit = 0x80 >> ((u - x1) % 8); + const uint8_t dst_mask = 0x80 >> (x % 8); + uint8_t* const dst_byte = &dst[(uint32_t)y * dst_stride + x / 8]; + if (src[(uint32_t)(v - y1) * src_stride + (u - x1) / 8] & src_bit) { + *dst_byte |= dst_mask; + } else { + *dst_byte &= (uint8_t)~dst_mask; + } + } + } +} + +/** + * Computes the native-space rectangle a display-space tile covers, using the + * same inverse-of-LVGL mapping as esp_epaper_rotate_frame(). The tile is + * (x1,y1,x2,y2) in display coordinates (exclusive x2/y2) with width/height the + * panel's native dimensions; the native rect is written to out_x/out_y/out_w/ + * out_h (out_w/out_h exclusive extents). + */ +static inline void esp_epaper_rotate_tile_rect(uint16_t width, uint16_t height, + uint16_t x1, uint16_t y1, uint16_t x2, uint16_t y2, uint8_t rotation, + uint16_t* out_x, uint16_t* out_y, uint16_t* out_w, uint16_t* out_h) { + uint16_t nx1; + uint16_t ny1; + uint16_t nx2; + uint16_t ny2; + switch (rotation) { + case 0: + nx1 = x1; + ny1 = y1; + nx2 = x2; + ny2 = y2; + break; + case 1: + nx1 = y1; + ny1 = height - x2; + nx2 = y2; + ny2 = height - x1; + break; + case 2: + nx1 = width - x2; + ny1 = height - y2; + nx2 = width - x1; + ny2 = height - y1; + break; + case 3: + default: + nx1 = width - y2; + ny1 = x1; + nx2 = width - y1; + ny2 = x2; + break; + } + *out_x = nx1; + *out_y = ny1; + *out_w = nx2 - nx1; + *out_h = ny2 - ny1; +} + #ifdef __cplusplus } #endif diff --git a/Libraries/elf_loader/Kconfig b/Libraries/elf_loader/Kconfig index be8f32b73..df72e828e 100644 --- a/Libraries/elf_loader/Kconfig +++ b/Libraries/elf_loader/Kconfig @@ -1,15 +1,15 @@ menu "Espressif ELF Loader Configuration" - visible if (IDF_TARGET_ESP32 || IDF_TARGET_ESP32S2 || IDF_TARGET_ESP32S3 || IDF_TARGET_ESP32C6 || IDF_TARGET_ESP32P4 || IDF_TARGET_ESP32C61) + visible if (IDF_TARGET_ESP32 || IDF_TARGET_ESP32C3 || IDF_TARGET_ESP32S2 || IDF_TARGET_ESP32S3 || IDF_TARGET_ESP32C6 || IDF_TARGET_ESP32P4 || IDF_TARGET_ESP32C61) config ELF_LOADER_BUS_ADDRESS_MIRROR bool default y if (IDF_TARGET_ESP32 || IDF_TARGET_ESP32S2 || IDF_TARGET_ESP32S3) - default n if (IDF_TARGET_ESP32C6 || IDF_TARGET_ESP32P4 || IDF_TARGET_ESP32C61) + default n if (IDF_TARGET_ESP32C3 || IDF_TARGET_ESP32C6 || IDF_TARGET_ESP32P4 || IDF_TARGET_ESP32C61) config ELF_LOADER bool "Enable Espressif ELF Loader" default y - depends on (IDF_TARGET_ESP32 || IDF_TARGET_ESP32S2 || IDF_TARGET_ESP32S3 || IDF_TARGET_ESP32C6 || IDF_TARGET_ESP32P4 || IDF_TARGET_ESP32C61) + depends on (IDF_TARGET_ESP32 || IDF_TARGET_ESP32C3 || IDF_TARGET_ESP32S2 || IDF_TARGET_ESP32S3 || IDF_TARGET_ESP32C6 || IDF_TARGET_ESP32P4 || IDF_TARGET_ESP32C61) help Select this option to enable ELF Loader and show the submenu with ELF Loader configuration choices. diff --git a/Libraries/elf_loader/idf_component.yml b/Libraries/elf_loader/idf_component.yml index 65795807b..b7efdc824 100644 --- a/Libraries/elf_loader/idf_component.yml +++ b/Libraries/elf_loader/idf_component.yml @@ -1,6 +1,7 @@ version: "1.1.1" targets: - esp32 + - esp32c3 - esp32s2 - esp32s3 - esp32c6 diff --git a/Libraries/esp_epaper b/Libraries/esp_epaper index 0bf4f144b..69ee83c3c 160000 --- a/Libraries/esp_epaper +++ b/Libraries/esp_epaper @@ -1 +1 @@ -Subproject commit 0bf4f144b543048f10bdadd49f0578858bdc2a39 +Subproject commit 69ee83c3ca493150dc6860b743ab71cdb447c08a diff --git a/Modules/lvgl-module/source/devices/display.cpp b/Modules/lvgl-module/source/devices/display.cpp index 82d9eb529..b70ca694b 100644 --- a/Modules/lvgl-module/source/devices/display.cpp +++ b/Modules/lvgl-module/source/devices/display.cpp @@ -1,8 +1,10 @@ // SPDX-License-Identifier: Apache-2.0 #include +#include #include +#include #include #include #include @@ -10,6 +12,7 @@ #include +#include #include #ifdef ESP_PLATFORM @@ -18,6 +21,20 @@ constexpr auto* TAG = "lvgl_display"; +// How long the refresh task waits for the panel to confirm the in-flight refresh finished. Must +// exceed the longest panel refresh - a full GDEQ0426T82 refresh measured ~4s on hardware - so +// refresh_in_flight is never cleared while the panel is still busy. A premature clear would let the +// next full frame stream into the busy panel and block the render thread in the driver's (infinite) +// write-time wait-before-use, defeating the async design. On a genuine timeout the task proceeds +// anyway and clears the flag: subsequent commands are safe via that same wait-before-use. +constexpr uint32_t LVGL_DISPLAY_WAIT_SYNC_TIMEOUT_MS = 10000; +// Upper bound for how long lvgl_display_remove() may block stopping the refresh task. The task +// always exits within LVGL_DISPLAY_WAIT_SYNC_TIMEOUT_MS plus the time to reacquire the LVGL lock: +// ulTaskNotifyTake() blocks indefinitely but the stop notification wakes it, and display_wait_sync() +// honors its timeout. +constexpr TickType_t LVGL_DISPLAY_REFRESH_TASK_JOIN_TIMEOUT = pdMS_TO_TICKS(15000); +constexpr configSTACK_DEPTH_TYPE LVGL_DISPLAY_REFRESH_TASK_STACK_SIZE = 4096; + struct LvglDisplayCtx { void* buf1; void* buf2; @@ -39,7 +56,13 @@ struct LvglDisplayCtx { // When true, rotation is done in software in the flush callback instead of via display_swap_xy()/ // display_mirror(); rotate_buf holds the rotated pixels and is sized like buf1. bool sw_rotate; + // Lazily allocated on the first flush that actually rotates (see lvgl_display_ensure_rotate_buf()): + // sw_rotate is often set for a panel that stays in its base orientation (e.g. an I1 e-paper at + // rotation 0), where an eager full-frame buffer would be allocated and never touched. void* rotate_buf; + // Cached LvglDisplayConfig::prefer_external_ram, needed when rotate_buf is allocated lazily in + // the flush callback (config is not available there). + bool prefer_external_ram; // Lazily created on the first sw_rotate flush that needs it (see lvgl_display_rotate_tile()). // Stays NULL - and every rotate falls back to rotate_buf/lv_draw_sw_rotate() - when the target // has no PPA (lvgl_ppa_is_supported()), the color format has no PPA color mode @@ -57,6 +80,38 @@ struct LvglDisplayCtx { // Mirrors LvglDisplayConfig::swap_bytes: the panel is big endian while the OS is little endian, // so we fix it in software. In the future, the driver should probably expose endianness requirements instead. bool byte_swap; + // Async refresh handling for slow-refresh panels (DISPLAY_CAPABILITY_SLOW_REFRESH, e.g. e-paper). + // The flush callback never blocks on the panel - it streams a full frame and returns, even though + // the panel then keeps driving for 1-3s; the wait happens on the refresh task below instead, so + // the render thread isn't stalled. See lvgl_display_refresh_task_main() for the state machine. + bool slow_refresh; + struct Thread* refresh_task; + // Written by lvgl_display_remove() to ask the refresh task to exit; read by the task itself. + std::atomic refresh_task_stop; + // Set only by lvgl_display_flush_cb(), right after it successfully streams a full frame; cleared + // only by the refresh task, after display_wait_sync() confirms the panel is idle again. One + // writer per direction, so no lock is needed. Guarantees a new draw never races an in-flight + // panel refresh. + std::atomic refresh_in_flight; + // Set only by lvgl_display_flush_cb() when it drops a full frame because refresh_in_flight was + // set; cleared only by the refresh task, which then re-renders so the newest content eventually + // reaches the panel. + std::atomic frame_pending; + // Windowed partial refresh (DISPLAY_CAPABILITY_PARTIAL_REFRESH on a slow-refresh I1 e-paper): + // draw_bitmap() tiles stream into the panel RAM during a cycle, and refresh() is triggered once + // per cycle from the last flush. The bridge drops a whole cycle when the previous refresh is + // still driving (tiles written into a busy panel would be ignored), then re-renders. The driver + // applies any fixed panel rotation itself (rotating tiles into a native shadow), so this path + // is valid for rotated panels too; only a runtime LVGL rotation drops cycles. + bool partial_refresh; + // True from the first flush of a cycle until its last flush; gates cycle-drop detection. + bool cycle_active; + // True when the current cycle was dropped at its first flush (previous refresh in flight, or + // rotation active); every flush of the cycle is skipped and no refresh is triggered. + bool cycle_dropped; + // Valid for the lifetime of the display; written in lvgl_display_add(), read by the refresh task. + struct Device* refresh_task_device; + lv_display_t* refresh_task_display; }; static void* lvgl_display_alloc_buffer(size_t size_bytes, bool prefer_external_ram) { @@ -220,6 +275,77 @@ static void* lvgl_display_try_ppa_rotate(struct LvglDisplayCtx* ctx, const uint8 return lvgl_ppa_rotate(ctx->ppa_handle, in_buff, w, h, rotation, color_format, false); } +// Ensures ctx->rotate_buf exists, allocating it sized like buf1 on the first call. sw_rotate is +// configured eagerly, but a panel may sit in its base orientation (rotation 0) for its whole life - +// e.g. an I1 e-paper - so the buffer is only created when a flush actually needs to rotate. +// Returns ERROR_NONE when the buffer is available (already or freshly allocated), ERROR_OUT_OF_MEMORY +// otherwise with rotate_buf left NULL. +static error_t lvgl_display_ensure_rotate_buf(struct LvglDisplayCtx* ctx) { + if (ctx->rotate_buf == NULL) { + ctx->rotate_buf = lvgl_display_alloc_buffer(ctx->buf_size_bytes, ctx->prefer_external_ram); + } + return ctx->rotate_buf != NULL ? ERROR_NONE : ERROR_OUT_OF_MEMORY; +} + +// Owns "wait for the panel to finish a refresh" for slow-refresh panels. Woken by +// lvgl_display_flush_cb() after every successfully streamed full frame, blocks in +// display_wait_sync() until the panel is idle again, then re-renders if a frame was dropped in the +// meantime. Runs at low priority so it never competes with rendering. The stop flag is latched by +// the notification below it, so a stop request always wakes the task out of ulTaskNotifyTake(). +static int32_t lvgl_display_refresh_task_main(void* context) { + struct LvglDisplayCtx* ctx = static_cast(context); + + while (!ctx->refresh_task_stop.load()) { + // Block until a full frame was just streamed to the panel (or we're asked to stop). The + // notification value latches, so a stream that races us re-blocking isn't lost. + ulTaskNotifyTake(pdTRUE, portMAX_DELAY); + if (ctx->refresh_task_stop.load()) { + break; + } + + // Wait for the panel to finish pushing out the frame that woke us. On timeout or a driver + // error, proceed anyway: a later draw's write-time wait-before-use is the backstop, and + // clearing refresh_in_flight below keeps the pipeline from stalling permanently. + error_t wait_result = display_wait_sync(ctx->refresh_task_device, LVGL_DISPLAY_WAIT_SYNC_TIMEOUT_MS); + if (wait_result == ERROR_TIMEOUT) { + LOG_W(TAG, "Panel refresh did not finish within %u ms", (unsigned int)LVGL_DISPLAY_WAIT_SYNC_TIMEOUT_MS); + } else if (wait_result != ERROR_NONE) { + LOG_W(TAG, "Waiting for panel refresh failed: %d", (int)wait_result); + } + + if (ctx->partial_refresh) { + // The panel just finished driving; mirror the last cycle's windows into the base image + // plane before any new cycle can write to the display RAM, keeping the differential + // refresh sequence diffling against the frame that is actually on screen. + error_t commit_result = display_commit_base(ctx->refresh_task_device); + if (commit_result != ERROR_NONE) { + LOG_W(TAG, "Committing base image failed: %d", (int)commit_result); + } + } + + // One writer per direction, no lock needed: only this task clears refresh_in_flight, only + // lvgl_display_flush_cb() sets it. + ctx->refresh_in_flight.store(false); + + if (ctx->frame_pending.load()) { + // A frame or partial cycle was dropped while the panel was busy (see the drop paths + // in lvgl_display_flush_cb()). Repaint from LVGL's current state so the panel + // eventually shows the newest content instead of a stale frame: for FULL mode + // invalidating any area redraws the whole screen; for partial mode it re-runs the + // banded cycle that was dropped. Either way the invalidate also resumes LVGL's + // refresh timer, which is paused between refreshes. + ctx->frame_pending.store(false); + lvgl_lock(); + lv_obj_t* screen = lv_display_get_screen_active(ctx->refresh_task_display); + if (screen != NULL) { + lv_obj_invalidate(screen); + } + lvgl_unlock(); + } + } + return 0; +} + static void lvgl_display_flush_cb(lv_display_t* disp, const lv_area_t* area, uint8_t* color_map) { struct LvglDeviceContext* wrapper = (struct LvglDeviceContext*)lv_display_get_driver_data(disp); struct LvglDisplayCtx* ctx = (struct LvglDisplayCtx*)wrapper->context; @@ -234,6 +360,63 @@ static void lvgl_display_flush_cb(lv_display_t* disp, const lv_area_t* area, uin lv_display_rotation_t rotation = lv_display_get_rotation(disp); bool rotating = ctx->sw_rotate && rotation != LV_DISPLAY_ROTATION_0; + // Windowed partial refresh (I1 e-paper): each flush_cb call is one tile of the + // current cycle. Tiles stream into the panel RAM as they arrive; the panel only + // drives once per cycle, triggered from the last flush. Unlike FULL mode there + // is no complete frame anywhere to present, so every tile must be handled. + if (ctx->partial_refresh) { + // First flush of a cycle decides whether the cycle runs or is dropped. + if (!ctx->cycle_active) { + ctx->cycle_active = true; + ctx->cycle_dropped = false; + if (ctx->refresh_in_flight.load()) { + // The panel is still driving the previous cycle. Tiles written now would + // be ignored (GDDRAM is read-only while BUSY) and the base plane is not + // committed yet, so drop the whole cycle; the refresh task re-renders + // once the panel is idle (see lvgl_display_refresh_task_main). + ctx->cycle_dropped = true; + ctx->frame_pending.store(true); + } else if (rotating) { + // Windowed refresh only supports the panel's base orientation; rotated + // content cannot be placed in the panel RAM correctly. + ctx->cycle_dropped = true; + LOG_E(TAG, "Partial refresh does not support rotation, dropping cycle"); + } + } + + if (!ctx->cycle_dropped) { + // LVGL reserves an 8-byte palette at the front of every I1 draw buffer; the + // tile's pixels follow it, tightly packed at the area's width. + error_t draw_result = display_draw_bitmap(wrapper->device, x1, y1, x2 + 1, y2 + 1, color_map + 8); + if (draw_result != ERROR_NONE) { + LOG_W(TAG, "draw_bitmap failed: %d", (int)draw_result); + } + } + + if (lv_display_flush_is_last(disp)) { + // End of the cycle: trigger the panel drive for everything streamed so far, + // then hand the "wait for it to finish" over to the refresh task. + if (!ctx->cycle_dropped) { + error_t refresh_result = display_refresh(wrapper->device, false); + if (refresh_result != ERROR_NONE) { + LOG_W(TAG, "refresh failed: %d", (int)refresh_result); + } else { + ctx->refresh_in_flight.store(true); + TaskHandle_t task_handle = thread_get_task_handle(ctx->refresh_task); + if (task_handle != NULL) { + xTaskNotifyGive(task_handle); + } else { + LOG_E(TAG, "Refresh task not running while streaming a frame"); + } + } + } + ctx->cycle_active = false; + ctx->cycle_dropped = false; + } + lv_display_flush_ready(disp); + return; + } + // In FULL mode, a refresh cycle can call this once per still-unjoined invalidated area (see // the comment below) before the frame is complete - rotating per-tile here would only ever // reflect the last tile written, not the accumulated whole frame. Rotate the whole buffer in @@ -250,6 +433,11 @@ static void lvgl_display_flush_cb(lv_display_t* disp, const lv_area_t* area, uin if (ppa_out != NULL) { color_map = (uint8_t*)ppa_out; } else { + if (lvgl_display_ensure_rotate_buf(ctx) != ERROR_NONE) { + LOG_E(TAG, "Failed to allocate rotation buffer, dropping frame"); + lv_display_flush_ready(disp); + return; + } uint32_t w_stride = lv_draw_buf_width_to_stride(w, color_format); uint32_t h_stride = lv_draw_buf_width_to_stride(h, color_format); if (rotation == LV_DISPLAY_ROTATION_180) { @@ -284,6 +472,16 @@ static void lvgl_display_flush_cb(lv_display_t* disp, const lv_area_t* area, uin // flush and present the whole buffer in one call, mirroring esp_lvgl_port_disp.c's own // lv_disp_flush_is_last() gate for its direct/full render mode. if (lv_display_flush_is_last(disp)) { + // Slow-refresh panels (e-paper) take 1-3s per refresh. If the previous refresh is still + // in flight, drop this frame instead of sending it into a busy panel: the newest content + // is re-rendered by the refresh task once the panel is idle (lvgl_display_refresh_task_main). + // The drop is invisible because the panel is mid-refresh right now - it never shows the + // dropped frame - and skipping avoids queueing stale refreshes behind the panel. + if (ctx->slow_refresh && ctx->refresh_in_flight.load()) { + ctx->frame_pending.store(true); + lv_display_flush_ready(disp); + return; + } uint8_t* fb_base; if (ctx->owns_buffers) { fb_base = (uint8_t*)ctx->buf1; @@ -312,6 +510,11 @@ static void lvgl_display_flush_cb(lv_display_t* disp, const lv_area_t* area, uin if (ppa_out != NULL) { fb_base = (uint8_t*)ppa_out; } else { + if (lvgl_display_ensure_rotate_buf(ctx) != ERROR_NONE) { + LOG_E(TAG, "Failed to allocate rotation buffer, dropping frame"); + lv_display_flush_ready(disp); + return; + } uint32_t src_stride = lv_draw_buf_width_to_stride(logical_w, color_format); uint32_t dest_stride = lv_draw_buf_width_to_stride(hres, color_format); lv_draw_sw_rotate(fb_base, ctx->rotate_buf, logical_w, logical_h, src_stride, dest_stride, rotation, color_format); @@ -319,7 +522,20 @@ static void lvgl_display_flush_cb(lv_display_t* disp, const lv_area_t* area, uin } } - display_draw_bitmap(wrapper->device, 0, 0, hres, vres, fb_base); + error_t draw_result = display_draw_bitmap(wrapper->device, 0, 0, hres, vres, fb_base); + if (draw_result != ERROR_NONE) { + LOG_W(TAG, "draw_bitmap failed: %d", (int)draw_result); + } else if (ctx->slow_refresh) { + // The panel is now refreshing on its own; hand the "wait for it to finish" over to + // the refresh task so the render thread isn't stalled for the whole refresh. + ctx->refresh_in_flight.store(true); + TaskHandle_t task_handle = thread_get_task_handle(ctx->refresh_task); + if (task_handle != NULL) { + xTaskNotifyGive(task_handle); + } else { + LOG_E(TAG, "Refresh task not running while streaming a frame"); + } + } } } else if (ctx->owns_buffers) { // PARTIAL mode: each flush_cb call is one independent, complete tile into a buffer that @@ -364,6 +580,7 @@ error_t lvgl_display_add(struct Device* device, const struct LvglDisplayConfig* ctx->byte_swap = config->swap_bytes; ctx->sw_rotate = config->sw_rotate; + ctx->prefer_external_ram = config->prefer_external_ram; // Only relevant when sw_rotate is set - lvgl_display_try_ppa_rotate() also checks // ctx->ppa_eligible directly, so this is safe to compute unconditionally. ctx->ppa_eligible = lvgl_ppa_is_supported() && lvgl_ppa_supports_color_format(lv_color_format); @@ -404,17 +621,37 @@ error_t lvgl_display_add(struct Device* device, const struct LvglDisplayConfig* size_t buf_size_bytes; if (lv_color_format == LV_COLOR_FORMAT_I1) { - // I1 packs 8 pixels/byte row-wise and LVGL reserves an 8-byte palette header at the - // buffer's start (see lvgl_display_flush_cb()). Always redraw the whole frame in one - // owned buffer instead of computing partial-region byte offsets against that packing. - buf_size_bytes = (size_t)((hres + 7) / 8) * vres + 8; - ctx->buf1 = lvgl_display_alloc_buffer(buf_size_bytes, config->prefer_external_ram); - if (ctx->buf1 == NULL) { - delete wrapper; - return ERROR_OUT_OF_MEMORY; + // Windowed partial refresh for I1 e-paper that advertises both PARTIAL_REFRESH and + // SLOW_REFRESH: render into a small owned buffer (buffer_height rows) and stream each + // flush tile to the panel as it arrives, triggering the panel drive once per cycle + // (see lvgl_display_flush_cb()). The +8 reserves LVGL's I1 palette header, which + // get_max_row() subtracts when computing the tile height. + bool partial_i1 = display_has_capability(device, DISPLAY_CAPABILITY_PARTIAL_REFRESH) && + display_has_capability(device, DISPLAY_CAPABILITY_SLOW_REFRESH); + if (partial_i1) { + uint16_t buf_height = config->buffer_height == 0 ? vres : config->buffer_height; + buf_size_bytes = (size_t)((hres + 7) / 8) * buf_height + 8; + ctx->buf1 = lvgl_display_alloc_buffer(buf_size_bytes, config->prefer_external_ram); + if (ctx->buf1 == NULL) { + delete wrapper; + return ERROR_OUT_OF_MEMORY; + } + ctx->owns_buffers = true; + render_mode = LV_DISPLAY_RENDER_MODE_PARTIAL; + ctx->partial_refresh = true; + } else { + // I1 packs 8 pixels/byte row-wise and LVGL reserves an 8-byte palette header at the + // buffer's start (see lvgl_display_flush_cb()). Always redraw the whole frame in one + // owned buffer instead of computing partial-region byte offsets against that packing. + buf_size_bytes = (size_t)((hres + 7) / 8) * vres + 8; + ctx->buf1 = lvgl_display_alloc_buffer(buf_size_bytes, config->prefer_external_ram); + if (ctx->buf1 == NULL) { + delete wrapper; + return ERROR_OUT_OF_MEMORY; + } + ctx->owns_buffers = true; + render_mode = LV_DISPLAY_RENDER_MODE_FULL; } - ctx->owns_buffers = true; - render_mode = LV_DISPLAY_RENDER_MODE_FULL; } else if (would_bind_fb_direct) { display_get_frame_buffer(device, 0, &ctx->buf1); if (fb_count > 1) { @@ -447,25 +684,12 @@ error_t lvgl_display_add(struct Device* device, const struct LvglDisplayConfig* ctx->buf_size_bytes = buf_size_bytes; - if (ctx->sw_rotate) { - ctx->rotate_buf = lvgl_display_alloc_buffer(buf_size_bytes, config->prefer_external_ram); - if (ctx->rotate_buf == NULL) { - if (ctx->owns_buffers) { - lvgl_display_free_buffer(ctx->buf1); - lvgl_display_free_buffer(ctx->buf2); - } - delete wrapper; - return ERROR_OUT_OF_MEMORY; - } - } - lv_display_t* disp = lv_display_create(hres, vres); if (disp == NULL) { if (ctx->owns_buffers) { lvgl_display_free_buffer(ctx->buf1); lvgl_display_free_buffer(ctx->buf2); } - lvgl_display_free_buffer(ctx->rotate_buf); delete wrapper; return ERROR_OUT_OF_MEMORY; } @@ -480,6 +704,42 @@ error_t lvgl_display_add(struct Device* device, const struct LvglDisplayConfig* // Apply once explicitly, independent of whether LV_EVENT_RESOLUTION_CHANGED fires on creation. lvgl_display_apply_rotation(wrapper, lv_display_get_rotation(disp)); + ctx->slow_refresh = display_has_capability(device, DISPLAY_CAPABILITY_SLOW_REFRESH); + ctx->refresh_task_device = device; + ctx->refresh_task_display = disp; + if (ctx->slow_refresh) { + // Slow-refresh panels take 1-3s per refresh; waiting for that in the flush callback would + // stall the render thread, so the wait happens on this dedicated low-priority task instead. + ctx->refresh_task = thread_alloc_full( + "lvgl_refresh", + LVGL_DISPLAY_REFRESH_TASK_STACK_SIZE, + lvgl_display_refresh_task_main, + ctx, + -1 // no CPU affinity (the kernel's no-affinity sentinel; tskNO_AFFINITY is ESP-IDF-only) + ); + if (ctx->refresh_task == NULL) { + lv_display_delete(disp); + if (ctx->owns_buffers) { + lvgl_display_free_buffer(ctx->buf1); + lvgl_display_free_buffer(ctx->buf2); + } + delete wrapper; + return ERROR_OUT_OF_MEMORY; + } + thread_set_priority(ctx->refresh_task, THREAD_PRIORITY_LOW); + if (thread_start(ctx->refresh_task) != ERROR_NONE) { + thread_free(ctx->refresh_task); + ctx->refresh_task = NULL; + lv_display_delete(disp); + if (ctx->owns_buffers) { + lvgl_display_free_buffer(ctx->buf1); + lvgl_display_free_buffer(ctx->buf2); + } + delete wrapper; + return ERROR_UNDEFINED; + } + } + *out_display = disp; return ERROR_NONE; } @@ -490,6 +750,31 @@ void lvgl_display_remove(lv_display_t* display) { } struct LvglDeviceContext* wrapper = (struct LvglDeviceContext*)lv_display_get_driver_data(display); + + if (wrapper != NULL) { + struct LvglDisplayCtx* ctx = (struct LvglDisplayCtx*)wrapper->context; + // Stop the refresh task before deleting the display: the task touches the device + // (display_wait_sync) and LVGL objects (invalidate), both gone after this point, and a + // still-running task would hold the panel busy while we tear the display down. + if (ctx->refresh_task != NULL) { + ctx->refresh_task_stop.store(true); + TaskHandle_t task_handle = thread_get_task_handle(ctx->refresh_task); + if (task_handle != NULL) { + // Wake the task out of ulTaskNotifyTake() so it observes the stop flag and exits. + xTaskNotifyGive(task_handle); + } + if (thread_join(ctx->refresh_task, LVGL_DISPLAY_REFRESH_TASK_JOIN_TIMEOUT, pdMS_TO_TICKS(50)) != ERROR_NONE) { + // The task only ever stops itself; if it's still alive the driver's wait_sync() + // ignored its timeout. Leak rather than free memory a live task may still touch. + LOG_E(TAG, "Refresh task did not stop in time, leaking display resources"); + ctx->refresh_task = NULL; + return; + } + thread_free(ctx->refresh_task); + ctx->refresh_task = NULL; + } + } + lv_display_delete(display); if (wrapper != NULL) { diff --git a/TactilityKernel/include/tactility/drivers/display.h b/TactilityKernel/include/tactility/drivers/display.h index 97de217e4..4da8ad835 100644 --- a/TactilityKernel/include/tactility/drivers/display.h +++ b/TactilityKernel/include/tactility/drivers/display.h @@ -29,7 +29,16 @@ enum DisplayCapability { * it copies/converts into its own buffer first). Lets the LVGL bridge allocate this display's * draw buffer(s) from non-DMA-capable memory instead of forcing scarce internal RAM. */ - DISPLAY_CAPABILITY_PREFER_EXTERNAL_RAM = 1 << 9 + DISPLAY_CAPABILITY_PREFER_EXTERNAL_RAM = 1 << 9, + /** + * Supports windowed refresh: draw_bitmap() tiles are applied to the panel + * as they arrive, and the panel refresh is triggered explicitly via + * refresh() once a full frame has been streamed. E-paper panels with a + * differential refresh sequence additionally expose commit_base() so the + * base image stays in sync with what is on screen. Without this capability + * the driver refreshes the whole panel implicitly. + */ + DISPLAY_CAPABILITY_PARTIAL_REFRESH = 1 << 10 }; /** @@ -81,6 +90,46 @@ struct DisplayApi { */ error_t (*draw_bitmap)(struct Device* device, int32_t x_start, int32_t y_start, int32_t x_end, int32_t y_end, const void* color_data); + /** + * @brief Blocks until the panel has finished applying the last draw_bitmap refresh. + * @warning Function pointer should be null when not applicable. + * @param[in] device the display device + * @param[in] timeout_ms maximum time to block in milliseconds, 0 = infinite + * @retval ERROR_NONE when the panel is idle + * @retval ERROR_TIMEOUT when the panel is still busy after timeout_ms + * @details Only meaningful for slow-refresh displays (e.g. e-paper, where a + * refresh takes seconds and draw_bitmap returns before the panel is idle). + * Normal TFTs have no such window and should leave this NULL. + */ + error_t (*wait_sync)(struct Device* device, uint32_t timeout_ms); + + /** + * @brief Triggers a panel refresh of the currently streamed content. + * @warning Function pointer should be null when not applicable. + * @param[in] device the display device + * @param[in] full_frame when true, refresh the whole panel; when false, apply + * a partial refresh of only the regions modified since the last refresh + * @retval ERROR_NONE when the refresh was triggered + * @details For e-paper with windowed refresh, draw_bitmap() only streams + * pixels into the panel RAM; the panel does not change until refresh() is + * called. The refresh drives in the background, so wait_sync() must be + * called before the next command sequence. + */ + error_t (*refresh)(struct Device* device, bool full_frame); + + /** + * @brief Commits the last windowed refresh to the panel's base image. + * @warning Function pointer should be null when not applicable. + * @param[in] device the display device + * @retval ERROR_NONE when the base image was committed + * @details Only for panels whose partial refresh is differential against a + * separate base image plane (e.g. SSD1677 fast 0xFC). Called after + * wait_sync() once the panel has finished driving, so the base plane stays + * equal to what is actually on screen and the next partial refresh diffs + * correctly. + */ + error_t (*commit_base)(struct Device* device); + /** * @brief Mirrors the image along the X and/or Y axis. * @warning Function pointer should be null if capability not available. @@ -252,6 +301,24 @@ error_t display_init(struct Device* device); */ error_t display_draw_bitmap(struct Device* device, int32_t x_start, int32_t y_start, int32_t x_end, int32_t y_end, const void* color_data); +/** + * @brief Blocks until the panel has finished applying the last draw_bitmap refresh. + * @retval ERROR_NOT_SUPPORTED when the display has no wait_sync implementation. + */ +error_t display_wait_sync(struct Device* device, uint32_t timeout_ms); + +/** + * @brief Triggers a panel refresh of the currently streamed content. + * @retval ERROR_NOT_SUPPORTED when the display has no refresh implementation. + */ +error_t display_refresh(struct Device* device, bool full_frame); + +/** + * @brief Commits the last windowed refresh to the panel's base image. + * @retval ERROR_NOT_SUPPORTED when the display has no commit_base implementation. + */ +error_t display_commit_base(struct Device* device); + /** * @brief Mirrors the image along the X and/or Y axis using the specified display. */ diff --git a/TactilityKernel/source/drivers/display.cpp b/TactilityKernel/source/drivers/display.cpp index ab0830981..66c525dbe 100644 --- a/TactilityKernel/source/drivers/display.cpp +++ b/TactilityKernel/source/drivers/display.cpp @@ -30,6 +30,33 @@ error_t display_draw_bitmap(Device* device, int32_t x_start, int32_t y_start, in return DISPLAY_DRIVER_API(driver)->draw_bitmap(device, x_start, y_start, x_end, y_end, color_data); } +error_t display_wait_sync(Device* device, uint32_t timeout_ms) { + const auto* driver = device_get_driver(device); + const auto* api = DISPLAY_DRIVER_API(driver); + if (api->wait_sync == nullptr) { + return ERROR_NOT_SUPPORTED; + } + return api->wait_sync(device, timeout_ms); +} + +error_t display_refresh(Device* device, bool full_frame) { + const auto* driver = device_get_driver(device); + const auto* api = DISPLAY_DRIVER_API(driver); + if (api->refresh == nullptr) { + return ERROR_NOT_SUPPORTED; + } + return api->refresh(device, full_frame); +} + +error_t display_commit_base(Device* device) { + const auto* driver = device_get_driver(device); + const auto* api = DISPLAY_DRIVER_API(driver); + if (api->commit_base == nullptr) { + return ERROR_NOT_SUPPORTED; + } + return api->commit_base(device); +} + error_t display_mirror(Device* device, bool x_axis, bool y_axis) { const auto* driver = device_get_driver(device); return DISPLAY_DRIVER_API(driver)->mirror(device, x_axis, y_axis); diff --git a/TactilityKernel/source/symbols.c b/TactilityKernel/source/symbols.c index 418b116f8..32668e71c 100644 --- a/TactilityKernel/source/symbols.c +++ b/TactilityKernel/source/symbols.c @@ -144,6 +144,7 @@ const struct ModuleSymbol KERNEL_SYMBOLS[] = { DEFINE_MODULE_SYMBOL(display_reset), DEFINE_MODULE_SYMBOL(display_init), DEFINE_MODULE_SYMBOL(display_draw_bitmap), + DEFINE_MODULE_SYMBOL(display_wait_sync), DEFINE_MODULE_SYMBOL(display_mirror), DEFINE_MODULE_SYMBOL(display_swap_xy), DEFINE_MODULE_SYMBOL(display_get_swap_xy), diff --git a/device.py b/device.py index 448908258..b0d9c914c 100644 --- a/device.py +++ b/device.py @@ -12,6 +12,19 @@ SHELL_COLOR_ORANGE = "\033[93m" SHELL_COLOR_RESET = "\033[m" +# Maximum core clock (MHz) per target +CPU_FREQUENCIES_MHZ = { + "esp32": 240, + "esp32s2": 240, + "esp32s3": 240, + "esp32c3": 160, + "esp32c6": 160, + "esp32h2": 96, + "esp32p4": 360, + "esp32c2": 120, + "esp32c5": 240, +} + DEVICES_DIRECTORY = "Devices" def print_warning(message): @@ -148,13 +161,13 @@ def write_tactility_variables(output_file, device_properties: dict, device_id: s def write_core_variables(output_file, device_properties: dict): idf_target = get_property_or_exit(device_properties, "hardware.target").lower() + cpu_frequency = get_property_or_default(device_properties, "hardware.cpuFreq", + CPU_FREQUENCIES_MHZ.get(idf_target, 240)) output_file.write("# Target\n") output_file.write(f"CONFIG_IDF_TARGET=\"{idf_target}\"\n") output_file.write("# CPU\n") - output_file.write("CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ_240=y\n") - output_file.write("CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ=240\n") - output_file.write(f"CONFIG_{idf_target.upper()}_DEFAULT_CPU_FREQ_240=y\n") - output_file.write(f"CONFIG_{idf_target.upper()}_DEFAULT_CPU_FREQ_MHZ=240\n") + output_file.write(f"CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ_{cpu_frequency}=y\n") + output_file.write(f"CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ={cpu_frequency}\n") if idf_target != "esp32": # Not available on original ESP32 output_file.write("# Enable usage of MALLOC_CAP_EXEC on IRAM:\n") output_file.write("CONFIG_ESP_SYSTEM_MEMPROT_FEATURE=n\n") @@ -177,7 +190,8 @@ def write_flash_variables(output_file, device_properties: dict): flash_size_number = flash_size[:-2] output_file.write(f"CONFIG_ESPTOOLPY_FLASHSIZE_{flash_size_number}MB=y\n") flash_mode = get_property_or_default(device_properties, "hardware.flashMode", 'QIO') - output_file.write(f"CONFIG_FLASHMODE_{flash_mode}=y\n") + output_file.write(f"CONFIG_ESPTOOLPY_FLASHMODE_{flash_mode}=y\n") + output_file.write(f"CONFIG_ESPTOOLPY_FLASHMODE=\"{flash_mode.lower()}\"\n") esptool_flash_freq = get_property_or_none(device_properties, "hardware.esptoolFlashFreq") if esptool_flash_freq is not None: output_file.write(f"CONFIG_ESPTOOLPY_FLASHFREQ_{esptool_flash_freq}=y\n")