#include "display_manager.h" #include "power_manager.h" #include // EPD-exact palette: values produce distance=0 in Panel_ED2208's // _rgb_to_epd_color nearest-color lookup, guaranteeing correct mapping. // Order matches our pipeline's palette indices (0=black..5=yellow). static const lgfx::bgr888_t EPD_PALETTE[6] = { {0, 0, 0}, // Black (EPD_BLACK) {255, 255, 255}, // White (EPD_WHITE) {191, 0, 0}, // Red (EPD_RED) {67, 138, 28}, // Green (EPD_GREEN) {100, 64, 255}, // Blue (EPD_BLUE) {255, 243, 56} // Yellow (EPD_YELLOW) }; void DisplayManager::begin(PowerManager& power) { _power = &power; _display = &M5.Display; // Ensure no automatic e-ink refreshes happen _display->setAutoDisplay(false); // Set rotation for landscape (device physically rotated) _display->setRotation(3); // Disable the panel driver's internal dithering (epd_quality applies a diagonal // bias pattern via _dither_row_rgb_pair). Since our ImagePipeline already does // proper Floyd-Steinberg dithering to the 6-color palette, the driver should // just do a clean nearest-color lookup with no additional spatial bias. // On Panel_ED2208 this only affects the dither function — NOT refresh quality. _display->setEpdMode(epd_mode_t::epd_fastest); Serial.printf("[display] Initialized: %dx%d, rotation=%d, epd_mode=fastest (no driver dither)\n", _display->width(), _display->height(), _display->getRotation()); } void DisplayManager::showSetupScreen() { _power->enableEPDPower(); // Batch all drawing — nothing hits the panel until display() at the end _display->startWrite(); _display->fillScreen(TFT_WHITE); // Generate QR code for WiFi auto-connect const char* qrData = "WIFI:T:nopass;S:PaperColor-Setup;;"; QRCode qrcode; uint8_t qrcodeData[qrcode_getBufferSize(6)]; qrcode_initText(&qrcode, qrcodeData, 6, ECC_LOW, qrData); // Draw QR code centered vertically, left-of-center horizontally int qrSize = qrcode.size; int scale = 4; int qrPixels = qrSize * scale; int qrX = (DISPLAY_WIDTH / 3) - (qrPixels / 2); int qrY = (DISPLAY_HEIGHT - qrPixels) / 2; for (int y = 0; y < qrSize; y++) { for (int x = 0; x < qrSize; x++) { uint16_t color = qrcode_getModule(&qrcode, x, y) ? TFT_BLACK : TFT_WHITE; _display->fillRect(qrX + x * scale, qrY + y * scale, scale, scale, color); } } // Text instructions to the right of QR code int textX = DISPLAY_WIDTH / 2 + 40; _display->setTextColor(TFT_BLACK); _display->setTextDatum(middle_left); _display->setTextSize(2); _display->drawString("PaperColor", textX, DISPLAY_HEIGHT / 2 - 60); _display->setTextSize(1); _display->drawString("Scan QR to connect", textX, DISPLAY_HEIGHT / 2 - 20); _display->drawString("or join WiFi:", textX, DISPLAY_HEIGHT / 2 + 10); _display->setTextSize(1.5); _display->drawString("PaperColor-Setup", textX, DISPLAY_HEIGHT / 2 + 40); _display->setTextSize(1); _display->drawString("Then open:", textX, DISPLAY_HEIGHT / 2 + 70); _display->drawString("192.168.4.1", textX, DISPLAY_HEIGHT / 2 + 95); _display->endWrite(); // Single e-ink refresh — the only panel update during boot triggerRefresh(); _power->disableEPDPower(); } void DisplayManager::showImage(const ProcessedImage& img) { if (!img.valid || img.framebuffer == nullptr) { Serial.println("[display] Invalid image — skipping"); return; } _power->enableEPDPower(); Serial.println("[display] Writing framebuffer to e-ink..."); // Bulk-write framebuffer using palette lookup (handles rotation internally) _display->startWrite(); _display->pushImage(0, 0, img.width, img.height, img.framebuffer, lgfx::color_depth_t::palette_8bit, EPD_PALETTE); _display->endWrite(); // Do NOT refresh here — caller should add metadata overlay then call refresh() } void DisplayManager::showMessage(const char* title, const char* body) { _power->enableEPDPower(); _display->startWrite(); _display->fillScreen(TFT_WHITE); _display->setTextColor(TFT_BLACK); _display->setTextDatum(middle_center); _display->setTextSize(2); _display->drawString(title, DISPLAY_WIDTH / 2, DISPLAY_HEIGHT / 2 - 30); _display->setTextSize(1); _display->drawString(body, DISPLAY_WIDTH / 2, DISPLAY_HEIGHT / 2 + 20); _display->endWrite(); triggerRefresh(); _power->disableEPDPower(); } void DisplayManager::showMetadata(const AssetInfo& info, uint8_t metaFlags, MetaPosition pos, const String& dateFmt, const String& timeFmt) { showMetadataInRegion(info, metaFlags, pos, 0, 0, DISPLAY_WIDTH, DISPLAY_HEIGHT, dateFmt, timeFmt); } void DisplayManager::showMetadataInRegion(const AssetInfo& info, uint8_t metaFlags, MetaPosition pos, uint16_t regionX, uint16_t regionY, uint16_t regionW, uint16_t regionH, const String& dateFmt, const String& timeFmt) { if (metaFlags == 0) return; // Build metadata string String metaText = ""; // Parse ISO datetime (YYYY-MM-DDTHH:MM:SS) into struct tm for strftime if (((metaFlags & META_DATE) || (metaFlags & META_TIME)) && info.dateTime.length() >= 19) { struct tm t = {}; sscanf(info.dateTime.c_str(), "%d-%d-%dT%d:%d:%d", &t.tm_year, &t.tm_mon, &t.tm_mday, &t.tm_hour, &t.tm_min, &t.tm_sec); t.tm_year -= 1900; t.tm_mon -= 1; char buf[64]; if ((metaFlags & META_DATE) && (metaFlags & META_TIME)) { String combinedFmt = dateFmt + " " + timeFmt; strftime(buf, sizeof(buf), combinedFmt.c_str(), &t); metaText += buf; } else if (metaFlags & META_DATE) { strftime(buf, sizeof(buf), dateFmt.c_str(), &t); metaText += buf; } else { strftime(buf, sizeof(buf), timeFmt.c_str(), &t); metaText += buf; } } else if ((metaFlags & META_DATE) && info.dateTime.length() >= 10) { // Fallback if datetime is too short for full parse metaText += info.dateTime.substring(0, 10); } if ((metaFlags & META_LOCATION) && info.city.length() > 0) { if (metaText.length() > 0) metaText += " | "; metaText += info.city; } if ((metaFlags & META_PEOPLE) && !info.people.empty()) { if (metaText.length() > 0) metaText += " | "; for (size_t i = 0; i < info.people.size(); i++) { if (i > 0) metaText += ", "; metaText += info.people[i]; } } if ((metaFlags & META_ALBUM)) { // Album name would need to be passed separately } if ((metaFlags & META_CAMERA) && info.camera.length() > 0) { if (metaText.length() > 0) metaText += " | "; metaText += info.camera; } if (metaText.length() == 0) return; _display->setTextSize(1.5f); _display->setTextColor(TFT_WHITE); switch (pos) { case MetaPosition::CaptionBottom: case MetaPosition::CaptionTop: { // Bar spanning the region width, positioned at region top or bottom uint16_t barH = 36; uint16_t barY = (pos == MetaPosition::CaptionTop) ? regionY : regionY + regionH - barH; _display->fillRect(regionX, barY, regionW, barH, TFT_BLACK); _display->setTextDatum(middle_center); _display->drawString(metaText.c_str(), regionX + regionW / 2, barY + barH / 2); break; } case MetaPosition::OverlayTopLeft: case MetaPosition::OverlayTopRight: case MetaPosition::OverlayBottomLeft: case MetaPosition::OverlayBottomRight: { static constexpr uint16_t MARGIN = 8; static constexpr uint16_t PAD_X = 8; static constexpr uint16_t PAD_Y = 5; static constexpr uint16_t TEXT_H = 20; uint16_t textW = _display->textWidth(metaText.c_str()); uint16_t badgeW = min((uint16_t)(textW + PAD_X * 2), regionW); uint16_t badgeH = TEXT_H + PAD_Y * 2; uint16_t bx, by; switch (pos) { case MetaPosition::OverlayTopLeft: bx = regionX + MARGIN; by = regionY + MARGIN; break; case MetaPosition::OverlayTopRight: bx = regionX + regionW - badgeW - MARGIN; by = regionY + MARGIN; break; case MetaPosition::OverlayBottomLeft: bx = regionX + MARGIN; by = regionY + regionH - badgeH - MARGIN; break; case MetaPosition::OverlayBottomRight: bx = regionX + regionW - badgeW - MARGIN; by = regionY + regionH - badgeH - MARGIN; break; default: bx = regionX + MARGIN; by = regionY + MARGIN; break; } _display->fillRect(bx, by, badgeW, badgeH, TFT_BLACK); _display->setTextDatum(middle_left); _display->drawString(metaText.c_str(), bx + PAD_X, by + badgeH / 2); break; } } } void DisplayManager::drawBadge(uint16_t x, uint16_t y, uint16_t w, uint16_t h) { _display->fillRect(x, y, w, h, TFT_BLACK); } void DisplayManager::showSleepIndicator(MetaPosition metaPos) { _power->enableEPDPower(); static constexpr uint16_t MARGIN = 8; static constexpr uint16_t PAD_X = 6; static constexpr uint16_t PAD_Y = 4; _display->setTextSize(1); uint16_t textW = _display->textWidth("SLP"); uint16_t textH = 14; uint16_t badgeW = textW + PAD_X * 2; uint16_t badgeH = textH + PAD_Y * 2; // Pick a corner opposite to metadata position uint16_t bx, by; switch (metaPos) { case MetaPosition::OverlayTopLeft: case MetaPosition::CaptionTop: bx = DISPLAY_WIDTH - badgeW - MARGIN; by = DISPLAY_HEIGHT - badgeH - MARGIN; break; case MetaPosition::OverlayTopRight: bx = MARGIN; by = DISPLAY_HEIGHT - badgeH - MARGIN; break; case MetaPosition::OverlayBottomLeft: bx = DISPLAY_WIDTH - badgeW - MARGIN; by = MARGIN; break; case MetaPosition::OverlayBottomRight: case MetaPosition::CaptionBottom: default: bx = MARGIN; by = MARGIN; break; } drawBadge(bx, by, badgeW, badgeH); _display->setTextColor(TFT_WHITE); _display->setTextDatum(middle_center); _display->drawString("SLP", bx + badgeW / 2, by + badgeH / 2); } void DisplayManager::showBatteryIndicator(uint8_t percent, MetaPosition metaPos) { static constexpr uint16_t MARGIN = 8; static constexpr uint16_t PAD_X = 6; static constexpr uint16_t PAD_Y = 4; // Battery icon dimensions static constexpr uint16_t BATT_W = 20; static constexpr uint16_t BATT_H = 10; static constexpr uint16_t NUB_W = 3; static constexpr uint16_t NUB_H = 5; static constexpr uint16_t GAP = 4; // Build percentage text char pctText[5]; snprintf(pctText, sizeof(pctText), "%d%%", percent); _display->setTextSize(1); uint16_t textW = _display->textWidth(pctText); uint16_t textH = 14; // Total badge size: icon + gap + text + padding uint16_t contentW = BATT_W + NUB_W + GAP + textW; uint16_t contentH = max(BATT_H, textH); uint16_t badgeW = contentW + PAD_X * 2; uint16_t badgeH = contentH + PAD_Y * 2; // Position: top-right by default, top-left if metadata occupies top-right uint16_t bx, by; if (metaPos == MetaPosition::OverlayTopRight) { bx = MARGIN; by = MARGIN; } else { bx = DISPLAY_WIDTH - badgeW - MARGIN; by = MARGIN; } // Draw badge background drawBadge(bx, by, badgeW, badgeH); // Draw battery outline (white rect with 1px border) uint16_t iconX = bx + PAD_X; uint16_t iconY = by + (badgeH - BATT_H) / 2; _display->drawRect(iconX, iconY, BATT_W, BATT_H, TFT_WHITE); // Draw nub on right side of battery uint16_t nubX = iconX + BATT_W; uint16_t nubY = iconY + (BATT_H - NUB_H) / 2; _display->fillRect(nubX, nubY, NUB_W, NUB_H, TFT_WHITE); // Draw fill level inside battery (1px inset) uint16_t fillMaxW = BATT_W - 2; uint16_t fillW = (fillMaxW * percent) / 100; if (fillW > 0) { _display->fillRect(iconX + 1, iconY + 1, fillW, BATT_H - 2, TFT_WHITE); } // Draw percentage text uint16_t textX = iconX + BATT_W + NUB_W + GAP; _display->setTextColor(TFT_WHITE); _display->setTextDatum(middle_left); _display->drawString(pctText, textX, by + badgeH / 2); } void DisplayManager::refresh() { unsigned long start = millis(); triggerRefresh(); unsigned long elapsed = millis() - start; Serial.printf("[display] Refresh complete in %lu ms\n", elapsed); _power->disableEPDPower(); } void DisplayManager::triggerRefresh() { // Panel_ED2208::_exec_transfer always sends the full 400x600 frame // regardless of dirty region — the dirty rect only gates whether // a transfer happens at all. ~30ms at 4MHz SPI, fine for photo frame. _display->display(); }