#include // needed for PlatformIO #include #include #include #include #include #include #include #include "MyMesh.h" #include #include "CompanionBluetooth.h" #include "CompanionWireless.h" #include "CompanionWiFi.h" #if COMPANION_FEATURE_USB_MOTA_SOURCE || COMPANION_FEATURE_BLE_MOTA_SOURCE #include #include #endif #if defined(COMPANION_PAIRING_UI_HIL) void companionPairingUiHilProbe(); #endif #if MESH_PACKET_LOGGING #include #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 #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 #endif #endif #ifdef USE_CC310_HW_CRYPTO #include #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 MultiSerialInterface interface_manager; // include bluetooth interface #if defined(BLE_PIN_CODE) #ifdef ESP32 // include esp32 bluetooth interface #include SerialBLEInterface bluetooth_interface; #elif defined(NRF52_PLATFORM) // include nrf52 bluetooth interface #include SerialBLEInterface bluetooth_interface; #elif defined(RP2040_PLATFORM) // include rp2040 (Pico W / CYW43) bluetooth interface #include 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 #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include SerialWifiInterface wifi_interface; static bool companion_wifi_interface_registered = false; #ifndef WIFI_PWD #define WIFI_PWD "" #endif #elif defined(RP2040_PLATFORM) #include 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 #include #include #include 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 ETHERNET_CLASS ethernet_interface; #endif // include hardware serial interface #if defined(SERIAL_RX) #include 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 #if defined(NRF52_PLATFORM) #include #include #endif #if defined(QSPIFLASH) #include 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 DataStore store(LittleFS, rtc_clock); #elif defined(ESP32) #include 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(&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(ðernet_interface); the_mesh.cancelSerialOperationsForRoute(ðernet_interface); interface_manager.forgetReplyRouteForDisconnected(ðernet_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 syncUsbTerminalInputEditing() { #if COMPANION_FEATURE_TEXT_TERMINAL the_mesh.setTerminalInputEditing(usbTerminalOutput(), usb_terminal_line_len != 0 || usb_terminal_discard_line || mesh::usbCompanionPort().available() > 0); #endif } 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(password - usb_terminal_line); } if (visible_len > 0) { usbTerminalOutput().write( reinterpret_cast(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(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(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, true)) { 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, true); } 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 companion_wifi_ntp_operation_generation{0}; static std::atomic companion_wifi_ntp_proof_generation{0}; static std::atomic companion_wifi_ntp_epoch{0}; static std::atomic 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(value->tv_sec) > UINT32_MAX) { return; } companion_wifi_ntp_epoch.store( static_cast(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(raw_system_time) <= UINT32_MAX) { const uint32_t epoch = static_cast(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(epoch) - static_cast(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(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, tcp=%u", 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(), wifi_interface.getStatus().listening ? 1U : 0U); } void formatCompanionWiFiTcpStatus(char* reply, size_t reply_size) { if (reply == nullptr || reply_size == 0) return; const auto status = wifi_interface.getStatus(); // Fit even full-width counters in the 160-byte Companion CLI reply. No // self-probe connection: it would displace the user's single TCP session. snprintf(reply, reply_size, "> tcp=%u %s reg=%u ip=%u client=%u fail=%lu err=%d retry=%lus " "acc=%lu rx=%lu/%02X tx=%lu/%02X drop=%s", (unsigned int)(status.port ? status.port : TCP_PORT), SerialWifiInterface::stateName(status.state), companion_wifi_interface_registered ? 1U : 0U, status.addressReady ? 1U : 0U, status.clientConnected ? 1U : 0U, (unsigned long)status.beginFailures, status.lastErrno, (unsigned long)((status.retryInMs + 999U) / 1000U), (unsigned long)status.acceptedClients, (unsigned long)status.validFrames, (unsigned int)status.lastOpcode, (unsigned long)status.txFrames, (unsigned int)status.lastTxOpcode, SerialWifiInterface::disconnectReasonName(status.lastDisconnectReason)); } #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(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 #include // mesh::ota::handle_ota_command(line, reply, board) #include #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(output).fd(); if (socket < 0) return 0; const int written = ::send(socket, data, size, MSG_DONTWAIT); return written > 0 ? static_cast(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 %d)", OTA_CONSOLE_TCP_PORT, OTA_CONSOLE_TCP_PORT); #else WIFI_DEBUG_PRINTLN("Local CLI and OTA console listening on :%d (nc %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(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(); if (companion_wifi_interface_registered) { 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(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(); if (companion_wifi_interface_registered) { 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(~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 TCP port :%d requested; get wifi.tcp for listener status)", WiFi.localIP().toString().c_str(), TCP_PORT); } }); companion_wifi_interface_registered = interface_manager.addInterface(InterfaceType::WiFi, &wifi_interface); if (!companion_wifi_interface_registered) { wifi_interface.disable(); WIFI_DEBUG_PRINTLN("Companion WiFi interface registration failed"); } #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(); if (interface_manager.addInterface(InterfaceType::WiFi, &wifi_interface)) { wifi_interface.begin(TCP_PORT); } } #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, ðernet_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(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 #if defined(ENABLE_USB_INTERFACE) syncUsbTerminalInputEditing(); #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(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(); syncUsbTerminalInputEditing(); // 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 const bool morse_timing = mesh::morseTimingActive(&radio_driver); if (!morse_timing) sensors.loop(); #if defined(WIO_E5_MINI_HEADLESS_COMPANION_UI) && defined(PIN_USER_BTN) serviceWioE5HeadlessControls(); #endif #ifdef DISPLAY_CLASS // Keep button/touch and critical UI power checks live. UITask itself skips // only background redraws while the radio needs precise Morse timing. #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. if (!morse_timing) 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(); if (!morse_timing) renderCompanionSetupDisplay(); } else { ui_task.loop(); } #else ui_task.loop(); #endif #endif #if defined(COMPANION_PAIRING_UI_HIL) if (!morse_timing) 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 && !morse_timing && !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 (!morse_timing && 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 }