Files
MeshCore/examples/companion_radio/main.cpp
T
mikecarper 8c698dc2d1 Reduce nRF52 Companion flash and stream Reader chapters
Use a 960-byte constant-time Ed25519 base table, compact hash/scalar code and unused-library trims while retaining Reader, BLE P-256 and the 8 KiB stack. RAK4631 Full saves 57,496 flash bytes and 3,152 static RAM bytes versus preview3.

Support complete chapters and verse ranges with bounded app continuation replies and cooperative terminal output. Use nonblocking TCP writes under reader backpressure. Add scalar/signature comparisons and a linked ARM crypto/stack CI gate.

Validated clean RAK4631, T1000-E, T096 and V4 Full builds, host regressions, and physical RAK chapter/reconnect and signing comparisons. Physical signing averages 35.9 ms versus 14.4 ms with the original table.
2026-10-13 01:02:30 -07:00

4131 lines
157 KiB
C++

#include <Arduino.h> // needed for PlatformIO
#include <helpers/ui/StartupScreen.h>
#include <helpers/ui/DisplayPowerSettings.h>
#include <helpers/UsbLoggingWatchdog.h>
#include <helpers/UsbLoggingClientActivity.h>
#include <Mesh.h>
#include <helpers/BluetoothMac.h>
#include "MyMesh.h"
#include <helpers/CompanionReader.h>
#include "CompanionBluetooth.h"
#include "CompanionWireless.h"
#include "CompanionWiFi.h"
#if COMPANION_FEATURE_USB_MOTA_SOURCE || COMPANION_FEATURE_BLE_MOTA_SOURCE
#include <helpers/ota/MotaSourceSerial.h>
#include <helpers/ota/OtaContext.h>
#endif
#if defined(COMPANION_PAIRING_UI_HIL)
void companionPairingUiHilProbe();
#endif
#if MESH_PACKET_LOGGING
#include <helpers/SerialPacketLog.h>
#endif
#ifdef ESP32_PLATFORM
#include "esp_bt.h"
#include "esp_pm.h"
#include "esp_sleep.h"
#include "esp_system.h"
#if defined(CONFIG_BLUEDROID_ENABLED) && !defined(MESH_USE_NIMBLE_ARDUINO)
#include <esp_bt_main.h>
#endif
#if defined(CONFIG_PM_ENABLE) && CONFIG_PM_ENABLE
#define COMPANION_IDF_PM_AVAILABLE 1
#else
#define COMPANION_IDF_PM_AVAILABLE 0
#endif
#if defined(BLE_PIN_CODE) && !CONFIG_IDF_TARGET_ESP32C6 \
&& (defined(CONFIG_BT_CTRL_MODEM_SLEEP) \
|| defined(CONFIG_BTDM_CTRL_MODEM_SLEEP))
#define COMPANION_BT_MODEM_SLEEP_AVAILABLE 1
#else
#define COMPANION_BT_MODEM_SLEEP_AVAILABLE 0
#endif
#if COMPANION_FEATURE_MEMORY_DIAGNOSTICS
#include <esp_heap_caps.h>
#endif
#endif
#ifdef USE_CC310_HW_CRYPTO
#include <helpers/NRF52Crypto.h>
#endif
// Believe it or not, this std C function is busted on some platforms!
static uint32_t _atoi(const char* sp) {
uint32_t n = 0;
while (*sp && *sp >= '0' && *sp <= '9') {
n *= 10;
n += (*sp++ - '0');
}
return n;
}
// interface manager
#include <helpers/MultiSerialInterface.h>
MultiSerialInterface interface_manager;
// include bluetooth interface
#if defined(BLE_PIN_CODE)
#ifdef ESP32
// include esp32 bluetooth interface
#include <helpers/esp32/SerialBLEInterface.h>
SerialBLEInterface bluetooth_interface;
#elif defined(NRF52_PLATFORM)
// include nrf52 bluetooth interface
#include <helpers/nrf52/SerialBLEInterface.h>
SerialBLEInterface bluetooth_interface;
#elif defined(RP2040_PLATFORM)
// include rp2040 (Pico W / CYW43) bluetooth interface
#include <helpers/rp2040/SerialBLEInterface.h>
SerialBLEInterface bluetooth_interface;
#else
#error "SerialBLEInterface is not defined for this platform"
#endif
#endif
// include wifi interface
#if defined(ENABLE_WIFI_INTERFACE) || defined(WIFI_SSID)
#ifndef WIFI_SSID
#define WIFI_SSID ""
#endif
#ifndef WIFI_PWD
#define WIFI_PWD ""
#endif
#ifndef TCP_PORT
#define TCP_PORT 5000
#endif
#ifdef ESP32
// include esp32 wifi interface
#include <helpers/wifi/SerialWifiInterface.h>
#include <helpers/WiFiSetupPortal.h>
#include <helpers/CLICommandUtils.h>
#include <helpers/WiFiReconnectPolicy.h>
#include <helpers/WiFiPowerSave.h>
#include <helpers/CompanionWiFiNtpPolicy.h>
#include <helpers/WiFiNetworkPowerPolicy.h>
#include <helpers/ui/DisplayTextLayout.h>
#include <helpers/ui/IndicatorRenderProfile.h>
#include <helpers/ui/WiFiSetupQrDisplay.h>
#include <helpers/esp32/SntpOperationCoordinator.h>
#include <helpers/esp32/TlsClockValidity.h>
#include <helpers/esp32/WiFiRadioPolicy.h>
#include <helpers/esp32/WiFiStationPolicy.h>
#include <Preferences.h>
#include <atomic>
#include <esp_sntp.h>
#include <esp_wifi.h>
SerialWifiInterface wifi_interface;
#ifndef WIFI_PWD
#define WIFI_PWD ""
#endif
#elif defined(RP2040_PLATFORM)
#include <helpers/wifi/SerialWifiInterface.h>
SerialWifiInterface wifi_interface;
static uint32_t last_wifi_reconnect_attempt = 0;
static bool pico_wifi_active = false;
static char pico_wifi_ssid[33] = {};
static char pico_wifi_pwd[64] = {};
#ifndef WIFI_RETRY_INTERVAL
#define WIFI_RETRY_INTERVAL 30000UL
#endif
#ifndef WIFI_RETRY_TIMEOUT
#define WIFI_RETRY_TIMEOUT 5000UL
#endif
#else
#error "SerialWifiInterface is not defined for this platform"
#endif
#endif
// include usb interface
#if defined(ENABLE_USB_INTERFACE)
#include <helpers/ArduinoSerialInterface.h>
#include <helpers/CLICommandUtils.h>
#include <helpers/UsbAsciiBinarySwitch.h>
#include <helpers/UsbLogging.h>
static const char USB_TERMINAL_START_TOKEN[] = "+++MESHCORE-TERM-START";
static const char USB_TERMINAL_STOP_TOKEN[] = "+++MESHCORE-TERM-STOP";
#if COMPANION_FEATURE_USB_MOTA_SOURCE
// motatool sends this command automatically when `serve --serial` opens the
// port. It hands USB ownership to the host-backed mOTA source from either
// the completed ASCII line or Binary mode's idle control-sequence parser.
static const char USB_MOTA_START_TOKEN[] = "ota folder on";
#endif
ArduinoSerialInterface usb_serial_interface;
#ifndef USB_CLIENT_IDLE_TIMEOUT
// how long a USB client is still considered present after its last frame,
// for targets which cannot report DTR (see setConnectedCheck below)
#define USB_CLIENT_IDLE_TIMEOUT (10*60*1000UL)
#endif
#ifndef USB_FRAME_REPLY_GRACE_MS
// A complete command frame proves that a Binary Companion client is
// listening even if an ESP32 HWCDC host-presence flag briefly flaps while
// the response is being queued. Keep this much time to finish the reply.
#define USB_FRAME_REPLY_GRACE_MS 2000UL
#endif
#ifndef USB_HOST_LOSS_GRACE_MS
// The bundled ESP32 HWCDC SOF detector declares loss after only a few
// missed ticks. Debounce that advisory signal before ending a host session.
#define USB_HOST_LOSS_GRACE_MS 2000UL
#endif
#ifndef USB_HOST_LOSS_EDGE_MS
// HWCDC's framework filter declares loss after roughly 5 ms without SOFs.
// Require a longer continuous gap before using that signal as a fallback
// session boundary when the finite BUS_RESET event queue drops its event.
#define USB_HOST_LOSS_EDGE_MS 100UL
#endif
#ifndef USB_TRANSPORT_RESET_RETRY_MS
// A failed purge can spend up to roughly 150 ms waiting on the live HWCDC
// writer/ring. Back off enough to keep WiFi and mesh service responsive.
#define USB_TRANSPORT_RESET_RETRY_MS 1000UL
#endif
#if defined(ESP32) && defined(ARDUINO_USB_MODE) && ARDUINO_USB_MODE == 1 \
&& defined(ARDUINO_USB_CDC_ON_BOOT) && ARDUINO_USB_CDC_ON_BOOT
mesh::UsbHostPresenceDebouncer usb_hwcdc_host_presence;
#endif
#endif
#if defined(ENABLE_USB_INTERFACE) && (COMPANION_FEATURE_NETWORK_TERMINAL || defined(WITH_WEBCONFIG))
static bool isNetworkTerminalActive();
#endif
// include ethernet interface
#if defined(ETHERNET_ENABLED)
#include <helpers/ethernet/EthernetInterface.h>
ETHERNET_CLASS ethernet_interface;
#endif
// include hardware serial interface
#if defined(SERIAL_RX)
#include <helpers/ArduinoSerialInterface.h>
ArduinoSerialInterface hardware_serial_interface;
#if defined(ENABLE_USB_INTERFACE) && defined(CONFIG_IDF_TARGET_ESP32S3) \
&& (!defined(ARDUINO_USB_CDC_ON_BOOT) || !ARDUINO_USB_CDC_ON_BOOT)
// USB-UART boards can name the same pins as an additional serial transport.
// Give that physical port only one parser and TX owner; native USB is separate.
static constexpr bool companion_serial_shares_usb_port =
SERIAL_RX == SOC_RX0 && SERIAL_TX == SOC_TX0;
#else
static constexpr bool companion_serial_shares_usb_port = false;
#endif
#if defined(CONFIG_IDF_TARGET_ESP32S3) && ENV_INCLUDE_GPS == 1
// GPS owns UART1. The S3's spare UART2 can use the same Companion pins.
HardwareSerial companion_serial(2);
#else
HardwareSerial companion_serial(1);
#endif
#endif
// platform file system
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
#include <InternalFileSystem.h>
#if defined(NRF52_PLATFORM)
#include <helpers/nrf52/InternalPrimaryFsBoot.h>
#include <helpers/nrf52/RamFallbackFileSystem.h>
#endif
#if defined(QSPIFLASH)
#include <CustomLFS_QSPIFlash.h>
DataStore store(InternalFS, QSPIFlash, rtc_clock);
#else
#if defined(EXTRAFS)
#include "ResilientInternalExtraFS.h"
extern "C" struct lfs_config* meshNrf52VoltagePrimaryFsConfig() {
return ResilientInternalExtraFS::primaryConfig();
}
extern "C" uint32_t __flash_arduino_end[];
ResilientInternalExtraFS ExtraFS(0xD4000, 0x19000, 128);
DataStore store(InternalFS, ExtraFS, rtc_clock);
#else
DataStore store(InternalFS, rtc_clock);
#endif
#endif
#elif defined(RP2040_PLATFORM)
#include <LittleFS.h>
DataStore store(LittleFS, rtc_clock);
#elif defined(ESP32)
#include <SPIFFS.h>
DataStore store(SPIFFS, rtc_clock);
#endif
/* GLOBAL OBJECTS */
#ifdef DISPLAY_CLASS
#include "UITask.h"
UITask ui_task(&board, &interface_manager);
#endif
StdRNG fast_rng;
SimpleMeshTables tables;
MyMesh the_mesh(radio_driver, fast_rng, rtc_clock, tables, store
#ifdef DISPLAY_CLASS
, &ui_task
#endif
);
#if defined(WIO_E5_MINI_HEADLESS_COMPANION_UI) && defined(PIN_USER_BTN)
namespace {
// The Wio E5 mini has no display, but its single button is still useful:
// double-click sends an advert; long-press during early boot enters the USB
// rescue CLI; a later long-press powers the board off. Keep those controls
// without pulling in the display-oriented UITask and its NullDisplayDriver.
constexpr uint32_t WIO_E5_BUTTON_READ_INTERVAL_MS = 10;
constexpr uint32_t WIO_E5_BUTTON_DEBOUNCE_MS = 50;
constexpr uint32_t WIO_E5_BUTTON_MULTI_CLICK_MS = 500;
constexpr uint32_t WIO_E5_BUTTON_LONG_PRESS_MS = 3000;
constexpr uint32_t WIO_E5_RESCUE_WINDOW_MS = 8000;
bool wio_e5_button_raw_pressed = false;
bool wio_e5_button_pressed = false;
bool wio_e5_button_long_press_handled = false;
uint8_t wio_e5_button_clicks = 0;
uint32_t wio_e5_button_last_read_at = 0;
uint32_t wio_e5_button_raw_changed_at = 0;
uint32_t wio_e5_button_pressed_at = 0;
uint32_t wio_e5_button_released_at = 0;
uint32_t wio_e5_controls_started_at = 0;
bool wioE5ButtonPressed() {
return digitalRead(PIN_USER_BTN) == LOW;
}
void beginWioE5HeadlessControls() {
const uint32_t now = millis();
wio_e5_button_raw_pressed = wioE5ButtonPressed();
wio_e5_button_pressed = wio_e5_button_raw_pressed;
// Match Button::begin(): a button already held across boot must be released
// and pressed again before it can trigger a long-press action.
wio_e5_button_long_press_handled = wio_e5_button_pressed;
wio_e5_button_raw_changed_at = now;
wio_e5_controls_started_at = now;
}
void serviceWioE5HeadlessControls() {
const uint32_t now = millis();
if (now - wio_e5_button_last_read_at < WIO_E5_BUTTON_READ_INTERVAL_MS) {
return;
}
wio_e5_button_last_read_at = now;
const bool raw_pressed = wioE5ButtonPressed();
if (raw_pressed != wio_e5_button_raw_pressed) {
wio_e5_button_raw_pressed = raw_pressed;
wio_e5_button_raw_changed_at = now;
}
if (now - wio_e5_button_raw_changed_at > WIO_E5_BUTTON_DEBOUNCE_MS
&& raw_pressed != wio_e5_button_pressed) {
wio_e5_button_pressed = raw_pressed;
if (wio_e5_button_pressed) {
wio_e5_button_pressed_at = now;
wio_e5_button_long_press_handled = false;
} else if (!wio_e5_button_long_press_handled) {
++wio_e5_button_clicks;
wio_e5_button_released_at = now;
}
}
if (wio_e5_button_pressed && !wio_e5_button_long_press_handled
&& now - wio_e5_button_pressed_at > WIO_E5_BUTTON_LONG_PRESS_MS) {
wio_e5_button_long_press_handled = true;
wio_e5_button_clicks = 0;
if (now - wio_e5_controls_started_at < WIO_E5_RESCUE_WINDOW_MS) {
the_mesh.enterCLIRescue();
} else if (the_mesh.prepareForUserShutdown()) {
board.powerOff();
}
}
if (!wio_e5_button_pressed && wio_e5_button_clicks != 0
&& now - wio_e5_button_released_at > WIO_E5_BUTTON_MULTI_CLICK_MS) {
if (wio_e5_button_clicks == 2) {
(void)the_mesh.advert();
}
wio_e5_button_clicks = 0;
}
}
} // namespace
#endif
#if COMPANION_FEATURE_BLE_MOTA_SOURCE
class Nrf52BleMotaSourceControl : public mesh::companion::MotaSourceControl {
public:
Nrf52BleMotaSourceControl()
: _source(bluetooth_interface.motaStream(),
mesh::ota::MotaStreamWritePolicy::NoFlush, 3000),
_packets_sent_at_start(0), _last_packets_sent(0) {}
bool start(char* reply, size_t reply_size) override {
if (!reply || reply_size == 0) return false;
if (!bluetooth_interface.isMotaChannelReady()) {
snprintf(reply, reply_size,
"ERR subscribe to the Bluetooth mOTA request characteristic first");
return false;
}
uint16_t drained = 0;
if (!mesh::ota::ota_acquire_context(reply, reply_size, true, &drained)) return false;
mesh::ota::OtaContext& context = mesh::ota::ota_ctx();
if (context.folder_active
&& context.folderLink() != mesh::ota::OtaContext::FOLDER_LINK_BLE) {
snprintf(reply, reply_size, "ERR mOTA source already uses %s",
mesh::ota::OtaContext::folderLinkName(context.folderLink()));
return false;
}
_packets_sent_at_start = context.manager.packetsSent();
_last_packets_sent = 0;
bluetooth_interface.setMotaStreamActive(true);
_source.resetSessionState(); // setActive(true) cleared the old ring generation.
if (!context.attach_folder_source(
&_source, mesh::ota::OtaContext::FOLDER_LINK_BLE, "ble",
reply, reply_size)) {
bluetooth_interface.setMotaStreamActive(false);
return false;
}
if (drained) {
size_t const used = strlen(reply);
snprintf(reply + used, reply_size - used,
"; cleared %u oldest queued messages for OTA", (unsigned)drained);
}
context.manager.announce();
mesh::usbLoggingPort().println("Bluetooth mOTA source attached");
return true;
}
bool stop(char* reply, size_t reply_size) override {
if (!reply || reply_size == 0) return false;
bluetooth_interface.setMotaStreamActive(false);
if (!mesh::ota::ota_context_if_active()) {
snprintf(reply, reply_size, "OK Bluetooth mOTA source stopped");
return true;
}
mesh::ota::OtaContext& context = mesh::ota::ota_ctx();
if (context.folder_active
&& context.folderLink() == mesh::ota::OtaContext::FOLDER_LINK_BLE) {
context.detach_folder();
context.manager.announce();
mesh::usbLoggingPort().println("Bluetooth mOTA source detached");
_last_packets_sent = context.manager.packetsSent()
- _packets_sent_at_start;
}
snprintf(reply, reply_size, "OK Bluetooth mOTA source stopped");
return true;
}
mesh::companion::MotaSourceStatus status() const override {
mesh::companion::MotaSourceStatus result;
result.channel_ready = bluetooth_interface.isMotaChannelReady();
if (!mesh::ota::ota_context_if_active()) {
result.packets_sent = _last_packets_sent;
return result;
}
const mesh::ota::OtaContext& context = mesh::ota::ota_ctx();
result.attached = context.folder_active
&& context.folderLink() == mesh::ota::OtaContext::FOLDER_LINK_BLE
&& bluetooth_interface.isMotaStreamActive();
result.another_link_active = context.folder_active
&& context.folderLink() != mesh::ota::OtaContext::FOLDER_LINK_BLE;
if (result.attached) {
context.folderSourceStats(result.offered, result.advertised);
result.packets_sent = context.manager.packetsSent()
- _packets_sent_at_start;
} else {
result.packets_sent = _last_packets_sent;
}
return result;
}
void loop() {
if (!mesh::ota::ota_context_if_active()) {
bluetooth_interface.setMotaStreamActive(false);
return;
}
mesh::ota::OtaContext& context = mesh::ota::ota_ctx();
const bool owns_folder = context.folder_active
&& context.folderLink() == mesh::ota::OtaContext::FOLDER_LINK_BLE;
if (!owns_folder) {
if (bluetooth_interface.isMotaStreamActive()) {
bluetooth_interface.setMotaStreamActive(false);
}
return;
}
if (bluetooth_interface.isMotaChannelReady()
&& bluetooth_interface.isMotaStreamActive()) {
return;
}
bluetooth_interface.setMotaStreamActive(false);
context.detach_folder();
context.manager.announce();
_last_packets_sent = context.manager.packetsSent()
- _packets_sent_at_start;
mesh::usbLoggingPort().println(
"Bluetooth mOTA source disconnected and was detached");
}
private:
mesh::ota::SerialMotaSource _source;
uint32_t _packets_sent_at_start;
uint32_t _last_packets_sent;
};
static Nrf52BleMotaSourceControl ble_mota_source_control;
#endif
/* END GLOBAL OBJECTS */
#ifdef RECOVERABLE_EXTERNAL_RADIO
static bool companion_radio_available = true;
static unsigned long companion_radio_retry_at = 0;
static const unsigned long COMPANION_RADIO_RETRY_MS = 60000UL;
static uint32_t companionFallbackRngSeed() {
#if defined(ESP32_PLATFORM)
return esp_random();
#else
uint32_t seed = micros();
#if defined(NRF52_PLATFORM)
seed ^= NRF_FICR->DEVICEID[0] ^ NRF_FICR->DEVICEID[1];
#endif
#ifdef USE_CC310_HW_CRYPTO
mesh::mixCC310Random(reinterpret_cast<uint8_t*>(&seed), sizeof(seed));
#endif
return seed;
#endif
}
static void serviceCompanionRadioRecovery() {
if (companion_radio_available
|| (long)(millis() - companion_radio_retry_at) < 0) return;
companion_radio_retry_at = millis() + COMPANION_RADIO_RETRY_MS;
mesh::usbLoggingPort().println("Radio recovery probe starting");
if (!radio_init()) {
mesh::usbLoggingPort().println(
"Radio recovery probe failed; companion services remain available");
return;
}
companion_radio_available = true;
the_mesh.activateRadio();
mesh::usbLoggingPort().println("Radio recovered; mesh transport is active");
}
#endif
#ifdef ESP32_PLATFORM
static int8_t applied_power_saving = -1;
static int8_t attempted_power_saving = -1;
static unsigned long power_saving_retry_at = 0;
static uint32_t companionNominalCpuMhz() {
#ifdef ESP32_POST_BOOT_CPU_FREQ
return ESP32_POST_BOOT_CPU_FREQ;
#elif defined(ESP32_CPU_FREQ)
return ESP32_CPU_FREQ;
#else
return F_CPU / 1000000UL;
#endif
}
#if COMPANION_BT_MODEM_SLEEP_AVAILABLE
static void applyCompanionBluetoothSleep(bool enabled,
bool allow_uninitialized) {
esp_err_t result = enabled ? esp_bt_sleep_enable()
: esp_bt_sleep_disable();
if (result == ESP_OK
|| (allow_uninitialized && result == ESP_ERR_INVALID_STATE)) {
return;
}
mesh::usbLoggingPort().printf("Bluetooth sleep %s failed: %s\r\n",
enabled ? "enable" : "disable",
esp_err_to_name(result));
}
#endif
static bool applyCompanionPowerSaving(bool enabled) {
const uint32_t nominal_mhz = companionNominalCpuMhz();
const uint32_t max_mhz = enabled && nominal_mhz > 80 ? 80 : nominal_mhz;
#if COMPANION_IDF_PM_AVAILABLE
const uint32_t min_mhz = enabled && max_mhz > 40 ? 40 : max_mhz;
#if (defined(ARDUINO_USB_CDC_ON_BOOT) && ARDUINO_USB_CDC_ON_BOOT) \
|| defined(ENABLE_USB_INTERFACE)
// Automatic light sleep interrupts native USB CDC and USB companions.
// Frequency scaling remains active when device power saving is enabled.
const bool automatic_light_sleep = false;
#else
const bool automatic_light_sleep = enabled;
#endif
#if CONFIG_IDF_TARGET_ESP32C3
esp_pm_config_esp32c3_t pm_config;
#elif CONFIG_IDF_TARGET_ESP32S3
esp_pm_config_esp32s3_t pm_config;
#elif CONFIG_IDF_TARGET_ESP32
esp_pm_config_esp32_t pm_config;
#else
esp_pm_config_t pm_config;
#endif
pm_config.max_freq_mhz = max_mhz;
pm_config.min_freq_mhz = min_mhz;
pm_config.light_sleep_enable = automatic_light_sleep;
esp_err_t pm_result = esp_pm_configure(&pm_config);
if (pm_result != ESP_OK) {
mesh::usbLoggingPort().printf("Device power saving failed: %s\r\n",
esp_err_to_name(pm_result));
return false;
}
#else
// Arduino's prebuilt ESP-IDF normally has CONFIG_PM_ENABLE disabled, in
// which case esp_pm_configure() is a stub returning ESP_ERR_NOT_SUPPORTED.
// Keep frequency throttling functional instead of retrying that stub.
if (!setCpuFrequencyMhz(max_mhz)) {
mesh::usbLoggingPort().printf(
"Device power saving failed: CPU %lu MHz is unsupported\r\n",
(unsigned long)max_mhz);
return false;
}
#endif
#if COMPANION_BT_MODEM_SLEEP_AVAILABLE
// Power saving is applied before the wireless interfaces are constructed.
// An uninitialized controller is expected here; apply the saved setting
// again immediately after BLEDevice::init() below. Some prebuilt ESP-IDF
// targets omit Bluetooth modem sleep entirely; the compile-time guard avoids
// calling an API which can only return ESP_ERR_NOT_SUPPORTED in those builds.
applyCompanionBluetoothSleep(enabled, true);
#endif
#if COMPANION_IDF_PM_AVAILABLE
mesh::usbLoggingPort().printf(
"Device power saving %s: CPU %lu-%lu MHz, automatic light sleep %s\r\n",
enabled ? "on" : "off", (unsigned long)min_mhz,
(unsigned long)max_mhz, automatic_light_sleep ? "on" : "off");
#else
mesh::usbLoggingPort().printf("Device power saving %s: CPU %lu MHz\r\n",
enabled ? "on" : "off",
(unsigned long)max_mhz);
#endif
return true;
}
static void serviceCompanionPowerSaving(bool force = false) {
const int8_t requested = the_mesh.getNodePrefs()->powersaving_enabled ? 1 : 0;
if (!force && applied_power_saving == requested) return;
const unsigned long now = millis();
if (!force && attempted_power_saving == requested
&& power_saving_retry_at != 0
&& (int32_t)(now - power_saving_retry_at) < 0) {
return;
}
attempted_power_saving = requested;
if (applyCompanionPowerSaving(requested != 0)) {
applied_power_saving = requested;
power_saving_retry_at = 0;
} else {
power_saving_retry_at = now + 5000;
if (power_saving_retry_at == 0) power_saving_retry_at = 1;
}
}
#endif
#if defined(ETHERNET_ENABLED)
static void cancelCompanionEthernetSession(void*) {
the_mesh.cancelSerialResponseStream(&ethernet_interface);
the_mesh.cancelSerialOperationsForRoute(&ethernet_interface);
interface_manager.forgetReplyRouteForDisconnected(&ethernet_interface);
}
#endif
#if defined(ENABLE_USB_INTERFACE)
static char usb_terminal_line[MAX_TRANS_UNIT * 2 + 32];
static size_t usb_terminal_line_len = 0;
static bool usb_terminal_discard_line = false;
static bool usb_host_session_connected = false;
static bool usb_logging_terminal_mode = false;
static bool usb_logging_network_parked = false;
static bool usb_protocol_initialized = false;
#if MESH_USB_LOGGING_AVAILABLE
static bool usb_logging_reply_hold = false;
static bool usb_logging_reply_staged = false;
static bool usb_logging_reply_pending = false;
static bool usb_logging_reply_state = false;
#endif
static mesh::UsbBinaryStartupProbe usb_binary_startup_probe;
static mesh::UsbAsciiSessionDefault usb_ascii_session_default;
static bool acceptUsbBinaryStartupFrame(uint32_t completed_at) {
// Radio/mesh work and the parser itself can cross the deadline after the
// main-loop precheck. Reject before command dispatch or Binary-client proof.
if (usb_binary_startup_probe.hasTimedOut(completed_at)) return false;
// This first valid frame establishes Binary ownership immediately, not at
// the later terminal-service poll. Later frames have no startup deadline.
usb_binary_startup_probe.cancel();
return true;
}
#if COMPANION_FEATURE_USB_MOTA_SOURCE
static bool usb_mota_mode = false;
static mesh::UsbMotaEntryOrigin usb_mota_entry_origin =
mesh::UsbMotaEntryOrigin::BINARY;
static char usb_mota_line[32];
static size_t usb_mota_line_len = 0;
static bool usb_mota_discard_line = false;
static bool usb_mota_disconnect_armed = false;
#endif
// nRF52's Adafruit CDC Stream::write() waits and yields until the complete
// request is queued. Functional terminal text uses a bounded queue over the
// single-attempt CDC0 facade so a normal host receives complete replies while
// an open but unread host cannot starve the mesh loop. Other platforms retain
// their ordinary stream.
static Stream& usbTerminalOutput() {
return mesh::usbTerminalPort();
}
static void clearUsbTerminalLine() {
memset(usb_terminal_line, 0, sizeof(usb_terminal_line));
usb_terminal_line_len = 0;
}
static void printUsbTerminalInputEcho() {
const char* password =
mesh::cli::terminalPasswordInput(usb_terminal_line);
size_t visible_len = usb_terminal_line_len;
if (password != nullptr) {
visible_len = static_cast<size_t>(password - usb_terminal_line);
}
if (visible_len > 0) {
usbTerminalOutput().write(
reinterpret_cast<const uint8_t*>(usb_terminal_line), visible_len);
}
for (size_t i = visible_len; i < usb_terminal_line_len; i++) {
usbTerminalOutput().print('*');
}
}
static void redrawUsbTerminalInput() {
// The documented picocom `--imap spchex` converts an echoed BS to "[08]".
// It deliberately leaves CR untouched, so redraw the edited line with CR
// and printable bytes only. Padding clears a removed tab or wide glyph.
usbTerminalOutput().print("\r> ");
printUsbTerminalInputEcho();
usbTerminalOutput().print(" ");
usbTerminalOutput().print("\r> ");
printUsbTerminalInputEcho();
}
static bool isUsbTerminalDataConnected() {
#if defined(NRF52_PLATFORM)
return mesh::isUsbCompanionClientConnected();
#elif defined(RP2040_PLATFORM)
return (bool)Serial;
#else
return board.isUsbDataConnected();
#endif
}
static constexpr uint32_t USB_TERMINAL_PROTOCOL_DRAIN_MS = 250;
static void queueUsbTerminalControlReply(const char* reply) {
if (reply == nullptr) return;
const size_t size = strlen(reply);
if (usbTerminalOutput().write(
reinterpret_cast<const uint8_t*>(reply), size) != size) {
// A transition reply takes priority over stale best-effort terminal text.
// Clear only the application queue; TinyUSB retains any prefix already
// accepted, so Binary/mOTA still cannot overtake it.
mesh::discardUsbTerminalOutput();
(void)usbTerminalOutput().write(
reinterpret_cast<const uint8_t*>(reply), size);
}
}
static void drainUsbTerminalOutputBeforeProtocolSwitch() {
const uint32_t started = millis();
while (mesh::hasPendingUsbTerminalOutput()
#if !(defined(ESP32) && defined(ARDUINO_USB_MODE) && ARDUINO_USB_MODE == 1 \
&& defined(ARDUINO_USB_CDC_ON_BOOT) && ARDUINO_USB_CDC_ON_BOOT)
&& isUsbTerminalDataConnected()
#endif
&& (uint32_t)(millis() - started)
< USB_TERMINAL_PROTOCOL_DRAIN_MS) {
mesh::serviceUsbTerminalPort();
#if defined(NRF52_PLATFORM)
board.feedWatchdog();
#endif
delay(1);
}
// Never let terminal bytes remain queued after another CDC0 protocol is
// enabled. A draining host normally empties this in a few milliseconds; an
// unread host loses only the residual text after the bounded deadline.
mesh::discardUsbTerminalOutput();
}
static bool hasObservableActiveUsbTerminalClient() {
#if defined(ESP32) && defined(ARDUINO_USB_MODE) && ARDUINO_USB_MODE == 1 \
&& defined(ARDUINO_USB_CDC_ON_BOOT) && ARDUINO_USB_CDC_ON_BOOT
// HWCDC exposes only whether USB is plugged in, not whether a host process
// has opened the terminal. Treat actual buffered activity as ownership; an
// idle physical cable must not permanently lock TCP port 5002.
return usb_terminal_line_len != 0 || usb_terminal_discard_line;
#else
return isUsbTerminalDataConnected();
#endif
}
static void cancelUsbSerialOperations() {
#if MESH_USB_LOGGING_AVAILABLE
// A cancelled reply must not enable diagnostics for the next USB host.
usb_logging_reply_pending = false;
#endif
// Contact streams and delayed replies capture their own destinations.
// Cancel only USB's ownership so simultaneous BLE/WiFi work keeps running.
the_mesh.cancelSerialResponseStream(&usb_serial_interface);
the_mesh.cancelSerialOperationsForRoute(&usb_serial_interface);
interface_manager.forgetReplyRouteForDisconnected(&usb_serial_interface);
}
static void enterUsbTerminalMode(bool show_banner = true) {
usb_binary_startup_probe.cancel();
usb_ascii_session_default.cancel();
cancelUsbSerialOperations();
mesh::discardUsbTerminalOutput();
usb_serial_interface.setPassthroughMode(true);
clearUsbTerminalLine();
usb_terminal_discard_line = false;
usb_logging_terminal_mode = false;
the_mesh.enterTerminalMode(show_banner);
}
static void enterUsbLoggingTerminalMode() {
enterUsbTerminalMode();
usb_logging_terminal_mode = true;
}
static void leaveUsbTerminalMode(bool acknowledge) {
if (acknowledge) {
queueUsbTerminalControlReply("\r\nOK - Binary mode\r\n");
drainUsbTerminalOutputBeforeProtocolSwitch();
} else {
mesh::discardUsbTerminalOutput();
}
the_mesh.exitTerminalMode();
// A network handoff can leave the USB CLI while diagnostics remain enabled.
// Do not briefly reopen the framed transport on that same logging stream.
usb_serial_interface.setPassthroughMode(
!mesh::hasDedicatedUsbLoggingPort() && mesh::isUsbLoggingEnabled());
clearUsbTerminalLine();
usb_terminal_discard_line = false;
usb_logging_terminal_mode = false;
}
#if COMPANION_FEATURE_USB_MOTA_SOURCE
static void resetUsbMotaMode() {
mesh::discardUsbTerminalOutput();
usb_mota_mode = false;
usb_mota_line_len = 0;
usb_mota_line[0] = 0;
usb_mota_discard_line = false;
usb_mota_disconnect_armed = false;
// A software owner change is not a transport reset. Retain any late binary
// reply and quarantine input until its known boundary is consumed.
usb_serial_interface.setPassthroughMode(
mesh::ota::OtaContext::serialFolderSource().hasPendingResponse());
}
static void leaveUsbMotaMode(bool acknowledge) {
const bool restore_ascii = acknowledge
&& usb_mota_entry_origin == mesh::UsbMotaEntryOrigin::ASCII;
char reply[160] = {0};
the_mesh.handleLocalControlCommand("ota folder off", reply, sizeof(reply));
if (acknowledge) {
char transition_reply[224];
snprintf(transition_reply, sizeof(transition_reply),
"\r\n%s\r\nOK - %s mode\r\n", reply,
restore_ascii ? "Terminal" : "Binary");
queueUsbTerminalControlReply(transition_reply);
drainUsbTerminalOutputBeforeProtocolSwitch();
}
resetUsbMotaMode();
// A text-first motatool session must hand CDC0 back to the text terminal.
// Otherwise closing the sender leaves this still-open USB host in Binary
// mode, and its next `ver` or `ota status` appears to get no reply.
if (restore_ascii) enterUsbTerminalMode(false);
}
static bool enterUsbMotaMode(mesh::UsbMotaEntryOrigin origin) {
// An explicit seeder command selects its own protocol owner, even if it
// arrived after a rejected startup frame. Neither a stale Binary deadline
// nor deferred default-ASCII restoration may reclaim a live mOTA stream.
usb_binary_startup_probe.cancel();
usb_ascii_session_default.cancel();
auto& source = mesh::ota::OtaContext::serialFolderSource();
if (source.hasPendingResponse()) {
queueUsbTerminalControlReply("\r\nERR waiting for previous mOTA response\r\n");
if (mesh::shouldRestoreAsciiAfterMotaFailure(origin)) enterUsbTerminalMode();
return false;
}
cancelUsbSerialOperations();
mesh::discardUsbTerminalOutput();
source.enableSharedTextControl();
source.resetSessionState();
usb_serial_interface.setPassthroughMode(true);
usb_mota_mode = true;
usb_mota_entry_origin = origin;
usb_mota_line_len = 0;
usb_mota_line[0] = 0;
usb_mota_discard_line = false;
usb_mota_disconnect_armed = isUsbTerminalDataConnected();
#if defined(ESP32) && defined(ARDUINO_USB_MODE) && ARDUINO_USB_MODE == 1 \
&& defined(ARDUINO_USB_CDC_ON_BOOT) && ARDUINO_USB_CDC_ON_BOOT
// A valid attach command is stronger host proof than HWCDC's advisory SOF
// sample, which may be low in the same loop as the received command.
usb_hwcdc_host_presence.noteClientActivity();
#endif
char reply[160] = {0};
if (!the_mesh.handleLocalControlCommand("ota folder on", reply, sizeof(reply))
|| strncmp(reply, "ERR", 3) == 0) {
char failure_reply[224];
snprintf(failure_reply, sizeof(failure_reply), "\r\n%s\r\n",
reply[0] ? reply : "ERR could not enter mOTA seeder mode");
queueUsbTerminalControlReply(failure_reply);
drainUsbTerminalOutputBeforeProtocolSwitch();
resetUsbMotaMode();
if (mesh::shouldRestoreAsciiAfterMotaFailure(origin)) {
enterUsbTerminalMode();
}
return false;
}
char attached_reply[224];
snprintf(attached_reply, sizeof(attached_reply), "\r\n%s\r\n", reply);
queueUsbTerminalControlReply(attached_reply);
// The mOTA request stream writes directly through the single-attempt CDC0
// facade. Finish this text barrier first so its first binary frame can never
// overtake a queued attach reply.
drainUsbTerminalOutputBeforeProtocolSwitch();
return true;
}
static void serviceUsbMota() {
#if !(defined(ESP32) && defined(ARDUINO_USB_MODE) && ARDUINO_USB_MODE == 1 \
&& defined(ARDUINO_USB_CDC_ON_BOOT) && ARDUINO_USB_CDC_ON_BOOT)
if (isUsbTerminalDataConnected()) {
usb_mota_disconnect_armed = true;
} else if (usb_mota_disconnect_armed) {
leaveUsbMotaMode(false);
return;
}
#endif
// A failed transaction can leave a late binary response, not just host text.
// Retain its known body boundary before recognizing clean shutdown controls.
// The static source accessor never allocates/acquires an OTA context here.
auto& source = mesh::ota::OtaContext::serialFolderSource();
int pending = source.availableControlBytes();
while (pending-- > 0) {
int value = source.readControlByte();
if (value == mesh::ota::SerialMotaSource::RESPONSE_BYTE) continue;
if (value < 0) break;
char c = (char)value;
if (usb_mota_discard_line) {
if (c == '\r' || c == '\n') usb_mota_discard_line = false;
continue;
}
if (c == '\r' || c == '\n') {
if (usb_mota_line_len == 0) continue;
usb_mota_line[usb_mota_line_len] = 0;
bool stop = strcmp(usb_mota_line, "ota folder off") == 0;
usb_mota_line_len = 0;
usb_mota_line[0] = 0;
if (stop) {
leaveUsbMotaMode(true);
return;
}
continue;
}
if (c != 0 && usb_mota_line_len < sizeof(usb_mota_line) - 1) {
usb_mota_line[usb_mota_line_len++] = c;
usb_mota_line[usb_mota_line_len] = 0;
} else {
usb_mota_line_len = 0;
usb_mota_line[0] = 0;
usb_mota_discard_line = true;
}
}
}
static bool serviceUsbMotaResponseBoundary() {
if (usb_mota_mode) return false;
auto& source = mesh::ota::OtaContext::serialFolderSource();
if (!source.hasPendingResponse()) return false;
// Failed attach or software handoff must not turn late binary data into
// terminal commands or Companion frames. Input typed while waiting is
// discarded; only an actual response boundary or host reset ends the wait.
if (!usb_serial_interface.isPassthroughMode()) {
usb_serial_interface.setPassthroughMode(true);
}
int pending = source.availableControlBytes();
while (pending-- > 0) {
if (source.readControlByte() == -1) break;
if (!source.hasPendingResponse()) {
source.resetSessionState();
usb_serial_interface.setPassthroughMode(
the_mesh.isTerminalMode() || usb_logging_network_parked
|| (!mesh::hasDedicatedUsbLoggingPort()
&& mesh::isUsbLoggingEnabled()));
break;
}
}
return true;
}
#endif
// Consume an exact TinyUSB owner-task edge, or HWCDC's framework-debounced SOF
// loss edge, before any protocol parser runs. No line, login, recipient, queued
// text, or mOTA attachment may survive into the next host session.
static bool usb_terminal_host_reset_completion_pending = false;
static uint32_t usb_terminal_host_reset_retry_at = 0;
static void resetUsbTerminalHostSession() {
#if COMPANION_FEATURE_READER
mesh::cancelReaderCommand(&mesh::usbTerminalPort());
#endif
// Stop only work routed to the old USB host; a simultaneous BLE/WiFi
// transaction is independent and must continue. Clear the route after
// stopping its producer so no response can fall back to another transport.
cancelUsbSerialOperations();
the_mesh.resetUsbHostSessionInput();
#if COMPANION_FEATURE_USB_MOTA_SOURCE
if (usb_mota_mode) {
leaveUsbMotaMode(false);
}
mesh::ota::OtaContext::serialFolderSource().resetSessionState();
#endif
if (the_mesh.isTerminalMode()) leaveUsbTerminalMode(false);
mesh::discardUsbTerminalOutput();
usb_serial_interface.setPassthroughMode(false);
clearUsbTerminalLine();
usb_terminal_discard_line = false;
usb_logging_terminal_mode = false;
usb_binary_startup_probe.cancel();
// setPassthroughMode() clears parser/queue state but frame ownership is a
// separate proof. Revoke all three explicitly at the session boundary.
usb_serial_interface.resetSessionState();
usb_ascii_session_default.request(
usb_serial_interface.getCompletedFrameCount());
#if defined(ESP32) && defined(ARDUINO_USB_MODE) && ARDUINO_USB_MODE == 1 \
&& defined(ARDUINO_USB_CDC_ON_BOOT) && ARDUINO_USB_CDC_ON_BOOT
// Purge HWCDC's queues behind a temporary diagnostics gate. This keeps the
// core's shared RTOS objects alive while other firmware tasks are running.
if (!mesh::resetUsbCompanionTransport()) {
// Keep the producer gate and PHY quarantined, then retry from later loops.
// Rebooting on repeatable allocation/contention failures creates a host-
// enumeration restart loop and still does not prove stale bytes were gone.
usb_terminal_host_reset_completion_pending = true;
usb_terminal_host_reset_retry_at = millis() + USB_TRANSPORT_RESET_RETRY_MS;
return;
}
usb_terminal_host_reset_completion_pending = false;
usb_hwcdc_host_presence.reset();
#endif
}
static void serviceUsbTerminalHostSessionReset() {
#if defined(NRF52_PLATFORM) || defined(RP2040_PLATFORM) \
|| MESH_ESP32_TINYUSB_NONBLOCKING
// Observe disconnects even while Binary or mOTA owns USB. Owner-task reset
// events also catch a close/reopen that happens between two main-loop polls.
const bool connected = isUsbTerminalDataConnected();
const bool disconnected = usb_host_session_connected && !connected;
usb_host_session_connected = connected;
if (mesh::takeUsbTerminalSessionReset() || disconnected) {
usb_terminal_host_reset_completion_pending = true;
resetUsbTerminalHostSession();
}
if (usb_terminal_host_reset_completion_pending
&& mesh::tryCompleteUsbTerminalSessionReset()) {
usb_terminal_host_reset_completion_pending = false;
}
#elif defined(ESP32) && defined(ARDUINO_USB_MODE) && ARDUINO_USB_MODE == 1 \
&& defined(ARDUINO_USB_CDC_ON_BOOT) && ARDUINO_USB_CDC_ON_BOOT
if (usb_terminal_host_reset_completion_pending) {
if ((int32_t)(millis() - usb_terminal_host_reset_retry_at) >= 0) {
if (mesh::resetUsbCompanionTransport()) {
usb_terminal_host_reset_completion_pending = false;
usb_hwcdc_host_presence.reset();
} else {
usb_terminal_host_reset_retry_at =
millis() + USB_TRANSPORT_RESET_RETRY_MS;
}
}
return;
}
const bool host_present = board.isUsbHostConnected();
(void)usb_hwcdc_host_presence.observeHost(
host_present, millis(), USB_HOST_LOSS_EDGE_MS,
USB_HOST_LOSS_GRACE_MS);
const bool hardware_bus_reset = mesh::takeUsbTerminalSessionReset();
const bool physical_host_loss =
usb_hwcdc_host_presence.takeHostLossEdge();
const bool sustained_host_loss =
usb_hwcdc_host_presence.takeSustainedHostLoss();
if (hardware_bus_reset || physical_host_loss || sustained_host_loss) {
resetUsbTerminalHostSession();
}
#endif
}
static void serviceUsbAsciiSessionDefault() {
if (usb_ascii_session_default.shouldRestore(
!usb_terminal_host_reset_completion_pending,
#if COMPANION_FEATURE_NETWORK_TERMINAL || defined(WITH_WEBCONFIG)
isNetworkTerminalActive(),
#else
false,
#endif
usb_serial_interface.getCompletedFrameCount())) {
// A prompt contains '>', the Binary Companion frame marker. Do not send
// text to an unknown host: the first ASCII input reveals the terminal,
// while a framed app command switches without poisoning its first reply.
enterUsbTerminalMode(false);
}
}
static void beginUsbDefaultSession() {
#if MESH_USB_LOGGING_AVAILABLE
if (!mesh::hasDedicatedUsbLoggingPort() && mesh::isUsbLoggingEnabled()) {
enterUsbLoggingTerminalMode();
return;
}
#endif
usb_ascii_session_default.request(
usb_serial_interface.getCompletedFrameCount());
serviceUsbAsciiSessionDefault();
}
static void serviceUsbLoggingOwnership(bool logging_enabled);
static void serviceUsbTerminal() {
serviceUsbLoggingOwnership(mesh::isUsbLoggingEnabled());
if (usb_logging_network_parked) return;
#if COMPANION_FEATURE_USB_MOTA_SOURCE
if (serviceUsbMotaResponseBoundary()) return;
if (usb_mota_mode) {
serviceUsbMota();
return;
}
#endif
if (!the_mesh.isTerminalMode()) {
const mesh::UsbBinaryStartupProbe::Result probe_result =
usb_binary_startup_probe.poll(
millis(), usb_serial_interface.getCompletedFrameCount(),
usb_serial_interface.getLastFrameMillis());
if (probe_result
== mesh::UsbBinaryStartupProbe::Result::RETURN_TO_ASCII) {
enterUsbTerminalMode();
return;
}
if (usb_serial_interface.takeControlSequence()) {
enterUsbTerminalMode();
#if COMPANION_FEATURE_USB_MOTA_SOURCE
} else if (usb_serial_interface.takeSecondaryControlSequence()) {
enterUsbMotaMode(mesh::UsbMotaEntryOrigin::BINARY);
#endif
}
return;
}
Stream& usb_input = mesh::usbCompanionPort();
bool blank_input = false;
int pending = usb_input.available();
while (pending-- > 0) {
// A leftover CR/LF (for example after mOTA shutdown) must not hide a
// following frame or cause a banner to poison its first binary reply.
if (usb_terminal_line_len == 0 && !usb_terminal_discard_line) {
const int next_byte = usb_input.peek();
if (next_byte == '\r' || next_byte == '\n') {
usb_input.read();
blank_input = true;
continue;
}
// Re-check at each empty prompt, before consuming the '<' byte. A
// malformed or accidental probe still returns to ASCII after one second.
if (!usb_logging_terminal_mode
&& usb_binary_startup_probe.shouldStart(true, false, next_byte)) {
const uint32_t frame_count = usb_serial_interface.getCompletedFrameCount();
leaveUsbTerminalMode(false);
usb_binary_startup_probe.start(millis(), frame_count);
return;
}
}
if (the_mesh.isTerminalWaitingForInput()) enterUsbTerminalMode();
int value = usb_input.read();
if (value < 0) break;
char c = (char)value;
if (usb_terminal_discard_line) {
if (c == '\r' || c == '\n') {
usb_terminal_discard_line = false;
usbTerminalOutput().print("> ");
}
continue;
}
if (c == 0) {
// The handlers consume C strings. Reject the whole malformed line, not
// an apparently valid mutating command before an embedded NUL.
clearUsbTerminalLine();
usb_terminal_discard_line = true;
usbTerminalOutput().print("\r\n ERROR: invalid command\r\n");
continue;
}
if (c == '\b' || c == 0x7F) {
if (usb_terminal_line_len > 0) {
usb_terminal_line_len = mesh::cli::eraseLastTerminalInput(
usb_terminal_line, usb_terminal_line_len);
redrawUsbTerminalInput();
}
continue;
}
if (c == '\r' || c == '\n') {
if (usb_terminal_line_len == 0) continue;
usbTerminalOutput().print("\r\n");
#if COMPANION_FEATURE_USB_MOTA_SOURCE
// motatool is deliberately text-first: `serve --serial` opens the port
// and sends this command before its binary mOTA request/reply traffic.
// USB Companion boots in ASCII, so transfer ownership directly
// instead of letting the ordinary terminal command handler leave the
// stream in line-oriented mode.
if (strcmp(usb_terminal_line, USB_MOTA_START_TOKEN) == 0) {
#if MESH_USB_LOGGING_AVAILABLE
if (!mesh::hasDedicatedUsbLoggingPort()
&& mesh::isUsbLoggingEnabled()) {
clearUsbTerminalLine();
usbTerminalOutput().print(
"\r\nERROR: set usb.logging off before USB mOTA\r\n> ");
return;
}
#endif
leaveUsbTerminalMode(false);
enterUsbMotaMode(mesh::UsbMotaEntryOrigin::ASCII);
return;
}
#endif
// NUL-containing lines were discarded before reaching this point. On
// dual CDC hardware only CDC1 is the logging-reader endpoint.
if (!mesh::hasDedicatedUsbLoggingPort())
mesh::noteUsbLoggingStatsCommand(usb_terminal_line);
the_mesh.handleTerminalCommand(usb_terminal_line);
clearUsbTerminalLine();
#if MESH_USB_LOGGING_AVAILABLE
if (usb_logging_terminal_mode
&& !mesh::isUsbLoggingEnabled()) {
// The command reply belongs to the Companion's normal ASCII
// session. A separate, observable mode switch is required before the
// port accepts framed Binary Companion traffic again.
usb_logging_terminal_mode = false;
}
#endif
#if COMPANION_FEATURE_READER
if (!mesh::readerCommandActive(&usbTerminalOutput()))
#endif
usbTerminalOutput().print("> ");
return; // service at most one command per mesh loop
}
if (usb_terminal_line_len >= sizeof(usb_terminal_line) - 1) {
clearUsbTerminalLine();
usb_terminal_discard_line = true;
usbTerminalOutput().print("\r\n ERROR: command too long\r\n");
continue;
}
usb_terminal_line[usb_terminal_line_len++] = c;
usb_terminal_line[usb_terminal_line_len] = 0;
usbTerminalOutput().print(
mesh::cli::shouldMaskTerminalInput(usb_terminal_line) ? '*' : c);
if (strcmp(usb_terminal_line, USB_TERMINAL_STOP_TOKEN) == 0) {
#if MESH_USB_LOGGING_AVAILABLE
if (!mesh::hasDedicatedUsbLoggingPort() && mesh::isUsbLoggingEnabled()) {
clearUsbTerminalLine();
usbTerminalOutput().print(
"\r\nERROR: set usb.logging off before Binary mode\r\n> ");
return;
}
#endif
leaveUsbTerminalMode(true);
return;
}
}
// Enter alone should still reveal the otherwise silent ASCII session.
if (blank_input && usb_input.available() == 0
&& the_mesh.isTerminalWaitingForInput()) enterUsbTerminalMode();
}
static void serviceUsbLoggingOwnership(bool logging_enabled) {
// A saved logging-on preference makes the one available TTY behave like a
// logging repeater: plaintext diagnostics plus an input-capable CLI. Put the
// Companion interface into passthrough before it can mix framed traffic with
// logs. An active TCP terminal owns the role CLI, so logging must not reclaim
// it. Turning logging off retains the ordinary ASCII terminal.
#if MESH_USB_LOGGING_AVAILABLE
#if COMPANION_FEATURE_USB_MOTA_SOURCE
if (usb_mota_mode) {
if (mesh::hasDedicatedUsbLoggingPort() || !logging_enabled) return;
// Run before framed dispatch, not just the later terminal-input service.
leaveUsbMotaMode(false);
}
#endif
const mesh::UsbLoggingTerminalAction logging_action =
mesh::selectUsbLoggingTerminalAction(
mesh::hasDedicatedUsbLoggingPort(), logging_enabled,
the_mesh.isTerminalMode(), usb_logging_terminal_mode,
true,
#if COMPANION_FEATURE_NETWORK_TERMINAL || defined(WITH_WEBCONFIG)
isNetworkTerminalActive()
#else
false
#endif
);
if (usb_logging_network_parked
&& logging_action != mesh::UsbLoggingTerminalAction::PARK_USB_FOR_NETWORK) {
// Bytes typed while USB was log-only belong to neither the new ASCII CLI
// nor a new Binary client. Bound the discard to the existing RX snapshot.
#if COMPANION_FEATURE_USB_MOTA_SOURCE
const bool response_pending =
mesh::ota::OtaContext::serialFolderSource().hasPendingResponse();
#else
const bool response_pending = false;
#endif
// A timed-out mOTA transaction still owns its exact response boundary.
// Raw reads would consume its tail without advancing that tracker, leaving
// the stream quarantined forever. Let its bounded consumer drain instead.
Stream& input = mesh::usbCompanionPort();
int pending = response_pending ? 0 : input.available();
while (pending-- > 0) input.read();
usb_logging_network_parked = false;
usb_serial_interface.setPassthroughMode(
the_mesh.isTerminalMode() || response_pending);
}
switch (logging_action) {
case mesh::UsbLoggingTerminalAction::PARK_USB_FOR_NETWORK:
if (!usb_logging_network_parked
|| !usb_serial_interface.isPassthroughMode()) {
usb_binary_startup_probe.cancel();
cancelUsbSerialOperations();
mesh::discardUsbTerminalOutput();
usb_serial_interface.setPassthroughMode(true);
clearUsbTerminalLine();
usb_terminal_discard_line = false;
}
usb_logging_network_parked = true;
return; // Do not move the role CLI away from TCP/browser.
case mesh::UsbLoggingTerminalAction::CLAIM_USB:
usb_logging_network_parked = false;
if (!the_mesh.isTerminalMode()) {
enterUsbLoggingTerminalMode();
return;
}
usb_logging_terminal_mode = true;
break;
case mesh::UsbLoggingTerminalAction::RETURN_TO_BINARY:
leaveUsbTerminalMode(true);
return;
case mesh::UsbLoggingTerminalAction::KEEP_ASCII:
// Logging may also be disabled over BLE/WiFi. Stop treating this session
// as the logging terminal, but keep the ordinary ASCII terminal active;
// do not silently change the USB protocol underneath an idle host. A
// remote mode change also cancels any partially typed USB command before
// drawing a fresh prompt.
usb_logging_terminal_mode = false;
clearUsbTerminalLine();
usb_terminal_discard_line = false;
usbTerminalOutput().print(
"\r\nUSB logging off; ASCII terminal active\r\n> ");
break;
case mesh::UsbLoggingTerminalAction::NO_ACTION:
break;
}
#endif
}
void MyMesh::applyUsbLoggingState(bool enabled) {
#if MESH_USB_LOGGING_AVAILABLE
if (usb_logging_reply_hold) {
usb_logging_reply_staged = true;
usb_logging_reply_state = enabled;
return;
}
// A newer explicit setting (including one from BLE/WiFi) wins over an
// earlier USB command whose acknowledgement is still backpressured.
usb_logging_reply_pending = false;
#endif
// Park the framed transport before opening the diagnostic gate, including
// TCP/browser commands which execute outside the mesh dispatcher.
if (enabled && usb_protocol_initialized) serviceUsbLoggingOwnership(true);
mesh::setUsbLoggingEnabled(enabled);
if (usb_protocol_initialized) serviceUsbLoggingOwnership(enabled);
}
void MyMesh::beginUsbLoggingReplyBarrier(BaseSerialInterface* route) {
#if MESH_USB_LOGGING_AVAILABLE
usb_logging_reply_hold = usb_protocol_initialized
&& route == &usb_serial_interface
&& !mesh::hasDedicatedUsbLoggingPort()
&& !usb_serial_interface.isPassthroughMode();
usb_logging_reply_staged = false;
#else
(void)route;
#endif
}
void MyMesh::endUsbLoggingReplyBarrier(bool reply_queued) {
#if MESH_USB_LOGGING_AVAILABLE
usb_logging_reply_hold = false;
if (usb_logging_reply_staged) {
// No response fallback: a full queue leaves logging unchanged for this
// boot. Its saved preference can still take effect at the next boot.
usb_logging_reply_pending = reply_queued;
usb_logging_reply_staged = false;
}
#else
(void)reply_queued;
#endif
}
static void serviceUsbLoggingReplyBarrier() {
#if MESH_USB_LOGGING_AVAILABLE
if (!usb_logging_reply_pending) return;
if (!usb_serial_interface.isConnected()
|| usb_serial_interface.isPassthroughMode()) {
usb_logging_reply_pending = false;
return;
}
// One nonblocking transport pass. Never wait on an unread USB host or
// include RX parser work in this check: a partial next command cannot hold
// the already queued acknowledgement. Once the complete frame is admitted,
// the ordered native FIFO sends it before any new terminal/log bytes.
usb_serial_interface.loop();
if (usb_serial_interface.isWriteBusy()) return;
const bool enabled = usb_logging_reply_state;
usb_logging_reply_pending = false;
the_mesh.applyUsbLoggingState(enabled);
#endif
}
static void expireUsbBinaryStartupProbeBeforeDispatch() {
const uint32_t now = millis();
if (!usb_binary_startup_probe.hasTimedOut(now)) return;
// The dispatcher normally consumes Binary Companion input before the ASCII
// terminal service runs. Enforce the advertised deadline here so bytes that
// are still incomplete at one second cannot complete a frame afterwards.
// Entering passthrough resets the partial binary parser; drain only bytes
// already queued for that expired attempt so they cannot begin a new probe.
Stream& usb_input = mesh::usbCompanionPort();
int pending = usb_input.available();
while (pending-- > 0) usb_input.read();
enterUsbTerminalMode();
}
#endif
#if !defined(ENABLE_USB_INTERFACE)
void MyMesh::applyUsbLoggingState(bool enabled) {
mesh::setUsbLoggingEnabled(enabled);
}
void MyMesh::beginUsbLoggingReplyBarrier(BaseSerialInterface*) {}
void MyMesh::endUsbLoggingReplyBarrier(bool) {}
#endif
#if defined(ENABLE_USB_INTERFACE) && (COMPANION_FEATURE_NETWORK_TERMINAL || defined(WITH_WEBCONFIG))
static bool isNetworkTerminalActive() {
return the_mesh.isAnyNetworkTerminalMode();
}
#endif
#if defined(WITH_WEBCONFIG) && defined(ENABLE_USB_INTERFACE)
static mesh::UsbTcpTerminalHandoff browser_usb_handoff;
bool MyMesh::beginStreamTerminal(Stream& output) {
static_assert(mesh::kTerminalCommandCapacity == MAX_TRANS_UNIT * 2 + 32,
"Browser and USB/TCP command capacities must match");
if (isAnyNetworkTerminalMode()) return false;
const bool ascii_selected = isTerminalMode();
const bool input_idle = usb_terminal_line_len == 0 && !usb_terminal_discard_line
&& !usb_binary_startup_probe.isActive()
;
if (!browser_usb_handoff.begin(ascii_selected,
hasObservableActiveUsbTerminalClient(), input_idle,
usb_serial_interface.getCompletedFrameCount())) return false;
if (ascii_selected) leaveUsbTerminalMode(false);
if (enterNetworkTerminalMode(output)) {
serviceUsbLoggingOwnership(mesh::isUsbLoggingEnabled());
return true;
}
if (browser_usb_handoff.shouldRestoreAscii(
usb_serial_interface.getCompletedFrameCount())) enterUsbTerminalMode();
return false;
}
void MyMesh::endStreamTerminal(Stream& output) {
const bool owned = isNetworkTerminalMode(output);
exitNetworkTerminalMode(output);
if (!owned) {
browser_usb_handoff.cancel();
} else if (browser_usb_handoff.shouldRestoreAscii(
usb_serial_interface.getCompletedFrameCount())) {
enterUsbTerminalMode();
}
}
#elif defined(WITH_WEBCONFIG)
bool MyMesh::beginStreamTerminal(Stream& output) {
return enterNetworkTerminalMode(output);
}
void MyMesh::endStreamTerminal(Stream& output) {
exitNetworkTerminalMode(output);
}
#endif
void halt() {
while (1) ;
}
/* WIFI RECONNECT TRACKERS */
#if defined(ESP32) && defined(WIFI_SSID)
static const unsigned long WIFI_SETUP_FALLBACK_MS = 120000UL;
static const char COMPANION_WIFI_SETUP_AP[] = "MeshCore-Setup";
WiFiReconnectPolicy::Tracker wifi_reconnect_tracker;
bool wifi_setup_attempted = false;
unsigned long last_wifi_setup_attempt = 0;
bool wifi_setup_recovery_mode = false;
static char configured_wifi_ssid[32];
static char configured_wifi_password[65];
static bool companion_wifi_has_credentials = false;
static bool companion_wifi_requested = false;
static bool companion_wifi_active = false;
static bool companion_wifi_disable_in_progress = false;
static bool companion_wifi_services_stopped = false;
static bool companion_wifi_background_stopped = false;
static bool companion_wifi_espnow_restore_pending = false;
static uint32_t companion_wifi_espnow_restore_at = 0;
#ifdef WITH_WEBCONFIG
static bool companion_wifi_webconfig_start_pending = false;
#endif
static volatile bool companion_wifi_setup_requested = false;
static volatile bool companion_wifi_setup_stop_requested = false;
static bool companion_wifi_credential_reload_pending = false;
static unsigned long companion_wifi_credential_reload_at = 0;
static bool companion_wifi_power_save_loaded = false;
static uint8_t companion_wifi_power_save = mesh::wifi::kDefaultPowerSave;
static CompanionWiFiDisplayState companion_wifi_display_state =
CompanionWiFiDisplayState::NotRendered;
static uint32_t companion_wifi_display_frames = 0;
static bool companion_display_available = false;
static uint8_t companion_display_begin_status = 0;
#if defined(COMPANION_EXCLUSIVE_WIFI_BLE)
static CompanionTransportMode companion_transport_boot_mode =
CompanionTransportMode::Bluetooth;
static bool companion_transport_boot_mode_loaded = false;
CompanionTransportMode getCompanionTransportMode() {
return the_mesh.getNodePrefs()->wifi_enabled != 0
? CompanionTransportMode::WiFi
: CompanionTransportMode::Bluetooth;
}
bool selectCompanionTransportMode(CompanionTransportMode mode) {
if (mode != CompanionTransportMode::Bluetooth
&& mode != CompanionTransportMode::WiFi) {
return false;
}
CompanionNodePrefs* prefs = the_mesh.getNodePrefs();
const uint8_t previous = prefs->wifi_enabled;
const uint8_t selected = mode == CompanionTransportMode::WiFi ? 1 : 0;
if (previous == selected) return true;
prefs->wifi_enabled = selected;
if (the_mesh.savePrefs()) return true;
prefs->wifi_enabled = previous;
return false;
}
static bool companionTransportWiFiActiveAtBoot() {
return companion_transport_boot_mode == CompanionTransportMode::WiFi;
}
static void loadCompanionTransportModeForBoot() {
companion_transport_boot_mode = getCompanionTransportMode();
companion_transport_boot_mode_loaded = true;
companion_wifi_requested = companionTransportWiFiActiveAtBoot();
mesh::usbLoggingPort().printf(
"Companion transport: exclusive %s selected for this boot\r\n",
companion_wifi_requested ? "WiFi" : "Bluetooth");
}
static void releaseCompanionBluetoothMemoryForWiFi() {
const esp_err_t result = esp_bt_mem_release(ESP_BT_MODE_BTDM);
if (result == ESP_OK) {
mesh::usbLoggingPort().println(
"Companion transport: released Bluetooth memory for exclusive WiFi mode");
} else {
mesh::usbLoggingPort().printf(
"Companion transport: Bluetooth memory release failed: %s\r\n",
esp_err_to_name(result));
}
}
#endif
static constexpr uint32_t COMPANION_WIFI_NTP_TIMEOUT_MS = 15000UL;
static mesh::wifi::CompanionWiFiNtpPolicy companion_wifi_ntp_policy;
static std::atomic<uint32_t> companion_wifi_ntp_operation_generation{0};
static std::atomic<uint32_t> companion_wifi_ntp_proof_generation{0};
static std::atomic<uint32_t> companion_wifi_ntp_epoch{0};
static std::atomic<uint32_t> companion_wifi_ntp_proven_at{0};
static bool companion_wifi_ntp_pending = false;
static uint32_t companion_wifi_ntp_started = 0;
static uint32_t companion_wifi_ntp_previous_epoch = 0;
static void clearCompanionWiFiNtpCallback() {
companion_wifi_ntp_operation_generation.store(
0, std::memory_order_release);
esp_sntp_set_time_sync_notification_cb(nullptr);
// The policy below owns the cadence. Leave no SDK-default (three-hour in
// the current ESP32 toolchain) background poll running between the boot
// request and the explicit 24-hour refresh.
esp_sntp_stop();
}
static mesh::sntp_coord::OperationLease companion_wifi_ntp_operation(
mesh::sntp_coord::processWideCoordinator(),
clearCompanionWiFiNtpCallback);
static void noteCompanionWiFiNtpTime(struct timeval* value) {
const uint32_t generation =
companion_wifi_ntp_operation_generation.load(
std::memory_order_acquire);
if (!mesh::sntp_coord::processWideCoordinator().owns(generation)) return;
if (value == nullptr || !mesh::tls_clock::timeIsValid(value->tv_sec)
|| static_cast<uint64_t>(value->tv_sec) > UINT32_MAX) {
return;
}
companion_wifi_ntp_epoch.store(
static_cast<uint32_t>(value->tv_sec), std::memory_order_release);
companion_wifi_ntp_proven_at.store(millis(), std::memory_order_release);
companion_wifi_ntp_proof_generation.store(
generation, std::memory_order_release);
}
static bool companionWiFiMqttOwnsNtp() {
#ifdef WITH_MQTT_BRIDGE
const bool running = the_mesh.isMQTTRunning();
if (running && the_mesh.hasFreshMQTTNtpThisBoot()) {
the_mesh.noteInternetClockSet();
}
return running;
#else
return false;
#endif
}
static void cancelCompanionWiFiNtp(bool retry_on_reconnect) {
if (!companion_wifi_ntp_pending) return;
companion_wifi_ntp_pending = false;
companion_wifi_ntp_operation.release();
if (retry_on_reconnect) companion_wifi_ntp_policy.requestNow();
}
static void serviceCompanionWiFiNtp() {
if (mesh::wifi::networkPower().backgroundSuspended()) {
cancelCompanionWiFiNtp(false);
return;
}
// Configured MQTT builds already perform a fresh startup NTP sync and a
// daily refresh. The common Companion owner covers every non-MQTT or
// runtime-unconfigured WiFi node without racing MQTT's SNTP proof.
if (companionWiFiMqttOwnsNtp()) {
cancelCompanionWiFiNtp(false);
companion_wifi_ntp_policy.requestNow();
return;
}
if (WiFi.status() != WL_CONNECTED) {
cancelCompanionWiFiNtp(true);
return;
}
const uint32_t now = millis();
if (companion_wifi_ntp_pending) {
const uint32_t proof_epoch = companion_wifi_ntp_epoch.load(
std::memory_order_acquire);
const uint32_t proof_generation =
companion_wifi_ntp_proof_generation.load(
std::memory_order_acquire);
const uint32_t proven_at = companion_wifi_ntp_proven_at.load(
std::memory_order_acquire);
if (proof_epoch != 0 && companion_wifi_ntp_operation.owns()
&& proof_generation == companion_wifi_ntp_operation.generation()
&& proven_at - companion_wifi_ntp_started <=
COMPANION_WIFI_NTP_TIMEOUT_MS) {
// The callback's epoch is freshness proof, not the value to install:
// the main loop may consume it one or more seconds later. Copy the
// current ESP system clock so an external RTC is not immediately
// written behind the time that SNTP established.
const time_t raw_system_time = time(nullptr);
if (mesh::tls_clock::timeIsValid(raw_system_time)
&& static_cast<uint64_t>(raw_system_time) <= UINT32_MAX) {
const uint32_t epoch = static_cast<uint32_t>(raw_system_time);
const bool moved_backward =
epoch < companion_wifi_ntp_previous_epoch;
rtc_clock.setCurrentTime(epoch);
if (moved_backward) rtc_clock.resetUniqueTime(epoch);
the_mesh.noteInternetClockSet();
const int64_t adjustment = static_cast<int64_t>(epoch)
- static_cast<int64_t>(companion_wifi_ntp_previous_epoch);
mesh::usbLoggingPort().printf(
"Companion WiFi NTP: clock synchronized (%lu, adjustment %+lld seconds)\r\n",
(unsigned long)epoch, (long long)adjustment);
companion_wifi_ntp_pending = false;
companion_wifi_ntp_operation.release();
companion_wifi_ntp_policy.noteSuccess(now);
return;
}
}
if (now - companion_wifi_ntp_started >=
COMPANION_WIFI_NTP_TIMEOUT_MS) {
mesh::usbLoggingPort().println(
"Companion WiFi NTP: timed out; retrying in 5 minutes");
companion_wifi_ntp_pending = false;
companion_wifi_ntp_operation.release();
companion_wifi_ntp_policy.noteFailure(now);
}
return;
}
if (!companion_wifi_ntp_policy.attemptDue(now)) return;
if (!companion_wifi_ntp_operation.tryAcquire()) {
companion_wifi_ntp_policy.noteBusy(now);
return;
}
companion_wifi_ntp_epoch.store(0, std::memory_order_release);
companion_wifi_ntp_proven_at.store(0, std::memory_order_release);
companion_wifi_ntp_proof_generation.store(0, std::memory_order_release);
companion_wifi_ntp_operation_generation.store(
companion_wifi_ntp_operation.generation(),
std::memory_order_release);
companion_wifi_ntp_started = now;
companion_wifi_ntp_previous_epoch = rtc_clock.getCurrentTime();
companion_wifi_ntp_pending = true;
esp_sntp_set_time_sync_notification_cb(noteCompanionWiFiNtpTime);
esp_sntp_set_sync_status(SNTP_SYNC_STATUS_RESET);
configTime(0, 0, "time.cloudflare.com", "time.google.com",
"pool.ntp.org");
mesh::usbLoggingPort().println(
"Companion WiFi NTP: requesting fresh UTC time");
}
static bool companionWiFiBluetoothActive() {
#if defined(COMPANION_EXCLUSIVE_WIFI_BLE)
const CompanionTransportMode active_mode =
companion_transport_boot_mode_loaded
? companion_transport_boot_mode
: getCompanionTransportMode();
return active_mode == CompanionTransportMode::Bluetooth;
#elif defined(BLE_PIN_CODE)
return true;
#else
return false;
#endif
}
const char* companionWiFiPowerSaveName(uint8_t mode) {
if (mode == mesh::wifi::kPowerSaveMin) return "min";
if (mode == mesh::wifi::kPowerSaveMax) return "max";
return "none";
}
static wifi_ps_type_t companionWiFiPowerSaveType(uint8_t mode) {
if (mode == mesh::wifi::kPowerSaveNone) return WIFI_PS_NONE;
if (mode == mesh::wifi::kPowerSaveMax) return WIFI_PS_MAX_MODEM;
return WIFI_PS_MIN_MODEM;
}
void reloadCompanionWiFiPowerSave() {
uint8_t configured = mesh::wifi::kDefaultPowerSave;
Preferences nvs;
// On a fresh install the namespace may not exist until WiFi setup runs.
// Create it here so loading the default does not log NVS_NOT_FOUND.
if (nvs.begin("mesh-wifi", false)) {
configured = nvs.getUChar("powersave", mesh::wifi::kDefaultPowerSave);
nvs.end();
}
companion_wifi_power_save = mesh::wifi::effectivePowerSave(
configured, companionWiFiBluetoothActive(),
mesh::wifi::kPrimaryEspNowRadio);
companion_wifi_power_save_loaded = true;
applyCompanionWiFiPowerSave();
}
uint8_t getCompanionWiFiPowerSave() {
if (!companion_wifi_power_save_loaded) reloadCompanionWiFiPowerSave();
return companion_wifi_power_save;
}
const char* getCompanionWiFiPowerSaveName() {
return companionWiFiPowerSaveName(getCompanionWiFiPowerSave());
}
bool applyCompanionWiFiPowerSave() {
if (!companion_wifi_power_save_loaded) {
reloadCompanionWiFiPowerSave();
return true;
}
if (WiFi.getMode() == WIFI_OFF) return true;
return esp_wifi_set_ps(
companionWiFiPowerSaveType(companion_wifi_power_save)) == ESP_OK;
}
CompanionWiFiPowerSaveResult setCompanionWiFiPowerSave(uint8_t mode) {
if (mode > mesh::wifi::kPowerSaveMax) {
return CompanionWiFiPowerSaveResult::InvalidMode;
}
if (mesh::wifi::kPrimaryEspNowRadio
&& mode == mesh::wifi::kPowerSaveMax) {
return CompanionWiFiPowerSaveResult::PrimaryEspNowConflict;
}
if (mesh::wifi::effectivePowerSave(
mode, companionWiFiBluetoothActive(),
mesh::wifi::kPrimaryEspNowRadio)
!= mode) {
return CompanionWiFiPowerSaveResult::BluetoothConflict;
}
Preferences nvs;
if (!nvs.begin("mesh-wifi", false)) {
return CompanionWiFiPowerSaveResult::StorageError;
}
const bool saved =
nvs.putUChar("powersave", mode) == sizeof(uint8_t);
nvs.end();
if (!saved) return CompanionWiFiPowerSaveResult::StorageError;
companion_wifi_power_save = mode;
companion_wifi_power_save_loaded = true;
if (WiFi.getMode() == WIFI_OFF) {
return CompanionWiFiPowerSaveResult::SavedForNextConnection;
}
return applyCompanionWiFiPowerSave()
? CompanionWiFiPowerSaveResult::Applied
: CompanionWiFiPowerSaveResult::SavedForNextConnection;
}
static void loadCompanionWiFiCredentials() {
companion_wifi_has_credentials = WiFiSetupPortal::loadStoredCredentials(
configured_wifi_ssid, sizeof(configured_wifi_ssid),
configured_wifi_password, sizeof(configured_wifi_password));
if (!companion_wifi_has_credentials
&& !WiFiSetupPortal::isPlaceholderSSID(WIFI_SSID)
&& mesh::cli::standaloneWiFiPasswordValid(WIFI_PWD)) {
strncpy(configured_wifi_ssid, WIFI_SSID, sizeof(configured_wifi_ssid) - 1);
configured_wifi_ssid[sizeof(configured_wifi_ssid) - 1] = '\0';
strncpy(configured_wifi_password, WIFI_PWD, sizeof(configured_wifi_password) - 1);
configured_wifi_password[sizeof(configured_wifi_password) - 1] = '\0';
companion_wifi_has_credentials = true;
}
}
void scheduleCompanionWiFiCredentialReload() {
loadCompanionWiFiCredentials();
mesh::wifi::networkPower().requestProbe();
companion_wifi_credential_reload_pending = true;
companion_wifi_credential_reload_at = millis() + 1500UL;
if (companion_wifi_credential_reload_at == 0) {
companion_wifi_credential_reload_at = 1;
}
}
bool isCompanionWiFiEnabled() {
return companion_wifi_requested;
}
bool hasCompanionWiFiCredentials() {
return companion_wifi_has_credentials;
}
bool isCompanionWiFiConnected() {
// The display describes the station link, not the lifecycle of the
// Companion TCP services. A valid station association remains the source
// of truth even if service bookkeeping is delayed or being recovered.
if (!companion_wifi_requested) return false;
if (WiFi.status() == WL_CONNECTED) return true;
wifi_ap_record_t access_point = {};
return esp_wifi_sta_get_ap_info(&access_point) == ESP_OK;
}
void noteCompanionWiFiDisplayState(CompanionWiFiDisplayState state) {
companion_wifi_display_state = state;
companion_wifi_display_frames++;
}
void formatCompanionWiFiDisplayStatus(char* reply, size_t reply_size) {
if (reply == nullptr || reply_size == 0) return;
static const char* const names[] = {
"not-rendered", "setup", "off", "ready", "not-configured",
"connecting",
};
uint8_t state = static_cast<uint8_t>(companion_wifi_display_state);
if (state >= sizeof(names) / sizeof(names[0])) state = 0;
wifi_ap_record_t access_point = {};
const bool idf_associated =
esp_wifi_sta_get_ap_info(&access_point) == ESP_OK;
snprintf(reply, reply_size,
"> state=%s, frames=%lu, display=%u/%02X, requested=%u, services=%u, "
"wl=%d, idf=%u, IP=%s",
names[state], (unsigned long)companion_wifi_display_frames,
companion_display_available ? 1U : 0U,
(unsigned int)companion_display_begin_status,
companion_wifi_requested ? 1U : 0U,
companion_wifi_active ? 1U : 0U, (int)WiFi.status(),
idf_associated ? 1U : 0U,
WiFi.localIP().toString().c_str());
}
#ifdef WITH_WEBCONFIG
void formatCompanionWiFiStatus(char* reply, size_t reply_size) {
const mesh::wifi::CompanionWiFiRuntimeState runtime = {
companion_wifi_requested,
companion_wifi_active,
companion_wifi_credential_reload_pending,
};
WebConfigServer::formatWiFiStatus(reply, reply_size, &runtime);
}
#endif
void requestCompanionWiFiSetup() {
if (mesh::wireless::control().blocked(mesh::wireless::WiFi)) return;
mesh::wifi::networkPower().requestProbe();
companion_wifi_setup_stop_requested = false;
companion_wifi_setup_requested = true;
}
void requestCompanionWiFiSetupStop() {
companion_wifi_setup_requested = false;
companion_wifi_setup_stop_requested = true;
}
bool setCompanionWiFiEnabled(bool enabled) {
if (enabled && mesh::wireless::control().blocked(mesh::wireless::WiFi)) return false;
#if defined(COMPANION_EXCLUSIVE_WIFI_BLE)
return selectCompanionTransportMode(enabled ? CompanionTransportMode::WiFi
: CompanionTransportMode::Bluetooth);
#else
CompanionNodePrefs* prefs = the_mesh.getNodePrefs();
const uint8_t previous = prefs->wifi_enabled;
prefs->wifi_enabled = enabled ? 1 : 0;
if (!the_mesh.savePrefs()) {
prefs->wifi_enabled = previous;
return false;
}
companion_wifi_requested = enabled;
if (enabled) mesh::wifi::networkPower().requestProbe();
if (!companion_wifi_requested && companion_wifi_active) {
companion_wifi_disable_in_progress = true;
}
return true;
#endif
}
bool toggleCompanionWiFi() {
#if defined(COMPANION_EXCLUSIVE_WIFI_BLE)
const bool current = getCompanionTransportMode() == CompanionTransportMode::WiFi;
#else
const bool current = companion_wifi_requested;
#endif
return setCompanionWiFiEnabled(!current) ? !current : current;
}
#if defined(WITH_WEBCONFIG) && defined(DISPLAY_CLASS) \
&& UI_WIFI_SETUP_HOME_PAGE != 1
static DisplayDriver* companion_setup_display = nullptr;
static unsigned long companion_setup_display_refresh = 0;
static bool hasLargeCompanionSetupDisplay() {
return companion_setup_display->width() >= 128
&& companion_setup_display->height() >= 128;
}
static int drawCompanionSetupValue(int y, int max_lines,
const char* value) {
static constexpr int margin = 6;
static constexpr int line_height = 13;
return mesh::ui::drawTextWrapped(
*companion_setup_display, margin, y,
companion_setup_display->width() - margin * 2, line_height,
max_lines, value != nullptr && value[0] != 0 ? value : "(not set)");
}
static void renderLargeCompanionSetupDisplay(const char* title,
const char* wifi_label,
const char* wifi_name,
const char* address,
bool connecting) {
static constexpr int line_height = 13;
companion_setup_display->drawTextCentered(
companion_setup_display->width() / 2, 4, title);
companion_setup_display->setCursor(6, 24);
companion_setup_display->print(wifi_label);
const int wifi_lines = drawCompanionSetupValue(38, 3, wifi_name);
int next_y = 38 + (wifi_lines > 0 ? wifi_lines : 1) * line_height + 8;
if (connecting) {
companion_setup_display->drawTextCentered(
companion_setup_display->width() / 2, next_y, "Please wait...");
return;
}
companion_setup_display->setCursor(6, next_y);
companion_setup_display->print("Open in browser:");
char url[32];
snprintf(url, sizeof(url), "http://%s/", address ? address : "");
drawCompanionSetupValue(next_y + 14, 2, url);
}
static void renderCompanionSetupDisplay() {
if (!companion_setup_display
|| static_cast<int32_t>(millis() - companion_setup_display_refresh) < 0) return;
companion_setup_display_refresh = millis() + 1000;
if (!companion_setup_display->isOn()) return;
companion_setup_display->startFrame();
companion_setup_display->setCompactText(false);
companion_setup_display->setTextSize(1);
companion_setup_display->setColor(UIColor::primary_txt);
char setup_ssid[33] = {0};
char setup_ip[16] = {0};
if (WebConfigServer::getSetupInfo(setup_ssid, sizeof(setup_ssid),
setup_ip, sizeof(setup_ip))) {
if (mesh::ui::drawWiFiSetupQr(
*companion_setup_display, setup_ssid, setup_ip)) {
companion_setup_display->endFrame();
return;
}
if (hasLargeCompanionSetupDisplay()) {
renderLargeCompanionSetupDisplay(
"WebUI setup", "Join open WiFi:", setup_ssid, setup_ip, false);
} else {
companion_setup_display->drawTextCentered(
companion_setup_display->width() / 2, 0, "WebUI setup");
companion_setup_display->setCursor(0, 14);
companion_setup_display->print("Join open WiFi:");
companion_setup_display->drawTextEllipsized(
0, 25, companion_setup_display->width(), setup_ssid);
companion_setup_display->setCursor(0, 39);
companion_setup_display->print("Open in browser:");
companion_setup_display->drawTextCentered(
companion_setup_display->width() / 2, 51, setup_ip);
}
} else if (WiFi.status() == WL_CONNECTED) {
const String ip = WiFi.localIP().toString();
if (hasLargeCompanionSetupDisplay()) {
renderLargeCompanionSetupDisplay(
"WebUI ready", "Join WiFi:", configured_wifi_ssid, ip.c_str(),
false);
} else {
companion_setup_display->drawTextCentered(
companion_setup_display->width() / 2, 0, "WebUI");
companion_setup_display->setCursor(0, 14);
companion_setup_display->print("Join WiFi:");
companion_setup_display->drawTextEllipsized(
0, 25, companion_setup_display->width(), configured_wifi_ssid);
companion_setup_display->setCursor(0, 39);
companion_setup_display->print("Open in browser:");
companion_setup_display->drawTextCentered(
companion_setup_display->width() / 2, 51, ip.c_str());
}
} else {
if (hasLargeCompanionSetupDisplay()) {
renderLargeCompanionSetupDisplay(
"WiFi connecting", "WiFi name:", configured_wifi_ssid, nullptr,
true);
} else {
companion_setup_display->drawTextCentered(
companion_setup_display->width() / 2, 8, "WiFi connecting");
companion_setup_display->setCursor(0, 25);
companion_setup_display->print("SSID:");
companion_setup_display->drawTextEllipsized(
0, 38, companion_setup_display->width(), configured_wifi_ssid);
companion_setup_display->drawTextCentered(
companion_setup_display->width() / 2, 52, "Please wait...");
}
}
companion_setup_display->setCompactText(false);
companion_setup_display->endFrame();
}
#endif
static bool saveCompanionWiFi(void*, const char* ssid, const char* password) {
if (!WiFiSetupPortal::saveStoredCredentials(ssid, password)) return false;
strncpy(configured_wifi_ssid, ssid, sizeof(configured_wifi_ssid) - 1);
configured_wifi_ssid[sizeof(configured_wifi_ssid) - 1] = '\0';
strncpy(configured_wifi_password, password ? password : "", sizeof(configured_wifi_password) - 1);
configured_wifi_password[sizeof(configured_wifi_password) - 1] = '\0';
companion_wifi_has_credentials = true;
return true;
}
#endif
/* WIFI TEXT CONSOLE - one client at a time on a dedicated port, separate from
Binary Companion (5000) and the mOTA seeder (5001). A Full Companion exposes
the same role CLI here as its USB terminal. Other OTA-enabled Companion
builds expose local maintenance commands and `ota ...` commands. */
#if defined(ESP32) && defined(WIFI_SSID) && defined(ENABLE_OTA)
#include <lwip/sockets.h>
#include <helpers/ota/OtaCli.h> // mesh::ota::handle_ota_command(line, reply, board)
#include <helpers/esp32/WiFiOtaSeeder.h>
#ifndef OTA_CONSOLE_TCP_PORT
#define OTA_CONSOLE_TCP_PORT 5002
#endif
static WiFiServer ota_console_server(OTA_CONSOLE_TCP_PORT);
static WiFiClient ota_console_client;
static char ota_console_line[MAX_TRANS_UNIT * 2 + 32];
static size_t ota_console_len = 0;
static bool ota_console_discard_line = false;
#if COMPANION_FEATURE_NETWORK_TERMINAL && COMPANION_FEATURE_READER
static size_t ota_console_try_reader_write(Stream& output,
const uint8_t* data, size_t size) {
// WiFiClient::write retries socket readiness with one-second waits.
// A paused chapter reader must instead leave its cursor for the next loop.
const int socket = static_cast<WiFiClient&>(output).fd();
if (socket < 0) return 0;
const int written = ::send(socket, data, size, MSG_DONTWAIT);
return written > 0 ? static_cast<size_t>(written) : 0;
}
#endif
#if COMPANION_FEATURE_NETWORK_TERMINAL && defined(ENABLE_USB_INTERFACE)
static mesh::UsbTcpTerminalHandoff ota_console_usb_handoff;
#endif
static void ota_console_clear_line() {
memset(ota_console_line, 0, sizeof(ota_console_line));
ota_console_len = 0;
}
static void ota_console_start() {
ota_console_server.begin();
#if COMPANION_FEATURE_NETWORK_TERMINAL
WIFI_DEBUG_PRINTLN("Full Companion terminal listening on :%d (nc <ip> %d)",
OTA_CONSOLE_TCP_PORT, OTA_CONSOLE_TCP_PORT);
#else
WIFI_DEBUG_PRINTLN("Local CLI and OTA console listening on :%d (nc <ip> %d)",
OTA_CONSOLE_TCP_PORT, OTA_CONSOLE_TCP_PORT);
#endif
}
#if COMPANION_FEATURE_NETWORK_TERMINAL
static void ota_console_release_terminal() {
the_mesh.exitNetworkTerminalMode(ota_console_client);
#if defined(ENABLE_USB_INTERFACE)
if (ota_console_usb_handoff.shouldRestoreAscii(
usb_serial_interface.getCompletedFrameCount())) {
enterUsbTerminalMode();
}
#endif
}
#endif
static void ota_console_stop() {
#if COMPANION_FEATURE_NETWORK_TERMINAL
ota_console_release_terminal();
#endif
if (ota_console_client) ota_console_client.stop();
ota_console_server.end();
ota_console_clear_line();
ota_console_discard_line = false;
}
static void ota_console_loop() {
if (!ota_console_client || !ota_console_client.connected()) {
#if COMPANION_FEATURE_NETWORK_TERMINAL
ota_console_release_terminal();
#endif
WiFiClient c = ota_console_server.available();
if (c) {
ota_console_client = c;
ota_console_clear_line();
ota_console_discard_line = false;
#if COMPANION_FEATURE_NETWORK_TERMINAL
#if defined(ENABLE_USB_INTERFACE)
const bool usb_ascii_selected = the_mesh.isTerminalMode();
const bool usb_input_idle = usb_terminal_line_len == 0
&& !usb_terminal_discard_line
&& !usb_binary_startup_probe.isActive();
if (!ota_console_usb_handoff.begin(
usb_ascii_selected, hasObservableActiveUsbTerminalClient(),
usb_input_idle,
usb_serial_interface.getCompletedFrameCount())) {
ota_console_client.print(
"ERROR: active USB currently owns the Full Companion terminal\r\n");
ota_console_client.stop();
return;
}
if (usb_ascii_selected) leaveUsbTerminalMode(false);
#endif
if (!the_mesh.enterNetworkTerminalMode(ota_console_client)) {
#if defined(ENABLE_USB_INTERFACE)
if (ota_console_usb_handoff.shouldRestoreAscii(
usb_serial_interface.getCompletedFrameCount())) {
enterUsbTerminalMode();
}
#endif
ota_console_client.print(
"ERROR: another client currently owns the Full Companion terminal\r\n");
ota_console_client.stop();
}
#if defined(ENABLE_USB_INTERFACE)
serviceUsbLoggingOwnership(mesh::isUsbLoggingEnabled());
#endif
#else
ota_console_client.print("Local CLI and OTA console - type `version` or `ota status`\r\n> ");
#endif
}
return;
}
#if COMPANION_FEATURE_NETWORK_TERMINAL
if (!the_mesh.isNetworkTerminalMode(ota_console_client)) {
#if defined(ENABLE_USB_INTERFACE)
ota_console_usb_handoff.cancel();
#endif
ota_console_client.print(
"\r\nERROR: terminal ownership moved to USB; closing\r\n");
ota_console_client.stop();
ota_console_clear_line();
ota_console_discard_line = false;
return;
}
#endif
while (ota_console_client.available()) {
char ch = (char)ota_console_client.read();
if (ota_console_discard_line) {
if (ch == '\r' || ch == '\n') {
ota_console_discard_line = false;
ota_console_client.print(" ERROR: command too long\r\n> ");
}
continue;
}
if (ch == '\r' || ch == '\n') {
if (ota_console_len == 0) continue; // ignore blanks / the CRLF pair
ota_console_line[ota_console_len] = 0;
#if COMPANION_FEATURE_NETWORK_TERMINAL
if (strcmp(ota_console_line, "disconnect") == 0) {
ota_console_client.print(" OK - disconnecting\r\n");
ota_console_release_terminal();
ota_console_client.stop();
ota_console_clear_line();
ota_console_discard_line = false;
return;
}
#if COMPANION_FEATURE_READER
the_mesh.handleTerminalCommand(ota_console_line, true,
ota_console_try_reader_write);
#else
the_mesh.handleTerminalCommand(ota_console_line);
#endif
#if COMPANION_FEATURE_READER
if (!mesh::readerCommandActive(&ota_console_client))
#endif
ota_console_client.print("> ");
#else
char reply[160]; reply[0] = 0;
if (!handleCompanionWirelessCommand(ota_console_line, reply, sizeof(reply), CompanionWirelessSource::Network)
&& !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> ");
#endif
ota_console_clear_line();
} else if (ota_console_len < sizeof(ota_console_line) - 1) {
ota_console_line[ota_console_len++] = ch;
} else {
ota_console_clear_line();
ota_console_discard_line = true;
}
}
}
#endif
#if defined(ESP32) && defined(WIFI_SSID)
static bool companionWiFiBackgroundSuspended() {
// MQTT publishes this flag only after its own TLS and SNTP work is quiet.
// A foreground OTA command can acquire the radio before its next pass.
return mesh::wifi::networkPower().backgroundSuspended()
&& !mesh::wifi::networkPower().foregroundActive()
&& !board.isOTAUpdateRunning();
}
static bool companionWiFiAutomaticRecoveryAllowed() {
// MQTT owns one combined outage/cooldown clock. Raising an automatic AP
// during that grace period would create a foreground owner indefinitely.
return !mesh::wifi::networkPower().managed();
}
#ifdef WITH_WEBCONFIG
static void startCompanionWiFiAutoWebConfig() {
companion_wifi_webconfig_start_pending = false;
if (!WebConfigServer::loadEnabled(true)) return;
if (companion_wifi_has_credentials && WiFi.status() != WL_CONNECTED) {
// The Companion owns the initial saved-SSID attempt. Do not ask the
// portal to start its separate fifteen-second AP fallback at boot.
companion_wifi_webconfig_start_pending = true;
return;
}
char web_reply[160];
the_mesh.startWebConfig(!companion_wifi_has_credentials, web_reply);
WIFI_DEBUG_PRINTLN("%s", web_reply);
}
static void serviceCompanionWiFiAutoWebConfig() {
if (!companion_wifi_webconfig_start_pending || !companion_wifi_requested
|| !companion_wifi_active || companionWiFiBackgroundSuspended()) return;
if (!WebConfigServer::loadEnabled(true)) {
companion_wifi_webconfig_start_pending = false;
return;
}
if (WiFi.status() != WL_CONNECTED
|| the_mesh.isWebConfigActiveOrStopping()) return;
startCompanionWiFiAutoWebConfig();
}
#endif
static void serviceCompanionWiFiNetworkPowerHold() {
bool active = wifi_interface.isConnected()
|| companion_wifi_credential_reload_pending
|| companion_wifi_setup_requested
|| board.isOTAUpdateRunning();
#ifdef ENABLE_OTA
active = active || ota_console_client.connected();
#endif
mesh::wifi::networkPower().setHold(
mesh::wifi::NetworkPowerHold::CompanionClient, active);
}
static void resetCompanionWiFiRecoveryState() {
wifi_reconnect_tracker = WiFiReconnectPolicy::Tracker();
wifi_setup_attempted = false;
last_wifi_setup_attempt = 0;
wifi_setup_recovery_mode = false;
}
static void serviceCompanionWiFiCredentialReload() {
if (!companion_wifi_credential_reload_pending
|| static_cast<int32_t>(millis() - companion_wifi_credential_reload_at) < 0) {
return;
}
if (companionWiFiBackgroundSuspended()) return;
#ifdef WITH_WEBCONFIG
if (the_mesh.isWebConfigActiveOrStopping()) return;
#endif
// Retain the pending reload until the operator's OTA/setup AP closes.
if (!mesh::wifi::stationMutationAllowed()) return;
companion_wifi_credential_reload_pending = false;
companion_wifi_credential_reload_at = 0;
companion_wifi_espnow_restore_pending = false;
loadCompanionWiFiCredentials();
resetCompanionWiFiRecoveryState();
if (!companion_wifi_requested || !companion_wifi_active
|| !companion_wifi_has_credentials) {
return;
}
WiFi.mode(WIFI_STA);
mesh::wifi::applyProtocolMask(WIFI_IF_STA);
mesh::wifi::setStationAutoReconnect(true);
WiFi.disconnect(false, false);
mesh::wifi::beginStation(
configured_wifi_ssid, configured_wifi_password);
wifi_reconnect_tracker.noteAttempt(millis());
WIFI_DEBUG_PRINTLN("WiFi credentials reloaded; reconnecting to saved SSID");
}
static void startCompanionWiFi() {
if (mesh::wireless::control().blocked(mesh::wireless::WiFi)) return;
if (companion_wifi_active) return;
if (companionWiFiBackgroundSuspended()) return;
if (!mesh::wifi::stationMutationAllowed()) return;
board.setInhibitSleep(true);
companion_wifi_espnow_restore_pending = false;
mesh::wifi::setStationAutoReconnect(true);
resetCompanionWiFiRecoveryState();
if (companion_wifi_has_credentials) {
WiFi.mode(WIFI_STA);
mesh::wifi::applyProtocolMask(WIFI_IF_STA);
wifi_reconnect_tracker.noteDisconnected(millis());
mesh::wifi::beginStation(
configured_wifi_ssid, configured_wifi_password);
}
#ifndef WITH_WEBCONFIG
else if (!wifiSetupPortal().begin(COMPANION_WIFI_SETUP_AP,
saveCompanionWiFi, nullptr)) {
WIFI_DEBUG_PRINTLN("WiFi setup: could not start setup portal");
}
#endif
#ifdef WITH_WEBCONFIG
startCompanionWiFiAutoWebConfig();
#endif
applyCompanionWiFiPowerSave();
wifi_interface.begin(TCP_PORT);
wifi_interface.enable();
#ifdef ENABLE_OTA
ota_console_start();
#endif
companion_wifi_active = true;
companion_wifi_services_stopped = false;
companion_wifi_background_stopped = false;
WIFI_DEBUG_PRINTLN("WiFi enabled by BOOT/GPIO 0 control");
}
static void cancelCompanionWiFiSession(void*) {
the_mesh.cancelSerialResponseStream(&wifi_interface);
the_mesh.cancelSerialOperationsForRoute(&wifi_interface);
interface_manager.forgetReplyRouteForDisconnected(&wifi_interface);
}
static void stopCompanionWiFiServices() {
if (companion_wifi_services_stopped) return;
wifi_interface.end();
#ifdef ENABLE_OTA
ota_console_stop();
mesh::ota::WiFiOtaSeeder::stop();
#endif
#ifdef WITH_MQTT_BRIDGE
the_mesh.stopMQTT();
#endif
companion_wifi_services_stopped = true;
}
static void stopCompanionInfrastructureWiFi() {
WiFi.setAutoReconnect(false);
if (mesh::wifi::espNowChannelConstrained()) {
// Primary ESP-NOW and a live optional bridge both share this driver.
// Drop only station association and keep their fixed mesh channel.
WiFi.disconnect(false, false);
WiFi.mode(WIFI_STA);
mesh::wifi::applyProtocolMask(WIFI_IF_STA);
companion_wifi_espnow_restore_pending =
mesh::wifi::restoreEspNowChannel() != ESP_OK;
companion_wifi_espnow_restore_at = millis() + 1000UL;
} else {
companion_wifi_espnow_restore_pending = false;
WiFi.disconnect(true, false);
WiFi.mode(WIFI_OFF);
}
}
static void serviceCompanionEspNowChannelRestore() {
if (!companion_wifi_espnow_restore_pending) return;
if (!mesh::wifi::espNowChannelConstrained()) {
companion_wifi_espnow_restore_pending = false;
return;
}
if (board.isOTAUpdateRunning()
|| mesh::wifi::networkPower().foregroundActive()
|| !mesh::wifi::stationMutationAllowed()) return;
const uint32_t now = millis();
if (static_cast<int32_t>(now - companion_wifi_espnow_restore_at) < 0) return;
// esp_wifi_set_channel may fail until an asynchronous disconnect/scan
// completes. Retry the channel only; repeated disconnects would prevent
// the shared ESP-NOW receiver from recovering during a long cooldown.
companion_wifi_espnow_restore_pending =
mesh::wifi::restoreEspNowChannel() != ESP_OK;
companion_wifi_espnow_restore_at = now + 1000UL;
}
static void serviceCompanionWiFiBackgroundPower() {
serviceCompanionEspNowChannelRestore();
if (!companion_wifi_active || !companion_wifi_requested) return;
if (companionWiFiBackgroundSuspended()) {
if (companion_wifi_background_stopped) return;
// Settings and MQTT's scheduling task stay enabled. These idle socket
// listeners and the common NTP request own no useful foreground work.
cancelCompanionWiFiNtp(false);
wifi_interface.end();
#ifdef ENABLE_OTA
ota_console_stop();
#endif
stopCompanionInfrastructureWiFi();
board.setInhibitSleep(mesh::wifi::espNowChannelConstrained());
companion_wifi_background_stopped = true;
WIFI_DEBUG_PRINTLN("Network unavailable; WiFi suspended until the next recovery probe");
return;
}
if (!companion_wifi_background_stopped) return;
if (!mesh::wifi::stationMutationAllowed()) return;
board.setInhibitSleep(true);
companion_wifi_espnow_restore_pending = false;
resetCompanionWiFiRecoveryState();
if (companion_wifi_has_credentials) {
WiFi.mode(WIFI_STA);
mesh::wifi::applyProtocolMask(WIFI_IF_STA);
mesh::wifi::setStationAutoReconnect(true);
wifi_reconnect_tracker.noteDisconnected(millis());
mesh::wifi::beginStation(configured_wifi_ssid, configured_wifi_password);
}
applyCompanionWiFiPowerSave();
wifi_interface.begin(TCP_PORT);
wifi_interface.enable();
#ifdef ENABLE_OTA
ota_console_start();
#endif
companion_wifi_background_stopped = false;
WIFI_DEBUG_PRINTLN("WiFi resumed for a bounded network recovery probe");
}
static bool finishStoppingCompanionWiFi() {
#ifdef COMPANION_RADIO_FULL
if (board.isOTAUpdateRunning()) {
char reply[160];
if (!board.stopOTAUpdate(reply)) return false;
}
#endif
cancelCompanionWiFiNtp(false);
stopCompanionWiFiServices();
#ifdef WITH_WEBCONFIG
if (the_mesh.isWebConfigActiveOrStopping()) {
the_mesh.stopWebConfig();
return false;
}
#else
if (wifiSetupPortal().isActive()) {
wifiSetupPortal().stop();
return false;
}
if (wifiSetupPortal().isStopping()) return false;
#endif
stopCompanionInfrastructureWiFi();
board.setInhibitSleep(false);
resetCompanionWiFiRecoveryState();
companion_wifi_active = false;
companion_wifi_background_stopped = false;
#ifdef WITH_WEBCONFIG
companion_wifi_webconfig_start_pending = false;
#endif
#if defined(MESH_PRIMARY_ESPNOW) && MESH_PRIMARY_ESPNOW
WIFI_DEBUG_PRINTLN(
"Companion WiFi services disabled; ESP-NOW mesh radio remains active");
#else
WIFI_DEBUG_PRINTLN("WiFi radio disabled by BOOT/GPIO 0 control");
#endif
return true;
}
static void serviceCompanionWiFiState() {
if (!companion_wifi_requested && companion_wifi_active) {
companion_wifi_disable_in_progress = true;
}
if (companion_wifi_disable_in_progress) {
if (!finishStoppingCompanionWiFi()) return;
companion_wifi_disable_in_progress = false;
}
if (companion_wifi_requested && !companion_wifi_active) {
startCompanionWiFi();
}
serviceCompanionWiFiBackgroundPower();
#ifdef WITH_WEBCONFIG
serviceCompanionWiFiAutoWebConfig();
#endif
}
#endif
#if defined(BLE_PIN_CODE)
static bool companion_bluetooth_initialized = false;
static char companion_bluetooth_start_failure[96] =
"Bluetooth has not started";
static uint32_t companion_bluetooth_start_at = 0;
static constexpr uint32_t COMPANION_BLUETOOTH_RETRY_MS = 5000UL;
#ifndef COMPANION_BLUETOOTH_BOOT_DELAY_MS
// Most boards can start Bluefruit as soon as the Companion store and radio
// are ready. A board profile may defer only its first start while a shared
// USB/radio power domain settles; retries continue to use the normal delay.
#define COMPANION_BLUETOOTH_BOOT_DELAY_MS 0UL
#endif
static uint8_t companion_bluetooth_session_address[
mesh::companion::BLUETOOTH_MAC_BYTES] = {};
static bool companion_bluetooth_session_address_ready = false;
static bool companion_bluetooth_identity_prepared = false;
static bool companion_bluetooth_clear_bonds_this_boot = false;
static uint8_t companion_bluetooth_session_mode =
mesh::companion::BLUETOOTH_MAC_DEFAULT;
static uint8_t companion_bluetooth_session_stealth_mode =
mesh::companion::BLUETOOTH_STEALTH_OFF;
static uint8_t companion_bluetooth_saved_session_address[
mesh::companion::BLUETOOTH_MAC_BYTES] = {};
static bool companion_bluetooth_rotation_arm_pending = false;
static bool companion_bluetooth_stealth_transition_pending = false;
static mesh::companion::BluetoothPeerIdentity
companion_bluetooth_stealth_transition_peer;
static bool companion_bluetooth_stealth_recovery_pending = false;
static uint32_t companion_bluetooth_identity_retry_at = 0;
static bool prepareCompanionBluetoothIdentity(
CompanionNodePrefs* prefs) {
if (companion_bluetooth_identity_prepared) return true;
if (prefs == nullptr) return false;
bool address_rotated = false;
if (!the_mesh.prepareBluetoothMacForBoot(address_rotated)) {
mesh::usbLoggingPort().println(
"Companion: Bluetooth address preparation save failed");
return false;
}
companion_bluetooth_identity_prepared = true;
companion_bluetooth_session_mode = prefs->bluetooth_mac_mode;
companion_bluetooth_session_stealth_mode = prefs->bluetooth_stealth_mode;
companion_bluetooth_clear_bonds_this_boot = address_rotated
|| companion_bluetooth_session_stealth_mode
== mesh::companion::BLUETOOTH_STEALTH_PAIRING;
memcpy(companion_bluetooth_saved_session_address,
prefs->bluetooth_mac,
sizeof(companion_bluetooth_saved_session_address));
if (address_rotated) {
mesh::usbLoggingPort().println(
"Companion: Bluetooth address changed for this boot");
}
return true;
}
static const uint8_t* companionBluetoothAddress(
const CompanionNodePrefs* prefs, bool& clear_bonds) {
clear_bonds = companion_bluetooth_clear_bonds_this_boot;
if (prefs == nullptr
|| !mesh::companion::isValidBluetoothMacMode(
prefs->bluetooth_mac_mode)) {
mesh::usbLoggingPort().println(
"Companion: invalid Bluetooth MAC mode; using factory address");
return nullptr;
}
if (prefs->bluetooth_mac_mode
== mesh::companion::BLUETOOTH_MAC_RANDOM_EVERY_BOOT) {
if (!companion_bluetooth_session_address_ready) {
fast_rng.random(companion_bluetooth_session_address,
sizeof(companion_bluetooth_session_address));
mesh::companion::makeRandomStaticBluetoothMac(
companion_bluetooth_session_address);
companion_bluetooth_session_address_ready = true;
}
// A new local identity cannot safely reuse bonds created for the
// previous boot's identity.
clear_bonds = true;
return companion_bluetooth_session_address;
}
if (mesh::companion::bluetoothMacModeUsesSavedAddress(
prefs->bluetooth_mac_mode)) {
if (mesh::companion::isValidBluetoothMac(prefs->bluetooth_mac)) {
return prefs->bluetooth_mac;
}
mesh::usbLoggingPort().println(
"Companion: invalid saved Bluetooth MAC; using factory address");
}
return nullptr;
}
static void scheduleCompanionBluetoothRetry() {
companion_bluetooth_start_at = millis() + COMPANION_BLUETOOTH_RETRY_MS;
if (companion_bluetooth_start_at == 0) companion_bluetooth_start_at = 1;
}
static void startCompanionBluetooth() {
if (companion_bluetooth_initialized) return;
companion_bluetooth_start_at = 0;
mesh::usbLoggingPort().println("Companion: starting Bluetooth");
CompanionNodePrefs* prefs = the_mesh.getNodePrefs();
if (!prepareCompanionBluetoothIdentity(prefs)) {
strncpy(companion_bluetooth_start_failure,
"Bluetooth identity preparation could not save",
sizeof(companion_bluetooth_start_failure) - 1);
companion_bluetooth_start_failure[
sizeof(companion_bluetooth_start_failure) - 1] = 0;
mesh::usbLoggingPort().println(
"Companion: Bluetooth identity preparation failed; retrying");
scheduleCompanionBluetoothRetry();
return;
}
if (!interface_manager.addInterface(InterfaceType::Bluetooth,
&bluetooth_interface)) {
strncpy(companion_bluetooth_start_failure,
"no free Companion interface slot",
sizeof(companion_bluetooth_start_failure) - 1);
companion_bluetooth_start_failure[
sizeof(companion_bluetooth_start_failure) - 1] = 0;
mesh::usbLoggingPort().println(
"Companion: no interface slot available for Bluetooth; retrying");
scheduleCompanionBluetoothRetry();
return;
}
const bool custom_bluetooth_name =
mesh::companion::hasCustomBluetoothName(prefs->bluetooth_name);
bool clear_bonds = false;
const uint8_t* bluetooth_address =
companionBluetoothAddress(prefs, clear_bonds);
const bool stealth_pair_once = companion_bluetooth_session_stealth_mode
== mesh::companion::BLUETOOTH_STEALTH_PAIRING;
mesh::companion::BluetoothPeerIdentity bonded_only_peer;
const mesh::companion::BluetoothPeerIdentity* bonded_only_peer_ptr =
nullptr;
if (companion_bluetooth_session_stealth_mode
== mesh::companion::BLUETOOTH_STEALTH_PAIRED) {
bonded_only_peer.type = prefs->bluetooth_stealth_peer_type;
memcpy(bonded_only_peer.address, prefs->bluetooth_stealth_peer,
sizeof(bonded_only_peer.address));
if (mesh::companion::isValidBluetoothPeerIdentity(
bonded_only_peer)) {
bonded_only_peer_ptr = &bonded_only_peer;
}
}
if (!bluetooth_interface.begin(custom_bluetooth_name ? "" : BLE_NAME_PREFIX,
custom_bluetooth_name
? prefs->bluetooth_name
: prefs->node_name,
the_mesh.getBLEPin(), bluetooth_address,
clear_bonds, stealth_pair_once,
bonded_only_peer_ptr)) {
strncpy(companion_bluetooth_start_failure,
#if defined(NRF52_PLATFORM)
bluetooth_interface.beginFailure(),
#else
"Bluetooth initialization failed",
#endif
sizeof(companion_bluetooth_start_failure) - 1);
companion_bluetooth_start_failure[
sizeof(companion_bluetooth_start_failure) - 1] = 0;
interface_manager.removeInterface(&bluetooth_interface);
#if defined(NRF52_PLATFORM)
// A partly initialized Bluefruit stack cannot be started again safely.
// Leave the interface unregistered so the UI cannot advertise a PIN
// for it, and avoid consuming more heap every five seconds.
mesh::usbLoggingPort().println(
"Companion: Bluetooth initialization failed; reboot required");
#else
mesh::usbLoggingPort().println(
"Companion: Bluetooth initialization failed; retrying in 5 seconds");
scheduleCompanionBluetoothRetry();
#endif
return;
}
#if defined(ESP32_PLATFORM) && COMPANION_BT_MODEM_SLEEP_AVAILABLE
applyCompanionBluetoothSleep(
the_mesh.getNodePrefs()->powersaving_enabled != 0, false);
#endif
companion_bluetooth_initialized = true;
companion_bluetooth_start_failure[0] = 0;
if (interface_manager.isEnabled() && the_mesh.isBluetoothEnabledPreference()) {
bluetooth_interface.enable();
}
}
static void serviceDeferredCompanionBluetooth() {
if (companion_bluetooth_initialized) return;
if (companion_bluetooth_start_at == 0
|| (int32_t)(millis() - companion_bluetooth_start_at) < 0) return;
startCompanionBluetooth();
}
#if defined(NRF52_PLATFORM)
static void serviceCompanionBluetoothName() {
if (!companion_bluetooth_initialized) return;
static uint32_t last_check = 0;
const uint32_t now = millis();
if ((uint32_t)(now - last_check) < 250) return;
last_check = now;
const CompanionNodePrefs* prefs = the_mesh.getNodePrefs();
if (prefs == nullptr) return;
const bool custom =
mesh::companion::hasCustomBluetoothName(prefs->bluetooth_name);
bluetooth_interface.refreshName(custom ? "" : BLE_NAME_PREFIX,
custom ? prefs->bluetooth_name
: prefs->node_name);
}
#endif
static void serviceCompanionBluetoothIdentity() {
if (!companion_bluetooth_initialized) return;
if (bluetooth_interface.takeBondedOnlyRecovery()) {
companion_bluetooth_stealth_recovery_pending = true;
companion_bluetooth_identity_retry_at = 0;
}
const uint32_t now = millis();
if (companion_bluetooth_identity_retry_at != 0
&& (int32_t)(now - companion_bluetooth_identity_retry_at) < 0) {
return;
}
if (companion_bluetooth_stealth_recovery_pending) {
if (the_mesh.resetBluetoothStealthPairing()) {
mesh::usbLoggingPort().println(
"Companion: saved stealth bond unavailable; reopening pairing");
board.reboot();
} else {
companion_bluetooth_identity_retry_at =
now + COMPANION_BLUETOOTH_RETRY_MS;
if (companion_bluetooth_identity_retry_at == 0) {
companion_bluetooth_identity_retry_at = 1;
}
mesh::usbLoggingPort().println(
"Companion: stealth recovery save failed; retrying");
}
return;
}
const CompanionNodePrefs* prefs = the_mesh.getNodePrefs();
bool successful_connection = false;
if (companion_bluetooth_session_stealth_mode
== mesh::companion::BLUETOOTH_STEALTH_PAIRING) {
if (!companion_bluetooth_stealth_transition_pending) {
mesh::companion::BluetoothPeerIdentity peer;
if (bluetooth_interface.takeSuccessfulConnection(&peer)) {
successful_connection = true;
companion_bluetooth_stealth_transition_peer = peer;
companion_bluetooth_stealth_transition_pending = true;
}
}
if (companion_bluetooth_stealth_transition_pending) {
if (prefs == nullptr
|| prefs->bluetooth_stealth_mode
!= mesh::companion::BLUETOOTH_STEALTH_PAIRING
|| !mesh::companion::bluetoothMacPoliciesMatch(
prefs->bluetooth_mac_mode, companion_bluetooth_session_mode)
|| memcmp(prefs->bluetooth_mac,
companion_bluetooth_saved_session_address,
sizeof(companion_bluetooth_saved_session_address)) != 0) {
// A command changed identity while this session was running. Keep
// it usable until reboot applies the requested settings.
bluetooth_interface.cancelStealthPairingTransition();
companion_bluetooth_stealth_transition_pending = false;
companion_bluetooth_session_stealth_mode =
mesh::companion::BLUETOOTH_STEALTH_OFF;
companion_bluetooth_identity_retry_at = 0;
} else if (the_mesh.saveBluetoothStealthPeer(
companion_bluetooth_stealth_transition_peer)) {
companion_bluetooth_stealth_transition_pending = false;
companion_bluetooth_session_stealth_mode =
mesh::companion::BLUETOOTH_STEALTH_PAIRED;
companion_bluetooth_identity_retry_at = 0;
if (bluetooth_interface.enableBondedOnlyAdvertising(
companion_bluetooth_stealth_transition_peer)) {
mesh::usbLoggingPort().println(
"Companion: stealth Bluetooth now accepts its bonded peer only");
} else {
companion_bluetooth_stealth_recovery_pending = true;
}
} else {
companion_bluetooth_identity_retry_at =
now + COMPANION_BLUETOOTH_RETRY_MS;
if (companion_bluetooth_identity_retry_at == 0) {
companion_bluetooth_identity_retry_at = 1;
}
mesh::usbLoggingPort().println(
"Companion: stealth peer save failed; retrying");
}
}
} else {
successful_connection = bluetooth_interface.takeSuccessfulConnection();
}
// Stealth and MAC rotation consume the same authenticated event; neither
// policy may swallow it before the other has persisted its state.
if (!mesh::companion::bluetoothMacModeIsRandomAfterConnect(
companion_bluetooth_session_mode)) {
return;
}
if (successful_connection) {
companion_bluetooth_rotation_arm_pending = true;
}
if (!companion_bluetooth_rotation_arm_pending) return;
if (prefs == nullptr
|| !mesh::companion::bluetoothMacModeIsRandomAfterConnect(
prefs->bluetooth_mac_mode)
|| memcmp(prefs->bluetooth_mac,
companion_bluetooth_saved_session_address,
sizeof(companion_bluetooth_saved_session_address)) != 0) {
companion_bluetooth_rotation_arm_pending = false;
return;
}
if (the_mesh.armBluetoothMacRotationAfterConnection()) {
companion_bluetooth_rotation_arm_pending = false;
companion_bluetooth_identity_retry_at = 0;
mesh::usbLoggingPort().println(
"Companion: Bluetooth rotation armed for next boot");
} else {
companion_bluetooth_identity_retry_at =
now + COMPANION_BLUETOOTH_RETRY_MS;
if (companion_bluetooth_identity_retry_at == 0) {
companion_bluetooth_identity_retry_at = 1;
}
mesh::usbLoggingPort().println(
"Companion: Bluetooth rotation marker save failed; retrying");
}
}
#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.cancelSerialResponseStream(&bluetooth_interface);
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;
if (!the_mesh.setBluetoothEnabledPreference(false)) {
companion_bluetooth_off_at = 0;
mesh::usbLoggingPort().println(
"Bluetooth off cancelled: preference save failed");
return;
}
interface_manager.setBluetoothAutoEnable(false);
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 (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) {
if (!companion_bluetooth_initialized) {
snprintf(reply, reply_size,
"Error: Bluetooth unavailable in this boot: %s",
companion_bluetooth_start_failure);
return true;
}
if (!mesh::wireless::control().allowService(mesh::wireless::Bluetooth)) {
snprintf(reply, reply_size, "Error: 2.4ghz is off or changing; use set 2.4ghz on first");
return true;
}
const bool previous_preference = the_mesh.isBluetoothEnabledPreference();
if (!the_mesh.setBluetoothEnabledPreference(true)) {
snprintf(reply, reply_size, "Error: Bluetooth preference save failed; unchanged");
return true;
}
interface_manager.setBluetoothAutoEnable(true);
interface_manager.enableBluetooth();
if (interface_manager.isBluetoothEnabled()) {
companion_bluetooth_off_at = 0;
snprintf(reply, reply_size, "OK - Bluetooth on (saved)");
} else if (the_mesh.setBluetoothEnabledPreference(previous_preference)) {
interface_manager.setBluetoothAutoEnable(previous_preference);
snprintf(reply, reply_size, "Error: Bluetooth enable failed; preference restored");
} else {
snprintf(reply, reply_size,
"Error: Bluetooth enable failed; rollback save failed (saved on)");
}
return true;
}
if (!interface_manager.isBluetoothEnabled()) {
if (!the_mesh.setBluetoothEnabledPreference(false)) {
snprintf(reply, reply_size, "Error: Bluetooth preference save failed; unchanged");
return true;
}
companion_bluetooth_off_at = 0;
interface_manager.setBluetoothAutoEnable(false);
snprintf(reply, reply_size, "OK - Bluetooth already off (saved)");
return true;
}
const bool force = action == CompanionBluetoothCommand::ForceOff;
if (!force && mesh::wireless::control().pending()) {
snprintf(reply, reply_size, "Error: wireless change pending; get 2.4ghz for status");
return true;
}
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) {
if (!the_mesh.setBluetoothEnabledPreference(false)) {
snprintf(reply, reply_size, "Error: Bluetooth preference save failed; unchanged");
return true;
}
interface_manager.setBluetoothAutoEnable(false);
disableCompanionBluetoothForCli();
snprintf(reply, reply_size, "OK - Bluetooth off (saved)");
} 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 (save pending)");
}
#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(
"Full Companion memory %s: heap=%u largest_internal=%u psram_free=%u/%u offline_queue=%d\r\n",
stage, (unsigned)ESP.getFreeHeap(),
(unsigned)heap_caps_get_largest_free_block(MALLOC_CAP_INTERNAL),
(unsigned)ESP.getFreePsram(), (unsigned)ESP.getPsramSize(),
the_mesh.getOfflineQueueCapacity());
}
#endif
class CompanionWirelessBackend : public mesh::wireless::Backend {
public:
uint8_t available() const override {
uint8_t mask = 0;
#if defined(ESP32) && defined(WIFI_SSID)
#if defined(COMPANION_EXCLUSIVE_WIFI_BLE)
if (companionTransportWiFiActiveAtBoot())
#endif
mask |= mesh::wireless::WiFi;
#endif
#if defined(BLE_PIN_CODE)
if (companion_bluetooth_initialized) mask |= mesh::wireless::Bluetooth;
#endif
#if defined(MESH_PRIMARY_ESPNOW) && MESH_PRIMARY_ESPNOW
mask |= mesh::wireless::EspNow;
#endif
return mask;
}
uint8_t enabled() const override {
uint8_t mask = 0;
#if defined(ESP32) && defined(WIFI_SSID)
#ifdef COMPANION_RADIO_FULL
if (board.isOTAUpdateRunning()) mask |= mesh::wireless::WiFi;
#endif
if (companion_wifi_requested || companion_wifi_active) mask |= mesh::wireless::WiFi;
#ifdef WITH_WEBCONFIG
if (the_mesh.isWebConfigActiveOrStopping()) mask |= mesh::wireless::WiFi;
#endif
#endif
#if defined(BLE_PIN_CODE)
if (interface_manager.isBluetoothEnabled()) mask |= mesh::wireless::Bluetooth;
#endif
#if defined(MESH_PRIMARY_ESPNOW) && MESH_PRIMARY_ESPNOW
if (radio_driver.isEnabled()) mask |= mesh::wireless::EspNow;
#endif
return mask;
}
uint8_t clients() const override {
using namespace mesh::wireless;
if (!interface_manager.isEnabled()) return 0;
uint8_t mask = 0;
if (interface_manager.isInterfaceConnected(InterfaceType::Bluetooth)) mask |= Bluetooth;
if (interface_manager.isInterfaceConnected(InterfaceType::WiFi)) mask |= mesh::wireless::WiFi;
if (interface_manager.isInterfaceConnected(InterfaceType::Ethernet)) mask |= Independent;
#if COMPANION_FEATURE_TEXT_TERMINAL && (COMPANION_FEATURE_NETWORK_TERMINAL || defined(WITH_WEBCONFIG))
if (the_mesh.isAnyNetworkTerminalMode()) mask |= mesh::wireless::WiFi;
#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)))
#if COMPANION_FEATURE_USB_MOTA_SOURCE
if (!usb_mota_mode)
#endif
if (usb_serial_interface.isEnabled() && isUsbTerminalDataConnected()) mask |= Independent;
#endif
return mask;
}
bool replyBusy() const override {
return interface_manager.hasPendingIO();
}
mesh::wireless::Result set(uint8_t service, bool on) override {
using namespace mesh::wireless;
#if defined(BLE_PIN_CODE)
if (service == Bluetooth) {
companion_bluetooth_off_at = 0;
if (on) interface_manager.enableBluetooth();
else disableCompanionBluetoothForCli();
return interface_manager.isBluetoothEnabled() == on ? Result::Done : Result::Failed;
}
#endif
#if defined(ESP32) && defined(WIFI_SSID)
if (service == mesh::wireless::WiFi) {
companion_wifi_requested = on;
if (!on) {
companion_wifi_setup_requested = false;
// Sockets stop before the asynchronous web teardown finishes. Keep
// this latched so a replacement on request completes that teardown
// before startCompanionWiFi's active-state shortcut can skip restart.
companion_wifi_disable_in_progress = true;
if (!finishStoppingCompanionWiFi()) return Result::Pending;
companion_wifi_disable_in_progress = false;
#if defined(MESH_PRIMARY_ESPNOW) && MESH_PRIMARY_ESPNOW
if (!radio_driver.isEnabled()) ::WiFi.mode(WIFI_OFF);
#endif
} else {
// Finish a previously requested teardown before opening new sockets.
if (companion_wifi_disable_in_progress) {
if (!finishStoppingCompanionWiFi()) return Result::Pending;
companion_wifi_disable_in_progress = false;
}
startCompanionWiFi();
}
return companion_wifi_active == on ? Result::Done : Result::Failed;
}
#endif
#if defined(MESH_PRIMARY_ESPNOW) && MESH_PRIMARY_ESPNOW
if (service == EspNow) {
if (on) radio_driver.init();
else {
radio_driver.end();
if (!(enabled() & mesh::wireless::WiFi)) {
::WiFi.setAutoReconnect(false);
::WiFi.mode(WIFI_OFF);
}
}
return radio_driver.isEnabled() == on ? Result::Done : Result::Failed;
}
#endif
return Result::Failed;
}
};
static CompanionWirelessBackend companion_wireless;
#if defined(ESP32_PLATFORM) && defined(ENABLE_USB_INTERFACE) \
&& defined(ARDUINO_USB_CDC_ON_BOOT) && ARDUINO_USB_CDC_ON_BOOT \
&& !defined(ETHERNET_ENABLED) && !defined(SERIAL_RX)
static bool companionNativeUsbLightSleepReady() {
// Full compiles every transport, but a compiled driver is not an active
// receiver. Preserve live services and pending replies while permitting
// the same short sleep slice when those services have been turned off.
uint8_t enabled_services = companion_wireless.enabled();
#if defined(ESP32) && defined(WIFI_SSID)
// The user's saved WiFi preference and an idle WebConfig listener remain
// enabled during automatic suspension. Only a confirmed physical power-off
// releases their sleep blocker; foreground work and shared ESP-NOW retain it.
if (companion_wifi_background_stopped
&& mesh::wifi::networkPower().backgroundSuspended()
&& !mesh::wifi::networkPower().foregroundActive()
&& !mesh::wifi::espNowChannelConstrained()) {
wifi_mode_t parked_mode = WIFI_MODE_NULL;
const esp_err_t parked_result = esp_wifi_get_mode(&parked_mode);
if ((parked_result == ESP_OK && parked_mode == WIFI_MODE_NULL)
|| parked_result == ESP_ERR_WIFI_NOT_INIT
|| parked_result == ESP_ERR_WIFI_NOT_STARTED) {
enabled_services &= static_cast<uint8_t>(~mesh::wireless::WiFi);
}
}
#endif
if (enabled_services != 0 || interface_manager.hasPendingIO()
|| board.isOTAUpdateRunning() || board.isRadioTestActive()
|| sensors.gpsUsesSerialUart(1)) return false;
#if defined(BLE_PIN_CODE) && !CONFIG_IDF_TARGET_ESP32C6
// Stopped advertising is not a stopped controller. Manual light sleep
// requires the RF driver to be disabled, including during BLE teardown.
if (esp_bt_controller_get_status() == ESP_BT_CONTROLLER_STATUS_ENABLED)
return false;
#if defined(CONFIG_BLUEDROID_ENABLED) && !defined(MESH_USE_NIMBLE_ARDUINO)
if (esp_bluedroid_get_status() == ESP_BLUEDROID_STATUS_ENABLED) return false;
#endif
#endif
#if defined(ESP32) && defined(WIFI_SSID)
// Catch an unfinished teardown even if the logical WiFi owner is off.
wifi_mode_t mode = WIFI_MODE_NULL;
const esp_err_t result = esp_wifi_get_mode(&mode);
if (result == ESP_OK) return mode == WIFI_MODE_NULL;
return result == ESP_ERR_WIFI_NOT_INIT || result == ESP_ERR_WIFI_NOT_STARTED;
#else
return true;
#endif
}
#endif
bool handleCompanionWirelessCommand(const char* command, char* reply, size_t size,
CompanionWirelessSource source) {
#if defined(BLE_PIN_CODE)
const auto wireless_command = mesh::wireless::parse(command);
if (reply && size && companion_bluetooth_off_at != 0
&& (wireless_command.action == mesh::wireless::Action::Off
|| (wireless_command.scope == mesh::wireless::All
&& wireless_command.action == mesh::wireless::Action::On))) {
snprintf(reply, size, "Error: Bluetooth change pending; get bluetooth for status");
return true;
}
#endif
uint8_t requester = 0;
if (source == CompanionWirelessSource::Usb) requester = mesh::wireless::Independent;
else if (source == CompanionWirelessSource::Network) requester = mesh::wireless::WiFi;
else if (source == CompanionWirelessSource::Framed) {
BaseSerialInterface* route = interface_manager.captureReplyRoute();
if (interface_manager.isReplyRouteAvailable(route)) {
#if defined(BLE_PIN_CODE)
if (route == &bluetooth_interface) requester = mesh::wireless::Bluetooth;
else
#endif
#if defined(ESP32) && defined(WIFI_SSID)
if (route == &wifi_interface) requester = mesh::wireless::WiFi;
else
#endif
requester = mesh::wireless::Independent;
}
}
return mesh::wireless::control().handle(command, reply, size, millis(), requester);
}
void setup() {
#if MESH_ESP32_HWCDC_SESSION_GUARD
// HWCDC's RX queue and TX ring are resized before Serial.begin() creates its
// mutex and enables the USB ISR. This is before the nRF52 ordering below:
// prepareUsbLoggingPort() is otherwise a no-op outside ESP32 HWCDC.
mesh::prepareUsbLoggingPort();
#endif
// On nRF52 TinyUSB, bring the primary CDC transport up before any board or
// wireless preparation. Bluefruit preserves USB power state while it
// enables the SoftDevice; a late first Serial.begin() can leave that enable
// path in an invalid state on native-USB boards such as the T1000-E.
Serial.begin(115200);
mesh::wireless::control().begin(companion_wireless);
#if !MESH_ESP32_HWCDC_SESSION_GUARD
mesh::prepareUsbLoggingPort();
#endif
#if MESH_PACKET_LOGGING
mesh::serialLogBegin();
#endif
mesh::beginUsbLoggingPort();
board.begin();
#ifdef HAS_EXTERNAL_WATCHDOG
external_watchdog.begin();
#endif
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
#if defined(NRF52_PLATFORM)
// InternalFileSystem::begin() auto-formats the entire primary store after a
// mount failure. The base mount plus a full raw scan distinguishes a virgin
// erased device from nonblank/corrupt identity storage. Only proven-erased
// media may be initialized automatically. A nonblank failure gets three
// additional mounts three seconds apart before the unusable store is erased
// so the node can still recover without physical access.
const mesh::storage::InternalSecondaryFsBootResult primary_fs_boot =
mesh::storage::beginInternalPrimaryFilesystemSafely(
InternalFS,
#if defined(EXTRAFS) && !defined(QSPIFLASH)
ResilientInternalExtraFS::primaryConfig()
#else
nullptr
#endif
);
if (!mesh::storage::internalPrimaryFilesystemReady(primary_fs_boot)) {
mesh::storage::RamFallbackFileSystem* ram_primary_fs =
mesh::storage::createRamFallbackFileSystem();
if (ram_primary_fs != nullptr) {
MESH_DEBUG_PRINTLN(
"InternalFS: physical storage unusable; running from volatile RAM filesystem");
store.useVolatilePrimaryFS(ram_primary_fs->filesystem());
} else {
MESH_DEBUG_PRINTLN(
"InternalFS: physical storage and RAM fallback initialization failed");
store.markPrimaryFSUnavailable();
}
}
#else
InternalFS.begin();
#endif
#elif defined(RP2040_PLATFORM)
LittleFS.begin();
#elif defined(ESP32)
SPIFFS.begin(true);
#else
#error "need to define filesystem"
#endif
#ifdef DISPLAY_CLASS
// Load only the display policy before radio and secondary-storage startup.
FILESYSTEM* display_prefs_fs = store.getPrimaryFS();
#if defined(NRF52_PLATFORM)
// Never open files on a primary store whose mount and RAM fallback failed.
if (!mesh::storage::internalPrimaryFilesystemReady(primary_fs_boot)
&& !store.isVolatilePrimaryFS()) display_prefs_fs = nullptr;
#endif
mesh::ui::loadDisplayPowerSettings(display_prefs_fs,
#ifdef BLE_PIN_CODE
true,
#else
false,
#endif
true);
mesh::ui::StartupScreen startup_screen;
#endif
#ifdef DISPLAY_CLASS
DisplayDriver* disp = NULL;
if (display.begin()) {
disp = &display;
#if defined(ESP32) && defined(WIFI_SSID) && defined(WITH_WEBCONFIG) \
&& UI_WIFI_SETUP_HOME_PAGE != 1
companion_setup_display = disp;
#endif
startup_screen.begin(disp,
board.isExternalPowered() || board.isUsbHostConnected());
}
#if defined(ESP32) && defined(WIFI_SSID)
companion_display_available = disp != NULL;
#if defined(SENSECAP_INDICATOR_LORA)
companion_display_begin_status = display.beginStatus();
#else
companion_display_begin_status = companion_display_available ? 1 : 0;
#endif
#endif
#endif
int radioinit_attempts = 0;
bool radio_available = false;
while (!(radio_available = radio_init())) {
++radioinit_attempts;
MESH_DEBUG_PRINTLN("Radio init failed! (attempt %d)", radioinit_attempts);
if (radioinit_attempts >= 3) {
#if defined(RECOVERABLE_EXTERNAL_RADIO)
// Continue into a recovery-capable Companion instead of trapping native
// USB in a reboot loop. The main loop retries the radio independently.
mesh::usbLoggingPort().println(
"Radio unavailable; starting companion management services");
break;
#else
MESH_DEBUG_PRINTLN("Radio init failed 3x - rebooting");
board.reboot();
#endif
}
delay(500);
}
#ifdef RECOVERABLE_EXTERNAL_RADIO
companion_radio_available = radio_available;
companion_radio_retry_at = millis() + COMPANION_RADIO_RETRY_MS;
fast_rng.begin(radio_available ? radio_driver.getRngSeed()
: companionFallbackRngSeed());
#ifdef DISPLAY_CLASS
if (!radio_available && disp != NULL) {
disp->startFrame();
disp->drawTextCentered(disp->width() / 2, 20, "Radio unavailable");
disp->drawTextCentered(disp->width() / 2, 40, "Starting interfaces...");
disp->endFrame();
}
#endif
#else
fast_rng.begin(radio_driver.getRngSeed());
#endif
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
#if defined(QSPIFLASH)
if (!QSPIFlash.begin()) {
// debug output might not be available at this point, might be too early. maybe should fall back to InternalFS here?
MESH_DEBUG_PRINTLN("CustomLFS_QSPIFlash: failed to initialize");
#if defined(NRF52_PLATFORM)
// A failed nrfx QSPI init can leave its IRQ pending/enabled. That IRQ
// storm starves BLE and the main loop even though this build can fall
// back to internal storage. Fully release the peripheral on failure.
NVIC_DisableIRQ(QSPI_IRQn);
NVIC_ClearPendingIRQ(QSPI_IRQn);
NRF_QSPI->TASKS_DEACTIVATE = 1;
NRF_QSPI->ENABLE = QSPI_ENABLE_ENABLE_Disabled;
#endif
store.disableSecondaryFS(true);
} else {
MESH_DEBUG_PRINTLN("CustomLFS_QSPIFlash: initialized successfully");
}
#else
#if defined(EXTRAFS)
const bool extra_fs_map_ready =
!store.isVolatilePrimaryFS()
&& mesh::storage::internalPrimaryFilesystemReady(primary_fs_boot)
&& ExtraFS.loadPageMap(InternalFS);
const bool extra_fs_geometry_valid =
mesh::storage::isExpectedInternalExtraFsGeometry(
ExtraFS.getFlashAddr(), ExtraFS.getFlashSize(),
ExtraFS.getBlockSize())
&& mesh::storage::isInternalExtraFsReservedByApplication(
(uint32_t)(uintptr_t)__flash_arduino_end);
// Initial mount is always non-destructive, even for blank media.
// DataStore validates primary first, then retries/rebuilds ExtraFS.
if (!extra_fs_map_ready || !extra_fs_geometry_valid
|| ExtraFS.recoveryPending()
|| !ExtraFS.Adafruit_LittleFS::begin()) {
if (!extra_fs_map_ready) {
MESH_DEBUG_PRINTLN("CustomLFS: bad-page map unreadable; preserving ExtraFS and blocking writes");
} else if (!extra_fs_geometry_valid) {
MESH_DEBUG_PRINTLN("CustomLFS: internal ExtraFS geometry is not 100 KiB; refusing to mount or erase it");
} else if (ExtraFS.recoveryPending()) {
MESH_DEBUG_PRINTLN("CustomLFS: recorded physical page fault; deferring scan until primary validation");
} else {
MESH_DEBUG_PRINTLN("CustomLFS: internal ExtraFS mount failed; deferring automatic recovery until primary validation");
}
store.disableSecondaryFS(true);
}
#endif
#endif
store.begin();
the_mesh.begin(
#ifdef DISPLAY_CLASS
disp != NULL
#else
false
#endif
,
radio_available
#ifdef DISPLAY_CLASS
, &startup_screen
#endif
);
#if defined(NRF52_PLATFORM)
if (store.isVolatilePrimaryFS()) {
strncpy(the_mesh.getNodePrefs()->node_name,
mesh::storage::BAD_FILESYSTEM_NODE_NAME,
sizeof(the_mesh.getNodePrefs()->node_name));
the_mesh.getNodePrefs()->node_name[
sizeof(the_mesh.getNodePrefs()->node_name) - 1] = 0;
}
#endif
#elif defined(RP2040_PLATFORM)
store.begin();
the_mesh.begin(
#ifdef DISPLAY_CLASS
disp != NULL
#else
false
#endif
,
radio_available
#ifdef DISPLAY_CLASS
, &startup_screen
#endif
);
#elif defined(ESP32)
store.begin();
the_mesh.begin(
#ifdef DISPLAY_CLASS
disp != NULL
#else
false
#endif
,
radio_available
#ifdef DISPLAY_CLASS
, &startup_screen
#endif
);
#else
#error "need to define filesystem"
#endif
#if COMPANION_FEATURE_BLE_MOTA_SOURCE
the_mesh.setMotaSourceControl(&ble_mota_source_control);
#endif
// nRF52 cannot decide whether to add its optional logging CDC interface
// until the saved Companion preferences above are available. Single-TTY
// platforms have no separate port, so this is a harmless no-op there.
mesh::beginUsbLoggingPort();
#if defined(NRF52_PLATFORM)
mesh::loadUsbLoggingWatchdog(store.getPrimaryFS(), !store.isVolatilePrimaryFS(),
[]() -> uint32_t { return rtc_clock.getCurrentTime(); });
#else
mesh::loadUsbLoggingWatchdog(store.getPrimaryFS(), true,
[]() -> uint32_t { return rtc_clock.getCurrentTime(); });
#endif
#if defined(BLE_PIN_CODE)
// Keep stack registration available for a later USB `set bluetooth on`,
// without advertising even briefly when startup enables all interfaces.
interface_manager.setBluetoothAutoEnable(the_mesh.isBluetoothEnabledPreference());
#endif
// Lock the saved transport selection before bringing up either wireless stack.
#if defined(COMPANION_EXCLUSIVE_WIFI_BLE)
loadCompanionTransportModeForBoot();
if (companionTransportWiFiActiveAtBoot()) {
// This boot will never initialize Bluetooth. Reclaim both the controller
// and host allocations before WiFi starts; the release is irreversible
// until reboot, which is why transport changes are next-boot-only.
releaseCompanionBluetoothMemoryForWiFi();
}
#if defined(INDICATOR_TRANSPORT_RENDER_PROFILE) && defined(DISPLAY_CLASS)
if (disp != nullptr) {
const mesh::ui::IndicatorRenderProfile render_profile =
mesh::ui::selectIndicatorRenderProfile(
#if defined(MESH_PRIMARY_ESPNOW) && MESH_PRIMARY_ESPNOW
true,
#else
false,
#endif
companionTransportWiFiActiveAtBoot());
const bool selected = disp->setRenderScale(
render_profile.coordinate_scale, render_profile.output_zoom);
mesh::usbLoggingPort().printf(
"Indicator render: %dx%d internal canvas%s\r\n",
disp->renderWidth(), disp->renderHeight(),
selected ? "" : " (requested profile unavailable; restored fallback)");
}
#endif
#endif
// Load WiFi credentials without starting the radio.
#if defined(ESP32) && defined(WIFI_SSID)
loadCompanionWiFiCredentials();
#endif
#if defined(ESP32_PLATFORM) && COMPANION_FEATURE_MEMORY_DIAGNOSTICS
logFullCompanionMemory("before interfaces");
#endif
// Generic WiFi+BLE builds reserve NimBLE first. The Indicator exclusive
// profile instead starts Bluetooth only when it was selected for this boot.
#if defined(BLE_PIN_CODE) && defined(ESP32) && defined(WIFI_SSID)
#if defined(COMPANION_EXCLUSIVE_WIFI_BLE)
if (!companionTransportWiFiActiveAtBoot()) startCompanionBluetooth();
#else
startCompanionBluetooth();
#endif
#endif
// add wifi interface
#if defined(ESP32) && defined(WIFI_SSID)
wifi_interface.setSessionChangedCallback(cancelCompanionWiFiSession, nullptr);
#if defined(COMPANION_EXCLUSIVE_WIFI_BLE)
if (companionTransportWiFiActiveAtBoot()) {
#endif
WiFi.onEvent([](WiFiEvent_t event, WiFiEventInfo_t info){
if (event == ARDUINO_EVENT_WIFI_STA_DISCONNECTED) {
#if defined(MESH_PRIMARY_ESPNOW) && MESH_PRIMARY_ESPNOW
mesh::wifi::restoreEspNowChannel();
#endif
if (companion_wifi_requested) {
WIFI_DEBUG_PRINTLN("WiFi disconnected; automatic recovery is active");
}
} else if (event == ARDUINO_EVENT_WIFI_STA_GOT_IP) {
if (!mesh::wifi::enforceStationChannel()) {
WIFI_DEBUG_PRINTLN(
"Rejected WiFi association outside ESP-NOW channel %u",
(unsigned)mesh::wifi::activeEspNowChannel());
return;
}
#if defined(MESH_PRIMARY_ESPNOW) && MESH_PRIMARY_ESPNOW
// Association can replace the station protocol bitmap after the
// pre-connect setup. Restore LR receive support once the AP has
// finished configuring the shared radio; B/G/N remain enabled for
// the infrastructure connection.
if (mesh::wifi::applyProtocolMask(WIFI_IF_STA) != ESP_OK) {
WIFI_DEBUG_PRINTLN(
"Failed to restore ESP-NOW/WiFi protocol coexistence");
}
#endif
WIFI_DEBUG_PRINTLN("connected! IP %s (companion app on :%d)",
WiFi.localIP().toString().c_str(), TCP_PORT);
}
});
interface_manager.addInterface(InterfaceType::WiFi, &wifi_interface);
#if !defined(COMPANION_EXCLUSIVE_WIFI_BLE)
companion_wifi_requested = the_mesh.getNodePrefs()->wifi_enabled != 0;
#endif
if (companion_wifi_requested) {
startCompanionWiFi();
} else {
stopCompanionInfrastructureWiFi();
board.setInhibitSleep(false);
WIFI_DEBUG_PRINTLN("WiFi remains off; use set wifi.enabled 1 to enable it");
}
#if defined(COMPANION_EXCLUSIVE_WIFI_BLE)
}
#endif
#endif
#if defined(ESP32_PLATFORM) && COMPANION_FEATURE_MEMORY_DIAGNOSTICS
logFullCompanionMemory("after WiFi start");
#endif
// ESP32 WiFi+BLE companions started BLE above so its controller memory could
// not be fragmented by WiFi. Other BLE companions start here.
#if defined(BLE_PIN_CODE)
#if !(defined(ESP32) && defined(WIFI_SSID))
#if COMPANION_BLUETOOTH_BOOT_DELAY_MS > 0
companion_bluetooth_start_at =
millis() + COMPANION_BLUETOOTH_BOOT_DELAY_MS;
if (companion_bluetooth_start_at == 0) {
companion_bluetooth_start_at = 1;
}
#else
startCompanionBluetooth();
#endif
#endif
#endif
// add usb interface
#if defined(RP2040_PLATFORM) && defined(ENABLE_WIFI_INTERFACE)
wifi_interface.setSessionChangedCallback([](void*) {
the_mesh.cancelSerialResponseStream(&wifi_interface);
the_mesh.cancelSerialOperationsForRoute(&wifi_interface);
interface_manager.forgetReplyRouteForDisconnected(&wifi_interface);
}, nullptr);
const auto* wifi_prefs = the_mesh.getNodePrefs();
StrHelper::strncpy(pico_wifi_ssid,
wifi_prefs->wifi_ssid[0] ? wifi_prefs->wifi_ssid : WIFI_SSID,
sizeof(pico_wifi_ssid));
StrHelper::strncpy(pico_wifi_pwd,
wifi_prefs->wifi_ssid[0] ? wifi_prefs->wifi_pwd : WIFI_PWD,
sizeof(pico_wifi_pwd));
pico_wifi_active = wifi_prefs->wifi_enabled && pico_wifi_ssid[0];
if (pico_wifi_active) {
WiFi.setTimeout(WIFI_RETRY_TIMEOUT);
WiFi.beginNoBlock(pico_wifi_ssid, pico_wifi_pwd);
last_wifi_reconnect_attempt = millis();
wifi_interface.begin(TCP_PORT);
interface_manager.addInterface(InterfaceType::WiFi, &wifi_interface);
}
#endif
#if defined(ENABLE_USB_INTERFACE)
#if COMPANION_FEATURE_USB_MOTA_SOURCE
usb_serial_interface.begin(mesh::usbCompanionPort(),
USB_TERMINAL_START_TOKEN, USB_MOTA_START_TOKEN);
#else
usb_serial_interface.begin(mesh::usbCompanionPort(),
USB_TERMINAL_START_TOKEN);
#endif
// keep frames intact and pace the contact stream when the host is slow
usb_serial_interface.enableFlowControl(true);
usb_serial_interface.setReceiveFrameCheck(acceptUsbBinaryStartupFrame);
#if defined(ESP32) && defined(ARDUINO_USB_MODE) && ARDUINO_USB_MODE == 1 \
&& defined(ARDUINO_USB_CDC_ON_BOOT) && ARDUINO_USB_CDC_ON_BOOT
// The ESP32 USB-Serial-JTAG peripheral (HWCDC) has no DTR concept at all.
// Its bool operator is a transient RX/TX activity heuristic, and even the
// SOF-based host-presence flag can briefly flap around a bus reset. A
// complete command frame is stronger proof that a client is listening, so
// grant it a short lease in which the response can be queued and drained.
// Outside that lease, require the physical host signal as well as recent
// Binary Companion activity so an idle cable does not claim the interface.
// The idle cap stays absolute: an abandoned multi-frame response must not
// lock BLE or WiFi dispatch forever merely because SOFs continue.
usb_serial_interface.setConnectedCheck([]() {
return usb_hwcdc_host_presence.isClientConnected(
board.isUsbHostConnected(), millis(),
usb_serial_interface.hasReceivedFrame(),
usb_serial_interface.getLastFrameMillis(),
usb_serial_interface.getCompletedFrameCount(),
USB_FRAME_REPLY_GRACE_MS, USB_CLIENT_IDLE_TIMEOUT,
USB_HOST_LOSS_EDGE_MS,
USB_HOST_LOSS_GRACE_MS,
the_mesh.hasFiniteDelayedReplyForRoute(&usb_serial_interface));
});
#elif defined(NRF52_PLATFORM)
usb_serial_interface.setConnectedCheck([]() {
return mesh::isUsbCompanionClientConnected();
});
#elif (defined(ESP32) && defined(ARDUINO_USB_CDC_ON_BOOT) && ARDUINO_USB_CDC_ON_BOOT) \
|| defined(RP2040_PLATFORM)
// native USB-CDC (TinyUSB): (bool)Serial reflects DTR, ie. the host really
// has the port open. A classic ESP32 behind a UART bridge has no such
// signal and keeps the assume-connected default.
usb_serial_interface.setConnectedCheck([]() { return (bool)Serial; });
#endif
interface_manager.addInterface(InterfaceType::USB, &usb_serial_interface);
usb_protocol_initialized = true;
// Select ASCII before any framed dispatch, including non-Full USB builds.
// BLE/WiFi/Ethernet keep their existing Binary Companion transports.
beginUsbDefaultSession();
#endif
// add ethernet interface
#if defined(ETHERNET_ENABLED)
ethernet_interface.setSessionChangedCallback(cancelCompanionEthernetSession, nullptr);
ethernet_interface.begin();
interface_manager.addInterface(InterfaceType::Ethernet, &ethernet_interface);
#endif
// add hardware serial interface
#if defined(SERIAL_RX)
if (!companion_serial_shares_usb_port) {
companion_serial.setPins(SERIAL_RX, SERIAL_TX);
companion_serial.begin(115200);
hardware_serial_interface.begin(companion_serial);
hardware_serial_interface.enableFlowControl(true);
interface_manager.addInterface(InterfaceType::HardwareSerial, &hardware_serial_interface);
}
#endif
the_mesh.startInterface(interface_manager);
#if defined(ESP32) && defined(WIFI_SSID)
if (!companion_wifi_requested) wifi_interface.disable();
#endif
sensors.begin();
#if ENV_INCLUDE_GPS == 1
// Device power saving applies a 10-minute awake, 30-minute sleep GPS cycle.
if (sensors.getLocationProvider() != NULL) {
sensors.getLocationProvider()->setPowerSavingProfile(600, 1800);
sensors.setPowerSavingEnabled(
the_mesh.getNodePrefs()->powersaving_enabled != 0);
}
#endif
#if ENV_INCLUDE_GPS == 1
the_mesh.applyGpsPrefs();
#endif
#ifdef DISPLAY_CLASS
ui_task.begin(disp, &sensors, the_mesh.getNodePrefs()); // still want to pass this in as dependency, as prefs might be moved
#endif
#if defined(WIO_E5_MINI_HEADLESS_COMPANION_UI) && defined(PIN_USER_BTN)
beginWioE5HeadlessControls();
#endif
board.onBootComplete();
#ifdef ESP32_PLATFORM
serviceCompanionPowerSaving(true);
#endif
}
static void serviceUsbLoggingOnlySession() {
#if MESH_ESP32_USB_CONSOLE_COOPERATIVE && !defined(ENABLE_USB_INTERFACE)
// BLE/WiFi-only Companions still own native USB diagnostics. Mark setup
// complete and finish bus-reset cleanup even without a USB protocol owner.
(void)mesh::takeUsbTerminalSessionReset();
(void)mesh::tryCompleteUsbTerminalSessionReset();
#endif
}
static bool usbLoggingRecoverySafe(void*) {
if (board.isOTAUpdateRunning() || board.isRadioTestActive()
|| radio_driver.isWatchdogObserving() || radio_driver.isCalibratingNoiseFloor()
|| !the_mesh.canRecoverUsbLogging()) return false;
#if defined(ENABLE_USB_INTERFACE)
#if COMPANION_FEATURE_USB_MOTA_SOURCE
if (usb_mota_mode) return false;
#endif
// The logging CDC may be separate, but USB re-enumeration/reboot affects the
// primary Companion connection too. Never reset a live Binary client.
// A network CLI can own the role while USB is parked in log-only
// passthrough, so the USB transport, not the role CLI, identifies Binary.
if (!usb_serial_interface.isPassthroughMode()
&& (isUsbTerminalDataConnected() || usb_serial_interface.hasPendingIO())) return false;
const auto usb = mesh::usbLoggingStatus();
if (usb.reader_connected && !usb.stalled
&& (usb_terminal_line_len || usb_terminal_discard_line)) return false;
#endif
return true;
}
void loop() {
#if defined(RP2040_PLATFORM) && defined(ENABLE_WIFI_INTERFACE)
if (pico_wifi_active && WiFi.status() != WL_CONNECTED
&& static_cast<uint32_t>(millis() - last_wifi_reconnect_attempt)
>= WIFI_RETRY_INTERVAL) {
WiFi.setTimeout(WIFI_RETRY_TIMEOUT);
WiFi.beginNoBlock(pico_wifi_ssid, pico_wifi_pwd);
last_wifi_reconnect_attempt = millis();
}
#endif
mesh::wireless::control().service(millis());
#if defined(ESP32) && defined(WIFI_SSID)
serviceCompanionWiFiNetworkPowerHold();
#endif
#if defined(NRF52_PLATFORM)
board.feedWatchdog();
#endif
serviceUsbLoggingOnlySession();
#if defined(ENABLE_USB_INTERFACE)
serviceUsbTerminalHostSessionReset();
serviceUsbAsciiSessionDefault();
serviceUsbLoggingReplyBarrier();
serviceUsbLoggingOwnership(mesh::isUsbLoggingEnabled()); // Before framed dispatch.
#endif
// Identify nRF52 CDC 1 when a terminal opens it. Doing this on the connection
// edge avoids losing the marker before the host has opened the port.
mesh::serviceUsbLoggingPort();
#if defined(ENABLE_USB_INTERFACE)
// Drain functional CDC0 text before the mesh can emit a Binary Companion or
// serial-mOTA frame in this iteration.
mesh::serviceUsbTerminalPort();
#endif
#if defined(ENABLE_USB_INTERFACE)
expireUsbBinaryStartupProbeBeforeDispatch();
#if COMPANION_FEATURE_USB_MOTA_SOURCE
serviceUsbMotaResponseBoundary();
#endif
#endif
the_mesh.loop();
#if defined(NRF52_PLATFORM) && defined(EXTRAFS) && !defined(QSPIFLASH)
// The flash-page number is retained across a software reset without trying
// to write primary identity storage while radio/BLE tasks are running.
// Early boot then verifies and records the page map before remounting.
if (ExtraFS.ioFailed()) {
static uint32_t last_fault_record_attempt = 0;
if (static_cast<uint32_t>(millis() - last_fault_record_attempt) >= 5000) {
last_fault_record_attempt = millis();
if (ExtraFS.requestBootScan()) {
MESH_DEBUG_PRINTLN("ExtraFS: physical write failed; rebooting for isolated page scan");
board.reboot();
}
}
}
#endif
#ifdef RECOVERABLE_EXTERNAL_RADIO
serviceCompanionRadioRecovery();
#endif
#if defined(ENABLE_USB_INTERFACE)
serviceUsbTerminal();
// Commands can synchronously enqueue a multi-line reply. Give it the
// remaining CDC FIFO capacity now; later loops retain any unwritten suffix.
mesh::serviceUsbTerminalPort();
#endif
interface_manager.loop();
#if COMPANION_FEATURE_BLE_MOTA_SOURCE
ble_mota_source_control.loop();
#endif
sensors.loop();
#if defined(WIO_E5_MINI_HEADLESS_COMPANION_UI) && defined(PIN_USER_BTN)
serviceWioE5HeadlessControls();
#endif
#ifdef DISPLAY_CLASS
#ifdef INDICATOR_WIFI_FONT_RECOVERY
// The Indicator keeps rendering with its built-in fallback while a missing
// SD font is recovered by a background TLS task. This poll only launches
// work after station Wi-Fi connects and installs a completed font on the
// main/UI task.
display.serviceFontRecovery();
#endif
#if defined(ESP32) && defined(WIFI_SSID) && defined(WITH_WEBCONFIG) \
&& UI_WIFI_SETUP_HOME_PAGE != 1
ui_task.servicePairingState();
if (ui_task.isPairingPromptActive()) {
ui_task.loop();
} else if (isCompanionWiFiEnabled()
&& the_mesh.isWebConfigSetupActive()) {
#if defined(TBEAM_1W) && defined(PIN_WIFI_BTN)
ui_task.serviceWiFiToggleButton();
#endif
ui_task.loop(); // Continue button/touch handling while the portal is active.
renderCompanionSetupDisplay();
} else {
ui_task.loop();
}
#else
ui_task.loop();
#endif
#endif
#if defined(COMPANION_PAIRING_UI_HIL)
companionPairingUiHilProbe();
#endif
rtc_clock.tick();
board.loop();
if (mesh::serviceUsbLoggingWatchdog(usbLoggingRecoverySafe)) board.reboot();
#ifdef TBEAM_1W
board.updateFanControl();
#endif
#ifdef HAS_EXTERNAL_WATCHDOG
external_watchdog.loop();
#endif
#ifdef ESP32_PLATFORM
serviceCompanionPowerSaving();
#endif
// USB power alone (for example, a wall charger) does not inhibit sleep.
// Host sessions, live logging, and button activity still need service.
bool can_sleep = the_mesh.getNodePrefs()->powersaving_enabled
&& !the_mesh.hasPendingWork()
&& !mesh::isUsbLoggingWatchdogArmed()
&& !mesh::wireless::control().pending();
#if defined(ESP32_PLATFORM) && MESH_USB_LOGGING_AVAILABLE
// The native-USB-only light-sleep path below bypasses ESP32Board::sleep.
can_sleep = can_sleep && !mesh::isUsbLoggingEnabled();
#endif
#if defined(ESP32_PLATFORM) && defined(ENABLE_USB_INTERFACE)
const bool usb_sleep_held = board.isUsbSleepHeld();
can_sleep = can_sleep && !usb_sleep_held;
#elif defined(NRF52_PLATFORM)
can_sleep = can_sleep && !board.isUsbHostConnected();
#endif
#if defined(MOMENTARY_BUTTON_WAKE_FROM_SLEEP) \
&& MOMENTARY_BUTTON_WAKE_FROM_SLEEP \
&& defined(PIN_USER_BTN) && defined(DISPLAY_CLASS)
// GPIO wake starts the poller, but multi-click and long-press results
// are due after a time interval rather than another edge. The G3 also
// retains a two-minute wake interval after button activity.
can_sleep = can_sleep && !user_btn.needsPolling();
#endif
if (can_sleep) {
#if defined(NRF52_PLATFORM)
board.sleep(0); // nrf ignores seconds param, sleeps whenever possible
#elif defined(ESP32_PLATFORM)
#if defined(ENABLE_USB_INTERFACE) \
&& defined(ARDUINO_USB_CDC_ON_BOOT) && ARDUINO_USB_CDC_ON_BOOT \
&& !defined(ETHERNET_ENABLED) && !defined(SERIAL_RX)
// Automatic light sleep is disabled for native USB, even in a PM-enabled
// core. A short timer slice gives idle battery builds real light sleep without
// delaying radio, GPS, button, or newly attached USB work by more than the
// normal 10 ms loop cadence. can_sleep already proved no USB host is up.
if (!companionNativeUsbLightSleepReady()
|| esp_sleep_enable_timer_wakeup(10000ULL) != ESP_OK
|| esp_light_sleep_start() != ESP_OK) {
vTaskDelay(pdMS_TO_TICKS(10));
}
#elif COMPANION_IDF_PM_AVAILABLE
// Yield long enough for ESP-IDF automatic light sleep to enter when no
// driver holds a power-management lock.
vTaskDelay(pdMS_TO_TICKS(10));
#else
// Connected transports need their own modem sleep and must retain the
// normal FreeRTOS idle behavior.
vTaskDelay(pdMS_TO_TICKS(10));
#endif
#elif defined(RP2040_PLATFORM) || defined(STM32_PLATFORM)
board.sleep(0); // event-driven idle; interrupts wake the main loop
#endif
}
#if defined(ESP32_PLATFORM)
else if (the_mesh.getNodePrefs()->powersaving_enabled) {
delay(1); // CPU idle remains available while USB/logging/button holds are active.
}
#endif
#if defined(ESP32) && defined(WIFI_SSID)
#ifdef WITH_WEBCONFIG
the_mesh.serviceWebConfig();
if (companion_wifi_setup_stop_requested) {
companion_wifi_setup_stop_requested = false;
the_mesh.stopWebConfig();
}
if (companion_wifi_setup_requested) {
companion_wifi_setup_requested = false;
char web_reply[160];
if (!the_mesh.startWebConfig(true, web_reply)) {
WIFI_DEBUG_PRINTLN("Display WiFi setup request: %s", web_reply);
}
}
#endif
serviceCompanionWiFiCredentialReload();
serviceCompanionWiFiNetworkPowerHold();
serviceCompanionWiFiState();
if (companion_wifi_requested && companion_wifi_active
&& !companionWiFiBackgroundSuspended()
&& !companion_wifi_background_stopped) {
#ifdef ENABLE_OTA
ota_console_loop(); // service the local text console (port 5002)
#endif
const unsigned long wifi_now = millis();
// The owner of a recovery setup AP may deliberately retry its saved SSID
// in AP+STA mode. Other APs, including browser OTA, must remain untouched by
// this background station maintenance and its five-minute reconnect timer.
const bool setup_station_handoff =
#ifdef COMPANION_RADIO_FULL
!board.isOTAUpdateRunning() &&
#endif
#ifdef WITH_WEBCONFIG
the_mesh.isWebConfigSetupActive()
&& (wifi_setup_recovery_mode || the_mesh.isWebConfigWiFiRecoveryActive());
#else
wifiSetupPortal().isActive();
#endif
if (mesh::wifi::stationMutationAllowed(setup_station_handoff)) {
const bool station_channel_ok = mesh::wifi::enforceStationChannel();
if (station_channel_ok && WiFi.status() == WL_CONNECTED) {
wifi_reconnect_tracker.noteConnected();
wifi_setup_attempted = false;
#ifdef WITH_WEBCONFIG
// startAutoMode() can raise the setup AP after its own short connection
// timeout, before the main-loop fallback marks recovery mode. A successful
// saved-SSID retry should close either kind of recovery AP.
if (configured_wifi_ssid[0] && the_mesh.isWebConfigSetupActive()
&& (wifi_setup_recovery_mode
|| the_mesh.isWebConfigWiFiRecoveryActive())) {
mesh::wifi::setStationAutoReconnect(true);
the_mesh.stopWebConfig();
wifi_setup_recovery_mode = false;
}
#endif
if (wifi_setup_recovery_mode
#ifdef WITH_WEBCONFIG
&& !the_mesh.isWebConfigSetupActive()
#else
&& !wifiSetupPortal().isActive()
#endif
) {
wifi_setup_recovery_mode = false;
}
} else if (configured_wifi_ssid[0]) {
wifi_reconnect_tracker.noteDisconnected(wifi_now);
if (!wifi_setup_recovery_mode && companionWiFiAutomaticRecoveryAllowed()
#ifdef WITH_WEBCONFIG
&& !the_mesh.isWebConfigSetupActive()
#else
&& !wifiSetupPortal().isActive()
#endif
&& wifi_reconnect_tracker.disconnectedFor(
wifi_now, WIFI_SETUP_FALLBACK_MS)
&& (!wifi_setup_attempted
|| WiFiReconnectPolicy::elapsedMs(
wifi_now, last_wifi_setup_attempt) >= WIFI_SETUP_FALLBACK_MS)) {
wifi_setup_attempted = true;
last_wifi_setup_attempt = wifi_now;
#ifdef WITH_WEBCONFIG
if (WebConfigServer::loadEnabled(true)) {
char web_reply[160];
if (the_mesh.startWebConfig(true, web_reply)) {
wifi_setup_recovery_mode = true;
WIFI_DEBUG_PRINTLN("WiFi unavailable for two minutes; %s", web_reply);
}
}
#else
if (wifiSetupPortal().begin(COMPANION_WIFI_SETUP_AP, saveCompanionWiFi, nullptr)) {
wifiSetupPortal().configureRecovery(
configured_wifi_ssid, configured_wifi_password,
WiFiReconnectPolicy::kRetryIntervalMs,
WiFiReconnectPolicy::kRetryIntervalMs - WIFI_SETUP_FALLBACK_MS);
wifi_setup_recovery_mode = true;
WIFI_DEBUG_PRINTLN("WiFi unavailable for two minutes; setup AP started");
}
#endif
}
}
// Reassert the saved credentials every five minutes after an AP outage.
// Primary ESP-NOW builds deliberately disable the driver's unconstrained
// auto-reconnect; ordinary builds retain it and use this as a recovery path.
// WebConfig's
// setup AP uses AP+STA mode, so the station retry can run without taking the
// recovery page down. The legacy portal owns its own identical retry timer.
const bool reconnect_owned_here =
#ifdef WITH_WEBCONFIG
!the_mesh.isWebConfigSetupActive()
|| wifi_setup_recovery_mode
|| the_mesh.isWebConfigWiFiRecoveryActive();
#else
!wifiSetupPortal().isActive();
#endif
if (configured_wifi_ssid[0] && reconnect_owned_here
&& WiFi.status() != WL_CONNECTED
&& wifi_reconnect_tracker.retryDue(wifi_now)) {
WIFI_DEBUG_PRINTLN("WiFi still unavailable; retrying saved SSID");
wifi_reconnect_tracker.noteAttempt(wifi_now);
#ifdef WITH_WEBCONFIG
if (!the_mesh.isWebConfigSetupActive()) {
WiFi.mode(WIFI_STA);
mesh::wifi::setStationAutoReconnect(true);
}
#else
WiFi.mode(WIFI_STA);
mesh::wifi::setStationAutoReconnect(true);
#endif
WiFi.disconnect(false, false);
mesh::wifi::beginStation(
configured_wifi_ssid, configured_wifi_password);
}
}
#ifdef WITH_MQTT_BRIDGE
the_mesh.serviceMQTT(configured_wifi_ssid, configured_wifi_password);
serviceCompanionWiFiNtp();
#else
serviceCompanionWiFiNtp();
#endif
}
#endif
#if defined(BLE_PIN_CODE)
#if defined(COMPANION_EXCLUSIVE_WIFI_BLE)
if (!companionTransportWiFiActiveAtBoot()) {
serviceDeferredCompanionBluetooth();
}
#else
serviceDeferredCompanionBluetooth();
#endif
serviceCompanionBluetoothIdentity();
serviceCompanionBluetoothControl();
#if defined(NRF52_PLATFORM)
serviceCompanionBluetoothName();
#endif
#endif
}