Files
firmware/src/modules/AdminModule.cpp
T
8384659608 fix: apply all LoRa config changes live without rebooting (#9962)
* fix: apply all LoRa config changes live without rebooting

All LoRa radio settings (SF, BW, CR, frequency, power, preset,
sx126x_rx_boosted_gain) now apply immediately via reconfigure()
without requiring a node reboot.

- AdminModule: requiresReboot = false for all LoRa config changes;
  LoRa changes were already handled by the configChanged observer
  calling reconfigure() but the reboot flag was set unnecessarily
- AdminModule: validate LORA_24 region against radio hardware at
  config time; reject with BAD_REQUEST if hardware lacks 2.4 GHz
  capability (wideLora() returns false or no radio instance)
- SX126xInterface/LR11x0Interface: apply sx126x_rx_boosted_gain in
  reconfigure(); register 0x08AC is writable in STDBY mode (SX1261/2
  datasheet §9.6); retention registers written so setting survives
  warm-sleep cycles; log warning on setter failure
- DebugRenderer: show BW/SF/CR on debug screen when custom modem is
  active instead of the preset name
- DisplayFormatters: clarify comment on getModemPresetDisplayName

* fix: remove redundant reboot after LoRa config changes in on-device menus

Region, frequency slot, and radio preset pickers in MenuHandler all
called reloadConfig() then immediately set rebootAtMsec. reloadConfig()
already fires the configChanged observer which calls reconfigure(), so
the forced reboot was unnecessary — same rationale as the parent commit.

* fix: guard LORA_24 region selection against hardware capability in on-device menu

Without a reboot, reconfigure() now applies region changes directly.
Previously getRadio() caught the LORA_24-on-sub-GHz mismatch post-reboot
and reverted to UNSET — that safety net is gone. Add an explicit wideLora()
check in LoraRegionPicker so sub-GHz-only hardware silently ignores LORA_24
selection instead of attempting a live reconfigure with an invalid frequency.

---------

Co-authored-by: elwimen <elwimen@users.noreply.github.com>
Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
2026-03-22 09:39:41 -05:00

1591 lines
69 KiB
C++

#include "AdminModule.h"
#include "Channels.h"
#include "MeshService.h"
#include "NodeDB.h"
#include "PowerFSM.h"
#include "RTC.h"
#include "SPILock.h"
#include "input/InputBroker.h"
#include "meshUtils.h"
#include <FSCommon.h>
#include <ctype.h> // for better whitespace handling
#if defined(ARCH_ESP32) && !MESHTASTIC_EXCLUDE_WIFI
#include "MeshtasticOTA.h"
#endif
#include "Router.h"
#include "configuration.h"
#include "main.h"
#ifdef ARCH_NRF52
#include "main.h"
#endif
#ifdef ARCH_PORTDUINO
#include "unistd.h"
#endif
#include "Default.h"
#include "MeshRadio.h"
#include "RadioInterface.h"
#include "TypeConversions.h"
#include "mesh/RadioLibInterface.h"
#if !MESHTASTIC_EXCLUDE_MQTT
#include "mqtt/MQTT.h"
#endif
#if !MESHTASTIC_EXCLUDE_GPS
#include "GPS.h"
#endif
#if MESHTASTIC_EXCLUDE_GPS
#include "modules/PositionModule.h"
#endif
#if !defined(ARCH_STM32WL) && !MESHTASTIC_EXCLUDE_I2C
#include "motion/AccelerometerThread.h"
#endif
#if (defined(ARCH_ESP32) || defined(ARCH_NRF52) || defined(ARCH_RP2040)) && !defined(CONFIG_IDF_TARGET_ESP32S2) && \
!defined(CONFIG_IDF_TARGET_ESP32C3)
#include "SerialModule.h"
#endif
AdminModule *adminModule;
bool hasOpenEditTransaction;
/// A special reserved string to indicate strings we can not share with external nodes. We will use this 'reserved' word instead.
/// Also, to make setting work correctly, if someone tries to set a string to this reserved value we assume they don't really want
/// a change.
static const char *secretReserved = "sekrit";
/// If buf is the reserved secret word, replace the buffer with currentVal
static void writeSecret(char *buf, size_t bufsz, const char *currentVal)
{
if (strcmp(buf, secretReserved) == 0) {
strncpy(buf, currentVal, bufsz);
}
}
/**
* @brief Handle received protobuf message
*
* @param mp Received MeshPacket
* @param r Decoded AdminMessage
* @return bool
*/
bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshtastic_AdminMessage *r)
{
// if handled == false, then let others look at this message also if they want
bool handled = false;
assert(r);
bool fromOthers = !isFromUs(&mp);
if (mp.which_payload_variant != meshtastic_MeshPacket_decoded_tag) {
return handled;
}
meshtastic_Channel *ch = &channels.getByIndex(mp.channel);
// Could tighten this up further by tracking the last public_key we went an AdminMessage request to
// and only allowing responses from that remote.
if (messageIsResponse(r)) {
LOG_DEBUG("Allow admin response message");
} else if (mp.from == 0) {
if (config.security.is_managed) {
LOG_INFO("Ignore local admin payload because is_managed");
return handled;
}
} else if (strcasecmp(ch->settings.name, Channels::adminChannel) == 0) {
if (!config.security.admin_channel_enabled) {
LOG_INFO("Ignore admin channel, legacy admin is disabled");
myReply = allocErrorResponse(meshtastic_Routing_Error_NOT_AUTHORIZED, &mp);
return handled;
}
} else if (mp.pki_encrypted) {
if ((config.security.admin_key[0].size == 32 &&
memcmp(mp.public_key.bytes, config.security.admin_key[0].bytes, 32) == 0) ||
(config.security.admin_key[1].size == 32 &&
memcmp(mp.public_key.bytes, config.security.admin_key[1].bytes, 32) == 0) ||
(config.security.admin_key[2].size == 32 &&
memcmp(mp.public_key.bytes, config.security.admin_key[2].bytes, 32) == 0)) {
LOG_INFO("PKC admin payload with authorized sender key");
// Automatically favorite the node that is using the admin key
auto remoteNode = nodeDB->getMeshNode(mp.from);
if (remoteNode && !remoteNode->is_favorite) {
if (config.device.role == meshtastic_Config_DeviceConfig_Role_CLIENT_BASE) {
// Special case for CLIENT_BASE: is_favorite has special meaning, and we don't want to automatically set it
// without the user doing so deliberately.
LOG_INFO("PKC admin valid, but not auto-favoriting node %x because role==CLIENT_BASE", mp.from);
} else {
LOG_INFO("PKC admin valid. Auto-favoriting node %x", mp.from);
remoteNode->is_favorite = true;
}
}
} else {
myReply = allocErrorResponse(meshtastic_Routing_Error_ADMIN_PUBLIC_KEY_UNAUTHORIZED, &mp);
LOG_INFO("Received PKC admin payload, but the sender public key does not match the admin authorized key!");
return handled;
}
} else {
LOG_INFO("Ignore unauthorized admin payload %i", r->which_payload_variant);
myReply = allocErrorResponse(meshtastic_Routing_Error_NOT_AUTHORIZED, &mp);
return handled;
}
LOG_INFO("Handle admin payload %i", r->which_payload_variant);
// all of the get and set messages, including those for other modules, flow through here first.
// any message that changes state, we want to check the passkey for
if (mp.from != 0 && !messageIsRequest(r) && !messageIsResponse(r)) {
if (!checkPassKey(r)) {
LOG_WARN("Admin message without session_key!");
myReply = allocErrorResponse(meshtastic_Routing_Error_ADMIN_BAD_SESSION_KEY, &mp);
return handled;
}
}
switch (r->which_payload_variant) {
/**
* Getters
*/
case meshtastic_AdminMessage_get_owner_request_tag:
LOG_DEBUG("Client got owner");
handleGetOwner(mp);
break;
case meshtastic_AdminMessage_get_config_request_tag:
LOG_DEBUG("Client got config");
handleGetConfig(mp, r->get_config_request);
break;
case meshtastic_AdminMessage_get_module_config_request_tag:
LOG_DEBUG("Client got module config");
handleGetModuleConfig(mp, r->get_module_config_request);
break;
case meshtastic_AdminMessage_get_channel_request_tag: {
uint32_t i = r->get_channel_request - 1;
LOG_DEBUG("Client got channel %u", i);
if (i >= MAX_NUM_CHANNELS)
myReply = allocErrorResponse(meshtastic_Routing_Error_BAD_REQUEST, &mp);
else
handleGetChannel(mp, i);
break;
}
/**
* Setters
*/
case meshtastic_AdminMessage_set_owner_tag:
LOG_DEBUG("Client set owner");
// Validate names
if (*r->set_owner.long_name) {
const char *start = r->set_owner.long_name;
// Skip all whitespace (space, tab, newline, etc)
while (*start && isspace((unsigned char)*start))
start++;
if (*start == '\0') {
LOG_WARN("Rejected long_name: must contain at least 1 non-whitespace character");
myReply = allocErrorResponse(meshtastic_Routing_Error_BAD_REQUEST, &mp);
break;
}
}
if (*r->set_owner.short_name) {
const char *start = r->set_owner.short_name;
while (*start && isspace((unsigned char)*start))
start++;
if (*start == '\0') {
LOG_WARN("Rejected short_name: must contain at least 1 non-whitespace character");
myReply = allocErrorResponse(meshtastic_Routing_Error_BAD_REQUEST, &mp);
break;
}
}
handleSetOwner(r->set_owner);
break;
case meshtastic_AdminMessage_set_config_tag: {
LOG_DEBUG("Client set config");
// Non-LoRa configs need no further validation.
if (r->set_config.which_payload_variant != meshtastic_Config_lora_tag) {
LOG_DEBUG("Non-LoRa config, applying directly");
handleSetConfig(r->set_config, fromOthers);
break;
}
// Only LORA_24 requires hardware capability validation.
if (r->set_config.payload_variant.lora.region != meshtastic_Config_LoRaConfig_RegionCode_LORA_24) {
LOG_DEBUG("LoRa config, region is not LORA_24, applying directly");
handleSetConfig(r->set_config, fromOthers);
break;
}
// 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");
handleSetConfig(r->set_config, fromOthers);
break;
}
LOG_WARN("Radio hardware does not support 2.4 GHz; rejecting LORA_24 region");
myReply = allocErrorResponse(meshtastic_Routing_Error_BAD_REQUEST, &mp);
break;
}
case meshtastic_AdminMessage_set_module_config_tag:
LOG_DEBUG("Client set module config");
if (!handleSetModuleConfig(r->set_module_config)) {
myReply = allocErrorResponse(meshtastic_Routing_Error_BAD_REQUEST, &mp);
}
break;
case meshtastic_AdminMessage_set_channel_tag:
LOG_DEBUG("Client set channel %d", r->set_channel.index);
if (r->set_channel.index < 0 || r->set_channel.index >= (int)MAX_NUM_CHANNELS)
myReply = allocErrorResponse(meshtastic_Routing_Error_BAD_REQUEST, &mp);
else
handleSetChannel(r->set_channel);
break;
case meshtastic_AdminMessage_set_ham_mode_tag:
LOG_DEBUG("Client set ham mode");
handleSetHamMode(r->set_ham_mode);
break;
case meshtastic_AdminMessage_get_ui_config_request_tag: {
LOG_DEBUG("Client is getting device-ui config");
handleGetDeviceUIConfig(mp);
handled = true;
break;
}
/**
* Other
*/
case meshtastic_AdminMessage_reboot_seconds_tag: {
reboot(r->reboot_seconds);
break;
}
case meshtastic_AdminMessage_ota_request_tag: {
#if defined(ARCH_ESP32)
LOG_INFO("OTA Requested");
if (r->ota_request.ota_hash.size != 32) {
suppressRebootBanner = true;
sendWarningAndLog("Cannot start OTA: Invalid `ota_hash` provided.");
break;
}
meshtastic_OTAMode mode = r->ota_request.reboot_ota_mode;
const char *mode_name = (mode == METHOD_OTA_BLE ? "BLE" : "WiFi");
// Check that we have an OTA partition
const esp_partition_t *part = MeshtasticOTA::getAppPartition();
if (part == NULL) {
suppressRebootBanner = true;
sendWarningAndLog("Cannot start OTA: Cannot find OTA Loader partition.");
break;
}
static esp_app_desc_t app_desc;
if (!MeshtasticOTA::getAppDesc(part, &app_desc)) {
suppressRebootBanner = true;
sendWarningAndLog("Cannot start OTA: Device does have a valid OTA Loader.");
break;
}
if (!MeshtasticOTA::checkOTACapability(&app_desc, mode)) {
suppressRebootBanner = true;
sendWarningAndLog("OTA Loader does not support %s", mode_name);
break;
}
if (MeshtasticOTA::trySwitchToOTA()) {
suppressRebootBanner = true;
if (screen)
screen->startFirmwareUpdateScreen();
MeshtasticOTA::saveConfig(&config.network, mode, r->ota_request.ota_hash.bytes);
sendWarningAndLog("Rebooting to %s OTA", mode_name);
} else {
sendWarningAndLog("Unable to switch to the OTA partition.");
}
#endif
int s = 1; // Reboot in 1 second, hard coded
LOG_INFO("Reboot in %d seconds", s);
rebootAtMsec = (s < 0) ? 0 : (millis() + s * 1000);
break;
}
case meshtastic_AdminMessage_shutdown_seconds_tag: {
int32_t s = r->shutdown_seconds;
LOG_INFO("Shutdown in %d seconds", s);
shutdownAtMsec = (s < 0) ? 0 : (millis() + s * 1000);
break;
}
case meshtastic_AdminMessage_get_device_metadata_request_tag: {
LOG_INFO("Client got device metadata");
handleGetDeviceMetadata(mp);
break;
}
case meshtastic_AdminMessage_factory_reset_config_tag: {
disableBluetooth();
LOG_INFO("Initiate factory config reset");
nodeDB->factoryReset();
LOG_INFO("Factory config reset finished, rebooting soon");
reboot(DEFAULT_REBOOT_SECONDS);
break;
}
case meshtastic_AdminMessage_factory_reset_device_tag: {
disableBluetooth();
LOG_INFO("Initiate full factory reset");
nodeDB->factoryReset(true);
reboot(DEFAULT_REBOOT_SECONDS);
break;
}
case meshtastic_AdminMessage_nodedb_reset_tag: {
disableBluetooth();
LOG_INFO("Initiate node-db reset");
// CLIENT_BASE, ROUTER and ROUTER_LATE are able to preserve the remaining hop count when relaying a packet via a
// favorited node, so ensure that their favorites are kept on reset
bool rolePreference =
isOneOf(config.device.role, meshtastic_Config_DeviceConfig_Role_CLIENT_BASE,
meshtastic_Config_DeviceConfig_Role_ROUTER, meshtastic_Config_DeviceConfig_Role_ROUTER_LATE);
nodeDB->resetNodes(rolePreference ? rolePreference : r->nodedb_reset);
reboot(DEFAULT_REBOOT_SECONDS);
break;
}
case meshtastic_AdminMessage_store_ui_config_tag: {
LOG_INFO("Storing device-ui config");
handleStoreDeviceUIConfig(r->store_ui_config);
handled = true;
break;
}
case meshtastic_AdminMessage_begin_edit_settings_tag: {
LOG_INFO("Begin transaction for editing settings");
hasOpenEditTransaction = true;
break;
}
case meshtastic_AdminMessage_commit_edit_settings_tag: {
disableBluetooth();
LOG_INFO("Commit transaction for edited settings");
hasOpenEditTransaction = false;
saveChanges(SEGMENT_CONFIG | SEGMENT_MODULECONFIG | SEGMENT_DEVICESTATE | SEGMENT_CHANNELS | SEGMENT_NODEDATABASE);
break;
}
case meshtastic_AdminMessage_get_device_connection_status_request_tag: {
LOG_INFO("Client got device connection status");
handleGetDeviceConnectionStatus(mp);
break;
}
case meshtastic_AdminMessage_get_module_config_response_tag: {
LOG_INFO("Client received a get_module_config response");
if (fromOthers && r->get_module_config_response.which_payload_variant ==
meshtastic_AdminMessage_ModuleConfigType_REMOTEHARDWARE_CONFIG) {
handleGetModuleConfigResponse(mp, r);
}
break;
}
case meshtastic_AdminMessage_remove_by_nodenum_tag: {
LOG_INFO("Client received remove_nodenum command");
nodeDB->removeNodeByNum(r->remove_by_nodenum);
break;
}
case meshtastic_AdminMessage_add_contact_tag: {
LOG_INFO("Client received add_contact command");
nodeDB->addFromContact(r->add_contact);
break;
}
case meshtastic_AdminMessage_set_favorite_node_tag: {
LOG_INFO("Client received set_favorite_node command");
meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(r->set_favorite_node);
if (node != NULL) {
node->is_favorite = true;
saveChanges(SEGMENT_NODEDATABASE, false);
if (screen)
screen->setFrames(graphics::Screen::FOCUS_PRESERVE); // <-- Rebuild screens
}
break;
}
case meshtastic_AdminMessage_remove_favorite_node_tag: {
LOG_INFO("Client received remove_favorite_node command");
meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(r->remove_favorite_node);
if (node != NULL) {
node->is_favorite = false;
saveChanges(SEGMENT_NODEDATABASE, false);
if (screen)
screen->setFrames(graphics::Screen::FOCUS_PRESERVE); // <-- Rebuild screens
}
break;
}
case meshtastic_AdminMessage_set_ignored_node_tag: {
LOG_INFO("Client received set_ignored_node command");
meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(r->set_ignored_node);
if (node != NULL) {
node->is_ignored = true;
node->has_device_metrics = false;
node->has_position = false;
node->user.public_key.size = 0;
memset(node->user.public_key.bytes, 0, sizeof(node->user.public_key.bytes));
saveChanges(SEGMENT_NODEDATABASE, false);
}
break;
}
case meshtastic_AdminMessage_remove_ignored_node_tag: {
LOG_INFO("Client received remove_ignored_node command");
meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(r->remove_ignored_node);
if (node != NULL) {
node->is_ignored = false;
saveChanges(SEGMENT_NODEDATABASE, false);
}
break;
}
case meshtastic_AdminMessage_toggle_muted_node_tag: {
LOG_INFO("Client received toggle_muted_node command");
meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(r->toggle_muted_node);
if (node != NULL) {
node->bitfield ^= (1 << NODEINFO_BITFIELD_IS_MUTED_SHIFT);
saveChanges(SEGMENT_NODEDATABASE, false);
}
break;
}
case meshtastic_AdminMessage_set_fixed_position_tag: {
LOG_INFO("Client received set_fixed_position command");
meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(nodeDB->getNodeNum());
node->has_position = true;
node->position = TypeConversions::ConvertToPositionLite(r->set_fixed_position);
nodeDB->setLocalPosition(r->set_fixed_position);
config.position.fixed_position = true;
saveChanges(SEGMENT_NODEDATABASE | SEGMENT_CONFIG, false);
#if !MESHTASTIC_EXCLUDE_GPS
if (gps != nullptr)
gps->enable();
// Send our new fixed position to the mesh for good measure
positionModule->sendOurPosition();
#endif
break;
}
case meshtastic_AdminMessage_remove_fixed_position_tag: {
LOG_INFO("Client received remove_fixed_position command");
nodeDB->clearLocalPosition();
config.position.fixed_position = false;
saveChanges(SEGMENT_NODEDATABASE | SEGMENT_CONFIG, false);
break;
}
case meshtastic_AdminMessage_set_time_only_tag: {
LOG_INFO("Client received set_time_only command");
struct timeval tv;
tv.tv_sec = r->set_time_only;
tv.tv_usec = 0;
perhapsSetRTC(RTCQualityNTP, &tv, false);
break;
}
case meshtastic_AdminMessage_enter_dfu_mode_request_tag: {
LOG_INFO("Client requesting to enter DFU mode");
#if HAS_SCREEN
IF_SCREEN(screen->showSimpleBanner("Device is rebooting\ninto DFU mode.", 0));
#endif
#if defined(ARCH_NRF52) || defined(ARCH_RP2040)
enterDfuMode();
#endif
break;
}
case meshtastic_AdminMessage_delete_file_request_tag: {
LOG_DEBUG("Client requesting to delete file: %s", r->delete_file_request);
#ifdef FSCom
spiLock->lock();
if (FSCom.remove(r->delete_file_request)) {
LOG_DEBUG("Successfully deleted file");
} else {
LOG_DEBUG("Failed to delete file");
}
spiLock->unlock();
#endif
break;
}
case meshtastic_AdminMessage_backup_preferences_tag: {
LOG_INFO("Client requesting to backup preferences");
if (nodeDB->backupPreferences(r->backup_preferences)) {
myReply = allocErrorResponse(meshtastic_Routing_Error_NONE, &mp);
} else {
myReply = allocErrorResponse(meshtastic_Routing_Error_BAD_REQUEST, &mp);
}
break;
}
case meshtastic_AdminMessage_restore_preferences_tag: {
LOG_INFO("Client requesting to restore preferences");
if (nodeDB->restorePreferences(r->backup_preferences,
SEGMENT_DEVICESTATE | SEGMENT_CONFIG | SEGMENT_MODULECONFIG | SEGMENT_CHANNELS)) {
myReply = allocErrorResponse(meshtastic_Routing_Error_NONE, &mp);
LOG_DEBUG("Rebooting after successful restore of preferences");
reboot(1000);
disableBluetooth();
} else {
myReply = allocErrorResponse(meshtastic_Routing_Error_BAD_REQUEST, &mp);
}
break;
}
case meshtastic_AdminMessage_remove_backup_preferences_tag: {
LOG_INFO("Client requesting to remove backup preferences");
#ifdef FSCom
if (r->remove_backup_preferences == meshtastic_AdminMessage_BackupLocation_FLASH) {
spiLock->lock();
FSCom.remove(backupFileName);
spiLock->unlock();
} else if (r->remove_backup_preferences == meshtastic_AdminMessage_BackupLocation_SD) {
// TODO: After more mainline SD card support
LOG_ERROR("SD backup removal not implemented yet");
}
#endif
break;
}
case meshtastic_AdminMessage_send_input_event_tag: {
LOG_INFO("Client requesting to send input event");
handleSendInputEvent(r->send_input_event);
break;
}
#ifdef ARCH_PORTDUINO
case meshtastic_AdminMessage_exit_simulator_tag:
LOG_INFO("Exiting simulator");
exit(0);
break;
#endif
default:
meshtastic_AdminMessage res = meshtastic_AdminMessage_init_default;
AdminMessageHandleResult handleResult = MeshModule::handleAdminMessageForAllModules(mp, r, &res);
if (handleResult == AdminMessageHandleResult::HANDLED_WITH_RESPONSE) {
setPassKey(&res);
myReply = allocDataProtobuf(res);
} else if (mp.decoded.want_response) {
LOG_DEBUG("Module API did not respond to admin message. req.variant=%d", r->which_payload_variant);
} else if (handleResult != AdminMessageHandleResult::HANDLED) {
// Probably a message sent by us or sent to our local node. FIXME, we should avoid scanning these messages
LOG_DEBUG("Module API did not handle admin message %d", r->which_payload_variant);
}
break;
}
// Allow any observers (e.g. the UI) to handle/respond
AdminMessageHandleResult observerResult = AdminMessageHandleResult::NOT_HANDLED;
meshtastic_AdminMessage observerResponse = meshtastic_AdminMessage_init_default;
AdminModule_ObserverData observerData = {
.request = r,
.response = &observerResponse,
.result = &observerResult,
};
notifyObservers(&observerData);
if (observerResult == AdminMessageHandleResult::HANDLED_WITH_RESPONSE) {
setPassKey(&observerResponse);
myReply = allocDataProtobuf(observerResponse);
LOG_DEBUG("Observer responded to admin message");
} else if (observerResult == AdminMessageHandleResult::HANDLED) {
LOG_DEBUG("Observer handled admin message");
}
// If asked for a response and it is not yet set, generate an 'ACK' response
if (mp.decoded.want_response && !myReply) {
myReply = allocErrorResponse(meshtastic_Routing_Error_NONE, &mp);
}
if (mp.pki_encrypted && myReply) {
myReply->pki_encrypted = true;
}
return handled;
}
void AdminModule::handleGetModuleConfigResponse(const meshtastic_MeshPacket &mp, meshtastic_AdminMessage *r)
{
// Skip if it's disabled or no pins are exposed
if (!r->get_module_config_response.payload_variant.remote_hardware.enabled ||
r->get_module_config_response.payload_variant.remote_hardware.available_pins_count == 0) {
LOG_DEBUG("Remote hardware module disabled or no available_pins. Skip");
return;
}
for (uint8_t i = 0; i < devicestate.node_remote_hardware_pins_count; i++) {
if (devicestate.node_remote_hardware_pins[i].node_num == 0 || !devicestate.node_remote_hardware_pins[i].has_pin) {
continue;
}
for (uint8_t j = 0; j < r->get_module_config_response.payload_variant.remote_hardware.available_pins_count; j++) {
auto availablePin = r->get_module_config_response.payload_variant.remote_hardware.available_pins[j];
if (i < devicestate.node_remote_hardware_pins_count) {
devicestate.node_remote_hardware_pins[i].node_num = mp.from;
devicestate.node_remote_hardware_pins[i].pin = availablePin;
}
i++;
}
}
}
/**
* Setter methods
*/
void AdminModule::handleSetOwner(const meshtastic_User &o)
{
int changed = 0;
if (*o.long_name) {
changed |= strcmp(owner.long_name, o.long_name);
strncpy(owner.long_name, o.long_name, sizeof(owner.long_name));
}
if (*o.short_name) {
changed |= strcmp(owner.short_name, o.short_name);
strncpy(owner.short_name, o.short_name, sizeof(owner.short_name));
}
snprintf(owner.id, sizeof(owner.id), "!%08x", nodeDB->getNodeNum());
if (owner.is_licensed != o.is_licensed) {
changed = 1;
owner.is_licensed = o.is_licensed;
if (channels.ensureLicensedOperation()) {
sendWarning(licensedModeMessage);
}
}
if (owner.has_is_unmessagable != o.has_is_unmessagable ||
(o.has_is_unmessagable && owner.is_unmessagable != o.is_unmessagable)) {
changed = 1;
owner.has_is_unmessagable = owner.has_is_unmessagable || o.has_is_unmessagable;
owner.is_unmessagable = o.is_unmessagable;
}
if (changed) { // If nothing really changed, don't broadcast on the network or write to flash
service->reloadOwner(!hasOpenEditTransaction);
saveChanges(SEGMENT_DEVICESTATE | SEGMENT_NODEDATABASE);
}
}
void AdminModule::handleSetConfig(const meshtastic_Config &c, bool fromOthers)
{
auto changes = SEGMENT_CONFIG;
auto existingRole = config.device.role;
bool isRegionUnset = (config.lora.region == meshtastic_Config_LoRaConfig_RegionCode_UNSET);
bool requiresReboot = true;
switch (c.which_payload_variant) {
case meshtastic_Config_device_tag:
LOG_INFO("Set config: Device");
config.has_device = true;
#if !defined(ARCH_PORTDUINO) && !defined(ARCH_STM32WL) && !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR
if (config.device.double_tap_as_button_press == false && c.payload_variant.device.double_tap_as_button_press == true &&
accelerometerThread->enabled == false) {
config.device.double_tap_as_button_press = c.payload_variant.device.double_tap_as_button_press;
accelerometerThread->enabled = true;
accelerometerThread->start();
}
#endif
if (config.device.button_gpio == c.payload_variant.device.button_gpio &&
config.device.buzzer_gpio == c.payload_variant.device.buzzer_gpio &&
config.device.role == c.payload_variant.device.role &&
config.device.rebroadcast_mode == c.payload_variant.device.rebroadcast_mode) {
requiresReboot = false;
}
config.device = c.payload_variant.device;
if (config.device.rebroadcast_mode == meshtastic_Config_DeviceConfig_RebroadcastMode_NONE &&
(config.device.role == meshtastic_Config_DeviceConfig_Role_ROUTER ||
config.device.role == meshtastic_Config_DeviceConfig_Role_ROUTER_LATE)) {
config.device.rebroadcast_mode = meshtastic_Config_DeviceConfig_RebroadcastMode_ALL;
const char *warning = "Rebroadcast mode can't be set to NONE for a router role";
LOG_WARN(warning);
sendWarning(warning);
}
// If we're setting router role for the first time, install its intervals
if (existingRole != c.payload_variant.device.role) {
nodeDB->installRoleDefaults(c.payload_variant.device.role);
changes |= SEGMENT_NODEDATABASE | SEGMENT_DEVICESTATE; // Some role defaults affect owner
}
if (config.device.node_info_broadcast_secs < min_node_info_broadcast_secs) {
LOG_DEBUG("Tried to set node_info_broadcast_secs too low, setting to %d", min_node_info_broadcast_secs);
config.device.node_info_broadcast_secs = min_node_info_broadcast_secs;
}
// Router Client and Repeater deprecated; Set it to client
if (IS_ONE_OF(c.payload_variant.device.role, meshtastic_Config_DeviceConfig_Role_ROUTER_CLIENT,
meshtastic_Config_DeviceConfig_Role_REPEATER)) {
config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT;
if (moduleConfig.store_forward.enabled && !moduleConfig.store_forward.is_server) {
moduleConfig.store_forward.is_server = true;
changes |= SEGMENT_MODULECONFIG;
requiresReboot = true;
}
}
#if USERPREFS_EVENT_MODE
// If we're in event mode, nobody is a Router or Router Late
if (config.device.role == meshtastic_Config_DeviceConfig_Role_ROUTER ||
config.device.role == meshtastic_Config_DeviceConfig_Role_ROUTER_LATE) {
config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT;
}
#endif
break;
case meshtastic_Config_position_tag:
LOG_INFO("Set config: Position");
config.has_position = true;
// If we have turned off the GPS (disabled or not present) and we're not using fixed position,
// clear the stored position since it may not get updated
if (config.position.gps_mode == meshtastic_Config_PositionConfig_GpsMode_ENABLED &&
c.payload_variant.position.gps_mode != meshtastic_Config_PositionConfig_GpsMode_ENABLED &&
config.position.fixed_position == false && c.payload_variant.position.fixed_position == false) {
nodeDB->clearLocalPosition();
saveChanges(SEGMENT_NODEDATABASE | SEGMENT_CONFIG, false);
}
config.position = c.payload_variant.position;
// Save nodedb as well in case we got a fixed position packet
break;
case meshtastic_Config_power_tag:
LOG_INFO("Set config: Power");
config.has_power = true;
// Really just the adc override is the only thing that can change without a reboot
if (config.power.device_battery_ina_address == c.payload_variant.power.device_battery_ina_address &&
config.power.is_power_saving == c.payload_variant.power.is_power_saving &&
config.power.ls_secs == c.payload_variant.power.ls_secs &&
config.power.min_wake_secs == c.payload_variant.power.min_wake_secs &&
config.power.on_battery_shutdown_after_secs == c.payload_variant.power.on_battery_shutdown_after_secs &&
config.power.sds_secs == c.payload_variant.power.sds_secs &&
config.power.wait_bluetooth_secs == c.payload_variant.power.wait_bluetooth_secs) {
requiresReboot = false;
}
config.power = c.payload_variant.power;
if (c.payload_variant.power.on_battery_shutdown_after_secs > 0 &&
c.payload_variant.power.on_battery_shutdown_after_secs < 30) {
LOG_WARN("Tried to set on_battery_shutdown_after_secs too low, set to min 30 seconds");
config.power.on_battery_shutdown_after_secs = 30;
}
break;
case meshtastic_Config_network_tag:
LOG_INFO("Set config: WiFi");
config.has_network = true;
config.network = c.payload_variant.network;
break;
case meshtastic_Config_display_tag:
LOG_INFO("Set config: Display");
config.has_display = true;
if (config.display.screen_on_secs == c.payload_variant.display.screen_on_secs &&
config.display.flip_screen == c.payload_variant.display.flip_screen &&
config.display.oled == c.payload_variant.display.oled &&
config.display.displaymode == c.payload_variant.display.displaymode) {
requiresReboot = false;
} else if (config.display.displaymode != meshtastic_Config_DisplayConfig_DisplayMode_COLOR &&
c.payload_variant.display.displaymode == meshtastic_Config_DisplayConfig_DisplayMode_COLOR) {
config.bluetooth.enabled = false;
}
#if !defined(ARCH_PORTDUINO) && !defined(ARCH_STM32WL) && !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR
if (config.display.wake_on_tap_or_motion == false && c.payload_variant.display.wake_on_tap_or_motion == true &&
accelerometerThread->enabled == false) {
config.display.wake_on_tap_or_motion = c.payload_variant.display.wake_on_tap_or_motion;
accelerometerThread->enabled = true;
accelerometerThread->start();
}
#endif
config.display = c.payload_variant.display;
break;
case meshtastic_Config_lora_tag: {
// Wrap the entire case in a block to scope variables and avoid crossing initialization
auto oldLoraConfig = config.lora;
auto validatedLora = c.payload_variant.lora;
LOG_INFO("Set config: LoRa");
config.has_lora = true;
if (validatedLora.coding_rate != clampCodingRate(validatedLora.coding_rate)) {
LOG_WARN("Invalid coding_rate %d, setting to %d", validatedLora.coding_rate, LORA_CR_DEFAULT);
validatedLora.coding_rate = LORA_CR_DEFAULT;
}
if (validatedLora.spread_factor != clampSpreadFactor(validatedLora.spread_factor)) {
LOG_WARN("Invalid spread_factor %d, setting to %d", validatedLora.spread_factor, LORA_SF_DEFAULT);
validatedLora.spread_factor = LORA_SF_DEFAULT;
}
// If we're setting a new region, check the region is valid and then init the region or discard the change
if (validatedLora.region != myRegion->code) {
// Region has changed so check whether it is valid for e.g. licensing conditions and if the lora config is valid
if (RadioInterface::validateConfigRegion(validatedLora) && RadioInterface::validateConfigLora(validatedLora)) {
// If we're setting region for the first time, init the region and regenerate the keys
if (isRegionUnset && validatedLora.region > meshtastic_Config_LoRaConfig_RegionCode_UNSET) {
#if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN || MESHTASTIC_EXCLUDE_PKI)
if (crypto) {
crypto->ensurePkiKeys(config.security, owner);
}
#endif
// new region is valid and we're coming from an unset region, so enable tx
validatedLora.tx_enabled = true;
}
// If we're unsetting the region for some reason, disable tx
if (!isRegionUnset && validatedLora.region == meshtastic_Config_LoRaConfig_RegionCode_UNSET) {
validatedLora.tx_enabled = false;
}
// Ensure initRegion() uses the newly validated region
config.lora.region = validatedLora.region;
initRegion();
if (myRegion->dutyCycle < 100) {
validatedLora.ignore_mqtt = true; // Ignore MQTT by default if region has a duty cycle limit
}
if (strncmp(moduleConfig.mqtt.root, default_mqtt_root, strlen(default_mqtt_root)) == 0) {
// Default root is in use, so subscribe to the appropriate MQTT topic for this region
sprintf(moduleConfig.mqtt.root, "%s/%s", default_mqtt_root, myRegion->name);
}
changes = SEGMENT_CONFIG | SEGMENT_MODULECONFIG;
} else {
// Region validation has failed, so just copy all of the old config over the new config
validatedLora = oldLoraConfig;
}
} // end of new region handling
if (!RadioInterface::validateConfigLora(validatedLora)) {
if (fromOthers) {
LOG_WARN("Invalid LoRa config received from another node, rejecting changes");
// modem_preset set to use the old setting if the check fails
validatedLora.modem_preset = oldLoraConfig.modem_preset;
} else {
LOG_WARN("Invalid LoRa config received from client, using corrected values");
RadioInterface::clampConfigLora(validatedLora);
}
// use_preset and bandwidth are coerced into valid values by the check.
}
// All LoRa radio changes apply live via configChanged observer → reconfigure().
// reconfigure() puts the radio in standby, reprograms all modem parameters, and restarts receive.
requiresReboot = false;
#if defined(ARCH_PORTDUINO)
// If running on portduino and using SimRadio, do not require reboot
if (SimRadio::instance) {
requiresReboot = false;
}
#endif
#ifdef RF95_FAN_EN
// Turn PA off if disabled by config
if (c.payload_variant.lora.pa_fan_disabled) {
digitalWrite(RF95_FAN_EN, LOW ^ 0);
} else {
digitalWrite(RF95_FAN_EN, HIGH ^ 0);
}
#endif
#if HAS_LORA_FEM
// Apply FEM LNA mode from config (only meaningful on hardware that supports it)
// Note that a rejected lora config will revert this as well.
if (loraFEMInterface.isLnaCanControl()) {
loraFEMInterface.setLNAEnable(validatedLora.fem_lna_mode != meshtastic_Config_LoRaConfig_FEM_LNA_Mode_DISABLED);
} else if (validatedLora.fem_lna_mode != meshtastic_Config_LoRaConfig_FEM_LNA_Mode_NOT_PRESENT) {
// Hardware FEM does not support LNA control; normalize stored config to match actual capability
LOG_WARN("FEM LNA mode configured but current FEM does not support LNA control; normalizing to NOT_PRESENT");
validatedLora.fem_lna_mode = meshtastic_Config_LoRaConfig_FEM_LNA_Mode_NOT_PRESENT;
}
#endif
config.lora = validatedLora; // Finally, return the validated config back to the main config
break;
}
case meshtastic_Config_bluetooth_tag:
LOG_INFO("Set config: Bluetooth");
config.has_bluetooth = true;
config.bluetooth = c.payload_variant.bluetooth;
break;
case meshtastic_Config_security_tag:
LOG_INFO("Set config: Security");
config.security = c.payload_variant.security;
#if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN) && !(MESHTASTIC_EXCLUDE_PKI)
// If the client set the key to blank, go ahead and regenerate so long as we're not in ham mode
if (!owner.is_licensed && config.lora.region != meshtastic_Config_LoRaConfig_RegionCode_UNSET) {
if (config.security.private_key.size != 32) {
crypto->generateKeyPair(config.security.public_key.bytes, config.security.private_key.bytes);
} else {
if (crypto->regeneratePublicKey(config.security.public_key.bytes, config.security.private_key.bytes)) {
config.security.public_key.size = 32;
}
}
}
#endif
owner.public_key.size = config.security.public_key.size;
memcpy(owner.public_key.bytes, config.security.public_key.bytes, config.security.public_key.size);
#if !MESHTASTIC_EXCLUDE_PKI
crypto->setDHPrivateKey(config.security.private_key.bytes);
#endif
if (config.security.is_managed && !(config.security.admin_key[0].size == 32 || config.security.admin_key[1].size == 32 ||
config.security.admin_key[2].size == 32)) {
config.security.is_managed = false;
const char *warning = "You must provide at least one admin public key to enable managed mode";
LOG_WARN(warning);
sendWarning(warning);
}
if (config.security.debug_log_api_enabled == c.payload_variant.security.debug_log_api_enabled &&
config.security.serial_enabled == c.payload_variant.security.serial_enabled)
requiresReboot = false;
break;
case meshtastic_Config_device_ui_tag:
// NOOP! This is handled by handleStoreDeviceUIConfig
break;
}
if (requiresReboot && !hasOpenEditTransaction) {
disableBluetooth();
} // end of switch case which_payload_variant
saveChanges(changes, requiresReboot);
} // end of handleSetConfig
bool AdminModule::handleSetModuleConfig(const meshtastic_ModuleConfig &c)
{
bool shouldReboot = true;
// If we are in an open transaction or configuring MQTT or Serial (which have validation), defer disabling Bluetooth
// Otherwise, disable Bluetooth to prevent the phone from interfering with the config
if (!hasOpenEditTransaction && !IS_ONE_OF(c.which_payload_variant, meshtastic_ModuleConfig_mqtt_tag,
meshtastic_ModuleConfig_serial_tag, meshtastic_ModuleConfig_statusmessage_tag)) {
disableBluetooth();
}
switch (c.which_payload_variant) {
case meshtastic_ModuleConfig_mqtt_tag:
#if MESHTASTIC_EXCLUDE_MQTT
LOG_WARN("Set module config: MESHTASTIC_EXCLUDE_MQTT is defined. Not setting MQTT config");
return false;
#else
LOG_INFO("Set module config: MQTT");
if (!MQTT::isValidConfig(c.payload_variant.mqtt)) {
return false;
}
// Disable Bluetooth to prevent interference during MQTT configuration
disableBluetooth();
moduleConfig.has_mqtt = true;
moduleConfig.mqtt = c.payload_variant.mqtt;
#endif
break;
case meshtastic_ModuleConfig_serial_tag:
LOG_INFO("Set module config: Serial");
#if (defined(ARCH_ESP32) || defined(ARCH_NRF52) || defined(ARCH_RP2040)) && !defined(CONFIG_IDF_TARGET_ESP32S2) && \
!defined(CONFIG_IDF_TARGET_ESP32C3)
if (!SerialModule::isValidConfig(c.payload_variant.serial)) {
LOG_ERROR("Invalid serial config");
return false;
}
disableBluetooth(); // Disable Bluetooth to prevent interference during Serial configuration
#endif
moduleConfig.has_serial = true;
moduleConfig.serial = c.payload_variant.serial;
break;
case meshtastic_ModuleConfig_external_notification_tag:
LOG_INFO("Set module config: External Notification");
moduleConfig.has_external_notification = true;
moduleConfig.external_notification = c.payload_variant.external_notification;
break;
case meshtastic_ModuleConfig_store_forward_tag:
LOG_INFO("Set module config: Store & Forward");
moduleConfig.has_store_forward = true;
moduleConfig.store_forward = c.payload_variant.store_forward;
break;
case meshtastic_ModuleConfig_range_test_tag:
LOG_INFO("Set module config: Range Test");
moduleConfig.has_range_test = true;
moduleConfig.range_test = c.payload_variant.range_test;
break;
case meshtastic_ModuleConfig_telemetry_tag:
LOG_INFO("Set module config: Telemetry");
moduleConfig.has_telemetry = true;
moduleConfig.telemetry = c.payload_variant.telemetry;
break;
case meshtastic_ModuleConfig_canned_message_tag:
LOG_INFO("Set module config: Canned Message");
moduleConfig.has_canned_message = true;
moduleConfig.canned_message = c.payload_variant.canned_message;
break;
case meshtastic_ModuleConfig_audio_tag:
LOG_INFO("Set module config: Audio");
moduleConfig.has_audio = true;
moduleConfig.audio = c.payload_variant.audio;
break;
case meshtastic_ModuleConfig_remote_hardware_tag:
LOG_INFO("Set module config: Remote Hardware");
moduleConfig.has_remote_hardware = true;
moduleConfig.remote_hardware = c.payload_variant.remote_hardware;
break;
case meshtastic_ModuleConfig_neighbor_info_tag:
LOG_INFO("Set module config: Neighbor Info");
moduleConfig.has_neighbor_info = true;
if (moduleConfig.neighbor_info.update_interval < min_neighbor_info_broadcast_secs) {
LOG_DEBUG("Tried to set update_interval too low, setting to %d", default_neighbor_info_broadcast_secs);
moduleConfig.neighbor_info.update_interval = default_neighbor_info_broadcast_secs;
}
moduleConfig.neighbor_info = c.payload_variant.neighbor_info;
break;
case meshtastic_ModuleConfig_detection_sensor_tag:
LOG_INFO("Set module config: Detection Sensor");
moduleConfig.has_detection_sensor = true;
moduleConfig.detection_sensor = c.payload_variant.detection_sensor;
break;
case meshtastic_ModuleConfig_ambient_lighting_tag:
LOG_INFO("Set module config: Ambient Lighting");
moduleConfig.has_ambient_lighting = true;
moduleConfig.ambient_lighting = c.payload_variant.ambient_lighting;
break;
case meshtastic_ModuleConfig_paxcounter_tag:
LOG_INFO("Set module config: Paxcounter");
moduleConfig.has_paxcounter = true;
moduleConfig.paxcounter = c.payload_variant.paxcounter;
break;
case meshtastic_ModuleConfig_statusmessage_tag:
LOG_INFO("Set module config: StatusMessage");
moduleConfig.has_statusmessage = true;
moduleConfig.statusmessage = c.payload_variant.statusmessage;
shouldReboot = false;
break;
case meshtastic_ModuleConfig_traffic_management_tag:
LOG_INFO("Set module config: Traffic Management");
moduleConfig.has_traffic_management = true;
moduleConfig.traffic_management = c.payload_variant.traffic_management;
break;
}
saveChanges(SEGMENT_MODULECONFIG, shouldReboot);
return true;
}
void AdminModule::handleSetChannel(const meshtastic_Channel &cc)
{
channels.setChannel(cc);
if (channels.ensureLicensedOperation()) {
sendWarning(licensedModeMessage);
}
channels.onConfigChanged(); // tell the radios about this change
saveChanges(SEGMENT_CHANNELS, false);
}
/**
* Getters
*/
void AdminModule::handleGetOwner(const meshtastic_MeshPacket &req)
{
if (req.decoded.want_response) {
// We create the reply here
meshtastic_AdminMessage res = meshtastic_AdminMessage_init_default;
res.get_owner_response = owner;
res.which_payload_variant = meshtastic_AdminMessage_get_owner_response_tag;
setPassKey(&res);
myReply = allocDataProtobuf(res);
if (req.pki_encrypted) {
myReply->pki_encrypted = true;
}
}
}
void AdminModule::handleGetConfig(const meshtastic_MeshPacket &req, const uint32_t configType)
{
meshtastic_AdminMessage res = meshtastic_AdminMessage_init_default;
if (req.decoded.want_response) {
switch (configType) {
case meshtastic_AdminMessage_ConfigType_DEVICE_CONFIG:
LOG_INFO("Get config: Device");
res.get_config_response.which_payload_variant = meshtastic_Config_device_tag;
res.get_config_response.payload_variant.device = config.device;
break;
case meshtastic_AdminMessage_ConfigType_POSITION_CONFIG:
LOG_INFO("Get config: Position");
res.get_config_response.which_payload_variant = meshtastic_Config_position_tag;
res.get_config_response.payload_variant.position = config.position;
break;
case meshtastic_AdminMessage_ConfigType_POWER_CONFIG:
LOG_INFO("Get config: Power");
res.get_config_response.which_payload_variant = meshtastic_Config_power_tag;
res.get_config_response.payload_variant.power = config.power;
break;
case meshtastic_AdminMessage_ConfigType_NETWORK_CONFIG:
LOG_INFO("Get config: Network");
res.get_config_response.which_payload_variant = meshtastic_Config_network_tag;
res.get_config_response.payload_variant.network = config.network;
writeSecret(res.get_config_response.payload_variant.network.wifi_psk,
sizeof(res.get_config_response.payload_variant.network.wifi_psk), config.network.wifi_psk);
break;
case meshtastic_AdminMessage_ConfigType_DISPLAY_CONFIG:
LOG_INFO("Get config: Display");
res.get_config_response.which_payload_variant = meshtastic_Config_display_tag;
res.get_config_response.payload_variant.display = config.display;
break;
case meshtastic_AdminMessage_ConfigType_LORA_CONFIG:
LOG_INFO("Get config: LoRa");
res.get_config_response.which_payload_variant = meshtastic_Config_lora_tag;
res.get_config_response.payload_variant.lora = config.lora;
break;
case meshtastic_AdminMessage_ConfigType_BLUETOOTH_CONFIG:
LOG_INFO("Get config: Bluetooth");
res.get_config_response.which_payload_variant = meshtastic_Config_bluetooth_tag;
res.get_config_response.payload_variant.bluetooth = config.bluetooth;
break;
case meshtastic_AdminMessage_ConfigType_SECURITY_CONFIG:
LOG_INFO("Get config: Security");
res.get_config_response.which_payload_variant = meshtastic_Config_security_tag;
res.get_config_response.payload_variant.security = config.security;
break;
case meshtastic_AdminMessage_ConfigType_SESSIONKEY_CONFIG:
LOG_INFO("Get config: Sessionkey");
res.get_config_response.which_payload_variant = meshtastic_Config_sessionkey_tag;
break;
case meshtastic_AdminMessage_ConfigType_DEVICEUI_CONFIG:
// NOOP! This is handled by handleGetDeviceUIConfig
res.get_config_response.which_payload_variant = meshtastic_Config_device_ui_tag;
break;
}
// NOTE: The phone app needs to know the ls_secs value so it can properly expect sleep behavior.
// So even if we internally use 0 to represent 'use default' we still need to send the value we are
// using to the app (so that even old phone apps work with new device loads).
// r.get_radio_response.preferences.ls_secs = getPref_ls_secs();
// hideSecret(r.get_radio_response.preferences.wifi_ssid); // hmm - leave public for now, because only minimally
// private and useful for users to know current provisioning)
// hideSecret(r.get_radio_response.preferences.wifi_password); r.get_config_response.which_payloadVariant =
// Config_ModuleConfig_telemetry_tag;
res.which_payload_variant = meshtastic_AdminMessage_get_config_response_tag;
setPassKey(&res);
myReply = allocDataProtobuf(res);
if (req.pki_encrypted) {
myReply->pki_encrypted = true;
}
}
}
void AdminModule::handleGetModuleConfig(const meshtastic_MeshPacket &req, const uint32_t configType)
{
meshtastic_AdminMessage res = meshtastic_AdminMessage_init_default;
if (req.decoded.want_response) {
const char *configName = "?";
switch (configType) {
case meshtastic_AdminMessage_ModuleConfigType_MQTT_CONFIG:
configName = "MQTT";
res.get_module_config_response.which_payload_variant = meshtastic_ModuleConfig_mqtt_tag;
res.get_module_config_response.payload_variant.mqtt = moduleConfig.mqtt;
break;
case meshtastic_AdminMessage_ModuleConfigType_SERIAL_CONFIG:
configName = "Serial";
res.get_module_config_response.which_payload_variant = meshtastic_ModuleConfig_serial_tag;
res.get_module_config_response.payload_variant.serial = moduleConfig.serial;
break;
case meshtastic_AdminMessage_ModuleConfigType_EXTNOTIF_CONFIG:
configName = "External Notification";
res.get_module_config_response.which_payload_variant = meshtastic_ModuleConfig_external_notification_tag;
res.get_module_config_response.payload_variant.external_notification = moduleConfig.external_notification;
break;
case meshtastic_AdminMessage_ModuleConfigType_STOREFORWARD_CONFIG:
configName = "Store & Forward";
res.get_module_config_response.which_payload_variant = meshtastic_ModuleConfig_store_forward_tag;
res.get_module_config_response.payload_variant.store_forward = moduleConfig.store_forward;
break;
case meshtastic_AdminMessage_ModuleConfigType_RANGETEST_CONFIG:
configName = "Range Test";
res.get_module_config_response.which_payload_variant = meshtastic_ModuleConfig_range_test_tag;
res.get_module_config_response.payload_variant.range_test = moduleConfig.range_test;
break;
case meshtastic_AdminMessage_ModuleConfigType_TELEMETRY_CONFIG:
configName = "Telemetry";
res.get_module_config_response.which_payload_variant = meshtastic_ModuleConfig_telemetry_tag;
res.get_module_config_response.payload_variant.telemetry = moduleConfig.telemetry;
break;
case meshtastic_AdminMessage_ModuleConfigType_CANNEDMSG_CONFIG:
configName = "Canned Message";
res.get_module_config_response.which_payload_variant = meshtastic_ModuleConfig_canned_message_tag;
res.get_module_config_response.payload_variant.canned_message = moduleConfig.canned_message;
break;
case meshtastic_AdminMessage_ModuleConfigType_AUDIO_CONFIG:
configName = "Audio";
res.get_module_config_response.which_payload_variant = meshtastic_ModuleConfig_audio_tag;
res.get_module_config_response.payload_variant.audio = moduleConfig.audio;
break;
case meshtastic_AdminMessage_ModuleConfigType_REMOTEHARDWARE_CONFIG:
configName = "Remote Hardware";
res.get_module_config_response.which_payload_variant = meshtastic_ModuleConfig_remote_hardware_tag;
res.get_module_config_response.payload_variant.remote_hardware = moduleConfig.remote_hardware;
break;
case meshtastic_AdminMessage_ModuleConfigType_NEIGHBORINFO_CONFIG:
configName = "Neighbor Info";
res.get_module_config_response.which_payload_variant = meshtastic_ModuleConfig_neighbor_info_tag;
res.get_module_config_response.payload_variant.neighbor_info = moduleConfig.neighbor_info;
break;
case meshtastic_AdminMessage_ModuleConfigType_DETECTIONSENSOR_CONFIG:
configName = "Detection Sensor";
res.get_module_config_response.which_payload_variant = meshtastic_ModuleConfig_detection_sensor_tag;
res.get_module_config_response.payload_variant.detection_sensor = moduleConfig.detection_sensor;
break;
case meshtastic_AdminMessage_ModuleConfigType_AMBIENTLIGHTING_CONFIG:
configName = "Ambient Lighting";
res.get_module_config_response.which_payload_variant = meshtastic_ModuleConfig_ambient_lighting_tag;
res.get_module_config_response.payload_variant.ambient_lighting = moduleConfig.ambient_lighting;
break;
case meshtastic_AdminMessage_ModuleConfigType_PAXCOUNTER_CONFIG:
configName = "Paxcounter";
res.get_module_config_response.which_payload_variant = meshtastic_ModuleConfig_paxcounter_tag;
res.get_module_config_response.payload_variant.paxcounter = moduleConfig.paxcounter;
break;
case meshtastic_AdminMessage_ModuleConfigType_STATUSMESSAGE_CONFIG:
configName = "StatusMessage";
res.get_module_config_response.which_payload_variant = meshtastic_ModuleConfig_statusmessage_tag;
res.get_module_config_response.payload_variant.statusmessage = moduleConfig.statusmessage;
break;
case meshtastic_AdminMessage_ModuleConfigType_TRAFFICMANAGEMENT_CONFIG:
configName = "Traffic Management";
res.get_module_config_response.which_payload_variant = meshtastic_ModuleConfig_traffic_management_tag;
res.get_module_config_response.payload_variant.traffic_management = moduleConfig.traffic_management;
break;
}
LOG_INFO("Get module config: %s", configName);
// NOTE: The phone app needs to know the ls_secsvalue so it can properly expect sleep behavior.
// So even if we internally use 0 to represent 'use default' we still need to send the value we are
// using to the app (so that even old phone apps work with new device loads).
// r.get_radio_response.preferences.ls_secs = getPref_ls_secs();
// hideSecret(r.get_radio_response.preferences.wifi_ssid); // hmm - leave public for now, because only minimally
// private and useful for users to know current provisioning)
// hideSecret(r.get_radio_response.preferences.wifi_password); r.get_config_response.which_payloadVariant =
// Config_ModuleConfig_telemetry_tag;
res.which_payload_variant = meshtastic_AdminMessage_get_module_config_response_tag;
setPassKey(&res);
myReply = allocDataProtobuf(res);
if (req.pki_encrypted) {
myReply->pki_encrypted = true;
}
}
}
void AdminModule::handleGetNodeRemoteHardwarePins(const meshtastic_MeshPacket &req)
{
meshtastic_AdminMessage r = meshtastic_AdminMessage_init_default;
r.which_payload_variant = meshtastic_AdminMessage_get_node_remote_hardware_pins_response_tag;
for (uint8_t i = 0; i < devicestate.node_remote_hardware_pins_count; i++) {
if (devicestate.node_remote_hardware_pins[i].node_num == 0 || !devicestate.node_remote_hardware_pins[i].has_pin) {
continue;
}
r.get_node_remote_hardware_pins_response.node_remote_hardware_pins[i] = devicestate.node_remote_hardware_pins[i];
}
for (uint8_t i = 0; i < moduleConfig.remote_hardware.available_pins_count; i++) {
if (!moduleConfig.remote_hardware.available_pins[i].gpio_pin) {
continue;
}
meshtastic_NodeRemoteHardwarePin nodePin = meshtastic_NodeRemoteHardwarePin_init_default;
nodePin.node_num = nodeDB->getNodeNum();
nodePin.pin = moduleConfig.remote_hardware.available_pins[i];
r.get_node_remote_hardware_pins_response.node_remote_hardware_pins[i + 12] = nodePin;
}
setPassKey(&r);
myReply = allocDataProtobuf(r);
if (req.pki_encrypted) {
myReply->pki_encrypted = true;
}
}
void AdminModule::handleGetDeviceMetadata(const meshtastic_MeshPacket &req)
{
meshtastic_AdminMessage r = meshtastic_AdminMessage_init_default;
r.get_device_metadata_response = getDeviceMetadata();
r.which_payload_variant = meshtastic_AdminMessage_get_device_metadata_response_tag;
setPassKey(&r);
myReply = allocDataProtobuf(r);
if (req.pki_encrypted) {
myReply->pki_encrypted = true;
}
}
void AdminModule::handleGetDeviceConnectionStatus(const meshtastic_MeshPacket &req)
{
meshtastic_AdminMessage r = meshtastic_AdminMessage_init_default;
meshtastic_DeviceConnectionStatus conn = meshtastic_DeviceConnectionStatus_init_zero;
#if HAS_WIFI
conn.has_wifi = true;
conn.wifi.has_status = true;
#ifdef ARCH_PORTDUINO
conn.wifi.status.is_connected = true;
#else
conn.wifi.status.is_connected = WiFi.status() == WL_CONNECTED;
#endif
strncpy(conn.wifi.ssid, config.network.wifi_ssid, 33);
if (conn.wifi.status.is_connected) {
conn.wifi.rssi = WiFi.RSSI();
conn.wifi.status.ip_address = WiFi.localIP();
#ifndef MESHTASTIC_EXCLUDE_MQTT
conn.wifi.status.is_mqtt_connected = mqtt && mqtt->isConnectedDirectly();
#endif
conn.wifi.status.is_syslog_connected = false; // FIXME wire this up
}
#endif
#if HAS_ETHERNET && !defined(USE_WS5500)
conn.has_ethernet = true;
conn.ethernet.has_status = true;
if (Ethernet.linkStatus() == LinkON) {
conn.ethernet.status.is_connected = true;
conn.ethernet.status.ip_address = Ethernet.localIP();
#if !MESHTASTIC_EXCLUDE_MQTT
conn.ethernet.status.is_mqtt_connected = mqtt && mqtt->isConnectedDirectly();
#endif
conn.ethernet.status.is_syslog_connected = false; // FIXME wire this up
} else {
conn.ethernet.status.is_connected = false;
}
#endif
#if HAS_BLUETOOTH
conn.has_bluetooth = true;
conn.bluetooth.pin = config.bluetooth.fixed_pin;
#ifdef ARCH_ESP32
if (config.bluetooth.enabled && nimbleBluetooth) {
conn.bluetooth.is_connected = nimbleBluetooth->isConnected();
conn.bluetooth.rssi = nimbleBluetooth->getRssi();
}
#elif defined(ARCH_NRF52)
if (config.bluetooth.enabled && nrf52Bluetooth) {
conn.bluetooth.is_connected = nrf52Bluetooth->isConnected();
}
#endif
#endif
conn.has_serial = true; // No serial-less devices
#if !MESHTASTIC_EXCLUDE_POWER_FSM
conn.serial.is_connected = powerFSM.getState() == &stateSERIAL;
#else
conn.serial.is_connected = powerFSM.getState();
#endif
conn.serial.baud = SERIAL_BAUD;
r.get_device_connection_status_response = conn;
r.which_payload_variant = meshtastic_AdminMessage_get_device_connection_status_response_tag;
setPassKey(&r);
myReply = allocDataProtobuf(r);
if (req.pki_encrypted) {
myReply->pki_encrypted = true;
}
}
void AdminModule::handleGetChannel(const meshtastic_MeshPacket &req, uint32_t channelIndex)
{
if (req.decoded.want_response) {
// We create the reply here
meshtastic_AdminMessage r = meshtastic_AdminMessage_init_default;
r.get_channel_response = channels.getByIndex(channelIndex);
r.which_payload_variant = meshtastic_AdminMessage_get_channel_response_tag;
setPassKey(&r);
myReply = allocDataProtobuf(r);
if (req.pki_encrypted) {
myReply->pki_encrypted = true;
}
}
}
void AdminModule::handleGetDeviceUIConfig(const meshtastic_MeshPacket &req)
{
meshtastic_AdminMessage r = meshtastic_AdminMessage_init_default;
r.which_payload_variant = meshtastic_AdminMessage_get_ui_config_response_tag;
r.get_ui_config_response = uiconfig;
myReply = allocDataProtobuf(r);
if (req.pki_encrypted) {
myReply->pki_encrypted = true;
}
}
void AdminModule::reboot(int32_t seconds)
{
LOG_INFO("Reboot in %d seconds", seconds);
if (screen)
screen->showSimpleBanner("Rebooting...", 0); // stays on screen
rebootAtMsec = (seconds < 0) ? 0 : (millis() + seconds * 1000);
}
void AdminModule::saveChanges(int saveWhat, bool shouldReboot)
{
if (!hasOpenEditTransaction) {
LOG_INFO("Save changes to disk");
service->reloadConfig(saveWhat); // Calls saveToDisk among other things
} else {
LOG_INFO("Delay save of changes to disk until the open transaction is committed");
}
if (shouldReboot && !hasOpenEditTransaction) {
reboot(DEFAULT_REBOOT_SECONDS);
}
}
void AdminModule::handleStoreDeviceUIConfig(const meshtastic_DeviceUIConfig &uicfg)
{
nodeDB->saveProto("/prefs/uiconfig.proto", meshtastic_DeviceUIConfig_size, &meshtastic_DeviceUIConfig_msg, &uicfg);
}
void AdminModule::handleSetHamMode(const meshtastic_HamParameters &p)
{
// Validate ham parameters before setting since this would bypass validation in the owner struct
const char *fieldsToCheck[] = {p.call_sign, p.short_name};
const char *fieldNames[] = {"call_sign", "short_name"};
for (int i = 0; i < 2; i++) {
if (*fieldsToCheck[i]) {
const char *start = fieldsToCheck[i];
while (*start && isspace((unsigned char)*start))
start++;
if (*start == '\0') {
LOG_WARN("Rejected ham %s: must contain at least 1 non-whitespace character", fieldNames[i]);
return;
}
}
}
// Set call sign and override lora limitations for licensed use
strncpy(owner.long_name, p.call_sign, sizeof(owner.long_name));
strncpy(owner.short_name, p.short_name, sizeof(owner.short_name));
owner.is_licensed = true;
config.lora.override_duty_cycle = true;
config.lora.tx_power = p.tx_power;
config.lora.override_frequency = p.frequency;
// Set node info broadcast interval to 10 minutes
// For FCC minimum call-sign announcement
config.device.node_info_broadcast_secs = 600;
config.device.rebroadcast_mode = meshtastic_Config_DeviceConfig_RebroadcastMode_LOCAL_ONLY;
// Remove PSK of primary channel for plaintext amateur usage
if (channels.ensureLicensedOperation()) {
sendWarning(licensedModeMessage);
}
channels.onConfigChanged();
service->reloadOwner(false);
saveChanges(SEGMENT_CONFIG | SEGMENT_NODEDATABASE | SEGMENT_DEVICESTATE | SEGMENT_CHANNELS);
}
AdminModule::AdminModule() : ProtobufModule("Admin", meshtastic_PortNum_ADMIN_APP, &meshtastic_AdminMessage_msg)
{
// restrict to the admin channel for rx
// boundChannel = Channels::adminChannel;
}
void AdminModule::setPassKey(meshtastic_AdminMessage *res)
{
if (session_time == 0 || millis() / 1000 > session_time + 150) {
for (int i = 0; i < 8; i++) {
session_passkey[i] = random();
}
session_time = millis() / 1000;
}
memcpy(res->session_passkey.bytes, session_passkey, 8);
res->session_passkey.size = 8;
printBytes("Set admin key to ", res->session_passkey.bytes, 8);
// if halfway to session_expire, regenerate session_passkey, reset the timeout
// set the key in the packet
}
bool AdminModule::checkPassKey(meshtastic_AdminMessage *res)
{ // check that the key in the packet is still valid
printBytes("Incoming session key: ", res->session_passkey.bytes, 8);
printBytes("Expected session key: ", session_passkey, 8);
return (session_time + 300 > millis() / 1000 && res->session_passkey.size == 8 &&
memcmp(res->session_passkey.bytes, session_passkey, 8) == 0);
}
bool AdminModule::messageIsResponse(const meshtastic_AdminMessage *r)
{
if (r->which_payload_variant == meshtastic_AdminMessage_get_channel_response_tag ||
r->which_payload_variant == meshtastic_AdminMessage_get_owner_response_tag ||
r->which_payload_variant == meshtastic_AdminMessage_get_config_response_tag ||
r->which_payload_variant == meshtastic_AdminMessage_get_module_config_response_tag ||
r->which_payload_variant == meshtastic_AdminMessage_get_canned_message_module_messages_response_tag ||
r->which_payload_variant == meshtastic_AdminMessage_get_device_metadata_response_tag ||
r->which_payload_variant == meshtastic_AdminMessage_get_ringtone_response_tag ||
r->which_payload_variant == meshtastic_AdminMessage_get_device_connection_status_response_tag ||
r->which_payload_variant == meshtastic_AdminMessage_get_node_remote_hardware_pins_response_tag ||
r->which_payload_variant == meshtastic_AdminMessage_get_ui_config_response_tag)
return true;
else
return false;
}
bool AdminModule::messageIsRequest(const meshtastic_AdminMessage *r)
{
if (r->which_payload_variant == meshtastic_AdminMessage_get_channel_request_tag ||
r->which_payload_variant == meshtastic_AdminMessage_get_owner_request_tag ||
r->which_payload_variant == meshtastic_AdminMessage_get_config_request_tag ||
r->which_payload_variant == meshtastic_AdminMessage_get_module_config_request_tag ||
r->which_payload_variant == meshtastic_AdminMessage_get_canned_message_module_messages_request_tag ||
r->which_payload_variant == meshtastic_AdminMessage_get_device_metadata_request_tag ||
r->which_payload_variant == meshtastic_AdminMessage_get_ringtone_request_tag ||
r->which_payload_variant == meshtastic_AdminMessage_get_device_connection_status_request_tag ||
r->which_payload_variant == meshtastic_AdminMessage_get_node_remote_hardware_pins_request_tag ||
r->which_payload_variant == meshtastic_AdminMessage_get_ui_config_request_tag)
return true;
else
return false;
}
void AdminModule::handleSendInputEvent(const meshtastic_AdminMessage_InputEvent &inputEvent)
{
LOG_DEBUG("Processing input event: event_code=%u, kb_char=%u, touch_x=%u, touch_y=%u", inputEvent.event_code,
inputEvent.kb_char, inputEvent.touch_x, inputEvent.touch_y);
// Create InputEvent for injection
InputEvent event = {.inputEvent = (input_broker_event)inputEvent.event_code,
.kbchar = (unsigned char)inputEvent.kb_char,
.touchX = inputEvent.touch_x,
.touchY = inputEvent.touch_y};
// Log the event being injected
LOG_INFO("Injecting input event from admin: source=%s, event=%u, char=%c(%u), touch=(%u,%u)", event.source, event.inputEvent,
(event.kbchar >= 32 && event.kbchar <= 126) ? event.kbchar : '?', event.kbchar, event.touchX, event.touchY);
// Wake the device if asleep
powerFSM.trigger(EVENT_INPUT);
#if !defined(MESHTASTIC_EXCLUDE_INPUTBROKER)
// Inject the event through InputBroker
if (inputBroker) {
inputBroker->injectInputEvent(&event);
} else {
LOG_ERROR("InputBroker not available for event injection");
}
#endif
}
void AdminModule::sendWarning(const char *format, ...)
{
meshtastic_ClientNotification *cn = clientNotificationPool.allocZeroed();
if (!cn)
return;
cn->level = meshtastic_LogRecord_Level_WARNING;
cn->time = getValidTime(RTCQualityFromNet);
va_list args;
va_start(args, format);
// Format the arguments directly into the notification object
vsnprintf(cn->message, sizeof(cn->message), format, args);
va_end(args);
service->sendClientNotification(cn);
}
void AdminModule::sendWarningAndLog(const char *format, ...)
{
// We need a temporary buffer to hold the formatted text so we can log it
// Using 250 bytes as a safe upper limit for typical text notifications
char buf[250];
va_list args;
va_start(args, 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.
sendWarning("%s", buf);
}
void disableBluetooth()
{
#if HAS_BLUETOOTH
#ifdef ARCH_ESP32
if (nimbleBluetooth)
nimbleBluetooth->deinit();
#elif defined(ARCH_NRF52)
if (nrf52Bluetooth)
nrf52Bluetooth->shutdown();
#endif
#endif
}