mirror of
https://github.com/mikecarper/MeshCore.git
synced 2026-09-17 11:04:20 +00:00
Retain same-IP replies and park one displaced IP backlog for 30 seconds using existing fixed buffers. Cancel old route-owned operations on takeover, preserve partial TCP frames, and cover reconnect isolation and queue limits with regression tests.
3209 lines
120 KiB
C++
3209 lines
120 KiB
C++
#include <Arduino.h> // needed for PlatformIO
|
|
#include <Mesh.h>
|
|
#include <helpers/BluetoothMac.h>
|
|
#include "MyMesh.h"
|
|
#include "CompanionBluetooth.h"
|
|
#include "CompanionWireless.h"
|
|
#include "CompanionWiFi.h"
|
|
#if MESH_PACKET_LOGGING
|
|
#include <helpers/SerialPacketLog.h>
|
|
#endif
|
|
|
|
#ifdef ESP32_PLATFORM
|
|
#include "esp_bt.h"
|
|
#include "esp_pm.h"
|
|
#include "esp_sleep.h"
|
|
#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
|
|
// Believe it or not, this std C function is busted on some platforms!
|
|
static uint32_t _atoi(const char* sp) {
|
|
uint32_t n = 0;
|
|
while (*sp && *sp >= '0' && *sp <= '9') {
|
|
n *= 10;
|
|
n += (*sp++ - '0');
|
|
}
|
|
return n;
|
|
}
|
|
|
|
// interface manager
|
|
#include <helpers/MultiSerialInterface.h>
|
|
MultiSerialInterface interface_manager;
|
|
|
|
// include bluetooth interface
|
|
#if defined(BLE_PIN_CODE)
|
|
#ifdef ESP32
|
|
// include esp32 bluetooth interface
|
|
#include <helpers/esp32/SerialBLEInterface.h>
|
|
SerialBLEInterface bluetooth_interface;
|
|
#elif defined(NRF52_PLATFORM)
|
|
// include nrf52 bluetooth interface
|
|
#include <helpers/nrf52/SerialBLEInterface.h>
|
|
SerialBLEInterface bluetooth_interface;
|
|
#else
|
|
#error "SerialBLEInterface is not defined for this platform"
|
|
#endif
|
|
#endif
|
|
|
|
// include wifi interface
|
|
#ifdef WIFI_SSID
|
|
#ifndef TCP_PORT
|
|
#define TCP_PORT 5000
|
|
#endif
|
|
#ifdef ESP32
|
|
// include esp32 wifi interface
|
|
#include <helpers/esp32/SerialWifiInterface.h>
|
|
#include <helpers/WiFiSetupPortal.h>
|
|
#include <helpers/CLICommandUtils.h>
|
|
#include <helpers/WiFiReconnectPolicy.h>
|
|
#include <helpers/WiFiPowerSave.h>
|
|
#include <helpers/CompanionWiFiNtpPolicy.h>
|
|
#include <helpers/ui/DisplayTextLayout.h>
|
|
#include <helpers/ui/IndicatorRenderProfile.h>
|
|
#include <helpers/ui/WiFiSetupQrDisplay.h>
|
|
#include <helpers/esp32/SntpOperationCoordinator.h>
|
|
#include <helpers/esp32/TlsClockValidity.h>
|
|
#include <helpers/esp32/WiFiRadioPolicy.h>
|
|
#include <helpers/esp32/WiFiStationPolicy.h>
|
|
#include <Preferences.h>
|
|
#include <atomic>
|
|
#include <esp_sntp.h>
|
|
#include <esp_wifi.h>
|
|
SerialWifiInterface wifi_interface;
|
|
#ifndef WIFI_PWD
|
|
#define WIFI_PWD ""
|
|
#endif
|
|
#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;
|
|
HardwareSerial companion_serial(1);
|
|
#endif
|
|
|
|
// platform file system
|
|
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
|
|
#include <InternalFileSystem.h>
|
|
#if defined(NRF52_PLATFORM)
|
|
#include <flash/flash_nrf5x.h>
|
|
#include <helpers/nrf52/InternalSecondaryFsRepair.h>
|
|
#if defined(NRF52840_XXAA)
|
|
static const uint32_t INTERNAL_PRIMARY_FS_START = 0x000ED000UL;
|
|
#else
|
|
static const uint32_t INTERNAL_PRIMARY_FS_START = 0x0006D000UL;
|
|
#endif
|
|
static const uint32_t INTERNAL_PRIMARY_FS_SIZE =
|
|
7UL * FLASH_NRF52_PAGE_SIZE;
|
|
#endif
|
|
#if defined(QSPIFLASH)
|
|
#include <CustomLFS_QSPIFlash.h>
|
|
DataStore store(InternalFS, QSPIFlash, rtc_clock);
|
|
#else
|
|
#if defined(EXTRAFS)
|
|
#include <CustomLFS.h>
|
|
extern "C" uint32_t __flash_arduino_end[];
|
|
CustomLFS ExtraFS(0xD4000, 0x19000, 128);
|
|
DataStore store(InternalFS, ExtraFS, rtc_clock);
|
|
#else
|
|
DataStore store(InternalFS, rtc_clock);
|
|
#endif
|
|
#endif
|
|
#elif defined(RP2040_PLATFORM)
|
|
#include <LittleFS.h>
|
|
DataStore store(LittleFS, rtc_clock);
|
|
#elif defined(ESP32)
|
|
#include <SPIFFS.h>
|
|
DataStore store(SPIFFS, rtc_clock);
|
|
#endif
|
|
|
|
/* GLOBAL OBJECTS */
|
|
#ifdef DISPLAY_CLASS
|
|
#include "UITask.h"
|
|
UITask ui_task(&board, &interface_manager);
|
|
#endif
|
|
|
|
StdRNG fast_rng;
|
|
SimpleMeshTables tables;
|
|
MyMesh the_mesh(radio_driver, fast_rng, rtc_clock, tables, store
|
|
#ifdef DISPLAY_CLASS
|
|
, &ui_task
|
|
#endif
|
|
);
|
|
|
|
#if COMPANION_FEATURE_BLE_MOTA_SOURCE
|
|
#include <helpers/ota/MotaSourceSerial.h>
|
|
#include <helpers/ota/OtaContext.h>
|
|
|
|
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;
|
|
}
|
|
|
|
if (!mesh::ota::ota_acquire_context(reply, reply_size)) 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);
|
|
if (!context.attach_folder_source(
|
|
&_source, mesh::ota::OtaContext::FOLDER_LINK_BLE, "ble",
|
|
reply, reply_size)) {
|
|
bluetooth_interface.setMotaStreamActive(false);
|
|
return false;
|
|
}
|
|
|
|
context.manager.announce();
|
|
mesh::usbLoggingPort().println("Bluetooth mOTA source attached");
|
|
return true;
|
|
}
|
|
|
|
bool stop(char* reply, size_t reply_size) override {
|
|
if (!reply || reply_size == 0) return false;
|
|
bluetooth_interface.setMotaStreamActive(false);
|
|
if (!mesh::ota::ota_context_if_active()) {
|
|
snprintf(reply, reply_size, "OK Bluetooth mOTA source stopped");
|
|
return true;
|
|
}
|
|
mesh::ota::OtaContext& context = mesh::ota::ota_ctx();
|
|
if (context.folder_active
|
|
&& context.folderLink() == mesh::ota::OtaContext::FOLDER_LINK_BLE) {
|
|
context.detach_folder();
|
|
context.manager.announce();
|
|
mesh::usbLoggingPort().println("Bluetooth mOTA source detached");
|
|
_last_packets_sent = context.manager.packetsSent()
|
|
- _packets_sent_at_start;
|
|
}
|
|
snprintf(reply, reply_size, "OK Bluetooth mOTA source stopped");
|
|
return true;
|
|
}
|
|
|
|
mesh::companion::MotaSourceStatus status() const override {
|
|
mesh::companion::MotaSourceStatus result;
|
|
result.channel_ready = bluetooth_interface.isMotaChannelReady();
|
|
if (!mesh::ota::ota_context_if_active()) {
|
|
result.packets_sent = _last_packets_sent;
|
|
return result;
|
|
}
|
|
const mesh::ota::OtaContext& context = mesh::ota::ota_ctx();
|
|
result.attached = context.folder_active
|
|
&& context.folderLink() == mesh::ota::OtaContext::FOLDER_LINK_BLE
|
|
&& bluetooth_interface.isMotaStreamActive();
|
|
result.another_link_active = context.folder_active
|
|
&& context.folderLink() != mesh::ota::OtaContext::FOLDER_LINK_BLE;
|
|
if (result.attached) {
|
|
context.folderSourceStats(result.offered, result.advertised);
|
|
result.packets_sent = context.manager.packetsSent()
|
|
- _packets_sent_at_start;
|
|
} else {
|
|
result.packets_sent = _last_packets_sent;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
void loop() {
|
|
if (!mesh::ota::ota_context_if_active()) {
|
|
bluetooth_interface.setMotaStreamActive(false);
|
|
return;
|
|
}
|
|
mesh::ota::OtaContext& context = mesh::ota::ota_ctx();
|
|
const bool owns_folder = context.folder_active
|
|
&& context.folderLink() == mesh::ota::OtaContext::FOLDER_LINK_BLE;
|
|
if (!owns_folder) {
|
|
if (bluetooth_interface.isMotaStreamActive()) {
|
|
bluetooth_interface.setMotaStreamActive(false);
|
|
}
|
|
return;
|
|
}
|
|
if (bluetooth_interface.isMotaChannelReady()
|
|
&& bluetooth_interface.isMotaStreamActive()) {
|
|
return;
|
|
}
|
|
|
|
bluetooth_interface.setMotaStreamActive(false);
|
|
context.detach_folder();
|
|
context.manager.announce();
|
|
_last_packets_sent = context.manager.packetsSent()
|
|
- _packets_sent_at_start;
|
|
mesh::usbLoggingPort().println(
|
|
"Bluetooth mOTA source disconnected and was detached");
|
|
}
|
|
|
|
private:
|
|
mesh::ota::SerialMotaSource _source;
|
|
uint32_t _packets_sent_at_start;
|
|
uint32_t _last_packets_sent;
|
|
};
|
|
|
|
static Nrf52BleMotaSourceControl ble_mota_source_control;
|
|
#endif
|
|
|
|
/* END GLOBAL OBJECTS */
|
|
|
|
#ifdef RECOVERABLE_EXTERNAL_RADIO
|
|
static bool companion_radio_available = true;
|
|
static unsigned long companion_radio_retry_at = 0;
|
|
static const unsigned long COMPANION_RADIO_RETRY_MS = 60000UL;
|
|
|
|
static void serviceCompanionRadioRecovery() {
|
|
if (companion_radio_available
|
|
|| (long)(millis() - companion_radio_retry_at) < 0) return;
|
|
|
|
companion_radio_retry_at = millis() + COMPANION_RADIO_RETRY_MS;
|
|
mesh::usbLoggingPort().println("Radio recovery probe starting");
|
|
if (!radio_init()) {
|
|
mesh::usbLoggingPort().println(
|
|
"Radio recovery probe failed; companion services remain available");
|
|
return;
|
|
}
|
|
|
|
companion_radio_available = true;
|
|
the_mesh.activateRadio();
|
|
mesh::usbLoggingPort().println("Radio recovered; mesh transport is active");
|
|
}
|
|
#endif
|
|
|
|
#ifdef ESP32_PLATFORM
|
|
static int8_t applied_power_saving = -1;
|
|
static int8_t attempted_power_saving = -1;
|
|
static unsigned long power_saving_retry_at = 0;
|
|
|
|
static uint32_t companionNominalCpuMhz() {
|
|
#ifdef ESP32_POST_BOOT_CPU_FREQ
|
|
return ESP32_POST_BOOT_CPU_FREQ;
|
|
#elif defined(ESP32_CPU_FREQ)
|
|
return ESP32_CPU_FREQ;
|
|
#else
|
|
return F_CPU / 1000000UL;
|
|
#endif
|
|
}
|
|
|
|
#if COMPANION_BT_MODEM_SLEEP_AVAILABLE
|
|
static void applyCompanionBluetoothSleep(bool enabled,
|
|
bool allow_uninitialized) {
|
|
esp_err_t result = enabled ? esp_bt_sleep_enable()
|
|
: esp_bt_sleep_disable();
|
|
if (result == ESP_OK
|
|
|| (allow_uninitialized && result == ESP_ERR_INVALID_STATE)) {
|
|
return;
|
|
}
|
|
|
|
mesh::usbLoggingPort().printf("Bluetooth sleep %s failed: %s\r\n",
|
|
enabled ? "enable" : "disable",
|
|
esp_err_to_name(result));
|
|
}
|
|
#endif
|
|
|
|
static bool applyCompanionPowerSaving(bool enabled) {
|
|
const uint32_t nominal_mhz = companionNominalCpuMhz();
|
|
const uint32_t max_mhz = enabled && nominal_mhz > 80 ? 80 : nominal_mhz;
|
|
|
|
#if COMPANION_IDF_PM_AVAILABLE
|
|
const uint32_t min_mhz = enabled && max_mhz > 40 ? 40 : max_mhz;
|
|
#if defined(ARDUINO_USB_CDC_ON_BOOT) && ARDUINO_USB_CDC_ON_BOOT
|
|
// Automatic light sleep interrupts native USB CDC on ESP32 companions.
|
|
// Frequency scaling remains active when device power saving is enabled.
|
|
const bool automatic_light_sleep = false;
|
|
#else
|
|
const bool automatic_light_sleep = enabled;
|
|
#endif
|
|
#if CONFIG_IDF_TARGET_ESP32C3
|
|
esp_pm_config_esp32c3_t pm_config;
|
|
#elif CONFIG_IDF_TARGET_ESP32S3
|
|
esp_pm_config_esp32s3_t pm_config;
|
|
#elif CONFIG_IDF_TARGET_ESP32
|
|
esp_pm_config_esp32_t pm_config;
|
|
#else
|
|
esp_pm_config_t pm_config;
|
|
#endif
|
|
pm_config.max_freq_mhz = max_mhz;
|
|
pm_config.min_freq_mhz = min_mhz;
|
|
pm_config.light_sleep_enable = automatic_light_sleep;
|
|
|
|
esp_err_t pm_result = esp_pm_configure(&pm_config);
|
|
if (pm_result != ESP_OK) {
|
|
mesh::usbLoggingPort().printf("Device power saving failed: %s\r\n",
|
|
esp_err_to_name(pm_result));
|
|
return false;
|
|
}
|
|
#else
|
|
// Arduino's prebuilt ESP-IDF normally has CONFIG_PM_ENABLE disabled, in
|
|
// which case esp_pm_configure() is a stub returning ESP_ERR_NOT_SUPPORTED.
|
|
// Keep frequency throttling functional instead of retrying that stub.
|
|
if (!setCpuFrequencyMhz(max_mhz)) {
|
|
mesh::usbLoggingPort().printf(
|
|
"Device power saving failed: CPU %lu MHz is unsupported\r\n",
|
|
(unsigned long)max_mhz);
|
|
return false;
|
|
}
|
|
#endif
|
|
|
|
#if COMPANION_BT_MODEM_SLEEP_AVAILABLE
|
|
// Power saving is applied before the wireless interfaces are constructed.
|
|
// An uninitialized controller is expected here; apply the saved setting
|
|
// again immediately after BLEDevice::init() below. Some prebuilt ESP-IDF
|
|
// targets omit Bluetooth modem sleep entirely; the compile-time guard avoids
|
|
// calling an API which can only return ESP_ERR_NOT_SUPPORTED in those builds.
|
|
applyCompanionBluetoothSleep(enabled, true);
|
|
#endif
|
|
|
|
#if COMPANION_IDF_PM_AVAILABLE
|
|
mesh::usbLoggingPort().printf(
|
|
"Device power saving %s: CPU %lu-%lu MHz, automatic light sleep %s\r\n",
|
|
enabled ? "on" : "off", (unsigned long)min_mhz,
|
|
(unsigned long)max_mhz, automatic_light_sleep ? "on" : "off");
|
|
#else
|
|
mesh::usbLoggingPort().printf("Device power saving %s: CPU %lu MHz\r\n",
|
|
enabled ? "on" : "off",
|
|
(unsigned long)max_mhz);
|
|
#endif
|
|
return true;
|
|
}
|
|
|
|
static void serviceCompanionPowerSaving(bool force = false) {
|
|
const int8_t requested = the_mesh.getNodePrefs()->powersaving_enabled ? 1 : 0;
|
|
if (!force && applied_power_saving == requested) return;
|
|
|
|
const unsigned long now = millis();
|
|
if (!force && attempted_power_saving == requested
|
|
&& power_saving_retry_at != 0
|
|
&& (int32_t)(now - power_saving_retry_at) < 0) {
|
|
return;
|
|
}
|
|
|
|
attempted_power_saving = requested;
|
|
if (applyCompanionPowerSaving(requested != 0)) {
|
|
applied_power_saving = requested;
|
|
power_saving_retry_at = 0;
|
|
} else {
|
|
power_saving_retry_at = now + 5000;
|
|
if (power_saving_retry_at == 0) power_saving_retry_at = 1;
|
|
}
|
|
}
|
|
#endif
|
|
|
|
#if defined(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 mesh::UsbBinaryStartupProbe usb_binary_startup_probe;
|
|
static mesh::UsbAsciiSessionDefault usb_ascii_session_default;
|
|
#if COMPANION_FEATURE_USB_MOTA_SOURCE
|
|
static bool usb_mota_mode = false;
|
|
static char usb_mota_line[32];
|
|
static size_t usb_mota_line_len = 0;
|
|
static bool usb_mota_disconnect_armed = false;
|
|
#endif
|
|
|
|
// nRF52's Adafruit CDC Stream::write() waits and yields until the complete
|
|
// request is queued. Functional terminal text uses a bounded queue over the
|
|
// single-attempt CDC0 facade so a normal host receives complete replies while
|
|
// an open but unread host cannot starve the mesh loop. Other platforms retain
|
|
// their ordinary stream.
|
|
static Stream& usbTerminalOutput() {
|
|
return mesh::usbTerminalPort();
|
|
}
|
|
|
|
static void clearUsbTerminalLine() {
|
|
memset(usb_terminal_line, 0, sizeof(usb_terminal_line));
|
|
usb_terminal_line_len = 0;
|
|
}
|
|
|
|
static void printUsbTerminalInputEcho() {
|
|
const char* password =
|
|
mesh::cli::terminalPasswordInput(usb_terminal_line);
|
|
size_t visible_len = usb_terminal_line_len;
|
|
if (password != nullptr) {
|
|
visible_len = static_cast<size_t>(password - usb_terminal_line);
|
|
}
|
|
|
|
if (visible_len > 0) {
|
|
usbTerminalOutput().write(
|
|
reinterpret_cast<const uint8_t*>(usb_terminal_line), visible_len);
|
|
}
|
|
for (size_t i = visible_len; i < usb_terminal_line_len; i++) {
|
|
usbTerminalOutput().print('*');
|
|
}
|
|
}
|
|
|
|
static void redrawUsbTerminalInput() {
|
|
// The documented picocom `--imap spchex` converts an echoed BS to "[08]".
|
|
// It deliberately leaves CR untouched, so redraw the edited line with CR
|
|
// and printable bytes only. Padding clears a removed tab or wide glyph.
|
|
usbTerminalOutput().print("\r> ");
|
|
printUsbTerminalInputEcho();
|
|
usbTerminalOutput().print(" ");
|
|
usbTerminalOutput().print("\r> ");
|
|
printUsbTerminalInputEcho();
|
|
}
|
|
|
|
static bool isUsbTerminalDataConnected() {
|
|
#if defined(NRF52_PLATFORM)
|
|
return mesh::isUsbCompanionClientConnected();
|
|
#elif defined(RP2040_PLATFORM)
|
|
return (bool)Serial;
|
|
#else
|
|
return board.isUsbDataConnected();
|
|
#endif
|
|
}
|
|
|
|
static constexpr uint32_t USB_TERMINAL_PROTOCOL_DRAIN_MS = 250;
|
|
|
|
static void queueUsbTerminalControlReply(const char* reply) {
|
|
if (reply == nullptr) return;
|
|
const size_t size = strlen(reply);
|
|
if (usbTerminalOutput().write(
|
|
reinterpret_cast<const uint8_t*>(reply), size) != size) {
|
|
// A transition reply takes priority over stale best-effort terminal text.
|
|
// Clear only the application queue; TinyUSB retains any prefix already
|
|
// accepted, so Binary/mOTA still cannot overtake it.
|
|
mesh::discardUsbTerminalOutput();
|
|
(void)usbTerminalOutput().write(
|
|
reinterpret_cast<const uint8_t*>(reply), size);
|
|
}
|
|
}
|
|
|
|
static void drainUsbTerminalOutputBeforeProtocolSwitch() {
|
|
const uint32_t started = millis();
|
|
while (mesh::hasPendingUsbTerminalOutput()
|
|
#if !(defined(ESP32) && defined(ARDUINO_USB_MODE) && ARDUINO_USB_MODE == 1 \
|
|
&& defined(ARDUINO_USB_CDC_ON_BOOT) && ARDUINO_USB_CDC_ON_BOOT)
|
|
&& isUsbTerminalDataConnected()
|
|
#endif
|
|
&& (uint32_t)(millis() - started)
|
|
< USB_TERMINAL_PROTOCOL_DRAIN_MS) {
|
|
mesh::serviceUsbTerminalPort();
|
|
#if defined(NRF52_PLATFORM)
|
|
board.feedWatchdog();
|
|
#endif
|
|
delay(1);
|
|
}
|
|
// Never let terminal bytes remain queued after another CDC0 protocol is
|
|
// enabled. A draining host normally empties this in a few milliseconds; an
|
|
// unread host loses only the residual text after the bounded deadline.
|
|
mesh::discardUsbTerminalOutput();
|
|
}
|
|
|
|
static bool hasObservableActiveUsbTerminalClient() {
|
|
#if defined(ESP32) && defined(ARDUINO_USB_MODE) && ARDUINO_USB_MODE == 1 \
|
|
&& defined(ARDUINO_USB_CDC_ON_BOOT) && ARDUINO_USB_CDC_ON_BOOT
|
|
// HWCDC exposes only whether USB is plugged in, not whether a host process
|
|
// has opened the terminal. Treat actual buffered activity as ownership; an
|
|
// idle physical cable must not permanently lock TCP port 5002.
|
|
return usb_terminal_line_len != 0 || usb_terminal_discard_line;
|
|
#else
|
|
return isUsbTerminalDataConnected();
|
|
#endif
|
|
}
|
|
|
|
static void cancelUsbSerialOperations() {
|
|
// Contact enumeration uses the manager's pinned streaming route; delayed
|
|
// single replies capture their own route inside MyMesh. Cancel only USB's
|
|
// ownership so a simultaneous BLE/WiFi operation keeps running.
|
|
if (interface_manager.isReplyRouteFor(&usb_serial_interface)) {
|
|
the_mesh.cancelSerialResponseStream();
|
|
}
|
|
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();
|
|
usb_serial_interface.setPassthroughMode(false);
|
|
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_disconnect_armed = false;
|
|
usb_serial_interface.setPassthroughMode(false);
|
|
}
|
|
|
|
static void leaveUsbMotaMode(bool acknowledge) {
|
|
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 - Binary mode\r\n", reply);
|
|
queueUsbTerminalControlReply(transition_reply);
|
|
drainUsbTerminalOutputBeforeProtocolSwitch();
|
|
}
|
|
resetUsbMotaMode();
|
|
}
|
|
|
|
static bool enterUsbMotaMode(mesh::UsbMotaEntryOrigin origin) {
|
|
cancelUsbSerialOperations();
|
|
mesh::discardUsbTerminalOutput();
|
|
usb_serial_interface.setPassthroughMode(true);
|
|
usb_mota_mode = true;
|
|
usb_mota_line_len = 0;
|
|
usb_mota_line[0] = 0;
|
|
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
|
|
|
|
// SerialMotaSource consumes framed responses synchronously while serving a
|
|
// block. Bytes left here are host control text, notably motatool's automatic
|
|
// `ota folder off` on a clean shutdown.
|
|
Stream& usb_input = mesh::usbCompanionPort();
|
|
while (usb_input.available()) {
|
|
int value = usb_input.read();
|
|
if (value < 0) break;
|
|
char c = (char)value;
|
|
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 (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;
|
|
}
|
|
}
|
|
}
|
|
#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() {
|
|
// 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);
|
|
}
|
|
#endif
|
|
if (the_mesh.isTerminalMode()) leaveUsbTerminalMode(false);
|
|
mesh::discardUsbTerminalOutput();
|
|
usb_serial_interface.setPassthroughMode(false);
|
|
clearUsbTerminalLine();
|
|
usb_terminal_discard_line = false;
|
|
usb_logging_terminal_mode = false;
|
|
usb_binary_startup_probe.cancel();
|
|
// setPassthroughMode() clears parser/queue state but frame ownership is a
|
|
// separate proof. Revoke all three explicitly at the session boundary.
|
|
usb_serial_interface.resetSessionState();
|
|
usb_ascii_session_default.request(
|
|
usb_serial_interface.getCompletedFrameCount());
|
|
#if defined(ESP32) && defined(ARDUINO_USB_MODE) && ARDUINO_USB_MODE == 1 \
|
|
&& defined(ARDUINO_USB_CDC_ON_BOOT) && ARDUINO_USB_CDC_ON_BOOT
|
|
// Purge HWCDC's queues behind a temporary diagnostics gate. This keeps the
|
|
// core's shared RTOS objects alive while other firmware tasks are running.
|
|
if (!mesh::resetUsbCompanionTransport()) {
|
|
// Keep the producer gate and PHY quarantined, then retry from later loops.
|
|
// Rebooting on repeatable allocation/contention failures creates a host-
|
|
// enumeration restart loop and still does not prove stale bytes were gone.
|
|
usb_terminal_host_reset_completion_pending = true;
|
|
usb_terminal_host_reset_retry_at = millis() + USB_TRANSPORT_RESET_RETRY_MS;
|
|
return;
|
|
}
|
|
usb_terminal_host_reset_completion_pending = false;
|
|
usb_hwcdc_host_presence.reset();
|
|
#endif
|
|
}
|
|
|
|
static void serviceUsbTerminalHostSessionReset() {
|
|
#if defined(NRF52_PLATFORM) || defined(RP2040_PLATFORM) \
|
|
|| MESH_ESP32_TINYUSB_NONBLOCKING
|
|
// Observe disconnects even while Binary or mOTA owns USB. Owner-task reset
|
|
// events also catch a close/reopen that happens between two main-loop polls.
|
|
const bool connected = isUsbTerminalDataConnected();
|
|
const bool disconnected = usb_host_session_connected && !connected;
|
|
usb_host_session_connected = connected;
|
|
if (mesh::takeUsbTerminalSessionReset() || disconnected) {
|
|
usb_terminal_host_reset_completion_pending = true;
|
|
resetUsbTerminalHostSession();
|
|
}
|
|
|
|
if (usb_terminal_host_reset_completion_pending
|
|
&& mesh::tryCompleteUsbTerminalSessionReset()) {
|
|
usb_terminal_host_reset_completion_pending = false;
|
|
}
|
|
#elif defined(ESP32) && defined(ARDUINO_USB_MODE) && ARDUINO_USB_MODE == 1 \
|
|
&& defined(ARDUINO_USB_CDC_ON_BOOT) && ARDUINO_USB_CDC_ON_BOOT
|
|
if (usb_terminal_host_reset_completion_pending) {
|
|
if ((int32_t)(millis() - usb_terminal_host_reset_retry_at) >= 0) {
|
|
if (mesh::resetUsbCompanionTransport()) {
|
|
usb_terminal_host_reset_completion_pending = false;
|
|
usb_hwcdc_host_presence.reset();
|
|
} else {
|
|
usb_terminal_host_reset_retry_at =
|
|
millis() + USB_TRANSPORT_RESET_RETRY_MS;
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
|
|
const bool host_present = board.isUsbHostConnected();
|
|
(void)usb_hwcdc_host_presence.observeHost(
|
|
host_present, millis(), USB_HOST_LOSS_EDGE_MS,
|
|
USB_HOST_LOSS_GRACE_MS);
|
|
const bool hardware_bus_reset = mesh::takeUsbTerminalSessionReset();
|
|
const bool physical_host_loss =
|
|
usb_hwcdc_host_presence.takeHostLossEdge();
|
|
const bool sustained_host_loss =
|
|
usb_hwcdc_host_presence.takeSustainedHostLoss();
|
|
if (hardware_bus_reset || physical_host_loss || sustained_host_loss) {
|
|
resetUsbTerminalHostSession();
|
|
}
|
|
#endif
|
|
}
|
|
|
|
static void serviceUsbAsciiSessionDefault() {
|
|
if (usb_ascii_session_default.shouldRestore(
|
|
!usb_terminal_host_reset_completion_pending,
|
|
#if COMPANION_FEATURE_NETWORK_TERMINAL || defined(WITH_WEBCONFIG)
|
|
isNetworkTerminalActive(),
|
|
#else
|
|
false,
|
|
#endif
|
|
usb_serial_interface.getCompletedFrameCount())) {
|
|
// A prompt contains '>', the Binary Companion frame marker. Do not send
|
|
// text to an unknown host: the first ASCII input reveals the terminal,
|
|
// while a framed app command switches without poisoning its first reply.
|
|
enterUsbTerminalMode(false);
|
|
}
|
|
}
|
|
|
|
static void beginUsbDefaultSession() {
|
|
#if MESH_USB_LOGGING_AVAILABLE
|
|
if (!mesh::hasDedicatedUsbLoggingPort() && mesh::isUsbLoggingEnabled()) {
|
|
enterUsbLoggingTerminalMode();
|
|
return;
|
|
}
|
|
#endif
|
|
usb_ascii_session_default.request(
|
|
usb_serial_interface.getCompletedFrameCount());
|
|
serviceUsbAsciiSessionDefault();
|
|
}
|
|
|
|
static void serviceUsbTerminal() {
|
|
#if COMPANION_FEATURE_USB_MOTA_SOURCE
|
|
if (usb_mota_mode) {
|
|
serviceUsbMota();
|
|
return;
|
|
}
|
|
#endif
|
|
// 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
|
|
const mesh::UsbLoggingTerminalAction logging_action =
|
|
mesh::selectUsbLoggingTerminalAction(
|
|
mesh::hasDedicatedUsbLoggingPort(), mesh::isUsbLoggingEnabled(),
|
|
the_mesh.isTerminalMode(), usb_logging_terminal_mode,
|
|
true,
|
|
#if COMPANION_FEATURE_NETWORK_TERMINAL || defined(WITH_WEBCONFIG)
|
|
isNetworkTerminalActive()
|
|
#else
|
|
false
|
|
#endif
|
|
);
|
|
switch (logging_action) {
|
|
case mesh::UsbLoggingTerminalAction::CLAIM_USB:
|
|
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
|
|
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;
|
|
}
|
|
|
|
// Companion boots as a useful ASCII terminal. MeshCLI's first framed
|
|
// command begins with '<'; hand that byte over untouched at an empty prompt.
|
|
// A malformed or accidental probe times out and restores the terminal.
|
|
if (!usb_logging_terminal_mode
|
|
&& usb_binary_startup_probe.shouldStart(
|
|
usb_terminal_line_len == 0, usb_terminal_discard_line,
|
|
mesh::usbCompanionPort().peek())) {
|
|
const uint32_t frame_count = usb_serial_interface.getCompletedFrameCount();
|
|
leaveUsbTerminalMode(false);
|
|
usb_binary_startup_probe.start(millis(), frame_count);
|
|
return;
|
|
}
|
|
|
|
Stream& usb_input = mesh::usbCompanionPort();
|
|
if (the_mesh.isTerminalWaitingForInput() && usb_input.available() > 0) {
|
|
enterUsbTerminalMode();
|
|
}
|
|
while (usb_input.available()) {
|
|
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 == '\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) {
|
|
leaveUsbTerminalMode(false);
|
|
enterUsbMotaMode(mesh::UsbMotaEntryOrigin::ASCII);
|
|
return;
|
|
}
|
|
#endif
|
|
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
|
|
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;
|
|
}
|
|
}
|
|
}
|
|
|
|
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) && (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)) return true;
|
|
if (browser_usb_handoff.shouldRestoreAscii(
|
|
usb_serial_interface.getCompletedFrameCount())) enterUsbTerminalMode();
|
|
return false;
|
|
}
|
|
|
|
void MyMesh::endStreamTerminal(Stream& output) {
|
|
const bool owned = isNetworkTerminalMode(output);
|
|
exitNetworkTerminalMode(output);
|
|
if (!owned) {
|
|
browser_usb_handoff.cancel();
|
|
} else if (browser_usb_handoff.shouldRestoreAscii(
|
|
usb_serial_interface.getCompletedFrameCount())) {
|
|
enterUsbTerminalMode();
|
|
}
|
|
}
|
|
#elif defined(WITH_WEBCONFIG)
|
|
bool MyMesh::beginStreamTerminal(Stream& output) {
|
|
return enterNetworkTerminalMode(output);
|
|
}
|
|
|
|
void MyMesh::endStreamTerminal(Stream& output) {
|
|
exitNetworkTerminalMode(output);
|
|
}
|
|
#endif
|
|
|
|
void halt() {
|
|
while (1) ;
|
|
}
|
|
|
|
/* WIFI RECONNECT TRACKERS */
|
|
#if defined(ESP32) && defined(WIFI_SSID)
|
|
static const unsigned long WIFI_SETUP_FALLBACK_MS = 120000UL;
|
|
static const char COMPANION_WIFI_SETUP_AP[] = "MeshCore-Setup";
|
|
WiFiReconnectPolicy::Tracker wifi_reconnect_tracker;
|
|
bool wifi_setup_attempted = false;
|
|
unsigned long last_wifi_setup_attempt = 0;
|
|
bool wifi_setup_recovery_mode = false;
|
|
static char configured_wifi_ssid[32];
|
|
static char configured_wifi_password[65];
|
|
static bool companion_wifi_has_credentials = false;
|
|
static bool companion_wifi_requested = true;
|
|
static bool companion_wifi_active = false;
|
|
static bool companion_wifi_disable_in_progress = false;
|
|
static bool companion_wifi_services_stopped = false;
|
|
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::WiFi;
|
|
static bool companion_transport_boot_mode_loaded = false;
|
|
|
|
CompanionTransportMode getCompanionTransportMode() {
|
|
return the_mesh.getNodePrefs()->wifi_enabled != 0
|
|
? CompanionTransportMode::WiFi
|
|
: CompanionTransportMode::Bluetooth;
|
|
}
|
|
|
|
bool selectCompanionTransportMode(CompanionTransportMode mode) {
|
|
if (mode != CompanionTransportMode::Bluetooth
|
|
&& mode != CompanionTransportMode::WiFi) {
|
|
return false;
|
|
}
|
|
|
|
CompanionNodePrefs* prefs = the_mesh.getNodePrefs();
|
|
const uint8_t previous = prefs->wifi_enabled;
|
|
const uint8_t selected = mode == CompanionTransportMode::WiFi ? 1 : 0;
|
|
if (previous == selected) return true;
|
|
|
|
prefs->wifi_enabled = selected;
|
|
if (the_mesh.savePrefs()) return true;
|
|
prefs->wifi_enabled = previous;
|
|
return false;
|
|
}
|
|
|
|
static bool companionTransportWiFiActiveAtBoot() {
|
|
return companion_transport_boot_mode == CompanionTransportMode::WiFi;
|
|
}
|
|
|
|
static void loadCompanionTransportModeForBoot() {
|
|
companion_transport_boot_mode = getCompanionTransportMode();
|
|
companion_transport_boot_mode_loaded = true;
|
|
companion_wifi_requested = companionTransportWiFiActiveAtBoot();
|
|
mesh::usbLoggingPort().printf(
|
|
"Companion transport: exclusive %s selected for this boot\r\n",
|
|
companion_wifi_requested ? "WiFi" : "Bluetooth");
|
|
}
|
|
|
|
static void releaseCompanionBluetoothMemoryForWiFi() {
|
|
const esp_err_t result = esp_bt_mem_release(ESP_BT_MODE_BTDM);
|
|
if (result == ESP_OK) {
|
|
mesh::usbLoggingPort().println(
|
|
"Companion transport: released Bluetooth memory for exclusive WiFi mode");
|
|
} else {
|
|
mesh::usbLoggingPort().printf(
|
|
"Companion transport: Bluetooth memory release failed: %s\r\n",
|
|
esp_err_to_name(result));
|
|
}
|
|
}
|
|
#endif
|
|
|
|
static constexpr uint32_t COMPANION_WIFI_NTP_TIMEOUT_MS = 15000UL;
|
|
static mesh::wifi::CompanionWiFiNtpPolicy companion_wifi_ntp_policy;
|
|
static std::atomic<uint32_t> companion_wifi_ntp_operation_generation{0};
|
|
static std::atomic<uint32_t> companion_wifi_ntp_proof_generation{0};
|
|
static std::atomic<uint32_t> companion_wifi_ntp_epoch{0};
|
|
static std::atomic<uint32_t> companion_wifi_ntp_proven_at{0};
|
|
static bool companion_wifi_ntp_pending = false;
|
|
static uint32_t companion_wifi_ntp_started = 0;
|
|
static uint32_t companion_wifi_ntp_previous_epoch = 0;
|
|
|
|
static void clearCompanionWiFiNtpCallback() {
|
|
companion_wifi_ntp_operation_generation.store(
|
|
0, std::memory_order_release);
|
|
esp_sntp_set_time_sync_notification_cb(nullptr);
|
|
// The policy below owns the cadence. Leave no SDK-default (three-hour in
|
|
// the current ESP32 toolchain) background poll running between the boot
|
|
// request and the explicit 24-hour refresh.
|
|
esp_sntp_stop();
|
|
}
|
|
|
|
static mesh::sntp_coord::OperationLease companion_wifi_ntp_operation(
|
|
mesh::sntp_coord::processWideCoordinator(),
|
|
clearCompanionWiFiNtpCallback);
|
|
|
|
static void noteCompanionWiFiNtpTime(struct timeval* value) {
|
|
const uint32_t generation =
|
|
companion_wifi_ntp_operation_generation.load(
|
|
std::memory_order_acquire);
|
|
if (!mesh::sntp_coord::processWideCoordinator().owns(generation)) return;
|
|
if (value == nullptr || !mesh::tls_clock::timeIsValid(value->tv_sec)
|
|
|| static_cast<uint64_t>(value->tv_sec) > UINT32_MAX) {
|
|
return;
|
|
}
|
|
companion_wifi_ntp_epoch.store(
|
|
static_cast<uint32_t>(value->tv_sec), std::memory_order_release);
|
|
companion_wifi_ntp_proven_at.store(millis(), std::memory_order_release);
|
|
companion_wifi_ntp_proof_generation.store(
|
|
generation, std::memory_order_release);
|
|
}
|
|
|
|
static bool companionWiFiMqttOwnsNtp() {
|
|
#ifdef WITH_MQTT_BRIDGE
|
|
const bool running = the_mesh.isMQTTRunning();
|
|
if (running && the_mesh.hasFreshMQTTNtpThisBoot()) {
|
|
the_mesh.noteInternetClockSet();
|
|
}
|
|
return running;
|
|
#else
|
|
return false;
|
|
#endif
|
|
}
|
|
|
|
static void cancelCompanionWiFiNtp(bool retry_on_reconnect) {
|
|
if (!companion_wifi_ntp_pending) return;
|
|
companion_wifi_ntp_pending = false;
|
|
companion_wifi_ntp_operation.release();
|
|
if (retry_on_reconnect) companion_wifi_ntp_policy.requestNow();
|
|
}
|
|
|
|
static void serviceCompanionWiFiNtp() {
|
|
// Configured MQTT builds already perform a fresh startup NTP sync and a
|
|
// daily refresh. The common Companion owner covers every non-MQTT or
|
|
// runtime-unconfigured WiFi node without racing MQTT's SNTP proof.
|
|
if (companionWiFiMqttOwnsNtp()) {
|
|
cancelCompanionWiFiNtp(false);
|
|
companion_wifi_ntp_policy.requestNow();
|
|
return;
|
|
}
|
|
|
|
if (WiFi.status() != WL_CONNECTED) {
|
|
cancelCompanionWiFiNtp(true);
|
|
return;
|
|
}
|
|
|
|
const uint32_t now = millis();
|
|
if (companion_wifi_ntp_pending) {
|
|
const uint32_t proof_epoch = companion_wifi_ntp_epoch.load(
|
|
std::memory_order_acquire);
|
|
const uint32_t proof_generation =
|
|
companion_wifi_ntp_proof_generation.load(
|
|
std::memory_order_acquire);
|
|
const uint32_t proven_at = companion_wifi_ntp_proven_at.load(
|
|
std::memory_order_acquire);
|
|
if (proof_epoch != 0 && companion_wifi_ntp_operation.owns()
|
|
&& proof_generation == companion_wifi_ntp_operation.generation()
|
|
&& proven_at - companion_wifi_ntp_started <=
|
|
COMPANION_WIFI_NTP_TIMEOUT_MS) {
|
|
// The callback's epoch is freshness proof, not the value to install:
|
|
// the main loop may consume it one or more seconds later. Copy the
|
|
// current ESP system clock so an external RTC is not immediately
|
|
// written behind the time that SNTP established.
|
|
const time_t raw_system_time = time(nullptr);
|
|
if (mesh::tls_clock::timeIsValid(raw_system_time)
|
|
&& static_cast<uint64_t>(raw_system_time) <= UINT32_MAX) {
|
|
const uint32_t epoch = static_cast<uint32_t>(raw_system_time);
|
|
const bool moved_backward =
|
|
epoch < companion_wifi_ntp_previous_epoch;
|
|
rtc_clock.setCurrentTime(epoch);
|
|
if (moved_backward) rtc_clock.resetUniqueTime(epoch);
|
|
the_mesh.noteInternetClockSet();
|
|
|
|
const int64_t adjustment = static_cast<int64_t>(epoch)
|
|
- static_cast<int64_t>(companion_wifi_ntp_previous_epoch);
|
|
mesh::usbLoggingPort().printf(
|
|
"Companion WiFi NTP: clock synchronized (%lu, adjustment %+lld seconds)\r\n",
|
|
(unsigned long)epoch, (long long)adjustment);
|
|
companion_wifi_ntp_pending = false;
|
|
companion_wifi_ntp_operation.release();
|
|
companion_wifi_ntp_policy.noteSuccess(now);
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (now - companion_wifi_ntp_started >=
|
|
COMPANION_WIFI_NTP_TIMEOUT_MS) {
|
|
mesh::usbLoggingPort().println(
|
|
"Companion WiFi NTP: timed out; retrying in 5 minutes");
|
|
companion_wifi_ntp_pending = false;
|
|
companion_wifi_ntp_operation.release();
|
|
companion_wifi_ntp_policy.noteFailure(now);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (!companion_wifi_ntp_policy.attemptDue(now)) return;
|
|
if (!companion_wifi_ntp_operation.tryAcquire()) {
|
|
companion_wifi_ntp_policy.noteBusy(now);
|
|
return;
|
|
}
|
|
|
|
companion_wifi_ntp_epoch.store(0, std::memory_order_release);
|
|
companion_wifi_ntp_proven_at.store(0, std::memory_order_release);
|
|
companion_wifi_ntp_proof_generation.store(0, std::memory_order_release);
|
|
companion_wifi_ntp_operation_generation.store(
|
|
companion_wifi_ntp_operation.generation(),
|
|
std::memory_order_release);
|
|
companion_wifi_ntp_started = now;
|
|
companion_wifi_ntp_previous_epoch = rtc_clock.getCurrentTime();
|
|
companion_wifi_ntp_pending = true;
|
|
esp_sntp_set_time_sync_notification_cb(noteCompanionWiFiNtpTime);
|
|
esp_sntp_set_sync_status(SNTP_SYNC_STATUS_RESET);
|
|
configTime(0, 0, "time.cloudflare.com", "time.google.com",
|
|
"pool.ntp.org");
|
|
mesh::usbLoggingPort().println(
|
|
"Companion WiFi NTP: requesting fresh UTC time");
|
|
}
|
|
|
|
static bool companionWiFiBluetoothActive() {
|
|
#if defined(COMPANION_EXCLUSIVE_WIFI_BLE)
|
|
const CompanionTransportMode active_mode =
|
|
companion_transport_boot_mode_loaded
|
|
? companion_transport_boot_mode
|
|
: getCompanionTransportMode();
|
|
return active_mode == CompanionTransportMode::Bluetooth;
|
|
#elif defined(BLE_PIN_CODE)
|
|
return true;
|
|
#else
|
|
return false;
|
|
#endif
|
|
}
|
|
|
|
const char* companionWiFiPowerSaveName(uint8_t mode) {
|
|
if (mode == mesh::wifi::kPowerSaveMin) return "min";
|
|
if (mode == mesh::wifi::kPowerSaveMax) return "max";
|
|
return "none";
|
|
}
|
|
|
|
static wifi_ps_type_t companionWiFiPowerSaveType(uint8_t mode) {
|
|
if (mode == mesh::wifi::kPowerSaveNone) return WIFI_PS_NONE;
|
|
if (mode == mesh::wifi::kPowerSaveMax) return WIFI_PS_MAX_MODEM;
|
|
return WIFI_PS_MIN_MODEM;
|
|
}
|
|
|
|
void reloadCompanionWiFiPowerSave() {
|
|
uint8_t configured = mesh::wifi::kDefaultPowerSave;
|
|
Preferences nvs;
|
|
if (nvs.begin("mesh-wifi", true)) {
|
|
configured = nvs.getUChar("powersave", mesh::wifi::kDefaultPowerSave);
|
|
nvs.end();
|
|
}
|
|
companion_wifi_power_save = mesh::wifi::effectivePowerSave(
|
|
configured, companionWiFiBluetoothActive(),
|
|
mesh::wifi::kPrimaryEspNowRadio);
|
|
companion_wifi_power_save_loaded = true;
|
|
applyCompanionWiFiPowerSave();
|
|
}
|
|
|
|
uint8_t getCompanionWiFiPowerSave() {
|
|
if (!companion_wifi_power_save_loaded) reloadCompanionWiFiPowerSave();
|
|
return companion_wifi_power_save;
|
|
}
|
|
|
|
const char* getCompanionWiFiPowerSaveName() {
|
|
return companionWiFiPowerSaveName(getCompanionWiFiPowerSave());
|
|
}
|
|
|
|
bool applyCompanionWiFiPowerSave() {
|
|
if (!companion_wifi_power_save_loaded) {
|
|
reloadCompanionWiFiPowerSave();
|
|
return true;
|
|
}
|
|
if (WiFi.getMode() == WIFI_OFF) return true;
|
|
return esp_wifi_set_ps(
|
|
companionWiFiPowerSaveType(companion_wifi_power_save)) == ESP_OK;
|
|
}
|
|
|
|
CompanionWiFiPowerSaveResult setCompanionWiFiPowerSave(uint8_t mode) {
|
|
if (mode > mesh::wifi::kPowerSaveMax) {
|
|
return CompanionWiFiPowerSaveResult::InvalidMode;
|
|
}
|
|
if (mesh::wifi::kPrimaryEspNowRadio
|
|
&& mode == mesh::wifi::kPowerSaveMax) {
|
|
return CompanionWiFiPowerSaveResult::PrimaryEspNowConflict;
|
|
}
|
|
if (mesh::wifi::effectivePowerSave(
|
|
mode, companionWiFiBluetoothActive(),
|
|
mesh::wifi::kPrimaryEspNowRadio)
|
|
!= mode) {
|
|
return CompanionWiFiPowerSaveResult::BluetoothConflict;
|
|
}
|
|
Preferences nvs;
|
|
if (!nvs.begin("mesh-wifi", false)) {
|
|
return CompanionWiFiPowerSaveResult::StorageError;
|
|
}
|
|
const bool saved =
|
|
nvs.putUChar("powersave", mode) == sizeof(uint8_t);
|
|
nvs.end();
|
|
if (!saved) return CompanionWiFiPowerSaveResult::StorageError;
|
|
|
|
companion_wifi_power_save = mode;
|
|
companion_wifi_power_save_loaded = true;
|
|
if (WiFi.getMode() == WIFI_OFF) {
|
|
return CompanionWiFiPowerSaveResult::SavedForNextConnection;
|
|
}
|
|
return applyCompanionWiFiPowerSave()
|
|
? CompanionWiFiPowerSaveResult::Applied
|
|
: CompanionWiFiPowerSaveResult::SavedForNextConnection;
|
|
}
|
|
|
|
static void loadCompanionWiFiCredentials() {
|
|
companion_wifi_has_credentials = WiFiSetupPortal::loadStoredCredentials(
|
|
configured_wifi_ssid, sizeof(configured_wifi_ssid),
|
|
configured_wifi_password, sizeof(configured_wifi_password));
|
|
if (!companion_wifi_has_credentials
|
|
&& !WiFiSetupPortal::isPlaceholderSSID(WIFI_SSID)
|
|
&& mesh::cli::standaloneWiFiPasswordValid(WIFI_PWD)) {
|
|
strncpy(configured_wifi_ssid, WIFI_SSID, sizeof(configured_wifi_ssid) - 1);
|
|
configured_wifi_ssid[sizeof(configured_wifi_ssid) - 1] = '\0';
|
|
strncpy(configured_wifi_password, WIFI_PWD, sizeof(configured_wifi_password) - 1);
|
|
configured_wifi_password[sizeof(configured_wifi_password) - 1] = '\0';
|
|
companion_wifi_has_credentials = true;
|
|
}
|
|
}
|
|
|
|
void scheduleCompanionWiFiCredentialReload() {
|
|
loadCompanionWiFiCredentials();
|
|
companion_wifi_credential_reload_pending = true;
|
|
companion_wifi_credential_reload_at = millis() + 1500UL;
|
|
if (companion_wifi_credential_reload_at == 0) {
|
|
companion_wifi_credential_reload_at = 1;
|
|
}
|
|
}
|
|
|
|
bool isCompanionWiFiEnabled() {
|
|
return companion_wifi_requested;
|
|
}
|
|
|
|
bool hasCompanionWiFiCredentials() {
|
|
return companion_wifi_has_credentials;
|
|
}
|
|
|
|
bool isCompanionWiFiConnected() {
|
|
// The display describes the station link, not the lifecycle of the
|
|
// Companion TCP services. A valid station association remains the source
|
|
// of truth even if service bookkeeping is delayed or being recovered.
|
|
if (!companion_wifi_requested) return false;
|
|
if (WiFi.status() == WL_CONNECTED) return true;
|
|
wifi_ap_record_t access_point = {};
|
|
return esp_wifi_sta_get_ap_info(&access_point) == ESP_OK;
|
|
}
|
|
|
|
void noteCompanionWiFiDisplayState(CompanionWiFiDisplayState state) {
|
|
companion_wifi_display_state = state;
|
|
companion_wifi_display_frames++;
|
|
}
|
|
|
|
void formatCompanionWiFiDisplayStatus(char* reply, size_t reply_size) {
|
|
if (reply == nullptr || reply_size == 0) return;
|
|
static const char* const names[] = {
|
|
"not-rendered", "setup", "off", "ready", "not-configured",
|
|
"connecting",
|
|
};
|
|
uint8_t state = static_cast<uint8_t>(companion_wifi_display_state);
|
|
if (state >= sizeof(names) / sizeof(names[0])) state = 0;
|
|
wifi_ap_record_t access_point = {};
|
|
const bool idf_associated =
|
|
esp_wifi_sta_get_ap_info(&access_point) == ESP_OK;
|
|
snprintf(reply, reply_size,
|
|
"> state=%s, frames=%lu, display=%u/%02X, requested=%u, services=%u, "
|
|
"wl=%d, idf=%u, IP=%s",
|
|
names[state], (unsigned long)companion_wifi_display_frames,
|
|
companion_display_available ? 1U : 0U,
|
|
(unsigned int)companion_display_begin_status,
|
|
companion_wifi_requested ? 1U : 0U,
|
|
companion_wifi_active ? 1U : 0U, (int)WiFi.status(),
|
|
idf_associated ? 1U : 0U,
|
|
WiFi.localIP().toString().c_str());
|
|
}
|
|
|
|
#ifdef WITH_WEBCONFIG
|
|
void formatCompanionWiFiStatus(char* reply, size_t reply_size) {
|
|
const mesh::wifi::CompanionWiFiRuntimeState runtime = {
|
|
companion_wifi_requested,
|
|
companion_wifi_active,
|
|
companion_wifi_credential_reload_pending,
|
|
};
|
|
WebConfigServer::formatWiFiStatus(reply, reply_size, &runtime);
|
|
}
|
|
#endif
|
|
|
|
void requestCompanionWiFiSetup() {
|
|
if (mesh::wireless::control().blocked(mesh::wireless::WiFi)) return;
|
|
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 toggleCompanionWiFi() {
|
|
if (mesh::wireless::control().blocked(mesh::wireless::WiFi)) return false;
|
|
#if defined(COMPANION_EXCLUSIVE_WIFI_BLE)
|
|
const CompanionTransportMode current = getCompanionTransportMode();
|
|
const CompanionTransportMode selected =
|
|
current == CompanionTransportMode::WiFi
|
|
? CompanionTransportMode::Bluetooth
|
|
: CompanionTransportMode::WiFi;
|
|
if (!selectCompanionTransportMode(selected)) {
|
|
WIFI_DEBUG_PRINTLN(
|
|
"Companion transport selection could not be saved");
|
|
return current == CompanionTransportMode::WiFi;
|
|
}
|
|
WIFI_DEBUG_PRINTLN(
|
|
"Companion transport %s saved; reboot required",
|
|
selected == CompanionTransportMode::WiFi ? "WiFi" : "Bluetooth");
|
|
return selected == CompanionTransportMode::WiFi;
|
|
#else
|
|
companion_wifi_requested = !companion_wifi_requested;
|
|
the_mesh.getNodePrefs()->wifi_enabled = companion_wifi_requested ? 1 : 0;
|
|
the_mesh.savePrefs();
|
|
if (!companion_wifi_requested && companion_wifi_active) {
|
|
companion_wifi_disable_in_progress = true;
|
|
}
|
|
WIFI_DEBUG_PRINTLN("BOOT/GPIO 0 click requested WiFi %s",
|
|
companion_wifi_requested ? "on" : "off");
|
|
return companion_wifi_requested;
|
|
#endif
|
|
}
|
|
|
|
#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 <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 && 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();
|
|
}
|
|
#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;
|
|
}
|
|
the_mesh.handleTerminalCommand(ota_console_line);
|
|
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 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;
|
|
}
|
|
#ifdef WITH_WEBCONFIG
|
|
if (the_mesh.isWebConfigActiveOrStopping()) return;
|
|
#endif
|
|
|
|
companion_wifi_credential_reload_pending = false;
|
|
companion_wifi_credential_reload_at = 0;
|
|
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;
|
|
|
|
board.setInhibitSleep(true);
|
|
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
|
|
if (WebConfigServer::loadEnabled(true)) {
|
|
char web_reply[160];
|
|
the_mesh.startWebConfig(!companion_wifi_has_credentials, web_reply);
|
|
WIFI_DEBUG_PRINTLN("%s", web_reply);
|
|
}
|
|
#endif
|
|
|
|
applyCompanionWiFiPowerSave();
|
|
wifi_interface.begin(TCP_PORT);
|
|
wifi_interface.enable();
|
|
#ifdef ENABLE_OTA
|
|
ota_console_start();
|
|
#endif
|
|
companion_wifi_active = true;
|
|
companion_wifi_services_stopped = false;
|
|
WIFI_DEBUG_PRINTLN("WiFi enabled by BOOT/GPIO 0 control");
|
|
}
|
|
|
|
static void cancelCompanionWiFiSession(void*) {
|
|
if (interface_manager.isReplyRouteFor(&wifi_interface)) {
|
|
the_mesh.cancelSerialResponseStream();
|
|
}
|
|
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 defined(MESH_PRIMARY_ESPNOW) && MESH_PRIMARY_ESPNOW
|
|
// ESP-NOW is the mesh radio on this target. Drop the station/AP services
|
|
// without stopping the driver, then restore its fixed LR mesh channel.
|
|
WiFi.disconnect(false, false);
|
|
WiFi.mode(WIFI_STA);
|
|
mesh::wifi::applyProtocolMask(WIFI_IF_STA);
|
|
mesh::wifi::restoreEspNowChannel();
|
|
#else
|
|
WiFi.disconnect(true, false);
|
|
WiFi.mode(WIFI_OFF);
|
|
#endif
|
|
}
|
|
|
|
static bool finishStoppingCompanionWiFi() {
|
|
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;
|
|
#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();
|
|
}
|
|
}
|
|
#endif
|
|
|
|
#if defined(BLE_PIN_CODE)
|
|
static bool companion_bluetooth_initialized = false;
|
|
static uint32_t companion_bluetooth_start_at = 0;
|
|
static constexpr uint32_t COMPANION_BLUETOOTH_RETRY_MS = 5000UL;
|
|
static uint8_t companion_bluetooth_session_address[
|
|
mesh::companion::BLUETOOTH_MAC_BYTES] = {};
|
|
static bool companion_bluetooth_session_address_ready = false;
|
|
static bool companion_bluetooth_identity_prepared = false;
|
|
static bool companion_bluetooth_clear_bonds_this_boot = false;
|
|
static uint8_t companion_bluetooth_session_mode =
|
|
mesh::companion::BLUETOOTH_MAC_DEFAULT;
|
|
static uint8_t companion_bluetooth_session_stealth_mode =
|
|
mesh::companion::BLUETOOTH_STEALTH_OFF;
|
|
static uint8_t companion_bluetooth_saved_session_address[
|
|
mesh::companion::BLUETOOTH_MAC_BYTES] = {};
|
|
static bool companion_bluetooth_rotation_arm_pending = false;
|
|
static bool companion_bluetooth_stealth_transition_pending = false;
|
|
static mesh::companion::BluetoothPeerIdentity
|
|
companion_bluetooth_stealth_transition_peer;
|
|
static bool companion_bluetooth_stealth_recovery_pending = false;
|
|
static uint32_t companion_bluetooth_identity_retry_at = 0;
|
|
|
|
static bool prepareCompanionBluetoothIdentity(
|
|
CompanionNodePrefs* prefs) {
|
|
if (companion_bluetooth_identity_prepared) return true;
|
|
if (prefs == nullptr) return false;
|
|
|
|
bool address_rotated = false;
|
|
if (!the_mesh.prepareBluetoothMacForBoot(address_rotated)) {
|
|
mesh::usbLoggingPort().println(
|
|
"Companion: Bluetooth address preparation save failed");
|
|
return false;
|
|
}
|
|
|
|
companion_bluetooth_identity_prepared = true;
|
|
companion_bluetooth_session_mode = prefs->bluetooth_mac_mode;
|
|
companion_bluetooth_session_stealth_mode = prefs->bluetooth_stealth_mode;
|
|
companion_bluetooth_clear_bonds_this_boot = address_rotated
|
|
|| companion_bluetooth_session_stealth_mode
|
|
== mesh::companion::BLUETOOTH_STEALTH_PAIRING;
|
|
memcpy(companion_bluetooth_saved_session_address,
|
|
prefs->bluetooth_mac,
|
|
sizeof(companion_bluetooth_saved_session_address));
|
|
if (address_rotated) {
|
|
mesh::usbLoggingPort().println(
|
|
"Companion: Bluetooth address changed for this boot");
|
|
}
|
|
return true;
|
|
}
|
|
|
|
static const uint8_t* companionBluetoothAddress(
|
|
const CompanionNodePrefs* prefs, bool& clear_bonds) {
|
|
clear_bonds = companion_bluetooth_clear_bonds_this_boot;
|
|
if (prefs == nullptr
|
|
|| !mesh::companion::isValidBluetoothMacMode(
|
|
prefs->bluetooth_mac_mode)) {
|
|
mesh::usbLoggingPort().println(
|
|
"Companion: invalid Bluetooth MAC mode; using factory address");
|
|
return nullptr;
|
|
}
|
|
|
|
if (prefs->bluetooth_mac_mode
|
|
== mesh::companion::BLUETOOTH_MAC_RANDOM_EVERY_BOOT) {
|
|
if (!companion_bluetooth_session_address_ready) {
|
|
fast_rng.random(companion_bluetooth_session_address,
|
|
sizeof(companion_bluetooth_session_address));
|
|
mesh::companion::makeRandomStaticBluetoothMac(
|
|
companion_bluetooth_session_address);
|
|
companion_bluetooth_session_address_ready = true;
|
|
}
|
|
// A new local identity cannot safely reuse bonds created for the
|
|
// previous boot's identity.
|
|
clear_bonds = true;
|
|
return companion_bluetooth_session_address;
|
|
}
|
|
|
|
if (mesh::companion::bluetoothMacModeUsesSavedAddress(
|
|
prefs->bluetooth_mac_mode)) {
|
|
if (mesh::companion::isValidBluetoothMac(prefs->bluetooth_mac)) {
|
|
return prefs->bluetooth_mac;
|
|
}
|
|
mesh::usbLoggingPort().println(
|
|
"Companion: invalid saved Bluetooth MAC; using factory address");
|
|
}
|
|
return nullptr;
|
|
}
|
|
|
|
static void scheduleCompanionBluetoothRetry() {
|
|
companion_bluetooth_start_at = millis() + COMPANION_BLUETOOTH_RETRY_MS;
|
|
if (companion_bluetooth_start_at == 0) companion_bluetooth_start_at = 1;
|
|
}
|
|
|
|
static void startCompanionBluetooth() {
|
|
if (companion_bluetooth_initialized) return;
|
|
companion_bluetooth_start_at = 0;
|
|
|
|
mesh::usbLoggingPort().println("Companion: starting Bluetooth");
|
|
CompanionNodePrefs* prefs = the_mesh.getNodePrefs();
|
|
if (!prepareCompanionBluetoothIdentity(prefs)) {
|
|
mesh::usbLoggingPort().println(
|
|
"Companion: Bluetooth identity preparation failed; retrying");
|
|
scheduleCompanionBluetoothRetry();
|
|
return;
|
|
}
|
|
if (!interface_manager.addInterface(InterfaceType::Bluetooth,
|
|
&bluetooth_interface)) {
|
|
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)) {
|
|
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;
|
|
if (interface_manager.isEnabled()) bluetooth_interface.enable();
|
|
}
|
|
|
|
static void serviceDeferredCompanionBluetooth() {
|
|
if (companion_bluetooth_initialized) return;
|
|
if (companion_bluetooth_start_at == 0
|
|
|| (int32_t)(millis() - companion_bluetooth_start_at) < 0) return;
|
|
startCompanionBluetooth();
|
|
}
|
|
|
|
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.cancelSerialOperationsForRoute(&bluetooth_interface);
|
|
interface_manager.disableBluetooth();
|
|
interface_manager.forgetReplyRouteForDisconnected(&bluetooth_interface);
|
|
}
|
|
|
|
static void serviceCompanionBluetoothControl() {
|
|
if (companion_bluetooth_off_at == 0) return;
|
|
const int32_t elapsed = (int32_t)(millis() - companion_bluetooth_off_at);
|
|
if (elapsed < 0) return;
|
|
// The other host may have disconnected while the BLE reply was draining.
|
|
if (!companion_bluetooth_force_off && !hasCompanionNonBluetoothClient()) {
|
|
companion_bluetooth_off_at = 0;
|
|
mesh::usbLoggingPort().println(
|
|
"Bluetooth off cancelled: no other active management connection");
|
|
return;
|
|
}
|
|
// Give the requesting BLE client its reply, with a bounded wait even when
|
|
// its notification queue is stuck. The command itself reports a request.
|
|
if (bluetooth_interface.hasPendingIO() && elapsed < 1750) return;
|
|
disableCompanionBluetoothForCli();
|
|
}
|
|
#endif
|
|
|
|
bool handleCompanionBluetoothCommand(const char* command, char* reply,
|
|
size_t reply_size,
|
|
CompanionBluetoothCommandSource source) {
|
|
if (!reply || reply_size == 0) return false;
|
|
const auto action = parseCompanionBluetoothCommand(command);
|
|
if (action == CompanionBluetoothCommand::None) return false;
|
|
if (action == CompanionBluetoothCommand::Invalid) {
|
|
snprintf(reply, reply_size, "Error: use set bluetooth on|off [force]; force only with off");
|
|
return true;
|
|
}
|
|
#if defined(BLE_PIN_CODE)
|
|
if (!companion_bluetooth_initialized) {
|
|
snprintf(reply, reply_size, "Error: Bluetooth unavailable in this boot; check transport mode/startup");
|
|
return true;
|
|
}
|
|
if (action == CompanionBluetoothCommand::Get) {
|
|
snprintf(reply, reply_size, "bluetooth %s%s",
|
|
interface_manager.isBluetoothEnabled() ? "on" : "off",
|
|
companion_bluetooth_off_at ? " (off pending)" : "");
|
|
return true;
|
|
}
|
|
if (action == CompanionBluetoothCommand::On) {
|
|
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;
|
|
}
|
|
companion_bluetooth_off_at = 0;
|
|
interface_manager.enableBluetooth();
|
|
snprintf(reply, reply_size, "%s", interface_manager.isBluetoothEnabled()
|
|
? "OK - Bluetooth on (this boot)" : "Error: Bluetooth enable failed");
|
|
return true;
|
|
}
|
|
if (!interface_manager.isBluetoothEnabled()) {
|
|
companion_bluetooth_off_at = 0;
|
|
snprintf(reply, reply_size, "OK - Bluetooth already off (this boot)");
|
|
return true;
|
|
}
|
|
const bool force = action == CompanionBluetoothCommand::ForceOff;
|
|
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) {
|
|
disableCompanionBluetoothForCli();
|
|
snprintf(reply, reply_size, "OK - Bluetooth off (this boot)");
|
|
} else {
|
|
companion_bluetooth_force_off = force;
|
|
companion_bluetooth_off_at = millis() + 250;
|
|
if (companion_bluetooth_off_at == 0) companion_bluetooth_off_at = 1;
|
|
snprintf(reply, reply_size, "OK - Bluetooth off requested (this boot)");
|
|
}
|
|
#else
|
|
(void)source;
|
|
snprintf(reply, reply_size, "Error: Bluetooth is not supported in this build");
|
|
#endif
|
|
return true;
|
|
}
|
|
|
|
#if defined(ESP32_PLATFORM) && COMPANION_FEATURE_MEMORY_DIAGNOSTICS
|
|
static void logFullCompanionMemory(const char* stage) {
|
|
mesh::usbLoggingPort().printf(
|
|
"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)
|
|
if (companion_wifi_requested || companion_wifi_active) mask |= mesh::wireless::WiFi;
|
|
#ifdef WITH_WEBCONFIG
|
|
if (the_mesh.isWebConfigActiveOrStopping()) mask |= mesh::wireless::WiFi;
|
|
#endif
|
|
#endif
|
|
#if defined(BLE_PIN_CODE)
|
|
if (interface_manager.isBluetoothEnabled()) mask |= mesh::wireless::Bluetooth;
|
|
#endif
|
|
#if defined(MESH_PRIMARY_ESPNOW) && MESH_PRIMARY_ESPNOW
|
|
if (radio_driver.isEnabled()) mask |= mesh::wireless::EspNow;
|
|
#endif
|
|
return mask;
|
|
}
|
|
uint8_t clients() const override {
|
|
using namespace mesh::wireless;
|
|
if (!interface_manager.isEnabled()) return 0;
|
|
uint8_t mask = 0;
|
|
if (interface_manager.isInterfaceConnected(InterfaceType::Bluetooth)) mask |= Bluetooth;
|
|
if (interface_manager.isInterfaceConnected(InterfaceType::WiFi)) mask |= mesh::wireless::WiFi;
|
|
if (interface_manager.isInterfaceConnected(InterfaceType::Ethernet)) mask |= Independent;
|
|
#if COMPANION_FEATURE_TEXT_TERMINAL && (COMPANION_FEATURE_NETWORK_TERMINAL || defined(WITH_WEBCONFIG))
|
|
if (the_mesh.isAnyNetworkTerminalMode()) mask |= mesh::wireless::WiFi;
|
|
#endif
|
|
#if defined(ENABLE_USB_INTERFACE) \
|
|
&& (defined(NRF52_PLATFORM) || defined(RP2040_PLATFORM) \
|
|
|| (defined(ESP32) && defined(ARDUINO_USB_CDC_ON_BOOT) && ARDUINO_USB_CDC_ON_BOOT \
|
|
&& (!defined(ARDUINO_USB_MODE) || ARDUINO_USB_MODE == 0)))
|
|
#if COMPANION_FEATURE_USB_MOTA_SOURCE
|
|
if (!usb_mota_mode)
|
|
#endif
|
|
if (usb_serial_interface.isEnabled() && isUsbTerminalDataConnected()) mask |= Independent;
|
|
#endif
|
|
return mask;
|
|
}
|
|
bool replyBusy() const override {
|
|
return interface_manager.hasPendingIO();
|
|
}
|
|
mesh::wireless::Result set(uint8_t service, bool on) override {
|
|
using namespace mesh::wireless;
|
|
#if defined(BLE_PIN_CODE)
|
|
if (service == Bluetooth) {
|
|
companion_bluetooth_off_at = 0;
|
|
if (on) interface_manager.enableBluetooth();
|
|
else disableCompanionBluetoothForCli();
|
|
return interface_manager.isBluetoothEnabled() == on ? Result::Done : Result::Failed;
|
|
}
|
|
#endif
|
|
#if defined(ESP32) && defined(WIFI_SSID)
|
|
if (service == mesh::wireless::WiFi) {
|
|
companion_wifi_requested = on;
|
|
if (!on) {
|
|
companion_wifi_setup_requested = false;
|
|
// Sockets stop before the asynchronous web teardown finishes. Keep
|
|
// this latched so a replacement on request completes that teardown
|
|
// before startCompanionWiFi's active-state shortcut can skip restart.
|
|
companion_wifi_disable_in_progress = true;
|
|
if (!finishStoppingCompanionWiFi()) return Result::Pending;
|
|
companion_wifi_disable_in_progress = false;
|
|
#if defined(MESH_PRIMARY_ESPNOW) && MESH_PRIMARY_ESPNOW
|
|
if (!radio_driver.isEnabled()) ::WiFi.mode(WIFI_OFF);
|
|
#endif
|
|
} else {
|
|
// Finish a previously requested teardown before opening new sockets.
|
|
if (companion_wifi_disable_in_progress) {
|
|
if (!finishStoppingCompanionWiFi()) return Result::Pending;
|
|
companion_wifi_disable_in_progress = false;
|
|
}
|
|
startCompanionWiFi();
|
|
}
|
|
return companion_wifi_active == on ? Result::Done : Result::Failed;
|
|
}
|
|
#endif
|
|
#if defined(MESH_PRIMARY_ESPNOW) && MESH_PRIMARY_ESPNOW
|
|
if (service == EspNow) {
|
|
if (on) radio_driver.init();
|
|
else {
|
|
radio_driver.end();
|
|
if (!(enabled() & mesh::wireless::WiFi)) {
|
|
::WiFi.setAutoReconnect(false);
|
|
::WiFi.mode(WIFI_OFF);
|
|
}
|
|
}
|
|
return radio_driver.isEnabled() == on ? Result::Done : Result::Failed;
|
|
}
|
|
#endif
|
|
return Result::Failed;
|
|
}
|
|
};
|
|
static CompanionWirelessBackend companion_wireless;
|
|
|
|
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() {
|
|
mesh::wireless::control().begin(companion_wireless);
|
|
mesh::prepareUsbLoggingPort();
|
|
Serial.begin(115200);
|
|
#if MESH_PACKET_LOGGING
|
|
mesh::serialLogBegin();
|
|
#endif
|
|
mesh::beginUsbLoggingPort();
|
|
board.begin();
|
|
|
|
#ifdef HAS_EXTERNAL_WATCHDOG
|
|
external_watchdog.begin();
|
|
#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
|
|
disp->turnOff(); // No boot content before the saved policy is loaded.
|
|
}
|
|
#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()
|
|
: radio_fallback_rng_seed());
|
|
#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(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; format is followed by a required
|
|
// base remount because Adafruit_LittleFS::format() does not remount an
|
|
// already-unmounted instance.
|
|
const mesh::storage::InternalSecondaryFsBootResult primary_fs_boot =
|
|
mesh::storage::prepareInternalSecondaryFilesystem(
|
|
[]() -> bool {
|
|
return InternalFS.Adafruit_LittleFS::begin();
|
|
},
|
|
[]() -> bool {
|
|
return mesh::storage::isErasedFlashRange(
|
|
INTERNAL_PRIMARY_FS_START, INTERNAL_PRIMARY_FS_SIZE,
|
|
[](uint32_t address) -> uint32_t {
|
|
return *reinterpret_cast<const volatile uint32_t*>(address);
|
|
});
|
|
},
|
|
[]() -> bool {
|
|
InternalFS.end();
|
|
return InternalFS.format();
|
|
});
|
|
if (primary_fs_boot
|
|
== mesh::storage::InternalSecondaryFsBootResult::PreservedNonBlank
|
|
|| primary_fs_boot
|
|
== mesh::storage::InternalSecondaryFsBootResult::InitializationFailed) {
|
|
if (primary_fs_boot
|
|
== mesh::storage::InternalSecondaryFsBootResult::PreservedNonBlank) {
|
|
MESH_DEBUG_PRINTLN(
|
|
"InternalFS: mount failed; preserving nonblank primary storage and blocking startup writes");
|
|
} else {
|
|
MESH_DEBUG_PRINTLN(
|
|
"InternalFS: erased primary storage initialization failed; blocking startup writes");
|
|
}
|
|
store.markPrimaryFSUnavailable();
|
|
}
|
|
#else
|
|
InternalFS.begin();
|
|
#endif
|
|
#if defined(QSPIFLASH)
|
|
if (!QSPIFlash.begin()) {
|
|
// debug output might not be available at this point, might be too early. maybe should fall back to InternalFS here?
|
|
MESH_DEBUG_PRINTLN("CustomLFS_QSPIFlash: failed to initialize");
|
|
#if defined(NRF52_PLATFORM)
|
|
// A failed nrfx QSPI init can leave its IRQ pending/enabled. That IRQ
|
|
// storm starves BLE and the main loop even though this build can fall
|
|
// back to internal storage. Fully release the peripheral on failure.
|
|
NVIC_DisableIRQ(QSPI_IRQn);
|
|
NVIC_ClearPendingIRQ(QSPI_IRQn);
|
|
NRF_QSPI->TASKS_DEACTIVATE = 1;
|
|
NRF_QSPI->ENABLE = QSPI_ENABLE_ENABLE_Disabled;
|
|
#endif
|
|
store.disableSecondaryFS(true);
|
|
} else {
|
|
MESH_DEBUG_PRINTLN("CustomLFS_QSPIFlash: initialized successfully");
|
|
}
|
|
#else
|
|
#if defined(EXTRAFS)
|
|
const bool extra_fs_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_geometry_valid
|
|
|| !ExtraFS.Adafruit_LittleFS::begin()) {
|
|
if (!extra_fs_geometry_valid) {
|
|
MESH_DEBUG_PRINTLN("CustomLFS: internal ExtraFS geometry is not 100 KiB; refusing to mount or erase it");
|
|
} 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
|
|
);
|
|
#elif defined(RP2040_PLATFORM)
|
|
LittleFS.begin();
|
|
store.begin();
|
|
the_mesh.begin(
|
|
#ifdef DISPLAY_CLASS
|
|
disp != NULL
|
|
#else
|
|
false
|
|
#endif
|
|
,
|
|
radio_available
|
|
);
|
|
#elif defined(ESP32)
|
|
SPIFFS.begin(true);
|
|
store.begin();
|
|
the_mesh.begin(
|
|
#ifdef DISPLAY_CLASS
|
|
disp != NULL
|
|
#else
|
|
false
|
|
#endif
|
|
,
|
|
radio_available
|
|
);
|
|
#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();
|
|
|
|
// 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.
|
|
#ifdef 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
|
|
#ifdef WIFI_SSID
|
|
wifi_interface.setSessionChangedCallback(cancelCompanionWiFiSession, nullptr);
|
|
#if defined(COMPANION_EXCLUSIVE_WIFI_BLE)
|
|
if (companionTransportWiFiActiveAtBoot()) {
|
|
#endif
|
|
WiFi.onEvent([](WiFiEvent_t event, WiFiEventInfo_t info){
|
|
if (event == ARDUINO_EVENT_WIFI_STA_DISCONNECTED) {
|
|
#if defined(MESH_PRIMARY_ESPNOW) && MESH_PRIMARY_ESPNOW
|
|
mesh::wifi::restoreEspNowChannel();
|
|
#endif
|
|
if (companion_wifi_requested) {
|
|
WIFI_DEBUG_PRINTLN("WiFi disconnected; automatic recovery is active");
|
|
}
|
|
} else if (event == ARDUINO_EVENT_WIFI_STA_GOT_IP) {
|
|
if (!mesh::wifi::enforceStationChannel()) {
|
|
WIFI_DEBUG_PRINTLN(
|
|
"Rejected WiFi association outside ESP-NOW channel %u",
|
|
(unsigned)mesh::wifi::activeEspNowChannel());
|
|
return;
|
|
}
|
|
#if defined(MESH_PRIMARY_ESPNOW) && MESH_PRIMARY_ESPNOW
|
|
// Association can replace the station protocol bitmap after the
|
|
// pre-connect setup. Restore LR receive support once the AP has
|
|
// finished configuring the shared radio; B/G/N remain enabled for
|
|
// the infrastructure connection.
|
|
if (mesh::wifi::applyProtocolMask(WIFI_IF_STA) != ESP_OK) {
|
|
WIFI_DEBUG_PRINTLN(
|
|
"Failed to restore ESP-NOW/WiFi protocol coexistence");
|
|
}
|
|
#endif
|
|
WIFI_DEBUG_PRINTLN("connected! IP %s (companion app on :%d)",
|
|
WiFi.localIP().toString().c_str(), TCP_PORT);
|
|
}
|
|
});
|
|
|
|
interface_manager.addInterface(InterfaceType::WiFi, &wifi_interface);
|
|
#if !defined(COMPANION_EXCLUSIVE_WIFI_BLE)
|
|
companion_wifi_requested = the_mesh.getNodePrefs()->wifi_enabled != 0;
|
|
#endif
|
|
if (companion_wifi_requested) {
|
|
startCompanionWiFi();
|
|
} else {
|
|
stopCompanionInfrastructureWiFi();
|
|
board.setInhibitSleep(false);
|
|
WIFI_DEBUG_PRINTLN("WiFi remains off from the saved BOOT/GPIO 0 setting");
|
|
}
|
|
#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))
|
|
startCompanionBluetooth();
|
|
#endif
|
|
#endif
|
|
|
|
// add usb interface
|
|
#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);
|
|
#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);
|
|
// 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.begin();
|
|
interface_manager.addInterface(InterfaceType::Ethernet, ðernet_interface);
|
|
#endif
|
|
|
|
// add hardware serial interface
|
|
#if defined(SERIAL_RX)
|
|
companion_serial.setPins(SERIAL_RX, SERIAL_TX);
|
|
companion_serial.begin(115200);
|
|
hardware_serial_interface.begin(companion_serial);
|
|
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, 5-minute sleep GPS cycle.
|
|
if (sensors.getLocationProvider() != NULL) {
|
|
sensors.getLocationProvider()->setPowerSavingProfile(600, 300);
|
|
sensors.setPowerSavingEnabled(
|
|
the_mesh.getNodePrefs()->powersaving_enabled != 0);
|
|
}
|
|
#endif
|
|
#if ENV_INCLUDE_GPS == 1
|
|
the_mesh.applyGpsPrefs();
|
|
#endif
|
|
|
|
#ifdef DISPLAY_CLASS
|
|
ui_task.begin(disp, &sensors, the_mesh.getNodePrefs()); // still want to pass this in as dependency, as prefs might be moved
|
|
#endif
|
|
|
|
board.onBootComplete();
|
|
|
|
#ifdef ESP32_PLATFORM
|
|
serviceCompanionPowerSaving(true);
|
|
#endif
|
|
}
|
|
|
|
void loop() {
|
|
mesh::wireless::control().service(millis());
|
|
#if defined(NRF52_PLATFORM)
|
|
board.feedWatchdog();
|
|
#endif
|
|
#if defined(ENABLE_USB_INTERFACE)
|
|
serviceUsbTerminalHostSessionReset();
|
|
serviceUsbAsciiSessionDefault();
|
|
#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();
|
|
#endif
|
|
the_mesh.loop();
|
|
#ifdef RECOVERABLE_EXTERNAL_RADIO
|
|
serviceCompanionRadioRecovery();
|
|
#endif
|
|
#if defined(ENABLE_USB_INTERFACE)
|
|
serviceUsbTerminal();
|
|
// Commands can synchronously enqueue a multi-line reply. Give it the
|
|
// remaining CDC FIFO capacity now; later loops retain any unwritten suffix.
|
|
mesh::serviceUsbTerminalPort();
|
|
#endif
|
|
interface_manager.loop();
|
|
#if COMPANION_FEATURE_BLE_MOTA_SOURCE
|
|
ble_mota_source_control.loop();
|
|
#endif
|
|
sensors.loop();
|
|
#ifdef DISPLAY_CLASS
|
|
#ifdef INDICATOR_WIFI_FONT_RECOVERY
|
|
// The Indicator keeps rendering with its built-in fallback while a missing
|
|
// SD font is recovered by a background TLS task. This poll only launches
|
|
// work after station Wi-Fi connects and installs a completed font on the
|
|
// main/UI task.
|
|
display.serviceFontRecovery();
|
|
#endif
|
|
#if defined(ESP32) && defined(WIFI_SSID) && defined(WITH_WEBCONFIG) \
|
|
&& UI_WIFI_SETUP_HOME_PAGE != 1
|
|
ui_task.servicePairingState();
|
|
if (ui_task.isPairingPromptActive()) {
|
|
ui_task.loop();
|
|
} else if (isCompanionWiFiEnabled()
|
|
&& the_mesh.isWebConfigSetupActive()) {
|
|
#if defined(TBEAM_1W) && defined(PIN_WIFI_BTN)
|
|
ui_task.serviceWiFiToggleButton();
|
|
#endif
|
|
ui_task.loop(); // Continue button/touch handling while the portal is active.
|
|
renderCompanionSetupDisplay();
|
|
} else {
|
|
ui_task.loop();
|
|
}
|
|
#else
|
|
ui_task.loop();
|
|
#endif
|
|
#endif
|
|
rtc_clock.tick();
|
|
#ifdef TBEAM_1W
|
|
board.updateFanControl();
|
|
#endif
|
|
#ifdef HAS_EXTERNAL_WATCHDOG
|
|
external_watchdog.loop();
|
|
#endif
|
|
|
|
#ifdef ESP32_PLATFORM
|
|
serviceCompanionPowerSaving();
|
|
#endif
|
|
|
|
// USB power alone (for example, a wall charger) does not inhibit sleep.
|
|
// Host sessions, live logging, and button activity still need service.
|
|
bool can_sleep = the_mesh.getNodePrefs()->powersaving_enabled
|
|
&& !the_mesh.hasPendingWork()
|
|
&& !mesh::wireless::control().pending();
|
|
#if defined(ESP32_PLATFORM) && MESH_USB_LOGGING_AVAILABLE
|
|
// The native-USB-only light-sleep path below bypasses ESP32Board::sleep.
|
|
can_sleep = can_sleep && !mesh::isUsbLoggingEnabled();
|
|
#endif
|
|
#if defined(NRF52_PLATFORM) \
|
|
|| (defined(ESP32_PLATFORM) && defined(ENABLE_USB_INTERFACE))
|
|
can_sleep = can_sleep && !board.isUsbHostConnected();
|
|
#endif
|
|
#if defined(MOMENTARY_BUTTON_WAKE_FROM_SLEEP) \
|
|
&& MOMENTARY_BUTTON_WAKE_FROM_SLEEP \
|
|
&& defined(PIN_USER_BTN) && defined(DISPLAY_CLASS)
|
|
// GPIO wake starts the poller, but multi-click and long-press results
|
|
// are due after a time interval rather than another edge. The G3 also
|
|
// retains a two-minute wake interval after button activity.
|
|
can_sleep = can_sleep && !user_btn.needsPolling();
|
|
#endif
|
|
if (can_sleep) {
|
|
#if defined(NRF52_PLATFORM)
|
|
board.sleep(0); // nrf ignores seconds param, sleeps whenever possible
|
|
#elif defined(ESP32_PLATFORM)
|
|
#if COMPANION_IDF_PM_AVAILABLE
|
|
// Yield long enough for ESP-IDF automatic light sleep to enter when no
|
|
// driver holds a power-management lock.
|
|
vTaskDelay(pdMS_TO_TICKS(10));
|
|
#elif defined(ENABLE_USB_INTERFACE) \
|
|
&& defined(ARDUINO_USB_CDC_ON_BOOT) && ARDUINO_USB_CDC_ON_BOOT \
|
|
&& !defined(BLE_PIN_CODE) && !defined(WIFI_SSID) \
|
|
&& !defined(ETHERNET_ENABLED) && !defined(SERIAL_RX)
|
|
// The stock Arduino core has no automatic light sleep. A short timer
|
|
// slice gives native-USB-only battery builds real light sleep without
|
|
// delaying radio, GPS, button, or newly attached USB work by more than the
|
|
// normal 10 ms loop cadence. can_sleep already proved no USB host is up.
|
|
if (esp_sleep_enable_timer_wakeup(10000ULL) != ESP_OK
|
|
|| esp_light_sleep_start() != ESP_OK) {
|
|
vTaskDelay(pdMS_TO_TICKS(10));
|
|
}
|
|
#else
|
|
// Connected transports need their own modem sleep and must retain the
|
|
// normal FreeRTOS idle behavior.
|
|
vTaskDelay(pdMS_TO_TICKS(10));
|
|
#endif
|
|
#elif defined(RP2040_PLATFORM) || defined(STM32_PLATFORM)
|
|
board.sleep(0); // event-driven idle; interrupts wake the main loop
|
|
#endif
|
|
}
|
|
#if defined(ESP32_PLATFORM)
|
|
else if (the_mesh.getNodePrefs()->powersaving_enabled) {
|
|
delay(1); // CPU idle remains available while USB/logging/button holds are active.
|
|
}
|
|
#endif
|
|
|
|
#if defined(ESP32) && defined(WIFI_SSID)
|
|
#ifdef WITH_WEBCONFIG
|
|
the_mesh.serviceWebConfig();
|
|
if (companion_wifi_setup_stop_requested) {
|
|
companion_wifi_setup_stop_requested = false;
|
|
the_mesh.stopWebConfig();
|
|
}
|
|
if (companion_wifi_setup_requested) {
|
|
companion_wifi_setup_requested = false;
|
|
char web_reply[160];
|
|
if (!the_mesh.startWebConfig(true, web_reply)) {
|
|
WIFI_DEBUG_PRINTLN("Display WiFi setup request: %s", web_reply);
|
|
}
|
|
}
|
|
#endif
|
|
serviceCompanionWiFiCredentialReload();
|
|
serviceCompanionWiFiState();
|
|
if (companion_wifi_requested && companion_wifi_active) {
|
|
#ifdef ENABLE_OTA
|
|
ota_console_loop(); // service the local text console (port 5002)
|
|
#endif
|
|
const unsigned long wifi_now = millis();
|
|
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
|
|
#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();
|
|
#endif
|
|
}
|