Add persistent T-Beam charge voltage controls and report Mesh Solar limits

This commit is contained in:
mikecarper committed 2026-10-06 10:56:32 -07:00
1 parent 3177ccfafa
commit 613d9d7c43
15 files changed
+1275 -9

No files matched your search

+2
View File
@@ -227,6 +227,8 @@ jobs:
- name: Verify ESP32 static DRAM budget
run: |
python3 -B test/test_esp32_dram.py
python3 -B test/test_battery_charge_cli.py -v
python3 -B test/test_tbeam_charge_target.py -v
python3 -B test/test_elrs_power.py
python3 -B test/test_ota_heap_context.py
python3 -B test/test_ota_self_serve_policy.py -v
+74
View File
@@ -0,0 +1,74 @@
# Battery charge voltage
The `charge.voltage` commands change the battery charger's constant-voltage
target on supported boards. They are available through the shared text CLI in
the companion, repeater, room-server, sensor, and secure-chat roles. They do not change the
battery voltage display, low-voltage shutdown, battery chemistry, or charge
current.
```text
get charge.voltage
get charge.voltage.options
set charge.voltage 4.1
```
Values are in volts. Query the options first: supported targets depend on the
PMU fitted to the board. The firmware rejects values that the detected PMU
cannot represent; it does not round them to a supported target.
On T-Beam boards, a successful change is saved in NVS and reapplied after a
restart. The firmware reads the PMU setting back before reporting success.
Boards without a saved setting retain their existing startup behavior: the full
PMU driver sets 4.2 V, while the compact classic T-Beam observer driver leaves
the charger setting untouched. A failed hardware write, readback, or preference
save is reported as an error.
If the saved target cannot be read, is invalid, or cannot be restored, startup
does not overwrite it with a default target. `get charge.voltage` reports the
live charger voltage with `(boot restore failed)`. A successful saved change
clears that warning. This cannot guarantee restoration while the PMU or storage
is failing, but it avoids silently resetting a saved lower target to 4.2 V.
## Supported boards
| Board family | Detected PMU | Charge targets (V) |
| --- | --- | --- |
| LilyGo T-Beam SX1262 or SX1276 | AXP192 | 4.10, 4.15, 4.20, 4.36 |
| LilyGo T-Beam SX1262 or SX1276 | AXP2101 | 4.00, 4.10, 4.20, 4.35, 4.40 |
| LilyGo T-Beam S3 Supreme SX1262 | AXP2101 | 4.00, 4.10, 4.20, 4.35, 4.40 |
The classic T-Beam driver detects the fitted AXP192 or AXP2101. Radio type does
not determine which PMU is present. T-Beam 1W uses a different power design and
does not support this setting.
Neither AXP192 nor AXP2101 supports a 3.65 V target. The lowest supported target
is 4.10 V for AXP192 and 4.00 V for AXP2101. These PMUs cannot be configured as
LiFePO4 chargers by changing this setting.
## Heltec Mesh Solar
Mesh Solar reports `charge.voltage` as unsupported because its CN3795 charger's
constant-voltage target is set by hardware. Heltec supplies separate Li-ion
(`I`) and LiFePO4 (`F`) hardware versions, with 4.2 V/cell and 3.6 V/cell charging
respectively. Cell-count soldering and switches must also match the battery
configuration. A software battery-type setting does not convert one hardware
version into the other. See [Heltec's usage guide](https://wiki.heltec.org/docs/devices/open-source-hardware/nrf52840-series/mesh-solar/usage-guide)
and [MeshSolar datasheet, sections 4.1 and 4.4](https://resource.heltec.cn/download/MeshSolar/datasheet/MeshSolar_V1.0.0.pdf).
The BQ4050 on this board is a battery gauge and protection controller. Its
`ChargingVoltage()` is a requested voltage for a compatible smart charger. The
pinned Mesh Solar library calls that parameter `eoc`, but writing it does not
change the CN3795's voltage-feedback circuit. Valid charge termination also
requires the charging current to taper, so lowering `eoc` alone does not create
a reliable lower-voltage charge cutoff. Charge-overvoltage protection is a
separate safeguard, not the charger's normal constant-voltage target. See
[TI's BQ4050 technical reference manual, sections 4.5, 4.6, and 4.11](https://www.ti.com/lit/pdf/SLUUAQ3).
These commands do not write Mesh Solar's BQ4050 data flash or change its battery
type, protection limits, cell count, or charge-FET configuration.
## Battery profiles
The nRF52 `battery.profile`, `battery.full`, and `battery.empty` settings control
voltage-based reporting and protection. They do not change any charger target.
See [Battery profile commands](cli_commands.md#configure-nrf52-battery-protection).
+16
View File
@@ -5111,6 +5111,22 @@ manufacturer images and the boards still requiring untouched factory dumps.
---
#### Configure battery charge voltage
T-Beam SX1262/SX1276 and T-Beam S3 Supreme support persistent charger-voltage
selection through the fitted AXP192 or AXP2101 PMU:
```text
get charge.voltage.options
get charge.voltage
set charge.voltage 4.1
```
Values are exact supported targets in volts. Neither T-Beam PMU supports 3.65 V.
Heltec Mesh Solar's CN3795 charge voltage is set by its Li-ion or LiFePO4 hardware
version, so these commands report that limitation without changing BMS settings.
See [charge-voltage behavior and supported targets](charge_voltage.md).
#### Configure nRF52 battery protection
**Usage:** `get pwrmgt.bootlock`, `set pwrmgt.bootlock <2500-4200>`
+4
View File
@@ -1,4 +1,5 @@
#include <helpers/ui/DisplayPowerSettings.h>
#include <helpers/BatteryChargeCLI.h>
#include <helpers/ui/StartupScreen.h>
#include "NotificationSettingsFile.h"
#include "MyMesh.h"
@@ -9854,6 +9855,9 @@ bool MyMesh::handleCommand(const char* command, uint32_t sender_timestamp,
return true;
}
if (mesh::power::handleBatteryChargeCommand(board, command, reply,
reply_capacity)) return true;
// Hook for future variant-specific commands not covered by the shared,
// runtime-aware FEM handlers above.
if (board.handleCommand(command, sender_timestamp, reply)) {
+7
View File
@@ -1,4 +1,5 @@
#include <helpers/RadioProfileCLI.h>
#include <helpers/BatteryChargeCLI.h>
#include <Arduino.h> // needed for PlatformIO
#include <Mesh.h>
#if MESH_PACKET_LOGGING
@@ -468,6 +469,12 @@ public:
}
void handleCommand(const char* command) {
char charge_reply[160];
if (mesh::power::handleBatteryChargeCommand(board, command, charge_reply,
sizeof(charge_reply))) {
Serial.println(charge_reply);
return;
}
#if defined(NRF52_POWER_MANAGEMENT)
char voltage_reply[160];
if (mesh::power::handleVoltagePolicyCommand(command, voltage_reply,
+12
View File
@@ -239,6 +239,18 @@ public:
// True while a board can identify an active battery-charging source.
virtual bool isChargerActive() { return false; }
// Actual charger voltage, in millivolts. Battery reporting and protection
// thresholds are separate capabilities. A successful setter must verify
// hardware readback and persist the setting for the next boot.
virtual bool getBatteryChargeTarget(uint16_t& millivolts) { return false; }
virtual bool supportsBatteryChargeTarget(uint16_t millivolts) { return false; }
virtual bool setBatteryChargeTarget(uint16_t millivolts) { return false; }
virtual const char* getBatteryChargeTargetOptions() const { return nullptr; }
virtual bool batteryChargeTargetRestoreFailed() const { return false; }
virtual const char* getBatteryChargeTargetUnsupportedReason() const {
return "charge.voltage is not supported on this board";
}
// Optional, source-specific power detection. Boards that can distinguish a USB
// supply from a solar charger override these; defaults keep every other board
// unaffected (the generic CLI prints "n/a" when a capability is absent).
+101
View File
@@ -0,0 +1,101 @@
#pragma once
#include <stddef.h>
#include <stdint.h>
#include <stdio.h>
#include <string.h>
namespace mesh {
namespace power {
// Parse volts without floating-point rounding, signs, exponents, or ignored
// suffixes. Millivolt precision keeps unsupported targets from being rounded
// into a supported charger setting.
inline bool parseChargeVolts(const char* value, uint16_t& millivolts) {
if (!value || *value < '0' || *value > '9') return false;
uint32_t whole = 0, fraction = 0;
while (*value >= '0' && *value <= '9') {
whole = whole * 10 + (*value++ - '0');
if (whole > 65) return false;
}
unsigned digits = 0;
if (*value == '.') {
++value;
while (*value >= '0' && *value <= '9') {
if (++digits > 3) return false;
fraction = fraction * 10 + (*value++ - '0');
}
if (digits == 0) return false;
}
while (*value == ' ' || *value == '\t') ++value;
if (*value) return false;
while (digits++ < 3) fraction *= 10;
const uint32_t result = whole * 1000 + fraction;
if (result > 65535) return false;
millivolts = static_cast<uint16_t>(result);
return true;
}
inline bool chargeCommandKey(const char* text, const char* key) {
const size_t length = strlen(key);
return strncmp(text, key, length) == 0
&& (text[length] == 0 || text[length] == ' ' || text[length] == '\t');
}
// Shared by infrastructure, Companion, and Terminal Chat. Board code owns
// hardware validation and persistence; the CLI must not alter BMS protection
// settings to simulate an unsupported charger voltage.
template <typename Board>
bool handleBatteryChargeCommand(Board& board, const char* command,
char* reply, size_t reply_capacity) {
if (!command || !reply || !reply_capacity) return false;
while (*command == ' ' || *command == '\t') ++command;
const bool get = strncmp(command, "get ", 4) == 0;
const bool set = strncmp(command, "set ", 4) == 0;
if (!get && !set) return false;
const char* key = command + 4;
const bool options = get && chargeCommandKey(key, "charge.voltage.options");
if (!options && !chargeCommandKey(key, "charge.voltage")) return false;
const char* value = key + strlen(options ? "charge.voltage.options" : "charge.voltage");
while (*value == ' ' || *value == '\t') ++value;
if (get && *value) {
snprintf(reply, reply_capacity, "Error: usage get charge.voltage[.options]");
return true;
}
const char* targets = board.getBatteryChargeTargetOptions();
if (!targets) {
snprintf(reply, reply_capacity, "Error: %s", board.getBatteryChargeTargetUnsupportedReason());
return true;
}
if (options) {
snprintf(reply, reply_capacity, "> %s V", targets);
return true;
}
uint16_t millivolts = 0;
if (get) {
if (!board.getBatteryChargeTarget(millivolts)) {
snprintf(reply, reply_capacity, "Error: charger read failed");
} else {
snprintf(reply, reply_capacity, "> %u.%03u V%s",
static_cast<unsigned>(millivolts / 1000),
static_cast<unsigned>(millivolts % 1000),
board.batteryChargeTargetRestoreFailed() ? " (boot restore failed)" : "");
}
return true;
}
if (!parseChargeVolts(value, millivolts)) {
snprintf(reply, reply_capacity, "Error: usage set charge.voltage <volts>");
} else if (!board.supportsBatteryChargeTarget(millivolts)) {
snprintf(reply, reply_capacity, "Error: unsupported target; allowed %s V", targets);
} else if (!board.setBatteryChargeTarget(millivolts)) {
snprintf(reply, reply_capacity, "Error: charge target not confirmed saved; check get charge.voltage");
} else {
snprintf(reply, reply_capacity, "OK - charge.voltage %u.%03u V (saved)",
static_cast<unsigned>(millivolts / 1000),
static_cast<unsigned>(millivolts % 1000));
}
return true;
}
} // namespace power
} // namespace mesh
+2
View File
@@ -4,6 +4,7 @@
#include "UsbLoggingWatchdog.h"
#include "HilStartupTrace.h"
#include <helpers/ui/DisplayPowerSettings.h>
#include <helpers/BatteryChargeCLI.h>
#include "CLICommandUtils.h"
#include "GpsPowerPolicy.h"
#include "FloodAdvertCLI.h"
@@ -2840,6 +2841,7 @@ uint8_t CommonCLI::buildAdvertData(uint8_t node_type, uint8_t* app_data) {
void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* reply) {
PrefsSaveReplyGuard save_reply(_prefs_save_failures, reply);
mesh::cli::normalizeCommandVerb(command);
if (mesh::power::handleBatteryChargeCommand(*_board, command, reply, 160)) return;
if (mesh::handleUsbLoggingWatchdogCommand(command, reply, 160)) return;
if (handleManagementCommand(command, reply)) return;
if (mesh::wireless::control().handle(command, reply, 160, millis(),
+120 -7
View File
@@ -2,15 +2,19 @@
#include <Arduino.h>
#include "TBeamBoard.h"
#include "TBeamChargeTarget.h"
#include <nvs.h>
//#include <RadioLib.h>
uint32_t deviceOnline = 0x00;
#if !defined(PORTABLE_MQTT_OBSERVER) || defined(TBEAM_SUPREME_SX1262)
bool pmuInterrupt;
static void setPmuFlag()
{
pmuInterrupt = true;
}
#endif
void TBeamBoard::begin() {
@@ -26,7 +30,9 @@ void TBeamBoard::begin() {
Wire.begin(PIN_BOARD_SDA, PIN_BOARD_SCL);
#endif
power_init();
charge_pmu_ready = power_init();
charge_target_restore_failed = !charge_pmu_ready;
if (charge_pmu_ready) restoreBatteryChargeTarget();
//Configure user button
pinMode(PIN_USER_BTN, INPUT);
@@ -39,8 +45,8 @@ void TBeamBoard::begin() {
esp_reset_reason_t reason = esp_reset_reason();
if (reason == ESP_RST_DEEPSLEEP) {
long wakeup_source = esp_sleep_get_ext1_wakeup_status();
if (wakeup_source & (1 << P_LORA_DIO_1)) { // received a LoRa packet (while in deep sleep)
uint64_t wakeup_source = esp_sleep_get_ext1_wakeup_status();
if (wakeup_source & (1ULL << P_LORA_DIO_1)) { // received a LoRa packet (while in deep sleep)
startup_reason = BD_STARTUP_RX_PACKET;
}
@@ -139,13 +145,14 @@ void TBeamBoard::printPMU()
}
#endif
#if defined(PORTABLE_MQTT_OBSERVER) && !defined(TBEAM_SUPREME_SX1262)
static bool pmuRead(uint8_t reg, uint8_t& value) {
PMU_WIRE_PORT.beginTransmission(I2C_PMU_ADD);
PMU_WIRE_PORT.write(reg);
if (PMU_WIRE_PORT.endTransmission(false) != 0 ||
PMU_WIRE_PORT.requestFrom(I2C_PMU_ADD, 1) != 1) return false;
value = PMU_WIRE_PORT.read();
const int received = PMU_WIRE_PORT.read();
if (received < 0) return false;
value = static_cast<uint8_t>(received);
return true;
}
@@ -156,6 +163,111 @@ static bool pmuWrite(uint8_t reg, uint8_t value) {
return PMU_WIRE_PORT.endTransmission() == 0;
}
namespace {
struct ChargePmuIO {
bool read(uint8_t reg, uint8_t& value) { return pmuRead(reg, value); }
bool write(uint8_t reg, uint8_t value) { return pmuWrite(reg, value); }
};
class ChargeTargetStore {
nvs_handle_t handle = 0;
bool opened = false;
bool absent_namespace = false;
static constexpr const char* key = "target_mv";
public:
ChargeTargetStore(const char* name, bool read_only) {
if (!name) return;
const esp_err_t result = nvs_open(name, read_only ? NVS_READONLY : NVS_READWRITE,
&handle);
opened = result == ESP_OK;
absent_namespace = read_only && result == ESP_ERR_NVS_NOT_FOUND;
}
~ChargeTargetStore() { if (opened) nvs_close(handle); }
bool read(uint16_t& value, bool& present) {
if (absent_namespace) {
value = 0;
present = false;
return true;
}
if (!opened) return false;
const esp_err_t result = nvs_get_u16(handle, key, &value);
if (result == ESP_ERR_NVS_NOT_FOUND) {
value = 0;
present = false;
return true;
}
if (result != ESP_OK) return false;
present = true;
return true;
}
bool write(uint16_t value) {
if (!opened || nvs_set_u16(handle, key, value) != ESP_OK ||
nvs_commit(handle) != ESP_OK) return false;
uint16_t actual = 0;
bool present = false;
return read(actual, present) && present && actual == value;
}
bool erase() {
if (!opened) return false;
const esp_err_t result = nvs_erase_key(handle, key);
if (result != ESP_OK && result != ESP_ERR_NVS_NOT_FOUND) return false;
if (result == ESP_OK && nvs_commit(handle) != ESP_OK) return false;
uint16_t actual = 0;
bool present = false;
return read(actual, present) && !present;
}
};
} // namespace
uint8_t TBeamBoard::getChargeTargetModel() const {
if (!charge_pmu_ready) return 0;
#if defined(PORTABLE_MQTT_OBSERVER) && !defined(TBEAM_SUPREME_SX1262)
return pmu_model;
#else
if (!PMU) return 0;
if (PMU->getChipModel() == XPOWERS_AXP192) return mesh::tbeam_charge::AXP192;
if (PMU->getChipModel() == XPOWERS_AXP2101) return mesh::tbeam_charge::AXP2101;
return 0;
#endif
}
bool TBeamBoard::getBatteryChargeTarget(uint16_t& millivolts) {
ChargePmuIO io;
return mesh::tbeam_charge::read(io, getChargeTargetModel(), millivolts);
}
bool TBeamBoard::supportsBatteryChargeTarget(uint16_t millivolts) {
return mesh::tbeam_charge::supports(getChargeTargetModel(), millivolts);
}
const char* TBeamBoard::getBatteryChargeTargetOptions() const {
return mesh::tbeam_charge::options(getChargeTargetModel());
}
bool TBeamBoard::setBatteryChargeTarget(uint16_t millivolts) {
const uint8_t model = getChargeTargetModel();
if (!mesh::tbeam_charge::supports(model, millivolts)) return false;
ChargePmuIO io;
ChargeTargetStore store(mesh::tbeam_charge::storeNamespace(model), false);
const bool success = mesh::tbeam_charge::setPersistent(io, store, model, millivolts);
if (success) charge_target_restore_failed = false;
return success;
}
void TBeamBoard::restoreBatteryChargeTarget() {
const uint8_t model = getChargeTargetModel();
ChargePmuIO io;
ChargeTargetStore store(mesh::tbeam_charge::storeNamespace(model), true);
#if defined(PORTABLE_MQTT_OBSERVER) && !defined(TBEAM_SUPREME_SX1262)
const bool apply_fresh_default = false;
#else
const bool apply_fresh_default = true;
#endif
charge_target_restore_failed =
!mesh::tbeam_charge::configureBoot(io, store, model, apply_fresh_default);
}
#if defined(PORTABLE_MQTT_OBSERVER) && !defined(TBEAM_SUPREME_SX1262)
static bool pmuUpdate(uint8_t reg, uint8_t clear_mask, uint8_t set_mask) {
uint8_t value;
return pmuRead(reg, value) && pmuWrite(reg, (value & ~clear_mask) | set_mask);
@@ -163,7 +275,10 @@ static bool pmuUpdate(uint8_t reg, uint8_t clear_mask, uint8_t set_mask) {
bool TBeamBoard::power_init() {
PMU_WIRE_PORT.begin(PIN_BOARD_SDA, PIN_BOARD_SCL);
pmu_model = 0;
if (!pmuRead(0x03, pmu_model)) return false;
if (pmu_model != mesh::tbeam_charge::AXP192 &&
pmu_model != mesh::tbeam_charge::AXP2101) return false;
if (pmu_model == 0x4A) {
// AXP2101: ALDO2 and ALDO3 at 3.3 V, plus battery voltage ADC.
@@ -234,7 +349,6 @@ bool TBeamBoard::power_init()
PMU->disableIRQ(XPOWERS_AXP192_ALL_IRQ); //Disable PMU IRQ
PMU->setChargerConstantCurr(XPOWERS_AXP192_CHG_CUR_450MA); //Set battery charging current
PMU->setChargeTargetVoltage(XPOWERS_AXP192_CHG_VOL_4V2); //Set battery charge-stop voltage
}
else if(PMU->getChipModel() == XPOWERS_AXP2101){
#ifdef TBEAM_SUPREME_SX1262
@@ -315,7 +429,6 @@ bool TBeamBoard::power_init()
PMU->disableIRQ(XPOWERS_AXP2101_ALL_IRQ); //Disable all PMU interrupts
PMU->setChargerConstantCurr(XPOWERS_AXP2101_CHG_CUR_500MA); //Set battery charging current to 500mA
PMU->setChargeTargetVoltage(XPOWERS_AXP2101_CHG_VOL_4V2); //Set battery charging cutoff voltage to 4.2V
}
+13 -2
View File
@@ -118,6 +118,10 @@ enum {
};
bool power_init();
bool charge_pmu_ready = false;
bool charge_target_restore_failed = false;
uint8_t getChargeTargetModel() const;
void restoreBatteryChargeTarget();
//void radiotype_detect();
public:
@@ -127,6 +131,13 @@ public:
void scanDevices(TwoWire *w);
#endif
void begin();
bool getBatteryChargeTarget(uint16_t& millivolts) override;
bool supportsBatteryChargeTarget(uint16_t millivolts) override;
bool setBatteryChargeTarget(uint16_t millivolts) override;
const char* getBatteryChargeTargetOptions() const override;
bool batteryChargeTargetRestoreFailed() const override {
return charge_target_restore_failed;
}
#ifndef TBEAM_SUPREME_SX1262
void onBeforeTransmit() override{
@@ -138,9 +149,9 @@ public:
#endif
uint16_t getBattMilliVolts(){
if (!charge_pmu_ready) return 0;
#if defined(PORTABLE_MQTT_OBSERVER) && !defined(TBEAM_SUPREME_SX1262)
uint8_t high_reg = pmu_model == 0x4A ? 0x34 : 0x78;
uint8_t low_reg = high_reg + 1;
PMU_WIRE_PORT.beginTransmission(I2C_PMU_ADD);
PMU_WIRE_PORT.write(high_reg);
if (PMU_WIRE_PORT.endTransmission(false) != 0 ||
@@ -150,7 +161,7 @@ public:
if (pmu_model == 0x4A) return ((high & 0x1F) << 8) | low;
return (uint16_t)((((high << 4) | (low & 0x0F)) * 11) / 10);
#else
return PMU->getBattVoltage();
return PMU ? PMU->getBattVoltage() : 0;
#endif
}
+151
View File
@@ -0,0 +1,151 @@
#pragma once
#include <stdint.h>
namespace mesh {
namespace tbeam_charge {
enum : uint8_t { AXP192 = 0x03, AXP2101 = 0x4A };
struct Setting {
uint8_t reg;
uint8_t mask;
uint8_t bits;
};
// These are the discrete hardware targets, not a voltage range. In particular,
// neither PMU supports the 3.65 V termination voltage used by LiFePO4 cells.
inline bool encode(uint8_t model, uint16_t millivolts, Setting& setting) {
if (model == AXP192) {
setting.reg = 0x33;
setting.mask = 0x60;
switch (millivolts) {
case 4100: setting.bits = 0x00; return true;
case 4150: setting.bits = 0x20; return true;
case 4200: setting.bits = 0x40; return true;
case 4360: setting.bits = 0x60; return true;
default: return false;
}
}
if (model == AXP2101) {
setting.reg = 0x64;
setting.mask = 0x07;
switch (millivolts) {
case 4000: setting.bits = 1; return true;
case 4100: setting.bits = 2; return true;
case 4200: setting.bits = 3; return true;
case 4350: setting.bits = 4; return true;
case 4400: setting.bits = 5; return true;
default: return false;
}
}
return false;
}
inline bool supports(uint8_t model, uint16_t millivolts) {
Setting setting;
return encode(model, millivolts, setting);
}
inline const char* options(uint8_t model) {
if (model == AXP192) return "4.10,4.15,4.20,4.36";
if (model == AXP2101) return "4.00,4.10,4.20,4.35,4.40";
return nullptr;
}
inline const char* storeNamespace(uint8_t model) {
if (model == AXP192) return "tbeam192_chg";
if (model == AXP2101) return "tbeam2101_chg";
return nullptr;
}
inline bool decode(uint8_t model, uint8_t value, uint16_t& millivolts) {
if (model == AXP192) {
static const uint16_t targets[] = {4100, 4150, 4200, 4360};
millivolts = targets[(value & 0x60) >> 5];
return true;
}
if (model == AXP2101) {
static const uint16_t targets[] = {4000, 4100, 4200, 4350, 4400};
const uint8_t code = value & 0x07;
if (code < 1 || code > 5) return false;
millivolts = targets[code - 1];
return true;
}
return false;
}
template<typename IO>
bool read(IO& io, uint8_t model, uint16_t& millivolts) {
Setting setting;
if (!encode(model, 4200, setting)) return false;
uint8_t detected, value;
if (!io.read(0x03, detected) || detected != model ||
!io.read(setting.reg, value)) return false;
return decode(model, value, millivolts);
}
template<typename IO>
bool write(IO& io, uint8_t model, uint16_t millivolts) {
Setting setting;
if (!encode(model, millivolts, setting)) return false;
uint8_t detected, previous;
if (!io.read(0x03, detected) || detected != model ||
!io.read(setting.reg, previous)) return false;
const uint8_t value = (previous & ~setting.mask) | setting.bits;
if (value != previous && !io.write(setting.reg, value)) return false;
uint8_t actual;
return io.read(setting.reg, actual) && actual == value;
}
// Store reads distinguish an absent key from an I/O failure; writes include
// commit and readback. The caller must keep the store open for the transaction.
template<typename IO, typename Store>
bool setPersistent(IO& io, Store& store, uint8_t model, uint16_t millivolts) {
if (!supports(model, millivolts)) return false;
uint16_t previousHardware, previousStored = 0;
bool existed = false;
if (!read(io, model, previousHardware) ||
!store.read(previousStored, existed)) return false;
if (!write(io, model, millivolts)) {
// A failed readback may follow a successful physical write.
write(io, model, previousHardware);
return false;
}
if (existed && previousStored == millivolts) return true;
if (store.write(millivolts)) return true;
// Restore persistence before restoring hardware, so a successful rollback
// cannot leave the requested target to be silently reapplied on next boot.
const bool restoredStore = existed ? store.write(previousStored) : store.erase();
if (restoredStore) {
write(io, model, previousHardware);
} else {
// A failed commit has an uncertain durable result. Match a readable saved
// setting where possible; otherwise keep the last verified requested
// hardware target and report failure, never pretend the state is unchanged.
uint16_t stored = 0;
bool present = false;
if (store.read(stored, present)) {
if (!present) write(io, model, previousHardware);
else if (supports(model, stored)) write(io, model, stored);
}
}
return false;
}
template<typename IO, typename Store>
bool configureBoot(IO& io, Store& store, uint8_t model, bool apply_fresh_default) {
uint16_t stored = 0;
bool present = false;
if (!store.read(stored, present)) return false;
// A missing preference is the only condition that permits the existing full
// driver's 4.2 V default. Unreadable/invalid saved settings must never raise
// the charger target; preserve the PMU state and surface a boot failure.
if (!present) return !apply_fresh_default || write(io, model, 4200);
return supports(model, stored) && write(io, model, stored);
}
} // namespace tbeam_charge
} // namespace mesh
+180
View File
@@ -0,0 +1,180 @@
#!/usr/bin/env python3
"""Run the production charger CLI with hardware/storage failure fixtures."""
from pathlib import Path
import subprocess
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[1]
class BatteryChargeCliTest(unittest.TestCase):
def test_commands_failures_precision_and_bounded_replies(self):
source = r'''
#include <MeshCore.h>
#include <helpers/BatteryChargeCLI.h>
#include <cassert>
#include <cstring>
#include <string>
struct Board : mesh::MainBoard {
bool capable = true, read_ok = true, save_ok = true, restore_failed = false;
unsigned reads = 0, writes = 0;
uint16_t target = 4200;
uint16_t getBattMilliVolts() override { return 3700; }
const char* getManufacturerName() const override { return "fixture"; }
void reboot() override {}
uint8_t getStartupReason() const override { return 0; }
const char* getBatteryChargeTargetOptions() const override {
return capable ? "4.10,4.15,4.20,4.36" : nullptr;
}
bool getBatteryChargeTarget(uint16_t& result) override {
++reads;
if (!read_ok) return false;
result = target;
return true;
}
bool batteryChargeTargetRestoreFailed() const override { return restore_failed; }
bool supportsBatteryChargeTarget(uint16_t mv) override {
return mv == 4100 || mv == 4150 || mv == 4200 || mv == 4360;
}
bool setBatteryChargeTarget(uint16_t mv) override {
++writes;
if (!save_ok) return false;
target = mv;
return true;
}
};
int main() {
using mesh::power::handleBatteryChargeCommand;
using mesh::power::parseChargeVolts;
Board board;
char reply[160];
assert(handleBatteryChargeCommand(board, "get charge.voltage", reply, sizeof(reply)));
assert(std::string(reply) == "> 4.200 V" && board.reads == 1);
board.restore_failed = true;
assert(handleBatteryChargeCommand(board, "get charge.voltage", reply, sizeof(reply)));
assert(std::string(reply) == "> 4.200 V (boot restore failed)");
board.restore_failed = false;
assert(handleBatteryChargeCommand(board, "get charge.voltage.options", reply, sizeof(reply)));
assert(std::string(reply) == "> 4.10,4.15,4.20,4.36 V");
for (const char* value : {"4.1", "4.10", "4.100", "04.100", "4.10 \t"}) {
std::string command = "set charge.voltage "; command += value;
assert(handleBatteryChargeCommand(board, command.c_str(), reply, sizeof(reply)));
assert(std::string(reply) == "OK - charge.voltage 4.100 V (saved)");
assert(board.target == 4100);
}
for (const char* value : {"", "4.", ".1", "4.1000", "4.1garbage", "+4.1", "-4.1",
"nan", "inf", "4e0", "65536", "65.536", "999999999999999999999", "4 1"}) {
const unsigned writes = board.writes;
std::string command = "set charge.voltage "; command += value;
assert(handleBatteryChargeCommand(board, command.c_str(), reply, sizeof(reply)));
assert(std::string(reply).find("Error: usage") == 0 && board.writes == writes);
}
for (const char* value : {"3.65", "3.650", "4", "4.099", "4.101", "0", "65.535"}) {
const unsigned writes = board.writes;
std::string command = "set charge.voltage "; command += value;
assert(handleBatteryChargeCommand(board, command.c_str(), reply, sizeof(reply)));
assert(std::string(reply).find("Error: unsupported target") == 0);
assert(board.writes == writes && board.target == 4100);
}
uint16_t parsed = 123;
assert(parseChargeVolts("3.65", parsed) && parsed == 3650);
assert(parseChargeVolts("65.535", parsed) && parsed == 65535);
assert(!parseChargeVolts("65.536", parsed) && parsed == 65535);
board.read_ok = false;
assert(handleBatteryChargeCommand(board, "get charge.voltage", reply, sizeof(reply)));
assert(std::string(reply) == "Error: charger read failed");
board.save_ok = false;
assert(handleBatteryChargeCommand(board, "set charge.voltage 4.2", reply, sizeof(reply)));
assert(std::string(reply).find("Error: charge target not confirmed saved") == 0);
assert(board.target == 4100);
board.capable = false;
unsigned writes = board.writes, reads = board.reads;
assert(handleBatteryChargeCommand(board, "set charge.voltage 3.65", reply, sizeof(reply)));
assert(std::string(reply).find("not supported on this board") != std::string::npos);
assert(board.writes == writes && board.reads == reads);
board.capable = true;
for (const char* command : {"get charge.voltage.bad", "set charge.voltage.options", "charge.voltage",
"get battery.full", "set radio 910,62.5,7,5"}) {
assert(!handleBatteryChargeCommand(board, command, reply, sizeof(reply)));
}
assert(handleBatteryChargeCommand(board, "get charge.voltage extra", reply, sizeof(reply)));
assert(std::string(reply).find("Error: usage") == 0);
// Companion prefixes reserve three bytes; callers also use smaller buffers.
for (size_t capacity = 1; capacity <= 157; ++capacity) {
char bounded[164]; memset(bounded, '#', sizeof(bounded));
memcpy(bounded, "ab|", 3);
assert(handleBatteryChargeCommand(board, "set charge.voltage 3.65", bounded + 3, capacity));
assert(memcmp(bounded, "ab|", 3) == 0);
assert(bounded[3 + capacity] == '#' && memchr(bounded + 3, 0, capacity));
}
assert(!handleBatteryChargeCommand(board, nullptr, reply, sizeof(reply)));
assert(!handleBatteryChargeCommand(board, "get charge.voltage", nullptr, sizeof(reply)));
assert(!handleBatteryChargeCommand(board, "get charge.voltage", reply, 0));
}
'''
with tempfile.TemporaryDirectory() as temp:
path = Path(temp)
(path / "test.cpp").write_text(source, encoding="ascii")
binary = path / "test"
subprocess.run(["c++", "-std=c++11", "-Wall", "-Wextra",
"-fsanitize=address,undefined", "-fno-omit-frame-pointer",
"-I", str(ROOT / "src"), str(path / "test.cpp"),
"-o", str(binary)], check=True, capture_output=True)
subprocess.run([str(binary)], check=True)
def test_shared_role_dispatch_and_meshsolar_hardware_limit(self):
# Pin independent role entry points so a parser tested here cannot be
# omitted from the Companion/BLE path while working on serial repeaters.
for name in ("src/helpers/CommonCLI.cpp", "examples/companion_radio/MyMesh.cpp",
"examples/simple_secure_chat/main.cpp"):
source = (ROOT / name).read_text()
self.assertIn("#include <helpers/BatteryChargeCLI.h>", source)
self.assertIn("mesh::power::handleBatteryChargeCommand(", source)
source = r'''
#include <MeshCore.h>
#include <helpers/BatteryChargeCLI.h>
#include "MeshSolarBoard.h"
#include <cassert>
#include <string>
int main() {
MeshSolarBoard board;
char reply[160];
assert(!board.supportsBatteryChargeTarget(3650));
assert(!board.setBatteryChargeTarget(3650));
for (const char* cmd : {"get charge.voltage", "get charge.voltage.options", "set charge.voltage 3.65"}) {
assert(mesh::power::handleBatteryChargeCommand(board, cmd, reply, sizeof(reply)));
assert(std::string(reply).find("CN3795") != std::string::npos);
assert(std::string(reply).find("hardware") != std::string::npos);
}
}
'''
with tempfile.TemporaryDirectory() as temp:
path = Path(temp)
(path / "helpers").mkdir()
(path / "Arduino.h").write_text(
"#pragma once\n#include <stdint.h>\ninline uint16_t meshSolarGetBattVoltage() {return 7400;}\n",
encoding="ascii")
(path / "helpers/NRF52Board.h").write_text(r'''
#pragma once
#include <MeshCore.h>
class NRF52Board : public mesh::MainBoard {
public:
explicit NRF52Board(const char*) {}
void reboot() override {}
uint8_t getStartupReason() const override { return 0; }
};
using NRF52BoardDCDC = NRF52Board;
''', encoding="ascii")
(path / "test.cpp").write_text(source, encoding="ascii")
binary = path / "test"
subprocess.run(["c++", "-std=c++11", "-I", temp, "-I", str(ROOT / "src"),
"-I", str(ROOT / "variants/heltec_mesh_solar"),
str(path / "test.cpp"), "-o", str(binary)],
check=True, capture_output=True)
subprocess.run([str(binary)], check=True)
if __name__ == "__main__":
unittest.main()
+20
View File
@@ -18,6 +18,7 @@ PREAMBLE = r'''
#include <initializer_list>
#include <helpers/CLICommandUtils.h>
#include <helpers/RadioProfileCommandUtils.h>
#include <helpers/BatteryChargeCLI.h>
#define ESP32 1
#define WIFI_SSID "test"
#define WITH_MQTT_BRIDGE 1
@@ -84,6 +85,14 @@ struct Prefs {
};
struct Board {
int power_offs=0;
uint16_t charge_target=4200;
unsigned charge_writes=0;
const char* getBatteryChargeTargetOptions() const {return "4.10,4.20";}
bool batteryChargeTargetRestoreFailed() const {return false;}
const char* getBatteryChargeTargetUnsupportedReason() const {return "unsupported";}
bool getBatteryChargeTarget(uint16_t& mv){mv=charge_target;return true;}
bool supportsBatteryChargeTarget(uint16_t mv){return mv==4100 || mv==4200;}
bool setBatteryChargeTarget(uint16_t mv){++charge_writes;charge_target=mv;return true;}
bool rebootToUf2Bootloader(){return false;}
bool handleCommand(const char*,uint32_t,char*){return false;}
void powerOff(){++power_offs;}
@@ -153,6 +162,17 @@ SCENARIOS = r'''
int main() {
MyMesh node;
char reply[160] = {};
assert(node.handleCommand("set charge.voltage 4.1",0,reply));
assert(board.charge_target==4100 && strstr(reply,"saved"));
for(uint32_t stamp:{0u,100u}) {
node.onCLICommandRecv(ContactInfo{},nullptr,stamp,"A7|set charge.voltage 4.2",reply);
assert(board.charge_target==4200 && !strcmp(reply,"A7|OK - charge.voltage 4.200 V (saved)"));
const unsigned writes=board.charge_writes;
node.onCLICommandRecv(ContactInfo{},nullptr,stamp,"A7|set charge.voltage 3.65",reply);
assert(board.charge_writes==writes && !strncmp(reply,"A7|Error: unsupported target",27));
node.onCLICommandRecv(ContactInfo{false},nullptr,stamp,"set charge.voltage 4.1",reply);
assert(board.charge_writes==writes && board.charge_target==4200);
}
const char* local[] = {"get password", "get prv.key", "get wifi.pwd", "get mqtt1.password",
"get mqtt1.token", "stats-core", "stats-radio", "stats-radio-diag", "stats-packets"};
for(const char* command:local) {
+567
View File
@@ -0,0 +1,567 @@
#!/usr/bin/env python3
"""Run the real T-Beam charge driver against fault-injected I2C and NVS."""
from pathlib import Path
import shutil
import subprocess
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[1]
HELPER_TEST = r'''
#include <cassert>
#include <cstdint>
#include <cstring>
#include "helpers/esp32/TBeamChargeTarget.h"
namespace charge = mesh::tbeam_charge;
struct IO {
uint8_t reg[256] = {};
unsigned reads = 0, writes = 0, failRead = 0, failWrite = 0;
unsigned corruptRead = 0;
bool read(uint8_t address, uint8_t& value) {
if (++reads == failRead) return false;
value = reg[address];
if (reads == corruptRead) value ^= 1;
return true;
}
bool write(uint8_t address, uint8_t value) {
if (++writes == failWrite) return false;
reg[address] = value;
return true;
}
};
struct Store {
uint16_t value = 0;
bool present = false;
unsigned reads = 0, writes = 0, erases = 0;
unsigned failRead = 0, failWrite = 0, failErase = 0;
bool failedWriteCommits = false;
bool read(uint16_t& out, bool& exists) {
if (++reads == failRead) return false;
out = value;
exists = present;
return true;
}
bool write(uint16_t requested) {
const bool success = ++writes != failWrite;
if (success || failedWriteCommits) { value = requested; present = true; }
return success;
}
bool erase() {
if (++erases == failErase) return false;
present = false;
return true;
}
};
static IO initialized(uint8_t model) {
IO io;
io.reg[0x03] = model;
io.reg[0x33] = 0xD9; // Charging enabled, 4.20 V, unrelated current/end bits.
io.reg[0x64] = 0xBB; // 4.20 V, unrelated reserved/configuration bits.
return io;
}
int main() {
const uint16_t axp192[] = {4100,4150,4200,4360};
const uint16_t axp2101[] = {4000,4100,4200,4350,4400};
for (uint8_t model : {charge::AXP192, charge::AXP2101}) {
const uint16_t* targets = model == charge::AXP192 ? axp192 : axp2101;
const unsigned count = model == charge::AXP192 ? 4 : 5;
for (unsigned index = 0; index < count; ++index) {
IO io = initialized(model);
const uint8_t address = model == charge::AXP192 ? 0x33 : 0x64;
const uint8_t mask = model == charge::AXP192 ? 0x60 : 0x07;
const uint8_t unrelated = io.reg[address] & ~mask;
assert(charge::write(io, model, targets[index]));
assert((io.reg[address] & ~mask) == unrelated);
assert((io.reg[address] & mask) ==
(model == charge::AXP192 ? index << 5 : index + 1));
uint16_t actual = 7;
assert(charge::read(io, model, actual) && actual == targets[index]);
}
for (uint16_t invalid : {uint16_t(0),uint16_t(3650),uint16_t(4099),
uint16_t(4199),uint16_t(4201),uint16_t(65535)}) {
IO io = initialized(model);
Store store;
assert(!charge::setPersistent(io, store, model, invalid));
assert(io.reads == 0 && io.writes == 0 && store.reads == 0);
}
const uint16_t target = targets[0];
for (unsigned fail = 1; fail <= 5; ++fail) {
IO io = initialized(model);
io.failRead = fail;
Store store;
assert(!charge::setPersistent(io, store, model, target));
assert(!store.present && store.writes == 0);
uint16_t actual = 0;
assert(charge::read(io, model, actual) && actual == 4200);
}
{
IO io = initialized(model);
io.failWrite = 1;
Store store;
assert(!charge::setPersistent(io, store, model, target));
assert(!store.present && store.writes == 0);
}
{
IO io = initialized(model);
io.corruptRead = 5; // Setter's register readback, after physical write.
Store store;
assert(!charge::setPersistent(io, store, model, target));
uint16_t actual = 0;
assert(charge::read(io, model, actual) && actual == 4200);
assert(!store.present && store.writes == 0);
}
{
IO io = initialized(model);
Store store;
store.failRead = 1;
assert(!charge::setPersistent(io, store, model, target));
assert(io.writes == 0 && store.writes == 0);
}
for (bool existed : {false, true}) {
for (bool failedCommit : {false, true}) {
IO io = initialized(model);
Store store;
store.present = existed;
store.value = 4200;
store.failWrite = 1;
store.failedWriteCommits = failedCommit;
assert(!charge::setPersistent(io, store, model, target));
assert(store.present == existed && (!existed || store.value == 4200));
uint16_t actual = 0;
assert(charge::read(io, model, actual) && actual == 4200);
}
}
{
IO io = initialized(model);
Store store;
store.failWrite = 1;
store.failedWriteCommits = true;
store.failErase = 1; // Persistent rollback fails after a partial commit.
assert(!charge::setPersistent(io, store, model, target));
assert(store.present && store.value == target);
uint16_t actual = 0;
assert(charge::read(io, model, actual) && actual == target);
}
{
IO io = initialized(model);
Store store;
assert(charge::setPersistent(io, store, model, target));
assert(store.present && store.value == target && store.writes == 1);
const unsigned writes = io.writes;
assert(charge::setPersistent(io, store, model, target));
assert(store.writes == 1 && io.writes == writes);
IO rebooted = initialized(model);
assert(charge::configureBoot(rebooted, store, model, false));
uint16_t actual = 0;
assert(charge::read(rebooted, model, actual) && actual == target);
}
{
IO io = initialized(model);
Store store;
assert(charge::configureBoot(io, store, model, false));
store.present = true;
store.value = 3650;
assert(!charge::configureBoot(io, store, model, true));
assert(io.reads == 0 && io.writes == 0);
}
{
IO io = initialized(model);
Store store;
assert(charge::write(io, model, targets[0]));
store.failRead = 1;
const unsigned previousWrites = io.writes;
assert(!charge::configureBoot(io, store, model, true));
assert(io.writes == previousWrites);
uint16_t actual = 0;
assert(charge::read(io, model, actual) && actual == targets[0]);
}
{
IO io = initialized(model);
Store store;
assert(charge::write(io, model, targets[0]));
const unsigned previousWrites = io.writes;
assert(charge::configureBoot(io, store, model, false));
assert(io.writes == previousWrites);
assert(charge::configureBoot(io, store, model, true));
uint16_t actual = 0;
assert(charge::read(io, model, actual) && actual == 4200);
}
}
assert(std::strcmp(charge::storeNamespace(charge::AXP192),
charge::storeNamespace(charge::AXP2101)) != 0);
for (uint8_t code : {uint8_t(0),uint8_t(6),uint8_t(7)}) {
IO io = initialized(charge::AXP2101);
io.reg[0x64] = code;
uint16_t target = 17;
assert(!charge::read(io, charge::AXP2101, target) && target == 17);
}
{
IO io = initialized(0xFF);
Store store;
assert(!charge::setPersistent(io, store, 0xFF, 4200));
assert(io.reads == 0 && io.writes == 0 && store.reads == 0);
assert(charge::options(0xFF) == nullptr);
}
{
IO io = initialized(charge::AXP2101);
uint16_t target = 17;
assert(!charge::read(io, charge::AXP192, target) && target == 17);
assert(!charge::write(io, charge::AXP192, 4100) && io.writes == 0);
}
}
'''
MOCK_SDK = r'''
#pragma once
#include <cassert>
#include <cstdint>
#include <map>
#include <string>
#include <vector>
constexpr int LOW = 0, HIGH = 1, INPUT = 2, INPUT_PULLUP = 3, FALLING = 4;
constexpr int ESP_RST_DEEPSLEEP = 5, ESP_SLEEP_WAKEUP_UNDEFINED = 0;
constexpr int BD_STARTUP_RX_PACKET = 1;
using esp_reset_reason_t = int;
using gpio_num_t = int;
extern int reset_reason;
extern uint64_t wakeup_mask;
inline int esp_reset_reason() { return reset_reason; }
inline int esp_sleep_get_wakeup_cause() { return 0; }
inline uint64_t esp_sleep_get_ext1_wakeup_status() { return wakeup_mask; }
inline void rtc_gpio_hold_dis(int) {}
inline void rtc_gpio_deinit(int) {}
inline void pinMode(int, int) {}
inline void digitalWrite(int, int) {}
inline void attachInterrupt(int, void(*)(), int) {}
inline void delay(int) {}
#define _BV(x) (1U << (x))
#define MESH_DEBUG_PRINTLN(...) do {} while (0)
struct TwoWire {
uint8_t reg[256] = {};
std::vector<uint8_t> tx;
uint8_t address = 0;
unsigned writes = 0;
bool negative_read_once = false;
bool target_write_fails_once = false;
void begin(int, int) {}
void beginTransmission(int) { tx.clear(); }
void write(uint8_t value) { tx.push_back(value); }
int endTransmission(bool = true) {
if (tx.size() == 1) address = tx[0];
if (tx.size() == 2) {
if (target_write_fails_once && (tx[0] == 0x33 || tx[0] == 0x64)) {
target_write_fails_once = false;
return 1;
}
reg[tx[0]] = tx[1];
++writes;
}
return 0;
}
int requestFrom(int, int count) { return count; }
int read() {
if (negative_read_once) { negative_read_once = false; return -1; }
return reg[address++];
}
};
extern TwoWire Wire, Wire1;
class ESP32Board {
public:
int startup_reason = 0;
virtual ~ESP32Board() {}
void begin() {}
virtual void onBeforeTransmit() {}
virtual void onAfterTransmit() {}
virtual bool getBatteryChargeTarget(uint16_t&) { return false; }
virtual bool supportsBatteryChargeTarget(uint16_t) { return false; }
virtual bool setBatteryChargeTarget(uint16_t) { return false; }
virtual const char* getBatteryChargeTargetOptions() const { return nullptr; }
virtual bool batteryChargeTargetRestoreFailed() const { return false; }
};
constexpr int XPOWERS_AXP192 = 192, XPOWERS_AXP2101 = 2101;
constexpr int XPOWERS_LDO2 = 0, XPOWERS_LDO3 = 0, XPOWERS_DCDC1 = 0;
constexpr int XPOWERS_DCDC2 = 0, XPOWERS_DCDC3 = 0, XPOWERS_DCDC4 = 0;
constexpr int XPOWERS_DCDC5 = 0, XPOWERS_ALDO1 = 0, XPOWERS_ALDO2 = 0;
constexpr int XPOWERS_ALDO3 = 0, XPOWERS_ALDO4 = 0, XPOWERS_BLDO1 = 0;
constexpr int XPOWERS_BLDO2 = 0, XPOWERS_VBACKUP = 0, XPOWERS_CHG_LED_CTRL_CHG = 0;
constexpr int XPOWERS_DLDO1 = 0, XPOWERS_DLDO2 = 0;
constexpr int XPOWERS_AXP2101_DCDC4_VOL2_MAX = 0, XPOWERS_AXP192_ALL_IRQ = 0;
constexpr int XPOWERS_AXP2101_ALL_IRQ = 0, XPOWERS_AXP192_CHG_CUR_450MA = 0;
constexpr int XPOWERS_AXP2101_CHG_CUR_500MA = 0, XPOWERS_POWEROFF_4S = 0;
constexpr int XPOWERS_AXP192_CHG_VOL_4V2 = 2, XPOWERS_AXP2101_CHG_VOL_4V2 = 3;
class XPowersLibInterface {
TwoWire& bus;
int model;
public:
XPowersLibInterface(TwoWire& wire, int type) : bus(wire), model(type) {}
virtual ~XPowersLibInterface() {}
bool init() { return bus.reg[0x03] == (model == XPOWERS_AXP192 ? 0x03 : 0x4A); }
int getChipModel() const { return model; }
uint16_t getBattVoltage() { return 4200; }
void setChargingLedMode(int) {}
void setPowerChannelVoltage(int, int) {}
void enablePowerOutput(int) {}
void disablePowerOutput(int) {}
void setProtectedChannel(int) {}
void disableIRQ(int) {}
void setChargerConstantCurr(int) {}
void setChargeTargetVoltage(uint8_t code) {
if (model == XPOWERS_AXP192) bus.reg[0x33] = (bus.reg[0x33] & 0x9F) | (code << 5);
else bus.reg[0x64] = (bus.reg[0x64] & 0xF8) | code;
}
void clearIrqStatus() {}
void disableTSPinMeasure() {}
void enableSystemVoltageMeasure() {}
void enableVbusVoltageMeasure() {}
void enableBattVoltageMeasure() {}
void setPowerKeyPressOffTime(int) {}
};
struct XPowersAXP192 : XPowersLibInterface {
XPowersAXP192(TwoWire& bus, int, int, int) : XPowersLibInterface(bus, XPOWERS_AXP192) {}
};
struct XPowersAXP2101 : XPowersLibInterface {
XPowersAXP2101(TwoWire& bus, int, int, int) : XPowersLibInterface(bus, XPOWERS_AXP2101) {}
};
using esp_err_t = int;
using nvs_handle_t = uint32_t;
constexpr int ESP_OK = 0, ESP_FAIL = 1, ESP_ERR_NVS_NOT_FOUND = 2;
constexpr int NVS_READONLY = 0, NVS_READWRITE = 1;
extern std::map<std::string,uint16_t> saved;
extern std::string opened_namespace;
extern bool nvs_open_fails, nvs_commit_fails_once;
extern bool nvs_set_fails_once, nvs_erase_fails_once;
extern unsigned nvs_reads, nvs_fail_read_at;
inline int nvs_open(const char* name, int mode, nvs_handle_t* handle) {
if (nvs_open_fails) return ESP_FAIL;
if (mode == NVS_READONLY && saved.find(name) == saved.end()) return ESP_ERR_NVS_NOT_FOUND;
opened_namespace = name;
*handle = 1;
return ESP_OK;
}
inline void nvs_close(nvs_handle_t) {}
inline int nvs_get_u16(nvs_handle_t, const char*, uint16_t* value) {
if (++nvs_reads == nvs_fail_read_at) return ESP_FAIL;
auto found = saved.find(opened_namespace);
if (found == saved.end()) return ESP_ERR_NVS_NOT_FOUND;
*value = found->second;
return ESP_OK;
}
inline int nvs_set_u16(nvs_handle_t, const char*, uint16_t value) {
if (nvs_set_fails_once) { nvs_set_fails_once = false; return ESP_FAIL; }
saved[opened_namespace] = value;
return ESP_OK;
}
inline int nvs_commit(nvs_handle_t) {
if (nvs_commit_fails_once) { nvs_commit_fails_once = false; return ESP_FAIL; }
return ESP_OK;
}
inline int nvs_erase_key(nvs_handle_t, const char*) {
if (nvs_erase_fails_once) { nvs_erase_fails_once = false; return ESP_FAIL; }
return saved.erase(opened_namespace) ? ESP_OK : ESP_ERR_NVS_NOT_FOUND;
}
'''
BOARD_TEST = r'''
#include "helpers/esp32/TBeamBoard.h"
#include <cstring>
TwoWire Wire, Wire1;
int reset_reason = 0;
uint64_t wakeup_mask = 0;
std::map<std::string,uint16_t> saved;
std::string opened_namespace;
bool nvs_open_fails = false, nvs_commit_fails_once = false;
bool nvs_set_fails_once = false, nvs_erase_fails_once = false;
unsigned nvs_reads = 0, nvs_fail_read_at = 0;
#ifdef TBEAM_SUPREME_SX1262
#define BUS Wire1
#else
#define BUS Wire
#endif
int main() {
for (uint8_t model : {uint8_t(0x03),uint8_t(0x4A)}) {
saved.clear();
BUS.reg[0x03] = model;
BUS.reg[0x33] = 0x99;
BUS.reg[0x64] = 0xBA;
const char* own_namespace = model == 0x03 ? "tbeam192_chg" : "tbeam2101_chg";
const char* other_namespace = model == 0x03 ? "tbeam2101_chg" : "tbeam192_chg";
TBeamBoard board;
assert(board.getBattMilliVolts() == 0);
board.begin();
uint16_t actual = 0;
assert(board.getBatteryChargeTarget(actual));
#if defined(PORTABLE_MQTT_OBSERVER) && !defined(TBEAM_SUPREME_SX1262)
assert(actual == 4100); // Fresh compact boot leaves charger target alone.
#else
assert(actual == 4200); // Existing full-library default is preserved.
#endif
assert(saved.empty());
assert(!board.batteryChargeTargetRestoreFailed());
const unsigned previous_writes = BUS.writes;
assert(!board.setBatteryChargeTarget(3650));
assert(BUS.writes == previous_writes && saved.empty());
assert(board.supportsBatteryChargeTarget(4100));
assert(!board.supportsBatteryChargeTarget(3650));
assert(std::strcmp(board.getBatteryChargeTargetOptions(), model == 0x03 ?
"4.10,4.15,4.20,4.36" : "4.00,4.10,4.20,4.35,4.40") == 0);
assert(board.setBatteryChargeTarget(4100));
assert(saved.at(own_namespace) == 4100);
assert(saved.find(other_namespace) == saved.end());
assert(board.getBatteryChargeTarget(actual) && actual == 4100);
reset_reason = ESP_RST_DEEPSLEEP;
wakeup_mask = 1ULL << P_LORA_DIO_1;
TBeamBoard radio_wake;
radio_wake.begin();
assert(radio_wake.startup_reason == BD_STARTUP_RX_PACKET);
reset_reason = 0;
wakeup_mask = 0;
nvs_commit_fails_once = true;
assert(!board.setBatteryChargeTarget(4200));
assert(saved.at(own_namespace) == 4100);
assert(board.getBatteryChargeTarget(actual) && actual == 4100);
const unsigned saved_writes = BUS.writes;
nvs_fail_read_at = nvs_reads + 1; // Initial saved-state snapshot fails.
assert(!board.setBatteryChargeTarget(4200));
assert(BUS.writes == saved_writes && saved.at(own_namespace) == 4100);
nvs_fail_read_at = nvs_reads + 2; // Save commits, but verification read fails.
assert(!board.setBatteryChargeTarget(4200));
assert(saved.at(own_namespace) == 4100);
assert(board.getBatteryChargeTarget(actual) && actual == 4100);
nvs_fail_read_at = 0;
nvs_set_fails_once = true;
assert(!board.setBatteryChargeTarget(4200));
assert(saved.at(own_namespace) == 4100);
assert(board.getBatteryChargeTarget(actual) && actual == 4100);
BUS.negative_read_once = true;
actual = 17;
assert(!board.getBatteryChargeTarget(actual) && actual == 17);
saved.erase(own_namespace);
nvs_commit_fails_once = true;
nvs_erase_fails_once = true; // Save and rollback both have uncertain results.
assert(!board.setBatteryChargeTarget(4200));
assert(saved.at(own_namespace) == 4200);
assert(board.getBatteryChargeTarget(actual) && actual == 4200);
assert(board.setBatteryChargeTarget(4100));
nvs_open_fails = true;
assert(!board.setBatteryChargeTarget(4200));
nvs_open_fails = false;
assert(board.getBatteryChargeTarget(actual) && actual == 4100);
TBeamBoard rebooted;
rebooted.begin();
assert(rebooted.getBatteryChargeTarget(actual) && actual == 4100);
assert(!rebooted.batteryChargeTargetRestoreFailed());
saved[own_namespace] = 3650;
BUS.reg[0x33] = 0x99;
BUS.reg[0x64] = 0xBA;
TBeamBoard invalid_saved;
invalid_saved.begin();
assert(invalid_saved.getBatteryChargeTarget(actual) && actual == 4100);
assert(invalid_saved.batteryChargeTargetRestoreFailed());
assert(!invalid_saved.setBatteryChargeTarget(3650));
assert(invalid_saved.batteryChargeTargetRestoreFailed());
assert(invalid_saved.setBatteryChargeTarget(4100));
assert(!invalid_saved.batteryChargeTargetRestoreFailed());
saved[own_namespace] = 4100;
nvs_open_fails = true;
TBeamBoard unreadable_namespace;
unreadable_namespace.begin();
nvs_open_fails = false;
assert(unreadable_namespace.getBatteryChargeTarget(actual) && actual == 4100);
assert(unreadable_namespace.batteryChargeTargetRestoreFailed());
nvs_fail_read_at = nvs_reads + 1;
TBeamBoard unreadable_entry;
unreadable_entry.begin();
nvs_fail_read_at = 0;
assert(unreadable_entry.getBatteryChargeTarget(actual) && actual == 4100);
assert(unreadable_entry.batteryChargeTargetRestoreFailed());
// A validated lower target fails to write; preserve the existing lower
// PMU target, never invoke the historical full-library 4.20 V setter.
saved[own_namespace] = model == 0x03 ? 4100 : 4000;
BUS.reg[0x33] = 0xB9; // 4.15 V.
BUS.reg[0x64] = 0xBA; // 4.10 V.
BUS.target_write_fails_once = true;
TBeamBoard failed_restore;
failed_restore.begin();
assert(!BUS.target_write_fails_once);
assert(failed_restore.getBatteryChargeTarget(actual));
assert(actual == (model == 0x03 ? 4150 : 4100));
assert(failed_restore.batteryChargeTargetRestoreFailed());
assert(failed_restore.setBatteryChargeTarget(4100));
assert(!failed_restore.batteryChargeTargetRestoreFailed());
saved.erase(own_namespace);
saved[other_namespace] = 4150;
TBeamBoard wrong_model_saved;
wrong_model_saved.begin();
assert(wrong_model_saved.getBatteryChargeTarget(actual));
#if defined(PORTABLE_MQTT_OBSERVER) && !defined(TBEAM_SUPREME_SX1262)
assert(actual == 4100);
#else
assert(actual == 4200);
#endif
assert(!wrong_model_saved.batteryChargeTargetRestoreFailed());
}
saved.clear();
BUS.reg[0x03] = 0xFF;
const unsigned writes = BUS.writes;
TBeamBoard unknown;
unknown.begin();
assert(unknown.getBattMilliVolts() == 0);
uint16_t actual = 17;
assert(!unknown.getBatteryChargeTarget(actual) && actual == 17);
assert(!unknown.supportsBatteryChargeTarget(4200));
assert(!unknown.setBatteryChargeTarget(4200));
assert(unknown.getBatteryChargeTargetOptions() == nullptr);
assert(BUS.writes == writes && saved.empty());
}
'''
class TBeamChargeTargetTest(unittest.TestCase):
def setUp(self):
self.compiler = shutil.which("g++")
if not self.compiler:
self.skipTest("g++ is required")
def build_and_run(self, directory, source, extra=()):
harness = directory / "harness.cpp"
harness.write_text(source, encoding="ascii")
binary = directory / "harness"
command = [self.compiler, "-std=c++11", "-Wall", "-Wextra", "-Werror",
"-I", str(directory), "-I", str(ROOT / "src"), str(harness),
*extra, "-o", str(binary)]
built = subprocess.run(command, capture_output=True, text=True)
self.assertEqual(built.returncode, 0, built.stderr)
ran = subprocess.run([str(binary)], capture_output=True, text=True)
self.assertEqual(ran.returncode, 0, ran.stderr)
def test_discrete_targets_and_persistent_transaction_faults(self):
with tempfile.TemporaryDirectory(prefix="meshcore-tbeam-charge-") as temporary:
self.build_and_run(Path(temporary), "#include <initializer_list>\n" + HELPER_TEST)
def test_real_driver_full_compact_and_supreme_paths(self):
for board, compact in (("TBEAM_SX1262", False), ("TBEAM_SX1276", False),
("TBEAM_SUPREME_SX1262", False),
("TBEAM_SX1262", True), ("TBEAM_SX1276", True)):
with self.subTest(board=board, compact=compact):
with tempfile.TemporaryDirectory(prefix="meshcore-tbeam-driver-") as temporary:
directory = Path(temporary)
(directory / "MockSdk.h").write_text(MOCK_SDK, encoding="ascii")
for name in ("Arduino.h", "Wire.h", "XPowersLib.h", "nvs.h",
"helpers/ESP32Board.h"):
stub = directory / name
stub.parent.mkdir(parents=True, exist_ok=True)
stub.write_text('#include "MockSdk.h"\n', encoding="ascii")
definitions = ["-D" + board, "-DPIN_BOARD_SDA=21", "-DPIN_BOARD_SCL=22",
"-DPIN_USER_BTN=38", "-DP_LORA_TX_LED=4",
"-DP_LORA_DIO_1=33", "-DP_LORA_NSS=18"]
if board == "TBEAM_SUPREME_SX1262":
definitions = definitions[:-2] # The real header supplies these pins.
if compact:
definitions.append("-DPORTABLE_MQTT_OBSERVER")
self.build_and_run(directory, BOARD_TEST, [*definitions,
str(ROOT / "src/helpers/esp32/TBeamBoard.cpp")])
if __name__ == "__main__":
unittest.main()
@@ -29,6 +29,12 @@ public:
return meshSolarGetBattVoltage();
}
const char* getBatteryChargeTargetUnsupportedReason() const override {
// The I/F hardware versions use different CN3795 charger voltages. BQ4050
// charge-algorithm requests and protection thresholds do not set that CV.
return "Mesh Solar CN3795 charge voltage is hardware-set; BQ4050 settings do not change charger voltage";
}
const char* getManufacturerName() const override {
return "Heltec Mesh Solar";
}