Waveshare ESP32-S3 Touch AMOLED 2.06" board support (#692)

This commit is contained in:
Loelin 2026-10-14 02:44:39 -04:00 committed by GitHub
parent a65a44de6b
commit 6a3c549324
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
38 changed files with 1779 additions and 109 deletions

View File

@ -0,0 +1,19 @@
file(GLOB_RECURSE SOURCE_FILES source/*.c*)
idf_component_register(
SRCS ${SOURCE_FILES}
INCLUDE_DIRS "source"
REQUIRES TactilityKernel Tactility TactilityFreeRtos lvgl-module lvgl-window-manager-module
)
# Board-local link-time hooks (see source/lvgl_scaling.cpp and source/lvgl_insets.cpp): keep the
# Material theme's widget sizes usable while tightening its spacing, and keep the UI clear of the
# top/bottom edges. No core Tactility/LVGL source is modified.
idf_build_set_property(LINK_OPTIONS "-Wl,--wrap=window_manager_configure" APPEND)
idf_build_set_property(LINK_OPTIONS "-Wl,--wrap=lv_display_create" APPEND)
idf_build_set_property(LINK_OPTIONS "-Wl,--wrap=lv_style_set_pad_top" APPEND)
idf_build_set_property(LINK_OPTIONS "-Wl,--wrap=lv_style_set_pad_bottom" APPEND)
idf_build_set_property(LINK_OPTIONS "-Wl,--wrap=lv_style_set_pad_left" APPEND)
idf_build_set_property(LINK_OPTIONS "-Wl,--wrap=lv_style_set_pad_right" APPEND)
idf_build_set_property(LINK_OPTIONS "-Wl,--wrap=lv_style_set_pad_row" APPEND)
idf_build_set_property(LINK_OPTIONS "-Wl,--wrap=lv_style_set_pad_column" APPEND)

View File

@ -0,0 +1,32 @@
general.vendor=Waveshare
general.name=S3 Touch AMOLED 2.06"
general.incubating=true
apps.launcherAppId=tactility.launcher
hardware.target=ESP32S3
hardware.flashSize=32MB
hardware.spiRam=true
hardware.spiRamMode=OCT
hardware.spiRamSpeed=120M
hardware.tinyUsbMsc=true
hardware.esptoolFlashFreq=120M
hardware.bluetooth=true
storage.userDataLocation=Internal
display.size=2.06"
display.shape=rectangle
display.dpi=326
lvgl.colorDepth=16
lvgl.uiDensity=default
lvgl.fontSize=22
sdkconfig.CONFIG_TASK_WDT_TIMEOUT_S=10
sdkconfig.CONFIG_SPIRAM_MALLOC_RESERVE_INTERNAL=131072
# LVGL v9 symbol (v8 was LV_DISP_DEF_REFR_PERIOD). Upstream moved this out of device.py,
# so pin it per-device: 10ms keeps the panel above the 16ms animation tick.
sdkconfig.CONFIG_LV_DEF_REFR_PERIOD=10
# Drive the screensaver animation at the panel's refresh rate instead of the generic 50ms.
sdkconfig.CONFIG_TT_DISPLAY_IDLE_TICK_MS=16

View File

@ -0,0 +1,11 @@
dependencies:
- Platforms/platform-esp32
- Drivers/sh8601-module
- Drivers/axp2101-module
- Drivers/bm8563-module
- Drivers/qmi8658-module
- Drivers/ft5x06-module
- Drivers/es8311-module
- Drivers/es7210-module
- Drivers/audio-stream-module
dts: waveshare,s3-touch-amoled-206.dts

View File

@ -0,0 +1,266 @@
// SPDX-License-Identifier: Apache-2.0
// Board-local Bluetooth auto-reconnect for the Waveshare S3 Touch AMOLED 2.06.
//
// Tactility's own auto-connect runs on the "bt-events" thread
// (Tactility/Source/bluetooth/Bluetooth.cpp). That thread can be blocked while the radio is on,
// so saved peers (e.g. a BLE keyboard) never get reconnected. This task subscribes to the kernel
// BT driver directly - its own subscription and its own thread, so it doesn't depend on bt-events
// - and, whenever the radio is on, scans for and reconnects every saved peer it sees.
#include <sdkconfig.h>
#if defined(CONFIG_BT_NIMBLE_ENABLED)
#include <Tactility/Thread.h>
#include <Tactility/bluetooth/Bluetooth.h>
#include <Tactility/bluetooth/BluetoothPairedDevice.h>
#include <tactility/concurrent/task_event_group.h>
#include <tactility/device.h>
#include <tactility/drivers/bluetooth.h>
#include <tactility/error.h>
#include <tactility/log.h>
#include <array>
#include <atomic>
#include <cstring>
#include <memory>
#include <vector>
namespace tt::bluetooth {
// The core HID host resolves a peer's address type from a scan cache that is filled in by the
// (blocked) bt-events thread. The helper that fills it is internal to Tactility (declared in
// Private/Tactility/bluetooth/BluetoothPrivate.h, which isn't exported to components), so it is
// re-declared here to prime that cache with the address type this task learns from its own scan.
void cacheScanAddr(const uint8_t addr[6], uint8_t addr_type);
} // namespace tt::bluetooth
namespace {
constexpr auto* TAG = "WsBtReconnect";
// How long the task blocks per wait, and how many of those slices make up one periodic retry.
// The slice bounds how quickly stop() is noticed; the total paces the retry scan.
constexpr TickType_t WAIT_SLICE = pdMS_TO_TICKS(500);
constexpr int RETRY_SLICES = 40; // ~20 s
// This task runs the same heavy work the core dispatches to the main task (settings::loadAll()
// plus tt::bluetooth::connect() -> hidHostConnect()), so it needs at least the main task's stack
// (CONFIG_ESP_MAIN_TASK_STACK_SIZE, 6144). Sized to match NimBLE's host task.
constexpr size_t TASK_STACK_SIZE = 8192;
// The full CONFIG_TT_DEVICE_NAME ("Waveshare S3 Touch AMOLED 2.06\"", 30 chars) doesn't fit the
// 31-byte legacy advertising payload, so the board advertises this shorter name instead. The full
// name is still shown in the UI. Must stay <= 26 chars to fit alongside the advertising flags.
constexpr const char* ADVERTISED_NAME = "S3 Touch AMOLED";
std::atomic<bool> reconnect_running { false };
std::unique_ptr<tt::Thread> reconnect_thread;
struct SavedPeer {
std::array<uint8_t, 6> addr;
int profile_id;
bool attempted;
};
// Only ever touched by the task.
std::vector<SavedPeer> saved_peers;
Device* bt_device = nullptr;
void refresh_saved_peers() {
saved_peers.clear();
for (const auto& peer : tt::bluetooth::settings::loadAll()) {
saved_peers.push_back(SavedPeer { .addr = peer.addr, .profile_id = peer.profileId, .attempted = false });
}
static int last_count = -1;
if (static_cast<int>(saved_peers.size()) != last_count) {
last_count = static_cast<int>(saved_peers.size());
LOG_I(TAG, "%d saved peer(s)", last_count);
for (const auto& peer : saved_peers) {
LOG_I(TAG, " saved %s profile=%d", tt::bluetooth::settings::addrToHex(peer.addr).c_str(), peer.profile_id);
}
}
}
SavedPeer* find_saved_peer(const BtAddr addr) {
for (auto& peer : saved_peers) {
if (std::memcmp(peer.addr.data(), addr, 6) == 0) {
return &peer;
}
}
return nullptr;
}
void connect_peer(const BtPeerRecord& record) {
SavedPeer* peer = find_saved_peer(record.addr);
if (peer == nullptr || peer->attempted) {
return;
}
peer->attempted = true;
// Prime the core HID host cache with the address type from our own scan.
tt::bluetooth::cacheScanAddr(record.addr, record.addr_type);
const auto hex = tt::bluetooth::settings::addrToHex(peer->addr);
LOG_I(TAG, "Reconnecting saved peer %s (profile=%d)", hex.c_str(), peer->profile_id);
tt::bluetooth::connect(peer->addr, peer->profile_id);
}
void scan_if_idle() {
if (bt_device == nullptr || saved_peers.empty()) {
return;
}
if (!tt::bluetooth::isRadioOnOrPending(bt_device)) {
return;
}
if (bluetooth_is_scanning(bt_device)) {
return;
}
for (auto& peer : saved_peers) {
peer.attempted = false;
}
LOG_I(TAG, "Scanning for %d saved peer(s)", static_cast<int>(saved_peers.size()));
bluetooth_scan_start(bt_device);
}
int32_t task_main() {
TaskEventGroup event_group {};
task_event_group_construct(&event_group);
LOG_I(TAG, "Task started");
// Wait for the Bluetooth device to be constructed and started (the module starts before the
// devicetree's devices do).
while (reconnect_running.load()) {
if (device_get_first_active_by_type(&BLUETOOTH_TYPE, &bt_device) == ERROR_NONE) {
break;
}
bt_device = nullptr;
vTaskDelay(pdMS_TO_TICKS(500));
}
BtEventSubscription subscription {};
if (!reconnect_running.load() || bt_device == nullptr ||
bluetooth_event_subscribe(bt_device, &subscription, &event_group) != ERROR_NONE) {
LOG_W(TAG, "Failed to acquire/subscribe the Bluetooth device");
if (bt_device != nullptr) {
device_put(bt_device);
bt_device = nullptr;
}
task_event_group_destruct(&event_group);
return 0;
}
LOG_I(TAG, "Subscribed to Bluetooth events");
// Advertise a name that fits the legacy advertising payload (see ADVERTISED_NAME). The driver
// restarts advertising with it if the radio is already on.
if (bluetooth_set_device_name(bt_device, ADVERTISED_NAME) != ERROR_NONE) {
LOG_W(TAG, "Failed to set the advertised Bluetooth name");
}
refresh_saved_peers();
scan_if_idle();
int slices = 0;
while (reconnect_running.load()) {
task_event_group_wait_any(&event_group, nullptr, WAIT_SLICE);
BtEvent event {};
while (bluetooth_event_poll(&subscription, &event) == ERROR_NONE) {
switch (event.type) {
case BT_EVENT_RADIO_STATE_CHANGED:
LOG_I(TAG, "Radio state -> %d", static_cast<int>(event.radio_state));
if (event.radio_state == BT_RADIO_STATE_ON) {
refresh_saved_peers();
scan_if_idle();
}
break;
case BT_EVENT_SCAN_FINISHED:
LOG_I(TAG, "Scan finished");
break;
case BT_EVENT_PROFILE_STATE_CHANGED:
LOG_I(TAG, "Profile %d state -> %d",
static_cast<int>(event.profile_state.profile),
static_cast<int>(event.profile_state.state));
// A saved peer dropped (e.g. the keyboard was powered off): rescan now instead
// of waiting for the next periodic tick.
if (event.profile_state.state == BT_PROFILE_STATE_IDLE) {
refresh_saved_peers();
scan_if_idle();
}
break;
case BT_EVENT_PEER_FOUND: {
SavedPeer* match = find_saved_peer(event.peer.addr);
if (event.peer.name[0] != '\0' || match != nullptr) {
std::array<uint8_t, 6> peer_addr {};
std::memcpy(peer_addr.data(), event.peer.addr, 6);
LOG_I(TAG, "Peer %s '%s' type=%d saved=%d",
tt::bluetooth::settings::addrToHex(peer_addr).c_str(),
event.peer.name,
static_cast<int>(event.peer.addr_type),
match != nullptr ? 1 : 0);
}
connect_peer(event.peer);
break;
}
default:
break;
}
}
// Periodic retry: pick up newly paired peers and rescan when nothing connected yet.
if (++slices >= RETRY_SLICES) {
slices = 0;
refresh_saved_peers();
LOG_I(TAG, "Tick: radioOn=%d scanning=%d saved=%d",
static_cast<int>(tt::bluetooth::isRadioOnOrPending(bt_device)),
static_cast<int>(bluetooth_is_scanning(bt_device)),
static_cast<int>(saved_peers.size()));
scan_if_idle();
}
}
bluetooth_event_unsubscribe(bt_device, &subscription);
device_put(bt_device);
bt_device = nullptr;
task_event_group_destruct(&event_group);
return 0;
}
void task_start() {
reconnect_thread = std::make_unique<tt::Thread>(
"ws-bt-reconnect",
TASK_STACK_SIZE,
[]() -> int32_t { return task_main(); }
);
reconnect_thread->start();
}
} // namespace
void waveshare_bluetooth_reconnect_start() {
if (reconnect_running.exchange(true)) {
return;
}
task_start();
}
void waveshare_bluetooth_reconnect_stop() {
if (!reconnect_running.exchange(false)) {
return;
}
if (reconnect_thread != nullptr) {
reconnect_thread->join();
reconnect_thread.reset();
}
}
#else
void waveshare_bluetooth_reconnect_start() {}
void waveshare_bluetooth_reconnect_stop() {}
#endif // CONFIG_BT_NIMBLE_ENABLED

View File

@ -0,0 +1,14 @@
// SPDX-License-Identifier: Apache-2.0
#pragma once
/**
* Board-local Bluetooth auto-reconnect for the Waveshare S3 Touch AMOLED 2.06.
*
* Starts a task that scans and reconnects saved (paired) BLE peers. It runs its own kernel BT
* event subscription on its own thread instead of relying on Tactility's "bt-events" thread,
* which can block while the radio is on.
*/
void waveshare_bluetooth_reconnect_start();
/** Stops the task, if running. */
void waveshare_bluetooth_reconnect_stop();

View File

@ -0,0 +1,50 @@
// SPDX-License-Identifier: Apache-2.0
// Keeps this board's UI clear of the display edges with a fixed inset.
//
// Core Tactility only applies the insets it derives from the display shape for round displays (see
// Tactility/Source/lvgl/Lvgl.cpp), and this rectangular AMOLED reports no shape, so instead of
// changing core source this wraps the window-manager screen-init callback - the same board-local
// link-time wrapping used in lvgl_scaling.cpp.
//
// The padding is applied to the window-manager root widget (the argument the screen-init callback
// receives and builds its chrome under), not just to the app container it returns. That way the
// statusbar, which the callback adds as a sibling of the app container, is inset with the rest of
// the UI instead of sitting on the top edge of the display.
//
// lv_obj_set_style_pad_all() writes the local style prop directly, so it is NOT affected by the
// /8 padding wrapper in lvgl_scaling.cpp (that one only covers lv_style_set_pad_*).
#include <lvgl_window_manager/window_manager.h>
#include <lvgl/lvgl.h>
#include <stdint.h>
namespace {
// Fixed insets, in pixels: horizontal on the left/right, vertical on the top/bottom.
constexpr int32_t INSET_HORIZONTAL_PX = 50;
constexpr int32_t INSET_VERTICAL_PX = 30;
// The callback passed to window_manager_configure(), invoked later to build the window tree.
WindowManagerScreenInitFn original_screen_init = nullptr;
} // namespace
extern "C" {
static lv_obj_t* wrapping_screen_init(lv_obj_t* root_widget) {
lv_obj_set_style_pad_hor(root_widget, INSET_HORIZONTAL_PX, LV_STATE_DEFAULT);
lv_obj_set_style_pad_ver(root_widget, INSET_VERTICAL_PX, LV_STATE_DEFAULT);
return original_screen_init != nullptr ? original_screen_init(root_widget) : root_widget;
}
void __real_window_manager_configure(WindowManagerScreenInitFn screen_init);
void __wrap_window_manager_configure(WindowManagerScreenInitFn screen_init) {
original_screen_init = screen_init;
__real_window_manager_configure(screen_init != nullptr ? wrapping_screen_init : nullptr);
}
} // extern "C"

View File

@ -0,0 +1,86 @@
// SPDX-License-Identifier: Apache-2.0
// Tactility's Material themes (Modules/lvgl-module) derive both widget metrics (switch/checkbox/
// slider sizes, radius, borders, ...) and spacing (padding/gaps) from the display DPI. On this
// high-DPI panel (`display.dpi` = 326) those metrics are far too large relative to the text, but
// simply lowering the DPI to tighten the spacing also shrinks the widgets to unusable sizes - the
// switch track, for example, is LV_DPX_CALC(dpi, 52) x LV_DPX_CALC(dpi, 32).
//
// So the two are decoupled here, board-locally, with link-time wrappers (the same mechanism used
// elsewhere) instead of editing core Tactility/LVGL source:
// - lv_display_create(): the display DPI tracks the text font, so widget sizes stay usable;
// - lv_style_set_pad_*(): the theme's style paddings/gaps are divided, so spacing stays tight.
// Only the two theme files use style-based padding (apps use lv_obj_set_style_*, which does not go
// through these setters), so the padding wrappers affect the theme and nothing else.
#include <tactility/log.h>
#include <lvgl/fonts.h>
#include <lvgl/lvgl.h>
constexpr auto* TAG = "waveshare-s3-touch-amoled-206";
namespace {
// Widget metrics scale with the display DPI; this puts them near Material's baseline at the
// default 22 px font (e.g. the switch track is ~50 x 31 px).
constexpr int32_t WIDGET_DPI_PER_FONT_PIXEL = 7;
// Spacing is divided by this instead of following the widget DPI (e.g. the theme's largest control
// padding, dp(24), becomes ~3 px).
constexpr int32_t PADDING_DIVISOR = 8;
int32_t scalePadding(int32_t value) {
// Keep zero and negative values (handle/knob geometry) as the theme designed them.
if (value <= 0) {
return value;
}
return (value + PADDING_DIVISOR / 2) / PADDING_DIVISOR;
}
int32_t displayDpiForFontSize(int32_t font_size) {
return font_size * WIDGET_DPI_PER_FONT_PIXEL;
}
} // namespace
extern "C" {
lv_display_t* __real_lv_display_create(int32_t hor_res, int32_t ver_res);
lv_display_t* __wrap_lv_display_create(int32_t hor_res, int32_t ver_res) {
lv_display_t* display = __real_lv_display_create(hor_res, ver_res);
if (display == nullptr) {
return display;
}
const int32_t font_size = static_cast<int32_t>(lvgl_get_text_font_height(FONT_SIZE_DEFAULT));
if (font_size <= 0) {
return display;
}
const int32_t dpi = displayDpiForFontSize(font_size);
if (lv_display_get_dpi(display) != dpi) {
lv_display_set_dpi(display, dpi);
LOG_I(TAG, "LVGL display DPI set to %d for text font %d px", (int)dpi, (int)font_size);
}
return display;
}
// lv_style_set_pad_all/hor/ver/gap are static inline and call these base setters, so wrapping the
// six base ones covers every padding a theme style sets.
#define WRAP_PADDING_SETTER(name) \
void __real_##name(lv_style_t* style, int32_t value); \
void __wrap_##name(lv_style_t* style, int32_t value) { __real_##name(style, scalePadding(value)); }
WRAP_PADDING_SETTER(lv_style_set_pad_top)
WRAP_PADDING_SETTER(lv_style_set_pad_bottom)
WRAP_PADDING_SETTER(lv_style_set_pad_left)
WRAP_PADDING_SETTER(lv_style_set_pad_right)
WRAP_PADDING_SETTER(lv_style_set_pad_row)
WRAP_PADDING_SETTER(lv_style_set_pad_column)
#undef WRAP_PADDING_SETTER
} // extern "C"

View File

@ -0,0 +1,26 @@
#include "bluetooth_reconnect.h"
#include <tactility/module.h>
extern "C" {
static error_t start() {
waveshare_bluetooth_reconnect_start();
return ERROR_NONE;
}
static error_t stop() {
waveshare_bluetooth_reconnect_stop();
return ERROR_NONE;
}
Module waveshare_s3_touch_amoled_206_module = {
.name = "waveshare-s3-touch-amoled-206",
.start = start,
.stop = stop,
.drivers = nullptr,
.symbols = nullptr,
.internal = nullptr
};
}

View File

@ -0,0 +1,179 @@
/dts-v1/;
#include <tactility/bindings/root.h>
#include <tactility/bindings/esp32_ble.h>
#include <tactility/bindings/esp32_wifi_pinned.h>
#include <tactility/bindings/esp32_gpio.h>
#include <tactility/bindings/esp32_i2c_master.h>
#include <tactility/bindings/esp32_i2s.h>
#include <tactility/bindings/esp32_spi.h>
#include <tactility/bindings/esp32_sdspi.h>
#include <tactility/bindings/esp32_usbdevice.h>
#include <tactility/bindings/gpio_hog.h>
#include <bindings/bm8563.h>
#include <bindings/qmi8658.h>
#include <bindings/axp2101.h>
#include <bindings/ft5x06.h>
#include <bindings/es8311.h>
#include <bindings/es7210.h>
#include <bindings/sh8601.h>
#include <bindings/sh8601_backlight.h>
// Reference: https://www.waveshare.com/wiki/ESP32-S3-Touch-AMOLED-2.06
/ {
compatible = "root";
model = "Waveshare S3 Touch AMOLED 2.06";
wifi0 {
compatible = "espressif,esp32-wifi-pinned";
};
ble0 {
compatible = "espressif,esp32-ble";
};
gpio0 {
compatible = "espressif,esp32-gpio";
gpio-count = <49>;
};
// ES8311 speaker amplifier enable, active-high on GPIO46.
amp_enable {
compatible = "gpio-hog";
pin = <&gpio0 46 GPIO_FLAG_NONE>;
mode = <GPIO_HOG_MODE_OUTPUT_HIGH>;
};
// i2s0 must be before i2c0 so the codecs can reference it.
i2s0 {
compatible = "espressif,esp32-i2s";
port = <I2S_NUM_1>;
pin-bclk = <&gpio0 41 GPIO_FLAG_NONE>;
pin-ws = <&gpio0 45 GPIO_FLAG_NONE>;
pin-data-out = <&gpio0 40 GPIO_FLAG_NONE>;
pin-data-in = <&gpio0 42 GPIO_FLAG_NONE>;
pin-mclk = <&gpio0 16 GPIO_FLAG_NONE>;
};
i2c0 {
compatible = "espressif,esp32-i2c-master";
port = <I2C_NUM_0>;
clock-frequency = <400000>;
pin-sda = <&gpio0 15 GPIO_FLAG_PULL_UP>;
pin-scl = <&gpio0 14 GPIO_FLAG_PULL_UP>;
bm8563 {
compatible = "belling,bm8563";
reg = <0x51>;
};
qmi8658 {
compatible = "qst,qmi8658";
reg = <0x6B>;
// Maps the sensor frame onto the device frame (Linux IIO "mount-matrix" convention).
// The QMI8658C is rotated relative to the panel; this matrix is the one calibrated
// on-device so auto-rotate orients the UI correctly.
mount-matrix = <0 -1 0
1 0 0
0 0 1>;
};
// Mirrors the board's original power-up register writes: every LDO enabled, with
// ALDO1 at 1.8V and ALDO2-4 at 3.3V. The BLDO/DLDO rails keep their chip defaults.
axp2101: axp2101 {
compatible = "x-powers,axp2101";
reg = <0x34>;
aldo1-millivolt = <1800>;
aldo1-enabled;
aldo2-millivolt = <3300>;
aldo2-enabled;
aldo3-millivolt = <3300>;
aldo3-enabled;
aldo4-millivolt = <3300>;
aldo4-enabled;
bldo1-enabled;
bldo2-enabled;
dldo1-enabled;
dldo2-enabled;
};
// FT3168, register-compatible with the FT5x06.
touch0 {
compatible = "focaltech,ft5x06";
reg = <0x38>;
x-max = <410>;
y-max = <502>;
pin-reset = <&gpio0 9 GPIO_FLAG_NONE>;
pin-interrupt = <&gpio0 38 GPIO_FLAG_NONE>;
disable-monitor-timeout;
};
es8311: es8311 {
compatible = "everest,es8311";
reg = <0x18>;
i2s = <&i2s0>;
};
es7210 {
compatible = "everest,es7210";
reg = <0x40>;
i2s = <&i2s0>;
// Only MIC1 is wired to a real capsule on this board.
mic-mask = <1>;
};
};
// AMOLED display on QSPI: CS=12, PCLK=11, D0..D3=4..7, RST=8.
spi2 {
compatible = "espressif,esp32-spi";
host = <SPI2_HOST>;
cs-gpios = <&gpio0 12 GPIO_FLAG_NONE>;
pin-mosi = <&gpio0 4 GPIO_FLAG_NONE>;
pin-miso = <&gpio0 5 GPIO_FLAG_NONE>;
pin-wp = <&gpio0 6 GPIO_FLAG_NONE>;
pin-hd = <&gpio0 7 GPIO_FLAG_NONE>;
pin-sclk = <&gpio0 11 GPIO_FLAG_NONE>;
max-transfer-size = <65536>;
display0: display@0 {
compatible = "shenghe,sh8601";
horizontal-resolution = <410>;
vertical-resolution = <502>;
gap-x = <22>;
gap-y = <0>;
pixel-clock-hz = <40000000>;
pin-reset = <&gpio0 8 GPIO_FLAG_NONE>;
// The SH8601 has no backlight pin; brightness is a panel command. The driver finds
// this child device via the display's children, and the backlight uses its parent.
display_backlight {
compatible = "sh8601-backlight";
brightness-default = <255>;
};
};
};
// SD card on SPI3: CS=17, MOSI=1, MISO=3, SCLK=2.
spi3 {
compatible = "espressif,esp32-spi";
host = <SPI3_HOST>;
cs-gpios = <&gpio0 17 GPIO_FLAG_NONE>;
pin-mosi = <&gpio0 1 GPIO_FLAG_NONE>;
pin-miso = <&gpio0 3 GPIO_FLAG_NONE>;
pin-sclk = <&gpio0 2 GPIO_FLAG_NONE>;
max-transfer-size = <4096>;
sdcard@0 {
compatible = "espressif,esp32-sdspi";
frequency-khz = <20000>;
};
};
usbdevice0 {
compatible = "espressif,esp32-usbdevice";
usbdevicemsc0 {
compatible = "espressif,esp32-usbdevice-msc";
};
};
};

View File

@ -35,3 +35,11 @@ properties:
type: phandles type: phandles
default: GPIO_PIN_SPEC_NONE default: GPIO_PIN_SPEC_NONE
description: Interrupt GPIO pin description: Interrupt GPIO pin
disable-monitor-timeout:
type: boolean
default: false
description: >
Keep the controller out of monitor mode so it never NAKs a periodic I2C poll. Writes 0 to the
FT5x06 monitor/sleep timeout register (0x87) and 0 (active) to the FT3168/FT5x06 power-mode
register (0xA5). Some boards read the controller infrequently enough (e.g. apps that bypass
LVGL's periodic read) that it otherwise sleeps and NAKs subsequent I2C reads.

View File

@ -21,6 +21,8 @@ struct Ft5x06Config {
bool mirror_y; bool mirror_y;
struct GpioPinSpec pin_reset; struct GpioPinSpec pin_reset;
struct GpioPinSpec pin_interrupt; struct GpioPinSpec pin_interrupt;
// When true, disable the controller's monitor/sleep timeout (register 0x87 = 0) after init.
bool disable_monitor_timeout;
}; };
#ifdef __cplusplus #ifdef __cplusplus

View File

@ -153,6 +153,26 @@ static error_t start(Device* device) {
return ERROR_RESOURCE; return ERROR_RESOURCE;
} }
// Register 0x87 is the delay (in seconds) before the controller enters monitor mode. When the
// controller is only read infrequently (e.g. an app polling the driver directly rather than
// LVGL's periodic read), it sleeps and NAKs the next I2C read - boards can opt out entirely.
if (config->disable_monitor_timeout) {
// FT5x06: 0 = never auto-enter monitor mode.
uint8_t disable_monitor = 0;
if (esp_lcd_panel_io_tx_param(internal->io_handle, 0x87, &disable_monitor, 1) != ESP_OK) {
LOG_W(TAG, "Failed to disable monitor timeout");
}
// FT3168/FT3x68 (and FT5x06): register 0xA5 is the power mode
// (0=active, 1=monitor, 2=standby, 3=hibernate). The controller can power up in - or fall
// into - monitor mode, where it stops answering I2C until touched, which makes the periodic
// poll fail. Force active so every read is answered.
uint8_t power_mode_active = 0;
if (esp_lcd_panel_io_tx_param(internal->io_handle, 0xA5, &power_mode_active, 1) != ESP_OK) {
LOG_W(TAG, "Failed to set active power mode");
}
}
device_set_driver_data(device, internal); device_set_driver_data(device, internal);
return ERROR_NONE; return ERROR_NONE;
} }

View File

@ -5,27 +5,11 @@ include: [ "i2c-device.yaml" ]
compatible: "qst,qmi8658" compatible: "qst,qmi8658"
properties: properties:
swap-xy: mount-matrix:
type: boolean type: values
default: false default: [1, 0, 0, 0, 1, 0, 0, 0, 1]
description: Swap the X and Y axes description: >
swap-xz: Row-major 3x3 rotation matrix that maps the sensor frame onto the device frame, following the
type: boolean Linux IIO "mount-matrix" convention (x' = m[0]*x + m[1]*y + m[2]*z, and so on). It is applied
default: false to the accelerometer and gyroscope readings so consumers always see the device frame,
description: Swap the X and Z axes regardless of how the IMU is physically oriented on the PCB. Defaults to the identity matrix.
swap-yz:
type: boolean
default: false
description: Swap the Y and Z axes
invert-x:
type: boolean
default: false
description: Invert the X axis
invert-y:
type: boolean
default: false
description: Invert the Y axis
invert-z:
type: boolean
default: false
description: Invert the Z axis

View File

@ -14,12 +14,12 @@ extern "C" {
struct Qmi8658Config { struct Qmi8658Config {
/** I2C address (0x6A when SA0=low, 0x6B when SA0=high) */ /** I2C address (0x6A when SA0=low, 0x6B when SA0=high) */
uint8_t address; uint8_t address;
bool swap_xy; /**
bool swap_xz; * Row-major 3x3 rotation matrix mapping the sensor frame onto the device frame (Linux IIO
bool swap_yz; * "mount-matrix" convention: `out[i] = m[i * 3] * x + m[i * 3 + 1] * y + m[i * 3 + 2] * z`).
bool invert_x; * Identity means the sensor frame already matches the device frame.
bool invert_y; */
bool invert_z; int32_t mount_matrix[9];
}; };
/** /**

View File

@ -4,8 +4,6 @@
#include <tactility/drivers/i2c_controller.h> #include <tactility/drivers/i2c_controller.h>
#include <tactility/log.h> #include <tactility/log.h>
#include <algorithm>
#define TAG "QMI8658" #define TAG "QMI8658"
// Register map (QMI8658 datasheet) // Register map (QMI8658 datasheet)
@ -37,6 +35,17 @@ static constexpr TickType_t I2C_TIMEOUT_TICKS = pdMS_TO_TICKS(10);
#define GET_CONFIG(device) (static_cast<const Qmi8658Config*>((device)->config)) #define GET_CONFIG(device) (static_cast<const Qmi8658Config*>((device)->config))
// Applies the devicetree mount matrix (sensor frame -> device frame) to a 3-axis reading.
static void applyMountMatrix(const Qmi8658Config& config, float& x, float& y, float& z) {
const int32_t* m = config.mount_matrix;
const float rx = m[0] * x + m[1] * y + m[2] * z;
const float ry = m[3] * x + m[4] * y + m[5] * z;
const float rz = m[6] * x + m[7] * y + m[8] * z;
x = rx;
y = ry;
z = rz;
}
// region Driver lifecycle // region Driver lifecycle
static error_t start(Device* device) { static error_t start(Device* device) {
@ -113,13 +122,7 @@ error_t qmi8658_read_accel(Device* device, ImuAccelData* data) {
toI16(buf[2], buf[3]) * ACCEL_SCALE, toI16(buf[2], buf[3]) * ACCEL_SCALE,
toI16(buf[4], buf[5]) * ACCEL_SCALE, toI16(buf[4], buf[5]) * ACCEL_SCALE,
}; };
const auto& config = *GET_CONFIG(device); applyMountMatrix(*GET_CONFIG(device), axes[0], axes[1], axes[2]);
if (config.swap_xy) std::swap(axes[0], axes[1]);
if (config.swap_xz) std::swap(axes[0], axes[2]);
if (config.swap_yz) std::swap(axes[1], axes[2]);
if (config.invert_x) axes[0] = -axes[0];
if (config.invert_y) axes[1] = -axes[1];
if (config.invert_z) axes[2] = -axes[2];
data->ax = axes[0]; data->ax = axes[0];
data->ay = axes[1]; data->ay = axes[1];
data->az = axes[2]; data->az = axes[2];
@ -139,9 +142,13 @@ error_t qmi8658_read_gyro(Device* device, ImuGyroData* data) {
return static_cast<int16_t>(static_cast<uint16_t>(hi) << 8 | lo); return static_cast<int16_t>(static_cast<uint16_t>(hi) << 8 | lo);
}; };
data->gx = toI16(buf[0], buf[1]) * GYRO_SCALE; float gx = toI16(buf[0], buf[1]) * GYRO_SCALE;
data->gy = toI16(buf[2], buf[3]) * GYRO_SCALE; float gy = toI16(buf[2], buf[3]) * GYRO_SCALE;
data->gz = toI16(buf[4], buf[5]) * GYRO_SCALE; float gz = toI16(buf[4], buf[5]) * GYRO_SCALE;
applyMountMatrix(*GET_CONFIG(device), gx, gy, gz);
data->gx = gx;
data->gy = gy;
data->gz = gz;
return ERROR_NONE; return ERROR_NONE;
} }

View File

@ -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(sh8601-module
SRCS ${SOURCE_FILES}
INCLUDE_DIRS include/
REQUIRES TactilityKernel platform-esp32 esp_lcd_sh8601
)

View File

@ -0,0 +1,195 @@
Apache License
==============
_Version 2.0, January 2004_
_&lt;<http://www.apache.org/licenses/>&gt;_
### 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.

View File

@ -0,0 +1,9 @@
# SH8601 display
A driver for the `SH8601` AMOLED display panel, over SPI/QSPI.
The SH8601 has no dedicated backlight pin: brightness is controlled through the panel's own
command interface. This module therefore exposes two devices - the display itself and a
`sh8601-backlight` device that sends brightness commands through the display's panel IO.
License: [Apache v2.0](LICENSE-Apache-2.0.md)

View File

@ -0,0 +1,13 @@
description: >
Backlight for an SH8601 AMOLED panel. The panel has no dedicated backlight pin: brightness is
controlled over the panel's own QSPI command interface (command 0x51), so this driver sends
that command through the parent display device's panel IO. Must be declared as a child node of
the display device it dims.
compatible: "sh8601-backlight"
properties:
brightness-default:
type: int
default: 255
description: Default brightness level (0-255), applied by set_brightness_default()

View File

@ -0,0 +1,39 @@
description: Shenghe SH8601 AMOLED display panel (SPI/QSPI)
bus: spi
compatible: "shenghe,sh8601"
properties:
horizontal-resolution:
type: int
required: true
description: Horizontal resolution in pixels
vertical-resolution:
type: int
required: true
description: Vertical resolution in pixels
gap-x:
type: int
default: 0
description: X offset applied to all draw operations
gap-y:
type: int
default: 0
description: Y offset applied to all draw operations
invert-color:
type: boolean
default: false
description: Invert the panel's color output
pixel-clock-hz:
type: int
default: 40000000
description: QSPI pixel clock frequency in Hz
transaction-queue-depth:
type: int
default: 10
description: Size of the internal SPI transaction queue
pin-reset:
type: phandles
default: GPIO_PIN_SPEC_NONE
description: Reset GPIO pin

View File

@ -0,0 +1,7 @@
// SPDX-License-Identifier: Apache-2.0
#pragma once
#include <tactility/bindings/bindings.h>
#include <drivers/sh8601.h>
DEFINE_DEVICETREE(sh8601, struct Sh8601Config)

View File

@ -0,0 +1,7 @@
// SPDX-License-Identifier: Apache-2.0
#pragma once
#include <tactility/bindings/bindings.h>
#include <drivers/sh8601_backlight.h>
DEFINE_DEVICETREE(sh8601_backlight, struct Sh8601BacklightConfig)

View File

@ -0,0 +1,27 @@
// SPDX-License-Identifier: Apache-2.0
#pragma once
#ifdef __cplusplus
extern "C" {
#endif
#include <stdint.h>
#include <stdbool.h>
#include <tactility/device.h>
#include <tactility/drivers/gpio.h>
struct Sh8601Config {
uint16_t horizontal_resolution;
uint16_t vertical_resolution;
int32_t gap_x;
int32_t gap_y;
bool invert_color;
uint32_t pixel_clock_hz;
uint8_t transaction_queue_depth;
struct GpioPinSpec pin_reset;
};
#ifdef __cplusplus
}
#endif

View File

@ -0,0 +1,25 @@
// SPDX-License-Identifier: Apache-2.0
#pragma once
#ifdef __cplusplus
extern "C" {
#endif
#include <stdint.h>
#include <tactility/device.h>
/**
* @brief Devicetree configuration for an SH8601 panel's backlight.
*
* The SH8601 has no separate backlight control line; brightness is set with the panel's own
* 0x51 command, sent through the parent display device's panel IO.
*/
struct Sh8601BacklightConfig {
/** Default brightness level (0-255), applied by set_brightness_default() */
uint8_t brightness_default;
};
#ifdef __cplusplus
}
#endif

View File

@ -0,0 +1,14 @@
// SPDX-License-Identifier: Apache-2.0
#pragma once
#include <tactility/module.h>
#ifdef __cplusplus
extern "C" {
#endif
extern struct Module sh8601_module;
#ifdef __cplusplus
}
#endif

View File

@ -0,0 +1,3 @@
dependencies:
- TactilityKernel
bindings: bindings

View File

@ -0,0 +1,25 @@
// SPDX-License-Identifier: Apache-2.0
#include <tactility/driver.h>
#include <tactility/module.h>
extern "C" {
extern Driver sh8601_driver;
extern Driver sh8601_backlight_driver;
static Driver* const sh8601_drivers[] = {
&sh8601_driver,
&sh8601_backlight_driver,
nullptr
};
Module sh8601_module = {
.name = "sh8601",
.start = nullptr,
.stop = nullptr,
.drivers = sh8601_drivers,
.symbols = nullptr,
.internal = nullptr
};
} // extern "C"

View File

@ -0,0 +1,618 @@
// SPDX-License-Identifier: Apache-2.0
#include <drivers/sh8601.h>
#include <drivers/sh8601_backlight.h>
#include <sh8601_module.h>
#include <tactility/check.h>
#include <tactility/device.h>
#include <tactility/driver.h>
#include <tactility/drivers/backlight.h>
#include <tactility/drivers/display.h>
#include <tactility/drivers/esp32_spi.h>
#include <tactility/drivers/spi_controller.h>
#include <tactility/error.h>
#include <tactility/log.h>
#include <esp_err.h>
#include <esp_lcd_panel_io.h>
#include <esp_lcd_panel_ops.h>
#include <esp_lcd_sh8601.h>
#include <freertos/FreeRTOS.h>
#include <freertos/semphr.h>
#include <cstdlib>
#include <cstring>
#include <new>
constexpr auto* TAG = "SH8601";
#define GET_CONFIG(device) (static_cast<const Sh8601Config*>((device)->config))
#define GET_BACKLIGHT_CONFIG(device) (static_cast<const Sh8601BacklightConfig*>((device)->config))
// QSPI commands encode their opcode in the top byte of the 32-bit command word (see the SH8601
// component's tx_param()/tx_color()).
#define SH8601_QSPI_OPCODE_WRITE_CMD (0x02U << 24)
// Brightness command; the parameter byte is a 0-255 duty level (0 = off).
#define SH8601_CMD_BRIGHTNESS 0x51
// MIRROR/SWAP_XY are deliberately not advertised: the SH8601 component only implements a partial
// mirror (X only, no Y) and rejects swap_xy outright, so the LVGL bridge falls back to software
// rotation (see devices.cpp's can_hw_rotate).
#define SH8601_STATIC_CAPABILITIES (DISPLAY_CAPABILITY_CAP_SET_GAP | DISPLAY_CAPABILITY_INVERT_COLOR | \
DISPLAY_CAPABILITY_ON_OFF | DISPLAY_CAPABILITY_PREFER_EXTERNAL_RAM)
struct Sh8601Internal {
Device* spi_controller;
esp_lcd_panel_io_handle_t io_handle;
esp_lcd_panel_handle_t panel_handle;
// draw_bitmap() must block until the transfer physically completes (DisplayApi's synchronous
// contract); see gc9a01-module for the same pattern.
SemaphoreHandle_t draw_done_semaphore;
// Full-screen copy of what the panel is currently showing, in the panel's byte order. The
// SH8601 requires an even-aligned draw window (even start coordinate and even width/height),
// so when LVGL hands us an odd-aligned region the extra border pixels have to come from
// somewhere - this is that somewhere.
uint8_t* shadow;
// Gather buffer for the (possibly expanded) aligned window handed to the panel.
uint8_t* staging;
};
// Bring-up sequence from the Waveshare BSP (esp32_s3_touch_amoled_2_06.c), proven correct on real
// hardware. The component sends MADCTL/COLMOD itself before these.
static const uint8_t SH8601_INIT_DATA_11[] = {0x00};
static const uint8_t SH8601_INIT_DATA_C4[] = {0x80};
static const uint8_t SH8601_INIT_DATA_44[] = {0x01, 0xD1};
static const uint8_t SH8601_INIT_DATA_35[] = {0x00};
static const uint8_t SH8601_INIT_DATA_53[] = {0x20};
static const uint8_t SH8601_INIT_DATA_63[] = {0xFF};
static const uint8_t SH8601_INIT_DATA_51_OFF[] = {0x00};
static const uint8_t SH8601_INIT_DATA_2A[] = {0x00, 0x16, 0x01, 0xAF};
static const uint8_t SH8601_INIT_DATA_2B[] = {0x00, 0x00, 0x01, 0xF5};
static const uint8_t SH8601_INIT_DATA_29[] = {0x00};
static const uint8_t SH8601_INIT_DATA_51_ON[] = {0xFF};
static const sh8601_lcd_init_cmd_t SH8601_INIT_COMMANDS[] = {
{0x11, SH8601_INIT_DATA_11, 0, 120},
{0xC4, SH8601_INIT_DATA_C4, 1, 0},
{0x44, SH8601_INIT_DATA_44, 2, 0},
{0x35, SH8601_INIT_DATA_35, 1, 0},
{0x53, SH8601_INIT_DATA_53, 1, 10},
{0x63, SH8601_INIT_DATA_63, 1, 10},
{0x51, SH8601_INIT_DATA_51_OFF, 1, 10},
{0x2A, SH8601_INIT_DATA_2A, 4, 0},
{0x2B, SH8601_INIT_DATA_2B, 4, 0},
{0x29, SH8601_INIT_DATA_29, 0, 10},
{0x51, SH8601_INIT_DATA_51_ON, 1, 0},
};
static bool IRAM_ATTR on_color_trans_done(esp_lcd_panel_io_handle_t, esp_lcd_panel_io_event_data_t*, void* user_ctx) {
auto* internal = static_cast<Sh8601Internal*>(user_ctx);
BaseType_t high_task_woken = pdFALSE;
xSemaphoreGiveFromISR(internal->draw_done_semaphore, &high_task_woken);
return high_task_woken == pdTRUE;
}
static int pin_or_unused(const struct GpioPinSpec& pin) {
return pin.gpio_controller == nullptr ? -1 : static_cast<int>(pin.pin);
}
// Sends an already even-aligned, tightly-packed region to the panel and blocks until the last
// chunk has physically completed.
static error_t present(Sh8601Internal* internal, int32_t x_start, int32_t y_start, int32_t x_end, int32_t y_end, const void* data) {
xSemaphoreTake(internal->draw_done_semaphore, 0);
spi_controller_lock(internal->spi_controller);
esp_err_t ret = esp_lcd_panel_draw_bitmap(internal->panel_handle, x_start, y_start, x_end, y_end, data);
if (ret != ESP_OK) {
spi_controller_unlock(internal->spi_controller);
return ERROR_RESOURCE;
}
// Hold the bus lock across the wait too: the command/data phases aren't wrapped in their own
// acquire_bus by esp_lcd_panel_io_spi, so another bus user could otherwise interleave with a
// transfer that's still in flight.
xSemaphoreTake(internal->draw_done_semaphore, portMAX_DELAY);
spi_controller_unlock(internal->spi_controller);
return ERROR_NONE;
}
// region Driver lifecycle
static error_t start(Device* device) {
auto* parent = device_get_parent(device);
check(device_get_type(parent) == &SPI_CONTROLLER_TYPE);
const auto* spi_config = static_cast<const Esp32SpiConfig*>(parent->config);
const auto* config = GET_CONFIG(device);
struct GpioPinSpec cs_pin;
if (esp32_spi_get_cs_pin(device, &cs_pin) != ERROR_NONE) {
LOG_E(TAG, "Failed to resolve CS pin");
return ERROR_RESOURCE;
}
auto* internal = static_cast<Sh8601Internal*>(calloc(1, sizeof(Sh8601Internal)));
if (internal == nullptr) {
return ERROR_OUT_OF_MEMORY;
}
internal->spi_controller = parent;
internal->draw_done_semaphore = xSemaphoreCreateBinary();
if (internal->draw_done_semaphore == nullptr) {
free(internal);
return ERROR_OUT_OF_MEMORY;
}
const size_t frame_buffer_size = static_cast<size_t>(config->horizontal_resolution) * config->vertical_resolution * 2;
internal->shadow = static_cast<uint8_t*>(malloc(frame_buffer_size));
internal->staging = static_cast<uint8_t*>(malloc(frame_buffer_size));
if (internal->shadow == nullptr || internal->staging == nullptr) {
LOG_E(TAG, "Failed to allocate frame buffers");
free(internal->shadow);
free(internal->staging);
vSemaphoreDelete(internal->draw_done_semaphore);
free(internal);
return ERROR_OUT_OF_MEMORY;
}
std::memset(internal->shadow, 0, frame_buffer_size);
// Built manually rather than with SH8601_PANEL_IO_QSPI_CONFIG(): the component's macro assigns
// the plain int -1 to dc_gpio_num (gpio_num_t), which C++ rejects (unlike C).
esp_lcd_panel_io_spi_config_t io_config = {
.cs_gpio_num = static_cast<gpio_num_t>(pin_or_unused(cs_pin)),
.dc_gpio_num = static_cast<gpio_num_t>(-1),
.spi_mode = 0,
.pclk_hz = config->pixel_clock_hz,
.trans_queue_depth = config->transaction_queue_depth,
.on_color_trans_done = on_color_trans_done,
.user_ctx = internal,
.lcd_cmd_bits = 32,
.lcd_param_bits = 8,
.cs_ena_pretrans = 0,
.cs_ena_posttrans = 0,
.flags = {
.dc_high_on_cmd = 0,
.dc_low_on_data = 0,
.dc_low_on_param = 0,
.octal_mode = 0,
.quad_mode = 1,
.sio_mode = 0,
// The LVGL color buffer lives in PSRAM (see PREFER_EXTERNAL_RAM). Without this the SPI
// driver bounces every chunk through a freshly-allocated internal DMA buffer (up to the
// bus's 64KB max-transfer-size), which fails with "Failed to allocate priv TX buffer"
// once internal RAM is tight (e.g. during Wi-Fi). ESP32-S3 can DMA from PSRAM directly.
.psram_dma_direct = 1,
.lsb_first = 0,
.cs_high_active = 0,
},
};
esp_err_t ret = esp_lcd_new_panel_io_spi((esp_lcd_spi_bus_handle_t)spi_config->host, &io_config, &internal->io_handle);
if (ret != ESP_OK) {
LOG_E(TAG, "Failed to create panel IO: %s", esp_err_to_name(ret));
free(internal->shadow);
free(internal->staging);
vSemaphoreDelete(internal->draw_done_semaphore);
free(internal);
return ERROR_RESOURCE;
}
sh8601_vendor_config_t vendor_config = {
.init_cmds = SH8601_INIT_COMMANDS,
.init_cmds_size = sizeof(SH8601_INIT_COMMANDS) / sizeof(SH8601_INIT_COMMANDS[0]),
.flags = {
.use_qspi_interface = 1,
},
};
esp_lcd_panel_dev_config_t panel_config = {
.rgb_ele_order = LCD_RGB_ELEMENT_ORDER_RGB,
.data_endian = LCD_RGB_DATA_ENDIAN_LITTLE,
.bits_per_pixel = 16,
.reset_gpio_num = static_cast<gpio_num_t>(pin_or_unused(config->pin_reset)),
.vendor_config = &vendor_config,
// The SH8601's reset line is fixed active-low in hardware.
.flags = { .reset_active_high = false },
};
ret = esp_lcd_new_panel_sh8601(internal->io_handle, &panel_config, &internal->panel_handle);
if (ret != ESP_OK) {
LOG_E(TAG, "Failed to create panel: %s", esp_err_to_name(ret));
esp_lcd_panel_io_del(internal->io_handle);
free(internal->shadow);
free(internal->staging);
vSemaphoreDelete(internal->draw_done_semaphore);
free(internal);
return ERROR_RESOURCE;
}
spi_controller_lock(internal->spi_controller);
bool ok =
esp_lcd_panel_reset(internal->panel_handle) == ESP_OK &&
esp_lcd_panel_init(internal->panel_handle) == ESP_OK;
if (ok) {
ok = esp_lcd_panel_set_gap(internal->panel_handle, config->gap_x, config->gap_y) == ESP_OK;
}
ok = ok && (!config->invert_color || esp_lcd_panel_invert_color(internal->panel_handle, true) == ESP_OK);
ok = ok && esp_lcd_panel_disp_on_off(internal->panel_handle, true) == ESP_OK;
spi_controller_unlock(internal->spi_controller);
if (!ok) {
LOG_E(TAG, "Failed to bring up panel");
esp_lcd_panel_del(internal->panel_handle);
esp_lcd_panel_io_del(internal->io_handle);
free(internal->shadow);
free(internal->staging);
vSemaphoreDelete(internal->draw_done_semaphore);
free(internal);
return ERROR_RESOURCE;
}
device_set_driver_data(device, internal);
// Wipe the panel's undefined power-up GRAM content. Done in horizontal bands (rather than one
// full-frame draw) so the panel IO never queues several large PSRAM-bounce chunks at once.
constexpr int32_t CLEAR_BAND_HEIGHT = 32;
for (int32_t y = 0; y < config->vertical_resolution; y += CLEAR_BAND_HEIGHT) {
int32_t y_end = y + CLEAR_BAND_HEIGHT;
if (y_end > config->vertical_resolution) {
y_end = config->vertical_resolution;
}
if (present(internal, 0, y, config->horizontal_resolution, y_end, internal->shadow + static_cast<size_t>(y) * config->horizontal_resolution * 2) != ERROR_NONE) {
LOG_W(TAG, "Failed to clear panel");
break;
}
}
return ERROR_NONE;
}
static error_t stop(Device* device) {
auto* internal = static_cast<Sh8601Internal*>(device_get_driver_data(device));
spi_controller_lock(internal->spi_controller);
if (internal->panel_handle != nullptr) {
if (esp_lcd_panel_del(internal->panel_handle) != ESP_OK) {
LOG_E(TAG, "Failed to delete panel");
spi_controller_unlock(internal->spi_controller);
return ERROR_RESOURCE;
}
internal->panel_handle = nullptr;
}
if (internal->io_handle != nullptr) {
if (esp_lcd_panel_io_del(internal->io_handle) != ESP_OK) {
LOG_E(TAG, "Failed to delete panel IO");
spi_controller_unlock(internal->spi_controller);
return ERROR_RESOURCE;
}
internal->io_handle = nullptr;
}
spi_controller_unlock(internal->spi_controller);
vSemaphoreDelete(internal->draw_done_semaphore);
free(internal->shadow);
free(internal->staging);
free(internal);
device_set_driver_data(device, nullptr);
return ERROR_NONE;
}
// endregion
// region DisplayApi
static error_t sh8601_reset(Device* device) {
auto* internal = static_cast<Sh8601Internal*>(device_get_driver_data(device));
const auto* config = GET_CONFIG(device);
spi_controller_lock(internal->spi_controller);
error_t result = esp_lcd_panel_reset(internal->panel_handle) == ESP_OK ? ERROR_NONE : ERROR_RESOURCE;
spi_controller_unlock(internal->spi_controller);
if (result == ERROR_NONE) {
// The reset drops the panel's image, so the shadow no longer describes it.
std::memset(internal->shadow, 0, static_cast<size_t>(config->horizontal_resolution) * config->vertical_resolution * 2);
}
return result;
}
static error_t sh8601_init(Device* device) {
auto* internal = static_cast<Sh8601Internal*>(device_get_driver_data(device));
spi_controller_lock(internal->spi_controller);
error_t result = esp_lcd_panel_init(internal->panel_handle) == ESP_OK ? ERROR_NONE : ERROR_RESOURCE;
spi_controller_unlock(internal->spi_controller);
return result;
}
static error_t sh8601_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<Sh8601Internal*>(device_get_driver_data(device));
const auto* config = GET_CONFIG(device);
const int32_t hres = config->horizontal_resolution;
const int32_t vres = config->vertical_resolution;
if (x_start < 0) x_start = 0;
if (y_start < 0) y_start = 0;
if (x_end > hres) x_end = hres;
if (y_end > vres) y_end = vres;
if (x_start >= x_end || y_start >= y_end) {
return ERROR_NONE;
}
const size_t shadow_stride = static_cast<size_t>(hres) * 2;
// Keep the shadow in sync with the pixels LVGL just handed us. Row-by-row because the incoming
// region is tightly packed while the shadow spans the full screen.
const int32_t width = x_end - x_start;
const int32_t height = y_end - y_start;
const auto* source = static_cast<const uint8_t*>(color_data);
for (int32_t row = 0; row < height; row++) {
std::memcpy(
internal->shadow + static_cast<size_t>(y_start + row) * shadow_stride + static_cast<size_t>(x_start) * 2,
source + static_cast<size_t>(row) * width * 2,
static_cast<size_t>(width) * 2
);
}
// Expand to the even-aligned window the SH8601 requires: even start, even width/height.
int32_t aligned_x_start = x_start & ~1;
int32_t aligned_y_start = y_start & ~1;
int32_t aligned_x_end = (x_end + 1) & ~1;
int32_t aligned_y_end = (y_end + 1) & ~1;
if (aligned_x_end > hres) aligned_x_end = hres;
if (aligned_y_end > vres) aligned_y_end = vres;
const bool already_aligned =
aligned_x_start == x_start && aligned_y_start == y_start &&
aligned_x_end == x_end && aligned_y_end == y_end;
const uint8_t* send_buffer;
if (already_aligned) {
send_buffer = source;
} else {
const int32_t aligned_width = aligned_x_end - aligned_x_start;
uint8_t* destination = internal->staging;
for (int32_t row = aligned_y_start; row < aligned_y_end; row++) {
std::memcpy(
destination,
internal->shadow + static_cast<size_t>(row) * shadow_stride + static_cast<size_t>(aligned_x_start) * 2,
static_cast<size_t>(aligned_width) * 2
);
destination += static_cast<size_t>(aligned_width) * 2;
}
send_buffer = internal->staging;
}
return present(internal, aligned_x_start, aligned_y_start, aligned_x_end, aligned_y_end, send_buffer);
}
static error_t sh8601_mirror(Device*, bool, bool) {
return ERROR_NOT_SUPPORTED;
}
static error_t sh8601_swap_xy(Device*, bool) {
return ERROR_NOT_SUPPORTED;
}
static error_t sh8601_set_gap(Device* device, int32_t x_gap, int32_t y_gap) {
auto* internal = static_cast<Sh8601Internal*>(device_get_driver_data(device));
spi_controller_lock(internal->spi_controller);
error_t result = esp_lcd_panel_set_gap(internal->panel_handle, x_gap, y_gap) == ESP_OK ? ERROR_NONE : ERROR_RESOURCE;
spi_controller_unlock(internal->spi_controller);
return result;
}
static int32_t sh8601_get_gap_x(Device* device) {
return GET_CONFIG(device)->gap_x;
}
static int32_t sh8601_get_gap_y(Device* device) {
return GET_CONFIG(device)->gap_y;
}
static error_t sh8601_invert_color(Device* device, bool invert_color_data) {
auto* internal = static_cast<Sh8601Internal*>(device_get_driver_data(device));
spi_controller_lock(internal->spi_controller);
error_t result = esp_lcd_panel_invert_color(internal->panel_handle, invert_color_data) == ESP_OK ? ERROR_NONE : ERROR_RESOURCE;
spi_controller_unlock(internal->spi_controller);
return result;
}
static error_t sh8601_disp_on_off(Device* device, bool on_off) {
auto* internal = static_cast<Sh8601Internal*>(device_get_driver_data(device));
spi_controller_lock(internal->spi_controller);
error_t result = esp_lcd_panel_disp_on_off(internal->panel_handle, on_off) == ESP_OK ? ERROR_NONE : ERROR_RESOURCE;
spi_controller_unlock(internal->spi_controller);
return result;
}
// The panel is big-endian while the OS is little-endian, so every pixel's two bytes are swapped.
static enum DisplayColorFormat sh8601_get_color_format(Device*) {
return DISPLAY_COLOR_FORMAT_RGB565_SWAPPED;
}
static uint16_t sh8601_get_resolution_x(Device* device) {
return GET_CONFIG(device)->horizontal_resolution;
}
static uint16_t sh8601_get_resolution_y(Device* device) {
return GET_CONFIG(device)->vertical_resolution;
}
struct BacklightSearch {
Device* found;
};
static bool find_backlight_child(Device* child, void* context) {
auto* search = static_cast<BacklightSearch*>(context);
if (device_get_type(child) == &BACKLIGHT_TYPE) {
search->found = child;
return false;
}
return true;
}
// The backlight is a child node of the display (see the sh8601-backlight binding). A phandle
// between the two would be a cycle the devicetree compiler can't emit in either order.
static Device* find_backlight(Device* device) {
BacklightSearch search = { nullptr };
device_for_each_child(device, &search, find_backlight_child);
return search.found;
}
static error_t sh8601_get_backlight(Device* device, Device** backlight) {
auto* found = find_backlight(device);
if (found == nullptr) {
return ERROR_NOT_SUPPORTED;
}
*backlight = found;
return ERROR_NONE;
}
// BACKLIGHT availability is per-board (a board could wire a panel without using the panel's own
// brightness command), so it can't live in the driver-wide static capability mask.
static bool sh8601_has_capability(Device* device, uint32_t capability) {
uint32_t capabilities = SH8601_STATIC_CAPABILITIES;
if (find_backlight(device) != nullptr) {
capabilities |= DISPLAY_CAPABILITY_BACKLIGHT;
}
return (capabilities & capability) == capability;
}
static const DisplayApi sh8601_display_api = {
.capabilities = SH8601_STATIC_CAPABILITIES,
.reset = sh8601_reset,
.init = sh8601_init,
.draw_bitmap = sh8601_draw_bitmap,
.clear = nullptr,
.refresh = nullptr,
.mirror = sh8601_mirror,
.swap_xy = sh8601_swap_xy,
.get_swap_xy = nullptr,
.get_mirror_x = nullptr,
.get_mirror_y = nullptr,
.set_gap = sh8601_set_gap,
.get_gap_x = sh8601_get_gap_x,
.get_gap_y = sh8601_get_gap_y,
.invert_color = sh8601_invert_color,
.disp_on_off = sh8601_disp_on_off,
.disp_sleep = nullptr,
.get_color_format = sh8601_get_color_format,
.get_resolution_x = sh8601_get_resolution_x,
.get_resolution_y = sh8601_get_resolution_y,
// No direct frame buffer access: the panel streams from an LVGL-owned buffer.
.get_frame_buffer = nullptr,
.get_frame_buffer_count = nullptr,
.get_backlight = sh8601_get_backlight,
.has_capability = sh8601_has_capability,
};
Driver sh8601_driver = {
.name = "sh8601",
.compatible = (const char*[]) { "shenghe,sh8601", nullptr },
.start_device = start,
.stop_device = stop,
.api = &sh8601_display_api,
.device_type = &DISPLAY_TYPE,
.internal = nullptr
};
// endregion
// region Backlight
struct Sh8601BacklightInternal {
uint8_t brightness;
};
static error_t sh8601_backlight_set_brightness(Device* device, uint8_t brightness) {
// The backlight is a child of the display it dims.
auto* display = device_get_parent(device);
if (display == nullptr) {
return ERROR_INVALID_STATE;
}
auto* display_internal = static_cast<Sh8601Internal*>(device_get_driver_data(display));
if (display_internal == nullptr || display_internal->io_handle == nullptr) {
return ERROR_INVALID_STATE;
}
uint32_t lcd_cmd = SH8601_CMD_BRIGHTNESS;
lcd_cmd <<= 8;
lcd_cmd |= SH8601_QSPI_OPCODE_WRITE_CMD;
spi_controller_lock(display_internal->spi_controller);
esp_err_t ret = esp_lcd_panel_io_tx_param(display_internal->io_handle, lcd_cmd, &brightness, 1);
spi_controller_unlock(display_internal->spi_controller);
if (ret != ESP_OK) {
LOG_E(TAG, "Failed to set brightness: %s", esp_err_to_name(ret));
return ERROR_RESOURCE;
}
static_cast<Sh8601BacklightInternal*>(device_get_driver_data(device))->brightness = brightness;
return ERROR_NONE;
}
static error_t sh8601_backlight_set_brightness_default(Device* device) {
return sh8601_backlight_set_brightness(device, GET_BACKLIGHT_CONFIG(device)->brightness_default);
}
static error_t sh8601_backlight_get_brightness(Device* device, uint8_t* out_brightness) {
*out_brightness = static_cast<Sh8601BacklightInternal*>(device_get_driver_data(device))->brightness;
return ERROR_NONE;
}
static uint8_t sh8601_backlight_get_min_brightness(Device*) {
return 0;
}
static uint8_t sh8601_backlight_get_max_brightness(Device*) {
return 255;
}
static const BacklightApi SH8601_BACKLIGHT_API = {
.set_brightness = sh8601_backlight_set_brightness,
.set_brightness_default = sh8601_backlight_set_brightness_default,
.get_brightness = sh8601_backlight_get_brightness,
.get_min_brightness = sh8601_backlight_get_min_brightness,
.get_max_brightness = sh8601_backlight_get_max_brightness,
};
static error_t sh8601_backlight_start(Device* device) {
if (device_get_parent(device) == nullptr) {
return ERROR_INVALID_STATE;
}
auto* internal = new(std::nothrow) Sh8601BacklightInternal { .brightness = 0 };
if (internal == nullptr) {
return ERROR_OUT_OF_MEMORY;
}
device_set_driver_data(device, internal);
sh8601_backlight_set_brightness_default(device); // Allowed to fail, we don't care about the result
return ERROR_NONE;
}
static error_t sh8601_backlight_stop(Device* device) {
sh8601_backlight_set_brightness(device, 0); // Allowed to fail, we don't care about the result
auto* internal = static_cast<Sh8601BacklightInternal*>(device_get_driver_data(device));
device_set_driver_data(device, nullptr);
delete internal;
return ERROR_NONE;
}
Driver sh8601_backlight_driver = {
.name = "sh8601_backlight",
.compatible = (const char*[]) { "sh8601-backlight", nullptr },
.start_device = sh8601_backlight_start,
.stop_device = sh8601_backlight_stop,
.api = &SH8601_BACKLIGHT_API,
.device_type = &BACKLIGHT_TYPE,
.internal = nullptr
};
// endregion

View File

@ -29,7 +29,6 @@ struct DisplaySettings {
uint32_t backlightTimeoutMs; // 0 = Never uint32_t backlightTimeoutMs; // 0 = Never
ScreensaverType screensaverType = ScreensaverType::BouncingBalls; ScreensaverType screensaverType = ScreensaverType::BouncingBalls;
bool autoRotateEnabled = false; bool autoRotateEnabled = false;
Orientation autoRotateMountRotation = Orientation::Landscape; // IMU-to-panel mount offset
}; };
/** Compares default settings with the function parameter to return the difference */ /** Compares default settings with the function parameter to return the difference */

View File

@ -92,4 +92,12 @@ menu "Tactility App"
bool "Set true when a touch screen calibration is required before the device is usable" bool "Set true when a touch screen calibration is required before the device is usable"
default n default n
depends on TT_TOUCH_CALIBRATION_SUPPORTED depends on TT_TOUCH_CALIBRATION_SUPPORTED
config TT_DISPLAY_IDLE_TICK_MS
int "Display idle tick interval (ms)"
default 50
range 10 200
help
How often the display-idle service wakes to drive the screensaver animation and poll the
backlight timeout. Lower it on boards with a fast panel so the screensaver animates at
the panel's refresh rate; higher values reduce wakeups and save power.
endmenu endmenu

View File

@ -20,13 +20,11 @@ public:
void reset(lv_display_rotation_t rotation); void reset(lv_display_rotation_t rotation);
/** /**
* @param ax, ay gravity-plane components from the IMU, in g * @param ax, ay gravity-plane components from the IMU, in g (already in the device frame)
* @param mountQuarterTurns how the IMU is mounted relative to the panel's upright direction,
* as a count of extra 90-degree steps (0-3)
* @return the debounced current rotation (unchanged until a new reading is stable for * @return the debounced current rotation (unchanged until a new reading is stable for
* several consecutive updates) * several consecutive updates)
*/ */
lv_display_rotation_t update(float ax, float ay, int mountQuarterTurns); lv_display_rotation_t update(float ax, float ay);
}; };
} // namespace tt::service::autorotate } // namespace tt::service::autorotate

View File

@ -5,6 +5,8 @@
#include <Tactility/settings/DisplaySettings.h> #include <Tactility/settings/DisplaySettings.h>
#include <Tactility/Timer.h> #include <Tactility/Timer.h>
#include <sdkconfig.h>
#include <atomic> #include <atomic>
#include <memory> #include <memory>
@ -29,8 +31,14 @@ class DisplayIdleService final : public Service {
// Active screensaver instance // Active screensaver instance
std::unique_ptr<Screensaver> screensaver; std::unique_ptr<Screensaver> screensaver;
// Screensaver auto-off: turn off backlight after 5 minutes of screensaver // Drives the screensaver animation (each tick moves the balls) and polls the backlight timeout.
// Boards with a fast panel can lower it via CONFIG_TT_DISPLAY_IDLE_TICK_MS so the animation
// runs at the panel's refresh rate; the 50 ms fallback matches the generic default.
#if defined(CONFIG_TT_DISPLAY_IDLE_TICK_MS) && CONFIG_TT_DISPLAY_IDLE_TICK_MS > 0
static constexpr uint32_t TICK_INTERVAL_MS = CONFIG_TT_DISPLAY_IDLE_TICK_MS;
#else
static constexpr uint32_t TICK_INTERVAL_MS = 50; static constexpr uint32_t TICK_INTERVAL_MS = 50;
#endif
static constexpr uint32_t SCREENSAVER_AUTO_OFF_MS = 5 * 60 * 1000; // 5 minutes static constexpr uint32_t SCREENSAVER_AUTO_OFF_MS = 5 * 60 * 1000; // 5 minutes
static constexpr int SCREENSAVER_AUTO_OFF_TICKS = SCREENSAVER_AUTO_OFF_MS / TICK_INTERVAL_MS; static constexpr int SCREENSAVER_AUTO_OFF_TICKS = SCREENSAVER_AUTO_OFF_MS / TICK_INTERVAL_MS;
int screensaverActiveCounter = 0; int screensaverActiveCounter = 0;

View File

@ -87,8 +87,6 @@ struct Context {
lv_obj_t* orientationGroup = nullptr; lv_obj_t* orientationGroup = nullptr;
lv_obj_t* orientationChips = nullptr; lv_obj_t* orientationChips = nullptr;
lv_obj_t* autoRotateSwitch = nullptr; lv_obj_t* autoRotateSwitch = nullptr;
// Shown when auto-rotate is on: the sensor mounting label and chips
lv_obj_t* mountRotationGroup = nullptr;
}; };
@ -153,13 +151,10 @@ void setCheckedChip(lv_obj_t* chips, settings::display::Orientation orientation)
}); });
} }
/** Shows the manual orientation or the sensor mounting, depending on whether auto-rotate is on */ /** Shows or hides the manual orientation chips, depending on whether auto-rotate is on */
void updateOrientationVisibility(Context* ctx) { void updateOrientationVisibility(Context* ctx) {
const bool auto_rotate = ctx->autoRotateSwitch != nullptr && ctx->displaySettings.autoRotateEnabled; const bool auto_rotate = ctx->autoRotateSwitch != nullptr && ctx->displaySettings.autoRotateEnabled;
lv_obj_set_hidden(ctx->orientationGroup, auto_rotate); lv_obj_set_hidden(ctx->orientationGroup, auto_rotate);
if (ctx->mountRotationGroup != nullptr) {
lv_obj_set_hidden(ctx->mountRotationGroup, !auto_rotate);
}
} }
void onOrientationChipPressed(lv_event_t* event) { void onOrientationChipPressed(lv_event_t* event) {
@ -185,15 +180,6 @@ void onAutoRotateSwitch(lv_event_t* event) {
updateOrientationVisibility(ctx); updateOrientationVisibility(ctx);
} }
void onMountRotationChipPressed(lv_event_t* event) {
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
auto* chip = lv_event_get_target_obj(event);
const auto orientation = static_cast<settings::display::Orientation>(lv_obj_get_index(chip));
ctx->displaySettings.autoRotateMountRotation = orientation;
ctx->displaySettingsUpdated = true;
setCheckedChip(lv_obj_get_parent(chip), orientation);
}
/** Creates a transparent column in a card with a title, which centers the title and the content below it */ /** Creates a transparent column in a card with a title, which centers the title and the content below it */
lv_obj_t* createCardGroup(lv_obj_t* card, const char* title) { lv_obj_t* createCardGroup(lv_obj_t* card, const char* title) {
auto* group = lv_obj_create(card); auto* group = lv_obj_create(card);
@ -409,7 +395,7 @@ void createWidgets(lv_obj_t* parent, void* userData) {
lv_obj_add_event_cb(ctx->autoRotateSwitch, onAutoRotateSwitch, LV_EVENT_VALUE_CHANGED, ctx); lv_obj_add_event_cb(ctx->autoRotateSwitch, onAutoRotateSwitch, LV_EVENT_VALUE_CHANGED, ctx);
} }
// The card shows the manual orientation, or the sensor mounting when auto-rotate is on // The card shows the manual orientation; auto-rotate hides it while it drives the rotation
auto* orientation_card = lvgl_card_create(main_wrapper); auto* orientation_card = lvgl_card_create(main_wrapper);
lv_obj_set_size(orientation_card, LV_PCT(100), LV_SIZE_CONTENT); lv_obj_set_size(orientation_card, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_flex_flow(orientation_card, LV_FLEX_FLOW_COLUMN); lv_obj_set_flex_flow(orientation_card, LV_FLEX_FLOW_COLUMN);
@ -417,12 +403,6 @@ void createWidgets(lv_obj_t* parent, void* userData) {
ctx->orientationGroup = createCardGroup(orientation_card, "Orientation"); ctx->orientationGroup = createCardGroup(orientation_card, "Orientation");
ctx->orientationChips = createOrientationChips(ctx->orientationGroup, ctx->displaySettings.orientation, onOrientationChipPressed, ctx); ctx->orientationChips = createOrientationChips(ctx->orientationGroup, ctx->displaySettings.orientation, onOrientationChipPressed, ctx);
// Auto-rotate needs to know how the sensor is mounted relative to the display
if (has_imu) {
ctx->mountRotationGroup = createCardGroup(orientation_card, "Sensor mounting");
createOrientationChips(ctx->mountRotationGroup, ctx->displaySettings.autoRotateMountRotation, onMountRotationChipPressed, ctx);
}
updateOrientationVisibility(ctx); updateOrientationVisibility(ctx);
// Screen timeout // Screen timeout
@ -470,7 +450,6 @@ void destroyWidgets(void* userData) {
ctx->orientationGroup = nullptr; ctx->orientationGroup = nullptr;
ctx->orientationChips = nullptr; ctx->orientationChips = nullptr;
ctx->autoRotateSwitch = nullptr; ctx->autoRotateSwitch = nullptr;
ctx->mountRotationGroup = nullptr;
} }
// Mirrors the old onHide() behaviour: persist the settings (regardless of whether the app is // Mirrors the old onHide() behaviour: persist the settings (regardless of whether the app is

View File

@ -44,8 +44,7 @@ void AutoRotateService::tick() {
return; // Retry on next tick return; // Retry on next tick
} }
int mount_quarter_turns = static_cast<int>(cachedDisplaySettings.autoRotateMountRotation); lv_display_rotation_t rotation = orientationMath.update(accel.ax, accel.ay);
lv_display_rotation_t rotation = orientationMath.update(accel.ax, accel.ay, mount_quarter_turns);
Orientation orientation = settings::display::fromLvglDisplayRotation(rotation); Orientation orientation = settings::display::fromLvglDisplayRotation(rotation);
if (orientation == cachedDisplaySettings.orientation) { if (orientation == cachedDisplaySettings.orientation) {
lvgl_unlock(); lvgl_unlock();

View File

@ -8,7 +8,7 @@ constexpr float FLAT_THRESHOLD = 0.45F; // gravity in the screen plane below thi
constexpr int DEBOUNCE = 5; // consecutive readings before switching constexpr int DEBOUNCE = 5; // consecutive readings before switching
// gx toward +Y -> rotation 0; each +90 degrees of gx/gy angle is one more quarter-turn. // gx toward +Y -> rotation 0; each +90 degrees of gx/gy angle is one more quarter-turn.
static lv_display_rotation_t gravityToRotation(float gx, float gy, int mountQuarterTurns, lv_display_rotation_t fallback) { static lv_display_rotation_t gravityToRotation(float gx, float gy, lv_display_rotation_t fallback) {
float plane = sqrtf((gx * gx) + (gy * gy)); float plane = sqrtf((gx * gx) + (gy * gy));
if (plane < FLAT_THRESHOLD) { if (plane < FLAT_THRESHOLD) {
return fallback; // lying flat: keep the current one return fallback; // lying flat: keep the current one
@ -19,7 +19,7 @@ static lv_display_rotation_t gravityToRotation(float gx, float gy, int mountQuar
ang += 360.0F; ang += 360.0F;
} }
int idx = ((int)((ang + 45.0F) / 90.0F) + mountQuarterTurns) % 4; int idx = (int)((ang + 45.0F) / 90.0F) % 4;
static constexpr lv_display_rotation_t map[4] = { static constexpr lv_display_rotation_t map[4] = {
LV_DISPLAY_ROTATION_0, LV_DISPLAY_ROTATION_0,
LV_DISPLAY_ROTATION_90, LV_DISPLAY_ROTATION_90,
@ -35,8 +35,8 @@ void OrientationMath::reset(lv_display_rotation_t rotation) {
stableCount = 0; stableCount = 0;
} }
lv_display_rotation_t OrientationMath::update(float ax, float ay, int mountQuarterTurns) { lv_display_rotation_t OrientationMath::update(float ax, float ay) {
lv_display_rotation_t next = gravityToRotation(ax, ay, mountQuarterTurns, current); lv_display_rotation_t next = gravityToRotation(ax, ay, current);
if (next == current) { if (next == current) {
stableCount = 0; stableCount = 0;
return current; return current;

View File

@ -29,7 +29,6 @@ constexpr auto* SETTINGS_KEY_TIMEOUT_ENABLED = "backlightTimeoutEnabled";
constexpr auto* SETTINGS_KEY_TIMEOUT_MS = "backlightTimeoutMs"; constexpr auto* SETTINGS_KEY_TIMEOUT_MS = "backlightTimeoutMs";
constexpr auto* SETTINGS_KEY_SCREENSAVER_TYPE = "screensaverType"; constexpr auto* SETTINGS_KEY_SCREENSAVER_TYPE = "screensaverType";
constexpr auto* SETTINGS_KEY_AUTO_ROTATE_ENABLED = "autoRotateEnabled"; constexpr auto* SETTINGS_KEY_AUTO_ROTATE_ENABLED = "autoRotateEnabled";
constexpr auto* SETTINGS_KEY_AUTO_ROTATE_MOUNT_ROTATION = "autoRotateMountRotation";
// Uses the kernel display, so it also works while LVGL isn't running // Uses the kernel display, so it also works while LVGL isn't running
static bool isOriginallyLandscape() { static bool isOriginallyLandscape() {
@ -175,12 +174,6 @@ bool load(DisplaySettings& settings) {
auto_rotate_enabled = (auto_rotate_enabled_entry->second == "1" || auto_rotate_enabled_entry->second == "true" || auto_rotate_enabled_entry->second == "True"); auto_rotate_enabled = (auto_rotate_enabled_entry->second == "1" || auto_rotate_enabled_entry->second == "true" || auto_rotate_enabled_entry->second == "True");
} }
auto mount_rotation_entry = map.find(SETTINGS_KEY_AUTO_ROTATE_MOUNT_ROTATION);
Orientation mount_rotation = Orientation::Landscape;
if (mount_rotation_entry != map.end()) {
fromString(mount_rotation_entry->second, mount_rotation);
}
settings.orientation = orientation; settings.orientation = orientation;
settings.gammaCurve = gamma_curve; settings.gammaCurve = gamma_curve;
settings.backlightDuty = backlight_duty; settings.backlightDuty = backlight_duty;
@ -188,7 +181,6 @@ bool load(DisplaySettings& settings) {
settings.backlightTimeoutMs = timeout_ms; settings.backlightTimeoutMs = timeout_ms;
settings.screensaverType = screensaver_type; settings.screensaverType = screensaver_type;
settings.autoRotateEnabled = auto_rotate_enabled; settings.autoRotateEnabled = auto_rotate_enabled;
settings.autoRotateMountRotation = mount_rotation;
return true; return true;
} }
@ -201,8 +193,7 @@ DisplaySettings getDefault() {
.backlightTimeoutEnabled = false, .backlightTimeoutEnabled = false,
.backlightTimeoutMs = 60000, .backlightTimeoutMs = 60000,
.screensaverType = ScreensaverType::BouncingBalls, .screensaverType = ScreensaverType::BouncingBalls,
.autoRotateEnabled = false, .autoRotateEnabled = false
.autoRotateMountRotation = Orientation::Landscape
}; };
} }
@ -223,7 +214,6 @@ bool save(const DisplaySettings& settings) {
map[SETTINGS_KEY_TIMEOUT_MS] = std::to_string(settings.backlightTimeoutMs); map[SETTINGS_KEY_TIMEOUT_MS] = std::to_string(settings.backlightTimeoutMs);
map[SETTINGS_KEY_SCREENSAVER_TYPE] = toString(settings.screensaverType); map[SETTINGS_KEY_SCREENSAVER_TYPE] = toString(settings.screensaverType);
map[SETTINGS_KEY_AUTO_ROTATE_ENABLED] = settings.autoRotateEnabled ? "1" : "0"; map[SETTINGS_KEY_AUTO_ROTATE_ENABLED] = settings.autoRotateEnabled ? "1" : "0";
map[SETTINGS_KEY_AUTO_ROTATE_MOUNT_ROTATION] = toString(settings.autoRotateMountRotation);
auto settings_path = getSettingsFilePath(); auto settings_path = getSettingsFilePath();
if (!file::findOrCreateParentDirectory(settings_path, 0755)) { if (!file::findOrCreateParentDirectory(settings_path, 0755)) {
return false; return false;

View File

@ -6,14 +6,14 @@ using tt::service::autorotate::OrientationMath;
TEST_CASE("a flat reading below the threshold keeps the current rotation") { TEST_CASE("a flat reading below the threshold keeps the current rotation") {
OrientationMath math; OrientationMath math;
math.reset(LV_DISPLAY_ROTATION_0); math.reset(LV_DISPLAY_ROTATION_0);
CHECK_EQ(math.update(0.1F, 0.1F, 0), LV_DISPLAY_ROTATION_0); CHECK_EQ(math.update(0.1F, 0.1F), LV_DISPLAY_ROTATION_0);
} }
TEST_CASE("gravity toward +X classifies as rotation 90 when no debounce is needed yet") { TEST_CASE("gravity toward +X classifies as rotation 90 when no debounce is needed yet") {
OrientationMath math; OrientationMath math;
math.reset(LV_DISPLAY_ROTATION_0); math.reset(LV_DISPLAY_ROTATION_0);
// A single reading only sets the debounce target - the previous rotation is still returned. // A single reading only sets the debounce target - the previous rotation is still returned.
CHECK_EQ(math.update(1.0F, 0.0F, 0), LV_DISPLAY_ROTATION_0); CHECK_EQ(math.update(1.0F, 0.0F), LV_DISPLAY_ROTATION_0);
} }
TEST_CASE("a new rotation is only applied after enough consecutive stable readings") { TEST_CASE("a new rotation is only applied after enough consecutive stable readings") {
@ -23,10 +23,10 @@ TEST_CASE("a new rotation is only applied after enough consecutive stable readin
// The 1st call only sets the debounce target; 5 more consecutive agreeing calls are then needed. // The 1st call only sets the debounce target; 5 more consecutive agreeing calls are then needed.
lv_display_rotation_t last = LV_DISPLAY_ROTATION_0; lv_display_rotation_t last = LV_DISPLAY_ROTATION_0;
for (int i = 0; i < 5; i++) { for (int i = 0; i < 5; i++) {
last = math.update(1.0F, 0.0F, 0); last = math.update(1.0F, 0.0F);
CHECK_EQ(last, LV_DISPLAY_ROTATION_0); // not yet debounced CHECK_EQ(last, LV_DISPLAY_ROTATION_0); // not yet debounced
} }
last = math.update(1.0F, 0.0F, 0); last = math.update(1.0F, 0.0F);
CHECK_EQ(last, LV_DISPLAY_ROTATION_90); // 6th call (5th consecutive stable reading) switches CHECK_EQ(last, LV_DISPLAY_ROTATION_90); // 6th call (5th consecutive stable reading) switches
} }
@ -37,7 +37,7 @@ TEST_CASE("all four gravity directions map to the expected rotation") {
auto settle = [&](float gx, float gy) { auto settle = [&](float gx, float gy) {
lv_display_rotation_t result = LV_DISPLAY_ROTATION_0; lv_display_rotation_t result = LV_DISPLAY_ROTATION_0;
for (int i = 0; i < 6; i++) { for (int i = 0; i < 6; i++) {
result = math.update(gx, gy, 0); result = math.update(gx, gy);
} }
return result; return result;
}; };
@ -48,35 +48,23 @@ TEST_CASE("all four gravity directions map to the expected rotation") {
CHECK_EQ(settle(-1.0F, 0.0F), LV_DISPLAY_ROTATION_270); CHECK_EQ(settle(-1.0F, 0.0F), LV_DISPLAY_ROTATION_270);
} }
TEST_CASE("mount rotation offsets which rotation a gravity direction maps to") {
OrientationMath math;
math.reset(LV_DISPLAY_ROTATION_0);
lv_display_rotation_t result = LV_DISPLAY_ROTATION_0;
for (int i = 0; i < 6; i++) {
// Gravity toward +X normally means rotation 90, but a 1-quarter-turn mount offset shifts it by one more step.
result = math.update(1.0F, 0.0F, 1);
}
CHECK_EQ(result, LV_DISPLAY_ROTATION_180);
}
TEST_CASE("an unstable target resets the debounce counter") { TEST_CASE("an unstable target resets the debounce counter") {
OrientationMath math; OrientationMath math;
math.reset(LV_DISPLAY_ROTATION_0); math.reset(LV_DISPLAY_ROTATION_0);
// Get close to switching to rotation 90... // Get close to switching to rotation 90...
for (int i = 0; i < 4; i++) { for (int i = 0; i < 4; i++) {
math.update(1.0F, 0.0F, 0); math.update(1.0F, 0.0F);
} }
// ...then a different target interrupts the debounce run. // ...then a different target interrupts the debounce run.
lv_display_rotation_t result = math.update(0.0F, -1.0F, 0); lv_display_rotation_t result = math.update(0.0F, -1.0F);
CHECK_EQ(result, LV_DISPLAY_ROTATION_0); CHECK_EQ(result, LV_DISPLAY_ROTATION_0);
// Confirms the rotation-180 debounce restarted from zero rather than continuing. // Confirms the rotation-180 debounce restarted from zero rather than continuing.
for (int i = 0; i < 4; i++) { for (int i = 0; i < 4; i++) {
result = math.update(0.0F, -1.0F, 0); result = math.update(0.0F, -1.0F);
CHECK_EQ(result, LV_DISPLAY_ROTATION_0); CHECK_EQ(result, LV_DISPLAY_ROTATION_0);
} }
result = math.update(0.0F, -1.0F, 0); result = math.update(0.0F, -1.0F);
CHECK_EQ(result, LV_DISPLAY_ROTATION_180); CHECK_EQ(result, LV_DISPLAY_ROTATION_180);
} }

View File

@ -50,6 +50,10 @@ dependencies:
rules: rules:
- if: "target in [esp32, esp32s3]" - if: "target in [esp32, esp32s3]"
espressif/esp_lcd_gc9a01: "2.0.4" espressif/esp_lcd_gc9a01: "2.0.4"
espressif/esp_lcd_sh8601:
version: "2.0.1~1"
rules:
- if: "target in [esp32s3]"
espressif/esp_lcd_jd9165: espressif/esp_lcd_jd9165:
version: "2.0.2" version: "2.0.2"
rules: rules: