Report CLI save failures and validate advert intervals

Roll back failed password and advert changes, propagate identity-store results, and report unsaved common and observer settings. Correct observer filter persistence and KISS OTA capability claims. Add fault-injection and packaging regressions.
This commit is contained in:
mikecarper
2026-09-15 20:05:30 -07:00
parent b847b46ef7
commit 8ef2d41fb7
15 changed files with 475 additions and 29 deletions
+1
View File
@@ -91,6 +91,7 @@ jobs:
python3 -B test/test_ota_identity_policy.py -v
python3 -B test/test_companion_radio_gain_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_prefs_commit.py -v
python3 -B test/test_mqtt_prefs_commit.py -v
python3 -B test/test_identity_and_settings_recovery.py -v
+6
View File
@@ -2821,6 +2821,12 @@ is_lora_ota_build() {
local env_name=$1
local env_name_lc=${env_name,,}
# KISS uses the radio as a host-controlled modem. Shared board flags do not
# add an application OTA manager or CLI, even under the auto/FULL profiles.
if is_kiss_modem_target "$env_name"; then
return 1
fi
# ESP32 USB and WiFi companions keep OTA so they can seed a host folder over
# serial or TCP and can participate in LoRa OTA without using the FULL profile.
if is_esp32_usb_wifi_companion_ota_build "$env_name"; then
+2 -2
View File
@@ -10574,7 +10574,7 @@ void MyMesh::formatPacketStatsReply(char *reply) {
getNumRecvFlood(), getNumRecvDirect());
}
void MyMesh::saveIdentity(const mesh::LocalIdentity &new_id) {
bool MyMesh::saveIdentity(const mesh::LocalIdentity &new_id) {
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
IdentityStore store(*_fs, "");
#elif defined(ESP32)
@@ -10584,7 +10584,7 @@ void MyMesh::saveIdentity(const mesh::LocalIdentity &new_id) {
#else
#error "need to define saveIdentity()"
#endif
store.save("_main", new_id);
return store.saveWithRetry("_main", new_id);
}
void MyMesh::clearStats() {
+1 -1
View File
@@ -1067,7 +1067,7 @@ public:
void onDefaultRegionChanged(const RegionEntry* r) override;
mesh::LocalIdentity& getSelfId() override { return self_id; }
void saveIdentity(const mesh::LocalIdentity& new_id) override;
bool saveIdentity(const mesh::LocalIdentity& new_id) override;
void clearStats() override;
#if defined(WITH_WEBCONFIG) || defined(ETHERNET_ENABLED)
+2 -2
View File
@@ -1751,7 +1751,7 @@ bool MyMesh::setRxBoostedGain(bool enable) {
return radio_driver.setRxBoostedGainMode(enable);
}
void MyMesh::saveIdentity(const mesh::LocalIdentity &new_id) {
bool MyMesh::saveIdentity(const mesh::LocalIdentity &new_id) {
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
IdentityStore store(*_fs, "");
#elif defined(ESP32)
@@ -1761,7 +1761,7 @@ void MyMesh::saveIdentity(const mesh::LocalIdentity &new_id) {
#else
#error "need to define saveIdentity()"
#endif
store.save("_main", new_id);
return store.saveWithRetry("_main", new_id);
}
void MyMesh::startRegionsLoad() {
+1 -1
View File
@@ -504,7 +504,7 @@ public:
static bool saveFilter(ClientInfo* client);
void saveIdentity(const mesh::LocalIdentity& new_id) override;
bool saveIdentity(const mesh::LocalIdentity& new_id) override;
void clearStats() override;
#if defined(WITH_WEBCONFIG) || defined(ETHERNET_ENABLED)
void handleLocalCommand(char* command, char* reply, Stream& output) {
+2 -2
View File
@@ -1092,7 +1092,7 @@ bool SensorMesh::formatFileSystem() {
#endif
}
void SensorMesh::saveIdentity(const mesh::LocalIdentity& new_id) {
bool SensorMesh::saveIdentity(const mesh::LocalIdentity& new_id) {
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
IdentityStore store(*_fs, "");
#elif defined(ESP32)
@@ -1102,7 +1102,7 @@ void SensorMesh::saveIdentity(const mesh::LocalIdentity& new_id) {
#else
#error "need to define saveIdentity()"
#endif
store.save("_main", new_id);
return store.saveWithRetry("_main", new_id);
}
void SensorMesh::applyTempRadioParams(float freq, float bw, uint8_t sf, uint8_t cr, int timeout_mins, uint16_t preamble) {
+1 -1
View File
@@ -92,7 +92,7 @@ public:
void formatRadioStatsReply(char *reply) override;
void formatPacketStatsReply(char *reply) override;
mesh::LocalIdentity& getSelfId() override { return self_id; }
void saveIdentity(const mesh::LocalIdentity& new_id) override;
bool saveIdentity(const mesh::LocalIdentity& new_id) override;
void clearStats() override { }
mesh::Radio* getProfileRadio() override { return _radio; }
void applyTempRadioParams(float freq, float bw, uint8_t sf, uint8_t cr, int timeout_mins, uint16_t preamble = 0) override;
+85 -18
View File
@@ -1,5 +1,6 @@
#include <Arduino.h>
#include "CommonCLI.h"
#include "PrefsSaveReplyGuard.h"
#include <helpers/ui/DisplayPowerSettings.h>
#include "CLICommandUtils.h"
#include "FloodAdvertCLI.h"
@@ -2448,16 +2449,52 @@ bool CommonCLI::saveMQTTPrefs(FILESYSTEM* fs) {
#define MIN_LOCAL_ADVERT_INTERVAL 60
void CommonCLI::savePrefs(PrefsSaveRouting::Scope scope) {
uint8_t old_advert_interval = _prefs->advert_interval;
if (_prefs->advert_interval * 2 < MIN_LOCAL_ADVERT_INTERVAL) {
if (!trySavePrefs(scope)) {
++_prefs_save_failures;
if (PrefsSaveRouting::planFor(scope).common
&& (!_common_save_result_known || !_common_save_succeeded)) {
_prefs->markUnsaved();
}
}
}
bool CommonCLI::trySavePrefs(PrefsSaveRouting::Scope scope) {
const PrefsSaveRouting::Plan plan = PrefsSaveRouting::planFor(scope);
const uint8_t old_advert_interval = _prefs->advert_interval;
if (plan.common && _prefs->advert_interval * 2 < MIN_LOCAL_ADVERT_INTERVAL) {
_prefs->advert_interval = 0; // turn it off, now that device has been manually configured
}
// If advert_interval was changed, update the timer to reflect the change
if (old_advert_interval != _prefs->advert_interval) {
_callbacks->updateAdvertTimer();
// A previous successful transaction must not mask a callback that did not
// save this time. Both images have independent commit results.
if (plan.common) {
_common_save_result_known = false;
_common_save_succeeded = false;
}
#ifdef WITH_MQTT_BRIDGE
if (plan.observer) {
_observer_save_result_known = false;
_observer_save_succeeded = false;
}
#endif
_callbacks->savePrefs(scope);
_prefs->clearDirty();
const bool common_saved = !plan.common
|| (_common_save_result_known && _common_save_succeeded);
if (plan.common) {
if (common_saved) {
_prefs->clearDirty();
if (old_advert_interval != _prefs->advert_interval) {
_callbacks->updateAdvertTimer();
}
} else {
_prefs->advert_interval = old_advert_interval;
}
}
#ifdef WITH_MQTT_BRIDGE
return common_saved && (!plan.observer
|| (_observer_save_result_known && _observer_save_succeeded));
#else
return common_saved;
#endif
}
bool CommonCLI::saveObserverPrefs() {
@@ -2465,7 +2502,9 @@ bool CommonCLI::saveObserverPrefs() {
_observer_save_result_known = false;
_observer_save_succeeded = false;
_callbacks->savePrefs(PrefsSaveRouting::Scope::Observer);
return _observer_save_result_known && _observer_save_succeeded;
const bool saved = _observer_save_result_known && _observer_save_succeeded;
if (!saved) ++_prefs_save_failures;
return saved;
#else
return false;
#endif
@@ -2518,6 +2557,7 @@ uint8_t CommonCLI::buildAdvertData(uint8_t node_type, uint8_t* app_data) {
}
void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* reply) {
PrefsSaveReplyGuard save_reply(_prefs_save_failures, reply);
mesh::cli::normalizeCommandVerb(command);
if (mesh::wireless::control().handle(command, reply, 160, millis(),
_callbacks->wirelessCommandSource(sender_timestamp))) return;
@@ -2712,8 +2752,14 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re
}
} else if (memcmp(command, "password ", 9) == 0) {
// change admin password
char previous[sizeof(_prefs->password)];
memcpy(previous, _prefs->password, sizeof(previous));
StrHelper::strncpy(_prefs->password, &command[9], sizeof(_prefs->password));
savePrefs();
if (!trySavePrefs()) {
memcpy(_prefs->password, previous, sizeof(previous));
strcpy(reply, "Error: password not saved; unchanged");
return;
}
sprintf(reply, "password now: %s", _prefs->password); // echo back just to let admin know for sure!!
} else if (memcmp(command, "clear stats", 11) == 0) {
_callbacks->clearStats();
@@ -3462,28 +3508,46 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep
strcpy(reply, "ERROR: telemetry.access must be all or acl");
}
} else if (memcmp(config, "flood.advert.interval ", 22) == 0) {
int hours = _atoi(&config[22]);
if ((hours > 0 && hours < 3) || (hours > 168)) {
strcpy(reply, "Error: interval range is 3-168 hours");
uint32_t hours = 0;
if (!parseUint32Strict(&config[22], hours)
|| (hours != 0 && (hours < 3 || hours > 168))) {
strcpy(reply, "Error: interval must be 0 (off) or 3-168 hours");
} else {
const uint8_t previous = _prefs->flood_advert_interval;
_prefs->flood_advert_interval = (uint8_t)(hours);
if (!trySavePrefs()) {
_prefs->flood_advert_interval = previous;
strcpy(reply, "Error: interval not saved; unchanged");
return;
}
_callbacks->updateFloodAdvertTimer();
savePrefs();
strcpy(reply, "OK");
}
} else if (memcmp(config, "advert.interval ", 16) == 0) {
int mins = _atoi(&config[16]);
if ((mins > 0 && mins < MIN_LOCAL_ADVERT_INTERVAL) || (mins > 240)) {
sprintf(reply, "Error: interval range is %d-240 minutes", MIN_LOCAL_ADVERT_INTERVAL);
uint32_t mins = 0;
if (!parseUint32Strict(&config[16], mins)
|| (mins != 0 && (mins < MIN_LOCAL_ADVERT_INTERVAL || mins > 240))) {
sprintf(reply, "Error: interval must be 0 (off) or %d-240 minutes", MIN_LOCAL_ADVERT_INTERVAL);
} else {
const uint8_t previous = _prefs->advert_interval;
_prefs->advert_interval = (uint8_t)(mins / 2);
if (!trySavePrefs()) {
_prefs->advert_interval = previous;
strcpy(reply, "Error: interval not saved; unchanged");
return;
}
_callbacks->updateAdvertTimer();
savePrefs();
strcpy(reply, "OK");
}
} else if (memcmp(config, "guest.password ", 15) == 0) {
char previous[sizeof(_prefs->guest_password)];
memcpy(previous, _prefs->guest_password, sizeof(previous));
StrHelper::strncpy(_prefs->guest_password, &config[15], sizeof(_prefs->guest_password));
savePrefs();
if (!trySavePrefs()) {
memcpy(_prefs->guest_password, previous, sizeof(previous));
strcpy(reply, "Error: guest password not saved; unchanged");
return;
}
strcpy(reply, "OK");
} else if (memcmp(config, "prv.key ", 8) == 0) {
uint8_t prv_key[PRV_KEY_SIZE];
@@ -3492,7 +3556,10 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep
if (success && mesh::LocalIdentity::validatePrivateKey(prv_key)) {
mesh::LocalIdentity new_id;
new_id.readFrom(prv_key, PRV_KEY_SIZE);
_callbacks->saveIdentity(new_id);
if (!_callbacks->saveIdentity(new_id)) {
strcpy(reply, "Error: identity not saved; current key unchanged");
return;
}
strcpy(reply, "OK, reboot to apply! New pubkey: ");
mesh::Utils::toHex(&reply[33], new_id.pub_key, PUB_KEY_SIZE);
} else {
+5 -1
View File
@@ -424,6 +424,7 @@ public:
bool isDirty() const override { return ConfigSerializer::isDirty() || radio.isDirty() || custom.isDirty(); }
void clearDirty() override { ConfigSerializer::clearDirty(); radio.clearDirty(); custom.clearDirty(); }
void markUnsaved() { markDirty(); }
};
#ifdef WITH_MQTT_BRIDGE
@@ -515,7 +516,7 @@ public:
virtual bool supportsAdvancedRetryConfig() const { return false; }
virtual void onRetryConfigChanged() { }
virtual mesh::LocalIdentity& getSelfId() = 0;
virtual void saveIdentity(const mesh::LocalIdentity& new_id) = 0;
virtual bool saveIdentity(const mesh::LocalIdentity& new_id) = 0;
virtual void clearStats() = 0;
virtual void applyTempRadioParams(float freq, float bw, uint8_t sf, uint8_t cr, int timeout_mins, uint16_t preamble = 0) = 0;
virtual uint32_t getTempRadioDurationSeconds() const { return 0; }
@@ -761,11 +762,14 @@ 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;
uint32_t _prefs_save_failures = 0;
mesh::RadioProfileCLI _radio_profiles;
mesh::RTCClock* getRTCClock() { return _rtc; }
void savePrefs(
PrefsSaveRouting::Scope scope = PrefsSaveRouting::Scope::Common);
bool trySavePrefs(
PrefsSaveRouting::Scope scope = PrefsSaveRouting::Scope::Common);
bool saveObserverPrefs();
void loadPrefsInt(FILESYSTEM* _fs, const char* filename);
#ifdef WITH_MQTT_BRIDGE
+1 -1
View File
@@ -637,7 +637,7 @@ bool CommonCLI::handleObserverSetCmd(uint32_t sender_timestamp, const char* conf
strcpy(reply, "Error: filter must be all, none, or a CSV of types 0-15 / names (advert,txt_msg,...)");
} else {
_mqtt_prefs.mqtt_slot_packet_filter[slot] = filter_mask;
savePrefs();
saveObserverPrefs();
char filter_text[MQTTPacketFilter::kFilterTextSize];
MQTTPacketFilter::format(filter_mask, filter_text, sizeof(filter_text));
snprintf(reply, 160, "OK - slot %d packet types: %s", slot + 1, filter_text);
+21
View File
@@ -0,0 +1,21 @@
#pragma once
#include <stdint.h>
#include <string.h>
// Legacy setters can apply live settings before saving. Never let their final
// OK overwrite a persistence error, including through nested CLI dispatch.
class PrefsSaveReplyGuard {
const uint32_t& _failures;
const uint32_t _start;
char* _reply;
public:
PrefsSaveReplyGuard(const uint32_t& failures, char* reply)
: _failures(failures), _start(failures), _reply(reply) {}
~PrefsSaveReplyGuard() {
if (_failures != _start) {
strcpy(_reply, "Error: settings not saved; unsaved changes may remain active until reboot");
}
}
};
+222
View File
@@ -0,0 +1,222 @@
#include <cassert>
#include <cstdint>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <initializer_list>
#include <string>
#include <helpers/PrefsSaveRouting.h>
#include <helpers/PrefsSaveReplyGuard.h>
#define MIN_LOCAL_ADVERT_INTERVAL 60
#define PUB_KEY_SIZE 32
#define PRV_KEY_SIZE 64
namespace mesh {
struct LocalIdentity {
uint8_t pub_key[PUB_KEY_SIZE]{};
static bool validatePrivateKey(const uint8_t*) { return true; }
void readFrom(const uint8_t* key, size_t) { memcpy(pub_key, key, sizeof(pub_key)); }
};
struct Utils {
// Valid synthetic keys only; cryptography and real file transactions have
// their own production identity recovery tests.
static bool fromHex(uint8_t* out, size_t n, const char*) { memset(out, 0x11, n); return true; }
static void toHex(char* out, const uint8_t*, size_t n) { memset(out, '1', 2*n); out[2*n] = 0; }
};
}
struct Prefs {
uint8_t advert_interval = 30, flood_advert_interval = 12, multi_acks = 0;
char password[16] = "old-admin", guest_password[16] = "old-guest";
bool dirty = true;
void clearDirty() { dirty = false; }
void markUnsaved() { dirty = true; }
};
struct StrHelper {
static void strncpy(char* out, const char* in, size_t n) {
std::strncpy(out, in, n-1); out[n-1] = 0;
}
};
struct CommonCLI;
struct Callbacks {
CommonCLI* cli;
Prefs persisted;
bool common_ok = true, observer_ok = true, known = true, identity_ok = false;
unsigned saves = 0, identity_saves = 0, local_updates = 0, flood_updates = 0;
void savePrefs(PrefsSaveRouting::Scope);
void updateAdvertTimer();
void updateFloodAdvertTimer();
bool saveIdentity(const mesh::LocalIdentity&) { ++identity_saves; return identity_ok; }
};
struct CommonCLI {
Prefs prefs;
Prefs* _prefs = &prefs;
Callbacks callbacks{this, prefs};
Callbacks* _callbacks = &callbacks;
bool _common_save_result_known = false, _common_save_succeeded = false;
bool _observer_save_result_known = false, _observer_save_succeeded = false;
uint32_t _prefs_save_failures = 0;
void savePrefs(PrefsSaveRouting::Scope scope = PrefsSaveRouting::Scope::Common);
bool trySavePrefs(PrefsSaveRouting::Scope scope = PrefsSaveRouting::Scope::Common);
bool saveObserverPrefs();
void set(const char* config, char* reply);
void password(const char* command, char* reply);
};
void Callbacks::savePrefs(PrefsSaveRouting::Scope scope) {
++saves;
if (!known) return;
const auto plan = PrefsSaveRouting::planFor(scope);
if (plan.common) {
cli->_common_save_result_known = true;
cli->_common_save_succeeded = common_ok;
if (common_ok) persisted = cli->prefs;
}
#ifdef WITH_MQTT_BRIDGE
if (plan.observer) {
cli->_observer_save_result_known = true;
cli->_observer_save_succeeded = observer_ok;
}
#endif
}
void Callbacks::updateAdvertTimer() {
assert(persisted.advert_interval == cli->prefs.advert_interval);
++local_updates;
}
void Callbacks::updateFloodAdvertTimer() {
assert(persisted.flood_advert_interval == cli->prefs.flood_advert_interval);
++flood_updates;
}
@PARSERS@
@SAVE_METHODS@
void CommonCLI::set(const char* input, char* reply) {
// Match the firmware's fixed-capacity CLI input buffer.
char command[160]{};
snprintf(command, sizeof(command), "%s", input);
const char* config = command;
@REPLY_GUARD@
@SET_BRANCHES@
}
void CommonCLI::password(const char* command, char* reply) {
@REPLY_GUARD@
@PASSWORD@
}
bool error(const char* reply) { return strncmp(reply, "Error:", 6) == 0; }
int main() {
char reply[160]{};
for (bool guest : {false, true}) {
CommonCLI cli;
cli.prefs.advert_interval = 2;
cli.callbacks.common_ok = false;
if (guest) cli.set("guest.password new-guest", reply);
else cli.password("password new-admin", reply);
assert(error(reply) && strstr(reply, "unchanged"));
assert(strcmp(cli.prefs.password, "old-admin") == 0);
assert(strcmp(cli.prefs.guest_password, "old-guest") == 0);
assert(cli.prefs.advert_interval == 2 && cli.callbacks.local_updates == 0);
assert(cli.prefs.dirty && cli.callbacks.saves == 1);
cli.callbacks.common_ok = true;
if (guest) cli.set("guest.password new-guest", reply);
else cli.password("password new-admin", reply);
assert(!error(reply));
assert(strcmp(guest ? cli.prefs.guest_password : cli.prefs.password,
guest ? "new-guest" : "new-admin") == 0);
assert(cli.prefs.advert_interval == 0 && cli.callbacks.local_updates == 1);
assert(!cli.prefs.dirty && cli.callbacks.saves == 2);
assert(strcmp(cli.prefs.password, cli.callbacks.persisted.password) == 0);
assert(strcmp(cli.prefs.guest_password, cli.callbacks.persisted.guest_password) == 0);
}
for (bool flood : {false, true}) {
const char* key = flood ? "flood.advert.interval " : "advert.interval ";
CommonCLI cli;
for (const char* invalid : {"", " ", "nonsense", "-1", "+3", "1x", "3 4",
"4294967296", "999999999999999999999999999999", "1", "2"}) {
cli.set((std::string(key) + invalid).c_str(), reply);
assert(error(reply));
assert(cli.prefs.advert_interval == 30 && cli.prefs.flood_advert_interval == 12);
assert(cli.callbacks.saves == 0 && cli.callbacks.local_updates == 0
&& cli.callbacks.flood_updates == 0);
}
cli.set((std::string(key) + (flood ? "169" : "241")).c_str(), reply);
assert(error(reply) && cli.callbacks.saves == 0);
cli.callbacks.common_ok = false;
cli.set((std::string(key) + (flood ? "3" : "120")).c_str(), reply);
assert(error(reply) && strstr(reply, "unchanged"));
assert(cli.prefs.advert_interval == 30 && cli.prefs.flood_advert_interval == 12);
assert(cli.callbacks.local_updates == 0 && cli.callbacks.flood_updates == 0);
cli.callbacks.common_ok = true;
for (const char* valid : {"0", flood ? "3" : "60", flood ? "168" : "240"}) {
cli.set((std::string(key) + valid).c_str(), reply);
assert(strcmp(reply, "OK") == 0);
assert(cli.prefs.advert_interval == cli.callbacks.persisted.advert_interval);
assert(cli.prefs.flood_advert_interval == cli.callbacks.persisted.flood_advert_interval);
}
assert(flood ? cli.callbacks.flood_updates == 3 : cli.callbacks.local_updates == 3);
}
{
CommonCLI cli;
const std::string key = "prv.key " + std::string(128, '1');
cli.set(key.c_str(), reply);
assert(error(reply) && cli.callbacks.identity_saves == 1);
cli.callbacks.identity_ok = true;
cli.set(key.c_str(), reply);
assert(strncmp(reply, "OK, reboot", 10) == 0 && cli.callbacks.identity_saves == 2);
}
{
CommonCLI cli;
cli.prefs.dirty = false;
cli.callbacks.common_ok = false;
cli.set("multi.acks 2", reply);
assert(error(reply) && strstr(reply, "unsaved changes"));
assert(cli.prefs.multi_acks == 2 && cli.callbacks.persisted.multi_acks == 0);
assert(cli.prefs.dirty && cli._prefs_save_failures == 1);
cli.callbacks.common_ok = true;
cli.set("multi.acks 1", reply);
assert(strcmp(reply, "OK") == 0 && !cli.prefs.dirty);
// A callback skipping a save must not inherit the previous success latch.
cli.callbacks.known = false;
cli.set("multi.acks 2", reply);
assert(error(reply) && cli.prefs.dirty && cli._prefs_save_failures == 2);
}
{
CommonCLI cli;
cli.prefs.advert_interval = 2;
assert(cli.trySavePrefs(PrefsSaveRouting::Scope::Observer));
assert(cli.prefs.dirty && cli.prefs.advert_interval == 2 && cli.callbacks.local_updates == 0);
#ifdef WITH_MQTT_BRIDGE
cli.callbacks.observer_ok = false;
{
PrefsSaveReplyGuard guard(cli._prefs_save_failures, reply);
assert(!cli.saveObserverPrefs());
strcpy(reply, "OK");
}
assert(error(reply));
cli.callbacks.observer_ok = true;
{
PrefsSaveReplyGuard guard(cli._prefs_save_failures, reply);
assert(cli.saveObserverPrefs());
strcpy(reply, "OK");
}
assert(strcmp(reply, "OK") == 0);
cli.callbacks.observer_ok = false;
assert(!cli.trySavePrefs(PrefsSaveRouting::Scope::Both));
assert(!cli.prefs.dirty && cli.prefs.advert_interval == 0); // common DID commit
cli.callbacks.observer_ok = true;
cli.callbacks.common_ok = false;
cli.prefs.advert_interval = 2; cli.prefs.dirty = true;
assert(!cli.trySavePrefs(PrefsSaveRouting::Scope::Both));
assert(cli.prefs.dirty && cli.prefs.advert_interval == 2); // common did NOT commit
#endif
}
{
uint32_t failures = UINT32_MAX;
{
PrefsSaveReplyGuard outer(failures, reply);
{ PrefsSaveReplyGuard inner(failures, reply); ++failures; strcpy(reply, "OK"); }
assert(error(reply));
strcpy(reply, "OK");
}
assert(error(reply));
{ PrefsSaveReplyGuard next(failures, reply); strcpy(reply, "OK"); }
assert(strcmp(reply, "OK") == 0);
}
puts("Common CLI persistence and interval regressions passed");
}
+23
View File
@@ -319,6 +319,29 @@ require(rak_usb, "build_flags", "FORCE_GPS_ALIVE")
# replace each with that exact board's Full target.
init_project_context >/dev/null
# KISS inherits board OTA flags, not the application OTA implementation.
for kiss_env in "${SUPPORTED_PIO_ENVS[@]}"; do
is_kiss_modem_target "$kiss_env" || continue
for kiss_profile in auto standard full; do
(
BUILD_PROFILE_FOR_TARGET=$kiss_profile
ESP32_FULL_BUILD=1
if is_lora_ota_build "$kiss_env"; then
fail "$kiss_env $kiss_profile incorrectly promises LoRa OTA"
fi
BUILD_CAPABILITIES=()
BUILD_EXPECTATIONS=()
BUILD_REDUCTIONS=()
declare_build_capability_contract "$kiss_env" "${PIO_ENV_PLATFORM_BY_NAME[$kiss_env]}"
for expectation in "${BUILD_EXPECTATIONS[@]}"; do
case "$expectation" in
ota.update.lora=*|ota.cli=*) fail "$kiss_env $kiss_profile has an impossible OTA contract" ;;
esac
done
)
done
done
# The portable inflater is shared by every Arduino platform, but it does not
# supply the OTA manager or a supported bootloader/apply path. In particular,
# auto overlays for PicoW and RAK_3x72 must remain buildable without OTA.
+102
View File
@@ -0,0 +1,102 @@
"""Exercise production CLI save acknowledgements, rollback, and interval parsing."""
from pathlib import Path
import re
import subprocess
import tempfile
import unittest
from test_replay_reset_integration import extract_braced
ROOT = Path(__file__).resolve().parents[1]
def compile_run(source, macros=()):
with tempfile.TemporaryDirectory(prefix='common-cli-save-') as directory:
work = Path(directory)
cpp, exe = work / 'test.cpp', work / 'test'
cpp.write_text(source, encoding='utf-8')
subprocess.run(['g++', '-std=c++17', '-Wall', '-Wextra', '-Werror',
'-fsanitize=address,undefined', '-fno-sanitize-recover=all',
'-fno-pie', '-no-pie', *['-D' + m + '=1' for m in macros],
'-I' + str(ROOT / 'src'), str(cpp), '-o', str(exe)], check=True)
subprocess.run([str(exe)], check=True)
class CommonCLISaveResultsTest(unittest.TestCase):
def test_production_handlers(self):
source = (ROOT / 'src/helpers/CommonCLI.cpp').read_text(encoding='utf-8')
code = (ROOT / 'test/fixtures/common_cli_save_results/main.cpp').read_text()
methods = '\n'.join(extract_braced(source, signature) for signature in (
'void CommonCLI::savePrefs(PrefsSaveRouting::Scope scope)',
'bool CommonCLI::trySavePrefs(', 'bool CommonCLI::saveObserverPrefs('))
parsers = '\n'.join(extract_braced(source, signature) for signature in (
'static bool looksUnsignedInteger(', 'static const char* skipSpacesConst(',
'static bool parseUint32Strict('))
branches = '\nelse '.join(extract_braced(source, signature) for signature in (
'if (memcmp(config, "flood.advert.interval ", 22) == 0)',
'if (memcmp(config, "advert.interval ", 16) == 0)',
'if (memcmp(config, "guest.password ", 15) == 0)',
'if (memcmp(config, "prv.key ", 8) == 0)',
'if (memcmp(config, "multi.acks ", 11) == 0)'))
handler = extract_braced(source, 'void CommonCLI::handleCommand(')
guard = re.search(r'PrefsSaveReplyGuard save_reply\([^;]+;', handler).group()
for key, value in {
'PARSERS': parsers, 'SAVE_METHODS': methods, 'SET_BRANCHES': branches,
'REPLY_GUARD': guard,
'PASSWORD': extract_braced(source, 'if (memcmp(command, "password ", 9) == 0)'),
}.items():
code = code.replace('@' + key + '@', value)
for macros in ((), ('WITH_MQTT_BRIDGE',)):
with self.subTest(macros=macros):
compile_run(code, macros)
def test_observer_setters_use_the_observer_store(self):
source = (ROOT / 'src/helpers/CommonCLI_Observer.cpp').read_text()
setter = extract_braced(source, 'bool CommonCLI::handleObserverSetCmd(')
self.assertNotIn('savePrefs(', setter)
self.assertIn('saveObserverPrefs();', setter)
def test_all_identity_callbacks_return_the_store_result(self):
methods = []
for role, name, cls in (('simple_repeater', 'MyMesh', 'Repeater'),
('simple_room_server', 'MyMesh', 'Room'),
('simple_sensor', 'SensorMesh', 'Sensor')):
source = (ROOT / 'examples' / role / (name + '.cpp')).read_text()
header = (ROOT / 'examples' / role / (name + '.h')).read_text()
self.assertIn('bool saveIdentity(', header)
method = extract_braced(source, f'bool {name}::saveIdentity(')
methods.append(method.replace(name + '::', cls + '::'))
code = r'''
#include <cassert>
#include <cstring>
#include <initializer_list>
namespace mesh { struct LocalIdentity {}; }
struct FS { bool ok = false; unsigned calls = 0; };
struct IdentityStore {
FS& fs;
IdentityStore(FS& f, const char*) : fs(f) {}
bool saveWithRetry(const char* name, const mesh::LocalIdentity&) {
assert(strcmp(name, "_main") == 0); ++fs.calls; return fs.ok;
}
};
struct Repeater { FS fs; FS* _fs = &fs; bool saveIdentity(const mesh::LocalIdentity&); };
struct Room { FS fs; FS* _fs = &fs; bool saveIdentity(const mesh::LocalIdentity&); };
struct Sensor { FS fs; FS* _fs = &fs; bool saveIdentity(const mesh::LocalIdentity&); };
@METHODS@
template<typename Role> void check() {
Role role; mesh::LocalIdentity id;
for (bool result : {false, true, false}) {
role.fs.ok = result;
assert(role.saveIdentity(id) == result);
}
assert(role.fs.calls == 3);
}
int main() { check<Repeater>(); check<Room>(); check<Sensor>(); }
'''.replace('@METHODS@', '\n'.join(methods))
for platform in ('NRF52_PLATFORM', 'STM32_PLATFORM', 'ESP32', 'RP2040_PLATFORM'):
with self.subTest(platform=platform):
compile_run(code, (platform,))
if __name__ == '__main__':
unittest.main()