feat: Initial support for the x3 (#875)

## Summary

Adds Xteink X3 hardware support to CrossPoint Reader. The X3 uses the
same SSD1677 e-ink controller as the X4 but with a different panel
(792x528 vs 800x480), different button layout, and an I2C fuel gauge
(BQ27220) instead of ADC-based battery reading.

All X3-specific behavior is gated by runtime device detection — X4
behavior is unchanged.

Depends on community-sdk X3 support: open-x4-epaper/community-sdk#19
(merged).

## Changes

### HAL Layer

**HalGPIO** (`lib/hal/HalGPIO.cpp/.h`)
- I2C-based device fingerprinting at boot: probes for BQ27220 fuel
gauge, DS3231 RTC, and QMI8658 IMU to distinguish X3 from X4
- Detection result cached in NVS for fast subsequent boots
- Exposes `deviceIsX3()` / `deviceIsX4()` helpers used throughout the
codebase
- X3 button mapping (7 GPIOs vs X4's layout)
- USB connection detection and wake classification for X3

**HalDisplay** (`lib/hal/HalDisplay.cpp/.h`)
- Calls `einkDisplay.setDisplayX3()` before init when X3 is detected
- Requests display resync after power button / flash wake events
- Runtime display dimension accessors (`getDisplayWidth()`,
`getDisplayHeight()`, `getBufferSize()`)
- Exposed as global `display` instance for use by image converters

**HalPowerManager** (`lib/hal/HalPowerManager.cpp/.h`)
- X3 battery reading via I2C fuel gauge (BQ27220 at 0x55, SOC register)
- X3 power button uses GPIO hold for deep sleep

### Display & Rendering

**GfxRenderer** (`lib/GfxRenderer/GfxRenderer.cpp/.h`)
- Buffer size and display dimensions are now runtime values (not
compile-time constants) to support both panel sizes
- X3 anti-aliasing tuning: only the darker grayscale level is applied to
avoid washed-out text on the X3 panel. X4 retains both levels via
`deviceIsX4()` gate

**Image Converters** (`lib/JpegToBmpConverter`, `lib/PngToBmpConverter`)
- Cover image prescale target uses runtime display dimensions from HAL
instead of hardcoded 800x480

### UI Themes

**BaseTheme / LyraTheme** (`src/components/themes/`)
- X3 button position mapping for the different physical layout
- Adjusted UI element positioning for 792x528 viewport

### Boot & Init

**main.cpp**
- X3 hardware detection logging
- Adjusted init sequence for X3 (no `HalSystem::begin()` dependency on
X3 path)

**HomeActivity**
- Uses runtime `renderer.getBufferSize()` instead of static
`GfxRenderer::getBufferSize()`

FYI I did not add support for the gyro page turner. That can be it's own
PR.
This commit is contained in:
Justin Mitchell
2026-04-04 09:25:43 -06:00
committed by GitHub
parent e6c6e72a24
commit 9b3885135f
16 changed files with 564 additions and 132 deletions

View File

@@ -1,6 +1,7 @@
#include "GfxRenderer.h" #include "GfxRenderer.h"
#include <FontDecompressor.h> #include <FontDecompressor.h>
#include <HalGPIO.h>
#include <Logging.h> #include <Logging.h>
#include <Utf8.h> #include <Utf8.h>
@@ -28,6 +29,11 @@ void GfxRenderer::begin() {
LOG_ERR("GFX", "!! No framebuffer"); LOG_ERR("GFX", "!! No framebuffer");
assert(false); assert(false);
} }
panelWidth = display.getDisplayWidth();
panelHeight = display.getDisplayHeight();
panelWidthBytes = display.getDisplayWidthBytes();
frameBufferSize = display.getBufferSize();
bwBufferChunks.assign((frameBufferSize + BW_BUFFER_CHUNK_SIZE - 1) / BW_BUFFER_CHUNK_SIZE, nullptr);
} }
void GfxRenderer::insertFont(const int fontId, EpdFontFamily font) { fontMap.insert({fontId, font}); } void GfxRenderer::insertFont(const int fontId, EpdFontFamily font) { fontMap.insert({fontId, font}); }
@@ -35,25 +41,25 @@ void GfxRenderer::insertFont(const int fontId, EpdFontFamily font) { fontMap.ins
// Translate logical (x,y) coordinates to physical panel coordinates based on current orientation // Translate logical (x,y) coordinates to physical panel coordinates based on current orientation
// This should always be inlined for better performance // This should always be inlined for better performance
static inline void rotateCoordinates(const GfxRenderer::Orientation orientation, const int x, const int y, int* phyX, static inline void rotateCoordinates(const GfxRenderer::Orientation orientation, const int x, const int y, int* phyX,
int* phyY) { int* phyY, const uint16_t panelWidth, const uint16_t panelHeight) {
switch (orientation) { switch (orientation) {
case GfxRenderer::Portrait: { case GfxRenderer::Portrait: {
// Logical portrait (480x800) → panel (800x480) // Logical portrait (480x800) → panel (800x480)
// Rotation: 90 degrees clockwise // Rotation: 90 degrees clockwise
*phyX = y; *phyX = y;
*phyY = HalDisplay::DISPLAY_HEIGHT - 1 - x; *phyY = panelHeight - 1 - x;
break; break;
} }
case GfxRenderer::LandscapeClockwise: { case GfxRenderer::LandscapeClockwise: {
// Logical landscape (800x480) rotated 180 degrees (swap top/bottom and left/right) // Logical landscape (800x480) rotated 180 degrees (swap top/bottom and left/right)
*phyX = HalDisplay::DISPLAY_WIDTH - 1 - x; *phyX = panelWidth - 1 - x;
*phyY = HalDisplay::DISPLAY_HEIGHT - 1 - y; *phyY = panelHeight - 1 - y;
break; break;
} }
case GfxRenderer::PortraitInverted: { case GfxRenderer::PortraitInverted: {
// Logical portrait (480x800) → panel (800x480) // Logical portrait (480x800) → panel (800x480)
// Rotation: 90 degrees counter-clockwise // Rotation: 90 degrees counter-clockwise
*phyX = HalDisplay::DISPLAY_WIDTH - 1 - y; *phyX = panelWidth - 1 - y;
*phyY = x; *phyY = x;
break; break;
} }
@@ -125,8 +131,9 @@ static void renderCharImpl(const GfxRenderer& renderer, GfxRenderer::RenderMode
if (renderMode == GfxRenderer::BW && bmpVal < 3) { if (renderMode == GfxRenderer::BW && bmpVal < 3) {
// Black (also paints over the grays in BW mode) // Black (also paints over the grays in BW mode)
renderer.drawPixel(screenX, screenY, pixelState); renderer.drawPixel(screenX, screenY, pixelState);
} else if (renderMode == GfxRenderer::GRAYSCALE_MSB && (bmpVal == 1 || bmpVal == 2)) { } else if (renderMode == GfxRenderer::GRAYSCALE_MSB && (bmpVal == 1 || (gpio.deviceIsX4() && bmpVal == 2))) {
// Light gray (also mark the MSB if it's going to be a dark gray too) // Light gray (also mark the MSB if it's going to be a dark gray too)
// X3 AA tuning: keep only the darker antialias level to avoid washed text
// We have to flag pixels in reverse for the gray buffers, as 0 leave alone, 1 update // We have to flag pixels in reverse for the gray buffers, as 0 leave alone, 1 update
renderer.drawPixel(screenX, screenY, false); renderer.drawPixel(screenX, screenY, false);
} else if (renderMode == GfxRenderer::GRAYSCALE_LSB && bmpVal == 1) { } else if (renderMode == GfxRenderer::GRAYSCALE_LSB && bmpVal == 1) {
@@ -168,16 +175,16 @@ void GfxRenderer::drawPixel(const int x, const int y, const bool state) const {
int phyY = 0; int phyY = 0;
// Note: this call should be inlined for better performance // Note: this call should be inlined for better performance
rotateCoordinates(orientation, x, y, &phyX, &phyY); rotateCoordinates(orientation, x, y, &phyX, &phyY, panelWidth, panelHeight);
// Bounds checking against physical panel dimensions // Bounds checking against runtime panel dimensions
if (phyX < 0 || phyX >= HalDisplay::DISPLAY_WIDTH || phyY < 0 || phyY >= HalDisplay::DISPLAY_HEIGHT) { if (phyX < 0 || phyX >= panelWidth || phyY < 0 || phyY >= panelHeight) {
LOG_ERR("GFX", "!! Outside range (%d, %d) -> (%d, %d)", x, y, phyX, phyY); LOG_ERR("GFX", "!! Outside range (%d, %d) -> (%d, %d)", x, y, phyX, phyY);
return; return;
} }
// Calculate byte position and bit position // Calculate byte position and bit position
const uint16_t byteIndex = phyY * HalDisplay::DISPLAY_WIDTH_BYTES + (phyX / 8); const uint32_t byteIndex = static_cast<uint32_t>(phyY) * panelWidthBytes + (phyX / 8);
const uint8_t bitPosition = 7 - (phyX % 8); // MSB first const uint8_t bitPosition = 7 - (phyX % 8); // MSB first
if (state) { if (state) {
@@ -556,7 +563,7 @@ void GfxRenderer::fillRoundedRect(const int x, const int y, const int width, con
void GfxRenderer::drawImage(const uint8_t bitmap[], const int x, const int y, const int width, const int height) const { void GfxRenderer::drawImage(const uint8_t bitmap[], const int x, const int y, const int width, const int height) const {
int rotatedX = 0; int rotatedX = 0;
int rotatedY = 0; int rotatedY = 0;
rotateCoordinates(orientation, x, y, &rotatedX, &rotatedY); rotateCoordinates(orientation, x, y, &rotatedX, &rotatedY, panelWidth, panelHeight);
// Rotate origin corner // Rotate origin corner
switch (orientation) { switch (orientation) {
case Portrait: case Portrait:
@@ -596,12 +603,24 @@ void GfxRenderer::drawBitmap(const Bitmap& bitmap, const int x, const int y, con
LOG_DBG("GFX", "Cropping %dx%d by %dx%d pix, is %s", bitmap.getWidth(), bitmap.getHeight(), cropPixX, cropPixY, LOG_DBG("GFX", "Cropping %dx%d by %dx%d pix, is %s", bitmap.getWidth(), bitmap.getHeight(), cropPixX, cropPixY,
bitmap.isTopDown() ? "top-down" : "bottom-up"); bitmap.isTopDown() ? "top-down" : "bottom-up");
if (maxWidth > 0 && (1.0f - cropX) * bitmap.getWidth() > maxWidth) { const float croppedWidth = (1.0f - cropX) * static_cast<float>(bitmap.getWidth());
scale = static_cast<float>(maxWidth) / static_cast<float>((1.0f - cropX) * bitmap.getWidth()); const float croppedHeight = (1.0f - cropY) * static_cast<float>(bitmap.getHeight());
isScaled = true; bool hasTargetBounds = false;
float fitScale = 1.0f;
if (maxWidth > 0 && croppedWidth > 0.0f) {
fitScale = static_cast<float>(maxWidth) / croppedWidth;
hasTargetBounds = true;
} }
if (maxHeight > 0 && (1.0f - cropY) * bitmap.getHeight() > maxHeight) {
scale = std::min(scale, static_cast<float>(maxHeight) / static_cast<float>((1.0f - cropY) * bitmap.getHeight())); if (maxHeight > 0 && croppedHeight > 0.0f) {
const float heightScale = static_cast<float>(maxHeight) / croppedHeight;
fitScale = hasTargetBounds ? std::min(fitScale, heightScale) : heightScale;
hasTargetBounds = true;
}
if (hasTargetBounds && fitScale < 1.0f) {
scale = fitScale;
isScaled = true; isScaled = true;
} }
LOG_DBG("GFX", "Scaling by %f - %s", scale, isScaled ? "scaled" : "not scaled"); LOG_DBG("GFX", "Scaling by %f - %s", scale, isScaled ? "scaled" : "not scaled");
@@ -664,7 +683,7 @@ void GfxRenderer::drawBitmap(const Bitmap& bitmap, const int x, const int y, con
if (renderMode == BW && val < 3) { if (renderMode == BW && val < 3) {
drawPixel(screenX, screenY); drawPixel(screenX, screenY);
} else if (renderMode == GRAYSCALE_MSB && (val == 1 || val == 2)) { } else if (renderMode == GRAYSCALE_MSB && (val == 1 || (gpio.deviceIsX4() && val == 2))) {
drawPixel(screenX, screenY, false); drawPixel(screenX, screenY, false);
} else if (renderMode == GRAYSCALE_LSB && val == 1) { } else if (renderMode == GRAYSCALE_LSB && val == 1) {
drawPixel(screenX, screenY, false); drawPixel(screenX, screenY, false);
@@ -822,7 +841,7 @@ void GfxRenderer::clearScreen(const uint8_t color) const {
} }
void GfxRenderer::invertScreen() const { void GfxRenderer::invertScreen() const {
for (int i = 0; i < HalDisplay::BUFFER_SIZE; i++) { for (uint32_t i = 0; i < frameBufferSize; i++) {
frameBuffer[i] = ~frameBuffer[i]; frameBuffer[i] = ~frameBuffer[i];
} }
} }
@@ -923,13 +942,13 @@ int GfxRenderer::getScreenWidth() const {
case Portrait: case Portrait:
case PortraitInverted: case PortraitInverted:
// 480px wide in portrait logical coordinates // 480px wide in portrait logical coordinates
return HalDisplay::DISPLAY_HEIGHT; return panelHeight;
case LandscapeClockwise: case LandscapeClockwise:
case LandscapeCounterClockwise: case LandscapeCounterClockwise:
// 800px wide in landscape logical coordinates // 800px wide in landscape logical coordinates
return HalDisplay::DISPLAY_WIDTH; return panelWidth;
} }
return HalDisplay::DISPLAY_HEIGHT; return panelHeight;
} }
int GfxRenderer::getScreenHeight() const { int GfxRenderer::getScreenHeight() const {
@@ -937,13 +956,13 @@ int GfxRenderer::getScreenHeight() const {
case Portrait: case Portrait:
case PortraitInverted: case PortraitInverted:
// 800px tall in portrait logical coordinates // 800px tall in portrait logical coordinates
return HalDisplay::DISPLAY_WIDTH; return panelWidth;
case LandscapeClockwise: case LandscapeClockwise:
case LandscapeCounterClockwise: case LandscapeCounterClockwise:
// 480px tall in landscape logical coordinates // 480px tall in landscape logical coordinates
return HalDisplay::DISPLAY_HEIGHT; return panelHeight;
} }
return HalDisplay::DISPLAY_WIDTH; return panelWidth;
} }
int GfxRenderer::getSpaceWidth(const int fontId, const EpdFontFamily::Style style) const { int GfxRenderer::getSpaceWidth(const int fontId, const EpdFontFamily::Style style) const {
@@ -1095,7 +1114,7 @@ void GfxRenderer::drawTextRotated90CW(const int fontId, const int x, const int y
uint8_t* GfxRenderer::getFrameBuffer() const { return frameBuffer; } uint8_t* GfxRenderer::getFrameBuffer() const { return frameBuffer; }
size_t GfxRenderer::getBufferSize() { return HalDisplay::BUFFER_SIZE; } size_t GfxRenderer::getBufferSize() const { return frameBufferSize; }
// unused // unused
// void GfxRenderer::grayscaleRevert() const { display.grayscaleRevert(); } // void GfxRenderer::grayscaleRevert() const { display.grayscaleRevert(); }
@@ -1123,7 +1142,7 @@ void GfxRenderer::freeBwBufferChunks() {
*/ */
bool GfxRenderer::storeBwBuffer() { bool GfxRenderer::storeBwBuffer() {
// Allocate and copy each chunk // Allocate and copy each chunk
for (size_t i = 0; i < BW_BUFFER_NUM_CHUNKS; i++) { for (size_t i = 0; i < bwBufferChunks.size(); i++) {
// Check if any chunks are already allocated // Check if any chunks are already allocated
if (bwBufferChunks[i]) { if (bwBufferChunks[i]) {
LOG_ERR("GFX", "!! BW buffer chunk %zu already stored - this is likely a bug, freeing chunk", i); LOG_ERR("GFX", "!! BW buffer chunk %zu already stored - this is likely a bug, freeing chunk", i);
@@ -1132,19 +1151,20 @@ bool GfxRenderer::storeBwBuffer() {
} }
const size_t offset = i * BW_BUFFER_CHUNK_SIZE; const size_t offset = i * BW_BUFFER_CHUNK_SIZE;
bwBufferChunks[i] = static_cast<uint8_t*>(malloc(BW_BUFFER_CHUNK_SIZE)); const size_t chunkSize = std::min(BW_BUFFER_CHUNK_SIZE, static_cast<size_t>(frameBufferSize - offset));
bwBufferChunks[i] = static_cast<uint8_t*>(malloc(chunkSize));
if (!bwBufferChunks[i]) { if (!bwBufferChunks[i]) {
LOG_ERR("GFX", "!! Failed to allocate BW buffer chunk %zu (%zu bytes)", i, BW_BUFFER_CHUNK_SIZE); LOG_ERR("GFX", "!! Failed to allocate BW buffer chunk %zu (%zu bytes)", i, chunkSize);
// Free previously allocated chunks // Free previously allocated chunks
freeBwBufferChunks(); freeBwBufferChunks();
return false; return false;
} }
memcpy(bwBufferChunks[i], frameBuffer + offset, BW_BUFFER_CHUNK_SIZE); memcpy(bwBufferChunks[i], frameBuffer + offset, chunkSize);
} }
LOG_DBG("GFX", "Stored BW buffer in %zu chunks (%zu bytes each)", BW_BUFFER_NUM_CHUNKS, BW_BUFFER_CHUNK_SIZE); LOG_DBG("GFX", "Stored BW buffer in %zu chunks (%zu bytes each)", bwBufferChunks.size(), BW_BUFFER_CHUNK_SIZE);
return true; return true;
} }
@@ -1168,9 +1188,10 @@ void GfxRenderer::restoreBwBuffer() {
return; return;
} }
for (size_t i = 0; i < BW_BUFFER_NUM_CHUNKS; i++) { for (size_t i = 0; i < bwBufferChunks.size(); i++) {
const size_t offset = i * BW_BUFFER_CHUNK_SIZE; const size_t offset = i * BW_BUFFER_CHUNK_SIZE;
memcpy(frameBuffer + offset, bwBufferChunks[i], BW_BUFFER_CHUNK_SIZE); const size_t chunkSize = std::min(BW_BUFFER_CHUNK_SIZE, static_cast<size_t>(frameBufferSize - offset));
memcpy(frameBuffer + offset, bwBufferChunks[i], chunkSize);
} }
display.cleanupGrayscaleBuffers(frameBuffer); display.cleanupGrayscaleBuffers(frameBuffer);

View File

@@ -30,16 +30,17 @@ class GfxRenderer {
private: private:
static constexpr size_t BW_BUFFER_CHUNK_SIZE = 8000; // 8KB chunks to allow for non-contiguous memory static constexpr size_t BW_BUFFER_CHUNK_SIZE = 8000; // 8KB chunks to allow for non-contiguous memory
static constexpr size_t BW_BUFFER_NUM_CHUNKS = HalDisplay::BUFFER_SIZE / BW_BUFFER_CHUNK_SIZE;
static_assert(BW_BUFFER_CHUNK_SIZE * BW_BUFFER_NUM_CHUNKS == HalDisplay::BUFFER_SIZE,
"BW buffer chunking does not line up with display buffer size");
HalDisplay& display; HalDisplay& display;
RenderMode renderMode; RenderMode renderMode;
Orientation orientation; Orientation orientation;
bool fadingFix; bool fadingFix;
uint8_t* frameBuffer = nullptr; uint8_t* frameBuffer = nullptr;
uint8_t* bwBufferChunks[BW_BUFFER_NUM_CHUNKS] = {nullptr}; uint16_t panelWidth = HalDisplay::DISPLAY_WIDTH;
uint16_t panelHeight = HalDisplay::DISPLAY_HEIGHT;
uint16_t panelWidthBytes = HalDisplay::DISPLAY_WIDTH_BYTES;
uint32_t frameBufferSize = HalDisplay::BUFFER_SIZE;
std::vector<uint8_t*> bwBufferChunks;
std::map<int, EpdFontFamily> fontMap; std::map<int, EpdFontFamily> fontMap;
// Mutable because drawText() is const but needs to delegate scan-mode // Mutable because drawText() is const but needs to delegate scan-mode
@@ -155,5 +156,5 @@ class GfxRenderer {
// Low level functions // Low level functions
uint8_t* getFrameBuffer() const; uint8_t* getFrameBuffer() const;
static size_t getBufferSize(); size_t getBufferSize() const;
}; };

View File

@@ -1,5 +1,6 @@
#include "JpegToBmpConverter.h" #include "JpegToBmpConverter.h"
#include <HalDisplay.h>
#include <HalStorage.h> #include <HalStorage.h>
#include <Logging.h> #include <Logging.h>
#include <picojpeg.h> #include <picojpeg.h>
@@ -27,8 +28,6 @@ constexpr bool USE_FLOYD_STEINBERG = false; // Floyd-Steinberg error diffusion
constexpr bool USE_NOISE_DITHERING = false; // Hash-based noise dithering (good for downsampling) constexpr bool USE_NOISE_DITHERING = false; // Hash-based noise dithering (good for downsampling)
// Pre-resize to target display size (CRITICAL: avoids dithering artifacts from post-downsampling) // Pre-resize to target display size (CRITICAL: avoids dithering artifacts from post-downsampling)
constexpr bool USE_PRESCALE = true; // true: scale image to target size before dithering constexpr bool USE_PRESCALE = true; // true: scale image to target size before dithering
constexpr int TARGET_MAX_WIDTH = 480; // Max width for cover images (portrait display width)
constexpr int TARGET_MAX_HEIGHT = 800; // Max height for cover images (portrait display height)
// ============================================================================ // ============================================================================
inline void write16(Print& out, const uint16_t value) { inline void write16(Print& out, const uint16_t value) {
@@ -559,7 +558,10 @@ bool JpegToBmpConverter::jpegFileToBmpStreamInternal(FsFile& jpegFile, Print& bm
// Core function: Convert JPEG file to 2-bit BMP (uses default target size) // Core function: Convert JPEG file to 2-bit BMP (uses default target size)
bool JpegToBmpConverter::jpegFileToBmpStream(FsFile& jpegFile, Print& bmpOut, bool crop) { bool JpegToBmpConverter::jpegFileToBmpStream(FsFile& jpegFile, Print& bmpOut, bool crop) {
return jpegFileToBmpStreamInternal(jpegFile, bmpOut, TARGET_MAX_WIDTH, TARGET_MAX_HEIGHT, false, crop); // Use runtime display dimensions (swapped for portrait cover sizing)
const int targetWidth = display.getDisplayHeight();
const int targetHeight = display.getDisplayWidth();
return jpegFileToBmpStreamInternal(jpegFile, bmpOut, targetWidth, targetHeight, false, crop);
} }
// Convert with custom target size (for thumbnails, 2-bit) // Convert with custom target size (for thumbnails, 2-bit)

View File

@@ -1,5 +1,6 @@
#include "PngToBmpConverter.h" #include "PngToBmpConverter.h"
#include <HalDisplay.h>
#include <HalStorage.h> #include <HalStorage.h>
#include <InflateReader.h> #include <InflateReader.h>
#include <Logging.h> #include <Logging.h>
@@ -16,8 +17,6 @@ constexpr bool USE_8BIT_OUTPUT = false;
constexpr bool USE_ATKINSON = true; constexpr bool USE_ATKINSON = true;
constexpr bool USE_FLOYD_STEINBERG = false; constexpr bool USE_FLOYD_STEINBERG = false;
constexpr bool USE_PRESCALE = true; constexpr bool USE_PRESCALE = true;
constexpr int TARGET_MAX_WIDTH = 480;
constexpr int TARGET_MAX_HEIGHT = 800;
// ============================================================================ // ============================================================================
// BMP writing helpers (same as JpegToBmpConverter) // BMP writing helpers (same as JpegToBmpConverter)
@@ -822,7 +821,10 @@ bool PngToBmpConverter::pngFileToBmpStreamInternal(FsFile& pngFile, Print& bmpOu
} }
bool PngToBmpConverter::pngFileToBmpStream(FsFile& pngFile, Print& bmpOut, bool crop) { bool PngToBmpConverter::pngFileToBmpStream(FsFile& pngFile, Print& bmpOut, bool crop) {
return pngFileToBmpStreamInternal(pngFile, bmpOut, TARGET_MAX_WIDTH, TARGET_MAX_HEIGHT, false, crop); // Use runtime display dimensions (swapped for portrait cover sizing)
const int targetWidth = display.getDisplayHeight();
const int targetHeight = display.getDisplayWidth();
return pngFileToBmpStreamInternal(pngFile, bmpOut, targetWidth, targetHeight, false, crop);
} }
bool PngToBmpConverter::pngFileToBmpStreamWithSize(FsFile& pngFile, Print& bmpOut, int targetMaxWidth, bool PngToBmpConverter::pngFileToBmpStreamWithSize(FsFile& pngFile, Print& bmpOut, int targetMaxWidth,

View File

@@ -1,13 +1,30 @@
#include <HalDisplay.h> #include <HalDisplay.h>
#include <HalGPIO.h> #include <HalGPIO.h>
// Global HalDisplay instance
HalDisplay display;
#define SD_SPI_MISO 7 #define SD_SPI_MISO 7
HalDisplay::HalDisplay() : einkDisplay(EPD_SCLK, EPD_MOSI, EPD_CS, EPD_DC, EPD_RST, EPD_BUSY) {} HalDisplay::HalDisplay() : einkDisplay(EPD_SCLK, EPD_MOSI, EPD_CS, EPD_DC, EPD_RST, EPD_BUSY) {}
HalDisplay::~HalDisplay() {} HalDisplay::~HalDisplay() {}
void HalDisplay::begin() { einkDisplay.begin(); } void HalDisplay::begin() {
// Set X3-specific panel mode before initializing.
if (gpio.deviceIsX3()) {
einkDisplay.setDisplayX3();
}
einkDisplay.begin();
// Request resync after specific wakeup events to ensure clean display state
const auto wakeupReason = gpio.getWakeupReason();
if (wakeupReason == HalGPIO::WakeupReason::PowerButton || wakeupReason == HalGPIO::WakeupReason::AfterFlash ||
wakeupReason == HalGPIO::WakeupReason::Other) {
einkDisplay.requestResync();
}
}
void HalDisplay::clearScreen(uint8_t color) const { einkDisplay.clearScreen(color); } void HalDisplay::clearScreen(uint8_t color) const { einkDisplay.clearScreen(color); }
@@ -34,10 +51,18 @@ EInkDisplay::RefreshMode convertRefreshMode(HalDisplay::RefreshMode mode) {
} }
void HalDisplay::displayBuffer(HalDisplay::RefreshMode mode, bool turnOffScreen) { void HalDisplay::displayBuffer(HalDisplay::RefreshMode mode, bool turnOffScreen) {
if (gpio.deviceIsX3() && mode == RefreshMode::HALF_REFRESH) {
einkDisplay.requestResync(1);
}
einkDisplay.displayBuffer(convertRefreshMode(mode), turnOffScreen); einkDisplay.displayBuffer(convertRefreshMode(mode), turnOffScreen);
} }
void HalDisplay::refreshDisplay(HalDisplay::RefreshMode mode, bool turnOffScreen) { void HalDisplay::refreshDisplay(HalDisplay::RefreshMode mode, bool turnOffScreen) {
if (gpio.deviceIsX3() && mode == RefreshMode::HALF_REFRESH) {
einkDisplay.requestResync(1);
}
einkDisplay.refreshDisplay(convertRefreshMode(mode), turnOffScreen); einkDisplay.refreshDisplay(convertRefreshMode(mode), turnOffScreen);
} }
@@ -56,3 +81,11 @@ void HalDisplay::copyGrayscaleMsbBuffers(const uint8_t* msbBuffer) { einkDisplay
void HalDisplay::cleanupGrayscaleBuffers(const uint8_t* bwBuffer) { einkDisplay.cleanupGrayscaleBuffers(bwBuffer); } void HalDisplay::cleanupGrayscaleBuffers(const uint8_t* bwBuffer) { einkDisplay.cleanupGrayscaleBuffers(bwBuffer); }
void HalDisplay::displayGrayBuffer(bool turnOffScreen) { einkDisplay.displayGrayBuffer(turnOffScreen); } void HalDisplay::displayGrayBuffer(bool turnOffScreen) { einkDisplay.displayGrayBuffer(turnOffScreen); }
uint16_t HalDisplay::getDisplayWidth() const { return einkDisplay.getDisplayWidth(); }
uint16_t HalDisplay::getDisplayHeight() const { return einkDisplay.getDisplayHeight(); }
uint16_t HalDisplay::getDisplayWidthBytes() const { return einkDisplay.getDisplayWidthBytes(); }
uint32_t HalDisplay::getBufferSize() const { return einkDisplay.getBufferSize(); }

View File

@@ -49,6 +49,14 @@ class HalDisplay {
void displayGrayBuffer(bool turnOffScreen = false); void displayGrayBuffer(bool turnOffScreen = false);
// Runtime geometry passthrough
uint16_t getDisplayWidth() const;
uint16_t getDisplayHeight() const;
uint16_t getDisplayWidthBytes() const;
uint32_t getBufferSize() const;
private: private:
EInkDisplay einkDisplay; EInkDisplay einkDisplay;
}; };
extern HalDisplay display;

View File

@@ -1,11 +1,206 @@
#include <HalGPIO.h> #include <HalGPIO.h>
#include <Logging.h>
#include <Preferences.h>
#include <SPI.h> #include <SPI.h>
#include <Wire.h>
#include <esp_sleep.h>
// Global HalGPIO instance
HalGPIO gpio;
namespace X3GPIO {
struct X3ProbeResult {
bool bq27220 = false;
bool ds3231 = false;
bool qmi8658 = false;
uint8_t score() const {
return static_cast<uint8_t>(bq27220) + static_cast<uint8_t>(ds3231) + static_cast<uint8_t>(qmi8658);
}
};
bool readI2CReg8(uint8_t addr, uint8_t reg, uint8_t* outValue) {
Wire.beginTransmission(addr);
Wire.write(reg);
if (Wire.endTransmission(false) != 0) {
return false;
}
if (Wire.requestFrom(addr, static_cast<uint8_t>(1), static_cast<uint8_t>(true)) < 1) {
return false;
}
*outValue = Wire.read();
return true;
}
bool readI2CReg16LE(uint8_t addr, uint8_t reg, uint16_t* outValue) {
Wire.beginTransmission(addr);
Wire.write(reg);
if (Wire.endTransmission(false) != 0) {
return false;
}
if (Wire.requestFrom(addr, static_cast<uint8_t>(2), static_cast<uint8_t>(true)) < 2) {
while (Wire.available()) {
Wire.read();
}
return false;
}
const uint8_t lo = Wire.read();
const uint8_t hi = Wire.read();
*outValue = (static_cast<uint16_t>(hi) << 8) | lo;
return true;
}
bool readBQ27220CurrentMA(int16_t* outCurrent) {
uint16_t raw = 0;
if (!readI2CReg16LE(I2C_ADDR_BQ27220, BQ27220_CUR_REG, &raw)) {
return false;
}
*outCurrent = static_cast<int16_t>(raw);
return true;
}
bool probeBQ27220Signature() {
uint16_t soc = 0;
uint16_t voltageMv = 0;
if (!readI2CReg16LE(I2C_ADDR_BQ27220, BQ27220_SOC_REG, &soc)) {
return false;
}
if (soc > 100) {
return false;
}
if (!readI2CReg16LE(I2C_ADDR_BQ27220, BQ27220_VOLT_REG, &voltageMv)) {
return false;
}
return voltageMv >= 2500 && voltageMv <= 5000;
}
bool probeDS3231Signature() {
uint8_t sec = 0;
if (!readI2CReg8(I2C_ADDR_DS3231, DS3231_SEC_REG, &sec)) {
return false;
}
const uint8_t tensDigit = (sec >> 4) & 0x07;
const uint8_t onesDigit = sec & 0x0F;
return tensDigit <= 5 && onesDigit <= 9;
}
bool probeQMI8658Signature() {
uint8_t whoami = 0;
if (readI2CReg8(I2C_ADDR_QMI8658, QMI8658_WHO_AM_I_REG, &whoami) && whoami == QMI8658_WHO_AM_I_VALUE) {
return true;
}
if (readI2CReg8(I2C_ADDR_QMI8658_ALT, QMI8658_WHO_AM_I_REG, &whoami) && whoami == QMI8658_WHO_AM_I_VALUE) {
return true;
}
return false;
}
X3ProbeResult runX3ProbePass() {
X3ProbeResult result;
Wire.begin(X3_I2C_SDA, X3_I2C_SCL, X3_I2C_FREQ);
Wire.setTimeOut(6);
result.bq27220 = probeBQ27220Signature();
result.ds3231 = probeDS3231Signature();
result.qmi8658 = probeQMI8658Signature();
Wire.end();
pinMode(20, INPUT);
pinMode(0, INPUT);
return result;
}
} // namespace X3GPIO
namespace {
constexpr char HW_NAMESPACE[] = "cphw";
constexpr char NVS_KEY_DEV_OVERRIDE[] = "dev_ovr"; // 0=auto, 1=x4, 2=x3
constexpr char NVS_KEY_DEV_CACHED[] = "dev_det"; // 0=unknown, 1=x4, 2=x3
enum class NvsDeviceValue : uint8_t { Unknown = 0, X4 = 1, X3 = 2 };
NvsDeviceValue readNvsDeviceValue(const char* key, NvsDeviceValue defaultValue) {
Preferences prefs;
if (!prefs.begin(HW_NAMESPACE, true)) {
return defaultValue;
}
const uint8_t raw = prefs.getUChar(key, static_cast<uint8_t>(defaultValue));
prefs.end();
if (raw > static_cast<uint8_t>(NvsDeviceValue::X3)) {
return defaultValue;
}
return static_cast<NvsDeviceValue>(raw);
}
void writeNvsDeviceValue(const char* key, NvsDeviceValue value) {
Preferences prefs;
if (!prefs.begin(HW_NAMESPACE, false)) {
return;
}
prefs.putUChar(key, static_cast<uint8_t>(value));
prefs.end();
}
HalGPIO::DeviceType nvsToDeviceType(NvsDeviceValue value) {
return value == NvsDeviceValue::X3 ? HalGPIO::DeviceType::X3 : HalGPIO::DeviceType::X4;
}
HalGPIO::DeviceType detectDeviceTypeWithFingerprint() {
// Explicit override for recovery/support:
// 0 = auto, 1 = force X4, 2 = force X3
const NvsDeviceValue overrideValue = readNvsDeviceValue(NVS_KEY_DEV_OVERRIDE, NvsDeviceValue::Unknown);
if (overrideValue == NvsDeviceValue::X3 || overrideValue == NvsDeviceValue::X4) {
LOG_INF("HW", "Device override active: %s", overrideValue == NvsDeviceValue::X3 ? "X3" : "X4");
return nvsToDeviceType(overrideValue);
}
const NvsDeviceValue cachedValue = readNvsDeviceValue(NVS_KEY_DEV_CACHED, NvsDeviceValue::Unknown);
if (cachedValue == NvsDeviceValue::X3 || cachedValue == NvsDeviceValue::X4) {
LOG_INF("HW", "Using cached device type: %s", cachedValue == NvsDeviceValue::X3 ? "X3" : "X4");
return nvsToDeviceType(cachedValue);
}
// No cache yet: run active X3 fingerprint probe and persist result.
const X3GPIO::X3ProbeResult pass1 = X3GPIO::runX3ProbePass();
delay(2);
const X3GPIO::X3ProbeResult pass2 = X3GPIO::runX3ProbePass();
const uint8_t score1 = pass1.score();
const uint8_t score2 = pass2.score();
LOG_INF("HW", "X3 probe scores: pass1=%u(bq=%d rtc=%d imu=%d) pass2=%u(bq=%d rtc=%d imu=%d)", score1, pass1.bq27220,
pass1.ds3231, pass1.qmi8658, score2, pass2.bq27220, pass2.ds3231, pass2.qmi8658);
const bool x3Confirmed = (score1 >= 2) && (score2 >= 2);
const bool x4Confirmed = (score1 == 0) && (score2 == 0);
if (x3Confirmed) {
writeNvsDeviceValue(NVS_KEY_DEV_CACHED, NvsDeviceValue::X3);
return HalGPIO::DeviceType::X3;
}
if (x4Confirmed) {
writeNvsDeviceValue(NVS_KEY_DEV_CACHED, NvsDeviceValue::X4);
return HalGPIO::DeviceType::X4;
}
// Conservative fallback for first boot with inconclusive probes.
return HalGPIO::DeviceType::X4;
}
} // namespace
void HalGPIO::begin() { void HalGPIO::begin() {
inputMgr.begin(); inputMgr.begin();
SPI.begin(EPD_SCLK, SPI_MISO, EPD_MOSI, EPD_CS); SPI.begin(EPD_SCLK, SPI_MISO, EPD_MOSI, EPD_CS);
_deviceType = detectDeviceTypeWithFingerprint();
if (deviceIsX4()) {
pinMode(BAT_GPIO0, INPUT);
pinMode(UART0_RXD, INPUT); pinMode(UART0_RXD, INPUT);
} }
}
void HalGPIO::update() { void HalGPIO::update() {
inputMgr.update(); inputMgr.update();
@@ -28,16 +223,73 @@ bool HalGPIO::wasAnyReleased() const { return inputMgr.wasAnyReleased(); }
unsigned long HalGPIO::getHeldTime() const { return inputMgr.getHeldTime(); } unsigned long HalGPIO::getHeldTime() const { return inputMgr.getHeldTime(); }
void HalGPIO::startDeepSleep() {
// Ensure that the power button has been released to avoid immediately turning back on if you're holding it
while (inputMgr.isPressed(BTN_POWER)) {
delay(50);
inputMgr.update();
}
// Arm the wakeup trigger *after* the button is released
esp_deep_sleep_enable_gpio_wakeup(1ULL << InputManager::POWER_BUTTON_PIN, ESP_GPIO_WAKEUP_GPIO_LOW);
// Enter Deep Sleep
esp_deep_sleep_start();
}
void HalGPIO::verifyPowerButtonWakeup(uint16_t requiredDurationMs, bool shortPressAllowed) {
if (shortPressAllowed) {
// Fast path - no duration check needed
return;
}
// TODO: Intermittent edge case remains: a single tap followed by another single tap
// can still power on the device. Tighten wake debounce/state handling here.
// Calibrate: subtract boot time already elapsed, assuming button held since boot
const uint16_t calibration = millis();
const uint16_t calibratedDuration = (calibration < requiredDurationMs) ? (requiredDurationMs - calibration) : 1;
const auto start = millis();
inputMgr.update();
// inputMgr.isPressed() may take up to ~500ms to return correct state
while (!inputMgr.isPressed(BTN_POWER) && millis() - start < 1000) {
delay(10);
inputMgr.update();
}
if (inputMgr.isPressed(BTN_POWER)) {
do {
delay(10);
inputMgr.update();
} while (inputMgr.isPressed(BTN_POWER) && inputMgr.getHeldTime() < calibratedDuration);
if (inputMgr.getHeldTime() < calibratedDuration) {
startDeepSleep();
}
} else {
startDeepSleep();
}
}
bool HalGPIO::isUsbConnected() const { bool HalGPIO::isUsbConnected() const {
if (deviceIsX3()) {
// X3: infer USB/charging via BQ27220 Current() register (0x0C, signed mA).
// Positive current means charging.
for (uint8_t attempt = 0; attempt < 2; ++attempt) {
int16_t currentMa = 0;
if (X3GPIO::readBQ27220CurrentMA(&currentMa)) {
return currentMa > 0;
}
delay(2);
}
return false;
}
// U0RXD/GPIO20 reads HIGH when USB is connected // U0RXD/GPIO20 reads HIGH when USB is connected
return digitalRead(UART0_RXD) == HIGH; return digitalRead(UART0_RXD) == HIGH;
} }
HalGPIO::WakeupReason HalGPIO::getWakeupReason() const { HalGPIO::WakeupReason HalGPIO::getWakeupReason() const {
const bool usbConnected = isUsbConnected();
const auto wakeupCause = esp_sleep_get_wakeup_cause(); const auto wakeupCause = esp_sleep_get_wakeup_cause();
const auto resetReason = esp_reset_reason(); const auto resetReason = esp_reset_reason();
const bool usbConnected = isUsbConnected();
if ((wakeupCause == ESP_SLEEP_WAKEUP_UNDEFINED && resetReason == ESP_RST_POWERON && !usbConnected) || if ((wakeupCause == ESP_SLEEP_WAKEUP_UNDEFINED && resetReason == ESP_RST_POWERON && !usbConnected) ||
(wakeupCause == ESP_SLEEP_WAKEUP_GPIO && resetReason == ESP_RST_DEEPSLEEP && usbConnected)) { (wakeupCause == ESP_SLEEP_WAKEUP_GPIO && resetReason == ESP_RST_DEEPSLEEP && usbConnected)) {
return WakeupReason::PowerButton; return WakeupReason::PowerButton;

View File

@@ -1,7 +1,6 @@
#pragma once #pragma once
#include <Arduino.h> #include <Arduino.h>
#include <BatteryMonitor.h>
#include <InputManager.h> #include <InputManager.h>
// Display SPI pins (custom pins for XteinkX4, not hardware SPI defaults) // Display SPI pins (custom pins for XteinkX4, not hardware SPI defaults)
@@ -18,6 +17,27 @@
#define UART0_RXD 20 // Used for USB connection detection #define UART0_RXD 20 // Used for USB connection detection
// Xteink X3 Hardware
#define X3_I2C_SDA 20
#define X3_I2C_SCL 0
#define X3_I2C_FREQ 400000
// TI BQ27220 Fuel gauge I2C
#define I2C_ADDR_BQ27220 0x55 // Fuel gauge I2C address
#define BQ27220_SOC_REG 0x2C // StateOfCharge() command code (%)
#define BQ27220_CUR_REG 0x0C // Current() command code (signed mA)
#define BQ27220_VOLT_REG 0x08 // Voltage() command code (mV)
// Analog DS3231 RTC I2C
#define I2C_ADDR_DS3231 0x68 // RTC I2C address
#define DS3231_SEC_REG 0x00 // Seconds command code (BCD)
// QST QMI8658 IMU I2C
#define I2C_ADDR_QMI8658 0x6B // IMU I2C address
#define I2C_ADDR_QMI8658_ALT 0x6A // IMU I2C fallback address
#define QMI8658_WHO_AM_I_REG 0x00 // WHO_AM_I command code
#define QMI8658_WHO_AM_I_VALUE 0x05 // WHO_AM_I expected value
class HalGPIO { class HalGPIO {
#if CROSSPOINT_EMULATED == 0 #if CROSSPOINT_EMULATED == 0
InputManager inputMgr; InputManager inputMgr;
@@ -26,9 +46,19 @@ class HalGPIO {
bool lastUsbConnected = false; bool lastUsbConnected = false;
bool usbStateChanged = false; bool usbStateChanged = false;
public:
enum class DeviceType : uint8_t { X4, X3 };
private:
DeviceType _deviceType = DeviceType::X4;
public: public:
HalGPIO() = default; HalGPIO() = default;
// Inline device type helpers for cleaner downstream checks
inline bool deviceIsX3() const { return _deviceType == DeviceType::X3; }
inline bool deviceIsX4() const { return _deviceType == DeviceType::X4; }
// Start button GPIO and setup SPI for screen and SD card // Start button GPIO and setup SPI for screen and SD card
void begin(); void begin();
@@ -41,6 +71,14 @@ class HalGPIO {
bool wasAnyReleased() const; bool wasAnyReleased() const;
unsigned long getHeldTime() const; unsigned long getHeldTime() const;
// Setup wake up GPIO and enter deep sleep
void startDeepSleep();
// Verify power button was held long enough after wakeup.
// If verification fails, enters deep sleep and does not return.
// Should only be called when wakeup reason is PowerButton.
void verifyPowerButtonWakeup(uint16_t requiredDurationMs, bool shortPressAllowed);
// Check if USB is connected // Check if USB is connected
bool isUsbConnected() const; bool isUsbConnected() const;
@@ -61,4 +99,4 @@ class HalGPIO {
static constexpr uint8_t BTN_POWER = 6; static constexpr uint8_t BTN_POWER = 6;
}; };
extern HalGPIO gpio; // Singleton extern HalGPIO gpio;

View File

@@ -11,7 +11,15 @@
HalPowerManager powerManager; // Singleton instance HalPowerManager powerManager; // Singleton instance
void HalPowerManager::begin() { void HalPowerManager::begin() {
if (gpio.deviceIsX3()) {
// X3 uses an I2C fuel gauge for battery monitoring.
// I2C init must come AFTER gpio.begin() so early hardware detection/probes are finished.
Wire.begin(X3_I2C_SDA, X3_I2C_SCL, X3_I2C_FREQ);
Wire.setTimeOut(4);
_batteryUseI2C = true;
} else {
pinMode(BAT_GPIO0, INPUT); pinMode(BAT_GPIO0, INPUT);
}
normalFreq = getCpuFrequencyMhz(); normalFreq = getCpuFrequencyMhz();
modeMutex = xSemaphoreCreateMutex(); modeMutex = xSemaphoreCreateMutex();
assert(modeMutex != nullptr); assert(modeMutex != nullptr);
@@ -78,8 +86,35 @@ void HalPowerManager::startDeepSleep(HalGPIO& gpio) const {
} }
uint16_t HalPowerManager::getBatteryPercentage() const { uint16_t HalPowerManager::getBatteryPercentage() const {
if (_batteryUseI2C) {
const unsigned long now = millis();
if (_batteryLastPollMs != 0 && (now - _batteryLastPollMs) < BATTERY_POLL_MS) {
return _batteryCachedPercent;
}
// Read SOC directly from I2C fuel gauge (16-bit LE register).
// On I2C error, keep last known value to avoid UI jitter/slowdowns.
Wire.beginTransmission(I2C_ADDR_BQ27220);
Wire.write(BQ27220_SOC_REG);
if (Wire.endTransmission(false) != 0) {
_batteryLastPollMs = now;
return _batteryCachedPercent;
}
Wire.requestFrom(I2C_ADDR_BQ27220, (uint8_t)2);
if (Wire.available() < 2) {
_batteryLastPollMs = now;
return _batteryCachedPercent;
}
const uint8_t lo = Wire.read();
const uint8_t hi = Wire.read();
const uint16_t soc = (hi << 8) | lo;
_batteryCachedPercent = soc > 100 ? 100 : soc;
_batteryLastPollMs = now;
return _batteryCachedPercent;
}
static const BatteryMonitor battery = BatteryMonitor(BAT_GPIO0); static const BatteryMonitor battery = BatteryMonitor(BAT_GPIO0);
return battery.readPercentage(); _batteryCachedPercent = battery.readPercentage();
return _batteryCachedPercent;
} }
HalPowerManager::Lock::Lock() { HalPowerManager::Lock::Lock() {

View File

@@ -1,8 +1,10 @@
#pragma once #pragma once
#include <Arduino.h> #include <Arduino.h>
#include <BatteryMonitor.h>
#include <InputManager.h> #include <InputManager.h>
#include <Logging.h> #include <Logging.h>
#include <Wire.h>
#include <freertos/semphr.h> #include <freertos/semphr.h>
#include <cassert> #include <cassert>
@@ -16,6 +18,11 @@ class HalPowerManager {
int normalFreq = 0; // MHz int normalFreq = 0; // MHz
bool isLowPower = false; bool isLowPower = false;
// I2C fuel gauge configuration for X3 battery monitoring
bool _batteryUseI2C = false; // True if using I2C fuel gauge (X3), false for ADC (X4)
mutable int _batteryCachedPercent = 0; // Last read battery percentage (0-100)
mutable unsigned long _batteryLastPollMs = 0; // Timestamp of last battery read in milliseconds
enum LockMode { None, NormalSpeed }; enum LockMode { None, NormalSpeed };
LockMode currentLockMode = None; LockMode currentLockMode = None;
SemaphoreHandle_t modeMutex = nullptr; // Protect access to currentLockMode SemaphoreHandle_t modeMutex = nullptr; // Protect access to currentLockMode
@@ -23,6 +30,7 @@ class HalPowerManager {
public: public:
static constexpr int LOW_POWER_FREQ = 10; // MHz static constexpr int LOW_POWER_FREQ = 10; // MHz
static constexpr unsigned long IDLE_POWER_SAVING_MS = 3000; // ms static constexpr unsigned long IDLE_POWER_SAVING_MS = 3000; // ms
static constexpr unsigned long BATTERY_POLL_MS = 1500; // ms
void begin(); void begin();

View File

@@ -138,7 +138,7 @@ bool HomeActivity::storeCoverBuffer() {
// Free any existing buffer first // Free any existing buffer first
freeCoverBuffer(); freeCoverBuffer();
const size_t bufferSize = GfxRenderer::getBufferSize(); const size_t bufferSize = renderer.getBufferSize();
coverBuffer = static_cast<uint8_t*>(malloc(bufferSize)); coverBuffer = static_cast<uint8_t*>(malloc(bufferSize));
if (!coverBuffer) { if (!coverBuffer) {
return false; return false;
@@ -158,7 +158,7 @@ bool HomeActivity::restoreCoverBuffer() {
return false; return false;
} }
const size_t bufferSize = GfxRenderer::getBufferSize(); const size_t bufferSize = renderer.getBufferSize();
memcpy(frameBuffer, coverBuffer, bufferSize); memcpy(frameBuffer, coverBuffer, bufferSize);
return true; return true;
} }

View File

@@ -140,7 +140,10 @@ void BaseTheme::drawButtonHints(GfxRenderer& renderer, const char* btn1, const c
constexpr int buttonHeight = BaseMetrics::values.buttonHintsHeight; constexpr int buttonHeight = BaseMetrics::values.buttonHintsHeight;
constexpr int buttonY = BaseMetrics::values.buttonHintsHeight; // Distance from bottom constexpr int buttonY = BaseMetrics::values.buttonHintsHeight; // Distance from bottom
constexpr int textYOffset = 7; // Distance from top of button to text baseline constexpr int textYOffset = 7; // Distance from top of button to text baseline
constexpr int buttonPositions[] = {25, 130, 245, 350}; // X3 has wider screen in portrait (528 vs 480), use more spacing
constexpr int x4ButtonPositions[] = {25, 130, 245, 350};
constexpr int x3ButtonPositions[] = {38, 154, 268, 384};
const int* buttonPositions = gpio.deviceIsX3() ? x3ButtonPositions : x4ButtonPositions;
const char* labels[] = {btn1, btn2, btn3, btn4}; const char* labels[] = {btn1, btn2, btn3, btn4};
for (int i = 0; i < 4; i++) { for (int i = 0; i < 4; i++) {
@@ -162,53 +165,66 @@ void BaseTheme::drawSideButtonHints(const GfxRenderer& renderer, const char* top
const int screenWidth = renderer.getScreenWidth(); const int screenWidth = renderer.getScreenWidth();
constexpr int buttonWidth = BaseMetrics::values.sideButtonHintsWidth; // Width on screen (height when rotated) constexpr int buttonWidth = BaseMetrics::values.sideButtonHintsWidth; // Width on screen (height when rotated)
constexpr int buttonHeight = 80; // Height on screen (width when rotated) constexpr int buttonHeight = 80; // Height on screen (width when rotated)
constexpr int buttonX = 4; // Distance from right edge constexpr int buttonMargin = 4;
// Position for the button group - buttons share a border so they're adjacent
constexpr int topButtonY = 345; // Top button position
const char* labels[] = {topBtn, bottomBtn}; if (gpio.deviceIsX3()) {
// X3 layout: Up on left side, Down on right side, positioned higher
constexpr int x3ButtonY = 155;
// Draw the shared border for both buttons as one unit
const int x = screenWidth - buttonX - buttonWidth;
// Draw top button outline (3 sides, bottom open)
if (topBtn != nullptr && topBtn[0] != '\0') { if (topBtn != nullptr && topBtn[0] != '\0') {
renderer.drawLine(x, topButtonY, x + buttonWidth - 1, topButtonY); // Top const int leftX = buttonMargin;
renderer.drawLine(x, topButtonY, x, topButtonY + buttonHeight - 1); // Left renderer.drawRect(leftX, x3ButtonY, buttonWidth, buttonHeight);
renderer.drawLine(x + buttonWidth - 1, topButtonY, x + buttonWidth - 1, topButtonY + buttonHeight - 1); // Right const int textWidth = renderer.getTextWidth(SMALL_FONT_ID, topBtn);
const int textHeight = renderer.getTextHeight(SMALL_FONT_ID);
const int textX = leftX + (buttonWidth - textHeight) / 2;
const int textY = x3ButtonY + (buttonHeight + textWidth) / 2;
renderer.drawTextRotated90CW(SMALL_FONT_ID, textX, textY, topBtn);
} }
// Draw shared middle border
if ((topBtn != nullptr && topBtn[0] != '\0') || (bottomBtn != nullptr && bottomBtn[0] != '\0')) {
renderer.drawLine(x, topButtonY + buttonHeight, x + buttonWidth - 1, topButtonY + buttonHeight); // Shared border
}
// Draw bottom button outline (3 sides, top is shared)
if (bottomBtn != nullptr && bottomBtn[0] != '\0') { if (bottomBtn != nullptr && bottomBtn[0] != '\0') {
renderer.drawLine(x, topButtonY + buttonHeight, x, topButtonY + 2 * buttonHeight - 1); // Left const int rightX = screenWidth - buttonMargin - buttonWidth;
renderer.drawLine(x + buttonWidth - 1, topButtonY + buttonHeight, x + buttonWidth - 1, renderer.drawRect(rightX, x3ButtonY, buttonWidth, buttonHeight);
topButtonY + 2 * buttonHeight - 1); // Right const int textWidth = renderer.getTextWidth(SMALL_FONT_ID, bottomBtn);
renderer.drawLine(x, topButtonY + 2 * buttonHeight - 1, x + buttonWidth - 1, const int textHeight = renderer.getTextHeight(SMALL_FONT_ID);
topButtonY + 2 * buttonHeight - 1); // Bottom const int textX = rightX + (buttonWidth - textHeight) / 2;
const int textY = x3ButtonY + (buttonHeight + textWidth) / 2;
renderer.drawTextRotated90CW(SMALL_FONT_ID, textX, textY, bottomBtn);
}
} else {
// X4 layout: Both buttons stacked on right side
constexpr int topButtonY = 345;
const char* labels[] = {topBtn, bottomBtn};
const int x = screenWidth - buttonMargin - buttonWidth;
if (topBtn != nullptr && topBtn[0] != '\0') {
renderer.drawLine(x, topButtonY, x + buttonWidth - 1, topButtonY);
renderer.drawLine(x, topButtonY, x, topButtonY + buttonHeight - 1);
renderer.drawLine(x + buttonWidth - 1, topButtonY, x + buttonWidth - 1, topButtonY + buttonHeight - 1);
}
if ((topBtn != nullptr && topBtn[0] != '\0') || (bottomBtn != nullptr && bottomBtn[0] != '\0')) {
renderer.drawLine(x, topButtonY + buttonHeight, x + buttonWidth - 1, topButtonY + buttonHeight);
}
if (bottomBtn != nullptr && bottomBtn[0] != '\0') {
renderer.drawLine(x, topButtonY + buttonHeight, x, topButtonY + 2 * buttonHeight - 1);
renderer.drawLine(x + buttonWidth - 1, topButtonY + buttonHeight, x + buttonWidth - 1,
topButtonY + 2 * buttonHeight - 1);
renderer.drawLine(x, topButtonY + 2 * buttonHeight - 1, x + buttonWidth - 1, topButtonY + 2 * buttonHeight - 1);
} }
// Draw text for each button
for (int i = 0; i < 2; i++) { for (int i = 0; i < 2; i++) {
if (labels[i] != nullptr && labels[i][0] != '\0') { if (labels[i] != nullptr && labels[i][0] != '\0') {
const int y = topButtonY + i * buttonHeight; const int y = topButtonY + i * buttonHeight;
// Draw rotated text centered in the button
const int textWidth = renderer.getTextWidth(SMALL_FONT_ID, labels[i]); const int textWidth = renderer.getTextWidth(SMALL_FONT_ID, labels[i]);
const int textHeight = renderer.getTextHeight(SMALL_FONT_ID); const int textHeight = renderer.getTextHeight(SMALL_FONT_ID);
// Center the rotated text in the button
const int textX = x + (buttonWidth - textHeight) / 2; const int textX = x + (buttonWidth - textHeight) / 2;
const int textY = y + (buttonHeight + textWidth) / 2; const int textY = y + (buttonHeight + textWidth) / 2;
renderer.drawTextRotated90CW(SMALL_FONT_ID, textX, textY, labels[i]); renderer.drawTextRotated90CW(SMALL_FONT_ID, textX, textY, labels[i]);
} }
} }
} }
}
void BaseTheme::drawList(const GfxRenderer& renderer, Rect rect, int itemCount, int selectedIndex, void BaseTheme::drawList(const GfxRenderer& renderer, Rect rect, int itemCount, int selectedIndex,
const std::function<std::string(int index)>& rowTitle, const std::function<std::string(int index)>& rowTitle,

View File

@@ -342,7 +342,10 @@ void LyraTheme::drawButtonHints(GfxRenderer& renderer, const char* btn1, const c
constexpr int buttonHeight = LyraMetrics::values.buttonHintsHeight; constexpr int buttonHeight = LyraMetrics::values.buttonHintsHeight;
constexpr int buttonY = LyraMetrics::values.buttonHintsHeight; // Distance from bottom constexpr int buttonY = LyraMetrics::values.buttonHintsHeight; // Distance from bottom
constexpr int textYOffset = 7; // Distance from top of button to text baseline constexpr int textYOffset = 7; // Distance from top of button to text baseline
constexpr int buttonPositions[] = {58, 146, 254, 342}; // X3 has wider screen in portrait (528 vs 480), use more spacing
constexpr int x4ButtonPositions[] = {58, 146, 254, 342};
constexpr int x3ButtonPositions[] = {65, 157, 291, 383};
const int* buttonPositions = gpio.deviceIsX3() ? x3ButtonPositions : x4ButtonPositions;
const char* labels[] = {btn1, btn2, btn3, btn4}; const char* labels[] = {btn1, btn2, btn3, btn4};
for (int i = 0; i < 4; i++) { for (int i = 0; i < 4; i++) {
@@ -371,37 +374,50 @@ void LyraTheme::drawSideButtonHints(const GfxRenderer& renderer, const char* top
const int screenWidth = renderer.getScreenWidth(); const int screenWidth = renderer.getScreenWidth();
constexpr int buttonWidth = LyraMetrics::values.sideButtonHintsWidth; // Width on screen (height when rotated) constexpr int buttonWidth = LyraMetrics::values.sideButtonHintsWidth; // Width on screen (height when rotated)
constexpr int buttonHeight = 78; // Height on screen (width when rotated) constexpr int buttonHeight = 78; // Height on screen (width when rotated)
// Position for the button group - buttons share a border so they're adjacent constexpr int buttonMargin = 0;
if (gpio.deviceIsX3()) {
// X3 layout: Up on left side, Down on right side, positioned higher
constexpr int x3ButtonY = 155;
if (topBtn != nullptr && topBtn[0] != '\0') {
renderer.drawRoundedRect(buttonMargin, x3ButtonY, buttonWidth, buttonHeight, 1, cornerRadius, false, true, false,
true, true);
const int textWidth = renderer.getTextWidth(SMALL_FONT_ID, topBtn);
renderer.drawTextRotated90CW(SMALL_FONT_ID, buttonMargin, x3ButtonY + (buttonHeight + textWidth) / 2, topBtn);
}
if (bottomBtn != nullptr && bottomBtn[0] != '\0') {
const int rightX = screenWidth - buttonWidth;
renderer.drawRoundedRect(rightX, x3ButtonY, buttonWidth, buttonHeight, 1, cornerRadius, true, false, true, false,
true);
const int textWidth = renderer.getTextWidth(SMALL_FONT_ID, bottomBtn);
renderer.drawTextRotated90CW(SMALL_FONT_ID, rightX, x3ButtonY + (buttonHeight + textWidth) / 2, bottomBtn);
}
} else {
// X4 layout: Both buttons stacked on right side
const char* labels[] = {topBtn, bottomBtn}; const char* labels[] = {topBtn, bottomBtn};
// Draw the shared border for both buttons as one unit
const int x = screenWidth - buttonWidth; const int x = screenWidth - buttonWidth;
// Draw top button outline
if (topBtn != nullptr && topBtn[0] != '\0') { if (topBtn != nullptr && topBtn[0] != '\0') {
renderer.drawRoundedRect(x, topHintButtonY, buttonWidth, buttonHeight, 1, cornerRadius, true, false, true, false, renderer.drawRoundedRect(x, topHintButtonY, buttonWidth, buttonHeight, 1, cornerRadius, true, false, true, false,
true); true);
} }
// Draw bottom button outline
if (bottomBtn != nullptr && bottomBtn[0] != '\0') { if (bottomBtn != nullptr && bottomBtn[0] != '\0') {
renderer.drawRoundedRect(x, topHintButtonY + buttonHeight + 5, buttonWidth, buttonHeight, 1, cornerRadius, true, renderer.drawRoundedRect(x, topHintButtonY + buttonHeight + 5, buttonWidth, buttonHeight, 1, cornerRadius, true,
false, true, false, true); false, true, false, true);
} }
// Draw text for each button
for (int i = 0; i < 2; i++) { for (int i = 0; i < 2; i++) {
if (labels[i] != nullptr && labels[i][0] != '\0') { if (labels[i] != nullptr && labels[i][0] != '\0') {
const int y = topHintButtonY + (i * buttonHeight + 5); const int y = topHintButtonY + (i * buttonHeight) + 5;
// Draw rotated text centered in the button
const int textWidth = renderer.getTextWidth(SMALL_FONT_ID, labels[i]); const int textWidth = renderer.getTextWidth(SMALL_FONT_ID, labels[i]);
renderer.drawTextRotated90CW(SMALL_FONT_ID, x, y + (buttonHeight + textWidth) / 2, labels[i]); renderer.drawTextRotated90CW(SMALL_FONT_ID, x, y + (buttonHeight + textWidth) / 2, labels[i]);
} }
} }
} }
}
void LyraTheme::drawRecentBookCover(GfxRenderer& renderer, Rect rect, const std::vector<RecentBook>& recentBooks, void LyraTheme::drawRecentBookCover(GfxRenderer& renderer, Rect rect, const std::vector<RecentBook>& recentBooks,
const int selectorIndex, bool& coverRendered, bool& coverBufferStored, const int selectorIndex, bool& coverRendered, bool& coverBufferStored,

View File

@@ -27,8 +27,6 @@
#include "util/ButtonNavigator.h" #include "util/ButtonNavigator.h"
#include "util/ScreenshotUtil.h" #include "util/ScreenshotUtil.h"
HalDisplay display;
HalGPIO gpio;
MappedInputManager mappedInputManager(gpio); MappedInputManager mappedInputManager(gpio);
GfxRenderer renderer(display); GfxRenderer renderer(display);
ActivityManager activityManager(renderer, mappedInputManager); ActivityManager activityManager(renderer, mappedInputManager);
@@ -171,7 +169,6 @@ void verifyPowerButtonDuration() {
powerManager.startDeepSleep(gpio); powerManager.startDeepSleep(gpio);
} }
} }
void waitForPowerRelease() { void waitForPowerRelease() {
gpio.update(); gpio.update();
while (gpio.isPressed(HalGPIO::BTN_POWER)) { while (gpio.isPressed(HalGPIO::BTN_POWER)) {
@@ -189,7 +186,6 @@ void enterDeepSleep() {
activityManager.goToSleep(); activityManager.goToSleep();
display.deepSleep(); display.deepSleep();
LOG_DBG("MAIN", "Power button press calibration value: %lu ms", t2 - t1);
LOG_DBG("MAIN", "Entering deep sleep"); LOG_DBG("MAIN", "Entering deep sleep");
powerManager.startDeepSleep(gpio); powerManager.startDeepSleep(gpio);
@@ -235,15 +231,17 @@ void setup() {
gpio.begin(); gpio.begin();
powerManager.begin(); powerManager.begin();
// Only start serial if USB connected #ifdef ENABLE_SERIAL_LOG
if (gpio.isUsbConnected()) { if (gpio.isUsbConnected()) {
Serial.begin(115200); Serial.begin(115200);
// Wait up to 3 seconds for Serial to be ready to catch early logs const unsigned long start = millis();
unsigned long start = millis(); while (!Serial && (millis() - start) < 500) {
while (!Serial && (millis() - start) < 3000) {
delay(10); delay(10);
} }
} }
#endif
LOG_INF("MAIN", "Hardware detect: %s", gpio.deviceIsX3() ? "X3" : "X4");
// SD Card Initialization // SD Card Initialization
// We need 6 open files concurrently when parsing a new chapter // We need 6 open files concurrently when parsing a new chapter
@@ -263,11 +261,12 @@ void setup() {
UITheme::getInstance().reload(); UITheme::getInstance().reload();
ButtonNavigator::setMappedInputManager(mappedInputManager); ButtonNavigator::setMappedInputManager(mappedInputManager);
switch (gpio.getWakeupReason()) { const auto wakeupReason = gpio.getWakeupReason();
switch (wakeupReason) {
case HalGPIO::WakeupReason::PowerButton: case HalGPIO::WakeupReason::PowerButton:
// For normal wakeups, verify power button press duration
LOG_DBG("MAIN", "Verifying power button press duration"); LOG_DBG("MAIN", "Verifying power button press duration");
verifyPowerButtonDuration(); gpio.verifyPowerButtonWakeup(SETTINGS.getPowerButtonDuration(),
SETTINGS.shortPwrBtn == CrossPointSettings::SHORT_PWRBTN::SLEEP);
break; break;
case HalGPIO::WakeupReason::AfterUSBPower: case HalGPIO::WakeupReason::AfterUSBPower:
// If USB power caused a cold boot, go back to sleep // If USB power caused a cold boot, go back to sleep
@@ -332,9 +331,10 @@ void loop() {
String cmd = line.substring(4); String cmd = line.substring(4);
cmd.trim(); cmd.trim();
if (cmd == "SCREENSHOT") { if (cmd == "SCREENSHOT") {
logSerial.printf("SCREENSHOT_START:%d\n", HalDisplay::BUFFER_SIZE); const uint32_t bufferSize = display.getBufferSize();
logSerial.printf("SCREENSHOT_START:%d\n", bufferSize);
uint8_t* buf = display.getFrameBuffer(); uint8_t* buf = display.getFrameBuffer();
logSerial.write(buf, HalDisplay::BUFFER_SIZE); logSerial.write(buf, bufferSize);
logSerial.printf("SCREENSHOT_END\n"); logSerial.printf("SCREENSHOT_END\n");
} }
} }