diff --git a/.gitignore b/.gitignore index ebeefa5..a93933c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,10 @@ .pio.nosync .pio-core/ +.dummy/ +managed_components/ +sdkconfig.defaults +sdkconfig.esp32s3 +sdkconfig.* .vscode/c_cpp_properties.json .vscode/extensions.json .vscode/launch.json @@ -16,3 +21,8 @@ dist/ build/ /web_apps/**/*.gz + +# PDF text extraction (intermediate) +docs/9.0.5/extracted/ + +.pio/ diff --git a/docs/9.0.5/BQ27427_Notes_EN.md b/docs/9.0.5/BQ27427_Notes_EN.md new file mode 100644 index 0000000..ee8c448 --- /dev/null +++ b/docs/9.0.5/BQ27427_Notes_EN.md @@ -0,0 +1,102 @@ +# BQ27427 Quick Reference (English) + +Sources: TI datasheet SLUSEBSA (`bq27427_datasheet.pdf`) and Technical +Reference Manual SLUUCD5 (`bq27427_trm_sluucd5.pdf`). Raw extracted text in +`extracted/`. All protocol details verified on hardware (see +`tools/probe_9.0.5/`). + +## Device + +Single-cell Li-ion system-side Impedance Track fuel gauge with integrated +7 mOhm sense resistor. I2C up to 400 kHz, **fixed 7-bit address 0x55** +(write byte 0xAA, read byte 0xAB). Chem profiles: 3230 (4.35 V default), +1202 (4.2 V), 3142 (4.4 V). NORMAL 50 uA / SLEEP 9 uA. Package 9-ball DSBGA (YZF). + +## I2C protocol + +``` +write: S 0xAA CMD DATA... P +read: S 0xAA CMD Sr 0xAB DATA[7:0] DATA[7:0] N P +``` + +- Standard commands return 2 bytes, little-endian; address pointer + auto-increments after each ack; reads above 0x6B are NACKed. +- Standard-command results update at least every 2 s; read-only commands at + most twice per second. t(BUF) >= 66 us between packets at 400 kHz. +- The I2C engine releases SDA/SCL if held low for 2 s. + +## Standard Commands (TRM Table 5-1) + +| Command | Code | Unit | Access | +|---|---|---|---| +| Control() | 0x00-0x01 | - | R/W | +| Temperature() | 0x02-0x03 | 0.1 K | R/W | +| Voltage() | 0x04-0x05 | mV | R | +| Flags() | 0x06-0x07 | - | R | +| NominalAvailableCapacity() | 0x08-0x09 | mAh | R | +| FullAvailableCapacity() | 0x0A-0x0B | mAh | R | +| RemainingCapacity() | 0x0C-0x0D | mAh | R | +| FullChargeCapacity() | 0x0E-0x0F | mAh | R | +| AverageCurrent() | 0x10-0x11 | mA | R | +| StandbyCurrent() | 0x12-0x13 | mA | R | +| MaxLoadCurrent() | 0x14-0x15 | mA | R | +| AveragePower() | 0x18-0x19 | mW | R | +| StateOfCharge() | 0x1C-0x1D | % | R | +| InternalTemperature() | 0x1E-0x1F | 0.1 K | R | +| **StateOfHealth()** | **0x20-0x21** | % | R | +| RemainingCapacityUnfiltered() | 0x28-0x29 | mAh | R | +| RemainingCapacityFiltered() | 0x2A-0x2B | mAh | R | +| FullChargeCapacityUnfiltered() | 0x2C-0x2D | mAh | R | +| FullChargeCapacityFiltered() | 0x2E-0x2F | mAh | R | +| StateOfChargeUnfiltered() | 0x30-0x31 | % | R | + +Notes: StateOfHealth is at 0x20 (TRM 5.13), not 0x2E; BQ27427 has **no** +CycleCount standard command (tables found online mix in BQ27441 info). +Verified live: SOH @0x20 reads 94%. + +## Control() Subcommands (TRM Table 5-2) + +Write CONTROL (0x00) + 2-byte little-endian subcommand, re-point to 0x00, +read 2 bytes. + +| Subcommand | Value | Sealed | Description | +|---|---|---|---| +| CONTROL_STATUS | 0x0000 | yes | status word (bit13 SS = sealed) | +| DEVICE_TYPE | 0x0001 | yes | **0x0427 for BQ27427** | +| FW_VERSION | 0x0002 | yes | 0x0202 (current ROM) | +| DM_CODE | 0x0004 | yes | data memory config code | +| PREV_MACWRITE | 0x0007 | yes | previous MAC command | +| CHEM_ID | 0x0008 | yes | hex-nibble chem id (0x1202 = "1202") | +| BAT_INSERT / BAT_REMOVE | 0x000C/0x000D | yes | force BAT_DET flag | +| SET_CFGUPDATE | 0x0013 | no | enter CONFIG UPDATE mode | +| SMOOTH_SYNC | 0x0019 | yes | sync smoothed capacity | +| SHUTDOWN_ENABLE / SHUTDOWN | 0x001B/0x001C | no | shutdown mode | +| SEALED | 0x0020 | no | enter sealed mode | +| PULSE_SOC_INT | 0x0023 | yes | 1 ms GPOUT pulse | +| CHEM_A/B/C | 0x0030-0x0032 | no | switch chem 3230/1202/3142 | +| RESET | 0x0041 | no | full device reset | +| SOFT_RESET | 0x0042 | no | exit CONFIG UPDATE, resume gauging | + +UNSEAL key: 0x8000, written twice. + +## Chem switch to 1202 (4.2 V cell, verified) + +``` +1. (if sealed) UNSEAL 0x8000 x2 +2. SET_CFGUPDATE 0x0013 +3. wait 1 s +4. CHEM_B 0x0031 +5. wait 100 ms +6. SOFT_RESET 0x0042 +7. wait 2 s +8. verify CHEM_ID == 0x1202 +``` + +Persists in NVM. + +## Data timing + +- Voltage/current: immediate. +- SOC: valid ~2 s after reset or chem switch. +- FCC / SOH: converge after ~1-2 full charge/discharge cycles (new cell or + right after a chem switch). diff --git "a/docs/9.0.5/BQ27427_\344\270\255\346\226\207\346\211\213\345\206\214.md" "b/docs/9.0.5/BQ27427_\344\270\255\346\226\207\346\211\213\345\206\214.md" new file mode 100644 index 0000000..dec36d6 --- /dev/null +++ "b/docs/9.0.5/BQ27427_\344\270\255\346\226\207\346\211\213\345\206\214.md" @@ -0,0 +1,150 @@ +# BQ27427 中文技术手册(摘要翻译) + +> 来源:TI 数据手册 SLUSEBSA(bq27427.pdf)+ 技术参考手册 SLUUCD5(sluucd5.pdf) +> 原始 PDF 见本目录。整理:2026-08-14,用于 HDS 9.0.5 固件开发 +> 所有协议细节均经真机验证(见 tools/probe_9.0.5/) + +## 1. 芯片概述 + +BQ27427 是 TI 的**单节锂电池系统侧电量计(fuel gauge)**,采用专利 Impedance Track™ 阻抗跟踪算法: + +- 内置 7 mΩ 集成采样电阻(无需外部检流电阻) +- 板上供电:直接由电池供电,内置 LDO +- 预编程三种化学特性配置(Chem ID):**3230(4.35 V 默认)**、1202(4.2 V)、3142(4.4 V) +- 极低功耗:NORMAL 50 µA / SLEEP 9 µA +- 自动适应电池老化、自放电、温度与负载变化 +- 报告:剩余容量(mAh)、电量百分比 SOC(%)、电池电压(mV)、健康度 SOH(%) +- 封装:9-ball DSBGA(YZF),1.62 × 1.58 mm + +## 2. 接口 + +| 项目 | 值 | +|---|---| +| 通信接口 | I2C,最高 400 kHz | +| I2C 7-bit 地址 | **固定 0x55**(1010101,不可更改)| +| 写地址字节 | 0xAA | +| 读地址字节 | 0xAB | +| 上拉电阻 | SCL/SDA 需外部 10 kΩ 上拉(典型)| + +## 3. I2C 通信协议 + +``` +写(1 字节命令): S 0xAA CMD[7:0] DATA[7:0] P +读(2 字节数据): S 0xAA CMD[7:0] Sr 0xAB DATA[7:0] DATA[7:0] N P +``` + +- 标准命令 = 2 字节数据,需两次连续 I2C 传输;多字节数据**低字节在前(Little-Endian)** +- 地址指针在每次应答后自动 +1;超过 0x6B 的地址读取被 NACK +- I2C 引擎在总线被拉低 2 秒后释放 SDA/SCL +- 标准命令结果 ≥2 秒更新;只读命令每秒最多发起 2 次 +- 400 kHz 下两次传输间需 t(BUF) ≥ 66 µs + +## 4. 标准命令表(TRM Table 5-1) + +| 命令 | 命令码 | 单位 | 访问 | +|---|---|---|---| +| Control() | 0x00-0x01 | - | 读写 | +| Temperature() | 0x02-0x03 | 0.1 K | 读写 | +| Voltage() | 0x04-0x05 | mV | 只读 | +| Flags() | 0x06-0x07 | - | 只读 | +| NominalAvailableCapacity() | 0x08-0x09 | mAh | 只读 | +| FullAvailableCapacity() | 0x0A-0x0B | mAh | 只读 | +| RemainingCapacity() | 0x0C-0x0D | mAh | 只读 | +| FullChargeCapacity() | 0x0E-0x0F | mAh | 只读 | +| AverageCurrent() | 0x10-0x11 | mA | 只读 | +| StandbyCurrent() | 0x12-0x13 | mA | 只读 | +| MaxLoadCurrent() | 0x14-0x15 | mA | 只读 | +| AveragePower() | 0x18-0x19 | mW | 只读 | +| StateOfCharge() | 0x1C-0x1D | % | 只读 | +| InternalTemperature() | 0x1E-0x1F | 0.1 K | 只读 | +| **StateOfHealth()** | **0x20-0x21** | % | 只读 | +| RemainingCapacityUnfiltered() | 0x28-0x29 | mAh | 只读 | +| RemainingCapacityFiltered() | 0x2A-0x2B | mAh | 只读 | +| FullChargeCapacityUnfiltered() | 0x2C-0x2D | mAh | 只读 | +| FullChargeCapacityFiltered() | 0x2E-0x2F | mAh | 只读 | +| StateOfChargeUnfiltered() | 0x30-0x31 | % | 只读 | + +**注意**: +- **StateOfHealth() = 0x20**(TRM 5.13 节):SOH = 预测 FCC(25°C, SOH LoadI)÷ DesignCapacity, + 返回 0-100%。网上有些表写 SOH 在 0x2E / CycleCount 在 0x20,那是 BQ27441 等老芯片的; + **BQ27427 没有 CycleCount 标准命令**(整本 TRM 无 Cycle)。真机验证:SOH@0x20 读出 94%。 +- 温度 0.1 K 分辨率:°C = raw × 0.1 - 273.15 + +## 5. Control() 子命令(TRM Table 5-2) + +使用方式:向 0x00 写入 2 字节子命令(低字节在前),**重新指向 0x00**,再读 2 字节结果。 + +| 子命令 | 值 | SEALED 可访问 | 说明 | +|---|---|---|---| +| CONTROL_STATUS | 0x0000 | ✓ | 状态字(bit13 SS=密封)| +| **DEVICE_TYPE** | **0x0001** | ✓ | 设备类型,**BQ27427 返回 0x0427** | +| FW_VERSION | 0x0002 | ✓ | 固件版本(当前 ROM 0x0202)| +| DM_CODE | 0x0004 | ✓ | Data Memory 配置码 | +| PREV_MACWRITE | 0x0007 | ✓ | 上一条 MAC 命令码 | +| CHEM_ID | 0x0008 | ✓ | 化学特性 ID(十六进制字符编码:0x3230="3230")| +| BAT_INSERT / BAT_REMOVE | 0x000C/0x000D | ✓ | 强制电池在位标志 | +| SET_CFGUPDATE | 0x0013 | ✗ | 进入 CONFIG UPDATE 模式 | +| SMOOTH_SYNC | 0x0019 | ✓ | 平滑容量同步 | +| SHUTDOWN_ENABLE / SHUTDOWN | 0x001B/0x001C | ✗ | 关机模式 | +| SEALED | 0x0020 | ✗ | 进入密封模式 | +| PULSE_SOC_INT | 0x0023 | ✓ | GPOUT 1 ms 脉冲 | +| CHEM_A/B/C | 0x0030-0x0032 | ✗ | 切换化学特性 3230/1202/3142 | +| RESET | 0x0041 | ✗ | 全器件复位 | +| SOFT_RESET | 0x0042 | ✗ | 退出 CONFIG UPDATE,恢复计量 | + +**UNSEAL 密钥:0x8000,需连续写两次。** + +## 6. 开发要点(实测确认) + +### 6.1 开机检测 + +1. 扫描 I2C 地址 0x55; +2. CONTROL + DEVICE_TYPE(0x0001)返回 **0x0427** → 确认; +3. 无应答或类型不符 → 判定无电量计,全部相关功能禁用。 + +### 6.2 化学特性切换(4.2 V 电池 → Chem 1202) + +芯片出厂默认 3230(4.35 V)。切换流程(TRM 5.1.15 + 应用示例,真机验证): + +``` +1. (若密封)UNSEAL 0x8000 ×2 +2. SET_CFGUPDATE 0x0013 +3. 等 1 s(IT 处理停止) +4. CHEM_B 0x0031(→ 1202) +5. 等 100 ms +6. SOFT_RESET 0x0042 +7. 等 2 s(SOC 重新有效) +8. 读 CHEM_ID 验证 = 0x1202 +``` + +切换结果持久化在 NVM,重启无需重复。 + +### 6.3 读取示例 + +```c +// 电压(mV) +Wire.beginTransmission(0x55); Wire.write(0x04); Wire.endTransmission(true); +Wire.requestFrom(0x55, 2); // 低字节在前,0x42 0x10 → 4162 mV + +// 电流(AverageCurrent 0x10,有符号 int16 mA,>0 = 流入电池 = 充电) +Wire.beginTransmission(0x55); Wire.write(0x10); Wire.endTransmission(true); +Wire.requestFrom(0x55, 2); + +// Control 子命令:写 CONTROL+子命令(带 STOP)→ 重新指向 0x00 → 读 2 字节 +Wire.beginTransmission(0x55); +Wire.write(0x00); Wire.write(0x01); Wire.write(0x00); // DEVICE_TYPE +Wire.endTransmission(true); +delay(2); +Wire.beginTransmission(0x55); Wire.write(0x00); Wire.endTransmission(true); +Wire.requestFrom(0x55, 2); // 0x27 0x04 → 0x0427 +``` + +**实测提示**:所有写操作必须带 STOP(`endTransmission(true)`),否则 CONTROL +子命令不执行;地址指针不会自动回 0,读前必须重新指向 0x00。 + +### 6.4 数据时效 + +- 电压/电流:即时 +- SOC:复位/化学切换后约 2 秒有效 +- FCC(满充容量)/SOH(健康度):需要电池经历**约 1-2 个完整充放电循环**学习收敛 + (新电池或刚切换化学特性后) diff --git a/docs/9.0.5/PR_DESCRIPTION.md b/docs/9.0.5/PR_DESCRIPTION.md new file mode 100644 index 0000000..7412eb3 --- /dev/null +++ b/docs/9.0.5/PR_DESCRIPTION.md @@ -0,0 +1,150 @@ +# PR: HDS 9.0.5 support — BQ27427 battery fuel gauge + +## Summary + +HDS 9.0.5 hardware adds a **TI BQ27427YZFR single-cell Li-ion fuel gauge** +(fixed I2C address 0x55, Impedance Track algorithm) compared to 8.x boards. +This PR adds detection, initialization, battery info, and battery +protection while keeping full compatibility with 8.x hardware — +**one single firmware binary serves all boards**, the board type is +detected at runtime. + +## Design principles + +- **Single firmware, runtime detection**: one PlatformIO env (`esp32s3`). + At startup the firmware probes I2C address 0x55 (with retries for + cold-start timing) and verifies DEVICE_TYPE == 0x0427. When the gauge is + present (9.0.5), battery voltage/percent come from the gauge, GPIO6 + becomes CHRG_CTRL (charge enable) and GPIO14 is the gauge GPOUT. When + absent (8.x), the legacy ADC/ADS1115 battery path is used and GPIO6 stays + the battery ADC input. No compile-time board variant is needed. +- **Clean config structure**: the `V8_1` pin block stays pure 8.1; the + 9.0.5 differences (CHRG_CTRL=6, GPOUT=14) live in a separate section + with a comment explaining the runtime role switch. +- **ADS1115 support retained**: `ADS_init()` is gated on `!b_hasFuelGauge`; + boards that later drop the ADS1115 fall back to the existing internal-ADC + path. +- **No changes to existing menu framework logic**: new menu items are + registered with the existing mechanism. `mainMenuSize()` centralizes the + effective main-menu size so returning from a submenu cannot resurrect + hidden items (review fix). + +## Changes + +| File | Change | +|---|---| +| `include/config.h` | 9.0.5 differences section (CHRG_CTRL=6, GPOUT=14) after the pure 8.1 block | +| `include/fuel_gauge.h` | Driver API (pin macros overridable) | +| `src/fuel_gauge.cpp` | Detection with retry, Chem 1202 enforcement, CC_GAIN sign-bit fix, read API, charging via TP4056 CHRG pin, low-SOC notify, design-capacity query/set, deep-sleep hook | +| `include/fuel_gauge_menu.h` | Bat. Info menu: full dual-column page with gauge, 4-line page without | +| `include/menu.h` | Register `menuBatInfo` + `menuBatteryProtect`; `mainMenuSize()`; `compactMainMenu()` hides Battery Protect on gauge-less boards; runtime battery paths | +| `include/parameter.h` | New globals `b_hasFuelGauge`, `b_batteryProtect` | +| `include/power.h` | `batteryPercent()` (gauge SOC or legacy map, clamped 0-100); runtime battery paths | +| `include/storage.h` | `KEY_BAT_PROTECT`, `KEY_BAT_CAPACITY_SET` (+ migration path) | +| `include/usbcomm.h` | `bc` command (query/set design capacity); runtime-gated legacy commands | +| `include/decent_protocol.h`, `include/websocket.h` | Battery percent via `batteryPercent()` | +| `src/hds.ino` | `fuelGaugeBegin()` + `compactMainMenu()`; one-time design-capacity write (700 mAh); runtime battery paths | +| `platformio.ini` | Gauge library dep; CPU 80 MHz config; single `esp32s3` env | +| `tools/bq27427_probe/` | I2C diagnostics probe project | +| `.gitignore`, `lib/README.md` | Build artifacts, library source notes | + +## Library dependency + +`edrean/BQ27427 Battery Fuel Gauge Arduino Library @ 1.0.4` (MIT, resolved +automatically from the PlatformIO registry; upstream: +https://github.com/edreanernst/BQ27427_Arduino_Library) + +## Features + +### 1. Boot detection and initialization + +1. Probe I2C address 0x55 with retries; no ACK -> treat as 8.x. +2. Read CONTROL + DEVICE_TYPE (0x0001), verify 0x0427. +3. Chemistry check: if CHEM_ID != 1202 run the TRM switch flow with long + CFGUPMODE timeouts and sealed-state restore (the library's setChemID + uses a 50 ms timeout and its chemID()/CHEM_B comparison never matches + the hex-nibble encoding the chip returns). Hard-coded for the 4.2 V cell. +4. CC_GAIN sign-bit fix: early batches ship a negative coulomb-counter gain + which inverts current/power readings; the value lives in RAM and resets + on POR, so it is re-checked and fixed on every boot. +5. Design capacity: written **once** (700 mAh) when the NVS flag + `KEY_BAT_CAPACITY_SET` is unset, so reflashing firmware never resets the + value. The `bc` USB command can query/set it manually (300-2000 mAh + range, user-initiated, does not touch the flag). + +### 2. Bat. Info menu + +- With gauge (9.0.5): one dual-column page — voltage, chip temp, current, + power, capacity, FCC, health, battery level, charging, USB; plus a note + page. 500 ms live refresh; ENTER cycles, NEXT steps back (exit on first + page). +- Without gauge (8.x): 4-line page — voltage, battery level, charging, USB. + Battery percent is clamped to 0-100 (the legacy voltage map extrapolates + above 100 % at full charge). +- Charging state reads the **TP4056 CHRG pin** (GPIO10, low = charging). + +### 3. Battery Protect (9.0.5, off by default, persisted in NVS) + +Menu on/off. When enabled, SOC >= 80 % pulls CHRG_CTRL low to cut off the +charger; charging resumes at 75 % (hysteresis). Verified on hardware +(cutoff triggered at 97 % on a nearly-full battery). + +### 4. Low-SOC notify + +Serial notification (once per transition) when SOC drops below 10 %. + +## Testing + +All boards run the same firmware binary. + +### HDS 9.0.5 (with BQ27427) + +| Item | Result | +|---|---| +| Device identification | DEVICE_TYPE 0x0427, FW 0x0202 | +| Chemistry | 1202 (4.2 V) enforced, persists across reboots | +| CC_GAIN | negative sign bit detected and fixed at boot (0xB4 -> 0x34) | +| Design capacity | set once to 700 mAh; FCC then re-evaluated (1256 -> 646 mAh, SOH ~92 %) | +| Charging (CHRG pin) | low = charging, verified | +| Bat. Info menu | dual-column page, cycling, 500 ms refresh | +| Battery Protect | CHRG_CTRL gating verified (cutoff at 97 %) | +| `bc` command | query + write verified | +| Weighing | normal operation, no regression | + +### HDS 8.3.1 (no BQ27427) + +| Item | Result | +|---|---| +| Boot | normal | +| Gauge probe | skipped cleanly after retries | +| Bat. Info menu | 4-line page (voltage/level/CRG/USB), percent clamped | +| Weighing | normal operation, no regression | + +### Notes + +- 8.2.0 board: new-NVS migration path was missing the new keys, which + blocked first boot; fixed (keys are now ensured in the migration path + too). The board's weighing readouts were erratic without a load cell + attached — hardware setup, not firmware. +- ADS1232 effective-resolution test screen lives in a separate copy + (`openscale9_0_5_bittest/`, not part of this PR): 3 rounds of 10 s / + 100 samples each, reporting min/max/range/distinct/effective bits and + the minimum division for a 2 kg range. 8.3.1 measured ~17.2-17.6 + effective bits (~0.01 g division); 9.0.5 similar. + +## Out of scope / follow-ups + +- [ ] Deep-sleep hook wiring into the actual `power.h` sleep path (API only + in this PR); GPOUT (GPIO14) available for SOC_INT wake-up +- [ ] BLE command for design capacity (USB `bc` only for now) +- [ ] Menu registration single-point refactor (separate task) +- [ ] Long-term observation of FCC/SOH learning convergence (~1-2 full cycles) +- [ ] Cycle-count feature: not supported by BQ27427 (no CycleCount command); + host-side counting or SOH can be used instead + +## References + +- TI datasheet SLUSEBSA / TRM SLUUCD5 (see `docs/9.0.5/`, Chinese translation included) +- Key protocol details (Control() read method, chem-switch flow, SOH at 0x20, + CC_GAIN sign-bit issue, STOP-terminated writes) documented in + `docs/9.0.5/BQ27427_中文手册.md` and `BQ27427_Notes_EN.md` diff --git a/docs/9.0.5/README.md b/docs/9.0.5/README.md new file mode 100644 index 0000000..02b870c --- /dev/null +++ b/docs/9.0.5/README.md @@ -0,0 +1,45 @@ +# HDS 9.0.5 support + +本目录记录 openscale(上一正式版 8.3.1)对 **HDS 9.0.5 硬件**的支持工作。 +9.0.5 与 8.3.1 的差异:板上新增 **TI BQ27427YZFR** 电池电量计(I2C 固定 0x55)。 + +**兼容性核心原则(经用户确认)**:开机通过 I2C 地址查找芯片;找不到 BQ27427 时, +Bat. Info 菜单、深睡钩子、化学特性配置全部不启用,8.3.1 板行为与旧版完全一致。 +ADS1115 功能保留:有电量计时跳过其初始化/使用,无则照旧;未来去掉 ADS1115 的 +板子走已有内部 ADC 回退路径。 + +## 代码位置(已合入 openscale9_0_5) + +- `src/fuel_gauge.h/cpp` — 兼容层驱动(自包含,引脚宏可覆盖) +- `src/fuel_gauge_menu.h/cpp` — Bat. Info 菜单(分页数据页,末页后退出) +- `lib/BQ27427_Battery_Fuel_Gauge_Arduino_Library-1.0.4/` — 主参考库(MIT,Arduino 注册表版) +- `lib/BQ27427-Arduino-arkhipenko/` — 备选库(测试用) +- `tools/probe_9.0.5/` — 原始协议探针(裸 Wire 验证) +- `tools/driver_test_9.0.5/` — 驱动冒烟测试(真机验证驱动 API) +- `docs/9.0.5/` — 中文手册(PDF + 翻译)、本说明 +- `platformio.ini` — 含 80 MHz CPU 配置(f_cpu + custom_sdkconfig 组合) + 与 BQ27427 库依赖 + +## 实测结论(2026-08-14,真机) + +| 项目 | 结果 | +|---|---| +| I2C 总线设备 | 0x48(ADS1115)、0x55(BQ27427)| +| DEVICE_TYPE / FW_VERSION | 0x0427 / 0x0202 | +| 化学特性 | 已切 **0x1202(4.2 V)** 并持久化 | +| 电池读数 | 4162 mV / 99% SOC / 1237 mAh / SOH 94% / 27.8 °C | +| 充电识别 | AverageCurrent >30 mA 判充电;USB 用原 USB_DET 逻辑 | + +## 关键踩坑记录 + +1. Control() 子命令:写后指针停在 0x02,必须重新指向 0x00 再读;所有写带 STOP。 +2. CHEM_ID 返回十六进制字符编码(0x1202 ≠ 十进制 1202)。 +3. 化学切换必须 SOFT_RESET 才生效(TRM 5.1.15)。 +4. SOH=0x20、无 CycleCount(BQ27427 TRM;网上表混入 BQ27441 的信息)。 +5. PlatformIO 6.1.19 的 `src_dir` 只认 `[platformio]` 段。 +6. CH9102 重枚举后端口名会变,刷写前 `ls /dev/cu.*` 确认。 + +## 合入主固件的改动点(integration) + +见 `integration.md`(同目录): +hds.ino 开机调用、menu.h 注册、power.h 电压来源切换、深睡钩子、platformio.ini。 diff --git a/docs/9.0.5/bq27427_datasheet.pdf b/docs/9.0.5/bq27427_datasheet.pdf new file mode 100644 index 0000000..2f17e44 Binary files /dev/null and b/docs/9.0.5/bq27427_datasheet.pdf differ diff --git a/docs/9.0.5/bq27427_trm_sluucd5.pdf b/docs/9.0.5/bq27427_trm_sluucd5.pdf new file mode 100644 index 0000000..9b479d3 Binary files /dev/null and b/docs/9.0.5/bq27427_trm_sluucd5.pdf differ diff --git a/docs/9.0.5/integration.md b/docs/9.0.5/integration.md new file mode 100644 index 0000000..3391b4c --- /dev/null +++ b/docs/9.0.5/integration.md @@ -0,0 +1,29 @@ +# HDS 9.0.5 合入改动清单(openscale9_0_5 分支) + +本次合入的完整改动。未推送 GitHub。 + +## 已改文件 + +| 文件 | 改动 | +|---|---| +| `src/hds.ino` | `#include "fuel_gauge.h"`;`Wire.begin` 后调用 `fuelGaugeBegin(); compactMainMenu();` | +| `src/fuel_gauge.h` | 驱动 API 声明;`extern volatile bool b_hasFuelGauge`;引脚宏(可覆盖) | +| `src/fuel_gauge.cpp` | 检测(0x55 + DEVICE_TYPE 0x0427)、Chem 1202 强制(TRM 流程)、读取 API、深睡钩子 | +| `src/fuel_gauge_menu.h` | Bat. Info 菜单(header-only,14 项分页,末页后 NEXT 退出) | +| `include/menu.h` | include 两个新头;`Menu menuBatInfo`;`mainMenu[]` 加入;`compactMainMenu()`(无芯片时运行时移除菜单项) | +| `include/parameter.h` | `volatile bool b_hasFuelGauge`(全局状态按规范放这里) | +| `platformio.ini` | `lib_deps` 加 `edrean/BQ27427 Battery Fuel Gauge Arduino Library @ 1.0.4`(注册表自动下载);80 MHz CPU 配置(f_cpu + custom_sdkconfig) | +| `.gitignore` | 加 `.dummy/`、`managed_components/`、`sdkconfig.*` | +| `lib/README.md` | 库来源说明(注册表 + GitHub,勿手动导入) | + +## 兼容性原则(用户确认) + +- 开机 I2C 探测 0x55 + DEVICE_TYPE 验证;找不到 → 菜单无 Bat. Info、深睡钩子 no-op、Chem 配置跳过,8.3.1 板行为与旧版完全一致(`compactMainMenu` 运行时移除菜单项)。 +- ADS1115:有电量计时跳过初始化/使用;无则照旧;未来去 ADS1115 的板子走内部 ADC 回退。 + +## 待办(合入后) + +1. `power.h` 的 `updateBattery()`:电量计存在时用 `fuelGaugeVoltageV()` 作为电压源,跳过 ADS1115 路径(`ADS_init` 调用加 `if (!b_hasFuelGauge)` 条件) +2. `power.h` 的 `esp32_sleep()`:深睡前调用 `fuelGaugeSleep()` +3. 菜单注册单点化重构(单独任务) +4. 8.3.1 板回归测试(无 0x55 时菜单与旧版一致) diff --git a/include/config.h b/include/config.h index eb07f10..4af7fe5 100644 --- a/include/config.h +++ b/include/config.h @@ -99,6 +99,12 @@ #define SCALE_A0 -1 #endif +// HDS 9.0.5 shares the 8.1 pinout; the differences are runtime-selected by +// fuel gauge detection (fuelGaugeBegin): GPIO6 is the battery ADC input on +// 8.x boards and CHRG_CTRL (charge enable, low = disabled) on 9.0.5, and +// GPIO14 is the gauge's GPOUT line. +#define CHRG_CTRL 6 +#define GPOUT 14 #ifdef V8_0 #define PCB_VER (char*)"PCB: 8.0.0" diff --git a/include/decent_protocol.h b/include/decent_protocol.h index 8623149..7019922 100644 --- a/include/decent_protocol.h +++ b/include/decent_protocol.h @@ -265,11 +265,7 @@ static inline void buildLedResponsePacket(byte data[7]) { if (b_is_charging) { batteryByte = 0xFF; } else { - float perc = (f_batteryVoltage - showEmptyBatteryBelowVoltage) / - (showFullBatteryAboveVoltage - showEmptyBatteryBelowVoltage) * 100.0f; - if (perc < 0) perc = 0; - if (perc > 100) perc = 100; - batteryByte = (byte)perc; + batteryByte = (byte)batteryPercent(); } data[0] = 0x03; diff --git a/include/fuel_gauge.h b/include/fuel_gauge.h new file mode 100644 index 0000000..85b4a78 --- /dev/null +++ b/include/fuel_gauge.h @@ -0,0 +1,46 @@ +// BQ27427 fuel gauge compat layer for HDS 9.0.5. +// 8.3.1 boards have no gauge: every call no-ops and reports absent. +#ifndef FUEL_GAUGE_H +#define FUEL_GAUGE_H +#include + +extern volatile bool b_hasFuelGauge; +extern volatile bool b_batteryProtect; + +#ifndef FUEL_GAUGE_I2C_SDA +#define FUEL_GAUGE_I2C_SDA 5 +#endif +#ifndef FUEL_GAUGE_I2C_SCL +#define FUEL_GAUGE_I2C_SCL 4 +#endif +#ifndef FUEL_GAUGE_USB_DET +#define FUEL_GAUGE_USB_DET 8 +#endif +// Charger CHRG status pin (TP4056 output): low while charging. +#ifndef FUEL_GAUGE_CHRG_DET +#define FUEL_GAUGE_CHRG_DET 10 +#endif + +bool fuelGaugeBegin(); +void fuelGaugeLoop(); +void fuelGaugeProtectSet(bool enable); +float fuelGaugeVoltageV(); +int16_t fuelGaugeCurrentMa(); +int16_t fuelGaugeStandbyMa(); +int16_t fuelGaugeMaxLoadMa(); +int16_t fuelGaugePowerMw(); +uint16_t fuelGaugeCapacityMah(); +uint16_t fuelGaugeFullCapacityMah(); +uint16_t fuelGaugeDesignCapacityMah(); +bool fuelGaugeSetCapacity(uint16_t mAh); +uint8_t fuelGaugeSoHPercent(); +uint8_t fuelGaugeSocPercent(); +float fuelGaugeTempC(); +bool fuelGaugeCharging(); +bool fuelGaugeFullCharged(); +bool fuelGaugeDischarging(); +bool fuelGaugeChgAllowed(); +bool fuelGaugeUsbPlugged(); +void fuelGaugeSleep(); + +#endif diff --git a/include/fuel_gauge_menu.h b/include/fuel_gauge_menu.h new file mode 100644 index 0000000..5f52038 --- /dev/null +++ b/include/fuel_gauge_menu.h @@ -0,0 +1,111 @@ +// Bat. Info menu for the BQ27427 fuel gauge, following the showStatus +// pattern: draw a frame, hold it, then poll buttons. ENTER cycles pages, +// NEXT steps back (exit on first page). +#ifndef FUEL_GAUGE_MENU_H +#define FUEL_GAUGE_MENU_H +#include "fuel_gauge.h" +#include "power.h" +#include "display.h" + +#ifndef MENU_BTN_ENTER +#define MENU_BTN_ENTER 2 +#endif +#ifndef MENU_BTN_BACK +#define MENU_BTN_BACK 1 +#endif + +static bool b_showBatInfoData = false; +static bool s_showBatInfoBusy = false; +static int i_batInfoPage = 0; + +static int batInfoTotalPages() { + return b_hasFuelGauge ? 2 : 1; +} + +static void drawBatInfoPage() { + char pageInfo[10]; + snprintf(pageInfo, sizeof(pageInfo), "%d/%d", i_batInfoPage + 1, + batInfoTotalPages()); + + u8g2.firstPage(); + u8g2.setFont(u8g2_font_6x12_tr); + do { + u8g2.drawUTF8(AR(pageInfo), 12, pageInfo); + if (!b_hasFuelGauge) { + char l[8]; + snprintf(l, sizeof(l), "%.2fV", f_batteryVoltage); + u8g2.drawUTF8(0, 12, l); + snprintf(l, sizeof(l), "%d%%", batteryPercent()); + u8g2.drawUTF8(0, 24, l); + snprintf(l, sizeof(l), "CRG %s", fuelGaugeUsbPlugged() ? "Y" : "N"); + u8g2.drawUTF8(0, 36, l); + snprintf(l, sizeof(l), "USB %s", fuelGaugeUsbPlugged() ? "Y" : "N"); + u8g2.drawUTF8(0, 48, l); + } else if (i_batInfoPage == 0) { + char l[12]; + char r[12]; + snprintf(l, sizeof(l), "%.2f V", fuelGaugeVoltageV()); + snprintf(r, sizeof(r), "%.1f C", fuelGaugeTempC()); + u8g2.drawUTF8(0, 12, l); + u8g2.drawUTF8(64, 12, r); + snprintf(l, sizeof(l), "%d mA", fuelGaugeCurrentMa()); + snprintf(r, sizeof(r), "%d mW", fuelGaugePowerMw()); + u8g2.drawUTF8(0, 24, l); + u8g2.drawUTF8(64, 24, r); + snprintf(l, sizeof(l), "Cap %u", fuelGaugeCapacityMah()); + snprintf(r, sizeof(r), "FCC %u", fuelGaugeFullCapacityMah()); + u8g2.drawUTF8(0, 36, l); + u8g2.drawUTF8(64, 36, r); + snprintf(l, sizeof(l), "Health %u%%", fuelGaugeSoHPercent()); + snprintf(r, sizeof(r), "Bat. %u%%", fuelGaugeSocPercent()); + u8g2.drawUTF8(0, 48, l); + u8g2.drawUTF8(64, 48, r); + snprintf(l, sizeof(l), "CRG %s", fuelGaugeCharging() ? "Y" : "N"); + snprintf(r, sizeof(r), "USB %s", fuelGaugeUsbPlugged() ? "Y" : "N"); + u8g2.drawUTF8(0, 60, l); + u8g2.drawUTF8(64, 60, r); + } else { + u8g2.drawUTF8(0, 12, "True cap & health"); + u8g2.drawUTF8(0, 24, "after a full charge"); + u8g2.drawUTF8(0, 36, "and discharge cycle"); + } + } while (u8g2.nextPage()); +} + +void showBatInfo() { + if (s_showBatInfoBusy) { + return; + } + s_showBatInfoBusy = true; + b_showBatInfoData = true; + i_batInfoPage = 0; + drawBatInfoPage(); + delay(1000); + unsigned long t_lastRefresh = millis(); + while (b_showBatInfoData) { + if (digitalRead(MENU_BTN_ENTER) == LOW) { + i_batInfoPage = (i_batInfoPage + 1) % batInfoTotalPages(); + drawBatInfoPage(); + t_lastRefresh = millis(); + delay(250); + } + if (digitalRead(MENU_BTN_BACK) == LOW) { + if (i_batInfoPage <= 0) { + b_showBatInfoData = false; + } else { + i_batInfoPage--; + drawBatInfoPage(); + t_lastRefresh = millis(); + delay(250); + } + } + if (millis() - t_lastRefresh >= 500) { + drawBatInfoPage(); + t_lastRefresh = millis(); + } + delay(50); + } + s_showBatInfoBusy = false; +} + +#endif diff --git a/include/menu.h b/include/menu.h index 75b010f..e19ef77 100644 --- a/include/menu.h +++ b/include/menu.h @@ -3,6 +3,8 @@ #include "esp32-hal.h" #include "parameter.h" +#include "fuel_gauge.h" +#include "fuel_gauge_menu.h" #if HDS_FEATURE_WIFI #include "mdns_name.h" #include "wifi_setup.h" @@ -46,12 +48,15 @@ void showWifiStatus(); #endif void heartbeatOn(); void heartbeatOff(); +void batteryProtectOn(); +void batteryProtectOff(); void calibrate(); void drawButton(); #if HDS_FEATURE_PULL_OTA void wifiUpdate(); #endif void showStatus(); +void compactMainMenu(); void showAbout(); void showMenu(); void showLogo(); @@ -97,9 +102,11 @@ Menu menuWifi = { "WiFi Settings", NULL, NULL, NULL }; #endif Menu menuStatus = { "Status", showStatus, NULL, NULL }; Menu menuAbout = { "About", showAbout, NULL, NULL }; +Menu menuBatInfo = { "Bat. Info", showBatInfo, NULL, NULL }; Menu menuLogo = { "Show Logo", showLogo, NULL, NULL }; Menu menuFactory = { "Factory", NULL, NULL, NULL }; Menu menuHeartbeat = { "Heartbeat", NULL, NULL, NULL }; +Menu menuBatteryProtect = { "Battery Protect", NULL, NULL, NULL }; Menu menuFlipScreen = { "Flip Screen", NULL, NULL, NULL }; Menu menuTimeOnTop = { "Time On Top", NULL, NULL, NULL }; Menu menuBtnFuncWhileConnected = { "Button with BLE", NULL, NULL, NULL }; @@ -148,6 +155,11 @@ Menu menuHeartbeatOn = { "Heartbeat On", heartbeatOn, NULL, &menuHeartbeat }; Menu menuHeartbeatOff = { "Heartbeat Off", heartbeatOff, NULL, &menuHeartbeat }; Menu *heartbeatMenu[] = { &menuHeartbeatBack, &menuHeartbeatOn, &menuHeartbeatOff }; +Menu menuBatProtectBack = { "Back", NULL, NULL, &menuBatteryProtect }; +Menu menuBatProtectOn = { "Protect On", batteryProtectOn, NULL, &menuBatteryProtect }; +Menu menuBatProtectOff = { "Protect Off", batteryProtectOff, NULL, &menuBatteryProtect }; +Menu *batteryProtectMenu[] = { &menuBatProtectBack, &menuBatProtectOn, + &menuBatProtectOff }; Menu menuFlipScreenBack = { "Back", NULL, NULL, &menuFlipScreen }; Menu menuFlipScreenOn = { "Flip On", flipScreenOn, NULL, &menuFlipScreen }; @@ -214,6 +226,8 @@ Menu *mainMenu[] = { &menuStatus, &menuAbout, &menuLogo, &menuHeartbeat, &menuFlipScreen, &menuTimeOnTop, &menuBtnFuncWhileConnected, &menuAutoSleep, &menuQuickBoot, &menuDriftComp, + &menuBatInfo, + &menuBatteryProtect, #if HDS_ENABLE_GRINDER &menuGrinder, #endif @@ -221,6 +235,13 @@ Menu *mainMenu[] = { Menu **currentMenu = mainMenu; Menu *currentSelection = mainMenu[0]; int currentMenuSize = getMenuSize(mainMenu); + +// Battery Protect is hidden on gauge-less hardware (removed from the array +// by compactMainMenu); centralize the effective size so returning from any +// submenu cannot resurrect it. +int mainMenuSize() { + return getMenuSize(mainMenu) - (b_hasFuelGauge ? 0 : 1); +} int currentIndex = 0; const int linesPerPage = 4; @@ -236,6 +257,7 @@ void linkSubmenus() { menuWifi.subMenu = wifiUpdateMenu[0]; #endif menuHeartbeat.subMenu = heartbeatMenu[0]; + menuBatteryProtect.subMenu = batteryProtectMenu[0]; menuFlipScreen.subMenu = flipScreenMenu[0]; menuTimeOnTop.subMenu = timeOnTopMenu[0]; menuBtnFuncWhileConnected.subMenu = btnFuncWhileConnectedMenu[0]; @@ -265,7 +287,7 @@ void exitMenu() { b_grinderMenuDirectEntry = false; #endif currentMenu = mainMenu; - currentMenuSize = getMenuSize(mainMenu); + currentMenuSize = mainMenuSize(); currentIndex = 0; currentSelection = currentMenu[currentIndex]; t_menuExitTime = millis(); @@ -461,6 +483,26 @@ void heartbeatOn() { Serial.println("Heartbeat detection...On"); } +void batteryProtectOn() { + b_batteryProtect = true; + storagePutBool(KEY_BAT_PROTECT, true); + fuelGaugeProtectSet(true); + actionMessage = "Protect On"; + t_actionMessage = millis(); + t_actionMessageDelay = 1000; + Serial.println("Battery protect...On"); +} + +void batteryProtectOff() { + b_batteryProtect = false; + storagePutBool(KEY_BAT_PROTECT, false); + fuelGaugeProtectSet(false); + actionMessage = "Protect Off"; + t_actionMessage = millis(); + t_actionMessageDelay = 1000; + Serial.println("Battery protect...Off"); +} + void heartbeatOff() { b_requireHeartBeat = false; actionMessage = "Heartbeat Off"; @@ -1593,7 +1635,11 @@ void calibrateVoltage() { long adcSum = 0; for (int i = 0; i < numReadings; i++) { - adcSum += analogRead(BATTERY_PIN); + if (!b_hasFuelGauge) { + adcSum += analogRead(BATTERY_PIN); + } else { + adcSum += (long)(fuelGaugeVoltageV() * 1000.0f); + } delay(10); } @@ -1617,6 +1663,23 @@ void navigateMenu(int direction) { Serial.println(currentIndex); } +void compactMainMenu() { + // 8.3.1 keeps Bat. Info (limited data without the gauge), but Battery + // Protect needs the gauge and the CHRG_CTRL pin. + if (b_hasFuelGauge) { + return; + } + for (int i = 0; i < currentMenuSize; i++) { + if (mainMenu[i] == &menuBatteryProtect) { + for (int j = i; j < currentMenuSize - 1; j++) { + mainMenu[j] = mainMenu[j + 1]; + } + currentMenuSize--; + break; + } + } +} + void selectMenu() { if (currentSelection->subMenu) { #ifdef BUZZER @@ -1636,6 +1699,9 @@ void selectMenu() { } else if (currentSelection == &menuHeartbeat) { currentMenu = heartbeatMenu; currentMenuSize = getMenuSize(heartbeatMenu); + } else if (currentSelection == &menuBatteryProtect) { + currentMenu = batteryProtectMenu; + currentMenuSize = getMenuSize(batteryProtectMenu); } else if (currentSelection == &menuFlipScreen) { currentMenu = flipScreenMenu; currentMenuSize = getMenuSize(flipScreenMenu); @@ -1673,7 +1739,7 @@ void selectMenu() { } #endif currentMenu = mainMenu; - currentMenuSize = getMenuSize(mainMenu); + currentMenuSize = mainMenuSize(); currentIndex = 0; currentSelection = currentMenu[currentIndex]; } diff --git a/include/parameter.h b/include/parameter.h index a4774b4..a607249 100644 --- a/include/parameter.h +++ b/include/parameter.h @@ -384,6 +384,8 @@ bool b_minus_container_button = false; //是否减去称豆容器 bool b_ready_to_brew = false; //准备冲煮并计时 bool b_is_charging = false; //正在充电标识 bool b_espnow = false; +volatile bool b_hasFuelGauge = false; //BQ27427 电量计存在(9.0.5) +volatile bool b_batteryProtect = false; //电池保护:充电上限 80%(9.0.5) //bool b_debug = DEBUG; //debug信息显示 #if DEBUG_BATTERY bool b_debug_battery = false; //debug电池信息 diff --git a/include/power.h b/include/power.h index 0527657..2d25208 100644 --- a/include/power.h +++ b/include/power.h @@ -8,6 +8,7 @@ #include "gyro.h" #endif #include "espnow.h" +#include "fuel_gauge.h" #ifdef ESP32 #include "driver/rtc_io.h" @@ -55,6 +56,21 @@ const float referenceVoltage = 3.3; const float lowBatteryThreshold = 3.2; +int batteryPercent() { + if (b_hasFuelGauge) { + return fuelGaugeSocPercent(); + } + int p = map(f_batteryVoltage * 1000, showEmptyBatteryBelowVoltage * 1000, + showFullBatteryAboveVoltage * 1000, 0, 100); + if (p < 0) { + p = 0; + } + if (p > 100) { + p = 100; + } + return p; +} + void (*resetFunc)(void) = 0; void reset() { @@ -349,7 +365,11 @@ void power_off(int min) { if (!b_is_charging) { if (!b_softSleep) { if (f_batteryVoltage < lowBatteryThreshold) { - updateBattery(BATTERY_PIN); +if (!b_hasFuelGauge) { + updateBattery(BATTERY_PIN); + } else { + f_batteryVoltage = fuelGaugeVoltageV(); + } } if (f_batteryVoltage > lowBatteryThreshold) { i_lowBatteryCount = 0; @@ -404,7 +424,11 @@ void power_off(double sec) { if (!b_is_charging) { if (!b_softSleep) { if (f_batteryVoltage < lowBatteryThreshold) { - updateBattery(BATTERY_PIN); +if (!b_hasFuelGauge) { + updateBattery(BATTERY_PIN); + } else { + f_batteryVoltage = fuelGaugeVoltageV(); + } } if (f_batteryVoltage > lowBatteryThreshold) { i_lowBatteryCount = 0; @@ -453,8 +477,8 @@ float get_bat_voltage() { #endif //CHECKBATTERY void checkBattery() { - float perc = map(f_batteryVoltage * 1000, showEmptyBatteryBelowVoltage * 1000, showFullBatteryAboveVoltage * 1000, 0, 100); -#if defined(V7_4) || defined(V7_5) || defined(V8_0) || defined(V8_1) + float perc = batteryPercent(); +#if defined(V7_4) || defined(V7_5) || defined(V8_0) || defined(V8_1) || defined(V9_0_5) if (digitalRead(USB_DET) == LOW) { #else if (digitalRead(BATTERY_CHARGING) == LOW) { diff --git a/include/storage.h b/include/storage.h index 998f8f1..3ce6df9 100644 --- a/include/storage.h +++ b/include/storage.h @@ -27,6 +27,8 @@ constexpr const char *KEY_WIFI_BOOT = "wifi_boot"; constexpr const char *KEY_AUTO_SLEEP = "auto_sleep"; constexpr const char *KEY_QUICK_BOOT = "quick_boot"; constexpr const char *KEY_DRIFT_MAX = "drift_max"; +constexpr const char *KEY_BAT_PROTECT = "bat_protect"; +constexpr const char *KEY_BAT_CAPACITY_SET = "bat_cap_set"; constexpr size_t LEGACY_EEPROM_BYTES = 512; constexpr size_t LEGACY_CAL1_ADDRESS = 0; @@ -116,7 +118,7 @@ inline bool storageHasAllSettings() { KEY_CAL1, KEY_CONTAINER, KEY_MODE, KEY_POUROVER, KEY_ESPRESSO, KEY_BEEP, KEY_WELCOME, KEY_BAT_CAL, KEY_HEARTBEAT, KEY_SCREEN_FLIP, KEY_TIME_ON_TOP, KEY_BTN_CONN, KEY_WIFI_BOOT, KEY_AUTO_SLEEP, - KEY_QUICK_BOOT, KEY_DRIFT_MAX + KEY_QUICK_BOOT, KEY_DRIFT_MAX, KEY_BAT_PROTECT, KEY_BAT_CAPACITY_SET }; for (const char *key : keys) { if (!settingsPreferences.isKey(key)) { @@ -142,7 +144,9 @@ inline bool storageEnsureDefaults() { storageEnsureBool(KEY_WIFI_BOOT, false) && storageEnsureBool(KEY_AUTO_SLEEP, true) && storageEnsureBool(KEY_QUICK_BOOT, false) && - storageEnsureFloat(KEY_DRIFT_MAX, 0.05f); + storageEnsureFloat(KEY_DRIFT_MAX, 0.05f) && + storageEnsureBool(KEY_BAT_PROTECT, false) && + storageEnsureBool(KEY_BAT_CAPACITY_SET, false); } inline bool storageLegacyBool(size_t address, bool defaultValue) { @@ -232,7 +236,9 @@ inline bool storageMigrateLegacyEeprom() { storageEnsureBool(KEY_WIFI_BOOT, storageLegacyBool(LEGACY_WIFI_BOOT_ADDRESS, false)) && storageEnsureBool(KEY_AUTO_SLEEP, storageLegacyBool(LEGACY_AUTO_SLEEP_ADDRESS, true)) && storageEnsureBool(KEY_QUICK_BOOT, storageLegacyBool(LEGACY_QUICK_BOOT_ADDRESS, false)) && - storageEnsureFloat(KEY_DRIFT_MAX, driftMax); + storageEnsureFloat(KEY_DRIFT_MAX, driftMax) && + storageEnsureBool(KEY_BAT_PROTECT, false) && + storageEnsureBool(KEY_BAT_CAPACITY_SET, false); EEPROM.end(); return migrated; } diff --git a/include/usbcomm.h b/include/usbcomm.h index bd0a799..4a8ead6 100644 --- a/include/usbcomm.h +++ b/include/usbcomm.h @@ -318,7 +318,7 @@ class MyUsbCallbacks { if (inputString.startsWith("v")) { Serial.print("Battery Voltage:"); Serial.print(f_batteryVoltage); - if (b_ads1115InitFail) { + if (b_ads1115InitFail && !b_hasFuelGauge) { int adcValue = analogRead(BATTERY_PIN); float voltageAtPin = (adcValue / adcResolution) * referenceVoltage; Serial.print("\tADC Voltage:"); @@ -330,7 +330,7 @@ class MyUsbCallbacks { Serial.print(i_lowBatteryCountTotal); } - if (inputString.startsWith("vf ")) { + if (!b_hasFuelGauge && inputString.startsWith("vf ")) { int adcValue = analogRead(BATTERY_PIN); float voltageAtPin = (adcValue / adcResolution) * referenceVoltage; float batteryVoltage = voltageAtPin * dividerRatio; @@ -341,6 +341,20 @@ class MyUsbCallbacks { Serial.println(f_batteryCalibrationFactor); } + if (inputString.startsWith("bc")) { + if (inputString.length() > 2) { + uint16_t cap = inputString.substring(3).toInt(); + if (fuelGaugeSetCapacity(cap)) { + Serial.printf("Battery capacity set to: %u mAh\n", cap); + } else { + Serial.println("Invalid capacity (300-2000 mAh)"); + } + } else { + Serial.printf("Battery capacity: %u mAh\n", + fuelGaugeDesignCapacityMah()); + } + } + if (inputString.startsWith("cv ")) { float newCalibrationValue = inputString.substring(3).toFloat(); if (isValidCalibrationValue(newCalibrationValue)) { diff --git a/include/websocket.h b/include/websocket.h index d903a86..83e3e3d 100644 --- a/include/websocket.h +++ b/include/websocket.h @@ -101,11 +101,7 @@ bool websocketIsCharging() { } int websocketBatteryPercent() { - float perc = (f_batteryVoltage - showEmptyBatteryBelowVoltage) / - (showFullBatteryAboveVoltage - showEmptyBatteryBelowVoltage) * 100.0f; - if (perc < 0) perc = 0; - if (perc > 100) perc = 100; - return (int)perc; + return batteryPercent(); } #endif diff --git a/lib/README.md b/lib/README.md new file mode 100644 index 0000000..ab6f957 --- /dev/null +++ b/lib/README.md @@ -0,0 +1,8 @@ +# Local library dir. + +BQ27427 libraries are resolved automatically from the PlatformIO registry and GitHub: +- edrean/BQ27427 Battery Fuel Gauge Arduino Library @ 1.0.4 (primary, MIT) + https://github.com/edreanernst/BQ27427_Arduino_Library +- https://github.com/arkhipenko/BQ27427-Arduino (alternative) + +Do not vendor libraries here; use lib_deps in platformio.ini. diff --git a/platformio.ini b/platformio.ini index 21fbdcb..25b892d 100644 --- a/platformio.ini +++ b/platformio.ini @@ -43,6 +43,13 @@ extra_scripts = # The vendored copy is kept in partitions/default_8MB.csv. board_build.partitions = partitions/default_8MB.csv board_build.filesystem = littlefs +; Run the ESP32-S3 CPU at 80 MHz instead of the 240 MHz default to cut power draw. +; board_build.f_cpu makes pioarduino generate the matching sdkconfig entries +; (disabling the other frequency choices); custom_sdkconfig must be present to +; activate the HybridCompile path that applies them, so it is set to a comment. +board_build.f_cpu = 80000000L +custom_sdkconfig = + # keep HybridCompile active so board_build.f_cpu is applied monitor_speed = 115200 upload_speed = 921600 build_flags = @@ -63,6 +70,7 @@ build_flags = # lib_compat_mode = strict lib_deps = + edrean/BQ27427 Battery Fuel Gauge Arduino Library @ 1.0.4 bblanchon/ArduinoJson @ 7.4.3 bxparks/AceButton @ 1.10.1 https://github.com/decentespresso/ADS1232_ADC.git#24cd10d3037c878f751a057377303598a00f977e diff --git a/src/fuel_gauge.cpp b/src/fuel_gauge.cpp new file mode 100644 index 0000000..bf9ad00 --- /dev/null +++ b/src/fuel_gauge.cpp @@ -0,0 +1,304 @@ +#include "fuel_gauge.h" +#include "config.h" +#include +#include + +#define BQ_ADDR 0x55 +#define LOW_SOC_NOTIFY_PERCENT 10 +#define PROTECT_CHARGE_LIMIT 80 +#define PROTECT_CHARGE_RESUME 75 + +static BQ27427 bq; +static bool s_present = false; + +static void writeControlOnly(uint16_t subcmd) { + Wire.beginTransmission(BQ_ADDR); + Wire.write(0x00); + Wire.write(subcmd & 0xFF); + Wire.write(subcmd >> 8); + Wire.endTransmission(true); +} + +static uint16_t readControl(uint16_t subcmd) { + writeControlOnly(subcmd); + delay(2); + Wire.beginTransmission(BQ_ADDR); + Wire.write(0x00); + Wire.endTransmission(true); + uint16_t value = 0; + if (Wire.requestFrom(BQ_ADDR, 2) == 2) { + value = Wire.read(); + value |= (uint16_t)Wire.read() << 8; + } + return value; +} + +// The library's setChemID polls CFGUPMODE with a 50 ms timeout, which is too +// short for this gauge; do the TRM flow manually with generous timeouts and +// restore the sealed state the chip came in with. +static bool waitFlag(uint16_t mask, bool set, unsigned long timeoutMs) { + unsigned long t0 = millis(); + while (millis() - t0 < timeoutMs) { + if (((bq.flags() & mask) != 0) == set) { + return true; + } + delay(10); + } + return false; +} + +static bool enforceChem1202() { + // CHEM_ID returns the chem number as hex-nibble text (0x1202 = "1202"); + // the library's chemID()/CHEM_B comparison never matches, so read raw. + if (readControl(0x0008) == 0x1202) { + return true; + } + bool wasSealed = (readControl(0x0000) >> 13) & 1; + if (wasSealed) { + writeControlOnly(0x8000); + writeControlOnly(0x8000); + } + Serial.println("fuelGauge: switching chemistry to 1202 (4.2 V)"); + writeControlOnly(0x0013); // SET_CFGUPDATE + if (!waitFlag(BQ27427_FLAG_CFGUPMODE, true, 2000)) { + Serial.println("fuelGauge: chemistry switch FAILED (cfgupdate)"); + if (wasSealed) { + writeControlOnly(0x0020); + } + return false; + } + delay(1000); // let IT processing stop before changing chemistry + writeControlOnly(0x0031); // CHEM_B + delay(100); + writeControlOnly(0x0042); // SOFT_RESET + if (!waitFlag(BQ27427_FLAG_CFGUPMODE, false, 2000)) { + Serial.println("fuelGauge: chemistry switch FAILED (soft reset)"); + if (wasSealed) { + writeControlOnly(0x0020); + } + return false; + } + delay(500); // let the chem id settle after the reset + bool ok = readControl(0x0008) == 0x1202; + if (wasSealed) { + writeControlOnly(0x0020); + } + Serial.printf("fuelGauge: chemistry switch %s\n", ok ? "OK" : "FAILED"); + return ok; +} + +// Early BQ27427 batches ship with a wrong CC_GAIN sign bit (negative), which +// inverts current/power readings and the DSG flag. The value lives in RAM and +// resets to the ROM default on every POR, so re-check and fix on each boot. +static void i2cWrite(uint8_t reg, const uint8_t *data, uint8_t len) { + Wire.beginTransmission(BQ_ADDR); + Wire.write(reg); + for (uint8_t i = 0; i < len; i++) { + Wire.write(data[i]); + } + Wire.endTransmission(true); +} + +static uint8_t i2cRead(uint8_t reg) { + Wire.beginTransmission(BQ_ADDR); + Wire.write(reg); + Wire.endTransmission(true); + Wire.requestFrom(BQ_ADDR, 1); + return Wire.available() ? Wire.read() : 0; +} + +static void loadCcGainBlock() { + uint8_t v = 0x69; + i2cWrite(0x3E, &v, 1); // subclass 105 + v = 0x00; + i2cWrite(0x3F, &v, 1); // block 0 +} + +static void fixCcGainSign() { + loadCcGainBlock(); + uint8_t signByte = i2cRead(0x45); + if ((signByte & 0x80) == 0) { + return; + } + Serial.printf("fuelGauge: CC_GAIN sign bit set (0x%02X), fixing\n", signByte); + writeControlOnly(0x8000); + writeControlOnly(0x8000); + delay(100); + writeControlOnly(0x0013); // SET_CFGUPDATE + delay(1000); + loadCcGainBlock(); + signByte &= 0x7F; + i2cWrite(0x45, &signByte, 1); + uint8_t sum = 0; + for (uint8_t i = 0; i < 32; i++) { + sum += i2cRead(0x40 + i); + } + uint8_t csum = 255 - sum; + i2cWrite(0x60, &csum, 1); + writeControlOnly(0x0042); // SOFT_RESET + delay(2000); + loadCcGainBlock(); + Serial.printf("fuelGauge: CC_GAIN now 0x%02X\n", i2cRead(0x45)); +} + +static bool s_lowSocNotified = false; +static bool s_chrgEnabled = true; + +uint16_t fuelGaugeDesignCapacityMah() { + return bq.capacity(DESIGN); +} + +bool fuelGaugeSetCapacity(uint16_t mAh) { + if (mAh < 300 || mAh > 2000) { + return false; + } + Serial.printf("fuelGauge: setting design capacity to %u mAh\n", mAh); + if (!bq.setCapacity(mAh)) { + Serial.println("fuelGauge: set capacity FAILED"); + return false; + } + delay(500); + Serial.printf("fuelGauge: design capacity now %u mAh\n", + bq.capacity(DESIGN)); + return true; +} + +// Called periodically from the main loop. Serial-notify once when the SOC +// drops below 10 %. With battery protection enabled, CHRG_CTRL gates the +// charger: cut off at 80 %, resume at 75 % (hysteresis). +void fuelGaugeLoop() { + if (!s_present) { + return; + } + uint8_t soc = fuelGaugeSocPercent(); + if (soc < LOW_SOC_NOTIFY_PERCENT && !s_lowSocNotified) { + s_lowSocNotified = true; + Serial.printf("fuelGauge: battery low! SOC %u%% (below %u%%)\n", soc, + LOW_SOC_NOTIFY_PERCENT); + } + if (soc >= LOW_SOC_NOTIFY_PERCENT && s_lowSocNotified) { + s_lowSocNotified = false; + } + if (!b_batteryProtect) { + if (!s_chrgEnabled) { + digitalWrite(CHRG_CTRL, HIGH); + s_chrgEnabled = true; + } + return; + } + if (soc >= PROTECT_CHARGE_LIMIT && s_chrgEnabled) { + digitalWrite(CHRG_CTRL, LOW); + s_chrgEnabled = false; + Serial.printf("fuelGauge: protect, charge cut off at %u%%\n", soc); + } else if (soc <= PROTECT_CHARGE_RESUME && !s_chrgEnabled) { + digitalWrite(CHRG_CTRL, HIGH); + s_chrgEnabled = true; + Serial.printf("fuelGauge: protect, charge resumed at %u%%\n", soc); + } +} + +void fuelGaugeProtectSet(bool enable) { + b_batteryProtect = enable; + if (!b_batteryProtect) { + digitalWrite(CHRG_CTRL, HIGH); + s_chrgEnabled = true; + } +} + +bool fuelGaugeBegin() { + Wire.begin(FUEL_GAUGE_I2C_SDA, FUEL_GAUGE_I2C_SCL); + // The gauge may still be powering up right after battery insertion; retry. + for (int attempt = 0; attempt < 5 && !s_present; attempt++) { + if (attempt > 0) { + delay(200); + } + Wire.beginTransmission(BQ_ADDR); + s_present = Wire.endTransmission() == 0; + } + if (!s_present) { + b_hasFuelGauge = false; + return false; + } + s_present = bq.begin(FUEL_GAUGE_I2C_SDA, FUEL_GAUGE_I2C_SCL); + b_hasFuelGauge = s_present; + if (s_present) { + Serial.printf("fuelGauge: BQ27427 @0x%02X present\n", BQ_ADDR); + if (!enforceChem1202()) { + s_present = false; + b_hasFuelGauge = false; + return false; + } + fixCcGainSign(); + pinMode(CHRG_CTRL, OUTPUT); + digitalWrite(CHRG_CTRL, HIGH); + s_chrgEnabled = true; + pinMode(GPOUT, INPUT); + Serial.printf("fuelGauge: FCC %u mAh, design %u mAh\n", + fuelGaugeFullCapacityMah(), fuelGaugeDesignCapacityMah()); + } + return s_present; +} + +float fuelGaugeVoltageV() { + return bq.voltage() / 1000.0f; +} + +int16_t fuelGaugeCurrentMa() { + return bq.current(); +} + +int16_t fuelGaugeStandbyMa() { + return bq.current(STBY); +} + +int16_t fuelGaugeMaxLoadMa() { + return bq.current(MAX); +} + +int16_t fuelGaugePowerMw() { + return bq.power(); +} + +uint16_t fuelGaugeCapacityMah() { + return bq.capacity(); +} + +uint16_t fuelGaugeFullCapacityMah() { + return bq.capacity(FULL); +} + +uint8_t fuelGaugeSoHPercent() { + return bq.soh(); +} + +uint8_t fuelGaugeSocPercent() { + return (uint8_t)bq.soc(); +} + +float fuelGaugeTempC() { + return bq.temperature(INTERNAL_TEMP) * 0.1f - 273.15f; +} + +bool fuelGaugeCharging() { + return digitalRead(FUEL_GAUGE_CHRG_DET) == LOW; +} + +bool fuelGaugeFullCharged() { + return bq.fcFlag(); +} + +bool fuelGaugeDischarging() { + return bq.dsgFlag(); +} + +bool fuelGaugeChgAllowed() { + return bq.chgFlag(); +} + +bool fuelGaugeUsbPlugged() { + return digitalRead(FUEL_GAUGE_USB_DET) == LOW; +} + +void fuelGaugeSleep() { + (void)bq; +} diff --git a/src/hds.ino b/src/hds.ino index 89d3de1..a4c24fb 100644 --- a/src/hds.ino +++ b/src/hds.ino @@ -31,6 +31,7 @@ #include "menu.h" #include "ble.h" #include "usbcomm.h" +#include "fuel_gauge.h" #include "finger_detection.h" #if HDS_ENABLE_GRINDER @@ -456,7 +457,7 @@ void setup() { button_init(); linkSubmenus(); pinMode(BATTERY_CHARGING, INPUT_PULLUP); -#if defined(V7_4) || defined(V7_5) || defined(V8_0) || defined(V8_1) +#if defined(V7_4) || defined(V7_5) || defined(V8_0) || defined(V8_1) || defined(V9_0_5) pinMode(USB_DET, INPUT_PULLUP); pinMode(OLED_CS, OUTPUT); pinMode(OLED_DC, OUTPUT); @@ -543,7 +544,7 @@ void setup() { gpio_hold_dis((gpio_num_t)I2C_SDA); gpio_hold_dis((gpio_num_t)PWR_CTRL); -#if defined(V7_3) || defined(V7_4) || defined(V7_5) || defined(V8_0) || defined(V8_1) +#if defined(V7_3) || defined(V7_4) || defined(V7_5) || defined(V8_0) || defined(V8_1) || defined(V9_0_5) gpio_hold_dis((gpio_num_t)ACC_PWR_CTRL); Serial.println("ACC_PWR_CTRL = HIGH"); #endif @@ -551,11 +552,22 @@ void setup() { #ifdef ESP32 Wire.begin(I2C_SDA, I2C_SCL); #endif + fuelGaugeBegin(); + compactMainMenu(); + b_batteryProtect = storageGetBool(KEY_BAT_PROTECT, false); + if (b_hasFuelGauge && !storageGetBool(KEY_BAT_CAPACITY_SET, false)) { + if (fuelGaugeSetCapacity(700)) { + storagePutBool(KEY_BAT_CAPACITY_SET, true); + Serial.println("fuelGauge: design capacity set once (700 mAh)"); + } + } #ifdef HW_SPI SPI.begin(OLED_SCLK, -1, OLED_SDIN, OLED_CS); #endif #ifdef ADS1115ADC - ADS_init(); + if (!b_hasFuelGauge) { + ADS_init(); + } #endif delay(50); b_requireHeartBeat = storageGetBool(KEY_HEARTBEAT, true); @@ -828,7 +840,11 @@ void setup() { Serial.println("Setup complete..."); t_bootTare = millis(); b_bootTare = true; - updateBattery(BATTERY_PIN); +if (!b_hasFuelGauge) { + updateBattery(BATTERY_PIN); + } else { + f_batteryVoltage = fuelGaugeVoltageV(); + } #if HDS_FEATURE_PULL_OTA if (b_pendingOtaLittleFs) { if (!pullOtaResumePendingLittleFs()) { @@ -1448,6 +1464,7 @@ void loop() { processWsPendingCmds(); processBleStatusResponse(); processBleVoltageResponse(); + fuelGaugeLoop(); if (b_powerOff){ shut_down_now_nobeep(); @@ -1527,7 +1544,11 @@ void loop() { debugData(); #endif //DEBUG if (millis() - t_batteryRefresh > i_batteryRefreshTareInterval){ - updateBattery(BATTERY_PIN); +if (!b_hasFuelGauge) { + updateBattery(BATTERY_PIN); + } else { + f_batteryVoltage = fuelGaugeVoltageV(); + } } checkBattery(); if (b_ota) { @@ -1550,7 +1571,7 @@ void loop() { } else if (GPIO_power_on_with == BATTERY_CHARGING) { if (b_chargingOLED) { if (digitalRead(BATTERY_CHARGING) == LOW && !b_calibration) { - float perc = map(f_batteryVoltage * 1000, showEmptyBatteryBelowVoltage * 1000, showFullBatteryAboveVoltage * 1000, 0, 100); + float perc = batteryPercent(); chargingOLED((int)perc, f_batteryVoltage); b_showChargingUI = true; } else { @@ -1868,7 +1889,7 @@ void drawBattery() { t_batteryIcon = millis(); b_showBatteryIcon = !b_showBatteryIcon; } -#if defined(V7_4) || defined(V7_5) || defined(V8_0) || defined(V8_1) +#if defined(V7_4) || defined(V7_5) || defined(V8_0) || defined(V8_1) || defined(V9_0_5) if (digitalRead(USB_DET) == LOW) { #else if (digitalRead(BATTERY_CHARGING) == LOW) { @@ -1877,7 +1898,7 @@ void drawBattery() { u8g2.drawXBM(121, 51, 7, 12, image_battery_charging); } else { b_is_charging = false; - int i_batteryPercent = map(f_batteryVoltage * 1000, showEmptyBatteryBelowVoltage * 1000, showFullBatteryAboveVoltage * 1000, 0, 100); + int i_batteryPercent = batteryPercent(); if (i_batteryPercent <= 5) { if (b_showBatteryIcon) u8g2.drawXBM(121, 52, 7, 12, image_battery_0); @@ -1939,7 +1960,7 @@ void drawDebug() { snprintf(chargingText, sizeof(chargingText), "Not charging"); char batteryText[10]; - int perc = map(f_batteryVoltage * 1000, showEmptyBatteryBelowVoltage * 1000, showFullBatteryAboveVoltage * 1000, 0, 100); + int perc = batteryPercent(); snprintf(batteryText, sizeof(batteryText), "%d%%", (perc > 100) ? 100 : perc); char voltageText[10]; diff --git a/tools/bq27427_probe/platformio.ini b/tools/bq27427_probe/platformio.ini new file mode 100644 index 0000000..58facb0 --- /dev/null +++ b/tools/bq27427_probe/platformio.ini @@ -0,0 +1,6 @@ +[env:esp32s3] +platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.311/platform-espressif32.zip +board = esp32-s3-devkitc-1 +framework = arduino +monitor_speed = 115200 +lib_deps = edrean/BQ27427 Battery Fuel Gauge Arduino Library @ 1.0.4 diff --git a/tools/bq27427_probe/src/main.cpp b/tools/bq27427_probe/src/main.cpp new file mode 100644 index 0000000..64232b4 --- /dev/null +++ b/tools/bq27427_probe/src/main.cpp @@ -0,0 +1,28 @@ +// Minimal I2C bus scan for the HDS board. +#include +#include + +#define I2C_SDA 5 +#define I2C_SCL 4 + +void setup() { + Serial.begin(115200); + while (!Serial) {} + Wire.begin(I2C_SDA, I2C_SCL); + delay(200); + Serial.println("I2C scan (SDA=5 SCL=4):"); + uint8_t found = 0; + for (uint8_t a = 1; a < 128; a++) { + Wire.beginTransmission(a); + if (Wire.endTransmission() == 0) { + Serial.printf(" found 0x%02X\n", a); + found++; + } + } + Serial.printf(" %u device(s)\n", found); + Serial.println("done"); +} + +void loop() { + delay(1000); +}