diff --git a/platformio.ini b/platformio.ini
index eaa05a466..34eaa3588 100644
--- a/platformio.ini
+++ b/platformio.ini
@@ -12,6 +12,15 @@ extra_configs =
description = Meshtastic
+; Shared E-Ink hardware layer (chipset drivers, panel profiles, backlight).
+; Variants opt in by extending this env; unconverted variants keep the legacy
+; EInkDisplay2/EInkDynamicDisplay stack. See src/graphics/eink/.
+[niche]
+build_src_filter =
+ +
+build_flags =
+ -D MESHTASTIC_INCLUDE_NICHE_GRAPHICS
+
[env]
test_build_src = true
extra_scripts =
@@ -104,7 +113,7 @@ build_unflags =
-std=gnu++11
build_flags = ${env.build_flags} -Os
-std=gnu++17
-build_src_filter = ${env.build_src_filter} - + -
+build_src_filter = ${env.build_src_filter} - + - -
; Common libs for communicating over TCP/IP networks such as MQTT
[networking_base]
diff --git a/src/graphics/BaseUIEInkDisplay.cpp b/src/graphics/BaseUIEInkDisplay.cpp
new file mode 100644
index 000000000..0506ba68d
--- /dev/null
+++ b/src/graphics/BaseUIEInkDisplay.cpp
@@ -0,0 +1,208 @@
+#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS
+
+#include "./BaseUIEInkDisplay.h"
+
+#include "configuration.h"
+#include "main.h"
+
+using namespace NicheGraphics;
+
+BaseUIEInkDisplay::BaseUIEInkDisplay(Drivers::EInk *driver, uint8_t rotation) : driver(driver), rotation(rotation & 0x3)
+{
+ this->geometry = GEOMETRY_RAWMODE;
+
+ // BaseUI draws in UI orientation. Physical panel dimensions are swapped for 90°/270°.
+ const bool swap = (this->rotation == 1) || (this->rotation == 3);
+ this->displayWidth = swap ? driver->height : driver->width;
+ this->displayHeight = swap ? driver->width : driver->height;
+
+ // OLEDDisplay indexes buffer[x + (y/8) * displayWidth]
+ this->displayBufferSize = displayWidth * ((displayHeight + 7) / 8);
+
+ // Panel-native row-major buffer
+ panelRowBytes = ((driver->width - 1) / 8) + 1;
+ panelBufferSize = panelRowBytes * driver->height;
+ panelBuffer = new uint8_t[panelBufferSize];
+ memset(panelBuffer, 0xFF, panelBufferSize); // All white
+}
+
+BaseUIEInkDisplay::~BaseUIEInkDisplay()
+{
+ delete[] panelBuffer;
+}
+
+bool BaseUIEInkDisplay::connect()
+{
+ LOG_INFO("Init BaseUI E-Ink (%u x %u, rot %u)", driver->width, driver->height, rotation);
+ return true;
+}
+
+void BaseUIEInkDisplay::addFrameFlag(frameFlagTypes flag)
+{
+ frameFlags = (frameFlagTypes)(frameFlags | flag);
+}
+
+void BaseUIEInkDisplay::setDisplayResilience(uint8_t fastPerFull, float stressMultiplier)
+{
+ this->fastPerFull = (fastPerFull == 0) ? 1 : fastPerFull;
+ this->stressMultiplier = stressMultiplier;
+}
+
+void BaseUIEInkDisplay::joinAsyncRefresh()
+{
+ if (driver->busy())
+ driver->await();
+}
+
+// OLEDDisplayUi tick path. Honours rate-limit unless flags demand otherwise.
+void BaseUIEInkDisplay::display()
+{
+ const bool demandFast = frameFlags & DEMAND_FAST;
+ const bool cosmetic = frameFlags & COSMETIC;
+ const bool unlimitedFast = frameFlags & UNLIMITED_FAST;
+
+ if (!demandFast && !cosmetic && !unlimitedFast) {
+ if (!forceDisplay(lastDrawMsec == 0 ? 0 : 1000))
+ return;
+ return;
+ }
+
+ forceDisplay(0);
+}
+
+// Keyframe path. Returns true if a frame was pushed (sets lastDrawMsec).
+bool BaseUIEInkDisplay::forceDisplay(uint32_t msecLimit)
+{
+ const uint32_t now = millis();
+ if (lastDrawMsec != 0 && (now - lastDrawMsec) < msecLimit)
+ return false;
+
+ const bool blocking = frameFlags & BLOCKING;
+ Drivers::EInk::UpdateTypes type = decide();
+
+ // Don't pile frames on top of a running update - wait it out.
+ if (driver->busy())
+ driver->await();
+
+ const bool pushed = commit(type, blocking);
+ if (pushed)
+ lastDrawMsec = now;
+
+ // Reset flags for next frame
+ frameFlags = BACKGROUND;
+ return pushed;
+}
+
+bool BaseUIEInkDisplay::commit(Drivers::EInk::UpdateTypes type, bool blocking)
+{
+ uint32_t hash = repack();
+
+ // Skip if frame unchanged. Exception: caller explicitly wants a refresh (COSMETIC or FULL).
+ if (hash == lastHash && type != Drivers::EInk::UpdateTypes::FULL && lastDrawMsec != 0)
+ return false;
+ lastHash = hash;
+
+ // Fall back to FULL on panels that don't advertise FAST support.
+ if (type == Drivers::EInk::UpdateTypes::FAST && !driver->supports(Drivers::EInk::UpdateTypes::FAST))
+ type = Drivers::EInk::UpdateTypes::FULL;
+
+ driver->update(panelBuffer, type);
+
+ if (blocking)
+ driver->await();
+ return true;
+}
+
+Drivers::EInk::UpdateTypes BaseUIEInkDisplay::decide()
+{
+ typedef Drivers::EInk::UpdateTypes UT;
+
+ const bool unlimitedFast = frameFlags & UNLIMITED_FAST;
+
+ // Explicit flag wins outright
+ if (frameFlags & COSMETIC) {
+ fullRefreshDebt = max(fullRefreshDebt - 1.0f, 0.0f);
+ return UT::FULL;
+ }
+ if (frameFlags & DEMAND_FAST) {
+ if (!unlimitedFast) {
+ fullRefreshDebt += (fullRefreshDebt < 1.0f) ? (1.0f / fastPerFull) : (stressMultiplier * (1.0f / fastPerFull));
+ }
+ return UT::FAST;
+ }
+
+ const bool explicitFast = frameFlags & RESPONSIVE;
+
+ if (explicitFast || unlimitedFast) {
+ if (!unlimitedFast) {
+ fullRefreshDebt += (fullRefreshDebt < 1.0f) ? (1.0f / fastPerFull) : (stressMultiplier * (1.0f / fastPerFull));
+ }
+ return UT::FAST;
+ }
+
+ // BACKGROUND / unspecified: let debt decide
+ if (fullRefreshDebt >= 1.0f) {
+ fullRefreshDebt = max(fullRefreshDebt - 1.0f, 0.0f);
+ return UT::FULL;
+ }
+ fullRefreshDebt += 1.0f / fastPerFull;
+ return UT::FAST;
+}
+
+uint32_t BaseUIEInkDisplay::repack()
+{
+ memset(panelBuffer, 0xFF, panelBufferSize); // start all-white
+
+ const uint16_t pw = driver->width;
+ const uint16_t ph = driver->height;
+
+ // OLEDDisplay buffer: byte = buffer[x + (y/8) * displayWidth]; bit = 1 << (y & 7); 1 = black
+ // Niche buffer: byte = (y * panelRowBytes) + (x/8); bit = 1 << (7 - x%8); 1 = white
+ for (uint16_t oy = 0; oy < displayHeight; oy++) {
+ for (uint16_t ox = 0; ox < displayWidth; ox++) {
+ const uint8_t b = buffer[ox + (oy / 8) * displayWidth];
+ const bool isBlack = b & (1 << (oy & 7));
+
+ uint16_t px, py;
+ switch (rotation) {
+ case 1: // 90° CW: OLED (ox,oy) → panel (pw-1-oy, ox)
+ px = pw - 1 - oy;
+ py = ox;
+ break;
+ case 2: // 180°
+ px = pw - 1 - ox;
+ py = ph - 1 - oy;
+ break;
+ case 3: // 270° CW
+ px = oy;
+ py = ph - 1 - ox;
+ break;
+ case 0:
+ default:
+ px = ox;
+ py = oy;
+ break;
+ }
+
+ if (px >= pw || py >= ph)
+ continue;
+
+ const uint32_t byteNum = (py * panelRowBytes) + (px / 8);
+ const uint8_t bitNum = 7 - (px % 8);
+ if (isBlack)
+ panelBuffer[byteNum] &= ~(1 << bitNum);
+ else
+ panelBuffer[byteNum] |= (1 << bitNum);
+ }
+ }
+
+ // FNV-1a
+ uint32_t h = 2166136261u;
+ for (uint32_t i = 0; i < panelBufferSize; i++) {
+ h ^= panelBuffer[i];
+ h *= 16777619u;
+ }
+ return h;
+}
+
+#endif // MESHTASTIC_INCLUDE_NICHE_GRAPHICS
diff --git a/src/graphics/BaseUIEInkDisplay.h b/src/graphics/BaseUIEInkDisplay.h
new file mode 100644
index 000000000..5a411ffb4
--- /dev/null
+++ b/src/graphics/BaseUIEInkDisplay.h
@@ -0,0 +1,98 @@
+/*
+
+OLEDDisplay adapter that routes BaseUI pixel output to a NicheGraphics::Drivers::EInk driver.
+
+One adapter serves all E-Ink variants: the panel driver and orientation are injected at construction,
+and FULL/FAST selection is made by the shared DisplayHealth model (same as InkHUD).
+
+Replaces the per-board branching in EInkDisplay2 / EInkDynamicDisplay / EInkParallelDisplay.
+
+*/
+
+#pragma once
+
+#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS
+
+#include "configuration.h"
+
+#include "graphics/eink/Drivers/EInk.h"
+
+#include
+
+namespace NicheGraphics
+{
+
+class BaseUIEInkDisplay : public OLEDDisplay
+{
+ public:
+ // Flags Screen.cpp sets via EINK_ADD_FRAMEFLAG before triggering a draw.
+ // Bits are combined; decided at render time.
+ enum frameFlagTypes : uint8_t {
+ BACKGROUND = (1 << 0), // Regular OLEDDisplayUi tick - no urgency, UNSPECIFIED
+ RESPONSIVE = (1 << 1), // User-driven refresh - prefer FAST
+ COSMETIC = (1 << 2), // Clean splash / wake-from-sleep - force FULL
+ DEMAND_FAST = (1 << 3), // Menu interaction - force FAST
+ BLOCKING = (1 << 4), // Wait for update to finish before returning
+ UNLIMITED_FAST = (1 << 5), // Suppress health-driven FULL promotion (typing modes)
+ };
+
+ BaseUIEInkDisplay(Drivers::EInk *driver, uint8_t rotation);
+ ~BaseUIEInkDisplay() override;
+
+ // OLEDDisplay overrides
+ bool connect() override;
+ void display() override;
+ void sendCommand(uint8_t com) override { (void)com; }
+ int getBufferOffset(void) override { return 0; }
+
+ // BaseUI public API (same shape as the old EInkDynamicDisplay)
+ bool forceDisplay(uint32_t msecLimit = 1000);
+ void addFrameFlag(frameFlagTypes flag);
+ void joinAsyncRefresh();
+ void enableUnlimitedFastMode() { addFrameFlag(UNLIMITED_FAST); }
+ void disableUnlimitedFastMode() { frameFlags = (frameFlagTypes)(frameFlags & ~UNLIMITED_FAST); }
+
+ // Tuning, called once per panel profile
+ void setDisplayResilience(uint8_t fastPerFull, float stressMultiplier = 2.0f);
+
+ // Exposed so Screen.cpp / variants can read the rotation passed in at construction
+ uint8_t getRotation() const { return rotation; }
+
+ private:
+ // Perform an update now, unconditionally. Returns true if a frame was pushed to the driver.
+ bool commit(Drivers::EInk::UpdateTypes type, bool blocking);
+
+ // Convert OLEDDisplay's column-major buffer into the panel's row-major MSB-left buffer.
+ // Applies rotation. Returns the hash of the panel buffer for frame-skip comparison.
+ uint32_t repack();
+
+ // Decide FULL vs FAST based on current frame flags + accumulated debt.
+ Drivers::EInk::UpdateTypes decide();
+
+ Drivers::EInk *driver = nullptr;
+ uint8_t rotation = 0; // 0=0°, 1=90°CW, 2=180°, 3=270°CW
+ uint8_t *panelBuffer = nullptr;
+ uint32_t panelBufferSize = 0;
+ uint16_t panelRowBytes = 0;
+
+ frameFlagTypes frameFlags = BACKGROUND;
+ uint32_t lastDrawMsec = 0;
+ uint32_t lastHash = 0;
+
+ // DisplayHealth-style debt tracking
+ float fullRefreshDebt = 0.0f;
+ uint8_t fastPerFull = 7;
+ float stressMultiplier = 2.0f;
+};
+
+} // namespace NicheGraphics
+
+// Compat macros used throughout Screen.cpp - route straight to the adapter.
+#define EINK_ADD_FRAMEFLAG(display, flag) \
+ static_cast(display)->addFrameFlag(NicheGraphics::BaseUIEInkDisplay::flag)
+#define EINK_JOIN_ASYNCREFRESH(display) static_cast(display)->joinAsyncRefresh()
+
+#else // !MESHTASTIC_INCLUDE_NICHE_GRAPHICS
+#define EINK_ADD_FRAMEFLAG(display, flag)
+#define EINK_JOIN_ASYNCREFRESH(display)
+#endif
diff --git a/src/graphics/Screen.cpp b/src/graphics/Screen.cpp
index 6db6ba166..50ddc495a 100644
--- a/src/graphics/Screen.cpp
+++ b/src/graphics/Screen.cpp
@@ -29,6 +29,10 @@ along with this program. If not, see .
#if HAS_SCREEN
#include "EInkParallelDisplay.h"
#include
+#if defined(MESHTASTIC_INCLUDE_NICHE_GRAPHICS) && !defined(MESHTASTIC_INCLUDE_INKHUD)
+// Provided by each niche-enabled variant's nicheGraphics.h (defined once, in the main.cpp TU).
+extern NicheGraphics::BaseUIEInkDisplay *setupNicheGraphicsBaseUI();
+#endif
#if defined(USE_HUB75)
#include "graphics/HUB75Display.h" // ESP32 HUB75 (I2S-DMA)
#endif
@@ -607,6 +611,9 @@ Screen::Screen(ScanI2C::DeviceAddress address, meshtastic_Config_DisplayConfig_O
LOG_DEBUG("Make TFTDisplay!");
dispdev = new TFTDisplay(address.address, -1, -1, geometry,
(address.port == ScanI2C::I2CPort::WIRE1) ? HW_I2C::I2C_TWO : HW_I2C::I2C_ONE);
+#elif defined(USE_EINK) && defined(MESHTASTIC_INCLUDE_NICHE_GRAPHICS) && !defined(MESHTASTIC_INCLUDE_INKHUD)
+ // NicheGraphics-backed BaseUI E-Ink path. Variant provides setupNicheGraphicsBaseUI() in its nicheGraphics.h.
+ dispdev = setupNicheGraphicsBaseUI();
#elif defined(USE_EINK) && !defined(USE_EINK_DYNAMICDISPLAY) && !defined(USE_EINK_PARALLELDISPLAY)
dispdev = new EInkDisplay(address.address, -1, -1, geometry,
(address.port == ScanI2C::I2CPort::WIRE1) ? HW_I2C::I2C_TWO : HW_I2C::I2C_ONE);
@@ -886,7 +893,9 @@ void Screen::setup()
// observer can see them.
if (meshtastic_security::shouldRedactDisplay()) {
drawLockdownLockScreen(dispdev);
-#if defined(USE_EINK_PARALLELDISPLAY)
+#if defined(MESHTASTIC_INCLUDE_NICHE_GRAPHICS) && !defined(MESHTASTIC_INCLUDE_INKHUD)
+ static_cast(dispdev)->forceDisplay();
+#elif defined(USE_EINK_PARALLELDISPLAY)
// Parallel-display variants drive refresh through a different path;
// a bare drawLockdownLockScreen above lands the frame into the
// panel buffer and the next ui->update() commits it as normal.
@@ -1047,7 +1056,9 @@ void Screen::forceDisplay(bool forceUiUpdate)
}
// Tell EInk class to update the display
-#if defined(USE_EINK_PARALLELDISPLAY)
+#if defined(MESHTASTIC_INCLUDE_NICHE_GRAPHICS) && !defined(MESHTASTIC_INCLUDE_INKHUD)
+ static_cast(dispdev)->forceDisplay();
+#elif defined(USE_EINK_PARALLELDISPLAY)
static_cast(dispdev)->forceDisplay();
#elif defined(USE_EINK)
static_cast(dispdev)->forceDisplay();
@@ -1249,7 +1260,8 @@ int32_t Screen::runOnce()
// If an E-Ink display struggles with fast refresh, force carousel to use full refresh instead
// Carousel is potentially a major source of E-Ink display wear
-#if !defined(EINK_BACKGROUND_USES_FAST)
+ // (NicheGraphics BaseUI variants leave this to the shared DisplayHealth model instead)
+#if !defined(EINK_BACKGROUND_USES_FAST) && !defined(MESHTASTIC_INCLUDE_NICHE_GRAPHICS)
EINK_ADD_FRAMEFLAG(dispdev, COSMETIC);
#endif
@@ -1286,7 +1298,8 @@ void Screen::setScreensaverFrames(FrameCallback einkScreensaver)
static FrameCallback screensaverFrame;
static OverlayCallback screensaverOverlay;
-#if defined(HAS_EINK_ASYNCFULL) && defined(USE_EINK_DYNAMICDISPLAY)
+#if (defined(HAS_EINK_ASYNCFULL) && defined(USE_EINK_DYNAMICDISPLAY)) || \
+ (defined(MESHTASTIC_INCLUDE_NICHE_GRAPHICS) && !defined(MESHTASTIC_INCLUDE_INKHUD))
// Join (await) a currently running async refresh, then run the post-update code.
// Avoid skipping of screensaver frame. Would otherwise be handled by NotifiedWorkerThread.
EINK_JOIN_ASYNCREFRESH(dispdev);
@@ -1313,7 +1326,9 @@ void Screen::setScreensaverFrames(FrameCallback einkScreensaver)
updateUiFrame(ui);
} while (ui->getUiState()->lastUpdate < startUpdate);
-#if defined(USE_EINK_PARALLELDISPLAY)
+#if defined(MESHTASTIC_INCLUDE_NICHE_GRAPHICS) && !defined(MESHTASTIC_INCLUDE_INKHUD)
+ static_cast(dispdev)->forceDisplay(0);
+#elif defined(USE_EINK_PARALLELDISPLAY)
static_cast(dispdev)->forceDisplay(0);
#elif defined(USE_EINK) && !defined(USE_EINK_DYNAMICDISPLAY)
// Old EInkDisplay class
@@ -1328,7 +1343,7 @@ void Screen::setScreensaverFrames(FrameCallback einkScreensaver)
#ifdef EINK_HASQUIRK_GHOSTING
EINK_ADD_FRAMEFLAG(dispdev, COSMETIC); // Really ugly to see ghosting from "screen paused"
#else
- EINK_ADD_FRAMEFLAG(dispdev, RESPONSIVE); // Really nice to wake screen with a fast-refresh
+ EINK_ADD_FRAMEFLAG(dispdev, RESPONSIVE); // Really nice to wake screen with a fast-refresh
#endif
}
#endif
diff --git a/src/graphics/Screen.h b/src/graphics/Screen.h
index 997ec5d57..d3b061482 100644
--- a/src/graphics/Screen.h
+++ b/src/graphics/Screen.h
@@ -108,8 +108,15 @@ class Screen
#include
#endif
+#if defined(MESHTASTIC_INCLUDE_NICHE_GRAPHICS) && !defined(MESHTASTIC_INCLUDE_INKHUD)
+// NicheGraphics-backed BaseUI e-ink stack; supplies the EINK_* compat macros for converted variants.
+// InkHUD builds keep the legacy includes: their TUs carry InkHUD's own NicheGraphics::Drivers classes,
+// which would collide with graphics/eink/ declarations until InkHUD moves onto the shared layer.
+#include "BaseUIEInkDisplay.h"
+#else
#include "EInkDisplay2.h"
#include "EInkDynamicDisplay.h"
+#endif
#include "PointStruct.h"
#include "Power.h"
#include "TFTDisplay.h"
diff --git a/src/graphics/eink/Backlight/LatchingBacklight.cpp b/src/graphics/eink/Backlight/LatchingBacklight.cpp
new file mode 100644
index 000000000..ad92e28ea
--- /dev/null
+++ b/src/graphics/eink/Backlight/LatchingBacklight.cpp
@@ -0,0 +1,108 @@
+#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS
+
+#include "./LatchingBacklight.h"
+
+#include "assert.h"
+
+#include "sleep.h"
+
+using namespace NicheGraphics::Drivers;
+
+// Private constructor
+// Called by getInstance
+LatchingBacklight::LatchingBacklight()
+{
+ // Attach the deep sleep callback
+ deepSleepObserver.observe(¬ifyDeepSleep);
+}
+
+// Get access to (or create) the singleton instance of this class
+LatchingBacklight *LatchingBacklight::getInstance()
+{
+ // Instantiate the class the first time this method is called
+ static LatchingBacklight *const singletonInstance = new LatchingBacklight;
+
+ return singletonInstance;
+}
+
+// Which pin controls the backlight?
+// Is the light active HIGH (default) or active LOW?
+void LatchingBacklight::setPin(uint8_t pin, bool activeWhen)
+{
+ this->pin = pin;
+ this->logicActive = activeWhen;
+
+ pinMode(pin, OUTPUT);
+ off(); // Explicit off seem required by T-Echo?
+}
+
+// Called when device is shutting down
+// Ensures the backlight is off
+int LatchingBacklight::beforeDeepSleep(void *unused)
+{
+ // Contingency only
+ // - pin wasn't set
+ if (pin != static_cast(-1)) {
+ off();
+ pinMode(pin, INPUT); // High impedance - unnecessary?
+ } else
+ LOG_WARN("LatchingBacklight instantiated, but pin not set");
+ return 0; // Continue with deep sleep
+}
+
+// Turn the backlight on *temporarily*
+// This should be used for momentary illumination, such as while a button is held
+// The effect on the backlight is the same; peek and latch are separated to simplify short vs long press button handling
+void LatchingBacklight::peek()
+{
+ assert(pin != static_cast(-1));
+ digitalWrite(pin, logicActive); // On
+ on = true;
+ latched = false;
+}
+
+// Turn the backlight on, and keep it on
+// This should be used when the backlight should remain active, even after user input ends
+// e.g. when enabled via the menu
+// The effect on the backlight is the same; peek and latch are separated to simplify short vs long press button handling
+void LatchingBacklight::latch()
+{
+ assert(pin != static_cast(-1));
+
+ // Blink if moving from peek to latch
+ // Indicates to user that the transition has taken place
+ if (on && !latched) {
+ digitalWrite(pin, !logicActive); // Off
+ delay(25);
+ digitalWrite(pin, logicActive); // On
+ delay(25);
+ digitalWrite(pin, !logicActive); // Off
+ delay(25);
+ }
+
+ digitalWrite(pin, logicActive); // On
+ on = true;
+ latched = true;
+}
+
+// Turn the backlight off
+// Suitable for ending both peek and latch
+void LatchingBacklight::off()
+{
+ assert(pin != static_cast(-1));
+ digitalWrite(pin, !logicActive); // Off
+ on = false;
+ latched = false;
+}
+
+bool LatchingBacklight::isOn()
+{
+ return on;
+}
+
+bool LatchingBacklight::isLatched()
+{
+ return latched;
+}
+
+#endif
diff --git a/src/graphics/eink/Backlight/LatchingBacklight.h b/src/graphics/eink/Backlight/LatchingBacklight.h
new file mode 100644
index 000000000..87862ea1b
--- /dev/null
+++ b/src/graphics/eink/Backlight/LatchingBacklight.h
@@ -0,0 +1,50 @@
+/*
+
+ Singleton class
+ On-demand control of a display's backlight, connected to a GPIO
+ Initial use case is control of T-Echo's frontlight, via the capacitive touch button
+
+ - momentary on
+ - latched on
+
+*/
+
+#pragma once
+
+#include "configuration.h"
+
+#include "Observer.h"
+
+namespace NicheGraphics::Drivers
+{
+
+class LatchingBacklight
+{
+ public:
+ static LatchingBacklight *getInstance(); // Create or get the singleton instance
+ void setPin(uint8_t pin, bool activeWhen = HIGH);
+
+ int beforeDeepSleep(void *unused); // Callback for auto-shutoff
+
+ void peek(); // Backlight on temporarily, e.g. while button held
+ void latch(); // Backlight on permanently, e.g. toggled via menu
+ void off(); // Backlight off. Suitable for both peek and latch
+
+ bool isOn(); // Either peek or latch
+ bool isLatched();
+
+ private:
+ LatchingBacklight(); // Constructor made private: force use of getInstance
+
+ // Get notified when the system is shutting down
+ CallbackObserver deepSleepObserver =
+ CallbackObserver(this, &LatchingBacklight::beforeDeepSleep);
+
+ uint8_t pin = static_cast(-1);
+ bool logicActive = HIGH; // Is light active HIGH or active LOW
+
+ bool on = false; // Is light on (either peek or latched)
+ bool latched = false; // Is light latched on
+};
+
+} // namespace NicheGraphics::Drivers
\ No newline at end of file
diff --git a/src/graphics/eink/Drivers/DEPG0213BNS800.cpp b/src/graphics/eink/Drivers/DEPG0213BNS800.cpp
new file mode 100644
index 000000000..2c8df96ed
--- /dev/null
+++ b/src/graphics/eink/Drivers/DEPG0213BNS800.cpp
@@ -0,0 +1,132 @@
+#include "./DEPG0213BNS800.h"
+
+#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS
+
+using namespace NicheGraphics::Drivers;
+
+// Describes the operation performed when a "fast refresh" is performed
+// Source: Modified from GxEPD2 (GxEPD2_213_BN)
+static const uint8_t LUT_FAST[] = {
+ // 1 2 3
+ 0x00, 0x40, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // B2B (Existing black pixels)
+ 0x80, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // B2W (New white pixels)
+ 0x40, 0x40, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // W2B (New black pixels)
+ 0x00, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // W2W (Existing white pixels)
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // VCOM
+
+ 0x07, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, // 1. Any pixels changing W2B or B2W. Two medium taps.
+ 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // 2. All pixels. One short tap.
+ 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // 3. Cooldown
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, //
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, //
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, //
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, //
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, //
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, //
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, //
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, //
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, //
+ 0x22, 0x22, 0x22, 0x22, 0x22, 0x22, 0x00, 0x00, 0x00, //
+};
+
+// How strongly the pixels are pulled and pushed
+void DEPG0213BNS800::configVoltages()
+{
+ switch (updateType) {
+ case FAST:
+ // Reference: display datasheet, GxEPD1
+ sendCommand(0x03); // Gate voltage
+ sendData(0x17); // VGH: 20V
+
+ // Reference: display datasheet, GxEPD1
+ sendCommand(0x04); // Source voltage
+ sendData(0x41); // VSH1: 15V
+ sendData(0x00); // VSH2: NA
+ sendData(0x32); // VSL: -15V
+
+ // GxEPD1 sets this at -1.2V, but that seems to be drive the pixels very hard
+ sendCommand(0x2C); // VCOM voltage
+ sendData(0x08); // VCOM: -0.2V
+ break;
+
+ case FULL:
+ default:
+ // From OTP memory
+ break;
+ }
+}
+
+// Load settings about how the pixels are moved from old state to new state during a refresh
+// - manually specified,
+// - or with stored values from displays OTP memory
+void DEPG0213BNS800::configWaveform()
+{
+ switch (updateType) {
+ case FAST:
+ sendCommand(0x3C); // Border waveform:
+ sendData(0x80); // VSS
+
+ sendCommand(0x32); // Write LUT register from MCU:
+ sendData(LUT_FAST, sizeof(LUT_FAST)); // (describes operation for a FAST refresh)
+ break;
+
+ case FULL:
+ default:
+ // From OTP memory
+ break;
+ }
+}
+
+// Describes the sequence of events performed by the displays controller IC during a refresh
+// Includes "power up", "load settings from memory", "update the pixels", etc
+void DEPG0213BNS800::configUpdateSequence()
+{
+ switch (updateType) {
+ case FAST:
+ sendCommand(0x22); // Set "update sequence"
+ sendData(0xCF); // Differential, use manually loaded waveform
+ break;
+
+ case FULL:
+ default:
+ sendCommand(0x22); // Set "update sequence"
+ sendData(0xF7); // Non-differential, load waveform from OTP
+ break;
+ }
+}
+
+// Once the refresh operation has been started,
+// begin periodically polling the display to check for completion, using the normal Meshtastic threading code
+// Only used when refresh is "async"
+void DEPG0213BNS800::detachFromUpdate()
+{
+ switch (updateType) {
+ case FAST:
+ return beginPolling(50, 500); // At least 500ms, then poll every 50ms
+ case FULL:
+ default:
+ return beginPolling(100, 3500); // At least 3500ms, then poll every 100ms
+ }
+}
+
+// For this display, we do not need to re-write the new image.
+// We're overriding SSD16XX::finalizeUpdate to make this small optimization.
+// The display does also work just fine with the generic SSD16XX method, though.
+void DEPG0213BNS800::finalizeUpdate()
+{
+ // Put a copy of the image into the "old memory".
+ // Used with differential refreshes (e.g. FAST update), to determine which px need to move, and which can remain in place
+ // We need to keep the "old memory" up to date, because don't know whether next refresh will be FULL or FAST etc.
+ if (updateType != FULL) {
+ // writeNewImage(); // Not required for this display
+ writeOldImage();
+ sendCommand(0x7F); // Terminate image write without update
+ wait();
+ }
+
+ // Enter deep-sleep to save a few µA
+ // Waking from this requires that display's reset pin is broken out
+ if (pin_rst != 0xFF)
+ deepSleep();
+}
+#endif // MESHTASTIC_INCLUDE_NICHE_GRAPHICS
\ No newline at end of file
diff --git a/src/graphics/eink/Drivers/DEPG0213BNS800.h b/src/graphics/eink/Drivers/DEPG0213BNS800.h
new file mode 100644
index 000000000..e37969edf
--- /dev/null
+++ b/src/graphics/eink/Drivers/DEPG0213BNS800.h
@@ -0,0 +1,44 @@
+/*
+
+E-Ink display driver
+ - DEPG0213BNS800
+ - Manufacturer: DKE
+ - Size: 2.13 inch
+ - Resolution: 122px x 250px
+ - Flex connector marking (not a unique identifier): FPC-7528B
+
+ Note: this is from an older generation of DKE panels, which still used Solomon Systech controller ICs.
+ DKE's website suggests that the latest DEPG0213BN displays may use Fitipower controllers instead.
+*/
+
+#pragma once
+
+#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS
+
+#include "configuration.h"
+
+#include "./SSD16XX.h"
+
+namespace NicheGraphics::Drivers
+{
+class DEPG0213BNS800 : public SSD16XX
+{
+ // Display properties
+ private:
+ static constexpr uint32_t width = 122;
+ static constexpr uint32_t height = 250;
+ static constexpr UpdateTypes supported = (UpdateTypes)(FULL | FAST);
+
+ public:
+ DEPG0213BNS800() : SSD16XX(width, height, supported, 1) {} // Note: left edge of this display is offset by 1 byte
+
+ protected:
+ void configVoltages() override;
+ void configWaveform() override;
+ void configUpdateSequence() override;
+ void detachFromUpdate() override;
+ void finalizeUpdate() override; // Only overridden for a slight optimization
+};
+
+} // namespace NicheGraphics::Drivers
+#endif // MESHTASTIC_INCLUDE_NICHE_GRAPHICS
diff --git a/src/graphics/eink/Drivers/DEPG0290BNS800.cpp b/src/graphics/eink/Drivers/DEPG0290BNS800.cpp
new file mode 100644
index 000000000..15134d5ad
--- /dev/null
+++ b/src/graphics/eink/Drivers/DEPG0290BNS800.cpp
@@ -0,0 +1,125 @@
+#include "./DEPG0290BNS800.h"
+
+#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS
+
+using namespace NicheGraphics::Drivers;
+
+// Describes the operation performed when a "fast refresh" is performed
+// Source: custom, with DEPG0150BNS810 as a reference
+static const uint8_t LUT_FAST[] = {
+ // 1 2 3 4
+ 0x40, 0x00, 0x40, 0x40, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // B2B (Existing black pixels)
+ 0x00, 0x80, 0x80, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // B2W (New white pixels)
+ 0x00, 0x40, 0x40, 0x40, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // W2B (New black pixels)
+ 0x00, 0x00, 0x00, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // W2W (Existing white pixels)
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // VCOM
+
+ 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // 1. Tap existing black pixels back into place
+ 0x06, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // 2. Move new pixels
+ 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // 3. New pixels, and also existing black pixels
+ 0x02, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, // 4. All pixels, then cooldown
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, //
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, //
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, //
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, //
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, //
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, //
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, //
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, //
+
+ 0x22, 0x22, 0x22, 0x22, 0x22, 0x22, 0x00, 0x00, 0x00,
+};
+
+// How strongly the pixels are pulled and pushed
+void DEPG0290BNS800::configVoltages()
+{
+ switch (updateType) {
+ case FAST:
+ // Listed as "typical" in datasheet
+ sendCommand(0x04);
+ sendData(0x41); // VSH1 15V
+ sendData(0x00); // VSH2 NA
+ sendData(0x32); // VSL -15V
+ break;
+
+ case FULL:
+ default:
+ // From OTP memory
+ break;
+ }
+}
+
+// Load settings about how the pixels are moved from old state to new state during a refresh
+// - manually specified,
+// - or with stored values from displays OTP memory
+void DEPG0290BNS800::configWaveform()
+{
+ switch (updateType) {
+ case FAST:
+ sendCommand(0x3C); // Border waveform:
+ sendData(0x60); // Actively hold screen border during update
+
+ sendCommand(0x32); // Write LUT register from MCU:
+ sendData(LUT_FAST, sizeof(LUT_FAST)); // (describes operation for a FAST refresh)
+ break;
+
+ case FULL:
+ default:
+ // From OTP memory
+ break;
+ }
+}
+
+// Describes the sequence of events performed by the displays controller IC during a refresh
+// Includes "power up", "load settings from memory", "update the pixels", etc
+void DEPG0290BNS800::configUpdateSequence()
+{
+ switch (updateType) {
+ case FAST:
+ sendCommand(0x22); // Set "update sequence"
+ sendData(0xCF); // Differential, use manually loaded waveform
+ break;
+
+ case FULL:
+ default:
+ sendCommand(0x22); // Set "update sequence"
+ sendData(0xF7); // Non-differential, load waveform from OTP
+ break;
+ }
+}
+
+// Once the refresh operation has been started,
+// begin periodically polling the display to check for completion, using the normal Meshtastic threading code
+// Only used when refresh is "async"
+void DEPG0290BNS800::detachFromUpdate()
+{
+ switch (updateType) {
+ case FAST:
+ return beginPolling(50, 450); // At least 450ms for fast refresh
+ case FULL:
+ default:
+ return beginPolling(100, 3000); // At least 3 seconds for full refresh
+ }
+}
+
+// For this display, we do not need to re-write the new image.
+// We're overriding SSD16XX::finalizeUpdate to make this small optimization.
+// The display does also work just fine with the generic SSD16XX method, though.
+void DEPG0290BNS800::finalizeUpdate()
+{
+ // Put a copy of the image into the "old memory".
+ // Used with differential refreshes (e.g. FAST update), to determine which px need to move, and which can remain in place
+ // We need to keep the "old memory" up to date, because don't know whether next refresh will be FULL or FAST etc.
+ if (updateType != FULL) {
+ // writeNewImage(); // Not required for this display
+ writeOldImage();
+ sendCommand(0x7F); // Terminate image write without update
+ wait();
+ }
+
+ // Enter deep-sleep to save a few µA
+ // Waking from this requires that display's reset pin is broken out
+ if (pin_rst != 0xFF)
+ deepSleep();
+}
+#endif // MESHTASTIC_INCLUDE_NICHE_GRAPHICS
\ No newline at end of file
diff --git a/src/graphics/eink/Drivers/DEPG0290BNS800.h b/src/graphics/eink/Drivers/DEPG0290BNS800.h
new file mode 100644
index 000000000..761cf772a
--- /dev/null
+++ b/src/graphics/eink/Drivers/DEPG0290BNS800.h
@@ -0,0 +1,42 @@
+/*
+
+E-Ink display driver
+ - DEPG0290BNS800
+ - Manufacturer: DKE
+ - Size: 2.9 inch
+ - Resolution: 128px x 296px
+ - Flex connector marking (not a unique identifier): FPC-7519 rev.b
+
+*/
+
+#pragma once
+
+#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS
+
+#include "configuration.h"
+
+#include "./SSD16XX.h"
+
+namespace NicheGraphics::Drivers
+{
+class DEPG0290BNS800 : public SSD16XX
+{
+ // Display properties
+ private:
+ static constexpr uint32_t width = 128;
+ static constexpr uint32_t height = 296;
+ static constexpr UpdateTypes supported = (UpdateTypes)(FULL | FAST);
+
+ public:
+ DEPG0290BNS800() : SSD16XX(width, height, supported, 1) {} // Note: left edge of this display is offset by 1 byte
+
+ protected:
+ void configVoltages() override;
+ void configWaveform() override;
+ void configUpdateSequence() override;
+ void detachFromUpdate() override;
+ void finalizeUpdate() override; // Only overridden for a slight optimization
+};
+
+} // namespace NicheGraphics::Drivers
+#endif // MESHTASTIC_INCLUDE_NICHE_GRAPHICS
diff --git a/src/graphics/eink/Drivers/E0213A367.cpp b/src/graphics/eink/Drivers/E0213A367.cpp
new file mode 100644
index 000000000..f19cb4ff7
--- /dev/null
+++ b/src/graphics/eink/Drivers/E0213A367.cpp
@@ -0,0 +1,84 @@
+#include "./E0213A367.h"
+
+#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS
+
+using namespace NicheGraphics::Drivers;
+
+// Map the display controller IC's output to the connected panel
+void E0213A367::configScanning()
+{
+ // "Driver output control"
+ // Scan gates from 0 to 249 (vertical resolution 250px)
+ sendCommand(0x01);
+ sendData(0xF9);
+ sendData(0x00);
+}
+
+// Specify which information is used to control the sequence of voltages applied to move the pixels
+void E0213A367::configWaveform()
+{
+ // This command (0x37) is poorly documented
+ // As of July 2025, the datasheet for this display's controller IC is unavailable
+ // The values are supplied by Heltec, who presumably have privileged access to information from the display manufacturer
+ // Datasheet for the similar SSD1680 IC hints at the function of this command:
+
+ // "Spare VCOM OTP selection":
+ // Unclear why 0x40 is set. Sane values for related SSD1680 seem to be 0x80 or 0x00.
+ // Maybe value is redundant? No noticeable impact when set to 0x00.
+ // We'll leave it set to 0x40, following Heltec's lead, just in case.
+
+ // "Display Mode"
+ // Seems to specify whether a waveform stored in OTP should use display mode 1 or 2 (full refresh or differential refresh)
+
+ // Unusual that waveforms are programmed to OTP, but this meta information is not ..?
+
+ sendCommand(0x37); // "Write Register for Display Option" ?
+ sendData(0x40); // "Spare VCOM OTP selection" ?
+ sendData(0x80); // "Display Mode for WS[7:0]" ?
+ sendData(0x03); // "Display Mode for WS[15:8]" ?
+ sendData(0x0E); // "Display Mode [23:16]" ?
+
+ switch (updateType) {
+ case FAST:
+ sendCommand(0x3C); // Border waveform:
+ sendData(0x81); // As specified by Heltec. Actually VCOM (0x80)?. Bit 0 seems redundant here.
+ break;
+ case FULL:
+ default:
+ sendCommand(0x3C); // Border waveform:
+ sendData(0x01); // Follow LUT 1 (blink same as white pixels)
+ break;
+ }
+}
+
+// Tell controller IC which operations to run
+void E0213A367::configUpdateSequence()
+{
+ switch (updateType) {
+ case FAST:
+ sendCommand(0x22); // Set "update sequence"
+ sendData(0xFF); // Will load LUT from OTP memory, Display mode 2 "differential refresh"
+ break;
+ case FULL:
+ default:
+ sendCommand(0x22); // Set "update sequence"
+ sendData(0xF7); // Will load LUT from OTP memory, Display mode 1 "full refresh"
+ break;
+ }
+}
+
+// Once the refresh operation has been started,
+// begin periodically polling the display to check for completion, using the normal Meshtastic threading code
+// Only used when refresh is "async"
+void E0213A367::detachFromUpdate()
+{
+ switch (updateType) {
+ case FAST:
+ return beginPolling(50, 500); // At least 500ms for fast refresh
+ case FULL:
+ default:
+ return beginPolling(100, 1500); // At least 1.5 seconds for full refresh
+ }
+}
+
+#endif // MESHTASTIC_INCLUDE_NICHE_GRAPHICS
\ No newline at end of file
diff --git a/src/graphics/eink/Drivers/E0213A367.h b/src/graphics/eink/Drivers/E0213A367.h
new file mode 100644
index 000000000..1397f99f8
--- /dev/null
+++ b/src/graphics/eink/Drivers/E0213A367.h
@@ -0,0 +1,41 @@
+/*
+
+E-Ink display driver
+ - E0213A367
+ - Manufacturer: SEEKINK
+ - Size: 2.13 inch
+ - Resolution: 122px x 250px
+ - Flex connector marking: HINK-E0213A162-A1 (hidden, printed on reverse)
+
+*/
+
+#pragma once
+
+#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS
+
+#include "configuration.h"
+
+#include "./SSD1682.h"
+
+namespace NicheGraphics::Drivers
+{
+class E0213A367 : public SSD1682
+{
+ // Display properties
+ private:
+ static constexpr uint32_t width = 122;
+ static constexpr uint32_t height = 250;
+ static constexpr UpdateTypes supported = (UpdateTypes)(FULL | FAST);
+
+ public:
+ E0213A367() : SSD1682(width, height, supported, 0) {}
+
+ protected:
+ void configScanning() override;
+ void configWaveform() override;
+ void configUpdateSequence() override;
+ void detachFromUpdate() override;
+};
+
+} // namespace NicheGraphics::Drivers
+#endif // MESHTASTIC_INCLUDE_NICHE_GRAPHICS
\ No newline at end of file
diff --git a/src/graphics/eink/Drivers/ED047TC1.cpp b/src/graphics/eink/Drivers/ED047TC1.cpp
new file mode 100644
index 000000000..d637f0396
--- /dev/null
+++ b/src/graphics/eink/Drivers/ED047TC1.cpp
@@ -0,0 +1,226 @@
+/*
+
+ NicheGraphics parallel E-Ink driver for the LilyGo T5-S3-ePaper-Pro (ED047TC1).
+
+ InkHUD buffer format : 1bpp, horizontal bytes, MSB = leftmost pixel, 1 = white
+ FastEPD buffer format: 1bpp, horizontal bytes, MSB = leftmost pixel, 1 = white
+
+ Both formats share the same pixel layout and polarity (1 = white, 0 = black).
+ The InkHUD safe-area buffer (928×508) is copied into the centre of the physical
+ 960×540 FastEPD buffer so content clears the panel's inactive edge border.
+ See ED047TC1.h for the H_OFFSET_BYTES / V_OFFSET_TOP / V_OFFSET_BOTTOM constants.
+
+*/
+
+#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS
+#ifdef T5_S3_EPAPER_PRO
+
+#include "./ED047TC1.h"
+
+#include "FastEPD.h"
+#include "configuration.h"
+
+using namespace NicheGraphics::Drivers;
+
+#if defined(T5_S3_EPAPER_PRO_V2)
+// FastEPD helper symbols are defined in FastEPD.inl with C++ linkage.
+extern void bbepPCA9535DigitalWrite(uint8_t pin, uint8_t value);
+extern uint8_t bbepPCA9535DigitalRead(uint8_t pin);
+extern int bbepI2CWrite(unsigned char iAddr, unsigned char *pData, int iLen);
+extern int bbepI2CReadRegister(unsigned char iAddr, unsigned char u8Register, unsigned char *pData, int iLen);
+#endif
+
+namespace
+{
+#if defined(T5_S3_EPAPER_PRO_V2)
+// FastEPD default V2 power callback blocks forever waiting for PWRGOOD.
+// Replace it with a timeout-safe version so boot never deadlocks.
+int safeEPDiyV7EinkPower(void *pBBEP, int bOn)
+{
+ static bool warnedPgood = false;
+ static bool warnedTpsPg = false;
+ static bool warnedTpsWrite = false;
+
+ FASTEPDSTATE *pState = static_cast(pBBEP);
+ if (!pState) {
+ return BBEP_ERROR_BAD_PARAMETER;
+ }
+
+ if (bOn == pState->pwr_on) {
+ return BBEP_SUCCESS;
+ }
+
+ if (bOn) {
+ bbepPCA9535DigitalWrite(8, 1); // OE on
+ bbepPCA9535DigitalWrite(9, 1); // GMOD on
+ bbepPCA9535DigitalWrite(13, 1); // WAKEUP on
+ bbepPCA9535DigitalWrite(11, 1); // PWRUP on
+ bbepPCA9535DigitalWrite(12, 1); // VCOM CTRL on
+ delay(1);
+
+ const uint32_t pgoodStart = millis();
+ bool pgoodSeen = false;
+ while (!bbepPCA9535DigitalRead(14)) { // CFG_PIN_PWRGOOD
+ if ((millis() - pgoodStart) > 1200) {
+ if (!warnedPgood) {
+ LOG_WARN("ED047TC1: PWRGOOD timeout, continuing with fallback power-on path");
+ warnedPgood = true;
+ }
+ break;
+ }
+ delay(1);
+ }
+ if (bbepPCA9535DigitalRead(14)) {
+ pgoodSeen = true;
+ }
+
+ uint8_t ucTemp[4] = {0};
+ ucTemp[0] = 0x01; // TPS_REG_ENABLE
+ ucTemp[1] = 0x3f; // enable rails
+ const int tpsEnableRc = bbepI2CWrite(0x68, ucTemp, 2);
+
+ const int vcom = pState->iVCOM / -10;
+ ucTemp[0] = 3; // VCOM registers 3+4 (L + H)
+ ucTemp[1] = static_cast(vcom);
+ ucTemp[2] = static_cast(vcom >> 8);
+ const int tpsVcomRc = bbepI2CWrite(0x68, ucTemp, 3);
+ // bbepI2CWrite returns 0 on success
+ if ((tpsEnableRc != 0 || tpsVcomRc != 0) && !warnedTpsWrite) {
+ LOG_WARN("ED047TC1: TPS write did not ACK, continuing with fallback");
+ warnedTpsWrite = true;
+ }
+
+ int iTimeout = 0;
+ uint8_t u8Value = 0;
+ while (iTimeout < 220 && ((u8Value & 0xfa) != 0xfa)) {
+ bbepI2CReadRegister(0x68, 0x0F, &u8Value, 1); // TPS_REG_PG
+ iTimeout++;
+ delay(1);
+ }
+ if (iTimeout >= 220 && !warnedTpsPg) {
+ if (pgoodSeen) {
+ LOG_WARN("ED047TC1: TPS power-good register timeout, panel may still work");
+ } else {
+ LOG_WARN("ED047TC1: TPS power-good register timeout after PWRGOOD fallback");
+ }
+ warnedTpsPg = true;
+ }
+
+ pState->pwr_on = 1;
+ } else {
+ bbepPCA9535DigitalWrite(8, 0); // OE off
+ bbepPCA9535DigitalWrite(9, 0); // GMOD off
+ bbepPCA9535DigitalWrite(11, 0); // PWRUP off
+ bbepPCA9535DigitalWrite(12, 0); // VCOM CTRL off
+ delay(1);
+ bbepPCA9535DigitalWrite(13, 0); // WAKEUP off
+ pState->pwr_on = 0;
+ }
+
+ return BBEP_SUCCESS;
+}
+#endif
+
+class SafeFastEPD : public FASTEPD
+{
+ public:
+ void installSafePowerHandler()
+ {
+#if defined(T5_S3_EPAPER_PRO_V2)
+ _state.pfnEinkPower = safeEPDiyV7EinkPower;
+#endif
+ }
+};
+} // namespace
+
+void ED047TC1::begin(SPIClass *spi, uint8_t pin_dc, uint8_t pin_cs, uint8_t pin_busy, uint8_t pin_rst)
+{
+ // Parallel display - SPI parameters are not used
+ (void)spi;
+ (void)pin_dc;
+ (void)pin_cs;
+ (void)pin_busy;
+ (void)pin_rst;
+
+ SafeFastEPD *safeEpaper = new SafeFastEPD;
+ epaper = safeEpaper;
+
+ int initRc = BBEP_ERROR_BAD_PARAMETER;
+#if defined(T5_S3_EPAPER_PRO_V1)
+ initRc = epaper->initPanel(BB_PANEL_LILYGO_T5PRO, 28000000);
+#elif defined(T5_S3_EPAPER_PRO_V2)
+ initRc = epaper->initPanel(BB_PANEL_LILYGO_T5PRO_V2, 28000000);
+ // Initialize all PCA9535 port-0 pins as outputs / HIGH
+ for (int i = 0; i < 8; i++) {
+ epaper->ioPinMode(i, OUTPUT);
+ epaper->ioWrite(i, HIGH);
+ }
+ // On this board, the physical side key is labeled IO48; electrically it maps to PCA9535 IO12 (bit 2 on port-1).
+ // FastEPD's generic V7 init drives 8..13 as outputs; force IO12 back to input
+ // so variant touch-control polling can read the key reliably.
+ epaper->ioPinMode(10, INPUT);
+#else
+#error "ED047TC1 driver: unsupported variant - define T5_S3_EPAPER_PRO_V1 or T5_S3_EPAPER_PRO_V2"
+#endif
+
+ if (initRc != BBEP_SUCCESS || epaper->currentBuffer() == nullptr) {
+ LOG_ERROR("ED047TC1 initPanel failed rc=%d; running headless", initRc);
+ delete epaper;
+ epaper = nullptr;
+ return;
+ }
+
+ safeEpaper->installSafePowerHandler();
+
+ const int modeRc = epaper->setMode(BB_MODE_1BPP);
+ if (modeRc != BBEP_SUCCESS) {
+ LOG_WARN("ED047TC1 setMode failed rc=%d", modeRc);
+ }
+
+ const int clearRc = epaper->clearWhite();
+ if (clearRc != BBEP_SUCCESS) {
+ LOG_WARN("ED047TC1 clearWhite failed rc=%d", clearRc);
+ }
+
+ const int fullRc = epaper->fullUpdate(true); // Blocking initial clear
+ if (fullRc != BBEP_SUCCESS) {
+ LOG_WARN("ED047TC1 initial fullUpdate failed rc=%d", fullRc);
+ }
+}
+
+void ED047TC1::update(uint8_t *imageData, UpdateTypes type)
+{
+ if (!epaper)
+ return;
+
+ // InkHUD renders into a DISPLAY_WIDTH × DISPLAY_HEIGHT safe-area buffer.
+ // We need to place that into the centre of the physical 960×540 FastEPD buffer,
+ // leaving blank margins at every edge to avoid the panel's inactive border.
+ const uint32_t srcRowBytes = (DISPLAY_WIDTH + 7) / 8; // bytes per row in InkHUD buffer (116)
+ const uint32_t dstRowBytes = (960 + 7) / 8; // bytes per row in physical buffer (120)
+ const uint32_t dstTotalRows = 540;
+
+ uint8_t *cur = epaper->currentBuffer();
+
+ // Fill physical buffer with white (0xFF = white in FastEPD 1bpp)
+ memset(cur, 0xFF, dstRowBytes * dstTotalRows);
+
+ // Copy each InkHUD row into the physical buffer with horizontal + vertical offsets
+ for (uint32_t row = 0; row < DISPLAY_HEIGHT; row++) {
+ const uint8_t *srcRow = imageData + row * srcRowBytes;
+ uint8_t *dstRow = cur + (row + V_OFFSET_TOP) * dstRowBytes + H_OFFSET_BYTES;
+ memcpy(dstRow, srcRow, srcRowBytes);
+ }
+
+ if (type == FULL) {
+ epaper->fullUpdate(CLEAR_SLOW, false);
+ epaper->backupPlane(); // Sync pPrevious so next partialUpdate has a correct baseline
+ } else {
+ // FAST: true partial update - compares pCurrent vs pPrevious and only applies
+ // update waveform to rows that changed. partialUpdate() updates pPrevious.
+ epaper->partialUpdate(false, 0, dstTotalRows - 1);
+ }
+}
+
+#endif // T5_S3_EPAPER_PRO
+#endif // MESHTASTIC_INCLUDE_NICHE_GRAPHICS
diff --git a/src/graphics/eink/Drivers/ED047TC1.h b/src/graphics/eink/Drivers/ED047TC1.h
new file mode 100644
index 000000000..bec11deff
--- /dev/null
+++ b/src/graphics/eink/Drivers/ED047TC1.h
@@ -0,0 +1,90 @@
+/*
+
+ E-Ink display driver adapter
+ - ED047TC1 (via FastEPD library)
+ - Manufacturer: E Ink / used in LilyGo T5-E-Paper-S3-Pro
+ - Size: 4.7 inch
+ - Physical resolution: 960px x 540px
+ - Interface: 8-bit parallel (NOT SPI)
+
+ Unlike the other NicheGraphics EInk drivers, this one drives a parallel e-paper
+ panel via the FastEPD library. SPI parameters passed to begin() are ignored.
+
+ The ED047TC1 panel has an inactive pixel border on all four edges (~4-8 physical
+ pixels). DISPLAY_WIDTH / DISPLAY_HEIGHT expose a reduced "safe area" to InkHUD so
+ that content is never drawn into this dead zone. The update() method copies the
+ InkHUD frame buffer into the centre of the larger physical 960×540 buffer, using
+ H_OFFSET_BYTES (horizontal, whole bytes = 8 pixels per byte),
+ V_OFFSET_TOP and V_OFFSET_BOTTOM (vertical, pixel rows) to position it.
+
+ Changing these constants shifts content inward from each physical edge:
+ H_OFFSET_BYTES = 2 → 16px left margin, 16px right margin (960 - 16 - 16 = 928)
+ V_OFFSET_TOP = 16 → 16px top margin
+ V_OFFSET_BOTTOM = 16 → 16px bottom margin (540 - 16 - 16 = 508)
+
+*/
+
+#pragma once
+
+#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS
+
+#include "configuration.h"
+
+#include "./EInk.h"
+
+// Forward declare to avoid pulling FastEPD into all translation units
+class FASTEPD;
+
+namespace NicheGraphics::Drivers
+{
+
+class ED047TC1 : public EInk
+{
+ // Safe-area dimensions exposed to InkHUD (physical panel is 960×540).
+ //
+ // The ED047TC1 has an inactive pixel border on all physical edges.
+ // The physical buffer coordinates do NOT directly match the visual orientation
+ // due to FastEPD's portrait scan direction and InkHUD's rotation=3 (270° CW):
+ //
+ // Physical buffer Visual on device (rotation=3)
+ // ───────────────── ──────────────────────────────
+ // Physical LEFT cols → Visual TOP edge
+ // Physical RIGHT cols → Visual BOTTOM edge
+ // Physical TOP rows → Visual RIGHT edge
+ // Physical BOTTOM rows → Visual LEFT edge
+ //
+ // Offset constants shift the InkHUD safe-area away from each physical dead zone:
+ // H_OFFSET_BYTES : whole bytes from physical left (8px per byte, affects visual TOP)
+ // Physical right margin = 960 − H_OFFSET_BYTES×8 − DISPLAY_WIDTH (affects visual BOTTOM)
+ // V_OFFSET_TOP : pixel rows from physical top (affects visual RIGHT)
+ // V_OFFSET_BOTTOM: pixel rows from physical bottom (affects visual LEFT)
+ //
+ // Calibrated by flashing a 1px border box and adjusting until all 4 sides are visible.
+
+ static constexpr uint16_t DISPLAY_WIDTH = 928; // 960 − H_OFFSET_BYTES×8 − right_margin (16+16 = 32px)
+ static constexpr uint16_t DISPLAY_HEIGHT = 508; // 540 − V_OFFSET_TOP − V_OFFSET_BOTTOM (16+16 = 32px)
+
+ static constexpr uint8_t H_OFFSET_BYTES = 2; // visual TOP : 16px physical left margin
+ // visual BOTTOM: 960−16−928=16px physical right margin
+ static constexpr uint8_t V_OFFSET_TOP = 16; // visual RIGHT : 16px physical top margin
+ static constexpr uint8_t V_OFFSET_BOTTOM = 16; // visual LEFT : 16px physical bottom margin
+
+ static constexpr UpdateTypes supported = static_cast(FULL | FAST);
+
+ public:
+ ED047TC1() : EInk(DISPLAY_WIDTH, DISPLAY_HEIGHT, supported) {}
+
+ // EInk interface - SPI params are not used for this parallel display
+ void begin(SPIClass *spi, uint8_t pin_dc, uint8_t pin_cs, uint8_t pin_busy, uint8_t pin_rst = 0xFF) override;
+ void update(uint8_t *imageData, UpdateTypes type) override;
+
+ protected:
+ bool isUpdateDone() override { return true; } // FastEPD updates are blocking
+
+ private:
+ FASTEPD *epaper = nullptr;
+};
+
+} // namespace NicheGraphics::Drivers
+
+#endif // MESHTASTIC_INCLUDE_NICHE_GRAPHICS
diff --git a/src/graphics/eink/Drivers/EInk.cpp b/src/graphics/eink/Drivers/EInk.cpp
new file mode 100644
index 000000000..cd2e9dc98
--- /dev/null
+++ b/src/graphics/eink/Drivers/EInk.cpp
@@ -0,0 +1,86 @@
+#include "./EInk.h"
+
+#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS
+
+using namespace NicheGraphics::Drivers;
+
+// Separate from EInk::begin method, as derived class constructors can probably supply these parameters as constants
+EInk::EInk(uint16_t width, uint16_t height, UpdateTypes supported)
+ : concurrency::OSThread("EInkDriver"), width(width), height(height), supportedUpdateTypes(supported)
+{
+ OSThread::disable();
+}
+
+// Used by NicheGraphics implementations to check if a display supports a specific refresh operation.
+// Whether or not the update type is supported is specified in the constructor
+bool EInk::supports(UpdateTypes type)
+{
+ // The EInkUpdateTypes enum assigns each type a unique bit. We are checking if that bit is set.
+ if (supportedUpdateTypes & type)
+ return true;
+ else
+ return false;
+}
+
+// Begins using the OSThread to detect when a display update is complete
+// This allows the refresh operation to run "asynchronously".
+// Rather than blocking execution waiting for the update to complete, we are periodically checking the hardware's BUSY pin
+// The expectedDuration argument allows us to delay the start of this checking, if we know "roughly" how long an update takes.
+// Potentially, a display without hardware BUSY could rely entirely on "expectedDuration",
+// provided its isUpdateDone() override always returns true.
+void EInk::beginPolling(uint32_t interval, uint32_t expectedDuration)
+{
+ updateRunning = true;
+ pollingInterval = interval;
+ pollingBegunAt = millis();
+
+ // To minimize load, we can choose to delay polling for a few seconds, if we know roughly how long the update will take
+ // By default, expectedDuration is 0, and we'll start polling immediately
+ OSThread::setIntervalFromNow(expectedDuration);
+ OSThread::enabled = true;
+}
+
+// Meshtastic's pseudo-threading layer
+// We're using this as a timer, to periodically check if an update is complete
+// This is what allows us to update the display asynchronously
+int32_t EInk::runOnce()
+{
+ // Check for polling timeout
+ // Manually set at 10 seconds, in case some big task holds up the firmware's cooperative multitasking
+ if (millis() - pollingBegunAt > 10000)
+ failed = true;
+
+ // Handle failure
+ // - polling timeout
+ // - other error (derived classes)
+ if (failed) {
+ LOG_WARN("Display update failed. Check wiring & power supply.");
+ updateRunning = false;
+ failed = false;
+ return disable();
+ }
+
+ // If update not yet done
+ if (!isUpdateDone())
+ return pollingInterval; // Poll again in a few ms
+
+ // If update done
+ finalizeUpdate(); // Any post-update code: power down panel hardware, hibernate, etc
+ updateRunning = false; // Change what we report via EInk::busy()
+ return disable(); // Stop polling
+}
+
+// Wait for an in progress update to complete before continuing
+// Run a normal (async) update first, *then* call await
+void EInk::await()
+{
+ // Stop our concurrency thread
+ OSThread::disable();
+
+ // Sit and block until the update is complete
+ while (updateRunning) {
+ runOnce();
+ yield();
+ }
+}
+#endif // MESHTASTIC_INCLUDE_NICHE_GRAPHICS
\ No newline at end of file
diff --git a/src/graphics/eink/Drivers/EInk.h b/src/graphics/eink/Drivers/EInk.h
new file mode 100644
index 000000000..3c51d4f1d
--- /dev/null
+++ b/src/graphics/eink/Drivers/EInk.h
@@ -0,0 +1,57 @@
+/*
+
+ Base class for E-Ink display drivers
+
+*/
+
+#pragma once
+
+#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS
+#include "configuration.h"
+
+#include "concurrency/OSThread.h"
+#include
+
+namespace NicheGraphics::Drivers
+{
+
+class EInk : private concurrency::OSThread
+{
+ public:
+ // Different possible operations used to update an E-Ink display
+ // Some displays will not support all operations
+ // Each value needs a unique bit. In some cases, we might set more than one bit (e.g. EInk::supportedUpdateType)
+ enum UpdateTypes : uint8_t {
+ UNSPECIFIED = 0,
+ FULL = 1 << 0,
+ FAST = 1 << 1, // "Partial Refresh"
+ };
+
+ EInk(uint16_t width, uint16_t height, UpdateTypes supported);
+ virtual void begin(SPIClass *spi, uint8_t pin_dc, uint8_t pin_cs, uint8_t pin_busy, uint8_t pin_rst = -1) = 0;
+ virtual void update(uint8_t *imageData, UpdateTypes type) = 0; // Change the display image
+ void await(); // Wait for an in-progress update to complete before proceeding
+ bool supports(UpdateTypes type); // Can display perform a certain update type
+ bool busy() { return updateRunning; } // Display able to update right now?
+
+ const uint16_t width; // Public so that NicheGraphics implementations can access. Safe because const.
+ const uint16_t height;
+
+ protected:
+ void beginPolling(uint32_t interval, uint32_t expectedDuration); // Begin checking repeatedly if update finished
+ virtual bool isUpdateDone() = 0; // Check once if update finished
+ virtual void finalizeUpdate() {} // Run any post-update code
+ bool failed = false; // If an error occurred during update
+
+ private:
+ int32_t runOnce() override; // Repeated checking if update finished
+
+ const UpdateTypes supportedUpdateTypes; // Capabilities of a derived display class
+ bool updateRunning = false; // see EInk::busy()
+ uint32_t pollingInterval = 0; // How often to check if update complete (ms)
+ uint32_t pollingBegunAt = 0; // To timeout during polling
+};
+
+} // namespace NicheGraphics::Drivers
+
+#endif // MESHTASTIC_INCLUDE_NICHE_GRAPHICS
\ No newline at end of file
diff --git a/src/graphics/eink/Drivers/EInkParallel.cpp b/src/graphics/eink/Drivers/EInkParallel.cpp
new file mode 100644
index 000000000..07d002621
--- /dev/null
+++ b/src/graphics/eink/Drivers/EInkParallel.cpp
@@ -0,0 +1,143 @@
+#include "./EInkParallel.h"
+
+#if defined(MESHTASTIC_INCLUDE_NICHE_GRAPHICS) && defined(ARCH_ESP32) && defined(NICHE_HAS_FASTEPD)
+
+#include "FastEPD.h"
+
+using namespace NicheGraphics::Drivers;
+
+EInkParallel::EInkParallel(uint16_t width, uint16_t height, uint32_t panelType, uint32_t panelClock)
+ : EInk(width, height, (UpdateTypes)(FULL | FAST)), panelType(panelType), panelClock(panelClock)
+{
+}
+
+EInkParallel::~EInkParallel()
+{
+ // The refresh task deletes itself; if it never finishes, leak epaper rather than
+ // free state under a live task.
+ for (int i = 0; i < 100 && asyncRunning.load(); ++i)
+ delay(50);
+ if (asyncRunning.load())
+ return;
+ delete epaper;
+}
+
+void EInkParallel::begin(SPIClass *, uint8_t, uint8_t, uint8_t, uint8_t)
+{
+ // Parallel panels don't use the SPI args; FastEPD owns the bus.
+ if (!epaper) {
+ epaper = new FASTEPD;
+ int initRc = epaper->initPanel((int)panelType, panelClock);
+ postPanelInit();
+
+ // FastEPD allocates its framebuffer only from PSRAM; if PSRAM init failed the alloc
+ // returns NULL and initPanel() returns an error, so clearWhite() below would
+ // memset(NULL). panelReady stays false so the update paths no-op -> node runs headless.
+ if (initRc != BBEP_SUCCESS || epaper->currentBuffer() == nullptr) {
+ LOG_ERROR("EPD framebuffer unavailable (initPanel rc=%d, PSRAM=%u); running headless", initRc,
+ (unsigned)ESP.getPsramSize());
+ return;
+ }
+ panelReady = true;
+ epaper->setMode(BB_MODE_1BPP);
+ epaper->clearWhite();
+ epaper->fullUpdate(true);
+ }
+}
+
+void EInkParallel::update(uint8_t *imageData, UpdateTypes type)
+{
+ if (!epaper || !panelReady)
+ return;
+
+ // A running async refresh still reads the framebuffer; defer this frame (caller
+ // polls isUpdateDone() and re-renders).
+ if (asyncRunning.load())
+ return;
+
+ pendingType = type;
+ copyImageInverted(imageData);
+
+ if (type == FULL) {
+ // Pick CLEAR_SLOW periodically to clear ghosting.
+ pendingClearMode = (fastRefreshCount >= FULL_SLOW_PERIOD) ? CLEAR_SLOW : CLEAR_FAST;
+ fastRefreshCount = 0;
+
+ asyncRunning.store(true);
+ BaseType_t rc = xTaskCreatePinnedToCore(asyncFullTask, "epd_full", 4096 / sizeof(StackType_t), this, 2, &asyncTaskHandle,
+#if CONFIG_FREERTOS_UNICORE
+ 0
+#else
+ 1
+#endif
+ );
+ if (rc != pdPASS) {
+ LOG_WARN("Async full failed; running blocking");
+ epaper->fullUpdate(pendingClearMode, false);
+ epaper->backupPlane();
+ asyncRunning.store(false);
+ asyncTaskHandle = nullptr;
+ return; // synchronous: nothing to poll
+ }
+ // Begin polling for completion.
+ beginPolling(100, 1500);
+ } else {
+ // FAST: synchronous partial / clipped fullUpdate. Block briefly here.
+ epaper->fullUpdate(CLEAR_FAST, false);
+ epaper->backupPlane();
+ fastRefreshCount++;
+ // No polling needed; isUpdateDone() will report done immediately.
+ beginPolling(10, 0);
+ }
+}
+
+void EInkParallel::asyncFullTask(void *param)
+{
+ auto *self = static_cast(param);
+ if (!self) {
+ vTaskDelete(nullptr);
+ return;
+ }
+ self->epaper->fullUpdate(self->pendingClearMode, false);
+ self->epaper->backupPlane();
+ // Handle first: once asyncRunning reads false, no other thread may touch task state.
+ self->asyncTaskHandle = nullptr;
+ self->asyncRunning.store(false);
+ vTaskDelete(nullptr);
+}
+
+bool EInkParallel::isUpdateDone()
+{
+ return !asyncRunning.load();
+}
+
+void EInkParallel::finalizeUpdate()
+{
+ pendingType = UpdateTypes::UNSPECIFIED;
+}
+
+// Convert a niche-format buffer (row-major, MSB-left, 1=WHITE) into FastEPD's currentBuffer
+// (row-major, MSB-left, 1=BLACK). Polarity inversion only.
+void EInkParallel::copyImageInverted(const uint8_t *src)
+{
+ uint8_t *dst = epaper->currentBuffer();
+ if (!dst || !src)
+ return;
+
+ const uint16_t rowBytes = ((width - 1) / 8) + 1;
+ const uint32_t total = rowBytes * height;
+
+ // Mask off bits beyond the panel width in the trailing byte of each row.
+ const uint8_t trailingMask = (uint8_t)(0xFFu << ((rowBytes * 8) - width));
+
+ for (uint16_t y = 0; y < height; y++) {
+ const uint32_t base = y * rowBytes;
+ for (uint16_t b = 0; b < rowBytes - 1; b++) {
+ dst[base + b] = ~src[base + b];
+ }
+ dst[base + rowBytes - 1] = (~src[base + rowBytes - 1]) & trailingMask;
+ }
+ (void)total;
+}
+
+#endif // MESHTASTIC_INCLUDE_NICHE_GRAPHICS && ARCH_ESP32 && NICHE_HAS_FASTEPD
diff --git a/src/graphics/eink/Drivers/EInkParallel.h b/src/graphics/eink/Drivers/EInkParallel.h
new file mode 100644
index 000000000..93d60024c
--- /dev/null
+++ b/src/graphics/eink/Drivers/EInkParallel.h
@@ -0,0 +1,73 @@
+/*
+
+Parallel-EPD niche driver, backed by FastEPD.
+
+Used for boards with an 8-bit parallel EPD interface (e.g. LILYGO T5 S3 ePaper).
+The base class signature passes SPI parameters; this driver ignores them and uses FastEPD
+to drive the parallel bus directly.
+
+Gated on NICHE_HAS_FASTEPD because FastEPD is a heavy dependency that only parallel-EPD
+variants want pulled in. Variants opt in by defining NICHE_HAS_FASTEPD in their platformio.ini
+and adding the FastEPD library to lib_deps.
+
+*/
+
+#pragma once
+
+#include "configuration.h"
+
+#if defined(MESHTASTIC_INCLUDE_NICHE_GRAPHICS) && defined(ARCH_ESP32) && defined(NICHE_HAS_FASTEPD)
+
+#include "./EInk.h"
+
+#include
+#include
+#include
+
+class FASTEPD;
+
+namespace NicheGraphics::Drivers
+{
+
+class EInkParallel : public EInk
+{
+ public:
+ EInkParallel(uint16_t width, uint16_t height, uint32_t panelType, uint32_t panelClock = 28000000);
+ ~EInkParallel();
+
+ // SPI parameters are unused for parallel panels.
+ void begin(SPIClass *spi, uint8_t pin_dc, uint8_t pin_cs, uint8_t pin_busy, uint8_t pin_rst = -1) override;
+ void update(uint8_t *imageData, UpdateTypes type) override;
+
+ FASTEPD *fastEpd() { return epaper; }
+
+ protected:
+ bool isUpdateDone() override;
+ void finalizeUpdate() override;
+
+ // Hook for boards that need to bring up GPIO expanders / power pins after FastEPD::initPanel.
+ virtual void postPanelInit() {}
+
+ private:
+ void copyImageInverted(const uint8_t *src);
+ static void asyncFullTask(void *param);
+
+ FASTEPD *epaper = nullptr;
+ uint32_t panelType;
+ uint32_t panelClock;
+
+ // Set only when begin() fully succeeds; update paths no-op while false.
+ bool panelReady = false;
+
+ UpdateTypes pendingType = UpdateTypes::UNSPECIFIED;
+ int pendingClearMode = 0; // CLEAR_FAST/CLEAR_SLOW picked in update(), consumed by asyncFullTask
+ std::atomic asyncRunning{false};
+ TaskHandle_t asyncTaskHandle = nullptr;
+
+ uint8_t fastRefreshCount = 0;
+ static constexpr uint8_t FULL_SLOW_PERIOD = 100;
+};
+
+} // namespace NicheGraphics::Drivers
+
+#endif // MESHTASTIC_INCLUDE_NICHE_GRAPHICS && ARCH_ESP32 && NICHE_HAS_FASTEPD
diff --git a/src/graphics/eink/Drivers/GDEH0122T61.cpp b/src/graphics/eink/Drivers/GDEH0122T61.cpp
new file mode 100644
index 000000000..38aac91fc
--- /dev/null
+++ b/src/graphics/eink/Drivers/GDEH0122T61.cpp
@@ -0,0 +1,49 @@
+#include "./GDEH0122T61.h"
+
+#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS
+
+using namespace NicheGraphics::Drivers;
+
+void GDEH0122T61::configScanning()
+{
+ sendCommand(0x01);
+ sendData(0xAF); // Scan until gate 175 (176px vertical resolution, low byte)
+ sendData(0x00); // high byte
+ sendData(0x00);
+}
+
+void GDEH0122T61::configWaveform()
+{
+ sendCommand(0x3C);
+ sendData(0x05);
+
+ sendCommand(0x18);
+ sendData(0x80);
+}
+
+void GDEH0122T61::configUpdateSequence()
+{
+ switch (updateType) {
+ case FAST:
+ sendCommand(0x22);
+ sendData(0xFF);
+ break;
+ case FULL:
+ default:
+ sendCommand(0x22);
+ sendData(0xF7);
+ break;
+ }
+}
+
+void GDEH0122T61::detachFromUpdate()
+{
+ switch (updateType) {
+ case FAST:
+ return beginPolling(50, 250);
+ case FULL:
+ default:
+ return beginPolling(100, 1500);
+ }
+}
+#endif // MESHTASTIC_INCLUDE_NICHE_GRAPHICS
diff --git a/src/graphics/eink/Drivers/GDEH0122T61.h b/src/graphics/eink/Drivers/GDEH0122T61.h
new file mode 100644
index 000000000..42c585d0e
--- /dev/null
+++ b/src/graphics/eink/Drivers/GDEH0122T61.h
@@ -0,0 +1,43 @@
+/*
+
+E-Ink display driver
+ - GDEH0122T61
+ - Manufacturer: Good Display
+ - Size: 1.22 inch
+ - Resolution: 192px x 176px
+ - Controller IC: SSD1681 (operating in a sub-200x200 window)
+
+ Used by: t-echo-lite.
+
+*/
+
+#pragma once
+
+#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS
+
+#include "configuration.h"
+
+#include "./SSD16XX.h"
+
+namespace NicheGraphics::Drivers
+{
+class GDEH0122T61 : public SSD16XX
+{
+ private:
+ static constexpr uint32_t width = 192;
+ static constexpr uint32_t height = 176;
+ static constexpr UpdateTypes supported = (UpdateTypes)(FULL | FAST);
+
+ public:
+ GDEH0122T61() : SSD16XX(width, height, supported) {}
+
+ protected:
+ void configScanning() override;
+ void configWaveform() override;
+ void configUpdateSequence() override;
+ void detachFromUpdate() override;
+};
+
+} // namespace NicheGraphics::Drivers
+
+#endif // MESHTASTIC_INCLUDE_NICHE_GRAPHICS
diff --git a/src/graphics/eink/Drivers/GDEQ031T10.cpp b/src/graphics/eink/Drivers/GDEQ031T10.cpp
new file mode 100644
index 000000000..f6ed66ee8
--- /dev/null
+++ b/src/graphics/eink/Drivers/GDEQ031T10.cpp
@@ -0,0 +1,57 @@
+#include "./GDEQ031T10.h"
+
+#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS
+
+using namespace NicheGraphics::Drivers;
+
+void GDEQ031T10::configScanning()
+{
+ sendCommand(0x01);
+ sendData(0x3F); // 319, low byte
+ sendData(0x01); // 319, high byte
+ sendData(0x00);
+}
+
+void GDEQ031T10::configWaveform()
+{
+ sendCommand(0x3C);
+ sendData(0x01);
+
+ sendCommand(0x18);
+ sendData(0x80);
+}
+
+void GDEQ031T10::configUpdateSequence()
+{
+ switch (updateType) {
+ case FAST:
+ sendCommand(0x21);
+ sendData(0x00);
+ sendData(0x00);
+
+ sendCommand(0x22);
+ sendData(0xFF);
+ break;
+ case FULL:
+ default:
+ sendCommand(0x21);
+ sendData(0x40);
+ sendData(0x00);
+
+ sendCommand(0x22);
+ sendData(0xF7);
+ break;
+ }
+}
+
+void GDEQ031T10::detachFromUpdate()
+{
+ switch (updateType) {
+ case FAST:
+ return beginPolling(50, 400);
+ case FULL:
+ default:
+ return beginPolling(100, 2500);
+ }
+}
+#endif // MESHTASTIC_INCLUDE_NICHE_GRAPHICS
diff --git a/src/graphics/eink/Drivers/GDEQ031T10.h b/src/graphics/eink/Drivers/GDEQ031T10.h
new file mode 100644
index 000000000..9d26156fe
--- /dev/null
+++ b/src/graphics/eink/Drivers/GDEQ031T10.h
@@ -0,0 +1,43 @@
+/*
+
+E-Ink display driver
+ - GDEQ031T10
+ - Manufacturer: Good Display
+ - Size: 3.1 inch
+ - Resolution: 240px x 320px
+ - Controller IC: SSD1677 (SSD16XX-family, larger memory range)
+
+ Used by: t-deck-pro.
+
+*/
+
+#pragma once
+
+#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS
+
+#include "configuration.h"
+
+#include "./SSD16XX.h"
+
+namespace NicheGraphics::Drivers
+{
+class GDEQ031T10 : public SSD16XX
+{
+ private:
+ static constexpr uint32_t width = 240;
+ static constexpr uint32_t height = 320;
+ static constexpr UpdateTypes supported = (UpdateTypes)(FULL | FAST);
+
+ public:
+ GDEQ031T10() : SSD16XX(width, height, supported) {}
+
+ protected:
+ void configScanning() override;
+ void configWaveform() override;
+ void configUpdateSequence() override;
+ void detachFromUpdate() override;
+};
+
+} // namespace NicheGraphics::Drivers
+
+#endif // MESHTASTIC_INCLUDE_NICHE_GRAPHICS
diff --git a/src/graphics/eink/Drivers/GDEW0102T4.cpp b/src/graphics/eink/Drivers/GDEW0102T4.cpp
new file mode 100644
index 000000000..a670db0d0
--- /dev/null
+++ b/src/graphics/eink/Drivers/GDEW0102T4.cpp
@@ -0,0 +1,178 @@
+#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS
+
+#include "./GDEW0102T4.h"
+
+#include
+
+using namespace NicheGraphics::Drivers;
+
+// LUTs from GxEPD2_102.cpp (GDEW0102T4 / UC8175).
+static const uint8_t LUT_W_FULL[] = {
+ 0x60, 0x5A, 0x5A, 0x00, 0x00, 0x01, //
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, //
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, //
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, //
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, //
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, //
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, //
+};
+
+static const uint8_t LUT_B_FULL[] = {
+ 0x90, 0x5A, 0x5A, 0x00, 0x00, 0x01, //
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, //
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, //
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, //
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, //
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, //
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, //
+};
+
+static const uint8_t LUT_W_FAST[] = {
+ 0x60, 0x01, 0x01, 0x00, 0x00, 0x01, //
+ 0x80, 0x12, 0x00, 0x00, 0x00, 0x01, //
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, //
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, //
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, //
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, //
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, //
+};
+
+static const uint8_t LUT_B_FAST[] = {
+ 0x90, 0x01, 0x01, 0x00, 0x00, 0x01, //
+ 0x40, 0x14, 0x00, 0x00, 0x00, 0x01, //
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, //
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, //
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, //
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, //
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, //
+};
+
+GDEW0102T4::GDEW0102T4() : UC8175(width, height, supported) {}
+
+void GDEW0102T4::setFastConfig(FastConfig cfg)
+{
+ // Clamp out only clearly invalid PLL settings.
+ if (cfg.reg30 < 0x05)
+ cfg.reg30 = 0x05;
+ fastConfig = cfg;
+}
+
+GDEW0102T4::FastConfig GDEW0102T4::getFastConfig() const
+{
+ return fastConfig;
+}
+
+void GDEW0102T4::configCommon()
+{
+ // Init path aligned with GxEPD2_GDEW0102T4 (UC8175 family).
+ sendCommand(0xD2);
+ sendData(0x3F);
+
+ sendCommand(0x00);
+ sendData(0x6F);
+
+ sendCommand(0x01);
+ sendData(0x03);
+ sendData(0x00);
+ sendData(0x2B);
+ sendData(0x2B);
+
+ sendCommand(0x06);
+ sendData(0x3F);
+
+ sendCommand(0x2A);
+ sendData(0x00);
+ sendData(0x00);
+
+ sendCommand(0x30); // PLL / drive clock
+ sendData(0x13);
+
+ sendCommand(0x50); // Last border/data interval; subtle but can affect artifacts
+ sendData(0x57);
+
+ sendCommand(0x60);
+ sendData(0x22);
+
+ sendCommand(0x61);
+ sendData(width);
+ sendData(height);
+
+ sendCommand(0x82); // VCOM DC setting
+ sendData(0x12);
+
+ sendCommand(0xE3);
+ sendData(0x33);
+}
+
+void GDEW0102T4::configFull()
+{
+ sendCommand(0x23);
+ sendData(LUT_W_FULL, sizeof(LUT_W_FULL));
+ sendCommand(0x24);
+ sendData(LUT_B_FULL, sizeof(LUT_B_FULL));
+
+ powerOn();
+}
+
+void GDEW0102T4::configFast()
+{
+ uint8_t lutW[sizeof(LUT_W_FAST)];
+ uint8_t lutB[sizeof(LUT_B_FAST)];
+ memcpy(lutW, LUT_W_FAST, sizeof(LUT_W_FAST));
+ memcpy(lutB, LUT_B_FAST, sizeof(LUT_B_FAST));
+
+ // Second stage duration bytes are the main "darkness vs ghosting" control for this panel.
+ lutW[7] = fastConfig.lutW2;
+ lutB[7] = fastConfig.lutB2;
+
+ sendCommand(0x30);
+ sendData(fastConfig.reg30);
+
+ sendCommand(0x50);
+ sendData(fastConfig.reg50);
+
+ sendCommand(0x82);
+ sendData(fastConfig.reg82);
+
+ sendCommand(0x23);
+ sendData(lutW, sizeof(lutW));
+ sendCommand(0x24);
+ sendData(lutB, sizeof(lutB));
+
+ powerOn();
+}
+
+void GDEW0102T4::writeOldImage()
+{
+ // On this panel, FULL refresh is most reliable when "old image" is all white.
+ if (updateType == FULL) {
+ sendCommand(0x10);
+ // Use buffered writes of 0xFF to avoid per-byte SPI transactions.
+ const uint16_t chunkSize = 64;
+ uint8_t ffBuf[chunkSize];
+ memset(ffBuf, 0xFF, sizeof(ffBuf));
+
+ uint32_t remaining = bufferSize;
+ while (remaining > 0) {
+ uint16_t toSend = remaining > chunkSize ? chunkSize : static_cast(remaining);
+ sendData(ffBuf, toSend);
+ remaining -= toSend;
+ }
+ return;
+ }
+
+ // FAST refresh uses differential data (previous frame as old image).
+ if (previousBuffer) {
+ writeImage(0x10, previousBuffer);
+ } else {
+ writeImage(0x10, buffer);
+ }
+}
+
+void GDEW0102T4::finalizeUpdate()
+{
+ // Keep panel out of deep-sleep between updates for better reliability of repeated FAST refresh.
+ powerOff();
+}
+
+#endif // MESHTASTIC_INCLUDE_NICHE_GRAPHICS
diff --git a/src/graphics/eink/Drivers/GDEW0102T4.h b/src/graphics/eink/Drivers/GDEW0102T4.h
new file mode 100644
index 000000000..02df8b4fe
--- /dev/null
+++ b/src/graphics/eink/Drivers/GDEW0102T4.h
@@ -0,0 +1,55 @@
+/*
+
+E-Ink display driver
+ - GDEW0102T4
+ - Controller: UC8175
+ - Size: 1.02 inch
+ - Resolution: 80px x 128px
+
+*/
+
+#pragma once
+
+#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS
+
+#include "configuration.h"
+
+#include "./UC8175.h"
+
+namespace NicheGraphics::Drivers
+{
+
+class GDEW0102T4 : public UC8175
+{
+ private:
+ static constexpr uint16_t width = 80;
+ static constexpr uint16_t height = 128;
+ static constexpr UpdateTypes supported = (UpdateTypes)(FULL | FAST);
+
+ public:
+ struct FastConfig {
+ uint8_t reg30;
+ uint8_t reg50;
+ uint8_t reg82;
+ uint8_t lutW2;
+ uint8_t lutB2;
+ };
+
+ GDEW0102T4();
+ void setFastConfig(FastConfig cfg);
+ FastConfig getFastConfig() const;
+
+ protected:
+ void configCommon() override;
+ void configFull() override;
+ void configFast() override;
+ void writeOldImage() override;
+ void finalizeUpdate() override;
+
+ private:
+ FastConfig fastConfig = {0x13, 0xF2, 0x12, 0x0E, 0x14};
+};
+
+} // namespace NicheGraphics::Drivers
+
+#endif // MESHTASTIC_INCLUDE_NICHE_GRAPHICS
diff --git a/src/graphics/eink/Drivers/GDEY0154D67.cpp b/src/graphics/eink/Drivers/GDEY0154D67.cpp
new file mode 100644
index 000000000..9a06fa841
--- /dev/null
+++ b/src/graphics/eink/Drivers/GDEY0154D67.cpp
@@ -0,0 +1,58 @@
+#include "./GDEY0154D67.h"
+
+#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS
+
+using namespace NicheGraphics::Drivers;
+
+// Map the display controller IC's output to the connected panel
+void GDEY0154D67::configScanning()
+{
+ // "Driver output control"
+ sendCommand(0x01);
+ sendData(0xC7); // Scan until gate 199 (200px vertical res.)
+ sendData(0x00);
+ sendData(0x00);
+}
+
+// Specify which information is used to control the sequence of voltages applied to move the pixels
+// - For this display, configUpdateSequence() specifies that a suitable LUT will be loaded from
+// the controller IC's OTP memory, when the update procedure begins.
+void GDEY0154D67::configWaveform()
+{
+ sendCommand(0x3C); // Border waveform:
+ sendData(0x05); // Screen border should follow LUT1 waveform (actively drive pixels white)
+
+ sendCommand(0x18); // Temperature sensor:
+ sendData(0x80); // Use internal temperature sensor to select an appropriate refresh waveform
+}
+
+void GDEY0154D67::configUpdateSequence()
+{
+ switch (updateType) {
+ case FAST:
+ sendCommand(0x22); // Set "update sequence"
+ sendData(0xFF); // Will load LUT from OTP memory, Display mode 2 "differential refresh"
+ break;
+
+ case FULL:
+ default:
+ sendCommand(0x22); // Set "update sequence"
+ sendData(0xF7); // Will load LUT from OTP memory
+ break;
+ }
+}
+
+// Once the refresh operation has been started,
+// begin periodically polling the display to check for completion, using the normal Meshtastic threading code
+// Only used when refresh is "async"
+void GDEY0154D67::detachFromUpdate()
+{
+ switch (updateType) {
+ case FAST:
+ return beginPolling(50, 300); // At least 300ms for fast refresh
+ case FULL:
+ default:
+ return beginPolling(100, 1500); // At least 1.5 seconds for full refresh
+ }
+}
+#endif // MESHTASTIC_INCLUDE_NICHE_GRAPHICS
\ No newline at end of file
diff --git a/src/graphics/eink/Drivers/GDEY0154D67.h b/src/graphics/eink/Drivers/GDEY0154D67.h
new file mode 100644
index 000000000..e391eea50
--- /dev/null
+++ b/src/graphics/eink/Drivers/GDEY0154D67.h
@@ -0,0 +1,42 @@
+/*
+
+E-Ink display driver
+ - GDEY0154D67
+ - Manufacturer: Goodisplay
+ - Size: 1.54 inch
+ - Resolution: 200px x 200px
+ - Flex connector marking (not a unique identifier): FPC-B001
+
+*/
+
+#pragma once
+
+#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS
+
+#include "configuration.h"
+
+#include "./SSD16XX.h"
+
+namespace NicheGraphics::Drivers
+{
+class GDEY0154D67 : public SSD16XX
+{
+ // Display properties
+ private:
+ static constexpr uint32_t width = 200;
+ static constexpr uint32_t height = 200;
+ static constexpr UpdateTypes supported = (UpdateTypes)(FULL | FAST);
+
+ public:
+ GDEY0154D67() : SSD16XX(width, height, supported) {}
+
+ protected:
+ void configScanning() override;
+ void configWaveform() override;
+ void configUpdateSequence() override;
+ void detachFromUpdate() override;
+};
+
+} // namespace NicheGraphics::Drivers
+
+#endif // MESHTASTIC_INCLUDE_NICHE_GRAPHICS
\ No newline at end of file
diff --git a/src/graphics/eink/Drivers/GDEY0213B74.cpp b/src/graphics/eink/Drivers/GDEY0213B74.cpp
new file mode 100644
index 000000000..b3a585eb7
--- /dev/null
+++ b/src/graphics/eink/Drivers/GDEY0213B74.cpp
@@ -0,0 +1,58 @@
+#include "./GDEY0213B74.h"
+
+#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS
+
+using namespace NicheGraphics::Drivers;
+
+// Map the display controller IC's output to the connected panel
+void GDEY0213B74::configScanning()
+{
+ // "Driver output control"
+ sendCommand(0x01);
+ sendData(0xF9);
+ sendData(0x00);
+ sendData(0x00);
+}
+
+// Specify which information is used to control the sequence of voltages applied to move the pixels
+// - For this display, configUpdateSequence() specifies that a suitable LUT will be loaded from
+// the controller IC's OTP memory, when the update procedure begins.
+void GDEY0213B74::configWaveform()
+{
+ sendCommand(0x3C); // Border waveform:
+ sendData(0x05); // Screen border should follow LUT1 waveform (actively drive pixels white)
+
+ sendCommand(0x18); // Temperature sensor:
+ sendData(0x80); // Use internal temperature sensor to select an appropriate refresh waveform
+}
+
+void GDEY0213B74::configUpdateSequence()
+{
+ switch (updateType) {
+ case FAST:
+ sendCommand(0x22); // Set "update sequence"
+ sendData(0xFF); // Will load LUT from OTP memory, Display mode 2 "differential refresh"
+ break;
+
+ case FULL:
+ default:
+ sendCommand(0x22); // Set "update sequence"
+ sendData(0xF7); // Will load LUT from OTP memory
+ break;
+ }
+}
+
+// Once the refresh operation has been started,
+// begin periodically polling the display to check for completion, using the normal Meshtastic threading code
+// Only used when refresh is "async"
+void GDEY0213B74::detachFromUpdate()
+{
+ switch (updateType) {
+ case FAST:
+ return beginPolling(50, 500); // At least 500ms for fast refresh
+ case FULL:
+ default:
+ return beginPolling(100, 2000); // At least 2 seconds for full refresh
+ }
+}
+#endif // MESHTASTIC_INCLUDE_NICHE_GRAPHICS
\ No newline at end of file
diff --git a/src/graphics/eink/Drivers/GDEY0213B74.h b/src/graphics/eink/Drivers/GDEY0213B74.h
new file mode 100644
index 000000000..907bbd7ee
--- /dev/null
+++ b/src/graphics/eink/Drivers/GDEY0213B74.h
@@ -0,0 +1,44 @@
+/*
+
+E-Ink display driver
+ - GDEY0213B74
+ - Manufacturer: Goodisplay
+ - Size: 2.13 inch
+ - Resolution: 122px x 250px
+ - Flex connector marking (not a unique identifier):
+ - FPC-A002
+ - FPC-A005 20.06.15 TRX
+
+*/
+
+#pragma once
+
+#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS
+
+#include "configuration.h"
+
+#include "./SSD16XX.h"
+
+namespace NicheGraphics::Drivers
+{
+class GDEY0213B74 : public SSD16XX
+{
+ // Display properties
+ private:
+ static constexpr uint32_t width = 122;
+ static constexpr uint32_t height = 250;
+ static constexpr UpdateTypes supported = (UpdateTypes)(FULL | FAST);
+
+ public:
+ GDEY0213B74() : SSD16XX(width, height, supported) {}
+
+ protected:
+ virtual void configScanning() override;
+ virtual void configWaveform() override;
+ virtual void configUpdateSequence() override;
+ void detachFromUpdate() override;
+};
+
+} // namespace NicheGraphics::Drivers
+
+#endif // MESHTASTIC_INCLUDE_NICHE_GRAPHICS
\ No newline at end of file
diff --git a/src/graphics/eink/Drivers/GDEY029T94.cpp b/src/graphics/eink/Drivers/GDEY029T94.cpp
new file mode 100644
index 000000000..ec2e801db
--- /dev/null
+++ b/src/graphics/eink/Drivers/GDEY029T94.cpp
@@ -0,0 +1,49 @@
+#include "./GDEY029T94.h"
+
+#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS
+
+using namespace NicheGraphics::Drivers;
+
+void GDEY029T94::configScanning()
+{
+ sendCommand(0x01);
+ sendData(0x27); // 295, low byte
+ sendData(0x01); // 295, high byte
+ sendData(0x00);
+}
+
+void GDEY029T94::configWaveform()
+{
+ sendCommand(0x3C);
+ sendData(0x05);
+
+ sendCommand(0x18);
+ sendData(0x80);
+}
+
+void GDEY029T94::configUpdateSequence()
+{
+ switch (updateType) {
+ case FAST:
+ sendCommand(0x22);
+ sendData(0xFF);
+ break;
+ case FULL:
+ default:
+ sendCommand(0x22);
+ sendData(0xF7);
+ break;
+ }
+}
+
+void GDEY029T94::detachFromUpdate()
+{
+ switch (updateType) {
+ case FAST:
+ return beginPolling(50, 300);
+ case FULL:
+ default:
+ return beginPolling(100, 2000);
+ }
+}
+#endif // MESHTASTIC_INCLUDE_NICHE_GRAPHICS
diff --git a/src/graphics/eink/Drivers/GDEY029T94.h b/src/graphics/eink/Drivers/GDEY029T94.h
new file mode 100644
index 000000000..732fb185c
--- /dev/null
+++ b/src/graphics/eink/Drivers/GDEY029T94.h
@@ -0,0 +1,43 @@
+/*
+
+E-Ink display driver
+ - GDEY029T94 (also sold as GDEY029T94-V2)
+ - Manufacturer: Good Display
+ - Size: 2.9 inch
+ - Resolution: 128px x 296px
+ - Controller IC: SSD1680
+
+ Used by: esp32-s3-pico, crowpanel-esp32s3-2-epaper.
+
+*/
+
+#pragma once
+
+#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS
+
+#include "configuration.h"
+
+#include "./SSD16XX.h"
+
+namespace NicheGraphics::Drivers
+{
+class GDEY029T94 : public SSD16XX
+{
+ private:
+ static constexpr uint32_t width = 128;
+ static constexpr uint32_t height = 296;
+ static constexpr UpdateTypes supported = (UpdateTypes)(FULL | FAST);
+
+ public:
+ GDEY029T94() : SSD16XX(width, height, supported) {}
+
+ protected:
+ void configScanning() override;
+ void configWaveform() override;
+ void configUpdateSequence() override;
+ void detachFromUpdate() override;
+};
+
+} // namespace NicheGraphics::Drivers
+
+#endif // MESHTASTIC_INCLUDE_NICHE_GRAPHICS
diff --git a/src/graphics/eink/Drivers/GDEY042T81.cpp b/src/graphics/eink/Drivers/GDEY042T81.cpp
new file mode 100644
index 000000000..4f19dcc9e
--- /dev/null
+++ b/src/graphics/eink/Drivers/GDEY042T81.cpp
@@ -0,0 +1,49 @@
+#include "./GDEY042T81.h"
+
+#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS
+
+using namespace NicheGraphics::Drivers;
+
+void GDEY042T81::configWaveform()
+{
+ sendCommand(0x3C);
+ sendData(0x01);
+
+ sendCommand(0x18);
+ sendData(0x80);
+}
+
+void GDEY042T81::configUpdateSequence()
+{
+ switch (updateType) {
+ case FAST:
+ sendCommand(0x21);
+ sendData(0x00);
+ sendData(0x00);
+
+ sendCommand(0x22);
+ sendData(0xFF);
+ break;
+ case FULL:
+ default:
+ sendCommand(0x21);
+ sendData(0x40);
+ sendData(0x00);
+
+ sendCommand(0x22);
+ sendData(0xF7);
+ break;
+ }
+}
+
+void GDEY042T81::detachFromUpdate()
+{
+ switch (updateType) {
+ case FAST:
+ return beginPolling(50, 1000);
+ case FULL:
+ default:
+ return beginPolling(100, 3500);
+ }
+}
+#endif // MESHTASTIC_INCLUDE_NICHE_GRAPHICS
diff --git a/src/graphics/eink/Drivers/GDEY042T81.h b/src/graphics/eink/Drivers/GDEY042T81.h
new file mode 100644
index 000000000..166b45266
--- /dev/null
+++ b/src/graphics/eink/Drivers/GDEY042T81.h
@@ -0,0 +1,42 @@
+/*
+
+E-Ink display driver
+ - GDEY042T81
+ - Manufacturer: Good Display
+ - Size: 4.2 inch
+ - Resolution: 400px x 300px
+ - Controller IC: SSD1683
+
+ Used by: ME25LS01-4Y10TD_e-ink.
+
+*/
+
+#pragma once
+
+#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS
+
+#include "configuration.h"
+
+#include "./SSD16XX.h"
+
+namespace NicheGraphics::Drivers
+{
+class GDEY042T81 : public SSD16XX
+{
+ private:
+ static constexpr uint32_t width = 400;
+ static constexpr uint32_t height = 300;
+ static constexpr UpdateTypes supported = (UpdateTypes)(FULL | FAST);
+
+ public:
+ GDEY042T81() : SSD16XX(width, height, supported) {}
+
+ protected:
+ void configWaveform() override;
+ void configUpdateSequence() override;
+ void detachFromUpdate() override;
+};
+
+} // namespace NicheGraphics::Drivers
+
+#endif // MESHTASTIC_INCLUDE_NICHE_GRAPHICS
diff --git a/src/graphics/eink/Drivers/GDEY0579T93.cpp b/src/graphics/eink/Drivers/GDEY0579T93.cpp
new file mode 100644
index 000000000..f076108fb
--- /dev/null
+++ b/src/graphics/eink/Drivers/GDEY0579T93.cpp
@@ -0,0 +1,57 @@
+#include "./GDEY0579T93.h"
+
+#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS
+
+using namespace NicheGraphics::Drivers;
+
+void GDEY0579T93::configScanning()
+{
+ sendCommand(0x01);
+ sendData(0x0F); // 271, low byte
+ sendData(0x01); // 271, high byte
+ sendData(0x00);
+}
+
+void GDEY0579T93::configWaveform()
+{
+ sendCommand(0x3C);
+ sendData(0x01);
+
+ sendCommand(0x18);
+ sendData(0x80);
+}
+
+void GDEY0579T93::configUpdateSequence()
+{
+ switch (updateType) {
+ case FAST:
+ sendCommand(0x21);
+ sendData(0x00);
+ sendData(0x00);
+
+ sendCommand(0x22);
+ sendData(0xFF);
+ break;
+ case FULL:
+ default:
+ sendCommand(0x21);
+ sendData(0x40);
+ sendData(0x00);
+
+ sendCommand(0x22);
+ sendData(0xF7);
+ break;
+ }
+}
+
+void GDEY0579T93::detachFromUpdate()
+{
+ switch (updateType) {
+ case FAST:
+ return beginPolling(100, 2000);
+ case FULL:
+ default:
+ return beginPolling(150, 5000);
+ }
+}
+#endif // MESHTASTIC_INCLUDE_NICHE_GRAPHICS
diff --git a/src/graphics/eink/Drivers/GDEY0579T93.h b/src/graphics/eink/Drivers/GDEY0579T93.h
new file mode 100644
index 000000000..05956405c
--- /dev/null
+++ b/src/graphics/eink/Drivers/GDEY0579T93.h
@@ -0,0 +1,43 @@
+/*
+
+E-Ink display driver
+ - GDEY0579T93
+ - Manufacturer: Good Display
+ - Size: 5.79 inch
+ - Resolution: 792px x 272px
+ - Controller IC: SSD1683 (extended memory range)
+
+ Used by: crowpanel-esp32s3-5-epaper.
+
+*/
+
+#pragma once
+
+#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS
+
+#include "configuration.h"
+
+#include "./SSD16XX.h"
+
+namespace NicheGraphics::Drivers
+{
+class GDEY0579T93 : public SSD16XX
+{
+ private:
+ static constexpr uint32_t width = 792;
+ static constexpr uint32_t height = 272;
+ static constexpr UpdateTypes supported = (UpdateTypes)(FULL | FAST);
+
+ public:
+ GDEY0579T93() : SSD16XX(width, height, supported) {}
+
+ protected:
+ void configScanning() override;
+ void configWaveform() override;
+ void configUpdateSequence() override;
+ void detachFromUpdate() override;
+};
+
+} // namespace NicheGraphics::Drivers
+
+#endif // MESHTASTIC_INCLUDE_NICHE_GRAPHICS
diff --git a/src/graphics/eink/Drivers/HINK_E0213A289.cpp b/src/graphics/eink/Drivers/HINK_E0213A289.cpp
new file mode 100644
index 000000000..0509b0502
--- /dev/null
+++ b/src/graphics/eink/Drivers/HINK_E0213A289.cpp
@@ -0,0 +1,61 @@
+#include "./HINK_E0213A289.h"
+
+#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS
+
+using namespace NicheGraphics::Drivers;
+
+// Map the display controller IC's output to the connected panel
+void HINK_E0213A289::configScanning()
+{
+ // "Driver output control"
+ // Scan gates from 0 to 249 (vertical resolution 250px)
+ sendCommand(0x01);
+ sendData(0xF9); // Maximum gate # (249, bits 0-7)
+ sendData(0x00); // Maximum gate # (bit 8)
+ sendData(0x00); // (Do not invert scanning order)
+}
+
+// Specify which information is used to control the sequence of voltages applied to move the pixels
+// - For this display, configUpdateSequence() specifies that a suitable LUT will be loaded from
+// the controller IC's OTP memory, when the update procedure begins.
+void HINK_E0213A289::configWaveform()
+{
+ sendCommand(0x3C); // Border waveform:
+ sendData(0x05); // Screen border should follow LUT1 waveform (actively drive pixels white)
+
+ sendCommand(0x18); // Temperature sensor:
+ sendData(0x80); // Use internal temperature sensor to select an appropriate refresh waveform
+}
+
+// Describes the sequence of events performed by the displays controller IC during a refresh
+// Includes "power up", "load settings from memory", "update the pixels", etc
+void HINK_E0213A289::configUpdateSequence()
+{
+ switch (updateType) {
+ case FAST:
+ sendCommand(0x22); // Set "update sequence"
+ sendData(0xFF); // Will load LUT from OTP memory, Display mode 2 "differential refresh"
+ break;
+
+ case FULL:
+ default:
+ sendCommand(0x22); // Set "update sequence"
+ sendData(0xF7); // Will load LUT from OTP memory
+ break;
+ }
+}
+
+// Once the refresh operation has been started,
+// begin periodically polling the display to check for completion, using the normal Meshtastic threading code
+// Only used when refresh is "async"
+void HINK_E0213A289::detachFromUpdate()
+{
+ switch (updateType) {
+ case FAST:
+ return beginPolling(50, 500); // At least 500ms for fast refresh
+ case FULL:
+ default:
+ return beginPolling(100, 1000); // At least 1 second for full refresh (quick; display only blinks pixels once)
+ }
+}
+#endif // MESHTASTIC_INCLUDE_NICHE_GRAPHICS
\ No newline at end of file
diff --git a/src/graphics/eink/Drivers/HINK_E0213A289.h b/src/graphics/eink/Drivers/HINK_E0213A289.h
new file mode 100644
index 000000000..eab0bf59d
--- /dev/null
+++ b/src/graphics/eink/Drivers/HINK_E0213A289.h
@@ -0,0 +1,44 @@
+/*
+
+E-Ink display driver
+ - HINK_E0213A289
+ - Manufacturer: Holitech
+ - Size: 2.13 inch
+ - Resolution: 122px x 250px
+ - Flex connector label (not a unique identifier): FPC-7528B
+
+ Note: as of Feb. 2025, these panels are used for "WeActStudio 2.13in B&W" display modules
+
+*/
+
+#pragma once
+
+#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS
+
+#include "configuration.h"
+
+#include "./SSD16XX.h"
+
+namespace NicheGraphics::Drivers
+{
+class HINK_E0213A289 : public SSD16XX
+{
+ // Display properties
+ private:
+ static constexpr uint32_t width = 122;
+ static constexpr uint32_t height = 250;
+ static constexpr UpdateTypes supported = (UpdateTypes)(FULL | FAST);
+
+ public:
+ HINK_E0213A289() : SSD16XX(width, height, supported, 1) {}
+
+ protected:
+ void configScanning() override;
+ void configWaveform() override;
+ void configUpdateSequence() override;
+ void detachFromUpdate() override;
+};
+
+} // namespace NicheGraphics::Drivers
+
+#endif // MESHTASTIC_INCLUDE_NICHE_GRAPHICS
\ No newline at end of file
diff --git a/src/graphics/eink/Drivers/HINK_E042A87.cpp b/src/graphics/eink/Drivers/HINK_E042A87.cpp
new file mode 100644
index 000000000..1b72bc4a9
--- /dev/null
+++ b/src/graphics/eink/Drivers/HINK_E042A87.cpp
@@ -0,0 +1,58 @@
+#include "./HINK_E042A87.h"
+
+#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS
+
+using namespace NicheGraphics::Drivers;
+
+// Load settings about how the pixels are moved from old state to new state during a refresh
+// - manually specified,
+// - or with stored values from displays OTP memory
+void HINK_E042A87::configWaveform()
+{
+ sendCommand(0x3C); // Border waveform:
+ sendData(0x01); // Follow LUT for VSH1
+
+ sendCommand(0x18); // Temperature sensor:
+ sendData(0x80); // Use internal temperature sensor to select an appropriate refresh waveform
+}
+
+// Describes the sequence of events performed by the displays controller IC during a refresh
+// Includes "power up", "load settings from memory", "update the pixels", etc
+void HINK_E042A87::configUpdateSequence()
+{
+ switch (updateType) {
+ case FAST:
+ sendCommand(0x21); // Use both "old" and "new" image memory (differential)
+ sendData(0x00);
+ sendData(0x00);
+
+ sendCommand(0x22); // Set "update sequence"
+ sendData(0xFF); // Differential, load waveform from OTP
+ break;
+
+ case FULL:
+ default:
+ sendCommand(0x21); // Bypass "old" image memory (non-differential)
+ sendData(0x40);
+ sendData(0x00);
+
+ sendCommand(0x22); // Set "update sequence":
+ sendData(0xF7); // Non-differential, load waveform from OTP
+ break;
+ }
+}
+
+// Once the refresh operation has been started,
+// begin periodically polling the display to check for completion, using the normal Meshtastic threading code
+// Only used when refresh is "async"
+void HINK_E042A87::detachFromUpdate()
+{
+ switch (updateType) {
+ case FAST:
+ return beginPolling(50, 1000); // At least 1 second, then check every 50ms
+ case FULL:
+ default:
+ return beginPolling(100, 3500); // At least 3.5 seconds, then check every 100ms
+ }
+}
+#endif // MESHTASTIC_INCLUDE_NICHE_GRAPHICS
\ No newline at end of file
diff --git a/src/graphics/eink/Drivers/HINK_E042A87.h b/src/graphics/eink/Drivers/HINK_E042A87.h
new file mode 100644
index 000000000..612072b50
--- /dev/null
+++ b/src/graphics/eink/Drivers/HINK_E042A87.h
@@ -0,0 +1,43 @@
+/*
+
+E-Ink display driver
+ - HINK-E042A87
+ - Manufacturer: Holitech
+ - Size: 4.2 inch
+ - Resolution: 400px x 300px
+ - Flex connector marking (not a unique identifier): HINK-E042A07-FPC-A1
+ - Silver sticker with QR code, marked: HE042A87
+
+ Note: as of Feb. 2025, these panels are used for "WeActStudio 4.2in B&W" display modules
+
+*/
+
+#pragma once
+
+#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS
+
+#include "configuration.h"
+
+#include "./SSD16XX.h"
+
+namespace NicheGraphics::Drivers
+{
+class HINK_E042A87 : public SSD16XX
+{
+ // Display properties
+ private:
+ static constexpr uint32_t width = 400;
+ static constexpr uint32_t height = 300;
+ static constexpr UpdateTypes supported = (UpdateTypes)(FULL | FAST);
+
+ public:
+ HINK_E042A87() : SSD16XX(width, height, supported) {}
+
+ protected:
+ void configWaveform() override;
+ void configUpdateSequence() override;
+ void detachFromUpdate() override;
+};
+
+} // namespace NicheGraphics::Drivers
+#endif // MESHTASTIC_INCLUDE_NICHE_GRAPHICS
\ No newline at end of file
diff --git a/src/graphics/eink/Drivers/LCMEN2R13ECC1.cpp b/src/graphics/eink/Drivers/LCMEN2R13ECC1.cpp
new file mode 100644
index 000000000..7c77390dc
--- /dev/null
+++ b/src/graphics/eink/Drivers/LCMEN2R13ECC1.cpp
@@ -0,0 +1,68 @@
+#include "./LCMEN2R13ECC1.h"
+
+#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS
+
+using namespace NicheGraphics::Drivers;
+
+// Map the display controller IC's output to the connected panel
+void LCMEN2R13ECC1::configScanning()
+{
+ // "Driver output control"
+ sendCommand(0x01);
+ sendData(0xF9);
+ sendData(0x00);
+ sendData(0x00);
+
+ // To-do: delete this method?
+ // Values set here might be redundant: F9, 00, 00 seems to be default
+}
+
+// Specify which information is used to control the sequence of voltages applied to move the pixels
+// - For this display, configUpdateSequence() specifies that a suitable LUT will be loaded from
+// the controller IC's OTP memory, when the update procedure begins.
+void LCMEN2R13ECC1::configWaveform()
+{
+ switch (updateType) {
+ case FAST:
+ sendCommand(0x3C); // Border waveform:
+ sendData(0x85);
+ break;
+
+ case FULL:
+ default:
+ // From OTP memory
+ break;
+ }
+}
+
+void LCMEN2R13ECC1::configUpdateSequence()
+{
+ switch (updateType) {
+ case FAST:
+ sendCommand(0x22); // Set "update sequence"
+ sendData(0xFF); // Will load LUT from OTP memory, Display mode 2 "differential refresh"
+ break;
+
+ case FULL:
+ default:
+ sendCommand(0x22); // Set "update sequence"
+ sendData(0xF7); // Will load LUT from OTP memory
+ break;
+ }
+}
+
+// Once the refresh operation has been started,
+// begin periodically polling the display to check for completion, using the normal Meshtastic threading code
+// Only used when refresh is "async"
+void LCMEN2R13ECC1::detachFromUpdate()
+{
+ switch (updateType) {
+ case FAST:
+ return beginPolling(50, 800); // At least 800ms for fast refresh
+ case FULL:
+ default:
+ return beginPolling(100, 2500); // At least 2.5 seconds for full refresh
+ }
+}
+
+#endif // MESHTASTIC_INCLUDE_NICHE_GRAPHICS
\ No newline at end of file
diff --git a/src/graphics/eink/Drivers/LCMEN2R13ECC1.h b/src/graphics/eink/Drivers/LCMEN2R13ECC1.h
new file mode 100644
index 000000000..5fd7b15a6
--- /dev/null
+++ b/src/graphics/eink/Drivers/LCMEN2R13ECC1.h
@@ -0,0 +1,40 @@
+/*
+
+E-Ink display driver
+ - LCMEN2R13ECC1 (SSD1680)
+ - Manufacturer: WISEVAST
+ - Size: 2.13 inch
+ - Resolution: 122px x 250px
+
+*/
+
+#pragma once
+
+#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS
+
+#include "configuration.h"
+
+#include "./SSD16XX.h"
+
+namespace NicheGraphics::Drivers
+{
+class LCMEN2R13ECC1 : public SSD16XX
+{
+ // Display properties
+ private:
+ static constexpr uint32_t width = 122;
+ static constexpr uint32_t height = 250;
+ static constexpr UpdateTypes supported = (UpdateTypes)(FULL | FAST);
+
+ public:
+ LCMEN2R13ECC1() : SSD16XX(width, height, supported, 1) {} // Note: left edge of this display is offset by 1 byte
+
+ protected:
+ virtual void configScanning() override;
+ virtual void configWaveform() override;
+ virtual void configUpdateSequence() override;
+ void detachFromUpdate() override;
+};
+
+} // namespace NicheGraphics::Drivers
+#endif // MESHTASTIC_INCLUDE_NICHE_GRAPHICS
\ No newline at end of file
diff --git a/src/graphics/eink/Drivers/LCMEN2R13EFC1.cpp b/src/graphics/eink/Drivers/LCMEN2R13EFC1.cpp
new file mode 100644
index 000000000..f639f3c10
--- /dev/null
+++ b/src/graphics/eink/Drivers/LCMEN2R13EFC1.cpp
@@ -0,0 +1,326 @@
+#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS
+
+#include "./LCMEN2R13EFC1.h"
+
+#include
+
+#include "SPILock.h"
+#include "Throttle.h"
+
+using namespace NicheGraphics::Drivers;
+
+// Look up table: fast refresh, common electrode
+static const uint8_t LUT_FAST_VCOMDC[] = {
+ 0x01, 0x06, 0x03, 0x02, 0x01, 0x01, 0x01, //
+ 0x01, 0x06, 0x02, 0x01, 0x01, 0x01, 0x01, //
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, //
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, //
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, //
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, //
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, //
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, //
+};
+
+// Look up table: fast refresh, pixels which remain white
+static const uint8_t LUT_FAST_WW[] = {
+ 0x01, 0x06, 0x03, 0x02, 0x81, 0x01, 0x01, //
+ 0x01, 0x06, 0x02, 0x01, 0x01, 0x01, 0x01, //
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, //
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, //
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, //
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, //
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, //
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, //
+};
+
+// Look up table: fast refresh, pixel which change from black to white
+static const uint8_t LUT_FAST_BW[] = {
+ 0x01, 0x86, 0x83, 0x82, 0x81, 0x01, 0x01, //
+ 0x01, 0x86, 0x82, 0x01, 0x01, 0x01, 0x01, //
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, //
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, //
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, //
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, //
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, //
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, //
+};
+
+// Look up table: fast refresh, pixels which change from white to black
+static const uint8_t LUT_FAST_WB[] = {
+ 0x01, 0x46, 0x43, 0x02, 0x01, 0x01, 0x01, //
+ 0x01, 0x46, 0x42, 0x01, 0x01, 0x01, 0x01, //
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, //
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, //
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, //
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, //
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, //
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, //
+};
+
+// Look up table: fast refresh, pixels which remain black
+static const uint8_t LUT_FAST_BB[] = {
+ 0x01, 0x06, 0x03, 0x42, 0x41, 0x01, 0x01, //
+ 0x01, 0x06, 0x02, 0x01, 0x01, 0x01, 0x01, //
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, //
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, //
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, //
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, //
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, //
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, //
+};
+
+LCMEN213EFC1::LCMEN213EFC1() : EInk(width, height, supported)
+{
+ // Pre-calculate size of the image buffer, for convenience
+
+ // Determine the X dimension of the image buffer, in bytes.
+ // Along rows, pixels are stored 8 per byte.
+ // Not all display widths are divisible by 8. Need to make sure bytecount accommodates padding for these.
+ bufferRowSize = ((width - 1) / 8) + 1;
+
+ // Total size of image buffer, in bytes.
+ bufferSize = bufferRowSize * height;
+}
+
+void LCMEN213EFC1::begin(SPIClass *spi, uint8_t pin_dc, uint8_t pin_cs, uint8_t pin_busy, uint8_t pin_rst)
+{
+ this->spi = spi;
+ this->pin_dc = pin_dc;
+ this->pin_cs = pin_cs;
+ this->pin_busy = pin_busy;
+ this->pin_rst = pin_rst;
+
+ pinMode(pin_dc, OUTPUT);
+ pinMode(pin_cs, OUTPUT);
+ pinMode(pin_busy, INPUT);
+
+ // Reset is active low, hold high
+ if (pin_rst != (uint8_t)-1)
+ pinMode(pin_rst, INPUT_PULLUP);
+
+ reset();
+}
+
+// Display an image on the display
+void LCMEN213EFC1::update(uint8_t *imageData, UpdateTypes type)
+{
+ this->updateType = type;
+ this->buffer = imageData;
+
+ reset();
+
+ // Config
+ if (updateType == FULL)
+ configFull();
+ else
+ configFast();
+
+ // Transfer image data
+ if (updateType == FULL) {
+ writeNewImage();
+ writeOldImage();
+ } else {
+ writeNewImage();
+ }
+
+ sendCommand(0x04); // Power on the panel voltage
+ wait();
+
+ sendCommand(0x12); // Begin executing the update
+
+ // Let the update run async, on display hardware. Base class will poll completion, then finalize.
+ // For a blocking update, call await after update
+ detachFromUpdate();
+}
+
+void LCMEN213EFC1::wait(uint32_t timeoutMs)
+{
+ // Fail-through: skip if an earlier step of this update sequence already failed
+ if (failed)
+ return;
+
+ // Busy when LOW; timeout sets failed so the sequence fails through (cleared by EInk::runOnce)
+ const uint32_t start = millis();
+ while (digitalRead(pin_busy) == LOW) {
+ if (!Throttle::isWithinTimespanMs(start, timeoutMs)) {
+ failed = true;
+ break;
+ }
+ yield();
+ }
+}
+
+void LCMEN213EFC1::reset()
+{
+ if (pin_rst != (uint8_t)-1) {
+ pinMode(pin_rst, OUTPUT);
+ digitalWrite(pin_rst, LOW);
+ delay(10);
+ pinMode(pin_rst, INPUT_PULLUP);
+ wait();
+ }
+
+ sendCommand(0x12);
+ wait();
+}
+
+void LCMEN213EFC1::sendCommand(const uint8_t command)
+{
+ if (failed)
+ return;
+
+ // Take firmware's SPI lock
+ spiLock->lock();
+
+ spi->beginTransaction(spiSettings);
+ digitalWrite(pin_dc, LOW); // DC pin low indicates command
+ digitalWrite(pin_cs, LOW);
+ spi->transfer(command);
+ digitalWrite(pin_cs, HIGH);
+ digitalWrite(pin_dc, HIGH);
+ spi->endTransaction();
+
+ spiLock->unlock();
+}
+
+void LCMEN213EFC1::sendData(uint8_t data)
+{
+ sendData(&data, 1);
+}
+
+void LCMEN213EFC1::sendData(const uint8_t *data, uint32_t size)
+{
+ if (failed)
+ return;
+
+ // Take firmware's SPI lock
+ spiLock->lock();
+
+ spi->beginTransaction(spiSettings);
+ digitalWrite(pin_dc, HIGH); // DC pin HIGH indicates data, instead of command
+ digitalWrite(pin_cs, LOW);
+
+ // Platform-specific SPI command
+ // Mothballing. This display model is only used by Heltec Wireless Paper (ESP32)
+#if defined(ARCH_ESP32)
+ spi->transferBytes(data, NULL, size); // NULL for a "write only" transfer
+#elif defined(ARCH_NRF52)
+ spi->transfer(data, NULL, size); // NULL for a "write only" transfer
+#else
+#error Not implemented yet? Feel free to add other platforms here.
+#endif
+
+ digitalWrite(pin_cs, HIGH);
+ digitalWrite(pin_dc, HIGH);
+ spi->endTransaction();
+
+ spiLock->unlock();
+}
+
+void LCMEN213EFC1::configFull()
+{
+ sendCommand(0x00); // Panel setting register
+ sendData(0b11 << 6 // Display resolution
+ | 1 << 4 // B&W only
+ | 1 << 3 // Vertical scan direction
+ | 1 << 2 // Horizontal scan direction
+ | 1 << 1 // Shutdown: no
+ | 1 << 0 // Reset: no
+ );
+
+ sendCommand(0x50); // VCOM and data interval setting register
+ sendData(0b10 << 6 // Border driven white
+ | 0b11 << 4 // Invert image colors: no
+ | 0b0111 << 0 // Interval between VCOM on and image data (default)
+ );
+}
+
+void LCMEN213EFC1::configFast()
+{
+ sendCommand(0x00); // Panel setting register
+ sendData(0b11 << 6 // Display resolution
+ | 1 << 5 // LUT from registers (set below)
+ | 1 << 4 // B&W only
+ | 1 << 3 // Vertical scan direction
+ | 1 << 2 // Horizontal scan direction
+ | 1 << 1 // Shutdown: no
+ | 1 << 0 // Reset: no
+ );
+
+ sendCommand(0x50); // VCOM and data interval setting register
+ sendData(0b11 << 6 // Border floating
+ | 0b01 << 4 // Invert image colors: no
+ | 0b0111 << 0 // Interval between VCOM on and image data (default)
+ );
+
+ // Load the various LUTs
+ sendCommand(0x20); // VCOM
+ sendData(LUT_FAST_VCOMDC, sizeof(LUT_FAST_VCOMDC));
+
+ sendCommand(0x21); // White -> White
+ sendData(LUT_FAST_WW, sizeof(LUT_FAST_WW));
+
+ sendCommand(0x22); // Black -> White
+ sendData(LUT_FAST_BW, sizeof(LUT_FAST_BW));
+
+ sendCommand(0x23); // White -> Black
+ sendData(LUT_FAST_WB, sizeof(LUT_FAST_WB));
+
+ sendCommand(0x24); // Black -> Black
+ sendData(LUT_FAST_BB, sizeof(LUT_FAST_BB));
+}
+
+void LCMEN213EFC1::writeNewImage()
+{
+ sendCommand(0x13);
+ sendData(buffer, bufferSize);
+}
+
+void LCMEN213EFC1::writeOldImage()
+{
+ sendCommand(0x10);
+ sendData(buffer, bufferSize);
+}
+
+void LCMEN213EFC1::detachFromUpdate()
+{
+ // To save power / cycles, displays can choose to specify an "expected duration" for various refresh types
+ // If we know a full-refresh takes at least 4 seconds, we can delay polling until 3 seconds have passed
+ // If not implemented, we'll just poll right from the get-go
+ switch (updateType) {
+ case FULL:
+ EInk::beginPolling(10, 3650);
+ break;
+ case FAST:
+ EInk::beginPolling(10, 720);
+ break;
+ default:
+ assert(false);
+ }
+}
+
+bool LCMEN213EFC1::isUpdateDone()
+{
+ // Busy when LOW
+ if (digitalRead(pin_busy) == LOW)
+ return false;
+ else
+ return true;
+}
+
+void LCMEN213EFC1::finalizeUpdate()
+{
+ // Power off the panel voltages
+ sendCommand(0x02);
+ wait();
+
+ // Put a copy of the image into the "old memory".
+ // Used with differential refreshes (e.g. FAST update), to determine which px need to move, and which can remain in place
+ // We need to keep the "old memory" up to date, because don't know whether next refresh will be FULL or FAST etc.
+ if (updateType != FULL) {
+ writeOldImage();
+ wait();
+ }
+}
+
+#endif // MESHTASTIC_INCLUDE_NICHE_GRAPHICS
\ No newline at end of file
diff --git a/src/graphics/eink/Drivers/LCMEN2R13EFC1.h b/src/graphics/eink/Drivers/LCMEN2R13EFC1.h
new file mode 100644
index 000000000..b1e799984
--- /dev/null
+++ b/src/graphics/eink/Drivers/LCMEN2R13EFC1.h
@@ -0,0 +1,71 @@
+/*
+
+E-Ink display driver
+ - LCMEN213EFC1
+ - Manufacturer: Wisevast
+ - Size: 2.13 inch
+ - Resolution: 122px x 250px
+ - Flex connector marking (not a unique identifier): HINK-E0213A162-FPC-A0 (Hidden, printed on back-side)
+
+Note: this display uses an uncommon controller IC, Fitipower JD79656.
+It is implemented as a "one-off", directly inheriting the EInk base class, unlike SSD16XX displays.
+
+*/
+
+#pragma once
+
+#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS
+
+#include "configuration.h"
+
+#include "./EInk.h"
+
+namespace NicheGraphics::Drivers
+{
+
+class LCMEN213EFC1 : public EInk
+{
+ // Display properties
+ private:
+ static constexpr uint32_t width = 122;
+ static constexpr uint32_t height = 250;
+ static constexpr UpdateTypes supported = (UpdateTypes)(FULL | FAST);
+
+ public:
+ LCMEN213EFC1();
+ void begin(SPIClass *spi, uint8_t pin_dc, uint8_t pin_cs, uint8_t pin_busy, uint8_t pin_rst);
+ void update(uint8_t *imageData, UpdateTypes type) override;
+
+ protected:
+ void wait(uint32_t timeoutMs = 5000);
+ void reset();
+ void sendCommand(const uint8_t command);
+ void sendData(const uint8_t data);
+ void sendData(const uint8_t *data, uint32_t size);
+ void configFull(); // Configure display for FULL refresh
+ void configFast(); // Configure display for FAST refresh
+ void writeNewImage();
+ void writeOldImage(); // Used for "differential update", aka FAST refresh
+
+ void detachFromUpdate();
+ bool isUpdateDone();
+ void finalizeUpdate();
+
+ protected:
+ uint8_t bufferOffsetX = 0; // In bytes. Panel x=0 does not always align with controller x=0. Quirky internal wiring?
+ uint8_t bufferRowSize = 0; // In bytes. Rows store 8 pixels per byte. Rounded up to fit (e.g. 122px would require 16 bytes)
+ uint32_t bufferSize = 0; // In bytes. Rows * Columns
+ uint8_t *buffer = nullptr;
+ UpdateTypes updateType = UpdateTypes::UNSPECIFIED;
+
+ uint8_t pin_dc = -1;
+ uint8_t pin_cs = -1;
+ uint8_t pin_busy = -1;
+ uint8_t pin_rst = -1;
+ SPIClass *spi = nullptr;
+ SPISettings spiSettings = SPISettings(6000000, MSBFIRST, SPI_MODE0);
+};
+
+} // namespace NicheGraphics::Drivers
+
+#endif // MESHTASTIC_INCLUDE_NICHE_GRAPHICS
\ No newline at end of file
diff --git a/src/graphics/eink/Drivers/README-legacy.md b/src/graphics/eink/Drivers/README-legacy.md
new file mode 100644
index 000000000..14a9edd0b
--- /dev/null
+++ b/src/graphics/eink/Drivers/README-legacy.md
@@ -0,0 +1,3 @@
+# NicheGraphics - Drivers
+
+Common drivers which can be used by various NicheGraphics UIs
diff --git a/src/graphics/eink/Drivers/README.md b/src/graphics/eink/Drivers/README.md
new file mode 100644
index 000000000..5ff969ce2
--- /dev/null
+++ b/src/graphics/eink/Drivers/README.md
@@ -0,0 +1,132 @@
+# NicheGraphics - E-Ink Driver
+
+A driver for E-Ink SPI displays. Suitable for re-use by various NicheGraphics UIs.
+
+Your UI should use the class `NicheGraphics::Drivers::EInk` .
+When you set up a hardware variant, you will use one of the specific display model classes, which extend the EInk class.
+
+An example setup might look like this:
+
+```cpp
+void setupNicheGraphics()
+{
+ using namespace NicheGraphics;
+
+ // An imaginary UI
+ YourCustomUI *yourUI = new YourCustomUI();
+
+ // Setup SPI
+ SPIClass *hspi = new SPIClass(HSPI);
+ hspi->begin(PIN_EINK_SCLK, -1, PIN_EINK_MOSI, PIN_EINK_CS);
+
+ // Setup EInk driver
+ Drivers::EInk *driver = new Drivers::DEPG0290BNS800();
+ driver->begin(hspi, PIN_EINK_DC, PIN_EINK_CS, PIN_EINK_BUSY);
+
+ // Pass the driver to your UI
+ YourUI::driver = driver;
+}
+```
+
+- [Methods](#methods)
+ - [`update(uint8_t *imageData, UpdateTypes type)`](#updateuint8_t-imagedata-updatetypes-type)
+ - [`await()`](#await)
+ - [`supports(UpdateTypes type)`](#supportsupdatetypes-type)
+ - [`busy()`](#busy)
+ - [`width()`](#width)
+ - [`height()`](#height)
+- [Supporting New Displays](#supporting-new-displays)
+ - [Controller IC](#controller-ic)
+ - [Finding Information](#finding-information)
+
+## Methods
+
+### `update(uint8_t *imageData, UpdateTypes type)`
+
+Update the image on the display
+
+- _`imageData`_ to draw to the display.
+- _`type`_ which type of update to perform.
+ - `FULL`
+ - `FAST` (partial refresh)
+ - (Other custom types may be possible)
+
+The imageData is a 1-bit image. X-Pixels are 8-per byte, with the MSB being the leftmost pixel. This was not an InkHUD design decision; it is the raw format accepted by the E-Ink display controllers ICs.
+
+_To-do: add a helper method to `NicheGraphics::Drivers::EInk` to do this arithmetic for you._
+
+```cpp
+uint16_t w = driver->width;
+uint16_t h = driver->height;
+
+uint8_t image[ (w/8) * h ]; // X pixels are 8-per-byte
+
+image[0] |= (1 << 7); // Set pixel x=0, y=0
+image[0] |= (1 << 0); // Set pixel x=7, y=0
+image[1] |= (1 << 7); // Set pixel x=8, y=0
+
+uint8_t x = 12;
+uint8_t y = 2;
+uint8_t yBytes = y * (w/8);
+uint8_t xBytes = x / 8;
+uint8_t xBits = (7-x) % 8;
+image[yBytes + xBytes] |= (1 << xBits); // Set pixel x=12, y=2
+```
+
+### `await()`
+
+Wait for an in-progress update to complete before continuing
+
+### `supports(UpdateTypes type)`
+
+Check if display supports a specific update type. `true` if supported.
+
+- _`type`_ type to check
+
+### `busy()`
+
+Check if display is already performing an `update()`. `true` if already updating.
+
+### `width`
+
+Width of the display, in pixels. Note: most displays are portrait. Your UI will need to implement rotation in software.
+
+### `height`
+
+Height of the display, in pixels. Note: most displays are portrait. Your UI will need to implement rotation in software.
+
+## Supporting New Displays
+
+_This topic is not covered in depth, but these notes may be helpful._
+
+The `NicheGraphics::Drivers::EInk` class contains only the mechanism for implementing an E-Ink driver on-top of Meshtastic's `OSThread`. A driver for a specific display needs to extend this class.
+
+### Controller IC
+
+If your display uses a controller IC from Solomon Systech, you can probably extend the existing `Drivers::SSD16XX` class, making only minor modifications.
+
+At this stage, displays using controller ICS from other manufacturers (UltraChip, Fitipower, etc) need to manually implemented. See `Drivers::LCMEN2R13EFC1` for an example.
+
+Generic base classes for manufacturers other than Solomon Systech might be added here in the future.
+
+### Finding Information
+
+#### Flex-Connector Labels
+
+The orange flex-connector attached to E-Ink displays is often printed with an identifying label. This is not a _totally_ unique identifier, but does give a very strong clue as to the true model of the display, which can be used to search out further information.
+
+#### Datasheets
+
+The manufacturer of a DIY display module may publish a datasheet. These are often incomplete, but might reveal the true model of the display, or the controller IC.
+
+If you can determine the true model name of the display, you can likely find a more complete datasheet on the display manufacturer's website. This will often provide a "typical operating sequence"; a general overview of the code used to drive the display
+
+#### Example Code
+
+The manufacturer of a DIY module may publish example code. You may have more luck finding example code published by the display manufacturer themselves, if you can determine the true model of the panel. These examples are a very valuable reference.
+
+#### Other E-Ink drivers
+
+Libraries like ZinggJM's GxEPD2 can be valuable sources of information, although your panel may not be _specifically_ supported, and only _compatible_ with a driver there, so some caution is advised.
+
+The display selection file in GxEPD2's Hello World example is also a useful resource for matching "flex connector labels" with display models, but the flex connector label is _not_ a unique identifier, so this is only another clue.
diff --git a/src/graphics/eink/Drivers/SSD1682.cpp b/src/graphics/eink/Drivers/SSD1682.cpp
new file mode 100644
index 000000000..e63b96333
--- /dev/null
+++ b/src/graphics/eink/Drivers/SSD1682.cpp
@@ -0,0 +1,42 @@
+#include "./SSD1682.h"
+
+#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS
+
+using namespace NicheGraphics::Drivers;
+
+SSD1682::SSD1682(uint16_t width, uint16_t height, EInk::UpdateTypes supported, uint8_t bufferOffsetX)
+ : SSD16XX(width, height, supported, bufferOffsetX)
+{
+}
+
+// SSD1682 only accepts single-byte x and y values
+// This causes an incompatibility with the default SSD16XX::configFullscreen
+void SSD1682::configFullscreen()
+{
+ // Define the boundaries of the "fullscreen" region, for the controller IC
+ // Not static: bounds must come from this instance, not whichever instance ran first
+ const uint8_t sx = bufferOffsetX; // Notice the offset
+ const uint8_t sy = 0;
+ const uint8_t ex = bufferRowSize + bufferOffsetX - 1; // End is "max index", not "count". Minus 1 handles this
+ const uint8_t ey = height - 1; // Same: 0x45 Y-range is inclusive
+
+ // Data entry mode - Left to Right, Top to Bottom
+ sendCommand(0x11);
+ sendData(0x03);
+
+ // Select controller IC memory region to display a fullscreen image
+ sendCommand(0x44); // Memory X start - end
+ sendData(sx);
+ sendData(ex);
+ sendCommand(0x45); // Memory Y start - end
+ sendData(sy);
+ sendData(ey);
+
+ // Place the cursor at the start of this memory region, ready to send image data x=0 y=0
+ sendCommand(0x4E); // Memory cursor X
+ sendData(sx);
+ sendCommand(0x4F); // Memory cursor y
+ sendData(sy);
+}
+
+#endif
\ No newline at end of file
diff --git a/src/graphics/eink/Drivers/SSD1682.h b/src/graphics/eink/Drivers/SSD1682.h
new file mode 100644
index 000000000..ba3008537
--- /dev/null
+++ b/src/graphics/eink/Drivers/SSD1682.h
@@ -0,0 +1,31 @@
+/*
+
+E-Ink base class for displays based on SSD1682
+
+SSD1682 has a few quirks. We're implementing them here in a new base class,
+to avoid re-implementing them every time we need to add a new SSD1682-based display.
+
+*/
+
+#pragma once
+
+#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS
+
+#include "configuration.h"
+
+#include "./SSD16XX.h"
+
+namespace NicheGraphics::Drivers
+{
+
+class SSD1682 : public SSD16XX
+{
+ public:
+ SSD1682(uint16_t width, uint16_t height, EInk::UpdateTypes supported, uint8_t bufferOffsetX = 0);
+ virtual void configFullscreen(); // Select memory region on controller IC
+ virtual void deepSleep() {} // Not usable (image memory not retained)
+};
+
+} // namespace NicheGraphics::Drivers
+
+#endif // MESHTASTIC_INCLUDE_NICHE_GRAPHICS
\ No newline at end of file
diff --git a/src/graphics/eink/Drivers/SSD16XX.cpp b/src/graphics/eink/Drivers/SSD16XX.cpp
new file mode 100644
index 000000000..a91c51a7d
--- /dev/null
+++ b/src/graphics/eink/Drivers/SSD16XX.cpp
@@ -0,0 +1,273 @@
+#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS
+
+#include "./SSD16XX.h"
+
+#include "SPILock.h"
+
+using namespace NicheGraphics::Drivers;
+
+SSD16XX::SSD16XX(uint16_t width, uint16_t height, UpdateTypes supported, uint8_t bufferOffsetX)
+ : EInk(width, height, supported), bufferOffsetX(bufferOffsetX)
+{
+ // Pre-calculate size of the image buffer, for convenience
+
+ // Determine the X dimension of the image buffer, in bytes.
+ // Along rows, pixels are stored 8 per byte.
+ // Not all display widths are divisible by 8. Need to make sure bytecount accommodates padding for these.
+ bufferRowSize = ((width - 1) / 8) + 1;
+
+ // Total size of image buffer, in bytes.
+ bufferSize = bufferRowSize * height;
+}
+
+void SSD16XX::begin(SPIClass *spi, uint8_t pin_dc, uint8_t pin_cs, uint8_t pin_busy, uint8_t pin_rst)
+{
+ this->spi = spi;
+ this->pin_dc = pin_dc;
+ this->pin_cs = pin_cs;
+ this->pin_busy = pin_busy;
+ this->pin_rst = pin_rst;
+
+ pinMode(pin_dc, OUTPUT);
+ pinMode(pin_cs, OUTPUT);
+ pinMode(pin_busy, INPUT);
+
+ // If using a reset pin, hold high
+ // Reset is active low for Solomon Systech ICs
+ if (pin_rst != 0xFF)
+ pinMode(pin_rst, INPUT_PULLUP);
+
+ reset();
+}
+
+// Poll the displays busy pin until an operation is complete
+// Timeout and set fail flag if something went wrong and the display got stuck
+void SSD16XX::wait(uint32_t timeout)
+{
+ // Don't bother waiting if part of the update sequence failed
+ // In that situation, we're now just failing-through the process, until we can try again with next update.
+ if (failed)
+ return;
+
+ uint32_t startMs = millis();
+
+ // Busy when HIGH
+ while (digitalRead(pin_busy) == HIGH) {
+ // Check for timeout
+ if (millis() - startMs > timeout) {
+ failed = true;
+ break;
+ }
+ yield();
+ }
+}
+
+void SSD16XX::reset()
+{
+ // Check if reset pin is defined
+ if (pin_rst != 0xFF) {
+ pinMode(pin_rst, OUTPUT);
+ digitalWrite(pin_rst, LOW);
+ delay(10);
+ digitalWrite(pin_rst, HIGH);
+ delay(10);
+ wait();
+ }
+
+ sendCommand(0x12);
+ wait();
+}
+
+void SSD16XX::sendCommand(const uint8_t command)
+{
+ // Abort if part of the update sequence failed
+ // This will unlock again once we have failed-through the entire process
+ if (failed)
+ return;
+
+ // Take firmware's SPI lock
+ spiLock->lock();
+
+ spi->beginTransaction(spiSettings);
+ digitalWrite(pin_dc, LOW); // DC pin low indicates command
+ digitalWrite(pin_cs, LOW);
+ spi->transfer(command);
+ digitalWrite(pin_cs, HIGH);
+ digitalWrite(pin_dc, HIGH);
+ spi->endTransaction();
+
+ spiLock->unlock();
+}
+
+void SSD16XX::sendData(uint8_t data)
+{
+ sendData(&data, 1);
+}
+
+void SSD16XX::sendData(const uint8_t *data, uint32_t size)
+{
+ // Abort if part of the update sequence failed
+ // This will unlock again once we have failed-through the entire process
+ if (failed)
+ return;
+
+ // Take firmware's SPI lock
+ spiLock->lock();
+
+ spi->beginTransaction(spiSettings);
+ digitalWrite(pin_dc, HIGH); // DC pin HIGH indicates data, instead of command
+ digitalWrite(pin_cs, LOW);
+
+ // Platform-specific SPI command
+#if defined(ARCH_ESP32)
+ spi->transferBytes(data, NULL, size); // NULL for a "write only" transfer
+#elif defined(ARCH_NRF52)
+ spi->transfer(data, NULL, size); // NULL for a "write only" transfer
+#else
+#error Not implemented yet? Feel free to add other platforms here.
+#endif
+
+ digitalWrite(pin_cs, HIGH);
+ digitalWrite(pin_dc, HIGH);
+ spi->endTransaction();
+
+ spiLock->unlock();
+}
+
+void SSD16XX::configFullscreen()
+{
+ // Placing this code in a separate method because it's probably pretty consistent between displays
+ // Should make it tidier to override SSD16XX::configure
+
+ // Define the boundaries of the "fullscreen" region, for the controller IC
+ // Not static: bounds must come from this instance, not whichever instance ran first
+ const uint16_t sx = bufferOffsetX; // Notice the offset
+ const uint16_t sy = 0;
+ const uint16_t ex = bufferRowSize + bufferOffsetX - 1; // End is "max index", not "count". Minus 1 handles this
+ const uint16_t ey = height - 1; // Same: 0x45 Y-range is inclusive
+
+ // Split into bytes
+ const uint8_t sy1 = sy & 0xFF;
+ const uint8_t sy2 = (sy >> 8) & 0xFF;
+ const uint8_t ey1 = ey & 0xFF;
+ const uint8_t ey2 = (ey >> 8) & 0xFF;
+
+ // Data entry mode - Left to Right, Top to Bottom
+ sendCommand(0x11);
+ sendData(0x03);
+
+ // Select controller IC memory region to display a fullscreen image
+ sendCommand(0x44); // Memory X start - end
+ sendData(sx);
+ sendData(ex);
+ sendCommand(0x45); // Memory Y start - end
+ sendData(sy1);
+ sendData(sy2);
+ sendData(ey1);
+ sendData(ey2);
+
+ // Place the cursor at the start of this memory region, ready to send image data x=0 y=0
+ sendCommand(0x4E); // Memory cursor X
+ sendData(sx);
+ sendCommand(0x4F); // Memory cursor y
+ sendData(sy1);
+ sendData(sy2);
+}
+
+void SSD16XX::update(uint8_t *imageData, UpdateTypes type)
+{
+ this->updateType = type;
+ this->buffer = imageData;
+
+ reset();
+
+ configFullscreen();
+ configScanning(); // Virtual, unused by base class
+ configVoltages(); // Virtual, unused by base class
+ configWaveform(); // Virtual, unused by base class
+ wait();
+
+ if (updateType == FULL) {
+ writeNewImage();
+ writeOldImage();
+ } else {
+ writeNewImage();
+ }
+
+ configUpdateSequence();
+ sendCommand(0x20); // Begin executing the update
+
+ // Let the update run async, on display hardware. Base class will poll completion, then finalize.
+ // For a blocking update, call await after update
+ detachFromUpdate();
+}
+
+// Send SPI commands for controller IC to begin executing the refresh operation
+void SSD16XX::configUpdateSequence()
+{
+ switch (updateType) {
+ default:
+ sendCommand(0x22); // Set "update sequence"
+ sendData(0xF7); // Non-differential, load waveform from OTP
+ break;
+ }
+}
+
+void SSD16XX::writeNewImage()
+{
+ sendCommand(0x24);
+ sendData(buffer, bufferSize);
+}
+
+void SSD16XX::writeOldImage()
+{
+ sendCommand(0x26);
+ sendData(buffer, bufferSize);
+}
+
+void SSD16XX::detachFromUpdate()
+{
+ // To save power / cycles, displays can choose to specify an "expected duration" for various refresh types
+ // If we know a full-refresh takes at least 4 seconds, we can delay polling until 3 seconds have passed
+ // If not implemented, we'll just poll right from the get-go
+ switch (updateType) {
+ default:
+ EInk::beginPolling(100, 0);
+ }
+}
+
+bool SSD16XX::isUpdateDone()
+{
+ // Busy when HIGH
+ if (digitalRead(pin_busy) == HIGH)
+ return false;
+ else
+ return true;
+}
+
+void SSD16XX::finalizeUpdate()
+{
+ // Put a copy of the image into the "old memory".
+ // Used with differential refreshes (e.g. FAST update), to determine which px need to move, and which can remain in place
+ // We need to keep the "old memory" up to date, because don't know whether next refresh will be FULL or FAST etc.
+ if (updateType != FULL) {
+ writeNewImage(); // Only required by some controller variants. Todo: Override just for GDEY0154D678?
+ writeOldImage();
+ sendCommand(0x7F); // Terminate image write without update
+ wait();
+ }
+
+ // Enter deep-sleep to save a few µA
+ // Waking from this requires that display's reset pin is broken out
+ if (pin_rst != 0xFF)
+ deepSleep();
+}
+
+// Enter a lower-power state
+// May only save a few µA..
+void SSD16XX::deepSleep()
+{
+ sendCommand(0x10); // Enter deep sleep
+ sendData(0x01); // Mode 1: preserve image RAM
+}
+#endif // MESHTASTIC_INCLUDE_NICHE_GRAPHICS
\ No newline at end of file
diff --git a/src/graphics/eink/Drivers/SSD16XX.h b/src/graphics/eink/Drivers/SSD16XX.h
new file mode 100644
index 000000000..3f92818ce
--- /dev/null
+++ b/src/graphics/eink/Drivers/SSD16XX.h
@@ -0,0 +1,66 @@
+/*
+
+E-Ink base class for displays based on SSD16XX
+
+Most (but not all) SPI E-Ink displays use this family of controller IC.
+Implementing new SSD16XX displays should be fairly painless.
+See DEPG0154BNS800 and DEPG0290BNS800 for examples.
+
+*/
+
+#pragma once
+
+#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS
+
+#include "configuration.h"
+
+#include "./EInk.h"
+
+namespace NicheGraphics::Drivers
+{
+
+class SSD16XX : public EInk
+{
+ public:
+ SSD16XX(uint16_t width, uint16_t height, UpdateTypes supported, uint8_t bufferOffsetX = 0);
+ virtual void begin(SPIClass *spi, uint8_t pin_dc, uint8_t pin_cs, uint8_t pin_busy, uint8_t pin_rst = -1);
+ virtual void update(uint8_t *imageData, UpdateTypes type) override;
+
+ protected:
+ virtual void wait(uint32_t timeout = 1000);
+ virtual void reset();
+ virtual void sendCommand(const uint8_t command);
+ virtual void sendData(const uint8_t data);
+ virtual void sendData(const uint8_t *data, uint32_t size);
+ virtual void configFullscreen(); // Select memory region on controller IC
+ virtual void configScanning() {} // Optional. First & last gates, scan direction, etc
+ virtual void configVoltages() {} // Optional. Manual panel voltages, soft-start, etc
+ virtual void configWaveform() {} // Optional. LUT, panel border, temperature sensor, etc
+ virtual void configUpdateSequence(); // Tell controller IC which operations to run
+
+ virtual void writeNewImage();
+ virtual void writeOldImage(); // Image which can be used at *next* update for "differential refresh"
+
+ virtual void detachFromUpdate();
+ virtual bool isUpdateDone() override;
+ virtual void finalizeUpdate() override;
+ virtual void deepSleep();
+
+ protected:
+ uint8_t bufferOffsetX = 0; // In bytes. Panel x=0 does not always align with controller x=0. Quirky internal wiring?
+ uint8_t bufferRowSize = 0; // In bytes. Rows store 8 pixels per byte. Rounded up to fit (e.g. 122px would require 16 bytes)
+ uint32_t bufferSize = 0; // In bytes. Rows * Columns
+ uint8_t *buffer = nullptr;
+ UpdateTypes updateType = UpdateTypes::UNSPECIFIED;
+
+ uint8_t pin_dc = -1;
+ uint8_t pin_cs = -1;
+ uint8_t pin_busy = -1;
+ uint8_t pin_rst = -1;
+ SPIClass *spi = nullptr;
+ SPISettings spiSettings = SPISettings(4000000, MSBFIRST, SPI_MODE0);
+};
+
+} // namespace NicheGraphics::Drivers
+
+#endif // MESHTASTIC_INCLUDE_NICHE_GRAPHICS
\ No newline at end of file
diff --git a/src/graphics/eink/Drivers/UC8175.cpp b/src/graphics/eink/Drivers/UC8175.cpp
new file mode 100644
index 000000000..151b5436e
--- /dev/null
+++ b/src/graphics/eink/Drivers/UC8175.cpp
@@ -0,0 +1,201 @@
+#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS
+
+#include "./UC8175.h"
+
+#include
+
+#include "SPILock.h"
+
+using namespace NicheGraphics::Drivers;
+
+UC8175::UC8175(uint16_t width, uint16_t height, UpdateTypes supported) : EInk(width, height, supported)
+{
+ bufferRowSize = ((width - 1) / 8) + 1;
+ bufferSize = bufferRowSize * height;
+}
+
+void UC8175::begin(SPIClass *spi, uint8_t pin_dc, uint8_t pin_cs, uint8_t pin_busy, uint8_t pin_rst)
+{
+ this->spi = spi;
+ this->pin_dc = pin_dc;
+ this->pin_cs = pin_cs;
+ this->pin_busy = pin_busy;
+ this->pin_rst = pin_rst;
+
+ pinMode(pin_dc, OUTPUT);
+ pinMode(pin_cs, OUTPUT);
+ pinMode(pin_busy, INPUT);
+
+ // Reset is active LOW, hold HIGH when idle.
+ if (pin_rst != (uint8_t)-1) {
+ pinMode(pin_rst, OUTPUT);
+ digitalWrite(pin_rst, HIGH);
+ }
+
+ if (!previousBuffer) {
+ previousBuffer = new uint8_t[bufferSize];
+ if (previousBuffer)
+ memset(previousBuffer, 0xFF, bufferSize);
+ }
+}
+
+void UC8175::update(uint8_t *imageData, UpdateTypes type)
+{
+ buffer = imageData;
+ updateType = (type == UpdateTypes::UNSPECIFIED) ? UpdateTypes::FULL : type;
+
+ if (updateType == FAST && hasPreviousBuffer && previousBuffer && memcmp(previousBuffer, buffer, bufferSize) == 0)
+ return;
+
+ reset();
+ configCommon();
+
+ if (updateType == FAST)
+ configFast();
+ else
+ configFull();
+
+ writeOldImage();
+ writeNewImage();
+ sendCommand(0x12); // Display refresh.
+
+ if (previousBuffer) {
+ memcpy(previousBuffer, buffer, bufferSize);
+ hasPreviousBuffer = true;
+ }
+
+ detachFromUpdate();
+}
+
+void UC8175::wait(uint32_t timeoutMs)
+{
+ if (failed)
+ return;
+
+ uint32_t started = millis();
+ while (digitalRead(pin_busy) == BUSY_ACTIVE) {
+ if ((millis() - started) > timeoutMs) {
+ failed = true;
+ break;
+ }
+ yield();
+ }
+}
+
+void UC8175::reset()
+{
+ if (pin_rst != (uint8_t)-1) {
+ digitalWrite(pin_rst, LOW);
+ delay(20);
+ digitalWrite(pin_rst, HIGH);
+ delay(20);
+ }
+ // No soft-reset fallback: UC8175 command 0x12 is display refresh, not reset.
+
+ wait(3000);
+}
+
+void UC8175::sendCommand(uint8_t command)
+{
+ if (failed)
+ return;
+
+ spiLock->lock();
+ spi->beginTransaction(spiSettings);
+ digitalWrite(pin_dc, LOW);
+ digitalWrite(pin_cs, LOW);
+ spi->transfer(command);
+ digitalWrite(pin_cs, HIGH);
+ digitalWrite(pin_dc, HIGH);
+ spi->endTransaction();
+ spiLock->unlock();
+}
+
+void UC8175::sendData(uint8_t data)
+{
+ sendData(&data, 1);
+}
+
+void UC8175::sendData(const uint8_t *data, uint32_t size)
+{
+ if (failed)
+ return;
+
+ spiLock->lock();
+ spi->beginTransaction(spiSettings);
+ digitalWrite(pin_dc, HIGH);
+ digitalWrite(pin_cs, LOW);
+
+#if defined(ARCH_ESP32)
+ spi->transferBytes(data, NULL, size);
+#elif defined(ARCH_NRF52)
+ spi->transfer(data, NULL, size);
+#else
+ for (uint32_t i = 0; i < size; ++i)
+ spi->transfer(data[i]);
+#endif
+
+ digitalWrite(pin_cs, HIGH);
+ digitalWrite(pin_dc, HIGH);
+ spi->endTransaction();
+ spiLock->unlock();
+}
+
+void UC8175::powerOn()
+{
+ sendCommand(0x04);
+ wait(2000);
+}
+
+void UC8175::powerOff()
+{
+ sendCommand(0x02); // Power off.
+ wait(1500);
+}
+
+void UC8175::writeImage(uint8_t command, const uint8_t *image)
+{
+ sendCommand(command);
+ sendData(image, bufferSize);
+}
+
+void UC8175::writeOldImage()
+{
+ if (updateType == FAST && previousBuffer)
+ writeImage(0x10, previousBuffer);
+ else
+ writeImage(0x10, buffer);
+}
+
+void UC8175::writeNewImage()
+{
+ writeImage(0x13, buffer);
+}
+
+void UC8175::detachFromUpdate()
+{
+ switch (updateType) {
+ case FAST:
+ return beginPolling(50, 400);
+ case FULL:
+ default:
+ return beginPolling(100, 2000);
+ }
+}
+
+bool UC8175::isUpdateDone()
+{
+ return digitalRead(pin_busy) != BUSY_ACTIVE;
+}
+
+void UC8175::finalizeUpdate()
+{
+ powerOff();
+
+ if (pin_rst != (uint8_t)-1) {
+ sendCommand(0x07); // Deep sleep.
+ sendData(0xA5);
+ }
+}
+
+#endif // MESHTASTIC_INCLUDE_NICHE_GRAPHICS
diff --git a/src/graphics/eink/Drivers/UC8175.h b/src/graphics/eink/Drivers/UC8175.h
new file mode 100644
index 000000000..b248d4bea
--- /dev/null
+++ b/src/graphics/eink/Drivers/UC8175.h
@@ -0,0 +1,62 @@
+// E-Ink base class for displays based on UC8175 / UC8176 style controller ICs.
+
+#pragma once
+
+#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS
+
+#include "configuration.h"
+
+#include "./EInk.h"
+
+namespace NicheGraphics::Drivers
+{
+
+class UC8175 : public EInk
+{
+ public:
+ UC8175(uint16_t width, uint16_t height, UpdateTypes supported);
+ void begin(SPIClass *spi, uint8_t pin_dc, uint8_t pin_cs, uint8_t pin_busy, uint8_t pin_rst = -1) override;
+ void update(uint8_t *imageData, UpdateTypes type) override;
+
+ protected:
+ virtual void wait(uint32_t timeoutMs = 1000);
+ virtual void reset();
+ virtual void sendCommand(uint8_t command);
+ virtual void sendData(uint8_t data);
+ virtual void sendData(const uint8_t *data, uint32_t size);
+
+ virtual void configCommon() = 0; // Always run
+ virtual void configFull() = 0; // Run when updateType == FULL
+ virtual void configFast() = 0; // Run when updateType == FAST
+
+ virtual void powerOn();
+ virtual void powerOff();
+ virtual void writeOldImage();
+ virtual void writeNewImage();
+ virtual void writeImage(uint8_t command, const uint8_t *image);
+
+ virtual void detachFromUpdate();
+ virtual bool isUpdateDone() override;
+ virtual void finalizeUpdate() override;
+
+ protected:
+ static constexpr uint8_t BUSY_ACTIVE = LOW;
+
+ uint16_t bufferRowSize = 0;
+ uint32_t bufferSize = 0;
+ uint8_t *buffer = nullptr;
+ uint8_t *previousBuffer = nullptr;
+ bool hasPreviousBuffer = false;
+ UpdateTypes updateType = UpdateTypes::UNSPECIFIED;
+
+ uint8_t pin_dc = (uint8_t)-1;
+ uint8_t pin_cs = (uint8_t)-1;
+ uint8_t pin_busy = (uint8_t)-1;
+ uint8_t pin_rst = (uint8_t)-1;
+ SPIClass *spi = nullptr;
+ SPISettings spiSettings = SPISettings(8000000, MSBFIRST, SPI_MODE0);
+};
+
+} // namespace NicheGraphics::Drivers
+
+#endif // MESHTASTIC_INCLUDE_NICHE_GRAPHICS
diff --git a/src/graphics/eink/Drivers/ZJY122250_0213BAAMFGN.cpp b/src/graphics/eink/Drivers/ZJY122250_0213BAAMFGN.cpp
new file mode 100644
index 000000000..e83588905
--- /dev/null
+++ b/src/graphics/eink/Drivers/ZJY122250_0213BAAMFGN.cpp
@@ -0,0 +1,68 @@
+#include "./ZJY122250_0213BAAMFGN.h"
+
+#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS
+
+using namespace NicheGraphics::Drivers;
+
+// Map the display controller IC's output to the connected panel
+void ZJY122250_0213BAAMFGN::configScanning()
+{
+ // "Driver output control"
+ // Scan gates from 0 to 249 (vertical resolution 250px)
+ sendCommand(0x01);
+ sendData(0xF9);
+ sendData(0x00);
+ sendData(0x00);
+}
+
+// Specify which information is used to control the sequence of voltages applied to move the pixels
+// - For this display, configUpdateSequence() specifies that a suitable LUT will be loaded from
+// the controller IC's OTP memory, when the update procedure begins.
+void ZJY122250_0213BAAMFGN::configWaveform()
+{
+ switch (updateType) {
+ case FAST:
+ sendCommand(0x3C); // Border waveform:
+ sendData(0x80); // VCOM
+ break;
+ case FULL:
+ default:
+ sendCommand(0x3C); // Border waveform:
+ sendData(0x01); // Follow LUT 1 (blink same as white pixels)
+ break;
+ }
+
+ sendCommand(0x18); // Temperature sensor:
+ sendData(0x80); // Use internal temperature sensor to select an appropriate refresh waveform
+}
+
+void ZJY122250_0213BAAMFGN::configUpdateSequence()
+{
+ switch (updateType) {
+ case FAST:
+ sendCommand(0x22); // Set "update sequence"
+ sendData(0xFF); // Will load LUT from OTP memory, Display mode 2 "differential refresh"
+ break;
+
+ case FULL:
+ default:
+ sendCommand(0x22); // Set "update sequence"
+ sendData(0xF7); // Will load LUT from OTP memory
+ break;
+ }
+}
+
+// Once the refresh operation has been started,
+// begin periodically polling the display to check for completion, using the normal Meshtastic threading code
+// Only used when refresh is "async"
+void ZJY122250_0213BAAMFGN::detachFromUpdate()
+{
+ switch (updateType) {
+ case FAST:
+ return beginPolling(50, 500); // At least 500ms for fast refresh
+ case FULL:
+ default:
+ return beginPolling(100, 2000); // At least 2 seconds for full refresh
+ }
+}
+#endif // MESHTASTIC_INCLUDE_NICHE_GRAPHICS
\ No newline at end of file
diff --git a/src/graphics/eink/Drivers/ZJY122250_0213BAAMFGN.h b/src/graphics/eink/Drivers/ZJY122250_0213BAAMFGN.h
new file mode 100644
index 000000000..82c4ec107
--- /dev/null
+++ b/src/graphics/eink/Drivers/ZJY122250_0213BAAMFGN.h
@@ -0,0 +1,42 @@
+/*
+
+E-Ink display driver
+ - ZJY122250_0213BAAMFGN
+ - Manufacturer: Zhongjingyuan
+ - Size: 2.13 inch
+ - Resolution: 250px x 122px
+ - Flex connector marking (not a unique identifier): FPC-A002
+
+*/
+
+#pragma once
+
+#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS
+
+#include "configuration.h"
+
+#include "./SSD16XX.h"
+
+namespace NicheGraphics::Drivers
+{
+class ZJY122250_0213BAAMFGN : public SSD16XX
+{
+ // Display properties
+ private:
+ static constexpr uint32_t width = 122;
+ static constexpr uint32_t height = 250;
+ static constexpr UpdateTypes supported = (UpdateTypes)(FULL | FAST);
+
+ public:
+ ZJY122250_0213BAAMFGN() : SSD16XX(width, height, supported) {}
+
+ protected:
+ virtual void configScanning() override;
+ virtual void configWaveform() override;
+ virtual void configUpdateSequence() override;
+ void detachFromUpdate() override;
+};
+
+} // namespace NicheGraphics::Drivers
+
+#endif // MESHTASTIC_INCLUDE_NICHE_GRAPHICS
\ No newline at end of file
diff --git a/src/graphics/eink/Drivers/ZJY128296_029EAAMFGN.cpp b/src/graphics/eink/Drivers/ZJY128296_029EAAMFGN.cpp
new file mode 100644
index 000000000..a8f43420f
--- /dev/null
+++ b/src/graphics/eink/Drivers/ZJY128296_029EAAMFGN.cpp
@@ -0,0 +1,59 @@
+#include "./ZJY128296_029EAAMFGN.h"
+
+#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS
+
+using namespace NicheGraphics::Drivers;
+
+// Map the display controller IC's output to the connected panel
+void ZJY128296_029EAAMFGN::configScanning()
+{
+ // "Driver output control"
+ // Scan gates from 0 to 295 (vertical resolution 296px)
+ sendCommand(0x01);
+ sendData(0x27); // Number of gates (295, bits 0-7)
+ sendData(0x01); // Number of gates (295, bit 8)
+ sendData(0x00); // (Do not invert scanning order)
+}
+
+// Specify which information is used to control the sequence of voltages applied to move the pixels
+// - For this display, configUpdateSequence() specifies that a suitable LUT will be loaded from
+// the controller IC's OTP memory, when the update procedure begins.
+void ZJY128296_029EAAMFGN::configWaveform()
+{
+ sendCommand(0x3C); // Border waveform:
+ sendData(0x05); // Screen border should follow LUT1 waveform (actively drive pixels white)
+
+ sendCommand(0x18); // Temperature sensor:
+ sendData(0x80); // Use internal temperature sensor to select an appropriate refresh waveform
+}
+
+void ZJY128296_029EAAMFGN::configUpdateSequence()
+{
+ switch (updateType) {
+ case FAST:
+ sendCommand(0x22); // Set "update sequence"
+ sendData(0xFF); // Will load LUT from OTP memory, Display mode 2 "differential refresh"
+ break;
+
+ case FULL:
+ default:
+ sendCommand(0x22); // Set "update sequence"
+ sendData(0xF7); // Will load LUT from OTP memory
+ break;
+ }
+}
+
+// Once the refresh operation has been started,
+// begin periodically polling the display to check for completion, using the normal Meshtastic threading code
+// Only used when refresh is "async"
+void ZJY128296_029EAAMFGN::detachFromUpdate()
+{
+ switch (updateType) {
+ case FAST:
+ return beginPolling(50, 300); // At least 300ms for fast refresh
+ case FULL:
+ default:
+ return beginPolling(100, 2000); // At least 2 seconds for full refresh
+ }
+}
+#endif // MESHTASTIC_INCLUDE_NICHE_GRAPHICS
\ No newline at end of file
diff --git a/src/graphics/eink/Drivers/ZJY128296_029EAAMFGN.h b/src/graphics/eink/Drivers/ZJY128296_029EAAMFGN.h
new file mode 100644
index 000000000..27644e709
--- /dev/null
+++ b/src/graphics/eink/Drivers/ZJY128296_029EAAMFGN.h
@@ -0,0 +1,44 @@
+/*
+
+E-Ink display driver
+ - ZJY128296-029EAAMFGN
+ - Manufacturer: Zhongjingyuan
+ - Size: 2.9 inch
+ - Resolution: 128px x 296px
+ - Flex connector label (not a unique identifier): FPC-A005 20.06.15 TRX
+
+ Note: as of Feb. 2025, these panels are used for "WeActStudio 2.9in B&W" display modules
+
+*/
+
+#pragma once
+
+#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS
+
+#include "configuration.h"
+
+#include "./SSD16XX.h"
+
+namespace NicheGraphics::Drivers
+{
+class ZJY128296_029EAAMFGN : public SSD16XX
+{
+ // Display properties
+ private:
+ static constexpr uint32_t width = 128;
+ static constexpr uint32_t height = 296;
+ static constexpr UpdateTypes supported = (UpdateTypes)(FULL | FAST);
+
+ public:
+ ZJY128296_029EAAMFGN() : SSD16XX(width, height, supported) {}
+
+ protected:
+ void configScanning() override;
+ void configWaveform() override;
+ void configUpdateSequence() override;
+ void detachFromUpdate() override;
+};
+
+} // namespace NicheGraphics::Drivers
+
+#endif // MESHTASTIC_INCLUDE_NICHE_GRAPHICS
\ No newline at end of file
diff --git a/src/graphics/eink/Drivers/ZJY200200_0154DAAMFGN.h b/src/graphics/eink/Drivers/ZJY200200_0154DAAMFGN.h
new file mode 100644
index 000000000..fb16bcf2f
--- /dev/null
+++ b/src/graphics/eink/Drivers/ZJY200200_0154DAAMFGN.h
@@ -0,0 +1,32 @@
+/*
+
+E-Ink display driver
+ - ZJY200200-0154DAAMFGN
+ - Manufacturer: Zhongjingyuan
+ - Size: 1.54 inch
+ - Resolution: 200px x 200px
+ - Flex connector marking: FPC-B001
+
+ Note: as of Feb. 2025, these panels are used for "WeActStudio 1.54in B&W" display modules
+
+ This *is* a distinct panel, however the driver is currently identical to GDEY0154D67
+ We recognize it as separate now, to avoid breaking any custom builds if the drivers do need to diverge in future.
+
+*/
+
+#pragma once
+
+#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS
+
+#include "configuration.h"
+
+#include "./GDEY0154D67.h"
+
+namespace NicheGraphics::Drivers
+{
+
+typedef GDEY0154D67 ZJY200200_0154DAAMFGN;
+
+} // namespace NicheGraphics::Drivers
+
+#endif // MESHTASTIC_INCLUDE_NICHE_GRAPHICS
\ No newline at end of file
diff --git a/src/graphics/eink/Panels/DEPG0213BNS800.h b/src/graphics/eink/Panels/DEPG0213BNS800.h
new file mode 100644
index 000000000..9ea40b2fb
--- /dev/null
+++ b/src/graphics/eink/Panels/DEPG0213BNS800.h
@@ -0,0 +1,25 @@
+#pragma once
+
+#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS
+
+#include "./PanelProfile.h"
+#include "graphics/eink/Drivers/DEPG0213BNS800.h"
+
+namespace NicheGraphics::Panels
+{
+class DEPG0213BNS800 : public PanelProfile
+{
+ public:
+ NicheGraphics::Drivers::EInk *create() override
+ {
+ prePowerOn();
+ SPIClass *spi = beginSpi();
+ auto *drv = new NicheGraphics::Drivers::DEPG0213BNS800();
+ drv->begin(spi, pinDC(), pinCS(), pinBusy(), pinReset());
+ return drv;
+ }
+ uint8_t rotation() const override { return 3; }
+};
+} // namespace NicheGraphics::Panels
+
+#endif
diff --git a/src/graphics/eink/Panels/DEPG0290BNS800.h b/src/graphics/eink/Panels/DEPG0290BNS800.h
new file mode 100644
index 000000000..3ad4e0ac3
--- /dev/null
+++ b/src/graphics/eink/Panels/DEPG0290BNS800.h
@@ -0,0 +1,25 @@
+#pragma once
+
+#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS
+
+#include "./PanelProfile.h"
+#include "graphics/eink/Drivers/DEPG0290BNS800.h"
+
+namespace NicheGraphics::Panels
+{
+class DEPG0290BNS800 : public PanelProfile
+{
+ public:
+ NicheGraphics::Drivers::EInk *create() override
+ {
+ prePowerOn();
+ SPIClass *spi = beginSpi();
+ auto *drv = new NicheGraphics::Drivers::DEPG0290BNS800();
+ drv->begin(spi, pinDC(), pinCS(), pinBusy(), pinReset());
+ return drv;
+ }
+ uint8_t rotation() const override { return 1; }
+};
+} // namespace NicheGraphics::Panels
+
+#endif
diff --git a/src/graphics/eink/Panels/E0213A367.h b/src/graphics/eink/Panels/E0213A367.h
new file mode 100644
index 000000000..6637063c4
--- /dev/null
+++ b/src/graphics/eink/Panels/E0213A367.h
@@ -0,0 +1,25 @@
+#pragma once
+
+#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS
+
+#include "./PanelProfile.h"
+#include "graphics/eink/Drivers/E0213A367.h"
+
+namespace NicheGraphics::Panels
+{
+class E0213A367 : public PanelProfile
+{
+ public:
+ NicheGraphics::Drivers::EInk *create() override
+ {
+ prePowerOn();
+ SPIClass *spi = beginSpi();
+ auto *drv = new NicheGraphics::Drivers::E0213A367();
+ drv->begin(spi, pinDC(), pinCS(), pinBusy(), pinReset());
+ return drv;
+ }
+ uint8_t rotation() const override { return 3; }
+};
+} // namespace NicheGraphics::Panels
+
+#endif
diff --git a/src/graphics/eink/Panels/GDEH0122T61.h b/src/graphics/eink/Panels/GDEH0122T61.h
new file mode 100644
index 000000000..8a09f30dd
--- /dev/null
+++ b/src/graphics/eink/Panels/GDEH0122T61.h
@@ -0,0 +1,24 @@
+#pragma once
+
+#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS
+
+#include "./PanelProfile.h"
+#include "graphics/eink/Drivers/GDEH0122T61.h"
+
+namespace NicheGraphics::Panels
+{
+class GDEH0122T61 : public PanelProfile
+{
+ public:
+ NicheGraphics::Drivers::EInk *create() override
+ {
+ prePowerOn();
+ SPIClass *spi = beginSpi();
+ auto *drv = new NicheGraphics::Drivers::GDEH0122T61();
+ drv->begin(spi, pinDC(), pinCS(), pinBusy(), pinReset());
+ return drv;
+ }
+};
+} // namespace NicheGraphics::Panels
+
+#endif
diff --git a/src/graphics/eink/Panels/GDEQ031T10.h b/src/graphics/eink/Panels/GDEQ031T10.h
new file mode 100644
index 000000000..f1d0dfbe7
--- /dev/null
+++ b/src/graphics/eink/Panels/GDEQ031T10.h
@@ -0,0 +1,24 @@
+#pragma once
+
+#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS
+
+#include "./PanelProfile.h"
+#include "graphics/eink/Drivers/GDEQ031T10.h"
+
+namespace NicheGraphics::Panels
+{
+class GDEQ031T10 : public PanelProfile
+{
+ public:
+ NicheGraphics::Drivers::EInk *create() override
+ {
+ prePowerOn();
+ SPIClass *spi = beginSpi();
+ auto *drv = new NicheGraphics::Drivers::GDEQ031T10();
+ drv->begin(spi, pinDC(), pinCS(), pinBusy(), pinReset());
+ return drv;
+ }
+};
+} // namespace NicheGraphics::Panels
+
+#endif
diff --git a/src/graphics/eink/Panels/GDEW0102T4.h b/src/graphics/eink/Panels/GDEW0102T4.h
new file mode 100644
index 000000000..68eb62d2c
--- /dev/null
+++ b/src/graphics/eink/Panels/GDEW0102T4.h
@@ -0,0 +1,25 @@
+#pragma once
+
+#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS
+
+#include "./PanelProfile.h"
+#include "graphics/eink/Drivers/GDEW0102T4.h"
+
+namespace NicheGraphics::Panels
+{
+class GDEW0102T4 : public PanelProfile
+{
+ public:
+ NicheGraphics::Drivers::EInk *create() override
+ {
+ prePowerOn();
+ SPIClass *spi = beginSpi();
+ auto *drv = new NicheGraphics::Drivers::GDEW0102T4();
+ drv->begin(spi, pinDC(), pinCS(), pinBusy(), pinReset());
+ return drv;
+ }
+ uint8_t rotation() const override { return 3; }
+};
+} // namespace NicheGraphics::Panels
+
+#endif
diff --git a/src/graphics/eink/Panels/GDEY0154D67.h b/src/graphics/eink/Panels/GDEY0154D67.h
new file mode 100644
index 000000000..940bf7c8e
--- /dev/null
+++ b/src/graphics/eink/Panels/GDEY0154D67.h
@@ -0,0 +1,24 @@
+#pragma once
+
+#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS
+
+#include "./PanelProfile.h"
+#include "graphics/eink/Drivers/GDEY0154D67.h"
+
+namespace NicheGraphics::Panels
+{
+class GDEY0154D67 : public PanelProfile
+{
+ public:
+ NicheGraphics::Drivers::EInk *create() override
+ {
+ prePowerOn();
+ SPIClass *spi = beginSpi();
+ auto *drv = new NicheGraphics::Drivers::GDEY0154D67();
+ drv->begin(spi, pinDC(), pinCS(), pinBusy(), pinReset());
+ return drv;
+ }
+};
+} // namespace NicheGraphics::Panels
+
+#endif
diff --git a/src/graphics/eink/Panels/GDEY0213B74.h b/src/graphics/eink/Panels/GDEY0213B74.h
new file mode 100644
index 000000000..f743d477d
--- /dev/null
+++ b/src/graphics/eink/Panels/GDEY0213B74.h
@@ -0,0 +1,25 @@
+#pragma once
+
+#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS
+
+#include "./PanelProfile.h"
+#include "graphics/eink/Drivers/GDEY0213B74.h"
+
+namespace NicheGraphics::Panels
+{
+class GDEY0213B74 : public PanelProfile
+{
+ public:
+ NicheGraphics::Drivers::EInk *create() override
+ {
+ prePowerOn();
+ SPIClass *spi = beginSpi();
+ auto *drv = new NicheGraphics::Drivers::GDEY0213B74();
+ drv->begin(spi, pinDC(), pinCS(), pinBusy(), pinReset());
+ return drv;
+ }
+ uint8_t rotation() const override { return 3; }
+};
+} // namespace NicheGraphics::Panels
+
+#endif
diff --git a/src/graphics/eink/Panels/GDEY029T94.h b/src/graphics/eink/Panels/GDEY029T94.h
new file mode 100644
index 000000000..18cad37b2
--- /dev/null
+++ b/src/graphics/eink/Panels/GDEY029T94.h
@@ -0,0 +1,25 @@
+#pragma once
+
+#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS
+
+#include "./PanelProfile.h"
+#include "graphics/eink/Drivers/GDEY029T94.h"
+
+namespace NicheGraphics::Panels
+{
+class GDEY029T94 : public PanelProfile
+{
+ public:
+ NicheGraphics::Drivers::EInk *create() override
+ {
+ prePowerOn();
+ SPIClass *spi = beginSpi();
+ auto *drv = new NicheGraphics::Drivers::GDEY029T94();
+ drv->begin(spi, pinDC(), pinCS(), pinBusy(), pinReset());
+ return drv;
+ }
+ uint8_t rotation() const override { return 1; }
+};
+} // namespace NicheGraphics::Panels
+
+#endif
diff --git a/src/graphics/eink/Panels/GDEY042T81.h b/src/graphics/eink/Panels/GDEY042T81.h
new file mode 100644
index 000000000..bba0253f8
--- /dev/null
+++ b/src/graphics/eink/Panels/GDEY042T81.h
@@ -0,0 +1,24 @@
+#pragma once
+
+#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS
+
+#include "./PanelProfile.h"
+#include "graphics/eink/Drivers/GDEY042T81.h"
+
+namespace NicheGraphics::Panels
+{
+class GDEY042T81 : public PanelProfile
+{
+ public:
+ NicheGraphics::Drivers::EInk *create() override
+ {
+ prePowerOn();
+ SPIClass *spi = beginSpi();
+ auto *drv = new NicheGraphics::Drivers::GDEY042T81();
+ drv->begin(spi, pinDC(), pinCS(), pinBusy(), pinReset());
+ return drv;
+ }
+};
+} // namespace NicheGraphics::Panels
+
+#endif
diff --git a/src/graphics/eink/Panels/GDEY0579T93.h b/src/graphics/eink/Panels/GDEY0579T93.h
new file mode 100644
index 000000000..5c3d98f55
--- /dev/null
+++ b/src/graphics/eink/Panels/GDEY0579T93.h
@@ -0,0 +1,24 @@
+#pragma once
+
+#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS
+
+#include "./PanelProfile.h"
+#include "graphics/eink/Drivers/GDEY0579T93.h"
+
+namespace NicheGraphics::Panels
+{
+class GDEY0579T93 : public PanelProfile
+{
+ public:
+ NicheGraphics::Drivers::EInk *create() override
+ {
+ prePowerOn();
+ SPIClass *spi = beginSpi();
+ auto *drv = new NicheGraphics::Drivers::GDEY0579T93();
+ drv->begin(spi, pinDC(), pinCS(), pinBusy(), pinReset());
+ return drv;
+ }
+};
+} // namespace NicheGraphics::Panels
+
+#endif
diff --git a/src/graphics/eink/Panels/HINK_E042A87.h b/src/graphics/eink/Panels/HINK_E042A87.h
new file mode 100644
index 000000000..2b6ae446d
--- /dev/null
+++ b/src/graphics/eink/Panels/HINK_E042A87.h
@@ -0,0 +1,24 @@
+#pragma once
+
+#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS
+
+#include "./PanelProfile.h"
+#include "graphics/eink/Drivers/HINK_E042A87.h"
+
+namespace NicheGraphics::Panels
+{
+class HINK_E042A87 : public PanelProfile
+{
+ public:
+ NicheGraphics::Drivers::EInk *create() override
+ {
+ prePowerOn();
+ SPIClass *spi = beginSpi();
+ auto *drv = new NicheGraphics::Drivers::HINK_E042A87();
+ drv->begin(spi, pinDC(), pinCS(), pinBusy(), pinReset());
+ return drv;
+ }
+};
+} // namespace NicheGraphics::Panels
+
+#endif
diff --git a/src/graphics/eink/Panels/LCMEN213EFC1.h b/src/graphics/eink/Panels/LCMEN213EFC1.h
new file mode 100644
index 000000000..7a2dc6e2a
--- /dev/null
+++ b/src/graphics/eink/Panels/LCMEN213EFC1.h
@@ -0,0 +1,25 @@
+#pragma once
+
+#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS
+
+#include "./PanelProfile.h"
+#include "graphics/eink/Drivers/LCMEN2R13EFC1.h"
+
+namespace NicheGraphics::Panels
+{
+class LCMEN213EFC1 : public PanelProfile
+{
+ public:
+ NicheGraphics::Drivers::EInk *create() override
+ {
+ prePowerOn();
+ SPIClass *spi = beginSpi();
+ auto *drv = new NicheGraphics::Drivers::LCMEN213EFC1();
+ drv->begin(spi, pinDC(), pinCS(), pinBusy(), pinReset());
+ return drv;
+ }
+ uint8_t rotation() const override { return 3; }
+};
+} // namespace NicheGraphics::Panels
+
+#endif
diff --git a/src/graphics/eink/Panels/LCMEN2R13ECC1.h b/src/graphics/eink/Panels/LCMEN2R13ECC1.h
new file mode 100644
index 000000000..18c6adcf0
--- /dev/null
+++ b/src/graphics/eink/Panels/LCMEN2R13ECC1.h
@@ -0,0 +1,25 @@
+#pragma once
+
+#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS
+
+#include "./PanelProfile.h"
+#include "graphics/eink/Drivers/LCMEN2R13ECC1.h"
+
+namespace NicheGraphics::Panels
+{
+class LCMEN2R13ECC1 : public PanelProfile
+{
+ public:
+ NicheGraphics::Drivers::EInk *create() override
+ {
+ prePowerOn();
+ SPIClass *spi = beginSpi();
+ auto *drv = new NicheGraphics::Drivers::LCMEN2R13ECC1();
+ drv->begin(spi, pinDC(), pinCS(), pinBusy(), pinReset());
+ return drv;
+ }
+ uint8_t rotation() const override { return 3; }
+};
+} // namespace NicheGraphics::Panels
+
+#endif
diff --git a/src/graphics/eink/Panels/PanelProfile.cpp b/src/graphics/eink/Panels/PanelProfile.cpp
new file mode 100644
index 000000000..3ec35e34c
--- /dev/null
+++ b/src/graphics/eink/Panels/PanelProfile.cpp
@@ -0,0 +1,70 @@
+#include "./PanelProfile.h"
+
+#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS
+
+using namespace NicheGraphics::Panels;
+
+SPIClass *PanelProfile::beginSpi()
+{
+#if defined(ARCH_ESP32)
+ auto *spi = new SPIClass(HSPI);
+#if defined(PIN_EINK_SCLK) && defined(PIN_EINK_MOSI) && defined(PIN_EINK_CS)
+ spi->begin(PIN_EINK_SCLK, -1, PIN_EINK_MOSI, PIN_EINK_CS);
+#else
+ spi->begin();
+#endif
+ return spi;
+#elif defined(ARCH_NRF52)
+ SPI1.begin();
+ return &SPI1;
+#else
+ return &SPI;
+#endif
+}
+
+int8_t PanelProfile::backlightPin() const
+{
+#ifdef PIN_EINK_EN
+ return PIN_EINK_EN;
+#else
+ return -1;
+#endif
+}
+
+uint8_t PanelProfile::pinDC() const
+{
+#ifdef PIN_EINK_DC
+ return PIN_EINK_DC;
+#else
+ return 0xFF;
+#endif
+}
+
+uint8_t PanelProfile::pinCS() const
+{
+#ifdef PIN_EINK_CS
+ return PIN_EINK_CS;
+#else
+ return 0xFF;
+#endif
+}
+
+uint8_t PanelProfile::pinBusy() const
+{
+#ifdef PIN_EINK_BUSY
+ return PIN_EINK_BUSY;
+#else
+ return 0xFF;
+#endif
+}
+
+int8_t PanelProfile::pinReset() const
+{
+#ifdef PIN_EINK_RES
+ return PIN_EINK_RES;
+#else
+ return -1;
+#endif
+}
+
+#endif // MESHTASTIC_INCLUDE_NICHE_GRAPHICS
diff --git a/src/graphics/eink/Panels/PanelProfile.h b/src/graphics/eink/Panels/PanelProfile.h
new file mode 100644
index 000000000..81d599efa
--- /dev/null
+++ b/src/graphics/eink/Panels/PanelProfile.h
@@ -0,0 +1,52 @@
+/*
+
+Panel profile: single source of truth for how a specific E-Ink panel is wired and brought up.
+Variants subclass a per-panel profile only to override differences (SPI bus, pins, rotation, backlight pin,
+power-up quirks). The profile's create() constructs and begins the underlying
+NicheGraphics::Drivers::EInk subclass exactly once.
+
+*/
+
+#pragma once
+
+#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS
+
+#include "configuration.h"
+
+#include "graphics/eink/Drivers/EInk.h"
+
+#include
+
+namespace NicheGraphics::Panels
+{
+
+class PanelProfile
+{
+ public:
+ virtual ~PanelProfile() = default;
+
+ // Produce and begin() the underlying E-Ink driver. Called once per boot.
+ virtual NicheGraphics::Drivers::EInk *create() = 0;
+
+ // Public, variant-overridable metadata
+ virtual uint8_t rotation() const { return 0; }
+ virtual int8_t backlightPin() const;
+
+ protected:
+ // Default SPI bring-up. ESP32 uses HSPI with PIN_EINK_SCLK/MOSI; nRF52 uses SPI1 (pins from variant.h).
+ // Variants override when using a non-default bus or pin set.
+ virtual SPIClass *beginSpi();
+
+ // Pin defaults read the variant's PIN_EINK_* macros. Variants override if mapping differs.
+ virtual uint8_t pinDC() const;
+ virtual uint8_t pinCS() const;
+ virtual uint8_t pinBusy() const;
+ virtual int8_t pinReset() const;
+
+ // Hook for variants that need to raise a power rail / observe settle time before SPI traffic.
+ virtual void prePowerOn() {}
+};
+
+} // namespace NicheGraphics::Panels
+
+#endif // MESHTASTIC_INCLUDE_NICHE_GRAPHICS
diff --git a/src/graphics/eink/Panels/T5Epaper.h b/src/graphics/eink/Panels/T5Epaper.h
new file mode 100644
index 000000000..05091f3f1
--- /dev/null
+++ b/src/graphics/eink/Panels/T5Epaper.h
@@ -0,0 +1,38 @@
+/*
+
+Panel profile base for the LILYGO T5 ePaper Pro family (ED047TC1, 960x540, 8-bit parallel via FastEPD).
+
+V1 and V2 use different FastEPD panel IDs and V2 also needs GPIO-expander pins raised.
+Variants subclass to provide a Drivers::EInkParallel subclass that implements
+postPanelInit() if needed.
+
+*/
+
+#pragma once
+
+#include "configuration.h"
+
+#if defined(MESHTASTIC_INCLUDE_NICHE_GRAPHICS) && defined(ARCH_ESP32) && defined(NICHE_HAS_FASTEPD)
+
+#include "./PanelProfile.h"
+#include "graphics/eink/Drivers/EInkParallel.h"
+
+namespace NicheGraphics::Panels
+{
+class T5EpaperPanel : public PanelProfile
+{
+ public:
+ NicheGraphics::Drivers::EInk *create() override
+ {
+ auto *drv = makeDriver();
+ drv->begin(nullptr, 0, 0, 0); // SPI args ignored
+ return drv;
+ }
+
+ protected:
+ // Variant returns a Drivers::EInkParallel subclass configured for its specific panel/init.
+ virtual NicheGraphics::Drivers::EInkParallel *makeDriver() = 0;
+};
+} // namespace NicheGraphics::Panels
+
+#endif // MESHTASTIC_INCLUDE_NICHE_GRAPHICS && ARCH_ESP32 && NICHE_HAS_FASTEPD
diff --git a/src/graphics/eink/Panels/ZJY122250_0213BAAMFGN.h b/src/graphics/eink/Panels/ZJY122250_0213BAAMFGN.h
new file mode 100644
index 000000000..d84c4d7eb
--- /dev/null
+++ b/src/graphics/eink/Panels/ZJY122250_0213BAAMFGN.h
@@ -0,0 +1,25 @@
+#pragma once
+
+#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS
+
+#include "./PanelProfile.h"
+#include "graphics/eink/Drivers/ZJY122250_0213BAAMFGN.h"
+
+namespace NicheGraphics::Panels
+{
+class ZJY122250_0213BAAMFGN : public PanelProfile
+{
+ public:
+ NicheGraphics::Drivers::EInk *create() override
+ {
+ prePowerOn();
+ SPIClass *spi = beginSpi();
+ auto *drv = new NicheGraphics::Drivers::ZJY122250_0213BAAMFGN();
+ drv->begin(spi, pinDC(), pinCS(), pinBusy(), pinReset());
+ return drv;
+ }
+ uint8_t rotation() const override { return 1; }
+};
+} // namespace NicheGraphics::Panels
+
+#endif
diff --git a/src/input/TouchScreenImpl1.cpp b/src/input/TouchScreenImpl1.cpp
index 1b0a93896..7f54a4c31 100644
--- a/src/input/TouchScreenImpl1.cpp
+++ b/src/input/TouchScreenImpl1.cpp
@@ -7,7 +7,7 @@
#include "sleep.h"
#include
-#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS
+#ifdef MESHTASTIC_INCLUDE_INKHUD
#include "graphics/niche/InkHUD/InkHUD.h"
#include "graphics/niche/InkHUD/SystemApplet.h"
#endif
@@ -109,7 +109,7 @@ bool TouchScreenImpl1::getTouch(int16_t &x, int16_t &y)
bool TouchScreenImpl1::fastTapModeEnabled() const
{
-#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS
+#ifdef MESHTASTIC_INCLUDE_INKHUD
const auto *inkhud = NicheGraphics::InkHUD::InkHUD::getInstance();
if (!inkhud) {
return false;
diff --git a/src/modules/CannedMessageModule.cpp b/src/modules/CannedMessageModule.cpp
index c05a67497..00aa786b2 100644
--- a/src/modules/CannedMessageModule.cpp
+++ b/src/modules/CannedMessageModule.cpp
@@ -34,7 +34,9 @@ extern MessageStore messageStore;
#if !MESHTASTIC_EXCLUDE_GPS
#include "GPS.h"
#endif
-#if defined(USE_EINK) && defined(USE_EINK_DYNAMICDISPLAY)
+#if defined(MESHTASTIC_INCLUDE_NICHE_GRAPHICS) && !defined(MESHTASTIC_INCLUDE_INKHUD)
+#include "graphics/BaseUIEInkDisplay.h" // NicheGraphics-backed BaseUI e-ink adapter
+#elif defined(USE_EINK) && defined(USE_EINK_DYNAMICDISPLAY)
#include "graphics/EInkDynamicDisplay.h" // To select between full and fast refresh on E-Ink displays
#endif
@@ -1905,7 +1907,9 @@ void CannedMessageModule::drawFrame(OLEDDisplay *display, OLEDDisplayUiState *st
// Free Text Input Screen
if (this->runState == CANNED_MESSAGE_RUN_STATE_FREETEXT) {
requestFocus();
-#if defined(USE_EINK) && defined(USE_EINK_DYNAMICDISPLAY)
+#if defined(USE_EINK) && defined(MESHTASTIC_INCLUDE_NICHE_GRAPHICS) && !defined(MESHTASTIC_INCLUDE_INKHUD)
+ static_cast(display)->enableUnlimitedFastMode();
+#elif defined(USE_EINK) && defined(USE_EINK_DYNAMICDISPLAY)
EInkDynamicDisplay *einkDisplay = static_cast(display);
einkDisplay->enableUnlimitedFastMode();
#endif