diff --git a/lib/GfxRenderer/Bitmap.cpp b/lib/GfxRenderer/Bitmap.cpp new file mode 100644 index 0000000..0e0b0d6 --- /dev/null +++ b/lib/GfxRenderer/Bitmap.cpp @@ -0,0 +1,189 @@ +#include "Bitmap.h" + +#include +#include + +uint16_t Bitmap::readLE16(File& f) { + const int c0 = f.read(); + const int c1 = f.read(); + const auto b0 = static_cast(c0 < 0 ? 0 : c0); + const auto b1 = static_cast(c1 < 0 ? 0 : c1); + return static_cast(b0) | (static_cast(b1) << 8); +} + +uint32_t Bitmap::readLE32(File& f) { + const int c0 = f.read(); + const int c1 = f.read(); + const int c2 = f.read(); + const int c3 = f.read(); + + const auto b0 = static_cast(c0 < 0 ? 0 : c0); + const auto b1 = static_cast(c1 < 0 ? 0 : c1); + const auto b2 = static_cast(c2 < 0 ? 0 : c2); + const auto b3 = static_cast(c3 < 0 ? 0 : c3); + + return static_cast(b0) | (static_cast(b1) << 8) | (static_cast(b2) << 16) | + (static_cast(b3) << 24); +} + +const char* Bitmap::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 1, 2, 8, 24, or 32)"; + case BmpReaderError::UnsupportedCompression: + return "UnsupportedCompression (expected BI_RGB or BI_BITFIELDS for 32bpp)"; + case BmpReaderError::BadDimensions: + return "BadDimensions"; + case BmpReaderError::PaletteTooLarge: + return "PaletteTooLarge"; + + case BmpReaderError::SeekPixelDataFailed: + return "SeekPixelDataFailed"; + case BmpReaderError::BufferTooSmall: + return "BufferTooSmall"; + + case BmpReaderError::OomRowBuffer: + return "OomRowBuffer"; + case BmpReaderError::ShortReadRow: + return "ShortReadRow"; + } + return "Unknown"; +} + +BmpReaderError Bitmap::parseHeaders() { + 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; + + file.seek(8, SeekCur); + bfOffBits = readLE32(file); + + // --- DIB HEADER --- + const uint32_t biSize = readLE32(file); + if (biSize < 40) return BmpReaderError::DIBTooSmall; + + width = static_cast(readLE32(file)); + const auto rawHeight = static_cast(readLE32(file)); + topDown = rawHeight < 0; + height = topDown ? -rawHeight : rawHeight; + + const uint16_t planes = readLE16(file); + bpp = readLE16(file); + const uint32_t comp = readLE32(file); + const bool validBpp = bpp == 1 || bpp == 2 || bpp == 8 || bpp == 24 || bpp == 32; + + if (planes != 1) return BmpReaderError::BadPlanes; + if (!validBpp) return BmpReaderError::UnsupportedBpp; + // Allow BI_RGB (0) for all, and BI_BITFIELDS (3) for 32bpp which is common for BGRA masks. + if (!(comp == 0 || (bpp == 32 && comp == 3))) return BmpReaderError::UnsupportedCompression; + + file.seek(12, SeekCur); // biSizeImage, biXPelsPerMeter, biYPelsPerMeter + const uint32_t colorsUsed = readLE32(file); + if (colorsUsed > 256u) return BmpReaderError::PaletteTooLarge; + file.seek(4, SeekCur); // biClrImportant + + if (width <= 0 || height <= 0) return BmpReaderError::BadDimensions; + + // Pre-calculate Row Bytes to avoid doing this every row + rowBytes = (width * bpp + 31) / 32 * 4; + + for (int i = 0; i < 256; i++) paletteLum[i] = static_cast(i); + if (colorsUsed > 0) { + for (uint32_t i = 0; i < colorsUsed; i++) { + uint8_t rgb[4]; + file.read(rgb, 4); // Read B, G, R, Reserved in one go + paletteLum[i] = (77u * rgb[2] + 150u * rgb[1] + 29u * rgb[0]) >> 8; + } + } + + if (!file.seek(bfOffBits)) { + return BmpReaderError::SeekPixelDataFailed; + } + + return BmpReaderError::Ok; +} + +// packed 2bpp output, 0 = black, 1 = dark gray, 2 = light gray, 3 = white +BmpReaderError Bitmap::readRow(uint8_t* data, uint8_t* rowBuffer) const { + // Note: rowBuffer should be pre-allocated by the caller to size 'rowBytes' + if (file.read(rowBuffer, rowBytes) != rowBytes) return BmpReaderError::ShortReadRow; + + uint8_t* outPtr = data; + uint8_t currentOutByte = 0; + int bitShift = 6; + + // Helper lambda to pack 2bpp color into the output stream + auto packPixel = [&](uint8_t lum) { + uint8_t color = (lum >> 6); // Simple 2-bit reduction: 0-255 -> 0-3 + currentOutByte |= (color << bitShift); + if (bitShift == 0) { + *outPtr++ = currentOutByte; + currentOutByte = 0; + bitShift = 6; + } else { + bitShift -= 2; + } + }; + + switch (bpp) { + case 8: { + for (int x = 0; x < width; x++) { + packPixel(paletteLum[rowBuffer[x]]); + } + break; + } + case 24: { + const uint8_t* p = rowBuffer; + for (int x = 0; x < width; x++) { + uint8_t lum = (77u * p[2] + 150u * p[1] + 29u * p[0]) >> 8; + packPixel(lum); + p += 3; + } + break; + } + case 1: { + for (int x = 0; x < width; x++) { + uint8_t lum = (rowBuffer[x >> 3] & (0x80 >> (x & 7))) ? 0xFF : 0x00; + packPixel(lum); + } + break; + } + case 32: { + const uint8_t* p = rowBuffer; + for (int x = 0; x < width; x++) { + uint8_t lum = (77u * p[2] + 150u * p[1] + 29u * p[0]) >> 8; + packPixel(lum); + p += 4; + } + break; + } + } + + // Flush remaining bits if width is not a multiple of 4 + if (bitShift != 6) *outPtr = currentOutByte; + + return BmpReaderError::Ok; +} + +BmpReaderError Bitmap::rewindToData() const { + if (!file.seek(bfOffBits)) { + return BmpReaderError::SeekPixelDataFailed; + } + + return BmpReaderError::Ok; +} diff --git a/lib/GfxRenderer/Bitmap.h b/lib/GfxRenderer/Bitmap.h new file mode 100644 index 0000000..88dc88d --- /dev/null +++ b/lib/GfxRenderer/Bitmap.h @@ -0,0 +1,52 @@ +#pragma once + +#include + +enum class BmpReaderError : uint8_t { + Ok = 0, + FileInvalid, + SeekStartFailed, + + NotBMP, + DIBTooSmall, + + BadPlanes, + UnsupportedBpp, + UnsupportedCompression, + + BadDimensions, + PaletteTooLarge, + + SeekPixelDataFailed, + BufferTooSmall, + OomRowBuffer, + ShortReadRow, +}; + +class Bitmap { + public: + static const char* errorToString(BmpReaderError err); + + explicit Bitmap(File& file) : file(file) {} + BmpReaderError parseHeaders(); + BmpReaderError readRow(uint8_t* data, uint8_t* rowBuffer) const; + BmpReaderError rewindToData() const; + int getWidth() const { return width; } + int getHeight() const { return height; } + bool isTopDown() const { return topDown; } + bool hasGreyscale() const { return bpp > 1; } + int getRowBytes() const { return rowBytes; } + + private: + static uint16_t readLE16(File& f); + static uint32_t readLE32(File& f); + + File& file; + int width = 0; + int height = 0; + bool topDown = false; + uint32_t bfOffBits = 0; + uint16_t bpp = 0; + int rowBytes = 0; + uint8_t paletteLum[256] = {}; +}; diff --git a/lib/GfxRenderer/GfxRenderer.cpp b/lib/GfxRenderer/GfxRenderer.cpp index ac36668..19c959f 100644 --- a/lib/GfxRenderer/GfxRenderer.cpp +++ b/lib/GfxRenderer/GfxRenderer.cpp @@ -119,6 +119,66 @@ void GfxRenderer::drawImage(const uint8_t bitmap[], const int x, const int y, co einkDisplay.drawImage(bitmap, y, x, height, width); } +void GfxRenderer::drawBitmap(const Bitmap& bitmap, const int x, const int y, const int maxWidth, + const int maxHeight) const { + float scale = 1.0f; + bool isScaled = false; + if (maxWidth > 0 && bitmap.getWidth() > maxWidth) { + scale = static_cast(maxWidth) / static_cast(bitmap.getWidth()); + isScaled = true; + } + if (maxHeight > 0 && bitmap.getHeight() > maxHeight) { + scale = std::min(scale, static_cast(maxHeight) / static_cast(bitmap.getHeight())); + isScaled = true; + } + + const uint8_t outputRowSize = (bitmap.getWidth() + 3) / 4; + auto* outputRow = static_cast(malloc(outputRowSize)); + auto* rowBytes = static_cast(malloc(bitmap.getRowBytes())); + + for (int bmpY = 0; bmpY < bitmap.getHeight(); bmpY++) { + // The BMP's (0, 0) is the bottom-left corner (if the height is positive, top-left if negative). + // Screen's (0, 0) is the top-left corner. + int screenY = y + (bitmap.isTopDown() ? bmpY : bitmap.getHeight() - 1 - bmpY); + if (isScaled) { + screenY = std::floor(screenY * scale); + } + if (screenY >= getScreenHeight()) { + break; + } + + if (bitmap.readRow(outputRow, rowBytes) != BmpReaderError::Ok) { + Serial.printf("[%lu] [GFX] Failed to read row %d from bitmap\n", millis(), bmpY); + free(outputRow); + free(rowBytes); + return; + } + + for (int bmpX = 0; bmpX < bitmap.getWidth(); bmpX++) { + int screenX = x + bmpX; + if (isScaled) { + screenX = std::floor(screenX * scale); + } + if (screenX >= getScreenWidth()) { + break; + } + + const uint8_t val = outputRow[bmpX / 4] >> (6 - ((bmpX * 2) % 8)) & 0x3; + + if (renderMode == BW && val < 3) { + drawPixel(screenX, screenY); + } else if (renderMode == GRAYSCALE_MSB && (val == 1 || val == 2)) { + drawPixel(screenX, screenY, false); + } else if (renderMode == GRAYSCALE_LSB && val == 1) { + drawPixel(screenX, screenY, false); + } + } + } + + free(outputRow); + free(rowBytes); +} + void GfxRenderer::clearScreen(const uint8_t color) const { einkDisplay.clearScreen(color); } void GfxRenderer::invertScreen() const { diff --git a/lib/GfxRenderer/GfxRenderer.h b/lib/GfxRenderer/GfxRenderer.h index f07fcfb..838e018 100644 --- a/lib/GfxRenderer/GfxRenderer.h +++ b/lib/GfxRenderer/GfxRenderer.h @@ -2,9 +2,12 @@ #include #include +#include #include +#include "Bitmap.h" + class GfxRenderer { public: enum RenderMode { BW, GRAYSCALE_LSB, GRAYSCALE_MSB }; @@ -45,6 +48,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; + void drawBitmap(const Bitmap& bitmap, int x, int y, int maxWidth, int maxHeight) const; // Text int getTextWidth(int fontId, const char* text, EpdFontStyle style = REGULAR) const; diff --git a/src/activities/boot_sleep/SleepActivity.cpp b/src/activities/boot_sleep/SleepActivity.cpp index 2abe91e..417a662 100644 --- a/src/activities/boot_sleep/SleepActivity.cpp +++ b/src/activities/boot_sleep/SleepActivity.cpp @@ -3,10 +3,26 @@ #include #include "CrossPointSettings.h" +#include "SD.h" #include "config.h" #include "images/CrossLarge.h" void SleepActivity::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) { + Bitmap bitmap(file); + if (bitmap.parseHeaders() == BmpReaderError::Ok) { + renderCustomSleepScreen(bitmap); + return; + } + } + + renderDefaultSleepScreen(); +} + +void SleepActivity::renderDefaultSleepScreen() const { const auto pageWidth = GfxRenderer::getScreenWidth(); const auto pageHeight = GfxRenderer::getScreenHeight(); @@ -22,3 +38,50 @@ void SleepActivity::onEnter() { renderer.displayBuffer(EInkDisplay::HALF_REFRESH); } + +void SleepActivity::renderCustomSleepScreen(const Bitmap& bitmap) const { + int x, y; + const auto pageWidth = GfxRenderer::getScreenWidth(); + const auto pageHeight = GfxRenderer::getScreenHeight(); + + if (bitmap.getWidth() > pageWidth || bitmap.getHeight() > pageHeight) { + // image will scale, make sure placement is right + const float ratio = static_cast(bitmap.getWidth()) / static_cast(bitmap.getHeight()); + const float screenRatio = static_cast(pageWidth) / static_cast(pageHeight); + + if (ratio > screenRatio) { + // image wider than viewport ratio, scaled down image needs to be centered vertically + x = 0; + y = (pageHeight - pageWidth / ratio) / 2; + } else { + // image taller than viewport ratio, scaled down image needs to be centered horizontally + x = (pageWidth - pageHeight * ratio) / 2; + y = 0; + } + } else { + // center the image + x = (pageWidth - bitmap.getWidth()) / 2; + y = (pageHeight - bitmap.getHeight()) / 2; + } + + renderer.clearScreen(); + renderer.drawBitmap(bitmap, x, y, pageWidth, pageHeight); + renderer.displayBuffer(EInkDisplay::HALF_REFRESH); + + if (bitmap.hasGreyscale()) { + bitmap.rewindToData(); + renderer.clearScreen(0x00); + renderer.setRenderMode(GfxRenderer::GRAYSCALE_LSB); + renderer.drawBitmap(bitmap, x, y, pageWidth, pageHeight); + renderer.copyGrayscaleLsbBuffers(); + + bitmap.rewindToData(); + renderer.clearScreen(0x00); + renderer.setRenderMode(GfxRenderer::GRAYSCALE_MSB); + renderer.drawBitmap(bitmap, x, y, pageWidth, pageHeight); + renderer.copyGrayscaleMsbBuffers(); + + renderer.displayGrayBuffer(); + renderer.setRenderMode(GfxRenderer::BW); + } +} diff --git a/src/activities/boot_sleep/SleepActivity.h b/src/activities/boot_sleep/SleepActivity.h index 1626481..9d4a7c4 100644 --- a/src/activities/boot_sleep/SleepActivity.h +++ b/src/activities/boot_sleep/SleepActivity.h @@ -1,8 +1,14 @@ #pragma once #include "../Activity.h" +class Bitmap; + class SleepActivity final : public Activity { public: explicit SleepActivity(GfxRenderer& renderer, InputManager& inputManager) : Activity(renderer, inputManager) {} void onEnter() override; + + private: + void renderDefaultSleepScreen() const; + void renderCustomSleepScreen(const Bitmap& bitmap) const; };