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 <FontDecompressor.h>
#include <HalGPIO.h>
#include <Logging.h>
#include <Utf8.h>
@@ -28,6 +29,11 @@ void GfxRenderer::begin() {
LOG_ERR("GFX", "!! No framebuffer");
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}); }
@@ -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
// This should always be inlined for better performance
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) {
case GfxRenderer::Portrait: {
// Logical portrait (480x800) → panel (800x480)
// Rotation: 90 degrees clockwise
*phyX = y;
*phyY = HalDisplay::DISPLAY_HEIGHT - 1 - x;
*phyY = panelHeight - 1 - x;
break;
}
case GfxRenderer::LandscapeClockwise: {
// Logical landscape (800x480) rotated 180 degrees (swap top/bottom and left/right)
*phyX = HalDisplay::DISPLAY_WIDTH - 1 - x;
*phyY = HalDisplay::DISPLAY_HEIGHT - 1 - y;
*phyX = panelWidth - 1 - x;
*phyY = panelHeight - 1 - y;
break;
}
case GfxRenderer::PortraitInverted: {
// Logical portrait (480x800) → panel (800x480)
// Rotation: 90 degrees counter-clockwise
*phyX = HalDisplay::DISPLAY_WIDTH - 1 - y;
*phyX = panelWidth - 1 - y;
*phyY = x;
break;
}
@@ -125,8 +131,9 @@ static void renderCharImpl(const GfxRenderer& renderer, GfxRenderer::RenderMode
if (renderMode == GfxRenderer::BW && bmpVal < 3) {
// Black (also paints over the grays in BW mode)
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)
// 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
renderer.drawPixel(screenX, screenY, false);
} 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;
// 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
if (phyX < 0 || phyX >= HalDisplay::DISPLAY_WIDTH || phyY < 0 || phyY >= HalDisplay::DISPLAY_HEIGHT) {
// Bounds checking against runtime panel dimensions
if (phyX < 0 || phyX >= panelWidth || phyY < 0 || phyY >= panelHeight) {
LOG_ERR("GFX", "!! Outside range (%d, %d) -> (%d, %d)", x, y, phyX, phyY);
return;
}
// 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
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 {
int rotatedX = 0;
int rotatedY = 0;
rotateCoordinates(orientation, x, y, &rotatedX, &rotatedY);
rotateCoordinates(orientation, x, y, &rotatedX, &rotatedY, panelWidth, panelHeight);
// Rotate origin corner
switch (orientation) {
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,
bitmap.isTopDown() ? "top-down" : "bottom-up");
if (maxWidth > 0 && (1.0f - cropX) * bitmap.getWidth() > maxWidth) {
scale = static_cast<float>(maxWidth) / static_cast<float>((1.0f - cropX) * bitmap.getWidth());
isScaled = true;
const float croppedWidth = (1.0f - cropX) * static_cast<float>(bitmap.getWidth());
const float croppedHeight = (1.0f - cropY) * static_cast<float>(bitmap.getHeight());
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;
}
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) {
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);
} else if (renderMode == GRAYSCALE_LSB && val == 1) {
drawPixel(screenX, screenY, false);
@@ -822,7 +841,7 @@ void GfxRenderer::clearScreen(const uint8_t color) 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];
}
}
@@ -923,13 +942,13 @@ int GfxRenderer::getScreenWidth() const {
case Portrait:
case PortraitInverted:
// 480px wide in portrait logical coordinates
return HalDisplay::DISPLAY_HEIGHT;
return panelHeight;
case LandscapeClockwise:
case LandscapeCounterClockwise:
// 800px wide in landscape logical coordinates
return HalDisplay::DISPLAY_WIDTH;
return panelWidth;
}
return HalDisplay::DISPLAY_HEIGHT;
return panelHeight;
}
int GfxRenderer::getScreenHeight() const {
@@ -937,13 +956,13 @@ int GfxRenderer::getScreenHeight() const {
case Portrait:
case PortraitInverted:
// 800px tall in portrait logical coordinates
return HalDisplay::DISPLAY_WIDTH;
return panelWidth;
case LandscapeClockwise:
case LandscapeCounterClockwise:
// 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 {
@@ -1095,7 +1114,7 @@ void GfxRenderer::drawTextRotated90CW(const int fontId, const int x, const int y
uint8_t* GfxRenderer::getFrameBuffer() const { return frameBuffer; }
size_t GfxRenderer::getBufferSize() { return HalDisplay::BUFFER_SIZE; }
size_t GfxRenderer::getBufferSize() const { return frameBufferSize; }
// unused
// void GfxRenderer::grayscaleRevert() const { display.grayscaleRevert(); }
@@ -1123,7 +1142,7 @@ void GfxRenderer::freeBwBufferChunks() {
*/
bool GfxRenderer::storeBwBuffer() {
// 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
if (bwBufferChunks[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;
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]) {
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
freeBwBufferChunks();
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;
}
@@ -1168,9 +1188,10 @@ void GfxRenderer::restoreBwBuffer() {
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;
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);