From b31e80a71b57bb031237b52affa71e49b5e45208 Mon Sep 17 00:00:00 2001 From: Lago Date: Fri, 3 Apr 2026 16:25:55 +0200 Subject: [PATCH] feat: battery monitoring, external RTC persistence, SD used-space fix - Add BQ27220 fuel gauge: reads SOC percentage every 5s - Add BQ25896 PMIC: detects charging status (pre-charge/fast/done) - Add MAX31329 external RTC: reads time on boot, syncs from NTP - Battery UI: icon + percentage in header, cyan when charging - Fix SD used bytes: fallback to recursive dir walk when usedBytes() returns 0 - Libraries: PMIC_BQ25896, kode_bq27220, kode_MAX31329 --- platformio.ini | 3 + src/main.cpp | 149 +++++++++++++++++++++++++++++++++++++++++++++++++ src/ui.cpp | 63 +++++++++++++++++++++ src/ui.h | 1 + 4 files changed, 216 insertions(+) diff --git a/platformio.ini b/platformio.ini index b68c953..dd938c1 100644 --- a/platformio.ini +++ b/platformio.ini @@ -65,3 +65,6 @@ lib_deps = https://github.com/bitbank2/bb_captouch.git https://github.com/xreef/SimpleFTPServer.git bblanchon/ArduinoJson @ ^7 + https://github.com/sqmsmu/PMIC_BQ25896.git + https://github.com/kodediy/kode_bq27220.git + https://github.com/kodediy/kode_MAX31329.git diff --git a/src/main.cpp b/src/main.cpp index 66748eb..ae8e848 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -9,6 +9,7 @@ #include #include +#include #include #include #include @@ -19,6 +20,9 @@ #include #include #include +#include +#include +#include #include "ui.h" // --------------------------------------------------------------------------- @@ -37,6 +41,15 @@ static Adafruit_NeoPixel pixel(NEO_PIXEL_COUNT, NEO_PIXEL_PIN, NEO_GRB + NEO_KHZ // FTP server FtpServer ftpSrv; +// Battery fuel gauge (BQ27220 @ 0x55) +static BQ27220 fuelGauge; + +// PMIC / Charger (BQ25896 @ 0x6B) +static PMIC_BQ25896 pmic; + +// External RTC (MAX31329 @ 0xD0) +static MAX31329 rtc; + // --------------------------------------------------------------------------- // LED colour definitions // --------------------------------------------------------------------------- @@ -53,7 +66,11 @@ static bool sd_ok = false; static bool wifi_connected = false; static bool ftp_client_on = false; static bool ftp_transfer = false; +static bool rtc_ok = false; +static bool gauge_ok = false; +static bool pmic_ok = false; static unsigned long last_time_update = 0; +static unsigned long last_batt_update = 0; // --------------------------------------------------------------------------- // NeoPixel helpers @@ -84,10 +101,30 @@ static void updateLed() { // --------------------------------------------------------------------------- /** Get used / total space on SD card in MiB and push to UI */ +static uint64_t calcDirSize(fs::FS &fs, const char *dirname) { + uint64_t total = 0; + File root = fs.open(dirname); + if (!root || !root.isDirectory()) return 0; + File f = root.openNextFile(); + while (f) { + if (f.isDirectory()) { + total += calcDirSize(fs, f.path()); + } else { + total += f.size(); + } + f = root.openNextFile(); + } + return total; +} + static void updateSdInfo() { if (!sd_ok) return; uint64_t total = SD_MMC.totalBytes() / (1024ULL * 1024ULL); uint64_t used = SD_MMC.usedBytes() / (1024ULL * 1024ULL); + /* SD_MMC.usedBytes() returns 0 on exFAT / large cards — walk filesystem */ + if (used == 0 && total > 0) { + used = calcDirSize(SD_MMC, "/") / (1024ULL * 1024ULL); + } ui_set_sd(used, total); } @@ -157,6 +194,36 @@ void ftpTransferCallback(FtpTransferOperation ftpOperation, const char *name, ui updateLed(); } +// --------------------------------------------------------------------------- +// Battery monitoring +// --------------------------------------------------------------------------- + +/** Update battery percentage and charging status every 5 seconds */ +static void updateBattery() { + unsigned long now = millis(); + if (now - last_batt_update < 5000) return; + last_batt_update = now; + + int soc = -1; + bool charging = false; + + /* Read state-of-charge from fuel gauge */ + if (gauge_ok) { + soc = fuelGauge.readStateOfChargePercent(); + if (soc < 0 || soc > 100) soc = -1; // sanity check + } + + /* Read charging status from PMIC */ + if (pmic_ok) { + pmic.setCONV_START(true); // trigger ADC conversion + uint8_t chrg_stat = pmic.get_VBUS_STAT_reg().chrg_stat; + // chrg_stat: 0=Not Charging, 1=Pre-charge, 2=Fast Charging, 3=Charge Done + charging = (chrg_stat == 1 || chrg_stat == 2); + } + + ui_set_battery(soc, charging); +} + // --------------------------------------------------------------------------- // Time display // --------------------------------------------------------------------------- @@ -269,6 +336,82 @@ static bool initWiFi() { return false; } +/** Initialise I2C bus and battery / RTC peripherals */ +static void initPeripherals() { + Wire.begin(48, 47); // SDA=48, SCL=47 + + /* ── Fuel Gauge (BQ27220 @ 0x55) ──────────────────────────── */ + gauge_ok = fuelGauge.begin(); + if (gauge_ok) { + int soc = fuelGauge.readStateOfChargePercent(); + int mv = fuelGauge.readVoltageMillivolts(); + Serial.printf("[BAT] Fuel gauge ready — SOC: %d%% Voltage: %d mV\n", soc, mv); + } else { + Serial.println("[BAT] Warning: BQ27220 fuel gauge not found"); + } + + /* ── PMIC (BQ25896 @ 0x6B) ───────────────────────────────── */ + pmic.begin(); + pmic_ok = pmic.isConnected(); + if (pmic_ok) { + pmic.setCONV_START(true); // trigger first ADC conversion + Serial.printf("[BAT] PMIC ready — VBUS: %d mV VBAT: %d mV\n", + pmic.getVBUSV(), pmic.getBATV()); + } else { + Serial.println("[BAT] Warning: BQ25896 PMIC not found"); + } + + /* ── External RTC (MAX31329 @ 0xD0) ──────────────────────── */ + rtc.begin(); + /* Try reading time — if year is valid (>2020), set ESP32 system clock */ + if (rtc.readTime()) { + if (rtc.t.year >= 2020 && rtc.t.year <= 2099) { + rtc_ok = true; + struct tm t = {}; + t.tm_year = rtc.t.year - 1900; + t.tm_mon = rtc.t.month - 1; + t.tm_mday = rtc.t.day; + t.tm_hour = rtc.t.hour; + t.tm_min = rtc.t.minute; + t.tm_sec = rtc.t.second; + struct timeval tv = { .tv_sec = mktime(&t), .tv_usec = 0 }; + settimeofday(&tv, NULL); + Serial.printf("[RTC] External RTC time: %04d-%02d-%02d %02d:%02d:%02d\n", + rtc.t.year, rtc.t.month, rtc.t.day, + rtc.t.hour, rtc.t.minute, rtc.t.second); + } else { + Serial.printf("[RTC] External RTC has invalid year (%d) — will set from NTP\n", rtc.t.year); + } + } else { + Serial.println("[RTC] Warning: MAX31329 external RTC not responding"); + } +} + +/** Write current system time to external RTC (call after NTP sync) */ +static void syncRtcFromSystem() { + if (!rtc_ok && !gauge_ok) return; // RTC not available + + struct tm timeinfo; + if (!getLocalTime(&timeinfo, 0)) return; + + rtc.t.year = timeinfo.tm_year + 1900; + rtc.t.month = timeinfo.tm_mon + 1; + rtc.t.day = timeinfo.tm_mday; + rtc.t.hour = timeinfo.tm_hour; + rtc.t.minute = timeinfo.tm_min; + rtc.t.second = timeinfo.tm_sec; + rtc.t.dayOfWeek = timeinfo.tm_wday; + + if (rtc.writeTime()) { + rtc_ok = true; + Serial.printf("[RTC] Updated external RTC: %04d-%02d-%02d %02d:%02d:%02d\n", + rtc.t.year, rtc.t.month, rtc.t.day, + rtc.t.hour, rtc.t.minute, rtc.t.second); + } else { + Serial.println("[RTC] Warning: Failed to write time to external RTC"); + } +} + /** Configure NTP with Vienna timezone and wait briefly for sync */ static void initNtp() { Serial.println("[NTP] Configuring time (Vienna CET/CEST)..."); @@ -280,6 +423,8 @@ static void initNtp() { char buf[32]; strftime(buf, sizeof(buf), "%Y-%m-%d %H:%M:%S", &timeinfo); Serial.printf("[NTP] Time synced: %s\n", buf); + /* Write NTP time to external RTC for persistence */ + syncRtcFromSystem(); return; } } @@ -336,6 +481,9 @@ void setup() { // Create the LVGL status UI ui_init(); + // Initialise I2C peripherals (battery, RTC) + initPeripherals(); + // IO Expander — check SD card presence if (!checkSdPresence()) { Serial.println("[SD] WARNING: SD card not detected by IO expander"); @@ -388,5 +536,6 @@ void loop() { ftpSrv.handleFTP(); display.update(); updateTimeDisplay(); + updateBattery(); delay(5); } \ No newline at end of file diff --git a/src/ui.cpp b/src/ui.cpp index a1c2e13..8e07a16 100644 --- a/src/ui.cpp +++ b/src/ui.cpp @@ -42,6 +42,9 @@ static lv_obj_t *lbl_time; static lv_obj_t *lbl_date; static lv_obj_t *obj_error_bar; static lv_obj_t *lbl_error; +static lv_obj_t *lbl_battery; +static lv_obj_t *obj_batt_icon; +static lv_obj_t *obj_batt_fill; /* ── cached IP for FTP URL ───────────────────────────────────────── */ static char cached_ip[48] = "0.0.0.0"; @@ -77,6 +80,32 @@ void ui_init() LV_ALIGN_TOP_MID, 0, 16, "FTP Explorer"); lv_obj_set_style_text_letter_space(lbl_title, 2, 0); + /* ── battery indicator (top-right) ─────────────────────────── */ + /* battery outline (22x12 rounded rect) */ + obj_batt_icon = lv_obj_create(scr); + lv_obj_set_size(obj_batt_icon, 30, 14); + lv_obj_set_style_radius(obj_batt_icon, 3, 0); + lv_obj_set_style_bg_opa(obj_batt_icon, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_color(obj_batt_icon, lv_color_hex(CLR_GRAY), 0); + lv_obj_set_style_border_width(obj_batt_icon, 1, 0); + lv_obj_set_style_pad_all(obj_batt_icon, 2, 0); + lv_obj_clear_flag(obj_batt_icon, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_align(obj_batt_icon, LV_ALIGN_TOP_RIGHT, -28, 22); + + /* battery fill bar (inside the outline) */ + obj_batt_fill = lv_obj_create(obj_batt_icon); + lv_obj_set_size(obj_batt_fill, 0, 8); + lv_obj_set_style_radius(obj_batt_fill, 1, 0); + lv_obj_set_style_bg_color(obj_batt_fill, lv_color_hex(CLR_GREEN), 0); + lv_obj_set_style_bg_opa(obj_batt_fill, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(obj_batt_fill, 0, 0); + lv_obj_clear_flag(obj_batt_fill, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_align(obj_batt_fill, LV_ALIGN_LEFT_MID, 0, 0); + + /* battery percentage text */ + lbl_battery = make_label(scr, &lv_font_montserrat_12, CLR_GRAY, + LV_ALIGN_TOP_RIGHT, -62, 23, "--"); + /* subtle separator line */ static lv_point_precise_t line_pts[] = {{40, 0}, {370, 0}}; line_sep = lv_line_create(scr); @@ -322,3 +351,37 @@ void ui_set_error(const char *msg) lv_obj_add_flag(obj_error_bar, LV_OBJ_FLAG_HIDDEN); } } + +/* ── ui_set_battery ──────────────────────────────────────────────── */ +void ui_set_battery(int pct, bool charging) +{ + /* pct < 0 means fuel gauge unavailable */ + if (pct < 0) { + lv_label_set_text(lbl_battery, "--"); + lv_obj_set_size(obj_batt_fill, 0, 8); + return; + } + if (pct > 100) pct = 100; + + /* percentage label */ + if (charging) { + lv_label_set_text_fmt(lbl_battery, LV_SYMBOL_CHARGE " %d%%", pct); + lv_obj_set_style_text_color(lbl_battery, lv_color_hex(CLR_CYAN), 0); + } else { + lv_label_set_text_fmt(lbl_battery, "%d%%", pct); + lv_obj_set_style_text_color(lbl_battery, lv_color_hex(CLR_GRAY), 0); + } + + /* fill bar width: max ~22px (inner width of battery outline) */ + int fill_w = (22 * pct) / 100; + if (fill_w < 1 && pct > 0) fill_w = 1; + lv_obj_set_size(obj_batt_fill, fill_w, 8); + + /* colour: green > 20%, red <= 20%, cyan when charging */ + uint32_t clr = CLR_GREEN; + if (charging) clr = CLR_CYAN; + else if (pct <= 20) clr = CLR_RED; + lv_obj_set_style_bg_color(obj_batt_fill, lv_color_hex(clr), 0); + lv_obj_set_style_border_color(obj_batt_icon, + lv_color_hex(charging ? CLR_CYAN : CLR_GRAY), 0); +} diff --git a/src/ui.h b/src/ui.h index e5ed3e8..44a1da8 100644 --- a/src/ui.h +++ b/src/ui.h @@ -8,4 +8,5 @@ void ui_set_ftp(const char *status); void ui_set_time(const char *time_str); void ui_set_date(const char *date_str); void ui_set_sd(uint64_t used_mb, uint64_t total_mb); +void ui_set_battery(int pct, bool charging); void ui_set_error(const char *msg);