Add configurable nRF52 battery profiles with LiFePO4 cutoff

This commit is contained in:
mikecarper committed 2026-09-30 01:11:10 -07:00
1 parent 5d19a0a731
commit 2d78b8f06c
31 files changed
+692 -32

No files matched your search

+2
View File
@@ -212,6 +212,8 @@ should use command `0x42`. See [Companion radio binary protocol](companion_proto
| Bridge | [`get/set bridge.secret`](cli_commands.md#set-the-esp-now-secret) | ESP-NOW is ESP32 only | No | No | No |
| Board | [`get bootloader.ver`](cli_commands.md#view-the-bootloader-version-nrf52-only) | nRF52 bootloader metadata | Yes | Yes | Yes |
| Board | [`get pwrmgt.support`; `get pwrmgt.source`; `get pwrmgt.bootreason`; `get pwrmgt.bootmv`](nrf52_power_management.md#cli-commands) | Board power-management implementation | Feature | Feature | Feature |
| Board | [`get/set pwrmgt.bootlock`; `get/set pwrmgt.cutoff`](cli_commands.md#configure-nrf52-battery-protection) | nRF52 power-management boards with a text CLI | Feature | Feature | Feature |
| Board | [`get/set battery.profile`; `get/set battery.empty`; `get/set battery.full`; `get/set adc.multiplier`](cli_commands.md#configure-nrf52-battery-protection) | nRF52 power-management boards with a text CLI; relative ADC calibration | Feature | Feature | Feature |
| Ethernet | [`eth.status`](cli_commands.md#view-ethernet-connection-status) | Ethernet target | Feature | Feature | No |
| Browser OTA | [`start ota [ap]`; `stop ota`](cli_commands.md#start-or-stop-an-over-the-air-ota-firmware-update) | ESP32 browser uploader | No | No | No |
| WebConfig | [`start webconfig [ap]`; `stop webconfig`; `get/set webui`](cli_commands.md#browser-configuration-portal-esp32-repeater-and-room-server) | ESP32 WebConfig | No | No | No |
+71 -1
View File
@@ -1836,7 +1836,13 @@ through binary command `0x42`; this command does not report the Bluetooth PIN.
**Default:** `0.0` (value defined by board)
**Note:** Returns "Error: unsupported by this board" if hardware doesn't support it
**Note:** On nRF52 boards with power management and a boot-voltage setting,
this is a saved *relative* calibration: `1.000` keeps the board conversion,
`1.050` raises all reported battery millivolts by 5%, and `0` resets to
`1.000`. The allowed range is `0.500`–`1.500`. This affects boot protection,
running cutoff, telemetry, battery alerts, and percentage. Calibrate against a
voltmeter before enabling a low cutoff. Other boards retain their existing
board-specific multiplier behavior, or return unsupported.
---
@@ -4790,6 +4796,70 @@ manufacturer images and the boards still requiring untouched factory dumps.
---
#### Configure nRF52 battery protection
**Usage:** `get pwrmgt.bootlock`, `set pwrmgt.bootlock <2500-4200>`
**Usage:** `get pwrmgt.cutoff`, `set pwrmgt.cutoff <off|2500-4200>`
Values are battery millivolts and are saved in internal storage. The boot lock
defaults to each board's compiled threshold and takes effect on the next boot.
Boards whose compiled boot lock is disabled also accept `set pwrmgt.bootlock off`.
The running cutoff
defaults to `off`; when enabled, three valid low readings at 30-second
intervals shut the node down. Both checks are bypassed while externally
powered. A nonzero cutoff cannot exceed the boot lock, and the boot lock
cannot be disabled while a cutoff is active. A fixed 2000 mV pre-check
protects flash on boards with a nonzero compiled boot lock before the saved
setting can be loaded. In the `custom` profile, the lower limit for the boot
lock and running cutoff is 2000 mV. Firmware warns when any saved voltage is
below 2500 mV; that experimental range may brown out the board or interrupt
flash writes. These commands are available on nRF52 boards with
`NRF52_POWER_MANAGEMENT` and a boot-voltage configuration, in roles with a
text CLI. The KISS modem has no text CLI for changing these settings.
`battery.profile` selects saved voltage-to-percentage endpoints and protection
settings. `liion` uses 3000 mV = 0%, 4200 mV = 100%, the board's original boot
lock, and running cutoff off. `lifepo4` uses 2700 mV = 0%, 3550 mV = 100%,
2900 mV boot lock, and 2700 mV running cutoff. `custom` starts with the current
values and allows endpoint overrides. These percentages are simple voltage
estimates; they are not an accurate state-of-charge measurement under load.
```text
get battery.profile
set battery.profile lifepo4
set battery.profile liion
set battery.profile custom
get battery.empty
set battery.empty 2700
get battery.full
set battery.full 3550
get adc.multiplier
set adc.multiplier 1.050
```
The custom empty/full endpoints may be 2000–4200 mV and must be at least 100 mV
apart. Setting either endpoint automatically selects `custom`; boot lock and
cutoff remain separately adjustable. `get pwrmgt.bootlock` and
`get pwrmgt.cutoff` show the values in force after selecting a profile.
For a RAK3401/RAK13302 LiFePO₄ cell:
```text
set battery.profile lifepo4
```
The 2.7 V cutoff is a cell-protection choice, not a guarantee of 1 W RF
output. The [RAK13302 datasheet](https://docs.rakwireless.com/product-categories/wisblock/rak13302/datasheet-vd/)
specifies 3.0 V minimum at VBAT and lists 5 V / 1 A peak for 30 dBm output.
Use a cutoff at or above 3.0 V if keeping the radio within its published
battery-input range matters; verify the actual voltage under transmit load.
The shutdown wake comparator has a separate fixed hardware reference. Changing
these millivolt settings does not change that wake threshold; a unit may need
to charge above it or have USB power restored before it wakes.
---
### Ethernet (when Ethernet support is compiled in)
Ethernet support is available on RAK4631 boards with a RAK13800 (W5100S) Ethernet module. Use the `_ethernet` firmware variants (e.g. `RAK_4631_repeater_ethernet`) to enable this feature.
+10 -4
View File
@@ -190,6 +190,13 @@ Power management status can be queried via the CLI:
| `get pwrmgt.bootreason` | Returns reset and shutdown reason strings |
| `get pwrmgt.bootmv` | Returns boot voltage in millivolts |
Boards with nRF52 power management and a text CLI also support saved
`pwrmgt.bootlock`, `pwrmgt.cutoff`, `battery.profile`, `battery.empty`,
`battery.full`, and relative `adc.multiplier` settings. See the
[CLI reference](cli_commands.md#configure-nrf52-battery-protection) for their
ranges, board-specific defaults, and wake behavior. KISS builds honor a saved
policy if one exists, but have no text CLI for changing it.
On boards without power management enabled, `get pwrmgt.source` and
`get pwrmgt.bootmv` return:
```
@@ -212,11 +219,10 @@ DEBUG: PWRMGT: Boot voltage = 3450 mV (threshold = 3300 mV)
DEBUG: PWRMGT: LPCOMP wake configured (AIN7, ref=3/8 VDD)
```
## Phase 2 (Planned)
## Further power-management work (Planned)
- Runtime voltage monitoring
- Voltage state machine (Normal -> Warning -> Critical -> Shutdown)
- Configurable thresholds
- A voltage state machine (Normal -> Warning -> Critical -> Shutdown)
- Configurable wake comparator thresholds
- Load shedding callbacks for power reduction
- Deep sleep integration
- Scheduled wake-up
+8
View File
@@ -23,6 +23,9 @@
#include <helpers/StatsFormatHelper.h>
#include <helpers/UsbAsciiBinarySwitch.h>
#include <helpers/UsbLogging.h>
#if defined(NRF52_POWER_MANAGEMENT)
#include <helpers/NRF52VoltagePolicy.h>
#endif
#if defined(NRF52_PLATFORM)
#include <helpers/nrf52/RamFallbackFileSystem.h>
#endif
@@ -9316,6 +9319,11 @@ bool MyMesh::handleCommand(const char* command, uint32_t sender_timestamp,
while (*command == ' ' || *command == '\t') command++;
}
#if defined(NRF52_POWER_MANAGEMENT)
if (mesh::power::handleVoltagePolicyCommand(command, reply,
reply_capacity)) return true;
#endif
if (_radio_profiles.handle(command, reply, reply_capacity, sender_timestamp != 0)) return true;
if (!strncmp(command, "set tempradio ", 14)) command += 4;
if (!strcmp(command, "get tempradio")) command += 4;
+3
View File
@@ -197,6 +197,9 @@ static bool isNetworkTerminalActive();
#else
#if defined(EXTRAFS)
#include "ResilientInternalExtraFS.h"
extern "C" struct lfs_config* meshNrf52VoltagePrimaryFsConfig() {
return ResilientInternalExtraFS::primaryConfig();
}
extern "C" uint32_t __flash_arduino_end[];
ResilientInternalExtraFS ExtraFS(0xD4000, 0x19000, 128);
DataStore store(InternalFS, ExtraFS, rtc_clock);
@@ -1,5 +1,8 @@
#include "UITask.h"
#include <helpers/TxtDataHelpers.h>
#if defined(NRF52_POWER_MANAGEMENT)
#include <helpers/NRF52VoltagePolicy.h>
#endif
#include <helpers/ui/BluetoothPairingUiPolicy.h>
#include <helpers/ui/CompanionHomeLayout.h>
#include <helpers/ui/CompanionMessageHistory.h>
@@ -484,6 +487,10 @@ class HomeScreen : public UIScreen {
int batteryPercentage = showBattery
? ((batteryMilliVolts - minMilliVolts) * 100) / (maxMilliVolts - minMilliVolts)
: 0;
#if defined(NRF52_POWER_MANAGEMENT)
batteryPercentage = showBattery
? mesh::power::configuredBatteryPercent(batteryMilliVolts) : 0;
#endif
if (batteryPercentage < 0) batteryPercentage = 0; // Clamp to 0%
if (batteryPercentage > 100) batteryPercentage = 100; // Clamp to 100%
+13 -1
View File
@@ -4,6 +4,9 @@
#endif
#include <Arduino.h>
#include <helpers/TxtDataHelpers.h>
#if defined(NRF52_POWER_MANAGEMENT)
#include <helpers/NRF52VoltagePolicy.h>
#endif
#include <helpers/ui/BluetoothPairingUiPolicy.h>
#include <helpers/ui/RadioProfileDisplayPage.h>
#include <helpers/ui/RadioProfileSystemStatus.h>
@@ -285,6 +288,9 @@ void UITask::renderBatteryIndicator(uint16_t batteryMilliVolts) {
const int minMilliVolts = BATT_MIN_MILLIVOLTS;
const int maxMilliVolts = BATT_MAX_MILLIVOLTS;
int batteryPercentage = ((batteryMilliVolts - minMilliVolts) * 100) / (maxMilliVolts - minMilliVolts);
#if defined(NRF52_POWER_MANAGEMENT)
batteryPercentage = mesh::power::configuredBatteryPercent(batteryMilliVolts);
#endif
if (batteryPercentage < 0) batteryPercentage = 0; // Clamp to 0%
if (batteryPercentage > 100) batteryPercentage = 100; // Clamp to 100%
@@ -495,7 +501,13 @@ void UITask::userLedHandler() {
}
if (cur_time > next_batt_check) { // battery reads are not free, keep them rare
low_batt = _board->getBattMilliVolts() < LOW_BATT_MILLIVOLTS;
uint16_t low_batt_mv = LOW_BATT_MILLIVOLTS;
#if defined(NRF52_POWER_MANAGEMENT)
low_batt_mv = mesh::power::remapBatteryWarningMillivolts(
LOW_BATT_MILLIVOLTS, BATT_MIN_MILLIVOLTS, BATT_MAX_MILLIVOLTS,
mesh::power::configuredEmpty(), mesh::power::configuredFull());
#endif
low_batt = _board->getBattMilliVolts() < low_batt_mv;
next_batt_check = cur_time + 60000;
}
+7
View File
@@ -7,6 +7,9 @@
#include <new> // std::nothrow (heap-allocated flood rule table)
#include <stdlib.h> // for qsort()
#include <helpers/CLICommandUtils.h>
#if defined(NRF52_POWER_MANAGEMENT)
#include <helpers/NRF52VoltagePolicy.h>
#endif
#include <helpers/ClientACLCLI.h>
#include <helpers/ClockSyncUtils.h>
#include <helpers/ClientLoginPersistence.h>
@@ -455,6 +458,9 @@ static bool verifyFloodSettingsWrite(FILESYSTEM* fs, const char* filename,
}
static uint8_t batteryPercentFromMilliVolts(uint16_t batt_mv) {
#if defined(NRF52_POWER_MANAGEMENT)
return mesh::power::configuredBatteryPercent(batt_mv);
#else
const int min_mv = BATT_MIN_MILLIVOLTS;
const int max_mv = BATT_MAX_MILLIVOLTS;
if (max_mv <= min_mv) return 100;
@@ -463,6 +469,7 @@ static uint8_t batteryPercentFromMilliVolts(uint16_t batt_mv) {
if (pct < 0) return 0;
if (pct > 100) return 100;
return (uint8_t)pct;
#endif
}
static bool parseBatteryAlertPercent(const char* value, uint8_t min_value, uint8_t max_value, uint8_t& result) {
+12
View File
@@ -21,6 +21,9 @@
#include <helpers/StaticPoolPacketManager.h>
#include <helpers/SimpleMeshTables.h>
#include <helpers/IdentityStore.h>
#if defined(NRF52_POWER_MANAGEMENT)
#include <helpers/NRF52VoltagePolicy.h>
#endif
#if defined(ESP32_PLATFORM)
#include <helpers/ESP32TrueRandom.h>
#endif
@@ -464,6 +467,14 @@ public:
}
void handleCommand(const char* command) {
#if defined(NRF52_POWER_MANAGEMENT)
char voltage_reply[160];
if (mesh::power::handleVoltagePolicyCommand(command, voltage_reply,
sizeof(voltage_reply))) {
Serial.println(voltage_reply);
return;
}
#endif
char profile_reply[160];
if (_radio_profiles.handle(command, profile_reply, sizeof(profile_reply))) {
Serial.println(profile_reply); return;
@@ -785,6 +796,7 @@ void setup() {
void loop() {
#if defined(NRF52_PLATFORM)
board.feedWatchdog();
board.loop();
#endif
the_mesh.loop();
rtc_clock.tick();
+9
View File
@@ -11,6 +11,9 @@
#include "AdvertDataHelpers.h"
#include "AlertReporter.h" // for alertReporterBannedChannelMatch()
#include "sensors/EnvironmentI2CConfig.h"
#if defined(NRF52_POWER_MANAGEMENT)
#include "NRF52VoltagePolicy.h"
#endif
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
#include "AtomicFileWriter.h"
#elif defined(ESP32_PLATFORM) || defined(RP2040_PLATFORM)
@@ -3325,6 +3328,9 @@ bool CommonCLI::handleSdCardGetCmd(const char* config, char* reply) {
void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* reply) {
const char* config = &command[4];
#if defined(NRF52_POWER_MANAGEMENT)
if (mesh::power::handleVoltagePolicyCommand(command, reply, 160)) return;
#endif
// Infrastructure roles dispatch here rather than through CommonRadioPrefs.
if (strncmp(config, "path.hash.mode", 14) == 0
&& (config[14] == 0 || config[14] == ' ' || config[14] == '\t')) {
@@ -4631,6 +4637,9 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep
void CommonCLI::handleGetCmd(uint32_t sender_timestamp, char* command, char* reply) {
const char* config = &command[4];
#if defined(NRF52_POWER_MANAGEMENT)
if (mesh::power::handleVoltagePolicyCommand(command, reply, 160)) return;
#endif
if (strcmp(config, "password") == 0) {
if (sender_timestamp == 0) {
snprintf(reply, 160, "> %s", _prefs->password);
+50 -11
View File
@@ -1,6 +1,7 @@
#if defined(NRF52_PLATFORM)
#include "NRF52Board.h"
#include "PowerManagementUtils.h"
#include "NRF52VoltagePolicy.h"
#include "Nrf52BootloaderVersion.h"
#include "nrf52/SoftDeviceState.h"
#include <target.h>
@@ -142,6 +143,38 @@ void NRF52Board::begin() {
#endif
}
void NRF52Board::loop() {
#ifdef NRF52_POWER_MANAGEMENT
if (!mesh::power::voltagePolicySupported()) return;
static uint32_t last_check = 0;
static uint8_t low_count = 0;
static uint16_t last_threshold = 0;
const uint16_t threshold = mesh::power::configuredCutoff();
const uint32_t now = millis();
if (threshold != last_threshold) {
last_threshold = threshold;
low_count = 0;
last_check = now;
}
if (threshold == 0 || now - last_check < 30000UL) return;
last_check = now;
if (isExternalPowered()) {
low_count = 0;
return;
}
low_count = mesh::power::nextLowVoltageCount(
low_count, getBattMilliVolts(), threshold, false);
if (low_count >= 3) initiateShutdown(SHUTDOWN_REASON_LOW_VOLTAGE);
#endif
}
#ifdef NRF52_POWER_MANAGEMENT
uint16_t NRF52Board::calibrateBatteryMillivolts(uint16_t raw_mv) const {
return mesh::power::calibratedMillivolts(
raw_mv, mesh::power::configuredAdcPermille());
}
#endif
#if NRF52_WATCHDOG_TIMEOUT_SECONDS > 0
static void reloadWatchdogChannels() {
// Ordinarily only RR0 is enabled. If a bootloader left the watchdog running
@@ -277,20 +310,26 @@ bool NRF52Board::checkBootVoltage(const PowerMgtConfig* config) {
}
boot_voltage_mv = mesh::power::medianVoltage(samples[0], samples[1], samples[2]);
if (config->voltage_bootlock == 0) return true; // Protection disabled
// Skip check if externally powered
if (isExternalPowered()) {
MESH_DEBUG_PRINTLN("PWRMGT: Boot check skipped (external power)");
return true;
const bool external = isExternalPowered();
// Loading the policy still accesses flash. Keep a hard floor ahead of it
// for boards whose compiled policy enables boot protection.
const uint16_t preflight = config->voltage_bootlock == 0 ? 0
: mesh::power::MIN_CUSTOM_MV;
if (mesh::power::shouldBootLock(boot_voltage_mv, preflight, external)) {
initiateShutdown(SHUTDOWN_REASON_BOOT_PROTECT);
return false;
}
MESH_DEBUG_PRINTLN("PWRMGT: Boot voltage = %u mV (threshold = %u mV)",
boot_voltage_mv, config->voltage_bootlock);
mesh::power::loadVoltagePolicyAtBoot(config->voltage_bootlock);
boot_voltage_mv = calibrateBatteryMillivolts(boot_voltage_mv);
const uint16_t threshold = mesh::power::configuredBootlock();
if (threshold == 0 || external) return true;
// Only trigger shutdown if reading is valid (>1000mV) AND below threshold
// This prevents spurious shutdowns on ADC glitches or uninitialized reads
if (mesh::power::shouldBootLock(boot_voltage_mv, config->voltage_bootlock, false)) {
MESH_DEBUG_PRINTLN("PWRMGT: Boot voltage = %u mV (threshold = %u mV)",
boot_voltage_mv, threshold);
// Only valid ADC readings (>1000 mV) can initiate protective shutdown.
if (mesh::power::shouldBootLock(boot_voltage_mv, threshold, false)) {
MESH_DEBUG_PRINTLN("PWRMGT: Boot voltage too low - entering protective shutdown");
initiateShutdown(SHUTDOWN_REASON_BOOT_PROTECT);
+6
View File
@@ -56,6 +56,7 @@ public:
#endif
{}
virtual void begin();
void loop() override;
// Feed the hardware main-loop watchdog. The first call starts it, so normal
// setup (including filesystem mount and radio initialization) is not timed.
// A loop blocked forever in SoftDevice-backed flash I/O will stop feeding it
@@ -78,6 +79,11 @@ public:
bool isUsbDataConnected() override;
bool isUsbHostConnected() override;
#ifdef NRF52_POWER_MANAGEMENT
// Relative to each board's built-in ADC conversion; 1.000 is unchanged.
uint16_t calibrateBatteryMillivolts(uint16_t raw_mv) const;
#endif
void attachDynamicPrefs(KeyValueStore* prefs) { (void)prefs; } // no-op
#ifdef NRF52_POWER_MANAGEMENT
+294
View File
@@ -0,0 +1,294 @@
#include "NRF52VoltagePolicy.h"
#if defined(NRF52_PLATFORM) && defined(NRF52_POWER_MANAGEMENT)
#include <InternalFileSystem.h>
#include "IdentityStore.h"
#include "AtomicFileWriter.h"
#include "PersistentStoreFormat.h"
#include <stdio.h>
#include <string.h>
// A Companion using resilient internal ExtraFS has a custom primary LittleFS
// geometry. Its application supplies that config; ordinary roles use default.
extern "C" struct lfs_config* meshNrf52VoltagePrimaryFsConfig()
__attribute__((weak));
namespace mesh {
namespace power {
static const char* POLICY_PATH = "/pwrmgt_v1";
enum Profile : uint8_t { LI_ION = 0, LIFEPO4 = 1, CUSTOM = 2 };
static bool supported = false;
static bool boot_off_allowed = false;
static uint16_t board_bootlock = 0;
static uint16_t saved_bootlock = 0;
static uint16_t saved_cutoff = 0;
static uint16_t empty_mv = 3000;
static uint16_t full_mv = 4200;
static uint16_t adc_permille = 1000;
static Profile profile = LI_ION;
static bool validPercentRange(uint16_t empty, uint16_t full, Profile kind) {
return empty >= (kind == CUSTOM ? MIN_CUSTOM_MV : MIN_CONFIGURED_MV)
&& full <= MAX_CONFIGURED_MV
&& full >= empty + 100;
}
static void encode(uint8_t (&data)[20], uint16_t boot, uint16_t low,
uint16_t empty, uint16_t full, uint16_t adc, Profile kind) {
data[0] = 'N'; data[1] = 'V'; data[2] = 'P'; data[3] = '2';
mesh::storage::writeLE16(data + 4, boot);
mesh::storage::writeLE16(data + 6, low);
mesh::storage::writeLE16(data + 8, empty);
mesh::storage::writeLE16(data + 10, full);
mesh::storage::writeLE16(data + 12, adc);
data[14] = kind;
data[15] = 0;
mesh::storage::writeLE32(data + 16,
mesh::storage::updateCRC32(0xFFFFFFFFUL, data, 16));
}
static void readMountedPolicy() {
File file(InternalFS);
if (!file.open(POLICY_PATH, FILE_O_READ)) return;
uint8_t data[20];
const size_t length = file.size();
const bool complete = (length == 12 || length == sizeof(data))
&& file.read(data, length) == length;
file.close();
if (!complete || memcmp(data, "NVP", 3) != 0) return;
const bool old_format = length == 12 && data[3] == '1';
const bool new_format = length == 20 && data[3] == '2';
if ((!old_format && !new_format)
|| mesh::storage::readLE32(data + length - 4)
!= mesh::storage::updateCRC32(0xFFFFFFFFUL, data, length - 4)) return;
const uint16_t boot = mesh::storage::readLE16(data + 4);
const uint16_t low = mesh::storage::readLE16(data + 6);
const Profile kind = new_format ? static_cast<Profile>(data[14]) : LI_ION;
if (new_format && (data[14] > CUSTOM || data[15] != 0)) return;
if (!validThresholdPair(boot, low, boot_off_allowed,
kind == CUSTOM ? MIN_CUSTOM_MV : MIN_CONFIGURED_MV)) return;
if (new_format) {
const uint16_t empty = mesh::storage::readLE16(data + 8);
const uint16_t full = mesh::storage::readLE16(data + 10);
const uint16_t adc = mesh::storage::readLE16(data + 12);
if (!validPercentRange(empty, full, kind) || adc < 500 || adc > 1500) return;
empty_mv = empty;
full_mv = full;
adc_permille = adc;
profile = static_cast<Profile>(data[14]);
}
saved_bootlock = boot;
saved_cutoff = low;
}
void loadVoltagePolicyAtBoot(uint16_t board_default_mv) {
supported = true;
board_bootlock = board_default_mv;
boot_off_allowed = board_default_mv == 0;
saved_bootlock = board_default_mv;
saved_cutoff = 0;
empty_mv = 3000;
full_mv = 4200;
adc_permille = 1000;
profile = LI_ION;
struct lfs_config* config = meshNrf52VoltagePrimaryFsConfig
? meshNrf52VoltagePrimaryFsConfig() : nullptr;
const bool mounted = config == nullptr
? InternalFS.Adafruit_LittleFS::begin()
: InternalFS.Adafruit_LittleFS::begin(config);
if (mounted) readMountedPolicy();
// Never call InternalFileSystem::begin() here: it auto-formats on failure.
// The role's ordinary primary mount retains its existing recovery behavior.
InternalFS.end();
}
bool voltagePolicySupported() { return supported; }
bool bootlockOffAllowed() { return boot_off_allowed; }
uint16_t configuredBootlock() { return saved_bootlock; }
uint16_t configuredCutoff() { return saved_cutoff; }
uint16_t configuredEmpty() { return empty_mv; }
uint16_t configuredFull() { return full_mv; }
uint16_t configuredAdcPermille() { return adc_permille; }
uint8_t configuredBatteryPercent(uint16_t mv) {
return batteryPercent(mv, empty_mv, full_mv);
}
static bool save(uint16_t boot, uint16_t low, uint16_t empty, uint16_t full,
uint16_t adc, Profile kind) {
if (!supported || !validThresholdPair(boot, low, boot_off_allowed,
kind == CUSTOM ? MIN_CUSTOM_MV : MIN_CONFIGURED_MV)
|| !validPercentRange(empty, full, kind)
|| adc < 500 || adc > 1500) return false;
uint8_t data[20];
encode(data, boot, low, empty, full, adc, kind);
mesh::AtomicFileWriter writer(&InternalFS, POLICY_PATH);
if (!writer || writer.write(data, sizeof(data)) != sizeof(data)
|| !writer.commit()) return false;
saved_bootlock = boot;
saved_cutoff = low;
empty_mv = empty;
full_mv = full;
adc_permille = adc;
profile = kind;
return true;
}
bool setConfiguredBootlock(uint16_t mv) {
return save(mv, saved_cutoff, empty_mv, full_mv, adc_permille, profile);
}
bool setConfiguredCutoff(uint16_t mv) {
return save(saved_bootlock, mv, empty_mv, full_mv, adc_permille, profile);
}
static bool matchKey(const char* command, const char* key, bool set) {
const char* prefix = set ? "set " : "get ";
if (strncmp(command, prefix, 4) != 0) return false;
command += 4;
const size_t len = strlen(key);
return strncmp(command, key, len) == 0
&& (command[len] == 0 || command[len] == ' ' || command[len] == '\t');
}
static const char* const KEYS[] = {
"pwrmgt.bootlock", "pwrmgt.cutoff", "battery.profile",
"battery.empty", "battery.full", "adc.multiplier"
};
static int commandKey(const char* command, bool set) {
if (command == nullptr) return -1;
for (unsigned i = 0; i < sizeof(KEYS) / sizeof(KEYS[0]); ++i) {
if (matchKey(command, KEYS[i], set)) return (int)i;
}
return -1;
}
bool isVoltagePolicyCommand(const char* command) {
return commandKey(command, false) >= 0 || commandKey(command, true) >= 0;
}
// Exact decimal with up to three fractional places. Zero restores factory scale.
static bool parseAdcPermille(const char* value, uint16_t& out) {
if (*value == 0) return false;
uint32_t whole = 0, fraction = 0;
unsigned digits = 0;
bool dot = false;
for (const char* p = value; *p; ++p) {
if (*p == '.') {
if (dot) return false;
dot = true;
} else if (*p >= '0' && *p <= '9') {
if (!dot) {
whole = whole * 10 + (*p - '0');
if (whole > 1) return false;
} else {
if (++digits > 3) return false;
fraction = fraction * 10 + (*p - '0');
}
} else return false;
}
if (dot && digits == 0) return false;
while (digits++ < 3) fraction *= 10;
const uint32_t result = whole * 1000 + fraction;
if (result == 0) { out = 1000; return true; }
if (result < 500 || result > 1500) return false;
out = (uint16_t)result;
return true;
}
bool handleVoltagePolicyCommand(const char* command, char* reply,
size_t reply_capacity) {
if (reply == nullptr || reply_capacity == 0) return false;
const bool set = command != nullptr && strncmp(command, "set ", 4) == 0;
const int key = commandKey(command, set);
if (key < 0) return false;
if (!supported) {
snprintf(reply, reply_capacity, "Error: voltage policy unsupported");
return true;
}
const char* value = command + 4 + strlen(KEYS[key]);
while (*value == ' ' || *value == '\t') ++value;
if (!set) {
if (*value != 0) snprintf(reply, reply_capacity, "Error: get %s", KEYS[key]);
else if (key == 0 || key == 1) {
const uint16_t mv = key == 0 ? saved_bootlock : saved_cutoff;
if (mv == 0) snprintf(reply, reply_capacity, "> off");
else snprintf(reply, reply_capacity, "> %u mV", (unsigned)mv);
} else if (key == 2) {
snprintf(reply, reply_capacity, "> %s",
profile == LIFEPO4 ? "lifepo4" : profile == CUSTOM ? "custom" : "liion");
} else if (key == 5) {
snprintf(reply, reply_capacity, "> %u.%03u",
(unsigned)(adc_permille / 1000), (unsigned)(adc_permille % 1000));
} else {
snprintf(reply, reply_capacity, "> %u mV",
(unsigned)(key == 3 ? empty_mv : full_mv));
}
return true;
}
if (key == 2) {
uint16_t boot = saved_bootlock, low = saved_cutoff;
uint16_t empty = empty_mv, full = full_mv;
Profile next = profile;
if (strcmp(value, "lifepo4") == 0) {
next = LIFEPO4; boot = 2900; low = 2700; empty = 2700; full = 3550;
} else if (strcmp(value, "liion") == 0) {
next = LI_ION; boot = board_bootlock; low = 0; empty = 3000; full = 4200;
} else if (strcmp(value, "custom") == 0) next = CUSTOM;
else {
snprintf(reply, reply_capacity, "Error: profile liion|lifepo4|custom");
return true;
}
if (!save(boot, low, empty, full, adc_permille, next))
snprintf(reply, reply_capacity, "Error: battery profile could not be saved");
else snprintf(reply, reply_capacity, "OK - %s profile saved; reboot for bootlock", value);
return true;
}
if (key == 5) {
uint16_t adc;
if (!parseAdcPermille(value, adc))
snprintf(reply, reply_capacity, "Error: multiplier 0 or 0.500-1.500");
else if (!save(saved_bootlock, saved_cutoff, empty_mv, full_mv, adc, profile))
snprintf(reply, reply_capacity, "Error: multiplier could not be saved");
else snprintf(reply, reply_capacity, "OK - ADC multiplier %u.%03u saved",
(unsigned)(adc / 1000), (unsigned)(adc % 1000));
return true;
}
uint16_t mv = 0;
const uint16_t minimum = (profile == CUSTOM || key == 3 || key == 4)
? MIN_CUSTOM_MV : MIN_CONFIGURED_MV;
if (!parseVoltageMillivolts(value, mv,
key == 1 || (key == 0 && boot_off_allowed), minimum)) {
snprintf(reply, reply_capacity, "Error: set %s %s%u-4200",
KEYS[key], (key == 1 || (key == 0 && boot_off_allowed)) ? "off|" : "",
(unsigned)minimum);
} else if (key == 0 && saved_cutoff != 0 && (mv == 0 || mv < saved_cutoff)) {
snprintf(reply, reply_capacity, "Error: bootlock must be at least cutoff");
} else if (key == 1 && mv != 0 && (saved_bootlock == 0 || mv > saved_bootlock)) {
snprintf(reply, reply_capacity, "Error: cutoff must not exceed bootlock");
} else if ((key == 3 && mv + 100 > full_mv)
|| (key == 4 && mv < empty_mv + 100)) {
snprintf(reply, reply_capacity, "Error: full must exceed empty by 100 mV");
} else {
const bool ok = save(key == 0 ? mv : saved_bootlock,
key == 1 ? mv : saved_cutoff,
key == 3 ? mv : empty_mv,
key == 4 ? mv : full_mv,
adc_permille, key >= 3 ? CUSTOM : profile);
if (!ok) snprintf(reply, reply_capacity, "Error: voltage setting could not be saved");
else snprintf(reply, reply_capacity, "OK - %s %s%s%s", KEYS[key], value,
key == 0 ? "; reboot required" : " (saved)",
mv != 0 && mv < MIN_CONFIGURED_MV
? "; WARNING: below 2500 mV risks brownout/flash failure" : "");
}
return true;
}
} // namespace power
} // namespace mesh
#endif
+31
View File
@@ -0,0 +1,31 @@
#pragma once
#include <stddef.h>
#include <stdint.h>
#include "NRF52VoltageRules.h"
#if defined(NRF52_PLATFORM) && defined(NRF52_POWER_MANAGEMENT)
namespace mesh {
namespace power {
// Called by checkBootVoltage(). Uses a single non-formatting mount to read the
// policy, then releases the FS so the application can mount it normally later.
void loadVoltagePolicyAtBoot(uint16_t board_default_mv);
bool voltagePolicySupported();
bool bootlockOffAllowed();
uint16_t configuredBootlock();
uint16_t configuredCutoff();
uint16_t configuredEmpty();
uint16_t configuredFull();
uint16_t configuredAdcPermille();
uint8_t configuredBatteryPercent(uint16_t mv);
bool setConfiguredBootlock(uint16_t mv);
bool setConfiguredCutoff(uint16_t mv);
bool isVoltagePolicyCommand(const char* command);
bool handleVoltagePolicyCommand(const char* command, char* reply,
size_t reply_capacity);
} // namespace power
} // namespace mesh
#endif
+78
View File
@@ -0,0 +1,78 @@
#pragma once
#include <stdint.h>
#include <string.h>
namespace mesh {
namespace power {
static const uint16_t MIN_CONFIGURED_MV = 2500;
static const uint16_t MIN_CUSTOM_MV = 2000;
static const uint16_t MAX_CONFIGURED_MV = 4200;
inline bool validThresholdPair(uint16_t boot, uint16_t low,
bool boot_off_allowed,
uint16_t minimum_mv = MIN_CONFIGURED_MV) {
return (boot == 0 ? boot_off_allowed
: boot >= minimum_mv && boot <= MAX_CONFIGURED_MV)
&& (low == 0 || (low >= minimum_mv && low <= MAX_CONFIGURED_MV))
&& (low == 0 || (boot != 0 && low <= boot));
}
inline bool parseVoltageMillivolts(const char* value, uint16_t& mv,
bool allow_off,
uint16_t minimum_mv = MIN_CONFIGURED_MV) {
if (value == nullptr) return false;
if (allow_off && strcmp(value, "off") == 0) { mv = 0; return true; }
if (*value == 0) return false;
uint32_t number = 0;
for (const char* p = value; *p; ++p) {
if (*p < '0' || *p > '9') return false;
number = number * 10 + (*p - '0');
if (number > MAX_CONFIGURED_MV) return false;
}
if (number < minimum_mv) return false;
mv = (uint16_t)number;
return true;
}
inline uint8_t nextLowVoltageCount(uint8_t previous, uint16_t mv,
uint16_t cutoff, bool external_power) {
if (cutoff == 0 || external_power || mv <= 1000 || mv >= cutoff) return 0;
return previous < 3 ? previous + 1 : 3;
}
inline uint8_t batteryPercent(uint16_t mv, uint16_t empty_mv,
uint16_t full_mv) {
if (full_mv <= empty_mv) return 0;
if (mv <= empty_mv) return 0;
if (mv >= full_mv) return 100;
return (uint8_t)(((uint32_t)(mv - empty_mv) * 100U)
/ (full_mv - empty_mv));
}
// Keep a legacy voltage warning at the same fraction of the displayed battery
// range when the user selects a different chemistry or custom endpoints.
inline uint16_t remapBatteryWarningMillivolts(uint16_t old_warning,
uint16_t old_empty,
uint16_t old_full,
uint16_t new_empty,
uint16_t new_full) {
if (old_full <= old_empty || new_full <= new_empty) return old_warning;
if (old_warning <= old_empty) return new_empty;
if (old_warning >= old_full) return new_full;
const uint32_t old_span = old_full - old_empty;
const uint32_t new_span = new_full - new_empty;
return new_empty + (uint16_t)(((uint32_t)(old_warning - old_empty)
* new_span + old_span / 2U) / old_span);
}
inline uint16_t calibratedMillivolts(uint16_t raw_mv,
uint16_t multiplier_permille) {
if (raw_mv == 0) return 0;
const uint32_t value = ((uint32_t)raw_mv * multiplier_permille + 500U) / 1000U;
return value > 65535U ? 65535U : (uint16_t)value;
}
} // namespace power
} // namespace mesh
+1
View File
@@ -140,6 +140,7 @@ does not reflect the GoogleTest count -- run the built binary directly
| `test_packet_manager` | `src/Packet.cpp`, `src/Dispatcher.cpp`, `src/helpers/StaticPoolPacketManager.cpp` | truncated-packet rejection, unavailable-radio behavior, scoped RX-delay replacement, queue/CAD scheduling, and staged radio/TX recovery |
| `test_persistent_store_format` | `src/helpers/PersistentStoreFormat.h` | contact-page headers and CRCs, dirty-page state, stable slot allocation, and bounded resumable legacy migration across power loss |
| `test_power_management` | `src/helpers/PowerManagementUtils.h` | median filtering of a brownout outlier and valid-reading requirements for the boot lock |
| `test_nrf52_voltage_rules` | `src/helpers/NRF52VoltageRules.h` | board-default threshold bounds, strict CLI values, and consecutive runtime low-voltage readings |
| `test_rx_power_saving` | `src/helpers/radiolib/RXPowerSaving.h` | guarded timer/capture bounds across SF5-SF12, bandwidths, TCXO delays and wire preambles; experimental profiles; retuning and strict CLI parsing |
| `test_noise_floor_estimator` | `src/helpers/radiolib/NoiseFloorEstimator.h` | spaced low percentile, captured off-SF traffic, weighting, contamination limits, persistent rises, resets and clock rollover |
| `test_region_names` | `src/helpers/RegionNameUtils.h` | canonical public-region markers while preserving distinct private and differently named regions |
@@ -0,0 +1,75 @@
#include <gtest/gtest.h>
#include <helpers/NRF52VoltageRules.h>
TEST(NRF52VoltageRules, KeepsBoardDefaultsAndRejectsUnsafePairs) {
EXPECT_TRUE(mesh::power::validThresholdPair(3300, 0, false));
EXPECT_TRUE(mesh::power::validThresholdPair(3000, 2700, false));
EXPECT_TRUE(mesh::power::validThresholdPair(0, 0, true));
EXPECT_FALSE(mesh::power::validThresholdPair(0, 0, false));
EXPECT_FALSE(mesh::power::validThresholdPair(2499, 0, false));
EXPECT_FALSE(mesh::power::validThresholdPair(4201, 0, false));
EXPECT_FALSE(mesh::power::validThresholdPair(3000, 3100, false));
EXPECT_FALSE(mesh::power::validThresholdPair(0, 2700, true));
EXPECT_TRUE(mesh::power::validThresholdPair(2000, 2000, false,
mesh::power::MIN_CUSTOM_MV));
EXPECT_FALSE(mesh::power::validThresholdPair(1999, 0, false,
mesh::power::MIN_CUSTOM_MV));
}
TEST(NRF52VoltageRules, ParsesCliMillivoltsStrictly) {
uint16_t mv = 1234;
EXPECT_TRUE(mesh::power::parseVoltageMillivolts("3000", mv, false));
EXPECT_EQ(mv, 3000);
EXPECT_FALSE(mesh::power::parseVoltageMillivolts("off", mv, false));
EXPECT_EQ(mv, 3000);
EXPECT_TRUE(mesh::power::parseVoltageMillivolts("off", mv, true));
EXPECT_EQ(mv, 0);
EXPECT_FALSE(mesh::power::parseVoltageMillivolts("2499", mv, true));
EXPECT_FALSE(mesh::power::parseVoltageMillivolts("4201", mv, true));
EXPECT_FALSE(mesh::power::parseVoltageMillivolts("3000mV", mv, true));
EXPECT_FALSE(mesh::power::parseVoltageMillivolts("3000 ", mv, true));
EXPECT_FALSE(mesh::power::parseVoltageMillivolts("", mv, true));
EXPECT_TRUE(mesh::power::parseVoltageMillivolts("2000", mv, false,
mesh::power::MIN_CUSTOM_MV));
EXPECT_FALSE(mesh::power::parseVoltageMillivolts("1999", mv, false,
mesh::power::MIN_CUSTOM_MV));
}
TEST(NRF52VoltageRules, RequiresThreeConsecutiveValidLowReadings) {
using mesh::power::nextLowVoltageCount;
EXPECT_EQ(nextLowVoltageCount(0, 2690, 2700, false), 1);
EXPECT_EQ(nextLowVoltageCount(1, 2650, 2700, false), 2);
EXPECT_EQ(nextLowVoltageCount(2, 2600, 2700, false), 3);
EXPECT_EQ(nextLowVoltageCount(2, 2700, 2700, false), 0);
EXPECT_EQ(nextLowVoltageCount(2, 999, 2700, false), 0);
EXPECT_EQ(nextLowVoltageCount(2, 2600, 2700, true), 0);
EXPECT_EQ(nextLowVoltageCount(2, 2600, 0, false), 0);
}
TEST(NRF52VoltageRules, LiFePO4EndpointsAndAdcCalibration) {
using mesh::power::batteryPercent;
using mesh::power::calibratedMillivolts;
EXPECT_EQ(batteryPercent(2700, 2700, 3550), 0);
EXPECT_EQ(batteryPercent(3125, 2700, 3550), 50);
EXPECT_EQ(batteryPercent(3550, 2700, 3550), 100);
EXPECT_EQ(batteryPercent(3700, 2700, 3550), 100);
EXPECT_EQ(calibratedMillivolts(3000, 1000), 3000);
EXPECT_EQ(calibratedMillivolts(3000, 1050), 3150);
EXPECT_EQ(calibratedMillivolts(0, 1500), 0);
}
TEST(NRF52VoltageRules, RemapsCompanionWarningWithBatteryRange) {
using mesh::power::remapBatteryWarningMillivolts;
EXPECT_EQ(remapBatteryWarningMillivolts(3500, 3000, 4200,
3000, 4200), 3500);
EXPECT_EQ(remapBatteryWarningMillivolts(3500, 3000, 4200,
2700, 3550), 3054);
EXPECT_EQ(remapBatteryWarningMillivolts(3500, 3000, 4200,
2000, 3500), 2625);
}
int main(int argc, char** argv) {
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
}
@@ -26,7 +26,7 @@ public:
}
raw = raw / BATTERY_SAMPLES;
return (ADC_MULTIPLIER * raw) / 4096;
return calibrateBatteryMillivolts((ADC_MULTIPLIER * raw) / 4096);
}
const char* getManufacturerName() const override {
@@ -26,7 +26,7 @@ public:
}
raw = raw / BATTERY_SAMPLES;
return (ADC_MULTIPLIER * raw) / 4096;
return calibrateBatteryMillivolts((ADC_MULTIPLIER * raw) / 4096);
}
const char* getManufacturerName() const override {
@@ -26,7 +26,7 @@ public:
}
raw = raw / BATTERY_SAMPLES;
return (ADC_MULTIPLIER * raw) / 4096;
return calibrateBatteryMillivolts((ADC_MULTIPLIER * raw) / 4096);
}
const char* getManufacturerName() const override {
@@ -26,7 +26,7 @@ public:
}
raw = raw / BATTERY_SAMPLES;
return (ADC_MULTIPLIER * raw) / 4096;
return calibrateBatteryMillivolts((ADC_MULTIPLIER * raw) / 4096);
}
const char* getManufacturerName() const override {
+1 -1
View File
@@ -76,7 +76,7 @@ uint16_t T096Board::getBattMilliVolts() {
adcvalue = analogRead(PIN_VBAT_READ);
digitalWrite(PIN_BAT_CTL, 0);
return (uint16_t)((float)adcvalue * MV_LSB * 4.9);
return calibrateBatteryMillivolts((uint16_t)((float)adcvalue * MV_LSB * 4.9));
}
void T096Board::variant_shutdown() {
nrf_gpio_cfg_default(PIN_VEXT_EN);
+1 -1
View File
@@ -77,7 +77,7 @@ uint16_t T1Board::getBattMilliVolts() {
int adcvalue = analogRead(PIN_VBAT_READ);
digitalWrite(PIN_BAT_CTL, !ADC_CTRL_ENABLED);
return (uint16_t)((float)adcvalue * MV_LSB * ADC_MULTIPLIER);
return calibrateBatteryMillivolts((uint16_t)((float)adcvalue * MV_LSB * ADC_MULTIPLIER));
}
void T1Board::variant_shutdown() {
+1 -1
View File
@@ -39,7 +39,7 @@ public:
adcvalue = analogRead(PIN_VBAT_READ);
digitalWrite(6, 0);
return (uint16_t)((float)adcvalue * MV_LSB * 4.9);
return calibrateBatteryMillivolts((uint16_t)((float)adcvalue * MV_LSB * 4.9));
}
const char* getManufacturerName() const override {
@@ -81,7 +81,7 @@ uint16_t HeltecTowerV2Board::getBattMilliVolts() {
int adcvalue = analogRead(PIN_VBAT_READ);
digitalWrite(PIN_BAT_CTL, LOW);
return (uint16_t)((float)adcvalue * MV_LSB * ADC_MULTIPLIER);
return calibrateBatteryMillivolts((uint16_t)((float)adcvalue * MV_LSB * ADC_MULTIPLIER));
}
const char* HeltecTowerV2Board::getManufacturerName() const {
+1 -1
View File
@@ -39,7 +39,7 @@ public:
raw = raw / BATTERY_SAMPLES;
// ADC_MULTIPLIER is the voltage divider ratio
return (raw * ADC_MULTIPLIER * AREF_VOLTAGE) / 4.096;
return calibrateBatteryMillivolts((raw * ADC_MULTIPLIER * AREF_VOLTAGE) / 4.096);
}
const char* getManufacturerName() const override {
+1 -1
View File
@@ -54,7 +54,7 @@ public:
}
raw = raw / BATTERY_SAMPLES;
return (ADC_MULTIPLIER * raw) / 4096;
return calibrateBatteryMillivolts((ADC_MULTIPLIER * raw) / 4096);
}
const char* getManufacturerName() const override {
+1 -1
View File
@@ -39,7 +39,7 @@ public:
}
raw = raw / BATTERY_SAMPLES;
return (ADC_MULTIPLIER * raw) / 4096;
return calibrateBatteryMillivolts((ADC_MULTIPLIER * raw) / 4096);
}
const char* getManufacturerName() const override {
+1 -1
View File
@@ -29,7 +29,7 @@ public:
}
raw = raw / BATTERY_SAMPLES;
return (ADC_MULTIPLIER * raw) / 4096;
return calibrateBatteryMillivolts((ADC_MULTIPLIER * raw) / 4096);
}
const char* getManufacturerName() const override {
+1 -1
View File
@@ -30,7 +30,7 @@ public:
analogReference(AR_INTERNAL_3_0);
delay(10);
adcvalue = analogRead(BATTERY_PIN);
return (adcvalue * ADC_MULTIPLIER * AREF_VOLTAGE) / 4.096;
return calibrateBatteryMillivolts((adcvalue * ADC_MULTIPLIER * AREF_VOLTAGE) / 4.096);
}
const char* getManufacturerName() const override {
+2 -2
View File
@@ -68,7 +68,7 @@ uint16_t XiaoNrf52Board::getBattMilliVolts() {
// VBAT_ENABLE must be LOW to read battery voltage
digitalWrite(VBAT_ENABLE, LOW);
int adcvalue = analogRead(PIN_VBAT);
return (adcvalue * ADC_MULTIPLIER * AREF_VOLTAGE) / 4.096;
return calibrateBatteryMillivolts((adcvalue * ADC_MULTIPLIER * AREF_VOLTAGE) / 4.096);
}
#endif
#endif