Files
MeshCore/examples/companion_radio/MeshtextCompanion.cpp
mikecarper 9ce2a076eb Include and qualify Meshtext in RAK4631 Full Companion
Port Meshtext settings to verified atomic nRF52 storage and use the existing radio RNG and hardware entropy. Keep the ExtraFS boundary, GPS, contacts, channel count and 8 KiB stack unchanged; omit unused library data to fit the image. Require linked codec and Companion transport evidence and build the real RAK Full profile in CI. Add configuration and storage-failure regression coverage.
2026-10-12 22:39:00 -07:00

636 lines
31 KiB
C++

#include "MyMesh.h"
#if MESH_ENABLE_MESHTEXT_CHAT
#if defined(NRF52_PLATFORM)
#include <helpers/NRF52Crypto.h>
#else
#include <Preferences.h>
#include <esp_system.h>
#endif
#include <helpers/CLICommandUtils.h>
#include <helpers/UTF8Helpers.h>
namespace {
constexpr size_t LegacySettingsSize = 16;
constexpr size_t SettingsSize = 40;
bool validShortName(const char* name) {
if (!name) return false;
const size_t length = strnlen(name, 17);
if (length > 16 || mesh::validUtf8PrefixLength(name, length) != length) return false;
unsigned characters = 0;
for (size_t i = 0; i < length;) {
const uint8_t first = static_cast<uint8_t>(name[i++]);
uint32_t scalar = first;
unsigned remaining = 0;
if (first >= 0xf0) { scalar &= 7; remaining = 3; }
else if (first >= 0xe0) { scalar &= 15; remaining = 2; }
else if (first >= 0xc0) { scalar &= 31; remaining = 1; }
while (remaining--) scalar = (scalar << 6) | (static_cast<uint8_t>(name[i++]) & 63);
// Reject control/bidi characters in a displayed NodeInfo short name.
if (scalar <= 32 || (scalar >= 0x7f && scalar <= 0x9f) || scalar == ':'
|| (scalar >= 0x200b && scalar <= 0x200f) || (scalar >= 0x2028 && scalar <= 0x202f)
|| (scalar >= 0x2060 && scalar <= 0x206f) || scalar == 0xfeff) return false;
if (++characters > 4) return false;
}
return true; // Empty is the stored default, selected explicitly by the CLI.
}
void makeWireShortName(const char* configured, uint32_t node, char out[5]) {
// Stock User.short_name has four UTF-8 bytes, not four Unicode scalars.
// Project older saved labels without changing their persistent settings.
const size_t length = mesh::validUtf8PrefixLength(configured, 4);
if (length) {
memcpy(out, configured, length);
out[length] = 0;
} else {
snprintf(out, 5, "%04lx", (unsigned long)(node & 0xffff));
}
}
bool validNodeOverride(uint32_t node) {
return node == 0 || (node >= 4 && node != UINT32_MAX);
}
void makeSettings(uint8_t image[SettingsSize], mesh::mt::Region region, uint32_t slot,
uint8_t hops, const char* short_name, uint32_t node_override) {
memset(image, 0, SettingsSize);
memcpy(image, "MT03", 4);
image[4] = static_cast<uint8_t>(region);
image[5] = hops;
for (unsigned i = 0; i < 4; ++i) image[8 + i] = slot >> (8 * i);
memcpy(image + 12, short_name, strlen(short_name));
for (unsigned i = 0; i < 4; ++i) image[32 + i] = node_override >> (8 * i);
mesh::Utils::sha256(image + 36, 4, image, 36);
}
bool describe(const ChannelDetails& channel, mesh::mt::Channel& wire) {
return mesh::mt::describeChannel(channel.name, channel.channel.secret, wire,
[](uint8_t* out, size_t size, const uint8_t* in, size_t length) {
mesh::Utils::sha256(out, size, in, length);
});
}
bool nextToken(const char*& cursor, char* out, size_t capacity) {
while (*cursor == ' ' || *cursor == '\t') ++cursor;
size_t size = 0;
while (*cursor && *cursor != ' ' && *cursor != '\t') {
if (size + 1 >= capacity) return false;
out[size++] = *cursor++;
}
out[size] = 0;
return size != 0;
}
bool endTokens(const char* cursor) {
while (*cursor == ' ' || *cursor == '\t') ++cursor;
return !*cursor;
}
}
mesh::mt::Region MyMesh::meshtextRegion() const {
return _mt_region == mesh::mt::Region::Unset
? mesh::mt::inferRegion(_prefs.freq) : _mt_region;
}
void MyMesh::refreshMeshtextIdentity() {
uint8_t digest[4];
mesh::Utils::sha256(digest, sizeof(digest), self_id.pub_key, PUB_KEY_SIZE);
_mt_default_node = uint32_t(digest[0]) | (uint32_t(digest[1]) << 8)
| (uint32_t(digest[2]) << 16) | (uint32_t(digest[3]) << 24);
if (_mt_default_node < 4 || _mt_default_node == UINT32_MAX) _mt_default_node = 0x0badf00d;
_mt_node = _mt_node_override ? _mt_node_override : _mt_default_node;
_mt_seen.clear();
// Importing a new MeshCore identity may change the derived sender ID, but
// must never restart the packet sequence used by any earlier sender ID.
}
void MyMesh::beginMeshtext() {
refreshMeshtextIdentity();
// Independent random boot seed, then monotonic IDs. Never reuse a packet ID
// within this boot (including failed admission) with the same CTR sender.
#if defined(NRF52_PLATFORM)
getRNG()->random(reinterpret_cast<uint8_t*>(&_mt_packet_id), sizeof(_mt_packet_id));
#if defined(USE_CC310_HW_CRYPTO)
mesh::mixCC310Random(reinterpret_cast<uint8_t*>(&_mt_packet_id), sizeof(_mt_packet_id));
#endif
#else
_mt_packet_id = esp_random();
#endif
if (!_mt_packet_id) _mt_packet_id = 1;
uint8_t image[SettingsSize] = {}, expected[SettingsSize] = {};
#if defined(NRF52_PLATFORM)
size_t size = 0;
if (!_store->loadMeshtextSettings(image, size)) { _mt_settings_valid = false; return; }
#else
Preferences prefs;
if (!prefs.begin("mesh-mt", false)) { _mt_settings_valid = false; return; }
const size_t size = prefs.getBytesLength("settings");
#endif
if (size) {
#if defined(NRF52_PLATFORM)
_mt_settings_valid = size == SettingsSize;
#else
_mt_settings_valid = (size == SettingsSize || size == LegacySettingsSize)
&& prefs.getBytes("settings", image, size) == size;
#endif
if (_mt_settings_valid) {
const auto region = static_cast<mesh::mt::Region>(image[4]);
const bool legacy = size == LegacySettingsSize;
const bool legacy_v1 = legacy && memcmp(image, "MT01", 4) == 0;
const uint8_t hops = legacy_v1 ? 7 : image[5];
const uint32_t slot = uint32_t(image[8]) | (uint32_t(image[9]) << 8)
| (uint32_t(image[10]) << 16) | (uint32_t(image[11]) << 24);
if (legacy) {
memcpy(expected, legacy_v1 ? "MT01" : "MT02", 4);
expected[4] = image[4]; expected[5] = legacy_v1 ? 0 : hops;
memcpy(expected + 8, image + 8, 4);
mesh::Utils::sha256(expected + 12, 4, expected, 12);
} else if (validShortName(reinterpret_cast<const char*>(image + 12))) {
const uint32_t node_override = uint32_t(image[32]) | (uint32_t(image[33]) << 8)
| (uint32_t(image[34]) << 16) | (uint32_t(image[35]) << 24);
if (validNodeOverride(node_override))
makeSettings(expected, region, slot, hops, reinterpret_cast<const char*>(image + 12), node_override);
}
_mt_settings_valid = region <= mesh::mt::Region::ANZ && slot <= 1024 && hops <= 7
&& memcmp(image, expected, size) == 0;
if (_mt_settings_valid) {
_mt_region = region; _mt_slot = slot; _mt_hop_limit = hops;
if (!legacy) {
memcpy(_mt_short_name, image + 12, sizeof(_mt_short_name));
_mt_node_override = uint32_t(image[32]) | (uint32_t(image[33]) << 8)
| (uint32_t(image[34]) << 16) | (uint32_t(image[35]) << 24);
_mt_node = _mt_node_override ? _mt_node_override : _mt_default_node;
}
}
}
}
#if !defined(NRF52_PLATFORM)
prefs.end();
#endif
refreshMeshtextProfile();
}
bool MyMesh::saveMeshtextSettings(mesh::mt::Region region, uint32_t slot, uint8_t hops,
const char* short_name, uint32_t node_override) {
if (hops == UINT8_MAX) hops = _mt_hop_limit;
if (!short_name) short_name = _mt_short_name;
if (node_override == UINT32_MAX) node_override = _mt_node_override;
if (region > mesh::mt::Region::ANZ || slot > 1024 || hops > 7 || !validShortName(short_name)
|| !validNodeOverride(node_override) || hasForeignRadioFrame()) return false;
uint8_t image[SettingsSize];
makeSettings(image, region, slot, hops, short_name, node_override);
#if defined(NRF52_PLATFORM)
const bool ok = _store->saveMeshtextSettings(image);
#else
uint8_t verify[SettingsSize];
Preferences prefs;
if (!prefs.begin("mesh-mt", false)) return false;
// NVS commits this one blob atomically; never split region and slot into
// separate commits which could join an unintended network after power loss.
const bool ok = prefs.putBytes("settings", image, sizeof(image)) == sizeof(image)
&& prefs.getBytes("settings", verify, sizeof(verify)) == sizeof(verify)
&& memcmp(image, verify, sizeof(image)) == 0;
prefs.end();
#endif
if (!ok) {
// A failed read-back cannot prove whether storage committed. Suspend the
// backend rather than silently use one network now and another at boot.
_mt_settings_valid = false;
_mt_profile_dirty = true;
refreshMeshtextProfile();
return false;
}
_mt_region = region; _mt_slot = slot; _mt_hop_limit = hops; _mt_settings_valid = true;
memcpy(_mt_short_name, image + 12, sizeof(_mt_short_name));
_mt_node_override = node_override;
_mt_node = node_override ? node_override : _mt_default_node;
// Keep the packet sequence monotonic even when changing away from and back
// to a sender ID. Never reset it on a name/settings change (CTR nonce reuse).
_mt_profile_dirty = true;
_mt_seen.clear();
refreshMeshtextProfile();
return true;
}
bool MyMesh::eraseMeshtextSettings() {
// The filesystem erase also removes channels/identity on the next boot.
// Do not keep sending with their still-resident RAM copies until then.
_mt_active = false;
_mt_settings_valid = false;
_mt_profile_dirty = true;
_mt_seen.clear();
_radio_profiles.clearAutoSecondary();
_radio_profiles.clearSecondaryProtocol();
#if defined(NRF52_PLATFORM)
const bool ok = _store->eraseMeshtextSettings();
#else
Preferences prefs;
if (!prefs.begin("mesh-mt", false)) {
_mt_status = "Meshtext settings erase failed";
return false;
}
const bool ok = !prefs.getBytesLength("settings") || prefs.remove("settings");
prefs.end();
#endif
if (ok) {
_mt_region = mesh::mt::Region::Unset; _mt_slot = 0; _mt_hop_limit = 7;
_mt_short_name[0] = 0; _mt_node_override = 0; _mt_node = _mt_default_node;
}
_mt_status = ok ? "settings erased; reboot required" : "Meshtext settings erase failed";
return ok;
}
bool MyMesh::isMeshtextChannel(const ChannelDetails& channel) const {
const auto alias = parseCompanionChannelAlias(channel.name);
if (alias.kind != CompanionChannelKind::Meshtext) return false;
// With the backend present, Meshtext names are explicit protocol requests even
// while setup is blocked. Never silently emit a different wire protocol.
return true;
}
bool MyMesh::validateMeshtextChannelChange(int index, const ChannelDetails& candidate) {
const auto candidate_kind = parseCompanionChannelAlias(candidate.name).kind;
// Unrelated MeshCore edits and removals remain possible even when an old
// Meshtext configuration is suspended or incompatible with a new firmware build.
if (!candidate.name[0] || candidate_kind == CompanionChannelKind::MeshCore) return true;
bool have_preset = false, have_mt = false, have_r2 = false;
mesh::mt::Preset preset = mesh::mt::Preset::LongFast;
for (int i = 0; i < MAX_GROUP_CHANNELS; ++i) {
ChannelDetails channel;
if (i == index) channel = candidate;
else if (!BaseChatMesh::getChannel(i, channel)) continue;
if (!channel.name[0]) continue;
const auto alias = parseCompanionChannelAlias(channel.name);
if (alias.kind == CompanionChannelKind::Radio2) have_r2 = true;
if (alias.kind != CompanionChannelKind::Meshtext) continue;
mesh::mt::Preset found;
const bool named = mesh::mt::parsePreset(alias.tail, found);
mesh::mt::Channel wire;
if (!describe(channel, wire)) return false;
have_mt = true;
if (named) {
if (have_preset && preset != found) return false;
preset = found; have_preset = true;
}
}
// radio2 is one modem profile, not a second physical transceiver. There is
// no silent last-channel-wins behavior and no protocol bridging.
if (have_mt && have_r2) return false;
if (!have_mt) return true;
if (!_radio->supportsMeshtext() || !_mt_settings_valid) return false;
if (have_preset) {
mesh::RadioProfileParams requested;
if (!mesh::mt::modemConfig(meshtextRegion(), preset, requested, _mt_slot)
|| !_radio->validateProfile(requested)) return false;
if (_radio_profiles.manualSecondaryConfigured()) {
const auto saved = _radio_profiles.savedSecondaryConfig();
if (!mesh::mt::sameModulation(saved.params, requested)
|| !mesh::mt::frequencyInRegion(meshtextRegion(), saved.params)) return false;
} else if (!_radio_profiles.canAutoSecondary()) return false;
}
return true;
}
void MyMesh::refreshMeshtextProfile() {
const auto* current_profiles = _radio->profiles();
const uint32_t primary_generation = current_profiles ? current_profiles->generation[0] : 0;
const uint32_t secondary_generation = current_profiles ? current_profiles->generation[1] : 0;
const uint32_t intent_generation = _radio_profiles.secondaryIntentGeneration();
if (!_mt_profile_dirty && primary_generation == _mt_primary_generation
&& secondary_generation == _mt_secondary_generation && intent_generation == _mt_intent_generation
&& _mt_last_radio_ready == _radio_available && _mt_primary_frequency == _prefs.freq) return;
// Hashing names and scanning the channel table every radio loop would add
// blind time to the dual-profile scanner. Recompute only on configuration
// or lease/generation edges; RX/TX still derive a fresh bounded wire copy.
struct CacheUpdate {
MyMesh& owner;
~CacheUpdate() {
const auto* p = owner._radio->profiles();
owner._mt_primary_generation = p ? p->generation[0] : 0;
owner._mt_secondary_generation = p ? p->generation[1] : 0;
owner._mt_intent_generation = owner._radio_profiles.secondaryIntentGeneration();
owner._mt_last_radio_ready = owner._radio_available;
owner._mt_primary_frequency = owner._prefs.freq;
owner._mt_profile_dirty = false;
}
} cache_update{*this};
_mt_active = false;
bool have_mt = false, have_r2 = false, have_preset = false, conflict = false;
mesh::mt::Preset preset = mesh::mt::Preset::LongFast;
for (int i = 0; i < MAX_GROUP_CHANNELS; ++i) {
ChannelDetails channel;
if (!BaseChatMesh::getChannel(i, channel) || !channel.name[0]) continue;
const auto alias = parseCompanionChannelAlias(channel.name);
if (alias.kind == CompanionChannelKind::Radio2) have_r2 = true;
if (alias.kind != CompanionChannelKind::Meshtext) continue;
mesh::mt::Preset found;
const bool named = mesh::mt::parsePreset(alias.tail, found);
have_mt = true;
mesh::mt::Channel wire;
if (!describe(channel, wire)) conflict = true;
if (named) {
if (have_preset && preset != found) conflict = true;
preset = found; have_preset = true;
}
}
const char* blocked = nullptr;
if (!have_mt) blocked = "no Meshtext channels";
else if (conflict || have_r2) blocked = "conflicting radio2 channels/presets";
else if (!_radio_available || !_radio->supportsMeshtext()) blocked = "modem unavailable";
else if (!_mt_settings_valid) blocked = "invalid Meshtext settings; set mt.region to repair";
else if (meshtextRegion() == mesh::mt::Region::Unset) blocked = "region ambiguous; set mt.region";
if (blocked) {
_mt_status = blocked;
_radio_profiles.clearAutoSecondary();
_radio_profiles.clearSecondaryProtocol();
return;
}
mesh::RadioProfileParams requested;
if (have_preset && (!mesh::mt::modemConfig(meshtextRegion(), preset, requested, _mt_slot)
|| !_radio->validateProfile(requested))) {
_mt_status = "preset/slot unavailable in this region or modem";
_radio_profiles.clearAutoSecondary();
_radio_profiles.clearSecondaryProtocol();
return;
}
if (_radio_profiles.manualSecondaryConfigured()) {
const auto saved = _radio_profiles.savedSecondaryConfig();
if (!mesh::mt::frequencyInRegion(meshtextRegion(), saved.params)
|| (have_preset && !mesh::mt::sameModulation(saved.params, requested))) {
_mt_status = "manual radio2 does not match Meshtext preset/region";
_radio_profiles.clearSecondaryProtocol();
return;
}
_mt_active = _radio_profiles.useSecondaryProtocol(mesh::RadioProfileProtocol::Meshtext);
} else if (have_preset) {
mesh::RadioProfileConfig config;
config.params = requested; config.mode = mesh::RadioProfileMode::RxTx;
const auto* profiles = _radio->profiles();
if (_radio_profiles.autoSecondaryActive() && profiles
&& profiles->secondary.params != requested) {
_radio_profiles.clearAutoSecondary(); // only release the profile we own
}
_mt_active = _radio_profiles.requestAutoSecondary(config);
} else {
// A custom channel cannot reconstruct an RF preset after reboot. Losing
// its last named-preset dependency must release the auto owner, not leave
// a ghost-enabled secondary or silently turn into MeshCore channel chat.
_radio_profiles.clearAutoSecondary();
_radio_profiles.clearSecondaryProtocol();
_mt_status = "custom Meshtext channel needs a named preset or manual radio2";
return;
}
_mt_status = _mt_active ? "active" : "radio2 manually off, leased, scheduled or busy";
}
bool MyMesh::sendMeshtextText(const ChannelDetails& channel, const char* text, size_t length) {
refreshMeshtextProfile();
if (!_mt_active || !text || !length || hasForeignRadioFrame()) return false;
const auto* profiles = _radio->profiles();
if (!profiles || !profiles->canTransmit(1)
|| profiles->secondary.params.protocol != mesh::RadioProfileProtocol::Meshtext) return false;
mesh::mt::Channel wire;
if (!describe(channel, wire)) return false;
// Stock TEXT_MESSAGE_APP contains the user's text only. Sender identity is
// in the radio header; name metadata requires a separately requested User.
if (length > mesh::mt::MaxTextSize) return false;
if (!_mt_packet_id) return false; // exhaustion: do not wrap/reuse CTR nonces.
const uint32_t id = _mt_packet_id++;
uint8_t raw[mesh::mt::MaxFrameSize];
// Outbound Meshtext channel chat defaults to seven hops; the CLI can reduce it.
// This does not enable forwarding or ACK responses in this Companion.
const size_t size = mesh::mt::encodeText(_mt_node, id, _mt_hop_limit, wire.name, wire.key,
wire.key_length, text, length, raw);
return size && queueForeignRadioFrame(raw, size, 1, profiles->generation[1]);
}
bool MyMesh::sendMeshtextNodeInfo(const ChannelDetails& channel) {
refreshMeshtextProfile();
if (!_mt_active || hasForeignRadioFrame()) return false;
const auto* profiles = _radio->profiles();
if (!profiles || !profiles->canTransmit(1)
|| profiles->secondary.params.protocol != mesh::RadioProfileProtocol::Meshtext) return false;
mesh::mt::Channel wire;
if (!describe(channel, wire)) return false;
const size_t full_length = strnlen(_prefs.node_name, sizeof(_prefs.node_name));
if (full_length == sizeof(_prefs.node_name)
|| mesh::validUtf8PrefixLength(_prefs.node_name, full_length) != full_length) return false;
const size_t long_length = mesh::validUtf8PrefixLength(_prefs.node_name, 24);
char short_name[5];
makeWireShortName(_mt_short_name, _mt_node, short_name);
if (!_mt_packet_id) return false;
// Share the text packet sequence: separate counters would reuse CTR nonces.
const uint32_t id = _mt_packet_id++;
uint8_t raw[mesh::mt::MaxFrameSize];
const size_t size = mesh::mt::encodeNodeInfo(_mt_node, id, _mt_hop_limit, wire.name, wire.key,
wire.key_length, _prefs.node_name, long_length, short_name, strlen(short_name), raw);
// Only the explicit CLI reaches here. No timer, RX reply or chat piggyback.
return size && queueForeignRadioFrame(raw, size, 1, profiles->generation[1]);
}
bool MyMesh::onForeignRadioFrame(const uint8_t* bytes, size_t length, uint8_t profile,
uint32_t generation, float snr, float rssi) {
(void)rssi;
const auto* profiles = _radio->profiles();
if (!_mt_active || !profiles || profile != 1 || generation != profiles->generation[1]) return false;
for (int i = 0; i < MAX_GROUP_CHANNELS; ++i) {
ChannelDetails channel;
mesh::mt::Channel wire;
if (!BaseChatMesh::getChannel(i, channel) || !describe(channel, wire)) continue;
mesh::mt::DecodedText decoded;
if (!mesh::mt::decodeText(bytes, length, wire.name, wire.key, wire.key_length, _mt_node, decoded)) continue;
if (_mt_seen.seen(decoded.from, decoded.id)) return true;
char text[MAX_FRAME_SIZE];
const int prefix = snprintf(text, sizeof(text), "!%08lx: ", (unsigned long)decoded.from);
size_t copy = decoded.text_len;
if (copy > sizeof(text) - prefix - 1) copy = sizeof(text) - prefix - 1;
// Preserve UTF-8 when fitting a stock peer text into the Companion envelope.
while (copy < decoded.text_len && copy && (uint8_t(decoded.text[copy]) & 0xc0) == 0x80) --copy;
memcpy(text + prefix, decoded.text, copy); text[prefix + copy] = 0;
const uint32_t timestamp = getRTCClock()->getCurrentTime();
ChannelDetails policy_channel;
if (!getChannel(i, policy_channel)) return false;
if (queueChannelText(i, policy_channel.channel.secret, snr, decoded.hops, timestamp, text, false)) {
_mt_seen.remember(decoded.from, decoded.id);
++_mt_received;
}
return true;
}
return false;
}
void MyMesh::onForeignRadioSendComplete(bool success) {
if (success) ++_mt_sent;
else ++_mt_failed;
}
bool MyMesh::handleMeshtextCommand(const char* command, char* reply, size_t capacity) {
const char* nodeinfo_args = !strncmp(command, "send mt.nodeinfo ", 17) ? command + 17 : nullptr;
if (!strcmp(command, "send mt.nodeinfo")) nodeinfo_args = "";
if (nodeinfo_args) {
char index_text[12]; uint32_t index = 0;
ChannelDetails channel;
if (!nextToken(nodeinfo_args, index_text, sizeof(index_text)) || !endTokens(nodeinfo_args)
|| !mesh::cli::parseUnsignedIntegerStrict(index_text, index) || index >= MAX_GROUP_CHANNELS) {
snprintf(reply, capacity, "Error: use send mt.nodeinfo <channel slot>");
} else if (!getChannelForSave(index, channel) || !isMeshtextChannel(channel)) {
snprintf(reply, capacity, "Error: configured Meshtext channel required");
} else if (!sendMeshtextNodeInfo(channel)) {
snprintf(reply, capacity, "Error: NodeInfo not queued; check get mt.status, TX mode or pending TX");
} else {
snprintf(reply, capacity, "OK - NodeInfo queued on Meshtext channel %lu; no automatic adverts",
(unsigned long)index);
}
return true;
}
const char* channel_args = !strncmp(command, "set mt.channel ", 15) ? command + 15 : nullptr;
const char* channel_get = !strncmp(command, "get mt.channel ", 15) ? command + 15 : nullptr;
const char* channel_delete = !strncmp(command, "delete mt.channel ", 18) ? command + 18 : nullptr;
if (channel_args || channel_get || channel_delete) {
const char* cursor = channel_args ? channel_args : channel_get ? channel_get : channel_delete;
char index_text[12]; uint32_t index = 0;
if (!nextToken(cursor, index_text, sizeof(index_text))
|| !mesh::cli::parseUnsignedIntegerStrict(index_text, index) || index >= MAX_GROUP_CHANNELS) {
snprintf(reply, capacity, "Error: invalid channel slot"); return true;
}
ChannelDetails previous{}, channel{};
if (!getChannelForSave(index, previous)) {
snprintf(reply, capacity, "Error: channel slot unavailable"); return true;
}
if (channel_get || channel_delete) {
if (!endTokens(cursor) || !previous.name[0] || !isMeshtextChannel(previous)) {
snprintf(reply, capacity, "Error: Meshtext channel not found or extra arguments"); return true;
}
if (channel_get) {
mesh::mt::Channel wire;
if (!describe(previous, wire)) snprintf(reply, capacity, "Error: invalid Meshtext channel");
else snprintf(reply, capacity, "> %lu %s; wire=%s; AES-%u; key not displayed",
(unsigned long)index, previous.name, wire.name, unsigned(wire.key_length * 8));
return true;
}
} else {
char key_text[65];
if (!nextToken(cursor, channel.name, sizeof(channel.name))
|| !nextToken(cursor, key_text, sizeof(key_text)) || !endTokens(cursor)
|| !isMeshtextChannel(channel)) {
snprintf(reply, capacity, "Error: use set mt.channel <slot> <mt-name> <32/64 hex digits|AQ==>"); return true;
}
if (!strcmp(key_text, "AQ==")) memcpy(channel.channel.secret, mesh::mt::DefaultPsk, 16);
else {
const size_t digits = strlen(key_text);
if ((digits != 32 && digits != 64)
|| !mesh::Utils::fromHex(channel.channel.secret, digits / 2, key_text)) {
snprintf(reply, capacity, "Error: key must be 32/64 hex digits or AQ=="); return true;
}
}
if (!validateMeshtextChannelChange(index, channel)) {
snprintf(reply, capacity, "Error: conflicting Meshtext preset, region, manual radio2 or busy owner"); return true;
}
}
if (!setChannel(index, channel)) snprintf(reply, capacity, "Error: invalid Meshtext channel");
else if (!saveChannels()) {
setChannel(index, previous);
snprintf(reply, capacity, "Error: channel not saved; previous channel restored");
} else {
_mt_seen.clear(); _mt_profile_dirty = true;
refreshMeshtextProfile();
snprintf(reply, capacity, "OK - Meshtext channel %lu %s; %s", (unsigned long)index,
channel_delete ? "deleted" : "saved", _mt_status);
}
return true;
}
const char* preset_value = !strncmp(command, "set mt.preset ", 14) ? command + 14 : nullptr;
if (preset_value) {
mesh::mt::Preset preset; mesh::RadioProfileParams requested;
if (!mesh::mt::parsePreset(preset_value, preset)
|| !mesh::mt::modemConfig(meshtextRegion(), preset, requested, _mt_slot)
|| !_radio->supportsMeshtext() || !_radio->validateProfile(requested)) {
snprintf(reply, capacity, "Error: invalid Meshtext preset/region/slot; use set mt.region first"); return true;
}
// Explicit provisioning for custom private names. This is a manual saved
// radio2 choice, not an automatic alias owner, so deletion cannot undo it.
char radio_command[96];
snprintf(radio_command, sizeof(radio_command), "set radio2 %.6f,%g,%u,%u,rxtx",
requested.freq, requested.bw, requested.sf, requested.cr);
_radio_profiles.handle(radio_command, reply, capacity);
_mt_profile_dirty = true;
refreshMeshtextProfile();
return true;
}
const bool status = !strcmp(command, "get mt") || !strcmp(command, "get mt.status");
const bool get_region = !strcmp(command, "get mt.region");
const bool get_slot = !strcmp(command, "get mt.slot");
const bool get_hops = !strcmp(command, "get mt.hops");
const bool get_node = !strcmp(command, "get mt.node");
const bool get_short_name = !strcmp(command, "get mt.shortname");
const char* region_value = !strncmp(command, "set mt.region ", 14) ? command + 14 : nullptr;
const char* slot_value = !strncmp(command, "set mt.slot ", 12) ? command + 12 : nullptr;
const char* hops_value = !strncmp(command, "set mt.hops ", 12) ? command + 12 : nullptr;
const char* node_value = !strncmp(command, "set mt.node ", 12) ? command + 12 : nullptr;
const char* short_name_value = !strncmp(command, "set mt.shortname ", 17) ? command + 17 : nullptr;
if (!strcmp(command, "set mt.node")) node_value = "";
if (!strcmp(command, "set mt.shortname")) short_name_value = "";
if (!status && !get_region && !get_slot && !get_hops && !get_node && !get_short_name
&& !region_value && !slot_value && !hops_value && !node_value && !short_name_value) return false;
if (region_value || slot_value || hops_value || node_value || short_name_value) {
auto region = _mt_region; uint32_t slot = _mt_slot;
uint32_t hops = _mt_hop_limit;
uint32_t node_override = _mt_node_override;
const char* short_name = _mt_short_name;
if (region_value && !mesh::mt::parseRegion(region_value, region)) {
snprintf(reply, capacity, "Error: use set mt.region auto|us|eu868|eu433|anz"); return true;
}
if (slot_value && (!mesh::cli::parseUnsignedIntegerStrict(slot_value, slot) || slot > 1024)) {
snprintf(reply, capacity, "Error: mt.slot must be 0 (preset default) or 1..1024"); return true;
}
if (hops_value && (!mesh::cli::parseUnsignedIntegerStrict(hops_value, hops) || hops > 7)) {
snprintf(reply, capacity, "Error: mt.hops must be 0..7"); return true;
}
if (node_value) {
if (!strcmp(node_value, "default")) node_override = 0;
else {
if (*node_value == '!') ++node_value;
uint8_t bytes[4];
if (strlen(node_value) != 8 || !mesh::Utils::fromHex(bytes, sizeof(bytes), node_value)) {
snprintf(reply, capacity, "Error: use set mt.node !<8 hex digits>|default"); return true;
}
node_override = (uint32_t(bytes[0]) << 24) | (uint32_t(bytes[1]) << 16)
| (uint32_t(bytes[2]) << 8) | uint32_t(bytes[3]);
if (!node_override || !validNodeOverride(node_override)) {
snprintf(reply, capacity, "Error: mt.node IDs 0..3 and ffffffff are reserved; use default to reset"); return true;
}
}
}
if (short_name_value) {
short_name = !strcmp(short_name_value, "default") ? "" : short_name_value;
if ((!*short_name && strcmp(short_name_value, "default")) || !validShortName(short_name)) {
snprintf(reply, capacity, "Error: mt.shortname must be 1..4 UTF-8 characters (no spaces/control/colon), or default"); return true;
}
}
if (!saveMeshtextSettings(region, slot, hops, short_name, node_override))
snprintf(reply, capacity, "Error: Meshtext settings not saved (pending TX or storage error)");
else if (node_value)
snprintf(reply, capacity, "OK - mt.node=!%08lx (%s); choose a unique ID", (unsigned long)_mt_node,
_mt_node_override ? "saved override" : "identity default");
else if (short_name_value)
snprintf(reply, capacity, "OK - mt.shortname=%s; node=!%08lx", _mt_short_name[0] ? _mt_short_name : "default",
(unsigned long)_mt_node);
else snprintf(reply, capacity, "OK - mt.region=%s mt.slot=%lu mt.hops=%lu; %s", mesh::mt::regionName(region), (unsigned long)slot, (unsigned long)hops, _mt_status);
return true;
}
refreshMeshtextProfile();
if (get_region) snprintf(reply, capacity, "> %s; effective=%s", mesh::mt::regionName(_mt_region), mesh::mt::regionName(meshtextRegion()));
else if (get_slot) snprintf(reply, capacity, "> %lu%s", (unsigned long)_mt_slot, _mt_slot ? "" : " (preset default)");
else if (get_hops) snprintf(reply, capacity, "> %u", _mt_hop_limit);
else if (get_node) snprintf(reply, capacity, "> !%08lx (%s)", (unsigned long)_mt_node,
_mt_node_override ? "saved override" : "identity default");
else if (get_short_name) {
char short_name[5]; makeWireShortName(_mt_short_name, _mt_node, short_name);
snprintf(reply, capacity, "> %s; effective=%s (manual NodeInfo only)",
_mt_short_name[0] ? _mt_short_name : "default", short_name);
}
else {
const auto* profiles = _radio->profiles();
const auto p = profiles ? profiles->secondary.params : mesh::RadioProfileParams{};
snprintf(reply, capacity, "> Meshtext !%08lx; %s; %s; %.3f/%g/SF%u/CR%u; hops=%u; RX=%lu TX=%lu fail=%lu; RX time-shared",
(unsigned long)_mt_node, _mt_status, _radio_profiles.autoSecondaryActive() ? "auto radio2" : "manual radio2",
p.freq, p.bw, p.sf, p.cr, _mt_hop_limit, (unsigned long)_mt_received, (unsigned long)_mt_sent, (unsigned long)_mt_failed);
}
return true;
}
#endif