Persist unchanged Companion channel route SETs before OK

Require a real raw-table save for same-value channel SETs. This repairs the false acknowledgement after successful constructor recovery of an older backup followed by a failed candidate write.

Keep resolved alias keys, raw AUTO policy and runtime generation untouched. Preserve GET and unchanged contact behavior, changed-policy validation and indeterminate-publication quarantine.

Cover real stale-backup creation, failure/retry, reboot and exact old-skip controls. Add actual alias/BaseChatMesh and raw app-key regression tests on all four backends. Verified 117 related host tests, 16 final caller rechecks, four firmware qualifications and linked nRF52 crypto checks.
This commit is contained in:
mikecarper committed 2026-10-16 11:34:51 -07:00
1 parent 24b1264a0e
commit 4b81a9d5e0
6 files changed
+340 -16

No files matched your search

+1
View File
@@ -323,6 +323,7 @@ jobs:
python3 -B test/test_companion_channel_alias.py -v
python3 -B test/test_companion_tx_routing.py -v
python3 -B test/test_companion_channel_publication.py -v
python3 -B test/test_companion_tx_raw_alias.py -v
python3 -B test/test_companion_traffic_routing.py -v
python3 -B test/test_ethernet_kiss.py -v
python3 -B test/test_ethernet_radio.py -v
+8 -4
View File
@@ -3744,13 +3744,17 @@ bool MyMesh::handleTxRoutingCommand(const char* command, char* reply, size_t rep
return true;
}
uint8_t policy = parsed.channel ? channel.channel.tx_radio : contact->tx_radio;
if (parsed.set && policy != parsed.policy) {
bool saved;
if (parsed.channel) {
if (parsed.set) {
bool saved = true;
if (policy == parsed.policy) {
// A prior recovery may have restored older disk data. Save the raw
// table without mutating a resolved alias or its runtime generation.
if (parsed.channel) saved = saveChannels();
} else if (parsed.channel) {
const auto alias = parseCompanionChannelAlias(channel.name);
if (isAliasRadio2Defined()
&& (alias.kind == CompanionChannelKind::Radio2 || alias.kind == CompanionChannelKind::Meshtext)) {
snprintf(reply, reply_size, "Error: channel prefix controls routing; rename it to change routing");
snprintf(reply, reply_size, "Error: prefix controls routing; rename channel");
return true;
}
// Persist the app PSK, not the resolved wire-key copy used for reporting.
+107 -5
View File
@@ -19,7 +19,7 @@ static void failPublicObservationAfterPublish(MyMesh& node) {
#endif
}
static void publication_uncertainty_cannot_acknowledge_a_rolled_back_value() {
static void publication_uncertainty_quarantines_further_channel_writes() {
MyMesh node;
seed(node);
const auto old_image = node.store.fs.files.at("/channels2");
@@ -34,8 +34,8 @@ static void publication_uncertainty_cannot_acknowledge_a_rolled_back_value() {
assert(!node.store.hasIncompleteContactLoad());
const unsigned writes = node.writes;
assert(node.handleTxRoutingCommand("set tx.channel 0 radio", reply, sizeof(reply)));
// Exact regression invariant: without the publication latch this same-value
// branch returns OK without saving, although a reboot restores BOTH.
// Indeterminate publication must keep channel writes quarantined, including
// a new request for the caller's rolled-back live value.
assert(strstr(reply, "storage unavailable"));
assert(node.writes == writes && node.store.hasIncompleteChannelLoad());
assert(node.store.fs.files.at("/channels2") == published);
@@ -62,12 +62,64 @@ static void prepublication_failures_remain_retryable() {
node.store.fs.fail_write = false;
const unsigned writes = node.writes;
assert(node.handleTxRoutingCommand("set tx.channel 0 radio", reply, sizeof(reply)));
assert(!strncmp(reply, "OK", 2) && node.writes == writes);
assert(!strncmp(reply, "OK", 2) && node.writes == writes + 1);
assert(last_channel_outcome == 2);
assert(node.store.fs.files.at("/channels2")[0] == mesh::encodeRadioTxPolicy(mesh::RADIO_TX_PRIMARY));
assert(node.handleTxRoutingCommand("set tx.channel 0 both", reply, sizeof(reply)));
assert(!strncmp(reply, "OK", 2) && last_channel_outcome == 2);
}
static void unchanged_channel_sets_require_durability_without_mutation() {
MyMesh node;
seed(node);
const auto old_image = node.store.fs.files.at("/channels2");
const unsigned sets = node.channel_sets, generation = node._ethernet_channel_generation;
node._mt_delivery_config_dirty = false;
char reply[160];
node.store.fs.fail_write = true;
const unsigned failed_writes = node.writes;
assert(node.handleTxRoutingCommand("set tx.channel 0 radio", reply, sizeof(reply)));
assert(strstr(reply, "not saved") && node.writes == failed_writes + 1);
assert(last_channel_outcome == 1 && !node.store.hasIncompleteChannelLoad());
assert(node.store.fs.files.at("/channels2") == old_image);
node.store.fs.fail_write = false;
const unsigned retry_writes = node.writes;
assert(node.handleTxRoutingCommand("set tx.channel 0 radio", reply, sizeof(reply)));
assert(!strncmp(reply, "OK", 2) && node.writes == retry_writes + 1);
assert(last_channel_outcome == 2);
assert(node.store.fs.files.at("/channels2") == old_image);
assert(node.channel_sets == sets && node._ethernet_channel_generation == generation);
assert(!node._mt_delivery_config_dirty);
const unsigned read_writes = node.writes;
assert(node.handleTxRoutingCommand("get tx.channel 0", reply, sizeof(reply)));
assert(reply[0] == '>' && node.writes == read_writes);
assert(node.handleTxRoutingCommand("set tx.user \"Healthy contact\" auto", reply, sizeof(reply)));
assert(!strncmp(reply, "OK", 2) && node.writes == read_writes);
}
static void unchanged_alias_sets_save_the_raw_policy_and_key() {
MyMesh node;
seed(node);
strcpy(node.channels[1].name, "#r2-emergency");
memset(node.channels[1].channel.secret, 0x42, 32);
node.channels[1].channel.tx_radio = mesh::RADIO_TX_AUTO;
node.radio.config.secondary.mode = mesh::RadioProfileMode::RxTx;
assert(node.store.saveChannels(&node));
const auto image = node.store.fs.files.at("/channels2");
const unsigned sets = node.channel_sets, generation = node._ethernet_channel_generation;
node._mt_delivery_config_dirty = false;
char reply[160];
const unsigned writes = node.writes;
assert(node.handleTxRoutingCommand("set tx.channel 1 radio2", reply, sizeof(reply)));
assert(!strncmp(reply, "OK", 2) && node.writes == writes + 1);
assert(strstr(reply, "active=radio2"));
assert(node.store.fs.files.at("/channels2") == image);
assert(image[68] == mesh::encodeRadioTxPolicy(mesh::RADIO_TX_AUTO));
assert(image[68 + 36] == 0x42);
assert(node.channel_sets == sets && node._ethernet_channel_generation == generation);
assert(!node._mt_delivery_config_dirty);
}
static void binary_psk_rollback_preserves_live_communications_but_blocks_writes() {
MyMesh node;
seed(node);
@@ -97,6 +149,51 @@ static void binary_psk_rollback_preserves_live_communications_but_blocks_writes(
}
#if defined(ESP32_PLATFORM) || defined(RP2040_PLATFORM)
static void restored_stale_backup_requires_a_new_commit_before_acknowledgement() {
for (bool fail_again : {false, true}) {
MyMesh node;
seed(node);
const auto old_image = node.store.fs.files.at("/channels2");
char reply[160];
node.store.fs.fail_remove = true; // Retain old backup after successful publish.
assert(node.handleTxRoutingCommand("set tx.channel 0 both", reply, sizeof(reply)));
assert(!strncmp(reply, "OK", 2) && last_channel_outcome == 2);
assert(node.store.fs.files.at("/channels2.bak") == old_image);
node.store.fs.fail_remove = false;
node.store.fs.files.erase("/channels2"); // Loss of just the active metadata entry.
node.store.fs.fail_write = true;
assert(node.handleTxRoutingCommand("set tx.channel 0 off", reply, sizeof(reply)));
assert(strstr(reply, "not saved") && last_channel_outcome == 1);
assert(!node.store.hasIncompleteChannelLoad());
assert(node.channels[0].channel.tx_radio == mesh::RADIO_TX_BOTH);
assert(node.store.fs.files.at("/channels2") == old_image);
node.store.fs.fail_write = fail_again;
const unsigned sets = node.channel_sets, generation = node._ethernet_channel_generation;
node._mt_delivery_config_dirty = false;
const unsigned writes = node.writes;
assert(node.handleTxRoutingCommand("set tx.channel 0 both", reply, sizeof(reply)));
if (fail_again) {
assert(strstr(reply, "not saved") && node.writes == writes + 1);
assert(node.store.fs.files.at("/channels2") == old_image);
assert(!node.store.hasIncompleteChannelLoad());
node.store.fs.fail_write = false;
assert(node.handleTxRoutingCommand("set tx.channel 0 both", reply, sizeof(reply)));
}
assert(!strncmp(reply, "OK", 2));
// Exact negative-control invariant: old same-value SET skipped persistence
// and falsely acknowledged BOTH while this public record still held RADIO.
assert(mesh::decodeRadioTxPolicy(node.store.fs.files.at("/channels2")[0]) == mesh::RADIO_TX_BOTH);
assert(node.writes == writes + (fail_again ? 2 : 1));
assert(last_channel_outcome == 2);
assert(node.channel_sets == sets && node._ethernet_channel_generation == generation);
assert(!node._mt_delivery_config_dirty);
MyMesh reboot;
reboot.store.fs.files = node.store.fs.files;
reboot.store.loadChannels(&reboot);
assert(reboot.channels[0].channel.tx_radio == mesh::RADIO_TX_BOTH);
}
}
static void known_rollback_is_retryable_but_failed_rollback_is_quarantined() {
for (bool rollback_fails : {false, true}) {
MyMesh node;
@@ -148,8 +245,13 @@ static void constructor_recovery_uncertainty_is_quarantined_without_a_commit() {
#endif
int main() {
#if defined(ESP32_PLATFORM) || defined(RP2040_PLATFORM)
restored_stale_backup_requires_a_new_commit_before_acknowledgement();
#endif
prepublication_failures_remain_retryable();
publication_uncertainty_cannot_acknowledge_a_rolled_back_value();
unchanged_channel_sets_require_durability_without_mutation();
unchanged_alias_sets_save_the_raw_policy_and_key();
publication_uncertainty_quarantines_further_channel_writes();
binary_psk_rollback_preserves_live_communications_but_blocks_writes();
#if defined(ESP32_PLATFORM) || defined(RP2040_PLATFORM)
known_rollback_is_retryable_but_failed_rollback_is_quarantined();
+25 -5
View File
@@ -12,7 +12,7 @@ from test_companion_preferences_transaction import esp_recovery_helpers
ROOT = Path(__file__).resolve().parents[1]
def publication_fixture(work, missing_latch):
def publication_fixture(work, missing_latch, skip_noop_save=False):
store = (ROOT / "examples/companion_radio/DataStore.cpp").read_text()
header = (ROOT / "examples/companion_radio/DataStore.h").read_text()
mesh = (ROOT / "examples/companion_radio/MyMesh.cpp").read_text()
@@ -74,7 +74,18 @@ def publication_fixture(work, missing_latch):
errno = EIO;
return -1; // ESP metadata errors remain distinguishable from absence.
}""", 1)
methods = recovery + "\n" + method(mesh, "bool MyMesh::handleTxRoutingCommand(")
route = method(mesh, "bool MyMesh::handleTxRoutingCommand(")
if skip_noop_save:
# Restore exactly the pre-fix outer changed-policy condition and skip
# the new unchanged-channel branch; all writer and latch code remains.
opening = route.index(" if (parsed.set) {", route.index(" uint8_t policy ="))
changed = " } else if (parsed.channel) {"
changed_end = route.index(changed, opening) + len(changed)
assert "if (parsed.channel) saved = saveChannels();" in route[opening:changed_end]
route = (route[:opening]
+ " if (parsed.set && policy != parsed.policy) {\n bool saved;\n if (parsed.channel) {"
+ route[changed_end:])
methods = recovery + "\n" + route
methods += "\n" + method(store, "void DataStore::loadChannels(")
methods += "\n" + save + "\n" + method(store, "bool DataStore::hasIncompleteContactLoad(")
binary_start = mesh.index(" } else if (cmd_frame[0] == CMD_SET_CHANNEL && len >= 2 + 32 + 16) {")
@@ -131,10 +142,10 @@ static bool contactPathPresence(FILESYSTEM* fs, const char* path, bool& present,
class CompanionChannelPublicationTest(unittest.TestCase):
def run_platform(self, platform, missing_latch=False):
def run_platform(self, platform, missing_latch=False, skip_noop_save=False):
with tempfile.TemporaryDirectory(prefix="mesh-channel-publication-") as directory:
work = Path(directory)
fixture = publication_fixture(work, missing_latch)
fixture = publication_fixture(work, missing_latch, skip_noop_save)
binary = work / "test"
compiled = subprocess.run([
"g++", "-std=c++17", "-O1", "-g", "-Wall", "-Wextra", "-D" + platform + "=1",
@@ -146,7 +157,10 @@ class CompanionChannelPublicationTest(unittest.TestCase):
], capture_output=True, text=True, timeout=60)
self.assertEqual(compiled.returncode, 0, compiled.stdout + compiled.stderr)
result = subprocess.run([str(binary)], capture_output=True, text=True, timeout=10)
if missing_latch:
if skip_noop_save:
self.assertNotEqual(result.returncode, 0)
self.assertIn('mesh::decodeRadioTxPolicy(node.store.fs.files.at("/channels2")[0]) == mesh::RADIO_TX_BOTH', result.stderr)
elif missing_latch:
self.assertNotEqual(result.returncode, 0)
self.assertIn('strstr(reply, "storage unavailable")', result.stderr)
else:
@@ -177,6 +191,12 @@ class CompanionChannelPublicationTest(unittest.TestCase):
def test_missing_rp2040_publication_latch_is_detected(self):
self.run_platform("RP2040_PLATFORM", missing_latch=True)
def test_esp32_old_same_value_skip_after_stale_backup_is_detected(self):
self.run_platform("ESP32_PLATFORM", skip_noop_save=True)
def test_rp2040_old_same_value_skip_after_stale_backup_is_detected(self):
self.run_platform("RP2040_PLATFORM", skip_noop_save=True)
if __name__ == "__main__":
unittest.main()
+176
View File
@@ -0,0 +1,176 @@
#!/usr/bin/env python3
"""Keep actual channel aliases raw across durable same-value TX route SETs."""
from pathlib import Path
import hashlib
import shutil
import subprocess
import tempfile
import unittest
from test_companion_channel_publication import publication_fixture
from test_radio_receive_contract import method
ROOT = Path(__file__).resolve().parents[1]
def replace_once(source, before, after):
assert source.count(before) == 1, before
return source.replace(before, after, 1)
def raw_alias_fixture(work):
mesh = (ROOT / "examples/companion_radio/MyMesh.cpp").read_text(encoding="ascii")
header = (ROOT / "examples/companion_radio/MyMesh.h").read_text(encoding="ascii")
base = (ROOT / "src/helpers/BaseChatMesh.cpp").read_text(encoding="ascii")
# The alias/key helpers execute unchanged. Supply independent SHA-256
# vectors only at their crypto boundary, including cached channel hashes.
vectors = [b"#r2-emergency", b"#emergency", bytes(16)]
vectors += [hashlib.sha256(name).digest()[:16] for name in vectors[:2]]
sha = "static void sha256(uint8_t* out, size_t size, const uint8_t* input, int length) {\n"
for raw in vectors:
data = ",".join(str(value) for value in raw)
digest = ",".join(str(value) for value in hashlib.sha256(raw).digest())
sha += (" { static const uint8_t in[] = {" + data + "};\n"
" static const uint8_t hash[] = {" + digest + "};\n"
" if (length == int(sizeof(in)) && !memcmp(in,input,sizeof(in))) "
"{ assert(size <= sizeof(hash)); memcpy(out,hash,size); return; } }\n")
sha += ' assert(!"input outside independent SHA256 oracle");\n}\n'
generated = publication_fixture(work, False)
code = generated.read_text(encoding="ascii")
code = code[:code.index("int main() {")]
code = replace_once(code, "struct Utils {", "struct Utils {\n" + sha)
# Replace the publication fixture's simplified channel boundary with the
# real raw accessors, alias resolution and generation-changing setter.
for signature, declaration in (
(" bool getChannel(int i, ChannelDetails& c)", " bool getChannel(int, ChannelDetails&);"),
(" bool setChannel(int i, const ChannelDetails& c)", " bool setChannel(int, const ChannelDetails&);"),
(" bool isAliasRadio2Defined() const", " bool isAliasRadio2Defined() const;"),
(" bool resolveChannelAlias(ChannelDetails&) const", " bool resolveChannelAlias(ChannelDetails&) const;"),
):
code = replace_once(code, method(code, signature), declaration)
for signature, production in (
(" bool getChannelForSave(uint8_t i,ChannelDetails& c)", " bool getChannelForSave(uint8_t channel_idx,"),
(" bool onChannelLoaded(uint8_t i,const ChannelDetails& c)", " bool onChannelLoaded(uint8_t channel_idx,"),
):
code = replace_once(code, method(code, signature), method(header, production))
code = replace_once(code, " ChannelDetails channels[MAX_GROUP_CHANNELS];\n", "")
code = replace_once(code, "class MyMesh : public DataStoreHost {", """
class BaseChatMesh {
public:
ChannelDetails channels[MAX_GROUP_CHANNELS];
bool getChannel(int, ChannelDetails&);
bool setChannel(int, const ChannelDetails&);
};
class MyMesh : public BaseChatMesh, public DataStoreHost {""")
code = replace_once(code, " bool writable=true, writes_ok=true; unsigned writes=0;", """
struct { bool enabled = false; } _ethernet_settings;
bool writable=true, writes_ok=true; unsigned writes=0;""")
assert "unsigned channel_sets=0, _ethernet_channel_generation=0;" in code
code += "\n" + "\n".join(method(base, signature) for signature in (
"bool BaseChatMesh::getChannel(", "bool BaseChatMesh::setChannel("))
code += "\n" + "\n".join(method(mesh, signature) for signature in (
"bool MyMesh::isAliasRadio2Defined(", "bool MyMesh::resolveChannelAlias(",
"bool MyMesh::getChannel(", "bool MyMesh::setChannel("))
code += r'''
int main() {
MyMesh node;
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
node.store.fs.rename_replaces = true;
#endif
strcpy(node.channels[0].name, "#r2-emergency");
mesh::Utils::sha256(node.channels[0].channel.secret, 16,
reinterpret_cast<const uint8_t*>(node.channels[0].name), strlen(node.channels[0].name));
node.channels[0].channel.tx_radio = mesh::RADIO_TX_AUTO;
node.radio.config.secondary.mode = mesh::RadioProfileMode::RxTx;
node.radio.config.cross = mesh::RadioCrossMode::Off;
strcpy(node.contacts[1].name, "Healthy contact");
node.contacts[1].tx_radio = mesh::RADIO_TX_PRIMARY;
assert(node.store.saveChannels(&node));
const ChannelDetails raw = node.channels[0];
const auto original = node.store.fs.files.at("/channels2");
uint8_t canonical_key[16];
mesh::Utils::sha256(canonical_key, sizeof(canonical_key),
reinterpret_cast<const uint8_t*>("#emergency"), strlen("#emergency"));
ChannelDetails effective;
assert(node.getChannel(0, effective));
assert(effective.channel.tx_radio == mesh::RADIO_TX_SECONDARY);
assert(memcmp(effective.channel.secret, raw.channel.secret, 16));
assert(!memcmp(effective.channel.secret, canonical_key, sizeof(canonical_key)));
char reply[160] = {};
const auto generation = node._ethernet_channel_generation;
auto writes = node.writes;
node.store.fs.fail_write = true;
assert(node.handleTxRoutingCommand("set tx.channel 0 radio2", reply, sizeof(reply)));
assert(strstr(reply, "not saved") && node.writes == writes + 1);
assert(!memcmp(&node.channels[0], &raw, sizeof(raw)));
assert(node._ethernet_channel_generation == generation);
assert(node.store.fs.files.at("/channels2") == original);
assert(!node.store.hasIncompleteChannelLoad());
node.store.fs.fail_write = false;
writes = node.writes;
assert(node.handleTxRoutingCommand("set tx.channel 0 radio2", reply, sizeof(reply)));
assert(!strncmp(reply, "OK", 2) && node.writes == writes + 1);
assert(!memcmp(&node.channels[0], &raw, sizeof(raw)));
assert(node._ethernet_channel_generation == generation);
assert(node.store.fs.files.at("/channels2") == original);
assert(original[0] == mesh::encodeRadioTxPolicy(mesh::RADIO_TX_AUTO));
assert(!memcmp(original.data() + 36, raw.channel.secret, 32));
writes = node.writes;
assert(node.handleTxRoutingCommand("get tx.channel 0", reply, sizeof(reply)));
assert(node.handleTxRoutingCommand("set tx.user \"Healthy contact\" radio", reply, sizeof(reply)));
assert(!strncmp(reply, "OK", 2) && node.writes == writes);
assert(node.handleTxRoutingCommand("set tx.channel 0 radio", reply, sizeof(reply)));
assert(strstr(reply, "prefix controls routing") && node.writes == writes);
MyMesh reboot;
reboot.store.fs.files = node.store.fs.files;
reboot.radio.config.secondary = node.radio.config.secondary;
reboot.store.loadChannels(&reboot);
assert(reboot.channels[0].channel.tx_radio == mesh::RADIO_TX_AUTO);
assert(!memcmp(reboot.channels[0].channel.secret, raw.channel.secret, 32));
assert(reboot.getChannel(0, effective));
assert(effective.channel.tx_radio == mesh::RADIO_TX_SECONDARY);
assert(!memcmp(effective.channel.secret, canonical_key, sizeof(canonical_key)));
puts("PASS: actual channel SET retry, raw AUTO/app PSK and effective SECONDARY/wire key");
}
'''
generated.write_text(code, encoding="ascii")
return generated
class CompanionTxRawAliasTest(unittest.TestCase):
def run_platform(self, platform):
compiler = shutil.which("g++") or shutil.which("clang++")
self.assertIsNotNone(compiler, "a C++ compiler is required")
with tempfile.TemporaryDirectory(prefix="channel-samevalue-real-alias-") as directory:
work = Path(directory)
generated = raw_alias_fixture(work)
binary = work / "test"
compiled = subprocess.run([
compiler, "-std=c++17", "-O1", "-g", "-Wall", "-Wextra", "-D" + platform + "=1",
"-DMESH_CONTACT_CACHE=0", "-DMESH_ENABLE_MESHTEXT_CHAT=0", "-DMESH_ENABLE_ETHERNET_TUNNEL=1",
"-fsanitize=address,undefined", "-fno-sanitize-recover=all", "-fno-pie", "-no-pie",
"-I", str(work), "-I", str(ROOT / "test/fixtures/radio_profiles/mocks"),
"-I", str(ROOT / "test/mocks"), "-I", str(ROOT / "src"), "-I", str(ROOT / "src/helpers"),
str(generated), "-o", str(binary),
], capture_output=True, text=True, timeout=60)
self.assertEqual(compiled.returncode, 0, compiled.stdout + compiled.stderr)
tested = subprocess.run([str(binary)], capture_output=True, text=True, timeout=20)
self.assertEqual(tested.returncode, 0, tested.stdout + tested.stderr)
self.assertIn("PASS: actual channel SET retry", tested.stdout)
def test_nrf52_raw_alias_failure_and_retry(self):
self.run_platform("NRF52_PLATFORM")
def test_stm32_raw_alias_failure_and_retry(self):
self.run_platform("STM32_PLATFORM")
def test_esp32_raw_alias_failure_and_retry(self):
self.run_platform("ESP32_PLATFORM")
def test_rp2040_raw_alias_failure_and_retry(self):
self.run_platform("RP2040_PLATFORM")
if __name__ == "__main__":
unittest.main()
+23 -2
View File
@@ -54,6 +54,11 @@ struct DataStore {
@CHANNEL_METHOD@
const char* _channel_recovery_source=nullptr;
bool hasIncompleteContactLoad() const;
DataStore() {
#if defined(STM32_PLATFORM)
fs.rename_replaces=true;
#endif
}
FILESYSTEM* _getContactsChannelsFS() { return &fs; }
File openRead(FILESYSTEM* fs, const char* name) { return fs->open(name,"r"); }
void loadChannels(DataStoreHost*);
@@ -68,6 +73,8 @@ public:
DataStore store;
DataStore* _store=&store;
bool writable=true, writes_ok=true; unsigned writes=0;
unsigned channel_sets=0, _ethernet_channel_generation=0;
bool _mt_delivery_config_dirty=false;
bool canMutateContacts() const { return writable && !store.hasIncompleteContactLoad(); }
int getTotalContactSlots() const { return contacts.size(); }
ContactInfo* getContactPtrByIdx(int i) { return &contacts.at(i); }
@@ -81,7 +88,11 @@ public:
}
return true;
}
bool setChannel(int i, const ChannelDetails& c) { if (i<0 || i>=MAX_GROUP_CHANNELS) return false; channels[i]=c; return true; }
bool setChannel(int i, const ChannelDetails& c) {
if (i<0 || i>=MAX_GROUP_CHANNELS) return false;
++channel_sets; ++_ethernet_channel_generation; _mt_delivery_config_dirty=true;
channels[i]=c; return true;
}
bool saveChannels() { ++writes; return writes_ok && store.saveChannels(this); }
bool scheduleContactWrite(const ContactInfo&) { ++writes; return writes_ok; }
bool flushContactsBeforeReboot() { return writes_ok; }
@@ -126,6 +137,9 @@ int main() {
cmd("set tx.user \"Alice Jones\" radio2"); assert(!strncmp(reply,"OK",2));
assert(strstr(reply,"active=radio2") && node.contacts[1].tx_radio==mesh::RADIO_TX_SECONDARY);
assert(node.contacts[2].tx_radio==mesh::RADIO_TX_AUTO);
const unsigned unchanged_contact_writes=node.writes;
cmd("set tx.user \"Alice Jones\" radio2"); assert(!strncmp(reply,"OK",2));
assert(node.writes==unchanged_contact_writes);
cmd("get tx.user Alice Jones"); assert(strstr(reply,"radio2"));
cmd("set tx.user Bob off"); assert(strstr(reply,"ambiguous"));
cmd("set tx.user key:111111111111 off"); assert(strstr(reply,"ambiguous"));
@@ -291,9 +305,16 @@ int main() {
aliased.radio.config.secondary.mode=mesh::RadioProfileMode::RxTx;
auto alias_cmd=[&](const char* text) { assert(aliased.handleTxRoutingCommand(text,reply,sizeof(reply))); };
alias_cmd("get tx.channel 1"); assert(strstr(reply,"active=radio2"));
assert(aliased.writes==0);
alias_cmd("set tx.channel 1 radio"); assert(strstr(reply,"prefix controls routing"));
assert(aliased.writes==0 && aliased.channels[1].channel.tx_radio==mesh::RADIO_TX_AUTO);
alias_cmd("set tx.channel 1 radio2"); assert(!strncmp(reply,"OK",2) && aliased.writes==0);
alias_cmd("set tx.channel 1 radio2"); assert(!strncmp(reply,"OK",2) && aliased.writes==1);
assert(aliased.channel_sets==0 && aliased._ethernet_channel_generation==0);
assert(!aliased._mt_delivery_config_dirty);
assert(aliased.channels[1].channel.tx_radio==mesh::RADIO_TX_AUTO);
assert(aliased.store.fs.files["/channels2"][68]==mesh::encodeRadioTxPolicy(mesh::RADIO_TX_AUTO));
assert(aliased.store.fs.files["/channels2"][68+36]==42);
assert(!strcmp(reinterpret_cast<const char*>(aliased.store.fs.files["/channels2"].data()+68+4),"#r2-emergency"));
aliased.radio.config.secondary.mode=mesh::RadioProfileMode::Off;
alias_cmd("set tx.channel 1 radio"); assert(!strncmp(reply,"OK",2));
assert(aliased.channels[1].channel.tx_radio==mesh::RADIO_TX_PRIMARY);