fix: eliminate diagonal dither pattern from display driver

Root cause: Panel_ED2208's epd_quality mode applies _dither_row_rgb_pair
(diagonal bias pattern with dither=140) during _exec_transfer(). Since our
ImagePipeline already does proper Floyd-Steinberg dithering, the driver's
additional dithering was creating visible diagonal artifacts.

Fix: setEpdMode(epd_fastest) selects _dither_row_none (clean nearest-color
lookup, no spatial bias). On Panel_ED2208 this only affects the dither
function — NOT refresh quality or waveform.

Also: default dither_noise setting to OFF since error scatter was targeting
the wrong layer (it made gradient areas fuzzier without fixing the real
problem in the display driver).

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-03 19:52:22 -04:00
parent fd7643db6a
commit 065d80ca89
20 changed files with 1100 additions and 138 deletions

View File

@@ -61,6 +61,8 @@ void displayTask(void* param) {
while (true) {
unsigned long now = millis();
Settings s = settingsManager.get();
imagePipeline.setPipelineMode(static_cast<PipelineMode>(s.pipeline_mode));
imagePipeline.setBlueNoiseEnabled(s.dither_noise != 0);
unsigned long intervalMs = s.interval_min * 60000UL;
bool shouldRefresh = false;
@@ -160,6 +162,10 @@ void displayTask(void* param) {
rightX, 0, halfW, DISPLAY_HEIGHT,
s.date_fmt, s.time_fmt);
}
if (s.show_battery) {
uint8_t battPct = powerManager.getBatteryPercent();
displayManager.showBatteryIndicator(battPct, s.meta_pos);
}
displayManager.refresh();
imagePipeline.freeImage(img);
displayed = true;
@@ -188,6 +194,10 @@ void displayTask(void* param) {
displayManager.showMetadata(info, s.meta_flags, s.meta_pos,
s.date_fmt, s.time_fmt);
}
if (s.show_battery) {
uint8_t battPct = powerManager.getBatteryPercent();
displayManager.showBatteryIndicator(battPct, s.meta_pos);
}
displayManager.refresh();
imagePipeline.freeImage(img);
displayed = true;
@@ -222,6 +232,67 @@ void webActionCallback(const String& action) {
}
}
// Minimal boot path for RTC timer wake: fetch one photo, display, sleep again
void timerWakeCycle() {
Serial.println("[timer_wake] Starting minimal wake cycle");
Settings s = settingsManager.get();
// Connect WiFi (blocking)
wifiManager.begin(settingsManager);
if (!wifiManager.isConnected()) {
Serial.println("[timer_wake] WiFi failed — going back to sleep");
powerManager.enterDeepSleep(s.interval_min);
return;
}
// Initialize Immich client and apply pipeline mode
immichClient.begin(s.immich_url, s.immich_key);
imagePipeline.setPipelineMode(static_cast<PipelineMode>(s.pipeline_mode));
imagePipeline.setBlueNoiseEnabled(s.dither_noise != 0);
// Fetch a small batch of random assets
static constexpr int TIMER_WAKE_RETRIES = 5;
bool displayed = false;
std::vector<String> ids = immichClient.fetchRandomAssetIds(10);
for (int i = 0; i < (int)ids.size() && i < TIMER_WAKE_RETRIES && !displayed; i++) {
AssetInfo info = immichClient.fetchAssetInfo(ids[i]);
uint8_t* jpegBuf = nullptr;
size_t jpegSize = 0;
if (!immichClient.downloadAsset(ids[i], s.img_quality, &jpegBuf, &jpegSize)) {
continue;
}
ProcessedImage img = imagePipeline.process(jpegBuf, jpegSize);
free(jpegBuf);
if (img.valid) {
displayManager.showImage(img);
if (s.meta_flags != 0) {
displayManager.showMetadata(info, s.meta_flags, s.meta_pos,
s.date_fmt, s.time_fmt);
}
if (s.show_battery) {
uint8_t battPct = powerManager.getBatteryPercent();
displayManager.showBatteryIndicator(battPct, s.meta_pos);
}
displayManager.showSleepIndicator(s.meta_pos);
displayManager.refresh();
imagePipeline.freeImage(img);
displayed = true;
}
}
if (!displayed) {
Serial.println("[timer_wake] Failed to display any photo");
}
Serial.printf("[timer_wake] Cycle complete — sleeping %u min\n", s.interval_min);
powerManager.enterDeepSleep(s.interval_min);
}
void setup() {
auto cfg = M5.config();
cfg.clear_display = false; // Don't clear/refresh e-ink on init
@@ -238,15 +309,41 @@ void setup() {
Serial.printf("[main] Internal RAM: %u KB free\n",
(unsigned)(heap_caps_get_free_size(MALLOC_CAP_INTERNAL) / 1024));
// Initialize core subsystems needed for both wake paths
powerManager.begin();
settingsManager.begin();
displayManager.begin(powerManager);
// Check if we woke from a timed deep sleep (persisted flag in NVS)
bool timerWoke = PowerManager::wasTimedSleepWake();
if (timerWoke) {
Serial.println("[main] Woke from timed deep sleep");
// Escape hatch: hold play/pause button during boot to stay awake
pinMode(PIN_BTN_DOWN, INPUT_PULLUP);
delay(50);
bool pauseHeld = (digitalRead(PIN_BTN_DOWN) == LOW);
if (!pauseHeld) {
// Minimal cycle: show photo and go back to sleep
timerWakeCycle();
// timerWakeCycle() calls enterDeepSleep — should not return
return;
}
// Escape hatch triggered — clear the flag and enter full interactive mode
PowerManager::clearTimedSleepFlag();
Serial.println("[main] Play/pause held — entering full interactive mode");
}
// Full interactive boot path
// Create state mutex
stateMutex = xSemaphoreCreateMutex();
// Initialize subsystems
powerManager.begin();
settingsManager.begin();
// Initialize remaining subsystems
wifiManager.begin(settingsManager);
buttonHandler.begin(powerManager, settingsManager);
displayManager.begin(powerManager);
if (!wifiManager.isAPMode()) {
// Station mode — set up Immich and web server
@@ -304,11 +401,15 @@ void loop() {
}
break;
case ButtonEvent::DeepSleep: {
Serial.println("[main] Entering deep sleep...");
Serial.println("[main] Entering timed deep sleep slideshow...");
Settings sleepS = settingsManager.get();
displayManager.showSleepIndicator(sleepS.meta_pos);
if (sleepS.show_battery) {
uint8_t battPct = powerManager.getBatteryPercent();
displayManager.showBatteryIndicator(battPct, sleepS.meta_pos);
}
displayManager.refresh();
powerManager.enterDeepSleep();
powerManager.enterDeepSleep(sleepS.interval_min);
break;
}
case ButtonEvent::FactoryReset: