Add guarded Bluetooth CLI on/off with force override

Add get bluetooth and set bluetooth on|off, with ble aliases. Require another
active management connection before disabling Bluetooth unless off has a
trailing force argument. Keep the toggle local to the current boot.

Drain Bluetooth replies before disconnecting and recheck the remaining
connection before applying a normal shutdown. Enabling Bluetooth cancels a
pending shutdown. Document the commands and add compiled regression coverage
to CI for native USB, HWCDC, UART, network clients, force, and timer rollover.

Validation: CLI, Bluetooth, and USB regression tests passed. XIAO ESP32-S3
and RAK4631 Full Companion builds passed RAM, flash, and capability checks.
This commit is contained in:
mikecarper committed 2026-09-14 23:02:54 -07:00
1 parent 091c8d8324
commit 071ca663c2
6 files changed
+428 -2

No files matched your search

@@ -0,0 +1,47 @@
#pragma once
#include <stddef.h>
#include <string.h>
enum class CompanionBluetoothCommandSource { Other, Terminal, Framed };
enum class CompanionBluetoothCommand { None, Get, On, Off, ForceOff, Invalid };
inline const char* skipBluetoothCommandSpace(const char* text) {
while (*text == ' ' || *text == '\t') ++text;
return text;
}
inline bool bluetoothCommandWord(const char* text, const char* word) {
const size_t length = strlen(word);
return strncmp(text, word, length) == 0
&& *skipBluetoothCommandSpace(text + length) == 0;
}
inline CompanionBluetoothCommand parseCompanionBluetoothCommand(const char* text) {
if (!text) return CompanionBluetoothCommand::None;
text = skipBluetoothCommandSpace(text);
if (bluetoothCommandWord(text, "get bluetooth")
|| bluetoothCommandWord(text, "get ble")) return CompanionBluetoothCommand::Get;
const char* value = nullptr;
const char* const prefixes[] = {"set bluetooth", "set ble"};
for (const char* prefix : prefixes) {
const size_t length = strlen(prefix);
if (strncmp(text, prefix, length) == 0
&& (text[length] == 0 || text[length] == ' ' || text[length] == '\t')) {
value = skipBluetoothCommandSpace(text + length);
break;
}
}
if (!value) return CompanionBluetoothCommand::None;
if (bluetoothCommandWord(value, "on")) return CompanionBluetoothCommand::On;
if (bluetoothCommandWord(value, "off")) return CompanionBluetoothCommand::Off;
if (strncmp(value, "off", 3) == 0 && (value[3] == ' ' || value[3] == '\t')
&& bluetoothCommandWord(skipBluetoothCommandSpace(value + 3), "force")) {
return CompanionBluetoothCommand::ForceOff;
}
return CompanionBluetoothCommand::Invalid;
}
bool handleCompanionBluetoothCommand(
const char* command, char* reply, size_t reply_size,
CompanionBluetoothCommandSource source = CompanionBluetoothCommandSource::Other);
+13 -1
View File
@@ -1,5 +1,6 @@
#include <helpers/ui/DisplayPowerSettings.h>
#include "MyMesh.h"
#include "CompanionBluetooth.h"
#if defined(MESH_SOAK_DIAGNOSTICS)
#include "../../tools/hil/S3SoakDiagnostics.h"
#endif
@@ -2301,6 +2302,8 @@ bool MyMesh::handleLocalControlCommand(const char* command, char* reply,
if (!command || !reply || reply_size == 0) return false;
while (*command == ' ') command++;
if (handleCompanionBluetoothCommand(command, reply, reply_size)) return true;
if (strcmp(command, "get powersaving") == 0 || strcmp(command, "powersaving") == 0) {
snprintf(reply, reply_size, "> %s", _prefs.powersaving_enabled ? "on" : "off");
return true;
@@ -5331,7 +5334,9 @@ void MyMesh::handleCmdFrame(size_t len) {
char command[MAX_FRAME_SIZE] = {0};
memcpy(command, &cmd_frame[1], command_len);
char reply[MAX_FRAME_SIZE] = {0};
if (!handleLocalControlCommand(command, reply, sizeof(reply))) {
if (!handleCompanionBluetoothCommand(command, reply, sizeof(reply),
CompanionBluetoothCommandSource::Framed)
&& !handleLocalControlCommand(command, reply, sizeof(reply))) {
writeErrFrame(ERR_CODE_UNSUPPORTED_CMD);
} else {
out_frame[0] = RESP_CODE_OK;
@@ -7492,6 +7497,11 @@ void MyMesh::handleTerminalCommand(char* command) {
#endif
char local_reply[160];
if (handleCompanionBluetoothCommand(command, local_reply, sizeof(local_reply),
CompanionBluetoothCommandSource::Terminal)) {
terminalOutput().printf(" %s\r\n", local_reply);
return;
}
if (handleDirectCommand(command, local_reply, sizeof(local_reply))) {
terminalOutput().printf(" %s\r\n", local_reply);
return;
@@ -7983,6 +7993,8 @@ void MyMesh::handleTerminalCommand(char* command) {
terminalOutput().print(" get bluetooth.name\r\n");
terminalOutput().print(" set bluetooth.name <name|default>\r\n");
#if defined(BLE_PIN_CODE)
terminalOutput().print(" get bluetooth (alias: get ble)\r\n");
terminalOutput().print(" set bluetooth <on|off> [force] (this boot; force only with off)\r\n");
terminalOutput().print(" get bluetooth.mac\r\n");
terminalOutput().print(
" set bluetooth.mac <address|random|random-every-boot|random-after-connect|default>\r\n");
+117 -1
View File
@@ -2,6 +2,7 @@
#include <Mesh.h>
#include <helpers/BluetoothMac.h>
#include "MyMesh.h"
#include "CompanionBluetooth.h"
#include "CompanionWiFi.h"
#if MESH_PACKET_LOGGING
#include <helpers/SerialPacketLog.h>
@@ -1797,7 +1798,9 @@ void halt() {
ota_console_client.print("> ");
#else
char reply[160]; reply[0] = 0;
if (!the_mesh.handleLocalCommand(ota_console_line, reply)
if (!handleCompanionBluetoothCommand(ota_console_line, reply, sizeof(reply),
CompanionBluetoothCommandSource::Terminal)
&& !the_mesh.handleLocalCommand(ota_console_line, reply)
&& !mesh::ota::handle_ota_command(ota_console_line, reply, board))
strcpy(reply, "Unknown local or OTA command");
ota_console_client.print(" -> "); ota_console_client.print(reply); ota_console_client.print("\r\n> ");
@@ -2249,6 +2252,118 @@ void halt() {
}
#endif
#if defined(BLE_PIN_CODE)
static uint32_t companion_bluetooth_off_at = 0;
static bool companion_bluetooth_force_off = false;
static bool hasCompanionNonBluetoothClient() {
if (!interface_manager.isEnabled()) return false;
if (interface_manager.isInterfaceConnected(InterfaceType::WiFi)
|| interface_manager.isInterfaceConnected(InterfaceType::Ethernet)) return true;
#if COMPANION_FEATURE_TEXT_TERMINAL \
&& (COMPANION_FEATURE_NETWORK_TERMINAL || defined(WITH_WEBCONFIG))
if (the_mesh.isAnyNetworkTerminalMode()) return true;
#endif
#if defined(ENABLE_USB_INTERFACE) \
&& (defined(NRF52_PLATFORM) || defined(RP2040_PLATFORM) \
|| (defined(ESP32) && defined(ARDUINO_USB_CDC_ON_BOOT) && ARDUINO_USB_CDC_ON_BOOT \
&& (!defined(ARDUINO_USB_MODE) || ARDUINO_USB_MODE == 0)))
// Native CDC reports an open host session (including nRF52's DTR-low
// MeshCLI session). HWCDC SOFs and UART bridges cannot prove an open app;
// those clients count only when they issue this command themselves.
#if COMPANION_FEATURE_USB_MOTA_SOURCE
if (usb_mota_mode) return false;
#endif
if (usb_serial_interface.isEnabled() && isUsbTerminalDataConnected()) return true;
#endif
return false;
}
static void disableCompanionBluetoothForCli() {
companion_bluetooth_off_at = 0;
the_mesh.cancelSerialOperationsForRoute(&bluetooth_interface);
interface_manager.disableBluetooth();
interface_manager.forgetReplyRouteForDisconnected(&bluetooth_interface);
}
static void serviceCompanionBluetoothControl() {
if (companion_bluetooth_off_at == 0) return;
const int32_t elapsed = (int32_t)(millis() - companion_bluetooth_off_at);
if (elapsed < 0) return;
// The other host may have disconnected while the BLE reply was draining.
if (!companion_bluetooth_force_off && !hasCompanionNonBluetoothClient()) {
companion_bluetooth_off_at = 0;
mesh::usbLoggingPort().println(
"Bluetooth off cancelled: no other active management connection");
return;
}
// Give the requesting BLE client its reply, with a bounded wait even when
// its notification queue is stuck. The command itself reports a request.
if (bluetooth_interface.hasPendingIO() && elapsed < 1750) return;
disableCompanionBluetoothForCli();
}
#endif
bool handleCompanionBluetoothCommand(const char* command, char* reply,
size_t reply_size,
CompanionBluetoothCommandSource source) {
if (!reply || reply_size == 0) return false;
const auto action = parseCompanionBluetoothCommand(command);
if (action == CompanionBluetoothCommand::None) return false;
if (action == CompanionBluetoothCommand::Invalid) {
snprintf(reply, reply_size, "Error: use set bluetooth on|off [force]; force only with off");
return true;
}
#if defined(BLE_PIN_CODE)
if (!companion_bluetooth_initialized) {
snprintf(reply, reply_size, "Error: Bluetooth unavailable in this boot; check transport mode/startup");
return true;
}
if (action == CompanionBluetoothCommand::Get) {
snprintf(reply, reply_size, "bluetooth %s%s",
interface_manager.isBluetoothEnabled() ? "on" : "off",
companion_bluetooth_off_at ? " (off pending)" : "");
return true;
}
if (action == CompanionBluetoothCommand::On) {
companion_bluetooth_off_at = 0;
interface_manager.enableBluetooth();
snprintf(reply, reply_size, "%s", interface_manager.isBluetoothEnabled()
? "OK - Bluetooth on (this boot)" : "Error: Bluetooth enable failed");
return true;
}
if (!interface_manager.isBluetoothEnabled()) {
companion_bluetooth_off_at = 0;
snprintf(reply, reply_size, "OK - Bluetooth already off (this boot)");
return true;
}
const bool force = action == CompanionBluetoothCommand::ForceOff;
BaseSerialInterface* route = interface_manager.captureReplyRoute();
const bool non_bluetooth_requester = source == CompanionBluetoothCommandSource::Terminal
|| (source == CompanionBluetoothCommandSource::Framed
&& route != &bluetooth_interface
&& interface_manager.isReplyRouteAvailable(route));
if (!force && !non_bluetooth_requester && !hasCompanionNonBluetoothClient()) {
snprintf(reply, reply_size,
"Error: no other active management connection; use USB/TCP or set bluetooth off force");
return true;
}
if (non_bluetooth_requester) {
disableCompanionBluetoothForCli();
snprintf(reply, reply_size, "OK - Bluetooth off (this boot)");
} else {
companion_bluetooth_force_off = force;
companion_bluetooth_off_at = millis() + 250;
if (companion_bluetooth_off_at == 0) companion_bluetooth_off_at = 1;
snprintf(reply, reply_size, "OK - Bluetooth off requested (this boot)");
}
#else
(void)source;
snprintf(reply, reply_size, "Error: Bluetooth is not supported in this build");
#endif
return true;
}
#if defined(ESP32_PLATFORM) && COMPANION_FEATURE_MEMORY_DIAGNOSTICS
static void logFullCompanionMemory(const char* stage) {
mesh::usbLoggingPort().printf(
@@ -2920,5 +3035,6 @@ void loop() {
serviceDeferredCompanionBluetooth();
#endif
serviceCompanionBluetoothIdentity();
serviceCompanionBluetoothControl();
#endif
}