diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index c06b9c16d..07f2a229c 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -10,6 +10,18 @@ on: workflow_dispatch: jobs: + nrf52-companion-smoke: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Setup Build Environment + uses: ./.github/actions/setup-build-environment + - name: Qualify RAK4631 Full Companion with linked Meshtext support + run: >- + bash build.sh build-firmware RAK_4631_companion_radio_full + --firmware-version v1.17.1.10-halo-keymind-cascade-dev + --radio-preset usa-cascade-fixed --profile cascade --require-ota + nrf52-sensor-smoke: runs-on: ubuntu-latest steps: @@ -287,10 +299,12 @@ jobs: - name: Verify Meshtext chat codec, transport and Companion integration run: | python3 -B test/test_meshtext_chat.py -v + python3 -B test/test_meshtext_config.py -v python3 -B test/test_meshtext_transport.py -v python3 -B test/test_meshtext_license.py -v python3 -B test/test_radio_profile_auto.py -v python3 -B test/test_companion_meshtext.py -v + python3 -B test/test_companion_meshtext_storage.py -v python3 -B test/test_meshtext_auto_radio2_hil.py -v python3 -B test/test_meshtext_auto_radio2_hil_sdk.py -v diff --git a/build_legacy.sh b/build_legacy.sh index 13704b39a..ade2a87f7 100755 --- a/build_legacy.sh +++ b/build_legacy.sh @@ -4473,6 +4473,11 @@ write_build_capability_manifest() { || is_xiao_qspi_canonical_repeater_build "$env_name"; then checker_args+=(--require-ota) fi + if [ "$env_name" = "RAK_4631_companion_radio_full" ]; then + # This profile is offered for MeshTalk testing. Check executable codec and + # Companion transport definitions, not a channel name or CLI help string. + checker_args+=(--require-meshtext) + fi if [ "$env_platform" = "ESP32_PLATFORM" ]; then checker_args+=(--firmware-bin "${build_output_dir}/firmware.bin" --partitions "${build_output_dir}/partitions.bin") diff --git a/docs/radio_profiles.md b/docs/radio_profiles.md index 08791bd6e..4b6885ebe 100644 --- a/docs/radio_profiles.md +++ b/docs/radio_profiles.md @@ -281,8 +281,10 @@ recognize these aliases. ### Meshtext channel chat `MESH_ENABLE_MESHTEXT_CHAT` enables the text-only Meshtext backend in the -Companion. It defaults on only for ESP32-S3 **Full Companion** builds using -SX1262 LoRa, not an ESP-NOW primary. Other builds default off. This is not +Companion. It defaults on for ESP32-S3 **Full Companion** builds using +SX1262 LoRa and for the RAK4631 **Full Companion** starting with 1.17.1.10 +preview3. An ESP-NOW primary and other builds default off. RAK4631 1.17.1.10 +preview2 and earlier builds did not include this backend. This is not a replacement for stock peer firmware or a cross-protocol bridge. In an enabled build, an `mt-` name always requests Meshtext, even if radio2 is diff --git a/examples/companion_radio/DataStore.cpp b/examples/companion_radio/DataStore.cpp index 5d4a17c4e..9d2031a2d 100644 --- a/examples/companion_radio/DataStore.cpp +++ b/examples/companion_radio/DataStore.cpp @@ -371,7 +371,7 @@ static void cleanupAtomicTempFiles(FILESYSTEM* fs) { "/_main.id.tmp", "/new_prefs.tmp", "/channels2.tmp", "/contacts3.tmp", "/contacts4.mig.tmp", "/adv_blobs.tmp", "/.extrafs.mig.tmp", "/extrafs.badpages.tmp", - "/extrafs.badpages.bak.tmp"}; + "/extrafs.badpages.bak.tmp", "/mesh_mt.tmp"}; for (size_t i = 0; i < sizeof(fixed_temp_paths) / sizeof(fixed_temp_paths[0]); i++) { if (fs->exists(fixed_temp_paths[i])) fs->remove(fixed_temp_paths[i]); } @@ -573,6 +573,56 @@ void DataStore::useVolatilePrimaryFS(FILESYSTEM& fs) { _contact_load_incomplete = false; _prefs_load_incomplete = false; } + +bool DataStore::loadMeshtextSettings(uint8_t image[40], size_t& size) { + size = 0; + if (image == nullptr || _fs == nullptr || _primary_storage_unavailable + || _volatile_primary_fs) return false; + bool present = false; + uint32_t stored_size = 0; + if (!contactPathPresence(_fs, "/mesh_mt", present, &stored_size)) return false; + if (!present) return true; + // This backend has never stored the legacy ESP32-only NVS formats. Do not + // accept a truncated image, directory or uncommitted .tmp as fresh defaults. + if (stored_size != 40) return false; + for (uint8_t attempt = 0; attempt < 3; ++attempt) { + File file = openRead(_fs, "/mesh_mt"); + if (!file) continue; + uint8_t loaded[40]; + const bool ok = !file.isDirectory() && file.size() == sizeof(loaded) + && file.read(loaded, sizeof(loaded)) == sizeof(loaded); + file.close(); + if (ok) { + memcpy(image, loaded, sizeof(loaded)); + size = sizeof(loaded); + return true; + } + } + return false; +} + +bool DataStore::saveMeshtextSettings(const uint8_t image[40]) { + if (image == nullptr || _fs == nullptr || _primary_storage_unavailable + || _volatile_primary_fs) return false; + bool present = false; + if (!contactPathPresence(_fs, "/mesh_mt", present)) return false; + if (!contactPathPresence(_fs, "/mesh_mt.tmp", present)) return false; + if (present && !_fs->remove("/mesh_mt.tmp")) return false; + mesh::AtomicFileWriter file(_fs, "/mesh_mt"); + return file && file.write(image, 40) == 40 && file.commit(); +} + +bool DataStore::eraseMeshtextSettings() { + if (_fs == nullptr || _primary_storage_unavailable || _volatile_primary_fs) return false; + bool present = false; + // A staging file must never become a recovery source after explicit erase. + for (const char* path : {"/mesh_mt.tmp", "/mesh_mt"}) { + if (!contactPathPresence(_fs, path, present)) return false; + if (present && !_fs->remove(path)) return false; + if (!contactPathPresence(_fs, path, present) || present) return false; + } + return true; +} #endif void DataStore::disableSecondaryFS(bool authority_unknown) { diff --git a/examples/companion_radio/DataStore.h b/examples/companion_radio/DataStore.h index 30ef3cc70..4cc231c93 100644 --- a/examples/companion_radio/DataStore.h +++ b/examples/companion_radio/DataStore.h @@ -188,6 +188,11 @@ public: #if defined(NRF52_PLATFORM) void useVolatilePrimaryFS(FILESYSTEM& fs); bool isVolatilePrimaryFS() const { return _volatile_primary_fs; } + // Meshtext settings belong to the primary store, alongside the identity. + // An absent record is size zero; unreadable or volatile storage is failure. + bool loadMeshtextSettings(uint8_t image[40], size_t& size); + bool saveMeshtextSettings(const uint8_t image[40]); + bool eraseMeshtextSettings(); #endif void markPrimaryFSUnavailable(); void disableSecondaryFS(bool authority_unknown = true); diff --git a/examples/companion_radio/MeshtextCompanion.cpp b/examples/companion_radio/MeshtextCompanion.cpp index 03ba97b3e..771249a98 100644 --- a/examples/companion_radio/MeshtextCompanion.cpp +++ b/examples/companion_radio/MeshtextCompanion.cpp @@ -1,8 +1,12 @@ #include "MyMesh.h" #if MESH_ENABLE_MESHTEXT_CHAT +#if defined(NRF52_PLATFORM) +#include +#else #include #include +#endif #include #include @@ -98,15 +102,31 @@ void MyMesh::beginMeshtext() { refreshMeshtextIdentity(); // Independent random boot seed, then monotonic IDs. Never reuse a packet ID // within this boot (including failed admission) with the same CTR sender. +#if defined(NRF52_PLATFORM) + getRNG()->random(reinterpret_cast(&_mt_packet_id), sizeof(_mt_packet_id)); +#if defined(USE_CC310_HW_CRYPTO) + mesh::mixCC310Random(reinterpret_cast(&_mt_packet_id), sizeof(_mt_packet_id)); +#endif +#else _mt_packet_id = esp_random(); +#endif if (!_mt_packet_id) _mt_packet_id = 1; + uint8_t image[SettingsSize] = {}, expected[SettingsSize] = {}; +#if defined(NRF52_PLATFORM) + size_t size = 0; + if (!_store->loadMeshtextSettings(image, size)) { _mt_settings_valid = false; return; } +#else Preferences prefs; if (!prefs.begin("mesh-mt", false)) { _mt_settings_valid = false; return; } const size_t size = prefs.getBytesLength("settings"); +#endif if (size) { - uint8_t image[SettingsSize] = {}, expected[SettingsSize] = {}; +#if defined(NRF52_PLATFORM) + _mt_settings_valid = size == SettingsSize; +#else _mt_settings_valid = (size == SettingsSize || size == LegacySettingsSize) && prefs.getBytes("settings", image, size) == size; +#endif if (_mt_settings_valid) { const auto region = static_cast(image[4]); const bool legacy = size == LegacySettingsSize; @@ -138,7 +158,9 @@ void MyMesh::beginMeshtext() { } } } +#if !defined(NRF52_PLATFORM) prefs.end(); +#endif refreshMeshtextProfile(); } @@ -149,8 +171,12 @@ bool MyMesh::saveMeshtextSettings(mesh::mt::Region region, uint32_t slot, uint8_ if (node_override == UINT32_MAX) node_override = _mt_node_override; if (region > mesh::mt::Region::ANZ || slot > 1024 || hops > 7 || !validShortName(short_name) || !validNodeOverride(node_override) || hasForeignRadioFrame()) return false; - uint8_t image[SettingsSize], verify[SettingsSize]; + uint8_t image[SettingsSize]; makeSettings(image, region, slot, hops, short_name, node_override); +#if defined(NRF52_PLATFORM) + const bool ok = _store->saveMeshtextSettings(image); +#else + uint8_t verify[SettingsSize]; Preferences prefs; if (!prefs.begin("mesh-mt", false)) return false; // NVS commits this one blob atomically; never split region and slot into @@ -159,8 +185,9 @@ bool MyMesh::saveMeshtextSettings(mesh::mt::Region region, uint32_t slot, uint8_ && prefs.getBytes("settings", verify, sizeof(verify)) == sizeof(verify) && memcmp(image, verify, sizeof(image)) == 0; prefs.end(); +#endif if (!ok) { - // A failed read-back cannot prove whether NVS committed. Suspend the + // A failed read-back cannot prove whether storage committed. Suspend the // backend rather than silently use one network now and another at boot. _mt_settings_valid = false; _mt_profile_dirty = true; @@ -188,6 +215,9 @@ bool MyMesh::eraseMeshtextSettings() { _mt_seen.clear(); _radio_profiles.clearAutoSecondary(); _radio_profiles.clearSecondaryProtocol(); +#if defined(NRF52_PLATFORM) + const bool ok = _store->eraseMeshtextSettings(); +#else Preferences prefs; if (!prefs.begin("mesh-mt", false)) { _mt_status = "Meshtext settings erase failed"; @@ -195,6 +225,7 @@ bool MyMesh::eraseMeshtextSettings() { } const bool ok = !prefs.getBytesLength("settings") || prefs.remove("settings"); prefs.end(); +#endif if (ok) { _mt_region = mesh::mt::Region::Unset; _mt_slot = 0; _mt_hop_limit = 7; _mt_short_name[0] = 0; _mt_node_override = 0; _mt_node = _mt_default_node; diff --git a/scripts/check_firmware_capabilities.py b/scripts/check_firmware_capabilities.py index 27bff6211..4c10f3a6e 100644 --- a/scripts/check_firmware_capabilities.py +++ b/scripts/check_firmware_capabilities.py @@ -70,6 +70,10 @@ def parse_args() -> argparse.Namespace: parser.add_argument("--capability", action="append", default=[]) parser.add_argument("--reduction", action="append", default=[]) parser.add_argument("--require-ota", action="store_true") + parser.add_argument( + "--require-meshtext", action="store_true", + help="Require linked Meshtext encode/decode and Companion raw-frame transport implementations.", + ) parser.add_argument("--firmware-bin", type=Path) parser.add_argument("--partitions", type=Path) parser.add_argument("--dfu-package", type=Path) @@ -190,6 +194,7 @@ def main() -> int: checks = [] malformed = False third_party_notices = [] + codec_symbols, chat_symbols = [], [] try: codec_symbols, chat_symbols = meshtext_evidence(args.image) if codec_symbols: @@ -202,6 +207,10 @@ def main() -> int: except (OSError, ValueError) as error: print(f"capability check: cannot qualify linked Meshtext code: {error}", file=sys.stderr) malformed = True + if args.require_meshtext and not chat_symbols: + checks.append({"capability": MESHTEXT_CAPABILITY, + "evidence": "Meshtext encode/decode, Companion send/receive, and Dispatcher raw-frame queue implementations", + "present": False, "source": "linked ELF symbols"}) application = b"" if args.expect_application: try: @@ -237,6 +246,10 @@ def main() -> int: capabilities = stable_unique( args.capability + [check["capability"] for check in checks] ) + if not chat_symbols: + # Neither a requested capability nor CLI strings prove the optional + # codec and its complete Companion transport survived linking. + capabilities = [value for value in capabilities if value != MESHTEXT_CAPABILITY] missing = [check for check in checks if not check["present"]] ota_methods = stable_unique([ check["capability"].removeprefix("ota.update.") diff --git a/src/helpers/MeshtextChat.cpp b/src/helpers/MeshtextChat.cpp index 594336638..935c95a2f 100644 --- a/src/helpers/MeshtextChat.cpp +++ b/src/helpers/MeshtextChat.cpp @@ -265,6 +265,11 @@ bool modemConfig(Region region, Preset preset, RadioProfileParams& params, uint3 return true; } +// Keep the nRF52 codec entry points visible under LTO so release qualification +// can verify executable send/receive code instead of assuming a feature flag. +#if defined(NRF52_PLATFORM) && defined(__GNUC__) +__attribute__((noinline)) +#endif size_t encodeText(uint32_t from, uint32_t id, uint8_t hop_limit, const char* channel_name, const uint8_t* key, size_t key_len, const char* text, size_t text_len, uint8_t out[MaxFrameSize]) { @@ -317,6 +322,9 @@ size_t encodeNodeInfo(uint32_t from, uint32_t id, uint8_t hop_limit, const char* return finishFrame(from, id, hop_limit, channel_name, key, key_len, out, count); } +#if defined(NRF52_PLATFORM) && defined(__GNUC__) +__attribute__((noinline)) +#endif bool decodeText(const uint8_t* raw, size_t length, const char* channel_name, const uint8_t* key, size_t key_len, uint32_t local_node, DecodedText& decoded) { if (raw == nullptr || key == nullptr || (key_len != 16 && key_len != 32) diff --git a/src/helpers/MeshtextConfig.h b/src/helpers/MeshtextConfig.h index 3ef465f82..f885ff365 100644 --- a/src/helpers/MeshtextConfig.h +++ b/src/helpers/MeshtextConfig.h @@ -7,15 +7,19 @@ #include #endif -// Channel chat is an optional Companion transport, never a bridge. Start with -// the roomy ESP32-S3 Full/SX1262 family; other builds retain every existing -// feature and do not acquire the codec or its bounded raw-frame queue. A new +// Channel chat is an optional Companion transport, never a bridge. Qualified +// families are ESP32-S3 Full/SX1262 and RAK4631 Full; other builds retain their +// features and do not acquire the codec or its bounded raw-frame queue. A new // family must qualify its modem implementation, flash and runtime RAM first. #ifndef MESH_ENABLE_MESHTEXT_CHAT #if defined(ESP32_PLATFORM) && defined(CONFIG_IDF_TARGET_ESP32S3) \ && defined(COMPANION_RADIO_FULL) && defined(USE_SX1262) \ && !(defined(MESH_PRIMARY_ESPNOW) && MESH_PRIMARY_ESPNOW) #define MESH_ENABLE_MESHTEXT_CHAT 1 + #elif defined(NRF52_PLATFORM) && defined(RAK_4631) \ + && defined(COMPANION_RADIO_FULL) && defined(USE_SX1262) \ + && !(defined(MESH_PRIMARY_ESPNOW) && MESH_PRIMARY_ESPNOW) + #define MESH_ENABLE_MESHTEXT_CHAT 1 #else #define MESH_ENABLE_MESHTEXT_CHAT 0 #endif diff --git a/test/test_companion_meshtext_storage.py b/test/test_companion_meshtext_storage.py new file mode 100644 index 000000000..34909afa1 --- /dev/null +++ b/test/test_companion_meshtext_storage.py @@ -0,0 +1,213 @@ +"""Exercise actual nRF52 Meshtext store methods and atomic writes without hardware.""" + +from pathlib import Path +import os +import shutil +import subprocess +import tempfile +import unittest + +from cpp_source import body + +ROOT = Path(__file__).resolve().parents[1] + +HARNESS = r''' +#include +#include +#include +#include +#include +#include +#include +#include +#include +class FakeFilesystem; +class File { + FakeFilesystem* fs; + std::string path; + size_t position = 0; + bool opened = false; + bool writing = false; +public: + explicit File(FakeFilesystem& owner): fs(&owner) {} + bool open(const char*, uint8_t); + explicit operator bool() const { return opened; } + size_t write(const uint8_t*, size_t); + int read(void*, uint16_t); + uint32_t size() const; + bool isDirectory() const; + void close() { opened = false; } + void flush() {} +}; +class FakeFilesystem { +public: + std::unordered_map> files; + unsigned read_failures = 0; + bool stat_failure = false, rename_failure = false, false_rename = false; + bool remove_failure = false, short_read = false, directory = false; + size_t max_write = std::numeric_limits::max(); + unsigned operations = 0; + bool exists(const char* path) const { return files.count(path); } + bool remove(const char* path) { ++operations; return !remove_failure && files.erase(path); } + bool rename(const char* source, const char* target) { + ++operations; + if (rename_failure || !exists(source)) return false; + if (!false_rename) { files[target] = files[source]; files.erase(source); } + return true; + } +}; +enum { FILE_O_READ = 0, FILE_O_WRITE = 1 }; +bool File::open(const char* value, uint8_t mode) { + ++fs->operations; path = value; writing = mode == FILE_O_WRITE; + if (!writing && fs->read_failures) { --fs->read_failures; return false; } + if (!writing && !fs->exists(value)) return false; + position = writing ? fs->files[path].size() : 0; opened = true; return true; +} +size_t File::write(const uint8_t* data, size_t size) { + if (!opened || !writing) return 0; + size = std::min(size, fs->max_write); + auto& bytes = fs->files[path]; bytes.insert(bytes.end(), data, data + size); + position += size; return size; +} +int File::read(void* data, uint16_t size) { + if (!opened || writing) return -1; + const auto& bytes = fs->files[path]; + size_t count = std::min(size, bytes.size() - std::min(bytes.size(), position)); + if (fs->short_read && count) --count; + memcpy(data, bytes.data() + position, count); position += count; return count; +} +uint32_t File::size() const { return fs->exists(path.c_str()) ? fs->files[path].size() : 0; } +bool File::isDirectory() const { return fs->directory; } +#define NRF52_PLATFORM 1 +#define FILESYSTEM FakeFilesystem +#define ATOMIC_FILE_WRITER_IMPLEMENTATION +#include +class DataStore { +public: + FILESYSTEM* _fs; + bool _primary_storage_unavailable = false, _volatile_primary_fs = false; + explicit DataStore(FILESYSTEM& fs): _fs(&fs) {} + bool loadMeshtextSettings(uint8_t[40], size_t&); + bool saveMeshtextSettings(const uint8_t[40]); + bool eraseMeshtextSettings(); + File openRead(FILESYSTEM* fs, const char* path) { + File file(*fs); file.open(path, FILE_O_READ); return file; + } +}; +// Fault-injected metadata boundary; the methods themselves are production code. +static bool contactPathPresence(FILESYSTEM* fs, const char* path, bool& present, + uint32_t* size = nullptr) { + ++fs->operations; + if (fs->stat_failure) return false; + present = fs->exists(path); + if (present && size) *size = fs->files[path].size(); + return true; +} +__METHODS__ +static std::vector image(unsigned value) { return std::vector(40, value); } +int main(int argc, char** argv) { + assert(argc == 2); const std::string test = argv[1]; + FakeFilesystem fs; DataStore store(fs); uint8_t out[40]; memset(out, 77, sizeof(out)); + size_t size = 99; const auto old = image(11), replacement = image(22); + if (test == "roundtrip") { + assert(store.loadMeshtextSettings(out, size) && size == 0); + assert(store.saveMeshtextSettings(old.data())); + DataStore reboot(fs); assert(reboot.loadMeshtextSettings(out, size) && size == 40); + assert(!memcmp(out, old.data(), 40)); + assert(store.saveMeshtextSettings(replacement.data())); + assert(reboot.loadMeshtextSettings(out, size) && !memcmp(out, replacement.data(), 40)); + assert(fs.files.count("/mesh_mt") && !fs.files.count("/mesh_mt.tmp")); + } else if (test == "sizes") { + for (unsigned length : {0, 16, 39, 41, 400}) { + fs.files["/mesh_mt"] = std::vector(length, 33); + assert(!store.loadMeshtextSettings(out, size) && size == 0 && out[0] == 77); + } + fs.files["/mesh_mt"] = old; fs.directory = true; + assert(!store.loadMeshtextSettings(out, size) && out[0] == 77); + } else if (test == "reads") { + fs.files["/mesh_mt"] = old; fs.read_failures = 2; + assert(store.loadMeshtextSettings(out, size) && size == 40 && !memcmp(out, old.data(), 40)); + memset(out, 77, sizeof(out)); fs.read_failures = 3; + assert(!store.loadMeshtextSettings(out, size) && size == 0 && out[0] == 77); + fs.short_read = true; + assert(!store.loadMeshtextSettings(out, size) && size == 0 && out[0] == 77); + } else if (test == "writes") { + fs.files["/mesh_mt"] = old; fs.max_write = 10; + assert(!store.saveMeshtextSettings(replacement.data()) && fs.files["/mesh_mt"] == old); + fs.max_write = 40; fs.rename_failure = true; + assert(!store.saveMeshtextSettings(replacement.data()) && fs.files["/mesh_mt"] == old); + fs.rename_failure = false; fs.false_rename = true; + assert(!store.saveMeshtextSettings(replacement.data()) && fs.files["/mesh_mt"] == old); + assert(!fs.files.count("/mesh_mt.tmp")); + fs.false_rename = false; fs.read_failures = 3; + assert(!store.saveMeshtextSettings(replacement.data()) && fs.files["/mesh_mt"] == old); + } else if (test == "quarantine") { + for (unsigned reason = 0; reason != 3; ++reason) { + store._primary_storage_unavailable = reason == 0; + store._volatile_primary_fs = reason == 1; fs.stat_failure = reason == 2; + assert(!store.loadMeshtextSettings(out, size) && size == 0); + assert(!store.saveMeshtextSettings(replacement.data())); + assert(!store.eraseMeshtextSettings()); + } + assert(fs.files.empty()); + } else if (test == "staging_erase") { + fs.files["/mesh_mt.tmp"] = replacement; + assert(store.loadMeshtextSettings(out, size) && size == 0 && out[0] == 77); + fs.files["/mesh_mt"] = old; + assert(store.loadMeshtextSettings(out, size) && !memcmp(out, old.data(), 40)); + fs.remove_failure = true; + assert(!store.eraseMeshtextSettings() && fs.files["/mesh_mt"] == old); + assert(!store.saveMeshtextSettings(replacement.data()) && fs.files["/mesh_mt"] == old); + fs.remove_failure = false; assert(store.eraseMeshtextSettings() && fs.files.empty()); + assert(store.eraseMeshtextSettings()); + assert(store.loadMeshtextSettings(out, size) && size == 0); + } else return 2; +} +''' + + +class CompanionMeshtextStorageTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + compiler = os.environ.get("CXX") or shutil.which("g++") or shutil.which("clang++") + if not compiler: + raise unittest.SkipTest("a host C++ compiler is required") + cls.temp = tempfile.TemporaryDirectory(prefix="meshtext-storage-") + cls.addClassCleanup(cls.temp.cleanup) + source = (ROOT / "examples/companion_radio/DataStore.cpp").read_text(encoding="utf-8") + methods = "\n".join(body(source, "bool DataStore::" + operation + "(") for operation in ( + "loadMeshtextSettings", "saveMeshtextSettings", "eraseMeshtextSettings")) + directory = Path(cls.temp.name) + harness = directory / "storage.cpp" + harness.write_text(HARNESS.replace("__METHODS__", methods), encoding="ascii") + cls.binary = directory / "storage" + result = subprocess.run([compiler, "-std=c++17", "-Wall", "-Wextra", "-Werror", + "-I", str(ROOT / "src"), str(harness), "-o", str(cls.binary)], + capture_output=True, text=True, timeout=60) + if result.returncode: + raise AssertionError(result.stdout + result.stderr) + + def run_case(self, case): + subprocess.run([str(self.binary), case], check=True, timeout=10) + + def test_primary_record_survives_reboot_and_verified_replacement(self): + self.run_case("roundtrip") + + def test_truncated_oversized_and_directory_records_fail_closed(self): + self.run_case("sizes") + + def test_bounded_read_recovery_preserves_output_on_failure(self): + self.run_case("reads") + + def test_partial_write_and_failed_publish_preserve_committed_settings(self): + self.run_case("writes") + + def test_metadata_errors_and_volatile_fallback_cannot_claim_durability(self): + self.run_case("quarantine") + + def test_uncommitted_staging_is_ignored_and_explicit_erase_removes_both(self): + self.run_case("staging_erase") + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_meshtext_config.py b/test/test_meshtext_config.py new file mode 100644 index 000000000..97e6a4684 --- /dev/null +++ b/test/test_meshtext_config.py @@ -0,0 +1,90 @@ +"""Check effective feature guards without overriding Meshtext enablement. + +Preprocess the production header in both Arduino include orders. These cases +catch a working host codec being advertised for a firmware build that omitted +it, and catch different class layouts between Arduino and shared mesh sources. +No PlatformIO or hardware is used. +""" + +from pathlib import Path +import shutil +import subprocess +import tempfile +import unittest + + +ROOT = Path(__file__).resolve().parents[1] + + +class MeshtextConfigTest(unittest.TestCase): + def setUp(self): + self.compiler = shutil.which("g++") or shutil.which("clang++") + if self.compiler is None: + self.skipTest("a C++ preprocessor is required") + + def effective(self, defines, *, arduino_first=False, sdk_target=None): + with tempfile.TemporaryDirectory(prefix="meshcore-mt-config-") as folder: + work = Path(folder) + (work / "Arduino.h").write_text( + "#pragma once\n#ifndef ARDUINO\n#define ARDUINO 10819\n#endif\n", + encoding="ascii", + ) + (work / "sdkconfig.h").write_text( + "#pragma once\n" + ("#define " + sdk_target + " 1\n" if sdk_target else ""), + encoding="ascii", + ) + includes = ["helpers/MeshtextConfig.h", "Arduino.h"] + if arduino_first: + includes.reverse() + source = "".join("#include <" + name + ">\n" for name in includes) + source += "MESH_ENABLE_MESHTEXT_CHAT\n" + result = subprocess.run( + [self.compiler, "-E", "-P", "-x", "c++", "-", + "-I", str(work), "-I", str(ROOT / "src"), + *("-D" + define for define in defines)], + input=source, capture_output=True, text=True, + ) + self.assertEqual(result.returncode, 0, result.stderr) + return result.stdout.strip() + + def assert_orders(self, defines, expected, **kwargs): + for arduino_first in (False, True): + with self.subTest(defines=defines, arduino_first=arduino_first, **kwargs): + self.assertEqual(self.effective(defines, arduino_first=arduino_first, **kwargs), + str(expected)) + + def test_rak4631_full_companion_is_enabled_in_every_translation_unit(self): + self.assert_orders(["NRF52_PLATFORM", "RAK_4631", "COMPANION_RADIO_FULL", "USE_SX1262"], 1) + + def test_nrf52_default_stays_scoped_to_qualified_hardware_and_role(self): + enabled = ["NRF52_PLATFORM", "RAK_4631", "COMPANION_RADIO_FULL", "USE_SX1262"] + for missing in enabled: + self.assert_orders([item for item in enabled if item != missing], 0) + self.assert_orders(["NRF52_PLATFORM", "RAK_3401", "COMPANION_RADIO_FULL", "USE_SX1262"], 0) + self.assert_orders(enabled + ["MESH_PRIMARY_ESPNOW=1"], 0) + self.assert_orders(enabled + ["MESH_PRIMARY_ESPNOW=0"], 1) + + def test_esp32_s3_full_keeps_effective_sdk_target_enablement(self): + enabled = ["ESP32_PLATFORM", "ARDUINO=10819", "COMPANION_RADIO_FULL", "USE_SX1262"] + self.assert_orders(enabled, 1, sdk_target="CONFIG_IDF_TARGET_ESP32S3") + self.assert_orders(enabled + ["MESH_PRIMARY_ESPNOW=0"], 1, + sdk_target="CONFIG_IDF_TARGET_ESP32S3") + self.assert_orders(enabled + ["MESH_PRIMARY_ESPNOW=1"], 0, + sdk_target="CONFIG_IDF_TARGET_ESP32S3") + for target in (None, "CONFIG_IDF_TARGET_ESP32", "CONFIG_IDF_TARGET_ESP32S2"): + self.assert_orders(enabled, 0, sdk_target=target) + for missing in ("COMPANION_RADIO_FULL", "USE_SX1262"): + self.assert_orders([item for item in enabled if item != missing], 0, + sdk_target="CONFIG_IDF_TARGET_ESP32S3") + + def test_explicit_enable_and_disable_override_defaults(self): + self.assert_orders(["MESH_ENABLE_MESHTEXT_CHAT=1"], 1) + self.assert_orders(["NRF52_PLATFORM", "RAK_4631", "COMPANION_RADIO_FULL", + "USE_SX1262", "MESH_ENABLE_MESHTEXT_CHAT=0"], 0) + self.assert_orders(["ESP32_PLATFORM", "ARDUINO=10819", "COMPANION_RADIO_FULL", + "USE_SX1262", "MESH_ENABLE_MESHTEXT_CHAT=0"], 0, + sdk_target="CONFIG_IDF_TARGET_ESP32S3") + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_meshtext_license.py b/test/test_meshtext_license.py index c68dd290c..55a3c35c7 100644 --- a/test/test_meshtext_license.py +++ b/test/test_meshtext_license.py @@ -51,6 +51,71 @@ class MeshtextLicenseTest(unittest.TestCase): self.assertEqual(result.returncode, 0, result.stderr) return json.loads(output.read_text()) + def require_chat(self, build, *extra_args): + output = build / "required.capabilities.json" + result = subprocess.run([sys.executable, str(ROOT / "scripts/check_firmware_capabilities.py"), + "--image", str(build / "firmware.elf"), "--output", str(output), + "--target", "RAK_4631_companion_radio_full", "--platform", "NRF52_PLATFORM", + "--build-profile", "full", "--require-meshtext", *extra_args], + capture_output=True, text=True) + return result, json.loads(output.read_text()) + + def test_required_chat_accepts_complete_linked_codec_and_transport(self): + with tempfile.TemporaryDirectory() as folder: + root = Path(folder) + elf32(root / "firmware.elf") + result, manifest = self.require_chat(root) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertTrue(manifest["verified"]) + self.assertIn(license.CAPABILITY, manifest["capabilities"]) + check = next(item for item in manifest["verification"] + if item["capability"] == license.CAPABILITY) + self.assertTrue(check["present"]) + self.assertEqual(check["source"], "linked ELF symbols") + self.assertEqual(len(check["evidence"].split("; ")), len(SYMBOLS)) + + def test_required_chat_rejects_every_missing_or_nonrunning_implementation(self): + with tempfile.TemporaryDirectory() as folder: + root = Path(folder) + cases = [("unrelated code", {"symbols": ("_ZN4mesh9unrelatedEv",)}), + ("codec only", {"symbols": SYMBOLS[:2]}), + ("transport only", {"symbols": SYMBOLS[2:]}), + ("unallocated", {"flags": 4}), + ("data section", {"flags": 2})] + for symbol in SYMBOLS: + cases += [("missing " + symbol, {"symbols": tuple(name for name in SYMBOLS if name != symbol)}), + ("undefined " + symbol, {"undefined": (symbol,)}), + ("zero size " + symbol, {"zero": (symbol,)})] + for label, options in cases: + with self.subTest(label=label): + elf32(root / "firmware.elf", **options) + result, manifest = self.require_chat(root) + self.assertEqual(result.returncode, 1, result.stderr) + self.assertFalse(manifest["verified"]) + self.assertNotIn(license.CAPABILITY, manifest["capabilities"]) + self.assertIn("promised", result.stderr) + self.assertIn("linked ELF symbols", result.stderr) + + def test_required_chat_cannot_be_qualified_by_cli_strings_or_requested_capabilities(self): + with tempfile.TemporaryDirectory() as folder: + root = Path(folder) + marker = "get mt.status" + images = [b"CLI " + marker.encode() + b"\0" + b"\0".join(s.encode() for s in SYMBOLS)] + elf32(root / "firmware.elf", symbols=SYMBOLS[:2]) + images.append((root / "firmware.elf").read_bytes() + marker.encode() + + b"\0".join(s.encode() for s in SYMBOLS)) + for image in images: + with self.subTest(elf=image.startswith(b"\x7fELF")): + (root / "firmware.elf").write_bytes(image) + result, manifest = self.require_chat(root, "--capability", license.CAPABILITY, + "--expect", license.CAPABILITY + "=" + marker) + self.assertEqual(result.returncode, 1, result.stderr) + self.assertFalse(manifest["verified"]) + self.assertNotIn(license.CAPABILITY, manifest["capabilities"]) + marker_check = next(item for item in manifest["verification"] + if item["source"] == "linked image") + self.assertTrue(marker_check["present"]) + @unittest.skipUnless(sys.platform.startswith("linux"), "requires a Linux ELF compiler") def test_compiler_produced_symbols_qualify_chat_and_notice(self): compiler = shutil.which("g++") diff --git a/variants/rak4631/platformio.ini b/variants/rak4631/platformio.ini index 24d7bd8cc..aaee20df0 100644 --- a/variants/rak4631/platformio.ini +++ b/variants/rak4631/platformio.ini @@ -416,6 +416,12 @@ lib_deps = extends = env:RAK_4631_companion_radio_usb build_flags = ${env:RAK_4631_companion_radio_usb.build_flags} + ; Meshtext needs flash headroom without changing the ExtraFS boundary or + ; removing GPS. These omit unused automatic NMEA caches/debug strings and + ; the vendor bitmap that SSD1306Display clears before its first transfer. + -D SFE_UBLOX_DISABLE_AUTO_NMEA=1 + -D SFE_UBLOX_REDUCED_PROG_MEM=1 + -D SSD1306_NO_SPLASH=1 -D ETHERNET_ENABLED=1 -D ETHERNET_USE_RAK13800 -D ETHERNET_CLASS=RAK13800EthernetInterface