From 70f9c2466fe01ac3524878ab422aff94b9d95625 Mon Sep 17 00:00:00 2001 From: Ken Van Hoeylandt Date: Thu, 29 Oct 2026 22:22:23 +0100 Subject: [PATCH 01/11] Improve mesh settings --- Modules/mesh-meshcore-module/README.md | 11 +- .../private/mesh_meshcore/meshcore_crypto.h | 3 + .../private/mesh_meshcore/meshcore_protocol.h | 9 + .../source/meshcore_backend.cpp | 208 +++++++-- .../source/meshcore_crypto.cpp | 18 +- .../source/meshcore_protocol.cpp | 21 + .../tests/source/protocol_test.cpp | 46 ++ Modules/mesh-meshtastic-module/README.md | 4 +- .../source/meshtastic_backend.cpp | 80 +++- Modules/mesh-module/include/mesh/backend.h | 19 + Modules/mesh-module/include/mesh/mesh.h | 82 +++- .../mesh-module/private/mesh/mesh_settings.h | 18 +- Modules/mesh-module/source/mesh_command.cpp | 10 +- Modules/mesh-module/source/mesh_service.cpp | 250 +++++++++- Modules/mesh-module/source/mesh_settings.cpp | 100 +++- Modules/mesh-module/source/module.cpp | 7 + .../mesh-module/tests/source/service_test.cpp | 139 +++++- .../Source/app/meshsettings/MeshSettings.cpp | 441 ++++++++++++++---- .../include/tactility/drivers/radio.h | 1 + 19 files changed, 1248 insertions(+), 219 deletions(-) diff --git a/Modules/mesh-meshcore-module/README.md b/Modules/mesh-meshcore-module/README.md index d48f8c48a..a258c2880 100644 --- a/Modules/mesh-meshcore-module/README.md +++ b/Modules/mesh-meshcore-module/README.md @@ -14,10 +14,13 @@ Supported: This node is a chat node: it does not repeat packets for others. -Radio presets: "Default" is EU_868 (869.618 MHz, 62.5 kHz, SF8, CR 4/8) US_915 (910.525 MHz, 62.5 kHz, SF7, CR 4/5) or -AU_915 (915.8 MHz, 250 kHz, SF10, CR 4/5). -"EU NL" is the Dutch community setting for EU_868: 869.618 MHz, 62.5 kHz, SF7, CR 4/5. -Other regions report `ERROR_NOT_SUPPORTED`. +Radio presets: "Default" is EU_868 (869.618 MHz, 62.5 kHz, SF8, CR 4/8), US_915 (910.525 MHz, 62.5 kHz, SF7, CR 4/5) or +AU_915 (915.8 MHz, 250 kHz, SF10, CR 4/5), and reports `ERROR_NOT_SUPPORTED` in other regions. +The other presets are the settings that the MeshCore community suggests per country, e.g. "Netherlands" and "Australia (Narrow)". +They set the frequency themselves. When the current region doesn't allow it, `mesh_get_preset_region()` returns the region of the country, which the app selects before the preset. +Presets also set the network parameters: the path hash size (bytes per hop, 1 to 3) and the default region scope. "Netherlands" and +"Netherlands (Limburg)" use 2 bytes and the scope "nl". Flooded packets with a scope are sent as transport flood packets, with the code that +MeshCore computes from the scope's key (SHA-256 of "#" and the name). Both can be changed with `mesh_set_network_settings()`. A radio with `RADIO_MODULATION_ESPNOW` (no LoRa) uses MeshCore's ESP-NOW transport in every region, without presets: unencrypted broadcast frames on Wi-Fi channel 1 at 20 dBm. It reaches other Tactility and MeshCore ESP-NOW nodes in Wi-Fi range, not LoRa nodes. diff --git a/Modules/mesh-meshcore-module/private/mesh_meshcore/meshcore_crypto.h b/Modules/mesh-meshcore-module/private/mesh_meshcore/meshcore_crypto.h index f7dd4ee19..bdf9b5d42 100644 --- a/Modules/mesh-meshcore-module/private/mesh_meshcore/meshcore_crypto.h +++ b/Modules/mesh-meshcore-module/private/mesh_meshcore/meshcore_crypto.h @@ -12,6 +12,9 @@ constexpr size_t MESHCORE_CIPHER_KEY_SIZE = 16; constexpr size_t MESHCORE_CIPHER_MAC_SIZE = 2; constexpr size_t MESHCORE_CIPHER_BLOCK_SIZE = 16; +/** HMAC-SHA256 with a key of any length */ +void meshcore_hmac_sha256(const uint8_t* key, size_t key_length, const uint8_t* data, size_t length, uint8_t mac[32]); + void meshcore_sha256(uint8_t* hash, size_t hash_size, const uint8_t* data, size_t length); /** SHA-256 over two concatenated inputs, truncated to @a hash_size */ diff --git a/Modules/mesh-meshcore-module/private/mesh_meshcore/meshcore_protocol.h b/Modules/mesh-meshcore-module/private/mesh_meshcore/meshcore_protocol.h index 54b9ec20d..3f7a3e666 100644 --- a/Modules/mesh-meshcore-module/private/mesh_meshcore/meshcore_protocol.h +++ b/Modules/mesh-meshcore-module/private/mesh_meshcore/meshcore_protocol.h @@ -52,6 +52,15 @@ void meshcore_channel_from_key(const uint8_t key[16], MeshCoreChannel* channel); /** Key of a hashtag channel (e.g. "#test"): the first 16 bytes of the SHA-256 of its name */ void meshcore_hashtag_channel_key(const char* name, uint8_t key[16]); +/** Key of a region scope: the key of the hashtag channel with the same name, which gets a "#" in front when it has none */ +void meshcore_scope_key(const char* name, uint8_t key[16]); + +/** + * The code that identifies a packet as part of a region scope: the first 2 bytes of an HMAC-SHA256 over the payload type + * and the payload, keyed with the scope's key. 0x0000 and 0xFFFF are reserved, so they are moved to the nearest other code. + */ +uint16_t meshcore_transport_code(const uint8_t key[16], const MeshCorePacket& packet); + /** * Build the URI that MeshCore apps scan as a QR code to add a contact: * meshcore://contact/add?name=&public_key=&type= diff --git a/Modules/mesh-meshcore-module/source/meshcore_backend.cpp b/Modules/mesh-meshcore-module/source/meshcore_backend.cpp index 52b25c95a..aef7d0bbd 100644 --- a/Modules/mesh-meshcore-module/source/meshcore_backend.cpp +++ b/Modules/mesh-meshcore-module/source/meshcore_backend.cpp @@ -15,6 +15,7 @@ #include #include +#include #include #include #include @@ -198,7 +199,27 @@ Contact* findContact(Backend* backend, const uint8_t publicKey[MESHCORE_PUBLIC_K return nullptr; } -bool transmit(Backend* backend, const MeshCorePacket& packet, uint32_t seq) { +/** Flooded packets get the path hash size of the network, and a code that limits them to the region scope */ +MeshCorePacket applyNetwork(Backend* backend, const MeshCorePacket& original) { + MeshCorePacket packet = original; + if (packet.route != MeshCoreRoute::Flood) { + return packet; + } + MeshNetworkSettings network; + mesh_host_get_network(backend->host, &network); + packet.pathHashSize = static_cast(std::clamp(network.path_hash_size, 1, 3)); + if (network.region_scope[0] != '\0') { + uint8_t key[16]; + meshcore_scope_key(network.region_scope, key); + packet.route = MeshCoreRoute::TransportFlood; + packet.transportCodes[0] = meshcore_transport_code(key, packet); + packet.transportCodes[1] = 0; + } + return packet; +} + +bool transmit(Backend* backend, const MeshCorePacket& original, uint32_t seq) { + const MeshCorePacket packet = applyNetwork(backend, original); uint8_t frame[FRAME_SIZE]; const size_t length = meshcore_packet_serialize(packet, frame, sizeof(frame)); if (length == 0) { @@ -344,9 +365,109 @@ void destroy(void* context) { } constexpr size_t PRESET_DEFAULT = 0; -constexpr size_t PRESET_EU_NL = 1; -const char* const presets[] = { "Default", "EU NL", nullptr }; +/** The default preset follows the region of the radio */ +struct DefaultProfile { + RadioRegion region; + int32_t frequency; + int32_t bandwidth; + int32_t spreadingFactor; + int32_t codingRate; +}; + +constexpr std::array DEFAULT_PROFILES = { + DefaultProfile { .region = RADIO_REGION_EU_868, .frequency = 869618000, .bandwidth = 62500, .spreadingFactor = 8, .codingRate = 8 }, + DefaultProfile { .region = RADIO_REGION_US_915, .frequency = 910525000, .bandwidth = 62500, .spreadingFactor = 7, .codingRate = 5 }, + DefaultProfile { .region = RADIO_REGION_AU_915, .frequency = 915800000, .bandwidth = 250000, .spreadingFactor = 10, .codingRate = 5 }, +}; + +/** The community suggested settings per country. Preset index 0 is the default, so these start at index 1. */ +struct Profile { + const char* name; + /** The region that is selected for the preset when the current region doesn't allow its frequency */ + RadioRegion region; + int32_t frequency; + int32_t bandwidth; + int32_t spreadingFactor; + int32_t codingRate; + /** Bytes per hop in the path */ + int32_t pathHashSize; + /** Empty for no scope */ + const char* regionScope; +}; + +constexpr std::array PROFILES = { + Profile { .name = "Australia", .region = RADIO_REGION_AU_915, .frequency = 915800000, .bandwidth = 250000, .spreadingFactor = 10, .codingRate = 5, .pathHashSize = 1, .regionScope = "" }, + Profile { .name = "Australia (Narrow)", .region = RADIO_REGION_AU_915, .frequency = 916575000, .bandwidth = 62500, .spreadingFactor = 7, .codingRate = 7, .pathHashSize = 1, .regionScope = "" }, + Profile { .name = "Australia (Mid)", .region = RADIO_REGION_AU_915, .frequency = 915075000, .bandwidth = 125000, .spreadingFactor = 9, .codingRate = 5, .pathHashSize = 1, .regionScope = "" }, + Profile { .name = "Australia: SA, WA", .region = RADIO_REGION_AU_915, .frequency = 923125000, .bandwidth = 62500, .spreadingFactor = 8, .codingRate = 8, .pathHashSize = 1, .regionScope = "" }, + Profile { .name = "Australia: QLD", .region = RADIO_REGION_AU_915, .frequency = 923125000, .bandwidth = 62500, .spreadingFactor = 8, .codingRate = 5, .pathHashSize = 1, .regionScope = "" }, + Profile { .name = "Brazil", .region = RADIO_REGION_AU_915, .frequency = 923125000, .bandwidth = 62500, .spreadingFactor = 8, .codingRate = 8, .pathHashSize = 1, .regionScope = "" }, + Profile { .name = "Canada", .region = RADIO_REGION_US_915, .frequency = 910525000, .bandwidth = 62500, .spreadingFactor = 7, .codingRate = 5, .pathHashSize = 3, .regionScope = "" }, + Profile { .name = "Costa Rica", .region = RADIO_REGION_US_915, .frequency = 910525000, .bandwidth = 125000, .spreadingFactor = 11, .codingRate = 5, .pathHashSize = 1, .regionScope = "" }, + Profile { .name = "EU/UK (Narrow)", .region = RADIO_REGION_EU_868, .frequency = 869618000, .bandwidth = 62500, .spreadingFactor = 8, .codingRate = 8, .pathHashSize = 1, .regionScope = "" }, + Profile { .name = "EU/UK (Deprecated)", .region = RADIO_REGION_EU_868, .frequency = 869525000, .bandwidth = 250000, .spreadingFactor = 11, .codingRate = 5, .pathHashSize = 1, .regionScope = "" }, + Profile { .name = "Czech Republic (Narrow)", .region = RADIO_REGION_EU_868, .frequency = 869432000, .bandwidth = 62500, .spreadingFactor = 7, .codingRate = 5, .pathHashSize = 2, .regionScope = "" }, + Profile { .name = "EU 433MHz (Long Range)", .region = RADIO_REGION_EU_433, .frequency = 433650000, .bandwidth = 250000, .spreadingFactor = 11, .codingRate = 5, .pathHashSize = 1, .regionScope = "" }, + Profile { .name = "EU 433MHz (Narrow)", .region = RADIO_REGION_EU_433, .frequency = 433650000, .bandwidth = 62500, .spreadingFactor = 8, .codingRate = 8, .pathHashSize = 1, .regionScope = "" }, + Profile { .name = "Hungary", .region = RADIO_REGION_EU_868, .frequency = 869618000, .bandwidth = 62500, .spreadingFactor = 7, .codingRate = 5, .pathHashSize = 2, .regionScope = "" }, + Profile { .name = "Netherlands", .region = RADIO_REGION_EU_868, .frequency = 869618000, .bandwidth = 62500, .spreadingFactor = 7, .codingRate = 5, .pathHashSize = 2, .regionScope = "nl" }, + Profile { .name = "Netherlands (Limburg)", .region = RADIO_REGION_EU_868, .frequency = 869618000, .bandwidth = 62500, .spreadingFactor = 8, .codingRate = 8, .pathHashSize = 2, .regionScope = "nl" }, + Profile { .name = "New Zealand (Narrow)", .region = RADIO_REGION_AU_915, .frequency = 917375000, .bandwidth = 62500, .spreadingFactor = 7, .codingRate = 5, .pathHashSize = 2, .regionScope = "" }, + Profile { .name = "New Zealand (Gisborne)", .region = RADIO_REGION_AU_915, .frequency = 917375000, .bandwidth = 250000, .spreadingFactor = 11, .codingRate = 5, .pathHashSize = 1, .regionScope = "" }, + Profile { .name = "Portugal 433", .region = RADIO_REGION_EU_433, .frequency = 433375000, .bandwidth = 62500, .spreadingFactor = 9, .codingRate = 6, .pathHashSize = 1, .regionScope = "" }, + Profile { .name = "Portugal 868", .region = RADIO_REGION_EU_868, .frequency = 869618000, .bandwidth = 62500, .spreadingFactor = 7, .codingRate = 6, .pathHashSize = 1, .regionScope = "" }, + Profile { .name = "Slovakia", .region = RADIO_REGION_EU_868, .frequency = 869618000, .bandwidth = 62500, .spreadingFactor = 7, .codingRate = 5, .pathHashSize = 2, .regionScope = "" }, + Profile { .name = "Switzerland", .region = RADIO_REGION_EU_868, .frequency = 869618000, .bandwidth = 62500, .spreadingFactor = 8, .codingRate = 8, .pathHashSize = 1, .regionScope = "" }, + Profile { .name = "USA", .region = RADIO_REGION_US_915, .frequency = 910525000, .bandwidth = 62500, .spreadingFactor = 7, .codingRate = 5, .pathHashSize = 1, .regionScope = "" }, + Profile { .name = "USA - Southern California", .region = RADIO_REGION_US_915, .frequency = 927875000, .bandwidth = 62500, .spreadingFactor = 7, .codingRate = 5, .pathHashSize = 3, .regionScope = "" }, + Profile { .name = "Vietnam (Narrow)", .region = RADIO_REGION_US_915, .frequency = 920250000, .bandwidth = 62500, .spreadingFactor = 8, .codingRate = 5, .pathHashSize = 1, .regionScope = "" }, + Profile { .name = "Vietnam (Deprecated)", .region = RADIO_REGION_US_915, .frequency = 920250000, .bandwidth = 250000, .spreadingFactor = 11, .codingRate = 5, .pathHashSize = 1, .regionScope = "" }, +}; + +/** "Default", the names of PROFILES, and a terminating nullptr */ +constexpr auto presets = [] { + std::array names {}; + names[0] = "Default"; + for (size_t i = 0; i < PROFILES.size(); i++) { + names[i + 1] = PROFILES[i].name; + } + return names; +}(); + +/** Whether the whole channel of width @a bandwidth around @a frequency lies in one band of @a region */ +bool fitsRegion(int32_t frequency, int32_t bandwidth, RadioRegion region) { + const RadioRegionBand* bands = nullptr; + const size_t count = radio_get_region_bands(region, &bands); + return std::any_of(bands, bands + count, [=](const RadioRegionBand& band) { + return frequency - bandwidth / 2 >= band.frequency_min && frequency + bandwidth / 2 <= band.frequency_max; + }); +} + +void getPresetNetwork(size_t preset, MeshNetworkSettings* network) { + *network = { .path_hash_size = 1 }; + if (preset != PRESET_DEFAULT && preset <= PROFILES.size()) { + const Profile& profile = PROFILES[preset - 1]; + network->path_hash_size = profile.pathHashSize; + strncpy(network->region_scope, profile.regionScope, MESH_NAME_MAX_LENGTH); + } +} + +error_t getPresetRegion(size_t preset, RadioRegion current, RadioRegion* region) { + *region = current; + if (preset == PRESET_DEFAULT || preset > PROFILES.size()) { + return ERROR_NONE; + } + const Profile& profile = PROFILES[preset - 1]; + if (fitsRegion(profile.frequency, profile.bandwidth, current)) { + return ERROR_NONE; + } + if (fitsRegion(profile.frequency, profile.bandwidth, profile.region)) { + *region = profile.region; + return ERROR_NONE; + } + return ERROR_NOT_SUPPORTED; +} error_t getRadioProfile(void*, RadioRegion region, RadioModulation modulation, size_t preset, MeshRadioProfile* profile) { // MeshCore's ESP-NOW transport broadcasts unencrypted frames on the Wi-Fi channel that the radio is on, at 20 dBm. @@ -370,53 +491,40 @@ error_t getRadioProfile(void*, RadioRegion region, RadioModulation modulation, s return ERROR_NOT_SUPPORTED; } - // MeshCore's recommended presets. Sync word 0x12 is LoRa's private network sync word. - switch (region) { - case RADIO_REGION_EU_868: - *profile = { - .modulation = RADIO_MODULATION_LORA, - .frequency = 869618000, - .bandwidth = 62500, - .spreading_factor = (preset == PRESET_EU_NL) ? 7 : 8, - .coding_rate = (preset == PRESET_EU_NL) ? 5 : 8, - .sync_word = 0x12, - .preamble_length = 16, - .power = 22, - }; - return ERROR_NONE; - case RADIO_REGION_US_915: - if (preset != PRESET_DEFAULT) { - return ERROR_NOT_SUPPORTED; - } - *profile = { - .modulation = RADIO_MODULATION_LORA, - .frequency = 910525000, - .bandwidth = 62500, - .spreading_factor = 7, - .coding_rate = 5, - .sync_word = 0x12, - .preamble_length = 16, - .power = 22, - }; - return ERROR_NONE; - case RADIO_REGION_AU_915: - if (preset != PRESET_DEFAULT) { - return ERROR_NOT_SUPPORTED; - } - *profile = { - .modulation = RADIO_MODULATION_LORA, - .frequency = 915800000, - .bandwidth = 250000, - .spreading_factor = 10, - .coding_rate = 5, - .sync_word = 0x12, - .preamble_length = 16, - .power = 22, - }; - return ERROR_NONE; - default: + int32_t frequency = 0; + int32_t bandwidth = 0; + int32_t spreadingFactor = 0; + int32_t codingRate = 0; + if (preset == PRESET_DEFAULT) { + const auto* match = std::find_if(DEFAULT_PROFILES.begin(), DEFAULT_PROFILES.end(), [region](const DefaultProfile& candidate) { return candidate.region == region; }); + if (match == DEFAULT_PROFILES.end()) { return ERROR_NOT_SUPPORTED; + } + frequency = match->frequency; + bandwidth = match->bandwidth; + spreadingFactor = match->spreadingFactor; + codingRate = match->codingRate; + } else if (preset <= PROFILES.size()) { + const Profile& match = PROFILES[preset - 1]; + frequency = match.frequency; + bandwidth = match.bandwidth; + spreadingFactor = match.spreadingFactor; + codingRate = match.codingRate; + } else { + return ERROR_NOT_SUPPORTED; } + // Sync word 0x12 is LoRa's private network sync word + *profile = { + .modulation = RADIO_MODULATION_LORA, + .frequency = frequency, + .bandwidth = bandwidth, + .spreading_factor = spreadingFactor, + .coding_rate = codingRate, + .sync_word = 0x12, + .preamble_length = 16, + .power = 22, + }; + return ERROR_NONE; } void onStart(void* context) { @@ -577,8 +685,10 @@ const MeshBackendApi meshcore_backend_api = { .id = "meshcore", .create = create, .destroy = destroy, - .presets = presets, + .presets = presets.data(), .get_radio_profile = getRadioProfile, + .get_preset_region = getPresetRegion, + .get_preset_network = getPresetNetwork, .on_start = onStart, .on_receive = onReceive, .on_tick = onTick, diff --git a/Modules/mesh-meshcore-module/source/meshcore_crypto.cpp b/Modules/mesh-meshcore-module/source/meshcore_crypto.cpp index bf600c0fc..cb75376c4 100644 --- a/Modules/mesh-meshcore-module/source/meshcore_crypto.cpp +++ b/Modules/mesh-meshcore-module/source/meshcore_crypto.cpp @@ -27,13 +27,7 @@ psa_key_id_t importKey(psa_key_type_t type, psa_algorithm_t algorithm, psa_key_u } void hmac(const uint8_t secret[MESHCORE_SECRET_SIZE], const uint8_t* data, size_t length, uint8_t mac[32]) { - const auto algorithm = PSA_ALG_HMAC(PSA_ALG_SHA_256); - const psa_key_id_t key = importKey(PSA_KEY_TYPE_HMAC, algorithm, PSA_KEY_USAGE_SIGN_MESSAGE, secret, MESHCORE_SECRET_SIZE); - size_t macLength = 0; - if (key == PSA_KEY_ID_NULL || psa_mac_compute(key, algorithm, data, length, mac, 32, &macLength) != PSA_SUCCESS) { - memset(mac, 0, 32); - } - psa_destroy_key(key); + meshcore_hmac_sha256(secret, MESHCORE_SECRET_SIZE, data, length, mac); } /** AES-128-ECB over whole blocks */ @@ -58,6 +52,16 @@ bool aesEcb(const uint8_t secret[MESHCORE_SECRET_SIZE], bool encrypt, const uint } // namespace +void meshcore_hmac_sha256(const uint8_t* secret, size_t secret_length, const uint8_t* data, size_t length, uint8_t mac[32]) { + const auto algorithm = PSA_ALG_HMAC(PSA_ALG_SHA_256); + const psa_key_id_t key = importKey(PSA_KEY_TYPE_HMAC, algorithm, PSA_KEY_USAGE_SIGN_MESSAGE, secret, secret_length); + size_t macLength = 0; + if (key == PSA_KEY_ID_NULL || psa_mac_compute(key, algorithm, data, length, mac, 32, &macLength) != PSA_SUCCESS) { + memset(mac, 0, 32); + } + psa_destroy_key(key); +} + void meshcore_sha256(uint8_t* hash, size_t hash_size, const uint8_t* data, size_t length) { meshcore_sha256_pair(hash, hash_size, data, length, nullptr, 0); } diff --git a/Modules/mesh-meshcore-module/source/meshcore_protocol.cpp b/Modules/mesh-meshcore-module/source/meshcore_protocol.cpp index d980f7428..b917283ce 100644 --- a/Modules/mesh-meshcore-module/source/meshcore_protocol.cpp +++ b/Modules/mesh-meshcore-module/source/meshcore_protocol.cpp @@ -3,6 +3,8 @@ #include +#include + #include #include @@ -60,6 +62,25 @@ void meshcore_hashtag_channel_key(const char* name, uint8_t key[16]) { meshcore_sha256(key, 16, reinterpret_cast(name), strlen(name)); } +void meshcore_scope_key(const char* name, uint8_t key[16]) { + char hashtag[MESH_NAME_MAX_LENGTH + 2]; + snprintf(hashtag, sizeof(hashtag), "%s%s", name[0] == '#' ? "" : "#", name); + meshcore_hashtag_channel_key(hashtag, key); +} + +uint16_t meshcore_transport_code(const uint8_t key[16], const MeshCorePacket& packet) { + uint8_t message[1 + MESHCORE_MAX_PAYLOAD_SIZE]; + message[0] = static_cast(packet.payloadType); + memcpy(message + 1, packet.payload, packet.payloadLength); + uint8_t mac[32]; + meshcore_hmac_sha256(key, 16, message, 1 + packet.payloadLength, mac); + const uint16_t code = static_cast(mac[0] | (mac[1] << 8)); + if (code == 0x0000) { + return 0x0001; + } + return (code == 0xFFFF) ? 0xFFFE : code; +} + void meshcore_shared_secret(const MeshCoreIdentity& identity, const uint8_t peerPublicKey[MESHCORE_PUBLIC_KEY_SIZE], uint8_t secret[MESHCORE_SECRET_SIZE]) { ed25519_key_exchange(secret, peerPublicKey, identity.privateKey); } diff --git a/Modules/mesh-meshcore-module/tests/source/protocol_test.cpp b/Modules/mesh-meshcore-module/tests/source/protocol_test.cpp index bb4497f01..ab10894b9 100644 --- a/Modules/mesh-meshcore-module/tests/source/protocol_test.cpp +++ b/Modules/mesh-meshcore-module/tests/source/protocol_test.cpp @@ -1,5 +1,6 @@ #include "doctest.h" +#include #include #include @@ -159,3 +160,48 @@ TEST_CASE("MeshCore contact URIs match the format of the MeshCore app") { // Too small for the URI CHECK_FALSE(meshcore_build_contact_uri("Example Contact", key, 1, uri, 60)); } + +TEST_CASE("a MeshCore preset needs the region that allows its frequency") { + const auto presetIndex = [](const char* name) { + for (size_t i = 0; meshcore_backend_api.presets[i] != nullptr; i++) { + if (strcmp(meshcore_backend_api.presets[i], name) == 0) { + return i; + } + } + FAIL("unknown preset"); + return size_t(0); + }; + RadioRegion region = RADIO_REGION_UNSET; + + CHECK_EQ(meshcore_backend_api.get_preset_region(presetIndex("Netherlands"), RADIO_REGION_US_915, ®ion), ERROR_NONE); + CHECK_EQ(region, RADIO_REGION_EU_868); + CHECK_EQ(meshcore_backend_api.get_preset_region(presetIndex("Australia"), RADIO_REGION_EU_868, ®ion), ERROR_NONE); + CHECK_EQ(region, RADIO_REGION_AU_915); + CHECK_EQ(meshcore_backend_api.get_preset_region(presetIndex("USA"), RADIO_REGION_US_915, ®ion), ERROR_NONE); + CHECK_EQ(region, RADIO_REGION_US_915); + CHECK_EQ(meshcore_backend_api.get_preset_region(presetIndex("EU 433MHz (Narrow)"), RADIO_REGION_EU_868, ®ion), ERROR_NONE); + CHECK_EQ(region, RADIO_REGION_EU_433); + CHECK_EQ(meshcore_backend_api.get_preset_region(presetIndex("Default"), RADIO_REGION_JP_920, ®ion), ERROR_NONE); + CHECK_EQ(region, RADIO_REGION_JP_920); +} + +TEST_CASE("a region scope limits a packet with a code that other MeshCore nodes compute the same way") { + // The key and codes were computed with an independent HMAC-SHA256 + uint8_t key[16]; + meshcore_scope_key("nl", key); + const uint8_t expected[16] = { 0xa8, 0x28, 0x9f, 0x73, 0x66, 0x5c, 0x28, 0x05, 0x7d, 0x4f, 0x38, 0x15, 0xa6, 0x00, 0x29, 0x21 }; + CHECK_EQ(memcmp(key, expected, sizeof(key)), 0); + uint8_t hashtagKey[16]; + meshcore_scope_key("#nl", hashtagKey); + CHECK_EQ(memcmp(key, hashtagKey, sizeof(key)), 0); + + MeshCorePacket packet; + packet.payloadType = MeshCorePayload::TextMessage; + packet.payloadLength = 5; + memcpy(packet.payload, "hello", 5); + CHECK_EQ(meshcore_transport_code(key, packet), 0xEEC8); + + packet.payloadType = MeshCorePayload::Advert; + packet.payloadLength = 0; + CHECK_EQ(meshcore_transport_code(key, packet), 0xF329); +} diff --git a/Modules/mesh-meshtastic-module/README.md b/Modules/mesh-meshtastic-module/README.md index 400a3ecc2..4d82b532f 100644 --- a/Modules/mesh-meshtastic-module/README.md +++ b/Modules/mesh-meshtastic-module/README.md @@ -5,7 +5,7 @@ Supported: -- The LongFast preset (SF11, 250 kHz, CR 4/5, sync word 0x2B), in the frequency slot that the primary channel's +- The LongFast, LongModerate, LongSlow, MediumFast, MediumSlow, ShortFast, ShortSlow and ShortTurbo modem presets (sync word 0x2B), in the frequency slot that the primary channel's name selects within the region's band, like Meshtastic does. - Channels: the primary channel is preconfigured as "LongFast" with the default key. More channels can be added with `mesh_set_channel()`, with a 1-byte PSK index, or a 16 or 32-byte key. @@ -16,5 +16,5 @@ Supported: The node number is random and stored in the backend's data directory. The node doesn't rebroadcast packets for others (like the CLIENT_MUTE role). -Not implemented: public key (PKI) encryption of direct messages, other modem presets, positions and telemetry. +Not implemented: public key (PKI) encryption of direct messages, positions and telemetry. Direct messages are encrypted with the primary channel's key, which current firmware still accepts. diff --git a/Modules/mesh-meshtastic-module/source/meshtastic_backend.cpp b/Modules/mesh-meshtastic-module/source/meshtastic_backend.cpp index b10149105..505144bf2 100644 --- a/Modules/mesh-meshtastic-module/source/meshtastic_backend.cpp +++ b/Modules/mesh-meshtastic-module/source/meshtastic_backend.cpp @@ -15,6 +15,7 @@ #include #include +#include #include #include #include @@ -35,9 +36,36 @@ constexpr size_t MAX_KNOWN_NAMES = 128; constexpr uint64_t NODE_INFO_INTERVAL_MS = 3ULL * 3600 * 1000; constexpr uint64_t ACK_TIMEOUT_MS = 60 * 1000; constexpr auto* DEFAULT_CHANNEL_NAME = "LongFast"; -constexpr int32_t LONG_FAST_BANDWIDTH = 250000; constexpr int32_t MAX_RADIO_POWER = 22; +/** Meshtastic's modem presets. Index 0 is the default. */ +struct ModemPreset { + const char* name; + int32_t bandwidth; + int32_t spreadingFactor; + int32_t codingRate; +}; + +constexpr std::array MODEM_PRESETS = { + ModemPreset { .name = "LongFast", .bandwidth = 250000, .spreadingFactor = 11, .codingRate = 5 }, + ModemPreset { .name = "LongModerate", .bandwidth = 125000, .spreadingFactor = 11, .codingRate = 8 }, + ModemPreset { .name = "LongSlow", .bandwidth = 125000, .spreadingFactor = 12, .codingRate = 8 }, + ModemPreset { .name = "MediumFast", .bandwidth = 250000, .spreadingFactor = 9, .codingRate = 5 }, + ModemPreset { .name = "MediumSlow", .bandwidth = 250000, .spreadingFactor = 10, .codingRate = 5 }, + ModemPreset { .name = "ShortFast", .bandwidth = 250000, .spreadingFactor = 7, .codingRate = 5 }, + ModemPreset { .name = "ShortSlow", .bandwidth = 250000, .spreadingFactor = 8, .codingRate = 5 }, + ModemPreset { .name = "ShortTurbo", .bandwidth = 500000, .spreadingFactor = 7, .codingRate = 5 }, +}; + +/** The names of MODEM_PRESETS and a terminating nullptr */ +constexpr auto PRESET_NAMES = [] { + std::array names {}; + for (size_t i = 0; i < MODEM_PRESETS.size(); i++) { + names[i] = MODEM_PRESETS[i].name; + } + return names; +}(); + /** Meshtastic's region table, used to place the channel's frequency slot */ struct RegionInfo { RadioRegion region; @@ -341,24 +369,54 @@ void destroy(void* context) { tt::deleteObject(static_cast(context)); } -error_t getRadioProfile(void* context, RadioRegion region, RadioModulation modulation, size_t, MeshRadioProfile* profile) { +/** The primary channel of a node that never customised it is named after the preset, which keeps it compatible with other nodes on that preset */ +void renameDefaultPrimaryChannel(Backend* backend, const char* presetName) { + Channel* primary = nullptr; + for (auto& channel : backend->channels) { + if (channel.index == 0) { + primary = &channel; + } + } + if (primary == nullptr || strcmp(primary->name, presetName) == 0) { + return; + } + const uint8_t defaultPsk = 1; + MeshtasticChannelKey defaultKey {}; + meshtastic_expand_psk(&defaultPsk, 1, &defaultKey); + const bool isDefaultKey = primary->key.length == defaultKey.length && memcmp(primary->key.bytes, defaultKey.bytes, defaultKey.length) == 0; + const bool isPresetName = std::any_of(MODEM_PRESETS.begin(), MODEM_PRESETS.end(), [primary](const ModemPreset& preset) { return strcmp(preset.name, primary->name) == 0; }); + if (isDefaultKey && isPresetName) { + addChannel(backend, 0, presetName, defaultKey); + saveChannels(backend); + } +} + +error_t getRadioProfile(void* context, RadioRegion region, RadioModulation modulation, size_t presetIndex, MeshRadioProfile* profile) { auto* backend = static_cast(context); - if (modulation != RADIO_MODULATION_LORA) { + if (modulation != RADIO_MODULATION_LORA || presetIndex >= MODEM_PRESETS.size()) { return ERROR_NOT_SUPPORTED; } + const ModemPreset& preset = MODEM_PRESETS[presetIndex]; for (const auto& info : REGIONS) { if (info.region != region) { continue; } - // The LongFast preset, in the slot that the primary channel's name selects - const Channel* primary = findChannel(backend, 0); - const char* slotName = (primary != nullptr && primary->name[0] != '\0') ? primary->name : DEFAULT_CHANNEL_NAME; + if (preset.bandwidth > info.frequencyEnd - info.frequencyStart) { + return ERROR_NOT_SUPPORTED; + } + // Without a backend there are no channels, and the preset's own name selects the slot + if (backend != nullptr) { + renameDefaultPrimaryChannel(backend, preset.name); + } + // The slot that the primary channel's name selects + const Channel* primary = (backend != nullptr) ? findChannel(backend, 0) : nullptr; + const char* slotName = (primary != nullptr && primary->name[0] != '\0') ? primary->name : preset.name; *profile = { .modulation = RADIO_MODULATION_LORA, - .frequency = meshtastic_channel_frequency(slotName, info.frequencyStart, info.frequencyEnd, LONG_FAST_BANDWIDTH), - .bandwidth = LONG_FAST_BANDWIDTH, - .spreading_factor = 11, - .coding_rate = 5, + .frequency = meshtastic_channel_frequency(slotName, info.frequencyStart, info.frequencyEnd, preset.bandwidth), + .bandwidth = preset.bandwidth, + .spreading_factor = preset.spreadingFactor, + .coding_rate = preset.codingRate, .sync_word = 0x2B, .preamble_length = 16, .power = std::min(info.powerLimit, MAX_RADIO_POWER), @@ -518,7 +576,7 @@ const MeshBackendApi meshtastic_backend_api = { .id = "meshtastic", .create = create, .destroy = destroy, - .presets = nullptr, + .presets = PRESET_NAMES.data(), .get_radio_profile = getRadioProfile, .on_start = onStart, .on_receive = onReceive, diff --git a/Modules/mesh-module/include/mesh/backend.h b/Modules/mesh-module/include/mesh/backend.h index dbaa38f00..05d698b48 100644 --- a/Modules/mesh-module/include/mesh/backend.h +++ b/Modules/mesh-module/include/mesh/backend.h @@ -48,11 +48,27 @@ struct MeshBackendApi { /** * @param[in] modulation RADIO_MODULATION_LORA, or RADIO_MODULATION_ESPNOW for a radio that can't do LoRa + * @param[in] backend the backend state, or NULL to only get the parameters of @a preset for a backend that isn't active * @param[in] preset index in presets, 0 when the backend has none. Presets are for LoRa. * @retval ERROR_NOT_SUPPORTED when the backend can't operate in @a region with @a modulation and @a preset */ error_t (*get_radio_profile)(void* backend, enum RadioRegion region, enum RadioModulation modulation, size_t preset, struct MeshRadioProfile* profile); + /** + * Optional. The region that @a preset needs, for presets that set their own frequency. + * Doesn't use the host or the backend state, and can be called from any thread. + * @param[in] current the region of the radio + * @param[out] region @a current when the preset fits it or doesn't depend on the region + * @retval ERROR_NOT_SUPPORTED when no region allows the preset + */ + error_t (*get_preset_region)(size_t preset, enum RadioRegion current, enum RadioRegion* region); + + /** + * Optional. The network parameters of a preset. Doesn't use the host or the backend state, and can be called from any thread. + * @param[out] network the values of the preset, with an empty scope for none + */ + void (*get_preset_network)(size_t preset, struct MeshNetworkSettings* network); + /** Called once the radio is on with the backend's profile */ void (*on_start)(void* backend); @@ -143,6 +159,9 @@ void mesh_host_advert_heard(struct MeshHost* host, const struct MeshAddress* add /** Get a path in the backend's own data directory, which exists after this call */ error_t mesh_host_get_data_path(struct MeshHost* host, const char* child_path, char* out_path, size_t out_path_size); +/** Get the network parameters that the backend runs with: the preset's values with the settings applied. The scope is empty for none. */ +void mesh_host_get_network(struct MeshHost* host, struct MeshNetworkSettings* network); + /** Get the name this node announces */ void mesh_host_get_node_name(struct MeshHost* host, char* name, size_t name_size); diff --git a/Modules/mesh-module/include/mesh/mesh.h b/Modules/mesh-module/include/mesh/mesh.h index d6933409f..6cb9f4284 100644 --- a/Modules/mesh-module/include/mesh/mesh.h +++ b/Modules/mesh-module/include/mesh/mesh.h @@ -205,23 +205,91 @@ error_t mesh_set_backend(const char* id); /** @retval ERROR_NOT_FOUND when no backend is selected */ error_t mesh_get_backend(char* id, size_t id_size); -/** @return the number of radio presets of the selected backend, 0 when it has none */ -size_t mesh_get_preset_count(void); +/* + * Presets and radio settings are kept per backend, so a backend can be configured before it is selected. + * @a backend is the id of a backend, or NULL for the selected backend. + */ + +/** @return the number of radio presets of the backend, 0 when it has none or the radio doesn't use LoRa */ +size_t mesh_get_preset_count(const char* backend); /** * @param[in] index the preset index, below mesh_get_preset_count() * @retval ERROR_NOT_FOUND for an invalid index */ -error_t mesh_get_preset_name(size_t index, char* name, size_t name_size); +error_t mesh_get_preset_name(const char* backend, size_t index, char* name, size_t name_size); + +/** + * Select the radio preset of a backend. The choice is persisted. The radio settings of the backend are cleared. + * @retval ERROR_NOT_FOUND when the backend has no such preset + */ +error_t mesh_set_preset(const char* backend, const char* name); /** - * Select the radio preset of the selected backend. The choice is persisted and resets when the backend changes. - * @retval ERROR_NOT_FOUND when the selected backend has no such preset + * Get the region that a preset needs, because some presets set their own frequency. + * The region of the radio when the preset fits it or doesn't depend on the region. + * @retval ERROR_NOT_FOUND when the backend has no such preset + * @retval ERROR_NOT_SUPPORTED when no region allows the preset */ -error_t mesh_set_preset(const char* name); +error_t mesh_get_preset_region(const char* backend, const char* name, enum RadioRegion* region); /** Get the selected preset name, which is the backend's default preset when none was selected */ -error_t mesh_get_preset(char* name, size_t name_size); +error_t mesh_get_preset(const char* backend, char* name, size_t name_size); + +/** LoRa parameters of a backend. A value of 0 uses the value of the preset. */ +struct MeshRadioSettings { + /** In Hz */ + int32_t frequency; + /** In Hz */ + int32_t bandwidth; + int32_t spreading_factor; + /** The denominator of the coding rate: 5 for 4/5 up to 8 for 4/8 */ + int32_t coding_rate; + /** In dBm */ + int32_t power; +}; + +/** + * Set LoRa parameters of a backend. The choice is persisted and resets when the preset changes. + * The radio is reconfigured when the backend is selected and the service is running. + * @retval ERROR_NOT_FOUND when there is no such backend + */ +error_t mesh_set_radio_settings(const char* backend, const struct MeshRadioSettings* settings); + +error_t mesh_get_radio_settings(const char* backend, struct MeshRadioSettings* settings); + +/** + * Get the LoRa parameters that a backend would run with: the preset's values with the settings applied. + * @retval ERROR_INVALID_STATE when the radio doesn't use LoRa or has no region + */ +error_t mesh_get_radio_parameters(const char* backend, struct MeshRadioSettings* parameters); + +/** Region scope that turns the scope off, for a preset that has one */ +#define MESH_REGION_SCOPE_NONE "*" + +/** Network parameters of a backend that don't concern the radio. A backend ignores the ones it doesn't have. */ +struct MeshNetworkSettings { + /** Bytes per hop in the path of a packet, 0 uses the value of the preset */ + int32_t path_hash_size; + /** Name of the region that flooded packets are limited to, empty uses the value of the preset, MESH_REGION_SCOPE_NONE for no scope */ + char region_scope[MESH_NAME_MAX_LENGTH + 1]; +}; + +/** + * Set network parameters of a backend. The choice is persisted and resets when the preset changes. + * The radio is reconfigured when the backend is selected and the service is running. + * @retval ERROR_NOT_FOUND when there is no such backend + */ +error_t mesh_set_network_settings(const char* backend, const struct MeshNetworkSettings* settings); + +error_t mesh_get_network_settings(const char* backend, struct MeshNetworkSettings* settings); + +/** + * Get the network parameters that a backend would run with: the preset's values with the settings applied. + * The scope is empty for none. + * @retval ERROR_NOT_FOUND when there is no such backend + */ +error_t mesh_get_network_parameters(const char* backend, struct MeshNetworkSettings* parameters); // endregion diff --git a/Modules/mesh-module/private/mesh/mesh_settings.h b/Modules/mesh-module/private/mesh/mesh_settings.h index a7991e598..d1ef0052e 100644 --- a/Modules/mesh-module/private/mesh/mesh_settings.h +++ b/Modules/mesh-module/private/mesh/mesh_settings.h @@ -8,13 +8,27 @@ struct MeshSettings { bool enabled; char backend[MESH_BACKEND_ID_MAX_LENGTH + 1]; - /** Empty for the backend's default preset */ - char preset[MESH_NAME_MAX_LENGTH + 1]; char node_name[MESH_NAME_MAX_LENGTH + 1]; /** The name of the radio device, empty for the default radio */ char radio[MESH_NAME_MAX_LENGTH + 1]; }; +/** The settings of one backend, which are kept for backends that aren't selected */ +struct MeshBackendSettings { + /** Empty for the backend's default preset */ + char preset[MESH_NAME_MAX_LENGTH + 1]; + struct MeshRadioSettings radio_settings; + struct MeshNetworkSettings network; +}; + +/** + * Loads the settings of a backend, or defaults when there are none. + * @param[in] migrate_legacy take settings that were stored without a backend, for the backend that was selected then + */ +void mesh_settings_load_backend(const char* backend, bool migrate_legacy, struct MeshBackendSettings* settings); + +error_t mesh_settings_save_backend(const char* backend, const struct MeshBackendSettings* settings); + /** Loads the settings, or defaults when there are none */ void mesh_settings_load(struct MeshSettings* settings); diff --git a/Modules/mesh-module/source/mesh_command.cpp b/Modules/mesh-module/source/mesh_command.cpp index 964bad448..13a8c62ce 100644 --- a/Modules/mesh-module/source/mesh_command.cpp +++ b/Modules/mesh-module/source/mesh_command.cpp @@ -56,7 +56,7 @@ static int command_status() { printf("backend: %s\n", backend); } char preset[MESH_NAME_MAX_LENGTH + 1]; - if (mesh_get_preset(preset, sizeof(preset)) == ERROR_NONE) { + if (mesh_get_preset(nullptr, preset, sizeof(preset)) == ERROR_NONE) { printf("preset: %s\n", preset); } printf("region: %s\n", radio_region_to_string(radio_settings_load_region())); @@ -141,7 +141,7 @@ static int command_preset(int argc, char* argv[]) { } if (argc == 3) { - error_t error = mesh_set_preset(argv[2]); + error_t error = mesh_set_preset(nullptr, argv[2]); if (error == ERROR_NOT_FOUND) { printf("mesh: unknown preset: %s\n", argv[2]); return 1; @@ -153,15 +153,15 @@ static int command_preset(int argc, char* argv[]) { } char selected[MESH_NAME_MAX_LENGTH + 1] = {}; - mesh_get_preset(selected, sizeof(selected)); - const size_t count = mesh_get_preset_count(); + mesh_get_preset(nullptr, selected, sizeof(selected)); + const size_t count = mesh_get_preset_count(nullptr); if (count == 0) { printf("The backend has no presets\n"); return 0; } for (size_t i = 0; i < count; i++) { char name[MESH_NAME_MAX_LENGTH + 1]; - if (mesh_get_preset_name(i, name, sizeof(name)) == ERROR_NONE) { + if (mesh_get_preset_name(nullptr, i, name, sizeof(name)) == ERROR_NONE) { printf("%s %s\n", strcmp(name, selected) == 0 ? "*" : " ", name); } } diff --git a/Modules/mesh-module/source/mesh_service.cpp b/Modules/mesh-module/source/mesh_service.cpp index 66eb93dd7..bf73d2972 100644 --- a/Modules/mesh-module/source/mesh_service.cpp +++ b/Modules/mesh-module/source/mesh_service.cpp @@ -26,6 +26,7 @@ #include #include +#include #include #include #include @@ -70,6 +71,12 @@ struct Globals { size_t backendCount = 0; MeshSettings settings {}; bool settingsLoaded = false; + /** Settings of the backends, loaded on first use */ + struct BackendSettingsEntry { + const MeshBackendApi* api; + MeshBackendSettings settings; + } backendSettings[MAX_BACKENDS] = {}; + size_t backendSettingsCount = 0; MeshEventSubscription* subscriptions = nullptr; }; @@ -169,6 +176,25 @@ const MeshBackendApi* getSelectedBackend() { return (globals().backendCount > 0) ? globals().backends[0] : nullptr; } +/** @param[in] id the backend, or NULL for the selected backend. Requires the global lock and loaded settings. */ +const MeshBackendApi* resolveBackend(const char* id) { + return (id != nullptr) ? findBackend(id) : getSelectedBackend(); +} + +/** Requires the global lock and loaded settings */ +MeshBackendSettings& getBackendSettings(const MeshBackendApi* api) { + auto& entries = globals().backendSettings; + for (size_t i = 0; i < globals().backendSettingsCount; i++) { + if (entries[i].api == api) { + return entries[i].settings; + } + } + auto& entry = entries[globals().backendSettingsCount < MAX_BACKENDS ? globals().backendSettingsCount++ : MAX_BACKENDS - 1]; + entry.api = api; + mesh_settings_load_backend(api->id, getSelectedBackend() == api, &entry.settings); + return entry.settings; +} + size_t getPresetCount(const MeshBackendApi* api) { size_t count = 0; if (api->presets != nullptr) { @@ -261,6 +287,8 @@ struct AirtimeRecord { */ struct MeshHost { MeshState state = MESH_STATE_OFF; + /** The network parameters of the active backend */ + MeshNetworkSettings network {}; MeshStore store; tt::std::vector nodes; /** The saved contacts of every backend */ @@ -571,6 +599,52 @@ bool applyProfile(MeshHost* host) { void resubmitPending(MeshHost* host); +/** + * The profile of a preset with the settings applied, and the power limited to what the region allows (the radio rejects more). + * @param[in] backend the backend state, or NULL to only get the parameters of the preset + */ +error_t getRadioProfile(const MeshBackendApi* api, void* backend, RadioRegion region, RadioModulation modulation, size_t preset, const MeshRadioSettings& settings, MeshRadioProfile* profile) { + const error_t error = api->get_radio_profile(backend, region, modulation, preset, profile); + if (error != ERROR_NONE || modulation != RADIO_MODULATION_LORA) { + return error; + } + if (settings.frequency != 0) profile->frequency = settings.frequency; + if (settings.bandwidth != 0) profile->bandwidth = settings.bandwidth; + if (settings.spreading_factor != 0) profile->spreading_factor = settings.spreading_factor; + if (settings.coding_rate != 0) profile->coding_rate = settings.coding_rate; + if (settings.power != 0) profile->power = settings.power; + + const RadioRegionBand* bands = nullptr; + const size_t bandCount = radio_get_region_bands(region, &bands); + int32_t maxPower = 0; + for (size_t i = 0; i < bandCount; i++) { + maxPower = std::max(maxPower, bands[i].power_max); + } + if (bandCount > 0) { + profile->power = std::min(profile->power, maxPower); + } + return ERROR_NONE; +} + +/** The preset's network parameters with the settings applied */ +MeshNetworkSettings getNetwork(const MeshBackendApi* api, size_t preset, const MeshNetworkSettings& settings) { + MeshNetworkSettings network = {}; + if (api->get_preset_network != nullptr) { + api->get_preset_network(preset, &network); + } + if (settings.path_hash_size != 0) { + network.path_hash_size = settings.path_hash_size; + } + if (settings.region_scope[0] == '\0') { + // The preset's scope + } else if (strcmp(settings.region_scope, MESH_REGION_SCOPE_NONE) == 0) { + network.region_scope[0] = '\0'; + } else { + strncpy(network.region_scope, settings.region_scope, MESH_NAME_MAX_LENGTH); + } + return network; +} + /** (Re)select the backend and configure the radio from the settings and the radio's region */ void configure(MeshHost* host) { deactivateBackend(host); @@ -589,7 +663,8 @@ void configure(MeshHost* host) { lock(); const MeshBackendApi* api = getSelectedBackend(); - const size_t preset = (api != nullptr) ? findPreset(api, globals().settings.preset) : 0; + const MeshBackendSettings backendSettings = (api != nullptr) ? getBackendSettings(api) : MeshBackendSettings {}; + const size_t preset = (api != nullptr) ? findPreset(api, backendSettings.preset) : 0; unlock(); if (api == nullptr) { setStateLocked(host, MESH_STATE_NO_BACKEND); @@ -613,13 +688,16 @@ void configure(MeshHost* host) { } // A radio that doesn't do LoRa does ESP-NOW const RadioModulation modulation = usesLora(host->radio) ? RADIO_MODULATION_LORA : RADIO_MODULATION_ESPNOW; - if (api->get_radio_profile(backend, host->appliedRegion, modulation, preset, &host->profile) != ERROR_NONE) { + if (getRadioProfile(api, backend, host->appliedRegion, modulation, preset, backendSettings.radio_settings, &host->profile) != ERROR_NONE) { LOG_E(TAG, "Backend %s doesn't support region %s with preset %u", api->id, radio_region_to_string(host->appliedRegion), (unsigned)preset); api->destroy(backend); host->api = nullptr; setStateLocked(host, MESH_STATE_ERROR); return; } + lock(); + host->network = getNetwork(api, preset, backendSettings.network); + unlock(); host->backend = backend; if (!applyProfile(host)) { @@ -1165,6 +1243,12 @@ error_t mesh_host_get_data_path(MeshHost* host, const char* child_path, char* ou return (written < 0 || (size_t)written >= out_path_size) ? ERROR_BUFFER_OVERFLOW : ERROR_NONE; } +void mesh_host_get_network(MeshHost* host, MeshNetworkSettings* network) { + lock(); + *network = host->network; + unlock(); +} + void mesh_host_get_node_name(MeshHost*, char* name, size_t name_size) { lock(); ensureSettingsLoaded(); @@ -1386,9 +1470,6 @@ error_t mesh_set_backend(const char* id) { return ERROR_NOT_FOUND; } ensureSettingsLoaded(); - if (strncmp(globals().settings.backend, id, MESH_BACKEND_ID_MAX_LENGTH) != 0) { - globals().settings.preset[0] = '\0'; - } strncpy(globals().settings.backend, id, MESH_BACKEND_ID_MAX_LENGTH); globals().settings.backend[MESH_BACKEND_ID_MAX_LENGTH] = '\0'; const MeshSettings copy = globals().settings; @@ -1417,21 +1498,21 @@ error_t mesh_get_backend(char* id, size_t id_size) { return result; } -size_t mesh_get_preset_count(void) { +size_t mesh_get_preset_count(const char* backend) { const bool applies = presetsApply(); lock(); ensureSettingsLoaded(); - const MeshBackendApi* api = getSelectedBackend(); + const MeshBackendApi* api = resolveBackend(backend); const size_t count = (api != nullptr && applies) ? getPresetCount(api) : 0; unlock(); return count; } -error_t mesh_get_preset_name(size_t index, char* name, size_t name_size) { +error_t mesh_get_preset_name(const char* backend, size_t index, char* name, size_t name_size) { const bool applies = presetsApply(); lock(); ensureSettingsLoaded(); - const MeshBackendApi* api = getSelectedBackend(); + const MeshBackendApi* api = resolveBackend(backend); error_t result = ERROR_NOT_FOUND; if (api != nullptr && applies && index < getPresetCount(api) && name_size > 0) { strncpy(name, api->presets[index], name_size - 1); @@ -1442,35 +1523,62 @@ error_t mesh_get_preset_name(size_t index, char* name, size_t name_size) { return result; } -error_t mesh_set_preset(const char* name) { +/** Stores the settings of a backend. Reconfigures the radio when it's the selected backend. */ +static error_t saveBackendSettings(const MeshBackendApi* api, const MeshBackendSettings& settings) { + getBackendSettings(api) = settings; + if (service != nullptr && api == getSelectedBackend()) { + MeshHost* host = service; + post(host, [host] { configure(host); }); + } + return ERROR_NONE; +} + +error_t mesh_set_preset(const char* backend, const char* name) { const bool applies = presetsApply(); lock(); ensureSettingsLoaded(); - const MeshBackendApi* api = getSelectedBackend(); + const MeshBackendApi* api = resolveBackend(backend); const size_t index = (api != nullptr) ? findPreset(api, name) : 0; if (api == nullptr || !applies || index >= getPresetCount(api) || strcmp(api->presets[index], name) != 0) { unlock(); return ERROR_NOT_FOUND; } - strncpy(globals().settings.preset, name, MESH_NAME_MAX_LENGTH); - globals().settings.preset[MESH_NAME_MAX_LENGTH] = '\0'; - const MeshSettings copy = globals().settings; - if (service != nullptr) { - MeshHost* host = service; - post(host, [host] { configure(host); }); + MeshBackendSettings settings = {}; + strncpy(settings.preset, name, MESH_NAME_MAX_LENGTH); + saveBackendSettings(api, settings); + const MeshBackendSettings copy = settings; + const std::string id = api->id; + unlock(); + return mesh_settings_save_backend(id.c_str(), ©); +} + +error_t mesh_get_preset_region(const char* backend, const char* name, RadioRegion* region) { + Device* radio = findRadio(); + const RadioRegion current = (radio != nullptr) ? radio_get_region(radio) : RADIO_REGION_UNSET; + if (radio != nullptr) { + device_put(radio); + } + lock(); + ensureSettingsLoaded(); + const MeshBackendApi* api = resolveBackend(backend); + const size_t index = (api != nullptr) ? findPreset(api, name) : 0; + error_t result = ERROR_NOT_FOUND; + if (api != nullptr && index < getPresetCount(api) && strcmp(api->presets[index], name) == 0) { + *region = current; + result = (api->get_preset_region != nullptr) ? api->get_preset_region(index, current, region) : ERROR_NONE; } unlock(); - return mesh_settings_save(©); + return result; } -error_t mesh_get_preset(char* name, size_t name_size) { +error_t mesh_get_preset(const char* backend, char* name, size_t name_size) { const bool applies = presetsApply(); lock(); ensureSettingsLoaded(); - const MeshBackendApi* api = getSelectedBackend(); + const MeshBackendApi* api = resolveBackend(backend); error_t result = ERROR_NOT_FOUND; if (api != nullptr && applies && getPresetCount(api) > 0 && name_size > 0) { - strncpy(name, api->presets[findPreset(api, globals().settings.preset)], name_size - 1); + strncpy(name, api->presets[findPreset(api, getBackendSettings(api).preset)], name_size - 1); name[name_size - 1] = '\0'; result = ERROR_NONE; } @@ -1478,6 +1586,106 @@ error_t mesh_get_preset(char* name, size_t name_size) { return result; } +error_t mesh_set_radio_settings(const char* backend, const MeshRadioSettings* radio_settings) { + lock(); + ensureSettingsLoaded(); + const MeshBackendApi* api = resolveBackend(backend); + if (api == nullptr) { + unlock(); + return ERROR_NOT_FOUND; + } + MeshBackendSettings settings = getBackendSettings(api); + settings.radio_settings = *radio_settings; + saveBackendSettings(api, settings); + const std::string id = api->id; + unlock(); + return mesh_settings_save_backend(id.c_str(), &settings); +} + +error_t mesh_get_radio_settings(const char* backend, MeshRadioSettings* radio_settings) { + lock(); + ensureSettingsLoaded(); + const MeshBackendApi* api = resolveBackend(backend); + error_t result = ERROR_NOT_FOUND; + if (api != nullptr) { + *radio_settings = getBackendSettings(api).radio_settings; + result = ERROR_NONE; + } + unlock(); + return result; +} + +error_t mesh_set_network_settings(const char* backend, const MeshNetworkSettings* network_settings) { + lock(); + ensureSettingsLoaded(); + const MeshBackendApi* api = resolveBackend(backend); + if (api == nullptr) { + unlock(); + return ERROR_NOT_FOUND; + } + MeshBackendSettings settings = getBackendSettings(api); + settings.network = *network_settings; + saveBackendSettings(api, settings); + const std::string id = api->id; + unlock(); + return mesh_settings_save_backend(id.c_str(), &settings); +} + +error_t mesh_get_network_settings(const char* backend, MeshNetworkSettings* network_settings) { + lock(); + ensureSettingsLoaded(); + const MeshBackendApi* api = resolveBackend(backend); + error_t result = ERROR_NOT_FOUND; + if (api != nullptr) { + *network_settings = getBackendSettings(api).network; + result = ERROR_NONE; + } + unlock(); + return result; +} + +error_t mesh_get_network_parameters(const char* backend, MeshNetworkSettings* parameters) { + lock(); + ensureSettingsLoaded(); + const MeshBackendApi* api = resolveBackend(backend); + error_t result = ERROR_NOT_FOUND; + if (api != nullptr) { + const MeshBackendSettings settings = getBackendSettings(api); + *parameters = getNetwork(api, findPreset(api, settings.preset), settings.network); + result = ERROR_NONE; + } + unlock(); + return result; +} + +error_t mesh_get_radio_parameters(const char* backend, MeshRadioSettings* parameters) { + const bool applies = presetsApply(); + Device* radio = findRadio(); + const RadioRegion region = (radio != nullptr) ? radio_get_region(radio) : RADIO_REGION_UNSET; + if (radio != nullptr) { + device_put(radio); + } + lock(); + ensureSettingsLoaded(); + const MeshBackendApi* api = resolveBackend(backend); + error_t result = ERROR_INVALID_STATE; + if (api != nullptr && applies && region != RADIO_REGION_UNSET) { + const MeshBackendSettings settings = getBackendSettings(api); + MeshRadioProfile profile {}; + // The profile of the running backend has the same values, so it doesn't need its own state + result = getRadioProfile(api, nullptr, region, RADIO_MODULATION_LORA, findPreset(api, settings.preset), settings.radio_settings, &profile); + if (result == ERROR_NONE) { + parameters->frequency = profile.frequency; + parameters->bandwidth = profile.bandwidth; + parameters->spreading_factor = profile.spreading_factor; + parameters->coding_rate = profile.coding_rate; + parameters->power = profile.power; + } + } + unlock(); + return result; +} + error_t mesh_send_text(const MeshAddress* to, uint8_t channel, const char* text, uint32_t* seq) { if (text == nullptr || strlen(text) > MESH_TEXT_MAX_LENGTH) { return ERROR_OUT_OF_RANGE; diff --git a/Modules/mesh-module/source/mesh_settings.cpp b/Modules/mesh-module/source/mesh_settings.cpp index f13690ab9..300c016e4 100644 --- a/Modules/mesh-module/source/mesh_settings.cpp +++ b/Modules/mesh-module/source/mesh_settings.cpp @@ -9,6 +9,7 @@ #include #include +#include #include #ifdef ESP_PLATFORM @@ -22,6 +23,13 @@ constexpr auto* SETTINGS_FILE_NAME = "settings.properties"; constexpr auto* KEY_ENABLED = "enabled"; constexpr auto* KEY_BACKEND = "backend"; constexpr auto* KEY_PRESET = "preset"; +constexpr auto* KEY_FREQUENCY = "frequency"; +constexpr auto* KEY_BANDWIDTH = "bandwidth"; +constexpr auto* KEY_SPREADING_FACTOR = "spreadingFactor"; +constexpr auto* KEY_CODING_RATE = "codingRate"; +constexpr auto* KEY_POWER = "power"; +constexpr auto* KEY_PATH_HASH_SIZE = "pathHashSize"; +constexpr auto* KEY_REGION_SCOPE = "regionScope"; constexpr auto* KEY_NODE_NAME = "nodeName"; constexpr auto* KEY_RADIO = "radio"; // Earlier versions stored this as the name when the user hadn't chosen one @@ -54,9 +62,6 @@ static void read_settings_file(MeshSettings* settings) { if (properties_file_get(file, KEY_BACKEND, value, sizeof(value)) == ERROR_NONE) { strncpy(settings->backend, value, MESH_BACKEND_ID_MAX_LENGTH); } - if (properties_file_get(file, KEY_PRESET, value, sizeof(value)) == ERROR_NONE) { - strncpy(settings->preset, value, MESH_NAME_MAX_LENGTH); - } if (properties_file_get(file, KEY_NODE_NAME, value, sizeof(value)) == ERROR_NONE && value[0] != '\0') { strncpy(settings->node_name, value, MESH_NAME_MAX_LENGTH); } @@ -79,27 +84,106 @@ void mesh_settings_load(MeshSettings* settings) { } } -error_t mesh_settings_save(const MeshSettings* settings) { +/** Opens the settings file for writing and makes sure that its directory exists */ +static PropertiesFile* open_for_write() { char directory[FILE_MAX_PATH_STRING_LENGTH]; char path[FILE_MAX_PATH_STRING_LENGTH]; if (service_paths_get_user_data_directory(MESH_SERVICE_ID, directory, sizeof(directory)) != ERROR_NONE || service_paths_get_user_data_path(MESH_SERVICE_ID, SETTINGS_FILE_NAME, path, sizeof(path)) != ERROR_NONE) { - return ERROR_BUFFER_OVERFLOW; + return nullptr; } const error_t made = directory_make(directory, true); if (made != ERROR_NONE && made != ERROR_ALREADY_EXISTS) { LOG_E(TAG, "Failed to create %s", directory); - return ERROR_RESOURCE; + return nullptr; } + return properties_file_open(path); +} - PropertiesFile* file = properties_file_open(path); +error_t mesh_settings_save(const MeshSettings* settings) { + PropertiesFile* file = open_for_write(); if (file == nullptr) { return ERROR_RESOURCE; } properties_file_set(file, KEY_ENABLED, settings->enabled ? "true" : "false"); properties_file_set(file, KEY_BACKEND, settings->backend); - properties_file_set(file, KEY_PRESET, settings->preset); properties_file_set(file, KEY_NODE_NAME, settings->node_name); properties_file_set(file, KEY_RADIO, settings->radio); return properties_file_close(file); } + +/** Reads a setting stored as ".", or as "" when @a legacy */ +static bool read_backend_value(const PropertiesFile* file, const char* backend, const char* key, bool legacy, char* value, size_t value_size) { + char name[MESH_BACKEND_ID_MAX_LENGTH + 24]; + snprintf(name, sizeof(name), "%s.%s", backend, key); + if (properties_file_get(file, name, value, value_size) == ERROR_NONE) { + return true; + } + return legacy && properties_file_get(file, key, value, value_size) == ERROR_NONE; +} + +void mesh_settings_load_backend(const char* backend, bool migrate_legacy, MeshBackendSettings* settings) { + *settings = {}; + char path[FILE_MAX_PATH_STRING_LENGTH]; + if (service_paths_get_user_data_path(MESH_SERVICE_ID, SETTINGS_FILE_NAME, path, sizeof(path)) != ERROR_NONE) { + return; + } + PropertiesFile* file = properties_file_open(path); + if (file == nullptr) { + return; + } + char value[64]; + if (read_backend_value(file, backend, KEY_PRESET, migrate_legacy, value, sizeof(value))) { + strncpy(settings->preset, value, MESH_NAME_MAX_LENGTH); + } + const struct { + const char* key; + int32_t* target; + } numbers[] = { + { KEY_FREQUENCY, &settings->radio_settings.frequency }, + { KEY_BANDWIDTH, &settings->radio_settings.bandwidth }, + { KEY_SPREADING_FACTOR, &settings->radio_settings.spreading_factor }, + { KEY_CODING_RATE, &settings->radio_settings.coding_rate }, + { KEY_POWER, &settings->radio_settings.power }, + { KEY_PATH_HASH_SIZE, &settings->network.path_hash_size }, + }; + for (const auto& number : numbers) { + if (read_backend_value(file, backend, number.key, migrate_legacy, value, sizeof(value))) { + *number.target = atoi(value); + } + } + if (read_backend_value(file, backend, KEY_REGION_SCOPE, migrate_legacy, value, sizeof(value))) { + strncpy(settings->network.region_scope, value, MESH_NAME_MAX_LENGTH); + } + properties_file_close(file); +} + +error_t mesh_settings_save_backend(const char* backend, const MeshBackendSettings* settings) { + PropertiesFile* file = open_for_write(); + if (file == nullptr) { + return ERROR_RESOURCE; + } + const struct { + const char* key; + int32_t value; + } numbers[] = { + { KEY_FREQUENCY, settings->radio_settings.frequency }, + { KEY_BANDWIDTH, settings->radio_settings.bandwidth }, + { KEY_SPREADING_FACTOR, settings->radio_settings.spreading_factor }, + { KEY_CODING_RATE, settings->radio_settings.coding_rate }, + { KEY_POWER, settings->radio_settings.power }, + { KEY_PATH_HASH_SIZE, settings->network.path_hash_size }, + }; + char name[MESH_BACKEND_ID_MAX_LENGTH + 24]; + snprintf(name, sizeof(name), "%s.%s", backend, KEY_PRESET); + properties_file_set(file, name, settings->preset); + for (const auto& number : numbers) { + char value[16]; + snprintf(name, sizeof(name), "%s.%s", backend, number.key); + snprintf(value, sizeof(value), "%d", (int)number.value); + properties_file_set(file, name, value); + } + snprintf(name, sizeof(name), "%s.%s", backend, KEY_REGION_SCOPE); + properties_file_set(file, name, settings->network.region_scope); + return properties_file_close(file); +} diff --git a/Modules/mesh-module/source/module.cpp b/Modules/mesh-module/source/module.cpp index c896608a4..56ec3dd75 100644 --- a/Modules/mesh-module/source/module.cpp +++ b/Modules/mesh-module/source/module.cpp @@ -126,6 +126,13 @@ static const ModuleSymbol mesh_module_symbols[] = { DEFINE_MODULE_SYMBOL(mesh_get_preset_name), DEFINE_MODULE_SYMBOL(mesh_set_preset), DEFINE_MODULE_SYMBOL(mesh_get_preset), + DEFINE_MODULE_SYMBOL(mesh_get_preset_region), + DEFINE_MODULE_SYMBOL(mesh_set_radio_settings), + DEFINE_MODULE_SYMBOL(mesh_get_radio_settings), + DEFINE_MODULE_SYMBOL(mesh_get_radio_parameters), + DEFINE_MODULE_SYMBOL(mesh_set_network_settings), + DEFINE_MODULE_SYMBOL(mesh_get_network_settings), + DEFINE_MODULE_SYMBOL(mesh_get_network_parameters), DEFINE_MODULE_SYMBOL(mesh_send_text), DEFINE_MODULE_SYMBOL(mesh_cancel), DEFINE_MODULE_SYMBOL(mesh_raw_send), diff --git a/Modules/mesh-module/tests/source/service_test.cpp b/Modules/mesh-module/tests/source/service_test.cpp index 7b7c7a667..8a1d31883 100644 --- a/Modules/mesh-module/tests/source/service_test.cpp +++ b/Modules/mesh-module/tests/source/service_test.cpp @@ -81,6 +81,20 @@ error_t loopbackGetRadioProfile(void*, RadioRegion, RadioModulation modulation, return ERROR_NONE; } +// The "Other" preset stands for one that sets its own frequency in the US band +error_t loopbackGetPresetRegion(size_t preset, RadioRegion current, RadioRegion* region) { + *region = (preset == 1) ? RADIO_REGION_US_915 : current; + return ERROR_NONE; +} + +// The "Other" preset stands for one with a larger path hash size and a scope +void loopbackGetPresetNetwork(size_t preset, MeshNetworkSettings* network) { + *network = { .path_hash_size = (preset == 1) ? 2 : 1 }; + if (preset == 1) { + strcpy(network->region_scope, "nl"); + } +} + void loopbackOnStart(void*) { startCount++; } // Channel 0xFF stands for frames the backend can't read, 0xFE for adverts @@ -130,6 +144,8 @@ const MeshBackendApi loopbackApi = { .destroy = loopbackDestroy, .presets = loopbackPresets, .get_radio_profile = loopbackGetRadioProfile, + .get_preset_region = loopbackGetPresetRegion, + .get_preset_network = loopbackGetPresetNetwork, .on_start = loopbackOnStart, .on_receive = loopbackOnReceive, .on_tick = loopbackOnTick, @@ -278,23 +294,124 @@ TEST_CASE("mesh service sends and receives through the active backend") { REQUIRE(waitFor([] { return mesh_get_state() == MESH_STATE_ON; })); char name[MESH_NAME_MAX_LENGTH + 1]; - CHECK_EQ(mesh_get_preset_count(), 2); - CHECK_EQ(mesh_get_preset_name(1, name, sizeof(name)), ERROR_NONE); + CHECK_EQ(mesh_get_preset_count(nullptr), 2); + CHECK_EQ(mesh_get_preset_name(nullptr, 1, name, sizeof(name)), ERROR_NONE); CHECK_EQ(std::string(name), "Other"); - CHECK_EQ(mesh_get_preset_name(2, name, sizeof(name)), ERROR_NOT_FOUND); - CHECK_EQ(mesh_get_preset(name, sizeof(name)), ERROR_NONE); + CHECK_EQ(mesh_get_preset_name(nullptr, 2, name, sizeof(name)), ERROR_NOT_FOUND); + CHECK_EQ(mesh_get_preset(nullptr, name, sizeof(name)), ERROR_NONE); CHECK_EQ(std::string(name), "Default"); - CHECK_EQ(mesh_set_preset("Missing"), ERROR_NOT_FOUND); - REQUIRE_EQ(mesh_set_preset("Other"), ERROR_NONE); - CHECK_EQ(mesh_get_preset(name, sizeof(name)), ERROR_NONE); + CHECK_EQ(mesh_set_preset(nullptr, "Missing"), ERROR_NOT_FOUND); + REQUIRE_EQ(mesh_set_preset(nullptr, "Other"), ERROR_NONE); + CHECK_EQ(mesh_get_preset(nullptr, name, sizeof(name)), ERROR_NONE); CHECK_EQ(std::string(name), "Other"); CHECK(waitFor([] { int32_t spreadingFactor = 0; return radio_get_parameter(&local, RADIO_PARAMETER_SPREADING_FACTOR, &spreadingFactor) == ERROR_NONE && spreadingFactor == 7; })); CHECK(waitFor([] { return mesh_get_state() == MESH_STATE_ON; })); - REQUIRE_EQ(mesh_set_preset("Default"), ERROR_NONE); + REQUIRE_EQ(mesh_set_preset(nullptr, "Default"), ERROR_NONE); + } + + SUBCASE("a backend that isn't selected can be configured") { + MeshBackendApi spareApi = loopbackApi; + spareApi.id = "spare"; + REQUIRE_EQ(mesh_backend_register(&spareApi), ERROR_NONE); + REQUIRE_EQ(radio_set_region(&local, RADIO_REGION_EU_868), ERROR_NONE); + REQUIRE_EQ(service_manager_start(MESH_SERVICE_ID), ERROR_NONE); + REQUIRE_EQ(mesh_set_backend("loopback"), ERROR_NONE); + REQUIRE(waitFor([] { return mesh_get_state() == MESH_STATE_ON; })); + + char name[MESH_NAME_MAX_LENGTH + 1]; + REQUIRE_EQ(mesh_set_preset("spare", "Other"), ERROR_NONE); + const MeshRadioSettings settings = { .power = 5 }; + REQUIRE_EQ(mesh_set_radio_settings("spare", &settings), ERROR_NONE); + + // The selected backend keeps its own settings, and the radio keeps running with them + CHECK_EQ(mesh_get_preset("spare", name, sizeof(name)), ERROR_NONE); + CHECK_EQ(std::string(name), "Other"); + CHECK_EQ(mesh_get_preset("loopback", name, sizeof(name)), ERROR_NONE); + CHECK_EQ(std::string(name), "Default"); + MeshRadioSettings stored = {}; + CHECK_EQ(mesh_get_radio_settings("loopback", &stored), ERROR_NONE); + CHECK_EQ(stored.power, 0); + CHECK_EQ(mesh_get_radio_settings("spare", &stored), ERROR_NONE); + CHECK_EQ(stored.power, 5); + int32_t spreadingFactor = 0; + CHECK_EQ(radio_get_parameter(&local, RADIO_PARAMETER_SPREADING_FACTOR, &spreadingFactor), ERROR_NONE); + CHECK_EQ(spreadingFactor, 11); + + // The parameters of the backend that isn't running follow its preset and settings + MeshRadioSettings parameters = {}; + CHECK_EQ(mesh_get_radio_parameters("spare", ¶meters), ERROR_NONE); + CHECK_EQ(parameters.spreading_factor, 7); + CHECK_EQ(parameters.power, 5); + CHECK_EQ(mesh_get_preset("missing", name, sizeof(name)), ERROR_NOT_FOUND); + + REQUIRE_EQ(mesh_set_preset("spare", "Default"), ERROR_NONE); + REQUIRE_EQ(mesh_backend_unregister(&spareApi), ERROR_NONE); + } + + SUBCASE("network parameters follow the preset and the settings") { + REQUIRE_EQ(radio_set_region(&local, RADIO_REGION_EU_868), ERROR_NONE); + REQUIRE_EQ(service_manager_start(MESH_SERVICE_ID), ERROR_NONE); + REQUIRE(waitFor([] { return mesh_get_state() == MESH_STATE_ON; })); + + MeshNetworkSettings network = {}; + CHECK_EQ(mesh_get_network_parameters(nullptr, &network), ERROR_NONE); + CHECK_EQ(network.path_hash_size, 1); + CHECK_EQ(std::string(network.region_scope), ""); + + REQUIRE_EQ(mesh_set_preset(nullptr, "Other"), ERROR_NONE); + CHECK_EQ(mesh_get_network_parameters(nullptr, &network), ERROR_NONE); + CHECK_EQ(network.path_hash_size, 2); + CHECK_EQ(std::string(network.region_scope), "nl"); + + // A setting replaces the preset's value, and the scope can be turned off + MeshNetworkSettings settings = { .path_hash_size = 3 }; + strcpy(settings.region_scope, MESH_REGION_SCOPE_NONE); + REQUIRE_EQ(mesh_set_network_settings(nullptr, &settings), ERROR_NONE); + CHECK_EQ(mesh_get_network_parameters(nullptr, &network), ERROR_NONE); + CHECK_EQ(network.path_hash_size, 3); + CHECK_EQ(std::string(network.region_scope), ""); + + // The running backend sees them after the radio was configured again + CHECK(waitFor([] { + MeshNetworkSettings hosted = {}; + return mesh_get_network_settings(nullptr, &hosted) == ERROR_NONE && hosted.path_hash_size == 3; + })); + + // Selecting a preset clears the settings + REQUIRE_EQ(mesh_set_preset(nullptr, "Default"), ERROR_NONE); + CHECK_EQ(mesh_get_network_settings(nullptr, &settings), ERROR_NONE); + CHECK_EQ(settings.path_hash_size, 0); + CHECK_EQ(std::string(settings.region_scope), ""); + CHECK_EQ(mesh_get_network_settings("missing", &settings), ERROR_NOT_FOUND); + } + + SUBCASE("a preset reports the region it needs") { + REQUIRE_EQ(radio_set_region(&local, RADIO_REGION_EU_868), ERROR_NONE); + REQUIRE_EQ(service_manager_start(MESH_SERVICE_ID), ERROR_NONE); + REQUIRE(waitFor([] { return mesh_get_state() == MESH_STATE_ON; })); + RadioRegion region = RADIO_REGION_UNSET; + CHECK_EQ(mesh_get_preset_region(nullptr, "Other", ®ion), ERROR_NONE); + CHECK_EQ(region, RADIO_REGION_US_915); + CHECK_EQ(mesh_get_preset_region(nullptr, "Default", ®ion), ERROR_NONE); + CHECK_EQ(region, RADIO_REGION_EU_868); + CHECK_EQ(mesh_get_preset_region(nullptr, "Missing", ®ion), ERROR_NOT_FOUND); + } + + SUBCASE("the power is limited to what the region allows") { + REQUIRE_EQ(radio_set_region(&local, RADIO_REGION_EU_868), ERROR_NONE); + const MeshRadioSettings tooPowerful = { .power = 40 }; + REQUIRE_EQ(mesh_set_radio_settings(nullptr, &tooPowerful), ERROR_NONE); + REQUIRE_EQ(service_manager_start(MESH_SERVICE_ID), ERROR_NONE); + CHECK(waitFor([] { return mesh_get_state() == MESH_STATE_ON; })); + int32_t power = 0; + CHECK_EQ(radio_get_parameter(&local, RADIO_PARAMETER_POWER, &power), ERROR_NONE); + CHECK_EQ(power, 27); + const MeshRadioSettings none = {}; + REQUIRE_EQ(mesh_set_radio_settings(nullptr, &none), ERROR_NONE); } SUBCASE("a new node name is announced by the backend") { @@ -350,7 +467,7 @@ TEST_CASE("mesh service sends and receives through the active backend") { CHECK_EQ(mesh_get_radio_kind(3, &kind), ERROR_NOT_FOUND); CHECK_EQ(mesh_get_radio(name, sizeof(name)), ERROR_NONE); CHECK_EQ(std::string(name), "radio_local"); - CHECK_EQ(mesh_get_preset_count(), 2); + CHECK_EQ(mesh_get_preset_count(nullptr), 2); CHECK_FALSE(espNowFake.enabled); CHECK_EQ(mesh_set_radio("missing"), ERROR_NOT_FOUND); @@ -363,7 +480,7 @@ TEST_CASE("mesh service sends and receives through the active backend") { CHECK_EQ(espNowFake.power, 20); CHECK_EQ(mesh_get_radio(name, sizeof(name)), ERROR_NONE); CHECK_EQ(std::string(name), "radio_espnow"); - CHECK_EQ(mesh_get_preset_count(), 0); + CHECK_EQ(mesh_get_preset_count(nullptr), 0); CHECK_EQ(radio_acquire(&local, "someone-else"), ERROR_NONE); CHECK_EQ(radio_release(&local), ERROR_NONE); @@ -372,7 +489,7 @@ TEST_CASE("mesh service sends and receives through the active backend") { CHECK(waitFor([] { return !espNowFake.enabled && mesh_get_state() == MESH_STATE_ON; })); CHECK_EQ(mesh_get_radio(name, sizeof(name)), ERROR_NONE); CHECK_EQ(std::string(name), "radio_local"); - CHECK_EQ(mesh_get_preset_count(), 2); + CHECK_EQ(mesh_get_preset_count(nullptr), 2); REQUIRE_EQ(service_manager_stop(MESH_SERVICE_ID), ERROR_NONE); REQUIRE_EQ(radio_set_region(&espNow, RADIO_REGION_UNSET), ERROR_NONE); diff --git a/Tactility/Source/app/meshsettings/MeshSettings.cpp b/Tactility/Source/app/meshsettings/MeshSettings.cpp index c82fef5b3..c5a945ad0 100644 --- a/Tactility/Source/app/meshsettings/MeshSettings.cpp +++ b/Tactility/Source/app/meshsettings/MeshSettings.cpp @@ -31,6 +31,9 @@ #include #include +#include +#include +#include #include #include #include @@ -42,6 +45,13 @@ extern const ::AppManifest manifest; namespace { constexpr auto* TAG = "MeshSettings"; +constexpr auto* MESHCORE_ID = "meshcore"; +constexpr auto* MESHTASTIC_ID = "meshtastic"; + +enum class RadioSetting { Frequency, Bandwidth, SpreadingFactor, CodingRate, Power, PathHashSize, RegionScope }; +constexpr size_t RADIO_SETTING_COUNT = 7; + +constexpr std::array RADIO_SETTING_TITLES = { "Frequency (MHz)", "Bandwidth", "Spreading factor", "Coding rate", "Transmit power", "Path hash size", "Region scope" }; struct Context { uint32_t appInstanceId; @@ -49,17 +59,25 @@ struct Context { uint32_t selectServiceBit = 0; uint32_t selectRegionBit = 0; uint32_t selectPresetBit = 0; - uint32_t addChannelBit = 0; + uint32_t selectModemPresetBit = 0; uint32_t changeNameBit = 0; uint32_t selectRadioBit = 0; + uint32_t radioSettingBits[RADIO_SETTING_COUNT] = {}; uint32_t serviceDialogId = 0; uint32_t regionDialogId = 0; uint32_t presetDialogId = 0; - uint32_t channelDialogId = 0; uint32_t nameDialogId = 0; uint32_t radioDialogId = 0; - AppStream channelStream {}; - char channelBuffer[MESH_NAME_MAX_LENGTH + 1] = {}; + uint32_t radioSettingDialogId = 0; + RadioSetting settingKind = RadioSetting::Bandwidth; + uint32_t scopeDialogId = 0; + AppStream scopeStream {}; + char scopeBuffer[MESH_NAME_MAX_LENGTH + 1] = {}; + uint32_t frequencyDialogId = 0; + AppStream frequencyStream {}; + char frequencyBuffer[16] = {}; + /** Values that the items of the open radio setting dialog stand for */ + std::vector radioSettingValues; AppStream nameStream {}; char nameBuffer[MESH_NAME_MAX_LENGTH + 1] = {}; // Shown by updateLabels(), which can run on another app's thread and so must not query the services. @@ -70,10 +88,17 @@ struct Context { bool radioChoice = false; std::string regionText; std::string presetText; + std::string modemPresetText; + /** Whether the selected backend has presets, which is when the radio does LoRa */ bool hasPresets = false; + /** The backend that the open preset dialog is for */ + const char* presetBackend = MESHCORE_ID; + /** Only shown in the UI, so it isn't persisted */ + bool showAdvanced = false; + bool hasMeshCore = false; + bool hasMeshtastic = false; + std::string radioSettingTexts[RADIO_SETTING_COUNT]; std::string statusText; - /** Channel names of the active backend, empty when it has no channels */ - std::string channelsText; /** Index 0 is "Disabled", the rest are backend ids */ std::vector serviceIds; std::vector presetNames; @@ -85,10 +110,15 @@ struct Context { lv_obj_t* radioButton = nullptr; lv_obj_t* radioLabel = nullptr; lv_obj_t* regionLabel = nullptr; - lv_obj_t* presetRow = nullptr; + lv_obj_t* meshCoreTitle = nullptr; + lv_obj_t* meshCoreCard = nullptr; + lv_obj_t* profileLabel = nullptr; + lv_obj_t* advancedSwitch = nullptr; + lv_obj_t* radioSettingRows[RADIO_SETTING_COUNT] = {}; + lv_obj_t* radioSettingLabels[RADIO_SETTING_COUNT] = {}; + lv_obj_t* meshtasticTitle = nullptr; + lv_obj_t* meshtasticCard = nullptr; lv_obj_t* presetLabel = nullptr; - lv_obj_t* channelsRow = nullptr; - lv_obj_t* channelsLabel = nullptr; lv_obj_t* statusLabel = nullptr; }; @@ -106,11 +136,15 @@ std::string getCurrentService() { return (mesh_get_backend(id, sizeof(id)) == ERROR_NONE) ? getServiceLabel(id) : "Enabled"; } -std::string getStatus() { +/** @param[in] lora whether the radio does LoRa, which is bound to the region */ +std::string getStatus(bool lora) { const auto state = mesh_get_state(); if (state == MESH_STATE_NO_REGION) { return "Select a region to turn on the radio"; } + if (state == MESH_STATE_ERROR && lora) { + return std::string("Settings not allowed in ") + settings::radio::getRegionLabel(settings::radio::loadRegion()); + } return mesh_state_to_string(state); } @@ -129,29 +163,21 @@ void updateLabels(Context* ctx) { } lv_label_set_text(ctx->radioLabel, ctx->radioText.c_str()); lv_label_set_text(ctx->regionLabel, ctx->regionText.c_str()); - lv_obj_set_hidden(ctx->presetRow, !ctx->hasPresets); - lv_label_set_text(ctx->presetLabel, ctx->presetText.c_str()); - lv_obj_set_hidden(ctx->channelsRow, ctx->channelsText.empty()); - lv_label_set_text(ctx->channelsLabel, ctx->channelsText.c_str()); + const bool showMeshCore = ctx->hasMeshCore; + const bool showMeshtastic = ctx->hasMeshtastic; + lv_obj_set_hidden(ctx->meshCoreTitle, !showMeshCore); + lv_obj_set_hidden(ctx->meshCoreCard, !showMeshCore); + lv_label_set_text(ctx->profileLabel, ctx->presetText.c_str()); + for (size_t i = 0; i < RADIO_SETTING_COUNT; i++) { + lv_obj_set_hidden(ctx->radioSettingRows[i], !ctx->showAdvanced); + lv_label_set_text(ctx->radioSettingLabels[i], ctx->radioSettingTexts[i].c_str()); + } + lv_obj_set_hidden(ctx->meshtasticTitle, !showMeshtastic); + lv_obj_set_hidden(ctx->meshtasticCard, !showMeshtastic); + lv_label_set_text(ctx->presetLabel, ctx->modemPresetText.c_str()); lv_label_set_text(ctx->statusLabel, ctx->statusText.c_str()); } -/** Queries the service thread, so it's called without the LVGL lock */ -void loadChannels(Context* ctx) { - MeshChannel channels[8]; - size_t count = 0; - ctx->channelsText.clear(); - if (mesh_get_channels(channels, 8, &count) != ERROR_NONE) { - return; - } - for (size_t i = 0; i < count; i++) { - if (!ctx->channelsText.empty()) { - ctx->channelsText += ", "; - } - ctx->channelsText += channels[i].name; - } -} - /** The device names of the radios, and the names to show for them: the kind of radio, plus the device name when there are several of a kind */ void loadRadios(std::vector& names, std::vector& labels) { names.clear(); @@ -173,9 +199,38 @@ void loadRadios(std::vector& names, std::vector& label } } +std::string formatRadioSetting(RadioSetting kind, int32_t value) { + if (value == 0) { + return "-"; + } + char text[24]; + switch (kind) { + case RadioSetting::Frequency: + snprintf(text, sizeof(text), "%.3f", value / 1e6); + break; + case RadioSetting::Bandwidth: + snprintf(text, sizeof(text), "%g kHz", value / 1000.0); + break; + case RadioSetting::SpreadingFactor: + snprintf(text, sizeof(text), "SF%d", (int)value); + break; + case RadioSetting::CodingRate: + snprintf(text, sizeof(text), "4/%d", (int)value); + break; + case RadioSetting::Power: + snprintf(text, sizeof(text), "%d dBm", (int)value); + break; + case RadioSetting::PathHashSize: + snprintf(text, sizeof(text), "%d byte%s", (int)value, value == 1 ? "" : "s"); + break; + case RadioSetting::RegionScope: + break; + } + return text; +} + /** Reads everything that updateLabels() shows. Queries the service thread, so it's called without the LVGL lock. */ void loadState(Context* ctx) { - loadChannels(ctx); ctx->serviceText = getCurrentService(); char name[MESH_NAME_MAX_LENGTH + 1]; mesh_get_node_name(name, sizeof(name)); @@ -195,21 +250,52 @@ void loadState(Context* ctx) { ctx->radioChoice = radioNames.size() >= 2; ctx->regionText = settings::radio::getRegionLabel(settings::radio::loadRegion()); char preset[MESH_NAME_MAX_LENGTH + 1]; - ctx->hasPresets = mesh_is_enabled() && mesh_get_preset(preset, sizeof(preset)) == ERROR_NONE; - ctx->presetText = ctx->hasPresets ? preset : ""; - ctx->statusText = getStatus(); + ctx->hasPresets = mesh_get_preset(nullptr, preset, sizeof(preset)) == ERROR_NONE; + ctx->hasMeshCore = mesh_get_preset(MESHCORE_ID, preset, sizeof(preset)) == ERROR_NONE; + ctx->presetText = ctx->hasMeshCore ? preset : ""; + ctx->hasMeshtastic = mesh_get_preset(MESHTASTIC_ID, preset, sizeof(preset)) == ERROR_NONE; + ctx->modemPresetText = ctx->hasMeshtastic ? preset : ""; + // The values that MeshCore would run with, which are the preset's with the user's settings applied + MeshRadioSettings settings {}; + if (mesh_get_radio_parameters(MESHCORE_ID, &settings) != ERROR_NONE) { + mesh_get_radio_settings(MESHCORE_ID, &settings); + } + ctx->radioSettingTexts[static_cast(RadioSetting::Frequency)] = formatRadioSetting(RadioSetting::Frequency, settings.frequency); + ctx->radioSettingTexts[static_cast(RadioSetting::Bandwidth)] = formatRadioSetting(RadioSetting::Bandwidth, settings.bandwidth); + ctx->radioSettingTexts[static_cast(RadioSetting::SpreadingFactor)] = formatRadioSetting(RadioSetting::SpreadingFactor, settings.spreading_factor); + ctx->radioSettingTexts[static_cast(RadioSetting::CodingRate)] = formatRadioSetting(RadioSetting::CodingRate, settings.coding_rate); + ctx->radioSettingTexts[static_cast(RadioSetting::Power)] = formatRadioSetting(RadioSetting::Power, settings.power); + MeshNetworkSettings network {}; + mesh_get_network_parameters(MESHCORE_ID, &network); + ctx->radioSettingTexts[static_cast(RadioSetting::PathHashSize)] = formatRadioSetting(RadioSetting::PathHashSize, network.path_hash_size); + ctx->radioSettingTexts[static_cast(RadioSetting::RegionScope)] = network.region_scope[0] != '\0' ? network.region_scope : "None"; + ctx->statusText = getStatus(ctx->hasPresets); } -void onAddChannelPressed(lv_event_t* event) { +void onSelectRadioPressed(lv_event_t* event) { auto* ctx = static_cast(lv_event_get_user_data(event)); - task_event_group_signal(ctx->eventGroup, ctx->addChannelBit); + task_event_group_signal(ctx->eventGroup, ctx->selectRadioBit); } -void onSelectRadioPressed(lv_event_t* event) { +void signalRadioSetting(lv_event_t* event, RadioSetting kind) { auto* ctx = static_cast(lv_event_get_user_data(event)); - task_event_group_signal(ctx->eventGroup, ctx->selectRadioBit); + task_event_group_signal(ctx->eventGroup, ctx->radioSettingBits[static_cast(kind)]); +} + +void onSelectFrequencyPressed(lv_event_t* event) { signalRadioSetting(event, RadioSetting::Frequency); } +void onAdvancedSwitch(lv_event_t* event) { + auto* ctx = static_cast(lv_event_get_user_data(event)); + ctx->showAdvanced = lv_obj_has_state(ctx->advancedSwitch, LV_STATE_CHECKED); + updateLabels(ctx); } +void onSelectPathHashSizePressed(lv_event_t* event) { signalRadioSetting(event, RadioSetting::PathHashSize); } +void onSelectRegionScopePressed(lv_event_t* event) { signalRadioSetting(event, RadioSetting::RegionScope); } +void onSelectBandwidthPressed(lv_event_t* event) { signalRadioSetting(event, RadioSetting::Bandwidth); } +void onSelectSpreadingFactorPressed(lv_event_t* event) { signalRadioSetting(event, RadioSetting::SpreadingFactor); } +void onSelectCodingRatePressed(lv_event_t* event) { signalRadioSetting(event, RadioSetting::CodingRate); } +void onSelectPowerPressed(lv_event_t* event) { signalRadioSetting(event, RadioSetting::Power); } + void onChangeNamePressed(lv_event_t* event) { auto* ctx = static_cast(lv_event_get_user_data(event)); task_event_group_signal(ctx->eventGroup, ctx->changeNameBit); @@ -246,35 +332,6 @@ void onNameDialogResult(Context* ctx, int32_t result) { app_stream_unsubscribe(&ctx->nameStream); } -void startChannelDialog(Context* ctx) { - if (ctx->channelDialogId != 0) { - return; - } - ctx->channelDialogId = inputdialog::start( - ctx->appInstanceId, - "Join channel", - "Hashtag channel name, e.g. #test", - "#", - ctx->channelStream, - ctx->channelBuffer, - sizeof(ctx->channelBuffer), - ctx->eventGroup - ); -} - -void onChannelDialogResult(Context* ctx, int32_t result) { - if (result == 0) { - char name[sizeof(Context::channelBuffer)] = {}; - const size_t length = app_stream_read(&ctx->channelStream, name, sizeof(name) - 1); - name[length] = '\0'; - const error_t error = joinHashtagChannel(name); - if (error != ERROR_NONE) { - LOG_E(TAG, "Failed to join channel %s: %s", name, error_to_string(error)); - } - } - app_stream_unsubscribe(&ctx->channelStream); -} - void onBackPressed(lv_event_t* event) { auto* ctx = static_cast(lv_event_get_user_data(event)); app_event_emit_close(ctx->appInstanceId); @@ -290,6 +347,11 @@ void onSelectRegionPressed(lv_event_t* event) { task_event_group_signal(ctx->eventGroup, ctx->selectRegionBit); } +void onSelectModemPresetPressed(lv_event_t* event) { + auto* ctx = static_cast(lv_event_get_user_data(event)); + task_event_group_signal(ctx->eventGroup, ctx->selectModemPresetBit); +} + void onSelectPresetPressed(lv_event_t* event) { auto* ctx = static_cast(lv_event_get_user_data(event)); task_event_group_signal(ctx->eventGroup, ctx->selectPresetBit); @@ -344,6 +406,143 @@ void onRadioSelected(Context* ctx, int32_t selected) { } } +void startRadioSettingSelection(Context* ctx, RadioSetting kind) { + if (ctx->radioSettingDialogId != 0) { + return; + } + if (kind == RadioSetting::Frequency) { + if (ctx->frequencyDialogId == 0) { + const std::string& current = ctx->radioSettingTexts[static_cast(kind)]; + ctx->frequencyDialogId = inputdialog::start( + ctx->appInstanceId, + RADIO_SETTING_TITLES[static_cast(kind)], + "e.g. 869.618, empty for the preset's value", + current == "-" ? "" : current.c_str(), + ctx->frequencyStream, + ctx->frequencyBuffer, + sizeof(ctx->frequencyBuffer), + ctx->eventGroup + ); + } + return; + } + if (kind == RadioSetting::RegionScope) { + if (ctx->scopeDialogId == 0) { + const std::string& current = ctx->radioSettingTexts[static_cast(kind)]; + ctx->scopeDialogId = inputdialog::start( + ctx->appInstanceId, + RADIO_SETTING_TITLES[static_cast(kind)], + "e.g. nl, * for none, empty for the preset's value", + current == "None" ? MESH_REGION_SCOPE_NONE : current.c_str(), + ctx->scopeStream, + ctx->scopeBuffer, + sizeof(ctx->scopeBuffer), + ctx->eventGroup + ); + } + return; + } + std::vector values; + switch (kind) { + case RadioSetting::RegionScope: + case RadioSetting::Frequency: + break; + case RadioSetting::PathHashSize: + values = { 1, 2, 3 }; + break; + case RadioSetting::Bandwidth: + values = { 7800, 10400, 15600, 20800, 31250, 41700, 62500, 125000, 250000, 500000 }; + break; + case RadioSetting::SpreadingFactor: + values = { 5, 6, 7, 8, 9, 10, 11, 12 }; + break; + case RadioSetting::CodingRate: + values = { 5, 6, 7, 8 }; + break; + case RadioSetting::Power: + for (int32_t power = 2; power <= 22; power++) { + values.push_back(power); + } + break; + } + // Item 0 uses the profile value + ctx->radioSettingValues = { 0 }; + ctx->radioSettingValues.insert(ctx->radioSettingValues.end(), values.begin(), values.end()); + std::vector items = { "Profile value" }; + for (const int32_t value : values) { + items.push_back(formatRadioSetting(kind, value)); + } + ctx->settingKind = kind; + ctx->radioSettingDialogId = selectiondialog::start(ctx->appInstanceId, RADIO_SETTING_TITLES[static_cast(kind)], items); +} + +void onFrequencyDialogResult(Context* ctx, int32_t result) { + if (result == 0) { + char text[sizeof(Context::frequencyBuffer)] = {}; + const size_t length = app_stream_read(&ctx->frequencyStream, text, sizeof(text) - 1); + text[length] = '\0'; + char* end = nullptr; + const double megahertz = strtod(text, &end); + const bool cleared = text[0] == '\0'; + if (cleared || (end != text && megahertz >= 100.0 && megahertz <= 1000.0)) { + MeshRadioSettings settings {}; + mesh_get_radio_settings(MESHCORE_ID, &settings); + settings.frequency = cleared ? 0 : static_cast(megahertz * 1e6 + 0.5); + const error_t error = mesh_set_radio_settings(MESHCORE_ID, &settings); + if (error != ERROR_NONE) { + LOG_E(TAG, "Failed to set the frequency: %s", error_to_string(error)); + } + } + } + app_stream_unsubscribe(&ctx->frequencyStream); +} + +void onScopeDialogResult(Context* ctx, int32_t result) { + if (result == 0) { + char text[sizeof(Context::scopeBuffer)] = {}; + const size_t length = app_stream_read(&ctx->scopeStream, text, sizeof(text) - 1); + text[length] = '\0'; + MeshNetworkSettings network {}; + mesh_get_network_settings(MESHCORE_ID, &network); + strncpy(network.region_scope, text, MESH_NAME_MAX_LENGTH); + const error_t error = mesh_set_network_settings(MESHCORE_ID, &network); + if (error != ERROR_NONE) { + LOG_E(TAG, "Failed to set the region scope: %s", error_to_string(error)); + } + } + app_stream_unsubscribe(&ctx->scopeStream); +} + +void onRadioSettingSelected(Context* ctx, int32_t selected) { + if (selected < 0 || selected >= static_cast(ctx->radioSettingValues.size())) { + return; + } + const int32_t value = ctx->radioSettingValues[selected]; + if (ctx->settingKind == RadioSetting::PathHashSize) { + MeshNetworkSettings network {}; + mesh_get_network_settings(MESHCORE_ID, &network); + network.path_hash_size = value; + mesh_set_network_settings(MESHCORE_ID, &network); + return; + } + MeshRadioSettings settings {}; + mesh_get_radio_settings(MESHCORE_ID, &settings); + switch (ctx->settingKind) { + case RadioSetting::Frequency: + case RadioSetting::PathHashSize: + case RadioSetting::RegionScope: + break; + case RadioSetting::Bandwidth: settings.bandwidth = value; break; + case RadioSetting::SpreadingFactor: settings.spreading_factor = value; break; + case RadioSetting::CodingRate: settings.coding_rate = value; break; + case RadioSetting::Power: settings.power = value; break; + } + const error_t error = mesh_set_radio_settings(MESHCORE_ID, &settings); + if (error != ERROR_NONE) { + LOG_E(TAG, "Failed to set the radio setting: %s", error_to_string(error)); + } +} + void startRegionSelection(Context* ctx) { if (ctx->regionDialogId != 0) { return; @@ -361,15 +560,16 @@ void onRegionSelected(int32_t selected) { } } -void startPresetSelection(Context* ctx) { +void startPresetSelection(Context* ctx, const char* backend) { if (ctx->presetDialogId != 0) { return; } + ctx->presetBackend = backend; ctx->presetNames.clear(); - const size_t count = mesh_get_preset_count(); + const size_t count = mesh_get_preset_count(backend); for (size_t i = 0; i < count; i++) { char name[MESH_NAME_MAX_LENGTH + 1]; - if (mesh_get_preset_name(i, name, sizeof(name)) == ERROR_NONE) { + if (mesh_get_preset_name(backend, i, name, sizeof(name)) == ERROR_NONE) { ctx->presetNames.emplace_back(name); } } @@ -380,7 +580,13 @@ void startPresetSelection(Context* ctx) { void onPresetSelected(Context* ctx, int32_t selected) { if (selected >= 0 && selected < static_cast(ctx->presetNames.size())) { - mesh_set_preset(ctx->presetNames[selected].c_str()); + const char* name = ctx->presetNames[selected].c_str(); + // A preset can need another region. It's set first so the radio never gets a preset that its region doesn't allow. + RadioRegion region = RADIO_REGION_UNSET; + if (mesh_get_preset_region(ctx->presetBackend, name, ®ion) == ERROR_NONE && region != RADIO_REGION_UNSET && region != settings::radio::loadRegion()) { + settings::radio::saveRegion(region); + } + mesh_set_preset(ctx->presetBackend, name); } } @@ -464,19 +670,47 @@ void createWidgets(lv_obj_t* parent, void* userData) { ctx->regionLabel = createTitleAndValue(regionRow, "Region"); createChangeButton(regionRow, onSelectRegionPressed, ctx); - ctx->presetRow = createRow(card); - ctx->presetLabel = createTitleAndValue(ctx->presetRow, "Preset"); - createChangeButton(ctx->presetRow, onSelectPresetPressed, ctx); - - ctx->channelsRow = createRow(card); - ctx->channelsLabel = createTitleAndValue(ctx->channelsRow, "Channels"); - auto* addButton = lv_button_create(ctx->channelsRow); - lv_label_set_text(lv_label_create(addButton), "Join"); - lv_obj_add_event_cb(addButton, onAddChannelPressed, LV_EVENT_SHORT_CLICKED, ctx); - auto* statusRow = createRow(card); ctx->statusLabel = createTitleAndValue(statusRow, "Status"); + ctx->meshCoreTitle = lv_label_create(wrapper); + lv_label_set_text(ctx->meshCoreTitle, "MeshCore"); + ctx->meshCoreCard = lvgl_card_create(wrapper); + lv_obj_set_size(ctx->meshCoreCard, LV_PCT(100), LV_SIZE_CONTENT); + lv_obj_set_flex_flow(ctx->meshCoreCard, LV_FLEX_FLOW_COLUMN); + + auto* profileRow = createRow(ctx->meshCoreCard); + ctx->profileLabel = createTitleAndValue(profileRow, "Selected preset"); + createChangeButton(profileRow, onSelectPresetPressed, ctx); + + auto* advancedRow = createRow(ctx->meshCoreCard); + auto* advancedLabel = lv_label_create(advancedRow); + lv_label_set_text(advancedLabel, "Advanced settings"); + lv_obj_set_flex_grow(advancedLabel, 1); + ctx->advancedSwitch = lv_switch_create(advancedRow); + lv_obj_set_state(ctx->advancedSwitch, LV_STATE_CHECKED, ctx->showAdvanced); + lv_obj_add_event_cb(ctx->advancedSwitch, onAdvancedSwitch, LV_EVENT_VALUE_CHANGED, ctx); + + const lv_event_cb_t radioSettingCallbacks[RADIO_SETTING_COUNT] = { + onSelectFrequencyPressed, onSelectBandwidthPressed, onSelectSpreadingFactorPressed, onSelectCodingRatePressed, onSelectPowerPressed, onSelectPathHashSizePressed, onSelectRegionScopePressed + }; + for (size_t i = 0; i < RADIO_SETTING_COUNT; i++) { + auto* row = createRow(ctx->meshCoreCard); + ctx->radioSettingRows[i] = row; + ctx->radioSettingLabels[i] = createTitleAndValue(row, RADIO_SETTING_TITLES[i]); + createChangeButton(row, radioSettingCallbacks[i], ctx); + } + + ctx->meshtasticTitle = lv_label_create(wrapper); + lv_label_set_text(ctx->meshtasticTitle, "Meshtastic"); + ctx->meshtasticCard = lvgl_card_create(wrapper); + lv_obj_set_size(ctx->meshtasticCard, LV_PCT(100), LV_SIZE_CONTENT); + lv_obj_set_flex_flow(ctx->meshtasticCard, LV_FLEX_FLOW_COLUMN); + + auto* presetRow = createRow(ctx->meshtasticCard); + ctx->presetLabel = createTitleAndValue(presetRow, "Modem preset"); + createChangeButton(presetRow, onSelectModemPresetPressed, ctx); + updateLabels(ctx); } @@ -487,10 +721,19 @@ void destroyWidgets(void* userData) { ctx->radioButton = nullptr; ctx->radioLabel = nullptr; ctx->regionLabel = nullptr; - ctx->presetRow = nullptr; + ctx->meshCoreTitle = nullptr; + ctx->meshCoreCard = nullptr; + ctx->profileLabel = nullptr; + ctx->advancedSwitch = nullptr; + for (auto& row : ctx->radioSettingRows) { + row = nullptr; + } + for (auto& label : ctx->radioSettingLabels) { + label = nullptr; + } + ctx->meshtasticTitle = nullptr; + ctx->meshtasticCard = nullptr; ctx->presetLabel = nullptr; - ctx->channelsRow = nullptr; - ctx->channelsLabel = nullptr; ctx->statusLabel = nullptr; } @@ -504,9 +747,12 @@ int32_t appMain(int argc, char* argv[]) { check(task_event_group_claim_bit(&eventGroup, &ctx.selectServiceBit) == ERROR_NONE); check(task_event_group_claim_bit(&eventGroup, &ctx.selectRegionBit) == ERROR_NONE); check(task_event_group_claim_bit(&eventGroup, &ctx.selectPresetBit) == ERROR_NONE); - check(task_event_group_claim_bit(&eventGroup, &ctx.addChannelBit) == ERROR_NONE); + check(task_event_group_claim_bit(&eventGroup, &ctx.selectModemPresetBit) == ERROR_NONE); check(task_event_group_claim_bit(&eventGroup, &ctx.changeNameBit) == ERROR_NONE); check(task_event_group_claim_bit(&eventGroup, &ctx.selectRadioBit) == ERROR_NONE); + for (auto& bit : ctx.radioSettingBits) { + check(task_event_group_claim_bit(&eventGroup, &bit) == ERROR_NONE); + } loadState(&ctx); AppEventSubscription appEvents {}; @@ -528,10 +774,10 @@ int32_t appMain(int argc, char* argv[]) { startRegionSelection(&ctx); } if (flags & ctx.selectPresetBit) { - startPresetSelection(&ctx); + startPresetSelection(&ctx, MESHCORE_ID); } - if (flags & ctx.addChannelBit) { - startChannelDialog(&ctx); + if (flags & ctx.selectModemPresetBit) { + startPresetSelection(&ctx, MESHTASTIC_ID); } if (flags & ctx.changeNameBit) { startNameDialog(&ctx); @@ -539,11 +785,16 @@ int32_t appMain(int argc, char* argv[]) { if (flags & ctx.selectRadioBit) { startRadioSelection(&ctx); } + for (size_t i = 0; i < RADIO_SETTING_COUNT; i++) { + if (flags & ctx.radioSettingBits[i]) { + startRadioSettingSelection(&ctx, static_cast(i)); + } + } bool changed = false; MeshEvent meshEvent {}; while (mesh_event_poll(&meshEvents, &meshEvent) == ERROR_NONE) { - changed |= meshEvent.type == MESH_EVENT_STATE || meshEvent.type == MESH_EVENT_CHANNEL; + changed |= meshEvent.type == MESH_EVENT_STATE; } AppEvent event {}; @@ -562,12 +813,18 @@ int32_t appMain(int argc, char* argv[]) { } else if (event.result.launch_id == ctx.presetDialogId) { ctx.presetDialogId = 0; onPresetSelected(&ctx, event.result.result); - } else if (event.result.launch_id == ctx.channelDialogId) { - ctx.channelDialogId = 0; - onChannelDialogResult(&ctx, event.result.result); } else if (event.result.launch_id == ctx.radioDialogId) { ctx.radioDialogId = 0; onRadioSelected(&ctx, event.result.result); + } else if (event.result.launch_id == ctx.scopeDialogId) { + ctx.scopeDialogId = 0; + onScopeDialogResult(&ctx, event.result.result); + } else if (event.result.launch_id == ctx.frequencyDialogId) { + ctx.frequencyDialogId = 0; + onFrequencyDialogResult(&ctx, event.result.result); + } else if (event.result.launch_id == ctx.radioSettingDialogId) { + ctx.radioSettingDialogId = 0; + onRadioSettingSelected(&ctx, event.result.result); } else if (event.result.launch_id == ctx.nameDialogId) { ctx.nameDialogId = 0; onNameDialogResult(&ctx, event.result.result); diff --git a/TactilityKernel/include/tactility/drivers/radio.h b/TactilityKernel/include/tactility/drivers/radio.h index 649f52f54..a761cb354 100644 --- a/TactilityKernel/include/tactility/drivers/radio.h +++ b/TactilityKernel/include/tactility/drivers/radio.h @@ -129,6 +129,7 @@ enum RadioRegion { RADIO_REGION_EU_868, RADIO_REGION_US_915, RADIO_REGION_AU_915, + RADIO_REGION_AU_923, RADIO_REGION_NZ_865, RADIO_REGION_IN_865, RADIO_REGION_RU_868, From cadd8343bd71b5e6ca7ca6f07b22a3a825e082df Mon Sep 17 00:00:00 2001 From: Ken Van Hoeylandt Date: Thu, 29 Oct 2026 22:53:59 +0100 Subject: [PATCH 02/11] Fix for test crash --- TactilityKernel/include/tactility/drivers/radio.h | 1 - 1 file changed, 1 deletion(-) diff --git a/TactilityKernel/include/tactility/drivers/radio.h b/TactilityKernel/include/tactility/drivers/radio.h index a761cb354..649f52f54 100644 --- a/TactilityKernel/include/tactility/drivers/radio.h +++ b/TactilityKernel/include/tactility/drivers/radio.h @@ -129,7 +129,6 @@ enum RadioRegion { RADIO_REGION_EU_868, RADIO_REGION_US_915, RADIO_REGION_AU_915, - RADIO_REGION_AU_923, RADIO_REGION_NZ_865, RADIO_REGION_IN_865, RADIO_REGION_RU_868, From a0af6f698aad7a4387a07ec39678487c5f3ae74f Mon Sep 17 00:00:00 2001 From: Ken Van Hoeylandt Date: Thu, 29 Oct 2026 22:54:09 +0100 Subject: [PATCH 03/11] Increase wifi buffer (SPIRAM) --- device.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/device.py b/device.py index 1617a64ec..31a626856 100755 --- a/device.py +++ b/device.py @@ -252,6 +252,8 @@ def write_spiram_variables(output_file, device_properties: dict): # Reduce IRAM usage output_file.write("CONFIG_SPIRAM_USE_MALLOC=y\n") output_file.write("CONFIG_SPIRAM_TRY_ALLOCATE_WIFI_LWIP=y\n") + # With 5 buffers, HTTPS downloads stall for seconds on some access points. These buffers are allocated in SPIRAM. + output_file.write("CONFIG_ESP_WIFI_DYNAMIC_RX_BUFFER_NUM=16\n") # TLS sessions (handshakes, record buffers) otherwise take internal RAM output_file.write("CONFIG_MBEDTLS_EXTERNAL_MEM_ALLOC=y\n") # Performance improvements From 27e40ccae4fc8e55482983d216888f387b00814d Mon Sep 17 00:00:00 2001 From: Ken Van Hoeylandt Date: Thu, 29 Oct 2026 22:55:30 +0100 Subject: [PATCH 04/11] Fix for incompatible region selection --- .../mesh-meshcore-module/source/meshcore_backend.cpp | 3 +++ .../tests/source/protocol_test.cpp | 11 +++++++++++ 2 files changed, 14 insertions(+) diff --git a/Modules/mesh-meshcore-module/source/meshcore_backend.cpp b/Modules/mesh-meshcore-module/source/meshcore_backend.cpp index aef7d0bbd..f10980ffa 100644 --- a/Modules/mesh-meshcore-module/source/meshcore_backend.cpp +++ b/Modules/mesh-meshcore-module/source/meshcore_backend.cpp @@ -506,6 +506,9 @@ error_t getRadioProfile(void*, RadioRegion region, RadioModulation modulation, s codingRate = match->codingRate; } else if (preset <= PROFILES.size()) { const Profile& match = PROFILES[preset - 1]; + if (!fitsRegion(match.frequency, match.bandwidth, region)) { + return ERROR_NOT_SUPPORTED; + } frequency = match.frequency; bandwidth = match.bandwidth; spreadingFactor = match.spreadingFactor; diff --git a/Modules/mesh-meshcore-module/tests/source/protocol_test.cpp b/Modules/mesh-meshcore-module/tests/source/protocol_test.cpp index ab10894b9..c8e0d0908 100644 --- a/Modules/mesh-meshcore-module/tests/source/protocol_test.cpp +++ b/Modules/mesh-meshcore-module/tests/source/protocol_test.cpp @@ -205,3 +205,14 @@ TEST_CASE("a region scope limits a packet with a code that other MeshCore nodes packet.payloadLength = 0; CHECK_EQ(meshcore_transport_code(key, packet), 0xF329); } + +TEST_CASE("a MeshCore preset only gives a radio profile in a region that allows its frequency") { + size_t netherlands = 0; + while (strcmp(meshcore_backend_api.presets[netherlands], "Netherlands") != 0) { + netherlands++; + } + MeshRadioProfile profile {}; + CHECK_EQ(meshcore_backend_api.get_radio_profile(nullptr, RADIO_REGION_US_915, RADIO_MODULATION_LORA, netherlands, &profile), ERROR_NOT_SUPPORTED); + CHECK_EQ(meshcore_backend_api.get_radio_profile(nullptr, RADIO_REGION_EU_868, RADIO_MODULATION_LORA, netherlands, &profile), ERROR_NONE); + CHECK_EQ(profile.frequency, 869618000); +} From 36d2275f0d57a5b6d3a597f779f982bed543ad1c Mon Sep 17 00:00:00 2001 From: Ken Van Hoeylandt Date: Thu, 29 Oct 2026 23:15:17 +0100 Subject: [PATCH 05/11] Fix for launcher app sorting --- Tactility/Source/app/launcher/AppsMode.cpp | 3 ++- Tactility/Source/app/launcher/SettingsMode.cpp | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/Tactility/Source/app/launcher/AppsMode.cpp b/Tactility/Source/app/launcher/AppsMode.cpp index ebd3db2a0..71ba4d1b0 100644 --- a/Tactility/Source/app/launcher/AppsMode.cpp +++ b/Tactility/Source/app/launcher/AppsMode.cpp @@ -11,6 +11,7 @@ #include #include +#include #include #include @@ -110,7 +111,7 @@ std::vector collectItems() { if (a.highlighted != b.highlighted) { return a.highlighted; } - return a.name < b.name; + return strcasecmp(a.name.c_str(), b.name.c_str()) < 0; }); return items; } diff --git a/Tactility/Source/app/launcher/SettingsMode.cpp b/Tactility/Source/app/launcher/SettingsMode.cpp index 35cfdc0be..982c7f72a 100644 --- a/Tactility/Source/app/launcher/SettingsMode.cpp +++ b/Tactility/Source/app/launcher/SettingsMode.cpp @@ -7,6 +7,7 @@ #include #include +#include namespace tt::app::launcher { @@ -52,7 +53,7 @@ std::vector collectItems() { std::vector<::AppManifest> collected; app_manager_for_each_manifest(collectManifest, &collected); std::ranges::sort(collected, [](const ::AppManifest& a, const ::AppManifest& b) { - return strcmp(a.name, b.name) < 0; + return strcasecmp(a.name, b.name) < 0; }); std::vector items; From 5bc3fafe7e612ec0cf2654c6888b1d7681927023 Mon Sep 17 00:00:00 2001 From: Ken Van Hoeylandt Date: Thu, 29 Oct 2026 23:15:24 +0100 Subject: [PATCH 06/11] Fix for stackoverflows --- Tactility/Source/app/meshchat/MeshConversation.cpp | 2 +- Tactility/Source/app/meshsettings/MeshSettings.cpp | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Tactility/Source/app/meshchat/MeshConversation.cpp b/Tactility/Source/app/meshchat/MeshConversation.cpp index 6b198c75f..81d79f7af 100644 --- a/Tactility/Source/app/meshchat/MeshConversation.cpp +++ b/Tactility/Source/app/meshchat/MeshConversation.cpp @@ -479,7 +479,7 @@ extern const ::AppManifest conversationManifest = { .category = APP_CATEGORY_USER, .location = { APP_LOCATION_MEMORY, reinterpret_cast(appMain) }, .flags = APP_MANIFEST_FLAG_HIDDEN, - .stack = { .depth = 5120, .desired_memory_capability = 0 }, + .stack = { .depth = 6144, .desired_memory_capability = 0 }, }; } // namespace tt::app::meshchat diff --git a/Tactility/Source/app/meshsettings/MeshSettings.cpp b/Tactility/Source/app/meshsettings/MeshSettings.cpp index c5a945ad0..e3194cee6 100644 --- a/Tactility/Source/app/meshsettings/MeshSettings.cpp +++ b/Tactility/Source/app/meshsettings/MeshSettings.cpp @@ -925,7 +925,7 @@ extern const ::AppManifest manifest = { .category = APP_CATEGORY_SETTINGS, .location = { .type = APP_LOCATION_MEMORY, .location = reinterpret_cast(appMain) }, .flags = 0, - .stack = { .depth = 6144, .desired_memory_capability = 0 }, + .stack = { .depth = 7168, .desired_memory_capability = 0 }, }; } // namespace tt::app::meshsettings From ce8e504545eed50edd157201887dd7a6f66a7bef Mon Sep 17 00:00:00 2001 From: Ken Van Hoeylandt Date: Thu, 29 Oct 2026 23:17:25 +0100 Subject: [PATCH 07/11] Add support for removal/adding of Public MeshCore channel --- Modules/mesh-meshcore-module/README.md | 5 +- .../source/meshcore_backend.cpp | 12 +++- .../source/meshtastic_backend.cpp | 60 +++++++++++-------- Modules/mesh-module/include/mesh/backend.h | 3 +- Modules/mesh-module/include/mesh/mesh.h | 4 +- Modules/mesh-module/source/mesh_service.cpp | 3 - .../mesh-module/tests/source/service_test.cpp | 4 +- .../Source/app/meshchat/MeshChannels.cpp | 4 +- .../Source/app/meshchat/MeshConversation.cpp | 8 +-- .../Source/app/meshsettings/MeshSettings.cpp | 10 +++- 10 files changed, 66 insertions(+), 47 deletions(-) diff --git a/Modules/mesh-meshcore-module/README.md b/Modules/mesh-meshcore-module/README.md index a258c2880..e06abf8c7 100644 --- a/Modules/mesh-meshcore-module/README.md +++ b/Modules/mesh-meshcore-module/README.md @@ -7,8 +7,9 @@ Supported: - Identity: an Ed25519 key pair created on first start and stored in the backend's data directory. - Adverts: sent at start and every 12 hours, received adverts are verified and remembered as contacts. -- Channel messages (`GRP_TXT`): the "Public" channel is preconfigured, more channels can be added with - `mesh_set_channel()` (16-byte keys). +- Channel messages (`GRP_TXT`): the "Public" channel is preconfigured on a new node. It can be removed, and added again with + `mesh_set_channel()` and the name "Public" without a key. More channels can be added with `mesh_set_channel()` + (16-byte keys, or hashtag names without a key). - Direct messages (`TXT_MSG`) to and from known contacts, sent as flood packets. Received messages are acknowledged, and acknowledgements of sent messages mark them as delivered. diff --git a/Modules/mesh-meshcore-module/source/meshcore_backend.cpp b/Modules/mesh-meshcore-module/source/meshcore_backend.cpp index f10980ffa..df469fff0 100644 --- a/Modules/mesh-meshcore-module/source/meshcore_backend.cpp +++ b/Modules/mesh-meshcore-module/source/meshcore_backend.cpp @@ -50,6 +50,8 @@ struct PendingAck { uint64_t deadlineMs; }; +constexpr auto* PUBLIC_CHANNEL_NAME = "Public"; + struct Backend { MeshHost* host = nullptr; MeshCoreIdentity identity {}; @@ -154,7 +156,9 @@ void loadChannels(Backend* backend) { tt::std::vector data; // Record: index (1) + name (32) + key (16) constexpr size_t RECORD_SIZE = 1 + MESH_NAME_MAX_LENGTH + 1 + 16; - if (getPath(backend, "channels.bin", path, sizeof(path)) && readFile(path, data)) { + // Only a node that never saved its channels starts with the Public channel, so that it can be removed + const bool saved = getPath(backend, "channels.bin", path, sizeof(path)) && readFile(path, data); + if (saved) { for (size_t offset = 0; offset + RECORD_SIZE <= data.size(); offset += RECORD_SIZE) { char name[MESH_NAME_MAX_LENGTH + 1]; memcpy(name, data.data() + offset + 1, sizeof(name)); @@ -162,8 +166,8 @@ void loadChannels(Backend* backend) { addChannel(backend, data[offset], name, data.data() + offset + 1 + sizeof(name)); } } - if (backend->channels.empty()) { - addChannel(backend, 0, "Public", MESHCORE_PUBLIC_CHANNEL_KEY); + if (!saved) { + addChannel(backend, 0, PUBLIC_CHANNEL_NAME, MESHCORE_PUBLIC_CHANNEL_KEY); } } @@ -656,6 +660,8 @@ error_t setChannel(void* context, const MeshChannel* channel) { memcpy(key, channel->key, sizeof(key)); } else if (channel->key_length == 0 && channel->name[0] == '#') { meshcore_hashtag_channel_key(channel->name, key); + } else if (channel->key_length == 0 && strcmp(channel->name, PUBLIC_CHANNEL_NAME) == 0) { + memcpy(key, MESHCORE_PUBLIC_CHANNEL_KEY, sizeof(key)); } else { return ERROR_INVALID_ARGUMENT; } diff --git a/Modules/mesh-meshtastic-module/source/meshtastic_backend.cpp b/Modules/mesh-meshtastic-module/source/meshtastic_backend.cpp index 505144bf2..e97ea3b99 100644 --- a/Modules/mesh-meshtastic-module/source/meshtastic_backend.cpp +++ b/Modules/mesh-meshtastic-module/source/meshtastic_backend.cpp @@ -91,6 +91,8 @@ struct Channel { char name[MESH_NAME_MAX_LENGTH + 1]; MeshtasticChannelKey key; uint8_t hash; + /** The primary channel that was never customised is named after the selected preset, like other nodes name it */ + bool followsPreset; }; struct Backend { @@ -178,17 +180,30 @@ void loadOrCreateNodeNum(Backend* backend) { } } -void addChannel(Backend* backend, uint8_t index, const char* name, const MeshtasticChannelKey& key) { +void addChannel(Backend* backend, uint8_t index, const char* name, const MeshtasticChannelKey& key, bool followsPreset = false) { Channel channel {}; channel.index = index; strncpy(channel.name, name, MESH_NAME_MAX_LENGTH); channel.key = key; channel.hash = meshtastic_channel_hash(name, key); + channel.followsPreset = followsPreset; std::erase_if(backend->channels, [index](const Channel& existing) { return existing.index == index; }); backend->channels.push_back(channel); std::sort(backend->channels.begin(), backend->channels.end(), [](const Channel& a, const Channel& b) { return a.index < b.index; }); } +MeshtasticChannelKey getDefaultKey() { + const uint8_t defaultPsk = 1; + MeshtasticChannelKey key {}; + meshtastic_expand_psk(&defaultPsk, 1, &key); + return key; +} + +bool isDefaultKey(const MeshtasticChannelKey& key) { + const MeshtasticChannelKey defaultKey = getDefaultKey(); + return key.length == defaultKey.length && memcmp(key.bytes, defaultKey.bytes, defaultKey.length) == 0; +} + // Record: index (1) + name (32) + key length (1) + key (32) constexpr size_t CHANNEL_RECORD_SIZE = 1 + MESH_NAME_MAX_LENGTH + 1 + 1 + 32; @@ -204,15 +219,14 @@ void loadChannels(Backend* backend) { key.length = data[offset + 1 + sizeof(name)]; memcpy(key.bytes, data.data() + offset + 2 + sizeof(name), 32); if (key.length == 16 || key.length == 32) { - addChannel(backend, data[offset], name, key); + // A primary channel without a name and with the default key is the one that follows the preset + const bool followsPreset = data[offset] == 0 && name[0] == '\0' && isDefaultKey(key); + addChannel(backend, data[offset], followsPreset ? DEFAULT_CHANNEL_NAME : name, key, followsPreset); } } } if (backend->channels.empty()) { - const uint8_t defaultPsk = 1; - MeshtasticChannelKey key {}; - meshtastic_expand_psk(&defaultPsk, 1, &key); - addChannel(backend, 0, DEFAULT_CHANNEL_NAME, key); + addChannel(backend, 0, DEFAULT_CHANNEL_NAME, getDefaultKey(), true); } } @@ -224,7 +238,9 @@ void saveChannels(Backend* backend) { tt::std::vector data; for (const auto& channel : backend->channels) { data.push_back(channel.index); - data.insert(data.end(), channel.name, channel.name + MESH_NAME_MAX_LENGTH + 1); + const char emptyName[MESH_NAME_MAX_LENGTH + 1] = {}; + const char* name = channel.followsPreset ? emptyName : channel.name; + data.insert(data.end(), name, name + MESH_NAME_MAX_LENGTH + 1); data.push_back(channel.key.length); data.insert(data.end(), channel.key.bytes, channel.key.bytes + 32); } @@ -369,26 +385,16 @@ void destroy(void* context) { tt::deleteObject(static_cast(context)); } -/** The primary channel of a node that never customised it is named after the preset, which keeps it compatible with other nodes on that preset */ -void renameDefaultPrimaryChannel(Backend* backend, const char* presetName) { - Channel* primary = nullptr; +/** The primary channel that was never customised takes the name of the selected preset. It isn't saved, so a channel that was configured with a preset's name keeps its name. */ +void followPreset(Backend* backend, const char* presetName) { for (auto& channel : backend->channels) { - if (channel.index == 0) { - primary = &channel; + if (channel.index == 0 && channel.followsPreset && strcmp(channel.name, presetName) != 0) { + // Copied because addChannel() replaces the channel + const MeshtasticChannelKey key = channel.key; + addChannel(backend, 0, presetName, key, true); + return; } } - if (primary == nullptr || strcmp(primary->name, presetName) == 0) { - return; - } - const uint8_t defaultPsk = 1; - MeshtasticChannelKey defaultKey {}; - meshtastic_expand_psk(&defaultPsk, 1, &defaultKey); - const bool isDefaultKey = primary->key.length == defaultKey.length && memcmp(primary->key.bytes, defaultKey.bytes, defaultKey.length) == 0; - const bool isPresetName = std::any_of(MODEM_PRESETS.begin(), MODEM_PRESETS.end(), [primary](const ModemPreset& preset) { return strcmp(preset.name, primary->name) == 0; }); - if (isDefaultKey && isPresetName) { - addChannel(backend, 0, presetName, defaultKey); - saveChannels(backend); - } } error_t getRadioProfile(void* context, RadioRegion region, RadioModulation modulation, size_t presetIndex, MeshRadioProfile* profile) { @@ -406,7 +412,7 @@ error_t getRadioProfile(void* context, RadioRegion region, RadioModulation modul } // Without a backend there are no channels, and the preset's own name selects the slot if (backend != nullptr) { - renameDefaultPrimaryChannel(backend, preset.name); + followPreset(backend, preset.name); } // The slot that the primary channel's name selects const Channel* primary = (backend != nullptr) ? findChannel(backend, 0) : nullptr; @@ -560,6 +566,10 @@ error_t setChannel(void* context, const MeshChannel* channel) { error_t removeChannel(void* context, uint8_t index) { auto* backend = static_cast(context); + // Meshtastic needs the primary channel to place the node in a frequency slot + if (index == 0) { + return ERROR_INVALID_ARGUMENT; + } const size_t removed = std::erase_if(backend->channels, [index](const Channel& existing) { return existing.index == index; }); if (removed == 0) { return ERROR_NOT_FOUND; diff --git a/Modules/mesh-module/include/mesh/backend.h b/Modules/mesh-module/include/mesh/backend.h index 05d698b48..96f211fb7 100644 --- a/Modules/mesh-module/include/mesh/backend.h +++ b/Modules/mesh-module/include/mesh/backend.h @@ -106,8 +106,9 @@ struct MeshBackendApi { void (*on_node_name_changed)(void* backend); /** - * Optional. The service doesn't call it for channel 0. + * Optional. * @retval ERROR_NOT_FOUND when there is no such channel + * @retval ERROR_INVALID_ARGUMENT when the backend can't do without the channel */ error_t (*remove_channel)(void* backend, uint8_t index); }; diff --git a/Modules/mesh-module/include/mesh/mesh.h b/Modules/mesh-module/include/mesh/mesh.h index 6cb9f4284..eb54bd050 100644 --- a/Modules/mesh-module/include/mesh/mesh.h +++ b/Modules/mesh-module/include/mesh/mesh.h @@ -396,9 +396,9 @@ error_t mesh_get_channels(struct MeshChannel* channels, size_t max_count, size_t error_t mesh_set_channel(const struct MeshChannel* channel); /** - * Leave a channel. The stored messages remain. Channel 0 is the primary channel and can't be removed. + * Leave a channel. The stored messages remain. * @retval ERROR_NOT_FOUND when there is no such channel - * @retval ERROR_INVALID_ARGUMENT for channel 0 + * @retval ERROR_INVALID_ARGUMENT when the backend can't do without the channel, e.g. the primary channel of Meshtastic * @retval ERROR_NOT_SUPPORTED when the backend has no channels */ error_t mesh_remove_channel(uint8_t index); diff --git a/Modules/mesh-module/source/mesh_service.cpp b/Modules/mesh-module/source/mesh_service.cpp index bf73d2972..4239332ec 100644 --- a/Modules/mesh-module/source/mesh_service.cpp +++ b/Modules/mesh-module/source/mesh_service.cpp @@ -1938,9 +1938,6 @@ error_t mesh_set_channel(const MeshChannel* channel) { } error_t mesh_remove_channel(uint8_t index) { - if (index == 0) { - return ERROR_INVALID_ARGUMENT; - } error_t result = ERROR_INVALID_STATE; const error_t callResult = callOnServiceThread([&](MeshHost* host) { if (host->api == nullptr) { diff --git a/Modules/mesh-module/tests/source/service_test.cpp b/Modules/mesh-module/tests/source/service_test.cpp index 8a1d31883..6ee30177a 100644 --- a/Modules/mesh-module/tests/source/service_test.cpp +++ b/Modules/mesh-module/tests/source/service_test.cpp @@ -675,8 +675,8 @@ TEST_CASE("mesh service sends and receives through the active backend") { CHECK_EQ(mesh_conversation_get(&alice, 0, 0, conversation, 4, &count), ERROR_NONE); CHECK_EQ(count, 1); - // The primary channel stays, and the loopback backend has no channels - CHECK_EQ(mesh_remove_channel(0), ERROR_INVALID_ARGUMENT); + // The loopback backend has no channels + CHECK_EQ(mesh_remove_channel(0), ERROR_NOT_SUPPORTED); CHECK_EQ(mesh_remove_channel(2), ERROR_NOT_SUPPORTED); CHECK_EQ(mesh_cancel(seq), ERROR_INVALID_STATE); diff --git a/Tactility/Source/app/meshchat/MeshChannels.cpp b/Tactility/Source/app/meshchat/MeshChannels.cpp index a42b4513e..3e78f14ba 100644 --- a/Tactility/Source/app/meshchat/MeshChannels.cpp +++ b/Tactility/Source/app/meshchat/MeshChannels.cpp @@ -200,7 +200,7 @@ void startJoinChannel(Context* ctx) { ctx->joinChannelDialogId = inputdialog::start( ctx->appInstanceId, "Join channel", - "Hashtag channel name, e.g. #test", + "Hashtag channel name, e.g. #test, or Public", "#", ctx->channelStream, ctx->channelBuffer, @@ -216,7 +216,7 @@ void onJoinChannelResult(Context* ctx, int32_t result) { name[length] = '\0'; const error_t error = meshsettings::joinHashtagChannel(name); if (error == ERROR_INVALID_ARGUMENT) { - showMessage(ctx, "Couldn't join channel", "Use a channel name that starts with #. This mesh network can't join a channel by name."); + showMessage(ctx, "Couldn't join channel", "Use a channel name that starts with # or Public. This mesh network can't join a channel by name."); } else if (error != ERROR_NONE) { LOG_E(TAG, "Failed to join channel %s: %s", name, error_to_string(error)); showMessage(ctx, "Couldn't join channel", error == ERROR_INVALID_STATE ? getNotReadyText().c_str() : error_to_string(error)); diff --git a/Tactility/Source/app/meshchat/MeshConversation.cpp b/Tactility/Source/app/meshchat/MeshConversation.cpp index 81d79f7af..a097b339d 100644 --- a/Tactility/Source/app/meshchat/MeshConversation.cpp +++ b/Tactility/Source/app/meshchat/MeshConversation.cpp @@ -201,10 +201,7 @@ void createWidgets(lv_obj_t* parent, void* userData) { if (ctx->isPrivateChannel) { addIconAction(toolbar, LVGL_ICON_SHARED_INFO, onInfoPressed, ctx); } - // The primary channel can't be left - if (ctx->isContact || ctx->channel != 0) { - addIconAction(toolbar, LVGL_ICON_SHARED_PERSON_REMOVE, onRemovePressed, ctx); - } + addIconAction(toolbar, LVGL_ICON_SHARED_PERSON_REMOVE, onRemovePressed, ctx); ctx->statusLabel = lv_label_create(parent); lv_obj_set_width(ctx->statusLabel, LV_PCT(100)); @@ -366,7 +363,8 @@ bool onRemoveResult(Context* ctx, int32_t result) { const error_t error = ctx->isContact ? mesh_remove_contact(&ctx->peer) : mesh_remove_channel(ctx->channel); if (error != ERROR_NONE && error != ERROR_NOT_FOUND) { LOG_E(TAG, "Failed to remove: %s", error_to_string(error)); - ctx->messageDialogId = alertdialog::start(ctx->appInstanceId, "Couldn't remove", error_to_string(error)); + const char* reason = (error == ERROR_INVALID_ARGUMENT) ? "This channel can't be left." : error_to_string(error); + ctx->messageDialogId = alertdialog::start(ctx->appInstanceId, "Couldn't remove", reason); return false; } return true; diff --git a/Tactility/Source/app/meshsettings/MeshSettings.cpp b/Tactility/Source/app/meshsettings/MeshSettings.cpp index e3194cee6..3f2e154bc 100644 --- a/Tactility/Source/app/meshsettings/MeshSettings.cpp +++ b/Tactility/Source/app/meshsettings/MeshSettings.cpp @@ -851,7 +851,7 @@ int32_t appMain(int argc, char* argv[]) { } // namespace -/** A new channel gets the next free index, and a channel with the same name keeps its index */ +/** A new channel gets the next free index after the primary one, and a channel with the same name keeps its index */ static error_t findChannelIndex(const char* name, uint8_t* index) { MeshChannel channels[8]; size_t count = 0; @@ -859,14 +859,20 @@ static error_t findChannelIndex(const char* name, uint8_t* index) { if (error != ERROR_NONE) { return error; } - *index = 0; + *index = 1; + bool primaryTaken = false; for (size_t i = 0; i < count; i++) { if (strcmp(channels[i].name, name) == 0) { *index = channels[i].index; return ERROR_NONE; } + primaryTaken |= channels[i].index == 0; *index = std::max(*index, channels[i].index + 1); } + // The Public channel of MeshCore takes the primary index again when it was removed + if (!primaryTaken && strcmp(name, "Public") == 0) { + *index = 0; + } return ERROR_NONE; } From 913b6da3e5c897cc1c1b9cbd2289f0116d03dcb1 Mon Sep 17 00:00:00 2001 From: Ken Van Hoeylandt Date: Thu, 29 Oct 2026 23:22:17 +0100 Subject: [PATCH 08/11] Fixes --- .../private/mesh_meshcore/meshcore_crypto.h | 7 +- .../private/mesh_meshcore/meshcore_protocol.h | 3 +- .../source/meshcore_backend.cpp | 24 ++++-- .../source/meshcore_crypto.cpp | 7 +- .../source/meshcore_protocol.cpp | 16 ++-- .../tests/source/protocol_test.cpp | 7 +- Modules/mesh-module/include/mesh/mesh.h | 2 + .../mesh-module/private/mesh/mesh_settings.h | 9 ++- Modules/mesh-module/source/mesh_service.cpp | 74 +++++++++++++++---- Modules/mesh-module/source/mesh_settings.cpp | 70 +++++++++++++++--- .../Source/app/meshsettings/MeshSettings.cpp | 5 +- 11 files changed, 176 insertions(+), 48 deletions(-) diff --git a/Modules/mesh-meshcore-module/private/mesh_meshcore/meshcore_crypto.h b/Modules/mesh-meshcore-module/private/mesh_meshcore/meshcore_crypto.h index bdf9b5d42..12e933db2 100644 --- a/Modules/mesh-meshcore-module/private/mesh_meshcore/meshcore_crypto.h +++ b/Modules/mesh-meshcore-module/private/mesh_meshcore/meshcore_crypto.h @@ -12,8 +12,11 @@ constexpr size_t MESHCORE_CIPHER_KEY_SIZE = 16; constexpr size_t MESHCORE_CIPHER_MAC_SIZE = 2; constexpr size_t MESHCORE_CIPHER_BLOCK_SIZE = 16; -/** HMAC-SHA256 with a key of any length */ -void meshcore_hmac_sha256(const uint8_t* key, size_t key_length, const uint8_t* data, size_t length, uint8_t mac[32]); +/** + * HMAC-SHA256 with a key of any length + * @return false when the MAC couldn't be computed, in which case @a mac is all zeros + */ +bool meshcore_hmac_sha256(const uint8_t* key, size_t key_length, const uint8_t* data, size_t length, uint8_t mac[32]); void meshcore_sha256(uint8_t* hash, size_t hash_size, const uint8_t* data, size_t length); diff --git a/Modules/mesh-meshcore-module/private/mesh_meshcore/meshcore_protocol.h b/Modules/mesh-meshcore-module/private/mesh_meshcore/meshcore_protocol.h index 3f7a3e666..941e2b7a2 100644 --- a/Modules/mesh-meshcore-module/private/mesh_meshcore/meshcore_protocol.h +++ b/Modules/mesh-meshcore-module/private/mesh_meshcore/meshcore_protocol.h @@ -58,8 +58,9 @@ void meshcore_scope_key(const char* name, uint8_t key[16]); /** * The code that identifies a packet as part of a region scope: the first 2 bytes of an HMAC-SHA256 over the payload type * and the payload, keyed with the scope's key. 0x0000 and 0xFFFF are reserved, so they are moved to the nearest other code. + * @return false when the HMAC couldn't be computed, in which case @a code is not set */ -uint16_t meshcore_transport_code(const uint8_t key[16], const MeshCorePacket& packet); +bool meshcore_transport_code(const uint8_t key[16], const MeshCorePacket& packet, uint16_t* code); /** * Build the URI that MeshCore apps scan as a QR code to add a contact: diff --git a/Modules/mesh-meshcore-module/source/meshcore_backend.cpp b/Modules/mesh-meshcore-module/source/meshcore_backend.cpp index df469fff0..ab56e6deb 100644 --- a/Modules/mesh-meshcore-module/source/meshcore_backend.cpp +++ b/Modules/mesh-meshcore-module/source/meshcore_backend.cpp @@ -203,11 +203,15 @@ Contact* findContact(Backend* backend, const uint8_t publicKey[MESHCORE_PUBLIC_K return nullptr; } -/** Flooded packets get the path hash size of the network, and a code that limits them to the region scope */ -MeshCorePacket applyNetwork(Backend* backend, const MeshCorePacket& original) { +/** + * Flooded packets get the path hash size of the network, and a code that limits them to the region scope. + * @return false when the scope's code couldn't be computed, because sending the packet without it would leave the scope + */ +bool applyNetwork(Backend* backend, const MeshCorePacket& original, MeshCorePacket* result) { MeshCorePacket packet = original; if (packet.route != MeshCoreRoute::Flood) { - return packet; + *result = packet; + return true; } MeshNetworkSettings network; mesh_host_get_network(backend->host, &network); @@ -215,15 +219,23 @@ MeshCorePacket applyNetwork(Backend* backend, const MeshCorePacket& original) { if (network.region_scope[0] != '\0') { uint8_t key[16]; meshcore_scope_key(network.region_scope, key); + uint16_t code; + if (!meshcore_transport_code(key, packet, &code)) { + return false; + } packet.route = MeshCoreRoute::TransportFlood; - packet.transportCodes[0] = meshcore_transport_code(key, packet); + packet.transportCodes[0] = code; packet.transportCodes[1] = 0; } - return packet; + *result = packet; + return true; } bool transmit(Backend* backend, const MeshCorePacket& original, uint32_t seq) { - const MeshCorePacket packet = applyNetwork(backend, original); + MeshCorePacket packet; + if (!applyNetwork(backend, original, &packet)) { + return false; + } uint8_t frame[FRAME_SIZE]; const size_t length = meshcore_packet_serialize(packet, frame, sizeof(frame)); if (length == 0) { diff --git a/Modules/mesh-meshcore-module/source/meshcore_crypto.cpp b/Modules/mesh-meshcore-module/source/meshcore_crypto.cpp index cb75376c4..b728c0c60 100644 --- a/Modules/mesh-meshcore-module/source/meshcore_crypto.cpp +++ b/Modules/mesh-meshcore-module/source/meshcore_crypto.cpp @@ -27,6 +27,7 @@ psa_key_id_t importKey(psa_key_type_t type, psa_algorithm_t algorithm, psa_key_u } void hmac(const uint8_t secret[MESHCORE_SECRET_SIZE], const uint8_t* data, size_t length, uint8_t mac[32]) { + // On failure the MAC is all zeros, which doesn't match any valid MAC meshcore_hmac_sha256(secret, MESHCORE_SECRET_SIZE, data, length, mac); } @@ -52,14 +53,16 @@ bool aesEcb(const uint8_t secret[MESHCORE_SECRET_SIZE], bool encrypt, const uint } // namespace -void meshcore_hmac_sha256(const uint8_t* secret, size_t secret_length, const uint8_t* data, size_t length, uint8_t mac[32]) { +bool meshcore_hmac_sha256(const uint8_t* secret, size_t secret_length, const uint8_t* data, size_t length, uint8_t mac[32]) { const auto algorithm = PSA_ALG_HMAC(PSA_ALG_SHA_256); const psa_key_id_t key = importKey(PSA_KEY_TYPE_HMAC, algorithm, PSA_KEY_USAGE_SIGN_MESSAGE, secret, secret_length); size_t macLength = 0; - if (key == PSA_KEY_ID_NULL || psa_mac_compute(key, algorithm, data, length, mac, 32, &macLength) != PSA_SUCCESS) { + const bool ok = key != PSA_KEY_ID_NULL && psa_mac_compute(key, algorithm, data, length, mac, 32, &macLength) == PSA_SUCCESS; + if (!ok) { memset(mac, 0, 32); } psa_destroy_key(key); + return ok; } void meshcore_sha256(uint8_t* hash, size_t hash_size, const uint8_t* data, size_t length) { diff --git a/Modules/mesh-meshcore-module/source/meshcore_protocol.cpp b/Modules/mesh-meshcore-module/source/meshcore_protocol.cpp index b917283ce..3bd7c767d 100644 --- a/Modules/mesh-meshcore-module/source/meshcore_protocol.cpp +++ b/Modules/mesh-meshcore-module/source/meshcore_protocol.cpp @@ -68,17 +68,21 @@ void meshcore_scope_key(const char* name, uint8_t key[16]) { meshcore_hashtag_channel_key(hashtag, key); } -uint16_t meshcore_transport_code(const uint8_t key[16], const MeshCorePacket& packet) { +bool meshcore_transport_code(const uint8_t key[16], const MeshCorePacket& packet, uint16_t* code) { uint8_t message[1 + MESHCORE_MAX_PAYLOAD_SIZE]; message[0] = static_cast(packet.payloadType); memcpy(message + 1, packet.payload, packet.payloadLength); uint8_t mac[32]; - meshcore_hmac_sha256(key, 16, message, 1 + packet.payloadLength, mac); - const uint16_t code = static_cast(mac[0] | (mac[1] << 8)); - if (code == 0x0000) { - return 0x0001; + if (!meshcore_hmac_sha256(key, 16, message, 1 + packet.payloadLength, mac)) { + return false; + } + const uint16_t value = static_cast(mac[0] | (mac[1] << 8)); + if (value == 0x0000) { + *code = 0x0001; + } else { + *code = (value == 0xFFFF) ? 0xFFFE : value; } - return (code == 0xFFFF) ? 0xFFFE : code; + return true; } void meshcore_shared_secret(const MeshCoreIdentity& identity, const uint8_t peerPublicKey[MESHCORE_PUBLIC_KEY_SIZE], uint8_t secret[MESHCORE_SECRET_SIZE]) { diff --git a/Modules/mesh-meshcore-module/tests/source/protocol_test.cpp b/Modules/mesh-meshcore-module/tests/source/protocol_test.cpp index c8e0d0908..998b6e2c3 100644 --- a/Modules/mesh-meshcore-module/tests/source/protocol_test.cpp +++ b/Modules/mesh-meshcore-module/tests/source/protocol_test.cpp @@ -199,11 +199,14 @@ TEST_CASE("a region scope limits a packet with a code that other MeshCore nodes packet.payloadType = MeshCorePayload::TextMessage; packet.payloadLength = 5; memcpy(packet.payload, "hello", 5); - CHECK_EQ(meshcore_transport_code(key, packet), 0xEEC8); + uint16_t code = 0; + REQUIRE(meshcore_transport_code(key, packet, &code)); + CHECK_EQ(code, 0xEEC8); packet.payloadType = MeshCorePayload::Advert; packet.payloadLength = 0; - CHECK_EQ(meshcore_transport_code(key, packet), 0xF329); + REQUIRE(meshcore_transport_code(key, packet, &code)); + CHECK_EQ(code, 0xF329); } TEST_CASE("a MeshCore preset only gives a radio profile in a region that allows its frequency") { diff --git a/Modules/mesh-module/include/mesh/mesh.h b/Modules/mesh-module/include/mesh/mesh.h index eb54bd050..249da4999 100644 --- a/Modules/mesh-module/include/mesh/mesh.h +++ b/Modules/mesh-module/include/mesh/mesh.h @@ -260,6 +260,8 @@ error_t mesh_get_radio_settings(const char* backend, struct MeshRadioSettings* s /** * Get the LoRa parameters that a backend would run with: the preset's values with the settings applied. + * The values of the radio when the backend is running. Otherwise the backend's defaults, which can differ from + * what it runs with when its state selects the frequency, like the primary channel of Meshtastic. * @retval ERROR_INVALID_STATE when the radio doesn't use LoRa or has no region */ error_t mesh_get_radio_parameters(const char* backend, struct MeshRadioSettings* parameters); diff --git a/Modules/mesh-module/private/mesh/mesh_settings.h b/Modules/mesh-module/private/mesh/mesh_settings.h index d1ef0052e..3f9354050 100644 --- a/Modules/mesh-module/private/mesh/mesh_settings.h +++ b/Modules/mesh-module/private/mesh/mesh_settings.h @@ -21,11 +21,14 @@ struct MeshBackendSettings { struct MeshNetworkSettings network; }; +/** Loads the settings of a backend, or defaults when there are none */ +void mesh_settings_load_backend(const char* backend, struct MeshBackendSettings* settings); + /** - * Loads the settings of a backend, or defaults when there are none. - * @param[in] migrate_legacy take settings that were stored without a backend, for the backend that was selected then + * Settings were once stored without a backend, for the backend that was selected. Moves them to @a owner once. + * Settings of a backend are never read from the unqualified keys, so no other backend can take them. */ -void mesh_settings_load_backend(const char* backend, bool migrate_legacy, struct MeshBackendSettings* settings); +void mesh_settings_migrate_legacy(const char* owner); error_t mesh_settings_save_backend(const char* backend, const struct MeshBackendSettings* settings); diff --git a/Modules/mesh-module/source/mesh_service.cpp b/Modules/mesh-module/source/mesh_service.cpp index 4239332ec..2072f10a5 100644 --- a/Modules/mesh-module/source/mesh_service.cpp +++ b/Modules/mesh-module/source/mesh_service.cpp @@ -77,6 +77,7 @@ struct Globals { MeshBackendSettings settings; } backendSettings[MAX_BACKENDS] = {}; size_t backendSettingsCount = 0; + bool legacySettingsMigrated = false; MeshEventSubscription* subscriptions = nullptr; }; @@ -189,9 +190,17 @@ MeshBackendSettings& getBackendSettings(const MeshBackendApi* api) { return entries[i].settings; } } + if (!globals().legacySettingsMigrated) { + // The backend that was selected when the settings were saved owns the ones that were stored without a backend + const MeshBackendApi* owner = getSelectedBackend(); + if (owner != nullptr) { + mesh_settings_migrate_legacy(owner->id); + globals().legacySettingsMigrated = true; + } + } auto& entry = entries[globals().backendSettingsCount < MAX_BACKENDS ? globals().backendSettingsCount++ : MAX_BACKENDS - 1]; entry.api = api; - mesh_settings_load_backend(api->id, getSelectedBackend() == api, &entry.settings); + mesh_settings_load_backend(api->id, &entry.settings); return entry.settings; } @@ -1523,13 +1532,22 @@ error_t mesh_get_preset_name(const char* backend, size_t index, char* name, size return result; } -/** Stores the settings of a backend. Reconfigures the radio when it's the selected backend. */ -static error_t saveBackendSettings(const MeshBackendApi* api, const MeshBackendSettings& settings) { +/** + * Persists the settings of a backend, and only when that worked uses them: the cache is updated and the radio is + * reconfigured when the backend is selected. Called without the global lock, because saving does file operations. + */ +static error_t commitBackendSettings(const MeshBackendApi* api, const MeshBackendSettings& settings) { + const error_t saved = mesh_settings_save_backend(api->id, &settings); + if (saved != ERROR_NONE) { + return saved; + } + lock(); getBackendSettings(api) = settings; if (service != nullptr && api == getSelectedBackend()) { MeshHost* host = service; post(host, [host] { configure(host); }); } + unlock(); return ERROR_NONE; } @@ -1545,11 +1563,8 @@ error_t mesh_set_preset(const char* backend, const char* name) { } MeshBackendSettings settings = {}; strncpy(settings.preset, name, MESH_NAME_MAX_LENGTH); - saveBackendSettings(api, settings); - const MeshBackendSettings copy = settings; - const std::string id = api->id; unlock(); - return mesh_settings_save_backend(id.c_str(), ©); + return commitBackendSettings(api, settings); } error_t mesh_get_preset_region(const char* backend, const char* name, RadioRegion* region) { @@ -1586,7 +1601,22 @@ error_t mesh_get_preset(const char* backend, char* name, size_t name_size) { return result; } +/** Whether the whole channel of width @a bandwidth around @a frequency lies in one band of @a region */ +static bool fitsRegion(RadioRegion region, int32_t frequency, int32_t bandwidth) { + const RadioRegionBand* bands = nullptr; + const size_t count = radio_get_region_bands(region, &bands); + return std::any_of(bands, bands + count, [=](const RadioRegionBand& band) { + return frequency - bandwidth / 2 >= band.frequency_min && frequency + bandwidth / 2 <= band.frequency_max; + }); +} + error_t mesh_set_radio_settings(const char* backend, const MeshRadioSettings* radio_settings) { + const bool applies = presetsApply(); + Device* radio = findRadio(); + const RadioRegion region = (radio != nullptr) ? radio_get_region(radio) : RADIO_REGION_UNSET; + if (radio != nullptr) { + device_put(radio); + } lock(); ensureSettingsLoaded(); const MeshBackendApi* api = resolveBackend(backend); @@ -1596,10 +1626,17 @@ error_t mesh_set_radio_settings(const char* backend, const MeshRadioSettings* ra } MeshBackendSettings settings = getBackendSettings(api); settings.radio_settings = *radio_settings; - saveBackendSettings(api, settings); - const std::string id = api->id; + if (applies && region != RADIO_REGION_UNSET) { + // The radio rejects a channel outside the bands of the region, which would leave the mesh in the error state + MeshRadioProfile profile {}; + if (getRadioProfile(api, nullptr, region, RADIO_MODULATION_LORA, findPreset(api, settings.preset), settings.radio_settings, &profile) == ERROR_NONE && + !fitsRegion(region, profile.frequency, profile.bandwidth)) { + unlock(); + return ERROR_OUT_OF_RANGE; + } + } unlock(); - return mesh_settings_save_backend(id.c_str(), &settings); + return commitBackendSettings(api, settings); } error_t mesh_get_radio_settings(const char* backend, MeshRadioSettings* radio_settings) { @@ -1625,10 +1662,8 @@ error_t mesh_set_network_settings(const char* backend, const MeshNetworkSettings } MeshBackendSettings settings = getBackendSettings(api); settings.network = *network_settings; - saveBackendSettings(api, settings); - const std::string id = api->id; unlock(); - return mesh_settings_save_backend(id.c_str(), &settings); + return commitBackendSettings(api, settings); } error_t mesh_get_network_settings(const char* backend, MeshNetworkSettings* network_settings) { @@ -1669,10 +1704,19 @@ error_t mesh_get_radio_parameters(const char* backend, MeshRadioSettings* parame ensureSettingsLoaded(); const MeshBackendApi* api = resolveBackend(backend); error_t result = ERROR_INVALID_STATE; - if (api != nullptr && applies && region != RADIO_REGION_UNSET) { + if (api != nullptr && service != nullptr && service->api == api && service->backend != nullptr && service->state == MESH_STATE_ON && + service->profile.modulation == RADIO_MODULATION_LORA) { + // The running backend knows its own state, e.g. the frequency slot that its primary channel selects + parameters->frequency = service->profile.frequency; + parameters->bandwidth = service->profile.bandwidth; + parameters->spreading_factor = service->profile.spreading_factor; + parameters->coding_rate = service->profile.coding_rate; + parameters->power = service->profile.power; + result = ERROR_NONE; + } else if (api != nullptr && applies && region != RADIO_REGION_UNSET) { const MeshBackendSettings settings = getBackendSettings(api); MeshRadioProfile profile {}; - // The profile of the running backend has the same values, so it doesn't need its own state + // Without a running backend there is no state, so a backend that depends on it uses its defaults result = getRadioProfile(api, nullptr, region, RADIO_MODULATION_LORA, findPreset(api, settings.preset), settings.radio_settings, &profile); if (result == ERROR_NONE) { parameters->frequency = profile.frequency; diff --git a/Modules/mesh-module/source/mesh_settings.cpp b/Modules/mesh-module/source/mesh_settings.cpp index 300c016e4..011923311 100644 --- a/Modules/mesh-module/source/mesh_settings.cpp +++ b/Modules/mesh-module/source/mesh_settings.cpp @@ -3,6 +3,7 @@ #include +#include #include #include #include @@ -30,6 +31,7 @@ constexpr auto* KEY_CODING_RATE = "codingRate"; constexpr auto* KEY_POWER = "power"; constexpr auto* KEY_PATH_HASH_SIZE = "pathHashSize"; constexpr auto* KEY_REGION_SCOPE = "regionScope"; +constexpr auto* KEY_LEGACY_MIGRATED = "legacySettingsMigrated"; constexpr auto* KEY_NODE_NAME = "nodeName"; constexpr auto* KEY_RADIO = "radio"; // Earlier versions stored this as the name when the user hadn't chosen one @@ -44,6 +46,23 @@ static uint32_t random_u32() { #endif } +/** + * Saving opens the file, sets keys and rewrites the whole file from what was read when it was opened. + * Holding this from open to close keeps concurrent saves from reverting each other's keys. + */ +struct FileMutex { + Mutex handle {}; + FileMutex() { mutex_construct(&handle); } + ~FileMutex() { mutex_destruct(&handle); } +}; + +static FileMutex fileMutex; + +struct FileLock { + FileLock() { mutex_lock(&fileMutex.handle); } + ~FileLock() { mutex_unlock(&fileMutex.handle); } +}; + static void read_settings_file(MeshSettings* settings) { char path[FILE_MAX_PATH_STRING_LENGTH]; if (service_paths_get_user_data_path(MESH_SERVICE_ID, SETTINGS_FILE_NAME, path, sizeof(path)) != ERROR_NONE) { @@ -101,6 +120,7 @@ static PropertiesFile* open_for_write() { } error_t mesh_settings_save(const MeshSettings* settings) { + FileLock fileLock; PropertiesFile* file = open_for_write(); if (file == nullptr) { return ERROR_RESOURCE; @@ -112,17 +132,42 @@ error_t mesh_settings_save(const MeshSettings* settings) { return properties_file_close(file); } -/** Reads a setting stored as ".", or as "" when @a legacy */ -static bool read_backend_value(const PropertiesFile* file, const char* backend, const char* key, bool legacy, char* value, size_t value_size) { +/** The keys of MeshBackendSettings */ +static const char* const BACKEND_KEYS[] = { + KEY_PRESET, KEY_FREQUENCY, KEY_BANDWIDTH, KEY_SPREADING_FACTOR, KEY_CODING_RATE, KEY_POWER, KEY_PATH_HASH_SIZE, KEY_REGION_SCOPE, +}; + +static void qualified_name(const char* backend, const char* key, char* name, size_t size) { + snprintf(name, size, "%s.%s", backend, key); +} + +void mesh_settings_migrate_legacy(const char* owner) { + FileLock fileLock; + char path[FILE_MAX_PATH_STRING_LENGTH]; + if (service_paths_get_user_data_path(MESH_SERVICE_ID, SETTINGS_FILE_NAME, path, sizeof(path)) != ERROR_NONE) { + return; + } + PropertiesFile* file = properties_file_open(path); + if (file == nullptr) { + return; + } + if (properties_file_has(file, KEY_LEGACY_MIGRATED)) { + properties_file_close(file); + return; + } + char value[64]; char name[MESH_BACKEND_ID_MAX_LENGTH + 24]; - snprintf(name, sizeof(name), "%s.%s", backend, key); - if (properties_file_get(file, name, value, value_size) == ERROR_NONE) { - return true; + for (const char* key : BACKEND_KEYS) { + qualified_name(owner, key, name, sizeof(name)); + if (!properties_file_has(file, name) && properties_file_get(file, key, value, sizeof(value)) == ERROR_NONE) { + properties_file_set(file, name, value); + } } - return legacy && properties_file_get(file, key, value, value_size) == ERROR_NONE; + properties_file_set(file, KEY_LEGACY_MIGRATED, "true"); + properties_file_close(file); } -void mesh_settings_load_backend(const char* backend, bool migrate_legacy, MeshBackendSettings* settings) { +void mesh_settings_load_backend(const char* backend, MeshBackendSettings* settings) { *settings = {}; char path[FILE_MAX_PATH_STRING_LENGTH]; if (service_paths_get_user_data_path(MESH_SERVICE_ID, SETTINGS_FILE_NAME, path, sizeof(path)) != ERROR_NONE) { @@ -133,7 +178,9 @@ void mesh_settings_load_backend(const char* backend, bool migrate_legacy, MeshBa return; } char value[64]; - if (read_backend_value(file, backend, KEY_PRESET, migrate_legacy, value, sizeof(value))) { + char name[MESH_BACKEND_ID_MAX_LENGTH + 24]; + qualified_name(backend, KEY_PRESET, name, sizeof(name)); + if (properties_file_get(file, name, value, sizeof(value)) == ERROR_NONE) { strncpy(settings->preset, value, MESH_NAME_MAX_LENGTH); } const struct { @@ -148,17 +195,20 @@ void mesh_settings_load_backend(const char* backend, bool migrate_legacy, MeshBa { KEY_PATH_HASH_SIZE, &settings->network.path_hash_size }, }; for (const auto& number : numbers) { - if (read_backend_value(file, backend, number.key, migrate_legacy, value, sizeof(value))) { + qualified_name(backend, number.key, name, sizeof(name)); + if (properties_file_get(file, name, value, sizeof(value)) == ERROR_NONE) { *number.target = atoi(value); } } - if (read_backend_value(file, backend, KEY_REGION_SCOPE, migrate_legacy, value, sizeof(value))) { + qualified_name(backend, KEY_REGION_SCOPE, name, sizeof(name)); + if (properties_file_get(file, name, value, sizeof(value)) == ERROR_NONE) { strncpy(settings->network.region_scope, value, MESH_NAME_MAX_LENGTH); } properties_file_close(file); } error_t mesh_settings_save_backend(const char* backend, const MeshBackendSettings* settings) { + FileLock fileLock; PropertiesFile* file = open_for_write(); if (file == nullptr) { return ERROR_RESOURCE; diff --git a/Tactility/Source/app/meshsettings/MeshSettings.cpp b/Tactility/Source/app/meshsettings/MeshSettings.cpp index 3f2e154bc..5974b143a 100644 --- a/Tactility/Source/app/meshsettings/MeshSettings.cpp +++ b/Tactility/Source/app/meshsettings/MeshSettings.cpp @@ -25,6 +25,7 @@ #include #include +#include #include #include #include @@ -484,7 +485,7 @@ void onFrequencyDialogResult(Context* ctx, int32_t result) { char* end = nullptr; const double megahertz = strtod(text, &end); const bool cleared = text[0] == '\0'; - if (cleared || (end != text && megahertz >= 100.0 && megahertz <= 1000.0)) { + if (cleared || (end != text && *end == '\0' && megahertz >= 100.0 && megahertz <= 1000.0)) { MeshRadioSettings settings {}; mesh_get_radio_settings(MESHCORE_ID, &settings); settings.frequency = cleared ? 0 : static_cast(megahertz * 1e6 + 0.5); @@ -690,6 +691,8 @@ void createWidgets(lv_obj_t* parent, void* userData) { ctx->advancedSwitch = lv_switch_create(advancedRow); lv_obj_set_state(ctx->advancedSwitch, LV_STATE_CHECKED, ctx->showAdvanced); lv_obj_add_event_cb(ctx->advancedSwitch, onAdvancedSwitch, LV_EVENT_VALUE_CHANGED, ctx); + // The arrow keys would toggle a switch that is in the input group itself + lvgl_grid_navigation_add(advancedRow); const lv_event_cb_t radioSettingCallbacks[RADIO_SETTING_COUNT] = { onSelectFrequencyPressed, onSelectBandwidthPressed, onSelectSpreadingFactorPressed, onSelectCodingRatePressed, onSelectPowerPressed, onSelectPathHashSizePressed, onSelectRegionScopePressed From 5fe5fd44e904ab1147705adb61c5e17cde2f6296 Mon Sep 17 00:00:00 2001 From: Ken Van Hoeylandt Date: Thu, 29 Oct 2026 23:52:26 +0100 Subject: [PATCH 09/11] Fix issues and add tests --- .../source/meshcore_backend.cpp | 102 +++++++++-- .../tests/source/protocol_test.cpp | 38 +++++ .../source/meshtastic_backend.cpp | 12 +- .../tests/source/protocol_test.cpp | 45 +++++ Modules/mesh-module/include/mesh/mesh.h | 5 + .../mesh-module/private/mesh/mesh_settings.h | 4 +- Modules/mesh-module/source/mesh_command.cpp | 8 + Modules/mesh-module/source/mesh_service.cpp | 43 +++-- Modules/mesh-module/source/mesh_settings.cpp | 10 +- .../mesh-module/tests/source/service_test.cpp | 158 +++++++++++++++++- Tactility/Source/app/launcher/AppsMode.cpp | 6 +- .../Source/app/launcher/SettingsMode.cpp | 6 +- .../Source/app/meshchat/MeshChannels.cpp | 2 +- .../Source/app/meshsettings/MeshSettings.cpp | 5 +- 14 files changed, 399 insertions(+), 45 deletions(-) diff --git a/Modules/mesh-meshcore-module/source/meshcore_backend.cpp b/Modules/mesh-meshcore-module/source/meshcore_backend.cpp index ab56e6deb..c3b4276b0 100644 --- a/Modules/mesh-meshcore-module/source/meshcore_backend.cpp +++ b/Modules/mesh-meshcore-module/source/meshcore_backend.cpp @@ -102,6 +102,45 @@ bool writeFile(const char* path, const uint8_t* data, size_t length) { return ok; } +/** + * Writes the new contents to ".tmp" and checks that they are complete before they replace the file. + * The previous file is kept until the new one is in place, because some filesystems can't rename over an existing file. + */ +bool writeFileSafely(const char* path, const uint8_t* data, size_t length) { + char tempPath[FILE_MAX_PATH_STRING_LENGTH]; + char backupPath[FILE_MAX_PATH_STRING_LENGTH]; + if (snprintf(tempPath, sizeof(tempPath), "%s.tmp", path) >= (int)sizeof(tempPath) || + snprintf(backupPath, sizeof(backupPath), "%s.old", path) >= (int)sizeof(backupPath)) { + return false; + } + FILE* file = fopen(tempPath, "wb"); + if (file == nullptr) { + return false; + } + const bool written = (length == 0 || fwrite(data, 1, length, file) == length) && fflush(file) == 0; + const bool closed = fclose(file) == 0; + if (!written || !closed) { + remove(tempPath); + return false; + } + if (rename(tempPath, path) == 0) { + return true; + } + // The file exists and can't be replaced by a rename: move it aside, so that it can be restored + remove(backupPath); + if (rename(path, backupPath) != 0) { + remove(tempPath); + return false; + } + if (rename(tempPath, path) != 0) { + rename(backupPath, path); + remove(tempPath); + return false; + } + remove(backupPath); + return true; +} + void loadOrCreateIdentity(Backend* backend) { char path[FILE_MAX_PATH_STRING_LENGTH]; if (!getPath(backend, "identity.bin", path, sizeof(path))) { @@ -151,19 +190,52 @@ void addChannel(Backend* backend, uint8_t index, const char* name, const uint8_t std::sort(backend->channels.begin(), backend->channels.end(), [](const Channel& a, const Channel& b) { return a.index < b.index; }); } +// File: magic (4) + channel count (1) + records. Record: index (1) + name (32) + key (16). +// The count and the size tell a complete file from one of an interrupted save, and an empty list from either. +constexpr char CHANNELS_MAGIC[4] = { 'M', 'C', 'C', 'H' }; +constexpr size_t CHANNELS_HEADER_SIZE = sizeof(CHANNELS_MAGIC) + 1; +constexpr size_t CHANNEL_RECORD_SIZE = 1 + MESH_NAME_MAX_LENGTH + 1 + 16; + +/** + * Adds the channels of a saved file. Files from before the header have records only, and can't be empty. + * @return false when the file is incomplete, in which case no channel was added + */ +bool parseChannels(Backend* backend, const tt::std::vector& data) { + size_t offset = 0; + size_t count; + if (data.size() >= CHANNELS_HEADER_SIZE && memcmp(data.data(), CHANNELS_MAGIC, sizeof(CHANNELS_MAGIC)) == 0) { + count = data[sizeof(CHANNELS_MAGIC)]; + offset = CHANNELS_HEADER_SIZE; + if (data.size() != CHANNELS_HEADER_SIZE + count * CHANNEL_RECORD_SIZE) { + return false; + } + } else if (!data.empty() && data.size() % CHANNEL_RECORD_SIZE == 0) { + count = data.size() / CHANNEL_RECORD_SIZE; + } else { + return false; + } + for (size_t i = 0; i < count; i++, offset += CHANNEL_RECORD_SIZE) { + char name[MESH_NAME_MAX_LENGTH + 1]; + memcpy(name, data.data() + offset + 1, sizeof(name)); + name[MESH_NAME_MAX_LENGTH] = '\0'; + addChannel(backend, data[offset], name, data.data() + offset + 1 + sizeof(name)); + } + return true; +} + void loadChannels(Backend* backend) { char path[FILE_MAX_PATH_STRING_LENGTH]; - tt::std::vector data; - // Record: index (1) + name (32) + key (16) - constexpr size_t RECORD_SIZE = 1 + MESH_NAME_MAX_LENGTH + 1 + 16; - // Only a node that never saved its channels starts with the Public channel, so that it can be removed - const bool saved = getPath(backend, "channels.bin", path, sizeof(path)) && readFile(path, data); - if (saved) { - for (size_t offset = 0; offset + RECORD_SIZE <= data.size(); offset += RECORD_SIZE) { - char name[MESH_NAME_MAX_LENGTH + 1]; - memcpy(name, data.data() + offset + 1, sizeof(name)); - name[MESH_NAME_MAX_LENGTH] = '\0'; - addChannel(backend, data[offset], name, data.data() + offset + 1 + sizeof(name)); + char backupPath[FILE_MAX_PATH_STRING_LENGTH]; + // Only a node without a complete saved list starts with the Public channel, so that it can be removed + bool saved = false; + if (getPath(backend, "channels.bin", path, sizeof(path)) && snprintf(backupPath, sizeof(backupPath), "%s.old", path) < (int)sizeof(backupPath)) { + // The backup is the previous file when a save was interrupted while it replaced the file + for (const char* candidate : { static_cast(path), static_cast(backupPath) }) { + tt::std::vector data; + if (readFile(candidate, data) && parseChannels(backend, data)) { + saved = true; + break; + } } } if (!saved) { @@ -177,12 +249,16 @@ void saveChannels(Backend* backend) { return; } tt::std::vector data; + data.insert(data.end(), CHANNELS_MAGIC, CHANNELS_MAGIC + sizeof(CHANNELS_MAGIC)); + data.push_back(static_cast(backend->channels.size())); for (const auto& channel : backend->channels) { data.push_back(channel.index); data.insert(data.end(), channel.name, channel.name + MESH_NAME_MAX_LENGTH + 1); data.insert(data.end(), channel.key, channel.key + 16); } - writeFile(path, data.data(), data.size()); + if (!writeFileSafely(path, data.data(), data.size())) { + LOG_E(TAG, "Failed to save the channels"); + } } // endregion @@ -215,7 +291,7 @@ bool applyNetwork(Backend* backend, const MeshCorePacket& original, MeshCorePack } MeshNetworkSettings network; mesh_host_get_network(backend->host, &network); - packet.pathHashSize = static_cast(std::clamp(network.path_hash_size, 1, 3)); + packet.pathHashSize = static_cast(std::clamp(network.path_hash_size, 1, MESH_PATH_HASH_SIZE_MAX)); if (network.region_scope[0] != '\0') { uint8_t key[16]; meshcore_scope_key(network.region_scope, key); diff --git a/Modules/mesh-meshcore-module/tests/source/protocol_test.cpp b/Modules/mesh-meshcore-module/tests/source/protocol_test.cpp index 998b6e2c3..896acabb1 100644 --- a/Modules/mesh-meshcore-module/tests/source/protocol_test.cpp +++ b/Modules/mesh-meshcore-module/tests/source/protocol_test.cpp @@ -219,3 +219,41 @@ TEST_CASE("a MeshCore preset only gives a radio profile in a region that allows CHECK_EQ(meshcore_backend_api.get_radio_profile(nullptr, RADIO_REGION_EU_868, RADIO_MODULATION_LORA, netherlands, &profile), ERROR_NONE); CHECK_EQ(profile.frequency, 869618000); } + +TEST_CASE("HMAC-SHA256 matches the RFC 4231 test vector") { + const uint8_t key[] = { 'J', 'e', 'f', 'e' }; + const char* message = "what do ya want for nothing?"; + const uint8_t expected[32] = { + 0x5b, 0xdc, 0xc1, 0x46, 0xbf, 0x60, 0x75, 0x4e, 0x6a, 0x04, 0x24, 0x26, 0x08, 0x95, 0x75, 0xc7, + 0x5a, 0x00, 0x3f, 0x08, 0x9d, 0x27, 0x39, 0x83, 0x9d, 0xec, 0x58, 0xb9, 0x64, 0xec, 0x38, 0x43, + }; + uint8_t mac[32]; + REQUIRE(meshcore_hmac_sha256(key, sizeof(key), reinterpret_cast(message), strlen(message), mac)); + CHECK_EQ(memcmp(mac, expected, sizeof(mac)), 0); +} + +TEST_CASE("every MeshCore preset can be used in the region that it selects") { + for (size_t preset = 0; meshcore_backend_api.presets[preset] != nullptr; preset++) { + CAPTURE(std::string(meshcore_backend_api.presets[preset])); + // Whatever region the radio has, a preset ends up in a region that gives it a profile that lies in the region's bands + for (int current = RADIO_REGION_EU_433; current < RADIO_REGION_COUNT; current++) { + RadioRegion region = RADIO_REGION_UNSET; + if (meshcore_backend_api.get_preset_region(preset, static_cast(current), ®ion) != ERROR_NONE) { + FAIL("no region allows the preset"); + } + MeshRadioProfile profile {}; + if (meshcore_backend_api.get_radio_profile(nullptr, region, RADIO_MODULATION_LORA, preset, &profile) != ERROR_NONE) { + // The default preset only exists in some regions + CHECK_EQ(preset, 0); + continue; + } + const RadioRegionBand* bands = nullptr; + const size_t count = radio_get_region_bands(region, &bands); + bool fits = false; + for (size_t i = 0; i < count; i++) { + fits |= profile.frequency - profile.bandwidth / 2 >= bands[i].frequency_min && profile.frequency + profile.bandwidth / 2 <= bands[i].frequency_max; + } + CHECK(fits); + } + } +} diff --git a/Modules/mesh-meshtastic-module/source/meshtastic_backend.cpp b/Modules/mesh-meshtastic-module/source/meshtastic_backend.cpp index e97ea3b99..9d1a15b12 100644 --- a/Modules/mesh-meshtastic-module/source/meshtastic_backend.cpp +++ b/Modules/mesh-meshtastic-module/source/meshtastic_backend.cpp @@ -207,11 +207,16 @@ bool isDefaultKey(const MeshtasticChannelKey& key) { // Record: index (1) + name (32) + key length (1) + key (32) constexpr size_t CHANNEL_RECORD_SIZE = 1 + MESH_NAME_MAX_LENGTH + 1 + 1 + 32; +// Files that follow the preset start with this marker. Earlier files have records only, and stored the primary channel +// that was never customised as "LongFast", which can't be told from one that was named that on purpose. +constexpr char CHANNELS_MAGIC[4] = { 'M', 'T', 'C', 'H' }; + void loadChannels(Backend* backend) { char path[FILE_MAX_PATH_STRING_LENGTH]; tt::std::vector data; if (mesh_host_get_data_path(backend->host, "channels.bin", path, sizeof(path)) == ERROR_NONE && readFile(path, data)) { - for (size_t offset = 0; offset + CHANNEL_RECORD_SIZE <= data.size(); offset += CHANNEL_RECORD_SIZE) { + const bool hasMarker = data.size() >= sizeof(CHANNELS_MAGIC) && memcmp(data.data(), CHANNELS_MAGIC, sizeof(CHANNELS_MAGIC)) == 0; + for (size_t offset = hasMarker ? sizeof(CHANNELS_MAGIC) : 0; offset + CHANNEL_RECORD_SIZE <= data.size(); offset += CHANNEL_RECORD_SIZE) { char name[MESH_NAME_MAX_LENGTH + 1]; memcpy(name, data.data() + offset + 1, sizeof(name)); name[MESH_NAME_MAX_LENGTH] = '\0'; @@ -220,7 +225,9 @@ void loadChannels(Backend* backend) { memcpy(key.bytes, data.data() + offset + 2 + sizeof(name), 32); if (key.length == 16 || key.length == 32) { // A primary channel without a name and with the default key is the one that follows the preset - const bool followsPreset = data[offset] == 0 && name[0] == '\0' && isDefaultKey(key); + // An earlier file has "LongFast" for it, which is taken for the default because that is what nearly all of them are + const bool legacyDefault = !hasMarker && strcmp(name, DEFAULT_CHANNEL_NAME) == 0; + const bool followsPreset = data[offset] == 0 && (name[0] == '\0' || legacyDefault) && isDefaultKey(key); addChannel(backend, data[offset], followsPreset ? DEFAULT_CHANNEL_NAME : name, key, followsPreset); } } @@ -236,6 +243,7 @@ void saveChannels(Backend* backend) { return; } tt::std::vector data; + data.insert(data.end(), CHANNELS_MAGIC, CHANNELS_MAGIC + sizeof(CHANNELS_MAGIC)); for (const auto& channel : backend->channels) { data.push_back(channel.index); const char emptyName[MESH_NAME_MAX_LENGTH + 1] = {}; diff --git a/Modules/mesh-meshtastic-module/tests/source/protocol_test.cpp b/Modules/mesh-meshtastic-module/tests/source/protocol_test.cpp index 4297054f5..5167e3b23 100644 --- a/Modules/mesh-meshtastic-module/tests/source/protocol_test.cpp +++ b/Modules/mesh-meshtastic-module/tests/source/protocol_test.cpp @@ -1,8 +1,12 @@ #include "doctest.h" +#include #include +#include + #include +#include namespace { @@ -113,3 +117,44 @@ TEST_CASE("Meshtastic User encoding round trip") { CHECK_EQ(decoded.longName, "Tactility"); CHECK_EQ(decoded.shortName, "Tact"); } + +TEST_CASE("a Meshtastic preset either fits the region or is refused") { + size_t presetCount = 0; + while (meshtastic_backend_api.presets[presetCount] != nullptr) { + presetCount++; + } + CHECK_EQ(presetCount, 8); + + for (int region = RADIO_REGION_EU_433; region < RADIO_REGION_COUNT; region++) { + // Meshtastic's 433 MHz band starts at 433.0 MHz, below the 433.05 MHz of the radio region, so its first slot doesn't fit + if (region == RADIO_REGION_EU_433) { + continue; + } + for (size_t preset = 0; preset < presetCount; preset++) { + CAPTURE(std::string(radio_region_to_string(static_cast(region)))); + CAPTURE(std::string(meshtastic_backend_api.presets[preset])); + MeshRadioProfile profile {}; + if (meshtastic_backend_api.get_radio_profile(nullptr, static_cast(region), RADIO_MODULATION_LORA, preset, &profile) != ERROR_NONE) { + continue; + } + const RadioRegionBand* bands = nullptr; + const size_t count = radio_get_region_bands(static_cast(region), &bands); + bool fits = false; + for (size_t i = 0; i < count; i++) { + fits |= profile.frequency - profile.bandwidth / 2 >= bands[i].frequency_min && profile.frequency + profile.bandwidth / 2 <= bands[i].frequency_max; + } + CHECK(fits); + } + } +} + +TEST_CASE("ShortTurbo is refused where its bandwidth exceeds the band") { + size_t shortTurbo = 0; + while (strcmp(meshtastic_backend_api.presets[shortTurbo], "ShortTurbo") != 0) { + shortTurbo++; + } + MeshRadioProfile profile {}; + CHECK_EQ(meshtastic_backend_api.get_radio_profile(nullptr, RADIO_REGION_EU_868, RADIO_MODULATION_LORA, shortTurbo, &profile), ERROR_NOT_SUPPORTED); + CHECK_EQ(meshtastic_backend_api.get_radio_profile(nullptr, RADIO_REGION_US_915, RADIO_MODULATION_LORA, shortTurbo, &profile), ERROR_NONE); + CHECK_EQ(profile.bandwidth, 500000); +} diff --git a/Modules/mesh-module/include/mesh/mesh.h b/Modules/mesh-module/include/mesh/mesh.h index 249da4999..4676be32b 100644 --- a/Modules/mesh-module/include/mesh/mesh.h +++ b/Modules/mesh-module/include/mesh/mesh.h @@ -222,6 +222,7 @@ error_t mesh_get_preset_name(const char* backend, size_t index, char* name, size /** * Select the radio preset of a backend. The choice is persisted. The radio settings of the backend are cleared. * @retval ERROR_NOT_FOUND when the backend has no such preset + * @retval ERROR_NOT_SUPPORTED when the region of the radio doesn't allow the preset, e.g. a bandwidth wider than its band */ error_t mesh_set_preset(const char* backend, const char* name); @@ -269,6 +270,9 @@ error_t mesh_get_radio_parameters(const char* backend, struct MeshRadioSettings* /** Region scope that turns the scope off, for a preset that has one */ #define MESH_REGION_SCOPE_NONE "*" +/** The largest number of bytes per hop in the path of a packet */ +#define MESH_PATH_HASH_SIZE_MAX 3 + /** Network parameters of a backend that don't concern the radio. A backend ignores the ones it doesn't have. */ struct MeshNetworkSettings { /** Bytes per hop in the path of a packet, 0 uses the value of the preset */ @@ -280,6 +284,7 @@ struct MeshNetworkSettings { /** * Set network parameters of a backend. The choice is persisted and resets when the preset changes. * The radio is reconfigured when the backend is selected and the service is running. + * @retval ERROR_INVALID_ARGUMENT when the path hash size is negative or above MESH_PATH_HASH_SIZE_MAX * @retval ERROR_NOT_FOUND when there is no such backend */ error_t mesh_set_network_settings(const char* backend, const struct MeshNetworkSettings* settings); diff --git a/Modules/mesh-module/private/mesh/mesh_settings.h b/Modules/mesh-module/private/mesh/mesh_settings.h index 3f9354050..7dff33ba5 100644 --- a/Modules/mesh-module/private/mesh/mesh_settings.h +++ b/Modules/mesh-module/private/mesh/mesh_settings.h @@ -27,8 +27,10 @@ void mesh_settings_load_backend(const char* backend, struct MeshBackendSettings* /** * Settings were once stored without a backend, for the backend that was selected. Moves them to @a owner once. * Settings of a backend are never read from the unqualified keys, so no other backend can take them. + * @retval ERROR_NONE when the settings were moved, or were moved before + * @retval ERROR_RESOURCE when the file couldn't be opened or written, so it can be tried again */ -void mesh_settings_migrate_legacy(const char* owner); +error_t mesh_settings_migrate_legacy(const char* owner); error_t mesh_settings_save_backend(const char* backend, const struct MeshBackendSettings* settings); diff --git a/Modules/mesh-module/source/mesh_command.cpp b/Modules/mesh-module/source/mesh_command.cpp index 13a8c62ce..899e5bfbe 100644 --- a/Modules/mesh-module/source/mesh_command.cpp +++ b/Modules/mesh-module/source/mesh_command.cpp @@ -141,6 +141,14 @@ static int command_preset(int argc, char* argv[]) { } if (argc == 3) { + // A preset can need another region. It's saved first, and the preset isn't set when that fails. + RadioRegion region = RADIO_REGION_UNSET; + if (mesh_get_preset_region(nullptr, argv[2], ®ion) == ERROR_NONE && region != RADIO_REGION_UNSET && region != radio_settings_load_region()) { + const error_t region_error = radio_settings_save_region(region); + if (region_error != ERROR_NONE) { + return report_error("region", region_error); + } + } error_t error = mesh_set_preset(nullptr, argv[2]); if (error == ERROR_NOT_FOUND) { printf("mesh: unknown preset: %s\n", argv[2]); diff --git a/Modules/mesh-module/source/mesh_service.cpp b/Modules/mesh-module/source/mesh_service.cpp index 2072f10a5..46896a143 100644 --- a/Modules/mesh-module/source/mesh_service.cpp +++ b/Modules/mesh-module/source/mesh_service.cpp @@ -194,8 +194,9 @@ MeshBackendSettings& getBackendSettings(const MeshBackendApi* api) { // The backend that was selected when the settings were saved owns the ones that were stored without a backend const MeshBackendApi* owner = getSelectedBackend(); if (owner != nullptr) { - mesh_settings_migrate_legacy(owner->id); - globals().legacySettingsMigrated = true; + if (mesh_settings_migrate_legacy(owner->id) == ERROR_NONE) { + globals().legacySettingsMigrated = true; + } } } auto& entry = entries[globals().backendSettingsCount < MAX_BACKENDS ? globals().backendSettingsCount++ : MAX_BACKENDS - 1]; @@ -641,7 +642,8 @@ MeshNetworkSettings getNetwork(const MeshBackendApi* api, size_t preset, const M if (api->get_preset_network != nullptr) { api->get_preset_network(preset, &network); } - if (settings.path_hash_size != 0) { + // A value that was stored by hand can be out of range, and the preset's value is used then + if (settings.path_hash_size >= 1 && settings.path_hash_size <= MESH_PATH_HASH_SIZE_MAX) { network.path_hash_size = settings.path_hash_size; } if (settings.region_scope[0] == '\0') { @@ -1532,6 +1534,15 @@ error_t mesh_get_preset_name(const char* backend, size_t index, char* name, size return result; } +/** Whether the whole channel of width @a bandwidth around @a frequency lies in one band of @a region */ +static bool fitsRegion(RadioRegion region, int32_t frequency, int32_t bandwidth) { + const RadioRegionBand* bands = nullptr; + const size_t count = radio_get_region_bands(region, &bands); + return std::any_of(bands, bands + count, [=](const RadioRegionBand& band) { + return frequency - bandwidth / 2 >= band.frequency_min && frequency + bandwidth / 2 <= band.frequency_max; + }); +} + /** * Persists the settings of a backend, and only when that worked uses them: the cache is updated and the radio is * reconfigured when the backend is selected. Called without the global lock, because saving does file operations. @@ -1553,6 +1564,11 @@ static error_t commitBackendSettings(const MeshBackendApi* api, const MeshBacken error_t mesh_set_preset(const char* backend, const char* name) { const bool applies = presetsApply(); + Device* radio = findRadio(); + const RadioRegion region = (radio != nullptr) ? radio_get_region(radio) : RADIO_REGION_UNSET; + if (radio != nullptr) { + device_put(radio); + } lock(); ensureSettingsLoaded(); const MeshBackendApi* api = resolveBackend(backend); @@ -1563,6 +1579,15 @@ error_t mesh_set_preset(const char* backend, const char* name) { } MeshBackendSettings settings = {}; strncpy(settings.preset, name, MESH_NAME_MAX_LENGTH); + if (region != RADIO_REGION_UNSET) { + // The radio would reject a preset that doesn't fit the region, which would leave the mesh in the error state + MeshRadioProfile profile {}; + if (getRadioProfile(api, nullptr, region, RADIO_MODULATION_LORA, index, settings.radio_settings, &profile) != ERROR_NONE || + !fitsRegion(region, profile.frequency, profile.bandwidth)) { + unlock(); + return ERROR_NOT_SUPPORTED; + } + } unlock(); return commitBackendSettings(api, settings); } @@ -1601,15 +1626,6 @@ error_t mesh_get_preset(const char* backend, char* name, size_t name_size) { return result; } -/** Whether the whole channel of width @a bandwidth around @a frequency lies in one band of @a region */ -static bool fitsRegion(RadioRegion region, int32_t frequency, int32_t bandwidth) { - const RadioRegionBand* bands = nullptr; - const size_t count = radio_get_region_bands(region, &bands); - return std::any_of(bands, bands + count, [=](const RadioRegionBand& band) { - return frequency - bandwidth / 2 >= band.frequency_min && frequency + bandwidth / 2 <= band.frequency_max; - }); -} - error_t mesh_set_radio_settings(const char* backend, const MeshRadioSettings* radio_settings) { const bool applies = presetsApply(); Device* radio = findRadio(); @@ -1653,6 +1669,9 @@ error_t mesh_get_radio_settings(const char* backend, MeshRadioSettings* radio_se } error_t mesh_set_network_settings(const char* backend, const MeshNetworkSettings* network_settings) { + if (network_settings->path_hash_size < 0 || network_settings->path_hash_size > MESH_PATH_HASH_SIZE_MAX) { + return ERROR_INVALID_ARGUMENT; + } lock(); ensureSettingsLoaded(); const MeshBackendApi* api = resolveBackend(backend); diff --git a/Modules/mesh-module/source/mesh_settings.cpp b/Modules/mesh-module/source/mesh_settings.cpp index 011923311..9c38edac7 100644 --- a/Modules/mesh-module/source/mesh_settings.cpp +++ b/Modules/mesh-module/source/mesh_settings.cpp @@ -141,19 +141,19 @@ static void qualified_name(const char* backend, const char* key, char* name, siz snprintf(name, size, "%s.%s", backend, key); } -void mesh_settings_migrate_legacy(const char* owner) { +error_t mesh_settings_migrate_legacy(const char* owner) { FileLock fileLock; char path[FILE_MAX_PATH_STRING_LENGTH]; if (service_paths_get_user_data_path(MESH_SERVICE_ID, SETTINGS_FILE_NAME, path, sizeof(path)) != ERROR_NONE) { - return; + return ERROR_RESOURCE; } PropertiesFile* file = properties_file_open(path); if (file == nullptr) { - return; + return ERROR_RESOURCE; } if (properties_file_has(file, KEY_LEGACY_MIGRATED)) { properties_file_close(file); - return; + return ERROR_NONE; } char value[64]; char name[MESH_BACKEND_ID_MAX_LENGTH + 24]; @@ -164,7 +164,7 @@ void mesh_settings_migrate_legacy(const char* owner) { } } properties_file_set(file, KEY_LEGACY_MIGRATED, "true"); - properties_file_close(file); + return properties_file_close(file) == ERROR_NONE ? ERROR_NONE : ERROR_RESOURCE; } void mesh_settings_load_backend(const char* backend, MeshBackendSettings* settings) { diff --git a/Modules/mesh-module/tests/source/service_test.cpp b/Modules/mesh-module/tests/source/service_test.cpp index 6ee30177a..369adcf93 100644 --- a/Modules/mesh-module/tests/source/service_test.cpp +++ b/Modules/mesh-module/tests/source/service_test.cpp @@ -12,7 +12,11 @@ #include #include #include +#include #include +#include + +#include #include #include @@ -61,17 +65,17 @@ void* loopbackCreate(MeshHost* host) { } void loopbackDestroy(void*) {} -const char* const loopbackPresets[] = { "Default", "Other", nullptr }; +const char* const loopbackPresets[] = { "Default", "Other", "Wide", nullptr }; -error_t loopbackGetRadioProfile(void*, RadioRegion, RadioModulation modulation, size_t preset, MeshRadioProfile* profile) { +error_t loopbackGetRadioProfile(void*, RadioRegion region, RadioModulation modulation, size_t preset, MeshRadioProfile* profile) { if (modulation == RADIO_MODULATION_ESPNOW) { *profile = { .modulation = RADIO_MODULATION_ESPNOW, .power = 20, .channel = 6 }; return ERROR_NONE; } *profile = { .modulation = RADIO_MODULATION_LORA, - .frequency = 869525000, - .bandwidth = 250000, + .frequency = (region == RADIO_REGION_US_915) ? 915000000 : 869525000, + .bandwidth = (preset == 2) ? 500000 : 250000, .spreading_factor = (preset == 1) ? 7 : 11, .coding_rate = 5, .sync_word = 0x2B, @@ -294,10 +298,10 @@ TEST_CASE("mesh service sends and receives through the active backend") { REQUIRE(waitFor([] { return mesh_get_state() == MESH_STATE_ON; })); char name[MESH_NAME_MAX_LENGTH + 1]; - CHECK_EQ(mesh_get_preset_count(nullptr), 2); + CHECK_EQ(mesh_get_preset_count(nullptr), 3); CHECK_EQ(mesh_get_preset_name(nullptr, 1, name, sizeof(name)), ERROR_NONE); CHECK_EQ(std::string(name), "Other"); - CHECK_EQ(mesh_get_preset_name(nullptr, 2, name, sizeof(name)), ERROR_NOT_FOUND); + CHECK_EQ(mesh_get_preset_name(nullptr, 3, name, sizeof(name)), ERROR_NOT_FOUND); CHECK_EQ(mesh_get_preset(nullptr, name, sizeof(name)), ERROR_NONE); CHECK_EQ(std::string(name), "Default"); @@ -389,6 +393,73 @@ TEST_CASE("mesh service sends and receives through the active backend") { CHECK_EQ(mesh_get_network_settings("missing", &settings), ERROR_NOT_FOUND); } + SUBCASE("a frequency outside the region is rejected") { + REQUIRE_EQ(radio_set_region(&local, RADIO_REGION_EU_868), ERROR_NONE); + REQUIRE_EQ(service_manager_start(MESH_SERVICE_ID), ERROR_NONE); + REQUIRE(waitFor([] { return mesh_get_state() == MESH_STATE_ON; })); + + MeshRadioSettings settings = { .frequency = 915000000 }; + CHECK_EQ(mesh_set_radio_settings(nullptr, &settings), ERROR_OUT_OF_RANGE); + // A bandwidth that makes the channel leave the band is rejected too + settings = { .bandwidth = 500000 }; + CHECK_EQ(mesh_set_radio_settings(nullptr, &settings), ERROR_OUT_OF_RANGE); + + // Nothing was stored, and the radio keeps running + MeshRadioSettings stored = { .power = 1 }; + CHECK_EQ(mesh_get_radio_settings(nullptr, &stored), ERROR_NONE); + CHECK_EQ(stored.frequency, 0); + CHECK_EQ(stored.bandwidth, 0); + CHECK_EQ(mesh_get_state(), MESH_STATE_ON); + + settings = { .frequency = 869525000 }; + CHECK_EQ(mesh_set_radio_settings(nullptr, &settings), ERROR_NONE); + const MeshRadioSettings none = {}; + REQUIRE_EQ(mesh_set_radio_settings(nullptr, &none), ERROR_NONE); + } + + SUBCASE("a preset that the region doesn't fit isn't selected") { + char name[MESH_NAME_MAX_LENGTH + 1]; + REQUIRE_EQ(radio_set_region(&local, RADIO_REGION_EU_868), ERROR_NONE); + REQUIRE_EQ(service_manager_start(MESH_SERVICE_ID), ERROR_NONE); + REQUIRE(waitFor([] { return mesh_get_state() == MESH_STATE_ON; })); + + // The 500 kHz of "Wide" doesn't fit the 250 kHz band, and the radio stays as it was + CHECK_EQ(mesh_set_preset(nullptr, "Wide"), ERROR_NOT_SUPPORTED); + CHECK_EQ(mesh_get_preset(nullptr, name, sizeof(name)), ERROR_NONE); + CHECK_EQ(std::string(name), "Default"); + CHECK_EQ(mesh_get_state(), MESH_STATE_ON); + + REQUIRE_EQ(radio_set_region(&local, RADIO_REGION_US_915), ERROR_NONE); + CHECK(waitFor([] { return mesh_get_state() == MESH_STATE_ON; })); + CHECK_EQ(mesh_set_preset(nullptr, "Wide"), ERROR_NONE); + CHECK(waitFor([] { + int32_t bandwidth = 0; + return radio_get_parameter(&local, RADIO_PARAMETER_BANDWIDTH, &bandwidth) == ERROR_NONE && bandwidth == 500000; + })); + REQUIRE_EQ(mesh_set_preset(nullptr, "Default"), ERROR_NONE); + } + + SUBCASE("a path hash size outside the range is rejected") { + REQUIRE_EQ(radio_set_region(&local, RADIO_REGION_EU_868), ERROR_NONE); + REQUIRE_EQ(service_manager_start(MESH_SERVICE_ID), ERROR_NONE); + REQUIRE(waitFor([] { return mesh_get_state() == MESH_STATE_ON; })); + MeshNetworkSettings settings = { .path_hash_size = 4 }; + CHECK_EQ(mesh_set_network_settings(nullptr, &settings), ERROR_INVALID_ARGUMENT); + settings.path_hash_size = -1; + CHECK_EQ(mesh_set_network_settings(nullptr, &settings), ERROR_INVALID_ARGUMENT); + MeshNetworkSettings stored = { .path_hash_size = 9 }; + CHECK_EQ(mesh_get_network_settings(nullptr, &stored), ERROR_NONE); + CHECK_EQ(stored.path_hash_size, 0); + + settings.path_hash_size = MESH_PATH_HASH_SIZE_MAX; + CHECK_EQ(mesh_set_network_settings(nullptr, &settings), ERROR_NONE); + MeshNetworkSettings parameters = {}; + CHECK_EQ(mesh_get_network_parameters(nullptr, ¶meters), ERROR_NONE); + CHECK_EQ(parameters.path_hash_size, MESH_PATH_HASH_SIZE_MAX); + settings.path_hash_size = 0; + CHECK_EQ(mesh_set_network_settings(nullptr, &settings), ERROR_NONE); + } + SUBCASE("a preset reports the region it needs") { REQUIRE_EQ(radio_set_region(&local, RADIO_REGION_EU_868), ERROR_NONE); REQUIRE_EQ(service_manager_start(MESH_SERVICE_ID), ERROR_NONE); @@ -467,7 +538,7 @@ TEST_CASE("mesh service sends and receives through the active backend") { CHECK_EQ(mesh_get_radio_kind(3, &kind), ERROR_NOT_FOUND); CHECK_EQ(mesh_get_radio(name, sizeof(name)), ERROR_NONE); CHECK_EQ(std::string(name), "radio_local"); - CHECK_EQ(mesh_get_preset_count(nullptr), 2); + CHECK_EQ(mesh_get_preset_count(nullptr), 3); CHECK_FALSE(espNowFake.enabled); CHECK_EQ(mesh_set_radio("missing"), ERROR_NOT_FOUND); @@ -489,7 +560,7 @@ TEST_CASE("mesh service sends and receives through the active backend") { CHECK(waitFor([] { return !espNowFake.enabled && mesh_get_state() == MESH_STATE_ON; })); CHECK_EQ(mesh_get_radio(name, sizeof(name)), ERROR_NONE); CHECK_EQ(std::string(name), "radio_local"); - CHECK_EQ(mesh_get_preset_count(nullptr), 2); + CHECK_EQ(mesh_get_preset_count(nullptr), 3); REQUIRE_EQ(service_manager_stop(MESH_SERVICE_ID), ERROR_NONE); REQUIRE_EQ(radio_set_region(&espNow, RADIO_REGION_UNSET), ERROR_NONE); @@ -706,3 +777,74 @@ TEST_CASE("mesh service sends and receives through the active backend") { device_destruct(&local); file_system_remove(dataFs); } + +TEST_CASE("legacy mesh settings belong to the backend that owned them") { + char dataPath[] = "/tmp/mesh_settings_test_XXXXXX"; + REQUIRE_NE(mkdtemp(dataPath), nullptr); + FileSystem* dataFs = file_system_add("data", &fakeDataApi, dataPath); + + char directory[FILE_MAX_PATH_STRING_LENGTH]; + char path[FILE_MAX_PATH_STRING_LENGTH]; + REQUIRE_EQ(service_paths_get_user_data_directory(MESH_SERVICE_ID, directory, sizeof(directory)), ERROR_NONE); + REQUIRE_EQ(service_paths_get_user_data_path(MESH_SERVICE_ID, "settings.properties", path, sizeof(path)), ERROR_NONE); + REQUIRE_EQ(directory_make(directory, true), ERROR_NONE); + PropertiesFile* file = properties_file_open(path); + REQUIRE_NE(file, nullptr); + properties_file_set(file, "preset", "Other"); + properties_file_set(file, "power", "7"); + REQUIRE_EQ(properties_file_close(file), ERROR_NONE); + + CHECK_EQ(mesh_settings_migrate_legacy("first"), ERROR_NONE); + // A second call, for any backend, moves nothing + CHECK_EQ(mesh_settings_migrate_legacy("second"), ERROR_NONE); + + MeshBackendSettings first = {}; + mesh_settings_load_backend("first", &first); + CHECK_EQ(std::string(first.preset), "Other"); + CHECK_EQ(first.radio_settings.power, 7); + + MeshBackendSettings second = {}; + mesh_settings_load_backend("second", &second); + CHECK_EQ(std::string(second.preset), ""); + CHECK_EQ(second.radio_settings.power, 0); + + file_system_remove(dataFs); +} + +TEST_CASE("saving settings of two backends at the same time keeps both") { + char dataPath[] = "/tmp/mesh_settings_test_XXXXXX"; + REQUIRE_NE(mkdtemp(dataPath), nullptr); + FileSystem* dataFs = file_system_add("data", &fakeDataApi, dataPath); + + constexpr int ROUNDS = 40; + const auto save = [](void* backend) -> int32_t { + for (int i = 1; i <= ROUNDS; i++) { + MeshBackendSettings settings = {}; + settings.radio_settings.power = i; + if (mesh_settings_save_backend(static_cast(backend), &settings) != ERROR_NONE) { + return 1; + } + } + return 0; + }; + Thread* threads[2] = { + thread_alloc_full("save_first", 8192, save, const_cast("first"), -1), + thread_alloc_full("save_second", 8192, save, const_cast("second"), -1), + }; + for (auto* thread : threads) { + REQUIRE_EQ(thread_start(thread), ERROR_NONE); + } + for (auto* thread : threads) { + CHECK_EQ(thread_join(thread, pdMS_TO_TICKS(10000), 1), ERROR_NONE); + thread_free(thread); + } + + // Both threads finished and neither one's keys were lost. The race that the file lock prevents is too narrow to fail reliably without it. + MeshBackendSettings loaded = {}; + mesh_settings_load_backend("first", &loaded); + CHECK_EQ(loaded.radio_settings.power, ROUNDS); + mesh_settings_load_backend("second", &loaded); + CHECK_EQ(loaded.radio_settings.power, ROUNDS); + + file_system_remove(dataFs); +} diff --git a/Tactility/Source/app/launcher/AppsMode.cpp b/Tactility/Source/app/launcher/AppsMode.cpp index 71ba4d1b0..6c5edba07 100644 --- a/Tactility/Source/app/launcher/AppsMode.cpp +++ b/Tactility/Source/app/launcher/AppsMode.cpp @@ -111,7 +111,11 @@ std::vector collectItems() { if (a.highlighted != b.highlighted) { return a.highlighted; } - return strcasecmp(a.name.c_str(), b.name.c_str()) < 0; + const int nameOrder = strcasecmp(a.name.c_str(), b.name.c_str()); + if (nameOrder != 0) { + return nameOrder < 0; + } + return a.id < b.id; }); return items; } diff --git a/Tactility/Source/app/launcher/SettingsMode.cpp b/Tactility/Source/app/launcher/SettingsMode.cpp index 982c7f72a..f20a10dc9 100644 --- a/Tactility/Source/app/launcher/SettingsMode.cpp +++ b/Tactility/Source/app/launcher/SettingsMode.cpp @@ -53,7 +53,11 @@ std::vector collectItems() { std::vector<::AppManifest> collected; app_manager_for_each_manifest(collectManifest, &collected); std::ranges::sort(collected, [](const ::AppManifest& a, const ::AppManifest& b) { - return strcasecmp(a.name, b.name) < 0; + const int nameOrder = strcasecmp(a.name, b.name); + if (nameOrder != 0) { + return nameOrder < 0; + } + return strcmp(a.id, b.id) < 0; }); std::vector items; diff --git a/Tactility/Source/app/meshchat/MeshChannels.cpp b/Tactility/Source/app/meshchat/MeshChannels.cpp index 3e78f14ba..e965155f4 100644 --- a/Tactility/Source/app/meshchat/MeshChannels.cpp +++ b/Tactility/Source/app/meshchat/MeshChannels.cpp @@ -200,7 +200,7 @@ void startJoinChannel(Context* ctx) { ctx->joinChannelDialogId = inputdialog::start( ctx->appInstanceId, "Join channel", - "Hashtag channel name, e.g. #test, or Public", + "Hashtag channel name, e.g. #test. To join Public, delete the # first.", "#", ctx->channelStream, ctx->channelBuffer, diff --git a/Tactility/Source/app/meshsettings/MeshSettings.cpp b/Tactility/Source/app/meshsettings/MeshSettings.cpp index 5974b143a..fccfbe5f3 100644 --- a/Tactility/Source/app/meshsettings/MeshSettings.cpp +++ b/Tactility/Source/app/meshsettings/MeshSettings.cpp @@ -585,7 +585,10 @@ void onPresetSelected(Context* ctx, int32_t selected) { // A preset can need another region. It's set first so the radio never gets a preset that its region doesn't allow. RadioRegion region = RADIO_REGION_UNSET; if (mesh_get_preset_region(ctx->presetBackend, name, ®ion) == ERROR_NONE && region != RADIO_REGION_UNSET && region != settings::radio::loadRegion()) { - settings::radio::saveRegion(region); + if (!settings::radio::saveRegion(region)) { + LOG_E(TAG, "Failed to save the region, so the preset isn't selected"); + return; + } } mesh_set_preset(ctx->presetBackend, name); } From 9b666ba9120db4b658648f6ecca13895c705c10f Mon Sep 17 00:00:00 2001 From: Ken Van Hoeylandt Date: Fri, 30 Oct 2026 00:20:22 +0100 Subject: [PATCH 10/11] Fixes --- .../source/meshcore_backend.cpp | 30 ++---- .../source/meshtastic_backend.cpp | 24 +++-- .../mesh-module/private/mesh/mesh_settings.h | 7 +- Modules/mesh-module/source/mesh_service.cpp | 102 ++++++++++++------ Modules/mesh-module/source/mesh_settings.cpp | 7 +- .../mesh-module/tests/source/service_test.cpp | 26 ++++- 6 files changed, 126 insertions(+), 70 deletions(-) diff --git a/Modules/mesh-meshcore-module/source/meshcore_backend.cpp b/Modules/mesh-meshcore-module/source/meshcore_backend.cpp index c3b4276b0..9b1d43f1f 100644 --- a/Modules/mesh-meshcore-module/source/meshcore_backend.cpp +++ b/Modules/mesh-meshcore-module/source/meshcore_backend.cpp @@ -190,31 +190,19 @@ void addChannel(Backend* backend, uint8_t index, const char* name, const uint8_t std::sort(backend->channels.begin(), backend->channels.end(), [](const Channel& a, const Channel& b) { return a.index < b.index; }); } -// File: magic (4) + channel count (1) + records. Record: index (1) + name (32) + key (16). -// The count and the size tell a complete file from one of an interrupted save, and an empty list from either. -constexpr char CHANNELS_MAGIC[4] = { 'M', 'C', 'C', 'H' }; -constexpr size_t CHANNELS_HEADER_SIZE = sizeof(CHANNELS_MAGIC) + 1; +// File: records of index (1) + name (32) + key (16). The format is the one of older versions, which can read a file back. +// A save replaces the file as a whole, so a file that is whole records is complete and an empty one is an empty list. constexpr size_t CHANNEL_RECORD_SIZE = 1 + MESH_NAME_MAX_LENGTH + 1 + 16; /** - * Adds the channels of a saved file. Files from before the header have records only, and can't be empty. - * @return false when the file is incomplete, in which case no channel was added + * Adds the channels of a saved file. + * @return false when the file ends in a partial record, in which case no channel was added */ bool parseChannels(Backend* backend, const tt::std::vector& data) { - size_t offset = 0; - size_t count; - if (data.size() >= CHANNELS_HEADER_SIZE && memcmp(data.data(), CHANNELS_MAGIC, sizeof(CHANNELS_MAGIC)) == 0) { - count = data[sizeof(CHANNELS_MAGIC)]; - offset = CHANNELS_HEADER_SIZE; - if (data.size() != CHANNELS_HEADER_SIZE + count * CHANNEL_RECORD_SIZE) { - return false; - } - } else if (!data.empty() && data.size() % CHANNEL_RECORD_SIZE == 0) { - count = data.size() / CHANNEL_RECORD_SIZE; - } else { + if (data.size() % CHANNEL_RECORD_SIZE != 0) { return false; } - for (size_t i = 0; i < count; i++, offset += CHANNEL_RECORD_SIZE) { + for (size_t offset = 0; offset < data.size(); offset += CHANNEL_RECORD_SIZE) { char name[MESH_NAME_MAX_LENGTH + 1]; memcpy(name, data.data() + offset + 1, sizeof(name)); name[MESH_NAME_MAX_LENGTH] = '\0'; @@ -249,8 +237,6 @@ void saveChannels(Backend* backend) { return; } tt::std::vector data; - data.insert(data.end(), CHANNELS_MAGIC, CHANNELS_MAGIC + sizeof(CHANNELS_MAGIC)); - data.push_back(static_cast(backend->channels.size())); for (const auto& channel : backend->channels) { data.push_back(channel.index); data.insert(data.end(), channel.name, channel.name + MESH_NAME_MAX_LENGTH + 1); @@ -531,8 +517,10 @@ constexpr auto presets = [] { bool fitsRegion(int32_t frequency, int32_t bandwidth, RadioRegion region) { const RadioRegionBand* bands = nullptr; const size_t count = radio_get_region_bands(region, &bands); + const int64_t low = static_cast(frequency) - bandwidth / 2; + const int64_t high = static_cast(frequency) + bandwidth / 2; return std::any_of(bands, bands + count, [=](const RadioRegionBand& band) { - return frequency - bandwidth / 2 >= band.frequency_min && frequency + bandwidth / 2 <= band.frequency_max; + return low >= band.frequency_min && high <= band.frequency_max; }); } diff --git a/Modules/mesh-meshtastic-module/source/meshtastic_backend.cpp b/Modules/mesh-meshtastic-module/source/meshtastic_backend.cpp index 9d1a15b12..8fc088323 100644 --- a/Modules/mesh-meshtastic-module/source/meshtastic_backend.cpp +++ b/Modules/mesh-meshtastic-module/source/meshtastic_backend.cpp @@ -18,6 +18,7 @@ #include #include #include +#include #include #ifdef ESP_PLATFORM @@ -207,16 +208,19 @@ bool isDefaultKey(const MeshtasticChannelKey& key) { // Record: index (1) + name (32) + key length (1) + key (32) constexpr size_t CHANNEL_RECORD_SIZE = 1 + MESH_NAME_MAX_LENGTH + 1 + 1 + 32; -// Files that follow the preset start with this marker. Earlier files have records only, and stored the primary channel -// that was never customised as "LongFast", which can't be told from one that was named that on purpose. -constexpr char CHANNELS_MAGIC[4] = { 'M', 'T', 'C', 'H' }; +// The records keep the format of older versions, which can read the file back. A file next to it marks that the primary channel +// that was never customised is stored without a name. Earlier files stored it as "LongFast", which can't be told from one that was +// named that on purpose. +constexpr auto* CHANNELS_MARKER_FILE = "channels.preset"; void loadChannels(Backend* backend) { char path[FILE_MAX_PATH_STRING_LENGTH]; tt::std::vector data; if (mesh_host_get_data_path(backend->host, "channels.bin", path, sizeof(path)) == ERROR_NONE && readFile(path, data)) { - const bool hasMarker = data.size() >= sizeof(CHANNELS_MAGIC) && memcmp(data.data(), CHANNELS_MAGIC, sizeof(CHANNELS_MAGIC)) == 0; - for (size_t offset = hasMarker ? sizeof(CHANNELS_MAGIC) : 0; offset + CHANNEL_RECORD_SIZE <= data.size(); offset += CHANNEL_RECORD_SIZE) { + char markerPath[FILE_MAX_PATH_STRING_LENGTH]; + const bool hasMarker = mesh_host_get_data_path(backend->host, CHANNELS_MARKER_FILE, markerPath, sizeof(markerPath)) == ERROR_NONE && + access(markerPath, F_OK) == 0; + for (size_t offset = 0; offset + CHANNEL_RECORD_SIZE <= data.size(); offset += CHANNEL_RECORD_SIZE) { char name[MESH_NAME_MAX_LENGTH + 1]; memcpy(name, data.data() + offset + 1, sizeof(name)); name[MESH_NAME_MAX_LENGTH] = '\0'; @@ -243,7 +247,6 @@ void saveChannels(Backend* backend) { return; } tt::std::vector data; - data.insert(data.end(), CHANNELS_MAGIC, CHANNELS_MAGIC + sizeof(CHANNELS_MAGIC)); for (const auto& channel : backend->channels) { data.push_back(channel.index); const char emptyName[MESH_NAME_MAX_LENGTH + 1] = {}; @@ -252,7 +255,14 @@ void saveChannels(Backend* backend) { data.push_back(channel.key.length); data.insert(data.end(), channel.key.bytes, channel.key.bytes + 32); } - writeFile(path, data.data(), data.size()); + if (!writeFile(path, data.data(), data.size())) { + return; + } + char markerPath[FILE_MAX_PATH_STRING_LENGTH]; + if (mesh_host_get_data_path(backend->host, CHANNELS_MARKER_FILE, markerPath, sizeof(markerPath)) == ERROR_NONE) { + const uint8_t marker = 1; + writeFile(markerPath, &marker, sizeof(marker)); + } } // endregion diff --git a/Modules/mesh-module/private/mesh/mesh_settings.h b/Modules/mesh-module/private/mesh/mesh_settings.h index 7dff33ba5..ec60ced96 100644 --- a/Modules/mesh-module/private/mesh/mesh_settings.h +++ b/Modules/mesh-module/private/mesh/mesh_settings.h @@ -21,8 +21,11 @@ struct MeshBackendSettings { struct MeshNetworkSettings network; }; -/** Loads the settings of a backend, or defaults when there are none */ -void mesh_settings_load_backend(const char* backend, struct MeshBackendSettings* settings); +/** + * Loads the settings of a backend, or defaults when there are none. + * @retval ERROR_RESOURCE when the settings file couldn't be opened, in which case @a settings has the defaults and the load can be tried again + */ +error_t mesh_settings_load_backend(const char* backend, struct MeshBackendSettings* settings); /** * Settings were once stored without a backend, for the backend that was selected. Moves them to @a owner once. diff --git a/Modules/mesh-module/source/mesh_service.cpp b/Modules/mesh-module/source/mesh_service.cpp index 46896a143..808c92fc7 100644 --- a/Modules/mesh-module/source/mesh_service.cpp +++ b/Modules/mesh-module/source/mesh_service.cpp @@ -75,6 +75,10 @@ struct Globals { struct BackendSettingsEntry { const MeshBackendApi* api; MeshBackendSettings settings; + /** False when the settings couldn't be read, so the entry has defaults and is loaded again at the next lookup */ + bool loaded; + /** Held by a setter from reading the settings to committing them, so that two updates of one backend don't overwrite each other. Taken before the global lock. */ + Mutex update; } backendSettings[MAX_BACKENDS] = {}; size_t backendSettingsCount = 0; bool legacySettingsMigrated = false; @@ -183,11 +187,15 @@ const MeshBackendApi* resolveBackend(const char* id) { } /** Requires the global lock and loaded settings */ -MeshBackendSettings& getBackendSettings(const MeshBackendApi* api) { +/** Requires the global lock and loaded settings */ +Globals::BackendSettingsEntry& getBackendEntry(const MeshBackendApi* api) { auto& entries = globals().backendSettings; for (size_t i = 0; i < globals().backendSettingsCount; i++) { if (entries[i].api == api) { - return entries[i].settings; + if (!entries[i].loaded) { + entries[i].loaded = mesh_settings_load_backend(api->id, &entries[i].settings) == ERROR_NONE; + } + return entries[i]; } } if (!globals().legacySettingsMigrated) { @@ -201,8 +209,14 @@ MeshBackendSettings& getBackendSettings(const MeshBackendApi* api) { } auto& entry = entries[globals().backendSettingsCount < MAX_BACKENDS ? globals().backendSettingsCount++ : MAX_BACKENDS - 1]; entry.api = api; - mesh_settings_load_backend(api->id, &entry.settings); - return entry.settings; + mutex_construct(&entry.update); + entry.loaded = mesh_settings_load_backend(api->id, &entry.settings) == ERROR_NONE; + return entry; +} + +/** Requires the global lock and loaded settings */ +MeshBackendSettings& getBackendSettings(const MeshBackendApi* api) { + return getBackendEntry(api).settings; } size_t getPresetCount(const MeshBackendApi* api) { @@ -609,6 +623,19 @@ bool applyProfile(MeshHost* host) { void resubmitPending(MeshHost* host); +/** @return the band of @a region that holds the whole channel of width @a bandwidth around @a frequency, NULL when there is none */ +const RadioRegionBand* findBand(RadioRegion region, int32_t frequency, int32_t bandwidth) { + const RadioRegionBand* bands = nullptr; + const size_t count = radio_get_region_bands(region, &bands); + // 64-bit, because a frequency that was typed in can be close to the limit of 32 bits + const int64_t low = static_cast(frequency) - bandwidth / 2; + const int64_t high = static_cast(frequency) + bandwidth / 2; + const auto* band = std::find_if(bands, bands + count, [=](const RadioRegionBand& candidate) { + return low >= candidate.frequency_min && high <= candidate.frequency_max; + }); + return (band != bands + count) ? band : nullptr; +} + /** * The profile of a preset with the settings applied, and the power limited to what the region allows (the radio rejects more). * @param[in] backend the backend state, or NULL to only get the parameters of the preset @@ -624,14 +651,10 @@ error_t getRadioProfile(const MeshBackendApi* api, void* backend, RadioRegion re if (settings.coding_rate != 0) profile->coding_rate = settings.coding_rate; if (settings.power != 0) profile->power = settings.power; - const RadioRegionBand* bands = nullptr; - const size_t bandCount = radio_get_region_bands(region, &bands); - int32_t maxPower = 0; - for (size_t i = 0; i < bandCount; i++) { - maxPower = std::max(maxPower, bands[i].power_max); - } - if (bandCount > 0) { - profile->power = std::min(profile->power, maxPower); + // The limit of the band that holds the channel, because bands of a region have their own limits + const RadioRegionBand* band = findBand(region, profile->frequency, profile->bandwidth); + if (band != nullptr) { + profile->power = std::min(profile->power, band->power_max); } return ERROR_NONE; } @@ -1534,15 +1557,6 @@ error_t mesh_get_preset_name(const char* backend, size_t index, char* name, size return result; } -/** Whether the whole channel of width @a bandwidth around @a frequency lies in one band of @a region */ -static bool fitsRegion(RadioRegion region, int32_t frequency, int32_t bandwidth) { - const RadioRegionBand* bands = nullptr; - const size_t count = radio_get_region_bands(region, &bands); - return std::any_of(bands, bands + count, [=](const RadioRegionBand& band) { - return frequency - bandwidth / 2 >= band.frequency_min && frequency + bandwidth / 2 <= band.frequency_max; - }); -} - /** * Persists the settings of a backend, and only when that worked uses them: the cache is updated and the radio is * reconfigured when the backend is selected. Called without the global lock, because saving does file operations. @@ -1553,7 +1567,10 @@ static error_t commitBackendSettings(const MeshBackendApi* api, const MeshBacken return saved; } lock(); - getBackendSettings(api) = settings; + auto& entry = getBackendEntry(api); + entry.settings = settings; + // What was saved is what the backend runs with, whether or not the file could be read before + entry.loaded = true; if (service != nullptr && api == getSelectedBackend()) { MeshHost* host = service; post(host, [host] { configure(host); }); @@ -1562,6 +1579,13 @@ static error_t commitBackendSettings(const MeshBackendApi* api, const MeshBacken return ERROR_NONE; } +/** Holds the update mutex of a backend. Taken without the global lock, and the global lock is taken inside. */ +struct UpdateGuard { + Mutex* mutex; + explicit UpdateGuard(Mutex* mutex) : mutex(mutex) { mutex_lock(mutex); } + ~UpdateGuard() { mutex_unlock(mutex); } +}; + error_t mesh_set_preset(const char* backend, const char* name) { const bool applies = presetsApply(); Device* radio = findRadio(); @@ -1577,18 +1601,20 @@ error_t mesh_set_preset(const char* backend, const char* name) { unlock(); return ERROR_NOT_FOUND; } + Mutex* updateMutex = &getBackendEntry(api).update; + unlock(); + + UpdateGuard guard(updateMutex); MeshBackendSettings settings = {}; strncpy(settings.preset, name, MESH_NAME_MAX_LENGTH); if (region != RADIO_REGION_UNSET) { // The radio would reject a preset that doesn't fit the region, which would leave the mesh in the error state MeshRadioProfile profile {}; if (getRadioProfile(api, nullptr, region, RADIO_MODULATION_LORA, index, settings.radio_settings, &profile) != ERROR_NONE || - !fitsRegion(region, profile.frequency, profile.bandwidth)) { - unlock(); + findBand(region, profile.frequency, profile.bandwidth) == nullptr) { return ERROR_NOT_SUPPORTED; } } - unlock(); return commitBackendSettings(api, settings); } @@ -1640,18 +1666,25 @@ error_t mesh_set_radio_settings(const char* backend, const MeshRadioSettings* ra unlock(); return ERROR_NOT_FOUND; } + Mutex* updateMutex = &getBackendEntry(api).update; + unlock(); + + UpdateGuard guard(updateMutex); + lock(); MeshBackendSettings settings = getBackendSettings(api); + unlock(); settings.radio_settings = *radio_settings; if (applies && region != RADIO_REGION_UNSET) { - // The radio rejects a channel outside the bands of the region, which would leave the mesh in the error state + // The radio rejects a channel outside the bands of the region, and a power above the limit of the band that holds + // the channel. Either would leave the mesh in the error state. MeshRadioProfile profile {}; - if (getRadioProfile(api, nullptr, region, RADIO_MODULATION_LORA, findPreset(api, settings.preset), settings.radio_settings, &profile) == ERROR_NONE && - !fitsRegion(region, profile.frequency, profile.bandwidth)) { - unlock(); - return ERROR_OUT_OF_RANGE; + if (getRadioProfile(api, nullptr, region, RADIO_MODULATION_LORA, findPreset(api, settings.preset), settings.radio_settings, &profile) == ERROR_NONE) { + const RadioRegionBand* band = findBand(region, profile.frequency, profile.bandwidth); + if (band == nullptr || settings.radio_settings.power > band->power_max) { + return ERROR_OUT_OF_RANGE; + } } } - unlock(); return commitBackendSettings(api, settings); } @@ -1679,9 +1712,14 @@ error_t mesh_set_network_settings(const char* backend, const MeshNetworkSettings unlock(); return ERROR_NOT_FOUND; } + Mutex* updateMutex = &getBackendEntry(api).update; + unlock(); + + UpdateGuard guard(updateMutex); + lock(); MeshBackendSettings settings = getBackendSettings(api); - settings.network = *network_settings; unlock(); + settings.network = *network_settings; return commitBackendSettings(api, settings); } diff --git a/Modules/mesh-module/source/mesh_settings.cpp b/Modules/mesh-module/source/mesh_settings.cpp index 9c38edac7..334f3b5e2 100644 --- a/Modules/mesh-module/source/mesh_settings.cpp +++ b/Modules/mesh-module/source/mesh_settings.cpp @@ -167,15 +167,15 @@ error_t mesh_settings_migrate_legacy(const char* owner) { return properties_file_close(file) == ERROR_NONE ? ERROR_NONE : ERROR_RESOURCE; } -void mesh_settings_load_backend(const char* backend, MeshBackendSettings* settings) { +error_t mesh_settings_load_backend(const char* backend, MeshBackendSettings* settings) { *settings = {}; char path[FILE_MAX_PATH_STRING_LENGTH]; if (service_paths_get_user_data_path(MESH_SERVICE_ID, SETTINGS_FILE_NAME, path, sizeof(path)) != ERROR_NONE) { - return; + return ERROR_RESOURCE; } PropertiesFile* file = properties_file_open(path); if (file == nullptr) { - return; + return ERROR_RESOURCE; } char value[64]; char name[MESH_BACKEND_ID_MAX_LENGTH + 24]; @@ -205,6 +205,7 @@ void mesh_settings_load_backend(const char* backend, MeshBackendSettings* settin strncpy(settings->network.region_scope, value, MESH_NAME_MAX_LENGTH); } properties_file_close(file); + return ERROR_NONE; } error_t mesh_settings_save_backend(const char* backend, const MeshBackendSettings* settings) { diff --git a/Modules/mesh-module/tests/source/service_test.cpp b/Modules/mesh-module/tests/source/service_test.cpp index 369adcf93..2e6c3a1c7 100644 --- a/Modules/mesh-module/tests/source/service_test.cpp +++ b/Modules/mesh-module/tests/source/service_test.cpp @@ -472,15 +472,31 @@ TEST_CASE("mesh service sends and receives through the active backend") { CHECK_EQ(mesh_get_preset_region(nullptr, "Missing", ®ion), ERROR_NOT_FOUND); } - SUBCASE("the power is limited to what the region allows") { + SUBCASE("the power is limited to what the band of the channel allows") { REQUIRE_EQ(radio_set_region(&local, RADIO_REGION_EU_868), ERROR_NONE); - const MeshRadioSettings tooPowerful = { .power = 40 }; - REQUIRE_EQ(mesh_set_radio_settings(nullptr, &tooPowerful), ERROR_NONE); + // 869.525 MHz is in the 27 dBm band of EU_868, and 868.1 MHz in the 14 dBm band + MeshRadioSettings settings = { .power = 40 }; + CHECK_EQ(mesh_set_radio_settings(nullptr, &settings), ERROR_OUT_OF_RANGE); + settings = { .frequency = 868100000, .power = 20 }; + CHECK_EQ(mesh_set_radio_settings(nullptr, &settings), ERROR_OUT_OF_RANGE); + + settings = { .power = 27 }; + REQUIRE_EQ(mesh_set_radio_settings(nullptr, &settings), ERROR_NONE); REQUIRE_EQ(service_manager_start(MESH_SERVICE_ID), ERROR_NONE); - CHECK(waitFor([] { return mesh_get_state() == MESH_STATE_ON; })); + REQUIRE(waitFor([] { return mesh_get_state() == MESH_STATE_ON; })); int32_t power = 0; CHECK_EQ(radio_get_parameter(&local, RADIO_PARAMETER_POWER, &power), ERROR_NONE); CHECK_EQ(power, 27); + + settings = { .frequency = 868100000, .power = 14 }; + REQUIRE_EQ(mesh_set_radio_settings(nullptr, &settings), ERROR_NONE); + CHECK(waitFor([] { + int32_t current = 0; + return radio_get_parameter(&local, RADIO_PARAMETER_POWER, ¤t) == ERROR_NONE && current == 14; + })); + CHECK_EQ(mesh_get_state(), MESH_STATE_ON); + + // Without a setting, the power of the preset is limited to the band, here 14 dBm of the preset to 14 dBm const MeshRadioSettings none = {}; REQUIRE_EQ(mesh_set_radio_settings(nullptr, &none), ERROR_NONE); } @@ -799,7 +815,7 @@ TEST_CASE("legacy mesh settings belong to the backend that owned them") { CHECK_EQ(mesh_settings_migrate_legacy("second"), ERROR_NONE); MeshBackendSettings first = {}; - mesh_settings_load_backend("first", &first); + CHECK_EQ(mesh_settings_load_backend("first", &first), ERROR_NONE); CHECK_EQ(std::string(first.preset), "Other"); CHECK_EQ(first.radio_settings.power, 7); From ad62dbdf0432c93d5deff87cfd44c2e910897e80 Mon Sep 17 00:00:00 2001 From: Ken Van Hoeylandt Date: Fri, 30 Oct 2026 07:59:10 +0100 Subject: [PATCH 11/11] Update Modules/mesh-module/source/mesh_service.cpp Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- Modules/mesh-module/source/mesh_service.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Modules/mesh-module/source/mesh_service.cpp b/Modules/mesh-module/source/mesh_service.cpp index 808c92fc7..04917519e 100644 --- a/Modules/mesh-module/source/mesh_service.cpp +++ b/Modules/mesh-module/source/mesh_service.cpp @@ -1680,7 +1680,7 @@ error_t mesh_set_radio_settings(const char* backend, const MeshRadioSettings* ra MeshRadioProfile profile {}; if (getRadioProfile(api, nullptr, region, RADIO_MODULATION_LORA, findPreset(api, settings.preset), settings.radio_settings, &profile) == ERROR_NONE) { const RadioRegionBand* band = findBand(region, profile.frequency, profile.bandwidth); - if (band == nullptr || settings.radio_settings.power > band->power_max) { + if (profile.bandwidth <= 0 || band == nullptr || settings.radio_settings.power > band->power_max) { return ERROR_OUT_OF_RANGE; } }