Detect OLED or the original stock TFT touch assembly in ordinary V4.2/V4.3 images. Add saved live display.type auto/oled/tft/off selection and report the active selection in board output. Keep sensors on primary I2C, preserve shared power, and isolate TFT FSPI from radio HSPI through sleep and changes. Consolidate future picker/build choices only with application capability proof. Retain exact legacy OTA identities and separate R8 hardware images. Document controls and physical qualification limits. Validation: five serialized Full builds with capability/size/RAM checks; focused host tests, existing R8/T-Deck/OLED regressions, build-profile suite, and picker/provider tests. V4.3 OLED live changes and off/auto reboot persistence passed on Mercerwood. Physical TFT qualification remains pending.
11768 lines
463 KiB
C++
11768 lines
463 KiB
C++
#include <helpers/RegulatorSettings.h>
|
|
#if defined(ESP32_PLATFORM)
|
|
#include <helpers/esp32/CpuClockPolicy.h>
|
|
#include <helpers/esp32/PowerTestControls.h>
|
|
#endif
|
|
#include <helpers/LedSettings.h>
|
|
#include <helpers/ui/DisplayPowerSettings.h>
|
|
#if defined(HELTEC_V4_DISPLAY_UNIFIED) && HELTEC_V4_DISPLAY_UNIFIED && defined(DISPLAY_CLASS)
|
|
#include <helpers/ui/HeltecV4DisplaySettings.h>
|
|
#endif
|
|
#include <helpers/ui/DisplayNightLocation.h>
|
|
#if defined(OLED_AUTO_DETECT) && OLED_AUTO_DETECT && defined(DISPLAY_CLASS)
|
|
#include <helpers/ui/OLEDControllerSettings.h>
|
|
#endif
|
|
#include <helpers/BatteryChargeCLI.h>
|
|
#include <helpers/BatteryCalibrationStore.h>
|
|
#include <helpers/MorseCLI.h>
|
|
#include <helpers/MorseLoopPolicy.h>
|
|
#if defined(MESH_HIL_TX_FREQ_MHZ)
|
|
#include <helpers/radiolib/HilTxFrequencyGuard.h>
|
|
#endif
|
|
#if defined(RAK_4631)
|
|
#include <helpers/PeripheralModePrefs.h>
|
|
#endif
|
|
#include <helpers/ui/StartupScreen.h>
|
|
#include "NotificationSettingsFile.h"
|
|
#include "MyMesh.h"
|
|
#if defined(RAK_4631) && defined(COMPANION_RADIO_FULL) \
|
|
|| (defined(STM32_PLATFORM) && defined(STM32WLxx) && COMPANION_FEATURE_TEXT_TERMINAL)
|
|
#include "CompanionHelpWriter.h"
|
|
#endif
|
|
#include <helpers/CompanionStartupFrequency.h>
|
|
#include <helpers/CompanionRadioDiagnostics.h>
|
|
#include <helpers/CompanionPowerDiagnostics.h>
|
|
#include <helpers/CompanionTxRoutingCLI.h>
|
|
#include <helpers/CompanionTrafficRouting.h>
|
|
#include <helpers/CompanionChannelAlias.h>
|
|
#include <helpers/FleetCommand.h>
|
|
#include "CompanionBluetooth.h"
|
|
#include "CompanionWireless.h"
|
|
#include "CompanionRetry.h"
|
|
#if defined(MESH_SOAK_DIAGNOSTICS)
|
|
#include "../../tools/hil/S3SoakDiagnostics.h"
|
|
#endif
|
|
#if defined(ESP32_PLATFORM) && defined(MESH_HIL_RADIO_TRACE) && MESH_HIL_RADIO_TRACE
|
|
#include <helpers/radiolib/ESP32RadioTraceCLI.h>
|
|
#endif
|
|
|
|
#include <Arduino.h> // needed for PlatformIO
|
|
#include <ctype.h>
|
|
#ifdef ENABLE_WIFI_INTERFACE
|
|
#include <WiFi.h>
|
|
#endif
|
|
#include <Mesh.h>
|
|
#include <helpers/CompanionHardwareCommandCompat.h>
|
|
#if defined(MESH_HIL_BLE_BENCH) && MESH_HIL_BLE_BENCH
|
|
#include <helpers/CompanionBleBench.h>
|
|
#endif
|
|
#include <helpers/CompanionStatusResponse.h>
|
|
#include <helpers/CompanionReader.h>
|
|
#include <helpers/IdentityGeneration.h>
|
|
#include <helpers/LazyPersistence.h>
|
|
#include <helpers/StorageLayout.h>
|
|
#include <helpers/StatsFormatHelper.h>
|
|
#include <helpers/SmallTableSort.h>
|
|
#include <helpers/UsbAsciiBinarySwitch.h>
|
|
#include <helpers/UsbLogging.h>
|
|
#if defined(ESP32_PLATFORM) && defined(WIFI_SSID)
|
|
#include <helpers/esp32/CompanionAdminPassword.h>
|
|
#include <helpers/esp32/ManagementPassword.h>
|
|
#endif
|
|
#include <helpers/UsbLoggingWatchdog.h>
|
|
#if MESH_PACKET_LOGGING
|
|
#include <helpers/SerialPacketLog.h>
|
|
#endif
|
|
#if defined(NRF52_POWER_MANAGEMENT)
|
|
#include <helpers/NRF52VoltagePolicy.h>
|
|
#endif
|
|
#if defined(NRF52_PLATFORM)
|
|
#include <helpers/nrf52/RamFallbackFileSystem.h>
|
|
#include <helpers/nrf52/BleDebugTrace.h>
|
|
#endif
|
|
#if defined(ENABLE_OTA)
|
|
#include <helpers/ota/OtaContext.h>
|
|
#if defined(OTA_RAK15002_SD_STORE) && defined(ETHERNET_USE_RAK13800)
|
|
bool handleRak4631PeripheralCommand(const char* command, char* reply, size_t capacity);
|
|
#endif
|
|
#endif
|
|
#if defined(ENABLE_OTA)
|
|
#include <helpers/ota/OtaSpeedConfig.h>
|
|
#endif
|
|
#include "helpers/radiolib/RXPowerSaving.h"
|
|
#include "helpers/radiolib/RxBoostedGainDefaults.h"
|
|
#include "helpers/radiolib/CadTiming.h"
|
|
|
|
#if COMPANION_FEATURE_READER
|
|
static bool isCompanionReaderRequest(const char* command) {
|
|
if (!command) return false;
|
|
mesh::bible::skipSpace(command);
|
|
if (!mesh::bible::token(command, "get")) return false;
|
|
mesh::bible::skipSpace(command);
|
|
return mesh::bible::token(command, "reader");
|
|
}
|
|
#endif
|
|
|
|
#if defined(MESHCORE_EXTRAFS_HIL) \
|
|
&& (!defined(NRF52_PLATFORM) || !defined(EXTRAFS) || defined(QSPIFLASH))
|
|
#error "MESHCORE_EXTRAFS_HIL requires an nRF52 internal ExtraFS build"
|
|
#endif
|
|
|
|
#if COMPANION_FEATURE_MEMORY_DIAGNOSTICS
|
|
#include <helpers/CompanionTerminalDiagnostics.h>
|
|
#endif
|
|
|
|
#if defined(ESP32_PLATFORM) \
|
|
&& (defined(BOARD_HAS_PSRAM) || COMPANION_FEATURE_MEMORY_DIAGNOSTICS)
|
|
#include <esp_heap_caps.h>
|
|
#endif
|
|
|
|
#if defined(ESP32) && defined(WIFI_SSID)
|
|
#include "CompanionWiFi.h"
|
|
#include <helpers/WiFiPowerSave.h>
|
|
#endif
|
|
|
|
#if defined(ESP32_PLATFORM)
|
|
#include <helpers/ESP32TrueRandom.h>
|
|
#endif
|
|
|
|
#if defined(MESH_PRIMARY_ESPNOW) && MESH_PRIMARY_ESPNOW
|
|
#include <helpers/esp32/WiFiRadioPolicy.h>
|
|
#endif
|
|
|
|
#include <helpers/CLICommandUtils.h>
|
|
#include <helpers/GpsPowerPolicy.h>
|
|
#if COMPANION_FEATURE_TEXT_TERMINAL
|
|
#include <helpers/TracePathHelpers.h>
|
|
#endif
|
|
|
|
#if COMPANION_FEATURE_OTA_CLI
|
|
#include <helpers/ota/OtaCli.h>
|
|
#include <helpers/ota/OtaContext.h>
|
|
#include <helpers/ota/CompanionOtaConfig.h>
|
|
#endif
|
|
|
|
#if defined(WITH_MQTT_BRIDGE) && defined(ESP32_PLATFORM) && defined(WIFI_SSID)
|
|
#include <helpers/MQTTDefaults.h>
|
|
#endif
|
|
|
|
#if defined(ESP32) && defined(WIFI_SSID)
|
|
#include <helpers/WiFiSetupPortal.h>
|
|
#endif
|
|
#ifdef WITH_WEBCONFIG
|
|
#include <WiFi.h>
|
|
#include <esp_wifi.h>
|
|
#endif
|
|
|
|
static uint32_t nextRadioApplyRetryDelay(uint8_t& failure_count) {
|
|
uint8_t shift = failure_count < 5 ? failure_count : 5;
|
|
if (failure_count < 6) failure_count++;
|
|
uint32_t delay_ms = 1000UL << shift;
|
|
return delay_ms > 30000UL ? 30000UL : delay_ms;
|
|
}
|
|
|
|
|
|
#if RXPS_FIXED_ENABLED
|
|
#if RXPS_FIXED_LEVEL < 1 || RXPS_FIXED_LEVEL > 10
|
|
#error "RXPS_FIXED_LEVEL must be between 1 and 10"
|
|
#endif
|
|
#if RXPS_FIXED_PREAMBLE != 16 && RXPS_FIXED_PREAMBLE != 32
|
|
#error "RXPS_FIXED_PREAMBLE must be 16 or 32"
|
|
#endif
|
|
#endif
|
|
|
|
#define CMD_APP_START 1
|
|
#define CMD_SEND_TXT_MSG 2
|
|
#define CMD_SEND_CHANNEL_TXT_MSG 3
|
|
#define CMD_GET_CONTACTS 4 // with optional 'since' (for efficient sync)
|
|
#define CMD_GET_DEVICE_TIME 5
|
|
#define CMD_SET_DEVICE_TIME 6
|
|
#define CMD_SEND_SELF_ADVERT 7
|
|
#define CMD_SET_ADVERT_NAME 8
|
|
#define CMD_ADD_UPDATE_CONTACT 9
|
|
#define CMD_SYNC_NEXT_MESSAGE 10
|
|
#define CMD_SET_RADIO_PARAMS 11
|
|
#define CMD_SET_RADIO_TX_POWER 12
|
|
#define CMD_RESET_PATH 13
|
|
#define CMD_SET_ADVERT_LATLON 14
|
|
#define CMD_REMOVE_CONTACT 15
|
|
#define CMD_SHARE_CONTACT 16
|
|
#define CMD_EXPORT_CONTACT 17
|
|
#define CMD_IMPORT_CONTACT 18
|
|
#define CMD_REBOOT 19
|
|
#define CMD_GET_BATT_AND_STORAGE 20 // was CMD_GET_BATTERY_VOLTAGE
|
|
#define CMD_SET_TUNING_PARAMS 21
|
|
#define CMD_DEVICE_QUERY 22
|
|
#define CMD_EXPORT_PRIVATE_KEY 23
|
|
#define CMD_IMPORT_PRIVATE_KEY 24
|
|
#define CMD_SEND_RAW_DATA 25
|
|
#define CMD_SEND_LOGIN 26
|
|
#define CMD_SEND_STATUS_REQ 27
|
|
#define CMD_HAS_CONNECTION 28
|
|
#define CMD_LOGOUT 29 // 'Disconnect'
|
|
#define CMD_GET_CONTACT_BY_KEY 30
|
|
#define CMD_GET_CHANNEL 31
|
|
#define CMD_SET_CHANNEL 32
|
|
#define CMD_SIGN_START 33
|
|
#define CMD_SIGN_DATA 34
|
|
#define CMD_SIGN_FINISH 35
|
|
#define CMD_SEND_TRACE_PATH 36
|
|
#define CMD_SET_DEVICE_PIN 37
|
|
#define CMD_SET_OTHER_PARAMS 38
|
|
#define CMD_SEND_TELEMETRY_REQ 39 // can deprecate this
|
|
#define CMD_GET_CUSTOM_VARS 40
|
|
#define CMD_SET_CUSTOM_VAR 41
|
|
#define CMD_GET_ADVERT_PATH 42
|
|
#define CMD_GET_TUNING_PARAMS 43
|
|
// NOTE: decimal command IDs 44..49 (0x2C..0x31) remain parked
|
|
#define CMD_SEND_BINARY_REQ 50
|
|
#define CMD_FACTORY_RESET 51
|
|
#define CMD_SEND_PATH_DISCOVERY_REQ 52
|
|
#define CMD_SET_FLOOD_SCOPE_KEY 54 // v8+
|
|
#define CMD_SEND_CONTROL_DATA 55 // v8+
|
|
#define CMD_GET_STATS 56 // v8+, second byte is stats type
|
|
#define CMD_SEND_ANON_REQ 57
|
|
#define CMD_SET_AUTOADD_CONFIG 58
|
|
#define CMD_GET_AUTOADD_CONFIG 59
|
|
#define CMD_GET_ALLOWED_REPEAT_FREQ 60
|
|
#define CMD_SET_PATH_HASH_MODE 61
|
|
#define CMD_SEND_CHANNEL_DATA 62
|
|
#define CMD_SET_DEFAULT_FLOOD_SCOPE 63
|
|
#define CMD_GET_DEFAULT_FLOOD_SCOPE 64
|
|
#define CMD_SEND_RAW_PACKET 65
|
|
|
|
#if defined(RADIO_FEM_RXGAIN) && (RADIO_FEM_RXGAIN == 0)
|
|
static constexpr uint8_t DEFAULT_FEM_RX_GAIN = 0;
|
|
#else
|
|
static constexpr uint8_t DEFAULT_FEM_RX_GAIN = 1;
|
|
#endif
|
|
|
|
// Stats sub-types for CMD_GET_STATS
|
|
#define STATS_TYPE_CORE 0
|
|
#define STATS_TYPE_RADIO 1
|
|
#define STATS_TYPE_PACKETS 2
|
|
|
|
#define RESP_CODE_OK 0
|
|
#define RESP_CODE_ERR 1
|
|
#define RESP_CODE_CONTACTS_START 2 // first reply to CMD_GET_CONTACTS
|
|
#define RESP_CODE_CONTACT 3 // multiple of these (after CMD_GET_CONTACTS)
|
|
#define RESP_CODE_END_OF_CONTACTS 4 // last reply to CMD_GET_CONTACTS
|
|
#define CONTACT_STREAM_FRAME_INTERVAL_MS 5 // let slow USB hosts drain accepted frames
|
|
#define RESP_CODE_SELF_INFO 5 // reply to CMD_APP_START
|
|
#define RESP_CODE_SENT 6 // reply to CMD_SEND_TXT_MSG
|
|
#define RESP_CODE_CONTACT_MSG_RECV 7 // a reply to CMD_SYNC_NEXT_MESSAGE (ver < 3)
|
|
#define RESP_CODE_CHANNEL_MSG_RECV 8 // a reply to CMD_SYNC_NEXT_MESSAGE (ver < 3)
|
|
#define RESP_CODE_CURR_TIME 9 // a reply to CMD_GET_DEVICE_TIME
|
|
#define RESP_CODE_NO_MORE_MESSAGES 10 // a reply to CMD_SYNC_NEXT_MESSAGE
|
|
#define RESP_CODE_EXPORT_CONTACT 11
|
|
#define RESP_CODE_BATT_AND_STORAGE 12 // a reply to a CMD_GET_BATT_AND_STORAGE
|
|
#define RESP_CODE_DEVICE_INFO 13 // a reply to CMD_DEVICE_QUERY
|
|
#define RESP_CODE_PRIVATE_KEY 14 // a reply to CMD_EXPORT_PRIVATE_KEY
|
|
#define RESP_CODE_DISABLED 15
|
|
#define RESP_CODE_CONTACT_MSG_RECV_V3 16 // a reply to CMD_SYNC_NEXT_MESSAGE (ver >= 3)
|
|
#define RESP_CODE_CHANNEL_MSG_RECV_V3 17 // a reply to CMD_SYNC_NEXT_MESSAGE (ver >= 3)
|
|
#define RESP_CODE_CHANNEL_INFO 18 // a reply to CMD_GET_CHANNEL
|
|
#define RESP_CODE_SIGN_START 19
|
|
#define RESP_CODE_SIGNATURE 20
|
|
#define RESP_CODE_CUSTOM_VARS 21
|
|
#define RESP_CODE_ADVERT_PATH 22
|
|
#define RESP_CODE_TUNING_PARAMS 23
|
|
#define RESP_CODE_STATS 24 // v8+, second byte is stats type
|
|
#define RESP_CODE_AUTOADD_CONFIG 25
|
|
#define RESP_ALLOWED_REPEAT_FREQ 26
|
|
#define RESP_CODE_CHANNEL_DATA_RECV 27
|
|
#define RESP_CODE_DEFAULT_FLOOD_SCOPE 28
|
|
#define RESP_CODE_CLI_REPLY 29 // v14+, a reply to CMD_RUN_CLI_COMMAND
|
|
|
|
#define MAX_CHANNEL_DATA_LENGTH (MAX_FRAME_SIZE - 9)
|
|
|
|
#define SEND_TIMEOUT_BASE_MILLIS 500
|
|
#define FLOOD_SEND_TIMEOUT_FACTOR 16.0f
|
|
#define DIRECT_SEND_PERHOP_FACTOR 6.0f
|
|
#define DIRECT_SEND_PERHOP_EXTRA_MILLIS 250
|
|
#define LAZY_CONTACTS_WRITE_DELAY 5000
|
|
#if defined(ESP32_PLATFORM)
|
|
// An ESP save streams one record/CRC chunk per pass. Keep the full
|
|
// mesh loop responsive between steps without adding seconds of page delay.
|
|
#define CONTACT_PAGE_WRITE_GAP 1
|
|
#else
|
|
#define CONTACT_PAGE_WRITE_GAP 100
|
|
#endif
|
|
#define EXPECTED_ACK_RETRY_RECHECK_MILLIS 1000
|
|
#define EXPECTED_ACK_HOST_GRACE_MILLIS 10000
|
|
#define EXPECTED_ACK_HOST_RECHECK_MILLIS 10
|
|
|
|
// The encrypted body of an ANON_REQ starts with its usual four-byte tag.
|
|
// A distinct marker after that tag prevents a contact introduction from being
|
|
// mistaken for a login or another anonymous request.
|
|
#if MESH_ENABLE_ONE_KEY_DM
|
|
static constexpr uint8_t ONE_KEY_INTRO_MARKER[] = {'D', 'M', 'K', '1'};
|
|
static constexpr uint8_t ONE_KEY_REJECT_MARKER[] = {'D', 'M', 'R', '1'};
|
|
#endif
|
|
|
|
static bool save_filter(const ContactInfo& c);
|
|
|
|
#ifndef DEFAULT_MULTI_ACKS
|
|
#define DEFAULT_MULTI_ACKS 0
|
|
#endif
|
|
#ifndef DEFAULT_PATH_HASH_MODE
|
|
#define DEFAULT_PATH_HASH_MODE 0
|
|
#endif
|
|
#ifndef DEFAULT_COMPANION_PATH_HASH_MODE
|
|
#if defined(CASCADE_PROFILE) && CASCADE_PROFILE
|
|
// Mode is zero-based: Cascade Companions use two bytes per path hop.
|
|
#define DEFAULT_COMPANION_PATH_HASH_MODE 1
|
|
#else
|
|
#define DEFAULT_COMPANION_PATH_HASH_MODE DEFAULT_PATH_HASH_MODE
|
|
#endif
|
|
#endif
|
|
#ifndef DEFAULT_MANUAL_ADD_CONTACTS
|
|
#define DEFAULT_MANUAL_ADD_CONTACTS 0
|
|
#endif
|
|
#ifndef DEFAULT_AUTOADD_CONFIG
|
|
#define DEFAULT_AUTOADD_CONFIG 0
|
|
#endif
|
|
#ifndef DEFAULT_BUZZER_QUIET
|
|
#define DEFAULT_BUZZER_QUIET 1
|
|
#endif
|
|
#ifndef DEFAULT_CAD_ENABLED
|
|
// Preserve the tuned Companion behavior that preceded the runtime setting.
|
|
#define DEFAULT_CAD_ENABLED 1
|
|
#endif
|
|
|
|
#ifndef EMERGENCY_CLIENT_REPEAT_HOLD_MS
|
|
#define EMERGENCY_CLIENT_REPEAT_HOLD_MS 120000UL
|
|
#endif
|
|
#ifndef EMERGENCY_CLIENT_REPEAT_JITTER_MS
|
|
#define EMERGENCY_CLIENT_REPEAT_JITTER_MS 15000UL
|
|
#endif
|
|
|
|
#define PUBLIC_GROUP_PSK "izOH6cXN6mrJ5e26oRXNcg=="
|
|
// First 16 bytes of SHA-256("#testing"), encoded as base64.
|
|
#define TESTING_GROUP_PSK "zeXoLPUVZH3LVHp5pPBl0Q=="
|
|
|
|
#if COMPANION_FEATURE_TEXT_TERMINAL
|
|
static const char* terminalContactTypeName(uint8_t type) {
|
|
if (type == ADV_TYPE_CHAT) return "Chat";
|
|
if (type == ADV_TYPE_REPEATER) return "Repeater";
|
|
if (type == ADV_TYPE_ROOM) return "Room";
|
|
if (type == ADV_TYPE_SENSOR) return "Sensor";
|
|
return "Unknown";
|
|
}
|
|
#endif
|
|
|
|
// these are _pushed_ to client app at any time
|
|
#define PUSH_CODE_ADVERT 0x80
|
|
#define PUSH_CODE_PATH_UPDATED 0x81
|
|
#define PUSH_CODE_SEND_CONFIRMED 0x82
|
|
#define PUSH_CODE_MSG_WAITING 0x83
|
|
#define PUSH_CODE_RAW_DATA 0x84
|
|
#define PUSH_CODE_LOGIN_SUCCESS 0x85
|
|
#define PUSH_CODE_LOGIN_FAIL 0x86
|
|
#define PUSH_CODE_STATUS_RESPONSE 0x87
|
|
#define PUSH_CODE_LOG_RX_DATA 0x88
|
|
#define PUSH_CODE_TRACE_DATA 0x89
|
|
#define PUSH_CODE_NEW_ADVERT 0x8A
|
|
#define PUSH_CODE_TELEMETRY_RESPONSE 0x8B
|
|
#define PUSH_CODE_BINARY_RESPONSE 0x8C
|
|
#define PUSH_CODE_PATH_DISCOVERY_RESPONSE 0x8D
|
|
|
|
static const uint8_t EMERGENCY_CHANNEL_SECRET[CIPHER_KEY_SIZE] = {
|
|
0xe1, 0xad, 0x57, 0x8d, 0x25, 0x10, 0x8e, 0x34,
|
|
0x48, 0x08, 0xf3, 0x0d, 0xfd, 0xaa, 0xf9, 0x26
|
|
};
|
|
|
|
#define EMERGENCY_CLIENT_REPEAT_TABLE_SIZE 63
|
|
static uint16_t emergency_client_repeats[EMERGENCY_CLIENT_REPEAT_TABLE_SIZE];
|
|
static uint8_t emergency_client_repeat_next;
|
|
static unsigned long emergency_client_repeat_send_at;
|
|
static uint16_t emergency_client_repeat_key;
|
|
static mesh::Packet* emergency_client_repeat_packet;
|
|
|
|
#define PUSH_CODE_CONTROL_DATA 0x8E // v8+
|
|
#define PUSH_CODE_CONTACT_DELETED 0x8F // used to notify client app of deleted contact when overwriting oldest
|
|
#define PUSH_CODE_CONTACTS_FULL 0x90 // used to notify client app that contacts storage is full
|
|
#define PUSH_CODE_ONE_KEY_DM_REJECTED 0x91 // full sender key follows
|
|
|
|
#define ERR_CODE_UNSUPPORTED_CMD 1
|
|
#define ERR_CODE_NOT_FOUND 2
|
|
#define ERR_CODE_TABLE_FULL 3
|
|
#define ERR_CODE_BAD_STATE 4
|
|
#define ERR_CODE_FILE_IO_ERROR 5
|
|
#define ERR_CODE_ILLEGAL_ARG 6
|
|
|
|
// Copied from simple_repeater (could probably be shared)
|
|
#define CTL_TYPE_NODE_DISCOVER_REQ 0x80
|
|
#define CTL_TYPE_NODE_DISCOVER_RESP 0x90
|
|
|
|
#define MAX_SIGN_DATA_LEN (8 * 1024) // 8K
|
|
static constexpr unsigned long SIGN_SESSION_TIMEOUT_MILLIS = 120000;
|
|
|
|
// Auto-add config bitmask
|
|
// Bit 0: If set, overwrite oldest non-favourite contact when contacts file is full
|
|
// Bits 1-4: these indicate which contact types to auto-add when manual_contact_mode = 0x01
|
|
#define AUTO_ADD_OVERWRITE_OLDEST (1 << 0) // 0x01 - overwrite oldest non-favourite when full
|
|
#define AUTO_ADD_CHAT (1 << 1) // 0x02 - auto-add Chat (Companion) (ADV_TYPE_CHAT)
|
|
#define AUTO_ADD_REPEATER (1 << 2) // 0x04 - auto-add Repeater (ADV_TYPE_REPEATER)
|
|
#define AUTO_ADD_ROOM_SERVER (1 << 3) // 0x08 - auto-add Room Server (ADV_TYPE_ROOM)
|
|
#define AUTO_ADD_SENSOR (1 << 4) // 0x10 - auto-add Sensor (ADV_TYPE_SENSOR)
|
|
|
|
void MyMesh::writeOKFrame(BaseSerialInterface* route) {
|
|
uint8_t buf[1];
|
|
buf[0] = RESP_CODE_OK;
|
|
if (route != NULL) {
|
|
_serial->writeFrameToRoute(route, buf, 1);
|
|
} else {
|
|
_serial->writeFrame(buf, 1);
|
|
}
|
|
}
|
|
void MyMesh::writeErrFrame(uint8_t err_code, BaseSerialInterface* route) {
|
|
uint8_t buf[2];
|
|
buf[0] = RESP_CODE_ERR;
|
|
buf[1] = err_code;
|
|
if (route != NULL) {
|
|
_serial->writeFrameToRoute(route, buf, 2);
|
|
} else {
|
|
_serial->writeFrame(buf, 2);
|
|
}
|
|
}
|
|
|
|
size_t MyMesh::writePendingSerialFrame(const uint8_t frame[], size_t len, uint32_t now) {
|
|
if (!_delayed_replies.storeRequest(frame, len, now)) return 0;
|
|
_delayed_replies.serviceRequest(_serial, now);
|
|
return len; // Retained by the producer, not necessarily admitted yet.
|
|
}
|
|
|
|
void MyMesh::writeDisabledFrame() {
|
|
uint8_t buf[1];
|
|
buf[0] = RESP_CODE_DISABLED;
|
|
_serial->writeFrame(buf, 1);
|
|
}
|
|
|
|
bool MyMesh::writeContactRespFrame(uint8_t code, const ContactInfo &contact,
|
|
BaseSerialInterface* route) {
|
|
int i = 0;
|
|
out_frame[i++] = code;
|
|
memcpy(&out_frame[i], contact.id.pub_key, PUB_KEY_SIZE);
|
|
i += PUB_KEY_SIZE;
|
|
out_frame[i++] = contact.type;
|
|
out_frame[i++] = contact.flags;
|
|
out_frame[i++] = contact.out_path_len;
|
|
if (!contact.copyPathTo(&out_frame[i])) return false;
|
|
i += MAX_PATH_SIZE;
|
|
StrHelper::strzcpy((char *)&out_frame[i], contact.name, 32);
|
|
i += 32;
|
|
memcpy(&out_frame[i], &contact.last_advert_timestamp, 4);
|
|
i += 4;
|
|
memcpy(&out_frame[i], &contact.gps_lat, 4);
|
|
i += 4;
|
|
memcpy(&out_frame[i], &contact.gps_lon, 4);
|
|
i += 4;
|
|
memcpy(&out_frame[i], &contact.lastmod, 4);
|
|
i += 4;
|
|
return (route != NULL ? _serial->writeFrameToRoute(route, out_frame, i)
|
|
: _serial->writeFrame(out_frame, i)) == (size_t)i;
|
|
}
|
|
|
|
void MyMesh::stopContactsIterator() {
|
|
_iter_pending_contact = ContactInfo();
|
|
if (!_iter_started) return;
|
|
_iter_started = false;
|
|
_iter_start_pending = false;
|
|
_iter_contact_pending = false;
|
|
_iter_next_frame_at = 0;
|
|
_iter_reply_route = NULL;
|
|
}
|
|
|
|
static constexpr int CONTACT_UPDATE_FRAME_MIN_LEN =
|
|
1 + PUB_KEY_SIZE + 1 + 1 + 1 + MAX_PATH_SIZE + 32 + 4;
|
|
static constexpr int CONTACT_UPDATE_FRAME_GPS_LEN =
|
|
CONTACT_UPDATE_FRAME_MIN_LEN + 8;
|
|
static constexpr int CONTACT_UPDATE_FRAME_LASTMOD_LEN =
|
|
CONTACT_UPDATE_FRAME_GPS_LEN + 4;
|
|
|
|
static bool isPersistentContactType(uint8_t type) {
|
|
return type == ADV_TYPE_CHAT || type == ADV_TYPE_REPEATER
|
|
|| type == ADV_TYPE_ROOM || type == ADV_TYPE_SENSOR;
|
|
}
|
|
|
|
bool MyMesh::updateContactFromFrame(ContactInfo &contact, uint32_t& last_mod, const uint8_t *frame, int len) {
|
|
if (frame == NULL || len < CONTACT_UPDATE_FRAME_MIN_LEN
|
|
|| (len > CONTACT_UPDATE_FRAME_MIN_LEN
|
|
&& len < CONTACT_UPDATE_FRAME_GPS_LEN)
|
|
|| (len > CONTACT_UPDATE_FRAME_GPS_LEN
|
|
&& len < CONTACT_UPDATE_FRAME_LASTMOD_LEN)
|
|
|| frame[0] != CMD_ADD_UPDATE_CONTACT) {
|
|
return false;
|
|
}
|
|
|
|
const uint8_t type = frame[1 + PUB_KEY_SIZE];
|
|
const uint8_t out_path_len = frame[1 + PUB_KEY_SIZE + 1 + 1];
|
|
if (!isPersistentContactType(type)
|
|
|| (out_path_len != OUT_PATH_UNKNOWN
|
|
&& !mesh::Packet::isValidPathLen(out_path_len))) {
|
|
return false;
|
|
}
|
|
|
|
int i = 1; // command byte
|
|
memcpy(contact.id.pub_key, &frame[i], PUB_KEY_SIZE);
|
|
i += PUB_KEY_SIZE;
|
|
contact.type = frame[i++];
|
|
contact.flags = frame[i++];
|
|
contact.out_path_len = frame[i++];
|
|
if (!contact.setRawPath(&frame[i])) return false;
|
|
i += MAX_PATH_SIZE;
|
|
memcpy(contact.name, &frame[i], 32);
|
|
contact.name[sizeof(contact.name) - 1] = 0;
|
|
i += 32;
|
|
memcpy(&contact.last_advert_timestamp, &frame[i], 4);
|
|
i += 4;
|
|
if (len >= CONTACT_UPDATE_FRAME_GPS_LEN) { // optional fields
|
|
memcpy(&contact.gps_lat, &frame[i], 4);
|
|
i += 4;
|
|
memcpy(&contact.gps_lon, &frame[i], 4);
|
|
i += 4;
|
|
if (len >= CONTACT_UPDATE_FRAME_LASTMOD_LEN) {
|
|
memcpy(&last_mod, &frame[i], 4);
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
#if COMPANION_RTC_FAST_OFFLINE_FRAMES
|
|
alignas(4) RTC_FAST_ATTR MyMesh::Frame MyMesh::rtc_fast_offline_frames[COMPANION_RTC_FAST_OFFLINE_FRAMES];
|
|
#endif
|
|
#if COMPANION_RTC_SLOW_OFFLINE_FRAMES
|
|
alignas(4) RTC_SLOW_ATTR MyMesh::Frame MyMesh::rtc_slow_offline_frames[COMPANION_RTC_SLOW_OFFLINE_FRAMES];
|
|
#endif
|
|
|
|
bool MyMesh::Frame::isChannelMsg() const {
|
|
return len > 0 && (buf[0] == RESP_CODE_CHANNEL_MSG_RECV || buf[0] == RESP_CODE_CHANNEL_MSG_RECV_V3 ||
|
|
buf[0] == RESP_CODE_CHANNEL_DATA_RECV);
|
|
}
|
|
|
|
int MyMesh::getOfflineQueueCapacity() const {
|
|
#if defined(OTA_SHARED_COMPANION_QUEUE)
|
|
return offline_queue.capacity();
|
|
#elif defined(ESP32_PLATFORM) && defined(BOARD_HAS_PSRAM)
|
|
return offline_queue_capacity;
|
|
#else
|
|
return OFFLINE_QUEUE_SIZE;
|
|
#endif
|
|
}
|
|
|
|
MyMesh::Frame& MyMesh::offlineQueueFrameAt(int logical_index) {
|
|
#if defined(OTA_SHARED_COMPANION_QUEUE)
|
|
return offline_queue.at((offline_queue_head + logical_index) % getOfflineQueueCapacity());
|
|
#elif COMPANION_RTC_FAST_OFFLINE_FRAMES || COMPANION_RTC_SLOW_OFFLINE_FRAMES
|
|
return offline_queue.at((offline_queue_head + logical_index) % getOfflineQueueCapacity());
|
|
#elif ONE_KEY_DM_SHARED_OFFLINE_QUEUE
|
|
return offline_queue[logical_index];
|
|
#else
|
|
return offline_queue[(offline_queue_head + logical_index) % getOfflineQueueCapacity()];
|
|
#endif
|
|
}
|
|
|
|
#if ONE_KEY_DM_SHARED_OFFLINE_QUEUE
|
|
MyMesh::Frame& MyMesh::heldDMFrameAt(uint8_t index) {
|
|
return offline_queue[getOfflineQueueCapacity() - 1 - index];
|
|
}
|
|
|
|
void MyMesh::removeHeldOneKeyDM(uint8_t index) {
|
|
for (uint8_t j = index + 1; j < held_dm_count; ++j) {
|
|
held_dms[j - 1] = held_dms[j];
|
|
heldDMFrameAt(j - 1) = heldDMFrameAt(j);
|
|
}
|
|
--held_dm_count;
|
|
}
|
|
#endif
|
|
|
|
#if defined(OTA_SHARED_COMPANION_QUEUE)
|
|
mesh::ota::OtaContext* MyMesh::acquireOfflineQueueForOta(void* owner) {
|
|
MyMesh* mesh = static_cast<MyMesh*>(owner);
|
|
return mesh->offline_queue.acquire(mesh->offline_queue_len, mesh->offline_queue_head);
|
|
}
|
|
|
|
uint16_t MyMesh::drainOfflineQueueForOta(void* owner) {
|
|
MyMesh* mesh = static_cast<MyMesh*>(owner);
|
|
uint8_t discarded[MAX_FRAME_SIZE];
|
|
uint16_t removed = 0;
|
|
// Explicit OTA work borrows the queue's workspace region. Keep the newest
|
|
// retained frames and notify the UI for each oldest frame consumed.
|
|
while (mesh->offline_queue_len > static_cast<int>(OTA_RETAINED_OFFLINE_FRAMES)) {
|
|
if (mesh->getFromOfflineQueue(discarded) <= 0) break;
|
|
++removed;
|
|
}
|
|
return removed;
|
|
}
|
|
|
|
void MyMesh::releaseOfflineQueueFromOta(void* owner) {
|
|
MyMesh* mesh = static_cast<MyMesh*>(owner);
|
|
mesh->offline_queue.release(mesh->offline_queue_head);
|
|
}
|
|
#endif
|
|
|
|
void MyMesh::initializeOfflineQueue() {
|
|
#if defined(OTA_SHARED_COMPANION_QUEUE)
|
|
mesh::ota::ota_set_context_storage(this, acquireOfflineQueueForOta,
|
|
releaseOfflineQueueFromOta, drainOfflineQueueForOta);
|
|
#elif defined(ESP32_PLATFORM) && defined(BOARD_HAS_PSRAM)
|
|
if (offline_queue != offline_queue_fallback || OFFLINE_QUEUE_SIZE <= offline_queue_capacity) return;
|
|
|
|
int requested_capacity = OFFLINE_QUEUE_SIZE;
|
|
while (requested_capacity > offline_queue_capacity) {
|
|
void* storage = heap_caps_malloc(sizeof(Frame) * requested_capacity,
|
|
MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
|
|
if (storage) {
|
|
offline_queue = static_cast<Frame*>(storage);
|
|
offline_queue_capacity = requested_capacity;
|
|
return;
|
|
}
|
|
|
|
if (requested_capacity > 256) {
|
|
requested_capacity = 256;
|
|
} else if (requested_capacity > 128) {
|
|
requested_capacity = 128;
|
|
} else {
|
|
requested_capacity = offline_queue_capacity;
|
|
}
|
|
}
|
|
#endif
|
|
}
|
|
|
|
bool MyMesh::addToOfflineQueue(const uint8_t frame[], int len) {
|
|
int capacity = getOfflineQueueCapacity();
|
|
#if ONE_KEY_DM_SHARED_OFFLINE_QUEUE
|
|
capacity -= held_dm_count;
|
|
#endif
|
|
if (!frame || len <= 0 || len > MAX_FRAME_SIZE || capacity <= 0) {
|
|
MESH_DEBUG_PRINTLN("WARN: invalid offline queue frame length: %d", len);
|
|
return false;
|
|
}
|
|
|
|
if (offline_queue_len >= capacity) {
|
|
MESH_DEBUG_PRINTLN("WARN: offline_queue is full!");
|
|
int pos = 0;
|
|
while (pos < offline_queue_len) {
|
|
if (offlineQueueFrameAt(pos).isChannelMsg()) {
|
|
for (int i = pos; i < offline_queue_len - 1; i++) { // delete oldest channel msg from queue
|
|
offlineQueueFrameAt(i) = offlineQueueFrameAt(i + 1);
|
|
}
|
|
MESH_DEBUG_PRINTLN("INFO: removed oldest channel message from queue.");
|
|
Frame& tail = offlineQueueFrameAt(offline_queue_len - 1);
|
|
tail.len = len;
|
|
memcpy(tail.buf, frame, len);
|
|
#ifdef DISPLAY_CLASS
|
|
if (_ui) _ui->syncMessageQueue(offline_queue_len, pos);
|
|
#endif
|
|
return true;
|
|
}
|
|
pos++;
|
|
}
|
|
MESH_DEBUG_PRINTLN("INFO: no channel messages to remove from queue.");
|
|
} else {
|
|
Frame& tail = offlineQueueFrameAt(offline_queue_len);
|
|
tail.len = len;
|
|
memcpy(tail.buf, frame, len);
|
|
offline_queue_len++;
|
|
#ifdef DISPLAY_CLASS
|
|
if (_ui) _ui->syncMessageQueue(offline_queue_len);
|
|
#endif
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
int MyMesh::peekOfflineQueue(uint8_t frame[]) {
|
|
if (!frame || offline_queue_len <= 0) return 0;
|
|
Frame& head = offlineQueueFrameAt(0);
|
|
memcpy(frame, head.buf, head.len);
|
|
return head.len;
|
|
}
|
|
|
|
void MyMesh::popOfflineQueue() {
|
|
if (offline_queue_len > 0) {
|
|
offline_queue_len--;
|
|
#if ONE_KEY_DM_SHARED_OFFLINE_QUEUE
|
|
for (int i = 0; i < offline_queue_len; ++i) {
|
|
offlineQueueFrameAt(i) = offlineQueueFrameAt(i + 1);
|
|
}
|
|
#else
|
|
if (offline_queue_len == 0) {
|
|
offline_queue_head = 0;
|
|
} else {
|
|
offline_queue_head = (offline_queue_head + 1) % getOfflineQueueCapacity();
|
|
}
|
|
#endif
|
|
#ifdef DISPLAY_CLASS
|
|
if (_ui) _ui->syncMessageQueue(offline_queue_len, 0);
|
|
#endif
|
|
}
|
|
}
|
|
|
|
int MyMesh::getFromOfflineQueue(uint8_t frame[]) {
|
|
// Explicit OTA queue draining intentionally consumes frames without app
|
|
// delivery. Normal app downloads commit only after transport acceptance.
|
|
const int len = peekOfflineQueue(frame);
|
|
if (len > 0) popOfflineQueue();
|
|
return len;
|
|
}
|
|
|
|
void MyMesh::syncNextOfflineMessage() {
|
|
BaseSerialInterface* reply_route = _serial->captureReplyRoute();
|
|
const Frame* head = offline_queue_len > 0 ? &offlineQueueFrameAt(0) : nullptr;
|
|
size_t out_len = head ? head->len : 0;
|
|
const bool have_message = out_len > 0;
|
|
const uint8_t* reply_frame = out_frame;
|
|
if (have_message) {
|
|
// A full or disconnected requester must leave this message pending.
|
|
// Another connected transport cannot accept its reply on its behalf.
|
|
// Transports consume or copy the frame before returning, so the retained
|
|
// queue head can be sent directly and is only removed after acceptance.
|
|
if (!_serial->isReplyRouteAvailable(reply_route)) return;
|
|
reply_frame = head->buf;
|
|
} else {
|
|
out_frame[0] = RESP_CODE_NO_MORE_MESSAGES;
|
|
out_len = 1;
|
|
}
|
|
const size_t accepted = _serial->writeFrameToRoute(reply_route, reply_frame, out_len);
|
|
if (have_message && accepted == out_len) {
|
|
popOfflineQueue();
|
|
}
|
|
}
|
|
|
|
float MyMesh::getAirtimeBudgetFactor() const {
|
|
// TempRadio is a short-lived, explicitly coordinated OTA channel. Let its
|
|
// primary transfer use the full dispatcher budget; the persisted public
|
|
// channel duty factor becomes authoritative again as soon as TempRadio ends.
|
|
#if COMPANION_FEATURE_TEMP_RADIO
|
|
if (isTempRadioActive()) return 0.0f;
|
|
#endif
|
|
return _prefs.airtime_factor;
|
|
}
|
|
|
|
bool MyMesh::getCADEnabled() const {
|
|
return _prefs.cad_enabled != 0;
|
|
}
|
|
|
|
uint32_t MyMesh::getCADFailRetryDelay() const {
|
|
return _prefs.cad_retry_delay_ms != 0
|
|
? _prefs.cad_retry_delay_ms
|
|
: BaseChatMesh::getCADFailRetryDelay();
|
|
}
|
|
|
|
uint32_t MyMesh::getCADFailMaxDuration() const {
|
|
return _prefs.cad_max_duration_ms != 0
|
|
? _prefs.cad_max_duration_ms
|
|
: mesh::Dispatcher::getCADFailMaxDuration();
|
|
}
|
|
|
|
int MyMesh::getInterferenceThreshold() const {
|
|
return _prefs.interference_threshold;
|
|
}
|
|
|
|
int MyMesh::calcRxDelay(float score, uint32_t air_time) const {
|
|
if (_prefs.rx_delay_base <= 0.0f) return 0;
|
|
return (int)((powf(_prefs.rx_delay_base, 0.85f - score) - 1.0f) * air_time);
|
|
}
|
|
|
|
uint32_t MyMesh::getRetransmitDelay(const mesh::Packet *packet) {
|
|
uint32_t t = (_radio->getProfileAirtime(packet->radio_profile,
|
|
packet->getRawLength(), packet->tx_cr) * _prefs.tx_delay_factor);
|
|
return getRNG()->nextInt(0, 5*t + 1);
|
|
}
|
|
uint32_t MyMesh::getDirectRetransmitDelay(const mesh::Packet *packet) {
|
|
uint32_t t = (_radio->getProfileAirtime(packet->radio_profile,
|
|
packet->getRawLength(), packet->tx_cr) * _prefs.direct_tx_delay_factor);
|
|
return getRNG()->nextInt(0, 5*t + 1);
|
|
}
|
|
|
|
uint8_t MyMesh::getExtraAckTransmitCount() const {
|
|
return _prefs.multi_acks;
|
|
}
|
|
|
|
bool MyMesh::hasLocationTelemetryRecipient() {
|
|
if (_prefs.telemetry_mode_loc == TELEM_MODE_DENY) return false;
|
|
|
|
ContactsIterator iter = startContactsIterator();
|
|
ContactInfo contact;
|
|
while (iter.hasNext(this, contact)) {
|
|
if (contact.type == ADV_TYPE_NONE) continue;
|
|
if (_prefs.telemetry_mode_loc == TELEM_MODE_ALLOW_ALL) return true;
|
|
if (_prefs.telemetry_mode_loc == TELEM_MODE_ALLOW_FLAGS &&
|
|
((contact.flags >> 1) & TELEM_PERM_LOCATION)) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
void MyMesh::updateGpsTelemetryPolicy() {
|
|
sensors.setTelemetryLocationAccessAvailable(hasLocationTelemetryRecipient());
|
|
}
|
|
|
|
#if MESH_PACKET_LOGGING && !MESH_PACKET_LOGGING_COMPACT
|
|
const char* MyMesh::getLogDateTime() {
|
|
// The shared RX/TX summaries use this same virtual formatter. Keep the
|
|
// repeater's UTC record grammar so USB packet bridges can pair RAW and RX.
|
|
static char timestamp[32];
|
|
const DateTime now(getRTCClock()->getCurrentTime());
|
|
snprintf(timestamp, sizeof(timestamp), "%02u:%02u:%02u - %u/%u/%u U",
|
|
(unsigned)now.hour(), (unsigned)now.minute(), (unsigned)now.second(),
|
|
(unsigned)now.day(), (unsigned)now.month(), (unsigned)now.year());
|
|
return timestamp;
|
|
}
|
|
#endif
|
|
|
|
void MyMesh::logRxRaw(float snr, float rssi, const uint8_t raw[], int len) {
|
|
#if MESH_PACKET_LOGGING
|
|
if (mesh::isUsbLoggingEnabled()) {
|
|
mesh::SerialLogLine<> line;
|
|
#if MESH_PACKET_LOGGING_COMPACT
|
|
line.printf("R");
|
|
line.hex(raw, len);
|
|
line.flush(mesh::usbLoggingPort(), false);
|
|
#else
|
|
line.printf("%s RAW: ", getLogDateTime());
|
|
line.hex(raw, len);
|
|
line.flush(mesh::usbLoggingPort());
|
|
#endif
|
|
}
|
|
#endif
|
|
#if defined(WITH_MQTT_BRIDGE) && defined(ESP32_PLATFORM) && defined(WIFI_SSID)
|
|
if (_mqtt_bridge && _mqtt_bridge->isRunning()) {
|
|
_mqtt_bridge->storeRawRadioData(raw, len, snr, rssi);
|
|
}
|
|
#endif
|
|
|
|
if (_serial->isConnected() && len + 3 <= MAX_FRAME_SIZE) {
|
|
int i = 0;
|
|
out_frame[i++] = PUSH_CODE_LOG_RX_DATA;
|
|
out_frame[i++] = (int8_t)(snr * 4);
|
|
out_frame[i++] = (int8_t)(rssi);
|
|
memcpy(&out_frame[i], raw, len);
|
|
i += len;
|
|
|
|
_serial->writeFrame(out_frame, i);
|
|
}
|
|
}
|
|
|
|
#if defined(WITH_MQTT_BRIDGE) && defined(ESP32_PLATFORM) && defined(WIFI_SSID)
|
|
void MyMesh::logRx(mesh::Packet* packet, int, float) {
|
|
if (_mqtt_bridge && _mqtt_bridge->isRunning()) {
|
|
_mqtt_bridge->onPacketReceived(packet);
|
|
}
|
|
}
|
|
|
|
void MyMesh::logTx(mesh::Packet* packet, int) {
|
|
if (_mqtt_bridge && _mqtt_bridge->isRunning()) {
|
|
_mqtt_bridge->sendPacket(packet);
|
|
}
|
|
}
|
|
#endif
|
|
|
|
bool MyMesh::isAutoAddEnabled() const {
|
|
return (_prefs.manual_add_contacts & 1) == 0;
|
|
}
|
|
|
|
bool MyMesh::shouldAutoAddContactType(uint8_t contact_type) const {
|
|
if ((_prefs.manual_add_contacts & 1) == 0) {
|
|
return true;
|
|
}
|
|
|
|
uint8_t type_bit = 0;
|
|
switch (contact_type) {
|
|
case ADV_TYPE_CHAT:
|
|
type_bit = AUTO_ADD_CHAT;
|
|
break;
|
|
case ADV_TYPE_REPEATER:
|
|
type_bit = AUTO_ADD_REPEATER;
|
|
break;
|
|
case ADV_TYPE_ROOM:
|
|
type_bit = AUTO_ADD_ROOM_SERVER;
|
|
break;
|
|
case ADV_TYPE_SENSOR:
|
|
type_bit = AUTO_ADD_SENSOR;
|
|
break;
|
|
default:
|
|
return false; // Unknown type, don't auto-add
|
|
}
|
|
|
|
return (_prefs.autoadd_config & type_bit) != 0;
|
|
}
|
|
|
|
bool MyMesh::shouldOverwriteWhenFull() const {
|
|
return (_prefs.autoadd_config & AUTO_ADD_OVERWRITE_OLDEST) != 0;
|
|
}
|
|
|
|
uint8_t MyMesh::getAutoAddMaxHops() const {
|
|
return _prefs.autoadd_max_hops;
|
|
}
|
|
|
|
bool MyMesh::canMutateContacts() const {
|
|
return !_store->hasIncompleteContactLoad();
|
|
}
|
|
|
|
bool MyMesh::onContactOverwrite(const ContactInfo& contact) {
|
|
uint16_t released_slot = mesh::storage::CONTACT_SLOT_NONE;
|
|
if (!scheduleContactWriteAfterRelease(contact, released_slot)) {
|
|
MESH_DEBUG_PRINTLN(
|
|
"Contact overwrite refused: persistent slot release failed");
|
|
return false;
|
|
}
|
|
if (!_store->deleteBlobByKey(contact.id.pub_key, PUB_KEY_SIZE)) {
|
|
// releaseContact() is failure-atomic and this single-threaded path still
|
|
// owns the just-freed slot. Put it back before refusing the overwrite so a
|
|
// full filesystem cannot silently retain stale advert state for a contact
|
|
// which the live table has already replaced.
|
|
if (!restoreContactWriteAfterRelease(contact, released_slot)) {
|
|
MESH_DEBUG_PRINTLN(
|
|
"Contact overwrite rollback failed after advert-cache error");
|
|
}
|
|
MESH_DEBUG_PRINTLN(
|
|
"Contact overwrite refused: advert-cache deletion failed");
|
|
return false;
|
|
}
|
|
if (_serial != NULL && _serial->isConnected()) {
|
|
out_frame[0] = PUSH_CODE_CONTACT_DELETED;
|
|
memcpy(&out_frame[1], contact.id.pub_key, PUB_KEY_SIZE);
|
|
_serial->writeFrame(out_frame, 1 + PUB_KEY_SIZE);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
void MyMesh::onContactsFull() {
|
|
if (_serial != NULL && _serial->isConnected()) {
|
|
out_frame[0] = PUSH_CODE_CONTACTS_FULL;
|
|
_serial->writeFrame(out_frame, 1);
|
|
}
|
|
}
|
|
|
|
void MyMesh::onDiscoveredContact(ContactInfo &contact, bool is_new, uint8_t path_len, const uint8_t* path) {
|
|
if (_serial->isConnected()) {
|
|
if (is_new) {
|
|
writeContactRespFrame(PUSH_CODE_NEW_ADVERT, contact);
|
|
} else {
|
|
out_frame[0] = PUSH_CODE_ADVERT;
|
|
memcpy(&out_frame[1], contact.id.pub_key, PUB_KEY_SIZE);
|
|
_serial->writeFrame(out_frame, 1 + PUB_KEY_SIZE);
|
|
}
|
|
} else {
|
|
#ifdef DISPLAY_CLASS
|
|
if (_ui) _ui->notify(UIEventType::newContactMessage);
|
|
#endif
|
|
}
|
|
|
|
#if COMPANION_FEATURE_TEXT_TERMINAL
|
|
if (hasTerminalOutput() && _terminal_display.shouldShowAdvert()) {
|
|
Stream& output = terminalOutput();
|
|
output.printf("\r\nADVERT from -> %s\r\n", contact.name);
|
|
output.printf(" type: %s\r\n", terminalContactTypeName(contact.type));
|
|
output.print(" public key: ");
|
|
mesh::Utils::printHex(output, contact.id.pub_key, PUB_KEY_SIZE);
|
|
output.print("\r\n> ");
|
|
}
|
|
#endif
|
|
|
|
// add inbound-path to mem cache
|
|
if (path && mesh::Packet::isValidPathLen(path_len)) { // check path is valid
|
|
AdvertPath* p = advert_paths;
|
|
uint32_t oldest = 0xFFFFFFFF;
|
|
for (int i = 0; i < ADVERT_PATH_TABLE_SIZE; i++) { // check if already in table, otherwise evict oldest
|
|
if (memcmp(advert_paths[i].pubkey_prefix, contact.id.pub_key, sizeof(AdvertPath::pubkey_prefix)) == 0) {
|
|
p = &advert_paths[i]; // found
|
|
break;
|
|
}
|
|
if (advert_paths[i].recv_timestamp < oldest) {
|
|
oldest = advert_paths[i].recv_timestamp;
|
|
p = &advert_paths[i];
|
|
}
|
|
}
|
|
|
|
memcpy(p->pubkey_prefix, contact.id.pub_key, sizeof(p->pubkey_prefix));
|
|
strcpy(p->name, contact.name);
|
|
p->recv_timestamp = getRTCClock()->getCurrentTime();
|
|
p->path_len = mesh::Packet::copyPath(p->path, path, path_len);
|
|
}
|
|
|
|
ContactInfo* stored = lookupContactByPubKey(contact.id.pub_key, PUB_KEY_SIZE);
|
|
if (stored == &contact) scheduleContactWrite(contact);
|
|
updateGpsTelemetryPolicy();
|
|
}
|
|
|
|
static int sort_by_recent(const void *a, const void *b) {
|
|
return ((AdvertPath *) b)->recv_timestamp - ((AdvertPath *) a)->recv_timestamp;
|
|
}
|
|
|
|
int MyMesh::getRecentlyHeard(AdvertPath dest[], int max_num) {
|
|
if (max_num > ADVERT_PATH_TABLE_SIZE) max_num = ADVERT_PATH_TABLE_SIZE;
|
|
mesh::sortSmallTable(advert_paths, ADVERT_PATH_TABLE_SIZE, sizeof(advert_paths[0]), sort_by_recent);
|
|
|
|
for (int i = 0; i < max_num; i++) {
|
|
dest[i] = advert_paths[i];
|
|
}
|
|
return max_num;
|
|
}
|
|
|
|
#if defined(DISPLAY_CLASS) && !(defined(UI_NO_DISCOVER_SCREEN) && (UI_NO_DISCOVER_SCREEN + 0 != 0))
|
|
int MyMesh::getDiscoveredNodes(DiscoveredNode nodes[], int max_num) {
|
|
if (!nodes || max_num <= 0) return 0;
|
|
if (max_num > DISCOVERED_NODES_TABLE_SIZE) max_num = DISCOVERED_NODES_TABLE_SIZE;
|
|
if (max_num > disc_nodes_count) max_num = disc_nodes_count;
|
|
|
|
for (int i = 0; i < max_num; i++) {
|
|
nodes[i] = discovered_nodes[i];
|
|
}
|
|
return max_num;
|
|
}
|
|
|
|
bool MyMesh::requestRepeatersDiscovery() {
|
|
uint8_t cmd_bytes[6];
|
|
cmd_bytes[0] = CTL_TYPE_NODE_DISCOVER_REQ | 1; // DISCOVER_REQ | prefix only
|
|
cmd_bytes[1] = 0xFF; // Repeaters
|
|
getRNG()->random(&cmd_bytes[2], 4); // tag
|
|
disc_nodes_count = 0;
|
|
disc_node_req_active = false;
|
|
memcpy(&disc_node_req_tag, &cmd_bytes[2], sizeof(disc_node_req_tag));
|
|
mesh::Packet* req = createControlData(cmd_bytes, sizeof(cmd_bytes));
|
|
if (req) {
|
|
sendZeroHop(req);
|
|
disc_node_req_active = true;
|
|
disc_node_req_at = millis();
|
|
return disc_node_req_active;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
void MyMesh::checkControlDataForPendingDiscovery(uint8_t payload[], size_t p_len) {
|
|
if (!disc_node_req_active
|
|
|| static_cast<uint32_t>(millis() - disc_node_req_at) >= 30000UL
|
|
|| (p_len < 14)
|
|
|| ((payload[0] & 0xF0) != CTL_TYPE_NODE_DISCOVER_RESP)
|
|
|| (disc_nodes_count >= DISCOVERED_NODES_TABLE_SIZE)
|
|
|| (memcmp(&payload[2], &disc_node_req_tag, 4))) {
|
|
return;
|
|
}
|
|
memcpy(&discovered_nodes[disc_nodes_count].pubkey_prefix, &payload[6], 8);
|
|
discovered_nodes[disc_nodes_count].type = payload[0] & 0xF;
|
|
discovered_nodes[disc_nodes_count].snr_out = ((int8_t)payload[1]) / 4.0;
|
|
discovered_nodes[disc_nodes_count].snr_in = _radio->getLastSNR();
|
|
ContactInfo* c = lookupContactByPubKey(&payload[6], 8);
|
|
if (c != NULL) {
|
|
StrHelper::strncpy(discovered_nodes[disc_nodes_count].name, c->name,
|
|
sizeof(discovered_nodes[disc_nodes_count].name));
|
|
} else {
|
|
discovered_nodes[disc_nodes_count].name[0] = 0;
|
|
}
|
|
disc_nodes_count ++;
|
|
}
|
|
#endif
|
|
|
|
#if COMPANION_FEATURE_TEXT_TERMINAL
|
|
void MyMesh::onContactVisit(const ContactInfo& contact) {
|
|
if (contact.type == ADV_TYPE_NONE) return;
|
|
|
|
Stream& output = terminalOutput();
|
|
output.printf(" %s (%s) - ", contact.name, terminalContactTypeName(contact.type));
|
|
char relative_time[40];
|
|
int32_t seconds_from_now = contact.last_advert_timestamp - getRTCClock()->getCurrentTime();
|
|
AdvertTimeHelper::formatRelativeTimeDiff(relative_time, seconds_from_now, false);
|
|
output.println(relative_time);
|
|
}
|
|
#endif
|
|
|
|
void MyMesh::onContactPathUpdated(const ContactInfo &contact) {
|
|
out_frame[0] = PUSH_CODE_PATH_UPDATED;
|
|
memcpy(&out_frame[1], contact.id.pub_key, PUB_KEY_SIZE);
|
|
_serial->writeFrame(out_frame, 1 + PUB_KEY_SIZE); // NOTE: app may not be connected
|
|
|
|
scheduleContactWrite(contact);
|
|
|
|
#if COMPANION_FEATURE_TEXT_TERMINAL
|
|
if (hasTerminalOutput()) {
|
|
terminalOutput().printf("\r\nPATH updated -> %s\r\n> ", contact.name);
|
|
}
|
|
#endif
|
|
}
|
|
|
|
void MyMesh::clearExpectedAck(AckTableEntry& entry, bool cancel_retries) {
|
|
if (cancel_retries && entry.ack != 0 && !entry.confirmed) {
|
|
cancelActiveRetries(entry.retry_key);
|
|
}
|
|
memset(&entry, 0, sizeof(entry));
|
|
}
|
|
|
|
void MyMesh::expireExpectedAcks() {
|
|
unsigned long now = _ms->getMillis();
|
|
uint32_t nearest_delay = UINT32_MAX;
|
|
|
|
for (AckTableEntry* cursor = expected_ack_table;
|
|
cursor != expected_ack_table + EXPECTED_ACK_TABLE_SIZE; ++cursor) {
|
|
AckTableEntry& entry = *cursor;
|
|
if (entry.ack == 0) {
|
|
continue;
|
|
}
|
|
|
|
if (entry.reply_route != NULL
|
|
&& !_serial->isReplyRouteAvailable(entry.reply_route)) {
|
|
// The requesting transport owns only the eventual ACK notification,
|
|
// not the already-accepted radio transmission. Detach a vanished host
|
|
// without cancelling Mesh's retry sequence or forgetting the semantic
|
|
// ACK match; a late ACK can still stop those retries safely.
|
|
entry.reply_route = NULL;
|
|
}
|
|
|
|
// Use one 32-bit clock snapshot for this pass, including counter wrap.
|
|
const bool expired = (int32_t)((uint32_t)now - (uint32_t)entry.expires_at) >= 0;
|
|
if (entry.confirmed) {
|
|
// The radio delivery is complete. Retain only this captured transport's
|
|
// notification, with a fixed deadline independent of duplicate ACKs.
|
|
if (entry.reply_route == NULL || expired) {
|
|
clearExpectedAck(entry, false);
|
|
continue;
|
|
}
|
|
// Align both 32-bit fields while keeping the nine-byte wire format.
|
|
alignas(uint32_t) uint8_t storage[12];
|
|
uint8_t* confirmation = storage + 3;
|
|
confirmation[0] = PUSH_CODE_SEND_CONFIRMED;
|
|
memcpy(&confirmation[1], &entry.ack, 4);
|
|
uint32_t trip_time = entry.msg_sent;
|
|
memcpy(&confirmation[5], &trip_time, 4);
|
|
if (_serial->writeFrameToRoute(entry.reply_route, confirmation, 9) == 9) {
|
|
clearExpectedAck(entry, false);
|
|
continue;
|
|
}
|
|
} else if (expired) {
|
|
if (!hasActiveRetries(entry.retry_key)) {
|
|
#if COMPANION_FEATURE_TEXT_TERMINAL
|
|
if (entry.terminal_origin && hasTerminalOutput()) {
|
|
terminalOutput().print("\r\n ERROR: timed out, no ACK.\r\n> ");
|
|
}
|
|
#endif
|
|
clearExpectedAck(entry, false);
|
|
continue;
|
|
}
|
|
// Keep the semantic match alive while its lower-level retry sequence is
|
|
// active, so a newer app submission can replace that sequence cleanly.
|
|
entry.expires_at = (uint32_t)(now + EXPECTED_ACK_RETRY_RECHECK_MILLIS);
|
|
}
|
|
|
|
uint32_t delay = (uint32_t)(entry.expires_at - now);
|
|
if (entry.confirmed && delay > EXPECTED_ACK_HOST_RECHECK_MILLIS) {
|
|
delay = EXPECTED_ACK_HOST_RECHECK_MILLIS;
|
|
}
|
|
if (delay < nearest_delay) {
|
|
nearest_delay = delay;
|
|
}
|
|
}
|
|
|
|
has_next_ack_expiry = nearest_delay != UINT32_MAX;
|
|
next_ack_expiry = has_next_ack_expiry ? (uint32_t)(now + nearest_delay) : 0;
|
|
}
|
|
|
|
MyMesh::AckTableEntry* MyMesh::findPendingTextMessage(
|
|
const uint8_t text_fingerprint[MAX_HASH_SIZE], uint32_t message_timestamp) {
|
|
expireExpectedAcks();
|
|
for (AckTableEntry* cursor = expected_ack_table;
|
|
cursor != expected_ack_table + EXPECTED_ACK_TABLE_SIZE; ++cursor) {
|
|
AckTableEntry& entry = *cursor;
|
|
if (entry.ack != 0 && !entry.confirmed
|
|
&& entry.message_timestamp != message_timestamp
|
|
&& memcmp(entry.text_fingerprint, text_fingerprint, MAX_HASH_SIZE) == 0
|
|
&& hasActiveRetries(entry.retry_key)) {
|
|
return &entry;
|
|
}
|
|
}
|
|
return NULL;
|
|
}
|
|
|
|
void MyMesh::onContactReferenceChanged(const ContactInfo* previous, ContactInfo* replacement) {
|
|
for (AckTableEntry* entry = expected_ack_table;
|
|
entry != expected_ack_table + EXPECTED_ACK_TABLE_SIZE; ++entry) {
|
|
if (entry->ack != 0 && entry->contact == previous) entry->contact = replacement;
|
|
}
|
|
}
|
|
|
|
bool MyMesh::processAck(const uint8_t *data, ContactInfo*& peer) {
|
|
peer = NULL;
|
|
expireExpectedAcks();
|
|
uint32_t incoming;
|
|
memcpy(&incoming, data, sizeof(incoming));
|
|
|
|
// see if matches any in a table
|
|
for (AckTableEntry* cursor = expected_ack_table;
|
|
cursor != expected_ack_table + EXPECTED_ACK_TABLE_SIZE; ++cursor) {
|
|
AckTableEntry& entry = *cursor;
|
|
if (entry.ack != 0
|
|
&& incoming == entry.ack) { // got an ACK from recipient
|
|
// A duplicate must not cancel a newer chat timeout or learn another
|
|
// return path merely because its host notification is still queued.
|
|
if (entry.confirmed) continue;
|
|
ContactInfo* contact = entry.contact;
|
|
const unsigned long now = _ms->getMillis();
|
|
entry.msg_sent = (uint32_t)(now - entry.msg_sent);
|
|
entry.expires_at = (uint32_t)(now + EXPECTED_ACK_HOST_GRACE_MILLIS);
|
|
entry.confirmed = true;
|
|
cancelActiveRetries(entry.retry_key);
|
|
#if COMPANION_FEATURE_TEXT_TERMINAL
|
|
if (entry.terminal_origin && hasTerminalOutput()) {
|
|
terminalOutput().printf("\r\n Got ACK! (round trip: %lu ms)\r\n> ",
|
|
entry.msg_sent);
|
|
}
|
|
#endif
|
|
#if MESH_ENABLE_ONE_KEY_DM
|
|
if (contact != NULL && contact->type == ADV_TYPE_CHAT) rememberOneKeyAck(*contact);
|
|
#endif
|
|
expireExpectedAcks();
|
|
peer = contact;
|
|
return true;
|
|
}
|
|
}
|
|
return checkConnectionsAck(data, peer);
|
|
}
|
|
|
|
#if MESH_ENABLE_ONE_KEY_DM
|
|
bool MyMesh::hasOneKeyAck(const ContactInfo& contact) const {
|
|
for (uint8_t i = 0; i < one_key_peer_count; ++i) {
|
|
if (one_key_peers[i].status == 1 &&
|
|
memcmp(one_key_peers[i].pub_key, contact.id.pub_key, PUB_KEY_SIZE) == 0) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
bool MyMesh::hasOneKeyReject(const ContactInfo& contact) const {
|
|
for (uint8_t i = 0; i < one_key_peer_count; ++i) {
|
|
if (one_key_peers[i].status == 2 &&
|
|
memcmp(one_key_peers[i].pub_key, contact.id.pub_key, PUB_KEY_SIZE) == 0) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
void MyMesh::rememberOneKeyIntro(const ContactInfo& contact, uint32_t tag) {
|
|
for (uint8_t i = 0; i < one_key_peer_count; ++i) {
|
|
if (memcmp(one_key_peers[i].pub_key, contact.id.pub_key, PUB_KEY_SIZE) == 0) {
|
|
one_key_peers[i].intro_tag = tag;
|
|
one_key_peers[i].status = 0;
|
|
return;
|
|
}
|
|
}
|
|
OneKeyPeerState& peer = one_key_peers[one_key_peer_next];
|
|
memcpy(peer.pub_key, contact.id.pub_key, PUB_KEY_SIZE);
|
|
peer.intro_tag = tag;
|
|
peer.status = 0;
|
|
if (one_key_peer_count < ONE_KEY_PEERS) ++one_key_peer_count;
|
|
one_key_peer_next = (one_key_peer_next + 1) % ONE_KEY_PEERS;
|
|
}
|
|
|
|
void MyMesh::rememberOneKeyAck(const ContactInfo& contact) {
|
|
if (hasOneKeyAck(contact)) return;
|
|
for (uint8_t i = 0; i < one_key_peer_count; ++i) {
|
|
if (memcmp(one_key_peers[i].pub_key, contact.id.pub_key, PUB_KEY_SIZE) == 0) {
|
|
one_key_peers[i].status = 1;
|
|
return;
|
|
}
|
|
}
|
|
OneKeyPeerState& peer = one_key_peers[one_key_peer_next];
|
|
memcpy(peer.pub_key, contact.id.pub_key, PUB_KEY_SIZE);
|
|
peer.intro_tag = 0;
|
|
peer.status = 1;
|
|
if (one_key_peer_count < ONE_KEY_PEERS) ++one_key_peer_count;
|
|
one_key_peer_next = (one_key_peer_next + 1) % ONE_KEY_PEERS;
|
|
}
|
|
|
|
bool MyMesh::rememberOneKeyReject(const ContactInfo& contact, uint32_t tag) {
|
|
for (uint8_t i = 0; i < one_key_peer_count; ++i) {
|
|
OneKeyPeerState& peer = one_key_peers[i];
|
|
if (peer.status == 0 && peer.intro_tag == tag &&
|
|
memcmp(peer.pub_key, contact.id.pub_key, PUB_KEY_SIZE) == 0) {
|
|
peer.status = 2;
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
uint32_t MyMesh::sendOneKeyIntroduction(const ContactInfo& contact) {
|
|
// ANON_REQ carries the sender's full key in its outer header. It therefore
|
|
// reaches a recipient that cannot yet decrypt a normal text packet, while
|
|
// old Companion firmware simply ignores this unfamiliar request body.
|
|
uint8_t body[4 + sizeof(ONE_KEY_INTRO_MARKER) + 32 + SIGNATURE_SIZE];
|
|
const uint32_t tag = getRTCClock()->getCurrentTimeUnique();
|
|
memcpy(body, &tag, sizeof(tag));
|
|
memcpy(body + 4, ONE_KEY_INTRO_MARKER, sizeof(ONE_KEY_INTRO_MARKER));
|
|
char fallback_name[20];
|
|
snprintf(fallback_name, sizeof(fallback_name), "Unknown %02X%02X%02X",
|
|
self_id.pub_key[0], self_id.pub_key[1], self_id.pub_key[2]);
|
|
const char* name = _prefs.node_name[0] ? _prefs.node_name : fallback_name;
|
|
const size_t name_len = strnlen(name, 31);
|
|
memcpy(body + 4 + sizeof(ONE_KEY_INTRO_MARKER), name, name_len);
|
|
body[4 + sizeof(ONE_KEY_INTRO_MARKER) + name_len] = 0;
|
|
const size_t signed_body_len = 4 + sizeof(ONE_KEY_INTRO_MARKER) + name_len + 1;
|
|
uint8_t signed_message[PUB_KEY_SIZE + 4 + sizeof(ONE_KEY_INTRO_MARKER) + 32];
|
|
memcpy(signed_message, contact.id.pub_key, PUB_KEY_SIZE);
|
|
memcpy(signed_message + PUB_KEY_SIZE, body, signed_body_len);
|
|
self_id.sign(body + signed_body_len, signed_message,
|
|
PUB_KEY_SIZE + signed_body_len);
|
|
|
|
mesh::Packet* intro = createAnonDatagram(
|
|
PAYLOAD_TYPE_ANON_REQ, self_id, contact.id,
|
|
contact.getSharedSecret(self_id), body,
|
|
signed_body_len + SIGNATURE_SIZE);
|
|
if (intro == NULL) return 0;
|
|
const uint32_t airtime = getTransmitAirtime(intro);
|
|
uint32_t delay;
|
|
bool sent;
|
|
if (contact.out_path_len == OUT_PATH_UNKNOWN) {
|
|
delay = calcFloodTimeoutMillisFor(airtime) / 2;
|
|
sent = sendFloodScoped(contact, intro);
|
|
} else {
|
|
const uint32_t hops = (contact.out_path_len & 63) + 1;
|
|
delay = (airtime * 3 + 500) * hops;
|
|
sent = sendDirect(intro, contact.getPath(), contact.out_path_len);
|
|
}
|
|
// Scheduling the text after the introduction also lets the ordinary text
|
|
// path work on older recipients which already know this sender.
|
|
if (sent) rememberOneKeyIntro(contact, tag);
|
|
return sent ? delay : 0;
|
|
}
|
|
|
|
void MyMesh::onAnonDataRecv(mesh::Packet* packet, const uint8_t* secret,
|
|
const mesh::Identity& sender, uint8_t* data,
|
|
size_t len) {
|
|
if (packet->getPayloadType() != PAYLOAD_TYPE_ANON_REQ
|
|
|| len < 4 + sizeof(ONE_KEY_INTRO_MARKER) + 2 + SIGNATURE_SIZE
|
|
|| memcmp(data + 4, ONE_KEY_INTRO_MARKER,
|
|
sizeof(ONE_KEY_INTRO_MARKER)) != 0) return;
|
|
|
|
const char* name = reinterpret_cast<const char*>(data + 4 + sizeof(ONE_KEY_INTRO_MARKER));
|
|
const size_t available = min(size_t(32), len - 4 - sizeof(ONE_KEY_INTRO_MARKER) - SIGNATURE_SIZE);
|
|
const char* end = static_cast<const char*>(memchr(name, 0, available));
|
|
if (end == NULL || end == name || size_t(end - name) >= sizeof(ContactInfo::name)) return;
|
|
const size_t signed_body_len = 4 + sizeof(ONE_KEY_INTRO_MARKER) + size_t(end - name) + 1;
|
|
if (signed_body_len + SIGNATURE_SIZE > len) return;
|
|
uint8_t signed_message[PUB_KEY_SIZE + 4 + sizeof(ONE_KEY_INTRO_MARKER) + 32];
|
|
memcpy(signed_message, self_id.pub_key, PUB_KEY_SIZE);
|
|
memcpy(signed_message + PUB_KEY_SIZE, data, signed_body_len);
|
|
if (!sender.verify(data + signed_body_len, signed_message,
|
|
PUB_KEY_SIZE + signed_body_len)) return;
|
|
if (lookupPersistentContactByPubKey(sender.pub_key, PUB_KEY_SIZE) != NULL) return;
|
|
|
|
ContactInfo contact = {};
|
|
memcpy(contact.id.pub_key, sender.pub_key, PUB_KEY_SIZE);
|
|
StrHelper::strncpy(contact.name, name, sizeof(contact.name));
|
|
contact.type = ADV_TYPE_CHAT;
|
|
contact.out_path_len = OUT_PATH_UNKNOWN;
|
|
contact.lastmod = getRTCClock()->getCurrentTime();
|
|
if (_prefs.one_key_dm_enabled != 1) {
|
|
rememberVerifiedPendingSender(sender.pub_key);
|
|
// Synthetic advert for the attached app's normal manual-add flow. It is
|
|
// never added to the radio peer table until the user accepts it.
|
|
onDiscoveredContact(contact, true, packet->path_len, packet->path);
|
|
|
|
uint8_t refusal[4 + sizeof(ONE_KEY_REJECT_MARKER) + SIGNATURE_SIZE];
|
|
memcpy(refusal, data, 4);
|
|
memcpy(refusal + 4, ONE_KEY_REJECT_MARKER, sizeof(ONE_KEY_REJECT_MARKER));
|
|
uint8_t signed_refusal[PUB_KEY_SIZE + 4 + sizeof(ONE_KEY_REJECT_MARKER)];
|
|
memcpy(signed_refusal, sender.pub_key, PUB_KEY_SIZE);
|
|
memcpy(signed_refusal + PUB_KEY_SIZE, refusal, 4 + sizeof(ONE_KEY_REJECT_MARKER));
|
|
self_id.sign(refusal + 4 + sizeof(ONE_KEY_REJECT_MARKER),
|
|
signed_refusal, sizeof(signed_refusal));
|
|
mesh::Packet* reply = createDatagram(PAYLOAD_TYPE_RESPONSE, sender, secret,
|
|
refusal, sizeof(refusal));
|
|
if (reply != NULL) sendFloodScoped(contact, reply, 300);
|
|
return;
|
|
}
|
|
if (!canMutateContacts()) return;
|
|
if (!addContact(contact)) {
|
|
onContactsFull();
|
|
return;
|
|
}
|
|
ContactInfo* added = lookupPersistentContactByPubKey(sender.pub_key, PUB_KEY_SIZE);
|
|
if (added == NULL || !scheduleContactWrite(*added)) {
|
|
if (added != NULL) removeContact(*added);
|
|
return;
|
|
}
|
|
// A prior anonymous service request may occupy a transient slot for this
|
|
// key. Remove it so regular text lookup finds the durable contact first.
|
|
ContactInfo* transient = lookupTransientContactByPubKey(sender.pub_key, PUB_KEY_SIZE);
|
|
if (transient != NULL) clearTransientContact(*transient, added);
|
|
onDiscoveredContact(*added, true, OUT_PATH_UNKNOWN, NULL);
|
|
}
|
|
|
|
void MyMesh::rememberVerifiedPendingSender(const uint8_t* pub_key) {
|
|
for (uint8_t i = 0; i < verified_pending_count; ++i) {
|
|
if (memcmp(verified_pending_keys[i], pub_key, PUB_KEY_SIZE) == 0) return;
|
|
}
|
|
if (verified_pending_count == MAX_HELD_ONE_KEY_DMS) {
|
|
for (uint8_t i = 1; i < verified_pending_count; ++i) {
|
|
memcpy(verified_pending_keys[i - 1], verified_pending_keys[i], PUB_KEY_SIZE);
|
|
}
|
|
--verified_pending_count;
|
|
}
|
|
memcpy(verified_pending_keys[verified_pending_count++], pub_key, PUB_KEY_SIZE);
|
|
}
|
|
|
|
void MyMesh::forgetVerifiedPendingSender(const uint8_t* pub_key) {
|
|
for (uint8_t i = 0; i < verified_pending_count; ++i) {
|
|
if (memcmp(verified_pending_keys[i], pub_key, PUB_KEY_SIZE) == 0) {
|
|
for (uint8_t j = i + 1; j < verified_pending_count; ++j) {
|
|
memcpy(verified_pending_keys[j - 1], verified_pending_keys[j], PUB_KEY_SIZE);
|
|
}
|
|
--verified_pending_count;
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
void MyMesh::makeOneKeyDMId(uint8_t id[ONE_KEY_DM_ID_SIZE], uint32_t timestamp,
|
|
const char* text) {
|
|
mesh::Utils::sha256(id, ONE_KEY_DM_ID_SIZE,
|
|
reinterpret_cast<const uint8_t*>(×tamp), sizeof(timestamp),
|
|
reinterpret_cast<const uint8_t*>(text), strlen(text));
|
|
}
|
|
|
|
bool MyMesh::wasDeliveredOneKeyDM(const uint8_t* pub_key,
|
|
const uint8_t id[ONE_KEY_DM_ID_SIZE]) const {
|
|
for (uint8_t i = 0; i < delivered_dm_count; ++i) {
|
|
if (memcmp(delivered_dms[i].sender_key, pub_key, PUB_KEY_SIZE) == 0 &&
|
|
memcmp(delivered_dms[i].id, id, ONE_KEY_DM_ID_SIZE) == 0) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
void MyMesh::rememberDeliveredOneKeyDM(const uint8_t* pub_key,
|
|
const uint8_t id[ONE_KEY_DM_ID_SIZE]) {
|
|
if (wasDeliveredOneKeyDM(pub_key, id)) return;
|
|
DeliveredOneKeyDM& entry = delivered_dms[delivered_dm_next];
|
|
memcpy(entry.sender_key, pub_key, PUB_KEY_SIZE);
|
|
memcpy(entry.id, id, ONE_KEY_DM_ID_SIZE);
|
|
if (delivered_dm_count < MAX_HELD_ONE_KEY_DMS) ++delivered_dm_count;
|
|
delivered_dm_next = (delivered_dm_next + 1) % MAX_HELD_ONE_KEY_DMS;
|
|
}
|
|
|
|
bool MyMesh::onAddressedTextPacket(mesh::Packet* packet, uint8_t src_hash,
|
|
const uint8_t* mac_and_data, size_t len) {
|
|
for (uint8_t i = 0; i < verified_pending_count; ++i) {
|
|
const uint8_t* pub_key = verified_pending_keys[i];
|
|
if (pub_key[0] != src_hash) continue;
|
|
if (lookupPersistentContactByPubKey(pub_key, PUB_KEY_SIZE) != NULL) continue;
|
|
|
|
uint8_t secret[PUB_KEY_SIZE];
|
|
self_id.calcSharedSecret(secret, pub_key);
|
|
uint8_t data[MAX_PACKET_PAYLOAD + 1];
|
|
const int decoded_len = mesh::Utils::MACThenDecrypt(secret, data, mac_and_data, len);
|
|
if (decoded_len <= 5 || decoded_len > MAX_PACKET_PAYLOAD) continue;
|
|
data[decoded_len] = 0;
|
|
if ((data[4] >> 2) != TXT_TYPE_PLAIN) continue;
|
|
const char* text = reinterpret_cast<const char*>(&data[5]);
|
|
if (strlen(text) > MAX_TEXT_LEN) continue;
|
|
|
|
uint32_t timestamp;
|
|
memcpy(×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 ONE_KEY_DM_COMPACT_HELD
|
|
uint8_t held_id[ONE_KEY_DM_ID_SIZE];
|
|
makeOneKeyDMId(held_id, held_dms[j].timestamp, held_dms[j].text);
|
|
#else
|
|
const uint8_t* held_id = held_dms[j].id;
|
|
#endif
|
|
if (memcmp(held_dms[j].sender_key, pub_key, PUB_KEY_SIZE) == 0 &&
|
|
memcmp(held_id, id, ONE_KEY_DM_ID_SIZE) == 0) return true;
|
|
}
|
|
|
|
// Only verified and decryptable messages count toward the FIFO of 15.
|
|
#if ONE_KEY_DM_SHARED_OFFLINE_QUEUE
|
|
if (held_dm_count == MAX_HELD_ONE_KEY_DMS) removeHeldOneKeyDM(0);
|
|
if (offline_queue_len + held_dm_count >= getOfflineQueueCapacity()) {
|
|
// Preserve private messages already waiting for the app. As with the
|
|
// ordinary queue, an old channel message may be displaced instead.
|
|
int pos = 0;
|
|
while (pos < offline_queue_len && !offlineQueueFrameAt(pos).isChannelMsg()) ++pos;
|
|
if (pos == offline_queue_len) return false;
|
|
for (int j = pos; j < offline_queue_len - 1; ++j) {
|
|
offlineQueueFrameAt(j) = offlineQueueFrameAt(j + 1);
|
|
}
|
|
--offline_queue_len;
|
|
#ifdef DISPLAY_CLASS
|
|
if (_ui) _ui->syncMessageQueue(offline_queue_len, pos);
|
|
#endif
|
|
}
|
|
HeldOneKeyDM& entry = held_dms[held_dm_count];
|
|
memcpy(entry.sender_key, pub_key, PUB_KEY_SIZE);
|
|
memcpy(entry.id, id, ONE_KEY_DM_ID_SIZE);
|
|
Frame& held = heldDMFrameAt(held_dm_count++);
|
|
memcpy(held.buf, ×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);
|
|
#if ONE_KEY_DM_COMPACT_HELD
|
|
entry.timestamp = timestamp;
|
|
// Preserve the exact ACK of the authenticated original plaintext,
|
|
// including its attempt/type byte; the delivery ID is derived separately.
|
|
mesh::Utils::sha256(entry.ack_hash, sizeof(entry.ack_hash), data,
|
|
5 + strlen(text), pub_key, PUB_KEY_SIZE);
|
|
entry.path_len = packet->isRouteFlood() ? packet->path_len : 0xFF;
|
|
entry.snr = packet->_snr;
|
|
memcpy(entry.text, text, strlen(text) + 1);
|
|
#else
|
|
memcpy(entry.id, id, ONE_KEY_DM_ID_SIZE);
|
|
entry.packet = *packet;
|
|
#endif
|
|
#endif
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
void MyMesh::releaseHeldOneKeyDMs() {
|
|
for (uint8_t i = 0; i < verified_pending_count; ) {
|
|
if (lookupPersistentContactByPubKey(verified_pending_keys[i], PUB_KEY_SIZE) != NULL) {
|
|
uint8_t key[PUB_KEY_SIZE];
|
|
memcpy(key, verified_pending_keys[i], PUB_KEY_SIZE);
|
|
forgetVerifiedPendingSender(key);
|
|
} else {
|
|
++i;
|
|
}
|
|
}
|
|
|
|
for (uint8_t i = 0; i < held_dm_count; ) {
|
|
HeldOneKeyDM& entry = held_dms[i];
|
|
ContactInfo* contact = lookupPersistentContactByPubKey(entry.sender_key, PUB_KEY_SIZE);
|
|
if (contact == NULL) { ++i; continue; }
|
|
#if ONE_KEY_DM_SHARED_OFFLINE_QUEUE
|
|
// The held frame itself occupies the slot needed for ordinary delivery.
|
|
// Copy its small payload before removing it from the tail of the pool.
|
|
Frame& held = heldDMFrameAt(i);
|
|
uint32_t timestamp;
|
|
memcpy(×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<int8_t>(held.buf[9]);
|
|
uint8_t text_len = held.buf[10];
|
|
char text[MAX_TEXT_LEN + 1];
|
|
if (held.len != 12 + text_len || text_len > MAX_TEXT_LEN ||
|
|
held.buf[11 + text_len] != 0) {
|
|
removeHeldOneKeyDM(i);
|
|
continue;
|
|
}
|
|
memcpy(text, &held.buf[11], text_len + 1);
|
|
uint8_t sender_key[PUB_KEY_SIZE];
|
|
uint8_t id[ONE_KEY_DM_ID_SIZE];
|
|
memcpy(sender_key, entry.sender_key, sizeof(sender_key));
|
|
memcpy(id, entry.id, sizeof(id));
|
|
removeHeldOneKeyDM(i);
|
|
if (!wasDeliveredOneKeyDM(sender_key, id)) {
|
|
mesh::Packet packet;
|
|
packet.header = path_len == 0xFF ? ROUTE_TYPE_DIRECT : ROUTE_TYPE_FLOOD;
|
|
packet.path_len = path_len == 0xFF ? 0 : path_len;
|
|
packet._snr = snr;
|
|
onMessageRecv(*contact, &packet, timestamp, text);
|
|
rememberDeliveredOneKeyDM(sender_key, id);
|
|
}
|
|
sendAckTo(*contact, ack_hash, sizeof(ack_hash));
|
|
#else
|
|
if (offline_queue_len >= getOfflineQueueCapacity()) break;
|
|
|
|
#if ONE_KEY_DM_COMPACT_HELD
|
|
uint8_t id[ONE_KEY_DM_ID_SIZE];
|
|
makeOneKeyDMId(id, entry.timestamp, entry.text);
|
|
if (!wasDeliveredOneKeyDM(entry.sender_key, id)) {
|
|
mesh::Packet packet;
|
|
packet.header = entry.path_len == 0xFF ? ROUTE_TYPE_DIRECT : ROUTE_TYPE_FLOOD;
|
|
packet.path_len = entry.path_len == 0xFF ? 0 : entry.path_len;
|
|
packet._snr = entry.snr;
|
|
onMessageRecv(*contact, &packet, entry.timestamp, entry.text);
|
|
rememberDeliveredOneKeyDM(entry.sender_key, id);
|
|
}
|
|
sendAckTo(*contact, entry.ack_hash, sizeof(entry.ack_hash));
|
|
#else
|
|
uint8_t data[MAX_PACKET_PAYLOAD + 1];
|
|
const int decoded_len = mesh::Utils::MACThenDecrypt(
|
|
contact->getSharedSecret(self_id), data, &entry.packet.payload[2],
|
|
entry.packet.payload_len - 2);
|
|
if (decoded_len > 5 && decoded_len <= MAX_PACKET_PAYLOAD) {
|
|
data[decoded_len] = 0;
|
|
if ((data[4] >> 2) == TXT_TYPE_PLAIN) {
|
|
const char* text = reinterpret_cast<const char*>(&data[5]);
|
|
uint32_t timestamp;
|
|
memcpy(×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));
|
|
}
|
|
}
|
|
}
|
|
#endif
|
|
for (uint8_t j = i + 1; j < held_dm_count; ++j) held_dms[j - 1] = held_dms[j];
|
|
--held_dm_count;
|
|
#endif
|
|
}
|
|
}
|
|
#endif // MESH_ENABLE_ONE_KEY_DM
|
|
|
|
bool MyMesh::queueMessage(const ContactInfo &from, uint8_t txt_type, mesh::Packet *pkt,
|
|
uint32_t sender_timestamp, const uint8_t *extra,
|
|
int extra_len, const char *text,
|
|
bool terminal_command_reply,
|
|
uint32_t terminal_command_elapsed_millis) {
|
|
int i = 0;
|
|
if (app_target_ver >= 3) {
|
|
out_frame[i++] = RESP_CODE_CONTACT_MSG_RECV_V3;
|
|
out_frame[i++] = (int8_t)(pkt->getSNR() * 4);
|
|
out_frame[i++] = 0; // reserved1
|
|
out_frame[i++] = 0; // reserved2
|
|
} else {
|
|
out_frame[i++] = RESP_CODE_CONTACT_MSG_RECV;
|
|
}
|
|
memcpy(&out_frame[i], from.id.pub_key, 6);
|
|
i += 6; // just 6-byte prefix
|
|
uint8_t path_len = out_frame[i++] = pkt->isRouteFlood() ? pkt->path_len : 0xFF;
|
|
out_frame[i++] = txt_type;
|
|
memcpy(&out_frame[i], &sender_timestamp, 4);
|
|
i += 4;
|
|
if (extra_len > 0) {
|
|
memcpy(&out_frame[i], extra, extra_len);
|
|
i += extra_len;
|
|
}
|
|
int tlen = strlen(text); // TODO: UTF-8 ??
|
|
if (i + tlen > MAX_FRAME_SIZE) {
|
|
tlen = MAX_FRAME_SIZE - i;
|
|
}
|
|
memcpy(&out_frame[i], text, tlen);
|
|
i += tlen;
|
|
const char* sensitive_reply = text;
|
|
if (isxdigit((unsigned char)text[0]) && isxdigit((unsigned char)text[1])
|
|
&& text[2] == '|') sensitive_reply += 3;
|
|
if (txt_type == TXT_TYPE_CLI_DATA
|
|
&& strncmp(sensitive_reply, "KEYBACKUP ", 10) == 0) {
|
|
// Identity exports must never enter the normal offline message queue or
|
|
// text terminal (both retain/echo plaintext). Deliver once to a live
|
|
// directly attached app; if that link is gone the requester can retry.
|
|
const bool expected = strlen(sensitive_reply) == 10 + 16 + 1 + PRV_KEY_SIZE * 2
|
|
&& private_key_backup_route != NULL
|
|
&& _serial->isReplyRouteAvailable(private_key_backup_route)
|
|
&& !millisHasNowPassed(private_key_backup_deadline)
|
|
&& memcmp(from.id.pub_key, private_key_backup_sender, 6) == 0
|
|
&& strncmp(sensitive_reply + 10, private_key_backup_nonce, 16) == 0
|
|
&& sensitive_reply[26] == ' ';
|
|
if (expected) {
|
|
_serial->writeFrameToRoute(private_key_backup_route, out_frame, i);
|
|
}
|
|
private_key_backup_route = NULL;
|
|
private_key_backup_deadline = 0;
|
|
memset(private_key_backup_nonce, 0, sizeof(private_key_backup_nonce));
|
|
memset(private_key_backup_sender, 0, sizeof(private_key_backup_sender));
|
|
memset(out_frame, 0, i);
|
|
return false;
|
|
}
|
|
const bool queued = addToOfflineQueue(out_frame, i);
|
|
// Signed room posts are retried by the server until retained. Do not make
|
|
// an unretained post visible as delivered or notify the user about it.
|
|
if (txt_type == TXT_TYPE_SIGNED_PLAIN && !queued) return false;
|
|
|
|
if (_serial->isConnected()) {
|
|
uint8_t frame[1];
|
|
frame[0] = PUSH_CODE_MSG_WAITING; // send push 'tickle'
|
|
_serial->writeFrame(frame, 1);
|
|
}
|
|
|
|
#if COMPANION_FEATURE_TEXT_TERMINAL
|
|
if (hasTerminalOutput()) {
|
|
Stream& output = terminalOutput();
|
|
const char* kind = txt_type == TXT_TYPE_CLI_DATA ? "CLI" : "MSG";
|
|
output.printf("\r\n(%s) %s -> from %s",
|
|
mesh::cli::terminalInboundRouteLabel(pkt->isRouteDirect()),
|
|
kind, from.name);
|
|
if (terminal_command_reply) {
|
|
output.printf(" (round trip %lu ms)",
|
|
(unsigned long)terminal_command_elapsed_millis);
|
|
}
|
|
output.printf("\r\n %s\r\n> ", text);
|
|
}
|
|
#endif
|
|
|
|
#if COMPANION_FEATURE_NOTIFICATIONS
|
|
const bool plain_notification = txt_type == TXT_TYPE_PLAIN || txt_type == TXT_TYPE_SIGNED_PLAIN;
|
|
const bool remote_notification = plain_notification
|
|
&& _notifications.remoteMessage(from.id.pub_key, text, millis(), sender_timestamp);
|
|
const bool notification_managed = remote_notification || (plain_notification
|
|
&& _notifications.message(from.id.pub_key, nullptr, _serial->isConnected(), millis()));
|
|
if (_ui) _ui->setMessageNotificationOverride(notification_managed, _notifications.overridesScreen());
|
|
#else
|
|
const bool notification_managed = false;
|
|
#endif
|
|
|
|
#ifdef DISPLAY_CLASS
|
|
// we only want to show text messages on display, not cli data
|
|
bool should_display = txt_type == TXT_TYPE_PLAIN || txt_type == TXT_TYPE_SIGNED_PLAIN;
|
|
if (should_display && _ui) {
|
|
_ui->newMsg(path_len, from.name, text, offline_queue_len,
|
|
-1, nullptr, queued ? offline_queue_len - 1 : -1);
|
|
if (!notification_managed && !_serial->isConnected()) {
|
|
_ui->notify(UIEventType::contactMessage);
|
|
}
|
|
}
|
|
#endif
|
|
if (_ui) _ui->setMessageNotificationOverride(false);
|
|
return queued;
|
|
}
|
|
|
|
static uint16_t emergencyClientRepeatKey(const mesh::Packet* packet) {
|
|
const uint8_t* p = packet->payload;
|
|
return ((uint16_t)p[1]) | ((uint16_t)p[2] << 8);
|
|
}
|
|
|
|
static bool __attribute__((noinline)) hasEmergencyClientRepeat(uint16_t key) {
|
|
for (uint8_t i = 0; i < EMERGENCY_CLIENT_REPEAT_TABLE_SIZE; i++) {
|
|
if (emergency_client_repeats[i] == key) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
bool MyMesh::filterRecvFloodPacket(mesh::Packet* packet) {
|
|
if (emergency_client_repeat_packet != NULL && packet->getPathHashCount() > 0
|
|
&& packet->radio_origin == emergency_client_repeat_packet->radio_origin) {
|
|
if (emergencyClientRepeatKey(packet) == emergency_client_repeat_key) {
|
|
releasePacket(emergency_client_repeat_packet);
|
|
emergency_client_repeat_packet = NULL;
|
|
}
|
|
}
|
|
// REVISIT: try to determine which Region (from transport_codes[1]) that Sender is indicating for replies/responses
|
|
// if unknown, fallback to finding Region from transport_codes[0], the 'scope' used by Sender
|
|
return false;
|
|
}
|
|
|
|
bool MyMesh::allowPacketForward(const mesh::Packet* packet) {
|
|
if (!_prefs.isRepeatEn()) return false;
|
|
#ifdef COMPANION_MESH_CLOCK_SYNC
|
|
_clock_sync.observeAcceptedFlood(packet);
|
|
#endif
|
|
return true;
|
|
}
|
|
|
|
bool MyMesh::isAliasRadio2Defined() const {
|
|
const auto profiles = _radio->profiles();
|
|
return profiles != nullptr && profiles->enabled();
|
|
}
|
|
|
|
bool MyMesh::resolveChannelAlias(ChannelDetails& channel) const {
|
|
uint8_t original_key[16];
|
|
memcpy(original_key, channel.channel.secret, sizeof(original_key));
|
|
if (!resolveCompanionChannelKey(channel.name, channel.channel.secret,
|
|
[](uint8_t* out, size_t size, const uint8_t* text, int length) {
|
|
mesh::Utils::sha256(out, size, text, length);
|
|
})) return false;
|
|
const auto kind = parseCompanionChannelAlias(channel.name).kind;
|
|
#if MESH_ENABLE_MESHTEXT_CHAT
|
|
if (isMeshtextChannel(channel)) channel.channel.tx_radio = mesh::RADIO_TX_OFF;
|
|
#endif
|
|
if (isAliasRadio2Defined()
|
|
&& (kind == CompanionChannelKind::Radio2 || kind == CompanionChannelKind::Meshtext)) {
|
|
channel.channel.tx_radio = kind == CompanionChannelKind::Radio2
|
|
? mesh::RADIO_TX_SECONDARY : mesh::RADIO_TX_OFF;
|
|
}
|
|
if (memcmp(original_key, channel.channel.secret, sizeof(original_key)) != 0) {
|
|
// BaseChatMesh already cached the hash for ordinary/private keys. Only a
|
|
// rewritten automatic alias needs another SHA-256 during receive scans.
|
|
mesh::Utils::sha256(channel.channel.hash, sizeof(channel.channel.hash), channel.channel.secret, 16);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
bool MyMesh::getChannel(int channel_idx, ChannelDetails& channel) {
|
|
return BaseChatMesh::getChannel(channel_idx, channel) && resolveChannelAlias(channel);
|
|
}
|
|
|
|
bool MyMesh::setChannel(int channel_idx, const ChannelDetails& channel) {
|
|
if (parseCompanionChannelAlias(channel.name).kind == CompanionChannelKind::Invalid) return false;
|
|
#if MESH_ENABLE_ETHERNET_TUNNEL
|
|
if (_ethernet_settings.enabled && channel.name[0]
|
|
&& parseCompanionChannelAlias(channel.name).kind != CompanionChannelKind::MeshCore) return false;
|
|
#endif
|
|
const bool saved = BaseChatMesh::setChannel(channel_idx, channel);
|
|
#if MESH_ENABLE_MESHTEXT_CHAT
|
|
if (saved) _mt_delivery_config_dirty = true;
|
|
#endif
|
|
#if MESH_ENABLE_ETHERNET_TUNNEL
|
|
if (saved) ++_ethernet_channel_generation;
|
|
#endif
|
|
return saved;
|
|
}
|
|
|
|
int MyMesh::searchChannelsForPacket(const mesh::Packet* packet, const uint8_t* hash,
|
|
mesh::GroupChannel matches[], int max_matches) {
|
|
int count = 0;
|
|
const bool radio2_defined = isAliasRadio2Defined();
|
|
for (int priority = 0; priority < 2; ++priority) {
|
|
for (int i = 0; i < MAX_GROUP_CHANNELS && count < max_matches; ++i) {
|
|
ChannelDetails channel;
|
|
if (!getChannel(i, channel) || channel.name[0] == 0
|
|
#if MESH_ENABLE_MESHTEXT_CHAT
|
|
|| isMeshtextChannel(channel)
|
|
#endif
|
|
|| companionChannelReceivePriority(channel.name, packet->radio_origin, radio2_defined) != priority
|
|
|| channel.channel.hash[0] != hash[0]) continue;
|
|
bool duplicate = false;
|
|
for (int j = 0; j < count; ++j) {
|
|
if (memcmp(matches[j].secret, channel.channel.secret, sizeof(channel.channel.secret)) == 0) {
|
|
duplicate = true;
|
|
break;
|
|
}
|
|
}
|
|
if (!duplicate) matches[count++] = channel.channel;
|
|
}
|
|
}
|
|
return count;
|
|
}
|
|
|
|
int MyMesh::findChannelIdxForPacket(const mesh::GroupChannel& key, const mesh::Packet* packet) {
|
|
const bool radio2_defined = isAliasRadio2Defined();
|
|
for (int priority = 0; priority < 2; ++priority) {
|
|
for (int i = 0; i < MAX_GROUP_CHANNELS; ++i) {
|
|
ChannelDetails channel;
|
|
if (getChannel(i, channel) && channel.name[0] != 0
|
|
#if MESH_ENABLE_MESHTEXT_CHAT
|
|
&& !isMeshtextChannel(channel)
|
|
#endif
|
|
&& companionChannelReceivePriority(channel.name, packet->radio_origin, radio2_defined) == priority
|
|
&& memcmp(channel.channel.secret, key.secret, sizeof(key.secret)) == 0) return i;
|
|
}
|
|
}
|
|
return -1;
|
|
}
|
|
|
|
bool MyMesh::allowGroupPacketOnRadio(mesh::Packet* packet) {
|
|
if (!isAliasRadio2Defined() || packet->payload_len < 1) return true;
|
|
if (packet->payload_len > MAX_PACKET_PAYLOAD) return false;
|
|
// Reject only an authenticated key match. The one-byte channel hash alone
|
|
// must not suppress unrelated channels or opaque relaying.
|
|
for (int i = 0; i < MAX_GROUP_CHANNELS; ++i) {
|
|
ChannelDetails channel;
|
|
if (!getChannel(i, channel) || channel.name[0] == 0
|
|
|| channel.channel.hash[0] != packet->payload[0]) continue;
|
|
const auto alias = parseCompanionChannelAlias(channel.name);
|
|
if (alias.kind != CompanionChannelKind::Radio2
|
|
&& alias.kind != CompanionChannelKind::Meshtext) continue;
|
|
uint8_t data[MAX_PACKET_PAYLOAD];
|
|
if (mesh::Utils::MACThenDecrypt(channel.channel.secret, data, packet->payload + 1,
|
|
packet->payload_len - 1) <= 0) continue;
|
|
const int selected = findChannelIdxForPacket(channel.channel, packet);
|
|
if (selected < 0) return false;
|
|
ChannelDetails selected_channel;
|
|
if (getChannel(selected, selected_channel)
|
|
&& parseCompanionChannelAlias(selected_channel.name).kind == CompanionChannelKind::Radio2) {
|
|
// Also bind direct-path relaying, which runs before local delivery.
|
|
packet->tx_radio = mesh::RADIO_TX_SECONDARY;
|
|
}
|
|
return true;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
void MyMesh::onGroupDataRecv(mesh::Packet* packet, uint8_t type,
|
|
const mesh::GroupChannel& key, uint8_t* data, size_t len) {
|
|
const int index = findChannelIdxForPacket(key, packet);
|
|
if (index < 0) return;
|
|
ChannelDetails channel;
|
|
if (getChannel(index, channel) && isAliasRadio2Defined()
|
|
&& parseCompanionChannelAlias(channel.name).kind == CompanionChannelKind::Radio2) {
|
|
// Forwarding and the emergency hold copy stay on the originating profile.
|
|
packet->tx_radio = mesh::RADIO_TX_SECONDARY;
|
|
}
|
|
BaseChatMesh::onGroupDataRecv(packet, type, key, data, len);
|
|
}
|
|
|
|
#if defined(COMPANION_MESH_CLOCK_SYNC) || defined(MESHCORE_HAS_COLOR_DISPLAY)
|
|
void MyMesh::onAdvertRecv(mesh::Packet* packet, const mesh::Identity& id,
|
|
uint32_t timestamp, const uint8_t* app_data,
|
|
size_t app_data_len) {
|
|
#if defined(MESHCORE_HAS_COLOR_DISPLAY)
|
|
if (!isLocalContactImport(packet)) {
|
|
mesh::ui::observeDisplayNightAdvert(packet, id, self_id, app_data,
|
|
app_data_len, millis());
|
|
}
|
|
#endif
|
|
BaseChatMesh::onAdvertRecv(packet, id, timestamp, app_data, app_data_len);
|
|
#ifdef COMPANION_MESH_CLOCK_SYNC
|
|
_clock_sync.observeVerifiedAdvert(packet, id, timestamp);
|
|
#endif
|
|
}
|
|
#endif
|
|
|
|
#ifdef COMPANION_MESH_CLOCK_SYNC
|
|
void MyMesh::onGroupPacketRecv(mesh::Packet* packet) {
|
|
_clock_sync.observeGroupPacket(packet);
|
|
}
|
|
#endif
|
|
|
|
bool MyMesh::allowFloodRetry(const mesh::Packet* packet) const {
|
|
if (_morse_reply.ownsReply(packet)) return false;
|
|
if (packet == NULL || _prefs.flood_retry_attempts == 0) return false;
|
|
// A companion may retry its own advert once, using the core's deliberately
|
|
// slow origin-advert delay. Do not add retries while relaying a neighbour's
|
|
// advert; the ordinary forwarding and recent-echo guard still apply.
|
|
return packet->getPayloadType() != PAYLOAD_TYPE_ADVERT
|
|
|| (_prefs.flood_retry_advert_enabled && isSelfOriginAdvert(packet));
|
|
}
|
|
|
|
uint8_t MyMesh::getFloodRetryMaxPathLength(const mesh::Packet* packet) const {
|
|
return applyGroupDataFloodRetryPathGate(
|
|
packet, _prefs.flood_retry_max_path,
|
|
_prefs.flood_retry_group_max_path);
|
|
}
|
|
|
|
uint8_t MyMesh::getFloodRetryMaxAttempts(const mesh::Packet* packet) const {
|
|
(void)packet;
|
|
return _prefs.flood_retry_attempts;
|
|
}
|
|
|
|
bool MyMesh::sendFloodScoped(const TransportKey& scope, mesh::Packet* pkt, uint32_t delay_millis) {
|
|
if (scope.isNull()) {
|
|
return sendFlood(pkt, delay_millis, _prefs.path_hash_mode + 1);
|
|
} else {
|
|
uint16_t codes[2];
|
|
codes[0] = scope.calcTransportCode(pkt);
|
|
codes[1] = 0; // REVISIT: set to 'home' Region, for sender/return region?
|
|
return sendFlood(pkt, codes, delay_millis, _prefs.path_hash_mode + 1);
|
|
}
|
|
}
|
|
|
|
bool MyMesh::sendFloodScoped(const ContactInfo& recipient, mesh::Packet* pkt, uint32_t delay_millis) {
|
|
// TODO: dynamic send_scope, depending on recipient and current 'home' Region
|
|
if (send_unscoped) {
|
|
return sendFlood(pkt, delay_millis, _prefs.path_hash_mode + 1); // app explicitly requested un-scoped
|
|
} else {
|
|
TransportKey default_scope;
|
|
memcpy(&default_scope.key, _prefs.default_scope_key, sizeof(default_scope.key));
|
|
|
|
auto scope = send_scope.isNull() ? &default_scope : &send_scope;
|
|
return sendFloodScoped(*scope, pkt, delay_millis);
|
|
}
|
|
}
|
|
bool MyMesh::sendFloodScoped(const mesh::GroupChannel& channel, mesh::Packet* pkt, uint32_t delay_millis) {
|
|
// TODO: have per-channel send_scope
|
|
if (send_unscoped) {
|
|
return sendFlood(pkt, delay_millis, _prefs.path_hash_mode + 1); // app explicitly requested un-scoped
|
|
} else {
|
|
TransportKey default_scope;
|
|
memcpy(&default_scope.key, _prefs.default_scope_key, sizeof(default_scope.key));
|
|
|
|
auto scope = send_scope.isNull() ? &default_scope : &send_scope;
|
|
return sendFloodScoped(*scope, pkt, delay_millis);
|
|
}
|
|
}
|
|
|
|
void MyMesh::onRadioProfileCopyQueued(mesh::Packet* packet, const mesh::Packet* original, uint8_t priority) {
|
|
mesh::Mesh::onRadioProfileCopyQueued(packet, original, priority);
|
|
_primary_radio_reply.trackCopy(original, packet);
|
|
_morse_reply.copy(original, packet);
|
|
#if MESH_ENABLE_FLEET_CONTROL
|
|
if (original && original == _fleet_admission_original) _fleet_admission_copy = packet;
|
|
#endif
|
|
}
|
|
|
|
void MyMesh::onSendComplete(mesh::Packet* packet) {
|
|
mesh::Mesh::onSendComplete(packet);
|
|
if (_primary_radio_reply.complete(packet) && !_primary_radio_reply.waiting())
|
|
finishPrimaryRadioReply(true);
|
|
// Use the completed packet's idle gap before a busy queue starts another TX.
|
|
servicePendingLocalRadioSettings();
|
|
_morse_reply.complete(packet);
|
|
}
|
|
|
|
void MyMesh::onSendFail(mesh::Packet* packet) {
|
|
mesh::Mesh::onSendFail(packet);
|
|
if (_primary_radio_reply.fail(packet)) finishPrimaryRadioReply(false);
|
|
else if (!_primary_radio_reply.waiting() && _primary_radio_reply.succeeded())
|
|
finishPrimaryRadioReply(true);
|
|
_morse_reply.fail(packet);
|
|
}
|
|
|
|
bool MyMesh::allowDirectRetry(const mesh::Packet* packet, const uint8_t* next_hop_hash,
|
|
uint8_t next_hop_hash_len) const {
|
|
return !_morse_reply.ownsReply(packet)
|
|
&& !_primary_radio_reply.contains(packet)
|
|
&& mesh::Mesh::allowDirectRetry(packet, next_hop_hash, next_hop_hash_len);
|
|
}
|
|
|
|
#if MESH_ENABLE_FLEET_CONTROL
|
|
|
|
bool MyMesh::sendFleetCommandData(mesh::GroupChannel& channel, const uint8_t* envelope, size_t length) {
|
|
if (!envelope || !length || length > mesh::FleetCommand::MaxEnvelopeLength) return false;
|
|
if (length <= mesh::FleetCommand::MaxPayloadLength) {
|
|
return sendGroupData(channel, nullptr, OUT_PATH_UNKNOWN,
|
|
mesh::FleetCommand::DataType, envelope, (int)length);
|
|
}
|
|
mesh::Packet* reserved[2] = {};
|
|
uint8_t group[3 + mesh::FleetCommand::MaxPayloadLength];
|
|
group[0] = (uint8_t)mesh::FleetCommand::DataType;
|
|
group[1] = (uint8_t)(mesh::FleetCommand::DataType >> 8);
|
|
// Reserve both encrypted packets before handing either to the dispatcher.
|
|
for (uint8_t part = 0; part < 2; ++part) {
|
|
const size_t bytes = mesh::FleetCommand::fragment(envelope, length, part,
|
|
group + 3, sizeof(group) - 3);
|
|
if (bytes) {
|
|
group[2] = (uint8_t)bytes;
|
|
reserved[part] = createGroupDatagram(PAYLOAD_TYPE_GRP_DATA, channel, group, 3 + bytes);
|
|
}
|
|
if (!reserved[part]) {
|
|
if (reserved[0]) releasePacket(reserved[0]);
|
|
memset(group, 0, sizeof(group));
|
|
return false;
|
|
}
|
|
}
|
|
memset(group, 0, sizeof(group));
|
|
_fleet_admission_original = reserved[0];
|
|
_fleet_admission_copy = nullptr;
|
|
const bool first_queued = sendFloodScoped(channel, reserved[0]);
|
|
mesh::Packet* first_copy = _fleet_admission_copy;
|
|
_fleet_admission_original = nullptr;
|
|
_fleet_admission_copy = nullptr;
|
|
if (!first_queued) {
|
|
// sendFloodScoped/sendPacket owns and releases the failed first packet.
|
|
releasePacket(reserved[1]);
|
|
return false;
|
|
}
|
|
if (sendFloodScoped(channel, reserved[1])) return true;
|
|
// A failed second send has already been released. No dispatcher loop runs
|
|
// inside admission, so the first packet and its optional copy are still
|
|
// queued and can be retired by exact pointer without touching other work.
|
|
for (int i = _mgr->getOutboundTotal() - 1; i >= 0; --i) {
|
|
const mesh::Packet* queued = _mgr->getOutboundByIdx(i);
|
|
if (queued != reserved[0] && queued != first_copy) continue;
|
|
mesh::Packet* cancelled = _mgr->removeOutboundByIdx(i);
|
|
cancelOutboundRadioRetry(cancelled);
|
|
onSendFail(cancelled); // Clear its pending flood retry metadata too.
|
|
releasePacket(cancelled);
|
|
}
|
|
return false;
|
|
}
|
|
#endif
|
|
|
|
#if MESH_ENABLE_LOST_REPLY
|
|
void MyMesh::maybeReplyToLostQuestion(const ContactInfo& from,
|
|
uint32_t sender_timestamp, const char* text) {
|
|
if ((_prefs.lost_reply != mesh::companion::LostReplyNo
|
|
&& _prefs.lost_reply != mesh::companion::LostReplyYes)
|
|
|| from.type != ADV_TYPE_CHAT || isTransientContact(from)
|
|
|| !mesh::companion::isLostReplyQuestion(text)) return;
|
|
|
|
// Contact cache references are borrowed. Preserve identity before servicing
|
|
// other sends, then acquire the recipient only when ready to compose it.
|
|
uint8_t recipient_key[PUB_KEY_SIZE];
|
|
memcpy(recipient_key, from.id.pub_key, sizeof(recipient_key));
|
|
const uint32_t now = _ms->getMillis();
|
|
if (!lost_reply_limiter.canReply(recipient_key, sender_timestamp, now)) return;
|
|
|
|
expireExpectedAcks();
|
|
AckTableEntry* available = NULL;
|
|
for (AckTableEntry& entry : expected_ack_table) {
|
|
if (entry.ack == 0) { available = &entry; break; }
|
|
}
|
|
// Automatic answers must leave ordinary sends and their ACK/RX reserve
|
|
// intact. A busy application can retry its question after capacity returns.
|
|
if (available == NULL || _mgr->getFreeCount() <= 2) return;
|
|
ContactInfo* recipient = lookupPersistentContactByPubKey(recipient_key, PUB_KEY_SIZE);
|
|
if (recipient == NULL || recipient->type != ADV_TYPE_CHAT) return;
|
|
const char* answer = _prefs.lost_reply == mesh::companion::LostReplyYes ? "Yes" : "No";
|
|
const uint32_t timestamp = getRTCClock()->getCurrentTimeUnique();
|
|
uint32_t expected_ack = 0, timeout = 0;
|
|
// The final argument receives the actual packet hash for ACK cancellation. No
|
|
// semantic message retry key is supplied, so user sends are not replaced.
|
|
uint8_t retry_key[MAX_HASH_SIZE] = {};
|
|
const int result = sendMessageDetached(*recipient, timestamp, answer,
|
|
expected_ack, timeout, retry_key);
|
|
// Automatic ACKs use the existing per-entry expiry service. Do not move an
|
|
// application or terminal send's shared BaseChatMesh timeout deadline.
|
|
if (result == MSG_SEND_FAILED) return;
|
|
|
|
lost_reply_limiter.rememberReply(recipient_key, sender_timestamp, now);
|
|
if (expected_ack != 0) {
|
|
clearExpectedAck(*available, false);
|
|
available->msg_sent = now;
|
|
available->expires_at = futureMillis(timeout);
|
|
available->ack = expected_ack;
|
|
available->message_timestamp = timestamp;
|
|
available->contact = recipient;
|
|
memcpy(available->retry_key, retry_key, sizeof(available->retry_key));
|
|
mesh::Utils::sha256(available->text_fingerprint, sizeof(available->text_fingerprint),
|
|
recipient_key, PUB_KEY_SIZE,
|
|
(const uint8_t*)answer, strlen(answer));
|
|
// No application or terminal transport owns this automatic send.
|
|
available->reply_route = NULL;
|
|
expireExpectedAcks();
|
|
}
|
|
}
|
|
#endif
|
|
|
|
void MyMesh::onMessageRecv(const ContactInfo &from, mesh::Packet *pkt, uint32_t sender_timestamp,
|
|
const char *text) {
|
|
#if MESH_ENABLE_LOST_REPLY
|
|
// Answer independently of app inbox capacity, including held/retried DMs.
|
|
maybeReplyToLostQuestion(from, sender_timestamp, text);
|
|
#endif
|
|
#if MESH_ENABLE_ONE_KEY_DM
|
|
uint8_t dm_id[ONE_KEY_DM_ID_SIZE];
|
|
makeOneKeyDMId(dm_id, sender_timestamp, text);
|
|
if (wasDeliveredOneKeyDM(from.id.pub_key, dm_id)) return;
|
|
#if ONE_KEY_DM_SHARED_OFFLINE_QUEUE
|
|
bool replaced_held_dm = false;
|
|
#endif
|
|
for (uint8_t i = 0; i < held_dm_count; ++i) {
|
|
#if ONE_KEY_DM_COMPACT_HELD
|
|
uint8_t held_id[ONE_KEY_DM_ID_SIZE];
|
|
makeOneKeyDMId(held_id, held_dms[i].timestamp, held_dms[i].text);
|
|
#else
|
|
const uint8_t* held_id = held_dms[i].id;
|
|
#endif
|
|
if (memcmp(held_dms[i].sender_key, from.id.pub_key, PUB_KEY_SIZE) == 0 &&
|
|
memcmp(held_id, dm_id, ONE_KEY_DM_ID_SIZE) == 0) {
|
|
#if ONE_KEY_DM_SHARED_OFFLINE_QUEUE
|
|
// Removing the held frame frees exactly one ordinary slot for this retry.
|
|
removeHeldOneKeyDM(i);
|
|
replaced_held_dm = true;
|
|
#else
|
|
// Keep the held copy if the ordinary delivery path cannot queue this
|
|
// just-arrived retry; otherwise accepting a peer with a full app queue
|
|
// could silently discard the original when the queue drains.
|
|
if (offline_queue_len >= getOfflineQueueCapacity()) return;
|
|
rememberDeliveredOneKeyDM(from.id.pub_key, dm_id);
|
|
#endif
|
|
break;
|
|
}
|
|
}
|
|
#endif
|
|
markConnectionActive(from); // in case this is from a server, and we have a connection
|
|
// BaseChatMesh updates lastmod immediately before this callback.
|
|
scheduleContactWrite(from);
|
|
queueMessage(from, TXT_TYPE_PLAIN, pkt, sender_timestamp, NULL, 0, text);
|
|
#if MESH_ENABLE_ONE_KEY_DM
|
|
#if ONE_KEY_DM_SHARED_OFFLINE_QUEUE
|
|
if (replaced_held_dm) rememberDeliveredOneKeyDM(from.id.pub_key, dm_id);
|
|
#endif
|
|
#endif
|
|
}
|
|
|
|
void MyMesh::onCommandDataRecv(const ContactInfo &from, mesh::Packet *pkt, uint32_t sender_timestamp, const char *text) {
|
|
markConnectionActive(from); // in case this is from a server, and we have a connection
|
|
bool terminal_command_reply = false;
|
|
uint32_t terminal_command_elapsed_millis = 0;
|
|
#if COMPANION_FEATURE_TEXT_TERMINAL
|
|
terminal_command_reply = _terminal_command.takeReply(
|
|
from.id.pub_key, _ms->getMillis(), terminal_command_elapsed_millis);
|
|
if (terminal_command_reply) _terminal_command_target[0] = 0;
|
|
#endif
|
|
queueMessage(from, TXT_TYPE_CLI_DATA, pkt, sender_timestamp, NULL, 0, text,
|
|
terminal_command_reply, terminal_command_elapsed_millis);
|
|
}
|
|
|
|
void MyMesh::onCLICommandRecv(const ContactInfo &from, mesh::Packet *pkt, uint32_t sender_timestamp,
|
|
const char *text, char* reply) {
|
|
markConnectionActive(from); // in case this is from a server, and we have a connection
|
|
if (from.isRemoteCLIAllowed()) {
|
|
_primary_radio_reply_admission = false;
|
|
const uint32_t before = _morse_reply.generation();
|
|
_morse_reply.beginCommand(from.id.pub_key);
|
|
// A timestamp received over the radio must never grant local-console access.
|
|
if (!handleCommand(text, sender_timestamp ? sender_timestamp : 1, reply)) {
|
|
strcat(reply, "Unknown command"); // reply may have cmd prefix from 'text'
|
|
}
|
|
_morse_reply.endCommand();
|
|
_morse_reply_admission = _morse_reply.generation() != before;
|
|
} else {
|
|
queueMessage(from, TXT_TYPE_CLI_COMMAND, pkt, sender_timestamp, NULL, 0, text);
|
|
}
|
|
}
|
|
|
|
void MyMesh::onCLIReplyPrepared(const ContactInfo& from, mesh::Packet* reply) {
|
|
if (_primary_radio_reply_admission) {
|
|
_primary_radio_reply.prepare(reply);
|
|
if (reply) reply->radio_reply = true;
|
|
}
|
|
if (!_morse_reply_admission || !_morse_reply.pending()
|
|
|| memcmp(from.id.pub_key, _morse_reply.actor(), PUB_KEY_SIZE) != 0) return;
|
|
_morse_reply.prepareReply(reply);
|
|
if (reply) reply->radio_reply = true;
|
|
}
|
|
|
|
void MyMesh::onCLIReplyQueued(const ContactInfo& from, mesh::Packet* reply, bool queued) {
|
|
if (_primary_radio_reply_admission) {
|
|
_primary_radio_reply.arm(queued ? reply : nullptr);
|
|
_primary_radio_reply_admission = false;
|
|
}
|
|
if (!_morse_reply_admission) return;
|
|
if (memcmp(from.id.pub_key, _morse_reply.actor(), PUB_KEY_SIZE) == 0)
|
|
_morse_reply.armReply(reply, queued);
|
|
_morse_reply_admission = false;
|
|
}
|
|
|
|
bool MyMesh::onSignedMessageRecv(const ContactInfo &from, mesh::Packet *pkt, uint32_t sender_timestamp,
|
|
const uint8_t *sender_prefix, const char *text) {
|
|
if (!queueMessage(from, TXT_TYPE_SIGNED_PLAIN, pkt, sender_timestamp, sender_prefix, 4, text)) return false;
|
|
markConnectionActive(from);
|
|
// from.sync_since change needs to be persisted
|
|
scheduleContactWrite(from);
|
|
return true;
|
|
}
|
|
|
|
void MyMesh::onChannelMessageRecv(const mesh::GroupChannel &channel, mesh::Packet *pkt, uint32_t timestamp,
|
|
const char *text) {
|
|
const bool is_emergency_channel =
|
|
memcmp(channel.secret, EMERGENCY_CHANNEL_SECRET,
|
|
sizeof(EMERGENCY_CHANNEL_SECRET)) == 0;
|
|
const int selected_channel = findChannelIdxForPacket(channel, pkt);
|
|
if (selected_channel < 0) return;
|
|
queueChannelText(selected_channel, channel.secret, pkt->getSNR(),
|
|
pkt->isRouteFlood() ? pkt->path_len : 0xFF,
|
|
timestamp, text, is_emergency_channel);
|
|
|
|
if (pkt->isRouteFlood() && is_emergency_channel) {
|
|
bool zero_path = pkt->getPathHashCount() == 0;
|
|
uint16_t key = emergencyClientRepeatKey(pkt);
|
|
if (hasEmergencyClientRepeat(key)) {
|
|
pkt->markDoNotRetransmit();
|
|
} else {
|
|
emergency_client_repeats[emergency_client_repeat_next] = key;
|
|
emergency_client_repeat_next++;
|
|
if (emergency_client_repeat_next >= EMERGENCY_CLIENT_REPEAT_TABLE_SIZE) emergency_client_repeat_next = 0;
|
|
|
|
if (zero_path && emergency_client_repeat_packet == NULL) {
|
|
emergency_client_repeat_packet = obtainNewPacket();
|
|
if (emergency_client_repeat_packet != NULL) {
|
|
*emergency_client_repeat_packet = *pkt;
|
|
emergency_client_repeat_key = key;
|
|
emergency_client_repeat_send_at = futureMillis(
|
|
(int)(EMERGENCY_CLIENT_REPEAT_HOLD_MS + getRNG()->nextInt(0, EMERGENCY_CLIENT_REPEAT_JITTER_MS + 1)));
|
|
pkt->markDoNotRetransmit();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
static bool isEmergencyNoticeChannelName(const char* name) {
|
|
const auto alias = parseCompanionChannelAlias(name);
|
|
return alias.hashtag && alias.kind != CompanionChannelKind::Invalid
|
|
&& alias.kind != CompanionChannelKind::Meshtext
|
|
&& strcmp(alias.tail, "emergency") == 0;
|
|
}
|
|
|
|
void MyMesh::seedEmergencyChannelNotice(uint8_t index, const ChannelDetails& previous) {
|
|
if (index >= MAX_GROUP_CHANNELS) return;
|
|
const uint8_t bit = 1U << (index % 8);
|
|
uint8_t& pending = pending_emergency_notices[index / 8];
|
|
ChannelDetails channel;
|
|
if (!getChannel(index, channel) || !isEmergencyNoticeChannelName(channel.name)) {
|
|
pending &= ~bit;
|
|
return;
|
|
}
|
|
if (!isEmergencyNoticeChannelName(previous.name)) pending |= bit;
|
|
if (!(pending & bit)) return; // Edits and reapplying a saved channel are not joins.
|
|
|
|
// Drain real traffic first. Starting with an empty inbox also makes this
|
|
// notice the oldest evictable channel frame if more traffic arrives later.
|
|
// Do not take a newly freed slot while a real one-key DM is still held.
|
|
if (offline_queue_len != 0) return;
|
|
#if MESH_ENABLE_ONE_KEY_DM
|
|
if (held_dm_count != 0) return;
|
|
#endif
|
|
if (queueChannelText(index, channel.channel.secret, 0, 0xFF,
|
|
getRTCClock()->getCurrentTime(),
|
|
"#EMERGENCY: This is an emergency channel. Do not use for testing or small talk.",
|
|
true, false)) pending &= ~bit;
|
|
}
|
|
|
|
void MyMesh::servicePendingChannelNotices() {
|
|
for (unsigned index = 0; index < MAX_GROUP_CHANNELS; ++index) {
|
|
if (!(pending_emergency_notices[index / 8] & (1U << (index % 8)))) continue;
|
|
ChannelDetails current{};
|
|
getChannel(index, current);
|
|
seedEmergencyChannelNotice(index, current);
|
|
return; // At most one local notice per loop, never a radio send.
|
|
}
|
|
}
|
|
|
|
bool MyMesh::queueChannelText(uint8_t channel_idx, const uint8_t secret[32], float snr,
|
|
uint8_t path_len, uint32_t timestamp, const char* text,
|
|
bool is_emergency_channel, bool notify) {
|
|
if (!text || channel_idx >= MAX_GROUP_CHANNELS) return false;
|
|
int i = 0;
|
|
if (app_target_ver >= 3) {
|
|
out_frame[i++] = RESP_CODE_CHANNEL_MSG_RECV_V3;
|
|
const float bounded_snr = !isfinite(snr) ? 0 : snr < -32 ? -32 : snr > 31.75f ? 31.75f : snr;
|
|
out_frame[i++] = (int8_t)(bounded_snr * 4);
|
|
out_frame[i++] = 0; // reserved1
|
|
out_frame[i++] = 0; // reserved2
|
|
} else {
|
|
out_frame[i++] = RESP_CODE_CHANNEL_MSG_RECV;
|
|
}
|
|
|
|
out_frame[i++] = channel_idx;
|
|
out_frame[i++] = path_len;
|
|
|
|
out_frame[i++] = TXT_TYPE_PLAIN;
|
|
memcpy(&out_frame[i], ×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 = notify && _notifications.message(nullptr, secret, _serial && _serial->isConnected(), millis());
|
|
if (_ui) _ui->setMessageNotificationOverride(notification_managed, _notifications.overridesScreen());
|
|
#else
|
|
const bool notification_managed = false;
|
|
#endif
|
|
// Older screen UIs can vibrate inside newMsg(), independently of notify().
|
|
// Treat local notices as managed/screen-suppressed while retaining history.
|
|
if (!notify && _ui) _ui->setMessageNotificationOverride(true, true);
|
|
if (_serial && _serial->isConnected()) {
|
|
uint8_t frame[1];
|
|
frame[0] = PUSH_CODE_MSG_WAITING;
|
|
_serial->writeFrame(frame, 1);
|
|
} else {
|
|
#ifdef DISPLAY_CLASS
|
|
if (notify && _ui && !notification_managed) _ui->notify(UIEventType::channelMessage);
|
|
#endif
|
|
}
|
|
|
|
#if COMPANION_FEATURE_TEXT_TERMINAL
|
|
if (notify && hasTerminalOutput()
|
|
&& _terminal_display.shouldShowChannel(is_emergency_channel)) {
|
|
ChannelDetails details;
|
|
const char* channel_name = getChannel(channel_idx, details) ? details.name : "Unknown";
|
|
terminalOutput().printf(
|
|
"\r\nCHANNEL MSG -> %s (%s)\r\n %s\r\n> ", channel_name,
|
|
path_len == 0xFF ? "DIRECT" : "FLOOD", text);
|
|
}
|
|
#endif
|
|
#ifdef DISPLAY_CLASS
|
|
// Get the channel name from the channel index
|
|
const char *channel_name = "Unknown";
|
|
ChannelDetails channel_details;
|
|
if (getChannel(channel_idx, channel_details)) {
|
|
channel_name = channel_details.name;
|
|
}
|
|
char channel_label[64];
|
|
snprintf(channel_label, sizeof(channel_label), "Ch %u %s",
|
|
(unsigned int)channel_idx, channel_name);
|
|
if (_ui) {
|
|
_ui->newMsg(path_len, channel_label, text, offline_queue_len,
|
|
channel_idx, channel_name, queued ? offline_queue_len - 1 : -1);
|
|
}
|
|
#endif
|
|
|
|
if (_ui) _ui->setMessageNotificationOverride(false);
|
|
|
|
return true;
|
|
}
|
|
|
|
void MyMesh::onChannelDataRecv(const mesh::GroupChannel &channel, mesh::Packet *pkt, uint16_t data_type,
|
|
const uint8_t *data, size_t data_len) {
|
|
if (data_len > MAX_CHANNEL_DATA_LENGTH) {
|
|
MESH_DEBUG_PRINTLN("onChannelDataRecv: dropping payload_len=%d exceeds frame limit=%d",
|
|
(uint32_t)data_len, (uint32_t)MAX_CHANNEL_DATA_LENGTH);
|
|
return;
|
|
}
|
|
|
|
int i = 0;
|
|
out_frame[i++] = RESP_CODE_CHANNEL_DATA_RECV;
|
|
out_frame[i++] = (int8_t)(pkt->getSNR() * 4);
|
|
out_frame[i++] = rxViaTag(pkt, 0); // reserved1: the profile it arrived on, while radio2 is on
|
|
out_frame[i++] = 0; // reserved2
|
|
|
|
const int selected_channel = findChannelIdxForPacket(channel, pkt);
|
|
if (selected_channel < 0) return;
|
|
uint8_t channel_idx = selected_channel;
|
|
out_frame[i++] = channel_idx;
|
|
out_frame[i++] = pkt->isRouteFlood() ? pkt->path_len : 0xFF;
|
|
out_frame[i++] = (uint8_t)(data_type & 0xFF);
|
|
out_frame[i++] = (uint8_t)(data_type >> 8);
|
|
out_frame[i++] = (uint8_t)data_len;
|
|
|
|
int copy_len = (int)data_len;
|
|
if (copy_len > 0) {
|
|
memcpy(&out_frame[i], data, copy_len);
|
|
i += copy_len;
|
|
}
|
|
addToOfflineQueue(out_frame, i);
|
|
|
|
if (_serial->isConnected()) {
|
|
uint8_t frame[1];
|
|
frame[0] = PUSH_CODE_MSG_WAITING; // send push 'tickle'
|
|
_serial->writeFrame(frame, 1);
|
|
}
|
|
}
|
|
|
|
uint8_t MyMesh::onContactRequest(const ContactInfo &contact, uint32_t sender_timestamp, const uint8_t *data,
|
|
uint8_t len, uint8_t *reply) {
|
|
if (data == NULL || len == 0 || reply == NULL) return 0;
|
|
#if MESH_ENABLE_LOST_REPLY
|
|
if (data[0] == mesh::tracker::RequestType) {
|
|
return handleTrackerStatusRequest(contact, sender_timestamp, data, len, reply);
|
|
}
|
|
#endif
|
|
if (data[0] == REQ_TYPE_GET_TELEMETRY_DATA) {
|
|
uint8_t permissions = 0;
|
|
uint8_t cp = contact.flags >> 1; // LSB used as 'favourite' bit (so only use upper bits)
|
|
|
|
if (_prefs.telemetry_mode_base == TELEM_MODE_ALLOW_ALL) {
|
|
permissions = TELEM_PERM_BASE;
|
|
} else if (_prefs.telemetry_mode_base == TELEM_MODE_ALLOW_FLAGS) {
|
|
permissions = cp & TELEM_PERM_BASE;
|
|
}
|
|
|
|
if (_prefs.telemetry_mode_loc == TELEM_MODE_ALLOW_ALL) {
|
|
permissions |= TELEM_PERM_LOCATION;
|
|
} else if (_prefs.telemetry_mode_loc == TELEM_MODE_ALLOW_FLAGS) {
|
|
permissions |= cp & TELEM_PERM_LOCATION;
|
|
}
|
|
|
|
if (_prefs.telemetry_mode_env == TELEM_MODE_ALLOW_ALL) {
|
|
permissions |= TELEM_PERM_ENVIRONMENT;
|
|
} else if (_prefs.telemetry_mode_env == TELEM_MODE_ALLOW_FLAGS) {
|
|
permissions |= cp & TELEM_PERM_ENVIRONMENT;
|
|
}
|
|
|
|
uint8_t perm_mask = ~(data[1]); // NEW: first reserved byte (of 4), is now inverse mask to apply to permissions
|
|
permissions &= perm_mask;
|
|
|
|
if (permissions & TELEM_PERM_BASE) { // only respond if base permission bit is set
|
|
telemetry.reset();
|
|
telemetry.addVoltage(TELEM_CHANNEL_SELF, (float)board.getBattMilliVolts() / 1000.0f);
|
|
// query other sensors -- target specific
|
|
sensors.querySensors(permissions, telemetry);
|
|
|
|
float temperature = board.getMCUTemperature();
|
|
if(!isnan(temperature)) { // Supported boards with built-in temperature sensor. ESP32-C3 may return NAN
|
|
telemetry.addTemperature(TELEM_CHANNEL_SELF, temperature); // Built-in MCU Temperature
|
|
}
|
|
|
|
memcpy(reply, &sender_timestamp,
|
|
4); // reflect sender_timestamp back in response packet (kind of like a 'tag')
|
|
|
|
uint8_t tlen = telemetry.getSize();
|
|
memcpy(&reply[4], telemetry.getBuffer(), tlen);
|
|
return 4 + tlen;
|
|
}
|
|
}
|
|
return 0; // unknown
|
|
}
|
|
|
|
#if MESH_ENABLE_LOST_REPLY
|
|
uint8_t MyMesh::handleTrackerStatusRequest(const ContactInfo& from, uint32_t tag,
|
|
const uint8_t* data, uint8_t len,
|
|
uint8_t* reply) {
|
|
mesh::tracker::StatusRequest request;
|
|
if (reply == NULL || !mesh::tracker::parseStatusRequest(data, len, request)
|
|
|| isTransientContact(from)
|
|
|| (from.type != ADV_TYPE_CHAT && from.type != ADV_TYPE_SENSOR)) return 0;
|
|
|
|
// A contact cache reference can move when another contact is loaded.
|
|
uint8_t key[PUB_KEY_SIZE];
|
|
memcpy(key, from.id.pub_key, sizeof(key));
|
|
ContactInfo* saved = lookupPersistentContactByPubKey(key, sizeof(key));
|
|
if (saved == NULL
|
|
|| (saved->type != ADV_TYPE_CHAT && saved->type != ADV_TYPE_SENSOR)) return 0;
|
|
const uint32_t now = _ms->getMillis();
|
|
if (!lost_reply_limiter.canReply(key, tag, now)) return 0;
|
|
|
|
const uint8_t state = _prefs.lost_reply <= mesh::companion::LostReplyYes
|
|
? _prefs.lost_reply : uint8_t(mesh::companion::LostReplyOff);
|
|
const size_t count = mesh::tracker::makeStatusResponse(
|
|
tag, state, state == mesh::tracker::Lost ? 120 : 0,
|
|
reply, mesh::tracker::ResponseLength);
|
|
if (count != 0) {
|
|
lost_reply_limiter.rememberReply(key, tag, now);
|
|
const uint32_t owner_time = getRTCClock()->getCurrentTime();
|
|
const uint32_t clock_difference = tag > owner_time
|
|
? tag - owner_time : owner_time - tag;
|
|
// The freshest authenticated advert/report owns position. Clock-bounded
|
|
// reports cannot poison that durable floor with a wildly future tag.
|
|
if ((request.flags & mesh::tracker::FreshGps) && canMutateContacts()
|
|
&& tag > saved->last_advert_timestamp && clock_difference <= 300) {
|
|
saved->gps_lat = request.latitude / 10;
|
|
saved->gps_lon = request.longitude / 10;
|
|
saved->last_advert_timestamp = tag;
|
|
saved->lastmod = owner_time;
|
|
onDiscoveredContact(*saved, false, 0, NULL);
|
|
}
|
|
}
|
|
// BaseChatMesh sends this response without touching the user DM ACK table.
|
|
return uint8_t(count);
|
|
}
|
|
#endif
|
|
|
|
void MyMesh::onContactResponse(const ContactInfo &contact, const uint8_t *data, uint8_t len) {
|
|
const uint32_t now = _ms->getMillis();
|
|
servicePendingSerialReply(now); // Expire before considering any radio callback.
|
|
if (data == NULL || len < 4) return;
|
|
|
|
#if MESH_ENABLE_ONE_KEY_DM
|
|
// Decryption returns block-aligned plaintext including zero padding.
|
|
if (len >= 4 + sizeof(ONE_KEY_REJECT_MARKER) + SIGNATURE_SIZE &&
|
|
len < 4 + sizeof(ONE_KEY_REJECT_MARKER) + SIGNATURE_SIZE + CIPHER_BLOCK_SIZE &&
|
|
memcmp(data + 4, ONE_KEY_REJECT_MARKER, sizeof(ONE_KEY_REJECT_MARKER)) == 0) {
|
|
uint8_t signed_refusal[PUB_KEY_SIZE + 4 + sizeof(ONE_KEY_REJECT_MARKER)];
|
|
memcpy(signed_refusal, self_id.pub_key, PUB_KEY_SIZE);
|
|
memcpy(signed_refusal + PUB_KEY_SIZE, data, 4 + sizeof(ONE_KEY_REJECT_MARKER));
|
|
uint32_t intro_tag;
|
|
memcpy(&intro_tag, data, sizeof(intro_tag));
|
|
if (contact.id.verify(data + 4 + sizeof(ONE_KEY_REJECT_MARKER),
|
|
signed_refusal, sizeof(signed_refusal)) &&
|
|
rememberOneKeyReject(contact, intro_tag) && _serial->isConnected()) {
|
|
out_frame[0] = PUSH_CODE_ONE_KEY_DM_REJECTED;
|
|
memcpy(&out_frame[1], contact.id.pub_key, PUB_KEY_SIZE);
|
|
_serial->writeFrame(out_frame, 1 + PUB_KEY_SIZE);
|
|
}
|
|
return;
|
|
}
|
|
#endif
|
|
|
|
uint32_t tag;
|
|
memcpy(&tag, data, 4);
|
|
|
|
const bool matched = _delayed_replies.requestMatches(contact.id.pub_key, tag);
|
|
const auto kind = _delayed_replies.request.kind;
|
|
if (matched && _delayed_replies.request.phase != mesh::CompanionDelayedReplies::AwaitRadio) return;
|
|
// Login has no reflected nonce. Known old request replies take precedence
|
|
// over its permissive envelope; exact newer reflected replies remain valid
|
|
// immediately after a successful login despite the login quarantine.
|
|
if (matched && kind == mesh::CompanionDelayedReplies::Login
|
|
&& _delayed_replies.blocksResponse(contact.id.pub_key, tag, now)) return;
|
|
|
|
if (matched && kind == mesh::CompanionDelayedReplies::Login) {
|
|
if (len < 6 || (data[4] == RESP_SERVER_LOGIN_OK && len < 13)) return;
|
|
// yes, is response to pending sendLogin()
|
|
uint16_t keep_alive_secs = 0;
|
|
#if COMPANION_FEATURE_TEXT_TERMINAL
|
|
const bool terminal_login_response = _delayed_replies.request.terminal && _terminal_login_pending
|
|
&& memcmp(_terminal_login_key, contact.id.pub_key,
|
|
sizeof(_terminal_login_key)) == 0;
|
|
#endif
|
|
int i = 0;
|
|
#if COMPANION_FEATURE_TEXT_TERMINAL
|
|
bool login_success = false;
|
|
bool modern_login = false;
|
|
#endif
|
|
if (len >= 6 && memcmp(&data[4], "OK", 2) == 0) { // legacy Repeater login OK response
|
|
#if COMPANION_FEATURE_TEXT_TERMINAL
|
|
login_success = true;
|
|
#endif
|
|
out_frame[i++] = PUSH_CODE_LOGIN_SUCCESS;
|
|
out_frame[i++] = 0; // legacy: is_admin = false
|
|
memcpy(&out_frame[i], contact.id.pub_key, 6);
|
|
i += 6; // pub_key_prefix
|
|
} else if (len >= 13 && data[4] == RESP_SERVER_LOGIN_OK) { // new login response
|
|
#if COMPANION_FEATURE_TEXT_TERMINAL
|
|
login_success = true;
|
|
modern_login = true;
|
|
#endif
|
|
keep_alive_secs = ((uint16_t)data[5]) * 16;
|
|
out_frame[i++] = PUSH_CODE_LOGIN_SUCCESS;
|
|
out_frame[i++] = data[6]; // permissions (eg. is_admin)
|
|
memcpy(&out_frame[i], contact.id.pub_key, 6);
|
|
i += 6; // pub_key_prefix
|
|
memcpy(&out_frame[i], &tag, 4);
|
|
i += 4; // NEW: include server timestamp
|
|
out_frame[i++] = data[7]; // NEW (v7): ACL permissions
|
|
out_frame[i++] = data[12]; // FIRMWARE_VER_LEVEL
|
|
} else {
|
|
out_frame[i++] = PUSH_CODE_LOGIN_FAIL;
|
|
out_frame[i++] = 0; // reserved
|
|
memcpy(&out_frame[i], contact.id.pub_key, 6);
|
|
i += 6; // pub_key_prefix
|
|
}
|
|
// Terminal login has its own text destination and deliberately captures
|
|
// no Binary route. Binary logins retain their exact requester even if a
|
|
// different transport has issued a command in the meantime.
|
|
_delayed_replies.rememberLoginTag(tag);
|
|
if (writePendingSerialFrame(out_frame, i, now) != (size_t)i) return;
|
|
if (keep_alive_secs > 0) startConnection(contact, keep_alive_secs);
|
|
#if COMPANION_FEATURE_TEXT_TERMINAL
|
|
if (terminal_login_response) {
|
|
if (hasTerminalOutput()) {
|
|
Stream& output = terminalOutput();
|
|
if (login_success && modern_login) {
|
|
output.printf(
|
|
"\r\nLOGIN -> %s accepted (ACL permissions 0x%02X, server v%u)\r\n> ",
|
|
_terminal_login_target, (unsigned)data[7], (unsigned)data[12]);
|
|
} else if (login_success) {
|
|
output.printf("\r\nLOGIN -> %s accepted (legacy server)\r\n> ",
|
|
_terminal_login_target);
|
|
} else {
|
|
output.printf("\r\nLOGIN -> %s rejected\r\n> ",
|
|
_terminal_login_target);
|
|
}
|
|
}
|
|
clearTerminalLogin(now);
|
|
}
|
|
#endif
|
|
} else if (matched && kind == mesh::CompanionDelayedReplies::Status) {
|
|
// Require a complete response that also fits its host-protocol envelope.
|
|
// The app parses at least 48 bytes and otherwise throws a RangeError.
|
|
if (!mesh::companionStatusResponseIsLongEnough(len)
|
|
|| len > MAX_FRAME_SIZE - 4) {
|
|
MESH_DEBUG_PRINTLN(
|
|
"onContactResponse(), invalid status response size: len=%u, expected>=%u",
|
|
(unsigned)len,
|
|
(unsigned)mesh::COMPANION_MIN_STATUS_RESPONSE_SIZE);
|
|
return;
|
|
}
|
|
|
|
int i = 0;
|
|
out_frame[i++] = PUSH_CODE_STATUS_RESPONSE;
|
|
out_frame[i++] = 0; // reserved
|
|
memcpy(&out_frame[i], contact.id.pub_key, 6);
|
|
i += 6; // pub_key_prefix
|
|
memcpy(&out_frame[i], &data[4], len - 4);
|
|
i += (len - 4);
|
|
writePendingSerialFrame(out_frame, i, now);
|
|
} else if (matched && kind == mesh::CompanionDelayedReplies::Telemetry) {
|
|
if (len <= 4 || len > MAX_FRAME_SIZE - 4) {
|
|
return;
|
|
}
|
|
int i = 0;
|
|
out_frame[i++] = PUSH_CODE_TELEMETRY_RESPONSE;
|
|
out_frame[i++] = 0; // reserved
|
|
memcpy(&out_frame[i], contact.id.pub_key, 6);
|
|
i += 6; // pub_key_prefix
|
|
memcpy(&out_frame[i], &data[4], len - 4);
|
|
i += (len - 4);
|
|
writePendingSerialFrame(out_frame, i, now);
|
|
} else if (matched && kind == mesh::CompanionDelayedReplies::Binary) {
|
|
if (len <= 4 || len > MAX_FRAME_SIZE - 2) {
|
|
return;
|
|
}
|
|
int i = 0;
|
|
out_frame[i++] = PUSH_CODE_BINARY_RESPONSE;
|
|
out_frame[i++] = 0; // reserved
|
|
memcpy(&out_frame[i], &tag, 4); // app needs to match this to RESP_CODE_SENT.tag
|
|
i += 4;
|
|
memcpy(&out_frame[i], &data[4], len - 4);
|
|
i += (len - 4);
|
|
writePendingSerialFrame(out_frame, i, now);
|
|
} else {
|
|
// Forward unsolicited responses, including pushed telemetry, to the app.
|
|
// Pending requests above retain their captured reply route.
|
|
// A matching tag with no response body is malformed, not an unsolicited
|
|
// push, and must not escape through the app's generic response path.
|
|
if (matched || _delayed_replies.blocksResponse(contact.id.pub_key, tag,
|
|
now)) return;
|
|
if (_serial != NULL && _serial->isConnected() && len <= MAX_FRAME_SIZE - 2) {
|
|
int i = 0;
|
|
out_frame[i++] = PUSH_CODE_BINARY_RESPONSE;
|
|
out_frame[i++] = 0; // reserved
|
|
memcpy(&out_frame[i], data, len);
|
|
i += len;
|
|
_serial->writeFrame(out_frame, i);
|
|
}
|
|
}
|
|
}
|
|
|
|
bool MyMesh::onContactPathRecv(ContactInfo& contact, uint8_t* in_path, uint8_t in_path_len, uint8_t* out_path, uint8_t out_path_len, uint8_t extra_type, uint8_t* extra, uint8_t extra_len) {
|
|
const uint32_t now = _ms->getMillis();
|
|
servicePendingSerialReply(now);
|
|
if (extra_type == PAYLOAD_TYPE_RESPONSE && extra != NULL && extra_len >= 4) {
|
|
uint32_t tag;
|
|
memcpy(&tag, extra, 4);
|
|
|
|
if (_delayed_replies.request.kind == mesh::CompanionDelayedReplies::Discovery
|
|
&& _delayed_replies.requestMatches(contact.id.pub_key, tag)) {
|
|
if (_delayed_replies.request.phase != mesh::CompanionDelayedReplies::AwaitRadio) return false;
|
|
if (extra_len <= 4 || !mesh::Packet::isValidPathLen(in_path_len)
|
|
|| !mesh::Packet::isValidPathLen(out_path_len)) {
|
|
MESH_DEBUG_PRINTLN("onContactPathRecv, invalid path sizes: %d, %d", in_path_len, out_path_len);
|
|
} else {
|
|
int i = 0;
|
|
out_frame[i++] = PUSH_CODE_PATH_DISCOVERY_RESPONSE;
|
|
out_frame[i++] = 0; // reserved
|
|
memcpy(&out_frame[i], contact.id.pub_key, 6);
|
|
i += 6; // pub_key_prefix
|
|
out_frame[i++] = out_path_len;
|
|
i += mesh::Packet::writePath(&out_frame[i], out_path, out_path_len);
|
|
out_frame[i++] = in_path_len;
|
|
i += mesh::Packet::writePath(&out_frame[i], in_path, in_path_len);
|
|
// NOTE: telemetry data in 'extra' is discarded at present
|
|
|
|
writePendingSerialFrame(out_frame, i, now);
|
|
}
|
|
return false; // DON'T send reciprocal path!
|
|
}
|
|
}
|
|
// let base class handle received path and data
|
|
return BaseChatMesh::onContactPathRecv(contact, in_path, in_path_len, out_path, out_path_len, extra_type, extra, extra_len);
|
|
}
|
|
|
|
void MyMesh::onControlDataRecv(mesh::Packet *packet) {
|
|
if (packet->payload_len + 4 > MAX_FRAME_SIZE) {
|
|
MESH_DEBUG_PRINTLN("onControlDataRecv(), payload_len too long: %d", packet->payload_len);
|
|
return;
|
|
}
|
|
#if defined(DISPLAY_CLASS) && !(defined(UI_NO_DISCOVER_SCREEN) && (UI_NO_DISCOVER_SCREEN + 0 != 0))
|
|
checkControlDataForPendingDiscovery(packet->payload, packet->payload_len);
|
|
#endif
|
|
int i = 0;
|
|
out_frame[i++] = PUSH_CODE_CONTROL_DATA;
|
|
out_frame[i++] = (int8_t)(packet->getSNR() * 4);
|
|
out_frame[i++] = (int8_t)packet->getRSSI();
|
|
out_frame[i++] = packet->path_len;
|
|
memcpy(&out_frame[i], packet->payload, packet->payload_len);
|
|
i += packet->payload_len;
|
|
|
|
if (_serial->isConnected()) {
|
|
_serial->writeFrame(out_frame, i);
|
|
} else {
|
|
MESH_DEBUG_PRINTLN("onControlDataRecv(), data received while app offline");
|
|
}
|
|
}
|
|
|
|
// Which profile a received packet arrived on, for the app, in a frame byte that
|
|
// stock firmware leaves reserved: 0xA0 the primary, 0xA1 radio2. MeshCore Open
|
|
// TAK shows it under a MediaMesh photo or voice clip ("Received via MediaMesh",
|
|
// "via main mesh", or both); Wadamesh standalones send the same tags. While
|
|
// radio2 is off everything is the primary, so the stock value (`none`) stays,
|
|
// as it does for a packet that came in over the UDP bridge.
|
|
uint8_t MyMesh::rxViaTag(const mesh::Packet *pkt, uint8_t none) const {
|
|
const mesh::RadioProfiles *profiles = _radio ? _radio->profiles() : nullptr;
|
|
if (!pkt || !profiles || !profiles->enabled()) return none;
|
|
#if defined(WITH_UDP_BRIDGE)
|
|
if (pkt->udp_bridge_ingress) return none;
|
|
#endif
|
|
return pkt->radio_origin == 1 ? 0xA1 : 0xA0;
|
|
}
|
|
|
|
void MyMesh::onRawDataRecv(mesh::Packet *packet) {
|
|
if (packet->payload_len + 4 > MAX_FRAME_SIZE) {
|
|
MESH_DEBUG_PRINTLN("onRawDataRecv(), payload_len too long: %d", packet->payload_len);
|
|
return;
|
|
}
|
|
int i = 0;
|
|
out_frame[i++] = PUSH_CODE_RAW_DATA;
|
|
out_frame[i++] = (int8_t)(packet->getSNR() * 4);
|
|
out_frame[i++] = (int8_t)packet->getRSSI();
|
|
out_frame[i++] = rxViaTag(packet, 0xFF); // reserved: the profile it arrived on, while radio2 is on
|
|
memcpy(&out_frame[i], packet->payload, packet->payload_len);
|
|
i += packet->payload_len;
|
|
|
|
if (_serial->isConnected()) {
|
|
_serial->writeFrame(out_frame, i);
|
|
} else {
|
|
MESH_DEBUG_PRINTLN("onRawDataRecv(), data received while app offline");
|
|
}
|
|
}
|
|
|
|
void MyMesh::onTraceRecv(mesh::Packet *packet, uint32_t tag, uint32_t auth_code, uint8_t flags,
|
|
const uint8_t *path_snrs, const uint8_t *path_hashes, uint8_t path_len) {
|
|
const uint32_t now = _ms->getMillis();
|
|
serviceBinaryTraceReply(now);
|
|
#if COMPANION_FEATURE_TEXT_TERMINAL
|
|
serviceTerminalTrace(now);
|
|
#endif
|
|
if (_delayed_replies.blocksTrace(tag, auth_code, now)) return;
|
|
const bool binary_trace_match = _delayed_replies.hasBinaryTrace()
|
|
&& tag == _delayed_replies.trace.tag && auth_code == _delayed_replies.trace.auth;
|
|
if (binary_trace_match
|
|
&& _delayed_replies.trace.phase != mesh::CompanionDelayedReplies::AwaitRadio) return;
|
|
uint8_t path_sz = flags & 0x03; // NEW v1.11+
|
|
if (12 + path_len + (path_len >> path_sz) + 1 > MAX_FRAME_SIZE) {
|
|
MESH_DEBUG_PRINTLN("onTraceRecv(), path_len is too long: %d", (uint32_t)path_len);
|
|
return;
|
|
}
|
|
int i = 0;
|
|
out_frame[i++] = PUSH_CODE_TRACE_DATA;
|
|
out_frame[i++] = 0; // reserved
|
|
out_frame[i++] = path_len;
|
|
out_frame[i++] = flags;
|
|
memcpy(&out_frame[i], &tag, 4);
|
|
i += 4;
|
|
memcpy(&out_frame[i], &auth_code, 4);
|
|
i += 4;
|
|
memcpy(&out_frame[i], path_hashes, path_len);
|
|
i += path_len;
|
|
|
|
memcpy(&out_frame[i], path_snrs, path_len >> path_sz);
|
|
i += path_len >> path_sz;
|
|
out_frame[i++] = (int8_t)(packet->getSNR() * 4); // extra/final SNR (to this node)
|
|
|
|
#if COMPANION_FEATURE_TEXT_TERMINAL
|
|
if (hasTerminalOutput() && _terminal_trace_pending
|
|
&& tag == _terminal_trace_tag && auth_code == _terminal_trace_auth) {
|
|
Stream& output = terminalOutput();
|
|
const uint8_t hash_size = _terminal_trace_hash_size;
|
|
const uint8_t hop_count = hash_size == 0 ? 0 : path_len / hash_size;
|
|
const uint8_t response_hash_size = 1 << (flags & 0x03);
|
|
const unsigned long elapsed = (uint32_t)(now - _terminal_trace_sent_at);
|
|
if (hash_size == 0 || response_hash_size != hash_size
|
|
|| path_len % hash_size != 0
|
|
|| hop_count >= MAX_PATH_SIZE) {
|
|
return; // A malformed match must not consume a later valid response.
|
|
} else {
|
|
output.printf("\r\nTRACE -> %s (%lu ms)\r\n",
|
|
_terminal_trace_target, elapsed);
|
|
output.print(" ");
|
|
for (uint8_t hop = 0; hop < hop_count; hop++) {
|
|
output.print(((float)(int8_t)path_snrs[hop]) / 4.0f, 2);
|
|
output.print(" dB -> [");
|
|
mesh::Utils::printHex(output, &path_hashes[hop * hash_size],
|
|
hash_size);
|
|
output.print("] -> ");
|
|
}
|
|
output.print(packet->getSNR(), 2);
|
|
output.print(" dB\r\n> ");
|
|
}
|
|
clearTerminalTrace(now);
|
|
}
|
|
#endif
|
|
|
|
if (binary_trace_match) {
|
|
_delayed_replies.storeBinaryTrace(out_frame, i, now);
|
|
serviceBinaryTraceReply(now);
|
|
}
|
|
}
|
|
|
|
uint32_t MyMesh::calcFloodTimeoutMillisFor(uint32_t pkt_airtime_millis) const {
|
|
return SEND_TIMEOUT_BASE_MILLIS + (FLOOD_SEND_TIMEOUT_FACTOR * pkt_airtime_millis);
|
|
}
|
|
uint32_t MyMesh::calcDirectTimeoutMillisFor(uint32_t pkt_airtime_millis, uint8_t path_len) const {
|
|
uint8_t path_hash_count = path_len & 63;
|
|
return SEND_TIMEOUT_BASE_MILLIS +
|
|
((pkt_airtime_millis * DIRECT_SEND_PERHOP_FACTOR + DIRECT_SEND_PERHOP_EXTRA_MILLIS) *
|
|
(path_hash_count + 1));
|
|
}
|
|
|
|
void MyMesh::onSendTimeout() {
|
|
expireExpectedAcks();
|
|
}
|
|
|
|
MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMeshTables &tables, DataStore& store, AbstractUITask* ui)
|
|
: BaseChatMesh(radio, *new ArduinoMillis(), rng, rtc, *new StaticPoolPacketManager(16), tables),
|
|
#ifdef COMPANION_MESH_CLOCK_SYNC
|
|
_clock_sync(radio, _clock_sync_millis, rtc, _clock_sync_acl, sensors,
|
|
_prefs.airtime_factor),
|
|
#endif
|
|
_serial(NULL), _mota_source_control(NULL),
|
|
telemetry(MAX_PACKET_PAYLOAD - 4),
|
|
#if COMPANION_RTC_FAST_OFFLINE_FRAMES || COMPANION_RTC_SLOW_OFFLINE_FRAMES
|
|
offline_queue(rtcFastQueueBank(), rtcSlowQueueBank()),
|
|
#endif
|
|
_store(&store), _ui(ui), _iter(0) {
|
|
_iter_started = false;
|
|
_iter_reply_route = NULL;
|
|
_iter_start_pending = false;
|
|
_iter_contact_pending = false;
|
|
_iter_next_frame_at = 0;
|
|
_iter_total_count = 0;
|
|
_iter_table_revision = 0;
|
|
_cli_rescue = false;
|
|
#if COMPANION_FEATURE_TEXT_TERMINAL
|
|
_terminal_mode = false;
|
|
_terminal_output = NULL;
|
|
_terminal_recipient_set = false;
|
|
memset(_terminal_recipient_key, 0, sizeof(_terminal_recipient_key));
|
|
_terminal_login_pending = false;
|
|
memset(_terminal_login_key, 0, sizeof(_terminal_login_key));
|
|
_terminal_login_expires_at = 0;
|
|
_terminal_login_target[0] = 0;
|
|
clearTerminalCommand();
|
|
_terminal_trace_pending = false;
|
|
clearTerminalTrace();
|
|
#endif
|
|
saved_radio_apply_pending = false;
|
|
_radio_available = true;
|
|
radio_apply_retry_at = 0;
|
|
radio_apply_failures = 0;
|
|
_scheduled_reboot_at = 0;
|
|
#if COMPANION_FEATURE_TEMP_RADIO
|
|
_temp_radio_set_at = 0;
|
|
_temp_radio_revert_at = 0;
|
|
_temp_radio_retry_at = 0;
|
|
_temp_radio_freq = 0.0f;
|
|
_temp_radio_bw = 0.0f;
|
|
_temp_radio_sf = 0;
|
|
_temp_radio_cr = 0;
|
|
_temp_radio_failures = 0;
|
|
_temp_radio_applied = false;
|
|
#endif
|
|
cli_command[0] = 0;
|
|
offline_queue_len = 0;
|
|
offline_queue_head = 0;
|
|
#if defined(ESP32_PLATFORM) && defined(BOARD_HAS_PSRAM)
|
|
offline_queue = offline_queue_fallback;
|
|
offline_queue_capacity = OFFLINE_QUEUE_PSRAM_FALLBACK_SIZE;
|
|
#endif
|
|
app_target_ver = 0;
|
|
memset(expected_ack_table, 0, sizeof(expected_ack_table));
|
|
next_ack_idx = 0;
|
|
next_ack_expiry = 0;
|
|
has_next_ack_expiry = false;
|
|
sign_data = NULL;
|
|
sign_data_reply_route = NULL;
|
|
sign_data_deadline = 0;
|
|
dirty_contacts_expiry = 0;
|
|
dirty_contacts_failures = 0;
|
|
memset(advert_paths, 0, sizeof(advert_paths));
|
|
memset(send_scope.key, 0, sizeof(send_scope.key));
|
|
send_unscoped = false;
|
|
|
|
// defaults
|
|
_prefs.airtime_factor = 1.0;
|
|
_prefs.tx_delay_factor = 0.5f;
|
|
_prefs.direct_tx_delay_factor = 0.2f;
|
|
strcpy(_prefs.node_name, "NONAME");
|
|
_prefs.freq = LORA_FREQ;
|
|
_prefs.sf = LORA_SF;
|
|
_prefs.bw = LORA_BW;
|
|
_prefs.cr = LORA_CR;
|
|
_prefs.multi_acks = DEFAULT_MULTI_ACKS;
|
|
_prefs.manual_add_contacts = DEFAULT_MANUAL_ADD_CONTACTS;
|
|
_prefs.tx_power_dbm = LORA_TX_POWER;
|
|
_prefs.buzzer_quiet = DEFAULT_BUZZER_QUIET ? 1 : 0;
|
|
_prefs.vibe_quiet = 0;
|
|
_prefs.gps_enabled = 0; // GPS disabled by default
|
|
_prefs.gps_interval = 0; // Use the default 1-second fix-processing interval
|
|
_prefs.gps_sync_interval_hours = 0; // Retain the board's default time-sync cadence
|
|
#if defined(RAK_4631)
|
|
_prefs.peripherals_shared = 0; // A genuinely fresh missing image is GPS-only.
|
|
#endif
|
|
_prefs.autoadd_config = DEFAULT_AUTOADD_CONFIG;
|
|
_prefs.path_hash_mode = DEFAULT_COMPANION_PATH_HASH_MODE;
|
|
#ifdef RADIO_FEM_TXGAIN
|
|
_prefs.radio_fem_txgain = RADIO_FEM_TXGAIN;
|
|
#else
|
|
_prefs.radio_fem_txgain = 0;
|
|
#endif
|
|
#ifdef DEFAULT_RX_DELAY_BASE
|
|
_prefs.rx_delay_base = DEFAULT_RX_DELAY_BASE;
|
|
#endif
|
|
_prefs.setRepeatEn(false);
|
|
#if defined(USE_SX1262) || defined(USE_SX1268) || defined(USE_LR1110) \
|
|
|| defined(USE_LR2021) || defined(SX126X_RX_BOOSTED_GAIN) \
|
|
|| defined(RX_BOOSTED_GAIN)
|
|
_prefs.rx_boosted_gain = mesh::radio::configuredRxBoostedGainDefault();
|
|
#endif
|
|
_prefs.radio_fem_rxgain = DEFAULT_FEM_RX_GAIN;
|
|
_prefs.rx_powersaving_enabled = RXPS_FIXED_ENABLED ? 1 : 0;
|
|
_prefs.rx_ps_level = RXPS_FIXED_LEVEL;
|
|
_prefs.rx_ps_preamble = RXPS_FIXED_PREAMBLE;
|
|
_prefs.rx_ps_rx_us = RX_POWERSAVING_DEFAULT_RX_US;
|
|
_prefs.rx_ps_sleep_us = RX_POWERSAVING_DEFAULT_SLEEP_US;
|
|
_prefs.powersaving_enabled = 1;
|
|
_prefs.powersaving_policy_version = 0;
|
|
// Keep WiFi-only compatibility builds reachable when they have no other
|
|
// CLI transport. Full and Bluetooth/wired-capable Companions are opt-in.
|
|
#if defined(WIFI_SSID) && !defined(BLE_PIN_CODE) && !defined(ENABLE_USB_INTERFACE) \
|
|
&& !defined(SERIAL_RX) && !defined(ETHERNET_ENABLED)
|
|
_prefs.wifi_enabled = 1;
|
|
#else
|
|
_prefs.wifi_enabled = 0;
|
|
#endif
|
|
memset(_prefs.bluetooth_name, 0, sizeof(_prefs.bluetooth_name));
|
|
_prefs.bluetooth_enabled = 1;
|
|
_prefs.usb_debug_enabled = 0;
|
|
_prefs.display_rotation_degrees = 0;
|
|
_prefs.cad_enabled = DEFAULT_CAD_ENABLED ? 1 : 0;
|
|
_prefs.cad_scan_timeout_ms = 0;
|
|
_prefs.cad_retry_delay_ms = 0;
|
|
_prefs.cad_max_duration_ms = 0;
|
|
#if defined(ENABLE_USB_INTERFACE)
|
|
// Keep a USB Companion's primary stream exclusively framed on a fresh
|
|
// install. nRF52 Full can add a diagnostics port; ESP32 and other single-TTY
|
|
// builds switch the primary stream into the text terminal before logs.
|
|
_prefs.usb_logging_enabled = 0;
|
|
#else
|
|
_prefs.usb_logging_enabled = 1;
|
|
#endif
|
|
recalcRxPowerSavingFromLevel(_prefs.rx_ps_level, _prefs.sf, _prefs.bw,
|
|
_prefs.rx_ps_preamble, &_prefs.rx_ps_rx_us,
|
|
&_prefs.rx_ps_sleep_us);
|
|
|
|
#if defined(WITH_MQTT_BRIDGE) && defined(ESP32_PLATFORM) && defined(WIFI_SSID)
|
|
memset(&_mqtt_prefs, 0, sizeof(_mqtt_prefs));
|
|
_mqtt_bridge = nullptr;
|
|
_mqtt_configured = false;
|
|
_mqtt_started = false;
|
|
#endif
|
|
#ifdef WITH_WEBCONFIG
|
|
_webconfig = nullptr;
|
|
_wc_mqtt_dirty = false;
|
|
#endif
|
|
if (_ui != nullptr) _ui->setShutdownGuard(this);
|
|
}
|
|
|
|
void MyMesh::begin(bool has_display, bool radio_available,
|
|
mesh::ui::StartupScreen* startup_screen) {
|
|
_radio_available = radio_available;
|
|
setRadioAvailable(radio_available);
|
|
initializeContactStorage();
|
|
initializeOfflineQueue();
|
|
BaseChatMesh::begin();
|
|
_radio_profiles.begin(_store->getPrimaryFS(), _radio, getRTCClock());
|
|
mesh::loadMorseSettings(_store->getPrimaryFS());
|
|
#if defined(ENABLE_OTA)
|
|
mesh::ota::beginSpeedConfig(_store->getPrimaryFS());
|
|
#endif
|
|
#if COMPANION_FEATURE_OTA_CLI
|
|
mesh::ota::beginCompanionOtaConfig(_store->getPrimaryFS());
|
|
mesh::ota::ota_set_context_config_loader(mesh::ota::loadCompanionOtaConfig);
|
|
#endif
|
|
|
|
#ifdef DISPLAY_CLASS
|
|
if (startup_screen != nullptr) startup_screen->loadingIdentity();
|
|
#endif
|
|
const bool identity_loaded = _store->loadMainIdentity(self_id);
|
|
const bool is_new_install = !identity_loaded
|
|
|| mesh::hasReservedIdentityPrefix(self_id);
|
|
if (is_new_install && !_store->canCreateMainIdentity()) {
|
|
MESH_DEBUG_PRINTLN(
|
|
"Identity unavailable from persistent storage; rebooting without replacement");
|
|
board.reboot();
|
|
return;
|
|
}
|
|
|
|
bool identity_ready = true;
|
|
if (is_new_install) {
|
|
{
|
|
#ifdef DISPLAY_CLASS
|
|
if (startup_screen != nullptr) startup_screen->generatingKey();
|
|
mesh::ScopedIdentityGenerationProgress progress(
|
|
mesh::ui::StartupScreen::progress, startup_screen);
|
|
#endif
|
|
identity_ready = mesh::generateUsableLocalIdentity(self_id, radio_new_identity);
|
|
}
|
|
#ifdef DISPLAY_CLASS
|
|
// Generation has finished. Flash persistence must never retain its label
|
|
// or cooperative animation callback.
|
|
if (startup_screen != nullptr) {
|
|
if (identity_ready) startup_screen->savingIdentity();
|
|
else startup_screen->starting();
|
|
}
|
|
#endif
|
|
if (identity_ready) identity_ready = _store->saveMainIdentity(self_id);
|
|
}
|
|
#ifdef DISPLAY_CLASS
|
|
if (startup_screen != nullptr) startup_screen->starting();
|
|
#endif
|
|
|
|
#if defined(ESP32_PLATFORM)
|
|
mesh::discardESP32TrueRandom();
|
|
#endif
|
|
if (!identity_ready) {
|
|
MESH_DEBUG_PRINTLN("Identity generation/save failed; rebooting");
|
|
board.reboot();
|
|
return;
|
|
}
|
|
#if defined(ENABLE_OTA)
|
|
mesh::ota::ota_refresh_seeder_identity(self_id.pub_key);
|
|
#endif
|
|
|
|
// if name is provided as a build flag, use that as default node name instead
|
|
#ifdef ADVERT_NAME
|
|
strcpy(_prefs.node_name, ADVERT_NAME);
|
|
#else
|
|
// use hex of first 4 bytes of identity public key as default node name
|
|
char pub_key_hex[10];
|
|
mesh::Utils::toHex(pub_key_hex, self_id.pub_key, 4);
|
|
strcpy(_prefs.node_name, pub_key_hex);
|
|
#endif
|
|
|
|
// if build provides default-scope, init with that
|
|
#ifdef DEFAULT_FLOOD_SCOPE_NAME
|
|
strcpy(_prefs.default_scope_name, DEFAULT_FLOOD_SCOPE_NAME);
|
|
{
|
|
TransportKeyStore temp;
|
|
TransportKey key;
|
|
temp.getAutoKeyFor(0, "#" DEFAULT_FLOOD_SCOPE_NAME, key);
|
|
memcpy(_prefs.default_scope_key, key.key, sizeof(key.key));
|
|
}
|
|
#endif
|
|
|
|
mesh::ui::loadDisplayPowerSettings(_store->getPrimaryFS(),
|
|
#ifdef BLE_PIN_CODE
|
|
true,
|
|
#else
|
|
false,
|
|
#endif
|
|
true // All Companion transports show startup unless the saved mode forbids it.
|
|
);
|
|
|
|
#if COMPANION_FEATURE_NOTIFICATIONS
|
|
auto& notification_settings = _notifications.settings();
|
|
FILESYSTEM* notification_fs = _store->getPrimaryFS();
|
|
if (!(mesh::notify::readSettings(notification_fs, "/notify_prefs", notification_settings)
|
|
&& _notifications.validSettings(notification_settings))) {
|
|
if (!(mesh::notify::readSettings(notification_fs, "/notify_prefs.bak", notification_settings)
|
|
&& _notifications.validSettings(notification_settings)))
|
|
mesh::notify::resetSettings(notification_settings);
|
|
}
|
|
|
|
#endif
|
|
|
|
// load persisted prefs
|
|
const bool prefs_ready =
|
|
_store->loadPrefs(_prefs, sensors.node_lat, sensors.node_lon);
|
|
#if MESH_HIL_COMPANION_STARTUP_FREQ_MHZ
|
|
// A role transition can expose old Companion preferences independently of
|
|
// the Repeater's saved radio. Keep this private image on its bench channel
|
|
// before saved parameters or automatic traffic can consume that frequency.
|
|
mesh::companion::applyHilStartupFrequency(_prefs.freq);
|
|
#endif
|
|
FILESYSTEM* battery_calibration_fs = _store->getPrimaryFS();
|
|
#if defined(NRF52_PLATFORM)
|
|
if (_store->isVolatilePrimaryFS()) battery_calibration_fs = nullptr;
|
|
#endif
|
|
if (!mesh::power::beginBatteryCalibrationStore(board, battery_calibration_fs)) {
|
|
MESH_DEBUG_PRINTLN("Battery calibration could not be restored; using board default");
|
|
}
|
|
#if defined(ESP32_PLATFORM) && defined(WIFI_SSID)
|
|
#ifdef ADMIN_PASSWORD
|
|
const char* management_default = ADMIN_PASSWORD;
|
|
#else
|
|
const char* management_default = "password";
|
|
#endif
|
|
_management_password_ready = mesh::esp32::companionAdminPassword::load(
|
|
_management_password, management_default);
|
|
if (_management_password_ready) {
|
|
_management_password_ready =
|
|
mesh::esp32::managementPassword::publish(_management_password);
|
|
}
|
|
if (!_management_password_ready) {
|
|
mesh::usbLoggingPort().println(
|
|
"Management password unavailable; use password <new-password> on USB to repair");
|
|
}
|
|
#endif
|
|
#if defined(RAK_4631)
|
|
// Only a verified image (or a fresh missing file) may permit GPS-only power.
|
|
// A failed load never replaces a stored shared policy with an unsafe cut.
|
|
if (!prefs_ready) _prefs.peripherals_shared = 1;
|
|
sensors.configureSharedPeripheralMode(_prefs.peripherals_shared != 0);
|
|
#endif
|
|
|
|
#if defined(NRF52_PLATFORM)
|
|
// A volatile primary filesystem is the final recovery mode after the
|
|
// physical medium has exhausted its delayed reads and destructive repair.
|
|
// Apply the diagnostic name before BLE, USB, MQTT, or adverts consume it.
|
|
if (_store->isVolatilePrimaryFS()) {
|
|
strncpy(_prefs.node_name, mesh::storage::BAD_FILESYSTEM_NODE_NAME,
|
|
sizeof(_prefs.node_name));
|
|
_prefs.node_name[sizeof(_prefs.node_name) - 1] = 0;
|
|
}
|
|
#endif
|
|
|
|
// v1.17.1.2 repairs the Companion default-off regression for both fresh
|
|
// installs and devices that already persisted the regressed value. The
|
|
// appended policy marker makes this a one-time migration, so a later
|
|
// explicit `powersaving off` choice remains persistent.
|
|
const bool power_saving_default_migrated =
|
|
migrateCompanionPowerSavingDefault(_prefs);
|
|
|
|
_prefs.node_name[sizeof(_prefs.node_name) - 1] = 0;
|
|
const bool bluetooth_name_had_terminator =
|
|
memchr(_prefs.bluetooth_name, 0, sizeof(_prefs.bluetooth_name)) != NULL;
|
|
_prefs.bluetooth_name[sizeof(_prefs.bluetooth_name) - 1] = 0;
|
|
const bool bluetooth_name_repaired =
|
|
!bluetooth_name_had_terminator
|
|
|| (_prefs.bluetooth_name[0] != 0
|
|
&& !mesh::companion::isValidBluetoothName(_prefs.bluetooth_name));
|
|
if (bluetooth_name_repaired) {
|
|
memset(_prefs.bluetooth_name, 0, sizeof(_prefs.bluetooth_name));
|
|
}
|
|
const bool display_rotation_repaired =
|
|
_prefs.display_rotation_degrees != 0
|
|
&& _prefs.display_rotation_degrees != 90
|
|
&& _prefs.display_rotation_degrees != 180
|
|
&& _prefs.display_rotation_degrees != 270;
|
|
if (display_rotation_repaired) {
|
|
_prefs.display_rotation_degrees = 0;
|
|
}
|
|
|
|
bool bluetooth_mac_repaired = false;
|
|
if (!mesh::companion::isValidBluetoothStealthMode(
|
|
_prefs.bluetooth_stealth_mode)) {
|
|
_prefs.bluetooth_stealth_mode = mesh::companion::BLUETOOTH_STEALTH_OFF;
|
|
bluetooth_mac_repaired = true;
|
|
}
|
|
if (!mesh::companion::isValidBluetoothMacMode(
|
|
_prefs.bluetooth_mac_mode)) {
|
|
_prefs.bluetooth_mac_mode =
|
|
mesh::companion::BLUETOOTH_MAC_DEFAULT;
|
|
memset(_prefs.bluetooth_mac, 0, sizeof(_prefs.bluetooth_mac));
|
|
clearCompanionBluetoothStealthPeer(_prefs);
|
|
bluetooth_mac_repaired = true;
|
|
} else if (mesh::companion::bluetoothMacModeUsesSavedAddress(
|
|
_prefs.bluetooth_mac_mode)
|
|
&& !mesh::companion::isValidBluetoothMac(
|
|
_prefs.bluetooth_mac)
|
|
&& !mesh::companion::bluetoothMacModeIsRandomAfterConnect(
|
|
_prefs.bluetooth_mac_mode)) {
|
|
_prefs.bluetooth_mac_mode =
|
|
mesh::companion::BLUETOOTH_MAC_DEFAULT;
|
|
memset(_prefs.bluetooth_mac, 0, sizeof(_prefs.bluetooth_mac));
|
|
clearCompanionBluetoothStealthPeer(_prefs);
|
|
bluetooth_mac_repaired = true;
|
|
}
|
|
|
|
mesh::companion::BluetoothPeerIdentity saved_stealth_peer;
|
|
saved_stealth_peer.type = _prefs.bluetooth_stealth_peer_type;
|
|
memcpy(saved_stealth_peer.address, _prefs.bluetooth_stealth_peer,
|
|
sizeof(saved_stealth_peer.address));
|
|
bool stealth_peer_payload_empty = true;
|
|
for (size_t i = 0; i < sizeof(_prefs.bluetooth_stealth_peer); i++) {
|
|
stealth_peer_payload_empty = stealth_peer_payload_empty
|
|
&& _prefs.bluetooth_stealth_peer[i] == 0;
|
|
}
|
|
if (_prefs.bluetooth_stealth_mode
|
|
== mesh::companion::BLUETOOTH_STEALTH_PAIRED
|
|
&& !mesh::companion::isValidBluetoothPeerIdentity(
|
|
saved_stealth_peer)) {
|
|
_prefs.bluetooth_stealth_mode = mesh::companion::BLUETOOTH_STEALTH_PAIRING;
|
|
bluetooth_mac_repaired = true;
|
|
}
|
|
if (_prefs.bluetooth_stealth_mode
|
|
!= mesh::companion::BLUETOOTH_STEALTH_PAIRED
|
|
&& (_prefs.bluetooth_stealth_peer_type
|
|
!= mesh::companion::BLUETOOTH_PEER_ADDRESS_NONE
|
|
|| !stealth_peer_payload_empty)) {
|
|
_prefs.bluetooth_stealth_peer_type =
|
|
mesh::companion::BLUETOOTH_PEER_ADDRESS_NONE;
|
|
memset(_prefs.bluetooth_stealth_peer, 0,
|
|
sizeof(_prefs.bluetooth_stealth_peer));
|
|
bluetooth_mac_repaired = true;
|
|
}
|
|
|
|
// sanitise bad pref values
|
|
_prefs.rx_delay_base = constrain(_prefs.rx_delay_base, 0, 20.0f);
|
|
if (!isfinite(_prefs.tx_delay_factor)) _prefs.tx_delay_factor = 0.5f;
|
|
if (!isfinite(_prefs.direct_tx_delay_factor)) _prefs.direct_tx_delay_factor = 0.2f;
|
|
_prefs.tx_delay_factor = constrain(_prefs.tx_delay_factor, 0, 2.0f);
|
|
_prefs.direct_tx_delay_factor = constrain(_prefs.direct_tx_delay_factor, 0, 2.0f);
|
|
if (!isfinite(_prefs.airtime_factor)) _prefs.airtime_factor = 1.0f;
|
|
// `set dutycycle 1` stores factor 99; the legacy `set af` input limit is
|
|
// narrower, but must not truncate an accepted duty cycle on restart.
|
|
_prefs.airtime_factor = constrain(_prefs.airtime_factor, 0, 99.0f);
|
|
_prefs.freq = constrain(_prefs.freq, 150.0f, 2500.0f);
|
|
_prefs.bw = constrain(_prefs.bw, 7.8f, 500.0f);
|
|
_prefs.sf = constrain(_prefs.sf, 5, 12);
|
|
_prefs.cr = constrain(_prefs.cr, 5, 8);
|
|
_prefs.tx_power_dbm = constrain(_prefs.tx_power_dbm, -9, MAX_LORA_TX_POWER);
|
|
_prefs.flood_retry_attempts = constrain(_prefs.flood_retry_attempts, 0, 15);
|
|
if (_prefs.flood_retry_max_path > 63
|
|
&& _prefs.flood_retry_max_path != FLOOD_RETRY_PATH_GATE_DISABLED) {
|
|
_prefs.flood_retry_max_path = 1;
|
|
}
|
|
if (_prefs.flood_retry_group_max_path > 63
|
|
&& _prefs.flood_retry_group_max_path != FLOOD_RETRY_PATH_GATE_DISABLED) {
|
|
_prefs.flood_retry_group_max_path = FLOOD_RETRY_PATH_GATE_DISABLED;
|
|
}
|
|
_prefs.flood_retry_advert_enabled = _prefs.flood_retry_advert_enabled ? 1 : 0;
|
|
_prefs.multi_acks = constrain(_prefs.multi_acks, 0, 1);
|
|
_prefs.manual_add_contacts = constrain(_prefs.manual_add_contacts, 0, 1);
|
|
_prefs.vibe_quiet = constrain(_prefs.vibe_quiet, 0, 1);
|
|
_prefs.gps_enabled = constrain(_prefs.gps_enabled, 0, 1); // Ensure boolean 0 or 1
|
|
_prefs.gps_interval = constrain(_prefs.gps_interval, 0, 86400); // Max 24 hours
|
|
_prefs.gps_sync_interval_hours = constrain(_prefs.gps_sync_interval_hours,
|
|
0, mesh::gps::MAX_SYNC_INTERVAL_HOURS);
|
|
_prefs.autoadd_config &= AUTO_ADD_OVERWRITE_OLDEST | AUTO_ADD_CHAT | AUTO_ADD_REPEATER | AUTO_ADD_ROOM_SERVER | AUTO_ADD_SENSOR;
|
|
_prefs.path_hash_mode = constrain(_prefs.path_hash_mode, 0, 2);
|
|
_prefs.radio_fem_rxgain_override = constrain(_prefs.radio_fem_rxgain_override, 0, 1);
|
|
if (!_prefs.radio_fem_rxgain_override) {
|
|
_prefs.radio_fem_rxgain = DEFAULT_FEM_RX_GAIN;
|
|
}
|
|
_prefs.radio_fem_rxgain = constrain(_prefs.radio_fem_rxgain, 0, 1);
|
|
_prefs.radio_fem_txgain = constrain(_prefs.radio_fem_txgain, 0, 1);
|
|
_prefs.rx_powersaving_enabled = constrain(_prefs.rx_powersaving_enabled, 0, 1);
|
|
_prefs.powersaving_enabled = constrain(_prefs.powersaving_enabled, 0, 1);
|
|
#if defined(ESP32_PLATFORM)
|
|
if (!mesh::esp32::cpuclock::validSelection(_prefs.cpu_freq_mhz)
|
|
|| (_prefs.cpu_freq_mhz && !board.supportsCpuFrequencyMHz(_prefs.cpu_freq_mhz)))
|
|
_prefs.cpu_freq_mhz = 0;
|
|
board.setCpuFrequencyRequest(_prefs.cpu_freq_mhz, _prefs.powersaving_enabled != 0);
|
|
#endif
|
|
_prefs.wifi_enabled = constrain(_prefs.wifi_enabled, 0, 1);
|
|
_prefs.usb_logging_enabled = constrain(_prefs.usb_logging_enabled, 0, 2);
|
|
_prefs.usb_debug_enabled = _prefs.usb_debug_enabled == 1 ? 1 : 0;
|
|
_prefs.cad_enabled = constrain(_prefs.cad_enabled, 0, 1);
|
|
if (_prefs.cad_scan_timeout_ms != 0
|
|
&& (_prefs.cad_scan_timeout_ms < mesh::CAD_SCAN_MIN_TIMEOUT_MS
|
|
|| _prefs.cad_scan_timeout_ms > mesh::CAD_SCAN_MAX_TIMEOUT_MS)) {
|
|
_prefs.cad_scan_timeout_ms = 0;
|
|
}
|
|
_prefs.rx_ps_level = constrain(_prefs.rx_ps_level, 0, 10);
|
|
if (_prefs.rx_ps_preamble != 16 && _prefs.rx_ps_preamble != 32) {
|
|
_prefs.rx_ps_preamble = 0;
|
|
}
|
|
ensureRxPowerSavingDefaults(&_prefs.rx_ps_rx_us, &_prefs.rx_ps_sleep_us);
|
|
recalcRxPowerSavingFromLevel(_prefs.rx_ps_level, _prefs.sf, _prefs.bw,
|
|
_prefs.rx_ps_preamble, &_prefs.rx_ps_rx_us,
|
|
&_prefs.rx_ps_sleep_us);
|
|
if (prefs_ready
|
|
&& (power_saving_default_migrated || bluetooth_name_repaired
|
|
|| display_rotation_repaired || bluetooth_mac_repaired)) {
|
|
_store->savePrefs(_prefs, sensors.node_lat, sensors.node_lon);
|
|
}
|
|
#if MESH_USB_LOGGING_AVAILABLE
|
|
const bool usb_logging_enabled = _prefs.usb_logging_enabled != 0;
|
|
mesh::configureUsbLoggingPacketStream(_prefs.usb_logging_enabled == 2);
|
|
mesh::setUsbDebugEnabled(_prefs.usb_debug_enabled != 0);
|
|
applyUsbLoggingState(usb_logging_enabled);
|
|
if (!mesh::saveUsbLoggingBootPreference(usb_logging_enabled)) {
|
|
MESH_DEBUG_PRINTLN("Unable to save next-boot USB logging interface state");
|
|
}
|
|
#endif
|
|
|
|
#ifdef BLE_PIN_CODE // 123456 by default
|
|
if (_prefs.ble_pin == 0) {
|
|
#ifdef DISPLAY_CLASS
|
|
if (has_display && BLE_PIN_CODE == 123456) {
|
|
StdRNG rng;
|
|
_active_ble_pin = rng.nextInt(100000, 999999); // random pin each session
|
|
} else {
|
|
_active_ble_pin = BLE_PIN_CODE; // otherwise static pin
|
|
}
|
|
#else
|
|
_active_ble_pin = BLE_PIN_CODE; // otherwise static pin
|
|
#endif
|
|
} else {
|
|
_active_ble_pin = _prefs.ble_pin;
|
|
}
|
|
#else
|
|
_active_ble_pin = 0;
|
|
#endif
|
|
|
|
resetContacts();
|
|
_store->loadContacts(this);
|
|
_store->recoverContacts(this);
|
|
if (_store->hasPendingContactWrites()) {
|
|
dirty_contacts_expiry = mesh::nonzeroLazyPersistenceDeadline(
|
|
futureMillis(LAZY_CONTACTS_WRITE_DELAY));
|
|
}
|
|
updateGpsTelemetryPolicy();
|
|
bootstrapRTCfromContacts();
|
|
addChannel("Public", PUBLIC_GROUP_PSK); // pre-configure Andy's public channel
|
|
if (is_new_install) {
|
|
addChannel("#testing", TESTING_GROUP_PSK);
|
|
}
|
|
// A saved table takes precedence over compiled defaults on later boots.
|
|
_store->loadChannels(this);
|
|
if (is_new_install) {
|
|
// Make the first-boot defaults survive before a companion app changes them.
|
|
_store->saveChannels(this);
|
|
}
|
|
|
|
#ifdef COMPANION_MESH_CLOCK_SYNC
|
|
// Fixed fallback policy for this Companion build. A successful host time
|
|
// update below suppresses mesh correction for the remainder of the boot.
|
|
_clock_sync.begin(nullptr);
|
|
#endif
|
|
|
|
configureRadioFromPrefs();
|
|
#if MESH_ENABLE_TX_BEACONS
|
|
beginBeacons();
|
|
#endif
|
|
#if MESH_ENABLE_ETHERNET_TUNNEL
|
|
beginEthernetTunnel();
|
|
#endif
|
|
#if MESH_ENABLE_MESHTEXT_CHAT
|
|
beginMeshtext();
|
|
#endif
|
|
|
|
#if defined(WITH_MQTT_BRIDGE) && defined(ESP32_PLATFORM) && defined(WIFI_SSID)
|
|
_mqtt_enabled = CompanionMqttSetupPortal::loadEnabled();
|
|
// Keep settings available to the USB CLI even when this boot selects BLE.
|
|
applyMQTTDefaults(&_mqtt_prefs);
|
|
if (!CompanionMqttSetupPortal::loadStoredConfig(_mqtt_prefs)) {
|
|
applyMQTTDefaults(&_mqtt_prefs);
|
|
}
|
|
_mqtt_configured = CompanionMqttSetupPortal::hasConfiguredSlot(_mqtt_prefs);
|
|
// Companion WiFi owns the connection and its mesh-wifi NVS setting is
|
|
// canonical. Keep MQTT reconnects from restoring a stale MQTT-pref value.
|
|
_mqtt_prefs.wifi_power_save = getCompanionWiFiPowerSave();
|
|
|
|
#if defined(COMPANION_EXCLUSIVE_WIFI_BLE)
|
|
if (_prefs.wifi_enabled != 0) {
|
|
#endif
|
|
MQTTNodeInfo node_info;
|
|
node_info.node_name = _prefs.node_name;
|
|
node_info.freq = &_prefs.freq;
|
|
node_info.bw = &_prefs.bw;
|
|
node_info.sf = &_prefs.sf;
|
|
node_info.cr = &_prefs.cr;
|
|
node_info.repeat_flag = &_prefs.client_repeat;
|
|
node_info.repeat_when_nonzero = true;
|
|
_mqtt_bridge = new MQTTBridge(node_info, &_mqtt_prefs,
|
|
getRTCClock(), &self_id, false);
|
|
if (_mqtt_bridge) {
|
|
char device_id[65];
|
|
mesh::Utils::toHex(device_id, self_id.pub_key, PUB_KEY_SIZE);
|
|
_mqtt_bridge->setDeviceID(device_id);
|
|
_mqtt_bridge->setFirmwareVersion(FIRMWARE_VERSION);
|
|
_mqtt_bridge->setBoardModel(board.getManufacturerName());
|
|
_mqtt_bridge->setBuildDate(FIRMWARE_BUILD_DATE);
|
|
_mqtt_bridge->setStatsSources(this, _radio, &board, _ms);
|
|
}
|
|
#if defined(COMPANION_EXCLUSIVE_WIFI_BLE)
|
|
}
|
|
#endif
|
|
#endif
|
|
|
|
#ifdef WITH_WEBCONFIG
|
|
#if defined(COMPANION_EXCLUSIVE_WIFI_BLE)
|
|
if (_prefs.wifi_enabled != 0) {
|
|
#endif
|
|
void* web_mqtt_prefs = nullptr;
|
|
#ifdef WITH_MQTT_BRIDGE
|
|
web_mqtt_prefs = &_mqtt_prefs;
|
|
#endif
|
|
_webconfig = new WebConfigServer(this, web_mqtt_prefs, false,
|
|
self_id.pub_key, FIRMWARE_VERSION,
|
|
FIRMWARE_BUILD_DATE,
|
|
"companion", board.getManufacturerName());
|
|
#if defined(COMPANION_EXCLUSIVE_WIFI_BLE)
|
|
}
|
|
#endif
|
|
#endif
|
|
}
|
|
|
|
void MyMesh::configureRadioFromPrefs() {
|
|
board.attachDynamicPrefs(_prefs.getCustom());
|
|
#if defined(WIO_E5_MINI_HEADLESS_COMPANION_UI)
|
|
// The reserved preference byte keeps the existing image and filesystem size.
|
|
_prefs.leds_quiet = _prefs.leds_quiet == 1 ? 1 : 0;
|
|
mesh::leds::setEnabled(!_prefs.leds_quiet);
|
|
board.applyLedPolicy();
|
|
mesh::leds::applyStatusPolicy();
|
|
#endif
|
|
|
|
if (!_radio_available) {
|
|
board.setLoRaFemLnaEnabled(_prefs.radio_fem_rxgain);
|
|
board.setLoRaFemPaGainEnabled(_prefs.radio_fem_txgain);
|
|
MESH_DEBUG_PRINTLN("Radio unavailable: companion services are running in recovery mode");
|
|
return;
|
|
}
|
|
|
|
saved_radio_apply_pending |= !_primary_radio_blocked && !applySavedRadioParams();
|
|
radio_driver.setCADScanTimeoutMillis(_prefs.cad_scan_timeout_ms);
|
|
_radio->setCADEnabled(_prefs.cad_enabled != 0);
|
|
if (!saved_radio_apply_pending) {
|
|
saved_radio_apply_pending = !radio_driver.setTxPower(_prefs.tx_power_dbm)
|
|
|| (radio_driver.supportsRxBoostedGainMode()
|
|
&& !radio_driver.setRxBoostedGainMode(_prefs.rx_boosted_gain));
|
|
}
|
|
const bool fem_gain_changed = board.canControlLoRaFemLna()
|
|
&& board.isLoRaFemLnaEnabled() != (_prefs.radio_fem_rxgain != 0);
|
|
if (board.setLoRaFemLnaEnabled(_prefs.radio_fem_rxgain) && fem_gain_changed) {
|
|
_radio->recalibrateNoiseFloor();
|
|
}
|
|
board.setLoRaFemPaGainEnabled(_prefs.radio_fem_txgain);
|
|
MESH_DEBUG_PRINTLN("RX Boosted Gain Mode: %s",
|
|
radio_driver.getRxBoostedGainMode() ? "Enabled" : "Disabled");
|
|
}
|
|
|
|
void MyMesh::activateRadio() {
|
|
if (_radio_available) return;
|
|
|
|
_radio_available = true;
|
|
setRadioAvailable(true);
|
|
configureRadioFromPrefs();
|
|
MESH_DEBUG_PRINTLN("Radio recovery completed; mesh transport is active");
|
|
}
|
|
|
|
mesh::RadioParamApplyResult MyMesh::tryApplyRadioParams(float freq, float bw, uint8_t sf, uint8_t cr, bool temporary, uint16_t preamble) {
|
|
if (!_radio_available) return mesh::RadioParamApplyResult::FAILED;
|
|
|
|
uint32_t rx_us = _prefs.rx_ps_rx_us;
|
|
uint32_t sleep_us = _prefs.rx_ps_sleep_us;
|
|
if (_prefs.rx_powersaving_enabled && _prefs.rx_ps_level != 0
|
|
&& !recalcRxPowerSavingFromLevel(_prefs.rx_ps_level, sf, bw,
|
|
_prefs.rx_ps_preamble, &rx_us, &sleep_us)) {
|
|
return mesh::RadioParamApplyResult::FAILED;
|
|
}
|
|
uint32_t timings[2] = {rx_us, sleep_us};
|
|
const uint32_t* applied_timings = _prefs.rx_powersaving_enabled
|
|
&& radio_driver.supportsRxPowerSaving() ? timings : NULL;
|
|
return _radio_profiles.applyPrimary(freq, bw, sf, cr, temporary,
|
|
temporary ? preamble : _radio_profiles.primaryPreamble(), applied_timings);
|
|
}
|
|
|
|
bool MyMesh::applySavedRadioParams() {
|
|
return tryApplyRadioParams(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr)
|
|
== mesh::RadioParamApplyResult::APPLIED;
|
|
}
|
|
|
|
__attribute__((noinline)) void MyMesh::queueSavedRadioApply() {
|
|
saved_radio_apply_pending = true;
|
|
radio_apply_retry_at = 0;
|
|
radio_apply_failures = 0;
|
|
}
|
|
|
|
void MyMesh::finishPrimaryRadioReply(bool delivered) {
|
|
_primary_radio_reply.clear();
|
|
if (delivered) {
|
|
_primary_radio_blocked = false;
|
|
queueSavedRadioApply();
|
|
}
|
|
}
|
|
|
|
MyMesh::RadioSettingResult MyMesh::applyAndSaveRadioParams(
|
|
float freq, float bw, uint8_t sf, uint8_t cr, uint8_t repeat, uint16_t preamble, bool remote) {
|
|
#if COMPANION_FEATURE_TEMP_RADIO
|
|
// A permanent remote change cannot own the acknowledgement and a lease's
|
|
// restoration at once. Local changes retain the existing deferred behavior.
|
|
if (remote && (_temp_radio_applied || _temp_radio_set_at || _temp_radio_revert_at))
|
|
return RadioSettingResult::RadioRejected;
|
|
#endif
|
|
if (freq < 150.0f || !CommonRadioPrefs::isValidBandwidth(bw)
|
|
|| !_radio_profiles.acceptsPrimary(freq, bw, sf, cr, preamble)
|
|
|| (repeat && !isValidClientRepeatFreq(uint32_t(freq * 1000.0f))))
|
|
return RadioSettingResult::RadioRejected;
|
|
uint32_t rx_us = _prefs.rx_ps_rx_us, sleep_us = _prefs.rx_ps_sleep_us;
|
|
if (_prefs.rx_ps_level
|
|
&& !recalcRxPowerSavingFromLevel(_prefs.rx_ps_level, sf, bw,
|
|
_prefs.rx_ps_preamble, &rx_us, &sleep_us)
|
|
&& _prefs.rx_powersaving_enabled) return RadioSettingResult::RadioRejected;
|
|
|
|
const uint16_t previous_preamble = _radio_profiles.primaryPreamble();
|
|
if (!_radio_profiles.savePrimaryPreamble(preamble)) return RadioSettingResult::SaveFailed;
|
|
if (remote && _primary_radio_blocked && freq == _prefs.freq && bw == _prefs.bw
|
|
&& sf == _prefs.sf && cr == _prefs.cr && repeat == _prefs.client_repeat
|
|
&& preamble == previous_preamble) {
|
|
// Replaying an unconfirmed request sends a fresh acknowledgement without
|
|
// saving again. Only this response's exact copies can release its target.
|
|
_primary_radio_reply.clear();
|
|
_primary_radio_reply_admission = true;
|
|
return RadioSettingResult::Saved;
|
|
}
|
|
const auto result = _radio_available && !remote && !_primary_radio_blocked
|
|
#if COMPANION_FEATURE_TEMP_RADIO
|
|
&& !_temp_radio_applied && !_temp_radio_set_at && !_temp_radio_revert_at
|
|
#endif
|
|
? tryApplyRadioParams(freq, bw, sf, cr) : mesh::RadioParamApplyResult::BUSY;
|
|
if (result == mesh::RadioParamApplyResult::FAILED) {
|
|
const bool restored = _radio_profiles.restorePrimaryPreamble(previous_preamble);
|
|
queueSavedRadioApply();
|
|
return restored ? RadioSettingResult::RadioRejected : RadioSettingResult::SaveUnconfirmed;
|
|
}
|
|
|
|
const float previous_freq = _prefs.freq, previous_bw = _prefs.bw;
|
|
const uint8_t previous_sf = _prefs.sf, previous_cr = _prefs.cr;
|
|
const uint8_t previous_repeat = _prefs.client_repeat;
|
|
const uint32_t previous_rx = _prefs.rx_ps_rx_us, previous_sleep = _prefs.rx_ps_sleep_us;
|
|
_prefs.freq = freq; _prefs.bw = bw; _prefs.sf = sf; _prefs.cr = cr;
|
|
_prefs.client_repeat = repeat;
|
|
_prefs.rx_ps_rx_us = rx_us; _prefs.rx_ps_sleep_us = sleep_us;
|
|
if (savePrefs()) {
|
|
_primary_radio_reply.clear();
|
|
_primary_radio_blocked = remote;
|
|
_primary_radio_reply_admission = remote;
|
|
if (result == mesh::RadioParamApplyResult::BUSY) queueSavedRadioApply();
|
|
return RadioSettingResult::Saved;
|
|
}
|
|
|
|
_prefs.freq = previous_freq; _prefs.bw = previous_bw;
|
|
_prefs.sf = previous_sf; _prefs.cr = previous_cr;
|
|
_prefs.client_repeat = previous_repeat;
|
|
_prefs.rx_ps_rx_us = previous_rx; _prefs.rx_ps_sleep_us = previous_sleep;
|
|
const bool preamble_restored = _radio_profiles.restorePrimaryPreamble(previous_preamble);
|
|
if ((result == mesh::RadioParamApplyResult::APPLIED && !applySavedRadioParams())
|
|
|| !preamble_restored) queueSavedRadioApply();
|
|
return preamble_restored ? RadioSettingResult::SaveFailed : RadioSettingResult::SaveUnconfirmed;
|
|
}
|
|
|
|
bool MyMesh::applyRxPowerSavingRfRxDisabled(const char* value, char* reply, size_t capacity) {
|
|
if (strcmp(value, "on") && strcmp(value, "off")) {
|
|
mesh::cli::formatError(reply, capacity, "use set radio.rxps.rfrx_disabled on|off");
|
|
} else if (!radio_driver.supportsRxPowerSavingRfRxDisable()) {
|
|
mesh::cli::formatError(reply, capacity, "unsupported");
|
|
} else {
|
|
const bool disabled = !strcmp(value, "on");
|
|
const auto result = _radio_available
|
|
? radio_driver.trySetRxPowerSavingRfRxDisabled(disabled) : mesh::RadioParamApplyResult::BUSY;
|
|
if (result == mesh::RadioParamApplyResult::FAILED) {
|
|
mesh::cli::formatError(reply, capacity, "RXEN change failed");
|
|
} else {
|
|
rf_rx_disable_pending = result == mesh::RadioParamApplyResult::BUSY ? int8_t(disabled) : -1;
|
|
rf_rx_retry_at = 0;
|
|
snprintf(reply, capacity, "OK");
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
void MyMesh::servicePendingLocalRadioSettings() {
|
|
_radio_profiles.servicePendingTuning();
|
|
servicePendingTxPower();
|
|
if (rf_rx_disable_pending >= 0 && _radio_available
|
|
&& !(_radio && _radio->isCarrierWaveActive())
|
|
&& (!rf_rx_retry_at || millisHasNowPassed(rf_rx_retry_at))) {
|
|
const auto result = radio_driver.trySetRxPowerSavingRfRxDisabled(rf_rx_disable_pending != 0);
|
|
if (result == mesh::RadioParamApplyResult::APPLIED) rf_rx_disable_pending = -1;
|
|
rf_rx_retry_at = result == mesh::RadioParamApplyResult::FAILED ? futureMillis(1000) : 0;
|
|
if (result == mesh::RadioParamApplyResult::FAILED && !rf_rx_retry_at) rf_rx_retry_at = 1;
|
|
}
|
|
if (!saved_radio_apply_pending || !_radio_available
|
|
|| (_radio && (_radio->isCarrierWaveActive()
|
|
|| _radio->isMorseTxActive() || _radio->isMorseRxActive()))
|
|
#if COMPANION_FEATURE_TEMP_RADIO
|
|
|| _temp_radio_applied || _temp_radio_set_at || _temp_radio_revert_at
|
|
#endif
|
|
|| (radio_apply_retry_at && !millisHasNowPassed(radio_apply_retry_at))) return;
|
|
// Driver admission preserves unread packets. This also runs in the idle
|
|
// gap before Dispatcher starts the next packet of a saturated TX queue.
|
|
const auto gain = radio_driver.supportsRxBoostedGainMode()
|
|
? radio_driver.trySetRxBoostedGainMode(_prefs.rx_boosted_gain)
|
|
: mesh::RadioParamApplyResult::APPLIED;
|
|
const auto tuple = !_primary_radio_blocked && gain == mesh::RadioParamApplyResult::APPLIED
|
|
? tryApplyRadioParams(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr) : gain;
|
|
const auto power = tuple == mesh::RadioParamApplyResult::APPLIED
|
|
? radio_driver.trySetTxPower(_prefs.tx_power_dbm) : tuple;
|
|
uint32_t rx_us = _prefs.rx_ps_rx_us, sleep_us = _prefs.rx_ps_sleep_us;
|
|
if (_primary_radio_blocked && _prefs.rx_ps_level && _radio && _radio->profiles()) {
|
|
const auto& active = _radio->profiles()->primary;
|
|
recalcRxPowerSavingFromLevel(_prefs.rx_ps_level, active.sf, active.bw,
|
|
_prefs.rx_ps_preamble, &rx_us, &sleep_us);
|
|
}
|
|
const auto result = power == mesh::RadioParamApplyResult::APPLIED
|
|
&& radio_driver.supportsRxPowerSaving()
|
|
? radio_driver.trySetRxPowerSaving(_prefs.rx_powersaving_enabled != 0,
|
|
rx_us, sleep_us) : power;
|
|
if (result == mesh::RadioParamApplyResult::APPLIED) {
|
|
saved_radio_apply_pending = false;
|
|
radio_apply_retry_at = 0;
|
|
radio_apply_failures = 0;
|
|
} else if (result == mesh::RadioParamApplyResult::FAILED) {
|
|
radio_apply_retry_at = futureMillis(nextRadioApplyRetryDelay(radio_apply_failures));
|
|
if (!radio_apply_retry_at) radio_apply_retry_at = 1;
|
|
}
|
|
}
|
|
|
|
#if COMPANION_FEATURE_TEMP_RADIO
|
|
static bool isFullCompanionBandwidth(float bw) {
|
|
static const float supported[] = {
|
|
7.8f, 10.4f, 15.6f, 20.8f, 31.25f, 41.7f,
|
|
62.5f, 125.0f, 250.0f, 500.0f
|
|
};
|
|
for (float candidate : supported) {
|
|
if (fabsf(candidate - bw) <= 0.01f) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
bool MyMesh::scheduleTempRadio(float freq, float bw, uint8_t sf, uint8_t cr,
|
|
uint32_t timeout_mins, char* reply,
|
|
size_t reply_size) {
|
|
if (_primary_radio_blocked) {
|
|
mesh::cli::formatError(reply, reply_size, "radio acknowledgement pending");
|
|
return false;
|
|
}
|
|
#if defined(OTA_SHARED_COMPANION_QUEUE)
|
|
uint16_t drained = 0;
|
|
if (!mesh::ota::ota_acquire_context(reply, reply_size, true, &drained)) return false;
|
|
mesh::ota::ota_ctx().release_when_idle = false;
|
|
#endif
|
|
_temp_radio_freq = freq;
|
|
_temp_radio_bw = bw;
|
|
_temp_radio_sf = sf;
|
|
_temp_radio_cr = cr;
|
|
_temp_radio_set_at = futureMillis(1500); // let the local reply drain first
|
|
_temp_radio_revert_at = futureMillis(1500 + (int)(timeout_mins * 60000UL));
|
|
_temp_radio_retry_at = 0;
|
|
_temp_radio_failures = 0;
|
|
snprintf(reply, reply_size, "OK - temp params for %lu mins",
|
|
(unsigned long)timeout_mins);
|
|
appendRxPowerSavingAdjustmentNote(reply, reply_size, sf, bw);
|
|
#if defined(OTA_SHARED_COMPANION_QUEUE)
|
|
if (drained) {
|
|
size_t const used = strlen(reply);
|
|
snprintf(reply + used, reply_size - used, "; OTA cleared %u oldest queued messages",
|
|
(unsigned)drained);
|
|
}
|
|
#endif
|
|
return true;
|
|
}
|
|
|
|
void MyMesh::scheduleNormalRadio(char* reply, size_t reply_size) {
|
|
if (_primary_radio_blocked) {
|
|
mesh::cli::formatError(reply, reply_size, "radio acknowledgement pending");
|
|
return;
|
|
}
|
|
_temp_radio_set_at = 0;
|
|
_temp_radio_revert_at = futureMillis(1500); // keep the reply on the active tuple
|
|
_temp_radio_retry_at = 0;
|
|
_temp_radio_failures = 0;
|
|
snprintf(reply, reply_size, "OK - normal radio restore scheduled");
|
|
}
|
|
#endif
|
|
|
|
static bool parseCadTimingMillis(const char* text, uint32_t minimum,
|
|
uint32_t maximum, uint16_t& result) {
|
|
if (strcmp(text, "auto") == 0 || strcmp(text, "0") == 0) {
|
|
result = 0;
|
|
return true;
|
|
}
|
|
|
|
uint32_t parsed = 0;
|
|
if (!mesh::cli::parseUnsignedIntegerStrict(text, parsed)
|
|
|| parsed < minimum || parsed > maximum) {
|
|
return false;
|
|
}
|
|
result = (uint16_t)parsed;
|
|
return true;
|
|
}
|
|
|
|
bool MyMesh::handleCadCommand(const char* command, char* reply,
|
|
size_t reply_size) {
|
|
if (command == NULL || reply == NULL || reply_size == 0) return false;
|
|
|
|
if (strcmp(command, "get radio.cad") == 0
|
|
|| strcmp(command, "get cad") == 0) {
|
|
char scan[32];
|
|
char retry[24];
|
|
char maximum[24];
|
|
if (_prefs.cad_scan_timeout_ms == 0) {
|
|
uint32_t effective = _radio_available
|
|
? radio_driver.getCADScanTimeoutMillis() : 0;
|
|
if (effective == 0) {
|
|
effective = mesh::calculateCadScanTimeoutMillis(_prefs.sf, _prefs.bw);
|
|
}
|
|
snprintf(scan, sizeof(scan), "auto(%lu)", (unsigned long)effective);
|
|
} else {
|
|
snprintf(scan, sizeof(scan), "%u", (unsigned)_prefs.cad_scan_timeout_ms);
|
|
}
|
|
if (_prefs.cad_retry_delay_ms == 0) {
|
|
snprintf(retry, sizeof(retry), "auto");
|
|
} else {
|
|
snprintf(retry, sizeof(retry), "%u", (unsigned)_prefs.cad_retry_delay_ms);
|
|
}
|
|
if (_prefs.cad_max_duration_ms == 0) {
|
|
snprintf(maximum, sizeof(maximum), "auto");
|
|
} else {
|
|
snprintf(maximum, sizeof(maximum), "%u", (unsigned)_prefs.cad_max_duration_ms);
|
|
}
|
|
mesh::companion::formatCadDiagnostics(reply, reply_size,
|
|
_prefs.cad_enabled != 0, scan, retry, maximum, board.n_cad_busy);
|
|
return true;
|
|
}
|
|
|
|
const char* value = NULL;
|
|
if (strncmp(command, "set radio.cad", 13) == 0
|
|
&& (command[13] == 0 || command[13] == ' '
|
|
|| command[13] == '\t')) {
|
|
value = command + 13;
|
|
} else if (strncmp(command, "set cad", 7) == 0
|
|
&& (command[7] == 0 || command[7] == ' '
|
|
|| command[7] == '\t')) {
|
|
value = command + 7;
|
|
} else {
|
|
return false;
|
|
}
|
|
while (*value == ' ' || *value == '\t') value++;
|
|
|
|
if (strcmp(value, "on") == 0 || strcmp(value, "off") == 0) {
|
|
const uint8_t previous = _prefs.cad_enabled;
|
|
_prefs.cad_enabled = strcmp(value, "on") == 0 ? 1 : 0;
|
|
if (_radio_available) _radio->setCADEnabled(_prefs.cad_enabled != 0);
|
|
if (!savePrefs()) {
|
|
_prefs.cad_enabled = previous;
|
|
if (_radio_available) _radio->setCADEnabled(previous != 0);
|
|
mesh::cli::formatError(reply, reply_size, "CAD changed but save failed");
|
|
} else {
|
|
snprintf(reply, reply_size, "OK - radio.cad %s", value);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
if (strncmp(value, "timings", 7) == 0
|
|
&& (value[7] == 0 || value[7] == ' ' || value[7] == '\t')) {
|
|
value += 7;
|
|
while (*value == ' ' || *value == '\t') value++;
|
|
char token_storage[64] = {0};
|
|
const char* tokens[3] = {NULL};
|
|
size_t token_count = 0;
|
|
uint16_t scan_ms;
|
|
uint16_t retry_ms;
|
|
uint16_t max_ms;
|
|
if (!mesh::cli::splitWhitespaceFieldsStrict(
|
|
value, token_storage, sizeof(token_storage), tokens, 3,
|
|
token_count)
|
|
|| token_count != 3
|
|
|| !parseCadTimingMillis(tokens[0],
|
|
mesh::CAD_SCAN_MIN_TIMEOUT_MS,
|
|
mesh::CAD_SCAN_MAX_TIMEOUT_MS, scan_ms)
|
|
|| !parseCadTimingMillis(tokens[1], 1, 60000, retry_ms)
|
|
|| !parseCadTimingMillis(tokens[2], 1, 60000, max_ms)) {
|
|
mesh::cli::formatError(reply, reply_size, "use set radio.cad timings <auto|100-3500> <auto|1-60000> <auto|1-60000>");
|
|
return true;
|
|
}
|
|
|
|
const uint16_t previous_scan = _prefs.cad_scan_timeout_ms;
|
|
const uint16_t previous_retry = _prefs.cad_retry_delay_ms;
|
|
const uint16_t previous_max = _prefs.cad_max_duration_ms;
|
|
if (_radio_available && !radio_driver.setCADScanTimeoutMillis(scan_ms)) {
|
|
mesh::cli::formatError(reply, reply_size, "CAD scan timing is unsupported");
|
|
return true;
|
|
}
|
|
_prefs.cad_scan_timeout_ms = scan_ms;
|
|
_prefs.cad_retry_delay_ms = retry_ms;
|
|
_prefs.cad_max_duration_ms = max_ms;
|
|
if (!savePrefs()) {
|
|
_prefs.cad_scan_timeout_ms = previous_scan;
|
|
_prefs.cad_retry_delay_ms = previous_retry;
|
|
_prefs.cad_max_duration_ms = previous_max;
|
|
if (_radio_available) radio_driver.setCADScanTimeoutMillis(previous_scan);
|
|
mesh::cli::formatError(reply, reply_size, "CAD timings changed but save failed");
|
|
} else {
|
|
snprintf(reply, reply_size, "OK - radio.cad timings saved");
|
|
}
|
|
return true;
|
|
}
|
|
|
|
mesh::cli::formatError(reply, reply_size, "use set radio.cad <on|off> or set radio.cad timings <scan_ms|auto> <retry_ms|auto> <max_ms|auto>");
|
|
return true;
|
|
}
|
|
|
|
bool MyMesh::handleTrafficRoutingCommand(const char* command, char* reply, size_t reply_size) {
|
|
if (!reply || !reply_size) return false;
|
|
const auto parsed = mesh::companion::parseTrafficRouteCommand(command);
|
|
if (!parsed.matched) return false;
|
|
const char* kind = parsed.kind == mesh::companion::TrafficKind::Media ? "media" : "ssh";
|
|
if (!parsed.valid) {
|
|
snprintf(reply, reply_size, "Error: use get tx.%s or set tx.%s auto|radio|radio2|both|off", kind, kind);
|
|
return true;
|
|
}
|
|
uint8_t& policy = parsed.kind == mesh::companion::TrafficKind::Media ? _prefs.tx_media : _prefs.tx_ssh;
|
|
if (parsed.set) {
|
|
const uint8_t previous = policy;
|
|
policy = parsed.policy;
|
|
// Even an unchanged value must have durable storage before we say saved.
|
|
if (!savePrefs()) {
|
|
policy = previous;
|
|
snprintf(reply, reply_size, "Error: tx.%s was not saved", kind);
|
|
return true;
|
|
}
|
|
}
|
|
const uint8_t effective = mesh::companion::trafficPolicy(parsed.kind,
|
|
_prefs.tx_media, _prefs.tx_ssh, _radio->profiles());
|
|
const uint8_t mask = mesh::companion::trafficTransmitMask(effective, _radio->profiles());
|
|
snprintf(reply, reply_size, "%s tx.%s %s; active=%s%s", parsed.set ? "OK -" : ">", kind,
|
|
mesh::radioTxPolicyName(policy), effective == mesh::RADIO_TX_AUTO ? "channel/global"
|
|
: mask ? mesh::radioTxPolicyName(mask) : "off",
|
|
(policy == mesh::RADIO_TX_SECONDARY || policy == mesh::RADIO_TX_BOTH) && !(mask & 2)
|
|
? " (radio2 unavailable for TX)" : "");
|
|
return true;
|
|
}
|
|
|
|
bool MyMesh::sendClientGroupData(mesh::GroupChannel& channel, uint8_t* path, uint8_t path_len,
|
|
uint16_t data_type, const uint8_t* data, int data_len) {
|
|
if (data_len < 0 || data_len > MAX_GROUP_DATA_LENGTH || (data_len && !data)) return false;
|
|
const uint8_t policy = mesh::companion::trafficPolicy(mesh::companion::groupTrafficKind(data_type),
|
|
_prefs.tx_media, _prefs.tx_ssh, _radio->profiles());
|
|
if (policy == mesh::RADIO_TX_AUTO) return sendGroupData(channel, path, path_len, data_type, data, data_len);
|
|
// Override only this datagram, not the saved channel, PSK, alias or text route.
|
|
mesh::GroupChannel routed = channel;
|
|
routed.tx_radio = policy;
|
|
return sendGroupData(routed, path, path_len, data_type, data, data_len);
|
|
}
|
|
|
|
bool MyMesh::sendClientRawData(const uint8_t* data, size_t data_len, uint8_t* path, uint8_t path_len) {
|
|
if (!data || data_len < 4) return false;
|
|
auto pkt = createRawData(data, data_len);
|
|
if (!pkt) return false;
|
|
const uint8_t policy = mesh::companion::trafficPolicy(mesh::companion::rawTrafficKind(data, data_len),
|
|
_prefs.tx_media, _prefs.tx_ssh, _radio->profiles());
|
|
if (policy != mesh::RADIO_TX_AUTO) pkt->tx_radio = policy;
|
|
// sendDirect owns/releases the packet on either success or failure.
|
|
return sendDirect(pkt, path, path_len);
|
|
}
|
|
|
|
bool MyMesh::handleTxRoutingCommand(const char* command, char* reply, size_t reply_size) {
|
|
const auto parsed = mesh::companion::parseTxRouteCommand(command);
|
|
if (!parsed.matched) return false;
|
|
const char* kind = parsed.channel ? "channel" : "user";
|
|
if (!parsed.valid) {
|
|
snprintf(reply, reply_size, "Error: use get/set tx.%s <name|%s> [auto|radio|radio2|both|off]",
|
|
kind, parsed.channel ? "index" : "key:hex");
|
|
return true;
|
|
}
|
|
if (parsed.set && (!canMutateContacts()
|
|
|| (parsed.channel && _store->hasIncompleteChannelLoad()))) {
|
|
mesh::cli::formatError(reply, reply_size, "storage unavailable; TX routing not saved");
|
|
return true;
|
|
}
|
|
|
|
ContactInfo* contact = nullptr;
|
|
ChannelDetails channel;
|
|
int channel_index = -1;
|
|
unsigned matches = 0, overrides = 0;
|
|
if (parsed.channel) {
|
|
uint32_t index = 0;
|
|
const bool by_index = !parsed.quoted && mesh::cli::parseUnsignedIntegerStrict(parsed.target, index);
|
|
for (int i = 0; i < MAX_GROUP_CHANNELS; ++i) {
|
|
ChannelDetails candidate;
|
|
if (!getChannel(i, candidate) || !candidate.name[0]) continue;
|
|
if (candidate.channel.tx_radio != mesh::RADIO_TX_AUTO) ++overrides;
|
|
if (by_index ? index == (uint32_t)i : !strcmp(candidate.name, parsed.target)) {
|
|
channel = candidate; channel_index = i; ++matches;
|
|
}
|
|
}
|
|
} else {
|
|
uint8_t key[32]; size_t key_bytes = 0;
|
|
const bool by_key = !parsed.quoted && !strncmp(parsed.target, "key:", 4);
|
|
if (by_key && !mesh::companion::parseTxRouteKey(parsed.target + 4, key, key_bytes)) {
|
|
mesh::cli::formatError(reply, reply_size, "key: needs 12-64 hexadecimal digits (whole bytes)");
|
|
return true;
|
|
}
|
|
for (int i = MAX_ANON_CONTACTS; i < getTotalContactSlots(); ++i) {
|
|
ContactInfo* candidate = getContactPtrByIdx(i);
|
|
if (!candidate || candidate->type == ADV_TYPE_NONE) continue;
|
|
if (candidate->tx_radio != mesh::RADIO_TX_AUTO) ++overrides;
|
|
if (by_key ? !memcmp(candidate->id.pub_key, key, key_bytes) : !strcmp(candidate->name, parsed.target)) {
|
|
contact = candidate; ++matches;
|
|
}
|
|
}
|
|
}
|
|
if (!parsed.target[0]) {
|
|
snprintf(reply, reply_size, "> tx.%s: %u overrides; use get tx.%s <name|%s>",
|
|
kind, overrides, kind, parsed.channel ? "index" : "key:hex");
|
|
return true;
|
|
}
|
|
if (matches != 1) {
|
|
snprintf(reply, reply_size, "Error: %s %s%s", kind, matches ? "is ambiguous" : "not found",
|
|
matches ? (parsed.channel ? "; use an index" : "; use a longer key: prefix") : "");
|
|
return true;
|
|
}
|
|
uint8_t policy = parsed.channel ? channel.channel.tx_radio : contact->tx_radio;
|
|
if (parsed.set) {
|
|
bool saved = true;
|
|
if (policy == parsed.policy) {
|
|
// A prior recovery may have restored older disk data. Save the raw
|
|
// table without mutating a resolved alias or its runtime generation.
|
|
if (parsed.channel) saved = saveChannels();
|
|
} else if (parsed.channel) {
|
|
const auto alias = parseCompanionChannelAlias(channel.name);
|
|
if (isAliasRadio2Defined()
|
|
&& (alias.kind == CompanionChannelKind::Radio2 || alias.kind == CompanionChannelKind::Meshtext)) {
|
|
mesh::cli::formatError(reply, reply_size, "prefix controls routing; rename channel");
|
|
return true;
|
|
}
|
|
// Persist the app PSK, not the resolved wire-key copy used for reporting.
|
|
getChannelForSave(channel_index, channel);
|
|
const ChannelDetails previous = channel;
|
|
channel.channel.tx_radio = parsed.policy;
|
|
saved = setChannel(channel_index, channel) && saveChannels();
|
|
if (!saved) setChannel(channel_index, previous);
|
|
} else {
|
|
contact->tx_radio = parsed.policy;
|
|
saved = scheduleContactWrite(*contact) && flushContactsBeforeReboot();
|
|
if (!saved) {
|
|
contact->tx_radio = policy;
|
|
scheduleContactWrite(*contact);
|
|
}
|
|
}
|
|
if (!saved) {
|
|
mesh::cli::formatError(reply, reply_size, "storage unavailable; TX routing not saved");
|
|
return true;
|
|
}
|
|
policy = parsed.policy;
|
|
}
|
|
const auto* profiles = _radio->profiles();
|
|
const uint8_t mask = policy == mesh::RADIO_TX_AUTO ? (profiles ? profiles->transmitMask(0) : 1)
|
|
: mesh::explicitRadioTxMask(policy, profiles && profiles->canTransmit(1));
|
|
const char* active = mask ? mesh::radioTxPolicyName(mask) : "off";
|
|
char label[80];
|
|
if (parsed.channel) snprintf(label, sizeof(label), "%d \"%s\"", channel_index, channel.name);
|
|
else {
|
|
char key_prefix[13]; mesh::Utils::toHex(key_prefix, contact->id.pub_key, 6);
|
|
snprintf(label, sizeof(label), "\"%s\" key:%s", contact->name, key_prefix);
|
|
}
|
|
snprintf(reply, reply_size, "%s tx.%s %s %s; active=%s%s",
|
|
parsed.set ? "OK -" : ">", kind, label, mesh::radioTxPolicyName(policy), active,
|
|
(policy == mesh::RADIO_TX_SECONDARY || policy == mesh::RADIO_TX_BOTH) && !(mask & 2)
|
|
? " (radio2 unavailable for TX)" : "");
|
|
return true;
|
|
}
|
|
|
|
bool MyMesh::handleLocalControlCommand(const char* command, char* reply,
|
|
size_t reply_size) {
|
|
if (!command || !reply || reply_size == 0) return false;
|
|
while (*command == ' ') command++;
|
|
#if defined(ESP32_PLATFORM) && defined(WIFI_SSID)
|
|
if (strncmp(command, "password", 8) == 0
|
|
&& (command[8] == 0 || command[8] == ' ' || command[8] == '\t')) {
|
|
const char* value = command + 8;
|
|
// Consume the command separator only. Extra spaces belong to the secret,
|
|
// exactly as in infrastructure CLI and browser password forms.
|
|
if (*value == ' ' || *value == '\t') ++value;
|
|
if (!mesh::esp32::managementPassword::validPassword(value)) {
|
|
mesh::cli::formatError(reply, reply_size, "password must be 1-15 bytes, no line breaks; use 8-15 ASCII for setup WiFi");
|
|
return true;
|
|
}
|
|
mesh::esp32::managementPassword::ChangeGuard change;
|
|
if (!change.acquired()) {
|
|
mesh::cli::formatError(reply, reply_size, "OTA upload or password change busy; retry");
|
|
return true;
|
|
}
|
|
if (!mesh::esp32::companionAdminPassword::save(value)) {
|
|
mesh::cli::formatError(reply, reply_size, "password save unconfirmed; reboot may differ");
|
|
return true;
|
|
}
|
|
memcpy(_management_password, value, strlen(value) + 1);
|
|
_management_password_ready = change.publish(_management_password);
|
|
if (!_management_password_ready) {
|
|
mesh::cli::formatError(reply, reply_size, "password saved; management unavailable, reboot required");
|
|
} else {
|
|
// The browser batch recognizes this established success prefix and
|
|
// removes the secret before publishing its result.
|
|
snprintf(reply, reply_size, "password now: %s", value);
|
|
}
|
|
return true;
|
|
}
|
|
#endif
|
|
if (mesh::powerdiag::handleCommand(command, reply, reply_size, millis())) return true;
|
|
if (mesh::companion::handlePowerDiagnosticsCommand(board, command, reply, reply_size)) return true;
|
|
#if defined(ESP32_PLATFORM)
|
|
if (mesh::esp32::cpuclock::handleCommand(board, _prefs.cpu_freq_mhz,
|
|
_prefs.powersaving_enabled != 0, command, reply, reply_size,
|
|
[this]() { return savePrefs(); })) return true;
|
|
if (mesh::esp32::handlePowerTestCommand(command, reply, reply_size, true)) return true;
|
|
#endif
|
|
#if defined(NRF52_PLATFORM) && defined(OTA_SD_STORE)
|
|
if (mesh::cli::handleSdCardCommand(_sdcard_cli, board, command, reply, reply_size)) return true;
|
|
#endif
|
|
#if defined(OTA_RAK15002_SD_STORE) && defined(ETHERNET_USE_RAK13800)
|
|
if (handleRak4631PeripheralCommand(command, reply, reply_size)) return true;
|
|
#endif
|
|
#if defined(MESH_HIL_TX_FREQ_MHZ)
|
|
if (mesh::handleHilTxFrequencyCommand(command, reply, reply_size)) return true;
|
|
#endif
|
|
#if defined(RAK_4631)
|
|
// This helper is also reached only after normal remote-admin admission.
|
|
if (mesh::peripherals::handleCommand(_prefs.peripherals_shared, command,
|
|
reply, reply_size, sensors.supportsSharedPeripheralMode(),
|
|
[this]() { return savePrefs(); })) return true;
|
|
#else
|
|
if (strcmp(command, "get peripherals.mode") == 0
|
|
|| (strncmp(command, "set peripherals.mode", 20) == 0
|
|
&& (command[20] == 0 || command[20] == ' ' || command[20] == '\t'))) {
|
|
mesh::cli::formatError(reply, reply_size, "unsupported");
|
|
return true;
|
|
}
|
|
#endif
|
|
|
|
if (strcmp(command, "get public.key") == 0) {
|
|
char public_key_hex[PUB_KEY_SIZE * 2 + 1];
|
|
mesh::Utils::toHex(public_key_hex, self_id.pub_key, PUB_KEY_SIZE);
|
|
snprintf(reply, reply_size, "> %s", public_key_hex);
|
|
return true;
|
|
}
|
|
|
|
#if MESH_ENABLE_ETHERNET_TUNNEL
|
|
if (handleEthernetTunnelCommand(command, reply, reply_size)) return true;
|
|
#endif
|
|
if (handleTrafficRoutingCommand(command, reply, reply_size)) return true;
|
|
if (handleTxRoutingCommand(command, reply, reply_size)) return true;
|
|
#if MESH_ENABLE_TX_BEACONS
|
|
if (handleBeaconCommand(command, reply, reply_size)) return true;
|
|
#endif
|
|
#if MESH_ENABLE_MESHTEXT_CHAT
|
|
if (handleMeshtextCommand(command, reply, reply_size)) return true;
|
|
#endif
|
|
if (mesh::handleUsbLoggingWatchdogCommand(command, reply, reply_size)) return true;
|
|
#if COMPANION_FEATURE_OTA_CLI
|
|
const auto* ota_context = mesh::ota::ota_context_if_active();
|
|
const float adaptive_ota_pace = ota_context
|
|
? ota_context->manager.adaptivePacketSpeed() : mesh::ota::OTA_SPEED_DEFAULT;
|
|
if (mesh::ota::handleSpeedCommand(command, reply, reply_size, adaptive_ota_pace)) return true;
|
|
#endif
|
|
if (handleCompanionBluetoothCommand(command, reply, reply_size)) return true;
|
|
if (handleCompanionWirelessCommand(command, reply, reply_size)) return true;
|
|
if (handleRxPowerSavingGetCommand(command, reply, reply_size)) return true;
|
|
if (strncmp(command, "set radio.rxps", 14) == 0
|
|
&& (command[14] == 0 || command[14] == ' ' || command[14] == '\t')) {
|
|
const char* value = command + 14;
|
|
while (*value == ' ' || *value == '\t') ++value;
|
|
char result[160];
|
|
applyAndSaveRxPowerSaving(value, result);
|
|
snprintf(reply, reply_size, "%s", result);
|
|
return true;
|
|
}
|
|
static const char rf_rx_key[] = "set radio.rxps.rfrx_disabled";
|
|
if (!strncmp(command, rf_rx_key, sizeof(rf_rx_key) - 1)
|
|
&& (!command[sizeof(rf_rx_key) - 1] || command[sizeof(rf_rx_key) - 1] == ' '
|
|
|| command[sizeof(rf_rx_key) - 1] == '\t')) {
|
|
const char* value = command + sizeof(rf_rx_key) - 1;
|
|
while (*value == ' ' || *value == '\t') ++value;
|
|
return applyRxPowerSavingRfRxDisabled(value, reply, reply_size);
|
|
}
|
|
if (mesh::companion::handleRadioDiagnosticsCommand<false>(
|
|
radio_driver, _radio_available, command, reply, reply_size, [this]() {
|
|
radio_driver.resetStats();
|
|
this->resetStats();
|
|
static_cast<SimpleMeshTables*>(getTables())->resetStats();
|
|
})) return true;
|
|
|
|
if (strcmp(command, "get powersaving") == 0 || strcmp(command, "powersaving") == 0) {
|
|
snprintf(reply, reply_size, "> %s", _prefs.powersaving_enabled ? "on" : "off");
|
|
return true;
|
|
}
|
|
const char* power_value = nullptr;
|
|
if (strncmp(command, "set powersaving ", 16) == 0) power_value = command + 16;
|
|
else if (strncmp(command, "powersaving ", 12) == 0) power_value = command + 12;
|
|
if (power_value) {
|
|
char result[160] = {};
|
|
applyAndSavePowerSaving(power_value, result);
|
|
snprintf(reply, reply_size, "%s", result);
|
|
return true;
|
|
}
|
|
|
|
#if ENV_INCLUDE_GPS == 1
|
|
if (strcmp(command, "get gps.sync.interval") == 0) {
|
|
auto* provider = sensors.getLocationProvider();
|
|
if (provider == nullptr) {
|
|
mesh::cli::formatError(reply, reply_size, "GPS unavailable");
|
|
} else {
|
|
const uint16_t hours = provider->getTimeSyncIntervalHours();
|
|
if (hours == 0) snprintf(reply, reply_size, "> default (board GPS sync policy)");
|
|
else snprintf(reply, reply_size, "> %u hours", (unsigned)hours);
|
|
}
|
|
return true;
|
|
}
|
|
const char* gps_sync_value = nullptr;
|
|
if (strncmp(command, "set gps.sync.interval", 21) == 0
|
|
&& (command[21] == 0 || command[21] == ' ' || command[21] == '\t')) {
|
|
gps_sync_value = command + 21;
|
|
while (*gps_sync_value == ' ' || *gps_sync_value == '\t') ++gps_sync_value;
|
|
}
|
|
if (gps_sync_value) {
|
|
uint16_t hours = 0;
|
|
if (!mesh::gps::parseSyncIntervalHours(gps_sync_value, hours)) {
|
|
mesh::cli::formatError(reply, reply_size, "GPS sync interval must be 1..336 hours");
|
|
} else if (sensors.getLocationProvider() == nullptr) {
|
|
mesh::cli::formatError(reply, reply_size, "GPS unavailable");
|
|
} else if (!savePreference(_prefs.gps_sync_interval_hours, hours)) {
|
|
mesh::cli::formatError(reply, reply_size, "GPS sync interval could not be saved");
|
|
} else {
|
|
sensors.applyGpsTimeSyncInterval(hours);
|
|
snprintf(reply, reply_size, "OK - GPS sync interval %u hours (saved)", (unsigned)hours);
|
|
}
|
|
return true;
|
|
}
|
|
if (strcmp(command, "get gps") == 0) {
|
|
const char* gps = sensors.getSettingByKey("gps");
|
|
snprintf(reply, reply_size, gps ? "> %s" : "Error: GPS unavailable",
|
|
gps && gps[0] == '1' ? "on" : "off");
|
|
return true;
|
|
}
|
|
const char* gps_value = nullptr;
|
|
if (strncmp(command, "set gps ", 8) == 0) gps_value = command + 8;
|
|
if (gps_value) {
|
|
if (strcmp(gps_value, "on") != 0 && strcmp(gps_value, "off") != 0) {
|
|
mesh::cli::formatError(reply, reply_size, "use set gps on|off");
|
|
return true;
|
|
}
|
|
const bool enabled = strcmp(gps_value, "on") == 0;
|
|
if (sensors.getSettingByKey("gps") == nullptr) {
|
|
mesh::cli::formatError(reply, reply_size, "GPS unavailable");
|
|
} else {
|
|
if (setGpsEnabled(enabled)) {
|
|
snprintf(reply, reply_size, "OK - GPS %s (saved)", enabled ? "on" : "off");
|
|
} else {
|
|
mesh::cli::formatError(reply, reply_size, "GPS could not be saved");
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
#endif
|
|
|
|
#if defined(ESP32_PLATFORM) && defined(COMPANION_RADIO_FULL)
|
|
if (strcmp(command, "start ota") == 0
|
|
|| strcmp(command, "start ota ap") == 0) {
|
|
#if defined(COMPANION_EXCLUSIVE_WIFI_BLE)
|
|
if (!isCompanionWiFiEnabled()) {
|
|
snprintf(reply, reply_size,
|
|
"ERR: select WiFi transport and reboot before starting WiFi OTA");
|
|
return true;
|
|
}
|
|
#endif
|
|
// Board implementations use the common 160-byte CLI reply contract.
|
|
// Binary Companion callers may supply a shorter buffer.
|
|
char ota_reply[160] = {0};
|
|
bool webconfig_stopped = false;
|
|
#if defined(ESP32_PLATFORM) && defined(WIFI_SSID)
|
|
if (!_management_password_ready
|
|
|| !mesh::esp32::managementPassword::publish(_management_password)) {
|
|
mesh::cli::formatError(reply, reply_size, "management password unavailable; set password on USB");
|
|
return true;
|
|
}
|
|
#endif
|
|
#ifdef WITH_WEBCONFIG
|
|
if (isWebConfigActiveOrStopping()) {
|
|
if (!_webconfig->stopForOTA(ota_reply)) {
|
|
snprintf(reply, reply_size, "%s", ota_reply);
|
|
return true;
|
|
}
|
|
webconfig_stopped = true;
|
|
ota_reply[0] = 0;
|
|
}
|
|
#endif
|
|
if (!board.startOTAUpdate(_prefs.node_name, ota_reply,
|
|
strcmp(command, "start ota ap") == 0)) {
|
|
if (!ota_reply[0]) strcpy(ota_reply, "ERR: WiFi OTA unavailable");
|
|
}
|
|
if (webconfig_stopped) {
|
|
const size_t used = strlen(ota_reply);
|
|
snprintf(ota_reply + used, sizeof(ota_reply) - used, "; WebConfig stopped");
|
|
}
|
|
snprintf(reply, reply_size, "%s", ota_reply);
|
|
return true;
|
|
}
|
|
if (strcmp(command, "stop ota") == 0) {
|
|
char ota_reply[160] = {0};
|
|
if (!board.stopOTAUpdate(ota_reply) && !ota_reply[0]) {
|
|
snprintf(ota_reply, sizeof(ota_reply), "ERR: WiFi OTA unavailable");
|
|
}
|
|
snprintf(reply, reply_size, "%s", ota_reply);
|
|
return true;
|
|
}
|
|
#endif
|
|
|
|
#if MESH_USB_LOGGING_AVAILABLE
|
|
if (strcmp(command, "get usb.debug") == 0) {
|
|
snprintf(reply, reply_size, "usb.debug %s%s",
|
|
mesh::isUsbDebugEnabled() ? "on" : "off",
|
|
#if MESH_DEBUG
|
|
mesh::isUsbLoggingEnabled() ? "" : " (USB logging off)"
|
|
#else
|
|
" (core debug not compiled)"
|
|
#endif
|
|
);
|
|
return true;
|
|
}
|
|
if (strncmp(command, "set usb.debug", 13) == 0
|
|
&& (command[13] == 0 || command[13] == ' ' || command[13] == '\t')) {
|
|
const char* value = command + 13;
|
|
while (*value == ' ' || *value == '\t') value++;
|
|
if (strcmp(value, "on") != 0 && strcmp(value, "off") != 0) {
|
|
mesh::cli::formatError(reply, reply_size, "use set usb.debug <on|off>");
|
|
} else {
|
|
const uint8_t enabled = strcmp(value, "on") == 0 ? 1 : 0;
|
|
if (!savePreference(_prefs.usb_debug_enabled, enabled)) {
|
|
mesh::cli::formatError(reply, reply_size, "USB debug setting could not be saved");
|
|
} else {
|
|
snprintf(reply, reply_size, "OK - USB debug %s (saved)%s",
|
|
enabled ? "on" : "off",
|
|
#if MESH_DEBUG
|
|
""
|
|
#else
|
|
" (core debug not compiled)"
|
|
#endif
|
|
);
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
if (strcmp(command, "get usb.logging") == 0) {
|
|
snprintf(reply, reply_size, "usb.logging %s; port: %s%s",
|
|
mesh::isUsbLoggingEnabled()
|
|
? (mesh::isUsbLoggingPacketStream() ? "stream" : "on") : "off",
|
|
mesh::usbLoggingPortDescription(),
|
|
(mesh::usbLoggingInterfaceRestartRequired()
|
|
|| (_prefs.usb_logging_enabled != 0
|
|
&& (_prefs.usb_logging_enabled == 2)
|
|
!= mesh::isUsbLoggingPacketStream()))
|
|
? " (reboot required to change USB interfaces)" : "");
|
|
return true;
|
|
}
|
|
if (strncmp(command, "set usb.logging", 15) == 0
|
|
&& (command[15] == 0 || command[15] == ' ' || command[15] == '\t')) {
|
|
const char* value = command + 15;
|
|
while (*value == ' ' || *value == '\t') value++;
|
|
bool enabled = false;
|
|
bool reboot_if_needed = false;
|
|
const bool packet_stream = strcmp(value, "stream") == 0
|
|
|| strcmp(value, "stream reboot") == 0;
|
|
if (packet_stream) {
|
|
enabled = true;
|
|
reboot_if_needed = value[6] != 0;
|
|
}
|
|
if (!packet_stream
|
|
&& !mesh::cli::parseLoggingToggle(value, enabled, reboot_if_needed)) {
|
|
mesh::cli::formatError(reply, reply_size, "use set usb.logging <on|off|stream> [reboot]");
|
|
} else {
|
|
_prefs.usb_logging_enabled = enabled ? (packet_stream ? 2 : 1) : 0;
|
|
applyUsbLoggingState(enabled);
|
|
if (!savePrefs()) {
|
|
mesh::cli::formatError(reply, reply_size, "USB logging changed for this boot but save failed");
|
|
} else if (!mesh::saveUsbLoggingBootPreference(enabled)) {
|
|
mesh::cli::formatError(reply, reply_size, "setting saved, but next-boot USB interface state could not be saved");
|
|
} else if (mesh::usbLoggingInterfaceRestartRequired()
|
|
|| (enabled && packet_stream != mesh::isUsbLoggingPacketStream())) {
|
|
if (reboot_if_needed) {
|
|
snprintf(reply, reply_size,
|
|
"OK - USB logging %s (saved); rebooting to change USB interfaces",
|
|
enabled ? (packet_stream ? "stream" : "on") : "off");
|
|
_scheduled_reboot_at = futureMillis(1000);
|
|
if (_scheduled_reboot_at == 0) _scheduled_reboot_at = 1;
|
|
} else {
|
|
snprintf(reply, reply_size,
|
|
"OK - USB logging %s (saved); reboot required to change USB interfaces",
|
|
enabled ? (packet_stream ? "stream" : "on") : "off");
|
|
}
|
|
} else {
|
|
snprintf(reply, reply_size, "OK - USB logging %s (saved)",
|
|
enabled ? (packet_stream ? "stream" : "on") : "off");
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
#endif
|
|
|
|
if (strcmp(command, "get role") == 0) {
|
|
snprintf(reply, reply_size, "> companion");
|
|
return true;
|
|
}
|
|
if (strcmp(command, "get bootloader.ver") == 0) {
|
|
#if defined(NRF52_PLATFORM)
|
|
char version[128]; // Preserve the bootloader's preview/fork/build suffix.
|
|
if (board.getBootloaderVersion(version, sizeof(version))) {
|
|
snprintf(reply, reply_size, "> %s", version);
|
|
} else {
|
|
snprintf(reply, reply_size, "> unknown");
|
|
}
|
|
#else
|
|
mesh::cli::formatError(reply, reply_size, "unsupported");
|
|
#endif
|
|
return true;
|
|
}
|
|
if (strcmp(command, "get lat") == 0 || strcmp(command, "get lon") == 0) {
|
|
snprintf(reply, reply_size, "> %.6f", command[5] == 'a' ? sensors.node_lat : sensors.node_lon);
|
|
return true;
|
|
}
|
|
if (strcmp(command, "board") == 0) {
|
|
const char* hardware_name = board.getManufacturerName();
|
|
snprintf(reply, reply_size, "%s",
|
|
hardware_name != NULL ? hardware_name : "Unknown hardware");
|
|
return true;
|
|
}
|
|
|
|
if (strcmp(command, "version") == 0) {
|
|
snprintf(reply, reply_size, "Companion %s (protocol %u, build %s)",
|
|
FIRMWARE_VERSION, (unsigned)FIRMWARE_VER_CODE,
|
|
FIRMWARE_BUILD_DATE);
|
|
return true;
|
|
}
|
|
|
|
#if defined(NRF52_PLATFORM) && defined(EXTRAFS) && !defined(QSPIFLASH)
|
|
if (strcmp(command, "get flash.health") == 0) {
|
|
return _store->formatInternalExtraFSHealth(reply, reply_size);
|
|
}
|
|
#endif
|
|
|
|
if (strncmp(command, "get ", 4) == 0 &&
|
|
mesh::cli::handleStorageLayoutGet(command + 4, board, reply,
|
|
reply_size)) {
|
|
return true;
|
|
}
|
|
|
|
if (strcmp(command, "get pwrmgt.bootreason") == 0
|
|
|| strcmp(command, "powerlog") == 0) {
|
|
if (!board.isPowerManagementInitialized()) {
|
|
mesh::cli::formatError(reply, reply_size, "power management unsupported");
|
|
} else {
|
|
snprintf(reply, reply_size, "> Reset: %s; Shutdown: %s",
|
|
board.getResetReasonString(board.getResetReason()),
|
|
board.getShutdownReasonString(board.getShutdownReason()));
|
|
}
|
|
return true;
|
|
}
|
|
|
|
if (strcmp(command, "get contact.cache.timing") == 0) {
|
|
#if MESH_CONTACT_CACHE
|
|
const auto& cache = mesh::contactSecretCache();
|
|
snprintf(reply, reply_size, "last_flash_us=%lu last_calculation_us=%lu",
|
|
(unsigned long)cache.last_flash_read_us,
|
|
(unsigned long)cache.last_calculation_us);
|
|
#else
|
|
snprintf(reply, reply_size, "contact cache disabled");
|
|
#endif
|
|
return true;
|
|
}
|
|
if (strcmp(command, "get contact.cache") == 0) {
|
|
#if MESH_CONTACT_CACHE
|
|
const auto& cache = mesh::contactSecretCache();
|
|
snprintf(reply, reply_size, "paths=16 secrets=16 miss=%s ram_hits=%lu flash_hits=%lu calculations=%lu save_skips=%lu",
|
|
MESH_CONTACT_SECRET_FLASH_CACHE ? "flash" : "calculate",
|
|
(unsigned long)cache.ram_hits, (unsigned long)cache.flash_hits,
|
|
(unsigned long)cache.calculations, (unsigned long)cache.save_failures);
|
|
#else
|
|
snprintf(reply, reply_size, "paths=inline secrets=inline");
|
|
#endif
|
|
return true;
|
|
}
|
|
#if COMPANION_FEATURE_MEMORY_DIAGNOSTICS
|
|
if (strcmp(command, "memory") == 0) {
|
|
const mesh::CompanionMemoryDiagnostics diagnostics = {
|
|
(uint32_t)ESP.getFreeHeap(),
|
|
(uint32_t)ESP.getMinFreeHeap(),
|
|
(uint32_t)ESP.getMaxAllocHeap(),
|
|
(uint32_t)heap_caps_get_free_size(MALLOC_CAP_INTERNAL),
|
|
(uint32_t)heap_caps_get_largest_free_block(MALLOC_CAP_INTERNAL),
|
|
(uint32_t)ESP.getFreePsram(),
|
|
(uint32_t)ESP.getPsramSize(),
|
|
offline_queue_len,
|
|
getOfflineQueueCapacity()
|
|
};
|
|
mesh::formatCompanionMemoryDiagnostics(reply, reply_size, diagnostics);
|
|
return true;
|
|
}
|
|
#endif
|
|
|
|
if (handleCadCommand(command, reply, reply_size)) return true;
|
|
|
|
if (strcmp(command, "get radio.rxgain") == 0) {
|
|
if (!radio_driver.supportsRxBoostedGainMode()) {
|
|
mesh::cli::formatError(reply, reply_size, "unsupported");
|
|
} else {
|
|
snprintf(reply, reply_size, "> %s",
|
|
_prefs.rx_boosted_gain ? "on" : "off");
|
|
}
|
|
return true;
|
|
}
|
|
|
|
if (strncmp(command, "set radio.rxgain ", 17) == 0) {
|
|
const char* value = command + 17;
|
|
if (strcmp(value, "on") != 0 && strcmp(value, "off") != 0) {
|
|
mesh::cli::formatError(reply, reply_size, "use set radio.rxgain on|off");
|
|
} else if (!radio_driver.supportsRxBoostedGainMode()) {
|
|
mesh::cli::formatError(reply, reply_size, "unsupported");
|
|
} else if (!applyAndSaveRxBoostedGain(strcmp(value, "on") == 0)) {
|
|
mesh::cli::formatError(reply, reply_size, "RX gain failed");
|
|
} else {
|
|
snprintf(reply, reply_size, "OK - radio.rxgain %s", value);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
if (strcmp(command, "get radio.fem.rxgain") == 0) {
|
|
if (!board.canControlLoRaFemLna()) {
|
|
mesh::cli::formatError(reply, reply_size, "unsupported");
|
|
} else {
|
|
snprintf(reply, reply_size, "> %s",
|
|
board.isLoRaFemLnaEnabled() ? "on" : "off");
|
|
}
|
|
return true;
|
|
}
|
|
|
|
if (strncmp(command, "set radio.fem.rxgain ", 21) == 0) {
|
|
const char* value = command + 21;
|
|
if (strcmp(value, "on") != 0 && strcmp(value, "off") != 0) {
|
|
mesh::cli::formatError(reply, reply_size, "use set radio.fem.rxgain on|off");
|
|
} else if (_radio && (_radio->isMorseTxActive() || _radio->isMorseRxActive())) {
|
|
mesh::cli::formatError(reply, reply_size, "stop Morse before changing LoRa FEM gain");
|
|
} else if (!board.canControlLoRaFemLna()) {
|
|
mesh::cli::formatError(reply, reply_size, "unsupported");
|
|
} else if (!applyAndSaveFemRxGain(strcmp(value, "on") == 0)) {
|
|
mesh::cli::formatError(reply, reply_size, "failed to apply or save FEM RX gain");
|
|
} else {
|
|
snprintf(reply, reply_size, "OK - radio.fem.rxgain %s", value);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
if (strcmp(command, "get radio.fem.txgain") == 0) {
|
|
if (!board.canControlLoRaFemPaGain()) {
|
|
if (board.isLoRaFemPaGainEnabled()) {
|
|
snprintf(reply, reply_size, "> on (fixed TX PA)");
|
|
} else {
|
|
mesh::cli::formatError(reply, reply_size, "unsupported");
|
|
}
|
|
} else {
|
|
snprintf(reply, reply_size, "> %s",
|
|
board.isLoRaFemPaGainEnabled() ? "on" : "off");
|
|
}
|
|
return true;
|
|
}
|
|
|
|
if (strncmp(command, "set radio.fem.txgain ", 21) == 0) {
|
|
const char* value = command + 21;
|
|
if (strcmp(value, "on") != 0 && strcmp(value, "off") != 0) {
|
|
mesh::cli::formatError(reply, reply_size, "use set radio.fem.txgain on|off");
|
|
} else if (_radio && (_radio->isMorseTxActive() || _radio->isMorseRxActive())) {
|
|
mesh::cli::formatError(reply, reply_size, "stop Morse before changing LoRa FEM gain");
|
|
} else if (!board.canControlLoRaFemPaGain()) {
|
|
snprintf(reply, reply_size, "%s", board.isLoRaFemPaGainEnabled()
|
|
? "Error: TX PA is fixed on; use set tx to adjust power"
|
|
: "Error: unsupported");
|
|
} else if (!applyAndSaveFemTxGain(strcmp(value, "on") == 0)) {
|
|
mesh::cli::formatError(reply, reply_size, "failed to apply or save FEM TX gain");
|
|
} else {
|
|
snprintf(reply, reply_size, "OK - radio.fem.txgain %s", value);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
#if defined(HELTEC_V4_DISPLAY_UNIFIED) && HELTEC_V4_DISPLAY_UNIFIED && defined(DISPLAY_CLASS)
|
|
if (mesh::ui::handleHeltecV4DisplayCommand(command, reply, reply_size, display)) return true;
|
|
#endif
|
|
if (mesh::ui::handleDisplayPowerCommand(command, reply, reply_size,
|
|
_ui != nullptr && _ui->supportsInboxModes())) {
|
|
if (_ui) _ui->inboxModeChanged();
|
|
return true;
|
|
}
|
|
|
|
if (strcmp(command, "get display.touch") == 0) {
|
|
if (_ui == NULL || !_ui->supportsTouchDebug()) {
|
|
mesh::cli::formatError(reply, reply_size, "touchscreen diagnostics unsupported");
|
|
} else {
|
|
snprintf(reply, reply_size, "display.touch %s (off after reboot)",
|
|
_ui->isTouchDebugEnabled() ? "on" : "off");
|
|
}
|
|
return true;
|
|
}
|
|
if (strncmp(command, "set display.touch", 17) == 0
|
|
&& (command[17] == 0 || command[17] == ' ' || command[17] == '\t')) {
|
|
const char* value = command + 17;
|
|
while (*value == ' ' || *value == '\t') value++;
|
|
if (strcmp(value, "on") != 0 && strcmp(value, "off") != 0) {
|
|
mesh::cli::formatError(reply, reply_size, "use set display.touch on|off");
|
|
} else if (_ui == NULL || !_ui->supportsTouchDebug()) {
|
|
mesh::cli::formatError(reply, reply_size, "touchscreen diagnostics unsupported");
|
|
} else if (!_ui->setTouchDebugEnabled(strcmp(value, "on") == 0)) {
|
|
mesh::cli::formatError(reply, reply_size, "touchscreen diagnostics unavailable");
|
|
} else {
|
|
snprintf(reply, reply_size, "OK - display.touch %s (off after reboot)", value);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
#if defined(OLED_AUTO_DETECT) && OLED_AUTO_DETECT && defined(DISPLAY_CLASS)
|
|
if (mesh::ui::handleOLEDControllerCommand(command, reply, reply_size, true)) {
|
|
return true;
|
|
}
|
|
#endif
|
|
|
|
if (strcmp(command, "get display.rotation") == 0) {
|
|
if (_ui == NULL || !_ui->supportsDisplayRotation()) {
|
|
mesh::cli::formatError(reply, reply_size, "display rotation is unsupported");
|
|
} else if (_prefs.display_rotation_degrees == 0) {
|
|
snprintf(reply, reply_size, "display.rotation default");
|
|
} else {
|
|
snprintf(reply, reply_size, "display.rotation %u",
|
|
(unsigned)_prefs.display_rotation_degrees);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
if (strncmp(command, "set display.rotation", 20) == 0
|
|
&& (command[20] == 0 || command[20] == ' '
|
|
|| command[20] == '\t')) {
|
|
const char* value = command + 20;
|
|
while (*value == ' ' || *value == '\t') value++;
|
|
char* end = NULL;
|
|
const unsigned long degrees = strtoul(value, &end, 10);
|
|
while (end != NULL && (*end == ' ' || *end == '\t')) end++;
|
|
const bool valid = value[0] != 0 && end != NULL && *end == 0
|
|
&& (degrees == 0 || degrees == 90 || degrees == 180
|
|
|| degrees == 270);
|
|
if (!valid) {
|
|
mesh::cli::formatError(reply, reply_size, "use set display.rotation <0|90|180|270>");
|
|
} else if (_ui == NULL || !_ui->supportsDisplayRotation()) {
|
|
mesh::cli::formatError(reply, reply_size, "display rotation is unsupported");
|
|
} else {
|
|
const uint16_t previous = _prefs.display_rotation_degrees;
|
|
if (!_ui->setDisplayRotationDegrees((uint16_t)degrees)) {
|
|
mesh::cli::formatError(reply, reply_size, "display rotation failed");
|
|
} else {
|
|
_prefs.display_rotation_degrees = (uint16_t)degrees;
|
|
if (!savePrefs()) {
|
|
_prefs.display_rotation_degrees = previous;
|
|
_ui->setDisplayRotationDegrees(previous);
|
|
mesh::cli::formatError(reply, reply_size, "display rotation changed but save failed");
|
|
} else if (degrees == 0) {
|
|
snprintf(reply, reply_size,
|
|
"OK - display rotation reset to board default");
|
|
} else {
|
|
snprintf(reply, reply_size, "OK - display rotation %lu",
|
|
degrees);
|
|
}
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
if (strcmp(command, "get bluetooth.name") == 0
|
|
|| strcmp(command, "get ble.name") == 0) {
|
|
formatBluetoothNameStatus(reply, reply_size);
|
|
return true;
|
|
}
|
|
|
|
const char* bluetooth_name_value = NULL;
|
|
if (strncmp(command, "set bluetooth.name", 18) == 0
|
|
&& (command[18] == 0 || command[18] == ' '
|
|
|| command[18] == '\t')) {
|
|
bluetooth_name_value = command + 18;
|
|
} else if (strncmp(command, "set ble.name", 12) == 0
|
|
&& (command[12] == 0 || command[12] == ' '
|
|
|| command[12] == '\t')) {
|
|
bluetooth_name_value = command + 12;
|
|
}
|
|
if (bluetooth_name_value != NULL) {
|
|
while (*bluetooth_name_value == ' ' || *bluetooth_name_value == '\t') {
|
|
bluetooth_name_value++;
|
|
}
|
|
if (bluetooth_name_value[0] == 0) {
|
|
mesh::cli::formatError(reply, reply_size, "use set bluetooth.name <name|default>");
|
|
} else {
|
|
const bool clear_override = strcmp(bluetooth_name_value, "default") == 0
|
|
|| strcmp(bluetooth_name_value, "clear") == 0;
|
|
applyAndSaveBluetoothName(clear_override ? "" : bluetooth_name_value,
|
|
reply, reply_size);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
#if defined(BLE_PIN_CODE)
|
|
if (strcmp(command, "get bluetooth.stealth") == 0
|
|
|| strcmp(command, "get ble.stealth") == 0) {
|
|
formatBluetoothStealthStatus(reply, reply_size);
|
|
return true;
|
|
}
|
|
|
|
const char* bluetooth_stealth_value = NULL;
|
|
if (strncmp(command, "set bluetooth.stealth", 21) == 0
|
|
&& (command[21] == 0 || command[21] == ' '
|
|
|| command[21] == '\t')) {
|
|
bluetooth_stealth_value = command + 21;
|
|
} else if (strncmp(command, "set ble.stealth", 15) == 0
|
|
&& (command[15] == 0 || command[15] == ' '
|
|
|| command[15] == '\t')) {
|
|
bluetooth_stealth_value = command + 15;
|
|
}
|
|
if (bluetooth_stealth_value != NULL) {
|
|
while (*bluetooth_stealth_value == ' ' || *bluetooth_stealth_value == '\t') {
|
|
bluetooth_stealth_value++;
|
|
}
|
|
applyAndSaveBluetoothStealth(bluetooth_stealth_value, reply, reply_size);
|
|
return true;
|
|
}
|
|
|
|
if (strcmp(command, "get bluetooth.mac") == 0
|
|
|| strcmp(command, "get ble.mac") == 0) {
|
|
formatBluetoothMacStatus(reply, reply_size);
|
|
return true;
|
|
}
|
|
|
|
const char* bluetooth_mac_value = NULL;
|
|
if (strncmp(command, "set bluetooth.mac", 17) == 0
|
|
&& (command[17] == 0 || command[17] == ' '
|
|
|| command[17] == '\t')) {
|
|
bluetooth_mac_value = command + 17;
|
|
} else if (strncmp(command, "set ble.mac", 11) == 0
|
|
&& (command[11] == 0 || command[11] == ' '
|
|
|| command[11] == '\t')) {
|
|
bluetooth_mac_value = command + 11;
|
|
}
|
|
if (bluetooth_mac_value != NULL) {
|
|
while (*bluetooth_mac_value == ' ' || *bluetooth_mac_value == '\t') {
|
|
bluetooth_mac_value++;
|
|
}
|
|
if (bluetooth_mac_value[0] == 0) {
|
|
mesh::cli::formatError(reply, reply_size, "use set bluetooth.mac <address|random|random-every-boot|random-after-connect|default>");
|
|
} else {
|
|
applyAndSaveBluetoothMac(bluetooth_mac_value, reply, reply_size);
|
|
}
|
|
return true;
|
|
}
|
|
#endif
|
|
|
|
if (strncmp(command, "set pin", 7) == 0
|
|
&& (command[7] == 0 || command[7] == ' '
|
|
|| command[7] == '\t')) {
|
|
const char* value = command + 7;
|
|
while (*value == ' ' || *value == '\t') value++;
|
|
int32_t parsed = 0;
|
|
if (!mesh::cli::parseIntegerStrict(value, parsed)
|
|
|| parsed < 0 || parsed > 999999) {
|
|
mesh::cli::formatError(reply, reply_size, "pin must be 0-999999");
|
|
} else {
|
|
const uint32_t previous = _prefs.ble_pin;
|
|
_prefs.ble_pin = static_cast<uint32_t>(parsed);
|
|
if (!savePrefs()) {
|
|
_prefs.ble_pin = previous;
|
|
mesh::cli::formatError(reply, reply_size, "pin changed but save failed");
|
|
} else {
|
|
snprintf(reply, reply_size, "> pin is now %06lu",
|
|
(unsigned long)_prefs.ble_pin);
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
#if defined(MESH_PRIMARY_ESPNOW) && MESH_PRIMARY_ESPNOW
|
|
if (strcmp(command, "get espnow.channel") == 0) {
|
|
formatEspNowChannel(reply, reply_size);
|
|
return true;
|
|
}
|
|
|
|
if (strncmp(command, "set espnow.channel", 18) == 0
|
|
&& (command[18] == 0 || command[18] == ' '
|
|
|| command[18] == '\t')) {
|
|
const char* value = command + 18;
|
|
while (*value == ' ' || *value == '\t') value++;
|
|
applyAndSaveEspNowChannel(value, reply, reply_size);
|
|
return true;
|
|
}
|
|
#endif
|
|
|
|
#if defined(ESP32) && defined(WIFI_SSID)
|
|
if (handleCompanionWiFiCompatibilityCommand(command, reply, reply_size)) {
|
|
return true;
|
|
}
|
|
if (strcmp(command, "get wifi.ip") == 0) {
|
|
char ip[16] = {};
|
|
#ifdef WITH_WEBCONFIG
|
|
if (WebConfigServer::getSetupInfo(nullptr, 0, ip, sizeof(ip))) {
|
|
snprintf(reply, reply_size, "> %s", ip);
|
|
return true;
|
|
}
|
|
#endif
|
|
snprintf(reply, reply_size, "> %s", isCompanionWiFiConnected()
|
|
? WiFi.localIP().toString().c_str() : "(not connected)");
|
|
return true;
|
|
}
|
|
if (strcmp(command, "get wifi.enabled") == 0) {
|
|
snprintf(reply, reply_size, "> %u", (unsigned)_prefs.wifi_enabled);
|
|
return true;
|
|
}
|
|
if (strcmp(command, "get wifi.tcp") == 0) {
|
|
formatCompanionWiFiTcpStatus(reply, reply_size);
|
|
return true;
|
|
}
|
|
if (strncmp(command, "set wifi.enabled ", 17) == 0) {
|
|
int32_t enabled;
|
|
if (!mesh::cli::parseIntegerStrict(command + 17, enabled)
|
|
|| enabled < 0 || enabled > 1) {
|
|
mesh::cli::formatError(reply, reply_size, "WiFi enabled must be 0 or 1");
|
|
} else if (!setCompanionWiFiEnabled(enabled != 0)) {
|
|
mesh::cli::formatError(reply, reply_size, "WiFi setting could not be saved");
|
|
} else {
|
|
#if defined(COMPANION_EXCLUSIVE_WIFI_BLE)
|
|
snprintf(reply, reply_size, "OK - reboot to apply WiFi settings");
|
|
#else
|
|
snprintf(reply, reply_size, "OK - WiFi setting saved");
|
|
#endif
|
|
}
|
|
return true;
|
|
}
|
|
if (strcmp(command, "get display.wifi") == 0) {
|
|
formatCompanionWiFiDisplayStatus(reply, reply_size);
|
|
return true;
|
|
}
|
|
|
|
#if defined(COMPANION_EXCLUSIVE_WIFI_BLE)
|
|
if (strcmp(command, "get companion.transport") == 0) {
|
|
snprintf(reply, reply_size, "%s",
|
|
getCompanionTransportMode() == CompanionTransportMode::WiFi
|
|
? "wifi" : "ble");
|
|
return true;
|
|
}
|
|
|
|
if (strncmp(command, "set companion.transport", 23) == 0
|
|
&& (command[23] == 0 || command[23] == ' '
|
|
|| command[23] == '\t')) {
|
|
const char* value = command + 23;
|
|
while (*value == ' ' || *value == '\t') value++;
|
|
CompanionTransportMode selected;
|
|
if (strcmp(value, "wifi") == 0) {
|
|
selected = CompanionTransportMode::WiFi;
|
|
} else if (strcmp(value, "ble") == 0) {
|
|
selected = CompanionTransportMode::Bluetooth;
|
|
} else {
|
|
mesh::cli::formatError(reply, reply_size, "use set companion.transport <wifi|ble>");
|
|
return true;
|
|
}
|
|
if (!selectCompanionTransportMode(selected)) {
|
|
mesh::cli::formatError(reply, reply_size, "failed to save companion transport");
|
|
} else {
|
|
snprintf(reply, reply_size,
|
|
"OK - companion transport %s saved; reboot required", value);
|
|
}
|
|
return true;
|
|
}
|
|
#endif
|
|
|
|
#ifdef WITH_WEBCONFIG
|
|
// The same bounded configuration parser serves USB, TCP, framed control and
|
|
// the browser. Only the caller outside a WebConfig batch owns its save hook.
|
|
#ifdef WITH_MQTT_BRIDGE
|
|
if (strcmp(command, "get mqtt.enabled") == 0) {
|
|
snprintf(reply, reply_size, "> %s", _mqtt_enabled ? "on" : "off");
|
|
return true;
|
|
}
|
|
if (strcmp(command, "get mqtt.running") == 0) {
|
|
snprintf(reply, reply_size, "> %s", isMQTTRunning() ? "on" : "off");
|
|
return true;
|
|
}
|
|
const char* mqtt_value = nullptr;
|
|
if (strncmp(command, "set mqtt.enabled ", 17) == 0) mqtt_value = command + 17;
|
|
if (mqtt_value) {
|
|
if (strcmp(mqtt_value, "on") != 0 && strcmp(mqtt_value, "off") != 0) {
|
|
mesh::cli::formatError(reply, reply_size, "use set mqtt.enabled on|off");
|
|
} else if (!CompanionMqttSetupPortal::saveEnabled(strcmp(mqtt_value, "on") == 0)) {
|
|
mesh::cli::formatError(reply, reply_size, "failed to save MQTT setting");
|
|
} else {
|
|
_mqtt_enabled = strcmp(mqtt_value, "on") == 0;
|
|
if (!_mqtt_enabled) stopMQTT();
|
|
snprintf(reply, reply_size, "OK - MQTT %s (saved)%s", mqtt_value,
|
|
_mqtt_enabled && !_mqtt_configured ? "; configure a broker slot to connect" : "");
|
|
}
|
|
return true;
|
|
}
|
|
#if MESH_USB_LOGGING_AVAILABLE
|
|
if (strcmp(command, "get logging.output") == 0) {
|
|
snprintf(reply, reply_size, "> %s", mesh::cli::loggingOutputName(
|
|
mesh::isUsbLoggingEnabled(), _mqtt_enabled));
|
|
return true;
|
|
}
|
|
if (strncmp(command, "set logging.output ", 19) == 0) {
|
|
bool usb = false, wifi = false;
|
|
if (!mesh::cli::parseLoggingOutput(command + 19, usb, wifi)) {
|
|
mesh::cli::formatError(reply, reply_size, "use set logging.output off|usb|wifi|both");
|
|
} else if (!CompanionMqttSetupPortal::saveEnabled(wifi)) {
|
|
mesh::cli::formatError(reply, reply_size, "failed to save MQTT setting");
|
|
} else {
|
|
_mqtt_enabled = wifi;
|
|
if (!wifi) stopMQTT();
|
|
_prefs.usb_logging_enabled = usb
|
|
? (_prefs.usb_logging_enabled == 2 ? 2 : 1) : 0;
|
|
applyUsbLoggingState(usb);
|
|
if (!savePrefs() || !mesh::saveUsbLoggingBootPreference(usb)) {
|
|
mesh::cli::formatError(reply, reply_size, "logging output changed but USB setting could not be saved");
|
|
} else {
|
|
// USB ownership changes can clear the terminal's input buffer while
|
|
// this command is executing. Use parsed values, not that borrowed text.
|
|
snprintf(reply, reply_size, "OK - logging.output %s (saved)",
|
|
mesh::cli::loggingOutputName(usb, wifi));
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
#endif
|
|
if ((strncmp(command, "get ", 4) == 0 || strncmp(command, "set ", 4) == 0)
|
|
&& (strncmp(command + 4, "mqtt", 4) == 0
|
|
|| strncmp(command + 4, "snmp", 4) == 0
|
|
|| strncmp(command + 4, "timezone", 8) == 0)) {
|
|
char copy[160], result[160] = {};
|
|
if (strlen(command) >= sizeof(copy)) {
|
|
mesh::cli::formatError(reply, reply_size, "command too long");
|
|
return true;
|
|
}
|
|
strcpy(copy, command);
|
|
const bool owns_batch = !_wc_batch_active;
|
|
if (owns_batch) onConfigBatchStart();
|
|
execCommand(copy, result);
|
|
if (owns_batch) onConfigBatchEnd();
|
|
snprintf(reply, reply_size, "%s", result);
|
|
return true;
|
|
}
|
|
#endif
|
|
if (strncmp(command, "get ", 4) == 0) {
|
|
const mesh::cli::StandaloneWiFiKey wifi_key =
|
|
mesh::cli::classifyStandaloneWiFiGet(command + 4);
|
|
switch (wifi_key) {
|
|
case mesh::cli::StandaloneWiFiKey::SSID:
|
|
WebConfigServer::formatWiFiSSID(reply, reply_size);
|
|
// The official app recognizes this sentinel as an empty SSID.
|
|
if (strcmp(reply, "> (not configured)") == 0) {
|
|
snprintf(reply, reply_size, "> (not set)");
|
|
}
|
|
return true;
|
|
case mesh::cli::StandaloneWiFiKey::Status:
|
|
formatCompanionWiFiStatus(reply, reply_size);
|
|
return true;
|
|
case mesh::cli::StandaloneWiFiKey::CLI:
|
|
WebConfigServer::formatWiFiCliStatus(reply, reply_size);
|
|
return true;
|
|
default:
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (strncmp(command, "set ", 4) == 0) {
|
|
const char* value = NULL;
|
|
const mesh::cli::StandaloneWiFiKey wifi_key =
|
|
mesh::cli::classifyStandaloneWiFiSet(command + 4, &value);
|
|
bool saved = false;
|
|
switch (wifi_key) {
|
|
case mesh::cli::StandaloneWiFiKey::SSID:
|
|
saved = WebConfigServer::setStandaloneWiFiSSID(
|
|
value, reply, reply_size);
|
|
break;
|
|
case mesh::cli::StandaloneWiFiKey::Password:
|
|
saved = WebConfigServer::setStandaloneWiFiPassword(
|
|
value, reply, reply_size);
|
|
break;
|
|
case mesh::cli::StandaloneWiFiKey::CLI:
|
|
WebConfigServer::setWiFiCliEnabled(value, reply, reply_size);
|
|
return true;
|
|
default:
|
|
break;
|
|
}
|
|
if (wifi_key == mesh::cli::StandaloneWiFiKey::SSID
|
|
|| wifi_key == mesh::cli::StandaloneWiFiKey::Password) {
|
|
if (saved) {
|
|
char saved_ssid[32] = {0};
|
|
char saved_password[65] = {0};
|
|
uint8_t saved_power_save = mesh::wifi::kDefaultPowerSave;
|
|
const bool has_ssid = WebConfigServer::loadStandaloneWiFi(
|
|
saved_ssid, sizeof(saved_ssid), saved_password,
|
|
sizeof(saved_password), &saved_power_save);
|
|
memset(saved_password, 0, sizeof(saved_password));
|
|
if (_webconfig) {
|
|
_webconfig->reloadStandaloneWiFi();
|
|
_webconfig->requestRestart();
|
|
}
|
|
#ifdef WITH_MQTT_BRIDGE
|
|
if (_mqtt_enabled && _mqtt_bridge) _mqtt_bridge->requestRestart();
|
|
#endif
|
|
if (has_ssid) {
|
|
scheduleCompanionWiFiCredentialReload();
|
|
snprintf(reply, reply_size,
|
|
"OK - WiFi %s saved; reconnect scheduled",
|
|
wifi_key == mesh::cli::StandaloneWiFiKey::SSID
|
|
? "SSID" : "password");
|
|
} else {
|
|
snprintf(reply, reply_size,
|
|
"OK - WiFi password saved; set wifi.ssid to connect");
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
}
|
|
|
|
if (strcmp(command, "get webui") == 0) {
|
|
const bool enabled = WebConfigServer::loadEnabled(true);
|
|
if (!_webconfig
|
|
|| (!_webconfig->isRunning() && !_webconfig->isStopping())) {
|
|
snprintf(reply, reply_size, "> %s, inactive", enabled ? "on" : "off");
|
|
} else if (_webconfig->mode() == WebConfigServer::MODE_SETUP) {
|
|
char ssid[33] = {0};
|
|
char ip[16] = {0};
|
|
WebConfigServer::getSetupInfo(ssid, sizeof(ssid), ip, sizeof(ip));
|
|
snprintf(reply, reply_size, "> %s, setup AP %s http://%s/",
|
|
enabled ? "on" : "off", ssid, ip);
|
|
} else if (_webconfig->mode() == WebConfigServer::MODE_CONNECTING) {
|
|
snprintf(reply, reply_size, "> %s, connecting to WiFi",
|
|
enabled ? "on" : "off");
|
|
} else {
|
|
snprintf(reply, reply_size, "> %s, http://%s/",
|
|
enabled ? "on" : "off",
|
|
WiFi.localIP().toString().c_str());
|
|
}
|
|
return true;
|
|
}
|
|
|
|
if (strcmp(command, "set webui on") == 0
|
|
|| strcmp(command, "set webui off") == 0) {
|
|
const bool enabled = command[10] == 'o' && command[11] == 'n';
|
|
if (!WebConfigServer::saveEnabled(enabled)) {
|
|
mesh::cli::formatError(reply, reply_size, "failed to save webui setting");
|
|
} else if (!enabled) {
|
|
if (_webconfig) _webconfig->requestStop();
|
|
snprintf(reply, reply_size, "OK - webui off");
|
|
} else if (_webconfig
|
|
&& _webconfig->isRunning()) {
|
|
snprintf(reply, reply_size, "OK - webui on (already active)");
|
|
} else {
|
|
char start_reply[160] = {0};
|
|
startWebConfig(false, start_reply);
|
|
if (strncmp(start_reply, "WebConfig", 9) == 0) {
|
|
snprintf(reply, reply_size, "OK - webui on; %s", start_reply);
|
|
} else {
|
|
snprintf(reply, reply_size, "%s", start_reply);
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
if (strncmp(command, "set webui", 9) == 0
|
|
&& (command[9] == 0 || command[9] == ' ' || command[9] == '\t')) {
|
|
mesh::cli::formatError(reply, reply_size, "usage set webui on|off");
|
|
return true;
|
|
}
|
|
|
|
if (strcmp(command, "start webconfig") == 0
|
|
|| strcmp(command, "start webconfig ap") == 0) {
|
|
startWebConfig(strcmp(command, "start webconfig ap") == 0, reply);
|
|
return true;
|
|
}
|
|
if (strncmp(command, "start webconfig", 15) == 0
|
|
&& (command[15] == 0 || command[15] == ' ' || command[15] == '\t')) {
|
|
snprintf(reply, reply_size, "ERR: usage start webconfig [ap]");
|
|
return true;
|
|
}
|
|
if (strcmp(command, "stop webconfig") == 0) {
|
|
if (!_webconfig || (!_webconfig->isRunning() && !_webconfig->isStopping()
|
|
&& !_webconfig->isStartPending())) {
|
|
snprintf(reply, reply_size, "Err: webconfig not running");
|
|
} else {
|
|
_webconfig->requestStop();
|
|
snprintf(reply, reply_size, "OK - webconfig stopping");
|
|
}
|
|
return true;
|
|
}
|
|
#endif
|
|
|
|
if (strcmp(command, "get wifi.powersave") == 0) {
|
|
formatWiFiPowerSaving(reply, reply_size);
|
|
return true;
|
|
}
|
|
|
|
if (strcmp(command, "set wifi.powersave") == 0
|
|
|| strncmp(command, "set wifi.powersave ", 19) == 0) {
|
|
const char* value = command + 18;
|
|
while (*value == ' ') value++;
|
|
applyAndSaveWiFiPowerSaving(value, reply, reply_size);
|
|
return true;
|
|
}
|
|
#endif
|
|
|
|
#if COMPANION_FEATURE_TEMP_RADIO || COMPANION_FEATURE_OTA_CLI
|
|
#if COMPANION_FEATURE_TEMP_RADIO
|
|
if (strcmp(command, "tempradio") == 0) {
|
|
if (_temp_radio_set_at) {
|
|
snprintf(reply, reply_size, "TempRadio pending: %.3f,%.2f,%u,%u",
|
|
_temp_radio_freq, _temp_radio_bw,
|
|
(unsigned)_temp_radio_sf, (unsigned)_temp_radio_cr);
|
|
mesh::RadioProfiles preview;
|
|
if (_radio->profiles()) preview = *_radio->profiles();
|
|
preview.primary.freq = _temp_radio_freq; preview.primary.bw = _temp_radio_bw;
|
|
preview.primary.sf = _temp_radio_sf; preview.primary.cr = _temp_radio_cr;
|
|
preview.primary.preamble = _temp_radio_preamble;
|
|
const size_t used = strlen(reply);
|
|
if (used < reply_size) snprintf(reply + used, reply_size - used, ",preamble=%u", preview.preamble(0, rxPowerSavingPreambleForParams(_temp_radio_sf, _temp_radio_bw)));
|
|
mesh::RadioProfileCLI::appendChirpWarning(reply, reply_size, preview);
|
|
} else if (isTempRadioActive()) {
|
|
uint32_t seconds = (_temp_radio_revert_at - _ms->getMillis()) / 1000UL;
|
|
snprintf(reply, reply_size, "TempRadio active: %.3f,%.2f,%u,%u %lus left",
|
|
_temp_radio_freq, _temp_radio_bw,
|
|
(unsigned)_temp_radio_sf, (unsigned)_temp_radio_cr,
|
|
(unsigned long)seconds);
|
|
_radio_profiles.appendPreamble(reply, reply_size);
|
|
} else {
|
|
snprintf(reply, reply_size, "TempRadio inactive");
|
|
}
|
|
return true;
|
|
}
|
|
|
|
if (strncmp(command, "tempradio ", 10) == 0) {
|
|
char legacy[120];
|
|
uint16_t preamble = 0;
|
|
if (!mesh::RadioProfileCLI::parseSuffix(command + 10, 5, legacy, sizeof(legacy), preamble)) {
|
|
mesh::cli::formatError(reply, reply_size, "invalid preamble"); return true;
|
|
}
|
|
float freq = 0.0f, bw = 0.0f;
|
|
uint8_t sf = 0, cr = 0;
|
|
uint32_t timeout_mins = 0;
|
|
if (!mesh::cli::parseTemporaryRadioTupleStrict(
|
|
legacy, freq, bw, sf, cr, timeout_mins)
|
|
|| !isfinite(freq) || !isfinite(bw)
|
|
|| freq < 150.0f || freq > 2500.0f
|
|
|| !isFullCompanionBandwidth(bw)
|
|
|| sf < 5 || sf > 12 || cr < 5 || cr > 8
|
|
|| timeout_mins == 0 || timeout_mins > 10080UL
|
|
|| !_radio_profiles.acceptsPrimary(freq, bw, sf, cr, preamble)) {
|
|
snprintf(reply, reply_size,
|
|
"ERR usage: tempradio freq,bw,sf,cr,minutes (minutes 1-10080)");
|
|
return true;
|
|
}
|
|
if (scheduleTempRadio(freq, bw, sf, cr, timeout_mins, reply, reply_size)) {
|
|
_temp_radio_preamble = preamble;
|
|
_radio_profiles.appendPrimaryChirpWarning(reply, reply_size, sf, bw, preamble);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
if (strcmp(command, "normalradio") == 0) {
|
|
scheduleNormalRadio(reply, reply_size);
|
|
return true;
|
|
}
|
|
#endif
|
|
|
|
#if COMPANION_FEATURE_OTA_CLI
|
|
if (strncmp(command, "ota", 3) == 0
|
|
&& (command[3] == 0 || command[3] == ' ')) {
|
|
char ota_reply[160] = {0};
|
|
const char* action = command + 3;
|
|
while (*action == ' ') ++action;
|
|
// Only an explicit transfer, catalog search, or source attach may evict
|
|
// unread offline frames. Read-only OTA queries leave the queue intact.
|
|
bool const drain_queue = strcmp(action, "folder on") == 0
|
|
|| strcmp(action, "ls") == 0 || strncmp(action, "ls ", 3) == 0
|
|
|| strncmp(action, "pull ", 5) == 0
|
|
|| strncmp(action, "get ", 4) == 0
|
|
|| strncmp(action, "download ", 9) == 0
|
|
|| strcmp(action, "install") == 0
|
|
|| strcmp(action, "apply") == 0
|
|
|| strcmp(action, "applydelta") == 0
|
|
|| strncmp(action, "bootloader install ", 19) == 0
|
|
|| strncmp(action, "rescue install ", 15) == 0;
|
|
uint16_t drained = 0;
|
|
if (!mesh::ota::ota_acquire_context(ota_reply, sizeof(ota_reply), drain_queue, &drained)) {
|
|
snprintf(reply, reply_size, "%s", ota_reply);
|
|
return true;
|
|
}
|
|
auto& context = mesh::ota::ota_ctx();
|
|
const auto previous = mesh::ota::OtaConfigState::capture(context);
|
|
if (!mesh::ota::handle_ota_command(command, ota_reply, board)) return false;
|
|
if (context.config_dirty) {
|
|
if (!mesh::ota::saveCompanionOtaConfig(mesh::ota::OtaConfigState::capture(context))) {
|
|
previous.apply(context);
|
|
snprintf(ota_reply, sizeof(ota_reply), "ERR OTA settings save failed; settings unchanged");
|
|
}
|
|
context.config_dirty = false;
|
|
}
|
|
if (drained) {
|
|
size_t const used = strlen(ota_reply);
|
|
snprintf(ota_reply + used, sizeof(ota_reply) - used,
|
|
"; cleared %u oldest queued messages for OTA", (unsigned)drained);
|
|
}
|
|
snprintf(reply, reply_size, "%s", ota_reply);
|
|
return true;
|
|
}
|
|
#endif
|
|
#endif
|
|
|
|
return false;
|
|
}
|
|
|
|
#if COMPANION_FEATURE_TEMP_RADIO
|
|
void MyMesh::serviceTempRadio() {
|
|
const unsigned long now = _ms->getMillis();
|
|
const bool retry_ready = !_temp_radio_retry_at
|
|
|| _temp_radio_retry_at == now || millisHasNowPassed(_temp_radio_retry_at);
|
|
const bool revert_due = _temp_radio_revert_at
|
|
&& (_temp_radio_revert_at == now || millisHasNowPassed(_temp_radio_revert_at));
|
|
|
|
if (revert_due) {
|
|
if (hasOutbound() || !retry_ready) return;
|
|
mesh::RadioParamApplyResult result = tryApplyRadioParams(
|
|
_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr);
|
|
if (result == mesh::RadioParamApplyResult::APPLIED
|
|
&& radio_driver.setTxPower(_prefs.tx_power_dbm)
|
|
&& (!radio_driver.supportsRxBoostedGainMode()
|
|
|| radio_driver.setRxBoostedGainMode(_prefs.rx_boosted_gain))) {
|
|
_temp_radio_set_at = 0;
|
|
_temp_radio_revert_at = 0;
|
|
_temp_radio_retry_at = 0;
|
|
_temp_radio_failures = 0;
|
|
_temp_radio_applied = false;
|
|
MESH_DEBUG_PRINTLN("Full companion restored normal radio");
|
|
} else {
|
|
_temp_radio_retry_at = futureMillis(
|
|
nextRadioApplyRetryDelay(_temp_radio_failures));
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (!_temp_radio_set_at
|
|
|| (_temp_radio_set_at != now && !millisHasNowPassed(_temp_radio_set_at))
|
|
|| hasOutbound() || !retry_ready) return;
|
|
|
|
mesh::RadioParamApplyResult result = tryApplyRadioParams(
|
|
_temp_radio_freq, _temp_radio_bw, _temp_radio_sf, _temp_radio_cr, true, _temp_radio_preamble);
|
|
if (result == mesh::RadioParamApplyResult::APPLIED) {
|
|
_temp_radio_set_at = 0;
|
|
_temp_radio_retry_at = 0;
|
|
_temp_radio_failures = 0;
|
|
_temp_radio_applied = true;
|
|
MESH_DEBUG_PRINTLN("Full companion entered TempRadio");
|
|
} else if (result == mesh::RadioParamApplyResult::BUSY) {
|
|
_temp_radio_retry_at = futureMillis(250);
|
|
} else {
|
|
// A partially applied tuple must never become persistent. Restore the
|
|
// saved Companion settings through the same bounded retry path.
|
|
_temp_radio_set_at = 0;
|
|
_temp_radio_revert_at = futureMillis(1);
|
|
_temp_radio_retry_at = 0;
|
|
saved_radio_apply_pending = true;
|
|
}
|
|
}
|
|
#endif
|
|
|
|
const char *MyMesh::getNodeName() {
|
|
return _prefs.node_name;
|
|
}
|
|
CompanionNodePrefs *MyMesh::getNodePrefs() {
|
|
return &_prefs;
|
|
}
|
|
uint32_t MyMesh::getBLEPin() {
|
|
return _active_ble_pin;
|
|
}
|
|
|
|
bool MyMesh::setBluetoothEnabledPreference(bool enabled) {
|
|
return savePreference(_prefs.bluetooth_enabled, static_cast<uint8_t>(enabled));
|
|
}
|
|
|
|
bool MyMesh::isBluetoothEnabledPreference() const {
|
|
return _prefs.bluetooth_enabled != 0;
|
|
}
|
|
|
|
#if defined(WITH_MQTT_BRIDGE) && defined(ESP32_PLATFORM) && defined(WIFI_SSID)
|
|
static void copyMqttString(char* dest, size_t dest_size, const char* src) {
|
|
strncpy(dest, src ? src : "", dest_size - 1);
|
|
dest[dest_size - 1] = 0;
|
|
}
|
|
|
|
void MyMesh::serviceMQTT(const char* wifi_ssid, const char* wifi_password) {
|
|
if (!_mqtt_enabled) {
|
|
stopMQTT();
|
|
if (_mqtt_bridge) _mqtt_bridge->loop();
|
|
return;
|
|
}
|
|
#ifdef WITH_WEBCONFIG
|
|
if (_wc_batch_active) return;
|
|
#endif
|
|
// OTA keeps its radio/heap ownership; retain desired MQTT for a later loop.
|
|
if (board.isOTAUpdateRunning()) {
|
|
if (_mqtt_bridge && _mqtt_bridge->isStopping()) _mqtt_bridge->loop();
|
|
return;
|
|
}
|
|
if (_mqtt_bridge && !_mqtt_bridge->isRunning()
|
|
&& !_mqtt_bridge->isStopping()) {
|
|
if (strcmp(_mqtt_prefs.wifi_ssid, wifi_ssid ? wifi_ssid : "") != 0) {
|
|
copyMqttString(_mqtt_prefs.wifi_ssid, sizeof(_mqtt_prefs.wifi_ssid), wifi_ssid);
|
|
}
|
|
if (strcmp(_mqtt_prefs.wifi_password, wifi_password ? wifi_password : "") != 0) {
|
|
copyMqttString(_mqtt_prefs.wifi_password, sizeof(_mqtt_prefs.wifi_password), wifi_password);
|
|
}
|
|
}
|
|
if (_mqtt_bridge) _mqtt_bridge->loop();
|
|
_mqtt_started = _mqtt_bridge && _mqtt_bridge->isRunning()
|
|
&& !_mqtt_bridge->isStopping();
|
|
|
|
// Start the recovery scheduler even when the router is down at boot. The
|
|
// Companion still owns association; waiting for GOT_IP here would leave its
|
|
// failed station outside MQTT/NTP's shared power budget indefinitely.
|
|
if (_mqtt_configured && wifi_ssid && wifi_ssid[0]
|
|
&& !_mqtt_started && _mqtt_bridge
|
|
&& !_mqtt_bridge->isStartPending()
|
|
#ifdef WITH_WEBCONFIG
|
|
&& !isWebConfigSetupActive()
|
|
#endif
|
|
) {
|
|
_mqtt_bridge->begin();
|
|
_mqtt_started = _mqtt_bridge->isRunning() && !_mqtt_bridge->isStopping();
|
|
if (_mqtt_started) {
|
|
mesh::usbLoggingPort().println("MQTT companion: bridge started");
|
|
} else {
|
|
mesh::usbLoggingPort().println(
|
|
"MQTT companion: bridge could not start");
|
|
}
|
|
}
|
|
}
|
|
|
|
void MyMesh::stopMQTT() {
|
|
if (_mqtt_bridge) _mqtt_bridge->requestStop();
|
|
_mqtt_started = false;
|
|
}
|
|
#endif
|
|
|
|
#ifdef WITH_WEBCONFIG
|
|
static bool wcParseBool(const char* value, bool& out) {
|
|
if (strcmp(value, "on") == 0) { out = true; return true; }
|
|
if (strcmp(value, "off") == 0) { out = false; return true; }
|
|
return false;
|
|
}
|
|
|
|
static bool wcParseLong(const char* value, long min_value, long max_value, long& out) {
|
|
if (!value || !value[0]) return false;
|
|
char* end = nullptr;
|
|
long parsed = strtol(value, &end, 10);
|
|
if (!end || *end != 0 || parsed < min_value || parsed > max_value) return false;
|
|
out = parsed;
|
|
return true;
|
|
}
|
|
|
|
static bool wcParseDouble(const char* value, double min_value, double max_value, double& out) {
|
|
float parsed = 0.0f;
|
|
if (!mesh::cli::parseDecimalStrict(value, parsed)
|
|
|| parsed < min_value || parsed > max_value) return false;
|
|
out = parsed;
|
|
return true;
|
|
}
|
|
|
|
static bool wcValidNtpHost(const char* value) {
|
|
if (!value || !value[0] || strlen(value) > 63 || value[0] == '.') return false;
|
|
size_t len = strlen(value);
|
|
if (value[len - 1] == '.') return false;
|
|
for (size_t i = 0; i < len; i++) {
|
|
char c = value[i];
|
|
if (!isalnum(static_cast<unsigned char>(c)) && c != '.' && c != '-') return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
static bool wcValidHexKey(const char* value) {
|
|
if (!value || strlen(value) != 64) return false;
|
|
for (int i = 0; i < 64; i++) {
|
|
if (!isxdigit(static_cast<unsigned char>(value[i]))) return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
static bool wcCopyValue(char* dest, size_t dest_size, const char* value) {
|
|
if (!dest || dest_size == 0 || !value || strlen(value) >= dest_size) return false;
|
|
strncpy(dest, value, dest_size - 1);
|
|
dest[dest_size - 1] = 0;
|
|
return true;
|
|
}
|
|
|
|
void MyMesh::getNodeSnapshot(WebConfigServer::NodeSnapshot& s) {
|
|
memset(&s, 0, sizeof(s));
|
|
s.display_rotation_supported = _ui != NULL && _ui->supportsDisplayRotation();
|
|
s.display_rotation_degrees = _prefs.display_rotation_degrees;
|
|
#if defined(ESP32_PLATFORM) && defined(WIFI_SSID)
|
|
if (_management_password_ready) {
|
|
wcCopyValue(s.admin_password, sizeof(s.admin_password), _management_password);
|
|
}
|
|
#endif
|
|
wcCopyValue(s.name, sizeof(s.name), _prefs.node_name);
|
|
wcCopyValue(s.bluetooth_name, sizeof(s.bluetooth_name),
|
|
_prefs.bluetooth_name);
|
|
s.lat = sensors.node_lat;
|
|
s.lon = sensors.node_lon;
|
|
s.freq = _prefs.freq;
|
|
s.bw = _prefs.bw;
|
|
s.sf = _prefs.sf;
|
|
s.cr = _prefs.cr;
|
|
s.tx_power = _prefs.tx_power_dbm;
|
|
s.airtime_factor = _prefs.airtime_factor;
|
|
s.rx_delay = _prefs.rx_delay_base;
|
|
s.rx_gain = _prefs.rx_boosted_gain;
|
|
s.fem_rx_gain = board.isLoRaFemLnaEnabled();
|
|
s.rx_ps_enabled = _prefs.rx_powersaving_enabled;
|
|
s.rx_ps_level = _prefs.rx_ps_level;
|
|
s.rx_ps_preamble = _prefs.rx_ps_preamble;
|
|
s.rx_ps_rx_us = _prefs.rx_ps_rx_us;
|
|
s.rx_ps_sleep_us = _prefs.rx_ps_sleep_us;
|
|
s.power_saving = _prefs.powersaving_enabled;
|
|
#if defined(ESP32_PLATFORM)
|
|
s.cpu_freq_mhz = _prefs.cpu_freq_mhz;
|
|
s.cpu_actual_mhz = board.getCpuFrequencyMHz();
|
|
s.cpu_supported_mask = (board.supportsCpuFrequencyMHz(80) ? 1 : 0)
|
|
| (board.supportsCpuFrequencyMHz(160) ? 2 : 0)
|
|
| (board.supportsCpuFrequencyMHz(240) ? 4 : 0);
|
|
#endif
|
|
#if MESH_ENABLE_LOST_REPLY
|
|
s.lost_reply = _prefs.lost_reply;
|
|
#endif
|
|
#ifdef WITH_MQTT_BRIDGE
|
|
s.mqtt_enabled = _mqtt_enabled;
|
|
#endif
|
|
s.repeat = _prefs.client_repeat != 0;
|
|
s.capabilities = WebConfigServer::CAP_LOCATION | WebConfigServer::CAP_AIRTIME
|
|
| WebConfigServer::CAP_RX_DELAY | WebConfigServer::CAP_POWER_SAVING;
|
|
#if MESH_ENABLE_LOST_REPLY
|
|
s.capabilities |= WebConfigServer::CAP_LOST_REPLY;
|
|
#endif
|
|
#ifdef BLE_PIN_CODE
|
|
s.capabilities |= WebConfigServer::CAP_BLUETOOTH_NAME
|
|
| WebConfigServer::CAP_BLUETOOTH_MAC
|
|
| WebConfigServer::CAP_BLUETOOTH_STEALTH;
|
|
s.bluetooth_stealth = mesh::companion::bluetoothStealthEnabled(
|
|
_prefs.bluetooth_stealth_mode);
|
|
if (_prefs.bluetooth_mac_mode
|
|
== mesh::companion::BLUETOOTH_MAC_RANDOM_EVERY_BOOT) {
|
|
wcCopyValue(s.bluetooth_mac, sizeof(s.bluetooth_mac),
|
|
"random-every-boot");
|
|
} else if (mesh::companion::bluetoothMacModeIsRandomAfterConnect(
|
|
_prefs.bluetooth_mac_mode)) {
|
|
wcCopyValue(s.bluetooth_mac, sizeof(s.bluetooth_mac),
|
|
"random-after-connect");
|
|
} else if (mesh::companion::bluetoothMacModeUsesSavedAddress(
|
|
_prefs.bluetooth_mac_mode)
|
|
&& mesh::companion::isValidBluetoothMac(
|
|
_prefs.bluetooth_mac)) {
|
|
mesh::companion::formatBluetoothMac(
|
|
_prefs.bluetooth_mac, s.bluetooth_mac, sizeof(s.bluetooth_mac));
|
|
}
|
|
#endif
|
|
#if defined(ESP32) && defined(WIFI_SSID)
|
|
s.capabilities |= WebConfigServer::CAP_WIFI_POWER_SAVE;
|
|
#endif
|
|
#if defined(MESH_PRIMARY_ESPNOW) && MESH_PRIMARY_ESPNOW
|
|
s.capabilities |= WebConfigServer::CAP_ESPNOW_CHANNEL;
|
|
#endif
|
|
if (radio_driver.supportsRxBoostedGainMode()) {
|
|
s.capabilities |= WebConfigServer::CAP_RX_GAIN;
|
|
}
|
|
if (board.canControlLoRaFemLna()) {
|
|
s.capabilities |= WebConfigServer::CAP_FEM_RX_GAIN;
|
|
}
|
|
if (radio_driver.supportsRxPowerSaving()) {
|
|
s.capabilities |= WebConfigServer::CAP_RX_POWER_SAVING;
|
|
}
|
|
}
|
|
|
|
bool MyMesh::startWebConfig(bool force_ap, char* reply) {
|
|
if (board.isOTAUpdateRunning()) {
|
|
strcpy(reply, "Err: OTA server is running - 'stop ota' first");
|
|
return false;
|
|
}
|
|
#if defined(ESP32_PLATFORM) && defined(WIFI_SSID)
|
|
if (!_management_password_ready) {
|
|
strcpy(reply, "Err: management password unavailable; set password on USB");
|
|
return false;
|
|
}
|
|
#endif
|
|
#if defined(COMPANION_EXCLUSIVE_WIFI_BLE)
|
|
if (!isCompanionWiFiEnabled()) {
|
|
strcpy(reply,
|
|
"Err: WebUI unavailable while Bluetooth transport is active; select WiFi and reboot");
|
|
return false;
|
|
}
|
|
#endif
|
|
if (!_webconfig) {
|
|
strcpy(reply, "Err: WebUI unavailable (not enough memory)");
|
|
return false;
|
|
}
|
|
if (_webconfig->isRunning()) {
|
|
if (force_ap && !_webconfig->isStopping()
|
|
&& _webconfig->mode() == WebConfigServer::MODE_LAN) {
|
|
#ifdef WITH_MQTT_BRIDGE
|
|
if (_mqtt_bridge) {
|
|
_mqtt_bridge->end();
|
|
_mqtt_started = false;
|
|
}
|
|
#endif
|
|
return _webconfig->requestStart(true, reply);
|
|
}
|
|
strcpy(reply, "Err: WebUI already running");
|
|
return false;
|
|
}
|
|
if (force_ap) {
|
|
#ifdef WITH_MQTT_BRIDGE
|
|
if (_mqtt_bridge) {
|
|
_mqtt_bridge->end();
|
|
_mqtt_started = false;
|
|
}
|
|
#endif
|
|
return _webconfig->requestStart(true, reply);
|
|
}
|
|
return _webconfig->requestStart(false, reply);
|
|
}
|
|
|
|
void MyMesh::stopWebConfig() {
|
|
if (_webconfig) _webconfig->requestStop();
|
|
}
|
|
|
|
void MyMesh::serviceWebConfig() {
|
|
if (_webconfig) _webconfig->tick(millis());
|
|
}
|
|
|
|
bool MyMesh::isWebConfigActiveOrStopping() const {
|
|
return _webconfig && (_webconfig->isRunning() || _webconfig->isStopping()
|
|
|| _webconfig->isStartPending());
|
|
}
|
|
|
|
bool MyMesh::isWebConfigWiFiTransitionActive() const {
|
|
return _webconfig && (_webconfig->isStopping()
|
|
|| _webconfig->mode() == WebConfigServer::MODE_SETUP);
|
|
}
|
|
|
|
bool MyMesh::isWebConfigSetupActive() const {
|
|
return _webconfig && _webconfig->mode() == WebConfigServer::MODE_SETUP
|
|
&& !_webconfig->isStopping();
|
|
}
|
|
|
|
bool MyMesh::isWebConfigWiFiRecoveryActive() const {
|
|
return _webconfig && _webconfig->isSavedWiFiRecoveryActive();
|
|
}
|
|
|
|
void MyMesh::rebootNow() {
|
|
if (MyMesh::prepareForUiShutdown()) {
|
|
board.reboot();
|
|
} else {
|
|
MESH_DEBUG_PRINTLN("Reboot refused: pending contacts could not be saved");
|
|
}
|
|
}
|
|
|
|
void MyMesh::onConfigBatchStart() {
|
|
_wc_batch_active = true;
|
|
_wc_mqtt_dirty = false;
|
|
}
|
|
|
|
void MyMesh::onConfigBatchEnd() {
|
|
#if defined(ESP32) && defined(WIFI_SSID)
|
|
// The WebConfig WiFi form writes the canonical mesh-wifi namespace itself.
|
|
// Reload it here so the Companion runtime and MQTT reconnect policy cannot
|
|
// retain a stale modem-sleep mode.
|
|
reloadCompanionWiFiPowerSave();
|
|
syncWiFiPowerSaving();
|
|
#endif
|
|
#ifdef WITH_MQTT_BRIDGE
|
|
if (_wc_mqtt_dirty) {
|
|
CompanionMqttSetupPortal::saveStoredConfig(_mqtt_prefs);
|
|
if (_mqtt_bridge) _mqtt_bridge->end();
|
|
_mqtt_started = false;
|
|
// loadStoredConfig only replaces its destination after validation. Pass
|
|
// the live preferences directly: another 2.8 KB copy here nests with the
|
|
// loader's scratch copy and NVS calls, overflowing the ESP32 loop stack.
|
|
CompanionMqttSetupPortal::loadStoredConfig(_mqtt_prefs);
|
|
_mqtt_configured = CompanionMqttSetupPortal::hasConfiguredSlot(_mqtt_prefs);
|
|
// The standalone Companion setting remains canonical even if MQTT config
|
|
// verification reloaded an older copy of this field.
|
|
_mqtt_prefs.wifi_power_save = getCompanionWiFiPowerSave();
|
|
}
|
|
#endif
|
|
_wc_mqtt_dirty = false;
|
|
_wc_batch_active = false;
|
|
}
|
|
|
|
void MyMesh::execAdminCommand(char* cmd, char* reply) {
|
|
// The settings-batch API runs independently from the interactive terminal.
|
|
// The browser CLI uses runStreamTerminal() for the shared USB/TCP session;
|
|
// a settings batch must not capture or reset that session's stream.
|
|
reply[0] = 0;
|
|
if (!cmd || (strlen(cmd) > 2 && cmd[2] == '|')) {
|
|
mesh::cli::formatError(reply, 160, "enter a plain CLI command");
|
|
return;
|
|
}
|
|
if (handleCommand(cmd, 0, reply)) return;
|
|
// The settings form already implements the Companion's MQTT and remaining
|
|
// configuration setters. Its batch hooks persist/restart MQTT once per batch.
|
|
execCommand(cmd, reply);
|
|
}
|
|
|
|
void MyMesh::execCommand(char* cmd, char* reply) {
|
|
reply[0] = 0;
|
|
#if defined(ESP32_PLATFORM) && defined(WIFI_SSID)
|
|
if (cmd && strncmp(cmd, "password", 8) == 0
|
|
&& (cmd[8] == 0 || cmd[8] == ' ' || cmd[8] == '\t')) {
|
|
handleLocalControlCommand(cmd, reply, 160);
|
|
return;
|
|
}
|
|
#endif
|
|
#ifdef WITH_MQTT_BRIDGE
|
|
if (cmd && strncmp(cmd, "set mqtt.enabled ", 17) == 0) {
|
|
handleLocalControlCommand(cmd, reply, 160);
|
|
return;
|
|
}
|
|
if (cmd && strncmp(cmd, "get ", 4) == 0) {
|
|
const char* key = cmd + 4;
|
|
const char* value = nullptr;
|
|
if (strcmp(key, "mqtt.status") == 0) {
|
|
MQTTBridge::formatMqttStatusReply(reply, 160, &_mqtt_prefs);
|
|
return;
|
|
}
|
|
if (strcmp(key, "mqtt.stats") == 0) {
|
|
MQTTBridge::formatMqttStatsReply(reply, 160);
|
|
return;
|
|
}
|
|
if (strcmp(key, "mqtt.origin") == 0) value = _mqtt_prefs.mqtt_origin;
|
|
else if (strcmp(key, "mqtt.iata") == 0) value = _mqtt_prefs.mqtt_iata;
|
|
else if (strcmp(key, "mqtt.packets") == 0) value = _mqtt_prefs.mqtt_packets_enabled ? "on" : "off";
|
|
else if (strcmp(key, "mqtt.raw") == 0) value = _mqtt_prefs.mqtt_raw_enabled ? "on" : "off";
|
|
else if (strcmp(key, "mqtt.rx") == 0) value = _mqtt_prefs.mqtt_rx_enabled ? "on" : "off";
|
|
else if (strcmp(key, "mqtt.advert.verify") == 0) value = _mqtt_prefs.mqtt_advert_verify ? "on" : "off";
|
|
else if (strcmp(key, "mqtt.tx") == 0) value = _mqtt_prefs.mqtt_tx_enabled == 2 ? "advert" : _mqtt_prefs.mqtt_tx_enabled ? "on" : "off";
|
|
else if (strcmp(key, "mqtt.ntp") == 0) value = _mqtt_prefs.mqtt_ntp_server;
|
|
else if (strcmp(key, "mqtt.owner") == 0) value = _mqtt_prefs.mqtt_owner_public_key;
|
|
else if (strcmp(key, "mqtt.email") == 0) value = _mqtt_prefs.mqtt_email;
|
|
else if (strcmp(key, "timezone") == 0) value = _mqtt_prefs.timezone_string;
|
|
else if (strcmp(key, "snmp") == 0) value = _mqtt_prefs.snmp_enabled ? "on" : "off";
|
|
else if (strcmp(key, "snmp.community") == 0) value = _mqtt_prefs.snmp_community;
|
|
else if (strcmp(key, "mqtt.interval") == 0) {
|
|
snprintf(reply, 160, "> %lu minutes (%lu ms)",
|
|
(unsigned long)((_mqtt_prefs.mqtt_status_interval + 29999) / 60000),
|
|
(unsigned long)_mqtt_prefs.mqtt_status_interval);
|
|
return;
|
|
} else if (strcmp(key, "timezone.offset") == 0) {
|
|
snprintf(reply, 160, "> %d", (int)_mqtt_prefs.timezone_offset);
|
|
return;
|
|
} else if (strlen(key) >= 7 && strncmp(key, "mqtt", 4) == 0
|
|
&& key[4] >= '1' && key[4] <= '0' + MAX_MQTT_SLOTS && key[5] == '.') {
|
|
const int slot = key[4] - '1';
|
|
const char* field = key + 6;
|
|
if (strcmp(field, "diag") == 0) {
|
|
MQTTBridge::formatSlotDiagReply(reply, 160, slot);
|
|
return;
|
|
}
|
|
if (strcmp(field, "preset") == 0) value = _mqtt_prefs.mqtt_slot_preset[slot];
|
|
else if (strcmp(field, "server") == 0) value = _mqtt_prefs.mqtt_slot_host[slot];
|
|
else if (strcmp(field, "username") == 0) value = _mqtt_prefs.mqtt_slot_username[slot];
|
|
else if (strcmp(field, "topic") == 0) value = _mqtt_prefs.mqtt_slot_topic[slot];
|
|
else if (strcmp(field, "audience") == 0) value = _mqtt_prefs.mqtt_slot_audience[slot];
|
|
else if (strcmp(field, "password") == 0) value = _mqtt_prefs.mqtt_slot_password[slot][0] ? "********" : "";
|
|
else if (strcmp(field, "token") == 0) value = _mqtt_prefs.mqtt_slot_token[slot][0] ? "********" : "";
|
|
else if (strcmp(field, "port") == 0) {
|
|
snprintf(reply, 160, "> %u", (unsigned)_mqtt_prefs.mqtt_slot_port[slot]);
|
|
return;
|
|
}
|
|
}
|
|
if (value) {
|
|
snprintf(reply, 160, "> %s", value);
|
|
return;
|
|
}
|
|
}
|
|
#endif
|
|
if (handleCadCommand(cmd, reply, 160)) return;
|
|
if (cmd && (strcmp(cmd, "get bluetooth.name") == 0
|
|
|| strcmp(cmd, "get ble.name") == 0)) {
|
|
formatBluetoothNameStatus(reply, 160);
|
|
return;
|
|
}
|
|
#if defined(BLE_PIN_CODE)
|
|
if (cmd && (strcmp(cmd, "get bluetooth.stealth") == 0
|
|
|| strcmp(cmd, "get ble.stealth") == 0)) {
|
|
formatBluetoothStealthStatus(reply, 160);
|
|
return;
|
|
}
|
|
if (cmd && (strcmp(cmd, "get bluetooth.mac") == 0
|
|
|| strcmp(cmd, "get ble.mac") == 0)) {
|
|
formatBluetoothMacStatus(reply, 160);
|
|
return;
|
|
}
|
|
#endif
|
|
#if defined(MESH_PRIMARY_ESPNOW) && MESH_PRIMARY_ESPNOW
|
|
if (cmd && strcmp(cmd, "get espnow.channel") == 0) {
|
|
formatEspNowChannel(reply, 160);
|
|
return;
|
|
}
|
|
#endif
|
|
#if defined(ESP32) && defined(WIFI_SSID)
|
|
if (cmd && strcmp(cmd, "get wifi.powersave") == 0) {
|
|
formatWiFiPowerSaving(reply, 160);
|
|
return;
|
|
}
|
|
#endif
|
|
if (!cmd || strncmp(cmd, "set ", 4) != 0) {
|
|
mesh::cli::formatError(reply, 160, "unsupported command");
|
|
return;
|
|
}
|
|
const char* start = cmd + 4;
|
|
const char* split = strchr(start, ' ');
|
|
if (!split) {
|
|
mesh::cli::formatError(reply, 160, "missing value");
|
|
return;
|
|
}
|
|
char key[24];
|
|
const size_t key_len = split - start;
|
|
if (key_len == 0 || key_len >= sizeof(key)) {
|
|
mesh::cli::formatError(reply, 160, "unsupported setting");
|
|
return;
|
|
}
|
|
memcpy(key, start, key_len);
|
|
key[key_len] = 0;
|
|
const char* value = split + 1;
|
|
|
|
if (strcmp(key, "name") == 0) {
|
|
char name[sizeof(_prefs.node_name)];
|
|
if (!value[0] || !wcCopyValue(name, sizeof(name), value)) {
|
|
mesh::cli::formatError(reply, 160, "name must be 1-31 characters");
|
|
} else {
|
|
strcpy(reply, saveAdvertName(name) ? "OK" : "Error: name could not be saved");
|
|
}
|
|
return;
|
|
}
|
|
if (strcmp(key, "bluetooth.name") == 0) {
|
|
applyAndSaveBluetoothName(value, reply, 160);
|
|
return;
|
|
}
|
|
#if defined(BLE_PIN_CODE)
|
|
if (strcmp(key, "bluetooth.stealth") == 0
|
|
|| strcmp(key, "ble.stealth") == 0) {
|
|
applyAndSaveBluetoothStealth(value, reply, 160);
|
|
return;
|
|
}
|
|
if (strcmp(key, "bluetooth.mac") == 0) {
|
|
applyAndSaveBluetoothMac(value, reply, 160);
|
|
return;
|
|
}
|
|
#endif
|
|
#if defined(MESH_PRIMARY_ESPNOW) && MESH_PRIMARY_ESPNOW
|
|
if (strcmp(key, "espnow.channel") == 0) {
|
|
applyAndSaveEspNowChannel(value, reply, 160);
|
|
return;
|
|
}
|
|
#endif
|
|
if (strcmp(key, "lat") == 0 || strcmp(key, "lon") == 0) {
|
|
double parsed;
|
|
const bool latitude = key[1] == 'a';
|
|
if (!wcParseDouble(value, latitude ? -90.0 : -180.0,
|
|
latitude ? 90.0 : 180.0, parsed)) {
|
|
strcpy(reply, latitude ? "Error: latitude must be -90 to 90"
|
|
: "Error: longitude must be -180 to 180");
|
|
} else {
|
|
strcpy(reply, saveAdvertLocation(latitude ? parsed : sensors.node_lat,
|
|
latitude ? sensors.node_lon : parsed)
|
|
? "OK" : "Error: location could not be saved");
|
|
}
|
|
return;
|
|
}
|
|
if (strcmp(key, "radio") == 0) {
|
|
float freq, bw; uint8_t sf, cr;
|
|
if (!mesh::cli::parseRadioTupleStrict(value, freq, bw, sf, cr)) {
|
|
mesh::cli::formatError(reply, 160, "radio must be freq,bw,sf,cr");
|
|
} else {
|
|
const auto result = applyAndSaveRadioParams(freq, bw, sf, cr,
|
|
_prefs.client_repeat, _radio_profiles.primaryPreamble());
|
|
strcpy(reply, result == RadioSettingResult::Saved ? "OK"
|
|
: result == RadioSettingResult::SaveFailed ? "Error: radio not saved"
|
|
: result == RadioSettingResult::SaveUnconfirmed ? "Error: radio save unconfirmed"
|
|
: "Error: radio rejected");
|
|
}
|
|
return;
|
|
}
|
|
if (strcmp(key, "tx") == 0) {
|
|
long parsed;
|
|
if (!wcParseLong(value, -9, MAX_LORA_TX_POWER, parsed)) {
|
|
snprintf(reply, 160, "Error: TX -9..%d dBm", MAX_LORA_TX_POWER);
|
|
} else {
|
|
const auto result = applyAndSaveTxPower(static_cast<int8_t>(parsed));
|
|
strcpy(reply, result == RadioSettingResult::Saved
|
|
? (tx_power_apply_pending ? "OK - saved; pending" : "OK")
|
|
: result != RadioSettingResult::SaveFailed
|
|
? "Error: TX power rejected"
|
|
: "Error: TX not saved");
|
|
}
|
|
return;
|
|
}
|
|
if (strcmp(key, "af") == 0 || strcmp(key, "rxdelay") == 0) {
|
|
double parsed;
|
|
const bool is_af = strcmp(key, "af") == 0;
|
|
if (!wcParseDouble(value, 0.0, is_af ? 9.0 : 20.0, parsed)) {
|
|
strcpy(reply, is_af ? "Error: airtime factor must be 0-9"
|
|
: "Error: RX delay must be 0-20");
|
|
} else {
|
|
strcpy(reply, savePreference(is_af ? _prefs.airtime_factor : _prefs.rx_delay_base,
|
|
static_cast<float>(parsed))
|
|
? "OK" : "Error: setting could not be saved");
|
|
}
|
|
return;
|
|
}
|
|
if (strcmp(key, "radio.rxgain") == 0 || strcmp(key, "repeat") == 0) {
|
|
bool enabled;
|
|
if (!wcParseBool(value, enabled)) {
|
|
mesh::cli::formatError(reply, 160, "must be on or off");
|
|
} else {
|
|
if (strcmp(key, "radio.rxgain") == 0) {
|
|
if (!radio_driver.supportsRxBoostedGainMode()) {
|
|
mesh::cli::formatError(reply, 160, "RX gain unsupported");
|
|
return;
|
|
}
|
|
if (!applyAndSaveRxBoostedGain(enabled)) {
|
|
mesh::cli::formatError(reply, 160, "RX gain failed");
|
|
return;
|
|
}
|
|
} else {
|
|
if (!savePreference(_prefs.client_repeat, static_cast<uint8_t>(enabled))) {
|
|
mesh::cli::formatError(reply, 160, "repeat could not be saved");
|
|
return;
|
|
}
|
|
}
|
|
strcpy(reply, "OK");
|
|
}
|
|
return;
|
|
}
|
|
if (strcmp(key, "radio.rxps") == 0) {
|
|
applyAndSaveRxPowerSaving(value, reply);
|
|
return;
|
|
}
|
|
if (strcmp(key, "powersaving") == 0) {
|
|
applyAndSavePowerSaving(value, reply);
|
|
return;
|
|
}
|
|
#if defined(ESP32) && defined(WIFI_SSID)
|
|
if (strcmp(key, "wifi.powersave") == 0) {
|
|
applyAndSaveWiFiPowerSaving(value, reply, 160);
|
|
return;
|
|
}
|
|
#endif
|
|
if (strcmp(key, "radio.fem.rxgain") == 0) {
|
|
bool enabled;
|
|
if (!wcParseBool(value, enabled)) {
|
|
mesh::cli::formatError(reply, 160, "must be on or off");
|
|
} else if (_radio && (_radio->isMorseTxActive() || _radio->isMorseRxActive())) {
|
|
mesh::cli::formatError(reply, 160, "stop Morse before changing LoRa FEM gain");
|
|
} else if (!board.canControlLoRaFemLna()) {
|
|
mesh::cli::formatError(reply, 160, "unsupported");
|
|
} else if (!applyAndSaveFemRxGain(enabled)) {
|
|
mesh::cli::formatError(reply, 160, "failed to apply FEM RX gain");
|
|
} else {
|
|
strcpy(reply, "OK");
|
|
}
|
|
return;
|
|
}
|
|
if (strcmp(key, "radio.fem.txgain") == 0) {
|
|
bool enabled;
|
|
if (!wcParseBool(value, enabled)) {
|
|
mesh::cli::formatError(reply, 160, "must be on or off");
|
|
} else if (_radio && (_radio->isMorseTxActive() || _radio->isMorseRxActive())) {
|
|
mesh::cli::formatError(reply, 160, "stop Morse before changing LoRa FEM gain");
|
|
} else if (!board.canControlLoRaFemPaGain()) {
|
|
mesh::cli::formatError(reply, 160, "unsupported");
|
|
} else if (!applyAndSaveFemTxGain(enabled)) {
|
|
mesh::cli::formatError(reply, 160, "failed to apply FEM TX gain");
|
|
} else {
|
|
strcpy(reply, "OK");
|
|
}
|
|
return;
|
|
}
|
|
|
|
#ifdef WITH_MQTT_BRIDGE
|
|
bool changed = false;
|
|
if (strcmp(key, "mqtt.origin") == 0) {
|
|
changed = wcCopyValue(_mqtt_prefs.mqtt_origin, sizeof(_mqtt_prefs.mqtt_origin), value);
|
|
} else if (strcmp(key, "mqtt.iata") == 0) {
|
|
changed = wcCopyValue(_mqtt_prefs.mqtt_iata, sizeof(_mqtt_prefs.mqtt_iata), value);
|
|
if (changed) {
|
|
for (char* p = _mqtt_prefs.mqtt_iata; *p; p++) *p = toupper(static_cast<unsigned char>(*p));
|
|
}
|
|
} else if (strcmp(key, "mqtt.status") == 0 || strcmp(key, "mqtt.packets") == 0
|
|
|| strcmp(key, "mqtt.raw") == 0 || strcmp(key, "mqtt.rx") == 0
|
|
|| strcmp(key, "mqtt.advert.verify") == 0
|
|
|| strcmp(key, "snmp") == 0) {
|
|
bool enabled;
|
|
if (!wcParseBool(value, enabled)) {
|
|
mesh::cli::formatError(reply, 160, "must be on or off");
|
|
return;
|
|
}
|
|
if (strcmp(key, "mqtt.status") == 0) _mqtt_prefs.mqtt_status_enabled = enabled;
|
|
else if (strcmp(key, "mqtt.packets") == 0) _mqtt_prefs.mqtt_packets_enabled = enabled;
|
|
else if (strcmp(key, "mqtt.raw") == 0) _mqtt_prefs.mqtt_raw_enabled = enabled;
|
|
else if (strcmp(key, "mqtt.rx") == 0) _mqtt_prefs.mqtt_rx_enabled = enabled;
|
|
else if (strcmp(key, "mqtt.advert.verify") == 0) _mqtt_prefs.mqtt_advert_verify = enabled;
|
|
else _mqtt_prefs.snmp_enabled = enabled;
|
|
changed = true;
|
|
} else if (strcmp(key, "mqtt.tx") == 0) {
|
|
if (strcmp(value, "off") == 0) _mqtt_prefs.mqtt_tx_enabled = 0;
|
|
else if (strcmp(value, "on") == 0) _mqtt_prefs.mqtt_tx_enabled = 1;
|
|
else if (strcmp(value, "advert") == 0) _mqtt_prefs.mqtt_tx_enabled = 2;
|
|
else {
|
|
mesh::cli::formatError(reply, 160, "MQTT TX must be off, on, or advert");
|
|
return;
|
|
}
|
|
changed = true;
|
|
} else if (strcmp(key, "mqtt.interval") == 0) {
|
|
long minutes;
|
|
if (!wcParseLong(value, 1, 60, minutes)) {
|
|
mesh::cli::formatError(reply, 160, "interval must be 1-60 minutes");
|
|
return;
|
|
}
|
|
_mqtt_prefs.mqtt_status_interval = static_cast<uint32_t>(minutes) * 60000UL;
|
|
changed = true;
|
|
} else if (strcmp(key, "mqtt.ntp") == 0) {
|
|
if (strcmp(value, "none") == 0) {
|
|
_mqtt_prefs.mqtt_ntp_server[0] = 0;
|
|
changed = true;
|
|
} else if (wcValidNtpHost(value)) {
|
|
changed = wcCopyValue(_mqtt_prefs.mqtt_ntp_server,
|
|
sizeof(_mqtt_prefs.mqtt_ntp_server), value);
|
|
}
|
|
} else if (strcmp(key, "mqtt.owner") == 0) {
|
|
if (!value[0]) {
|
|
_mqtt_prefs.mqtt_owner_public_key[0] = 0;
|
|
changed = true;
|
|
} else if (wcValidHexKey(value)) {
|
|
changed = wcCopyValue(_mqtt_prefs.mqtt_owner_public_key,
|
|
sizeof(_mqtt_prefs.mqtt_owner_public_key), value);
|
|
}
|
|
} else if (strcmp(key, "mqtt.email") == 0) {
|
|
changed = wcCopyValue(_mqtt_prefs.mqtt_email, sizeof(_mqtt_prefs.mqtt_email), value);
|
|
} else if (strcmp(key, "timezone") == 0) {
|
|
changed = wcCopyValue(_mqtt_prefs.timezone_string, sizeof(_mqtt_prefs.timezone_string), value);
|
|
} else if (strcmp(key, "timezone.offset") == 0) {
|
|
long offset;
|
|
if (!wcParseLong(value, -12, 14, offset)) {
|
|
mesh::cli::formatError(reply, 160, "timezone offset must be -12 to 14");
|
|
return;
|
|
}
|
|
_mqtt_prefs.timezone_offset = static_cast<int8_t>(offset);
|
|
changed = true;
|
|
} else if (strcmp(key, "snmp.community") == 0) {
|
|
changed = wcCopyValue(_mqtt_prefs.snmp_community,
|
|
sizeof(_mqtt_prefs.snmp_community), value);
|
|
} else if (strncmp(key, "mqtt", 4) == 0 && key[4] >= '1'
|
|
&& key[4] <= ('0' + MAX_MQTT_SLOTS) && key[5] == '.') {
|
|
int slot = key[4] - '1';
|
|
const char* field = key + 6;
|
|
if (strcmp(field, "preset") == 0) {
|
|
const bool valid = findMQTTPreset(value) != nullptr
|
|
|| strcmp(value, MQTT_PRESET_CUSTOM) == 0
|
|
|| strcmp(value, MQTT_PRESET_NONE) == 0;
|
|
if (!valid) {
|
|
mesh::cli::formatError(reply, 160, "unknown MQTT preset");
|
|
return;
|
|
}
|
|
if (findMQTTPreset(value)) {
|
|
for (int i = 0; i < MAX_MQTT_SLOTS; i++) {
|
|
if (i != slot && strcmp(_mqtt_prefs.mqtt_slot_preset[i], value) == 0) {
|
|
snprintf(reply, 160, "Error: preset already assigned to slot %d", i + 1);
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
changed = wcCopyValue(_mqtt_prefs.mqtt_slot_preset[slot],
|
|
sizeof(_mqtt_prefs.mqtt_slot_preset[slot]), value);
|
|
} else if (strcmp(field, "server") == 0) {
|
|
changed = wcCopyValue(_mqtt_prefs.mqtt_slot_host[slot],
|
|
sizeof(_mqtt_prefs.mqtt_slot_host[slot]), value);
|
|
} else if (strcmp(field, "port") == 0) {
|
|
uint16_t port;
|
|
if (!mesh::cli::parseMqttPort(value, port)) {
|
|
mesh::cli::formatError(reply, 160, "port must be 0-65535 (0 uses the URI default)");
|
|
return;
|
|
}
|
|
_mqtt_prefs.mqtt_slot_port[slot] = static_cast<uint16_t>(port);
|
|
changed = true;
|
|
} else if (strcmp(field, "username") == 0) {
|
|
changed = wcCopyValue(_mqtt_prefs.mqtt_slot_username[slot],
|
|
sizeof(_mqtt_prefs.mqtt_slot_username[slot]), value);
|
|
} else if (strcmp(field, "password") == 0) {
|
|
changed = wcCopyValue(_mqtt_prefs.mqtt_slot_password[slot],
|
|
sizeof(_mqtt_prefs.mqtt_slot_password[slot]), value);
|
|
} else if (strcmp(field, "token") == 0) {
|
|
changed = wcCopyValue(_mqtt_prefs.mqtt_slot_token[slot],
|
|
sizeof(_mqtt_prefs.mqtt_slot_token[slot]), value);
|
|
} else if (strcmp(field, "topic") == 0) {
|
|
if (strcmp(_mqtt_prefs.mqtt_slot_preset[slot], MQTT_PRESET_CUSTOM) != 0) {
|
|
mesh::cli::formatError(reply, 160, "topic only applies to custom slots");
|
|
return;
|
|
}
|
|
changed = wcCopyValue(_mqtt_prefs.mqtt_slot_topic[slot],
|
|
sizeof(_mqtt_prefs.mqtt_slot_topic[slot]), value);
|
|
} else if (strcmp(field, "audience") == 0) {
|
|
changed = wcCopyValue(_mqtt_prefs.mqtt_slot_audience[slot],
|
|
sizeof(_mqtt_prefs.mqtt_slot_audience[slot]), value);
|
|
}
|
|
}
|
|
|
|
if (changed) {
|
|
_wc_mqtt_dirty = true;
|
|
strcpy(reply, "OK");
|
|
} else if (reply[0] == 0) {
|
|
mesh::cli::formatError(reply, 160, "invalid or unsupported value");
|
|
}
|
|
#else
|
|
mesh::cli::formatError(reply, 160, "unsupported setting for this companion");
|
|
#endif
|
|
}
|
|
|
|
void MyMesh::buildStatsJson(char* buf, size_t buf_size) {
|
|
char ip[20] = "";
|
|
int wifi_rssi = 0;
|
|
if (WiFi.status() == WL_CONNECTED) {
|
|
strncpy(ip, WiFi.localIP().toString().c_str(), sizeof(ip) - 1);
|
|
wifi_rssi = WiFi.RSSI();
|
|
} else if (_webconfig && _webconfig->mode() == WebConfigServer::MODE_SETUP) {
|
|
strncpy(ip, WiFi.softAPIP().toString().c_str(), sizeof(ip) - 1);
|
|
}
|
|
int pos = snprintf(buf, buf_size,
|
|
"{\"uptime_s\":%lu,\"batt_mv\":%u,"
|
|
"\"heap_free\":%lu,\"heap_min\":%lu,\"heap_max_alloc\":%lu,"
|
|
"\"noise\":%d,\"rssi\":%d,\"snr\":%.1f,"
|
|
"\"airtime_s\":%lu,\"rx_airtime_s\":%lu,"
|
|
"\"recv\":%lu,\"sent\":%lu,\"rx_err\":%lu,"
|
|
"\"sent_flood\":%lu,\"sent_direct\":%lu,\"recv_flood\":%lu,\"recv_direct\":%lu,"
|
|
"\"tx_queue\":%d,\"wifi_rssi\":%d,\"ip\":\"%s\",\"mqtt_queue\":%d,\"slots\":[",
|
|
(unsigned long)(millis() / 1000), (unsigned)board.getBattMilliVolts(),
|
|
(unsigned long)ESP.getFreeHeap(), (unsigned long)ESP.getMinFreeHeap(),
|
|
(unsigned long)ESP.getMaxAllocHeap(),
|
|
(int)_radio->getNoiseFloor(), (int)radio_driver.getLastRSSI(),
|
|
radio_driver.getLastSNR(),
|
|
(unsigned long)(getTotalAirTime() / 1000), (unsigned long)(getReceiveAirTime() / 1000),
|
|
(unsigned long)radio_driver.getPacketsRecv(), (unsigned long)radio_driver.getPacketsSent(),
|
|
(unsigned long)radio_driver.getPacketsRecvErrors(),
|
|
(unsigned long)getNumSentFlood(), (unsigned long)getNumSentDirect(),
|
|
(unsigned long)getNumRecvFlood(), (unsigned long)getNumRecvDirect(),
|
|
(int)_mgr->getOutboundCount(0xFFFFFFFF), wifi_rssi, ip,
|
|
#ifdef WITH_MQTT_BRIDGE
|
|
_mqtt_bridge ? _mqtt_bridge->getQueueSize() : 0);
|
|
#else
|
|
0);
|
|
#endif
|
|
if (pos < 0 || pos >= static_cast<int>(buf_size) - 3) return;
|
|
|
|
#ifdef WITH_MQTT_BRIDGE
|
|
bool first = true;
|
|
for (int i = 0; i < RUNTIME_MQTT_SLOTS; i++) {
|
|
MQTTBridge::SlotStatusSnapshot status;
|
|
if (!MQTTBridge::getSlotStatusSnapshot(i, &status)) continue;
|
|
int written = snprintf(buf + pos, buf_size - pos,
|
|
"%s{\"n\":%d,\"name\":\"%s\",\"state\":\"%s\"}",
|
|
first ? "" : ",", i + 1, status.name, status.state);
|
|
if (written < 0 || written >= static_cast<int>(buf_size - pos)) break;
|
|
pos += written;
|
|
first = false;
|
|
}
|
|
#endif
|
|
snprintf(buf + pos, buf_size - pos, "]}");
|
|
}
|
|
#endif
|
|
|
|
struct FreqRange {
|
|
uint32_t lower_freq, upper_freq;
|
|
};
|
|
|
|
static FreqRange repeat_freq_ranges[] = {
|
|
#ifdef ALLOWED_REPEAT_FREQ_RANGE
|
|
ALLOWED_REPEAT_FREQ_RANGE
|
|
#else
|
|
{ 433000, 433000 },
|
|
{ 869495, 869495 },
|
|
{ 918000, 918000 }
|
|
#endif
|
|
};
|
|
|
|
bool MyMesh::isValidClientRepeatFreq(uint32_t f) const {
|
|
for (int i = 0; i < sizeof(repeat_freq_ranges)/sizeof(repeat_freq_ranges[0]); i++) {
|
|
auto r = &repeat_freq_ranges[i];
|
|
if (f >= r->lower_freq && f <= r->upper_freq) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
void MyMesh::startInterface(BaseSerialInterface &serial) {
|
|
_serial = &serial;
|
|
serial.enable();
|
|
}
|
|
|
|
void MyMesh::clearPendingReqs() {
|
|
_delayed_replies.retireRequest(_ms->getMillis());
|
|
}
|
|
|
|
bool MyMesh::hasPendingReqs() const {
|
|
return _delayed_replies.hasRequest();
|
|
}
|
|
|
|
bool MyMesh::beginPendingRequest(mesh::CompanionDelayedReplies::Kind kind,
|
|
const ContactInfo& contact, bool terminal) {
|
|
servicePendingSerialReply();
|
|
_request_tag_rejected = false;
|
|
return _delayed_replies.reserveRequest(kind, contact.id.pub_key,
|
|
terminal || _serial == NULL ? NULL : _serial->captureReplyRoute(),
|
|
terminal, _ms->getMillis());
|
|
}
|
|
|
|
bool MyMesh::allowRequestTag(uint32_t tag) {
|
|
// Other internal Base callers need no host correlation reservation.
|
|
if (_delayed_replies.request.phase != mesh::CompanionDelayedReplies::Reserved) return true;
|
|
_request_tag_rejected = !_delayed_replies.allowRequestTag(tag);
|
|
return !_request_tag_rejected;
|
|
}
|
|
|
|
bool MyMesh::allocateRequestTag(uint32_t& tag) {
|
|
// Login replies use the server's clock, so their retired tag can equal
|
|
// the next locally generated request tag even with synchronized clocks.
|
|
// Skip held tags instead of reporting BAD_STATE to the app. One history
|
|
// slot belongs to this reservation, leaving at most HISTORY_SIZE - 1
|
|
// collisions. Keep the wall clock and all stale-response guards intact.
|
|
for (unsigned attempt = 0;
|
|
attempt < mesh::CompanionDelayedReplies::HISTORY_SIZE; ++attempt) {
|
|
tag = getRTCClock()->getCurrentTimeUnique();
|
|
if (allowRequestTag(tag)) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
void MyMesh::armPendingRequest(uint32_t tag, uint32_t timeout, bool flood) {
|
|
const uint32_t now = _ms->getMillis();
|
|
_delayed_replies.armRequest(tag, timeout, flood, now);
|
|
servicePendingSerialReply(now);
|
|
}
|
|
|
|
void MyMesh::finishPendingRequest(int result, uint32_t tag, uint32_t timeout) {
|
|
if (result == MSG_SEND_FAILED) {
|
|
abandonPendingRequest();
|
|
writeErrFrame(_request_tag_rejected ? ERR_CODE_BAD_STATE : ERR_CODE_TABLE_FULL);
|
|
} else {
|
|
armPendingRequest(tag, timeout, result == MSG_SEND_SENT_FLOOD);
|
|
}
|
|
}
|
|
|
|
void MyMesh::abandonPendingRequest() {
|
|
_delayed_replies.abandonRequest();
|
|
}
|
|
|
|
bool MyMesh::hasFiniteDelayedReplyForRoute(BaseSerialInterface* route) const {
|
|
if (route == NULL) return false;
|
|
if (_delayed_replies.hasReplyForRoute(route, _ms->getMillis())) {
|
|
return true;
|
|
}
|
|
for (int i = 0; i < EXPECTED_ACK_TABLE_SIZE; ++i) {
|
|
if (expected_ack_table[i].ack != 0
|
|
&& expected_ack_table[i].reply_route == route) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
void MyMesh::servicePendingSerialReply() {
|
|
servicePendingSerialReply(_ms->getMillis());
|
|
}
|
|
|
|
void MyMesh::servicePendingSerialReply(uint32_t now) {
|
|
#if COMPANION_FEATURE_TEXT_TERMINAL
|
|
serviceTerminalLogin(now);
|
|
#endif
|
|
_delayed_replies.serviceRequest(_serial, now);
|
|
}
|
|
|
|
void MyMesh::clearBinaryTraceReply() {
|
|
_delayed_replies.retireBinaryTrace(_ms->getMillis());
|
|
}
|
|
|
|
void MyMesh::serviceBinaryTraceReply() {
|
|
serviceBinaryTraceReply(_ms->getMillis());
|
|
}
|
|
|
|
void MyMesh::serviceBinaryTraceReply(uint32_t now) {
|
|
_delayed_replies.serviceBinaryTrace(_serial, now);
|
|
}
|
|
|
|
void MyMesh::cancelSigningSession() {
|
|
if (sign_data != NULL) free(sign_data);
|
|
sign_data = NULL;
|
|
sign_data_len = 0;
|
|
sign_data_reply_route = NULL;
|
|
sign_data_deadline = 0;
|
|
}
|
|
|
|
void MyMesh::serviceSigningSession() {
|
|
if (sign_data == NULL) return;
|
|
if (!_serial->isReplyRouteAvailable(sign_data_reply_route)
|
|
|| sign_data_deadline == _ms->getMillis()
|
|
|| millisHasNowPassed(sign_data_deadline)) {
|
|
cancelSigningSession();
|
|
}
|
|
}
|
|
|
|
void MyMesh::cancelSerialResponseStream(BaseSerialInterface* route) {
|
|
if (route == NULL || _iter_reply_route == route) stopContactsIterator();
|
|
}
|
|
|
|
void MyMesh::cancelSerialOperationsForRoute(BaseSerialInterface* route) {
|
|
if (route == NULL) return;
|
|
if (private_key_backup_route == route) {
|
|
private_key_backup_route = NULL;
|
|
private_key_backup_deadline = 0;
|
|
memset(private_key_backup_nonce, 0, sizeof(private_key_backup_nonce));
|
|
memset(private_key_backup_sender, 0, sizeof(private_key_backup_sender));
|
|
}
|
|
if (_delayed_replies.request.route == route) clearPendingReqs();
|
|
if (_delayed_replies.trace.route == route) clearBinaryTraceReply();
|
|
if (sign_data_reply_route == route) cancelSigningSession();
|
|
for (int i = 0; i < EXPECTED_ACK_TABLE_SIZE; ++i) {
|
|
if (expected_ack_table[i].reply_route == route) {
|
|
// A host-session change must prevent the late ACK from reaching a new
|
|
// session on the same transport, but it must not revoke radio delivery
|
|
// for a message for which RESP_CODE_SENT was already returned.
|
|
expected_ack_table[i].reply_route = NULL;
|
|
}
|
|
}
|
|
expireExpectedAcks();
|
|
}
|
|
|
|
void MyMesh::resetUsbHostSessionInput() {
|
|
// CLI rescue deliberately remains active across reconnects, but no bytes
|
|
// typed by the old host may complete a command for the new one. This is
|
|
// especially important for the rescue shell's erase/rm commands.
|
|
if (_cli_rescue) {
|
|
memset(cli_command, 0, sizeof(cli_command));
|
|
_cli_line_overflow = false;
|
|
}
|
|
}
|
|
|
|
void MyMesh::handleCmdFrame(size_t len) {
|
|
if (len == 0) {
|
|
writeErrFrame(ERR_CODE_ILLEGAL_ARG);
|
|
return;
|
|
}
|
|
|
|
#if defined(MESH_HIL_BLE_BENCH) && MESH_HIL_BLE_BENCH
|
|
if (cmd_frame[0] == mesh::companion::kBleBenchCommand) {
|
|
// Direct Companion transport benchmark only: no RF dispatch, settings,
|
|
// identity reads or persistent state. Reuse the normal reply queue and
|
|
// its required-frame admission/backpressure, including the source route.
|
|
const size_t echoed = mesh::companion::buildBleBenchEcho(
|
|
cmd_frame, len, out_frame, MAX_FRAME_SIZE);
|
|
if (echoed) _serial->writeFrame(out_frame, echoed);
|
|
else writeErrFrame(ERR_CODE_ILLEGAL_ARG);
|
|
return;
|
|
}
|
|
#endif
|
|
|
|
servicePendingSerialReply();
|
|
const bool starts_long_lived_request =
|
|
(cmd_frame[0] == CMD_SEND_LOGIN && len >= 1 + PUB_KEY_SIZE)
|
|
|| (cmd_frame[0] == CMD_SEND_ANON_REQ && len > 1 + PUB_KEY_SIZE)
|
|
|| (cmd_frame[0] == CMD_SEND_STATUS_REQ && len >= 1 + PUB_KEY_SIZE)
|
|
|| (cmd_frame[0] == CMD_SEND_PATH_DISCOVERY_REQ
|
|
&& len >= 2 + PUB_KEY_SIZE)
|
|
|| (cmd_frame[0] == CMD_SEND_TELEMETRY_REQ
|
|
&& len >= 4 + PUB_KEY_SIZE)
|
|
|| (cmd_frame[0] == CMD_SEND_BINARY_REQ
|
|
&& len >= 2 + PUB_KEY_SIZE);
|
|
if (starts_long_lived_request && hasPendingReqs()) {
|
|
// There is one correlation slot for these request families. Reject before
|
|
// transmitting rather than orphaning an earlier USB/BLE client's SENT
|
|
// operation and reassigning its late response to the new requester.
|
|
writeErrFrame(ERR_CODE_BAD_STATE);
|
|
return;
|
|
}
|
|
|
|
if (cmd_frame[0] == CMD_DEVICE_QUERY && len >= 2) { // sent when app establishes connection
|
|
app_target_ver = cmd_frame[1]; // which version of protocol does app understand
|
|
|
|
int i = 0;
|
|
out_frame[i++] = RESP_CODE_DEVICE_INFO;
|
|
out_frame[i++] = FIRMWARE_VER_CODE;
|
|
out_frame[i++] = MAX_CONTACTS / 2; // v3+
|
|
out_frame[i++] = MAX_GROUP_CHANNELS; // v3+
|
|
memcpy(&out_frame[i], &_active_ble_pin, 4);
|
|
i += 4;
|
|
memset(&out_frame[i], 0, 12);
|
|
strcpy((char *)&out_frame[i], FIRMWARE_BUILD_DATE);
|
|
i += 12;
|
|
StrHelper::strzcpy((char *)&out_frame[i], board.getManufacturerName(), 40);
|
|
i += 40;
|
|
StrHelper::strzcpy((char *)&out_frame[i], FIRMWARE_VERSION, 20);
|
|
i += 20;
|
|
out_frame[i++] = _prefs.isRepeatEn() ? 1 : 0; // v9+
|
|
out_frame[i++] = _prefs.path_hash_mode; // v10+
|
|
_serial->writeFrame(out_frame, i);
|
|
} else if (cmd_frame[0] == CMD_APP_START &&
|
|
len >= 8) { // sent when app establishes connection, respond with node ID
|
|
// cmd_frame[1..7] reserved future
|
|
char *app_name = (char *)&cmd_frame[8];
|
|
cmd_frame[len] = 0; // make app_name null terminated
|
|
MESH_DEBUG_PRINTLN("App %s connected", app_name);
|
|
|
|
BaseSerialInterface* app_route = _serial->captureReplyRoute();
|
|
cancelSerialResponseStream(app_route);
|
|
cancelSerialOperationsForRoute(app_route);
|
|
int i = 0;
|
|
out_frame[i++] = RESP_CODE_SELF_INFO;
|
|
out_frame[i++] = ADV_TYPE_CHAT; // what this node Advert identifies as (maybe node's pronouns too?? :-)
|
|
out_frame[i++] = _prefs.tx_power_dbm;
|
|
out_frame[i++] = MAX_LORA_TX_POWER;
|
|
memcpy(&out_frame[i], self_id.pub_key, PUB_KEY_SIZE);
|
|
i += PUB_KEY_SIZE;
|
|
|
|
int32_t lat, lon;
|
|
lat = (sensors.node_lat * 1000000.0);
|
|
lon = (sensors.node_lon * 1000000.0);
|
|
memcpy(&out_frame[i], &lat, 4);
|
|
i += 4;
|
|
memcpy(&out_frame[i], &lon, 4);
|
|
i += 4;
|
|
out_frame[i++] = _prefs.multi_acks; // new v7+
|
|
out_frame[i++] = _prefs.advert_loc_policy;
|
|
out_frame[i++] = (_prefs.telemetry_mode_env << 4) | (_prefs.telemetry_mode_loc << 2) |
|
|
(_prefs.telemetry_mode_base); // v5+
|
|
out_frame[i++] = _prefs.manual_add_contacts;
|
|
|
|
uint32_t freq = _prefs.freq * 1000;
|
|
memcpy(&out_frame[i], &freq, 4);
|
|
i += 4;
|
|
uint32_t bw = _prefs.bw * 1000;
|
|
memcpy(&out_frame[i], &bw, 4);
|
|
i += 4;
|
|
out_frame[i++] = _prefs.sf;
|
|
out_frame[i++] = _prefs.cr;
|
|
|
|
int tlen = strlen(_prefs.node_name); // revisit: UTF_8 ??
|
|
memcpy(&out_frame[i], _prefs.node_name, tlen);
|
|
i += tlen;
|
|
_serial->writeFrame(out_frame, i);
|
|
} else if (mesh::companion::isRunCliFrame(cmd_frame[0], len)) { // V14+
|
|
int i = 1;
|
|
char *text = (char *)&cmd_frame[i];
|
|
const size_t tlen = mesh::companion::cliCommandTextLength(
|
|
&cmd_frame[i], len - i);
|
|
if (tlen == 0) {
|
|
writeErrFrame(ERR_CODE_ILLEGAL_ARG);
|
|
} else {
|
|
text[tlen] = 0; // ensure null
|
|
|
|
BaseSerialInterface* reply_route = _serial->captureReplyRoute();
|
|
beginUsbLoggingReplyBarrier(reply_route);
|
|
reply_buf[0] = 0;
|
|
bool handled = false;
|
|
#if defined(MESHCORE_EXTRAFS_HIL)
|
|
// This hook is deliberately reachable only from a directly attached
|
|
// Companion frame. It is not part of handleCommand(), so an on-air
|
|
// administrator command can never fill, corrupt, or reset the device.
|
|
handled = handleExtraFsHilCommand(text, reply_buf, sizeof(reply_buf));
|
|
#endif
|
|
if (!handled) handled = handleCommand(text, 0, reply_buf);
|
|
if (!handled) {
|
|
strcat(reply_buf, "Unknown command"); // reply_buf may have cmd prefix from 'text'
|
|
}
|
|
out_frame[0] = RESP_CODE_CLI_REPLY;
|
|
int rlen = strlen(reply_buf);
|
|
memcpy(&out_frame[1], reply_buf, rlen);
|
|
const bool reply_queued = _serial->writeFrameToRoute(
|
|
reply_route, out_frame, 1 + rlen) == static_cast<size_t>(1 + rlen);
|
|
endUsbLoggingReplyBarrier(reply_queued);
|
|
}
|
|
} else if (cmd_frame[0] == CMD_SEND_TXT_MSG && len >= 14) {
|
|
int i = 1;
|
|
uint8_t txt_type = cmd_frame[i++];
|
|
uint8_t attempt = cmd_frame[i++];
|
|
uint32_t msg_timestamp;
|
|
memcpy(&msg_timestamp, &cmd_frame[i], 4);
|
|
i += 4;
|
|
uint8_t *pub_key_prefix = &cmd_frame[i];
|
|
i += 6;
|
|
ContactInfo *recipient = lookupContactByPubKey(pub_key_prefix, 6);
|
|
if (recipient && (txt_type == TXT_TYPE_PLAIN || txt_type == TXT_TYPE_CLI_DATA || txt_type == TXT_TYPE_CLI_COMMAND)) {
|
|
char *text = (char *)&cmd_frame[i];
|
|
int tlen = len - i;
|
|
uint32_t est_timeout;
|
|
text[tlen] = 0; // ensure null
|
|
|
|
uint8_t text_fingerprint[MAX_HASH_SIZE] = { 0 };
|
|
uint8_t packet_retry_key[MAX_HASH_SIZE] = { 0 };
|
|
AckTableEntry* replacement_entry = NULL;
|
|
if (txt_type == TXT_TYPE_PLAIN) {
|
|
mesh::Utils::sha256(text_fingerprint, sizeof(text_fingerprint),
|
|
recipient->id.pub_key, PUB_KEY_SIZE,
|
|
(const uint8_t*)text, strlen(text));
|
|
}
|
|
|
|
int result;
|
|
uint32_t expected_ack;
|
|
uint32_t one_key_delay = 0;
|
|
if (txt_type == TXT_TYPE_CLI_DATA || txt_type == TXT_TYPE_CLI_COMMAND) {
|
|
const uint32_t logical_request_id = msg_timestamp;
|
|
msg_timestamp = getRTCClock()->getCurrentTimeUnique(); // Use node's RTC instead of app timestamp to avoid tripping replay protection
|
|
result = sendCommandData(*recipient, msg_timestamp, attempt, txt_type,
|
|
text, est_timeout, logical_request_id);
|
|
expected_ack = 0; // no Ack expected
|
|
} else {
|
|
const uint32_t app_timestamp = msg_timestamp;
|
|
const bool is_room_post = recipient->type == ADV_TYPE_ROOM;
|
|
if (is_room_post
|
|
&& !room_message_timestamps.find(text_fingerprint, app_timestamp,
|
|
&msg_timestamp)) {
|
|
// Room login/control packets already use the node's monotonic clock.
|
|
// Give a new post that same clock source, then preserve the mapping
|
|
// so application retries keep one logical server-side timestamp.
|
|
msg_timestamp = getRTCClock()->getCurrentTimeUnique();
|
|
}
|
|
replacement_entry = findPendingTextMessage(text_fingerprint, msg_timestamp);
|
|
#if MESH_ENABLE_ONE_KEY_DM
|
|
if (recipient->type == ADV_TYPE_CHAT
|
|
&& !hasOneKeyReject(*recipient)
|
|
&& (attempt != 0 || !hasOneKeyAck(*recipient))
|
|
&& tlen <= MAX_TEXT_LEN
|
|
&& (attempt <= 3 || tlen <= MAX_TEXT_LEN - 2)) {
|
|
one_key_delay = sendOneKeyIntroduction(*recipient);
|
|
}
|
|
#endif
|
|
result = sendMessage(*recipient, msg_timestamp, attempt, text, expected_ack, est_timeout,
|
|
packet_retry_key, NULL, text_fingerprint, one_key_delay);
|
|
if (result != MSG_SEND_FAILED && is_room_post) {
|
|
room_message_timestamps.remember(text_fingerprint, app_timestamp,
|
|
msg_timestamp);
|
|
}
|
|
}
|
|
if (result == MSG_SEND_FAILED) {
|
|
writeErrFrame(ERR_CODE_TABLE_FULL);
|
|
} else {
|
|
if ((txt_type == TXT_TYPE_CLI_DATA || txt_type == TXT_TYPE_CLI_COMMAND)
|
|
&& strncmp(text, "backup prv.key ", 15) == 0
|
|
&& strlen(text + 15) == 16) {
|
|
private_key_backup_route = _serial->captureReplyRoute();
|
|
private_key_backup_deadline = futureMillis(120000);
|
|
memcpy(private_key_backup_nonce, text + 15, 16);
|
|
private_key_backup_nonce[16] = 0;
|
|
memcpy(private_key_backup_sender, recipient->id.pub_key, 6);
|
|
}
|
|
if (replacement_entry != NULL) {
|
|
// The newest successfully-queued submission wins. Keep the older
|
|
// entry intact if composition, validation, or queueing failed.
|
|
clearExpectedAck(*replacement_entry, false);
|
|
}
|
|
if (expected_ack) {
|
|
AckTableEntry& entry = replacement_entry != NULL
|
|
? *replacement_entry
|
|
: expected_ack_table[next_ack_idx];
|
|
// Reusing this circular slot changes only ACK bookkeeping. Retry
|
|
// ownership lives in Mesh and ends only on an ACK, retry completion,
|
|
// or a successfully queued semantic replacement.
|
|
clearExpectedAck(entry, false);
|
|
entry.msg_sent = _ms->getMillis() + one_key_delay; // scheduled radio send
|
|
entry.expires_at = futureMillis(est_timeout);
|
|
entry.ack = expected_ack;
|
|
entry.message_timestamp = msg_timestamp;
|
|
entry.contact = recipient;
|
|
entry.reply_route = _serial->captureReplyRoute();
|
|
memcpy(entry.text_fingerprint, text_fingerprint, sizeof(entry.text_fingerprint));
|
|
memcpy(entry.retry_key, packet_retry_key, sizeof(entry.retry_key));
|
|
if (replacement_entry == NULL) {
|
|
next_ack_idx = (next_ack_idx + 1) % EXPECTED_ACK_TABLE_SIZE;
|
|
}
|
|
}
|
|
if (replacement_entry != NULL || expected_ack != 0) {
|
|
expireExpectedAcks();
|
|
}
|
|
|
|
out_frame[0] = RESP_CODE_SENT;
|
|
out_frame[1] = (result == MSG_SEND_SENT_FLOOD) ? 1 : 0;
|
|
memcpy(&out_frame[2], &expected_ack, 4);
|
|
memcpy(&out_frame[6], &est_timeout, 4);
|
|
_serial->writeFrame(out_frame, 10);
|
|
}
|
|
} else {
|
|
writeErrFrame(recipient == NULL
|
|
? ERR_CODE_NOT_FOUND
|
|
: ERR_CODE_UNSUPPORTED_CMD); // unknown recipient, or unsupported TXT_TYPE_*
|
|
}
|
|
} else if (cmd_frame[0] == CMD_SEND_CHANNEL_TXT_MSG) { // send GroupChannel text msg
|
|
if (len < 7) {
|
|
writeErrFrame(ERR_CODE_ILLEGAL_ARG);
|
|
return;
|
|
}
|
|
int i = 1;
|
|
uint8_t txt_type = cmd_frame[i++]; // should be TXT_TYPE_PLAIN
|
|
uint8_t channel_idx = cmd_frame[i++];
|
|
uint32_t msg_timestamp;
|
|
memcpy(&msg_timestamp, &cmd_frame[i], 4);
|
|
i += 4;
|
|
const char *text = (char *)&cmd_frame[i];
|
|
|
|
if (txt_type != TXT_TYPE_PLAIN) {
|
|
writeErrFrame(ERR_CODE_UNSUPPORTED_CMD);
|
|
} else {
|
|
ChannelDetails channel;
|
|
bool success = getChannel(channel_idx, channel);
|
|
#if MESH_ENABLE_MESHTEXT_CHAT
|
|
if (success && isMeshtextChannel(channel)) {
|
|
if (sendMeshtextText(channel, text, len - i)) {
|
|
#if UI_POCKET_GUI
|
|
if (_ui) _ui->channelMessageSent(channel_idx, channel.name, _prefs.node_name, text, len - i);
|
|
#endif
|
|
writeOKFrame();
|
|
}
|
|
else writeErrFrame(ERR_CODE_BAD_STATE);
|
|
} else
|
|
#endif
|
|
if (success && isAliasRadio2Defined()
|
|
&& parseCompanionChannelAlias(channel.name).kind == CompanionChannelKind::Meshtext) {
|
|
writeErrFrame(ERR_CODE_UNSUPPORTED_CMD);
|
|
} else if (success && sendGroupMessage(msg_timestamp, channel.channel, _prefs.node_name, text, len - i)) {
|
|
#if UI_POCKET_GUI
|
|
if (_ui) _ui->channelMessageSent(channel_idx, channel.name, _prefs.node_name, text, len - i);
|
|
#endif
|
|
writeOKFrame();
|
|
} else {
|
|
writeErrFrame(ERR_CODE_NOT_FOUND); // bad channel_idx
|
|
}
|
|
}
|
|
} else if (cmd_frame[0] == CMD_SEND_CHANNEL_DATA) { // send GroupChannel datagram
|
|
if (len < 3) {
|
|
writeErrFrame(ERR_CODE_ILLEGAL_ARG);
|
|
return;
|
|
}
|
|
int i = 1;
|
|
uint8_t channel_idx = cmd_frame[i++];
|
|
uint8_t path_len = cmd_frame[i++];
|
|
|
|
// validate path len, allowing 0xFF for flood
|
|
if (!mesh::Packet::isValidPathLen(path_len) && path_len != OUT_PATH_UNKNOWN) {
|
|
MESH_DEBUG_PRINTLN("CMD_SEND_CHANNEL_DATA invalid path size: %d", path_len);
|
|
writeErrFrame(ERR_CODE_ILLEGAL_ARG);
|
|
return;
|
|
}
|
|
|
|
// parse provided path if not flood
|
|
uint8_t path[MAX_PATH_SIZE];
|
|
size_t path_bytes = 0;
|
|
if (path_len != OUT_PATH_UNKNOWN) {
|
|
path_bytes = (size_t)(path_len & 63) * (size_t)((path_len >> 6) + 1);
|
|
}
|
|
if ((size_t)i + path_bytes + 2 > len) {
|
|
writeErrFrame(ERR_CODE_ILLEGAL_ARG);
|
|
return;
|
|
}
|
|
if (path_len != OUT_PATH_UNKNOWN) {
|
|
i += mesh::Packet::writePath(path, &cmd_frame[i], path_len);
|
|
}
|
|
|
|
uint16_t data_type = ((uint16_t)cmd_frame[i]) | (((uint16_t)cmd_frame[i + 1]) << 8);
|
|
i += 2;
|
|
const uint8_t *payload = &cmd_frame[i];
|
|
int payload_len = (len > (size_t)i) ? (int)(len - i) : 0;
|
|
|
|
ChannelDetails channel;
|
|
if (!getChannel(channel_idx, channel)) {
|
|
writeErrFrame(ERR_CODE_NOT_FOUND); // bad channel_idx
|
|
#if MESH_ENABLE_MESHTEXT_CHAT
|
|
} else if (isMeshtextChannel(channel)) {
|
|
writeErrFrame(ERR_CODE_UNSUPPORTED_CMD); // channel text only, never raw/admin data
|
|
#endif
|
|
} else if (isAliasRadio2Defined()
|
|
&& parseCompanionChannelAlias(channel.name).kind == CompanionChannelKind::Meshtext) {
|
|
writeErrFrame(ERR_CODE_UNSUPPORTED_CMD);
|
|
} else if (data_type == DATA_TYPE_RESERVED) {
|
|
writeErrFrame(ERR_CODE_ILLEGAL_ARG);
|
|
} else if (payload_len > MAX_GROUP_DATA_LENGTH) {
|
|
MESH_DEBUG_PRINTLN("CMD_SEND_CHANNEL_DATA payload too long: %d > %d", payload_len, MAX_GROUP_DATA_LENGTH);
|
|
writeErrFrame(ERR_CODE_ILLEGAL_ARG);
|
|
} else if (sendClientGroupData(channel.channel, path, path_len, data_type, payload, payload_len)) {
|
|
writeOKFrame();
|
|
} else {
|
|
writeErrFrame(ERR_CODE_TABLE_FULL);
|
|
}
|
|
} else if (cmd_frame[0] == CMD_GET_CONTACTS) { // get Contact list
|
|
if (_iter_started) {
|
|
writeErrFrame(ERR_CODE_BAD_STATE); // iterator is currently busy
|
|
} else if (_store->hasIncompleteContactLoad()) {
|
|
// Never present a transiently unread page as an authoritative partial
|
|
// contact list. A reboot can retry the untouched source page.
|
|
writeErrFrame(ERR_CODE_FILE_IO_ERROR);
|
|
} else {
|
|
if (len >= 5) { // has optional 'since' param
|
|
memcpy(&_iter_filter_since, &cmd_frame[1], 4);
|
|
} else {
|
|
_iter_filter_since = 0;
|
|
}
|
|
|
|
// Retain this stream's destination independently of later commands.
|
|
// BLE's paced contact sync must not hold USB/WiFi command polling until
|
|
// the entire list finishes, or redirect their replies to the BLE app.
|
|
_iter_reply_route = _serial->captureReplyRoute();
|
|
_iter = startContactsIterator();
|
|
_iter_started = true;
|
|
_iter_start_pending = true;
|
|
_iter_contact_pending = false;
|
|
_iter_next_frame_at = 0;
|
|
_iter_total_count = getNumContacts();
|
|
_iter_table_revision = getContactTableRevision();
|
|
_most_recent_lastmod = 0;
|
|
|
|
uint8_t reply[5];
|
|
reply[0] = RESP_CODE_CONTACTS_START;
|
|
memcpy(&reply[1], &_iter_total_count, 4); // total, NOT filtered count
|
|
if (_serial->writeFrameToRoute(_iter_reply_route, reply, 5) == 5) {
|
|
_iter_start_pending = false;
|
|
_iter_next_frame_at = futureMillis(CONTACT_STREAM_FRAME_INTERVAL_MS);
|
|
}
|
|
}
|
|
} else if (cmd_frame[0] == CMD_SET_ADVERT_NAME && len >= 2) {
|
|
char name[sizeof(_prefs.node_name)];
|
|
int nlen = len - 1;
|
|
if (nlen > sizeof(name) - 1) nlen = sizeof(name) - 1; // max len
|
|
memcpy(name, &cmd_frame[1], nlen);
|
|
name[nlen] = 0;
|
|
if (saveAdvertName(name)) writeOKFrame();
|
|
else writeErrFrame(ERR_CODE_FILE_IO_ERROR);
|
|
} else if (cmd_frame[0] == CMD_SET_ADVERT_LATLON && len >= 9) {
|
|
int32_t lat, lon, alt = 0;
|
|
memcpy(&lat, &cmd_frame[1], 4);
|
|
memcpy(&lon, &cmd_frame[5], 4);
|
|
if (len >= 13) {
|
|
memcpy(&alt, &cmd_frame[9], 4); // for FUTURE support
|
|
}
|
|
if (lat <= 90 * 1E6 && lat >= -90 * 1E6 && lon <= 180 * 1E6 && lon >= -180 * 1E6) {
|
|
if (saveAdvertLocation(((double)lat) / 1000000.0,
|
|
((double)lon) / 1000000.0)) writeOKFrame();
|
|
else writeErrFrame(ERR_CODE_FILE_IO_ERROR);
|
|
} else {
|
|
writeErrFrame(ERR_CODE_ILLEGAL_ARG); // invalid geo coordinate
|
|
}
|
|
} else if (cmd_frame[0] == CMD_GET_DEVICE_TIME) {
|
|
uint8_t reply[5];
|
|
reply[0] = RESP_CODE_CURR_TIME;
|
|
uint32_t now = getRTCClock()->getCurrentTime();
|
|
memcpy(&reply[1], &now, 4);
|
|
_serial->writeFrame(reply, 5);
|
|
} else if (cmd_frame[0] == CMD_SET_DEVICE_TIME && len >= 5) {
|
|
uint32_t secs;
|
|
memcpy(&secs, &cmd_frame[1], 4);
|
|
uint32_t curr = getRTCClock()->getCurrentTime();
|
|
if (secs >= curr) {
|
|
getRTCClock()->setCurrentTime(secs);
|
|
#ifdef COMPANION_MESH_CLOCK_SYNC
|
|
_clock_sync.onManualClockSet();
|
|
#endif
|
|
writeOKFrame();
|
|
} else {
|
|
writeErrFrame(ERR_CODE_ILLEGAL_ARG);
|
|
}
|
|
} else if (cmd_frame[0] == CMD_SEND_SELF_ADVERT) {
|
|
mesh::Packet* pkt;
|
|
if (_prefs.advert_loc_policy == ADVERT_LOC_NONE) {
|
|
pkt = createSelfAdvert(_prefs.node_name);
|
|
} else {
|
|
pkt = createSelfAdvert(_prefs.node_name, sensors.node_lat, sensors.node_lon);
|
|
}
|
|
if (pkt) {
|
|
if (len >= 2 && cmd_frame[1] == 1) { // optional param (1 = flood, 0 = zero hop)
|
|
unsigned long delay_millis = 0;
|
|
TransportKey default_scope;
|
|
memcpy(&default_scope.key, _prefs.default_scope_key, sizeof(default_scope.key));
|
|
sendFloodScoped(default_scope, pkt, delay_millis);
|
|
} else {
|
|
sendZeroHop(pkt);
|
|
}
|
|
writeOKFrame();
|
|
} else {
|
|
writeErrFrame(ERR_CODE_TABLE_FULL);
|
|
}
|
|
} else if (cmd_frame[0] == CMD_RESET_PATH && len >= 1 + 32) {
|
|
if (_store->hasIncompleteContactLoad()) {
|
|
writeErrFrame(ERR_CODE_FILE_IO_ERROR);
|
|
} else {
|
|
uint8_t *pub_key = &cmd_frame[1];
|
|
ContactInfo *recipient = lookupPersistentContactByPubKey(pub_key, PUB_KEY_SIZE);
|
|
if (recipient) {
|
|
const uint8_t previous_out_path_len = recipient->out_path_len;
|
|
recipient->out_path_len = OUT_PATH_UNKNOWN;
|
|
// recipient->lastmod = ?? shouldn't be needed, app already has this version of contact
|
|
if (scheduleContactWrite(*recipient)) {
|
|
ContactInfo* transient = lookupTransientContactByPubKey(
|
|
pub_key, PUB_KEY_SIZE);
|
|
if (transient != NULL) clearTransientContact(*transient, recipient);
|
|
writeOKFrame();
|
|
} else {
|
|
recipient->out_path_len = previous_out_path_len;
|
|
writeErrFrame(ERR_CODE_FILE_IO_ERROR);
|
|
}
|
|
} else {
|
|
writeErrFrame(ERR_CODE_NOT_FOUND); // unknown contact
|
|
}
|
|
}
|
|
} else if (cmd_frame[0] == CMD_ADD_UPDATE_CONTACT) {
|
|
if (len < CONTACT_UPDATE_FRAME_MIN_LEN) {
|
|
writeErrFrame(ERR_CODE_ILLEGAL_ARG);
|
|
} else if (_store->hasIncompleteContactLoad()) {
|
|
writeErrFrame(ERR_CODE_FILE_IO_ERROR);
|
|
} else {
|
|
uint8_t *pub_key = &cmd_frame[1];
|
|
ContactInfo *recipient = lookupPersistentContactByPubKey(pub_key, PUB_KEY_SIZE);
|
|
ContactInfo candidate = {};
|
|
if (recipient != NULL) candidate = *recipient;
|
|
|
|
uint32_t last_mod = getRTCClock()->getCurrentTime(); // fallback value if not present in cmd_frame
|
|
if (!updateContactFromFrame(candidate, last_mod, cmd_frame, len)) {
|
|
writeErrFrame(ERR_CODE_ILLEGAL_ARG);
|
|
} else {
|
|
candidate.lastmod = last_mod;
|
|
if (recipient == NULL) candidate.sync_since = 0;
|
|
|
|
ContactInfo* transient = lookupTransientContactByPubKey(
|
|
pub_key, PUB_KEY_SIZE);
|
|
|
|
if (recipient != NULL) {
|
|
const ContactInfo previous = *recipient;
|
|
*recipient = candidate;
|
|
if (scheduleContactWrite(*recipient)) {
|
|
// A prior anonymous request can leave a same-key entry in the
|
|
// reserved prefix. Retire it in place; never compact that prefix.
|
|
if (transient != NULL) clearTransientContact(*transient, recipient);
|
|
updateGpsTelemetryPolicy();
|
|
writeOKFrame();
|
|
} else {
|
|
*recipient = previous;
|
|
writeErrFrame(ERR_CODE_FILE_IO_ERROR);
|
|
}
|
|
} else {
|
|
// Preserve the transient and its pending ACK references until the
|
|
// persistent destination is accepted. Failure preserves the transient.
|
|
if (addContact(candidate)) {
|
|
ContactInfo* added = lookupPersistentContactByPubKey(
|
|
candidate.id.pub_key, PUB_KEY_SIZE);
|
|
if (added != NULL && scheduleContactWrite(*added)) {
|
|
if (transient != NULL) clearTransientContact(*transient, added);
|
|
updateGpsTelemetryPolicy();
|
|
writeOKFrame();
|
|
} else {
|
|
if (added != NULL) removeContact(*added);
|
|
writeErrFrame(ERR_CODE_FILE_IO_ERROR);
|
|
}
|
|
} else {
|
|
writeErrFrame(ERR_CODE_TABLE_FULL);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} else if (cmd_frame[0] == CMD_REMOVE_CONTACT && len >= 1 + PUB_KEY_SIZE) {
|
|
if (_store->hasIncompleteContactLoad()) {
|
|
writeErrFrame(ERR_CODE_FILE_IO_ERROR);
|
|
} else {
|
|
uint8_t *pub_key = &cmd_frame[1];
|
|
ContactInfo *recipient = lookupPersistentContactByPubKey(pub_key, PUB_KEY_SIZE);
|
|
if (recipient == NULL) {
|
|
writeErrFrame(ERR_CODE_NOT_FOUND);
|
|
} else {
|
|
ContactInfo removed = *recipient;
|
|
// releaseContact() is failure-atomic, so reserve the persistent deletion
|
|
// before changing the live table. A failed release must not be ACKed.
|
|
uint16_t released_slot = mesh::storage::CONTACT_SLOT_NONE;
|
|
if (!scheduleContactWriteAfterRelease(removed, released_slot)) {
|
|
writeErrFrame(ERR_CODE_FILE_IO_ERROR);
|
|
} else if (!_store->deleteBlobByKey(pub_key, PUB_KEY_SIZE)) {
|
|
// Keep the live contact and restore ownership of its persistent slot.
|
|
// A full filesystem must not turn a failed cache deletion into an
|
|
// acknowledged contact removal with stale share data left behind.
|
|
if (!restoreContactWriteAfterRelease(*recipient, released_slot)) {
|
|
MESH_DEBUG_PRINTLN(
|
|
"Contact removal rollback failed after advert-cache error");
|
|
}
|
|
writeErrFrame(ERR_CODE_FILE_IO_ERROR);
|
|
} else if (!removeContact(*recipient)) {
|
|
// The lookup above makes this unreachable in the single-threaded mesh
|
|
// loop, but restore persistence bookkeeping if that invariant changes.
|
|
restoreContactWriteAfterRelease(*recipient, released_slot);
|
|
writeErrFrame(ERR_CODE_BAD_STATE);
|
|
} else {
|
|
ContactInfo* transient = lookupTransientContactByPubKey(
|
|
pub_key, PUB_KEY_SIZE);
|
|
if (transient != NULL) clearTransientContact(*transient);
|
|
updateGpsTelemetryPolicy();
|
|
writeOKFrame();
|
|
}
|
|
}
|
|
}
|
|
} else if (cmd_frame[0] == CMD_SHARE_CONTACT && len >= 1 + PUB_KEY_SIZE) {
|
|
uint8_t *pub_key = &cmd_frame[1];
|
|
ContactInfo *recipient = lookupContactByPubKey(pub_key, PUB_KEY_SIZE);
|
|
if (recipient) {
|
|
if (shareContactZeroHop(*recipient)) {
|
|
writeOKFrame();
|
|
} else {
|
|
writeErrFrame(ERR_CODE_TABLE_FULL); // unable to send
|
|
}
|
|
} else {
|
|
writeErrFrame(ERR_CODE_NOT_FOUND);
|
|
}
|
|
} else if (cmd_frame[0] == CMD_GET_CONTACT_BY_KEY && len >= 1 + PUB_KEY_SIZE) {
|
|
uint8_t *pub_key = &cmd_frame[1];
|
|
ContactInfo *contact = lookupContactByPubKey(pub_key, PUB_KEY_SIZE);
|
|
if (contact) {
|
|
writeContactRespFrame(RESP_CODE_CONTACT, *contact);
|
|
} else {
|
|
writeErrFrame(ERR_CODE_NOT_FOUND); // not found
|
|
}
|
|
} else if (cmd_frame[0] == CMD_EXPORT_CONTACT) {
|
|
if (len < 1 + PUB_KEY_SIZE) {
|
|
// export SELF
|
|
mesh::Packet* pkt;
|
|
if (_prefs.advert_loc_policy == ADVERT_LOC_NONE) {
|
|
pkt = createSelfAdvert(_prefs.node_name);
|
|
} else {
|
|
pkt = createSelfAdvert(_prefs.node_name, sensors.node_lat, sensors.node_lon);
|
|
}
|
|
if (pkt) {
|
|
pkt->header |= ROUTE_TYPE_FLOOD; // would normally be sent in this mode
|
|
|
|
out_frame[0] = RESP_CODE_EXPORT_CONTACT;
|
|
uint8_t out_len = pkt->writeTo(&out_frame[1]);
|
|
releasePacket(pkt); // undo the obtainNewPacket()
|
|
_serial->writeFrame(out_frame, out_len + 1);
|
|
} else {
|
|
writeErrFrame(ERR_CODE_TABLE_FULL); // Error
|
|
}
|
|
} else {
|
|
uint8_t *pub_key = &cmd_frame[1];
|
|
ContactInfo *recipient = lookupContactByPubKey(pub_key, PUB_KEY_SIZE);
|
|
uint8_t out_len;
|
|
if (recipient && (out_len = exportContact(*recipient, &out_frame[1])) > 0) {
|
|
out_frame[0] = RESP_CODE_EXPORT_CONTACT;
|
|
_serial->writeFrame(out_frame, out_len + 1);
|
|
} else {
|
|
writeErrFrame(ERR_CODE_NOT_FOUND); // not found
|
|
}
|
|
}
|
|
} else if (cmd_frame[0] == CMD_IMPORT_CONTACT && len > 2 + 32 + 64) {
|
|
if (_store->hasIncompleteContactLoad()) {
|
|
// importContact() is asynchronous: without this admission gate the
|
|
// queued advert would be ACKed here and then deliberately discarded by
|
|
// BaseChatMesh's incomplete-table mutation veto on the next loop.
|
|
writeErrFrame(ERR_CODE_FILE_IO_ERROR);
|
|
} else if (importContact(&cmd_frame[1], len - 1)) {
|
|
updateGpsTelemetryPolicy();
|
|
writeOKFrame();
|
|
} else {
|
|
writeErrFrame(ERR_CODE_ILLEGAL_ARG);
|
|
}
|
|
} else if (cmd_frame[0] == CMD_SYNC_NEXT_MESSAGE) {
|
|
syncNextOfflineMessage();
|
|
} else if (cmd_frame[0] == CMD_SET_RADIO_PARAMS) {
|
|
if (len < 11) { writeErrFrame(ERR_CODE_ILLEGAL_ARG); return; }
|
|
uint32_t freq, bw;
|
|
memcpy(&freq, &cmd_frame[1], 4);
|
|
memcpy(&bw, &cmd_frame[5], 4);
|
|
const uint8_t sf = cmd_frame[9], cr = cmd_frame[10];
|
|
const uint8_t repeat = len > 11 ? cmd_frame[11] : 0;
|
|
if (freq < 150000 || freq > 2500000 || bw < 7000 || bw > 500000
|
|
|| sf < 5 || sf > 12 || cr < 5 || cr > 8) {
|
|
writeErrFrame(ERR_CODE_ILLEGAL_ARG);
|
|
} else {
|
|
const auto result = applyAndSaveRadioParams(freq / 1000.0f, bw / 1000.0f,
|
|
sf, cr, repeat, _radio_profiles.primaryPreamble());
|
|
if (result == RadioSettingResult::Saved) writeOKFrame();
|
|
else writeErrFrame(result == RadioSettingResult::RadioRejected
|
|
? ERR_CODE_ILLEGAL_ARG : ERR_CODE_FILE_IO_ERROR);
|
|
}
|
|
} else if (cmd_frame[0] == CMD_SET_RADIO_TX_POWER) {
|
|
if (len < 2) {
|
|
writeErrFrame(ERR_CODE_ILLEGAL_ARG);
|
|
return;
|
|
}
|
|
int8_t power = (int8_t)cmd_frame[1];
|
|
if (power < -9 || power > MAX_LORA_TX_POWER) {
|
|
writeErrFrame(ERR_CODE_ILLEGAL_ARG);
|
|
} else {
|
|
const auto result = applyAndSaveTxPower(power);
|
|
if (result == RadioSettingResult::Saved) writeOKFrame();
|
|
else writeErrFrame(result == RadioSettingResult::RadioRejected
|
|
? ERR_CODE_BAD_STATE : ERR_CODE_FILE_IO_ERROR);
|
|
}
|
|
} else if (cmd_frame[0] == CMD_SET_TUNING_PARAMS) {
|
|
if (len < 9) {
|
|
writeErrFrame(ERR_CODE_ILLEGAL_ARG);
|
|
return;
|
|
}
|
|
int i = 1;
|
|
uint32_t rx, af;
|
|
memcpy(&rx, &cmd_frame[i], 4);
|
|
i += 4;
|
|
memcpy(&af, &cmd_frame[i], 4);
|
|
i += 4;
|
|
const float previous_rx = _prefs.rx_delay_base, previous_af = _prefs.airtime_factor;
|
|
_prefs.rx_delay_base = ((float)rx) / 1000.0f;
|
|
_prefs.airtime_factor = ((float)af) / 1000.0f;
|
|
if (savePrefs()) {
|
|
writeOKFrame();
|
|
} else {
|
|
_prefs.rx_delay_base = previous_rx;
|
|
_prefs.airtime_factor = previous_af;
|
|
writeErrFrame(ERR_CODE_FILE_IO_ERROR);
|
|
}
|
|
} else if (cmd_frame[0] == CMD_GET_TUNING_PARAMS) {
|
|
uint32_t rx = _prefs.rx_delay_base * 1000, af = _prefs.airtime_factor * 1000;
|
|
int i = 0;
|
|
out_frame[i++] = RESP_CODE_TUNING_PARAMS;
|
|
memcpy(&out_frame[i], &rx, 4); i += 4;
|
|
memcpy(&out_frame[i], &af, 4); i += 4;
|
|
_serial->writeFrame(out_frame, i);
|
|
} else if (cmd_frame[0] == CMD_SET_OTHER_PARAMS) {
|
|
if (len < 2) {
|
|
writeErrFrame(ERR_CODE_ILLEGAL_ARG);
|
|
return;
|
|
}
|
|
const uint8_t previous_manual = _prefs.manual_add_contacts;
|
|
const uint8_t previous_base = _prefs.telemetry_mode_base;
|
|
const uint8_t previous_loc = _prefs.telemetry_mode_loc;
|
|
const uint8_t previous_env = _prefs.telemetry_mode_env;
|
|
const uint8_t previous_policy = _prefs.advert_loc_policy;
|
|
const uint8_t previous_acks = _prefs.multi_acks;
|
|
_prefs.manual_add_contacts = cmd_frame[1];
|
|
if (len >= 3) {
|
|
_prefs.telemetry_mode_base = cmd_frame[2] & 0x03; // v5+
|
|
_prefs.telemetry_mode_loc = (cmd_frame[2] >> 2) & 0x03;
|
|
_prefs.telemetry_mode_env = (cmd_frame[2] >> 4) & 0x03;
|
|
|
|
if (len >= 4) {
|
|
_prefs.advert_loc_policy = cmd_frame[3];
|
|
if (len >= 5) {
|
|
_prefs.multi_acks = cmd_frame[4];
|
|
}
|
|
}
|
|
}
|
|
if (savePrefs()) {
|
|
updateGpsTelemetryPolicy();
|
|
writeOKFrame();
|
|
} else {
|
|
_prefs.manual_add_contacts = previous_manual;
|
|
_prefs.telemetry_mode_base = previous_base;
|
|
_prefs.telemetry_mode_loc = previous_loc;
|
|
_prefs.telemetry_mode_env = previous_env;
|
|
_prefs.advert_loc_policy = previous_policy;
|
|
_prefs.multi_acks = previous_acks;
|
|
writeErrFrame(ERR_CODE_FILE_IO_ERROR);
|
|
}
|
|
} else if (cmd_frame[0] == CMD_SET_PATH_HASH_MODE && len >= 3 && cmd_frame[1] == 0) {
|
|
if (cmd_frame[2] >= 3) {
|
|
writeErrFrame(ERR_CODE_ILLEGAL_ARG);
|
|
} else {
|
|
if (savePreference(_prefs.path_hash_mode, cmd_frame[2])) writeOKFrame();
|
|
else writeErrFrame(ERR_CODE_FILE_IO_ERROR);
|
|
}
|
|
} else if (cmd_frame[0] == CMD_REBOOT && len >= 7 && memcmp(&cmd_frame[1], "reboot", 6) == 0) {
|
|
if (!MyMesh::prepareForUiShutdown()) {
|
|
writeErrFrame(ERR_CODE_FILE_IO_ERROR);
|
|
return;
|
|
}
|
|
board.reboot();
|
|
} else if (cmd_frame[0] == CMD_GET_BATT_AND_STORAGE) {
|
|
uint8_t reply[11];
|
|
int i = 0;
|
|
reply[i++] = RESP_CODE_BATT_AND_STORAGE;
|
|
uint16_t battery_millivolts = board.getBattMilliVolts();
|
|
uint32_t used = _store->getStorageUsedKb();
|
|
uint32_t total = _store->getStorageTotalKb();
|
|
memcpy(&reply[i], &battery_millivolts, 2); i += 2;
|
|
memcpy(&reply[i], &used, 4); i += 4;
|
|
memcpy(&reply[i], &total, 4); i += 4;
|
|
_serial->writeFrame(reply, i);
|
|
} else if (cmd_frame[0] == CMD_EXPORT_PRIVATE_KEY) {
|
|
#if ENABLE_PRIVATE_KEY_EXPORT
|
|
uint8_t reply[65];
|
|
reply[0] = RESP_CODE_PRIVATE_KEY;
|
|
self_id.writeTo(&reply[1], 64);
|
|
_serial->writeFrame(reply, 65);
|
|
#else
|
|
writeDisabledFrame();
|
|
#endif
|
|
} else if (cmd_frame[0] == CMD_IMPORT_PRIVATE_KEY && len >= 65) {
|
|
#if ENABLE_PRIVATE_KEY_IMPORT
|
|
if (!mesh::LocalIdentity::validatePrivateKey(&cmd_frame[1])) {
|
|
writeErrFrame(ERR_CODE_ILLEGAL_ARG); // invalid key
|
|
#if MESH_ENABLE_MESHTEXT_CHAT
|
|
} else if (hasForeignRadioFrame()) {
|
|
// Finish queued/sending MT text before changing its sender identity.
|
|
writeErrFrame(ERR_CODE_BAD_STATE);
|
|
#endif
|
|
} else if (!flushContactsBeforeReboot()) {
|
|
// loadContacts() resets the dirty-page map, so never replace the
|
|
// identity and reload while newer contact state exists only in RAM.
|
|
writeErrFrame(ERR_CODE_FILE_IO_ERROR);
|
|
} else {
|
|
mesh::LocalIdentity identity;
|
|
identity.readFrom(&cmd_frame[1], 64);
|
|
if (_store->saveMainIdentity(identity)) {
|
|
self_id = identity;
|
|
#if MESH_ENABLE_MESHTEXT_CHAT
|
|
refreshMeshtextIdentity();
|
|
#endif
|
|
#if defined(ENABLE_OTA)
|
|
mesh::ota::ota_refresh_seeder_identity(self_id.pub_key);
|
|
#endif
|
|
writeOKFrame();
|
|
// re-load contacts, to invalidate ecdh shared_secrets
|
|
stopContactsIterator();
|
|
resetContacts();
|
|
MyMesh::onContactCacheFlushed();
|
|
_store->loadContacts(this);
|
|
_store->recoverContacts(this);
|
|
if (_store->hasPendingContactWrites()) {
|
|
mesh::scheduleLazyPersistenceMutation(
|
|
dirty_contacts_expiry, dirty_contacts_failures,
|
|
futureMillis(LAZY_CONTACTS_WRITE_DELAY));
|
|
}
|
|
updateGpsTelemetryPolicy();
|
|
} else {
|
|
writeErrFrame(ERR_CODE_FILE_IO_ERROR);
|
|
}
|
|
}
|
|
#else
|
|
writeDisabledFrame();
|
|
#endif
|
|
} else if (cmd_frame[0] == CMD_SEND_RAW_DATA && len >= 6) {
|
|
int i = 1;
|
|
uint8_t path_len = cmd_frame[i++];
|
|
if (mesh::Packet::isValidPathLen(path_len)) {
|
|
uint8_t path[MAX_PATH_SIZE];
|
|
const size_t path_bytes = static_cast<size_t>(path_len & 63) * ((path_len >> 6) + 1);
|
|
if (static_cast<size_t>(i) + path_bytes + 4 > len) { // min payload 4 bytes, before reading path
|
|
writeErrFrame(ERR_CODE_ILLEGAL_ARG);
|
|
} else {
|
|
i += mesh::Packet::writePath(path, &cmd_frame[i], path_len);
|
|
if (sendClientRawData(&cmd_frame[i], len - i, path, path_len)) {
|
|
writeOKFrame();
|
|
} else {
|
|
writeErrFrame(ERR_CODE_BAD_STATE);
|
|
}
|
|
}
|
|
} else {
|
|
writeErrFrame(ERR_CODE_UNSUPPORTED_CMD); // flood, not supported (yet)
|
|
}
|
|
} else if (cmd_frame[0] == CMD_SEND_LOGIN && len >= 1 + PUB_KEY_SIZE) {
|
|
uint8_t *pub_key = &cmd_frame[1];
|
|
ContactInfo *recipient = lookupContactByPubKey(pub_key, PUB_KEY_SIZE);
|
|
char *password = (char *)&cmd_frame[1 + PUB_KEY_SIZE];
|
|
cmd_frame[len] = 0; // ensure null terminator in password
|
|
if (recipient) {
|
|
if (!beginPendingRequest(mesh::CompanionDelayedReplies::Login, *recipient)) {
|
|
writeErrFrame(ERR_CODE_BAD_STATE);
|
|
return;
|
|
}
|
|
uint32_t tag, est_timeout = 0;
|
|
memcpy(&tag, recipient->id.pub_key, 4);
|
|
int result = sendLogin(*recipient, password, est_timeout);
|
|
finishPendingRequest(result, tag, est_timeout);
|
|
} else {
|
|
writeErrFrame(ERR_CODE_NOT_FOUND); // contact not found
|
|
}
|
|
} else if (cmd_frame[0] == CMD_SEND_ANON_REQ && len > 1 + PUB_KEY_SIZE) {
|
|
uint8_t *pub_key = &cmd_frame[1];
|
|
ContactInfo *recipient = lookupContactByPubKey(pub_key, PUB_KEY_SIZE);
|
|
ContactInfo anon;
|
|
if (recipient == NULL) { // FIRMWARE_VER_CODE 13+, allow non-contact requests
|
|
memset(&anon, 0, sizeof(anon));
|
|
#if defined(NRF52_PLATFORM)
|
|
anon.storage_slot = mesh::storage::CONTACT_SLOT_NONE;
|
|
#endif
|
|
memcpy(anon.id.pub_key, pub_key, PUB_KEY_SIZE);
|
|
anon.out_path_len = 0; // default to zero-hop direct
|
|
anon.type = ADV_TYPE_NONE; // unknown
|
|
anon.lastmod = getRTCClock()->getCurrentTime();
|
|
|
|
if (addContact(anon)) recipient = &anon;
|
|
}
|
|
uint8_t *data = &cmd_frame[1 + PUB_KEY_SIZE];
|
|
if (recipient) {
|
|
if (!beginPendingRequest(mesh::CompanionDelayedReplies::Binary, *recipient)) {
|
|
writeErrFrame(ERR_CODE_BAD_STATE);
|
|
return;
|
|
}
|
|
uint32_t tag = 0, est_timeout = 0;
|
|
int result = sendAnonReq(*recipient, data, len - (1 + PUB_KEY_SIZE), tag, est_timeout);
|
|
finishPendingRequest(result, tag, est_timeout);
|
|
} else {
|
|
writeErrFrame(ERR_CODE_TABLE_FULL); // contacts full
|
|
}
|
|
} else if (cmd_frame[0] == CMD_SEND_STATUS_REQ && len >= 1 + PUB_KEY_SIZE) {
|
|
uint8_t *pub_key = &cmd_frame[1];
|
|
ContactInfo *recipient = lookupContactByPubKey(pub_key, PUB_KEY_SIZE);
|
|
if (recipient) {
|
|
if (!beginPendingRequest(mesh::CompanionDelayedReplies::Status, *recipient)) {
|
|
writeErrFrame(ERR_CODE_BAD_STATE);
|
|
return;
|
|
}
|
|
uint32_t tag = 0, est_timeout = 0;
|
|
int result = sendRequest(*recipient, REQ_TYPE_GET_STATUS, tag, est_timeout);
|
|
finishPendingRequest(result, tag, est_timeout);
|
|
} else {
|
|
writeErrFrame(ERR_CODE_NOT_FOUND); // contact not found
|
|
}
|
|
} else if (cmd_frame[0] == CMD_SEND_PATH_DISCOVERY_REQ && len >= 2 + PUB_KEY_SIZE && cmd_frame[1] == 0) {
|
|
uint8_t *pub_key = &cmd_frame[2];
|
|
ContactInfo *recipient = lookupContactByPubKey(pub_key, PUB_KEY_SIZE);
|
|
if (recipient) {
|
|
if (!beginPendingRequest(mesh::CompanionDelayedReplies::Discovery, *recipient)) {
|
|
writeErrFrame(ERR_CODE_BAD_STATE);
|
|
return;
|
|
}
|
|
uint32_t tag = 0, est_timeout = 0;
|
|
// 'Path Discovery' is just a special case of flood + Telemetry req
|
|
uint8_t req_data[9];
|
|
req_data[0] = REQ_TYPE_GET_TELEMETRY_DATA;
|
|
req_data[1] = ~(TELEM_PERM_BASE); // NEW: inverse permissions mask (ie. we only want BASE telemetry)
|
|
memset(&req_data[2], 0, 3); // reserved
|
|
getRNG()->random(&req_data[5], 4); // random blob to help make packet-hash unique
|
|
auto save = recipient->out_path_len; // temporarily force sendRequest() to flood
|
|
recipient->out_path_len = OUT_PATH_UNKNOWN;
|
|
int result = sendRequest(*recipient, req_data, sizeof(req_data), tag, est_timeout);
|
|
recipient->out_path_len = save;
|
|
finishPendingRequest(result, tag, est_timeout);
|
|
} else {
|
|
writeErrFrame(ERR_CODE_NOT_FOUND); // contact not found
|
|
}
|
|
} else if (cmd_frame[0] == CMD_SEND_TELEMETRY_REQ && len >= 4 + PUB_KEY_SIZE) { // can deprecate, in favour of CMD_SEND_BINARY_REQ
|
|
uint8_t *pub_key = &cmd_frame[4];
|
|
ContactInfo *recipient = lookupContactByPubKey(pub_key, PUB_KEY_SIZE);
|
|
if (recipient) {
|
|
if (!beginPendingRequest(mesh::CompanionDelayedReplies::Telemetry, *recipient)) {
|
|
writeErrFrame(ERR_CODE_BAD_STATE);
|
|
return;
|
|
}
|
|
uint32_t tag = 0, est_timeout = 0;
|
|
int result = sendRequest(*recipient, REQ_TYPE_GET_TELEMETRY_DATA, tag, est_timeout);
|
|
finishPendingRequest(result, tag, est_timeout);
|
|
} else {
|
|
writeErrFrame(ERR_CODE_NOT_FOUND); // contact not found
|
|
}
|
|
} else if (cmd_frame[0] == CMD_SEND_TELEMETRY_REQ && len == 4) { // 'self' telemetry request
|
|
telemetry.reset();
|
|
telemetry.addVoltage(TELEM_CHANNEL_SELF, (float)board.getBattMilliVolts() / 1000.0f);
|
|
float temperature = board.getMCUTemperature();
|
|
if(!isnan(temperature)) { // Supported boards with built-in temperature sensor. ESP32-C3 may return NAN
|
|
telemetry.addTemperature(TELEM_CHANNEL_SELF, temperature); // Built-in MCU Temperature
|
|
}
|
|
|
|
// query other sensors -- target specific
|
|
sensors.querySensors(0xFF, telemetry);
|
|
|
|
int i = 0;
|
|
out_frame[i++] = PUSH_CODE_TELEMETRY_RESPONSE;
|
|
out_frame[i++] = 0; // reserved
|
|
memcpy(&out_frame[i], self_id.pub_key, 6);
|
|
i += 6; // pub_key_prefix
|
|
uint8_t tlen = telemetry.getSize();
|
|
memcpy(&out_frame[i], telemetry.getBuffer(), tlen);
|
|
i += tlen;
|
|
_serial->writeFrame(out_frame, i);
|
|
} else if (cmd_frame[0] == CMD_SEND_BINARY_REQ && len >= 2 + PUB_KEY_SIZE) {
|
|
uint8_t *pub_key = &cmd_frame[1];
|
|
ContactInfo *recipient = lookupContactByPubKey(pub_key, PUB_KEY_SIZE);
|
|
if (recipient) {
|
|
uint8_t *req_data = &cmd_frame[1 + PUB_KEY_SIZE];
|
|
if (!beginPendingRequest(mesh::CompanionDelayedReplies::Binary, *recipient)) {
|
|
writeErrFrame(ERR_CODE_BAD_STATE);
|
|
return;
|
|
}
|
|
uint32_t tag = 0, est_timeout = 0;
|
|
int result = sendRequest(*recipient, req_data, len - (1 + PUB_KEY_SIZE), tag, est_timeout);
|
|
finishPendingRequest(result, tag, est_timeout);
|
|
} else {
|
|
writeErrFrame(ERR_CODE_NOT_FOUND); // contact not found
|
|
}
|
|
} else if (cmd_frame[0] == CMD_HAS_CONNECTION && len >= 1 + PUB_KEY_SIZE) {
|
|
uint8_t *pub_key = &cmd_frame[1];
|
|
if (hasConnectionTo(pub_key)) {
|
|
writeOKFrame();
|
|
} else {
|
|
writeErrFrame(ERR_CODE_NOT_FOUND);
|
|
}
|
|
} else if (cmd_frame[0] == CMD_LOGOUT && len >= 1 + PUB_KEY_SIZE) {
|
|
uint8_t *pub_key = &cmd_frame[1];
|
|
stopConnection(pub_key);
|
|
writeOKFrame();
|
|
} else if (cmd_frame[0] == CMD_GET_CHANNEL && len >= 2) {
|
|
uint8_t channel_idx = cmd_frame[1];
|
|
ChannelDetails channel;
|
|
if (getChannelForSave(channel_idx, channel)) {
|
|
int i = 0;
|
|
out_frame[i++] = RESP_CODE_CHANNEL_INFO;
|
|
out_frame[i++] = channel_idx;
|
|
// This fixed-width field must not retain bytes from an earlier reply.
|
|
strncpy((char *)&out_frame[i], channel.name, 32);
|
|
out_frame[i + 31] = 0;
|
|
i += 32;
|
|
size_t key_size = 16; // Original app response remains byte-for-byte compatible.
|
|
#if MESH_ENABLE_MESHTEXT_CHAT
|
|
if (len >= 3 && cmd_frame[2] == 32 && isMeshtextChannel(channel)) key_size = 32;
|
|
#endif
|
|
memcpy(&out_frame[i], channel.channel.secret, key_size);
|
|
i += key_size;
|
|
_serial->writeFrame(out_frame, i);
|
|
} else {
|
|
writeErrFrame(ERR_CODE_NOT_FOUND);
|
|
}
|
|
} else if (cmd_frame[0] == CMD_SET_CHANNEL && len >= 2 + 32 + 32) {
|
|
#if MESH_ENABLE_MESHTEXT_CHAT
|
|
const uint8_t channel_idx = cmd_frame[1];
|
|
ChannelDetails previous{}, channel{};
|
|
StrHelper::strncpy(channel.name, (char *)&cmd_frame[2], 32);
|
|
memcpy(channel.channel.secret, &cmd_frame[2 + 32], sizeof(channel.channel.secret));
|
|
if (parseCompanionChannelAlias(channel.name).kind != CompanionChannelKind::Meshtext) {
|
|
writeErrFrame(ERR_CODE_UNSUPPORTED_CMD); // MeshCore wire protocol is still AES-128.
|
|
return;
|
|
}
|
|
if (!validateMeshtextChannelChange(channel_idx, channel)) {
|
|
writeErrFrame(ERR_CODE_BAD_STATE);
|
|
return;
|
|
}
|
|
if (!getChannelForSave(channel_idx, previous) || !setChannel(channel_idx, channel)) {
|
|
writeErrFrame(ERR_CODE_NOT_FOUND);
|
|
} else if (!saveChannels()) {
|
|
setChannel(channel_idx, previous);
|
|
writeErrFrame(ERR_CODE_FILE_IO_ERROR);
|
|
} else {
|
|
_mt_seen.clear();
|
|
_mt_profile_dirty = true;
|
|
refreshMeshtextProfile();
|
|
writeOKFrame();
|
|
}
|
|
#else
|
|
writeErrFrame(ERR_CODE_UNSUPPORTED_CMD);
|
|
#endif
|
|
} else if (cmd_frame[0] == CMD_SET_CHANNEL && len >= 2 + 32 + 16) {
|
|
uint8_t channel_idx = cmd_frame[1];
|
|
ChannelDetails previous{};
|
|
ChannelDetails channel;
|
|
StrHelper::strncpy(channel.name, (char *)&cmd_frame[2], 32);
|
|
memset(channel.channel.secret, 0, sizeof(channel.channel.secret));
|
|
memcpy(channel.channel.secret, &cmd_frame[2 + 32], 16); // NOTE: only 128-bit supported
|
|
const auto alias = parseCompanionChannelAlias(channel.name);
|
|
if (alias.kind == CompanionChannelKind::Invalid) {
|
|
writeErrFrame(ERR_CODE_ILLEGAL_ARG);
|
|
return;
|
|
}
|
|
#if MESH_ENABLE_MESHTEXT_CHAT
|
|
// Legacy apps fetch only 16 bytes. A label-only edit must not silently
|
|
// truncate a provisioned MT AES-256 key to AES-128. An explicit 32-byte SET
|
|
// (zero upper half for AES-128) is the unambiguous way to replace that key.
|
|
if (alias.kind == CompanionChannelKind::Meshtext
|
|
&& getChannelForSave(channel_idx, previous) && isMeshtextChannel(previous)
|
|
&& !memcmp(channel.channel.secret, previous.channel.secret, 16)) {
|
|
memcpy(channel.channel.secret + 16, previous.channel.secret + 16, 16);
|
|
}
|
|
if (!validateMeshtextChannelChange(channel_idx, channel)) {
|
|
writeErrFrame(ERR_CODE_BAD_STATE); // Conflicting preset, manual RF, region or busy owner.
|
|
return;
|
|
}
|
|
#else
|
|
if (alias.kind == CompanionChannelKind::Meshtext && isAliasRadio2Defined()) {
|
|
writeErrFrame(ERR_CODE_UNSUPPORTED_CMD); // Meshtext transport not implemented yet.
|
|
return;
|
|
}
|
|
#endif
|
|
const bool have_previous = getChannelForSave(channel_idx, previous);
|
|
ChannelDetails wire_channel = channel;
|
|
ChannelDetails previous_wire_channel = previous;
|
|
resolveChannelAlias(wire_channel);
|
|
if (have_previous) resolveChannelAlias(previous_wire_channel);
|
|
if (have_previous && channel.name[0] != 0 && previous.name[0] != 0
|
|
&& !memcmp(wire_channel.channel.secret, previous_wire_channel.channel.secret, sizeof(channel.channel.secret))) {
|
|
channel.channel.tx_radio = previous.channel.tx_radio;
|
|
}
|
|
if (!have_previous
|
|
|| !setChannel(channel_idx, channel)) {
|
|
writeErrFrame(ERR_CODE_NOT_FOUND); // bad channel_idx
|
|
} else if (saveChannels()) {
|
|
#if MESH_ENABLE_MESHTEXT_CHAT
|
|
_mt_seen.clear();
|
|
_mt_profile_dirty = true;
|
|
refreshMeshtextProfile();
|
|
#endif
|
|
writeOKFrame();
|
|
// Local history seed only after the durable save and its acknowledgement.
|
|
seedEmergencyChannelNotice(channel_idx, previous);
|
|
} else {
|
|
// AtomicFileWriter preserves the last complete channels file. Keep the
|
|
// live table consistent with it when a full or failing filesystem cannot
|
|
// commit this update, and never acknowledge a change that will disappear
|
|
// after reboot.
|
|
setChannel(channel_idx, previous);
|
|
writeErrFrame(ERR_CODE_FILE_IO_ERROR);
|
|
}
|
|
} else if (cmd_frame[0] == CMD_SIGN_START) {
|
|
BaseSerialInterface* signing_route = _serial->captureReplyRoute();
|
|
if (signing_route == NULL) {
|
|
writeErrFrame(ERR_CODE_BAD_STATE);
|
|
return;
|
|
}
|
|
if (sign_data != NULL && sign_data_reply_route != signing_route) {
|
|
writeErrFrame(ERR_CODE_BAD_STATE);
|
|
return;
|
|
}
|
|
cancelSigningSession();
|
|
sign_data = (uint8_t *)malloc(MAX_SIGN_DATA_LEN);
|
|
if (sign_data == NULL) {
|
|
writeErrFrame(ERR_CODE_TABLE_FULL);
|
|
return;
|
|
}
|
|
sign_data_len = 0;
|
|
sign_data_reply_route = signing_route;
|
|
sign_data_deadline = futureMillis(SIGN_SESSION_TIMEOUT_MILLIS);
|
|
|
|
out_frame[0] = RESP_CODE_SIGN_START;
|
|
out_frame[1] = 0; // reserved
|
|
uint32_t len = MAX_SIGN_DATA_LEN;
|
|
memcpy(&out_frame[2], &len, 4);
|
|
_serial->writeFrame(out_frame, 6);
|
|
} else if (cmd_frame[0] == CMD_SIGN_DATA && len > 1) {
|
|
BaseSerialInterface* signing_route = _serial->captureReplyRoute();
|
|
if (sign_data == NULL || sign_data_reply_route != signing_route) {
|
|
writeErrFrame(ERR_CODE_BAD_STATE);
|
|
} else if (sign_data_len + (len - 1) > MAX_SIGN_DATA_LEN) {
|
|
writeErrFrame(ERR_CODE_TABLE_FULL); // error: too long
|
|
} else {
|
|
memcpy(&sign_data[sign_data_len], &cmd_frame[1], len - 1);
|
|
sign_data_len += (len - 1);
|
|
sign_data_deadline = futureMillis(SIGN_SESSION_TIMEOUT_MILLIS);
|
|
writeOKFrame();
|
|
}
|
|
} else if (cmd_frame[0] == CMD_SIGN_FINISH) {
|
|
if (sign_data != NULL
|
|
&& sign_data_reply_route == _serial->captureReplyRoute()) {
|
|
self_id.sign(&out_frame[1], sign_data, sign_data_len);
|
|
|
|
cancelSigningSession();
|
|
|
|
out_frame[0] = RESP_CODE_SIGNATURE;
|
|
_serial->writeFrame(out_frame, 1 + SIGNATURE_SIZE);
|
|
} else {
|
|
writeErrFrame(ERR_CODE_BAD_STATE);
|
|
}
|
|
} else if (cmd_frame[0] == CMD_SEND_TRACE_PATH && len > 10 && len - 10 <= MAX_PACKET_PAYLOAD - 9) {
|
|
serviceBinaryTraceReply();
|
|
if (_delayed_replies.hasBinaryTrace()) {
|
|
writeErrFrame(ERR_CODE_BAD_STATE);
|
|
return;
|
|
}
|
|
uint8_t path_len = len - 10;
|
|
uint8_t flags = cmd_frame[9];
|
|
uint8_t path_sz = flags & 0x03; // NEW v1.11+
|
|
if ((path_len >> path_sz) >= MAX_PATH_SIZE || (path_len % (1 << path_sz)) != 0) { // make sure is multiple of path_sz
|
|
writeErrFrame(ERR_CODE_ILLEGAL_ARG);
|
|
} else {
|
|
uint32_t tag, auth;
|
|
memcpy(&tag, &cmd_frame[1], 4);
|
|
memcpy(&auth, &cmd_frame[5], 4);
|
|
if (!_delayed_replies.reserveBinaryTrace(tag, auth,
|
|
_serial->captureReplyRoute(), _ms->getMillis())) {
|
|
writeErrFrame(ERR_CODE_BAD_STATE);
|
|
return;
|
|
}
|
|
auto pkt = createTrace(tag, auth, flags);
|
|
if (pkt) {
|
|
// Compute before handing ownership to sendDirect(), which releases the
|
|
// packet itself if validation or queueing fails.
|
|
uint32_t t = getTransmitAirtime(pkt, 2 + pkt->payload_len + path_len);
|
|
uint32_t est_timeout = calcDirectTimeoutMillisFor(t, path_len >> path_sz);
|
|
if (sendDirect(pkt, &cmd_frame[10], path_len)) {
|
|
_delayed_replies.armBinaryTrace(est_timeout, _ms->getMillis());
|
|
serviceBinaryTraceReply();
|
|
} else {
|
|
_delayed_replies.abandonBinaryTrace();
|
|
writeErrFrame(ERR_CODE_TABLE_FULL);
|
|
}
|
|
} else {
|
|
_delayed_replies.abandonBinaryTrace();
|
|
writeErrFrame(ERR_CODE_TABLE_FULL);
|
|
}
|
|
}
|
|
} else if (cmd_frame[0] == CMD_SET_DEVICE_PIN && len >= 5) {
|
|
|
|
// get pin from command frame
|
|
uint32_t pin;
|
|
memcpy(&pin, &cmd_frame[1], 4);
|
|
|
|
// ensure pin is zero, or a valid 6 digit pin
|
|
if (pin == 0 || (pin >= 100000 && pin <= 999999)) {
|
|
if (savePreference(_prefs.ble_pin, pin)) writeOKFrame();
|
|
else writeErrFrame(ERR_CODE_FILE_IO_ERROR);
|
|
} else {
|
|
writeErrFrame(ERR_CODE_ILLEGAL_ARG);
|
|
}
|
|
} else if (cmd_frame[0] == CMD_GET_CUSTOM_VARS) {
|
|
out_frame[0] = RESP_CODE_CUSTOM_VARS;
|
|
char *dp = (char *)&out_frame[1];
|
|
for (int i = 0; i < sensors.getNumSettings() && dp - (char *)&out_frame[1] < 140; i++) {
|
|
if (i > 0) {
|
|
*dp++ = ',';
|
|
}
|
|
strcpy(dp, sensors.getSettingName(i));
|
|
dp = strchr(dp, 0);
|
|
*dp++ = ':';
|
|
strcpy(dp, sensors.getSettingValue(i));
|
|
dp = strchr(dp, 0);
|
|
}
|
|
_serial->writeFrame(out_frame, dp - (char *)out_frame);
|
|
} else if (cmd_frame[0] == CMD_SET_CUSTOM_VAR && len >= 4) {
|
|
cmd_frame[len] = 0;
|
|
char *sp = (char *)&cmd_frame[1];
|
|
char *np = strchr(sp, ':'); // look for separator char
|
|
if (np) {
|
|
*np++ = 0; // modify 'cmd_frame', replace ':' with null
|
|
bool success = sensors.setSettingValue(sp, np);
|
|
if (success) {
|
|
#if ENV_INCLUDE_GPS == 1
|
|
// Update node preferences for GPS settings
|
|
if (strcmp(sp, "gps") == 0) {
|
|
if (!savePreference(_prefs.gps_enabled, static_cast<uint8_t>(np[0] == '1'))) {
|
|
applyGpsPrefs();
|
|
writeErrFrame(ERR_CODE_FILE_IO_ERROR);
|
|
return;
|
|
}
|
|
} else if (strcmp(sp, "gps_interval") == 0) {
|
|
uint32_t interval_seconds = atoi(np);
|
|
if (!savePreference(_prefs.gps_interval,
|
|
static_cast<uint32_t>(constrain(interval_seconds, 0, 86400)))) {
|
|
applyGpsPrefs();
|
|
writeErrFrame(ERR_CODE_FILE_IO_ERROR);
|
|
return;
|
|
}
|
|
}
|
|
#endif
|
|
writeOKFrame();
|
|
} else {
|
|
writeErrFrame(ERR_CODE_ILLEGAL_ARG);
|
|
}
|
|
} else {
|
|
writeErrFrame(ERR_CODE_ILLEGAL_ARG);
|
|
}
|
|
} else if (mesh::companion::isFemRxGainGet(cmd_frame[0])) {
|
|
if (len != 1) {
|
|
writeErrFrame(ERR_CODE_ILLEGAL_ARG);
|
|
} else if (!board.canControlLoRaFemLna()) {
|
|
writeErrFrame(ERR_CODE_UNSUPPORTED_CMD);
|
|
} else {
|
|
out_frame[0] = RESP_CODE_OK;
|
|
uint8_t value = board.isLoRaFemLnaEnabled() ? 1 : 0;
|
|
memcpy(&out_frame[1], &value, 1);
|
|
_serial->writeFrame(out_frame, 2);
|
|
}
|
|
} else if (mesh::companion::isFemRxGainSet(cmd_frame[0])) {
|
|
uint8_t value = len >= 2 ? cmd_frame[1] : 0;
|
|
if (len != 2) {
|
|
writeErrFrame(ERR_CODE_ILLEGAL_ARG);
|
|
} else if (!board.canControlLoRaFemLna()) {
|
|
writeErrFrame(ERR_CODE_UNSUPPORTED_CMD);
|
|
} else if (value <= 1) {
|
|
if (applyAndSaveFemRxGain(value != 0)) {
|
|
writeOKFrame();
|
|
} else {
|
|
writeErrFrame(ERR_CODE_BAD_STATE);
|
|
}
|
|
} else {
|
|
writeErrFrame(ERR_CODE_ILLEGAL_ARG);
|
|
}
|
|
} else if (mesh::companion::isRadioRxGainGet(cmd_frame[0])) {
|
|
if (len != 1) {
|
|
writeErrFrame(ERR_CODE_ILLEGAL_ARG);
|
|
} else if (!radio_driver.supportsRxBoostedGainMode()) {
|
|
writeErrFrame(ERR_CODE_UNSUPPORTED_CMD);
|
|
} else {
|
|
out_frame[0] = RESP_CODE_OK;
|
|
out_frame[1] = _prefs.rx_boosted_gain ? 1 : 0;
|
|
_serial->writeFrame(out_frame, 2);
|
|
}
|
|
} else if (mesh::companion::isRadioRxGainSet(cmd_frame[0])) {
|
|
uint8_t value = len >= 2 ? cmd_frame[1] : 0;
|
|
if (len != 2 || value > 1) {
|
|
writeErrFrame(ERR_CODE_ILLEGAL_ARG);
|
|
} else if (!radio_driver.supportsRxBoostedGainMode()) {
|
|
writeErrFrame(ERR_CODE_UNSUPPORTED_CMD);
|
|
} else if (!applyAndSaveRxBoostedGain(value != 0)) {
|
|
writeErrFrame(ERR_CODE_BAD_STATE);
|
|
} else {
|
|
writeOKFrame();
|
|
}
|
|
} else if (mesh::companion::isWiFiPowerSaveGet(cmd_frame[0])) {
|
|
if (len != 1) {
|
|
writeErrFrame(ERR_CODE_ILLEGAL_ARG);
|
|
} else {
|
|
#if defined(ESP32) && defined(WIFI_SSID)
|
|
out_frame[0] = RESP_CODE_OK;
|
|
out_frame[1] = getCompanionWiFiPowerSave();
|
|
_serial->writeFrame(out_frame, 2);
|
|
#else
|
|
writeErrFrame(ERR_CODE_UNSUPPORTED_CMD);
|
|
#endif
|
|
}
|
|
} else if (mesh::companion::isWiFiPowerSaveSet(cmd_frame[0])) {
|
|
if (len != 2) {
|
|
writeErrFrame(ERR_CODE_ILLEGAL_ARG);
|
|
} else {
|
|
#if defined(ESP32) && defined(WIFI_SSID)
|
|
const uint8_t value = cmd_frame[1];
|
|
if (value > mesh::wifi::kPowerSaveMax) {
|
|
writeErrFrame(ERR_CODE_ILLEGAL_ARG);
|
|
} else {
|
|
char reply[160];
|
|
if (applyAndSaveWiFiPowerSaving(
|
|
companionWiFiPowerSaveName(value), reply, sizeof(reply))) {
|
|
writeOKFrame();
|
|
} else {
|
|
writeErrFrame(ERR_CODE_BAD_STATE);
|
|
}
|
|
}
|
|
#else
|
|
writeErrFrame(ERR_CODE_UNSUPPORTED_CMD);
|
|
#endif
|
|
}
|
|
} else if (mesh::companion::isBluetoothNameGet(cmd_frame[0])) {
|
|
if (len != 1) {
|
|
writeErrFrame(ERR_CODE_ILLEGAL_ARG);
|
|
} else {
|
|
out_frame[0] = RESP_CODE_OK;
|
|
out_frame[1] = mesh::companion::hasCustomBluetoothName(
|
|
_prefs.bluetooth_name) ? 1 : 0;
|
|
char* effective_name = reinterpret_cast<char*>(&out_frame[2]);
|
|
mesh::companion::formatBluetoothName(
|
|
effective_name, MAX_FRAME_SIZE - 1, _prefs.bluetooth_name,
|
|
BLE_NAME_PREFIX, _prefs.node_name);
|
|
_serial->writeFrame(out_frame, 2 + strlen(effective_name));
|
|
}
|
|
} else if (mesh::companion::isBluetoothNameSet(cmd_frame[0])) {
|
|
const size_t name_len = len - 1;
|
|
if (name_len > mesh::companion::BLUETOOTH_NAME_MAX_BYTES
|
|
|| memchr(&cmd_frame[1], 0, name_len) != NULL) {
|
|
writeErrFrame(ERR_CODE_ILLEGAL_ARG);
|
|
} else {
|
|
char name[mesh::companion::BLUETOOTH_NAME_SIZE] = {0};
|
|
if (name_len != 0) memcpy(name, &cmd_frame[1], name_len);
|
|
if (name_len != 0 && !mesh::companion::isValidBluetoothName(name)) {
|
|
writeErrFrame(ERR_CODE_ILLEGAL_ARG);
|
|
} else if (!saveBluetoothNameOverride(name)) {
|
|
writeErrFrame(ERR_CODE_BAD_STATE);
|
|
} else {
|
|
writeOKFrame();
|
|
}
|
|
}
|
|
} else if (cmd_frame[0]
|
|
== mesh::companion::CMD_EXEC_LOCAL_OTA_CONTROL) {
|
|
#if COMPANION_FEATURE_OTA_CLI
|
|
const size_t command_len = len - 1;
|
|
if (!mesh::companion::isBleOtaControlCommandAllowed(
|
|
&cmd_frame[1], command_len)) {
|
|
writeErrFrame(ERR_CODE_ILLEGAL_ARG);
|
|
} else {
|
|
char command[MAX_FRAME_SIZE] = {0};
|
|
memcpy(command, &cmd_frame[1], command_len);
|
|
char reply[MAX_FRAME_SIZE] = {0};
|
|
if (!handleCompanionWirelessCommand(command, reply, sizeof(reply), CompanionWirelessSource::Framed)
|
|
&& !handleCompanionBluetoothCommand(command, reply, sizeof(reply),
|
|
CompanionBluetoothCommandSource::Framed)
|
|
&& !handleLocalControlCommand(command, reply, sizeof(reply))) {
|
|
writeErrFrame(ERR_CODE_UNSUPPORTED_CMD);
|
|
} else {
|
|
out_frame[0] = RESP_CODE_OK;
|
|
const size_t reply_len = strnlen(reply, MAX_FRAME_SIZE - 2);
|
|
out_frame[1] = static_cast<uint8_t>(reply_len);
|
|
memcpy(&out_frame[2], reply, reply_len);
|
|
_serial->writeFrame(out_frame, 2 + reply_len);
|
|
}
|
|
}
|
|
#else
|
|
writeErrFrame(ERR_CODE_UNSUPPORTED_CMD);
|
|
#endif
|
|
} else if (cmd_frame[0] == mesh::companion::CMD_BLE_MOTA_SOURCE) {
|
|
#if defined(COMPANION_RADIO_FULL)
|
|
if (len != 2) {
|
|
writeErrFrame(ERR_CODE_ILLEGAL_ARG);
|
|
} else if (_mota_source_control == NULL) {
|
|
writeErrFrame(ERR_CODE_UNSUPPORTED_CMD);
|
|
} else {
|
|
const mesh::companion::MotaSourceAction action =
|
|
static_cast<mesh::companion::MotaSourceAction>(cmd_frame[1]);
|
|
char control_reply[96] = {0};
|
|
bool action_ok = true;
|
|
if (action == mesh::companion::MotaSourceAction::Start) {
|
|
action_ok = _mota_source_control->start(control_reply,
|
|
sizeof(control_reply));
|
|
} else if (action == mesh::companion::MotaSourceAction::Stop) {
|
|
action_ok = _mota_source_control->stop(control_reply,
|
|
sizeof(control_reply));
|
|
} else if (action != mesh::companion::MotaSourceAction::Status) {
|
|
writeErrFrame(ERR_CODE_ILLEGAL_ARG);
|
|
return;
|
|
}
|
|
|
|
if (!action_ok) {
|
|
writeErrFrame(ERR_CODE_BAD_STATE);
|
|
} else {
|
|
const mesh::companion::MotaSourceStatus status =
|
|
_mota_source_control->status();
|
|
uint8_t flags = 0;
|
|
if (status.channel_ready) {
|
|
flags |= mesh::companion::MOTA_SOURCE_FLAG_CHANNEL_READY;
|
|
}
|
|
if (status.attached) {
|
|
flags |= mesh::companion::MOTA_SOURCE_FLAG_ATTACHED;
|
|
}
|
|
if (status.another_link_active) {
|
|
flags |=
|
|
mesh::companion::MOTA_SOURCE_FLAG_ANOTHER_LINK_ACTIVE;
|
|
}
|
|
out_frame[0] = RESP_CODE_OK;
|
|
out_frame[1] = cmd_frame[1];
|
|
out_frame[2] = flags;
|
|
out_frame[3] = static_cast<uint8_t>(status.offered & 0xFF);
|
|
out_frame[4] = static_cast<uint8_t>(status.offered >> 8);
|
|
out_frame[5] = static_cast<uint8_t>(status.advertised & 0xFF);
|
|
out_frame[6] = static_cast<uint8_t>(status.advertised >> 8);
|
|
out_frame[7] = static_cast<uint8_t>(status.packets_sent & 0xFF);
|
|
out_frame[8] = static_cast<uint8_t>((status.packets_sent >> 8) & 0xFF);
|
|
out_frame[9] = static_cast<uint8_t>((status.packets_sent >> 16) & 0xFF);
|
|
out_frame[10] = static_cast<uint8_t>((status.packets_sent >> 24) & 0xFF);
|
|
_serial->writeFrame(out_frame, 11);
|
|
}
|
|
}
|
|
#else
|
|
writeErrFrame(ERR_CODE_UNSUPPORTED_CMD);
|
|
#endif
|
|
} else if (cmd_frame[0] == CMD_GET_ADVERT_PATH && len >= PUB_KEY_SIZE+2) {
|
|
// FUTURE use: uint8_t reserved = cmd_frame[1];
|
|
uint8_t *pub_key = &cmd_frame[2];
|
|
AdvertPath* found = NULL;
|
|
for (int i = 0; i < ADVERT_PATH_TABLE_SIZE; i++) {
|
|
auto p = &advert_paths[i];
|
|
if (memcmp(p->pubkey_prefix, pub_key, sizeof(p->pubkey_prefix)) == 0) {
|
|
found = p;
|
|
break;
|
|
}
|
|
}
|
|
if (found) {
|
|
int i = 0;
|
|
out_frame[i++] = RESP_CODE_ADVERT_PATH;
|
|
memcpy(&out_frame[i], &found->recv_timestamp, 4); i += 4;
|
|
out_frame[i++] = found->path_len;
|
|
i += mesh::Packet::writePath(&out_frame[i], found->path, found->path_len);
|
|
_serial->writeFrame(out_frame, i);
|
|
} else {
|
|
writeErrFrame(ERR_CODE_NOT_FOUND);
|
|
}
|
|
} else if (cmd_frame[0] == CMD_GET_STATS && len >= 2) {
|
|
uint8_t stats_type = cmd_frame[1];
|
|
if (stats_type == STATS_TYPE_CORE) {
|
|
int i = 0;
|
|
out_frame[i++] = RESP_CODE_STATS;
|
|
out_frame[i++] = STATS_TYPE_CORE;
|
|
uint16_t battery_mv = board.getBattMilliVolts();
|
|
uint32_t uptime_secs = _ms->getMillis() / 1000;
|
|
uint8_t queue_len = (uint8_t)_mgr->getOutboundCount(0xFFFFFFFF);
|
|
memcpy(&out_frame[i], &battery_mv, 2); i += 2;
|
|
memcpy(&out_frame[i], &uptime_secs, 4); i += 4;
|
|
memcpy(&out_frame[i], &_err_flags, 2); i += 2;
|
|
out_frame[i++] = queue_len;
|
|
_serial->writeFrame(out_frame, i);
|
|
} else if (stats_type == STATS_TYPE_RADIO) {
|
|
int i = 0;
|
|
out_frame[i++] = RESP_CODE_STATS;
|
|
out_frame[i++] = STATS_TYPE_RADIO;
|
|
int16_t noise_floor = (int16_t)_radio->getNoiseFloor();
|
|
int8_t last_rssi = (int8_t)radio_driver.getLastRSSI();
|
|
int8_t last_snr = (int8_t)(radio_driver.getLastSNR() * 4); // scaled by 4 for 0.25 dB precision
|
|
uint32_t tx_air_secs = getTotalAirTime() / 1000;
|
|
uint32_t rx_air_secs = getReceiveAirTime() / 1000;
|
|
memcpy(&out_frame[i], &noise_floor, 2); i += 2;
|
|
out_frame[i++] = last_rssi;
|
|
out_frame[i++] = last_snr;
|
|
memcpy(&out_frame[i], &tx_air_secs, 4); i += 4;
|
|
memcpy(&out_frame[i], &rx_air_secs, 4); i += 4;
|
|
_serial->writeFrame(out_frame, i);
|
|
} else if (stats_type == STATS_TYPE_PACKETS) {
|
|
int i = 0;
|
|
out_frame[i++] = RESP_CODE_STATS;
|
|
out_frame[i++] = STATS_TYPE_PACKETS;
|
|
uint32_t recv = radio_driver.getPacketsRecv();
|
|
uint32_t sent = radio_driver.getPacketsSent();
|
|
uint32_t n_sent_flood = getNumSentFlood();
|
|
uint32_t n_sent_direct = getNumSentDirect();
|
|
uint32_t n_recv_flood = getNumRecvFlood();
|
|
uint32_t n_recv_direct = getNumRecvDirect();
|
|
uint32_t n_recv_errors = radio_driver.getPacketsRecvErrors();
|
|
memcpy(&out_frame[i], &recv, 4); i += 4;
|
|
memcpy(&out_frame[i], &sent, 4); i += 4;
|
|
memcpy(&out_frame[i], &n_sent_flood, 4); i += 4;
|
|
memcpy(&out_frame[i], &n_sent_direct, 4); i += 4;
|
|
memcpy(&out_frame[i], &n_recv_flood, 4); i += 4;
|
|
memcpy(&out_frame[i], &n_recv_direct, 4); i += 4;
|
|
memcpy(&out_frame[i], &n_recv_errors, 4); i += 4;
|
|
_serial->writeFrame(out_frame, i);
|
|
} else {
|
|
writeErrFrame(ERR_CODE_ILLEGAL_ARG); // invalid stats sub-type
|
|
}
|
|
} else if (cmd_frame[0] == CMD_FACTORY_RESET && len >= 6 && memcmp(&cmd_frame[1], "reset", 5) == 0) {
|
|
BaseSerialInterface* const reply_route = _serial->captureReplyRoute();
|
|
// Formatting is synchronous in the command-dispatch loop. Keep the
|
|
// requesting transport available if storage rejects/fails the erase.
|
|
bool success = _store->formatFileSystem();
|
|
if (success) {
|
|
#if MESH_ENABLE_TX_BEACONS
|
|
suspendBeacons();
|
|
#endif
|
|
MESH_DEBUG_PRINTLN("Factory reset: disabling interfaces before reboot");
|
|
_serial->disable();
|
|
writeOKFrame();
|
|
delay(1000);
|
|
board.reboot(); // doesn't return
|
|
} else {
|
|
writeErrFrame(ERR_CODE_FILE_IO_ERROR, reply_route);
|
|
}
|
|
} else if (cmd_frame[0] == CMD_SET_FLOOD_SCOPE_KEY && len >= 2 && cmd_frame[1] == 0) {
|
|
if (len >= 2 + 16) {
|
|
memcpy(send_scope.key, &cmd_frame[2], sizeof(send_scope.key)); // set scope override TransportKey
|
|
} else {
|
|
memset(send_scope.key, 0, sizeof(send_scope.key)); // reset scope override
|
|
}
|
|
send_unscoped = false;
|
|
writeOKFrame();
|
|
} else if (cmd_frame[0] == CMD_SET_FLOOD_SCOPE_KEY && len >= 2 && cmd_frame[1] == 1) { // ver 12+
|
|
send_unscoped = true;
|
|
writeOKFrame();
|
|
} else if (cmd_frame[0] == CMD_SET_DEFAULT_FLOOD_SCOPE && len >= 1) {
|
|
char previous_name[sizeof(_prefs.default_scope_name)];
|
|
uint8_t previous_key[sizeof(_prefs.default_scope_key)];
|
|
memcpy(previous_name, _prefs.default_scope_name, sizeof(previous_name));
|
|
memcpy(previous_key, _prefs.default_scope_key, sizeof(previous_key));
|
|
if (len >= 1+31+16) {
|
|
const void* terminator = memchr(&cmd_frame[1], 0, 31);
|
|
size_t n = terminator == NULL ? 31 : (const uint8_t*)terminator - &cmd_frame[1];
|
|
if (n > 0 && n < 31) {
|
|
memcpy(_prefs.default_scope_name, &cmd_frame[1], n);
|
|
_prefs.default_scope_name[n] = 0;
|
|
memcpy(_prefs.default_scope_key, &cmd_frame[1+31], 16);
|
|
} else {
|
|
writeErrFrame(ERR_CODE_ILLEGAL_ARG);
|
|
return;
|
|
}
|
|
} else {
|
|
memset(_prefs.default_scope_name, 0, sizeof(_prefs.default_scope_name)); // set default scope to null
|
|
memset(_prefs.default_scope_key, 0, sizeof(_prefs.default_scope_key));
|
|
}
|
|
if (savePrefs()) {
|
|
writeOKFrame();
|
|
} else {
|
|
memcpy(_prefs.default_scope_name, previous_name, sizeof(previous_name));
|
|
memcpy(_prefs.default_scope_key, previous_key, sizeof(previous_key));
|
|
writeErrFrame(ERR_CODE_FILE_IO_ERROR);
|
|
}
|
|
} else if (cmd_frame[0] == CMD_GET_DEFAULT_FLOOD_SCOPE) {
|
|
out_frame[0] = RESP_CODE_DEFAULT_FLOOD_SCOPE;
|
|
if (strlen(_prefs.default_scope_name) > 0) {
|
|
memcpy(&out_frame[1], _prefs.default_scope_name, 31);
|
|
memcpy(&out_frame[1+31], _prefs.default_scope_key, 16);
|
|
_serial->writeFrame(out_frame, 1+31+16);
|
|
} else {
|
|
_serial->writeFrame(out_frame, 1); // no name or key means null
|
|
}
|
|
} else if (cmd_frame[0] == CMD_SEND_CONTROL_DATA && len >= 2 && (cmd_frame[1] & 0x80) != 0) {
|
|
auto resp = createControlData(&cmd_frame[1], len - 1);
|
|
if (resp) {
|
|
sendZeroHop(resp);
|
|
writeOKFrame();
|
|
} else {
|
|
writeErrFrame(ERR_CODE_TABLE_FULL);
|
|
}
|
|
} else if (cmd_frame[0] == CMD_SET_AUTOADD_CONFIG) {
|
|
if (len < 2) {
|
|
writeErrFrame(ERR_CODE_ILLEGAL_ARG);
|
|
return;
|
|
}
|
|
const uint8_t previous_config = _prefs.autoadd_config;
|
|
const uint8_t previous_max_hops = _prefs.autoadd_max_hops;
|
|
_prefs.autoadd_config = cmd_frame[1];
|
|
if (len >= 3) {
|
|
_prefs.autoadd_max_hops = min(cmd_frame[2], (uint8_t)64);
|
|
}
|
|
if (savePrefs()) {
|
|
writeOKFrame();
|
|
} else {
|
|
_prefs.autoadd_config = previous_config;
|
|
_prefs.autoadd_max_hops = previous_max_hops;
|
|
writeErrFrame(ERR_CODE_FILE_IO_ERROR);
|
|
}
|
|
} else if (cmd_frame[0] == CMD_GET_AUTOADD_CONFIG) {
|
|
int i = 0;
|
|
out_frame[i++] = RESP_CODE_AUTOADD_CONFIG;
|
|
out_frame[i++] = _prefs.autoadd_config;
|
|
out_frame[i++] = _prefs.autoadd_max_hops;
|
|
_serial->writeFrame(out_frame, i);
|
|
} else if (cmd_frame[0] == CMD_GET_ALLOWED_REPEAT_FREQ) {
|
|
int i = 0;
|
|
out_frame[i++] = RESP_ALLOWED_REPEAT_FREQ;
|
|
for (int k = 0; k < sizeof(repeat_freq_ranges)/sizeof(repeat_freq_ranges[0]) && i + 8 < sizeof(out_frame); k++) {
|
|
auto r = &repeat_freq_ranges[k];
|
|
memcpy(&out_frame[i], &r->lower_freq, 4); i += 4;
|
|
memcpy(&out_frame[i], &r->upper_freq, 4); i += 4;
|
|
}
|
|
_serial->writeFrame(out_frame, i);
|
|
} else if (cmd_frame[0] == CMD_SEND_RAW_PACKET && len >= 4) {
|
|
auto pkt = obtainNewPacket();
|
|
if (pkt) {
|
|
uint8_t priority = cmd_frame[1];
|
|
if (tryParsePacket(pkt, &cmd_frame[2], len - 2)) {
|
|
if (pkt->getPayloadType() == PAYLOAD_TYPE_RAW_CUSTOM) {
|
|
const uint8_t policy = mesh::companion::trafficPolicy(
|
|
mesh::companion::rawTrafficKind(pkt->payload, pkt->payload_len),
|
|
_prefs.tx_media, _prefs.tx_ssh, _radio->profiles());
|
|
if (policy != mesh::RADIO_TX_AUTO) {
|
|
if (!mesh::companion::trafficTransmitMask(policy, _radio->profiles())) {
|
|
releasePacket(pkt);
|
|
writeErrFrame(ERR_CODE_BAD_STATE);
|
|
return;
|
|
}
|
|
pkt->tx_radio = policy;
|
|
}
|
|
}
|
|
sendPacket(pkt, priority, 0);
|
|
writeOKFrame();
|
|
} else {
|
|
releasePacket(pkt);
|
|
writeErrFrame(ERR_CODE_ILLEGAL_ARG);
|
|
}
|
|
} else {
|
|
writeErrFrame(ERR_CODE_TABLE_FULL);
|
|
}
|
|
} else {
|
|
writeErrFrame(ERR_CODE_UNSUPPORTED_CMD);
|
|
MESH_DEBUG_PRINTLN("ERROR: unknown command: %02X", cmd_frame[0]);
|
|
}
|
|
}
|
|
|
|
static bool save_filter(const ContactInfo& c) {
|
|
return c.type != ADV_TYPE_NONE; // don't save the transient/anon entries
|
|
}
|
|
|
|
__attribute__((noinline))
|
|
void MyMesh::onContactCacheFlushed() {
|
|
// Share the deadline reset after successful persistence or deliberate
|
|
// retirement of the old RAM table. Internal calls bypass virtual overrides.
|
|
mesh::resetLazyPersistenceAfterSuccess(dirty_contacts_expiry, dirty_contacts_failures);
|
|
}
|
|
|
|
bool MyMesh::resetContactsForRecovery() {
|
|
// Release retained paths before resetting the table. Keep the stream owner
|
|
// so its changed table revision restarts a complete list on the next pass.
|
|
#if MESH_CONTACT_CACHE
|
|
_iter_pending_contact = ContactInfo();
|
|
#endif
|
|
_iter_contact_pending = false;
|
|
resetContacts();
|
|
MyMesh::onContactCacheFlushed();
|
|
return true;
|
|
}
|
|
|
|
void MyMesh::saveContacts() {
|
|
if (_store->contactsAreVolatile()) {
|
|
MyMesh::onContactCacheFlushed();
|
|
return;
|
|
}
|
|
const bool success = _store->saveContacts(this, save_filter);
|
|
if (!success) {
|
|
scheduleContactWriteRetry();
|
|
} else if (_store->hasPendingContactWrites()) {
|
|
dirty_contacts_failures = 0;
|
|
dirty_contacts_expiry = mesh::nonzeroLazyPersistenceDeadline(
|
|
futureMillis(CONTACT_PAGE_WRITE_GAP));
|
|
} else {
|
|
MyMesh::onContactCacheFlushed();
|
|
}
|
|
}
|
|
|
|
void MyMesh::scheduleContactWriteRetry() {
|
|
const uint32_t retry_delay = mesh::recordLazyPersistenceSaveFailure(
|
|
dirty_contacts_failures, LAZY_CONTACTS_WRITE_DELAY,
|
|
mesh::LAZY_PERSISTENCE_MAX_RETRY_DELAY_MILLIS);
|
|
mesh::completeLazyPersistenceSave(
|
|
dirty_contacts_expiry, false, futureMillis(retry_delay));
|
|
MESH_DEBUG_PRINTLN("Contact persistence failed; retry in %lu ms",
|
|
(unsigned long)retry_delay);
|
|
}
|
|
|
|
bool MyMesh::isContactWriteDue() const {
|
|
// Read the clock once. Two separate reads can straddle the exact deadline
|
|
// and make both equality and the library's strict-after test return false.
|
|
const uint32_t now = _ms->getMillis();
|
|
return dirty_contacts_expiry != 0
|
|
&& (int32_t)(now - dirty_contacts_expiry) >= 0;
|
|
}
|
|
|
|
bool MyMesh::flushContactsBeforeReboot() {
|
|
// This helper also backs explicit durability promises such as tx.contact.
|
|
if (_store->contactsAreVolatile()) return false;
|
|
#if defined(ESP32_PLATFORM)
|
|
const uint32_t now = _ms->getMillis();
|
|
if (_store->hasPendingAdvertWrites() && !_store->isAdvertWriteDue(now)) return false;
|
|
if (!_store->flushAdvertWrites(now)) return false;
|
|
#endif
|
|
// Non-nRF stores use the legacy monolithic file and therefore do not report
|
|
// dirty pages. The lazy-write deadline still proves that RAM may be newer.
|
|
if (!dirty_contacts_expiry && !_store->hasPendingContactWrites()) return true;
|
|
if (!_store->flushContactWrites(this, save_filter)) {
|
|
scheduleContactWriteRetry();
|
|
return false;
|
|
}
|
|
MyMesh::onContactCacheFlushed();
|
|
return true;
|
|
}
|
|
|
|
bool MyMesh::prepareForOtaReboot() {
|
|
if (_store->contactsAreVolatile()) return true;
|
|
// Once a write has failed, honor its bounded retry deadline instead of
|
|
// letting the armed OTA apply hammer the same filesystem every loop.
|
|
if (dirty_contacts_failures != 0 && dirty_contacts_expiry != 0
|
|
&& !isContactWriteDue()) {
|
|
return false;
|
|
}
|
|
return flushContactsBeforeReboot();
|
|
}
|
|
|
|
__attribute__((noinline))
|
|
bool MyMesh::prepareForUiShutdown() {
|
|
return _store->contactsAreVolatile() || flushContactsBeforeReboot();
|
|
}
|
|
|
|
bool MyMesh::scheduleContactWrite(const ContactInfo& contact) {
|
|
// Anonymous contacts live only in the reserved transient prefix and are
|
|
// deliberately excluded from contact storage. Never allocate a persistent
|
|
// slot for one when a path/message callback updates its runtime state.
|
|
if (contact.type == ADV_TYPE_NONE || isTransientContact(contact)) return true;
|
|
if (_store->contactsAreVolatile()) return true;
|
|
if (!_store->markContactDirty(contact)) {
|
|
MESH_DEBUG_PRINTLN(
|
|
"Contact persistence scheduling failed: no persistent slot");
|
|
return false;
|
|
}
|
|
mesh::scheduleLazyPersistenceMutation(
|
|
dirty_contacts_expiry, dirty_contacts_failures,
|
|
futureMillis(LAZY_CONTACTS_WRITE_DELAY));
|
|
return true;
|
|
}
|
|
|
|
bool MyMesh::scheduleContactWriteAfterRelease(const ContactInfo& contact,
|
|
uint16_t& released_slot) {
|
|
#if defined(NRF52_PLATFORM)
|
|
released_slot = contact.storage_slot;
|
|
#else
|
|
released_slot = mesh::storage::CONTACT_SLOT_NONE;
|
|
#endif
|
|
if (!_store->releaseContact(contact)) {
|
|
MESH_DEBUG_PRINTLN(
|
|
"Contact removal scheduling failed: persistent slot was not reserved");
|
|
return false;
|
|
}
|
|
if (_store->contactsAreVolatile()) return true;
|
|
mesh::scheduleLazyPersistenceMutation(
|
|
dirty_contacts_expiry, dirty_contacts_failures,
|
|
futureMillis(LAZY_CONTACTS_WRITE_DELAY));
|
|
return true;
|
|
}
|
|
|
|
bool MyMesh::restoreContactWriteAfterRelease(const ContactInfo& contact,
|
|
uint16_t released_slot) {
|
|
if (!_store->restoreContactSlot(contact, released_slot)) {
|
|
MESH_DEBUG_PRINTLN(
|
|
"Contact removal rollback failed: released slot was not restored");
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
#if defined(NRF52_PLATFORM) && defined(EXTRAFS) && !defined(QSPIFLASH)
|
|
void MyMesh::repairInternalExtraFS(Stream& output) {
|
|
// Always run the store-level operation. When the 100 KiB ExtraFS is already
|
|
// active this is a non-destructive retry of any incomplete migration, not a
|
|
// format. Do not overwrite it from RAM until every authoritative source file
|
|
// has been copied, verified, and retired from primary storage.
|
|
const bool authoritative_reload_required =
|
|
_store->hasIncompleteContactLoad();
|
|
if (!_store->repairInternalExtraFS()) {
|
|
output.println(" Error: internal ExtraFS repair/migration failed; retry repair extrafs");
|
|
return;
|
|
}
|
|
|
|
if (authoritative_reload_required) {
|
|
// This boot intentionally loaded no/partial contacts while secondary
|
|
// authority was unknown. The repair has made the filesystem available,
|
|
// but rebuilding it from that RAM image would destroy the data just
|
|
// recovered. Reboot and reload the verified store before permitting any
|
|
// normal persistence.
|
|
output.println(
|
|
" > internal ExtraFS repaired; rebooting to reload authoritative data");
|
|
_scheduled_reboot_at = futureMillis(1000);
|
|
if (_scheduled_reboot_at == 0) _scheduled_reboot_at = 1;
|
|
return;
|
|
}
|
|
|
|
const bool contacts_saved = _store->saveContacts(this, save_filter);
|
|
const bool channels_saved = saveChannels();
|
|
const uint32_t capacity_kib = _store->getStorageTotalKb();
|
|
const bool capacity_ready = capacity_kib >= 84 && capacity_kib <= 100
|
|
&& (capacity_kib % 4) == 0;
|
|
if (contacts_saved && channels_saved && capacity_ready
|
|
&& !_store->hasPendingContactWrites()) {
|
|
MyMesh::onContactCacheFlushed();
|
|
output.printf(" > internal ExtraFS repaired (%lu KiB); contacts and channels rebuilt\n",
|
|
(unsigned long)_store->getStorageTotalKb());
|
|
} else {
|
|
if (!contacts_saved || _store->hasPendingContactWrites()) {
|
|
scheduleContactWriteRetry();
|
|
} else {
|
|
MyMesh::onContactCacheFlushed();
|
|
}
|
|
output.println(" Error: ExtraFS is active, but data rebuild/verification failed; retry repair extrafs");
|
|
}
|
|
}
|
|
|
|
void MyMesh::scanInternalExtraFS(Stream& output) {
|
|
if (!_store->requestInternalExtraFSBootScan()) {
|
|
output.println(" Error: could not record the boot-time ExtraFS scan request");
|
|
char health[160];
|
|
if (_store->formatInternalExtraFSHealth(health, sizeof(health))) {
|
|
output.printf(" > %s\n", health);
|
|
}
|
|
return;
|
|
}
|
|
output.println(" > boot-time physical scan scheduled; ExtraFS files will be erased; rebooting");
|
|
// This command deliberately discards the secondary filesystem. The normal
|
|
// scheduled-reboot path refuses to reset when unsaved contacts exist, which
|
|
// would strand the durable scan request on a damaged store.
|
|
output.flush();
|
|
delay(1000);
|
|
board.reboot();
|
|
}
|
|
|
|
#if defined(MESHCORE_EXTRAFS_HIL)
|
|
bool MyMesh::handleExtraFsHilCommand(const char* command, char* reply,
|
|
size_t reply_size) {
|
|
static const char* const fill_path = "/__hil.fill";
|
|
static const char* const contact_failure_marker = "/__hil.readfail";
|
|
static const uint8_t contact_stat_failure_flag = 0x80;
|
|
static const char* const prefix = "hil extrafs ";
|
|
if (strncmp(command, prefix, strlen(prefix)) != 0) return false;
|
|
|
|
FILESYSTEM* fs = _store->getSecondaryFS();
|
|
const char* action = command + strlen(prefix);
|
|
auto armContactPageFailure = [fs](uint8_t encoded_failure) -> int8_t {
|
|
if (fs == nullptr) return 0;
|
|
// Do not replace an unread/read-failure request with a stat-failure request
|
|
// (or vice versa). One marker allocation represents exactly one fault.
|
|
if (fs->exists(contact_failure_marker)) return -1;
|
|
|
|
File marker = fs->open(contact_failure_marker, FILE_O_WRITE);
|
|
const bool written = marker
|
|
&& marker.write(&encoded_failure, 1) == 1;
|
|
if (marker) {
|
|
marker.flush();
|
|
marker.close();
|
|
}
|
|
File verify = fs->open(contact_failure_marker, FILE_O_READ);
|
|
const bool durable = written && verify && verify.size() == 1
|
|
&& verify.read() == encoded_failure;
|
|
if (verify) verify.close();
|
|
if (!durable && fs->exists(contact_failure_marker)) {
|
|
fs->remove(contact_failure_marker);
|
|
}
|
|
return durable ? 1 : 0;
|
|
};
|
|
auto findHilContact = [this](const char* encoded,
|
|
size_t encoded_len) -> ContactInfo* {
|
|
// Seven bytes match the advert-cache key width and keep the direct-only
|
|
// HIL command below the Companion CLI frame limit.
|
|
if (encoded_len != 14) return nullptr;
|
|
char text[15];
|
|
memcpy(text, encoded, encoded_len);
|
|
text[encoded_len] = 0;
|
|
uint8_t key_prefix[7];
|
|
if (!mesh::Utils::fromHex(key_prefix, sizeof(key_prefix), text)) {
|
|
return nullptr;
|
|
}
|
|
return lookupPersistentContactByPubKey(
|
|
key_prefix, sizeof(key_prefix));
|
|
};
|
|
if (strcmp(action, "status") == 0) {
|
|
uint32_t fill_size = 0;
|
|
if (fs != nullptr) {
|
|
File fill = fs->open(fill_path, FILE_O_READ);
|
|
if (fill) {
|
|
fill_size = fill.size();
|
|
fill.close();
|
|
}
|
|
}
|
|
snprintf(reply, reply_size,
|
|
"HIL ExtraFS active=%u used=%luKiB total=%luKiB fill=%lu",
|
|
fs != nullptr ? 1U : 0U,
|
|
(unsigned long)_store->getStorageUsedKb(),
|
|
(unsigned long)_store->getStorageTotalKb(),
|
|
(unsigned long)fill_size);
|
|
return true;
|
|
}
|
|
|
|
if (strcmp(action, "clear") == 0) {
|
|
if (fs == nullptr) {
|
|
snprintf(reply, reply_size, "HIL ExtraFS unavailable");
|
|
} else if ((!fs->exists(fill_path) || fs->remove(fill_path))
|
|
&& !fs->exists(fill_path)) {
|
|
snprintf(reply, reply_size, "HIL ExtraFS filler cleared");
|
|
} else {
|
|
snprintf(reply, reply_size, "HIL ExtraFS filler clear failed");
|
|
}
|
|
return true;
|
|
}
|
|
|
|
static const char* const contact_slot_prefix = "contact-slot ";
|
|
if (strncmp(action, contact_slot_prefix,
|
|
strlen(contact_slot_prefix)) == 0) {
|
|
const char* encoded = action + strlen(contact_slot_prefix);
|
|
ContactInfo* contact = findHilContact(encoded, strlen(encoded));
|
|
if (contact == nullptr) {
|
|
snprintf(reply, reply_size,
|
|
"HIL usage: hil extrafs contact-slot <14 hex chars>");
|
|
} else {
|
|
snprintf(reply, reply_size, "HIL contact slot=%u",
|
|
(unsigned)contact->storage_slot);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
static const char* const seed_advert_prefix = "seed-advert ";
|
|
if (strncmp(action, seed_advert_prefix,
|
|
strlen(seed_advert_prefix)) == 0) {
|
|
const char* encoded = action + strlen(seed_advert_prefix);
|
|
static const char* const confirm = " CONFIRM";
|
|
const size_t value_len = strlen(encoded);
|
|
const size_t confirm_len = strlen(confirm);
|
|
ContactInfo* contact = value_len == 14 + confirm_len
|
|
&& strcmp(encoded + 14, confirm) == 0
|
|
? findHilContact(encoded, 14) : nullptr;
|
|
if (contact == nullptr || fs == nullptr) {
|
|
snprintf(reply, reply_size,
|
|
"HIL usage: hil extrafs seed-advert <14 hex chars> CONFIRM");
|
|
return true;
|
|
}
|
|
|
|
uint8_t advert[PUB_KEY_SIZE + 4 + SIGNATURE_SIZE];
|
|
memset(advert, 0xA5, sizeof(advert));
|
|
memcpy(advert, contact->id.pub_key, PUB_KEY_SIZE);
|
|
uint8_t verify[MAX_PACKET_PAYLOAD];
|
|
const bool saved = _store->putBlobByKey(
|
|
contact->id.pub_key, PUB_KEY_SIZE, advert, sizeof(advert));
|
|
const uint8_t stored = saved ? _store->getBlobByKey(
|
|
contact->id.pub_key, PUB_KEY_SIZE, verify) : 0;
|
|
if (!saved || stored != sizeof(advert)
|
|
|| memcmp(advert, verify, sizeof(advert)) != 0) {
|
|
snprintf(reply, reply_size, "HIL advert seed failed");
|
|
} else {
|
|
snprintf(reply, reply_size, "HIL advert seeded slot=%u",
|
|
(unsigned)contact->storage_slot);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
static const char* const clear_advert_prefix = "clear-advert ";
|
|
if (strncmp(action, clear_advert_prefix,
|
|
strlen(clear_advert_prefix)) == 0) {
|
|
const char* encoded = action + strlen(clear_advert_prefix);
|
|
static const char* const confirm = " CONFIRM";
|
|
const size_t value_len = strlen(encoded);
|
|
const size_t confirm_len = strlen(confirm);
|
|
ContactInfo* contact = value_len == 14 + confirm_len
|
|
&& strcmp(encoded + 14, confirm) == 0
|
|
? findHilContact(encoded, 14) : nullptr;
|
|
if (contact == nullptr || fs == nullptr) {
|
|
snprintf(reply, reply_size,
|
|
"HIL usage: hil extrafs clear-advert <14 hex chars> CONFIRM");
|
|
} else if (!_store->deleteBlobByKey(
|
|
contact->id.pub_key, PUB_KEY_SIZE)) {
|
|
snprintf(reply, reply_size, "HIL advert clear failed");
|
|
} else {
|
|
uint8_t verify[MAX_PACKET_PAYLOAD];
|
|
const uint8_t stored = _store->getBlobByKey(
|
|
contact->id.pub_key, PUB_KEY_SIZE, verify);
|
|
snprintf(reply, reply_size, stored == 0
|
|
? "HIL advert cleared" : "HIL advert clear verify failed");
|
|
}
|
|
return true;
|
|
}
|
|
|
|
static const char* const fail_stat_prefix = "fail-stat-page ";
|
|
if (strncmp(action, fail_stat_prefix, strlen(fail_stat_prefix)) == 0) {
|
|
const char* value = action + strlen(fail_stat_prefix);
|
|
char* end = nullptr;
|
|
const unsigned long page = strtoul(value, &end, 10);
|
|
char page_path[24];
|
|
snprintf(page_path, sizeof(page_path), "/contacts4_%02lu", page);
|
|
if (value == end || strcmp(end, " CONFIRM") != 0
|
|
|| page >= mesh::storage::CONTACT_PAGE_COUNT || fs == nullptr
|
|
|| !fs->exists(page_path) || _store->hasPendingContactWrites()
|
|
|| _store->hasIncompleteContactLoad()) {
|
|
snprintf(reply, reply_size,
|
|
"HIL usage: hil extrafs fail-stat-page <0..%u> CONFIRM",
|
|
(unsigned)(mesh::storage::CONTACT_PAGE_COUNT - 1));
|
|
return true;
|
|
}
|
|
const int8_t armed = armContactPageFailure(
|
|
contact_stat_failure_flag | static_cast<uint8_t>(page));
|
|
if (armed < 0) {
|
|
snprintf(reply, reply_size, "HIL contact failure already armed");
|
|
} else if (armed == 0) {
|
|
snprintf(reply, reply_size, "HIL stat-failure marker write failed");
|
|
} else {
|
|
snprintf(reply, reply_size,
|
|
"HIL contact page %lu stat failure armed", page);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
static const char* const fail_read_prefix = "fail-read-page ";
|
|
if (strncmp(action, fail_read_prefix, strlen(fail_read_prefix)) == 0) {
|
|
const char* value = action + strlen(fail_read_prefix);
|
|
char* end = nullptr;
|
|
const unsigned long page = strtoul(value, &end, 10);
|
|
char page_path[24];
|
|
snprintf(page_path, sizeof(page_path), "/contacts4_%02lu", page);
|
|
if (value == end || strcmp(end, " CONFIRM") != 0
|
|
|| page >= mesh::storage::CONTACT_PAGE_COUNT || fs == nullptr
|
|
|| !fs->exists(page_path) || _store->hasPendingContactWrites()
|
|
|| _store->hasIncompleteContactLoad()) {
|
|
snprintf(reply, reply_size,
|
|
"HIL usage: hil extrafs fail-read-page <0..%u> CONFIRM",
|
|
(unsigned)(mesh::storage::CONTACT_PAGE_COUNT - 1));
|
|
return true;
|
|
}
|
|
const int8_t armed = armContactPageFailure(static_cast<uint8_t>(page));
|
|
if (armed < 0) {
|
|
snprintf(reply, reply_size, "HIL contact failure already armed");
|
|
} else if (armed == 0) {
|
|
snprintf(reply, reply_size, "HIL read-failure marker write failed");
|
|
} else {
|
|
snprintf(reply, reply_size,
|
|
"HIL contact page %lu read failure armed", page);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
static const char* const fill_prefix = "fill ";
|
|
if (strncmp(action, fill_prefix, strlen(fill_prefix)) == 0) {
|
|
const char* value = action + strlen(fill_prefix);
|
|
char* end = nullptr;
|
|
const unsigned long requested = strtoul(value, &end, 10);
|
|
if (value == end || *end != 0 || requested > 128UL * 1024UL) {
|
|
snprintf(reply, reply_size,
|
|
"HIL usage: hil extrafs fill <0..131072>");
|
|
return true;
|
|
}
|
|
if (fs == nullptr) {
|
|
snprintf(reply, reply_size, "HIL ExtraFS unavailable");
|
|
return true;
|
|
}
|
|
if (fs->exists(fill_path) && !fs->remove(fill_path)) {
|
|
snprintf(reply, reply_size, "HIL ExtraFS old filler remove failed");
|
|
return true;
|
|
}
|
|
|
|
uint8_t chunk[128];
|
|
memset(chunk, 0xA5, sizeof(chunk));
|
|
uint32_t committed = 0;
|
|
while (committed < requested) {
|
|
// LittleFS can accept buffered writes which are all rolled back when the
|
|
// final sync runs out of blocks. Commit and read-verify each small extent
|
|
// so the filler represents durable pressure, not an optimistic write()
|
|
// count for a file which disappears on close.
|
|
File fill = fs->open(fill_path, FILE_O_WRITE);
|
|
if (!fill || fill.size() != committed || !fill.seek(committed)) {
|
|
if (fill) fill.close();
|
|
break;
|
|
}
|
|
|
|
const uint32_t extent_end = committed + 1024UL < requested
|
|
? committed + 1024UL : requested;
|
|
bool extent_written = true;
|
|
while (committed < extent_end) {
|
|
const size_t remaining = extent_end - committed;
|
|
const size_t count = remaining < sizeof(chunk) ? remaining : sizeof(chunk);
|
|
if (fill.write(chunk, count) != count) {
|
|
extent_written = false;
|
|
break;
|
|
}
|
|
committed += count;
|
|
}
|
|
fill.flush();
|
|
fill.close();
|
|
|
|
File verify = fs->open(fill_path, FILE_O_READ);
|
|
const uint32_t durable_size = verify ? verify.size() : 0;
|
|
if (verify) verify.close();
|
|
if (!extent_written || durable_size != committed) {
|
|
committed = durable_size;
|
|
break;
|
|
}
|
|
}
|
|
snprintf(reply, reply_size,
|
|
"HIL ExtraFS fill requested=%lu written=%lu used=%luKiB total=%luKiB",
|
|
requested, (unsigned long)committed,
|
|
(unsigned long)_store->getStorageUsedKb(),
|
|
(unsigned long)_store->getStorageTotalKb());
|
|
return true;
|
|
}
|
|
|
|
static const char* const corrupt_prefix = "corrupt-page ";
|
|
static const char* const page_mask_prefix = "page-mask ";
|
|
if (strncmp(action, page_mask_prefix, strlen(page_mask_prefix)) == 0) {
|
|
const char* value = action + strlen(page_mask_prefix);
|
|
char* end = nullptr;
|
|
const unsigned long page = strtoul(value, &end, 10);
|
|
if (value == end || *end != 0
|
|
|| page >= mesh::storage::CONTACT_PAGE_COUNT || fs == nullptr) {
|
|
snprintf(reply, reply_size,
|
|
"HIL usage: hil extrafs page-mask <0..%u>",
|
|
(unsigned)(mesh::storage::CONTACT_PAGE_COUNT - 1));
|
|
return true;
|
|
}
|
|
|
|
char path[24];
|
|
snprintf(path, sizeof(path), "/contacts4_%02lu", page);
|
|
File page_file = fs->open(path, FILE_O_READ);
|
|
uint8_t raw_header[mesh::storage::CONTACT_PAGE_HEADER_SIZE];
|
|
mesh::storage::ContactPageHeader header = {};
|
|
const bool valid = page_file
|
|
&& page_file.read(raw_header, sizeof(raw_header)) == sizeof(raw_header)
|
|
&& mesh::storage::decodeContactPageHeader(
|
|
raw_header, static_cast<uint8_t>(page), header);
|
|
if (page_file) page_file.close();
|
|
if (!valid) {
|
|
snprintf(reply, reply_size, "HIL contact page unavailable");
|
|
} else {
|
|
snprintf(reply, reply_size,
|
|
"HIL contact page %lu occupied=%08lx", page,
|
|
(unsigned long)header.occupied);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
static const char* const corrupt_occupied_prefix = "corrupt-occupied ";
|
|
if (strncmp(action, corrupt_occupied_prefix,
|
|
strlen(corrupt_occupied_prefix)) == 0) {
|
|
const char* value = action + strlen(corrupt_occupied_prefix);
|
|
char* page_end = nullptr;
|
|
const unsigned long page = strtoul(value, &page_end, 10);
|
|
const char* slot_value = page_end;
|
|
while (*slot_value == ' ') slot_value++;
|
|
char* slot_end = nullptr;
|
|
const unsigned long slot = strtoul(slot_value, &slot_end, 10);
|
|
if (value == page_end || slot_value == slot_end
|
|
|| strcmp(slot_end, " CONFIRM") != 0
|
|
|| page >= mesh::storage::CONTACT_PAGE_COUNT || slot >= 32
|
|
|| fs == nullptr || _store->hasPendingContactWrites()
|
|
|| _store->hasIncompleteContactLoad()) {
|
|
snprintf(reply, reply_size,
|
|
"HIL usage: hil extrafs corrupt-occupied <page> <bit 0..31> CONFIRM");
|
|
return true;
|
|
}
|
|
|
|
char path[24];
|
|
snprintf(path, sizeof(path), "/contacts4_%02lu", page);
|
|
const uint32_t byte_offset = 8 + slot / 8;
|
|
File original = fs->open(path, FILE_O_READ);
|
|
if (!original || !original.seek(byte_offset)) {
|
|
if (original) original.close();
|
|
snprintf(reply, reply_size, "HIL contact page unavailable");
|
|
return true;
|
|
}
|
|
const int prior = original.read();
|
|
original.close();
|
|
if (prior < 0) {
|
|
snprintf(reply, reply_size, "HIL contact page read failed");
|
|
return true;
|
|
}
|
|
|
|
const uint8_t changed = static_cast<uint8_t>(prior)
|
|
^ static_cast<uint8_t>(1U << (slot % 8));
|
|
File update = fs->open(path, FILE_O_WRITE);
|
|
if (!update || !update.seek(byte_offset)
|
|
|| update.write(&changed, 1) != 1) {
|
|
if (update) update.close();
|
|
snprintf(reply, reply_size, "HIL contact occupancy corrupt failed");
|
|
return true;
|
|
}
|
|
update.flush();
|
|
update.close();
|
|
|
|
File verify = fs->open(path, FILE_O_READ);
|
|
const bool changed_on_flash = verify && verify.seek(byte_offset)
|
|
&& verify.read() == changed;
|
|
if (verify) verify.close();
|
|
snprintf(reply, reply_size, changed_on_flash
|
|
? "HIL contact page %lu occupancy bit %lu corrupted"
|
|
: "HIL contact occupancy corrupt verify failed", page, slot);
|
|
return true;
|
|
}
|
|
|
|
if (strncmp(action, corrupt_prefix, strlen(corrupt_prefix)) == 0) {
|
|
const char* value = action + strlen(corrupt_prefix);
|
|
char* end = nullptr;
|
|
const unsigned long page = strtoul(value, &end, 10);
|
|
if (value == end || strcmp(end, " CONFIRM") != 0
|
|
|| page >= mesh::storage::CONTACT_PAGE_COUNT) {
|
|
snprintf(reply, reply_size,
|
|
"HIL usage: hil extrafs corrupt-page <0..%u> CONFIRM",
|
|
(unsigned)(mesh::storage::CONTACT_PAGE_COUNT - 1));
|
|
return true;
|
|
}
|
|
if (fs == nullptr || _store->hasPendingContactWrites()
|
|
|| _store->hasIncompleteContactLoad()) {
|
|
snprintf(reply, reply_size, "HIL ExtraFS unavailable or contacts pending");
|
|
return true;
|
|
}
|
|
|
|
char path[24];
|
|
snprintf(path, sizeof(path), "/contacts4_%02lu", page);
|
|
File original = fs->open(path, FILE_O_READ);
|
|
if (!original || original.size() != mesh::storage::CONTACT_PAGE_FILE_SIZE
|
|
|| !original.seek(mesh::storage::CONTACT_PAGE_HEADER_SIZE)) {
|
|
if (original) original.close();
|
|
snprintf(reply, reply_size, "HIL contact page unavailable");
|
|
return true;
|
|
}
|
|
const int prior = original.read();
|
|
original.close();
|
|
if (prior < 0) {
|
|
snprintf(reply, reply_size, "HIL contact page read failed");
|
|
return true;
|
|
}
|
|
|
|
const uint8_t changed = static_cast<uint8_t>(prior) ^ 0xA5U;
|
|
File update = fs->open(path, FILE_O_WRITE);
|
|
if (!update || !update.seek(mesh::storage::CONTACT_PAGE_HEADER_SIZE)
|
|
|| update.write(&changed, 1) != 1) {
|
|
if (update) update.close();
|
|
snprintf(reply, reply_size, "HIL contact page corrupt failed");
|
|
return true;
|
|
}
|
|
update.flush();
|
|
update.close();
|
|
|
|
File verify = fs->open(path, FILE_O_READ);
|
|
const bool changed_on_flash = verify
|
|
&& verify.seek(mesh::storage::CONTACT_PAGE_HEADER_SIZE)
|
|
&& verify.read() == changed;
|
|
if (verify) verify.close();
|
|
snprintf(reply, reply_size, changed_on_flash
|
|
? "HIL contact page %lu CRC corrupted"
|
|
: "HIL contact page corrupt verify failed", page);
|
|
return true;
|
|
}
|
|
|
|
if (strcmp(action, "unsafe-reset CONFIRM") == 0) {
|
|
// Intentionally bypass all persistence hooks so HIL can prove that a
|
|
// mutation was already committed before an event-driven sleep.
|
|
meshRegulatorCleanReset(); // Retained marker only; no persistence flush.
|
|
NVIC_SystemReset();
|
|
while (true) {}
|
|
}
|
|
|
|
snprintf(reply, reply_size,
|
|
"HIL usage: hil extrafs <status|fill N|clear|contact-slot K|seed-advert K CONFIRM|clear-advert K CONFIRM|fail-read-page N CONFIRM|page-mask N|corrupt-occupied N B CONFIRM|corrupt-page N CONFIRM|unsafe-reset CONFIRM>");
|
|
return true;
|
|
}
|
|
#endif
|
|
#endif
|
|
|
|
bool MyMesh::saveAdvertName(const char* name) {
|
|
char previous[sizeof(_prefs.node_name)];
|
|
memcpy(previous, _prefs.node_name, sizeof(previous));
|
|
StrHelper::strncpy(_prefs.node_name, name, sizeof(_prefs.node_name));
|
|
if (savePrefs()) {
|
|
#if MESH_ENABLE_MESHTEXT_CHAT
|
|
if (strncmp(previous, _prefs.node_name, sizeof(previous)) != 0) {
|
|
// NodeInfo contains the global advert name; retained ciphertext must
|
|
// not advertise the previous name after a successful rename. Preserve
|
|
// independent text retries, and wait for a new successful chat before
|
|
// announcing again rather than transmitting from a settings edit.
|
|
for (auto& frame : _mt_delivery) if (frame.nodeinfo) frame.cancelled = true;
|
|
memset(_mt_nodeinfo_announced, 0, sizeof(_mt_nodeinfo_announced));
|
|
memset(_mt_nodeinfo_pending, 0, sizeof(_mt_nodeinfo_pending));
|
|
}
|
|
#endif
|
|
return true;
|
|
}
|
|
memcpy(_prefs.node_name, previous, sizeof(previous));
|
|
return false;
|
|
}
|
|
|
|
#if ENV_INCLUDE_GPS == 1
|
|
bool MyMesh::setGpsEnabled(bool enabled) {
|
|
if (!sensors.setSettingValue("gps", enabled ? "1" : "0")) return false;
|
|
if (savePreference(_prefs.gps_enabled, static_cast<uint8_t>(enabled))) return true;
|
|
applyGpsPrefs();
|
|
return false;
|
|
}
|
|
#endif
|
|
|
|
bool MyMesh::saveAdvertLocation(double latitude, double longitude) {
|
|
const double previous_lat = sensors.node_lat, previous_lon = sensors.node_lon;
|
|
sensors.node_lat = latitude;
|
|
sensors.node_lon = longitude;
|
|
if (savePrefs()) return true;
|
|
sensors.node_lat = previous_lat;
|
|
sensors.node_lon = previous_lon;
|
|
return false;
|
|
}
|
|
|
|
MyMesh::RadioSettingResult MyMesh::applyAndSaveTxPower(int8_t power) {
|
|
if (!radio_driver.isTxPowerValid(power)) return RadioSettingResult::RadioRejected;
|
|
const int8_t previous = _prefs.tx_power_dbm;
|
|
const auto result = _radio_available
|
|
? radio_driver.trySetTxPower(power) : mesh::RadioParamApplyResult::BUSY;
|
|
if (result == mesh::RadioParamApplyResult::FAILED) {
|
|
// A rejected SPI transaction may have partly changed the hardware. Keep
|
|
// the saved setting authoritative and restore it without retuning RX.
|
|
tx_power_apply_pending = true;
|
|
tx_power_apply_retry_at = 0;
|
|
return RadioSettingResult::RadioRejected;
|
|
}
|
|
_prefs.tx_power_dbm = power;
|
|
if (savePrefs()) {
|
|
tx_power_apply_pending = result != mesh::RadioParamApplyResult::APPLIED;
|
|
tx_power_apply_retry_at = 0;
|
|
return RadioSettingResult::Saved;
|
|
}
|
|
|
|
_prefs.tx_power_dbm = previous;
|
|
// A busy attempt has not changed the earlier pending target or retry time.
|
|
if (result == mesh::RadioParamApplyResult::APPLIED) {
|
|
tx_power_apply_pending = !radio_driver.setTxPower(previous);
|
|
tx_power_apply_retry_at = 0;
|
|
}
|
|
return RadioSettingResult::SaveFailed;
|
|
}
|
|
|
|
void MyMesh::servicePendingTxPower() {
|
|
if (!tx_power_apply_pending || !_radio_available
|
|
|| (_radio && _radio->isCarrierWaveActive())) return;
|
|
const uint32_t now = _ms->getMillis();
|
|
if (tx_power_apply_retry_at
|
|
&& static_cast<int32_t>(now - tx_power_apply_retry_at) < 0) return;
|
|
const auto result = radio_driver.trySetTxPower(_prefs.tx_power_dbm);
|
|
if (result == mesh::RadioParamApplyResult::APPLIED) tx_power_apply_pending = false;
|
|
// Busy admission does not touch RX. Back off actual hardware failures.
|
|
tx_power_apply_retry_at = result == mesh::RadioParamApplyResult::FAILED
|
|
? (now + 1000u ? now + 1000u : 1u) : 0;
|
|
}
|
|
|
|
bool MyMesh::applyAndSaveFemRxGain(bool enabled) {
|
|
// Binary Companion setters and WebConfig also enter through this helper.
|
|
// Do not alter an external FEM while the Morse transmitter owns it.
|
|
if (_radio && (_radio->isMorseTxActive() || _radio->isMorseRxActive())) return false;
|
|
if (!board.canControlLoRaFemLna()) return false;
|
|
|
|
const bool previous_hardware = board.isLoRaFemLnaEnabled();
|
|
const uint8_t previous_pref = _prefs.radio_fem_rxgain;
|
|
const uint8_t previous_override = _prefs.radio_fem_rxgain_override;
|
|
const bool changed = previous_hardware != enabled;
|
|
if (!board.setLoRaFemLnaEnabled(enabled)) return false;
|
|
|
|
if (changed && _radio_available) _radio->recalibrateNoiseFloor();
|
|
_prefs.radio_fem_rxgain = enabled ? 1 : 0;
|
|
_prefs.radio_fem_rxgain_override = 1;
|
|
if (!savePrefs()) {
|
|
_prefs.radio_fem_rxgain = previous_pref;
|
|
_prefs.radio_fem_rxgain_override = previous_override;
|
|
board.setLoRaFemLnaEnabled(previous_hardware);
|
|
if (changed && _radio_available) _radio->recalibrateNoiseFloor();
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
bool MyMesh::applyAndSaveFemTxGain(bool enabled) {
|
|
if (_radio && (_radio->isMorseTxActive() || _radio->isMorseRxActive())) return false;
|
|
if (!board.canControlLoRaFemPaGain()) return false;
|
|
const bool previous_hardware = board.isLoRaFemPaGainEnabled();
|
|
const uint8_t previous_pref = _prefs.radio_fem_txgain;
|
|
if (!board.setLoRaFemPaGainEnabled(enabled)) return false;
|
|
|
|
_prefs.radio_fem_txgain = enabled ? 1 : 0;
|
|
if (!savePrefs()) {
|
|
_prefs.radio_fem_txgain = previous_pref;
|
|
board.setLoRaFemPaGainEnabled(previous_hardware);
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
bool MyMesh::applyAndSaveRxBoostedGain(bool enabled) {
|
|
if (!radio_driver.supportsRxBoostedGainMode()) return false;
|
|
const auto result = _radio_available
|
|
? radio_driver.trySetRxBoostedGainMode(enabled) : mesh::RadioParamApplyResult::BUSY;
|
|
if (result == mesh::RadioParamApplyResult::FAILED) {
|
|
queueSavedRadioApply();
|
|
return false;
|
|
}
|
|
const uint8_t previous_pref = _prefs.rx_boosted_gain;
|
|
_prefs.rx_boosted_gain = enabled ? 1 : 0;
|
|
if (!savePrefs()) {
|
|
_prefs.rx_boosted_gain = previous_pref;
|
|
if (result == mesh::RadioParamApplyResult::APPLIED
|
|
&& !radio_driver.setRxBoostedGainMode(previous_pref != 0)) queueSavedRadioApply();
|
|
return false;
|
|
}
|
|
if (result == mesh::RadioParamApplyResult::BUSY) queueSavedRadioApply();
|
|
return true;
|
|
}
|
|
|
|
bool MyMesh::saveBluetoothNameOverride(const char* name) {
|
|
if (name == NULL) return false;
|
|
|
|
char previous[sizeof(_prefs.bluetooth_name)];
|
|
memcpy(previous, _prefs.bluetooth_name, sizeof(previous));
|
|
memset(_prefs.bluetooth_name, 0, sizeof(_prefs.bluetooth_name));
|
|
if (name[0] != 0) {
|
|
StrHelper::strncpy(_prefs.bluetooth_name, name,
|
|
sizeof(_prefs.bluetooth_name));
|
|
}
|
|
|
|
if (savePrefs()) return true;
|
|
memcpy(_prefs.bluetooth_name, previous, sizeof(_prefs.bluetooth_name));
|
|
return false;
|
|
}
|
|
|
|
bool MyMesh::applyAndSaveBluetoothName(const char* value, char* reply,
|
|
size_t reply_size) {
|
|
if (reply == NULL || reply_size == 0) return false;
|
|
|
|
const bool use_default = value == NULL || value[0] == 0;
|
|
if (!use_default && !mesh::companion::isValidBluetoothName(value)) {
|
|
snprintf(reply, reply_size,
|
|
"Error: Bluetooth name must be 1-%u valid UTF-8 bytes without control characters",
|
|
(unsigned)mesh::companion::BLUETOOTH_NAME_MAX_BYTES);
|
|
return false;
|
|
}
|
|
|
|
if (!saveBluetoothNameOverride(use_default ? "" : value)) {
|
|
mesh::cli::formatError(reply, reply_size, "Bluetooth name save failed");
|
|
return false;
|
|
}
|
|
|
|
if (use_default) {
|
|
snprintf(reply, reply_size,
|
|
#if defined(NRF52_PLATFORM)
|
|
"OK - Bluetooth name follows %s<node name>; advertising refreshes after disconnect",
|
|
#else
|
|
"OK - Bluetooth name follows %s<node name>; reboot required",
|
|
#endif
|
|
BLE_NAME_PREFIX);
|
|
} else {
|
|
snprintf(reply, reply_size,
|
|
#if defined(NRF52_PLATFORM)
|
|
"OK - Bluetooth name saved as '%s'; advertising refreshes after disconnect", value);
|
|
#else
|
|
"OK - Bluetooth name saved as '%s'; reboot required", value);
|
|
#endif
|
|
}
|
|
return true;
|
|
}
|
|
|
|
void MyMesh::formatBluetoothNameStatus(char* reply, size_t reply_size) const {
|
|
if (reply == NULL || reply_size == 0) return;
|
|
|
|
char effective_name[64];
|
|
mesh::companion::formatBluetoothName(
|
|
effective_name, sizeof(effective_name), _prefs.bluetooth_name,
|
|
BLE_NAME_PREFIX, _prefs.node_name);
|
|
snprintf(reply, reply_size, "> %s (%s)", effective_name,
|
|
mesh::companion::hasCustomBluetoothName(_prefs.bluetooth_name)
|
|
? "custom" : "default from node name");
|
|
}
|
|
|
|
#if defined(BLE_PIN_CODE)
|
|
bool MyMesh::prepareBluetoothMacForBoot(bool& address_rotated) {
|
|
address_rotated = false;
|
|
if (_prefs.bluetooth_mac_mode
|
|
== mesh::companion::BLUETOOTH_MAC_RANDOM_EVERY_BOOT) {
|
|
// This policy deliberately invalidates the previous local BLE identity.
|
|
// Reopen first pairing before advertising rather than hiding behind a
|
|
// bond created for that old address. Address generation remains once per
|
|
// boot in main.cpp, including initialization retries.
|
|
if (!resetBluetoothStealthPairing()) return false;
|
|
address_rotated = true;
|
|
return true;
|
|
}
|
|
const bool rotate_after_connection =
|
|
mesh::companion::bluetoothMacShouldRotateAtBoot(
|
|
_prefs.bluetooth_mac_mode, _prefs.bluetooth_mac);
|
|
if (!rotate_after_connection) return true;
|
|
|
|
uint8_t address[mesh::companion::BLUETOOTH_MAC_BYTES];
|
|
getRNG()->random(address, sizeof(address));
|
|
mesh::companion::makeRandomStaticBluetoothMacDifferentFrom(
|
|
address, _prefs.bluetooth_mac);
|
|
address_rotated = saveBluetoothMac(
|
|
mesh::companion::BLUETOOTH_MAC_RANDOM_AFTER_CONNECT, address);
|
|
return address_rotated;
|
|
}
|
|
|
|
bool MyMesh::armBluetoothMacRotationAfterConnection() {
|
|
if (_prefs.bluetooth_mac_mode
|
|
== mesh::companion::BLUETOOTH_MAC_RANDOM_AFTER_CONNECT_ARMED) {
|
|
return true;
|
|
}
|
|
if (_prefs.bluetooth_mac_mode
|
|
!= mesh::companion::BLUETOOTH_MAC_RANDOM_AFTER_CONNECT) {
|
|
return true;
|
|
}
|
|
|
|
_prefs.bluetooth_mac_mode =
|
|
mesh::companion::BLUETOOTH_MAC_RANDOM_AFTER_CONNECT_ARMED;
|
|
if (savePrefs()) return true;
|
|
|
|
_prefs.bluetooth_mac_mode =
|
|
mesh::companion::BLUETOOTH_MAC_RANDOM_AFTER_CONNECT;
|
|
return false;
|
|
}
|
|
|
|
bool MyMesh::saveBluetoothStealthPeer(
|
|
const mesh::companion::BluetoothPeerIdentity& peer) {
|
|
if (!mesh::companion::isValidBluetoothPeerIdentity(peer)) return false;
|
|
if (_prefs.bluetooth_stealth_mode
|
|
== mesh::companion::BLUETOOTH_STEALTH_PAIRED
|
|
&& _prefs.bluetooth_stealth_peer_type == peer.type
|
|
&& memcmp(_prefs.bluetooth_stealth_peer, peer.address,
|
|
sizeof(_prefs.bluetooth_stealth_peer)) == 0) {
|
|
return true;
|
|
}
|
|
if (_prefs.bluetooth_stealth_mode
|
|
!= mesh::companion::BLUETOOTH_STEALTH_PAIRING) {
|
|
return false;
|
|
}
|
|
|
|
const uint8_t previous_mode = _prefs.bluetooth_mac_mode;
|
|
const uint8_t previous_type = _prefs.bluetooth_stealth_peer_type;
|
|
uint8_t previous_peer[mesh::companion::BLUETOOTH_MAC_BYTES];
|
|
memcpy(previous_peer, _prefs.bluetooth_stealth_peer,
|
|
sizeof(previous_peer));
|
|
|
|
_prefs.bluetooth_stealth_mode = mesh::companion::BLUETOOTH_STEALTH_PAIRED;
|
|
// One durable write records both consequences of a successful pairing.
|
|
// Stealth must not prevent random-after-connect from arming for next boot.
|
|
if (mesh::companion::bluetoothMacModeIsRandomAfterConnect(
|
|
_prefs.bluetooth_mac_mode)) {
|
|
_prefs.bluetooth_mac_mode =
|
|
mesh::companion::BLUETOOTH_MAC_RANDOM_AFTER_CONNECT_ARMED;
|
|
}
|
|
_prefs.bluetooth_stealth_peer_type = peer.type;
|
|
memcpy(_prefs.bluetooth_stealth_peer, peer.address,
|
|
sizeof(_prefs.bluetooth_stealth_peer));
|
|
if (savePrefs()) return true;
|
|
|
|
_prefs.bluetooth_mac_mode = previous_mode;
|
|
_prefs.bluetooth_stealth_mode = mesh::companion::BLUETOOTH_STEALTH_PAIRING;
|
|
_prefs.bluetooth_stealth_peer_type = previous_type;
|
|
memcpy(_prefs.bluetooth_stealth_peer, previous_peer,
|
|
sizeof(_prefs.bluetooth_stealth_peer));
|
|
return false;
|
|
}
|
|
|
|
bool MyMesh::resetBluetoothStealthPairing() {
|
|
if (_prefs.bluetooth_stealth_mode !=
|
|
mesh::companion::BLUETOOTH_STEALTH_PAIRED) {
|
|
return true;
|
|
}
|
|
|
|
const uint8_t previous_type = _prefs.bluetooth_stealth_peer_type;
|
|
uint8_t previous_peer[mesh::companion::BLUETOOTH_MAC_BYTES];
|
|
memcpy(previous_peer, _prefs.bluetooth_stealth_peer,
|
|
sizeof(previous_peer));
|
|
|
|
clearCompanionBluetoothStealthPeer(_prefs);
|
|
if (savePrefs()) return true;
|
|
|
|
_prefs.bluetooth_stealth_mode = mesh::companion::BLUETOOTH_STEALTH_PAIRED;
|
|
_prefs.bluetooth_stealth_peer_type = previous_type;
|
|
memcpy(_prefs.bluetooth_stealth_peer, previous_peer,
|
|
sizeof(_prefs.bluetooth_stealth_peer));
|
|
return false;
|
|
}
|
|
|
|
bool MyMesh::saveBluetoothMac(uint8_t mode, const uint8_t* address) {
|
|
if (!mesh::companion::isValidBluetoothMacMode(mode)) return false;
|
|
if (mesh::companion::bluetoothMacModeUsesSavedAddress(mode)
|
|
&& !mesh::companion::isValidBluetoothMac(address)) {
|
|
return false;
|
|
}
|
|
|
|
if (_prefs.bluetooth_mac_mode == mode
|
|
&& (!mesh::companion::bluetoothMacModeUsesSavedAddress(mode)
|
|
|| memcmp(_prefs.bluetooth_mac, address,
|
|
sizeof(_prefs.bluetooth_mac)) == 0)) {
|
|
return true;
|
|
}
|
|
|
|
const uint8_t previous_mode = _prefs.bluetooth_mac_mode;
|
|
const uint8_t previous_stealth_mode = _prefs.bluetooth_stealth_mode;
|
|
uint8_t previous_address[mesh::companion::BLUETOOTH_MAC_BYTES];
|
|
const uint8_t previous_peer_type = _prefs.bluetooth_stealth_peer_type;
|
|
uint8_t previous_peer[mesh::companion::BLUETOOTH_MAC_BYTES];
|
|
memcpy(previous_address, _prefs.bluetooth_mac, sizeof(previous_address));
|
|
memcpy(previous_peer, _prefs.bluetooth_stealth_peer,
|
|
sizeof(previous_peer));
|
|
|
|
_prefs.bluetooth_mac_mode = mode;
|
|
memset(_prefs.bluetooth_mac, 0, sizeof(_prefs.bluetooth_mac));
|
|
if (mesh::companion::bluetoothMacModeUsesSavedAddress(mode)) {
|
|
memcpy(_prefs.bluetooth_mac, address, sizeof(_prefs.bluetooth_mac));
|
|
}
|
|
clearCompanionBluetoothStealthPeer(_prefs);
|
|
|
|
if (savePrefs()) return true;
|
|
_prefs.bluetooth_mac_mode = previous_mode;
|
|
_prefs.bluetooth_stealth_mode = previous_stealth_mode;
|
|
memcpy(_prefs.bluetooth_mac, previous_address, sizeof(_prefs.bluetooth_mac));
|
|
_prefs.bluetooth_stealth_peer_type = previous_peer_type;
|
|
memcpy(_prefs.bluetooth_stealth_peer, previous_peer,
|
|
sizeof(_prefs.bluetooth_stealth_peer));
|
|
return false;
|
|
}
|
|
|
|
bool MyMesh::applyAndSaveBluetoothMac(const char* value, char* reply,
|
|
size_t reply_size) {
|
|
if (reply == NULL || reply_size == 0) return false;
|
|
if (value == NULL) {
|
|
mesh::cli::formatError(reply, reply_size, "use set bluetooth.mac <address|random|random-every-boot|random-after-connect|default>");
|
|
return false;
|
|
}
|
|
|
|
uint8_t mode = mesh::companion::BLUETOOTH_MAC_DEFAULT;
|
|
uint8_t address[mesh::companion::BLUETOOTH_MAC_BYTES] = {};
|
|
if (value[0] == 0 || strcmp(value, "default") == 0
|
|
|| strcmp(value, "clear") == 0) {
|
|
mode = mesh::companion::BLUETOOTH_MAC_DEFAULT;
|
|
} else if (strcmp(value, "random") == 0) {
|
|
mode = mesh::companion::BLUETOOTH_MAC_RANDOM_SAVED;
|
|
getRNG()->random(address, sizeof(address));
|
|
mesh::companion::makeRandomStaticBluetoothMac(address);
|
|
} else if (strcmp(value, "random-every-boot") == 0
|
|
|| strcmp(value, "random-everyboot") == 0
|
|
|| strcmp(value, "random_every_boot") == 0
|
|
|| strcmp(value, "random everyboot") == 0
|
|
|| strcmp(value, "random every boot") == 0
|
|
|| strcmp(value, "everyboot") == 0) {
|
|
mode = mesh::companion::BLUETOOTH_MAC_RANDOM_EVERY_BOOT;
|
|
} else if (strcmp(value, "random-after-connect") == 0
|
|
|| strcmp(value, "random-after-connection") == 0
|
|
|| strcmp(value, "random after connect") == 0
|
|
|| strcmp(value, "random after connection") == 0
|
|
|| strcmp(value, "random-on-reboot-after-connect") == 0) {
|
|
mode = mesh::companion::BLUETOOTH_MAC_RANDOM_AFTER_CONNECT;
|
|
getRNG()->random(address, sizeof(address));
|
|
mesh::companion::makeRandomStaticBluetoothMac(address);
|
|
} else {
|
|
const char* custom_value = value;
|
|
if (strncmp(custom_value, "custom", 6) == 0
|
|
&& (custom_value[6] == ' ' || custom_value[6] == '\t')) {
|
|
custom_value += 7;
|
|
while (*custom_value == ' ' || *custom_value == '\t') custom_value++;
|
|
}
|
|
if (!mesh::companion::parseBluetoothMac(custom_value, address)) {
|
|
mesh::cli::formatError(reply, reply_size, "use a random-static address (first byte C0-FF), random, random-every-boot, random-after-connect, or default");
|
|
return false;
|
|
}
|
|
mode = mesh::companion::BLUETOOTH_MAC_CUSTOM;
|
|
}
|
|
|
|
#if defined(RP2040_PLATFORM)
|
|
if (mode != mesh::companion::BLUETOOTH_MAC_DEFAULT) {
|
|
mesh::cli::formatError(reply, reply_size, "Bluetooth address overrides are not supported on Pico W");
|
|
return false;
|
|
}
|
|
#endif
|
|
|
|
if (!saveBluetoothMac(mode, address)) {
|
|
mesh::cli::formatError(reply, reply_size, "Bluetooth MAC save failed");
|
|
return false;
|
|
}
|
|
|
|
char formatted[mesh::companion::BLUETOOTH_MAC_TEXT_SIZE];
|
|
if (mode == mesh::companion::BLUETOOTH_MAC_DEFAULT) {
|
|
snprintf(reply, reply_size,
|
|
"OK - factory Bluetooth address restored; reboot and re-pair");
|
|
} else if (mode == mesh::companion::BLUETOOTH_MAC_RANDOM_EVERY_BOOT) {
|
|
snprintf(reply, reply_size,
|
|
"OK - Bluetooth address will randomize every boot; reboot and pair each time");
|
|
} else if (mesh::companion::bluetoothMacModeIsRandomAfterConnect(mode)) {
|
|
mesh::companion::formatBluetoothMac(address, formatted,
|
|
sizeof(formatted));
|
|
snprintf(reply, reply_size,
|
|
"OK - Bluetooth address saved as %s; it will rotate on the first boot after a connection",
|
|
formatted);
|
|
} else {
|
|
mesh::companion::formatBluetoothMac(address, formatted,
|
|
sizeof(formatted));
|
|
snprintf(reply, reply_size,
|
|
"OK - Bluetooth address saved as %s; reboot and re-pair",
|
|
formatted);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
void MyMesh::formatBluetoothMacStatus(char* reply, size_t reply_size) const {
|
|
if (reply == NULL || reply_size == 0) return;
|
|
|
|
const uint8_t mode = _prefs.bluetooth_mac_mode;
|
|
if (!mesh::companion::isValidBluetoothMacMode(mode)) {
|
|
snprintf(reply, reply_size,
|
|
"> factory address (invalid saved mode ignored)");
|
|
} else if (mode == mesh::companion::BLUETOOTH_MAC_DEFAULT) {
|
|
snprintf(reply, reply_size, "> factory address (default)");
|
|
} else if (mode == mesh::companion::BLUETOOTH_MAC_RANDOM_EVERY_BOOT) {
|
|
snprintf(reply, reply_size, "> random-every-boot (pair after each reboot)");
|
|
} else if (!mesh::companion::isValidBluetoothMac(
|
|
_prefs.bluetooth_mac)) {
|
|
snprintf(reply, reply_size,
|
|
"> factory address (invalid saved address ignored)");
|
|
} else if (mesh::companion::bluetoothMacModeIsRandomAfterConnect(mode)) {
|
|
char formatted[mesh::companion::BLUETOOTH_MAC_TEXT_SIZE];
|
|
mesh::companion::formatBluetoothMac(
|
|
_prefs.bluetooth_mac, formatted, sizeof(formatted));
|
|
snprintf(
|
|
reply, reply_size, "> %s (random-after-connect; %s)", formatted,
|
|
mode == mesh::companion::BLUETOOTH_MAC_RANDOM_AFTER_CONNECT_ARMED
|
|
? "rotation armed for next boot"
|
|
: "retained until a successful connection");
|
|
} else {
|
|
char formatted[mesh::companion::BLUETOOTH_MAC_TEXT_SIZE];
|
|
mesh::companion::formatBluetoothMac(
|
|
_prefs.bluetooth_mac, formatted, sizeof(formatted));
|
|
snprintf(reply, reply_size, "> %s (%s)", formatted,
|
|
mode == mesh::companion::BLUETOOTH_MAC_CUSTOM
|
|
? "custom" : "saved random");
|
|
}
|
|
}
|
|
|
|
bool MyMesh::applyAndSaveBluetoothStealth(const char* value, char* reply,
|
|
size_t reply_size) {
|
|
if (reply == NULL || reply_size == 0) return false;
|
|
if (value == NULL
|
|
|| (strcmp(value, "on") != 0 && strcmp(value, "off") != 0)) {
|
|
mesh::cli::formatError(reply, reply_size, "use set bluetooth.stealth on|off");
|
|
return false;
|
|
}
|
|
|
|
#if defined(RP2040_PLATFORM)
|
|
if (strcmp(value, "on") == 0) {
|
|
mesh::cli::formatError(reply, reply_size, "Bluetooth stealth is not supported on Pico W");
|
|
return false;
|
|
}
|
|
#endif
|
|
|
|
const uint8_t previous_mode = _prefs.bluetooth_stealth_mode;
|
|
const uint8_t previous_type = _prefs.bluetooth_stealth_peer_type;
|
|
uint8_t previous_peer[mesh::companion::BLUETOOTH_MAC_BYTES];
|
|
memcpy(previous_peer, _prefs.bluetooth_stealth_peer, sizeof(previous_peer));
|
|
if (setCompanionBluetoothStealth(_prefs, strcmp(value, "on") == 0)
|
|
&& !savePrefs()) {
|
|
_prefs.bluetooth_stealth_mode = previous_mode;
|
|
_prefs.bluetooth_stealth_peer_type = previous_type;
|
|
memcpy(_prefs.bluetooth_stealth_peer, previous_peer, sizeof(previous_peer));
|
|
mesh::cli::formatError(reply, reply_size, "Bluetooth stealth save failed");
|
|
return false;
|
|
}
|
|
|
|
snprintf(reply, reply_size,
|
|
"OK - Bluetooth stealth %s saved; MAC policy unchanged; reboot to apply",
|
|
value);
|
|
return true;
|
|
}
|
|
|
|
void MyMesh::formatBluetoothStealthStatus(char* reply, size_t reply_size) const {
|
|
if (reply == NULL || reply_size == 0) return;
|
|
if (!mesh::companion::bluetoothStealthEnabled(_prefs.bluetooth_stealth_mode)) {
|
|
snprintf(reply, reply_size, "> off");
|
|
} else {
|
|
snprintf(reply, reply_size, "> on (%s)",
|
|
_prefs.bluetooth_stealth_mode == mesh::companion::BLUETOOTH_STEALTH_PAIRED
|
|
? "bonded-peer-only advertising"
|
|
: "discoverable until first authenticated pairing");
|
|
}
|
|
}
|
|
#endif
|
|
|
|
#if defined(MESH_PRIMARY_ESPNOW) && MESH_PRIMARY_ESPNOW
|
|
void MyMesh::formatEspNowChannel(char* reply, size_t reply_size) const {
|
|
if (reply == NULL || reply_size == 0) return;
|
|
const uint8_t saved = mesh::wifi::loadConfiguredEspNowChannel();
|
|
const uint8_t active = mesh::wifi::activeEspNowChannel();
|
|
if (saved == active) {
|
|
snprintf(reply, reply_size, "> %u (saved and active)",
|
|
(unsigned)saved);
|
|
} else {
|
|
snprintf(reply, reply_size,
|
|
"> saved %u, active %u; reboot required",
|
|
(unsigned)saved, (unsigned)active);
|
|
}
|
|
}
|
|
|
|
bool MyMesh::applyAndSaveEspNowChannel(const char* value, char* reply,
|
|
size_t reply_size) {
|
|
if (reply == NULL || reply_size == 0) return false;
|
|
uint8_t channel = 0;
|
|
if (!mesh::wifi::parseEspNowChannel(value, channel)) {
|
|
mesh::cli::formatError(reply, reply_size, "ESP-NOW channel must be 1-13");
|
|
return false;
|
|
}
|
|
// Snapshot the boot channel before writing NVS so even a CLI-only image
|
|
// cannot reinterpret the newly-saved setting as active in this boot.
|
|
const uint8_t active = mesh::wifi::activeEspNowChannel();
|
|
if (!mesh::wifi::saveConfiguredEspNowChannel(channel)) {
|
|
mesh::cli::formatError(reply, reply_size, "failed to save ESP-NOW channel");
|
|
return false;
|
|
}
|
|
|
|
if (channel == active) {
|
|
snprintf(reply, reply_size,
|
|
"OK - ESP-NOW channel %u saved and active",
|
|
(unsigned)channel);
|
|
} else {
|
|
snprintf(reply, reply_size,
|
|
"OK - ESP-NOW channel %u saved; active %u; reboot required",
|
|
(unsigned)channel, (unsigned)active);
|
|
}
|
|
return true;
|
|
}
|
|
#endif
|
|
|
|
bool MyMesh::applyAndSavePowerSaving(const char* value, char* reply) {
|
|
bool enabled;
|
|
if (strcmp(value, "on") == 0) {
|
|
enabled = true;
|
|
} else if (strcmp(value, "off") == 0) {
|
|
enabled = false;
|
|
} else {
|
|
mesh::cli::formatError(reply, 160, "use set powersaving on|off");
|
|
return false;
|
|
}
|
|
|
|
if (!savePreference(_prefs.powersaving_enabled, static_cast<uint8_t>(enabled))) {
|
|
mesh::cli::formatError(reply, 160, "power saving could not be saved");
|
|
return false;
|
|
}
|
|
sensors.setPowerSavingEnabled(enabled);
|
|
snprintf(reply, 160, "OK - powersaving %s", enabled ? "on" : "off");
|
|
return true;
|
|
}
|
|
|
|
#if defined(ESP32) && defined(WIFI_SSID)
|
|
void MyMesh::syncWiFiPowerSaving() {
|
|
const uint8_t mode = getCompanionWiFiPowerSave();
|
|
#ifdef WITH_MQTT_BRIDGE
|
|
_mqtt_prefs.wifi_power_save = mode;
|
|
#endif
|
|
#ifdef WITH_WEBCONFIG
|
|
if (_webconfig) _webconfig->reloadStandaloneWiFi();
|
|
#endif
|
|
}
|
|
|
|
void MyMesh::formatWiFiPowerSaving(char* reply, size_t reply_size) const {
|
|
if (!reply || reply_size == 0) return;
|
|
snprintf(reply, reply_size, "> %s", getCompanionWiFiPowerSaveName());
|
|
}
|
|
|
|
bool MyMesh::applyAndSaveWiFiPowerSaving(const char* value, char* reply,
|
|
size_t reply_size) {
|
|
if (!reply || reply_size == 0) return false;
|
|
uint8_t mode = mesh::wifi::kDefaultPowerSave;
|
|
if (value && strcmp(value, "min") == 0) {
|
|
mode = mesh::wifi::kPowerSaveMin;
|
|
} else if (value && strcmp(value, "none") == 0) {
|
|
mode = mesh::wifi::kPowerSaveNone;
|
|
} else if (value && strcmp(value, "max") == 0) {
|
|
mode = mesh::wifi::kPowerSaveMax;
|
|
} else {
|
|
mesh::cli::formatError(reply, reply_size, "power save must be none, min, or max");
|
|
return false;
|
|
}
|
|
|
|
const CompanionWiFiPowerSaveResult result =
|
|
setCompanionWiFiPowerSave(mode);
|
|
if (result == CompanionWiFiPowerSaveResult::PrimaryEspNowConflict) {
|
|
mesh::cli::formatError(reply, reply_size, "power save max is unavailable while ESP-NOW is the primary radio");
|
|
return false;
|
|
}
|
|
if (result == CompanionWiFiPowerSaveResult::BluetoothConflict) {
|
|
mesh::cli::formatError(reply, reply_size, "power save none is unavailable while Bluetooth is active");
|
|
return false;
|
|
}
|
|
if (result == CompanionWiFiPowerSaveResult::InvalidMode) {
|
|
mesh::cli::formatError(reply, reply_size, "power save must be none, min, or max");
|
|
return false;
|
|
}
|
|
if (result == CompanionWiFiPowerSaveResult::StorageError) {
|
|
mesh::cli::formatError(reply, reply_size, "failed to save WiFi power save");
|
|
return false;
|
|
}
|
|
if (result == CompanionWiFiPowerSaveResult::SavedForNextBoot) {
|
|
snprintf(reply, reply_size,
|
|
"OK - power save none saved for wifi-only boot; reboot required (current mode unchanged)");
|
|
return true;
|
|
}
|
|
|
|
syncWiFiPowerSaving();
|
|
if (result == CompanionWiFiPowerSaveResult::Applied) {
|
|
snprintf(reply, reply_size, "OK - WiFi power save set to %s", value);
|
|
} else {
|
|
snprintf(reply, reply_size,
|
|
"OK - saved; WiFi power save applies on next connection");
|
|
}
|
|
return true;
|
|
}
|
|
#endif
|
|
|
|
bool MyMesh::handleRxPowerSavingGetCommand(const char* command, char* reply,
|
|
size_t reply_size) const {
|
|
if (!command || !reply || reply_size == 0) return false;
|
|
const bool config = strcmp(command, "get radio.rxps.config") == 0;
|
|
if (!config && strcmp(command, "get radio.rxps") != 0) return false;
|
|
if (!radio_driver.supportsRxPowerSaving()) {
|
|
mesh::cli::formatError(reply, reply_size, "RX power saving is unsupported on this radio");
|
|
return true;
|
|
}
|
|
|
|
uint32_t rx_us = _prefs.rx_ps_rx_us;
|
|
uint32_t sleep_us = _prefs.rx_ps_sleep_us;
|
|
uint8_t effective_level = 0;
|
|
uint8_t effective_preamble = 0;
|
|
if (!config && _prefs.rx_ps_level != 0) {
|
|
uint8_t sf = _prefs.sf;
|
|
float bw = _prefs.bw;
|
|
#if COMPANION_FEATURE_TEMP_RADIO
|
|
if (isTempRadioActive()) {
|
|
sf = _temp_radio_sf;
|
|
bw = _temp_radio_bw;
|
|
}
|
|
#endif
|
|
// Calculate into locals only. A diagnostic must not change saved timings,
|
|
// apply radio parameters, or create a pending preference write.
|
|
recalcRxPowerSavingFromLevel(_prefs.rx_ps_level, sf, bw,
|
|
_prefs.rx_ps_preamble, &rx_us, &sleep_us,
|
|
&effective_level, &effective_preamble);
|
|
}
|
|
snprintf(reply, reply_size, "> %s,level=%u,preamble=%u,rx=%lu,sleep=%lu%s",
|
|
_prefs.rx_powersaving_enabled ? "on" : "off",
|
|
(unsigned)_prefs.rx_ps_level, (unsigned)_prefs.rx_ps_preamble,
|
|
(unsigned long)rx_us, (unsigned long)sleep_us,
|
|
!config && radio_driver.isRxPowerSavingContinuousFallback()
|
|
? ",mode=continuous-fast" : "");
|
|
size_t used = strlen(reply);
|
|
if (!config && effective_level != 0 && effective_level != _prefs.rx_ps_level
|
|
&& used < reply_size - 1) {
|
|
snprintf(reply + used, reply_size - used, ",effective-level=%u",
|
|
(unsigned)effective_level);
|
|
}
|
|
used = strlen(reply);
|
|
if (!config && effective_preamble != 0 && effective_preamble != _prefs.rx_ps_preamble
|
|
&& used < reply_size - 1) {
|
|
snprintf(reply + used, reply_size - used, ",effective-preamble=%u",
|
|
(unsigned)effective_preamble);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
bool MyMesh::applyAndSaveRxPowerSaving(const char* value, char* reply) {
|
|
if (!radio_driver.supportsRxPowerSaving()) {
|
|
mesh::cli::formatError(reply, 160, "RXPS unsupported");
|
|
return false;
|
|
}
|
|
|
|
uint8_t enabled = _prefs.rx_powersaving_enabled;
|
|
uint8_t level = _prefs.rx_ps_level;
|
|
uint8_t preamble = _prefs.rx_ps_preamble;
|
|
uint32_t rx_us = _prefs.rx_ps_rx_us;
|
|
uint32_t sleep_us = _prefs.rx_ps_sleep_us;
|
|
bool level_requested = false;
|
|
bool manual_requested = false;
|
|
|
|
mesh::cli::RxPowerSavingArguments parsed = {};
|
|
if (!mesh::cli::parseRxPowerSavingArgumentsStrict(value, parsed)) {
|
|
mesh::cli::formatError(reply, 160, "use off, level 1-10, or RX_US SLEEP_US");
|
|
return false;
|
|
}
|
|
|
|
if (parsed.mode == mesh::cli::RxPowerSavingArgumentMode::Off) {
|
|
enabled = 0;
|
|
} else if (parsed.mode
|
|
== mesh::cli::RxPowerSavingArgumentMode::Conservative) {
|
|
enabled = 1;
|
|
level = RX_POWERSAVING_CONSERVATIVE_LEVEL;
|
|
preamble = RX_POWERSAVING_PROFILE_PREAMBLE;
|
|
level_requested = true;
|
|
} else if (parsed.mode == mesh::cli::RxPowerSavingArgumentMode::Balanced) {
|
|
enabled = 1;
|
|
level = RX_POWERSAVING_BALANCED_LEVEL;
|
|
preamble = RX_POWERSAVING_PROFILE_PREAMBLE;
|
|
level_requested = true;
|
|
} else if (parsed.mode == mesh::cli::RxPowerSavingArgumentMode::Level) {
|
|
if (parsed.level < 1 || parsed.level > 10) {
|
|
strcpy(reply, parsed.preamble == 0
|
|
? "Error: level must be 1-10"
|
|
: "Error: level 1-10; preamble 16 or 32");
|
|
return false;
|
|
}
|
|
if (parsed.preamble != 0
|
|
&& parsed.preamble != 16 && parsed.preamble != 32) {
|
|
mesh::cli::formatError(reply, 160, "level 1-10; preamble 16 or 32");
|
|
return false;
|
|
}
|
|
enabled = 1;
|
|
level = static_cast<uint8_t>(parsed.level);
|
|
preamble = static_cast<uint8_t>(parsed.preamble);
|
|
level_requested = true;
|
|
} else if (parsed.mode == mesh::cli::RxPowerSavingArgumentMode::Manual) {
|
|
if (parsed.rx_us < RX_POWERSAVING_MIN_MANUAL_PERIOD_US
|
|
|| parsed.rx_us > RX_POWERSAVING_MAX_PERIOD_US
|
|
|| parsed.sleep_us < RX_POWERSAVING_MIN_MANUAL_PERIOD_US
|
|
|| parsed.sleep_us > RX_POWERSAVING_MAX_PERIOD_US) {
|
|
snprintf(reply, 160, "Error: RX/SLEEP %lu-%lu us",
|
|
(unsigned long)RX_POWERSAVING_MIN_MANUAL_PERIOD_US,
|
|
(unsigned long)RX_POWERSAVING_MAX_PERIOD_US);
|
|
return false;
|
|
}
|
|
enabled = 1;
|
|
rx_us = parsed.rx_us;
|
|
sleep_us = parsed.sleep_us;
|
|
level = 0;
|
|
preamble = 0;
|
|
manual_requested = true;
|
|
}
|
|
|
|
if (level_requested) {
|
|
if (level < 1 || level > 10
|
|
|| (preamble != 0 && preamble != 16 && preamble != 32)
|
|
|| !recalcRxPowerSavingFromLevel(level, _prefs.sf, _prefs.bw, preamble,
|
|
&rx_us, &sleep_us)) {
|
|
mesh::cli::formatError(reply, 160, "level 1-10; preamble auto, 16 or 32");
|
|
return false;
|
|
}
|
|
}
|
|
if ((manual_requested || enabled)
|
|
&& (!isValidRxPowerSavingPeriod(rx_us)
|
|
|| !isValidRxPowerSavingPeriod(sleep_us))) {
|
|
snprintf(reply, 160, "Error: RX/SLEEP %lu-%lu us",
|
|
(unsigned long)RX_POWERSAVING_MIN_MANUAL_PERIOD_US,
|
|
(unsigned long)RX_POWERSAVING_MAX_PERIOD_US);
|
|
return false;
|
|
}
|
|
|
|
const auto result = _radio_available && !_primary_radio_blocked
|
|
? radio_driver.trySetRxPowerSaving(enabled != 0, rx_us, sleep_us)
|
|
: mesh::RadioParamApplyResult::BUSY;
|
|
if (result == mesh::RadioParamApplyResult::FAILED) {
|
|
queueSavedRadioApply();
|
|
mesh::cli::formatError(reply, 160, "RXPS rejected");
|
|
return false;
|
|
}
|
|
|
|
const uint8_t previous_enabled = _prefs.rx_powersaving_enabled;
|
|
const uint8_t previous_level = _prefs.rx_ps_level;
|
|
const uint8_t previous_preamble = _prefs.rx_ps_preamble;
|
|
const uint32_t previous_rx = _prefs.rx_ps_rx_us, previous_sleep = _prefs.rx_ps_sleep_us;
|
|
_prefs.rx_powersaving_enabled = enabled;
|
|
_prefs.rx_ps_rx_us = rx_us;
|
|
_prefs.rx_ps_sleep_us = sleep_us;
|
|
_prefs.rx_ps_level = level;
|
|
_prefs.rx_ps_preamble = preamble;
|
|
if (!savePrefs()) {
|
|
_prefs.rx_powersaving_enabled = previous_enabled;
|
|
_prefs.rx_ps_rx_us = previous_rx;
|
|
_prefs.rx_ps_sleep_us = previous_sleep;
|
|
_prefs.rx_ps_level = previous_level;
|
|
_prefs.rx_ps_preamble = previous_preamble;
|
|
if (result == mesh::RadioParamApplyResult::APPLIED
|
|
&& !radio_driver.setRxPowerSaving(previous_enabled != 0, previous_rx, previous_sleep))
|
|
queueSavedRadioApply();
|
|
mesh::cli::formatError(reply, 160, "RXPS not saved");
|
|
return false;
|
|
}
|
|
if (result == mesh::RadioParamApplyResult::BUSY) queueSavedRadioApply();
|
|
snprintf(reply, 160, "OK - %s,%lu,%lu",
|
|
enabled ? "on" : "off", (unsigned long)rx_us,
|
|
(unsigned long)sleep_us);
|
|
return true;
|
|
}
|
|
|
|
void MyMesh::appendRxPowerSavingAdjustmentNote(char* reply, size_t reply_size,
|
|
uint8_t sf, float bw) const {
|
|
if (!reply || reply_size == 0 || !_prefs.rx_powersaving_enabled
|
|
|| _prefs.rx_ps_level < 1 || _prefs.rx_ps_level > 10) {
|
|
return;
|
|
}
|
|
|
|
uint32_t rx_us = 0;
|
|
uint32_t sleep_us = 0;
|
|
uint8_t effective_level = 0;
|
|
uint8_t effective_preamble = 0;
|
|
if (!recalcRxPowerSavingFromLevel(
|
|
_prefs.rx_ps_level, sf, bw, _prefs.rx_ps_preamble,
|
|
&rx_us, &sleep_us, &effective_level, &effective_preamble)) {
|
|
return;
|
|
}
|
|
|
|
const size_t used = strlen(reply);
|
|
if (used >= reply_size - 1) return;
|
|
|
|
if (rxPowerSavingUsesContinuousFallback(rx_us, sleep_us)) {
|
|
snprintf(reply + used, reply_size - used,
|
|
"; RXPS continuous-fast (requested level %u)",
|
|
(unsigned)_prefs.rx_ps_level);
|
|
return;
|
|
}
|
|
|
|
const uint8_t requested_preamble = _prefs.rx_ps_preamble == 0
|
|
? rxPowerSavingPreambleForParams(sf, bw) : _prefs.rx_ps_preamble;
|
|
if (effective_level == _prefs.rx_ps_level
|
|
&& effective_preamble == requested_preamble) {
|
|
return;
|
|
}
|
|
|
|
snprintf(reply + used, reply_size - used,
|
|
"; RXPS effective level %u, preamble %u (saved minimum %u)",
|
|
(unsigned)effective_level, (unsigned)effective_preamble,
|
|
(unsigned)_prefs.rx_ps_level);
|
|
}
|
|
|
|
#if COMPANION_FEATURE_TEXT_TERMINAL
|
|
Stream& MyMesh::terminalOutput() {
|
|
if (isTerminalWaitingForInput()) return mesh::usbTerminalPort(false);
|
|
return _terminal_output != NULL ? *_terminal_output : Serial;
|
|
}
|
|
|
|
void MyMesh::resetTerminalSession() {
|
|
#if COMPANION_FEATURE_READER
|
|
mesh::cancelReaderCommand();
|
|
#endif
|
|
_terminal_input_editing = false;
|
|
_morse_terminal_owner = nullptr;
|
|
_morse_terminal_pending_chars = 0;
|
|
_morse_terminal_was_receiving = false;
|
|
_terminal_recipient_set = false;
|
|
memset(_terminal_recipient_key, 0, sizeof(_terminal_recipient_key));
|
|
clearTerminalLogin();
|
|
clearTerminalCommand();
|
|
clearTerminalTrace();
|
|
|
|
// A reply from an abandoned TCP or USB terminal session must not be shown
|
|
// to whichever terminal connects next. Keep the radio retry alive for the
|
|
// Binary Companion interface, but remove its old text-terminal ownership.
|
|
for (int i = 0; i < EXPECTED_ACK_TABLE_SIZE; i++) {
|
|
expected_ack_table[i].terminal_origin = false;
|
|
}
|
|
}
|
|
|
|
void MyMesh::serviceMorseTerminal() {
|
|
// Only an explicitly owned ASCII terminal receives spontaneous text.
|
|
// Never use terminalOutput()'s Serial fallback or append to BLE/USB frames.
|
|
if (!_radio || !hasTerminalOutput()) {
|
|
_morse_terminal_owner = nullptr;
|
|
_morse_terminal_pending_chars = 0;
|
|
_morse_terminal_was_receiving = false;
|
|
return;
|
|
}
|
|
// A temporarily silent/paused terminal still owns its cursor. Resuming
|
|
// must not replay the capture or reset a pending partial line.
|
|
if (!_terminal_morse_atomic || isTerminalWaitingForInput()
|
|
|| _terminal_input_editing || _terminal_output->available() > 0
|
|
|| _terminal_login_pending || _terminal_trace_pending
|
|
|| _terminal_command.isPending()) return;
|
|
#if COMPANION_FEATURE_READER
|
|
// A chapter cursor owns its entire logical output, including pauses under
|
|
// backpressure. Progress must not split a header, verse or final prompt.
|
|
if (mesh::readerCommandActive(_terminal_output)) return;
|
|
#endif
|
|
// The existing USB facade owns all queued suffixes, including replies and
|
|
// echo. Never append progress in the middle of an earlier text producer.
|
|
if (_terminal_mode && _terminal_output == &mesh::usbTerminalPort()
|
|
&& mesh::hasPendingUsbTerminalOutput()) return;
|
|
const bool receiving = _radio->isMorseRxActive();
|
|
const uint32_t generation = _radio->morseCaptureGeneration();
|
|
const size_t total = _radio->morseCaptureLength();
|
|
const uint32_t now = _ms->getMillis();
|
|
if (_morse_terminal_owner != _terminal_output || _morse_terminal_generation != generation) {
|
|
_morse_terminal_owner = _terminal_output;
|
|
_morse_terminal_generation = generation;
|
|
_morse_terminal_offset = receiving ? 0 : total;
|
|
_morse_terminal_pending_chars = 0;
|
|
_morse_terminal_was_receiving = receiving;
|
|
_morse_terminal_next_at = now;
|
|
}
|
|
if (receiving) _morse_terminal_was_receiving = true;
|
|
if (!_morse_terminal_was_receiving) return;
|
|
if (!_morse_terminal_pending_chars) {
|
|
if (_morse_terminal_offset >= total) {
|
|
if (!receiving) _morse_terminal_was_receiving = false;
|
|
return;
|
|
}
|
|
if (int32_t(now - _morse_terminal_next_at) < 0) return;
|
|
const size_t pending = total - _morse_terminal_offset;
|
|
_morse_terminal_pending_chars = pending < 48 ? pending : 48;
|
|
}
|
|
// Admit one complete record to an explicitly atomic stream. Its existing
|
|
// queue handles hardware short writes in chronological order with replies;
|
|
// Morse never retains an application-level suffix that can overtake input.
|
|
char text[49], line[80];
|
|
const size_t copied = _radio->readMorseCapture(_morse_terminal_offset, text,
|
|
size_t(_morse_terminal_pending_chars) + 1);
|
|
if (copied != _morse_terminal_pending_chars) {
|
|
_morse_terminal_pending_chars = 0;
|
|
return;
|
|
}
|
|
const int length = snprintf(line, sizeof(line), "\r\n[Morse RX] %s\r\n> ", text);
|
|
if (length <= 0 || size_t(length) >= sizeof(line)) return;
|
|
const int available = _terminal_output->availableForWrite();
|
|
if (available < length) return;
|
|
const size_t written = _terminal_output->write(
|
|
reinterpret_cast<const uint8_t*>(line), length);
|
|
if (written == size_t(length)) {
|
|
_morse_terminal_offset += _morse_terminal_pending_chars;
|
|
_morse_terminal_pending_chars = 0;
|
|
_morse_terminal_next_at = now + 250;
|
|
} else if (written) {
|
|
// A stream that violates its atomic admission contract is no longer safe
|
|
// for unsolicited progress. Keep the full capture available via CLI.
|
|
_terminal_morse_atomic = false;
|
|
_morse_terminal_pending_chars = 0;
|
|
}
|
|
}
|
|
|
|
void MyMesh::printTerminalBanner(bool show_binary_stop) {
|
|
Stream& output = terminalOutput();
|
|
#if COMPANION_FEATURE_TEMP_RADIO || COMPANION_FEATURE_NETWORK_TERMINAL
|
|
output.print("\r\n===== MeshCore Full Companion Terminal =====\r\n\r\n");
|
|
#else
|
|
output.print("\r\n===== MeshCore Companion Terminal =====\r\n\r\n");
|
|
#endif
|
|
output.printf("WELCOME %s\r\n", _prefs.node_name);
|
|
mesh::Utils::printHex(output, self_id.pub_key, PUB_KEY_SIZE);
|
|
output.printf("\r\nCompanion %s\r\n", FIRMWARE_VERSION);
|
|
output.print(" (enter 'help' for commands)\r\n");
|
|
if (show_binary_stop) {
|
|
output.print(" (+++MESHCORE-TERM-STOP returns to Binary mode)\r\n");
|
|
} else {
|
|
output.print(" (disconnect to close this network terminal)\r\n");
|
|
}
|
|
output.print("\r\n> ");
|
|
}
|
|
|
|
void MyMesh::enterTerminalMode(bool show_banner) {
|
|
_terminal_mode = true;
|
|
_terminal_usb_silent = !show_banner;
|
|
_terminal_output = &mesh::usbTerminalPort();
|
|
#if MESH_ESP32_TINYUSB_NONBLOCKING \
|
|
|| (defined(NRF52_PLATFORM) && (defined(ENABLE_USB_INTERFACE) || defined(USE_TINYUSB)))
|
|
_terminal_morse_atomic = true;
|
|
#else
|
|
_terminal_morse_atomic = false;
|
|
#endif
|
|
resetTerminalSession();
|
|
if (show_banner) printTerminalBanner(true);
|
|
}
|
|
|
|
void MyMesh::exitTerminalMode() {
|
|
_terminal_mode = false;
|
|
_terminal_usb_silent = false;
|
|
_terminal_morse_atomic = false;
|
|
resetTerminalSession();
|
|
if (_terminal_output == &mesh::usbTerminalPort()) _terminal_output = NULL;
|
|
}
|
|
|
|
#if COMPANION_FEATURE_NETWORK_TERMINAL || defined(WITH_WEBCONFIG)
|
|
bool MyMesh::enterNetworkTerminalMode(Stream& output, bool atomic_output) {
|
|
if (_terminal_mode || (_terminal_output != NULL && _terminal_output != &output)) return false;
|
|
_terminal_output = &output;
|
|
_terminal_morse_atomic = atomic_output;
|
|
resetTerminalSession();
|
|
printTerminalBanner(false);
|
|
return true;
|
|
}
|
|
|
|
void MyMesh::exitNetworkTerminalMode(Stream& output) {
|
|
if (_terminal_mode || _terminal_output != &output) return;
|
|
resetTerminalSession();
|
|
_terminal_morse_atomic = false;
|
|
_terminal_output = NULL;
|
|
}
|
|
|
|
bool MyMesh::isNetworkTerminalMode(const Stream& output) const {
|
|
return !_terminal_mode && _terminal_output == &output;
|
|
}
|
|
#endif
|
|
|
|
ContactInfo* MyMesh::getTerminalRecipient() {
|
|
if (!_terminal_recipient_set) return NULL;
|
|
|
|
ContactInfo* recipient = lookupContactByPubKey(_terminal_recipient_key, PUB_KEY_SIZE);
|
|
if (recipient == NULL) {
|
|
_terminal_recipient_set = false;
|
|
memset(_terminal_recipient_key, 0, sizeof(_terminal_recipient_key));
|
|
}
|
|
return recipient;
|
|
}
|
|
|
|
void MyMesh::printTerminalPath(const ContactInfo& recipient) {
|
|
terminalOutput().printf(" Path to %s: ", recipient.name);
|
|
if (recipient.out_path_len == OUT_PATH_UNKNOWN) {
|
|
terminalOutput().print("unknown (next send uses FLOOD)\r\n");
|
|
return;
|
|
}
|
|
if (!mesh::Packet::isValidPathLen(recipient.out_path_len)) {
|
|
terminalOutput().print("invalid\r\n");
|
|
return;
|
|
}
|
|
|
|
const uint8_t hash_size = (recipient.out_path_len >> 6) + 1;
|
|
const uint8_t hop_count = recipient.out_path_len & 63;
|
|
if (hop_count == 0) {
|
|
terminalOutput().print("direct (zero hop)\r\n");
|
|
return;
|
|
}
|
|
|
|
uint8_t path_bytes[MAX_PATH_SIZE];
|
|
if (!recipient.copyPathTo(path_bytes)) {
|
|
terminalOutput().print("unavailable (storage read failed)\r\n");
|
|
return;
|
|
}
|
|
for (uint8_t hop = 0; hop < hop_count; hop++) {
|
|
if (hop != 0) terminalOutput().print(',');
|
|
mesh::Utils::printHex(terminalOutput(),
|
|
&path_bytes[(size_t)hop * hash_size],
|
|
hash_size);
|
|
}
|
|
terminalOutput().printf(" (%u %s, %u-byte hashes; used by DIRECT sends)\r\n",
|
|
(unsigned)hop_count, hop_count == 1 ? "hop" : "hops",
|
|
(unsigned)hash_size);
|
|
}
|
|
|
|
void MyMesh::handleTerminalPath(ContactInfo& recipient,
|
|
const char* path_spec) {
|
|
if (path_spec == NULL) {
|
|
printTerminalPath(recipient);
|
|
return;
|
|
}
|
|
|
|
mesh::cli::TerminalPath path;
|
|
const mesh::cli::TerminalPathParseResult parsed =
|
|
mesh::cli::parseTerminalPath(path_spec, _terminal_tmp_buf,
|
|
MAX_PATH_SIZE, 63, path);
|
|
switch (parsed) {
|
|
case mesh::cli::TerminalPathParseResult::Valid:
|
|
break;
|
|
case mesh::cli::TerminalPathParseResult::Missing:
|
|
terminalOutput().print(" ERROR: use path <direct|clear|hops separated by spaces or commas>\r\n");
|
|
return;
|
|
case mesh::cli::TerminalPathParseResult::InvalidPrefix:
|
|
terminalOutput().print(" ERROR: each path hop must be 2, 4, or 6 hex digits\r\n");
|
|
return;
|
|
case mesh::cli::TerminalPathParseResult::MixedPrefixSize:
|
|
terminalOutput().print(" ERROR: all path hops must use the same width\r\n");
|
|
return;
|
|
case mesh::cli::TerminalPathParseResult::InvalidSeparator:
|
|
terminalOutput().print(" ERROR: separate path hops with spaces or commas\r\n");
|
|
return;
|
|
case mesh::cli::TerminalPathParseResult::TooManyHops:
|
|
terminalOutput().print(" ERROR: path must contain at most 63 hops\r\n");
|
|
return;
|
|
case mesh::cli::TerminalPathParseResult::RouteTooLong:
|
|
terminalOutput().print(" ERROR: path is too long\r\n");
|
|
return;
|
|
}
|
|
|
|
const ContactInfo previous = recipient;
|
|
if (!recipient.setPath(_terminal_tmp_buf,
|
|
path.mode == mesh::cli::TerminalPathMode::Clear ? OUT_PATH_UNKNOWN : path.encoded_len)
|
|
|| !scheduleContactWrite(recipient)) {
|
|
recipient = previous;
|
|
terminalOutput().print(
|
|
" ERROR: contact storage is unavailable; reboot and retry\r\n");
|
|
return;
|
|
}
|
|
printTerminalPath(recipient);
|
|
}
|
|
|
|
void MyMesh::rememberTerminalAck(ContactInfo& recipient, const char* text,
|
|
uint32_t message_timestamp, uint32_t expected_ack,
|
|
uint32_t est_timeout,
|
|
const uint8_t packet_retry_key[MAX_HASH_SIZE],
|
|
AckTableEntry* replacement_entry) {
|
|
if (expected_ack == 0) {
|
|
if (replacement_entry != NULL) {
|
|
clearExpectedAck(*replacement_entry, false);
|
|
}
|
|
return;
|
|
}
|
|
|
|
AckTableEntry& entry = replacement_entry != NULL
|
|
? *replacement_entry
|
|
: expected_ack_table[next_ack_idx];
|
|
clearExpectedAck(entry, false);
|
|
entry.msg_sent = _ms->getMillis();
|
|
entry.expires_at = futureMillis(est_timeout);
|
|
entry.ack = expected_ack;
|
|
entry.message_timestamp = message_timestamp;
|
|
entry.contact = &recipient;
|
|
entry.reply_route = NULL;
|
|
mesh::Utils::sha256(entry.text_fingerprint, sizeof(entry.text_fingerprint),
|
|
recipient.id.pub_key, PUB_KEY_SIZE,
|
|
(const uint8_t*)text, strlen(text));
|
|
memcpy(entry.retry_key, packet_retry_key, sizeof(entry.retry_key));
|
|
entry.terminal_origin = true;
|
|
if (replacement_entry == NULL) {
|
|
next_ack_idx = (next_ack_idx + 1) % EXPECTED_ACK_TABLE_SIZE;
|
|
}
|
|
expireExpectedAcks();
|
|
}
|
|
|
|
void MyMesh::importTerminalCard(char* command) {
|
|
if (_store->hasIncompleteContactLoad()) {
|
|
terminalOutput().print(
|
|
" ERROR: contact storage is unavailable; reboot and retry\r\n");
|
|
return;
|
|
}
|
|
|
|
while (*command == ' ') command++;
|
|
if (strncmp(command, "meshcore://", 11) != 0) {
|
|
terminalOutput().print(" ERROR: invalid card format\r\n");
|
|
return;
|
|
}
|
|
|
|
char* encoded = command + 11;
|
|
char* end = encoded + strlen(encoded);
|
|
while (end > encoded && !mesh::Utils::isHexChar(end[-1])) {
|
|
*--end = 0;
|
|
}
|
|
|
|
size_t encoded_len = strlen(encoded);
|
|
if (encoded_len == 0 || (encoded_len & 1) != 0
|
|
|| encoded_len / 2 > sizeof(_terminal_tmp_buf)) {
|
|
terminalOutput().print(" ERROR: invalid card format\r\n");
|
|
return;
|
|
}
|
|
|
|
size_t raw_len = encoded_len / 2;
|
|
if (!mesh::Utils::fromHex(_terminal_tmp_buf, raw_len, encoded)
|
|
|| !importContact(_terminal_tmp_buf, raw_len)) {
|
|
terminalOutput().print(" ERROR: invalid card\r\n");
|
|
return;
|
|
}
|
|
|
|
terminalOutput().print(" OK - contact import queued\r\n");
|
|
}
|
|
|
|
void MyMesh::listTerminalChannels() {
|
|
bool found = false;
|
|
terminalOutput().print("Channels:\r\n");
|
|
for (int i = 0; i < MAX_GROUP_CHANNELS; i++) {
|
|
ChannelDetails channel;
|
|
if (getChannel(i, channel) && channel.name[0] != 0) {
|
|
terminalOutput().printf(" %d: %s\r\n", i, channel.name);
|
|
found = true;
|
|
}
|
|
}
|
|
if (!found) terminalOutput().print(" (none configured)\r\n");
|
|
}
|
|
|
|
void MyMesh::handleTerminalDisplayCommand(const char* arguments) {
|
|
mesh::TerminalDisplayCommand display_command;
|
|
const mesh::TerminalDisplayParseResult result =
|
|
mesh::parseTerminalDisplayCommand(arguments, display_command);
|
|
|
|
if (result == mesh::TerminalDisplayParseResult::InvalidCategory
|
|
|| result == mesh::TerminalDisplayParseResult::InvalidValue) {
|
|
terminalOutput().print(
|
|
" ERROR: use show [adverts|channels|emergency] [on|off]\r\n");
|
|
return;
|
|
}
|
|
|
|
if (result == mesh::TerminalDisplayParseResult::StatusAll) {
|
|
terminalOutput().printf(" Terminal display: adverts %s, channels %s, emergency %s\r\n",
|
|
_terminal_display.isEnabled(
|
|
mesh::TerminalDisplayCategory::Adverts) ? "on" : "off",
|
|
_terminal_display.isEnabled(
|
|
mesh::TerminalDisplayCategory::Channels) ? "on" : "off",
|
|
_terminal_display.isEnabled(
|
|
mesh::TerminalDisplayCategory::Emergency) ? "on" : "off");
|
|
return;
|
|
}
|
|
|
|
if (result == mesh::TerminalDisplayParseResult::Updated) {
|
|
_terminal_display.setEnabled(display_command.category,
|
|
display_command.enabled);
|
|
}
|
|
terminalOutput().printf(" Terminal display: %s %s\r\n",
|
|
mesh::terminalDisplayCategoryName(display_command.category),
|
|
_terminal_display.isEnabled(display_command.category)
|
|
? "on" : "off");
|
|
}
|
|
|
|
void MyMesh::sendTerminalChannelMessage(ChannelDetails& channel,
|
|
const char* text) {
|
|
#if MESH_ENABLE_MESHTEXT_CHAT
|
|
if (isMeshtextChannel(channel)) {
|
|
if (text && sendMeshtextText(channel, text, strlen(text))) {
|
|
terminalOutput().printf(" Queued to %s (Meshtext).\r\n", channel.name);
|
|
} else {
|
|
terminalOutput().printf(" ERROR: %s; use get mt.status\r\n", _mt_status);
|
|
}
|
|
return;
|
|
}
|
|
#endif
|
|
if (isAliasRadio2Defined()
|
|
&& parseCompanionChannelAlias(channel.name).kind == CompanionChannelKind::Meshtext) {
|
|
terminalOutput().print(" ERROR: Meshtext channel transport is not available\r\n");
|
|
return;
|
|
}
|
|
const size_t prefix_len = strlen(_prefs.node_name) + 2; // "name: "
|
|
const size_t max_text_len = prefix_len < MAX_TEXT_LEN
|
|
? MAX_TEXT_LEN - prefix_len : 0;
|
|
const size_t text_len = text == NULL ? 0 : strlen(text);
|
|
if (text_len == 0) {
|
|
terminalOutput().print(" ERROR: message is empty\r\n");
|
|
} else if (text_len > max_text_len) {
|
|
terminalOutput().printf(" ERROR: message must be 1-%u UTF-8 bytes for this node name\r\n",
|
|
(unsigned)max_text_len);
|
|
} else if (sendGroupMessage(getRTCClock()->getCurrentTimeUnique(),
|
|
channel.channel, _prefs.node_name, text,
|
|
text_len)) {
|
|
terminalOutput().printf(" Sent to %s.\r\n", channel.name);
|
|
} else {
|
|
terminalOutput().print(" ERROR: unable to send\r\n");
|
|
}
|
|
}
|
|
|
|
void MyMesh::printTerminalSendStatus(const char* operation,
|
|
const ContactInfo& recipient, int result,
|
|
uint32_t timeout_millis) {
|
|
terminalOutput().printf(" %s sent to %s (", operation, recipient.name);
|
|
if (result == MSG_SEND_SENT_FLOOD) {
|
|
terminalOutput().print("FLOOD");
|
|
} else {
|
|
const uint8_t hop_count = recipient.out_path_len & 63;
|
|
if (hop_count == 0) {
|
|
terminalOutput().print("zero-hop DIRECT");
|
|
} else {
|
|
const uint8_t hash_size = (recipient.out_path_len >> 6) + 1;
|
|
terminalOutput().print("DIRECT via path ");
|
|
uint8_t path_bytes[MAX_PATH_SIZE];
|
|
const bool path_ready = recipient.copyPathTo(path_bytes);
|
|
if (!path_ready) terminalOutput().print("unavailable");
|
|
for (uint8_t hop = 0; path_ready && hop < hop_count; hop++) {
|
|
if (hop != 0) terminalOutput().print(',');
|
|
mesh::Utils::printHex(
|
|
terminalOutput(), &path_bytes[(size_t)hop * hash_size], hash_size);
|
|
}
|
|
terminalOutput().printf("; %u %s, %u-byte hashes", (unsigned)hop_count,
|
|
hop_count == 1 ? "hop" : "hops", (unsigned)hash_size);
|
|
}
|
|
}
|
|
terminalOutput().printf(", timeout %lu ms)\r\n", (unsigned long)timeout_millis);
|
|
}
|
|
|
|
void MyMesh::clearTerminalLogin() {
|
|
clearTerminalLogin(_ms->getMillis());
|
|
}
|
|
|
|
void MyMesh::clearTerminalLogin(uint32_t now) {
|
|
if (_terminal_login_pending && _delayed_replies.request.terminal
|
|
&& _delayed_replies.request.kind == mesh::CompanionDelayedReplies::Login
|
|
&& _delayed_replies.requestMatches(_terminal_login_key, 0)) {
|
|
_delayed_replies.retireRequest(now);
|
|
}
|
|
_terminal_login_pending = false;
|
|
memset(_terminal_login_key, 0, sizeof(_terminal_login_key));
|
|
_terminal_login_expires_at = 0;
|
|
_terminal_login_target[0] = 0;
|
|
}
|
|
|
|
void MyMesh::serviceTerminalLogin() {
|
|
serviceTerminalLogin(_ms->getMillis());
|
|
}
|
|
|
|
void MyMesh::serviceTerminalLogin(uint32_t now) {
|
|
if (!_terminal_login_pending) return;
|
|
if ((int32_t)(now - (uint32_t)_terminal_login_expires_at) < 0) {
|
|
return;
|
|
}
|
|
|
|
if (hasTerminalOutput()) {
|
|
terminalOutput().printf("\r\n ERROR: login to %s timed out (wrong password or no response).\r\n> ",
|
|
_terminal_login_target);
|
|
}
|
|
clearTerminalLogin(now);
|
|
}
|
|
|
|
void MyMesh::sendTerminalLogin(ContactInfo& recipient,
|
|
const char* password) {
|
|
servicePendingSerialReply();
|
|
if (_terminal_login_pending) {
|
|
terminalOutput().printf(" ERROR: login to %s is still pending\r\n",
|
|
_terminal_login_target);
|
|
return;
|
|
}
|
|
if (hasPendingReqs()) {
|
|
terminalOutput().print(
|
|
" ERROR: another Companion request is still pending\r\n");
|
|
return;
|
|
}
|
|
|
|
const size_t password_len = password == NULL ? 0 : strlen(password);
|
|
if (password_len == 0 || password_len > 15) {
|
|
terminalOutput().print(" ERROR: password must be 1-15 UTF-8 bytes\r\n");
|
|
return;
|
|
}
|
|
|
|
if (!beginPendingRequest(mesh::CompanionDelayedReplies::Login, recipient, true)) {
|
|
terminalOutput().print(" ERROR: login is quarantined or reply history is full\r\n");
|
|
return;
|
|
}
|
|
uint32_t est_timeout = 0;
|
|
const int result = sendLogin(recipient, password, est_timeout);
|
|
if (result == MSG_SEND_FAILED) {
|
|
abandonPendingRequest();
|
|
terminalOutput().print(" ERROR: unable to send login\r\n");
|
|
return;
|
|
}
|
|
|
|
uint32_t tag;
|
|
memcpy(&tag, recipient.id.pub_key, 4);
|
|
_delayed_replies.armRequest(tag, est_timeout,
|
|
result == MSG_SEND_SENT_FLOOD, _ms->getMillis());
|
|
_terminal_login_pending = true;
|
|
memcpy(_terminal_login_key, recipient.id.pub_key,
|
|
sizeof(_terminal_login_key));
|
|
const uint32_t timeout = est_timeout + est_timeout / 5;
|
|
_terminal_login_expires_at = _delayed_replies.request.radio_deadline;
|
|
StrHelper::strzcpy(_terminal_login_target, recipient.name,
|
|
sizeof(_terminal_login_target));
|
|
printTerminalSendStatus("Login", recipient, result, timeout);
|
|
}
|
|
|
|
void MyMesh::clearTerminalCommand() {
|
|
_terminal_command.clear();
|
|
_terminal_command_target[0] = 0;
|
|
}
|
|
|
|
void MyMesh::serviceTerminalCommand() {
|
|
uint32_t elapsed_millis = 0;
|
|
if (!_terminal_command.expire(_ms->getMillis(), elapsed_millis)) return;
|
|
|
|
if (hasTerminalOutput()) {
|
|
terminalOutput().printf("\r\n ERROR: command to %s timed out after %lu ms.\r\n> ",
|
|
_terminal_command_target, (unsigned long)elapsed_millis);
|
|
}
|
|
_terminal_command_target[0] = 0;
|
|
}
|
|
|
|
void MyMesh::sendTerminalCommand(ContactInfo& recipient,
|
|
const char* command) {
|
|
serviceTerminalLogin();
|
|
if (_terminal_login_pending) {
|
|
terminalOutput().printf(" ERROR: login to %s is still pending\r\n",
|
|
_terminal_login_target);
|
|
return;
|
|
}
|
|
|
|
serviceTerminalCommand();
|
|
if (_terminal_command.isPending()) {
|
|
terminalOutput().printf(" ERROR: command to %s is still pending\r\n",
|
|
_terminal_command_target);
|
|
return;
|
|
}
|
|
|
|
// Be defensive if a caller passes the complete terminal line instead of
|
|
// the already-parsed argument. Never transmit the local "cmd" wrapper.
|
|
const char* on_air_command = command;
|
|
const char* nested_command = NULL;
|
|
if (mesh::cli::parseTerminalArgumentCommand(
|
|
command, "cmd", nested_command)
|
|
== mesh::cli::TerminalArgumentCommandMatch::Valid) {
|
|
on_air_command = nested_command;
|
|
}
|
|
|
|
const size_t command_len =
|
|
on_air_command == NULL ? 0 : strlen(on_air_command);
|
|
if (command_len == 0 || command_len > MAX_CORRELATED_CLI_TEXT_LEN) {
|
|
terminalOutput().printf(" ERROR: remote command must be 1-%u UTF-8 bytes\r\n",
|
|
(unsigned)MAX_CORRELATED_CLI_TEXT_LEN);
|
|
return;
|
|
}
|
|
|
|
const uint32_t logical_request_id =
|
|
getRTCClock()->getCurrentTimeUnique();
|
|
const uint32_t timestamp = getRTCClock()->getCurrentTimeUnique();
|
|
const uint32_t command_started_at = _ms->getMillis();
|
|
uint32_t est_timeout = 0;
|
|
// Legacy server roles used CLI_DATA for requests as well as replies. Keep
|
|
// that interoperable request form for deployed Repeaters, Rooms, and
|
|
// Sensors; Chat/Companion nodes need the explicit CLI_COMMAND direction.
|
|
const bool is_server_role = recipient.type == ADV_TYPE_REPEATER
|
|
|| recipient.type == ADV_TYPE_ROOM
|
|
|| recipient.type == ADV_TYPE_SENSOR;
|
|
const uint8_t txt_type = is_server_role
|
|
? TXT_TYPE_CLI_DATA : TXT_TYPE_CLI_COMMAND;
|
|
const int result = sendCommandData(recipient, timestamp, 0,
|
|
txt_type, on_air_command,
|
|
est_timeout, logical_request_id);
|
|
if (result == MSG_SEND_FAILED) {
|
|
terminalOutput().print(" ERROR: unable to send remote command\r\n");
|
|
return;
|
|
}
|
|
|
|
_terminal_command.begin(recipient.id.pub_key, command_started_at,
|
|
est_timeout);
|
|
StrHelper::strzcpy(_terminal_command_target, recipient.name,
|
|
sizeof(_terminal_command_target));
|
|
|
|
printTerminalSendStatus("Remote command", recipient, result, est_timeout);
|
|
}
|
|
|
|
void MyMesh::clearTerminalTrace() {
|
|
clearTerminalTrace(_ms->getMillis());
|
|
}
|
|
|
|
void MyMesh::clearTerminalTrace(uint32_t now) {
|
|
if (_terminal_trace_pending) {
|
|
_delayed_replies.retireTrace(_terminal_trace_history, now,
|
|
_terminal_trace_expires_at);
|
|
}
|
|
_terminal_trace_history = mesh::CompanionDelayedReplies::NO_HISTORY;
|
|
_terminal_trace_pending = false;
|
|
_terminal_trace_hash_size = 0;
|
|
_terminal_trace_tag = 0;
|
|
_terminal_trace_auth = 0;
|
|
_terminal_trace_sent_at = 0;
|
|
_terminal_trace_expires_at = 0;
|
|
_terminal_trace_target[0] = 0;
|
|
}
|
|
|
|
void MyMesh::serviceTerminalTrace() {
|
|
serviceTerminalTrace(_ms->getMillis());
|
|
}
|
|
|
|
void MyMesh::serviceTerminalTrace(uint32_t now) {
|
|
if (!_terminal_trace_pending) return;
|
|
if ((int32_t)(now - (uint32_t)_terminal_trace_expires_at) < 0) {
|
|
return;
|
|
}
|
|
|
|
if (hasTerminalOutput()) {
|
|
terminalOutput().printf("\r\n ERROR: trace to %s timed out.\r\n> ",
|
|
_terminal_trace_target);
|
|
}
|
|
clearTerminalTrace(now);
|
|
}
|
|
|
|
void MyMesh::sendTerminalTraceRoute(const uint8_t* route, uint8_t hash_size,
|
|
uint8_t hop_count, const char* target) {
|
|
serviceTerminalTrace();
|
|
if (_terminal_trace_pending) {
|
|
terminalOutput().printf(" ERROR: trace to %s is still pending\r\n",
|
|
_terminal_trace_target);
|
|
return;
|
|
}
|
|
if (route == NULL || hop_count == 0 || hop_count >= MAX_PATH_SIZE) {
|
|
terminalOutput().print(" ERROR: trace path must contain 1-63 prefixes\r\n");
|
|
return;
|
|
}
|
|
|
|
const size_t route_byte_len = static_cast<size_t>(hash_size) * hop_count;
|
|
if (route_byte_len > MAX_PACKET_PAYLOAD - 9) {
|
|
terminalOutput().print(" ERROR: trace path is too long\r\n");
|
|
return;
|
|
}
|
|
|
|
const uint8_t flags = mesh::traceFlagsForHashSize(hash_size);
|
|
if (flags == 0xFF) {
|
|
terminalOutput().print(" ERROR: trace hash size must be 1, 2, or 4 bytes\r\n");
|
|
return;
|
|
}
|
|
|
|
mesh::Packet* packet = createTrace(0, 0, flags);
|
|
if (packet == NULL) {
|
|
terminalOutput().print(" ERROR: unable to allocate trace packet\r\n");
|
|
return;
|
|
}
|
|
const uint32_t airtime =
|
|
getTransmitAirtime(packet, 2 + packet->payload_len + route_byte_len);
|
|
if (airtime == 0) {
|
|
releasePacket(packet);
|
|
terminalOutput().print(" ERROR: unable to estimate trace timeout\r\n");
|
|
return;
|
|
}
|
|
const uint32_t base_timeout =
|
|
calcDirectTimeoutMillisFor(airtime, hop_count);
|
|
uint32_t trace_timeout = 0;
|
|
if (!mesh::calculateTerminalTraceTimeoutMillis(base_timeout,
|
|
trace_timeout)
|
|
|| trace_timeout > 0x7FFFFFFFU
|
|
- mesh::CompanionDelayedReplies::DELIVERY_GRACE_MS) {
|
|
releasePacket(packet);
|
|
terminalOutput().print(" ERROR: trace timeout is out of range\r\n");
|
|
return;
|
|
}
|
|
|
|
uint32_t tag = 0;
|
|
uint32_t auth = 0;
|
|
getRNG()->random((uint8_t*)&tag, sizeof(tag));
|
|
getRNG()->random((uint8_t*)&auth, sizeof(auth));
|
|
memcpy(packet->payload, &tag, sizeof(tag));
|
|
memcpy(&packet->payload[4], &auth, sizeof(auth));
|
|
const uint8_t history = _delayed_replies.reserveTrace(tag, auth, _ms->getMillis());
|
|
if (history == mesh::CompanionDelayedReplies::NO_HISTORY) {
|
|
releasePacket(packet);
|
|
terminalOutput().print(" ERROR: trace tag is quarantined or reply history is full\r\n");
|
|
return;
|
|
}
|
|
if (!sendDirect(packet, route, static_cast<uint8_t>(route_byte_len))) {
|
|
_delayed_replies.abandonTrace(history);
|
|
terminalOutput().print(" ERROR: unable to send trace\r\n");
|
|
return;
|
|
}
|
|
|
|
_terminal_trace_pending = true;
|
|
_terminal_trace_history = history;
|
|
_terminal_trace_hash_size = hash_size;
|
|
_terminal_trace_tag = tag;
|
|
_terminal_trace_auth = auth;
|
|
_terminal_trace_sent_at = _ms->getMillis();
|
|
_terminal_trace_expires_at = futureMillis(trace_timeout);
|
|
StrHelper::strzcpy(_terminal_trace_target, target,
|
|
sizeof(_terminal_trace_target));
|
|
terminalOutput().printf(" Trace sent to %s (%u route hops, timeout %lu ms)\r\n",
|
|
target, (unsigned)hop_count,
|
|
(unsigned long)trace_timeout);
|
|
}
|
|
|
|
void MyMesh::sendTerminalTrace(ContactInfo& recipient) {
|
|
if (recipient.out_path_len == OUT_PATH_UNKNOWN
|
|
|| !mesh::Packet::isValidPathLen(recipient.out_path_len)) {
|
|
terminalOutput().print(" ERROR: recipient has no valid direct path\r\n");
|
|
return;
|
|
}
|
|
|
|
const bool include_endpoint = recipient.type == ADV_TYPE_REPEATER
|
|
|| recipient.type == ADV_TYPE_ROOM;
|
|
mesh::RoundTripTracePath route;
|
|
uint8_t path_bytes[MAX_PATH_SIZE];
|
|
if (!recipient.copyPathTo(path_bytes) || !mesh::buildRoundTripTracePath(
|
|
path_bytes, recipient.out_path_len, recipient.id.pub_key,
|
|
include_endpoint, _terminal_tmp_buf, MAX_PACKET_PAYLOAD - 9,
|
|
route)) {
|
|
terminalOutput().print(" ERROR: recipient has no traceable round-trip path\r\n");
|
|
return;
|
|
}
|
|
if (route.hop_count >= MAX_PATH_SIZE) {
|
|
terminalOutput().print(" ERROR: round-trip trace path is too long\r\n");
|
|
return;
|
|
}
|
|
|
|
sendTerminalTraceRoute(_terminal_tmp_buf, route.hash_size, route.hop_count,
|
|
recipient.name);
|
|
}
|
|
|
|
void MyMesh::sendTerminalRawTrace(const char* arguments) {
|
|
mesh::RawTracePath route;
|
|
const mesh::RawTracePathParseResult parsed = mesh::parseRawTracePath(
|
|
arguments, _terminal_tmp_buf, MAX_PACKET_PAYLOAD - 9,
|
|
MAX_PATH_SIZE - 1, route);
|
|
switch (parsed) {
|
|
case mesh::RawTracePathParseResult::Valid:
|
|
break;
|
|
case mesh::RawTracePathParseResult::MissingHashSize:
|
|
case mesh::RawTracePathParseResult::MissingPrefixes:
|
|
terminalOutput().print(" ERROR: use trace path <1|2|4> <prefixes...>\r\n");
|
|
return;
|
|
case mesh::RawTracePathParseResult::InvalidHashSize:
|
|
terminalOutput().print(" ERROR: trace hash size must be 1, 2, or 4 bytes\r\n");
|
|
return;
|
|
case mesh::RawTracePathParseResult::InvalidPrefix:
|
|
terminalOutput().printf(" ERROR: every prefix must be exactly %u hex digits\r\n",
|
|
(unsigned)route.hash_size * 2);
|
|
return;
|
|
case mesh::RawTracePathParseResult::TooManyHops:
|
|
terminalOutput().print(" ERROR: trace path must contain at most 63 prefixes\r\n");
|
|
return;
|
|
case mesh::RawTracePathParseResult::RouteTooLong:
|
|
terminalOutput().print(" ERROR: trace path is too long\r\n");
|
|
return;
|
|
}
|
|
|
|
char target[32];
|
|
snprintf(target, sizeof(target), "raw %u-byte path",
|
|
(unsigned)route.hash_size);
|
|
sendTerminalTraceRoute(_terminal_tmp_buf, route.hash_size, route.hop_count,
|
|
target);
|
|
}
|
|
|
|
#if defined(WITH_WEBCONFIG) && COMPANION_FEATURE_READER
|
|
bool MyMesh::streamTerminalBusy() const {
|
|
return mesh::readerCommandActive(_terminal_output);
|
|
}
|
|
#endif
|
|
|
|
void MyMesh::handleTerminalCommand(char* command, bool reader_prompt,
|
|
mesh::ReaderTryWrite reader_try_write) {
|
|
while (*command == ' ' || *command == '\t') command++;
|
|
if (*command == 0) return;
|
|
mesh::cli::normalizeCommandVerb(command);
|
|
|
|
#if COMPANION_FEATURE_READER
|
|
if (mesh::morseTimingActive(_radio) && isCompanionReaderRequest(command)) {
|
|
mesh::cancelReaderCommand();
|
|
terminalOutput().print(" ERROR: Reader unavailable during Morse\r\n");
|
|
return;
|
|
}
|
|
if (mesh::handleReaderCommand(command, terminalOutput(), reader_prompt,
|
|
reader_try_write)) return;
|
|
#endif
|
|
|
|
// Match the repeater ASCII reply envelope so unmodified serial packet
|
|
// bridges can query this role. Framed/BLE replies retain their payloads.
|
|
char local_reply[160];
|
|
if (handleRxPowerSavingGetCommand(command, local_reply, sizeof(local_reply))) {
|
|
if (local_reply[0] == '>') {
|
|
terminalOutput().printf(" %s %s\r\n", command + 4, local_reply + 2);
|
|
} else {
|
|
terminalOutput().printf(" ERROR: %s\r\n", local_reply + 7);
|
|
}
|
|
return;
|
|
}
|
|
if (handleCompanionWirelessCommand(command, local_reply, sizeof(local_reply),
|
|
_terminal_mode ? CompanionWirelessSource::Usb : CompanionWirelessSource::Network)
|
|
|| handleCompanionBluetoothCommand(command, local_reply, sizeof(local_reply),
|
|
CompanionBluetoothCommandSource::Terminal)) {
|
|
terminalOutput().printf(" -> %s\r\n", local_reply);
|
|
return;
|
|
}
|
|
if (handleDirectCommand(command, local_reply, sizeof(local_reply))) {
|
|
terminalOutput().printf(" -> %s\r\n", local_reply);
|
|
return;
|
|
}
|
|
#if COMPANION_FEATURE_USB_MOTA_SOURCE
|
|
const bool usb_mota_owner_transition =
|
|
mesh::isUsbMotaOwnerTransitionCommand(command);
|
|
#else
|
|
const bool usb_mota_owner_transition = false;
|
|
#endif
|
|
if (!usb_mota_owner_transition
|
|
&& handleLocalControlCommand(command, local_reply, sizeof(local_reply))) {
|
|
terminalOutput().printf(" -> %s\r\n", local_reply);
|
|
return;
|
|
}
|
|
|
|
mesh::cli::TerminalChannelMessage channel_message;
|
|
const mesh::cli::TerminalChannelCommandMatch channel_match =
|
|
mesh::cli::parseTerminalChannelMessage(command, channel_message);
|
|
const char* login_password = NULL;
|
|
const mesh::cli::TerminalArgumentCommandMatch login_match =
|
|
mesh::cli::parseTerminalArgumentCommand(command, "login",
|
|
login_password);
|
|
const char* remote_command = NULL;
|
|
const mesh::cli::TerminalArgumentCommandMatch command_match =
|
|
mesh::cli::parseTerminalArgumentCommand(command, "cmd",
|
|
remote_command);
|
|
const char* path_spec = NULL;
|
|
const mesh::cli::TerminalArgumentCommandMatch path_match =
|
|
mesh::cli::parseTerminalArgumentCommand(command, "path", path_spec);
|
|
const char* recipient_prefix = NULL;
|
|
const mesh::cli::TerminalArgumentCommandMatch recipient_match =
|
|
mesh::cli::parseTerminalArgumentCommand(command, "to",
|
|
recipient_prefix);
|
|
const char* display_arguments = NULL;
|
|
const mesh::cli::TerminalArgumentCommandMatch display_match =
|
|
mesh::cli::parseTerminalArgumentCommand(command, "show",
|
|
display_arguments);
|
|
|
|
if (display_match != mesh::cli::TerminalArgumentCommandMatch::NoMatch) {
|
|
handleTerminalDisplayCommand(
|
|
display_match == mesh::cli::TerminalArgumentCommandMatch::Valid
|
|
? display_arguments : NULL);
|
|
} else if (strcmp(command, "channels") == 0) {
|
|
listTerminalChannels();
|
|
} else if (channel_match != mesh::cli::TerminalChannelCommandMatch::NoMatch) {
|
|
if (channel_match != mesh::cli::TerminalChannelCommandMatch::Valid) {
|
|
terminalOutput().print(" ERROR: use channel <name-or-slot> <message>\r\n");
|
|
return;
|
|
}
|
|
|
|
ChannelDetails channel;
|
|
bool found = false;
|
|
size_t requested_index = 0;
|
|
if (mesh::cli::parseTerminalChannelIndex(
|
|
channel_message, MAX_GROUP_CHANNELS, requested_index)) {
|
|
found = getChannel((int)requested_index, channel)
|
|
&& channel.name[0] != 0;
|
|
} else {
|
|
for (int i = 0; i < MAX_GROUP_CHANNELS; i++) {
|
|
if (getChannel(i, channel) && channel.name[0] != 0
|
|
&& mesh::cli::terminalChannelNameMatches(channel_message,
|
|
channel.name)) {
|
|
found = true;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!found) {
|
|
terminalOutput().print(" ERROR: channel not found (use 'channels')\r\n");
|
|
} else {
|
|
sendTerminalChannelMessage(channel, channel_message.text);
|
|
}
|
|
} else if (strncmp(command, "send ", 5) == 0) {
|
|
ContactInfo* recipient = getTerminalRecipient();
|
|
const char* text = command + 5;
|
|
if (recipient == NULL) {
|
|
terminalOutput().print(" ERROR: no recipient selected (use 'to' first)\r\n");
|
|
} else if (*text == 0 || strlen(text) > MAX_TEXT_LEN) {
|
|
terminalOutput().printf(" ERROR: message must be 1-%u characters\r\n", (unsigned)MAX_TEXT_LEN);
|
|
} else {
|
|
uint32_t expected_ack = 0;
|
|
uint32_t est_timeout = 0;
|
|
uint32_t message_timestamp = getRTCClock()->getCurrentTimeUnique();
|
|
uint8_t text_fingerprint[MAX_HASH_SIZE] = { 0 };
|
|
uint8_t packet_retry_key[MAX_HASH_SIZE] = { 0 };
|
|
mesh::Utils::sha256(text_fingerprint, sizeof(text_fingerprint),
|
|
recipient->id.pub_key, PUB_KEY_SIZE,
|
|
(const uint8_t*)text, strlen(text));
|
|
AckTableEntry* replacement_entry =
|
|
findPendingTextMessage(text_fingerprint, message_timestamp);
|
|
int result = sendMessage(*recipient, message_timestamp, 0, text,
|
|
expected_ack, est_timeout, packet_retry_key,
|
|
NULL, text_fingerprint);
|
|
if (result == MSG_SEND_FAILED) {
|
|
terminalOutput().print(" ERROR: unable to send\r\n");
|
|
} else {
|
|
rememberTerminalAck(*recipient, text, message_timestamp, expected_ack,
|
|
est_timeout, packet_retry_key, replacement_entry);
|
|
terminalOutput().printf(" message sent - %s\r\n",
|
|
result == MSG_SEND_SENT_FLOOD ? "FLOOD" : "DIRECT");
|
|
}
|
|
}
|
|
} else if (strncmp(command, "public ", 7) == 0) {
|
|
ChannelDetails channel;
|
|
const char* text = command + 7;
|
|
if (!getChannel(0, channel) || channel.name[0] == 0) {
|
|
terminalOutput().print(" ERROR: Public channel is unavailable\r\n");
|
|
} else {
|
|
sendTerminalChannelMessage(channel, text);
|
|
}
|
|
} else if (strcmp(command, "list") == 0 || strncmp(command, "list ", 5) == 0) {
|
|
int count = command[4] == ' ' ? atoi(command + 5) : 0;
|
|
scanRecentContacts(count, this);
|
|
} else if (strcmp(command, "clock") == 0) {
|
|
DateTime dt(getRTCClock()->getCurrentTime());
|
|
terminalOutput().printf("%02d:%02d - %d/%d/%d UTC\r\n",
|
|
dt.hour(), dt.minute(), dt.day(), dt.month(), dt.year());
|
|
} else if (strncmp(command, "time ", 5) == 0) {
|
|
uint32_t timestamp = strtoul(command + 5, NULL, 10);
|
|
uint32_t current = getRTCClock()->getCurrentTime();
|
|
if (timestamp >= current) {
|
|
getRTCClock()->setCurrentTime(timestamp);
|
|
#ifdef COMPANION_MESH_CLOCK_SYNC
|
|
_clock_sync.onManualClockSet();
|
|
#endif
|
|
terminalOutput().print(" OK - clock set\r\n");
|
|
} else {
|
|
terminalOutput().print(" ERROR: clock cannot go backwards\r\n");
|
|
}
|
|
} else if (recipient_match
|
|
== mesh::cli::TerminalArgumentCommandMatch::Valid) {
|
|
ContactInfo* recipient = NULL;
|
|
if (strlen(recipient_prefix) < sizeof(ContactInfo::name)) {
|
|
recipient = searchContactsByPrefix(recipient_prefix);
|
|
}
|
|
if (recipient == NULL || recipient->type == ADV_TYPE_NONE) {
|
|
terminalOutput().print(" ERROR: name prefix not found\r\n");
|
|
} else {
|
|
memcpy(_terminal_recipient_key, recipient->id.pub_key, PUB_KEY_SIZE);
|
|
_terminal_recipient_set = true;
|
|
terminalOutput().printf(" Recipient %s selected\r\n", recipient->name);
|
|
}
|
|
} else if (recipient_match
|
|
== mesh::cli::TerminalArgumentCommandMatch::MissingArgument) {
|
|
ContactInfo* recipient = getTerminalRecipient();
|
|
if (recipient != NULL) {
|
|
terminalOutput().printf(" Current recipient: %s\r\n", recipient->name);
|
|
} else {
|
|
terminalOutput().print(" No recipient selected\r\n");
|
|
}
|
|
} else if (path_match
|
|
!= mesh::cli::TerminalArgumentCommandMatch::NoMatch) {
|
|
ContactInfo* recipient = getTerminalRecipient();
|
|
if (recipient == NULL) {
|
|
terminalOutput().print(" ERROR: no recipient selected (use 'to' first)\r\n");
|
|
} else {
|
|
handleTerminalPath(
|
|
*recipient,
|
|
path_match == mesh::cli::TerminalArgumentCommandMatch::Valid
|
|
? path_spec : NULL);
|
|
}
|
|
} else if (login_match
|
|
!= mesh::cli::TerminalArgumentCommandMatch::NoMatch) {
|
|
ContactInfo* recipient = getTerminalRecipient();
|
|
if (login_match
|
|
!= mesh::cli::TerminalArgumentCommandMatch::Valid) {
|
|
terminalOutput().print(" ERROR: use login <admin-password>\r\n");
|
|
} else if (recipient == NULL) {
|
|
terminalOutput().print(" ERROR: no recipient selected (use 'to' first)\r\n");
|
|
} else {
|
|
sendTerminalLogin(*recipient, login_password);
|
|
}
|
|
} else if (command_match
|
|
!= mesh::cli::TerminalArgumentCommandMatch::NoMatch) {
|
|
ContactInfo* recipient = getTerminalRecipient();
|
|
if (command_match
|
|
!= mesh::cli::TerminalArgumentCommandMatch::Valid) {
|
|
terminalOutput().print(" ERROR: use cmd <remote-command>\r\n");
|
|
} else if (recipient == NULL) {
|
|
terminalOutput().print(" ERROR: no recipient selected (use 'to' first)\r\n");
|
|
} else {
|
|
sendTerminalCommand(*recipient, remote_command);
|
|
}
|
|
} else if (strcmp(command, "trace path") == 0
|
|
|| strncmp(command, "trace path ", 11) == 0
|
|
|| strncmp(command, "trace path\t", 11) == 0) {
|
|
sendTerminalRawTrace(command + 10);
|
|
} else if (strcmp(command, "trace") == 0
|
|
|| strncmp(command, "trace ", 6) == 0) {
|
|
ContactInfo* recipient = NULL;
|
|
if (command[5] == ' ') {
|
|
const char* prefix = command + 6;
|
|
while (*prefix == ' ') prefix++;
|
|
if (*prefix != 0 && strlen(prefix) < sizeof(ContactInfo::name)) {
|
|
recipient = searchContactsByPrefix(prefix);
|
|
}
|
|
if (recipient == NULL || recipient->type == ADV_TYPE_NONE) {
|
|
terminalOutput().print(" ERROR: recipient prefix not found\r\n");
|
|
return;
|
|
}
|
|
} else {
|
|
recipient = getTerminalRecipient();
|
|
if (recipient == NULL) {
|
|
terminalOutput().print(" ERROR: no recipient selected (use 'to' first)\r\n");
|
|
return;
|
|
}
|
|
}
|
|
sendTerminalTrace(*recipient);
|
|
} else if (strcmp(command, "advert") == 0) {
|
|
terminalOutput().print(advert() ? " advert sent (zero hop)\r\n"
|
|
: " ERROR: unable to send advert\r\n");
|
|
} else if (strcmp(command, "reset path") == 0) {
|
|
ContactInfo* recipient = getTerminalRecipient();
|
|
if (recipient == NULL) {
|
|
terminalOutput().print(" ERROR: no recipient selected\r\n");
|
|
} else {
|
|
const uint8_t previous_out_path_len = recipient->out_path_len;
|
|
resetPathTo(*recipient);
|
|
if (scheduleContactWrite(*recipient)) {
|
|
terminalOutput().print(" Done.\r\n");
|
|
} else {
|
|
recipient->out_path_len = previous_out_path_len;
|
|
terminalOutput().print(
|
|
" ERROR: contact storage is unavailable; reboot and retry\r\n");
|
|
}
|
|
}
|
|
} else if (strcmp(command, "card") == 0) {
|
|
mesh::Packet* packet = _prefs.advert_loc_policy == ADVERT_LOC_NONE
|
|
? createSelfAdvert(_prefs.node_name)
|
|
: createSelfAdvert(_prefs.node_name, sensors.node_lat, sensors.node_lon);
|
|
if (packet == NULL) {
|
|
terminalOutput().print(" ERROR: unable to create card\r\n");
|
|
} else {
|
|
packet->header |= ROUTE_TYPE_FLOOD;
|
|
uint8_t raw_len = packet->writeTo(_terminal_tmp_buf);
|
|
releasePacket(packet);
|
|
terminalOutput().print("meshcore://");
|
|
mesh::Utils::printHex(terminalOutput(), _terminal_tmp_buf, raw_len);
|
|
terminalOutput().print("\r\n");
|
|
}
|
|
} else if (strncmp(command, "import ", 7) == 0) {
|
|
importTerminalCard(command + 7);
|
|
} else if (strcmp(command, "powersaving") == 0
|
|
|| strcmp(command, "get powersaving") == 0) {
|
|
terminalOutput().printf(" powersaving %s\r\n",
|
|
_prefs.powersaving_enabled ? "on" : "off");
|
|
} else if (strncmp(command, "powersaving ", 12) == 0) {
|
|
char reply[160];
|
|
applyAndSavePowerSaving(command + 12, reply);
|
|
terminalOutput().printf(" -> %s\r\n", reply);
|
|
#if defined(ESP32) && defined(WIFI_SSID)
|
|
} else if (strcmp(command, "get wifi.powersave") == 0) {
|
|
char reply[160];
|
|
formatWiFiPowerSaving(reply, sizeof(reply));
|
|
terminalOutput().printf(" -> %s\r\n", reply);
|
|
#endif
|
|
} else if (strcmp(command, "get radio.rxgain") == 0) {
|
|
if (!radio_driver.supportsRxBoostedGainMode()) {
|
|
terminalOutput().print(" ERROR: RX gain unsupported\r\n");
|
|
} else {
|
|
terminalOutput().printf(" radio.rxgain %s\r\n",
|
|
_prefs.rx_boosted_gain ? "on" : "off");
|
|
}
|
|
} else if (strcmp(command, "get radio.fem.rxgain") == 0) {
|
|
if (!board.canControlLoRaFemLna()) {
|
|
terminalOutput().print(" ERROR: FEM RX gain control is unsupported on this board\r\n");
|
|
} else {
|
|
terminalOutput().printf(" FEM RX gain: %s\r\n",
|
|
board.isLoRaFemLnaEnabled() ? "on" : "off");
|
|
}
|
|
} else if (strcmp(command, "get radio.fem.txgain") == 0) {
|
|
if (!board.canControlLoRaFemPaGain()) {
|
|
if (board.isLoRaFemPaGainEnabled()) {
|
|
terminalOutput().print(" FEM TX gain: on (fixed TX PA)\r\n");
|
|
} else {
|
|
terminalOutput().print(" ERROR: FEM TX gain control is unsupported on this board\r\n");
|
|
}
|
|
} else {
|
|
terminalOutput().printf(" FEM TX gain: %s\r\n",
|
|
board.isLoRaFemPaGainEnabled() ? "on" : "off");
|
|
}
|
|
} else if (strncmp(command, "set ", 4) == 0) {
|
|
const char* config = command + 4;
|
|
if (strncmp(config, "powersaving ", 12) == 0) {
|
|
char reply[160];
|
|
applyAndSavePowerSaving(config + 12, reply);
|
|
terminalOutput().printf(" -> %s\r\n", reply);
|
|
#if defined(ESP32) && defined(WIFI_SSID)
|
|
} else if (strncmp(config, "wifi.powersave", 14) == 0
|
|
&& (config[14] == 0 || config[14] == ' '
|
|
|| config[14] == '\t')) {
|
|
const char* value = config + 14;
|
|
while (*value == ' ' || *value == '\t') value++;
|
|
char reply[160];
|
|
applyAndSaveWiFiPowerSaving(value, reply, sizeof(reply));
|
|
terminalOutput().printf(" -> %s\r\n", reply);
|
|
#endif
|
|
} else if (strncmp(config, "radio.rxgain", 12) == 0
|
|
&& (config[12] == 0 || config[12] == ' '
|
|
|| config[12] == '\t')) {
|
|
const char* value = config + 12;
|
|
while (*value == ' ' || *value == '\t') value++;
|
|
if (strcmp(value, "on") != 0 && strcmp(value, "off") != 0) {
|
|
terminalOutput().print(" ERROR: use on|off\r\n");
|
|
} else if (!radio_driver.supportsRxBoostedGainMode()) {
|
|
terminalOutput().print(" ERROR: RX gain unsupported\r\n");
|
|
} else if (!applyAndSaveRxBoostedGain(strcmp(value, "on") == 0)) {
|
|
terminalOutput().print(" ERROR: RX gain failed\r\n");
|
|
} else {
|
|
terminalOutput().printf(" OK - radio.rxgain %s\r\n", value);
|
|
}
|
|
} else if (strncmp(config, "af ", 3) == 0) {
|
|
float parsed = 0.0f;
|
|
if (!mesh::cli::parseDecimalStrict(config + 3, parsed)
|
|
|| parsed < 0.0f || parsed > 9.0f) {
|
|
terminalOutput().print(" ERROR: airtime factor must be 0-9\r\n");
|
|
} else {
|
|
terminalOutput().print(savePreference(_prefs.airtime_factor, parsed)
|
|
? " OK\r\n" : " ERROR: airtime factor could not be saved\r\n");
|
|
}
|
|
} else if (strncmp(config, "name ", 5) == 0 && config[5] != 0) {
|
|
terminalOutput().print(saveAdvertName(config + 5)
|
|
? " OK\r\n" : " ERROR: name could not be saved\r\n");
|
|
} else if (strncmp(config, "lat ", 4) == 0) {
|
|
float parsed = 0.0f;
|
|
if (!mesh::cli::parseDecimalStrict(config + 4, parsed)
|
|
|| parsed < -90.0f || parsed > 90.0f) {
|
|
terminalOutput().print(" ERROR: latitude must be -90 to 90\r\n");
|
|
} else {
|
|
terminalOutput().print(saveAdvertLocation(parsed, sensors.node_lon)
|
|
? " OK\r\n" : " ERROR: latitude could not be saved\r\n");
|
|
}
|
|
} else if (strncmp(config, "lon ", 4) == 0) {
|
|
float parsed = 0.0f;
|
|
if (!mesh::cli::parseDecimalStrict(config + 4, parsed)
|
|
|| parsed < -180.0f || parsed > 180.0f) {
|
|
terminalOutput().print(" ERROR: longitude must be -180 to 180\r\n");
|
|
} else {
|
|
terminalOutput().print(saveAdvertLocation(sensors.node_lat, parsed)
|
|
? " OK\r\n" : " ERROR: longitude could not be saved\r\n");
|
|
}
|
|
} else if (strncmp(config, "tx ", 3) == 0) {
|
|
handleCommand(command, 0, local_reply);
|
|
terminalOutput().printf(" -> %s\r\n", local_reply);
|
|
} else if (strncmp(config, "freq ", 5) == 0) {
|
|
handleCommand(command, 0, local_reply);
|
|
terminalOutput().printf(" -> %s\r\n", local_reply);
|
|
} else if (strncmp(config, "radio.fem.rxgain", 16) == 0
|
|
&& (config[16] == 0 || config[16] == ' '
|
|
|| config[16] == '\t')) {
|
|
const char* value = config + 16;
|
|
while (*value == ' ' || *value == '\t') value++;
|
|
if (strcmp(value, "on") != 0 && strcmp(value, "off") != 0) {
|
|
terminalOutput().print(" ERROR: use set radio.fem.rxgain <on|off>\r\n");
|
|
} else if (_radio && (_radio->isMorseTxActive() || _radio->isMorseRxActive())) {
|
|
terminalOutput().print(" ERROR: stop Morse before changing LoRa FEM gain\r\n");
|
|
} else if (!board.canControlLoRaFemLna()) {
|
|
terminalOutput().print(" ERROR: FEM RX gain control is unsupported on this board\r\n");
|
|
} else if (!applyAndSaveFemRxGain(strcmp(value, "on") == 0)) {
|
|
terminalOutput().print(" ERROR: failed to apply FEM RX gain\r\n");
|
|
} else {
|
|
terminalOutput().printf(" OK - FEM RX gain %s\r\n", value);
|
|
}
|
|
} else if (strncmp(config, "radio.fem.txgain", 16) == 0
|
|
&& (config[16] == 0 || config[16] == ' '
|
|
|| config[16] == '\t')) {
|
|
const char* value = config + 16;
|
|
while (*value == ' ' || *value == '\t') value++;
|
|
if (strcmp(value, "on") != 0 && strcmp(value, "off") != 0) {
|
|
terminalOutput().print(" ERROR: use set radio.fem.txgain <on|off>\r\n");
|
|
} else if (_radio && (_radio->isMorseTxActive() || _radio->isMorseRxActive())) {
|
|
terminalOutput().print(" ERROR: stop Morse before changing LoRa FEM gain\r\n");
|
|
} else if (!board.canControlLoRaFemPaGain()) {
|
|
terminalOutput().print(board.isLoRaFemPaGainEnabled()
|
|
? " ERROR: TX PA is fixed on; use set tx to adjust power\r\n"
|
|
: " ERROR: FEM TX gain control is unsupported on this board\r\n");
|
|
} else if (!applyAndSaveFemTxGain(strcmp(value, "on") == 0)) {
|
|
terminalOutput().print(" ERROR: failed to apply FEM TX gain\r\n");
|
|
} else {
|
|
terminalOutput().printf(" OK - FEM TX gain %s\r\n", value);
|
|
}
|
|
} else {
|
|
// The terminal has a few presentation-specific setters above, while
|
|
// handleCommand() owns the shared radio-pref and board-specific command
|
|
// surface used by framed/rescue clients. Use it only as the fallback so
|
|
// Full Companion does not develop holes such as `set radio ...` without
|
|
// changing the established terminal behavior of `set tx`, `set name`,
|
|
// or the WiFi commands.
|
|
if (handleCommand(command, 0, local_reply)) {
|
|
terminalOutput().printf(" -> %s\r\n", local_reply);
|
|
} else {
|
|
terminalOutput().printf(" ERROR: unknown setting: %s\r\n", config);
|
|
}
|
|
}
|
|
} else if (strcmp(command, "reboot") == 0) {
|
|
terminalOutput().print(" OK - rebooting in 1 second\r\n");
|
|
_scheduled_reboot_at = futureMillis(1000);
|
|
if (_scheduled_reboot_at == 0) _scheduled_reboot_at = 1;
|
|
#if defined(NRF52_PLATFORM) && defined(EXTRAFS) && !defined(QSPIFLASH)
|
|
} else if (strcmp(command, "repair extrafs") == 0) {
|
|
repairInternalExtraFS(terminalOutput());
|
|
} else if (strcmp(command, "scan extrafs erase") == 0) {
|
|
scanInternalExtraFS(terminalOutput());
|
|
#endif
|
|
} else if (strcmp(command, "ver") == 0) {
|
|
terminalOutput().printf(" -> Companion %s (protocol %u, build %s)\r\n",
|
|
FIRMWARE_VERSION, (unsigned)FIRMWARE_VER_CODE, FIRMWARE_BUILD_DATE);
|
|
} else if (strcmp(command, "help") == 0) {
|
|
auto& output = terminalOutput();
|
|
#if defined(RAK_4631) && defined(COMPANION_RADIO_FULL) \
|
|
|| (defined(STM32_PLATFORM) && defined(STM32WLxx) && COMPANION_FEATURE_TEXT_TERMINAL)
|
|
mesh::companion::writePackedCompanionHelp(output);
|
|
#else
|
|
#define MESH_COMPANION_HELP_BEGIN output.print(
|
|
#define MESH_COMPANION_HELP_END );
|
|
#define MESH_COMPANION_HELP_LINE_BEGIN output.println(
|
|
#define MESH_COMPANION_HELP_LINE_END );
|
|
#include "CompanionHelp.inc"
|
|
#undef MESH_COMPANION_HELP_BEGIN
|
|
#undef MESH_COMPANION_HELP_END
|
|
#undef MESH_COMPANION_HELP_LINE_BEGIN
|
|
#undef MESH_COMPANION_HELP_LINE_END
|
|
#endif
|
|
if (_terminal_mode) {
|
|
output.print(" +++MESHCORE-TERM-STOP\r\n");
|
|
} else {
|
|
output.print(" disconnect (network terminal)\r\n");
|
|
}
|
|
} else if (handleCommand(command, 0, local_reply)) {
|
|
// Fill the same safe shared-command surface for getters (`get name`,
|
|
// `get radio`, `get tx`, and variant commands). Terminal-only commands
|
|
// above still win, including the richer `ver` response and reboot flow.
|
|
terminalOutput().printf(" -> %s\r\n", local_reply);
|
|
} else {
|
|
terminalOutput().printf(" ERROR: unknown command: %s\r\n", command);
|
|
}
|
|
}
|
|
#endif
|
|
|
|
void MyMesh::enterCLIRescue() {
|
|
#if COMPANION_FEATURE_READER
|
|
mesh::cancelReaderCommand();
|
|
#endif
|
|
_cli_rescue = true;
|
|
cli_command[0] = 0;
|
|
_cli_line_overflow = false;
|
|
mesh::usbTerminalPort().println("========= CLI Rescue =========");
|
|
}
|
|
|
|
static bool isCompanionRadioPrefsCommand(const char* command) {
|
|
if (command == NULL) return false;
|
|
|
|
static const char* const exact_commands[] = {
|
|
"get radio", "get freq", "get af", "get dutycycle", "get tx",
|
|
"get rxdelay", "get path.hash.mode", "get multi.acks",
|
|
"get txdelay", "get direct.txdelay", "get int.thresh", "get agc.reset.interval",
|
|
"get radio.rxps.status"
|
|
};
|
|
for (const char* candidate : exact_commands) {
|
|
if (strcmp(command, candidate) == 0) return true;
|
|
}
|
|
|
|
static const char* const set_prefixes[] = {
|
|
"set radio ", "set af ", "set dutycycle ", "set rxdelay ",
|
|
"set path.hash.mode ", "set multi.acks ", "set txdelay ",
|
|
"set direct.txdelay ", "set int.thresh ", "set agc.reset.interval "
|
|
};
|
|
for (const char* prefix : set_prefixes) {
|
|
if (strncmp(command, prefix, strlen(prefix)) == 0) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// Called only for directly attached USB/BLE/TCP/browser clients. Commands
|
|
// received over LoRa use handleCommand() with a nonzero sender timestamp.
|
|
bool MyMesh::handleDirectCommand(const char* command, char* reply, size_t reply_size) {
|
|
if (mesh::power::handleStoredBatteryCalibrationCommand(
|
|
board, command, reply, reply_size)) return true;
|
|
#if MESH_ENABLE_LOST_REPLY
|
|
if (mesh::companion::handleLostReplyCommand(
|
|
_prefs, command, reply, reply_size, [this]() { return savePrefs(); })) return true;
|
|
#endif
|
|
#ifdef MESH_BUTTON_AUDIO_HIL
|
|
// Present only in an explicitly instrumented physical-test build.
|
|
if (_ui && _ui->handleButtonAudioTest(command, reply, reply_size)) return true;
|
|
#endif
|
|
#if defined(MESH_SOAK_DIAGNOSTICS)
|
|
if (mesh::hil::handleSoakCommand(command, reply, reply_size)) return true;
|
|
#endif
|
|
#if defined(ESP32_PLATFORM) && defined(MESH_HIL_RADIO_TRACE) && MESH_HIL_RADIO_TRACE
|
|
if (mesh::radiotrace::handleTraceCommand(command, reply, reply_size)) return true;
|
|
#endif
|
|
#if MESH_ENABLE_ONE_KEY_DM
|
|
if (strcmp(command, "get dm.one_key") == 0) {
|
|
snprintf(reply, reply_size, "> %s", _prefs.one_key_dm_enabled == 1 ? "on" : "off");
|
|
return true;
|
|
}
|
|
if (strcmp(command, "get dm.held") == 0) {
|
|
snprintf(reply, reply_size, "> %u", (unsigned)held_dm_count);
|
|
return true;
|
|
}
|
|
if (strncmp(command, "set dm.one_key ", 15) == 0) {
|
|
const char* value = command + 15;
|
|
if (strcmp(value, "on") != 0 && strcmp(value, "off") != 0) {
|
|
mesh::cli::formatError(reply, reply_size, "use on or off");
|
|
return true;
|
|
}
|
|
const uint8_t old_value = _prefs.one_key_dm_enabled;
|
|
_prefs.one_key_dm_enabled = strcmp(value, "on") == 0 ? 1 : 0;
|
|
if (!savePrefs()) {
|
|
_prefs.one_key_dm_enabled = old_value;
|
|
mesh::cli::formatError(reply, reply_size, "could not save DM setting");
|
|
} else {
|
|
snprintf(reply, reply_size, "> dm.one_key %s", value);
|
|
}
|
|
return true;
|
|
}
|
|
#else
|
|
if (strcmp(command, "get dm.one_key") == 0 || strcmp(command, "get dm.held") == 0 ||
|
|
strncmp(command, "set dm.one_key ", 15) == 0) {
|
|
mesh::cli::formatError(reply, reply_size, "one-key DMs unsupported on this build");
|
|
return true;
|
|
}
|
|
#endif
|
|
if (strcmp(command, "get key.backup.transport") == 0) {
|
|
snprintf(reply, reply_size, "ephemeral-routed-v1");
|
|
return true;
|
|
}
|
|
if (strcmp(command, "get password") == 0) {
|
|
#if defined(ESP32_PLATFORM) && defined(WIFI_SSID)
|
|
// Binary Companion and its legacy TCP transports have no admin-login
|
|
// handshake. Report status there, never disclose the management secret.
|
|
snprintf(reply, reply_size, _management_password_ready
|
|
? "> (management password set; use password <new-password> to change)"
|
|
: "Error: management password unavailable; set password on USB");
|
|
#else
|
|
snprintf(reply, reply_size, "> (no admin password on Companion)");
|
|
#endif
|
|
return true;
|
|
}
|
|
if (strcmp(command, "get prv.key") == 0) {
|
|
#if ENABLE_PRIVATE_KEY_EXPORT
|
|
uint8_t key[PRV_KEY_SIZE];
|
|
char hex[PRV_KEY_SIZE * 2 + 1];
|
|
const int length = self_id.writeTo(key, sizeof(key));
|
|
mesh::Utils::toHex(hex, key, length);
|
|
snprintf(reply, reply_size, "> %s", hex);
|
|
memset(key, 0, sizeof(key));
|
|
memset(hex, 0, sizeof(hex));
|
|
#else
|
|
mesh::cli::formatError(reply, reply_size, "private key export disabled in this build");
|
|
#endif
|
|
return true;
|
|
}
|
|
if (strcmp(command, "erase") == 0) {
|
|
#if MESH_ENABLE_TX_BEACONS
|
|
suspendBeacons();
|
|
#endif
|
|
if (_store->formatFileSystem()) {
|
|
stopContactsIterator();
|
|
resetContacts();
|
|
MyMesh::onContactCacheFlushed();
|
|
#if MESH_ENABLE_ETHERNET_TUNNEL
|
|
if (!eraseEthernetSettings()) {
|
|
snprintf(reply, reply_size, "File system erased; Error: Ethernet settings erase failed");
|
|
return true;
|
|
}
|
|
#endif
|
|
#if MESH_ENABLE_MESHTEXT_CHAT
|
|
if (!eraseMeshtextSettings()) {
|
|
snprintf(reply, reply_size, "File system erased; Error: MT settings erase failed");
|
|
return true;
|
|
}
|
|
#endif
|
|
snprintf(reply, reply_size, "File system erase: OK");
|
|
} else {
|
|
snprintf(reply, reply_size, "File system erase: Err");
|
|
}
|
|
return true;
|
|
}
|
|
if (strcmp(command, "stats-core") == 0) {
|
|
StatsFormatHelper::formatCoreStats(reply, board, *_ms, _err_flags, _mgr);
|
|
return true;
|
|
}
|
|
if (strcmp(command, "stats-radio") == 0) {
|
|
StatsFormatHelper::formatRadioStats(reply, _radio, radio_driver,
|
|
getTotalAirTime(), getReceiveAirTime());
|
|
return true;
|
|
}
|
|
if (strcmp(command, "stats-radio-diag") == 0) {
|
|
StatsFormatHelper::formatRadioDiag(reply, _radio, radio_driver, *_ms,
|
|
_err_flags, hasOutbound());
|
|
return true;
|
|
}
|
|
if (strcmp(command, "stats-packets") == 0) {
|
|
StatsFormatHelper::formatPacketStats(reply, radio_driver, getNumSentFlood(),
|
|
getNumSentDirect(), getNumRecvFlood(), getNumRecvDirect());
|
|
return true;
|
|
}
|
|
#if defined(ESP32) && defined(WIFI_SSID)
|
|
if (strcmp(command, "get wifi.pwd") == 0) {
|
|
#ifdef WITH_WEBCONFIG
|
|
WebConfigServer::formatWiFiPassword(reply, reply_size);
|
|
#else
|
|
char ssid[32] = {}, password[65] = {};
|
|
if (!WiFiSetupPortal::loadStoredCredentials(ssid, sizeof(ssid), password, sizeof(password))) {
|
|
#ifdef WIFI_PWD
|
|
snprintf(password, sizeof(password), "%s", WIFI_PWD);
|
|
#endif
|
|
}
|
|
snprintf(reply, reply_size, "> %s", password);
|
|
memset(password, 0, sizeof(password));
|
|
#endif
|
|
return true;
|
|
}
|
|
#ifdef WITH_MQTT_BRIDGE
|
|
if (strncmp(command, "get mqtt", 8) == 0 && command[8] >= '1'
|
|
&& command[8] <= '0' + MAX_MQTT_SLOTS && command[9] == '.') {
|
|
const int slot = command[8] - '1';
|
|
const char* field = command + 10;
|
|
const char* value = nullptr;
|
|
if (strcmp(field, "password") == 0) value = _mqtt_prefs.mqtt_slot_password[slot];
|
|
if (strcmp(field, "token") == 0) value = _mqtt_prefs.mqtt_slot_token[slot];
|
|
if (value) {
|
|
snprintf(reply, reply_size, "> %s", value);
|
|
return true;
|
|
}
|
|
}
|
|
#endif
|
|
#endif
|
|
return false;
|
|
}
|
|
|
|
bool MyMesh::handleCommand(const char* command, uint32_t sender_timestamp,
|
|
char* reply) {
|
|
if (command == NULL || reply == NULL) return false;
|
|
size_t reply_capacity = 160;
|
|
while (*command == ' ' || *command == '\t') command++;
|
|
|
|
if (strlen(command) > 3 && command[2] == '|') {
|
|
// Optional two-character request prefix used by Companion CLI clients.
|
|
memcpy(reply, command, 3);
|
|
reply += 3;
|
|
reply_capacity -= 3;
|
|
*reply = 0;
|
|
command += 3;
|
|
while (*command == ' ' || *command == '\t') command++;
|
|
}
|
|
|
|
#if defined(ESP32_PLATFORM)
|
|
// Catch remote requests before the direct-transport router below. That
|
|
// router is also used by USB/BLE/browser commands without a timestamp.
|
|
if (mesh::esp32::handlePowerTestCommand(command, reply, reply_capacity,
|
|
sender_timestamp == 0)) return true;
|
|
#endif
|
|
if (mesh::power::handleRegulatorCommand(command, reply, reply_capacity)) return true;
|
|
#if defined(WIO_E5_MINI_HEADLESS_COMPANION_UI)
|
|
if (handleCompactLedCommand(command, reply, reply_capacity)) return true;
|
|
#else
|
|
if (mesh::leds::handleCommand(command, reply, reply_capacity)) return true;
|
|
#endif
|
|
#if defined(PIN_BUZZER) || defined(PIN_VIBRATION) || defined(HAS_DRV2605)
|
|
if (handleAlertCommand(command, reply, reply_capacity)) return true;
|
|
#endif
|
|
|
|
#if COMPANION_FEATURE_READER
|
|
if (sender_timestamp == 0 && mesh::morseTimingActive(_radio)
|
|
&& isCompanionReaderRequest(command)) {
|
|
mesh::cli::formatError(reply, reply_capacity, "Reader unavailable during Morse");
|
|
return true;
|
|
}
|
|
if (sender_timestamp == 0
|
|
&& mesh::handleReaderReply(command, reply, reply_capacity)) return true;
|
|
#endif
|
|
|
|
if (mesh::power::handleStoredBatteryCalibrationCommand(
|
|
board, command, reply, reply_capacity)) return true;
|
|
#if defined(NRF52_POWER_MANAGEMENT)
|
|
if (mesh::power::handleVoltagePolicyCommand(command, reply,
|
|
reply_capacity)) return true;
|
|
#endif
|
|
|
|
#if defined(MESH_NRF52_BLE_TRACE) && MESH_NRF52_BLE_TRACE
|
|
if (sender_timestamp == 0
|
|
&& meshBleTraceCommand(command, reply, reply_capacity)) return true;
|
|
#endif
|
|
|
|
#if MESH_ENABLE_FLEET_CONTROL
|
|
if (!strncmp(command, "fleet", 5)
|
|
&& (command[5] == 0 || command[5] == ' ' || command[5] == '\t')) {
|
|
// Only the attached Companion client can originate multicast management.
|
|
// A remote CLI peer must not borrow this Companion's signing identity.
|
|
if (sender_timestamp != 0) {
|
|
mesh::cli::formatError(reply, reply_capacity, "fleet send requires local Companion access");
|
|
return true;
|
|
}
|
|
const char* text = command + 5;
|
|
while (*text == ' ' || *text == '\t') ++text;
|
|
if (strncmp(text, "send", 4)
|
|
|| (text[4] != ' ' && text[4] != '\t')) {
|
|
mesh::cli::formatError(reply, reply_capacity, "use fleet send <channel> [all|key|region:name|home:name|gps:lat,lon:km[;target...]] <command>");
|
|
return true;
|
|
}
|
|
text += 4;
|
|
while (*text == ' ' || *text == '\t') ++text;
|
|
uint32_t channel_index = 0;
|
|
bool have_index = false;
|
|
while (*text >= '0' && *text <= '9') {
|
|
have_index = true;
|
|
const uint8_t digit = *text++ - '0';
|
|
if (channel_index > (0xFFFFFFFFUL - digit) / 10UL) {
|
|
mesh::cli::formatError(reply, reply_capacity, "invalid fleet channel index");
|
|
return true;
|
|
}
|
|
channel_index = channel_index * 10UL + digit;
|
|
}
|
|
if (!have_index || channel_index >= MAX_GROUP_CHANNELS
|
|
|| (*text != ' ' && *text != '\t')) {
|
|
mesh::cli::formatError(reply, reply_capacity, "invalid fleet channel index");
|
|
return true;
|
|
}
|
|
while (*text == ' ' || *text == '\t') ++text;
|
|
mesh::FleetCommand::Targets targets{}; // No target means the whole fleet.
|
|
// Only a complete permitted command can omit its target. An invalid key
|
|
// or list must never silently broaden a request to the whole fleet.
|
|
if (!mesh::FleetCommand::commandAllowed(text)) {
|
|
const size_t target_length = strcspn(text, " \t");
|
|
// A target list is one signed request; never admit a partial list.
|
|
if (text[target_length] == 0
|
|
|| !mesh::FleetCommand::parseTargets(text, target_length, targets)) {
|
|
mesh::cli::formatError(reply, reply_capacity, "targets: all, 8/12/64-hex keys, region names, home:name or gps:lat,lon:km; semicolon-separated");
|
|
return true;
|
|
}
|
|
text += target_length;
|
|
while (*text == ' ' || *text == '\t') ++text;
|
|
}
|
|
if (!mesh::FleetCommand::commandAllowed(text)) {
|
|
mesh::cli::formatError(reply, reply_capacity, "command is not permitted for fleet management");
|
|
return true;
|
|
}
|
|
ChannelDetails channel;
|
|
if (!getChannel((int)channel_index, channel) || !channel.name[0]) {
|
|
mesh::cli::formatError(reply, reply_capacity, "fleet channel is not configured");
|
|
return true;
|
|
}
|
|
bool padded_key = true;
|
|
for (size_t i = mesh::FleetCommand::KeySize;
|
|
i < sizeof(channel.channel.secret); ++i)
|
|
padded_key = padded_key && channel.channel.secret[i] == 0;
|
|
if (!padded_key || !mesh::FleetCommand::privateKeyAllowed(channel.channel.secret)) {
|
|
mesh::cli::formatError(reply, reply_capacity, "fleet requires a private 128-bit channel key");
|
|
return true;
|
|
}
|
|
const uint32_t sequence = getRTCClock()->getCurrentTime();
|
|
static uint32_t last_fleet_sequence = 0;
|
|
if (sequence < mesh::FleetCommand::MinEpoch
|
|
|| sequence > 0xFFFFFFFFUL - mesh::FleetCommand::MaxLifetime) {
|
|
mesh::cli::formatError(reply, reply_capacity, "set the Companion clock before fleet send");
|
|
return true;
|
|
}
|
|
if (sequence <= last_fleet_sequence) {
|
|
mesh::cli::formatError(reply, reply_capacity, "wait for the next clock second before fleet send");
|
|
return true;
|
|
}
|
|
uint8_t payload[mesh::FleetCommand::MaxEnvelopeLength];
|
|
const size_t length = mesh::FleetCommand::encode(
|
|
self_id, channel.channel.secret, sequence,
|
|
sequence + mesh::FleetCommand::MaxLifetime, targets, text,
|
|
payload, sizeof(payload));
|
|
if (!length) {
|
|
mesh::cli::formatError(reply, reply_capacity, "fleet targets and command exceed two packets; use fewer targets");
|
|
} else if (!sendFleetCommandData(channel.channel, payload, length)) {
|
|
mesh::cli::formatError(reply, reply_capacity, "could not queue fleet command");
|
|
} else {
|
|
last_fleet_sequence = sequence;
|
|
snprintf(reply, reply_capacity, "OK - fleet command queued; seq=%lu",
|
|
(unsigned long)sequence);
|
|
}
|
|
memset(payload, 0, sizeof(payload));
|
|
return true;
|
|
}
|
|
|
|
#endif
|
|
if (mesh::handleMorseReplyCommand(_morse_reply, _radio, command, reply,
|
|
reply_capacity, millis())) return true;
|
|
if (mesh::handleMorseCommand(_radio, command, reply, reply_capacity)) return true;
|
|
if (_radio_profiles.handle(command, reply, reply_capacity, sender_timestamp != 0)) return true;
|
|
if (!strncmp(command, "set tempradio ", 14)) command += 4;
|
|
if (!strcmp(command, "get tempradio")) command += 4;
|
|
if (!strncmp(command, "set radio ", 10)) {
|
|
char legacy[120];
|
|
uint16_t preamble = 0;
|
|
float f, bw; uint8_t sf, cr;
|
|
if (!mesh::RadioProfileCLI::parseSuffix(command + 10, 4, legacy, sizeof(legacy), preamble)
|
|
|| !mesh::cli::parseRadioTupleStrict(legacy, f, bw, sf, cr)) {
|
|
mesh::cli::formatError(reply, reply_capacity, "invalid radio params"); return true;
|
|
}
|
|
if (!_radio_profiles.acceptsPrimary(f, bw, sf, cr, preamble)) {
|
|
mesh::cli::formatError(reply, reply_capacity, "radio params unsupported"); return true;
|
|
}
|
|
const auto result = applyAndSaveRadioParams(f, bw, sf, cr, _prefs.client_repeat,
|
|
preamble, sender_timestamp != 0);
|
|
snprintf(reply, reply_capacity, "%s", result == RadioSettingResult::Saved ? "OK"
|
|
: result == RadioSettingResult::SaveFailed ? "Error: radio not saved"
|
|
: result == RadioSettingResult::SaveUnconfirmed ? "Error: radio save unconfirmed"
|
|
: "Error: radio rejected");
|
|
if (result == RadioSettingResult::Saved)
|
|
_radio_profiles.appendPrimaryChirpWarning(reply, reply_capacity, sf, bw, preamble);
|
|
return true;
|
|
}
|
|
if (sender_timestamp == 0 && handleDirectCommand(command, reply, reply_capacity)) return true;
|
|
if (handleNotificationCommand(command, reply, reply_capacity)) return true;
|
|
|
|
if (mesh::companion::handleRetryCommand(
|
|
_prefs, command, reply, reply_capacity,
|
|
[this]() { return savePrefs(); },
|
|
[this]() { cancelAllFloodRetries(); })) return true;
|
|
|
|
#if COMPANION_FEATURE_USB_MOTA_SOURCE
|
|
if (mesh::isUsbMotaOwnerTransitionCommand(command)) {
|
|
snprintf(reply, reply_capacity,
|
|
"ERR ota folder on/off requires the owning USB mOTA session");
|
|
return true;
|
|
}
|
|
#endif
|
|
|
|
if (sender_timestamp == 0 && mesh::cli::isUf2ResetCommand(command)) {
|
|
if (!board.rebootToUf2Bootloader()) {
|
|
snprintf(reply, reply_capacity, "ERR: unsupported");
|
|
}
|
|
return true;
|
|
}
|
|
|
|
if (strncmp(command, "set freq ", 9) == 0) {
|
|
float frequency;
|
|
if (!mesh::cli::parseDecimalStrict(command + 9, frequency)
|
|
|| frequency < 150.0f || frequency > 2500.0f) {
|
|
mesh::cli::formatError(reply, reply_capacity, "frequency must be 150-2500 MHz");
|
|
} else {
|
|
const auto result = applyAndSaveRadioParams(frequency, _prefs.bw, _prefs.sf, _prefs.cr,
|
|
_prefs.client_repeat, _radio_profiles.primaryPreamble(), sender_timestamp != 0);
|
|
snprintf(reply, reply_capacity, "%s", result == RadioSettingResult::Saved ? "OK"
|
|
: result == RadioSettingResult::SaveFailed ? "Error: radio not saved"
|
|
: result == RadioSettingResult::SaveUnconfirmed ? "Error: radio save unconfirmed"
|
|
: "Error: radio rejected");
|
|
}
|
|
return true;
|
|
}
|
|
|
|
if (handleLocalControlCommand(command, reply, reply_capacity)) return true;
|
|
|
|
#if defined(MESH_HIL_TX_FREQ_MHZ)
|
|
// RF test builds verify active power before permitting any test TX.
|
|
if (strcmp(command, "get tx.status") == 0) {
|
|
snprintf(reply, reply_capacity, "> desired=%d,apply=%s", (int)_prefs.tx_power_dbm,
|
|
!tx_power_apply_pending && _radio_available
|
|
&& radio_driver.isTxPowerApplied(_prefs.tx_power_dbm)
|
|
? "applied" : "pending");
|
|
return true;
|
|
}
|
|
#endif
|
|
|
|
if (strncmp(command, "set tx ", 7) == 0) {
|
|
int32_t parsed = 0;
|
|
if (!mesh::cli::parseIntegerStrict(command + 7, parsed)) {
|
|
mesh::cli::formatError(reply, reply_capacity, "invalid TX power");
|
|
} else if (parsed < -9 || parsed > MAX_LORA_TX_POWER) {
|
|
snprintf(reply, reply_capacity, "Error: TX -9..%d dBm",
|
|
MAX_LORA_TX_POWER);
|
|
} else {
|
|
const int8_t requested = static_cast<int8_t>(parsed);
|
|
const auto result = applyAndSaveTxPower(requested);
|
|
if (result == RadioSettingResult::RadioRejected) {
|
|
mesh::cli::formatError(reply, reply_capacity, "TX power rejected");
|
|
} else if (result == RadioSettingResult::SaveFailed) {
|
|
mesh::cli::formatError(reply, reply_capacity, "TX not saved");
|
|
} else {
|
|
snprintf(reply, reply_capacity, "%s",
|
|
tx_power_apply_pending ? "OK - saved; pending" : "OK");
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
if (isCompanionRadioPrefsCommand(command)) {
|
|
const float previous_af = _prefs.airtime_factor;
|
|
const float previous_rxdelay = _prefs.rx_delay_base;
|
|
const float previous_txdelay = _prefs.tx_delay_factor;
|
|
const float previous_direct_txdelay = _prefs.direct_tx_delay_factor;
|
|
const uint8_t previous_interference = _prefs.interference_threshold;
|
|
const uint8_t previous_agc = _prefs.agc_reset_interval;
|
|
const uint8_t previous_hash_mode = _prefs.path_hash_mode;
|
|
const uint8_t previous_multi_acks = _prefs.multi_acks;
|
|
|
|
if (!_prefs.getRadioPrefs()->handleCommand(
|
|
command, sender_timestamp, reply, false)) {
|
|
return false;
|
|
}
|
|
if (_prefs.getRadioPrefs()->isDirty() && strncmp(reply, "OK", 2) == 0) {
|
|
if (!savePrefs()) {
|
|
_prefs.airtime_factor = previous_af;
|
|
_prefs.rx_delay_base = previous_rxdelay;
|
|
_prefs.tx_delay_factor = previous_txdelay;
|
|
_prefs.direct_tx_delay_factor = previous_direct_txdelay;
|
|
_prefs.interference_threshold = previous_interference;
|
|
_prefs.agc_reset_interval = previous_agc;
|
|
_prefs.path_hash_mode = previous_hash_mode;
|
|
_prefs.multi_acks = previous_multi_acks;
|
|
_prefs.clearDirty();
|
|
mesh::cli::formatError(reply, reply_capacity, "setting changed but save failed");
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
#if MESH_BATTERY_CHARGE_CONTROL
|
|
if (mesh::power::handleBatteryChargeCommand(board, command, reply,
|
|
reply_capacity)) return true;
|
|
#endif
|
|
|
|
// Hook for future variant-specific commands not covered by the shared,
|
|
// runtime-aware FEM handlers above.
|
|
if (board.handleCommand(command, sender_timestamp, reply)) {
|
|
if (_prefs.isDirty() && !savePrefs()) {
|
|
mesh::cli::formatError(reply, reply_capacity, "board setting changed but save failed");
|
|
}
|
|
return true;
|
|
}
|
|
|
|
if (sender_timestamp == 0
|
|
&& (strcmp(command, "poweroff") == 0
|
|
|| strcmp(command, "shutdown") == 0)) {
|
|
strcpy(reply, "OK");
|
|
board.powerOff();
|
|
return true;
|
|
}
|
|
|
|
if (strncmp(command, "set name ", 9) == 0) {
|
|
const char* name = command + 9;
|
|
if (!AdvertDataParser::isValidName(name)) {
|
|
strcpy(reply, "Error, bad chars");
|
|
} else {
|
|
strcpy(reply, saveAdvertName(name) ? "OK" : "Error: name could not be saved");
|
|
}
|
|
return true;
|
|
}
|
|
if (strcmp(command, "get name") == 0) {
|
|
snprintf(reply, reply_capacity, "> %s", _prefs.node_name);
|
|
return true;
|
|
}
|
|
|
|
if (strcmp(command, "ver") == 0) {
|
|
snprintf(reply, reply_capacity, "%s (Build: %s)", FIRMWARE_VERSION,
|
|
FIRMWARE_BUILD_DATE);
|
|
return true;
|
|
}
|
|
|
|
#if defined(RP2040_PLATFORM) && defined(ENABLE_WIFI_INTERFACE)
|
|
if (strcmp(command, "get wifi.ssid") == 0) {
|
|
const char* ssid = _prefs.wifi_ssid[0] ? _prefs.wifi_ssid : WIFI_SSID;
|
|
snprintf(reply, reply_capacity, "> %s", *ssid ? ssid : "(not set)");
|
|
return true;
|
|
}
|
|
if (strcmp(command, "get wifi.enabled") == 0) {
|
|
snprintf(reply, reply_capacity, "> %d", _prefs.wifi_enabled);
|
|
return true;
|
|
}
|
|
if (strcmp(command, "get wifi.status") == 0 || strcmp(command, "get wifi.ip") == 0) {
|
|
const bool connected = WiFi.status() == WL_CONNECTED;
|
|
if (strcmp(command, "get wifi.status") == 0) {
|
|
snprintf(reply, reply_capacity, "> %s", connected ? "connected" : "disconnected");
|
|
} else {
|
|
snprintf(reply, reply_capacity, "> %s", connected ? WiFi.localIP().toString().c_str() : "(not connected)");
|
|
}
|
|
return true;
|
|
}
|
|
const bool set_ssid = strncmp(command, "set wifi.ssid ", 14) == 0;
|
|
const bool set_pwd = strncmp(command, "set wifi.pwd ", 13) == 0;
|
|
const bool set_enabled = strncmp(command, "set wifi.enabled ", 17) == 0;
|
|
const bool clear_wifi = strcmp(command, "set wifi.clear") == 0;
|
|
if (set_ssid || set_pwd || set_enabled || clear_wifi) {
|
|
char old_ssid[sizeof(_prefs.wifi_ssid)], old_pwd[sizeof(_prefs.wifi_pwd)];
|
|
memcpy(old_ssid, _prefs.wifi_ssid, sizeof(old_ssid));
|
|
memcpy(old_pwd, _prefs.wifi_pwd, sizeof(old_pwd));
|
|
const uint8_t old_enabled = _prefs.wifi_enabled;
|
|
const char* value = command + (set_ssid ? 14 : set_pwd ? 13 : set_enabled ? 17 : 0);
|
|
int32_t enabled = 0;
|
|
if ((set_ssid && strlen(value) >= sizeof(_prefs.wifi_ssid))
|
|
|| (set_pwd && strlen(value) >= sizeof(_prefs.wifi_pwd))
|
|
|| (set_enabled && (!mesh::cli::parseIntegerStrict(value, enabled) || enabled < 0 || enabled > 1))) {
|
|
mesh::cli::formatError(reply, reply_capacity, "invalid WiFi setting");
|
|
return true;
|
|
}
|
|
if (set_ssid) strcpy(_prefs.wifi_ssid, value);
|
|
if (set_pwd) strcpy(_prefs.wifi_pwd, value);
|
|
if (set_enabled) _prefs.wifi_enabled = enabled;
|
|
if (clear_wifi) { _prefs.wifi_ssid[0] = 0; _prefs.wifi_pwd[0] = 0; }
|
|
if (savePrefs()) strcpy(reply, "OK - reboot to apply WiFi settings");
|
|
else {
|
|
memcpy(_prefs.wifi_ssid, old_ssid, sizeof(old_ssid));
|
|
memcpy(_prefs.wifi_pwd, old_pwd, sizeof(old_pwd));
|
|
_prefs.wifi_enabled = old_enabled;
|
|
mesh::cli::formatError(reply, reply_capacity, "WiFi setting could not be saved");
|
|
}
|
|
memset(old_pwd, 0, sizeof(old_pwd));
|
|
return true;
|
|
}
|
|
#endif
|
|
|
|
if (strcmp(command, "get tz.offset") == 0) {
|
|
sprintf(reply, "> %.9g", static_cast<double>(_prefs.tz_offset));
|
|
return true;
|
|
}
|
|
if (strncmp(command, "set tz.offset ", 14) == 0) {
|
|
float tz;
|
|
if (!mesh::cli::parseDecimalStrict(command + 14, tz)
|
|
|| !mesh::companion::timezone::valid(tz)) {
|
|
strcpy(reply, "Error, must be from -12 to +14");
|
|
} else {
|
|
strcpy(reply, savePreference<float>(_prefs.tz_offset, tz)
|
|
? "OK" : "Error: setting could not be saved");
|
|
}
|
|
return true;
|
|
}
|
|
|
|
return false; // not handled
|
|
}
|
|
|
|
void MyMesh::checkCLIRescueCmd() {
|
|
Stream& output = mesh::usbTerminalPort();
|
|
Stream& input = mesh::usbCompanionPort();
|
|
size_t len = strnlen(cli_command, sizeof(cli_command));
|
|
if (len == sizeof(cli_command)) {
|
|
cli_command[0] = 0;
|
|
len = 0;
|
|
_cli_line_overflow = true;
|
|
}
|
|
unsigned budget = sizeof(cli_command);
|
|
while (budget-- && input.available()) {
|
|
char c = input.read();
|
|
output.print(c); // echo
|
|
if (c == '\r' || c == '\n') {
|
|
if (_cli_line_overflow) {
|
|
_cli_line_overflow = false;
|
|
#if defined(STM32_PLATFORM)
|
|
output.println(" Error: unknown command");
|
|
#else
|
|
output.print("ERR: too long\r\n");
|
|
#endif
|
|
} else if (len) goto complete_line;
|
|
return;
|
|
}
|
|
if (_cli_line_overflow) continue;
|
|
if (len == sizeof(cli_command)-1) {
|
|
_cli_line_overflow = true;
|
|
cli_command[0] = 0;
|
|
len = 0;
|
|
continue;
|
|
}
|
|
cli_command[len++] = c;
|
|
cli_command[len] = 0;
|
|
}
|
|
|
|
return;
|
|
|
|
complete_line:
|
|
{
|
|
reply_buf[0] = 0;
|
|
#if COMPANION_FEATURE_READER
|
|
if (mesh::morseTimingActive(_radio) && isCompanionReaderRequest(cli_command)) {
|
|
mesh::cancelReaderCommand();
|
|
output.print(" ERROR: Reader unavailable during Morse\r\n");
|
|
} else if (mesh::handleReaderCommand(cli_command, output)) {
|
|
// USB rescue streams chapters/ranges cooperatively like the terminal.
|
|
} else
|
|
#endif
|
|
if (handleCommand(cli_command, 0, reply_buf)) {
|
|
// command was handled, print reply output
|
|
output.print(" "); output.print(reply_buf); output.println();
|
|
#if defined(NRF52_PLATFORM) && defined(EXTRAFS) && !defined(QSPIFLASH)
|
|
} else if (strcmp(cli_command, "repair extrafs") == 0) {
|
|
repairInternalExtraFS(output);
|
|
} else if (strcmp(cli_command, "scan extrafs erase") == 0) {
|
|
scanInternalExtraFS(output);
|
|
#endif
|
|
} else if (strcmp(cli_command, "rebuild") == 0) {
|
|
#if MESH_ENABLE_TX_BEACONS
|
|
suspendBeacons();
|
|
#endif
|
|
bool success = _store->formatFileSystem();
|
|
if (success) {
|
|
const bool identity_saved = _store->saveMainIdentity(self_id);
|
|
const bool prefs_saved = savePrefs();
|
|
const bool contacts_saved = _store->saveContacts(this, save_filter);
|
|
const bool channels_saved = saveChannels();
|
|
const bool contacts_complete =
|
|
contacts_saved && !_store->hasPendingContactWrites();
|
|
|
|
if (contacts_complete) {
|
|
MyMesh::onContactCacheFlushed();
|
|
} else {
|
|
scheduleContactWriteRetry();
|
|
}
|
|
|
|
if (identity_saved && prefs_saved && contacts_complete
|
|
&& channels_saved) {
|
|
output.println(" > erase and rebuild done");
|
|
} else {
|
|
output.println(" Error: erase succeeded, but data rebuild failed; retry rebuild");
|
|
}
|
|
} else {
|
|
output.println(" Error: erase failed");
|
|
}
|
|
} else if (strcmp(cli_command, "erase") == 0) {
|
|
#if MESH_ENABLE_TX_BEACONS
|
|
suspendBeacons();
|
|
#endif
|
|
bool success = _store->formatFileSystem();
|
|
#if MESH_ENABLE_ETHERNET_TUNNEL
|
|
if (success && !eraseEthernetSettings()) {
|
|
output.println(" Error: file system erased, but Ethernet settings erase failed");
|
|
return;
|
|
}
|
|
#endif
|
|
#if MESH_ENABLE_MESHTEXT_CHAT
|
|
if (success && !eraseMeshtextSettings()) {
|
|
output.println(" Error: file system erased, but MT settings erase failed");
|
|
return;
|
|
}
|
|
#endif
|
|
if (success) {
|
|
output.println(" > erase done");
|
|
} else {
|
|
output.println(" Error: erase failed");
|
|
}
|
|
} else if (memcmp(cli_command, "ls", 2) == 0) {
|
|
|
|
// get path from command e.g: "ls /adafruit"
|
|
const char *path = &cli_command[3];
|
|
|
|
bool is_fs2 = false;
|
|
if (memcmp(path, "UserData/", 9) == 0) {
|
|
path += 8; // skip "UserData"
|
|
} else if (memcmp(path, "ExtraFS/", 8) == 0) {
|
|
path += 7; // skip "ExtraFS"
|
|
is_fs2 = true;
|
|
}
|
|
output.printf("Listing files in %s\n", path);
|
|
|
|
// log each file and directory
|
|
File root = _store->openDirectory(path);
|
|
if (is_fs2 == false) {
|
|
if (root) {
|
|
File file = root.openNextFile();
|
|
while (file) {
|
|
if (file.isDirectory()) {
|
|
output.printf("[dir] UserData%s/%s\n", path, file.name());
|
|
} else {
|
|
output.printf("[file] UserData%s/%s (%d bytes)\n", path,
|
|
file.name(), file.size());
|
|
}
|
|
// move to next file
|
|
file = root.openNextFile();
|
|
}
|
|
root.close();
|
|
}
|
|
}
|
|
|
|
if (is_fs2 == true || strlen(path) == 0 || strcmp(path, "/") == 0) {
|
|
if (_store->getSecondaryFS() != nullptr) {
|
|
File root2 = _store->openDirectory(_store->getSecondaryFS(), path);
|
|
File file = root2.openNextFile();
|
|
while (file) {
|
|
if (file.isDirectory()) {
|
|
output.printf("[dir] ExtraFS%s/%s\n", path, file.name());
|
|
} else {
|
|
output.printf("[file] ExtraFS%s/%s (%d bytes)\n", path,
|
|
file.name(), file.size());
|
|
}
|
|
// move to next file
|
|
file = root2.openNextFile();
|
|
}
|
|
root2.close();
|
|
}
|
|
}
|
|
} else if (memcmp(cli_command, "cat", 3) == 0) {
|
|
|
|
// get path from command e.g: "cat /contacts3"
|
|
const char *path = &cli_command[4];
|
|
|
|
bool is_fs2 = false;
|
|
if (memcmp(path, "UserData/", 9) == 0) {
|
|
path += 8; // skip "UserData"
|
|
} else if (memcmp(path, "ExtraFS/", 8) == 0) {
|
|
path += 7; // skip "ExtraFS"
|
|
is_fs2 = true;
|
|
} else {
|
|
output.println(
|
|
"Invalid path provided, must start with UserData/ or ExtraFS/");
|
|
cli_command[0] = 0;
|
|
return;
|
|
}
|
|
|
|
// A corrupt internal ExtraFS is deliberately quarantined until the
|
|
// explicit repair command runs. Never pass that null secondary pointer
|
|
// into the raw filesystem helpers while it is unavailable.
|
|
FILESYSTEM* selected_fs = is_fs2
|
|
? _store->getSecondaryFS()
|
|
: _store->getPrimaryFS();
|
|
if (selected_fs == nullptr) {
|
|
output.println("ExtraFS is unavailable; run repair extrafs first");
|
|
} else {
|
|
// log file content as hex
|
|
File file = _store->openRead(selected_fs, path);
|
|
if(file){
|
|
|
|
// Stream the dump in bounded chunks. An ExtraFS file can be much
|
|
// larger than the nRF52 task stack, so never allocate file.size()
|
|
// bytes on the stack here.
|
|
uint8_t buffer[64];
|
|
while (file.available() > 0) {
|
|
const int count = file.read(buffer, sizeof(buffer));
|
|
if (count <= 0) break;
|
|
mesh::Utils::printHex(output, buffer, count);
|
|
}
|
|
output.print("\n");
|
|
|
|
file.close();
|
|
|
|
}
|
|
}
|
|
|
|
} else if (memcmp(cli_command, "rm ", 3) == 0) {
|
|
// get path from command e.g: "rm /adv_blobs"
|
|
const char *path = &cli_command[3];
|
|
MESH_DEBUG_PRINTLN("Removing file: %s", path);
|
|
// ensure path is not empty, or root dir
|
|
if(!path || strlen(path) == 0 || strcmp(path, "/") == 0){
|
|
output.println("Invalid path provided");
|
|
} else {
|
|
bool is_fs2 = false;
|
|
if (memcmp(path, "UserData/", 9) == 0) {
|
|
path += 8; // skip "UserData"
|
|
} else if (memcmp(path, "ExtraFS/", 8) == 0) {
|
|
path += 7; // skip "ExtraFS"
|
|
is_fs2 = true;
|
|
}
|
|
|
|
// remove file
|
|
FILESYSTEM* selected_fs = is_fs2
|
|
? _store->getSecondaryFS()
|
|
: _store->getPrimaryFS();
|
|
if (selected_fs == nullptr) {
|
|
output.println("ExtraFS is unavailable; run repair extrafs first");
|
|
} else {
|
|
if (is_fs2) {
|
|
MESH_DEBUG_PRINTLN("Removing file from ExtraFS: %s", path);
|
|
} else {
|
|
MESH_DEBUG_PRINTLN("Removing file from UserData: %s", path);
|
|
}
|
|
if (_store->removeFile(selected_fs, path)) {
|
|
output.println("File removed");
|
|
} else {
|
|
output.println("Failed to remove file");
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
} else if (strcmp(cli_command, "reboot") == 0) {
|
|
if (MyMesh::prepareForUiShutdown()) {
|
|
board.reboot(); // doesn't return
|
|
} else {
|
|
output.println(" Error: pending contacts could not be saved; reboot cancelled");
|
|
}
|
|
} else {
|
|
output.println(" Error: unknown command");
|
|
}
|
|
|
|
cli_command[0] = 0; // reset command buffer
|
|
}
|
|
}
|
|
|
|
void MyMesh::checkSerialInterface() {
|
|
size_t len = _serial->checkRecvFrame(cmd_frame);
|
|
if (_iter_started && !_serial->isReplyRouteAvailable(_iter_reply_route)) {
|
|
stopContactsIterator();
|
|
}
|
|
if (!_serial->isConnected()) {
|
|
stopContactsIterator();
|
|
return;
|
|
}
|
|
|
|
if (len > 0) {
|
|
handleCmdFrame(len);
|
|
}
|
|
if (_iter_started // check if our ContactsIterator is 'running'
|
|
&& (_iter_next_frame_at == 0
|
|
|| _iter_next_frame_at == _ms->getMillis()
|
|
|| millisHasNowPassed(_iter_next_frame_at))
|
|
&& !_serial->isReplyRouteWriteBusy(_iter_reply_route)
|
|
) {
|
|
if (_iter_table_revision != getContactTableRevision()) {
|
|
// The paced stream walks the live compacting table. If an advert, app
|
|
// command, or overwrite changes its membership mid-stream, start a fresh
|
|
// snapshot marker before reading another entry. Clients already reset
|
|
// their accumulation on CONTACTS_START, so they can never accept a
|
|
// skipped, duplicated, or wrong-count list.
|
|
_iter = startContactsIterator();
|
|
_iter_start_pending = true;
|
|
_iter_contact_pending = false;
|
|
_iter_total_count = getNumContacts();
|
|
_iter_table_revision = getContactTableRevision();
|
|
_most_recent_lastmod = 0;
|
|
}
|
|
|
|
if (_iter_start_pending) {
|
|
uint8_t reply[5];
|
|
reply[0] = RESP_CODE_CONTACTS_START;
|
|
memcpy(&reply[1], &_iter_total_count, 4);
|
|
if (_serial->writeFrameToRoute(_iter_reply_route, reply, sizeof(reply))
|
|
== sizeof(reply)) {
|
|
_iter_start_pending = false;
|
|
_iter_next_frame_at = futureMillis(CONTACT_STREAM_FRAME_INTERVAL_MS);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (!_iter_contact_pending) {
|
|
ContactInfo contact;
|
|
if (_iter.hasNext(this, contact)) {
|
|
if (contact.lastmod <= _iter_filter_since) return;
|
|
_iter_pending_contact = contact;
|
|
_iter_contact_pending = true;
|
|
} else { // EOF
|
|
out_frame[0] = RESP_CODE_END_OF_CONTACTS;
|
|
memcpy(&out_frame[1], &_most_recent_lastmod,
|
|
4); // include the most recent lastmod, so app can update their 'since'
|
|
if (_serial->writeFrameToRoute(_iter_reply_route, out_frame, 5) == 5) {
|
|
stopContactsIterator();
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Do not advance past a contact until its complete frame has been accepted
|
|
// by the selected transport. A transiently full/lost route must not turn a
|
|
// successful CONTACTS_START/END transaction into a silently short list.
|
|
if (writeContactRespFrame(RESP_CODE_CONTACT, _iter_pending_contact,
|
|
_iter_reply_route)) {
|
|
if (_iter_pending_contact.lastmod > _most_recent_lastmod) {
|
|
_most_recent_lastmod = _iter_pending_contact.lastmod;
|
|
}
|
|
_iter_contact_pending = false;
|
|
_iter_next_frame_at = futureMillis(CONTACT_STREAM_FRAME_INTERVAL_MS);
|
|
}
|
|
//} else if (!_serial->isWriteBusy()) {
|
|
// checkConnections(); // TODO - deprecate the 'Connections' stuff
|
|
}
|
|
}
|
|
|
|
void MyMesh::loop() {
|
|
// Storage callbacks can request recovery while holding ContactInfo/path
|
|
// references. Retire those references only here, between callbacks.
|
|
if (_store->contactRecoveryPending() && !mesh::morseTimingActive(_radio)) {
|
|
_store->recoverContacts(this);
|
|
updateGpsTelemetryPolicy();
|
|
if (!dirty_contacts_expiry && _store->hasPendingContactWrites()) {
|
|
dirty_contacts_expiry = mesh::nonzeroLazyPersistenceDeadline(
|
|
futureMillis(LAZY_CONTACTS_WRITE_DELAY));
|
|
}
|
|
}
|
|
#if MESH_ENABLE_MORSE_RX
|
|
if (_morse_reply.pending()) {
|
|
const ContactInfo* actor = lookupContactByPubKey(_morse_reply.actor(), PUB_KEY_SIZE);
|
|
const auto& settings = mesh::morseSettings();
|
|
_morse_reply.service(_radio, millis(), actor && actor->isRemoteCLIAllowed(),
|
|
mesh::morseSettingsUsable(), settings.frequency_khz, settings.wpm);
|
|
}
|
|
#endif
|
|
#if COMPANION_FEATURE_NOTIFICATIONS
|
|
_notifications.loop(millis(), _serial->isConnected());
|
|
#if defined(PIN_USER_BTN) && defined(USER_BTN_PRESSED)
|
|
const bool notification_button_down = digitalRead(PIN_USER_BTN) == USER_BTN_PRESSED;
|
|
if (notification_button_down && !_notification_button_down) _notifications.button();
|
|
_notification_button_down = notification_button_down;
|
|
#endif
|
|
#endif
|
|
|
|
if (!mesh::morseTimingActive(_radio)) _radio_profiles.loop();
|
|
#if MESH_ENABLE_TX_BEACONS
|
|
if (!mesh::morseTimingActive(_radio)) loopBeacons();
|
|
#endif
|
|
#if MESH_ENABLE_MESHTEXT_CHAT
|
|
refreshMeshtextProfile();
|
|
if (!mesh::morseTimingActive(_radio)) loopMeshtextDelivery();
|
|
#endif
|
|
#if MESH_ENABLE_ETHERNET_TUNNEL
|
|
if (!mesh::morseTimingActive(_radio)) loopEthernetTunnel();
|
|
#endif
|
|
#if defined(WITH_MQTT_BRIDGE) && defined(ESP32_PLATFORM) && defined(WIFI_SSID)
|
|
if (_mqtt_bridge) _mqtt_bridge->servicePendingClockCorrection();
|
|
#endif
|
|
if (!mesh::morseTimingActive(_radio) && _scheduled_reboot_at != 0
|
|
&& (_scheduled_reboot_at == _ms->getMillis()
|
|
|| millisHasNowPassed(_scheduled_reboot_at))) {
|
|
_scheduled_reboot_at = 0;
|
|
if (MyMesh::prepareForUiShutdown()) {
|
|
board.reboot();
|
|
} else {
|
|
MESH_DEBUG_PRINTLN("Scheduled reboot cancelled: pending contacts could not be saved");
|
|
}
|
|
return;
|
|
}
|
|
#if COMPANION_FEATURE_TEMP_RADIO
|
|
if (!mesh::morseTimingActive(_radio)) serviceTempRadio();
|
|
#endif
|
|
#if defined(OTA_SHARED_COMPANION_QUEUE)
|
|
mesh::ota::ota_release_context_if_idle(isAnyTempRadioActive() || _temp_radio_set_at != 0);
|
|
#elif defined(ENABLE_OTA) && defined(OTA_HEAP_CONTEXT)
|
|
mesh::ota::ota_service_temp_radio_context(isAnyTempRadioActive()
|
|
#if COMPANION_FEATURE_TEMP_RADIO
|
|
|| _temp_radio_set_at != 0
|
|
#endif
|
|
);
|
|
#endif
|
|
BaseChatMesh::loop();
|
|
#if defined(MESHCORE_HAS_COLOR_DISPLAY)
|
|
if (!mesh::morseTimingActive(_radio)) {
|
|
mesh::ui::refreshDisplayNightLocation(sensors, getRTCClock()->getCurrentTime(), millis());
|
|
}
|
|
#endif
|
|
#ifdef COMPANION_MESH_CLOCK_SYNC
|
|
if (!mesh::morseTimingActive(_radio)) _clock_sync.loop();
|
|
#endif
|
|
#if COMPANION_FEATURE_TEXT_TERMINAL
|
|
serviceTerminalLogin();
|
|
serviceTerminalCommand();
|
|
serviceTerminalTrace();
|
|
serviceMorseTerminal();
|
|
#endif
|
|
servicePendingSerialReply();
|
|
#if COMPANION_FEATURE_READER
|
|
// Every verse chunk reinflates its 4 KiB flash block. Retain the cursor but
|
|
// defer that optional decode/output work until envelope timing is finished.
|
|
if (!mesh::morseTimingActive(_radio)) mesh::serviceReaderCommand();
|
|
#endif
|
|
serviceBinaryTraceReply();
|
|
if (!mesh::morseTimingActive(_radio)) serviceSigningSession();
|
|
|
|
if (has_next_ack_expiry
|
|
&& (next_ack_expiry == _ms->getMillis() || millisHasNowPassed(next_ack_expiry))) {
|
|
expireExpectedAcks();
|
|
}
|
|
if (emergency_client_repeat_packet != NULL && millisHasNowPassed(emergency_client_repeat_send_at)) {
|
|
mesh::Packet* pkt = emergency_client_repeat_packet;
|
|
emergency_client_repeat_packet = NULL;
|
|
sendPacket(pkt, 1, 0);
|
|
}
|
|
|
|
if (_cli_rescue) {
|
|
checkCLIRescueCmd();
|
|
} else {
|
|
checkSerialInterface();
|
|
#if defined(ENABLE_WIFI_INTERFACE) && defined(RP2040_PLATFORM) && !defined(ENABLE_USB_INTERFACE)
|
|
// RP2040 WiFi builds are headless and have no way into the rescue CLI (that needs a
|
|
// display + long-press), so serve config commands on the otherwise unused USB serial
|
|
checkCLIRescueCmd();
|
|
#endif
|
|
}
|
|
servicePendingLocalRadioSettings();
|
|
#if MESH_ENABLE_ONE_KEY_DM
|
|
if (held_dm_count != 0 || verified_pending_count != 0) releaseHeldOneKeyDMs();
|
|
#endif
|
|
if (!mesh::morseTimingActive(_radio)) servicePendingChannelNotices();
|
|
|
|
#if defined(ESP32_PLATFORM)
|
|
servicePersistence();
|
|
#else
|
|
// is there are pending dirty contacts write needed?
|
|
if (!mesh::morseTimingActive(_radio) && isContactWriteDue()) {
|
|
const bool success = _store->serviceContactWrites(this, save_filter);
|
|
if (!success) {
|
|
scheduleContactWriteRetry();
|
|
} else if (_store->hasPendingContactWrites()) {
|
|
dirty_contacts_failures = 0;
|
|
dirty_contacts_expiry = mesh::nonzeroLazyPersistenceDeadline(
|
|
futureMillis(CONTACT_PAGE_WRITE_GAP));
|
|
} else {
|
|
MyMesh::onContactCacheFlushed();
|
|
}
|
|
}
|
|
#endif
|
|
|
|
#ifdef DISPLAY_CLASS
|
|
if (_ui) {
|
|
_ui->setHasConnection(_serial->isConnected());
|
|
#if COMPANION_FEATURE_NOTIFICATIONS
|
|
const auto& notifications = _notifications.settings();
|
|
_ui->setNotificationOutputs(notifications.enabled ? notifications.outputs : 31);
|
|
_ui->setNotificationLedOwnership(_notifications.ownsLed());
|
|
if (notifications.enabled && !(notifications.outputs & mesh::notify::Led)) pulse(mesh::notify::Led, false, -1);
|
|
#endif
|
|
}
|
|
#endif
|
|
}
|
|
|
|
#if defined(ESP32_PLATFORM)
|
|
void MyMesh::servicePersistence() {
|
|
if (mesh::morseTimingActive(_radio)) return;
|
|
// Advert crypto runs before serial service. Defer its cache I/O until here,
|
|
// and alternate with due contact work so neither job can starve the other.
|
|
// Only one persistence job is serviced in a loop pass.
|
|
if (_store->consumeSynchronousAdvertIO()) return;
|
|
const bool contact_write_due = isContactWriteDue();
|
|
const bool advert_write_due = _store->isAdvertWriteDue(_ms->getMillis());
|
|
const bool service_advert = advert_write_due
|
|
&& (!contact_write_due || _advert_write_next);
|
|
if (service_advert) {
|
|
_store->serviceAdvertWrites(_ms->getMillis());
|
|
_advert_write_next = false;
|
|
return;
|
|
}
|
|
_advert_write_next = true;
|
|
if (isContactWriteDue()) {
|
|
const bool success = _store->serviceContactWrites(this, save_filter);
|
|
if (!success) {
|
|
scheduleContactWriteRetry();
|
|
} else if (_store->hasPendingContactWrites()) {
|
|
dirty_contacts_failures = 0;
|
|
dirty_contacts_expiry = mesh::nonzeroLazyPersistenceDeadline(
|
|
futureMillis(CONTACT_PAGE_WRITE_GAP));
|
|
} else {
|
|
MyMesh::onContactCacheFlushed();
|
|
}
|
|
}
|
|
}
|
|
#endif
|
|
|
|
bool MyMesh::advert() {
|
|
mesh::Packet* pkt;
|
|
if (_prefs.advert_loc_policy == ADVERT_LOC_NONE) {
|
|
pkt = createSelfAdvert(_prefs.node_name);
|
|
} else {
|
|
pkt = createSelfAdvert(_prefs.node_name, sensors.node_lat, sensors.node_lon);
|
|
}
|
|
if (pkt) {
|
|
sendZeroHop(pkt);
|
|
return true;
|
|
} else {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// To check if there is pending work
|
|
bool MyMesh::canRecoverUsbLogging() const {
|
|
// Do not discard dirty pages or interrupt a protocol/flash/radio operation.
|
|
// Normal lazy writes resolve this veto without adding writes to each poll.
|
|
return dirty_contacts_expiry == 0 && !_store->hasPendingContactWrites()
|
|
#if defined(ESP32_PLATFORM)
|
|
&& !_store->hasPendingAdvertWrites()
|
|
#endif
|
|
&& !hasOutbound() && !isAnyTempRadioActive() && !hasPendingOtaApply()
|
|
&& !saved_radio_apply_pending && !tx_power_apply_pending
|
|
&& rf_rx_disable_pending < 0 && !_radio_profiles.hasPendingTuning()
|
|
&& _scheduled_reboot_at == 0 && !hasPendingReqs()
|
|
&& !_delayed_replies.hasBinaryTrace() && sign_data == NULL
|
|
#if COMPANION_FEATURE_TEXT_TERMINAL
|
|
&& !_terminal_trace_pending
|
|
#endif
|
|
&& !(_serial && _serial->hasPendingIO())
|
|
&& !(_iter_started && _serial
|
|
&& _serial->isReplyRouteAvailable(_iter_reply_route));
|
|
}
|
|
|
|
bool MyMesh::hasPendingWork() const {
|
|
return pendingWorkReason() != 0;
|
|
}
|
|
|
|
mesh::powerdiag::Mask MyMesh::pendingWorkReason() const {
|
|
using mesh::powerdiag::Reason;
|
|
using mesh::powerdiag::mask;
|
|
#if COMPANION_FEATURE_READER
|
|
if (mesh::readerCommandActive()) return mask(Reason::Mesh);
|
|
#endif
|
|
#if COMPANION_FEATURE_NOTIFICATIONS
|
|
if (_notifications.active()) return mask(Reason::Mesh);
|
|
#endif
|
|
if (!canIdleDualRadio()) return mask(Reason::DualRadio);
|
|
if (_radio_available && radio_driver.isWatchdogObserving()) return mask(Reason::RadioWatchdog);
|
|
uint32_t noise_floor_delay_ms;
|
|
if (getNextNoiseFloorWakeDelay(noise_floor_delay_ms) && noise_floor_delay_ms == 0)
|
|
return mask(Reason::Calibration);
|
|
#if defined(NRF52_PLATFORM)
|
|
// nRF52 sleep is event-driven, so a future lazy-write deadline has no
|
|
// guaranteed interrupt to wake the loop when it becomes due. Keep polling
|
|
// through the initial write. After an actual storage failure, allow sleep
|
|
// during the bounded backoff; the next BLE/radio/GPIO event services an
|
|
// overdue retry without losing the dirty-page state.
|
|
const bool contact_write_needs_polling = dirty_contacts_expiry != 0
|
|
&& (dirty_contacts_failures == 0
|
|
|| isContactWriteDue());
|
|
#else
|
|
const bool contact_write_needs_polling = isContactWriteDue();
|
|
#endif
|
|
if (_serial != NULL && _serial->hasPendingIO()) return mask(Reason::SerialIO);
|
|
if (_iter_started && _serial != NULL
|
|
&& _serial->isReplyRouteAvailable(_iter_reply_route)) return mask(Reason::SerialIO);
|
|
if (hasPendingOtaApply()) return mask(Reason::Ota);
|
|
if (_radio_profiles.hasPendingTuning() || (rf_rx_disable_pending >= 0 && _radio_available)
|
|
|| (tx_power_apply_pending && _radio_available)
|
|
|| _scheduled_reboot_at != 0) return mask(Reason::Scheduled);
|
|
if (hasPendingReqs() || _delayed_replies.hasBinaryTrace()) return mask(Reason::Mesh);
|
|
#if COMPANION_FEATURE_TEXT_TERMINAL
|
|
if (_terminal_trace_pending) return mask(Reason::Mesh);
|
|
#endif
|
|
if (sign_data != NULL) return mask(Reason::Mesh);
|
|
if (hasQueuedWorkDue()) return mask(Reason::Queue);
|
|
if (hasRetryWorkDue()) return mask(Reason::Retry);
|
|
if (saved_radio_apply_pending && _radio_available
|
|
#if COMPANION_FEATURE_TEMP_RADIO
|
|
// Match loop()'s restore gate: a lease-blocked rollback is not
|
|
// runnable work. The lease's own timers below wake it when due.
|
|
&& !_temp_radio_applied && _temp_radio_set_at == 0
|
|
&& _temp_radio_revert_at == 0
|
|
#endif
|
|
) return mask(Reason::Scheduled);
|
|
if (contact_write_needs_polling || _store->contactRecoveryPending()) return mask(Reason::Mesh);
|
|
#if defined(ESP32_PLATFORM)
|
|
if (_store->hasPendingAdvertWrites()) return mask(Reason::Mesh);
|
|
#endif
|
|
if (emergency_client_repeat_packet != NULL
|
|
&& millisHasNowPassed(emergency_client_repeat_send_at)) return mask(Reason::Queue);
|
|
#if COMPANION_FEATURE_TEMP_RADIO
|
|
if ((_temp_radio_set_at != 0 && millisHasNowPassed(_temp_radio_set_at))
|
|
|| (_temp_radio_revert_at != 0 && millisHasNowPassed(_temp_radio_revert_at))
|
|
|| (_temp_radio_retry_at != 0 && millisHasNowPassed(_temp_radio_retry_at))) return mask(Reason::Scheduled);
|
|
#endif
|
|
return 0;
|
|
}
|