Compare commits

...

8 Commits

Author SHA1 Message Date
Sam Davis
fe548ce9a5
Merge dc2a6ca2f8 into 7a5719b46d 2025-12-15 22:43:08 +11:00
Dave Allie
7a5719b46d
Upgrade open-x4-sdk to fix white streaks on sleep screen (#21)
https://github.com/open-x4-epaper/community-sdk/pull/6 fixes a power down issue with the display which was causing streaks to appear on the sleep screen
2025-12-15 22:27:27 +11:00
Sam Davis
dc2a6ca2f8 add support for 1, 8 and 32 bit bmps 2025-12-15 17:48:09 +11:00
Sam Davis
9f51a31677 Rename BmpToMono -> BmpReader 2025-12-15 16:25:12 +11:00
Sam Davis
d60378719c rotate fullscreen bmp CCW instead of CW 2025-12-14 20:17:33 +11:00
Sam Davis
602d3da3a2 Render sleep.bmp when sleeping if it exists 2025-12-14 17:07:14 +11:00
Sam Davis
e2951c85e2 add separate render path when sleep.bmp is found in root of sd card 2025-12-14 15:06:23 +11:00
Sam Davis
130242fde8 ignore .vscode directory 2025-12-14 15:06:23 +11:00
9 changed files with 338 additions and 1 deletions

1
.gitignore vendored
View File

@ -1,3 +1,4 @@
.pio
.idea
.DS_Store
.vscode

210
lib/BmpReader/BmpReader.cpp Normal file
View File

@ -0,0 +1,210 @@
#include "BmpReader.h"
#include <cstdlib>
#include <cstring>
uint16_t BmpReader::readLE16(File& f) {
const int c0 = f.read();
const int c1 = f.read();
const uint8_t b0 = (uint8_t)(c0 < 0 ? 0 : c0);
const uint8_t b1 = (uint8_t)(c1 < 0 ? 0 : c1);
return (uint16_t)b0 | ((uint16_t)b1 << 8);
}
uint32_t BmpReader::readLE32(File& f) {
const int c0 = f.read();
const int c1 = f.read();
const int c2 = f.read();
const int c3 = f.read();
const uint8_t b0 = (uint8_t)(c0 < 0 ? 0 : c0);
const uint8_t b1 = (uint8_t)(c1 < 0 ? 0 : c1);
const uint8_t b2 = (uint8_t)(c2 < 0 ? 0 : c2);
const uint8_t b3 = (uint8_t)(c3 < 0 ? 0 : c3);
return (uint32_t)b0 | ((uint32_t)b1 << 8) | ((uint32_t)b2 << 16) | ((uint32_t)b3 << 24);
}
void BmpReader::freeMonoBitmap(MonoBitmap& bmp) {
if (bmp.data) {
free(bmp.data);
bmp.data = nullptr;
}
bmp.width = 0;
bmp.height = 0;
bmp.len = 0;
}
const char* BmpReader::errorToString(BmpReaderError err) {
switch (err) {
case BmpReaderError::Ok:
return "Ok";
case BmpReaderError::FileInvalid:
return "FileInvalid";
case BmpReaderError::SeekStartFailed:
return "SeekStartFailed";
case BmpReaderError::NotBMP:
return "NotBMP (missing 'BM')";
case BmpReaderError::DIBTooSmall:
return "DIBTooSmall (<40 bytes)";
case BmpReaderError::BadPlanes:
return "BadPlanes (!= 1)";
case BmpReaderError::UnsupportedBpp:
return "UnsupportedBpp (expected 24, 32 or 1)";
case BmpReaderError::UnsupportedCompression:
return "UnsupportedCompression (expected BI_RGB or BI_BITFIELDS for 32bpp)";
case BmpReaderError::BadDimensions:
return "BadDimensions";
case BmpReaderError::SeekPixelDataFailed:
return "SeekPixelDataFailed";
case BmpReaderError::OomOutput:
return "OomOutput";
case BmpReaderError::OomRowBuffer:
return "OomRowBuffer";
case BmpReaderError::ShortReadRow:
return "ShortReadRow";
}
return "Unknown";
}
BmpReaderError BmpReader::read(File& file, MonoBitmap& out, uint8_t threshold) {
freeMonoBitmap(out);
if (!file) return BmpReaderError::FileInvalid;
if (!file.seek(0)) return BmpReaderError::SeekStartFailed;
// --- BMP FILE HEADER ---
const uint16_t bfType = readLE16(file);
if (bfType != 0x4D42) return BmpReaderError::NotBMP;
(void)readLE32(file);
(void)readLE16(file);
(void)readLE16(file);
const uint32_t bfOffBits = readLE32(file);
// --- DIB HEADER ---
const uint32_t biSize = readLE32(file);
if (biSize < 40) return BmpReaderError::DIBTooSmall;
const int32_t srcW = (int32_t)readLE32(file);
int32_t srcHRaw = (int32_t)readLE32(file);
const uint16_t planes = readLE16(file);
const uint16_t bpp = readLE16(file);
const uint32_t comp = readLE32(file);
const bool is24Bit = (bpp == 24);
const bool is32Bit = (bpp == 32);
const bool is8Bit = (bpp == 8);
const bool is1Bit = (bpp == 1);
if (planes != 1) return BmpReaderError::BadPlanes;
if (!is24Bit && !is32Bit && !is8Bit && !is1Bit) return BmpReaderError::UnsupportedBpp;
// Allow BI_RGB (0) for all, and BI_BITFIELDS (3) for 32bpp which is common for BGRA masks.
if (!(comp == 0 || (is32Bit && comp == 3))) return BmpReaderError::UnsupportedCompression;
(void)readLE32(file); // biSizeImage
(void)readLE32(file); // biXPelsPerMeter
(void)readLE32(file); // biYPelsPerMeter
const uint32_t clrUsed = readLE32(file);
(void)readLE32(file); // biClrImportant
if (srcW <= 0) return BmpReaderError::BadDimensions;
const bool topDown = (srcHRaw < 0);
const int32_t srcH = topDown ? -srcHRaw : srcHRaw;
if (srcH <= 0) return BmpReaderError::BadDimensions;
// Output dimensions after 90° CCW rotation
out.width = (int)srcH;
out.height = (int)srcW;
const size_t outBytesPerRow = (size_t)(out.width + 7) / 8;
out.len = outBytesPerRow * (size_t)out.height;
out.data = (uint8_t*)malloc(out.len);
if (!out.data) return BmpReaderError::OomOutput;
memset(out.data, 0xFF, out.len);
// Palette for 8-bit indexed images
uint8_t paletteLum[256];
if (is8Bit) {
for (int i = 0; i < 256; i++) paletteLum[i] = (uint8_t)i; // default grayscale ramp
uint32_t paletteCount = (clrUsed == 0) ? 256u : clrUsed;
if (paletteCount > 256u) paletteCount = 256u;
for (uint32_t i = 0; i < paletteCount; i++) {
const int b = file.read();
const int g = file.read();
const int r = file.read();
(void)file.read(); // reserved
const uint8_t bb = (uint8_t)(b < 0 ? 0 : b);
const uint8_t gg = (uint8_t)(g < 0 ? 0 : g);
const uint8_t rr = (uint8_t)(r < 0 ? 0 : r);
paletteLum[i] = (uint8_t)((77u * rr + 150u * gg + 29u * bb) >> 8);
}
}
// Source row stride (padded to 4 bytes)
uint32_t bytesPerPixel = 0u;
if (is8Bit) {
bytesPerPixel = 1u;
} else if (is32Bit) {
bytesPerPixel = 4u;
} else if (is24Bit) {
bytesPerPixel = 3u;
}
const uint32_t srcBytesPerRow =
is1Bit ? ((uint32_t)srcW + 7u) / 8u : (uint32_t)srcW * bytesPerPixel; // bpp==1 ignores bytesPerPixel
const uint32_t srcRowStride = (srcBytesPerRow + 3u) & ~3u;
if (!file.seek(bfOffBits)) {
freeMonoBitmap(out);
return BmpReaderError::SeekPixelDataFailed;
}
uint8_t* rowBuf = (uint8_t*)malloc(srcRowStride);
if (!rowBuf) {
freeMonoBitmap(out);
return BmpReaderError::OomRowBuffer;
}
for (int fileRow = 0; fileRow < (int)srcH; fileRow++) {
if (file.read(rowBuf, srcRowStride) != (int)srcRowStride) {
free(rowBuf);
freeMonoBitmap(out);
return BmpReaderError::ShortReadRow;
}
const int srcY = topDown ? fileRow : ((int)srcH - 1 - fileRow);
for (int srcX = 0; srcX < (int)srcW; srcX++) {
bool isBlack;
if (is1Bit) {
const uint8_t byte = rowBuf[srcX >> 3];
const uint8_t mask = (uint8_t)(0x80u >> (srcX & 7));
const bool bitSet = (byte & mask) != 0;
// In 1bpp BMPs, palette index 0 is conventionally black and index 1 is white.
isBlack = !bitSet;
} else if (is8Bit) {
const uint8_t idx = rowBuf[srcX];
const uint8_t lum = paletteLum[idx];
isBlack = (lum < threshold);
} else {
const uint8_t* px = &rowBuf[srcX * bytesPerPixel];
const uint8_t b = px[0];
const uint8_t g = px[1];
const uint8_t r = px[2];
const uint8_t lum = (uint8_t)((77u * r + 150u * g + 29u * b) >> 8);
isBlack = (lum < threshold);
}
// 90° counter-clockwise: (x,y) -> (y, w-1-x)
const int outX = srcY;
const int outY = (int)srcW - 1 - srcX;
setMonoPixel(out.data, out.width, outX, outY, isBlack);
}
}
free(rowBuf);
return BmpReaderError::Ok;
}

57
lib/BmpReader/BmpReader.h Normal file
View File

@ -0,0 +1,57 @@
#pragma once
#include <Arduino.h>
#include <FS.h>
struct MonoBitmap {
int width = 0;
int height = 0;
size_t len = 0; // bytesPerRow * height
uint8_t* data = nullptr; // row-aligned, MSB-first, 1=white 0=black
};
enum class BmpReaderError : uint8_t {
Ok = 0,
FileInvalid,
SeekStartFailed,
NotBMP,
DIBTooSmall,
BadPlanes,
UnsupportedBpp,
UnsupportedCompression,
BadDimensions,
SeekPixelDataFailed,
OomOutput,
OomRowBuffer,
ShortReadRow,
};
class BmpReader {
public:
// Rotate 90° counter-clockwise: (w,h) -> (h,w)
// Used for converting portrait BMP (480x800) into landscape framebuffer (800x480)
// Supports 8-bit, 24-bit, 32-bit color and 1-bit monochrome BMPs.
static BmpReaderError read(File& file, MonoBitmap& out, uint8_t threshold = 160);
static void freeMonoBitmap(MonoBitmap& bmp);
static const char* errorToString(BmpReaderError err);
private:
static uint16_t readLE16(File& f);
static uint32_t readLE32(File& f);
// Writes a single pixel into a row-aligned 1bpp buffer (MSB-first), 0=black, 1=white
static inline void setMonoPixel(uint8_t* buf, int w, int x, int y, bool isBlack) {
const size_t bytesPerRow = (size_t)(w + 7) / 8;
const size_t idx = (size_t)y * bytesPerRow + (size_t)(x >> 3);
const uint8_t mask = (uint8_t)(0x80u >> (x & 7));
if (isBlack)
buf[idx] &= (uint8_t)~mask;
else
buf[idx] |= mask;
}
};

View File

@ -2,6 +2,8 @@
#include <Utf8.h>
#include "BmpReader.h"
void GfxRenderer::insertFont(const int fontId, EpdFontFamily font) { fontMap.insert({fontId, font}); }
void GfxRenderer::drawPixel(const int x, const int y, const bool state) const {
@ -119,6 +121,37 @@ void GfxRenderer::drawImage(const uint8_t bitmap[], const int x, const int y, co
einkDisplay.drawImage(bitmap, y, x, height, width);
}
bool GfxRenderer::drawFullScreenBmp(File& file) {
if (!file) {
Serial.printf("[%lu] [GFX] drawFullScreenBmp: invalid file\n", millis());
return false;
}
file.seek(0); // Ensure we're at the start of the file
MonoBitmap bmp;
auto err = BmpReader::read(file, bmp);
if (err != BmpReaderError::Ok) {
Serial.printf("[%lu] [GFX] BMP convert failed: %s\n", millis(), BmpReader::errorToString(err));
return false;
}
// Hard requirement: must match panel exactly
if (bmp.width != EInkDisplay::DISPLAY_WIDTH || bmp.height != EInkDisplay::DISPLAY_HEIGHT) {
Serial.printf("[%lu] [GFX] drawFullScreenBmp: rotated BMP size %dx%d does not match panel %dx%d\n", millis(),
bmp.width, bmp.height, EInkDisplay::DISPLAY_WIDTH, EInkDisplay::DISPLAY_HEIGHT);
BmpReader::freeMonoBitmap(bmp);
return false;
}
// Full-screen blit
einkDisplay.drawImage(bmp.data, 0, 0, bmp.width, bmp.height);
BmpReader::freeMonoBitmap(bmp);
return true;
}
void GfxRenderer::clearScreen(const uint8_t color) const { einkDisplay.clearScreen(color); }
void GfxRenderer::invertScreen() const {

View File

@ -2,6 +2,7 @@
#include <EInkDisplay.h>
#include <EpdFontFamily.h>
#include <FS.h>
#include <map>
@ -36,6 +37,7 @@ class GfxRenderer {
void drawRect(int x, int y, int width, int height, bool state = true) const;
void fillRect(int x, int y, int width, int height, bool state = true) const;
void drawImage(const uint8_t bitmap[], int x, int y, int width, int height) const;
bool drawFullScreenBmp(File& file);
// Text
int getTextWidth(int fontId, const char* text, EpdFontStyle style = REGULAR) const;

@ -1 +1 @@
Subproject commit 7e0dce916706da7d80ec225fade191aea6b87fb6
Subproject commit 4d0dcd5ff87fcd86eb2966a123e85b03284a03db

View File

@ -199,6 +199,12 @@ void setup() {
// SD Card Initialization
SD.begin(SD_SPI_CS, SPI, SPI_FQ);
// TODO: Remove testing code before merging
// exitScreen();
// enterNewScreen(new SleepScreen(renderer, inputManager));
// waitForPowerRelease();
// return;
appState.loadFromFile();
if (!appState.openEpubPath.empty()) {
auto epub = loadEpub(appState.openEpubPath);

View File

@ -2,10 +2,23 @@
#include <GfxRenderer.h>
#include "SD.h"
#include "config.h"
#include "images/CrossLarge.h"
void SleepScreen::onEnter() {
// Look for sleep.bmp on the root of the sd card to determine if we should
// render a custom sleep screen instead of the default.
auto file = SD.open("/sleep.bmp");
if (file) {
renderCustomSleepScreen(file);
return;
}
renderDefaultSleepScreen();
}
void SleepScreen::renderDefaultSleepScreen() {
const auto pageWidth = GfxRenderer::getScreenWidth();
const auto pageHeight = GfxRenderer::getScreenHeight();
@ -16,3 +29,15 @@ void SleepScreen::onEnter() {
renderer.invertScreen();
renderer.displayBuffer(EInkDisplay::HALF_REFRESH);
}
void SleepScreen::renderCustomSleepScreen(File& file) {
renderer.clearScreen();
bool didImageDrawSuccessfully = renderer.drawFullScreenBmp(file);
if (!didImageDrawSuccessfully) {
renderer.clearScreen();
renderer.drawCenteredText(UI_FONT_ID, GfxRenderer::getScreenHeight() / 2, "BAD CUSTOM SLEEP SCREEN", true, BOLD);
}
renderer.displayBuffer(EInkDisplay::HALF_REFRESH);
}

View File

@ -1,8 +1,11 @@
#pragma once
#include "SD.h"
#include "Screen.h"
class SleepScreen final : public Screen {
public:
explicit SleepScreen(GfxRenderer& renderer, InputManager& inputManager) : Screen(renderer, inputManager) {}
void onEnter() override;
void renderDefaultSleepScreen();
void renderCustomSleepScreen(File& file);
};