Add optional battery thresholds for repeat and flood forwarding

This commit is contained in:
mikecarper committed 2026-10-22 17:33:23 -07:00
1 parent 3d29a1d811
commit af106a0ee9
25 files changed
+1170 -19

No files matched your search

+1
View File
@@ -474,6 +474,7 @@ jobs:
python3 -B test/test_radio_regulator_modes.py -v
python3 -B test/test_regulator_recovery.py -v
python3 -B test/test_regulator_settings.py -v
python3 -B test/test_battery_forwarding.py -v
python3 -B test/test_power_saving_diagnostics.py -v
python3 -B test/test_power_saving_diagnostics_buckets.py -v
python3 -B test/test_power_saving_role_diagnostics.py -v
+97
View File
@@ -2601,6 +2601,103 @@ send text.flood checking ridge link
---
#### Pause forwarding when the battery is low
**Search terms:** low battery repeating, battery flood cutoff, save battery,
automatic repeat off.
Repeater, Room Server, and Sensor firmware can automatically pause forwarding
below a configured battery percentage. Both controls default to `off`, including
when upgrading. They are independent of battery alerts.
**Usage:**
- `get repeat.battery`
- `set repeat.battery <off|on|1-100>`
- `get flood.battery`
- `set flood.battery <off|on|1-100>`
- `get battery.forward`
`set repeat.battery on` enables the saved repeat threshold, initially **10%**.
`set flood.battery on` enables the saved flood threshold, initially **20%**.
A number both enables the control and sets its threshold. `off` disables it
while retaining that threshold for the next `on`. Changes are saved and take
effect without a reboot.
Below the repeat threshold, the node stops LoRa retransmissions of received
traffic. Below the flood threshold, it stops LoRa retransmissions of received
flood traffic but may still
relay routed traffic if repeating is otherwise allowed. Neither control stops
the node's own transmissions or replies to administration requests. The saved
manual repeat flag, flood limits, and forwarding rules remain unchanged; a
battery recovery does not override a manual restriction.
Received packets entering from a bridge are subject to the same LoRa transmit
limits. Exporting radio reception to WiFi, ESP-NOW, or RS232 is unchanged.
Forwarding resumes automatically after the measured percentage reaches two
percentage points above the applicable threshold, capped at 100%. With the
initial thresholds, flood forwarding pauses below 20% and resumes at 22%; all
relay forwarding pauses below 10% and resumes at 12%. This recovery margin
avoids repeatedly switching forwarding on and off near a threshold.
Battery voltage is sampled every 30 seconds while either control is enabled,
and immediately when its configuration changes. `get battery.forward` reports
the voltage, estimated percentage, whether the reading is valid, and which
battery controls are currently suppressing forwarding. A missing or invalid
reading does not suppress forwarding: accepted readings are 2000-20000 mV.
Voltage alone does not establish that a battery is physically connected.
To enable both default thresholds:
```text
set repeat.battery on
set flood.battery on
get battery.forward
```
To choose different thresholds and later disable the feature:
```text
set repeat.battery 15
set flood.battery 25
set repeat.battery off
set flood.battery off
```
#### Set the battery percentage voltage range
The forwarding thresholds use a voltage-derived percentage. On infrastructure
builds without the nRF52 voltage policy, the saved range is adjustable with:
```text
get battery.empty
get battery.full
set battery.empty 3000
set battery.full 4200
```
Values are millivolts, from 2000 to 20000, with full at least 100 mV above empty.
The default range comes from the board's compiled battery endpoints, normally
3000 mV for 0% and 4200 mV for 100%; boards with a two-cell pack retain their
compiled pack range. The percentage is interpolated between those endpoints
and limited to 0-100%. These settings describe the battery
range; they do not calibrate the voltage measurement or alter a charger.
Supported nRF52 power-management builds use their existing saved
`battery.profile`, `battery.empty`, and `battery.full` values instead. Their
endpoint range remains 2000-4200 mV, with at least 100 mV between endpoints.
See [nRF52 battery protection](#configure-nrf52-battery-protection) for profiles
and their separate boot-lock and cutoff effects. `set adc.multiplier` adjusts
voltage calibration on boards that support it; see
[battery reading calibration](#fine-tune-the-battery-reading).
Percentage remains an estimate: a voltage reading under transmit load may
differ from an idle reading. Check the reported voltage against a voltmeter
before relying on automatic forwarding limits.
---
#### Enable or disable the RX inactivity watchdog (Repeater Only)
**Usage:**
- `get rx.watchdog`
+6 -16
View File
@@ -496,21 +496,6 @@ static bool verifyFloodSettingsWrite(FILESYSTEM* fs, const char* filename,
return hash == expected_hash;
}
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;
int pct = (((int)batt_mv - min_mv) * 100) / (max_mv - min_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) {
if (value == NULL || *value == 0) {
return false;
@@ -1095,6 +1080,7 @@ bool MyMesh::floodChannelDataHopApplies(const mesh::Packet* packet) const {
bool MyMesh::allowPacketForward(const mesh::Packet *packet) {
if (!packet) return false;
if (!_cli.batteryAllowsForward(packet->isRouteFlood())) return false;
if (_prefs.disable_fwd && !(_prefs.trace_when_repeat_off == 1 && packet
&& packet->isRouteDirect() && packet->getPayloadType() == PAYLOAD_TYPE_TRACE)) return false;
#if !defined(PORTABLE_MQTT_OBSERVER) && MESH_ENABLE_FLOOD_RULE_ENGINE
@@ -1177,6 +1163,7 @@ bool MyMesh::allowPacketForward(const mesh::Packet *packet) {
}
bool MyMesh::allowPacketTransmit(const mesh::Packet* packet) const {
if (!_cli.batteryAllowsTransmit(packet)) return false;
// Forwarded traces have already appended this node's SNR. A locally
// originated trace starts with an empty SNR path and is not a relay.
// Recheck at egress so a queued relay/retry cannot outlive this opt-in.
@@ -1587,6 +1574,7 @@ uint32_t MyMesh::getDirectRetryAttemptStepMillis() const {
}
bool MyMesh::allowDirectRetry(const mesh::Packet* packet, const uint8_t* next_hop_hash, uint8_t next_hop_hash_len) const {
if (!_cli.batteryAllowsTransmit(packet)) return false;
if (_cli.morseReply().ownsReply(packet)) return false;
if (_prefs.disable_fwd && _prefs.trace_when_repeat_off != 1 && packet
&& packet->isRouteDirect() && packet->getPayloadType() == PAYLOAD_TYPE_TRACE
@@ -2132,6 +2120,7 @@ MyMesh::FloodRetryBridgeState* MyMesh::floodRetryBridgeStateFor(const mesh::Pack
}
bool MyMesh::allowFloodRetry(const mesh::Packet* packet) const {
if (!_cli.batteryAllowsTransmit(packet)) return false;
if (_cli.morseReply().ownsReply(packet)) return false;
if (_prefs.disable_fwd || constrain(_prefs.flood_retry_attempts, 0, 15) == 0) {
return false;
@@ -2447,6 +2436,7 @@ uint8_t MyMesh::getFloodRetryMaxPathLength(const mesh::Packet* packet) const {
}
uint8_t MyMesh::getFloodRetryMaxAttempts(const mesh::Packet* packet) const {
if (!_cli.batteryAllowsTransmit(packet)) return 0;
if (_prefs.disable_fwd) {
return 0;
}
@@ -4412,7 +4402,7 @@ void MyMesh::checkBatteryAlert() {
}
uint16_t batt_mv = board.getBattMilliVolts();
uint8_t batt_pct = batteryPercentFromMilliVolts(batt_mv);
uint8_t batt_pct = _cli.batteryPercent(batt_mv);
if (batt_mv <= LOW_BATTERY_MIN_VALID_MV || batt_pct >= _prefs.battery_alert_low_percent) {
return;
}
+9
View File
@@ -1658,6 +1658,7 @@ uint32_t MyMesh::getDirectRetransmitDelay(const mesh::Packet *packet) {
bool MyMesh::allowDirectRetry(const mesh::Packet* packet, const uint8_t* next_hop_hash,
uint8_t next_hop_hash_len) const {
if (!_cli.batteryAllowsTransmit(packet)) return false;
if (_cli.morseReply().ownsReply(packet)) return false;
(void)packet;
(void)next_hop_hash;
@@ -1691,6 +1692,7 @@ uint32_t MyMesh::getDirectRetryAttemptDelay(const mesh::Packet* packet, uint8_t
}
bool MyMesh::allowFloodRetry(const mesh::Packet* packet) const {
if (!_cli.batteryAllowsTransmit(packet)) return false;
if (_cli.morseReply().ownsReply(packet)) return false;
if (_prefs.disable_fwd || _prefs.flood_retry_attempts == 0) return false;
if (packet != NULL
@@ -1712,6 +1714,7 @@ uint8_t MyMesh::getFloodRetryMaxPathLength(const mesh::Packet* packet) const {
}
uint8_t MyMesh::getFloodRetryMaxAttempts(const mesh::Packet* packet) const {
if (!_cli.batteryAllowsTransmit(packet)) return 0;
if (_prefs.disable_fwd) return 0;
uint8_t attempts = constrain(_prefs.flood_retry_attempts, 0, 15);
@@ -1731,6 +1734,7 @@ void MyMesh::onRetryConfigChanged() {
}
bool MyMesh::allowPacketForward(const mesh::Packet *packet) {
if (!packet || !_cli.batteryAllowsForward(packet->isRouteFlood())) return false;
if (_prefs.disable_fwd) return false;
if (packet->isRouteFlood()) {
if (mesh::isFloodHopLimitExceeded(packet, _prefs.flood_max,
@@ -1767,6 +1771,11 @@ bool MyMesh::allowPacketForward(const mesh::Packet *packet) {
return true;
}
bool MyMesh::allowPacketTransmit(const mesh::Packet* packet) const {
return _cli.batteryAllowsTransmit(packet)
&& mesh::Mesh::allowPacketTransmit(packet);
}
mesh::DispatcherAction MyMesh::onRecvPacket(mesh::Packet* pkt) {
#if MESH_ENABLE_ROOM_FLOOD_RULE_ENGINE
bool scope_changed = false;
+1
View File
@@ -460,6 +460,7 @@ protected:
mesh::DispatcherAction onRecvPacket(mesh::Packet* pkt) override;
bool allowPacketForward(const mesh::Packet* packet) override;
bool allowPacketTransmit(const mesh::Packet* packet) const override;
void onAnonDataRecv(mesh::Packet* packet, const uint8_t* secret, const mesh::Identity& sender, uint8_t* data, size_t len) override;
int searchPeersByHash(const uint8_t* hash) override ;
void getPeerSharedSecret(uint8_t* dest_secret, int peer_idx) override;
+9
View File
@@ -442,6 +442,7 @@ bool SensorMesh::getCADEnabled() const {
}
bool SensorMesh::allowPacketForward(const mesh::Packet* packet) {
if (!packet || !_cli.batteryAllowsForward(packet->isRouteFlood())) return false;
if (isTrackerModeEnabled()) return false;
if (_prefs.disable_fwd) return false;
if (packet->isRouteFlood() && packet->getPathHashCount() >= _prefs.flood_max) return false;
@@ -449,6 +450,11 @@ bool SensorMesh::allowPacketForward(const mesh::Packet* packet) {
return true;
}
bool SensorMesh::allowPacketTransmit(const mesh::Packet* packet) const {
return _cli.batteryAllowsTransmit(packet)
&& mesh::Mesh::allowPacketTransmit(packet);
}
int SensorMesh::calcRxDelay(float score, uint32_t air_time) const {
if (_prefs.rx_delay_base <= 0.0f) return 0;
return (int) ((powf(_prefs.rx_delay_base, 0.85f - score) - 1.0f) * air_time);
@@ -467,6 +473,7 @@ uint32_t SensorMesh::getDirectRetransmitDelay(const mesh::Packet* packet) {
bool SensorMesh::allowDirectRetry(const mesh::Packet* packet, const uint8_t* next_hop_hash,
uint8_t next_hop_hash_len) const {
if (!_cli.batteryAllowsTransmit(packet)) return false;
if (_cli.morseReply().ownsReply(packet)) return false;
#if MESH_ENABLE_SENSOR_TRACKER
if (tracker_pending && packet != NULL) {
@@ -507,6 +514,7 @@ uint32_t SensorMesh::getDirectRetryAttemptDelay(const mesh::Packet* packet, uint
}
bool SensorMesh::allowFloodRetry(const mesh::Packet* packet) const {
if (!_cli.batteryAllowsTransmit(packet)) return false;
if (_cli.morseReply().ownsReply(packet)) return false;
if (packet != NULL && packet->flood_retry_policy == mesh::FLOOD_RETRY_POLICY_DENY) return false;
if (_prefs.disable_fwd || _prefs.flood_retry_attempts == 0) return false;
@@ -525,6 +533,7 @@ uint8_t SensorMesh::getFloodRetryMaxPathLength(const mesh::Packet* packet) const
}
uint8_t SensorMesh::getFloodRetryMaxAttempts(const mesh::Packet* packet) const {
if (!_cli.batteryAllowsTransmit(packet)) return 0;
if (_prefs.disable_fwd) return 0;
uint8_t attempts = constrain(_prefs.flood_retry_attempts, 0, 15);
+1
View File
@@ -190,6 +190,7 @@ protected:
// Mesh overrides
float getAirtimeBudgetFactor() const override;
bool allowPacketForward(const mesh::Packet* packet) override;
bool allowPacketTransmit(const mesh::Packet* packet) const override;
int calcRxDelay(float score, uint32_t air_time) const override;
uint32_t getRetransmitDelay(const mesh::Packet* packet) override;
uint32_t getDirectRetransmitDelay(const mesh::Packet* packet) override;
+3
View File
@@ -991,6 +991,7 @@ bool Dispatcher::tryParsePacket(Packet* pkt, const uint8_t* raw, int len) {
pkt->radio_local = false;
pkt->tx_radio = RADIO_TX_AUTO;
pkt->radio_reply = pkt->radio_reply_force = false;
pkt->forwarded = false;
#if defined(WITH_UDP_BRIDGE)
pkt->udp_bridge_ingress = false;
#endif
@@ -1193,6 +1194,7 @@ void Dispatcher::processRecvPacket(Packet* pkt) {
} else if (action == ACTION_MANUAL_HOLD) {
// sub-class is wanting to manually hold Packet instance, and call releasePacket() at appropriate time
} else { // ACTION_RETRANSMIT*
pkt->forwarded = true;
uint8_t priority = (action >> 24) - 1;
uint32_t _delay = action & 0xFFFFFF;
@@ -1371,6 +1373,7 @@ Packet* Dispatcher::obtainNewPacket() {
pkt->tx_radio = RADIO_TX_AUTO;
pkt->radio_reply = receive_context_active;
pkt->radio_reply_force = false;
pkt->forwarded = false;
#if defined(WITH_UDP_BRIDGE)
pkt->udp_bridge_ingress = false;
#endif
+6
View File
@@ -1113,6 +1113,7 @@ DispatcherAction Mesh::onRecvPacket(Packet* pkt) {
&& self_id.isHashMatch(&pkt->payload[i + offset], hash_size)
&& allowPacketForward(pkt) && !_tables->wasSeen(pkt)) {
_tables->markSeen(pkt);
pkt->forwarded = true;
// append SNR (Not hash!)
pkt->path[pkt->path_len++] = (int8_t) (pkt->getSNR()*4);
@@ -1158,6 +1159,7 @@ DispatcherAction Mesh::onRecvPacket(Packet* pkt) {
removePathPrefix(pkt, pkt->getPathHashCount());
} else if (self_id.isHashMatch(pkt->path, pkt->getPathHashSize()) || maybeShortCircuitDirect(pkt)) {
if (allowPacketForward(pkt)) {
pkt->forwarded = true;
const uint32_t d = getDirectRetransmitDelay(pkt);
if (pkt->getPayloadType() == PAYLOAD_TYPE_MULTIPART) {
return forwardMultipartDirect(pkt, d);
@@ -1506,6 +1508,7 @@ DispatcherAction Mesh::onRecvPacket(Packet* pkt) {
&& (n + 1) * pkt->getPathHashSize() <= MAX_PATH_SIZE
&& (primary_ota || _mgr->getFreeCount() > OTA_FWD_MIN_FREE)
&& allowPacketForward(pkt) && allowOtaForwardRetry(pkt)) {
pkt->forwarded = true;
rememberOtaForward(pkt); // record incoming hop count, before appending ourselves
self_id.copyHashTo(&pkt->path[n * pkt->getPathHashSize()], pkt->getPathHashSize());
pkt->setPathHashCount(n + 1);
@@ -1562,6 +1565,7 @@ DispatcherAction Mesh::routeRecvPacket(Packet* packet) {
}
}
if (!allowPacketForward(packet)) return ACTION_RELEASE;
packet->forwarded = true;
if (limiter) limiter->commit(packet->payload, hash, now);
// append this node's hash to 'path'
self_id.copyHashTo(&packet->path[n * packet->getPathHashSize()], packet->getPathHashSize());
@@ -1606,6 +1610,7 @@ void Mesh::routeDirectRecvAcks(Packet* packet, uint32_t delay_millis) {
auto a1 = createMultiAck(packet->payload, packet->payload_len, extra);
if (a1) {
a1->radio_reply = false; // relayed ACK, not a local response
a1->forwarded = true;
a1->path_len = Packet::copyPath(a1->path, packet->path, packet->path_len);
a1->header &= ~PH_ROUTE_MASK;
a1->header |= ROUTE_TYPE_DIRECT;
@@ -1619,6 +1624,7 @@ void Mesh::routeDirectRecvAcks(Packet* packet, uint32_t delay_millis) {
auto a2 = createAck(packet->payload, packet->payload_len);
if (a2) {
a2->radio_reply = false; // relayed ACK, not a local response
a2->forwarded = true;
a2->path_len = Packet::copyPath(a2->path, packet->path, packet->path_len);
a2->header &= ~PH_ROUTE_MASK;
a2->header |= ROUTE_TYPE_DIRECT;
+2
View File
@@ -17,6 +17,7 @@ Packet::Packet() {
radio_bound = false; radio_local = true;
tx_radio = RADIO_TX_AUTO;
radio_reply = radio_reply_force = false;
forwarded = false;
#if defined(WITH_UDP_BRIDGE)
udp_bridge_ingress = false;
#endif
@@ -88,6 +89,7 @@ bool Packet::readFrom(const uint8_t src[], uint8_t len) {
header = src[i++];
tx_radio = RADIO_TX_AUTO;
radio_reply = radio_reply_force = false;
forwarded = false;
#if defined(WITH_UDP_BRIDGE)
udp_bridge_ingress = false;
#endif
+1
View File
@@ -60,6 +60,7 @@ public:
int8_t _snr;
uint8_t tx_cr; // volatile local-only TX coding-rate override; not serialized
uint8_t flood_retry_policy; // volatile receive-policy result; not serialized
bool forwarded = false; // local relay ownership, copied into retries; never serialized
uint32_t radio_generation = 0;
uint32_t radio_origin_generation = 0;
uint8_t radio_profile = 0;
+1
View File
@@ -22,6 +22,7 @@ protected:
packet->radio_local = false;
packet->tx_radio = mesh::RADIO_TX_AUTO;
packet->radio_reply = packet->radio_reply_force = false;
packet->forwarded = true;
return true;
}
+188
View File
@@ -0,0 +1,188 @@
#pragma once
#include <stddef.h>
#include <stdint.h>
#include <stdio.h>
#include <string.h>
namespace mesh { namespace batteryforward {
static constexpr uint8_t Repeat = 1;
static constexpr uint8_t Flood = 2;
static constexpr uint32_t SampleIntervalMs = 30000;
static constexpr size_t RecordSize = 16;
struct Settings {
uint8_t enabled = 0;
uint8_t repeatBelow = 10;
uint8_t floodBelow = 20;
// Zero keeps the board's compiled endpoints. nRF52 uses its voltage policy.
uint16_t emptyMv = 0;
uint16_t fullMv = 0;
};
inline bool validEndpoints(uint16_t empty, uint16_t full) {
return empty >= 2000 && full <= 20000 && full >= empty + 100U;
}
inline bool validSettings(const Settings& s) {
return (s.enabled & ~(Repeat | Flood)) == 0
&& s.repeatBelow >= 1 && s.repeatBelow <= 100
&& s.floodBelow >= 1 && s.floodBelow <= 100
&& ((s.emptyMv == 0 && s.fullMv == 0) || validEndpoints(s.emptyMv, s.fullMv));
}
inline uint16_t checksum(const uint8_t* data, size_t size) {
uint16_t crc = 0xffff;
while (size--) {
crc ^= uint16_t(*data++) << 8;
for (unsigned bit = 0; bit < 8; ++bit)
crc = (crc & 0x8000) ? uint16_t((crc << 1) ^ 0x1021) : uint16_t(crc << 1);
}
return crc;
}
inline void encode(uint8_t* out, const Settings& s) {
memset(out, 0, RecordSize);
out[0] = 'B'; out[1] = 'F'; out[2] = 'R'; out[3] = 1;
out[4] = s.enabled; out[5] = s.repeatBelow; out[6] = s.floodBelow;
out[8] = uint8_t(s.emptyMv); out[9] = uint8_t(s.emptyMv >> 8);
out[10] = uint8_t(s.fullMv); out[11] = uint8_t(s.fullMv >> 8);
const uint16_t crc = checksum(out, 14);
out[14] = uint8_t(crc); out[15] = uint8_t(crc >> 8);
}
inline bool decode(const uint8_t* in, size_t size, Settings& s) {
s = Settings{};
if (!in || size != RecordSize || in[0] != 'B' || in[1] != 'F'
|| in[2] != 'R' || in[3] != 1 || in[7] || in[12] || in[13]
|| checksum(in, 14) != uint16_t(in[14] | (uint16_t(in[15]) << 8))) return false;
Settings candidate;
candidate.enabled = in[4]; candidate.repeatBelow = in[5]; candidate.floodBelow = in[6];
candidate.emptyMv = uint16_t(in[8] | (uint16_t(in[9]) << 8));
candidate.fullMv = uint16_t(in[10] | (uint16_t(in[11]) << 8));
if (!validSettings(candidate)) return false;
s = candidate;
return true;
}
inline uint8_t percent(uint16_t mv, uint16_t empty, uint16_t full) {
if (full <= empty || mv >= full) return 100;
if (mv <= empty) return 0;
return uint8_t(uint32_t(mv - empty) * 100U / (full - empty));
}
struct State {
uint32_t sampledAt = 0;
uint16_t millivolts = 0;
uint8_t percentage = 0;
bool sampled = false;
bool valid = false;
bool repeatBlocked = false;
bool floodBlocked = false;
bool due(uint32_t now) const {
return !sampled || uint32_t(now - sampledAt) >= SampleIntervalMs;
}
static bool blocked(bool was, bool enabled, bool known, uint8_t pct, uint8_t below) {
if (!enabled || !known) return false;
const unsigned resume = unsigned(below) + 2U > 100U ? 100U : unsigned(below) + 2U;
return was ? pct < resume : pct < below;
}
void update(uint32_t now, uint16_t mv, uint8_t pct, bool known, const Settings& s) {
sampled = true; sampledAt = now; millivolts = mv; percentage = pct;
valid = known && pct <= 100 && mv >= 2000 && mv <= 20000;
repeatBlocked = blocked(repeatBlocked, s.enabled & Repeat, valid, pct, s.repeatBelow);
floodBlocked = blocked(floodBlocked, s.enabled & Flood, valid, pct, s.floodBelow);
}
bool allows(bool flood) const { return !repeatBlocked && (!flood || !floodBlocked); }
};
inline bool parseNumber(const char* text, unsigned min, unsigned max, uint16_t& value) {
if (!text || !*text) return false;
unsigned number = 0;
for (const char* p = text; *p; ++p) {
if (*p < '0' || *p > '9') return false;
number = number * 10U + unsigned(*p - '0');
if (number > max) return false;
}
if (number < min) return false;
value = uint16_t(number);
return true;
}
// The save callback receives a candidate, so a failed save cannot change the
// active policy. Sampling remains a separate hardware boundary in CommonCLI.
template <typename Save>
bool handleCommand(Settings& s, const State& state, uint16_t empty, uint16_t full,
bool externalVoltagePolicy, const char* command,
char* reply, size_t capacity, Save save) {
if (!command || !reply || !capacity) return false;
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;
enum Kind { None, RepeatLimit, FloodLimit, Status, Empty, Full } kind = None;
size_t length = 0;
const char* names[] = {"repeat.battery", "flood.battery", "battery.forward", "battery.empty", "battery.full"};
for (unsigned i = 0; i < 5; ++i) {
const size_t n = strlen(names[i]);
if (strncmp(key, names[i], n) == 0 && (!key[n] || key[n] == ' ' || key[n] == '\t')) {
kind = Kind(i + 1); length = n; break;
}
}
if (kind == None || (externalVoltagePolicy && (kind == Empty || kind == Full))) return false;
const char* value = key + length;
while (*value == ' ' || *value == '\t') ++value;
if (get) {
if (*value) snprintf(reply, capacity, "Error: usage get %.*s", int(length), key);
else if (kind == RepeatLimit || kind == FloodLimit) {
const bool repeat = kind == RepeatLimit;
snprintf(reply, capacity, "> %s below=%u%%", s.enabled & (repeat ? Repeat : Flood) ? "on" : "off",
unsigned(repeat ? s.repeatBelow : s.floodBelow));
} else if (kind == Empty || kind == Full) {
snprintf(reply, capacity, "> %u", unsigned(kind == Empty ? empty : full));
} else {
char battery[12] = "unknown";
if (state.valid) snprintf(battery, sizeof(battery), "%u%%", unsigned(state.percentage));
snprintf(reply, capacity, "> battery=%s mv=%u repeat=%s flood=%s", battery,
unsigned(state.millivolts), state.allows(false) ? "allowed" : "blocked",
state.allows(true) ? "allowed" : "blocked");
}
return true;
}
if (kind == Status) {
snprintf(reply, capacity, "Error: usage get battery.forward");
return true;
}
Settings candidate = s;
uint16_t number = 0;
if (kind == RepeatLimit || kind == FloodLimit) {
const uint8_t bit = kind == RepeatLimit ? Repeat : Flood;
if (strcmp(value, "off") == 0) candidate.enabled &= ~bit;
else if (strcmp(value, "on") == 0) candidate.enabled |= bit;
else if (parseNumber(value, 1, 100, number)) {
candidate.enabled |= bit;
(kind == RepeatLimit ? candidate.repeatBelow : candidate.floodBelow) = uint8_t(number);
} else {
snprintf(reply, capacity, "Error: use set %.*s off|on|1-100", int(length), key);
return true;
}
} else {
if (!parseNumber(value, 2000, 20000, number)) {
snprintf(reply, capacity, "Error: use set %.*s 2000-20000 (mV)", int(length), key);
return true;
}
candidate.emptyMv = kind == Empty ? number : empty;
candidate.fullMv = kind == Full ? number : full;
if (!validEndpoints(candidate.emptyMv, candidate.fullMv)) {
snprintf(reply, capacity, "Error: battery.full must exceed battery.empty by at least 100 mV");
return true;
}
}
if (!save(candidate)) snprintf(reply, capacity, "Error: battery settings save unconfirmed; active policy unchanged");
else { s = candidate; snprintf(reply, capacity, "OK - saved"); }
return true;
}
}} // namespace mesh::batteryforward
+8 -1
View File
@@ -186,6 +186,7 @@ static bool isGpioConfig(const char* config) {
#endif
void CommonCLI::loop() {
loopBatteryForwarding();
#if MESH_ENABLE_MORSE_RX
if (_morse_reply.pending()) {
const ClientInfo* actor = _acl->getClient(_morse_reply.actor(), PUB_KEY_SIZE);
@@ -800,6 +801,7 @@ static void formatSnrDbX4Short(char* dest, size_t dest_len, int16_t snr_x4) {
}
void CommonCLI::loadPrefs(FILESYSTEM* fs) {
loadBatteryForwarding(fs);
mesh::hilStartupTrace("prefs_profiles_begin");
_radio_profiles.begin(fs, _callbacks->getProfileRadio(), _rtc, true);
#if MESH_ENABLE_CARRIER_TOOLS || MESH_ENABLE_MORSE_RX
@@ -3258,6 +3260,7 @@ bool CommonCLI::handleUDPBridgeCommand(uint32_t sender_timestamp,
void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* reply) {
PrefsSaveReplyGuard save_reply(_prefs_save_failures, reply);
mesh::cli::normalizeCommandVerb(command);
if (handleBatteryForwardingCommand(command, reply)) return;
#if defined(WITH_UDP_BRIDGE)
if (handleUDPBridgeCommand(sender_timestamp, command, reply)) return;
#endif
@@ -4220,7 +4223,10 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep
return;
}
#if defined(NRF52_POWER_MANAGEMENT)
if (mesh::power::handleVoltagePolicyCommand(command, reply, 160)) return;
if (mesh::power::handleVoltagePolicyCommand(command, reply, 160)) {
if (strncmp(reply, "OK", 2) == 0) loopBatteryForwarding(true);
return;
}
#endif
// Infrastructure roles dispatch here rather than through CommonRadioPrefs.
if (strncmp(config, "path.hash.mode", 14) == 0
@@ -5710,6 +5716,7 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep
} else if (_board->setAdcMultiplier(multiplier)) {
_prefs->adc_multiplier = multiplier;
savePrefs();
loopBatteryForwarding(true);
if (_prefs->adc_multiplier == 0.0f) {
strcpy(reply, "OK - using default board multiplier");
} else {
+13
View File
@@ -17,6 +17,7 @@
#include <helpers/MorseReplyHandoff.h>
#include <helpers/WirelessControl.h>
#include <helpers/FleetCommand.h>
#include <helpers/BatteryForwarding.h>
#if defined(WITH_UDP_BRIDGE)
#include <helpers/UDPBridgePrefs.h>
#endif
@@ -871,6 +872,15 @@ class CommonCLI {
NodePrefs* _prefs;
CommonCLICallbacks* _callbacks;
mesh::MainBoard* _board;
FILESYSTEM* _battery_forward_fs = nullptr;
mesh::batteryforward::Settings _battery_forward_settings;
mutable mesh::batteryforward::State _battery_forward_state;
void loadBatteryForwarding(FILESYSTEM* fs);
bool saveBatteryForwarding(const mesh::batteryforward::Settings& settings);
void loopBatteryForwarding(bool force = false) const;
bool handleBatteryForwardingCommand(const char* command, char* reply);
uint16_t batteryEmptyMv() const;
uint16_t batteryFullMv() const;
#if defined(WITH_ESPNOW_BRIDGE) \
|| (defined(WITH_MQTT_BRIDGE) && defined(LIGHTWEIGHT_WIFI_OTA))
// Browser OTA restores only transports it actually paused, not saved
@@ -1009,6 +1019,9 @@ public:
FILESYSTEM* _fs,
PrefsSaveRouting::Scope scope = PrefsSaveRouting::Scope::Both);
void loop();
bool batteryAllowsForward(bool flood) const;
bool batteryAllowsTransmit(const mesh::Packet* packet) const;
uint8_t batteryPercent(uint16_t mv) const;
bool hasActiveUserGpioTimer() const;
void handleCommand(uint32_t sender_timestamp, char* command, char* reply);
mesh::MainBoard* getBoard() { return _board; }
+132
View File
@@ -0,0 +1,132 @@
#include "CommonCLI.h"
#include "CheckedSettingsFile.h"
#include <MeshCore.h>
#if defined(NRF52_PLATFORM) && defined(NRF52_POWER_MANAGEMENT)
#include "NRF52VoltagePolicy.h"
#endif
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
#include "AtomicFileWriter.h"
#else
#include "ContactFileTransaction.h"
#endif
namespace {
const char* const BATTERY_FORWARD_PATH = "/battery_forward";
bool readBatteryForwarding(FILESYSTEM* fs, const char* path,
mesh::batteryforward::Settings& settings) {
uint8_t bytes[mesh::batteryforward::RecordSize];
return mesh::storage::readFixedSettingsFile(fs, path, bytes, sizeof(bytes))
&& mesh::batteryforward::decode(bytes, sizeof(bytes), settings);
}
}
void CommonCLI::loadBatteryForwarding(FILESYSTEM* fs) {
_battery_forward_fs = fs;
_battery_forward_settings = mesh::batteryforward::Settings{};
_battery_forward_state = mesh::batteryforward::State{};
#if defined(ESP32_PLATFORM) || defined(RP2040_PLATFORM)
if (!mesh::ContactFileTransaction::recover(fs, BATTERY_FORWARD_PATH)) {
_battery_forward_fs = nullptr;
return;
}
#endif
mesh::batteryforward::Settings loaded;
if (readBatteryForwarding(fs, BATTERY_FORWARD_PATH, loaded)
|| readBatteryForwarding(fs, "/battery_forward.bak", loaded))
_battery_forward_settings = loaded;
}
bool CommonCLI::saveBatteryForwarding(const mesh::batteryforward::Settings& settings) {
if (!_battery_forward_fs || !mesh::batteryforward::validSettings(settings)) return false;
uint8_t bytes[mesh::batteryforward::RecordSize];
mesh::batteryforward::encode(bytes, settings);
#if defined(NRF52_PLATFORM)
return mesh::storage::writeCheckedSettingsFile(_battery_forward_fs, BATTERY_FORWARD_PATH,
bytes, sizeof(bytes)) == mesh::AtomicFileWriter::Outcome::Committed;
#else
#if defined(STM32_PLATFORM)
mesh::AtomicFileWriter writer(_battery_forward_fs, BATTERY_FORWARD_PATH);
#else
mesh::ContactFileTransaction writer(_battery_forward_fs, BATTERY_FORWARD_PATH);
#endif
if (!writer || writer.write(bytes, sizeof(bytes)) != sizeof(bytes) || !writer.commit()) return false;
uint8_t verified[sizeof(bytes)];
return mesh::storage::readFixedSettingsFile(_battery_forward_fs, BATTERY_FORWARD_PATH,
verified, sizeof(verified)) && memcmp(bytes, verified, sizeof(bytes)) == 0;
#endif
}
uint16_t CommonCLI::batteryEmptyMv() const {
#if defined(NRF52_PLATFORM) && defined(NRF52_POWER_MANAGEMENT)
return mesh::power::configuredEmpty();
#else
#ifdef BATT_MIN_MILLIVOLTS
const uint16_t compiled = BATT_MIN_MILLIVOLTS;
#else
const uint16_t compiled = 3000;
#endif
return _battery_forward_settings.emptyMv ? _battery_forward_settings.emptyMv : compiled;
#endif
}
uint16_t CommonCLI::batteryFullMv() const {
#if defined(NRF52_PLATFORM) && defined(NRF52_POWER_MANAGEMENT)
return mesh::power::configuredFull();
#else
#ifdef BATT_MAX_MILLIVOLTS
const uint16_t compiled = BATT_MAX_MILLIVOLTS;
#else
const uint16_t compiled = 4200;
#endif
return _battery_forward_settings.fullMv ? _battery_forward_settings.fullMv : compiled;
#endif
}
uint8_t CommonCLI::batteryPercent(uint16_t mv) const {
return mesh::batteryforward::percent(mv, batteryEmptyMv(), batteryFullMv());
}
bool CommonCLI::batteryAllowsTransmit(const mesh::Packet* packet) const {
// Explicit relay ownership survives path stripping and retry clones. Own
// replies and OTA session traffic are not inferred from radio routing flags.
return !packet || !packet->forwarded || batteryAllowsForward(packet->isRouteFlood());
}
bool CommonCLI::batteryAllowsForward(bool flood) const {
// Receive/transmit admission can run before CommonCLI::loop(), including
// the first packet after boot or a long MCU sleep. Never use a stale gate.
loopBatteryForwarding();
return _battery_forward_state.allows(flood);
}
void CommonCLI::loopBatteryForwarding(bool force) const {
if (!force && !_battery_forward_settings.enabled) return;
const uint32_t now = millis();
if (!force && !_battery_forward_state.due(now)) return;
const uint16_t mv = _board->getBattMilliVolts();
_battery_forward_state.update(now, mv, batteryPercent(mv),
mesh::batteryforward::validEndpoints(batteryEmptyMv(), batteryFullMv()),
_battery_forward_settings);
}
bool CommonCLI::handleBatteryForwardingCommand(const char* command, char* reply) {
if (strncmp(command, "get battery.forward", 19) == 0) {
const char* tail = command + 19;
while (*tail == ' ' || *tail == '\t') ++tail;
if (!*tail) loopBatteryForwarding(true);
}
bool saved = false;
const bool handled = mesh::batteryforward::handleCommand(_battery_forward_settings,
_battery_forward_state, batteryEmptyMv(), batteryFullMv(),
#if defined(NRF52_PLATFORM) && defined(NRF52_POWER_MANAGEMENT)
true,
#else
false,
#endif
command, reply, 157, [this, &saved](const mesh::batteryforward::Settings& candidate) {
return saved = saveBatteryForwarding(candidate);
});
if (saved) loopBatteryForwarding(true);
return handled;
}
+1
View File
@@ -37,6 +37,7 @@ python3 test/test_companion_pairing_display.py # real OLED pairing wake, USB coe
python3 test/test_companion_identity_startup.py # real saved/missing/unreadable identity branch and truthful startup phases
python3 test/test_startup_screen.py # early display ordering and cooperative entropy progress
python3 -B test/test_common_prefs_legacy_rxgain.py -v # compact stock gain, appended tails and truncation
python3 -B test/test_battery_forwarding.py -v # battery relay thresholds, hysteresis, calibration and checked settings records
python3 test/test_companion_settings_persistence_contract.py # Atomic settings and appended Bluetooth fields
python3 test/test_webconfig_ui.py # Web controls, reboot handling and generated-page consistency
python3 test/test_webconfig_ui_runtime.py # Real Chromium, including independent stealth toggle
+1
View File
@@ -25,6 +25,7 @@ struct CommonCLI {
struct Profiles { unsigned loops = 0; void loop() { ++loops; } } _radio_profiles;
unsigned management_loops = 0;
CommonCLI(ACL& acl, Callbacks& callbacks) : _acl(&acl), _callbacks(&callbacks) {}
void loopBatteryForwarding(bool = false) {}
void loopManagement() { ++management_loops; }
void loop();
};
+2
View File
@@ -169,6 +169,8 @@ public:
FakeTrigger* alert_tasks[4] = {&alert1, &alert2};
int num_alert_tasks = 2;
struct CLI {
bool batteryAllowsForward(bool) const { return true; }
bool batteryAllowsTransmit(const mesh::Packet*) const { return true; }
struct NoMorseReply { void complete(const void*) {} void fail(const void*) {} } morse;
NoMorseReply& morseReply() { return morse; }
bool active = false;
+680
View File
@@ -0,0 +1,680 @@
"""Run the production battery forwarding policy, CLI and record under sanitizers."""
from pathlib import Path
import os
import subprocess
import tempfile
import unittest
from test_replay_reset_integration import extract_braced
ROOT = Path(__file__).resolve().parents[1]
PREFIX = r'''
#include <helpers/BatteryForwarding.h>
#include <cassert>
#include <cstring>
#include <initializer_list>
#include <string>
#include <vector>
using namespace mesh::batteryforward;
static bool same(const Settings& a, const Settings& b) {
return a.enabled == b.enabled && a.repeatBelow == b.repeatBelow
&& a.floodBelow == b.floodBelow && a.emptyMv == b.emptyMv && a.fullMv == b.fullMv;
}
struct CLI {
Settings settings;
State state;
Settings saved;
bool saveOk = true;
bool external = false;
unsigned saveCalls = 0;
uint16_t empty = 3000, full = 4200;
char reply[192] = {};
bool run(const char* command) {
strcpy(reply, "untouched");
return handleCommand(settings, state, empty, full, external, command,
reply, sizeof(reply), [&](const Settings& candidate) {
++saveCalls; saved = candidate; return saveOk;
});
}
void ok(const char* command) {
const unsigned before = saveCalls;
assert(run(command));
assert(strcmp(reply, "OK - saved") == 0 && saveCalls == before + 1);
assert(same(settings, saved));
}
void error(const char* command) {
const Settings before = settings;
const unsigned calls = saveCalls;
assert(run(command));
assert(strncmp(reply, "Error:", 6) == 0);
assert(same(settings, before) && saveCalls == calls);
}
};
'''
class BatteryForwardingTests(unittest.TestCase):
def exercise(self, body, prefix=PREFIX, flags=()):
with tempfile.TemporaryDirectory(prefix="battery-forwarding-") as folder:
work = Path(folder)
source, binary = work / "main.cpp", work / "check"
source.write_text(prefix + "\nint main() {\n" + body + "\n}\n", encoding="ascii")
command = [os.environ.get("CXX", "g++"), "-std=c++17", "-O1", "-g",
"-Wall", "-Wextra", "-Werror", "-Wno-unused-function",
"-fsanitize=address,undefined", "-fno-sanitize-recover=all",
"-fno-omit-frame-pointer", "-fno-pie", "-no-pie",
*flags, "-I", str(ROOT / "src"), str(source), "-o", str(binary)]
built = subprocess.run(command, capture_output=True, text=True, timeout=60)
self.assertEqual(built.returncode, 0, built.stdout + built.stderr)
result = subprocess.run([str(binary)], capture_output=True, text=True, timeout=30)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
def runtime_prefix(self, extra_members=""):
"""Keep the production admission/sampling/CLI methods; fake only board/save boundaries."""
source = (ROOT / "src/helpers/CommonCLI_Battery.cpp").read_text(encoding="ascii")
methods = "\n".join(extract_braced(source, signature) for signature in (
"uint16_t CommonCLI::batteryEmptyMv(", "uint16_t CommonCLI::batteryFullMv(",
"uint8_t CommonCLI::batteryPercent(", "bool CommonCLI::batteryAllowsTransmit(",
"bool CommonCLI::batteryAllowsForward(", "void CommonCLI::loopBatteryForwarding(",
"bool CommonCLI::handleBatteryForwardingCommand("))
return PREFIX + r'''
static uint32_t nowMs = 0;
static uint32_t millis() { return nowMs; }
namespace mesh {
struct Packet {
bool forwarded = false, flood = false;
bool isRouteFlood() const { return flood; }
};
namespace power {
static uint16_t configuredEmptyMv = 6000, configuredFullMv = 8400;
static uint16_t configuredEmpty() { return configuredEmptyMv; }
static uint16_t configuredFull() { return configuredFullMv; }
}
}
struct Board {
uint16_t mv = 3000;
unsigned reads = 0;
float multiplier = 1.0f;
bool adcSupported = true;
uint16_t getBattMilliVolts() { ++reads; return uint16_t(float(mv) * multiplier); }
bool setAdcMultiplier(float value) {
if (!adcSupported) return false;
multiplier = value == 0.0f ? 1.0f : value;
return true;
}
};
struct CommonCLI {
Board* _board;
Settings _battery_forward_settings;
mutable State _battery_forward_state;
bool saveOk = true;
unsigned saveCalls = 0;
Settings saved;
explicit CommonCLI(Board& board) : _board(&board) {}
bool saveBatteryForwarding(const Settings& settings) {
++saveCalls; saved = settings; return saveOk;
}
uint16_t batteryEmptyMv() const;
uint16_t batteryFullMv() const;
uint8_t batteryPercent(uint16_t mv) const;
bool batteryAllowsTransmit(const mesh::Packet* packet) const;
bool batteryAllowsForward(bool flood) const;
void loopBatteryForwarding(bool force = false) const;
bool handleBatteryForwardingCommand(const char* command, char* reply);
EXTRA_MEMBERS
};
'''.replace("EXTRA_MEMBERS", extra_members) + methods
def test_actual_set_command_voltage_policy_branch_refreshes_profile_endpoints_and_calibration(self):
source = (ROOT / "src/helpers/CommonCLI.cpp").read_text(encoding="ascii")
setter = extract_braced(source, "void CommonCLI::handleSetCmd(")
branch = extract_braced(setter, "if (mesh::power::handleVoltagePolicyCommand(")
prefix = self.runtime_prefix("void dispatchVoltage(char* command, char* reply);")
prefix += r'''
namespace mesh { namespace power {
static bool voltageSaveOk = true;
static unsigned voltageSaves = 0;
static Board* calibratedBoard = nullptr;
// This is the existing voltage-policy parser/save boundary. The dispatch block
// and battery refresh below are extracted unchanged from production.
static bool handleVoltagePolicyCommand(const char* command, char* reply, size_t capacity) {
uint16_t empty = configuredEmptyMv, full = configuredFullMv;
float calibration = calibratedBoard->multiplier;
if (strcmp(command, "set battery.profile lifepo4") == 0) { empty = 2700; full = 3550; }
else if (strcmp(command, "set battery.full 4200") == 0) full = 4200;
else if (strcmp(command, "set battery.empty 3300") == 0) empty = 3300;
else if (strcmp(command, "set adc.multiplier 1.2") == 0) calibration = 1.2f;
else if (strcmp(command, "set adc.multiplier 1.0") == 0) calibration = 1.0f;
else return false;
++voltageSaves;
if (!voltageSaveOk) snprintf(reply, capacity, "Error: voltage setting could not be saved");
else {
configuredEmptyMv = empty; configuredFullMv = full;
calibratedBoard->multiplier = calibration;
snprintf(reply, capacity, "OK - saved");
}
return true;
}
}}
void CommonCLI::dispatchVoltage(char* command, char* reply) {
''' + branch + r'''
strcpy(reply, "fallthrough");
}
'''
self.exercise(r'''
Board board;
board.mv = 3500;
CommonCLI cli(board);
mesh::power::calibratedBoard = &board;
mesh::power::configuredEmptyMv = 3000;
mesh::power::configuredFullMv = 4200;
cli._battery_forward_settings.enabled = Repeat;
cli._battery_forward_settings.repeatBelow = 60;
nowMs = 100;
assert(!cli.batteryAllowsForward(false) && board.reads == 1);
assert(cli._battery_forward_state.percentage == 41);
char reply[160];
auto dispatch = [&](const char* text) {
char command[80]; strcpy(command, text);
nowMs += 1; // Every command is much earlier than the 30-second sample expiry.
cli.dispatchVoltage(command, reply);
assert(strncmp(reply, "OK", 2) == 0);
};
dispatch("set battery.profile lifepo4");
assert(board.reads == 2 && cli._battery_forward_state.percentage == 94);
assert(cli._battery_forward_state.sampledAt == nowMs && cli.batteryAllowsForward(false));
dispatch("set battery.full 4200");
assert(board.reads == 3 && cli._battery_forward_state.percentage == 53);
assert(!cli.batteryAllowsForward(false));
dispatch("set battery.empty 3300");
assert(board.reads == 4 && cli._battery_forward_state.percentage == 22);
assert(!cli.batteryAllowsForward(false));
dispatch("set adc.multiplier 1.2");
assert(board.reads == 5 && cli._battery_forward_state.millivolts == 4200);
assert(cli._battery_forward_state.percentage == 100 && cli.batteryAllowsForward(false));
mesh::power::voltageSaveOk = false;
const uint32_t sampledAt = cli._battery_forward_state.sampledAt;
char failed[] = "set adc.multiplier 1.0";
++nowMs;
cli.dispatchVoltage(failed, reply);
assert(strncmp(reply, "Error:", 6) == 0 && board.reads == 5);
assert(mesh::power::voltageSaves == 5 && board.multiplier == 1.2f);
assert(cli._battery_forward_state.sampledAt == sampledAt && cli.batteryAllowsForward(false));
char unknown[] = "set unrelated on";
cli.dispatchVoltage(unknown, reply);
assert(strcmp(reply, "fallthrough") == 0 && board.reads == 5);
''', prefix=prefix, flags=("-DNRF52_PLATFORM", "-DNRF52_POWER_MANAGEMENT"))
def test_actual_set_command_adc_branch_resamples_success_and_skips_rejected_changes(self):
source = (ROOT / "src/helpers/CommonCLI.cpp").read_text(encoding="ascii")
setter = extract_braced(source, "void CommonCLI::handleSetCmd(")
branch = extract_braced(setter, 'if (memcmp(config, "adc.multiplier ", 15) == 0)')
utils = (ROOT / "src/helpers/CLICommandUtils.h").read_text(encoding="ascii")
parser = extract_braced(utils, "inline bool parseDecimalStrict(")
prefix = self.runtime_prefix(r'''
struct Prefs { float adc_multiplier = 0.0f; } prefs;
Prefs* _prefs = &prefs;
unsigned prefsSaves = 0;
void savePrefs() { ++prefsSaves; }
void dispatchAdc(char* command, char* reply);
''')
prefix += "\nnamespace mesh { namespace cli {\n" + parser + "\n}}\n"
prefix += "void CommonCLI::dispatchAdc(char* command, char* reply) {\n"
prefix += "const char* config = &command[4];\n" + branch + "\n}\n"
self.exercise(r'''
Board board;
board.mv = 3500;
CommonCLI cli(board);
cli._battery_forward_settings.enabled = Repeat;
cli._battery_forward_settings.repeatBelow = 60;
nowMs = 100;
assert(!cli.batteryAllowsForward(false) && board.reads == 1);
assert(cli._battery_forward_state.percentage == 41);
char reply[160];
char calibrate[] = "set adc.multiplier 1.2";
++nowMs;
cli.dispatchAdc(calibrate, reply);
assert(strncmp(reply, "OK", 2) == 0 && cli.prefsSaves == 1 && board.reads == 2);
assert(cli.prefs.adc_multiplier == 1.2f && cli._battery_forward_state.millivolts == 4200);
assert(cli._battery_forward_state.sampledAt == nowMs && cli.batteryAllowsForward(false));
char malformed[] = "set adc.multiplier 1.2junk";
cli.dispatchAdc(malformed, reply);
assert(strncmp(reply, "Error:", 6) == 0 && cli.prefsSaves == 1 && board.reads == 2);
board.adcSupported = false;
char unsupported[] = "set adc.multiplier 1.0";
cli.dispatchAdc(unsupported, reply);
assert(strcmp(reply, "Error: unsupported") == 0 && cli.prefsSaves == 1 && board.reads == 2);
assert(cli.prefs.adc_multiplier == 1.2f && cli.batteryAllowsForward(false));
board.adcSupported = true;
char reset[] = "set adc.multiplier 0";
cli.dispatchAdc(reset, reply);
assert(strcmp(reply, "OK - using default board multiplier") == 0);
assert(cli.prefsSaves == 2 && board.reads == 3 && cli._battery_forward_state.percentage == 41);
assert(!cli.batteryAllowsForward(false));
''', prefix=prefix, flags=("-DESP32_PLATFORM",))
def test_actual_status_whitespace_forces_fresh_sample_but_invalid_suffix_does_not(self):
self.exercise(r'''
Board board;
CommonCLI cli(board);
char reply[157];
board.mv = 4200;
assert(cli.handleBatteryForwardingCommand("get battery.forward \t", reply));
assert(board.reads == 1 && strstr(reply, "battery=100% mv=4200"));
board.mv = 3000;
assert(cli.handleBatteryForwardingCommand("get battery.forward x", reply));
assert(strncmp(reply, "Error:", 6) == 0 && board.reads == 1);
assert(!cli.handleBatteryForwardingCommand("get battery.forwarder", reply) && board.reads == 1);
assert(cli.handleBatteryForwardingCommand("get battery.forward\t", reply));
assert(board.reads == 2 && strstr(reply, "battery=0% mv=3000"));
assert(cli.saveCalls == 0);
''', prefix=self.runtime_prefix(), flags=("-DESP32_PLATFORM",))
def test_actual_runtime_admission_samples_before_first_packet_and_after_30_seconds(self):
self.exercise(r'''
Board board;
CommonCLI cli(board);
mesh::Packet own, relayed;
own.flood = true; relayed.forwarded = true;
const CommonCLI& admission = cli;
for (unsigned i = 0; i < 5; ++i) {
cli.loopBatteryForwarding();
assert(admission.batteryAllowsForward(false));
assert(admission.batteryAllowsForward(true));
}
assert(board.reads == 0 && !cli._battery_forward_state.sampled);
cli._battery_forward_settings.enabled = Repeat | Flood;
board.mv = 3108; // 9 percent: both policies must be applied before the CLI loop runs.
nowMs = 100;
assert(admission.batteryAllowsTransmit(nullptr) && admission.batteryAllowsTransmit(&own));
assert(board.reads == 0); // Locally owned traffic never invokes the relay battery gate.
assert(!admission.batteryAllowsTransmit(&relayed) && board.reads == 1);
assert(cli._battery_forward_state.sampledAt == 100 && cli._battery_forward_state.percentage == 9);
relayed.flood = true;
assert(!admission.batteryAllowsTransmit(&relayed) && board.reads == 1);
nowMs = 30099;
board.mv = 4200;
cli.loopBatteryForwarding();
assert(!admission.batteryAllowsForward(false) && board.reads == 1);
nowMs = 30100;
assert(admission.batteryAllowsTransmit(&relayed) && board.reads == 2);
assert(cli._battery_forward_state.sampledAt == 30100 && cli._battery_forward_state.percentage == 100);
cli.loopBatteryForwarding();
assert(admission.batteryAllowsForward(false) && board.reads == 2);
nowMs = 200000; // The next retry must refresh after a long sleep, before the main CLI loop.
board.mv = 3108;
assert(!admission.batteryAllowsTransmit(&relayed) && board.reads == 3);
assert(admission.batteryAllowsTransmit(&own) && admission.batteryAllowsTransmit(nullptr));
assert(board.reads == 3);
''', prefix=self.runtime_prefix(), flags=("-DESP32_PLATFORM",))
def test_actual_runtime_cli_immediately_resamples_saved_policy_and_status(self):
self.exercise(r'''
Board board;
board.mv = 3300; // 25 percent with the ordinary 3000/4200 endpoints.
CommonCLI cli(board);
char reply[157];
nowMs = 123;
assert(cli.handleBatteryForwardingCommand("set repeat.battery 30", reply));
assert(strcmp(reply, "OK - saved") == 0 && board.reads == 1 && cli.saveCalls == 1);
assert(!cli.batteryAllowsForward(false) && !cli.batteryAllowsForward(true) && board.reads == 1);
board.mv = 4200;
assert(cli.handleBatteryForwardingCommand("get battery.forward", reply));
assert(strstr(reply, "battery=100% mv=4200 repeat=allowed flood=allowed"));
assert(board.reads == 2 && cli.saveCalls == 1);
board.mv = 3108;
cli.saveOk = false;
assert(cli.handleBatteryForwardingCommand("set repeat.battery off", reply));
assert(strstr(reply, "save unconfirmed") && board.reads == 2 && cli.saveCalls == 2);
assert(cli._battery_forward_settings.enabled == Repeat && cli._battery_forward_state.percentage == 100);
cli.saveOk = true;
assert(cli.handleBatteryForwardingCommand("set repeat.battery off", reply));
assert(board.reads == 3 && cli._battery_forward_settings.enabled == 0);
assert(cli.batteryAllowsForward(false) && cli.batteryAllowsForward(true));
nowMs += 100000;
cli.loopBatteryForwarding();
assert(board.reads == 3); // Disabled policies do not poll the ADC.
assert(cli.handleBatteryForwardingCommand("set flood.battery 20", reply));
assert(board.reads == 4 && cli._battery_forward_state.percentage == 9);
mesh::Packet direct, flood, own;
direct.forwarded = flood.forwarded = true;
flood.flood = own.flood = true;
assert(cli.batteryAllowsTransmit(&direct));
assert(!cli.batteryAllowsTransmit(&flood));
assert(cli.batteryAllowsTransmit(&own));
// A missing or impossible battery reading immediately removes both gates.
board.mv = 0;
assert(cli.handleBatteryForwardingCommand("get battery.forward", reply));
assert(strstr(reply, "battery=unknown mv=0 repeat=allowed flood=allowed"));
assert(cli.batteryAllowsTransmit(&flood) && board.reads == 5);
''', prefix=self.runtime_prefix(), flags=("-DESP32_PLATFORM",))
def test_actual_runtime_custom_voltage_endpoints_resample_and_compiled_two_cell_defaults(self):
self.exercise(r'''
Board board;
CommonCLI cli(board);
board.mv = 7200;
assert(cli.batteryEmptyMv() == 6000 && cli.batteryFullMv() == 8400);
assert(cli.batteryPercent(board.mv) == 50);
char reply[157];
assert(cli.handleBatteryForwardingCommand("set repeat.battery 55", reply));
assert(!cli.batteryAllowsForward(false) && board.reads == 1);
assert(cli.handleBatteryForwardingCommand("set battery.empty 5000", reply));
assert(strcmp(reply, "OK - saved") == 0 && cli.batteryEmptyMv() == 5000);
assert(cli.batteryFullMv() == 8400 && cli._battery_forward_state.percentage == 64);
assert(cli.batteryAllowsForward(false) && board.reads == 2);
assert(cli.handleBatteryForwardingCommand("set battery.full 10000", reply));
assert(cli.batteryFullMv() == 10000 && cli._battery_forward_state.percentage == 44);
assert(!cli.batteryAllowsForward(false) && board.reads == 3);
''', prefix=self.runtime_prefix(), flags=("-DESP32_PLATFORM", "-DBATT_MIN_MILLIVOLTS=6000",
"-DBATT_MAX_MILLIVOLTS=8400"))
def test_actual_nrf52_runtime_uses_shared_voltage_policy_and_preserves_existing_endpoint_cli(self):
self.exercise(r'''
Board board;
board.mv = 7200;
CommonCLI cli(board);
char reply[157] = "untouched";
cli._battery_forward_settings.emptyMv = 3000;
cli._battery_forward_settings.fullMv = 4200;
assert(cli.batteryEmptyMv() == 6000 && cli.batteryFullMv() == 8400 && cli.batteryPercent(7200) == 50);
for (const char* command : {"get battery.empty", "get battery.full", "set battery.empty 3300", "set battery.full 4200"}) {
assert(!cli.handleBatteryForwardingCommand(command, reply));
assert(strcmp(reply, "untouched") == 0 && board.reads == 0 && cli.saveCalls == 0);
}
assert(cli.handleBatteryForwardingCommand("set repeat.battery 60", reply));
assert(!cli.batteryAllowsForward(false) && board.reads == 1);
mesh::power::configuredFullMv = 9000;
cli.loopBatteryForwarding(true);
assert(cli._battery_forward_state.percentage == 40 && board.reads == 2);
mesh::power::configuredFullMv = 8000;
cli.loopBatteryForwarding(true);
assert(cli._battery_forward_state.percentage == 60 && !cli.batteryAllowsForward(false));
board.mv = 7240;
cli.loopBatteryForwarding(true);
assert(cli._battery_forward_state.percentage == 62 && cli.batteryAllowsForward(false));
''', prefix=self.runtime_prefix(), flags=("-DNRF52_PLATFORM", "-DNRF52_POWER_MANAGEMENT"))
def test_defaults_are_off_and_thresholds_are_independent_and_persistent_in_cli(self):
self.exercise(r'''
CLI cli;
assert(validSettings(cli.settings));
assert(cli.settings.enabled == 0 && cli.settings.repeatBelow == 10
&& cli.settings.floodBelow == 20 && cli.settings.emptyMv == 0 && cli.settings.fullMv == 0);
cli.state.update(1, 3000, 0, true, cli.settings);
assert(cli.state.allows(false) && cli.state.allows(true));
assert(cli.run("get repeat.battery") && strcmp(cli.reply, "> off below=10%") == 0);
assert(cli.run("get flood.battery") && strcmp(cli.reply, "> off below=20%") == 0);
assert(cli.saveCalls == 0);
for (unsigned threshold = 1; threshold <= 100; ++threshold) {
const std::string command = "set repeat.battery " + std::to_string(threshold);
cli.ok(command.c_str());
assert(cli.settings.repeatBelow == threshold && cli.settings.floodBelow == 20);
assert(cli.settings.enabled == Repeat);
}
cli.ok("set repeat.battery 13");
cli.ok("set flood.battery 37");
assert(cli.settings.enabled == (Repeat | Flood));
cli.ok("set repeat.battery off");
assert(cli.settings.enabled == Flood && cli.settings.repeatBelow == 13);
cli.ok("set repeat.battery on");
cli.ok("set flood.battery off");
assert(cli.settings.enabled == Repeat && cli.settings.floodBelow == 37);
cli.ok("set flood.battery on");
assert(cli.settings.enabled == (Repeat | Flood));
assert(cli.settings.repeatBelow == 13 && cli.settings.floodBelow == 37);
for (unsigned threshold = 1; threshold <= 100; ++threshold) {
const std::string command = "set flood.battery " + std::to_string(threshold);
cli.ok(command.c_str());
assert(cli.settings.floodBelow == threshold && cli.settings.repeatBelow == 13);
}
''')
def test_cli_rejects_invalid_ranges_suffixes_extra_arguments_and_overflow(self):
self.exercise(r'''
CLI cli;
for (const char* value : {"", "0", "101", "-1", "+10", "10.0", "10%", "15x",
"off on", "on off", "15 20", "10 ", "10\t", "ON", "OFF",
"4294967296", "1844674407370955161600000000000000"}) {
for (const char* key : {"repeat.battery", "flood.battery"}) {
const std::string command = std::string("set ") + key + " " + value;
cli.error(command.c_str());
}
}
cli.error("get repeat.battery 10");
cli.error("get flood.battery off");
cli.error("get battery.forward now");
cli.error("set battery.forward on");
for (const char* command : {"get repeat.batterys", "set repeat.battery2 10", "get flood.batteryx",
"get battery.forwarder", "repeat.battery", "Get repeat.battery", ""}) {
assert(!cli.run(command));
assert(strcmp(cli.reply, "untouched") == 0 && cli.saveCalls == 0);
}
cli.ok("set repeat.battery\t17");
cli.ok("set flood.battery 21");
assert(cli.settings.repeatBelow == 17 && cli.settings.floodBelow == 21);
uint16_t parsed = 1234;
assert(!parseNumber(nullptr, 1, 100, parsed) && parsed == 1234);
assert(!parseNumber("", 1, 100, parsed) && parsed == 1234);
assert(parseNumber("00015", 1, 100, parsed) && parsed == 15);
''')
def test_command_null_and_bounded_reply_contract(self):
self.exercise(r'''
CLI cli;
unsigned calls = 0;
auto save = [&](const Settings&) { ++calls; return true; };
char guard[] = {'A', 'B', 'C'};
assert(!handleCommand(cli.settings, cli.state, 3000, 4200, false, nullptr, guard + 1, 1, save));
assert(!handleCommand(cli.settings, cli.state, 3000, 4200, false, "get repeat.battery", nullptr, 1, save));
assert(!handleCommand(cli.settings, cli.state, 3000, 4200, false, "get repeat.battery", guard + 1, 0, save));
assert(calls == 0 && guard[0] == 'A' && guard[1] == 'B' && guard[2] == 'C');
for (const char* command : {"get repeat.battery", "get battery.forward", "set flood.battery 999"}) {
assert(handleCommand(cli.settings, cli.state, 3000, 4200, false, command, guard + 1, 1, save));
assert(guard[0] == 'A' && guard[1] == '\0' && guard[2] == 'C' && calls == 0);
}
''')
def test_failed_persistence_leaves_active_candidate_and_sampling_unchanged(self):
self.exercise(r'''
CLI cli;
cli.ok("set repeat.battery 15");
cli.state.update(123, 3108, 9, true, cli.settings);
const Settings before = cli.settings;
const State sampled = cli.state;
cli.saveOk = false;
const unsigned calls = cli.saveCalls;
assert(cli.run("set repeat.battery off"));
assert(strstr(cli.reply, "save unconfirmed") && strstr(cli.reply, "active policy unchanged"));
assert(cli.saveCalls == calls + 1 && same(cli.settings, before));
assert(cli.saved.enabled == 0 && cli.saved.repeatBelow == 15);
assert(cli.state.sampledAt == sampled.sampledAt && cli.state.millivolts == sampled.millivolts
&& cli.state.percentage == sampled.percentage && cli.state.valid == sampled.valid
&& cli.state.repeatBlocked == sampled.repeatBlocked && cli.state.floodBlocked == sampled.floodBlocked);
assert(!cli.state.allows(false));
cli.saveOk = true;
cli.ok("set repeat.battery off");
// The CLI owns persistence; its caller must immediately re-evaluate the sample.
assert(cli.state.repeatBlocked && cli.state.sampledAt == 123);
cli.state.update(124, 3108, 9, true, cli.settings);
assert(cli.state.allows(false) && cli.state.allows(true));
cli.ok("set flood.battery 30");
cli.state.update(125, 3108, 9, true, cli.settings);
assert(cli.state.allows(false) && !cli.state.allows(true));
''')
def test_threshold_is_strictly_below_with_two_point_hysteresis_capped_at_100(self):
self.exercise(r'''
for (unsigned threshold = 1; threshold <= 100; ++threshold) {
for (unsigned pct = 0; pct <= 100; ++pct) {
const unsigned resume = threshold < 99 ? threshold + 2 : 100;
assert(State::blocked(false, true, true, pct, threshold) == (pct < threshold));
assert(State::blocked(true, true, true, pct, threshold) == (pct < resume));
assert(!State::blocked(true, false, true, pct, threshold));
assert(!State::blocked(true, true, false, pct, threshold));
}
}
Settings settings;
settings.enabled = Repeat | Flood;
State state;
state.update(1, 3240, 20, true, settings);
assert(state.allows(false) && state.allows(true));
state.update(2, 3228, 19, true, settings);
assert(state.allows(false) && !state.allows(true));
state.update(3, 3120, 10, true, settings);
assert(state.allows(false) && !state.allows(true));
state.update(4, 3108, 9, true, settings);
assert(!state.allows(false) && !state.allows(true));
state.update(5, 3132, 11, true, settings);
assert(!state.allows(false) && !state.allows(true));
state.update(6, 3144, 12, true, settings);
assert(state.allows(false) && !state.allows(true));
state.update(7, 3252, 21, true, settings);
assert(state.allows(false) && !state.allows(true));
state.update(8, 3264, 22, true, settings);
assert(state.allows(false) && state.allows(true));
''')
def test_invalid_unknown_battery_fails_open_and_sampling_handles_clock_wrap(self):
self.exercise(r'''
Settings settings;
settings.enabled = Repeat | Flood;
for (uint16_t mv : {uint16_t(0), uint16_t(1999), uint16_t(20001), uint16_t(65535)}) {
State state;
state.update(1, 3000, 0, true, settings);
assert(!state.allows(false));
state.update(2, mv, 0, true, settings);
assert(!state.valid && state.allows(false) && state.allows(true));
}
for (unsigned pct : {101U, 255U}) {
State state;
state.update(1, 3000, 0, true, settings);
state.update(2, 3000, pct, true, settings);
assert(!state.valid && state.allows(false) && state.allows(true));
}
State state;
assert(state.due(0));
state.update(0xfffffff0U, 3000, 0, true, settings);
assert(!state.due(0xfffffff0U) && !state.due(0x751fU));
assert(state.due(0x7520U));
state.update(7, 3000, 0, false, settings);
assert(!state.valid && state.allows(false) && state.allows(true));
CLI cli;
cli.state = state;
assert(cli.run("get battery.forward") && strstr(cli.reply, "battery=unknown"));
assert(strstr(cli.reply, "repeat=allowed flood=allowed") && cli.saveCalls == 0);
''')
def test_voltage_endpoints_allow_two_cell_conversion_and_reject_invalid_pairs(self):
self.exercise(r'''
CLI cli;
assert(cli.run("get battery.empty") && strcmp(cli.reply, "> 3000") == 0);
assert(cli.run("get battery.full") && strcmp(cli.reply, "> 4200") == 0);
cli.error("set battery.empty 4200");
cli.error("set battery.empty 4101");
cli.ok("set battery.empty 4100");
assert(cli.settings.emptyMv == 4100 && cli.settings.fullMv == 4200);
cli.empty = cli.settings.emptyMv;
cli.ok("set battery.full 8400");
cli.full = cli.settings.fullMv;
cli.ok("set battery.empty 6000");
assert(cli.settings.emptyMv == 6000 && cli.settings.fullMv == 8400);
assert(percent(6000, 6000, 8400) == 0 && percent(8400, 6000, 8400) == 100);
assert(percent(7200, 6000, 8400) == 50 && percent(7180, 6000, 8400) == 49);
assert(percent(0, 6000, 8400) == 0 && percent(65535, 6000, 8400) == 100);
assert(percent(3000, 4200, 4200) == 100 && percent(3000, 4200, 3000) == 100);
for (const char* value : {"0", "1999", "20001", "65536", "-1", "4.2", "4200mV", "4200 4300"}) {
for (const char* key : {"battery.empty", "battery.full"}) {
const std::string command = std::string("set ") + key + " " + value;
cli.error(command.c_str());
}
}
assert(validEndpoints(2000, 2100) && validEndpoints(2000, 20000));
assert(!validEndpoints(1999, 4200) && !validEndpoints(3000, 3099));
assert(!validEndpoints(19901, 20000) && !validEndpoints(2000, 20001));
''')
def test_nrf52_external_voltage_policy_falls_through_without_writing(self):
self.exercise(r'''
CLI cli;
cli.external = true;
for (const char* command : {"get battery.empty", "get battery.full", "set battery.empty 3300",
"set battery.full 8400", "set battery.empty nonsense"}) {
assert(!cli.run(command));
assert(strcmp(cli.reply, "untouched") == 0 && cli.saveCalls == 0);
assert(same(cli.settings, Settings{}));
}
cli.ok("set repeat.battery 17");
cli.ok("set flood.battery 28");
assert(cli.run("get repeat.battery") && strcmp(cli.reply, "> on below=17%") == 0);
assert(cli.run("get flood.battery") && strcmp(cli.reply, "> on below=28%") == 0);
''')
def test_record_roundtrip_truncation_bit_corruption_and_semantic_validation(self):
self.exercise(r'''
Settings expected;
expected.enabled = Repeat | Flood;
expected.repeatBelow = 17; expected.floodBelow = 43;
expected.emptyMv = 6000; expected.fullMv = 8400;
uint8_t bytes[RecordSize];
encode(bytes, expected);
Settings actual;
assert(decode(bytes, sizeof(bytes), actual) && same(actual, expected));
assert(bytes[8] == 0x70 && bytes[9] == 0x17 && bytes[10] == 0xd0 && bytes[11] == 0x20);
for (size_t byte = 0; byte < RecordSize; ++byte) {
for (unsigned bit = 0; bit < 8; ++bit) {
uint8_t corrupted[RecordSize];
memcpy(corrupted, bytes, sizeof(bytes));
corrupted[byte] ^= uint8_t(1U << bit);
actual = expected;
assert(!decode(corrupted, sizeof(corrupted), actual));
assert(same(actual, Settings{}));
}
}
for (size_t size = 0; size < RecordSize; ++size) {
std::vector<uint8_t> truncated(bytes, bytes + size);
actual = expected;
assert(!decode(truncated.data(), size, actual) && same(actual, Settings{}));
}
std::vector<uint8_t> oversized(bytes, bytes + RecordSize);
oversized.push_back(0);
actual = expected;
assert(!decode(oversized.data(), oversized.size(), actual) && same(actual, Settings{}));
actual = expected;
assert(!decode(nullptr, RecordSize, actual) && same(actual, Settings{}));
auto rejectRecord = [&](const uint8_t* input) {
uint8_t invalid[RecordSize];
memcpy(invalid, input, sizeof(invalid));
const uint16_t crc = checksum(invalid, 14);
invalid[14] = uint8_t(crc); invalid[15] = uint8_t(crc >> 8);
actual = expected;
assert(!decode(invalid, sizeof(invalid), actual) && same(actual, Settings{}));
};
for (size_t reserved : {size_t(7), size_t(12), size_t(13)}) {
uint8_t invalid[RecordSize];
memcpy(invalid, bytes, sizeof(bytes)); invalid[reserved] = 1;
rejectRecord(invalid);
}
for (unsigned fault = 0; fault < 7; ++fault) {
Settings invalid = expected;
if (fault == 0) invalid.enabled = 4;
if (fault == 1) invalid.repeatBelow = 0;
if (fault == 2) invalid.repeatBelow = 101;
if (fault == 3) invalid.floodBelow = 0;
if (fault == 4) invalid.floodBelow = 101;
if (fault == 5) invalid.emptyMv = 0;
if (fault == 6) invalid.fullMv = 6099;
assert(!validSettings(invalid));
uint8_t invalidBytes[RecordSize]; encode(invalidBytes, invalid);
rejectRecord(invalidBytes);
}
encode(bytes, Settings{});
assert(decode(bytes, RecordSize, actual) && same(actual, Settings{}));
''')
if __name__ == "__main__":
unittest.main()
+1
View File
@@ -173,6 +173,7 @@ struct CommonCLI {
UserGpio _user_gpio;
struct Profiles { unsigned loops=0; void loop() { ++loops; } } _radio_profiles;
unsigned management=0;
void loopBatteryForwarding(bool = false) {}
void loopManagement() { ++management; }
void loop();
};
+1 -1
View File
@@ -222,7 +222,7 @@ class RegularFileReadTest(unittest.TestCase):
"static File openFloodSettingsWrite(")
verification = section("examples/simple_repeater/MyMesh.cpp",
"static uint32_t updateFloodSettingsHash(",
"static uint8_t batteryPercentFromMilliVolts(")
"static bool parseBatteryAlertPercent(")
program = program.replace("@FLOOD_VERIFY@", read_helper + verification)
for platform in ("ESP32_PLATFORM", "NRF52_PLATFORM", "RP2040_PLATFORM", "STM32_PLATFORM"):
with self.subTest(platform=platform), tempfile.TemporaryDirectory() as tmp:
+2
View File
@@ -49,6 +49,8 @@ struct SimpleMeshTables {
struct MyMesh : mesh::Mesh {
struct CLI {
mesh::MorseReplyHandoff handoff;
bool batteryAllowsForward(bool) const { return true; }
bool batteryAllowsTransmit(const mesh::Packet*) const { return true; }
mesh::MorseReplyHandoff& morseReply() { return handoff; }
const mesh::MorseReplyHandoff& morseReply() const { return handoff; }
} _cli;
+3
View File
@@ -53,6 +53,9 @@ class Mesh { public:
class MyMesh : public mesh::Mesh {
public:
NodePrefs _prefs{};
struct BatteryPolicy {
bool batteryAllowsTransmit(const mesh::Packet*) const { return true; }
} _cli;
unsigned direct_cancellations = 0, flood_cancellations = 0;
uint8_t getFloodRetryMaxAttempts(const mesh::Packet*) const;
uint8_t roomFloodAttempts(const mesh::Packet*) const;
+1 -1
View File
@@ -20,7 +20,7 @@ struct Packet {
uint8_t payload[64] = {}, payload_len = 32, type = 5, header = (5 << 2);
uint8_t radio_profile=0,radio_origin=0,tx_radio=RADIO_TX_AUTO;
uint32_t radio_generation=0,radio_origin_generation=0;
bool radio_bound=false,radio_local=true,radio_reply=false,radio_reply_force=false;
bool radio_bound=false,radio_local=true,radio_reply=false,radio_reply_force=false,forwarded=false;
uint8_t getPayloadType() const { return (header >> 2) & 0x0f; }
bool violatesRoutePolicy() const {
const uint8_t route = header & 0x03;