diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index b4e9d5547..3125f3452 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -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 diff --git a/docs/cli_commands.md b/docs/cli_commands.md index 34466f5b8..69bd4d8a7 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -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 ` +- `get flood.battery` +- `set flood.battery ` +- `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` diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 0a0499b80..5d4dc5819 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -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; } diff --git a/examples/simple_room_server/MyMesh.cpp b/examples/simple_room_server/MyMesh.cpp index 2cfb06388..709794060 100644 --- a/examples/simple_room_server/MyMesh.cpp +++ b/examples/simple_room_server/MyMesh.cpp @@ -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; diff --git a/examples/simple_room_server/MyMesh.h b/examples/simple_room_server/MyMesh.h index fa14bb6b1..08cbf1721 100644 --- a/examples/simple_room_server/MyMesh.h +++ b/examples/simple_room_server/MyMesh.h @@ -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; diff --git a/examples/simple_sensor/SensorMesh.cpp b/examples/simple_sensor/SensorMesh.cpp index f81bf3bbd..8b42f7e3c 100644 --- a/examples/simple_sensor/SensorMesh.cpp +++ b/examples/simple_sensor/SensorMesh.cpp @@ -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); diff --git a/examples/simple_sensor/SensorMesh.h b/examples/simple_sensor/SensorMesh.h index 99ab81c0e..14ae14788 100644 --- a/examples/simple_sensor/SensorMesh.h +++ b/examples/simple_sensor/SensorMesh.h @@ -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; diff --git a/src/Dispatcher.cpp b/src/Dispatcher.cpp index 07c9a8362..77c494f56 100644 --- a/src/Dispatcher.cpp +++ b/src/Dispatcher.cpp @@ -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 diff --git a/src/Mesh.cpp b/src/Mesh.cpp index af5220707..92dee74fa 100644 --- a/src/Mesh.cpp +++ b/src/Mesh.cpp @@ -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; diff --git a/src/Packet.cpp b/src/Packet.cpp index 591f4dd16..b46ed0ca1 100644 --- a/src/Packet.cpp +++ b/src/Packet.cpp @@ -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 diff --git a/src/Packet.h b/src/Packet.h index 8b70970b1..4f4d4d5ff 100644 --- a/src/Packet.h +++ b/src/Packet.h @@ -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; diff --git a/src/helpers/AbstractBridge.h b/src/helpers/AbstractBridge.h index ba7370345..39304168a 100644 --- a/src/helpers/AbstractBridge.h +++ b/src/helpers/AbstractBridge.h @@ -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; } diff --git a/src/helpers/BatteryForwarding.h b/src/helpers/BatteryForwarding.h new file mode 100644 index 000000000..b5aacf00f --- /dev/null +++ b/src/helpers/BatteryForwarding.h @@ -0,0 +1,188 @@ +#pragma once + +#include +#include +#include +#include + +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 +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 diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index 2458e4304..6e8f845b1 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -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 { diff --git a/src/helpers/CommonCLI.h b/src/helpers/CommonCLI.h index 3493cc052..47c932003 100644 --- a/src/helpers/CommonCLI.h +++ b/src/helpers/CommonCLI.h @@ -17,6 +17,7 @@ #include #include #include +#include #if defined(WITH_UDP_BRIDGE) #include #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; } diff --git a/src/helpers/CommonCLI_Battery.cpp b/src/helpers/CommonCLI_Battery.cpp new file mode 100644 index 000000000..5821acbff --- /dev/null +++ b/src/helpers/CommonCLI_Battery.cpp @@ -0,0 +1,132 @@ +#include "CommonCLI.h" +#include "CheckedSettingsFile.h" +#include +#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; +} diff --git a/test/README.md b/test/README.md index 8f2666f05..7a8946d46 100644 --- a/test/README.md +++ b/test/README.md @@ -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 diff --git a/test/fixtures/morse_reply_loop.cpp b/test/fixtures/morse_reply_loop.cpp index 5876dc4a3..806691124 100644 --- a/test/fixtures/morse_reply_loop.cpp +++ b/test/fixtures/morse_reply_loop.cpp @@ -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(); }; diff --git a/test/fixtures/sensor_tracker_client.h b/test/fixtures/sensor_tracker_client.h index f0933b758..897d2660a 100644 --- a/test/fixtures/sensor_tracker_client.h +++ b/test/fixtures/sensor_tracker_client.h @@ -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; diff --git a/test/test_battery_forwarding.py b/test/test_battery_forwarding.py new file mode 100644 index 000000000..6c6de2559 --- /dev/null +++ b/test/test_battery_forwarding.py @@ -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 +#include +#include +#include +#include +#include +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 truncated(bytes, bytes + size); + actual = expected; + assert(!decode(truncated.data(), size, actual) && same(actual, Settings{})); + } + std::vector 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() diff --git a/test/test_morse_loop_policy.py b/test/test_morse_loop_policy.py index 66cf2ab68..13caa478f 100644 --- a/test/test_morse_loop_policy.py +++ b/test/test_morse_loop_policy.py @@ -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(); }; diff --git a/test/test_regular_file_reads.py b/test/test_regular_file_reads.py index d974de19f..08b8c6a50 100644 --- a/test/test_regular_file_reads.py +++ b/test/test_regular_file_reads.py @@ -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: diff --git a/test/test_repeater_trace_forwarding.py b/test/test_repeater_trace_forwarding.py index ae115e776..b26b30403 100644 --- a/test/test_repeater_trace_forwarding.py +++ b/test/test_repeater_trace_forwarding.py @@ -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; diff --git a/test/test_retry_cr_presets.py b/test/test_retry_cr_presets.py index 8af1dd264..fe10fd635 100644 --- a/test/test_retry_cr_presets.py +++ b/test/test_retry_cr_presets.py @@ -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; diff --git a/test/test_transport_filter_wiring.py b/test/test_transport_filter_wiring.py index d68472e0d..7b56bc22d 100644 --- a/test/test_transport_filter_wiring.py +++ b/test/test_transport_filter_wiring.py @@ -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;