BookReader

This commit is contained in:
HarukiToreda committed 2026-10-14 02:07:47 -04:00
1 parent 04cb888511
commit 60f7b8be7e
26 files changed
+10613 -175

No files matched your search

+1 -1
View File
@@ -452,4 +452,4 @@ void setupSDCard()
LOG_DEBUG("Total space: %lu MB", (uint32_t)(SD.totalBytes() / (1024 * 1024)));
LOG_DEBUG("Used space: %lu MB", (uint32_t)(SD.usedBytes() / (1024 * 1024)));
#endif
}
}
+388 -19
View File
@@ -18,7 +18,16 @@
#include "./ED047TC1.h"
#include "FastEPD.h"
#include "UptimeClock.h"
#include "configuration.h"
#include "mesh/Throttle.h"
#if INKHUD_BOOKREADER_GRAYSCALE
#include <esp_heap_caps.h>
#endif
extern volatile bool dma_is_done;
extern void bbepRowControl(FASTEPDSTATE *state, int type);
extern void bbepWriteRow(FASTEPDSTATE *state, uint8_t *data, int length, int rowStep);
using namespace NicheGraphics::Drivers;
@@ -123,12 +132,283 @@ int safeEPDiyV7EinkPower(void *pBBEP, int bOn)
class SafeFastEPD : public FASTEPD
{
public:
static constexpr unsigned whiteFinishPasses = 6;
void installSafePowerHandler()
{
#if defined(T5_S3_EPAPER_PRO_V2)
_state.pfnEinkPower = safeEPDiyV7EinkPower;
#endif
}
int partialUpdateWithWhiteFinish(uint32_t whitePixels)
{
if (dmaFault || _state.mode != BB_MODE_1BPP || _state.native_width != 960 || _state.native_height != 540 ||
_state.rotation != 0 || (_state.iFlags & (BB_PANEL_FLAG_MIRROR_X | BB_PANEL_FLAG_MIRROR_Y)) ||
_state.panelDef.iLinePadding || !_state.pTemp || !_state.dma_buf)
return BBEP_ERROR_BAD_PARAMETER;
int rc = FASTEPD::partialUpdate(true, 0, 539);
if (rc == BBEP_SUCCESS && whitePixels)
rc = finishWhitePixels(_state.pTemp, whiteFinishPasses);
if (rc == BBEP_SUCCESS)
rc = writePulseFrame(nullptr);
const int offRc = einkPower(0);
return rc == BBEP_SUCCESS ? offRc : rc;
}
bool hasDmaFault() const { return dmaFault; }
#if INKHUD_BOOKREADER_GRAYSCALE
bool promoteMonochromeBaseline()
{
if (dmaFault || _state.mode != BB_MODE_1BPP || _state.native_width != 960 || _state.native_height != 540 ||
_state.rotation != 0 || (_state.iFlags & (BB_PANEL_FLAG_MIRROR_X | BB_PANEL_FLAG_MIRROR_Y)) || !_state.pCurrent)
return false;
// Expand backwards; the larger gray plane overlaps the monochrome source.
for (size_t i = 960U * 540U / 8U; i-- > 0;) {
const uint8_t pixels = _state.pCurrent[i];
for (unsigned pair = 0; pair < 4; ++pair)
_state.pCurrent[i * 4U + pair] =
((pixels & (0x80U >> (pair * 2U))) ? 0xF0U : 0U) | ((pixels & (0x40U >> (pair * 2U))) ? 0x0FU : 0U);
}
return true;
}
// Optimize frame preparation only; panel drive timing stays unchanged.
__attribute__((optimize("O3"))) bool preparePanelGrayUpdate(const uint8_t *image, uint16_t width, uint16_t height,
uint16_t left, uint16_t top, bool baselineValid,
uint32_t &changed)
{
if (dmaFault || !image || _state.mode != BB_MODE_4BPP || _state.native_width != 960 || _state.native_height != 540 ||
_state.rotation != 0 || (_state.iFlags & (BB_PANEL_FLAG_MIRROR_X | BB_PANEL_FLAG_MIRROR_Y)) ||
_state.panelDef.iLinePadding || !_state.pCurrent || !_state.dma_buf || (left & 1U) || (width & 1U) ||
left + width > 960 || top + height > 540 || !_state.panelDef.pGrayMatrix || _state.panelDef.iMatrixSize != 16U * 8U)
return false;
if (baselineValid) {
uint16_t y = 0;
for (; y < height; ++y)
if (memcmp(_state.pCurrent + (top + y) * 480U + left / 2U, image + y * (width / 2U), width / 2U) != 0)
break;
if (y == height) {
changed = 0;
#if INKHUD_BOOKREADER_PERF_LOG
LOG_INFO("[BookReaderDraw] panel prepare skipped identical packed frame");
#endif
return true;
}
}
if (!grayLookupReady) {
const uint8_t *matrix = _state.panelDef.pGrayMatrix;
for (unsigned pass = 0; pass < 8; ++pass)
for (unsigned packed = 0; packed < 256; ++packed)
grayPairLookup[pass][packed] =
(matrix[(packed >> 4U) * 8U + pass] << 2U) | matrix[(packed & 15U) * 8U + pass];
grayLookupReady = true;
}
constexpr size_t planeBytes = 960U * 540U / 4U;
if (!grayPulsePlanes)
grayPulsePlanes = static_cast<uint8_t *>(heap_caps_malloc(planeBytes, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT));
if (!grayPulsePlanes)
return false;
memset(grayPulsePlanes, 0, planeBytes);
if (!baselineValid)
memset(_state.pCurrent, 0xFF, 960U * 540U / 2U);
changed = 0;
#if INKHUD_BOOKREADER_PERF_LOG
uint32_t inputHash = 2166136261U, targetHash = 2166136261U;
uint32_t targetCounts[4] = {}, transitions[16] = {};
uint32_t packedCounts[256] = {};
#endif
// Batch external-RAM transfers; compare packed pixels in local row buffers.
uint8_t oldRow[480], newRow[480];
const size_t rowBytes = width / 2U;
for (uint16_t y = 0; y < height; ++y) {
uint8_t *current = _state.pCurrent + (top + y) * 480U + left / 2U;
const uint8_t *next = image + y * (width / 2U);
memcpy(oldRow, current, rowBytes);
memcpy(newRow, next, rowBytes);
uint8_t *mask = grayPulsePlanes + (top + y) * 240U;
uint8_t pendingMask = 0;
bool rowChanged = false;
for (uint16_t x = 0; x < width; x += 2) {
const uint8_t oldPacked = oldRow[x / 2U];
const uint8_t input = newRow[x / 2U];
const uint8_t newPacked = input;
#if INKHUD_BOOKREADER_PERF_LOG
inputHash = (inputHash ^ input) * 16777619U;
targetHash = (targetHash ^ newPacked) * 16777619U;
++packedCounts[newPacked];
#endif
const unsigned physicalX = left + x;
if (!baselineValid || oldPacked != newPacked) {
const uint8_t difference = oldPacked ^ newPacked;
const bool highChanged = !baselineValid || (difference & 0xF0U);
const bool lowChanged = !baselineValid || (difference & 0x0FU);
#if INKHUD_BOOKREADER_PERF_LOG
if (highChanged)
++transitions[(oldPacked >> 6U) * 4U + (newPacked >> 6U)];
if (lowChanged)
++transitions[((oldPacked & 15U) / 4U) * 4U + (newPacked & 15U) / 4U];
#endif
const uint8_t pairMask = (highChanged ? 12U : 0U) | (lowChanged ? 3U : 0U);
pendingMask |= pairMask << ((physicalX & 2U) ? 0U : 4U);
changed += highChanged + lowChanged;
rowChanged = true;
}
if ((physicalX & 2U) || x + 2U == width) {
if (pendingMask)
mask[physicalX / 4U] = pendingMask;
pendingMask = 0;
}
}
if (rowChanged)
memcpy(current, newRow, rowBytes);
}
#if INKHUD_BOOKREADER_PERF_LOG
for (unsigned packed = 0; packed < 256U; ++packed) {
targetCounts[packed >> 6U] += packedCounts[packed];
targetCounts[(packed & 15U) >> 2U] += packedCounts[packed];
}
LOG_INFO("[BookReaderDraw] panel prepare inputHash=%08lx targetHash=%08lx baseline=%u "
"targetBands0-3,4-7,8-11,12-15=%lu,%lu,%lu,%lu",
static_cast<unsigned long>(inputHash), static_cast<unsigned long>(targetHash), baselineValid,
static_cast<unsigned long>(targetCounts[0]), static_cast<unsigned long>(targetCounts[1]),
static_cast<unsigned long>(targetCounts[2]), static_cast<unsigned long>(targetCounts[3]));
for (unsigned oldLevel = 0; oldLevel < 4; ++oldLevel)
LOG_INFO("[BookReaderDraw] transitions oldBand=%u toBands=%lu,%lu,%lu,%lu", oldLevel,
static_cast<unsigned long>(transitions[oldLevel * 4U]),
static_cast<unsigned long>(transitions[oldLevel * 4U + 1U]),
static_cast<unsigned long>(transitions[oldLevel * 4U + 2U]),
static_cast<unsigned long>(transitions[oldLevel * 4U + 3U]));
#endif
return true;
}
int updatePanelGray()
{
if (dmaFault || !grayPulsePlanes || !grayLookupReady || !_state.dma_buf)
return BBEP_ERROR_BAD_PARAMETER;
#if INKHUD_BOOKREADER_PERF_LOG
uint32_t passStarts[8] = {};
#endif
const int powerRc = einkPower(1);
if (powerRc != BBEP_SUCCESS) {
einkPower(0);
return powerRc;
}
int rc = BBEP_SUCCESS;
for (unsigned pass = 0; pass < 8 && rc == BBEP_SUCCESS; ++pass) {
#if INKHUD_BOOKREADER_PERF_LOG
passStarts[pass] = millis();
#endif
rc = writePulseFrame(grayPulsePlanes, grayPairLookup[pass]);
}
#if INKHUD_BOOKREADER_PERF_LOG
const uint32_t neutralStart = millis();
#endif
if (rc == BBEP_SUCCESS)
rc = writePulseFrame(nullptr);
#if INKHUD_BOOKREADER_PERF_LOG
const uint32_t powerOffStart = millis();
#endif
const int offRc = einkPower(0);
#if INKHUD_BOOKREADER_PERF_LOG
// Serial output must not stretch the powered waveform between phases.
for (unsigned pass = 0; pass < 8; ++pass)
LOG_INFO("[BookReaderDraw] panel-native streamed pass=%u at=%lu", pass, static_cast<unsigned long>(passStarts[pass]));
LOG_INFO("[BookReaderDraw] neutral at=%lu power-off at=%lu", static_cast<unsigned long>(neutralStart),
static_cast<unsigned long>(powerOffStart));
LOG_INFO("[BookReaderDraw] panel phase power-off END at=%lu rc=%d powerRc=%d", static_cast<unsigned long>(millis()), rc,
offRc);
#endif
return rc == BBEP_SUCCESS ? offRc : rc;
}
#endif
private:
#if INKHUD_BOOKREADER_GRAYSCALE
uint8_t *grayPulsePlanes = nullptr;
uint8_t grayPairLookup[8][256] = {};
bool grayLookupReady = false;
#endif
bool dmaFault = false;
int finishWhitePixels(uint8_t *plane, unsigned passes)
{
// Keep only the black-to-white pulses prepared by the monochrome partial update.
#if INKHUD_BOOKREADER_PERF_LOG
const uint32_t prepareStart = millis();
uint32_t whitePixels = 0;
#endif
uint8_t active = 0;
for (size_t i = 0; i < 960U * 540U / 4U; ++i) {
uint8_t finish = plane[i] & 0xAA;
plane[i] = finish;
active |= plane[i];
#if INKHUD_BOOKREADER_PERF_LOG
whitePixels += __builtin_popcount(static_cast<unsigned>(finish));
#endif
}
int rc = BBEP_SUCCESS;
#if INKHUD_BOOKREADER_PERF_LOG
LOG_INFO("[BookReaderDraw] white-only finish BEGIN at=%lu pixels=%lu passes=%u prepareMs=%lu mode=%d",
static_cast<unsigned long>(millis()), static_cast<unsigned long>(whitePixels), active ? passes : 0U,
static_cast<unsigned long>(millis() - prepareStart), _state.mode);
#endif
if (active)
for (unsigned pass = 0; pass < passes && rc == BBEP_SUCCESS; ++pass)
rc = writePulseFrame(plane);
#if INKHUD_BOOKREADER_PERF_LOG
LOG_INFO("[BookReaderDraw] white-only finish END rc=%d", rc);
#endif
return rc;
}
bool waitForDma()
{
if (dma_is_done)
return true;
const uint32_t start = Time::getMillis();
while (!dma_is_done) {
if (Throttle::hasElapsed(start, 250U)) {
dmaFault = true;
LOG_ERROR("ED047TC1 DMA timeout; display transfers stopped");
return false;
}
delayMicroseconds(1);
}
return true;
}
int writePulseFrame(const uint8_t *plane, const uint8_t *grayLookup = nullptr)
{
if (!waitForDma())
return BBEP_IO_ERROR;
bbepRowControl(&_state, ROW_START);
unsigned offset = 0;
for (unsigned row = 0; row < 540; ++row) {
// Alternate buffers keep the active DMA row untouched.
uint8_t *out = _state.dma_buf + offset;
if (grayLookup) {
const uint8_t *source = _state.pCurrent + row * 480U;
const uint8_t *mask = plane + row * 240U;
for (unsigned x = 0; x < 240; ++x)
out[x] = mask[x] ? ((grayLookup[source[x * 2U]] << 4U) | grayLookup[source[x * 2U + 1U]]) & mask[x] : 0U;
} else if (plane)
memcpy(out, plane + row * 240U, 240);
else
memset(out, 0, 240);
if (!waitForDma())
return BBEP_IO_ERROR;
bbepWriteRow(&_state, out, 240, row != 0);
offset ^= 240U;
}
if (!waitForDma())
return BBEP_IO_ERROR;
delayMicroseconds(230);
return BBEP_SUCCESS;
}
};
} // namespace
@@ -146,9 +426,9 @@ void ED047TC1::begin(SPIClass *spi, uint8_t pin_dc, uint8_t pin_cs, uint8_t pin_
int initRc = BBEP_ERROR_BAD_PARAMETER;
#if defined(T5_S3_EPAPER_PRO_V1)
initRc = epaper->initPanel(BB_PANEL_LILYGO_T5PRO, 28000000);
initRc = epaper->initPanel(BB_PANEL_LILYGO_T5PRO, 20000000);
#elif defined(T5_S3_EPAPER_PRO_V2)
initRc = epaper->initPanel(BB_PANEL_LILYGO_T5PRO_V2, 28000000);
initRc = epaper->initPanel(BB_PANEL_LILYGO_T5PRO_V2, 20000000);
// Initialize all PCA9535 port-0 pins as outputs / HIGH
for (int i = 0; i < 8; i++) {
epaper->ioPinMode(i, OUTPUT);
@@ -172,28 +452,42 @@ void ED047TC1::begin(SPIClass *spi, uint8_t pin_dc, uint8_t pin_cs, uint8_t pin_
const int modeRc = epaper->setMode(BB_MODE_1BPP);
if (modeRc != BBEP_SUCCESS) {
LOG_WARN("ED047TC1 setMode failed rc=%d", modeRc);
return;
}
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) {
panelReady = true;
// FastEPD clearWhite()/backupPlane() overrun the aliased previous plane.
epaper->fillScreen(BBEP_WHITE);
const int fullRc = epaper->fullUpdate(CLEAR_SLOW, false);
monochromeBaselineValid = fullRc == BBEP_SUCCESS;
failed = !monochromeBaselineValid;
if (failed)
LOG_WARN("ED047TC1 initial fullUpdate failed rc=%d", fullRc);
}
LOG_INFO("ED047TC1 panel profile: clock=20MHz partialPasses=4 fullPasses=5");
}
void ED047TC1::update(uint8_t *imageData, UpdateTypes type)
{
if (!epaper)
if (!panelReady || !imageData)
return;
#if INKHUD_BOOKREADER_GRAYSCALE
const bool leavingGrayscale = epaper->getMode() == BB_MODE_4BPP;
grayscaleBaselineValid = false;
#endif
if (epaper->getMode() != BB_MODE_1BPP) {
monochromeBaselineValid = false;
const int rc = epaper->setMode(BB_MODE_1BPP);
if (rc != BBEP_SUCCESS) {
failed = true;
LOG_WARN("ED047TC1 monochrome setMode failed rc=%d", rc);
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 (118)
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;
@@ -209,15 +503,90 @@ void ED047TC1::update(uint8_t *imageData, UpdateTypes type)
memcpy(dstRow, srcRow, srcRowBytes);
}
if (type == FULL) {
epaper->fullUpdate(CLEAR_SLOW, false);
epaper->backupPlane(); // Sync pPrevious so next partialUpdate has a correct baseline
int rc;
if (type == FULL || !monochromeBaselineValid) {
#if INKHUD_BOOKREADER_GRAYSCALE
rc = epaper->fullUpdate(leavingGrayscale ? CLEAR_WHITE : CLEAR_SLOW, false);
#else
rc = epaper->fullUpdate(CLEAR_SLOW, false);
#endif
} 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);
const uint32_t startMs = millis();
uint32_t blackToWhite = 0, whiteToBlack = 0;
const uint8_t *previous = epaper->previousBuffer();
for (size_t i = 0; i < dstRowBytes * dstTotalRows; ++i) {
blackToWhite += __builtin_popcount(static_cast<unsigned>(cur[i] & ~previous[i]));
whiteToBlack += __builtin_popcount(static_cast<unsigned>(~cur[i] & previous[i]));
}
auto *safeEpaper = static_cast<SafeFastEPD *>(epaper);
rc = safeEpaper->partialUpdateWithWhiteFinish(blackToWhite);
if (safeEpaper->hasDmaFault())
panelReady = false;
LOG_DEBUG("[EInkPerf] FAST blackToWhite=%lu whiteToBlack=%lu whiteExtraPasses=%u ms=%lu rc=%d",
static_cast<unsigned long>(blackToWhite), static_cast<unsigned long>(whiteToBlack),
blackToWhite ? SafeFastEPD::whiteFinishPasses : 0U, static_cast<unsigned long>(millis() - startMs), rc);
}
monochromeBaselineValid = rc == BBEP_SUCCESS;
failed = !monochromeBaselineValid;
if (failed)
LOG_WARN("ED047TC1 monochrome update failed rc=%d", rc);
}
#if INKHUD_BOOKREADER_GRAYSCALE
void ED047TC1::updateGrayscale(uint8_t *imageData)
{
if (!panelReady || !imageData)
return;
#if INKHUD_BOOKREADER_PERF_LOG
const uint32_t startMs = millis();
#endif
auto *safeEpaper = static_cast<SafeFastEPD *>(epaper);
if (epaper->getMode() != BB_MODE_4BPP)
grayscaleBaselineValid = monochromeBaselineValid && safeEpaper->promoteMonochromeBaseline();
monochromeBaselineValid = false;
const int modeRc = epaper->setMode(BB_MODE_4BPP);
if (modeRc != BBEP_SUCCESS) {
failed = true;
grayscaleBaselineValid = false;
LOG_WARN("ED047TC1 grayscale setMode failed rc=%d", modeRc);
return;
}
uint32_t changedPixels = 0;
const bool establishBaseline = !grayscaleBaselineValid;
if (!safeEpaper->preparePanelGrayUpdate(imageData, DISPLAY_WIDTH, DISPLAY_HEIGHT, H_OFFSET_BYTES * 8U, V_OFFSET_TOP,
grayscaleBaselineValid, changedPixels)) {
failed = true;
LOG_WARN("ED047TC1 panel-native grayscale preparation failed; retaining visible frame");
return;
}
if (!establishBaseline && !changedPixels) {
failed = false;
#if INKHUD_BOOKREADER_PERF_LOG
LOG_INFO("[BookReaderPerf] gray panel skipped unchanged ms=%lu", static_cast<unsigned long>(millis() - startMs));
#endif
return;
}
#if INKHUD_BOOKREADER_PERF_LOG
LOG_INFO("[BookReaderPerf] gray panel BEGIN clear=none waveform=panel-native shades=16 baseline=%u "
"changed=%lu prepareMs=%lu",
!establishBaseline, static_cast<unsigned long>(changedPixels), static_cast<unsigned long>(millis() - startMs));
const uint32_t driveStartMs = millis();
#endif
const int rc = safeEpaper->updatePanelGray();
if (safeEpaper->hasDmaFault())
panelReady = false;
#if INKHUD_BOOKREADER_PERF_LOG
LOG_INFO("[BookReaderPerf] gray panel END rc=%d driveMs=%lu totalMs=%lu", rc,
static_cast<unsigned long>(millis() - driveStartMs), static_cast<unsigned long>(millis() - startMs));
#endif
failed = rc != BBEP_SUCCESS;
grayscaleBaselineValid = !failed;
if (failed)
LOG_WARN("ED047TC1 grayscale update failed rc=%d", rc);
}
#endif
#endif // T5_S3_EPAPER_PRO
#endif // MESHTASTIC_INCLUDE_NICHE_GRAPHICS
@@ -77,12 +77,21 @@ class ED047TC1 : public EInk
// 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;
#if INKHUD_BOOKREADER_GRAYSCALE
bool supportsGrayscale() const override { return panelReady; }
void updateGrayscale(uint8_t *imageData) override;
#endif
protected:
bool isUpdateDone() override { return true; } // FastEPD updates are blocking
private:
FASTEPD *epaper = nullptr;
bool panelReady = false;
bool monochromeBaselineValid = false;
#if INKHUD_BOOKREADER_GRAYSCALE
bool grayscaleBaselineValid = false;
#endif
};
} // namespace NicheGraphics::Drivers
+8 -4
View File
@@ -30,9 +30,13 @@ class EInk : private concurrency::OSThread
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?
#if INKHUD_BOOKREADER_GRAYSCALE
virtual bool supportsGrayscale() const { return false; }
virtual void updateGrayscale(uint8_t *imageData) { (void)imageData; }
#endif
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;
@@ -54,4 +58,4 @@ class EInk : private concurrency::OSThread
} // namespace NicheGraphics::Drivers
#endif // MESHTASTIC_INCLUDE_NICHE_GRAPHICS
#endif // MESHTASTIC_INCLUDE_NICHE_GRAPHICS
+51 -25
View File
@@ -69,7 +69,8 @@ class Applet : public GFX
bool wantsToRender(); // Check whether applet wants to render
bool wantsToAutoshow(); // Check whether applet wants to become foreground
Drivers::EInk::UpdateTypes wantsUpdateType(); // Check which display update type the applet would prefer
bool wantsFullRender(); // Check whether applet wants to render over its previous render
bool wantsFullRender(); // Check whether applet wants to render over its
// previous render
void updateDimensions(); // Get current size from tile
void resetDrawingSpace(); // Makes sure every render starts with same parameters
@@ -85,6 +86,9 @@ class Applet : public GFX
// Event handlers
virtual void onRender(bool full) = 0; // For drawing the applet
#ifdef MESHTASTIC_INCLUDE_INKHUD_BOOKREADER
virtual bool keepScreenAwakeDuringRender() const { return false; }
#endif
virtual void onActivate() {}
virtual void onDeactivate() {}
virtual void onForeground() {}
@@ -104,6 +108,11 @@ class Applet : public GFX
virtual void onFreeText(char c) {}
virtual void onFreeTextDone() {}
virtual void onFreeTextCancel() {}
#ifdef MESHTASTIC_INCLUDE_INKHUD_BOOKREADER
// Visual-frame swipe direction; true prevents the board's app-switch
// fallback.
virtual bool onTouchSwipe(int8_t, int8_t) { return false; }
#endif
// Absolute display-space touch point, for touch-friendly UI interactions.
// Return true if consumed.
virtual bool onTouchPoint(uint16_t x, uint16_t y, bool longPress)
@@ -124,7 +133,8 @@ class Applet : public GFX
NAV_LEFT = 64, // | no-op | Joystick Left |
NAV_RIGHT = 128 // | no-op | Joystick Right |
};
bool isInputSubscribed(InputMask input); // Check if input should be handled by applet, this should not be overloaded.
bool isInputSubscribed(InputMask input); // Check if input should be handled by
// applet, this should not be overloaded.
virtual bool approveNotification(Notification &n); // Allow an applet to veto a notification
@@ -133,9 +143,11 @@ class Applet : public GFX
static uint16_t getHeaderHeight(); // How tall the "standard" applet header is
static AppletFont fontSmall, fontMedium, fontLarge; // The general purpose fonts, used by all applets
static AppletFont fontSmall, fontMedium,
fontLarge; // The general purpose fonts, used by all applets
const char *name = nullptr; // Shown in applet selection menu. Also used as an identifier by InkHUD::getSystemApplet
const char *name = nullptr; // Shown in applet selection menu. Also used as an
// identifier by InkHUD::getSystemApplet
protected:
void drawPixel(int16_t x, int16_t y, uint16_t color) override; // Place a single pixel. All drawing output passes through here
@@ -144,17 +156,20 @@ class Applet : public GFX
bool full = true); // Ask WindowManager to schedule a display update
void requestAutoshow(); // Ask for applet to be moved to foreground
uint16_t X(float f); // Map applet width, mapped from 0 to 1.0
uint16_t Y(float f); // Map applet height, mapped from 0 to 1.0
void setCrop(int16_t left, int16_t top, uint16_t width, uint16_t height); // Ignore pixels drawn outside a certain region
void resetCrop(); // Removes setCrop()
uint16_t X(float f); // Map applet width, mapped from 0 to 1.0
uint16_t Y(float f); // Map applet height, mapped from 0 to 1.0
void setCrop(int16_t left, int16_t top, uint16_t width,
uint16_t height); // Ignore pixels drawn outside a certain region
void resetCrop(); // Removes setCrop()
// User Input Handling
uint8_t subscribedInputs = 0b00000000; // Maybe uint16_t for futureproofing? other devices may need more inputs
uint8_t subscribedInputs = 0b00000000; // Maybe uint16_t for futureproofing?
// other devices may need more inputs
void setInputsSubscribed(uint8_t input,
bool captured); // Set if an input should be handled by applet or not, this should not be
// overloaded. Can take multiple inputs at once if you OR/`|` them together
bool captured); // Set if an input should be handled by applet or not,
// this should not be overloaded. Can take multiple inputs
// at once if you OR/`|` them together
// Text
@@ -162,26 +177,31 @@ class Applet : public GFX
AppletFont getFont();
uint16_t getTextWidth(const std::string &text);
uint16_t getTextWidth(const char *text);
uint32_t getWrappedTextHeight(int16_t left, uint16_t width, const std::string &text); // Result of printWrapped
uint32_t getWrappedTextHeight(int16_t left, uint16_t width,
const std::string &text); // Result of printWrapped
void printAt(int16_t x, int16_t y, const char *text, HorizontalAlignment ha = LEFT, VerticalAlignment va = TOP);
void printAt(int16_t x, int16_t y, const std::string &text, HorizontalAlignment ha = LEFT, VerticalAlignment va = TOP);
void printThick(int16_t xCenter, int16_t yCenter, const std::string &text, uint8_t thicknessX,
uint8_t thicknessY); // Faux bold
void printWrapped(int16_t left, int16_t top, uint16_t width, const std::string &text); // Per-word line wrapping
uint8_t thicknessY); // Faux bold
void printWrapped(int16_t left, int16_t top, uint16_t width,
const std::string &text); // Per-word line wrapping
void hatchRegion(int16_t x, int16_t y, uint16_t w, uint16_t h, uint8_t spacing, Color color); // Fill with sparse lines
void drawHeader(const std::string &text); // Draw the standard applet header
// Meshtastic Logo
static constexpr float LOGO_ASPECT_RATIO = 1.9; // Width:Height for drawing the Meshtastic logo
uint16_t getLogoWidth(uint16_t limitWidth, uint16_t limitHeight); // Size Meshtastic logo to fit within region
uint16_t getLogoHeight(uint16_t limitWidth, uint16_t limitHeight); // Size Meshtastic logo to fit within region
static constexpr float LOGO_ASPECT_RATIO = 1.9; // Width:Height for drawing the Meshtastic logo
uint16_t getLogoWidth(uint16_t limitWidth,
uint16_t limitHeight); // Size Meshtastic logo to fit within region
uint16_t getLogoHeight(uint16_t limitWidth,
uint16_t limitHeight); // Size Meshtastic logo to fit within region
void drawLogo(int16_t centerX, int16_t centerY, uint16_t width, uint16_t height,
Color color = BLACK); // Draw the Meshtastic logo
std::string hexifyNodeNum(NodeNum num); // Style as !0123abdc
SignalStrength getSignalStrength(float snr, float rssi); // Interpret SNR and RSSI, as an easy to understand value
std::string hexifyNodeNum(NodeNum num); // Style as !0123abdc
SignalStrength getSignalStrength(float snr,
float rssi); // Interpret SNR and RSSI, as an easy to understand value
std::string getTimeString(uint32_t epochSeconds); // Human readable
std::string getTimeString(); // Current time, human readable
std::string localizeDistance(uint32_t meters); // Human readable distance, imperial or metric
@@ -196,18 +216,24 @@ class Applet : public GFX
Persistence::LatestMessage *latestMessage = nullptr;
private:
Tile *assignedTile = nullptr; // Rendered pixels are fed into a Tile object, which translates them, then passes to WM
Tile *assignedTile = nullptr; // Rendered pixels are fed into a Tile object,
// which translates them, then passes to WM
bool active = false; // Has the user enabled this applet (at run-time)?
bool foreground = false; // Is the applet currently drawn on a tile?
bool wantRender = false; // In some situations, checked by WindowManager when updating, to skip unneeded redrawing.
bool wantAutoshow = false; // Does the applet have new data it would like to display in foreground?
bool wantRender = false; // In some situations, checked by WindowManager when
// updating, to skip unneeded redrawing.
bool wantAutoshow = false; // Does the applet have new data it would like to
// display in foreground?
NicheGraphics::Drivers::EInk::UpdateTypes wantUpdateType =
NicheGraphics::Drivers::EInk::UpdateTypes::UNSPECIFIED; // Which update method we'd prefer when redrawing the display
NicheGraphics::Drivers::EInk::UpdateTypes::UNSPECIFIED; // Which update method we'd prefer when redrawing the
// display
bool wantFullRender = true; // Render with a fresh canvas
using GFX::setFont; // Make sure derived classes use AppletFont instead of AdafruitGFX fonts directly
using GFX::setRotation; // Block setRotation calls. Rotation is handled globally by WindowManager.
using GFX::setFont; // Make sure derived classes use AppletFont instead of
// AdafruitGFX fonts directly
using GFX::setRotation; // Block setRotation calls. Rotation is handled
// globally by WindowManager.
AppletFont currentFont; // As passed to setFont
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,317 @@
#if defined(MESHTASTIC_INCLUDE_INKHUD) && defined(MESHTASTIC_INCLUDE_INKHUD_BOOKREADER)
#pragma once
#include "configuration.h"
#include "concurrency/OSThread.h"
#include "graphics/niche/InkHUD/Applet.h"
#include <array>
#include <cstdlib>
#include <memory>
#include <string>
#include <string_view>
#include <vector>
#ifdef ARCH_ESP32
#include <esp_heap_caps.h>
#endif
namespace NicheGraphics::InkHUD
{
template <typename T> struct ReaderTextAllocator : std::allocator<T> {
using value_type = T;
template <typename U> struct rebind {
using other = ReaderTextAllocator<U>;
};
ReaderTextAllocator() = default;
template <typename U> ReaderTextAllocator(const ReaderTextAllocator<U> &) {}
T *allocate(size_t count)
{
#ifdef ARCH_ESP32
if (count > SIZE_MAX / sizeof(T))
std::abort();
void *memory = heap_caps_malloc(count * sizeof(T), MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
if (!memory)
memory = heap_caps_malloc(count * sizeof(T), MALLOC_CAP_8BIT);
if (!memory)
std::abort();
return static_cast<T *>(memory);
#else
return std::allocator<T>::allocate(count);
#endif
}
void deallocate(T *memory, size_t count)
{
#ifdef ARCH_ESP32
(void)count;
heap_caps_free(memory);
#else
std::allocator<T>::deallocate(memory, count);
#endif
}
};
class BookReaderApplet : public Applet, private concurrency::OSThread
{
public:
enum class Mode : uint8_t {
BROWSER = 0,
READER = 1,
CHAPTERS = 2,
OPENING = 3,
MENU = 4,
};
enum class BookFormat : uint8_t {
PLAIN_TEXT = 0,
HTML_LIKE = 1,
RTF = 2,
EPUB_CONTAINER = 3,
};
struct BrowserEntry {
std::string name;
std::string path;
bool isDir = false;
uint32_t size = 0;
BookFormat format = BookFormat::PLAIN_TEXT;
};
struct PrefetchEntry {
int32_t partIndex = -1;
std::string path;
std::string text;
bool truncated = false;
void clear()
{
partIndex = -1;
path.clear();
text.clear();
truncated = false;
}
};
struct ContentsEntry {
std::string title;
std::string path;
std::string fragment;
uint8_t depth = 0;
int32_t part = -1;
int32_t line = -1;
size_t offset = std::string::npos;
};
struct DeferredPartLoad {
int32_t partIndex = -1;
void clear() { partIndex = -1; }
};
BookReaderApplet();
void onActivate() override;
void onDeactivate() override;
void onBackground() override;
void onShutdown() override;
void onRender(bool full) override;
bool keepScreenAwakeDuringRender() const override { return true; }
static void setDisplayRefreshActive(bool active);
void onButtonShortPress() override;
void onButtonLongPress() override;
void onExitShort() override;
void onNavUp() override;
void onNavDown() override;
void onNavLeft() override;
void onNavRight() override;
bool onTouchPoint(uint16_t x, uint16_t y, bool longPress) override;
bool onTouchSwipe(int8_t dx, int8_t dy) override;
private:
int32_t runOnce() override;
void ensurePageIndex();
void resetPageIndex();
void recordCurrentPartPages();
void installContents(const std::vector<ContentsEntry> &entries);
void resolveContentsOffsets(const std::string &path, const std::string &raw, std::string &text,
bool *incompleteTable = nullptr);
void updateContentsLines(int32_t part);
std::string readingProgress() const;
std::string readingContext() const;
void renderReadingProgress();
uint64_t pageIndexFingerprint() const;
std::string pageIndexLabel() const;
std::vector<ContentsEntry> contents;
std::vector<int32_t> partPageCounts;
uint16_t pageIndexWidth = 0, pageIndexHeight = 0;
size_t pageIndexNext = 0;
std::string pageIndexText;
size_t pageIndexImageOffset = 0;
bool pageIndexSaved = false;
struct PageIndexCache {
static constexpr uint32_t VERSION = 13;
uint32_t version = VERSION;
uint64_t fingerprint = 0;
uint16_t width = 0, height = 0, count = 0;
uint8_t textSize = 0;
std::array<uint16_t, 256> pages{};
};
Mode mode = Mode::BROWSER;
bool initialized = false;
bool initialLoadArmed = false;
Mode menuReturnMode = Mode::BROWSER;
uint8_t menuSelection = 0;
struct ReadingPosition {
uint32_t version = 1;
char path[256] = {};
int32_t part = -1;
int32_t line = 0;
int32_t bookmarkPart = -1;
int32_t bookmarkLine = -1;
uint8_t textSize = 0;
uint8_t bookmarkTextSize = 0;
} readingPosition;
std::string currentDir;
std::vector<BrowserEntry> entries;
int32_t selectedIndex = 0;
int32_t firstVisibleIndex = 0;
bool browserCursorShown = true;
bool hideTouchBrowserSelectionHighlight = false;
int32_t pendingOpenIndex = -1;
int32_t pendingChapterIndex = -1;
int pendingPageDelta = 0;
bool openingSplashRendered = false;
std::string openFilePath;
std::string bookPath;
std::string epubCoverImagePath;
bool useEpubCoverForFirstPart = false;
bool pageNavigation = false;
bool openFileTruncated = false;
bool fileLoaded = false;
std::string displayText;
// Retained lines must not consume the internal RAM needed by filesystem/RTOS locks.
using WrappedLine = std::basic_string<char, std::char_traits<char>, ReaderTextAllocator<char>>;
std::vector<WrappedLine, ReaderTextAllocator<WrappedLine>> wrappedLines;
std::vector<uint8_t, ReaderTextAllocator<uint8_t>> wrappedLineStyles;
std::vector<size_t> chapterOffsets;
std::vector<int32_t> chapterStartLines;
std::vector<BrowserEntry> pagedParts;
int32_t pagedPartIndex = -1;
bool pagedBookMode = false;
PrefetchEntry prefetchedNextPart;
DeferredPartLoad deferredPartLoad;
bool deferredNextPartPrefetch = false;
size_t prefetchImageIndex = 0;
bool backgroundReaderWorkPending = false;
bool backgroundReaderWorkArmed = false;
bool deferredWrapExpansion = false;
size_t deferredWrapImageOffset = 0;
uint32_t lastReaderInputMs = 0;
uint32_t lastReaderRenderMs = 0;
int32_t chapterPickerSelected = 0;
int32_t chapterPickerFirstVisible = 0;
bool chapterCursorShown = true;
bool hideTouchChapterSelectionHighlight = false;
bool deferInlineImagesUntilNextRender = false;
std::string imageViewKey;
std::string expandedImageLine;
bool expandedImageLoading = false;
struct InlineImageTarget {
std::string line;
int16_t left, top;
uint16_t width, height;
};
std::vector<InlineImageTarget> inlineImageTargets;
uint16_t imageZoomPermille = 1000;
bool imageInteractionPending = false;
bool imagePanOnlyRender = false;
bool imageViewportFilled = false;
int16_t imagePanX = 0;
int16_t imagePanY = 0;
int32_t scrollLine = 0;
int32_t linesPerPage = 1;
uint16_t wrapWidth = 0;
std::string status;
bool wrapDirty = true;
bool ensureInitialDirectory();
void resetSessionState();
bool ensureSDReady();
bool loadDirectory(const std::string &dir);
bool openSelectedEntry();
bool loadFile(const BrowserEntry &entry);
bool loadEpubContainerAsBook(const BrowserEntry &entry);
bool loadPartText(const BrowserEntry &part, size_t maxBytes, std::string &out, bool &truncatedOut,
bool *needsExpansion = nullptr);
bool loadPagedPart(int32_t partIndex, bool jumpToEndPage, size_t readLimitOverride = 0U, bool allowDeferredExpand = false);
void prefetchNextPartIfPossible();
void clearBackgroundReaderState();
void updateBackgroundReaderPending();
void queueDeferredPartExpansion(int32_t partIndex);
void queueDeferredWrapExpansion();
void queueDeferredNextPartPrefetch();
bool runBackgroundReaderPass();
void beginOpenSelectedEntry();
void moveSelection(int delta);
void scrollTouchList(int direction);
void onNavVertical(int direction);
void onNavHorizontal(int direction);
uint16_t libraryRowHeight() const;
void renderListScrollbar(int32_t count, int32_t first, int32_t rows, uint16_t top, uint16_t areaHeight);
void goToParentDirectory();
static void ensureSelectionVisible(int32_t selected, int32_t &first, int32_t rowsVisible);
void rewrapIfNeeded(uint16_t contentWidth, uint16_t contentHeight, bool allowPartialWrap = false);
void wrapDisplayText(uint16_t contentWidth, size_t maxLines = 0U);
void scrollByPages(int deltaPages, bool showLoading = true);
void resetImageView();
void closeExpandedImage();
bool currentImagePageLine(std::string *lineOut = nullptr) const;
bool syncImageViewToCurrentPage();
bool cycleImageZoom(bool zoomOut);
bool panImageView(int16_t dx, int16_t dy);
void queueImageInteractionUpdate(bool panOnly = false);
void openReaderMenu();
void selectMenuItem(uint8_t item);
uint8_t readerMenuItemCount() const;
void renderReaderMenu();
void renderReaderHeader(const char *rightLabel);
void renderImageToolbar();
AppletFont readingFont();
void saveReadingPosition();
void loadReadingPosition();
void restoreLocation(int32_t part, int32_t line);
int32_t estimateImageLineSpan(const std::string &imagePath, uint16_t contentWidth, uint16_t lineHeight) const;
bool drawInlineImage(const std::string &imagePath, int16_t left, int16_t top, uint16_t maxWidth, uint16_t maxHeight,
uint16_t zoomPermille = 1000, int16_t panX = 0, int16_t panY = 0,
std::vector<uint8_t> *monoBitmap = nullptr, bool useRenderedCache = false, bool preloadOnly = false,
bool inlineRaster = false, int16_t clipBottom = -1, bool *prefetchInterrupted = nullptr,
uint16_t *bitmapWidth = nullptr, uint16_t *bitmapHeight = nullptr);
bool prefetchDecodedImages(const std::string &text);
bool drawInlineImageSequence(const std::vector<std::string> &imagePaths, int16_t left, int16_t top, uint16_t maxWidth,
uint16_t maxHeight, uint16_t zoomPermille = 1000, int16_t panX = 0, int16_t panY = 0,
bool useRenderedCache = false);
bool jumpToChapter(int32_t chapterIndex);
void beginJumpToChapter(int32_t chapterIndex);
int32_t getCurrentChapterIndex() const;
bool openChapterPicker();
void closeChapterPicker();
void renderBrowser();
void renderOpening(bool full);
void renderLoading(int16_t left, int16_t top, uint16_t areaWidth, uint16_t areaHeight, bool overlay = true);
void renderReader(bool completeImages = false, bool imageOnly = false);
bool pageImagesNeedLoading(int32_t firstLine, int32_t count);
uint16_t alignedTextOffset(const std::string &line, uint16_t offset, uint8_t alignment);
void renderStyledText(int16_t x, int16_t y, std::string_view text, uint8_t &style, int16_t underlineY = -1);
void renderChapterPicker();
};
} // namespace NicheGraphics::InkHUD
#endif
@@ -0,0 +1,28 @@
#pragma once
#include <algorithm>
#include <cstdint>
namespace NicheGraphics::InkHUD
{
struct ReaderToolbar {
static constexpr uint16_t height = 128;
static constexpr uint8_t count = 5;
static constexpr uint16_t progressBottomInset = 8;
static uint16_t progressFooterHeight(uint16_t lineHeight) { return lineHeight + 2U * progressBottomInset; }
static uint16_t inset(uint16_t width) { return std::min<uint16_t>(24, width / 12); }
static uint16_t boundary(uint16_t width, uint8_t index)
{
return inset(width) + static_cast<uint32_t>(width - 2 * inset(width)) * index / count;
}
static uint8_t hit(uint16_t width, uint16_t x)
{
for (uint8_t i = 1; i < count; ++i) {
if (x < boundary(width, i))
return i - 1;
}
return count - 1;
}
};
} // namespace NicheGraphics::InkHUD
@@ -0,0 +1,30 @@
#pragma once
#include <algorithm>
#include <cstddef>
#include <cstdint>
#include <vector>
namespace NicheGraphics::InkHUD
{
inline int32_t readerPageCount(int32_t lines, int32_t step)
{
return 1 + (std::max<int32_t>(1, lines) - 1) / std::max<int32_t>(1, step);
}
inline int32_t readerLastPageStart(int32_t lines, int32_t step)
{
return (readerPageCount(lines, step) - 1) * std::max<int32_t>(1, step);
}
inline uint32_t readerPagePrefix(const std::vector<int32_t> &counts, size_t end)
{
uint32_t pages = 0;
if (end > counts.size())
return 0;
for (size_t i = 0; i < end; ++i) {
if (counts[i] <= 0)
return 0;
pages += counts[i];
}
return pages;
}
} // namespace NicheGraphics::InkHUD
@@ -0,0 +1,239 @@
#pragma once
#include <cstdint>
#include <cstring>
#include <string>
#include <string_view>
#include <utility>
#include <vector>
namespace NicheGraphics::InkHUD
{
constexpr const char *BULLET_MARKER = "[[_BR_BULLET_]]";
constexpr const char *FIRST_LINE_MARKER = "[[_BR_FIRST_]]";
constexpr const char *TABLE_START = "[[_BR_TABLE_]]";
constexpr const char *TABLE_END = "[[_BR_TABLE_END_]]";
constexpr const char *TABLE_ROW = "[[_BR_ROW_]]";
constexpr const char *TABLE_CELL = "[[_BR_CELL_]]";
constexpr const char *TABLE_LINE = "[[_BR_GRID_]]";
inline size_t readerTagEnd(std::string_view text, size_t pos)
{
if (pos >= text.size())
return std::string_view::npos;
const size_t start = pos && text[pos - 1] == '<' ? pos - 1 : pos;
for (const auto &block : {std::pair{"<!--", "-->"}, std::pair{"<![CDATA[", "]]>"}}) {
if (text.compare(start, std::strlen(block.first), block.first) == 0) {
const size_t end = text.find(block.second, start + std::strlen(block.first));
return end == std::string_view::npos ? end : end + 2;
}
}
char quote = 0;
for (; pos < text.size(); ++pos) {
const char c = text[pos];
if (quote) {
if (c == quote)
quote = 0;
} else if (c == '\'' || c == '"') {
quote = c;
} else if (c == '>') {
return pos;
}
}
return std::string_view::npos;
}
inline std::string readerXmlAttribute(std::string_view tag, std::string_view name)
{
auto space = [](char c) { return c == ' ' || c == '\t' || c == '\r' || c == '\n'; };
auto lower = [](char c) { return c >= 'A' && c <= 'Z' ? c + ('a' - 'A') : c; };
size_t pos = 0;
while (pos < tag.size()) {
if (space(tag[pos]) || tag[pos] == '<' || tag[pos] == '/' || tag[pos] == '?') {
++pos;
continue;
}
if (tag[pos] == '>')
break;
const size_t start = pos;
while (pos < tag.size() && !space(tag[pos]) && tag[pos] != '=' && tag[pos] != '/' && tag[pos] != '>' &&
tag[pos] != '\'' && tag[pos] != '"')
++pos;
const std::string_view key = tag.substr(start, pos - start);
while (pos < tag.size() && space(tag[pos]))
++pos;
if (pos == tag.size())
break;
if (tag[pos] != '=') {
if (tag[pos] == '\'' || tag[pos] == '"') {
const size_t end = tag.find(tag[pos], pos + 1);
pos = end == std::string_view::npos ? tag.size() : end + 1;
}
continue;
}
++pos;
while (pos < tag.size() && space(tag[pos]))
++pos;
if (pos == tag.size())
break;
const char quote = tag[pos] == '\'' || tag[pos] == '"' ? tag[pos++] : 0;
const size_t valueStart = pos;
while (pos < tag.size() && (quote ? tag[pos] != quote : !space(tag[pos]) && tag[pos] != '>'))
++pos;
if (quote && pos == tag.size())
return {};
const std::string_view value = tag.substr(valueStart, pos - valueStart);
if (quote)
++pos;
bool matches = !name.empty() && key.size() == name.size();
for (size_t i = 0; matches && i < key.size(); ++i)
matches = lower(key[i]) == lower(name[i]);
if (!matches)
continue;
std::string out;
out.reserve(value.size());
for (size_t i = 0; i < value.size();) {
if (value[i] != '&') {
out.push_back(value[i++]);
continue;
}
const size_t end = value.find_first_of(";&", i + 1);
if (end == std::string_view::npos) {
out.append(value.substr(i));
break;
}
if (value[end] == '&') {
out.append(value.substr(i, end - i));
i = end;
continue;
}
const std::string_view ref = value.substr(i + 1, end - i - 1);
uint32_t cp = ref == "amp" ? '&'
: ref == "lt" ? '<'
: ref == "gt" ? '>'
: ref == "quot" ? '"'
: ref == "apos" ? '\''
: ref == "nbsp" ? 0xA0
: ref == "hellip" ? 0x2026
: 0;
if (!ref.empty() && ref[0] == '#') {
const bool hex = ref.size() > 1 && ref[1] == 'x';
size_t digit = hex ? 2 : 1;
bool valid = digit < ref.size();
const uint32_t base = hex ? 16 : 10;
for (; valid && digit < ref.size(); ++digit) {
const char c = ref[digit];
const uint32_t n = c >= '0' && c <= '9' ? c - '0'
: c >= 'a' && c <= 'f' ? c - 'a' + 10
: c >= 'A' && c <= 'F' ? c - 'A' + 10
: 16;
valid = n < base && cp <= (0x10FFFFU - n) / base;
if (valid)
cp = cp * base + n;
}
if (!valid)
cp = 0;
}
const bool valid = cp == 9 || cp == 10 || cp == 13 || (cp >= 0x20 && cp <= 0xD7FF) ||
(cp >= 0xE000 && cp <= 0xFFFD) || (cp >= 0x10000 && cp <= 0x10FFFF);
if (!valid) {
out.append(value.substr(i, end - i + 1));
} else if (cp < 0x80) {
out.push_back(static_cast<char>(cp));
} else {
const unsigned count = cp < 0x800 ? 2 : cp < 0x10000 ? 3 : 4;
out.push_back(static_cast<char>((count == 2 ? 0xC0 : count == 3 ? 0xE0 : 0xF0) | (cp >> (6 * (count - 1)))));
for (unsigned shift = 6 * (count - 1); shift; shift -= 6)
out.push_back(static_cast<char>(0x80 | ((cp >> (shift - 6)) & 0x3F)));
}
i = end + 1;
}
return out;
}
return {};
}
inline std::vector<std::string> readerTableCells(const std::string &row)
{
std::vector<std::string> cells;
size_t start = row.find(TABLE_CELL);
while (start != std::string::npos) {
start += std::strlen(TABLE_CELL);
const size_t end = row.find(TABLE_CELL, start);
cells.push_back(row.substr(start, end == std::string::npos ? end : end - start));
start = end;
}
return cells;
}
inline size_t readerBulletLengthAt(const std::string &text, size_t pos)
{
if (pos >= text.size())
return 0;
if (text.size() - pos >= 3) {
const auto a = static_cast<uint8_t>(text[pos]);
const auto b = static_cast<uint8_t>(text[pos + 1]);
const auto c = static_cast<uint8_t>(text[pos + 2]);
if (a == 0xE2U && ((b == 0x80U && (c == 0xA2U || c == 0xA3U)) || (b == 0x97U && (c == 0x8FU || c == 0xA6U))))
return 3;
}
if (text[pos] != '&')
return 0;
for (const char *entity : {"&bull;", "&bullet;", "&#8226;", "&#x2022;"}) {
const size_t length = std::strlen(entity);
if (text.compare(pos, length, entity) == 0)
return length;
}
return 0;
}
inline bool readerParagraphStartsWithBullet(const std::string &text, size_t pos)
{
while (pos < text.size()) {
if (text[pos] == '<') {
const size_t end = readerTagEnd(text, pos + 1);
if (end == std::string::npos || text[pos + 1] == '/')
return false;
pos = end + 1;
} else if (text[pos] == ' ' || text[pos] == '\t' || text[pos] == '\r' || text[pos] == '\n') {
++pos;
} else {
return readerBulletLengthAt(text, pos) != 0;
}
}
return false;
}
inline size_t readerSpaceLengthAt(const std::string &text, size_t pos)
{
if (pos >= text.size())
return 0;
const size_t remaining = text.size() - pos;
if (remaining >= 2 && static_cast<uint8_t>(text[pos]) == 0xC2U && static_cast<uint8_t>(text[pos + 1]) == 0xA0U)
return 2;
if (remaining < 3)
return 0;
const auto a = static_cast<uint8_t>(text[pos]);
const auto b = static_cast<uint8_t>(text[pos + 1]);
const auto c = static_cast<uint8_t>(text[pos + 2]);
if ((a == 0xE2U && b == 0x80U && ((c >= 0x80U && c <= 0x8AU) || c == 0xAFU)) || (a == 0xE2U && b == 0x81U && c == 0x9FU) ||
(a == 0xE3U && b == 0x80U && c == 0x80U))
return 3;
return 0;
}
inline void appendHtmlBlockBreak(std::string &text, bool compact)
{
while (!text.empty() && text.back() == ' ')
text.pop_back();
if (text.empty())
return;
const bool endsWithBreak = text.back() == '\n';
if (compact && endsWithBreak)
return;
if (endsWithBreak && text.size() > 1 && text[text.size() - 2] == '\n')
return;
text.push_back('\n');
}
} // namespace NicheGraphics::InkHUD
@@ -0,0 +1,361 @@
#pragma once
#include <algorithm>
#include <array>
#include <cstddef>
#include <cstdint>
#include <vector>
#if INKHUD_BOOKREADER_GRAYSCALE && defined(ARCH_ESP32) && defined(__GNUC__)
// Pixel conversion favors speed; restore the firmware's size optimization after these helpers.
#pragma GCC push_options
#pragma GCC optimize("O3")
#endif
namespace NicheGraphics::InkHUD
{
inline size_t readerBitmapStride(uint16_t width)
{
#if INKHUD_BOOKREADER_GRAYSCALE
return (static_cast<size_t>(width) + 1U) / 2U;
#else
return (static_cast<size_t>(width) + 7U) / 8U;
#endif
}
#if INKHUD_BOOKREADER_GRAYSCALE
// Reader caches store ink coverage: zero is white, fifteen is black.
inline constexpr uint8_t readerInkFromLuminance(unsigned luminance)
{
// Blend linear and quadratic luminance to deepen midtones without clipping the endpoints.
const unsigned adjusted = (luminance * (1275U + 3U * luminance) + 1020U) / 2040U;
return 15U - (adjusted * 15U + 127U) / 255U;
}
inline constexpr auto readerInkLookup = [] {
std::array<uint8_t, 256> ink{};
for (unsigned luminance = 0; luminance < ink.size(); ++luminance)
ink[luminance] = readerInkFromLuminance(luminance);
return ink;
}();
// Common scaling footprints contain at most sixteen source pixels.
inline constexpr auto readerAverageInkLookup = [] {
std::array<std::array<uint8_t, 241>, 16> averages{};
for (unsigned samples = 1; samples <= averages.size(); ++samples)
for (unsigned sum = 0; sum <= samples * 15U; ++sum)
averages[samples - 1U][sum] = (sum + samples / 2U) / samples;
return averages;
}();
inline uint8_t readerGrayAt(const uint8_t *data, size_t stride, uint16_t x, uint16_t y)
{
return (data[y * stride + x / 2U] >> ((x & 1U) ? 0U : 4U)) & 15U;
}
inline unsigned readerShortGraySpanSum(const uint8_t *row, uint16_t left, uint16_t span)
{
const uint8_t pair = row[left / 2U];
unsigned ink = (pair >> ((left & 1U) ? 0U : 4U)) & 15U;
if (span >= 2U)
ink += (left & 1U) ? row[left / 2U + 1U] >> 4U : pair & 15U;
if (span == 3U)
ink += (row[left / 2U + 1U] >> ((left & 1U) ? 0U : 4U)) & 15U;
return ink;
}
inline void setReaderGray(uint8_t *data, size_t stride, uint16_t x, uint16_t y, uint8_t ink)
{
uint8_t &byte = data[y * stride + x / 2U];
const unsigned shift = (x & 1U) ? 0U : 4U;
byte = (byte & ~(15U << shift)) | ((ink & 15U) << shift);
}
#endif
inline bool rasterizeInlineRgb565Block(const uint16_t *pixels, int x, int y, int width, int height, int pixelStride, uint16_t sw,
uint16_t sh, uint16_t dw, uint16_t dh, std::vector<uint8_t> &destination)
{
const size_t stride = readerBitmapStride(dw);
if (!pixels || x < 0 || y < 0 || width <= 0 || height <= 0 || pixelStride < width || !sw || !sh || !dw || !dh ||
destination.size() < stride * dh)
return false;
if (x >= sw || y >= sh)
return true;
const uint32_t xEnd = static_cast<uint32_t>(x) + std::min<int>(width, sw - x);
const uint32_t yEnd = static_cast<uint32_t>(y) + std::min<int>(height, sh - y);
const uint32_t left = (static_cast<uint32_t>(x) * dw + sw - 1U) / sw;
const uint32_t top = (static_cast<uint32_t>(y) * dh + sh - 1U) / sh;
const uint32_t right = (xEnd * dw + sw - 1U) / sw;
const uint32_t bottom = (yEnd * dh + sh - 1U) / sh;
#if !INKHUD_BOOKREADER_GRAYSCALE
// Dither after scaling, so gray detail survives reduction.
constexpr uint8_t bayer[] = {0, 8, 2, 10, 12, 4, 14, 6, 3, 11, 1, 9, 15, 7, 13, 5};
#endif
for (uint32_t dy = top; dy < bottom; ++dy) {
const uint16_t *row = pixels + (dy * sh / dh - y) * pixelStride;
#if !INKHUD_BOOKREADER_GRAYSCALE
uint8_t *out = destination.data() + dy * stride;
#endif
for (uint32_t dx = left; dx < right; ++dx) {
const uint16_t rgb = row[dx * sw / dw - x];
#if INKHUD_BOOKREADER_GRAYSCALE
const unsigned r = (rgb >> 11) & 31U, g = (rgb >> 5) & 63U, b = rgb & 31U;
const unsigned luminance =
(((r << 3U) | (r >> 2U)) * 38U + ((g << 2U) | (g >> 4U)) * 75U + ((b << 3U) | (b >> 2U)) * 15U + 64U) >> 7U;
setReaderGray(destination.data(), stride, dx, dy, readerInkLookup[luminance]);
#else
const unsigned luminance = (((rgb >> 11) & 31U) * 38U + ((rgb >> 5) & 63U) * 75U + (rgb & 31U) * 15U) / 25U;
const unsigned threshold = bayer[(dy & 3U) * 4U + (dx & 3U)] * 16U + 8U;
const uint8_t mask = 0x80U >> (dx & 7U);
if (luminance < threshold)
out[dx / 8U] |= mask;
else
out[dx / 8U] &= static_cast<uint8_t>(~mask);
#endif
}
}
return true;
}
inline bool packGraySourceBlock(const uint8_t *pixels, int x, int y, int width, int height, int pixelStride, uint16_t sourceWidth,
uint16_t sourceHeight, std::vector<uint8_t> &source)
{
const size_t stride = readerBitmapStride(sourceWidth);
if (!pixels || width <= 0 || height <= 0 || pixelStride < width || source.size() < stride * sourceHeight)
return false;
#if INKHUD_BOOKREADER_GRAYSCALE
const auto inkAt = [](uint8_t gray) { return readerInkLookup[gray]; };
const int firstX = std::max(0, -x);
const int endX = std::min(width, static_cast<int>(sourceWidth) - x);
if (firstX >= endX)
return true;
for (int sy = std::max(0, -y); sy < height && y + sy < sourceHeight; ++sy) {
uint8_t *out = source.data() + static_cast<size_t>(y + sy) * stride + (x + firstX) / 2U;
const uint8_t *in = pixels + static_cast<size_t>(sy) * pixelStride + firstX;
int remaining = endX - firstX;
if ((x + firstX) & 1U) {
*out = (*out & 0xF0U) | inkAt(*in++);
++out;
--remaining;
}
for (; remaining >= 2; remaining -= 2, in += 2)
*out++ = (inkAt(in[0]) << 4U) | inkAt(in[1]);
if (remaining)
*out = (*out & 0x0FU) | (inkAt(*in) << 4U);
}
return true;
#else
if (x >= 0 && y >= 0 && (x & 7) == 0 && x + width <= sourceWidth && y + height <= sourceHeight) {
const size_t firstByte = static_cast<size_t>(x) / 8U;
for (int sy = 0; sy < height; ++sy) {
uint8_t *out = source.data() + static_cast<size_t>(y + sy) * stride + firstByte;
const uint8_t *in = pixels + static_cast<size_t>(sy) * pixelStride;
int sx = 0;
for (; sx + 8 <= width; sx += 8) {
out[sx / 8] = static_cast<uint8_t>((in[sx] < 132 ? 0x80U : 0U) | (in[sx + 1] < 132 ? 0x40U : 0U) |
(in[sx + 2] < 132 ? 0x20U : 0U) | (in[sx + 3] < 132 ? 0x10U : 0U) |
(in[sx + 4] < 132 ? 0x08U : 0U) | (in[sx + 5] < 132 ? 0x04U : 0U) |
(in[sx + 6] < 132 ? 0x02U : 0U) | (in[sx + 7] < 132 ? 0x01U : 0U));
}
for (; sx < width; ++sx) {
const uint8_t mask = 0x80U >> (sx & 7);
if (in[sx] < 132)
out[sx / 8] |= mask;
else
out[sx / 8] &= static_cast<uint8_t>(~mask);
}
}
return true;
}
for (int sy = 0; sy < height && y + sy < sourceHeight; ++sy) {
if (y + sy < 0)
continue;
uint8_t *row = source.data() + (y + sy) * stride;
for (int sx = 0; sx < width && x + sx < sourceWidth; ++sx) {
if (x + sx < 0)
continue;
const uint8_t mask = 0x80U >> ((x + sx) & 7);
// Match the RGB565 threshold for grayscale samples.
if (pixels[sy * pixelStride + sx] < 132)
row[(x + sx) / 8] |= mask;
else
row[(x + sx) / 8] &= static_cast<uint8_t>(~mask);
}
}
return true;
#endif
}
inline bool scaleSourceBitmap(const std::vector<uint8_t> &source, uint16_t sw, uint16_t sh, uint16_t dw, uint16_t dh,
std::vector<uint8_t> &destination)
{
const size_t sourceStride = readerBitmapStride(sw);
if (!sw || !sh || !dw || !dh || source.size() < sourceStride * sh || &source == &destination)
return false;
if (sw == dw && sh == dh) {
destination.assign(source.begin(), source.begin() + sourceStride * sh);
return true;
}
const size_t stride = readerBitmapStride(dw);
destination.assign(stride * dh, 0);
#if INKHUD_BOOKREADER_GRAYSCALE
std::vector<uint16_t> columns(static_cast<size_t>(dw) + 1U);
for (uint32_t x = 0; x <= dw; ++x)
columns[x] = x * sw / dw;
std::vector<uint16_t> spans(dw);
for (uint32_t x = 0; x < dw; ++x)
spans[x] = std::max<uint16_t>(1U, columns[x + 1U] - columns[x]);
std::vector<uint32_t> sums(dw);
uint32_t previousTop = UINT32_MAX, previousBottom = UINT32_MAX;
for (uint32_t y = 0; y < dh; ++y) {
const uint32_t top = y * sh / dh;
const uint32_t bottom = std::max(top + 1U, (y + 1U) * sh / dh);
if (y && top == previousTop && bottom == previousBottom) {
std::copy_n(destination.data() + (y - 1U) * stride, stride, destination.data() + y * stride);
continue;
}
previousTop = top;
previousBottom = bottom;
if (sw <= 2U * dw && bottom == top + 1U) {
uint8_t *out = destination.data() + y * stride;
const auto ink = [&](uint32_t x) {
const uint8_t first = readerGrayAt(source.data(), sourceStride, columns[x], top);
if (spans[x] == 1U)
return first;
const uint8_t second = readerGrayAt(source.data(), sourceStride, columns[x] + 1U, top);
return static_cast<uint8_t>((first + second + 1U) >> 1U);
};
uint32_t x = 0;
for (; x + 1U < dw; x += 2U)
out[x / 2U] = (ink(x) << 4U) | ink(x + 1U);
if (x < dw)
out[x / 2U] = ink(x) << 4U;
continue;
}
if (sw <= 2U * dw && bottom == top + 2U) {
const uint8_t *firstRow = source.data() + top * sourceStride;
const uint8_t *secondRow = firstRow + sourceStride;
uint8_t *out = destination.data() + y * stride;
const auto ink = [&](uint32_t x) {
const uint32_t left = columns[x];
const uint8_t first = firstRow[left / 2U], second = secondRow[left / 2U];
const unsigned shift = (left & 1U) ? 0U : 4U;
unsigned sum = ((first >> shift) & 15U) + ((second >> shift) & 15U);
if (spans[x] == 1U)
return (sum + 1U) >> 1U;
sum += (left & 1U) ? (firstRow[left / 2U + 1U] >> 4U) + (secondRow[left / 2U + 1U] >> 4U)
: (first & 15U) + (second & 15U);
return (sum + 2U) >> 2U;
};
uint32_t x = 0;
for (; x + 1U < dw; x += 2U)
out[x / 2U] = (ink(x) << 4U) | ink(x + 1U);
if (x < dw)
out[x / 2U] = ink(x) << 4U;
continue;
}
if (sw <= 3U * dw && bottom <= top + 3U) {
const uint8_t *firstRow = source.data() + top * sourceStride;
const unsigned rows = bottom - top;
uint8_t *out = destination.data() + y * stride;
const auto ink = [&](uint32_t x) {
const uint16_t left = columns[x], span = spans[x];
unsigned sum = readerShortGraySpanSum(firstRow, left, span);
if (rows >= 2U)
sum += readerShortGraySpanSum(firstRow + sourceStride, left, span);
if (rows == 3U)
sum += readerShortGraySpanSum(firstRow + 2U * sourceStride, left, span);
return readerAverageInkLookup[span * rows - 1U][sum];
};
uint32_t x = 0;
for (; x + 1U < dw; x += 2U)
out[x / 2U] = (ink(x) << 4U) | ink(x + 1U);
if (x < dw)
out[x / 2U] = ink(x) << 4U;
continue;
}
std::fill(sums.begin(), sums.end(), 0U);
// Read each source row sequentially instead of revisiting it for every destination pixel.
for (uint32_t sy = top; sy < bottom; ++sy) {
const uint8_t *row = source.data() + sy * sourceStride;
for (uint32_t x = 0; x < dw; ++x) {
const uint32_t left = columns[x];
if (spans[x] <= 3U) {
sums[x] += readerShortGraySpanSum(row, left, spans[x]);
continue;
}
const uint32_t right = left + spans[x];
uint32_t ink = 0;
uint32_t sx = left;
if (sx & 1U) {
ink += row[sx / 2U] & 15U;
++sx;
}
for (; sx + 1U < right; sx += 2U) {
const uint8_t pair = row[sx / 2U];
ink += (pair >> 4U) + (pair & 15U);
}
if (sx < right)
ink += row[sx / 2U] >> 4U;
sums[x] += ink;
}
}
uint8_t *out = destination.data() + y * stride;
const uint32_t rows = bottom - top;
const auto average = [&](uint32_t x) {
const uint32_t samples = spans[x] * rows;
return samples <= readerAverageInkLookup.size() ? static_cast<uint32_t>(readerAverageInkLookup[samples - 1U][sums[x]])
: (sums[x] + samples / 2U) / samples;
};
uint32_t x = 0;
for (; x + 1U < dw; x += 2U)
out[x / 2U] = (average(x) << 4U) | average(x + 1U);
if (x < dw)
out[x / 2U] = average(x) << 4U;
}
return true;
#else
if (dw < sw || dh < sh) {
// Preserve source-pixel coverage when reducing fine lines and screentones.
constexpr uint8_t bayer[] = {0, 8, 2, 10, 12, 4, 14, 6, 3, 11, 1, 9, 15, 7, 13, 5};
for (uint32_t y = 0; y < dh; ++y) {
const uint32_t top = y * sh / dh;
const uint32_t bottom = std::max(top + 1U, (y + 1U) * sh / dh);
uint8_t *out = destination.data() + y * stride;
for (uint32_t x = 0; x < dw; ++x) {
const uint32_t left = x * sw / dw;
const uint32_t right = std::max(left + 1U, (x + 1U) * sw / dw);
uint32_t black = 0;
for (uint32_t sy = top; sy < bottom; ++sy) {
const uint8_t *row = source.data() + sy * sourceStride;
for (uint32_t sx = left; sx < right; ++sx)
black += (row[sx / 8U] >> (7U - (sx & 7U))) & 1U;
}
const uint32_t samples = (right - left) * (bottom - top);
const uint32_t threshold = bayer[(y & 3U) * 4U + (x & 3U)] * 2U + 1U;
if (static_cast<uint64_t>(black) * 32U > static_cast<uint64_t>(samples) * threshold)
out[x / 8U] |= 0x80U >> (x & 7U);
}
}
return true;
}
std::vector<uint16_t> columns(dw);
for (uint32_t x = 0; x < dw; ++x)
columns[x] = x * sw / dw;
for (uint32_t y = 0; y < dh; ++y) {
const uint8_t *row = source.data() + (y * sh / dh) * sourceStride;
uint8_t *out = destination.data() + y * stride;
for (uint32_t x = 0; x < dw; ++x) {
const uint16_t sx = columns[x];
if (row[sx / 8] & (0x80U >> (sx & 7)))
out[x / 8] |= 0x80U >> (x & 7);
}
}
return true;
#endif
}
} // namespace NicheGraphics::InkHUD
#if INKHUD_BOOKREADER_GRAYSCALE && defined(ARCH_ESP32) && defined(__GNUC__)
#pragma GCC pop_options
#endif
+7 -1
View File
@@ -157,7 +157,13 @@ void InkHUD::Events::onExitShort()
// Touch-capable InkHUD nodes use EXIT/HOME as a quick app switcher launcher.
if (!dismissedExt && inkhud->hasTouchEnabledProvider()) {
inkhud->openAppSwitcher();
#ifdef MESHTASTIC_INCLUDE_INKHUD_BOOKREADER
Applet *userConsumer = inkhud->getActiveApplet();
if (userConsumer && userConsumer->isInputSubscribed(Applet::EXIT_SHORT))
userConsumer->onExitShort();
else
#endif
inkhud->openAppSwitcher();
return;
}
+15
View File
@@ -553,4 +553,19 @@ void InkHUD::InkHUD::drawPixel(int16_t x, int16_t y, Color c)
renderer->handlePixel(x, y, c);
}
#ifdef MESHTASTIC_INCLUDE_INKHUD_BOOKREADER
#if INKHUD_BOOKREADER_GRAYSCALE
void InkHUD::InkHUD::writeGrayBitmap(int16_t screenX, int16_t screenY, uint16_t w, uint16_t h, const uint8_t *bitmap,
uint16_t bitmapStride, uint16_t sourceX, uint16_t sourceY)
{
renderer->writeGrayBitmap(screenX, screenY, w, h, bitmap, bitmapStride, sourceX, sourceY);
}
#endif
void InkHUD::InkHUD::writeBitmap(int16_t screenX, int16_t screenY, uint16_t w, uint16_t h, const uint8_t *bitmap,
uint16_t bitmapStride, uint16_t sourceX, uint16_t sourceY)
{
renderer->writeBitmap(screenX, screenY, w, h, bitmap, bitmapStride, sourceX, sourceY);
}
#endif
#endif
+11
View File
@@ -134,6 +134,17 @@ class InkHUD
// Pass drawing output to Renderer
void drawPixel(int16_t x, int16_t y, Color c);
#ifdef MESHTASTIC_INCLUDE_INKHUD_BOOKREADER
// Fast bulk bitmap blit bypasses the per-pixel GFX/Tile call chain (used by BookReader for pan).
// screenX/screenY are post-tile-offset screen coordinates.
void writeBitmap(int16_t screenX, int16_t screenY, uint16_t w, uint16_t h, const uint8_t *bitmap, uint16_t bitmapStride,
uint16_t sourceX = 0, uint16_t sourceY = 0);
#if INKHUD_BOOKREADER_GRAYSCALE
void writeGrayBitmap(int16_t screenX, int16_t screenY, uint16_t w, uint16_t h, const uint8_t *bitmap, uint16_t bitmapStride,
uint16_t sourceX = 0, uint16_t sourceY = 0);
#endif
#endif
// Shared data which persists between boots
Persistence *persistence = nullptr;
+599 -47
View File
@@ -2,7 +2,15 @@
#include "./Renderer.h"
#ifdef MESHTASTIC_INCLUDE_INKHUD_BOOKREADER
#include "./Applets/User/BookReader/BookReaderApplet.h"
#include "PowerFSM.h"
#endif
#include "main.h"
#if INKHUD_BOOKREADER_GRAYSCALE
#include <array>
#include <esp_heap_caps.h>
#endif
#include "./Applet.h"
#include "./SystemApplet.h"
@@ -10,6 +18,16 @@
using namespace NicheGraphics;
#ifdef MESHTASTIC_INCLUDE_INKHUD_BOOKREADER
#ifndef INKHUD_BOOKREADER_PERF_LOG
#define INKHUD_BOOKREADER_PERF_LOG 0
#endif
#if INKHUD_BOOKREADER_PERF_LOG
static uint32_t readerRefreshStartMs = 0;
#endif
#endif
InkHUD::Renderer::Renderer() : concurrency::OSThread("Renderer")
{
// Nothing for the timer to do just yet
@@ -21,7 +39,8 @@ InkHUD::Renderer::Renderer() : concurrency::OSThread("Renderer")
}
// Connect the (fully set-up) E-Ink driver to InkHUD
// Should happen in your variant's nicheGraphics.h file, before InkHUD::begin is called
// Should happen in your variant's nicheGraphics.h file, before InkHUD::begin is
// called
void InkHUD::Renderer::setDriver(Drivers::EInk *driver)
{
// Make sure not already set
@@ -36,7 +55,8 @@ void InkHUD::Renderer::setDriver(Drivers::EInk *driver)
// Determine the dimensions 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.
// Not all display widths are divisible by 8. Need to make sure bytecount
// accommodates padding for these.
imageBufferWidth = ((driver->width - 1) / 8) + 1;
imageBufferHeight = driver->height;
@@ -44,10 +64,11 @@ void InkHUD::Renderer::setDriver(Drivers::EInk *driver)
imageBuffer = new uint8_t[imageBufferWidth * imageBufferHeight];
}
// Set the target number of FAST display updates in a row, before a FULL update is used for display health
// This value applies only to updates with an UNSPECIFIED update type
// If explicitly requested FAST updates exceed this target, the stressMultiplier parameter determines how many
// subsequent FULL updates will be performed, in an attempt to restore the display's health
// Set the target number of FAST display updates in a row, before a FULL update
// is used for display health This value applies only to updates with an
// UNSPECIFIED update type If explicitly requested FAST updates exceed this
// target, the stressMultiplier parameter determines how many subsequent FULL
// updates will be performed, in an attempt to restore the display's health
void InkHUD::Renderer::setDisplayResilience(uint8_t fastPerFull, float stressMultiplier)
{
displayHealth.fastPerFull = fastPerFull;
@@ -59,9 +80,354 @@ void InkHUD::Renderer::begin()
forceUpdate(Drivers::EInk::UpdateTypes::FULL, true, false);
}
#ifdef MESHTASTIC_INCLUDE_INKHUD_BOOKREADER
#if INKHUD_BOOKREADER_GRAYSCALE
void InkHUD::Renderer::setGrayPixel(uint16_t x, uint16_t y, uint8_t luminance)
{
uint8_t &byte = grayBuffer[static_cast<size_t>(y) * ((driver->width + 1U) / 2U) + x / 2U];
const unsigned shift = (x & 1U) ? 0U : 4U;
const uint8_t previous = (byte >> shift) & 15U;
grayPixelCount -= previous != 0U && previous != 15U;
grayPixelCount += luminance != 0U && luminance != 15U;
byte = (byte & ~(15U << shift)) | (luminance << shift);
}
#if defined(ARCH_ESP32) && defined(__GNUC__)
// Favor speed for the packed-pixel blit without changing panel timing.
__attribute__((optimize("O3")))
#endif
void InkHUD::Renderer::writeGrayBitmap(int16_t screenX, int16_t screenY, uint16_t w, uint16_t h, const uint8_t *bitmap,
uint16_t bitmapStride, uint16_t sourceX, uint16_t sourceY)
{
if (!bitmap || !w || !h)
return;
if (!grayBuffer && driver->supportsGrayscale()) {
grayBuffer = static_cast<uint8_t *>(
heap_caps_malloc(((driver->width + 1U) / 2U) * driver->height, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT));
if (grayBuffer) {
memset(grayBuffer, 0xFF, ((driver->width + 1U) / 2U) * driver->height);
for (uint16_t y = 0; y < driver->height; ++y)
for (uint16_t x = 0; x < driver->width; ++x)
setGrayPixel(x, y, (imageBuffer[y * imageBufferWidth + x / 8U] & (0x80U >> (x & 7U))) ? 15U : 0U);
}
}
constexpr uint8_t bayer[] = {0, 8, 2, 10, 12, 4, 14, 6, 3, 11, 1, 9, 15, 7, 13, 5};
const int32_t firstX = std::max<int32_t>(0, -static_cast<int32_t>(screenX));
const int32_t firstY = std::max<int32_t>(0, -static_cast<int32_t>(screenY));
const int32_t endX = std::min<int32_t>(w, static_cast<int32_t>(width()) - screenX);
const int32_t endY = std::min<int32_t>(h, static_cast<int32_t>(height()) - screenY);
if (firstX >= endX || firstY >= endY)
return;
const uint16_t monoStride = imageBufferWidth;
const uint16_t grayStride = (driver->width + 1U) / 2U;
uint8_t *const mono = imageBuffer;
uint8_t *const gray = grayBuffer;
uint32_t intermediatePixels = grayPixelCount;
static constexpr auto intermediateCount = [] {
std::array<uint8_t, 256> counts{};
for (unsigned pair = 0; pair < counts.size(); ++pair)
counts[pair] = ((pair >> 4U) != 0U && (pair >> 4U) != 15U) + ((pair & 15U) != 0U && (pair & 15U) != 15U);
return counts;
}();
#if INKHUD_BOOKREADER_PERF_LOG
const uint32_t blitStartMs = millis();
uint32_t inkHistogram[16] = {};
uint32_t blitHash = 2166136261U;
#endif
const auto writePixel = [&](uint8_t *monoRow, uint8_t *grayRow, int16_t x, int32_t sx, int32_t sy, uint8_t ink) {
#if INKHUD_BOOKREADER_PERF_LOG
++inkHistogram[ink];
blitHash = (blitHash ^ ink) * 16777619U;
#endif
if (grayRow) {
uint8_t &byte = grayRow[x / 2U];
const unsigned shift = (x & 1U) ? 0U : 4U;
const uint8_t previous = (byte >> shift) & 15U;
intermediatePixels -= previous != 0U && previous != 15U;
intermediatePixels += ink != 0U && ink != 15U;
byte = (byte & ~(15U << shift)) | ((15U - ink) << shift);
} else {
const bool black = ink * 32U > (bayer[((screenY + sy) & 3U) * 4U + ((screenX + sx) & 3U)] * 2U + 1U) * 15U;
bitWrite(monoRow[x / 8U], 7U - (x & 7U), !black);
}
};
const auto writeGraySpan = [&](uint8_t *row, int16_t x, int step, const uint8_t *source, int count, int sourceStride) {
int i = 0;
if ((x & 1U) != (step < 0)) {
writePixel(nullptr, row, x, 0, 0, source[0]);
x += step;
++i;
}
for (; i + 1 < count; i += 2, x += 2 * step) {
const uint8_t first = source[i * sourceStride];
const uint8_t second = source[(i + 1) * sourceStride];
#if INKHUD_BOOKREADER_PERF_LOG
++inkHistogram[first];
++inkHistogram[second];
blitHash = ((blitHash ^ first) * 16777619U ^ second) * 16777619U;
#endif
uint8_t &byte = row[x / 2U];
const uint8_t packedInk = step > 0 ? (first << 4U) | second : (second << 4U) | first;
intermediatePixels -= intermediateCount[byte];
intermediatePixels += intermediateCount[packedInk];
byte = packedInk ^ 0xFFU;
}
if (i < count)
writePixel(nullptr, row, x, 0, 0, source[i * sourceStride]);
};
// Transpose small SRAM tiles so rotated output does not thrash the PSRAM cache.
constexpr int32_t tileSize = 16;
uint8_t inks[tileSize][tileSize];
const uint8_t rotation = settings->rotation;
for (int32_t ty = firstY; ty < endY; ty += tileSize) {
const int32_t tileH = std::min(tileSize, endY - ty);
for (int32_t tx = firstX; tx < endX; tx += tileSize) {
const int32_t tileW = std::min(tileSize, endX - tx);
for (int32_t sy = 0; sy < tileH; ++sy) {
const uint8_t *srcRow = bitmap + static_cast<size_t>(sourceY + ty + sy) * bitmapStride;
uint16_t srcX = sourceX + tx;
int32_t sx = 0;
if (srcX & 1U) {
inks[sy][sx++] = srcRow[srcX++ / 2U] & 15U;
}
for (; sx + 1 < tileW; sx += 2, srcX += 2) {
const uint8_t pair = srcRow[srcX / 2U];
inks[sy][sx] = pair >> 4U;
inks[sy][sx + 1] = pair & 15U;
}
if (sx < tileW)
inks[sy][sx] = srcRow[srcX / 2U] >> 4U;
}
if (rotation & 1U) {
for (int32_t sx = 0; sx < tileW; ++sx) {
int16_t x = screenX + tx + sx;
int16_t y = screenY + ty;
rotatePixelCoords(&x, &y);
uint8_t *const monoRow = mono + static_cast<size_t>(y) * monoStride;
uint8_t *const grayRow = gray ? gray + static_cast<size_t>(y) * grayStride : nullptr;
if (grayRow) {
writeGraySpan(grayRow, x, rotation == 1 ? -1 : 1, reinterpret_cast<const uint8_t *>(inks) + sx, tileH,
tileSize);
continue;
}
for (int32_t sy = 0; sy < tileH; ++sy) {
writePixel(monoRow, grayRow, x, tx + sx, ty + sy, inks[sy][sx]);
x += rotation == 1 ? -1 : 1;
}
}
} else {
for (int32_t sy = 0; sy < tileH; ++sy) {
int16_t x = screenX + tx;
int16_t y = screenY + ty + sy;
rotatePixelCoords(&x, &y);
uint8_t *const monoRow = mono + static_cast<size_t>(y) * monoStride;
uint8_t *const grayRow = gray ? gray + static_cast<size_t>(y) * grayStride : nullptr;
if (grayRow) {
writeGraySpan(grayRow, x, rotation == 0 ? 1 : -1, reinterpret_cast<const uint8_t *>(inks) + sy * tileSize,
tileW, 1);
continue;
}
for (int32_t sx = 0; sx < tileW; ++sx) {
writePixel(monoRow, grayRow, x, tx + sx, ty + sy, inks[sy][sx]);
x += rotation == 0 ? 1 : -1;
}
}
}
}
}
grayPixelCount = intermediatePixels;
if (gray)
monoBufferNeedsSync = true;
#if INKHUD_BOOKREADER_PERF_LOG
LOG_INFO("[BookReaderDraw] blit xy=%d,%d size=%ux%u src=%u,%u "
"clip=%ld,%ld-%ld,%ld rotation=%u gray=%u hash=%08lx ms=%lu",
screenX, screenY, w, h, sourceX, sourceY, static_cast<long>(firstX), static_cast<long>(firstY),
static_cast<long>(endX), static_cast<long>(endY), rotation, gray != nullptr, static_cast<unsigned long>(blitHash),
static_cast<unsigned long>(millis() - blitStartMs));
LOG_INFO("[BookReaderDraw] image ink bins "
"white-to-black=%lu,%lu,%lu,%lu,%lu,%lu,%lu,%lu,%lu,%lu,%lu,%lu,%lu,"
"%lu,%lu,%lu",
inkHistogram[0], inkHistogram[1], inkHistogram[2], inkHistogram[3], inkHistogram[4], inkHistogram[5],
inkHistogram[6], inkHistogram[7], inkHistogram[8], inkHistogram[9], inkHistogram[10], inkHistogram[11],
inkHistogram[12], inkHistogram[13], inkHistogram[14], inkHistogram[15]);
#endif
}
#endif
// Fast path for blitting MSB-first 1-bpp bitmaps into imageBuffer.
void InkHUD::Renderer::writeBitmap(int16_t screenX, int16_t screenY, uint16_t w, uint16_t h, const uint8_t *bitmap,
uint16_t bitmapStride, uint16_t sourceX, uint16_t sourceY)
{
if (!bitmap || w == 0 || h == 0) {
return;
}
#if INKHUD_BOOKREADER_GRAYSCALE
if (grayBuffer) {
for (uint16_t y = 0; y < h; ++y)
for (uint16_t x = 0; x < w; ++x) {
const uint16_t sx = sourceX + x;
const bool black = bitmap[(sourceY + y) * bitmapStride + sx / 8U] & (0x80U >> (sx & 7U));
if (screenX + x >= 0 && screenY + y >= 0 && screenX + x < width() && screenY + y < height())
handlePixel(screenX + x, screenY + y, black ? BLACK : WHITE);
}
return;
}
#endif
switch (settings->rotation) {
case 0: {
// No rotation: iterate row-first for sequential imageBuffer access.
for (uint16_t sy = 0; sy < h; sy++) {
const int16_t bufY = static_cast<int16_t>(screenY + sy);
if (bufY < 0 || static_cast<uint16_t>(bufY) >= imageBufferHeight) {
continue;
}
const uint8_t *srcRow = bitmap + static_cast<size_t>(sourceY + sy) * bitmapStride;
uint8_t *dstRow = imageBuffer + static_cast<size_t>(bufY) * imageBufferWidth;
for (uint16_t sx = 0; sx < w; sx++) {
const int16_t bufX = static_cast<int16_t>(screenX + sx);
if (bufX < 0 || static_cast<uint16_t>(bufX) >= driver->width) {
continue;
}
const uint16_t srcX = static_cast<uint16_t>(sourceX + sx);
const bool black = (srcRow[srcX >> 3] & (static_cast<uint8_t>(0x80U) >> (srcX & 7U))) != 0U;
const uint8_t bitMask = static_cast<uint8_t>(0x80U) >> (static_cast<uint16_t>(bufX) & 7U);
if (black) {
dstRow[static_cast<uint16_t>(bufX) >> 3] &= static_cast<uint8_t>(~bitMask);
} else {
dstRow[static_cast<uint16_t>(bufX) >> 3] |= bitMask;
}
}
}
break;
}
case 1: {
// 90 degrees CW: iterate column-first so each inner loop writes one
// physical row.
const uint16_t dW = driver->width;
for (uint16_t sx = 0; sx < w; sx++) {
const int16_t physRow = static_cast<int16_t>(screenX + sx);
if (physRow < 0 || static_cast<uint16_t>(physRow) >= imageBufferHeight) {
continue;
}
uint8_t *dstRow = imageBuffer + static_cast<size_t>(physRow) * imageBufferWidth;
const uint16_t srcX = static_cast<uint16_t>(sourceX + sx);
const uint8_t srcMask = static_cast<uint8_t>(0x80U) >> (srcX & 7U);
const uint16_t srcByteOff = srcX >> 3;
for (uint16_t sy = 0; sy < h; sy++) {
const int32_t physColI =
static_cast<int32_t>(dW - 1U) - (static_cast<int32_t>(screenY) + static_cast<int32_t>(sy));
if (physColI < 0 || static_cast<uint32_t>(physColI) >= driver->width) {
continue;
}
const uint16_t physCol = static_cast<uint16_t>(physColI);
const bool black = (bitmap[static_cast<size_t>(sourceY + sy) * bitmapStride + srcByteOff] & srcMask) != 0U;
const uint8_t bitMask = static_cast<uint8_t>(0x80U) >> (physCol & 7U);
if (black) {
dstRow[physCol >> 3] &= static_cast<uint8_t>(~bitMask);
} else {
dstRow[physCol >> 3] |= bitMask;
}
}
}
break;
}
case 2: {
// 180 degrees: iterate row-first for sequential physical row access.
const uint32_t physicalWidth = driver->width;
for (uint16_t sy = 0; sy < h; sy++) {
const int32_t physRowI =
static_cast<int32_t>(imageBufferHeight - 1U) - (static_cast<int32_t>(screenY) + static_cast<int32_t>(sy));
if (physRowI < 0 || static_cast<uint32_t>(physRowI) >= static_cast<uint32_t>(imageBufferHeight)) {
continue;
}
uint8_t *dstRow = imageBuffer + static_cast<size_t>(physRowI) * imageBufferWidth;
const uint8_t *srcRow = bitmap + static_cast<size_t>(sourceY + sy) * bitmapStride;
for (uint16_t sx = 0; sx < w; sx++) {
const int32_t physColI =
static_cast<int32_t>(physicalWidth - 1U) - (static_cast<int32_t>(screenX) + static_cast<int32_t>(sx));
if (physColI < 0 || static_cast<uint32_t>(physColI) >= physicalWidth) {
continue;
}
const uint16_t physCol = static_cast<uint16_t>(physColI);
const uint16_t srcX = static_cast<uint16_t>(sourceX + sx);
const bool black = (srcRow[srcX >> 3] & (static_cast<uint8_t>(0x80U) >> (srcX & 7U))) != 0U;
const uint8_t bitMask = static_cast<uint8_t>(0x80U) >> (physCol & 7U);
if (black) {
dstRow[physCol >> 3] &= static_cast<uint8_t>(~bitMask);
} else {
dstRow[physCol >> 3] |= bitMask;
}
}
}
break;
}
case 3: {
// 270 degrees CW: iterate column-first so each inner loop writes one
// physical row.
for (uint16_t sx = 0; sx < w; sx++) {
const int32_t physRowI =
static_cast<int32_t>(imageBufferHeight - 1U) - (static_cast<int32_t>(screenX) + static_cast<int32_t>(sx));
if (physRowI < 0 || static_cast<uint32_t>(physRowI) >= static_cast<uint32_t>(imageBufferHeight)) {
continue;
}
const uint16_t physRow = static_cast<uint16_t>(physRowI);
uint8_t *dstRow = imageBuffer + static_cast<size_t>(physRow) * imageBufferWidth;
const uint16_t srcX = static_cast<uint16_t>(sourceX + sx);
const uint8_t srcMask = static_cast<uint8_t>(0x80U) >> (srcX & 7U);
const uint16_t srcByteOff = srcX >> 3;
for (uint16_t sy = 0; sy < h;) {
const int16_t physCol = static_cast<int16_t>(screenY + sy);
if (physCol < 0 || static_cast<uint16_t>(physCol) >= driver->width) {
++sy;
continue;
}
// Transpose eight source rows into one destination byte without
// changing any pixels.
if ((physCol & 7) == 0 && static_cast<uint32_t>(sy) + 8U <= h &&
static_cast<uint32_t>(physCol) + 8U <= driver->width) {
uint8_t blackBits = 0;
for (uint16_t row = 0; row < 8U; ++row) {
if (bitmap[static_cast<size_t>(sourceY + sy + row) * bitmapStride + srcByteOff] & srcMask)
blackBits |= static_cast<uint8_t>(0x80U >> row);
}
dstRow[static_cast<uint16_t>(physCol) >> 3] = static_cast<uint8_t>(~blackBits);
sy += 8U;
continue;
}
const bool black = (bitmap[static_cast<size_t>(sourceY + sy) * bitmapStride + srcByteOff] & srcMask) != 0U;
const uint8_t bitMask = static_cast<uint8_t>(0x80U) >> (static_cast<uint16_t>(physCol) & 7U);
if (black) {
dstRow[physCol >> 3] &= static_cast<uint8_t>(~bitMask);
} else {
dstRow[physCol >> 3] |= bitMask;
}
++sy;
}
}
break;
}
default: {
// Unexpected rotation: fall back to per-pixel handlePixel.
for (uint16_t sy = 0; sy < h; sy++) {
const uint8_t *srcRow = bitmap + static_cast<size_t>(sourceY + sy) * bitmapStride;
for (uint16_t sx = 0; sx < w; sx++) {
const uint16_t srcX = static_cast<uint16_t>(sourceX + sx);
const bool black = (srcRow[srcX >> 3] & (static_cast<uint8_t>(0x80U) >> (srcX & 7U))) != 0U;
handlePixel(static_cast<int16_t>(screenX + sx), static_cast<int16_t>(screenY + sy), black ? BLACK : WHITE);
}
}
break;
}
}
}
#endif
// Set a flag, which will be picked up by runOnce, ASAP.
// Quite likely, multiple applets will all want to respond to one event (Observable, etc)
// Each affected applet can independently call requestUpdate(), and all share the one opportunity to render, at next runOnce
// Quite likely, multiple applets will all want to respond to one event
// (Observable, etc) Each affected applet can independently call
// requestUpdate(), and all share the one opportunity to render, at next runOnce
void InkHUD::Renderer::requestUpdate(bool all)
{
requested = true;
@@ -74,12 +440,12 @@ void InkHUD::Renderer::requestUpdate(bool all)
runASAP = true;
}
// requestUpdate will not actually update if no requests were made by applets which are actually visible
// This can occur, because applets requestUpdate even from the background,
// in case the user's autoshow settings permit them to be moved to foreground.
// Sometimes, however, we will want to trigger a display update manually, in the absence of any sort of applet event
// Display health, for example.
// In these situations, we use forceUpdate
// requestUpdate will not actually update if no requests were made by applets
// which are actually visible This can occur, because applets requestUpdate even
// from the background, in case the user's autoshow settings permit them to be
// moved to foreground. Sometimes, however, we will want to trigger a display
// update manually, in the absence of any sort of applet event Display health,
// for example. In these situations, we use forceUpdate
void InkHUD::Renderer::forceUpdate(Drivers::EInk::UpdateTypes type, bool all, bool async)
{
requested = true;
@@ -87,17 +453,19 @@ void InkHUD::Renderer::forceUpdate(Drivers::EInk::UpdateTypes type, bool all, bo
renderAll |= all;
displayHealth.forceUpdateType(type);
// Normally, we need to start the timer, in case the display is busy and we briefly defer the update
// Normally, we need to start the timer, in case the display is busy and we
// briefly defer the update
if (async) {
// We will run the thread as soon as we loop(),
// after all Applets have had a chance to observe whatever event set this off
// after all Applets have had a chance to observe whatever event set this
// off
OSThread::setIntervalFromNow(0);
OSThread::enabled = true;
runASAP = true;
}
// If the update is *not* asynchronous, we begin the render process directly here
// so that it can block code flow while running
// If the update is *not* asynchronous, we begin the render process directly
// here so that it can block code flow while running
else
render(false);
}
@@ -112,15 +480,21 @@ void InkHUD::Renderer::awaitUpdate()
}
// Set a ready-to-draw pixel into the image buffer
// All rotations / translations have already taken place: this buffer data is formatted ready for the driver
// All rotations / translations have already taken place: this buffer data is
// formatted ready for the driver
void InkHUD::Renderer::handlePixel(int16_t x, int16_t y, Color c)
{
rotatePixelCoords(&x, &y);
uint32_t byteNum = (y * imageBufferWidth) + (x / 8); // X data is 8 pixels per byte
uint8_t bitNum = 7 - (x % 8); // Invert order: leftmost bit (most significant) is leftmost pixel of byte.
uint8_t bitNum = 7 - (x % 8); // Invert order: leftmost bit (most significant)
// is leftmost pixel of byte.
bitWrite(imageBuffer[byteNum], bitNum, c);
#if INKHUD_BOOKREADER_GRAYSCALE
if (grayBuffer)
setGrayPixel(x, y, c == WHITE ? 15U : 0U);
#endif
}
// Width of the display, relative to rotation
@@ -142,10 +516,15 @@ uint16_t InkHUD::Renderer::height()
}
// Runs at regular intervals
// - postponing render: until next loop(), allowing all applets to be notified of some Mesh event before render
// - postponing render: until next loop(), allowing all applets to be notified
// of some Mesh event before render
// - queuing another render: while one is already is progress
int32_t InkHUD::Renderer::runOnce()
{
#ifdef MESHTASTIC_INCLUDE_INKHUD_BOOKREADER
maintainRenderActivity();
#endif
// If an applet asked to render, and hardware is able, lets try now
if (requested && !driver->busy()) {
render();
@@ -153,16 +532,38 @@ int32_t InkHUD::Renderer::runOnce()
// If our render() call failed, try again shortly
// otherwise, stop our thread until next update due
#ifdef MESHTASTIC_INCLUDE_INKHUD_BOOKREADER
if (requested || keepAwakeUntilUpdateComplete)
#else
if (requested)
#endif
return 250UL;
else
return OSThread::disable();
}
#ifdef MESHTASTIC_INCLUDE_INKHUD_BOOKREADER
void InkHUD::Renderer::maintainRenderActivity()
{
if (keepAwakeUntilUpdateComplete) {
// Give the reader a full idle timeout after the physical refresh finishes.
powerFSM.trigger(EVENT_INPUT);
keepAwakeUntilUpdateComplete = driver->busy();
BookReaderApplet::setDisplayRefreshActive(keepAwakeUntilUpdateComplete);
#if INKHUD_BOOKREADER_PERF_LOG
if (!keepAwakeUntilUpdateComplete)
LOG_INFO("[BookReaderPerf] refresh END visible=1 ms=%lu timeoutRestarted=1",
static_cast<unsigned long>(millis() - readerRefreshStartMs));
#endif
}
}
#endif
// Applies the system-wide rotation to pixel positions
// This step is applied to image data which has already been translated by a Tile object
// This is the final step before the pixel is placed into the image buffer
// No return: values of the *x and *y parameters are modified by the method
// This step is applied to image data which has already been translated by a
// Tile object This is the final step before the pixel is placed into the image
// buffer No return: values of the *x and *y parameters are modified by the
// method
void InkHUD::Renderer::rotatePixelCoords(int16_t *x, int16_t *y)
{
// Apply a global rotation to pixel locations
@@ -190,8 +591,8 @@ void InkHUD::Renderer::rotatePixelCoords(int16_t *x, int16_t *y)
*y = y1;
}
// Make an attempt to gather image data from some / all applets, and update the display
// Might not be possible right now, if update already is progress.
// Make an attempt to gather image data from some / all applets, and update the
// display Might not be possible right now, if update already is progress.
void InkHUD::Renderer::render(bool async)
{
// Make sure the display is ready for a new update
@@ -204,16 +605,24 @@ void InkHUD::Renderer::render(bool async)
driver->await();
}
// Determine if a system applet has requested exclusive rights to request an update,
// or exclusive rights to render
// Determine if a system applet has requested exclusive rights to request an
// update, or exclusive rights to render
checkLocks();
// (Potentially) change applet to display new info,
// then check if this newly displayed applet makes a pending notification redundant
// then check if this newly displayed applet makes a pending notification
// redundant
inkhud->autoshow();
#ifdef MESHTASTIC_INCLUDE_INKHUD_BOOKREADER
// Consume this request before drawing; an applet may queue its next loading
// pass in onRender().
requested = false;
#endif
// If an update is justified.
// We don't know this until after autoshow has run, as new applets may now be in foreground
// We don't know this until after autoshow has run, as new applets may now be
// in foreground
if (shouldUpdate()) {
// Decide which technique the display will use to change image
@@ -226,41 +635,140 @@ void InkHUD::Renderer::render(bool async)
renderUserApplets();
renderPlaceholders();
renderSystemApplets();
#if INKHUD_BOOKREADER_GRAYSCALE
bool readerVisible = false;
#ifdef MESHTASTIC_INCLUDE_INKHUD_BOOKREADER
if (!lockRendering)
for (Applet *applet : inkhud->userApplets)
if (applet && applet->isActive() && applet->isForeground() && applet->keepScreenAwakeDuringRender()) {
readerVisible = true;
break;
}
#endif
const bool useGrayscale = grayBuffer && (grayPixelCount || readerVisible);
// Grayscale frames do not consume the monochrome buffer; rebuild it only on a monochrome transition.
if (!useGrayscale && grayBuffer && monoBufferNeedsSync) {
[[maybe_unused]] const uint32_t syncStartMs = millis();
const size_t grayStride = (driver->width + 1U) / 2U;
for (uint16_t y = 0; y < driver->height; ++y) {
const uint8_t *row = grayBuffer + static_cast<size_t>(y) * grayStride;
uint8_t *out = imageBuffer + static_cast<size_t>(y) * imageBufferWidth;
for (uint16_t bx = 0; bx < imageBufferWidth; ++bx) {
uint8_t white = 0xFFU;
for (uint16_t bit = 0; bit < 8U && bx * 8U + bit < driver->width; ++bit) {
const uint16_t x = bx * 8U + bit;
if (((row[x / 2U] >> ((x & 1U) ? 0U : 4U)) & 15U) == 0U)
white &= ~(0x80U >> bit);
}
out[bx] = white;
}
}
monoBufferNeedsSync = false;
#if INKHUD_BOOKREADER_PERF_LOG
LOG_INFO("[BookReaderPerf] monochrome buffer restored ms=%lu", static_cast<unsigned long>(millis() - syncStartMs));
#endif
}
if (useGrayscale && updateType != Drivers::EInk::UpdateTypes::FULL) {
displayHealth.forceUpdateType(Drivers::EInk::UpdateTypes::FULL);
updateType = displayHealth.decideUpdateType();
}
#endif
// Invert Buffer if set by user
if (config.display.displaymode == meshtastic_Config_DisplayConfig_DisplayMode_INVERTED) {
for (size_t i = 0; i < imageBufferWidth * imageBufferHeight; ++i) {
imageBuffer[i] = ~imageBuffer[i];
}
#if INKHUD_BOOKREADER_GRAYSCALE
if (useGrayscale)
for (size_t i = 0; i < ((driver->width + 1U) / 2U) * driver->height; ++i)
grayBuffer[i] = ~grayBuffer[i];
#endif
}
// Tell display to begin process of drawing new image
#ifdef MESHTASTIC_INCLUDE_INKHUD_BOOKREADER
if (keepAwakeUntilUpdateComplete)
BookReaderApplet::setDisplayRefreshActive(true);
#endif
LOG_INFO("Updating display");
driver->update(imageBuffer, updateType);
#if INKHUD_BOOKREADER_GRAYSCALE && INKHUD_BOOKREADER_PERF_LOG
static uint32_t frameId = 0;
LOG_INFO("[BookReaderDraw] submit frame=%lu type=%u gray=%u grayPixels=%lu "
"reader=%u renderAll=%u locked=%u inverted=%u",
static_cast<unsigned long>(++frameId), static_cast<unsigned>(updateType), useGrayscale,
static_cast<unsigned long>(grayPixelCount), readerVisible, renderAll, lockRendering,
config.display.displaymode == meshtastic_Config_DisplayConfig_DisplayMode_INVERTED);
#endif
#if defined(MESHTASTIC_INCLUDE_INKHUD_BOOKREADER) && INKHUD_BOOKREADER_PERF_LOG
if (keepAwakeUntilUpdateComplete) {
readerRefreshStartMs = millis();
LOG_INFO("[BookReaderPerf] refresh BEGIN type=%u async=%u", static_cast<unsigned>(updateType),
static_cast<unsigned>(async));
}
#endif
#if INKHUD_BOOKREADER_GRAYSCALE
if (useGrayscale)
driver->updateGrayscale(grayBuffer);
else
#endif
driver->update(imageBuffer, updateType);
#if INKHUD_BOOKREADER_GRAYSCALE
// Retained overlay renders must start from the original, uninverted pixels.
if (config.display.displaymode == meshtastic_Config_DisplayConfig_DisplayMode_INVERTED) {
driver->await();
for (size_t i = 0; i < imageBufferWidth * imageBufferHeight; ++i)
imageBuffer[i] = ~imageBuffer[i];
if (useGrayscale)
for (size_t i = 0; i < ((driver->width + 1U) / 2U) * driver->height; ++i)
grayBuffer[i] = ~grayBuffer[i];
}
#endif
// If not async, wait here until the update is complete
if (!async)
driver->await();
#ifdef MESHTASTIC_INCLUDE_INKHUD_BOOKREADER
maintainRenderActivity();
if (keepAwakeUntilUpdateComplete) {
OSThread::enabled = true;
OSThread::setIntervalFromNow(250UL);
}
#endif
}
// Our part is done now.
// If update is async, the display hardware is still performing the update process,
// but that's all handled by NicheGraphics::Drivers::EInk
// If update is async, the display hardware is still performing the update
// process, but that's all handled by NicheGraphics::Drivers::EInk
// Tidy up, ready for a new request
#ifndef MESHTASTIC_INCLUDE_INKHUD_BOOKREADER
requested = false;
#endif
forced = false;
renderAll = false;
}
// Manually fill the image buffer with WHITE
// Clears any old drawing
// Note: benchmarking revealed that this is *much* faster than setting pixels individually
// So much so that it's more efficient to re-render all applets,
// rather than rendering selectively, and manually blanking a portion of the display
// Note: benchmarking revealed that this is *much* faster than setting pixels
// individually So much so that it's more efficient to re-render all applets,
// rather than rendering selectively, and manually blanking a portion of the
// display
void InkHUD::Renderer::clearBuffer()
{
#if INKHUD_BOOKREADER_GRAYSCALE && INKHUD_BOOKREADER_PERF_LOG
LOG_INFO("[BookReaderDraw] clear framebuffer gray=%u", grayBuffer != nullptr);
#endif
memset(imageBuffer, 0xFF, imageBufferHeight * imageBufferWidth);
#if INKHUD_BOOKREADER_GRAYSCALE
if (grayBuffer)
memset(grayBuffer, 0xFF, ((driver->width + 1U) / 2U) * driver->height);
grayPixelCount = 0;
monoBufferNeedsSync = false;
#endif
}
// Manually clear the pixels below a tile
@@ -309,6 +817,39 @@ void InkHUD::Renderer::clearTile(Tile *t)
xEnd = driver->width;
if (yEnd > driver->height)
yEnd = driver->height;
#if INKHUD_BOOKREADER_GRAYSCALE && INKHUD_BOOKREADER_PERF_LOG
LOG_INFO("[BookReaderDraw] clear tile xy=%u,%u-%u,%u gray=%u", xStart, yStart, xEnd, yEnd, grayBuffer != nullptr);
#endif
#if INKHUD_BOOKREADER_GRAYSCALE
if (grayBuffer) {
if (xStart == 0 && yStart == 0 && xEnd == driver->width && yEnd == driver->height) {
memset(grayBuffer, 0xFF, ((driver->width + 1U) / 2U) * driver->height);
grayPixelCount = 0;
} else {
const size_t stride = (driver->width + 1U) / 2U;
const size_t firstByte = (xStart + 1U) / 2U;
const size_t endByte = xEnd / 2U;
uint32_t clearedGrayPixels = 0;
for (uint16_t y = yStart; y < yEnd; ++y) {
if ((xStart & 1U) && xEnd > xStart)
setGrayPixel(xStart, y, 15U);
if ((xEnd & 1U) && xEnd > xStart)
setGrayPixel(xEnd - 1U, y, 15U);
uint8_t *row = grayBuffer + static_cast<size_t>(y) * stride;
for (size_t x = firstByte; x < endByte; ++x) {
const uint8_t pair = row[x];
const uint8_t high = pair >> 4U;
const uint8_t low = pair & 15U;
clearedGrayPixels += high != 0U && high != 15U;
clearedGrayPixels += low != 0U && low != 15U;
}
if (endByte > firstByte)
memset(row + firstByte, 0xFF, endByte - firstByte);
}
grayPixelCount -= clearedGrayPixels;
}
}
#endif
// Clear the pixels
if (xStart == 0 && xEnd == driver->width) { // full width box is easier to clear
@@ -385,9 +926,10 @@ bool InkHUD::Renderer::shouldUpdate()
return should;
}
// Determine which type of E-Ink update the display will perform, to change the image.
// Considers the needs of the various applets, then weighs against display health.
// An update type specified by forceUpdate will be granted with no further questioning.
// Determine which type of E-Ink update the display will perform, to change the
// image. Considers the needs of the various applets, then weighs against
// display health. An update type specified by forceUpdate will be granted with
// no further questioning.
Drivers::EInk::UpdateTypes InkHUD::Renderer::decideUpdateType()
{
// Ask applets which update type they would prefer
@@ -411,10 +953,12 @@ Drivers::EInk::UpdateTypes InkHUD::Renderer::decideUpdateType()
}
// Run the drawing operations of any user applets which are currently displayed
// Pixel output is placed into the framebuffer, ready for handoff to the EInk driver
// Pixel output is placed into the framebuffer, ready for handoff to the EInk
// driver
void InkHUD::Renderer::renderUserApplets()
{
// Don't render user applets if a system applet has demanded the whole display to itself
// Don't render user applets if a system applet has demanded the whole display
// to itself
if (lockRendering)
return;
@@ -429,6 +973,12 @@ void InkHUD::Renderer::renderUserApplets()
uint32_t start = millis();
bool full = ua->wantsFullRender() || renderAll;
#ifdef MESHTASTIC_INCLUDE_INKHUD_BOOKREADER
if (ua->keepScreenAwakeDuringRender()) {
keepAwakeUntilUpdateComplete = true;
powerFSM.trigger(EVENT_INPUT);
}
#endif
ua->render(full); // Draw!
uint32_t stop = millis();
LOG_DEBUG("%s took %dms to render", ua->name, stop - start);
@@ -436,8 +986,9 @@ void InkHUD::Renderer::renderUserApplets()
}
}
// Run the drawing operations of any system applets which are currently displayed
// Pixel output is placed into the framebuffer, ready for handoff to the EInk driver
// Run the drawing operations of any system applets which are currently
// displayed Pixel output is placed into the framebuffer, ready for handoff to
// the EInk driver
void InkHUD::Renderer::renderSystemApplets()
{
SystemApplet *battery = inkhud->getSystemApplet("BatteryIcon");
@@ -473,8 +1024,8 @@ void InkHUD::Renderer::renderSystemApplets()
// uint32_t start = millis();
bool full = sa->wantsFullRender() || renderAll;
sa->render(full); // Draw!
// uint32_t stop = millis();
// LOG_DEBUG("%s took %dms to render", sa->name, stop - start);
// uint32_t stop = millis();
// LOG_DEBUG("%s took %dms to render", sa->name, stop - start);
}
}
@@ -483,7 +1034,8 @@ void InkHUD::Renderer::renderSystemApplets()
// In this case, we will fill the empty space with diagonal lines.
void InkHUD::Renderer::renderPlaceholders()
{
// Don't fill empty space with placeholders if a system applet wants exclusive use of the display
// Don't fill empty space with placeholders if a system applet wants exclusive
// use of the display
if (lockRendering)
return;
@@ -509,4 +1061,4 @@ void InkHUD::Renderer::renderPlaceholders()
// LOG_DEBUG("Placeholders took %dms to render", stop - start);
}
#endif
#endif
+22
View File
@@ -47,6 +47,16 @@ class Renderer : protected concurrency::OSThread
// Receives pixel output from an applet (via a tile, which translates the coordinates)
void handlePixel(int16_t x, int16_t y, Color c);
#ifdef MESHTASTIC_INCLUDE_INKHUD_BOOKREADER
// Fast path for writing MSB-first 1-bpp bitmaps in the same coordinate space as handlePixel().
void writeBitmap(int16_t screenX, int16_t screenY, uint16_t w, uint16_t h, const uint8_t *bitmap, uint16_t bitmapStride,
uint16_t sourceX = 0, uint16_t sourceY = 0);
#if INKHUD_BOOKREADER_GRAYSCALE
void writeGrayBitmap(int16_t screenX, int16_t screenY, uint16_t w, uint16_t h, const uint8_t *bitmap, uint16_t bitmapStride,
uint16_t sourceX = 0, uint16_t sourceY = 0);
#endif
#endif
// Size of display, in context of current rotation
uint16_t width();
@@ -61,6 +71,9 @@ class Renderer : protected concurrency::OSThread
// Execute the render process now, then hand off to driver for display update
void render(bool async = true);
#ifdef MESHTASTIC_INCLUDE_INKHUD_BOOKREADER
void maintainRenderActivity();
#endif
// Steps of the rendering process
@@ -80,6 +93,12 @@ class Renderer : protected concurrency::OSThread
uint16_t imageBufferHeight = 0;
uint16_t imageBufferWidth = 0;
uint32_t imageBufferSize = 0; // Bytes
#if INKHUD_BOOKREADER_GRAYSCALE
uint8_t *grayBuffer = nullptr;
uint32_t grayPixelCount = 0;
bool monoBufferNeedsSync = false;
void setGrayPixel(uint16_t x, uint16_t y, uint8_t luminance);
#endif
SystemApplet *lockRendering = nullptr; // Render this applet *only*
SystemApplet *lockRequests = nullptr; // Honor update requests from this applet *only*
@@ -87,6 +106,9 @@ class Renderer : protected concurrency::OSThread
bool requested = false;
bool forced = false;
bool renderAll = false;
#ifdef MESHTASTIC_INCLUDE_INKHUD_BOOKREADER
bool keepAwakeUntilUpdateComplete = false;
#endif
// For convenience
InkHUD *inkhud = nullptr;
+96
View File
@@ -20,6 +20,7 @@ A haphazard collection of notes which _might_ be helpful for developers.
- [NicheGraphics Framework](#nichegraphics-framework)
- [NicheGraphics E-Ink Drivers](#nichegraphics-e-ink-drivers)
- [InkHUD Applets](#inkhud-applets)
- [Book Reader Applet](#book-reader-applet)
- [Adding a Variant](#adding-a-variant)
- [platformio.ini](#platformioini)
- [nicheGraphics.h](#nichegraphicsh)
@@ -250,6 +251,101 @@ In a variant's `nicheGraphics.h`:
You will need to add these lines to any variants which will use your applet.
#### Book Reader Applet
`BookReaderApplet` provides an SD-card library, EPUB reader, contents picker, image viewer, and reader menu. Its implementation is in `src/graphics/niche/InkHUD/Applets/User/BookReader`.
##### Books and Layout
Place normal EPUB files in `/books` on the SD card. Subfolders are browsable. The library accepts `.epub`, `.kepub`, and `.epu` filenames containing EPUB archives; it does not open generic ZIP files or treat extracted EPUB folders as books. Standalone text, HTML, RTF, PDF, and comic archive files are not library entries.
The library lists folders before books and sorts names case-insensitively. Book rows wrap long titles and show the format and file size.
The reader uses the EPUB package's spine for reading order and its NCX or navigation document for contents entries. Contents links can target anchors within a chapter. Text is reflowed for the current viewport rather than preserving the publisher's page boundaries.
Supported content includes headings, paragraphs, bold and italic text, lists, basic tables, and JPEG/PNG images. A subset of inline and linked CSS controls alignment, indentation, spacing, and visibility. This is not a browser layout engine; arbitrary CSS, scripting, embedded fonts, and encrypted books are not supported.
Mixed text/image pages keep images together when paginating. An image that cannot fit in the remaining space moves to the following page; large images are fitted to the available image area. Image-only EPUB parts use a fitted image view with page and zoom controls. Consecutive images in an image-only part can form a combined image sequence.
##### Touch Controls
Touch-friendly layout is selected when the variant registers a touch-enabled provider with InkHUD.
| View | Control | Action |
| ---- | ------- | ------ |
| Library | Tap a book or folder | Open the entry |
| Library | Back | Go to the parent folder |
| Library / contents | Swipe up or down | Move through the list by a screenful |
| Contents | Tap an entry | Open its chapter or anchor |
| Contents | Back | Return to the reader |
| Reader | Menu | Open the reader menu |
| Reader | Contents | Open the book's contents picker, when available |
| Reader at 100% | Tap the left / right third | Previous / next page; tapping an inline image opens that image instead |
| Reader at 100% | Swipe left or up | Next page |
| Reader at 100% | Swipe right or down | Previous page |
| Mixed page | Tap an inline image | Expand it into the image viewer |
| Expanded image | Back | Close the image viewer and return to the text page |
| Image toolbar | `<` / `>` | Previous / next page |
| Image toolbar | `-` / `+` | Step down / up through zoom levels |
| Image toolbar | Percentage | Reset zoom to 100% and recenter |
| Image above 100% | Swipe | Pan the image; use toolbar arrows to change pages |
| Reader menu | Tap an item | Perform the selected action |
| Reader menu | Swipe | Move the selected menu item |
Image zoom levels are **100%, 120%, 150%, 175%, 260%, and 360%**. Here, 100% means fit-to-view, not one source pixel per display pixel. Panning is clamped to the image bounds. Pinch-to-zoom is not implemented.
The reader consumes touch events, including border taps, so they do not fall through to physical-button applet switching. Hardware button mappings remain variant-specific. On the T5 S3 Pro, the side key's short press toggles touch input, and holding it toggles the backlight; these are board controls, not reader gestures.
For non-touch layouts, navigation up/down moves the library or contents selection and turns reader pages. Right opens the selected entry; left returns to the parent folder or closes contents. In the reader, left/right turn pages. A short or long user-button press opens the selected library/contents entry or advances the reader page. In the reader menu, a short press cycles the selection; right or long press activates it, and left closes the menu.
##### Menu and Persistence
The reader menu provides Resume, Library, Contents, Bookmark this page, and Exit to applets. Go to bookmark and Remove bookmark appear when a bookmark exists. There is one explicit bookmark per book, separate from the saved reading position.
Reading positions and bookmarks are saved through InkHUD persistence using a per-book key. Reopening a book restores its saved part and wrapped-line position. The page-count cache is separate and is validated against the book and layout. Total page count can display `?` while background pagination is incomplete. Saved locations use wrapped lines, so changing the layout can affect the restored passage.
##### Variant Setup
The variant must provide a working SD filesystem and include both `MESHTASTIC_INCLUDE_INKHUD` and `MESHTASTIC_INCLUDE_INKHUD_BOOKREADER`. The reader's source and header are guarded by these feature definitions; an additional reader-specific source-filter exclusion/reinclusion is unnecessary.
Include and register the applet in the variant's `nicheGraphics.h`:
```cpp
#ifdef MESHTASTIC_INCLUDE_INKHUD_BOOKREADER
#include "graphics/niche/InkHUD/Applets/User/BookReader/BookReaderApplet.h"
#endif
// Inside setupNicheGraphics(), after creating the InkHUD instance:
#ifdef MESHTASTIC_INCLUDE_INKHUD_BOOKREADER
inkhud->addApplet("Book Reader", new InkHUD::BookReaderApplet, true, false);
#endif
```
| Build definition | Purpose |
| ---------------- | ------- |
| `INKHUD_BOOKREADER_ENABLE_INLINE_IMAGES=1` | Enable JPEG/PNG image rendering; provide the required decoder dependencies |
| `INKHUD_BOOKREADER_USE_JPEGDEC=1` | Use JPEGDEC instead of the ESP32 ROM JPEG decoder |
| `INKHUD_BOOKREADER_GRAYSCALE=1` | Enable the grayscale image path; requires compatible renderer and display-driver support |
| `INKHUD_BOOKREADER_PERF_LOG=1` | Enable detailed reader loading, parsing, caching, and rendering logs |
`variants/esp32s3/t5s3_epaper/platformio.ini` contains the current image-enabled configuration and JPEGDEC/PNGdec dependencies. Grayscale support is a driver capability, not a guarantee that every InkHUD display supports the same shades or refresh behavior.
Resource limits have overridable defaults in `BookReaderApplet.cpp`. Set overrides in the relevant variant, not as universal device limits. The `INKHUD_BOOKREADER_MAX_*` definitions bound text, metadata, archive directories, and individual archive/image entries; `INKHUD_BOOKREADER_CSS_MAX_*` bounds stylesheet work; `INKHUD_BOOKREADER_PREFETCH_MAX_*` bounds speculative text loading. Image sizing, JPEG scaling, and initial partial chapter reads have their own overrides. Oversized or unsupported content may be truncated or unavailable.
##### Loading and Implementation
Opening a book or an uncached chapter/page uses a centered book icon and loading label. Chapter reads, wrapping, pagination, and image prefetch are deferred where possible so the reader can show content before preparing the entire book. A cached transition need not show a loading indicator. E-paper refresh time is separate from CPU rendering time and depends on the display driver.
| Component | Responsibility |
| --------- | -------------- |
| `BookReaderApplet.cpp` / `.h` | Library, EPUB access, parsing, views, input, persistence, and background work |
| `ReaderText.h` | Shared text markers, quote-aware tag scanning, and XML attribute decoding |
| `ReaderPagination.h` | Page boundaries and page-count helpers |
| `ReaderControls.h` | Shared image-toolbar dimensions and hit testing |
| `SourceBitmap.h` | Image rasterization, scaling, and grayscale/monochrome bitmap helpers |
Decoded-source and rendered-image caches reduce repeat decoding during page changes, zooming, and panning. Background prefetch protects sources referenced by the current page and yields to interactive/display work. Cache residency and resource limits affect timing; compare the same pages and distinguish first-load from cached operations when profiling.
#### Applet Bases
If you need to create several similar applets, it might make sense to create a reusable base class. Several of these already exist in `src/graphics/niche/InkHUD/Applets/Bases`, but use these with caution, as they may be modified in future.
+42 -2
View File
@@ -1,6 +1,7 @@
#include "TouchScreenBase.h"
#include "main.h"
#include "mesh/Throttle.h"
#include <algorithm>
#if defined(RAK14014) && !defined(MESHTASTIC_EXCLUDE_CANNEDMESSAGES)
#include "modules/CannedMessageModule.h"
@@ -84,9 +85,21 @@ int32_t TouchScreenBase::runOnce()
this->setInterval(TOUCH_POLL_INTERVAL_IDLE);
const bool fastTapMode = fastTapModeEnabled();
const bool allowLongPress = longPressEnabled();
#ifdef MESHTASTIC_INCLUDE_INKHUD_BOOKREADER
const bool swipeFriendlyMode = swipeFriendlyModeEnabled();
const uint16_t thresholdX = swipeFriendlyMode ? std::min<uint16_t>(20, TOUCH_THRESHOLD_X) : TOUCH_THRESHOLD_X;
const uint16_t thresholdY = swipeFriendlyMode ? std::min<uint16_t>(20, TOUCH_THRESHOLD_Y) : TOUCH_THRESHOLD_Y;
#else
constexpr bool swipeFriendlyMode = false;
constexpr uint16_t thresholdX = TOUCH_THRESHOLD_X, thresholdY = TOUCH_THRESHOLD_Y;
#endif
// process touch events
#ifdef MESHTASTIC_INCLUDE_INKHUD_BOOKREADER
int16_t x = _last_x, y = _last_y;
#else
int16_t x, y;
#endif
bool touched = getTouch(x, y);
if (x < 0 || y < 0) // T-deck can emit phantom touch events with a negative value when turning off the screen
touched = false;
@@ -95,6 +108,16 @@ int32_t TouchScreenBase::runOnce()
this->setInterval(fastTapMode ? TOUCH_POLL_INTERVAL_ACTIVE_FAST : TOUCH_POLL_INTERVAL_ACTIVE);
_last_x = x;
_last_y = y;
#ifdef MESHTASTIC_INCLUDE_INKHUD_BOOKREADER
if (_touchedOld && swipeFriendlyMode) {
const int32_t dx = x - _first_x, dy = y - _first_y;
const int32_t peakDx = _peak_x - _first_x, peakDy = _peak_y - _first_y;
if (dx * dx + dy * dy > peakDx * peakDx + peakDy * peakDy) {
_peak_x = x;
_peak_y = y;
}
}
#endif
} else if (_touchedOld && ((uint32_t)millis() - _lastTouchSeenMs) < TOUCH_RELEASE_GRACE_MS) {
// Treat brief no-touch samples as continuous touch to preserve long-press detection.
touched = true;
@@ -107,6 +130,10 @@ int32_t TouchScreenBase::runOnce()
_longPressSuppressed = false;
_first_x = x;
_first_y = y;
#ifdef MESHTASTIC_INCLUDE_INKHUD_BOOKREADER
_peak_x = x;
_peak_y = y;
#endif
} else {
_state = TOUCH_EVENT_CLEARED;
uint32_t duration = nowMs - _pressStartMs;
@@ -117,11 +144,17 @@ int32_t TouchScreenBase::runOnce()
// compute distance
int16_t dx = x - _first_x;
int16_t dy = y - _first_y;
#ifdef MESHTASTIC_INCLUDE_INKHUD_BOOKREADER
if (swipeFriendlyMode) {
dx = _peak_x - _first_x;
dy = _peak_y - _first_y;
}
#endif
uint16_t adx = abs(dx);
uint16_t ady = abs(dy);
// swipe horizontal
if (adx > ady && adx > TOUCH_THRESHOLD_X) {
if (adx > ady && adx > thresholdX) {
if (0 > dx) { // swipe right to left
e.touchEvent = static_cast<char>(TOUCH_ACTION_LEFT);
LOG_DEBUG("action SWIPE: right to left");
@@ -131,7 +164,7 @@ int32_t TouchScreenBase::runOnce()
}
}
// swipe vertical
else if (ady > adx && ady > TOUCH_THRESHOLD_Y) {
else if ((ady > adx || (swipeFriendlyMode && ady == adx)) && ady > thresholdY) {
if (0 > dy) { // swipe bottom to top
e.touchEvent = static_cast<char>(TOUCH_ACTION_UP);
LOG_DEBUG("action SWIPE: bottom to top");
@@ -221,3 +254,10 @@ bool TouchScreenBase::longPressEnabled() const
{
return true;
}
#ifdef MESHTASTIC_INCLUDE_INKHUD_BOOKREADER
bool TouchScreenBase::swipeFriendlyModeEnabled() const
{
return false;
}
#endif
+10 -1
View File
@@ -17,6 +17,9 @@ class TouchScreenBase : public Observable<const InputEvent *>, public concurrenc
public:
explicit TouchScreenBase(const char *name, uint16_t width, uint16_t height);
void init(bool hasTouch);
#ifdef MESHTASTIC_INCLUDE_INKHUD_BOOKREADER
bool isTouchActive() const { return _touchedOld; }
#endif
protected:
enum TouchScreenBaseStateType { TOUCH_EVENT_OCCURRED, TOUCH_EVENT_CLEARED };
@@ -37,6 +40,9 @@ class TouchScreenBase : public Observable<const InputEvent *>, public concurrenc
virtual void onEvent(const TouchEvent &event) = 0;
virtual bool fastTapModeEnabled() const;
virtual bool longPressEnabled() const;
#ifdef MESHTASTIC_INCLUDE_INKHUD_BOOKREADER
virtual bool swipeFriendlyModeEnabled() const;
#endif
volatile TouchScreenBaseStateType _state = TOUCH_EVENT_CLEARED;
volatile TouchScreenBaseEventType _action = TOUCH_ACTION_NONE;
@@ -50,7 +56,10 @@ class TouchScreenBase : public Observable<const InputEvent *>, public concurrenc
bool _touchedOld = false; // previous touch state
int16_t _first_x, _last_x; // horizontal swipe direction
int16_t _first_y, _last_y; // vertical swipe direction
uint32_t _pressStartMs; // when the current touch began; read via Throttle::hasElapsed()
#ifdef MESHTASTIC_INCLUDE_INKHUD_BOOKREADER
int16_t _peak_x = 0, _peak_y = 0;
#endif
uint32_t _pressStartMs; // when the current touch began; read via Throttle::hasElapsed()
// LONG_PRESS repeat suppression while one touch is held: the bool is the armed flag, the
// deadline is read only while it is set. No value of the deadline can mean "unarmed".
+21
View File
@@ -129,9 +129,30 @@ bool TouchScreenImpl1::fastTapModeEnabled() const
bool TouchScreenImpl1::longPressEnabled() const
{
#ifdef MESHTASTIC_INCLUDE_INKHUD_BOOKREADER
return !fastTapModeEnabled() && !swipeFriendlyModeEnabled();
#else
return !fastTapModeEnabled();
#endif
}
#ifdef MESHTASTIC_INCLUDE_INKHUD_BOOKREADER
bool TouchScreenImpl1::swipeFriendlyModeEnabled() const
{
#ifdef MESHTASTIC_INCLUDE_INKHUD
auto *inkhud = NicheGraphics::InkHUD::InkHUD::getInstance();
if (inkhud) {
for (auto *sa : inkhud->systemApplets)
if (sa && sa->handleInput)
return false;
auto *applet = inkhud->getActiveApplet();
return applet && applet->isForeground() && applet->name && strcmp(applet->name, "Book Reader") == 0;
}
#endif
return false;
}
#endif
/**
* @brief forward touchscreen event
*
+3
View File
@@ -12,6 +12,9 @@ class TouchScreenImpl1 : public TouchScreenBase
virtual void onEvent(const TouchEvent &event);
bool fastTapModeEnabled() const override;
bool longPressEnabled() const override;
#ifdef MESHTASTIC_INCLUDE_INKHUD_BOOKREADER
bool swipeFriendlyModeEnabled() const override;
#endif
// Attach/detach a hardware interrupt on the touch IRQ pin (SCREEN_TOUCH_INT) so a new touch
// wakes the polling thread immediately. No-op on boards without a usable touch interrupt line.
@@ -13,9 +13,11 @@
#include "mesh/Throttle.h"
#include "sleep.h"
#include "touch/TouchDrvGT911.hpp"
#include <algorithm>
#include <cstring>
#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS
#include "graphics/niche/InkHUD/Applet.h"
#include "graphics/niche/InkHUD/InkHUD.h"
#include "graphics/niche/InkHUD/Persistence.h"
#include "graphics/niche/InkHUD/SystemApplet.h"
@@ -23,17 +25,20 @@
// Bridges touch events from TouchScreenImpl1 directly into InkHUD,
// bypassing the InputBroker (which is excluded in InkHUD builds).
// Routing mirrors the mini-epaper-s3 two-way rocker pattern:
// - Nav left/right: prevApplet/nextApplet when idle, navUp/Down when a system applet has focus (e.g. menu)
// - Nav left/right: prevApplet/nextApplet when idle, navUp/Down when a system
// applet has focus (e.g. menu)
// - Nav up/down: navUp/navDown always (menu scroll)
// - Tap/long-press: direct touch point dispatch (with fallback to short/long button semantics)
// - Tap/long-press: direct touch point dispatch (with fallback to short/long
// button semantics)
class TouchInkHUDBridge : public Observer<const InputEvent *>
{
int onNotify(const InputEvent *e) override
{
auto *inkhud = NicheGraphics::InkHUD::InkHUD::getInstance();
// Keep alignment in sync with the current rotation so that visual-frame gestures
// always pass through nav functions without remapping: (rotation + alignment) % 4 == 0.
// Keep alignment in sync with the current rotation so that visual-frame
// gestures always pass through nav functions without remapping: (rotation +
// alignment) % 4 == 0.
inkhud->persistence->settings.joystick.alignment = (4 - inkhud->persistence->settings.rotation) % 4;
// Check whether a system applet (e.g. menu) is currently handling input
@@ -45,6 +50,17 @@ class TouchInkHUDBridge : public Observer<const InputEvent *>
}
}
#ifdef MESHTASTIC_INCLUDE_INKHUD_BOOKREADER
if (!systemHandlingInput) {
auto *applet = inkhud->getActiveApplet();
const int8_t dx = (e->inputEvent == INPUT_BROKER_RIGHT) - (e->inputEvent == INPUT_BROKER_LEFT);
const int8_t dy = (e->inputEvent == INPUT_BROKER_DOWN) - (e->inputEvent == INPUT_BROKER_UP);
if (applet && (dx || dy) && applet->onTouchSwipe(dx, dy)) {
return 0;
}
}
#endif
switch (e->inputEvent) {
case INPUT_BROKER_USER_PRESS:
inkhud->touchTap(e->touchX, e->touchY);
@@ -102,8 +118,9 @@ volatile bool touchControllerReady = false;
volatile bool touchLightSleepActive = false;
volatile bool touchNeedsWake = false;
volatile bool touchIndicatorRefreshPending = false;
// When the light-sleep resume happened, not when the block expires: an interval bounds a missed
// 0-check by the settle time, where a stored deadline would block for up to half a wrap cycle.
// When the light-sleep resume happened, not when the block expires: an interval
// bounds a missed 0-check by the settle time, where a stored deadline would
// block for up to half a wrap cycle.
constexpr uint32_t TOUCH_RESUME_BLOCK_MS = 150;
volatile uint32_t touchResumeAtMs = 0;
volatile uint32_t touchStateEpoch = 1;
@@ -116,7 +133,8 @@ void showTouchIndicator(const char *text)
{
#if HAS_SCREEN
#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS
// InkHUD builds render a dedicated bottom-edge "TOUCH OFF" overlay instead of popup banners.
// InkHUD builds render a dedicated bottom-edge "TOUCH OFF" overlay instead of
// popup banners.
(void)text;
return;
#else
@@ -154,23 +172,11 @@ bool readPca9535Port1(uint8_t *value)
return true;
}
bool isPca9535SideKeyPressed()
{
uint8_t port1 = 0xFF;
if (!readPca9535Port1(&port1)) {
return false;
}
return (port1 & BOARD_PCA9535_BUTTON_MASK) == 0;
}
class SideKeyInterruptThread : public concurrency::OSThread
{
public:
SideKeyInterruptThread() : concurrency::OSThread("t5s3SideKeyInt", SAMPLE_MS)
SideKeyInterruptThread() : concurrency::OSThread("t5s3SideKeyInt", IDLE_POLL_MS)
{
// Do not run unless an edge arrives.
OSThread::disable();
instance = this;
#ifdef ARCH_ESP32
lsObserver.observe(&notifyLightSleep);
@@ -187,47 +193,57 @@ class SideKeyInterruptThread : public concurrency::OSThread
protected:
int32_t runOnce() override
{
const uint32_t now = millis();
const uint32_t now = Time::getMillis();
// 0 means the device has never light-slept, so no block is armed - test it first.
// 0 means the device has never light-slept, so no block is armed - test it
// first.
if (touchResumeAtMs != 0 && Throttle::isWithinTimespanMs(touchResumeAtMs, TOUCH_RESUME_BLOCK_MS)) {
resetStateAndStop();
return OSThread::disable();
resetState();
return IDLE_POLL_MS;
}
if (touchLightSleepActive) {
resetStateAndStop();
return OSThread::disable();
resetState();
return IDLE_POLL_MS;
}
// Ignore side-key handling while BOOT/user button is held.
if (digitalRead(BUTTON_PIN) == LOW) {
resetStateAndStop();
return OSThread::disable();
resetState();
return IDLE_POLL_MS;
}
if (state == State::REST) {
// Recover a latched expander interrupt even if its edge was missed.
if (digitalRead(BOARD_PCA9535_INT) != LOW)
return IDLE_POLL_MS;
irqAtMs = now;
state = State::IRQ_PENDING;
}
if (state == State::IRQ_PENDING && !Throttle::hasElapsed(irqAtMs, DEBOUNCE_MS))
return SAMPLE_MS;
uint8_t port1 = 0xFF;
if (!readPca9535Port1(&port1))
return SAMPLE_MS;
const bool pressed = (port1 & BOARD_PCA9535_BUTTON_MASK) == 0;
switch (state) {
case State::IRQ_PENDING:
// Initial debounce after expander interrupt edge.
if ((uint32_t)(now - irqAtMs) < DEBOUNCE_MS) {
return SAMPLE_MS;
if (!pressed) {
resetState();
return IDLE_POLL_MS;
}
if (isPca9535SideKeyPressed()) {
state = State::PRESSED;
pressStartMs = now;
return SAMPLE_MS;
}
// Spurious/cleared edge.
resetStateAndStop();
return OSThread::disable();
state = State::PRESSED;
pressStartMs = irqAtMs;
[[fallthrough]];
case State::PRESSED: {
if (isPca9535SideKeyPressed()) {
// Fire long-press action as soon as threshold is reached, without waiting for release.
if (!longPressFired && (uint32_t)(now - pressStartMs) >= LONG_PRESS_MIN_MS &&
(uint32_t)(now - lastActionMs) >= ACTION_COOLDOWN_MS) {
if (pressed) {
// Fire long-press action as soon as threshold is reached, without
// waiting for release.
if (!longPressFired && Throttle::hasElapsed(pressStartMs, LONG_PRESS_MIN_MS) &&
Throttle::hasElapsed(lastActionMs, ACTION_COOLDOWN_MS)) {
t5BacklightToggleUser();
longPressFired = true;
lastActionMs = now;
@@ -235,10 +251,12 @@ class SideKeyInterruptThread : public concurrency::OSThread
return SAMPLE_MS;
}
// Released: if long-press already fired, do nothing. Otherwise classify short press.
// Released: if long-press already fired, do nothing. Otherwise classify
// short press.
const uint32_t heldMs = now - pressStartMs;
if (!longPressFired && heldMs >= SHORT_PRESS_MIN_MS && (uint32_t)(now - lastActionMs) >= ACTION_COOLDOWN_MS) {
// If timeout forced touch/backlight off, short-press acts as a wake action first.
if (!longPressFired && heldMs >= SHORT_PRESS_MIN_MS && Throttle::hasElapsed(lastActionMs, ACTION_COOLDOWN_MS)) {
// If timeout forced touch/backlight off, short-press acts as a wake
// action first.
if (t5TouchIsForcedByTimeout()) {
t5TouchHandleUserInput();
t5BacklightHandleUserInput();
@@ -248,13 +266,13 @@ class SideKeyInterruptThread : public concurrency::OSThread
lastActionMs = now;
}
resetStateAndStop();
return OSThread::disable();
resetState();
return IDLE_POLL_MS;
}
case State::REST:
default:
return OSThread::disable();
return IDLE_POLL_MS;
}
}
@@ -266,6 +284,7 @@ class SideKeyInterruptThread : public concurrency::OSThread
};
static constexpr uint32_t SAMPLE_MS = 15;
static constexpr uint32_t IDLE_POLL_MS = 100;
static constexpr uint32_t DEBOUNCE_MS = 25;
static constexpr uint32_t SHORT_PRESS_MIN_MS = 30;
static constexpr uint32_t LONG_PRESS_MIN_MS = 450;
@@ -295,25 +314,16 @@ class SideKeyInterruptThread : public concurrency::OSThread
state = State::IRQ_PENDING;
irqAtMs = millis();
startThread();
OSThread::setIntervalFromNow(0);
runASAP = true;
BaseType_t higherWake = 0;
concurrency::mainDelay.interruptFromISR(&higherWake);
}
void startThread()
{
if (!OSThread::enabled) {
OSThread::setIntervalFromNow(0);
OSThread::enabled = true;
runASAP = true;
}
}
void resetStateAndStop()
void resetState()
{
state = State::REST;
longPressFired = false;
if (OSThread::enabled) {
OSThread::disable();
}
}
#ifdef ARCH_ESP32
@@ -324,7 +334,7 @@ class SideKeyInterruptThread : public concurrency::OSThread
// If INT is left asserted low, light sleep exits immediately.
uint8_t ignored = 0xFF;
(void)readPca9535Port1(&ignored);
resetStateAndStop();
resetState();
return 0;
}
@@ -375,7 +385,8 @@ void refreshTouchIndicatorInInkHUD(bool async = true)
touchStatus->bringToForeground();
}
// Re-render all applets so touch-status visibility changes are immediately reflected.
// Re-render all applets so touch-status visibility changes are immediately
// reflected.
inkhud->forceUpdate(NicheGraphics::Drivers::EInk::UpdateTypes::FAST, true, async);
}
#endif
@@ -434,7 +445,8 @@ void t5TouchSetForcedByTimeout(bool forced)
if (forced) {
// Timeout only gates touch input in software. Avoid GT911 I2C here because
// PowerFSM same-state transitions can fire from phone contact and screen timeout.
// PowerFSM same-state transitions can fire from phone contact and screen
// timeout.
touchNeedsWake = false;
} else if (touchInputEnabled && touchControllerReady && !touchLightSleepActive) {
touchNeedsWake = false;
@@ -512,7 +524,8 @@ void toggleTouchInputEnabled()
}
// Commands the GT911 into standby before the Wire bus is torn down.
// notifyDeepSleep fires before Wire.end() in doDeepSleep(), so I2C is still available here.
// notifyDeepSleep fires before Wire.end() in doDeepSleep(), so I2C is still
// available here.
struct TouchDeepSleepObserver {
int onDeepSleep(void *)
{
@@ -528,7 +541,8 @@ struct TouchLightSleepObserver {
{
touchLightSleepActive = true;
#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS
// Render touch-off overlay before sleeping so user sees touch is unavailable.
// Render touch-off overlay before sleeping so user sees touch is
// unavailable.
touchIndicatorRefreshPending = true;
refreshTouchIndicatorInInkHUD(false);
touchIndicatorRefreshPending = false;
@@ -585,7 +599,8 @@ bool readTouch(int16_t *x, int16_t *y)
suppressFromMs = 0;
}
// Let buses and peripherals settle briefly after light-sleep wake. 0 means no wake yet.
// Let buses and peripherals settle briefly after light-sleep wake. 0 means no
// wake yet.
if (touchResumeAtMs != 0 && Throttle::isWithinTimespanMs(touchResumeAtMs, TOUCH_RESUME_BLOCK_MS)) {
return false;
}
@@ -607,21 +622,23 @@ bool readTouch(int16_t *x, int16_t *y)
return false;
}
// After a recovery pulse, emit a brief "released" window so gesture state can reset.
// After a recovery pulse, emit a brief "released" window so gesture state can
// reset.
if (suppressFromMs != 0 && Throttle::isWithinTimespanMs(suppressFromMs, TOUCH_WAKE_SUPPRESS_MS)) {
return false;
}
#endif
if (!digitalRead(GT911_PIN_INT)) {
// 0.4.x deprecates getPoint() in favour of getTouchPoints(); only the first touch is used here.
const TouchPoints &points = touch.getTouchPoints();
if (points.getPointCount()) {
const int16_t raw_x = static_cast<int16_t>(points.getPoint(0).x);
const int16_t raw_y = static_cast<int16_t>(points.getPoint(0).y);
#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS
// Transform raw GT911 axes to visual-frame coordinates for the current display rotation.
// rotation=3 is the physical identity (device's default orientation).
// Transform raw GT911 axes to visual-frame coordinates for the current
// display rotation. rotation=3 is the physical identity (device's default
// orientation).
switch (NicheGraphics::InkHUD::InkHUD::getInstance()->persistence->settings.rotation) {
default:
case 3:
@@ -0,0 +1,16 @@
#pragma once
#include "sdkconfig.h"
#if defined(MESHTASTIC_INCLUDE_INKHUD_BOOKREADER) && CONFIG_IDF_TARGET_ESP32S3
#define dsps_fft2r_fc32_aes3_enabled 1
#define dsps_fft2r_sc16_aes3_enabled 1
#endif
#ifndef dsps_fft2r_fc32_aes3_enabled
#define dsps_fft2r_fc32_aes3_enabled 0
#endif
#ifndef dsps_fft2r_sc16_aes3_enabled
#define dsps_fft2r_sc16_aes3_enabled 0
#endif
@@ -25,6 +25,9 @@ This is driven via the FastEPD library through the NicheGraphics ED047TC1 driver
// Applets
#include "graphics/niche/InkHUD/Applets/User/AllMessage/AllMessageApplet.h"
#ifdef MESHTASTIC_INCLUDE_INKHUD_BOOKREADER
#include "graphics/niche/InkHUD/Applets/User/BookReader/BookReaderApplet.h"
#endif
#include "graphics/niche/InkHUD/Applets/User/DM/DMApplet.h"
#include "graphics/niche/InkHUD/Applets/User/FavoritesMap/FavoritesMapApplet.h"
#include "graphics/niche/InkHUD/Applets/User/Heard/HeardApplet.h"
@@ -88,6 +91,9 @@ void setupNicheGraphics()
inkhud->addApplet("Recents List", new InkHUD::RecentsListApplet, true, false); // Activated, not autoshown
inkhud->addApplet("Heard", new InkHUD::HeardApplet, true, false, 0); // Activated, not autoshown, default on tile 0
inkhud->addApplet("Favorites Map", new InkHUD::FavoritesMapApplet, false, false); // Not Active, not autoshown
#ifdef MESHTASTIC_INCLUDE_INKHUD_BOOKREADER
inkhud->addApplet("Book Reader", new InkHUD::BookReaderApplet, true, false); // Activated, not autoshown
#endif
// Enable reusable InkHUD touch status indicator for this touch-capable board.
inkhud->setTouchEnabledProvider(isTouchInputEnabled);
@@ -55,7 +55,26 @@ custom_meshtastic_has_ink_hud = true
build_flags =
${t5s3_epaper_base.build_flags}
${inkhud.build_flags}
-D MESHTASTIC_INCLUDE_INKHUD_BOOKREADER
-D INKHUD_BOOKREADER_GRAYSCALE=1
-D SDCARD_USE_SPI1
-D INKHUD_BOOKREADER_ENABLE_INLINE_IMAGES=1
; Board-specific resource limits; other reader variants can set their own.
-D INKHUD_BOOKREADER_MAX_FILE_BYTES=196608U
-D INKHUD_BOOKREADER_MAX_HTML_FILE_BYTES=524288U
-D INKHUD_BOOKREADER_MAX_OPF_PARSE_BYTES=131072U
-D INKHUD_BOOKREADER_MAX_TOC_PARSE_BYTES=131072U
-D INKHUD_BOOKREADER_CSS_MAX_INLINE_TOTAL_BYTES=49152U
-D INKHUD_BOOKREADER_CSS_MAX_EXTERNAL_FILE_BYTES=32768U
-D INKHUD_BOOKREADER_CSS_MAX_EXTERNAL_TOTAL_BYTES=65536U
-D INKHUD_BOOKREADER_CSS_MAX_SOURCE_TEXT_BYTES=98304U
-D INKHUD_BOOKREADER_PREFETCH_MAX_FILE_BYTES=65536U
-D INKHUD_BOOKREADER_PREFETCH_MAX_TEXT_BYTES=98304U
-D INKHUD_BOOKREADER_MAX_EPUB_CENTRAL_DIR_BYTES=524288U
-D INKHUD_BOOKREADER_MAX_EPUB_UNCOMPRESSED_ENTRY_BYTES=2097152U
-D INKHUD_BOOKREADER_MAX_EPUB_INLINE_IMAGE_BYTES=2097152U
-D INKHUD_BOOKREADER_INITIAL_PARTIAL_BYTES=16384U
-D INKHUD_BOOKREADER_USE_JPEGDEC=1
-D T5_S3_EPAPER_PRO_V2
build_src_filter =
${t5s3_epaper_base.build_src_filter}
@@ -63,6 +82,9 @@ build_src_filter =
lib_deps =
${inkhud.lib_deps} ; InkHUD libs first, so we get GFXRoot instead of AdafruitGFX
${t5s3_epaper_base.lib_deps}
# EPUB inline image rendering for Book Reader (scoped to this env only)
https://github.com/bitbank2/JPEGDEC/archive/refs/tags/1.8.4.zip
https://github.com/bitbank2/PNGdec/archive/refs/tags/1.1.6.zip
[env:t5s3-epaper-v1] ; H752
+4
View File
@@ -80,6 +80,10 @@ void t5SetHomeCapButtonEventsEnabled(bool enabled);
#define BOARD_PCA9535_BUTTON_MASK 0x04
#endif
// SD card
#ifdef MESHTASTIC_INCLUDE_INKHUD_BOOKREADER
// The reader uses the radio's SPI instance so SD and LoRa share its lock.
#undef SDCARD_USE_SPI1
#endif
#define HAS_SDCARD
#define SDCARD_CS SPI_CS
#define SD_SPI_FREQUENCY 75000000U