Add optional battery thresholds for repeat and flood forwarding
This commit is contained in:
1 parent
3d29a1d811
commit
af106a0ee9
25 files changed
+1170
-19
No files matched your search
@@ -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
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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 {
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Vendored
+1
@@ -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
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user