diff --git a/README.md b/README.md index 1bb25f1..871c309 100644 --- a/README.md +++ b/README.md @@ -2,14 +2,22 @@ A simple sample project for the Xteink X4 e-ink device using the GxEPD2 library. +Join our Discord server for support and discussion: + +- [Xteink eReader Community](https://discord.gg/2cdKUbWRE8) + ![Sample](images/sample.png) ## Hardware -- **Device**: Xteink X4 +- **Device**: [Xteink X4](https://www.xteink.com/products/xteink-x4) - **Board**: ESP32-C3 (QFN32) -- **Display**: [4.26" E-Ink (800×480px, GDEQ0426T82, SSD1677 controller)](https://www.good-display.com/product/457.html) +- **Flash**: 16MB (SPI), 6.5MB app0 / app1 partitions + spiffs +- **RAM**: 400KB (327680 bytes usable with PlatformIO, no PSRAM) +- **Display**: [4.26" E-Ink (800×480px, GDEQ0426T82, SSD1677 controller)](https://www.good-display.com/product/457.html) (220PPI) - **Custom SPI pins**: SCLK=8, MOSI=10, CS=21, DC=4, RST=5, BUSY=6 +- **Battery**: 650mAh +- **Storage**: microSD card slot ### Resources @@ -46,6 +54,7 @@ Before flashing custom firmware, back up the factory firmware: # Read entire 16MB flash python -m esptool --chip esp32c3 --port COM4 read_flash 0x0 0x1000000 firmware_backup.bin ``` + ```powershell # Read only app0 (faster) python -m esptool --chip esp32c3 --port COM4 read_flash 0x10000 0x640000 app0_backup.bin @@ -94,7 +103,7 @@ python -m esptool --port COM4 write_flash 0xE000 otadata_boot_app1.bin - [x] Wakeup and deep sleep - [x] Read battery percentage - [ ] Better rendering with grayscale support -- [ ] SD card reader +- [x] SD card reader - [ ] WiFi - [ ] Bluetooth @@ -105,24 +114,39 @@ The XteinkX4 uses **resistor ladder networks** connected to two ADC pins for but ### Button ADC Values **GPIO1 (4 buttons)**: + - Back: ~3470 - Confirm: ~2655 - Left: ~1470 - Right: ~3 **GPIO2 (2 buttons)**: + - Volume Up: ~2205 - Volume Down: ~3 -**GPIO3 (Power button)**: -- Pressed: ~3 -- This example uses a 2-second-long press for sleep and a 1.5-second-long press to wake from sleep +### Power Button -**Battery ADC**: -- GPIO0, raw value ranges up to ~2800 when charging. ~2760 when not charging and full. -- Voltage divider is ~2 (2 x 10K resistors), `CONV_FACTOR=1.6113` for [this library](https://github.com/pangodream/18650CL) -- [See here](https://www.pangodream.es/esp32-getting-battery-charging-level) for details how voltage and charge level are calculated -- `CONV_FACTOR` may need calibration depending on the device, `1.5176` working well on mine +**GPIO3**: + +- Pressed: LOW +- This example uses a 1-second-long press for sleep and a 1-second-long press to wake from sleep + +### Battery Voltage + +- GPIO0 is connected to the battery via a voltage divider (2x10K resistors), reading 1/2 of the actual voltage +- UART0_RXD/GPIO20 can be used to detect USB connection (charging or not) + +### microSD (TF) Card + +SD SPI bus is shared with EPD, GPIO12 is used for CS (SS). + +``` +CS (SS) -> IO12 +DO (MISO) -> IO7 +DI (MOSI) -> IO10 +SCK (SCLK) -> IO8 +``` ### Implementation Notes diff --git a/platformio.ini b/platformio.ini index 75a0c80..3b2e24c 100644 --- a/platformio.ini +++ b/platformio.ini @@ -16,7 +16,6 @@ board_build.partitions = default_16MB.csv ; Libraries lib_deps = zinggjm/GxEPD2@^1.5.9 - https://github.com/pangodream/18650CL ; Build flags build_flags = diff --git a/src/BatteryMonitor.cpp b/src/BatteryMonitor.cpp new file mode 100644 index 0000000..7f40c4b --- /dev/null +++ b/src/BatteryMonitor.cpp @@ -0,0 +1,54 @@ +// BatteryMonitor.cpp +#include "BatteryMonitor.h" +#include "esp_adc_cal.h" + +BatteryMonitor::BatteryMonitor(uint8_t adcPin, float dividerMultiplier) + : _adcPin(adcPin), _dividerMultiplier(dividerMultiplier) +{ +} + +uint16_t BatteryMonitor::readPercentage() const +{ + return percentageFromMillivolts(readMillivolts()); +} + +uint16_t BatteryMonitor::readMillivolts() const +{ + const uint16_t raw = readRawMillivolts(); + const uint32_t mv = millivoltsFromRawAdc(raw); + return static_cast(mv * _dividerMultiplier); +} + +uint16_t BatteryMonitor::readRawMillivolts() const +{ + const uint16_t raw = analogRead(_adcPin); + return raw; +} + +double BatteryMonitor::readVolts() const +{ + return static_cast(readMillivolts()) / 1000.0; +} + +uint16_t BatteryMonitor::percentageFromMillivolts(uint16_t millivolts) +{ + double volts = millivolts / 1000.0; + // Polynomial derived from LiPo samples + double y = -144.9390 * volts * volts * volts + + 1655.8629 * volts * volts - + 6158.8520 * volts + + 7501.3202; + + // Clamp to [0,100] and round + y = max(y, 0.0); + y = min(y, 100.0); + y = round(y); + return static_cast(y); +} + +uint16_t BatteryMonitor::millivoltsFromRawAdc(uint16_t adc_raw) +{ + esp_adc_cal_characteristics_t adc_chars; + esp_adc_cal_characterize(ADC_UNIT_1, ADC_ATTEN_DB_12, ADC_WIDTH_BIT_12, 1100, &adc_chars); + return esp_adc_cal_raw_to_voltage(adc_raw, &adc_chars); +} diff --git a/src/BatteryMonitor.h b/src/BatteryMonitor.h new file mode 100644 index 0000000..aea7a12 --- /dev/null +++ b/src/BatteryMonitor.h @@ -0,0 +1,33 @@ +// BatteryMonitor.h +#pragma once + +#include + +class BatteryMonitor +{ +public: + // Optional divider multiplier parameter defaults to 2.0 + explicit BatteryMonitor(uint8_t adcPin, float dividerMultiplier = 2.0f); + + // Read voltage and return percentage (0-100) + uint16_t readPercentage() const; + + // Read the battery voltage in millivolts (accounts for divider) + uint16_t readMillivolts() const; + + // Read raw millivolts from ADC (doesn't account for divider) + uint16_t readRawMillivolts() const; + + // Read the battery voltage in volts (accounts for divider) + double readVolts() const; + + // Percentage (0-100) from a millivolt value + static uint16_t percentageFromMillivolts(uint16_t millivolts); + + // Calibrate a raw ADC reading and return millivolts + static uint16_t millivoltsFromRawAdc(uint16_t adc_raw); + +private: + uint8_t _adcPin; + float _dividerMultiplier; +}; diff --git a/src/main.cpp b/src/main.cpp index cfaa120..a176ca9 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -3,9 +3,15 @@ #include #include #include -#include "image.h" -#include "Pangodream_18650_CL.h" +#include +#include +#include +#include "image.h" +#include "BatteryMonitor.h" + + +#define SPI_FQ 40000000 // Display SPI pins (custom pins for XteinkX4, not hardware SPI defaults) #define EPD_SCLK 8 // SPI Clock #define EPD_MOSI 10 // SPI MOSI (Master Out Slave In) @@ -20,12 +26,14 @@ #define BTN_GPIO3 3 // Power button (digital) #define UART0_RXD 20 // Used for USB connection detection - #define BAT_GPIO0 0 // Battery voltage -#define READS 10 -#define CONV_FACTOR 1.5176 -Pangodream_18650_CL BL(BAT_GPIO0, CONV_FACTOR, READS); +#define SD_SPI_CS 12 +#define SD_SPI_MISO 7 + +static bool g_sdReady = false; + +static BatteryMonitor g_battery(BAT_GPIO0); static int rawBat = 0; @@ -44,7 +52,7 @@ volatile DisplayCommand displayCommand = DISPLAY_NONE; // GxEPD2 display - Using GxEPD2_426_GDEQ0426T82 // Note: XteinkX4 has 4.26" 800x480 display GxEPD2_BW display( - GxEPD2_426_GDEQ0426T82(EPD_CS, EPD_DC, EPD_RST, EPD_BUSY)); + GxEPD2_426_GDEQ0426T82(EPD_CS, EPD_DC, EPD_RST, EPD_BUSY)); // FreeRTOS task for non-blocking display updates TaskHandle_t displayTaskHandle = NULL; @@ -78,6 +86,7 @@ volatile Button currentPressedButton = NONE; const unsigned long POWER_BUTTON_WAKEUP_MS = 1000; // Time required to confirm boot from sleep const unsigned long POWER_BUTTON_SLEEP_MS = 1000; // Time required to enter sleep mode + // Get button name as string const char *getButtonName(Button btn) { @@ -163,11 +172,90 @@ void drawBatteryInfo() display.printf("Power: %s", charging ? "Charging" : "Battery"); display.setCursor(40, 200); - display.printf("Raw: %i", rawBat); + display.printf("Raw: %i", g_battery.readRawMillivolts()); display.setCursor(40, 240); - display.printf("Volts: %.2f V", BL.getBatteryVolts()); + display.printf("Volts: %.2f V", g_battery.readVolts()); display.setCursor(40, 280); - display.printf("Charge: %i%%", BL.getBatteryChargeLevel()); + display.printf("Charge: %i%%", g_battery.readPercentage()); +} + +// Draw up to top file names from SD on the display, below battery info +static void drawSdTopFiles() +{ + // Layout constants aligned with drawBatteryInfo() block + const int startX = 40; + const int startY = 350; + const int lineHeight = 26; + const int maxLines = 5; + const int maxChars = 30; + + display.setFont(&FreeMonoBold12pt7b); + + display.setCursor(20, 320); + display.print("Top 5 files on SD:"); + + auto drawTruncated = [&](int lineIdx, const char *text) + { + // Render a single line, truncating with ellipsis if needed + String s(text ? text : ""); + if ((int) s.length() > maxChars) + { + s.remove(maxChars - 1); + s += "…"; + } + display.setCursor(startX, startY + lineIdx * lineHeight); + display.print(s); + }; + + // Ensure SD is initialized using global flag; try to init if needed + if (!g_sdReady) + { + if (SD.begin(SD_SPI_CS, SPI, SPI_FQ)) + { + g_sdReady = true; + } + } + + if (!g_sdReady) + { + drawTruncated(0, "No card"); + return; + } + + File root = SD.open("/"); + if (!root || !root.isDirectory()) + { + drawTruncated(0, "No card"); + if (root) root.close(); + return; + } + + int count = 0; + for (File f = root.openNextFile(); f && count < maxLines; f = root.openNextFile()) + { + if (!f.isDirectory()) + { + const char *name = f.name(); + // Ensure only name + extension, no leading path + const char *basename = name; + if (basename) + { + const char *slash = strrchr(basename, '/'); + if (slash && *(slash + 1)) + basename = slash + 1; + } + drawTruncated(count, basename ? basename : ""); + count++; + } + f.close(); + } + + if (count == 0) + { + drawTruncated(0, "Empty"); + } + + root.close(); } // Display update task running on separate core @@ -201,6 +289,8 @@ void displayUpdateTask(void *parameter) // Draw battery information drawBatteryInfo(); + // Draw top 3 SD files below the battery block + drawSdTopFiles(); // Draw image at bottom right int16_t imgWidth = 263; @@ -214,7 +304,7 @@ void displayUpdateTask(void *parameter) else if (cmd == DISPLAY_TEXT) { // Use partial refresh for text updates - display.setPartialWindow(0, 75, display.width(), 300); + display.setPartialWindow(0, 75, display.width(), 225); display.firstPage(); do { @@ -321,6 +411,13 @@ void setup() delay(10); } + if (Serial) + { + // delay for monitor to start reading + delay(1000); + } + + Serial.println("\n================================="); Serial.println(" xteink x4 sample"); Serial.println("================================="); @@ -333,10 +430,21 @@ void setup() pinMode(BTN_GPIO3, INPUT_PULLUP); // Power button // Initialize SPI with custom pins - SPI.begin(EPD_SCLK, -1, EPD_MOSI, EPD_CS); - + SPI.begin(EPD_SCLK,SD_SPI_MISO, EPD_MOSI, EPD_CS); // Initialize display - display.init(115200); + SPISettings spi_settings(SPI_FQ, MSBFIRST, SPI_MODE0); + display.init(115200, true, 2, false, SPI, spi_settings); + + // SD Card Initialization + if (!SD.begin(SD_SPI_CS, SPI, SPI_FQ)) + { + Serial.print("\n SD card not detected\n"); + } + else + { + Serial.print("\n SD card detected\n"); + g_sdReady = true; + } // Setup display properties display.setRotation(3); // 270 degrees @@ -344,6 +452,7 @@ void setup() Serial.println("Display initialized"); + // Draw initial welcome screen currentPressedButton = NONE; displayCommand = DISPLAY_INITIAL; @@ -362,6 +471,39 @@ void setup() Serial.println("Setup complete!\n"); } +#ifdef DEBUG_IO +void debugIO() +{ + // Log raw analog levels of BTN1 and BTN2 not more often than once per second + rawBat = analogRead(BAT_GPIO0); + int rawBtn1 = analogRead(BTN_GPIO1); + int rawBtn2 = analogRead(BTN_GPIO2); + int rawBtn3 = digitalRead(BTN_GPIO3); + Serial.print("ADC BTN1="); + Serial.print(rawBtn1); + Serial.print(" BTN2="); + Serial.print(rawBtn2); + Serial.print(" BTN3="); + Serial.print(rawBtn3); + Serial.println(""); + + // log battery info + Serial.printf("== Battery (charging: %s) ==\n", isCharging() ? "yes" : "no"); + Serial.print("Value from pin (raw/calibrated): "); + Serial.print(rawBat); + Serial.print(" / "); + Serial.println(BatteryMonitor::millivoltsFromRawAdc(rawBat)); + Serial.print("Volts: "); + Serial.println(g_battery.readVolts()); + Serial.print("Charge level: "); + Serial.println(g_battery.readPercentage()); + Serial.println(""); + + // SD card +} +#endif + + void loop() { Button currentButton = GetPressedButton(); @@ -375,6 +517,10 @@ void loop() currentPressedButton = currentButton; displayCommand = DISPLAY_TEXT; +#ifdef DEBUG_IO + debugIO(); +#endif + if (currentButton == POWER) { unsigned long startTime = millis(); @@ -391,36 +537,5 @@ void loop() lastButton = currentButton; -#ifdef DEBUG_IO - // Log raw analog levels of BTN1 and BTN2 not more often than once per second - static unsigned long lastLogMs = 0; - unsigned long now = millis(); - if (now - lastLogMs >= 1000) - { - rawBat = analogRead(BAT_GPIO0); - int rawBtn1 = analogRead(BTN_GPIO1); - int rawBtn2 = analogRead(BTN_GPIO2); - int rawBtn3 = digitalRead(BTN_GPIO3); - Serial.print("ADC BTN1="); - Serial.print(rawBtn1); - Serial.print(" BTN2="); - Serial.print(rawBtn2); - Serial.print(" BTN3="); - Serial.print(rawBtn3); - - Serial.println(""); - - Serial.print("Value from pin: "); - Serial.println(rawBat); - Serial.print("Average value from pin: "); - Serial.println(BL.pinRead()); - Serial.print("Volts: "); - Serial.println(BL.getBatteryVolts()); - Serial.print("Charge level: "); - Serial.println(BL.getBatteryChargeLevel()); - Serial.println(""); - lastLogMs = now; - } delay(50); -#endif }