Apply saved TX power automatically across firmware roles

This commit is contained in:
mikecarper committed 2026-10-25 14:08:15 -07:00
1 parent 31e5d54074
commit 65928aae95
51 files changed
+3838 -2728

No files matched your search

+1
View File
@@ -617,6 +617,7 @@ jobs:
python3 -B test/test_room_sensor_radio_restore.py -v
python3 -B test/test_common_radio_persistence.py -v
python3 -B test/test_common_cli_save_results.py -v
python3 -B test/test_common_cli_tx_pending.py -v
python3 -B test/test_common_prefs_uncertain_reply.py -v
python3 -B test/test_common_prefs_apply_reply.py -v
python3 -B test/test_common_prefs_commit.py -v
+2 -2
View File
@@ -166,7 +166,7 @@ should use command `0x42`. See [Companion radio binary protocol](companion_proto
| Logging | [`get/set usb.watchdog off\|on\|auto`](usb_logging_watchdog.md) | Current source; recovery requires native USB, live logging, and durable storage; Auto qualifies from USB stats polling | Yes | Yes | Feature |
| Logging | [`get usb.watchdog.last`](usb_logging_watchdog.md#last-event-review) | Current source; latest USB watchdog event with advisory timestamp, uptime, action, reasons, and durability; native USB required | Yes | Yes | Feature |
| Radio | [`get radio`; `set radio ...`](cli_commands.md#view-or-change-this-nodes-radio-parameters) | All text CLI roles | Yes | Yes | Yes |
| Radio | [`get tx`; `set tx <dbm>`](cli_commands.md#view-or-change-this-nodes-transmit-power) | Board TX-power limits apply | Yes | Yes | Yes |
| Radio | [`get tx`; `set tx <dbm>`](cli_commands.md#view-or-change-this-nodes-transmit-power) | Board TX-power limits apply; busy changes apply automatically | Yes | Yes | Yes |
| Radio | [`tempradio ...`; `normalradio`](cli_commands.md#change-the-radio-parameters-for-a-set-duration) | Full parser | Yes | Yes | Yes |
| Radio | [`get/set/del radioat`; `get/set/del tempradioat`](cli_commands.md#schedule-radio-parameter-changes) | Full parser | Yes | Yes | Yes |
| Radio | [`get freq`; `set freq <mhz>`](cli_commands.md#view-or-change-this-nodes-frequency) | Local or authorized LoRa CLI | Yes | Yes | Yes |
@@ -342,7 +342,7 @@ should use command `0x42`. See [Companion radio binary protocol](companion_proto
| Logging | [`get/set usb.watchdog off\|on\|auto`](usb_logging_watchdog.md) | Current source; recovery requires native USB, live logging, and durable storage; Auto qualifies from USB stats polling | Yes | Yes | Feature | Yes | Yes |
| Logging | [`get usb.watchdog.last`](usb_logging_watchdog.md#last-event-review) | Current source; latest USB watchdog event with advisory timestamp, uptime, action, reasons, and durability; native USB required | Yes | Yes | Feature | Yes | Yes |
| Radio | [`get radio`; `set radio ...`](cli_commands.md#view-or-change-this-nodes-radio-parameters) | All text CLI roles | Yes | Yes | Yes | Yes | Yes |
| Radio | [`get tx`; `set tx <dbm>`](cli_commands.md#view-or-change-this-nodes-transmit-power) | Board TX-power limits apply | Yes | Yes | Yes | Yes | Yes |
| Radio | [`get tx`; `set tx <dbm>`](cli_commands.md#view-or-change-this-nodes-transmit-power) | Board TX-power limits apply; busy changes apply automatically | Yes | Yes | Yes | Yes | Yes |
| Radio | [`tempradio ...`; `normalradio`](cli_commands.md#change-the-radio-parameters-for-a-set-duration) | Full parser | Yes | Yes | Yes | Yes | Yes |
| Radio | [`get/set/del radioat`; `get/set/del tempradioat`](cli_commands.md#schedule-radio-parameter-changes) | Full parser | Yes | Yes | Yes | Yes | Yes |
| Radio | [`get freq`; `set freq <mhz>`](cli_commands.md#view-or-change-this-nodes-frequency) | Local or authorized LoRa CLI | Yes | Yes | Yes | Yes | Yes |
+9 -2
View File
@@ -1878,8 +1878,15 @@ minimum and return to it exactly when it is safe.
**Notes:** This setting only controls the power level of the LoRa chip. Some nodes have an additional power amplifier stage which increases the total output. Refer to the node's manual for the correct setting to use. **Setting a value too high may violate the laws in your country.**
The command strictly rejects malformed values and saves the new preference
only after the active radio driver accepts it. On LR2021, the chip range is
The command strictly rejects malformed values. It applies power immediately
when the radio is free. A busy radio returns `OK - saved; pending`; firmware
then applies the saved value automatically, without another command or reboot.
A newer successful change replaces the pending value. A failed save is reported
as an error and does not replace the previous runtime intent. Radio 2 and
temporary radio profiles do not require a manual retry. Background application
waits for active Morse, beacons and carrier-wave tests to finish.
On LR2021, the chip range is
`-9` to `22` dBm below 1500 MHz and `-19` to `12` dBm above 1500 MHz. A lower
board-specific external-PA limit still takes precedence. A saved profile is
clamped to the applicable limit when its frequency changes; temporary radio
+7 -5
View File
@@ -246,11 +246,13 @@ catalog; bytes `0x2C`-`0x31` are parked and `0x35` is unused.
The sections below detail the most common frames. Refer to the source named
above for command bodies that are not expanded here.
Radio changes (`0x0B`, `0x0C`) can wait briefly for a safe radio window before
replying. TX power waits up to three seconds when reception or transmission is
busy. Its OK response confirms both hardware acceptance and a saved setting;
timeout or disconnect discards a waiting power change. Successful settings
commands are saved independently, so an error does not undo earlier commands.
Radio parameter changes (`0x0B`) can wait briefly for a safe radio window before
replying. TX power (`0x0C`) returns OK once the requested setting is saved. If
the radio is busy, firmware applies that saved power automatically when it is
free; disconnecting does not cancel the change. A newer successful power save
replaces the pending value. No follow-up command or reconnect is needed.
Successful settings commands are saved independently, so an error does not undo
earlier commands.
### Private text when only the sender knows the recipient
+60 -52
View File
@@ -1972,6 +1972,8 @@ void MyMesh::onRadioProfileCopyQueued(mesh::Packet* packet, const mesh::Packet*
void MyMesh::onSendComplete(mesh::Packet* packet) {
mesh::Mesh::onSendComplete(packet);
// Use the completed packet's idle gap before a busy queue starts another TX.
servicePendingTxPower();
_morse_reply.complete(packet);
}
@@ -3534,8 +3536,7 @@ void MyMesh::finishRadioParamApply(float freq, float bw, uint8_t sf,
return;
}
// Preserve an outstanding power rollback; the full saved-radio recovery
// checks both the tuple and TX power before clearing this latch.
// Preserve any earlier saved-radio recovery while committing the tuple.
saved_radio_apply_pending = previous_recovery_pending;
radio_apply_retry_at = 0;
radio_apply_failures = 0;
@@ -3546,24 +3547,22 @@ void MyMesh::finishRadioParamApply(float freq, float bw, uint8_t sf,
writeOKFrame(route);
}
void MyMesh::deferRadioCommand(uint8_t kind) {
void MyMesh::deferRadioCommand() {
command_radio_reply_route = _serial->captureReplyRoute();
if (command_radio_reply_route == NULL) {
writeErrFrame(ERR_CODE_BAD_STATE);
return;
}
command_radio_apply_pending = kind;
command_radio_apply_pending = true;
command_radio_apply_deadline = futureMillis(COMMAND_RADIO_APPLY_TIMEOUT_MS);
}
void MyMesh::cancelPendingRadioParamApply() {
if (!command_radio_apply_pending) return;
const bool restore_tuple = command_radio_apply_pending == 1;
command_radio_apply_pending = false;
command_radio_apply_deadline = 0;
command_radio_reply_route = NULL;
if (!restore_tuple) return;
// The requested tuple was not committed. Reassert the persisted tuple in
// case a failed hardware apply only restored part of the old configuration.
saved_radio_apply_pending = true;
@@ -3583,20 +3582,6 @@ void MyMesh::servicePendingRadioParamApply() {
const bool expired = static_cast<int32_t>(static_cast<uint32_t>(
_ms->getMillis() - command_radio_apply_deadline)) >= 0;
if (command_radio_apply_pending == 2) {
const auto result = expired ? RadioSettingResult::RadioBusy
: applyAndSaveTxPower(static_cast<int8_t>(command_radio_repeat), true);
if (result == RadioSettingResult::RadioBusy
&& !expired) return;
if (result == RadioSettingResult::Saved) writeOKFrame(command_radio_reply_route);
else writeErrFrame(result == RadioSettingResult::SaveFailed
? ERR_CODE_FILE_IO_ERROR : ERR_CODE_BAD_STATE, command_radio_reply_route);
// The checked setter owns any failed-save rollback. Retire this command
// without forcing a tuple restore or consuming a pending receive.
cancelPendingRadioParamApply();
return;
}
mesh::RadioParamApplyResult result = tryApplyRadioParams(
command_radio_freq, command_radio_bw, command_radio_sf, command_radio_cr);
if (result == mesh::RadioParamApplyResult::APPLIED) {
@@ -5687,9 +5672,10 @@ void MyMesh::execCommand(char* cmd, char* reply) {
snprintf(reply, 160, "Error: TX -9..%d dBm", MAX_LORA_TX_POWER);
} else {
const auto result = applyAndSaveTxPower(static_cast<int8_t>(parsed));
strcpy(reply, result == RadioSettingResult::Saved ? "OK"
strcpy(reply, result == RadioSettingResult::Saved
? (tx_power_apply_pending ? "OK - saved; pending" : "OK")
: result != RadioSettingResult::SaveFailed
? "Error: radio busy or TX power rejected"
? "Error: TX power rejected"
: "Error: TX not saved");
}
return;
@@ -6808,7 +6794,7 @@ void MyMesh::handleCmdFrame(size_t len) {
command_radio_sf = sf;
command_radio_cr = cr;
command_radio_repeat = repeat;
deferRadioCommand(1);
deferRadioCommand();
MESH_DEBUG_PRINTLN("Deferred CMD_SET_RADIO_PARAMS while radio is busy");
return;
}
@@ -6836,15 +6822,8 @@ void MyMesh::handleCmdFrame(size_t len) {
int8_t power = (int8_t)cmd_frame[1];
if (power < -9 || power > MAX_LORA_TX_POWER) {
writeErrFrame(ERR_CODE_ILLEGAL_ARG);
} else if (command_radio_apply_pending) {
writeErrFrame(ERR_CODE_BAD_STATE);
} else {
const auto result = applyAndSaveTxPower(power);
if (result == RadioSettingResult::RadioBusy) {
command_radio_repeat = static_cast<uint8_t>(power);
deferRadioCommand(2);
return;
}
if (result == RadioSettingResult::Saved) writeOKFrame();
else writeErrFrame(result == RadioSettingResult::RadioRejected
? ERR_CODE_BAD_STATE : ERR_CODE_FILE_IO_ERROR);
@@ -8488,32 +8467,47 @@ bool MyMesh::saveAdvertLocation(double latitude, double longitude) {
return false;
}
MyMesh::RadioSettingResult MyMesh::applyAndSaveTxPower(int8_t power, bool from_pending) {
if (command_radio_apply_pending && !from_pending) return RadioSettingResult::RadioBusy;
MyMesh::RadioSettingResult MyMesh::applyAndSaveTxPower(int8_t power) {
if (!radio_driver.isTxPowerValid(power)) return RadioSettingResult::RadioRejected;
const int8_t previous = _prefs.tx_power_dbm;
if (_radio_available) {
const auto result = radio_driver.trySetTxPower(power);
if (result != mesh::RadioParamApplyResult::APPLIED) {
return result == mesh::RadioParamApplyResult::BUSY
? RadioSettingResult::RadioBusy : RadioSettingResult::RadioRejected;
}
const auto result = _radio_available && !command_radio_apply_pending
? radio_driver.trySetTxPower(power) : mesh::RadioParamApplyResult::BUSY;
if (result == mesh::RadioParamApplyResult::FAILED) {
// A rejected SPI transaction may have partly changed the hardware. Keep
// the saved setting authoritative and restore it without retuning RX.
tx_power_apply_pending = true;
tx_power_apply_retry_at = 0;
return RadioSettingResult::RadioRejected;
}
_prefs.tx_power_dbm = power;
if (savePrefs()) return RadioSettingResult::Saved;
if (savePrefs()) {
tx_power_apply_pending = result != mesh::RadioParamApplyResult::APPLIED;
tx_power_apply_retry_at = 0;
return RadioSettingResult::Saved;
}
_prefs.tx_power_dbm = previous;
if (_radio_available && !radio_driver.setTxPower(previous)) {
// An RX packet can arrive between the apply and its failed flash commit.
// Keep retrying the durable power rather than leaving the rejected value
// active until reboot. Recovery also restores the saved tuple, so an
// active TempRadio lease deliberately delays it until the lease ends.
saved_radio_apply_pending = true;
radio_apply_retry_at = 0;
radio_apply_failures = 0;
// A busy attempt has not changed the earlier pending target or retry time.
if (result == mesh::RadioParamApplyResult::APPLIED) {
tx_power_apply_pending = !radio_driver.setTxPower(previous);
tx_power_apply_retry_at = 0;
}
return RadioSettingResult::SaveFailed;
}
void MyMesh::servicePendingTxPower() {
if (!tx_power_apply_pending || !_radio_available || command_radio_apply_pending
|| (_radio && _radio->isCarrierWaveActive())) return;
const uint32_t now = _ms->getMillis();
if (tx_power_apply_retry_at
&& static_cast<int32_t>(now - tx_power_apply_retry_at) < 0) return;
const auto result = radio_driver.trySetTxPower(_prefs.tx_power_dbm);
if (result == mesh::RadioParamApplyResult::APPLIED) tx_power_apply_pending = false;
// Busy admission does not touch RX. Back off actual hardware failures.
tx_power_apply_retry_at = result == mesh::RadioParamApplyResult::FAILED
? (now + 1000u ? now + 1000u : 1u) : 0;
}
bool MyMesh::applyAndSaveFemRxGain(bool enabled) {
// Binary Companion setters and WebConfig also enter through this helper.
// Do not alter an external FEM while the Morse transmitter owns it.
@@ -10957,6 +10951,17 @@ bool MyMesh::handleCommand(const char* command, uint32_t sender_timestamp,
if (handleLocalControlCommand(command, reply, reply_capacity)) return true;
#if defined(MESH_HIL_TX_FREQ_MHZ)
// RF test builds verify active power before permitting any test TX.
if (strcmp(command, "get tx.status") == 0) {
snprintf(reply, reply_capacity, "> desired=%d,apply=%s", (int)_prefs.tx_power_dbm,
!tx_power_apply_pending && _radio_available
&& radio_driver.isTxPowerApplied(_prefs.tx_power_dbm)
? "applied" : "pending");
return true;
}
#endif
if (strncmp(command, "set tx ", 7) == 0) {
int32_t parsed = 0;
if (!mesh::cli::parseIntegerStrict(command + 7, parsed)) {
@@ -10967,12 +10972,13 @@ bool MyMesh::handleCommand(const char* command, uint32_t sender_timestamp,
} else {
const int8_t requested = static_cast<int8_t>(parsed);
const auto result = applyAndSaveTxPower(requested);
if (result == RadioSettingResult::RadioRejected || result == RadioSettingResult::RadioBusy) {
mesh::cli::formatError(reply, reply_capacity, "radio busy or TX power rejected");
if (result == RadioSettingResult::RadioRejected) {
mesh::cli::formatError(reply, reply_capacity, "TX power rejected");
} else if (result == RadioSettingResult::SaveFailed) {
mesh::cli::formatError(reply, reply_capacity, "TX not saved");
} else {
strcpy(reply, "OK");
snprintf(reply, reply_capacity, "%s",
tx_power_apply_pending ? "OK - saved; pending" : "OK");
}
}
return true;
@@ -11618,6 +11624,7 @@ void MyMesh::loop() {
#endif
}
servicePendingRadioParamApply();
servicePendingTxPower();
#if MESH_ENABLE_ONE_KEY_DM
if (held_dm_count != 0 || verified_pending_count != 0) releaseHeldOneKeyDMs();
#endif
@@ -11710,7 +11717,7 @@ bool MyMesh::canRecoverUsbLogging() const {
&& !_store->hasPendingAdvertWrites()
#endif
&& !hasOutbound() && !isAnyTempRadioActive() && !hasPendingOtaApply()
&& !command_radio_apply_pending && !saved_radio_apply_pending
&& !command_radio_apply_pending && !saved_radio_apply_pending && !tx_power_apply_pending
&& _scheduled_reboot_at == 0 && !hasPendingReqs()
&& !_delayed_replies.hasBinaryTrace() && sign_data == NULL
#if COMPANION_FEATURE_TEXT_TERMINAL
@@ -11755,7 +11762,8 @@ mesh::powerdiag::Mask MyMesh::pendingWorkReason() const {
if (_iter_started && _serial != NULL
&& _serial->isReplyRouteAvailable(_iter_reply_route)) return mask(Reason::SerialIO);
if (hasPendingOtaApply()) return mask(Reason::Ota);
if (command_radio_apply_pending || _scheduled_reboot_at != 0) return mask(Reason::Scheduled);
if (command_radio_apply_pending || (tx_power_apply_pending && _radio_available)
|| _scheduled_reboot_at != 0) return mask(Reason::Scheduled);
if (hasPendingReqs() || _delayed_replies.hasBinaryTrace()) return mask(Reason::Mesh);
#if COMPANION_FEATURE_TEXT_TERMINAL
if (_terminal_trace_pending) return mask(Reason::Mesh);
+7 -5
View File
@@ -675,8 +675,9 @@ private:
bool applyAndSaveFemRxGain(bool enabled);
bool applyAndSaveFemTxGain(bool enabled);
bool applyAndSaveRxBoostedGain(bool enabled);
enum class RadioSettingResult { Saved, RadioRejected, SaveFailed, RadioBusy };
RadioSettingResult applyAndSaveTxPower(int8_t power, bool from_pending = false);
enum class RadioSettingResult { Saved, RadioRejected, SaveFailed };
RadioSettingResult applyAndSaveTxPower(int8_t power);
void servicePendingTxPower();
bool handleCadCommand(const char* command, char* reply, size_t reply_size);
bool saveBluetoothNameOverride(const char* name);
bool applyAndSaveBluetoothName(const char* value, char* reply,
@@ -749,7 +750,7 @@ private:
void finishRadioParamApply(float freq, float bw, uint8_t sf, uint8_t cr,
uint8_t repeat,
BaseSerialInterface* route = nullptr);
void deferRadioCommand(uint8_t kind);
void deferRadioCommand();
void cancelPendingRadioParamApply();
void servicePendingRadioParamApply();
void servicePendingSerialReply();
@@ -911,10 +912,11 @@ private:
#endif
bool saved_radio_apply_pending;
bool _radio_available;
bool tx_power_apply_pending = false;
uint32_t tx_power_apply_retry_at = 0;
unsigned long radio_apply_retry_at;
uint8_t radio_apply_failures;
// 0: none, 1: modulation tuple, 2: TX power (stored in the repeat byte).
uint8_t command_radio_apply_pending;
bool command_radio_apply_pending;
float command_radio_freq;
float command_radio_bw;
uint8_t command_radio_sf;
+13 -1
View File
@@ -4376,6 +4376,7 @@ bool MyMesh::sendRepeatersFloodText(const char* text, const TransportKey* scope,
void MyMesh::onSendComplete(mesh::Packet* packet) {
mesh::Mesh::onSendComplete(packet);
_cli.servicePendingTxPower();
_cli.morseReply().complete(packet);
#if MESH_ENABLE_FLEET_CONTROL
if (_cli.fleetChannel()) _cli.fleetChannel()->complete(packet, _cli.radioProfiles());
@@ -5882,6 +5883,16 @@ bool MyMesh::setTxPower(int8_t power_dbm) {
return radio_driver.setTxPower(power_dbm);
}
mesh::RadioParamApplyResult MyMesh::trySetTxPower(int8_t power_dbm) {
return radio_driver.trySetTxPower(power_dbm);
}
#if defined(MESH_HIL_TX_FREQ_MHZ)
bool MyMesh::isTxPowerApplied(int8_t power_dbm) const {
return radio_driver.isTxPowerApplied(power_dbm);
}
#endif
bool MyMesh::setRxPowerSaving(bool enable, uint32_t rx_us, uint32_t sleep_us) {
bool ok = radio_driver.setRxPowerSaving(enable, rx_us, sleep_us);
MESH_DEBUG_PRINTLN("RX Power Saving: %s (%lu/%lu us)%s",
@@ -14791,7 +14802,8 @@ mesh::powerdiag::Mask MyMesh::pendingWorkReason() const {
#if defined(WITH_WEBCONFIG) || defined(ETHERNET_ENABLED)
if (_local_cli_output.busy()) return mask(Reason::SerialIO);
#endif
if (deferred_cli_command.pending || pending_self_advert || _cli.hasActiveUserGpioTimer()) return mask(Reason::Scheduled);
if (deferred_cli_command.pending || pending_self_advert || _cli.hasActiveUserGpioTimer()
|| _cli.hasPendingTxPower()) return mask(Reason::Scheduled);
#if defined(WITH_BRIDGE)
const AbstractBridge* active_bridge = activeBridge();
if (active_bridge && active_bridge->isRunning()) {
+5 -1
View File
@@ -1282,6 +1282,10 @@ public:
void cancelPendingSerialOutput();
#endif
bool setTxPower(int8_t power_dbm) override;
mesh::RadioParamApplyResult trySetTxPower(int8_t power_dbm) override;
#if defined(MESH_HIL_TX_FREQ_MHZ)
bool isTxPowerApplied(int8_t power_dbm) const override;
#endif
bool setRxPowerSaving(bool enable, uint32_t rx_us, uint32_t sleep_us) override;
bool supportsRxPowerSavingRfRxDisable() const override;
bool setRxPowerSavingRfRxDisabled(bool disabled) override;
@@ -1356,7 +1360,7 @@ public:
#endif
bool canRecoverUsbLogging() const {
return dirty_contacts_expiry == 0 && !hasOutbound() && !isAnyTempRadioActive()
&& !hasPendingOtaApply() && !saved_radio_apply_pending
&& !hasPendingOtaApply() && !saved_radio_apply_pending && !_cli.hasPendingTxPower()
&& !temp_radio_handoff_pending && _ota_update_at == 0;
}
#if MESH_ENABLE_FLOOD_RULE_ENGINE
+12
View File
@@ -2424,6 +2424,7 @@ void MyMesh::onGroupPacketRecv(mesh::Packet* packet) {
void MyMesh::onSendComplete(mesh::Packet* packet) {
mesh::Mesh::onSendComplete(packet);
_cli.servicePendingTxPower();
_cli.morseReply().complete(packet);
#if MESH_ENABLE_FLEET_CONTROL
if (_cli.fleetChannel()) _cli.fleetChannel()->complete(packet, _cli.radioProfiles());
@@ -3190,6 +3191,16 @@ bool MyMesh::setTxPower(int8_t power_dbm) {
return radio_driver.setTxPower(power_dbm);
}
mesh::RadioParamApplyResult MyMesh::trySetTxPower(int8_t power_dbm) {
return radio_driver.trySetTxPower(power_dbm);
}
#if defined(MESH_HIL_TX_FREQ_MHZ)
bool MyMesh::isTxPowerApplied(int8_t power_dbm) const {
return radio_driver.isTxPowerApplied(power_dbm);
}
#endif
bool MyMesh::setRxPowerSaving(bool enable, uint32_t rx_us, uint32_t sleep_us) {
return radio_driver.setRxPowerSaving(enable, rx_us, sleep_us);
}
@@ -4489,6 +4500,7 @@ mesh::powerdiag::Mask MyMesh::pendingWorkReason() const {
using mesh::powerdiag::Reason;
if (!canIdleDualRadio()) return mask(Reason::DualRadio);
if (hasPendingOtaApply()) return mask(Reason::Ota);
if (_cli.hasPendingTxPower()) return mask(Reason::Scheduled);
#if defined(ESP32_PLATFORM)
if (mesh::wifi::networkPower().foregroundActive()) return mask(Reason::Network);
#endif
+5 -1
View File
@@ -620,6 +620,10 @@ public:
void cancelPendingSerialOutput();
#endif
bool setTxPower(int8_t power_dbm) override;
mesh::RadioParamApplyResult trySetTxPower(int8_t power_dbm) override;
#if defined(MESH_HIL_TX_FREQ_MHZ)
bool isTxPowerApplied(int8_t power_dbm) const override;
#endif
bool setRxPowerSaving(bool enable, uint32_t rx_us, uint32_t sleep_us) override;
void recalibrateNoiseFloor() override { _radio->recalibrateNoiseFloor(); }
void getRxPsWatchdogCounts(uint32_t* soft, uint32_t* hard) override;
@@ -700,7 +704,7 @@ public:
#endif
bool canRecoverUsbLogging() const {
return dirty_contacts_expiry == 0 && !hasOutbound() && !isAnyTempRadioActive()
&& !hasPendingOtaApply() && !saved_radio_apply_pending
&& !hasPendingOtaApply() && !saved_radio_apply_pending && !_cli.hasPendingTxPower()
&& set_radio_at == 0 && _ota_update_at == 0;
}
#if MESH_ENABLE_ROOM_FLOOD_RULE_ENGINE
+12 -1
View File
@@ -1110,6 +1110,7 @@ bool SensorMesh::handleIncomingMsg(ClientInfo& from, uint32_t timestamp, uint8_t
#if !MESH_ENABLE_SENSOR_TRACKER
void SensorMesh::onSendComplete(mesh::Packet* packet) {
mesh::Mesh::onSendComplete(packet);
_cli.servicePendingTxPower();
_cli.morseReply().complete(packet);
}
@@ -1460,6 +1461,16 @@ bool SensorMesh::setTxPower(int8_t power_dbm) {
return radio_driver.setTxPower(power_dbm);
}
mesh::RadioParamApplyResult SensorMesh::trySetTxPower(int8_t power_dbm) {
return radio_driver.trySetTxPower(power_dbm);
}
#if defined(MESH_HIL_TX_FREQ_MHZ)
bool SensorMesh::isTxPowerApplied(int8_t power_dbm) const {
return radio_driver.isTxPowerApplied(power_dbm);
}
#endif
bool SensorMesh::setRxPowerSaving(bool enable, uint32_t rx_us, uint32_t sleep_us) {
return radio_driver.setRxPowerSaving(enable, rx_us, sleep_us);
}
@@ -1730,7 +1741,7 @@ mesh::powerdiag::Mask SensorMesh::pendingWorkReason() const {
#endif
if (!canIdleDualRadio()) return mask(Reason::DualRadio);
if (hasPendingOtaApply()) return mask(Reason::Ota);
if (_cli.hasActiveUserGpioTimer()) return mask(Reason::Scheduled);
if (_cli.hasActiveUserGpioTimer() || _cli.hasPendingTxPower()) return mask(Reason::Scheduled);
if (radio_driver.isWatchdogObserving()) return mask(Reason::RadioWatchdog);
uint32_t noise_floor_delay_ms;
if (getNextNoiseFloorWakeDelay(noise_floor_delay_ms) && noise_floor_delay_ms == 0)
+5 -1
View File
@@ -73,7 +73,7 @@ public:
void loop();
bool canRecoverUsbLogging() const {
return dirty_contacts_expiry == 0 && !hasOutbound() && !isAnyTempRadioActive()
&& !hasPendingOtaApply() && !saved_radio_apply_pending
&& !hasPendingOtaApply() && !saved_radio_apply_pending && !_cli.hasPendingTxPower()
&& set_radio_at == 0;
}
void handleCommand(uint32_t sender_timestamp, char* command, char* reply,
@@ -115,6 +115,10 @@ public:
void eraseLogFile() override { }
void dumpLogFile() override { }
bool setTxPower(int8_t power_dbm) override;
mesh::RadioParamApplyResult trySetTxPower(int8_t power_dbm) override;
#if defined(MESH_HIL_TX_FREQ_MHZ)
bool isTxPowerApplied(int8_t power_dbm) const override;
#endif
bool setRxPowerSaving(bool enable, uint32_t rx_us, uint32_t sleep_us) override;
void recalibrateNoiseFloor() override { _radio->recalibrateNoiseFloor(); }
void getRxPsWatchdogCounts(uint32_t* soft, uint32_t* hard) override;
+3 -1
View File
@@ -83,7 +83,8 @@ bool SensorMesh::trackerNeedsRadio() const {
|| board.isRadioTestActive() || mesh::isUsbLoggingWatchdogArmed()
|| isAnyTempRadioActive() || hasPendingOtaApply() || isDualRadioActive()
|| set_radio_at || revert_radio_at || saved_radio_apply_pending
|| _cli.hasActiveUserGpioTimer() || hasOutbound() || _mgr->getOutboundTotal() != 0)
|| _cli.hasActiveUserGpioTimer() || _cli.hasPendingTxPower()
|| hasOutbound() || _mgr->getOutboundTotal() != 0)
return true;
uint32_t delay;
if (hasPendingRadioWork() || getNextRetryWakeDelay(delay)) return true;
@@ -269,6 +270,7 @@ bool SensorMesh::handleTrackerPath(mesh::Packet* packet, uint8_t* path, uint8_t
void SensorMesh::onSendComplete(mesh::Packet* packet) {
mesh::Mesh::onSendComplete(packet);
_cli.servicePendingTxPower();
_cli.morseReply().complete(packet);
if (!tracker_pending || !packet || packet->getPayloadType() != PAYLOAD_TYPE_REQ) return;
uint8_t hash[MAX_HASH_SIZE];
+57 -5
View File
@@ -173,6 +173,7 @@ void CommonCLI::loop() {
}
#endif
if (!mesh::morseTimingActive(_callbacks->getProfileRadio())) {
servicePendingTxPower();
_radio_profiles.loop();
loopManagement();
#if MESH_ENABLE_TX_BEACONS
@@ -197,6 +198,55 @@ bool CommonCLI::hasActiveUserGpioTimer() const {
#endif
}
void CommonCLI::servicePendingTxPower() {
if (!_tx_power_pending || (_tx_power_retry_at
&& static_cast<int32_t>(millis() - _tx_power_retry_at) < 0)) return;
// A pending normal setting must not become a power-meter sweep if a CW
// test started after the setting was accepted.
const mesh::Radio* radio = _callbacks->getProfileRadio();
if (radio && radio->isCarrierWaveActive()) return;
const auto result = _callbacks->trySetTxPower(_prefs->tx_power_dbm);
if (result == mesh::RadioParamApplyResult::APPLIED) _tx_power_pending = false;
_tx_power_retry_at = result == mesh::RadioParamApplyResult::FAILED
? millis() + 1000 : 0;
if (result == mesh::RadioParamApplyResult::FAILED && !_tx_power_retry_at)
_tx_power_retry_at = 1;
}
void CommonCLI::applyAndSaveTxPower(int8_t power, char* reply) {
using Result = mesh::RadioParamApplyResult;
const int8_t previous = _prefs->tx_power_dbm;
const bool previous_pending = _tx_power_pending;
const uint32_t previous_retry_at = _tx_power_retry_at;
const auto result = _callbacks->trySetTxPower(power);
if (result == Result::FAILED) {
// A failed SPI write may have changed the hardware partially. Reassert
// the saved desired power without accepting or saving the rejected one.
_tx_power_pending = true;
_tx_power_retry_at = 0;
mesh::cli::formatError(reply, 160, "TX power rejected");
return;
}
_prefs->tx_power_dbm = power;
if (trySavePrefs()) {
_tx_power_pending = result == Result::BUSY;
_tx_power_retry_at = 0;
strcpy(reply, _tx_power_pending ? "OK - saved; pending" : "OK");
return;
}
_prefs->tx_power_dbm = previous;
_tx_power_pending = previous_pending;
_tx_power_retry_at = previous_retry_at;
if (result == Result::APPLIED) {
const auto restored = _callbacks->trySetTxPower(previous);
_tx_power_pending = restored != Result::APPLIED;
_tx_power_retry_at = restored == Result::FAILED ? millis() + 1000 : 0;
if (restored == Result::FAILED && !_tx_power_retry_at) _tx_power_retry_at = 1;
}
mesh::cli::formatError(reply, 160, "TX save unconfirmed; reboot may differ");
}
// Old fork firmware persisted the (since removed) NodePrefs MQTT fields to /com_prefs
// as a zero-filled gap between owner_info (which ends at offset 290) and a trailing
// observer block (rx_boosted_gain, flood_max_*, snmp/watchdog/alert settings).
@@ -5322,12 +5372,8 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep
} else if (!mesh::isLoRaTxPowerValid(power, _prefs->freq)) {
sprintf(reply, "Error: TX power range is %d to %d dBm",
(int)minimum, (int)maximum);
} else if (!_callbacks->setTxPower(static_cast<int8_t>(power))) {
mesh::cli::formatError(reply, 160, "radio busy or TX power rejected");
} else {
_prefs->tx_power_dbm = static_cast<int8_t>(power);
savePrefs();
strcpy(reply, "OK");
applyAndSaveTxPower(static_cast<int8_t>(power), reply);
}
} else if (memcmp(config, "freq ", 5) == 0) {
float freq = 0.0f;
@@ -6045,6 +6091,12 @@ void CommonCLI::handleGetCmd(uint32_t sender_timestamp, char* command, char* rep
} else {
strcpy(reply, "> strict");
}
#if defined(MESH_HIL_TX_FREQ_MHZ)
} else if (configKeyEquals(config, "tx.status")) {
sprintf(reply, "> desired=%d,apply=%s", (int)_prefs->tx_power_dbm,
!_tx_power_pending && _callbacks->isTxPowerApplied(_prefs->tx_power_dbm)
? "applied" : "pending");
#endif
} else if (configKeyEquals(config, "tx")) {
sprintf(reply, "> %d", (int32_t) _prefs->tx_power_dbm);
} else if (configKeyEquals(config, "freq")) {
+12
View File
@@ -564,6 +564,13 @@ public:
virtual void eraseLogFile() = 0;
virtual void dumpLogFile() = 0;
virtual bool setTxPower(int8_t power_dbm) = 0;
virtual mesh::RadioParamApplyResult trySetTxPower(int8_t power_dbm) {
return setTxPower(power_dbm) ? mesh::RadioParamApplyResult::APPLIED
: mesh::RadioParamApplyResult::FAILED;
}
#if defined(MESH_HIL_TX_FREQ_MHZ)
virtual bool isTxPowerApplied(int8_t) const { return false; }
#endif
virtual void formatNeighborsReply(char *reply) = 0;
// A zero-length prefix clears the whole table; pubkey may be null then.
// Nonempty prefixes must contain 1..PUB_KEY_SIZE bytes.
@@ -941,6 +948,8 @@ class CommonCLI {
bool _com_prefs_needs_upgrade = false; // old-format legacy prefs detected; rewrite once after load
bool _common_save_result_known = false;
bool _common_save_succeeded = false;
bool _tx_power_pending = false;
uint32_t _tx_power_retry_at = 0;
uint32_t _prefs_save_failures = 0;
mesh::RadioProfileCLI _radio_profiles;
#if MESH_ENABLE_TX_BEACONS
@@ -961,6 +970,7 @@ class CommonCLI {
PrefsSaveRouting::Scope scope = PrefsSaveRouting::Scope::Common);
bool trySavePrefs(
PrefsSaveRouting::Scope scope = PrefsSaveRouting::Scope::Common);
void applyAndSaveTxPower(int8_t power, char* reply);
bool applyAndSaveAdcMultiplier(float multiplier, char* reply, size_t capacity);
bool saveObserverPrefs();
void loadPrefsInt(FILESYSTEM* _fs, const char* filename);
@@ -1051,6 +1061,8 @@ public:
#endif
uint8_t batteryPercent(uint16_t mv) const;
bool hasActiveUserGpioTimer() const;
bool hasPendingTxPower() const { return _tx_power_pending; }
void servicePendingTxPower();
void handleCommand(uint32_t sender_timestamp, char* command, char* reply);
mesh::MainBoard* getBoard() { return _board; }
uint8_t buildAdvertData(uint8_t node_type, uint8_t* app_data);
+4
View File
@@ -202,6 +202,10 @@ void CommonRadioPrefs::formatRxPowerSavingStatus(char* reply, size_t reply_size)
RXPowerSavingCLI::get(config, status, sf, bw, reply, reply_size, wire_preamble);
}
#if defined(NRF52_PLATFORM)
// Keep shared radio parsing out of the Companion's larger command dispatcher.
__attribute__((noinline))
#endif
bool CommonRadioPrefs::handleCommand(const char* command, uint32_t sender_timestamp, char* reply) {
(void)sender_timestamp;
if (command == nullptr || reply == nullptr) return false;
+15 -1
View File
@@ -68,9 +68,23 @@ public:
bool isCalibratingNoiseFloor() const { return false; }
uint32_t intID();
bool isTxPowerValid(int8_t dbm) const {
// The SDK accepts 8..84 quarter-dBm units; the public API uses whole dBm.
if (dbm < 2 || dbm > 21) return false;
#ifdef MIN_LORA_TX_POWER
if (dbm < MIN_LORA_TX_POWER) return false;
#endif
#ifdef MAX_LORA_TX_POWER
if (dbm > MAX_LORA_TX_POWER) return false;
#endif
return true;
}
bool isTxPowerApplied(int8_t dbm) const {
return initialized_ && tx_power_dbm_ == dbm;
}
bool setTxPower(int8_t dbm);
mesh::RadioParamApplyResult trySetTxPower(int8_t dbm) {
return setTxPower(dbm) ? mesh::RadioParamApplyResult::APPLIED
return isTxPowerValid(dbm) && setTxPower(dbm) ? mesh::RadioParamApplyResult::APPLIED
: mesh::RadioParamApplyResult::FAILED;
}
};
File diff suppressed because it is too large. Load diff
@@ -139,12 +139,10 @@ public:
}
protected:
#if MESH_ENABLE_TX_BEACONS
bool checkBeaconOutputPower(float frequency_mhz, int8_t dbm) const override {
(void)frequency_mhz;
return dbm >= _min_dbm && dbm <= _max_dbm;
}
#endif
// Emit a gain-control code. Override for a PA driven by PWM or an external
// DAC instead of the MCU's own.
virtual void writeGainControl(uint8_t code) {
+33 -2
View File
@@ -324,8 +324,33 @@ bool RadioLibWrapper::setTxPower(int8_t dbm) {
return trySetTxPower(dbm) == mesh::RadioParamApplyResult::APPLIED;
}
bool RadioLibWrapper::isTxPowerValid(int8_t dbm) const {
#ifdef MIN_LORA_TX_POWER
if (dbm < MIN_LORA_TX_POWER) return false;
#endif
#ifdef MAX_LORA_TX_POWER
if (dbm > MAX_LORA_TX_POWER) return false;
#endif
#if MESH_RADIO_PROFILE_CHIP == 1121 || MESH_RADIO_PROFILE_CHIP == 2021
// These drivers intentionally clip desired power to the physically selected
// band's PA limit. A temporary/radio2 band must not reject saved intent.
return true;
#else
return checkBeaconOutputPower(_cur_freq, dbm);
#endif
}
bool RadioLibWrapper::isTxPowerApplied(int8_t dbm) const {
#if MESH_ENABLE_TX_BEACONS
if (_beacon_owned) return false;
#endif
return !_cw_active && _dbm_valid && _dbm_applied && _cur_dbm == dbm
&& !_profile_repair_required;
}
mesh::RadioParamApplyResult RadioLibWrapper::trySetTxPower(int8_t dbm) {
using Result = mesh::RadioParamApplyResult;
if (!isTxPowerValid(dbm)) return Result::FAILED;
#if MESH_ENABLE_TX_BEACONS
if (_beacon_owned) return Result::BUSY;
#endif
@@ -348,7 +373,8 @@ mesh::RadioParamApplyResult RadioLibWrapper::trySetTxPower(int8_t dbm) {
// Permit meter sweeps through the board's normal power/PA calibration.
// SX127x's power setter enters standby, so re-key without resetting the
// modem or extending the original CW deadline.
if (applyCachedTxPower(dbm) != RADIOLIB_ERR_NONE) {
_dbm_applied = applyCachedTxPower(dbm) == RADIOLIB_ERR_NONE;
if (!_dbm_applied) {
stopCarrierWave();
return Result::FAILED;
}
@@ -882,6 +908,7 @@ bool RadioLibWrapper::validateBeaconPower(float frequency_mhz, int8_t power) con
return isfinite(frequency_mhz) && mesh::isLoRaTxPowerValid(power, frequency_mhz)
&& checkBeaconOutputPower(frequency_mhz, power);
}
#endif
bool RadioLibWrapper::checkBeaconOutputPower(float frequency_mhz, int8_t power) const {
(void)frequency_mhz;
@@ -911,6 +938,7 @@ bool RadioLibWrapper::checkBeaconOutputPower(float frequency_mhz, int8_t power)
#endif
}
#if MESH_ENABLE_TX_BEACONS
uint32_t RadioLibWrapper::getBeaconAirtime(const mesh::RadioProfileParams& p, int length) {
if (length <= 0 || length > MAX_TRANS_UNIT || !validateProfile(p)) return 0;
const uint16_t preamble = p.protocol == mesh::RadioProfileProtocol::Meshtext
@@ -967,6 +995,7 @@ mesh::RadioParamApplyResult RadioLibWrapper::prepareBeaconTransmit(
// an exclusive lease until the ordinary receiver has been restored.
_beacon_restore_profile = _active_profile;
_beacon_owned = true;
_dbm_applied = false;
_beacon_restore_at = 0;
_beacon_cad_timeout = cadScanTimeoutMillis(p.sf, p.bw);
_physical_preamble = p.protocol == mesh::RadioProfileProtocol::Meshtext
@@ -1019,7 +1048,7 @@ mesh::RadioParamApplyResult RadioLibWrapper::restoreBeaconReceive() {
#if MESH_ENABLE_MESHTEXT_WIRE || MESH_ENABLE_ETHERNET_WIRE
restored = restored && applyProtocol(p.protocol);
#endif
restored = restored && applyCachedTxPower(_cur_dbm) == RADIOLIB_ERR_NONE;
restored = _dbm_applied = restored && applyCachedTxPower(_cur_dbm) == RADIOLIB_ERR_NONE;
endProfileRetune(restored);
if (restored) {
// No RX ran on the beacon tuple, so any remaining event belongs to its
@@ -2163,6 +2192,7 @@ bool RadioLibWrapper::stopCarrierWave() {
if (_morse_rx_owned && _morse_rx) _morse_rx->stop(static_cast<uint32_t>(millis()));
#endif
_cw_stopping = true;
_dbm_applied = false;
// Deassert external TX even if SPI fails. SX127x must also rebuild its LoRa
// modem after FSK direct mode before normal packet work can resume.
const int16_t status = _radio->finishTransmit();
@@ -2184,6 +2214,7 @@ bool RadioLibWrapper::stopCarrierWave() {
restored = status == RADIOLIB_ERR_NONE || standby_status == RADIOLIB_ERR_NONE
? restoreCarrierWaveModem() : restoreAfterDeepInit();
}
_dbm_applied = restored && _dbm_valid;
if (!restored) {
_cw_retry_at = static_cast<uint32_t>(millis()) + 100;
return false; // hold normal work until stopping the carrier is confirmed
+2 -2
View File
@@ -355,11 +355,9 @@ protected:
virtual int16_t applyCachedTxPower(int8_t dbm) {
return _radio->setOutputPower(dbm);
}
#if MESH_ENABLE_TX_BEACONS
// External amplifiers override this in antenna dBm. The public capability
// still checks the board's explicit limits before consulting this hook.
virtual bool checkBeaconOutputPower(float frequency_mhz, int8_t dbm) const;
#endif
virtual bool applyRxBoostedGainMode(bool) { return false; }
#if MESH_ENABLE_CARRIER_TOOLS || !defined(STM32_PLATFORM)
virtual int16_t enterCarrierWave() { return _radio->transmitDirect(); }
@@ -574,6 +572,8 @@ public:
bool setParams(float freq, float bw, uint8_t sf, uint8_t cr,
const uint32_t* rx_ps_timings = NULL);
uint32_t getRngSeed();
bool isTxPowerValid(int8_t dbm) const;
bool isTxPowerApplied(int8_t dbm) const;
bool setTxPower(int8_t dbm);
mesh::RadioParamApplyResult trySetTxPower(int8_t dbm);
+2
View File
@@ -75,6 +75,8 @@ public:
== mesh::RadioParamApplyResult::APPLIED;
}
void powerOff() {}
bool isTxPowerValid(int8_t /*dbm*/) const { return true; }
bool isTxPowerApplied(int8_t /*dbm*/) const { return true; }
bool setTxPower(int8_t /*dbm*/) { return true; }
mesh::RadioParamApplyResult trySetTxPower(int8_t /*dbm*/) {
return mesh::RadioParamApplyResult::APPLIED;
+16
View File
@@ -9,6 +9,8 @@
// Persistence has its own production-parser tests with an in-memory FS.
// This fixture isolates the actual CommonCLI setting branches and callbacks.
namespace mesh {
enum class RadioParamApplyResult : uint8_t { APPLIED, BUSY, FAILED };
struct Radio { bool isCarrierWaveActive() const { return false; } };
struct RadioProfileCLI {
static bool parseSuffix(const char* input, unsigned fields, char* legacy,
size_t size, uint16_t& preamble) {
@@ -61,11 +63,18 @@ struct Callbacks {
bool gain=false;
void savePrefs() { ++saves; }
bool setTxPower(int8_t value) { ++tx_calls; if (!accept) return false; power=value; return true; }
mesh::RadioParamApplyResult trySetTxPower(int8_t value) {
return setTxPower(value) ? mesh::RadioParamApplyResult::APPLIED
: mesh::RadioParamApplyResult::FAILED;
}
bool isTxPowerApplied(int8_t value) const { return power == value; }
const mesh::Radio* getProfileRadio() const { return nullptr; }
bool setRxBoostedGain(bool value) { ++gain_calls; if (!accept) return false; gain=value; return true; }
bool configSideDetectors(const uint8_t*,uint8_t,float) { return accept; }
};
struct Board { unsigned n_cad_busy=0; };
bool isValidLoRaBandwidth(float bw) { return bw==125 || bw==62.5f; }
uint32_t millis() { return 1000; }
void recalcRxPowerSavingFromLevel(uint8_t,uint8_t,float,uint8_t,uint32_t*,uint32_t*) {}
void appendRxPowerSavingAdjustmentNote(char*,Prefs*,uint8_t,float) {}
@KEY_EQUALS@
@@ -78,7 +87,12 @@ public:
Prefs* _prefs=&prefs;
Callbacks* _callbacks=&callbacks;
Board* _board=&board;
bool _tx_power_pending=false;
uint32_t _tx_power_retry_at=0;
void savePrefs() { callbacks.savePrefs(); }
bool trySavePrefs() { savePrefs(); return true; }
void applyAndSaveTxPower(int8_t power, char* reply);
void servicePendingTxPower();
bool savePrimaryRadioParams(float freq, float bw, uint8_t sf, uint8_t cr, uint16_t) {
prefs.freq=freq; prefs.bw=bw; prefs.sf=sf; prefs.cr=cr;
prefs.tx_power_dbm=mesh::clampLoRaTxPower(prefs.tx_power_dbm,freq);
@@ -104,6 +118,8 @@ public:
}
};
@TX_METHODS@
struct Case { const char *key, *value; double expected; };
int main() {
// Local USB and authenticated on-air calls use the same infrastructure CLI.
+252
View File
@@ -0,0 +1,252 @@
#include <cassert>
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <deque>
#include <initializer_list>
#include <vector>
#include <helpers/CLICommandUtils.h>
#include <helpers/PrefsSaveRouting.h>
#define MESH_HIL_TX_FREQ_MHZ 909.5f
#define MIN_LORA_TX_POWER -9
#define MAX_LORA_TX_POWER 22
#define MIN_LOCAL_ADVERT_INTERVAL 60
#include <helpers/radiolib/RadioPowerLimits.h>
static uint32_t now_ms = 1000;
static bool completion_seen = false;
uint32_t millis() { return now_ms; }
namespace mesh {
@ENUM@
struct Radio {
bool carrier = false, morse = false;
bool isCarrierWaveActive() const { return carrier; }
};
bool morseTimingActive(const Radio* radio) { return radio && radio->morse; }
}
using Result = mesh::RadioParamApplyResult;
struct CallbackBase {
virtual bool setTxPower(int8_t) = 0;
@DEFAULT_CALLBACK@
@DEFAULT_APPLIED@
};
struct LegacyCallbacks : CallbackBase {
bool accept = true;
bool setTxPower(int8_t) override { return accept; }
};
struct Prefs {
int8_t tx_power_dbm = 10;
float freq = 915;
uint8_t advert_interval = 0;
void clearDirty() {}
};
class CommonCLI;
struct Callbacks : CallbackBase {
CommonCLI* owner = nullptr;
mesh::Radio radio;
std::deque<Result> results;
std::vector<int8_t> attempts;
unsigned saves = 0;
bool save_ok = true, acknowledge = true;
bool power_verified = false, transmitting = false, require_completion = false;
int8_t hardware = 10, persisted = 10;
bool setTxPower(int8_t power) override {
return trySetTxPower(power) == Result::APPLIED;
}
Result trySetTxPower(int8_t power) override {
if (require_completion) assert(completion_seen);
attempts.push_back(power);
const Result result = transmitting ? Result::BUSY
: results.empty() ? Result::APPLIED : results.front();
if (!transmitting && !results.empty()) results.pop_front();
// FAILED deliberately models a partial hardware write.
if (result != Result::BUSY) hardware = power;
if (result != Result::BUSY) power_verified = result == Result::APPLIED;
return result;
}
bool isTxPowerApplied(int8_t power) const override {
return power_verified && hardware == power;
}
mesh::Radio* getProfileRadio() { return &radio; }
void updateAdvertTimer() {}
void savePrefs(PrefsSaveRouting::Scope);
};
@KEY_EQUALS@
class CommonCLI {
public:
Prefs prefs;
Callbacks callbacks;
Prefs* _prefs = &prefs;
Callbacks* _callbacks = &callbacks;
bool _tx_power_pending = false;
uint32_t _tx_power_retry_at = 0;
bool _common_save_result_known = false;
bool _common_save_succeeded = false;
struct Profiles { void loop() {} } _radio_profiles;
struct MorseReply { void complete(const void*) {} } _morse_reply;
MorseReply& morseReply() { return _morse_reply; }
CommonCLI() { callbacks.owner = this; }
@PENDING_QUERY@
void loopManagement() {}
void loop();
void applyAndSaveTxPower(int8_t, char*);
void servicePendingTxPower();
bool trySavePrefs(PrefsSaveRouting::Scope scope = PrefsSaveRouting::Scope::Common);
void command(const char* text, char* reply) {
if (!strncmp(text, "set ", 4)) {
const char* config = text + 4;
@SET@
else strcpy(reply, "unknown config");
} else if (!strncmp(text, "get ", 4)) {
const char* config = text + 4;
@GET@
else strcpy(reply, "unknown config");
} else strcpy(reply, "unknown command");
}
};
void Callbacks::savePrefs(PrefsSaveRouting::Scope scope) {
assert(scope == PrefsSaveRouting::Scope::Common);
++saves;
if (acknowledge) {
owner->_common_save_result_known = true;
owner->_common_save_succeeded = save_ok;
}
if (save_ok) persisted = owner->prefs.tx_power_dbm;
}
@METHODS@
int main() {
LegacyCallbacks legacy;
assert(!legacy.isTxPowerApplied(10));
assert(legacy.trySetTxPower(14) == Result::APPLIED);
assert(!legacy.isTxPowerApplied(14)); // The legacy bool setter cannot prove cache validity.
legacy.accept = false;
assert(legacy.trySetTxPower(14) == Result::FAILED);
char reply[160];
{
CommonCLI cli;
// Boot and full-radio recovery can own the outstanding hardware apply,
// with no dedicated TX queue flag. Never infer success from that flag.
assert(!cli.hasPendingTxPower() && cli.callbacks.hardware == cli.prefs.tx_power_dbm);
cli.command("get tx.status", reply);
assert(!strcmp(reply, "> desired=10,apply=pending"));
cli.callbacks.power_verified = true;
cli.command("get tx.status", reply);
assert(!strcmp(reply, "> desired=10,apply=applied"));
cli.callbacks.hardware = 12;
cli.command("get tx.status", reply);
assert(!strcmp(reply, "> desired=10,apply=pending"));
assert(cli.callbacks.saves == 0 && cli.callbacks.attempts.empty());
}
{
CommonCLI cli;
cli.callbacks.results = {Result::BUSY, Result::BUSY, Result::APPLIED};
cli.command("set tx 14", reply);
assert(!strcmp(reply, "OK - saved; pending") && cli.hasPendingTxPower());
assert(cli.prefs.tx_power_dbm == 14 && cli.callbacks.persisted == 14);
assert(cli.callbacks.hardware == 10 && cli.callbacks.saves == 1);
cli.command("get tx", reply); assert(!strcmp(reply, "> 14"));
cli.command("get tx.status", reply);
assert(!strcmp(reply, "> desired=14,apply=pending"));
cli.loop(); assert(cli.hasPendingTxPower() && cli.callbacks.saves == 1);
cli.loop(); assert(!cli.hasPendingTxPower() && cli.callbacks.hardware == 14);
cli.loop(); assert(cli.callbacks.attempts.size() == 3 && cli.callbacks.saves == 1);
cli.command("get tx.status", reply);
assert(!strcmp(reply, "> desired=14,apply=applied"));
}
{
CommonCLI cli;
cli.callbacks.results = {Result::BUSY, Result::BUSY, Result::APPLIED};
cli.command("set tx 14", reply); cli.command("set tx 16", reply);
cli.loop();
assert(!cli.hasPendingTxPower() && cli.callbacks.hardware == 16);
assert(cli.prefs.tx_power_dbm == 16 && cli.callbacks.persisted == 16);
assert(cli.callbacks.attempts == std::vector<int8_t>({14, 16, 16}));
assert(cli.callbacks.saves == 2);
}
{
CommonCLI cli;
cli.callbacks.results = {Result::BUSY, Result::BUSY};
cli.command("set tx 14", reply);
cli._tx_power_retry_at = now_ms + 500;
cli.callbacks.save_ok = false;
cli.command("set tx 16", reply);
assert(!strncmp(reply, "Error", 5) && cli.prefs.tx_power_dbm == 14);
assert(cli.hasPendingTxPower() && cli._tx_power_retry_at == now_ms + 500);
cli.loop(); assert(cli.callbacks.attempts.size() == 2);
now_ms += 500; cli.loop();
assert(cli.callbacks.hardware == 14 && !cli.hasPendingTxPower());
}
for (Result restore : {Result::APPLIED, Result::BUSY, Result::FAILED}) {
CommonCLI cli;
cli.callbacks.save_ok = false;
cli.callbacks.results = {Result::APPLIED, restore};
cli.command("set tx 16", reply);
assert(!strncmp(reply, "Error", 5) && cli.prefs.tx_power_dbm == 10);
assert(cli.callbacks.saves == 1 && cli.callbacks.persisted == 10);
assert(cli.callbacks.attempts == std::vector<int8_t>({16, 10}));
assert(cli.hasPendingTxPower() == (restore != Result::APPLIED));
cli.command("get tx.status", reply);
assert(!strcmp(reply, restore == Result::APPLIED
? "> desired=10,apply=applied" : "> desired=10,apply=pending"));
if (restore == Result::FAILED) {
// A partial restore must remain unverified even if another recovery
// owner retires the dedicated TX queue before a successful write.
cli._tx_power_pending = false;
cli.command("get tx.status", reply);
assert(!strcmp(reply, "> desired=10,apply=pending"));
cli._tx_power_pending = true;
const auto count = cli.callbacks.attempts.size();
now_ms += 999; cli.loop(); assert(cli.callbacks.attempts.size() == count);
++now_ms;
}
cli.loop(); assert(!cli.hasPendingTxPower() && cli.callbacks.hardware == 10);
assert(cli.callbacks.saves == 1);
}
{
CommonCLI cli;
cli.callbacks.results = {Result::FAILED};
cli.command("set tx 16", reply);
assert(!strncmp(reply, "Error", 5) && cli.prefs.tx_power_dbm == 10);
assert(cli.callbacks.saves == 0 && cli.hasPendingTxPower());
cli.loop(); assert(cli.callbacks.hardware == 10 && !cli.hasPendingTxPower());
}
for (uint32_t start : {1000U, UINT32_MAX - 999U, UINT32_MAX - 500U}) {
now_ms = start;
CommonCLI cli;
cli.callbacks.results = {Result::BUSY, Result::FAILED, Result::APPLIED};
cli.command("set tx 14", reply); cli.loop();
assert(cli.hasPendingTxPower() && cli._tx_power_retry_at != 0);
const auto count = cli.callbacks.attempts.size();
now_ms = start + 999; cli.loop(); assert(cli.callbacks.attempts.size() == count);
now_ms = cli._tx_power_retry_at; cli.loop();
assert(!cli.hasPendingTxPower() && cli.callbacks.hardware == 14);
assert(cli.callbacks.saves == 1);
}
{
CommonCLI cli;
cli.callbacks.results = {Result::BUSY, Result::APPLIED};
cli.command("set tx 14", reply);
cli.callbacks.radio.carrier = true; cli.loop();
assert(cli.callbacks.attempts.size() == 1 && cli.hasPendingTxPower());
cli.callbacks.radio.carrier = false; cli.callbacks.radio.morse = true; cli.loop();
assert(cli.callbacks.attempts.size() == 1 && cli.hasPendingTxPower());
cli.callbacks.radio.morse = false; cli.loop();
assert(cli.callbacks.hardware == 14 && !cli.hasPendingTxPower());
}
{
CommonCLI cli;
cli.command("set tx 14", reply); assert(!strcmp(reply, "OK"));
cli.callbacks.acknowledge = false;
cli.command("set tx 16", reply);
assert(!strncmp(reply, "Error", 5) && cli.prefs.tx_power_dbm == 14);
assert(cli.callbacks.hardware == 14 && !cli.hasPendingTxPower());
}
for (const char* value : {"garbage", "14tail", "-10", "23", "256", "2147483648"}) {
CommonCLI cli;
char command[80]; snprintf(command, sizeof(command), "set tx %s", value);
cli.command(command, reply);
assert(!strncmp(reply, "Error", 5) && cli.prefs.tx_power_dbm == 10);
assert(cli.callbacks.saves == 0 && cli.callbacks.attempts.empty());
}
}
+3 -1
View File
@@ -23,10 +23,11 @@ struct CommonCLI {
ACL* _acl;
Callbacks* _callbacks;
struct Profiles { unsigned loops = 0; void loop() { ++loops; } } _radio_profiles;
unsigned management_loops = 0;
unsigned management_loops = 0, tx_services = 0;
CommonCLI(ACL& acl, Callbacks& callbacks) : _acl(&acl), _callbacks(&callbacks) {}
void loopBatteryForwarding(bool = false) {}
void loopManagement() { ++management_loops; }
void servicePendingTxPower() { ++tx_services; }
void loop();
};
@LOOP@
@@ -56,6 +57,7 @@ int main() {
assert(radio.rx_starts == unsigned(allowed) && !cli._morse_reply.pending());
assert(acl.requested_key_size == 32 && cli._radio_profiles.loops == unsigned(allowed ? 1 : 2));
assert(cli.management_loops == unsigned(allowed ? 1 : 2));
assert(cli.tx_services == cli.management_loops);
mesh::usable = true; mesh::current = mesh::MorseSettings();
}
}
+2
View File
@@ -169,6 +169,7 @@ public:
FakeTrigger* alert_tasks[4] = {&alert1, &alert2};
int num_alert_tasks = 2;
struct CLI {
void servicePendingTxPower() {}
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;
@@ -179,6 +180,7 @@ public:
SensorMesh* owner = nullptr;
void loop();
bool hasActiveUserGpioTimer() const { return active; }
bool hasPendingTxPower() const { return false; }
} _cli;
struct ClockSync { unsigned observed = 0, loops = 0;
void loop() { ++loops; }
+1
View File
@@ -95,6 +95,7 @@ struct CLI {
bool scheduled = false;
unsigned attempts = 0;
bool hasActiveUserGpioTimer() const { return false; }
bool hasPendingTxPower() const { return false; }
void loop();
};
class SensorMesh : public mesh::Mesh {
+1
View File
@@ -88,6 +88,7 @@ struct RadioLibWrapper {
virtual bool supportsMeshtext() const { return false; }
virtual bool applyProtocol(mesh::RadioProfileProtocol p) { return p == mesh::RadioProfileProtocol::MeshCore; }
virtual int16_t applyCachedTxPower(int8_t) { return 0; }
virtual bool checkBeaconOutputPower(float, int8_t) const { return false; }
bool setTxPower(int8_t value) { return applyCachedTxPower(value) == 0; }
bool radioDeepInit() { _radio->resetPacketDefaults(); return true; }
bool registerInterruptAction() { return true; }
+7
View File
@@ -175,6 +175,7 @@ struct RadioLibWrapper {
void onSendFinished();
uint32_t getBeaconAirtime(const mesh::RadioProfileParams&, int);
bool validateBeaconPower(float, int8_t) const;
bool isTxPowerApplied(int8_t) const;
virtual bool checkBeaconOutputPower(float, int8_t) const;
void cacheBeaconRestoreParams();
Result prepareBeaconTransmit(const mesh::RadioProfileParams&, int8_t);
@@ -197,6 +198,7 @@ int main() {
assert(!w.restoreAfterDeepInit() && !w._dbm_applied);
w.power_fail = false;
assert(w.restoreAfterDeepInit() && w._dbm_applied);
assert(w.isTxPowerApplied(20));
++cases;
}
for (bool dual : {false, true}) for (bool rxps : {false, true}) for (bool mt : {false, true}) {
@@ -210,6 +212,7 @@ int main() {
w._rx_ps_enabled = w._rx_ps_armed = rxps;
const auto before = w._profiles;
assert(w.prepareBeaconTransmit(beacon(mt), 10) == Result::APPLIED);
assert(!w._dbm_applied && !w.isTxPowerApplied(20) && !w.isTxPowerApplied(10));
assert(w._beacon_owned && w.state == STATE_IDLE && w.arms == 0);
assert(w.modem.freq == 915 && w.modem.power == 10);
assert(w.modem.preamble == (mt ? 16 : 32));
@@ -224,6 +227,7 @@ int main() {
assert(!w.isBeaconChannelBusy() && !w.pending && w.state == STATE_IDLE);
assert(w.restoreBeaconReceive() == Result::APPLIED);
assert(!w._beacon_owned && w.state == STATE_RX && w._rx_ps_armed == rxps);
assert(w.isTxPowerApplied(20));
assert(w.modem.freq == (dual ? 908 : 906) && w.modem.power == 20);
assert(w.modem.protocol == mesh::RadioProfileProtocol::MeshCore);
assert(w.recalibrations == 0 && w.deep_calls == 0);
@@ -247,8 +251,10 @@ int main() {
if (failure == 1) w.protocol_fail = true;
if (failure == 2) w.power_fail = true;
assert(w.prepareBeaconTransmit(beacon(true), 10) == Result::FAILED && w._beacon_owned);
assert(!w._dbm_applied && !w.isTxPowerApplied(20));
w.repair_on_deep = true;
assert(w.restoreBeaconReceive() == Result::APPLIED && !w._beacon_owned);
assert(w.isTxPowerApplied(20));
assert(w.modem.freq == 906 && w.modem.protocol == mesh::RadioProfileProtocol::MeshCore && w.modem.power == 20);
++cases;
}
@@ -388,6 +394,7 @@ class BeaconRadioTest(unittest.TestCase):
"void RadioLibWrapper::onSendFinished()",
"uint32_t RadioLibWrapper::getBeaconAirtime(",
"bool RadioLibWrapper::validateBeaconPower(",
"bool RadioLibWrapper::isTxPowerApplied(",
"bool RadioLibWrapper::checkBeaconOutputPower(",
"void RadioLibWrapper::cacheBeaconRestoreParams()",
"mesh::RadioParamApplyResult RadioLibWrapper::prepareBeaconTransmit(",
+14 -2
View File
@@ -89,7 +89,7 @@ struct Chip {
standby(); if (power_result) return power_result;
power=value; rfo=force_rfo; ramp_time=4; return 0;
}
int checkOutputPower(int8_t value, int8_t* clipped, bool force_hp) {
int checkOutputPower(int8_t value, int8_t* clipped, bool force_hp=false) {
const int minimum=frequency>1000 ? -18 : force_hp || value>14 ? -9 : -17;
const int maximum=frequency>1000 ? 13 : force_hp || value>14 ? 22 : 14;
if (clipped) *clipped=value<minimum ? minimum : value>maximum ? maximum : value;
@@ -193,6 +193,9 @@ struct RadioLibWrapper : mesh::Radio {
}
virtual float getCurrentRSSI() { ++rssi_reads; g_mock_millis+=rssi_latency; return rssi; }
bool setTxPower(int8_t dbm);
bool isTxPowerValid(int8_t dbm) const;
bool isTxPowerApplied(int8_t dbm) const;
virtual bool checkBeaconOutputPower(float, int8_t) const;
mesh::RadioParamApplyResult trySetTxPower(int8_t dbm);
@NATIVE_HOOKS@
#if MESH_ENABLE_MORSE_RX || !defined(STM32_PLATFORM)
@@ -272,7 +275,9 @@ int main() {
using Result=mesh::RadioParamApplyResult;
for (uint8_t profile : {0,1}) {
RadioLibWrapper w;
assert(w.isTxPowerApplied(17));
assert(w.setCarrierWave(profile,1250)==Result::APPLIED);
assert(!w.isTxPowerApplied(17));
assert(w.frequency==(profile ? 911.5f : 909.5f));
assert(w.chip.carrier && !w.chip.callback && w.board.before==1 && w.board.awake);
assert(w.chip.standby_calls==2 && !w._rx_ps_armed && !w._nf_calib_active);
@@ -280,6 +285,7 @@ int main() {
advance(1249); assert(w.serviceCarrierWave() && w.chip.carrier);
advance(1); assert(w.serviceCarrierWave() && !w.chip.carrier);
assert(!w._cw_active && w.isInRecvMode() && w._rx_ps_armed);
assert(w.isTxPowerApplied(17));
assert(w.frequency==909.5f && w.board.after==1 && !w.board.awake && w.chip.callback);
assert(!w.serviceCarrierWave());
}
@@ -321,11 +327,13 @@ int main() {
w.chip.standby_result=-705;
assert(w.setCarrierWave(0,0)==Result::FAILED);
assert(w._cw_active && w._cw_stopping && w.board.awake && w.board.after==1);
assert(!w._dbm_applied && !w.isTxPowerApplied(17));
const auto attempts=w.chip.stops;
advance(99); w.serviceCarrierWave(); assert(w.chip.stops==attempts);
w.reset_ok=true;
advance(1); w.serviceCarrierWave();
assert(!w._cw_active && !w.chip.carrier && w.board.after==1 && w.isInRecvMode());
assert(w.isTxPowerApplied(17));
}
{
RadioLibWrapper w; w.setCarrierWave(0,100);
@@ -458,6 +466,9 @@ def production_harness():
source = (ROOT/'src/helpers/radiolib/RadioLibWrappers.cpp').read_text()
signatures = ('uint8_t RadioLibWrapper::beginReconfigure(',
'bool RadioLibWrapper::setTxPower(',
'bool RadioLibWrapper::isTxPowerValid(',
'bool RadioLibWrapper::isTxPowerApplied(',
'bool RadioLibWrapper::checkBeaconOutputPower(',
'mesh::RadioParamApplyResult RadioLibWrapper::trySetTxPower(',
'mesh::RadioParamApplyResult RadioLibWrapper::setCarrierWave(',
'uint32_t RadioLibWrapper::carrierWaveRemainingMillis(',
@@ -519,7 +530,8 @@ def production_harness():
'bool validateMorseFrequency(')))
dac = (ROOT/'src/helpers/radiolib/DacPaSX1276Wrapper.h').read_text()
code = code.replace('@DAC_METHODS@', '\n'.join(method(dac, s) for s in (
'int16_t applyCachedTxPower(', 'uint8_t indexForDbm(')))
'int16_t applyCachedTxPower(', 'uint8_t indexForDbm(',
'bool checkBeaconOutputPower(')))
return code
+7
View File
@@ -63,6 +63,7 @@ COMMON_QUERY_EXCEPTIONS = {
'radio.rxps.status': 'Live radio status for set radio.rxps',
'role': 'Build-selected firmware role',
'rxps.wd': 'Runtime RX power-saving recovery counters',
'tx.status': 'HIL-only applied-power verification for guarded RF tests',
'wifi.pwd': 'Setter is in the observer or standalone-WiFi delegate',
}
OBSERVER_QUERY_EXCEPTIONS = {
@@ -84,6 +85,7 @@ class CLISettingsContractTest(unittest.TestCase):
self.assertIsNotNone(compiler, 'Host C++ compiler required')
profiles = {
'minimal': [],
'hil': ['MESH_HIL_TX_FREQ_MHZ'],
'nrf52_gps_sd': ['NRF52_PLATFORM', 'ENV_INCLUDE_GPS', 'OTA_SD_STORE', 'NRF52_POWER_MANAGEMENT'],
'esp32_web': ['ESP_PLATFORM', 'ESP32_PLATFORM', 'ADMIN_PASSWORD', 'MESH_USB_LOGGING_AVAILABLE'],
'esp32_g3': ['ESP_PLATFORM', 'ESP32_PLATFORM', 'STATION_G3_ESP32'],
@@ -216,6 +218,7 @@ class CLISettingsContractTest(unittest.TestCase):
'public.key', # Read-only public identity
'pwrmgt.bootreason', 'role', # Boot/build facts
'radio.rxps.config', # Detail view of radio.rxps
'tx.status', # HIL-only applied-power verification
'wifi.status', 'wifi.ip', # Live WiFi link state
})
@@ -268,6 +271,10 @@ class CLISettingsContractTest(unittest.TestCase):
fixture = fixture.replace('@GET@', ' else '.join(get_blocks))
fixture = fixture.replace('@DISPATCH@', dispatch)
fixture = fixture.replace('@KEY_EQUALS@', extract_braced(source, 'static bool configKeyEquals('))
fixture = fixture.replace('@TX_METHODS@', '\n'.join(
extract_braced(source, signature).replace('CommonCLI::', 'CLI::')
for signature in ('void CommonCLI::applyAndSaveTxPower(',
'void CommonCLI::servicePendingTxPower(')))
cpp.write_text(fixture, encoding='utf-8')
sanitizers = (['-fsanitize=address,undefined', '-fno-sanitize-recover=all',
'-fno-pie', '-no-pie'] if sys.platform.startswith('linux') else [])
+238
View File
@@ -0,0 +1,238 @@
"""Exercise saved TX intent through the production CLI, save and loop methods."""
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]
def preprocess(source, hil=False):
result = subprocess.run([
os.environ.get('CXX', 'g++'), '-E', '-P', '-x', 'c++',
*(['-DMESH_HIL_TX_FREQ_MHZ=909.5f'] if hil else []), '-',
], input=source, text=True, capture_output=True, check=True)
return result.stdout
def production_fixture():
source = (ROOT / 'src/helpers/CommonCLI.cpp').read_text()
header = (ROOT / 'src/helpers/CommonCLI.h').read_text()
dispatcher = (ROOT / 'src/Dispatcher.h').read_text()
code = (ROOT / 'test/fixtures/common_cli_tx_pending.cpp').read_text()
methods = '\n'.join(extract_braced(source, signature) for signature in (
'void CommonCLI::applyAndSaveTxPower(', 'void CommonCLI::servicePendingTxPower(',
'bool CommonCLI::trySavePrefs(', 'void CommonCLI::loop('))
setter = extract_braced(source, 'void CommonCLI::handleSetCmd(')
getter = preprocess(extract_braced(source, 'void CommonCLI::handleGetCmd('), hil=True)
values = {
'METHODS': methods,
'SET': extract_braced(setter, 'if (memcmp(config, "tx ", 3) == 0)'),
'GET': ' else '.join(extract_braced(getter,
f'if (configKeyEquals(config, "{key}"))') for key in ('tx.status', 'tx')),
'KEY_EQUALS': extract_braced(source, 'static bool configKeyEquals('),
'ENUM': extract_braced(dispatcher, 'enum class RadioParamApplyResult') + ';',
'DEFAULT_CALLBACK': extract_braced(header,
'virtual mesh::RadioParamApplyResult trySetTxPower('),
'DEFAULT_APPLIED': extract_braced(header, 'virtual bool isTxPowerApplied('),
'PENDING_QUERY': extract_braced(header, 'bool hasPendingTxPower() const'),
}
for key, value in values.items():
code = code.replace('@' + key + '@', value)
return code
class CommonCLITxPendingTests(unittest.TestCase):
def test_saved_power_applies_automatically_without_resaving(self):
code = production_fixture()
with tempfile.TemporaryDirectory(prefix='meshcore-common-cli-tx-') as directory:
cpp, exe = Path(directory) / 'test.cpp', Path(directory) / 'test'
variants = [('production', code, True),
('drops_pending', code.replace(
'_tx_power_pending = result == Result::BUSY;',
'_tx_power_pending = false;'), False),
('no_background_service', code.replace(
' servicePendingTxPower();', ''), False),
('unverified_applied_claim', code.replace(
' && _callbacks->isTxPowerApplied(_prefs->tx_power_dbm)', ''), False),
('stale_save_ack', code.replace(
' _common_save_result_known = false;\n'
' _common_save_succeeded = false;',
'/* stale save acknowledgement */'), False)]
for name, fixture, succeeds in variants:
with self.subTest(variant=name):
cpp.write_text(fixture, encoding='ascii')
flags = [] if os.name == 'nt' else [
'-fsanitize=address,undefined', '-fno-sanitize-recover=all',
'-fno-pie', '-no-pie']
built = subprocess.run([
os.environ.get('CXX', 'g++'), '-std=c++17', '-Wall', '-Wextra',
'-Werror', *flags, '-I', str(ROOT / 'src'), str(cpp), '-o', str(exe),
], text=True, capture_output=True)
self.assertEqual(built.returncode, 0, built.stdout + built.stderr)
ran = subprocess.run([str(exe)], text=True, capture_output=True)
if succeeds:
self.assertEqual(ran.returncode, 0, ran.stdout + ran.stderr)
else:
self.assertNotEqual(ran.returncode, 0, name + ' escaped detection')
def test_actual_completion_callbacks_apply_before_a_saturated_queue_starts_next_tx(self):
dispatcher = extract_braced((ROOT / 'src/Dispatcher.cpp').read_text(),
'void Dispatcher::loop(')
completed = extract_braced(dispatcher, 'if (_radio->isSendComplete())')
self.assertLess(completed.index('_radio->onSendFinished();'),
completed.index('onSendComplete(outbound);'))
self.assertLess(dispatcher.index('onSendComplete(outbound);'), dispatcher.rindex('checkSend();'))
prefix = production_fixture().split('int main() {', 1)[0]
fixture = r'''
#define MAX_HASH_SIZE 8
#define PAYLOAD_TYPE_REQ 1
namespace mesh {
namespace tracker { constexpr unsigned kReplyWindowSeconds = 30; }
struct Packet {
int getPayloadType() const { return 0; }
void calculatePacketHash(uint8_t* out) const { memset(out, 0, MAX_HASH_SIZE); }
};
struct Mesh {
virtual void onSendComplete(Packet*) { completion_seen = true; }
};
}
struct TxRole : mesh::Mesh {
CommonCLI _cli;
struct Barrier {
bool complete(mesh::Packet*) { return false; }
bool waiting() const { return false; }
} temp_radio_reply_barrier;
mesh::Packet* pending_battery_alert_packet = nullptr;
bool battery_alert_sent = false, tracker_pending = false, tracker_sent = false;
uint64_t uptime_millis = 0, last_battery_alert_sent = 0;
uint32_t last_millis = 0, tracker_reply_until = 0;
uint8_t tracker_packet_hash[MAX_HASH_SIZE] = {};
uint32_t futureMillis(uint32_t delay) const { return millis() + delay; }
void finishRadioReply(bool) {}
void onSendComplete(mesh::Packet*) override;
};
@CALLBACK@
int main() {
// The queue never drains: every completion schedules another packet before
// CommonCLI::loop gets control again. Only the actual completion hook can
// admit the queued TX setting in the idle hardware gap.
for (bool receive_busy_at_first_completion : {false, true}) {
now_ms = 1000;
TxRole role;
auto& cli = role._cli;
auto& driver = cli.callbacks;
mesh::Packet packet;
char reply[160];
driver.transmitting = true;
cli.command("set tx 14", reply);
assert(!strcmp(reply, "OK - saved; pending"));
assert(cli.hasPendingTxPower() && driver.saves == 1 && driver.hardware == 10);
unsigned applied_after = 0;
for (unsigned sent = 1; sent <= 4; ++sent) {
cli.loop(); assert(driver.transmitting);
// Dispatcher calls onSendFinished before the role completion callback.
driver.transmitting = false;
completion_seen = false;
driver.require_completion = true;
if (sent == 1 && receive_busy_at_first_completion) driver.results = {Result::BUSY};
now_ms += 5000;
role.onSendComplete(&packet);
assert(completion_seen && driver.saves == 1);
if (!receive_busy_at_first_completion || sent > 1) {
assert(!cli.hasPendingTxPower() && driver.hardware == 14);
if (!applied_after) applied_after = now_ms;
} else assert(cli.hasPendingTxPower() && driver.hardware == 10);
driver.require_completion = false;
// checkSend() immediately starts the next queued packet. A post-Mesh
// service would now see BUSY for the entire burst.
driver.transmitting = true;
cli.loop();
}
assert(applied_after > 0 && applied_after < 30000);
assert(driver.persisted == 14 && driver.saves == 1);
}
}
'''
with tempfile.TemporaryDirectory(prefix='meshcore-common-cli-tx-burst-') as directory:
cpp, exe = Path(directory) / 'test.cpp', Path(directory) / 'test'
for role, name, filename in (
('simple_repeater', 'MyMesh', 'MyMesh.cpp'),
('simple_room_server', 'MyMesh', 'MyMesh.cpp'),
('simple_sensor', 'SensorMesh', 'SensorMesh.cpp'),
('simple_sensor', 'SensorMesh', 'Tracker.cpp')):
source = (ROOT / 'examples' / role / filename).read_text()
callback = preprocess(extract_braced(source, f'void {name}::onSendComplete('))
callback = callback.replace(name + '::', 'TxRole::')
for omit_gap in (False, True):
with self.subTest(role=role, file=filename, omit_gap=omit_gap):
actual = callback.replace('_cli.servicePendingTxPower();', '') if omit_gap else callback
cpp.write_text(prefix + fixture.replace('@CALLBACK@', actual), encoding='ascii')
flags = [] if os.name == 'nt' else [
'-fsanitize=address,undefined', '-fno-sanitize-recover=all', '-fno-pie', '-no-pie']
built = subprocess.run([
os.environ.get('CXX', 'g++'), '-std=c++17', '-Wall', '-Wextra', '-Werror',
*flags, '-I', str(ROOT / 'src'), str(cpp), '-o', str(exe),
], capture_output=True, text=True)
self.assertEqual(built.returncode, 0, built.stdout + built.stderr)
ran = subprocess.run([str(exe)], capture_output=True, text=True)
if omit_gap:
self.assertNotEqual(ran.returncode, 0, 'missing completion service escaped detection')
else:
self.assertEqual(ran.returncode, 0, ran.stdout + ran.stderr)
def test_applied_power_diagnostic_and_callbacks_are_hil_only(self):
common = (ROOT / 'src/helpers/CommonCLI.cpp').read_text()
header = (ROOT / 'src/helpers/CommonCLI.h').read_text()
getter = extract_braced(common, 'void CommonCLI::handleGetCmd(')
self.assertNotIn('"tx.status"', preprocess(getter))
self.assertIn('"tx.status"', preprocess(getter, hil=True))
self.assertIn('"tx"', preprocess(getter))
def check_guard(text, definition):
guarded = '#if defined(MESH_HIL_TX_FREQ_MHZ)\n' + definition + '\n#endif'
self.assertIn(guarded, text)
self.assertNotIn('isTxPowerApplied', preprocess(guarded))
self.assertIn('isTxPowerApplied', preprocess(guarded, hil=True))
check_guard(header, ' ' + extract_braced(header, 'virtual bool isTxPowerApplied('))
for role, name in (('simple_repeater', 'MyMesh'),
('simple_room_server', 'MyMesh'),
('simple_sensor', 'SensorMesh')):
with self.subTest(role=role):
source = (ROOT / 'examples' / role / (name + '.cpp')).read_text()
role_header = (ROOT / 'examples' / role / (name + '.h')).read_text()
check_guard(source, extract_braced(source, f'bool {name}::isTxPowerApplied('))
check_guard(role_header, ' bool isTxPowerApplied(int8_t power_dbm) const override;')
def test_every_infrastructure_role_preserves_tristate_and_pending_work(self):
for role, name in (('simple_repeater', 'MyMesh'),
('simple_room_server', 'MyMesh'),
('simple_sensor', 'SensorMesh')):
with self.subTest(role=role):
source = (ROOT / 'examples' / role / (name + '.cpp')).read_text()
header = (ROOT / 'examples' / role / (name + '.h')).read_text()
callback = extract_braced(source,
f'mesh::RadioParamApplyResult {name}::trySetTxPower(')
self.assertIn('return radio_driver.trySetTxPower(power_dbm);', callback)
applied = extract_braced(source,
f'bool {name}::isTxPowerApplied(')
self.assertIn('return radio_driver.isTxPowerApplied(power_dbm);', applied)
pending = extract_braced(source,
f'mesh::powerdiag::Mask {name}::pendingWorkReason() const')
# Include pending intent during hardware-failure backoff too:
# nRF52's event-driven sleep has no guaranteed timer wake.
self.assertIn('_cli.hasPendingTxPower()', pending)
self.assertLess(pending.index('_cli.hasPendingTxPower()'),
pending.index('radio_apply_backoff')
if 'radio_apply_backoff' in pending else len(pending))
recovery = extract_braced(header, 'bool canRecoverUsbLogging() const')
self.assertIn('!_cli.hasPendingTxPower()', recovery)
self.assertIn('_cli.loop();', extract_braced(source, f'void {name}::loop('))
if __name__ == '__main__':
unittest.main()
+261 -179
View File
@@ -23,10 +23,18 @@ HARNESS = r'''
#include <helpers/CLICommandUtils.h>
#define MESH_DEBUG_PRINTLN(...) ((void)0)
#define MAX_LORA_TX_POWER 22
#define COMPANION_FEATURE_TEMP_RADIO 0
#define COMPANION_FEATURE_TEMP_RADIO 1
#define COMMAND_RADIO_APPLY_TIMEOUT_MS 3000
@CODES@
namespace mesh { enum class RadioParamApplyResult { APPLIED, BUSY, FAILED }; }
static bool tx_inflight=false;
static std::vector<unsigned> completion_order;
namespace mesh {
enum class RadioParamApplyResult { APPLIED, BUSY, FAILED };
struct Packet {};
struct Mesh {
void onSendComplete(Packet*) { completion_order.push_back(1); }
};
}
using Apply = mesh::RadioParamApplyResult;
static uint32_t now_ms = 100;
struct Clock { uint32_t getMillis() const { return now_ms; } } clock_source;
@@ -49,15 +57,27 @@ void recalcRxPowerSavingFromLevel(uint8_t,uint8_t sf,float bw,uint8_t,
uint32_t* rx,uint32_t* sleep) {
*rx=sf*100; *sleep=uint32_t(bw*100);
}
struct Radio {
bool carrier=false, morse_tx=false, morse_rx=false;
bool isCarrierWaveActive() const { return carrier; }
bool isMorseTxActive() const { return morse_tx; }
bool isMorseRxActive() const { return morse_rx; }
} radio;
struct Driver {
int8_t power=3;
bool partial_failure=false, power_verified=true;
int8_t power=3, invalid_power=127;
unsigned calls=0;
std::deque<Apply> accepted;
bool isTxPowerValid(int8_t requested) const { return requested!=invalid_power; }
bool isTxPowerApplied(int8_t requested) const { return power_verified && power==requested; }
Apply trySetTxPower(int8_t requested) {
++calls;
Apply result=Apply::APPLIED;
if (!accepted.empty()) { result=accepted.front(); accepted.pop_front(); }
if (result==Apply::APPLIED) power=requested;
if (tx_inflight || radio.carrier || radio.morse_tx || radio.morse_rx) result=Apply::BUSY;
if (result!=Apply::BUSY) power_verified=result==Apply::APPLIED;
if (result==Apply::APPLIED || (result==Apply::FAILED && partial_failure)) power=requested;
if (result==Apply::APPLIED) completion_order.push_back(2);
return result;
}
bool setTxPower(int8_t requested) { return trySetTxPower(requested)==Apply::APPLIED; }
@@ -67,7 +87,7 @@ struct Driver {
bool setRxPowerSaving(bool,uint32_t,uint32_t) { return true; }
} radio_driver;
@RETRY_DELAY@
struct MyMesh {
struct MyMesh : mesh::Mesh {
Prefs _prefs, durable, live;
BaseSerialInterface serial, original, other;
BaseSerialInterface* _serial=&serial;
@@ -76,7 +96,10 @@ struct MyMesh {
unsigned saves=0, applies=0;
std::deque<Apply> accepted;
bool saved_radio_apply_pending=false;
uint8_t command_radio_apply_pending=0;
bool tx_power_apply_pending=false, _temp_radio_applied=false;
uint32_t tx_power_apply_retry_at=0, _temp_radio_set_at=0, _temp_radio_revert_at=0;
Radio* _radio=&radio;
bool command_radio_apply_pending=false;
uint32_t radio_apply_retry_at=0, command_radio_apply_deadline=0;
uint8_t radio_apply_failures=0;
float command_radio_freq=0, command_radio_bw=0;
@@ -85,11 +108,18 @@ struct MyMesh {
uint8_t cmd_frame[180]={};
struct Reply { uint8_t error; BaseSerialInterface* route; };
std::vector<Reply> replies;
struct {
void complete(mesh::Packet*) { completion_order.push_back(3); }
} _morse_reply;
@RESULT_ENUM@;
MyMesh() { serial.current=&original; radio_driver=Driver(); now_ms=100; }
MyMesh() {
serial.current=&original;radio_driver=Driver();radio=Radio();now_ms=100;
tx_inflight=false;completion_order.clear();
}
uint32_t futureMillis(uint32_t delay) const { return now_ms+delay; }
bool millisHasNowPassed(uint32_t deadline) const { return int32_t(now_ms-deadline)>=0; }
bool hasOutbound() const { return outbound; }
bool isTempRadioActive() const { return _temp_radio_applied; }
bool isValidClientRepeatFreq(uint32_t) const { return true; }
bool savePrefs() { ++saves; if(!storage_accepts)return false; durable=_prefs; return true; }
void writeOKFrame(BaseSerialInterface* route=nullptr) { replies.push_back({0,route?route:serial.current}); }
@@ -102,15 +132,20 @@ struct MyMesh {
return result;
}
bool applySavedRadioParams();
RadioSettingResult applyAndSaveTxPower(int8_t,bool from_pending=false);
RadioSettingResult applyAndSaveTxPower(int8_t);
void servicePendingTxPower();
void onSendComplete(mesh::Packet*);
void finishRadioParamApply(float,float,uint8_t,uint8_t,uint8_t,BaseSerialInterface* = nullptr);
void deferRadioCommand(uint8_t);
void deferRadioCommand();
void servicePendingRadioParamApply();
void cancelPendingRadioParamApply();
void frame(size_t len) { @FRAME_BRANCHES@ }
void recover() { @RECOVERY@ }
bool command(const char* command,char* reply) {
const size_t reply_capacity=160;
#if defined(MESH_HIL_TX_FREQ_MHZ)
@TX_STATUS_BRANCH@
#endif
@CLI_BRANCH@
return false;
}
@@ -141,13 +176,31 @@ struct MyMesh {
};
@METHODS@
int main() {
// A real hardware rejection remains an immediate error, without a save.
#if defined(MESH_HIL_TX_FREQ_MHZ)
// Saved power alone is not proof of the current hardware state. Startup,
// recovery and unavailable radios must not report an unverified target.
{
MyMesh m;radio_driver.accepted={Apply::FAILED};m.powerFrame(17);
m.expectError(ERR_CODE_BAD_STATE);
assert(!m.command_radio_apply_pending && m.saves==0);
assert(m._prefs.tx_power_dbm==3 && m.durable.tx_power_dbm==3 && radio_driver.power==3);
MyMesh m;char reply[160]={};
assert(m.command("get tx.status",reply) && !strcmp(reply,"> desired=3,apply=applied"));
radio_driver.power_verified=false;
assert(m.command("get tx.status",reply) && !strcmp(reply,"> desired=3,apply=pending"));
radio_driver.power_verified=true;radio_driver.power=7;
assert(m.command("get tx.status",reply) && !strcmp(reply,"> desired=3,apply=pending"));
radio_driver.power=3;m._radio_available=false;
assert(m.command("get tx.status",reply) && !strcmp(reply,"> desired=3,apply=pending"));
m._radio_available=true;m.tx_power_apply_pending=true;
assert(m.command("get tx.status",reply) && !strcmp(reply,"> desired=3,apply=pending"));
m.tx_power_apply_pending=false;
assert(m.command("get tx.status",reply) && !strcmp(reply,"> desired=3,apply=applied"));
assert(m.saves==0 && radio_driver.calls==0);
}
#else
{
MyMesh m;char reply[160]={};
assert(!m.command("get tx.status",reply));
}
#endif
// Invalid input never reaches hardware or storage.
for (int value : {-10,23}) {
MyMesh m;m.powerFrame(uint8_t(value));m.expectError(ERR_CODE_ILLEGAL_ARG);
assert(m.saves==0 && radio_driver.calls==0);
@@ -156,178 +209,197 @@ int main() {
MyMesh m;m.powerFrame(17,1);m.expectError(ERR_CODE_ILLEGAL_ARG);
assert(m.saves==0 && radio_driver.calls==0);
}
// Hardware-specific invalid values cannot become saved intent, even while
// a pending tuple prevents the ordinary hardware setter from being called.
for(bool tuple_pending : {false,true}) {
MyMesh m;radio_driver.accepted={Apply::BUSY};m.powerFrame(17);
m.replies.clear();m.command_radio_apply_pending=tuple_pending;
m.tx_power_apply_retry_at=now_ms+1000;radio_driver.invalid_power=18;
const unsigned calls=radio_driver.calls;
m.powerFrame(18);m.expectError(ERR_CODE_BAD_STATE);
assert(m.tx_power_apply_pending && m.tx_power_apply_retry_at==now_ms+1000);
assert(m._prefs.tx_power_dbm==17 && m.durable.tx_power_dbm==17);
assert(m.saves==1 && radio_driver.calls==calls);
assert(m.command_radio_apply_pending==tuple_pending);
}
// Immediate application still commits before acknowledging the request.
{
MyMesh m;m.powerFrame(17);m.expectError(0);
assert(m.saves==1 && m.durable.tx_power_dbm==17 && radio_driver.power==17);
assert(!m.tx_power_apply_pending && !m.command_radio_apply_pending);
}
{
MyMesh m;m._radio_available=false;m.powerFrame(17);m.expectError(0);
assert(m.durable.tx_power_dbm==17 && radio_driver.calls==0);
}
// App settings can reach power while an RX packet is in progress. Keep the
// original requester and acknowledge only after the hardware and save pass.
// Busy reception accepts one durable intent. It completes automatically,
// without a second command, additional save or delayed protocol reply.
for (int power : {-9,17,22}) {
MyMesh m;radio_driver.accepted={Apply::BUSY,Apply::BUSY,Apply::APPLIED};
m.powerFrame(uint8_t(power));
assert(m.replies.empty() && m.command_radio_apply_pending==2 && m.saves==0);
assert(m.command_radio_reply_route==&m.original);
assert(int8_t(m.command_radio_repeat)==power);
assert(m.command_radio_apply_deadline==now_ms+COMMAND_RADIO_APPLY_TIMEOUT_MS);
assert(m._prefs.tx_power_dbm==3 && m.durable.tx_power_dbm==3 && radio_driver.power==3);
m.serial.current=&m.other;
m.servicePendingRadioParamApply();
assert(m.replies.empty() && m.command_radio_apply_pending==2 && m.saves==0);
m.servicePendingRadioParamApply();m.expectError(0);
assert(!m.command_radio_apply_pending && m.saves==1);
assert(m.command_radio_reply_route==nullptr && m.command_radio_apply_deadline==0);
m.powerFrame(uint8_t(power));m.expectError(0);
assert(m.tx_power_apply_pending && !m.command_radio_apply_pending);
assert(m._prefs.tx_power_dbm==power && m.durable.tx_power_dbm==power);
assert(radio_driver.power==power);
assert(m.saves==1 && radio_driver.power==3);
m.serial.current=&m.other;m.serial.disconnected=&m.original;m.serial.busy=true;
m.servicePendingTxPower();
assert(m.tx_power_apply_pending && m.saves==1 && radio_driver.power==3);
m.servicePendingTxPower();
assert(!m.tx_power_apply_pending && m.saves==1 && radio_driver.power==power);
assert(m.replies.size()==1); // It is saved intent, not an owned reply slot.
}
// Reproduce the app's multi-command settings save: the tuple is durable
// before the following power command encounters a temporary receive lease.
// A long receive lease cannot expire accepted power intent. Keep servicing
// it for more than 30 seconds, then apply once the radio is free.
{
MyMesh m;m.tupleFrame();m.expectError(0);m.replies.clear();
radio_driver.accepted={Apply::BUSY,Apply::APPLIED};m.powerFrame(17);
assert(m.replies.empty() && m.command_radio_apply_pending==2 && m.saves==1);
assert(m.durable.freq==920 && m.durable.tx_power_dbm==3);
m.servicePendingRadioParamApply();m.expectError(0);
assert(m.saves==2 && m.durable.freq==920 && m.durable.tx_power_dbm==17);
}
// Wait for reply-queue headroom before applying or committing the candidate.
{
MyMesh m;radio_driver.accepted={Apply::BUSY,Apply::APPLIED};m.powerFrame(17);
m.serial.current=&m.other;m.serial.busy=true;
const unsigned calls=radio_driver.calls;
m.servicePendingRadioParamApply();
assert(m.replies.empty() && m.saves==0 && radio_driver.calls==calls);
assert(m.command_radio_apply_pending==2 && m.durable.tx_power_dbm==3);
m.serial.busy=false;m.servicePendingRadioParamApply();m.expectError(0);
assert(!m.command_radio_apply_pending && m.durable.tx_power_dbm==17);
}
// A second settings request cannot replace the pending candidate or route.
for (bool tuple_request : {false,true}) {
MyMesh m;radio_driver.accepted={Apply::BUSY,Apply::APPLIED};m.powerFrame(17);
m.serial.current=&m.other;
if(tuple_request)m.tupleFrame();else m.powerFrame(12);
m.expectError(ERR_CODE_BAD_STATE,&m.other);
assert(m.command_radio_apply_pending==2 && m.command_radio_repeat==17);
assert(m.command_radio_reply_route==&m.original && m.saves==0 && radio_driver.calls==1);
char reply[160]={};
assert(m.command("set tx 12",reply) && strstr(reply,"Error"));
assert(m.command_radio_apply_pending==2 && m.command_radio_repeat==17);
assert(m.saves==0 && radio_driver.calls==1 && m._prefs.tx_power_dbm==3);
m.replies.clear();m.servicePendingRadioParamApply();m.expectError(0);
assert(m.durable.tx_power_dbm==17);
}
// The power request also respects a tuple already occupying the shared slot.
{
MyMesh m;m.accepted={Apply::BUSY,Apply::APPLIED};m.tupleFrame();
assert(m.command_radio_apply_pending==1);
m.serial.current=&m.other;m.powerFrame(17);m.expectError(ERR_CODE_BAD_STATE,&m.other);
assert(m.command_radio_apply_pending==1 && radio_driver.calls==0 && m.saves==0);
m.replies.clear();m.servicePendingRadioParamApply();m.expectError(0);
assert(m.durable.freq==920 && m.durable.tx_power_dbm==3);
}
// Losing the original route cancels the unsaved power, even when another
// transport supplies commands before the deferred operation is serviced.
{
MyMesh m;radio_driver.accepted={Apply::BUSY,Apply::APPLIED};m.powerFrame(17);
m.serial.current=&m.other;m.serial.disconnected=&m.original;
m.servicePendingRadioParamApply();
assert(!m.command_radio_apply_pending && m.replies.empty() && m.saves==0);
assert(!m.saved_radio_apply_pending);
assert(m._prefs.tx_power_dbm==3 && m.durable.tx_power_dbm==3 && radio_driver.power==3);
assert(radio_driver.calls==1);
}
{
MyMesh m;m.serial.current=nullptr;radio_driver.accepted={Apply::BUSY};m.powerFrame(17);
assert(m.replies.size()==1 && m.replies[0].error==ERR_CODE_BAD_STATE);
assert(!m.command_radio_apply_pending && m.saves==0 && radio_driver.power==3);
}
// Sustained busy hardware times out once on the captured route. Check both
// the ordinary clock and a deadline that crosses the millis rollover.
for (uint32_t start : {100u,UINT32_MAX-1000u}) {
MyMesh m;now_ms=start;
radio_driver.accepted={Apply::BUSY,Apply::BUSY,Apply::BUSY};m.powerFrame(17);
m.serial.current=&m.other;
const uint32_t deadline=m.command_radio_apply_deadline;
now_ms=deadline-1;m.servicePendingRadioParamApply();
assert(m.replies.empty() && m.command_radio_apply_pending==2 && m.saves==0);
now_ms=deadline;m.servicePendingRadioParamApply();m.expectError(ERR_CODE_BAD_STATE);
assert(!m.command_radio_apply_pending && !m.saved_radio_apply_pending && m.saves==0);
assert(m._prefs.tx_power_dbm==3 && m.durable.tx_power_dbm==3 && radio_driver.power==3);
m.servicePendingRadioParamApply();assert(m.replies.size()==1);
}
// Expiry must not commit a late success, including when queue backpressure
// prevented the timeout reply until after its original deadline.
for (bool blocked_queue : {false,true}) {
MyMesh m;radio_driver.accepted={Apply::BUSY,Apply::APPLIED};m.powerFrame(17);
m.serial.current=&m.other;now_ms=m.command_radio_apply_deadline;
if(blocked_queue) {
m.serial.busy=true;m.servicePendingRadioParamApply();
assert(m.replies.empty() && m.saves==0 && radio_driver.calls==1);
m.serial.busy=false;++now_ms;
MyMesh m;radio_driver.accepted={Apply::BUSY};m.powerFrame(17);m.expectError(0);
for(uint32_t elapsed=0;elapsed<=31000;elapsed+=1000) {
now_ms=100+elapsed;radio_driver.accepted={Apply::BUSY};m.servicePendingTxPower();
assert(m.tx_power_apply_pending && m.durable.tx_power_dbm==17);
assert(m.saves==1 && radio_driver.power==3 && m.replies.size()==1);
}
m.servicePendingRadioParamApply();m.expectError(ERR_CODE_BAD_STATE);
assert(!m.command_radio_apply_pending && !m.saved_radio_apply_pending);
assert(m.saves==0 && radio_driver.calls==1);
assert(m.durable.tx_power_dbm==3 && radio_driver.power==3);
++now_ms;radio_driver.accepted={Apply::APPLIED};m.servicePendingTxPower();
assert(!m.tx_power_apply_pending && radio_driver.power==17 && m.saves==1);
assert(m.replies.size()==1); // No resend or reboot was required.
}
// A later hardware rejection must not be mistaken for another busy retry.
// Recovery-mode firmware can accept a saved target before radio startup.
{
MyMesh m;radio_driver.accepted={Apply::BUSY,Apply::FAILED};m.powerFrame(17);
m.serial.current=&m.other;m.servicePendingRadioParamApply();m.expectError(ERR_CODE_BAD_STATE);
assert(!m.command_radio_apply_pending && !m.saved_radio_apply_pending && m.saves==0);
assert(m.durable.tx_power_dbm==3 && radio_driver.power==3);
MyMesh m;m._radio_available=false;m.powerFrame(17);m.expectError(0);
assert(m.tx_power_apply_pending && m.durable.tx_power_dbm==17 && radio_driver.calls==0);
m.servicePendingTxPower();assert(radio_driver.calls==0);
m._radio_available=true;m.servicePendingTxPower();
assert(!m.tx_power_apply_pending && radio_driver.power==17 && m.saves==1);
}
// Deferred commits retain the same storage-failure rollback as immediate
// requests, and cannot send a later success after reporting the failure.
for (bool rollback_busy : {false,true}) {
MyMesh m;m.storage_accepts=false;
radio_driver.accepted={Apply::BUSY,Apply::APPLIED,
rollback_busy?Apply::BUSY:Apply::APPLIED,Apply::APPLIED};
m.powerFrame(17);assert(m.replies.empty() && m.saves==0);
m.serial.current=&m.other;m.servicePendingRadioParamApply();m.expectError(ERR_CODE_FILE_IO_ERROR);
assert(!m.command_radio_apply_pending && m.saves==1);
assert(m._prefs.tx_power_dbm==3 && m.durable.tx_power_dbm==3);
assert(m.saved_radio_apply_pending==rollback_busy);
if(rollback_busy)m.recover();
assert(!m.saved_radio_apply_pending && radio_driver.power==3 && m.replies.size()==1);
m.servicePendingRadioParamApply();assert(m.replies.size()==1);
// A saturated TX queue can start its next packet before the outer loop gets
// another chance. Apply pending power in the real completion callback's gap.
{
MyMesh m;mesh::Packet completed;tx_inflight=true;
m.powerFrame(17);m.expectError(0);
assert(m.tx_power_apply_pending && m.saves==1 && radio_driver.power==3);
completion_order.clear();tx_inflight=false; // The completed TX is now idle.
m.onSendComplete(&completed);
assert((completion_order==std::vector<unsigned>{1,2,3}));
assert(!m.tx_power_apply_pending && radio_driver.power==17 && m.durable.tx_power_dbm==17);
assert(m.saves==1 && m.replies.size()==1);
completion_order.push_back(4);tx_inflight=true; // Dispatcher starts next TX.
const unsigned calls=radio_driver.calls;m.servicePendingTxPower();
assert(radio_driver.calls==calls && radio_driver.power==17 && m.saves==1);
}
// A failed commit restores RAM and the physical output-power setting.
// Newer accepted values replace saved pending intent, across frontends.
{
MyMesh m;radio_driver.accepted={Apply::BUSY,Apply::BUSY,Apply::APPLIED};
m.powerFrame(17);m.expectError(0);
char reply[160]={};
assert(m.command("set tx 12",reply) && !strncmp(reply,"OK",2));
assert(strstr(reply,"pending"));
assert(m.tx_power_apply_pending && m._prefs.tx_power_dbm==12 && m.durable.tx_power_dbm==12);
m.servicePendingTxPower();
assert(!m.tx_power_apply_pending && radio_driver.power==12 && m.saves==2);
m.servicePendingTxPower();assert(m.saves==2 && radio_driver.power==12);
}
// A rejected second save preserves the previously accepted pending target.
{
MyMesh m;radio_driver.accepted={Apply::BUSY,Apply::BUSY,Apply::APPLIED};
m.powerFrame(17);m.replies.clear();m.storage_accepts=false;
m.tx_power_apply_retry_at=now_ms+1000;
m.powerFrame(12);m.expectError(ERR_CODE_FILE_IO_ERROR);
assert(m.tx_power_apply_pending && m._prefs.tx_power_dbm==17 && m.durable.tx_power_dbm==17);
assert(m.tx_power_apply_retry_at==now_ms+1000);
const unsigned calls=radio_driver.calls;
m.servicePendingTxPower();assert(radio_driver.calls==calls);
now_ms=m.tx_power_apply_retry_at;
m.storage_accepts=true;m.servicePendingTxPower();
assert(!m.tx_power_apply_pending && radio_driver.power==17 && m.saves==2);
}
// Storage failure restores both the durable value and physical output.
{
MyMesh m;m.storage_accepts=false;m.powerFrame(17);m.expectError(ERR_CODE_FILE_IO_ERROR);
assert(m._prefs.tx_power_dbm==3 && m.durable.tx_power_dbm==3 && radio_driver.power==3);
assert(radio_driver.calls==2 && !m.saved_radio_apply_pending);
assert(radio_driver.calls==2 && !m.saved_radio_apply_pending && !m.tx_power_apply_pending);
}
// RX may become busy during flash I/O. Retain recovery through a rejected
// rollback and through a later successful tuple restore but failed power apply.
// A rollback blocked by RX is retried without restoring the whole tuple or
// waiting for a long-running temporary profile to expire.
{
MyMesh m;m.storage_accepts=false;
radio_driver.accepted={Apply::APPLIED,Apply::BUSY,Apply::FAILED,Apply::APPLIED};
radio_driver.accepted={Apply::APPLIED,Apply::BUSY,Apply::APPLIED};
m.powerFrame(17);m.expectError(ERR_CODE_FILE_IO_ERROR);
assert(m._prefs.tx_power_dbm==3 && radio_driver.power==17 && m.saved_radio_apply_pending);
m.recover();assert(m.saved_radio_apply_pending && radio_driver.power==17);
assert(m.radio_apply_failures==1 && m.radio_apply_retry_at>now_ms);
unsigned calls=radio_driver.calls;m.recover();assert(radio_driver.calls==calls);
now_ms=m.radio_apply_retry_at;m.recover();
assert(!m.saved_radio_apply_pending && radio_driver.power==3 && m.saves==1);
assert(m.replies.size()==1); // no later success for the failed command
assert(m._prefs.tx_power_dbm==3 && radio_driver.power==17 && m.tx_power_apply_pending);
assert(!m.saved_radio_apply_pending);
m._temp_radio_applied=true;m.servicePendingTxPower();
assert(!m.tx_power_apply_pending && radio_driver.power==3 && m.saves==1 && m.applies==0);
assert(m.replies.size()==1); // A failed command receives no later success.
}
// ASCII and framed setters share identical hardware/durability semantics.
// A hardware-confirmed rollback clears prior pending intent. If rollback
// is busy, retain the durable target for automatic physical recovery.
for(bool rollback_busy : {false,true}) {
MyMesh m;radio_driver.accepted={Apply::BUSY,Apply::APPLIED,
rollback_busy?Apply::BUSY:Apply::APPLIED,Apply::APPLIED};
m.powerFrame(17);m.replies.clear();m.storage_accepts=false;
m.tx_power_apply_retry_at=now_ms+1000;
m.powerFrame(12);m.expectError(ERR_CODE_FILE_IO_ERROR);
assert(m._prefs.tx_power_dbm==17 && m.durable.tx_power_dbm==17);
assert(m.tx_power_apply_pending==rollback_busy && m.tx_power_apply_retry_at==0);
assert(radio_driver.power==(rollback_busy?12:17));
const unsigned calls=radio_driver.calls;
m.servicePendingTxPower();
assert(!m.tx_power_apply_pending && radio_driver.power==17 && m.saves==2);
assert(radio_driver.calls==calls+unsigned(rollback_busy));
}
// A genuine hardware rejection remains an error, including partial writes.
// The previous durable value is reasserted automatically after rejection.
for(bool partial : {false,true}) {
MyMesh m;radio_driver.partial_failure=partial;
radio_driver.accepted={Apply::FAILED,Apply::APPLIED};m.powerFrame(17);
m.expectError(ERR_CODE_BAD_STATE);
assert(m.saves==0 && m._prefs.tx_power_dbm==3 && m.durable.tx_power_dbm==3);
assert(m.tx_power_apply_pending && !m.saved_radio_apply_pending);
assert(radio_driver.power==(partial?17:3));
m.servicePendingTxPower();
assert(!m.tx_power_apply_pending && radio_driver.power==3 && m.saves==0);
}
// Repeated hardware failures back off, then complete without losing intent.
// Verify wrap-safe retry deadlines as well as the ordinary clock.
for(uint32_t start : {100u,UINT32_MAX-500u,UINT32_MAX-999u}) {
MyMesh m;now_ms=start;
radio_driver.accepted={Apply::BUSY,Apply::FAILED,Apply::APPLIED};
m.powerFrame(17);m.servicePendingTxPower();
assert(m.tx_power_apply_pending && m.tx_power_apply_retry_at!=0);
const unsigned calls=radio_driver.calls;
assert(m.tx_power_apply_retry_at==(uint32_t(start+1000u)?uint32_t(start+1000u):1u));
now_ms=m.tx_power_apply_retry_at-1;m.servicePendingTxPower();
assert(radio_driver.calls==calls && m.tx_power_apply_pending);
++now_ms;m.servicePendingTxPower();
assert(!m.tx_power_apply_pending && radio_driver.power==17 && m.saves==1);
}
// CW and Morse ownership are retained. Temporary channel leases do not
// block TX-only updates: changing power must not revert their radio tuple.
for(unsigned owner=0;owner<3;++owner) {
MyMesh m;radio_driver.accepted={Apply::BUSY};m.powerFrame(17);
if(owner==0)radio.carrier=true;
else if(owner==1)radio.morse_tx=true;
else radio.morse_rx=true;
m.servicePendingTxPower();assert(m.tx_power_apply_pending && radio_driver.power==3);
radio=Radio();m._temp_radio_applied=true;m.servicePendingTxPower();
assert(!m.tx_power_apply_pending && radio_driver.power==17 && m.applies==0);
}
// A pending tuple retains its original route/deadline, while power is saved
// independently. Completing the tuple cannot overwrite newer power intent.
{
MyMesh m;char reply[160]={};radio_driver.accepted={Apply::FAILED,Apply::BUSY};
assert(m.command("set tx 17",reply) && strstr(reply,"Error"));
assert(m.command("set tx 17",reply) && strstr(reply,"Error"));
assert(m.saves==0 && m._prefs.tx_power_dbm==3);
m.storage_accepts=false;
assert(m.command("set tx 17",reply) && strstr(reply,"saved"));
assert(strstr(reply,"Error") && radio_driver.power==3);
assert(m.command("set tx 17junk",reply) && strstr(reply,"Error"));
assert(m.saves==1);
MyMesh m;m.accepted={Apply::BUSY,Apply::APPLIED};m.tupleFrame();
assert(m.replies.empty() && m.command_radio_apply_pending && m.saves==0);
m.serial.current=&m.other;m.powerFrame(17);m.expectError(0,&m.other);
assert(m.command_radio_apply_pending && m.tx_power_apply_pending && radio_driver.calls==0);
assert(m.durable.freq==915 && m.durable.tx_power_dbm==17);
m.replies.clear();m.servicePendingTxPower();assert(radio_driver.calls==0);
m.servicePendingRadioParamApply();m.expectError(0);
assert(!m.command_radio_apply_pending && m.tx_power_apply_pending);
assert(m.durable.freq==920 && m.durable.tx_power_dbm==17);
m.servicePendingTxPower();
assert(!m.tx_power_apply_pending && radio_driver.power==17 && m.saves==2);
}
// Both immediate and deferred tuple changes must commit before OK.
// Frontends retain strict validation and report accepted pending intent.
{
MyMesh m;char reply[160]={};radio_driver.accepted={Apply::BUSY,Apply::APPLIED};
assert(m.command("set tx 17",reply) && !strncmp(reply,"OK",2) && strstr(reply,"pending"));
assert(m.command("set tx 17junk",reply) && strstr(reply,"Error"));
assert(m.saves==1 && m.durable.tx_power_dbm==17);
m.servicePendingTxPower();assert(!m.tx_power_apply_pending && radio_driver.power==17);
}
// Both immediate and deferred tuple changes still commit before OK.
{
MyMesh m;m.tupleFrame();m.expectError(0);
assert(m._prefs.freq==920 && m.durable.freq==920 && m.live.freq==920);
@@ -353,22 +425,16 @@ int main() {
m.recover();assert(!m.saved_radio_apply_pending && m.live.freq==915);
assert(m.saves==1 && m.replies.size()==1);
}
// Disconnect cancels a deferred command without persisting its candidate.
// Disconnect still cancels an unsaved tuple.
{
MyMesh m;m.accepted={Apply::BUSY};m.tupleFrame();
m.serial.disconnected=&m.original;m.servicePendingRadioParamApply();
assert(!m.command_radio_apply_pending && m.replies.empty() && m.saves==0);
m.expectOriginalPrefs();
}
// Tuple completion must not swallow an outstanding power rollback.
{
MyMesh m;m.saved_radio_apply_pending=true;radio_driver.power=17;
m.tupleFrame();m.expectError(0);assert(m.saved_radio_apply_pending);
m.recover();assert(!m.saved_radio_apply_pending && radio_driver.power==3);
assert(m.live.freq==920 && m.durable.freq==920);
}
puts("Companion radio-setting commit and recovery regressions passed");
puts("Companion automatic TX-power commit and recovery regressions passed");
}
'''
SLEEP_HARNESS = r'''
@@ -396,8 +462,10 @@ struct MyMesh {
ContactStore store;
ContactStore* _store=&store;
bool _radio_available=true, _iter_started=false;
uint8_t command_radio_apply_pending=0;
bool command_radio_apply_pending=false;
bool saved_radio_apply_pending=false, _terminal_trace_pending=false;
bool tx_power_apply_pending=false;
uint32_t tx_power_apply_retry_at=0;
uint32_t radio_apply_retry_at=0, _scheduled_reboot_at=0;
uint32_t emergency_client_repeat_send_at=0, dirty_contacts_expiry=0;
uint8_t dirty_contacts_failures=0;
@@ -426,10 +494,17 @@ struct MyMesh {
int main() {
MyMesh m;
assert(!m.hasPendingWork());
m.command_radio_apply_pending=2;
assert(m.hasPendingWork()); // Deferred power must keep the retry loop awake.
m.command_radio_apply_pending=true;
assert(m.hasPendingWork()); // Deferred tuples must keep the retry loop awake.
assert(m.pendingWorkReason()==mesh::powerdiag::mask(mesh::powerdiag::Reason::Scheduled));
m.command_radio_apply_pending=0;
m.command_radio_apply_pending=false;
assert(!m.hasPendingWork());
m.tx_power_apply_pending=true;
assert(m.hasPendingWork()); // TX-only intent must be serviced automatically.
assert(m.pendingWorkReason()==mesh::powerdiag::mask(mesh::powerdiag::Reason::Scheduled));
m.tx_power_apply_retry_at=now_ms+1000;
assert(m.hasPendingWork()); // Event-driven sleep must not strand the retry.
m.tx_power_apply_pending=false;m.tx_power_apply_retry_at=0;
assert(!m.hasPendingWork());
m.store._contact_recovery=ContactStore::ContactRecoveryPending;
assert(m.hasPendingWork()); // Storage repair still needs the outer loop.
@@ -525,6 +600,8 @@ class CompanionRadioSettingsTransactionTests(unittest.TestCase):
'MyMesh::RadioSettingResult MyMesh::applyAndSaveTxPower(',
'void MyMesh::finishRadioParamApply(',
'void MyMesh::deferRadioCommand(',
'void MyMesh::servicePendingTxPower(',
'void MyMesh::onSendComplete(',
'void MyMesh::cancelPendingRadioParamApply(',
'void MyMesh::servicePendingRadioParamApply(',
))
@@ -536,6 +613,7 @@ class CompanionRadioSettingsTransactionTests(unittest.TestCase):
'@RETRY_DELAY@': extract_braced(text, 'static uint32_t nextRadioApplyRetryDelay('),
'@METHODS@': methods,
'@FRAME_BRANCHES@': frame_branches,
'@TX_STATUS_BRANCH@': extract_braced(text, 'if (strcmp(command, "get tx.status") == 0)'),
'@CLI_BRANCH@': extract_braced(text, 'if (strncmp(command, "set tx ", 7) == 0)'),
'@RECOVERY@': extract_braced(text, 'if (!command_radio_apply_pending && saved_radio_apply_pending && !hasOutbound()'),
}
@@ -546,10 +624,13 @@ class CompanionRadioSettingsTransactionTests(unittest.TestCase):
cpp = Path(tmp) / 'test.cpp'
binary = Path(tmp) / ('test.exe' if os.name == 'nt' else 'test')
cpp.write_text(harness, encoding='utf-8')
subprocess.run([os.environ.get('CXX', 'g++'), '-std=c++17', '-Wall', '-Wextra', '-Werror',
'-Wno-sign-compare', # existing frame parser compares size_t and byte offsets
'-I', str(ROOT / 'src'), str(cpp), '-o', str(binary)], check=True)
subprocess.run([str(binary)], check=True)
for hil in (False, True):
with self.subTest(hil=hil):
subprocess.run([os.environ.get('CXX', 'g++'), '-std=c++17', '-Wall', '-Wextra', '-Werror',
'-Wno-sign-compare', # existing frame parser compares size_t and byte offsets
*(['-DMESH_HIL_TX_FREQ_MHZ=907.125f'] if hil else []),
'-I', str(ROOT / 'src'), str(cpp), '-o', str(binary)], check=True)
subprocess.run([str(binary)], check=True)
def test_all_tx_power_frontends_use_checked_handler(self):
text = SOURCE.read_text(encoding='utf-8')
@@ -557,6 +638,7 @@ class CompanionRadioSettingsTransactionTests(unittest.TestCase):
self.assertIn('handleCommand(command, 0, local_reply)', terminal)
web = extract_braced(text, 'if (strcmp(key, "tx") == 0)')
self.assertIn('applyAndSaveTxPower(', web)
self.assertIn('servicePendingTxPower();', extract_braced(text, 'void MyMesh::loop()'))
self.assertIn('RadioSettingResult::SaveFailed',
extract_braced(text, 'if (strncmp(command, "set tx ", 7) == 0)'))
+88 -23
View File
@@ -19,7 +19,8 @@ SPEC.loader.exec_module(BENCH)
class FakePair:
def __init__(self, *, loss=False, bad_guard=False, primary_tx=0, conflict=False, inactive=False):
def __init__(self, *, loss=False, bad_guard=False, primary_tx=0, conflict=False, inactive=False,
initial_power=21, pending_reads=0):
self.clock = 100.0
self.last_source = None
self.last_payload = None
@@ -34,7 +35,10 @@ class FakePair:
self.name, self.preamble = name, 128
self.commands, self.requests = [], []
self.tx = 0
self.tx_power = 21
self.tx_power = initial_power
self.applied_tx_power = 21 if pending_reads else initial_power
self.pending_reads = pending_reads
self.tx_verified = False
def channel_name(self, slot):
return "mt-conflict" if conflict else "Public"
@@ -49,8 +53,16 @@ class FakePair:
return f"> RX=0,0 TX={primary_tx},{self.tx}; active=radio2"
if command == "get tx":
return f"> {self.tx_power}"
if command == "get tx.status":
if self.pending_reads:
self.pending_reads -= 1
return f"> desired={self.tx_power},apply=pending"
self.applied_tx_power = self.tx_power
self.tx_verified = self.applied_tx_power == 2
return f"> desired={self.tx_power},apply=applied"
if command == "set tx 2":
self.tx_power = 2
return "OK - saved; pending" if self.pending_reads else "OK"
if command.startswith("set radio2 "):
self.preamble = int(command.rsplit(",", 1)[1])
if command == "get radio2":
@@ -62,6 +74,8 @@ class FakePair:
return "OK"
def request(self, command, expected):
assert all(node.tx_verified and node.applied_tx_power == 2
for node in owner.nodes.values()), "RF before verified low TX power"
self.requests.append(command)
assert command[:2] == b"\x19\x00"
assert expected == (0,)
@@ -222,35 +236,86 @@ class CompanionRawRadio2SweepTest(unittest.TestCase):
self.assertEqual(result["unexpected_raw"], 1)
self.assertEqual(result["ignored_frames"], 1)
def test_existing_low_tx_skips_setter_and_transient_busy_is_bounded(self):
node = SimpleNamespace(cli=mock.Mock(return_value="> 2"))
def test_existing_low_tx_still_requires_applied_status(self):
node = SimpleNamespace(cli=mock.Mock(side_effect=("> 2", "> desired=2,apply=applied")))
result = BENCH.ensure_low_tx(node)
self.assertEqual(result["setter_attempts"], 0)
node.cli.assert_called_once_with("get tx")
node = SimpleNamespace(cli=mock.Mock(side_effect=("> 21",
RuntimeError("Companion CLI set tx rejected: Error: radio busy or TX power rejected"), "OK", "> 2")))
with mock.patch.object(BENCH.time, "sleep") as sleep:
result = BENCH.ensure_low_tx(node)
self.assertEqual(result["setter_attempts"], 2)
sleep.assert_called_once_with(0.25)
self.assertEqual(node.cli.call_args_list[-1], mock.call("get tx"))
self.assertEqual(node.cli.call_args_list, [mock.call("get tx"), mock.call("get tx.status")])
def test_unknown_tx_errors_or_wrong_readback_fail_closed(self):
for responses in (("> 21", RuntimeError("Companion CLI set tx rejected: Error: invalid TX power")),
("> 21", "OK", "> 21")):
def test_pending_power_is_polled_without_resubmitting_same_or_changed_desired_value(self):
for initial in (2, 21):
with self.subTest(initial=initial):
responses = [f"> {initial}"]
if initial != 2:
responses.append("OK - saved; pending")
responses += ["> desired=2,apply=pending"] * 2 + ["> desired=2,apply=applied"]
node = SimpleNamespace(cli=mock.Mock(side_effect=responses))
with mock.patch.object(BENCH.time, "sleep") as sleep:
result = BENCH.ensure_low_tx(node)
expected = [mock.call("get tx")]
if initial != 2:
expected.append(mock.call("set tx 2"))
expected += [mock.call("get tx.status")] * 3
self.assertEqual(node.cli.call_args_list, expected)
self.assertEqual(result["setter_attempts"], int(initial != 2))
self.assertEqual(result["status_polls"], 3)
self.assertEqual(sleep.call_args_list, [mock.call(0.25)] * 2)
def test_pending_apply_blocks_all_rf_until_both_radios_confirm_low_power(self):
for initial in (2, 21):
with self.subTest(initial=initial):
pair = FakePair(initial_power=initial, pending_reads=2)
result, _ = self.run_matrix(pair)
self.assertEqual(result["result"], "PASS", result.get("failure"))
self.assertEqual(pair.total_sent, 32)
for node in pair.nodes.values():
self.assertEqual(node.commands.count("set tx 2"), int(initial != 2))
self.assertEqual(node.commands.count("get tx.status"), 3)
def test_unapplied_power_times_out_without_setter_resubmission_or_rf(self):
for initial in (2, 21):
with self.subTest(initial=initial):
pair = FakePair(initial_power=initial, pending_reads=1000)
result, _ = self.run_matrix(pair)
self.assertEqual(result["result"], "FAIL")
self.assertIn("bounded setup window", result["failure"]["message"])
self.assertEqual(pair.total_sent, 0)
for node in pair.nodes.values():
self.assertLessEqual(node.commands.count("set tx 2"), int(initial != 2))
self.assertFalse(any(command.startswith("set radio2 ") for command in node.commands))
node = SimpleNamespace(cli=mock.Mock(side_effect=("> 21", "OK - saved; pending",
"> desired=2,apply=pending", "> desired=2,apply=pending")))
with mock.patch.object(BENCH.time, "sleep"), \
mock.patch.object(BENCH.time, "monotonic", side_effect=(0, 1, 8)):
with self.assertRaisesRegex(BENCH.Failure, "bounded setup window"):
BENCH.ensure_low_tx(node)
self.assertEqual(node.cli.call_args_list.count(mock.call("set tx 2")), 1)
def test_missing_malformed_or_contradictory_status_fails_closed(self):
for initial in (2, 21):
for status in ("OK", "unknown config", "", "> 2", "> desired=21,apply=applied",
"> desired=21,apply=pending", "> desired=2,apply=unknown",
"> desired=2,apply=applied; desired=21", "> desired=2,apply=applied\nOK"):
with self.subTest(initial=initial, status=status):
pair = FakePair(initial_power=initial)
original = pair.nodes["b"].cli
pair.nodes["b"].cli = lambda command: status if command == "get tx.status" else original(command)
result, _ = self.run_matrix(pair)
self.assertEqual(result["result"], "FAIL")
self.assertEqual(pair.total_sent, 0)
self.assertFalse(any(command.startswith("set radio2 ")
for node in pair.nodes.values() for command in node.commands))
def test_unknown_tx_setter_errors_or_missing_numeric_readback_fail_closed(self):
for responses in (("unknown config",),
("> 21", RuntimeError("Companion CLI set tx rejected: Error: invalid TX power")),
("> 21", "Error: radio busy or TX power rejected"),
("> 21", "unknown config"), ("> 21", "OK but rejected")):
with self.subTest(responses=responses):
node = SimpleNamespace(cli=mock.Mock(side_effect=responses))
with self.assertRaises(Exception), mock.patch.object(BENCH.time, "sleep") as sleep:
BENCH.ensure_low_tx(node)
sleep.assert_not_called()
def busy(command):
if command == "get tx":
return "> 21"
raise RuntimeError("Companion CLI set tx rejected: Error: radio busy or TX power rejected")
with mock.patch.object(BENCH.time, "sleep"), \
mock.patch.object(BENCH.time, "monotonic", side_effect=(0, 1, 8)):
with self.assertRaisesRegex(BENCH.Failure, "bounded8-second"):
BENCH.ensure_low_tx(SimpleNamespace(cli=busy))
def test_packet_window_rate_excludes_checkpoint_time_but_keeps_loss_timeouts(self):
packets = [{"received": True, "raw_bytes": 64, "elapsed_seconds": 0.1, "direction": "a_to_b"},
+1
View File
@@ -46,6 +46,7 @@ protected:
CustomSX1276* _radio;
int8_t cached = 0;
virtual int16_t applyCachedTxPower(int8_t dbm) = 0;
virtual bool checkBeaconOutputPower(float, int8_t) const = 0;
public:
CustomSX1276Wrapper(CustomSX1276& radio, mesh::MainBoard&) : _radio(&radio) {}
bool setTxPower(int8_t dbm) {
+1
View File
@@ -130,6 +130,7 @@ struct RoleCLI {
Board* getBoard() { return &board; }
ObserverPrefs* getObserverPrefs() { return &prefs; }
bool hasActiveUserGpioTimer() const { return false; }
bool hasPendingTxPower() const { return false; }
};
struct AbstractBridge {
bool running = false, stopping = false, allow_flash = true;
+7 -1
View File
@@ -82,16 +82,21 @@ int main(){
for(int stage=1;stage<=10;++stage){
failure=stage;radio.init();assert(!radio.isEnabled());
assert(!sdk_active&&wake_refs==0);
assert(!radio.isInRecvMode()&&!radio.isSendComplete());
assert(!radio.isInRecvMode()&&!radio.isSendComplete()&&!radio.isTxPowerApplied(20));
radio.end();assert(wake_refs==0); // failed startup and repeated off are safe
}
failure=0;WiFi.current=WIFI_OFF;radio.init();
assert(radio.isEnabled()&&sdk_active&&wake_refs==1&&!WiFi.autoreconnect);
const int started=init_calls;radio.init();assert(init_calls==started&&wake_refs==1);
assert(radio.setTxPower(13)&&power==52);
assert(radio.isTxPowerApplied(13)&&!radio.isTxPowerApplied(2));
assert(radio.isTxPowerValid(2)&&radio.isTxPowerValid(21));
assert(!radio.isTxPowerValid(1)&&!radio.isTxPowerValid(22));
assert(radio.trySetTxPower(22)==mesh::RadioParamApplyResult::FAILED&&power==52);
assert(radio.trySetTxPower(14)==mesh::RadioParamApplyResult::APPLIED&&power==56);
failure=6;
assert(radio.trySetTxPower(15)==mesh::RadioParamApplyResult::FAILED&&power==56);
assert(radio.isTxPowerApplied(14)&&!radio.isTxPowerApplied(15));
failure=0;
assert(radio.trySetTxPower(13)==mesh::RadioParamApplyResult::APPLIED&&power==52);
uint8_t raw[255]={0x15,1,2,3},out[255];
@@ -100,6 +105,7 @@ int main(){
assert(radio.startSendRaw(raw,sizeof(raw))&&tx_second_pending);
OnDataSent(nullptr,ESP_NOW_SEND_SUCCESS);
radio.end();const int sent=send_calls;
assert(!radio.isTxPowerApplied(13));
assert(!radio.isSendComplete()&&!radio.startSendRaw(raw,4)&&send_calls==sent);
OnDataSent(nullptr,ESP_NOW_SEND_SUCCESS); // callback arriving while disabled
deliver(raw,4);assert(radio.recvRaw(out,sizeof(out))==0);
+1
View File
@@ -87,6 +87,7 @@ struct Fleet {
};
struct CLI {
Fleet fleet;
void servicePendingTxPower() {}
Profiles profiles;
struct NoMorseReply {
void complete(const void*) {}
+54 -4
View File
@@ -1,5 +1,6 @@
"""Compile real Companion command dispatch with local and LoRa callers."""
from pathlib import Path
from itertools import product
import os
import subprocess
import tempfile
@@ -34,6 +35,7 @@ PREAMBLE = r'''
#define TXT_TYPE_CLI_COMMAND 1
uint32_t millis() { return 0; }
namespace mesh {
enum class RadioParamApplyResult { APPLIED, BUSY, FAILED };
// The real Morse parser, capture paging and ownership are executed by their
// dedicated tests. This fixture isolates unrelated local/remote CLI access.
template <typename Radio>
@@ -117,7 +119,19 @@ struct Board {
bool handleCommand(const char*,uint32_t,char*){return false;}
void powerOff(){++power_offs;}
} board;
struct Radio {bool setTxPower(int){return true;}} radio_driver;
struct Radio {
mesh::RadioParamApplyResult accepted=mesh::RadioParamApplyResult::APPLIED;
int8_t power=10;
bool power_verified=true;
bool isTxPowerValid(int8_t) const {return true;}
bool isTxPowerApplied(int8_t requested) const {return power_verified && power==requested;}
bool setTxPower(int requested){power=requested;power_verified=true;return true;}
mesh::RadioParamApplyResult trySetTxPower(int requested){
if(accepted==mesh::RadioParamApplyResult::APPLIED){power=requested;power_verified=true;}
if(accepted==mesh::RadioParamApplyResult::FAILED)power_verified=false;
return accepted;
}
} radio_driver;
struct AdvertDataParser {static bool isValidName(const char*){return true;}};
struct StrHelper {static void strncpy(char* out,const char* in,size_t len){::strncpy(out,in,len);}};
void recalcRxPowerSavingFromLevel(uint8_t,uint8_t,float,uint8_t,uint32_t*,uint32_t*){}
@@ -145,7 +159,8 @@ struct MyMesh : CompanionFleetFixture {
int value=0; int* _ms=&value; int* _radio=&value; int* _mgr=&value;
uint16_t _err_flags=0;
bool _radio_available=false, save_ok=true;
bool saved_radio_apply_pending=false;
bool saved_radio_apply_pending=false, command_radio_apply_pending=false, tx_power_apply_pending=false;
uint32_t tx_power_apply_retry_at=0;
unsigned radio_apply_retry_at=0;
uint8_t radio_apply_failures=0;
int resets=0, saves=0;
@@ -291,6 +306,40 @@ int main() {
assert(node.handleCommand(command,0,reply));
assert(!strcmp(reply,"OK") && board.power_offs==previous+1);
}
// Both directly attached and on-air CLI replies describe accepted durable
// intent. Request prefixes and the fixed 160-byte reply boundary survive.
node.save_ok=true;node._radio_available=true;
radio_driver.accepted=mesh::RadioParamApplyResult::BUSY;
for(uint32_t stamp:{0u,100u}) {
char bounded[162];memset(bounded,0x5A,sizeof(bounded));
assert(node.handleCommand("A7|set tx 17",stamp,bounded+1));
assert(!strcmp(bounded+1,"A7|OK - saved; pending"));
assert(bounded[0]==0x5A && bounded[161]==0x5A);
assert(node._prefs.tx_power_dbm==17 && node.tx_power_apply_pending);
#if defined(MESH_HIL_TX_FREQ_MHZ)
assert(node.handleCommand("A7|get tx.status",stamp,bounded+1));
assert(!strcmp(bounded+1,"A7|> desired=17,apply=pending"));
assert(bounded[0]==0x5A && bounded[161]==0x5A);
#else
assert(!node.handleCommand("A7|get tx.status",stamp,bounded+1));
#endif
node.onCLICommandRecv(ContactInfo{},nullptr,stamp,"B8|set tx 12",reply);
assert(!strcmp(reply,"B8|OK - saved; pending") && node._prefs.tx_power_dbm==12);
}
radio_driver.accepted=mesh::RadioParamApplyResult::APPLIED;
assert(node.handleCommand("set tx 15",0,reply) && !strcmp(reply,"OK"));
assert(!node.tx_power_apply_pending);
#if defined(MESH_HIL_TX_FREQ_MHZ)
assert(node.handleCommand("get tx.status",0,reply) && !strcmp(reply,"> desired=15,apply=applied"));
radio_driver.power_verified=false;
assert(node.handleCommand("get tx.status",0,reply) && !strcmp(reply,"> desired=15,apply=pending"));
radio_driver.power_verified=true;node._radio_available=false;
assert(node.handleCommand("get tx.status",0,reply) && !strcmp(reply,"> desired=15,apply=pending"));
node._radio_available=true;
assert(node.handleCommand("get tx.status",0,reply) && !strcmp(reply,"> desired=15,apply=applied"));
#else
assert(!node.handleCommand("get tx.status",0,reply));
#endif
// Exercise the real timezone dispatch and typed transaction helper, not a
// fixture-only setter: a failed save must preserve the exact fraction.
node.save_ok=true;
@@ -429,14 +478,15 @@ int main() {
header = (ROOT / "examples/companion_radio/MyMesh.h").read_text()
preamble = PREAMBLE.replace("@SAVE_PREFERENCE@", extract_braced(
header, "template <typename T> bool savePreference("))
for export, webconfig in ((0, False), (1, False), (0, True), (1, True)):
with self.subTest(export=export, webconfig=webconfig), tempfile.TemporaryDirectory() as temp:
for export, webconfig, hil in product((0, 1), (False, True), (False, True)):
with self.subTest(export=export, webconfig=webconfig, hil=hil), tempfile.TemporaryDirectory() as temp:
binary = Path(temp) / "test"
result = subprocess.run([
"c++", "-std=c++17", "-x", "c++", "-", "-I"+str(ROOT / "src"),
"-I"+str(ROOT / "test/fixtures"),
"-DENABLE_PRIVATE_KEY_EXPORT="+str(export),
"-DMESH_ENABLE_LOST_REPLY=1",
*(["-DMESH_HIL_TX_FREQ_MHZ=907.125f"] if hil else []),
*(["-DWITH_WEBCONFIG=1"] if webconfig else []),
*SANITIZER_FLAGS, "-o", str(binary),
], input=preamble+implementation+SCENARIOS, text=True, capture_output=True)
+4 -1
View File
@@ -43,7 +43,10 @@ struct MyMesh {
MQTTBridge* bridge = nullptr;
const AbstractBridge* activeBridge() const { return bridge; }
struct { bool pending = false; } deferred_cli_command;
struct { bool hasActiveUserGpioTimer() const { return false; } } _cli;
struct {
bool hasActiveUserGpioTimer() const { return false; }
bool hasPendingTxPower() const { return false; }
} _cli;
struct { int getNumClients() const { return 0; } } acl;
struct { bool battery_alert_enabled = false; } _prefs;
Radio radio_driver;
+4 -1
View File
@@ -172,9 +172,10 @@ struct CommonCLI {
Callbacks callbacks; Callbacks* _callbacks=&callbacks;
UserGpio _user_gpio;
struct Profiles { unsigned loops=0; void loop() { ++loops; } } _radio_profiles;
unsigned management=0;
unsigned management=0, tx_services=0;
void loopBatteryForwarding(bool = false) {}
void loopManagement() { ++management; }
void servicePendingTxPower() { ++tx_services; }
void loop();
};
@LOOP@
@@ -185,9 +186,11 @@ int main() {
assert(cli._user_gpio.loops==1 && cli.callbacks.completed==1);
const unsigned ordinary=mode==1 || mode==2 ? 0:1;
assert(cli._radio_profiles.loops==ordinary && cli.management==ordinary);
assert(cli.tx_services==ordinary);
radio_driver.tx=radio_driver.rx=false;
cli.loop();
assert(cli._radio_profiles.loops==ordinary+1 && cli.management==ordinary+1);
assert(cli.tx_services==ordinary+1);
assert(cli._user_gpio.loops==2 && cli.callbacks.completed==1);
}
}
+6 -1
View File
@@ -181,7 +181,11 @@ struct MyMesh {
uint32_t queue_delay = 0, retry_delay = 0, calibration_delay = 0;
struct { bool busy_value = false; bool busy() const { return busy_value; } } _local_cli_output;
struct { bool pending = false; } deferred_cli_command;
struct { bool gpio = false; bool hasActiveUserGpioTimer() const { return gpio; } } _cli;
struct {
bool gpio = false, tx_pending = false;
bool hasActiveUserGpioTimer() const { return gpio; }
bool hasPendingTxPower() const { return tx_pending; }
} _cli;
struct { bool watchdog = false, calibration = false;
bool isWatchdogObserving() const { return watchdog; }
bool isCalibratingNoiseFloor() const { return calibration; }
@@ -240,6 +244,7 @@ int main() {
node.dual = node.profile_idle = false;
check(node._local_cli_output.busy_value, Reason::SerialIO);
check(node.deferred_cli_command.pending, Reason::Scheduled);
check(node._cli.tx_pending, Reason::Scheduled);
check(node.uart.running, Reason::Bridge);
node.active_rs232_bridge_uart = 1;
assert(node.pendingWorkReason() == mask(Reason::BridgeCleanup));
+2
View File
@@ -44,6 +44,7 @@ struct Profiles {
};
struct CLI {
Profiles profiles;
void servicePendingTxPower() {}
struct NoMorseReply {
void complete(const void*) {}
void fail(const void*) {}
@@ -62,6 +63,7 @@ struct Manager {
};
struct MyMesh : mesh::Mesh {
CLI _cli;
void servicePendingTxPower() {}
Manager manager; Manager* _mgr = &manager;
mesh::TempRadioReplyBarrier temp_radio_reply_barrier;
bool radio_reply_secondary = false, primary_radio_mutation_starts_temp = false;
+114
View File
@@ -31,12 +31,16 @@ struct Chip {
return power_result;
}
int transmitDirect() { ++keys; return direct_result; }
int checkOutputPower(int8_t requested, int8_t*) const {
return requested >= -9 && requested <= 22 ? 0 : -1;
}
};
struct RadioLibWrapper {
Chip chip;
Chip* _radio = &chip;
uint8_t state = STATE_RX;
int8_t _cur_dbm = 17;
float _cur_freq = 915;
bool _dbm_valid = true, _dbm_applied = true, _profile_repair_required = false;
bool _profile_refresh_required = false, _rx_hold_continuous = false;
bool _rx_ps_armed = true, _cw_active = false, _cw_stopping = false;
@@ -55,10 +59,37 @@ struct RadioLibWrapper {
uint8_t beginReconfigure();
void endReconfigure(bool);
bool setTxPower(int8_t);
bool isTxPowerValid(int8_t) const;
bool isTxPowerApplied(int8_t) const;
virtual bool checkBeaconOutputPower(float, int8_t) const;
Result trySetTxPower(int8_t);
};
@METHODS@
int main() {
// Status needs proof of current normal hardware power, without SPI reads.
{
RadioLibWrapper radio;
assert(radio.isTxPowerApplied(17) && !radio.isTxPowerApplied(2));
radio.pending = radio.busy = radio.packet = true;
assert(radio.isTxPowerApplied(17));
radio._dbm_applied = false;
assert(!radio.isTxPowerApplied(17));
radio._dbm_applied = true; radio._dbm_valid = false;
assert(!radio.isTxPowerApplied(17));
radio._dbm_valid = true; radio._profile_repair_required = true;
assert(!radio.isTxPowerApplied(17));
radio._profile_repair_required = false; radio._cw_active = true;
assert(!radio.isTxPowerApplied(17));
radio._cw_active = false; radio._beacon_owned = true;
assert(!radio.isTxPowerApplied(17));
assert(!radio.chip.writes && !radio.chip.standbys && !radio.irq_reads
&& !radio.busy_reads && !radio.packet_reads && !radio.claims);
}
{
RadioLibWrapper radio; radio.pending = radio.busy = radio.packet = true;
assert(!radio.isTxPowerValid(23) && radio.trySetTxPower(23) == Result::FAILED);
assert(!radio.chip.writes && !radio.chip.standbys && !radio.irq_reads && !radio.claims);
}
// A temporary receive/TX condition does not become a hardware rejection.
for (unsigned reason = 0; reason < 4; ++reason) {
RadioLibWrapper radio;
@@ -76,6 +107,7 @@ int main() {
assert(radio.trySetTxPower(10) == Result::APPLIED);
assert(radio.chip.writes == 1 && radio.chip.standbys == 1 && radio.resumes == 1);
assert(radio._cur_dbm == 10 && radio._dbm_valid && radio._dbm_applied && radio.chip.power == 10);
assert(radio.isTxPowerApplied(10) && !radio.isTxPowerApplied(17));
}
// App saves can resend the current power while an unread packet owns RX.
for (unsigned reason = 0; reason < 4; ++reason) {
@@ -132,11 +164,13 @@ int main() {
radio.chip.power_result = -705; radio.chip.write_before_failure = true;
assert(radio.trySetTxPower(10) == Result::FAILED);
assert(radio._cur_dbm == 17 && radio.chip.power == 10 && !radio._dbm_applied);
assert(!radio.isTxPowerApplied(17) && !radio.isTxPowerApplied(10));
radio.busy = true;
assert(radio.trySetTxPower(17) == Result::BUSY && radio.chip.writes == 1);
radio.busy = false; radio.chip.power_result = 0;
assert(radio.trySetTxPower(17) == Result::APPLIED && radio.chip.writes == 2);
assert(radio.chip.power == 17 && radio._dbm_applied);
assert(radio.isTxPowerApplied(17));
assert(radio.trySetTxPower(17) == Result::APPLIED && radio.chip.writes == 2);
}
// Real command and standby failures remain failures, with the requested cache intact.
@@ -157,12 +191,92 @@ int main() {
class RadioTxPowerTransactionTests(unittest.TestCase):
def test_pure_family_and_board_limits_preserve_band_clipping(self):
source = (ROOT / "src/helpers/radiolib/RadioLibWrappers.cpp").read_text()
validator = method(source, "bool RadioLibWrapper::isTxPowerValid(")
checker = method(source, "bool RadioLibWrapper::checkBeaconOutputPower(")
dac = method((ROOT / "src/helpers/radiolib/DacPaSX1276Wrapper.h").read_text(),
"bool checkBeaconOutputPower(")
code = r'''
#include <cassert>
#include <cstdint>
#include <helpers/radiolib/LR2021Band.h>
#define RADIOLIB_ERR_NONE 0
struct Chip {
mutable unsigned checks = 0;
int checkOutputPower(int8_t power, int8_t*, bool rfo = false) const {
++checks;
#if MESH_RADIO_PROFILE_CHIP == 1276 || MESH_RADIO_PROFILE_CHIP == 1272
return rfo ? (power >= -4 && power <= 15 ? 0 : -1)
: ((power >= 2 && power <= 17) || power == 20 ? 0 : -1);
#else
return power >= -9 && power <= 22 ? 0 : -1;
#endif
}
};
using SX1276 = Chip;
using SX1272 = Chip;
struct RadioLibWrapper {
Chip chip;
Chip* _radio = &chip;
float _cur_freq = 2440;
virtual bool checkBeaconOutputPower(float, int8_t) const;
bool isTxPowerValid(int8_t) const;
};
@CHECKER@
@VALIDATOR@
struct DacWrapper : RadioLibWrapper {
int8_t _min_dbm = 17, _max_dbm = 30;
@DAC@
};
int main() {
RadioLibWrapper radio;
DacWrapper amplified;
for (int power = -128; power <= 127; ++power) {
bool board_ok = true;
#ifdef MIN_LORA_TX_POWER
board_ok = board_ok && power >= MIN_LORA_TX_POWER;
#endif
#ifdef MAX_LORA_TX_POWER
board_ok = board_ok && power <= MAX_LORA_TX_POWER;
#endif
#if MESH_RADIO_PROFILE_CHIP == 1121 || MESH_RADIO_PROFILE_CHIP == 2021
// Ordinary desired power remains valid on a lower-power temporary band.
const bool chip_ok = true;
#elif MESH_RADIO_PROFILE_CHIP == 1276 || MESH_RADIO_PROFILE_CHIP == 1272
const bool chip_ok = (power >= -4 && power <= 17) || power == 20;
#else
const bool chip_ok = power >= -9 && power <= 22;
#endif
assert(radio.isTxPowerValid(int8_t(power)) == (board_ok && chip_ok));
assert(amplified.isTxPowerValid(int8_t(power)) ==
#if MESH_RADIO_PROFILE_CHIP == 1121 || MESH_RADIO_PROFILE_CHIP == 2021
board_ok);
#else
(board_ok && power >= 17 && power <= 30));
#endif
}
#if MESH_RADIO_PROFILE_CHIP == 1121 || MESH_RADIO_PROFILE_CHIP == 2021
assert(radio.chip.checks == 0);
#endif
assert(amplified.chip.checks == 0);
}
'''
code = code.replace("@CHECKER@", checker).replace("@VALIDATOR@", validator).replace("@DAC@", dac)
for family in (1262, 1268, 68, 1276, 1272, 1110, 1121, 2021):
for limits in ((), ("-DMIN_LORA_TX_POWER=17", "-DMAX_LORA_TX_POWER=30")):
with self.subTest(family=family, limits=limits):
compile_run(code, defines=(f"-DMESH_RADIO_PROFILE_CHIP={family}", *limits))
def test_actual_busy_retry_noop_ownership_and_failures(self):
source = (ROOT / "src/helpers/radiolib/RadioLibWrappers.cpp").read_text()
methods = "\n".join(method(source, signature) for signature in (
"uint8_t RadioLibWrapper::beginReconfigure(",
"void RadioLibWrapper::endReconfigure(",
"bool RadioLibWrapper::setTxPower(",
"bool RadioLibWrapper::isTxPowerValid(",
"bool RadioLibWrapper::isTxPowerApplied(",
"bool RadioLibWrapper::checkBeaconOutputPower(",
"mesh::RadioParamApplyResult RadioLibWrapper::trySetTxPower("))
for carrier_tools in (0, 1):
with self.subTest(carrier_tools=carrier_tools):
+1
View File
@@ -75,6 +75,7 @@ struct Driver {
uint8_t beginReconfigure();
void endReconfigure(bool);
bool setTxPower(int8_t);
bool isTxPowerValid(int8_t) const { return true; }
mesh::RadioParamApplyResult trySetTxPower(int8_t);
} radio_driver;
@WRAPPER_METHODS@
+4 -1
View File
@@ -144,7 +144,10 @@ struct MyMesh : Callbacks {
#endif
}
struct { bool pending = false; } deferred_cli_command;
struct { bool hasActiveUserGpioTimer() const { return false; } } _cli;
struct {
bool hasActiveUserGpioTimer() const { return false; }
bool hasPendingTxPower() const { return false; }
} _cli;
struct {
bool isWatchdogObserving() const { return false; }
bool isCalibratingNoiseFloor() const { return false; }
+7 -3
View File
@@ -31,7 +31,11 @@ struct Radio {bool watchdog=false,calibration=false;
bool isWatchdogObserving()const{return watchdog;}
bool isCalibratingNoiseFloor()const{return calibration;}
} radio_driver;
struct Cli {bool gpio=false;bool hasActiveUserGpioTimer()const{return gpio;}};
struct Cli {
bool gpio=false,tx_pending=false;
bool hasActiveUserGpioTimer()const{return gpio;}
bool hasPendingTxPower()const{return tx_pending;}
};
struct Manager {int queued=0;int getOutboundTotal()const{return queued;}};
struct Clock {uint32_t now=100;uint32_t getCurrentTime()const{return now;}};
class SensorMesh {
@@ -94,7 +98,7 @@ int main(){
s.tracker_storage_fault=false;s.tracker_next_check=1900;
// All standard radio/console/update owners veto RF sleep.
for(bool* busy:{&s.board.usb,&s.board.ota,&s.board.test,&logging,&s.temp,&s.ota,
&s.dual,&s._cli.gpio,&s.outbound}){
&s.dual,&s._cli.gpio,&s._cli.tx_pending,&s.outbound}){
*busy=true;assert(s.trackerNeedsRadio());*busy=false;
}
s.manager.queued=1;assert(s.trackerNeedsRadio());s.manager.queued=0;
@@ -103,7 +107,7 @@ int main(){
s.saved_radio_apply_pending=true;assert(s.trackerNeedsRadio());
s.saved_radio_apply_pending=false;
#endif
for(bool* due:{&s.dual,&s.ota,&s._cli.gpio,&radio_driver.watchdog,
for(bool* due:{&s.dual,&s.ota,&s._cli.gpio,&s._cli.tx_pending,&radio_driver.watchdog,
&radio_driver.calibration,&s.queue_due,&s.retry_due}){
*due=true;assert(s.hasPendingWork()&&s.getPowerSaveSleepSeconds(30)==0);*due=false;
}
+11 -1
View File
@@ -45,11 +45,15 @@ struct SerialInterface {
bool isPassthroughMode() const{return passthrough;}
};
struct RoleMesh {
struct {
bool pending=false;
bool hasPendingTxPower() const{return pending;}
} _cli;
uint32_t dirty_contacts_expiry=0, _ota_update_at=0, set_radio_at=0,
_scheduled_reboot_at=0;
bool outbound=false, temporary=false, ota_apply=false,
saved_radio_apply_pending=false, temp_radio_handoff_pending=false,
command_radio_apply_pending=false,
command_radio_apply_pending=false, tx_power_apply_pending=false,
_iter_started=false, _terminal_trace_pending=false, terminal=true;
mesh::CompanionDelayedReplies _delayed_replies;
void* sign_data=nullptr;
@@ -154,6 +158,10 @@ class UsbLoggingWatchdogRoleTests(unittest.TestCase):
'bool canRecoverUsbLogging() const')
callback = method((directory / 'main.cpp').read_text(),
'static bool usbLoggingRecoverySafe(void*)')
specific += '''
the_mesh._cli.pending=true;assert(!usbLoggingRecoverySafe(nullptr));
the_mesh._cli.pending=false;
'''
command_checks = '''
reset();command[0]='x';assert(!usbLoggingRecoverySafe(nullptr));
mesh::status.reader_connected=false;assert(usbLoggingRecoverySafe(nullptr));
@@ -193,6 +201,8 @@ class UsbLoggingWatchdogRoleTests(unittest.TestCase):
role_checks = r'''
the_mesh.command_radio_apply_pending=true;assert(!usbLoggingRecoverySafe(nullptr));
the_mesh.command_radio_apply_pending=false;
the_mesh.tx_power_apply_pending=true;assert(!usbLoggingRecoverySafe(nullptr));
the_mesh.tx_power_apply_pending=false;
for(auto slot : {&the_mesh._delayed_replies.request, &the_mesh._delayed_replies.trace}){
slot->phase=mesh::CompanionDelayedReplies::AwaitRadio;
assert(!usbLoggingRecoverySafe(nullptr));
+39
View File
@@ -77,6 +77,45 @@ def embedded_page():
class WebConfigUiRuntimeTest(unittest.TestCase):
def test_tx_save_receipt_confirms_durable_save_and_automatic_application(self):
for reply, chip, toast in (("OK - saved; pending", "Saved", "Saved; radio changes apply automatically"),
("OK", "OK", "Saved [OK]")):
with self.subTest(reply=reply):
status, config = self.setup_values()
status.update(mode="lan", password_supported=False)
prelude = r'''<script>
(function(){
var status=%s,config=%s,reply=%s,fieldText="",toastText="";
function response(value){return Promise.resolve({ok:true,status:200,json:function(){return Promise.resolve(value)}})}
window.fetch=function(path){
if(path==="/api/status")return response(status);
if(path.indexOf("/api/config/result?")===0)return response({state:"done",reqid:"tx-receipt",all_ok:true,
results:[{key:"tx",reply:reply}]});
return response(config);
};
window.addEventListener("load",function(){
setTimeout(function(){
var input=document.createElement("input");input.setAttribute("data-k","tx");
document.getElementById("v-app").appendChild(input);
chipFor=function(el,ok,text){fieldText=text};
toast=function(text){toastText=text};
loadConfigSoft=function(){};
st.dirty.tx="17";
pollResult(null,0,0,true,"tx-receipt",{tx:"17"},{});
},150);
setTimeout(function(){
document.body.setAttribute("data-test-tx-chip",fieldText);
document.body.setAttribute("data-test-tx-toast",toastText);
document.body.setAttribute("data-test-tx-clean",!("tx" in st.dirty));
},600);
});
})();
</script>''' % (json.dumps(status), json.dumps(config), json.dumps(reply))
dom = self.run_page(prelude, virtual_time=900)
self.assertIn('data-test-tx-chip="%s"' % chip, dom)
self.assertIn('data-test-tx-toast="%s"' % toast, dom)
self.assertIn('data-test-tx-clean="true"', dom)
def test_saved_network_recovery_requires_login_before_setup_wizard(self):
status, config = self.setup_values()
status.update(auth=False, mode="setup", needs_setup=False,
+21 -26
View File
@@ -126,35 +126,30 @@ def receive_exact(node, payload, timeout):
def ensure_low_tx(node, timeout=8):
"""Avoid needless radio handoffs; retry only the exact transient rejection."""
def read_tx():
reply = node.cli("get tx")
match = re.fullmatch(r"\s*(?:>\s*)?([+-]?\d+)\s*", reply)
require(match is not None, "TX power readback is missing or ambiguous")
return int(match[1])
initial = read_tx()
if initial == RF["tx_dbm"]:
return {"initial_dbm": initial, "verified_dbm": initial, "setter_attempts": 0}
"""Accept power once, then prove its hardware application before any RF."""
reply = node.cli("get tx")
match = re.fullmatch(r"\s*(?:>\s*)?([+-]?\d+)\s*", reply)
require(match is not None, "TX power readback is missing or ambiguous")
initial = int(match[1])
deadline = time.monotonic() + timeout
attempts = 0
attempts = int(initial != RF["tx_dbm"])
if attempts:
reply = node.cli("set tx 2")
require(reply.strip() in ("OK", "OK - saved; pending"), "TX power setter rejected")
polls = 0
while True:
attempts += 1
try:
reply = node.cli("set tx 2")
except Exception as error:
if not re.fullmatch(r"Companion CLI set tx rejected: Error: radio busy or TX power rejected",
str(error)):
raise
busy = True
else:
busy = reply.strip() == "Error: radio busy or TX power rejected"
require(busy or not re.match(r"(?:Error|ERR|Err)\b", reply), "TX power setter rejected")
if not busy:
require(read_tx() == RF["tx_dbm"], "TX power is not verified at2dBm; refusing transmission")
return {"initial_dbm": initial, "verified_dbm": RF["tx_dbm"], "setter_attempts": attempts}
reply = node.cli("get tx.status")
match = re.fullmatch(r"\s*(?:>\s*)?desired=([+-]?\d+),apply=(pending|applied)\s*", reply)
require(match is not None, "TX apply status is missing or ambiguous; refusing transmission")
require(int(match[1]) == RF["tx_dbm"],
"TX desired power is not2dBm; refusing transmission")
polls += 1
if match[2] == "applied":
return {"initial_dbm": initial, "verified_dbm": RF["tx_dbm"],
"setter_attempts": attempts, "status_polls": polls, "verified_status": reply}
remaining = deadline - time.monotonic()
require(remaining > 0 and attempts < 33, "TX power stayed busy for the bounded8-second setup window")
require(remaining > 0 and polls < 33,
"TX power was not applied within the bounded setup window; refusing transmission")
time.sleep(min(0.25, remaining))
+5 -3
View File
@@ -1453,16 +1453,18 @@ function pollResult(cb,errs,idles,acked,reqid,submitted,auth){
fail();return;
}
$("#save-btn").disabled=false;
var fails=0,radioReboot=false;
var fails=0,radioReboot=false,pending=false;
(r.results||[]).forEach(function(it){
var ok=/^OK/i.test(it.reply);
var deferred=ok&&/\bpending\b/i.test(it.reply);
if(deferred)pending=true;
if(!ok)fails++;
if(!cb&&ok&&submitted&&(it.key in submitted)&&st.dirty[it.key]===submitted[it.key])delete st.dirty[it.key];
if(it.key==="radio"){
if(/reboot/i.test(it.reply))radioReboot=true;
}
var el=document.querySelector('#v-app [data-k="'+it.key+'"]');
if(el)chipFor(el,ok,ok?"OK":it.reply.replace(/^Err(or)?[:,]?\s*/i,""));
if(el)chipFor(el,ok,ok?(deferred?"Saved":"OK"):it.reply.replace(/^Err(or)?[:,]?\s*/i,""));
});
$("#radio-warn").classList.toggle("hide",!radioReboot);
var failed=fails>0||r.all_ok===false;
@@ -1495,7 +1497,7 @@ function pollResult(cb,errs,idles,acked,reqid,submitted,auth){
false,bluetoothAddressChanged?"Bluetooth address saved [OK] - rebooting":bluetoothStealthChanged?"Bluetooth stealth saved [OK] - rebooting":"Settings saved [OK] - rebooting",r.wifi_ip,rebootSeconds(r));
reauthAfterReceipt();
return;
}else toast("Saved [OK]");
}else toast(pending?"Saved; radio changes apply automatically":"Saved [OK]");
if(!reauthAfterReceipt())loadConfigSoft();
}).catch(function(e){
if(e.message==="auth"){