diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index 77a0cf3eb..8eda09132 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -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 diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 90e7c1281..f88450709 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -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. diff --git a/test/fixtures/companion_channel_publication/checks.h b/test/fixtures/companion_channel_publication/checks.h index f5530ba51..836388913 100644 --- a/test/fixtures/companion_channel_publication/checks.h +++ b/test/fixtures/companion_channel_publication/checks.h @@ -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(); diff --git a/test/test_companion_channel_publication.py b/test/test_companion_channel_publication.py index 604e44914..a536b5bb2 100644 --- a/test/test_companion_channel_publication.py +++ b/test/test_companion_channel_publication.py @@ -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() diff --git a/test/test_companion_tx_raw_alias.py b/test/test_companion_tx_raw_alias.py new file mode 100644 index 000000000..b1342b6be --- /dev/null +++ b/test/test_companion_tx_raw_alias.py @@ -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(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("#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() diff --git a/test/test_companion_tx_routing.py b/test/test_companion_tx_routing.py index 0458a07d4..134adc0c5 100644 --- a/test/test_companion_tx_routing.py +++ b/test/test_companion_tx_routing.py @@ -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(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);