diff --git a/Modules/mesh-meshcore-module/README.md b/Modules/mesh-meshcore-module/README.md index d48f8c48a..e06abf8c7 100644 --- a/Modules/mesh-meshcore-module/README.md +++ b/Modules/mesh-meshcore-module/README.md @@ -7,17 +7,21 @@ 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. 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..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,6 +12,12 @@ 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 + * @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); /** 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..941e2b7a2 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,16 @@ 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. + * @return false when the HMAC couldn't be computed, in which case @a code is not set + */ +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: * 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..9b1d43f1f 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 @@ -49,6 +50,8 @@ struct PendingAck { uint64_t deadlineMs; }; +constexpr auto* PUBLIC_CHANNEL_NAME = "Public"; + struct Backend { MeshHost* host = nullptr; MeshCoreIdentity identity {}; @@ -99,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))) { @@ -148,21 +190,44 @@ 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: 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. + * @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) { + if (data.size() % CHANNEL_RECORD_SIZE != 0) { + return false; + } + 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'; + 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; - if (getPath(backend, "channels.bin", path, sizeof(path)) && readFile(path, data)) { - 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 (backend->channels.empty()) { - addChannel(backend, 0, "Public", MESHCORE_PUBLIC_CHANNEL_KEY); + if (!saved) { + addChannel(backend, 0, PUBLIC_CHANNEL_NAME, MESHCORE_PUBLIC_CHANNEL_KEY); } } @@ -177,7 +242,9 @@ void saveChannels(Backend* backend) { 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 @@ -198,7 +265,39 @@ 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. + * @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) { + *result = packet; + return true; + } + MeshNetworkSettings network; + mesh_host_get_network(backend->host, &network); + 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); + uint16_t code; + if (!meshcore_transport_code(key, packet, &code)) { + return false; + } + packet.route = MeshCoreRoute::TransportFlood; + packet.transportCodes[0] = code; + packet.transportCodes[1] = 0; + } + *result = packet; + return true; +} + +bool transmit(Backend* backend, const MeshCorePacket& original, uint32_t seq) { + 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) { @@ -344,9 +443,111 @@ 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); + 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 low >= band.frequency_min && high <= 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 +571,43 @@ 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]; + if (!fitsRegion(match.frequency, match.bandwidth, region)) { return ERROR_NOT_SUPPORTED; + } + 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) { @@ -545,6 +736,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; } @@ -577,8 +770,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..b728c0c60 100644 --- a/Modules/mesh-meshcore-module/source/meshcore_crypto.cpp +++ b/Modules/mesh-meshcore-module/source/meshcore_crypto.cpp @@ -27,13 +27,8 @@ 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); + // On failure the MAC is all zeros, which doesn't match any valid MAC + meshcore_hmac_sha256(secret, MESHCORE_SECRET_SIZE, data, length, mac); } /** AES-128-ECB over whole blocks */ @@ -58,6 +53,18 @@ bool aesEcb(const uint8_t secret[MESHCORE_SECRET_SIZE], bool encrypt, const uint } // namespace +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; + 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) { 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..3bd7c767d 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,29 @@ 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); +} + +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]; + 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 true; +} + 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..896acabb1 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,100 @@ 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); + uint16_t code = 0; + REQUIRE(meshcore_transport_code(key, packet, &code)); + CHECK_EQ(code, 0xEEC8); + + packet.payloadType = MeshCorePayload::Advert; + packet.payloadLength = 0; + 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") { + 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); +} + +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/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..8fc088323 100644 --- a/Modules/mesh-meshtastic-module/source/meshtastic_backend.cpp +++ b/Modules/mesh-meshtastic-module/source/meshtastic_backend.cpp @@ -15,8 +15,10 @@ #include #include +#include #include #include +#include #include #ifdef ESP_PLATFORM @@ -35,9 +37,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; @@ -63,6 +92,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 { @@ -150,24 +181,45 @@ 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; +// 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)) { + 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)); @@ -176,15 +228,16 @@ 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 + // 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); } } } 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); } } @@ -196,11 +249,20 @@ 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); } - 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 @@ -341,24 +403,44 @@ 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 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 && 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; + } + } +} + +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) { + followPreset(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), @@ -502,6 +584,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; @@ -518,7 +604,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-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/backend.h b/Modules/mesh-module/include/mesh/backend.h index dbaa38f00..96f211fb7 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); @@ -90,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); }; @@ -143,6 +160,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..4676be32b 100644 --- a/Modules/mesh-module/include/mesh/mesh.h +++ b/Modules/mesh-module/include/mesh/mesh.h @@ -205,23 +205,98 @@ 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 + * @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); /** - * 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. + * 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); + +/** 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 */ + 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_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); + +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 @@ -328,9 +403,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/private/mesh/mesh_settings.h b/Modules/mesh-module/private/mesh/mesh_settings.h index a7991e598..ec60ced96 100644 --- a/Modules/mesh-module/private/mesh/mesh_settings.h +++ b/Modules/mesh-module/private/mesh/mesh_settings.h @@ -8,13 +8,35 @@ 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. + * @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. + * 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 + */ +error_t mesh_settings_migrate_legacy(const char* owner); + +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..899e5bfbe 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,15 @@ static int command_preset(int argc, char* argv[]) { } if (argc == 3) { - error_t error = mesh_set_preset(argv[2]); + // 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]); return 1; @@ -153,15 +161,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..04917519e 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,17 @@ 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; + /** 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; MeshEventSubscription* subscriptions = nullptr; }; @@ -169,6 +181,44 @@ 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 */ +/** 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) { + if (!entries[i].loaded) { + entries[i].loaded = mesh_settings_load_backend(api->id, &entries[i].settings) == ERROR_NONE; + } + return entries[i]; + } + } + 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) { + if (mesh_settings_migrate_legacy(owner->id) == ERROR_NONE) { + globals().legacySettingsMigrated = true; + } + } + } + auto& entry = entries[globals().backendSettingsCount < MAX_BACKENDS ? globals().backendSettingsCount++ : MAX_BACKENDS - 1]; + entry.api = api; + 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) { size_t count = 0; if (api->presets != nullptr) { @@ -261,6 +311,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 +623,62 @@ 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 + */ +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; + + // 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; +} + +/** 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); + } + // 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') { + // 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 +697,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 +722,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 +1277,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 +1504,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 +1532,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 +1557,94 @@ 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) { +/** + * 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(); + 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); }); + } + unlock(); + 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(); + const RadioRegion region = (radio != nullptr) ? radio_get_region(radio) : RADIO_REGION_UNSET; + if (radio != nullptr) { + device_put(radio); + } 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); }); + 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 || + findBand(region, profile.frequency, profile.bandwidth) == nullptr) { + return ERROR_NOT_SUPPORTED; + } + } + return commitBackendSettings(api, settings); +} + +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 +1652,141 @@ 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) { + 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); + if (api == nullptr) { + 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, 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) { + const RadioRegionBand* band = findBand(region, profile.frequency, profile.bandwidth); + if (profile.bandwidth <= 0 || band == nullptr || settings.radio_settings.power > band->power_max) { + return ERROR_OUT_OF_RANGE; + } + } + } + return commitBackendSettings(api, 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) { + 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); + if (api == nullptr) { + unlock(); + return ERROR_NOT_FOUND; + } + Mutex* updateMutex = &getBackendEntry(api).update; + unlock(); + + UpdateGuard guard(updateMutex); + lock(); + MeshBackendSettings settings = getBackendSettings(api); + unlock(); + settings.network = *network_settings; + return commitBackendSettings(api, 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 && 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 {}; + // 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; + 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; @@ -1730,9 +2039,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/source/mesh_settings.cpp b/Modules/mesh-module/source/mesh_settings.cpp index f13690ab9..334f3b5e2 100644 --- a/Modules/mesh-module/source/mesh_settings.cpp +++ b/Modules/mesh-module/source/mesh_settings.cpp @@ -3,12 +3,14 @@ #include +#include #include #include #include #include #include +#include #include #ifdef ESP_PLATFORM @@ -22,6 +24,14 @@ 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_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 @@ -36,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) { @@ -54,9 +81,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 +103,138 @@ 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) { + FileLock fileLock; + 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); } + +/** 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); +} + +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 ERROR_RESOURCE; + } + PropertiesFile* file = properties_file_open(path); + if (file == nullptr) { + return ERROR_RESOURCE; + } + if (properties_file_has(file, KEY_LEGACY_MIGRATED)) { + properties_file_close(file); + return ERROR_NONE; + } + char value[64]; + char name[MESH_BACKEND_ID_MAX_LENGTH + 24]; + 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); + } + } + properties_file_set(file, KEY_LEGACY_MIGRATED, "true"); + return properties_file_close(file) == ERROR_NONE ? ERROR_NONE : ERROR_RESOURCE; +} + +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 ERROR_RESOURCE; + } + PropertiesFile* file = properties_file_open(path); + if (file == nullptr) { + return ERROR_RESOURCE; + } + char value[64]; + 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 { + 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) { + qualified_name(backend, number.key, name, sizeof(name)); + if (properties_file_get(file, name, value, sizeof(value)) == ERROR_NONE) { + *number.target = atoi(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); + return ERROR_NONE; +} + +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; + } + 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..2e6c3a1c7 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, @@ -81,6 +85,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 +148,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 +298,207 @@ 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), 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(2, name, sizeof(name)), ERROR_NOT_FOUND); - CHECK_EQ(mesh_get_preset(name, sizeof(name)), ERROR_NONE); + 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"); - 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 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); + 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 band of the channel allows") { + REQUIRE_EQ(radio_set_region(&local, RADIO_REGION_EU_868), 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); + 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); } SUBCASE("a new node name is announced by the backend") { @@ -350,7 +554,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), 3); CHECK_FALSE(espNowFake.enabled); CHECK_EQ(mesh_set_radio("missing"), ERROR_NOT_FOUND); @@ -363,7 +567,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 +576,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), 3); REQUIRE_EQ(service_manager_stop(MESH_SERVICE_ID), ERROR_NONE); REQUIRE_EQ(radio_set_region(&espNow, RADIO_REGION_UNSET), ERROR_NONE); @@ -558,8 +762,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); @@ -589,3 +793,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 = {}; + CHECK_EQ(mesh_settings_load_backend("first", &first), ERROR_NONE); + 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 ebd3db2a0..6c5edba07 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,11 @@ std::vector collectItems() { if (a.highlighted != b.highlighted) { return a.highlighted; } - return a.name < b.name; + 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 35cfdc0be..f20a10dc9 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,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 strcmp(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 a42b4513e..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", + "Hashtag channel name, e.g. #test. To join Public, delete the # first.", "#", 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 6b198c75f..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; @@ -479,7 +477,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 c82fef5b3..fccfbe5f3 100644 --- a/Tactility/Source/app/meshsettings/MeshSettings.cpp +++ b/Tactility/Source/app/meshsettings/MeshSettings.cpp @@ -25,12 +25,16 @@ #include #include +#include #include #include #include #include #include +#include +#include +#include #include #include #include @@ -42,6 +46,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 +60,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 +89,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 +111,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 +137,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 +164,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 +200,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 +251,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 +333,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 +348,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 +407,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 && *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); + 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 +561,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 +581,16 @@ 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()) { + 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); } } @@ -464,19 +674,49 @@ 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); + // 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 + }; + 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 +727,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 +753,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 +780,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 +791,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 +819,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); @@ -594,7 +857,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; @@ -602,14 +865,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; } @@ -668,7 +937,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 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