diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index d955d337f..acbb8f39b 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -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 diff --git a/docs/charge_voltage.md b/docs/charge_voltage.md new file mode 100644 index 000000000..65a9e7d26 --- /dev/null +++ b/docs/charge_voltage.md @@ -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). diff --git a/docs/cli_commands.md b/docs/cli_commands.md index 3b1ba343c..8746c7d9a 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -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>` diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 6b7af1028..a1fe20531 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -1,4 +1,5 @@ #include +#include #include #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)) { diff --git a/examples/simple_secure_chat/main.cpp b/examples/simple_secure_chat/main.cpp index 29e41fc95..09c4605d3 100644 --- a/examples/simple_secure_chat/main.cpp +++ b/examples/simple_secure_chat/main.cpp @@ -1,4 +1,5 @@ #include +#include #include // needed for PlatformIO #include #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, diff --git a/src/MeshCore.h b/src/MeshCore.h index 26afcb331..ad3b5ebde 100644 --- a/src/MeshCore.h +++ b/src/MeshCore.h @@ -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). diff --git a/src/helpers/BatteryChargeCLI.h b/src/helpers/BatteryChargeCLI.h new file mode 100644 index 000000000..306a4f64d --- /dev/null +++ b/src/helpers/BatteryChargeCLI.h @@ -0,0 +1,101 @@ +#pragma once + +#include +#include +#include +#include + +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(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 +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(millivolts / 1000), + static_cast(millivolts % 1000), + board.batteryChargeTargetRestoreFailed() ? " (boot restore failed)" : ""); + } + return true; + } + if (!parseChargeVolts(value, millivolts)) { + snprintf(reply, reply_capacity, "Error: usage set charge.voltage "); + } 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(millivolts / 1000), + static_cast(millivolts % 1000)); + } + return true; +} + +} // namespace power +} // namespace mesh diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index c0a0fcb06..cfa7a6ac0 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -4,6 +4,7 @@ #include "UsbLoggingWatchdog.h" #include "HilStartupTrace.h" #include +#include #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(), diff --git a/src/helpers/esp32/TBeamBoard.cpp b/src/helpers/esp32/TBeamBoard.cpp index 3b26b5ebf..f8ad82a70 100644 --- a/src/helpers/esp32/TBeamBoard.cpp +++ b/src/helpers/esp32/TBeamBoard.cpp @@ -2,15 +2,19 @@ #include #include "TBeamBoard.h" +#include "TBeamChargeTarget.h" +#include //#include 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(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 } diff --git a/src/helpers/esp32/TBeamBoard.h b/src/helpers/esp32/TBeamBoard.h index d338eee2a..234be4657 100644 --- a/src/helpers/esp32/TBeamBoard.h +++ b/src/helpers/esp32/TBeamBoard.h @@ -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 } diff --git a/src/helpers/esp32/TBeamChargeTarget.h b/src/helpers/esp32/TBeamChargeTarget.h new file mode 100644 index 000000000..2f02c70be --- /dev/null +++ b/src/helpers/esp32/TBeamChargeTarget.h @@ -0,0 +1,151 @@ +#pragma once + +#include + +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 +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 +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 +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 +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 diff --git a/test/test_battery_charge_cli.py b/test/test_battery_charge_cli.py new file mode 100644 index 000000000..8b2389693 --- /dev/null +++ b/test/test_battery_charge_cli.py @@ -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 +#include +#include +#include +#include +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 ", source) + self.assertIn("mesh::power::handleBatteryChargeCommand(", source) + source = r''' +#include +#include +#include "MeshSolarBoard.h" +#include +#include +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 \ninline uint16_t meshSolarGetBattVoltage() {return 7400;}\n", + encoding="ascii") + (path / "helpers/NRF52Board.h").write_text(r''' +#pragma once +#include +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() diff --git a/test/test_local_cli_access.py b/test/test_local_cli_access.py index 77599261a..e70ea1a84 100644 --- a/test/test_local_cli_access.py +++ b/test/test_local_cli_access.py @@ -18,6 +18,7 @@ PREAMBLE = r''' #include #include #include +#include #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) { diff --git a/test/test_tbeam_charge_target.py b/test/test_tbeam_charge_target.py new file mode 100644 index 000000000..26d17f036 --- /dev/null +++ b/test/test_tbeam_charge_target.py @@ -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 +#include +#include +#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 +#include +#include +#include +#include +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 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 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 +TwoWire Wire, Wire1; +int reset_reason = 0; +uint64_t wakeup_mask = 0; +std::map 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 \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() diff --git a/variants/heltec_mesh_solar/MeshSolarBoard.h b/variants/heltec_mesh_solar/MeshSolarBoard.h index 816336255..8adc911be 100644 --- a/variants/heltec_mesh_solar/MeshSolarBoard.h +++ b/variants/heltec_mesh_solar/MeshSolarBoard.h @@ -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"; }