965 lines
47 KiB
C++
965 lines
47 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;
|
|
constexpr uint32_t AutoNodeInfoQuietMillis = 250;
|
|
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,
|
|
bool rebroadcast = false) {
|
|
memset(image, 0, SettingsSize);
|
|
memcpy(image, "MT04", 4);
|
|
image[4] = static_cast<uint8_t>(region);
|
|
image[5] = hops;
|
|
image[6] = rebroadcast ? 1 : 0;
|
|
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 channelFingerprint(const ChannelDetails& channel, uint32_t node, uint8_t out[8]) {
|
|
mesh::mt::Channel wire;
|
|
if (!describe(channel, wire)) return false;
|
|
uint8_t input[48] = {};
|
|
memcpy(input, wire.name, sizeof(wire.name));
|
|
memcpy(input + 12, wire.key, sizeof(wire.key));
|
|
for (unsigned i = 0; i < 4; ++i) input[44 + i] = node >> (8 * i);
|
|
mesh::Utils::sha256(out, 8, input, sizeof(input));
|
|
return true;
|
|
}
|
|
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() {
|
|
for (auto& frame : _mt_delivery) frame.cancelled = true;
|
|
memset(_mt_nodeinfo_announced, 0, sizeof(_mt_nodeinfo_announced));
|
|
memset(_mt_nodeinfo_pending, 0, sizeof(_mt_nodeinfo_pending));
|
|
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 bool legacy_v3 = !legacy && memcmp(image, "MT03", 4) == 0;
|
|
const uint8_t rebroadcast = legacy || legacy_v3 ? 0 : image[6];
|
|
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) && rebroadcast <= 1) {
|
|
makeSettings(expected, region, slot, hops, reinterpret_cast<const char*>(image + 12),
|
|
node_override, rebroadcast);
|
|
if (legacy_v3) {
|
|
memcpy(expected, "MT03", 4);
|
|
mesh::Utils::sha256(expected + 36, 4, expected, 36);
|
|
}
|
|
}
|
|
}
|
|
_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;
|
|
_mt_rebroadcast = rebroadcast != 0;
|
|
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, uint8_t rebroadcast) {
|
|
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 (rebroadcast == UINT8_MAX) rebroadcast = _mt_rebroadcast ? 1 : 0;
|
|
if (region > mesh::mt::Region::ANZ || slot > 1024 || hops > 7 || !validShortName(short_name)
|
|
|| !validNodeOverride(node_override) || rebroadcast > 1 || hasForeignRadioFrame()) return false;
|
|
uint8_t image[SettingsSize];
|
|
makeSettings(image, region, slot, hops, short_name, node_override, rebroadcast);
|
|
#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;
|
|
}
|
|
const bool announcement_changed = region != _mt_region || slot != _mt_slot
|
|
|| node_override != _mt_node_override || strcmp(short_name, _mt_short_name) != 0
|
|
|| (rebroadcast != 0) != _mt_rebroadcast;
|
|
_mt_region = region; _mt_slot = slot; _mt_hop_limit = hops; _mt_settings_valid = true;
|
|
_mt_rebroadcast = rebroadcast != 0;
|
|
for (auto& frame : _mt_delivery) frame.cancelled = true;
|
|
if (announcement_changed)
|
|
memset(_mt_nodeinfo_announced, 0, sizeof(_mt_nodeinfo_announced));
|
|
memset(_mt_nodeinfo_pending, 0, sizeof(_mt_nodeinfo_pending));
|
|
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() {
|
|
_mt_rebroadcast = false;
|
|
for (auto& frame : _mt_delivery) frame.cancelled = true;
|
|
memset(_mt_nodeinfo_pending, 0, sizeof(_mt_nodeinfo_pending));
|
|
// 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() {
|
|
#if MESH_ENABLE_ETHERNET_TUNNEL
|
|
// This owner must never revoke an Ethernet lease simply because it has
|
|
// no Meshtext channels. Explicit channel conflicts suspend Ethernet too.
|
|
if (_ethernet_settings.enabled) {
|
|
_mt_active = false;
|
|
_mt_status = "radio2 reserved for Ethernet";
|
|
_mt_profile_dirty = true;
|
|
return;
|
|
}
|
|
#endif
|
|
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();
|
|
const bool secondary_ready = _radio_profiles.secondaryRuntimeAvailable();
|
|
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
|
|
&& _mt_last_secondary_ready == secondary_ready) 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/readiness edges. Publishing an unchanged saved tuple
|
|
// can release a busy guard without changing either RF generation.
|
|
// 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_last_secondary_ready = owner._radio_profiles.secondaryRuntimeAvailable();
|
|
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";
|
|
}
|
|
|
|
int MyMesh::meshtextChannelIndex(const ChannelDetails& channel, uint8_t fingerprint[8]) {
|
|
if (!channelFingerprint(channel, _mt_node, fingerprint)) return -1;
|
|
for (int i = 0; i < MAX_GROUP_CHANNELS; ++i) {
|
|
ChannelDetails candidate;
|
|
uint8_t other[8];
|
|
if (BaseChatMesh::getChannel(i, candidate)
|
|
&& channelFingerprint(candidate, _mt_node, other)
|
|
&& memcmp(other, fingerprint, sizeof(other)) == 0) return i;
|
|
}
|
|
return -1;
|
|
}
|
|
|
|
bool MyMesh::meshtextDeliveryCurrent(const mesh::mt::PendingFrame& frame) {
|
|
const auto* profiles = _radio->profiles();
|
|
if (frame.cancelled || !_mt_active || !profiles || !profiles->canTransmit(1)
|
|
|| profiles->generation[1] != frame.generation
|
|
|| profiles->secondary.params.protocol != mesh::RadioProfileProtocol::Meshtext) return false;
|
|
ChannelDetails channel;
|
|
uint8_t fingerprint[8];
|
|
return BaseChatMesh::getChannel(frame.channel, channel)
|
|
&& channelFingerprint(channel, _mt_node, fingerprint)
|
|
&& memcmp(frame.fingerprint, fingerprint, sizeof(fingerprint)) == 0;
|
|
}
|
|
|
|
uint8_t MyMesh::meshtextRetryLimit(const mesh::mt::PendingFrame& frame) const {
|
|
if (frame.relayed) return 0; // One opportunistic relay, never origin flood retries.
|
|
// These are local, zero-hop origins: any general path gate admits them.
|
|
// GRP_DATA-only gates do not apply to channel text. No forwarding echo can
|
|
// exist when mt.hops is zero, so don't force all configured duplicates.
|
|
if (!(frame.bytes[12] & 7)) return 0;
|
|
const uint8_t count = _prefs.flood_retry_attempts > 15 ? 15 : _prefs.flood_retry_attempts;
|
|
return frame.nodeinfo ? (_prefs.flood_retry_advert_enabled && count ? 1 : 0) : count;
|
|
}
|
|
|
|
uint32_t MyMesh::meshtextRetryDelay(const mesh::mt::PendingFrame& frame) {
|
|
const uint64_t airtime = _radio->getProfileAirtime(1, frame.length);
|
|
// Same conservative flood wait/jitter and one-minute metadata extension,
|
|
// using the FOREIGN frame's real length and secondary PHY, not MC framing.
|
|
uint64_t delay = _radio->getProfileAirtime(1, mesh::mt::MaxFrameSize)
|
|
+ 20 * airtime + airtime * getRNG()->nextInt(0, 201) / 100;
|
|
if (frame.nodeinfo) delay += 60000;
|
|
if (!delay) delay = 1;
|
|
return delay > 0x7fffffffULL ? 0x7fffffffU : static_cast<uint32_t>(delay);
|
|
}
|
|
|
|
bool MyMesh::queueMeshtextDelivery(const ChannelDetails& channel, const uint8_t* bytes,
|
|
size_t length, bool nodeinfo) {
|
|
uint8_t fingerprint[8];
|
|
const int index = meshtextChannelIndex(channel, fingerprint);
|
|
if (index < 0 || !bytes || length <= mesh::mt::HeaderSize
|
|
|| length > mesh::mt::MaxFrameSize || hasForeignRadioFrame()) return false;
|
|
// Metadata has a reserved slot so a burst of chats awaiting echoes cannot
|
|
// prevent the first-use announcement. Backpressure is explicit, never a
|
|
// successful admission which silently drops retry state.
|
|
const unsigned first = nodeinfo ? mesh::mt::ChatDeliverySlots : 0;
|
|
const unsigned end = nodeinfo ? mesh::mt::DeliverySlots : mesh::mt::ChatDeliverySlots;
|
|
for (unsigned i = first; i < end; ++i) {
|
|
auto& frame = _mt_delivery[i];
|
|
if (frame.length) continue;
|
|
const auto* profiles = _radio->profiles();
|
|
if (!profiles || !queueForeignRadioFrame(bytes, length, 1, profiles->generation[1])) return false;
|
|
frame = {};
|
|
memcpy(frame.bytes, bytes, length);
|
|
memcpy(frame.fingerprint, fingerprint, sizeof(fingerprint));
|
|
frame.length = length; frame.channel = index; frame.nodeinfo = nodeinfo;
|
|
frame.generation = profiles->generation[1];
|
|
_mt_tx_slot = i;
|
|
if (memcmp(_mt_nodeinfo_fingerprint[index], fingerprint, sizeof(fingerprint))) {
|
|
memcpy(_mt_nodeinfo_fingerprint[index], fingerprint, sizeof(fingerprint));
|
|
_mt_nodeinfo_announced[index] = _mt_nodeinfo_pending[index] = false;
|
|
}
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
bool MyMesh::preemptMeshtextRelay() {
|
|
// Explicit user chat has priority over an optional, not-yet-sent relay.
|
|
for (unsigned i = 0; i < mesh::mt::ChatDeliverySlots; ++i) {
|
|
auto& frame = _mt_delivery[i];
|
|
if (!frame.length || !frame.relayed) continue;
|
|
if (_mt_tx_slot == static_cast<int>(i)) {
|
|
frame.cancelled = true;
|
|
if (!discardQueuedForeignRadioFrame(mesh::RadioProfileProtocol::Meshtext)) return false;
|
|
} else frame.clear();
|
|
}
|
|
return !hasForeignRadioFrame();
|
|
}
|
|
|
|
void MyMesh::maybeRebroadcastMeshtext(const ChannelDetails& channel, const uint8_t* bytes,
|
|
size_t length) {
|
|
// Called only after successful text decryption/parsing and local admission.
|
|
// Never forward unknown channels, opaque frames, DMs, NodeInfo or GPS.
|
|
if (!_mt_rebroadcast || !_mt_active || !bytes || length <= mesh::mt::HeaderSize
|
|
|| length > mesh::mt::MaxFrameSize || !(bytes[12] & 7)
|
|
|| (bytes[12] >> 5) < (bytes[12] & 7) || bytes[14] != 0) return;
|
|
const auto* profiles = _radio->profiles();
|
|
if (!profiles || !profiles->canTransmit(1)
|
|
|| profiles->secondary.params.protocol != mesh::RadioProfileProtocol::Meshtext) return;
|
|
for (const auto& pending : _mt_delivery) if (pending.length && pending.relayed) return;
|
|
uint8_t fingerprint[8];
|
|
const int index = meshtextChannelIndex(channel, fingerprint);
|
|
if (index < 0) return;
|
|
const uint64_t airtime = _radio->getProfileAirtime(1, length);
|
|
if (!airtime) return;
|
|
// Five ADDITIONAL exact packet airtimes, plus flood jitter. This does not
|
|
// replace the Dispatcher's existing CAD, RX-busy or airtime-budget waits.
|
|
uint64_t delay = 5 * airtime + airtime * getRNG()->nextInt(0, 201) / 100;
|
|
if (delay > 0x7fffffffULL) delay = 0x7fffffffULL;
|
|
for (unsigned i = 0; i < mesh::mt::ChatDeliverySlots; ++i) {
|
|
auto& frame = _mt_delivery[i];
|
|
if (frame.length) continue;
|
|
frame = {};
|
|
memcpy(frame.bytes, bytes, length);
|
|
frame.bytes[12] = (bytes[12] & 0xf8) | ((bytes[12] & 7) - 1);
|
|
frame.bytes[15] = static_cast<uint8_t>(_mt_node);
|
|
memcpy(frame.fingerprint, fingerprint, sizeof(fingerprint));
|
|
frame.length = length; frame.channel = index; frame.relayed = true;
|
|
frame.generation = profiles->generation[1];
|
|
frame.due = millis() + static_cast<uint32_t>(delay);
|
|
return; // Reuse one chat slot; no additional frame-sized RAM allocation.
|
|
}
|
|
}
|
|
|
|
void MyMesh::loopMeshtextDelivery() {
|
|
const auto* profiles = _radio->profiles();
|
|
const uint32_t now = millis();
|
|
const bool usable = _mt_active && profiles && profiles->canTransmit(1)
|
|
&& profiles->secondary.params.protocol == mesh::RadioProfileProtocol::Meshtext;
|
|
if (!usable || profiles->generation[1] != _mt_nodeinfo_generation) {
|
|
memset(_mt_nodeinfo_pending, 0, sizeof(_mt_nodeinfo_pending));
|
|
}
|
|
for (unsigned i = 0; i < mesh::mt::DeliverySlots; ++i) {
|
|
auto& frame = _mt_delivery[i];
|
|
if (!frame.length) continue;
|
|
if (!usable || profiles->generation[1] != frame.generation
|
|
|| (_mt_delivery_config_dirty && !meshtextDeliveryCurrent(frame))) frame.cancelled = true;
|
|
if (frame.relayed && (!_mt_rebroadcast || static_cast<int32_t>(now - frame.due) > 60000))
|
|
frame.cancelled = true; // Do not emit an opportunistic relay minutes later.
|
|
if (frame.cancelled || (frame.sent && !meshtextRetryLimit(frame))) {
|
|
if (_mt_tx_slot == static_cast<int>(i)) {
|
|
frame.cancelled = true;
|
|
discardQueuedForeignRadioFrame(mesh::RadioProfileProtocol::Meshtext);
|
|
} else frame.clear();
|
|
continue;
|
|
}
|
|
}
|
|
_mt_delivery_config_dirty = false;
|
|
if (!usable || hasForeignRadioFrame()) return;
|
|
// Announce only channels which actually completed a user text TX. Never
|
|
// turn boot, received traffic, a timer, or a configuration edit into TX.
|
|
// Stock peers need time to re-arm RX after chat/rebroadcast processing.
|
|
// Every successful MT TX restarts this gap; ordinary chat retries remain
|
|
// eligible below while the automatic announcement is still only intent.
|
|
const bool metadata_quiet = uint32_t(now - _mt_last_tx_completed_at) >= AutoNodeInfoQuietMillis;
|
|
for (int i = 0; metadata_quiet && i < MAX_GROUP_CHANNELS; ++i) {
|
|
if (!_mt_nodeinfo_pending[i]) continue;
|
|
ChannelDetails channel;
|
|
uint8_t fingerprint[8];
|
|
if (!BaseChatMesh::getChannel(i, channel)
|
|
|| !channelFingerprint(channel, _mt_node, fingerprint)
|
|
|| memcmp(fingerprint, _mt_nodeinfo_fingerprint[i], sizeof(fingerprint))) {
|
|
_mt_nodeinfo_pending[i] = false;
|
|
continue;
|
|
}
|
|
if (_mt_delivery[mesh::mt::ChatDeliverySlots].length) break;
|
|
if (sendMeshtextNodeInfo(channel)) return;
|
|
// Invalid names/admission failure must not become an unbounded automatic
|
|
// transmit/encoding loop. Another successfully sent chat can try again.
|
|
_mt_nodeinfo_pending[i] = false;
|
|
}
|
|
for (unsigned i = 0; i < mesh::mt::DeliverySlots; ++i) {
|
|
auto& frame = _mt_delivery[i];
|
|
if (!frame.length || static_cast<int32_t>(now - frame.due) < 0) continue;
|
|
if (!meshtextDeliveryCurrent(frame)) { frame.clear(); continue; }
|
|
if (frame.relayed) {
|
|
if (queueForeignRadioFrame(frame.bytes, frame.length, 1, frame.generation)) _mt_tx_slot = i;
|
|
return;
|
|
}
|
|
if (!frame.sent) continue;
|
|
if (frame.retries >= meshtextRetryLimit(frame)) {
|
|
++_mt_exhausted; frame.clear(); continue;
|
|
}
|
|
if (queueForeignRadioFrame(frame.bytes, frame.length, 1, frame.generation)) {
|
|
++frame.retries; _mt_tx_slot = i;
|
|
}
|
|
return; // Queue contention doesn't consume an attempt or spin.
|
|
}
|
|
}
|
|
|
|
bool MyMesh::sendMeshtextText(const ChannelDetails& channel, const char* text, size_t length) {
|
|
refreshMeshtextProfile();
|
|
if (!_mt_active || !text || !length) 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; first-use name metadata is a separate User packet.
|
|
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.
|
|
// Forwarding remains a separate, default-off setting; no ACK responses.
|
|
const size_t size = mesh::mt::encodeText(_mt_node, id, _mt_hop_limit, wire.name, wire.key,
|
|
wire.key_length, text, length, raw);
|
|
// A rejected local message must not evict a valid pending relay. Validate
|
|
// and encode first, then give an admitted user chat priority over it.
|
|
return size && preemptMeshtextRelay() && queueMeshtextDelivery(channel, raw, size, false);
|
|
}
|
|
|
|
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,
|
|
_mt_rebroadcast);
|
|
// Explicit CLI or first successfully transmitted chat, never a GPS packet.
|
|
return size && queueMeshtextDelivery(channel, raw, size, true);
|
|
}
|
|
|
|
bool MyMesh::onForeignRadioFrame(const uint8_t* bytes, size_t length, uint8_t profile,
|
|
uint32_t generation, float snr, float rssi) {
|
|
#if MESH_ENABLE_ETHERNET_TUNNEL
|
|
const auto* receiving_profiles = _radio->profiles();
|
|
if (receiving_profiles && profile < 2
|
|
&& receiving_profiles->params(profile).protocol == mesh::RadioProfileProtocol::Ethernet)
|
|
return onEthernetFrame(bytes, length, profile, generation);
|
|
#endif
|
|
(void)rssi;
|
|
const auto* profiles = _radio->profiles();
|
|
if (!_mt_active || !profiles || profile != 1 || generation != profiles->generation[1]) return false;
|
|
for (unsigned i = 0; i < mesh::mt::DeliverySlots; ++i) {
|
|
auto& frame = _mt_delivery[i];
|
|
if (frame.generation == generation && frame.matchesRelayCopy(bytes, length)
|
|
&& meshtextDeliveryCurrent(frame)) {
|
|
frame.heard = frame.cancelled = true;
|
|
if (_mt_tx_slot == static_cast<int>(i))
|
|
discardQueuedForeignRadioFrame(mesh::RadioProfileProtocol::Meshtext);
|
|
else frame.clear();
|
|
return true; // Cancel before the received-packet dedup early return.
|
|
}
|
|
if (frame.generation != generation || !frame.matchesEcho(bytes, length)
|
|
|| !meshtextDeliveryCurrent(frame)) continue;
|
|
if (!frame.heard) ++_mt_heard;
|
|
frame.heard = true;
|
|
if (_mt_tx_slot == static_cast<int>(i))
|
|
discardQueuedForeignRadioFrame(mesh::RadioProfileProtocol::Meshtext);
|
|
else frame.clear();
|
|
return true; // Own echo is never displayed, forwarded or ACKed.
|
|
}
|
|
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;
|
|
maybeRebroadcastMeshtext(channel, bytes, length);
|
|
}
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
void MyMesh::onForeignRadioSendComplete(bool success) {
|
|
#if MESH_ENABLE_ETHERNET_TUNNEL
|
|
if (queuedForeignRadioProtocol() == mesh::RadioProfileProtocol::Ethernet) {
|
|
if (success) ++_ethernet_sent;
|
|
else ++_ethernet_failed;
|
|
return;
|
|
}
|
|
#endif
|
|
if (success) _mt_last_tx_completed_at = millis();
|
|
const int slot = _mt_tx_slot;
|
|
_mt_tx_slot = -1;
|
|
if (slot < 0 || slot >= static_cast<int>(mesh::mt::DeliverySlots)) {
|
|
if (success) ++_mt_sent; else ++_mt_failed;
|
|
return; // e.g. an explicitly configured text beacon, not delivery work.
|
|
}
|
|
auto& frame = _mt_delivery[slot];
|
|
if (frame.relayed) {
|
|
if (success) ++_mt_sent;
|
|
else if (!frame.heard && !frame.cancelled) ++_mt_failed;
|
|
frame.clear();
|
|
return; // No local packet ID, NodeInfo, origin retry or announcement state.
|
|
}
|
|
if (frame.nodeinfo && !success) _mt_nodeinfo_pending[frame.channel] = false;
|
|
if (success) {
|
|
++_mt_sent;
|
|
if (frame.retries) ++_mt_retried;
|
|
}
|
|
else if (!frame.heard && !frame.cancelled) ++_mt_failed;
|
|
if (!success || frame.cancelled || !meshtextDeliveryCurrent(frame)) { frame.clear(); return; }
|
|
frame.sent = true;
|
|
const int index = frame.channel;
|
|
if (frame.nodeinfo) {
|
|
_mt_nodeinfo_announced[index] = true;
|
|
_mt_nodeinfo_pending[index] = false;
|
|
} else if (!frame.retries && !_mt_nodeinfo_announced[index]) {
|
|
_mt_nodeinfo_pending[index] = true;
|
|
_mt_nodeinfo_generation = frame.generation;
|
|
}
|
|
if (frame.heard || !meshtextRetryLimit(frame)) frame.clear();
|
|
else frame.due = millis() + meshtextRetryDelay(frame);
|
|
}
|
|
|
|
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 GPS",
|
|
(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");
|
|
if (!strcmp(command, "get mt.retry")) {
|
|
unsigned pending = 0;
|
|
for (const auto& frame : _mt_delivery) if (frame.length) ++pending;
|
|
snprintf(reply, capacity, "> retry=%lu heard=%lu exhausted=%lu pending=%u count=%u advert=%s",
|
|
(unsigned long)_mt_retried, (unsigned long)_mt_heard, (unsigned long)_mt_exhausted,
|
|
pending, _prefs.flood_retry_attempts, _prefs.flood_retry_advert_enabled ? "on" : "off");
|
|
return true;
|
|
}
|
|
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_rebroadcast = !strcmp(command, "get mt.rebroadcast")
|
|
|| !strcmp(command, "get mt.maybe_rebroadcast");
|
|
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* rebroadcast_value = !strncmp(command, "set mt.rebroadcast ", 19) ? command + 19
|
|
: !strncmp(command, "set mt.maybe_rebroadcast ", 25) ? command + 25 : 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 && !get_rebroadcast
|
|
&& !region_value && !slot_value && !hops_value && !node_value && !short_name_value && !rebroadcast_value) return false;
|
|
if (region_value || slot_value || hops_value || node_value || short_name_value || rebroadcast_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;
|
|
uint8_t rebroadcast = _mt_rebroadcast ? 1 : 0;
|
|
if (rebroadcast_value) {
|
|
if (!strcmp(rebroadcast_value, "on")) rebroadcast = 1;
|
|
else if (!strcmp(rebroadcast_value, "off")) rebroadcast = 0;
|
|
else { snprintf(reply, capacity, "Error: use set mt.rebroadcast on|off"); return true; }
|
|
}
|
|
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, rebroadcast))
|
|
snprintf(reply, capacity, "Error: Meshtext settings not saved (pending TX or storage error)");
|
|
else if (rebroadcast_value)
|
|
snprintf(reply, capacity, "OK - mt.rebroadcast=%s; extra wait=5 packet airtimes", rebroadcast ? "on" : "off");
|
|
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_rebroadcast) snprintf(reply, capacity, "> %s; extra wait=5 packet airtimes", _mt_rebroadcast ? "on" : "off");
|
|
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 (first-chat/manual NodeInfo)",
|
|
_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; rebroadcast=%s; %s; %s; %.3f/%g/SF%u/CR%u; hops=%u; RX=%lu TX=%lu fail=%lu; RX time-shared",
|
|
(unsigned long)_mt_node, _mt_rebroadcast ? "on" : "off", _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
|