Files
MeshCore/examples/companion_radio/MyMesh.cpp
T
mikecarper b00d346df7 Unify Heltec V4 OLED, TFT and headless display selection
Detect OLED or the original stock TFT touch assembly in ordinary V4.2/V4.3
images. Add saved live display.type auto/oled/tft/off selection and report
the active selection in board output. Keep sensors on primary I2C, preserve
shared power, and isolate TFT FSPI from radio HSPI through sleep and changes.

Consolidate future picker/build choices only with application capability
proof. Retain exact legacy OTA identities and separate R8 hardware images.
Document controls and physical qualification limits.

Validation: five serialized Full builds with capability/size/RAM checks;
focused host tests, existing R8/T-Deck/OLED regressions, build-profile suite,
and picker/provider tests. V4.3 OLED live changes and off/auto reboot
persistence passed on Mercerwood. Physical TFT qualification remains pending.
2026-10-26 15:19:17 -07:00

11768 lines
463 KiB
C++

#include <helpers/RegulatorSettings.h>
#if defined(ESP32_PLATFORM)
#include <helpers/esp32/CpuClockPolicy.h>
#include <helpers/esp32/PowerTestControls.h>
#endif
#include <helpers/LedSettings.h>
#include <helpers/ui/DisplayPowerSettings.h>
#if defined(HELTEC_V4_DISPLAY_UNIFIED) && HELTEC_V4_DISPLAY_UNIFIED && defined(DISPLAY_CLASS)
#include <helpers/ui/HeltecV4DisplaySettings.h>
#endif
#include <helpers/ui/DisplayNightLocation.h>
#if defined(OLED_AUTO_DETECT) && OLED_AUTO_DETECT && defined(DISPLAY_CLASS)
#include <helpers/ui/OLEDControllerSettings.h>
#endif
#include <helpers/BatteryChargeCLI.h>
#include <helpers/BatteryCalibrationStore.h>
#include <helpers/MorseCLI.h>
#include <helpers/MorseLoopPolicy.h>
#if defined(MESH_HIL_TX_FREQ_MHZ)
#include <helpers/radiolib/HilTxFrequencyGuard.h>
#endif
#if defined(RAK_4631)
#include <helpers/PeripheralModePrefs.h>
#endif
#include <helpers/ui/StartupScreen.h>
#include "NotificationSettingsFile.h"
#include "MyMesh.h"
#if defined(RAK_4631) && defined(COMPANION_RADIO_FULL) \
|| (defined(STM32_PLATFORM) && defined(STM32WLxx) && COMPANION_FEATURE_TEXT_TERMINAL)
#include "CompanionHelpWriter.h"
#endif
#include <helpers/CompanionStartupFrequency.h>
#include <helpers/CompanionRadioDiagnostics.h>
#include <helpers/CompanionPowerDiagnostics.h>
#include <helpers/CompanionTxRoutingCLI.h>
#include <helpers/CompanionTrafficRouting.h>
#include <helpers/CompanionChannelAlias.h>
#include <helpers/FleetCommand.h>
#include "CompanionBluetooth.h"
#include "CompanionWireless.h"
#include "CompanionRetry.h"
#if defined(MESH_SOAK_DIAGNOSTICS)
#include "../../tools/hil/S3SoakDiagnostics.h"
#endif
#if defined(ESP32_PLATFORM) && defined(MESH_HIL_RADIO_TRACE) && MESH_HIL_RADIO_TRACE
#include <helpers/radiolib/ESP32RadioTraceCLI.h>
#endif
#include <Arduino.h> // needed for PlatformIO
#include <ctype.h>
#ifdef ENABLE_WIFI_INTERFACE
#include <WiFi.h>
#endif
#include <Mesh.h>
#include <helpers/CompanionHardwareCommandCompat.h>
#if defined(MESH_HIL_BLE_BENCH) && MESH_HIL_BLE_BENCH
#include <helpers/CompanionBleBench.h>
#endif
#include <helpers/CompanionStatusResponse.h>
#include <helpers/CompanionReader.h>
#include <helpers/IdentityGeneration.h>
#include <helpers/LazyPersistence.h>
#include <helpers/StorageLayout.h>
#include <helpers/StatsFormatHelper.h>
#include <helpers/SmallTableSort.h>
#include <helpers/UsbAsciiBinarySwitch.h>
#include <helpers/UsbLogging.h>
#if defined(ESP32_PLATFORM) && defined(WIFI_SSID)
#include <helpers/esp32/CompanionAdminPassword.h>
#include <helpers/esp32/ManagementPassword.h>
#endif
#include <helpers/UsbLoggingWatchdog.h>
#if MESH_PACKET_LOGGING
#include <helpers/SerialPacketLog.h>
#endif
#if defined(NRF52_POWER_MANAGEMENT)
#include <helpers/NRF52VoltagePolicy.h>
#endif
#if defined(NRF52_PLATFORM)
#include <helpers/nrf52/RamFallbackFileSystem.h>
#include <helpers/nrf52/BleDebugTrace.h>
#endif
#if defined(ENABLE_OTA)
#include <helpers/ota/OtaContext.h>
#if defined(OTA_RAK15002_SD_STORE) && defined(ETHERNET_USE_RAK13800)
bool handleRak4631PeripheralCommand(const char* command, char* reply, size_t capacity);
#endif
#endif
#if defined(ENABLE_OTA)
#include <helpers/ota/OtaSpeedConfig.h>
#endif
#include "helpers/radiolib/RXPowerSaving.h"
#include "helpers/radiolib/RxBoostedGainDefaults.h"
#include "helpers/radiolib/CadTiming.h"
#if COMPANION_FEATURE_READER
static bool isCompanionReaderRequest(const char* command) {
if (!command) return false;
mesh::bible::skipSpace(command);
if (!mesh::bible::token(command, "get")) return false;
mesh::bible::skipSpace(command);
return mesh::bible::token(command, "reader");
}
#endif
#if defined(MESHCORE_EXTRAFS_HIL) \
&& (!defined(NRF52_PLATFORM) || !defined(EXTRAFS) || defined(QSPIFLASH))
#error "MESHCORE_EXTRAFS_HIL requires an nRF52 internal ExtraFS build"
#endif
#if COMPANION_FEATURE_MEMORY_DIAGNOSTICS
#include <helpers/CompanionTerminalDiagnostics.h>
#endif
#if defined(ESP32_PLATFORM) \
&& (defined(BOARD_HAS_PSRAM) || COMPANION_FEATURE_MEMORY_DIAGNOSTICS)
#include <esp_heap_caps.h>
#endif
#if defined(ESP32) && defined(WIFI_SSID)
#include "CompanionWiFi.h"
#include <helpers/WiFiPowerSave.h>
#endif
#if defined(ESP32_PLATFORM)
#include <helpers/ESP32TrueRandom.h>
#endif
#if defined(MESH_PRIMARY_ESPNOW) && MESH_PRIMARY_ESPNOW
#include <helpers/esp32/WiFiRadioPolicy.h>
#endif
#include <helpers/CLICommandUtils.h>
#include <helpers/GpsPowerPolicy.h>
#if COMPANION_FEATURE_TEXT_TERMINAL
#include <helpers/TracePathHelpers.h>
#endif
#if COMPANION_FEATURE_OTA_CLI
#include <helpers/ota/OtaCli.h>
#include <helpers/ota/OtaContext.h>
#include <helpers/ota/CompanionOtaConfig.h>
#endif
#if defined(WITH_MQTT_BRIDGE) && defined(ESP32_PLATFORM) && defined(WIFI_SSID)
#include <helpers/MQTTDefaults.h>
#endif
#if defined(ESP32) && defined(WIFI_SSID)
#include <helpers/WiFiSetupPortal.h>
#endif
#ifdef WITH_WEBCONFIG
#include <WiFi.h>
#include <esp_wifi.h>
#endif
static uint32_t nextRadioApplyRetryDelay(uint8_t& failure_count) {
uint8_t shift = failure_count < 5 ? failure_count : 5;
if (failure_count < 6) failure_count++;
uint32_t delay_ms = 1000UL << shift;
return delay_ms > 30000UL ? 30000UL : delay_ms;
}
#if RXPS_FIXED_ENABLED
#if RXPS_FIXED_LEVEL < 1 || RXPS_FIXED_LEVEL > 10
#error "RXPS_FIXED_LEVEL must be between 1 and 10"
#endif
#if RXPS_FIXED_PREAMBLE != 16 && RXPS_FIXED_PREAMBLE != 32
#error "RXPS_FIXED_PREAMBLE must be 16 or 32"
#endif
#endif
#define CMD_APP_START 1
#define CMD_SEND_TXT_MSG 2
#define CMD_SEND_CHANNEL_TXT_MSG 3
#define CMD_GET_CONTACTS 4 // with optional 'since' (for efficient sync)
#define CMD_GET_DEVICE_TIME 5
#define CMD_SET_DEVICE_TIME 6
#define CMD_SEND_SELF_ADVERT 7
#define CMD_SET_ADVERT_NAME 8
#define CMD_ADD_UPDATE_CONTACT 9
#define CMD_SYNC_NEXT_MESSAGE 10
#define CMD_SET_RADIO_PARAMS 11
#define CMD_SET_RADIO_TX_POWER 12
#define CMD_RESET_PATH 13
#define CMD_SET_ADVERT_LATLON 14
#define CMD_REMOVE_CONTACT 15
#define CMD_SHARE_CONTACT 16
#define CMD_EXPORT_CONTACT 17
#define CMD_IMPORT_CONTACT 18
#define CMD_REBOOT 19
#define CMD_GET_BATT_AND_STORAGE 20 // was CMD_GET_BATTERY_VOLTAGE
#define CMD_SET_TUNING_PARAMS 21
#define CMD_DEVICE_QUERY 22
#define CMD_EXPORT_PRIVATE_KEY 23
#define CMD_IMPORT_PRIVATE_KEY 24
#define CMD_SEND_RAW_DATA 25
#define CMD_SEND_LOGIN 26
#define CMD_SEND_STATUS_REQ 27
#define CMD_HAS_CONNECTION 28
#define CMD_LOGOUT 29 // 'Disconnect'
#define CMD_GET_CONTACT_BY_KEY 30
#define CMD_GET_CHANNEL 31
#define CMD_SET_CHANNEL 32
#define CMD_SIGN_START 33
#define CMD_SIGN_DATA 34
#define CMD_SIGN_FINISH 35
#define CMD_SEND_TRACE_PATH 36
#define CMD_SET_DEVICE_PIN 37
#define CMD_SET_OTHER_PARAMS 38
#define CMD_SEND_TELEMETRY_REQ 39 // can deprecate this
#define CMD_GET_CUSTOM_VARS 40
#define CMD_SET_CUSTOM_VAR 41
#define CMD_GET_ADVERT_PATH 42
#define CMD_GET_TUNING_PARAMS 43
// NOTE: decimal command IDs 44..49 (0x2C..0x31) remain parked
#define CMD_SEND_BINARY_REQ 50
#define CMD_FACTORY_RESET 51
#define CMD_SEND_PATH_DISCOVERY_REQ 52
#define CMD_SET_FLOOD_SCOPE_KEY 54 // v8+
#define CMD_SEND_CONTROL_DATA 55 // v8+
#define CMD_GET_STATS 56 // v8+, second byte is stats type
#define CMD_SEND_ANON_REQ 57
#define CMD_SET_AUTOADD_CONFIG 58
#define CMD_GET_AUTOADD_CONFIG 59
#define CMD_GET_ALLOWED_REPEAT_FREQ 60
#define CMD_SET_PATH_HASH_MODE 61
#define CMD_SEND_CHANNEL_DATA 62
#define CMD_SET_DEFAULT_FLOOD_SCOPE 63
#define CMD_GET_DEFAULT_FLOOD_SCOPE 64
#define CMD_SEND_RAW_PACKET 65
#if defined(RADIO_FEM_RXGAIN) && (RADIO_FEM_RXGAIN == 0)
static constexpr uint8_t DEFAULT_FEM_RX_GAIN = 0;
#else
static constexpr uint8_t DEFAULT_FEM_RX_GAIN = 1;
#endif
// Stats sub-types for CMD_GET_STATS
#define STATS_TYPE_CORE 0
#define STATS_TYPE_RADIO 1
#define STATS_TYPE_PACKETS 2
#define RESP_CODE_OK 0
#define RESP_CODE_ERR 1
#define RESP_CODE_CONTACTS_START 2 // first reply to CMD_GET_CONTACTS
#define RESP_CODE_CONTACT 3 // multiple of these (after CMD_GET_CONTACTS)
#define RESP_CODE_END_OF_CONTACTS 4 // last reply to CMD_GET_CONTACTS
#define CONTACT_STREAM_FRAME_INTERVAL_MS 5 // let slow USB hosts drain accepted frames
#define RESP_CODE_SELF_INFO 5 // reply to CMD_APP_START
#define RESP_CODE_SENT 6 // reply to CMD_SEND_TXT_MSG
#define RESP_CODE_CONTACT_MSG_RECV 7 // a reply to CMD_SYNC_NEXT_MESSAGE (ver < 3)
#define RESP_CODE_CHANNEL_MSG_RECV 8 // a reply to CMD_SYNC_NEXT_MESSAGE (ver < 3)
#define RESP_CODE_CURR_TIME 9 // a reply to CMD_GET_DEVICE_TIME
#define RESP_CODE_NO_MORE_MESSAGES 10 // a reply to CMD_SYNC_NEXT_MESSAGE
#define RESP_CODE_EXPORT_CONTACT 11
#define RESP_CODE_BATT_AND_STORAGE 12 // a reply to a CMD_GET_BATT_AND_STORAGE
#define RESP_CODE_DEVICE_INFO 13 // a reply to CMD_DEVICE_QUERY
#define RESP_CODE_PRIVATE_KEY 14 // a reply to CMD_EXPORT_PRIVATE_KEY
#define RESP_CODE_DISABLED 15
#define RESP_CODE_CONTACT_MSG_RECV_V3 16 // a reply to CMD_SYNC_NEXT_MESSAGE (ver >= 3)
#define RESP_CODE_CHANNEL_MSG_RECV_V3 17 // a reply to CMD_SYNC_NEXT_MESSAGE (ver >= 3)
#define RESP_CODE_CHANNEL_INFO 18 // a reply to CMD_GET_CHANNEL
#define RESP_CODE_SIGN_START 19
#define RESP_CODE_SIGNATURE 20
#define RESP_CODE_CUSTOM_VARS 21
#define RESP_CODE_ADVERT_PATH 22
#define RESP_CODE_TUNING_PARAMS 23
#define RESP_CODE_STATS 24 // v8+, second byte is stats type
#define RESP_CODE_AUTOADD_CONFIG 25
#define RESP_ALLOWED_REPEAT_FREQ 26
#define RESP_CODE_CHANNEL_DATA_RECV 27
#define RESP_CODE_DEFAULT_FLOOD_SCOPE 28
#define RESP_CODE_CLI_REPLY 29 // v14+, a reply to CMD_RUN_CLI_COMMAND
#define MAX_CHANNEL_DATA_LENGTH (MAX_FRAME_SIZE - 9)
#define SEND_TIMEOUT_BASE_MILLIS 500
#define FLOOD_SEND_TIMEOUT_FACTOR 16.0f
#define DIRECT_SEND_PERHOP_FACTOR 6.0f
#define DIRECT_SEND_PERHOP_EXTRA_MILLIS 250
#define LAZY_CONTACTS_WRITE_DELAY 5000
#if defined(ESP32_PLATFORM)
// An ESP save streams one record/CRC chunk per pass. Keep the full
// mesh loop responsive between steps without adding seconds of page delay.
#define CONTACT_PAGE_WRITE_GAP 1
#else
#define CONTACT_PAGE_WRITE_GAP 100
#endif
#define EXPECTED_ACK_RETRY_RECHECK_MILLIS 1000
#define EXPECTED_ACK_HOST_GRACE_MILLIS 10000
#define EXPECTED_ACK_HOST_RECHECK_MILLIS 10
// The encrypted body of an ANON_REQ starts with its usual four-byte tag.
// A distinct marker after that tag prevents a contact introduction from being
// mistaken for a login or another anonymous request.
#if MESH_ENABLE_ONE_KEY_DM
static constexpr uint8_t ONE_KEY_INTRO_MARKER[] = {'D', 'M', 'K', '1'};
static constexpr uint8_t ONE_KEY_REJECT_MARKER[] = {'D', 'M', 'R', '1'};
#endif
static bool save_filter(const ContactInfo& c);
#ifndef DEFAULT_MULTI_ACKS
#define DEFAULT_MULTI_ACKS 0
#endif
#ifndef DEFAULT_PATH_HASH_MODE
#define DEFAULT_PATH_HASH_MODE 0
#endif
#ifndef DEFAULT_COMPANION_PATH_HASH_MODE
#if defined(CASCADE_PROFILE) && CASCADE_PROFILE
// Mode is zero-based: Cascade Companions use two bytes per path hop.
#define DEFAULT_COMPANION_PATH_HASH_MODE 1
#else
#define DEFAULT_COMPANION_PATH_HASH_MODE DEFAULT_PATH_HASH_MODE
#endif
#endif
#ifndef DEFAULT_MANUAL_ADD_CONTACTS
#define DEFAULT_MANUAL_ADD_CONTACTS 0
#endif
#ifndef DEFAULT_AUTOADD_CONFIG
#define DEFAULT_AUTOADD_CONFIG 0
#endif
#ifndef DEFAULT_BUZZER_QUIET
#define DEFAULT_BUZZER_QUIET 1
#endif
#ifndef DEFAULT_CAD_ENABLED
// Preserve the tuned Companion behavior that preceded the runtime setting.
#define DEFAULT_CAD_ENABLED 1
#endif
#ifndef EMERGENCY_CLIENT_REPEAT_HOLD_MS
#define EMERGENCY_CLIENT_REPEAT_HOLD_MS 120000UL
#endif
#ifndef EMERGENCY_CLIENT_REPEAT_JITTER_MS
#define EMERGENCY_CLIENT_REPEAT_JITTER_MS 15000UL
#endif
#define PUBLIC_GROUP_PSK "izOH6cXN6mrJ5e26oRXNcg=="
// First 16 bytes of SHA-256("#testing"), encoded as base64.
#define TESTING_GROUP_PSK "zeXoLPUVZH3LVHp5pPBl0Q=="
#if COMPANION_FEATURE_TEXT_TERMINAL
static const char* terminalContactTypeName(uint8_t type) {
if (type == ADV_TYPE_CHAT) return "Chat";
if (type == ADV_TYPE_REPEATER) return "Repeater";
if (type == ADV_TYPE_ROOM) return "Room";
if (type == ADV_TYPE_SENSOR) return "Sensor";
return "Unknown";
}
#endif
// these are _pushed_ to client app at any time
#define PUSH_CODE_ADVERT 0x80
#define PUSH_CODE_PATH_UPDATED 0x81
#define PUSH_CODE_SEND_CONFIRMED 0x82
#define PUSH_CODE_MSG_WAITING 0x83
#define PUSH_CODE_RAW_DATA 0x84
#define PUSH_CODE_LOGIN_SUCCESS 0x85
#define PUSH_CODE_LOGIN_FAIL 0x86
#define PUSH_CODE_STATUS_RESPONSE 0x87
#define PUSH_CODE_LOG_RX_DATA 0x88
#define PUSH_CODE_TRACE_DATA 0x89
#define PUSH_CODE_NEW_ADVERT 0x8A
#define PUSH_CODE_TELEMETRY_RESPONSE 0x8B
#define PUSH_CODE_BINARY_RESPONSE 0x8C
#define PUSH_CODE_PATH_DISCOVERY_RESPONSE 0x8D
static const uint8_t EMERGENCY_CHANNEL_SECRET[CIPHER_KEY_SIZE] = {
0xe1, 0xad, 0x57, 0x8d, 0x25, 0x10, 0x8e, 0x34,
0x48, 0x08, 0xf3, 0x0d, 0xfd, 0xaa, 0xf9, 0x26
};
#define EMERGENCY_CLIENT_REPEAT_TABLE_SIZE 63
static uint16_t emergency_client_repeats[EMERGENCY_CLIENT_REPEAT_TABLE_SIZE];
static uint8_t emergency_client_repeat_next;
static unsigned long emergency_client_repeat_send_at;
static uint16_t emergency_client_repeat_key;
static mesh::Packet* emergency_client_repeat_packet;
#define PUSH_CODE_CONTROL_DATA 0x8E // v8+
#define PUSH_CODE_CONTACT_DELETED 0x8F // used to notify client app of deleted contact when overwriting oldest
#define PUSH_CODE_CONTACTS_FULL 0x90 // used to notify client app that contacts storage is full
#define PUSH_CODE_ONE_KEY_DM_REJECTED 0x91 // full sender key follows
#define ERR_CODE_UNSUPPORTED_CMD 1
#define ERR_CODE_NOT_FOUND 2
#define ERR_CODE_TABLE_FULL 3
#define ERR_CODE_BAD_STATE 4
#define ERR_CODE_FILE_IO_ERROR 5
#define ERR_CODE_ILLEGAL_ARG 6
// Copied from simple_repeater (could probably be shared)
#define CTL_TYPE_NODE_DISCOVER_REQ 0x80
#define CTL_TYPE_NODE_DISCOVER_RESP 0x90
#define MAX_SIGN_DATA_LEN (8 * 1024) // 8K
static constexpr unsigned long SIGN_SESSION_TIMEOUT_MILLIS = 120000;
// Auto-add config bitmask
// Bit 0: If set, overwrite oldest non-favourite contact when contacts file is full
// Bits 1-4: these indicate which contact types to auto-add when manual_contact_mode = 0x01
#define AUTO_ADD_OVERWRITE_OLDEST (1 << 0) // 0x01 - overwrite oldest non-favourite when full
#define AUTO_ADD_CHAT (1 << 1) // 0x02 - auto-add Chat (Companion) (ADV_TYPE_CHAT)
#define AUTO_ADD_REPEATER (1 << 2) // 0x04 - auto-add Repeater (ADV_TYPE_REPEATER)
#define AUTO_ADD_ROOM_SERVER (1 << 3) // 0x08 - auto-add Room Server (ADV_TYPE_ROOM)
#define AUTO_ADD_SENSOR (1 << 4) // 0x10 - auto-add Sensor (ADV_TYPE_SENSOR)
void MyMesh::writeOKFrame(BaseSerialInterface* route) {
uint8_t buf[1];
buf[0] = RESP_CODE_OK;
if (route != NULL) {
_serial->writeFrameToRoute(route, buf, 1);
} else {
_serial->writeFrame(buf, 1);
}
}
void MyMesh::writeErrFrame(uint8_t err_code, BaseSerialInterface* route) {
uint8_t buf[2];
buf[0] = RESP_CODE_ERR;
buf[1] = err_code;
if (route != NULL) {
_serial->writeFrameToRoute(route, buf, 2);
} else {
_serial->writeFrame(buf, 2);
}
}
size_t MyMesh::writePendingSerialFrame(const uint8_t frame[], size_t len, uint32_t now) {
if (!_delayed_replies.storeRequest(frame, len, now)) return 0;
_delayed_replies.serviceRequest(_serial, now);
return len; // Retained by the producer, not necessarily admitted yet.
}
void MyMesh::writeDisabledFrame() {
uint8_t buf[1];
buf[0] = RESP_CODE_DISABLED;
_serial->writeFrame(buf, 1);
}
bool MyMesh::writeContactRespFrame(uint8_t code, const ContactInfo &contact,
BaseSerialInterface* route) {
int i = 0;
out_frame[i++] = code;
memcpy(&out_frame[i], contact.id.pub_key, PUB_KEY_SIZE);
i += PUB_KEY_SIZE;
out_frame[i++] = contact.type;
out_frame[i++] = contact.flags;
out_frame[i++] = contact.out_path_len;
if (!contact.copyPathTo(&out_frame[i])) return false;
i += MAX_PATH_SIZE;
StrHelper::strzcpy((char *)&out_frame[i], contact.name, 32);
i += 32;
memcpy(&out_frame[i], &contact.last_advert_timestamp, 4);
i += 4;
memcpy(&out_frame[i], &contact.gps_lat, 4);
i += 4;
memcpy(&out_frame[i], &contact.gps_lon, 4);
i += 4;
memcpy(&out_frame[i], &contact.lastmod, 4);
i += 4;
return (route != NULL ? _serial->writeFrameToRoute(route, out_frame, i)
: _serial->writeFrame(out_frame, i)) == (size_t)i;
}
void MyMesh::stopContactsIterator() {
_iter_pending_contact = ContactInfo();
if (!_iter_started) return;
_iter_started = false;
_iter_start_pending = false;
_iter_contact_pending = false;
_iter_next_frame_at = 0;
_iter_reply_route = NULL;
}
static constexpr int CONTACT_UPDATE_FRAME_MIN_LEN =
1 + PUB_KEY_SIZE + 1 + 1 + 1 + MAX_PATH_SIZE + 32 + 4;
static constexpr int CONTACT_UPDATE_FRAME_GPS_LEN =
CONTACT_UPDATE_FRAME_MIN_LEN + 8;
static constexpr int CONTACT_UPDATE_FRAME_LASTMOD_LEN =
CONTACT_UPDATE_FRAME_GPS_LEN + 4;
static bool isPersistentContactType(uint8_t type) {
return type == ADV_TYPE_CHAT || type == ADV_TYPE_REPEATER
|| type == ADV_TYPE_ROOM || type == ADV_TYPE_SENSOR;
}
bool MyMesh::updateContactFromFrame(ContactInfo &contact, uint32_t& last_mod, const uint8_t *frame, int len) {
if (frame == NULL || len < CONTACT_UPDATE_FRAME_MIN_LEN
|| (len > CONTACT_UPDATE_FRAME_MIN_LEN
&& len < CONTACT_UPDATE_FRAME_GPS_LEN)
|| (len > CONTACT_UPDATE_FRAME_GPS_LEN
&& len < CONTACT_UPDATE_FRAME_LASTMOD_LEN)
|| frame[0] != CMD_ADD_UPDATE_CONTACT) {
return false;
}
const uint8_t type = frame[1 + PUB_KEY_SIZE];
const uint8_t out_path_len = frame[1 + PUB_KEY_SIZE + 1 + 1];
if (!isPersistentContactType(type)
|| (out_path_len != OUT_PATH_UNKNOWN
&& !mesh::Packet::isValidPathLen(out_path_len))) {
return false;
}
int i = 1; // command byte
memcpy(contact.id.pub_key, &frame[i], PUB_KEY_SIZE);
i += PUB_KEY_SIZE;
contact.type = frame[i++];
contact.flags = frame[i++];
contact.out_path_len = frame[i++];
if (!contact.setRawPath(&frame[i])) return false;
i += MAX_PATH_SIZE;
memcpy(contact.name, &frame[i], 32);
contact.name[sizeof(contact.name) - 1] = 0;
i += 32;
memcpy(&contact.last_advert_timestamp, &frame[i], 4);
i += 4;
if (len >= CONTACT_UPDATE_FRAME_GPS_LEN) { // optional fields
memcpy(&contact.gps_lat, &frame[i], 4);
i += 4;
memcpy(&contact.gps_lon, &frame[i], 4);
i += 4;
if (len >= CONTACT_UPDATE_FRAME_LASTMOD_LEN) {
memcpy(&last_mod, &frame[i], 4);
}
}
return true;
}
#if COMPANION_RTC_FAST_OFFLINE_FRAMES
alignas(4) RTC_FAST_ATTR MyMesh::Frame MyMesh::rtc_fast_offline_frames[COMPANION_RTC_FAST_OFFLINE_FRAMES];
#endif
#if COMPANION_RTC_SLOW_OFFLINE_FRAMES
alignas(4) RTC_SLOW_ATTR MyMesh::Frame MyMesh::rtc_slow_offline_frames[COMPANION_RTC_SLOW_OFFLINE_FRAMES];
#endif
bool MyMesh::Frame::isChannelMsg() const {
return len > 0 && (buf[0] == RESP_CODE_CHANNEL_MSG_RECV || buf[0] == RESP_CODE_CHANNEL_MSG_RECV_V3 ||
buf[0] == RESP_CODE_CHANNEL_DATA_RECV);
}
int MyMesh::getOfflineQueueCapacity() const {
#if defined(OTA_SHARED_COMPANION_QUEUE)
return offline_queue.capacity();
#elif defined(ESP32_PLATFORM) && defined(BOARD_HAS_PSRAM)
return offline_queue_capacity;
#else
return OFFLINE_QUEUE_SIZE;
#endif
}
MyMesh::Frame& MyMesh::offlineQueueFrameAt(int logical_index) {
#if defined(OTA_SHARED_COMPANION_QUEUE)
return offline_queue.at((offline_queue_head + logical_index) % getOfflineQueueCapacity());
#elif COMPANION_RTC_FAST_OFFLINE_FRAMES || COMPANION_RTC_SLOW_OFFLINE_FRAMES
return offline_queue.at((offline_queue_head + logical_index) % getOfflineQueueCapacity());
#elif ONE_KEY_DM_SHARED_OFFLINE_QUEUE
return offline_queue[logical_index];
#else
return offline_queue[(offline_queue_head + logical_index) % getOfflineQueueCapacity()];
#endif
}
#if ONE_KEY_DM_SHARED_OFFLINE_QUEUE
MyMesh::Frame& MyMesh::heldDMFrameAt(uint8_t index) {
return offline_queue[getOfflineQueueCapacity() - 1 - index];
}
void MyMesh::removeHeldOneKeyDM(uint8_t index) {
for (uint8_t j = index + 1; j < held_dm_count; ++j) {
held_dms[j - 1] = held_dms[j];
heldDMFrameAt(j - 1) = heldDMFrameAt(j);
}
--held_dm_count;
}
#endif
#if defined(OTA_SHARED_COMPANION_QUEUE)
mesh::ota::OtaContext* MyMesh::acquireOfflineQueueForOta(void* owner) {
MyMesh* mesh = static_cast<MyMesh*>(owner);
return mesh->offline_queue.acquire(mesh->offline_queue_len, mesh->offline_queue_head);
}
uint16_t MyMesh::drainOfflineQueueForOta(void* owner) {
MyMesh* mesh = static_cast<MyMesh*>(owner);
uint8_t discarded[MAX_FRAME_SIZE];
uint16_t removed = 0;
// Explicit OTA work borrows the queue's workspace region. Keep the newest
// retained frames and notify the UI for each oldest frame consumed.
while (mesh->offline_queue_len > static_cast<int>(OTA_RETAINED_OFFLINE_FRAMES)) {
if (mesh->getFromOfflineQueue(discarded) <= 0) break;
++removed;
}
return removed;
}
void MyMesh::releaseOfflineQueueFromOta(void* owner) {
MyMesh* mesh = static_cast<MyMesh*>(owner);
mesh->offline_queue.release(mesh->offline_queue_head);
}
#endif
void MyMesh::initializeOfflineQueue() {
#if defined(OTA_SHARED_COMPANION_QUEUE)
mesh::ota::ota_set_context_storage(this, acquireOfflineQueueForOta,
releaseOfflineQueueFromOta, drainOfflineQueueForOta);
#elif defined(ESP32_PLATFORM) && defined(BOARD_HAS_PSRAM)
if (offline_queue != offline_queue_fallback || OFFLINE_QUEUE_SIZE <= offline_queue_capacity) return;
int requested_capacity = OFFLINE_QUEUE_SIZE;
while (requested_capacity > offline_queue_capacity) {
void* storage = heap_caps_malloc(sizeof(Frame) * requested_capacity,
MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
if (storage) {
offline_queue = static_cast<Frame*>(storage);
offline_queue_capacity = requested_capacity;
return;
}
if (requested_capacity > 256) {
requested_capacity = 256;
} else if (requested_capacity > 128) {
requested_capacity = 128;
} else {
requested_capacity = offline_queue_capacity;
}
}
#endif
}
bool MyMesh::addToOfflineQueue(const uint8_t frame[], int len) {
int capacity = getOfflineQueueCapacity();
#if ONE_KEY_DM_SHARED_OFFLINE_QUEUE
capacity -= held_dm_count;
#endif
if (!frame || len <= 0 || len > MAX_FRAME_SIZE || capacity <= 0) {
MESH_DEBUG_PRINTLN("WARN: invalid offline queue frame length: %d", len);
return false;
}
if (offline_queue_len >= capacity) {
MESH_DEBUG_PRINTLN("WARN: offline_queue is full!");
int pos = 0;
while (pos < offline_queue_len) {
if (offlineQueueFrameAt(pos).isChannelMsg()) {
for (int i = pos; i < offline_queue_len - 1; i++) { // delete oldest channel msg from queue
offlineQueueFrameAt(i) = offlineQueueFrameAt(i + 1);
}
MESH_DEBUG_PRINTLN("INFO: removed oldest channel message from queue.");
Frame& tail = offlineQueueFrameAt(offline_queue_len - 1);
tail.len = len;
memcpy(tail.buf, frame, len);
#ifdef DISPLAY_CLASS
if (_ui) _ui->syncMessageQueue(offline_queue_len, pos);
#endif
return true;
}
pos++;
}
MESH_DEBUG_PRINTLN("INFO: no channel messages to remove from queue.");
} else {
Frame& tail = offlineQueueFrameAt(offline_queue_len);
tail.len = len;
memcpy(tail.buf, frame, len);
offline_queue_len++;
#ifdef DISPLAY_CLASS
if (_ui) _ui->syncMessageQueue(offline_queue_len);
#endif
return true;
}
return false;
}
int MyMesh::peekOfflineQueue(uint8_t frame[]) {
if (!frame || offline_queue_len <= 0) return 0;
Frame& head = offlineQueueFrameAt(0);
memcpy(frame, head.buf, head.len);
return head.len;
}
void MyMesh::popOfflineQueue() {
if (offline_queue_len > 0) {
offline_queue_len--;
#if ONE_KEY_DM_SHARED_OFFLINE_QUEUE
for (int i = 0; i < offline_queue_len; ++i) {
offlineQueueFrameAt(i) = offlineQueueFrameAt(i + 1);
}
#else
if (offline_queue_len == 0) {
offline_queue_head = 0;
} else {
offline_queue_head = (offline_queue_head + 1) % getOfflineQueueCapacity();
}
#endif
#ifdef DISPLAY_CLASS
if (_ui) _ui->syncMessageQueue(offline_queue_len, 0);
#endif
}
}
int MyMesh::getFromOfflineQueue(uint8_t frame[]) {
// Explicit OTA queue draining intentionally consumes frames without app
// delivery. Normal app downloads commit only after transport acceptance.
const int len = peekOfflineQueue(frame);
if (len > 0) popOfflineQueue();
return len;
}
void MyMesh::syncNextOfflineMessage() {
BaseSerialInterface* reply_route = _serial->captureReplyRoute();
const Frame* head = offline_queue_len > 0 ? &offlineQueueFrameAt(0) : nullptr;
size_t out_len = head ? head->len : 0;
const bool have_message = out_len > 0;
const uint8_t* reply_frame = out_frame;
if (have_message) {
// A full or disconnected requester must leave this message pending.
// Another connected transport cannot accept its reply on its behalf.
// Transports consume or copy the frame before returning, so the retained
// queue head can be sent directly and is only removed after acceptance.
if (!_serial->isReplyRouteAvailable(reply_route)) return;
reply_frame = head->buf;
} else {
out_frame[0] = RESP_CODE_NO_MORE_MESSAGES;
out_len = 1;
}
const size_t accepted = _serial->writeFrameToRoute(reply_route, reply_frame, out_len);
if (have_message && accepted == out_len) {
popOfflineQueue();
}
}
float MyMesh::getAirtimeBudgetFactor() const {
// TempRadio is a short-lived, explicitly coordinated OTA channel. Let its
// primary transfer use the full dispatcher budget; the persisted public
// channel duty factor becomes authoritative again as soon as TempRadio ends.
#if COMPANION_FEATURE_TEMP_RADIO
if (isTempRadioActive()) return 0.0f;
#endif
return _prefs.airtime_factor;
}
bool MyMesh::getCADEnabled() const {
return _prefs.cad_enabled != 0;
}
uint32_t MyMesh::getCADFailRetryDelay() const {
return _prefs.cad_retry_delay_ms != 0
? _prefs.cad_retry_delay_ms
: BaseChatMesh::getCADFailRetryDelay();
}
uint32_t MyMesh::getCADFailMaxDuration() const {
return _prefs.cad_max_duration_ms != 0
? _prefs.cad_max_duration_ms
: mesh::Dispatcher::getCADFailMaxDuration();
}
int MyMesh::getInterferenceThreshold() const {
return _prefs.interference_threshold;
}
int MyMesh::calcRxDelay(float score, uint32_t air_time) const {
if (_prefs.rx_delay_base <= 0.0f) return 0;
return (int)((powf(_prefs.rx_delay_base, 0.85f - score) - 1.0f) * air_time);
}
uint32_t MyMesh::getRetransmitDelay(const mesh::Packet *packet) {
uint32_t t = (_radio->getProfileAirtime(packet->radio_profile,
packet->getRawLength(), packet->tx_cr) * _prefs.tx_delay_factor);
return getRNG()->nextInt(0, 5*t + 1);
}
uint32_t MyMesh::getDirectRetransmitDelay(const mesh::Packet *packet) {
uint32_t t = (_radio->getProfileAirtime(packet->radio_profile,
packet->getRawLength(), packet->tx_cr) * _prefs.direct_tx_delay_factor);
return getRNG()->nextInt(0, 5*t + 1);
}
uint8_t MyMesh::getExtraAckTransmitCount() const {
return _prefs.multi_acks;
}
bool MyMesh::hasLocationTelemetryRecipient() {
if (_prefs.telemetry_mode_loc == TELEM_MODE_DENY) return false;
ContactsIterator iter = startContactsIterator();
ContactInfo contact;
while (iter.hasNext(this, contact)) {
if (contact.type == ADV_TYPE_NONE) continue;
if (_prefs.telemetry_mode_loc == TELEM_MODE_ALLOW_ALL) return true;
if (_prefs.telemetry_mode_loc == TELEM_MODE_ALLOW_FLAGS &&
((contact.flags >> 1) & TELEM_PERM_LOCATION)) {
return true;
}
}
return false;
}
void MyMesh::updateGpsTelemetryPolicy() {
sensors.setTelemetryLocationAccessAvailable(hasLocationTelemetryRecipient());
}
#if MESH_PACKET_LOGGING && !MESH_PACKET_LOGGING_COMPACT
const char* MyMesh::getLogDateTime() {
// The shared RX/TX summaries use this same virtual formatter. Keep the
// repeater's UTC record grammar so USB packet bridges can pair RAW and RX.
static char timestamp[32];
const DateTime now(getRTCClock()->getCurrentTime());
snprintf(timestamp, sizeof(timestamp), "%02u:%02u:%02u - %u/%u/%u U",
(unsigned)now.hour(), (unsigned)now.minute(), (unsigned)now.second(),
(unsigned)now.day(), (unsigned)now.month(), (unsigned)now.year());
return timestamp;
}
#endif
void MyMesh::logRxRaw(float snr, float rssi, const uint8_t raw[], int len) {
#if MESH_PACKET_LOGGING
if (mesh::isUsbLoggingEnabled()) {
mesh::SerialLogLine<> line;
#if MESH_PACKET_LOGGING_COMPACT
line.printf("R");
line.hex(raw, len);
line.flush(mesh::usbLoggingPort(), false);
#else
line.printf("%s RAW: ", getLogDateTime());
line.hex(raw, len);
line.flush(mesh::usbLoggingPort());
#endif
}
#endif
#if defined(WITH_MQTT_BRIDGE) && defined(ESP32_PLATFORM) && defined(WIFI_SSID)
if (_mqtt_bridge && _mqtt_bridge->isRunning()) {
_mqtt_bridge->storeRawRadioData(raw, len, snr, rssi);
}
#endif
if (_serial->isConnected() && len + 3 <= MAX_FRAME_SIZE) {
int i = 0;
out_frame[i++] = PUSH_CODE_LOG_RX_DATA;
out_frame[i++] = (int8_t)(snr * 4);
out_frame[i++] = (int8_t)(rssi);
memcpy(&out_frame[i], raw, len);
i += len;
_serial->writeFrame(out_frame, i);
}
}
#if defined(WITH_MQTT_BRIDGE) && defined(ESP32_PLATFORM) && defined(WIFI_SSID)
void MyMesh::logRx(mesh::Packet* packet, int, float) {
if (_mqtt_bridge && _mqtt_bridge->isRunning()) {
_mqtt_bridge->onPacketReceived(packet);
}
}
void MyMesh::logTx(mesh::Packet* packet, int) {
if (_mqtt_bridge && _mqtt_bridge->isRunning()) {
_mqtt_bridge->sendPacket(packet);
}
}
#endif
bool MyMesh::isAutoAddEnabled() const {
return (_prefs.manual_add_contacts & 1) == 0;
}
bool MyMesh::shouldAutoAddContactType(uint8_t contact_type) const {
if ((_prefs.manual_add_contacts & 1) == 0) {
return true;
}
uint8_t type_bit = 0;
switch (contact_type) {
case ADV_TYPE_CHAT:
type_bit = AUTO_ADD_CHAT;
break;
case ADV_TYPE_REPEATER:
type_bit = AUTO_ADD_REPEATER;
break;
case ADV_TYPE_ROOM:
type_bit = AUTO_ADD_ROOM_SERVER;
break;
case ADV_TYPE_SENSOR:
type_bit = AUTO_ADD_SENSOR;
break;
default:
return false; // Unknown type, don't auto-add
}
return (_prefs.autoadd_config & type_bit) != 0;
}
bool MyMesh::shouldOverwriteWhenFull() const {
return (_prefs.autoadd_config & AUTO_ADD_OVERWRITE_OLDEST) != 0;
}
uint8_t MyMesh::getAutoAddMaxHops() const {
return _prefs.autoadd_max_hops;
}
bool MyMesh::canMutateContacts() const {
return !_store->hasIncompleteContactLoad();
}
bool MyMesh::onContactOverwrite(const ContactInfo& contact) {
uint16_t released_slot = mesh::storage::CONTACT_SLOT_NONE;
if (!scheduleContactWriteAfterRelease(contact, released_slot)) {
MESH_DEBUG_PRINTLN(
"Contact overwrite refused: persistent slot release failed");
return false;
}
if (!_store->deleteBlobByKey(contact.id.pub_key, PUB_KEY_SIZE)) {
// releaseContact() is failure-atomic and this single-threaded path still
// owns the just-freed slot. Put it back before refusing the overwrite so a
// full filesystem cannot silently retain stale advert state for a contact
// which the live table has already replaced.
if (!restoreContactWriteAfterRelease(contact, released_slot)) {
MESH_DEBUG_PRINTLN(
"Contact overwrite rollback failed after advert-cache error");
}
MESH_DEBUG_PRINTLN(
"Contact overwrite refused: advert-cache deletion failed");
return false;
}
if (_serial != NULL && _serial->isConnected()) {
out_frame[0] = PUSH_CODE_CONTACT_DELETED;
memcpy(&out_frame[1], contact.id.pub_key, PUB_KEY_SIZE);
_serial->writeFrame(out_frame, 1 + PUB_KEY_SIZE);
}
return true;
}
void MyMesh::onContactsFull() {
if (_serial != NULL && _serial->isConnected()) {
out_frame[0] = PUSH_CODE_CONTACTS_FULL;
_serial->writeFrame(out_frame, 1);
}
}
void MyMesh::onDiscoveredContact(ContactInfo &contact, bool is_new, uint8_t path_len, const uint8_t* path) {
if (_serial->isConnected()) {
if (is_new) {
writeContactRespFrame(PUSH_CODE_NEW_ADVERT, contact);
} else {
out_frame[0] = PUSH_CODE_ADVERT;
memcpy(&out_frame[1], contact.id.pub_key, PUB_KEY_SIZE);
_serial->writeFrame(out_frame, 1 + PUB_KEY_SIZE);
}
} else {
#ifdef DISPLAY_CLASS
if (_ui) _ui->notify(UIEventType::newContactMessage);
#endif
}
#if COMPANION_FEATURE_TEXT_TERMINAL
if (hasTerminalOutput() && _terminal_display.shouldShowAdvert()) {
Stream& output = terminalOutput();
output.printf("\r\nADVERT from -> %s\r\n", contact.name);
output.printf(" type: %s\r\n", terminalContactTypeName(contact.type));
output.print(" public key: ");
mesh::Utils::printHex(output, contact.id.pub_key, PUB_KEY_SIZE);
output.print("\r\n> ");
}
#endif
// add inbound-path to mem cache
if (path && mesh::Packet::isValidPathLen(path_len)) { // check path is valid
AdvertPath* p = advert_paths;
uint32_t oldest = 0xFFFFFFFF;
for (int i = 0; i < ADVERT_PATH_TABLE_SIZE; i++) { // check if already in table, otherwise evict oldest
if (memcmp(advert_paths[i].pubkey_prefix, contact.id.pub_key, sizeof(AdvertPath::pubkey_prefix)) == 0) {
p = &advert_paths[i]; // found
break;
}
if (advert_paths[i].recv_timestamp < oldest) {
oldest = advert_paths[i].recv_timestamp;
p = &advert_paths[i];
}
}
memcpy(p->pubkey_prefix, contact.id.pub_key, sizeof(p->pubkey_prefix));
strcpy(p->name, contact.name);
p->recv_timestamp = getRTCClock()->getCurrentTime();
p->path_len = mesh::Packet::copyPath(p->path, path, path_len);
}
ContactInfo* stored = lookupContactByPubKey(contact.id.pub_key, PUB_KEY_SIZE);
if (stored == &contact) scheduleContactWrite(contact);
updateGpsTelemetryPolicy();
}
static int sort_by_recent(const void *a, const void *b) {
return ((AdvertPath *) b)->recv_timestamp - ((AdvertPath *) a)->recv_timestamp;
}
int MyMesh::getRecentlyHeard(AdvertPath dest[], int max_num) {
if (max_num > ADVERT_PATH_TABLE_SIZE) max_num = ADVERT_PATH_TABLE_SIZE;
mesh::sortSmallTable(advert_paths, ADVERT_PATH_TABLE_SIZE, sizeof(advert_paths[0]), sort_by_recent);
for (int i = 0; i < max_num; i++) {
dest[i] = advert_paths[i];
}
return max_num;
}
#if defined(DISPLAY_CLASS) && !(defined(UI_NO_DISCOVER_SCREEN) && (UI_NO_DISCOVER_SCREEN + 0 != 0))
int MyMesh::getDiscoveredNodes(DiscoveredNode nodes[], int max_num) {
if (!nodes || max_num <= 0) return 0;
if (max_num > DISCOVERED_NODES_TABLE_SIZE) max_num = DISCOVERED_NODES_TABLE_SIZE;
if (max_num > disc_nodes_count) max_num = disc_nodes_count;
for (int i = 0; i < max_num; i++) {
nodes[i] = discovered_nodes[i];
}
return max_num;
}
bool MyMesh::requestRepeatersDiscovery() {
uint8_t cmd_bytes[6];
cmd_bytes[0] = CTL_TYPE_NODE_DISCOVER_REQ | 1; // DISCOVER_REQ | prefix only
cmd_bytes[1] = 0xFF; // Repeaters
getRNG()->random(&cmd_bytes[2], 4); // tag
disc_nodes_count = 0;
disc_node_req_active = false;
memcpy(&disc_node_req_tag, &cmd_bytes[2], sizeof(disc_node_req_tag));
mesh::Packet* req = createControlData(cmd_bytes, sizeof(cmd_bytes));
if (req) {
sendZeroHop(req);
disc_node_req_active = true;
disc_node_req_at = millis();
return disc_node_req_active;
}
return false;
}
void MyMesh::checkControlDataForPendingDiscovery(uint8_t payload[], size_t p_len) {
if (!disc_node_req_active
|| static_cast<uint32_t>(millis() - disc_node_req_at) >= 30000UL
|| (p_len < 14)
|| ((payload[0] & 0xF0) != CTL_TYPE_NODE_DISCOVER_RESP)
|| (disc_nodes_count >= DISCOVERED_NODES_TABLE_SIZE)
|| (memcmp(&payload[2], &disc_node_req_tag, 4))) {
return;
}
memcpy(&discovered_nodes[disc_nodes_count].pubkey_prefix, &payload[6], 8);
discovered_nodes[disc_nodes_count].type = payload[0] & 0xF;
discovered_nodes[disc_nodes_count].snr_out = ((int8_t)payload[1]) / 4.0;
discovered_nodes[disc_nodes_count].snr_in = _radio->getLastSNR();
ContactInfo* c = lookupContactByPubKey(&payload[6], 8);
if (c != NULL) {
StrHelper::strncpy(discovered_nodes[disc_nodes_count].name, c->name,
sizeof(discovered_nodes[disc_nodes_count].name));
} else {
discovered_nodes[disc_nodes_count].name[0] = 0;
}
disc_nodes_count ++;
}
#endif
#if COMPANION_FEATURE_TEXT_TERMINAL
void MyMesh::onContactVisit(const ContactInfo& contact) {
if (contact.type == ADV_TYPE_NONE) return;
Stream& output = terminalOutput();
output.printf(" %s (%s) - ", contact.name, terminalContactTypeName(contact.type));
char relative_time[40];
int32_t seconds_from_now = contact.last_advert_timestamp - getRTCClock()->getCurrentTime();
AdvertTimeHelper::formatRelativeTimeDiff(relative_time, seconds_from_now, false);
output.println(relative_time);
}
#endif
void MyMesh::onContactPathUpdated(const ContactInfo &contact) {
out_frame[0] = PUSH_CODE_PATH_UPDATED;
memcpy(&out_frame[1], contact.id.pub_key, PUB_KEY_SIZE);
_serial->writeFrame(out_frame, 1 + PUB_KEY_SIZE); // NOTE: app may not be connected
scheduleContactWrite(contact);
#if COMPANION_FEATURE_TEXT_TERMINAL
if (hasTerminalOutput()) {
terminalOutput().printf("\r\nPATH updated -> %s\r\n> ", contact.name);
}
#endif
}
void MyMesh::clearExpectedAck(AckTableEntry& entry, bool cancel_retries) {
if (cancel_retries && entry.ack != 0 && !entry.confirmed) {
cancelActiveRetries(entry.retry_key);
}
memset(&entry, 0, sizeof(entry));
}
void MyMesh::expireExpectedAcks() {
unsigned long now = _ms->getMillis();
uint32_t nearest_delay = UINT32_MAX;
for (AckTableEntry* cursor = expected_ack_table;
cursor != expected_ack_table + EXPECTED_ACK_TABLE_SIZE; ++cursor) {
AckTableEntry& entry = *cursor;
if (entry.ack == 0) {
continue;
}
if (entry.reply_route != NULL
&& !_serial->isReplyRouteAvailable(entry.reply_route)) {
// The requesting transport owns only the eventual ACK notification,
// not the already-accepted radio transmission. Detach a vanished host
// without cancelling Mesh's retry sequence or forgetting the semantic
// ACK match; a late ACK can still stop those retries safely.
entry.reply_route = NULL;
}
// Use one 32-bit clock snapshot for this pass, including counter wrap.
const bool expired = (int32_t)((uint32_t)now - (uint32_t)entry.expires_at) >= 0;
if (entry.confirmed) {
// The radio delivery is complete. Retain only this captured transport's
// notification, with a fixed deadline independent of duplicate ACKs.
if (entry.reply_route == NULL || expired) {
clearExpectedAck(entry, false);
continue;
}
// Align both 32-bit fields while keeping the nine-byte wire format.
alignas(uint32_t) uint8_t storage[12];
uint8_t* confirmation = storage + 3;
confirmation[0] = PUSH_CODE_SEND_CONFIRMED;
memcpy(&confirmation[1], &entry.ack, 4);
uint32_t trip_time = entry.msg_sent;
memcpy(&confirmation[5], &trip_time, 4);
if (_serial->writeFrameToRoute(entry.reply_route, confirmation, 9) == 9) {
clearExpectedAck(entry, false);
continue;
}
} else if (expired) {
if (!hasActiveRetries(entry.retry_key)) {
#if COMPANION_FEATURE_TEXT_TERMINAL
if (entry.terminal_origin && hasTerminalOutput()) {
terminalOutput().print("\r\n ERROR: timed out, no ACK.\r\n> ");
}
#endif
clearExpectedAck(entry, false);
continue;
}
// Keep the semantic match alive while its lower-level retry sequence is
// active, so a newer app submission can replace that sequence cleanly.
entry.expires_at = (uint32_t)(now + EXPECTED_ACK_RETRY_RECHECK_MILLIS);
}
uint32_t delay = (uint32_t)(entry.expires_at - now);
if (entry.confirmed && delay > EXPECTED_ACK_HOST_RECHECK_MILLIS) {
delay = EXPECTED_ACK_HOST_RECHECK_MILLIS;
}
if (delay < nearest_delay) {
nearest_delay = delay;
}
}
has_next_ack_expiry = nearest_delay != UINT32_MAX;
next_ack_expiry = has_next_ack_expiry ? (uint32_t)(now + nearest_delay) : 0;
}
MyMesh::AckTableEntry* MyMesh::findPendingTextMessage(
const uint8_t text_fingerprint[MAX_HASH_SIZE], uint32_t message_timestamp) {
expireExpectedAcks();
for (AckTableEntry* cursor = expected_ack_table;
cursor != expected_ack_table + EXPECTED_ACK_TABLE_SIZE; ++cursor) {
AckTableEntry& entry = *cursor;
if (entry.ack != 0 && !entry.confirmed
&& entry.message_timestamp != message_timestamp
&& memcmp(entry.text_fingerprint, text_fingerprint, MAX_HASH_SIZE) == 0
&& hasActiveRetries(entry.retry_key)) {
return &entry;
}
}
return NULL;
}
void MyMesh::onContactReferenceChanged(const ContactInfo* previous, ContactInfo* replacement) {
for (AckTableEntry* entry = expected_ack_table;
entry != expected_ack_table + EXPECTED_ACK_TABLE_SIZE; ++entry) {
if (entry->ack != 0 && entry->contact == previous) entry->contact = replacement;
}
}
bool MyMesh::processAck(const uint8_t *data, ContactInfo*& peer) {
peer = NULL;
expireExpectedAcks();
uint32_t incoming;
memcpy(&incoming, data, sizeof(incoming));
// see if matches any in a table
for (AckTableEntry* cursor = expected_ack_table;
cursor != expected_ack_table + EXPECTED_ACK_TABLE_SIZE; ++cursor) {
AckTableEntry& entry = *cursor;
if (entry.ack != 0
&& incoming == entry.ack) { // got an ACK from recipient
// A duplicate must not cancel a newer chat timeout or learn another
// return path merely because its host notification is still queued.
if (entry.confirmed) continue;
ContactInfo* contact = entry.contact;
const unsigned long now = _ms->getMillis();
entry.msg_sent = (uint32_t)(now - entry.msg_sent);
entry.expires_at = (uint32_t)(now + EXPECTED_ACK_HOST_GRACE_MILLIS);
entry.confirmed = true;
cancelActiveRetries(entry.retry_key);
#if COMPANION_FEATURE_TEXT_TERMINAL
if (entry.terminal_origin && hasTerminalOutput()) {
terminalOutput().printf("\r\n Got ACK! (round trip: %lu ms)\r\n> ",
entry.msg_sent);
}
#endif
#if MESH_ENABLE_ONE_KEY_DM
if (contact != NULL && contact->type == ADV_TYPE_CHAT) rememberOneKeyAck(*contact);
#endif
expireExpectedAcks();
peer = contact;
return true;
}
}
return checkConnectionsAck(data, peer);
}
#if MESH_ENABLE_ONE_KEY_DM
bool MyMesh::hasOneKeyAck(const ContactInfo& contact) const {
for (uint8_t i = 0; i < one_key_peer_count; ++i) {
if (one_key_peers[i].status == 1 &&
memcmp(one_key_peers[i].pub_key, contact.id.pub_key, PUB_KEY_SIZE) == 0) return true;
}
return false;
}
bool MyMesh::hasOneKeyReject(const ContactInfo& contact) const {
for (uint8_t i = 0; i < one_key_peer_count; ++i) {
if (one_key_peers[i].status == 2 &&
memcmp(one_key_peers[i].pub_key, contact.id.pub_key, PUB_KEY_SIZE) == 0) return true;
}
return false;
}
void MyMesh::rememberOneKeyIntro(const ContactInfo& contact, uint32_t tag) {
for (uint8_t i = 0; i < one_key_peer_count; ++i) {
if (memcmp(one_key_peers[i].pub_key, contact.id.pub_key, PUB_KEY_SIZE) == 0) {
one_key_peers[i].intro_tag = tag;
one_key_peers[i].status = 0;
return;
}
}
OneKeyPeerState& peer = one_key_peers[one_key_peer_next];
memcpy(peer.pub_key, contact.id.pub_key, PUB_KEY_SIZE);
peer.intro_tag = tag;
peer.status = 0;
if (one_key_peer_count < ONE_KEY_PEERS) ++one_key_peer_count;
one_key_peer_next = (one_key_peer_next + 1) % ONE_KEY_PEERS;
}
void MyMesh::rememberOneKeyAck(const ContactInfo& contact) {
if (hasOneKeyAck(contact)) return;
for (uint8_t i = 0; i < one_key_peer_count; ++i) {
if (memcmp(one_key_peers[i].pub_key, contact.id.pub_key, PUB_KEY_SIZE) == 0) {
one_key_peers[i].status = 1;
return;
}
}
OneKeyPeerState& peer = one_key_peers[one_key_peer_next];
memcpy(peer.pub_key, contact.id.pub_key, PUB_KEY_SIZE);
peer.intro_tag = 0;
peer.status = 1;
if (one_key_peer_count < ONE_KEY_PEERS) ++one_key_peer_count;
one_key_peer_next = (one_key_peer_next + 1) % ONE_KEY_PEERS;
}
bool MyMesh::rememberOneKeyReject(const ContactInfo& contact, uint32_t tag) {
for (uint8_t i = 0; i < one_key_peer_count; ++i) {
OneKeyPeerState& peer = one_key_peers[i];
if (peer.status == 0 && peer.intro_tag == tag &&
memcmp(peer.pub_key, contact.id.pub_key, PUB_KEY_SIZE) == 0) {
peer.status = 2;
return true;
}
}
return false;
}
uint32_t MyMesh::sendOneKeyIntroduction(const ContactInfo& contact) {
// ANON_REQ carries the sender's full key in its outer header. It therefore
// reaches a recipient that cannot yet decrypt a normal text packet, while
// old Companion firmware simply ignores this unfamiliar request body.
uint8_t body[4 + sizeof(ONE_KEY_INTRO_MARKER) + 32 + SIGNATURE_SIZE];
const uint32_t tag = getRTCClock()->getCurrentTimeUnique();
memcpy(body, &tag, sizeof(tag));
memcpy(body + 4, ONE_KEY_INTRO_MARKER, sizeof(ONE_KEY_INTRO_MARKER));
char fallback_name[20];
snprintf(fallback_name, sizeof(fallback_name), "Unknown %02X%02X%02X",
self_id.pub_key[0], self_id.pub_key[1], self_id.pub_key[2]);
const char* name = _prefs.node_name[0] ? _prefs.node_name : fallback_name;
const size_t name_len = strnlen(name, 31);
memcpy(body + 4 + sizeof(ONE_KEY_INTRO_MARKER), name, name_len);
body[4 + sizeof(ONE_KEY_INTRO_MARKER) + name_len] = 0;
const size_t signed_body_len = 4 + sizeof(ONE_KEY_INTRO_MARKER) + name_len + 1;
uint8_t signed_message[PUB_KEY_SIZE + 4 + sizeof(ONE_KEY_INTRO_MARKER) + 32];
memcpy(signed_message, contact.id.pub_key, PUB_KEY_SIZE);
memcpy(signed_message + PUB_KEY_SIZE, body, signed_body_len);
self_id.sign(body + signed_body_len, signed_message,
PUB_KEY_SIZE + signed_body_len);
mesh::Packet* intro = createAnonDatagram(
PAYLOAD_TYPE_ANON_REQ, self_id, contact.id,
contact.getSharedSecret(self_id), body,
signed_body_len + SIGNATURE_SIZE);
if (intro == NULL) return 0;
const uint32_t airtime = getTransmitAirtime(intro);
uint32_t delay;
bool sent;
if (contact.out_path_len == OUT_PATH_UNKNOWN) {
delay = calcFloodTimeoutMillisFor(airtime) / 2;
sent = sendFloodScoped(contact, intro);
} else {
const uint32_t hops = (contact.out_path_len & 63) + 1;
delay = (airtime * 3 + 500) * hops;
sent = sendDirect(intro, contact.getPath(), contact.out_path_len);
}
// Scheduling the text after the introduction also lets the ordinary text
// path work on older recipients which already know this sender.
if (sent) rememberOneKeyIntro(contact, tag);
return sent ? delay : 0;
}
void MyMesh::onAnonDataRecv(mesh::Packet* packet, const uint8_t* secret,
const mesh::Identity& sender, uint8_t* data,
size_t len) {
if (packet->getPayloadType() != PAYLOAD_TYPE_ANON_REQ
|| len < 4 + sizeof(ONE_KEY_INTRO_MARKER) + 2 + SIGNATURE_SIZE
|| memcmp(data + 4, ONE_KEY_INTRO_MARKER,
sizeof(ONE_KEY_INTRO_MARKER)) != 0) return;
const char* name = reinterpret_cast<const char*>(data + 4 + sizeof(ONE_KEY_INTRO_MARKER));
const size_t available = min(size_t(32), len - 4 - sizeof(ONE_KEY_INTRO_MARKER) - SIGNATURE_SIZE);
const char* end = static_cast<const char*>(memchr(name, 0, available));
if (end == NULL || end == name || size_t(end - name) >= sizeof(ContactInfo::name)) return;
const size_t signed_body_len = 4 + sizeof(ONE_KEY_INTRO_MARKER) + size_t(end - name) + 1;
if (signed_body_len + SIGNATURE_SIZE > len) return;
uint8_t signed_message[PUB_KEY_SIZE + 4 + sizeof(ONE_KEY_INTRO_MARKER) + 32];
memcpy(signed_message, self_id.pub_key, PUB_KEY_SIZE);
memcpy(signed_message + PUB_KEY_SIZE, data, signed_body_len);
if (!sender.verify(data + signed_body_len, signed_message,
PUB_KEY_SIZE + signed_body_len)) return;
if (lookupPersistentContactByPubKey(sender.pub_key, PUB_KEY_SIZE) != NULL) return;
ContactInfo contact = {};
memcpy(contact.id.pub_key, sender.pub_key, PUB_KEY_SIZE);
StrHelper::strncpy(contact.name, name, sizeof(contact.name));
contact.type = ADV_TYPE_CHAT;
contact.out_path_len = OUT_PATH_UNKNOWN;
contact.lastmod = getRTCClock()->getCurrentTime();
if (_prefs.one_key_dm_enabled != 1) {
rememberVerifiedPendingSender(sender.pub_key);
// Synthetic advert for the attached app's normal manual-add flow. It is
// never added to the radio peer table until the user accepts it.
onDiscoveredContact(contact, true, packet->path_len, packet->path);
uint8_t refusal[4 + sizeof(ONE_KEY_REJECT_MARKER) + SIGNATURE_SIZE];
memcpy(refusal, data, 4);
memcpy(refusal + 4, ONE_KEY_REJECT_MARKER, sizeof(ONE_KEY_REJECT_MARKER));
uint8_t signed_refusal[PUB_KEY_SIZE + 4 + sizeof(ONE_KEY_REJECT_MARKER)];
memcpy(signed_refusal, sender.pub_key, PUB_KEY_SIZE);
memcpy(signed_refusal + PUB_KEY_SIZE, refusal, 4 + sizeof(ONE_KEY_REJECT_MARKER));
self_id.sign(refusal + 4 + sizeof(ONE_KEY_REJECT_MARKER),
signed_refusal, sizeof(signed_refusal));
mesh::Packet* reply = createDatagram(PAYLOAD_TYPE_RESPONSE, sender, secret,
refusal, sizeof(refusal));
if (reply != NULL) sendFloodScoped(contact, reply, 300);
return;
}
if (!canMutateContacts()) return;
if (!addContact(contact)) {
onContactsFull();
return;
}
ContactInfo* added = lookupPersistentContactByPubKey(sender.pub_key, PUB_KEY_SIZE);
if (added == NULL || !scheduleContactWrite(*added)) {
if (added != NULL) removeContact(*added);
return;
}
// A prior anonymous service request may occupy a transient slot for this
// key. Remove it so regular text lookup finds the durable contact first.
ContactInfo* transient = lookupTransientContactByPubKey(sender.pub_key, PUB_KEY_SIZE);
if (transient != NULL) clearTransientContact(*transient, added);
onDiscoveredContact(*added, true, OUT_PATH_UNKNOWN, NULL);
}
void MyMesh::rememberVerifiedPendingSender(const uint8_t* pub_key) {
for (uint8_t i = 0; i < verified_pending_count; ++i) {
if (memcmp(verified_pending_keys[i], pub_key, PUB_KEY_SIZE) == 0) return;
}
if (verified_pending_count == MAX_HELD_ONE_KEY_DMS) {
for (uint8_t i = 1; i < verified_pending_count; ++i) {
memcpy(verified_pending_keys[i - 1], verified_pending_keys[i], PUB_KEY_SIZE);
}
--verified_pending_count;
}
memcpy(verified_pending_keys[verified_pending_count++], pub_key, PUB_KEY_SIZE);
}
void MyMesh::forgetVerifiedPendingSender(const uint8_t* pub_key) {
for (uint8_t i = 0; i < verified_pending_count; ++i) {
if (memcmp(verified_pending_keys[i], pub_key, PUB_KEY_SIZE) == 0) {
for (uint8_t j = i + 1; j < verified_pending_count; ++j) {
memcpy(verified_pending_keys[j - 1], verified_pending_keys[j], PUB_KEY_SIZE);
}
--verified_pending_count;
return;
}
}
}
void MyMesh::makeOneKeyDMId(uint8_t id[ONE_KEY_DM_ID_SIZE], uint32_t timestamp,
const char* text) {
mesh::Utils::sha256(id, ONE_KEY_DM_ID_SIZE,
reinterpret_cast<const uint8_t*>(&timestamp), sizeof(timestamp),
reinterpret_cast<const uint8_t*>(text), strlen(text));
}
bool MyMesh::wasDeliveredOneKeyDM(const uint8_t* pub_key,
const uint8_t id[ONE_KEY_DM_ID_SIZE]) const {
for (uint8_t i = 0; i < delivered_dm_count; ++i) {
if (memcmp(delivered_dms[i].sender_key, pub_key, PUB_KEY_SIZE) == 0 &&
memcmp(delivered_dms[i].id, id, ONE_KEY_DM_ID_SIZE) == 0) return true;
}
return false;
}
void MyMesh::rememberDeliveredOneKeyDM(const uint8_t* pub_key,
const uint8_t id[ONE_KEY_DM_ID_SIZE]) {
if (wasDeliveredOneKeyDM(pub_key, id)) return;
DeliveredOneKeyDM& entry = delivered_dms[delivered_dm_next];
memcpy(entry.sender_key, pub_key, PUB_KEY_SIZE);
memcpy(entry.id, id, ONE_KEY_DM_ID_SIZE);
if (delivered_dm_count < MAX_HELD_ONE_KEY_DMS) ++delivered_dm_count;
delivered_dm_next = (delivered_dm_next + 1) % MAX_HELD_ONE_KEY_DMS;
}
bool MyMesh::onAddressedTextPacket(mesh::Packet* packet, uint8_t src_hash,
const uint8_t* mac_and_data, size_t len) {
for (uint8_t i = 0; i < verified_pending_count; ++i) {
const uint8_t* pub_key = verified_pending_keys[i];
if (pub_key[0] != src_hash) continue;
if (lookupPersistentContactByPubKey(pub_key, PUB_KEY_SIZE) != NULL) continue;
uint8_t secret[PUB_KEY_SIZE];
self_id.calcSharedSecret(secret, pub_key);
uint8_t data[MAX_PACKET_PAYLOAD + 1];
const int decoded_len = mesh::Utils::MACThenDecrypt(secret, data, mac_and_data, len);
if (decoded_len <= 5 || decoded_len > MAX_PACKET_PAYLOAD) continue;
data[decoded_len] = 0;
if ((data[4] >> 2) != TXT_TYPE_PLAIN) continue;
const char* text = reinterpret_cast<const char*>(&data[5]);
if (strlen(text) > MAX_TEXT_LEN) continue;
uint32_t timestamp;
memcpy(&timestamp, data, sizeof(timestamp));
uint8_t id[ONE_KEY_DM_ID_SIZE];
makeOneKeyDMId(id, timestamp, text);
for (uint8_t j = 0; j < held_dm_count; ++j) {
#if ONE_KEY_DM_COMPACT_HELD
uint8_t held_id[ONE_KEY_DM_ID_SIZE];
makeOneKeyDMId(held_id, held_dms[j].timestamp, held_dms[j].text);
#else
const uint8_t* held_id = held_dms[j].id;
#endif
if (memcmp(held_dms[j].sender_key, pub_key, PUB_KEY_SIZE) == 0 &&
memcmp(held_id, id, ONE_KEY_DM_ID_SIZE) == 0) return true;
}
// Only verified and decryptable messages count toward the FIFO of 15.
#if ONE_KEY_DM_SHARED_OFFLINE_QUEUE
if (held_dm_count == MAX_HELD_ONE_KEY_DMS) removeHeldOneKeyDM(0);
if (offline_queue_len + held_dm_count >= getOfflineQueueCapacity()) {
// Preserve private messages already waiting for the app. As with the
// ordinary queue, an old channel message may be displaced instead.
int pos = 0;
while (pos < offline_queue_len && !offlineQueueFrameAt(pos).isChannelMsg()) ++pos;
if (pos == offline_queue_len) return false;
for (int j = pos; j < offline_queue_len - 1; ++j) {
offlineQueueFrameAt(j) = offlineQueueFrameAt(j + 1);
}
--offline_queue_len;
#ifdef DISPLAY_CLASS
if (_ui) _ui->syncMessageQueue(offline_queue_len, pos);
#endif
}
HeldOneKeyDM& entry = held_dms[held_dm_count];
memcpy(entry.sender_key, pub_key, PUB_KEY_SIZE);
memcpy(entry.id, id, ONE_KEY_DM_ID_SIZE);
Frame& held = heldDMFrameAt(held_dm_count++);
memcpy(held.buf, &timestamp, sizeof(timestamp));
mesh::Utils::sha256(&held.buf[4], 4, data, 5 + strlen(text),
pub_key, PUB_KEY_SIZE);
held.buf[8] = packet->isRouteFlood() ? packet->path_len : 0xFF;
held.buf[9] = packet->_snr;
const uint8_t text_len = strlen(text);
held.buf[10] = text_len;
memcpy(&held.buf[11], text, text_len + 1);
held.len = 12 + text_len;
#else
if (held_dm_count == MAX_HELD_ONE_KEY_DMS) {
for (uint8_t j = 1; j < held_dm_count; ++j) held_dms[j - 1] = held_dms[j];
--held_dm_count;
}
HeldOneKeyDM& entry = held_dms[held_dm_count++];
memcpy(entry.sender_key, pub_key, PUB_KEY_SIZE);
#if ONE_KEY_DM_COMPACT_HELD
entry.timestamp = timestamp;
// Preserve the exact ACK of the authenticated original plaintext,
// including its attempt/type byte; the delivery ID is derived separately.
mesh::Utils::sha256(entry.ack_hash, sizeof(entry.ack_hash), data,
5 + strlen(text), pub_key, PUB_KEY_SIZE);
entry.path_len = packet->isRouteFlood() ? packet->path_len : 0xFF;
entry.snr = packet->_snr;
memcpy(entry.text, text, strlen(text) + 1);
#else
memcpy(entry.id, id, ONE_KEY_DM_ID_SIZE);
entry.packet = *packet;
#endif
#endif
return true;
}
return false;
}
void MyMesh::releaseHeldOneKeyDMs() {
for (uint8_t i = 0; i < verified_pending_count; ) {
if (lookupPersistentContactByPubKey(verified_pending_keys[i], PUB_KEY_SIZE) != NULL) {
uint8_t key[PUB_KEY_SIZE];
memcpy(key, verified_pending_keys[i], PUB_KEY_SIZE);
forgetVerifiedPendingSender(key);
} else {
++i;
}
}
for (uint8_t i = 0; i < held_dm_count; ) {
HeldOneKeyDM& entry = held_dms[i];
ContactInfo* contact = lookupPersistentContactByPubKey(entry.sender_key, PUB_KEY_SIZE);
if (contact == NULL) { ++i; continue; }
#if ONE_KEY_DM_SHARED_OFFLINE_QUEUE
// The held frame itself occupies the slot needed for ordinary delivery.
// Copy its small payload before removing it from the tail of the pool.
Frame& held = heldDMFrameAt(i);
uint32_t timestamp;
memcpy(&timestamp, held.buf, sizeof(timestamp));
uint8_t ack_hash[4];
memcpy(ack_hash, &held.buf[4], sizeof(ack_hash));
uint8_t path_len = held.buf[8];
int8_t snr = static_cast<int8_t>(held.buf[9]);
uint8_t text_len = held.buf[10];
char text[MAX_TEXT_LEN + 1];
if (held.len != 12 + text_len || text_len > MAX_TEXT_LEN ||
held.buf[11 + text_len] != 0) {
removeHeldOneKeyDM(i);
continue;
}
memcpy(text, &held.buf[11], text_len + 1);
uint8_t sender_key[PUB_KEY_SIZE];
uint8_t id[ONE_KEY_DM_ID_SIZE];
memcpy(sender_key, entry.sender_key, sizeof(sender_key));
memcpy(id, entry.id, sizeof(id));
removeHeldOneKeyDM(i);
if (!wasDeliveredOneKeyDM(sender_key, id)) {
mesh::Packet packet;
packet.header = path_len == 0xFF ? ROUTE_TYPE_DIRECT : ROUTE_TYPE_FLOOD;
packet.path_len = path_len == 0xFF ? 0 : path_len;
packet._snr = snr;
onMessageRecv(*contact, &packet, timestamp, text);
rememberDeliveredOneKeyDM(sender_key, id);
}
sendAckTo(*contact, ack_hash, sizeof(ack_hash));
#else
if (offline_queue_len >= getOfflineQueueCapacity()) break;
#if ONE_KEY_DM_COMPACT_HELD
uint8_t id[ONE_KEY_DM_ID_SIZE];
makeOneKeyDMId(id, entry.timestamp, entry.text);
if (!wasDeliveredOneKeyDM(entry.sender_key, id)) {
mesh::Packet packet;
packet.header = entry.path_len == 0xFF ? ROUTE_TYPE_DIRECT : ROUTE_TYPE_FLOOD;
packet.path_len = entry.path_len == 0xFF ? 0 : entry.path_len;
packet._snr = entry.snr;
onMessageRecv(*contact, &packet, entry.timestamp, entry.text);
rememberDeliveredOneKeyDM(entry.sender_key, id);
}
sendAckTo(*contact, entry.ack_hash, sizeof(entry.ack_hash));
#else
uint8_t data[MAX_PACKET_PAYLOAD + 1];
const int decoded_len = mesh::Utils::MACThenDecrypt(
contact->getSharedSecret(self_id), data, &entry.packet.payload[2],
entry.packet.payload_len - 2);
if (decoded_len > 5 && decoded_len <= MAX_PACKET_PAYLOAD) {
data[decoded_len] = 0;
if ((data[4] >> 2) == TXT_TYPE_PLAIN) {
const char* text = reinterpret_cast<const char*>(&data[5]);
uint32_t timestamp;
memcpy(&timestamp, data, sizeof(timestamp));
if (strlen(text) <= MAX_TEXT_LEN) {
if (!wasDeliveredOneKeyDM(entry.sender_key, entry.id)) {
onMessageRecv(*contact, &entry.packet, timestamp, text);
rememberDeliveredOneKeyDM(entry.sender_key, entry.id);
}
uint8_t ack_hash[4];
mesh::Utils::sha256(ack_hash, sizeof(ack_hash), data, 5 + strlen(text),
contact->id.pub_key, PUB_KEY_SIZE);
sendAckTo(*contact, ack_hash, sizeof(ack_hash));
}
}
}
#endif
for (uint8_t j = i + 1; j < held_dm_count; ++j) held_dms[j - 1] = held_dms[j];
--held_dm_count;
#endif
}
}
#endif // MESH_ENABLE_ONE_KEY_DM
bool MyMesh::queueMessage(const ContactInfo &from, uint8_t txt_type, mesh::Packet *pkt,
uint32_t sender_timestamp, const uint8_t *extra,
int extra_len, const char *text,
bool terminal_command_reply,
uint32_t terminal_command_elapsed_millis) {
int i = 0;
if (app_target_ver >= 3) {
out_frame[i++] = RESP_CODE_CONTACT_MSG_RECV_V3;
out_frame[i++] = (int8_t)(pkt->getSNR() * 4);
out_frame[i++] = 0; // reserved1
out_frame[i++] = 0; // reserved2
} else {
out_frame[i++] = RESP_CODE_CONTACT_MSG_RECV;
}
memcpy(&out_frame[i], from.id.pub_key, 6);
i += 6; // just 6-byte prefix
uint8_t path_len = out_frame[i++] = pkt->isRouteFlood() ? pkt->path_len : 0xFF;
out_frame[i++] = txt_type;
memcpy(&out_frame[i], &sender_timestamp, 4);
i += 4;
if (extra_len > 0) {
memcpy(&out_frame[i], extra, extra_len);
i += extra_len;
}
int tlen = strlen(text); // TODO: UTF-8 ??
if (i + tlen > MAX_FRAME_SIZE) {
tlen = MAX_FRAME_SIZE - i;
}
memcpy(&out_frame[i], text, tlen);
i += tlen;
const char* sensitive_reply = text;
if (isxdigit((unsigned char)text[0]) && isxdigit((unsigned char)text[1])
&& text[2] == '|') sensitive_reply += 3;
if (txt_type == TXT_TYPE_CLI_DATA
&& strncmp(sensitive_reply, "KEYBACKUP ", 10) == 0) {
// Identity exports must never enter the normal offline message queue or
// text terminal (both retain/echo plaintext). Deliver once to a live
// directly attached app; if that link is gone the requester can retry.
const bool expected = strlen(sensitive_reply) == 10 + 16 + 1 + PRV_KEY_SIZE * 2
&& private_key_backup_route != NULL
&& _serial->isReplyRouteAvailable(private_key_backup_route)
&& !millisHasNowPassed(private_key_backup_deadline)
&& memcmp(from.id.pub_key, private_key_backup_sender, 6) == 0
&& strncmp(sensitive_reply + 10, private_key_backup_nonce, 16) == 0
&& sensitive_reply[26] == ' ';
if (expected) {
_serial->writeFrameToRoute(private_key_backup_route, out_frame, i);
}
private_key_backup_route = NULL;
private_key_backup_deadline = 0;
memset(private_key_backup_nonce, 0, sizeof(private_key_backup_nonce));
memset(private_key_backup_sender, 0, sizeof(private_key_backup_sender));
memset(out_frame, 0, i);
return false;
}
const bool queued = addToOfflineQueue(out_frame, i);
// Signed room posts are retried by the server until retained. Do not make
// an unretained post visible as delivered or notify the user about it.
if (txt_type == TXT_TYPE_SIGNED_PLAIN && !queued) return false;
if (_serial->isConnected()) {
uint8_t frame[1];
frame[0] = PUSH_CODE_MSG_WAITING; // send push 'tickle'
_serial->writeFrame(frame, 1);
}
#if COMPANION_FEATURE_TEXT_TERMINAL
if (hasTerminalOutput()) {
Stream& output = terminalOutput();
const char* kind = txt_type == TXT_TYPE_CLI_DATA ? "CLI" : "MSG";
output.printf("\r\n(%s) %s -> from %s",
mesh::cli::terminalInboundRouteLabel(pkt->isRouteDirect()),
kind, from.name);
if (terminal_command_reply) {
output.printf(" (round trip %lu ms)",
(unsigned long)terminal_command_elapsed_millis);
}
output.printf("\r\n %s\r\n> ", text);
}
#endif
#if COMPANION_FEATURE_NOTIFICATIONS
const bool plain_notification = txt_type == TXT_TYPE_PLAIN || txt_type == TXT_TYPE_SIGNED_PLAIN;
const bool remote_notification = plain_notification
&& _notifications.remoteMessage(from.id.pub_key, text, millis(), sender_timestamp);
const bool notification_managed = remote_notification || (plain_notification
&& _notifications.message(from.id.pub_key, nullptr, _serial->isConnected(), millis()));
if (_ui) _ui->setMessageNotificationOverride(notification_managed, _notifications.overridesScreen());
#else
const bool notification_managed = false;
#endif
#ifdef DISPLAY_CLASS
// we only want to show text messages on display, not cli data
bool should_display = txt_type == TXT_TYPE_PLAIN || txt_type == TXT_TYPE_SIGNED_PLAIN;
if (should_display && _ui) {
_ui->newMsg(path_len, from.name, text, offline_queue_len,
-1, nullptr, queued ? offline_queue_len - 1 : -1);
if (!notification_managed && !_serial->isConnected()) {
_ui->notify(UIEventType::contactMessage);
}
}
#endif
if (_ui) _ui->setMessageNotificationOverride(false);
return queued;
}
static uint16_t emergencyClientRepeatKey(const mesh::Packet* packet) {
const uint8_t* p = packet->payload;
return ((uint16_t)p[1]) | ((uint16_t)p[2] << 8);
}
static bool __attribute__((noinline)) hasEmergencyClientRepeat(uint16_t key) {
for (uint8_t i = 0; i < EMERGENCY_CLIENT_REPEAT_TABLE_SIZE; i++) {
if (emergency_client_repeats[i] == key) {
return true;
}
}
return false;
}
bool MyMesh::filterRecvFloodPacket(mesh::Packet* packet) {
if (emergency_client_repeat_packet != NULL && packet->getPathHashCount() > 0
&& packet->radio_origin == emergency_client_repeat_packet->radio_origin) {
if (emergencyClientRepeatKey(packet) == emergency_client_repeat_key) {
releasePacket(emergency_client_repeat_packet);
emergency_client_repeat_packet = NULL;
}
}
// REVISIT: try to determine which Region (from transport_codes[1]) that Sender is indicating for replies/responses
// if unknown, fallback to finding Region from transport_codes[0], the 'scope' used by Sender
return false;
}
bool MyMesh::allowPacketForward(const mesh::Packet* packet) {
if (!_prefs.isRepeatEn()) return false;
#ifdef COMPANION_MESH_CLOCK_SYNC
_clock_sync.observeAcceptedFlood(packet);
#endif
return true;
}
bool MyMesh::isAliasRadio2Defined() const {
const auto profiles = _radio->profiles();
return profiles != nullptr && profiles->enabled();
}
bool MyMesh::resolveChannelAlias(ChannelDetails& channel) const {
uint8_t original_key[16];
memcpy(original_key, channel.channel.secret, sizeof(original_key));
if (!resolveCompanionChannelKey(channel.name, channel.channel.secret,
[](uint8_t* out, size_t size, const uint8_t* text, int length) {
mesh::Utils::sha256(out, size, text, length);
})) return false;
const auto kind = parseCompanionChannelAlias(channel.name).kind;
#if MESH_ENABLE_MESHTEXT_CHAT
if (isMeshtextChannel(channel)) channel.channel.tx_radio = mesh::RADIO_TX_OFF;
#endif
if (isAliasRadio2Defined()
&& (kind == CompanionChannelKind::Radio2 || kind == CompanionChannelKind::Meshtext)) {
channel.channel.tx_radio = kind == CompanionChannelKind::Radio2
? mesh::RADIO_TX_SECONDARY : mesh::RADIO_TX_OFF;
}
if (memcmp(original_key, channel.channel.secret, sizeof(original_key)) != 0) {
// BaseChatMesh already cached the hash for ordinary/private keys. Only a
// rewritten automatic alias needs another SHA-256 during receive scans.
mesh::Utils::sha256(channel.channel.hash, sizeof(channel.channel.hash), channel.channel.secret, 16);
}
return true;
}
bool MyMesh::getChannel(int channel_idx, ChannelDetails& channel) {
return BaseChatMesh::getChannel(channel_idx, channel) && resolveChannelAlias(channel);
}
bool MyMesh::setChannel(int channel_idx, const ChannelDetails& channel) {
if (parseCompanionChannelAlias(channel.name).kind == CompanionChannelKind::Invalid) return false;
#if MESH_ENABLE_ETHERNET_TUNNEL
if (_ethernet_settings.enabled && channel.name[0]
&& parseCompanionChannelAlias(channel.name).kind != CompanionChannelKind::MeshCore) return false;
#endif
const bool saved = BaseChatMesh::setChannel(channel_idx, channel);
#if MESH_ENABLE_MESHTEXT_CHAT
if (saved) _mt_delivery_config_dirty = true;
#endif
#if MESH_ENABLE_ETHERNET_TUNNEL
if (saved) ++_ethernet_channel_generation;
#endif
return saved;
}
int MyMesh::searchChannelsForPacket(const mesh::Packet* packet, const uint8_t* hash,
mesh::GroupChannel matches[], int max_matches) {
int count = 0;
const bool radio2_defined = isAliasRadio2Defined();
for (int priority = 0; priority < 2; ++priority) {
for (int i = 0; i < MAX_GROUP_CHANNELS && count < max_matches; ++i) {
ChannelDetails channel;
if (!getChannel(i, channel) || channel.name[0] == 0
#if MESH_ENABLE_MESHTEXT_CHAT
|| isMeshtextChannel(channel)
#endif
|| companionChannelReceivePriority(channel.name, packet->radio_origin, radio2_defined) != priority
|| channel.channel.hash[0] != hash[0]) continue;
bool duplicate = false;
for (int j = 0; j < count; ++j) {
if (memcmp(matches[j].secret, channel.channel.secret, sizeof(channel.channel.secret)) == 0) {
duplicate = true;
break;
}
}
if (!duplicate) matches[count++] = channel.channel;
}
}
return count;
}
int MyMesh::findChannelIdxForPacket(const mesh::GroupChannel& key, const mesh::Packet* packet) {
const bool radio2_defined = isAliasRadio2Defined();
for (int priority = 0; priority < 2; ++priority) {
for (int i = 0; i < MAX_GROUP_CHANNELS; ++i) {
ChannelDetails channel;
if (getChannel(i, channel) && channel.name[0] != 0
#if MESH_ENABLE_MESHTEXT_CHAT
&& !isMeshtextChannel(channel)
#endif
&& companionChannelReceivePriority(channel.name, packet->radio_origin, radio2_defined) == priority
&& memcmp(channel.channel.secret, key.secret, sizeof(key.secret)) == 0) return i;
}
}
return -1;
}
bool MyMesh::allowGroupPacketOnRadio(mesh::Packet* packet) {
if (!isAliasRadio2Defined() || packet->payload_len < 1) return true;
if (packet->payload_len > MAX_PACKET_PAYLOAD) return false;
// Reject only an authenticated key match. The one-byte channel hash alone
// must not suppress unrelated channels or opaque relaying.
for (int i = 0; i < MAX_GROUP_CHANNELS; ++i) {
ChannelDetails channel;
if (!getChannel(i, channel) || channel.name[0] == 0
|| channel.channel.hash[0] != packet->payload[0]) continue;
const auto alias = parseCompanionChannelAlias(channel.name);
if (alias.kind != CompanionChannelKind::Radio2
&& alias.kind != CompanionChannelKind::Meshtext) continue;
uint8_t data[MAX_PACKET_PAYLOAD];
if (mesh::Utils::MACThenDecrypt(channel.channel.secret, data, packet->payload + 1,
packet->payload_len - 1) <= 0) continue;
const int selected = findChannelIdxForPacket(channel.channel, packet);
if (selected < 0) return false;
ChannelDetails selected_channel;
if (getChannel(selected, selected_channel)
&& parseCompanionChannelAlias(selected_channel.name).kind == CompanionChannelKind::Radio2) {
// Also bind direct-path relaying, which runs before local delivery.
packet->tx_radio = mesh::RADIO_TX_SECONDARY;
}
return true;
}
return true;
}
void MyMesh::onGroupDataRecv(mesh::Packet* packet, uint8_t type,
const mesh::GroupChannel& key, uint8_t* data, size_t len) {
const int index = findChannelIdxForPacket(key, packet);
if (index < 0) return;
ChannelDetails channel;
if (getChannel(index, channel) && isAliasRadio2Defined()
&& parseCompanionChannelAlias(channel.name).kind == CompanionChannelKind::Radio2) {
// Forwarding and the emergency hold copy stay on the originating profile.
packet->tx_radio = mesh::RADIO_TX_SECONDARY;
}
BaseChatMesh::onGroupDataRecv(packet, type, key, data, len);
}
#if defined(COMPANION_MESH_CLOCK_SYNC) || defined(MESHCORE_HAS_COLOR_DISPLAY)
void MyMesh::onAdvertRecv(mesh::Packet* packet, const mesh::Identity& id,
uint32_t timestamp, const uint8_t* app_data,
size_t app_data_len) {
#if defined(MESHCORE_HAS_COLOR_DISPLAY)
if (!isLocalContactImport(packet)) {
mesh::ui::observeDisplayNightAdvert(packet, id, self_id, app_data,
app_data_len, millis());
}
#endif
BaseChatMesh::onAdvertRecv(packet, id, timestamp, app_data, app_data_len);
#ifdef COMPANION_MESH_CLOCK_SYNC
_clock_sync.observeVerifiedAdvert(packet, id, timestamp);
#endif
}
#endif
#ifdef COMPANION_MESH_CLOCK_SYNC
void MyMesh::onGroupPacketRecv(mesh::Packet* packet) {
_clock_sync.observeGroupPacket(packet);
}
#endif
bool MyMesh::allowFloodRetry(const mesh::Packet* packet) const {
if (_morse_reply.ownsReply(packet)) return false;
if (packet == NULL || _prefs.flood_retry_attempts == 0) return false;
// A companion may retry its own advert once, using the core's deliberately
// slow origin-advert delay. Do not add retries while relaying a neighbour's
// advert; the ordinary forwarding and recent-echo guard still apply.
return packet->getPayloadType() != PAYLOAD_TYPE_ADVERT
|| (_prefs.flood_retry_advert_enabled && isSelfOriginAdvert(packet));
}
uint8_t MyMesh::getFloodRetryMaxPathLength(const mesh::Packet* packet) const {
return applyGroupDataFloodRetryPathGate(
packet, _prefs.flood_retry_max_path,
_prefs.flood_retry_group_max_path);
}
uint8_t MyMesh::getFloodRetryMaxAttempts(const mesh::Packet* packet) const {
(void)packet;
return _prefs.flood_retry_attempts;
}
bool MyMesh::sendFloodScoped(const TransportKey& scope, mesh::Packet* pkt, uint32_t delay_millis) {
if (scope.isNull()) {
return sendFlood(pkt, delay_millis, _prefs.path_hash_mode + 1);
} else {
uint16_t codes[2];
codes[0] = scope.calcTransportCode(pkt);
codes[1] = 0; // REVISIT: set to 'home' Region, for sender/return region?
return sendFlood(pkt, codes, delay_millis, _prefs.path_hash_mode + 1);
}
}
bool MyMesh::sendFloodScoped(const ContactInfo& recipient, mesh::Packet* pkt, uint32_t delay_millis) {
// TODO: dynamic send_scope, depending on recipient and current 'home' Region
if (send_unscoped) {
return sendFlood(pkt, delay_millis, _prefs.path_hash_mode + 1); // app explicitly requested un-scoped
} else {
TransportKey default_scope;
memcpy(&default_scope.key, _prefs.default_scope_key, sizeof(default_scope.key));
auto scope = send_scope.isNull() ? &default_scope : &send_scope;
return sendFloodScoped(*scope, pkt, delay_millis);
}
}
bool MyMesh::sendFloodScoped(const mesh::GroupChannel& channel, mesh::Packet* pkt, uint32_t delay_millis) {
// TODO: have per-channel send_scope
if (send_unscoped) {
return sendFlood(pkt, delay_millis, _prefs.path_hash_mode + 1); // app explicitly requested un-scoped
} else {
TransportKey default_scope;
memcpy(&default_scope.key, _prefs.default_scope_key, sizeof(default_scope.key));
auto scope = send_scope.isNull() ? &default_scope : &send_scope;
return sendFloodScoped(*scope, pkt, delay_millis);
}
}
void MyMesh::onRadioProfileCopyQueued(mesh::Packet* packet, const mesh::Packet* original, uint8_t priority) {
mesh::Mesh::onRadioProfileCopyQueued(packet, original, priority);
_primary_radio_reply.trackCopy(original, packet);
_morse_reply.copy(original, packet);
#if MESH_ENABLE_FLEET_CONTROL
if (original && original == _fleet_admission_original) _fleet_admission_copy = packet;
#endif
}
void MyMesh::onSendComplete(mesh::Packet* packet) {
mesh::Mesh::onSendComplete(packet);
if (_primary_radio_reply.complete(packet) && !_primary_radio_reply.waiting())
finishPrimaryRadioReply(true);
// Use the completed packet's idle gap before a busy queue starts another TX.
servicePendingLocalRadioSettings();
_morse_reply.complete(packet);
}
void MyMesh::onSendFail(mesh::Packet* packet) {
mesh::Mesh::onSendFail(packet);
if (_primary_radio_reply.fail(packet)) finishPrimaryRadioReply(false);
else if (!_primary_radio_reply.waiting() && _primary_radio_reply.succeeded())
finishPrimaryRadioReply(true);
_morse_reply.fail(packet);
}
bool MyMesh::allowDirectRetry(const mesh::Packet* packet, const uint8_t* next_hop_hash,
uint8_t next_hop_hash_len) const {
return !_morse_reply.ownsReply(packet)
&& !_primary_radio_reply.contains(packet)
&& mesh::Mesh::allowDirectRetry(packet, next_hop_hash, next_hop_hash_len);
}
#if MESH_ENABLE_FLEET_CONTROL
bool MyMesh::sendFleetCommandData(mesh::GroupChannel& channel, const uint8_t* envelope, size_t length) {
if (!envelope || !length || length > mesh::FleetCommand::MaxEnvelopeLength) return false;
if (length <= mesh::FleetCommand::MaxPayloadLength) {
return sendGroupData(channel, nullptr, OUT_PATH_UNKNOWN,
mesh::FleetCommand::DataType, envelope, (int)length);
}
mesh::Packet* reserved[2] = {};
uint8_t group[3 + mesh::FleetCommand::MaxPayloadLength];
group[0] = (uint8_t)mesh::FleetCommand::DataType;
group[1] = (uint8_t)(mesh::FleetCommand::DataType >> 8);
// Reserve both encrypted packets before handing either to the dispatcher.
for (uint8_t part = 0; part < 2; ++part) {
const size_t bytes = mesh::FleetCommand::fragment(envelope, length, part,
group + 3, sizeof(group) - 3);
if (bytes) {
group[2] = (uint8_t)bytes;
reserved[part] = createGroupDatagram(PAYLOAD_TYPE_GRP_DATA, channel, group, 3 + bytes);
}
if (!reserved[part]) {
if (reserved[0]) releasePacket(reserved[0]);
memset(group, 0, sizeof(group));
return false;
}
}
memset(group, 0, sizeof(group));
_fleet_admission_original = reserved[0];
_fleet_admission_copy = nullptr;
const bool first_queued = sendFloodScoped(channel, reserved[0]);
mesh::Packet* first_copy = _fleet_admission_copy;
_fleet_admission_original = nullptr;
_fleet_admission_copy = nullptr;
if (!first_queued) {
// sendFloodScoped/sendPacket owns and releases the failed first packet.
releasePacket(reserved[1]);
return false;
}
if (sendFloodScoped(channel, reserved[1])) return true;
// A failed second send has already been released. No dispatcher loop runs
// inside admission, so the first packet and its optional copy are still
// queued and can be retired by exact pointer without touching other work.
for (int i = _mgr->getOutboundTotal() - 1; i >= 0; --i) {
const mesh::Packet* queued = _mgr->getOutboundByIdx(i);
if (queued != reserved[0] && queued != first_copy) continue;
mesh::Packet* cancelled = _mgr->removeOutboundByIdx(i);
cancelOutboundRadioRetry(cancelled);
onSendFail(cancelled); // Clear its pending flood retry metadata too.
releasePacket(cancelled);
}
return false;
}
#endif
#if MESH_ENABLE_LOST_REPLY
void MyMesh::maybeReplyToLostQuestion(const ContactInfo& from,
uint32_t sender_timestamp, const char* text) {
if ((_prefs.lost_reply != mesh::companion::LostReplyNo
&& _prefs.lost_reply != mesh::companion::LostReplyYes)
|| from.type != ADV_TYPE_CHAT || isTransientContact(from)
|| !mesh::companion::isLostReplyQuestion(text)) return;
// Contact cache references are borrowed. Preserve identity before servicing
// other sends, then acquire the recipient only when ready to compose it.
uint8_t recipient_key[PUB_KEY_SIZE];
memcpy(recipient_key, from.id.pub_key, sizeof(recipient_key));
const uint32_t now = _ms->getMillis();
if (!lost_reply_limiter.canReply(recipient_key, sender_timestamp, now)) return;
expireExpectedAcks();
AckTableEntry* available = NULL;
for (AckTableEntry& entry : expected_ack_table) {
if (entry.ack == 0) { available = &entry; break; }
}
// Automatic answers must leave ordinary sends and their ACK/RX reserve
// intact. A busy application can retry its question after capacity returns.
if (available == NULL || _mgr->getFreeCount() <= 2) return;
ContactInfo* recipient = lookupPersistentContactByPubKey(recipient_key, PUB_KEY_SIZE);
if (recipient == NULL || recipient->type != ADV_TYPE_CHAT) return;
const char* answer = _prefs.lost_reply == mesh::companion::LostReplyYes ? "Yes" : "No";
const uint32_t timestamp = getRTCClock()->getCurrentTimeUnique();
uint32_t expected_ack = 0, timeout = 0;
// The final argument receives the actual packet hash for ACK cancellation. No
// semantic message retry key is supplied, so user sends are not replaced.
uint8_t retry_key[MAX_HASH_SIZE] = {};
const int result = sendMessageDetached(*recipient, timestamp, answer,
expected_ack, timeout, retry_key);
// Automatic ACKs use the existing per-entry expiry service. Do not move an
// application or terminal send's shared BaseChatMesh timeout deadline.
if (result == MSG_SEND_FAILED) return;
lost_reply_limiter.rememberReply(recipient_key, sender_timestamp, now);
if (expected_ack != 0) {
clearExpectedAck(*available, false);
available->msg_sent = now;
available->expires_at = futureMillis(timeout);
available->ack = expected_ack;
available->message_timestamp = timestamp;
available->contact = recipient;
memcpy(available->retry_key, retry_key, sizeof(available->retry_key));
mesh::Utils::sha256(available->text_fingerprint, sizeof(available->text_fingerprint),
recipient_key, PUB_KEY_SIZE,
(const uint8_t*)answer, strlen(answer));
// No application or terminal transport owns this automatic send.
available->reply_route = NULL;
expireExpectedAcks();
}
}
#endif
void MyMesh::onMessageRecv(const ContactInfo &from, mesh::Packet *pkt, uint32_t sender_timestamp,
const char *text) {
#if MESH_ENABLE_LOST_REPLY
// Answer independently of app inbox capacity, including held/retried DMs.
maybeReplyToLostQuestion(from, sender_timestamp, text);
#endif
#if MESH_ENABLE_ONE_KEY_DM
uint8_t dm_id[ONE_KEY_DM_ID_SIZE];
makeOneKeyDMId(dm_id, sender_timestamp, text);
if (wasDeliveredOneKeyDM(from.id.pub_key, dm_id)) return;
#if ONE_KEY_DM_SHARED_OFFLINE_QUEUE
bool replaced_held_dm = false;
#endif
for (uint8_t i = 0; i < held_dm_count; ++i) {
#if ONE_KEY_DM_COMPACT_HELD
uint8_t held_id[ONE_KEY_DM_ID_SIZE];
makeOneKeyDMId(held_id, held_dms[i].timestamp, held_dms[i].text);
#else
const uint8_t* held_id = held_dms[i].id;
#endif
if (memcmp(held_dms[i].sender_key, from.id.pub_key, PUB_KEY_SIZE) == 0 &&
memcmp(held_id, dm_id, ONE_KEY_DM_ID_SIZE) == 0) {
#if ONE_KEY_DM_SHARED_OFFLINE_QUEUE
// Removing the held frame frees exactly one ordinary slot for this retry.
removeHeldOneKeyDM(i);
replaced_held_dm = true;
#else
// Keep the held copy if the ordinary delivery path cannot queue this
// just-arrived retry; otherwise accepting a peer with a full app queue
// could silently discard the original when the queue drains.
if (offline_queue_len >= getOfflineQueueCapacity()) return;
rememberDeliveredOneKeyDM(from.id.pub_key, dm_id);
#endif
break;
}
}
#endif
markConnectionActive(from); // in case this is from a server, and we have a connection
// BaseChatMesh updates lastmod immediately before this callback.
scheduleContactWrite(from);
queueMessage(from, TXT_TYPE_PLAIN, pkt, sender_timestamp, NULL, 0, text);
#if MESH_ENABLE_ONE_KEY_DM
#if ONE_KEY_DM_SHARED_OFFLINE_QUEUE
if (replaced_held_dm) rememberDeliveredOneKeyDM(from.id.pub_key, dm_id);
#endif
#endif
}
void MyMesh::onCommandDataRecv(const ContactInfo &from, mesh::Packet *pkt, uint32_t sender_timestamp, const char *text) {
markConnectionActive(from); // in case this is from a server, and we have a connection
bool terminal_command_reply = false;
uint32_t terminal_command_elapsed_millis = 0;
#if COMPANION_FEATURE_TEXT_TERMINAL
terminal_command_reply = _terminal_command.takeReply(
from.id.pub_key, _ms->getMillis(), terminal_command_elapsed_millis);
if (terminal_command_reply) _terminal_command_target[0] = 0;
#endif
queueMessage(from, TXT_TYPE_CLI_DATA, pkt, sender_timestamp, NULL, 0, text,
terminal_command_reply, terminal_command_elapsed_millis);
}
void MyMesh::onCLICommandRecv(const ContactInfo &from, mesh::Packet *pkt, uint32_t sender_timestamp,
const char *text, char* reply) {
markConnectionActive(from); // in case this is from a server, and we have a connection
if (from.isRemoteCLIAllowed()) {
_primary_radio_reply_admission = false;
const uint32_t before = _morse_reply.generation();
_morse_reply.beginCommand(from.id.pub_key);
// A timestamp received over the radio must never grant local-console access.
if (!handleCommand(text, sender_timestamp ? sender_timestamp : 1, reply)) {
strcat(reply, "Unknown command"); // reply may have cmd prefix from 'text'
}
_morse_reply.endCommand();
_morse_reply_admission = _morse_reply.generation() != before;
} else {
queueMessage(from, TXT_TYPE_CLI_COMMAND, pkt, sender_timestamp, NULL, 0, text);
}
}
void MyMesh::onCLIReplyPrepared(const ContactInfo& from, mesh::Packet* reply) {
if (_primary_radio_reply_admission) {
_primary_radio_reply.prepare(reply);
if (reply) reply->radio_reply = true;
}
if (!_morse_reply_admission || !_morse_reply.pending()
|| memcmp(from.id.pub_key, _morse_reply.actor(), PUB_KEY_SIZE) != 0) return;
_morse_reply.prepareReply(reply);
if (reply) reply->radio_reply = true;
}
void MyMesh::onCLIReplyQueued(const ContactInfo& from, mesh::Packet* reply, bool queued) {
if (_primary_radio_reply_admission) {
_primary_radio_reply.arm(queued ? reply : nullptr);
_primary_radio_reply_admission = false;
}
if (!_morse_reply_admission) return;
if (memcmp(from.id.pub_key, _morse_reply.actor(), PUB_KEY_SIZE) == 0)
_morse_reply.armReply(reply, queued);
_morse_reply_admission = false;
}
bool MyMesh::onSignedMessageRecv(const ContactInfo &from, mesh::Packet *pkt, uint32_t sender_timestamp,
const uint8_t *sender_prefix, const char *text) {
if (!queueMessage(from, TXT_TYPE_SIGNED_PLAIN, pkt, sender_timestamp, sender_prefix, 4, text)) return false;
markConnectionActive(from);
// from.sync_since change needs to be persisted
scheduleContactWrite(from);
return true;
}
void MyMesh::onChannelMessageRecv(const mesh::GroupChannel &channel, mesh::Packet *pkt, uint32_t timestamp,
const char *text) {
const bool is_emergency_channel =
memcmp(channel.secret, EMERGENCY_CHANNEL_SECRET,
sizeof(EMERGENCY_CHANNEL_SECRET)) == 0;
const int selected_channel = findChannelIdxForPacket(channel, pkt);
if (selected_channel < 0) return;
queueChannelText(selected_channel, channel.secret, pkt->getSNR(),
pkt->isRouteFlood() ? pkt->path_len : 0xFF,
timestamp, text, is_emergency_channel);
if (pkt->isRouteFlood() && is_emergency_channel) {
bool zero_path = pkt->getPathHashCount() == 0;
uint16_t key = emergencyClientRepeatKey(pkt);
if (hasEmergencyClientRepeat(key)) {
pkt->markDoNotRetransmit();
} else {
emergency_client_repeats[emergency_client_repeat_next] = key;
emergency_client_repeat_next++;
if (emergency_client_repeat_next >= EMERGENCY_CLIENT_REPEAT_TABLE_SIZE) emergency_client_repeat_next = 0;
if (zero_path && emergency_client_repeat_packet == NULL) {
emergency_client_repeat_packet = obtainNewPacket();
if (emergency_client_repeat_packet != NULL) {
*emergency_client_repeat_packet = *pkt;
emergency_client_repeat_key = key;
emergency_client_repeat_send_at = futureMillis(
(int)(EMERGENCY_CLIENT_REPEAT_HOLD_MS + getRNG()->nextInt(0, EMERGENCY_CLIENT_REPEAT_JITTER_MS + 1)));
pkt->markDoNotRetransmit();
}
}
}
}
}
static bool isEmergencyNoticeChannelName(const char* name) {
const auto alias = parseCompanionChannelAlias(name);
return alias.hashtag && alias.kind != CompanionChannelKind::Invalid
&& alias.kind != CompanionChannelKind::Meshtext
&& strcmp(alias.tail, "emergency") == 0;
}
void MyMesh::seedEmergencyChannelNotice(uint8_t index, const ChannelDetails& previous) {
if (index >= MAX_GROUP_CHANNELS) return;
const uint8_t bit = 1U << (index % 8);
uint8_t& pending = pending_emergency_notices[index / 8];
ChannelDetails channel;
if (!getChannel(index, channel) || !isEmergencyNoticeChannelName(channel.name)) {
pending &= ~bit;
return;
}
if (!isEmergencyNoticeChannelName(previous.name)) pending |= bit;
if (!(pending & bit)) return; // Edits and reapplying a saved channel are not joins.
// Drain real traffic first. Starting with an empty inbox also makes this
// notice the oldest evictable channel frame if more traffic arrives later.
// Do not take a newly freed slot while a real one-key DM is still held.
if (offline_queue_len != 0) return;
#if MESH_ENABLE_ONE_KEY_DM
if (held_dm_count != 0) return;
#endif
if (queueChannelText(index, channel.channel.secret, 0, 0xFF,
getRTCClock()->getCurrentTime(),
"#EMERGENCY: This is an emergency channel. Do not use for testing or small talk.",
true, false)) pending &= ~bit;
}
void MyMesh::servicePendingChannelNotices() {
for (unsigned index = 0; index < MAX_GROUP_CHANNELS; ++index) {
if (!(pending_emergency_notices[index / 8] & (1U << (index % 8)))) continue;
ChannelDetails current{};
getChannel(index, current);
seedEmergencyChannelNotice(index, current);
return; // At most one local notice per loop, never a radio send.
}
}
bool MyMesh::queueChannelText(uint8_t channel_idx, const uint8_t secret[32], float snr,
uint8_t path_len, uint32_t timestamp, const char* text,
bool is_emergency_channel, bool notify) {
if (!text || channel_idx >= MAX_GROUP_CHANNELS) return false;
int i = 0;
if (app_target_ver >= 3) {
out_frame[i++] = RESP_CODE_CHANNEL_MSG_RECV_V3;
const float bounded_snr = !isfinite(snr) ? 0 : snr < -32 ? -32 : snr > 31.75f ? 31.75f : snr;
out_frame[i++] = (int8_t)(bounded_snr * 4);
out_frame[i++] = 0; // reserved1
out_frame[i++] = 0; // reserved2
} else {
out_frame[i++] = RESP_CODE_CHANNEL_MSG_RECV;
}
out_frame[i++] = channel_idx;
out_frame[i++] = path_len;
out_frame[i++] = TXT_TYPE_PLAIN;
memcpy(&out_frame[i], &timestamp, 4);
i += 4;
int tlen = strlen(text); // TODO: UTF-8 ??
if (i + tlen > MAX_FRAME_SIZE) {
tlen = MAX_FRAME_SIZE - i;
while (tlen > 0 && (uint8_t(text[tlen]) & 0xc0) == 0x80) --tlen;
}
memcpy(&out_frame[i], text, tlen);
i += tlen;
const bool queued = addToOfflineQueue(out_frame, i);
if (!queued) return false;
#if COMPANION_FEATURE_NOTIFICATIONS
const bool notification_managed = notify && _notifications.message(nullptr, secret, _serial && _serial->isConnected(), millis());
if (_ui) _ui->setMessageNotificationOverride(notification_managed, _notifications.overridesScreen());
#else
const bool notification_managed = false;
#endif
// Older screen UIs can vibrate inside newMsg(), independently of notify().
// Treat local notices as managed/screen-suppressed while retaining history.
if (!notify && _ui) _ui->setMessageNotificationOverride(true, true);
if (_serial && _serial->isConnected()) {
uint8_t frame[1];
frame[0] = PUSH_CODE_MSG_WAITING;
_serial->writeFrame(frame, 1);
} else {
#ifdef DISPLAY_CLASS
if (notify && _ui && !notification_managed) _ui->notify(UIEventType::channelMessage);
#endif
}
#if COMPANION_FEATURE_TEXT_TERMINAL
if (notify && hasTerminalOutput()
&& _terminal_display.shouldShowChannel(is_emergency_channel)) {
ChannelDetails details;
const char* channel_name = getChannel(channel_idx, details) ? details.name : "Unknown";
terminalOutput().printf(
"\r\nCHANNEL MSG -> %s (%s)\r\n %s\r\n> ", channel_name,
path_len == 0xFF ? "DIRECT" : "FLOOD", text);
}
#endif
#ifdef DISPLAY_CLASS
// Get the channel name from the channel index
const char *channel_name = "Unknown";
ChannelDetails channel_details;
if (getChannel(channel_idx, channel_details)) {
channel_name = channel_details.name;
}
char channel_label[64];
snprintf(channel_label, sizeof(channel_label), "Ch %u %s",
(unsigned int)channel_idx, channel_name);
if (_ui) {
_ui->newMsg(path_len, channel_label, text, offline_queue_len,
channel_idx, channel_name, queued ? offline_queue_len - 1 : -1);
}
#endif
if (_ui) _ui->setMessageNotificationOverride(false);
return true;
}
void MyMesh::onChannelDataRecv(const mesh::GroupChannel &channel, mesh::Packet *pkt, uint16_t data_type,
const uint8_t *data, size_t data_len) {
if (data_len > MAX_CHANNEL_DATA_LENGTH) {
MESH_DEBUG_PRINTLN("onChannelDataRecv: dropping payload_len=%d exceeds frame limit=%d",
(uint32_t)data_len, (uint32_t)MAX_CHANNEL_DATA_LENGTH);
return;
}
int i = 0;
out_frame[i++] = RESP_CODE_CHANNEL_DATA_RECV;
out_frame[i++] = (int8_t)(pkt->getSNR() * 4);
out_frame[i++] = rxViaTag(pkt, 0); // reserved1: the profile it arrived on, while radio2 is on
out_frame[i++] = 0; // reserved2
const int selected_channel = findChannelIdxForPacket(channel, pkt);
if (selected_channel < 0) return;
uint8_t channel_idx = selected_channel;
out_frame[i++] = channel_idx;
out_frame[i++] = pkt->isRouteFlood() ? pkt->path_len : 0xFF;
out_frame[i++] = (uint8_t)(data_type & 0xFF);
out_frame[i++] = (uint8_t)(data_type >> 8);
out_frame[i++] = (uint8_t)data_len;
int copy_len = (int)data_len;
if (copy_len > 0) {
memcpy(&out_frame[i], data, copy_len);
i += copy_len;
}
addToOfflineQueue(out_frame, i);
if (_serial->isConnected()) {
uint8_t frame[1];
frame[0] = PUSH_CODE_MSG_WAITING; // send push 'tickle'
_serial->writeFrame(frame, 1);
}
}
uint8_t MyMesh::onContactRequest(const ContactInfo &contact, uint32_t sender_timestamp, const uint8_t *data,
uint8_t len, uint8_t *reply) {
if (data == NULL || len == 0 || reply == NULL) return 0;
#if MESH_ENABLE_LOST_REPLY
if (data[0] == mesh::tracker::RequestType) {
return handleTrackerStatusRequest(contact, sender_timestamp, data, len, reply);
}
#endif
if (data[0] == REQ_TYPE_GET_TELEMETRY_DATA) {
uint8_t permissions = 0;
uint8_t cp = contact.flags >> 1; // LSB used as 'favourite' bit (so only use upper bits)
if (_prefs.telemetry_mode_base == TELEM_MODE_ALLOW_ALL) {
permissions = TELEM_PERM_BASE;
} else if (_prefs.telemetry_mode_base == TELEM_MODE_ALLOW_FLAGS) {
permissions = cp & TELEM_PERM_BASE;
}
if (_prefs.telemetry_mode_loc == TELEM_MODE_ALLOW_ALL) {
permissions |= TELEM_PERM_LOCATION;
} else if (_prefs.telemetry_mode_loc == TELEM_MODE_ALLOW_FLAGS) {
permissions |= cp & TELEM_PERM_LOCATION;
}
if (_prefs.telemetry_mode_env == TELEM_MODE_ALLOW_ALL) {
permissions |= TELEM_PERM_ENVIRONMENT;
} else if (_prefs.telemetry_mode_env == TELEM_MODE_ALLOW_FLAGS) {
permissions |= cp & TELEM_PERM_ENVIRONMENT;
}
uint8_t perm_mask = ~(data[1]); // NEW: first reserved byte (of 4), is now inverse mask to apply to permissions
permissions &= perm_mask;
if (permissions & TELEM_PERM_BASE) { // only respond if base permission bit is set
telemetry.reset();
telemetry.addVoltage(TELEM_CHANNEL_SELF, (float)board.getBattMilliVolts() / 1000.0f);
// query other sensors -- target specific
sensors.querySensors(permissions, telemetry);
float temperature = board.getMCUTemperature();
if(!isnan(temperature)) { // Supported boards with built-in temperature sensor. ESP32-C3 may return NAN
telemetry.addTemperature(TELEM_CHANNEL_SELF, temperature); // Built-in MCU Temperature
}
memcpy(reply, &sender_timestamp,
4); // reflect sender_timestamp back in response packet (kind of like a 'tag')
uint8_t tlen = telemetry.getSize();
memcpy(&reply[4], telemetry.getBuffer(), tlen);
return 4 + tlen;
}
}
return 0; // unknown
}
#if MESH_ENABLE_LOST_REPLY
uint8_t MyMesh::handleTrackerStatusRequest(const ContactInfo& from, uint32_t tag,
const uint8_t* data, uint8_t len,
uint8_t* reply) {
mesh::tracker::StatusRequest request;
if (reply == NULL || !mesh::tracker::parseStatusRequest(data, len, request)
|| isTransientContact(from)
|| (from.type != ADV_TYPE_CHAT && from.type != ADV_TYPE_SENSOR)) return 0;
// A contact cache reference can move when another contact is loaded.
uint8_t key[PUB_KEY_SIZE];
memcpy(key, from.id.pub_key, sizeof(key));
ContactInfo* saved = lookupPersistentContactByPubKey(key, sizeof(key));
if (saved == NULL
|| (saved->type != ADV_TYPE_CHAT && saved->type != ADV_TYPE_SENSOR)) return 0;
const uint32_t now = _ms->getMillis();
if (!lost_reply_limiter.canReply(key, tag, now)) return 0;
const uint8_t state = _prefs.lost_reply <= mesh::companion::LostReplyYes
? _prefs.lost_reply : uint8_t(mesh::companion::LostReplyOff);
const size_t count = mesh::tracker::makeStatusResponse(
tag, state, state == mesh::tracker::Lost ? 120 : 0,
reply, mesh::tracker::ResponseLength);
if (count != 0) {
lost_reply_limiter.rememberReply(key, tag, now);
const uint32_t owner_time = getRTCClock()->getCurrentTime();
const uint32_t clock_difference = tag > owner_time
? tag - owner_time : owner_time - tag;
// The freshest authenticated advert/report owns position. Clock-bounded
// reports cannot poison that durable floor with a wildly future tag.
if ((request.flags & mesh::tracker::FreshGps) && canMutateContacts()
&& tag > saved->last_advert_timestamp && clock_difference <= 300) {
saved->gps_lat = request.latitude / 10;
saved->gps_lon = request.longitude / 10;
saved->last_advert_timestamp = tag;
saved->lastmod = owner_time;
onDiscoveredContact(*saved, false, 0, NULL);
}
}
// BaseChatMesh sends this response without touching the user DM ACK table.
return uint8_t(count);
}
#endif
void MyMesh::onContactResponse(const ContactInfo &contact, const uint8_t *data, uint8_t len) {
const uint32_t now = _ms->getMillis();
servicePendingSerialReply(now); // Expire before considering any radio callback.
if (data == NULL || len < 4) return;
#if MESH_ENABLE_ONE_KEY_DM
// Decryption returns block-aligned plaintext including zero padding.
if (len >= 4 + sizeof(ONE_KEY_REJECT_MARKER) + SIGNATURE_SIZE &&
len < 4 + sizeof(ONE_KEY_REJECT_MARKER) + SIGNATURE_SIZE + CIPHER_BLOCK_SIZE &&
memcmp(data + 4, ONE_KEY_REJECT_MARKER, sizeof(ONE_KEY_REJECT_MARKER)) == 0) {
uint8_t signed_refusal[PUB_KEY_SIZE + 4 + sizeof(ONE_KEY_REJECT_MARKER)];
memcpy(signed_refusal, self_id.pub_key, PUB_KEY_SIZE);
memcpy(signed_refusal + PUB_KEY_SIZE, data, 4 + sizeof(ONE_KEY_REJECT_MARKER));
uint32_t intro_tag;
memcpy(&intro_tag, data, sizeof(intro_tag));
if (contact.id.verify(data + 4 + sizeof(ONE_KEY_REJECT_MARKER),
signed_refusal, sizeof(signed_refusal)) &&
rememberOneKeyReject(contact, intro_tag) && _serial->isConnected()) {
out_frame[0] = PUSH_CODE_ONE_KEY_DM_REJECTED;
memcpy(&out_frame[1], contact.id.pub_key, PUB_KEY_SIZE);
_serial->writeFrame(out_frame, 1 + PUB_KEY_SIZE);
}
return;
}
#endif
uint32_t tag;
memcpy(&tag, data, 4);
const bool matched = _delayed_replies.requestMatches(contact.id.pub_key, tag);
const auto kind = _delayed_replies.request.kind;
if (matched && _delayed_replies.request.phase != mesh::CompanionDelayedReplies::AwaitRadio) return;
// Login has no reflected nonce. Known old request replies take precedence
// over its permissive envelope; exact newer reflected replies remain valid
// immediately after a successful login despite the login quarantine.
if (matched && kind == mesh::CompanionDelayedReplies::Login
&& _delayed_replies.blocksResponse(contact.id.pub_key, tag, now)) return;
if (matched && kind == mesh::CompanionDelayedReplies::Login) {
if (len < 6 || (data[4] == RESP_SERVER_LOGIN_OK && len < 13)) return;
// yes, is response to pending sendLogin()
uint16_t keep_alive_secs = 0;
#if COMPANION_FEATURE_TEXT_TERMINAL
const bool terminal_login_response = _delayed_replies.request.terminal && _terminal_login_pending
&& memcmp(_terminal_login_key, contact.id.pub_key,
sizeof(_terminal_login_key)) == 0;
#endif
int i = 0;
#if COMPANION_FEATURE_TEXT_TERMINAL
bool login_success = false;
bool modern_login = false;
#endif
if (len >= 6 && memcmp(&data[4], "OK", 2) == 0) { // legacy Repeater login OK response
#if COMPANION_FEATURE_TEXT_TERMINAL
login_success = true;
#endif
out_frame[i++] = PUSH_CODE_LOGIN_SUCCESS;
out_frame[i++] = 0; // legacy: is_admin = false
memcpy(&out_frame[i], contact.id.pub_key, 6);
i += 6; // pub_key_prefix
} else if (len >= 13 && data[4] == RESP_SERVER_LOGIN_OK) { // new login response
#if COMPANION_FEATURE_TEXT_TERMINAL
login_success = true;
modern_login = true;
#endif
keep_alive_secs = ((uint16_t)data[5]) * 16;
out_frame[i++] = PUSH_CODE_LOGIN_SUCCESS;
out_frame[i++] = data[6]; // permissions (eg. is_admin)
memcpy(&out_frame[i], contact.id.pub_key, 6);
i += 6; // pub_key_prefix
memcpy(&out_frame[i], &tag, 4);
i += 4; // NEW: include server timestamp
out_frame[i++] = data[7]; // NEW (v7): ACL permissions
out_frame[i++] = data[12]; // FIRMWARE_VER_LEVEL
} else {
out_frame[i++] = PUSH_CODE_LOGIN_FAIL;
out_frame[i++] = 0; // reserved
memcpy(&out_frame[i], contact.id.pub_key, 6);
i += 6; // pub_key_prefix
}
// Terminal login has its own text destination and deliberately captures
// no Binary route. Binary logins retain their exact requester even if a
// different transport has issued a command in the meantime.
_delayed_replies.rememberLoginTag(tag);
if (writePendingSerialFrame(out_frame, i, now) != (size_t)i) return;
if (keep_alive_secs > 0) startConnection(contact, keep_alive_secs);
#if COMPANION_FEATURE_TEXT_TERMINAL
if (terminal_login_response) {
if (hasTerminalOutput()) {
Stream& output = terminalOutput();
if (login_success && modern_login) {
output.printf(
"\r\nLOGIN -> %s accepted (ACL permissions 0x%02X, server v%u)\r\n> ",
_terminal_login_target, (unsigned)data[7], (unsigned)data[12]);
} else if (login_success) {
output.printf("\r\nLOGIN -> %s accepted (legacy server)\r\n> ",
_terminal_login_target);
} else {
output.printf("\r\nLOGIN -> %s rejected\r\n> ",
_terminal_login_target);
}
}
clearTerminalLogin(now);
}
#endif
} else if (matched && kind == mesh::CompanionDelayedReplies::Status) {
// Require a complete response that also fits its host-protocol envelope.
// The app parses at least 48 bytes and otherwise throws a RangeError.
if (!mesh::companionStatusResponseIsLongEnough(len)
|| len > MAX_FRAME_SIZE - 4) {
MESH_DEBUG_PRINTLN(
"onContactResponse(), invalid status response size: len=%u, expected>=%u",
(unsigned)len,
(unsigned)mesh::COMPANION_MIN_STATUS_RESPONSE_SIZE);
return;
}
int i = 0;
out_frame[i++] = PUSH_CODE_STATUS_RESPONSE;
out_frame[i++] = 0; // reserved
memcpy(&out_frame[i], contact.id.pub_key, 6);
i += 6; // pub_key_prefix
memcpy(&out_frame[i], &data[4], len - 4);
i += (len - 4);
writePendingSerialFrame(out_frame, i, now);
} else if (matched && kind == mesh::CompanionDelayedReplies::Telemetry) {
if (len <= 4 || len > MAX_FRAME_SIZE - 4) {
return;
}
int i = 0;
out_frame[i++] = PUSH_CODE_TELEMETRY_RESPONSE;
out_frame[i++] = 0; // reserved
memcpy(&out_frame[i], contact.id.pub_key, 6);
i += 6; // pub_key_prefix
memcpy(&out_frame[i], &data[4], len - 4);
i += (len - 4);
writePendingSerialFrame(out_frame, i, now);
} else if (matched && kind == mesh::CompanionDelayedReplies::Binary) {
if (len <= 4 || len > MAX_FRAME_SIZE - 2) {
return;
}
int i = 0;
out_frame[i++] = PUSH_CODE_BINARY_RESPONSE;
out_frame[i++] = 0; // reserved
memcpy(&out_frame[i], &tag, 4); // app needs to match this to RESP_CODE_SENT.tag
i += 4;
memcpy(&out_frame[i], &data[4], len - 4);
i += (len - 4);
writePendingSerialFrame(out_frame, i, now);
} else {
// Forward unsolicited responses, including pushed telemetry, to the app.
// Pending requests above retain their captured reply route.
// A matching tag with no response body is malformed, not an unsolicited
// push, and must not escape through the app's generic response path.
if (matched || _delayed_replies.blocksResponse(contact.id.pub_key, tag,
now)) return;
if (_serial != NULL && _serial->isConnected() && len <= MAX_FRAME_SIZE - 2) {
int i = 0;
out_frame[i++] = PUSH_CODE_BINARY_RESPONSE;
out_frame[i++] = 0; // reserved
memcpy(&out_frame[i], data, len);
i += len;
_serial->writeFrame(out_frame, i);
}
}
}
bool MyMesh::onContactPathRecv(ContactInfo& contact, uint8_t* in_path, uint8_t in_path_len, uint8_t* out_path, uint8_t out_path_len, uint8_t extra_type, uint8_t* extra, uint8_t extra_len) {
const uint32_t now = _ms->getMillis();
servicePendingSerialReply(now);
if (extra_type == PAYLOAD_TYPE_RESPONSE && extra != NULL && extra_len >= 4) {
uint32_t tag;
memcpy(&tag, extra, 4);
if (_delayed_replies.request.kind == mesh::CompanionDelayedReplies::Discovery
&& _delayed_replies.requestMatches(contact.id.pub_key, tag)) {
if (_delayed_replies.request.phase != mesh::CompanionDelayedReplies::AwaitRadio) return false;
if (extra_len <= 4 || !mesh::Packet::isValidPathLen(in_path_len)
|| !mesh::Packet::isValidPathLen(out_path_len)) {
MESH_DEBUG_PRINTLN("onContactPathRecv, invalid path sizes: %d, %d", in_path_len, out_path_len);
} else {
int i = 0;
out_frame[i++] = PUSH_CODE_PATH_DISCOVERY_RESPONSE;
out_frame[i++] = 0; // reserved
memcpy(&out_frame[i], contact.id.pub_key, 6);
i += 6; // pub_key_prefix
out_frame[i++] = out_path_len;
i += mesh::Packet::writePath(&out_frame[i], out_path, out_path_len);
out_frame[i++] = in_path_len;
i += mesh::Packet::writePath(&out_frame[i], in_path, in_path_len);
// NOTE: telemetry data in 'extra' is discarded at present
writePendingSerialFrame(out_frame, i, now);
}
return false; // DON'T send reciprocal path!
}
}
// let base class handle received path and data
return BaseChatMesh::onContactPathRecv(contact, in_path, in_path_len, out_path, out_path_len, extra_type, extra, extra_len);
}
void MyMesh::onControlDataRecv(mesh::Packet *packet) {
if (packet->payload_len + 4 > MAX_FRAME_SIZE) {
MESH_DEBUG_PRINTLN("onControlDataRecv(), payload_len too long: %d", packet->payload_len);
return;
}
#if defined(DISPLAY_CLASS) && !(defined(UI_NO_DISCOVER_SCREEN) && (UI_NO_DISCOVER_SCREEN + 0 != 0))
checkControlDataForPendingDiscovery(packet->payload, packet->payload_len);
#endif
int i = 0;
out_frame[i++] = PUSH_CODE_CONTROL_DATA;
out_frame[i++] = (int8_t)(packet->getSNR() * 4);
out_frame[i++] = (int8_t)packet->getRSSI();
out_frame[i++] = packet->path_len;
memcpy(&out_frame[i], packet->payload, packet->payload_len);
i += packet->payload_len;
if (_serial->isConnected()) {
_serial->writeFrame(out_frame, i);
} else {
MESH_DEBUG_PRINTLN("onControlDataRecv(), data received while app offline");
}
}
// Which profile a received packet arrived on, for the app, in a frame byte that
// stock firmware leaves reserved: 0xA0 the primary, 0xA1 radio2. MeshCore Open
// TAK shows it under a MediaMesh photo or voice clip ("Received via MediaMesh",
// "via main mesh", or both); Wadamesh standalones send the same tags. While
// radio2 is off everything is the primary, so the stock value (`none`) stays,
// as it does for a packet that came in over the UDP bridge.
uint8_t MyMesh::rxViaTag(const mesh::Packet *pkt, uint8_t none) const {
const mesh::RadioProfiles *profiles = _radio ? _radio->profiles() : nullptr;
if (!pkt || !profiles || !profiles->enabled()) return none;
#if defined(WITH_UDP_BRIDGE)
if (pkt->udp_bridge_ingress) return none;
#endif
return pkt->radio_origin == 1 ? 0xA1 : 0xA0;
}
void MyMesh::onRawDataRecv(mesh::Packet *packet) {
if (packet->payload_len + 4 > MAX_FRAME_SIZE) {
MESH_DEBUG_PRINTLN("onRawDataRecv(), payload_len too long: %d", packet->payload_len);
return;
}
int i = 0;
out_frame[i++] = PUSH_CODE_RAW_DATA;
out_frame[i++] = (int8_t)(packet->getSNR() * 4);
out_frame[i++] = (int8_t)packet->getRSSI();
out_frame[i++] = rxViaTag(packet, 0xFF); // reserved: the profile it arrived on, while radio2 is on
memcpy(&out_frame[i], packet->payload, packet->payload_len);
i += packet->payload_len;
if (_serial->isConnected()) {
_serial->writeFrame(out_frame, i);
} else {
MESH_DEBUG_PRINTLN("onRawDataRecv(), data received while app offline");
}
}
void MyMesh::onTraceRecv(mesh::Packet *packet, uint32_t tag, uint32_t auth_code, uint8_t flags,
const uint8_t *path_snrs, const uint8_t *path_hashes, uint8_t path_len) {
const uint32_t now = _ms->getMillis();
serviceBinaryTraceReply(now);
#if COMPANION_FEATURE_TEXT_TERMINAL
serviceTerminalTrace(now);
#endif
if (_delayed_replies.blocksTrace(tag, auth_code, now)) return;
const bool binary_trace_match = _delayed_replies.hasBinaryTrace()
&& tag == _delayed_replies.trace.tag && auth_code == _delayed_replies.trace.auth;
if (binary_trace_match
&& _delayed_replies.trace.phase != mesh::CompanionDelayedReplies::AwaitRadio) return;
uint8_t path_sz = flags & 0x03; // NEW v1.11+
if (12 + path_len + (path_len >> path_sz) + 1 > MAX_FRAME_SIZE) {
MESH_DEBUG_PRINTLN("onTraceRecv(), path_len is too long: %d", (uint32_t)path_len);
return;
}
int i = 0;
out_frame[i++] = PUSH_CODE_TRACE_DATA;
out_frame[i++] = 0; // reserved
out_frame[i++] = path_len;
out_frame[i++] = flags;
memcpy(&out_frame[i], &tag, 4);
i += 4;
memcpy(&out_frame[i], &auth_code, 4);
i += 4;
memcpy(&out_frame[i], path_hashes, path_len);
i += path_len;
memcpy(&out_frame[i], path_snrs, path_len >> path_sz);
i += path_len >> path_sz;
out_frame[i++] = (int8_t)(packet->getSNR() * 4); // extra/final SNR (to this node)
#if COMPANION_FEATURE_TEXT_TERMINAL
if (hasTerminalOutput() && _terminal_trace_pending
&& tag == _terminal_trace_tag && auth_code == _terminal_trace_auth) {
Stream& output = terminalOutput();
const uint8_t hash_size = _terminal_trace_hash_size;
const uint8_t hop_count = hash_size == 0 ? 0 : path_len / hash_size;
const uint8_t response_hash_size = 1 << (flags & 0x03);
const unsigned long elapsed = (uint32_t)(now - _terminal_trace_sent_at);
if (hash_size == 0 || response_hash_size != hash_size
|| path_len % hash_size != 0
|| hop_count >= MAX_PATH_SIZE) {
return; // A malformed match must not consume a later valid response.
} else {
output.printf("\r\nTRACE -> %s (%lu ms)\r\n",
_terminal_trace_target, elapsed);
output.print(" ");
for (uint8_t hop = 0; hop < hop_count; hop++) {
output.print(((float)(int8_t)path_snrs[hop]) / 4.0f, 2);
output.print(" dB -> [");
mesh::Utils::printHex(output, &path_hashes[hop * hash_size],
hash_size);
output.print("] -> ");
}
output.print(packet->getSNR(), 2);
output.print(" dB\r\n> ");
}
clearTerminalTrace(now);
}
#endif
if (binary_trace_match) {
_delayed_replies.storeBinaryTrace(out_frame, i, now);
serviceBinaryTraceReply(now);
}
}
uint32_t MyMesh::calcFloodTimeoutMillisFor(uint32_t pkt_airtime_millis) const {
return SEND_TIMEOUT_BASE_MILLIS + (FLOOD_SEND_TIMEOUT_FACTOR * pkt_airtime_millis);
}
uint32_t MyMesh::calcDirectTimeoutMillisFor(uint32_t pkt_airtime_millis, uint8_t path_len) const {
uint8_t path_hash_count = path_len & 63;
return SEND_TIMEOUT_BASE_MILLIS +
((pkt_airtime_millis * DIRECT_SEND_PERHOP_FACTOR + DIRECT_SEND_PERHOP_EXTRA_MILLIS) *
(path_hash_count + 1));
}
void MyMesh::onSendTimeout() {
expireExpectedAcks();
}
MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMeshTables &tables, DataStore& store, AbstractUITask* ui)
: BaseChatMesh(radio, *new ArduinoMillis(), rng, rtc, *new StaticPoolPacketManager(16), tables),
#ifdef COMPANION_MESH_CLOCK_SYNC
_clock_sync(radio, _clock_sync_millis, rtc, _clock_sync_acl, sensors,
_prefs.airtime_factor),
#endif
_serial(NULL), _mota_source_control(NULL),
telemetry(MAX_PACKET_PAYLOAD - 4),
#if COMPANION_RTC_FAST_OFFLINE_FRAMES || COMPANION_RTC_SLOW_OFFLINE_FRAMES
offline_queue(rtcFastQueueBank(), rtcSlowQueueBank()),
#endif
_store(&store), _ui(ui), _iter(0) {
_iter_started = false;
_iter_reply_route = NULL;
_iter_start_pending = false;
_iter_contact_pending = false;
_iter_next_frame_at = 0;
_iter_total_count = 0;
_iter_table_revision = 0;
_cli_rescue = false;
#if COMPANION_FEATURE_TEXT_TERMINAL
_terminal_mode = false;
_terminal_output = NULL;
_terminal_recipient_set = false;
memset(_terminal_recipient_key, 0, sizeof(_terminal_recipient_key));
_terminal_login_pending = false;
memset(_terminal_login_key, 0, sizeof(_terminal_login_key));
_terminal_login_expires_at = 0;
_terminal_login_target[0] = 0;
clearTerminalCommand();
_terminal_trace_pending = false;
clearTerminalTrace();
#endif
saved_radio_apply_pending = false;
_radio_available = true;
radio_apply_retry_at = 0;
radio_apply_failures = 0;
_scheduled_reboot_at = 0;
#if COMPANION_FEATURE_TEMP_RADIO
_temp_radio_set_at = 0;
_temp_radio_revert_at = 0;
_temp_radio_retry_at = 0;
_temp_radio_freq = 0.0f;
_temp_radio_bw = 0.0f;
_temp_radio_sf = 0;
_temp_radio_cr = 0;
_temp_radio_failures = 0;
_temp_radio_applied = false;
#endif
cli_command[0] = 0;
offline_queue_len = 0;
offline_queue_head = 0;
#if defined(ESP32_PLATFORM) && defined(BOARD_HAS_PSRAM)
offline_queue = offline_queue_fallback;
offline_queue_capacity = OFFLINE_QUEUE_PSRAM_FALLBACK_SIZE;
#endif
app_target_ver = 0;
memset(expected_ack_table, 0, sizeof(expected_ack_table));
next_ack_idx = 0;
next_ack_expiry = 0;
has_next_ack_expiry = false;
sign_data = NULL;
sign_data_reply_route = NULL;
sign_data_deadline = 0;
dirty_contacts_expiry = 0;
dirty_contacts_failures = 0;
memset(advert_paths, 0, sizeof(advert_paths));
memset(send_scope.key, 0, sizeof(send_scope.key));
send_unscoped = false;
// defaults
_prefs.airtime_factor = 1.0;
_prefs.tx_delay_factor = 0.5f;
_prefs.direct_tx_delay_factor = 0.2f;
strcpy(_prefs.node_name, "NONAME");
_prefs.freq = LORA_FREQ;
_prefs.sf = LORA_SF;
_prefs.bw = LORA_BW;
_prefs.cr = LORA_CR;
_prefs.multi_acks = DEFAULT_MULTI_ACKS;
_prefs.manual_add_contacts = DEFAULT_MANUAL_ADD_CONTACTS;
_prefs.tx_power_dbm = LORA_TX_POWER;
_prefs.buzzer_quiet = DEFAULT_BUZZER_QUIET ? 1 : 0;
_prefs.vibe_quiet = 0;
_prefs.gps_enabled = 0; // GPS disabled by default
_prefs.gps_interval = 0; // Use the default 1-second fix-processing interval
_prefs.gps_sync_interval_hours = 0; // Retain the board's default time-sync cadence
#if defined(RAK_4631)
_prefs.peripherals_shared = 0; // A genuinely fresh missing image is GPS-only.
#endif
_prefs.autoadd_config = DEFAULT_AUTOADD_CONFIG;
_prefs.path_hash_mode = DEFAULT_COMPANION_PATH_HASH_MODE;
#ifdef RADIO_FEM_TXGAIN
_prefs.radio_fem_txgain = RADIO_FEM_TXGAIN;
#else
_prefs.radio_fem_txgain = 0;
#endif
#ifdef DEFAULT_RX_DELAY_BASE
_prefs.rx_delay_base = DEFAULT_RX_DELAY_BASE;
#endif
_prefs.setRepeatEn(false);
#if defined(USE_SX1262) || defined(USE_SX1268) || defined(USE_LR1110) \
|| defined(USE_LR2021) || defined(SX126X_RX_BOOSTED_GAIN) \
|| defined(RX_BOOSTED_GAIN)
_prefs.rx_boosted_gain = mesh::radio::configuredRxBoostedGainDefault();
#endif
_prefs.radio_fem_rxgain = DEFAULT_FEM_RX_GAIN;
_prefs.rx_powersaving_enabled = RXPS_FIXED_ENABLED ? 1 : 0;
_prefs.rx_ps_level = RXPS_FIXED_LEVEL;
_prefs.rx_ps_preamble = RXPS_FIXED_PREAMBLE;
_prefs.rx_ps_rx_us = RX_POWERSAVING_DEFAULT_RX_US;
_prefs.rx_ps_sleep_us = RX_POWERSAVING_DEFAULT_SLEEP_US;
_prefs.powersaving_enabled = 1;
_prefs.powersaving_policy_version = 0;
// Keep WiFi-only compatibility builds reachable when they have no other
// CLI transport. Full and Bluetooth/wired-capable Companions are opt-in.
#if defined(WIFI_SSID) && !defined(BLE_PIN_CODE) && !defined(ENABLE_USB_INTERFACE) \
&& !defined(SERIAL_RX) && !defined(ETHERNET_ENABLED)
_prefs.wifi_enabled = 1;
#else
_prefs.wifi_enabled = 0;
#endif
memset(_prefs.bluetooth_name, 0, sizeof(_prefs.bluetooth_name));
_prefs.bluetooth_enabled = 1;
_prefs.usb_debug_enabled = 0;
_prefs.display_rotation_degrees = 0;
_prefs.cad_enabled = DEFAULT_CAD_ENABLED ? 1 : 0;
_prefs.cad_scan_timeout_ms = 0;
_prefs.cad_retry_delay_ms = 0;
_prefs.cad_max_duration_ms = 0;
#if defined(ENABLE_USB_INTERFACE)
// Keep a USB Companion's primary stream exclusively framed on a fresh
// install. nRF52 Full can add a diagnostics port; ESP32 and other single-TTY
// builds switch the primary stream into the text terminal before logs.
_prefs.usb_logging_enabled = 0;
#else
_prefs.usb_logging_enabled = 1;
#endif
recalcRxPowerSavingFromLevel(_prefs.rx_ps_level, _prefs.sf, _prefs.bw,
_prefs.rx_ps_preamble, &_prefs.rx_ps_rx_us,
&_prefs.rx_ps_sleep_us);
#if defined(WITH_MQTT_BRIDGE) && defined(ESP32_PLATFORM) && defined(WIFI_SSID)
memset(&_mqtt_prefs, 0, sizeof(_mqtt_prefs));
_mqtt_bridge = nullptr;
_mqtt_configured = false;
_mqtt_started = false;
#endif
#ifdef WITH_WEBCONFIG
_webconfig = nullptr;
_wc_mqtt_dirty = false;
#endif
if (_ui != nullptr) _ui->setShutdownGuard(this);
}
void MyMesh::begin(bool has_display, bool radio_available,
mesh::ui::StartupScreen* startup_screen) {
_radio_available = radio_available;
setRadioAvailable(radio_available);
initializeContactStorage();
initializeOfflineQueue();
BaseChatMesh::begin();
_radio_profiles.begin(_store->getPrimaryFS(), _radio, getRTCClock());
mesh::loadMorseSettings(_store->getPrimaryFS());
#if defined(ENABLE_OTA)
mesh::ota::beginSpeedConfig(_store->getPrimaryFS());
#endif
#if COMPANION_FEATURE_OTA_CLI
mesh::ota::beginCompanionOtaConfig(_store->getPrimaryFS());
mesh::ota::ota_set_context_config_loader(mesh::ota::loadCompanionOtaConfig);
#endif
#ifdef DISPLAY_CLASS
if (startup_screen != nullptr) startup_screen->loadingIdentity();
#endif
const bool identity_loaded = _store->loadMainIdentity(self_id);
const bool is_new_install = !identity_loaded
|| mesh::hasReservedIdentityPrefix(self_id);
if (is_new_install && !_store->canCreateMainIdentity()) {
MESH_DEBUG_PRINTLN(
"Identity unavailable from persistent storage; rebooting without replacement");
board.reboot();
return;
}
bool identity_ready = true;
if (is_new_install) {
{
#ifdef DISPLAY_CLASS
if (startup_screen != nullptr) startup_screen->generatingKey();
mesh::ScopedIdentityGenerationProgress progress(
mesh::ui::StartupScreen::progress, startup_screen);
#endif
identity_ready = mesh::generateUsableLocalIdentity(self_id, radio_new_identity);
}
#ifdef DISPLAY_CLASS
// Generation has finished. Flash persistence must never retain its label
// or cooperative animation callback.
if (startup_screen != nullptr) {
if (identity_ready) startup_screen->savingIdentity();
else startup_screen->starting();
}
#endif
if (identity_ready) identity_ready = _store->saveMainIdentity(self_id);
}
#ifdef DISPLAY_CLASS
if (startup_screen != nullptr) startup_screen->starting();
#endif
#if defined(ESP32_PLATFORM)
mesh::discardESP32TrueRandom();
#endif
if (!identity_ready) {
MESH_DEBUG_PRINTLN("Identity generation/save failed; rebooting");
board.reboot();
return;
}
#if defined(ENABLE_OTA)
mesh::ota::ota_refresh_seeder_identity(self_id.pub_key);
#endif
// if name is provided as a build flag, use that as default node name instead
#ifdef ADVERT_NAME
strcpy(_prefs.node_name, ADVERT_NAME);
#else
// use hex of first 4 bytes of identity public key as default node name
char pub_key_hex[10];
mesh::Utils::toHex(pub_key_hex, self_id.pub_key, 4);
strcpy(_prefs.node_name, pub_key_hex);
#endif
// if build provides default-scope, init with that
#ifdef DEFAULT_FLOOD_SCOPE_NAME
strcpy(_prefs.default_scope_name, DEFAULT_FLOOD_SCOPE_NAME);
{
TransportKeyStore temp;
TransportKey key;
temp.getAutoKeyFor(0, "#" DEFAULT_FLOOD_SCOPE_NAME, key);
memcpy(_prefs.default_scope_key, key.key, sizeof(key.key));
}
#endif
mesh::ui::loadDisplayPowerSettings(_store->getPrimaryFS(),
#ifdef BLE_PIN_CODE
true,
#else
false,
#endif
true // All Companion transports show startup unless the saved mode forbids it.
);
#if COMPANION_FEATURE_NOTIFICATIONS
auto& notification_settings = _notifications.settings();
FILESYSTEM* notification_fs = _store->getPrimaryFS();
if (!(mesh::notify::readSettings(notification_fs, "/notify_prefs", notification_settings)
&& _notifications.validSettings(notification_settings))) {
if (!(mesh::notify::readSettings(notification_fs, "/notify_prefs.bak", notification_settings)
&& _notifications.validSettings(notification_settings)))
mesh::notify::resetSettings(notification_settings);
}
#endif
// load persisted prefs
const bool prefs_ready =
_store->loadPrefs(_prefs, sensors.node_lat, sensors.node_lon);
#if MESH_HIL_COMPANION_STARTUP_FREQ_MHZ
// A role transition can expose old Companion preferences independently of
// the Repeater's saved radio. Keep this private image on its bench channel
// before saved parameters or automatic traffic can consume that frequency.
mesh::companion::applyHilStartupFrequency(_prefs.freq);
#endif
FILESYSTEM* battery_calibration_fs = _store->getPrimaryFS();
#if defined(NRF52_PLATFORM)
if (_store->isVolatilePrimaryFS()) battery_calibration_fs = nullptr;
#endif
if (!mesh::power::beginBatteryCalibrationStore(board, battery_calibration_fs)) {
MESH_DEBUG_PRINTLN("Battery calibration could not be restored; using board default");
}
#if defined(ESP32_PLATFORM) && defined(WIFI_SSID)
#ifdef ADMIN_PASSWORD
const char* management_default = ADMIN_PASSWORD;
#else
const char* management_default = "password";
#endif
_management_password_ready = mesh::esp32::companionAdminPassword::load(
_management_password, management_default);
if (_management_password_ready) {
_management_password_ready =
mesh::esp32::managementPassword::publish(_management_password);
}
if (!_management_password_ready) {
mesh::usbLoggingPort().println(
"Management password unavailable; use password <new-password> on USB to repair");
}
#endif
#if defined(RAK_4631)
// Only a verified image (or a fresh missing file) may permit GPS-only power.
// A failed load never replaces a stored shared policy with an unsafe cut.
if (!prefs_ready) _prefs.peripherals_shared = 1;
sensors.configureSharedPeripheralMode(_prefs.peripherals_shared != 0);
#endif
#if defined(NRF52_PLATFORM)
// A volatile primary filesystem is the final recovery mode after the
// physical medium has exhausted its delayed reads and destructive repair.
// Apply the diagnostic name before BLE, USB, MQTT, or adverts consume it.
if (_store->isVolatilePrimaryFS()) {
strncpy(_prefs.node_name, mesh::storage::BAD_FILESYSTEM_NODE_NAME,
sizeof(_prefs.node_name));
_prefs.node_name[sizeof(_prefs.node_name) - 1] = 0;
}
#endif
// v1.17.1.2 repairs the Companion default-off regression for both fresh
// installs and devices that already persisted the regressed value. The
// appended policy marker makes this a one-time migration, so a later
// explicit `powersaving off` choice remains persistent.
const bool power_saving_default_migrated =
migrateCompanionPowerSavingDefault(_prefs);
_prefs.node_name[sizeof(_prefs.node_name) - 1] = 0;
const bool bluetooth_name_had_terminator =
memchr(_prefs.bluetooth_name, 0, sizeof(_prefs.bluetooth_name)) != NULL;
_prefs.bluetooth_name[sizeof(_prefs.bluetooth_name) - 1] = 0;
const bool bluetooth_name_repaired =
!bluetooth_name_had_terminator
|| (_prefs.bluetooth_name[0] != 0
&& !mesh::companion::isValidBluetoothName(_prefs.bluetooth_name));
if (bluetooth_name_repaired) {
memset(_prefs.bluetooth_name, 0, sizeof(_prefs.bluetooth_name));
}
const bool display_rotation_repaired =
_prefs.display_rotation_degrees != 0
&& _prefs.display_rotation_degrees != 90
&& _prefs.display_rotation_degrees != 180
&& _prefs.display_rotation_degrees != 270;
if (display_rotation_repaired) {
_prefs.display_rotation_degrees = 0;
}
bool bluetooth_mac_repaired = false;
if (!mesh::companion::isValidBluetoothStealthMode(
_prefs.bluetooth_stealth_mode)) {
_prefs.bluetooth_stealth_mode = mesh::companion::BLUETOOTH_STEALTH_OFF;
bluetooth_mac_repaired = true;
}
if (!mesh::companion::isValidBluetoothMacMode(
_prefs.bluetooth_mac_mode)) {
_prefs.bluetooth_mac_mode =
mesh::companion::BLUETOOTH_MAC_DEFAULT;
memset(_prefs.bluetooth_mac, 0, sizeof(_prefs.bluetooth_mac));
clearCompanionBluetoothStealthPeer(_prefs);
bluetooth_mac_repaired = true;
} else if (mesh::companion::bluetoothMacModeUsesSavedAddress(
_prefs.bluetooth_mac_mode)
&& !mesh::companion::isValidBluetoothMac(
_prefs.bluetooth_mac)
&& !mesh::companion::bluetoothMacModeIsRandomAfterConnect(
_prefs.bluetooth_mac_mode)) {
_prefs.bluetooth_mac_mode =
mesh::companion::BLUETOOTH_MAC_DEFAULT;
memset(_prefs.bluetooth_mac, 0, sizeof(_prefs.bluetooth_mac));
clearCompanionBluetoothStealthPeer(_prefs);
bluetooth_mac_repaired = true;
}
mesh::companion::BluetoothPeerIdentity saved_stealth_peer;
saved_stealth_peer.type = _prefs.bluetooth_stealth_peer_type;
memcpy(saved_stealth_peer.address, _prefs.bluetooth_stealth_peer,
sizeof(saved_stealth_peer.address));
bool stealth_peer_payload_empty = true;
for (size_t i = 0; i < sizeof(_prefs.bluetooth_stealth_peer); i++) {
stealth_peer_payload_empty = stealth_peer_payload_empty
&& _prefs.bluetooth_stealth_peer[i] == 0;
}
if (_prefs.bluetooth_stealth_mode
== mesh::companion::BLUETOOTH_STEALTH_PAIRED
&& !mesh::companion::isValidBluetoothPeerIdentity(
saved_stealth_peer)) {
_prefs.bluetooth_stealth_mode = mesh::companion::BLUETOOTH_STEALTH_PAIRING;
bluetooth_mac_repaired = true;
}
if (_prefs.bluetooth_stealth_mode
!= mesh::companion::BLUETOOTH_STEALTH_PAIRED
&& (_prefs.bluetooth_stealth_peer_type
!= mesh::companion::BLUETOOTH_PEER_ADDRESS_NONE
|| !stealth_peer_payload_empty)) {
_prefs.bluetooth_stealth_peer_type =
mesh::companion::BLUETOOTH_PEER_ADDRESS_NONE;
memset(_prefs.bluetooth_stealth_peer, 0,
sizeof(_prefs.bluetooth_stealth_peer));
bluetooth_mac_repaired = true;
}
// sanitise bad pref values
_prefs.rx_delay_base = constrain(_prefs.rx_delay_base, 0, 20.0f);
if (!isfinite(_prefs.tx_delay_factor)) _prefs.tx_delay_factor = 0.5f;
if (!isfinite(_prefs.direct_tx_delay_factor)) _prefs.direct_tx_delay_factor = 0.2f;
_prefs.tx_delay_factor = constrain(_prefs.tx_delay_factor, 0, 2.0f);
_prefs.direct_tx_delay_factor = constrain(_prefs.direct_tx_delay_factor, 0, 2.0f);
if (!isfinite(_prefs.airtime_factor)) _prefs.airtime_factor = 1.0f;
// `set dutycycle 1` stores factor 99; the legacy `set af` input limit is
// narrower, but must not truncate an accepted duty cycle on restart.
_prefs.airtime_factor = constrain(_prefs.airtime_factor, 0, 99.0f);
_prefs.freq = constrain(_prefs.freq, 150.0f, 2500.0f);
_prefs.bw = constrain(_prefs.bw, 7.8f, 500.0f);
_prefs.sf = constrain(_prefs.sf, 5, 12);
_prefs.cr = constrain(_prefs.cr, 5, 8);
_prefs.tx_power_dbm = constrain(_prefs.tx_power_dbm, -9, MAX_LORA_TX_POWER);
_prefs.flood_retry_attempts = constrain(_prefs.flood_retry_attempts, 0, 15);
if (_prefs.flood_retry_max_path > 63
&& _prefs.flood_retry_max_path != FLOOD_RETRY_PATH_GATE_DISABLED) {
_prefs.flood_retry_max_path = 1;
}
if (_prefs.flood_retry_group_max_path > 63
&& _prefs.flood_retry_group_max_path != FLOOD_RETRY_PATH_GATE_DISABLED) {
_prefs.flood_retry_group_max_path = FLOOD_RETRY_PATH_GATE_DISABLED;
}
_prefs.flood_retry_advert_enabled = _prefs.flood_retry_advert_enabled ? 1 : 0;
_prefs.multi_acks = constrain(_prefs.multi_acks, 0, 1);
_prefs.manual_add_contacts = constrain(_prefs.manual_add_contacts, 0, 1);
_prefs.vibe_quiet = constrain(_prefs.vibe_quiet, 0, 1);
_prefs.gps_enabled = constrain(_prefs.gps_enabled, 0, 1); // Ensure boolean 0 or 1
_prefs.gps_interval = constrain(_prefs.gps_interval, 0, 86400); // Max 24 hours
_prefs.gps_sync_interval_hours = constrain(_prefs.gps_sync_interval_hours,
0, mesh::gps::MAX_SYNC_INTERVAL_HOURS);
_prefs.autoadd_config &= AUTO_ADD_OVERWRITE_OLDEST | AUTO_ADD_CHAT | AUTO_ADD_REPEATER | AUTO_ADD_ROOM_SERVER | AUTO_ADD_SENSOR;
_prefs.path_hash_mode = constrain(_prefs.path_hash_mode, 0, 2);
_prefs.radio_fem_rxgain_override = constrain(_prefs.radio_fem_rxgain_override, 0, 1);
if (!_prefs.radio_fem_rxgain_override) {
_prefs.radio_fem_rxgain = DEFAULT_FEM_RX_GAIN;
}
_prefs.radio_fem_rxgain = constrain(_prefs.radio_fem_rxgain, 0, 1);
_prefs.radio_fem_txgain = constrain(_prefs.radio_fem_txgain, 0, 1);
_prefs.rx_powersaving_enabled = constrain(_prefs.rx_powersaving_enabled, 0, 1);
_prefs.powersaving_enabled = constrain(_prefs.powersaving_enabled, 0, 1);
#if defined(ESP32_PLATFORM)
if (!mesh::esp32::cpuclock::validSelection(_prefs.cpu_freq_mhz)
|| (_prefs.cpu_freq_mhz && !board.supportsCpuFrequencyMHz(_prefs.cpu_freq_mhz)))
_prefs.cpu_freq_mhz = 0;
board.setCpuFrequencyRequest(_prefs.cpu_freq_mhz, _prefs.powersaving_enabled != 0);
#endif
_prefs.wifi_enabled = constrain(_prefs.wifi_enabled, 0, 1);
_prefs.usb_logging_enabled = constrain(_prefs.usb_logging_enabled, 0, 2);
_prefs.usb_debug_enabled = _prefs.usb_debug_enabled == 1 ? 1 : 0;
_prefs.cad_enabled = constrain(_prefs.cad_enabled, 0, 1);
if (_prefs.cad_scan_timeout_ms != 0
&& (_prefs.cad_scan_timeout_ms < mesh::CAD_SCAN_MIN_TIMEOUT_MS
|| _prefs.cad_scan_timeout_ms > mesh::CAD_SCAN_MAX_TIMEOUT_MS)) {
_prefs.cad_scan_timeout_ms = 0;
}
_prefs.rx_ps_level = constrain(_prefs.rx_ps_level, 0, 10);
if (_prefs.rx_ps_preamble != 16 && _prefs.rx_ps_preamble != 32) {
_prefs.rx_ps_preamble = 0;
}
ensureRxPowerSavingDefaults(&_prefs.rx_ps_rx_us, &_prefs.rx_ps_sleep_us);
recalcRxPowerSavingFromLevel(_prefs.rx_ps_level, _prefs.sf, _prefs.bw,
_prefs.rx_ps_preamble, &_prefs.rx_ps_rx_us,
&_prefs.rx_ps_sleep_us);
if (prefs_ready
&& (power_saving_default_migrated || bluetooth_name_repaired
|| display_rotation_repaired || bluetooth_mac_repaired)) {
_store->savePrefs(_prefs, sensors.node_lat, sensors.node_lon);
}
#if MESH_USB_LOGGING_AVAILABLE
const bool usb_logging_enabled = _prefs.usb_logging_enabled != 0;
mesh::configureUsbLoggingPacketStream(_prefs.usb_logging_enabled == 2);
mesh::setUsbDebugEnabled(_prefs.usb_debug_enabled != 0);
applyUsbLoggingState(usb_logging_enabled);
if (!mesh::saveUsbLoggingBootPreference(usb_logging_enabled)) {
MESH_DEBUG_PRINTLN("Unable to save next-boot USB logging interface state");
}
#endif
#ifdef BLE_PIN_CODE // 123456 by default
if (_prefs.ble_pin == 0) {
#ifdef DISPLAY_CLASS
if (has_display && BLE_PIN_CODE == 123456) {
StdRNG rng;
_active_ble_pin = rng.nextInt(100000, 999999); // random pin each session
} else {
_active_ble_pin = BLE_PIN_CODE; // otherwise static pin
}
#else
_active_ble_pin = BLE_PIN_CODE; // otherwise static pin
#endif
} else {
_active_ble_pin = _prefs.ble_pin;
}
#else
_active_ble_pin = 0;
#endif
resetContacts();
_store->loadContacts(this);
_store->recoverContacts(this);
if (_store->hasPendingContactWrites()) {
dirty_contacts_expiry = mesh::nonzeroLazyPersistenceDeadline(
futureMillis(LAZY_CONTACTS_WRITE_DELAY));
}
updateGpsTelemetryPolicy();
bootstrapRTCfromContacts();
addChannel("Public", PUBLIC_GROUP_PSK); // pre-configure Andy's public channel
if (is_new_install) {
addChannel("#testing", TESTING_GROUP_PSK);
}
// A saved table takes precedence over compiled defaults on later boots.
_store->loadChannels(this);
if (is_new_install) {
// Make the first-boot defaults survive before a companion app changes them.
_store->saveChannels(this);
}
#ifdef COMPANION_MESH_CLOCK_SYNC
// Fixed fallback policy for this Companion build. A successful host time
// update below suppresses mesh correction for the remainder of the boot.
_clock_sync.begin(nullptr);
#endif
configureRadioFromPrefs();
#if MESH_ENABLE_TX_BEACONS
beginBeacons();
#endif
#if MESH_ENABLE_ETHERNET_TUNNEL
beginEthernetTunnel();
#endif
#if MESH_ENABLE_MESHTEXT_CHAT
beginMeshtext();
#endif
#if defined(WITH_MQTT_BRIDGE) && defined(ESP32_PLATFORM) && defined(WIFI_SSID)
_mqtt_enabled = CompanionMqttSetupPortal::loadEnabled();
// Keep settings available to the USB CLI even when this boot selects BLE.
applyMQTTDefaults(&_mqtt_prefs);
if (!CompanionMqttSetupPortal::loadStoredConfig(_mqtt_prefs)) {
applyMQTTDefaults(&_mqtt_prefs);
}
_mqtt_configured = CompanionMqttSetupPortal::hasConfiguredSlot(_mqtt_prefs);
// Companion WiFi owns the connection and its mesh-wifi NVS setting is
// canonical. Keep MQTT reconnects from restoring a stale MQTT-pref value.
_mqtt_prefs.wifi_power_save = getCompanionWiFiPowerSave();
#if defined(COMPANION_EXCLUSIVE_WIFI_BLE)
if (_prefs.wifi_enabled != 0) {
#endif
MQTTNodeInfo node_info;
node_info.node_name = _prefs.node_name;
node_info.freq = &_prefs.freq;
node_info.bw = &_prefs.bw;
node_info.sf = &_prefs.sf;
node_info.cr = &_prefs.cr;
node_info.repeat_flag = &_prefs.client_repeat;
node_info.repeat_when_nonzero = true;
_mqtt_bridge = new MQTTBridge(node_info, &_mqtt_prefs,
getRTCClock(), &self_id, false);
if (_mqtt_bridge) {
char device_id[65];
mesh::Utils::toHex(device_id, self_id.pub_key, PUB_KEY_SIZE);
_mqtt_bridge->setDeviceID(device_id);
_mqtt_bridge->setFirmwareVersion(FIRMWARE_VERSION);
_mqtt_bridge->setBoardModel(board.getManufacturerName());
_mqtt_bridge->setBuildDate(FIRMWARE_BUILD_DATE);
_mqtt_bridge->setStatsSources(this, _radio, &board, _ms);
}
#if defined(COMPANION_EXCLUSIVE_WIFI_BLE)
}
#endif
#endif
#ifdef WITH_WEBCONFIG
#if defined(COMPANION_EXCLUSIVE_WIFI_BLE)
if (_prefs.wifi_enabled != 0) {
#endif
void* web_mqtt_prefs = nullptr;
#ifdef WITH_MQTT_BRIDGE
web_mqtt_prefs = &_mqtt_prefs;
#endif
_webconfig = new WebConfigServer(this, web_mqtt_prefs, false,
self_id.pub_key, FIRMWARE_VERSION,
FIRMWARE_BUILD_DATE,
"companion", board.getManufacturerName());
#if defined(COMPANION_EXCLUSIVE_WIFI_BLE)
}
#endif
#endif
}
void MyMesh::configureRadioFromPrefs() {
board.attachDynamicPrefs(_prefs.getCustom());
#if defined(WIO_E5_MINI_HEADLESS_COMPANION_UI)
// The reserved preference byte keeps the existing image and filesystem size.
_prefs.leds_quiet = _prefs.leds_quiet == 1 ? 1 : 0;
mesh::leds::setEnabled(!_prefs.leds_quiet);
board.applyLedPolicy();
mesh::leds::applyStatusPolicy();
#endif
if (!_radio_available) {
board.setLoRaFemLnaEnabled(_prefs.radio_fem_rxgain);
board.setLoRaFemPaGainEnabled(_prefs.radio_fem_txgain);
MESH_DEBUG_PRINTLN("Radio unavailable: companion services are running in recovery mode");
return;
}
saved_radio_apply_pending |= !_primary_radio_blocked && !applySavedRadioParams();
radio_driver.setCADScanTimeoutMillis(_prefs.cad_scan_timeout_ms);
_radio->setCADEnabled(_prefs.cad_enabled != 0);
if (!saved_radio_apply_pending) {
saved_radio_apply_pending = !radio_driver.setTxPower(_prefs.tx_power_dbm)
|| (radio_driver.supportsRxBoostedGainMode()
&& !radio_driver.setRxBoostedGainMode(_prefs.rx_boosted_gain));
}
const bool fem_gain_changed = board.canControlLoRaFemLna()
&& board.isLoRaFemLnaEnabled() != (_prefs.radio_fem_rxgain != 0);
if (board.setLoRaFemLnaEnabled(_prefs.radio_fem_rxgain) && fem_gain_changed) {
_radio->recalibrateNoiseFloor();
}
board.setLoRaFemPaGainEnabled(_prefs.radio_fem_txgain);
MESH_DEBUG_PRINTLN("RX Boosted Gain Mode: %s",
radio_driver.getRxBoostedGainMode() ? "Enabled" : "Disabled");
}
void MyMesh::activateRadio() {
if (_radio_available) return;
_radio_available = true;
setRadioAvailable(true);
configureRadioFromPrefs();
MESH_DEBUG_PRINTLN("Radio recovery completed; mesh transport is active");
}
mesh::RadioParamApplyResult MyMesh::tryApplyRadioParams(float freq, float bw, uint8_t sf, uint8_t cr, bool temporary, uint16_t preamble) {
if (!_radio_available) return mesh::RadioParamApplyResult::FAILED;
uint32_t rx_us = _prefs.rx_ps_rx_us;
uint32_t sleep_us = _prefs.rx_ps_sleep_us;
if (_prefs.rx_powersaving_enabled && _prefs.rx_ps_level != 0
&& !recalcRxPowerSavingFromLevel(_prefs.rx_ps_level, sf, bw,
_prefs.rx_ps_preamble, &rx_us, &sleep_us)) {
return mesh::RadioParamApplyResult::FAILED;
}
uint32_t timings[2] = {rx_us, sleep_us};
const uint32_t* applied_timings = _prefs.rx_powersaving_enabled
&& radio_driver.supportsRxPowerSaving() ? timings : NULL;
return _radio_profiles.applyPrimary(freq, bw, sf, cr, temporary,
temporary ? preamble : _radio_profiles.primaryPreamble(), applied_timings);
}
bool MyMesh::applySavedRadioParams() {
return tryApplyRadioParams(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr)
== mesh::RadioParamApplyResult::APPLIED;
}
__attribute__((noinline)) void MyMesh::queueSavedRadioApply() {
saved_radio_apply_pending = true;
radio_apply_retry_at = 0;
radio_apply_failures = 0;
}
void MyMesh::finishPrimaryRadioReply(bool delivered) {
_primary_radio_reply.clear();
if (delivered) {
_primary_radio_blocked = false;
queueSavedRadioApply();
}
}
MyMesh::RadioSettingResult MyMesh::applyAndSaveRadioParams(
float freq, float bw, uint8_t sf, uint8_t cr, uint8_t repeat, uint16_t preamble, bool remote) {
#if COMPANION_FEATURE_TEMP_RADIO
// A permanent remote change cannot own the acknowledgement and a lease's
// restoration at once. Local changes retain the existing deferred behavior.
if (remote && (_temp_radio_applied || _temp_radio_set_at || _temp_radio_revert_at))
return RadioSettingResult::RadioRejected;
#endif
if (freq < 150.0f || !CommonRadioPrefs::isValidBandwidth(bw)
|| !_radio_profiles.acceptsPrimary(freq, bw, sf, cr, preamble)
|| (repeat && !isValidClientRepeatFreq(uint32_t(freq * 1000.0f))))
return RadioSettingResult::RadioRejected;
uint32_t rx_us = _prefs.rx_ps_rx_us, sleep_us = _prefs.rx_ps_sleep_us;
if (_prefs.rx_ps_level
&& !recalcRxPowerSavingFromLevel(_prefs.rx_ps_level, sf, bw,
_prefs.rx_ps_preamble, &rx_us, &sleep_us)
&& _prefs.rx_powersaving_enabled) return RadioSettingResult::RadioRejected;
const uint16_t previous_preamble = _radio_profiles.primaryPreamble();
if (!_radio_profiles.savePrimaryPreamble(preamble)) return RadioSettingResult::SaveFailed;
if (remote && _primary_radio_blocked && freq == _prefs.freq && bw == _prefs.bw
&& sf == _prefs.sf && cr == _prefs.cr && repeat == _prefs.client_repeat
&& preamble == previous_preamble) {
// Replaying an unconfirmed request sends a fresh acknowledgement without
// saving again. Only this response's exact copies can release its target.
_primary_radio_reply.clear();
_primary_radio_reply_admission = true;
return RadioSettingResult::Saved;
}
const auto result = _radio_available && !remote && !_primary_radio_blocked
#if COMPANION_FEATURE_TEMP_RADIO
&& !_temp_radio_applied && !_temp_radio_set_at && !_temp_radio_revert_at
#endif
? tryApplyRadioParams(freq, bw, sf, cr) : mesh::RadioParamApplyResult::BUSY;
if (result == mesh::RadioParamApplyResult::FAILED) {
const bool restored = _radio_profiles.restorePrimaryPreamble(previous_preamble);
queueSavedRadioApply();
return restored ? RadioSettingResult::RadioRejected : RadioSettingResult::SaveUnconfirmed;
}
const float previous_freq = _prefs.freq, previous_bw = _prefs.bw;
const uint8_t previous_sf = _prefs.sf, previous_cr = _prefs.cr;
const uint8_t previous_repeat = _prefs.client_repeat;
const uint32_t previous_rx = _prefs.rx_ps_rx_us, previous_sleep = _prefs.rx_ps_sleep_us;
_prefs.freq = freq; _prefs.bw = bw; _prefs.sf = sf; _prefs.cr = cr;
_prefs.client_repeat = repeat;
_prefs.rx_ps_rx_us = rx_us; _prefs.rx_ps_sleep_us = sleep_us;
if (savePrefs()) {
_primary_radio_reply.clear();
_primary_radio_blocked = remote;
_primary_radio_reply_admission = remote;
if (result == mesh::RadioParamApplyResult::BUSY) queueSavedRadioApply();
return RadioSettingResult::Saved;
}
_prefs.freq = previous_freq; _prefs.bw = previous_bw;
_prefs.sf = previous_sf; _prefs.cr = previous_cr;
_prefs.client_repeat = previous_repeat;
_prefs.rx_ps_rx_us = previous_rx; _prefs.rx_ps_sleep_us = previous_sleep;
const bool preamble_restored = _radio_profiles.restorePrimaryPreamble(previous_preamble);
if ((result == mesh::RadioParamApplyResult::APPLIED && !applySavedRadioParams())
|| !preamble_restored) queueSavedRadioApply();
return preamble_restored ? RadioSettingResult::SaveFailed : RadioSettingResult::SaveUnconfirmed;
}
bool MyMesh::applyRxPowerSavingRfRxDisabled(const char* value, char* reply, size_t capacity) {
if (strcmp(value, "on") && strcmp(value, "off")) {
mesh::cli::formatError(reply, capacity, "use set radio.rxps.rfrx_disabled on|off");
} else if (!radio_driver.supportsRxPowerSavingRfRxDisable()) {
mesh::cli::formatError(reply, capacity, "unsupported");
} else {
const bool disabled = !strcmp(value, "on");
const auto result = _radio_available
? radio_driver.trySetRxPowerSavingRfRxDisabled(disabled) : mesh::RadioParamApplyResult::BUSY;
if (result == mesh::RadioParamApplyResult::FAILED) {
mesh::cli::formatError(reply, capacity, "RXEN change failed");
} else {
rf_rx_disable_pending = result == mesh::RadioParamApplyResult::BUSY ? int8_t(disabled) : -1;
rf_rx_retry_at = 0;
snprintf(reply, capacity, "OK");
}
}
return true;
}
void MyMesh::servicePendingLocalRadioSettings() {
_radio_profiles.servicePendingTuning();
servicePendingTxPower();
if (rf_rx_disable_pending >= 0 && _radio_available
&& !(_radio && _radio->isCarrierWaveActive())
&& (!rf_rx_retry_at || millisHasNowPassed(rf_rx_retry_at))) {
const auto result = radio_driver.trySetRxPowerSavingRfRxDisabled(rf_rx_disable_pending != 0);
if (result == mesh::RadioParamApplyResult::APPLIED) rf_rx_disable_pending = -1;
rf_rx_retry_at = result == mesh::RadioParamApplyResult::FAILED ? futureMillis(1000) : 0;
if (result == mesh::RadioParamApplyResult::FAILED && !rf_rx_retry_at) rf_rx_retry_at = 1;
}
if (!saved_radio_apply_pending || !_radio_available
|| (_radio && (_radio->isCarrierWaveActive()
|| _radio->isMorseTxActive() || _radio->isMorseRxActive()))
#if COMPANION_FEATURE_TEMP_RADIO
|| _temp_radio_applied || _temp_radio_set_at || _temp_radio_revert_at
#endif
|| (radio_apply_retry_at && !millisHasNowPassed(radio_apply_retry_at))) return;
// Driver admission preserves unread packets. This also runs in the idle
// gap before Dispatcher starts the next packet of a saturated TX queue.
const auto gain = radio_driver.supportsRxBoostedGainMode()
? radio_driver.trySetRxBoostedGainMode(_prefs.rx_boosted_gain)
: mesh::RadioParamApplyResult::APPLIED;
const auto tuple = !_primary_radio_blocked && gain == mesh::RadioParamApplyResult::APPLIED
? tryApplyRadioParams(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr) : gain;
const auto power = tuple == mesh::RadioParamApplyResult::APPLIED
? radio_driver.trySetTxPower(_prefs.tx_power_dbm) : tuple;
uint32_t rx_us = _prefs.rx_ps_rx_us, sleep_us = _prefs.rx_ps_sleep_us;
if (_primary_radio_blocked && _prefs.rx_ps_level && _radio && _radio->profiles()) {
const auto& active = _radio->profiles()->primary;
recalcRxPowerSavingFromLevel(_prefs.rx_ps_level, active.sf, active.bw,
_prefs.rx_ps_preamble, &rx_us, &sleep_us);
}
const auto result = power == mesh::RadioParamApplyResult::APPLIED
&& radio_driver.supportsRxPowerSaving()
? radio_driver.trySetRxPowerSaving(_prefs.rx_powersaving_enabled != 0,
rx_us, sleep_us) : power;
if (result == mesh::RadioParamApplyResult::APPLIED) {
saved_radio_apply_pending = false;
radio_apply_retry_at = 0;
radio_apply_failures = 0;
} else if (result == mesh::RadioParamApplyResult::FAILED) {
radio_apply_retry_at = futureMillis(nextRadioApplyRetryDelay(radio_apply_failures));
if (!radio_apply_retry_at) radio_apply_retry_at = 1;
}
}
#if COMPANION_FEATURE_TEMP_RADIO
static bool isFullCompanionBandwidth(float bw) {
static const float supported[] = {
7.8f, 10.4f, 15.6f, 20.8f, 31.25f, 41.7f,
62.5f, 125.0f, 250.0f, 500.0f
};
for (float candidate : supported) {
if (fabsf(candidate - bw) <= 0.01f) return true;
}
return false;
}
bool MyMesh::scheduleTempRadio(float freq, float bw, uint8_t sf, uint8_t cr,
uint32_t timeout_mins, char* reply,
size_t reply_size) {
if (_primary_radio_blocked) {
mesh::cli::formatError(reply, reply_size, "radio acknowledgement pending");
return false;
}
#if defined(OTA_SHARED_COMPANION_QUEUE)
uint16_t drained = 0;
if (!mesh::ota::ota_acquire_context(reply, reply_size, true, &drained)) return false;
mesh::ota::ota_ctx().release_when_idle = false;
#endif
_temp_radio_freq = freq;
_temp_radio_bw = bw;
_temp_radio_sf = sf;
_temp_radio_cr = cr;
_temp_radio_set_at = futureMillis(1500); // let the local reply drain first
_temp_radio_revert_at = futureMillis(1500 + (int)(timeout_mins * 60000UL));
_temp_radio_retry_at = 0;
_temp_radio_failures = 0;
snprintf(reply, reply_size, "OK - temp params for %lu mins",
(unsigned long)timeout_mins);
appendRxPowerSavingAdjustmentNote(reply, reply_size, sf, bw);
#if defined(OTA_SHARED_COMPANION_QUEUE)
if (drained) {
size_t const used = strlen(reply);
snprintf(reply + used, reply_size - used, "; OTA cleared %u oldest queued messages",
(unsigned)drained);
}
#endif
return true;
}
void MyMesh::scheduleNormalRadio(char* reply, size_t reply_size) {
if (_primary_radio_blocked) {
mesh::cli::formatError(reply, reply_size, "radio acknowledgement pending");
return;
}
_temp_radio_set_at = 0;
_temp_radio_revert_at = futureMillis(1500); // keep the reply on the active tuple
_temp_radio_retry_at = 0;
_temp_radio_failures = 0;
snprintf(reply, reply_size, "OK - normal radio restore scheduled");
}
#endif
static bool parseCadTimingMillis(const char* text, uint32_t minimum,
uint32_t maximum, uint16_t& result) {
if (strcmp(text, "auto") == 0 || strcmp(text, "0") == 0) {
result = 0;
return true;
}
uint32_t parsed = 0;
if (!mesh::cli::parseUnsignedIntegerStrict(text, parsed)
|| parsed < minimum || parsed > maximum) {
return false;
}
result = (uint16_t)parsed;
return true;
}
bool MyMesh::handleCadCommand(const char* command, char* reply,
size_t reply_size) {
if (command == NULL || reply == NULL || reply_size == 0) return false;
if (strcmp(command, "get radio.cad") == 0
|| strcmp(command, "get cad") == 0) {
char scan[32];
char retry[24];
char maximum[24];
if (_prefs.cad_scan_timeout_ms == 0) {
uint32_t effective = _radio_available
? radio_driver.getCADScanTimeoutMillis() : 0;
if (effective == 0) {
effective = mesh::calculateCadScanTimeoutMillis(_prefs.sf, _prefs.bw);
}
snprintf(scan, sizeof(scan), "auto(%lu)", (unsigned long)effective);
} else {
snprintf(scan, sizeof(scan), "%u", (unsigned)_prefs.cad_scan_timeout_ms);
}
if (_prefs.cad_retry_delay_ms == 0) {
snprintf(retry, sizeof(retry), "auto");
} else {
snprintf(retry, sizeof(retry), "%u", (unsigned)_prefs.cad_retry_delay_ms);
}
if (_prefs.cad_max_duration_ms == 0) {
snprintf(maximum, sizeof(maximum), "auto");
} else {
snprintf(maximum, sizeof(maximum), "%u", (unsigned)_prefs.cad_max_duration_ms);
}
mesh::companion::formatCadDiagnostics(reply, reply_size,
_prefs.cad_enabled != 0, scan, retry, maximum, board.n_cad_busy);
return true;
}
const char* value = NULL;
if (strncmp(command, "set radio.cad", 13) == 0
&& (command[13] == 0 || command[13] == ' '
|| command[13] == '\t')) {
value = command + 13;
} else if (strncmp(command, "set cad", 7) == 0
&& (command[7] == 0 || command[7] == ' '
|| command[7] == '\t')) {
value = command + 7;
} else {
return false;
}
while (*value == ' ' || *value == '\t') value++;
if (strcmp(value, "on") == 0 || strcmp(value, "off") == 0) {
const uint8_t previous = _prefs.cad_enabled;
_prefs.cad_enabled = strcmp(value, "on") == 0 ? 1 : 0;
if (_radio_available) _radio->setCADEnabled(_prefs.cad_enabled != 0);
if (!savePrefs()) {
_prefs.cad_enabled = previous;
if (_radio_available) _radio->setCADEnabled(previous != 0);
mesh::cli::formatError(reply, reply_size, "CAD changed but save failed");
} else {
snprintf(reply, reply_size, "OK - radio.cad %s", value);
}
return true;
}
if (strncmp(value, "timings", 7) == 0
&& (value[7] == 0 || value[7] == ' ' || value[7] == '\t')) {
value += 7;
while (*value == ' ' || *value == '\t') value++;
char token_storage[64] = {0};
const char* tokens[3] = {NULL};
size_t token_count = 0;
uint16_t scan_ms;
uint16_t retry_ms;
uint16_t max_ms;
if (!mesh::cli::splitWhitespaceFieldsStrict(
value, token_storage, sizeof(token_storage), tokens, 3,
token_count)
|| token_count != 3
|| !parseCadTimingMillis(tokens[0],
mesh::CAD_SCAN_MIN_TIMEOUT_MS,
mesh::CAD_SCAN_MAX_TIMEOUT_MS, scan_ms)
|| !parseCadTimingMillis(tokens[1], 1, 60000, retry_ms)
|| !parseCadTimingMillis(tokens[2], 1, 60000, max_ms)) {
mesh::cli::formatError(reply, reply_size, "use set radio.cad timings <auto|100-3500> <auto|1-60000> <auto|1-60000>");
return true;
}
const uint16_t previous_scan = _prefs.cad_scan_timeout_ms;
const uint16_t previous_retry = _prefs.cad_retry_delay_ms;
const uint16_t previous_max = _prefs.cad_max_duration_ms;
if (_radio_available && !radio_driver.setCADScanTimeoutMillis(scan_ms)) {
mesh::cli::formatError(reply, reply_size, "CAD scan timing is unsupported");
return true;
}
_prefs.cad_scan_timeout_ms = scan_ms;
_prefs.cad_retry_delay_ms = retry_ms;
_prefs.cad_max_duration_ms = max_ms;
if (!savePrefs()) {
_prefs.cad_scan_timeout_ms = previous_scan;
_prefs.cad_retry_delay_ms = previous_retry;
_prefs.cad_max_duration_ms = previous_max;
if (_radio_available) radio_driver.setCADScanTimeoutMillis(previous_scan);
mesh::cli::formatError(reply, reply_size, "CAD timings changed but save failed");
} else {
snprintf(reply, reply_size, "OK - radio.cad timings saved");
}
return true;
}
mesh::cli::formatError(reply, reply_size, "use set radio.cad <on|off> or set radio.cad timings <scan_ms|auto> <retry_ms|auto> <max_ms|auto>");
return true;
}
bool MyMesh::handleTrafficRoutingCommand(const char* command, char* reply, size_t reply_size) {
if (!reply || !reply_size) return false;
const auto parsed = mesh::companion::parseTrafficRouteCommand(command);
if (!parsed.matched) return false;
const char* kind = parsed.kind == mesh::companion::TrafficKind::Media ? "media" : "ssh";
if (!parsed.valid) {
snprintf(reply, reply_size, "Error: use get tx.%s or set tx.%s auto|radio|radio2|both|off", kind, kind);
return true;
}
uint8_t& policy = parsed.kind == mesh::companion::TrafficKind::Media ? _prefs.tx_media : _prefs.tx_ssh;
if (parsed.set) {
const uint8_t previous = policy;
policy = parsed.policy;
// Even an unchanged value must have durable storage before we say saved.
if (!savePrefs()) {
policy = previous;
snprintf(reply, reply_size, "Error: tx.%s was not saved", kind);
return true;
}
}
const uint8_t effective = mesh::companion::trafficPolicy(parsed.kind,
_prefs.tx_media, _prefs.tx_ssh, _radio->profiles());
const uint8_t mask = mesh::companion::trafficTransmitMask(effective, _radio->profiles());
snprintf(reply, reply_size, "%s tx.%s %s; active=%s%s", parsed.set ? "OK -" : ">", kind,
mesh::radioTxPolicyName(policy), effective == mesh::RADIO_TX_AUTO ? "channel/global"
: mask ? mesh::radioTxPolicyName(mask) : "off",
(policy == mesh::RADIO_TX_SECONDARY || policy == mesh::RADIO_TX_BOTH) && !(mask & 2)
? " (radio2 unavailable for TX)" : "");
return true;
}
bool MyMesh::sendClientGroupData(mesh::GroupChannel& channel, uint8_t* path, uint8_t path_len,
uint16_t data_type, const uint8_t* data, int data_len) {
if (data_len < 0 || data_len > MAX_GROUP_DATA_LENGTH || (data_len && !data)) return false;
const uint8_t policy = mesh::companion::trafficPolicy(mesh::companion::groupTrafficKind(data_type),
_prefs.tx_media, _prefs.tx_ssh, _radio->profiles());
if (policy == mesh::RADIO_TX_AUTO) return sendGroupData(channel, path, path_len, data_type, data, data_len);
// Override only this datagram, not the saved channel, PSK, alias or text route.
mesh::GroupChannel routed = channel;
routed.tx_radio = policy;
return sendGroupData(routed, path, path_len, data_type, data, data_len);
}
bool MyMesh::sendClientRawData(const uint8_t* data, size_t data_len, uint8_t* path, uint8_t path_len) {
if (!data || data_len < 4) return false;
auto pkt = createRawData(data, data_len);
if (!pkt) return false;
const uint8_t policy = mesh::companion::trafficPolicy(mesh::companion::rawTrafficKind(data, data_len),
_prefs.tx_media, _prefs.tx_ssh, _radio->profiles());
if (policy != mesh::RADIO_TX_AUTO) pkt->tx_radio = policy;
// sendDirect owns/releases the packet on either success or failure.
return sendDirect(pkt, path, path_len);
}
bool MyMesh::handleTxRoutingCommand(const char* command, char* reply, size_t reply_size) {
const auto parsed = mesh::companion::parseTxRouteCommand(command);
if (!parsed.matched) return false;
const char* kind = parsed.channel ? "channel" : "user";
if (!parsed.valid) {
snprintf(reply, reply_size, "Error: use get/set tx.%s <name|%s> [auto|radio|radio2|both|off]",
kind, parsed.channel ? "index" : "key:hex");
return true;
}
if (parsed.set && (!canMutateContacts()
|| (parsed.channel && _store->hasIncompleteChannelLoad()))) {
mesh::cli::formatError(reply, reply_size, "storage unavailable; TX routing not saved");
return true;
}
ContactInfo* contact = nullptr;
ChannelDetails channel;
int channel_index = -1;
unsigned matches = 0, overrides = 0;
if (parsed.channel) {
uint32_t index = 0;
const bool by_index = !parsed.quoted && mesh::cli::parseUnsignedIntegerStrict(parsed.target, index);
for (int i = 0; i < MAX_GROUP_CHANNELS; ++i) {
ChannelDetails candidate;
if (!getChannel(i, candidate) || !candidate.name[0]) continue;
if (candidate.channel.tx_radio != mesh::RADIO_TX_AUTO) ++overrides;
if (by_index ? index == (uint32_t)i : !strcmp(candidate.name, parsed.target)) {
channel = candidate; channel_index = i; ++matches;
}
}
} else {
uint8_t key[32]; size_t key_bytes = 0;
const bool by_key = !parsed.quoted && !strncmp(parsed.target, "key:", 4);
if (by_key && !mesh::companion::parseTxRouteKey(parsed.target + 4, key, key_bytes)) {
mesh::cli::formatError(reply, reply_size, "key: needs 12-64 hexadecimal digits (whole bytes)");
return true;
}
for (int i = MAX_ANON_CONTACTS; i < getTotalContactSlots(); ++i) {
ContactInfo* candidate = getContactPtrByIdx(i);
if (!candidate || candidate->type == ADV_TYPE_NONE) continue;
if (candidate->tx_radio != mesh::RADIO_TX_AUTO) ++overrides;
if (by_key ? !memcmp(candidate->id.pub_key, key, key_bytes) : !strcmp(candidate->name, parsed.target)) {
contact = candidate; ++matches;
}
}
}
if (!parsed.target[0]) {
snprintf(reply, reply_size, "> tx.%s: %u overrides; use get tx.%s <name|%s>",
kind, overrides, kind, parsed.channel ? "index" : "key:hex");
return true;
}
if (matches != 1) {
snprintf(reply, reply_size, "Error: %s %s%s", kind, matches ? "is ambiguous" : "not found",
matches ? (parsed.channel ? "; use an index" : "; use a longer key: prefix") : "");
return true;
}
uint8_t policy = parsed.channel ? channel.channel.tx_radio : contact->tx_radio;
if (parsed.set) {
bool saved = true;
if (policy == parsed.policy) {
// A prior recovery may have restored older disk data. Save the raw
// table without mutating a resolved alias or its runtime generation.
if (parsed.channel) saved = saveChannels();
} else if (parsed.channel) {
const auto alias = parseCompanionChannelAlias(channel.name);
if (isAliasRadio2Defined()
&& (alias.kind == CompanionChannelKind::Radio2 || alias.kind == CompanionChannelKind::Meshtext)) {
mesh::cli::formatError(reply, reply_size, "prefix controls routing; rename channel");
return true;
}
// Persist the app PSK, not the resolved wire-key copy used for reporting.
getChannelForSave(channel_index, channel);
const ChannelDetails previous = channel;
channel.channel.tx_radio = parsed.policy;
saved = setChannel(channel_index, channel) && saveChannels();
if (!saved) setChannel(channel_index, previous);
} else {
contact->tx_radio = parsed.policy;
saved = scheduleContactWrite(*contact) && flushContactsBeforeReboot();
if (!saved) {
contact->tx_radio = policy;
scheduleContactWrite(*contact);
}
}
if (!saved) {
mesh::cli::formatError(reply, reply_size, "storage unavailable; TX routing not saved");
return true;
}
policy = parsed.policy;
}
const auto* profiles = _radio->profiles();
const uint8_t mask = policy == mesh::RADIO_TX_AUTO ? (profiles ? profiles->transmitMask(0) : 1)
: mesh::explicitRadioTxMask(policy, profiles && profiles->canTransmit(1));
const char* active = mask ? mesh::radioTxPolicyName(mask) : "off";
char label[80];
if (parsed.channel) snprintf(label, sizeof(label), "%d \"%s\"", channel_index, channel.name);
else {
char key_prefix[13]; mesh::Utils::toHex(key_prefix, contact->id.pub_key, 6);
snprintf(label, sizeof(label), "\"%s\" key:%s", contact->name, key_prefix);
}
snprintf(reply, reply_size, "%s tx.%s %s %s; active=%s%s",
parsed.set ? "OK -" : ">", kind, label, mesh::radioTxPolicyName(policy), active,
(policy == mesh::RADIO_TX_SECONDARY || policy == mesh::RADIO_TX_BOTH) && !(mask & 2)
? " (radio2 unavailable for TX)" : "");
return true;
}
bool MyMesh::handleLocalControlCommand(const char* command, char* reply,
size_t reply_size) {
if (!command || !reply || reply_size == 0) return false;
while (*command == ' ') command++;
#if defined(ESP32_PLATFORM) && defined(WIFI_SSID)
if (strncmp(command, "password", 8) == 0
&& (command[8] == 0 || command[8] == ' ' || command[8] == '\t')) {
const char* value = command + 8;
// Consume the command separator only. Extra spaces belong to the secret,
// exactly as in infrastructure CLI and browser password forms.
if (*value == ' ' || *value == '\t') ++value;
if (!mesh::esp32::managementPassword::validPassword(value)) {
mesh::cli::formatError(reply, reply_size, "password must be 1-15 bytes, no line breaks; use 8-15 ASCII for setup WiFi");
return true;
}
mesh::esp32::managementPassword::ChangeGuard change;
if (!change.acquired()) {
mesh::cli::formatError(reply, reply_size, "OTA upload or password change busy; retry");
return true;
}
if (!mesh::esp32::companionAdminPassword::save(value)) {
mesh::cli::formatError(reply, reply_size, "password save unconfirmed; reboot may differ");
return true;
}
memcpy(_management_password, value, strlen(value) + 1);
_management_password_ready = change.publish(_management_password);
if (!_management_password_ready) {
mesh::cli::formatError(reply, reply_size, "password saved; management unavailable, reboot required");
} else {
// The browser batch recognizes this established success prefix and
// removes the secret before publishing its result.
snprintf(reply, reply_size, "password now: %s", value);
}
return true;
}
#endif
if (mesh::powerdiag::handleCommand(command, reply, reply_size, millis())) return true;
if (mesh::companion::handlePowerDiagnosticsCommand(board, command, reply, reply_size)) return true;
#if defined(ESP32_PLATFORM)
if (mesh::esp32::cpuclock::handleCommand(board, _prefs.cpu_freq_mhz,
_prefs.powersaving_enabled != 0, command, reply, reply_size,
[this]() { return savePrefs(); })) return true;
if (mesh::esp32::handlePowerTestCommand(command, reply, reply_size, true)) return true;
#endif
#if defined(NRF52_PLATFORM) && defined(OTA_SD_STORE)
if (mesh::cli::handleSdCardCommand(_sdcard_cli, board, command, reply, reply_size)) return true;
#endif
#if defined(OTA_RAK15002_SD_STORE) && defined(ETHERNET_USE_RAK13800)
if (handleRak4631PeripheralCommand(command, reply, reply_size)) return true;
#endif
#if defined(MESH_HIL_TX_FREQ_MHZ)
if (mesh::handleHilTxFrequencyCommand(command, reply, reply_size)) return true;
#endif
#if defined(RAK_4631)
// This helper is also reached only after normal remote-admin admission.
if (mesh::peripherals::handleCommand(_prefs.peripherals_shared, command,
reply, reply_size, sensors.supportsSharedPeripheralMode(),
[this]() { return savePrefs(); })) return true;
#else
if (strcmp(command, "get peripherals.mode") == 0
|| (strncmp(command, "set peripherals.mode", 20) == 0
&& (command[20] == 0 || command[20] == ' ' || command[20] == '\t'))) {
mesh::cli::formatError(reply, reply_size, "unsupported");
return true;
}
#endif
if (strcmp(command, "get public.key") == 0) {
char public_key_hex[PUB_KEY_SIZE * 2 + 1];
mesh::Utils::toHex(public_key_hex, self_id.pub_key, PUB_KEY_SIZE);
snprintf(reply, reply_size, "> %s", public_key_hex);
return true;
}
#if MESH_ENABLE_ETHERNET_TUNNEL
if (handleEthernetTunnelCommand(command, reply, reply_size)) return true;
#endif
if (handleTrafficRoutingCommand(command, reply, reply_size)) return true;
if (handleTxRoutingCommand(command, reply, reply_size)) return true;
#if MESH_ENABLE_TX_BEACONS
if (handleBeaconCommand(command, reply, reply_size)) return true;
#endif
#if MESH_ENABLE_MESHTEXT_CHAT
if (handleMeshtextCommand(command, reply, reply_size)) return true;
#endif
if (mesh::handleUsbLoggingWatchdogCommand(command, reply, reply_size)) return true;
#if COMPANION_FEATURE_OTA_CLI
const auto* ota_context = mesh::ota::ota_context_if_active();
const float adaptive_ota_pace = ota_context
? ota_context->manager.adaptivePacketSpeed() : mesh::ota::OTA_SPEED_DEFAULT;
if (mesh::ota::handleSpeedCommand(command, reply, reply_size, adaptive_ota_pace)) return true;
#endif
if (handleCompanionBluetoothCommand(command, reply, reply_size)) return true;
if (handleCompanionWirelessCommand(command, reply, reply_size)) return true;
if (handleRxPowerSavingGetCommand(command, reply, reply_size)) return true;
if (strncmp(command, "set radio.rxps", 14) == 0
&& (command[14] == 0 || command[14] == ' ' || command[14] == '\t')) {
const char* value = command + 14;
while (*value == ' ' || *value == '\t') ++value;
char result[160];
applyAndSaveRxPowerSaving(value, result);
snprintf(reply, reply_size, "%s", result);
return true;
}
static const char rf_rx_key[] = "set radio.rxps.rfrx_disabled";
if (!strncmp(command, rf_rx_key, sizeof(rf_rx_key) - 1)
&& (!command[sizeof(rf_rx_key) - 1] || command[sizeof(rf_rx_key) - 1] == ' '
|| command[sizeof(rf_rx_key) - 1] == '\t')) {
const char* value = command + sizeof(rf_rx_key) - 1;
while (*value == ' ' || *value == '\t') ++value;
return applyRxPowerSavingRfRxDisabled(value, reply, reply_size);
}
if (mesh::companion::handleRadioDiagnosticsCommand<false>(
radio_driver, _radio_available, command, reply, reply_size, [this]() {
radio_driver.resetStats();
this->resetStats();
static_cast<SimpleMeshTables*>(getTables())->resetStats();
})) return true;
if (strcmp(command, "get powersaving") == 0 || strcmp(command, "powersaving") == 0) {
snprintf(reply, reply_size, "> %s", _prefs.powersaving_enabled ? "on" : "off");
return true;
}
const char* power_value = nullptr;
if (strncmp(command, "set powersaving ", 16) == 0) power_value = command + 16;
else if (strncmp(command, "powersaving ", 12) == 0) power_value = command + 12;
if (power_value) {
char result[160] = {};
applyAndSavePowerSaving(power_value, result);
snprintf(reply, reply_size, "%s", result);
return true;
}
#if ENV_INCLUDE_GPS == 1
if (strcmp(command, "get gps.sync.interval") == 0) {
auto* provider = sensors.getLocationProvider();
if (provider == nullptr) {
mesh::cli::formatError(reply, reply_size, "GPS unavailable");
} else {
const uint16_t hours = provider->getTimeSyncIntervalHours();
if (hours == 0) snprintf(reply, reply_size, "> default (board GPS sync policy)");
else snprintf(reply, reply_size, "> %u hours", (unsigned)hours);
}
return true;
}
const char* gps_sync_value = nullptr;
if (strncmp(command, "set gps.sync.interval", 21) == 0
&& (command[21] == 0 || command[21] == ' ' || command[21] == '\t')) {
gps_sync_value = command + 21;
while (*gps_sync_value == ' ' || *gps_sync_value == '\t') ++gps_sync_value;
}
if (gps_sync_value) {
uint16_t hours = 0;
if (!mesh::gps::parseSyncIntervalHours(gps_sync_value, hours)) {
mesh::cli::formatError(reply, reply_size, "GPS sync interval must be 1..336 hours");
} else if (sensors.getLocationProvider() == nullptr) {
mesh::cli::formatError(reply, reply_size, "GPS unavailable");
} else if (!savePreference(_prefs.gps_sync_interval_hours, hours)) {
mesh::cli::formatError(reply, reply_size, "GPS sync interval could not be saved");
} else {
sensors.applyGpsTimeSyncInterval(hours);
snprintf(reply, reply_size, "OK - GPS sync interval %u hours (saved)", (unsigned)hours);
}
return true;
}
if (strcmp(command, "get gps") == 0) {
const char* gps = sensors.getSettingByKey("gps");
snprintf(reply, reply_size, gps ? "> %s" : "Error: GPS unavailable",
gps && gps[0] == '1' ? "on" : "off");
return true;
}
const char* gps_value = nullptr;
if (strncmp(command, "set gps ", 8) == 0) gps_value = command + 8;
if (gps_value) {
if (strcmp(gps_value, "on") != 0 && strcmp(gps_value, "off") != 0) {
mesh::cli::formatError(reply, reply_size, "use set gps on|off");
return true;
}
const bool enabled = strcmp(gps_value, "on") == 0;
if (sensors.getSettingByKey("gps") == nullptr) {
mesh::cli::formatError(reply, reply_size, "GPS unavailable");
} else {
if (setGpsEnabled(enabled)) {
snprintf(reply, reply_size, "OK - GPS %s (saved)", enabled ? "on" : "off");
} else {
mesh::cli::formatError(reply, reply_size, "GPS could not be saved");
}
}
return true;
}
#endif
#if defined(ESP32_PLATFORM) && defined(COMPANION_RADIO_FULL)
if (strcmp(command, "start ota") == 0
|| strcmp(command, "start ota ap") == 0) {
#if defined(COMPANION_EXCLUSIVE_WIFI_BLE)
if (!isCompanionWiFiEnabled()) {
snprintf(reply, reply_size,
"ERR: select WiFi transport and reboot before starting WiFi OTA");
return true;
}
#endif
// Board implementations use the common 160-byte CLI reply contract.
// Binary Companion callers may supply a shorter buffer.
char ota_reply[160] = {0};
bool webconfig_stopped = false;
#if defined(ESP32_PLATFORM) && defined(WIFI_SSID)
if (!_management_password_ready
|| !mesh::esp32::managementPassword::publish(_management_password)) {
mesh::cli::formatError(reply, reply_size, "management password unavailable; set password on USB");
return true;
}
#endif
#ifdef WITH_WEBCONFIG
if (isWebConfigActiveOrStopping()) {
if (!_webconfig->stopForOTA(ota_reply)) {
snprintf(reply, reply_size, "%s", ota_reply);
return true;
}
webconfig_stopped = true;
ota_reply[0] = 0;
}
#endif
if (!board.startOTAUpdate(_prefs.node_name, ota_reply,
strcmp(command, "start ota ap") == 0)) {
if (!ota_reply[0]) strcpy(ota_reply, "ERR: WiFi OTA unavailable");
}
if (webconfig_stopped) {
const size_t used = strlen(ota_reply);
snprintf(ota_reply + used, sizeof(ota_reply) - used, "; WebConfig stopped");
}
snprintf(reply, reply_size, "%s", ota_reply);
return true;
}
if (strcmp(command, "stop ota") == 0) {
char ota_reply[160] = {0};
if (!board.stopOTAUpdate(ota_reply) && !ota_reply[0]) {
snprintf(ota_reply, sizeof(ota_reply), "ERR: WiFi OTA unavailable");
}
snprintf(reply, reply_size, "%s", ota_reply);
return true;
}
#endif
#if MESH_USB_LOGGING_AVAILABLE
if (strcmp(command, "get usb.debug") == 0) {
snprintf(reply, reply_size, "usb.debug %s%s",
mesh::isUsbDebugEnabled() ? "on" : "off",
#if MESH_DEBUG
mesh::isUsbLoggingEnabled() ? "" : " (USB logging off)"
#else
" (core debug not compiled)"
#endif
);
return true;
}
if (strncmp(command, "set usb.debug", 13) == 0
&& (command[13] == 0 || command[13] == ' ' || command[13] == '\t')) {
const char* value = command + 13;
while (*value == ' ' || *value == '\t') value++;
if (strcmp(value, "on") != 0 && strcmp(value, "off") != 0) {
mesh::cli::formatError(reply, reply_size, "use set usb.debug <on|off>");
} else {
const uint8_t enabled = strcmp(value, "on") == 0 ? 1 : 0;
if (!savePreference(_prefs.usb_debug_enabled, enabled)) {
mesh::cli::formatError(reply, reply_size, "USB debug setting could not be saved");
} else {
snprintf(reply, reply_size, "OK - USB debug %s (saved)%s",
enabled ? "on" : "off",
#if MESH_DEBUG
""
#else
" (core debug not compiled)"
#endif
);
}
}
return true;
}
if (strcmp(command, "get usb.logging") == 0) {
snprintf(reply, reply_size, "usb.logging %s; port: %s%s",
mesh::isUsbLoggingEnabled()
? (mesh::isUsbLoggingPacketStream() ? "stream" : "on") : "off",
mesh::usbLoggingPortDescription(),
(mesh::usbLoggingInterfaceRestartRequired()
|| (_prefs.usb_logging_enabled != 0
&& (_prefs.usb_logging_enabled == 2)
!= mesh::isUsbLoggingPacketStream()))
? " (reboot required to change USB interfaces)" : "");
return true;
}
if (strncmp(command, "set usb.logging", 15) == 0
&& (command[15] == 0 || command[15] == ' ' || command[15] == '\t')) {
const char* value = command + 15;
while (*value == ' ' || *value == '\t') value++;
bool enabled = false;
bool reboot_if_needed = false;
const bool packet_stream = strcmp(value, "stream") == 0
|| strcmp(value, "stream reboot") == 0;
if (packet_stream) {
enabled = true;
reboot_if_needed = value[6] != 0;
}
if (!packet_stream
&& !mesh::cli::parseLoggingToggle(value, enabled, reboot_if_needed)) {
mesh::cli::formatError(reply, reply_size, "use set usb.logging <on|off|stream> [reboot]");
} else {
_prefs.usb_logging_enabled = enabled ? (packet_stream ? 2 : 1) : 0;
applyUsbLoggingState(enabled);
if (!savePrefs()) {
mesh::cli::formatError(reply, reply_size, "USB logging changed for this boot but save failed");
} else if (!mesh::saveUsbLoggingBootPreference(enabled)) {
mesh::cli::formatError(reply, reply_size, "setting saved, but next-boot USB interface state could not be saved");
} else if (mesh::usbLoggingInterfaceRestartRequired()
|| (enabled && packet_stream != mesh::isUsbLoggingPacketStream())) {
if (reboot_if_needed) {
snprintf(reply, reply_size,
"OK - USB logging %s (saved); rebooting to change USB interfaces",
enabled ? (packet_stream ? "stream" : "on") : "off");
_scheduled_reboot_at = futureMillis(1000);
if (_scheduled_reboot_at == 0) _scheduled_reboot_at = 1;
} else {
snprintf(reply, reply_size,
"OK - USB logging %s (saved); reboot required to change USB interfaces",
enabled ? (packet_stream ? "stream" : "on") : "off");
}
} else {
snprintf(reply, reply_size, "OK - USB logging %s (saved)",
enabled ? (packet_stream ? "stream" : "on") : "off");
}
}
return true;
}
#endif
if (strcmp(command, "get role") == 0) {
snprintf(reply, reply_size, "> companion");
return true;
}
if (strcmp(command, "get bootloader.ver") == 0) {
#if defined(NRF52_PLATFORM)
char version[128]; // Preserve the bootloader's preview/fork/build suffix.
if (board.getBootloaderVersion(version, sizeof(version))) {
snprintf(reply, reply_size, "> %s", version);
} else {
snprintf(reply, reply_size, "> unknown");
}
#else
mesh::cli::formatError(reply, reply_size, "unsupported");
#endif
return true;
}
if (strcmp(command, "get lat") == 0 || strcmp(command, "get lon") == 0) {
snprintf(reply, reply_size, "> %.6f", command[5] == 'a' ? sensors.node_lat : sensors.node_lon);
return true;
}
if (strcmp(command, "board") == 0) {
const char* hardware_name = board.getManufacturerName();
snprintf(reply, reply_size, "%s",
hardware_name != NULL ? hardware_name : "Unknown hardware");
return true;
}
if (strcmp(command, "version") == 0) {
snprintf(reply, reply_size, "Companion %s (protocol %u, build %s)",
FIRMWARE_VERSION, (unsigned)FIRMWARE_VER_CODE,
FIRMWARE_BUILD_DATE);
return true;
}
#if defined(NRF52_PLATFORM) && defined(EXTRAFS) && !defined(QSPIFLASH)
if (strcmp(command, "get flash.health") == 0) {
return _store->formatInternalExtraFSHealth(reply, reply_size);
}
#endif
if (strncmp(command, "get ", 4) == 0 &&
mesh::cli::handleStorageLayoutGet(command + 4, board, reply,
reply_size)) {
return true;
}
if (strcmp(command, "get pwrmgt.bootreason") == 0
|| strcmp(command, "powerlog") == 0) {
if (!board.isPowerManagementInitialized()) {
mesh::cli::formatError(reply, reply_size, "power management unsupported");
} else {
snprintf(reply, reply_size, "> Reset: %s; Shutdown: %s",
board.getResetReasonString(board.getResetReason()),
board.getShutdownReasonString(board.getShutdownReason()));
}
return true;
}
if (strcmp(command, "get contact.cache.timing") == 0) {
#if MESH_CONTACT_CACHE
const auto& cache = mesh::contactSecretCache();
snprintf(reply, reply_size, "last_flash_us=%lu last_calculation_us=%lu",
(unsigned long)cache.last_flash_read_us,
(unsigned long)cache.last_calculation_us);
#else
snprintf(reply, reply_size, "contact cache disabled");
#endif
return true;
}
if (strcmp(command, "get contact.cache") == 0) {
#if MESH_CONTACT_CACHE
const auto& cache = mesh::contactSecretCache();
snprintf(reply, reply_size, "paths=16 secrets=16 miss=%s ram_hits=%lu flash_hits=%lu calculations=%lu save_skips=%lu",
MESH_CONTACT_SECRET_FLASH_CACHE ? "flash" : "calculate",
(unsigned long)cache.ram_hits, (unsigned long)cache.flash_hits,
(unsigned long)cache.calculations, (unsigned long)cache.save_failures);
#else
snprintf(reply, reply_size, "paths=inline secrets=inline");
#endif
return true;
}
#if COMPANION_FEATURE_MEMORY_DIAGNOSTICS
if (strcmp(command, "memory") == 0) {
const mesh::CompanionMemoryDiagnostics diagnostics = {
(uint32_t)ESP.getFreeHeap(),
(uint32_t)ESP.getMinFreeHeap(),
(uint32_t)ESP.getMaxAllocHeap(),
(uint32_t)heap_caps_get_free_size(MALLOC_CAP_INTERNAL),
(uint32_t)heap_caps_get_largest_free_block(MALLOC_CAP_INTERNAL),
(uint32_t)ESP.getFreePsram(),
(uint32_t)ESP.getPsramSize(),
offline_queue_len,
getOfflineQueueCapacity()
};
mesh::formatCompanionMemoryDiagnostics(reply, reply_size, diagnostics);
return true;
}
#endif
if (handleCadCommand(command, reply, reply_size)) return true;
if (strcmp(command, "get radio.rxgain") == 0) {
if (!radio_driver.supportsRxBoostedGainMode()) {
mesh::cli::formatError(reply, reply_size, "unsupported");
} else {
snprintf(reply, reply_size, "> %s",
_prefs.rx_boosted_gain ? "on" : "off");
}
return true;
}
if (strncmp(command, "set radio.rxgain ", 17) == 0) {
const char* value = command + 17;
if (strcmp(value, "on") != 0 && strcmp(value, "off") != 0) {
mesh::cli::formatError(reply, reply_size, "use set radio.rxgain on|off");
} else if (!radio_driver.supportsRxBoostedGainMode()) {
mesh::cli::formatError(reply, reply_size, "unsupported");
} else if (!applyAndSaveRxBoostedGain(strcmp(value, "on") == 0)) {
mesh::cli::formatError(reply, reply_size, "RX gain failed");
} else {
snprintf(reply, reply_size, "OK - radio.rxgain %s", value);
}
return true;
}
if (strcmp(command, "get radio.fem.rxgain") == 0) {
if (!board.canControlLoRaFemLna()) {
mesh::cli::formatError(reply, reply_size, "unsupported");
} else {
snprintf(reply, reply_size, "> %s",
board.isLoRaFemLnaEnabled() ? "on" : "off");
}
return true;
}
if (strncmp(command, "set radio.fem.rxgain ", 21) == 0) {
const char* value = command + 21;
if (strcmp(value, "on") != 0 && strcmp(value, "off") != 0) {
mesh::cli::formatError(reply, reply_size, "use set radio.fem.rxgain on|off");
} else if (_radio && (_radio->isMorseTxActive() || _radio->isMorseRxActive())) {
mesh::cli::formatError(reply, reply_size, "stop Morse before changing LoRa FEM gain");
} else if (!board.canControlLoRaFemLna()) {
mesh::cli::formatError(reply, reply_size, "unsupported");
} else if (!applyAndSaveFemRxGain(strcmp(value, "on") == 0)) {
mesh::cli::formatError(reply, reply_size, "failed to apply or save FEM RX gain");
} else {
snprintf(reply, reply_size, "OK - radio.fem.rxgain %s", value);
}
return true;
}
if (strcmp(command, "get radio.fem.txgain") == 0) {
if (!board.canControlLoRaFemPaGain()) {
if (board.isLoRaFemPaGainEnabled()) {
snprintf(reply, reply_size, "> on (fixed TX PA)");
} else {
mesh::cli::formatError(reply, reply_size, "unsupported");
}
} else {
snprintf(reply, reply_size, "> %s",
board.isLoRaFemPaGainEnabled() ? "on" : "off");
}
return true;
}
if (strncmp(command, "set radio.fem.txgain ", 21) == 0) {
const char* value = command + 21;
if (strcmp(value, "on") != 0 && strcmp(value, "off") != 0) {
mesh::cli::formatError(reply, reply_size, "use set radio.fem.txgain on|off");
} else if (_radio && (_radio->isMorseTxActive() || _radio->isMorseRxActive())) {
mesh::cli::formatError(reply, reply_size, "stop Morse before changing LoRa FEM gain");
} else if (!board.canControlLoRaFemPaGain()) {
snprintf(reply, reply_size, "%s", board.isLoRaFemPaGainEnabled()
? "Error: TX PA is fixed on; use set tx to adjust power"
: "Error: unsupported");
} else if (!applyAndSaveFemTxGain(strcmp(value, "on") == 0)) {
mesh::cli::formatError(reply, reply_size, "failed to apply or save FEM TX gain");
} else {
snprintf(reply, reply_size, "OK - radio.fem.txgain %s", value);
}
return true;
}
#if defined(HELTEC_V4_DISPLAY_UNIFIED) && HELTEC_V4_DISPLAY_UNIFIED && defined(DISPLAY_CLASS)
if (mesh::ui::handleHeltecV4DisplayCommand(command, reply, reply_size, display)) return true;
#endif
if (mesh::ui::handleDisplayPowerCommand(command, reply, reply_size,
_ui != nullptr && _ui->supportsInboxModes())) {
if (_ui) _ui->inboxModeChanged();
return true;
}
if (strcmp(command, "get display.touch") == 0) {
if (_ui == NULL || !_ui->supportsTouchDebug()) {
mesh::cli::formatError(reply, reply_size, "touchscreen diagnostics unsupported");
} else {
snprintf(reply, reply_size, "display.touch %s (off after reboot)",
_ui->isTouchDebugEnabled() ? "on" : "off");
}
return true;
}
if (strncmp(command, "set display.touch", 17) == 0
&& (command[17] == 0 || command[17] == ' ' || command[17] == '\t')) {
const char* value = command + 17;
while (*value == ' ' || *value == '\t') value++;
if (strcmp(value, "on") != 0 && strcmp(value, "off") != 0) {
mesh::cli::formatError(reply, reply_size, "use set display.touch on|off");
} else if (_ui == NULL || !_ui->supportsTouchDebug()) {
mesh::cli::formatError(reply, reply_size, "touchscreen diagnostics unsupported");
} else if (!_ui->setTouchDebugEnabled(strcmp(value, "on") == 0)) {
mesh::cli::formatError(reply, reply_size, "touchscreen diagnostics unavailable");
} else {
snprintf(reply, reply_size, "OK - display.touch %s (off after reboot)", value);
}
return true;
}
#if defined(OLED_AUTO_DETECT) && OLED_AUTO_DETECT && defined(DISPLAY_CLASS)
if (mesh::ui::handleOLEDControllerCommand(command, reply, reply_size, true)) {
return true;
}
#endif
if (strcmp(command, "get display.rotation") == 0) {
if (_ui == NULL || !_ui->supportsDisplayRotation()) {
mesh::cli::formatError(reply, reply_size, "display rotation is unsupported");
} else if (_prefs.display_rotation_degrees == 0) {
snprintf(reply, reply_size, "display.rotation default");
} else {
snprintf(reply, reply_size, "display.rotation %u",
(unsigned)_prefs.display_rotation_degrees);
}
return true;
}
if (strncmp(command, "set display.rotation", 20) == 0
&& (command[20] == 0 || command[20] == ' '
|| command[20] == '\t')) {
const char* value = command + 20;
while (*value == ' ' || *value == '\t') value++;
char* end = NULL;
const unsigned long degrees = strtoul(value, &end, 10);
while (end != NULL && (*end == ' ' || *end == '\t')) end++;
const bool valid = value[0] != 0 && end != NULL && *end == 0
&& (degrees == 0 || degrees == 90 || degrees == 180
|| degrees == 270);
if (!valid) {
mesh::cli::formatError(reply, reply_size, "use set display.rotation <0|90|180|270>");
} else if (_ui == NULL || !_ui->supportsDisplayRotation()) {
mesh::cli::formatError(reply, reply_size, "display rotation is unsupported");
} else {
const uint16_t previous = _prefs.display_rotation_degrees;
if (!_ui->setDisplayRotationDegrees((uint16_t)degrees)) {
mesh::cli::formatError(reply, reply_size, "display rotation failed");
} else {
_prefs.display_rotation_degrees = (uint16_t)degrees;
if (!savePrefs()) {
_prefs.display_rotation_degrees = previous;
_ui->setDisplayRotationDegrees(previous);
mesh::cli::formatError(reply, reply_size, "display rotation changed but save failed");
} else if (degrees == 0) {
snprintf(reply, reply_size,
"OK - display rotation reset to board default");
} else {
snprintf(reply, reply_size, "OK - display rotation %lu",
degrees);
}
}
}
return true;
}
if (strcmp(command, "get bluetooth.name") == 0
|| strcmp(command, "get ble.name") == 0) {
formatBluetoothNameStatus(reply, reply_size);
return true;
}
const char* bluetooth_name_value = NULL;
if (strncmp(command, "set bluetooth.name", 18) == 0
&& (command[18] == 0 || command[18] == ' '
|| command[18] == '\t')) {
bluetooth_name_value = command + 18;
} else if (strncmp(command, "set ble.name", 12) == 0
&& (command[12] == 0 || command[12] == ' '
|| command[12] == '\t')) {
bluetooth_name_value = command + 12;
}
if (bluetooth_name_value != NULL) {
while (*bluetooth_name_value == ' ' || *bluetooth_name_value == '\t') {
bluetooth_name_value++;
}
if (bluetooth_name_value[0] == 0) {
mesh::cli::formatError(reply, reply_size, "use set bluetooth.name <name|default>");
} else {
const bool clear_override = strcmp(bluetooth_name_value, "default") == 0
|| strcmp(bluetooth_name_value, "clear") == 0;
applyAndSaveBluetoothName(clear_override ? "" : bluetooth_name_value,
reply, reply_size);
}
return true;
}
#if defined(BLE_PIN_CODE)
if (strcmp(command, "get bluetooth.stealth") == 0
|| strcmp(command, "get ble.stealth") == 0) {
formatBluetoothStealthStatus(reply, reply_size);
return true;
}
const char* bluetooth_stealth_value = NULL;
if (strncmp(command, "set bluetooth.stealth", 21) == 0
&& (command[21] == 0 || command[21] == ' '
|| command[21] == '\t')) {
bluetooth_stealth_value = command + 21;
} else if (strncmp(command, "set ble.stealth", 15) == 0
&& (command[15] == 0 || command[15] == ' '
|| command[15] == '\t')) {
bluetooth_stealth_value = command + 15;
}
if (bluetooth_stealth_value != NULL) {
while (*bluetooth_stealth_value == ' ' || *bluetooth_stealth_value == '\t') {
bluetooth_stealth_value++;
}
applyAndSaveBluetoothStealth(bluetooth_stealth_value, reply, reply_size);
return true;
}
if (strcmp(command, "get bluetooth.mac") == 0
|| strcmp(command, "get ble.mac") == 0) {
formatBluetoothMacStatus(reply, reply_size);
return true;
}
const char* bluetooth_mac_value = NULL;
if (strncmp(command, "set bluetooth.mac", 17) == 0
&& (command[17] == 0 || command[17] == ' '
|| command[17] == '\t')) {
bluetooth_mac_value = command + 17;
} else if (strncmp(command, "set ble.mac", 11) == 0
&& (command[11] == 0 || command[11] == ' '
|| command[11] == '\t')) {
bluetooth_mac_value = command + 11;
}
if (bluetooth_mac_value != NULL) {
while (*bluetooth_mac_value == ' ' || *bluetooth_mac_value == '\t') {
bluetooth_mac_value++;
}
if (bluetooth_mac_value[0] == 0) {
mesh::cli::formatError(reply, reply_size, "use set bluetooth.mac <address|random|random-every-boot|random-after-connect|default>");
} else {
applyAndSaveBluetoothMac(bluetooth_mac_value, reply, reply_size);
}
return true;
}
#endif
if (strncmp(command, "set pin", 7) == 0
&& (command[7] == 0 || command[7] == ' '
|| command[7] == '\t')) {
const char* value = command + 7;
while (*value == ' ' || *value == '\t') value++;
int32_t parsed = 0;
if (!mesh::cli::parseIntegerStrict(value, parsed)
|| parsed < 0 || parsed > 999999) {
mesh::cli::formatError(reply, reply_size, "pin must be 0-999999");
} else {
const uint32_t previous = _prefs.ble_pin;
_prefs.ble_pin = static_cast<uint32_t>(parsed);
if (!savePrefs()) {
_prefs.ble_pin = previous;
mesh::cli::formatError(reply, reply_size, "pin changed but save failed");
} else {
snprintf(reply, reply_size, "> pin is now %06lu",
(unsigned long)_prefs.ble_pin);
}
}
return true;
}
#if defined(MESH_PRIMARY_ESPNOW) && MESH_PRIMARY_ESPNOW
if (strcmp(command, "get espnow.channel") == 0) {
formatEspNowChannel(reply, reply_size);
return true;
}
if (strncmp(command, "set espnow.channel", 18) == 0
&& (command[18] == 0 || command[18] == ' '
|| command[18] == '\t')) {
const char* value = command + 18;
while (*value == ' ' || *value == '\t') value++;
applyAndSaveEspNowChannel(value, reply, reply_size);
return true;
}
#endif
#if defined(ESP32) && defined(WIFI_SSID)
if (handleCompanionWiFiCompatibilityCommand(command, reply, reply_size)) {
return true;
}
if (strcmp(command, "get wifi.ip") == 0) {
char ip[16] = {};
#ifdef WITH_WEBCONFIG
if (WebConfigServer::getSetupInfo(nullptr, 0, ip, sizeof(ip))) {
snprintf(reply, reply_size, "> %s", ip);
return true;
}
#endif
snprintf(reply, reply_size, "> %s", isCompanionWiFiConnected()
? WiFi.localIP().toString().c_str() : "(not connected)");
return true;
}
if (strcmp(command, "get wifi.enabled") == 0) {
snprintf(reply, reply_size, "> %u", (unsigned)_prefs.wifi_enabled);
return true;
}
if (strcmp(command, "get wifi.tcp") == 0) {
formatCompanionWiFiTcpStatus(reply, reply_size);
return true;
}
if (strncmp(command, "set wifi.enabled ", 17) == 0) {
int32_t enabled;
if (!mesh::cli::parseIntegerStrict(command + 17, enabled)
|| enabled < 0 || enabled > 1) {
mesh::cli::formatError(reply, reply_size, "WiFi enabled must be 0 or 1");
} else if (!setCompanionWiFiEnabled(enabled != 0)) {
mesh::cli::formatError(reply, reply_size, "WiFi setting could not be saved");
} else {
#if defined(COMPANION_EXCLUSIVE_WIFI_BLE)
snprintf(reply, reply_size, "OK - reboot to apply WiFi settings");
#else
snprintf(reply, reply_size, "OK - WiFi setting saved");
#endif
}
return true;
}
if (strcmp(command, "get display.wifi") == 0) {
formatCompanionWiFiDisplayStatus(reply, reply_size);
return true;
}
#if defined(COMPANION_EXCLUSIVE_WIFI_BLE)
if (strcmp(command, "get companion.transport") == 0) {
snprintf(reply, reply_size, "%s",
getCompanionTransportMode() == CompanionTransportMode::WiFi
? "wifi" : "ble");
return true;
}
if (strncmp(command, "set companion.transport", 23) == 0
&& (command[23] == 0 || command[23] == ' '
|| command[23] == '\t')) {
const char* value = command + 23;
while (*value == ' ' || *value == '\t') value++;
CompanionTransportMode selected;
if (strcmp(value, "wifi") == 0) {
selected = CompanionTransportMode::WiFi;
} else if (strcmp(value, "ble") == 0) {
selected = CompanionTransportMode::Bluetooth;
} else {
mesh::cli::formatError(reply, reply_size, "use set companion.transport <wifi|ble>");
return true;
}
if (!selectCompanionTransportMode(selected)) {
mesh::cli::formatError(reply, reply_size, "failed to save companion transport");
} else {
snprintf(reply, reply_size,
"OK - companion transport %s saved; reboot required", value);
}
return true;
}
#endif
#ifdef WITH_WEBCONFIG
// The same bounded configuration parser serves USB, TCP, framed control and
// the browser. Only the caller outside a WebConfig batch owns its save hook.
#ifdef WITH_MQTT_BRIDGE
if (strcmp(command, "get mqtt.enabled") == 0) {
snprintf(reply, reply_size, "> %s", _mqtt_enabled ? "on" : "off");
return true;
}
if (strcmp(command, "get mqtt.running") == 0) {
snprintf(reply, reply_size, "> %s", isMQTTRunning() ? "on" : "off");
return true;
}
const char* mqtt_value = nullptr;
if (strncmp(command, "set mqtt.enabled ", 17) == 0) mqtt_value = command + 17;
if (mqtt_value) {
if (strcmp(mqtt_value, "on") != 0 && strcmp(mqtt_value, "off") != 0) {
mesh::cli::formatError(reply, reply_size, "use set mqtt.enabled on|off");
} else if (!CompanionMqttSetupPortal::saveEnabled(strcmp(mqtt_value, "on") == 0)) {
mesh::cli::formatError(reply, reply_size, "failed to save MQTT setting");
} else {
_mqtt_enabled = strcmp(mqtt_value, "on") == 0;
if (!_mqtt_enabled) stopMQTT();
snprintf(reply, reply_size, "OK - MQTT %s (saved)%s", mqtt_value,
_mqtt_enabled && !_mqtt_configured ? "; configure a broker slot to connect" : "");
}
return true;
}
#if MESH_USB_LOGGING_AVAILABLE
if (strcmp(command, "get logging.output") == 0) {
snprintf(reply, reply_size, "> %s", mesh::cli::loggingOutputName(
mesh::isUsbLoggingEnabled(), _mqtt_enabled));
return true;
}
if (strncmp(command, "set logging.output ", 19) == 0) {
bool usb = false, wifi = false;
if (!mesh::cli::parseLoggingOutput(command + 19, usb, wifi)) {
mesh::cli::formatError(reply, reply_size, "use set logging.output off|usb|wifi|both");
} else if (!CompanionMqttSetupPortal::saveEnabled(wifi)) {
mesh::cli::formatError(reply, reply_size, "failed to save MQTT setting");
} else {
_mqtt_enabled = wifi;
if (!wifi) stopMQTT();
_prefs.usb_logging_enabled = usb
? (_prefs.usb_logging_enabled == 2 ? 2 : 1) : 0;
applyUsbLoggingState(usb);
if (!savePrefs() || !mesh::saveUsbLoggingBootPreference(usb)) {
mesh::cli::formatError(reply, reply_size, "logging output changed but USB setting could not be saved");
} else {
// USB ownership changes can clear the terminal's input buffer while
// this command is executing. Use parsed values, not that borrowed text.
snprintf(reply, reply_size, "OK - logging.output %s (saved)",
mesh::cli::loggingOutputName(usb, wifi));
}
}
return true;
}
#endif
if ((strncmp(command, "get ", 4) == 0 || strncmp(command, "set ", 4) == 0)
&& (strncmp(command + 4, "mqtt", 4) == 0
|| strncmp(command + 4, "snmp", 4) == 0
|| strncmp(command + 4, "timezone", 8) == 0)) {
char copy[160], result[160] = {};
if (strlen(command) >= sizeof(copy)) {
mesh::cli::formatError(reply, reply_size, "command too long");
return true;
}
strcpy(copy, command);
const bool owns_batch = !_wc_batch_active;
if (owns_batch) onConfigBatchStart();
execCommand(copy, result);
if (owns_batch) onConfigBatchEnd();
snprintf(reply, reply_size, "%s", result);
return true;
}
#endif
if (strncmp(command, "get ", 4) == 0) {
const mesh::cli::StandaloneWiFiKey wifi_key =
mesh::cli::classifyStandaloneWiFiGet(command + 4);
switch (wifi_key) {
case mesh::cli::StandaloneWiFiKey::SSID:
WebConfigServer::formatWiFiSSID(reply, reply_size);
// The official app recognizes this sentinel as an empty SSID.
if (strcmp(reply, "> (not configured)") == 0) {
snprintf(reply, reply_size, "> (not set)");
}
return true;
case mesh::cli::StandaloneWiFiKey::Status:
formatCompanionWiFiStatus(reply, reply_size);
return true;
case mesh::cli::StandaloneWiFiKey::CLI:
WebConfigServer::formatWiFiCliStatus(reply, reply_size);
return true;
default:
break;
}
}
if (strncmp(command, "set ", 4) == 0) {
const char* value = NULL;
const mesh::cli::StandaloneWiFiKey wifi_key =
mesh::cli::classifyStandaloneWiFiSet(command + 4, &value);
bool saved = false;
switch (wifi_key) {
case mesh::cli::StandaloneWiFiKey::SSID:
saved = WebConfigServer::setStandaloneWiFiSSID(
value, reply, reply_size);
break;
case mesh::cli::StandaloneWiFiKey::Password:
saved = WebConfigServer::setStandaloneWiFiPassword(
value, reply, reply_size);
break;
case mesh::cli::StandaloneWiFiKey::CLI:
WebConfigServer::setWiFiCliEnabled(value, reply, reply_size);
return true;
default:
break;
}
if (wifi_key == mesh::cli::StandaloneWiFiKey::SSID
|| wifi_key == mesh::cli::StandaloneWiFiKey::Password) {
if (saved) {
char saved_ssid[32] = {0};
char saved_password[65] = {0};
uint8_t saved_power_save = mesh::wifi::kDefaultPowerSave;
const bool has_ssid = WebConfigServer::loadStandaloneWiFi(
saved_ssid, sizeof(saved_ssid), saved_password,
sizeof(saved_password), &saved_power_save);
memset(saved_password, 0, sizeof(saved_password));
if (_webconfig) {
_webconfig->reloadStandaloneWiFi();
_webconfig->requestRestart();
}
#ifdef WITH_MQTT_BRIDGE
if (_mqtt_enabled && _mqtt_bridge) _mqtt_bridge->requestRestart();
#endif
if (has_ssid) {
scheduleCompanionWiFiCredentialReload();
snprintf(reply, reply_size,
"OK - WiFi %s saved; reconnect scheduled",
wifi_key == mesh::cli::StandaloneWiFiKey::SSID
? "SSID" : "password");
} else {
snprintf(reply, reply_size,
"OK - WiFi password saved; set wifi.ssid to connect");
}
}
return true;
}
}
if (strcmp(command, "get webui") == 0) {
const bool enabled = WebConfigServer::loadEnabled(true);
if (!_webconfig
|| (!_webconfig->isRunning() && !_webconfig->isStopping())) {
snprintf(reply, reply_size, "> %s, inactive", enabled ? "on" : "off");
} else if (_webconfig->mode() == WebConfigServer::MODE_SETUP) {
char ssid[33] = {0};
char ip[16] = {0};
WebConfigServer::getSetupInfo(ssid, sizeof(ssid), ip, sizeof(ip));
snprintf(reply, reply_size, "> %s, setup AP %s http://%s/",
enabled ? "on" : "off", ssid, ip);
} else if (_webconfig->mode() == WebConfigServer::MODE_CONNECTING) {
snprintf(reply, reply_size, "> %s, connecting to WiFi",
enabled ? "on" : "off");
} else {
snprintf(reply, reply_size, "> %s, http://%s/",
enabled ? "on" : "off",
WiFi.localIP().toString().c_str());
}
return true;
}
if (strcmp(command, "set webui on") == 0
|| strcmp(command, "set webui off") == 0) {
const bool enabled = command[10] == 'o' && command[11] == 'n';
if (!WebConfigServer::saveEnabled(enabled)) {
mesh::cli::formatError(reply, reply_size, "failed to save webui setting");
} else if (!enabled) {
if (_webconfig) _webconfig->requestStop();
snprintf(reply, reply_size, "OK - webui off");
} else if (_webconfig
&& _webconfig->isRunning()) {
snprintf(reply, reply_size, "OK - webui on (already active)");
} else {
char start_reply[160] = {0};
startWebConfig(false, start_reply);
if (strncmp(start_reply, "WebConfig", 9) == 0) {
snprintf(reply, reply_size, "OK - webui on; %s", start_reply);
} else {
snprintf(reply, reply_size, "%s", start_reply);
}
}
return true;
}
if (strncmp(command, "set webui", 9) == 0
&& (command[9] == 0 || command[9] == ' ' || command[9] == '\t')) {
mesh::cli::formatError(reply, reply_size, "usage set webui on|off");
return true;
}
if (strcmp(command, "start webconfig") == 0
|| strcmp(command, "start webconfig ap") == 0) {
startWebConfig(strcmp(command, "start webconfig ap") == 0, reply);
return true;
}
if (strncmp(command, "start webconfig", 15) == 0
&& (command[15] == 0 || command[15] == ' ' || command[15] == '\t')) {
snprintf(reply, reply_size, "ERR: usage start webconfig [ap]");
return true;
}
if (strcmp(command, "stop webconfig") == 0) {
if (!_webconfig || (!_webconfig->isRunning() && !_webconfig->isStopping()
&& !_webconfig->isStartPending())) {
snprintf(reply, reply_size, "Err: webconfig not running");
} else {
_webconfig->requestStop();
snprintf(reply, reply_size, "OK - webconfig stopping");
}
return true;
}
#endif
if (strcmp(command, "get wifi.powersave") == 0) {
formatWiFiPowerSaving(reply, reply_size);
return true;
}
if (strcmp(command, "set wifi.powersave") == 0
|| strncmp(command, "set wifi.powersave ", 19) == 0) {
const char* value = command + 18;
while (*value == ' ') value++;
applyAndSaveWiFiPowerSaving(value, reply, reply_size);
return true;
}
#endif
#if COMPANION_FEATURE_TEMP_RADIO || COMPANION_FEATURE_OTA_CLI
#if COMPANION_FEATURE_TEMP_RADIO
if (strcmp(command, "tempradio") == 0) {
if (_temp_radio_set_at) {
snprintf(reply, reply_size, "TempRadio pending: %.3f,%.2f,%u,%u",
_temp_radio_freq, _temp_radio_bw,
(unsigned)_temp_radio_sf, (unsigned)_temp_radio_cr);
mesh::RadioProfiles preview;
if (_radio->profiles()) preview = *_radio->profiles();
preview.primary.freq = _temp_radio_freq; preview.primary.bw = _temp_radio_bw;
preview.primary.sf = _temp_radio_sf; preview.primary.cr = _temp_radio_cr;
preview.primary.preamble = _temp_radio_preamble;
const size_t used = strlen(reply);
if (used < reply_size) snprintf(reply + used, reply_size - used, ",preamble=%u", preview.preamble(0, rxPowerSavingPreambleForParams(_temp_radio_sf, _temp_radio_bw)));
mesh::RadioProfileCLI::appendChirpWarning(reply, reply_size, preview);
} else if (isTempRadioActive()) {
uint32_t seconds = (_temp_radio_revert_at - _ms->getMillis()) / 1000UL;
snprintf(reply, reply_size, "TempRadio active: %.3f,%.2f,%u,%u %lus left",
_temp_radio_freq, _temp_radio_bw,
(unsigned)_temp_radio_sf, (unsigned)_temp_radio_cr,
(unsigned long)seconds);
_radio_profiles.appendPreamble(reply, reply_size);
} else {
snprintf(reply, reply_size, "TempRadio inactive");
}
return true;
}
if (strncmp(command, "tempradio ", 10) == 0) {
char legacy[120];
uint16_t preamble = 0;
if (!mesh::RadioProfileCLI::parseSuffix(command + 10, 5, legacy, sizeof(legacy), preamble)) {
mesh::cli::formatError(reply, reply_size, "invalid preamble"); return true;
}
float freq = 0.0f, bw = 0.0f;
uint8_t sf = 0, cr = 0;
uint32_t timeout_mins = 0;
if (!mesh::cli::parseTemporaryRadioTupleStrict(
legacy, freq, bw, sf, cr, timeout_mins)
|| !isfinite(freq) || !isfinite(bw)
|| freq < 150.0f || freq > 2500.0f
|| !isFullCompanionBandwidth(bw)
|| sf < 5 || sf > 12 || cr < 5 || cr > 8
|| timeout_mins == 0 || timeout_mins > 10080UL
|| !_radio_profiles.acceptsPrimary(freq, bw, sf, cr, preamble)) {
snprintf(reply, reply_size,
"ERR usage: tempradio freq,bw,sf,cr,minutes (minutes 1-10080)");
return true;
}
if (scheduleTempRadio(freq, bw, sf, cr, timeout_mins, reply, reply_size)) {
_temp_radio_preamble = preamble;
_radio_profiles.appendPrimaryChirpWarning(reply, reply_size, sf, bw, preamble);
}
return true;
}
if (strcmp(command, "normalradio") == 0) {
scheduleNormalRadio(reply, reply_size);
return true;
}
#endif
#if COMPANION_FEATURE_OTA_CLI
if (strncmp(command, "ota", 3) == 0
&& (command[3] == 0 || command[3] == ' ')) {
char ota_reply[160] = {0};
const char* action = command + 3;
while (*action == ' ') ++action;
// Only an explicit transfer, catalog search, or source attach may evict
// unread offline frames. Read-only OTA queries leave the queue intact.
bool const drain_queue = strcmp(action, "folder on") == 0
|| strcmp(action, "ls") == 0 || strncmp(action, "ls ", 3) == 0
|| strncmp(action, "pull ", 5) == 0
|| strncmp(action, "get ", 4) == 0
|| strncmp(action, "download ", 9) == 0
|| strcmp(action, "install") == 0
|| strcmp(action, "apply") == 0
|| strcmp(action, "applydelta") == 0
|| strncmp(action, "bootloader install ", 19) == 0
|| strncmp(action, "rescue install ", 15) == 0;
uint16_t drained = 0;
if (!mesh::ota::ota_acquire_context(ota_reply, sizeof(ota_reply), drain_queue, &drained)) {
snprintf(reply, reply_size, "%s", ota_reply);
return true;
}
auto& context = mesh::ota::ota_ctx();
const auto previous = mesh::ota::OtaConfigState::capture(context);
if (!mesh::ota::handle_ota_command(command, ota_reply, board)) return false;
if (context.config_dirty) {
if (!mesh::ota::saveCompanionOtaConfig(mesh::ota::OtaConfigState::capture(context))) {
previous.apply(context);
snprintf(ota_reply, sizeof(ota_reply), "ERR OTA settings save failed; settings unchanged");
}
context.config_dirty = false;
}
if (drained) {
size_t const used = strlen(ota_reply);
snprintf(ota_reply + used, sizeof(ota_reply) - used,
"; cleared %u oldest queued messages for OTA", (unsigned)drained);
}
snprintf(reply, reply_size, "%s", ota_reply);
return true;
}
#endif
#endif
return false;
}
#if COMPANION_FEATURE_TEMP_RADIO
void MyMesh::serviceTempRadio() {
const unsigned long now = _ms->getMillis();
const bool retry_ready = !_temp_radio_retry_at
|| _temp_radio_retry_at == now || millisHasNowPassed(_temp_radio_retry_at);
const bool revert_due = _temp_radio_revert_at
&& (_temp_radio_revert_at == now || millisHasNowPassed(_temp_radio_revert_at));
if (revert_due) {
if (hasOutbound() || !retry_ready) return;
mesh::RadioParamApplyResult result = tryApplyRadioParams(
_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr);
if (result == mesh::RadioParamApplyResult::APPLIED
&& radio_driver.setTxPower(_prefs.tx_power_dbm)
&& (!radio_driver.supportsRxBoostedGainMode()
|| radio_driver.setRxBoostedGainMode(_prefs.rx_boosted_gain))) {
_temp_radio_set_at = 0;
_temp_radio_revert_at = 0;
_temp_radio_retry_at = 0;
_temp_radio_failures = 0;
_temp_radio_applied = false;
MESH_DEBUG_PRINTLN("Full companion restored normal radio");
} else {
_temp_radio_retry_at = futureMillis(
nextRadioApplyRetryDelay(_temp_radio_failures));
}
return;
}
if (!_temp_radio_set_at
|| (_temp_radio_set_at != now && !millisHasNowPassed(_temp_radio_set_at))
|| hasOutbound() || !retry_ready) return;
mesh::RadioParamApplyResult result = tryApplyRadioParams(
_temp_radio_freq, _temp_radio_bw, _temp_radio_sf, _temp_radio_cr, true, _temp_radio_preamble);
if (result == mesh::RadioParamApplyResult::APPLIED) {
_temp_radio_set_at = 0;
_temp_radio_retry_at = 0;
_temp_radio_failures = 0;
_temp_radio_applied = true;
MESH_DEBUG_PRINTLN("Full companion entered TempRadio");
} else if (result == mesh::RadioParamApplyResult::BUSY) {
_temp_radio_retry_at = futureMillis(250);
} else {
// A partially applied tuple must never become persistent. Restore the
// saved Companion settings through the same bounded retry path.
_temp_radio_set_at = 0;
_temp_radio_revert_at = futureMillis(1);
_temp_radio_retry_at = 0;
saved_radio_apply_pending = true;
}
}
#endif
const char *MyMesh::getNodeName() {
return _prefs.node_name;
}
CompanionNodePrefs *MyMesh::getNodePrefs() {
return &_prefs;
}
uint32_t MyMesh::getBLEPin() {
return _active_ble_pin;
}
bool MyMesh::setBluetoothEnabledPreference(bool enabled) {
return savePreference(_prefs.bluetooth_enabled, static_cast<uint8_t>(enabled));
}
bool MyMesh::isBluetoothEnabledPreference() const {
return _prefs.bluetooth_enabled != 0;
}
#if defined(WITH_MQTT_BRIDGE) && defined(ESP32_PLATFORM) && defined(WIFI_SSID)
static void copyMqttString(char* dest, size_t dest_size, const char* src) {
strncpy(dest, src ? src : "", dest_size - 1);
dest[dest_size - 1] = 0;
}
void MyMesh::serviceMQTT(const char* wifi_ssid, const char* wifi_password) {
if (!_mqtt_enabled) {
stopMQTT();
if (_mqtt_bridge) _mqtt_bridge->loop();
return;
}
#ifdef WITH_WEBCONFIG
if (_wc_batch_active) return;
#endif
// OTA keeps its radio/heap ownership; retain desired MQTT for a later loop.
if (board.isOTAUpdateRunning()) {
if (_mqtt_bridge && _mqtt_bridge->isStopping()) _mqtt_bridge->loop();
return;
}
if (_mqtt_bridge && !_mqtt_bridge->isRunning()
&& !_mqtt_bridge->isStopping()) {
if (strcmp(_mqtt_prefs.wifi_ssid, wifi_ssid ? wifi_ssid : "") != 0) {
copyMqttString(_mqtt_prefs.wifi_ssid, sizeof(_mqtt_prefs.wifi_ssid), wifi_ssid);
}
if (strcmp(_mqtt_prefs.wifi_password, wifi_password ? wifi_password : "") != 0) {
copyMqttString(_mqtt_prefs.wifi_password, sizeof(_mqtt_prefs.wifi_password), wifi_password);
}
}
if (_mqtt_bridge) _mqtt_bridge->loop();
_mqtt_started = _mqtt_bridge && _mqtt_bridge->isRunning()
&& !_mqtt_bridge->isStopping();
// Start the recovery scheduler even when the router is down at boot. The
// Companion still owns association; waiting for GOT_IP here would leave its
// failed station outside MQTT/NTP's shared power budget indefinitely.
if (_mqtt_configured && wifi_ssid && wifi_ssid[0]
&& !_mqtt_started && _mqtt_bridge
&& !_mqtt_bridge->isStartPending()
#ifdef WITH_WEBCONFIG
&& !isWebConfigSetupActive()
#endif
) {
_mqtt_bridge->begin();
_mqtt_started = _mqtt_bridge->isRunning() && !_mqtt_bridge->isStopping();
if (_mqtt_started) {
mesh::usbLoggingPort().println("MQTT companion: bridge started");
} else {
mesh::usbLoggingPort().println(
"MQTT companion: bridge could not start");
}
}
}
void MyMesh::stopMQTT() {
if (_mqtt_bridge) _mqtt_bridge->requestStop();
_mqtt_started = false;
}
#endif
#ifdef WITH_WEBCONFIG
static bool wcParseBool(const char* value, bool& out) {
if (strcmp(value, "on") == 0) { out = true; return true; }
if (strcmp(value, "off") == 0) { out = false; return true; }
return false;
}
static bool wcParseLong(const char* value, long min_value, long max_value, long& out) {
if (!value || !value[0]) return false;
char* end = nullptr;
long parsed = strtol(value, &end, 10);
if (!end || *end != 0 || parsed < min_value || parsed > max_value) return false;
out = parsed;
return true;
}
static bool wcParseDouble(const char* value, double min_value, double max_value, double& out) {
float parsed = 0.0f;
if (!mesh::cli::parseDecimalStrict(value, parsed)
|| parsed < min_value || parsed > max_value) return false;
out = parsed;
return true;
}
static bool wcValidNtpHost(const char* value) {
if (!value || !value[0] || strlen(value) > 63 || value[0] == '.') return false;
size_t len = strlen(value);
if (value[len - 1] == '.') return false;
for (size_t i = 0; i < len; i++) {
char c = value[i];
if (!isalnum(static_cast<unsigned char>(c)) && c != '.' && c != '-') return false;
}
return true;
}
static bool wcValidHexKey(const char* value) {
if (!value || strlen(value) != 64) return false;
for (int i = 0; i < 64; i++) {
if (!isxdigit(static_cast<unsigned char>(value[i]))) return false;
}
return true;
}
static bool wcCopyValue(char* dest, size_t dest_size, const char* value) {
if (!dest || dest_size == 0 || !value || strlen(value) >= dest_size) return false;
strncpy(dest, value, dest_size - 1);
dest[dest_size - 1] = 0;
return true;
}
void MyMesh::getNodeSnapshot(WebConfigServer::NodeSnapshot& s) {
memset(&s, 0, sizeof(s));
s.display_rotation_supported = _ui != NULL && _ui->supportsDisplayRotation();
s.display_rotation_degrees = _prefs.display_rotation_degrees;
#if defined(ESP32_PLATFORM) && defined(WIFI_SSID)
if (_management_password_ready) {
wcCopyValue(s.admin_password, sizeof(s.admin_password), _management_password);
}
#endif
wcCopyValue(s.name, sizeof(s.name), _prefs.node_name);
wcCopyValue(s.bluetooth_name, sizeof(s.bluetooth_name),
_prefs.bluetooth_name);
s.lat = sensors.node_lat;
s.lon = sensors.node_lon;
s.freq = _prefs.freq;
s.bw = _prefs.bw;
s.sf = _prefs.sf;
s.cr = _prefs.cr;
s.tx_power = _prefs.tx_power_dbm;
s.airtime_factor = _prefs.airtime_factor;
s.rx_delay = _prefs.rx_delay_base;
s.rx_gain = _prefs.rx_boosted_gain;
s.fem_rx_gain = board.isLoRaFemLnaEnabled();
s.rx_ps_enabled = _prefs.rx_powersaving_enabled;
s.rx_ps_level = _prefs.rx_ps_level;
s.rx_ps_preamble = _prefs.rx_ps_preamble;
s.rx_ps_rx_us = _prefs.rx_ps_rx_us;
s.rx_ps_sleep_us = _prefs.rx_ps_sleep_us;
s.power_saving = _prefs.powersaving_enabled;
#if defined(ESP32_PLATFORM)
s.cpu_freq_mhz = _prefs.cpu_freq_mhz;
s.cpu_actual_mhz = board.getCpuFrequencyMHz();
s.cpu_supported_mask = (board.supportsCpuFrequencyMHz(80) ? 1 : 0)
| (board.supportsCpuFrequencyMHz(160) ? 2 : 0)
| (board.supportsCpuFrequencyMHz(240) ? 4 : 0);
#endif
#if MESH_ENABLE_LOST_REPLY
s.lost_reply = _prefs.lost_reply;
#endif
#ifdef WITH_MQTT_BRIDGE
s.mqtt_enabled = _mqtt_enabled;
#endif
s.repeat = _prefs.client_repeat != 0;
s.capabilities = WebConfigServer::CAP_LOCATION | WebConfigServer::CAP_AIRTIME
| WebConfigServer::CAP_RX_DELAY | WebConfigServer::CAP_POWER_SAVING;
#if MESH_ENABLE_LOST_REPLY
s.capabilities |= WebConfigServer::CAP_LOST_REPLY;
#endif
#ifdef BLE_PIN_CODE
s.capabilities |= WebConfigServer::CAP_BLUETOOTH_NAME
| WebConfigServer::CAP_BLUETOOTH_MAC
| WebConfigServer::CAP_BLUETOOTH_STEALTH;
s.bluetooth_stealth = mesh::companion::bluetoothStealthEnabled(
_prefs.bluetooth_stealth_mode);
if (_prefs.bluetooth_mac_mode
== mesh::companion::BLUETOOTH_MAC_RANDOM_EVERY_BOOT) {
wcCopyValue(s.bluetooth_mac, sizeof(s.bluetooth_mac),
"random-every-boot");
} else if (mesh::companion::bluetoothMacModeIsRandomAfterConnect(
_prefs.bluetooth_mac_mode)) {
wcCopyValue(s.bluetooth_mac, sizeof(s.bluetooth_mac),
"random-after-connect");
} else if (mesh::companion::bluetoothMacModeUsesSavedAddress(
_prefs.bluetooth_mac_mode)
&& mesh::companion::isValidBluetoothMac(
_prefs.bluetooth_mac)) {
mesh::companion::formatBluetoothMac(
_prefs.bluetooth_mac, s.bluetooth_mac, sizeof(s.bluetooth_mac));
}
#endif
#if defined(ESP32) && defined(WIFI_SSID)
s.capabilities |= WebConfigServer::CAP_WIFI_POWER_SAVE;
#endif
#if defined(MESH_PRIMARY_ESPNOW) && MESH_PRIMARY_ESPNOW
s.capabilities |= WebConfigServer::CAP_ESPNOW_CHANNEL;
#endif
if (radio_driver.supportsRxBoostedGainMode()) {
s.capabilities |= WebConfigServer::CAP_RX_GAIN;
}
if (board.canControlLoRaFemLna()) {
s.capabilities |= WebConfigServer::CAP_FEM_RX_GAIN;
}
if (radio_driver.supportsRxPowerSaving()) {
s.capabilities |= WebConfigServer::CAP_RX_POWER_SAVING;
}
}
bool MyMesh::startWebConfig(bool force_ap, char* reply) {
if (board.isOTAUpdateRunning()) {
strcpy(reply, "Err: OTA server is running - 'stop ota' first");
return false;
}
#if defined(ESP32_PLATFORM) && defined(WIFI_SSID)
if (!_management_password_ready) {
strcpy(reply, "Err: management password unavailable; set password on USB");
return false;
}
#endif
#if defined(COMPANION_EXCLUSIVE_WIFI_BLE)
if (!isCompanionWiFiEnabled()) {
strcpy(reply,
"Err: WebUI unavailable while Bluetooth transport is active; select WiFi and reboot");
return false;
}
#endif
if (!_webconfig) {
strcpy(reply, "Err: WebUI unavailable (not enough memory)");
return false;
}
if (_webconfig->isRunning()) {
if (force_ap && !_webconfig->isStopping()
&& _webconfig->mode() == WebConfigServer::MODE_LAN) {
#ifdef WITH_MQTT_BRIDGE
if (_mqtt_bridge) {
_mqtt_bridge->end();
_mqtt_started = false;
}
#endif
return _webconfig->requestStart(true, reply);
}
strcpy(reply, "Err: WebUI already running");
return false;
}
if (force_ap) {
#ifdef WITH_MQTT_BRIDGE
if (_mqtt_bridge) {
_mqtt_bridge->end();
_mqtt_started = false;
}
#endif
return _webconfig->requestStart(true, reply);
}
return _webconfig->requestStart(false, reply);
}
void MyMesh::stopWebConfig() {
if (_webconfig) _webconfig->requestStop();
}
void MyMesh::serviceWebConfig() {
if (_webconfig) _webconfig->tick(millis());
}
bool MyMesh::isWebConfigActiveOrStopping() const {
return _webconfig && (_webconfig->isRunning() || _webconfig->isStopping()
|| _webconfig->isStartPending());
}
bool MyMesh::isWebConfigWiFiTransitionActive() const {
return _webconfig && (_webconfig->isStopping()
|| _webconfig->mode() == WebConfigServer::MODE_SETUP);
}
bool MyMesh::isWebConfigSetupActive() const {
return _webconfig && _webconfig->mode() == WebConfigServer::MODE_SETUP
&& !_webconfig->isStopping();
}
bool MyMesh::isWebConfigWiFiRecoveryActive() const {
return _webconfig && _webconfig->isSavedWiFiRecoveryActive();
}
void MyMesh::rebootNow() {
if (MyMesh::prepareForUiShutdown()) {
board.reboot();
} else {
MESH_DEBUG_PRINTLN("Reboot refused: pending contacts could not be saved");
}
}
void MyMesh::onConfigBatchStart() {
_wc_batch_active = true;
_wc_mqtt_dirty = false;
}
void MyMesh::onConfigBatchEnd() {
#if defined(ESP32) && defined(WIFI_SSID)
// The WebConfig WiFi form writes the canonical mesh-wifi namespace itself.
// Reload it here so the Companion runtime and MQTT reconnect policy cannot
// retain a stale modem-sleep mode.
reloadCompanionWiFiPowerSave();
syncWiFiPowerSaving();
#endif
#ifdef WITH_MQTT_BRIDGE
if (_wc_mqtt_dirty) {
CompanionMqttSetupPortal::saveStoredConfig(_mqtt_prefs);
if (_mqtt_bridge) _mqtt_bridge->end();
_mqtt_started = false;
// loadStoredConfig only replaces its destination after validation. Pass
// the live preferences directly: another 2.8 KB copy here nests with the
// loader's scratch copy and NVS calls, overflowing the ESP32 loop stack.
CompanionMqttSetupPortal::loadStoredConfig(_mqtt_prefs);
_mqtt_configured = CompanionMqttSetupPortal::hasConfiguredSlot(_mqtt_prefs);
// The standalone Companion setting remains canonical even if MQTT config
// verification reloaded an older copy of this field.
_mqtt_prefs.wifi_power_save = getCompanionWiFiPowerSave();
}
#endif
_wc_mqtt_dirty = false;
_wc_batch_active = false;
}
void MyMesh::execAdminCommand(char* cmd, char* reply) {
// The settings-batch API runs independently from the interactive terminal.
// The browser CLI uses runStreamTerminal() for the shared USB/TCP session;
// a settings batch must not capture or reset that session's stream.
reply[0] = 0;
if (!cmd || (strlen(cmd) > 2 && cmd[2] == '|')) {
mesh::cli::formatError(reply, 160, "enter a plain CLI command");
return;
}
if (handleCommand(cmd, 0, reply)) return;
// The settings form already implements the Companion's MQTT and remaining
// configuration setters. Its batch hooks persist/restart MQTT once per batch.
execCommand(cmd, reply);
}
void MyMesh::execCommand(char* cmd, char* reply) {
reply[0] = 0;
#if defined(ESP32_PLATFORM) && defined(WIFI_SSID)
if (cmd && strncmp(cmd, "password", 8) == 0
&& (cmd[8] == 0 || cmd[8] == ' ' || cmd[8] == '\t')) {
handleLocalControlCommand(cmd, reply, 160);
return;
}
#endif
#ifdef WITH_MQTT_BRIDGE
if (cmd && strncmp(cmd, "set mqtt.enabled ", 17) == 0) {
handleLocalControlCommand(cmd, reply, 160);
return;
}
if (cmd && strncmp(cmd, "get ", 4) == 0) {
const char* key = cmd + 4;
const char* value = nullptr;
if (strcmp(key, "mqtt.status") == 0) {
MQTTBridge::formatMqttStatusReply(reply, 160, &_mqtt_prefs);
return;
}
if (strcmp(key, "mqtt.stats") == 0) {
MQTTBridge::formatMqttStatsReply(reply, 160);
return;
}
if (strcmp(key, "mqtt.origin") == 0) value = _mqtt_prefs.mqtt_origin;
else if (strcmp(key, "mqtt.iata") == 0) value = _mqtt_prefs.mqtt_iata;
else if (strcmp(key, "mqtt.packets") == 0) value = _mqtt_prefs.mqtt_packets_enabled ? "on" : "off";
else if (strcmp(key, "mqtt.raw") == 0) value = _mqtt_prefs.mqtt_raw_enabled ? "on" : "off";
else if (strcmp(key, "mqtt.rx") == 0) value = _mqtt_prefs.mqtt_rx_enabled ? "on" : "off";
else if (strcmp(key, "mqtt.advert.verify") == 0) value = _mqtt_prefs.mqtt_advert_verify ? "on" : "off";
else if (strcmp(key, "mqtt.tx") == 0) value = _mqtt_prefs.mqtt_tx_enabled == 2 ? "advert" : _mqtt_prefs.mqtt_tx_enabled ? "on" : "off";
else if (strcmp(key, "mqtt.ntp") == 0) value = _mqtt_prefs.mqtt_ntp_server;
else if (strcmp(key, "mqtt.owner") == 0) value = _mqtt_prefs.mqtt_owner_public_key;
else if (strcmp(key, "mqtt.email") == 0) value = _mqtt_prefs.mqtt_email;
else if (strcmp(key, "timezone") == 0) value = _mqtt_prefs.timezone_string;
else if (strcmp(key, "snmp") == 0) value = _mqtt_prefs.snmp_enabled ? "on" : "off";
else if (strcmp(key, "snmp.community") == 0) value = _mqtt_prefs.snmp_community;
else if (strcmp(key, "mqtt.interval") == 0) {
snprintf(reply, 160, "> %lu minutes (%lu ms)",
(unsigned long)((_mqtt_prefs.mqtt_status_interval + 29999) / 60000),
(unsigned long)_mqtt_prefs.mqtt_status_interval);
return;
} else if (strcmp(key, "timezone.offset") == 0) {
snprintf(reply, 160, "> %d", (int)_mqtt_prefs.timezone_offset);
return;
} else if (strlen(key) >= 7 && strncmp(key, "mqtt", 4) == 0
&& key[4] >= '1' && key[4] <= '0' + MAX_MQTT_SLOTS && key[5] == '.') {
const int slot = key[4] - '1';
const char* field = key + 6;
if (strcmp(field, "diag") == 0) {
MQTTBridge::formatSlotDiagReply(reply, 160, slot);
return;
}
if (strcmp(field, "preset") == 0) value = _mqtt_prefs.mqtt_slot_preset[slot];
else if (strcmp(field, "server") == 0) value = _mqtt_prefs.mqtt_slot_host[slot];
else if (strcmp(field, "username") == 0) value = _mqtt_prefs.mqtt_slot_username[slot];
else if (strcmp(field, "topic") == 0) value = _mqtt_prefs.mqtt_slot_topic[slot];
else if (strcmp(field, "audience") == 0) value = _mqtt_prefs.mqtt_slot_audience[slot];
else if (strcmp(field, "password") == 0) value = _mqtt_prefs.mqtt_slot_password[slot][0] ? "********" : "";
else if (strcmp(field, "token") == 0) value = _mqtt_prefs.mqtt_slot_token[slot][0] ? "********" : "";
else if (strcmp(field, "port") == 0) {
snprintf(reply, 160, "> %u", (unsigned)_mqtt_prefs.mqtt_slot_port[slot]);
return;
}
}
if (value) {
snprintf(reply, 160, "> %s", value);
return;
}
}
#endif
if (handleCadCommand(cmd, reply, 160)) return;
if (cmd && (strcmp(cmd, "get bluetooth.name") == 0
|| strcmp(cmd, "get ble.name") == 0)) {
formatBluetoothNameStatus(reply, 160);
return;
}
#if defined(BLE_PIN_CODE)
if (cmd && (strcmp(cmd, "get bluetooth.stealth") == 0
|| strcmp(cmd, "get ble.stealth") == 0)) {
formatBluetoothStealthStatus(reply, 160);
return;
}
if (cmd && (strcmp(cmd, "get bluetooth.mac") == 0
|| strcmp(cmd, "get ble.mac") == 0)) {
formatBluetoothMacStatus(reply, 160);
return;
}
#endif
#if defined(MESH_PRIMARY_ESPNOW) && MESH_PRIMARY_ESPNOW
if (cmd && strcmp(cmd, "get espnow.channel") == 0) {
formatEspNowChannel(reply, 160);
return;
}
#endif
#if defined(ESP32) && defined(WIFI_SSID)
if (cmd && strcmp(cmd, "get wifi.powersave") == 0) {
formatWiFiPowerSaving(reply, 160);
return;
}
#endif
if (!cmd || strncmp(cmd, "set ", 4) != 0) {
mesh::cli::formatError(reply, 160, "unsupported command");
return;
}
const char* start = cmd + 4;
const char* split = strchr(start, ' ');
if (!split) {
mesh::cli::formatError(reply, 160, "missing value");
return;
}
char key[24];
const size_t key_len = split - start;
if (key_len == 0 || key_len >= sizeof(key)) {
mesh::cli::formatError(reply, 160, "unsupported setting");
return;
}
memcpy(key, start, key_len);
key[key_len] = 0;
const char* value = split + 1;
if (strcmp(key, "name") == 0) {
char name[sizeof(_prefs.node_name)];
if (!value[0] || !wcCopyValue(name, sizeof(name), value)) {
mesh::cli::formatError(reply, 160, "name must be 1-31 characters");
} else {
strcpy(reply, saveAdvertName(name) ? "OK" : "Error: name could not be saved");
}
return;
}
if (strcmp(key, "bluetooth.name") == 0) {
applyAndSaveBluetoothName(value, reply, 160);
return;
}
#if defined(BLE_PIN_CODE)
if (strcmp(key, "bluetooth.stealth") == 0
|| strcmp(key, "ble.stealth") == 0) {
applyAndSaveBluetoothStealth(value, reply, 160);
return;
}
if (strcmp(key, "bluetooth.mac") == 0) {
applyAndSaveBluetoothMac(value, reply, 160);
return;
}
#endif
#if defined(MESH_PRIMARY_ESPNOW) && MESH_PRIMARY_ESPNOW
if (strcmp(key, "espnow.channel") == 0) {
applyAndSaveEspNowChannel(value, reply, 160);
return;
}
#endif
if (strcmp(key, "lat") == 0 || strcmp(key, "lon") == 0) {
double parsed;
const bool latitude = key[1] == 'a';
if (!wcParseDouble(value, latitude ? -90.0 : -180.0,
latitude ? 90.0 : 180.0, parsed)) {
strcpy(reply, latitude ? "Error: latitude must be -90 to 90"
: "Error: longitude must be -180 to 180");
} else {
strcpy(reply, saveAdvertLocation(latitude ? parsed : sensors.node_lat,
latitude ? sensors.node_lon : parsed)
? "OK" : "Error: location could not be saved");
}
return;
}
if (strcmp(key, "radio") == 0) {
float freq, bw; uint8_t sf, cr;
if (!mesh::cli::parseRadioTupleStrict(value, freq, bw, sf, cr)) {
mesh::cli::formatError(reply, 160, "radio must be freq,bw,sf,cr");
} else {
const auto result = applyAndSaveRadioParams(freq, bw, sf, cr,
_prefs.client_repeat, _radio_profiles.primaryPreamble());
strcpy(reply, result == RadioSettingResult::Saved ? "OK"
: result == RadioSettingResult::SaveFailed ? "Error: radio not saved"
: result == RadioSettingResult::SaveUnconfirmed ? "Error: radio save unconfirmed"
: "Error: radio rejected");
}
return;
}
if (strcmp(key, "tx") == 0) {
long parsed;
if (!wcParseLong(value, -9, MAX_LORA_TX_POWER, parsed)) {
snprintf(reply, 160, "Error: TX -9..%d dBm", MAX_LORA_TX_POWER);
} else {
const auto result = applyAndSaveTxPower(static_cast<int8_t>(parsed));
strcpy(reply, result == RadioSettingResult::Saved
? (tx_power_apply_pending ? "OK - saved; pending" : "OK")
: result != RadioSettingResult::SaveFailed
? "Error: TX power rejected"
: "Error: TX not saved");
}
return;
}
if (strcmp(key, "af") == 0 || strcmp(key, "rxdelay") == 0) {
double parsed;
const bool is_af = strcmp(key, "af") == 0;
if (!wcParseDouble(value, 0.0, is_af ? 9.0 : 20.0, parsed)) {
strcpy(reply, is_af ? "Error: airtime factor must be 0-9"
: "Error: RX delay must be 0-20");
} else {
strcpy(reply, savePreference(is_af ? _prefs.airtime_factor : _prefs.rx_delay_base,
static_cast<float>(parsed))
? "OK" : "Error: setting could not be saved");
}
return;
}
if (strcmp(key, "radio.rxgain") == 0 || strcmp(key, "repeat") == 0) {
bool enabled;
if (!wcParseBool(value, enabled)) {
mesh::cli::formatError(reply, 160, "must be on or off");
} else {
if (strcmp(key, "radio.rxgain") == 0) {
if (!radio_driver.supportsRxBoostedGainMode()) {
mesh::cli::formatError(reply, 160, "RX gain unsupported");
return;
}
if (!applyAndSaveRxBoostedGain(enabled)) {
mesh::cli::formatError(reply, 160, "RX gain failed");
return;
}
} else {
if (!savePreference(_prefs.client_repeat, static_cast<uint8_t>(enabled))) {
mesh::cli::formatError(reply, 160, "repeat could not be saved");
return;
}
}
strcpy(reply, "OK");
}
return;
}
if (strcmp(key, "radio.rxps") == 0) {
applyAndSaveRxPowerSaving(value, reply);
return;
}
if (strcmp(key, "powersaving") == 0) {
applyAndSavePowerSaving(value, reply);
return;
}
#if defined(ESP32) && defined(WIFI_SSID)
if (strcmp(key, "wifi.powersave") == 0) {
applyAndSaveWiFiPowerSaving(value, reply, 160);
return;
}
#endif
if (strcmp(key, "radio.fem.rxgain") == 0) {
bool enabled;
if (!wcParseBool(value, enabled)) {
mesh::cli::formatError(reply, 160, "must be on or off");
} else if (_radio && (_radio->isMorseTxActive() || _radio->isMorseRxActive())) {
mesh::cli::formatError(reply, 160, "stop Morse before changing LoRa FEM gain");
} else if (!board.canControlLoRaFemLna()) {
mesh::cli::formatError(reply, 160, "unsupported");
} else if (!applyAndSaveFemRxGain(enabled)) {
mesh::cli::formatError(reply, 160, "failed to apply FEM RX gain");
} else {
strcpy(reply, "OK");
}
return;
}
if (strcmp(key, "radio.fem.txgain") == 0) {
bool enabled;
if (!wcParseBool(value, enabled)) {
mesh::cli::formatError(reply, 160, "must be on or off");
} else if (_radio && (_radio->isMorseTxActive() || _radio->isMorseRxActive())) {
mesh::cli::formatError(reply, 160, "stop Morse before changing LoRa FEM gain");
} else if (!board.canControlLoRaFemPaGain()) {
mesh::cli::formatError(reply, 160, "unsupported");
} else if (!applyAndSaveFemTxGain(enabled)) {
mesh::cli::formatError(reply, 160, "failed to apply FEM TX gain");
} else {
strcpy(reply, "OK");
}
return;
}
#ifdef WITH_MQTT_BRIDGE
bool changed = false;
if (strcmp(key, "mqtt.origin") == 0) {
changed = wcCopyValue(_mqtt_prefs.mqtt_origin, sizeof(_mqtt_prefs.mqtt_origin), value);
} else if (strcmp(key, "mqtt.iata") == 0) {
changed = wcCopyValue(_mqtt_prefs.mqtt_iata, sizeof(_mqtt_prefs.mqtt_iata), value);
if (changed) {
for (char* p = _mqtt_prefs.mqtt_iata; *p; p++) *p = toupper(static_cast<unsigned char>(*p));
}
} else if (strcmp(key, "mqtt.status") == 0 || strcmp(key, "mqtt.packets") == 0
|| strcmp(key, "mqtt.raw") == 0 || strcmp(key, "mqtt.rx") == 0
|| strcmp(key, "mqtt.advert.verify") == 0
|| strcmp(key, "snmp") == 0) {
bool enabled;
if (!wcParseBool(value, enabled)) {
mesh::cli::formatError(reply, 160, "must be on or off");
return;
}
if (strcmp(key, "mqtt.status") == 0) _mqtt_prefs.mqtt_status_enabled = enabled;
else if (strcmp(key, "mqtt.packets") == 0) _mqtt_prefs.mqtt_packets_enabled = enabled;
else if (strcmp(key, "mqtt.raw") == 0) _mqtt_prefs.mqtt_raw_enabled = enabled;
else if (strcmp(key, "mqtt.rx") == 0) _mqtt_prefs.mqtt_rx_enabled = enabled;
else if (strcmp(key, "mqtt.advert.verify") == 0) _mqtt_prefs.mqtt_advert_verify = enabled;
else _mqtt_prefs.snmp_enabled = enabled;
changed = true;
} else if (strcmp(key, "mqtt.tx") == 0) {
if (strcmp(value, "off") == 0) _mqtt_prefs.mqtt_tx_enabled = 0;
else if (strcmp(value, "on") == 0) _mqtt_prefs.mqtt_tx_enabled = 1;
else if (strcmp(value, "advert") == 0) _mqtt_prefs.mqtt_tx_enabled = 2;
else {
mesh::cli::formatError(reply, 160, "MQTT TX must be off, on, or advert");
return;
}
changed = true;
} else if (strcmp(key, "mqtt.interval") == 0) {
long minutes;
if (!wcParseLong(value, 1, 60, minutes)) {
mesh::cli::formatError(reply, 160, "interval must be 1-60 minutes");
return;
}
_mqtt_prefs.mqtt_status_interval = static_cast<uint32_t>(minutes) * 60000UL;
changed = true;
} else if (strcmp(key, "mqtt.ntp") == 0) {
if (strcmp(value, "none") == 0) {
_mqtt_prefs.mqtt_ntp_server[0] = 0;
changed = true;
} else if (wcValidNtpHost(value)) {
changed = wcCopyValue(_mqtt_prefs.mqtt_ntp_server,
sizeof(_mqtt_prefs.mqtt_ntp_server), value);
}
} else if (strcmp(key, "mqtt.owner") == 0) {
if (!value[0]) {
_mqtt_prefs.mqtt_owner_public_key[0] = 0;
changed = true;
} else if (wcValidHexKey(value)) {
changed = wcCopyValue(_mqtt_prefs.mqtt_owner_public_key,
sizeof(_mqtt_prefs.mqtt_owner_public_key), value);
}
} else if (strcmp(key, "mqtt.email") == 0) {
changed = wcCopyValue(_mqtt_prefs.mqtt_email, sizeof(_mqtt_prefs.mqtt_email), value);
} else if (strcmp(key, "timezone") == 0) {
changed = wcCopyValue(_mqtt_prefs.timezone_string, sizeof(_mqtt_prefs.timezone_string), value);
} else if (strcmp(key, "timezone.offset") == 0) {
long offset;
if (!wcParseLong(value, -12, 14, offset)) {
mesh::cli::formatError(reply, 160, "timezone offset must be -12 to 14");
return;
}
_mqtt_prefs.timezone_offset = static_cast<int8_t>(offset);
changed = true;
} else if (strcmp(key, "snmp.community") == 0) {
changed = wcCopyValue(_mqtt_prefs.snmp_community,
sizeof(_mqtt_prefs.snmp_community), value);
} else if (strncmp(key, "mqtt", 4) == 0 && key[4] >= '1'
&& key[4] <= ('0' + MAX_MQTT_SLOTS) && key[5] == '.') {
int slot = key[4] - '1';
const char* field = key + 6;
if (strcmp(field, "preset") == 0) {
const bool valid = findMQTTPreset(value) != nullptr
|| strcmp(value, MQTT_PRESET_CUSTOM) == 0
|| strcmp(value, MQTT_PRESET_NONE) == 0;
if (!valid) {
mesh::cli::formatError(reply, 160, "unknown MQTT preset");
return;
}
if (findMQTTPreset(value)) {
for (int i = 0; i < MAX_MQTT_SLOTS; i++) {
if (i != slot && strcmp(_mqtt_prefs.mqtt_slot_preset[i], value) == 0) {
snprintf(reply, 160, "Error: preset already assigned to slot %d", i + 1);
return;
}
}
}
changed = wcCopyValue(_mqtt_prefs.mqtt_slot_preset[slot],
sizeof(_mqtt_prefs.mqtt_slot_preset[slot]), value);
} else if (strcmp(field, "server") == 0) {
changed = wcCopyValue(_mqtt_prefs.mqtt_slot_host[slot],
sizeof(_mqtt_prefs.mqtt_slot_host[slot]), value);
} else if (strcmp(field, "port") == 0) {
uint16_t port;
if (!mesh::cli::parseMqttPort(value, port)) {
mesh::cli::formatError(reply, 160, "port must be 0-65535 (0 uses the URI default)");
return;
}
_mqtt_prefs.mqtt_slot_port[slot] = static_cast<uint16_t>(port);
changed = true;
} else if (strcmp(field, "username") == 0) {
changed = wcCopyValue(_mqtt_prefs.mqtt_slot_username[slot],
sizeof(_mqtt_prefs.mqtt_slot_username[slot]), value);
} else if (strcmp(field, "password") == 0) {
changed = wcCopyValue(_mqtt_prefs.mqtt_slot_password[slot],
sizeof(_mqtt_prefs.mqtt_slot_password[slot]), value);
} else if (strcmp(field, "token") == 0) {
changed = wcCopyValue(_mqtt_prefs.mqtt_slot_token[slot],
sizeof(_mqtt_prefs.mqtt_slot_token[slot]), value);
} else if (strcmp(field, "topic") == 0) {
if (strcmp(_mqtt_prefs.mqtt_slot_preset[slot], MQTT_PRESET_CUSTOM) != 0) {
mesh::cli::formatError(reply, 160, "topic only applies to custom slots");
return;
}
changed = wcCopyValue(_mqtt_prefs.mqtt_slot_topic[slot],
sizeof(_mqtt_prefs.mqtt_slot_topic[slot]), value);
} else if (strcmp(field, "audience") == 0) {
changed = wcCopyValue(_mqtt_prefs.mqtt_slot_audience[slot],
sizeof(_mqtt_prefs.mqtt_slot_audience[slot]), value);
}
}
if (changed) {
_wc_mqtt_dirty = true;
strcpy(reply, "OK");
} else if (reply[0] == 0) {
mesh::cli::formatError(reply, 160, "invalid or unsupported value");
}
#else
mesh::cli::formatError(reply, 160, "unsupported setting for this companion");
#endif
}
void MyMesh::buildStatsJson(char* buf, size_t buf_size) {
char ip[20] = "";
int wifi_rssi = 0;
if (WiFi.status() == WL_CONNECTED) {
strncpy(ip, WiFi.localIP().toString().c_str(), sizeof(ip) - 1);
wifi_rssi = WiFi.RSSI();
} else if (_webconfig && _webconfig->mode() == WebConfigServer::MODE_SETUP) {
strncpy(ip, WiFi.softAPIP().toString().c_str(), sizeof(ip) - 1);
}
int pos = snprintf(buf, buf_size,
"{\"uptime_s\":%lu,\"batt_mv\":%u,"
"\"heap_free\":%lu,\"heap_min\":%lu,\"heap_max_alloc\":%lu,"
"\"noise\":%d,\"rssi\":%d,\"snr\":%.1f,"
"\"airtime_s\":%lu,\"rx_airtime_s\":%lu,"
"\"recv\":%lu,\"sent\":%lu,\"rx_err\":%lu,"
"\"sent_flood\":%lu,\"sent_direct\":%lu,\"recv_flood\":%lu,\"recv_direct\":%lu,"
"\"tx_queue\":%d,\"wifi_rssi\":%d,\"ip\":\"%s\",\"mqtt_queue\":%d,\"slots\":[",
(unsigned long)(millis() / 1000), (unsigned)board.getBattMilliVolts(),
(unsigned long)ESP.getFreeHeap(), (unsigned long)ESP.getMinFreeHeap(),
(unsigned long)ESP.getMaxAllocHeap(),
(int)_radio->getNoiseFloor(), (int)radio_driver.getLastRSSI(),
radio_driver.getLastSNR(),
(unsigned long)(getTotalAirTime() / 1000), (unsigned long)(getReceiveAirTime() / 1000),
(unsigned long)radio_driver.getPacketsRecv(), (unsigned long)radio_driver.getPacketsSent(),
(unsigned long)radio_driver.getPacketsRecvErrors(),
(unsigned long)getNumSentFlood(), (unsigned long)getNumSentDirect(),
(unsigned long)getNumRecvFlood(), (unsigned long)getNumRecvDirect(),
(int)_mgr->getOutboundCount(0xFFFFFFFF), wifi_rssi, ip,
#ifdef WITH_MQTT_BRIDGE
_mqtt_bridge ? _mqtt_bridge->getQueueSize() : 0);
#else
0);
#endif
if (pos < 0 || pos >= static_cast<int>(buf_size) - 3) return;
#ifdef WITH_MQTT_BRIDGE
bool first = true;
for (int i = 0; i < RUNTIME_MQTT_SLOTS; i++) {
MQTTBridge::SlotStatusSnapshot status;
if (!MQTTBridge::getSlotStatusSnapshot(i, &status)) continue;
int written = snprintf(buf + pos, buf_size - pos,
"%s{\"n\":%d,\"name\":\"%s\",\"state\":\"%s\"}",
first ? "" : ",", i + 1, status.name, status.state);
if (written < 0 || written >= static_cast<int>(buf_size - pos)) break;
pos += written;
first = false;
}
#endif
snprintf(buf + pos, buf_size - pos, "]}");
}
#endif
struct FreqRange {
uint32_t lower_freq, upper_freq;
};
static FreqRange repeat_freq_ranges[] = {
#ifdef ALLOWED_REPEAT_FREQ_RANGE
ALLOWED_REPEAT_FREQ_RANGE
#else
{ 433000, 433000 },
{ 869495, 869495 },
{ 918000, 918000 }
#endif
};
bool MyMesh::isValidClientRepeatFreq(uint32_t f) const {
for (int i = 0; i < sizeof(repeat_freq_ranges)/sizeof(repeat_freq_ranges[0]); i++) {
auto r = &repeat_freq_ranges[i];
if (f >= r->lower_freq && f <= r->upper_freq) return true;
}
return false;
}
void MyMesh::startInterface(BaseSerialInterface &serial) {
_serial = &serial;
serial.enable();
}
void MyMesh::clearPendingReqs() {
_delayed_replies.retireRequest(_ms->getMillis());
}
bool MyMesh::hasPendingReqs() const {
return _delayed_replies.hasRequest();
}
bool MyMesh::beginPendingRequest(mesh::CompanionDelayedReplies::Kind kind,
const ContactInfo& contact, bool terminal) {
servicePendingSerialReply();
_request_tag_rejected = false;
return _delayed_replies.reserveRequest(kind, contact.id.pub_key,
terminal || _serial == NULL ? NULL : _serial->captureReplyRoute(),
terminal, _ms->getMillis());
}
bool MyMesh::allowRequestTag(uint32_t tag) {
// Other internal Base callers need no host correlation reservation.
if (_delayed_replies.request.phase != mesh::CompanionDelayedReplies::Reserved) return true;
_request_tag_rejected = !_delayed_replies.allowRequestTag(tag);
return !_request_tag_rejected;
}
bool MyMesh::allocateRequestTag(uint32_t& tag) {
// Login replies use the server's clock, so their retired tag can equal
// the next locally generated request tag even with synchronized clocks.
// Skip held tags instead of reporting BAD_STATE to the app. One history
// slot belongs to this reservation, leaving at most HISTORY_SIZE - 1
// collisions. Keep the wall clock and all stale-response guards intact.
for (unsigned attempt = 0;
attempt < mesh::CompanionDelayedReplies::HISTORY_SIZE; ++attempt) {
tag = getRTCClock()->getCurrentTimeUnique();
if (allowRequestTag(tag)) return true;
}
return false;
}
void MyMesh::armPendingRequest(uint32_t tag, uint32_t timeout, bool flood) {
const uint32_t now = _ms->getMillis();
_delayed_replies.armRequest(tag, timeout, flood, now);
servicePendingSerialReply(now);
}
void MyMesh::finishPendingRequest(int result, uint32_t tag, uint32_t timeout) {
if (result == MSG_SEND_FAILED) {
abandonPendingRequest();
writeErrFrame(_request_tag_rejected ? ERR_CODE_BAD_STATE : ERR_CODE_TABLE_FULL);
} else {
armPendingRequest(tag, timeout, result == MSG_SEND_SENT_FLOOD);
}
}
void MyMesh::abandonPendingRequest() {
_delayed_replies.abandonRequest();
}
bool MyMesh::hasFiniteDelayedReplyForRoute(BaseSerialInterface* route) const {
if (route == NULL) return false;
if (_delayed_replies.hasReplyForRoute(route, _ms->getMillis())) {
return true;
}
for (int i = 0; i < EXPECTED_ACK_TABLE_SIZE; ++i) {
if (expected_ack_table[i].ack != 0
&& expected_ack_table[i].reply_route == route) {
return true;
}
}
return false;
}
void MyMesh::servicePendingSerialReply() {
servicePendingSerialReply(_ms->getMillis());
}
void MyMesh::servicePendingSerialReply(uint32_t now) {
#if COMPANION_FEATURE_TEXT_TERMINAL
serviceTerminalLogin(now);
#endif
_delayed_replies.serviceRequest(_serial, now);
}
void MyMesh::clearBinaryTraceReply() {
_delayed_replies.retireBinaryTrace(_ms->getMillis());
}
void MyMesh::serviceBinaryTraceReply() {
serviceBinaryTraceReply(_ms->getMillis());
}
void MyMesh::serviceBinaryTraceReply(uint32_t now) {
_delayed_replies.serviceBinaryTrace(_serial, now);
}
void MyMesh::cancelSigningSession() {
if (sign_data != NULL) free(sign_data);
sign_data = NULL;
sign_data_len = 0;
sign_data_reply_route = NULL;
sign_data_deadline = 0;
}
void MyMesh::serviceSigningSession() {
if (sign_data == NULL) return;
if (!_serial->isReplyRouteAvailable(sign_data_reply_route)
|| sign_data_deadline == _ms->getMillis()
|| millisHasNowPassed(sign_data_deadline)) {
cancelSigningSession();
}
}
void MyMesh::cancelSerialResponseStream(BaseSerialInterface* route) {
if (route == NULL || _iter_reply_route == route) stopContactsIterator();
}
void MyMesh::cancelSerialOperationsForRoute(BaseSerialInterface* route) {
if (route == NULL) return;
if (private_key_backup_route == route) {
private_key_backup_route = NULL;
private_key_backup_deadline = 0;
memset(private_key_backup_nonce, 0, sizeof(private_key_backup_nonce));
memset(private_key_backup_sender, 0, sizeof(private_key_backup_sender));
}
if (_delayed_replies.request.route == route) clearPendingReqs();
if (_delayed_replies.trace.route == route) clearBinaryTraceReply();
if (sign_data_reply_route == route) cancelSigningSession();
for (int i = 0; i < EXPECTED_ACK_TABLE_SIZE; ++i) {
if (expected_ack_table[i].reply_route == route) {
// A host-session change must prevent the late ACK from reaching a new
// session on the same transport, but it must not revoke radio delivery
// for a message for which RESP_CODE_SENT was already returned.
expected_ack_table[i].reply_route = NULL;
}
}
expireExpectedAcks();
}
void MyMesh::resetUsbHostSessionInput() {
// CLI rescue deliberately remains active across reconnects, but no bytes
// typed by the old host may complete a command for the new one. This is
// especially important for the rescue shell's erase/rm commands.
if (_cli_rescue) {
memset(cli_command, 0, sizeof(cli_command));
_cli_line_overflow = false;
}
}
void MyMesh::handleCmdFrame(size_t len) {
if (len == 0) {
writeErrFrame(ERR_CODE_ILLEGAL_ARG);
return;
}
#if defined(MESH_HIL_BLE_BENCH) && MESH_HIL_BLE_BENCH
if (cmd_frame[0] == mesh::companion::kBleBenchCommand) {
// Direct Companion transport benchmark only: no RF dispatch, settings,
// identity reads or persistent state. Reuse the normal reply queue and
// its required-frame admission/backpressure, including the source route.
const size_t echoed = mesh::companion::buildBleBenchEcho(
cmd_frame, len, out_frame, MAX_FRAME_SIZE);
if (echoed) _serial->writeFrame(out_frame, echoed);
else writeErrFrame(ERR_CODE_ILLEGAL_ARG);
return;
}
#endif
servicePendingSerialReply();
const bool starts_long_lived_request =
(cmd_frame[0] == CMD_SEND_LOGIN && len >= 1 + PUB_KEY_SIZE)
|| (cmd_frame[0] == CMD_SEND_ANON_REQ && len > 1 + PUB_KEY_SIZE)
|| (cmd_frame[0] == CMD_SEND_STATUS_REQ && len >= 1 + PUB_KEY_SIZE)
|| (cmd_frame[0] == CMD_SEND_PATH_DISCOVERY_REQ
&& len >= 2 + PUB_KEY_SIZE)
|| (cmd_frame[0] == CMD_SEND_TELEMETRY_REQ
&& len >= 4 + PUB_KEY_SIZE)
|| (cmd_frame[0] == CMD_SEND_BINARY_REQ
&& len >= 2 + PUB_KEY_SIZE);
if (starts_long_lived_request && hasPendingReqs()) {
// There is one correlation slot for these request families. Reject before
// transmitting rather than orphaning an earlier USB/BLE client's SENT
// operation and reassigning its late response to the new requester.
writeErrFrame(ERR_CODE_BAD_STATE);
return;
}
if (cmd_frame[0] == CMD_DEVICE_QUERY && len >= 2) { // sent when app establishes connection
app_target_ver = cmd_frame[1]; // which version of protocol does app understand
int i = 0;
out_frame[i++] = RESP_CODE_DEVICE_INFO;
out_frame[i++] = FIRMWARE_VER_CODE;
out_frame[i++] = MAX_CONTACTS / 2; // v3+
out_frame[i++] = MAX_GROUP_CHANNELS; // v3+
memcpy(&out_frame[i], &_active_ble_pin, 4);
i += 4;
memset(&out_frame[i], 0, 12);
strcpy((char *)&out_frame[i], FIRMWARE_BUILD_DATE);
i += 12;
StrHelper::strzcpy((char *)&out_frame[i], board.getManufacturerName(), 40);
i += 40;
StrHelper::strzcpy((char *)&out_frame[i], FIRMWARE_VERSION, 20);
i += 20;
out_frame[i++] = _prefs.isRepeatEn() ? 1 : 0; // v9+
out_frame[i++] = _prefs.path_hash_mode; // v10+
_serial->writeFrame(out_frame, i);
} else if (cmd_frame[0] == CMD_APP_START &&
len >= 8) { // sent when app establishes connection, respond with node ID
// cmd_frame[1..7] reserved future
char *app_name = (char *)&cmd_frame[8];
cmd_frame[len] = 0; // make app_name null terminated
MESH_DEBUG_PRINTLN("App %s connected", app_name);
BaseSerialInterface* app_route = _serial->captureReplyRoute();
cancelSerialResponseStream(app_route);
cancelSerialOperationsForRoute(app_route);
int i = 0;
out_frame[i++] = RESP_CODE_SELF_INFO;
out_frame[i++] = ADV_TYPE_CHAT; // what this node Advert identifies as (maybe node's pronouns too?? :-)
out_frame[i++] = _prefs.tx_power_dbm;
out_frame[i++] = MAX_LORA_TX_POWER;
memcpy(&out_frame[i], self_id.pub_key, PUB_KEY_SIZE);
i += PUB_KEY_SIZE;
int32_t lat, lon;
lat = (sensors.node_lat * 1000000.0);
lon = (sensors.node_lon * 1000000.0);
memcpy(&out_frame[i], &lat, 4);
i += 4;
memcpy(&out_frame[i], &lon, 4);
i += 4;
out_frame[i++] = _prefs.multi_acks; // new v7+
out_frame[i++] = _prefs.advert_loc_policy;
out_frame[i++] = (_prefs.telemetry_mode_env << 4) | (_prefs.telemetry_mode_loc << 2) |
(_prefs.telemetry_mode_base); // v5+
out_frame[i++] = _prefs.manual_add_contacts;
uint32_t freq = _prefs.freq * 1000;
memcpy(&out_frame[i], &freq, 4);
i += 4;
uint32_t bw = _prefs.bw * 1000;
memcpy(&out_frame[i], &bw, 4);
i += 4;
out_frame[i++] = _prefs.sf;
out_frame[i++] = _prefs.cr;
int tlen = strlen(_prefs.node_name); // revisit: UTF_8 ??
memcpy(&out_frame[i], _prefs.node_name, tlen);
i += tlen;
_serial->writeFrame(out_frame, i);
} else if (mesh::companion::isRunCliFrame(cmd_frame[0], len)) { // V14+
int i = 1;
char *text = (char *)&cmd_frame[i];
const size_t tlen = mesh::companion::cliCommandTextLength(
&cmd_frame[i], len - i);
if (tlen == 0) {
writeErrFrame(ERR_CODE_ILLEGAL_ARG);
} else {
text[tlen] = 0; // ensure null
BaseSerialInterface* reply_route = _serial->captureReplyRoute();
beginUsbLoggingReplyBarrier(reply_route);
reply_buf[0] = 0;
bool handled = false;
#if defined(MESHCORE_EXTRAFS_HIL)
// This hook is deliberately reachable only from a directly attached
// Companion frame. It is not part of handleCommand(), so an on-air
// administrator command can never fill, corrupt, or reset the device.
handled = handleExtraFsHilCommand(text, reply_buf, sizeof(reply_buf));
#endif
if (!handled) handled = handleCommand(text, 0, reply_buf);
if (!handled) {
strcat(reply_buf, "Unknown command"); // reply_buf may have cmd prefix from 'text'
}
out_frame[0] = RESP_CODE_CLI_REPLY;
int rlen = strlen(reply_buf);
memcpy(&out_frame[1], reply_buf, rlen);
const bool reply_queued = _serial->writeFrameToRoute(
reply_route, out_frame, 1 + rlen) == static_cast<size_t>(1 + rlen);
endUsbLoggingReplyBarrier(reply_queued);
}
} else if (cmd_frame[0] == CMD_SEND_TXT_MSG && len >= 14) {
int i = 1;
uint8_t txt_type = cmd_frame[i++];
uint8_t attempt = cmd_frame[i++];
uint32_t msg_timestamp;
memcpy(&msg_timestamp, &cmd_frame[i], 4);
i += 4;
uint8_t *pub_key_prefix = &cmd_frame[i];
i += 6;
ContactInfo *recipient = lookupContactByPubKey(pub_key_prefix, 6);
if (recipient && (txt_type == TXT_TYPE_PLAIN || txt_type == TXT_TYPE_CLI_DATA || txt_type == TXT_TYPE_CLI_COMMAND)) {
char *text = (char *)&cmd_frame[i];
int tlen = len - i;
uint32_t est_timeout;
text[tlen] = 0; // ensure null
uint8_t text_fingerprint[MAX_HASH_SIZE] = { 0 };
uint8_t packet_retry_key[MAX_HASH_SIZE] = { 0 };
AckTableEntry* replacement_entry = NULL;
if (txt_type == TXT_TYPE_PLAIN) {
mesh::Utils::sha256(text_fingerprint, sizeof(text_fingerprint),
recipient->id.pub_key, PUB_KEY_SIZE,
(const uint8_t*)text, strlen(text));
}
int result;
uint32_t expected_ack;
uint32_t one_key_delay = 0;
if (txt_type == TXT_TYPE_CLI_DATA || txt_type == TXT_TYPE_CLI_COMMAND) {
const uint32_t logical_request_id = msg_timestamp;
msg_timestamp = getRTCClock()->getCurrentTimeUnique(); // Use node's RTC instead of app timestamp to avoid tripping replay protection
result = sendCommandData(*recipient, msg_timestamp, attempt, txt_type,
text, est_timeout, logical_request_id);
expected_ack = 0; // no Ack expected
} else {
const uint32_t app_timestamp = msg_timestamp;
const bool is_room_post = recipient->type == ADV_TYPE_ROOM;
if (is_room_post
&& !room_message_timestamps.find(text_fingerprint, app_timestamp,
&msg_timestamp)) {
// Room login/control packets already use the node's monotonic clock.
// Give a new post that same clock source, then preserve the mapping
// so application retries keep one logical server-side timestamp.
msg_timestamp = getRTCClock()->getCurrentTimeUnique();
}
replacement_entry = findPendingTextMessage(text_fingerprint, msg_timestamp);
#if MESH_ENABLE_ONE_KEY_DM
if (recipient->type == ADV_TYPE_CHAT
&& !hasOneKeyReject(*recipient)
&& (attempt != 0 || !hasOneKeyAck(*recipient))
&& tlen <= MAX_TEXT_LEN
&& (attempt <= 3 || tlen <= MAX_TEXT_LEN - 2)) {
one_key_delay = sendOneKeyIntroduction(*recipient);
}
#endif
result = sendMessage(*recipient, msg_timestamp, attempt, text, expected_ack, est_timeout,
packet_retry_key, NULL, text_fingerprint, one_key_delay);
if (result != MSG_SEND_FAILED && is_room_post) {
room_message_timestamps.remember(text_fingerprint, app_timestamp,
msg_timestamp);
}
}
if (result == MSG_SEND_FAILED) {
writeErrFrame(ERR_CODE_TABLE_FULL);
} else {
if ((txt_type == TXT_TYPE_CLI_DATA || txt_type == TXT_TYPE_CLI_COMMAND)
&& strncmp(text, "backup prv.key ", 15) == 0
&& strlen(text + 15) == 16) {
private_key_backup_route = _serial->captureReplyRoute();
private_key_backup_deadline = futureMillis(120000);
memcpy(private_key_backup_nonce, text + 15, 16);
private_key_backup_nonce[16] = 0;
memcpy(private_key_backup_sender, recipient->id.pub_key, 6);
}
if (replacement_entry != NULL) {
// The newest successfully-queued submission wins. Keep the older
// entry intact if composition, validation, or queueing failed.
clearExpectedAck(*replacement_entry, false);
}
if (expected_ack) {
AckTableEntry& entry = replacement_entry != NULL
? *replacement_entry
: expected_ack_table[next_ack_idx];
// Reusing this circular slot changes only ACK bookkeeping. Retry
// ownership lives in Mesh and ends only on an ACK, retry completion,
// or a successfully queued semantic replacement.
clearExpectedAck(entry, false);
entry.msg_sent = _ms->getMillis() + one_key_delay; // scheduled radio send
entry.expires_at = futureMillis(est_timeout);
entry.ack = expected_ack;
entry.message_timestamp = msg_timestamp;
entry.contact = recipient;
entry.reply_route = _serial->captureReplyRoute();
memcpy(entry.text_fingerprint, text_fingerprint, sizeof(entry.text_fingerprint));
memcpy(entry.retry_key, packet_retry_key, sizeof(entry.retry_key));
if (replacement_entry == NULL) {
next_ack_idx = (next_ack_idx + 1) % EXPECTED_ACK_TABLE_SIZE;
}
}
if (replacement_entry != NULL || expected_ack != 0) {
expireExpectedAcks();
}
out_frame[0] = RESP_CODE_SENT;
out_frame[1] = (result == MSG_SEND_SENT_FLOOD) ? 1 : 0;
memcpy(&out_frame[2], &expected_ack, 4);
memcpy(&out_frame[6], &est_timeout, 4);
_serial->writeFrame(out_frame, 10);
}
} else {
writeErrFrame(recipient == NULL
? ERR_CODE_NOT_FOUND
: ERR_CODE_UNSUPPORTED_CMD); // unknown recipient, or unsupported TXT_TYPE_*
}
} else if (cmd_frame[0] == CMD_SEND_CHANNEL_TXT_MSG) { // send GroupChannel text msg
if (len < 7) {
writeErrFrame(ERR_CODE_ILLEGAL_ARG);
return;
}
int i = 1;
uint8_t txt_type = cmd_frame[i++]; // should be TXT_TYPE_PLAIN
uint8_t channel_idx = cmd_frame[i++];
uint32_t msg_timestamp;
memcpy(&msg_timestamp, &cmd_frame[i], 4);
i += 4;
const char *text = (char *)&cmd_frame[i];
if (txt_type != TXT_TYPE_PLAIN) {
writeErrFrame(ERR_CODE_UNSUPPORTED_CMD);
} else {
ChannelDetails channel;
bool success = getChannel(channel_idx, channel);
#if MESH_ENABLE_MESHTEXT_CHAT
if (success && isMeshtextChannel(channel)) {
if (sendMeshtextText(channel, text, len - i)) {
#if UI_POCKET_GUI
if (_ui) _ui->channelMessageSent(channel_idx, channel.name, _prefs.node_name, text, len - i);
#endif
writeOKFrame();
}
else writeErrFrame(ERR_CODE_BAD_STATE);
} else
#endif
if (success && isAliasRadio2Defined()
&& parseCompanionChannelAlias(channel.name).kind == CompanionChannelKind::Meshtext) {
writeErrFrame(ERR_CODE_UNSUPPORTED_CMD);
} else if (success && sendGroupMessage(msg_timestamp, channel.channel, _prefs.node_name, text, len - i)) {
#if UI_POCKET_GUI
if (_ui) _ui->channelMessageSent(channel_idx, channel.name, _prefs.node_name, text, len - i);
#endif
writeOKFrame();
} else {
writeErrFrame(ERR_CODE_NOT_FOUND); // bad channel_idx
}
}
} else if (cmd_frame[0] == CMD_SEND_CHANNEL_DATA) { // send GroupChannel datagram
if (len < 3) {
writeErrFrame(ERR_CODE_ILLEGAL_ARG);
return;
}
int i = 1;
uint8_t channel_idx = cmd_frame[i++];
uint8_t path_len = cmd_frame[i++];
// validate path len, allowing 0xFF for flood
if (!mesh::Packet::isValidPathLen(path_len) && path_len != OUT_PATH_UNKNOWN) {
MESH_DEBUG_PRINTLN("CMD_SEND_CHANNEL_DATA invalid path size: %d", path_len);
writeErrFrame(ERR_CODE_ILLEGAL_ARG);
return;
}
// parse provided path if not flood
uint8_t path[MAX_PATH_SIZE];
size_t path_bytes = 0;
if (path_len != OUT_PATH_UNKNOWN) {
path_bytes = (size_t)(path_len & 63) * (size_t)((path_len >> 6) + 1);
}
if ((size_t)i + path_bytes + 2 > len) {
writeErrFrame(ERR_CODE_ILLEGAL_ARG);
return;
}
if (path_len != OUT_PATH_UNKNOWN) {
i += mesh::Packet::writePath(path, &cmd_frame[i], path_len);
}
uint16_t data_type = ((uint16_t)cmd_frame[i]) | (((uint16_t)cmd_frame[i + 1]) << 8);
i += 2;
const uint8_t *payload = &cmd_frame[i];
int payload_len = (len > (size_t)i) ? (int)(len - i) : 0;
ChannelDetails channel;
if (!getChannel(channel_idx, channel)) {
writeErrFrame(ERR_CODE_NOT_FOUND); // bad channel_idx
#if MESH_ENABLE_MESHTEXT_CHAT
} else if (isMeshtextChannel(channel)) {
writeErrFrame(ERR_CODE_UNSUPPORTED_CMD); // channel text only, never raw/admin data
#endif
} else if (isAliasRadio2Defined()
&& parseCompanionChannelAlias(channel.name).kind == CompanionChannelKind::Meshtext) {
writeErrFrame(ERR_CODE_UNSUPPORTED_CMD);
} else if (data_type == DATA_TYPE_RESERVED) {
writeErrFrame(ERR_CODE_ILLEGAL_ARG);
} else if (payload_len > MAX_GROUP_DATA_LENGTH) {
MESH_DEBUG_PRINTLN("CMD_SEND_CHANNEL_DATA payload too long: %d > %d", payload_len, MAX_GROUP_DATA_LENGTH);
writeErrFrame(ERR_CODE_ILLEGAL_ARG);
} else if (sendClientGroupData(channel.channel, path, path_len, data_type, payload, payload_len)) {
writeOKFrame();
} else {
writeErrFrame(ERR_CODE_TABLE_FULL);
}
} else if (cmd_frame[0] == CMD_GET_CONTACTS) { // get Contact list
if (_iter_started) {
writeErrFrame(ERR_CODE_BAD_STATE); // iterator is currently busy
} else if (_store->hasIncompleteContactLoad()) {
// Never present a transiently unread page as an authoritative partial
// contact list. A reboot can retry the untouched source page.
writeErrFrame(ERR_CODE_FILE_IO_ERROR);
} else {
if (len >= 5) { // has optional 'since' param
memcpy(&_iter_filter_since, &cmd_frame[1], 4);
} else {
_iter_filter_since = 0;
}
// Retain this stream's destination independently of later commands.
// BLE's paced contact sync must not hold USB/WiFi command polling until
// the entire list finishes, or redirect their replies to the BLE app.
_iter_reply_route = _serial->captureReplyRoute();
_iter = startContactsIterator();
_iter_started = true;
_iter_start_pending = true;
_iter_contact_pending = false;
_iter_next_frame_at = 0;
_iter_total_count = getNumContacts();
_iter_table_revision = getContactTableRevision();
_most_recent_lastmod = 0;
uint8_t reply[5];
reply[0] = RESP_CODE_CONTACTS_START;
memcpy(&reply[1], &_iter_total_count, 4); // total, NOT filtered count
if (_serial->writeFrameToRoute(_iter_reply_route, reply, 5) == 5) {
_iter_start_pending = false;
_iter_next_frame_at = futureMillis(CONTACT_STREAM_FRAME_INTERVAL_MS);
}
}
} else if (cmd_frame[0] == CMD_SET_ADVERT_NAME && len >= 2) {
char name[sizeof(_prefs.node_name)];
int nlen = len - 1;
if (nlen > sizeof(name) - 1) nlen = sizeof(name) - 1; // max len
memcpy(name, &cmd_frame[1], nlen);
name[nlen] = 0;
if (saveAdvertName(name)) writeOKFrame();
else writeErrFrame(ERR_CODE_FILE_IO_ERROR);
} else if (cmd_frame[0] == CMD_SET_ADVERT_LATLON && len >= 9) {
int32_t lat, lon, alt = 0;
memcpy(&lat, &cmd_frame[1], 4);
memcpy(&lon, &cmd_frame[5], 4);
if (len >= 13) {
memcpy(&alt, &cmd_frame[9], 4); // for FUTURE support
}
if (lat <= 90 * 1E6 && lat >= -90 * 1E6 && lon <= 180 * 1E6 && lon >= -180 * 1E6) {
if (saveAdvertLocation(((double)lat) / 1000000.0,
((double)lon) / 1000000.0)) writeOKFrame();
else writeErrFrame(ERR_CODE_FILE_IO_ERROR);
} else {
writeErrFrame(ERR_CODE_ILLEGAL_ARG); // invalid geo coordinate
}
} else if (cmd_frame[0] == CMD_GET_DEVICE_TIME) {
uint8_t reply[5];
reply[0] = RESP_CODE_CURR_TIME;
uint32_t now = getRTCClock()->getCurrentTime();
memcpy(&reply[1], &now, 4);
_serial->writeFrame(reply, 5);
} else if (cmd_frame[0] == CMD_SET_DEVICE_TIME && len >= 5) {
uint32_t secs;
memcpy(&secs, &cmd_frame[1], 4);
uint32_t curr = getRTCClock()->getCurrentTime();
if (secs >= curr) {
getRTCClock()->setCurrentTime(secs);
#ifdef COMPANION_MESH_CLOCK_SYNC
_clock_sync.onManualClockSet();
#endif
writeOKFrame();
} else {
writeErrFrame(ERR_CODE_ILLEGAL_ARG);
}
} else if (cmd_frame[0] == CMD_SEND_SELF_ADVERT) {
mesh::Packet* pkt;
if (_prefs.advert_loc_policy == ADVERT_LOC_NONE) {
pkt = createSelfAdvert(_prefs.node_name);
} else {
pkt = createSelfAdvert(_prefs.node_name, sensors.node_lat, sensors.node_lon);
}
if (pkt) {
if (len >= 2 && cmd_frame[1] == 1) { // optional param (1 = flood, 0 = zero hop)
unsigned long delay_millis = 0;
TransportKey default_scope;
memcpy(&default_scope.key, _prefs.default_scope_key, sizeof(default_scope.key));
sendFloodScoped(default_scope, pkt, delay_millis);
} else {
sendZeroHop(pkt);
}
writeOKFrame();
} else {
writeErrFrame(ERR_CODE_TABLE_FULL);
}
} else if (cmd_frame[0] == CMD_RESET_PATH && len >= 1 + 32) {
if (_store->hasIncompleteContactLoad()) {
writeErrFrame(ERR_CODE_FILE_IO_ERROR);
} else {
uint8_t *pub_key = &cmd_frame[1];
ContactInfo *recipient = lookupPersistentContactByPubKey(pub_key, PUB_KEY_SIZE);
if (recipient) {
const uint8_t previous_out_path_len = recipient->out_path_len;
recipient->out_path_len = OUT_PATH_UNKNOWN;
// recipient->lastmod = ?? shouldn't be needed, app already has this version of contact
if (scheduleContactWrite(*recipient)) {
ContactInfo* transient = lookupTransientContactByPubKey(
pub_key, PUB_KEY_SIZE);
if (transient != NULL) clearTransientContact(*transient, recipient);
writeOKFrame();
} else {
recipient->out_path_len = previous_out_path_len;
writeErrFrame(ERR_CODE_FILE_IO_ERROR);
}
} else {
writeErrFrame(ERR_CODE_NOT_FOUND); // unknown contact
}
}
} else if (cmd_frame[0] == CMD_ADD_UPDATE_CONTACT) {
if (len < CONTACT_UPDATE_FRAME_MIN_LEN) {
writeErrFrame(ERR_CODE_ILLEGAL_ARG);
} else if (_store->hasIncompleteContactLoad()) {
writeErrFrame(ERR_CODE_FILE_IO_ERROR);
} else {
uint8_t *pub_key = &cmd_frame[1];
ContactInfo *recipient = lookupPersistentContactByPubKey(pub_key, PUB_KEY_SIZE);
ContactInfo candidate = {};
if (recipient != NULL) candidate = *recipient;
uint32_t last_mod = getRTCClock()->getCurrentTime(); // fallback value if not present in cmd_frame
if (!updateContactFromFrame(candidate, last_mod, cmd_frame, len)) {
writeErrFrame(ERR_CODE_ILLEGAL_ARG);
} else {
candidate.lastmod = last_mod;
if (recipient == NULL) candidate.sync_since = 0;
ContactInfo* transient = lookupTransientContactByPubKey(
pub_key, PUB_KEY_SIZE);
if (recipient != NULL) {
const ContactInfo previous = *recipient;
*recipient = candidate;
if (scheduleContactWrite(*recipient)) {
// A prior anonymous request can leave a same-key entry in the
// reserved prefix. Retire it in place; never compact that prefix.
if (transient != NULL) clearTransientContact(*transient, recipient);
updateGpsTelemetryPolicy();
writeOKFrame();
} else {
*recipient = previous;
writeErrFrame(ERR_CODE_FILE_IO_ERROR);
}
} else {
// Preserve the transient and its pending ACK references until the
// persistent destination is accepted. Failure preserves the transient.
if (addContact(candidate)) {
ContactInfo* added = lookupPersistentContactByPubKey(
candidate.id.pub_key, PUB_KEY_SIZE);
if (added != NULL && scheduleContactWrite(*added)) {
if (transient != NULL) clearTransientContact(*transient, added);
updateGpsTelemetryPolicy();
writeOKFrame();
} else {
if (added != NULL) removeContact(*added);
writeErrFrame(ERR_CODE_FILE_IO_ERROR);
}
} else {
writeErrFrame(ERR_CODE_TABLE_FULL);
}
}
}
}
} else if (cmd_frame[0] == CMD_REMOVE_CONTACT && len >= 1 + PUB_KEY_SIZE) {
if (_store->hasIncompleteContactLoad()) {
writeErrFrame(ERR_CODE_FILE_IO_ERROR);
} else {
uint8_t *pub_key = &cmd_frame[1];
ContactInfo *recipient = lookupPersistentContactByPubKey(pub_key, PUB_KEY_SIZE);
if (recipient == NULL) {
writeErrFrame(ERR_CODE_NOT_FOUND);
} else {
ContactInfo removed = *recipient;
// releaseContact() is failure-atomic, so reserve the persistent deletion
// before changing the live table. A failed release must not be ACKed.
uint16_t released_slot = mesh::storage::CONTACT_SLOT_NONE;
if (!scheduleContactWriteAfterRelease(removed, released_slot)) {
writeErrFrame(ERR_CODE_FILE_IO_ERROR);
} else if (!_store->deleteBlobByKey(pub_key, PUB_KEY_SIZE)) {
// Keep the live contact and restore ownership of its persistent slot.
// A full filesystem must not turn a failed cache deletion into an
// acknowledged contact removal with stale share data left behind.
if (!restoreContactWriteAfterRelease(*recipient, released_slot)) {
MESH_DEBUG_PRINTLN(
"Contact removal rollback failed after advert-cache error");
}
writeErrFrame(ERR_CODE_FILE_IO_ERROR);
} else if (!removeContact(*recipient)) {
// The lookup above makes this unreachable in the single-threaded mesh
// loop, but restore persistence bookkeeping if that invariant changes.
restoreContactWriteAfterRelease(*recipient, released_slot);
writeErrFrame(ERR_CODE_BAD_STATE);
} else {
ContactInfo* transient = lookupTransientContactByPubKey(
pub_key, PUB_KEY_SIZE);
if (transient != NULL) clearTransientContact(*transient);
updateGpsTelemetryPolicy();
writeOKFrame();
}
}
}
} else if (cmd_frame[0] == CMD_SHARE_CONTACT && len >= 1 + PUB_KEY_SIZE) {
uint8_t *pub_key = &cmd_frame[1];
ContactInfo *recipient = lookupContactByPubKey(pub_key, PUB_KEY_SIZE);
if (recipient) {
if (shareContactZeroHop(*recipient)) {
writeOKFrame();
} else {
writeErrFrame(ERR_CODE_TABLE_FULL); // unable to send
}
} else {
writeErrFrame(ERR_CODE_NOT_FOUND);
}
} else if (cmd_frame[0] == CMD_GET_CONTACT_BY_KEY && len >= 1 + PUB_KEY_SIZE) {
uint8_t *pub_key = &cmd_frame[1];
ContactInfo *contact = lookupContactByPubKey(pub_key, PUB_KEY_SIZE);
if (contact) {
writeContactRespFrame(RESP_CODE_CONTACT, *contact);
} else {
writeErrFrame(ERR_CODE_NOT_FOUND); // not found
}
} else if (cmd_frame[0] == CMD_EXPORT_CONTACT) {
if (len < 1 + PUB_KEY_SIZE) {
// export SELF
mesh::Packet* pkt;
if (_prefs.advert_loc_policy == ADVERT_LOC_NONE) {
pkt = createSelfAdvert(_prefs.node_name);
} else {
pkt = createSelfAdvert(_prefs.node_name, sensors.node_lat, sensors.node_lon);
}
if (pkt) {
pkt->header |= ROUTE_TYPE_FLOOD; // would normally be sent in this mode
out_frame[0] = RESP_CODE_EXPORT_CONTACT;
uint8_t out_len = pkt->writeTo(&out_frame[1]);
releasePacket(pkt); // undo the obtainNewPacket()
_serial->writeFrame(out_frame, out_len + 1);
} else {
writeErrFrame(ERR_CODE_TABLE_FULL); // Error
}
} else {
uint8_t *pub_key = &cmd_frame[1];
ContactInfo *recipient = lookupContactByPubKey(pub_key, PUB_KEY_SIZE);
uint8_t out_len;
if (recipient && (out_len = exportContact(*recipient, &out_frame[1])) > 0) {
out_frame[0] = RESP_CODE_EXPORT_CONTACT;
_serial->writeFrame(out_frame, out_len + 1);
} else {
writeErrFrame(ERR_CODE_NOT_FOUND); // not found
}
}
} else if (cmd_frame[0] == CMD_IMPORT_CONTACT && len > 2 + 32 + 64) {
if (_store->hasIncompleteContactLoad()) {
// importContact() is asynchronous: without this admission gate the
// queued advert would be ACKed here and then deliberately discarded by
// BaseChatMesh's incomplete-table mutation veto on the next loop.
writeErrFrame(ERR_CODE_FILE_IO_ERROR);
} else if (importContact(&cmd_frame[1], len - 1)) {
updateGpsTelemetryPolicy();
writeOKFrame();
} else {
writeErrFrame(ERR_CODE_ILLEGAL_ARG);
}
} else if (cmd_frame[0] == CMD_SYNC_NEXT_MESSAGE) {
syncNextOfflineMessage();
} else if (cmd_frame[0] == CMD_SET_RADIO_PARAMS) {
if (len < 11) { writeErrFrame(ERR_CODE_ILLEGAL_ARG); return; }
uint32_t freq, bw;
memcpy(&freq, &cmd_frame[1], 4);
memcpy(&bw, &cmd_frame[5], 4);
const uint8_t sf = cmd_frame[9], cr = cmd_frame[10];
const uint8_t repeat = len > 11 ? cmd_frame[11] : 0;
if (freq < 150000 || freq > 2500000 || bw < 7000 || bw > 500000
|| sf < 5 || sf > 12 || cr < 5 || cr > 8) {
writeErrFrame(ERR_CODE_ILLEGAL_ARG);
} else {
const auto result = applyAndSaveRadioParams(freq / 1000.0f, bw / 1000.0f,
sf, cr, repeat, _radio_profiles.primaryPreamble());
if (result == RadioSettingResult::Saved) writeOKFrame();
else writeErrFrame(result == RadioSettingResult::RadioRejected
? ERR_CODE_ILLEGAL_ARG : ERR_CODE_FILE_IO_ERROR);
}
} else if (cmd_frame[0] == CMD_SET_RADIO_TX_POWER) {
if (len < 2) {
writeErrFrame(ERR_CODE_ILLEGAL_ARG);
return;
}
int8_t power = (int8_t)cmd_frame[1];
if (power < -9 || power > MAX_LORA_TX_POWER) {
writeErrFrame(ERR_CODE_ILLEGAL_ARG);
} else {
const auto result = applyAndSaveTxPower(power);
if (result == RadioSettingResult::Saved) writeOKFrame();
else writeErrFrame(result == RadioSettingResult::RadioRejected
? ERR_CODE_BAD_STATE : ERR_CODE_FILE_IO_ERROR);
}
} else if (cmd_frame[0] == CMD_SET_TUNING_PARAMS) {
if (len < 9) {
writeErrFrame(ERR_CODE_ILLEGAL_ARG);
return;
}
int i = 1;
uint32_t rx, af;
memcpy(&rx, &cmd_frame[i], 4);
i += 4;
memcpy(&af, &cmd_frame[i], 4);
i += 4;
const float previous_rx = _prefs.rx_delay_base, previous_af = _prefs.airtime_factor;
_prefs.rx_delay_base = ((float)rx) / 1000.0f;
_prefs.airtime_factor = ((float)af) / 1000.0f;
if (savePrefs()) {
writeOKFrame();
} else {
_prefs.rx_delay_base = previous_rx;
_prefs.airtime_factor = previous_af;
writeErrFrame(ERR_CODE_FILE_IO_ERROR);
}
} else if (cmd_frame[0] == CMD_GET_TUNING_PARAMS) {
uint32_t rx = _prefs.rx_delay_base * 1000, af = _prefs.airtime_factor * 1000;
int i = 0;
out_frame[i++] = RESP_CODE_TUNING_PARAMS;
memcpy(&out_frame[i], &rx, 4); i += 4;
memcpy(&out_frame[i], &af, 4); i += 4;
_serial->writeFrame(out_frame, i);
} else if (cmd_frame[0] == CMD_SET_OTHER_PARAMS) {
if (len < 2) {
writeErrFrame(ERR_CODE_ILLEGAL_ARG);
return;
}
const uint8_t previous_manual = _prefs.manual_add_contacts;
const uint8_t previous_base = _prefs.telemetry_mode_base;
const uint8_t previous_loc = _prefs.telemetry_mode_loc;
const uint8_t previous_env = _prefs.telemetry_mode_env;
const uint8_t previous_policy = _prefs.advert_loc_policy;
const uint8_t previous_acks = _prefs.multi_acks;
_prefs.manual_add_contacts = cmd_frame[1];
if (len >= 3) {
_prefs.telemetry_mode_base = cmd_frame[2] & 0x03; // v5+
_prefs.telemetry_mode_loc = (cmd_frame[2] >> 2) & 0x03;
_prefs.telemetry_mode_env = (cmd_frame[2] >> 4) & 0x03;
if (len >= 4) {
_prefs.advert_loc_policy = cmd_frame[3];
if (len >= 5) {
_prefs.multi_acks = cmd_frame[4];
}
}
}
if (savePrefs()) {
updateGpsTelemetryPolicy();
writeOKFrame();
} else {
_prefs.manual_add_contacts = previous_manual;
_prefs.telemetry_mode_base = previous_base;
_prefs.telemetry_mode_loc = previous_loc;
_prefs.telemetry_mode_env = previous_env;
_prefs.advert_loc_policy = previous_policy;
_prefs.multi_acks = previous_acks;
writeErrFrame(ERR_CODE_FILE_IO_ERROR);
}
} else if (cmd_frame[0] == CMD_SET_PATH_HASH_MODE && len >= 3 && cmd_frame[1] == 0) {
if (cmd_frame[2] >= 3) {
writeErrFrame(ERR_CODE_ILLEGAL_ARG);
} else {
if (savePreference(_prefs.path_hash_mode, cmd_frame[2])) writeOKFrame();
else writeErrFrame(ERR_CODE_FILE_IO_ERROR);
}
} else if (cmd_frame[0] == CMD_REBOOT && len >= 7 && memcmp(&cmd_frame[1], "reboot", 6) == 0) {
if (!MyMesh::prepareForUiShutdown()) {
writeErrFrame(ERR_CODE_FILE_IO_ERROR);
return;
}
board.reboot();
} else if (cmd_frame[0] == CMD_GET_BATT_AND_STORAGE) {
uint8_t reply[11];
int i = 0;
reply[i++] = RESP_CODE_BATT_AND_STORAGE;
uint16_t battery_millivolts = board.getBattMilliVolts();
uint32_t used = _store->getStorageUsedKb();
uint32_t total = _store->getStorageTotalKb();
memcpy(&reply[i], &battery_millivolts, 2); i += 2;
memcpy(&reply[i], &used, 4); i += 4;
memcpy(&reply[i], &total, 4); i += 4;
_serial->writeFrame(reply, i);
} else if (cmd_frame[0] == CMD_EXPORT_PRIVATE_KEY) {
#if ENABLE_PRIVATE_KEY_EXPORT
uint8_t reply[65];
reply[0] = RESP_CODE_PRIVATE_KEY;
self_id.writeTo(&reply[1], 64);
_serial->writeFrame(reply, 65);
#else
writeDisabledFrame();
#endif
} else if (cmd_frame[0] == CMD_IMPORT_PRIVATE_KEY && len >= 65) {
#if ENABLE_PRIVATE_KEY_IMPORT
if (!mesh::LocalIdentity::validatePrivateKey(&cmd_frame[1])) {
writeErrFrame(ERR_CODE_ILLEGAL_ARG); // invalid key
#if MESH_ENABLE_MESHTEXT_CHAT
} else if (hasForeignRadioFrame()) {
// Finish queued/sending MT text before changing its sender identity.
writeErrFrame(ERR_CODE_BAD_STATE);
#endif
} else if (!flushContactsBeforeReboot()) {
// loadContacts() resets the dirty-page map, so never replace the
// identity and reload while newer contact state exists only in RAM.
writeErrFrame(ERR_CODE_FILE_IO_ERROR);
} else {
mesh::LocalIdentity identity;
identity.readFrom(&cmd_frame[1], 64);
if (_store->saveMainIdentity(identity)) {
self_id = identity;
#if MESH_ENABLE_MESHTEXT_CHAT
refreshMeshtextIdentity();
#endif
#if defined(ENABLE_OTA)
mesh::ota::ota_refresh_seeder_identity(self_id.pub_key);
#endif
writeOKFrame();
// re-load contacts, to invalidate ecdh shared_secrets
stopContactsIterator();
resetContacts();
MyMesh::onContactCacheFlushed();
_store->loadContacts(this);
_store->recoverContacts(this);
if (_store->hasPendingContactWrites()) {
mesh::scheduleLazyPersistenceMutation(
dirty_contacts_expiry, dirty_contacts_failures,
futureMillis(LAZY_CONTACTS_WRITE_DELAY));
}
updateGpsTelemetryPolicy();
} else {
writeErrFrame(ERR_CODE_FILE_IO_ERROR);
}
}
#else
writeDisabledFrame();
#endif
} else if (cmd_frame[0] == CMD_SEND_RAW_DATA && len >= 6) {
int i = 1;
uint8_t path_len = cmd_frame[i++];
if (mesh::Packet::isValidPathLen(path_len)) {
uint8_t path[MAX_PATH_SIZE];
const size_t path_bytes = static_cast<size_t>(path_len & 63) * ((path_len >> 6) + 1);
if (static_cast<size_t>(i) + path_bytes + 4 > len) { // min payload 4 bytes, before reading path
writeErrFrame(ERR_CODE_ILLEGAL_ARG);
} else {
i += mesh::Packet::writePath(path, &cmd_frame[i], path_len);
if (sendClientRawData(&cmd_frame[i], len - i, path, path_len)) {
writeOKFrame();
} else {
writeErrFrame(ERR_CODE_BAD_STATE);
}
}
} else {
writeErrFrame(ERR_CODE_UNSUPPORTED_CMD); // flood, not supported (yet)
}
} else if (cmd_frame[0] == CMD_SEND_LOGIN && len >= 1 + PUB_KEY_SIZE) {
uint8_t *pub_key = &cmd_frame[1];
ContactInfo *recipient = lookupContactByPubKey(pub_key, PUB_KEY_SIZE);
char *password = (char *)&cmd_frame[1 + PUB_KEY_SIZE];
cmd_frame[len] = 0; // ensure null terminator in password
if (recipient) {
if (!beginPendingRequest(mesh::CompanionDelayedReplies::Login, *recipient)) {
writeErrFrame(ERR_CODE_BAD_STATE);
return;
}
uint32_t tag, est_timeout = 0;
memcpy(&tag, recipient->id.pub_key, 4);
int result = sendLogin(*recipient, password, est_timeout);
finishPendingRequest(result, tag, est_timeout);
} else {
writeErrFrame(ERR_CODE_NOT_FOUND); // contact not found
}
} else if (cmd_frame[0] == CMD_SEND_ANON_REQ && len > 1 + PUB_KEY_SIZE) {
uint8_t *pub_key = &cmd_frame[1];
ContactInfo *recipient = lookupContactByPubKey(pub_key, PUB_KEY_SIZE);
ContactInfo anon;
if (recipient == NULL) { // FIRMWARE_VER_CODE 13+, allow non-contact requests
memset(&anon, 0, sizeof(anon));
#if defined(NRF52_PLATFORM)
anon.storage_slot = mesh::storage::CONTACT_SLOT_NONE;
#endif
memcpy(anon.id.pub_key, pub_key, PUB_KEY_SIZE);
anon.out_path_len = 0; // default to zero-hop direct
anon.type = ADV_TYPE_NONE; // unknown
anon.lastmod = getRTCClock()->getCurrentTime();
if (addContact(anon)) recipient = &anon;
}
uint8_t *data = &cmd_frame[1 + PUB_KEY_SIZE];
if (recipient) {
if (!beginPendingRequest(mesh::CompanionDelayedReplies::Binary, *recipient)) {
writeErrFrame(ERR_CODE_BAD_STATE);
return;
}
uint32_t tag = 0, est_timeout = 0;
int result = sendAnonReq(*recipient, data, len - (1 + PUB_KEY_SIZE), tag, est_timeout);
finishPendingRequest(result, tag, est_timeout);
} else {
writeErrFrame(ERR_CODE_TABLE_FULL); // contacts full
}
} else if (cmd_frame[0] == CMD_SEND_STATUS_REQ && len >= 1 + PUB_KEY_SIZE) {
uint8_t *pub_key = &cmd_frame[1];
ContactInfo *recipient = lookupContactByPubKey(pub_key, PUB_KEY_SIZE);
if (recipient) {
if (!beginPendingRequest(mesh::CompanionDelayedReplies::Status, *recipient)) {
writeErrFrame(ERR_CODE_BAD_STATE);
return;
}
uint32_t tag = 0, est_timeout = 0;
int result = sendRequest(*recipient, REQ_TYPE_GET_STATUS, tag, est_timeout);
finishPendingRequest(result, tag, est_timeout);
} else {
writeErrFrame(ERR_CODE_NOT_FOUND); // contact not found
}
} else if (cmd_frame[0] == CMD_SEND_PATH_DISCOVERY_REQ && len >= 2 + PUB_KEY_SIZE && cmd_frame[1] == 0) {
uint8_t *pub_key = &cmd_frame[2];
ContactInfo *recipient = lookupContactByPubKey(pub_key, PUB_KEY_SIZE);
if (recipient) {
if (!beginPendingRequest(mesh::CompanionDelayedReplies::Discovery, *recipient)) {
writeErrFrame(ERR_CODE_BAD_STATE);
return;
}
uint32_t tag = 0, est_timeout = 0;
// 'Path Discovery' is just a special case of flood + Telemetry req
uint8_t req_data[9];
req_data[0] = REQ_TYPE_GET_TELEMETRY_DATA;
req_data[1] = ~(TELEM_PERM_BASE); // NEW: inverse permissions mask (ie. we only want BASE telemetry)
memset(&req_data[2], 0, 3); // reserved
getRNG()->random(&req_data[5], 4); // random blob to help make packet-hash unique
auto save = recipient->out_path_len; // temporarily force sendRequest() to flood
recipient->out_path_len = OUT_PATH_UNKNOWN;
int result = sendRequest(*recipient, req_data, sizeof(req_data), tag, est_timeout);
recipient->out_path_len = save;
finishPendingRequest(result, tag, est_timeout);
} else {
writeErrFrame(ERR_CODE_NOT_FOUND); // contact not found
}
} else if (cmd_frame[0] == CMD_SEND_TELEMETRY_REQ && len >= 4 + PUB_KEY_SIZE) { // can deprecate, in favour of CMD_SEND_BINARY_REQ
uint8_t *pub_key = &cmd_frame[4];
ContactInfo *recipient = lookupContactByPubKey(pub_key, PUB_KEY_SIZE);
if (recipient) {
if (!beginPendingRequest(mesh::CompanionDelayedReplies::Telemetry, *recipient)) {
writeErrFrame(ERR_CODE_BAD_STATE);
return;
}
uint32_t tag = 0, est_timeout = 0;
int result = sendRequest(*recipient, REQ_TYPE_GET_TELEMETRY_DATA, tag, est_timeout);
finishPendingRequest(result, tag, est_timeout);
} else {
writeErrFrame(ERR_CODE_NOT_FOUND); // contact not found
}
} else if (cmd_frame[0] == CMD_SEND_TELEMETRY_REQ && len == 4) { // 'self' telemetry request
telemetry.reset();
telemetry.addVoltage(TELEM_CHANNEL_SELF, (float)board.getBattMilliVolts() / 1000.0f);
float temperature = board.getMCUTemperature();
if(!isnan(temperature)) { // Supported boards with built-in temperature sensor. ESP32-C3 may return NAN
telemetry.addTemperature(TELEM_CHANNEL_SELF, temperature); // Built-in MCU Temperature
}
// query other sensors -- target specific
sensors.querySensors(0xFF, telemetry);
int i = 0;
out_frame[i++] = PUSH_CODE_TELEMETRY_RESPONSE;
out_frame[i++] = 0; // reserved
memcpy(&out_frame[i], self_id.pub_key, 6);
i += 6; // pub_key_prefix
uint8_t tlen = telemetry.getSize();
memcpy(&out_frame[i], telemetry.getBuffer(), tlen);
i += tlen;
_serial->writeFrame(out_frame, i);
} else if (cmd_frame[0] == CMD_SEND_BINARY_REQ && len >= 2 + PUB_KEY_SIZE) {
uint8_t *pub_key = &cmd_frame[1];
ContactInfo *recipient = lookupContactByPubKey(pub_key, PUB_KEY_SIZE);
if (recipient) {
uint8_t *req_data = &cmd_frame[1 + PUB_KEY_SIZE];
if (!beginPendingRequest(mesh::CompanionDelayedReplies::Binary, *recipient)) {
writeErrFrame(ERR_CODE_BAD_STATE);
return;
}
uint32_t tag = 0, est_timeout = 0;
int result = sendRequest(*recipient, req_data, len - (1 + PUB_KEY_SIZE), tag, est_timeout);
finishPendingRequest(result, tag, est_timeout);
} else {
writeErrFrame(ERR_CODE_NOT_FOUND); // contact not found
}
} else if (cmd_frame[0] == CMD_HAS_CONNECTION && len >= 1 + PUB_KEY_SIZE) {
uint8_t *pub_key = &cmd_frame[1];
if (hasConnectionTo(pub_key)) {
writeOKFrame();
} else {
writeErrFrame(ERR_CODE_NOT_FOUND);
}
} else if (cmd_frame[0] == CMD_LOGOUT && len >= 1 + PUB_KEY_SIZE) {
uint8_t *pub_key = &cmd_frame[1];
stopConnection(pub_key);
writeOKFrame();
} else if (cmd_frame[0] == CMD_GET_CHANNEL && len >= 2) {
uint8_t channel_idx = cmd_frame[1];
ChannelDetails channel;
if (getChannelForSave(channel_idx, channel)) {
int i = 0;
out_frame[i++] = RESP_CODE_CHANNEL_INFO;
out_frame[i++] = channel_idx;
// This fixed-width field must not retain bytes from an earlier reply.
strncpy((char *)&out_frame[i], channel.name, 32);
out_frame[i + 31] = 0;
i += 32;
size_t key_size = 16; // Original app response remains byte-for-byte compatible.
#if MESH_ENABLE_MESHTEXT_CHAT
if (len >= 3 && cmd_frame[2] == 32 && isMeshtextChannel(channel)) key_size = 32;
#endif
memcpy(&out_frame[i], channel.channel.secret, key_size);
i += key_size;
_serial->writeFrame(out_frame, i);
} else {
writeErrFrame(ERR_CODE_NOT_FOUND);
}
} else if (cmd_frame[0] == CMD_SET_CHANNEL && len >= 2 + 32 + 32) {
#if MESH_ENABLE_MESHTEXT_CHAT
const uint8_t channel_idx = cmd_frame[1];
ChannelDetails previous{}, channel{};
StrHelper::strncpy(channel.name, (char *)&cmd_frame[2], 32);
memcpy(channel.channel.secret, &cmd_frame[2 + 32], sizeof(channel.channel.secret));
if (parseCompanionChannelAlias(channel.name).kind != CompanionChannelKind::Meshtext) {
writeErrFrame(ERR_CODE_UNSUPPORTED_CMD); // MeshCore wire protocol is still AES-128.
return;
}
if (!validateMeshtextChannelChange(channel_idx, channel)) {
writeErrFrame(ERR_CODE_BAD_STATE);
return;
}
if (!getChannelForSave(channel_idx, previous) || !setChannel(channel_idx, channel)) {
writeErrFrame(ERR_CODE_NOT_FOUND);
} else if (!saveChannels()) {
setChannel(channel_idx, previous);
writeErrFrame(ERR_CODE_FILE_IO_ERROR);
} else {
_mt_seen.clear();
_mt_profile_dirty = true;
refreshMeshtextProfile();
writeOKFrame();
}
#else
writeErrFrame(ERR_CODE_UNSUPPORTED_CMD);
#endif
} else if (cmd_frame[0] == CMD_SET_CHANNEL && len >= 2 + 32 + 16) {
uint8_t channel_idx = cmd_frame[1];
ChannelDetails previous{};
ChannelDetails channel;
StrHelper::strncpy(channel.name, (char *)&cmd_frame[2], 32);
memset(channel.channel.secret, 0, sizeof(channel.channel.secret));
memcpy(channel.channel.secret, &cmd_frame[2 + 32], 16); // NOTE: only 128-bit supported
const auto alias = parseCompanionChannelAlias(channel.name);
if (alias.kind == CompanionChannelKind::Invalid) {
writeErrFrame(ERR_CODE_ILLEGAL_ARG);
return;
}
#if MESH_ENABLE_MESHTEXT_CHAT
// Legacy apps fetch only 16 bytes. A label-only edit must not silently
// truncate a provisioned MT AES-256 key to AES-128. An explicit 32-byte SET
// (zero upper half for AES-128) is the unambiguous way to replace that key.
if (alias.kind == CompanionChannelKind::Meshtext
&& getChannelForSave(channel_idx, previous) && isMeshtextChannel(previous)
&& !memcmp(channel.channel.secret, previous.channel.secret, 16)) {
memcpy(channel.channel.secret + 16, previous.channel.secret + 16, 16);
}
if (!validateMeshtextChannelChange(channel_idx, channel)) {
writeErrFrame(ERR_CODE_BAD_STATE); // Conflicting preset, manual RF, region or busy owner.
return;
}
#else
if (alias.kind == CompanionChannelKind::Meshtext && isAliasRadio2Defined()) {
writeErrFrame(ERR_CODE_UNSUPPORTED_CMD); // Meshtext transport not implemented yet.
return;
}
#endif
const bool have_previous = getChannelForSave(channel_idx, previous);
ChannelDetails wire_channel = channel;
ChannelDetails previous_wire_channel = previous;
resolveChannelAlias(wire_channel);
if (have_previous) resolveChannelAlias(previous_wire_channel);
if (have_previous && channel.name[0] != 0 && previous.name[0] != 0
&& !memcmp(wire_channel.channel.secret, previous_wire_channel.channel.secret, sizeof(channel.channel.secret))) {
channel.channel.tx_radio = previous.channel.tx_radio;
}
if (!have_previous
|| !setChannel(channel_idx, channel)) {
writeErrFrame(ERR_CODE_NOT_FOUND); // bad channel_idx
} else if (saveChannels()) {
#if MESH_ENABLE_MESHTEXT_CHAT
_mt_seen.clear();
_mt_profile_dirty = true;
refreshMeshtextProfile();
#endif
writeOKFrame();
// Local history seed only after the durable save and its acknowledgement.
seedEmergencyChannelNotice(channel_idx, previous);
} else {
// AtomicFileWriter preserves the last complete channels file. Keep the
// live table consistent with it when a full or failing filesystem cannot
// commit this update, and never acknowledge a change that will disappear
// after reboot.
setChannel(channel_idx, previous);
writeErrFrame(ERR_CODE_FILE_IO_ERROR);
}
} else if (cmd_frame[0] == CMD_SIGN_START) {
BaseSerialInterface* signing_route = _serial->captureReplyRoute();
if (signing_route == NULL) {
writeErrFrame(ERR_CODE_BAD_STATE);
return;
}
if (sign_data != NULL && sign_data_reply_route != signing_route) {
writeErrFrame(ERR_CODE_BAD_STATE);
return;
}
cancelSigningSession();
sign_data = (uint8_t *)malloc(MAX_SIGN_DATA_LEN);
if (sign_data == NULL) {
writeErrFrame(ERR_CODE_TABLE_FULL);
return;
}
sign_data_len = 0;
sign_data_reply_route = signing_route;
sign_data_deadline = futureMillis(SIGN_SESSION_TIMEOUT_MILLIS);
out_frame[0] = RESP_CODE_SIGN_START;
out_frame[1] = 0; // reserved
uint32_t len = MAX_SIGN_DATA_LEN;
memcpy(&out_frame[2], &len, 4);
_serial->writeFrame(out_frame, 6);
} else if (cmd_frame[0] == CMD_SIGN_DATA && len > 1) {
BaseSerialInterface* signing_route = _serial->captureReplyRoute();
if (sign_data == NULL || sign_data_reply_route != signing_route) {
writeErrFrame(ERR_CODE_BAD_STATE);
} else if (sign_data_len + (len - 1) > MAX_SIGN_DATA_LEN) {
writeErrFrame(ERR_CODE_TABLE_FULL); // error: too long
} else {
memcpy(&sign_data[sign_data_len], &cmd_frame[1], len - 1);
sign_data_len += (len - 1);
sign_data_deadline = futureMillis(SIGN_SESSION_TIMEOUT_MILLIS);
writeOKFrame();
}
} else if (cmd_frame[0] == CMD_SIGN_FINISH) {
if (sign_data != NULL
&& sign_data_reply_route == _serial->captureReplyRoute()) {
self_id.sign(&out_frame[1], sign_data, sign_data_len);
cancelSigningSession();
out_frame[0] = RESP_CODE_SIGNATURE;
_serial->writeFrame(out_frame, 1 + SIGNATURE_SIZE);
} else {
writeErrFrame(ERR_CODE_BAD_STATE);
}
} else if (cmd_frame[0] == CMD_SEND_TRACE_PATH && len > 10 && len - 10 <= MAX_PACKET_PAYLOAD - 9) {
serviceBinaryTraceReply();
if (_delayed_replies.hasBinaryTrace()) {
writeErrFrame(ERR_CODE_BAD_STATE);
return;
}
uint8_t path_len = len - 10;
uint8_t flags = cmd_frame[9];
uint8_t path_sz = flags & 0x03; // NEW v1.11+
if ((path_len >> path_sz) >= MAX_PATH_SIZE || (path_len % (1 << path_sz)) != 0) { // make sure is multiple of path_sz
writeErrFrame(ERR_CODE_ILLEGAL_ARG);
} else {
uint32_t tag, auth;
memcpy(&tag, &cmd_frame[1], 4);
memcpy(&auth, &cmd_frame[5], 4);
if (!_delayed_replies.reserveBinaryTrace(tag, auth,
_serial->captureReplyRoute(), _ms->getMillis())) {
writeErrFrame(ERR_CODE_BAD_STATE);
return;
}
auto pkt = createTrace(tag, auth, flags);
if (pkt) {
// Compute before handing ownership to sendDirect(), which releases the
// packet itself if validation or queueing fails.
uint32_t t = getTransmitAirtime(pkt, 2 + pkt->payload_len + path_len);
uint32_t est_timeout = calcDirectTimeoutMillisFor(t, path_len >> path_sz);
if (sendDirect(pkt, &cmd_frame[10], path_len)) {
_delayed_replies.armBinaryTrace(est_timeout, _ms->getMillis());
serviceBinaryTraceReply();
} else {
_delayed_replies.abandonBinaryTrace();
writeErrFrame(ERR_CODE_TABLE_FULL);
}
} else {
_delayed_replies.abandonBinaryTrace();
writeErrFrame(ERR_CODE_TABLE_FULL);
}
}
} else if (cmd_frame[0] == CMD_SET_DEVICE_PIN && len >= 5) {
// get pin from command frame
uint32_t pin;
memcpy(&pin, &cmd_frame[1], 4);
// ensure pin is zero, or a valid 6 digit pin
if (pin == 0 || (pin >= 100000 && pin <= 999999)) {
if (savePreference(_prefs.ble_pin, pin)) writeOKFrame();
else writeErrFrame(ERR_CODE_FILE_IO_ERROR);
} else {
writeErrFrame(ERR_CODE_ILLEGAL_ARG);
}
} else if (cmd_frame[0] == CMD_GET_CUSTOM_VARS) {
out_frame[0] = RESP_CODE_CUSTOM_VARS;
char *dp = (char *)&out_frame[1];
for (int i = 0; i < sensors.getNumSettings() && dp - (char *)&out_frame[1] < 140; i++) {
if (i > 0) {
*dp++ = ',';
}
strcpy(dp, sensors.getSettingName(i));
dp = strchr(dp, 0);
*dp++ = ':';
strcpy(dp, sensors.getSettingValue(i));
dp = strchr(dp, 0);
}
_serial->writeFrame(out_frame, dp - (char *)out_frame);
} else if (cmd_frame[0] == CMD_SET_CUSTOM_VAR && len >= 4) {
cmd_frame[len] = 0;
char *sp = (char *)&cmd_frame[1];
char *np = strchr(sp, ':'); // look for separator char
if (np) {
*np++ = 0; // modify 'cmd_frame', replace ':' with null
bool success = sensors.setSettingValue(sp, np);
if (success) {
#if ENV_INCLUDE_GPS == 1
// Update node preferences for GPS settings
if (strcmp(sp, "gps") == 0) {
if (!savePreference(_prefs.gps_enabled, static_cast<uint8_t>(np[0] == '1'))) {
applyGpsPrefs();
writeErrFrame(ERR_CODE_FILE_IO_ERROR);
return;
}
} else if (strcmp(sp, "gps_interval") == 0) {
uint32_t interval_seconds = atoi(np);
if (!savePreference(_prefs.gps_interval,
static_cast<uint32_t>(constrain(interval_seconds, 0, 86400)))) {
applyGpsPrefs();
writeErrFrame(ERR_CODE_FILE_IO_ERROR);
return;
}
}
#endif
writeOKFrame();
} else {
writeErrFrame(ERR_CODE_ILLEGAL_ARG);
}
} else {
writeErrFrame(ERR_CODE_ILLEGAL_ARG);
}
} else if (mesh::companion::isFemRxGainGet(cmd_frame[0])) {
if (len != 1) {
writeErrFrame(ERR_CODE_ILLEGAL_ARG);
} else if (!board.canControlLoRaFemLna()) {
writeErrFrame(ERR_CODE_UNSUPPORTED_CMD);
} else {
out_frame[0] = RESP_CODE_OK;
uint8_t value = board.isLoRaFemLnaEnabled() ? 1 : 0;
memcpy(&out_frame[1], &value, 1);
_serial->writeFrame(out_frame, 2);
}
} else if (mesh::companion::isFemRxGainSet(cmd_frame[0])) {
uint8_t value = len >= 2 ? cmd_frame[1] : 0;
if (len != 2) {
writeErrFrame(ERR_CODE_ILLEGAL_ARG);
} else if (!board.canControlLoRaFemLna()) {
writeErrFrame(ERR_CODE_UNSUPPORTED_CMD);
} else if (value <= 1) {
if (applyAndSaveFemRxGain(value != 0)) {
writeOKFrame();
} else {
writeErrFrame(ERR_CODE_BAD_STATE);
}
} else {
writeErrFrame(ERR_CODE_ILLEGAL_ARG);
}
} else if (mesh::companion::isRadioRxGainGet(cmd_frame[0])) {
if (len != 1) {
writeErrFrame(ERR_CODE_ILLEGAL_ARG);
} else if (!radio_driver.supportsRxBoostedGainMode()) {
writeErrFrame(ERR_CODE_UNSUPPORTED_CMD);
} else {
out_frame[0] = RESP_CODE_OK;
out_frame[1] = _prefs.rx_boosted_gain ? 1 : 0;
_serial->writeFrame(out_frame, 2);
}
} else if (mesh::companion::isRadioRxGainSet(cmd_frame[0])) {
uint8_t value = len >= 2 ? cmd_frame[1] : 0;
if (len != 2 || value > 1) {
writeErrFrame(ERR_CODE_ILLEGAL_ARG);
} else if (!radio_driver.supportsRxBoostedGainMode()) {
writeErrFrame(ERR_CODE_UNSUPPORTED_CMD);
} else if (!applyAndSaveRxBoostedGain(value != 0)) {
writeErrFrame(ERR_CODE_BAD_STATE);
} else {
writeOKFrame();
}
} else if (mesh::companion::isWiFiPowerSaveGet(cmd_frame[0])) {
if (len != 1) {
writeErrFrame(ERR_CODE_ILLEGAL_ARG);
} else {
#if defined(ESP32) && defined(WIFI_SSID)
out_frame[0] = RESP_CODE_OK;
out_frame[1] = getCompanionWiFiPowerSave();
_serial->writeFrame(out_frame, 2);
#else
writeErrFrame(ERR_CODE_UNSUPPORTED_CMD);
#endif
}
} else if (mesh::companion::isWiFiPowerSaveSet(cmd_frame[0])) {
if (len != 2) {
writeErrFrame(ERR_CODE_ILLEGAL_ARG);
} else {
#if defined(ESP32) && defined(WIFI_SSID)
const uint8_t value = cmd_frame[1];
if (value > mesh::wifi::kPowerSaveMax) {
writeErrFrame(ERR_CODE_ILLEGAL_ARG);
} else {
char reply[160];
if (applyAndSaveWiFiPowerSaving(
companionWiFiPowerSaveName(value), reply, sizeof(reply))) {
writeOKFrame();
} else {
writeErrFrame(ERR_CODE_BAD_STATE);
}
}
#else
writeErrFrame(ERR_CODE_UNSUPPORTED_CMD);
#endif
}
} else if (mesh::companion::isBluetoothNameGet(cmd_frame[0])) {
if (len != 1) {
writeErrFrame(ERR_CODE_ILLEGAL_ARG);
} else {
out_frame[0] = RESP_CODE_OK;
out_frame[1] = mesh::companion::hasCustomBluetoothName(
_prefs.bluetooth_name) ? 1 : 0;
char* effective_name = reinterpret_cast<char*>(&out_frame[2]);
mesh::companion::formatBluetoothName(
effective_name, MAX_FRAME_SIZE - 1, _prefs.bluetooth_name,
BLE_NAME_PREFIX, _prefs.node_name);
_serial->writeFrame(out_frame, 2 + strlen(effective_name));
}
} else if (mesh::companion::isBluetoothNameSet(cmd_frame[0])) {
const size_t name_len = len - 1;
if (name_len > mesh::companion::BLUETOOTH_NAME_MAX_BYTES
|| memchr(&cmd_frame[1], 0, name_len) != NULL) {
writeErrFrame(ERR_CODE_ILLEGAL_ARG);
} else {
char name[mesh::companion::BLUETOOTH_NAME_SIZE] = {0};
if (name_len != 0) memcpy(name, &cmd_frame[1], name_len);
if (name_len != 0 && !mesh::companion::isValidBluetoothName(name)) {
writeErrFrame(ERR_CODE_ILLEGAL_ARG);
} else if (!saveBluetoothNameOverride(name)) {
writeErrFrame(ERR_CODE_BAD_STATE);
} else {
writeOKFrame();
}
}
} else if (cmd_frame[0]
== mesh::companion::CMD_EXEC_LOCAL_OTA_CONTROL) {
#if COMPANION_FEATURE_OTA_CLI
const size_t command_len = len - 1;
if (!mesh::companion::isBleOtaControlCommandAllowed(
&cmd_frame[1], command_len)) {
writeErrFrame(ERR_CODE_ILLEGAL_ARG);
} else {
char command[MAX_FRAME_SIZE] = {0};
memcpy(command, &cmd_frame[1], command_len);
char reply[MAX_FRAME_SIZE] = {0};
if (!handleCompanionWirelessCommand(command, reply, sizeof(reply), CompanionWirelessSource::Framed)
&& !handleCompanionBluetoothCommand(command, reply, sizeof(reply),
CompanionBluetoothCommandSource::Framed)
&& !handleLocalControlCommand(command, reply, sizeof(reply))) {
writeErrFrame(ERR_CODE_UNSUPPORTED_CMD);
} else {
out_frame[0] = RESP_CODE_OK;
const size_t reply_len = strnlen(reply, MAX_FRAME_SIZE - 2);
out_frame[1] = static_cast<uint8_t>(reply_len);
memcpy(&out_frame[2], reply, reply_len);
_serial->writeFrame(out_frame, 2 + reply_len);
}
}
#else
writeErrFrame(ERR_CODE_UNSUPPORTED_CMD);
#endif
} else if (cmd_frame[0] == mesh::companion::CMD_BLE_MOTA_SOURCE) {
#if defined(COMPANION_RADIO_FULL)
if (len != 2) {
writeErrFrame(ERR_CODE_ILLEGAL_ARG);
} else if (_mota_source_control == NULL) {
writeErrFrame(ERR_CODE_UNSUPPORTED_CMD);
} else {
const mesh::companion::MotaSourceAction action =
static_cast<mesh::companion::MotaSourceAction>(cmd_frame[1]);
char control_reply[96] = {0};
bool action_ok = true;
if (action == mesh::companion::MotaSourceAction::Start) {
action_ok = _mota_source_control->start(control_reply,
sizeof(control_reply));
} else if (action == mesh::companion::MotaSourceAction::Stop) {
action_ok = _mota_source_control->stop(control_reply,
sizeof(control_reply));
} else if (action != mesh::companion::MotaSourceAction::Status) {
writeErrFrame(ERR_CODE_ILLEGAL_ARG);
return;
}
if (!action_ok) {
writeErrFrame(ERR_CODE_BAD_STATE);
} else {
const mesh::companion::MotaSourceStatus status =
_mota_source_control->status();
uint8_t flags = 0;
if (status.channel_ready) {
flags |= mesh::companion::MOTA_SOURCE_FLAG_CHANNEL_READY;
}
if (status.attached) {
flags |= mesh::companion::MOTA_SOURCE_FLAG_ATTACHED;
}
if (status.another_link_active) {
flags |=
mesh::companion::MOTA_SOURCE_FLAG_ANOTHER_LINK_ACTIVE;
}
out_frame[0] = RESP_CODE_OK;
out_frame[1] = cmd_frame[1];
out_frame[2] = flags;
out_frame[3] = static_cast<uint8_t>(status.offered & 0xFF);
out_frame[4] = static_cast<uint8_t>(status.offered >> 8);
out_frame[5] = static_cast<uint8_t>(status.advertised & 0xFF);
out_frame[6] = static_cast<uint8_t>(status.advertised >> 8);
out_frame[7] = static_cast<uint8_t>(status.packets_sent & 0xFF);
out_frame[8] = static_cast<uint8_t>((status.packets_sent >> 8) & 0xFF);
out_frame[9] = static_cast<uint8_t>((status.packets_sent >> 16) & 0xFF);
out_frame[10] = static_cast<uint8_t>((status.packets_sent >> 24) & 0xFF);
_serial->writeFrame(out_frame, 11);
}
}
#else
writeErrFrame(ERR_CODE_UNSUPPORTED_CMD);
#endif
} else if (cmd_frame[0] == CMD_GET_ADVERT_PATH && len >= PUB_KEY_SIZE+2) {
// FUTURE use: uint8_t reserved = cmd_frame[1];
uint8_t *pub_key = &cmd_frame[2];
AdvertPath* found = NULL;
for (int i = 0; i < ADVERT_PATH_TABLE_SIZE; i++) {
auto p = &advert_paths[i];
if (memcmp(p->pubkey_prefix, pub_key, sizeof(p->pubkey_prefix)) == 0) {
found = p;
break;
}
}
if (found) {
int i = 0;
out_frame[i++] = RESP_CODE_ADVERT_PATH;
memcpy(&out_frame[i], &found->recv_timestamp, 4); i += 4;
out_frame[i++] = found->path_len;
i += mesh::Packet::writePath(&out_frame[i], found->path, found->path_len);
_serial->writeFrame(out_frame, i);
} else {
writeErrFrame(ERR_CODE_NOT_FOUND);
}
} else if (cmd_frame[0] == CMD_GET_STATS && len >= 2) {
uint8_t stats_type = cmd_frame[1];
if (stats_type == STATS_TYPE_CORE) {
int i = 0;
out_frame[i++] = RESP_CODE_STATS;
out_frame[i++] = STATS_TYPE_CORE;
uint16_t battery_mv = board.getBattMilliVolts();
uint32_t uptime_secs = _ms->getMillis() / 1000;
uint8_t queue_len = (uint8_t)_mgr->getOutboundCount(0xFFFFFFFF);
memcpy(&out_frame[i], &battery_mv, 2); i += 2;
memcpy(&out_frame[i], &uptime_secs, 4); i += 4;
memcpy(&out_frame[i], &_err_flags, 2); i += 2;
out_frame[i++] = queue_len;
_serial->writeFrame(out_frame, i);
} else if (stats_type == STATS_TYPE_RADIO) {
int i = 0;
out_frame[i++] = RESP_CODE_STATS;
out_frame[i++] = STATS_TYPE_RADIO;
int16_t noise_floor = (int16_t)_radio->getNoiseFloor();
int8_t last_rssi = (int8_t)radio_driver.getLastRSSI();
int8_t last_snr = (int8_t)(radio_driver.getLastSNR() * 4); // scaled by 4 for 0.25 dB precision
uint32_t tx_air_secs = getTotalAirTime() / 1000;
uint32_t rx_air_secs = getReceiveAirTime() / 1000;
memcpy(&out_frame[i], &noise_floor, 2); i += 2;
out_frame[i++] = last_rssi;
out_frame[i++] = last_snr;
memcpy(&out_frame[i], &tx_air_secs, 4); i += 4;
memcpy(&out_frame[i], &rx_air_secs, 4); i += 4;
_serial->writeFrame(out_frame, i);
} else if (stats_type == STATS_TYPE_PACKETS) {
int i = 0;
out_frame[i++] = RESP_CODE_STATS;
out_frame[i++] = STATS_TYPE_PACKETS;
uint32_t recv = radio_driver.getPacketsRecv();
uint32_t sent = radio_driver.getPacketsSent();
uint32_t n_sent_flood = getNumSentFlood();
uint32_t n_sent_direct = getNumSentDirect();
uint32_t n_recv_flood = getNumRecvFlood();
uint32_t n_recv_direct = getNumRecvDirect();
uint32_t n_recv_errors = radio_driver.getPacketsRecvErrors();
memcpy(&out_frame[i], &recv, 4); i += 4;
memcpy(&out_frame[i], &sent, 4); i += 4;
memcpy(&out_frame[i], &n_sent_flood, 4); i += 4;
memcpy(&out_frame[i], &n_sent_direct, 4); i += 4;
memcpy(&out_frame[i], &n_recv_flood, 4); i += 4;
memcpy(&out_frame[i], &n_recv_direct, 4); i += 4;
memcpy(&out_frame[i], &n_recv_errors, 4); i += 4;
_serial->writeFrame(out_frame, i);
} else {
writeErrFrame(ERR_CODE_ILLEGAL_ARG); // invalid stats sub-type
}
} else if (cmd_frame[0] == CMD_FACTORY_RESET && len >= 6 && memcmp(&cmd_frame[1], "reset", 5) == 0) {
BaseSerialInterface* const reply_route = _serial->captureReplyRoute();
// Formatting is synchronous in the command-dispatch loop. Keep the
// requesting transport available if storage rejects/fails the erase.
bool success = _store->formatFileSystem();
if (success) {
#if MESH_ENABLE_TX_BEACONS
suspendBeacons();
#endif
MESH_DEBUG_PRINTLN("Factory reset: disabling interfaces before reboot");
_serial->disable();
writeOKFrame();
delay(1000);
board.reboot(); // doesn't return
} else {
writeErrFrame(ERR_CODE_FILE_IO_ERROR, reply_route);
}
} else if (cmd_frame[0] == CMD_SET_FLOOD_SCOPE_KEY && len >= 2 && cmd_frame[1] == 0) {
if (len >= 2 + 16) {
memcpy(send_scope.key, &cmd_frame[2], sizeof(send_scope.key)); // set scope override TransportKey
} else {
memset(send_scope.key, 0, sizeof(send_scope.key)); // reset scope override
}
send_unscoped = false;
writeOKFrame();
} else if (cmd_frame[0] == CMD_SET_FLOOD_SCOPE_KEY && len >= 2 && cmd_frame[1] == 1) { // ver 12+
send_unscoped = true;
writeOKFrame();
} else if (cmd_frame[0] == CMD_SET_DEFAULT_FLOOD_SCOPE && len >= 1) {
char previous_name[sizeof(_prefs.default_scope_name)];
uint8_t previous_key[sizeof(_prefs.default_scope_key)];
memcpy(previous_name, _prefs.default_scope_name, sizeof(previous_name));
memcpy(previous_key, _prefs.default_scope_key, sizeof(previous_key));
if (len >= 1+31+16) {
const void* terminator = memchr(&cmd_frame[1], 0, 31);
size_t n = terminator == NULL ? 31 : (const uint8_t*)terminator - &cmd_frame[1];
if (n > 0 && n < 31) {
memcpy(_prefs.default_scope_name, &cmd_frame[1], n);
_prefs.default_scope_name[n] = 0;
memcpy(_prefs.default_scope_key, &cmd_frame[1+31], 16);
} else {
writeErrFrame(ERR_CODE_ILLEGAL_ARG);
return;
}
} else {
memset(_prefs.default_scope_name, 0, sizeof(_prefs.default_scope_name)); // set default scope to null
memset(_prefs.default_scope_key, 0, sizeof(_prefs.default_scope_key));
}
if (savePrefs()) {
writeOKFrame();
} else {
memcpy(_prefs.default_scope_name, previous_name, sizeof(previous_name));
memcpy(_prefs.default_scope_key, previous_key, sizeof(previous_key));
writeErrFrame(ERR_CODE_FILE_IO_ERROR);
}
} else if (cmd_frame[0] == CMD_GET_DEFAULT_FLOOD_SCOPE) {
out_frame[0] = RESP_CODE_DEFAULT_FLOOD_SCOPE;
if (strlen(_prefs.default_scope_name) > 0) {
memcpy(&out_frame[1], _prefs.default_scope_name, 31);
memcpy(&out_frame[1+31], _prefs.default_scope_key, 16);
_serial->writeFrame(out_frame, 1+31+16);
} else {
_serial->writeFrame(out_frame, 1); // no name or key means null
}
} else if (cmd_frame[0] == CMD_SEND_CONTROL_DATA && len >= 2 && (cmd_frame[1] & 0x80) != 0) {
auto resp = createControlData(&cmd_frame[1], len - 1);
if (resp) {
sendZeroHop(resp);
writeOKFrame();
} else {
writeErrFrame(ERR_CODE_TABLE_FULL);
}
} else if (cmd_frame[0] == CMD_SET_AUTOADD_CONFIG) {
if (len < 2) {
writeErrFrame(ERR_CODE_ILLEGAL_ARG);
return;
}
const uint8_t previous_config = _prefs.autoadd_config;
const uint8_t previous_max_hops = _prefs.autoadd_max_hops;
_prefs.autoadd_config = cmd_frame[1];
if (len >= 3) {
_prefs.autoadd_max_hops = min(cmd_frame[2], (uint8_t)64);
}
if (savePrefs()) {
writeOKFrame();
} else {
_prefs.autoadd_config = previous_config;
_prefs.autoadd_max_hops = previous_max_hops;
writeErrFrame(ERR_CODE_FILE_IO_ERROR);
}
} else if (cmd_frame[0] == CMD_GET_AUTOADD_CONFIG) {
int i = 0;
out_frame[i++] = RESP_CODE_AUTOADD_CONFIG;
out_frame[i++] = _prefs.autoadd_config;
out_frame[i++] = _prefs.autoadd_max_hops;
_serial->writeFrame(out_frame, i);
} else if (cmd_frame[0] == CMD_GET_ALLOWED_REPEAT_FREQ) {
int i = 0;
out_frame[i++] = RESP_ALLOWED_REPEAT_FREQ;
for (int k = 0; k < sizeof(repeat_freq_ranges)/sizeof(repeat_freq_ranges[0]) && i + 8 < sizeof(out_frame); k++) {
auto r = &repeat_freq_ranges[k];
memcpy(&out_frame[i], &r->lower_freq, 4); i += 4;
memcpy(&out_frame[i], &r->upper_freq, 4); i += 4;
}
_serial->writeFrame(out_frame, i);
} else if (cmd_frame[0] == CMD_SEND_RAW_PACKET && len >= 4) {
auto pkt = obtainNewPacket();
if (pkt) {
uint8_t priority = cmd_frame[1];
if (tryParsePacket(pkt, &cmd_frame[2], len - 2)) {
if (pkt->getPayloadType() == PAYLOAD_TYPE_RAW_CUSTOM) {
const uint8_t policy = mesh::companion::trafficPolicy(
mesh::companion::rawTrafficKind(pkt->payload, pkt->payload_len),
_prefs.tx_media, _prefs.tx_ssh, _radio->profiles());
if (policy != mesh::RADIO_TX_AUTO) {
if (!mesh::companion::trafficTransmitMask(policy, _radio->profiles())) {
releasePacket(pkt);
writeErrFrame(ERR_CODE_BAD_STATE);
return;
}
pkt->tx_radio = policy;
}
}
sendPacket(pkt, priority, 0);
writeOKFrame();
} else {
releasePacket(pkt);
writeErrFrame(ERR_CODE_ILLEGAL_ARG);
}
} else {
writeErrFrame(ERR_CODE_TABLE_FULL);
}
} else {
writeErrFrame(ERR_CODE_UNSUPPORTED_CMD);
MESH_DEBUG_PRINTLN("ERROR: unknown command: %02X", cmd_frame[0]);
}
}
static bool save_filter(const ContactInfo& c) {
return c.type != ADV_TYPE_NONE; // don't save the transient/anon entries
}
__attribute__((noinline))
void MyMesh::onContactCacheFlushed() {
// Share the deadline reset after successful persistence or deliberate
// retirement of the old RAM table. Internal calls bypass virtual overrides.
mesh::resetLazyPersistenceAfterSuccess(dirty_contacts_expiry, dirty_contacts_failures);
}
bool MyMesh::resetContactsForRecovery() {
// Release retained paths before resetting the table. Keep the stream owner
// so its changed table revision restarts a complete list on the next pass.
#if MESH_CONTACT_CACHE
_iter_pending_contact = ContactInfo();
#endif
_iter_contact_pending = false;
resetContacts();
MyMesh::onContactCacheFlushed();
return true;
}
void MyMesh::saveContacts() {
if (_store->contactsAreVolatile()) {
MyMesh::onContactCacheFlushed();
return;
}
const bool success = _store->saveContacts(this, save_filter);
if (!success) {
scheduleContactWriteRetry();
} else if (_store->hasPendingContactWrites()) {
dirty_contacts_failures = 0;
dirty_contacts_expiry = mesh::nonzeroLazyPersistenceDeadline(
futureMillis(CONTACT_PAGE_WRITE_GAP));
} else {
MyMesh::onContactCacheFlushed();
}
}
void MyMesh::scheduleContactWriteRetry() {
const uint32_t retry_delay = mesh::recordLazyPersistenceSaveFailure(
dirty_contacts_failures, LAZY_CONTACTS_WRITE_DELAY,
mesh::LAZY_PERSISTENCE_MAX_RETRY_DELAY_MILLIS);
mesh::completeLazyPersistenceSave(
dirty_contacts_expiry, false, futureMillis(retry_delay));
MESH_DEBUG_PRINTLN("Contact persistence failed; retry in %lu ms",
(unsigned long)retry_delay);
}
bool MyMesh::isContactWriteDue() const {
// Read the clock once. Two separate reads can straddle the exact deadline
// and make both equality and the library's strict-after test return false.
const uint32_t now = _ms->getMillis();
return dirty_contacts_expiry != 0
&& (int32_t)(now - dirty_contacts_expiry) >= 0;
}
bool MyMesh::flushContactsBeforeReboot() {
// This helper also backs explicit durability promises such as tx.contact.
if (_store->contactsAreVolatile()) return false;
#if defined(ESP32_PLATFORM)
const uint32_t now = _ms->getMillis();
if (_store->hasPendingAdvertWrites() && !_store->isAdvertWriteDue(now)) return false;
if (!_store->flushAdvertWrites(now)) return false;
#endif
// Non-nRF stores use the legacy monolithic file and therefore do not report
// dirty pages. The lazy-write deadline still proves that RAM may be newer.
if (!dirty_contacts_expiry && !_store->hasPendingContactWrites()) return true;
if (!_store->flushContactWrites(this, save_filter)) {
scheduleContactWriteRetry();
return false;
}
MyMesh::onContactCacheFlushed();
return true;
}
bool MyMesh::prepareForOtaReboot() {
if (_store->contactsAreVolatile()) return true;
// Once a write has failed, honor its bounded retry deadline instead of
// letting the armed OTA apply hammer the same filesystem every loop.
if (dirty_contacts_failures != 0 && dirty_contacts_expiry != 0
&& !isContactWriteDue()) {
return false;
}
return flushContactsBeforeReboot();
}
__attribute__((noinline))
bool MyMesh::prepareForUiShutdown() {
return _store->contactsAreVolatile() || flushContactsBeforeReboot();
}
bool MyMesh::scheduleContactWrite(const ContactInfo& contact) {
// Anonymous contacts live only in the reserved transient prefix and are
// deliberately excluded from contact storage. Never allocate a persistent
// slot for one when a path/message callback updates its runtime state.
if (contact.type == ADV_TYPE_NONE || isTransientContact(contact)) return true;
if (_store->contactsAreVolatile()) return true;
if (!_store->markContactDirty(contact)) {
MESH_DEBUG_PRINTLN(
"Contact persistence scheduling failed: no persistent slot");
return false;
}
mesh::scheduleLazyPersistenceMutation(
dirty_contacts_expiry, dirty_contacts_failures,
futureMillis(LAZY_CONTACTS_WRITE_DELAY));
return true;
}
bool MyMesh::scheduleContactWriteAfterRelease(const ContactInfo& contact,
uint16_t& released_slot) {
#if defined(NRF52_PLATFORM)
released_slot = contact.storage_slot;
#else
released_slot = mesh::storage::CONTACT_SLOT_NONE;
#endif
if (!_store->releaseContact(contact)) {
MESH_DEBUG_PRINTLN(
"Contact removal scheduling failed: persistent slot was not reserved");
return false;
}
if (_store->contactsAreVolatile()) return true;
mesh::scheduleLazyPersistenceMutation(
dirty_contacts_expiry, dirty_contacts_failures,
futureMillis(LAZY_CONTACTS_WRITE_DELAY));
return true;
}
bool MyMesh::restoreContactWriteAfterRelease(const ContactInfo& contact,
uint16_t released_slot) {
if (!_store->restoreContactSlot(contact, released_slot)) {
MESH_DEBUG_PRINTLN(
"Contact removal rollback failed: released slot was not restored");
return false;
}
return true;
}
#if defined(NRF52_PLATFORM) && defined(EXTRAFS) && !defined(QSPIFLASH)
void MyMesh::repairInternalExtraFS(Stream& output) {
// Always run the store-level operation. When the 100 KiB ExtraFS is already
// active this is a non-destructive retry of any incomplete migration, not a
// format. Do not overwrite it from RAM until every authoritative source file
// has been copied, verified, and retired from primary storage.
const bool authoritative_reload_required =
_store->hasIncompleteContactLoad();
if (!_store->repairInternalExtraFS()) {
output.println(" Error: internal ExtraFS repair/migration failed; retry repair extrafs");
return;
}
if (authoritative_reload_required) {
// This boot intentionally loaded no/partial contacts while secondary
// authority was unknown. The repair has made the filesystem available,
// but rebuilding it from that RAM image would destroy the data just
// recovered. Reboot and reload the verified store before permitting any
// normal persistence.
output.println(
" > internal ExtraFS repaired; rebooting to reload authoritative data");
_scheduled_reboot_at = futureMillis(1000);
if (_scheduled_reboot_at == 0) _scheduled_reboot_at = 1;
return;
}
const bool contacts_saved = _store->saveContacts(this, save_filter);
const bool channels_saved = saveChannels();
const uint32_t capacity_kib = _store->getStorageTotalKb();
const bool capacity_ready = capacity_kib >= 84 && capacity_kib <= 100
&& (capacity_kib % 4) == 0;
if (contacts_saved && channels_saved && capacity_ready
&& !_store->hasPendingContactWrites()) {
MyMesh::onContactCacheFlushed();
output.printf(" > internal ExtraFS repaired (%lu KiB); contacts and channels rebuilt\n",
(unsigned long)_store->getStorageTotalKb());
} else {
if (!contacts_saved || _store->hasPendingContactWrites()) {
scheduleContactWriteRetry();
} else {
MyMesh::onContactCacheFlushed();
}
output.println(" Error: ExtraFS is active, but data rebuild/verification failed; retry repair extrafs");
}
}
void MyMesh::scanInternalExtraFS(Stream& output) {
if (!_store->requestInternalExtraFSBootScan()) {
output.println(" Error: could not record the boot-time ExtraFS scan request");
char health[160];
if (_store->formatInternalExtraFSHealth(health, sizeof(health))) {
output.printf(" > %s\n", health);
}
return;
}
output.println(" > boot-time physical scan scheduled; ExtraFS files will be erased; rebooting");
// This command deliberately discards the secondary filesystem. The normal
// scheduled-reboot path refuses to reset when unsaved contacts exist, which
// would strand the durable scan request on a damaged store.
output.flush();
delay(1000);
board.reboot();
}
#if defined(MESHCORE_EXTRAFS_HIL)
bool MyMesh::handleExtraFsHilCommand(const char* command, char* reply,
size_t reply_size) {
static const char* const fill_path = "/__hil.fill";
static const char* const contact_failure_marker = "/__hil.readfail";
static const uint8_t contact_stat_failure_flag = 0x80;
static const char* const prefix = "hil extrafs ";
if (strncmp(command, prefix, strlen(prefix)) != 0) return false;
FILESYSTEM* fs = _store->getSecondaryFS();
const char* action = command + strlen(prefix);
auto armContactPageFailure = [fs](uint8_t encoded_failure) -> int8_t {
if (fs == nullptr) return 0;
// Do not replace an unread/read-failure request with a stat-failure request
// (or vice versa). One marker allocation represents exactly one fault.
if (fs->exists(contact_failure_marker)) return -1;
File marker = fs->open(contact_failure_marker, FILE_O_WRITE);
const bool written = marker
&& marker.write(&encoded_failure, 1) == 1;
if (marker) {
marker.flush();
marker.close();
}
File verify = fs->open(contact_failure_marker, FILE_O_READ);
const bool durable = written && verify && verify.size() == 1
&& verify.read() == encoded_failure;
if (verify) verify.close();
if (!durable && fs->exists(contact_failure_marker)) {
fs->remove(contact_failure_marker);
}
return durable ? 1 : 0;
};
auto findHilContact = [this](const char* encoded,
size_t encoded_len) -> ContactInfo* {
// Seven bytes match the advert-cache key width and keep the direct-only
// HIL command below the Companion CLI frame limit.
if (encoded_len != 14) return nullptr;
char text[15];
memcpy(text, encoded, encoded_len);
text[encoded_len] = 0;
uint8_t key_prefix[7];
if (!mesh::Utils::fromHex(key_prefix, sizeof(key_prefix), text)) {
return nullptr;
}
return lookupPersistentContactByPubKey(
key_prefix, sizeof(key_prefix));
};
if (strcmp(action, "status") == 0) {
uint32_t fill_size = 0;
if (fs != nullptr) {
File fill = fs->open(fill_path, FILE_O_READ);
if (fill) {
fill_size = fill.size();
fill.close();
}
}
snprintf(reply, reply_size,
"HIL ExtraFS active=%u used=%luKiB total=%luKiB fill=%lu",
fs != nullptr ? 1U : 0U,
(unsigned long)_store->getStorageUsedKb(),
(unsigned long)_store->getStorageTotalKb(),
(unsigned long)fill_size);
return true;
}
if (strcmp(action, "clear") == 0) {
if (fs == nullptr) {
snprintf(reply, reply_size, "HIL ExtraFS unavailable");
} else if ((!fs->exists(fill_path) || fs->remove(fill_path))
&& !fs->exists(fill_path)) {
snprintf(reply, reply_size, "HIL ExtraFS filler cleared");
} else {
snprintf(reply, reply_size, "HIL ExtraFS filler clear failed");
}
return true;
}
static const char* const contact_slot_prefix = "contact-slot ";
if (strncmp(action, contact_slot_prefix,
strlen(contact_slot_prefix)) == 0) {
const char* encoded = action + strlen(contact_slot_prefix);
ContactInfo* contact = findHilContact(encoded, strlen(encoded));
if (contact == nullptr) {
snprintf(reply, reply_size,
"HIL usage: hil extrafs contact-slot <14 hex chars>");
} else {
snprintf(reply, reply_size, "HIL contact slot=%u",
(unsigned)contact->storage_slot);
}
return true;
}
static const char* const seed_advert_prefix = "seed-advert ";
if (strncmp(action, seed_advert_prefix,
strlen(seed_advert_prefix)) == 0) {
const char* encoded = action + strlen(seed_advert_prefix);
static const char* const confirm = " CONFIRM";
const size_t value_len = strlen(encoded);
const size_t confirm_len = strlen(confirm);
ContactInfo* contact = value_len == 14 + confirm_len
&& strcmp(encoded + 14, confirm) == 0
? findHilContact(encoded, 14) : nullptr;
if (contact == nullptr || fs == nullptr) {
snprintf(reply, reply_size,
"HIL usage: hil extrafs seed-advert <14 hex chars> CONFIRM");
return true;
}
uint8_t advert[PUB_KEY_SIZE + 4 + SIGNATURE_SIZE];
memset(advert, 0xA5, sizeof(advert));
memcpy(advert, contact->id.pub_key, PUB_KEY_SIZE);
uint8_t verify[MAX_PACKET_PAYLOAD];
const bool saved = _store->putBlobByKey(
contact->id.pub_key, PUB_KEY_SIZE, advert, sizeof(advert));
const uint8_t stored = saved ? _store->getBlobByKey(
contact->id.pub_key, PUB_KEY_SIZE, verify) : 0;
if (!saved || stored != sizeof(advert)
|| memcmp(advert, verify, sizeof(advert)) != 0) {
snprintf(reply, reply_size, "HIL advert seed failed");
} else {
snprintf(reply, reply_size, "HIL advert seeded slot=%u",
(unsigned)contact->storage_slot);
}
return true;
}
static const char* const clear_advert_prefix = "clear-advert ";
if (strncmp(action, clear_advert_prefix,
strlen(clear_advert_prefix)) == 0) {
const char* encoded = action + strlen(clear_advert_prefix);
static const char* const confirm = " CONFIRM";
const size_t value_len = strlen(encoded);
const size_t confirm_len = strlen(confirm);
ContactInfo* contact = value_len == 14 + confirm_len
&& strcmp(encoded + 14, confirm) == 0
? findHilContact(encoded, 14) : nullptr;
if (contact == nullptr || fs == nullptr) {
snprintf(reply, reply_size,
"HIL usage: hil extrafs clear-advert <14 hex chars> CONFIRM");
} else if (!_store->deleteBlobByKey(
contact->id.pub_key, PUB_KEY_SIZE)) {
snprintf(reply, reply_size, "HIL advert clear failed");
} else {
uint8_t verify[MAX_PACKET_PAYLOAD];
const uint8_t stored = _store->getBlobByKey(
contact->id.pub_key, PUB_KEY_SIZE, verify);
snprintf(reply, reply_size, stored == 0
? "HIL advert cleared" : "HIL advert clear verify failed");
}
return true;
}
static const char* const fail_stat_prefix = "fail-stat-page ";
if (strncmp(action, fail_stat_prefix, strlen(fail_stat_prefix)) == 0) {
const char* value = action + strlen(fail_stat_prefix);
char* end = nullptr;
const unsigned long page = strtoul(value, &end, 10);
char page_path[24];
snprintf(page_path, sizeof(page_path), "/contacts4_%02lu", page);
if (value == end || strcmp(end, " CONFIRM") != 0
|| page >= mesh::storage::CONTACT_PAGE_COUNT || fs == nullptr
|| !fs->exists(page_path) || _store->hasPendingContactWrites()
|| _store->hasIncompleteContactLoad()) {
snprintf(reply, reply_size,
"HIL usage: hil extrafs fail-stat-page <0..%u> CONFIRM",
(unsigned)(mesh::storage::CONTACT_PAGE_COUNT - 1));
return true;
}
const int8_t armed = armContactPageFailure(
contact_stat_failure_flag | static_cast<uint8_t>(page));
if (armed < 0) {
snprintf(reply, reply_size, "HIL contact failure already armed");
} else if (armed == 0) {
snprintf(reply, reply_size, "HIL stat-failure marker write failed");
} else {
snprintf(reply, reply_size,
"HIL contact page %lu stat failure armed", page);
}
return true;
}
static const char* const fail_read_prefix = "fail-read-page ";
if (strncmp(action, fail_read_prefix, strlen(fail_read_prefix)) == 0) {
const char* value = action + strlen(fail_read_prefix);
char* end = nullptr;
const unsigned long page = strtoul(value, &end, 10);
char page_path[24];
snprintf(page_path, sizeof(page_path), "/contacts4_%02lu", page);
if (value == end || strcmp(end, " CONFIRM") != 0
|| page >= mesh::storage::CONTACT_PAGE_COUNT || fs == nullptr
|| !fs->exists(page_path) || _store->hasPendingContactWrites()
|| _store->hasIncompleteContactLoad()) {
snprintf(reply, reply_size,
"HIL usage: hil extrafs fail-read-page <0..%u> CONFIRM",
(unsigned)(mesh::storage::CONTACT_PAGE_COUNT - 1));
return true;
}
const int8_t armed = armContactPageFailure(static_cast<uint8_t>(page));
if (armed < 0) {
snprintf(reply, reply_size, "HIL contact failure already armed");
} else if (armed == 0) {
snprintf(reply, reply_size, "HIL read-failure marker write failed");
} else {
snprintf(reply, reply_size,
"HIL contact page %lu read failure armed", page);
}
return true;
}
static const char* const fill_prefix = "fill ";
if (strncmp(action, fill_prefix, strlen(fill_prefix)) == 0) {
const char* value = action + strlen(fill_prefix);
char* end = nullptr;
const unsigned long requested = strtoul(value, &end, 10);
if (value == end || *end != 0 || requested > 128UL * 1024UL) {
snprintf(reply, reply_size,
"HIL usage: hil extrafs fill <0..131072>");
return true;
}
if (fs == nullptr) {
snprintf(reply, reply_size, "HIL ExtraFS unavailable");
return true;
}
if (fs->exists(fill_path) && !fs->remove(fill_path)) {
snprintf(reply, reply_size, "HIL ExtraFS old filler remove failed");
return true;
}
uint8_t chunk[128];
memset(chunk, 0xA5, sizeof(chunk));
uint32_t committed = 0;
while (committed < requested) {
// LittleFS can accept buffered writes which are all rolled back when the
// final sync runs out of blocks. Commit and read-verify each small extent
// so the filler represents durable pressure, not an optimistic write()
// count for a file which disappears on close.
File fill = fs->open(fill_path, FILE_O_WRITE);
if (!fill || fill.size() != committed || !fill.seek(committed)) {
if (fill) fill.close();
break;
}
const uint32_t extent_end = committed + 1024UL < requested
? committed + 1024UL : requested;
bool extent_written = true;
while (committed < extent_end) {
const size_t remaining = extent_end - committed;
const size_t count = remaining < sizeof(chunk) ? remaining : sizeof(chunk);
if (fill.write(chunk, count) != count) {
extent_written = false;
break;
}
committed += count;
}
fill.flush();
fill.close();
File verify = fs->open(fill_path, FILE_O_READ);
const uint32_t durable_size = verify ? verify.size() : 0;
if (verify) verify.close();
if (!extent_written || durable_size != committed) {
committed = durable_size;
break;
}
}
snprintf(reply, reply_size,
"HIL ExtraFS fill requested=%lu written=%lu used=%luKiB total=%luKiB",
requested, (unsigned long)committed,
(unsigned long)_store->getStorageUsedKb(),
(unsigned long)_store->getStorageTotalKb());
return true;
}
static const char* const corrupt_prefix = "corrupt-page ";
static const char* const page_mask_prefix = "page-mask ";
if (strncmp(action, page_mask_prefix, strlen(page_mask_prefix)) == 0) {
const char* value = action + strlen(page_mask_prefix);
char* end = nullptr;
const unsigned long page = strtoul(value, &end, 10);
if (value == end || *end != 0
|| page >= mesh::storage::CONTACT_PAGE_COUNT || fs == nullptr) {
snprintf(reply, reply_size,
"HIL usage: hil extrafs page-mask <0..%u>",
(unsigned)(mesh::storage::CONTACT_PAGE_COUNT - 1));
return true;
}
char path[24];
snprintf(path, sizeof(path), "/contacts4_%02lu", page);
File page_file = fs->open(path, FILE_O_READ);
uint8_t raw_header[mesh::storage::CONTACT_PAGE_HEADER_SIZE];
mesh::storage::ContactPageHeader header = {};
const bool valid = page_file
&& page_file.read(raw_header, sizeof(raw_header)) == sizeof(raw_header)
&& mesh::storage::decodeContactPageHeader(
raw_header, static_cast<uint8_t>(page), header);
if (page_file) page_file.close();
if (!valid) {
snprintf(reply, reply_size, "HIL contact page unavailable");
} else {
snprintf(reply, reply_size,
"HIL contact page %lu occupied=%08lx", page,
(unsigned long)header.occupied);
}
return true;
}
static const char* const corrupt_occupied_prefix = "corrupt-occupied ";
if (strncmp(action, corrupt_occupied_prefix,
strlen(corrupt_occupied_prefix)) == 0) {
const char* value = action + strlen(corrupt_occupied_prefix);
char* page_end = nullptr;
const unsigned long page = strtoul(value, &page_end, 10);
const char* slot_value = page_end;
while (*slot_value == ' ') slot_value++;
char* slot_end = nullptr;
const unsigned long slot = strtoul(slot_value, &slot_end, 10);
if (value == page_end || slot_value == slot_end
|| strcmp(slot_end, " CONFIRM") != 0
|| page >= mesh::storage::CONTACT_PAGE_COUNT || slot >= 32
|| fs == nullptr || _store->hasPendingContactWrites()
|| _store->hasIncompleteContactLoad()) {
snprintf(reply, reply_size,
"HIL usage: hil extrafs corrupt-occupied <page> <bit 0..31> CONFIRM");
return true;
}
char path[24];
snprintf(path, sizeof(path), "/contacts4_%02lu", page);
const uint32_t byte_offset = 8 + slot / 8;
File original = fs->open(path, FILE_O_READ);
if (!original || !original.seek(byte_offset)) {
if (original) original.close();
snprintf(reply, reply_size, "HIL contact page unavailable");
return true;
}
const int prior = original.read();
original.close();
if (prior < 0) {
snprintf(reply, reply_size, "HIL contact page read failed");
return true;
}
const uint8_t changed = static_cast<uint8_t>(prior)
^ static_cast<uint8_t>(1U << (slot % 8));
File update = fs->open(path, FILE_O_WRITE);
if (!update || !update.seek(byte_offset)
|| update.write(&changed, 1) != 1) {
if (update) update.close();
snprintf(reply, reply_size, "HIL contact occupancy corrupt failed");
return true;
}
update.flush();
update.close();
File verify = fs->open(path, FILE_O_READ);
const bool changed_on_flash = verify && verify.seek(byte_offset)
&& verify.read() == changed;
if (verify) verify.close();
snprintf(reply, reply_size, changed_on_flash
? "HIL contact page %lu occupancy bit %lu corrupted"
: "HIL contact occupancy corrupt verify failed", page, slot);
return true;
}
if (strncmp(action, corrupt_prefix, strlen(corrupt_prefix)) == 0) {
const char* value = action + strlen(corrupt_prefix);
char* end = nullptr;
const unsigned long page = strtoul(value, &end, 10);
if (value == end || strcmp(end, " CONFIRM") != 0
|| page >= mesh::storage::CONTACT_PAGE_COUNT) {
snprintf(reply, reply_size,
"HIL usage: hil extrafs corrupt-page <0..%u> CONFIRM",
(unsigned)(mesh::storage::CONTACT_PAGE_COUNT - 1));
return true;
}
if (fs == nullptr || _store->hasPendingContactWrites()
|| _store->hasIncompleteContactLoad()) {
snprintf(reply, reply_size, "HIL ExtraFS unavailable or contacts pending");
return true;
}
char path[24];
snprintf(path, sizeof(path), "/contacts4_%02lu", page);
File original = fs->open(path, FILE_O_READ);
if (!original || original.size() != mesh::storage::CONTACT_PAGE_FILE_SIZE
|| !original.seek(mesh::storage::CONTACT_PAGE_HEADER_SIZE)) {
if (original) original.close();
snprintf(reply, reply_size, "HIL contact page unavailable");
return true;
}
const int prior = original.read();
original.close();
if (prior < 0) {
snprintf(reply, reply_size, "HIL contact page read failed");
return true;
}
const uint8_t changed = static_cast<uint8_t>(prior) ^ 0xA5U;
File update = fs->open(path, FILE_O_WRITE);
if (!update || !update.seek(mesh::storage::CONTACT_PAGE_HEADER_SIZE)
|| update.write(&changed, 1) != 1) {
if (update) update.close();
snprintf(reply, reply_size, "HIL contact page corrupt failed");
return true;
}
update.flush();
update.close();
File verify = fs->open(path, FILE_O_READ);
const bool changed_on_flash = verify
&& verify.seek(mesh::storage::CONTACT_PAGE_HEADER_SIZE)
&& verify.read() == changed;
if (verify) verify.close();
snprintf(reply, reply_size, changed_on_flash
? "HIL contact page %lu CRC corrupted"
: "HIL contact page corrupt verify failed", page);
return true;
}
if (strcmp(action, "unsafe-reset CONFIRM") == 0) {
// Intentionally bypass all persistence hooks so HIL can prove that a
// mutation was already committed before an event-driven sleep.
meshRegulatorCleanReset(); // Retained marker only; no persistence flush.
NVIC_SystemReset();
while (true) {}
}
snprintf(reply, reply_size,
"HIL usage: hil extrafs <status|fill N|clear|contact-slot K|seed-advert K CONFIRM|clear-advert K CONFIRM|fail-read-page N CONFIRM|page-mask N|corrupt-occupied N B CONFIRM|corrupt-page N CONFIRM|unsafe-reset CONFIRM>");
return true;
}
#endif
#endif
bool MyMesh::saveAdvertName(const char* name) {
char previous[sizeof(_prefs.node_name)];
memcpy(previous, _prefs.node_name, sizeof(previous));
StrHelper::strncpy(_prefs.node_name, name, sizeof(_prefs.node_name));
if (savePrefs()) {
#if MESH_ENABLE_MESHTEXT_CHAT
if (strncmp(previous, _prefs.node_name, sizeof(previous)) != 0) {
// NodeInfo contains the global advert name; retained ciphertext must
// not advertise the previous name after a successful rename. Preserve
// independent text retries, and wait for a new successful chat before
// announcing again rather than transmitting from a settings edit.
for (auto& frame : _mt_delivery) if (frame.nodeinfo) frame.cancelled = true;
memset(_mt_nodeinfo_announced, 0, sizeof(_mt_nodeinfo_announced));
memset(_mt_nodeinfo_pending, 0, sizeof(_mt_nodeinfo_pending));
}
#endif
return true;
}
memcpy(_prefs.node_name, previous, sizeof(previous));
return false;
}
#if ENV_INCLUDE_GPS == 1
bool MyMesh::setGpsEnabled(bool enabled) {
if (!sensors.setSettingValue("gps", enabled ? "1" : "0")) return false;
if (savePreference(_prefs.gps_enabled, static_cast<uint8_t>(enabled))) return true;
applyGpsPrefs();
return false;
}
#endif
bool MyMesh::saveAdvertLocation(double latitude, double longitude) {
const double previous_lat = sensors.node_lat, previous_lon = sensors.node_lon;
sensors.node_lat = latitude;
sensors.node_lon = longitude;
if (savePrefs()) return true;
sensors.node_lat = previous_lat;
sensors.node_lon = previous_lon;
return false;
}
MyMesh::RadioSettingResult MyMesh::applyAndSaveTxPower(int8_t power) {
if (!radio_driver.isTxPowerValid(power)) return RadioSettingResult::RadioRejected;
const int8_t previous = _prefs.tx_power_dbm;
const auto result = _radio_available
? radio_driver.trySetTxPower(power) : mesh::RadioParamApplyResult::BUSY;
if (result == mesh::RadioParamApplyResult::FAILED) {
// A rejected SPI transaction may have partly changed the hardware. Keep
// the saved setting authoritative and restore it without retuning RX.
tx_power_apply_pending = true;
tx_power_apply_retry_at = 0;
return RadioSettingResult::RadioRejected;
}
_prefs.tx_power_dbm = power;
if (savePrefs()) {
tx_power_apply_pending = result != mesh::RadioParamApplyResult::APPLIED;
tx_power_apply_retry_at = 0;
return RadioSettingResult::Saved;
}
_prefs.tx_power_dbm = previous;
// A busy attempt has not changed the earlier pending target or retry time.
if (result == mesh::RadioParamApplyResult::APPLIED) {
tx_power_apply_pending = !radio_driver.setTxPower(previous);
tx_power_apply_retry_at = 0;
}
return RadioSettingResult::SaveFailed;
}
void MyMesh::servicePendingTxPower() {
if (!tx_power_apply_pending || !_radio_available
|| (_radio && _radio->isCarrierWaveActive())) return;
const uint32_t now = _ms->getMillis();
if (tx_power_apply_retry_at
&& static_cast<int32_t>(now - tx_power_apply_retry_at) < 0) return;
const auto result = radio_driver.trySetTxPower(_prefs.tx_power_dbm);
if (result == mesh::RadioParamApplyResult::APPLIED) tx_power_apply_pending = false;
// Busy admission does not touch RX. Back off actual hardware failures.
tx_power_apply_retry_at = result == mesh::RadioParamApplyResult::FAILED
? (now + 1000u ? now + 1000u : 1u) : 0;
}
bool MyMesh::applyAndSaveFemRxGain(bool enabled) {
// Binary Companion setters and WebConfig also enter through this helper.
// Do not alter an external FEM while the Morse transmitter owns it.
if (_radio && (_radio->isMorseTxActive() || _radio->isMorseRxActive())) return false;
if (!board.canControlLoRaFemLna()) return false;
const bool previous_hardware = board.isLoRaFemLnaEnabled();
const uint8_t previous_pref = _prefs.radio_fem_rxgain;
const uint8_t previous_override = _prefs.radio_fem_rxgain_override;
const bool changed = previous_hardware != enabled;
if (!board.setLoRaFemLnaEnabled(enabled)) return false;
if (changed && _radio_available) _radio->recalibrateNoiseFloor();
_prefs.radio_fem_rxgain = enabled ? 1 : 0;
_prefs.radio_fem_rxgain_override = 1;
if (!savePrefs()) {
_prefs.radio_fem_rxgain = previous_pref;
_prefs.radio_fem_rxgain_override = previous_override;
board.setLoRaFemLnaEnabled(previous_hardware);
if (changed && _radio_available) _radio->recalibrateNoiseFloor();
return false;
}
return true;
}
bool MyMesh::applyAndSaveFemTxGain(bool enabled) {
if (_radio && (_radio->isMorseTxActive() || _radio->isMorseRxActive())) return false;
if (!board.canControlLoRaFemPaGain()) return false;
const bool previous_hardware = board.isLoRaFemPaGainEnabled();
const uint8_t previous_pref = _prefs.radio_fem_txgain;
if (!board.setLoRaFemPaGainEnabled(enabled)) return false;
_prefs.radio_fem_txgain = enabled ? 1 : 0;
if (!savePrefs()) {
_prefs.radio_fem_txgain = previous_pref;
board.setLoRaFemPaGainEnabled(previous_hardware);
return false;
}
return true;
}
bool MyMesh::applyAndSaveRxBoostedGain(bool enabled) {
if (!radio_driver.supportsRxBoostedGainMode()) return false;
const auto result = _radio_available
? radio_driver.trySetRxBoostedGainMode(enabled) : mesh::RadioParamApplyResult::BUSY;
if (result == mesh::RadioParamApplyResult::FAILED) {
queueSavedRadioApply();
return false;
}
const uint8_t previous_pref = _prefs.rx_boosted_gain;
_prefs.rx_boosted_gain = enabled ? 1 : 0;
if (!savePrefs()) {
_prefs.rx_boosted_gain = previous_pref;
if (result == mesh::RadioParamApplyResult::APPLIED
&& !radio_driver.setRxBoostedGainMode(previous_pref != 0)) queueSavedRadioApply();
return false;
}
if (result == mesh::RadioParamApplyResult::BUSY) queueSavedRadioApply();
return true;
}
bool MyMesh::saveBluetoothNameOverride(const char* name) {
if (name == NULL) return false;
char previous[sizeof(_prefs.bluetooth_name)];
memcpy(previous, _prefs.bluetooth_name, sizeof(previous));
memset(_prefs.bluetooth_name, 0, sizeof(_prefs.bluetooth_name));
if (name[0] != 0) {
StrHelper::strncpy(_prefs.bluetooth_name, name,
sizeof(_prefs.bluetooth_name));
}
if (savePrefs()) return true;
memcpy(_prefs.bluetooth_name, previous, sizeof(_prefs.bluetooth_name));
return false;
}
bool MyMesh::applyAndSaveBluetoothName(const char* value, char* reply,
size_t reply_size) {
if (reply == NULL || reply_size == 0) return false;
const bool use_default = value == NULL || value[0] == 0;
if (!use_default && !mesh::companion::isValidBluetoothName(value)) {
snprintf(reply, reply_size,
"Error: Bluetooth name must be 1-%u valid UTF-8 bytes without control characters",
(unsigned)mesh::companion::BLUETOOTH_NAME_MAX_BYTES);
return false;
}
if (!saveBluetoothNameOverride(use_default ? "" : value)) {
mesh::cli::formatError(reply, reply_size, "Bluetooth name save failed");
return false;
}
if (use_default) {
snprintf(reply, reply_size,
#if defined(NRF52_PLATFORM)
"OK - Bluetooth name follows %s<node name>; advertising refreshes after disconnect",
#else
"OK - Bluetooth name follows %s<node name>; reboot required",
#endif
BLE_NAME_PREFIX);
} else {
snprintf(reply, reply_size,
#if defined(NRF52_PLATFORM)
"OK - Bluetooth name saved as '%s'; advertising refreshes after disconnect", value);
#else
"OK - Bluetooth name saved as '%s'; reboot required", value);
#endif
}
return true;
}
void MyMesh::formatBluetoothNameStatus(char* reply, size_t reply_size) const {
if (reply == NULL || reply_size == 0) return;
char effective_name[64];
mesh::companion::formatBluetoothName(
effective_name, sizeof(effective_name), _prefs.bluetooth_name,
BLE_NAME_PREFIX, _prefs.node_name);
snprintf(reply, reply_size, "> %s (%s)", effective_name,
mesh::companion::hasCustomBluetoothName(_prefs.bluetooth_name)
? "custom" : "default from node name");
}
#if defined(BLE_PIN_CODE)
bool MyMesh::prepareBluetoothMacForBoot(bool& address_rotated) {
address_rotated = false;
if (_prefs.bluetooth_mac_mode
== mesh::companion::BLUETOOTH_MAC_RANDOM_EVERY_BOOT) {
// This policy deliberately invalidates the previous local BLE identity.
// Reopen first pairing before advertising rather than hiding behind a
// bond created for that old address. Address generation remains once per
// boot in main.cpp, including initialization retries.
if (!resetBluetoothStealthPairing()) return false;
address_rotated = true;
return true;
}
const bool rotate_after_connection =
mesh::companion::bluetoothMacShouldRotateAtBoot(
_prefs.bluetooth_mac_mode, _prefs.bluetooth_mac);
if (!rotate_after_connection) return true;
uint8_t address[mesh::companion::BLUETOOTH_MAC_BYTES];
getRNG()->random(address, sizeof(address));
mesh::companion::makeRandomStaticBluetoothMacDifferentFrom(
address, _prefs.bluetooth_mac);
address_rotated = saveBluetoothMac(
mesh::companion::BLUETOOTH_MAC_RANDOM_AFTER_CONNECT, address);
return address_rotated;
}
bool MyMesh::armBluetoothMacRotationAfterConnection() {
if (_prefs.bluetooth_mac_mode
== mesh::companion::BLUETOOTH_MAC_RANDOM_AFTER_CONNECT_ARMED) {
return true;
}
if (_prefs.bluetooth_mac_mode
!= mesh::companion::BLUETOOTH_MAC_RANDOM_AFTER_CONNECT) {
return true;
}
_prefs.bluetooth_mac_mode =
mesh::companion::BLUETOOTH_MAC_RANDOM_AFTER_CONNECT_ARMED;
if (savePrefs()) return true;
_prefs.bluetooth_mac_mode =
mesh::companion::BLUETOOTH_MAC_RANDOM_AFTER_CONNECT;
return false;
}
bool MyMesh::saveBluetoothStealthPeer(
const mesh::companion::BluetoothPeerIdentity& peer) {
if (!mesh::companion::isValidBluetoothPeerIdentity(peer)) return false;
if (_prefs.bluetooth_stealth_mode
== mesh::companion::BLUETOOTH_STEALTH_PAIRED
&& _prefs.bluetooth_stealth_peer_type == peer.type
&& memcmp(_prefs.bluetooth_stealth_peer, peer.address,
sizeof(_prefs.bluetooth_stealth_peer)) == 0) {
return true;
}
if (_prefs.bluetooth_stealth_mode
!= mesh::companion::BLUETOOTH_STEALTH_PAIRING) {
return false;
}
const uint8_t previous_mode = _prefs.bluetooth_mac_mode;
const uint8_t previous_type = _prefs.bluetooth_stealth_peer_type;
uint8_t previous_peer[mesh::companion::BLUETOOTH_MAC_BYTES];
memcpy(previous_peer, _prefs.bluetooth_stealth_peer,
sizeof(previous_peer));
_prefs.bluetooth_stealth_mode = mesh::companion::BLUETOOTH_STEALTH_PAIRED;
// One durable write records both consequences of a successful pairing.
// Stealth must not prevent random-after-connect from arming for next boot.
if (mesh::companion::bluetoothMacModeIsRandomAfterConnect(
_prefs.bluetooth_mac_mode)) {
_prefs.bluetooth_mac_mode =
mesh::companion::BLUETOOTH_MAC_RANDOM_AFTER_CONNECT_ARMED;
}
_prefs.bluetooth_stealth_peer_type = peer.type;
memcpy(_prefs.bluetooth_stealth_peer, peer.address,
sizeof(_prefs.bluetooth_stealth_peer));
if (savePrefs()) return true;
_prefs.bluetooth_mac_mode = previous_mode;
_prefs.bluetooth_stealth_mode = mesh::companion::BLUETOOTH_STEALTH_PAIRING;
_prefs.bluetooth_stealth_peer_type = previous_type;
memcpy(_prefs.bluetooth_stealth_peer, previous_peer,
sizeof(_prefs.bluetooth_stealth_peer));
return false;
}
bool MyMesh::resetBluetoothStealthPairing() {
if (_prefs.bluetooth_stealth_mode !=
mesh::companion::BLUETOOTH_STEALTH_PAIRED) {
return true;
}
const uint8_t previous_type = _prefs.bluetooth_stealth_peer_type;
uint8_t previous_peer[mesh::companion::BLUETOOTH_MAC_BYTES];
memcpy(previous_peer, _prefs.bluetooth_stealth_peer,
sizeof(previous_peer));
clearCompanionBluetoothStealthPeer(_prefs);
if (savePrefs()) return true;
_prefs.bluetooth_stealth_mode = mesh::companion::BLUETOOTH_STEALTH_PAIRED;
_prefs.bluetooth_stealth_peer_type = previous_type;
memcpy(_prefs.bluetooth_stealth_peer, previous_peer,
sizeof(_prefs.bluetooth_stealth_peer));
return false;
}
bool MyMesh::saveBluetoothMac(uint8_t mode, const uint8_t* address) {
if (!mesh::companion::isValidBluetoothMacMode(mode)) return false;
if (mesh::companion::bluetoothMacModeUsesSavedAddress(mode)
&& !mesh::companion::isValidBluetoothMac(address)) {
return false;
}
if (_prefs.bluetooth_mac_mode == mode
&& (!mesh::companion::bluetoothMacModeUsesSavedAddress(mode)
|| memcmp(_prefs.bluetooth_mac, address,
sizeof(_prefs.bluetooth_mac)) == 0)) {
return true;
}
const uint8_t previous_mode = _prefs.bluetooth_mac_mode;
const uint8_t previous_stealth_mode = _prefs.bluetooth_stealth_mode;
uint8_t previous_address[mesh::companion::BLUETOOTH_MAC_BYTES];
const uint8_t previous_peer_type = _prefs.bluetooth_stealth_peer_type;
uint8_t previous_peer[mesh::companion::BLUETOOTH_MAC_BYTES];
memcpy(previous_address, _prefs.bluetooth_mac, sizeof(previous_address));
memcpy(previous_peer, _prefs.bluetooth_stealth_peer,
sizeof(previous_peer));
_prefs.bluetooth_mac_mode = mode;
memset(_prefs.bluetooth_mac, 0, sizeof(_prefs.bluetooth_mac));
if (mesh::companion::bluetoothMacModeUsesSavedAddress(mode)) {
memcpy(_prefs.bluetooth_mac, address, sizeof(_prefs.bluetooth_mac));
}
clearCompanionBluetoothStealthPeer(_prefs);
if (savePrefs()) return true;
_prefs.bluetooth_mac_mode = previous_mode;
_prefs.bluetooth_stealth_mode = previous_stealth_mode;
memcpy(_prefs.bluetooth_mac, previous_address, sizeof(_prefs.bluetooth_mac));
_prefs.bluetooth_stealth_peer_type = previous_peer_type;
memcpy(_prefs.bluetooth_stealth_peer, previous_peer,
sizeof(_prefs.bluetooth_stealth_peer));
return false;
}
bool MyMesh::applyAndSaveBluetoothMac(const char* value, char* reply,
size_t reply_size) {
if (reply == NULL || reply_size == 0) return false;
if (value == NULL) {
mesh::cli::formatError(reply, reply_size, "use set bluetooth.mac <address|random|random-every-boot|random-after-connect|default>");
return false;
}
uint8_t mode = mesh::companion::BLUETOOTH_MAC_DEFAULT;
uint8_t address[mesh::companion::BLUETOOTH_MAC_BYTES] = {};
if (value[0] == 0 || strcmp(value, "default") == 0
|| strcmp(value, "clear") == 0) {
mode = mesh::companion::BLUETOOTH_MAC_DEFAULT;
} else if (strcmp(value, "random") == 0) {
mode = mesh::companion::BLUETOOTH_MAC_RANDOM_SAVED;
getRNG()->random(address, sizeof(address));
mesh::companion::makeRandomStaticBluetoothMac(address);
} else if (strcmp(value, "random-every-boot") == 0
|| strcmp(value, "random-everyboot") == 0
|| strcmp(value, "random_every_boot") == 0
|| strcmp(value, "random everyboot") == 0
|| strcmp(value, "random every boot") == 0
|| strcmp(value, "everyboot") == 0) {
mode = mesh::companion::BLUETOOTH_MAC_RANDOM_EVERY_BOOT;
} else if (strcmp(value, "random-after-connect") == 0
|| strcmp(value, "random-after-connection") == 0
|| strcmp(value, "random after connect") == 0
|| strcmp(value, "random after connection") == 0
|| strcmp(value, "random-on-reboot-after-connect") == 0) {
mode = mesh::companion::BLUETOOTH_MAC_RANDOM_AFTER_CONNECT;
getRNG()->random(address, sizeof(address));
mesh::companion::makeRandomStaticBluetoothMac(address);
} else {
const char* custom_value = value;
if (strncmp(custom_value, "custom", 6) == 0
&& (custom_value[6] == ' ' || custom_value[6] == '\t')) {
custom_value += 7;
while (*custom_value == ' ' || *custom_value == '\t') custom_value++;
}
if (!mesh::companion::parseBluetoothMac(custom_value, address)) {
mesh::cli::formatError(reply, reply_size, "use a random-static address (first byte C0-FF), random, random-every-boot, random-after-connect, or default");
return false;
}
mode = mesh::companion::BLUETOOTH_MAC_CUSTOM;
}
#if defined(RP2040_PLATFORM)
if (mode != mesh::companion::BLUETOOTH_MAC_DEFAULT) {
mesh::cli::formatError(reply, reply_size, "Bluetooth address overrides are not supported on Pico W");
return false;
}
#endif
if (!saveBluetoothMac(mode, address)) {
mesh::cli::formatError(reply, reply_size, "Bluetooth MAC save failed");
return false;
}
char formatted[mesh::companion::BLUETOOTH_MAC_TEXT_SIZE];
if (mode == mesh::companion::BLUETOOTH_MAC_DEFAULT) {
snprintf(reply, reply_size,
"OK - factory Bluetooth address restored; reboot and re-pair");
} else if (mode == mesh::companion::BLUETOOTH_MAC_RANDOM_EVERY_BOOT) {
snprintf(reply, reply_size,
"OK - Bluetooth address will randomize every boot; reboot and pair each time");
} else if (mesh::companion::bluetoothMacModeIsRandomAfterConnect(mode)) {
mesh::companion::formatBluetoothMac(address, formatted,
sizeof(formatted));
snprintf(reply, reply_size,
"OK - Bluetooth address saved as %s; it will rotate on the first boot after a connection",
formatted);
} else {
mesh::companion::formatBluetoothMac(address, formatted,
sizeof(formatted));
snprintf(reply, reply_size,
"OK - Bluetooth address saved as %s; reboot and re-pair",
formatted);
}
return true;
}
void MyMesh::formatBluetoothMacStatus(char* reply, size_t reply_size) const {
if (reply == NULL || reply_size == 0) return;
const uint8_t mode = _prefs.bluetooth_mac_mode;
if (!mesh::companion::isValidBluetoothMacMode(mode)) {
snprintf(reply, reply_size,
"> factory address (invalid saved mode ignored)");
} else if (mode == mesh::companion::BLUETOOTH_MAC_DEFAULT) {
snprintf(reply, reply_size, "> factory address (default)");
} else if (mode == mesh::companion::BLUETOOTH_MAC_RANDOM_EVERY_BOOT) {
snprintf(reply, reply_size, "> random-every-boot (pair after each reboot)");
} else if (!mesh::companion::isValidBluetoothMac(
_prefs.bluetooth_mac)) {
snprintf(reply, reply_size,
"> factory address (invalid saved address ignored)");
} else if (mesh::companion::bluetoothMacModeIsRandomAfterConnect(mode)) {
char formatted[mesh::companion::BLUETOOTH_MAC_TEXT_SIZE];
mesh::companion::formatBluetoothMac(
_prefs.bluetooth_mac, formatted, sizeof(formatted));
snprintf(
reply, reply_size, "> %s (random-after-connect; %s)", formatted,
mode == mesh::companion::BLUETOOTH_MAC_RANDOM_AFTER_CONNECT_ARMED
? "rotation armed for next boot"
: "retained until a successful connection");
} else {
char formatted[mesh::companion::BLUETOOTH_MAC_TEXT_SIZE];
mesh::companion::formatBluetoothMac(
_prefs.bluetooth_mac, formatted, sizeof(formatted));
snprintf(reply, reply_size, "> %s (%s)", formatted,
mode == mesh::companion::BLUETOOTH_MAC_CUSTOM
? "custom" : "saved random");
}
}
bool MyMesh::applyAndSaveBluetoothStealth(const char* value, char* reply,
size_t reply_size) {
if (reply == NULL || reply_size == 0) return false;
if (value == NULL
|| (strcmp(value, "on") != 0 && strcmp(value, "off") != 0)) {
mesh::cli::formatError(reply, reply_size, "use set bluetooth.stealth on|off");
return false;
}
#if defined(RP2040_PLATFORM)
if (strcmp(value, "on") == 0) {
mesh::cli::formatError(reply, reply_size, "Bluetooth stealth is not supported on Pico W");
return false;
}
#endif
const uint8_t previous_mode = _prefs.bluetooth_stealth_mode;
const uint8_t previous_type = _prefs.bluetooth_stealth_peer_type;
uint8_t previous_peer[mesh::companion::BLUETOOTH_MAC_BYTES];
memcpy(previous_peer, _prefs.bluetooth_stealth_peer, sizeof(previous_peer));
if (setCompanionBluetoothStealth(_prefs, strcmp(value, "on") == 0)
&& !savePrefs()) {
_prefs.bluetooth_stealth_mode = previous_mode;
_prefs.bluetooth_stealth_peer_type = previous_type;
memcpy(_prefs.bluetooth_stealth_peer, previous_peer, sizeof(previous_peer));
mesh::cli::formatError(reply, reply_size, "Bluetooth stealth save failed");
return false;
}
snprintf(reply, reply_size,
"OK - Bluetooth stealth %s saved; MAC policy unchanged; reboot to apply",
value);
return true;
}
void MyMesh::formatBluetoothStealthStatus(char* reply, size_t reply_size) const {
if (reply == NULL || reply_size == 0) return;
if (!mesh::companion::bluetoothStealthEnabled(_prefs.bluetooth_stealth_mode)) {
snprintf(reply, reply_size, "> off");
} else {
snprintf(reply, reply_size, "> on (%s)",
_prefs.bluetooth_stealth_mode == mesh::companion::BLUETOOTH_STEALTH_PAIRED
? "bonded-peer-only advertising"
: "discoverable until first authenticated pairing");
}
}
#endif
#if defined(MESH_PRIMARY_ESPNOW) && MESH_PRIMARY_ESPNOW
void MyMesh::formatEspNowChannel(char* reply, size_t reply_size) const {
if (reply == NULL || reply_size == 0) return;
const uint8_t saved = mesh::wifi::loadConfiguredEspNowChannel();
const uint8_t active = mesh::wifi::activeEspNowChannel();
if (saved == active) {
snprintf(reply, reply_size, "> %u (saved and active)",
(unsigned)saved);
} else {
snprintf(reply, reply_size,
"> saved %u, active %u; reboot required",
(unsigned)saved, (unsigned)active);
}
}
bool MyMesh::applyAndSaveEspNowChannel(const char* value, char* reply,
size_t reply_size) {
if (reply == NULL || reply_size == 0) return false;
uint8_t channel = 0;
if (!mesh::wifi::parseEspNowChannel(value, channel)) {
mesh::cli::formatError(reply, reply_size, "ESP-NOW channel must be 1-13");
return false;
}
// Snapshot the boot channel before writing NVS so even a CLI-only image
// cannot reinterpret the newly-saved setting as active in this boot.
const uint8_t active = mesh::wifi::activeEspNowChannel();
if (!mesh::wifi::saveConfiguredEspNowChannel(channel)) {
mesh::cli::formatError(reply, reply_size, "failed to save ESP-NOW channel");
return false;
}
if (channel == active) {
snprintf(reply, reply_size,
"OK - ESP-NOW channel %u saved and active",
(unsigned)channel);
} else {
snprintf(reply, reply_size,
"OK - ESP-NOW channel %u saved; active %u; reboot required",
(unsigned)channel, (unsigned)active);
}
return true;
}
#endif
bool MyMesh::applyAndSavePowerSaving(const char* value, char* reply) {
bool enabled;
if (strcmp(value, "on") == 0) {
enabled = true;
} else if (strcmp(value, "off") == 0) {
enabled = false;
} else {
mesh::cli::formatError(reply, 160, "use set powersaving on|off");
return false;
}
if (!savePreference(_prefs.powersaving_enabled, static_cast<uint8_t>(enabled))) {
mesh::cli::formatError(reply, 160, "power saving could not be saved");
return false;
}
sensors.setPowerSavingEnabled(enabled);
snprintf(reply, 160, "OK - powersaving %s", enabled ? "on" : "off");
return true;
}
#if defined(ESP32) && defined(WIFI_SSID)
void MyMesh::syncWiFiPowerSaving() {
const uint8_t mode = getCompanionWiFiPowerSave();
#ifdef WITH_MQTT_BRIDGE
_mqtt_prefs.wifi_power_save = mode;
#endif
#ifdef WITH_WEBCONFIG
if (_webconfig) _webconfig->reloadStandaloneWiFi();
#endif
}
void MyMesh::formatWiFiPowerSaving(char* reply, size_t reply_size) const {
if (!reply || reply_size == 0) return;
snprintf(reply, reply_size, "> %s", getCompanionWiFiPowerSaveName());
}
bool MyMesh::applyAndSaveWiFiPowerSaving(const char* value, char* reply,
size_t reply_size) {
if (!reply || reply_size == 0) return false;
uint8_t mode = mesh::wifi::kDefaultPowerSave;
if (value && strcmp(value, "min") == 0) {
mode = mesh::wifi::kPowerSaveMin;
} else if (value && strcmp(value, "none") == 0) {
mode = mesh::wifi::kPowerSaveNone;
} else if (value && strcmp(value, "max") == 0) {
mode = mesh::wifi::kPowerSaveMax;
} else {
mesh::cli::formatError(reply, reply_size, "power save must be none, min, or max");
return false;
}
const CompanionWiFiPowerSaveResult result =
setCompanionWiFiPowerSave(mode);
if (result == CompanionWiFiPowerSaveResult::PrimaryEspNowConflict) {
mesh::cli::formatError(reply, reply_size, "power save max is unavailable while ESP-NOW is the primary radio");
return false;
}
if (result == CompanionWiFiPowerSaveResult::BluetoothConflict) {
mesh::cli::formatError(reply, reply_size, "power save none is unavailable while Bluetooth is active");
return false;
}
if (result == CompanionWiFiPowerSaveResult::InvalidMode) {
mesh::cli::formatError(reply, reply_size, "power save must be none, min, or max");
return false;
}
if (result == CompanionWiFiPowerSaveResult::StorageError) {
mesh::cli::formatError(reply, reply_size, "failed to save WiFi power save");
return false;
}
if (result == CompanionWiFiPowerSaveResult::SavedForNextBoot) {
snprintf(reply, reply_size,
"OK - power save none saved for wifi-only boot; reboot required (current mode unchanged)");
return true;
}
syncWiFiPowerSaving();
if (result == CompanionWiFiPowerSaveResult::Applied) {
snprintf(reply, reply_size, "OK - WiFi power save set to %s", value);
} else {
snprintf(reply, reply_size,
"OK - saved; WiFi power save applies on next connection");
}
return true;
}
#endif
bool MyMesh::handleRxPowerSavingGetCommand(const char* command, char* reply,
size_t reply_size) const {
if (!command || !reply || reply_size == 0) return false;
const bool config = strcmp(command, "get radio.rxps.config") == 0;
if (!config && strcmp(command, "get radio.rxps") != 0) return false;
if (!radio_driver.supportsRxPowerSaving()) {
mesh::cli::formatError(reply, reply_size, "RX power saving is unsupported on this radio");
return true;
}
uint32_t rx_us = _prefs.rx_ps_rx_us;
uint32_t sleep_us = _prefs.rx_ps_sleep_us;
uint8_t effective_level = 0;
uint8_t effective_preamble = 0;
if (!config && _prefs.rx_ps_level != 0) {
uint8_t sf = _prefs.sf;
float bw = _prefs.bw;
#if COMPANION_FEATURE_TEMP_RADIO
if (isTempRadioActive()) {
sf = _temp_radio_sf;
bw = _temp_radio_bw;
}
#endif
// Calculate into locals only. A diagnostic must not change saved timings,
// apply radio parameters, or create a pending preference write.
recalcRxPowerSavingFromLevel(_prefs.rx_ps_level, sf, bw,
_prefs.rx_ps_preamble, &rx_us, &sleep_us,
&effective_level, &effective_preamble);
}
snprintf(reply, reply_size, "> %s,level=%u,preamble=%u,rx=%lu,sleep=%lu%s",
_prefs.rx_powersaving_enabled ? "on" : "off",
(unsigned)_prefs.rx_ps_level, (unsigned)_prefs.rx_ps_preamble,
(unsigned long)rx_us, (unsigned long)sleep_us,
!config && radio_driver.isRxPowerSavingContinuousFallback()
? ",mode=continuous-fast" : "");
size_t used = strlen(reply);
if (!config && effective_level != 0 && effective_level != _prefs.rx_ps_level
&& used < reply_size - 1) {
snprintf(reply + used, reply_size - used, ",effective-level=%u",
(unsigned)effective_level);
}
used = strlen(reply);
if (!config && effective_preamble != 0 && effective_preamble != _prefs.rx_ps_preamble
&& used < reply_size - 1) {
snprintf(reply + used, reply_size - used, ",effective-preamble=%u",
(unsigned)effective_preamble);
}
return true;
}
bool MyMesh::applyAndSaveRxPowerSaving(const char* value, char* reply) {
if (!radio_driver.supportsRxPowerSaving()) {
mesh::cli::formatError(reply, 160, "RXPS unsupported");
return false;
}
uint8_t enabled = _prefs.rx_powersaving_enabled;
uint8_t level = _prefs.rx_ps_level;
uint8_t preamble = _prefs.rx_ps_preamble;
uint32_t rx_us = _prefs.rx_ps_rx_us;
uint32_t sleep_us = _prefs.rx_ps_sleep_us;
bool level_requested = false;
bool manual_requested = false;
mesh::cli::RxPowerSavingArguments parsed = {};
if (!mesh::cli::parseRxPowerSavingArgumentsStrict(value, parsed)) {
mesh::cli::formatError(reply, 160, "use off, level 1-10, or RX_US SLEEP_US");
return false;
}
if (parsed.mode == mesh::cli::RxPowerSavingArgumentMode::Off) {
enabled = 0;
} else if (parsed.mode
== mesh::cli::RxPowerSavingArgumentMode::Conservative) {
enabled = 1;
level = RX_POWERSAVING_CONSERVATIVE_LEVEL;
preamble = RX_POWERSAVING_PROFILE_PREAMBLE;
level_requested = true;
} else if (parsed.mode == mesh::cli::RxPowerSavingArgumentMode::Balanced) {
enabled = 1;
level = RX_POWERSAVING_BALANCED_LEVEL;
preamble = RX_POWERSAVING_PROFILE_PREAMBLE;
level_requested = true;
} else if (parsed.mode == mesh::cli::RxPowerSavingArgumentMode::Level) {
if (parsed.level < 1 || parsed.level > 10) {
strcpy(reply, parsed.preamble == 0
? "Error: level must be 1-10"
: "Error: level 1-10; preamble 16 or 32");
return false;
}
if (parsed.preamble != 0
&& parsed.preamble != 16 && parsed.preamble != 32) {
mesh::cli::formatError(reply, 160, "level 1-10; preamble 16 or 32");
return false;
}
enabled = 1;
level = static_cast<uint8_t>(parsed.level);
preamble = static_cast<uint8_t>(parsed.preamble);
level_requested = true;
} else if (parsed.mode == mesh::cli::RxPowerSavingArgumentMode::Manual) {
if (parsed.rx_us < RX_POWERSAVING_MIN_MANUAL_PERIOD_US
|| parsed.rx_us > RX_POWERSAVING_MAX_PERIOD_US
|| parsed.sleep_us < RX_POWERSAVING_MIN_MANUAL_PERIOD_US
|| parsed.sleep_us > RX_POWERSAVING_MAX_PERIOD_US) {
snprintf(reply, 160, "Error: RX/SLEEP %lu-%lu us",
(unsigned long)RX_POWERSAVING_MIN_MANUAL_PERIOD_US,
(unsigned long)RX_POWERSAVING_MAX_PERIOD_US);
return false;
}
enabled = 1;
rx_us = parsed.rx_us;
sleep_us = parsed.sleep_us;
level = 0;
preamble = 0;
manual_requested = true;
}
if (level_requested) {
if (level < 1 || level > 10
|| (preamble != 0 && preamble != 16 && preamble != 32)
|| !recalcRxPowerSavingFromLevel(level, _prefs.sf, _prefs.bw, preamble,
&rx_us, &sleep_us)) {
mesh::cli::formatError(reply, 160, "level 1-10; preamble auto, 16 or 32");
return false;
}
}
if ((manual_requested || enabled)
&& (!isValidRxPowerSavingPeriod(rx_us)
|| !isValidRxPowerSavingPeriod(sleep_us))) {
snprintf(reply, 160, "Error: RX/SLEEP %lu-%lu us",
(unsigned long)RX_POWERSAVING_MIN_MANUAL_PERIOD_US,
(unsigned long)RX_POWERSAVING_MAX_PERIOD_US);
return false;
}
const auto result = _radio_available && !_primary_radio_blocked
? radio_driver.trySetRxPowerSaving(enabled != 0, rx_us, sleep_us)
: mesh::RadioParamApplyResult::BUSY;
if (result == mesh::RadioParamApplyResult::FAILED) {
queueSavedRadioApply();
mesh::cli::formatError(reply, 160, "RXPS rejected");
return false;
}
const uint8_t previous_enabled = _prefs.rx_powersaving_enabled;
const uint8_t previous_level = _prefs.rx_ps_level;
const uint8_t previous_preamble = _prefs.rx_ps_preamble;
const uint32_t previous_rx = _prefs.rx_ps_rx_us, previous_sleep = _prefs.rx_ps_sleep_us;
_prefs.rx_powersaving_enabled = enabled;
_prefs.rx_ps_rx_us = rx_us;
_prefs.rx_ps_sleep_us = sleep_us;
_prefs.rx_ps_level = level;
_prefs.rx_ps_preamble = preamble;
if (!savePrefs()) {
_prefs.rx_powersaving_enabled = previous_enabled;
_prefs.rx_ps_rx_us = previous_rx;
_prefs.rx_ps_sleep_us = previous_sleep;
_prefs.rx_ps_level = previous_level;
_prefs.rx_ps_preamble = previous_preamble;
if (result == mesh::RadioParamApplyResult::APPLIED
&& !radio_driver.setRxPowerSaving(previous_enabled != 0, previous_rx, previous_sleep))
queueSavedRadioApply();
mesh::cli::formatError(reply, 160, "RXPS not saved");
return false;
}
if (result == mesh::RadioParamApplyResult::BUSY) queueSavedRadioApply();
snprintf(reply, 160, "OK - %s,%lu,%lu",
enabled ? "on" : "off", (unsigned long)rx_us,
(unsigned long)sleep_us);
return true;
}
void MyMesh::appendRxPowerSavingAdjustmentNote(char* reply, size_t reply_size,
uint8_t sf, float bw) const {
if (!reply || reply_size == 0 || !_prefs.rx_powersaving_enabled
|| _prefs.rx_ps_level < 1 || _prefs.rx_ps_level > 10) {
return;
}
uint32_t rx_us = 0;
uint32_t sleep_us = 0;
uint8_t effective_level = 0;
uint8_t effective_preamble = 0;
if (!recalcRxPowerSavingFromLevel(
_prefs.rx_ps_level, sf, bw, _prefs.rx_ps_preamble,
&rx_us, &sleep_us, &effective_level, &effective_preamble)) {
return;
}
const size_t used = strlen(reply);
if (used >= reply_size - 1) return;
if (rxPowerSavingUsesContinuousFallback(rx_us, sleep_us)) {
snprintf(reply + used, reply_size - used,
"; RXPS continuous-fast (requested level %u)",
(unsigned)_prefs.rx_ps_level);
return;
}
const uint8_t requested_preamble = _prefs.rx_ps_preamble == 0
? rxPowerSavingPreambleForParams(sf, bw) : _prefs.rx_ps_preamble;
if (effective_level == _prefs.rx_ps_level
&& effective_preamble == requested_preamble) {
return;
}
snprintf(reply + used, reply_size - used,
"; RXPS effective level %u, preamble %u (saved minimum %u)",
(unsigned)effective_level, (unsigned)effective_preamble,
(unsigned)_prefs.rx_ps_level);
}
#if COMPANION_FEATURE_TEXT_TERMINAL
Stream& MyMesh::terminalOutput() {
if (isTerminalWaitingForInput()) return mesh::usbTerminalPort(false);
return _terminal_output != NULL ? *_terminal_output : Serial;
}
void MyMesh::resetTerminalSession() {
#if COMPANION_FEATURE_READER
mesh::cancelReaderCommand();
#endif
_terminal_input_editing = false;
_morse_terminal_owner = nullptr;
_morse_terminal_pending_chars = 0;
_morse_terminal_was_receiving = false;
_terminal_recipient_set = false;
memset(_terminal_recipient_key, 0, sizeof(_terminal_recipient_key));
clearTerminalLogin();
clearTerminalCommand();
clearTerminalTrace();
// A reply from an abandoned TCP or USB terminal session must not be shown
// to whichever terminal connects next. Keep the radio retry alive for the
// Binary Companion interface, but remove its old text-terminal ownership.
for (int i = 0; i < EXPECTED_ACK_TABLE_SIZE; i++) {
expected_ack_table[i].terminal_origin = false;
}
}
void MyMesh::serviceMorseTerminal() {
// Only an explicitly owned ASCII terminal receives spontaneous text.
// Never use terminalOutput()'s Serial fallback or append to BLE/USB frames.
if (!_radio || !hasTerminalOutput()) {
_morse_terminal_owner = nullptr;
_morse_terminal_pending_chars = 0;
_morse_terminal_was_receiving = false;
return;
}
// A temporarily silent/paused terminal still owns its cursor. Resuming
// must not replay the capture or reset a pending partial line.
if (!_terminal_morse_atomic || isTerminalWaitingForInput()
|| _terminal_input_editing || _terminal_output->available() > 0
|| _terminal_login_pending || _terminal_trace_pending
|| _terminal_command.isPending()) return;
#if COMPANION_FEATURE_READER
// A chapter cursor owns its entire logical output, including pauses under
// backpressure. Progress must not split a header, verse or final prompt.
if (mesh::readerCommandActive(_terminal_output)) return;
#endif
// The existing USB facade owns all queued suffixes, including replies and
// echo. Never append progress in the middle of an earlier text producer.
if (_terminal_mode && _terminal_output == &mesh::usbTerminalPort()
&& mesh::hasPendingUsbTerminalOutput()) return;
const bool receiving = _radio->isMorseRxActive();
const uint32_t generation = _radio->morseCaptureGeneration();
const size_t total = _radio->morseCaptureLength();
const uint32_t now = _ms->getMillis();
if (_morse_terminal_owner != _terminal_output || _morse_terminal_generation != generation) {
_morse_terminal_owner = _terminal_output;
_morse_terminal_generation = generation;
_morse_terminal_offset = receiving ? 0 : total;
_morse_terminal_pending_chars = 0;
_morse_terminal_was_receiving = receiving;
_morse_terminal_next_at = now;
}
if (receiving) _morse_terminal_was_receiving = true;
if (!_morse_terminal_was_receiving) return;
if (!_morse_terminal_pending_chars) {
if (_morse_terminal_offset >= total) {
if (!receiving) _morse_terminal_was_receiving = false;
return;
}
if (int32_t(now - _morse_terminal_next_at) < 0) return;
const size_t pending = total - _morse_terminal_offset;
_morse_terminal_pending_chars = pending < 48 ? pending : 48;
}
// Admit one complete record to an explicitly atomic stream. Its existing
// queue handles hardware short writes in chronological order with replies;
// Morse never retains an application-level suffix that can overtake input.
char text[49], line[80];
const size_t copied = _radio->readMorseCapture(_morse_terminal_offset, text,
size_t(_morse_terminal_pending_chars) + 1);
if (copied != _morse_terminal_pending_chars) {
_morse_terminal_pending_chars = 0;
return;
}
const int length = snprintf(line, sizeof(line), "\r\n[Morse RX] %s\r\n> ", text);
if (length <= 0 || size_t(length) >= sizeof(line)) return;
const int available = _terminal_output->availableForWrite();
if (available < length) return;
const size_t written = _terminal_output->write(
reinterpret_cast<const uint8_t*>(line), length);
if (written == size_t(length)) {
_morse_terminal_offset += _morse_terminal_pending_chars;
_morse_terminal_pending_chars = 0;
_morse_terminal_next_at = now + 250;
} else if (written) {
// A stream that violates its atomic admission contract is no longer safe
// for unsolicited progress. Keep the full capture available via CLI.
_terminal_morse_atomic = false;
_morse_terminal_pending_chars = 0;
}
}
void MyMesh::printTerminalBanner(bool show_binary_stop) {
Stream& output = terminalOutput();
#if COMPANION_FEATURE_TEMP_RADIO || COMPANION_FEATURE_NETWORK_TERMINAL
output.print("\r\n===== MeshCore Full Companion Terminal =====\r\n\r\n");
#else
output.print("\r\n===== MeshCore Companion Terminal =====\r\n\r\n");
#endif
output.printf("WELCOME %s\r\n", _prefs.node_name);
mesh::Utils::printHex(output, self_id.pub_key, PUB_KEY_SIZE);
output.printf("\r\nCompanion %s\r\n", FIRMWARE_VERSION);
output.print(" (enter 'help' for commands)\r\n");
if (show_binary_stop) {
output.print(" (+++MESHCORE-TERM-STOP returns to Binary mode)\r\n");
} else {
output.print(" (disconnect to close this network terminal)\r\n");
}
output.print("\r\n> ");
}
void MyMesh::enterTerminalMode(bool show_banner) {
_terminal_mode = true;
_terminal_usb_silent = !show_banner;
_terminal_output = &mesh::usbTerminalPort();
#if MESH_ESP32_TINYUSB_NONBLOCKING \
|| (defined(NRF52_PLATFORM) && (defined(ENABLE_USB_INTERFACE) || defined(USE_TINYUSB)))
_terminal_morse_atomic = true;
#else
_terminal_morse_atomic = false;
#endif
resetTerminalSession();
if (show_banner) printTerminalBanner(true);
}
void MyMesh::exitTerminalMode() {
_terminal_mode = false;
_terminal_usb_silent = false;
_terminal_morse_atomic = false;
resetTerminalSession();
if (_terminal_output == &mesh::usbTerminalPort()) _terminal_output = NULL;
}
#if COMPANION_FEATURE_NETWORK_TERMINAL || defined(WITH_WEBCONFIG)
bool MyMesh::enterNetworkTerminalMode(Stream& output, bool atomic_output) {
if (_terminal_mode || (_terminal_output != NULL && _terminal_output != &output)) return false;
_terminal_output = &output;
_terminal_morse_atomic = atomic_output;
resetTerminalSession();
printTerminalBanner(false);
return true;
}
void MyMesh::exitNetworkTerminalMode(Stream& output) {
if (_terminal_mode || _terminal_output != &output) return;
resetTerminalSession();
_terminal_morse_atomic = false;
_terminal_output = NULL;
}
bool MyMesh::isNetworkTerminalMode(const Stream& output) const {
return !_terminal_mode && _terminal_output == &output;
}
#endif
ContactInfo* MyMesh::getTerminalRecipient() {
if (!_terminal_recipient_set) return NULL;
ContactInfo* recipient = lookupContactByPubKey(_terminal_recipient_key, PUB_KEY_SIZE);
if (recipient == NULL) {
_terminal_recipient_set = false;
memset(_terminal_recipient_key, 0, sizeof(_terminal_recipient_key));
}
return recipient;
}
void MyMesh::printTerminalPath(const ContactInfo& recipient) {
terminalOutput().printf(" Path to %s: ", recipient.name);
if (recipient.out_path_len == OUT_PATH_UNKNOWN) {
terminalOutput().print("unknown (next send uses FLOOD)\r\n");
return;
}
if (!mesh::Packet::isValidPathLen(recipient.out_path_len)) {
terminalOutput().print("invalid\r\n");
return;
}
const uint8_t hash_size = (recipient.out_path_len >> 6) + 1;
const uint8_t hop_count = recipient.out_path_len & 63;
if (hop_count == 0) {
terminalOutput().print("direct (zero hop)\r\n");
return;
}
uint8_t path_bytes[MAX_PATH_SIZE];
if (!recipient.copyPathTo(path_bytes)) {
terminalOutput().print("unavailable (storage read failed)\r\n");
return;
}
for (uint8_t hop = 0; hop < hop_count; hop++) {
if (hop != 0) terminalOutput().print(',');
mesh::Utils::printHex(terminalOutput(),
&path_bytes[(size_t)hop * hash_size],
hash_size);
}
terminalOutput().printf(" (%u %s, %u-byte hashes; used by DIRECT sends)\r\n",
(unsigned)hop_count, hop_count == 1 ? "hop" : "hops",
(unsigned)hash_size);
}
void MyMesh::handleTerminalPath(ContactInfo& recipient,
const char* path_spec) {
if (path_spec == NULL) {
printTerminalPath(recipient);
return;
}
mesh::cli::TerminalPath path;
const mesh::cli::TerminalPathParseResult parsed =
mesh::cli::parseTerminalPath(path_spec, _terminal_tmp_buf,
MAX_PATH_SIZE, 63, path);
switch (parsed) {
case mesh::cli::TerminalPathParseResult::Valid:
break;
case mesh::cli::TerminalPathParseResult::Missing:
terminalOutput().print(" ERROR: use path <direct|clear|hops separated by spaces or commas>\r\n");
return;
case mesh::cli::TerminalPathParseResult::InvalidPrefix:
terminalOutput().print(" ERROR: each path hop must be 2, 4, or 6 hex digits\r\n");
return;
case mesh::cli::TerminalPathParseResult::MixedPrefixSize:
terminalOutput().print(" ERROR: all path hops must use the same width\r\n");
return;
case mesh::cli::TerminalPathParseResult::InvalidSeparator:
terminalOutput().print(" ERROR: separate path hops with spaces or commas\r\n");
return;
case mesh::cli::TerminalPathParseResult::TooManyHops:
terminalOutput().print(" ERROR: path must contain at most 63 hops\r\n");
return;
case mesh::cli::TerminalPathParseResult::RouteTooLong:
terminalOutput().print(" ERROR: path is too long\r\n");
return;
}
const ContactInfo previous = recipient;
if (!recipient.setPath(_terminal_tmp_buf,
path.mode == mesh::cli::TerminalPathMode::Clear ? OUT_PATH_UNKNOWN : path.encoded_len)
|| !scheduleContactWrite(recipient)) {
recipient = previous;
terminalOutput().print(
" ERROR: contact storage is unavailable; reboot and retry\r\n");
return;
}
printTerminalPath(recipient);
}
void MyMesh::rememberTerminalAck(ContactInfo& recipient, const char* text,
uint32_t message_timestamp, uint32_t expected_ack,
uint32_t est_timeout,
const uint8_t packet_retry_key[MAX_HASH_SIZE],
AckTableEntry* replacement_entry) {
if (expected_ack == 0) {
if (replacement_entry != NULL) {
clearExpectedAck(*replacement_entry, false);
}
return;
}
AckTableEntry& entry = replacement_entry != NULL
? *replacement_entry
: expected_ack_table[next_ack_idx];
clearExpectedAck(entry, false);
entry.msg_sent = _ms->getMillis();
entry.expires_at = futureMillis(est_timeout);
entry.ack = expected_ack;
entry.message_timestamp = message_timestamp;
entry.contact = &recipient;
entry.reply_route = NULL;
mesh::Utils::sha256(entry.text_fingerprint, sizeof(entry.text_fingerprint),
recipient.id.pub_key, PUB_KEY_SIZE,
(const uint8_t*)text, strlen(text));
memcpy(entry.retry_key, packet_retry_key, sizeof(entry.retry_key));
entry.terminal_origin = true;
if (replacement_entry == NULL) {
next_ack_idx = (next_ack_idx + 1) % EXPECTED_ACK_TABLE_SIZE;
}
expireExpectedAcks();
}
void MyMesh::importTerminalCard(char* command) {
if (_store->hasIncompleteContactLoad()) {
terminalOutput().print(
" ERROR: contact storage is unavailable; reboot and retry\r\n");
return;
}
while (*command == ' ') command++;
if (strncmp(command, "meshcore://", 11) != 0) {
terminalOutput().print(" ERROR: invalid card format\r\n");
return;
}
char* encoded = command + 11;
char* end = encoded + strlen(encoded);
while (end > encoded && !mesh::Utils::isHexChar(end[-1])) {
*--end = 0;
}
size_t encoded_len = strlen(encoded);
if (encoded_len == 0 || (encoded_len & 1) != 0
|| encoded_len / 2 > sizeof(_terminal_tmp_buf)) {
terminalOutput().print(" ERROR: invalid card format\r\n");
return;
}
size_t raw_len = encoded_len / 2;
if (!mesh::Utils::fromHex(_terminal_tmp_buf, raw_len, encoded)
|| !importContact(_terminal_tmp_buf, raw_len)) {
terminalOutput().print(" ERROR: invalid card\r\n");
return;
}
terminalOutput().print(" OK - contact import queued\r\n");
}
void MyMesh::listTerminalChannels() {
bool found = false;
terminalOutput().print("Channels:\r\n");
for (int i = 0; i < MAX_GROUP_CHANNELS; i++) {
ChannelDetails channel;
if (getChannel(i, channel) && channel.name[0] != 0) {
terminalOutput().printf(" %d: %s\r\n", i, channel.name);
found = true;
}
}
if (!found) terminalOutput().print(" (none configured)\r\n");
}
void MyMesh::handleTerminalDisplayCommand(const char* arguments) {
mesh::TerminalDisplayCommand display_command;
const mesh::TerminalDisplayParseResult result =
mesh::parseTerminalDisplayCommand(arguments, display_command);
if (result == mesh::TerminalDisplayParseResult::InvalidCategory
|| result == mesh::TerminalDisplayParseResult::InvalidValue) {
terminalOutput().print(
" ERROR: use show [adverts|channels|emergency] [on|off]\r\n");
return;
}
if (result == mesh::TerminalDisplayParseResult::StatusAll) {
terminalOutput().printf(" Terminal display: adverts %s, channels %s, emergency %s\r\n",
_terminal_display.isEnabled(
mesh::TerminalDisplayCategory::Adverts) ? "on" : "off",
_terminal_display.isEnabled(
mesh::TerminalDisplayCategory::Channels) ? "on" : "off",
_terminal_display.isEnabled(
mesh::TerminalDisplayCategory::Emergency) ? "on" : "off");
return;
}
if (result == mesh::TerminalDisplayParseResult::Updated) {
_terminal_display.setEnabled(display_command.category,
display_command.enabled);
}
terminalOutput().printf(" Terminal display: %s %s\r\n",
mesh::terminalDisplayCategoryName(display_command.category),
_terminal_display.isEnabled(display_command.category)
? "on" : "off");
}
void MyMesh::sendTerminalChannelMessage(ChannelDetails& channel,
const char* text) {
#if MESH_ENABLE_MESHTEXT_CHAT
if (isMeshtextChannel(channel)) {
if (text && sendMeshtextText(channel, text, strlen(text))) {
terminalOutput().printf(" Queued to %s (Meshtext).\r\n", channel.name);
} else {
terminalOutput().printf(" ERROR: %s; use get mt.status\r\n", _mt_status);
}
return;
}
#endif
if (isAliasRadio2Defined()
&& parseCompanionChannelAlias(channel.name).kind == CompanionChannelKind::Meshtext) {
terminalOutput().print(" ERROR: Meshtext channel transport is not available\r\n");
return;
}
const size_t prefix_len = strlen(_prefs.node_name) + 2; // "name: "
const size_t max_text_len = prefix_len < MAX_TEXT_LEN
? MAX_TEXT_LEN - prefix_len : 0;
const size_t text_len = text == NULL ? 0 : strlen(text);
if (text_len == 0) {
terminalOutput().print(" ERROR: message is empty\r\n");
} else if (text_len > max_text_len) {
terminalOutput().printf(" ERROR: message must be 1-%u UTF-8 bytes for this node name\r\n",
(unsigned)max_text_len);
} else if (sendGroupMessage(getRTCClock()->getCurrentTimeUnique(),
channel.channel, _prefs.node_name, text,
text_len)) {
terminalOutput().printf(" Sent to %s.\r\n", channel.name);
} else {
terminalOutput().print(" ERROR: unable to send\r\n");
}
}
void MyMesh::printTerminalSendStatus(const char* operation,
const ContactInfo& recipient, int result,
uint32_t timeout_millis) {
terminalOutput().printf(" %s sent to %s (", operation, recipient.name);
if (result == MSG_SEND_SENT_FLOOD) {
terminalOutput().print("FLOOD");
} else {
const uint8_t hop_count = recipient.out_path_len & 63;
if (hop_count == 0) {
terminalOutput().print("zero-hop DIRECT");
} else {
const uint8_t hash_size = (recipient.out_path_len >> 6) + 1;
terminalOutput().print("DIRECT via path ");
uint8_t path_bytes[MAX_PATH_SIZE];
const bool path_ready = recipient.copyPathTo(path_bytes);
if (!path_ready) terminalOutput().print("unavailable");
for (uint8_t hop = 0; path_ready && hop < hop_count; hop++) {
if (hop != 0) terminalOutput().print(',');
mesh::Utils::printHex(
terminalOutput(), &path_bytes[(size_t)hop * hash_size], hash_size);
}
terminalOutput().printf("; %u %s, %u-byte hashes", (unsigned)hop_count,
hop_count == 1 ? "hop" : "hops", (unsigned)hash_size);
}
}
terminalOutput().printf(", timeout %lu ms)\r\n", (unsigned long)timeout_millis);
}
void MyMesh::clearTerminalLogin() {
clearTerminalLogin(_ms->getMillis());
}
void MyMesh::clearTerminalLogin(uint32_t now) {
if (_terminal_login_pending && _delayed_replies.request.terminal
&& _delayed_replies.request.kind == mesh::CompanionDelayedReplies::Login
&& _delayed_replies.requestMatches(_terminal_login_key, 0)) {
_delayed_replies.retireRequest(now);
}
_terminal_login_pending = false;
memset(_terminal_login_key, 0, sizeof(_terminal_login_key));
_terminal_login_expires_at = 0;
_terminal_login_target[0] = 0;
}
void MyMesh::serviceTerminalLogin() {
serviceTerminalLogin(_ms->getMillis());
}
void MyMesh::serviceTerminalLogin(uint32_t now) {
if (!_terminal_login_pending) return;
if ((int32_t)(now - (uint32_t)_terminal_login_expires_at) < 0) {
return;
}
if (hasTerminalOutput()) {
terminalOutput().printf("\r\n ERROR: login to %s timed out (wrong password or no response).\r\n> ",
_terminal_login_target);
}
clearTerminalLogin(now);
}
void MyMesh::sendTerminalLogin(ContactInfo& recipient,
const char* password) {
servicePendingSerialReply();
if (_terminal_login_pending) {
terminalOutput().printf(" ERROR: login to %s is still pending\r\n",
_terminal_login_target);
return;
}
if (hasPendingReqs()) {
terminalOutput().print(
" ERROR: another Companion request is still pending\r\n");
return;
}
const size_t password_len = password == NULL ? 0 : strlen(password);
if (password_len == 0 || password_len > 15) {
terminalOutput().print(" ERROR: password must be 1-15 UTF-8 bytes\r\n");
return;
}
if (!beginPendingRequest(mesh::CompanionDelayedReplies::Login, recipient, true)) {
terminalOutput().print(" ERROR: login is quarantined or reply history is full\r\n");
return;
}
uint32_t est_timeout = 0;
const int result = sendLogin(recipient, password, est_timeout);
if (result == MSG_SEND_FAILED) {
abandonPendingRequest();
terminalOutput().print(" ERROR: unable to send login\r\n");
return;
}
uint32_t tag;
memcpy(&tag, recipient.id.pub_key, 4);
_delayed_replies.armRequest(tag, est_timeout,
result == MSG_SEND_SENT_FLOOD, _ms->getMillis());
_terminal_login_pending = true;
memcpy(_terminal_login_key, recipient.id.pub_key,
sizeof(_terminal_login_key));
const uint32_t timeout = est_timeout + est_timeout / 5;
_terminal_login_expires_at = _delayed_replies.request.radio_deadline;
StrHelper::strzcpy(_terminal_login_target, recipient.name,
sizeof(_terminal_login_target));
printTerminalSendStatus("Login", recipient, result, timeout);
}
void MyMesh::clearTerminalCommand() {
_terminal_command.clear();
_terminal_command_target[0] = 0;
}
void MyMesh::serviceTerminalCommand() {
uint32_t elapsed_millis = 0;
if (!_terminal_command.expire(_ms->getMillis(), elapsed_millis)) return;
if (hasTerminalOutput()) {
terminalOutput().printf("\r\n ERROR: command to %s timed out after %lu ms.\r\n> ",
_terminal_command_target, (unsigned long)elapsed_millis);
}
_terminal_command_target[0] = 0;
}
void MyMesh::sendTerminalCommand(ContactInfo& recipient,
const char* command) {
serviceTerminalLogin();
if (_terminal_login_pending) {
terminalOutput().printf(" ERROR: login to %s is still pending\r\n",
_terminal_login_target);
return;
}
serviceTerminalCommand();
if (_terminal_command.isPending()) {
terminalOutput().printf(" ERROR: command to %s is still pending\r\n",
_terminal_command_target);
return;
}
// Be defensive if a caller passes the complete terminal line instead of
// the already-parsed argument. Never transmit the local "cmd" wrapper.
const char* on_air_command = command;
const char* nested_command = NULL;
if (mesh::cli::parseTerminalArgumentCommand(
command, "cmd", nested_command)
== mesh::cli::TerminalArgumentCommandMatch::Valid) {
on_air_command = nested_command;
}
const size_t command_len =
on_air_command == NULL ? 0 : strlen(on_air_command);
if (command_len == 0 || command_len > MAX_CORRELATED_CLI_TEXT_LEN) {
terminalOutput().printf(" ERROR: remote command must be 1-%u UTF-8 bytes\r\n",
(unsigned)MAX_CORRELATED_CLI_TEXT_LEN);
return;
}
const uint32_t logical_request_id =
getRTCClock()->getCurrentTimeUnique();
const uint32_t timestamp = getRTCClock()->getCurrentTimeUnique();
const uint32_t command_started_at = _ms->getMillis();
uint32_t est_timeout = 0;
// Legacy server roles used CLI_DATA for requests as well as replies. Keep
// that interoperable request form for deployed Repeaters, Rooms, and
// Sensors; Chat/Companion nodes need the explicit CLI_COMMAND direction.
const bool is_server_role = recipient.type == ADV_TYPE_REPEATER
|| recipient.type == ADV_TYPE_ROOM
|| recipient.type == ADV_TYPE_SENSOR;
const uint8_t txt_type = is_server_role
? TXT_TYPE_CLI_DATA : TXT_TYPE_CLI_COMMAND;
const int result = sendCommandData(recipient, timestamp, 0,
txt_type, on_air_command,
est_timeout, logical_request_id);
if (result == MSG_SEND_FAILED) {
terminalOutput().print(" ERROR: unable to send remote command\r\n");
return;
}
_terminal_command.begin(recipient.id.pub_key, command_started_at,
est_timeout);
StrHelper::strzcpy(_terminal_command_target, recipient.name,
sizeof(_terminal_command_target));
printTerminalSendStatus("Remote command", recipient, result, est_timeout);
}
void MyMesh::clearTerminalTrace() {
clearTerminalTrace(_ms->getMillis());
}
void MyMesh::clearTerminalTrace(uint32_t now) {
if (_terminal_trace_pending) {
_delayed_replies.retireTrace(_terminal_trace_history, now,
_terminal_trace_expires_at);
}
_terminal_trace_history = mesh::CompanionDelayedReplies::NO_HISTORY;
_terminal_trace_pending = false;
_terminal_trace_hash_size = 0;
_terminal_trace_tag = 0;
_terminal_trace_auth = 0;
_terminal_trace_sent_at = 0;
_terminal_trace_expires_at = 0;
_terminal_trace_target[0] = 0;
}
void MyMesh::serviceTerminalTrace() {
serviceTerminalTrace(_ms->getMillis());
}
void MyMesh::serviceTerminalTrace(uint32_t now) {
if (!_terminal_trace_pending) return;
if ((int32_t)(now - (uint32_t)_terminal_trace_expires_at) < 0) {
return;
}
if (hasTerminalOutput()) {
terminalOutput().printf("\r\n ERROR: trace to %s timed out.\r\n> ",
_terminal_trace_target);
}
clearTerminalTrace(now);
}
void MyMesh::sendTerminalTraceRoute(const uint8_t* route, uint8_t hash_size,
uint8_t hop_count, const char* target) {
serviceTerminalTrace();
if (_terminal_trace_pending) {
terminalOutput().printf(" ERROR: trace to %s is still pending\r\n",
_terminal_trace_target);
return;
}
if (route == NULL || hop_count == 0 || hop_count >= MAX_PATH_SIZE) {
terminalOutput().print(" ERROR: trace path must contain 1-63 prefixes\r\n");
return;
}
const size_t route_byte_len = static_cast<size_t>(hash_size) * hop_count;
if (route_byte_len > MAX_PACKET_PAYLOAD - 9) {
terminalOutput().print(" ERROR: trace path is too long\r\n");
return;
}
const uint8_t flags = mesh::traceFlagsForHashSize(hash_size);
if (flags == 0xFF) {
terminalOutput().print(" ERROR: trace hash size must be 1, 2, or 4 bytes\r\n");
return;
}
mesh::Packet* packet = createTrace(0, 0, flags);
if (packet == NULL) {
terminalOutput().print(" ERROR: unable to allocate trace packet\r\n");
return;
}
const uint32_t airtime =
getTransmitAirtime(packet, 2 + packet->payload_len + route_byte_len);
if (airtime == 0) {
releasePacket(packet);
terminalOutput().print(" ERROR: unable to estimate trace timeout\r\n");
return;
}
const uint32_t base_timeout =
calcDirectTimeoutMillisFor(airtime, hop_count);
uint32_t trace_timeout = 0;
if (!mesh::calculateTerminalTraceTimeoutMillis(base_timeout,
trace_timeout)
|| trace_timeout > 0x7FFFFFFFU
- mesh::CompanionDelayedReplies::DELIVERY_GRACE_MS) {
releasePacket(packet);
terminalOutput().print(" ERROR: trace timeout is out of range\r\n");
return;
}
uint32_t tag = 0;
uint32_t auth = 0;
getRNG()->random((uint8_t*)&tag, sizeof(tag));
getRNG()->random((uint8_t*)&auth, sizeof(auth));
memcpy(packet->payload, &tag, sizeof(tag));
memcpy(&packet->payload[4], &auth, sizeof(auth));
const uint8_t history = _delayed_replies.reserveTrace(tag, auth, _ms->getMillis());
if (history == mesh::CompanionDelayedReplies::NO_HISTORY) {
releasePacket(packet);
terminalOutput().print(" ERROR: trace tag is quarantined or reply history is full\r\n");
return;
}
if (!sendDirect(packet, route, static_cast<uint8_t>(route_byte_len))) {
_delayed_replies.abandonTrace(history);
terminalOutput().print(" ERROR: unable to send trace\r\n");
return;
}
_terminal_trace_pending = true;
_terminal_trace_history = history;
_terminal_trace_hash_size = hash_size;
_terminal_trace_tag = tag;
_terminal_trace_auth = auth;
_terminal_trace_sent_at = _ms->getMillis();
_terminal_trace_expires_at = futureMillis(trace_timeout);
StrHelper::strzcpy(_terminal_trace_target, target,
sizeof(_terminal_trace_target));
terminalOutput().printf(" Trace sent to %s (%u route hops, timeout %lu ms)\r\n",
target, (unsigned)hop_count,
(unsigned long)trace_timeout);
}
void MyMesh::sendTerminalTrace(ContactInfo& recipient) {
if (recipient.out_path_len == OUT_PATH_UNKNOWN
|| !mesh::Packet::isValidPathLen(recipient.out_path_len)) {
terminalOutput().print(" ERROR: recipient has no valid direct path\r\n");
return;
}
const bool include_endpoint = recipient.type == ADV_TYPE_REPEATER
|| recipient.type == ADV_TYPE_ROOM;
mesh::RoundTripTracePath route;
uint8_t path_bytes[MAX_PATH_SIZE];
if (!recipient.copyPathTo(path_bytes) || !mesh::buildRoundTripTracePath(
path_bytes, recipient.out_path_len, recipient.id.pub_key,
include_endpoint, _terminal_tmp_buf, MAX_PACKET_PAYLOAD - 9,
route)) {
terminalOutput().print(" ERROR: recipient has no traceable round-trip path\r\n");
return;
}
if (route.hop_count >= MAX_PATH_SIZE) {
terminalOutput().print(" ERROR: round-trip trace path is too long\r\n");
return;
}
sendTerminalTraceRoute(_terminal_tmp_buf, route.hash_size, route.hop_count,
recipient.name);
}
void MyMesh::sendTerminalRawTrace(const char* arguments) {
mesh::RawTracePath route;
const mesh::RawTracePathParseResult parsed = mesh::parseRawTracePath(
arguments, _terminal_tmp_buf, MAX_PACKET_PAYLOAD - 9,
MAX_PATH_SIZE - 1, route);
switch (parsed) {
case mesh::RawTracePathParseResult::Valid:
break;
case mesh::RawTracePathParseResult::MissingHashSize:
case mesh::RawTracePathParseResult::MissingPrefixes:
terminalOutput().print(" ERROR: use trace path <1|2|4> <prefixes...>\r\n");
return;
case mesh::RawTracePathParseResult::InvalidHashSize:
terminalOutput().print(" ERROR: trace hash size must be 1, 2, or 4 bytes\r\n");
return;
case mesh::RawTracePathParseResult::InvalidPrefix:
terminalOutput().printf(" ERROR: every prefix must be exactly %u hex digits\r\n",
(unsigned)route.hash_size * 2);
return;
case mesh::RawTracePathParseResult::TooManyHops:
terminalOutput().print(" ERROR: trace path must contain at most 63 prefixes\r\n");
return;
case mesh::RawTracePathParseResult::RouteTooLong:
terminalOutput().print(" ERROR: trace path is too long\r\n");
return;
}
char target[32];
snprintf(target, sizeof(target), "raw %u-byte path",
(unsigned)route.hash_size);
sendTerminalTraceRoute(_terminal_tmp_buf, route.hash_size, route.hop_count,
target);
}
#if defined(WITH_WEBCONFIG) && COMPANION_FEATURE_READER
bool MyMesh::streamTerminalBusy() const {
return mesh::readerCommandActive(_terminal_output);
}
#endif
void MyMesh::handleTerminalCommand(char* command, bool reader_prompt,
mesh::ReaderTryWrite reader_try_write) {
while (*command == ' ' || *command == '\t') command++;
if (*command == 0) return;
mesh::cli::normalizeCommandVerb(command);
#if COMPANION_FEATURE_READER
if (mesh::morseTimingActive(_radio) && isCompanionReaderRequest(command)) {
mesh::cancelReaderCommand();
terminalOutput().print(" ERROR: Reader unavailable during Morse\r\n");
return;
}
if (mesh::handleReaderCommand(command, terminalOutput(), reader_prompt,
reader_try_write)) return;
#endif
// Match the repeater ASCII reply envelope so unmodified serial packet
// bridges can query this role. Framed/BLE replies retain their payloads.
char local_reply[160];
if (handleRxPowerSavingGetCommand(command, local_reply, sizeof(local_reply))) {
if (local_reply[0] == '>') {
terminalOutput().printf(" %s %s\r\n", command + 4, local_reply + 2);
} else {
terminalOutput().printf(" ERROR: %s\r\n", local_reply + 7);
}
return;
}
if (handleCompanionWirelessCommand(command, local_reply, sizeof(local_reply),
_terminal_mode ? CompanionWirelessSource::Usb : CompanionWirelessSource::Network)
|| handleCompanionBluetoothCommand(command, local_reply, sizeof(local_reply),
CompanionBluetoothCommandSource::Terminal)) {
terminalOutput().printf(" -> %s\r\n", local_reply);
return;
}
if (handleDirectCommand(command, local_reply, sizeof(local_reply))) {
terminalOutput().printf(" -> %s\r\n", local_reply);
return;
}
#if COMPANION_FEATURE_USB_MOTA_SOURCE
const bool usb_mota_owner_transition =
mesh::isUsbMotaOwnerTransitionCommand(command);
#else
const bool usb_mota_owner_transition = false;
#endif
if (!usb_mota_owner_transition
&& handleLocalControlCommand(command, local_reply, sizeof(local_reply))) {
terminalOutput().printf(" -> %s\r\n", local_reply);
return;
}
mesh::cli::TerminalChannelMessage channel_message;
const mesh::cli::TerminalChannelCommandMatch channel_match =
mesh::cli::parseTerminalChannelMessage(command, channel_message);
const char* login_password = NULL;
const mesh::cli::TerminalArgumentCommandMatch login_match =
mesh::cli::parseTerminalArgumentCommand(command, "login",
login_password);
const char* remote_command = NULL;
const mesh::cli::TerminalArgumentCommandMatch command_match =
mesh::cli::parseTerminalArgumentCommand(command, "cmd",
remote_command);
const char* path_spec = NULL;
const mesh::cli::TerminalArgumentCommandMatch path_match =
mesh::cli::parseTerminalArgumentCommand(command, "path", path_spec);
const char* recipient_prefix = NULL;
const mesh::cli::TerminalArgumentCommandMatch recipient_match =
mesh::cli::parseTerminalArgumentCommand(command, "to",
recipient_prefix);
const char* display_arguments = NULL;
const mesh::cli::TerminalArgumentCommandMatch display_match =
mesh::cli::parseTerminalArgumentCommand(command, "show",
display_arguments);
if (display_match != mesh::cli::TerminalArgumentCommandMatch::NoMatch) {
handleTerminalDisplayCommand(
display_match == mesh::cli::TerminalArgumentCommandMatch::Valid
? display_arguments : NULL);
} else if (strcmp(command, "channels") == 0) {
listTerminalChannels();
} else if (channel_match != mesh::cli::TerminalChannelCommandMatch::NoMatch) {
if (channel_match != mesh::cli::TerminalChannelCommandMatch::Valid) {
terminalOutput().print(" ERROR: use channel <name-or-slot> <message>\r\n");
return;
}
ChannelDetails channel;
bool found = false;
size_t requested_index = 0;
if (mesh::cli::parseTerminalChannelIndex(
channel_message, MAX_GROUP_CHANNELS, requested_index)) {
found = getChannel((int)requested_index, channel)
&& channel.name[0] != 0;
} else {
for (int i = 0; i < MAX_GROUP_CHANNELS; i++) {
if (getChannel(i, channel) && channel.name[0] != 0
&& mesh::cli::terminalChannelNameMatches(channel_message,
channel.name)) {
found = true;
break;
}
}
}
if (!found) {
terminalOutput().print(" ERROR: channel not found (use 'channels')\r\n");
} else {
sendTerminalChannelMessage(channel, channel_message.text);
}
} else if (strncmp(command, "send ", 5) == 0) {
ContactInfo* recipient = getTerminalRecipient();
const char* text = command + 5;
if (recipient == NULL) {
terminalOutput().print(" ERROR: no recipient selected (use 'to' first)\r\n");
} else if (*text == 0 || strlen(text) > MAX_TEXT_LEN) {
terminalOutput().printf(" ERROR: message must be 1-%u characters\r\n", (unsigned)MAX_TEXT_LEN);
} else {
uint32_t expected_ack = 0;
uint32_t est_timeout = 0;
uint32_t message_timestamp = getRTCClock()->getCurrentTimeUnique();
uint8_t text_fingerprint[MAX_HASH_SIZE] = { 0 };
uint8_t packet_retry_key[MAX_HASH_SIZE] = { 0 };
mesh::Utils::sha256(text_fingerprint, sizeof(text_fingerprint),
recipient->id.pub_key, PUB_KEY_SIZE,
(const uint8_t*)text, strlen(text));
AckTableEntry* replacement_entry =
findPendingTextMessage(text_fingerprint, message_timestamp);
int result = sendMessage(*recipient, message_timestamp, 0, text,
expected_ack, est_timeout, packet_retry_key,
NULL, text_fingerprint);
if (result == MSG_SEND_FAILED) {
terminalOutput().print(" ERROR: unable to send\r\n");
} else {
rememberTerminalAck(*recipient, text, message_timestamp, expected_ack,
est_timeout, packet_retry_key, replacement_entry);
terminalOutput().printf(" message sent - %s\r\n",
result == MSG_SEND_SENT_FLOOD ? "FLOOD" : "DIRECT");
}
}
} else if (strncmp(command, "public ", 7) == 0) {
ChannelDetails channel;
const char* text = command + 7;
if (!getChannel(0, channel) || channel.name[0] == 0) {
terminalOutput().print(" ERROR: Public channel is unavailable\r\n");
} else {
sendTerminalChannelMessage(channel, text);
}
} else if (strcmp(command, "list") == 0 || strncmp(command, "list ", 5) == 0) {
int count = command[4] == ' ' ? atoi(command + 5) : 0;
scanRecentContacts(count, this);
} else if (strcmp(command, "clock") == 0) {
DateTime dt(getRTCClock()->getCurrentTime());
terminalOutput().printf("%02d:%02d - %d/%d/%d UTC\r\n",
dt.hour(), dt.minute(), dt.day(), dt.month(), dt.year());
} else if (strncmp(command, "time ", 5) == 0) {
uint32_t timestamp = strtoul(command + 5, NULL, 10);
uint32_t current = getRTCClock()->getCurrentTime();
if (timestamp >= current) {
getRTCClock()->setCurrentTime(timestamp);
#ifdef COMPANION_MESH_CLOCK_SYNC
_clock_sync.onManualClockSet();
#endif
terminalOutput().print(" OK - clock set\r\n");
} else {
terminalOutput().print(" ERROR: clock cannot go backwards\r\n");
}
} else if (recipient_match
== mesh::cli::TerminalArgumentCommandMatch::Valid) {
ContactInfo* recipient = NULL;
if (strlen(recipient_prefix) < sizeof(ContactInfo::name)) {
recipient = searchContactsByPrefix(recipient_prefix);
}
if (recipient == NULL || recipient->type == ADV_TYPE_NONE) {
terminalOutput().print(" ERROR: name prefix not found\r\n");
} else {
memcpy(_terminal_recipient_key, recipient->id.pub_key, PUB_KEY_SIZE);
_terminal_recipient_set = true;
terminalOutput().printf(" Recipient %s selected\r\n", recipient->name);
}
} else if (recipient_match
== mesh::cli::TerminalArgumentCommandMatch::MissingArgument) {
ContactInfo* recipient = getTerminalRecipient();
if (recipient != NULL) {
terminalOutput().printf(" Current recipient: %s\r\n", recipient->name);
} else {
terminalOutput().print(" No recipient selected\r\n");
}
} else if (path_match
!= mesh::cli::TerminalArgumentCommandMatch::NoMatch) {
ContactInfo* recipient = getTerminalRecipient();
if (recipient == NULL) {
terminalOutput().print(" ERROR: no recipient selected (use 'to' first)\r\n");
} else {
handleTerminalPath(
*recipient,
path_match == mesh::cli::TerminalArgumentCommandMatch::Valid
? path_spec : NULL);
}
} else if (login_match
!= mesh::cli::TerminalArgumentCommandMatch::NoMatch) {
ContactInfo* recipient = getTerminalRecipient();
if (login_match
!= mesh::cli::TerminalArgumentCommandMatch::Valid) {
terminalOutput().print(" ERROR: use login <admin-password>\r\n");
} else if (recipient == NULL) {
terminalOutput().print(" ERROR: no recipient selected (use 'to' first)\r\n");
} else {
sendTerminalLogin(*recipient, login_password);
}
} else if (command_match
!= mesh::cli::TerminalArgumentCommandMatch::NoMatch) {
ContactInfo* recipient = getTerminalRecipient();
if (command_match
!= mesh::cli::TerminalArgumentCommandMatch::Valid) {
terminalOutput().print(" ERROR: use cmd <remote-command>\r\n");
} else if (recipient == NULL) {
terminalOutput().print(" ERROR: no recipient selected (use 'to' first)\r\n");
} else {
sendTerminalCommand(*recipient, remote_command);
}
} else if (strcmp(command, "trace path") == 0
|| strncmp(command, "trace path ", 11) == 0
|| strncmp(command, "trace path\t", 11) == 0) {
sendTerminalRawTrace(command + 10);
} else if (strcmp(command, "trace") == 0
|| strncmp(command, "trace ", 6) == 0) {
ContactInfo* recipient = NULL;
if (command[5] == ' ') {
const char* prefix = command + 6;
while (*prefix == ' ') prefix++;
if (*prefix != 0 && strlen(prefix) < sizeof(ContactInfo::name)) {
recipient = searchContactsByPrefix(prefix);
}
if (recipient == NULL || recipient->type == ADV_TYPE_NONE) {
terminalOutput().print(" ERROR: recipient prefix not found\r\n");
return;
}
} else {
recipient = getTerminalRecipient();
if (recipient == NULL) {
terminalOutput().print(" ERROR: no recipient selected (use 'to' first)\r\n");
return;
}
}
sendTerminalTrace(*recipient);
} else if (strcmp(command, "advert") == 0) {
terminalOutput().print(advert() ? " advert sent (zero hop)\r\n"
: " ERROR: unable to send advert\r\n");
} else if (strcmp(command, "reset path") == 0) {
ContactInfo* recipient = getTerminalRecipient();
if (recipient == NULL) {
terminalOutput().print(" ERROR: no recipient selected\r\n");
} else {
const uint8_t previous_out_path_len = recipient->out_path_len;
resetPathTo(*recipient);
if (scheduleContactWrite(*recipient)) {
terminalOutput().print(" Done.\r\n");
} else {
recipient->out_path_len = previous_out_path_len;
terminalOutput().print(
" ERROR: contact storage is unavailable; reboot and retry\r\n");
}
}
} else if (strcmp(command, "card") == 0) {
mesh::Packet* packet = _prefs.advert_loc_policy == ADVERT_LOC_NONE
? createSelfAdvert(_prefs.node_name)
: createSelfAdvert(_prefs.node_name, sensors.node_lat, sensors.node_lon);
if (packet == NULL) {
terminalOutput().print(" ERROR: unable to create card\r\n");
} else {
packet->header |= ROUTE_TYPE_FLOOD;
uint8_t raw_len = packet->writeTo(_terminal_tmp_buf);
releasePacket(packet);
terminalOutput().print("meshcore://");
mesh::Utils::printHex(terminalOutput(), _terminal_tmp_buf, raw_len);
terminalOutput().print("\r\n");
}
} else if (strncmp(command, "import ", 7) == 0) {
importTerminalCard(command + 7);
} else if (strcmp(command, "powersaving") == 0
|| strcmp(command, "get powersaving") == 0) {
terminalOutput().printf(" powersaving %s\r\n",
_prefs.powersaving_enabled ? "on" : "off");
} else if (strncmp(command, "powersaving ", 12) == 0) {
char reply[160];
applyAndSavePowerSaving(command + 12, reply);
terminalOutput().printf(" -> %s\r\n", reply);
#if defined(ESP32) && defined(WIFI_SSID)
} else if (strcmp(command, "get wifi.powersave") == 0) {
char reply[160];
formatWiFiPowerSaving(reply, sizeof(reply));
terminalOutput().printf(" -> %s\r\n", reply);
#endif
} else if (strcmp(command, "get radio.rxgain") == 0) {
if (!radio_driver.supportsRxBoostedGainMode()) {
terminalOutput().print(" ERROR: RX gain unsupported\r\n");
} else {
terminalOutput().printf(" radio.rxgain %s\r\n",
_prefs.rx_boosted_gain ? "on" : "off");
}
} else if (strcmp(command, "get radio.fem.rxgain") == 0) {
if (!board.canControlLoRaFemLna()) {
terminalOutput().print(" ERROR: FEM RX gain control is unsupported on this board\r\n");
} else {
terminalOutput().printf(" FEM RX gain: %s\r\n",
board.isLoRaFemLnaEnabled() ? "on" : "off");
}
} else if (strcmp(command, "get radio.fem.txgain") == 0) {
if (!board.canControlLoRaFemPaGain()) {
if (board.isLoRaFemPaGainEnabled()) {
terminalOutput().print(" FEM TX gain: on (fixed TX PA)\r\n");
} else {
terminalOutput().print(" ERROR: FEM TX gain control is unsupported on this board\r\n");
}
} else {
terminalOutput().printf(" FEM TX gain: %s\r\n",
board.isLoRaFemPaGainEnabled() ? "on" : "off");
}
} else if (strncmp(command, "set ", 4) == 0) {
const char* config = command + 4;
if (strncmp(config, "powersaving ", 12) == 0) {
char reply[160];
applyAndSavePowerSaving(config + 12, reply);
terminalOutput().printf(" -> %s\r\n", reply);
#if defined(ESP32) && defined(WIFI_SSID)
} else if (strncmp(config, "wifi.powersave", 14) == 0
&& (config[14] == 0 || config[14] == ' '
|| config[14] == '\t')) {
const char* value = config + 14;
while (*value == ' ' || *value == '\t') value++;
char reply[160];
applyAndSaveWiFiPowerSaving(value, reply, sizeof(reply));
terminalOutput().printf(" -> %s\r\n", reply);
#endif
} else if (strncmp(config, "radio.rxgain", 12) == 0
&& (config[12] == 0 || config[12] == ' '
|| config[12] == '\t')) {
const char* value = config + 12;
while (*value == ' ' || *value == '\t') value++;
if (strcmp(value, "on") != 0 && strcmp(value, "off") != 0) {
terminalOutput().print(" ERROR: use on|off\r\n");
} else if (!radio_driver.supportsRxBoostedGainMode()) {
terminalOutput().print(" ERROR: RX gain unsupported\r\n");
} else if (!applyAndSaveRxBoostedGain(strcmp(value, "on") == 0)) {
terminalOutput().print(" ERROR: RX gain failed\r\n");
} else {
terminalOutput().printf(" OK - radio.rxgain %s\r\n", value);
}
} else if (strncmp(config, "af ", 3) == 0) {
float parsed = 0.0f;
if (!mesh::cli::parseDecimalStrict(config + 3, parsed)
|| parsed < 0.0f || parsed > 9.0f) {
terminalOutput().print(" ERROR: airtime factor must be 0-9\r\n");
} else {
terminalOutput().print(savePreference(_prefs.airtime_factor, parsed)
? " OK\r\n" : " ERROR: airtime factor could not be saved\r\n");
}
} else if (strncmp(config, "name ", 5) == 0 && config[5] != 0) {
terminalOutput().print(saveAdvertName(config + 5)
? " OK\r\n" : " ERROR: name could not be saved\r\n");
} else if (strncmp(config, "lat ", 4) == 0) {
float parsed = 0.0f;
if (!mesh::cli::parseDecimalStrict(config + 4, parsed)
|| parsed < -90.0f || parsed > 90.0f) {
terminalOutput().print(" ERROR: latitude must be -90 to 90\r\n");
} else {
terminalOutput().print(saveAdvertLocation(parsed, sensors.node_lon)
? " OK\r\n" : " ERROR: latitude could not be saved\r\n");
}
} else if (strncmp(config, "lon ", 4) == 0) {
float parsed = 0.0f;
if (!mesh::cli::parseDecimalStrict(config + 4, parsed)
|| parsed < -180.0f || parsed > 180.0f) {
terminalOutput().print(" ERROR: longitude must be -180 to 180\r\n");
} else {
terminalOutput().print(saveAdvertLocation(sensors.node_lat, parsed)
? " OK\r\n" : " ERROR: longitude could not be saved\r\n");
}
} else if (strncmp(config, "tx ", 3) == 0) {
handleCommand(command, 0, local_reply);
terminalOutput().printf(" -> %s\r\n", local_reply);
} else if (strncmp(config, "freq ", 5) == 0) {
handleCommand(command, 0, local_reply);
terminalOutput().printf(" -> %s\r\n", local_reply);
} else if (strncmp(config, "radio.fem.rxgain", 16) == 0
&& (config[16] == 0 || config[16] == ' '
|| config[16] == '\t')) {
const char* value = config + 16;
while (*value == ' ' || *value == '\t') value++;
if (strcmp(value, "on") != 0 && strcmp(value, "off") != 0) {
terminalOutput().print(" ERROR: use set radio.fem.rxgain <on|off>\r\n");
} else if (_radio && (_radio->isMorseTxActive() || _radio->isMorseRxActive())) {
terminalOutput().print(" ERROR: stop Morse before changing LoRa FEM gain\r\n");
} else if (!board.canControlLoRaFemLna()) {
terminalOutput().print(" ERROR: FEM RX gain control is unsupported on this board\r\n");
} else if (!applyAndSaveFemRxGain(strcmp(value, "on") == 0)) {
terminalOutput().print(" ERROR: failed to apply FEM RX gain\r\n");
} else {
terminalOutput().printf(" OK - FEM RX gain %s\r\n", value);
}
} else if (strncmp(config, "radio.fem.txgain", 16) == 0
&& (config[16] == 0 || config[16] == ' '
|| config[16] == '\t')) {
const char* value = config + 16;
while (*value == ' ' || *value == '\t') value++;
if (strcmp(value, "on") != 0 && strcmp(value, "off") != 0) {
terminalOutput().print(" ERROR: use set radio.fem.txgain <on|off>\r\n");
} else if (_radio && (_radio->isMorseTxActive() || _radio->isMorseRxActive())) {
terminalOutput().print(" ERROR: stop Morse before changing LoRa FEM gain\r\n");
} else if (!board.canControlLoRaFemPaGain()) {
terminalOutput().print(board.isLoRaFemPaGainEnabled()
? " ERROR: TX PA is fixed on; use set tx to adjust power\r\n"
: " ERROR: FEM TX gain control is unsupported on this board\r\n");
} else if (!applyAndSaveFemTxGain(strcmp(value, "on") == 0)) {
terminalOutput().print(" ERROR: failed to apply FEM TX gain\r\n");
} else {
terminalOutput().printf(" OK - FEM TX gain %s\r\n", value);
}
} else {
// The terminal has a few presentation-specific setters above, while
// handleCommand() owns the shared radio-pref and board-specific command
// surface used by framed/rescue clients. Use it only as the fallback so
// Full Companion does not develop holes such as `set radio ...` without
// changing the established terminal behavior of `set tx`, `set name`,
// or the WiFi commands.
if (handleCommand(command, 0, local_reply)) {
terminalOutput().printf(" -> %s\r\n", local_reply);
} else {
terminalOutput().printf(" ERROR: unknown setting: %s\r\n", config);
}
}
} else if (strcmp(command, "reboot") == 0) {
terminalOutput().print(" OK - rebooting in 1 second\r\n");
_scheduled_reboot_at = futureMillis(1000);
if (_scheduled_reboot_at == 0) _scheduled_reboot_at = 1;
#if defined(NRF52_PLATFORM) && defined(EXTRAFS) && !defined(QSPIFLASH)
} else if (strcmp(command, "repair extrafs") == 0) {
repairInternalExtraFS(terminalOutput());
} else if (strcmp(command, "scan extrafs erase") == 0) {
scanInternalExtraFS(terminalOutput());
#endif
} else if (strcmp(command, "ver") == 0) {
terminalOutput().printf(" -> Companion %s (protocol %u, build %s)\r\n",
FIRMWARE_VERSION, (unsigned)FIRMWARE_VER_CODE, FIRMWARE_BUILD_DATE);
} else if (strcmp(command, "help") == 0) {
auto& output = terminalOutput();
#if defined(RAK_4631) && defined(COMPANION_RADIO_FULL) \
|| (defined(STM32_PLATFORM) && defined(STM32WLxx) && COMPANION_FEATURE_TEXT_TERMINAL)
mesh::companion::writePackedCompanionHelp(output);
#else
#define MESH_COMPANION_HELP_BEGIN output.print(
#define MESH_COMPANION_HELP_END );
#define MESH_COMPANION_HELP_LINE_BEGIN output.println(
#define MESH_COMPANION_HELP_LINE_END );
#include "CompanionHelp.inc"
#undef MESH_COMPANION_HELP_BEGIN
#undef MESH_COMPANION_HELP_END
#undef MESH_COMPANION_HELP_LINE_BEGIN
#undef MESH_COMPANION_HELP_LINE_END
#endif
if (_terminal_mode) {
output.print(" +++MESHCORE-TERM-STOP\r\n");
} else {
output.print(" disconnect (network terminal)\r\n");
}
} else if (handleCommand(command, 0, local_reply)) {
// Fill the same safe shared-command surface for getters (`get name`,
// `get radio`, `get tx`, and variant commands). Terminal-only commands
// above still win, including the richer `ver` response and reboot flow.
terminalOutput().printf(" -> %s\r\n", local_reply);
} else {
terminalOutput().printf(" ERROR: unknown command: %s\r\n", command);
}
}
#endif
void MyMesh::enterCLIRescue() {
#if COMPANION_FEATURE_READER
mesh::cancelReaderCommand();
#endif
_cli_rescue = true;
cli_command[0] = 0;
_cli_line_overflow = false;
mesh::usbTerminalPort().println("========= CLI Rescue =========");
}
static bool isCompanionRadioPrefsCommand(const char* command) {
if (command == NULL) return false;
static const char* const exact_commands[] = {
"get radio", "get freq", "get af", "get dutycycle", "get tx",
"get rxdelay", "get path.hash.mode", "get multi.acks",
"get txdelay", "get direct.txdelay", "get int.thresh", "get agc.reset.interval",
"get radio.rxps.status"
};
for (const char* candidate : exact_commands) {
if (strcmp(command, candidate) == 0) return true;
}
static const char* const set_prefixes[] = {
"set radio ", "set af ", "set dutycycle ", "set rxdelay ",
"set path.hash.mode ", "set multi.acks ", "set txdelay ",
"set direct.txdelay ", "set int.thresh ", "set agc.reset.interval "
};
for (const char* prefix : set_prefixes) {
if (strncmp(command, prefix, strlen(prefix)) == 0) return true;
}
return false;
}
// Called only for directly attached USB/BLE/TCP/browser clients. Commands
// received over LoRa use handleCommand() with a nonzero sender timestamp.
bool MyMesh::handleDirectCommand(const char* command, char* reply, size_t reply_size) {
if (mesh::power::handleStoredBatteryCalibrationCommand(
board, command, reply, reply_size)) return true;
#if MESH_ENABLE_LOST_REPLY
if (mesh::companion::handleLostReplyCommand(
_prefs, command, reply, reply_size, [this]() { return savePrefs(); })) return true;
#endif
#ifdef MESH_BUTTON_AUDIO_HIL
// Present only in an explicitly instrumented physical-test build.
if (_ui && _ui->handleButtonAudioTest(command, reply, reply_size)) return true;
#endif
#if defined(MESH_SOAK_DIAGNOSTICS)
if (mesh::hil::handleSoakCommand(command, reply, reply_size)) return true;
#endif
#if defined(ESP32_PLATFORM) && defined(MESH_HIL_RADIO_TRACE) && MESH_HIL_RADIO_TRACE
if (mesh::radiotrace::handleTraceCommand(command, reply, reply_size)) return true;
#endif
#if MESH_ENABLE_ONE_KEY_DM
if (strcmp(command, "get dm.one_key") == 0) {
snprintf(reply, reply_size, "> %s", _prefs.one_key_dm_enabled == 1 ? "on" : "off");
return true;
}
if (strcmp(command, "get dm.held") == 0) {
snprintf(reply, reply_size, "> %u", (unsigned)held_dm_count);
return true;
}
if (strncmp(command, "set dm.one_key ", 15) == 0) {
const char* value = command + 15;
if (strcmp(value, "on") != 0 && strcmp(value, "off") != 0) {
mesh::cli::formatError(reply, reply_size, "use on or off");
return true;
}
const uint8_t old_value = _prefs.one_key_dm_enabled;
_prefs.one_key_dm_enabled = strcmp(value, "on") == 0 ? 1 : 0;
if (!savePrefs()) {
_prefs.one_key_dm_enabled = old_value;
mesh::cli::formatError(reply, reply_size, "could not save DM setting");
} else {
snprintf(reply, reply_size, "> dm.one_key %s", value);
}
return true;
}
#else
if (strcmp(command, "get dm.one_key") == 0 || strcmp(command, "get dm.held") == 0 ||
strncmp(command, "set dm.one_key ", 15) == 0) {
mesh::cli::formatError(reply, reply_size, "one-key DMs unsupported on this build");
return true;
}
#endif
if (strcmp(command, "get key.backup.transport") == 0) {
snprintf(reply, reply_size, "ephemeral-routed-v1");
return true;
}
if (strcmp(command, "get password") == 0) {
#if defined(ESP32_PLATFORM) && defined(WIFI_SSID)
// Binary Companion and its legacy TCP transports have no admin-login
// handshake. Report status there, never disclose the management secret.
snprintf(reply, reply_size, _management_password_ready
? "> (management password set; use password <new-password> to change)"
: "Error: management password unavailable; set password on USB");
#else
snprintf(reply, reply_size, "> (no admin password on Companion)");
#endif
return true;
}
if (strcmp(command, "get prv.key") == 0) {
#if ENABLE_PRIVATE_KEY_EXPORT
uint8_t key[PRV_KEY_SIZE];
char hex[PRV_KEY_SIZE * 2 + 1];
const int length = self_id.writeTo(key, sizeof(key));
mesh::Utils::toHex(hex, key, length);
snprintf(reply, reply_size, "> %s", hex);
memset(key, 0, sizeof(key));
memset(hex, 0, sizeof(hex));
#else
mesh::cli::formatError(reply, reply_size, "private key export disabled in this build");
#endif
return true;
}
if (strcmp(command, "erase") == 0) {
#if MESH_ENABLE_TX_BEACONS
suspendBeacons();
#endif
if (_store->formatFileSystem()) {
stopContactsIterator();
resetContacts();
MyMesh::onContactCacheFlushed();
#if MESH_ENABLE_ETHERNET_TUNNEL
if (!eraseEthernetSettings()) {
snprintf(reply, reply_size, "File system erased; Error: Ethernet settings erase failed");
return true;
}
#endif
#if MESH_ENABLE_MESHTEXT_CHAT
if (!eraseMeshtextSettings()) {
snprintf(reply, reply_size, "File system erased; Error: MT settings erase failed");
return true;
}
#endif
snprintf(reply, reply_size, "File system erase: OK");
} else {
snprintf(reply, reply_size, "File system erase: Err");
}
return true;
}
if (strcmp(command, "stats-core") == 0) {
StatsFormatHelper::formatCoreStats(reply, board, *_ms, _err_flags, _mgr);
return true;
}
if (strcmp(command, "stats-radio") == 0) {
StatsFormatHelper::formatRadioStats(reply, _radio, radio_driver,
getTotalAirTime(), getReceiveAirTime());
return true;
}
if (strcmp(command, "stats-radio-diag") == 0) {
StatsFormatHelper::formatRadioDiag(reply, _radio, radio_driver, *_ms,
_err_flags, hasOutbound());
return true;
}
if (strcmp(command, "stats-packets") == 0) {
StatsFormatHelper::formatPacketStats(reply, radio_driver, getNumSentFlood(),
getNumSentDirect(), getNumRecvFlood(), getNumRecvDirect());
return true;
}
#if defined(ESP32) && defined(WIFI_SSID)
if (strcmp(command, "get wifi.pwd") == 0) {
#ifdef WITH_WEBCONFIG
WebConfigServer::formatWiFiPassword(reply, reply_size);
#else
char ssid[32] = {}, password[65] = {};
if (!WiFiSetupPortal::loadStoredCredentials(ssid, sizeof(ssid), password, sizeof(password))) {
#ifdef WIFI_PWD
snprintf(password, sizeof(password), "%s", WIFI_PWD);
#endif
}
snprintf(reply, reply_size, "> %s", password);
memset(password, 0, sizeof(password));
#endif
return true;
}
#ifdef WITH_MQTT_BRIDGE
if (strncmp(command, "get mqtt", 8) == 0 && command[8] >= '1'
&& command[8] <= '0' + MAX_MQTT_SLOTS && command[9] == '.') {
const int slot = command[8] - '1';
const char* field = command + 10;
const char* value = nullptr;
if (strcmp(field, "password") == 0) value = _mqtt_prefs.mqtt_slot_password[slot];
if (strcmp(field, "token") == 0) value = _mqtt_prefs.mqtt_slot_token[slot];
if (value) {
snprintf(reply, reply_size, "> %s", value);
return true;
}
}
#endif
#endif
return false;
}
bool MyMesh::handleCommand(const char* command, uint32_t sender_timestamp,
char* reply) {
if (command == NULL || reply == NULL) return false;
size_t reply_capacity = 160;
while (*command == ' ' || *command == '\t') command++;
if (strlen(command) > 3 && command[2] == '|') {
// Optional two-character request prefix used by Companion CLI clients.
memcpy(reply, command, 3);
reply += 3;
reply_capacity -= 3;
*reply = 0;
command += 3;
while (*command == ' ' || *command == '\t') command++;
}
#if defined(ESP32_PLATFORM)
// Catch remote requests before the direct-transport router below. That
// router is also used by USB/BLE/browser commands without a timestamp.
if (mesh::esp32::handlePowerTestCommand(command, reply, reply_capacity,
sender_timestamp == 0)) return true;
#endif
if (mesh::power::handleRegulatorCommand(command, reply, reply_capacity)) return true;
#if defined(WIO_E5_MINI_HEADLESS_COMPANION_UI)
if (handleCompactLedCommand(command, reply, reply_capacity)) return true;
#else
if (mesh::leds::handleCommand(command, reply, reply_capacity)) return true;
#endif
#if defined(PIN_BUZZER) || defined(PIN_VIBRATION) || defined(HAS_DRV2605)
if (handleAlertCommand(command, reply, reply_capacity)) return true;
#endif
#if COMPANION_FEATURE_READER
if (sender_timestamp == 0 && mesh::morseTimingActive(_radio)
&& isCompanionReaderRequest(command)) {
mesh::cli::formatError(reply, reply_capacity, "Reader unavailable during Morse");
return true;
}
if (sender_timestamp == 0
&& mesh::handleReaderReply(command, reply, reply_capacity)) return true;
#endif
if (mesh::power::handleStoredBatteryCalibrationCommand(
board, command, reply, reply_capacity)) return true;
#if defined(NRF52_POWER_MANAGEMENT)
if (mesh::power::handleVoltagePolicyCommand(command, reply,
reply_capacity)) return true;
#endif
#if defined(MESH_NRF52_BLE_TRACE) && MESH_NRF52_BLE_TRACE
if (sender_timestamp == 0
&& meshBleTraceCommand(command, reply, reply_capacity)) return true;
#endif
#if MESH_ENABLE_FLEET_CONTROL
if (!strncmp(command, "fleet", 5)
&& (command[5] == 0 || command[5] == ' ' || command[5] == '\t')) {
// Only the attached Companion client can originate multicast management.
// A remote CLI peer must not borrow this Companion's signing identity.
if (sender_timestamp != 0) {
mesh::cli::formatError(reply, reply_capacity, "fleet send requires local Companion access");
return true;
}
const char* text = command + 5;
while (*text == ' ' || *text == '\t') ++text;
if (strncmp(text, "send", 4)
|| (text[4] != ' ' && text[4] != '\t')) {
mesh::cli::formatError(reply, reply_capacity, "use fleet send <channel> [all|key|region:name|home:name|gps:lat,lon:km[;target...]] <command>");
return true;
}
text += 4;
while (*text == ' ' || *text == '\t') ++text;
uint32_t channel_index = 0;
bool have_index = false;
while (*text >= '0' && *text <= '9') {
have_index = true;
const uint8_t digit = *text++ - '0';
if (channel_index > (0xFFFFFFFFUL - digit) / 10UL) {
mesh::cli::formatError(reply, reply_capacity, "invalid fleet channel index");
return true;
}
channel_index = channel_index * 10UL + digit;
}
if (!have_index || channel_index >= MAX_GROUP_CHANNELS
|| (*text != ' ' && *text != '\t')) {
mesh::cli::formatError(reply, reply_capacity, "invalid fleet channel index");
return true;
}
while (*text == ' ' || *text == '\t') ++text;
mesh::FleetCommand::Targets targets{}; // No target means the whole fleet.
// Only a complete permitted command can omit its target. An invalid key
// or list must never silently broaden a request to the whole fleet.
if (!mesh::FleetCommand::commandAllowed(text)) {
const size_t target_length = strcspn(text, " \t");
// A target list is one signed request; never admit a partial list.
if (text[target_length] == 0
|| !mesh::FleetCommand::parseTargets(text, target_length, targets)) {
mesh::cli::formatError(reply, reply_capacity, "targets: all, 8/12/64-hex keys, region names, home:name or gps:lat,lon:km; semicolon-separated");
return true;
}
text += target_length;
while (*text == ' ' || *text == '\t') ++text;
}
if (!mesh::FleetCommand::commandAllowed(text)) {
mesh::cli::formatError(reply, reply_capacity, "command is not permitted for fleet management");
return true;
}
ChannelDetails channel;
if (!getChannel((int)channel_index, channel) || !channel.name[0]) {
mesh::cli::formatError(reply, reply_capacity, "fleet channel is not configured");
return true;
}
bool padded_key = true;
for (size_t i = mesh::FleetCommand::KeySize;
i < sizeof(channel.channel.secret); ++i)
padded_key = padded_key && channel.channel.secret[i] == 0;
if (!padded_key || !mesh::FleetCommand::privateKeyAllowed(channel.channel.secret)) {
mesh::cli::formatError(reply, reply_capacity, "fleet requires a private 128-bit channel key");
return true;
}
const uint32_t sequence = getRTCClock()->getCurrentTime();
static uint32_t last_fleet_sequence = 0;
if (sequence < mesh::FleetCommand::MinEpoch
|| sequence > 0xFFFFFFFFUL - mesh::FleetCommand::MaxLifetime) {
mesh::cli::formatError(reply, reply_capacity, "set the Companion clock before fleet send");
return true;
}
if (sequence <= last_fleet_sequence) {
mesh::cli::formatError(reply, reply_capacity, "wait for the next clock second before fleet send");
return true;
}
uint8_t payload[mesh::FleetCommand::MaxEnvelopeLength];
const size_t length = mesh::FleetCommand::encode(
self_id, channel.channel.secret, sequence,
sequence + mesh::FleetCommand::MaxLifetime, targets, text,
payload, sizeof(payload));
if (!length) {
mesh::cli::formatError(reply, reply_capacity, "fleet targets and command exceed two packets; use fewer targets");
} else if (!sendFleetCommandData(channel.channel, payload, length)) {
mesh::cli::formatError(reply, reply_capacity, "could not queue fleet command");
} else {
last_fleet_sequence = sequence;
snprintf(reply, reply_capacity, "OK - fleet command queued; seq=%lu",
(unsigned long)sequence);
}
memset(payload, 0, sizeof(payload));
return true;
}
#endif
if (mesh::handleMorseReplyCommand(_morse_reply, _radio, command, reply,
reply_capacity, millis())) return true;
if (mesh::handleMorseCommand(_radio, command, reply, reply_capacity)) return true;
if (_radio_profiles.handle(command, reply, reply_capacity, sender_timestamp != 0)) return true;
if (!strncmp(command, "set tempradio ", 14)) command += 4;
if (!strcmp(command, "get tempradio")) command += 4;
if (!strncmp(command, "set radio ", 10)) {
char legacy[120];
uint16_t preamble = 0;
float f, bw; uint8_t sf, cr;
if (!mesh::RadioProfileCLI::parseSuffix(command + 10, 4, legacy, sizeof(legacy), preamble)
|| !mesh::cli::parseRadioTupleStrict(legacy, f, bw, sf, cr)) {
mesh::cli::formatError(reply, reply_capacity, "invalid radio params"); return true;
}
if (!_radio_profiles.acceptsPrimary(f, bw, sf, cr, preamble)) {
mesh::cli::formatError(reply, reply_capacity, "radio params unsupported"); return true;
}
const auto result = applyAndSaveRadioParams(f, bw, sf, cr, _prefs.client_repeat,
preamble, sender_timestamp != 0);
snprintf(reply, reply_capacity, "%s", result == RadioSettingResult::Saved ? "OK"
: result == RadioSettingResult::SaveFailed ? "Error: radio not saved"
: result == RadioSettingResult::SaveUnconfirmed ? "Error: radio save unconfirmed"
: "Error: radio rejected");
if (result == RadioSettingResult::Saved)
_radio_profiles.appendPrimaryChirpWarning(reply, reply_capacity, sf, bw, preamble);
return true;
}
if (sender_timestamp == 0 && handleDirectCommand(command, reply, reply_capacity)) return true;
if (handleNotificationCommand(command, reply, reply_capacity)) return true;
if (mesh::companion::handleRetryCommand(
_prefs, command, reply, reply_capacity,
[this]() { return savePrefs(); },
[this]() { cancelAllFloodRetries(); })) return true;
#if COMPANION_FEATURE_USB_MOTA_SOURCE
if (mesh::isUsbMotaOwnerTransitionCommand(command)) {
snprintf(reply, reply_capacity,
"ERR ota folder on/off requires the owning USB mOTA session");
return true;
}
#endif
if (sender_timestamp == 0 && mesh::cli::isUf2ResetCommand(command)) {
if (!board.rebootToUf2Bootloader()) {
snprintf(reply, reply_capacity, "ERR: unsupported");
}
return true;
}
if (strncmp(command, "set freq ", 9) == 0) {
float frequency;
if (!mesh::cli::parseDecimalStrict(command + 9, frequency)
|| frequency < 150.0f || frequency > 2500.0f) {
mesh::cli::formatError(reply, reply_capacity, "frequency must be 150-2500 MHz");
} else {
const auto result = applyAndSaveRadioParams(frequency, _prefs.bw, _prefs.sf, _prefs.cr,
_prefs.client_repeat, _radio_profiles.primaryPreamble(), sender_timestamp != 0);
snprintf(reply, reply_capacity, "%s", result == RadioSettingResult::Saved ? "OK"
: result == RadioSettingResult::SaveFailed ? "Error: radio not saved"
: result == RadioSettingResult::SaveUnconfirmed ? "Error: radio save unconfirmed"
: "Error: radio rejected");
}
return true;
}
if (handleLocalControlCommand(command, reply, reply_capacity)) return true;
#if defined(MESH_HIL_TX_FREQ_MHZ)
// RF test builds verify active power before permitting any test TX.
if (strcmp(command, "get tx.status") == 0) {
snprintf(reply, reply_capacity, "> desired=%d,apply=%s", (int)_prefs.tx_power_dbm,
!tx_power_apply_pending && _radio_available
&& radio_driver.isTxPowerApplied(_prefs.tx_power_dbm)
? "applied" : "pending");
return true;
}
#endif
if (strncmp(command, "set tx ", 7) == 0) {
int32_t parsed = 0;
if (!mesh::cli::parseIntegerStrict(command + 7, parsed)) {
mesh::cli::formatError(reply, reply_capacity, "invalid TX power");
} else if (parsed < -9 || parsed > MAX_LORA_TX_POWER) {
snprintf(reply, reply_capacity, "Error: TX -9..%d dBm",
MAX_LORA_TX_POWER);
} else {
const int8_t requested = static_cast<int8_t>(parsed);
const auto result = applyAndSaveTxPower(requested);
if (result == RadioSettingResult::RadioRejected) {
mesh::cli::formatError(reply, reply_capacity, "TX power rejected");
} else if (result == RadioSettingResult::SaveFailed) {
mesh::cli::formatError(reply, reply_capacity, "TX not saved");
} else {
snprintf(reply, reply_capacity, "%s",
tx_power_apply_pending ? "OK - saved; pending" : "OK");
}
}
return true;
}
if (isCompanionRadioPrefsCommand(command)) {
const float previous_af = _prefs.airtime_factor;
const float previous_rxdelay = _prefs.rx_delay_base;
const float previous_txdelay = _prefs.tx_delay_factor;
const float previous_direct_txdelay = _prefs.direct_tx_delay_factor;
const uint8_t previous_interference = _prefs.interference_threshold;
const uint8_t previous_agc = _prefs.agc_reset_interval;
const uint8_t previous_hash_mode = _prefs.path_hash_mode;
const uint8_t previous_multi_acks = _prefs.multi_acks;
if (!_prefs.getRadioPrefs()->handleCommand(
command, sender_timestamp, reply, false)) {
return false;
}
if (_prefs.getRadioPrefs()->isDirty() && strncmp(reply, "OK", 2) == 0) {
if (!savePrefs()) {
_prefs.airtime_factor = previous_af;
_prefs.rx_delay_base = previous_rxdelay;
_prefs.tx_delay_factor = previous_txdelay;
_prefs.direct_tx_delay_factor = previous_direct_txdelay;
_prefs.interference_threshold = previous_interference;
_prefs.agc_reset_interval = previous_agc;
_prefs.path_hash_mode = previous_hash_mode;
_prefs.multi_acks = previous_multi_acks;
_prefs.clearDirty();
mesh::cli::formatError(reply, reply_capacity, "setting changed but save failed");
}
}
return true;
}
#if MESH_BATTERY_CHARGE_CONTROL
if (mesh::power::handleBatteryChargeCommand(board, command, reply,
reply_capacity)) return true;
#endif
// Hook for future variant-specific commands not covered by the shared,
// runtime-aware FEM handlers above.
if (board.handleCommand(command, sender_timestamp, reply)) {
if (_prefs.isDirty() && !savePrefs()) {
mesh::cli::formatError(reply, reply_capacity, "board setting changed but save failed");
}
return true;
}
if (sender_timestamp == 0
&& (strcmp(command, "poweroff") == 0
|| strcmp(command, "shutdown") == 0)) {
strcpy(reply, "OK");
board.powerOff();
return true;
}
if (strncmp(command, "set name ", 9) == 0) {
const char* name = command + 9;
if (!AdvertDataParser::isValidName(name)) {
strcpy(reply, "Error, bad chars");
} else {
strcpy(reply, saveAdvertName(name) ? "OK" : "Error: name could not be saved");
}
return true;
}
if (strcmp(command, "get name") == 0) {
snprintf(reply, reply_capacity, "> %s", _prefs.node_name);
return true;
}
if (strcmp(command, "ver") == 0) {
snprintf(reply, reply_capacity, "%s (Build: %s)", FIRMWARE_VERSION,
FIRMWARE_BUILD_DATE);
return true;
}
#if defined(RP2040_PLATFORM) && defined(ENABLE_WIFI_INTERFACE)
if (strcmp(command, "get wifi.ssid") == 0) {
const char* ssid = _prefs.wifi_ssid[0] ? _prefs.wifi_ssid : WIFI_SSID;
snprintf(reply, reply_capacity, "> %s", *ssid ? ssid : "(not set)");
return true;
}
if (strcmp(command, "get wifi.enabled") == 0) {
snprintf(reply, reply_capacity, "> %d", _prefs.wifi_enabled);
return true;
}
if (strcmp(command, "get wifi.status") == 0 || strcmp(command, "get wifi.ip") == 0) {
const bool connected = WiFi.status() == WL_CONNECTED;
if (strcmp(command, "get wifi.status") == 0) {
snprintf(reply, reply_capacity, "> %s", connected ? "connected" : "disconnected");
} else {
snprintf(reply, reply_capacity, "> %s", connected ? WiFi.localIP().toString().c_str() : "(not connected)");
}
return true;
}
const bool set_ssid = strncmp(command, "set wifi.ssid ", 14) == 0;
const bool set_pwd = strncmp(command, "set wifi.pwd ", 13) == 0;
const bool set_enabled = strncmp(command, "set wifi.enabled ", 17) == 0;
const bool clear_wifi = strcmp(command, "set wifi.clear") == 0;
if (set_ssid || set_pwd || set_enabled || clear_wifi) {
char old_ssid[sizeof(_prefs.wifi_ssid)], old_pwd[sizeof(_prefs.wifi_pwd)];
memcpy(old_ssid, _prefs.wifi_ssid, sizeof(old_ssid));
memcpy(old_pwd, _prefs.wifi_pwd, sizeof(old_pwd));
const uint8_t old_enabled = _prefs.wifi_enabled;
const char* value = command + (set_ssid ? 14 : set_pwd ? 13 : set_enabled ? 17 : 0);
int32_t enabled = 0;
if ((set_ssid && strlen(value) >= sizeof(_prefs.wifi_ssid))
|| (set_pwd && strlen(value) >= sizeof(_prefs.wifi_pwd))
|| (set_enabled && (!mesh::cli::parseIntegerStrict(value, enabled) || enabled < 0 || enabled > 1))) {
mesh::cli::formatError(reply, reply_capacity, "invalid WiFi setting");
return true;
}
if (set_ssid) strcpy(_prefs.wifi_ssid, value);
if (set_pwd) strcpy(_prefs.wifi_pwd, value);
if (set_enabled) _prefs.wifi_enabled = enabled;
if (clear_wifi) { _prefs.wifi_ssid[0] = 0; _prefs.wifi_pwd[0] = 0; }
if (savePrefs()) strcpy(reply, "OK - reboot to apply WiFi settings");
else {
memcpy(_prefs.wifi_ssid, old_ssid, sizeof(old_ssid));
memcpy(_prefs.wifi_pwd, old_pwd, sizeof(old_pwd));
_prefs.wifi_enabled = old_enabled;
mesh::cli::formatError(reply, reply_capacity, "WiFi setting could not be saved");
}
memset(old_pwd, 0, sizeof(old_pwd));
return true;
}
#endif
if (strcmp(command, "get tz.offset") == 0) {
sprintf(reply, "> %.9g", static_cast<double>(_prefs.tz_offset));
return true;
}
if (strncmp(command, "set tz.offset ", 14) == 0) {
float tz;
if (!mesh::cli::parseDecimalStrict(command + 14, tz)
|| !mesh::companion::timezone::valid(tz)) {
strcpy(reply, "Error, must be from -12 to +14");
} else {
strcpy(reply, savePreference<float>(_prefs.tz_offset, tz)
? "OK" : "Error: setting could not be saved");
}
return true;
}
return false; // not handled
}
void MyMesh::checkCLIRescueCmd() {
Stream& output = mesh::usbTerminalPort();
Stream& input = mesh::usbCompanionPort();
size_t len = strnlen(cli_command, sizeof(cli_command));
if (len == sizeof(cli_command)) {
cli_command[0] = 0;
len = 0;
_cli_line_overflow = true;
}
unsigned budget = sizeof(cli_command);
while (budget-- && input.available()) {
char c = input.read();
output.print(c); // echo
if (c == '\r' || c == '\n') {
if (_cli_line_overflow) {
_cli_line_overflow = false;
#if defined(STM32_PLATFORM)
output.println(" Error: unknown command");
#else
output.print("ERR: too long\r\n");
#endif
} else if (len) goto complete_line;
return;
}
if (_cli_line_overflow) continue;
if (len == sizeof(cli_command)-1) {
_cli_line_overflow = true;
cli_command[0] = 0;
len = 0;
continue;
}
cli_command[len++] = c;
cli_command[len] = 0;
}
return;
complete_line:
{
reply_buf[0] = 0;
#if COMPANION_FEATURE_READER
if (mesh::morseTimingActive(_radio) && isCompanionReaderRequest(cli_command)) {
mesh::cancelReaderCommand();
output.print(" ERROR: Reader unavailable during Morse\r\n");
} else if (mesh::handleReaderCommand(cli_command, output)) {
// USB rescue streams chapters/ranges cooperatively like the terminal.
} else
#endif
if (handleCommand(cli_command, 0, reply_buf)) {
// command was handled, print reply output
output.print(" "); output.print(reply_buf); output.println();
#if defined(NRF52_PLATFORM) && defined(EXTRAFS) && !defined(QSPIFLASH)
} else if (strcmp(cli_command, "repair extrafs") == 0) {
repairInternalExtraFS(output);
} else if (strcmp(cli_command, "scan extrafs erase") == 0) {
scanInternalExtraFS(output);
#endif
} else if (strcmp(cli_command, "rebuild") == 0) {
#if MESH_ENABLE_TX_BEACONS
suspendBeacons();
#endif
bool success = _store->formatFileSystem();
if (success) {
const bool identity_saved = _store->saveMainIdentity(self_id);
const bool prefs_saved = savePrefs();
const bool contacts_saved = _store->saveContacts(this, save_filter);
const bool channels_saved = saveChannels();
const bool contacts_complete =
contacts_saved && !_store->hasPendingContactWrites();
if (contacts_complete) {
MyMesh::onContactCacheFlushed();
} else {
scheduleContactWriteRetry();
}
if (identity_saved && prefs_saved && contacts_complete
&& channels_saved) {
output.println(" > erase and rebuild done");
} else {
output.println(" Error: erase succeeded, but data rebuild failed; retry rebuild");
}
} else {
output.println(" Error: erase failed");
}
} else if (strcmp(cli_command, "erase") == 0) {
#if MESH_ENABLE_TX_BEACONS
suspendBeacons();
#endif
bool success = _store->formatFileSystem();
#if MESH_ENABLE_ETHERNET_TUNNEL
if (success && !eraseEthernetSettings()) {
output.println(" Error: file system erased, but Ethernet settings erase failed");
return;
}
#endif
#if MESH_ENABLE_MESHTEXT_CHAT
if (success && !eraseMeshtextSettings()) {
output.println(" Error: file system erased, but MT settings erase failed");
return;
}
#endif
if (success) {
output.println(" > erase done");
} else {
output.println(" Error: erase failed");
}
} else if (memcmp(cli_command, "ls", 2) == 0) {
// get path from command e.g: "ls /adafruit"
const char *path = &cli_command[3];
bool is_fs2 = false;
if (memcmp(path, "UserData/", 9) == 0) {
path += 8; // skip "UserData"
} else if (memcmp(path, "ExtraFS/", 8) == 0) {
path += 7; // skip "ExtraFS"
is_fs2 = true;
}
output.printf("Listing files in %s\n", path);
// log each file and directory
File root = _store->openDirectory(path);
if (is_fs2 == false) {
if (root) {
File file = root.openNextFile();
while (file) {
if (file.isDirectory()) {
output.printf("[dir] UserData%s/%s\n", path, file.name());
} else {
output.printf("[file] UserData%s/%s (%d bytes)\n", path,
file.name(), file.size());
}
// move to next file
file = root.openNextFile();
}
root.close();
}
}
if (is_fs2 == true || strlen(path) == 0 || strcmp(path, "/") == 0) {
if (_store->getSecondaryFS() != nullptr) {
File root2 = _store->openDirectory(_store->getSecondaryFS(), path);
File file = root2.openNextFile();
while (file) {
if (file.isDirectory()) {
output.printf("[dir] ExtraFS%s/%s\n", path, file.name());
} else {
output.printf("[file] ExtraFS%s/%s (%d bytes)\n", path,
file.name(), file.size());
}
// move to next file
file = root2.openNextFile();
}
root2.close();
}
}
} else if (memcmp(cli_command, "cat", 3) == 0) {
// get path from command e.g: "cat /contacts3"
const char *path = &cli_command[4];
bool is_fs2 = false;
if (memcmp(path, "UserData/", 9) == 0) {
path += 8; // skip "UserData"
} else if (memcmp(path, "ExtraFS/", 8) == 0) {
path += 7; // skip "ExtraFS"
is_fs2 = true;
} else {
output.println(
"Invalid path provided, must start with UserData/ or ExtraFS/");
cli_command[0] = 0;
return;
}
// A corrupt internal ExtraFS is deliberately quarantined until the
// explicit repair command runs. Never pass that null secondary pointer
// into the raw filesystem helpers while it is unavailable.
FILESYSTEM* selected_fs = is_fs2
? _store->getSecondaryFS()
: _store->getPrimaryFS();
if (selected_fs == nullptr) {
output.println("ExtraFS is unavailable; run repair extrafs first");
} else {
// log file content as hex
File file = _store->openRead(selected_fs, path);
if(file){
// Stream the dump in bounded chunks. An ExtraFS file can be much
// larger than the nRF52 task stack, so never allocate file.size()
// bytes on the stack here.
uint8_t buffer[64];
while (file.available() > 0) {
const int count = file.read(buffer, sizeof(buffer));
if (count <= 0) break;
mesh::Utils::printHex(output, buffer, count);
}
output.print("\n");
file.close();
}
}
} else if (memcmp(cli_command, "rm ", 3) == 0) {
// get path from command e.g: "rm /adv_blobs"
const char *path = &cli_command[3];
MESH_DEBUG_PRINTLN("Removing file: %s", path);
// ensure path is not empty, or root dir
if(!path || strlen(path) == 0 || strcmp(path, "/") == 0){
output.println("Invalid path provided");
} else {
bool is_fs2 = false;
if (memcmp(path, "UserData/", 9) == 0) {
path += 8; // skip "UserData"
} else if (memcmp(path, "ExtraFS/", 8) == 0) {
path += 7; // skip "ExtraFS"
is_fs2 = true;
}
// remove file
FILESYSTEM* selected_fs = is_fs2
? _store->getSecondaryFS()
: _store->getPrimaryFS();
if (selected_fs == nullptr) {
output.println("ExtraFS is unavailable; run repair extrafs first");
} else {
if (is_fs2) {
MESH_DEBUG_PRINTLN("Removing file from ExtraFS: %s", path);
} else {
MESH_DEBUG_PRINTLN("Removing file from UserData: %s", path);
}
if (_store->removeFile(selected_fs, path)) {
output.println("File removed");
} else {
output.println("Failed to remove file");
}
}
}
} else if (strcmp(cli_command, "reboot") == 0) {
if (MyMesh::prepareForUiShutdown()) {
board.reboot(); // doesn't return
} else {
output.println(" Error: pending contacts could not be saved; reboot cancelled");
}
} else {
output.println(" Error: unknown command");
}
cli_command[0] = 0; // reset command buffer
}
}
void MyMesh::checkSerialInterface() {
size_t len = _serial->checkRecvFrame(cmd_frame);
if (_iter_started && !_serial->isReplyRouteAvailable(_iter_reply_route)) {
stopContactsIterator();
}
if (!_serial->isConnected()) {
stopContactsIterator();
return;
}
if (len > 0) {
handleCmdFrame(len);
}
if (_iter_started // check if our ContactsIterator is 'running'
&& (_iter_next_frame_at == 0
|| _iter_next_frame_at == _ms->getMillis()
|| millisHasNowPassed(_iter_next_frame_at))
&& !_serial->isReplyRouteWriteBusy(_iter_reply_route)
) {
if (_iter_table_revision != getContactTableRevision()) {
// The paced stream walks the live compacting table. If an advert, app
// command, or overwrite changes its membership mid-stream, start a fresh
// snapshot marker before reading another entry. Clients already reset
// their accumulation on CONTACTS_START, so they can never accept a
// skipped, duplicated, or wrong-count list.
_iter = startContactsIterator();
_iter_start_pending = true;
_iter_contact_pending = false;
_iter_total_count = getNumContacts();
_iter_table_revision = getContactTableRevision();
_most_recent_lastmod = 0;
}
if (_iter_start_pending) {
uint8_t reply[5];
reply[0] = RESP_CODE_CONTACTS_START;
memcpy(&reply[1], &_iter_total_count, 4);
if (_serial->writeFrameToRoute(_iter_reply_route, reply, sizeof(reply))
== sizeof(reply)) {
_iter_start_pending = false;
_iter_next_frame_at = futureMillis(CONTACT_STREAM_FRAME_INTERVAL_MS);
}
return;
}
if (!_iter_contact_pending) {
ContactInfo contact;
if (_iter.hasNext(this, contact)) {
if (contact.lastmod <= _iter_filter_since) return;
_iter_pending_contact = contact;
_iter_contact_pending = true;
} else { // EOF
out_frame[0] = RESP_CODE_END_OF_CONTACTS;
memcpy(&out_frame[1], &_most_recent_lastmod,
4); // include the most recent lastmod, so app can update their 'since'
if (_serial->writeFrameToRoute(_iter_reply_route, out_frame, 5) == 5) {
stopContactsIterator();
}
return;
}
}
// Do not advance past a contact until its complete frame has been accepted
// by the selected transport. A transiently full/lost route must not turn a
// successful CONTACTS_START/END transaction into a silently short list.
if (writeContactRespFrame(RESP_CODE_CONTACT, _iter_pending_contact,
_iter_reply_route)) {
if (_iter_pending_contact.lastmod > _most_recent_lastmod) {
_most_recent_lastmod = _iter_pending_contact.lastmod;
}
_iter_contact_pending = false;
_iter_next_frame_at = futureMillis(CONTACT_STREAM_FRAME_INTERVAL_MS);
}
//} else if (!_serial->isWriteBusy()) {
// checkConnections(); // TODO - deprecate the 'Connections' stuff
}
}
void MyMesh::loop() {
// Storage callbacks can request recovery while holding ContactInfo/path
// references. Retire those references only here, between callbacks.
if (_store->contactRecoveryPending() && !mesh::morseTimingActive(_radio)) {
_store->recoverContacts(this);
updateGpsTelemetryPolicy();
if (!dirty_contacts_expiry && _store->hasPendingContactWrites()) {
dirty_contacts_expiry = mesh::nonzeroLazyPersistenceDeadline(
futureMillis(LAZY_CONTACTS_WRITE_DELAY));
}
}
#if MESH_ENABLE_MORSE_RX
if (_morse_reply.pending()) {
const ContactInfo* actor = lookupContactByPubKey(_morse_reply.actor(), PUB_KEY_SIZE);
const auto& settings = mesh::morseSettings();
_morse_reply.service(_radio, millis(), actor && actor->isRemoteCLIAllowed(),
mesh::morseSettingsUsable(), settings.frequency_khz, settings.wpm);
}
#endif
#if COMPANION_FEATURE_NOTIFICATIONS
_notifications.loop(millis(), _serial->isConnected());
#if defined(PIN_USER_BTN) && defined(USER_BTN_PRESSED)
const bool notification_button_down = digitalRead(PIN_USER_BTN) == USER_BTN_PRESSED;
if (notification_button_down && !_notification_button_down) _notifications.button();
_notification_button_down = notification_button_down;
#endif
#endif
if (!mesh::morseTimingActive(_radio)) _radio_profiles.loop();
#if MESH_ENABLE_TX_BEACONS
if (!mesh::morseTimingActive(_radio)) loopBeacons();
#endif
#if MESH_ENABLE_MESHTEXT_CHAT
refreshMeshtextProfile();
if (!mesh::morseTimingActive(_radio)) loopMeshtextDelivery();
#endif
#if MESH_ENABLE_ETHERNET_TUNNEL
if (!mesh::morseTimingActive(_radio)) loopEthernetTunnel();
#endif
#if defined(WITH_MQTT_BRIDGE) && defined(ESP32_PLATFORM) && defined(WIFI_SSID)
if (_mqtt_bridge) _mqtt_bridge->servicePendingClockCorrection();
#endif
if (!mesh::morseTimingActive(_radio) && _scheduled_reboot_at != 0
&& (_scheduled_reboot_at == _ms->getMillis()
|| millisHasNowPassed(_scheduled_reboot_at))) {
_scheduled_reboot_at = 0;
if (MyMesh::prepareForUiShutdown()) {
board.reboot();
} else {
MESH_DEBUG_PRINTLN("Scheduled reboot cancelled: pending contacts could not be saved");
}
return;
}
#if COMPANION_FEATURE_TEMP_RADIO
if (!mesh::morseTimingActive(_radio)) serviceTempRadio();
#endif
#if defined(OTA_SHARED_COMPANION_QUEUE)
mesh::ota::ota_release_context_if_idle(isAnyTempRadioActive() || _temp_radio_set_at != 0);
#elif defined(ENABLE_OTA) && defined(OTA_HEAP_CONTEXT)
mesh::ota::ota_service_temp_radio_context(isAnyTempRadioActive()
#if COMPANION_FEATURE_TEMP_RADIO
|| _temp_radio_set_at != 0
#endif
);
#endif
BaseChatMesh::loop();
#if defined(MESHCORE_HAS_COLOR_DISPLAY)
if (!mesh::morseTimingActive(_radio)) {
mesh::ui::refreshDisplayNightLocation(sensors, getRTCClock()->getCurrentTime(), millis());
}
#endif
#ifdef COMPANION_MESH_CLOCK_SYNC
if (!mesh::morseTimingActive(_radio)) _clock_sync.loop();
#endif
#if COMPANION_FEATURE_TEXT_TERMINAL
serviceTerminalLogin();
serviceTerminalCommand();
serviceTerminalTrace();
serviceMorseTerminal();
#endif
servicePendingSerialReply();
#if COMPANION_FEATURE_READER
// Every verse chunk reinflates its 4 KiB flash block. Retain the cursor but
// defer that optional decode/output work until envelope timing is finished.
if (!mesh::morseTimingActive(_radio)) mesh::serviceReaderCommand();
#endif
serviceBinaryTraceReply();
if (!mesh::morseTimingActive(_radio)) serviceSigningSession();
if (has_next_ack_expiry
&& (next_ack_expiry == _ms->getMillis() || millisHasNowPassed(next_ack_expiry))) {
expireExpectedAcks();
}
if (emergency_client_repeat_packet != NULL && millisHasNowPassed(emergency_client_repeat_send_at)) {
mesh::Packet* pkt = emergency_client_repeat_packet;
emergency_client_repeat_packet = NULL;
sendPacket(pkt, 1, 0);
}
if (_cli_rescue) {
checkCLIRescueCmd();
} else {
checkSerialInterface();
#if defined(ENABLE_WIFI_INTERFACE) && defined(RP2040_PLATFORM) && !defined(ENABLE_USB_INTERFACE)
// RP2040 WiFi builds are headless and have no way into the rescue CLI (that needs a
// display + long-press), so serve config commands on the otherwise unused USB serial
checkCLIRescueCmd();
#endif
}
servicePendingLocalRadioSettings();
#if MESH_ENABLE_ONE_KEY_DM
if (held_dm_count != 0 || verified_pending_count != 0) releaseHeldOneKeyDMs();
#endif
if (!mesh::morseTimingActive(_radio)) servicePendingChannelNotices();
#if defined(ESP32_PLATFORM)
servicePersistence();
#else
// is there are pending dirty contacts write needed?
if (!mesh::morseTimingActive(_radio) && isContactWriteDue()) {
const bool success = _store->serviceContactWrites(this, save_filter);
if (!success) {
scheduleContactWriteRetry();
} else if (_store->hasPendingContactWrites()) {
dirty_contacts_failures = 0;
dirty_contacts_expiry = mesh::nonzeroLazyPersistenceDeadline(
futureMillis(CONTACT_PAGE_WRITE_GAP));
} else {
MyMesh::onContactCacheFlushed();
}
}
#endif
#ifdef DISPLAY_CLASS
if (_ui) {
_ui->setHasConnection(_serial->isConnected());
#if COMPANION_FEATURE_NOTIFICATIONS
const auto& notifications = _notifications.settings();
_ui->setNotificationOutputs(notifications.enabled ? notifications.outputs : 31);
_ui->setNotificationLedOwnership(_notifications.ownsLed());
if (notifications.enabled && !(notifications.outputs & mesh::notify::Led)) pulse(mesh::notify::Led, false, -1);
#endif
}
#endif
}
#if defined(ESP32_PLATFORM)
void MyMesh::servicePersistence() {
if (mesh::morseTimingActive(_radio)) return;
// Advert crypto runs before serial service. Defer its cache I/O until here,
// and alternate with due contact work so neither job can starve the other.
// Only one persistence job is serviced in a loop pass.
if (_store->consumeSynchronousAdvertIO()) return;
const bool contact_write_due = isContactWriteDue();
const bool advert_write_due = _store->isAdvertWriteDue(_ms->getMillis());
const bool service_advert = advert_write_due
&& (!contact_write_due || _advert_write_next);
if (service_advert) {
_store->serviceAdvertWrites(_ms->getMillis());
_advert_write_next = false;
return;
}
_advert_write_next = true;
if (isContactWriteDue()) {
const bool success = _store->serviceContactWrites(this, save_filter);
if (!success) {
scheduleContactWriteRetry();
} else if (_store->hasPendingContactWrites()) {
dirty_contacts_failures = 0;
dirty_contacts_expiry = mesh::nonzeroLazyPersistenceDeadline(
futureMillis(CONTACT_PAGE_WRITE_GAP));
} else {
MyMesh::onContactCacheFlushed();
}
}
}
#endif
bool MyMesh::advert() {
mesh::Packet* pkt;
if (_prefs.advert_loc_policy == ADVERT_LOC_NONE) {
pkt = createSelfAdvert(_prefs.node_name);
} else {
pkt = createSelfAdvert(_prefs.node_name, sensors.node_lat, sensors.node_lon);
}
if (pkt) {
sendZeroHop(pkt);
return true;
} else {
return false;
}
}
// To check if there is pending work
bool MyMesh::canRecoverUsbLogging() const {
// Do not discard dirty pages or interrupt a protocol/flash/radio operation.
// Normal lazy writes resolve this veto without adding writes to each poll.
return dirty_contacts_expiry == 0 && !_store->hasPendingContactWrites()
#if defined(ESP32_PLATFORM)
&& !_store->hasPendingAdvertWrites()
#endif
&& !hasOutbound() && !isAnyTempRadioActive() && !hasPendingOtaApply()
&& !saved_radio_apply_pending && !tx_power_apply_pending
&& rf_rx_disable_pending < 0 && !_radio_profiles.hasPendingTuning()
&& _scheduled_reboot_at == 0 && !hasPendingReqs()
&& !_delayed_replies.hasBinaryTrace() && sign_data == NULL
#if COMPANION_FEATURE_TEXT_TERMINAL
&& !_terminal_trace_pending
#endif
&& !(_serial && _serial->hasPendingIO())
&& !(_iter_started && _serial
&& _serial->isReplyRouteAvailable(_iter_reply_route));
}
bool MyMesh::hasPendingWork() const {
return pendingWorkReason() != 0;
}
mesh::powerdiag::Mask MyMesh::pendingWorkReason() const {
using mesh::powerdiag::Reason;
using mesh::powerdiag::mask;
#if COMPANION_FEATURE_READER
if (mesh::readerCommandActive()) return mask(Reason::Mesh);
#endif
#if COMPANION_FEATURE_NOTIFICATIONS
if (_notifications.active()) return mask(Reason::Mesh);
#endif
if (!canIdleDualRadio()) return mask(Reason::DualRadio);
if (_radio_available && radio_driver.isWatchdogObserving()) return mask(Reason::RadioWatchdog);
uint32_t noise_floor_delay_ms;
if (getNextNoiseFloorWakeDelay(noise_floor_delay_ms) && noise_floor_delay_ms == 0)
return mask(Reason::Calibration);
#if defined(NRF52_PLATFORM)
// nRF52 sleep is event-driven, so a future lazy-write deadline has no
// guaranteed interrupt to wake the loop when it becomes due. Keep polling
// through the initial write. After an actual storage failure, allow sleep
// during the bounded backoff; the next BLE/radio/GPIO event services an
// overdue retry without losing the dirty-page state.
const bool contact_write_needs_polling = dirty_contacts_expiry != 0
&& (dirty_contacts_failures == 0
|| isContactWriteDue());
#else
const bool contact_write_needs_polling = isContactWriteDue();
#endif
if (_serial != NULL && _serial->hasPendingIO()) return mask(Reason::SerialIO);
if (_iter_started && _serial != NULL
&& _serial->isReplyRouteAvailable(_iter_reply_route)) return mask(Reason::SerialIO);
if (hasPendingOtaApply()) return mask(Reason::Ota);
if (_radio_profiles.hasPendingTuning() || (rf_rx_disable_pending >= 0 && _radio_available)
|| (tx_power_apply_pending && _radio_available)
|| _scheduled_reboot_at != 0) return mask(Reason::Scheduled);
if (hasPendingReqs() || _delayed_replies.hasBinaryTrace()) return mask(Reason::Mesh);
#if COMPANION_FEATURE_TEXT_TERMINAL
if (_terminal_trace_pending) return mask(Reason::Mesh);
#endif
if (sign_data != NULL) return mask(Reason::Mesh);
if (hasQueuedWorkDue()) return mask(Reason::Queue);
if (hasRetryWorkDue()) return mask(Reason::Retry);
if (saved_radio_apply_pending && _radio_available
#if COMPANION_FEATURE_TEMP_RADIO
// Match loop()'s restore gate: a lease-blocked rollback is not
// runnable work. The lease's own timers below wake it when due.
&& !_temp_radio_applied && _temp_radio_set_at == 0
&& _temp_radio_revert_at == 0
#endif
) return mask(Reason::Scheduled);
if (contact_write_needs_polling || _store->contactRecoveryPending()) return mask(Reason::Mesh);
#if defined(ESP32_PLATFORM)
if (_store->hasPendingAdvertWrites()) return mask(Reason::Mesh);
#endif
if (emergency_client_repeat_packet != NULL
&& millisHasNowPassed(emergency_client_repeat_send_at)) return mask(Reason::Queue);
#if COMPANION_FEATURE_TEMP_RADIO
if ((_temp_radio_set_at != 0 && millisHasNowPassed(_temp_radio_set_at))
|| (_temp_radio_revert_at != 0 && millisHasNowPassed(_temp_radio_revert_at))
|| (_temp_radio_retry_at != 0 && millisHasNowPassed(_temp_radio_retry_at))) return mask(Reason::Scheduled);
#endif
return 0;
}