#include #include #include #include #if defined(RAK_4631) #include #endif #include #include "NotificationSettingsFile.h" #include "MyMesh.h" #include #include #include #include "CompanionBluetooth.h" #include "CompanionWireless.h" #include "CompanionRetry.h" #if defined(MESH_SOAK_DIAGNOSTICS) #include "../../tools/hil/S3SoakDiagnostics.h" #endif #include // needed for PlatformIO #include #ifdef ENABLE_WIFI_INTERFACE #include #endif #include #include #include #include #include #include #include #include #include #include #include #if MESH_PACKET_LOGGING #include #endif #if defined(NRF52_POWER_MANAGEMENT) #include #endif #if defined(NRF52_PLATFORM) #include #include #endif #if defined(ENABLE_OTA) #include #endif #if defined(ENABLE_OTA) #include #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 #endif #if defined(ESP32_PLATFORM) \ && (defined(BOARD_HAS_PSRAM) || COMPANION_FEATURE_MEMORY_DIAGNOSTICS) #include #endif #if defined(ESP32) && defined(WIFI_SSID) #include "CompanionWiFi.h" #include #endif #if defined(ESP32_PLATFORM) #include #endif #if defined(MESH_PRIMARY_ESPNOW) && MESH_PRIMARY_ESPNOW #include #endif #include #include #if COMPANION_FEATURE_TEXT_TERMINAL #include #endif #if COMPANION_FEATURE_OTA_CLI #include #include #include #endif #if defined(WITH_MQTT_BRIDGE) && defined(ESP32_PLATFORM) && defined(WIFI_SSID) #include #endif #if defined(ESP32) && defined(WIFI_SSID) #include #endif #ifdef WITH_WEBCONFIG #include #include #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; } static const uint32_t COMMAND_RADIO_APPLY_TIMEOUT_MS = 5000UL; #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_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 0 #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; } 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 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(owner); return mesh->offline_queue.acquire(mesh->offline_queue_len, mesh->offline_queue_head); } uint16_t MyMesh::drainOfflineQueueForOta(void* owner) { MyMesh* mesh = static_cast(owner); uint8_t discarded[MAX_FRAME_SIZE]; uint16_t removed = 0; // Explicit OTA work borrows the upper half of this volatile queue. Keep the // newest 128 frames and notify the UI for each oldest frame consumed. while (mesh->offline_queue_len > 128) { if (mesh->getFromOfflineQueue(discarded) <= 0) break; ++removed; } return removed; } void MyMesh::releaseOfflineQueueFromOta(void* owner) { MyMesh* mesh = static_cast(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(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->getEstAirtimeFor(packet->getPathByteLen() + packet->payload_len + 2) * _prefs.tx_delay_factor); return getRNG()->nextInt(0, 5*t + 1); } uint32_t MyMesh::getDirectRetransmitDelay(const mesh::Packet *packet) { uint32_t t = (_radio->getEstAirtimeFor(packet->getPathByteLen() + packet->payload_len + 2) * _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; qsort(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(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(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(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(×tamp), sizeof(timestamp), reinterpret_cast(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(&data[5]); if (strlen(text) > MAX_TEXT_LEN) continue; uint32_t timestamp; memcpy(×tamp, 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 (memcmp(held_dms[j].sender_key, pub_key, PUB_KEY_SIZE) == 0 && memcmp(held_dms[j].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, ×tamp, 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); memcpy(entry.id, id, ONE_KEY_DM_ID_SIZE); entry.packet = *packet; #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(×tamp, 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(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; 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(&data[5]); uint32_t timestamp; memcpy(×tamp, 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)); } } } 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; return BaseChatMesh::setChannel(channel_idx, channel); } 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); } #ifdef COMPANION_MESH_CLOCK_SYNC void MyMesh::onAdvertRecv(mesh::Packet* packet, const mesh::Identity& id, uint32_t timestamp, const uint8_t* app_data, size_t app_data_len) { BaseChatMesh::onAdvertRecv(packet, id, timestamp, app_data, app_data_len); _clock_sync.observeVerifiedAdvert(packet, id, timestamp); } 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); _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); _morse_reply.complete(packet); } void MyMesh::onSendFail(mesh::Packet* packet) { mesh::Mesh::onSendFail(packet); _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) && 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 (memcmp(held_dms[i].sender_key, from.id.pub_key, PUB_KEY_SIZE) == 0 && memcmp(held_dms[i].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()) { 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 (!_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 (!_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(); } } } } } 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) { 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], ×tamp, 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 = _notifications.message(nullptr, secret, _serial && _serial->isConnected(), millis()); if (_ui) _ui->setMessageNotificationOverride(notification_managed, _notifications.overridesScreen()); #else const bool notification_managed = false; #endif if (_serial && _serial->isConnected()) { uint8_t frame[1]; frame[0] = PUSH_CODE_MSG_WAITING; _serial->writeFrame(frame, 1); } else { #ifdef DISPLAY_CLASS if (_ui && !notification_managed) _ui->notify(UIEventType::channelMessage); #endif } #if COMPANION_FEATURE_TEXT_TERMINAL if (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++] = 0; // reserved1 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"); } } 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++] = 0xFF; // reserved (possibly path_len in future) 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), _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; command_radio_apply_pending = false; command_radio_freq = 0.0f; command_radio_bw = 0.0f; command_radio_sf = 0; command_radio_cr = 0; command_radio_repeat = 0; command_radio_apply_deadline = 0; command_radio_reply_route = NULL; _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.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_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 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); _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); 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_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 (!_radio_available) { saved_radio_apply_pending = false; 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 = !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; } void MyMesh::finishRadioParamApply(float freq, float bw, uint8_t sf, uint8_t cr, uint8_t repeat, BaseSerialInterface* route) { const float previous_freq = _prefs.freq; const float previous_bw = _prefs.bw; const uint8_t previous_sf = _prefs.sf; const uint8_t previous_cr = _prefs.cr; const uint8_t previous_repeat = _prefs.client_repeat; const uint32_t previous_rx_us = _prefs.rx_ps_rx_us; const uint32_t previous_sleep_us = _prefs.rx_ps_sleep_us; const bool previous_recovery_pending = saved_radio_apply_pending; _prefs.sf = sf; _prefs.cr = cr; _prefs.freq = freq; _prefs.bw = bw; _prefs.client_repeat = repeat; recalcRxPowerSavingFromLevel(_prefs.rx_ps_level, _prefs.sf, _prefs.bw, _prefs.rx_ps_preamble, &_prefs.rx_ps_rx_us, &_prefs.rx_ps_sleep_us); if (!savePrefs()) { _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_us; _prefs.rx_ps_sleep_us = previous_sleep_us; // Hardware accepted the candidate before this save. Restore the durable // tuple now, or let the existing backed-off recovery finish once RX is // idle. A failed save must never acknowledge a persistent radio change. const bool restored = applySavedRadioParams(); saved_radio_apply_pending = !restored || previous_recovery_pending; radio_apply_retry_at = 0; radio_apply_failures = 0; writeErrFrame(ERR_CODE_FILE_IO_ERROR, route); return; } // Preserve an outstanding power rollback; the full saved-radio recovery // checks both the tuple and TX power before clearing this latch. saved_radio_apply_pending = previous_recovery_pending; radio_apply_retry_at = 0; radio_apply_failures = 0; MESH_DEBUG_PRINTLN("OK: CMD_SET_RADIO_PARAMS: f=%d, bw=%d, sf=%d, cr=%d", (uint32_t)(freq * 1000.0f), (uint32_t)(bw * 1000.0f), (uint32_t)sf, (uint32_t)cr); writeOKFrame(route); } void MyMesh::cancelPendingRadioParamApply() { if (!command_radio_apply_pending) return; command_radio_apply_pending = false; command_radio_apply_deadline = 0; command_radio_reply_route = NULL; // The requested tuple was not committed. Reassert the persisted tuple in // case a failed hardware apply only restored part of the old configuration. saved_radio_apply_pending = true; radio_apply_retry_at = 0; radio_apply_failures = 0; } void MyMesh::servicePendingRadioParamApply() { if (!command_radio_apply_pending) return; if (!_serial || !_serial->isReplyRouteAvailable(command_radio_reply_route)) { cancelPendingRadioParamApply(); return; } // Leave enough queue headroom for the eventual command response. if (_serial->isReplyRouteWriteBusy(command_radio_reply_route)) return; mesh::RadioParamApplyResult result = tryApplyRadioParams( command_radio_freq, command_radio_bw, command_radio_sf, command_radio_cr); if (result == mesh::RadioParamApplyResult::APPLIED) { float freq = command_radio_freq; float bw = command_radio_bw; uint8_t sf = command_radio_sf; uint8_t cr = command_radio_cr; uint8_t repeat = command_radio_repeat; BaseSerialInterface* route = command_radio_reply_route; command_radio_apply_pending = false; command_radio_apply_deadline = 0; command_radio_reply_route = NULL; finishRadioParamApply(freq, bw, sf, cr, repeat, route); } else if (result == mesh::RadioParamApplyResult::FAILED) { writeErrFrame(ERR_CODE_ILLEGAL_ARG, command_radio_reply_route); cancelPendingRadioParamApply(); } else if (command_radio_apply_deadline == _ms->getMillis() || millisHasNowPassed(command_radio_apply_deadline)) { writeErrFrame(ERR_CODE_BAD_STATE, command_radio_reply_route); cancelPendingRadioParamApply(); } } #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 (command_radio_apply_pending) { snprintf(reply, reply_size, "ERR companion radio change is already 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) { _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); } snprintf(reply, reply_size, "radio.cad %s, scan=%s ms, retry=%s ms, max=%s ms", _prefs.cad_enabled ? "on" : "off", scan, retry, maximum); 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); snprintf(reply, reply_size, "Error: 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)) { snprintf(reply, reply_size, "Error: use set radio.cad timings "); 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)) { snprintf(reply, reply_size, "Error: 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); snprintf(reply, reply_size, "Error: CAD timings changed but save failed"); } else { snprintf(reply, reply_size, "OK - radio.cad timings saved"); } return true; } snprintf(reply, reply_size, "Error: use set radio.cad or set radio.cad timings "); return true; } 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 [auto|radio|radio2|both|off]", kind, parsed.channel ? "index" : "key:hex"); return true; } if (parsed.set && !canMutateContacts()) { snprintf(reply, reply_size, "Error: contact/channel storage unavailable"); 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)) { snprintf(reply, reply_size, "Error: 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 ", 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 && policy != parsed.policy) { bool saved; if (parsed.channel) { const auto alias = parseCompanionChannelAlias(channel.name); if (isAliasRadio2Defined() && (alias.kind == CompanionChannelKind::Radio2 || alias.kind == CompanionChannelKind::Meshtext)) { snprintf(reply, reply_size, "Error: channel prefix controls routing; rename it to change routing"); 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) { snprintf(reply, reply_size, "Error: TX routing was 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(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'))) { snprintf(reply, reply_size, "Error: 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 (handleTxRoutingCommand(command, reply, reply_size)) return true; #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 (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) { snprintf(reply, reply_size, "Error: 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)) { snprintf(reply, reply_size, "Error: GPS sync interval must be 1..336 hours"); } else if (sensors.getLocationProvider() == nullptr) { snprintf(reply, reply_size, "Error: GPS unavailable"); } else if (!savePreference(_prefs.gps_sync_interval_hours, hours)) { snprintf(reply, reply_size, "Error: 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) { snprintf(reply, reply_size, "Error: use set gps on|off"); return true; } const bool enabled = strcmp(gps_value, "on") == 0; if (sensors.getSettingByKey("gps") == nullptr) { snprintf(reply, reply_size, "Error: GPS unavailable"); } else { if (setGpsEnabled(enabled)) { snprintf(reply, reply_size, "OK - GPS %s (saved)", enabled ? "on" : "off"); } else { snprintf(reply, reply_size, "Error: 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; #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) { snprintf(reply, reply_size, "Error: use set usb.debug "); } else { const uint8_t enabled = strcmp(value, "on") == 0 ? 1 : 0; if (!savePreference(_prefs.usb_debug_enabled, enabled)) { snprintf(reply, reply_size, "Error: 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)) { snprintf(reply, reply_size, "Error: use set usb.logging [reboot]"); } else { _prefs.usb_logging_enabled = enabled ? (packet_stream ? 2 : 1) : 0; applyUsbLoggingState(enabled); if (!savePrefs()) { snprintf(reply, reply_size, "Error: USB logging changed for this boot but save failed"); } else if (!mesh::saveUsbLoggingBootPreference(enabled)) { snprintf(reply, reply_size, "Error: 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); } 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 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()) { snprintf(reply, reply_size, "Error: 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()) { snprintf(reply, reply_size, "Error: 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) { snprintf(reply, reply_size, "Error: use set radio.rxgain on|off"); } else if (!radio_driver.supportsRxBoostedGainMode()) { snprintf(reply, reply_size, "Error: unsupported"); } else if (!applyAndSaveRxBoostedGain(strcmp(value, "on") == 0)) { snprintf(reply, reply_size, "Error: radio busy or save failed"); } else { snprintf(reply, reply_size, "OK - radio.rxgain %s", value); } return true; } if (strcmp(command, "get radio.fem.rxgain") == 0) { if (!board.canControlLoRaFemLna()) { snprintf(reply, reply_size, "Error: 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) { snprintf(reply, reply_size, "Error: use set radio.fem.rxgain on|off"); } else if (_radio && (_radio->isMorseTxActive() || _radio->isMorseRxActive())) { snprintf(reply, reply_size, "Error: stop Morse before changing LoRa FEM gain"); } else if (!board.canControlLoRaFemLna()) { snprintf(reply, reply_size, "Error: unsupported"); } else if (!applyAndSaveFemRxGain(strcmp(value, "on") == 0)) { snprintf(reply, reply_size, "Error: 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 { snprintf(reply, reply_size, "Error: 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) { snprintf(reply, reply_size, "Error: use set radio.fem.txgain on|off"); } else if (_radio && (_radio->isMorseTxActive() || _radio->isMorseRxActive())) { snprintf(reply, reply_size, "Error: 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)) { snprintf(reply, reply_size, "Error: failed to apply or save FEM TX gain"); } else { snprintf(reply, reply_size, "OK - radio.fem.txgain %s", value); } return true; } 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()) { snprintf(reply, reply_size, "Error: 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) { snprintf(reply, reply_size, "Error: use set display.touch on|off"); } else if (_ui == NULL || !_ui->supportsTouchDebug()) { snprintf(reply, reply_size, "Error: touchscreen diagnostics unsupported"); } else if (!_ui->setTouchDebugEnabled(strcmp(value, "on") == 0)) { snprintf(reply, reply_size, "Error: touchscreen diagnostics unavailable"); } else { snprintf(reply, reply_size, "OK - display.touch %s (off after reboot)", value); } return true; } if (strcmp(command, "get display.rotation") == 0) { if (_ui == NULL || !_ui->supportsDisplayRotation()) { snprintf(reply, reply_size, "Error: 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) { snprintf(reply, reply_size, "Error: use set display.rotation <0|90|180|270>"); } else if (_ui == NULL || !_ui->supportsDisplayRotation()) { snprintf(reply, reply_size, "Error: display rotation is unsupported"); } else { const uint16_t previous = _prefs.display_rotation_degrees; if (!_ui->setDisplayRotationDegrees((uint16_t)degrees)) { snprintf(reply, reply_size, "Error: display rotation failed"); } else { _prefs.display_rotation_degrees = (uint16_t)degrees; if (!savePrefs()) { _prefs.display_rotation_degrees = previous; _ui->setDisplayRotationDegrees(previous); snprintf(reply, reply_size, "Error: 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) { snprintf(reply, reply_size, "Error: use set bluetooth.name "); } 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) { snprintf(reply, reply_size, "Error: use set bluetooth.mac "); } 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) { snprintf(reply, reply_size, "Error: pin must be 0-999999"); } else { const uint32_t previous = _prefs.ble_pin; _prefs.ble_pin = static_cast(parsed); if (!savePrefs()) { _prefs.ble_pin = previous; snprintf(reply, reply_size, "Error: 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 (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) { snprintf(reply, reply_size, "Error: WiFi enabled must be 0 or 1"); } else if (!setCompanionWiFiEnabled(enabled != 0)) { snprintf(reply, reply_size, "Error: 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 { snprintf(reply, reply_size, "Error: use set companion.transport "); return true; } if (!selectCompanionTransportMode(selected)) { snprintf(reply, reply_size, "Error: 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) { snprintf(reply, reply_size, "Error: use set mqtt.enabled on|off"); } else if (!CompanionMqttSetupPortal::saveEnabled(strcmp(mqtt_value, "on") == 0)) { snprintf(reply, reply_size, "Error: 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)) { snprintf(reply, reply_size, "Error: use set logging.output off|usb|wifi|both"); } else if (!CompanionMqttSetupPortal::saveEnabled(wifi)) { snprintf(reply, reply_size, "Error: 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)) { snprintf(reply, reply_size, "Error: 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)) { snprintf(reply, reply_size, "Error: 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(); if (has_ssid && _webconfig->isRunning()) { _webconfig->requestStop(); } } 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)) { snprintf(reply, reply_size, "Error: failed to save webui setting"); } else if (!enabled) { if (_webconfig && _webconfig->isRunning()) _webconfig->requestStop(); snprintf(reply, reply_size, "OK - webui off"); } else if (_webconfig && (_webconfig->isRunning() || _webconfig->isStopping())) { 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')) { snprintf(reply, reply_size, "Error: 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()) { 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)) { snprintf(reply, reply_size, "Error: 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; saved_radio_apply_pending = 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(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(); return; } #ifdef WITH_WEBCONFIG if (_wc_batch_active) return; #endif if (!_mqtt_started) { 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); } } // 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 && !_mqtt_started && _mqtt_bridge #ifdef WITH_WEBCONFIG && !isWebConfigSetupActive() #endif ) { _mqtt_started = true; // begin is one-shot; avoid retrying partial allocations _mqtt_bridge->begin(); if (_mqtt_bridge->isRunning()) { mesh::usbLoggingPort().println("MQTT companion: bridge started"); } else { mesh::usbLoggingPort().println( "MQTT companion: bridge could not start"); } } } void MyMesh::stopMQTT() { if (_mqtt_started && _mqtt_bridge) _mqtt_bridge->end(); _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(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(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)); 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 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 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() || _webconfig->isStopping()) { if (force_ap && !_webconfig->isStopping() && _webconfig->mode() == WebConfigServer::MODE_LAN) { #ifdef WITH_MQTT_BRIDGE if (_mqtt_started && _mqtt_bridge) { _mqtt_bridge->end(); _mqtt_started = false; } #endif return _webconfig->startSetupMode(reply); } strcpy(reply, _webconfig->isStopping() ? "Err: WebUI still stopping" : "Err: WebUI already running"); return false; } if (force_ap) { #ifdef WITH_MQTT_BRIDGE if (_mqtt_started && _mqtt_bridge) { _mqtt_bridge->end(); _mqtt_started = false; } #endif return _webconfig->startSetupMode(reply); } return _webconfig->startAutoMode(reply); } void MyMesh::stopWebConfig() { if (_webconfig && _webconfig->isRunning()) _webconfig->requestStop(); } void MyMesh::serviceWebConfig() { if (_webconfig) _webconfig->tick(millis()); } bool MyMesh::isWebConfigActiveOrStopping() const { return _webconfig && (_webconfig->isRunning() || _webconfig->isStopping()); } 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 (flushContactsBeforeReboot()) { 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_started && _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] == '|')) { strcpy(reply, "Error: enter a plain CLI command"); return; } if (handleCommand(cmd, 0, reply)) return; if (strcmp(cmd, "get radio.rxps") == 0 || strcmp(cmd, "get radio.rxps.config") == 0) { if (!radio_driver.supportsRxPowerSaving()) { strcpy(reply, "Error: RX power saving is unsupported on this radio"); } else { snprintf(reply, 160, "> %s,level=%u,preamble=%u,rx=%lu,sleep=%lu", _prefs.rx_powersaving_enabled ? "on" : "off", (unsigned)_prefs.rx_ps_level, (unsigned)_prefs.rx_ps_preamble, (unsigned long)_prefs.rx_ps_rx_us, (unsigned long)_prefs.rx_ps_sleep_us); } 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; #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.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, "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) { strcpy(reply, "Error: unsupported command"); return; } const char* start = cmd + 4; const char* split = strchr(start, ' '); if (!split) { strcpy(reply, "Error: missing value"); return; } char key[24]; const size_t key_len = split - start; if (key_len == 0 || key_len >= sizeof(key)) { strcpy(reply, "Error: 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)) { strcpy(reply, "Error: 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) || !isfinite(freq) || !isfinite(bw) || freq < 150.0f || freq > 2500.0f || bw < 7.0f || bw > 500.0f || sf < 5 || sf > 12 || cr < 5 || cr > 8) { strcpy(reply, "Error: radio must be freq,bw,sf,cr"); } else { const float previous_freq = _prefs.freq, previous_bw = _prefs.bw; const uint8_t previous_sf = _prefs.sf, previous_cr = _prefs.cr; const uint32_t previous_rx_us = _prefs.rx_ps_rx_us; const uint32_t previous_sleep_us = _prefs.rx_ps_sleep_us; _prefs.freq = freq; _prefs.bw = bw; _prefs.sf = sf; _prefs.cr = cr; recalcRxPowerSavingFromLevel(_prefs.rx_ps_level, _prefs.sf, _prefs.bw, _prefs.rx_ps_preamble, &_prefs.rx_ps_rx_us, &_prefs.rx_ps_sleep_us); if (savePrefs()) { strcpy(reply, "OK - reboot required"); } else { _prefs.freq = previous_freq; _prefs.bw = previous_bw; _prefs.sf = previous_sf; _prefs.cr = previous_cr; _prefs.rx_ps_rx_us = previous_rx_us; _prefs.rx_ps_sleep_us = previous_sleep_us; strcpy(reply, "Error: radio could not be saved"); } } return; } if (strcmp(key, "tx") == 0) { long parsed; if (!wcParseLong(value, -9, MAX_LORA_TX_POWER, parsed)) { snprintf(reply, 160, "Error: TX power must be -9 to %d", MAX_LORA_TX_POWER); } else { const auto result = applyAndSaveTxPower(static_cast(parsed)); strcpy(reply, result == RadioSettingResult::Saved ? "OK" : result == RadioSettingResult::RadioRejected ? "Error: radio busy or TX power rejected" : "Error: TX power could not be 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(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)) { strcpy(reply, "Error: must be on or off"); } else { if (strcmp(key, "radio.rxgain") == 0) { if (!radio_driver.supportsRxBoostedGainMode()) { strcpy(reply, "Error: RX boosted gain unsupported"); return; } if (!applyAndSaveRxBoostedGain(enabled)) { strcpy(reply, "Error: radio busy; retry"); return; } } else { if (!savePreference(_prefs.client_repeat, static_cast(enabled))) { strcpy(reply, "Error: 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)) { strcpy(reply, "Error: must be on or off"); } else if (_radio && (_radio->isMorseTxActive() || _radio->isMorseRxActive())) { strcpy(reply, "Error: stop Morse before changing LoRa FEM gain"); } else if (!board.canControlLoRaFemLna()) { strcpy(reply, "Error: unsupported"); } else if (!applyAndSaveFemRxGain(enabled)) { strcpy(reply, "Error: failed to apply FEM RX gain"); } else { strcpy(reply, "OK"); } return; } if (strcmp(key, "radio.fem.txgain") == 0) { bool enabled; if (!wcParseBool(value, enabled)) { strcpy(reply, "Error: must be on or off"); } else if (_radio && (_radio->isMorseTxActive() || _radio->isMorseRxActive())) { strcpy(reply, "Error: stop Morse before changing LoRa FEM gain"); } else if (!board.canControlLoRaFemPaGain()) { strcpy(reply, "Error: unsupported"); } else if (!applyAndSaveFemTxGain(enabled)) { strcpy(reply, "Error: 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(*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, "snmp") == 0) { bool enabled; if (!wcParseBool(value, enabled)) { strcpy(reply, "Error: 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 _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 { strcpy(reply, "Error: 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)) { strcpy(reply, "Error: interval must be 1-60 minutes"); return; } _mqtt_prefs.mqtt_status_interval = static_cast(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)) { strcpy(reply, "Error: timezone offset must be -12 to 14"); return; } _mqtt_prefs.timezone_offset = static_cast(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) { strcpy(reply, "Error: 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)) { strcpy(reply, "Error: port must be 0-65535 (0 uses the URI default)"); return; } _mqtt_prefs.mqtt_slot_port[slot] = static_cast(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) { strcpy(reply, "Error: 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) { strcpy(reply, "Error: invalid or unsupported value"); } #else strcpy(reply, "Error: 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(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(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()) || (command_radio_apply_pending && command_radio_reply_route == route)) { 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 (command_radio_reply_route == route) cancelPendingRadioParamApply(); 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; } 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(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)) 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)) { 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 (sendGroupData(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; } #if COMPANION_FEATURE_TEMP_RADIO if (isTempRadioActive() || _temp_radio_set_at != 0 || _temp_radio_revert_at != 0) { writeErrFrame(ERR_CODE_BAD_STATE); return; } #endif int i = 1; uint32_t freq; memcpy(&freq, &cmd_frame[i], 4); i += 4; uint32_t bw; memcpy(&bw, &cmd_frame[i], 4); i += 4; uint8_t sf = cmd_frame[i++]; uint8_t cr = cmd_frame[i++]; uint8_t repeat = 0; // default - false if (len > i) { repeat = cmd_frame[i++]; // FIRMWARE_VER_CODE 9+ } if (repeat && !isValidClientRepeatFreq(freq)) { writeErrFrame(ERR_CODE_ILLEGAL_ARG); } else if (freq >= 150000 && freq <= 2500000 && sf >= 5 && sf <= 12 && cr >= 5 && cr <= 8 && bw >= 7000 && bw <= 500000) { float new_freq = (float)freq / 1000.0; float new_bw = (float)bw / 1000.0; if (command_radio_apply_pending) { writeErrFrame(ERR_CODE_BAD_STATE); return; } mesh::RadioParamApplyResult result = tryApplyRadioParams(new_freq, new_bw, sf, cr); if (result == mesh::RadioParamApplyResult::BUSY) { // Capture this requester instead of relying on the mutable last-RX // route. This remains correct if a contact stream later unlocks or a // different transport supplies another command before completion. command_radio_reply_route = _serial->captureReplyRoute(); if (command_radio_reply_route == NULL) { writeErrFrame(ERR_CODE_BAD_STATE); return; } command_radio_apply_pending = true; command_radio_freq = new_freq; command_radio_bw = new_bw; command_radio_sf = sf; command_radio_cr = cr; command_radio_repeat = repeat; command_radio_apply_deadline = futureMillis(COMMAND_RADIO_APPLY_TIMEOUT_MS); MESH_DEBUG_PRINTLN("Deferred CMD_SET_RADIO_PARAMS while radio is busy"); return; } if (result == mesh::RadioParamApplyResult::FAILED) { // Persisted settings remain authoritative after a rejected change or // a hardware apply that had to be rolled back. saved_radio_apply_pending = true; radio_apply_retry_at = 0; radio_apply_failures = 0; writeErrFrame(ERR_CODE_ILLEGAL_ARG); return; } finishRadioParamApply(new_freq, new_bw, sf, cr, repeat); } else { MESH_DEBUG_PRINTLN("Error: CMD_SET_RADIO_PARAMS: f=%d, bw=%d, sf=%d, cr=%d", freq, bw, (uint32_t)sf, (uint32_t)cr); writeErrFrame(ERR_CODE_ILLEGAL_ARG); } } 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 (!flushContactsBeforeReboot()) { 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(); mesh::resetLazyPersistenceAfterSuccess( dirty_contacts_expiry, dirty_contacts_failures); _store->loadContacts(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 (path_len >= 0 && mesh::Packet::isValidPathLen(path_len)) { uint8_t path[MAX_PATH_SIZE]; i += mesh::Packet::writePath(path, &cmd_frame[i], path_len); if (i + 4 > len) { // min payload 4 bytes writeErrFrame(ERR_CODE_ILLEGAL_ARG); } else { auto pkt = createRawData(&cmd_frame[i], len - i); if (pkt) { sendDirect(pkt, path, path_len); writeOKFrame(); } else { writeErrFrame(ERR_CODE_TABLE_FULL); } } } 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; strcpy((char *)&out_frame[i], channel.name); 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(); } 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 = _radio->getEstAirtimeFor(9 + path_len + 2); 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(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(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(&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(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(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(status.offered & 0xFF); out_frame[4] = static_cast(status.offered >> 8); out_frame[5] = static_cast(status.advertised & 0xFF); out_frame[6] = static_cast(status.advertised >> 8); out_frame[7] = static_cast(status.packets_sent & 0xFF); out_frame[8] = static_cast((status.packets_sent >> 8) & 0xFF); out_frame[9] = static_cast((status.packets_sent >> 16) & 0xFF); out_frame[10] = static_cast((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) { if (_serial) { MESH_DEBUG_PRINTLN("Factory reset: disabling serial interface to prevent reconnects (BLE/WiFi)"); _serial->disable(); // Phone app disconnects before we can send OK frame so it's safe here } bool success = _store->formatFileSystem(); if (success) { writeOKFrame(); delay(1000); board.reboot(); // doesn't return } else { writeErrFrame(ERR_CODE_FILE_IO_ERROR); } } 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)) { 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 } void MyMesh::onContactCacheFlushed() { // Eviction already saved the pending mutations. Avoid writing them again // when the old five-second lazy-save deadline arrives. mesh::resetLazyPersistenceAfterSuccess(dirty_contacts_expiry, dirty_contacts_failures); } void MyMesh::saveContacts() { 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 { mesh::resetLazyPersistenceAfterSuccess( dirty_contacts_expiry, dirty_contacts_failures); } } 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() { #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; } mesh::resetLazyPersistenceAfterSuccess( dirty_contacts_expiry, dirty_contacts_failures); return true; } bool MyMesh::prepareForOtaReboot() { // 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(); } bool MyMesh::prepareForUiShutdown() { return 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->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; } 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); 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()) { mesh::resetLazyPersistenceAfterSuccess( dirty_contacts_expiry, dirty_contacts_failures); output.printf(" > internal ExtraFS repaired (%lu KiB); contacts and channels rebuilt\n", (unsigned long)_store->getStorageTotalKb()); } else { if (!contacts_saved || _store->hasPendingContactWrites()) { scheduleContactWriteRetry(); } else { mesh::resetLazyPersistenceAfterSuccess( dirty_contacts_expiry, dirty_contacts_failures); } 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(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(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(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 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(prior) ^ static_cast(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(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. NVIC_SystemReset(); while (true) {} } snprintf(reply, reply_size, "HIL usage: hil extrafs "); 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()) 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(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) { const int8_t previous = _prefs.tx_power_dbm; if (_radio_available && !radio_driver.setTxPower(power)) { return RadioSettingResult::RadioRejected; } _prefs.tx_power_dbm = power; if (savePrefs()) return RadioSettingResult::Saved; _prefs.tx_power_dbm = previous; if (_radio_available && !radio_driver.setTxPower(previous)) { // An RX packet can arrive between the apply and its failed flash commit. // Keep retrying the durable power rather than leaving the rejected value // active until reboot. Recovery also restores the saved tuple, so an // active TempRadio lease deliberately delays it until the lease ends. saved_radio_apply_pending = true; radio_apply_retry_at = 0; radio_apply_failures = 0; } return RadioSettingResult::SaveFailed; } 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() || (_radio_available && !radio_driver.setRxBoostedGainMode(enabled))) { 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 (_radio_available && !radio_driver.setRxBoostedGainMode(previous_pref != 0)) { // A receive can make the rollback busy after hardware accepted the // candidate. Retry the durable gain along with the saved radio settings. saved_radio_apply_pending = true; radio_apply_retry_at = 0; radio_apply_failures = 0; } return false; } 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)) { snprintf(reply, reply_size, "Error: Bluetooth name save failed"); return false; } if (use_default) { snprintf(reply, reply_size, #if defined(NRF52_PLATFORM) "OK - Bluetooth name follows %s; advertising refreshes after disconnect", #else "OK - Bluetooth name follows %s; 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) { snprintf(reply, reply_size, "Error: use set bluetooth.mac "); 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)) { snprintf(reply, reply_size, "Error: 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) { snprintf(reply, reply_size, "Error: Bluetooth address overrides are not supported on Pico W"); return false; } #endif if (!saveBluetoothMac(mode, address)) { snprintf(reply, reply_size, "Error: 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)) { snprintf(reply, reply_size, "Error: use set bluetooth.stealth on|off"); return false; } #if defined(RP2040_PLATFORM) if (strcmp(value, "on") == 0) { snprintf(reply, reply_size, "Error: 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)); snprintf(reply, reply_size, "Error: 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)) { snprintf(reply, reply_size, "Error: 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)) { snprintf(reply, reply_size, "Error: 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 { strcpy(reply, "Error: use set powersaving on|off"); return false; } if (!savePreference(_prefs.powersaving_enabled, static_cast(enabled))) { strcpy(reply, "Error: 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 { snprintf(reply, reply_size, "Error: power save must be none, min, or max"); return false; } const CompanionWiFiPowerSaveResult result = setCompanionWiFiPowerSave(mode); if (result == CompanionWiFiPowerSaveResult::PrimaryEspNowConflict) { snprintf(reply, reply_size, "Error: power save max is unavailable while ESP-NOW is the primary radio"); return false; } if (result == CompanionWiFiPowerSaveResult::BluetoothConflict) { snprintf(reply, reply_size, "Error: power save none is unavailable while Bluetooth is active"); return false; } if (result == CompanionWiFiPowerSaveResult::InvalidMode) { snprintf(reply, reply_size, "Error: power save must be none, min, or max"); return false; } if (result == CompanionWiFiPowerSaveResult::StorageError) { snprintf(reply, reply_size, "Error: failed to save WiFi power save"); return false; } 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::applyAndSaveRxPowerSaving(const char* value, char* reply) { if (!radio_driver.supportsRxPowerSaving()) { strcpy(reply, "Error: RX power saving 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)) { strcpy(reply, "Error: use off, level 1-10, or RX/SLEEP microseconds"); 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 must be 1-10; preamble must be 16 or 32"); return false; } if (parsed.preamble != 0 && parsed.preamble != 16 && parsed.preamble != 32) { strcpy(reply, "Error: level must be 1-10; preamble must be 16 or 32"); return false; } enabled = 1; level = static_cast(parsed.level); preamble = static_cast(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 must be %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)) { strcpy(reply, "Error: level must be 1-10; preamble must be auto, 16, or 32"); return false; } } if ((manual_requested || enabled) && (!isValidRxPowerSavingPeriod(rx_us) || !isValidRxPowerSavingPeriod(sleep_us))) { snprintf(reply, 160, "Error: RX/SLEEP must be %lu-%lu us", (unsigned long)RX_POWERSAVING_MIN_MANUAL_PERIOD_US, (unsigned long)RX_POWERSAVING_MAX_PERIOD_US); return false; } if (_radio_available && !radio_driver.setRxPowerSaving(enabled != 0, rx_us, sleep_us)) { strcpy(reply, "Error: radio busy; retry"); 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 (_radio_available && !radio_driver.setRxPowerSaving(previous_enabled != 0, previous_rx, previous_sleep)) { saved_radio_apply_pending = true; radio_apply_retry_at = 0; radio_apply_failures = 0; } strcpy(reply, "Error: RX power saving could not be saved"); return false; } 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(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 \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(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; } const uint32_t airtime = _radio->getEstAirtimeFor(9 + route_byte_len + 2); if (airtime == 0) { 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) { 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)); const uint8_t history = _delayed_replies.reserveTrace(tag, auth, _ms->getMillis()); if (history == mesh::CompanionDelayedReplies::NO_HISTORY) { terminalOutput().print(" ERROR: trace tag is quarantined or reply history is full\r\n"); return; } mesh::Packet* packet = createTrace(tag, auth, flags); if (packet == NULL) { _delayed_replies.abandonTrace(history); terminalOutput().print(" ERROR: unable to allocate trace packet\r\n"); return; } if (!sendDirect(packet, route, static_cast(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> \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 (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 \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 \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 \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); } else if (strcmp(command, "get radio.rxps.config") == 0) { if (!radio_driver.supportsRxPowerSaving()) { terminalOutput().print(" ERROR: RX power saving is unsupported on this radio\r\n"); } else { terminalOutput().printf( " radio.rxps.config %s,level=%u,preamble=%u,rx=%lu,sleep=%lu\r\n", _prefs.rx_powersaving_enabled ? "on" : "off", (unsigned)_prefs.rx_ps_level, (unsigned)_prefs.rx_ps_preamble, (unsigned long)_prefs.rx_ps_rx_us, (unsigned long)_prefs.rx_ps_sleep_us); } } else if (strcmp(command, "get radio.rxps") == 0) { if (!radio_driver.supportsRxPowerSaving()) { terminalOutput().print(" ERROR: RX power saving is unsupported on this radio\r\n"); } else { uint8_t effective_level = 0; uint8_t effective_preamble = 0; uint32_t effective_rx_us = _prefs.rx_ps_rx_us; uint32_t effective_sleep_us = _prefs.rx_ps_sleep_us; uint8_t active_sf = _prefs.sf; float active_bw = _prefs.bw; #if COMPANION_FEATURE_TEMP_RADIO if (isTempRadioActive()) { active_sf = _temp_radio_sf; active_bw = _temp_radio_bw; } #endif if (_prefs.rx_ps_level != 0) { recalcRxPowerSavingFromLevel( _prefs.rx_ps_level, active_sf, active_bw, _prefs.rx_ps_preamble, &effective_rx_us, &effective_sleep_us, &effective_level, &effective_preamble); } char effective_suffix[48] = {}; const bool adjusted_level = effective_level != 0 && effective_level != _prefs.rx_ps_level; const bool adjusted_preamble = effective_preamble != 0 && effective_preamble != _prefs.rx_ps_preamble; if (adjusted_level && adjusted_preamble) { snprintf(effective_suffix, sizeof(effective_suffix), ",effective-level=%u,effective-preamble=%u", (unsigned)effective_level, (unsigned)effective_preamble); } else if (adjusted_level) { snprintf(effective_suffix, sizeof(effective_suffix), ",effective-level=%u", (unsigned)effective_level); } else if (adjusted_preamble) { snprintf(effective_suffix, sizeof(effective_suffix), ",effective-preamble=%u", (unsigned)effective_preamble); } terminalOutput().printf(" radio.rxps %s,level=%u,preamble=%u,rx=%lu,sleep=%lu%s%s\r\n", _prefs.rx_powersaving_enabled ? "on" : "off", (unsigned)_prefs.rx_ps_level, (unsigned)_prefs.rx_ps_preamble, (unsigned long)effective_rx_us, (unsigned long)effective_sleep_us, radio_driver.isRxPowerSavingContinuousFallback() ? ",mode=continuous-fast" : "", effective_suffix); } #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 boosted gain is unsupported on this radio\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); } else if (strncmp(config, "radio.rxps ", 11) == 0) { char reply[160]; applyAndSaveRxPowerSaving(config + 11, 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 set radio.rxgain \r\n"); } else if (!radio_driver.supportsRxBoostedGainMode()) { terminalOutput().print(" ERROR: RX boosted gain is unsupported on this radio\r\n"); } else if (!applyAndSaveRxBoostedGain(strcmp(value, "on") == 0)) { terminalOutput().print(" ERROR: radio busy; retry\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 \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 \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 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) { terminalOutput().print( "Commands:\r\n" " stats-core / stats-radio / stats-radio-diag / stats-packets\r\n" " get prv.key (when private key export is enabled)\r\n" " get password (this role has no admin password)\r\n" " erase (erase stored settings and identity)\r\n" " board\r\n" " version\r\n"); terminalOutput().print(" get storage.layout\r\n"); #if MESH_ENABLE_MESHTEXT_CHAT terminalOutput().print(" get mt.status / get mt.region / get mt.slot / get mt.hops\r\n" " get mt.node / get mt.shortname\r\n" " set mt.region \r\n" " set mt.slot <0|1-based frequency slot>\r\n" " set mt.hops <0..7> (saved; default 7)\r\n" " set mt.node (saved; use a unique ID)\r\n" " set mt.shortname <1..4 UTF-8 characters|default> (saved; NodeInfo uses 4 bytes)\r\n" " send mt.nodeinfo (one manual advert; no automatic adverts)\r\n" " set mt.preset (saved manual radio2)\r\n" " set mt.channel <32/64 hex digits|AQ==>\r\n" " get mt.channel / delete mt.channel \r\n" " Join #mt-longfast or #mt-shortturbo in the app for automatic radio2\r\n"); #endif #if defined(NRF52_PLATFORM) && defined(EXTRAFS) && !defined(QSPIFLASH) terminalOutput().print(" get flash.health\r\n"); #endif #if COMPANION_FEATURE_READER terminalOutput().print(" get reader [:[-]] [page] (World English Bible, offline)\r\n"); #endif terminalOutput().print(" get pwrmgt.bootreason\r\n"); #if COMPANION_FEATURE_MEMORY_DIAGNOSTICS terminalOutput().print(" memory\r\n"); #endif terminalOutput().print(" get display.rotation\r\n"); #if COMPANION_FEATURE_NOTIFICATIONS terminalOutput().print(" get notify / get notify.rules / get notify.gpio.pins\r\n"); terminalOutput().print(" set notify.{sound|vibration|led|screen|gpio} on|off\r\n"); terminalOutput().print(" set notify. \r\n"); terminalOutput().print(" set notify.remote on|off (!notify, max 15s, no GPIO)\r\n"); terminalOutput().print(" notify.test / notify.stop / notify.delete \r\n"); #endif terminalOutput().print( " get display.inbox\r\n" " set display.inbox \r\n" " set display.rotation <0|90|180|270>\r\n" " get display.touch\r\n" " set display.touch (this boot only)\r\n" " get public.key\r\n"); terminalOutput().print( " set {name|lat|lon|freq|tx|af} {value}\r\n" " get wifi|espnow|2.4ghz\r\n" " set wifi|espnow on|off [force] (force only with off)\r\n" " set 2.4ghz on [all|force]|off [force] (this boot)\r\n" " get bluetooth.name\r\n" " set bluetooth.name \r\n"); #if defined(BLE_PIN_CODE) terminalOutput().print(" get bluetooth (alias: get ble)\r\n"); terminalOutput().print(" set bluetooth [force] (saved; force only with off)\r\n"); terminalOutput().print(" get bluetooth.mac\r\n"); terminalOutput().print( " set bluetooth.mac \r\n"); terminalOutput().print(" get bluetooth.stealth\r\n"); terminalOutput().print(" set bluetooth.stealth on|off\r\n"); #endif #if MESH_ENABLE_LOST_REPLY terminalOutput().print(" get lost.reply\r\n" " set lost.reply \r\n"); #endif terminalOutput().print(" set pin <0-999999>\r\n" " powersaving [on|off]\r\n"); #if MESH_USB_LOGGING_AVAILABLE terminalOutput().print( " get usb.logging\r\n" " set usb.logging [reboot]\r\n" " get usb.debug\r\n" " set usb.debug \r\n" #if MESH_USB_CONSOLE_COOPERATIVE " get usb.watchdog\r\n" " get usb.watchdog.last\r\n" " set usb.watchdog (saved; default auto)\r\n" #endif ); #endif #if defined(ESP32) && defined(WIFI_SSID) #if defined(COMPANION_RADIO_FULL) terminalOutput().print(" start ota [ap] (WiFi firmware update on port 8080)\r\n"); terminalOutput().print(" stop ota\r\n"); #endif #if defined(COMPANION_EXCLUSIVE_WIFI_BLE) terminalOutput().print(" get companion.transport\r\n"); terminalOutput().print(" set companion.transport \r\n"); #endif terminalOutput().print(" get wifi.powersave\r\n"); terminalOutput().print(" set wifi.powersave \r\n"); #ifdef WITH_WEBCONFIG terminalOutput().print(" get wifi.{ssid|pwd|status}\r\n"); terminalOutput().print(" set wifi.ssid \r\n"); terminalOutput().print(" set wifi.pwd \r\n"); terminalOutput().print(" get webui\r\n"); terminalOutput().print(" set webui \r\n"); terminalOutput().print(" start webconfig [ap]\r\n"); terminalOutput().print(" stop webconfig\r\n"); #endif #endif #if defined(MESH_PRIMARY_ESPNOW) && MESH_PRIMARY_ESPNOW terminalOutput().print(" get espnow.channel\r\n"); terminalOutput().print(" set espnow.channel <1-13>\r\n"); #endif // Keep identical help text in bounded chunks. Fewer virtual Print calls // leave room for USB controls on small-flash boards without cutting CLI. terminalOutput().print( " get radio.rxps\r\n" " get radio.rxps.config\r\n" " set radio.rxps \r\n" " get radio.cad\r\n" " set radio.cad \r\n" " set radio.cad timings \r\n"); terminalOutput().print( " get radio.rxgain\r\n" " set radio.rxgain \r\n" " get radio.fem.rxgain\r\n" " set radio.fem.rxgain \r\n" " get radio.fem.txgain\r\n" " set radio.fem.txgain \r\n"); terminalOutput().print( " get/set flood.retry.count <0-15>\r\n" " get/set flood.retry.path <0-63|off>\r\n" " get/set flood.retry.group.path <0-63|off>\r\n" " get/set flood.retry.advert \r\n" " card\r\n" " import \r\n" " clock\r\n" " time \r\n"); terminalOutput().print( " list [n]\r\n" " show [adverts|channels|emergency] [on|off]\r\n" " to [recipient name or prefix]\r\n" " path [direct|clear|hops separated by spaces or commas]\r\n" " send \r\n" " login \r\n" " cmd \r\n"); terminalOutput().print( " trace [recipient name or prefix]\r\n" " trace path <1|2|4> \r\n" " advert\r\n" " reset path\r\n" " public \r\n" " channels\r\n" " channel \r\n"); #if defined(NRF52_PLATFORM) && defined(EXTRAFS) && !defined(QSPIFLASH) terminalOutput().print(" repair extrafs (erases/rebuilds internal ExtraFS)\r\n"); terminalOutput().print(" scan extrafs erase (USB only; reboot for destructive page test)\r\n"); #endif #if COMPANION_FEATURE_TEMP_RADIO || COMPANION_FEATURE_OTA_CLI #if COMPANION_FEATURE_TEMP_RADIO terminalOutput().print(" tempradio [freq,bw,sf,cr,minutes]\r\n"); terminalOutput().print(" normalradio\r\n"); #endif #if COMPANION_FEATURE_OTA_CLI terminalOutput().print(" ota {status|ls|announce|folder|config|...}\r\n"); #endif #endif terminalOutput().print(" reboot\r\n" " ver\r\n"); #if defined(NRF52_PLATFORM) terminalOutput().println(" uf2reset"); #endif if (_terminal_mode) { terminalOutput().print(" +++MESHCORE-TERM-STOP\r\n"); } else { terminalOutput().print(" disconnect (closes the 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" }; 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_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 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) { snprintf(reply, reply_size, "Error: 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; snprintf(reply, reply_size, "Error: 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) { snprintf(reply, reply_size, "Error: 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) { snprintf(reply, reply_size, "> (no admin password on Companion)"); 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 snprintf(reply, reply_size, "Error: private key export disabled in this build"); #endif return true; } if (strcmp(command, "erase") == 0) { if (_store->formatFileSystem()) { stopContactsIterator(); resetContacts(); mesh::resetLazyPersistenceAfterSuccess(dirty_contacts_expiry, dirty_contacts_failures); #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 COMPANION_FEATURE_READER if (sender_timestamp == 0 && mesh::morseTimingActive(_radio) && isCompanionReaderRequest(command)) { snprintf(reply, reply_capacity, "Error: Reader unavailable during Morse"); return true; } if (sender_timestamp == 0 && mesh::handleReaderReply(command, reply, reply_capacity)) return true; #endif #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) { snprintf(reply, reply_capacity, "Error: 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')) { snprintf(reply, reply_capacity, "Error: use fleet send [all|key|region:name|home:name|gps:lat,lon:km[;target...]] "); 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) { snprintf(reply, reply_capacity, "Error: invalid fleet channel index"); return true; } channel_index = channel_index * 10UL + digit; } if (!have_index || channel_index >= MAX_GROUP_CHANNELS || (*text != ' ' && *text != '\t')) { snprintf(reply, reply_capacity, "Error: 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)) { snprintf(reply, reply_capacity, "Error: 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)) { snprintf(reply, reply_capacity, "Error: command is not permitted for fleet management"); return true; } ChannelDetails channel; if (!getChannel((int)channel_index, channel) || !channel.name[0]) { snprintf(reply, reply_capacity, "Error: 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)) { snprintf(reply, reply_capacity, "Error: 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) { snprintf(reply, reply_capacity, "Error: set the Companion clock before fleet send"); return true; } if (sequence <= last_fleet_sequence) { snprintf(reply, reply_capacity, "Error: 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) { snprintf(reply, reply_capacity, "Error: fleet targets and command exceed two packets; use fewer targets"); } else if (!sendFleetCommandData(channel.channel, payload, length)) { snprintf(reply, reply_capacity, "Error: 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; char profile_command[140]; const uint16_t previous_primary_preamble = _radio_profiles.primaryPreamble(); uint16_t requested_primary_preamble = previous_primary_preamble; 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)) { snprintf(reply, reply_capacity, "Error: invalid radio params"); return true; } if (!_radio_profiles.acceptsPrimary(f, bw, sf, cr, preamble)) { snprintf(reply, reply_capacity, "Error: radio params unsupported"); return true; } requested_primary_preamble = preamble; snprintf(profile_command, sizeof(profile_command), "set radio %s", legacy); command = profile_command; } 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) { snprintf(reply, reply_capacity, "Error: frequency must be 150-2500 MHz"); } else { const float previous = _prefs.freq; _prefs.freq = frequency; if (savePrefs()) { snprintf(reply, reply_capacity, "OK - reboot to apply"); } else { _prefs.freq = previous; snprintf(reply, reply_capacity, "Error: frequency could not be saved"); } } return true; } if (handleLocalControlCommand(command, reply, reply_capacity)) return true; if (strncmp(command, "set tx ", 7) == 0) { int32_t parsed = 0; if (!mesh::cli::parseIntegerStrict(command + 7, parsed)) { strcpy(reply, "Error: invalid TX power"); } else if (parsed < -9 || parsed > MAX_LORA_TX_POWER) { snprintf(reply, reply_capacity, "Error: TX power must be -9 to %d", MAX_LORA_TX_POWER); } else { const int8_t requested = static_cast(parsed); const auto result = applyAndSaveTxPower(requested); if (result == RadioSettingResult::RadioRejected) { strcpy(reply, "Error: radio busy or TX power rejected"); } else if (result == RadioSettingResult::SaveFailed) { strcpy(reply, "Error: TX power could not be saved"); } else { strcpy(reply, "OK"); } } return true; } if (isCompanionRadioPrefsCommand(command)) { const float previous_freq = _prefs.freq; const float previous_bw = _prefs.bw; const uint8_t previous_sf = _prefs.sf; const uint8_t previous_cr = _prefs.cr; 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; const uint32_t previous_rx_us = _prefs.rx_ps_rx_us; const uint32_t previous_sleep_us = _prefs.rx_ps_sleep_us; if (!_prefs.getRadioPrefs()->handleCommand( command, sender_timestamp, reply)) { return false; } if (_prefs.getRadioPrefs()->isDirty() && strncmp(reply, "OK", 2) == 0) { if (strncmp(command, "set radio ", 10) == 0) { recalcRxPowerSavingFromLevel( _prefs.rx_ps_level, _prefs.sf, _prefs.bw, _prefs.rx_ps_preamble, &_prefs.rx_ps_rx_us, &_prefs.rx_ps_sleep_us); } // Both validators must accept before either persistent image changes. // In particular, the shared prefs parser has a tighter BW tolerance. const bool primary_radio_command = strncmp(command, "set radio ", 10) == 0; const bool preamble_saved = !primary_radio_command || _radio_profiles.savePrimaryPreamble(requested_primary_preamble); if (!preamble_saved || !savePrefs()) { _prefs.freq = previous_freq; _prefs.bw = previous_bw; _prefs.sf = previous_sf; _prefs.cr = previous_cr; _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.rx_ps_rx_us = previous_rx_us; _prefs.rx_ps_sleep_us = previous_sleep_us; _prefs.clearDirty(); if (!preamble_saved) { strcpy(reply, "Error: preamble could not be saved"); } else if (primary_radio_command && !_radio_profiles.savePrimaryPreamble(previous_primary_preamble)) { strcpy(reply, "Error: radio save failed; preamble rollback failed"); } else { strcpy(reply, "Error: setting changed but save failed"); } } } // Keep get radio as the upstream four-field tuple. Apps parse this CSV // directly; preamble and timing diagnostics have separate commands. if (!strncmp(command, "set radio ", 10) && !strncmp(reply, "OK", 2)) _radio_profiles.appendPrimaryChirpWarning(reply, reply_capacity, _prefs.sf, _prefs.bw, _radio_profiles.primaryPreamble()); 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()) { strcpy(reply, "Error: 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))) { strcpy(reply, "Error: 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; strcpy(reply, "Error: 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, "> %d", _prefs.tz_offset); return true; } if (strncmp(command, "set tz.offset ", 14) == 0) { int32_t tz; if (!mesh::cli::parseIntegerStrict(command + 14, tz) || tz < -12 || tz > 14) { strcpy(reply, "Error, must be from -12 to +14"); } else { const int8_t previous = _prefs.tz_offset; _prefs.tz_offset = tz; if (savePrefs()) strcpy(reply, "OK"); else { _prefs.tz_offset = previous; strcpy(reply, "Error: timezone 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) { 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) { mesh::resetLazyPersistenceAfterSuccess( dirty_contacts_expiry, dirty_contacts_failures); } 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) { bool success = _store->formatFileSystem(); #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 (flushContactsBeforeReboot()) { 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() { #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_MESHTEXT_CHAT refreshMeshtextProfile(); #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 (flushContactsBeforeReboot()) { 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(); #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 2 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 (!command_radio_apply_pending && saved_radio_apply_pending && !hasOutbound() #if COMPANION_FEATURE_TEMP_RADIO && !_temp_radio_applied && _temp_radio_set_at == 0 && _temp_radio_revert_at == 0 #endif && (!radio_apply_retry_at || millisHasNowPassed(radio_apply_retry_at))) { // A power-saving wake can enter begin() with a complete packet already // waiting. Preserve that packet, then apply the persisted radio settings // once the receive/response path is idle. if ((!radio_driver.supportsRxBoostedGainMode() || radio_driver.setRxBoostedGainMode(_prefs.rx_boosted_gain)) && applySavedRadioParams() && radio_driver.setTxPower(_prefs.tx_power_dbm) && (!radio_driver.supportsRxPowerSaving() || radio_driver.setRxPowerSaving(_prefs.rx_powersaving_enabled != 0, _prefs.rx_ps_rx_us, _prefs.rx_ps_sleep_us))) { saved_radio_apply_pending = false; radio_apply_retry_at = 0; radio_apply_failures = 0; } else { radio_apply_retry_at = futureMillis(nextRadioApplyRetryDelay(radio_apply_failures)); } } 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 } servicePendingRadioParamApply(); #if MESH_ENABLE_ONE_KEY_DM if (held_dm_count != 0 || verified_pending_count != 0) releaseHeldOneKeyDMs(); #endif #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 { mesh::resetLazyPersistenceAfterSuccess( dirty_contacts_expiry, dirty_contacts_failures); } } #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 { mesh::resetLazyPersistenceAfterSuccess( dirty_contacts_expiry, dirty_contacts_failures); } } } #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() && !command_radio_apply_pending && !saved_radio_apply_pending && _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 { #if COMPANION_FEATURE_READER if (mesh::readerCommandActive()) return true; #endif #if COMPANION_FEATURE_NOTIFICATIONS if (_notifications.active()) return true; #endif if (isDualRadioActive()) return true; if (_radio_available && (radio_driver.isWatchdogObserving() || radio_driver.isCalibratingNoiseFloor())) return true; #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 return (_serial != NULL && _serial->hasPendingIO()) || (_iter_started && _serial != NULL && _serial->isReplyRouteAvailable(_iter_reply_route)) || hasPendingOtaApply() || command_radio_apply_pending || _scheduled_reboot_at != 0 || hasPendingReqs() || _delayed_replies.hasBinaryTrace() #if COMPANION_FEATURE_TEXT_TERMINAL || _terminal_trace_pending #endif || sign_data != NULL || hasQueuedWorkDue() || hasRetryWorkDue() || (saved_radio_apply_pending #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 && (!radio_apply_retry_at || millisHasNowPassed(radio_apply_retry_at))) || contact_write_needs_polling #if defined(ESP32_PLATFORM) || _store->hasPendingAdvertWrites() #endif || (emergency_client_repeat_packet != NULL && millisHasNowPassed(emergency_client_repeat_send_at)) #if COMPANION_FEATURE_TEMP_RADIO || (_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)) #endif ; }