From d73dc536d2f30118fbcc4be45a577c8623b1ecbe Mon Sep 17 00:00:00 2001 From: nomdetom Date: Thu, 25 Jun 2026 01:42:07 +0100 Subject: [PATCH 1/4] added warnings from firmware for simple channel setting mistakes --- src/modules/AdminModule.cpp | 53 +++++++++++++++++++++++++++++++++++++ src/modules/AdminModule.h | 2 ++ 2 files changed, 55 insertions(+) diff --git a/src/modules/AdminModule.cpp b/src/modules/AdminModule.cpp index 8bcfcd878da..de7d1b84fea 100644 --- a/src/modules/AdminModule.cpp +++ b/src/modules/AdminModule.cpp @@ -1,5 +1,6 @@ #include "AdminModule.h" #include "Channels.h" +#include "DisplayFormatters.h" #include "MeshService.h" #include "NodeDB.h" #include "PositionPrecision.h" @@ -1007,6 +1008,7 @@ void AdminModule::handleSetConfig(const meshtastic_Config &c, bool fromOthers) #endif config.lora = validatedLora; // Finally, return the validated config back to the main config + warnIfPresetNamedChannel(oldLoraConfig, validatedLora); break; } @@ -1168,6 +1170,7 @@ bool AdminModule::handleSetModuleConfig(const meshtastic_ModuleConfig &c) void AdminModule::handleSetChannel(const meshtastic_Channel &cc) { + warnIfBlankPsk(cc); channels.setChannel(cc); if (channels.ensureLicensedOperation()) { sendWarning(licensedModeMessage); @@ -1747,6 +1750,56 @@ void AdminModule::sendWarningAndLog(const char *format, ...) sendWarning("%s", buf); } +// Strip spaces and fold to lowercase for loose preset-name comparison. +static void normalizePresetName(const char *src, char *dst, size_t dstLen) +{ + size_t j = 0; + for (size_t i = 0; src[i] && j + 1 < dstLen; i++) { + if (src[i] != ' ') + dst[j++] = (char)tolower((unsigned char)src[i]); + } + dst[j] = '\0'; +} + +void AdminModule::warnIfPresetNamedChannel(const meshtastic_Config_LoRaConfig &oldLora, + const meshtastic_Config_LoRaConfig &newLora) +{ + if (!newLora.use_preset || newLora.modem_preset == oldLora.modem_preset) + return; + const char *oldName = DisplayFormatters::getModemPresetDisplayName(oldLora.modem_preset, false, oldLora.use_preset); + const char *newName = DisplayFormatters::getModemPresetDisplayName(newLora.modem_preset, false, newLora.use_preset); + + char normNew[32]; + normalizePresetName(newName, normNew, sizeof(normNew)); + + for (int i = 0; i < channels.getNumChannels(); i++) { + const meshtastic_Channel &ch = channels.getByIndex(i); + if (ch.role == meshtastic_Channel_Role_DISABLED || !ch.has_settings || !*ch.settings.name) + continue; + if (strcmp(ch.settings.name, oldName) == 0) { + sendWarning("Channel %d name '%s' matches the old preset. " + "Rename it manually if it should track the new preset.", + i, ch.settings.name); + continue; + } + char normChan[32]; + normalizePresetName(ch.settings.name, normChan, sizeof(normChan)); + if (strcmp(normChan, normNew) == 0) + sendWarning("Channel %d name '%s' looks like preset '%s' but won't auto-resolve — " + "clear the name to use the preset name automatically.", + i, ch.settings.name, newName); + } +} + +void AdminModule::warnIfBlankPsk(const meshtastic_Channel &cc) +{ + if (cc.role == meshtastic_Channel_Role_DISABLED || !cc.has_settings || cc.settings.psk.size > 0) + return; + sendWarning("Channel %d '%s' has a blank PSK (no encryption). " + "If you intended the default key, set PSK to 'AQ=='.", + cc.index, cc.settings.name); +} + void disableBluetooth() { #if HAS_BLUETOOTH diff --git a/src/modules/AdminModule.h b/src/modules/AdminModule.h index 1d5c64423bc..b7afadf91e6 100644 --- a/src/modules/AdminModule.h +++ b/src/modules/AdminModule.h @@ -83,6 +83,8 @@ class AdminModule : public ProtobufModule, public Obser bool messageIsRequest(const meshtastic_AdminMessage *r); void sendWarning(const char *format, ...) __attribute__((format(printf, 2, 3))); void sendWarningAndLog(const char *format, ...) __attribute__((format(printf, 2, 3))); + void warnIfPresetNamedChannel(const meshtastic_Config_LoRaConfig &oldLora, const meshtastic_Config_LoRaConfig &newLora); + void warnIfBlankPsk(const meshtastic_Channel &cc); }; static constexpr const char *licensedModeMessage = From 851406322887bed1d13db16756b0cc0b240a338b Mon Sep 17 00:00:00 2001 From: nomdetom Date: Thu, 25 Jun 2026 17:44:27 +0100 Subject: [PATCH 2/4] more and better checks --- src/modules/AdminModule.cpp | 169 ++++++++++++++++++++++++++++-------- src/modules/AdminModule.h | 4 +- 2 files changed, 135 insertions(+), 38 deletions(-) diff --git a/src/modules/AdminModule.cpp b/src/modules/AdminModule.cpp index de7d1b84fea..46b59fc8d1f 100644 --- a/src/modules/AdminModule.cpp +++ b/src/modules/AdminModule.cpp @@ -87,7 +87,7 @@ bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshta assert(r); #ifdef MESHTASTIC_ENCRYPTED_STORAGE - // While storage is locked, drop every admin payload — both local and + // While storage is locked, drop every admin payload - both local and // remote (PKC, mesh-relayed). Lockdown unlock is the prerequisite for // any admin operation: operators must authenticate via lockdown_auth // first. The lockdown_auth path itself is handled synchronously in @@ -98,9 +98,9 @@ bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshta // operator has even unlocked it. // Only gate when lockdown is ACTIVE. A lockdown-capable build that hasn't // been provisioned (or was disabled) is not unlocked either, but must - // still serve admin normally — so check isLockdownActive() first. + // still serve admin normally - so check isLockdownActive() first. if (EncryptedStorage::isLockdownActive() && !EncryptedStorage::isUnlocked()) { - LOG_WARN("AdminModule: dropping admin payload — storage locked"); + LOG_WARN("AdminModule: dropping admin payload - storage locked"); return handled; } #endif @@ -134,7 +134,7 @@ bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshta // local packets into the remote-PKC key check. // // Under MESHTASTIC_PHONEAPI_ACCESS_CONTROL, the per-connection auth - // gate lives in PhoneAPI::handleToRadioPacket — any local admin + // gate lives in PhoneAPI::handleToRadioPacket - any local admin // payload other than lockdown_auth is dropped there if the // originating connection is unauthorized. By the time we reach // this branch the connection has already proven the passphrase, @@ -166,7 +166,7 @@ bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshta // Note: PKC admin does NOT automatically authorize the // originating local PhoneAPI connection for content // redaction purposes. PKC and the per-connection lockdown - // auth slot are independent gates — operators using PKC + // auth slot are independent gates - operators using PKC // admin from a local app should still send lockdown_auth // separately to unlock the redacted FromRadio stream. // (The previous auto-authorize path read a shared @@ -215,7 +215,7 @@ bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshta #ifdef MESHTASTIC_ENCRYPTED_STORAGE // lockdown_auth is handled synchronously in - // PhoneAPI::handleToRadioPacket — see handleLockdownAuthInline. A + // PhoneAPI::handleToRadioPacket - see handleLockdownAuthInline. A // packet should not normally reach AdminModule under that flag set, // but if it ever does (e.g. injected via a non-PhoneAPI path), drop // it silently rather than leaking a partial response. @@ -299,7 +299,7 @@ bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshta break; } - // Hardware supports 2.4 GHz — apply the config. + // Hardware supports 2.4 GHz - apply the config. // Fail closed: null instance is treated as incapable. if (RadioLibInterface::instance && RadioLibInterface::instance->wideLora()) { LOG_DEBUG("LORA_24 requested, radio hardware supports 2.4 GHz, applying"); @@ -480,7 +480,7 @@ bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshta saveChanges(SEGMENT_NODEDATABASE, false); if (screen) screen->setFrames(graphics::Screen::FOCUS_PRESERVE); // <-- Rebuild screens - } else if (mp.from == 0) { // local request from the phone — tell the user why it didn't take + } else if (mp.from == 0) { // local request from the phone - tell the user why it didn't take sendWarning(NodeDB::PROTECTED_CAP_WARN_FMT, "favorite", r->set_favorite_node, MAX_NUM_NODES - 2); } else { LOG_WARN("Remote set_favorite_node for 0x%x refused: protected-node cap", r->set_favorite_node); @@ -509,7 +509,7 @@ bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshta if (nodeDB->setProtectedFlag(node, NODEINFO_BITFIELD_IS_IGNORED_MASK, true)) { nodeDB->eraseNodeSatellites(node->num); saveChanges(SEGMENT_NODEDATABASE, false); - } else if (mp.from == 0) { // local request from the phone — tell the user why it didn't take + } else if (mp.from == 0) { // local request from the phone - tell the user why it didn't take sendWarning(NodeDB::PROTECTED_CAP_WARN_FMT, "ignore", r->set_ignored_node, MAX_NUM_NODES - 2); } else { LOG_WARN("Remote set_ignored_node for 0x%x refused: protected-node cap", r->set_ignored_node); @@ -763,6 +763,8 @@ void AdminModule::handleSetConfig(const meshtastic_Config &c, bool fromOthers) auto existingRole = config.device.role; bool isRegionUnset = (config.lora.region == meshtastic_Config_LoRaConfig_RegionCode_UNSET); bool requiresReboot = true; + bool loraPresetWarnPending = false; + meshtastic_Config_LoRaConfig pendingOldLora = {}, pendingNewLora = {}; switch (c.which_payload_variant) { case meshtastic_Config_device_tag: { @@ -1008,7 +1010,11 @@ void AdminModule::handleSetConfig(const meshtastic_Config &c, bool fromOthers) #endif config.lora = validatedLora; // Finally, return the validated config back to the main config - warnIfPresetNamedChannel(oldLoraConfig, validatedLora); + if (validatedLora.modem_preset != oldLoraConfig.modem_preset) { + pendingOldLora = oldLoraConfig; + pendingNewLora = validatedLora; + loraPresetWarnPending = true; + } break; } @@ -1052,6 +1058,8 @@ void AdminModule::handleSetConfig(const meshtastic_Config &c, bool fromOthers) } // end of switch case which_payload_variant saveChanges(changes, requiresReboot); + if (loraPresetWarnPending) + warnOnLoraPresetChange(pendingOldLora, pendingNewLora); } // end of handleSetConfig bool AdminModule::handleSetModuleConfig(const meshtastic_ModuleConfig &c) @@ -1170,7 +1178,6 @@ bool AdminModule::handleSetModuleConfig(const meshtastic_ModuleConfig &c) void AdminModule::handleSetChannel(const meshtastic_Channel &cc) { - warnIfBlankPsk(cc); channels.setChannel(cc); if (channels.ensureLicensedOperation()) { sendWarning(licensedModeMessage); @@ -1196,6 +1203,7 @@ void AdminModule::handleSetChannel(const meshtastic_Channel &cc) if (clamped) sendWarning(publicChannelPrecisionMessage); saveChanges(SEGMENT_CHANNELS, false); + warnOnChannelSet(channels.getByIndex(cc.index)); // passes the saved channel } /** @@ -1761,44 +1769,133 @@ static void normalizePresetName(const char *src, char *dst, size_t dstLen) dst[j] = '\0'; } -void AdminModule::warnIfPresetNamedChannel(const meshtastic_Config_LoRaConfig &oldLora, - const meshtastic_Config_LoRaConfig &newLora) +/** + * @brief Emit client warnings for common misconfigurations on a newly committed channel. + * + * Called from handleSetChannel() after the channel has been saved. The following checks + * are performed: + * + * - Blank PSK (size == 0) on a non-licensed device: the channel has no encryption. + * - Blank name with a non-default key (not AQ== / 0x01): the name will auto-resolve to + * the current modem preset name, but the key mismatch means other preset nodes cannot + * decode traffic on this channel. + * - Named channel whose name is a case/space variant of the current modem preset: the + * explicit name prevents auto-resolution; client should clear it. + * - Same variant match but PSK is not the default key: looks like the preset channel but + * is incompatible with nodes using the preset's default key. + * + * @param cc The channel that was written. + */ +void AdminModule::warnOnChannelSet(const meshtastic_Channel &cc) +{ + if (cc.role == meshtastic_Channel_Role_DISABLED || !cc.has_settings) // don't check unused channels + return; + + if (cc.settings.psk.size == 0 && !owner.is_licensed) // blank PSK and unlicensed + sendWarningAndLog("Channel %d '%s' has a blank PSK (no encryption). " + "If you intended the default key, set PSK to 'AQ=='.", // max 100 bytes + cc.index, cc.settings.name); + + if (!config.lora.use_preset) { // custom or unset preset can mistype things too + if (*cc.settings.name) { + char normChan[32]; + normalizePresetName(cc.settings.name, normChan, sizeof(normChan)); + for (auto preset = _meshtastic_Config_LoRaConfig_ModemPreset_MIN; + preset <= _meshtastic_Config_LoRaConfig_ModemPreset_MAX; + preset = (meshtastic_Config_LoRaConfig_ModemPreset)(preset + 1)) { + const char *name = DisplayFormatters::getModemPresetDisplayName(preset, false, true); + if (strcmp(cc.settings.name, name) == 0) + break; // exact match - not a mistype, no warning + char normPreset[32]; + normalizePresetName(name, normPreset, sizeof(normPreset)); + if (strcmp(normChan, normPreset) == 0) { + sendWarningAndLog("Channel %d name '%s' looks like a mistype of '%s' - " + "make sure to type it exactly!", // max 90 bytes + cc.index, cc.settings.name, name); + break; + } + } + } + return; + } // custom or no preset config - no further checks + + const char *presetName = DisplayFormatters::getModemPresetDisplayName(config.lora.modem_preset, false, true); + bool isDefaultKey = (cc.settings.psk.size == 1 && cc.settings.psk.bytes[0] == 0x01); + char normPreset[32], normChan[32]; // max size is 11 plus nul, but allow for future expansion + normalizePresetName(presetName, normPreset, sizeof(normPreset)); + normalizePresetName(cc.settings.name, normChan, sizeof(normChan)); + + if (!*cc.settings.name) { + // Blank name resolves to the preset name - warn if the key won't match. + if (!isDefaultKey && cc.settings.psk.size > 0) + sendWarningAndLog("Channel %d will resolve to preset '%s' but uses a non-default key - " + "other nodes on this preset cannot decode it.", // max 112 bytes + cc.index, presetName); + return; + } + + if (strcmp(normChan, normPreset) != 0) // not using a preset name for channel + return; + + if (strcmp(cc.settings.name, presetName) != 0) + sendWarningAndLog("Channel %d name '%s' looks like a mistype of '%s' - " + "clear the name to use the preset name automatically.", // max 113 bytes + cc.index, cc.settings.name, presetName); + + if (!isDefaultKey && cc.settings.psk.size > 0) + sendWarningAndLog("Channel %d '%s' matches preset '%s' but uses a non-default key - " + "other nodes on this preset cannot decode it.", // max 118 bytes + cc.index, cc.settings.name, presetName); +} // warnOnChannelSet + +/** + * @brief Scan all channels for preset-name conflicts after a modem preset change is committed. + * + * Called from handleSetConfig() after the LoRa config has been saved, and only when + * modem_preset actually changed (rejected configs are never passed here). For every + * named, non-disabled channel two checks are performed: + * + * - Name matches the *old* preset (case-insensitive, spaces stripped): the channel + * was likely tracking the previous preset; the user should rename it if it should + * follow the new one. + * - Name matches the *new* preset: the channel name collides with the auto-generated + * preset name but won't resolve automatically because the name is set explicitly. + * + * No-ops if the new config does not use a preset. + * + * @param oldLora LoRa config before the update. + * @param newLora LoRa config after the update. + */ +void AdminModule::warnOnLoraPresetChange(const meshtastic_Config_LoRaConfig &oldLora, const meshtastic_Config_LoRaConfig &newLora) { if (!newLora.use_preset || newLora.modem_preset == oldLora.modem_preset) return; - const char *oldName = DisplayFormatters::getModemPresetDisplayName(oldLora.modem_preset, false, oldLora.use_preset); - const char *newName = DisplayFormatters::getModemPresetDisplayName(newLora.modem_preset, false, newLora.use_preset); - char normNew[32]; + char normOld[32] = {}, normNew[32]; + if (oldLora.use_preset) { + const char *oldName = DisplayFormatters::getModemPresetDisplayName(oldLora.modem_preset, false, true); + normalizePresetName(oldName, normOld, sizeof(normOld)); + } + const char *newName = DisplayFormatters::getModemPresetDisplayName(newLora.modem_preset, false, true); normalizePresetName(newName, normNew, sizeof(normNew)); for (int i = 0; i < channels.getNumChannels(); i++) { const meshtastic_Channel &ch = channels.getByIndex(i); if (ch.role == meshtastic_Channel_Role_DISABLED || !ch.has_settings || !*ch.settings.name) continue; - if (strcmp(ch.settings.name, oldName) == 0) { - sendWarning("Channel %d name '%s' matches the old preset. " - "Rename it manually if it should track the new preset.", - i, ch.settings.name); - continue; - } char normChan[32]; normalizePresetName(ch.settings.name, normChan, sizeof(normChan)); - if (strcmp(normChan, normNew) == 0) - sendWarning("Channel %d name '%s' looks like preset '%s' but won't auto-resolve — " - "clear the name to use the preset name automatically.", - i, ch.settings.name, newName); + if (*normOld && strcmp(normChan, normOld) == 0) { + sendWarningAndLog("Channel %d name '%s' matches the old preset. " + "Rename it manually if it should track the new preset.", // max 98 bytes + i, ch.settings.name); + } else if (strcmp(normChan, normNew) == 0) { + sendWarningAndLog("Channel %d '%s' looks like preset '%s' but won't auto-resolve - " + "clear the name to fix it.", // max 98 bytes + i, ch.settings.name, newName); + } } -} - -void AdminModule::warnIfBlankPsk(const meshtastic_Channel &cc) -{ - if (cc.role == meshtastic_Channel_Role_DISABLED || !cc.has_settings || cc.settings.psk.size > 0) - return; - sendWarning("Channel %d '%s' has a blank PSK (no encryption). " - "If you intended the default key, set PSK to 'AQ=='.", - cc.index, cc.settings.name); -} +} // warnOnLoraPresetChange void disableBluetooth() { diff --git a/src/modules/AdminModule.h b/src/modules/AdminModule.h index b7afadf91e6..7648791f07f 100644 --- a/src/modules/AdminModule.h +++ b/src/modules/AdminModule.h @@ -83,8 +83,8 @@ class AdminModule : public ProtobufModule, public Obser bool messageIsRequest(const meshtastic_AdminMessage *r); void sendWarning(const char *format, ...) __attribute__((format(printf, 2, 3))); void sendWarningAndLog(const char *format, ...) __attribute__((format(printf, 2, 3))); - void warnIfPresetNamedChannel(const meshtastic_Config_LoRaConfig &oldLora, const meshtastic_Config_LoRaConfig &newLora); - void warnIfBlankPsk(const meshtastic_Channel &cc); + void warnOnLoraPresetChange(const meshtastic_Config_LoRaConfig &oldLora, const meshtastic_Config_LoRaConfig &newLora); + void warnOnChannelSet(const meshtastic_Channel &cc); }; static constexpr const char *licensedModeMessage = From cf54ce8bf57d13ba9012393aabc7f9393857348a Mon Sep 17 00:00:00 2001 From: nomdetom Date: Thu, 25 Jun 2026 22:00:46 +0100 Subject: [PATCH 3/4] one ping only --- src/modules/AdminModule.cpp | 195 ++++++++++++++++++++++------ src/modules/AdminModule.h | 19 +++ test/test_admin_radio/test_main.cpp | 191 ++++++++++++++++++++++++++- 3 files changed, 365 insertions(+), 40 deletions(-) diff --git a/src/modules/AdminModule.cpp b/src/modules/AdminModule.cpp index 46b59fc8d1f..d6400f1f74a 100644 --- a/src/modules/AdminModule.cpp +++ b/src/modules/AdminModule.cpp @@ -447,6 +447,7 @@ bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshta LOG_INFO("Commit transaction for edited settings"); hasOpenEditTransaction = false; saveChanges(SEGMENT_CONFIG | SEGMENT_MODULECONFIG | SEGMENT_DEVICESTATE | SEGMENT_CHANNELS | SEGMENT_NODEDATABASE); + flushChannelWarnings(); // one coalesced message for everything edited in this transaction break; } case meshtastic_AdminMessage_get_device_connection_status_request_tag: { @@ -741,7 +742,7 @@ void AdminModule::handleSetOwner(const meshtastic_User &o) changed = 1; owner.is_licensed = o.is_licensed; if (channels.ensureLicensedOperation()) { - sendWarning(licensedModeMessage); + warnLicensedMode(); } } if (owner.has_is_unmessagable != o.has_is_unmessagable || @@ -1060,6 +1061,9 @@ void AdminModule::handleSetConfig(const meshtastic_Config &c, bool fromOthers) saveChanges(changes, requiresReboot); if (loraPresetWarnPending) warnOnLoraPresetChange(pendingOldLora, pendingNewLora); + // Inside an edit transaction the queued warnings are flushed once at commit; otherwise emit now. + if (!hasOpenEditTransaction) + flushChannelWarnings(); } // end of handleSetConfig bool AdminModule::handleSetModuleConfig(const meshtastic_ModuleConfig &c) @@ -1180,7 +1184,7 @@ void AdminModule::handleSetChannel(const meshtastic_Channel &cc) { channels.setChannel(cc); if (channels.ensureLicensedOperation()) { - sendWarning(licensedModeMessage); + warnLicensedMode(); } // Refresh derived state (primaryIndex in particular) BEFORE the precision clamp below. usesPublicKey() // resolves a secondary channel's key against the primary, so it must see the post-update primaryIndex; @@ -1204,6 +1208,9 @@ void AdminModule::handleSetChannel(const meshtastic_Channel &cc) sendWarning(publicChannelPrecisionMessage); saveChanges(SEGMENT_CHANNELS, false); warnOnChannelSet(channels.getByIndex(cc.index)); // passes the saved channel + // Inside an edit transaction the queued warnings are flushed once at commit; otherwise emit now. + if (!hasOpenEditTransaction) + flushChannelWarnings(); } /** @@ -1612,7 +1619,7 @@ void AdminModule::handleSetHamMode(const meshtastic_HamParameters &p) // Remove PSK of primary channel for plaintext amateur usage if (channels.ensureLicensedOperation()) { - sendWarning(licensedModeMessage); + warnLicensedMode(); } channels.onConfigChanged(); @@ -1750,11 +1757,11 @@ void AdminModule::sendWarningAndLog(const char *format, ...) vsnprintf(buf, sizeof(buf), format, args); va_end(args); - LOG_WARN(buf); - // 2. Call sendWarning - // SECURITY NOTE: We pass "%s", buf instead of just 'buf'. - // If 'buf' contained a % symbol (e.g. "Battery 50%"), passing it directly - // would crash sendWarning. "%s" treats it purely as text. + // SECURITY NOTE: Both LOG_WARN and sendWarning are printf-style, so we pass + // "%s", buf rather than 'buf' directly. If 'buf' contained a % symbol (e.g. a + // user-supplied channel name like "50%"), passing it as the format string would + // read bogus varargs and could crash. "%s" treats it purely as text. + LOG_WARN("%s", buf); sendWarning("%s", buf); } @@ -1769,6 +1776,68 @@ static void normalizePresetName(const char *src, char *dst, size_t dstLen) dst[j] = '\0'; } +// Record one channel-configuration warning. The first message is kept verbatim in case it +// turns out to be the only one; nameIssue/pskIssue and the channel bitmask feed the catch-all +// wording if more than one channel ends up flagged. flushChannelWarnings() emits the result. +void AdminModule::queueChannelWarning(uint8_t channelIndex, bool nameIssue, bool pskIssue, const char *format, ...) +{ + if (pendingWarningCount == 0) { + va_list args; + va_start(args, format); + vsnprintf(pendingWarningText, sizeof(pendingWarningText), format, args); + va_end(args); + } + if (channelIndex < MAX_NUM_CHANNELS) + pendingWarningChannels |= (1u << channelIndex); + pendingWarningNameIssue |= nameIssue; + pendingWarningPskIssue |= pskIssue; + pendingWarningCount++; +} + +// Queue the fixed "licensed mode activated" notice, deferring it to commit during an edit +// transaction so repeated triggers collapse to a single message. +void AdminModule::warnLicensedMode() +{ + if (hasOpenEditTransaction) + pendingLicenseWarning = true; + else + sendWarning(licensedModeMessage); +} + +// Emit the coalesced channel warning(s): nothing if none queued, the lone message verbatim if +// exactly one, otherwise a single catch-all naming every flagged channel. The licensed-mode +// notice, if queued, is emitted once alongside. Always resets state. +void AdminModule::flushChannelWarnings() +{ + if (pendingLicenseWarning) + sendWarning(licensedModeMessage); + + if (pendingWarningCount == 1) { + sendWarningAndLog("%s", pendingWarningText); + } else if (pendingWarningCount > 1) { + char list[48] = {}; + for (uint8_t i = 0; i < MAX_NUM_CHANNELS; i++) { + if (!(pendingWarningChannels & (1u << i))) + continue; + char num[8]; + snprintf(num, sizeof(num), "%s%u", *list ? ", " : "", i); + strncat(list, num, sizeof(list) - strlen(list) - 1); + } + if (pendingWarningNameIssue && pendingWarningPskIssue) + sendWarningAndLog("There may be name and PSK issues on channels %s", list); // max 60 bytes + else if (pendingWarningNameIssue) + sendWarningAndLog("There may be name issues on channels %s", list); // max 52 bytes + else + sendWarningAndLog("There may be PSK issues on channels %s", list); // max 51 bytes + } + pendingWarningText[0] = '\0'; + pendingWarningChannels = 0; + pendingWarningCount = 0; + pendingWarningNameIssue = false; + pendingWarningPskIssue = false; + pendingLicenseWarning = false; +} + /** * @brief Emit client warnings for common misconfigurations on a newly committed channel. * @@ -1791,12 +1860,10 @@ void AdminModule::warnOnChannelSet(const meshtastic_Channel &cc) if (cc.role == meshtastic_Channel_Role_DISABLED || !cc.has_settings) // don't check unused channels return; - if (cc.settings.psk.size == 0 && !owner.is_licensed) // blank PSK and unlicensed - sendWarningAndLog("Channel %d '%s' has a blank PSK (no encryption). " - "If you intended the default key, set PSK to 'AQ=='.", // max 100 bytes - cc.index, cc.settings.name); + bool blankPsk = (cc.settings.psk.size == 0 && !owner.is_licensed); if (!config.lora.use_preset) { // custom or unset preset can mistype things too + const char *mistypePreset = nullptr; if (*cc.settings.name) { char normChan[32]; normalizePresetName(cc.settings.name, normChan, sizeof(normChan)); @@ -1804,20 +1871,33 @@ void AdminModule::warnOnChannelSet(const meshtastic_Channel &cc) preset <= _meshtastic_Config_LoRaConfig_ModemPreset_MAX; preset = (meshtastic_Config_LoRaConfig_ModemPreset)(preset + 1)) { const char *name = DisplayFormatters::getModemPresetDisplayName(preset, false, true); + if (strcmp(name, "Invalid") == 0) + continue; // skip preset slots without a real display name if (strcmp(cc.settings.name, name) == 0) break; // exact match - not a mistype, no warning char normPreset[32]; normalizePresetName(name, normPreset, sizeof(normPreset)); if (strcmp(normChan, normPreset) == 0) { - sendWarningAndLog("Channel %d name '%s' looks like a mistype of '%s' - " - "make sure to type it exactly!", // max 90 bytes - cc.index, cc.settings.name, name); + mistypePreset = name; break; } } } + // At most one warning: collapse blank-PSK + mistype into a single catch-all. + if (blankPsk && mistypePreset) + queueChannelWarning(cc.index, true, true, "There may be name and PSK issues on channel %d", cc.index); + else if (mistypePreset) + queueChannelWarning(cc.index, true, false, + "Channel %d name '%s' looks like a mistype of '%s' - " + "make sure to type it exactly!", // max 90 bytes + cc.index, cc.settings.name, mistypePreset); + else if (blankPsk) + queueChannelWarning(cc.index, false, true, + "Channel %d '%s' has a blank PSK (no encryption). " + "If you intended the default key, set PSK to 'AQ=='.", // max 100 bytes + cc.index, cc.settings.name); return; - } // custom or no preset config - no further checks + } const char *presetName = DisplayFormatters::getModemPresetDisplayName(config.lora.modem_preset, false, true); bool isDefaultKey = (cc.settings.psk.size == 1 && cc.settings.psk.bytes[0] == 0x01); @@ -1826,26 +1906,60 @@ void AdminModule::warnOnChannelSet(const meshtastic_Channel &cc) normalizePresetName(cc.settings.name, normChan, sizeof(normChan)); if (!*cc.settings.name) { - // Blank name resolves to the preset name - warn if the key won't match. - if (!isDefaultKey && cc.settings.psk.size > 0) - sendWarningAndLog("Channel %d will resolve to preset '%s' but uses a non-default key - " - "other nodes on this preset cannot decode it.", // max 112 bytes - cc.index, presetName); + // Blank name resolves to the preset name - A and B are mutually exclusive (psk.size can't be both 0 and >0) + if (blankPsk) + queueChannelWarning(cc.index, false, true, + "Channel %d '%s' has a blank PSK (no encryption). " + "If you intended the default key, set PSK to 'AQ=='.", // max 100 bytes + cc.index, cc.settings.name); + else if (!isDefaultKey && cc.settings.psk.size > 0) + queueChannelWarning(cc.index, false, true, + "Channel %d will resolve to preset '%s' but uses a non-default key - " + "default-key nodes can't decode it.", // max 102 bytes + cc.index, presetName); return; } - if (strcmp(normChan, normPreset) != 0) // not using a preset name for channel + if (strcmp(normChan, normPreset) != 0) { // name unrelated to preset + if (blankPsk) + queueChannelWarning(cc.index, false, true, + "Channel %d '%s' has a blank PSK (no encryption). " + "If you intended the default key, set PSK to 'AQ=='.", // max 100 bytes + cc.index, cc.settings.name); return; + } - if (strcmp(cc.settings.name, presetName) != 0) - sendWarningAndLog("Channel %d name '%s' looks like a mistype of '%s' - " - "clear the name to use the preset name automatically.", // max 113 bytes - cc.index, cc.settings.name, presetName); + bool variantName = (strcmp(cc.settings.name, presetName) != 0); + bool keyMismatch = (!isDefaultKey && cc.settings.psk.size > 0); + int issues = (int)blankPsk + (int)variantName + (int)keyMismatch; - if (!isDefaultKey && cc.settings.psk.size > 0) - sendWarningAndLog("Channel %d '%s' matches preset '%s' but uses a non-default key - " - "other nodes on this preset cannot decode it.", // max 118 bytes - cc.index, cc.settings.name, presetName); + if (issues > 1) { + bool hasNameIssue = variantName; + bool hasPskIssue = blankPsk || keyMismatch; + if (hasNameIssue && hasPskIssue) + queueChannelWarning(cc.index, true, true, "There may be name and PSK issues on channel %d", cc.index); + else if (hasNameIssue) + queueChannelWarning(cc.index, true, false, "There may be name issues on channel %d", cc.index); + else + queueChannelWarning(cc.index, false, true, "There may be PSK issues on channel %d", cc.index); + return; + } + + if (blankPsk) + queueChannelWarning(cc.index, false, true, + "Channel %d '%s' has a blank PSK (no encryption). " + "If you intended the default key, set PSK to 'AQ=='.", // max 100 bytes + cc.index, cc.settings.name); + if (variantName) + queueChannelWarning(cc.index, true, false, + "Channel %d name '%s' looks like a mistype of '%s' - " + "clear the name to use the preset name automatically.", // max 113 bytes + cc.index, cc.settings.name, presetName); + if (keyMismatch) + queueChannelWarning(cc.index, false, true, + "Channel %d '%s' matches preset '%s' but uses a non-default key - " + "default-key nodes can't decode it.", // max 108 bytes + cc.index, cc.settings.name, presetName); } // warnOnChannelSet /** @@ -1879,21 +1993,24 @@ void AdminModule::warnOnLoraPresetChange(const meshtastic_Config_LoRaConfig &old const char *newName = DisplayFormatters::getModemPresetDisplayName(newLora.modem_preset, false, true); normalizePresetName(newName, normNew, sizeof(normNew)); + // Queue one (name) warning per affected channel; flushChannelWarnings() collapses them + // into a single message - either the lone warning verbatim or a catch-all listing indices. for (int i = 0; i < channels.getNumChannels(); i++) { const meshtastic_Channel &ch = channels.getByIndex(i); if (ch.role == meshtastic_Channel_Role_DISABLED || !ch.has_settings || !*ch.settings.name) continue; char normChan[32]; normalizePresetName(ch.settings.name, normChan, sizeof(normChan)); - if (*normOld && strcmp(normChan, normOld) == 0) { - sendWarningAndLog("Channel %d name '%s' matches the old preset. " - "Rename it manually if it should track the new preset.", // max 98 bytes - i, ch.settings.name); - } else if (strcmp(normChan, normNew) == 0) { - sendWarningAndLog("Channel %d '%s' looks like preset '%s' but won't auto-resolve - " - "clear the name to fix it.", // max 98 bytes - i, ch.settings.name, newName); - } + if (*normOld && strcmp(normChan, normOld) == 0) + queueChannelWarning(i, true, false, + "Channel %d name '%s' matches the old preset. " + "Rename it manually if it should track the new preset.", // max 98 bytes + i, ch.settings.name); + else if (strcmp(normChan, normNew) == 0) + queueChannelWarning(i, true, false, + "Channel %d '%s' looks like preset '%s' but won't auto-resolve - " + "clear the name to fix it.", // max 98 bytes + i, ch.settings.name, newName); } } // warnOnLoraPresetChange diff --git a/src/modules/AdminModule.h b/src/modules/AdminModule.h index 7648791f07f..a9c3080fdfc 100644 --- a/src/modules/AdminModule.h +++ b/src/modules/AdminModule.h @@ -85,6 +85,25 @@ class AdminModule : public ProtobufModule, public Obser void sendWarningAndLog(const char *format, ...) __attribute__((format(printf, 2, 3))); void warnOnLoraPresetChange(const meshtastic_Config_LoRaConfig &oldLora, const meshtastic_Config_LoRaConfig &newLora); void warnOnChannelSet(const meshtastic_Channel &cc); + + // Channel-configuration warnings are coalesced into a single client notification. + // queueChannelWarning() records one warning for a channel; while an edit transaction + // is open (begin/commit_edit_settings) the warnings accumulate and flushChannelWarnings() + // emits one combined message at commit. Outside a transaction the caller flushes + // immediately, so a single channel/preset edit still produces a single message. + void queueChannelWarning(uint8_t channelIndex, bool nameIssue, bool pskIssue, const char *format, ...) + __attribute__((format(printf, 5, 6))); + // Emit the "licensed mode activated" notice, deferring to commit during an edit transaction + // so repeated triggers (e.g. owner + several channels) produce a single message. + void warnLicensedMode(); + void flushChannelWarnings(); + + char pendingWarningText[250] = {}; // the lone queued message, used verbatim when only one fired + uint32_t pendingWarningChannels = 0; // bitmask of channel indices with a queued warning + uint8_t pendingWarningCount = 0; // number of queued warnings this transaction + bool pendingWarningNameIssue = false; // any queued warning was about a channel name + bool pendingWarningPskIssue = false; // any queued warning was about a PSK + bool pendingLicenseWarning = false; // a licensed-mode notice is queued for this transaction }; static constexpr const char *licensedModeMessage = diff --git a/test/test_admin_radio/test_main.cpp b/test/test_admin_radio/test_main.cpp index d905cc8a035..cddd252f17d 100644 --- a/test/test_admin_radio/test_main.cpp +++ b/test/test_admin_radio/test_main.cpp @@ -17,18 +17,30 @@ #include "NodeDB.h" #include "RadioInterface.h" #include "TestUtil.h" +#include "mesh/Channels.h" #include "modules/AdminModule.h" +#include #include +#include #include "meshtastic/config.pb.h" // hash() is a file-scope function in RadioInterface.cpp; link it in for slot-formula tests extern uint32_t hash(const char *str); +// Every client notification the AdminModule emits flows through sendClientNotification(); +// capture each formatted message so the warning/coalescing tests can assert on the exact +// set of messages produced by a sequence of admin messages. +static std::vector capturedWarnings; + class MockMeshService : public MeshService { public: - void sendClientNotification(meshtastic_ClientNotification *n) override { releaseClientNotificationToPool(n); } + void sendClientNotification(meshtastic_ClientNotification *n) override + { + capturedWarnings.push_back(n->message); + releaseClientNotificationToPool(n); + } }; static MockMeshService *mockMeshService; @@ -932,6 +944,7 @@ static void test_channelSpacingCalculation_placeholder() class AdminModuleTestShim : public AdminModule { public: + using AdminModule::handleReceivedProtobuf; // drive full incoming admin messages (begin/commit/set_channel) using AdminModule::handleSetConfig; }; @@ -1093,6 +1106,167 @@ static void test_handleSetConfig_fromOthers_lockedPresetFromNonTrioRegionRejecte TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, config.lora.modem_preset); } +// ----------------------------------------------------------------------- +// Channel-configuration warning + coalescing tests +// +// These exercise the real incoming-admin-message path (handleReceivedProtobuf): +// begin_edit_settings / set_channel / commit_edit_settings. Warnings raised while a +// transaction is open must be deferred and collapsed into a single notification at +// commit; outside a transaction each save emits its own single message immediately. +// ----------------------------------------------------------------------- + +static const uint8_t DEFAULT_KEY[] = {0x01}; // the well-known "default" PSK (AQ==) +static const uint8_t CUSTOM_KEY[] = {0x42, 0x17}; // any non-default key + +// Count captured warnings whose text contains substr. +static int warningsContaining(const char *substr) +{ + int n = 0; + for (const auto &w : capturedWarnings) + if (w.find(substr) != std::string::npos) + n++; + return n; +} + +static meshtastic_Channel makeChannel(int8_t index, meshtastic_Channel_Role role, const char *name, const uint8_t *psk, + size_t pskLen) +{ + meshtastic_Channel ch = meshtastic_Channel_init_zero; + ch.index = index; + ch.role = role; + ch.has_settings = true; + strncpy(ch.settings.name, name, sizeof(ch.settings.name) - 1); + ch.settings.psk.size = pskLen; + for (size_t i = 0; i < pskLen; i++) + ch.settings.psk.bytes[i] = psk[i]; + return ch; +} + +// Dispatch one admin message as if it arrived from a local (from==0) client, which bypasses +// the passkey/authorization gates so the switch body runs. +static void sendAdmin(meshtastic_AdminMessage &m) +{ + meshtastic_MeshPacket mp = meshtastic_MeshPacket_init_zero; + mp.from = 0; + mp.which_payload_variant = meshtastic_MeshPacket_decoded_tag; // required: handler drops non-decoded packets + testAdmin->handleReceivedProtobuf(mp, &m); +} + +static void sendSetChannel(const meshtastic_Channel &ch) +{ + meshtastic_AdminMessage m = meshtastic_AdminMessage_init_zero; + m.which_payload_variant = meshtastic_AdminMessage_set_channel_tag; + m.set_channel = ch; + sendAdmin(m); +} + +static void sendBeginEdit() +{ + meshtastic_AdminMessage m = meshtastic_AdminMessage_init_zero; + m.which_payload_variant = meshtastic_AdminMessage_begin_edit_settings_tag; + m.begin_edit_settings = true; + sendAdmin(m); +} + +static void sendCommitEdit() +{ + meshtastic_AdminMessage m = meshtastic_AdminMessage_init_zero; + m.which_payload_variant = meshtastic_AdminMessage_commit_edit_settings_tag; + m.commit_edit_settings = true; + sendAdmin(m); +} + +// Preset = LongFast on US, unlicensed owner. "LongFast" is the display name we compare against. +static void usePresetLongFast() +{ + config.lora = meshtastic_Config_LoRaConfig_init_zero; + config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_US; + config.lora.use_preset = true; + config.lora.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST; + initRegion(); + owner.is_licensed = false; +} + +static void test_warn_singleChannel_variantName_oneSpecificMessage() +{ + usePresetLongFast(); + // Name is a case/space variant of the preset with the default key: a single name issue. + sendSetChannel(makeChannel(0, meshtastic_Channel_Role_PRIMARY, "long fast", DEFAULT_KEY, 1)); + TEST_ASSERT_EQUAL_INT(1, (int)capturedWarnings.size()); + TEST_ASSERT_EQUAL_INT(1, warningsContaining("looks like a mistype of 'LongFast'")); +} + +static void test_warn_singleChannel_nameAndPsk_collapsedToCatchAll() +{ + usePresetLongFast(); + // Variant name AND a non-default key: two issues on one channel collapse to one catch-all. + sendSetChannel(makeChannel(0, meshtastic_Channel_Role_PRIMARY, "long fast", CUSTOM_KEY, 2)); + TEST_ASSERT_EQUAL_INT(1, (int)capturedWarnings.size()); + TEST_ASSERT_EQUAL_INT(1, warningsContaining("There may be name and PSK issues on channel 0")); +} + +static void test_warn_cleanChannel_noMessage() +{ + usePresetLongFast(); + // Exact preset name + default key: nothing to warn about. + sendSetChannel(makeChannel(0, meshtastic_Channel_Role_PRIMARY, "LongFast", DEFAULT_KEY, 1)); + TEST_ASSERT_EQUAL_INT(0, (int)capturedWarnings.size()); +} + +static void test_warn_transaction_multipleChannels_singleCoalescedMessage() +{ + usePresetLongFast(); + sendBeginEdit(); + sendSetChannel(makeChannel(0, meshtastic_Channel_Role_PRIMARY, "long fast", DEFAULT_KEY, 1)); + sendSetChannel(makeChannel(1, meshtastic_Channel_Role_SECONDARY, "long fast", DEFAULT_KEY, 1)); + // Nothing emitted yet - warnings are deferred until commit. + TEST_ASSERT_EQUAL_INT(0, (int)capturedWarnings.size()); + + sendCommitEdit(); + // Exactly one message, naming both channels. + TEST_ASSERT_EQUAL_INT(1, (int)capturedWarnings.size()); + TEST_ASSERT_EQUAL_INT(1, warningsContaining("There may be name issues on channels 0, 1")); +} + +static void test_warn_transaction_singleChannel_keepsSpecificMessage() +{ + usePresetLongFast(); + sendBeginEdit(); + sendSetChannel(makeChannel(0, meshtastic_Channel_Role_PRIMARY, "long fast", DEFAULT_KEY, 1)); + TEST_ASSERT_EQUAL_INT(0, (int)capturedWarnings.size()); + + sendCommitEdit(); + // One flagged channel: the specific message verbatim, not the plural catch-all. + TEST_ASSERT_EQUAL_INT(1, (int)capturedWarnings.size()); + TEST_ASSERT_EQUAL_INT(1, warningsContaining("looks like a mistype of 'LongFast'")); + TEST_ASSERT_EQUAL_INT(0, warningsContaining("on channels")); +} + +static void test_warn_license_noTransaction_emittedImmediately() +{ + usePresetLongFast(); + owner.is_licensed = true; + // Setting a channel that still carries a key triggers ensureLicensedOperation() to strip it. + sendSetChannel(makeChannel(0, meshtastic_Channel_Role_PRIMARY, "", CUSTOM_KEY, 2)); + TEST_ASSERT_EQUAL_INT(1, warningsContaining("Licensed mode activated")); +} + +static void test_warn_license_transaction_coalescedToSingleMessage() +{ + usePresetLongFast(); + owner.is_licensed = true; + sendBeginEdit(); + // Two separate triggers within one transaction (two channels with keys to strip). + sendSetChannel(makeChannel(0, meshtastic_Channel_Role_PRIMARY, "", CUSTOM_KEY, 2)); + sendSetChannel(makeChannel(1, meshtastic_Channel_Role_SECONDARY, "", CUSTOM_KEY, 2)); + TEST_ASSERT_EQUAL_INT(0, (int)capturedWarnings.size()); + + sendCommitEdit(); + // Collapsed to a single licensed-mode notice (and no channel warning, since names are blank). + TEST_ASSERT_EQUAL_INT(1, warningsContaining("Licensed mode activated")); + TEST_ASSERT_EQUAL_INT(1, (int)capturedWarnings.size()); +} + // ----------------------------------------------------------------------- // Test runner // ----------------------------------------------------------------------- @@ -1102,6 +1276,12 @@ void setUp(void) mockMeshService = new MockMeshService(); service = mockMeshService; testAdmin = new AdminModuleTestShim(); + capturedWarnings.clear(); + // Committing an edit transaction triggers a full saveToDisk(), which dereferences nodeDB. + // Create it once (kept reachable via the global, so no leak) for the warning tests; the + // other tests in this suite set their own config/region state and are unaffected. + if (!nodeDB) + nodeDB = new NodeDB(); } void tearDown(void) { @@ -1202,6 +1382,15 @@ void setup() RUN_TEST(test_handleSetConfig_fromOthers_siblingLockedPresetSwapsRegion); RUN_TEST(test_handleSetConfig_fromOthers_lockedPresetFromNonTrioRegionRejected); + // Channel-configuration warning + coalescing + RUN_TEST(test_warn_singleChannel_variantName_oneSpecificMessage); + RUN_TEST(test_warn_singleChannel_nameAndPsk_collapsedToCatchAll); + RUN_TEST(test_warn_cleanChannel_noMessage); + RUN_TEST(test_warn_transaction_multipleChannels_singleCoalescedMessage); + RUN_TEST(test_warn_transaction_singleChannel_keepsSpecificMessage); + RUN_TEST(test_warn_license_noTransaction_emittedImmediately); + RUN_TEST(test_warn_license_transaction_coalescedToSingleMessage); + exit(UNITY_END()); } From 0bf84bc6e8dfe4137e47e6012614d38339603bab Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Jul 2026 17:55:54 +0000 Subject: [PATCH 4/4] Drop literal 'AQ==' from blank-PSK channel warnings The base64 encoding of the default channel key isn't something a user should type in by hand; state the condition instead of a raw key value. --- src/modules/AdminModule.cpp | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/src/modules/AdminModule.cpp b/src/modules/AdminModule.cpp index a96da8e5d85..fb81bb64ccd 100644 --- a/src/modules/AdminModule.cpp +++ b/src/modules/AdminModule.cpp @@ -1969,7 +1969,7 @@ void AdminModule::flushChannelWarnings() * are performed: * * - Blank PSK (size == 0) on a non-licensed device: the channel has no encryption. - * - Blank name with a non-default key (not AQ== / 0x01): the name will auto-resolve to + * - Blank name with a non-default key (not the default key, 0x01): the name will auto-resolve to * the current modem preset name, but the key mismatch means other preset nodes cannot * decode traffic on this channel. * - Named channel whose name is a case/space variant of the current modem preset: the @@ -2017,8 +2017,7 @@ void AdminModule::warnOnChannelSet(const meshtastic_Channel &cc) cc.index, cc.settings.name, mistypePreset); else if (blankPsk) queueChannelWarning(cc.index, false, true, - "Channel %d '%s' has a blank PSK (no encryption). " - "If you intended the default key, set PSK to 'AQ=='.", // max 100 bytes + "Channel %d '%s' has a blank PSK (no encryption) instead of default key", // max 100 bytes cc.index, cc.settings.name); return; } @@ -2033,8 +2032,7 @@ void AdminModule::warnOnChannelSet(const meshtastic_Channel &cc) // Blank name resolves to the preset name - A and B are mutually exclusive (psk.size can't be both 0 and >0) if (blankPsk) queueChannelWarning(cc.index, false, true, - "Channel %d '%s' has a blank PSK (no encryption). " - "If you intended the default key, set PSK to 'AQ=='.", // max 100 bytes + "Channel %d '%s' has a blank PSK (no encryption) instead of default key", // max 100 bytes cc.index, cc.settings.name); else if (!isDefaultKey && cc.settings.psk.size > 0) queueChannelWarning(cc.index, false, true, @@ -2047,8 +2045,7 @@ void AdminModule::warnOnChannelSet(const meshtastic_Channel &cc) if (strcmp(normChan, normPreset) != 0) { // name unrelated to preset if (blankPsk) queueChannelWarning(cc.index, false, true, - "Channel %d '%s' has a blank PSK (no encryption). " - "If you intended the default key, set PSK to 'AQ=='.", // max 100 bytes + "Channel %d '%s' has a blank PSK (no encryption) instead of default key", // max 100 bytes cc.index, cc.settings.name); return; } @@ -2071,8 +2068,7 @@ void AdminModule::warnOnChannelSet(const meshtastic_Channel &cc) if (blankPsk) queueChannelWarning(cc.index, false, true, - "Channel %d '%s' has a blank PSK (no encryption). " - "If you intended the default key, set PSK to 'AQ=='.", // max 100 bytes + "Channel %d '%s' has a blank PSK (no encryption) instead of default key", // max 100 bytes cc.index, cc.settings.name); if (variantName) queueChannelWarning(cc.index, true, false,