Detect OLED or the original stock TFT touch assembly in ordinary V4.2/V4.3 images. Add saved live display.type auto/oled/tft/off selection and report the active selection in board output. Keep sensors on primary I2C, preserve shared power, and isolate TFT FSPI from radio HSPI through sleep and changes. Consolidate future picker/build choices only with application capability proof. Retain exact legacy OTA identities and separate R8 hardware images. Document controls and physical qualification limits. Validation: five serialized Full builds with capability/size/RAM checks; focused host tests, existing R8/T-Deck/OLED regressions, build-profile suite, and picker/provider tests. V4.3 OLED live changes and off/auto reboot persistence passed on Mercerwood. Physical TFT qualification remains pending.
4632 lines
180 KiB
C++
4632 lines
180 KiB
C++
#include <helpers/RegulatorSettings.h>
|
|
#include <helpers/LedSettings.h>
|
|
#include <Arduino.h> // needed for PlatformIO
|
|
#include <helpers/ui/StartupScreen.h>
|
|
#include <helpers/ui/DisplayPowerSettings.h>
|
|
#if defined(HELTEC_V4_DISPLAY_UNIFIED) && HELTEC_V4_DISPLAY_UNIFIED && defined(DISPLAY_CLASS)
|
|
#include <helpers/ui/HeltecV4DisplaySettings.h>
|
|
#endif
|
|
#if defined(OLED_AUTO_DETECT) && OLED_AUTO_DETECT
|
|
#include <helpers/ui/OLEDControllerSettings.h>
|
|
#endif
|
|
#include <helpers/UsbLoggingWatchdog.h>
|
|
#include <helpers/UsbLoggingClientActivity.h>
|
|
#include <helpers/MorseLoopPolicy.h>
|
|
#include <Mesh.h>
|
|
#include <helpers/BluetoothMac.h>
|
|
#include "MyMesh.h"
|
|
#include <helpers/CompanionReader.h>
|
|
#include "CompanionBluetooth.h"
|
|
#include "CompanionWireless.h"
|
|
#include "CompanionWiFi.h"
|
|
#if defined(MESH_PRIMARY_ESPNOW) && MESH_PRIMARY_ESPNOW
|
|
#include <WiFi.h>
|
|
#endif
|
|
#if COMPANION_FEATURE_USB_MOTA_SOURCE || COMPANION_FEATURE_BLE_MOTA_SOURCE
|
|
#include <helpers/ota/MotaSourceSerial.h>
|
|
#include <helpers/ota/OtaContext.h>
|
|
#endif
|
|
#if defined(COMPANION_PAIRING_UI_HIL)
|
|
void companionPairingUiHilProbe();
|
|
#endif
|
|
#if MESH_PACKET_LOGGING
|
|
#include <helpers/SerialPacketLog.h>
|
|
#endif
|
|
|
|
#ifdef ESP32_PLATFORM
|
|
#include "esp_bt.h"
|
|
#include "esp_pm.h"
|
|
#include "esp_sleep.h"
|
|
#include "esp_system.h"
|
|
#include <helpers/esp32/ProfileIdleSleep.h>
|
|
#include <helpers/esp32/LightSleepPolicy.h>
|
|
#if defined(CONFIG_BLUEDROID_ENABLED) && !defined(MESH_USE_NIMBLE_ARDUINO)
|
|
#include <esp_bt_main.h>
|
|
#endif
|
|
#if defined(CONFIG_PM_ENABLE) && CONFIG_PM_ENABLE
|
|
#define COMPANION_IDF_PM_AVAILABLE 1
|
|
#else
|
|
#define COMPANION_IDF_PM_AVAILABLE 0
|
|
#endif
|
|
#if defined(BLE_PIN_CODE) && !CONFIG_IDF_TARGET_ESP32C6 \
|
|
&& (defined(CONFIG_BT_CTRL_MODEM_SLEEP) \
|
|
|| defined(CONFIG_BTDM_CTRL_MODEM_SLEEP))
|
|
#define COMPANION_BT_MODEM_SLEEP_AVAILABLE 1
|
|
#else
|
|
#define COMPANION_BT_MODEM_SLEEP_AVAILABLE 0
|
|
#endif
|
|
#if COMPANION_FEATURE_MEMORY_DIAGNOSTICS
|
|
#include <esp_heap_caps.h>
|
|
#endif
|
|
#endif
|
|
|
|
#ifdef USE_CC310_HW_CRYPTO
|
|
#include <helpers/NRF52Crypto.h>
|
|
#endif
|
|
// Believe it or not, this std C function is busted on some platforms!
|
|
static uint32_t _atoi(const char* sp) {
|
|
uint32_t n = 0;
|
|
while (*sp && *sp >= '0' && *sp <= '9') {
|
|
n *= 10;
|
|
n += (*sp++ - '0');
|
|
}
|
|
return n;
|
|
}
|
|
|
|
// interface manager
|
|
#include <helpers/MultiSerialInterface.h>
|
|
#include <helpers/UsbBluetoothPriority.h>
|
|
MultiSerialInterface interface_manager;
|
|
|
|
// include bluetooth interface
|
|
#if defined(BLE_PIN_CODE)
|
|
#ifdef ESP32
|
|
// include esp32 bluetooth interface
|
|
#include <helpers/esp32/SerialBLEInterface.h>
|
|
SerialBLEInterface bluetooth_interface;
|
|
#elif defined(NRF52_PLATFORM)
|
|
// include nrf52 bluetooth interface
|
|
#include <helpers/nrf52/SerialBLEInterface.h>
|
|
SerialBLEInterface bluetooth_interface;
|
|
#elif defined(RP2040_PLATFORM)
|
|
// include rp2040 (Pico W / CYW43) bluetooth interface
|
|
#include <helpers/rp2040/SerialBLEInterface.h>
|
|
SerialBLEInterface bluetooth_interface;
|
|
#else
|
|
#error "SerialBLEInterface is not defined for this platform"
|
|
#endif
|
|
#endif
|
|
|
|
// include wifi interface
|
|
#if defined(ENABLE_WIFI_INTERFACE) || defined(WIFI_SSID)
|
|
#ifndef WIFI_SSID
|
|
#define WIFI_SSID ""
|
|
#endif
|
|
#ifndef WIFI_PWD
|
|
#define WIFI_PWD ""
|
|
#endif
|
|
#ifndef TCP_PORT
|
|
#define TCP_PORT 5000
|
|
#endif
|
|
#ifdef ESP32
|
|
// include esp32 wifi interface
|
|
#include <helpers/wifi/SerialWifiInterface.h>
|
|
#include <helpers/WiFiSetupPortal.h>
|
|
#include <helpers/CLICommandUtils.h>
|
|
#include <helpers/WiFiReconnectPolicy.h>
|
|
#include <helpers/WiFiPowerSave.h>
|
|
#include <helpers/CompanionWiFiNtpPolicy.h>
|
|
#include <helpers/CompanionWiFiStatusFormat.h>
|
|
#include <helpers/WiFiNetworkPowerPolicy.h>
|
|
#include <helpers/ui/DisplayTextLayout.h>
|
|
#include <helpers/ui/IndicatorRenderProfile.h>
|
|
#include <helpers/ui/WiFiSetupQrDisplay.h>
|
|
#include <helpers/esp32/SntpOperationCoordinator.h>
|
|
#include <helpers/esp32/TlsClockValidity.h>
|
|
#include <helpers/esp32/WiFiRadioPolicy.h>
|
|
#include <helpers/esp32/WiFiStationPolicy.h>
|
|
#include <Preferences.h>
|
|
#include <atomic>
|
|
#include <esp_sntp.h>
|
|
#include <esp_wifi.h>
|
|
#if defined(ESP32_PLATFORM) && defined(ENABLE_OTA)
|
|
#include <helpers/esp32/WiFiOtaSeeder.h>
|
|
#endif
|
|
SerialWifiInterface wifi_interface;
|
|
static bool companion_wifi_interface_registered = false;
|
|
#ifndef WIFI_PWD
|
|
#define WIFI_PWD ""
|
|
#endif
|
|
#elif defined(RP2040_PLATFORM)
|
|
#include <helpers/wifi/SerialWifiInterface.h>
|
|
SerialWifiInterface wifi_interface;
|
|
static uint32_t last_wifi_reconnect_attempt = 0;
|
|
static bool pico_wifi_active = false;
|
|
static char pico_wifi_ssid[33] = {};
|
|
static char pico_wifi_pwd[64] = {};
|
|
#ifndef WIFI_RETRY_INTERVAL
|
|
#define WIFI_RETRY_INTERVAL 30000UL
|
|
#endif
|
|
#ifndef WIFI_RETRY_TIMEOUT
|
|
#define WIFI_RETRY_TIMEOUT 5000UL
|
|
#endif
|
|
#else
|
|
#error "SerialWifiInterface is not defined for this platform"
|
|
#endif
|
|
#endif
|
|
|
|
// include usb interface
|
|
#if defined(ENABLE_USB_INTERFACE)
|
|
#include <helpers/ArduinoSerialInterface.h>
|
|
#include <helpers/CLICommandUtils.h>
|
|
#include <helpers/UsbAsciiBinarySwitch.h>
|
|
#include <helpers/UsbLogging.h>
|
|
static const char USB_TERMINAL_START_TOKEN[] = "+++MESHCORE-TERM-START";
|
|
static const char USB_TERMINAL_STOP_TOKEN[] = "+++MESHCORE-TERM-STOP";
|
|
#if COMPANION_FEATURE_USB_MOTA_SOURCE
|
|
// motatool sends this command automatically when `serve --serial` opens the
|
|
// port. It hands USB ownership to the host-backed mOTA source from either
|
|
// the completed ASCII line or Binary mode's idle control-sequence parser.
|
|
static const char USB_MOTA_START_TOKEN[] = "ota folder on";
|
|
#endif
|
|
ArduinoSerialInterface usb_serial_interface;
|
|
#ifndef USB_CLIENT_IDLE_TIMEOUT
|
|
// how long a USB client is still considered present after its last frame,
|
|
// for targets which cannot report DTR (see setConnectedCheck below)
|
|
#define USB_CLIENT_IDLE_TIMEOUT (10*60*1000UL)
|
|
#endif
|
|
#ifndef USB_FRAME_REPLY_GRACE_MS
|
|
// A complete command frame proves that a Binary Companion client is
|
|
// listening even if an ESP32 HWCDC host-presence flag briefly flaps while
|
|
// the response is being queued. Keep this much time to finish the reply.
|
|
#define USB_FRAME_REPLY_GRACE_MS 2000UL
|
|
#endif
|
|
#ifndef USB_HOST_LOSS_GRACE_MS
|
|
// The bundled ESP32 HWCDC SOF detector declares loss after only a few
|
|
// missed ticks. Debounce that advisory signal before ending a host session.
|
|
#define USB_HOST_LOSS_GRACE_MS 2000UL
|
|
#endif
|
|
#ifndef USB_HOST_LOSS_EDGE_MS
|
|
// HWCDC's framework filter declares loss after roughly 5 ms without SOFs.
|
|
// Require a longer continuous gap before using that signal as a fallback
|
|
// session boundary when the finite BUS_RESET event queue drops its event.
|
|
#define USB_HOST_LOSS_EDGE_MS 100UL
|
|
#endif
|
|
#ifndef USB_TRANSPORT_RESET_RETRY_MS
|
|
// A failed purge can spend up to roughly 150 ms waiting on the live HWCDC
|
|
// writer/ring. Back off enough to keep WiFi and mesh service responsive.
|
|
#define USB_TRANSPORT_RESET_RETRY_MS 1000UL
|
|
#endif
|
|
#if defined(ESP32) && defined(ARDUINO_USB_MODE) && ARDUINO_USB_MODE == 1 \
|
|
&& defined(ARDUINO_USB_CDC_ON_BOOT) && ARDUINO_USB_CDC_ON_BOOT
|
|
mesh::UsbHostPresenceDebouncer usb_hwcdc_host_presence;
|
|
#endif
|
|
#endif
|
|
|
|
#if defined(ENABLE_USB_INTERFACE) && (COMPANION_FEATURE_NETWORK_TERMINAL || defined(WITH_WEBCONFIG))
|
|
static bool isNetworkTerminalActive();
|
|
#endif
|
|
|
|
// include ethernet interface
|
|
#if defined(ETHERNET_ENABLED)
|
|
#include <helpers/ethernet/EthernetInterface.h>
|
|
ETHERNET_CLASS ethernet_interface;
|
|
#endif
|
|
|
|
// include hardware serial interface
|
|
#if defined(SERIAL_RX)
|
|
#include <helpers/ArduinoSerialInterface.h>
|
|
ArduinoSerialInterface hardware_serial_interface;
|
|
#if defined(ENABLE_USB_INTERFACE) && defined(CONFIG_IDF_TARGET_ESP32S3) \
|
|
&& (!defined(ARDUINO_USB_CDC_ON_BOOT) || !ARDUINO_USB_CDC_ON_BOOT)
|
|
// USB-UART boards can name the same pins as an additional serial transport.
|
|
// Give that physical port only one parser and TX owner; native USB is separate.
|
|
static constexpr bool companion_serial_shares_usb_port =
|
|
SERIAL_RX == SOC_RX0 && SERIAL_TX == SOC_TX0;
|
|
#else
|
|
static constexpr bool companion_serial_shares_usb_port = false;
|
|
#endif
|
|
#if defined(CONFIG_IDF_TARGET_ESP32S3) && ENV_INCLUDE_GPS == 1
|
|
// GPS owns UART1. The S3's spare UART2 can use the same Companion pins.
|
|
HardwareSerial companion_serial(2);
|
|
#else
|
|
HardwareSerial companion_serial(1);
|
|
#endif
|
|
#endif
|
|
|
|
// platform file system
|
|
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
|
|
#include <InternalFileSystem.h>
|
|
#if defined(NRF52_PLATFORM)
|
|
#include <helpers/nrf52/InternalPrimaryFsBoot.h>
|
|
#include <helpers/nrf52/RamFallbackFileSystem.h>
|
|
#endif
|
|
#if defined(QSPIFLASH)
|
|
#include <CustomLFS_QSPIFlash.h>
|
|
#include <helpers/nrf52/QspiFlashWake.h>
|
|
DataStore store(InternalFS, QSPIFlash, rtc_clock);
|
|
#else
|
|
#if defined(EXTRAFS)
|
|
#include "ResilientInternalExtraFS.h"
|
|
extern "C" struct lfs_config* meshNrf52VoltagePrimaryFsConfig() {
|
|
return ResilientInternalExtraFS::primaryConfig();
|
|
}
|
|
extern "C" uint32_t __flash_arduino_end[];
|
|
ResilientInternalExtraFS ExtraFS(0xD4000, 0x19000, 128);
|
|
DataStore store(InternalFS, ExtraFS, rtc_clock);
|
|
#else
|
|
DataStore store(InternalFS, rtc_clock);
|
|
#endif
|
|
#endif
|
|
#elif defined(RP2040_PLATFORM)
|
|
#include <LittleFS.h>
|
|
DataStore store(LittleFS, rtc_clock);
|
|
#elif defined(ESP32)
|
|
#include <SPIFFS.h>
|
|
DataStore store(SPIFFS, rtc_clock);
|
|
#endif
|
|
|
|
/* GLOBAL OBJECTS */
|
|
#ifdef DISPLAY_CLASS
|
|
#include "UITask.h"
|
|
UITask ui_task(&board, &interface_manager);
|
|
#endif
|
|
|
|
StdRNG fast_rng;
|
|
SimpleMeshTables tables;
|
|
#if COMPANION_MESH_PSRAM_STORAGE
|
|
static mesh::CompanionPsramMesh<MyMesh> companion_mesh_storage;
|
|
MyMesh& companionMeshInstance() { return companion_mesh_storage.get(); }
|
|
|
|
static bool initializeCompanionMeshStorage() {
|
|
const auto result = companion_mesh_storage.begin(
|
|
radio_driver, fast_rng, rtc_clock, tables, store, &ui_task);
|
|
using Result = mesh::CompanionPsramMesh<MyMesh>::Result;
|
|
if (result == Result::Ready) return true;
|
|
mesh::usbLoggingPort().println(result == Result::MissingPsram
|
|
? "ERROR: Full Companion requires working PSRAM; check the board/image"
|
|
: "ERROR: insufficient PSRAM for Full Companion; startup stopped");
|
|
return false;
|
|
}
|
|
#else
|
|
MyMesh the_mesh(radio_driver, fast_rng, rtc_clock, tables, store
|
|
#ifdef DISPLAY_CLASS
|
|
, &ui_task
|
|
#endif
|
|
);
|
|
#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);
|
|
}
|
|
|
|
// The mesh loop announces the fully enumerated source with a bounded burst.
|
|
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:
|
|
class Source : public mesh::ota::SerialMotaSource {
|
|
public:
|
|
using mesh::ota::SerialMotaSource::SerialMotaSource;
|
|
protected:
|
|
uint16_t responseFrameCapacity() const override {
|
|
return bluetooth_interface.motaResponseCapacity();
|
|
}
|
|
};
|
|
static_assert(sizeof(Source) == sizeof(mesh::ota::SerialMotaSource),
|
|
"BLE source MTU policy must not allocate additional state");
|
|
Source _source;
|
|
uint32_t _packets_sent_at_start;
|
|
uint32_t _last_packets_sent;
|
|
};
|
|
|
|
static Nrf52BleMotaSourceControl ble_mota_source_control;
|
|
#endif
|
|
|
|
/* END GLOBAL OBJECTS */
|
|
|
|
#ifdef RECOVERABLE_EXTERNAL_RADIO
|
|
static bool companion_radio_available = true;
|
|
static unsigned long companion_radio_retry_at = 0;
|
|
static const unsigned long COMPANION_RADIO_RETRY_MS = 60000UL;
|
|
|
|
static uint32_t companionFallbackRngSeed() {
|
|
#if defined(ESP32_PLATFORM)
|
|
return esp_random();
|
|
#else
|
|
uint32_t seed = micros();
|
|
#if defined(NRF52_PLATFORM)
|
|
seed ^= NRF_FICR->DEVICEID[0] ^ NRF_FICR->DEVICEID[1];
|
|
#endif
|
|
#ifdef USE_CC310_HW_CRYPTO
|
|
mesh::mixCC310Random(reinterpret_cast<uint8_t*>(&seed), sizeof(seed));
|
|
#endif
|
|
return seed;
|
|
#endif
|
|
}
|
|
|
|
static void serviceCompanionRadioRecovery() {
|
|
if (companion_radio_available
|
|
|| (long)(millis() - companion_radio_retry_at) < 0) return;
|
|
|
|
companion_radio_retry_at = millis() + COMPANION_RADIO_RETRY_MS;
|
|
mesh::usbLoggingPort().println("Radio recovery probe starting");
|
|
if (!radio_init()) {
|
|
mesh::usbLoggingPort().println(
|
|
"Radio recovery probe failed; companion services remain available");
|
|
return;
|
|
}
|
|
|
|
companion_radio_available = true;
|
|
the_mesh.activateRadio();
|
|
mesh::usbLoggingPort().println("Radio recovered; mesh transport is active");
|
|
}
|
|
#endif
|
|
|
|
#ifdef ESP32_PLATFORM
|
|
static int8_t applied_power_saving = -1;
|
|
static int8_t attempted_power_saving = -1;
|
|
static uint16_t applied_cpu_frequency = UINT16_MAX;
|
|
static uint16_t attempted_cpu_frequency = UINT16_MAX;
|
|
static unsigned long power_saving_retry_at = 0;
|
|
|
|
#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 uint16_t selected = the_mesh.getNodePrefs()->cpu_freq_mhz;
|
|
board.setRadioProfileCpuClockPolicy(the_mesh.isDualRadioActive());
|
|
const uint32_t max_mhz = board.resolveCpuFrequencyMHz(selected, enabled);
|
|
if (!board.applyCpuFrequencyMHz(selected, enabled)) {
|
|
mesh::usbLoggingPort().printf("Device power saving failed: CPU clock policy rejected\r\n");
|
|
return false;
|
|
}
|
|
#if COMPANION_IDF_PM_AVAILABLE
|
|
const uint32_t min_mhz = !selected && enabled && max_mhz > 40
|
|
&& !mesh::esp32::cpuclock::profilePerformanceEnabled(the_mesh.isDualRadioActive())
|
|
? 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
|
|
#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)
|
|
| (mesh::esp32::cpuclock::profilePerformanceEnabled(the_mesh.isDualRadioActive()) ? 2 : 0);
|
|
const uint16_t selected = the_mesh.getNodePrefs()->cpu_freq_mhz;
|
|
if (!force && applied_power_saving == requested && applied_cpu_frequency == selected) return;
|
|
|
|
const unsigned long now = millis();
|
|
if (!force && attempted_power_saving == requested && attempted_cpu_frequency == selected
|
|
&& power_saving_retry_at != 0
|
|
&& (int32_t)(now - power_saving_retry_at) < 0) {
|
|
return;
|
|
}
|
|
|
|
attempted_power_saving = requested;
|
|
attempted_cpu_frequency = selected;
|
|
if (applyCompanionPowerSaving((requested & 1) != 0)) {
|
|
applied_power_saving = requested;
|
|
applied_cpu_frequency = selected;
|
|
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);
|
|
}
|
|
#if defined(OTA_RAK15002_SD_STORE) && defined(ETHERNET_USE_RAK13800)
|
|
static bool prepareCompanionRakEthernet(void*) {
|
|
auto* context = mesh::ota::ota_context_if_active();
|
|
return !context || context->prepareRakSdForEthernet();
|
|
}
|
|
static bool canReleaseCompanionRakEthernet(void*) {
|
|
auto* context = mesh::ota::ota_context_if_active();
|
|
return !context || context->canReleaseRakSdForEthernet();
|
|
}
|
|
static bool releasedCompanionRakEthernet(void*) {
|
|
auto* context = mesh::ota::ota_context_if_active();
|
|
return !context || context->releasedRakSdEthernet();
|
|
}
|
|
|
|
bool handleRak4631PeripheralCommand(const char* command, char* reply, size_t capacity) {
|
|
const bool get = strcmp(command, "get eth") == 0;
|
|
const char* value = strncmp(command, "set eth ", 8) == 0 ? command + 8 : nullptr;
|
|
if (get || value) {
|
|
if (value && strcmp(value, "on") && strcmp(value, "off")) {
|
|
snprintf(reply, capacity, "Error: use set eth on|off");
|
|
} else if (value && !(strcmp(value, "on") == 0
|
|
? ethernet_interface.tryEnable() : ethernet_interface.tryDisable())) {
|
|
snprintf(reply, capacity, "Error: %s", ethernet_interface.last_error());
|
|
} else {
|
|
snprintf(reply, capacity, "%s eth=%s; RAK13800 (this boot)", get ? ">" : "OK -",
|
|
ethernet_interface.status());
|
|
}
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
#endif
|
|
#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<size_t>(password - usb_terminal_line);
|
|
}
|
|
|
|
if (visible_len > 0) {
|
|
usbTerminalOutput().write(
|
|
reinterpret_cast<const uint8_t*>(usb_terminal_line), visible_len);
|
|
}
|
|
for (size_t i = visible_len; i < usb_terminal_line_len; i++) {
|
|
usbTerminalOutput().print('*');
|
|
}
|
|
}
|
|
|
|
static void redrawUsbTerminalInput() {
|
|
// The documented picocom `--imap spchex` converts an echoed BS to "[08]".
|
|
// It deliberately leaves CR untouched, so redraw the edited line with CR
|
|
// and printable bytes only. Padding clears a removed tab or wide glyph.
|
|
usbTerminalOutput().print("\r> ");
|
|
printUsbTerminalInputEcho();
|
|
usbTerminalOutput().print(" ");
|
|
usbTerminalOutput().print("\r> ");
|
|
printUsbTerminalInputEcho();
|
|
}
|
|
|
|
static bool isUsbTerminalDataConnected() {
|
|
#if defined(NRF52_PLATFORM)
|
|
return mesh::isUsbCompanionClientConnected();
|
|
#elif defined(RP2040_PLATFORM)
|
|
return (bool)Serial;
|
|
#else
|
|
return board.isUsbDataConnected();
|
|
#endif
|
|
}
|
|
|
|
static constexpr uint32_t USB_TERMINAL_PROTOCOL_DRAIN_MS = 250;
|
|
|
|
static void queueUsbTerminalControlReply(const char* reply) {
|
|
if (reply == nullptr) return;
|
|
const size_t size = strlen(reply);
|
|
if (usbTerminalOutput().write(
|
|
reinterpret_cast<const uint8_t*>(reply), size) != size) {
|
|
// A transition reply takes priority over stale best-effort terminal text.
|
|
// Clear only the application queue; TinyUSB retains any prefix already
|
|
// accepted, so Binary/mOTA still cannot overtake it.
|
|
mesh::discardUsbTerminalOutput();
|
|
(void)usbTerminalOutput().write(
|
|
reinterpret_cast<const uint8_t*>(reply), size);
|
|
}
|
|
}
|
|
|
|
static void drainUsbTerminalOutputBeforeProtocolSwitch() {
|
|
const uint32_t started = millis();
|
|
while (mesh::hasPendingUsbTerminalOutput()
|
|
#if !(defined(ESP32) && defined(ARDUINO_USB_MODE) && ARDUINO_USB_MODE == 1 \
|
|
&& defined(ARDUINO_USB_CDC_ON_BOOT) && ARDUINO_USB_CDC_ON_BOOT)
|
|
&& isUsbTerminalDataConnected()
|
|
#endif
|
|
&& (uint32_t)(millis() - started)
|
|
< USB_TERMINAL_PROTOCOL_DRAIN_MS) {
|
|
mesh::serviceUsbTerminalPort();
|
|
#if defined(NRF52_PLATFORM)
|
|
board.feedWatchdog();
|
|
#endif
|
|
delay(1);
|
|
}
|
|
// Never let terminal bytes remain queued after another CDC0 protocol is
|
|
// enabled. A draining host normally empties this in a few milliseconds; an
|
|
// unread host loses only the residual text after the bounded deadline.
|
|
mesh::discardUsbTerminalOutput();
|
|
}
|
|
|
|
static bool hasObservableActiveUsbTerminalClient() {
|
|
#if defined(ESP32) && defined(ARDUINO_USB_MODE) && ARDUINO_USB_MODE == 1 \
|
|
&& defined(ARDUINO_USB_CDC_ON_BOOT) && ARDUINO_USB_CDC_ON_BOOT
|
|
// HWCDC exposes only whether USB is plugged in, not whether a host process
|
|
// has opened the terminal. Treat actual buffered activity as ownership; an
|
|
// idle physical cable must not permanently lock TCP port 5002.
|
|
return usb_terminal_line_len != 0 || usb_terminal_discard_line;
|
|
#else
|
|
return isUsbTerminalDataConnected();
|
|
#endif
|
|
}
|
|
|
|
static void cancelUsbSerialOperations() {
|
|
#if MESH_USB_LOGGING_AVAILABLE
|
|
// A cancelled reply must not enable diagnostics for the next USB host.
|
|
usb_logging_reply_pending = false;
|
|
#endif
|
|
// Contact streams and delayed replies capture their own destinations.
|
|
// Cancel only USB's ownership so simultaneous BLE/WiFi work keeps running.
|
|
the_mesh.cancelSerialResponseStream(&usb_serial_interface);
|
|
the_mesh.cancelSerialOperationsForRoute(&usb_serial_interface);
|
|
interface_manager.forgetReplyRouteForDisconnected(&usb_serial_interface);
|
|
}
|
|
|
|
static void enterUsbTerminalMode(bool show_banner = true) {
|
|
usb_binary_startup_probe.cancel();
|
|
usb_ascii_session_default.cancel();
|
|
cancelUsbSerialOperations();
|
|
mesh::discardUsbTerminalOutput();
|
|
usb_serial_interface.setPassthroughMode(true);
|
|
clearUsbTerminalLine();
|
|
usb_terminal_discard_line = false;
|
|
usb_logging_terminal_mode = false;
|
|
the_mesh.enterTerminalMode(show_banner);
|
|
}
|
|
|
|
static void enterUsbLoggingTerminalMode() {
|
|
enterUsbTerminalMode();
|
|
usb_logging_terminal_mode = true;
|
|
}
|
|
|
|
static void leaveUsbTerminalMode(bool acknowledge) {
|
|
if (acknowledge) {
|
|
queueUsbTerminalControlReply("\r\nOK - Binary mode\r\n");
|
|
drainUsbTerminalOutputBeforeProtocolSwitch();
|
|
} else {
|
|
mesh::discardUsbTerminalOutput();
|
|
}
|
|
the_mesh.exitTerminalMode();
|
|
// A network handoff can leave the USB CLI while diagnostics remain enabled.
|
|
// Do not briefly reopen the framed transport on that same logging stream.
|
|
usb_serial_interface.setPassthroughMode(
|
|
!mesh::hasDedicatedUsbLoggingPort() && mesh::isUsbLoggingEnabled());
|
|
clearUsbTerminalLine();
|
|
usb_terminal_discard_line = false;
|
|
usb_logging_terminal_mode = false;
|
|
}
|
|
|
|
#if COMPANION_FEATURE_USB_MOTA_SOURCE
|
|
static void resetUsbMotaMode() {
|
|
mesh::discardUsbTerminalOutput();
|
|
usb_mota_mode = false;
|
|
usb_mota_line_len = 0;
|
|
usb_mota_line[0] = 0;
|
|
usb_mota_discard_line = false;
|
|
usb_mota_disconnect_armed = false;
|
|
// A software owner change is not a transport reset. Retain any late binary
|
|
// reply and quarantine input until its known boundary is consumed.
|
|
usb_serial_interface.setPassthroughMode(
|
|
mesh::ota::OtaContext::serialFolderSource().hasPendingResponse());
|
|
}
|
|
|
|
static void leaveUsbMotaMode(bool acknowledge) {
|
|
const bool restore_ascii = acknowledge
|
|
&& usb_mota_entry_origin == mesh::UsbMotaEntryOrigin::ASCII;
|
|
char reply[160] = {0};
|
|
the_mesh.handleLocalControlCommand("ota folder off", reply, sizeof(reply));
|
|
if (acknowledge) {
|
|
char transition_reply[224];
|
|
snprintf(transition_reply, sizeof(transition_reply),
|
|
"\r\n%s\r\nOK - %s mode\r\n", reply,
|
|
restore_ascii ? "Terminal" : "Binary");
|
|
queueUsbTerminalControlReply(transition_reply);
|
|
drainUsbTerminalOutputBeforeProtocolSwitch();
|
|
}
|
|
resetUsbMotaMode();
|
|
// A text-first motatool session must hand CDC0 back to the text terminal.
|
|
// Otherwise closing the sender leaves this still-open USB host in Binary
|
|
// mode, and its next `ver` or `ota status` appears to get no reply.
|
|
if (restore_ascii) enterUsbTerminalMode(false);
|
|
}
|
|
|
|
static bool enterUsbMotaMode(mesh::UsbMotaEntryOrigin origin) {
|
|
// An explicit seeder command selects its own protocol owner, even if it
|
|
// arrived after a rejected startup frame. Neither a stale Binary deadline
|
|
// nor deferred default-ASCII restoration may reclaim a live mOTA stream.
|
|
usb_binary_startup_probe.cancel();
|
|
usb_ascii_session_default.cancel();
|
|
auto& source = mesh::ota::OtaContext::serialFolderSource();
|
|
if (source.hasPendingResponse()) {
|
|
queueUsbTerminalControlReply("\r\nERR waiting for previous mOTA response\r\n");
|
|
if (mesh::shouldRestoreAsciiAfterMotaFailure(origin)) enterUsbTerminalMode();
|
|
return false;
|
|
}
|
|
cancelUsbSerialOperations();
|
|
mesh::discardUsbTerminalOutput();
|
|
source.enableSharedTextControl();
|
|
source.resetSessionState();
|
|
usb_serial_interface.setPassthroughMode(true);
|
|
usb_mota_mode = true;
|
|
usb_mota_entry_origin = origin;
|
|
usb_mota_line_len = 0;
|
|
usb_mota_line[0] = 0;
|
|
usb_mota_discard_line = false;
|
|
usb_mota_disconnect_armed = isUsbTerminalDataConnected();
|
|
#if defined(ESP32) && defined(ARDUINO_USB_MODE) && ARDUINO_USB_MODE == 1 \
|
|
&& defined(ARDUINO_USB_CDC_ON_BOOT) && ARDUINO_USB_CDC_ON_BOOT
|
|
// A valid attach command is stronger host proof than HWCDC's advisory SOF
|
|
// sample, which may be low in the same loop as the received command.
|
|
usb_hwcdc_host_presence.noteClientActivity();
|
|
#endif
|
|
|
|
char reply[160] = {0};
|
|
if (!the_mesh.handleLocalControlCommand("ota folder on", reply, sizeof(reply))
|
|
|| strncmp(reply, "ERR", 3) == 0) {
|
|
char failure_reply[224];
|
|
snprintf(failure_reply, sizeof(failure_reply), "\r\n%s\r\n",
|
|
reply[0] ? reply : "ERR could not enter mOTA seeder mode");
|
|
queueUsbTerminalControlReply(failure_reply);
|
|
drainUsbTerminalOutputBeforeProtocolSwitch();
|
|
resetUsbMotaMode();
|
|
if (mesh::shouldRestoreAsciiAfterMotaFailure(origin)) {
|
|
enterUsbTerminalMode();
|
|
}
|
|
return false;
|
|
}
|
|
char attached_reply[224];
|
|
snprintf(attached_reply, sizeof(attached_reply), "\r\n%s\r\n", reply);
|
|
queueUsbTerminalControlReply(attached_reply);
|
|
// The mOTA request stream writes directly through the single-attempt CDC0
|
|
// facade. Finish this text barrier first so its first binary frame can never
|
|
// overtake a queued attach reply.
|
|
drainUsbTerminalOutputBeforeProtocolSwitch();
|
|
return true;
|
|
}
|
|
|
|
static void serviceUsbMota() {
|
|
#if !(defined(ESP32) && defined(ARDUINO_USB_MODE) && ARDUINO_USB_MODE == 1 \
|
|
&& defined(ARDUINO_USB_CDC_ON_BOOT) && ARDUINO_USB_CDC_ON_BOOT)
|
|
if (isUsbTerminalDataConnected()) {
|
|
usb_mota_disconnect_armed = true;
|
|
} else if (usb_mota_disconnect_armed) {
|
|
leaveUsbMotaMode(false);
|
|
return;
|
|
}
|
|
#endif
|
|
|
|
// A failed transaction can leave a late binary response, not just host text.
|
|
// Retain its known body boundary before recognizing clean shutdown controls.
|
|
// The static source accessor never allocates/acquires an OTA context here.
|
|
auto& source = mesh::ota::OtaContext::serialFolderSource();
|
|
int pending = source.availableControlBytes();
|
|
while (pending-- > 0) {
|
|
int value = source.readControlByte();
|
|
if (value == mesh::ota::SerialMotaSource::RESPONSE_BYTE) continue;
|
|
if (value < 0) break;
|
|
char c = (char)value;
|
|
if (usb_mota_discard_line) {
|
|
if (c == '\r' || c == '\n') usb_mota_discard_line = false;
|
|
continue;
|
|
}
|
|
if (c == '\r' || c == '\n') {
|
|
if (usb_mota_line_len == 0) continue;
|
|
usb_mota_line[usb_mota_line_len] = 0;
|
|
bool stop = strcmp(usb_mota_line, "ota folder off") == 0;
|
|
usb_mota_line_len = 0;
|
|
usb_mota_line[0] = 0;
|
|
if (stop) {
|
|
leaveUsbMotaMode(true);
|
|
return;
|
|
}
|
|
continue;
|
|
}
|
|
if (c != 0 && usb_mota_line_len < sizeof(usb_mota_line) - 1) {
|
|
usb_mota_line[usb_mota_line_len++] = c;
|
|
usb_mota_line[usb_mota_line_len] = 0;
|
|
} else {
|
|
usb_mota_line_len = 0;
|
|
usb_mota_line[0] = 0;
|
|
usb_mota_discard_line = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
static bool serviceUsbMotaResponseBoundary() {
|
|
if (usb_mota_mode) return false;
|
|
auto& source = mesh::ota::OtaContext::serialFolderSource();
|
|
if (!source.hasPendingResponse()) return false;
|
|
// Failed attach or software handoff must not turn late binary data into
|
|
// terminal commands or Companion frames. Input typed while waiting is
|
|
// discarded; only an actual response boundary or host reset ends the wait.
|
|
if (!usb_serial_interface.isPassthroughMode()) {
|
|
usb_serial_interface.setPassthroughMode(true);
|
|
}
|
|
int pending = source.availableControlBytes();
|
|
while (pending-- > 0) {
|
|
if (source.readControlByte() == -1) break;
|
|
if (!source.hasPendingResponse()) {
|
|
source.resetSessionState();
|
|
usb_serial_interface.setPassthroughMode(
|
|
the_mesh.isTerminalMode() || usb_logging_network_parked
|
|
|| (!mesh::hasDedicatedUsbLoggingPort()
|
|
&& mesh::isUsbLoggingEnabled()));
|
|
break;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
#endif
|
|
|
|
// Consume an exact TinyUSB owner-task edge, or HWCDC's framework-debounced SOF
|
|
// loss edge, before any protocol parser runs. No line, login, recipient, queued
|
|
// text, or mOTA attachment may survive into the next host session.
|
|
static bool usb_terminal_host_reset_completion_pending = false;
|
|
static uint32_t usb_terminal_host_reset_retry_at = 0;
|
|
|
|
static void resetUsbTerminalHostSession() {
|
|
#if COMPANION_FEATURE_READER
|
|
mesh::cancelReaderCommand(&mesh::usbTerminalPort());
|
|
#endif
|
|
// Stop only work routed to the old USB host; a simultaneous BLE/WiFi
|
|
// transaction is independent and must continue. Clear the route after
|
|
// stopping its producer so no response can fall back to another transport.
|
|
cancelUsbSerialOperations();
|
|
the_mesh.resetUsbHostSessionInput();
|
|
#if COMPANION_FEATURE_USB_MOTA_SOURCE
|
|
if (usb_mota_mode) {
|
|
leaveUsbMotaMode(false);
|
|
}
|
|
mesh::ota::OtaContext::serialFolderSource().resetSessionState();
|
|
#endif
|
|
if (the_mesh.isTerminalMode()) leaveUsbTerminalMode(false);
|
|
mesh::discardUsbTerminalOutput();
|
|
usb_serial_interface.setPassthroughMode(false);
|
|
clearUsbTerminalLine();
|
|
usb_terminal_discard_line = false;
|
|
usb_logging_terminal_mode = false;
|
|
usb_binary_startup_probe.cancel();
|
|
// setPassthroughMode() clears parser/queue state but frame ownership is a
|
|
// separate proof. Revoke all three explicitly at the session boundary.
|
|
usb_serial_interface.resetSessionState();
|
|
usb_ascii_session_default.request(
|
|
usb_serial_interface.getCompletedFrameCount());
|
|
#if defined(ESP32) && defined(ARDUINO_USB_MODE) && ARDUINO_USB_MODE == 1 \
|
|
&& defined(ARDUINO_USB_CDC_ON_BOOT) && ARDUINO_USB_CDC_ON_BOOT
|
|
// Purge HWCDC's queues behind a temporary diagnostics gate. This keeps the
|
|
// core's shared RTOS objects alive while other firmware tasks are running.
|
|
if (!mesh::resetUsbCompanionTransport()) {
|
|
// Keep the producer gate and PHY quarantined, then retry from later loops.
|
|
// Rebooting on repeatable allocation/contention failures creates a host-
|
|
// enumeration restart loop and still does not prove stale bytes were gone.
|
|
usb_terminal_host_reset_completion_pending = true;
|
|
usb_terminal_host_reset_retry_at = millis() + USB_TRANSPORT_RESET_RETRY_MS;
|
|
return;
|
|
}
|
|
usb_terminal_host_reset_completion_pending = false;
|
|
usb_hwcdc_host_presence.reset();
|
|
#endif
|
|
}
|
|
|
|
static void serviceUsbTerminalHostSessionReset() {
|
|
#if defined(NRF52_PLATFORM) || defined(RP2040_PLATFORM) \
|
|
|| MESH_ESP32_TINYUSB_NONBLOCKING
|
|
// Observe disconnects even while Binary or mOTA owns USB. Owner-task reset
|
|
// events also catch a close/reopen that happens between two main-loop polls.
|
|
const bool connected = isUsbTerminalDataConnected();
|
|
const bool disconnected = usb_host_session_connected && !connected;
|
|
usb_host_session_connected = connected;
|
|
if (mesh::takeUsbTerminalSessionReset() || disconnected) {
|
|
usb_terminal_host_reset_completion_pending = true;
|
|
resetUsbTerminalHostSession();
|
|
}
|
|
|
|
if (usb_terminal_host_reset_completion_pending
|
|
&& mesh::tryCompleteUsbTerminalSessionReset()) {
|
|
usb_terminal_host_reset_completion_pending = false;
|
|
}
|
|
#elif defined(ESP32) && defined(ARDUINO_USB_MODE) && ARDUINO_USB_MODE == 1 \
|
|
&& defined(ARDUINO_USB_CDC_ON_BOOT) && ARDUINO_USB_CDC_ON_BOOT
|
|
// Consume hardware and watchdog boundaries before retrying their cleanup.
|
|
// This clears the old USB owner exactly once, including when its queues are
|
|
// already empty, before the same quarantined epoch is allowed to reopen.
|
|
const bool hardware_bus_reset = mesh::takeUsbTerminalSessionReset();
|
|
if (hardware_bus_reset) {
|
|
resetUsbTerminalHostSession();
|
|
return;
|
|
}
|
|
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 physical_host_loss =
|
|
usb_hwcdc_host_presence.takeHostLossEdge();
|
|
const bool sustained_host_loss =
|
|
usb_hwcdc_host_presence.takeSustainedHostLoss();
|
|
if (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();
|
|
}
|
|
|
|
#if defined(ESP32_PLATFORM) \
|
|
&& (!defined(ARDUINO_USB_CDC_ON_BOOT) || !ARDUINO_USB_CDC_ON_BOOT) \
|
|
&& !defined(SERIAL_RX) && !defined(SERIAL_TX)
|
|
static bool usb_uart_quiet_parked = false;
|
|
static bool usb_uart_quiet_restore_enabled = false;
|
|
|
|
static bool transitionCompanionUartQuietSession(
|
|
void*, mesh::esp32::UartQuietSessionAction action) {
|
|
if (action == mesh::esp32::UartQuietSessionAction::Park) {
|
|
if (usb_uart_quiet_parked) return true;
|
|
if (!usb_protocol_initialized || usb_serial_interface.hasPendingIO()
|
|
|| mesh::hasPendingUsbTerminalOutput()) return false;
|
|
#if COMPANION_FEATURE_USB_MOTA_SOURCE
|
|
if (usb_mota_mode
|
|
|| mesh::ota::OtaContext::serialFolderSource().hasPendingResponse()) return false;
|
|
#endif
|
|
usb_uart_quiet_restore_enabled = usb_serial_interface.isEnabled();
|
|
// End only this host's ownership. The inbox and BLE/WiFi transactions
|
|
// remain live while the physical UART is unavailable.
|
|
cancelUsbSerialOperations();
|
|
#if COMPANION_FEATURE_READER
|
|
mesh::cancelReaderCommand(&mesh::usbTerminalPort());
|
|
#endif
|
|
the_mesh.resetUsbHostSessionInput();
|
|
if (the_mesh.isTerminalMode()) leaveUsbTerminalMode(false);
|
|
usb_binary_startup_probe.cancel();
|
|
usb_ascii_session_default.cancel();
|
|
mesh::discardUsbTerminalOutput();
|
|
clearUsbTerminalLine();
|
|
usb_terminal_discard_line = false;
|
|
usb_logging_terminal_mode = false;
|
|
usb_serial_interface.disable();
|
|
usb_uart_quiet_parked = true;
|
|
return true;
|
|
}
|
|
|
|
if (!usb_uart_quiet_parked) return true;
|
|
if (!Serial) return false; // The board must restore the driver first.
|
|
if (usb_uart_quiet_restore_enabled && interface_manager.isEnabled()) {
|
|
usb_serial_interface.enable();
|
|
// A new session must not inherit old parser bytes or reply destinations.
|
|
// Default ASCII is silent until input, so a framed client gets no banner.
|
|
beginUsbDefaultSession();
|
|
}
|
|
usb_uart_quiet_parked = false;
|
|
usb_uart_quiet_restore_enabled = false;
|
|
return true;
|
|
}
|
|
#endif
|
|
|
|
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");
|
|
}
|
|
#endif
|
|
|
|
#if defined(BLE_PIN_CODE)
|
|
static void releaseCompanionBluetoothMemoryForWiFi() {
|
|
const esp_err_t result = esp_bt_mem_release(ESP_BT_MODE_BTDM);
|
|
if (result == ESP_OK) {
|
|
mesh::usbLoggingPort().println(
|
|
"Companion transport: released Bluetooth memory for exclusive WiFi mode");
|
|
} else {
|
|
mesh::usbLoggingPort().printf(
|
|
"Companion transport: Bluetooth memory release failed: %s\r\n",
|
|
esp_err_to_name(result));
|
|
}
|
|
}
|
|
#endif
|
|
|
|
static constexpr uint32_t COMPANION_WIFI_NTP_TIMEOUT_MS = 15000UL;
|
|
static mesh::wifi::CompanionWiFiNtpPolicy companion_wifi_ntp_policy;
|
|
static std::atomic<uint32_t> companion_wifi_ntp_operation_generation{0};
|
|
static std::atomic<uint32_t> companion_wifi_ntp_proof_generation{0};
|
|
static std::atomic<uint32_t> companion_wifi_ntp_epoch{0};
|
|
static std::atomic<uint32_t> companion_wifi_ntp_proven_at{0};
|
|
static bool companion_wifi_ntp_pending = false;
|
|
static uint32_t companion_wifi_ntp_started = 0;
|
|
static uint32_t companion_wifi_ntp_previous_epoch = 0;
|
|
|
|
static void clearCompanionWiFiNtpCallback() {
|
|
companion_wifi_ntp_operation_generation.store(
|
|
0, std::memory_order_release);
|
|
esp_sntp_set_time_sync_notification_cb(nullptr);
|
|
// The policy below owns the cadence. Leave no SDK-default (three-hour in
|
|
// the current ESP32 toolchain) background poll running between the boot
|
|
// request and the explicit 24-hour refresh.
|
|
esp_sntp_stop();
|
|
}
|
|
|
|
static mesh::sntp_coord::OperationLease companion_wifi_ntp_operation(
|
|
mesh::sntp_coord::processWideCoordinator(),
|
|
clearCompanionWiFiNtpCallback);
|
|
|
|
static void noteCompanionWiFiNtpTime(struct timeval* value) {
|
|
const uint32_t generation =
|
|
companion_wifi_ntp_operation_generation.load(
|
|
std::memory_order_acquire);
|
|
if (!mesh::sntp_coord::processWideCoordinator().owns(generation)) return;
|
|
if (value == nullptr || !mesh::tls_clock::timeIsValid(value->tv_sec)
|
|
|| static_cast<uint64_t>(value->tv_sec) > UINT32_MAX) {
|
|
return;
|
|
}
|
|
companion_wifi_ntp_epoch.store(
|
|
static_cast<uint32_t>(value->tv_sec), std::memory_order_release);
|
|
companion_wifi_ntp_proven_at.store(millis(), std::memory_order_release);
|
|
companion_wifi_ntp_proof_generation.store(
|
|
generation, std::memory_order_release);
|
|
}
|
|
|
|
static bool companionWiFiMqttOwnsNtp() {
|
|
#ifdef WITH_MQTT_BRIDGE
|
|
const bool running = the_mesh.isMQTTRunning();
|
|
if (running && the_mesh.hasFreshMQTTNtpThisBoot()) {
|
|
the_mesh.noteInternetClockSet();
|
|
}
|
|
return running;
|
|
#else
|
|
return false;
|
|
#endif
|
|
}
|
|
|
|
static void cancelCompanionWiFiNtp(bool retry_on_reconnect) {
|
|
if (!companion_wifi_ntp_pending) return;
|
|
companion_wifi_ntp_pending = false;
|
|
companion_wifi_ntp_operation.release();
|
|
if (retry_on_reconnect) companion_wifi_ntp_policy.requestNow();
|
|
}
|
|
|
|
static void serviceCompanionWiFiNtp() {
|
|
if (mesh::wifi::networkPower().backgroundSuspended()) {
|
|
cancelCompanionWiFiNtp(false);
|
|
return;
|
|
}
|
|
// Configured MQTT builds already perform a fresh startup NTP sync and a
|
|
// daily refresh. The common Companion owner covers every non-MQTT or
|
|
// runtime-unconfigured WiFi node without racing MQTT's SNTP proof.
|
|
if (companionWiFiMqttOwnsNtp()) {
|
|
cancelCompanionWiFiNtp(false);
|
|
companion_wifi_ntp_policy.requestNow();
|
|
return;
|
|
}
|
|
|
|
if (WiFi.status() != WL_CONNECTED) {
|
|
cancelCompanionWiFiNtp(true);
|
|
return;
|
|
}
|
|
|
|
const uint32_t now = millis();
|
|
if (companion_wifi_ntp_pending) {
|
|
const uint32_t proof_epoch = companion_wifi_ntp_epoch.load(
|
|
std::memory_order_acquire);
|
|
const uint32_t proof_generation =
|
|
companion_wifi_ntp_proof_generation.load(
|
|
std::memory_order_acquire);
|
|
const uint32_t proven_at = companion_wifi_ntp_proven_at.load(
|
|
std::memory_order_acquire);
|
|
if (proof_epoch != 0 && companion_wifi_ntp_operation.owns()
|
|
&& proof_generation == companion_wifi_ntp_operation.generation()
|
|
&& proven_at - companion_wifi_ntp_started <=
|
|
COMPANION_WIFI_NTP_TIMEOUT_MS) {
|
|
// The callback's epoch is freshness proof, not the value to install:
|
|
// the main loop may consume it one or more seconds later. Copy the
|
|
// current ESP system clock so an external RTC is not immediately
|
|
// written behind the time that SNTP established.
|
|
const time_t raw_system_time = time(nullptr);
|
|
if (mesh::tls_clock::timeIsValid(raw_system_time)
|
|
&& static_cast<uint64_t>(raw_system_time) <= UINT32_MAX) {
|
|
const uint32_t epoch = static_cast<uint32_t>(raw_system_time);
|
|
const bool moved_backward =
|
|
epoch < companion_wifi_ntp_previous_epoch;
|
|
rtc_clock.setCurrentTime(epoch);
|
|
if (moved_backward) rtc_clock.resetUniqueTime(epoch);
|
|
the_mesh.noteInternetClockSet();
|
|
|
|
const int64_t adjustment = static_cast<int64_t>(epoch)
|
|
- static_cast<int64_t>(companion_wifi_ntp_previous_epoch);
|
|
mesh::usbLoggingPort().printf(
|
|
"Companion WiFi NTP: clock synchronized (%lu, adjustment %+lld seconds)\r\n",
|
|
(unsigned long)epoch, (long long)adjustment);
|
|
companion_wifi_ntp_pending = false;
|
|
companion_wifi_ntp_operation.release();
|
|
companion_wifi_ntp_policy.noteSuccess(now);
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (now - companion_wifi_ntp_started >=
|
|
COMPANION_WIFI_NTP_TIMEOUT_MS) {
|
|
mesh::usbLoggingPort().println(
|
|
"Companion WiFi NTP: timed out; retrying in 5 minutes");
|
|
companion_wifi_ntp_pending = false;
|
|
companion_wifi_ntp_operation.release();
|
|
companion_wifi_ntp_policy.noteFailure(now);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (!companion_wifi_ntp_policy.attemptDue(now)) return;
|
|
if (!companion_wifi_ntp_operation.tryAcquire()) {
|
|
companion_wifi_ntp_policy.noteBusy(now);
|
|
return;
|
|
}
|
|
|
|
companion_wifi_ntp_epoch.store(0, std::memory_order_release);
|
|
companion_wifi_ntp_proven_at.store(0, std::memory_order_release);
|
|
companion_wifi_ntp_proof_generation.store(0, std::memory_order_release);
|
|
companion_wifi_ntp_operation_generation.store(
|
|
companion_wifi_ntp_operation.generation(),
|
|
std::memory_order_release);
|
|
companion_wifi_ntp_started = now;
|
|
companion_wifi_ntp_previous_epoch = rtc_clock.getCurrentTime();
|
|
companion_wifi_ntp_pending = true;
|
|
esp_sntp_set_time_sync_notification_cb(noteCompanionWiFiNtpTime);
|
|
esp_sntp_set_sync_status(SNTP_SYNC_STATUS_RESET);
|
|
configTime(0, 0, "time.cloudflare.com", "time.google.com",
|
|
"pool.ntp.org");
|
|
mesh::usbLoggingPort().println(
|
|
"Companion WiFi NTP: requesting fresh UTC time");
|
|
}
|
|
|
|
static bool companionWiFiBluetoothActive() {
|
|
#if defined(COMPANION_EXCLUSIVE_WIFI_BLE)
|
|
const CompanionTransportMode active_mode =
|
|
companion_transport_boot_mode_loaded
|
|
? companion_transport_boot_mode
|
|
: getCompanionTransportMode();
|
|
return active_mode == CompanionTransportMode::Bluetooth;
|
|
#elif defined(BLE_PIN_CODE)
|
|
return mesh::wifi::activeCompanionMode()
|
|
!= mesh::wifi::CompanionMode::WiFiOnly;
|
|
#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;
|
|
}
|
|
const bool bluetooth_conflict = mesh::wifi::effectivePowerSave(
|
|
mode, companionWiFiBluetoothActive(),
|
|
mesh::wifi::kPrimaryEspNowRadio)
|
|
!= mode;
|
|
const bool next_boot_wifi_only =
|
|
#if defined(COMPANION_EXCLUSIVE_WIFI_BLE)
|
|
false;
|
|
#else
|
|
mesh::wifi::configuredCompanionMode()
|
|
== mesh::wifi::CompanionMode::WiFiOnly;
|
|
#endif
|
|
if (bluetooth_conflict && !next_boot_wifi_only) {
|
|
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.getUChar("powersave", 255) == mode;
|
|
nvs.end();
|
|
if (!saved) return CompanionWiFiPowerSaveResult::StorageError;
|
|
|
|
if (bluetooth_conflict) {
|
|
// Persist the requested next-boot value without feeding WIFI_PS_NONE to
|
|
// the SDK while the current boot still owns a Bluetooth controller.
|
|
return CompanionWiFiPowerSaveResult::SavedForNextBoot;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
bool handleCompanionWiFiCompatibilityCommand(const char* command, char* reply,
|
|
size_t reply_size) {
|
|
if (!command || !reply || !reply_size) return false;
|
|
if (strcmp(command, "get wifi.protocol") == 0) {
|
|
snprintf(reply, reply_size, "%s (boot=%s%s)",
|
|
mesh::wifi::stationProtocolName(mesh::wifi::configuredStationProtocol()),
|
|
mesh::wifi::stationProtocolName(mesh::wifi::activeStationProtocol()),
|
|
mesh::wifi::stationProtocolRestartPending() ? "; reboot required" : "");
|
|
return true;
|
|
}
|
|
if (strncmp(command, "set wifi.protocol", 17) == 0
|
|
&& (command[17] == 0 || command[17] == ' ' || command[17] == '\t')) {
|
|
const char* value = command + 17;
|
|
while (*value == ' ' || *value == '\t') ++value;
|
|
mesh::wifi::StationProtocol selected;
|
|
if (!mesh::wifi::parseStationProtocol(value, &selected)) {
|
|
snprintf(reply, reply_size, "Error: use set wifi.protocol bg|bgn");
|
|
} else {
|
|
const auto result = mesh::wifi::saveStationProtocol(selected);
|
|
if (result == mesh::wifi::CompatibilitySaveResult::RadioConflict) {
|
|
snprintf(reply, reply_size, "Error: bg conflicts with ESP-NOW/LR; use bgn");
|
|
} else if (result != mesh::wifi::CompatibilitySaveResult::Saved) {
|
|
snprintf(reply, reply_size, "Error: WiFi protocol could not be saved");
|
|
} else {
|
|
snprintf(reply, reply_size, "OK - wifi.protocol %s saved; reboot required", value);
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
#if !defined(COMPANION_EXCLUSIVE_WIFI_BLE)
|
|
if (strcmp(command, "get wifi.mode") == 0) {
|
|
snprintf(reply, reply_size, "%s (boot=%s%s)",
|
|
mesh::wifi::companionModeName(mesh::wifi::configuredCompanionMode()),
|
|
mesh::wifi::companionModeName(mesh::wifi::activeCompanionMode()),
|
|
mesh::wifi::companionModeRestartPending() ? "; reboot required" : "");
|
|
return true;
|
|
}
|
|
if (strncmp(command, "set wifi.mode", 13) == 0
|
|
&& (command[13] == 0 || command[13] == ' ' || command[13] == '\t')) {
|
|
const char* value = command + 13;
|
|
while (*value == ' ' || *value == '\t') ++value;
|
|
mesh::wifi::CompanionMode selected;
|
|
if (!mesh::wifi::parseCompanionMode(value, &selected)) {
|
|
snprintf(reply, reply_size, "Error: use set wifi.mode normal|wifi-only");
|
|
} else if (selected == mesh::wifi::CompanionMode::WiFiOnly
|
|
&& !the_mesh.getNodePrefs()->wifi_enabled) {
|
|
snprintf(reply, reply_size, "Error: set wifi.enabled 1 before selecting wifi-only");
|
|
} else if (mesh::wifi::saveCompanionMode(selected)
|
|
!= mesh::wifi::CompatibilitySaveResult::Saved) {
|
|
snprintf(reply, reply_size, "Error: WiFi mode could not be saved");
|
|
} else {
|
|
snprintf(reply, reply_size, "OK - wifi.mode %s saved; reboot required%s", value,
|
|
selected == mesh::wifi::CompanionMode::WiFiOnly
|
|
? "; Bluetooth will not start" : "");
|
|
}
|
|
return true;
|
|
}
|
|
#endif
|
|
if (strcmp(command, "get wifi.status") == 0) {
|
|
formatCompanionWiFiStatus(reply, reply_size);
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
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;
|
|
#if defined(HELTEC_V4_DISPLAY_UNIFIED) && HELTEC_V4_DISPLAY_UNIFIED \
|
|
&& defined(DISPLAY_CLASS)
|
|
companion_display_available =
|
|
display.selectedType() != mesh::ui::HeltecV4DisplayType::None;
|
|
companion_display_begin_status = companion_display_available ? 1 : 0;
|
|
#endif
|
|
static const char* const names[] = {
|
|
"not-rendered", "setup", "off", "ready", "not-configured",
|
|
"connecting",
|
|
};
|
|
uint8_t state = static_cast<uint8_t>(companion_wifi_display_state);
|
|
if (state >= sizeof(names) / sizeof(names[0])) state = 0;
|
|
wifi_ap_record_t access_point = {};
|
|
const bool idf_associated =
|
|
esp_wifi_sta_get_ap_info(&access_point) == ESP_OK;
|
|
snprintf(reply, reply_size,
|
|
"> state=%s, frames=%lu, display=%u/%02X, requested=%u, services=%u, "
|
|
"wl=%d, idf=%u, IP=%s, 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));
|
|
}
|
|
|
|
void formatCompanionWiFiStatus(char* reply, size_t reply_size) {
|
|
if (!reply || !reply_size) return;
|
|
char ssid[33] = {}, ip[16] = {}, phy[8] = "n/a";
|
|
const char* state = "unconfigured";
|
|
const wl_status_t status = WiFi.status();
|
|
bool setup_ap = false, has_rssi = false;
|
|
int rssi = 0;
|
|
#ifdef WITH_WEBCONFIG
|
|
setup_ap = WebConfigServer::getSetupInfo(ssid, sizeof(ssid), ip, sizeof(ip));
|
|
#endif
|
|
if (!setup_ap && (WiFi.getMode() & WIFI_AP)
|
|
&& static_cast<uint32_t>(WiFi.softAPIP()) != 0) {
|
|
setup_ap = true;
|
|
snprintf(ssid, sizeof(ssid), "%s", WiFi.softAPSSID().c_str());
|
|
snprintf(ip, sizeof(ip), "%s", WiFi.softAPIP().toString().c_str());
|
|
}
|
|
if (setup_ap) {
|
|
state = "setup-ap";
|
|
} else {
|
|
snprintf(ssid, sizeof(ssid), "%s", configured_wifi_ssid);
|
|
if (mesh::wifi::networkPower().backgroundSuspended()
|
|
&& !mesh::wifi::networkPower().foregroundActive()) {
|
|
state = "suspended(30m)";
|
|
} else if (status == WL_CONNECTED) {
|
|
state = "connected";
|
|
snprintf(ssid, sizeof(ssid), "%s", WiFi.SSID().c_str());
|
|
snprintf(ip, sizeof(ip), "%s", WiFi.localIP().toString().c_str());
|
|
has_rssi = true;
|
|
rssi = WiFi.RSSI();
|
|
} else if (!companion_wifi_has_credentials) {
|
|
state = "unconfigured";
|
|
} else if (!companion_wifi_requested) {
|
|
state = "off";
|
|
} else if (!companion_wifi_active) {
|
|
state = "inactive";
|
|
} else if (companion_wifi_credential_reload_pending) {
|
|
state = "reconnect-pending";
|
|
} else {
|
|
switch (status) {
|
|
case WL_NO_SSID_AVAIL: state = "ssid-not-found"; break;
|
|
case WL_CONNECT_FAILED: state = "connect-failed"; break;
|
|
case WL_CONNECTION_LOST: state = "link-lost"; break;
|
|
default: state = "connecting/retrying"; break;
|
|
}
|
|
}
|
|
}
|
|
uint8_t protocols = 0;
|
|
wifi_ps_type_t power_save = WIFI_PS_MIN_MODEM;
|
|
if (esp_wifi_get_protocol(WIFI_IF_STA, &protocols) == ESP_OK) {
|
|
snprintf(phy, sizeof(phy), "0x%02x", protocols);
|
|
}
|
|
const bool ps_ready = esp_wifi_get_ps(&power_save) == ESP_OK;
|
|
const char* mode_saved;
|
|
const char* mode_boot;
|
|
#if defined(COMPANION_EXCLUSIVE_WIFI_BLE)
|
|
mode_saved = getCompanionTransportMode() == CompanionTransportMode::WiFi ? "wifi" : "ble";
|
|
mode_boot = companionTransportWiFiActiveAtBoot() ? "wifi" : "ble";
|
|
#else
|
|
mode_saved = mesh::wifi::companionModeName(mesh::wifi::configuredCompanionMode());
|
|
mode_boot = mesh::wifi::companionModeName(mesh::wifi::activeCompanionMode());
|
|
#endif
|
|
bool ota_listening = false, ota_attached = false;
|
|
uint16_t ota_port = 0;
|
|
#if defined(ESP32_PLATFORM) && defined(ENABLE_OTA)
|
|
ota_listening = mesh::ota::WiFiOtaSeeder::isListening();
|
|
ota_attached = mesh::ota::WiFiOtaSeeder::isAttached();
|
|
ota_port = mesh::ota::WiFiOtaSeeder::port();
|
|
#endif
|
|
const mesh::wifi::CompanionWiFiStatusSnapshot snapshot = {
|
|
state, ssid, ip, has_rssi, rssi,
|
|
mesh::wifi::stationProtocolName(mesh::wifi::configuredStationProtocol()),
|
|
mesh::wifi::stationProtocolName(mesh::wifi::activeStationProtocol()), phy,
|
|
mode_saved, mode_boot,
|
|
!ps_ready ? "n/a" : power_save == WIFI_PS_NONE ? "none"
|
|
: power_save == WIFI_PS_MAX_MODEM ? "max" : "min",
|
|
companionWiFiBluetoothActive(), ota_listening, ota_attached, ota_port};
|
|
mesh::wifi::formatCompanionWiFiStatusSnapshot(reply, reply_size, snapshot);
|
|
}
|
|
|
|
void requestCompanionWiFiSetup() {
|
|
if (mesh::wireless::control().blocked(mesh::wireless::WiFi)) return;
|
|
mesh::wifi::networkPower().requestProbe();
|
|
companion_wifi_setup_stop_requested = false;
|
|
companion_wifi_setup_requested = true;
|
|
}
|
|
|
|
void requestCompanionWiFiSetupStop() {
|
|
companion_wifi_setup_requested = false;
|
|
companion_wifi_setup_stop_requested = true;
|
|
}
|
|
|
|
bool setCompanionWiFiEnabled(bool enabled) {
|
|
if (enabled && mesh::wireless::control().blocked(mesh::wireless::WiFi)) return false;
|
|
#if defined(COMPANION_EXCLUSIVE_WIFI_BLE)
|
|
return selectCompanionTransportMode(enabled ? CompanionTransportMode::WiFi
|
|
: CompanionTransportMode::Bluetooth);
|
|
#else
|
|
CompanionNodePrefs* prefs = the_mesh.getNodePrefs();
|
|
const uint8_t previous = prefs->wifi_enabled;
|
|
prefs->wifi_enabled = enabled ? 1 : 0;
|
|
if (!the_mesh.savePrefs()) {
|
|
prefs->wifi_enabled = previous;
|
|
return false;
|
|
}
|
|
companion_wifi_requested = enabled;
|
|
if (enabled) mesh::wifi::networkPower().requestProbe();
|
|
if (!companion_wifi_requested && companion_wifi_active) {
|
|
companion_wifi_disable_in_progress = true;
|
|
}
|
|
return true;
|
|
#endif
|
|
}
|
|
|
|
bool toggleCompanionWiFi() {
|
|
#if defined(COMPANION_EXCLUSIVE_WIFI_BLE)
|
|
const bool current = getCompanionTransportMode() == CompanionTransportMode::WiFi;
|
|
#else
|
|
const bool current = companion_wifi_requested;
|
|
#endif
|
|
return setCompanionWiFiEnabled(!current) ? !current : current;
|
|
}
|
|
|
|
#if defined(WITH_WEBCONFIG) && defined(DISPLAY_CLASS) \
|
|
&& UI_WIFI_SETUP_HOME_PAGE != 1
|
|
static DisplayDriver* companion_setup_display = nullptr;
|
|
static unsigned long companion_setup_display_refresh = 0;
|
|
|
|
static bool hasLargeCompanionSetupDisplay() {
|
|
return companion_setup_display->width() >= 128
|
|
&& companion_setup_display->height() >= 128;
|
|
}
|
|
|
|
static int drawCompanionSetupValue(int y, int max_lines,
|
|
const char* value) {
|
|
static constexpr int margin = 6;
|
|
static constexpr int line_height = 13;
|
|
return mesh::ui::drawTextWrapped(
|
|
*companion_setup_display, margin, y,
|
|
companion_setup_display->width() - margin * 2, line_height,
|
|
max_lines, value != nullptr && value[0] != 0 ? value : "(not set)");
|
|
}
|
|
|
|
static void renderLargeCompanionSetupDisplay(const char* title,
|
|
const char* wifi_label,
|
|
const char* wifi_name,
|
|
const char* address,
|
|
bool connecting) {
|
|
static constexpr int line_height = 13;
|
|
companion_setup_display->drawTextCentered(
|
|
companion_setup_display->width() / 2, 4, title);
|
|
companion_setup_display->setCursor(6, 24);
|
|
companion_setup_display->print(wifi_label);
|
|
const int wifi_lines = drawCompanionSetupValue(38, 3, wifi_name);
|
|
|
|
int next_y = 38 + (wifi_lines > 0 ? wifi_lines : 1) * line_height + 8;
|
|
if (connecting) {
|
|
companion_setup_display->drawTextCentered(
|
|
companion_setup_display->width() / 2, next_y, "Please wait...");
|
|
return;
|
|
}
|
|
|
|
companion_setup_display->setCursor(6, next_y);
|
|
companion_setup_display->print("Open in browser:");
|
|
char url[32];
|
|
snprintf(url, sizeof(url), "http://%s/", address ? address : "");
|
|
drawCompanionSetupValue(next_y + 14, 2, url);
|
|
}
|
|
|
|
static void renderCompanionSetupDisplay() {
|
|
if (!companion_setup_display
|
|
|| static_cast<int32_t>(millis() - companion_setup_display_refresh) < 0) return;
|
|
companion_setup_display_refresh = millis() + 1000;
|
|
|
|
if (!companion_setup_display->isOn()) return;
|
|
companion_setup_display->startFrame();
|
|
companion_setup_display->setCompactText(false);
|
|
companion_setup_display->setTextSize(1);
|
|
companion_setup_display->setColor(UIColor::primary_txt);
|
|
|
|
char setup_ssid[33] = {0};
|
|
char setup_ip[16] = {0};
|
|
if (WebConfigServer::getSetupInfo(setup_ssid, sizeof(setup_ssid),
|
|
setup_ip, sizeof(setup_ip))) {
|
|
if (mesh::ui::drawWiFiSetupQr(
|
|
*companion_setup_display, setup_ssid, setup_ip)) {
|
|
companion_setup_display->endFrame();
|
|
return;
|
|
}
|
|
if (hasLargeCompanionSetupDisplay()) {
|
|
renderLargeCompanionSetupDisplay(
|
|
"WebUI setup", "Join open WiFi:", setup_ssid, setup_ip, false);
|
|
} else {
|
|
companion_setup_display->drawTextCentered(
|
|
companion_setup_display->width() / 2, 0, "WebUI setup");
|
|
companion_setup_display->setCursor(0, 14);
|
|
companion_setup_display->print("Join open WiFi:");
|
|
companion_setup_display->drawTextEllipsized(
|
|
0, 25, companion_setup_display->width(), setup_ssid);
|
|
companion_setup_display->setCursor(0, 39);
|
|
companion_setup_display->print("Open in browser:");
|
|
companion_setup_display->drawTextCentered(
|
|
companion_setup_display->width() / 2, 51, setup_ip);
|
|
}
|
|
} else if (WiFi.status() == WL_CONNECTED) {
|
|
const String ip = WiFi.localIP().toString();
|
|
if (hasLargeCompanionSetupDisplay()) {
|
|
renderLargeCompanionSetupDisplay(
|
|
"WebUI ready", "Join WiFi:", configured_wifi_ssid, ip.c_str(),
|
|
false);
|
|
} else {
|
|
companion_setup_display->drawTextCentered(
|
|
companion_setup_display->width() / 2, 0, "WebUI");
|
|
companion_setup_display->setCursor(0, 14);
|
|
companion_setup_display->print("Join WiFi:");
|
|
companion_setup_display->drawTextEllipsized(
|
|
0, 25, companion_setup_display->width(), configured_wifi_ssid);
|
|
companion_setup_display->setCursor(0, 39);
|
|
companion_setup_display->print("Open in browser:");
|
|
companion_setup_display->drawTextCentered(
|
|
companion_setup_display->width() / 2, 51, ip.c_str());
|
|
}
|
|
} else {
|
|
if (hasLargeCompanionSetupDisplay()) {
|
|
renderLargeCompanionSetupDisplay(
|
|
"WiFi connecting", "WiFi name:", configured_wifi_ssid, nullptr,
|
|
true);
|
|
} else {
|
|
companion_setup_display->drawTextCentered(
|
|
companion_setup_display->width() / 2, 8, "WiFi connecting");
|
|
companion_setup_display->setCursor(0, 25);
|
|
companion_setup_display->print("SSID:");
|
|
companion_setup_display->drawTextEllipsized(
|
|
0, 38, companion_setup_display->width(), configured_wifi_ssid);
|
|
companion_setup_display->drawTextCentered(
|
|
companion_setup_display->width() / 2, 52, "Please wait...");
|
|
}
|
|
}
|
|
companion_setup_display->setCompactText(false);
|
|
companion_setup_display->endFrame();
|
|
}
|
|
#endif
|
|
|
|
static bool saveCompanionWiFi(void*, const char* ssid, const char* password) {
|
|
if (!WiFiSetupPortal::saveStoredCredentials(ssid, password)) return false;
|
|
strncpy(configured_wifi_ssid, ssid, sizeof(configured_wifi_ssid) - 1);
|
|
configured_wifi_ssid[sizeof(configured_wifi_ssid) - 1] = '\0';
|
|
strncpy(configured_wifi_password, password ? password : "", sizeof(configured_wifi_password) - 1);
|
|
configured_wifi_password[sizeof(configured_wifi_password) - 1] = '\0';
|
|
companion_wifi_has_credentials = true;
|
|
return true;
|
|
}
|
|
#endif
|
|
|
|
/* WIFI TEXT CONSOLE - one client at a time on a dedicated port, separate from
|
|
Binary Companion (5000) and the mOTA seeder (5001). A Full Companion exposes
|
|
the same role CLI here as its USB terminal. Other OTA-enabled Companion
|
|
builds expose local maintenance commands and `ota ...` commands. */
|
|
#if defined(ESP32) && defined(WIFI_SSID) && defined(ENABLE_OTA)
|
|
#include <lwip/sockets.h>
|
|
#include <helpers/ota/OtaCli.h> // mesh::ota::handle_ota_command(line, reply, board)
|
|
#include <helpers/esp32/WiFiOtaSeeder.h>
|
|
#ifndef OTA_CONSOLE_TCP_PORT
|
|
#define OTA_CONSOLE_TCP_PORT 5002
|
|
#endif
|
|
static WiFiServer ota_console_server(OTA_CONSOLE_TCP_PORT);
|
|
static WiFiClient ota_console_client;
|
|
static char ota_console_line[MAX_TRANS_UNIT * 2 + 32];
|
|
static size_t ota_console_len = 0;
|
|
static bool ota_console_discard_line = false;
|
|
#if COMPANION_FEATURE_NETWORK_TERMINAL && COMPANION_FEATURE_READER
|
|
static size_t ota_console_try_reader_write(Stream& output,
|
|
const uint8_t* data, size_t size) {
|
|
// WiFiClient::write retries socket readiness with one-second waits.
|
|
// A paused chapter reader must instead leave its cursor for the next loop.
|
|
const int socket = static_cast<WiFiClient&>(output).fd();
|
|
if (socket < 0) return 0;
|
|
const int written = ::send(socket, data, size, MSG_DONTWAIT);
|
|
return written > 0 ? static_cast<size_t>(written) : 0;
|
|
}
|
|
#endif
|
|
#if COMPANION_FEATURE_NETWORK_TERMINAL && defined(ENABLE_USB_INTERFACE)
|
|
static mesh::UsbTcpTerminalHandoff ota_console_usb_handoff;
|
|
#endif
|
|
|
|
static void ota_console_clear_line() {
|
|
memset(ota_console_line, 0, sizeof(ota_console_line));
|
|
ota_console_len = 0;
|
|
}
|
|
|
|
static void ota_console_start() {
|
|
ota_console_server.begin();
|
|
#if COMPANION_FEATURE_NETWORK_TERMINAL
|
|
WIFI_DEBUG_PRINTLN("Full Companion terminal listening on :%d (nc <ip> %d)",
|
|
OTA_CONSOLE_TCP_PORT, OTA_CONSOLE_TCP_PORT);
|
|
#else
|
|
WIFI_DEBUG_PRINTLN("Local CLI and OTA console listening on :%d (nc <ip> %d)",
|
|
OTA_CONSOLE_TCP_PORT, OTA_CONSOLE_TCP_PORT);
|
|
#endif
|
|
}
|
|
|
|
#if COMPANION_FEATURE_NETWORK_TERMINAL
|
|
static void ota_console_release_terminal() {
|
|
the_mesh.exitNetworkTerminalMode(ota_console_client);
|
|
#if defined(ENABLE_USB_INTERFACE)
|
|
if (ota_console_usb_handoff.shouldRestoreAscii(
|
|
usb_serial_interface.getCompletedFrameCount())) {
|
|
enterUsbTerminalMode();
|
|
}
|
|
#endif
|
|
}
|
|
#endif
|
|
|
|
static void ota_console_stop() {
|
|
#if COMPANION_FEATURE_NETWORK_TERMINAL
|
|
ota_console_release_terminal();
|
|
#endif
|
|
if (ota_console_client) ota_console_client.stop();
|
|
ota_console_server.end();
|
|
ota_console_clear_line();
|
|
ota_console_discard_line = false;
|
|
}
|
|
|
|
static void ota_console_loop() {
|
|
if (!ota_console_client || !ota_console_client.connected()) {
|
|
#if COMPANION_FEATURE_NETWORK_TERMINAL
|
|
ota_console_release_terminal();
|
|
#endif
|
|
WiFiClient c = ota_console_server.available();
|
|
if (c) {
|
|
ota_console_client = c;
|
|
ota_console_clear_line();
|
|
ota_console_discard_line = false;
|
|
#if COMPANION_FEATURE_NETWORK_TERMINAL
|
|
#if defined(ENABLE_USB_INTERFACE)
|
|
const bool usb_ascii_selected = the_mesh.isTerminalMode();
|
|
const bool usb_input_idle = usb_terminal_line_len == 0
|
|
&& !usb_terminal_discard_line
|
|
&& !usb_binary_startup_probe.isActive();
|
|
if (!ota_console_usb_handoff.begin(
|
|
usb_ascii_selected, hasObservableActiveUsbTerminalClient(),
|
|
usb_input_idle,
|
|
usb_serial_interface.getCompletedFrameCount())) {
|
|
ota_console_client.print(
|
|
"ERROR: active USB currently owns the Full Companion terminal\r\n");
|
|
ota_console_client.stop();
|
|
return;
|
|
}
|
|
if (usb_ascii_selected) leaveUsbTerminalMode(false);
|
|
#endif
|
|
if (!the_mesh.enterNetworkTerminalMode(ota_console_client)) {
|
|
#if defined(ENABLE_USB_INTERFACE)
|
|
if (ota_console_usb_handoff.shouldRestoreAscii(
|
|
usb_serial_interface.getCompletedFrameCount())) {
|
|
enterUsbTerminalMode();
|
|
}
|
|
#endif
|
|
ota_console_client.print(
|
|
"ERROR: another client currently owns the Full Companion terminal\r\n");
|
|
ota_console_client.stop();
|
|
}
|
|
#if defined(ENABLE_USB_INTERFACE)
|
|
serviceUsbLoggingOwnership(mesh::isUsbLoggingEnabled());
|
|
#endif
|
|
#else
|
|
ota_console_client.print("Local CLI and OTA console - type `version` or `ota status`\r\n> ");
|
|
#endif
|
|
}
|
|
return;
|
|
}
|
|
#if COMPANION_FEATURE_NETWORK_TERMINAL
|
|
if (!the_mesh.isNetworkTerminalMode(ota_console_client)) {
|
|
#if defined(ENABLE_USB_INTERFACE)
|
|
ota_console_usb_handoff.cancel();
|
|
#endif
|
|
ota_console_client.print(
|
|
"\r\nERROR: terminal ownership moved to USB; closing\r\n");
|
|
ota_console_client.stop();
|
|
ota_console_clear_line();
|
|
ota_console_discard_line = false;
|
|
return;
|
|
}
|
|
#endif
|
|
while (ota_console_client.available()) {
|
|
char ch = (char)ota_console_client.read();
|
|
if (ota_console_discard_line) {
|
|
if (ch == '\r' || ch == '\n') {
|
|
ota_console_discard_line = false;
|
|
ota_console_client.print(" ERROR: command too long\r\n> ");
|
|
}
|
|
continue;
|
|
}
|
|
if (ch == '\r' || ch == '\n') {
|
|
if (ota_console_len == 0) continue; // ignore blanks / the CRLF pair
|
|
ota_console_line[ota_console_len] = 0;
|
|
#if COMPANION_FEATURE_NETWORK_TERMINAL
|
|
if (strcmp(ota_console_line, "disconnect") == 0) {
|
|
ota_console_client.print(" OK - disconnecting\r\n");
|
|
ota_console_release_terminal();
|
|
ota_console_client.stop();
|
|
ota_console_clear_line();
|
|
ota_console_discard_line = false;
|
|
return;
|
|
}
|
|
#if COMPANION_FEATURE_READER
|
|
the_mesh.handleTerminalCommand(ota_console_line, true,
|
|
ota_console_try_reader_write);
|
|
#else
|
|
the_mesh.handleTerminalCommand(ota_console_line);
|
|
#endif
|
|
#if COMPANION_FEATURE_READER
|
|
if (!mesh::readerCommandActive(&ota_console_client))
|
|
#endif
|
|
ota_console_client.print("> ");
|
|
#else
|
|
char reply[160]; reply[0] = 0;
|
|
if (!handleCompanionWirelessCommand(ota_console_line, reply, sizeof(reply), CompanionWirelessSource::Network)
|
|
&& !handleCompanionBluetoothCommand(ota_console_line, reply, sizeof(reply),
|
|
CompanionBluetoothCommandSource::Terminal)
|
|
&& !the_mesh.handleLocalCommand(ota_console_line, reply)
|
|
&& !mesh::ota::handle_ota_command(ota_console_line, reply, board))
|
|
strcpy(reply, "Unknown local or OTA command");
|
|
ota_console_client.print(" -> "); ota_console_client.print(reply); ota_console_client.print("\r\n> ");
|
|
#endif
|
|
ota_console_clear_line();
|
|
} else if (ota_console_len < sizeof(ota_console_line) - 1) {
|
|
ota_console_line[ota_console_len++] = ch;
|
|
} else {
|
|
ota_console_clear_line();
|
|
ota_console_discard_line = true;
|
|
}
|
|
}
|
|
}
|
|
#endif
|
|
|
|
#if defined(ESP32) && defined(WIFI_SSID)
|
|
static bool companionWiFiBackgroundSuspended() {
|
|
// MQTT publishes this flag only after its own TLS and SNTP work is quiet.
|
|
// A foreground OTA command can acquire the radio before its next pass.
|
|
return mesh::wifi::networkPower().backgroundSuspended()
|
|
&& !mesh::wifi::networkPower().foregroundActive()
|
|
&& !board.isOTAUpdateRunning();
|
|
}
|
|
|
|
static bool companionWiFiAutomaticRecoveryAllowed() {
|
|
// MQTT owns one combined outage/cooldown clock. Raising an automatic AP
|
|
// during that grace period would create a foreground owner indefinitely.
|
|
return !mesh::wifi::networkPower().managed();
|
|
}
|
|
|
|
#ifdef WITH_WEBCONFIG
|
|
static void startCompanionWiFiAutoWebConfig() {
|
|
companion_wifi_webconfig_start_pending = false;
|
|
if (!WebConfigServer::loadEnabled(true)) return;
|
|
if (companion_wifi_has_credentials && WiFi.status() != WL_CONNECTED) {
|
|
// The Companion owns the initial saved-SSID attempt. Do not ask the
|
|
// portal to start its separate fifteen-second AP fallback at boot.
|
|
companion_wifi_webconfig_start_pending = true;
|
|
return;
|
|
}
|
|
char web_reply[160];
|
|
the_mesh.startWebConfig(!companion_wifi_has_credentials, web_reply);
|
|
WIFI_DEBUG_PRINTLN("%s", web_reply);
|
|
}
|
|
|
|
static void serviceCompanionWiFiAutoWebConfig() {
|
|
if (!companion_wifi_webconfig_start_pending || !companion_wifi_requested
|
|
|| !companion_wifi_active || companionWiFiBackgroundSuspended()) return;
|
|
if (!WebConfigServer::loadEnabled(true)) {
|
|
companion_wifi_webconfig_start_pending = false;
|
|
return;
|
|
}
|
|
if (WiFi.status() != WL_CONNECTED
|
|
|| the_mesh.isWebConfigActiveOrStopping()) return;
|
|
startCompanionWiFiAutoWebConfig();
|
|
}
|
|
#endif
|
|
|
|
static void serviceCompanionWiFiNetworkPowerHold() {
|
|
bool active = wifi_interface.isConnected()
|
|
|| companion_wifi_credential_reload_pending
|
|
|| companion_wifi_setup_requested
|
|
|| board.isOTAUpdateRunning();
|
|
#ifdef ENABLE_OTA
|
|
active = active || ota_console_client.connected();
|
|
#endif
|
|
mesh::wifi::networkPower().setHold(
|
|
mesh::wifi::NetworkPowerHold::CompanionClient, active);
|
|
}
|
|
|
|
static void resetCompanionWiFiRecoveryState() {
|
|
wifi_reconnect_tracker = WiFiReconnectPolicy::Tracker();
|
|
wifi_setup_attempted = false;
|
|
last_wifi_setup_attempt = 0;
|
|
wifi_setup_recovery_mode = false;
|
|
}
|
|
|
|
static void serviceCompanionWiFiCredentialReload() {
|
|
if (!companion_wifi_credential_reload_pending
|
|
|| static_cast<int32_t>(millis() - companion_wifi_credential_reload_at) < 0) {
|
|
return;
|
|
}
|
|
if (companionWiFiBackgroundSuspended()) return;
|
|
#ifdef WITH_WEBCONFIG
|
|
if (the_mesh.isWebConfigWiFiTransitionActive()) 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<int32_t>(now - companion_wifi_espnow_restore_at) < 0) return;
|
|
// esp_wifi_set_channel may fail until an asynchronous disconnect/scan
|
|
// completes. Retry the channel only; repeated disconnects would prevent
|
|
// the shared ESP-NOW receiver from recovering during a long cooldown.
|
|
companion_wifi_espnow_restore_pending =
|
|
mesh::wifi::restoreEspNowChannel() != ESP_OK;
|
|
companion_wifi_espnow_restore_at = now + 1000UL;
|
|
}
|
|
|
|
static void serviceCompanionWiFiBackgroundPower() {
|
|
serviceCompanionEspNowChannelRestore();
|
|
if (!companion_wifi_active || !companion_wifi_requested) return;
|
|
if (companionWiFiBackgroundSuspended()) {
|
|
if (companion_wifi_background_stopped) return;
|
|
// Settings and MQTT's scheduling task stay enabled. These idle socket
|
|
// listeners and the common NTP request own no useful foreground work.
|
|
cancelCompanionWiFiNtp(false);
|
|
wifi_interface.end();
|
|
#ifdef ENABLE_OTA
|
|
ota_console_stop();
|
|
#endif
|
|
stopCompanionInfrastructureWiFi();
|
|
board.setInhibitSleep(mesh::wifi::espNowChannelConstrained());
|
|
companion_wifi_background_stopped = true;
|
|
WIFI_DEBUG_PRINTLN("Network unavailable; WiFi suspended until the next recovery probe");
|
|
return;
|
|
}
|
|
if (!companion_wifi_background_stopped) return;
|
|
if (!mesh::wifi::stationMutationAllowed()) return;
|
|
|
|
board.setInhibitSleep(true);
|
|
companion_wifi_espnow_restore_pending = false;
|
|
resetCompanionWiFiRecoveryState();
|
|
if (companion_wifi_has_credentials) {
|
|
WiFi.mode(WIFI_STA);
|
|
mesh::wifi::applyProtocolMask(WIFI_IF_STA);
|
|
mesh::wifi::setStationAutoReconnect(true);
|
|
wifi_reconnect_tracker.noteDisconnected(millis());
|
|
mesh::wifi::beginStation(configured_wifi_ssid, configured_wifi_password);
|
|
}
|
|
applyCompanionWiFiPowerSave();
|
|
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 serviceCompanionUsbBluetoothPriority() {
|
|
#if defined(T1000_E) && defined(USE_TINYUSB)
|
|
static mesh::UsbBluetoothPriority priority;
|
|
const bool inhibited = priority.update(board.isUsbHostConnected(), millis());
|
|
if (inhibited && !interface_manager.isBluetoothInhibited()) {
|
|
// Abandon only this transport's work; USB must not inherit a partially
|
|
// streamed Bluetooth reply when the link is disconnected.
|
|
the_mesh.cancelSerialResponseStream(&bluetooth_interface);
|
|
the_mesh.cancelSerialOperationsForRoute(&bluetooth_interface);
|
|
}
|
|
interface_manager.setBluetoothInhibited(inhibited);
|
|
if (inhibited) {
|
|
interface_manager.forgetReplyRouteForDisconnected(&bluetooth_interface);
|
|
}
|
|
#endif
|
|
}
|
|
|
|
static void startCompanionBluetooth() {
|
|
if (companion_bluetooth_initialized) return;
|
|
companion_bluetooth_start_at = 0;
|
|
#if defined(ESP32) && defined(WIFI_SSID) \
|
|
&& !defined(COMPANION_EXCLUSIVE_WIFI_BLE)
|
|
if (mesh::wifi::activeCompanionMode()
|
|
== mesh::wifi::CompanionMode::WiFiOnly) {
|
|
snprintf(companion_bluetooth_start_failure,
|
|
sizeof(companion_bluetooth_start_failure),
|
|
"wifi-only boot; set wifi.mode normal and reboot to enable Bluetooth");
|
|
return;
|
|
}
|
|
#endif
|
|
|
|
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;
|
|
serviceCompanionUsbBluetoothPriority();
|
|
if (interface_manager.isEnabled() && the_mesh.isBluetoothEnabledPreference()) {
|
|
interface_manager.enableBluetooth();
|
|
}
|
|
}
|
|
|
|
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)"
|
|
#if defined(ESP32_PLATFORM)
|
|
: bluetooth_interface.isPowerTransitionPending() ? " (change pending)"
|
|
#endif
|
|
: interface_manager.isBluetoothInhibited()
|
|
? " (USB data connected)" : "");
|
|
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.isBluetoothInhibited()) {
|
|
companion_bluetooth_off_at = 0;
|
|
snprintf(reply, reply_size,
|
|
"OK - Bluetooth on (saved); paused while USB data is connected");
|
|
#if defined(ESP32_PLATFORM)
|
|
} else if (bluetooth_interface.isPowerTransitionPending()) {
|
|
companion_bluetooth_off_at = 0;
|
|
snprintf(reply, reply_size, "OK - Bluetooth on (saved); start pending");
|
|
#endif
|
|
} else 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);
|
|
disableCompanionBluetoothForCli();
|
|
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();
|
|
if (on && interface_manager.isBluetoothInhibited()) return Result::Done;
|
|
#if defined(ESP32_PLATFORM)
|
|
if (bluetooth_interface.isPowerTransitionPending()) return Result::Pending;
|
|
#endif
|
|
return interface_manager.isBluetoothEnabled() == on ? Result::Done : Result::Failed;
|
|
}
|
|
#endif
|
|
#if defined(ESP32) && defined(WIFI_SSID)
|
|
if (service == mesh::wireless::WiFi) {
|
|
companion_wifi_requested = on;
|
|
if (!on) {
|
|
companion_wifi_setup_requested = false;
|
|
// Sockets stop before the asynchronous web teardown finishes. Keep
|
|
// this latched so a replacement on request completes that teardown
|
|
// before startCompanionWiFi's active-state shortcut can skip restart.
|
|
companion_wifi_disable_in_progress = true;
|
|
if (!finishStoppingCompanionWiFi()) return Result::Pending;
|
|
companion_wifi_disable_in_progress = false;
|
|
#if defined(MESH_PRIMARY_ESPNOW) && MESH_PRIMARY_ESPNOW
|
|
if (!radio_driver.isEnabled()) ::WiFi.mode(WIFI_OFF);
|
|
#endif
|
|
} else {
|
|
// Finish a previously requested teardown before opening new sockets.
|
|
if (companion_wifi_disable_in_progress) {
|
|
if (!finishStoppingCompanionWiFi()) return Result::Pending;
|
|
companion_wifi_disable_in_progress = false;
|
|
}
|
|
startCompanionWiFi();
|
|
}
|
|
return companion_wifi_active == on ? Result::Done : Result::Failed;
|
|
}
|
|
#endif
|
|
#if defined(MESH_PRIMARY_ESPNOW) && MESH_PRIMARY_ESPNOW
|
|
if (service == EspNow) {
|
|
if (on) radio_driver.init();
|
|
else {
|
|
radio_driver.end();
|
|
if (!(enabled() & mesh::wireless::WiFi)) {
|
|
::WiFi.setAutoReconnect(false);
|
|
::WiFi.mode(WIFI_OFF);
|
|
}
|
|
}
|
|
return radio_driver.isEnabled() == on ? Result::Done : Result::Failed;
|
|
}
|
|
#endif
|
|
return Result::Failed;
|
|
}
|
|
};
|
|
static CompanionWirelessBackend companion_wireless;
|
|
|
|
#if defined(ESP32_PLATFORM) && defined(ENABLE_USB_INTERFACE) \
|
|
&& defined(ARDUINO_USB_CDC_ON_BOOT) && ARDUINO_USB_CDC_ON_BOOT \
|
|
&& !defined(ETHERNET_ENABLED) && !defined(SERIAL_RX)
|
|
static bool companionNativeUsbLightSleepReady() {
|
|
// Full compiles every transport, but a compiled driver is not an active
|
|
// receiver. Preserve live services and pending replies while permitting
|
|
// the same short sleep slice when those services have been turned off.
|
|
uint8_t enabled_services = companion_wireless.enabled();
|
|
#if defined(ESP32) && defined(WIFI_SSID)
|
|
// The user's saved WiFi preference and an idle WebConfig listener remain
|
|
// enabled during automatic suspension. Only a confirmed physical power-off
|
|
// releases their sleep blocker; foreground work and shared ESP-NOW retain it.
|
|
if (companion_wifi_background_stopped
|
|
&& mesh::wifi::networkPower().backgroundSuspended()
|
|
&& !mesh::wifi::networkPower().foregroundActive()
|
|
&& !mesh::wifi::espNowChannelConstrained()) {
|
|
wifi_mode_t parked_mode = WIFI_MODE_NULL;
|
|
const esp_err_t parked_result = esp_wifi_get_mode(&parked_mode);
|
|
if ((parked_result == ESP_OK && parked_mode == WIFI_MODE_NULL)
|
|
|| parked_result == ESP_ERR_WIFI_NOT_INIT
|
|
|| parked_result == ESP_ERR_WIFI_NOT_STARTED) {
|
|
enabled_services &= static_cast<uint8_t>(~mesh::wireless::WiFi);
|
|
}
|
|
}
|
|
#endif
|
|
#if defined(BLE_PIN_CODE)
|
|
if (bluetooth_interface.isPowerTransitionPending()) return false;
|
|
#endif
|
|
if (enabled_services != 0 || interface_manager.hasPendingIO()
|
|
|| board.isOTAUpdateRunning() || board.isRadioTestActive()
|
|
|| sensors.gpsUsesSerialUart(1)) return false;
|
|
return mesh::esp32::manualLightSleepReady();
|
|
}
|
|
#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();
|
|
#if defined(ESP32_PLATFORM)
|
|
board.setCompanionCpuClockPolicy(true);
|
|
#endif
|
|
board.begin();
|
|
#if COMPANION_MESH_PSRAM_STORAGE
|
|
if (!initializeCompanionMeshStorage()) {
|
|
// No partial mesh, radio, display or network service may start without
|
|
// the required external storage. Keep USB available to report the error.
|
|
for (;;) delay(1000);
|
|
}
|
|
#endif
|
|
|
|
#if defined(ESP32) && defined(WIFI_SSID)
|
|
// MyMesh::begin can already read the power-save policy for MQTT/WebConfig.
|
|
// Freeze compatibility before that first read and before either RF stack.
|
|
mesh::wifi::loadStationProtocolForBoot();
|
|
mesh::wifi::loadCompanionModeForBoot();
|
|
#if defined(BLE_PIN_CODE) && !defined(COMPANION_EXCLUSIVE_WIFI_BLE)
|
|
if (mesh::wifi::activeCompanionMode()
|
|
== mesh::wifi::CompanionMode::WiFiOnly) {
|
|
releaseCompanionBluetoothMemoryForWiFi();
|
|
}
|
|
#endif
|
|
#endif
|
|
|
|
#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
|
|
|
|
|
|
FILESYSTEM* regulator_prefs_fs = store.getPrimaryFS();
|
|
#if defined(NRF52_PLATFORM)
|
|
if (!mesh::storage::internalPrimaryFilesystemReady(primary_fs_boot)
|
|
|| store.isVolatilePrimaryFS()) regulator_prefs_fs = nullptr;
|
|
#endif
|
|
#if !defined(WIO_E5_MINI_HEADLESS_COMPANION_UI)
|
|
mesh::leds::beginSettings(board, regulator_prefs_fs);
|
|
#endif
|
|
if (!mesh::power::beginRegulatorSettings(board, radio_driver, regulator_prefs_fs)) {
|
|
MESH_DEBUG_PRINTLN("Regulator selection could not be applied");
|
|
}
|
|
mesh::RadioProfileCLI::configureBootTuning(regulator_prefs_fs, radio_driver);
|
|
|
|
|
|
#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
|
|
#if defined(HELTEC_V4_DISPLAY_UNIFIED) && HELTEC_V4_DISPLAY_UNIFIED
|
|
mesh::ui::loadHeltecV4DisplaySettings(regulator_prefs_fs, display);
|
|
#endif
|
|
#if defined(OLED_AUTO_DETECT) && OLED_AUTO_DETECT
|
|
mesh::ui::loadOLEDControllerSettings(regulator_prefs_fs);
|
|
#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 defined(XIAO_NRF52)
|
|
// The external NOR can remain asleep across an MCU/DFU reset. Wake it
|
|
// before CustomLFS activates QSPI, without changing any stored bytes.
|
|
const nrf_qspi_pins_t qspi_pins = {
|
|
(uint8_t)g_ADigitalPinMap[PIN_QSPI_SCK], (uint8_t)g_ADigitalPinMap[PIN_QSPI_CS],
|
|
(uint8_t)g_ADigitalPinMap[PIN_QSPI_IO0], (uint8_t)g_ADigitalPinMap[PIN_QSPI_IO1],
|
|
(uint8_t)g_ADigitalPinMap[PIN_QSPI_IO2], (uint8_t)g_ADigitalPinMap[PIN_QSPI_IO3]
|
|
};
|
|
mesh::nrf52::wakeQspiFlashBeforeActivate(qspi_pins);
|
|
#endif
|
|
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());
|
|
serviceCompanionUsbBluetoothPriority();
|
|
#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)
|
|
#if defined(PICOW_COMBINED_COMPANION) && PICOW_COMBINED_COMPANION == 1 \
|
|
&& defined(ENABLE_USB_INTERFACE) && defined(PIO_FRAMEWORK_ARDUINO_ENABLE_BLUETOOTH)
|
|
// Bind consolidation metadata to the actual combined transport setup path.
|
|
static const char combined_capability[] =
|
|
"meshcore.capability.companion.picow.combined.v1";
|
|
const volatile char* combined_marker = combined_capability;
|
|
(void)*combined_marker;
|
|
#endif
|
|
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;
|
|
#if defined(ESP32_PLATFORM) \
|
|
&& (!defined(ARDUINO_USB_CDC_ON_BOOT) || !ARDUINO_USB_CDC_ON_BOOT) \
|
|
&& !defined(SERIAL_RX) && !defined(SERIAL_TX)
|
|
mesh::esp32::registerUartQuietSessionControl(
|
|
nullptr, transitionCompanionUartQuietSession);
|
|
#endif
|
|
// 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);
|
|
#if defined(OTA_RAK15002_SD_STORE) && defined(ETHERNET_USE_RAK13800)
|
|
ethernet_interface.setSpiOwnershipHooks(prepareCompanionRakEthernet,
|
|
canReleaseCompanionRakEthernet, releasedCompanionRakEthernet, nullptr);
|
|
#endif
|
|
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
|
|
#if defined(HELTEC_V4_DISPLAY_UNIFIED) && HELTEC_V4_DISPLAY_UNIFIED
|
|
// Retain the wrapper dependency in headless mode so live selection can
|
|
// enable a screen. The UI skips rendering while no backend is selected.
|
|
disp = &display;
|
|
#endif
|
|
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(BLE_PIN_CODE)
|
|
serviceCompanionUsbBluetoothPriority();
|
|
#endif
|
|
#if defined(NRF52_PLATFORM)
|
|
board.prepareSleepCycle();
|
|
#endif
|
|
#if defined(RP2040_PLATFORM) && defined(ENABLE_WIFI_INTERFACE)
|
|
if (pico_wifi_active && WiFi.status() != WL_CONNECTED
|
|
&& static_cast<uint32_t>(millis() - last_wifi_reconnect_attempt)
|
|
>= WIFI_RETRY_INTERVAL) {
|
|
WiFi.setTimeout(WIFI_RETRY_TIMEOUT);
|
|
WiFi.beginNoBlock(pico_wifi_ssid, pico_wifi_pwd);
|
|
last_wifi_reconnect_attempt = millis();
|
|
}
|
|
#endif
|
|
mesh::wireless::control().service(millis());
|
|
#if defined(ESP32) && defined(WIFI_SSID)
|
|
serviceCompanionWiFiNetworkPowerHold();
|
|
#endif
|
|
#if defined(NRF52_PLATFORM)
|
|
board.feedWatchdog();
|
|
#endif
|
|
serviceUsbLoggingOnlySession();
|
|
#if defined(ENABLE_USB_INTERFACE)
|
|
serviceUsbTerminalHostSessionReset();
|
|
serviceUsbAsciiSessionDefault();
|
|
serviceUsbLoggingReplyBarrier();
|
|
serviceUsbLoggingOwnership(mesh::isUsbLoggingEnabled()); // Before framed dispatch.
|
|
#endif
|
|
// Identify nRF52 CDC 1 when a terminal opens it. Doing this on the connection
|
|
// edge avoids losing the marker before the host has opened the port.
|
|
mesh::serviceUsbLoggingPort();
|
|
#if defined(ENABLE_USB_INTERFACE)
|
|
// Drain functional CDC0 text before the mesh can emit a Binary Companion or
|
|
// serial-mOTA frame in this iteration.
|
|
mesh::serviceUsbTerminalPort();
|
|
#endif
|
|
#if defined(ENABLE_USB_INTERFACE)
|
|
expireUsbBinaryStartupProbeBeforeDispatch();
|
|
#if COMPANION_FEATURE_USB_MOTA_SOURCE
|
|
serviceUsbMotaResponseBoundary();
|
|
#endif
|
|
#endif
|
|
#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<uint32_t>(millis() - last_fault_record_attempt) >= 5000) {
|
|
last_fault_record_attempt = millis();
|
|
if (ExtraFS.requestBootScan()) {
|
|
MESH_DEBUG_PRINTLN("ExtraFS: physical write failed; rebooting for isolated page scan");
|
|
board.reboot();
|
|
}
|
|
}
|
|
}
|
|
#endif
|
|
#ifdef RECOVERABLE_EXTERNAL_RADIO
|
|
serviceCompanionRadioRecovery();
|
|
#endif
|
|
#if defined(ENABLE_USB_INTERFACE)
|
|
serviceUsbTerminal();
|
|
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();
|
|
mesh::power::serviceRegulatorSettings();
|
|
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
|
|
board.servicePowerTestControls(!interface_manager.hasPendingIO());
|
|
serviceCompanionPowerSaving();
|
|
#endif
|
|
#if defined(NRF52_PLATFORM)
|
|
if (the_mesh.getNodePrefs()->powersaving_enabled && the_mesh.isDualRadioActive()
|
|
&& !morse_timing) board.prepareProfileIdleCycle();
|
|
#endif
|
|
|
|
// USB power alone (for example, a wall charger) does not inhibit sleep.
|
|
// Host sessions, live logging, and button activity still need service.
|
|
using mesh::powerdiag::Reason;
|
|
using mesh::powerdiag::mask;
|
|
// Keep the existing short-circuit policy: report the first guard that
|
|
// actually vetoed sleep, without polling otherwise skipped peripherals.
|
|
mesh::powerdiag::Mask sleep_reason = [&]() -> mesh::powerdiag::Mask {
|
|
if (!the_mesh.getNodePrefs()->powersaving_enabled) return mask(Reason::PolicyOff);
|
|
#if defined(ESP32_PLATFORM)
|
|
if (!mesh::esp32::manualCpuSleepEnabled()) return mask(Reason::PolicyOff);
|
|
#endif
|
|
if (morse_timing) return mask(Reason::Morse);
|
|
const auto pending = the_mesh.pendingWorkReason();
|
|
if (pending) return pending;
|
|
if (mesh::isUsbLoggingWatchdogArmed()) return mask(Reason::UsbRecovery);
|
|
if (mesh::wireless::control().pending()) return mask(Reason::Wireless);
|
|
return 0;
|
|
}();
|
|
#if defined(ESP32_PLATFORM) && MESH_USB_LOGGING_AVAILABLE
|
|
// The native-USB-only light-sleep path below bypasses ESP32Board::sleep.
|
|
if (!sleep_reason && mesh::isUsbLoggingEnabled()) sleep_reason = mask(Reason::UsbLogging);
|
|
#endif
|
|
#if defined(ESP32_PLATFORM) && defined(ENABLE_USB_INTERFACE)
|
|
const bool usb_sleep_held = board.isUsbSleepHeld();
|
|
if (!sleep_reason && usb_sleep_held) sleep_reason = mask(Reason::UsbSession);
|
|
#elif defined(NRF52_PLATFORM)
|
|
if (!sleep_reason && board.isUsbHostConnected()) sleep_reason = mask(Reason::UsbSession);
|
|
#endif
|
|
#if defined(NRF52_PLATFORM) && defined(ETHERNET_USE_RAK13800)
|
|
// Polled Ethernet has no GPIO wake for a client or parked worker. OFF also
|
|
// disables its logical transport before main-thread resource release ends.
|
|
if (!sleep_reason && ethernet_interface.hardwareActive()) sleep_reason = mask(Reason::Network);
|
|
#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.
|
|
if (!sleep_reason && user_btn.needsPolling()) sleep_reason = mask(Reason::Button);
|
|
#endif
|
|
uint32_t noise_floor_delay_ms = 0;
|
|
const bool noise_floor_wait = !sleep_reason
|
|
&& the_mesh.getNextNoiseFloorWakeDelay(noise_floor_delay_ms);
|
|
if (noise_floor_wait && noise_floor_delay_ms == 0) sleep_reason = mask(Reason::Calibration);
|
|
if (mesh::powerdiag::enabled()) mesh::powerdiag::decision(sleep_reason, millis());
|
|
const bool can_sleep = sleep_reason == 0;
|
|
if (can_sleep && the_mesh.canIdleDualRadio()) {
|
|
#if defined(ESP32_PLATFORM)
|
|
const uint32_t maximum_slice_ms = mesh::esp32::profileSleepSliceMillis();
|
|
#else
|
|
const uint32_t maximum_slice_ms = 10;
|
|
#endif
|
|
const uint32_t idle_slice_ms = noise_floor_wait && noise_floor_delay_ms < maximum_slice_ms
|
|
? noise_floor_delay_ms : maximum_slice_ms;
|
|
#if defined(NRF52_PLATFORM)
|
|
if (noise_floor_wait) board.sleepMilliseconds(idle_slice_ms);
|
|
else board.sleep(0); // nrf ignores seconds param, sleeps whenever possible
|
|
#elif defined(ESP32_PLATFORM)
|
|
const uint32_t sleep_budget_started_us = static_cast<uint32_t>(esp_timer_get_time());
|
|
#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.
|
|
bool sleep_failed = false;
|
|
if (!companionNativeUsbLightSleepReady()) {
|
|
if (mesh::powerdiag::enabled()) mesh::powerdiag::boardVeto(Reason::Board, millis());
|
|
sleep_failed = true;
|
|
} else if (the_mesh.isDualRadioActive()) {
|
|
// The scanner owns a shorter absolute deadline than the normal loop
|
|
// cadence. The board also arms a radio GPIO wake and rechecks elapsed
|
|
// time after its setup; short/expired visits keep polling.
|
|
if (!mesh::esp32::tryProfileIdleSleep(the_mesh, board, sleep_budget_started_us, idle_slice_ms)
|
|
&& mesh::powerdiag::enabled())
|
|
mesh::powerdiag::decision(mask(Reason::DualRadio), millis());
|
|
} else {
|
|
esp_err_t error = esp_sleep_enable_timer_wakeup(static_cast<uint64_t>(idle_slice_ms) * 1000ULL);
|
|
if (error == ESP_OK) {
|
|
if (mesh::powerdiag::enabled()) mesh::powerdiag::sleepBegin(micros(), millis());
|
|
error = esp_light_sleep_start();
|
|
if (mesh::powerdiag::enabled()) mesh::powerdiag::sleepEnd(micros());
|
|
}
|
|
if (error != ESP_OK) {
|
|
if (mesh::powerdiag::enabled()) mesh::powerdiag::boardError(error, millis());
|
|
sleep_failed = true;
|
|
}
|
|
}
|
|
if (sleep_failed && !the_mesh.isDualRadioActive()) {
|
|
vTaskDelay(pdMS_TO_TICKS(idle_slice_ms));
|
|
}
|
|
#else
|
|
if (the_mesh.isDualRadioActive()) {
|
|
// A rounded RTOS delay can outlast a scanner visit. Use the same
|
|
// absolute, GPIO-wake deadline as infrastructure, with board USB,
|
|
// logging and physical wireless guards. A veto keeps polling.
|
|
if (!mesh::esp32::tryProfileIdleSleep(the_mesh, board, sleep_budget_started_us, idle_slice_ms)
|
|
&& mesh::powerdiag::enabled())
|
|
mesh::powerdiag::decision(mask(Reason::DualRadio), millis());
|
|
} else {
|
|
// PM-enabled cores may use automatic sleep; other connected transports
|
|
// retain their modem sleep and normal FreeRTOS idle behavior.
|
|
vTaskDelay(pdMS_TO_TICKS(idle_slice_ms));
|
|
}
|
|
#endif
|
|
#elif defined(RP2040_PLATFORM) || defined(STM32_PLATFORM)
|
|
if (noise_floor_wait) board.sleepMilliseconds(idle_slice_ms);
|
|
else board.sleep(0); // event-driven idle; interrupts wake the main loop
|
|
#endif
|
|
}
|
|
#if defined(ESP32_PLATFORM)
|
|
else if (!morse_timing && !the_mesh.isDualRadioActive()
|
|
&& the_mesh.getNodePrefs()->powersaving_enabled) {
|
|
// The scanner has a zero-delay polling contract and sub-ms RX visits.
|
|
// Keep the idle yield for USB/logging/button holds, not live radio2.
|
|
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(ESP32_PLATFORM)
|
|
// A logical off request must finish physical host/controller teardown even
|
|
// while this interface is disabled and no longer polled for frames.
|
|
bluetooth_interface.servicePowerState();
|
|
#endif
|
|
#if defined(NRF52_PLATFORM)
|
|
serviceCompanionBluetoothName();
|
|
#endif
|
|
#endif
|
|
}
|