Screensavers (#462)

Adds screensavers in addition to the backlight idle off. 
More info in screensavers.md

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

* **New Features**
  * Added a screensaver system with multiple styles (Bouncing Balls, Mystify, Matrix Rain) and a common screensaver interface; auto-starts after inactivity, dismisses on interaction, and supports auto-off/backlight behavior.
  * New Display setting and UI dropdown to choose and persist the screensaver.

* **Documentation**
  * Added comprehensive screensaver docs covering usage, extension, and configuration.

* **Chores**
  * Registered the display-idle service.

* **Bug Fixes**
  * Updated LVGL API calls to match renamed functions.

<sub>✏️ Tip: You can customize this high-level summary in your review settings.</sub>
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Shadowtrance 2026-01-28 02:21:16 +10:00 committed by GitHub
parent e6abd496f9
commit c05d46a28c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 1474 additions and 68 deletions

View File

@ -107,7 +107,7 @@ static void read_cb(lv_indev_t* indev, lv_indev_data_t* data) {
data->enc_diff = static_cast<int16_t>(clamp(diff, INT16_MIN, INT16_MAX));
if (diff != 0) {
lv_disp_trig_activity(nullptr);
lv_display_trigger_activity(nullptr);
}
} else {
// Pointer mode: read and clamp cursor position
@ -127,7 +127,7 @@ static void read_cb(lv_indev_t* indev, lv_indev_data_t* data) {
data->state = pressed ? LV_INDEV_STATE_PRESSED : LV_INDEV_STATE_RELEASED;
if (pressed) {
lv_disp_trig_activity(nullptr);
lv_display_trigger_activity(nullptr);
}
}

View File

@ -52,7 +52,7 @@ static void keyboard_read_callback(lv_indev_t* indev, lv_indev_data_t* data) {
data->state = LV_INDEV_STATE_PRESSED;
// TODO: Avoid performance hit by calling loadOrGetDefault() on each key press
// Ensure LVGL activity is triggered so idle services can wake the display
lv_disp_trig_activity(nullptr);
lv_display_trigger_activity(nullptr);
// Actively wake display/backlights immediately on key press (independent of idle tick)
// Restore display backlight if off (we assume duty 0 means dimmed)

View File

@ -0,0 +1,110 @@
# DisplayIdle Service
The DisplayIdle service manages screen timeout, screensavers, and backlight control for Tactility devices.
## Features
### Screen Timeout
When enabled, the display will automatically dim after a configurable period of inactivity. Timeout options:
- 15 seconds
- 30 seconds
- 1 minute
- 2 minutes
- 5 minutes
- Never
### Screensavers
Four screensaver options are available:
| Type | Description |
|------|-------------|
| **None** | Black screen only, backlight turns off immediately |
| **Bouncing Balls** | Colored balls bouncing around the screen |
| **Mystify** | Classic Windows-style polygon trails with color-changing effects |
| **Matrix Rain** | Digital rain effect with terminal-style grid movement, 6-color gradient trails, glow effects, and random character flicker |
### Auto-Off Feature
After 5 minutes of screensaver activity, the screensaver animation stops and the backlight turns off completely to save power. Touching the screen restores normal operation.
## Public API
The service exposes a public header for external control:
```cpp
#include <Tactility/service/displayidle/DisplayIdleService.h>
// Get service instance
auto displayIdle = tt::service::displayidle::findService();
// Force start screensaver immediately
displayIdle->startScreensaver();
// Force stop screensaver and restore backlight
displayIdle->stopScreensaver();
// Check if screensaver is currently active
bool active = displayIdle->isScreensaverActive();
// Reload settings (call after external settings changes)
displayIdle->reloadSettings();
```
## Architecture
### Files
| File | Purpose |
|------|---------|
| `DisplayIdleService.h` | Public header with service interface |
| `DisplayIdle.cpp` | Service implementation |
| `Screensaver.h` | Base class for screensaver implementations |
| `BouncingBallsScreensaver.h/cpp` | Bouncing balls screensaver |
| `MystifyScreensaver.h/cpp` | Mystify polygon screensaver |
| `MatrixRainScreensaver.h/cpp` | Matrix digital rain screensaver |
### Screensaver Base Class
All screensavers inherit from the `Screensaver` base class:
```cpp
class Screensaver {
public:
virtual void start(lv_obj_t* overlay, lv_coord_t screenW, lv_coord_t screenH) = 0;
virtual void stop() = 0;
virtual void update(lv_coord_t screenW, lv_coord_t screenH) = 0;
};
```
### Adding a New Screensaver
1. Create header and implementation files inheriting from `Screensaver`
2. Add enum value to `ScreensaverType` in `DisplaySettings.h` (before `Count` sentinel)
3. Add string conversion in `DisplaySettings.cpp` (`toString` and `fromString`)
4. Add dropdown option in `Display.cpp` (order must match enum order)
5. Add case in `DisplayIdle.cpp` `activateScreensaver()` switch
6. Include the new header in `DisplayIdle.cpp`
**Note:** The `ScreensaverType::Count` sentinel must always be the last enum value - it's used for bounds checking in the UI.
## Settings Integration
Settings are stored in `/data/settings/display.properties` and managed through `DisplaySettings.h`:
```cpp
struct DisplaySettings {
Orientation orientation;
uint8_t gammaCurve;
uint8_t backlightDuty;
bool backlightTimeoutEnabled;
uint32_t backlightTimeoutMs;
ScreensaverType screensaverType;
};
```
The Display app (`Display.cpp`) provides the UI for configuring these settings and notifies the DisplayIdle service when settings change via `reloadSettings()`.
## Timing
- Service tick interval: 50ms
- Wake activity threshold: 100ms
- Screensaver auto-off: 5 minutes (6000 ticks)

View File

@ -12,12 +12,21 @@ enum class Orientation {
PortraitFlipped,
};
enum class ScreensaverType {
None, // Just black screen
BouncingBalls,
Mystify,
MatrixRain,
Count // Sentinel for bounds checking - must be last
};
struct DisplaySettings {
Orientation orientation;
uint8_t gammaCurve;
uint8_t backlightDuty;
bool backlightTimeoutEnabled;
uint32_t backlightTimeoutMs; // 0 = Never
ScreensaverType screensaverType = ScreensaverType::BouncingBalls;
};
/** Compares default settings with the function parameter to return the difference */

View File

@ -0,0 +1,90 @@
#pragma once
#ifdef ESP_PLATFORM
#include <Tactility/service/Service.h>
#include <Tactility/settings/DisplaySettings.h>
#include <Tactility/Timer.h>
#include <atomic>
#include <memory>
// Forward declarations
typedef struct _lv_obj_t lv_obj_t;
typedef struct _lv_event_t lv_event_t;
namespace tt::service::displayidle {
class Screensaver;
class DisplayIdleService final : public Service {
std::unique_ptr<Timer> timer;
bool displayDimmed = false;
settings::display::DisplaySettings cachedDisplaySettings;
lv_obj_t* screensaverOverlay = nullptr;
std::atomic<bool> stopScreensaverRequested{false};
std::atomic<bool> settingsReloadRequested{false};
// Active screensaver instance
std::unique_ptr<Screensaver> screensaver;
// Screensaver auto-off: turn off backlight after 5 minutes of screensaver
static constexpr uint32_t TICK_INTERVAL_MS = 50;
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;
int screensaverActiveCounter = 0;
bool backlightOff = false;
static void stopScreensaverCb(lv_event_t* e);
/** @pre Caller must hold LVGL lock */
void activateScreensaver();
/** @pre Caller must hold LVGL lock */
void updateScreensaver();
void tick();
public:
bool onStart(ServiceContext& service) override;
void onStop(ServiceContext& service) override;
/**
* Force the screensaver to start immediately, regardless of idle timeout.
* @note Not thread-safe. Call from LVGL/main context only, not from
* arbitrary threads while the timer is running.
*/
void startScreensaver();
/**
* Force the screensaver to stop immediately and restore backlight.
* @note Not thread-safe. Call from LVGL/main context only, not from
* arbitrary threads while the timer is running.
*/
void stopScreensaver();
/**
* Check if the screensaver is currently active.
* @return true if the screensaver overlay is visible
* @note Not thread-safe. Call from timer thread or LVGL context only.
*/
bool isScreensaverActive() const;
/**
* Request reload of display settings from storage.
* Thread-safe: can be called from any thread. Actual reload
* happens on the next timer tick.
*/
void reloadSettings();
};
/**
* Find the DisplayIdle service instance.
* @return shared pointer to the service, or nullptr if not found
*/
std::shared_ptr<DisplayIdleService> findService();
} // namespace tt::service::displayidle
#endif // ESP_PLATFORM

View File

@ -53,7 +53,9 @@ namespace service {
namespace loader { extern const ServiceManifest manifest; }
namespace memorychecker { extern const ServiceManifest manifest; }
namespace statusbar { extern const ServiceManifest manifest; }
#ifdef ESP_PLATFORM
namespace displayidle { extern const ServiceManifest manifest; }
#endif
#if defined(ESP_PLATFORM) && defined(CONFIG_TT_DEVICE_LILYGO_TDECK)
namespace keyboardidle { extern const ServiceManifest manifest; }
#endif
@ -251,7 +253,9 @@ static void registerAndStartSecondaryServices() {
addService(service::loader::manifest);
addService(service::gui::manifest);
addService(service::statusbar::manifest);
#ifdef ESP_PLATFORM
addService(service::displayidle::manifest);
#endif
#if defined(ESP_PLATFORM) && defined(CONFIG_TT_DEVICE_LILYGO_TDECK)
addService(service::keyboardidle::manifest);
#endif

View File

@ -1,5 +1,8 @@
#include <Tactility/Tactility.h>
#ifdef ESP_PLATFORM
#include <Tactility/service/displayidle/DisplayIdleService.h>
#endif
#include <Tactility/settings/DisplaySettings.h>
#include <Tactility/Assets.h>
#include <Tactility/hal/display/DisplayDevice.h>
@ -22,6 +25,7 @@ class DisplayApp final : public App {
bool displaySettingsUpdated = false;
lv_obj_t* timeoutSwitch = nullptr;
lv_obj_t* timeoutDropdown = nullptr;
lv_obj_t* screensaverDropdown = nullptr;
static void onBacklightSliderEvent(lv_event_t* event) {
auto* slider = static_cast<lv_obj_t*>(lv_event_get_target(event));
@ -73,8 +77,14 @@ class DisplayApp final : public App {
if (app->timeoutDropdown) {
if (enabled) {
lv_obj_clear_state(app->timeoutDropdown, LV_STATE_DISABLED);
if (app->screensaverDropdown) {
lv_obj_clear_state(app->screensaverDropdown, LV_STATE_DISABLED);
}
} else {
lv_obj_add_state(app->timeoutDropdown, LV_STATE_DISABLED);
if (app->screensaverDropdown) {
lv_obj_add_state(app->screensaverDropdown, LV_STATE_DISABLED);
}
}
}
}
@ -91,6 +101,21 @@ class DisplayApp final : public App {
}
}
static void onScreensaverChanged(lv_event_t* event) {
auto* app = static_cast<DisplayApp*>(lv_event_get_user_data(event));
auto* dropdown = static_cast<lv_obj_t*>(lv_event_get_target(event));
uint32_t idx = lv_dropdown_get_selected(dropdown);
// Validate index bounds before casting to enum
if (idx >= static_cast<uint32_t>(settings::display::ScreensaverType::Count)) {
return;
}
auto selected_type = static_cast<settings::display::ScreensaverType>(idx);
if (selected_type != app->displaySettings.screensaverType) {
app->displaySettings.screensaverType = selected_type;
app->displaySettingsUpdated = true;
}
}
public:
void onShow(AppContext& app, lv_obj_t* parent) override {
@ -233,6 +258,28 @@ public:
if (!displaySettings.backlightTimeoutEnabled) {
lv_obj_add_state(timeoutDropdown, LV_STATE_DISABLED);
}
// Screensaver type
auto* screensaver_wrapper = lv_obj_create(main_wrapper);
lv_obj_set_size(screensaver_wrapper, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_style_pad_all(screensaver_wrapper, 0, LV_STATE_DEFAULT);
lv_obj_set_style_border_width(screensaver_wrapper, 0, LV_STATE_DEFAULT);
auto* screensaver_label = lv_label_create(screensaver_wrapper);
lv_label_set_text(screensaver_label, "Screensaver");
lv_obj_align(screensaver_label, LV_ALIGN_LEFT_MID, 0, 0);
screensaverDropdown = lv_dropdown_create(screensaver_wrapper);
// Note: order correlates with settings::display::ScreensaverType enum order
lv_dropdown_set_options(screensaverDropdown, "None\nBouncing Balls\nMystify\nMatrix Rain");
lv_obj_align(screensaverDropdown, LV_ALIGN_RIGHT_MID, 0, 0);
lv_obj_set_style_border_color(screensaverDropdown, lv_color_hex(0xFAFAFA), LV_PART_MAIN);
lv_obj_set_style_border_width(screensaverDropdown, 1, LV_PART_MAIN);
lv_obj_add_event_cb(screensaverDropdown, onScreensaverChanged, LV_EVENT_VALUE_CHANGED, this);
lv_dropdown_set_selected(screensaverDropdown, static_cast<uint16_t>(displaySettings.screensaverType));
if (!displaySettings.backlightTimeoutEnabled) {
lv_obj_add_state(screensaverDropdown, LV_STATE_DISABLED);
}
}
}
@ -242,6 +289,13 @@ public:
const settings::display::DisplaySettings settings_to_save = displaySettings;
getMainDispatcher().dispatch([settings_to_save] {
settings::display::save(settings_to_save);
#ifdef ESP_PLATFORM
// Notify DisplayIdle service to reload settings
auto displayIdle = service::displayidle::findService();
if (displayIdle) {
displayIdle->reloadSettings();
}
#endif
});
}
}

View File

@ -0,0 +1,125 @@
#ifdef ESP_PLATFORM
#include "BouncingBallsScreensaver.h"
#include <cstdlib>
namespace tt::service::displayidle {
void BouncingBallsScreensaver::start(lv_obj_t* overlay, lv_coord_t screenW, lv_coord_t screenH) {
for (int i = 0; i < NUM_BALLS; i++) {
initBall(balls_[i], overlay, screenW, screenH, i);
}
}
void BouncingBallsScreensaver::stop() {
for (auto& ball : balls_) {
ball.obj = nullptr; // Objects deleted by parent overlay
}
}
void BouncingBallsScreensaver::update(lv_coord_t screenW, lv_coord_t screenH) {
for (auto& ball : balls_) {
if (!ball.obj) continue;
ball.x += ball.dx;
ball.y += ball.dy;
bool bounced = false;
if (ball.x <= 0) {
ball.x = 0;
ball.dx = -ball.dx;
bounced = true;
} else if (ball.x >= screenW - BALL_SIZE) {
ball.x = screenW - BALL_SIZE;
ball.dx = -ball.dx;
bounced = true;
}
if (ball.y <= 0) {
ball.y = 0;
ball.dy = -ball.dy;
bounced = true;
} else if (ball.y >= screenH - BALL_SIZE) {
ball.y = screenH - BALL_SIZE;
ball.dy = -ball.dy;
bounced = true;
}
if (bounced) {
setRandomTargetColor(ball);
}
updateBallColor(ball);
lv_obj_set_pos(ball.obj, ball.x, ball.y);
}
}
void BouncingBallsScreensaver::initBall(Ball& ball, lv_obj_t* parent, lv_coord_t screenW, lv_coord_t screenH, int index) {
ball.obj = lv_obj_create(parent);
if (ball.obj == nullptr) {
return;
}
lv_obj_remove_style_all(ball.obj);
lv_obj_set_size(ball.obj, BALL_SIZE, BALL_SIZE);
lv_obj_set_style_bg_opa(ball.obj, LV_OPA_COVER, 0);
lv_obj_set_style_radius(ball.obj, BALL_SIZE / 2, 0);
ball.x = (screenW > BALL_SIZE) ? rand() % (screenW - BALL_SIZE) : 0;
ball.y = (screenH > BALL_SIZE) ? rand() % (screenH - BALL_SIZE) : 0;
// Random speeds between 1-4 with random direction
// Note: adjustment below may extend range to 1-5 to ensure varied paths
ball.dx = (rand() % 4) + 1;
ball.dy = (rand() % 4) + 1;
if (rand() % 2) ball.dx = -ball.dx;
if (rand() % 2) ball.dy = -ball.dy;
// Ensure dx != dy for more varied paths (may adjust dy to 5 or -5)
if (abs(ball.dx) == abs(ball.dy)) {
ball.dy = ball.dy > 0 ? ball.dy + 1 : ball.dy - 1;
}
uint32_t color = COLORS[index % COLORS.size()];
ball.currentR = ball.targetR = (color >> 16) & 0xFF;
ball.currentG = ball.targetG = (color >> 8) & 0xFF;
ball.currentB = ball.targetB = color & 0xFF;
ball.colorStep = 0;
lv_obj_set_style_bg_color(ball.obj, lv_color_make(ball.currentR, ball.currentG, ball.currentB), 0);
lv_obj_set_pos(ball.obj, ball.x, ball.y);
}
void BouncingBallsScreensaver::setRandomTargetColor(Ball& ball) {
uint32_t color = COLORS[rand() % COLORS.size()];
ball.targetR = (color >> 16) & 0xFF;
ball.targetG = (color >> 8) & 0xFF;
ball.targetB = color & 0xFF;
ball.colorStep = COLOR_FADE_STEPS;
}
void BouncingBallsScreensaver::updateBallColor(Ball& ball) {
if (ball.colorStep > 0) {
int stepR = (static_cast<int>(ball.targetR) - ball.currentR) / ball.colorStep;
int stepG = (static_cast<int>(ball.targetG) - ball.currentG) / ball.colorStep;
int stepB = (static_cast<int>(ball.targetB) - ball.currentB) / ball.colorStep;
ball.currentR = static_cast<uint8_t>(ball.currentR + stepR);
ball.currentG = static_cast<uint8_t>(ball.currentG + stepG);
ball.currentB = static_cast<uint8_t>(ball.currentB + stepB);
ball.colorStep--;
if (ball.colorStep == 0) {
ball.currentR = ball.targetR;
ball.currentG = ball.targetG;
ball.currentB = ball.targetB;
}
if (ball.obj) {
lv_obj_set_style_bg_color(ball.obj, lv_color_make(ball.currentR, ball.currentG, ball.currentB), 0);
}
}
}
} // namespace tt::service::displayidle
#endif // ESP_PLATFORM

View File

@ -0,0 +1,59 @@
#pragma once
#ifdef ESP_PLATFORM
#include "Screensaver.h"
#include <array>
#include <cstdint>
namespace tt::service::displayidle {
class BouncingBallsScreensaver final : public Screensaver {
public:
BouncingBallsScreensaver() = default;
~BouncingBallsScreensaver() override = default;
BouncingBallsScreensaver(const BouncingBallsScreensaver&) = delete;
BouncingBallsScreensaver& operator=(const BouncingBallsScreensaver&) = delete;
BouncingBallsScreensaver(BouncingBallsScreensaver&&) = delete;
BouncingBallsScreensaver& operator=(BouncingBallsScreensaver&&) = delete;
void start(lv_obj_t* overlay, lv_coord_t screenW, lv_coord_t screenH) override;
void stop() override;
void update(lv_coord_t screenW, lv_coord_t screenH) override;
private:
static constexpr int BALL_SIZE = 20;
static constexpr int NUM_BALLS = 5;
static constexpr int COLOR_FADE_STEPS = 15;
struct Ball {
lv_obj_t* obj = nullptr;
lv_coord_t x = 0;
lv_coord_t y = 0;
lv_coord_t dx = 0;
lv_coord_t dy = 0;
uint8_t currentR = 0, currentG = 0, currentB = 0;
uint8_t targetR = 0, targetG = 0, targetB = 0;
int colorStep = 0;
};
static constexpr std::array<uint32_t, 8> COLORS = {
0xFF0000, // Red
0x00FF00, // Green
0x0000FF, // Blue
0xFFFF00, // Yellow
0xFF00FF, // Magenta
0x00FFFF, // Cyan
0xFF8000, // Orange
0x80FF00, // Lime
};
std::array<Ball, NUM_BALLS> balls_;
void initBall(Ball& ball, lv_obj_t* parent, lv_coord_t screenW, lv_coord_t screenH, int index);
static void setRandomTargetColor(Ball& ball);
static void updateBallColor(Ball& ball);
};
} // namespace tt::service::displayidle
#endif // ESP_PLATFORM

View File

@ -1,91 +1,265 @@
#ifdef ESP_PLATFORM
#include <Tactility/service/displayidle/DisplayIdleService.h>
#include "Screensaver.h"
#include "BouncingBallsScreensaver.h"
#include "MatrixRainScreensaver.h"
#include "MystifyScreensaver.h"
#include <Tactility/Logger.h>
#include <Tactility/CoreDefines.h>
#include <Tactility/hal/display/DisplayDevice.h>
#include <Tactility/lvgl/LvglSync.h>
#include <Tactility/service/ServiceContext.h>
#include <Tactility/service/ServiceManifest.h>
#include <Tactility/service/ServiceRegistration.h>
#include <Tactility/settings/DisplaySettings.h>
#include <Tactility/Timer.h>
#include <cstdlib>
#include <ctime>
namespace tt::service::displayidle {
class DisplayIdleService final : public Service {
static const auto LOGGER = Logger("DisplayIdle");
std::unique_ptr<Timer> timer;
bool displayDimmed = false;
settings::display::DisplaySettings cachedDisplaySettings;
constexpr uint32_t kWakeActivityThresholdMs = 100;
static std::shared_ptr<hal::display::DisplayDevice> getDisplay() {
return hal::findFirstDevice<hal::display::DisplayDevice>(hal::Device::Type::Display);
static std::shared_ptr<hal::display::DisplayDevice> getDisplay() {
return hal::findFirstDevice<hal::display::DisplayDevice>(hal::Device::Type::Display);
}
void DisplayIdleService::stopScreensaverCb(lv_event_t* e) {
auto* self = static_cast<DisplayIdleService*>(lv_event_get_user_data(e));
lv_event_stop_bubbling(e);
self->stopScreensaverRequested.store(true, std::memory_order_release);
lv_display_trigger_activity(nullptr);
}
void DisplayIdleService::stopScreensaver() {
if (!lvgl::lock(100)) {
// Lock failed - keep flag set to retry on next tick
return;
}
void tick() {
// Settings are now cached and event-driven (no file I/O in timer callback!)
// This prevents watchdog timeout from blocking the Timer Service task
const auto restoreDuty = cachedDisplaySettings.backlightDuty;
const bool wasDimmed = displayDimmed;
if (lv_disp_get_default() == nullptr) {
return;
if (screensaverOverlay) {
if (screensaver) {
screensaver->stop();
screensaver.reset();
}
lv_obj_delete(screensaverOverlay);
screensaverOverlay = nullptr;
}
lvgl::unlock();
stopScreensaverRequested.store(false, std::memory_order_relaxed);
// Query LVGL inactivity once for both checks
uint32_t inactive_ms = 0;
if (lvgl::lock(100)) {
inactive_ms = lv_disp_get_inactive_time(nullptr);
lvgl::unlock();
} else {
return;
}
// Reset auto-off state
screensaverActiveCounter = 0;
backlightOff = false;
// TODO: The following logic only works with the first display. There might be multiple displays.
// Handle display backlight
auto display = getDisplay();
if (display != nullptr && display->supportsBacklightDuty()) {
// If timeout disabled, ensure backlight restored if we had dimmed it
if (!cachedDisplaySettings.backlightTimeoutEnabled || cachedDisplaySettings.backlightTimeoutMs == 0) {
if (displayDimmed) {
display->setBacklightDuty(cachedDisplaySettings.backlightDuty);
displayDimmed = false;
}
} else {
if (!displayDimmed && inactive_ms >= cachedDisplaySettings.backlightTimeoutMs) {
display->setBacklightDuty(0);
displayDimmed = true;
} else if (displayDimmed && inactive_ms < 100) {
display->setBacklightDuty(cachedDisplaySettings.backlightDuty);
displayDimmed = false;
// Restore backlight if display was dimmed
auto display = getDisplay();
if (display && wasDimmed) {
display->setBacklightDuty(restoreDuty);
}
displayDimmed = wasDimmed ? false : displayDimmed;
}
void DisplayIdleService::activateScreensaver() {
lv_obj_t* top = lv_layer_top();
if (screensaverOverlay != nullptr) return;
// Reset auto-off counter when starting screensaver
screensaverActiveCounter = 0;
backlightOff = false;
lv_coord_t screenW = lv_display_get_horizontal_resolution(nullptr);
lv_coord_t screenH = lv_display_get_vertical_resolution(nullptr);
// Black background overlay
screensaverOverlay = lv_obj_create(top);
lv_obj_remove_style_all(screensaverOverlay);
lv_obj_set_size(screensaverOverlay, LV_PCT(100), LV_PCT(100));
lv_obj_set_pos(screensaverOverlay, 0, 0);
lv_obj_set_style_bg_color(screensaverOverlay, lv_color_black(), 0);
lv_obj_set_style_bg_opa(screensaverOverlay, LV_OPA_COVER, 0);
lv_obj_add_flag(screensaverOverlay, LV_OBJ_FLAG_CLICKABLE);
lv_obj_add_event_cb(screensaverOverlay, stopScreensaverCb, LV_EVENT_CLICKED, this);
// Create and start the screensaver based on settings
switch (cachedDisplaySettings.screensaverType) {
case settings::display::ScreensaverType::Mystify:
screensaver = std::make_unique<MystifyScreensaver>();
break;
case settings::display::ScreensaverType::BouncingBalls:
screensaver = std::make_unique<BouncingBallsScreensaver>();
break;
case settings::display::ScreensaverType::MatrixRain:
screensaver = std::make_unique<MatrixRainScreensaver>();
break;
case settings::display::ScreensaverType::None:
default:
// Just black screen, no animated screensaver
screensaver = nullptr;
break;
}
if (screensaver) {
screensaver->start(screensaverOverlay, screenW, screenH);
}
}
void DisplayIdleService::updateScreensaver() {
if (screensaver) {
lv_coord_t screenW = lv_display_get_horizontal_resolution(nullptr);
lv_coord_t screenH = lv_display_get_vertical_resolution(nullptr);
screensaver->update(screenW, screenH);
}
}
void DisplayIdleService::tick() {
if (!lvgl::lock(100)) {
return;
}
if (lv_display_get_default() == nullptr) {
lvgl::unlock();
return;
}
// Check for settings reload request (thread-safe)
if (settingsReloadRequested.exchange(false, std::memory_order_acquire)) {
cachedDisplaySettings = settings::display::loadOrGetDefault();
}
uint32_t inactive_ms = 0;
inactive_ms = lv_display_get_inactive_time(nullptr);
// Only update if not stopping (prevents lag on touch)
if (displayDimmed && screensaverOverlay && !stopScreensaverRequested.load(std::memory_order_acquire)) {
// Check if screensaver should auto-off after 5 minutes
if (!backlightOff) {
screensaverActiveCounter++;
if (screensaverActiveCounter >= SCREENSAVER_AUTO_OFF_TICKS) {
// Stop screensaver animation and turn off backlight
if (screensaver) {
screensaver->stop();
screensaver.reset();
}
auto display = getDisplay();
if (display) {
display->setBacklightDuty(0);
}
backlightOff = true;
} else {
updateScreensaver();
}
}
}
lvgl::unlock();
// Check stop request early for faster response
if (stopScreensaverRequested.load(std::memory_order_acquire)) {
stopScreensaver();
return;
}
public:
bool onStart(ServiceContext& service) override {
// Load settings once at startup and cache them
// This eliminates file I/O from timer callback (prevents watchdog timeout)
cachedDisplaySettings = settings::display::loadOrGetDefault();
// Note: Settings changes require service restart to take effect
// TODO: Add DisplaySettingsChanged events for dynamic updates
timer = std::make_unique<Timer>(Timer::Type::Periodic, kernel::millisToTicks(250), [this]{ this->tick(); });
timer->setCallbackPriority(Thread::Priority::Lower);
timer->start();
return true;
}
void onStop(ServiceContext& service) override {
if (timer) {
timer->stop();
timer = nullptr;
}
// Ensure display restored on stop
auto display = getDisplay();
if (display && displayDimmed) {
display->setBacklightDuty(cachedDisplaySettings.backlightDuty);
displayDimmed = false;
auto display = getDisplay();
if (display != nullptr && display->supportsBacklightDuty()) {
if (!cachedDisplaySettings.backlightTimeoutEnabled || cachedDisplaySettings.backlightTimeoutMs == 0) {
if (displayDimmed) {
display->setBacklightDuty(cachedDisplaySettings.backlightDuty);
displayDimmed = false;
}
} else {
if (!displayDimmed && inactive_ms >= cachedDisplaySettings.backlightTimeoutMs) {
if (!lvgl::lock(100)) {
return; // Retry on next tick
}
activateScreensaver();
lvgl::unlock();
// Turn off backlight for "None" screensaver (just black screen)
if (cachedDisplaySettings.screensaverType == settings::display::ScreensaverType::None) {
display->setBacklightDuty(0);
}
displayDimmed = true;
} else if (displayDimmed && (inactive_ms < kWakeActivityThresholdMs)) {
stopScreensaver();
}
}
}
};
}
bool DisplayIdleService::onStart(ServiceContext& service) {
// Seed random number generator for varied screensaver patterns
srand(static_cast<unsigned int>(time(nullptr)));
cachedDisplaySettings = settings::display::loadOrGetDefault();
timer = std::make_unique<Timer>(Timer::Type::Periodic, kernel::millisToTicks(TICK_INTERVAL_MS), [this]{ this->tick(); });
timer->setCallbackPriority(Thread::Priority::Lower);
timer->start();
return true;
}
void DisplayIdleService::onStop(ServiceContext& service) {
if (timer) {
timer->stop();
timer = nullptr;
}
if (screensaverOverlay) {
// Retry screensaver cleanup during shutdown
constexpr int maxRetries = 5;
for (int i = 0; i < maxRetries && screensaverOverlay; ++i) {
stopScreensaver();
if (screensaverOverlay && i < maxRetries - 1) {
kernel::delayMillis(50); // Brief delay before retry
}
}
if (screensaverOverlay) {
LOGGER.warn("Failed to stop screensaver during shutdown - potential resource leak");
}
}
screensaver.reset();
}
void DisplayIdleService::startScreensaver() {
if (!lvgl::lock(100)) {
return;
}
// Reload settings to get current screensaver type
// Note: This is safe because we hold the LVGL lock which serializes with tick()
cachedDisplaySettings = settings::display::loadOrGetDefault();
activateScreensaver();
lvgl::unlock();
// Turn off backlight for "None" screensaver
auto display = getDisplay();
if (display && cachedDisplaySettings.screensaverType == settings::display::ScreensaverType::None) {
display->setBacklightDuty(0);
}
displayDimmed = true;
}
bool DisplayIdleService::isScreensaverActive() const {
return screensaverOverlay != nullptr;
}
void DisplayIdleService::reloadSettings() {
// Set flag for thread-safe reload - actual reload happens in tick()
settingsReloadRequested.store(true, std::memory_order_release);
}
std::shared_ptr<DisplayIdleService> findService() {
return std::static_pointer_cast<DisplayIdleService>(
findServiceById("DisplayIdle")
);
}
extern const ServiceManifest manifest = {
.id = "DisplayIdle",
@ -93,3 +267,5 @@ extern const ServiceManifest manifest = {
};
}
#endif // ESP_PLATFORM

View File

@ -0,0 +1,320 @@
#ifdef ESP_PLATFORM
#include "MatrixRainScreensaver.h"
#include <cstdlib>
#include <algorithm>
#include <cassert>
namespace tt::service::displayidle {
static_assert(MatrixRainScreensaver::MAX_TRAIL_LENGTH > MatrixRainScreensaver::MIN_TRAIL_LENGTH,
"MAX_TRAIL_LENGTH must be greater than MIN_TRAIL_LENGTH");
static_assert(MatrixRainScreensaver::MAX_TICK_DELAY >= MatrixRainScreensaver::MIN_TICK_DELAY,
"MAX_TICK_DELAY must be >= MIN_TICK_DELAY");
static constexpr int TRAIL_LENGTH_RANGE = MatrixRainScreensaver::MAX_TRAIL_LENGTH - MatrixRainScreensaver::MIN_TRAIL_LENGTH;
static constexpr int TICK_DELAY_RANGE = MatrixRainScreensaver::MAX_TICK_DELAY - MatrixRainScreensaver::MIN_TICK_DELAY + 1;
char MatrixRainScreensaver::randomChar() {
static const char chars[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789@#$%&*<>?";
return chars[rand() % (sizeof(chars) - 1)];
}
lv_color_t MatrixRainScreensaver::getTrailColor(int index, int trailLength) const {
if (trailLength <= 1) {
return lv_color_hex(COLOR_GRADIENT[0]);
}
float intensity = static_cast<float>(index) / static_cast<float>(trailLength - 1);
int colorIndex = static_cast<int>(intensity * (COLOR_GRADIENT.size() - 1) + 0.5f);
if (colorIndex < 0) colorIndex = 0;
if (colorIndex >= static_cast<int>(COLOR_GRADIENT.size())) {
colorIndex = COLOR_GRADIENT.size() - 1;
}
return lv_color_hex(COLOR_GRADIENT[colorIndex]);
}
int MatrixRainScreensaver::getRandomAvailableColumn() {
if (availableColumns_.empty()) {
return -1;
}
int idx = rand() % availableColumns_.size();
int column = availableColumns_[idx];
// O(1) removal: swap with last element and pop
availableColumns_[idx] = availableColumns_.back();
availableColumns_.pop_back();
return column;
}
void MatrixRainScreensaver::releaseColumn(int column) {
// Add column back to available pool if not already present (duplicate protection)
bool alreadyPresent = std::find(availableColumns_.begin(), availableColumns_.end(), column) != availableColumns_.end();
assert(!alreadyPresent && "Column released twice - indicates bug in column management");
if (!alreadyPresent) {
availableColumns_.push_back(column);
}
}
void MatrixRainScreensaver::createRainCharLabels(RainChar& rc, int trailIndex, int trailLength) {
if (overlay_ == nullptr) {
rc.label = nullptr;
rc.glowLabel = nullptr;
return;
}
rc.ch = randomChar();
// Create glow layer first (behind main text)
rc.glowLabel = lv_label_create(overlay_);
if (rc.glowLabel == nullptr) {
rc.label = nullptr;
return;
}
lv_obj_set_style_text_font(rc.glowLabel, &lv_font_montserrat_14, 0);
lv_obj_set_style_text_color(rc.glowLabel, lv_color_hex(COLOR_GLOW), 0);
lv_obj_set_style_opa(rc.glowLabel, LV_OPA_70, 0);
// Create main character label
rc.label = lv_label_create(overlay_);
if (rc.label == nullptr) {
lv_obj_delete(rc.glowLabel);
rc.glowLabel = nullptr;
return;
}
lv_obj_set_style_text_font(rc.label, &lv_font_montserrat_14, 0);
lv_obj_set_style_text_color(rc.label, getTrailColor(trailIndex, trailLength), 0);
char text[2] = {rc.ch, '\0'};
lv_label_set_text(rc.label, text);
lv_label_set_text(rc.glowLabel, text);
// Hide initially
lv_obj_set_pos(rc.label, -100, -100);
lv_obj_set_pos(rc.glowLabel, -100, -100);
}
void MatrixRainScreensaver::initRaindrop(Raindrop& drop, int column) {
drop.column = column;
drop.trailLength = MIN_TRAIL_LENGTH + rand() % TRAIL_LENGTH_RANGE;
drop.headRow = -(drop.trailLength); // Start above screen
drop.tickDelay = MIN_TICK_DELAY + rand() % TICK_DELAY_RANGE;
drop.tickCounter = 0;
drop.active = true;
// Create character labels for trail
drop.chars.resize(drop.trailLength);
for (int i = 0; i < drop.trailLength; i++) {
createRainCharLabels(drop.chars[i], i, drop.trailLength);
}
}
void MatrixRainScreensaver::resetRaindrop(Raindrop& drop) {
releaseColumn(drop.column);
int newColumn = getRandomAvailableColumn();
if (newColumn < 0) {
drop.active = false;
return;
}
drop.column = newColumn;
drop.tickDelay = MIN_TICK_DELAY + rand() % TICK_DELAY_RANGE;
drop.tickCounter = 0;
int newTrailLength = MIN_TRAIL_LENGTH + rand() % TRAIL_LENGTH_RANGE;
// Resize if needed
if (newTrailLength != drop.trailLength) {
// Delete excess labels
for (size_t i = newTrailLength; i < drop.chars.size(); i++) {
if (drop.chars[i].label) {
lv_obj_delete(drop.chars[i].label);
}
if (drop.chars[i].glowLabel) {
lv_obj_delete(drop.chars[i].glowLabel);
}
}
size_t oldSize = drop.chars.size();
drop.chars.resize(newTrailLength);
// Create new labels if needed (will be refreshed below)
for (size_t i = oldSize; i < drop.chars.size(); i++) {
createRainCharLabels(drop.chars[i], static_cast<int>(i), newTrailLength);
}
drop.trailLength = newTrailLength;
}
// Reset position above screen
drop.headRow = -(drop.trailLength);
// Refresh characters and colors for all labels
for (int i = 0; i < drop.trailLength; i++) {
auto& rc = drop.chars[i];
if (rc.label == nullptr || rc.glowLabel == nullptr) {
continue;
}
rc.ch = randomChar();
char text[2] = {rc.ch, '\0'};
lv_label_set_text(rc.label, text);
lv_label_set_text(rc.glowLabel, text);
lv_obj_set_style_text_color(rc.label, getTrailColor(i, drop.trailLength), 0);
lv_obj_set_pos(rc.label, -100, -100);
lv_obj_set_pos(rc.glowLabel, -100, -100);
}
drop.active = true;
}
void MatrixRainScreensaver::updateRaindropDisplay(Raindrop& drop) {
int xPos = drop.column * CHAR_WIDTH;
for (int i = 0; i < drop.trailLength; i++) {
auto& rc = drop.chars[i];
if (rc.label == nullptr || rc.glowLabel == nullptr) {
continue;
}
int row = drop.headRow - i;
int yPos = row * CHAR_HEIGHT;
// Only show characters that are on screen (grid-snapped positions)
if (row >= 0 && row < numRows_) {
lv_obj_set_pos(rc.label, xPos, yPos);
lv_obj_set_pos(rc.glowLabel, xPos + GLOW_OFFSET, yPos + GLOW_OFFSET);
} else {
lv_obj_set_pos(rc.label, -100, -100);
lv_obj_set_pos(rc.glowLabel, -100, -100);
}
}
}
void MatrixRainScreensaver::flickerRandomChars() {
constexpr int ERROR_PERCENT = 3;
for (auto& drop : drops_) {
if (!drop.active) continue;
for (int i = 0; i < drop.trailLength; i++) {
int row = drop.headRow - i;
// Only process visible characters
if (row >= 0 && row < numRows_) {
if (rand() % 100 < ERROR_PERCENT) {
auto& rc = drop.chars[i];
if (rc.label == nullptr || rc.glowLabel == nullptr) {
continue;
}
rc.ch = randomChar();
char text[2] = {rc.ch, '\0'};
lv_label_set_text(rc.label, text);
lv_label_set_text(rc.glowLabel, text);
}
}
}
}
}
void MatrixRainScreensaver::start(lv_obj_t* overlay, lv_coord_t screenW, lv_coord_t screenH) {
if (!drops_.empty()) {
stop();
}
overlay_ = overlay;
screenW_ = screenW;
screenH_ = screenH;
globalFlickerCounter_ = 0;
numColumns_ = screenW / CHAR_WIDTH;
numRows_ = screenH / CHAR_HEIGHT;
availableColumns_.clear();
for (int i = 0; i < numColumns_; i++) {
availableColumns_.push_back(i);
}
int numDrops = std::min(MAX_ACTIVE_DROPS, numColumns_);
drops_.resize(numDrops);
for (int i = 0; i < numDrops; i++) {
int col = getRandomAvailableColumn();
if (col >= 0) {
initRaindrop(drops_[i], col);
// Stagger start positions (guard against tiny screens)
int staggerRange = numRows_ / 2;
if (staggerRange > 0) {
drops_[i].headRow = -(rand() % staggerRange);
}
}
}
}
void MatrixRainScreensaver::stop() {
// Explicitly delete labels to prevent resource leaks
for (auto& drop : drops_) {
for (auto& rc : drop.chars) {
if (rc.label) {
lv_obj_delete(rc.label);
rc.label = nullptr;
}
if (rc.glowLabel) {
lv_obj_delete(rc.glowLabel);
rc.glowLabel = nullptr;
}
}
drop.chars.clear();
}
drops_.clear();
availableColumns_.clear();
overlay_ = nullptr;
}
void MatrixRainScreensaver::update(lv_coord_t screenW, lv_coord_t screenH) {
// Screen dimensions captured at start() - no dynamic resize support during screensaver
LV_UNUSED(screenW);
LV_UNUSED(screenH);
// Global glitch effect
globalFlickerCounter_++;
if (globalFlickerCounter_ >= 5) {
globalFlickerCounter_ = 0;
flickerRandomChars();
}
for (auto& drop : drops_) {
if (!drop.active) continue;
// Tick-based movement (terminal style - advance by row)
drop.tickCounter++;
if (drop.tickCounter >= drop.tickDelay) {
drop.tickCounter = 0;
// Advance head down by one row
drop.headRow++;
// Randomly change the head character when it advances
if (!drop.chars.empty() && drop.chars[0].label != nullptr && drop.chars[0].glowLabel != nullptr) {
drop.chars[0].ch = randomChar();
char text[2] = {drop.chars[0].ch, '\0'};
lv_label_set_text(drop.chars[0].label, text);
lv_label_set_text(drop.chars[0].glowLabel, text);
}
// Check if tail has completely left the screen
int tailRow = drop.headRow - (drop.trailLength - 1);
if (tailRow >= numRows_) {
resetRaindrop(drop);
continue;
}
}
updateRaindropDisplay(drop);
}
}
} // namespace tt::service::displayidle
#endif // ESP_PLATFORM

View File

@ -0,0 +1,86 @@
#pragma once
#ifdef ESP_PLATFORM
#include "Screensaver.h"
#include <array>
#include <vector>
#include <cstdint>
namespace tt::service::displayidle {
class MatrixRainScreensaver final : public Screensaver {
public:
MatrixRainScreensaver() = default;
MatrixRainScreensaver(const MatrixRainScreensaver&) = delete;
MatrixRainScreensaver& operator=(const MatrixRainScreensaver&) = delete;
MatrixRainScreensaver(MatrixRainScreensaver&&) = delete;
MatrixRainScreensaver& operator=(MatrixRainScreensaver&&) = delete;
static constexpr int CHAR_WIDTH = 12;
static constexpr int CHAR_HEIGHT = 16;
static constexpr int MIN_TRAIL_LENGTH = 8;
static constexpr int MAX_TRAIL_LENGTH = 20;
static constexpr int MAX_ACTIVE_DROPS = 16;
static constexpr int MIN_TICK_DELAY = 2; // Fastest: advance every 2 frames
static constexpr int MAX_TICK_DELAY = 5; // Slowest: advance every 5 frames
static constexpr int GLOW_OFFSET = 2;
~MatrixRainScreensaver() override = default;
void start(lv_obj_t* overlay, lv_coord_t screenW, lv_coord_t screenH) override;
void stop() override;
void update(lv_coord_t screenW, lv_coord_t screenH) override;
private:
// A single character cell in the rain
struct RainChar {
lv_obj_t* label = nullptr;
lv_obj_t* glowLabel = nullptr;
char ch = ' ';
};
// A raindrop - now uses integer row positions (terminal-style grid)
struct Raindrop {
int headRow = 0; // Current head row position (grid-based)
int column = 0; // Column index
int trailLength = 10;
int tickDelay = 3; // Frames between row advances
int tickCounter = 0; // Current tick count
std::vector<RainChar> chars;
bool active = false;
};
std::vector<Raindrop> drops_;
std::vector<int> availableColumns_;
lv_obj_t* overlay_ = nullptr;
lv_coord_t screenW_ = 0;
lv_coord_t screenH_ = 0;
int numColumns_ = 0;
int numRows_ = 0;
int globalFlickerCounter_ = 0;
// Six-color gradient palette
static constexpr std::array<uint32_t, 6> COLOR_GRADIENT = {
0xAAFFDD, // 0: Bright cyan-white (head)
0x00FF66, // 1: Bright green
0x00DD44, // 2: Medium-bright green
0x00AA33, // 3: Medium green
0x006622, // 4: Dim green
0x003311, // 5: Dark green (tail end)
};
static constexpr uint32_t COLOR_GLOW = 0x002208;
void initRaindrop(Raindrop& drop, int column);
void resetRaindrop(Raindrop& drop);
void updateRaindropDisplay(Raindrop& drop);
void flickerRandomChars();
void createRainCharLabels(RainChar& rc, int trailIndex, int trailLength);
lv_color_t getTrailColor(int index, int trailLength) const;
static char randomChar();
int getRandomAvailableColumn();
void releaseColumn(int column);
};
} // namespace tt::service::displayidle
#endif // ESP_PLATFORM

View File

@ -0,0 +1,209 @@
#ifdef ESP_PLATFORM
#include "MystifyScreensaver.h"
#include <cmath>
#include <cstdlib>
namespace tt::service::displayidle {
// Helper to generate random float in range [min, max]
static float randomFloat(float min, float max) {
if (max <= min) return min;
return min + (max - min) * static_cast<float>(rand()) / static_cast<float>(RAND_MAX);
}
void MystifyScreensaver::start(lv_obj_t* overlay, lv_coord_t screenW, lv_coord_t screenH) {
for (auto& polygon : polygons_) {
initPolygon(polygon, overlay, screenW, screenH);
}
}
void MystifyScreensaver::stop() {
for (auto& polygon : polygons_) {
for (auto& trailLines : polygon.lines) {
for (auto& line : trailLines) {
line = nullptr; // Objects deleted by parent overlay
}
}
polygon.historyHead = 0;
polygon.historyFull = false;
}
}
void MystifyScreensaver::update(lv_coord_t screenW, lv_coord_t screenH) {
constexpr float minSpeed = 0.5f;
constexpr float maxSpeed = 2.5f;
for (auto& polygon : polygons_) {
// Periodic color change
polygon.colorChangeCounter++;
if (polygon.colorChangeCounter >= COLOR_CHANGE_INTERVAL) {
polygon.colorChangeCounter = 0;
uint32_t colorHex = COLOR_POOL[rand() % COLOR_POOL.size()];
polygon.color = lv_color_hex(colorHex);
updateLineColors(polygon);
}
// Move vertices
for (auto& vertex : polygon.vertices) {
vertex.x += vertex.dx;
vertex.y += vertex.dy;
// Bounce off edges with slight angle variation for more organic movement
if (vertex.x <= 0) {
vertex.x = 0;
vertex.dx = std::fabs(vertex.dx) + randomFloat(-0.2f, 0.2f);
} else if (vertex.x >= screenW - 1) {
vertex.x = static_cast<float>(screenW - 1);
vertex.dx = -std::fabs(vertex.dx) + randomFloat(-0.2f, 0.2f);
}
if (vertex.y <= 0) {
vertex.y = 0;
vertex.dy = std::fabs(vertex.dy) + randomFloat(-0.2f, 0.2f);
} else if (vertex.y >= screenH - 1) {
vertex.y = static_cast<float>(screenH - 1);
vertex.dy = -std::fabs(vertex.dy) + randomFloat(-0.2f, 0.2f);
}
// Clamp speeds to prevent runaway acceleration or stalling
if (std::fabs(vertex.dx) < minSpeed) vertex.dx = (vertex.dx >= 0 ? minSpeed : -minSpeed);
if (std::fabs(vertex.dy) < minSpeed) vertex.dy = (vertex.dy >= 0 ? minSpeed : -minSpeed);
if (std::fabs(vertex.dx) > maxSpeed) vertex.dx = (vertex.dx >= 0 ? maxSpeed : -maxSpeed);
if (std::fabs(vertex.dy) > maxSpeed) vertex.dy = (vertex.dy >= 0 ? maxSpeed : -maxSpeed);
}
// Shift history
int newHead = polygon.historyHead + 1;
if (newHead >= TRAIL_LENGTH) {
newHead = 0;
polygon.historyFull = true;
}
polygon.historyHead = newHead;
// Store current positions at head
for (int v = 0; v < NUM_VERTICES; v++) {
polygon.history[newHead][v].x = polygon.vertices[v].x;
polygon.history[newHead][v].y = polygon.vertices[v].y;
}
// Pre-calculate history indices
int histIndices[TRAIL_LENGTH];
for (int t = 0; t < TRAIL_LENGTH; t++) {
int idx = newHead - t;
histIndices[t] = (idx >= 0) ? idx : idx + TRAIL_LENGTH;
}
// Calculate valid trail count
int validTrailCount = polygon.historyFull ? TRAIL_LENGTH : (newHead + 1);
// Update line positions
for (int t = 0; t < TRAIL_LENGTH; t++) {
auto& trailLines = polygon.lines[t];
// Hide lines beyond valid history
if (t >= validTrailCount) {
for (int e = 0; e < NUM_VERTICES; e++) {
if (trailLines[e]) {
lv_obj_add_flag(trailLines[e], LV_OBJ_FLAG_HIDDEN);
}
}
continue;
}
int histIndex = histIndices[t];
auto& hist = polygon.history[histIndex];
auto& linePointsTrail = polygon.linePoints[t];
for (int e = 0; e < NUM_VERTICES; e++) {
if (!trailLines[e]) continue;
lv_obj_remove_flag(trailLines[e], LV_OBJ_FLAG_HIDDEN);
int v2 = (e + 1) % NUM_VERTICES;
linePointsTrail[e][0].x = hist[e].x;
linePointsTrail[e][0].y = hist[e].y;
linePointsTrail[e][1].x = hist[v2].x;
linePointsTrail[e][1].y = hist[v2].y;
lv_line_set_points(trailLines[e], linePointsTrail[e].data(), 2);
}
}
}
}
void MystifyScreensaver::initPolygon(Polygon& polygon, lv_obj_t* parent, lv_coord_t screenW, lv_coord_t screenH) {
// Sanity check - avoid undefined behavior from modulo by zero
if (screenW <= 0 || screenH <= 0) return;
// Pick a random color from the pool
uint32_t colorHex = COLOR_POOL[rand() % COLOR_POOL.size()];
polygon.color = lv_color_hex(colorHex);
polygon.historyHead = 0;
polygon.historyFull = false;
// Stagger color changes so polygons don't all change at once
polygon.colorChangeCounter = rand() % COLOR_CHANGE_INTERVAL;
// Initialize vertices with random positions and velocities
for (int v = 0; v < NUM_VERTICES; v++) {
auto& vertex = polygon.vertices[v];
vertex.x = static_cast<float>(rand() % screenW);
vertex.y = static_cast<float>(rand() % screenH);
// Slower speeds (0.8-2.0) for smooth movement
vertex.dx = randomFloat(0.8f, 2.0f);
vertex.dy = randomFloat(0.8f, 2.0f);
if (rand() % 2) vertex.dx = -vertex.dx;
if (rand() % 2) vertex.dy = -vertex.dy;
// Ensure dx != dy for more interesting movement patterns
if (std::fabs(std::fabs(vertex.dx) - std::fabs(vertex.dy)) < 0.3f) {
vertex.dy += (vertex.dy > 0 ? 0.5f : -0.5f);
}
}
// Initialize history with current positions
for (int t = 0; t < TRAIL_LENGTH; t++) {
for (int v = 0; v < NUM_VERTICES; v++) {
polygon.history[t][v].x = polygon.vertices[v].x;
polygon.history[t][v].y = polygon.vertices[v].y;
}
}
// Create line objects with pre-computed opacity values
for (int t = 0; t < TRAIL_LENGTH; t++) {
int opacity = LV_OPA_COVER - (t * LV_OPA_COVER / TRAIL_LENGTH);
if (opacity < LV_OPA_10) opacity = LV_OPA_10;
for (int e = 0; e < NUM_VERTICES; e++) {
lv_obj_t* line = lv_line_create(parent);
if (line == nullptr) {
polygon.lines[t][e] = nullptr;
continue;
}
lv_obj_remove_style_all(line);
lv_obj_set_style_line_color(line, polygon.color, 0);
lv_obj_set_style_line_opa(line, opacity, 0);
lv_obj_set_style_line_width(line, 2, 0);
polygon.lines[t][e] = line;
}
}
}
void MystifyScreensaver::updateLineColors(Polygon& polygon) {
// Update all line colors when color changes
for (int t = 0; t < TRAIL_LENGTH; t++) {
for (int e = 0; e < NUM_VERTICES; e++) {
if (polygon.lines[t][e]) {
lv_obj_set_style_line_color(polygon.lines[t][e], polygon.color, 0);
}
}
}
}
} // namespace tt::service::displayidle
#endif // ESP_PLATFORM

View File

@ -0,0 +1,72 @@
#pragma once
#ifdef ESP_PLATFORM
#include "Screensaver.h"
#include <array>
#include <cstdint>
namespace tt::service::displayidle {
class MystifyScreensaver final : public Screensaver {
public:
static constexpr int NUM_POLYGONS = 2;
static constexpr int NUM_VERTICES = 4;
static constexpr int TRAIL_LENGTH = 8;
static constexpr int COLOR_CHANGE_INTERVAL = 200; // Frames between color changes (~10s at 20fps)
~MystifyScreensaver() override = default;
MystifyScreensaver() = default;
MystifyScreensaver(const MystifyScreensaver&) = delete;
MystifyScreensaver& operator=(const MystifyScreensaver&) = delete;
MystifyScreensaver(MystifyScreensaver&&) = delete;
MystifyScreensaver& operator=(MystifyScreensaver&&) = delete;
void start(lv_obj_t* overlay, lv_coord_t screenW, lv_coord_t screenH) override;
void stop() override;
void update(lv_coord_t screenW, lv_coord_t screenH) override;
private:
// Use floats for smooth sub-pixel movement
struct Vertex {
float x = 0;
float y = 0;
float dx = 0;
float dy = 0;
};
struct Polygon {
std::array<Vertex, NUM_VERTICES> vertices;
// History: [trail_index][vertex_index] = {x, y}
std::array<std::array<lv_point_precise_t, NUM_VERTICES>, TRAIL_LENGTH> history;
// Line objects: [trail_index][edge_index]
std::array<std::array<lv_obj_t*, NUM_VERTICES>, TRAIL_LENGTH> lines{};
// Persistent point arrays for each line (required by lv_line_set_points)
std::array<std::array<std::array<lv_point_precise_t, 2>, NUM_VERTICES>, TRAIL_LENGTH> linePoints;
lv_color_t color = {};
int historyHead = 0;
bool historyFull = false;
int colorChangeCounter = 0;
};
// Vibrant color pool for random selection
static constexpr std::array<uint32_t, 8> COLOR_POOL = {
0xFF00FF, // Magenta
0x00FFFF, // Cyan
0xFFFF00, // Yellow
0xFF6600, // Orange
0x00FF66, // Spring green
0x6600FF, // Purple
0xFF0066, // Hot pink
0x66FF00, // Lime
};
std::array<Polygon, NUM_POLYGONS> polygons_;
void initPolygon(Polygon& polygon, lv_obj_t* parent, lv_coord_t screenW, lv_coord_t screenH);
void updateLineColors(Polygon& polygon);
};
} // namespace tt::service::displayidle
#endif // ESP_PLATFORM

View File

@ -0,0 +1,48 @@
#pragma once
#ifdef ESP_PLATFORM
#include <lvgl.h>
namespace tt::service::displayidle {
/**
* Base class for screensaver implementations.
* Screensavers are responsible for creating their visual elements
* on the provided overlay and updating them each tick.
*/
class Screensaver {
public:
Screensaver() = default;
virtual ~Screensaver() = default;
Screensaver(const Screensaver&) = delete;
Screensaver& operator=(const Screensaver&) = delete;
Screensaver(Screensaver&&) = delete;
Screensaver& operator=(Screensaver&&) = delete;
/**
* Start the screensaver, creating visual elements on the overlay.
* @param overlay The parent LVGL object to create elements on
* @param screenW Screen width in pixels
* @param screenH Screen height in pixels
*/
virtual void start(lv_obj_t* overlay, lv_coord_t screenW, lv_coord_t screenH) = 0;
/**
* Stop the screensaver, cleaning up any internal state.
* Note: LVGL objects are deleted by the parent overlay, but any
* internal references should be cleared here.
*/
virtual void stop() = 0;
/**
* Update the screensaver animation for one frame.
* Called periodically while the screensaver is active.
* @param screenW Screen width in pixels
* @param screenH Screen height in pixels
*/
virtual void update(lv_coord_t screenW, lv_coord_t screenH) = 0;
};
} // namespace tt::service::displayidle
#endif // ESP_PLATFORM

View File

@ -32,7 +32,7 @@ class KeyboardIdleService final : public Service {
// Query LVGL inactivity once for both checks
uint32_t inactive_ms = 0;
if (lvgl::lock(100)) {
inactive_ms = lv_disp_get_inactive_time(nullptr);
inactive_ms = lv_display_get_inactive_time(nullptr);
lvgl::unlock();
}

View File

@ -16,6 +16,7 @@ constexpr auto* SETTINGS_KEY_GAMMA_CURVE = "gammaCurve";
constexpr auto* SETTINGS_KEY_BACKLIGHT_DUTY = "backlightDuty";
constexpr auto* SETTINGS_KEY_TIMEOUT_ENABLED = "backlightTimeoutEnabled";
constexpr auto* SETTINGS_KEY_TIMEOUT_MS = "backlightTimeoutMs";
constexpr auto* SETTINGS_KEY_SCREENSAVER_TYPE = "screensaverType";
static Orientation getDefaultOrientation() {
auto* display = lv_display_get_default();
@ -64,6 +65,40 @@ static bool fromString(const std::string& str, Orientation& orientation) {
}
}
static std::string toString(ScreensaverType type) {
switch (type) {
using enum ScreensaverType;
case None:
return "None";
case BouncingBalls:
return "BouncingBalls";
case Mystify:
return "Mystify";
case MatrixRain:
return "MatrixRain";
default:
std::unreachable();
}
}
static bool fromString(const std::string& str, ScreensaverType& type) {
if (str == "None") {
type = ScreensaverType::None;
return true;
} else if (str == "BouncingBalls") {
type = ScreensaverType::BouncingBalls;
return true;
} else if (str == "Mystify") {
type = ScreensaverType::Mystify;
return true;
} else if (str == "MatrixRain") {
type = ScreensaverType::MatrixRain;
return true;
} else {
return false;
}
}
bool load(DisplaySettings& settings) {
std::map<std::string, std::string> map;
if (!file::loadPropertiesFile(SETTINGS_FILE, map)) {
@ -103,11 +138,18 @@ bool load(DisplaySettings& settings) {
timeout_ms = static_cast<uint32_t>(std::strtoul(timeout_ms_entry->second.c_str(), nullptr, 10));
}
auto screensaver_entry = map.find(SETTINGS_KEY_SCREENSAVER_TYPE);
ScreensaverType screensaver_type = ScreensaverType::BouncingBalls;
if (screensaver_entry != map.end()) {
fromString(screensaver_entry->second, screensaver_type);
}
settings.orientation = orientation;
settings.gammaCurve = gamma_curve;
settings.backlightDuty = backlight_duty;
settings.backlightTimeoutEnabled = timeout_enabled;
settings.backlightTimeoutMs = timeout_ms;
settings.screensaverType = screensaver_type;
return true;
}
@ -118,7 +160,8 @@ DisplaySettings getDefault() {
.gammaCurve = 1,
.backlightDuty = 200,
.backlightTimeoutEnabled = false,
.backlightTimeoutMs = 60000
.backlightTimeoutMs = 60000,
.screensaverType = ScreensaverType::BouncingBalls
};
}
@ -137,6 +180,7 @@ bool save(const DisplaySettings& settings) {
map[SETTINGS_KEY_ORIENTATION] = toString(settings.orientation);
map[SETTINGS_KEY_TIMEOUT_ENABLED] = settings.backlightTimeoutEnabled ? "1" : "0";
map[SETTINGS_KEY_TIMEOUT_MS] = std::to_string(settings.backlightTimeoutMs);
map[SETTINGS_KEY_SCREENSAVER_TYPE] = toString(settings.screensaverType);
return file::savePropertiesFile(SETTINGS_FILE, map);
}