Inhero MR2 Power Management - Implementation Documentation (Rev 1.1)¶
Table of Contents¶
- Overview
- Hardware Architecture
- 1. Low-Voltage Detection (INA228 ALERT ISR)
- 2. Coulomb Counter & SOC (State of Charge)
- 3. Daily Energy Balance
- 4. Solar Power Management
- BQ25798 ADC at Low Battery Voltages
- NTC Plausibility Check (BME280)
- JEITA WARM Zone & VBAT_OVP Prevention
- JEITA Override (set board.jeitaignore)
- 5. Batt-TTL Prediction
- 6. RTC Wakeup Management
- 7. Power Management Flow
- 8. INA228 ALERT Pin (Rev 1.1)
- 9. SX1262 Power Control & PE4259 RF Switch
- 10. BQ25798 CE Pin Safety (Rev 1.1 — FET-inverted)
- 11. Statistics Persistence
- 12. CLI Commands
- See Also
This documentation describes the power management implementation for the Inhero MR2 board. Hardware Rev 1.1: INA228 ALERT on P1.02, TPS62840 EN via 3.3V_off switch, CE pin via N-FET (inverted).
Overview¶
The system combines INA228 ALERT-based low-voltage detection + System Sleep with GPIO latch + Coulomb Counter + daily energy balance + CE pin FET safety for maximum energy efficiency:
- INA228 ALERT ISR (P1.02) - Low-voltage detection via hardware interrupt
- System Sleep with GPIO latch (< 500µA) with RTC wake - Minimal power consumption during low-voltage
- CE Pin FET Safety - Inverted logic, solar charging possible in System Sleep
- Coulomb Counter (INA228) - Real-time SOC tracking
- Daily Energy Balance (7-day rolling) - Solar vs. battery
- RTC Wakeup Management (RV-3028-C7) - Periodic recovery checks
Feature Matrix¶
| Feature | Status | Notes |
|---|---|---|
| INA228 ALERT → Low-Voltage System Sleep | Active | ISR on P1.02 → volatile flag → tickPeriodic() → System Sleep with GPIO latch + RTC Wake |
| RTC Wakeup (Low-Voltage Recovery) | Active | 60 min (periodic) |
| BQ CE Pin Safety (FET-inverted) | Active | GPIO HIGH → FET ON → CE LOW → charge ON (BQ25798 CE active-low), Dual-Layer: GPIO + I2C |
| System Sleep with latched CE | Active | < 500µA, GPIO4 latch preserved HIGH → FET ON → CE LOW → solar charging possible |
| SOC via INA228 + manual battery capacity | Active | set board.batcap available |
| SOC→Li-ion mV Mapping (workaround) | Active | Will be removed when MeshCore transmits SOC% natively |
| MPPT Recovery + Stuck-PGOOD Handling | Active | Cooldown logic active |
Hardware Architecture¶
Components¶
| Component | Function | I2C | Pin | Details |
|---|---|---|---|---|
| RAK4630(H) | Core Module | — | — | nRF52840 SoC + SX1262 LoRa transceiver, high-band variant (see DATASHEET.md — LoRa Frequency Bands) |
| INA228 | Power Monitor | 0x40 | ALERT→P1.02 (ISR) | 100mΩ shunt, 1.6A max, Coulomb Counter, BUVL Alert |
| BME280 | Temp/Humidity/Pressure sensor | 0x76 | — | NTC calibration reference (set board.tccal), selftest |
| RV-3028-C7 | RTC | 0x52 | INT→GPIO17 | Countdown timer, wake-up. See FAQ #23 |
| BQ25798 | Battery Charger | 0x6B | INT→GPIO21 | MPPT, JEITA, 15-bit ADC (IBUS ~±30mA error at low currents; ADC has VBAT-dependent thresholds, see Section 4) |
| BQ CE Pin | Charge Enable | — | GPIO4 (P0.04) | Via N-FET: GPIO HIGH → FET ON → CE LOW → charge ON (BQ25798 CE active-low) |
| TPS62840 | Buck Converter | - | EN via 3.3V_off switch | 750mA, 3.3V rail |
| CE FET | N-FET | — | Gate←GPIO4 (ext. pull-down) | Drain→CE, Source→GND. GPIO HIGH → FET ON → CE LOW → charging on. Pull-down defaults gate LOW when floating. |
| Schottky diode | USB→VBUS Diode | — | — | VBUS-USB → VBUS-BQ (solar input). USB-C CC1/CC2 via 4.7kΩ to GND (USB sink). ⚠ Solar short also shorts VBUS-USB. |
1. Low-Voltage Detection (INA228 ALERT ISR)¶
Implementation (Rev 1.1 — Flag/Tick Architecture)¶
- Trigger: INA228 BUVL (Bus Under-Voltage Limit) ALERT on P1.02
- ISR:
BoardConfigContainer::lowVoltageAlertISR()→ setslowVoltageAlertFired = true(flag only, no FreeRTOS call) - Processing:
tickPeriodic()checks the ISR flag and ALERT pin level in the main loop →board.initiateShutdown(SHUTDOWN_REASON_LOW_VOLTAGE) - Fallback: Once per second, the averaged INA228 battery voltage is checked against the active sleep threshold, independently of SOC and CLI requests. A failed read (0 mV) is ignored. Detection also includes ADC averaging time and the shutdown sequence.
- Arming:
armLowVoltageAlert(type)receives the new battery type directly when configuration changes, sets BUVL, and enables the ISR. An already LOW pin is captured after attaching the interrupt.nonedisables the interrupt and voltage fallback and clears any pending alert.
Low-Voltage Flow¶
INA228 BUVL Alert (P1.02, FALLING edge)
│
▼
lowVoltageAlertISR() [ISR context]
│ Sets lowVoltageAlertFired = true (volatile flag)
▼
tickPeriodic() [Main loop context, next tick()]
│ Checks lowVoltageAlertFired == true
▼
board.initiateShutdown(SHUTDOWN_REASON_LOW_VOLTAGE)
│ CE latched HIGH (GPIO latch preserved → FET ON → CE LOW → charging stays ON)
│ RTC wake configured (LOW_VOLTAGE_SLEEP_MINUTES = 60)
│ GPREGRET2 → LOW_VOLTAGE_SLEEP flag
▼
sd_power_system_off() → System Sleep with GPIO latch (< 500µA)
Chemistry-Specific Thresholds (1-Level System, uniform 200mV hysteresis)¶
| Chemistry | lowv_sleep_mv (ALERT) | lowv_wake_mv (0% SOC) | Hysteresis |
|---|---|---|---|
| Li-ion 1S | 3100 | 3300 | 200mV |
| LiFePO4 1S | 2700 | 2900 | 200mV |
| LTO 2S | 3900 | 4100 | 200mV |
| Na-ion 1S | 2500 | 2700 | 200mV |
Implementation: BoardConfigContainer — battery_properties[] lookup table
- lowv_sleep_mv → INA228 BUVL Alert threshold, triggers System Sleep
- lowv_wake_mv → RTC wake threshold (early boot checks VBAT, decides boot or sleep again)
- Static methods: getLowVoltageSleepThreshold(type), getLowVoltageWakeThreshold(type)
2. Coulomb Counter & SOC (State of Charge)¶
INA228 Integration¶
- Driver:
lib/Ina228Driver.cpp - Init:
BoardConfigContainer::begin() - 100mΩ shunt calibration
- CURRENT_LSB = 1.6384A / 524288 ≈ 3.125µA
- ADC Range ±163.84mV (ADCRANGE=0, optimal for 1A @ 100mΩ)
- ADC Averaging: 256 samples (filters TX voltage peaks)
- BUVL Alert configured to
lowv_sleep_mv(chemistry-specific)
SOC Calculation¶
Method: updateBatterySOC() in BoardConfigContainer.cpp
- Primary: Coulomb Counting (INA228 CHARGE register)
- Update interval: every 60s via tickPeriodic(); no SOC updates while in low-voltage sleep (the RTC wake only checks VBAT and re-sleeps or boots)
Formula:
SOC_delta = charge_delta_mah / capacity_mah × 100%
SOC_new = SOC_old + SOC_delta
Auto-Sync: On BQ25798 "Charge Done", SOC is set to 100%.
Capacity Management¶
Configuration Required¶
Battery capacity must be set manually, as it varies widely in practice:
- Typical range: 4000-24000mAh (4-24Ah)
- CLI command: set board.batcap <mAh>
- Allowed range: 100-100000mAh
Important: Without correct capacity, SOC% and Batt-TTL calculations are inaccurate!
Persistence Mechanism¶
Storage Path: /inheromr2/batCap.txt (LittleFS via SimplePreferences)
Save Method: setBatteryCapacity() in BoardConfigContainer.cpp (persists via SimplePreferences)
Load Method: loadBatteryCapacity()
Saved on:
1. Manual setting: CLI command set board.batcap <mAh>
- Writes immediately to LittleFS
- Updates batteryStats.capacity_mah
Loaded on:
- Boot time: BoardConfigContainer::begin() calls loadBatteryCapacity()
- Fallback: When no saved capacity exists
- Validation: Range check 100-100000mAh
Persistence properties:
- ✅ Survives software shutdowns (System Sleep)
- ✅ Survives power cycles and low-voltage recovery
- ✅ Survives firmware updates (LittleFS preserved)
- ⚠️ Lost on: flash erase, rm -rf /inheromr2/, filesystem corruption
3. Daily Energy Balance¶
Tracking (168-Hour Ring Buffer)¶
Methods: updateHourlyStats() + calculateRollingStats() in BoardConfigContainer.cpp
- Called by: tickPeriodic() (every 60 min)
- Sampling: On each hour boundary (RTC time truncated to full hours), the completed hour is written into the ring buffer
Data structure: BatterySOCStats.hours[168] (7 days × 24 hours)
typedef struct {
uint32_t timestamp; // start of hour, unix seconds
float charged_mah; // charged this hour
float discharged_mah; // discharged this hour
float solar_mah; // solar share this hour
} HourlyBatteryStats;
The per-hour values are accumulated from INA228 CHARGE register deltas in updateBatterySOC() (every 60s): a positive delta counts as charged (and solar), a negative delta as discharged.
Calculations¶
Rolling sums (calculateRollingStats(), after each completed hour):
last_24h_net_mah = Σ(solar − discharged) over the last 24 hours
avg_3day_daily_net_mah = Σ(solar − discharged) over 72h / 3 (needs ≥ 24h of data)
avg_7day_daily_net_mah = Σ(solar − discharged) over 168h / 7 (needs ≥ 24h of data; used for Batt-TTL)
Living Status:
- living_on_battery = true when last_24h_net_mah < 0 (net deficit over the last 24h)
- Solar surplus (SOL in board.stats) is simply living_on_battery == false
4. Solar Power Management¶
Design Principle¶
The BQ25798 decides itself via PowerGood (PG) whether an input is usable. The charger runs in always-active mode (HIZ disabled). The firmware monitors solar status and re-enables MPPT as needed.
No INT pin interrupt — everything runs via polling in runMpptCycle() (60s interval).
Solar Checks¶
runMpptCycle() performs two checks each cycle:
1. checkAndFixSolarLogic() — PG-stuck recovery + MPPT re-enablement
2. updateMpptStats() — Updates MPPT statistics for 7-day average
PFM Forward Mode¶
- PFM forward mode is enabled by BQ25798 power-on default (PFM_FWD_DIS=0, REG0x12); the firmware does not modify it
- PFM improves efficiency at low solar currents
Minimum system voltage and MPPT¶
The firmware sets VSYSMIN to 2.50 V (REG00[5:0] = 0), including after
every CELL write. CELL programming resets VSYSMIN, VREG and ICHG to the cell-count
defaults; restoring all three prevents the previous LTO 2S linear-BATFET heating
problem. The die thermal-regulation setting remains 60 °C.
Below VSYSMIN the BQ25798 operates in minimum-system regulation and rejects hardware MPPT. The former 2.75 V setting therefore blocked MPPT on a Na-ion cell at 2.62–2.65 V. Configured MPPT is now applied after the chemistry parameters have been restored. This does not change the battery sleep/wake thresholds.
get board.mppt reports the stored preference. Use get board.mpptdiag to compare
that preference with the actual hardware bit and read back the voltage/current
settings. MPPT:cfg=1/hw=0 means requested on, hardware off; VSYS_MIN:1 indicates
minimum-system regulation. VSYSMIN, VINDPM and VREG are in mV; ICHG is in mA.
The diagnostic only reads registers and reports an error on a failed read.
MPPT Recovery + PG-Stuck¶
checkAndFixSolarLogic() handles two scenarios:
PG=1: MPPT re-enablement — BQ25798 automatically disables MPPT on faults. Readback check: only write when actual change needed.
PG=0: A HIZ toggle retries input qualification without an ADC/VBUS check. The charger qualifies the source itself, including at night or with a weak panel. This avoids blocking recovery when low VBAT and HIZ leave the ADC unavailable. The BQ must respond, and MPPT and charging must be enabled in configuration. A 5-minute cooldown applies between attempts, including failed register accesses. Hourly UV wakes also perform one attempt, then wait up to 1 s for PG and restore MPPT if possible before returning to sleep. CE follows the stored battery configuration.
After setting up ADC channels and flags, firmware clears HIZ immediately before
starting each telemetry one-shot, preserving EN_CHG and other bits.
There is no fixed delay during which source qualification could reassert HIZ
before the ADC start. Firmware does not clear HIZ repeatedly during a measurement
if the BQ sets it again. A fresh DONE flag and cleared ADC_EN are still required
within the 250 ms measurement timeout. HIZ is not restored after the measurement.
Invalid solar readings appear
as S:N/A and are omitted from LPP. Temporary ADC/TS/A-B diagnostic commands have been removed from the release
firmware. Existing cinfo, bqdiag and selftest commands remain available.
BQ25798 Interrupt Handling¶
BQ INT pin (GPIO 21): Not used as interrupt — INPUT_PULLUP against floating.
BQ status is checked via polling in runMpptCycle() every 60s.
Flag clearing on boot: BqDriver::clearInterruptFlags() (called from BoardConfigContainer::begin())
- Reads the CHARGER_FLAG/FAULT_FLAG registers 0x22–0x27 to de-assert the INT line
- Prevents stale faults from previous power cycle
Flag/Tick Architecture¶
All I2C operations run in main loop context via tickPeriodic() (called by InheroMr2Board::tick()). There are no FreeRTOS tasks for I2C access — this eliminates mutex and race conditions.
I2C Bus Recovery (in InheroMr2Board::begin()): After OTA/warm reset, an I2C slave may hold SDA low. Before Wire.begin(), up to 9 SCL pulses + STOP condition are generated to free the bus.
tickPeriodic() dispatches periodic work via millis() timers:
tickPeriodic() [called by tick(), main loop]
├─ Check low-voltage alert flag/pin and 1s voltage fallback → initiateShutdown()
├─ Every 60s: runMpptCycle()
│ ├─ checkAndFixSolarLogic() — PG-stuck recovery (HIZ toggle) + MPPT recovery
│ └─ updateMpptStats() — Update MPPT statistics
├─ Every 60s: updateBatterySOC()
└─ Every 60min: updateHourlyStats()
Remaining FreeRTOS tasks (GPIO only, no I2C):
- heartbeatTask — blue LED blink pattern
- ErrorLED lambda — red LED on missing components
Timing Summary: - MPPT Cycle: 60 seconds (via tickPeriodic) - SOC Update: 60 seconds (via tickPeriodic) - Hourly Stats: 60 minutes (via tickPeriodic)
BQ25798 ADC at Low Battery Voltages¶
Reference: BQ25798 Datasheet SLUSDV2B, Section 9.3.10 — ADC; Section 7.3.10 in SLUSDV2C.
Problem¶
The 15-bit ADC in the BQ25798 has voltage-dependent operating thresholds. With insufficient supply, a conversion may never start. In the observed low-VBAT state with HIZ=1, ADC_EN remained clear despite a start request; this bit alone therefore cannot confirm completion.
Datasheet Quote (Section 9.3.10, Rev. B)¶
"The ADC is allowed to operate if either VBUS > 3.4V or VBAT > 2.9V is valid. At battery only condition, if the TS_ADC channel is enabled, the ADC only works when battery voltage is higher than 3.2V, otherwise, the ADC works when the battery voltage is higher than 2.9V."
Operating Scenarios¶
| Condition | VBUS | VBAT | TS Channel | ADC | Temperature |
|---|---|---|---|---|---|
| Solar source qualified (PG=1) | > 3.4V | any | enabled | ✅ runs | ✅ available |
| Battery operation, normal | — | ≥ 3.2V | enabled | ✅ runs | ✅ available |
| Battery operation, low | — | 2.9–3.2V | disabled | ✅ runs | ❌ not available |
| Battery operation, critical | — | < 2.9V | disabled | ❌ timeout | ❌ not available |
Firmware Solution: TS Channel Control¶
The firmware reads the current battery voltage from the INA228, passes it to
BqDriver::getTelemetryData(vbat_mv) and additionally checks the charger's Power-Good bit:
- VBAT ≥ 3.2V (or unknown): TS channel enabled → ADC threshold 3.2V, temperature available
- VBAT < 3.2V with an input source qualified (PG = 1): TS channel stays enabled — the ADC runs off VBUS, so the temperature remains available
- VBAT < 3.2V on battery alone: TS channel disabled → ADC threshold drops to 2.9V, temperature shown as "N/A"
This allows the ADC to continue working in the 2.9–3.2V range for solar measurements (VBUS, IBUS), even when battery temperature cannot be read.
The 3.2V requirement applies to battery-only operation (SLUSDV2B 9.3.10). With a qualified input source, firmware keeps TS enabled even at low VBAT so that battery temperature remains available during charging.
The battery type never enters the decision. getTelemetryData() starts with ts_enabled = true for every chemistry; the driver has no notion of which chemistry is configured and no "NTC fitted" flag. Availability of a battery temperature depends on VBAT, on the input source, and on the plausibility check below.
Practical consequence per chemistry, because the 3.2V bar sits at different points of each discharge curve — all of this applies to battery-only operation, while charging from the panel the temperature is available throughout:
| Chemistry | Nominal | TS channel over the discharge curve (battery only) |
|---|---|---|
| LTO 2S | 4.6V | always above 3.2V → temperature available throughout |
| Li-ion 1S | 3.7V | above 3.2V for most of the curve (sleep threshold 3100mV) |
| LiFePO4 1S | 3.2V | large part of the flat plateau sits at or below 3.2V → often N/A |
| Na-ion 1S | 3.1V | large part of the curve below 3.2V → often N/A |
ADC Channel Configuration (only required channels)¶
On the MR2, D+, D−, VAC1, VAC2 are not connected. The firmware enables only the actually used channels:
| Register | Value (TS on) | Value (TS off) | Active Channels |
|---|---|---|---|
| 0x2F (ADC_FUNCTION_DISABLE_0) | 0x58 |
0x5C |
IBUS, VBUS, TDIE, (TS) |
| 0x30 (ADC_FUNCTION_DISABLE_1) | 0xF0 |
0xF0 |
none (D+/D−/VAC disabled) |
TDIE (bit 1) is cleared in both values, i.e. the charger's own silicon die temperature is converted in every one-shot. That is where the TDIE: field of board.cinfo comes from — it is the BQ25798 junction temperature, not the battery, not the board, not the MCU.
In one-shot mode, a fresh ADC_DONE_FLAG together with cleared ADC_EN confirms completion of the enabled channels. ADC_EN=0 alone is insufficient. Unused channels are disabled to reduce conversion time.
Fresh Solar Measurements¶
Before each one-shot, firmware stops any previous conversion, clears old completion flags and verifies the channel mask. It clears HIZ immediately before setting ADC_EN=1, with no additional startup delay and no repeated HIZ clearing within the same measurement if the BQ sets it again. CE, EN_CHG and the stored configuration remain unchanged.
A measurement is fresh only when a new ADC_DONE_FLAG and cleared ADC_EN are
observed within 250 ms and both voltage/current register reads succeed.
Otherwise, get board.telem shows S:N/A and invalid solar voltage/current values
are omitted from LPP. Firmware does not restore HIZ after the measurement.
In the Na-ion test at about 2.59 V, high panel open-circuit voltage with !PG was insufficient for a successful ADC conversion. Clearing HIZ and successfully qualifying the source (PG=1) restored charging and ADC operation. With a weak source, the BQ reasserted HIZ and the measurement remained invalid. PG recovery therefore does not require an ADC/VBUS measurement.
Temporary ADC, TS and A/B diagnostic commands have been removed from the release
firmware. Regular board diagnostics remain available through cinfo, bqdiag
and selftest.
Temperature Sentinel Values¶
The firmware uses special return values for invalid temperatures:
| Value | Meaning | Display |
|---|---|---|
| −999.0 | I2C communication error, or a reading rejected by the BME280 plausibility check | N/A |
| −888.0 | ADC not ready, one-shot did not complete, or TS channel off (VBAT between 0 and 3200mV) | N/A |
| −99.0 | NTC open/not connected (k > 0.99, or the RT2-only pole guard) | N/A |
| +99.0 | NTC short circuit (k < 0.01) | N/A |
| −50…+90°C | Valid measurement | XX°C |
−999.0 now has two producers: BqDriver::calculateBatteryTemp() on an I2C read error, and BoardConfigContainer::getTelemetryData() on an implausible reading (see next section). The two cases are not distinguishable at the CLI or telemetry surface — both simply read "N/A".
Display rule: Values ≤ −100°C are shown as "N/A" in CLI and omitted from CayenneLPP packets.
Code References¶
BqDriver::getTelemetryData(vbat_mv)— Main function with VBAT-dependent TS controlBqDriver::startADCOneShot(ts_enabled)— Configures ADC channels and starts conversionBoardConfigContainer::getTelemetryData()— Passes INA228 VBAT to BqDriver, applies calibration and plausibility check
NTC Plausibility Check (BME280)¶
Why the range check is not enough¶
A missing or open NTC does not produce an out-of-range value. With the Inhero divider (RT1 = 5.6 kΩ pullup to REGN, RT2 = 27 kΩ to GND) an absent NTC puts the divider exactly on the RT2-only pole of calculateBatteryTemp(). The extraction 1 / (g_total − g_rt2) then either trips the g_total <= g_rt2 guard (−99.0) or yields a huge NTC resistance that decodes to a bogus deep-cold value near −46 °C — inside the −50…+90 °C window, so the range check passes it through. One TS ADC LSB is 0.09765625 % of REGN, which at that operating point moves the decoded temperature by tens of degrees, so the bogus value is not even stable.
The check¶
After the −50…+90 °C window has passed and the tccal offset has been added, BoardConfigContainer::getTelemetryData() takes a fresh BME280 reading and compares:
ntcTemp = bqTemp + tcCalOffset // calibrated, not raw
bmeTemp = readBmeTemperature()
if (bmeTemp > -100.0 && bmeTemp < 100.0 &&
fabsf(ntcTemp - bmeTemp) > NTC_BME_MAX_DIFF_C) // 15.0 °C, strictly greater
→ battery temperature = -999.0f // reported as N/A
- The threshold is
NTC_BME_MAX_DIFF_C = 15.0 °C. A difference of exactly 15.0 °C passes. - The comparison runs on the calibrated value (raw +
tcCalOffset). - If the BME280 cannot be read — no answer at 0x76, forced measurement failed, or
ENV_INCLUDE_BME280 = 0—readBmeTemperature()returns −999.0, which fails the (−100, +100) guard and the check stands down: the NTC value passes unchanged. On the MR2 the sensor is compiled in (ENV_INCLUDE_BME280=1), so this is the fault path.
Consequences¶
| Area | Effect |
|---|---|
board.telem / CayenneLPP |
Rejected reading is reported as "N/A" / the LPP temperature field is omitted. There is no distinct "rejected" indication. |
| SOC derating | A rejected reading does not refresh lastValidBatteryTemp / lastTempUpdateMs. After 300000 ms (5 min) without an accepted NTC update, refreshTempDerating() falls back to the BME280; if that also fails, lastValidBatteryTemp keeps its previous value (default 25.0 °C = no derating). |
set board.tccal |
Unaffected. performTcCalibration() reads BqDriver::getTelemetryData(0) directly with tcCalOffset temporarily zeroed and averages raw values, discarding only the driver's own error codes (raw ≤ −800.0 or ≥ 98.0); it needs 3 of 5 valid samples. Calibration therefore still works on a board whose reading the filter would drop. |
JEITA WARM Zone & VBAT_OVP Prevention¶
Problem: Default JEITA Configuration + Inhero Divider¶
The Inhero MR2 uses a non-standard NTC voltage divider (RT1=5.6 kΩ pullup to REGN, RT2=27 kΩ parallel to GND) instead of the TI reference design (5.24 kΩ / 30.31 kΩ). This shifts TS thresholds lower by a temperature-dependent amount: ~5–6 °C in the cold range (where NTC resistance is large relative to RT2, amplifying the divider mismatch) and ~2–3 °C in the warm/hot range.
With the BQ25798 POR defaults (TS_WARM = 45°C, JEITA_VSET = VREG−400mV, EN_AUTO_IBATDIS = 1), this caused a critical failure chain at moderate temperatures (~42 °C):
42°C ambient → TS = 44.65% REGN (below VT3_FALL = 44.8%)
→ BQ enters WARM zone
→ JEITA_VSET reduces VREG: 3.5V − 400mV = 3.1V (LiFePO4)
→ Battery at 3.47V > 104% × 3.1V = 3.224V → VBAT_OVP triggers
→ Converter stops, EN_AUTO_IBATDIS sinks IBAT_LOAD = 30mA from battery
→ Total drain: −11mA (system) + −30mA (IBAT_LOAD) = −41mA
→ Recovery requires VBAT < 102% × 3.1V = 3.162V → hours of battery drain
Fix: Three Register Settings in configureBaseBQ()¶
| Setting | Register | Value | Effect |
|---|---|---|---|
setTsWarm(BQ25798_TS_WARM_55C) |
NTC Control 1 (0x18), bits 5:4 | 55 °C (37.7% REGN) | WARM zone starts at ~52 °C (Inhero), not ~42 °C |
setJeitaVSet(BQ25798_JEITA_VSET_UNCHANGED) |
NTC Control 0 (0x17), bits 7:5 | UNCHANGED | No VREG reduction in WARM — prevents VBAT_OVP |
JEITA_ISETH (POR default retained) |
NTC Control 0, bits 4:3 | 11b = ICHG unchanged | No charge current reduction in WARM |
setAutoIBATDIS(false) |
Charger Control 0, bit 7 | 0 | Disables 30 mA active battery discharge during OVP |
Result: With JEITA_VSET=UNCHANGED and JEITA_ISETH=ICHG unchanged, the WARM zone (T3–T5) is effectively neutralized. Charging continues at full voltage and full current until T-Hot (~58 °C), where charging is suspended entirely.
TS Threshold Comparison¶
| Zone Boundary | BQ Register | % REGN | TI Reference (°C) | Inhero MR2 (°C) | Shift |
|---|---|---|---|---|---|
| VT1 (Cold) | — | 72.0% | +3.7 | −2.0 | −5.7 °C |
| VT2 (Cool) | — | 69.8% | +7.9 | +2.8 | −5.1 °C |
| VT3 (Warm) | TS_WARM=55°C | 37.7% | +54.5 | +52.2 | −2.3 °C |
| VT5 (Hot) | — | 34.2% | +59.9 | +57.7 | −2.2 °C |
NTC models: 103AT (B25/50=3435) for TI reference, NCP15XH103F03RC (B25/85=3380) for Inhero. Typical %REGN from BQ25798 datasheet.
Code References¶
BoardConfigContainer::configureBaseBQ()— Applies all three settings at startupBqDriver::setTsWarm()/setJeitaVSet()— Existing driver APIBqDriver::setAutoIBATDIS()— Added to driver (Charger Control 0, bit 7)
JEITA Override (set board.jeitaignore)¶
Below the T-Cold boundary — −2.0 °C on the Inhero divider, see the threshold table above — the BQ25798 suspends charging for Li-ion and LiFePO4. set board.jeitaignore 1 sets the charger's TS_IGNORE bit (NTC Control 1, register 0x18, bit 0), which makes the charger treat the TS pin as always good, so charging continues through frost. It is off by default, it is bounded by a charge-rate gate, and it is used at the operator's own risk — the trade is described under What the override costs. The field background and the two positions on cold charging are in BATTERY_GUIDE.md.
Stored wish vs. derived state¶
The verb does not store an on/off flag for the override. It stores a user wish under the SimplePreferences key jeitaIgn (namespace inheromr2) and then immediately re-derives the effective state and programs the BQ25798:
ignore = !props->needs_jeita || (getJeitaIgnoreWish() && jeitaIgnoreGateOk())
The result lands in the static jeitaIgnoreActive, which is never persisted. applyJeitaIgnore() is called from setJeitaIgnoreWish(), from the CLI writers of imax and batcap, and at the end of configureChemistry().
needs_jeita is a field of the BatteryProperties table and answers one question only: does this chemistry need JEITA temperature supervision at all. It says nothing about whether an NTC is fitted.
| Chemistry | needs_jeita |
Override | Reason |
|---|---|---|---|
| Li-ion 1S | true | user wish AND gate | cold charging plates lithium on the anode |
| LiFePO4 1S | true | user wish AND gate | same mechanism, milder but present |
| LTO 2S | false | forced on | tolerates charging in frost, no cold limit to enforce |
| Na-ion 1S | false | forced on | the board enforces no cold limit; the permissible charge temperature is set by the cell datasheet (0 °C to −20 °C depending on the cell) |
| BAT_UNKNOWN | false | forced on | charge_enable = false, GPIO4 LOW → FET OFF → CE held HIGH by the pull-up → charging off; no charging to guard |
For a needs_jeita = false chemistry the verb is refused before anything is stored: Err: This chemistry runs without JEITA (always 1). With no chemistry set the reply is Err: Set board.bat first.
Fail-safe: if bqInitialized is false or the properties pointer is null, applyJeitaIgnore() forces jeitaIgnoreActive = false and returns without touching the BQ. With a dead BQ25798, get board.fmax therefore shows the stored frost behaviour even for LTO/Na-ion; the reply follows the derived runtime state.
The gate: imax ≤ 0.05C¶
jeitaIgnoreGateOk() has a precondition and a formula:
- Precondition —
batcapmust have been written by the user (a non-emptybatCappreference string). Without it the gate fails immediately: an unknown capacity gives the rate limit no reference. - Formula —
getMaxChargeCurrent_mA() <= jeitaIgnoreLimit_mA(capacity_mah), withjeitaIgnoreLimit_mA()returning0.05f * capacity_mah. The comparison is<=, so an imax exactly at 0.05C passes.
The gate reads the persisted capacity via loadBatteryCapacity(). getBatteryCapacity() returns the socStats RAM cache, which begin() fills only after configureChemistry() has run; gating the boot derivation against that cache would compare against 0 mAh and always fail.
Capacity (set board.batcap) |
0.05C limit | Example imax that passes |
|---|---|---|
| 1000 mAh | 50 mA | 50 mA |
| 3000 mAh | 150 mA | 150 mA, 100 mA |
| 8000 mAh | 400 mA | 400 mA, 250 mA |
| 20000 mAh | 1000 mA | 1000 mA, 500 mA |
1000 mAh is the bottom of the table for a reason: set board.imax has a floor of 50 mA, so below that capacity the 0.05C limit sits under every imax the CLI accepts and the gate can never pass.
A failed gate discards nothing. The wish stays in NVS; only jeitaIgnoreActive goes false and TS_IGNORE is cleared. Lowering imax back under 0.05C, or raising batcap, re-arms the override on its own — the CLI says so with ; jeitaignore 1, and there is no need to repeat set board.jeitaignore 1.
Boot behaviour¶
| Step | What happens |
|---|---|
| POR | jeitaIgnoreActive is a static initialised to false; the BQ25798 comes up with TS_IGNORE cleared |
configureBaseBQ() |
calls bq.setTsIgnore(false) explicitly — the hardware temperature guard is in charge from here |
configureChemistry() |
restores cell count, VREG, VSYSMIN and ICHG, then derives the JEITA override and applies the stored MPPT setting |
begin() |
calls setFrostChargeBehaviour(frost) afterwards, which re-writes JEITA_ISETC from the stored fmax mapping |
The derivation sits at the end of configureChemistry() on purpose. Writing the CELL bits resets ICHG to the 1 A POR default; deriving the override earlier would open a window in which the temperature guard is already off while ICHG is still 1 A, and an I2C failure inside that window would freeze the board in exactly that state. On the non-charging BAT_UNKNOWN early-return path the derivation still runs, purely so the reported state stays truthful.
Net effect after a power loss: the guard rules until configureChemistry() completes. An override that still passes the gate is active again; one that no longer passes stays off, with the wish preserved.
set board.bat runs the same derivation — it calls configureBaseBQ() and configureChemistry(), so the override is re-derived for the new chemistry. The stored wish survives a chemistry change, and the reply Bat set to <type> carries no transition suffix, so get board.jeitaignore is where the new state shows up. Switching to Li-ion or LiFePO4 also resets the stored fmax to 0%.
One register detail: because begin() re-writes ISETC after the derivation, a boot with the override active leaves ISETC holding the stored fmax mapping; applyJeitaIgnore() on its own writes UNCHANGED there. The difference is behaviourally moot — TS_IGNORE = 1 makes the BQ ignore the TS pin entirely.
Turning it off¶
set board.jeitaignore 0 stores wish = false and re-derives. On the transition from active to inactive, applyJeitaIgnore() calls setFrostChargeBehaviour(getFrostChargeBehaviour()), which re-programs JEITA_ISETC from the stored fmax mapping:
board.fmax |
JEITA_ISETC |
|---|---|
| 0% | ISETC_SUSPEND |
| 20% | 20_PERCENT |
| 40% | 40_PERCENT |
| 100% | UNCHANGED |
JEITA_ISETH gets no restore — its POR default is already "unchanged".
While the override is active, set board.fmax is refused with Err: Fmax N/A while jeitaignore is on and stores nothing; the previously stored value is what gets re-programmed once the override goes off. A chemistry that runs without JEITA is refused first, with Err: Fmax setting N/A for this chemistry (JEITA disabled).
What the override costs¶
TS_IGNORE acts on both ends of the JEITA window. Per the BQ25798 datasheet, with TS_IGNORE = 1 the charger considers the TS pin always good for charging and OTG, and TS_COLD_STAT / TS_COOL_STAT / TS_WARM_STAT / TS_HOT_STAT all report 000. So while the override is on:
- charging continues below −2 °C, at up to 0.05C;
- the hot-side charge suspend at T-Hot (≈ +57.7 °C on the Inhero divider) is gone as well.
The firmware offers no software replacement for either. The board sleeps in SYSTEMOFF with the charger enabled and no control loop runs there; a control loop would be asleep during the hours in which it would matter, which is why the design leaves it out. The 0.05C bound is the entire safety argument. On the hot side the residual risk stays small because 0.05C is thermally uninteresting.
On the cold side the gate bounds the rate; the mechanism remains. Charging a frozen graphite anode plates metallic lithium onto its surface. That is cumulative and permanent, and it shows up as capacity quietly gone.
The gate is one number for every temperature, while the tolerable charge rate falls as the cell gets colder. What that means for siting the node — and what the field evidence does and does not support — is covered in BATTERY_GUIDE.md.
Reading the state back¶
| Command | While the override is active |
|---|---|
get board.jeitaignore |
jeitaignore 1 — or jeitaignore 1 (chemistry) for LTO/Na-ion |
get board.fmax |
N/A (the test is isJeitaIgnoreActive(), so this holds for every chemistry) |
get board.conf |
F: reads N/A; J:1 is appended only for a needs_jeita chemistry |
get board.bqdiag |
ends with N:%02X = raw NTC_CONTROL_1 (0x18). Bit 0 is TS_IGNORE, so an odd N: value means the override is programmed in hardware. The TS: field of the same reply decodes STATUS_4 and reads "OK" while the override is on, because TS_IGNORE forces all four TS status bits to 000. |
Code References¶
BoardConfigContainer::setJeitaIgnoreWish()— stores the wish, re-derives, programs the BQBoardConfigContainer::jeitaIgnoreGateOk()/jeitaIgnoreLimit_mA()— the 0.05C gateBoardConfigContainer::applyJeitaIgnore(props)— derivation + TS_IGNORE/ISETC/ISETH programmingBqDriver::setTsIgnore()— NTC Control 1 (0x18), bit 0
5. Batt-TTL Prediction¶
Batt-TTL is short for battery time-to-live — the estimated remaining runtime on battery. It is not the packet hop limit that "TTL" denotes in mesh networking.
Data Source and Time Base¶
The Batt-TTL calculation is based on the 7-day moving average of daily net energy consumption, calculated from a 168-hour ring buffer (7 days) of hourly INA228 coulomb counter measurements.
Data Flow¶
INA228 Hardware Coulomb Counter (20-bit ADC, ±0.1% accuracy)
│
▼
updateHourlyStats() — every hour
│ Stores per hour: charged_mah, discharged_mah, solar_mah
│ in hours[168] ring buffer (BatterySOCStats.hours[])
▼
calculateRollingStats() — after each hourly update
│ Sums last 168 hours → divides by 7
│ → avg_7day_daily_net_mah (= solar − discharged per day)
│ Minimum requirement: ≥ 24 hours of valid data
▼
calculateTTL() — after calculateRollingStats()
│ extractable_mah / |deficit_per_day| × 24 = Batt-TTL hours
│ (extractable = remaining − trapped charge, see formula below)
▼
socStats.ttl_hours → getTTL_Hours() → board.stats / telemetry
Calculation¶
Method: calculateTTL() in BoardConfigContainer.cpp
- Called: After calculateRollingStats() (hourly)
- Time base: 7-day moving average (avg_7day_daily_net_mah) from hourly samples
Prerequisites for Batt-TTL > 0:
1. living_on_battery == true (24h net is negative, i.e. energy deficit)
2. avg_7day_daily_net_mah < 0 (7-day average shows net discharge)
3. capacity_mah > 0 (battery capacity known, via set board.batcap)
4. At least 24 hours of valid data in the ring buffer
Formula (Trapped-Charge model):
remaining_capacity_mah = (SOC% / 100) × capacity_mah
trapped_mah = capacity_mah × (1 − f(T))
extractable_mah = max(0, remaining_capacity_mah − trapped_mah)
daily_deficit_mah = -avg_7day_daily_net_mah (positive value)
TTL_hours = extractable_mah / daily_deficit_mah × 24
temp_derating_factor); f(T) = 1 at ≥ 25 °C, so at moderate temperatures nothing is trapped and the formula reduces to remaining/deficit.
Batt-TTL = 0 means:
- Device is solar-powered (net surplus) → living_on_battery == false
- Less than 24h of data collected (cold start)
- Battery capacity unknown
Infinite Batt-TTL (telemetry):
- When living_on_battery == false and SOC valid → transmitted as 990 days (max value)
Example: - SOC: 60% = 1200mAh remaining (with 2000mAh capacity) - Temperature ≥ 25 °C → f(T) = 1, no trapped charge → extractable = 1200mAh - 7-day avg: -100 mAh/day (from 168h hourly samples) - Batt-TTL: 1200 / 100 × 24 = 288 hours = 12 days
CLI output: board.stats
+150/+120/+90mAh C:200 D:50 3C:180 3D:60 7C:160 7D:70 SOL M:85% BT:N/A ← Solar surplus
-80/-100/-110mAh C:10 D:90 3C:15 3D:115 7C:20 7D:130 BAT M:45% BT:12d0h ← 12 days until empty
6. RTC Wakeup Management¶
RV-3028-C7 Integration¶
Pin: GPIO17 (WB_IO1) → RTC INT
Init: InheroMr2Board::begin()
- attachInterrupt(RTC_INT_PIN, rtcInterruptHandler, FALLING)
- Checks GPREGRET2 for wake-up reason
Countdown Timer Configuration¶
Method: configureRTCWake() in InheroMr2Board.cpp
- Tick Rate: 1/60 Hz (1 minute per tick), configured via TD=11 in CTRL1
- Max Countdown: 4095 minutes ≈ 2.8 days (12-bit timer register)
- Low-Voltage Sleep Interval: LOW_VOLTAGE_SLEEP_MINUTES = 60 min (1h)
- Rationale: Each wake is a System-ON reset with an early-boot fast path (minimal I2C: clear RTC TF, read VBAT, re-sleep) costing only ~0.03 mAh
Registers:
RV3028_CTRL1 (0x0F): TE=1, TD=11 (1/60 Hz), TRPT=0 (Single shot)
RV3028_CTRL2 (0x10): TIE=1 (Timer Interrupt Enable, bit 4)
RV3028_STATUS (0x0E): TF (Timer Flag, bit 3) — must be cleared after wake!
RV3028_TIMER_VALUE_0 (0x0A): Countdown value LSB
RV3028_TIMER_VALUE_1 (0x0B): Countdown value MSB (upper 4 bits)
Interrupt Handler¶
Method: rtcInterruptHandler() — only sets rtc_irq_pending = true.
The actual TF clear happens in main loop context in tick() via I2C (read-modify-write, clears only the TF bit):
// In InheroMr2Board::tick() — main loop context:
if (rtc_irq_pending) {
rtc_irq_pending = false;
// Read RV3028_REG_STATUS ...
uint8_t status = Wire.read();
status &= ~(1 << 3); // Clear TF bit only → INT pin goes HIGH via pull-up
Wire.beginTransmission(RTC_I2C_ADDR);
Wire.write(RV3028_REG_STATUS);
Wire.write(status); // write back — other status flags stay untouched
Wire.endTransmission();
}
Why not in the ISR? I2C (Wire) must not be called from an ISR context.
The ISR only sets the flag; tick() checks it in the main loop.
7. Power Management Flow¶
Shutdown Sequence (Rev 1.1 — System Sleep with GPIO latch)¶
Method: initiateShutdown() in InheroMr2Board.cpp
On Low-Voltage → System Sleep with GPIO latch (< 500µA, CE FET holds state):
Flow: INA228 ALERT ISR → Flag → tickPeriodic() → board.initiateShutdown(SHUTDOWN_REASON_LOW_VOLTAGE):
- Stop Background Tasks:
BoardConfigContainer::stopBackgroundTasks() - Stops heartbeat task (only remaining FreeRTOS task with GPIO)
-
Disarms INA228 low-voltage alert (detach ISR, disable BUVL)
-
INA228 to minimum current: release the ALERT pin (
enableAlert(false, ...),setUnderVoltageAlert(0)— a latched-LOW ALERT would waste ~330µA through the pull-up), thenshutdown()(ADC off, ~3.5µA) -
SX1262 Sleep + PE4259 off:
inhero::prepareRadioForSystemOff()— firstradio.sleep(false)(Cold Sleep via SPI, ~0.16µA), thendigitalWrite(SX126X_POWER_EN, LOW)(PE4259 VDD off) -
LEDs off: PIN_LED1, PIN_LED2 LOW
-
Latch CE pin HIGH (GPIO output latch preserved for P0.04):
digitalWrite(BQ_CE_PIN, HIGH)→ CE FET ON → CE LOW → charging active- P0.04 is excluded from
disconnectLeakyPullups()→ GPIO latch stays HIGH in System Sleep -
Without latch: ext. pull-down on gate → FET OFF → pull-up on CE → CE HIGH → charging OFF
-
INA228 + BQ25798 minimal current:
inhero::prepareIcsForSystemOff()(raw-I2C safety net, repeats the INA228 shutdown with readback) -
BME280 to sleep: forced Sleep mode via I2C (saves ~1–7µA; harmless NACK if not populated)
-
Configure RTC wake:
configureRTCWake(LOW_VOLTAGE_SLEEP_MINUTES)(60 min) -
Clear P0 LATCH for the RTC INT pin (a stale latch would fire DETECT immediately → instant wake → boot loop)
-
Release I2C:
Wire.end(), theninhero::disconnectLeakyPullups()(each held-LOW pull-up wastes ~250µA) -
Save shutdown reason:
NRF_POWER->GPREGRET2 = GPREGRET2_LOW_VOLTAGE_SLEEP | reason -
System Sleep with GPIO latch:
sd_power_system_off()→ nRF52840 System-Off (< 500µA total)- GPIO4 latch preserved (excluded from disconnectLeakyPullups) → FET stays ON → CE LOW → charging active
- RAM contents are lost (168h statistics, SOC, etc.)
- RTC interrupt on GPIO17 wakes system after timer expires
SOC is not written during shutdown — on the next successful recovery boot, begin() calls setSOCManually(0.0) (low-voltage recovery), so SOC restarts at 0%.
Why System Sleep with GPIO latch? - N-FET for CE pin → GPIO4 latch preserved HIGH → FET ON → CE LOW → charging active - Total consumption: < 500µA (nRF52840 System-Off + RTC + quiescent currents of all components)
168h statistics are lost on System Sleep — no persistence mechanism exists for the ring buffer data. After recovery, statistics start from zero.
Wake-up Check (Anti-Motorboating)¶
Method: InheroMr2Board::begin()
The code checks GPREGRET2 for shutdown reason and battery voltage for wake-up decisions.
2 Cases:
Case 1: Wake from Low-Voltage Sleep ((GPREGRET2 & 0x03) == SHUTDOWN_REASON_LOW_VOLTAGE)
// InheroMr2Board::begin() — Early Boot Fast Path (simplified)
uint8_t shutdown_reason = NRF_POWER->GPREGRET2;
if ((shutdown_reason & 0x03) == SHUTDOWN_REASON_LOW_VOLTAGE) {
Wire.begin();
inhero::clearTimerFlag(); // wake was a reset — the ISR never saw the RTC event
uint16_t vbat_mv = Ina228Driver::readVBATDirect(&Wire, INA228_I2C_ADDR);
uint16_t wake_threshold = getLowVoltageWakeThreshold();
if (vbat_mv == 0 || vbat_mv < wake_threshold) {
// Voltage still too low → back to System Sleep.
// The wake reset cleared all PIN_CNF — the sleep-time GPIO latch does NOT
// survive it. CE must be re-driven OUTPUT HIGH or solar charging stops.
pinMode(BQ_CE_PIN, OUTPUT);
digitalWrite(BQ_CE_PIN, HIGH);
inhero::prepareIcsForSystemOff(); // INA228 + BQ25798 to minimal current
inhero::prepareRadioForSystemOff(false); // SX1262 back to Cold Sleep
configureRTCWake(LOW_VOLTAGE_SLEEP_MINUTES);
inhero::disconnectLeakyPullups();
NRF_POWER->GPREGRET2 = GPREGRET2_LOW_VOLTAGE_SLEEP | SHUTDOWN_REASON_LOW_VOLTAGE;
sd_power_system_off(); // Stays in low-voltage sleep cycle
}
// Voltage OK → normal boot; low-voltage recovery marking + SOC=0%
// are applied after boardConfig.begin()
NRF_POWER->GPREGRET2 = SHUTDOWN_REASON_NONE;
}
Case 2: Normal Cold Boot (power-on, reset button, voltage OK)
else {
// Continue normal boot
// INA228 and all other components are initialized
}
Direct ADC Read (boardConfig not yet ready):
// Must read directly from INA228 ADC registers (20-bit ADC, left-aligned in 24-bit registers, ±0.1% accuracy)
uint16_t vbat_mv = Ina228Driver::readVBATDirect(&Wire, INA228_I2C_ADDR);
Voltage Thresholds (Chemistry-Specific, 1-Level System): | Chemistry | lowv_sleep_mv (ALERT) | lowv_wake_mv (Recovery) | Hysteresis | |-----------|----------------------|------------------------|------------| | Li-ion 1S | 3100 | 3300 | 200mV | | LiFePO4 1S | 2700 | 2900 | 200mV | | LTO 2S | 3900 | 4100 | 200mV | | Na-ion 1S | 2500 | 2700 | 200mV |
Anti-Motorboating: The early-boot check in begin() prevents the system from repeatedly booting and immediately crashing at marginal voltage. Only when VBAT is above lowv_wake_mv does it boot normally.
Power consumption in System Sleep with GPIO latch (Low-Voltage Sleep): - Total: < 500µA (nRF52840 System-Off + RTC + quiescent currents of all components) - CE FET: GPIO4 latch preserved HIGH → FET ON → CE LOW → solar charging active
8. INA228 ALERT Pin (Rev 1.1)¶
Wiring¶
Pin: INA228 ALERT → P1.02 (nRF52840 GPIO, with ext. pull-up) TPS62840 EN: Switched via 3.3V_off slide switch
Operation¶
The ALERT pin is used as a software interrupt:
armLowVoltageAlert()configures INA228 BUVL (Bus Under-Voltage Limit) tolowv_sleep_mv- ALERT fires as FALLING edge interrupt on P1.02
- ISR (
lowVoltageAlertISR()) setslowVoltageAlertFired = true(flag only, no FreeRTOS call) tickPeriodic()checks flag in the next main loop tick and callsinitiateShutdown()→ System Sleep
No latch problem: Since ALERT does not go to TPS62840 EN, there is no latched-off behavior.
The system can boot normally after RTC wake and check voltage in begin().
9. SX1262 Power Control & PE4259 RF Switch¶
Hardware Architecture¶
- SX1262: LoRa transceiver (SPI bus), sleep mode via
SetSleepSPI command - PE4259: SPDT RF antenna switch in single-pin mode:
- Pin 6 (VDD): GPIO 37 (P1.05,
SX126X_POWER_EN) — power supply (must be HIGH for operation) - Pin 4 (CTRL): SX1262 DIO2 — TX/RX switching (automatic via
setDio2AsRfSwitch(true))
Shutdown Sequence (in initiateShutdown())¶
The SX1262 is powered down in two steps — order is critical:
// Step 1: SX1262 to Cold Sleep via SPI (MUST be first!)
radio_driver.powerOff(); // → radio.sleep(false) → SPI SetSleep command
delay(10);
// Step 2: PE4259 RF switch power off
digitalWrite(SX126X_POWER_EN, LOW); // VDD off → PE4259 off
Why this order?
- radio.sleep(false) sends an SPI command to the SX1262 → ensures clean radio shutdown
- PE4259 VDD (GPIO 37) powers the RF switch, NOT the SX1262 directly
- SPI is powered by the nRF52840 3.3V rail, not by PE4259
- For safety: First put SX1262 to sleep, then power off PE4259
Boot Sequence (in begin())¶
// PE4259 VDD on → RF switch ready
pinMode(SX126X_POWER_EN, OUTPUT);
digitalWrite(SX126X_POWER_EN, HIGH);
delay(10); // PE4259 power-on time
// Later in radio_init() → target.cpp:
radio.std_init(&SPI); // → setDio2AsRfSwitch(true) → DIO2 controls TX/RX
Important details:
- SX126X_POWER_EN (GPIO 37 / P1.05) controls the PE4259 VDD, NOT the SX1262 power
- DIO2 is controlled internally by the SX1262 (setDio2AsRfSwitch(true)) — no GPIO needed
- Sleep current SX1262: ~0.16µA (Cold Sleep) — datasheet value
- Without radio_driver.powerOff(): SX1262 remains in RX mode → ~5mA power consumption!
10. BQ25798 CE Pin Safety (Rev 1.1 — FET-inverted)¶
Problem¶
The BQ25798 starts with default configuration (1S Li-ion, 4.2V charge voltage). If a LiFePO4 battery (3.5V max) is connected and the RAK has not yet booted, the BQ25798 would overcharge the battery → fire hazard.
Hardware Design (Rev 1.1 — FET-inverted)¶
- Pin:
BQ_CE_PIN= GPIO 4 (P0.04 / WB_IO4) - CE N-FET: Gate ← GPIO4 (ext. pull-down), Drain → CE, Source → GND
- External pull-down on Gate: Defaults gate LOW when GPIO is floating → FET OFF
- External pull-up on CE: 100 kΩ to REGN → CE HIGH when FET OFF → charging OFF (BQ25798 CE active-low)
- GPIO HIGH → FET ON → CE pulled to GND (LOW) → charging ON
- GPIO LOW → pull-down on gate → FET OFF → pull-up on CE → CE HIGH → charging OFF
- GPIO High-Z (unpowered/reset) → pull-down on gate → FET OFF → pull-up on CE → CE HIGH → charging OFF
Key point Rev 1.1: Charging is only active when GPIO4 is driven HIGH (by firmware or GPIO output latch in System Sleep). When the RAK is unpowered or unflashed, the external pull-down ensures FET OFF → CE HIGH → charging disabled — a deliberate safety feature.
3-Layer Protection (Rev 1.1)¶
| Layer | Location | Mechanism | When |
|---|---|---|---|
| 1. Hardware (passive) | Pull-down + Pull-up | RAK unpowered → pull-down on gate → FET OFF → pull-up on CE → CE HIGH → charging OFF | Always (safety default) |
| 2. Early Boot | InheroMr2Board::begin() |
GPIO4 not yet driven → FET OFF → CE HIGH → charging OFF until firmware configures it | Before I2C init |
| 3. Chemistry Configuration | configureChemistry() |
GPIO HIGH → FET ON → CE LOW → charging ON + I2C register for known chemistry | After BQ25798 configuration |
Dual-Layer Safety (Hardware + Software)¶
// In configureChemistry() — after BQ25798 register configuration:
bq.setChargeEnable(props->charge_enable); // Software layer (I2C register)
#ifdef BQ_CE_PIN
pinMode(BQ_CE_PIN, OUTPUT);
// Rev 1.1 FET-inverted: HIGH → FET ON → CE LOW → charging active (BQ25798: CE active-low)
// FET OFF → pull-up on CE → CE HIGH → charging disabled (safety default)
digitalWrite(BQ_CE_PIN, props->charge_enable ? HIGH : LOW); // HIGH=FET ON=CE LOW=charge on
#endif
charge_enableis part of theBatteryPropertiestableBAT_UNKNOWN→charge_enable = false→ GPIO LOW → FET OFF → CE HIGH → charging disabled + register disabled- Known chemistry →
charge_enable = true→ GPIO HIGH → FET ON → CE LOW → charging enabled + register enabled
Behavior in System Sleep with GPIO latch (Rev 1.1)¶
In Rev 1.1, System Sleep with GPIO latch is used (via initiateShutdown()):
- CE is set from the stored battery configuration before System Sleep (unknown chemistry: GPIO LOW).
- P0.04 is excluded from disconnectLeakyPullups() → the configured GPIO output latch is preserved
- With charging enabled: GPIO4 latched HIGH → CE FET ON → CE LOW → charging active
- BQ25798 MPPT/CC/CV runs autonomously in hardware → solar charging possible
During each hourly low-voltage wake, CE is restored as an output according to the stored chemistry after the GPIO reset. If charging and MPPT are configured on, firmware first checks whether the BQ25798 responds. This maintenance requires no ADC or VBUS measurement and stops on I²C errors. With PG=0, it performs one HIZ toggle, waits up to 1 s for PG and re-enables MPPT in the same wake if PG=1. Before returning to sleep, ADC and interrupts return to their low-power state. The RAK continues to drive CE during sleep; software maintenance runs only on wake.
- Power consumption: < 500µA (nRF52840 System-Off + RTC + quiescent currents of all components)
| State | CE Pin | Charging | Solar Recovery |
|---|---|---|---|
| RAK unpowered (no battery) | HIGH (pull-up, FET OFF) | Disabled (safety default) | N/A |
| Early Boot | HIGH (pull-up, GPIO not driven) | Disabled (not yet configured) | No |
| BAT_UNKNOWN | HIGH (GPIO LOW → FET OFF) | Disabled (CE + I2C register) | No |
| Chemistry configured | LOW (GPIO HIGH → FET ON) | Active | Yes |
| System Sleep (Low-Voltage) | LOW (GPIO latch HIGH → FET ON) | Active | Yes |
11. Statistics Persistence¶
Current State¶
The 168h ring buffer statistics (coulomb counter, MPPT data, SOC state) are stored in RAM only and are lost on every reboot — whether System Sleep or cold boot. No persistence mechanism exists (neither .noinit section nor LittleFS snapshot).
Persistent data (survives reboots via LittleFS):
- Battery type (batType)
- Battery capacity (batCap)
- NTC calibration (tcCal)
- MPPT setting (mpptEn)
- Installation altitude for QNH correction (altitude)
- Frost behavior (frost)
- Max charge current (maxChrg)
- LED setting (leds_en)
- JEITA override wish (jeitaIgn) — the effective override is derived from it on every chemistry apply and is never persisted
Non-persistent data (lost on reboot): - 168h energy ring buffer (hourly charge/discharge/solar mAh) - MPPT statistics (168h MPPT activity buffer) - SOC percentage (set to 0% after recovery, synchronized to 100% on "Charging Done") - Batt-TTL calculation (requires min. 24h data after each restart) - Daily energy balance (7-day window rebuilds after restart)
The INA228 calibration (SHUNT_CAL derived from CURRENT_LSB and the 100mΩ shunt) is computed from fixed constants in Ina228Driver::begin() on every boot, so it needs no persistence. There is no runtime correction factor any more — on Rev 1.1 the PCB layout and shunt tolerance make one unnecessary.
12. CLI Commands¶
Getters¶
board.bat # Query battery type
# Output: liion1s | lifepo1s | lto2s | naion1s | none
board.fmax # Query frost charge behavior
# Output: 0% | 20% | 40% | 100%
# Output: N/A whenever the JEITA override is active
# (chemistry-forced on LTO/Na-ion/none, or user-armed)
board.imax # Query maximum charge current
# Output: <current>mA (e.g. 500mA)
board.mppt # Query MPPT status
# Output: MPPT=1 | MPPT=0
board.telem # Real-time telemetry with SOC
# Output: B:<V>V/<I>mA/<T>C SOC:<percent>% S:<V>V/<solar current>
# Example: B:3.85V/125.4mA/22C SOC:68.5% S:5.12V/385mA
# Example: B:3.85V/-8.2mA/N/A SOC:N/A S:0.00V/0mA
board.stats # Energy statistics (balance + MPPT + Batt-TTL)
# Output: <24h>/<3d>/<7d>mAh C:<24h> D:<24h> 3C:<3d> 3D:<3d> 7C:<7d> 7D:<7d> <SOL|BAT> M:<mppt>% BT:<ttl>
# Example: +125/+45/+38mAh C:200 D:75 3C:150 3D:105 7C:140 7D:102 SOL M:85% BT:N/A
# Example: -30/-45/-40mAh C:10 D:40 3C:5 3D:50 7C:8 7D:48 BAT M:45% BT:12d0h
# SOL = Solar surplus, BAT = Energy deficit
# BT: Batt-TTL (N/A if solar surplus or <24h data)
board.cinfo # Charger info + last PG-stuck HIZ toggle
# Output: "PG / CC HIZ:never" or "!PG / !CHG HIZ:3m ago"
board.selftest # I²C hardware probe (all on-board devices)
# Output: "INA:OK BQ:OK RTC:OK BME:OK"
# Per-device states: OK | NACK | WR_FAIL (RTC only)
board.conf # All configuration values
# Output: B:<bat> F:<fmax> M:<mppt> I:<imax> Vco:<V> V0:<V>
# Example: B:liion1s F:0% M:1 I:500mA Vco:4.10 V0:3.30
# F: reads N/A while the JEITA override is active;
# " J:1" is appended only for a needs_jeita chemistry with the
# user override on (never for lto2s/naion1s, never for none)
# Example: B:liion1s F:N/A M:1 I:400mA Vco:4.10 V0:3.30 J:1
board.tccal # NTC temperature calibration offset
# Output: TC offset: +0.00 C (0.00=default)
board.leds # LED enable status (Heartbeat + BQ Stat)
# Output: "LEDs: ON (Heartbeat + BQ Stat)"
board.batcap # Battery capacity
# Output: 10000 mAh (set) or 2000 mAh (default; LiFePO4 defaults to 1500 mAh)
board.jeitaignore # JEITA override state
# Output: N/A ← no chemistry set (none)
# Output: jeitaignore 1 (chemistry) ← lto2s | naion1s
# Output: jeitaignore 1 ← user override effectively active
# Output: jeitaignore 1, N/A, C>0.05 ← wish stored, imax above 0.05C
# Output: jeitaignore 1, N/A, batcap not set
# Output: jeitaignore 0 ← no wish stored
Unknown getter → Err: bat|fmax|imax|mppt|telem|stats|cinfo|conf|tccal|leds|batcap|jeitaignore. The list omits the bqdiag, selftest and socdebug getters, which do work.
Setters¶
set board.bat <type> # Set battery chemistry
# Options: liion1s | lifepo1s | lto2s | naion1s | none
set board.fmax <value> # Set frost charge current reduction
# Options: 0% | 20% | 40% | 100%
# Limits charge current in T-Cool range (approx. -2 °C to +3 °C, see JEITA table in README)
# Err: Set board.bat first ← none
# Err: Fmax setting N/A for this chemistry (JEITA disabled) ← lto2s | naion1s
# Err: Fmax N/A while jeitaignore is on ← user override active
# None of the three refusals stores anything.
set board.imax <mA> # Set maximum charge current
# Range: 50-1500 mA (out of range: "Err: Try 50-1500", nothing written)
# imax is a gate quantity for the JEITA override — the reply
# names a state change:
# Max charge current set to 400mA
# Max charge current set to 400mA; jeitaignore 1
# Max charge current set to 800mA; jeitaignore N/A, C>0.05
set board.mppt <0|1> # Enable/disable MPPT
set board.batcap <mAh> # Set battery capacity
# Range: 100-100000 mAh
# (out of range: "Err: Invalid capacity (100-100000 mAh)", no re-derive)
# Also a gate quantity — same reply pattern as imax:
# Battery capacity set to 8000 mAh
# Battery capacity set to 8000 mAh; jeitaignore 1
# Battery capacity set to 4000 mAh; jeitaignore N/A, C>0.05
# Side effect: socStats.soc_valid = false, so SOC reads N/A
# until the next "Charging Done" sync or a manual set board.soc
set board.tccal # Calibrate NTC temperature (auto via BME280)
set board.tccal reset # Reset offset to 0.00
set board.leds <on|off> # Enable/disable LEDs (on/1, off/0)
set board.soc <percent> # Manually set SOC (0-100, INA228 must be ready)
set board.jeitaignore <1|0> # JEITA override — charge through frost, at the operator's own risk
# Accepts 1|0|true|false; anything else: "Err: Use 1|0"
# With no chemistry set: "Err: Set board.bat first"
# Only for needs_jeita chemistries (liion1s, lifepo1s); otherwise:
# Err: This chemistry runs without JEITA (always 1)
# Stores a wish, then re-derives the effective override:
# jeitaignore set to 1
# jeitaignore set to 1, N/A, C>0.05
# jeitaignore set to 1, N/A, batcap not set
# jeitaignore set to 0
# Gate: batcap must be set AND imax <= 0.05C. The wish survives a
# failed gate and re-arms on its own. See "JEITA Override" in
# Section 4 and BATTERY_GUIDE.md.
Unknown setter → Err: bat|imax|fmax|mppt|batcap|tccal|leds|soc|jeitaignore.
File Overview¶
Main Implementation¶
| File | Description |
|---|---|
| InheroMr2Board.h/cpp | Board class, init, shutdown, RTC, CLI commands |
| BoardConfigContainer.h/cpp | Battery management, BQ25798, INA228, MPPT, SOC, daily balance |
| lib/Ina228Driver.h/cpp | INA228 I2C communication, calibration, coulomb counter |
| lib/BqDriver.h/cpp | BQ25798 I2C communication, MPPT, charging |
Key Methods¶
| Method | File | Function |
|---|---|---|
begin() |
InheroMr2Board.cpp | Board initialization, wake-up check, early-boot low-voltage check |
initiateShutdown() |
InheroMr2Board.cpp | System Sleep shutdown (called by tickPeriodic after ALERT) |
configureRTCWake() |
InheroMr2Board.cpp | RTC countdown timer |
rtcInterruptHandler() |
InheroMr2Board.cpp | RTC INT ISR (sets flag) |
queryBoardTelemetry() |
InheroMr2Board.cpp | CayenneLPP telemetry collection |
getLowVoltageSleepThreshold() |
InheroMr2Board.cpp | Chemistry-specific sleep voltage (INA228 ALERT) |
getLowVoltageWakeThreshold() |
InheroMr2Board.cpp | Chemistry-specific wake voltage (0% SOC) |
armLowVoltageAlert() |
BoardConfigContainer.cpp | Arm INA228 BUVL alert + register ISR |
disarmLowVoltageAlert() |
BoardConfigContainer.cpp | Disarm INA228 alert + detach ISR |
lowVoltageAlertISR() |
BoardConfigContainer.cpp | ISR: sets lowVoltageAlertFired flag (checked in tickPeriodic) |
tickPeriodic() |
BoardConfigContainer.cpp | Main loop dispatch: MPPT (60s), SOC (60s), hourly (60min), low-V check |
runMpptCycle() |
BoardConfigContainer.cpp | Single MPPT cycle (solar checks, MPPT recovery) |
updateBatterySOC() |
BoardConfigContainer.cpp | Coulomb counter SOC calculation |
updateHourlyStats() |
BoardConfigContainer.cpp | Hourly sampling into the 168h ring buffer |
calculateRollingStats() |
BoardConfigContainer.cpp | 24h/3d/7d rolling sums + living_on_battery |
calculateTTL() |
BoardConfigContainer.cpp | Batt-TTL forecast |
applyJeitaIgnore() |
BoardConfigContainer.cpp | Derives the effective JEITA override, programs TS_IGNORE/ISETC/ISETH |
jeitaIgnoreGateOk() |
BoardConfigContainer.cpp | The 0.05C gate (batcap user-set AND imax ≤ 0.05C) |
refreshTempDerating() |
BoardConfigContainer.cpp | Derating temperature, BME280 fallback after 5 min without an accepted NTC update |
Ina228Driver::begin() |
lib/Ina228Driver.cpp | 100mΩ calibration, ADC config |
Ina228Driver::readVBATDirect() |
lib/Ina228Driver.cpp | Static early-boot VBAT read |
Code Fragments (Key Sections)¶
INA228 Shutdown Mode¶
// Ina228Driver.cpp — returns bool: false if the INA228 stays in continuous mode
bool Ina228Driver::shutdown() {
// Set operating mode to Shutdown (MODE = 0x0)
// This disables all conversions and Coulomb Counter.
// Retries up to 3× with readback — I2C writes can fail silently.
uint16_t adc_config = 0x0000; // MODE = 0x0 (Shutdown)
// ... write + readback retry loop, checks MODE bits [15:12] ...
}
INA228 Wake-up¶
// Ina228Driver.cpp
void Ina228Driver::wakeup() {
// Re-enable continuous measurement mode with full ADC configuration
// Must restore conversion times from begin() - defaults are much shorter (50µs)
uint16_t adc_config = (INA228_ADC_MODE_CONT_ALL << 12) | // MODE: Continuous all
(INA228_ADC_CT_2074us << 9) | // VBUSCT: 2074µs
(INA228_ADC_CT_4120us << 6) | // VSHCT: 4120µs
(INA228_ADC_CT_540us << 3) | // VTCT: 540µs
(INA228_ADC_AVG_256 << 0); // AVG: 256 samples (TX peak filtering)
writeRegister16(INA228_REG_ADC_CONFIG, adc_config);
}
RTC Interrupt Handler¶
// InheroMr2Board.cpp — ISR only sets flag, no I2C!
void InheroMr2Board::rtcInterruptHandler() {
rtc_irq_pending = true;
}
// TF clear happens in main loop context (tick())
INA228 Driver Access¶
// Direct access to INA228 driver
if (boardConfig.getIna228Driver() != nullptr) {
// INA228 specific code
}
Scenarios¶
Scenario A: Normal Discharge (Low-Voltage System Sleep) - Li-ion¶
t=0: VBAT = 3.7V → Normal (60s checks, coulomb counter running)
Daily balance: Today +150mAh SOLAR
t=+1h: VBAT = 3.5V → Normal (INA228 ALERT not triggered)
SOC: 45%
t=+2h: VBAT = 3.08V → INA228 ALERT fires (< 3100mV lowv_sleep_mv)
- lowVoltageAlertISR() → sets lowVoltageAlertFired flag
- tickPeriodic() detects flag in next tick()
- board.initiateShutdown(SHUTDOWN_REASON_LOW_VOLTAGE)
- CE latched (GPIO4 latch HIGH → FET ON → CE LOW → charging active)
- RTC: Wake in 1h (LOW_VOLTAGE_SLEEP_MINUTES = 60)
- sd_power_system_off() → System Sleep with GPIO latch (< 500µA)
t=+3h: RTC wakes → system boots → early boot check
- Ina228Driver::readVBATDirect() → VBAT = 3.15V
- VBAT < lowv_wake_mv (3300mV) → immediately back to sleep
- configureRTCWake(60) + sd_power_system_off()
t=+4h: RTC wakes → system boots → early boot check
- VBAT = 3.20V → still below 3300mV → back to sleep
t=+5h: RTC wakes → system boots → early boot check
- VBAT = 3.45V (solar recovery!)
- VBAT > lowv_wake_mv (3300mV) → normal boot
- Low-voltage recovery marked, SOC at 0%
- Coulomb counter restarts
- Daily balance rebuilds
Scenario B: Critical Discharge (Rev 1.1 — no hardware UVLO)¶
In Rev 1.1 there is no hardware UVLO (TPS62840 EN via 3.3V_off switch).
The INA228 ALERT on P1.02 serves as software interrupt for System Sleep.
t=0: VBAT = 3.08V → INA228 ALERT fires
- tickPeriodic() → initiateShutdown()
- System Sleep with GPIO latch (< 500µA), CE latched LOW (charging active), RTC wake 1h
t=+1h: RTC wake → early boot → VBAT = 3.05V (still below 3300mV)
- Immediately back to sleep (CE remains latched LOW → solar charging possible)
t=+2h: RTC wake → VBAT = 2.95V (dropped further, no solar)
- Immediately back to sleep
- Board continues cycling at < 500µA + hourly boot (~0.03mAh)
t=+∞: At < 500µA the battery can survive for months
- As soon as solar available → VBAT rises → normal boot at >3300mV
- NO latching: system can ALWAYS recover on its own
Scenario C: Energy Balance Tracking - LiFePO4¶
Day 0: VBAT = 3.2V, SOC = 85%
24 hourly entries land in hours[]: Σ charged +800mAh (solar), Σ discharged -450mAh
last_24h_net = +350mAh → SOLAR
Day 1: VBAT = 3.15V, SOC = 72%
Charged: +650mAh, Discharged: -520mAh
last_24h_net = +130mAh → SOLAR
Day 2: VBAT = 3.05V, SOC = 58%
Charged: +200mAh (heavy clouds), Discharged: -480mAh
last_24h_net = -280mAh → BAT (living_on_battery = true)
3-day avg: (350+130-280)/3 = +66.7 mAh/day
7-day avg: (350+130-280)/7 = +28.6 mAh/day
(168h window still part-filled — the sum is always divided by 7)
→ 7-day avg positive → Batt-TTL stays 0 (shown as N/A)
Day 3: VBAT = 2.95V, SOC = 42%
Charged: +150mAh (heavy clouds), Discharged: -500mAh
last_24h_net = -350mAh → BAT
3-day avg: (130-280-350)/3 = -166.7 mAh/day
7-day avg: (350+130-280-350)/7 = -21.4 mAh/day → negative → Batt-TTL is calculated
living_on_battery = true
Batt-TTL calculation (7-day avg basis, ≥25 °C → f(T)=1, nothing trapped):
remaining = 42% × 1500mAh = 630mAh
deficit = |-21.4| = 21.4 mAh/day
Batt-TTL = (630 / 21.4) × 24 ≈ 706 hours ≈ 29.4 days
CLI output: "-350/-167/-21mAh C:150 D:500 3C:.. 3D:.. 7C:.. 7D:.. BAT M:45% BT:29d10h"
See Also¶
- README.md — User documentation and CLI reference
- DATASHEET.md — Hardware specifications and pinout
- TELEMETRY.md — Telemetry channels explained (what the app displays)
- QUICK_START.md — Commissioning and configuration
- BATTERY_GUIDE.md — Battery chemistry comparison and deployment guide
- FAQ.md — Frequently asked questions
- CLI_CHEAT_SHEET.md — All CLI commands at a glance
Datasheets¶
- INA228: https://www.ti.com/product/INA228
- RV-3028-C7: https://www.microcrystal.com/en/products/real-time-clock-rtc-modules/rv-3028-c7/
- BQ25798: https://www.ti.com/product/BQ25798
- TPS62840: https://www.ti.com/product/TPS62840
- nRF52840: https://www.nordicsemi.com/products/nrf52840