A fork of https://github.com/crosspoint-reader/crosspoint-reader
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

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.

authored by

Justin Mitchell and committed by
GitHub
9b388513 e6c6e72a

+561 -129
+1 -1
lib/Epub/Epub/converters/ImageToFramebufferDecoder.h
··· 37 37 38 38 bool validateImageDimensions(int width, int height, const std::string& format); 39 39 void warnUnsupportedFeature(const std::string& feature, const std::string& imagePath); 40 - }; 40 + };
+53 -32
lib/GfxRenderer/GfxRenderer.cpp
··· 1 1 #include "GfxRenderer.h" 2 2 3 3 #include <FontDecompressor.h> 4 + #include <HalGPIO.h> 4 5 #include <Logging.h> 5 6 #include <Utf8.h> 6 7 ··· 28 29 LOG_ERR("GFX", "!! No framebuffer"); 29 30 assert(false); 30 31 } 32 + panelWidth = display.getDisplayWidth(); 33 + panelHeight = display.getDisplayHeight(); 34 + panelWidthBytes = display.getDisplayWidthBytes(); 35 + frameBufferSize = display.getBufferSize(); 36 + bwBufferChunks.assign((frameBufferSize + BW_BUFFER_CHUNK_SIZE - 1) / BW_BUFFER_CHUNK_SIZE, nullptr); 31 37 } 32 38 33 39 void GfxRenderer::insertFont(const int fontId, EpdFontFamily font) { fontMap.insert({fontId, font}); } ··· 35 41 // Translate logical (x,y) coordinates to physical panel coordinates based on current orientation 36 42 // This should always be inlined for better performance 37 43 static inline void rotateCoordinates(const GfxRenderer::Orientation orientation, const int x, const int y, int* phyX, 38 - int* phyY) { 44 + int* phyY, const uint16_t panelWidth, const uint16_t panelHeight) { 39 45 switch (orientation) { 40 46 case GfxRenderer::Portrait: { 41 47 // Logical portrait (480x800) → panel (800x480) 42 48 // Rotation: 90 degrees clockwise 43 49 *phyX = y; 44 - *phyY = HalDisplay::DISPLAY_HEIGHT - 1 - x; 50 + *phyY = panelHeight - 1 - x; 45 51 break; 46 52 } 47 53 case GfxRenderer::LandscapeClockwise: { 48 54 // Logical landscape (800x480) rotated 180 degrees (swap top/bottom and left/right) 49 - *phyX = HalDisplay::DISPLAY_WIDTH - 1 - x; 50 - *phyY = HalDisplay::DISPLAY_HEIGHT - 1 - y; 55 + *phyX = panelWidth - 1 - x; 56 + *phyY = panelHeight - 1 - y; 51 57 break; 52 58 } 53 59 case GfxRenderer::PortraitInverted: { 54 60 // Logical portrait (480x800) → panel (800x480) 55 61 // Rotation: 90 degrees counter-clockwise 56 - *phyX = HalDisplay::DISPLAY_WIDTH - 1 - y; 62 + *phyX = panelWidth - 1 - y; 57 63 *phyY = x; 58 64 break; 59 65 } ··· 125 131 if (renderMode == GfxRenderer::BW && bmpVal < 3) { 126 132 // Black (also paints over the grays in BW mode) 127 133 renderer.drawPixel(screenX, screenY, pixelState); 128 - } else if (renderMode == GfxRenderer::GRAYSCALE_MSB && (bmpVal == 1 || bmpVal == 2)) { 134 + } else if (renderMode == GfxRenderer::GRAYSCALE_MSB && (bmpVal == 1 || (gpio.deviceIsX4() && bmpVal == 2))) { 129 135 // Light gray (also mark the MSB if it's going to be a dark gray too) 136 + // X3 AA tuning: keep only the darker antialias level to avoid washed text 130 137 // We have to flag pixels in reverse for the gray buffers, as 0 leave alone, 1 update 131 138 renderer.drawPixel(screenX, screenY, false); 132 139 } else if (renderMode == GfxRenderer::GRAYSCALE_LSB && bmpVal == 1) { ··· 168 175 int phyY = 0; 169 176 170 177 // Note: this call should be inlined for better performance 171 - rotateCoordinates(orientation, x, y, &phyX, &phyY); 178 + rotateCoordinates(orientation, x, y, &phyX, &phyY, panelWidth, panelHeight); 172 179 173 - // Bounds checking against physical panel dimensions 174 - if (phyX < 0 || phyX >= HalDisplay::DISPLAY_WIDTH || phyY < 0 || phyY >= HalDisplay::DISPLAY_HEIGHT) { 180 + // Bounds checking against runtime panel dimensions 181 + if (phyX < 0 || phyX >= panelWidth || phyY < 0 || phyY >= panelHeight) { 175 182 LOG_ERR("GFX", "!! Outside range (%d, %d) -> (%d, %d)", x, y, phyX, phyY); 176 183 return; 177 184 } 178 185 179 186 // Calculate byte position and bit position 180 - const uint16_t byteIndex = phyY * HalDisplay::DISPLAY_WIDTH_BYTES + (phyX / 8); 187 + const uint32_t byteIndex = static_cast<uint32_t>(phyY) * panelWidthBytes + (phyX / 8); 181 188 const uint8_t bitPosition = 7 - (phyX % 8); // MSB first 182 189 183 190 if (state) { ··· 556 563 void GfxRenderer::drawImage(const uint8_t bitmap[], const int x, const int y, const int width, const int height) const { 557 564 int rotatedX = 0; 558 565 int rotatedY = 0; 559 - rotateCoordinates(orientation, x, y, &rotatedX, &rotatedY); 566 + rotateCoordinates(orientation, x, y, &rotatedX, &rotatedY, panelWidth, panelHeight); 560 567 // Rotate origin corner 561 568 switch (orientation) { 562 569 case Portrait: ··· 596 603 LOG_DBG("GFX", "Cropping %dx%d by %dx%d pix, is %s", bitmap.getWidth(), bitmap.getHeight(), cropPixX, cropPixY, 597 604 bitmap.isTopDown() ? "top-down" : "bottom-up"); 598 605 599 - if (maxWidth > 0 && (1.0f - cropX) * bitmap.getWidth() > maxWidth) { 600 - scale = static_cast<float>(maxWidth) / static_cast<float>((1.0f - cropX) * bitmap.getWidth()); 601 - isScaled = true; 606 + const float croppedWidth = (1.0f - cropX) * static_cast<float>(bitmap.getWidth()); 607 + const float croppedHeight = (1.0f - cropY) * static_cast<float>(bitmap.getHeight()); 608 + bool hasTargetBounds = false; 609 + float fitScale = 1.0f; 610 + 611 + if (maxWidth > 0 && croppedWidth > 0.0f) { 612 + fitScale = static_cast<float>(maxWidth) / croppedWidth; 613 + hasTargetBounds = true; 614 + } 615 + 616 + if (maxHeight > 0 && croppedHeight > 0.0f) { 617 + const float heightScale = static_cast<float>(maxHeight) / croppedHeight; 618 + fitScale = hasTargetBounds ? std::min(fitScale, heightScale) : heightScale; 619 + hasTargetBounds = true; 602 620 } 603 - if (maxHeight > 0 && (1.0f - cropY) * bitmap.getHeight() > maxHeight) { 604 - scale = std::min(scale, static_cast<float>(maxHeight) / static_cast<float>((1.0f - cropY) * bitmap.getHeight())); 621 + 622 + if (hasTargetBounds && fitScale < 1.0f) { 623 + scale = fitScale; 605 624 isScaled = true; 606 625 } 607 626 LOG_DBG("GFX", "Scaling by %f - %s", scale, isScaled ? "scaled" : "not scaled"); ··· 664 683 665 684 if (renderMode == BW && val < 3) { 666 685 drawPixel(screenX, screenY); 667 - } else if (renderMode == GRAYSCALE_MSB && (val == 1 || val == 2)) { 686 + } else if (renderMode == GRAYSCALE_MSB && (val == 1 || (gpio.deviceIsX4() && val == 2))) { 668 687 drawPixel(screenX, screenY, false); 669 688 } else if (renderMode == GRAYSCALE_LSB && val == 1) { 670 689 drawPixel(screenX, screenY, false); ··· 822 841 } 823 842 824 843 void GfxRenderer::invertScreen() const { 825 - for (int i = 0; i < HalDisplay::BUFFER_SIZE; i++) { 844 + for (uint32_t i = 0; i < frameBufferSize; i++) { 826 845 frameBuffer[i] = ~frameBuffer[i]; 827 846 } 828 847 } ··· 923 942 case Portrait: 924 943 case PortraitInverted: 925 944 // 480px wide in portrait logical coordinates 926 - return HalDisplay::DISPLAY_HEIGHT; 945 + return panelHeight; 927 946 case LandscapeClockwise: 928 947 case LandscapeCounterClockwise: 929 948 // 800px wide in landscape logical coordinates 930 - return HalDisplay::DISPLAY_WIDTH; 949 + return panelWidth; 931 950 } 932 - return HalDisplay::DISPLAY_HEIGHT; 951 + return panelHeight; 933 952 } 934 953 935 954 int GfxRenderer::getScreenHeight() const { ··· 937 956 case Portrait: 938 957 case PortraitInverted: 939 958 // 800px tall in portrait logical coordinates 940 - return HalDisplay::DISPLAY_WIDTH; 959 + return panelWidth; 941 960 case LandscapeClockwise: 942 961 case LandscapeCounterClockwise: 943 962 // 480px tall in landscape logical coordinates 944 - return HalDisplay::DISPLAY_HEIGHT; 963 + return panelHeight; 945 964 } 946 - return HalDisplay::DISPLAY_WIDTH; 965 + return panelWidth; 947 966 } 948 967 949 968 int GfxRenderer::getSpaceWidth(const int fontId, const EpdFontFamily::Style style) const { ··· 1095 1114 1096 1115 uint8_t* GfxRenderer::getFrameBuffer() const { return frameBuffer; } 1097 1116 1098 - size_t GfxRenderer::getBufferSize() { return HalDisplay::BUFFER_SIZE; } 1117 + size_t GfxRenderer::getBufferSize() const { return frameBufferSize; } 1099 1118 1100 1119 // unused 1101 1120 // void GfxRenderer::grayscaleRevert() const { display.grayscaleRevert(); } ··· 1123 1142 */ 1124 1143 bool GfxRenderer::storeBwBuffer() { 1125 1144 // Allocate and copy each chunk 1126 - for (size_t i = 0; i < BW_BUFFER_NUM_CHUNKS; i++) { 1145 + for (size_t i = 0; i < bwBufferChunks.size(); i++) { 1127 1146 // Check if any chunks are already allocated 1128 1147 if (bwBufferChunks[i]) { 1129 1148 LOG_ERR("GFX", "!! BW buffer chunk %zu already stored - this is likely a bug, freeing chunk", i); ··· 1132 1151 } 1133 1152 1134 1153 const size_t offset = i * BW_BUFFER_CHUNK_SIZE; 1135 - bwBufferChunks[i] = static_cast<uint8_t*>(malloc(BW_BUFFER_CHUNK_SIZE)); 1154 + const size_t chunkSize = std::min(BW_BUFFER_CHUNK_SIZE, static_cast<size_t>(frameBufferSize - offset)); 1155 + bwBufferChunks[i] = static_cast<uint8_t*>(malloc(chunkSize)); 1136 1156 1137 1157 if (!bwBufferChunks[i]) { 1138 - LOG_ERR("GFX", "!! Failed to allocate BW buffer chunk %zu (%zu bytes)", i, BW_BUFFER_CHUNK_SIZE); 1158 + LOG_ERR("GFX", "!! Failed to allocate BW buffer chunk %zu (%zu bytes)", i, chunkSize); 1139 1159 // Free previously allocated chunks 1140 1160 freeBwBufferChunks(); 1141 1161 return false; 1142 1162 } 1143 1163 1144 - memcpy(bwBufferChunks[i], frameBuffer + offset, BW_BUFFER_CHUNK_SIZE); 1164 + memcpy(bwBufferChunks[i], frameBuffer + offset, chunkSize); 1145 1165 } 1146 1166 1147 - LOG_DBG("GFX", "Stored BW buffer in %zu chunks (%zu bytes each)", BW_BUFFER_NUM_CHUNKS, BW_BUFFER_CHUNK_SIZE); 1167 + LOG_DBG("GFX", "Stored BW buffer in %zu chunks (%zu bytes each)", bwBufferChunks.size(), BW_BUFFER_CHUNK_SIZE); 1148 1168 return true; 1149 1169 } 1150 1170 ··· 1168 1188 return; 1169 1189 } 1170 1190 1171 - for (size_t i = 0; i < BW_BUFFER_NUM_CHUNKS; i++) { 1191 + for (size_t i = 0; i < bwBufferChunks.size(); i++) { 1172 1192 const size_t offset = i * BW_BUFFER_CHUNK_SIZE; 1173 - memcpy(frameBuffer + offset, bwBufferChunks[i], BW_BUFFER_CHUNK_SIZE); 1193 + const size_t chunkSize = std::min(BW_BUFFER_CHUNK_SIZE, static_cast<size_t>(frameBufferSize - offset)); 1194 + memcpy(frameBuffer + offset, bwBufferChunks[i], chunkSize); 1174 1195 } 1175 1196 1176 1197 display.cleanupGrayscaleBuffers(frameBuffer);
+6 -5
lib/GfxRenderer/GfxRenderer.h
··· 30 30 31 31 private: 32 32 static constexpr size_t BW_BUFFER_CHUNK_SIZE = 8000; // 8KB chunks to allow for non-contiguous memory 33 - static constexpr size_t BW_BUFFER_NUM_CHUNKS = HalDisplay::BUFFER_SIZE / BW_BUFFER_CHUNK_SIZE; 34 - static_assert(BW_BUFFER_CHUNK_SIZE * BW_BUFFER_NUM_CHUNKS == HalDisplay::BUFFER_SIZE, 35 - "BW buffer chunking does not line up with display buffer size"); 36 33 37 34 HalDisplay& display; 38 35 RenderMode renderMode; 39 36 Orientation orientation; 40 37 bool fadingFix; 41 38 uint8_t* frameBuffer = nullptr; 42 - uint8_t* bwBufferChunks[BW_BUFFER_NUM_CHUNKS] = {nullptr}; 39 + uint16_t panelWidth = HalDisplay::DISPLAY_WIDTH; 40 + uint16_t panelHeight = HalDisplay::DISPLAY_HEIGHT; 41 + uint16_t panelWidthBytes = HalDisplay::DISPLAY_WIDTH_BYTES; 42 + uint32_t frameBufferSize = HalDisplay::BUFFER_SIZE; 43 + std::vector<uint8_t*> bwBufferChunks; 43 44 std::map<int, EpdFontFamily> fontMap; 44 45 45 46 // Mutable because drawText() is const but needs to delegate scan-mode ··· 155 156 156 157 // Low level functions 157 158 uint8_t* getFrameBuffer() const; 158 - static size_t getBufferSize(); 159 + size_t getBufferSize() const; 159 160 };
+6 -4
lib/JpegToBmpConverter/JpegToBmpConverter.cpp
··· 1 1 #include "JpegToBmpConverter.h" 2 2 3 + #include <HalDisplay.h> 3 4 #include <HalStorage.h> 4 5 #include <Logging.h> 5 6 #include <picojpeg.h> ··· 26 27 constexpr bool USE_FLOYD_STEINBERG = false; // Floyd-Steinberg error diffusion (can cause "worm" artifacts) 27 28 constexpr bool USE_NOISE_DITHERING = false; // Hash-based noise dithering (good for downsampling) 28 29 // Pre-resize to target display size (CRITICAL: avoids dithering artifacts from post-downsampling) 29 - constexpr bool USE_PRESCALE = true; // true: scale image to target size before dithering 30 - constexpr int TARGET_MAX_WIDTH = 480; // Max width for cover images (portrait display width) 31 - constexpr int TARGET_MAX_HEIGHT = 800; // Max height for cover images (portrait display height) 30 + constexpr bool USE_PRESCALE = true; // true: scale image to target size before dithering 32 31 // ============================================================================ 33 32 34 33 inline void write16(Print& out, const uint16_t value) { ··· 559 558 560 559 // Core function: Convert JPEG file to 2-bit BMP (uses default target size) 561 560 bool JpegToBmpConverter::jpegFileToBmpStream(FsFile& jpegFile, Print& bmpOut, bool crop) { 562 - return jpegFileToBmpStreamInternal(jpegFile, bmpOut, TARGET_MAX_WIDTH, TARGET_MAX_HEIGHT, false, crop); 561 + // Use runtime display dimensions (swapped for portrait cover sizing) 562 + const int targetWidth = display.getDisplayHeight(); 563 + const int targetHeight = display.getDisplayWidth(); 564 + return jpegFileToBmpStreamInternal(jpegFile, bmpOut, targetWidth, targetHeight, false, crop); 563 565 } 564 566 565 567 // Convert with custom target size (for thumbnails, 2-bit)
+5 -3
lib/PngToBmpConverter/PngToBmpConverter.cpp
··· 1 1 #include "PngToBmpConverter.h" 2 2 3 + #include <HalDisplay.h> 3 4 #include <HalStorage.h> 4 5 #include <InflateReader.h> 5 6 #include <Logging.h> ··· 16 17 constexpr bool USE_ATKINSON = true; 17 18 constexpr bool USE_FLOYD_STEINBERG = false; 18 19 constexpr bool USE_PRESCALE = true; 19 - constexpr int TARGET_MAX_WIDTH = 480; 20 - constexpr int TARGET_MAX_HEIGHT = 800; 21 20 // ============================================================================ 22 21 23 22 // BMP writing helpers (same as JpegToBmpConverter) ··· 822 821 } 823 822 824 823 bool PngToBmpConverter::pngFileToBmpStream(FsFile& pngFile, Print& bmpOut, bool crop) { 825 - return pngFileToBmpStreamInternal(pngFile, bmpOut, TARGET_MAX_WIDTH, TARGET_MAX_HEIGHT, false, crop); 824 + // Use runtime display dimensions (swapped for portrait cover sizing) 825 + const int targetWidth = display.getDisplayHeight(); 826 + const int targetHeight = display.getDisplayWidth(); 827 + return pngFileToBmpStreamInternal(pngFile, bmpOut, targetWidth, targetHeight, false, crop); 826 828 } 827 829 828 830 bool PngToBmpConverter::pngFileToBmpStreamWithSize(FsFile& pngFile, Print& bmpOut, int targetMaxWidth,
+34 -1
lib/hal/HalDisplay.cpp
··· 1 1 #include <HalDisplay.h> 2 2 #include <HalGPIO.h> 3 3 4 + // Global HalDisplay instance 5 + HalDisplay display; 6 + 4 7 #define SD_SPI_MISO 7 5 8 6 9 HalDisplay::HalDisplay() : einkDisplay(EPD_SCLK, EPD_MOSI, EPD_CS, EPD_DC, EPD_RST, EPD_BUSY) {} 7 10 8 11 HalDisplay::~HalDisplay() {} 9 12 10 - void HalDisplay::begin() { einkDisplay.begin(); } 13 + void HalDisplay::begin() { 14 + // Set X3-specific panel mode before initializing. 15 + if (gpio.deviceIsX3()) { 16 + einkDisplay.setDisplayX3(); 17 + } 18 + 19 + einkDisplay.begin(); 20 + 21 + // Request resync after specific wakeup events to ensure clean display state 22 + const auto wakeupReason = gpio.getWakeupReason(); 23 + if (wakeupReason == HalGPIO::WakeupReason::PowerButton || wakeupReason == HalGPIO::WakeupReason::AfterFlash || 24 + wakeupReason == HalGPIO::WakeupReason::Other) { 25 + einkDisplay.requestResync(); 26 + } 27 + } 11 28 12 29 void HalDisplay::clearScreen(uint8_t color) const { einkDisplay.clearScreen(color); } 13 30 ··· 34 51 } 35 52 36 53 void HalDisplay::displayBuffer(HalDisplay::RefreshMode mode, bool turnOffScreen) { 54 + if (gpio.deviceIsX3() && mode == RefreshMode::HALF_REFRESH) { 55 + einkDisplay.requestResync(1); 56 + } 57 + 37 58 einkDisplay.displayBuffer(convertRefreshMode(mode), turnOffScreen); 38 59 } 39 60 40 61 void HalDisplay::refreshDisplay(HalDisplay::RefreshMode mode, bool turnOffScreen) { 62 + if (gpio.deviceIsX3() && mode == RefreshMode::HALF_REFRESH) { 63 + einkDisplay.requestResync(1); 64 + } 65 + 41 66 einkDisplay.refreshDisplay(convertRefreshMode(mode), turnOffScreen); 42 67 } 43 68 ··· 56 81 void HalDisplay::cleanupGrayscaleBuffers(const uint8_t* bwBuffer) { einkDisplay.cleanupGrayscaleBuffers(bwBuffer); } 57 82 58 83 void HalDisplay::displayGrayBuffer(bool turnOffScreen) { einkDisplay.displayGrayBuffer(turnOffScreen); } 84 + 85 + uint16_t HalDisplay::getDisplayWidth() const { return einkDisplay.getDisplayWidth(); } 86 + 87 + uint16_t HalDisplay::getDisplayHeight() const { return einkDisplay.getDisplayHeight(); } 88 + 89 + uint16_t HalDisplay::getDisplayWidthBytes() const { return einkDisplay.getDisplayWidthBytes(); } 90 + 91 + uint32_t HalDisplay::getBufferSize() const { return einkDisplay.getBufferSize(); }
+8
lib/hal/HalDisplay.h
··· 49 49 50 50 void displayGrayBuffer(bool turnOffScreen = false); 51 51 52 + // Runtime geometry passthrough 53 + uint16_t getDisplayWidth() const; 54 + uint16_t getDisplayHeight() const; 55 + uint16_t getDisplayWidthBytes() const; 56 + uint32_t getBufferSize() const; 57 + 52 58 private: 53 59 EInkDisplay einkDisplay; 54 60 }; 61 + 62 + extern HalDisplay display;
+255 -3
lib/hal/HalGPIO.cpp
··· 1 1 #include <HalGPIO.h> 2 + #include <Logging.h> 3 + #include <Preferences.h> 2 4 #include <SPI.h> 5 + #include <Wire.h> 6 + #include <esp_sleep.h> 7 + 8 + // Global HalGPIO instance 9 + HalGPIO gpio; 10 + 11 + namespace X3GPIO { 12 + 13 + struct X3ProbeResult { 14 + bool bq27220 = false; 15 + bool ds3231 = false; 16 + bool qmi8658 = false; 17 + 18 + uint8_t score() const { 19 + return static_cast<uint8_t>(bq27220) + static_cast<uint8_t>(ds3231) + static_cast<uint8_t>(qmi8658); 20 + } 21 + }; 22 + 23 + bool readI2CReg8(uint8_t addr, uint8_t reg, uint8_t* outValue) { 24 + Wire.beginTransmission(addr); 25 + Wire.write(reg); 26 + if (Wire.endTransmission(false) != 0) { 27 + return false; 28 + } 29 + if (Wire.requestFrom(addr, static_cast<uint8_t>(1), static_cast<uint8_t>(true)) < 1) { 30 + return false; 31 + } 32 + *outValue = Wire.read(); 33 + return true; 34 + } 35 + 36 + bool readI2CReg16LE(uint8_t addr, uint8_t reg, uint16_t* outValue) { 37 + Wire.beginTransmission(addr); 38 + Wire.write(reg); 39 + if (Wire.endTransmission(false) != 0) { 40 + return false; 41 + } 42 + if (Wire.requestFrom(addr, static_cast<uint8_t>(2), static_cast<uint8_t>(true)) < 2) { 43 + while (Wire.available()) { 44 + Wire.read(); 45 + } 46 + return false; 47 + } 48 + const uint8_t lo = Wire.read(); 49 + const uint8_t hi = Wire.read(); 50 + *outValue = (static_cast<uint16_t>(hi) << 8) | lo; 51 + return true; 52 + } 53 + 54 + bool readBQ27220CurrentMA(int16_t* outCurrent) { 55 + uint16_t raw = 0; 56 + if (!readI2CReg16LE(I2C_ADDR_BQ27220, BQ27220_CUR_REG, &raw)) { 57 + return false; 58 + } 59 + *outCurrent = static_cast<int16_t>(raw); 60 + return true; 61 + } 62 + 63 + bool probeBQ27220Signature() { 64 + uint16_t soc = 0; 65 + uint16_t voltageMv = 0; 66 + if (!readI2CReg16LE(I2C_ADDR_BQ27220, BQ27220_SOC_REG, &soc)) { 67 + return false; 68 + } 69 + if (soc > 100) { 70 + return false; 71 + } 72 + if (!readI2CReg16LE(I2C_ADDR_BQ27220, BQ27220_VOLT_REG, &voltageMv)) { 73 + return false; 74 + } 75 + return voltageMv >= 2500 && voltageMv <= 5000; 76 + } 77 + 78 + bool probeDS3231Signature() { 79 + uint8_t sec = 0; 80 + if (!readI2CReg8(I2C_ADDR_DS3231, DS3231_SEC_REG, &sec)) { 81 + return false; 82 + } 83 + const uint8_t tensDigit = (sec >> 4) & 0x07; 84 + const uint8_t onesDigit = sec & 0x0F; 85 + 86 + return tensDigit <= 5 && onesDigit <= 9; 87 + } 88 + 89 + bool probeQMI8658Signature() { 90 + uint8_t whoami = 0; 91 + if (readI2CReg8(I2C_ADDR_QMI8658, QMI8658_WHO_AM_I_REG, &whoami) && whoami == QMI8658_WHO_AM_I_VALUE) { 92 + return true; 93 + } 94 + if (readI2CReg8(I2C_ADDR_QMI8658_ALT, QMI8658_WHO_AM_I_REG, &whoami) && whoami == QMI8658_WHO_AM_I_VALUE) { 95 + return true; 96 + } 97 + return false; 98 + } 99 + 100 + X3ProbeResult runX3ProbePass() { 101 + X3ProbeResult result; 102 + Wire.begin(X3_I2C_SDA, X3_I2C_SCL, X3_I2C_FREQ); 103 + Wire.setTimeOut(6); 104 + 105 + result.bq27220 = probeBQ27220Signature(); 106 + result.ds3231 = probeDS3231Signature(); 107 + result.qmi8658 = probeQMI8658Signature(); 108 + 109 + Wire.end(); 110 + pinMode(20, INPUT); 111 + pinMode(0, INPUT); 112 + return result; 113 + } 114 + 115 + } // namespace X3GPIO 116 + 117 + namespace { 118 + constexpr char HW_NAMESPACE[] = "cphw"; 119 + constexpr char NVS_KEY_DEV_OVERRIDE[] = "dev_ovr"; // 0=auto, 1=x4, 2=x3 120 + constexpr char NVS_KEY_DEV_CACHED[] = "dev_det"; // 0=unknown, 1=x4, 2=x3 121 + 122 + enum class NvsDeviceValue : uint8_t { Unknown = 0, X4 = 1, X3 = 2 }; 123 + 124 + NvsDeviceValue readNvsDeviceValue(const char* key, NvsDeviceValue defaultValue) { 125 + Preferences prefs; 126 + if (!prefs.begin(HW_NAMESPACE, true)) { 127 + return defaultValue; 128 + } 129 + const uint8_t raw = prefs.getUChar(key, static_cast<uint8_t>(defaultValue)); 130 + prefs.end(); 131 + if (raw > static_cast<uint8_t>(NvsDeviceValue::X3)) { 132 + return defaultValue; 133 + } 134 + return static_cast<NvsDeviceValue>(raw); 135 + } 136 + 137 + void writeNvsDeviceValue(const char* key, NvsDeviceValue value) { 138 + Preferences prefs; 139 + if (!prefs.begin(HW_NAMESPACE, false)) { 140 + return; 141 + } 142 + prefs.putUChar(key, static_cast<uint8_t>(value)); 143 + prefs.end(); 144 + } 145 + 146 + HalGPIO::DeviceType nvsToDeviceType(NvsDeviceValue value) { 147 + return value == NvsDeviceValue::X3 ? HalGPIO::DeviceType::X3 : HalGPIO::DeviceType::X4; 148 + } 149 + 150 + HalGPIO::DeviceType detectDeviceTypeWithFingerprint() { 151 + // Explicit override for recovery/support: 152 + // 0 = auto, 1 = force X4, 2 = force X3 153 + const NvsDeviceValue overrideValue = readNvsDeviceValue(NVS_KEY_DEV_OVERRIDE, NvsDeviceValue::Unknown); 154 + if (overrideValue == NvsDeviceValue::X3 || overrideValue == NvsDeviceValue::X4) { 155 + LOG_INF("HW", "Device override active: %s", overrideValue == NvsDeviceValue::X3 ? "X3" : "X4"); 156 + return nvsToDeviceType(overrideValue); 157 + } 158 + 159 + const NvsDeviceValue cachedValue = readNvsDeviceValue(NVS_KEY_DEV_CACHED, NvsDeviceValue::Unknown); 160 + if (cachedValue == NvsDeviceValue::X3 || cachedValue == NvsDeviceValue::X4) { 161 + LOG_INF("HW", "Using cached device type: %s", cachedValue == NvsDeviceValue::X3 ? "X3" : "X4"); 162 + return nvsToDeviceType(cachedValue); 163 + } 164 + 165 + // No cache yet: run active X3 fingerprint probe and persist result. 166 + const X3GPIO::X3ProbeResult pass1 = X3GPIO::runX3ProbePass(); 167 + delay(2); 168 + const X3GPIO::X3ProbeResult pass2 = X3GPIO::runX3ProbePass(); 169 + 170 + const uint8_t score1 = pass1.score(); 171 + const uint8_t score2 = pass2.score(); 172 + 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, 173 + pass1.ds3231, pass1.qmi8658, score2, pass2.bq27220, pass2.ds3231, pass2.qmi8658); 174 + const bool x3Confirmed = (score1 >= 2) && (score2 >= 2); 175 + const bool x4Confirmed = (score1 == 0) && (score2 == 0); 176 + 177 + if (x3Confirmed) { 178 + writeNvsDeviceValue(NVS_KEY_DEV_CACHED, NvsDeviceValue::X3); 179 + return HalGPIO::DeviceType::X3; 180 + } 181 + 182 + if (x4Confirmed) { 183 + writeNvsDeviceValue(NVS_KEY_DEV_CACHED, NvsDeviceValue::X4); 184 + return HalGPIO::DeviceType::X4; 185 + } 186 + 187 + // Conservative fallback for first boot with inconclusive probes. 188 + return HalGPIO::DeviceType::X4; 189 + } 190 + 191 + } // namespace 3 192 4 193 void HalGPIO::begin() { 5 194 inputMgr.begin(); 6 195 SPI.begin(EPD_SCLK, SPI_MISO, EPD_MOSI, EPD_CS); 7 - pinMode(UART0_RXD, INPUT); 196 + 197 + _deviceType = detectDeviceTypeWithFingerprint(); 198 + 199 + if (deviceIsX4()) { 200 + pinMode(BAT_GPIO0, INPUT); 201 + pinMode(UART0_RXD, INPUT); 202 + } 8 203 } 9 204 10 205 void HalGPIO::update() { ··· 28 223 29 224 unsigned long HalGPIO::getHeldTime() const { return inputMgr.getHeldTime(); } 30 225 226 + void HalGPIO::startDeepSleep() { 227 + // Ensure that the power button has been released to avoid immediately turning back on if you're holding it 228 + while (inputMgr.isPressed(BTN_POWER)) { 229 + delay(50); 230 + inputMgr.update(); 231 + } 232 + // Arm the wakeup trigger *after* the button is released 233 + esp_deep_sleep_enable_gpio_wakeup(1ULL << InputManager::POWER_BUTTON_PIN, ESP_GPIO_WAKEUP_GPIO_LOW); 234 + // Enter Deep Sleep 235 + esp_deep_sleep_start(); 236 + } 237 + 238 + void HalGPIO::verifyPowerButtonWakeup(uint16_t requiredDurationMs, bool shortPressAllowed) { 239 + if (shortPressAllowed) { 240 + // Fast path - no duration check needed 241 + return; 242 + } 243 + // TODO: Intermittent edge case remains: a single tap followed by another single tap 244 + // can still power on the device. Tighten wake debounce/state handling here. 245 + 246 + // Calibrate: subtract boot time already elapsed, assuming button held since boot 247 + const uint16_t calibration = millis(); 248 + const uint16_t calibratedDuration = (calibration < requiredDurationMs) ? (requiredDurationMs - calibration) : 1; 249 + 250 + const auto start = millis(); 251 + inputMgr.update(); 252 + // inputMgr.isPressed() may take up to ~500ms to return correct state 253 + while (!inputMgr.isPressed(BTN_POWER) && millis() - start < 1000) { 254 + delay(10); 255 + inputMgr.update(); 256 + } 257 + if (inputMgr.isPressed(BTN_POWER)) { 258 + do { 259 + delay(10); 260 + inputMgr.update(); 261 + } while (inputMgr.isPressed(BTN_POWER) && inputMgr.getHeldTime() < calibratedDuration); 262 + if (inputMgr.getHeldTime() < calibratedDuration) { 263 + startDeepSleep(); 264 + } 265 + } else { 266 + startDeepSleep(); 267 + } 268 + } 269 + 31 270 bool HalGPIO::isUsbConnected() const { 271 + if (deviceIsX3()) { 272 + // X3: infer USB/charging via BQ27220 Current() register (0x0C, signed mA). 273 + // Positive current means charging. 274 + for (uint8_t attempt = 0; attempt < 2; ++attempt) { 275 + int16_t currentMa = 0; 276 + if (X3GPIO::readBQ27220CurrentMA(&currentMa)) { 277 + return currentMa > 0; 278 + } 279 + delay(2); 280 + } 281 + return false; 282 + } 32 283 // U0RXD/GPIO20 reads HIGH when USB is connected 33 284 return digitalRead(UART0_RXD) == HIGH; 34 285 } 35 286 36 287 HalGPIO::WakeupReason HalGPIO::getWakeupReason() const { 37 - const bool usbConnected = isUsbConnected(); 38 288 const auto wakeupCause = esp_sleep_get_wakeup_cause(); 39 289 const auto resetReason = esp_reset_reason(); 40 290 291 + const bool usbConnected = isUsbConnected(); 292 + 41 293 if ((wakeupCause == ESP_SLEEP_WAKEUP_UNDEFINED && resetReason == ESP_RST_POWERON && !usbConnected) || 42 294 (wakeupCause == ESP_SLEEP_WAKEUP_GPIO && resetReason == ESP_RST_DEEPSLEEP && usbConnected)) { 43 295 return WakeupReason::PowerButton; ··· 49 301 return WakeupReason::AfterUSBPower; 50 302 } 51 303 return WakeupReason::Other; 52 - } 304 + }
+40 -2
lib/hal/HalGPIO.h
··· 1 1 #pragma once 2 2 3 3 #include <Arduino.h> 4 - #include <BatteryMonitor.h> 5 4 #include <InputManager.h> 6 5 7 6 // Display SPI pins (custom pins for XteinkX4, not hardware SPI defaults) ··· 18 17 19 18 #define UART0_RXD 20 // Used for USB connection detection 20 19 20 + // Xteink X3 Hardware 21 + #define X3_I2C_SDA 20 22 + #define X3_I2C_SCL 0 23 + #define X3_I2C_FREQ 400000 24 + 25 + // TI BQ27220 Fuel gauge I2C 26 + #define I2C_ADDR_BQ27220 0x55 // Fuel gauge I2C address 27 + #define BQ27220_SOC_REG 0x2C // StateOfCharge() command code (%) 28 + #define BQ27220_CUR_REG 0x0C // Current() command code (signed mA) 29 + #define BQ27220_VOLT_REG 0x08 // Voltage() command code (mV) 30 + 31 + // Analog DS3231 RTC I2C 32 + #define I2C_ADDR_DS3231 0x68 // RTC I2C address 33 + #define DS3231_SEC_REG 0x00 // Seconds command code (BCD) 34 + 35 + // QST QMI8658 IMU I2C 36 + #define I2C_ADDR_QMI8658 0x6B // IMU I2C address 37 + #define I2C_ADDR_QMI8658_ALT 0x6A // IMU I2C fallback address 38 + #define QMI8658_WHO_AM_I_REG 0x00 // WHO_AM_I command code 39 + #define QMI8658_WHO_AM_I_VALUE 0x05 // WHO_AM_I expected value 40 + 21 41 class HalGPIO { 22 42 #if CROSSPOINT_EMULATED == 0 23 43 InputManager inputMgr; ··· 27 47 bool usbStateChanged = false; 28 48 29 49 public: 50 + enum class DeviceType : uint8_t { X4, X3 }; 51 + 52 + private: 53 + DeviceType _deviceType = DeviceType::X4; 54 + 55 + public: 30 56 HalGPIO() = default; 57 + 58 + // Inline device type helpers for cleaner downstream checks 59 + inline bool deviceIsX3() const { return _deviceType == DeviceType::X3; } 60 + inline bool deviceIsX4() const { return _deviceType == DeviceType::X4; } 31 61 32 62 // Start button GPIO and setup SPI for screen and SD card 33 63 void begin(); ··· 41 71 bool wasAnyReleased() const; 42 72 unsigned long getHeldTime() const; 43 73 74 + // Setup wake up GPIO and enter deep sleep 75 + void startDeepSleep(); 76 + 77 + // Verify power button was held long enough after wakeup. 78 + // If verification fails, enters deep sleep and does not return. 79 + // Should only be called when wakeup reason is PowerButton. 80 + void verifyPowerButtonWakeup(uint16_t requiredDurationMs, bool shortPressAllowed); 81 + 44 82 // Check if USB is connected 45 83 bool isUsbConnected() const; 46 84 ··· 61 99 static constexpr uint8_t BTN_POWER = 6; 62 100 }; 63 101 64 - extern HalGPIO gpio; // Singleton 102 + extern HalGPIO gpio;
+37 -2
lib/hal/HalPowerManager.cpp
··· 11 11 HalPowerManager powerManager; // Singleton instance 12 12 13 13 void HalPowerManager::begin() { 14 - pinMode(BAT_GPIO0, INPUT); 14 + if (gpio.deviceIsX3()) { 15 + // X3 uses an I2C fuel gauge for battery monitoring. 16 + // I2C init must come AFTER gpio.begin() so early hardware detection/probes are finished. 17 + Wire.begin(X3_I2C_SDA, X3_I2C_SCL, X3_I2C_FREQ); 18 + Wire.setTimeOut(4); 19 + _batteryUseI2C = true; 20 + } else { 21 + pinMode(BAT_GPIO0, INPUT); 22 + } 15 23 normalFreq = getCpuFrequencyMhz(); 16 24 modeMutex = xSemaphoreCreateMutex(); 17 25 assert(modeMutex != nullptr); ··· 78 86 } 79 87 80 88 uint16_t HalPowerManager::getBatteryPercentage() const { 89 + if (_batteryUseI2C) { 90 + const unsigned long now = millis(); 91 + if (_batteryLastPollMs != 0 && (now - _batteryLastPollMs) < BATTERY_POLL_MS) { 92 + return _batteryCachedPercent; 93 + } 94 + 95 + // Read SOC directly from I2C fuel gauge (16-bit LE register). 96 + // On I2C error, keep last known value to avoid UI jitter/slowdowns. 97 + Wire.beginTransmission(I2C_ADDR_BQ27220); 98 + Wire.write(BQ27220_SOC_REG); 99 + if (Wire.endTransmission(false) != 0) { 100 + _batteryLastPollMs = now; 101 + return _batteryCachedPercent; 102 + } 103 + Wire.requestFrom(I2C_ADDR_BQ27220, (uint8_t)2); 104 + if (Wire.available() < 2) { 105 + _batteryLastPollMs = now; 106 + return _batteryCachedPercent; 107 + } 108 + const uint8_t lo = Wire.read(); 109 + const uint8_t hi = Wire.read(); 110 + const uint16_t soc = (hi << 8) | lo; 111 + _batteryCachedPercent = soc > 100 ? 100 : soc; 112 + _batteryLastPollMs = now; 113 + return _batteryCachedPercent; 114 + } 81 115 static const BatteryMonitor battery = BatteryMonitor(BAT_GPIO0); 82 - return battery.readPercentage(); 116 + _batteryCachedPercent = battery.readPercentage(); 117 + return _batteryCachedPercent; 83 118 } 84 119 85 120 HalPowerManager::Lock::Lock() {
+8
lib/hal/HalPowerManager.h
··· 1 1 #pragma once 2 2 3 3 #include <Arduino.h> 4 + #include <BatteryMonitor.h> 4 5 #include <InputManager.h> 5 6 #include <Logging.h> 7 + #include <Wire.h> 6 8 #include <freertos/semphr.h> 7 9 8 10 #include <cassert> ··· 16 18 int normalFreq = 0; // MHz 17 19 bool isLowPower = false; 18 20 21 + // I2C fuel gauge configuration for X3 battery monitoring 22 + bool _batteryUseI2C = false; // True if using I2C fuel gauge (X3), false for ADC (X4) 23 + mutable int _batteryCachedPercent = 0; // Last read battery percentage (0-100) 24 + mutable unsigned long _batteryLastPollMs = 0; // Timestamp of last battery read in milliseconds 25 + 19 26 enum LockMode { None, NormalSpeed }; 20 27 LockMode currentLockMode = None; 21 28 SemaphoreHandle_t modeMutex = nullptr; // Protect access to currentLockMode ··· 23 30 public: 24 31 static constexpr int LOW_POWER_FREQ = 10; // MHz 25 32 static constexpr unsigned long IDLE_POWER_SAVING_MS = 3000; // ms 33 + static constexpr unsigned long BATTERY_POLL_MS = 1500; // ms 26 34 27 35 void begin(); 28 36
+2 -2
src/activities/home/HomeActivity.cpp
··· 138 138 // Free any existing buffer first 139 139 freeCoverBuffer(); 140 140 141 - const size_t bufferSize = GfxRenderer::getBufferSize(); 141 + const size_t bufferSize = renderer.getBufferSize(); 142 142 coverBuffer = static_cast<uint8_t*>(malloc(bufferSize)); 143 143 if (!coverBuffer) { 144 144 return false; ··· 158 158 return false; 159 159 } 160 160 161 - const size_t bufferSize = GfxRenderer::getBufferSize(); 161 + const size_t bufferSize = renderer.getBufferSize(); 162 162 memcpy(frameBuffer, coverBuffer, bufferSize); 163 163 return true; 164 164 }
+54 -38
src/components/themes/BaseTheme.cpp
··· 140 140 constexpr int buttonHeight = BaseMetrics::values.buttonHintsHeight; 141 141 constexpr int buttonY = BaseMetrics::values.buttonHintsHeight; // Distance from bottom 142 142 constexpr int textYOffset = 7; // Distance from top of button to text baseline 143 - constexpr int buttonPositions[] = {25, 130, 245, 350}; 143 + // X3 has wider screen in portrait (528 vs 480), use more spacing 144 + constexpr int x4ButtonPositions[] = {25, 130, 245, 350}; 145 + constexpr int x3ButtonPositions[] = {38, 154, 268, 384}; 146 + const int* buttonPositions = gpio.deviceIsX3() ? x3ButtonPositions : x4ButtonPositions; 144 147 const char* labels[] = {btn1, btn2, btn3, btn4}; 145 148 146 149 for (int i = 0; i < 4; i++) { ··· 162 165 const int screenWidth = renderer.getScreenWidth(); 163 166 constexpr int buttonWidth = BaseMetrics::values.sideButtonHintsWidth; // Width on screen (height when rotated) 164 167 constexpr int buttonHeight = 80; // Height on screen (width when rotated) 165 - constexpr int buttonX = 4; // Distance from right edge 166 - // Position for the button group - buttons share a border so they're adjacent 167 - constexpr int topButtonY = 345; // Top button position 168 - 169 - const char* labels[] = {topBtn, bottomBtn}; 170 - 171 - // Draw the shared border for both buttons as one unit 172 - const int x = screenWidth - buttonX - buttonWidth; 168 + constexpr int buttonMargin = 4; 173 169 174 - // Draw top button outline (3 sides, bottom open) 175 - if (topBtn != nullptr && topBtn[0] != '\0') { 176 - renderer.drawLine(x, topButtonY, x + buttonWidth - 1, topButtonY); // Top 177 - renderer.drawLine(x, topButtonY, x, topButtonY + buttonHeight - 1); // Left 178 - renderer.drawLine(x + buttonWidth - 1, topButtonY, x + buttonWidth - 1, topButtonY + buttonHeight - 1); // Right 179 - } 170 + if (gpio.deviceIsX3()) { 171 + // X3 layout: Up on left side, Down on right side, positioned higher 172 + constexpr int x3ButtonY = 155; 180 173 181 - // Draw shared middle border 182 - if ((topBtn != nullptr && topBtn[0] != '\0') || (bottomBtn != nullptr && bottomBtn[0] != '\0')) { 183 - renderer.drawLine(x, topButtonY + buttonHeight, x + buttonWidth - 1, topButtonY + buttonHeight); // Shared border 184 - } 174 + if (topBtn != nullptr && topBtn[0] != '\0') { 175 + const int leftX = buttonMargin; 176 + renderer.drawRect(leftX, x3ButtonY, buttonWidth, buttonHeight); 177 + const int textWidth = renderer.getTextWidth(SMALL_FONT_ID, topBtn); 178 + const int textHeight = renderer.getTextHeight(SMALL_FONT_ID); 179 + const int textX = leftX + (buttonWidth - textHeight) / 2; 180 + const int textY = x3ButtonY + (buttonHeight + textWidth) / 2; 181 + renderer.drawTextRotated90CW(SMALL_FONT_ID, textX, textY, topBtn); 182 + } 185 183 186 - // Draw bottom button outline (3 sides, top is shared) 187 - if (bottomBtn != nullptr && bottomBtn[0] != '\0') { 188 - renderer.drawLine(x, topButtonY + buttonHeight, x, topButtonY + 2 * buttonHeight - 1); // Left 189 - renderer.drawLine(x + buttonWidth - 1, topButtonY + buttonHeight, x + buttonWidth - 1, 190 - topButtonY + 2 * buttonHeight - 1); // Right 191 - renderer.drawLine(x, topButtonY + 2 * buttonHeight - 1, x + buttonWidth - 1, 192 - topButtonY + 2 * buttonHeight - 1); // Bottom 193 - } 184 + if (bottomBtn != nullptr && bottomBtn[0] != '\0') { 185 + const int rightX = screenWidth - buttonMargin - buttonWidth; 186 + renderer.drawRect(rightX, x3ButtonY, buttonWidth, buttonHeight); 187 + const int textWidth = renderer.getTextWidth(SMALL_FONT_ID, bottomBtn); 188 + const int textHeight = renderer.getTextHeight(SMALL_FONT_ID); 189 + const int textX = rightX + (buttonWidth - textHeight) / 2; 190 + const int textY = x3ButtonY + (buttonHeight + textWidth) / 2; 191 + renderer.drawTextRotated90CW(SMALL_FONT_ID, textX, textY, bottomBtn); 192 + } 193 + } else { 194 + // X4 layout: Both buttons stacked on right side 195 + constexpr int topButtonY = 345; 196 + const char* labels[] = {topBtn, bottomBtn}; 197 + const int x = screenWidth - buttonMargin - buttonWidth; 194 198 195 - // Draw text for each button 196 - for (int i = 0; i < 2; i++) { 197 - if (labels[i] != nullptr && labels[i][0] != '\0') { 198 - const int y = topButtonY + i * buttonHeight; 199 + if (topBtn != nullptr && topBtn[0] != '\0') { 200 + renderer.drawLine(x, topButtonY, x + buttonWidth - 1, topButtonY); 201 + renderer.drawLine(x, topButtonY, x, topButtonY + buttonHeight - 1); 202 + renderer.drawLine(x + buttonWidth - 1, topButtonY, x + buttonWidth - 1, topButtonY + buttonHeight - 1); 203 + } 199 204 200 - // Draw rotated text centered in the button 201 - const int textWidth = renderer.getTextWidth(SMALL_FONT_ID, labels[i]); 202 - const int textHeight = renderer.getTextHeight(SMALL_FONT_ID); 205 + if ((topBtn != nullptr && topBtn[0] != '\0') || (bottomBtn != nullptr && bottomBtn[0] != '\0')) { 206 + renderer.drawLine(x, topButtonY + buttonHeight, x + buttonWidth - 1, topButtonY + buttonHeight); 207 + } 203 208 204 - // Center the rotated text in the button 205 - const int textX = x + (buttonWidth - textHeight) / 2; 206 - const int textY = y + (buttonHeight + textWidth) / 2; 209 + if (bottomBtn != nullptr && bottomBtn[0] != '\0') { 210 + renderer.drawLine(x, topButtonY + buttonHeight, x, topButtonY + 2 * buttonHeight - 1); 211 + renderer.drawLine(x + buttonWidth - 1, topButtonY + buttonHeight, x + buttonWidth - 1, 212 + topButtonY + 2 * buttonHeight - 1); 213 + renderer.drawLine(x, topButtonY + 2 * buttonHeight - 1, x + buttonWidth - 1, topButtonY + 2 * buttonHeight - 1); 214 + } 207 215 208 - renderer.drawTextRotated90CW(SMALL_FONT_ID, textX, textY, labels[i]); 216 + for (int i = 0; i < 2; i++) { 217 + if (labels[i] != nullptr && labels[i][0] != '\0') { 218 + const int y = topButtonY + i * buttonHeight; 219 + const int textWidth = renderer.getTextWidth(SMALL_FONT_ID, labels[i]); 220 + const int textHeight = renderer.getTextHeight(SMALL_FONT_ID); 221 + const int textX = x + (buttonWidth - textHeight) / 2; 222 + const int textY = y + (buttonHeight + textWidth) / 2; 223 + renderer.drawTextRotated90CW(SMALL_FONT_ID, textX, textY, labels[i]); 224 + } 209 225 } 210 226 } 211 227 }
+39 -23
src/components/themes/lyra/LyraTheme.cpp
··· 342 342 constexpr int buttonHeight = LyraMetrics::values.buttonHintsHeight; 343 343 constexpr int buttonY = LyraMetrics::values.buttonHintsHeight; // Distance from bottom 344 344 constexpr int textYOffset = 7; // Distance from top of button to text baseline 345 - constexpr int buttonPositions[] = {58, 146, 254, 342}; 345 + // X3 has wider screen in portrait (528 vs 480), use more spacing 346 + constexpr int x4ButtonPositions[] = {58, 146, 254, 342}; 347 + constexpr int x3ButtonPositions[] = {65, 157, 291, 383}; 348 + const int* buttonPositions = gpio.deviceIsX3() ? x3ButtonPositions : x4ButtonPositions; 346 349 const char* labels[] = {btn1, btn2, btn3, btn4}; 347 350 348 351 for (int i = 0; i < 4; i++) { ··· 371 374 const int screenWidth = renderer.getScreenWidth(); 372 375 constexpr int buttonWidth = LyraMetrics::values.sideButtonHintsWidth; // Width on screen (height when rotated) 373 376 constexpr int buttonHeight = 78; // Height on screen (width when rotated) 374 - // Position for the button group - buttons share a border so they're adjacent 377 + constexpr int buttonMargin = 0; 375 378 376 - const char* labels[] = {topBtn, bottomBtn}; 379 + if (gpio.deviceIsX3()) { 380 + // X3 layout: Up on left side, Down on right side, positioned higher 381 + constexpr int x3ButtonY = 155; 377 382 378 - // Draw the shared border for both buttons as one unit 379 - const int x = screenWidth - buttonWidth; 380 - 381 - // Draw top button outline 382 - if (topBtn != nullptr && topBtn[0] != '\0') { 383 - renderer.drawRoundedRect(x, topHintButtonY, buttonWidth, buttonHeight, 1, cornerRadius, true, false, true, false, 384 - true); 385 - } 383 + if (topBtn != nullptr && topBtn[0] != '\0') { 384 + renderer.drawRoundedRect(buttonMargin, x3ButtonY, buttonWidth, buttonHeight, 1, cornerRadius, false, true, false, 385 + true, true); 386 + const int textWidth = renderer.getTextWidth(SMALL_FONT_ID, topBtn); 387 + renderer.drawTextRotated90CW(SMALL_FONT_ID, buttonMargin, x3ButtonY + (buttonHeight + textWidth) / 2, topBtn); 388 + } 386 389 387 - // Draw bottom button outline 388 - if (bottomBtn != nullptr && bottomBtn[0] != '\0') { 389 - renderer.drawRoundedRect(x, topHintButtonY + buttonHeight + 5, buttonWidth, buttonHeight, 1, cornerRadius, true, 390 - false, true, false, true); 391 - } 390 + if (bottomBtn != nullptr && bottomBtn[0] != '\0') { 391 + const int rightX = screenWidth - buttonWidth; 392 + renderer.drawRoundedRect(rightX, x3ButtonY, buttonWidth, buttonHeight, 1, cornerRadius, true, false, true, false, 393 + true); 394 + const int textWidth = renderer.getTextWidth(SMALL_FONT_ID, bottomBtn); 395 + renderer.drawTextRotated90CW(SMALL_FONT_ID, rightX, x3ButtonY + (buttonHeight + textWidth) / 2, bottomBtn); 396 + } 397 + } else { 398 + // X4 layout: Both buttons stacked on right side 399 + const char* labels[] = {topBtn, bottomBtn}; 400 + const int x = screenWidth - buttonWidth; 392 401 393 - // Draw text for each button 394 - for (int i = 0; i < 2; i++) { 395 - if (labels[i] != nullptr && labels[i][0] != '\0') { 396 - const int y = topHintButtonY + (i * buttonHeight + 5); 402 + if (topBtn != nullptr && topBtn[0] != '\0') { 403 + renderer.drawRoundedRect(x, topHintButtonY, buttonWidth, buttonHeight, 1, cornerRadius, true, false, true, false, 404 + true); 405 + } 397 406 398 - // Draw rotated text centered in the button 399 - const int textWidth = renderer.getTextWidth(SMALL_FONT_ID, labels[i]); 407 + if (bottomBtn != nullptr && bottomBtn[0] != '\0') { 408 + renderer.drawRoundedRect(x, topHintButtonY + buttonHeight + 5, buttonWidth, buttonHeight, 1, cornerRadius, true, 409 + false, true, false, true); 410 + } 400 411 401 - renderer.drawTextRotated90CW(SMALL_FONT_ID, x, y + (buttonHeight + textWidth) / 2, labels[i]); 412 + for (int i = 0; i < 2; i++) { 413 + if (labels[i] != nullptr && labels[i][0] != '\0') { 414 + const int y = topHintButtonY + (i * buttonHeight) + 5; 415 + const int textWidth = renderer.getTextWidth(SMALL_FONT_ID, labels[i]); 416 + renderer.drawTextRotated90CW(SMALL_FONT_ID, x, y + (buttonHeight + textWidth) / 2, labels[i]); 417 + } 402 418 } 403 419 } 404 420 }
+13 -13
src/main.cpp
··· 27 27 #include "util/ButtonNavigator.h" 28 28 #include "util/ScreenshotUtil.h" 29 29 30 - HalDisplay display; 31 - HalGPIO gpio; 32 30 MappedInputManager mappedInputManager(gpio); 33 31 GfxRenderer renderer(display); 34 32 ActivityManager activityManager(renderer, mappedInputManager); ··· 171 169 powerManager.startDeepSleep(gpio); 172 170 } 173 171 } 174 - 175 172 void waitForPowerRelease() { 176 173 gpio.update(); 177 174 while (gpio.isPressed(HalGPIO::BTN_POWER)) { ··· 189 186 activityManager.goToSleep(); 190 187 191 188 display.deepSleep(); 192 - LOG_DBG("MAIN", "Power button press calibration value: %lu ms", t2 - t1); 193 189 LOG_DBG("MAIN", "Entering deep sleep"); 194 190 195 191 powerManager.startDeepSleep(gpio); ··· 235 231 gpio.begin(); 236 232 powerManager.begin(); 237 233 238 - // Only start serial if USB connected 234 + #ifdef ENABLE_SERIAL_LOG 239 235 if (gpio.isUsbConnected()) { 240 236 Serial.begin(115200); 241 - // Wait up to 3 seconds for Serial to be ready to catch early logs 242 - unsigned long start = millis(); 243 - while (!Serial && (millis() - start) < 3000) { 237 + const unsigned long start = millis(); 238 + while (!Serial && (millis() - start) < 500) { 244 239 delay(10); 245 240 } 246 241 } 242 + #endif 243 + 244 + LOG_INF("MAIN", "Hardware detect: %s", gpio.deviceIsX3() ? "X3" : "X4"); 247 245 248 246 // SD Card Initialization 249 247 // We need 6 open files concurrently when parsing a new chapter ··· 263 261 UITheme::getInstance().reload(); 264 262 ButtonNavigator::setMappedInputManager(mappedInputManager); 265 263 266 - switch (gpio.getWakeupReason()) { 264 + const auto wakeupReason = gpio.getWakeupReason(); 265 + switch (wakeupReason) { 267 266 case HalGPIO::WakeupReason::PowerButton: 268 - // For normal wakeups, verify power button press duration 269 267 LOG_DBG("MAIN", "Verifying power button press duration"); 270 - verifyPowerButtonDuration(); 268 + gpio.verifyPowerButtonWakeup(SETTINGS.getPowerButtonDuration(), 269 + SETTINGS.shortPwrBtn == CrossPointSettings::SHORT_PWRBTN::SLEEP); 271 270 break; 272 271 case HalGPIO::WakeupReason::AfterUSBPower: 273 272 // If USB power caused a cold boot, go back to sleep ··· 332 331 String cmd = line.substring(4); 333 332 cmd.trim(); 334 333 if (cmd == "SCREENSHOT") { 335 - logSerial.printf("SCREENSHOT_START:%d\n", HalDisplay::BUFFER_SIZE); 334 + const uint32_t bufferSize = display.getBufferSize(); 335 + logSerial.printf("SCREENSHOT_START:%d\n", bufferSize); 336 336 uint8_t* buf = display.getFrameBuffer(); 337 - logSerial.write(buf, HalDisplay::BUFFER_SIZE); 337 + logSerial.write(buf, bufferSize); 338 338 logSerial.printf("SCREENSHOT_END\n"); 339 339 } 340 340 }