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,13 +1,30 @@
#include <HalDisplay.h>
#include <HalGPIO.h>
// Global HalDisplay instance
HalDisplay display;
#define SD_SPI_MISO 7
HalDisplay::HalDisplay() : einkDisplay(EPD_SCLK, EPD_MOSI, EPD_CS, EPD_DC, EPD_RST, EPD_BUSY) {}
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); }
@@ -34,10 +51,18 @@ EInkDisplay::RefreshMode convertRefreshMode(HalDisplay::RefreshMode mode) {
}
void HalDisplay::displayBuffer(HalDisplay::RefreshMode mode, bool turnOffScreen) {
if (gpio.deviceIsX3() && mode == RefreshMode::HALF_REFRESH) {
einkDisplay.requestResync(1);
}
einkDisplay.displayBuffer(convertRefreshMode(mode), turnOffScreen);
}
void HalDisplay::refreshDisplay(HalDisplay::RefreshMode mode, bool turnOffScreen) {
if (gpio.deviceIsX3() && mode == RefreshMode::HALF_REFRESH) {
einkDisplay.requestResync(1);
}
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::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);
// Runtime geometry passthrough
uint16_t getDisplayWidth() const;
uint16_t getDisplayHeight() const;
uint16_t getDisplayWidthBytes() const;
uint32_t getBufferSize() const;
private:
EInkDisplay einkDisplay;
};
extern HalDisplay display;

View File

@@ -1,10 +1,205 @@
#include <HalGPIO.h>
#include <Logging.h>
#include <Preferences.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() {
inputMgr.begin();
SPI.begin(EPD_SCLK, SPI_MISO, EPD_MOSI, EPD_CS);
pinMode(UART0_RXD, INPUT);
_deviceType = detectDeviceTypeWithFingerprint();
if (deviceIsX4()) {
pinMode(BAT_GPIO0, INPUT);
pinMode(UART0_RXD, INPUT);
}
}
void HalGPIO::update() {
@@ -28,16 +223,73 @@ bool HalGPIO::wasAnyReleased() const { return inputMgr.wasAnyReleased(); }
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 {
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
return digitalRead(UART0_RXD) == HIGH;
}
HalGPIO::WakeupReason HalGPIO::getWakeupReason() const {
const bool usbConnected = isUsbConnected();
const auto wakeupCause = esp_sleep_get_wakeup_cause();
const auto resetReason = esp_reset_reason();
const bool usbConnected = isUsbConnected();
if ((wakeupCause == ESP_SLEEP_WAKEUP_UNDEFINED && resetReason == ESP_RST_POWERON && !usbConnected) ||
(wakeupCause == ESP_SLEEP_WAKEUP_GPIO && resetReason == ESP_RST_DEEPSLEEP && usbConnected)) {
return WakeupReason::PowerButton;
@@ -49,4 +301,4 @@ HalGPIO::WakeupReason HalGPIO::getWakeupReason() const {
return WakeupReason::AfterUSBPower;
}
return WakeupReason::Other;
}
}

View File

@@ -1,7 +1,6 @@
#pragma once
#include <Arduino.h>
#include <BatteryMonitor.h>
#include <InputManager.h>
// Display SPI pins (custom pins for XteinkX4, not hardware SPI defaults)
@@ -18,6 +17,27 @@
#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 {
#if CROSSPOINT_EMULATED == 0
InputManager inputMgr;
@@ -26,9 +46,19 @@ class HalGPIO {
bool lastUsbConnected = false;
bool usbStateChanged = false;
public:
enum class DeviceType : uint8_t { X4, X3 };
private:
DeviceType _deviceType = DeviceType::X4;
public:
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
void begin();
@@ -41,6 +71,14 @@ class HalGPIO {
bool wasAnyReleased() 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
bool isUsbConnected() const;
@@ -61,4 +99,4 @@ class HalGPIO {
static constexpr uint8_t BTN_POWER = 6;
};
extern HalGPIO gpio; // Singleton
extern HalGPIO gpio;

View File

@@ -11,7 +11,15 @@
HalPowerManager powerManager; // Singleton instance
void HalPowerManager::begin() {
pinMode(BAT_GPIO0, INPUT);
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);
}
normalFreq = getCpuFrequencyMhz();
modeMutex = xSemaphoreCreateMutex();
assert(modeMutex != nullptr);
@@ -78,8 +86,35 @@ void HalPowerManager::startDeepSleep(HalGPIO& gpio) 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);
return battery.readPercentage();
_batteryCachedPercent = battery.readPercentage();
return _batteryCachedPercent;
}
HalPowerManager::Lock::Lock() {

View File

@@ -1,8 +1,10 @@
#pragma once
#include <Arduino.h>
#include <BatteryMonitor.h>
#include <InputManager.h>
#include <Logging.h>
#include <Wire.h>
#include <freertos/semphr.h>
#include <cassert>
@@ -16,6 +18,11 @@ class HalPowerManager {
int normalFreq = 0; // MHz
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 };
LockMode currentLockMode = None;
SemaphoreHandle_t modeMutex = nullptr; // Protect access to currentLockMode
@@ -23,6 +30,7 @@ class HalPowerManager {
public:
static constexpr int LOW_POWER_FREQ = 10; // MHz
static constexpr unsigned long IDLE_POWER_SAVING_MS = 3000; // ms
static constexpr unsigned long BATTERY_POLL_MS = 1500; // ms
void begin();