diff --git a/build.sh b/build.sh index 3e13cdeb..cd751e67 100755 --- a/build.sh +++ b/build.sh @@ -1709,17 +1709,28 @@ apply_esp32_lora_ota_size_profile() { # 0x10000..0x150000 app slot. The WebConfig portal is deliberately omitted. # WiFi/MQTT observers retain their small WiFi updater, while radio-only roles # avoid linking WiFi solely for that updater. Companions retain their target - # defaults because they are installed over USB. + # defaults because they are installed over USB. Keep ENV_INCLUDE_GPS for + # boards with onboard GPS; their target sensor managers require that support. export PLATFORMIO_BUILD_FLAGS="${PLATFORMIO_BUILD_FLAGS} -DWEBCONFIG_DISABLED=1" if is_mqtt_bridge_target "$env_name"; then - export MESHCORE_PORTABLE_NANO_LIBC=1 - export PLATFORMIO_BUILD_FLAGS="${PLATFORMIO_BUILD_FLAGS} -DLIGHTWEIGHT_WIFI_OTA=1 -DPORTABLE_MQTT_OBSERVER=1 -DPORTABLE_ESP32_MINIMAL_CLI=1 -DPORTABLE_ESP32_NANO_LIBC=1" - append_platformio_build_unflags "-DWITH_SNMP=1 -DMQTT_DEBUG=1 -DMQTT_MEMORY_DEBUG=1 -DDISPLAY_CLASS=SSD1306Display -DENV_INCLUDE_GPS=1 -DENV_INCLUDE_AHTX0=1 -DENV_INCLUDE_BME280=1 -DENV_INCLUDE_BMP280=1 -DENV_INCLUDE_SHTC3=1 -DENV_INCLUDE_SHT4X=1 -DENV_INCLUDE_LPS22HB=1 -DENV_INCLUDE_INA3221=1 -DENV_INCLUDE_INA219=1 -DENV_INCLUDE_INA226=1 -DENV_INCLUDE_INA260=1 -DENV_INCLUDE_MLX90614=1 -DENV_INCLUDE_VL53L0X=1 -DENV_INCLUDE_BME680=1 -DENV_INCLUDE_BMP085=1 -DENV_INCLUDE_RAK12035=1 -DENV_INCLUDE_BME680_BSEC=1" + # Keep the standard ESP-IDF libc. Use its ABI with the chip-ROM formatter, + # and retain a compact CLI for radio and active-bridge settings. + export PLATFORMIO_BUILD_FLAGS="${PLATFORMIO_BUILD_FLAGS} -DLIGHTWEIGHT_WIFI_OTA=1 -DPORTABLE_MQTT_OBSERVER=1 -DPORTABLE_ESP32_RADIO_CLI=1 -UDISPLAY_CLASS" + if [ "$FIRMWARE_FILENAME_INFIX" != "logging" ] \ + && [ "${MESHDEBUG_OVERRIDE,,}" != "on" ] \ + && [ "${PACKET_LOGGING_OVERRIDE,,}" != "on" ]; then + export PLATFORMIO_BUILD_FLAGS="${PLATFORMIO_BUILD_FLAGS} -DPORTABLE_ESP32_ROM_NANO_FORMAT=1" + fi + append_platformio_build_unflags "-DWITH_SNMP=1 -DMQTT_DEBUG=1 -DMQTT_MEMORY_DEBUG=1 -DDISPLAY_CLASS=SSD1306Display -DENV_INCLUDE_AHTX0=1 -DENV_INCLUDE_BME280=1 -DENV_INCLUDE_BMP280=1 -DENV_INCLUDE_SHTC3=1 -DENV_INCLUDE_SHT4X=1 -DENV_INCLUDE_LPS22HB=1 -DENV_INCLUDE_INA3221=1 -DENV_INCLUDE_INA219=1 -DENV_INCLUDE_INA226=1 -DENV_INCLUDE_INA260=1 -DENV_INCLUDE_MLX90614=1 -DENV_INCLUDE_VL53L0X=1 -DENV_INCLUDE_BME680=1 -DENV_INCLUDE_BMP085=1 -DENV_INCLUDE_RAK12035=1 -DENV_INCLUDE_BME680_BSEC=1" elif [[ "${env_name,,}" == *bridge_espnow* ]]; then - export MESHCORE_PORTABLE_NANO_LIBC=1 append_platformio_build_unflags "-DLIGHTWEIGHT_WIFI_OTA=1" - export PLATFORMIO_BUILD_FLAGS="${PLATFORMIO_BUILD_FLAGS} -ULIGHTWEIGHT_WIFI_OTA -DDISABLE_WIFI_OTA=1 -DPORTABLE_ESP32_MINIMAL_CLI=1 -DPORTABLE_ESP32_NANO_LIBC=1" - append_platformio_build_unflags "-DDISPLAY_CLASS=SSD1306Display -DENV_INCLUDE_GPS=1 -DENV_INCLUDE_AHTX0=1 -DENV_INCLUDE_BME280=1 -DENV_INCLUDE_BMP280=1 -DENV_INCLUDE_SHTC3=1 -DENV_INCLUDE_SHT4X=1 -DENV_INCLUDE_LPS22HB=1 -DENV_INCLUDE_INA3221=1 -DENV_INCLUDE_INA219=1 -DENV_INCLUDE_INA226=1 -DENV_INCLUDE_INA260=1 -DENV_INCLUDE_MLX90614=1 -DENV_INCLUDE_VL53L0X=1 -DENV_INCLUDE_BME680=1 -DENV_INCLUDE_BMP085=1 -DENV_INCLUDE_RAK12035=1 -DENV_INCLUDE_BME680_BSEC=1" + export PLATFORMIO_BUILD_FLAGS="${PLATFORMIO_BUILD_FLAGS} -ULIGHTWEIGHT_WIFI_OTA -DDISABLE_WIFI_OTA=1 -DPORTABLE_ESP32_RADIO_CLI=1 -UDISPLAY_CLASS" + if [ "$FIRMWARE_FILENAME_INFIX" != "logging" ] \ + && [ "${MESHDEBUG_OVERRIDE,,}" != "on" ] \ + && [ "${PACKET_LOGGING_OVERRIDE,,}" != "on" ]; then + export PLATFORMIO_BUILD_FLAGS="${PLATFORMIO_BUILD_FLAGS} -DPORTABLE_ESP32_ROM_NANO_FORMAT=1" + fi + append_platformio_build_unflags "-DDISPLAY_CLASS=SSD1306Display -DENV_INCLUDE_AHTX0=1 -DENV_INCLUDE_BME280=1 -DENV_INCLUDE_BMP280=1 -DENV_INCLUDE_SHTC3=1 -DENV_INCLUDE_SHT4X=1 -DENV_INCLUDE_LPS22HB=1 -DENV_INCLUDE_INA3221=1 -DENV_INCLUDE_INA219=1 -DENV_INCLUDE_INA226=1 -DENV_INCLUDE_INA260=1 -DENV_INCLUDE_MLX90614=1 -DENV_INCLUDE_VL53L0X=1 -DENV_INCLUDE_BME680=1 -DENV_INCLUDE_BMP085=1 -DENV_INCLUDE_RAK12035=1 -DENV_INCLUDE_BME680_BSEC=1" else append_platformio_build_unflags "-DLIGHTWEIGHT_WIFI_OTA=1" export PLATFORMIO_BUILD_FLAGS="${PLATFORMIO_BUILD_FLAGS} -ULIGHTWEIGHT_WIFI_OTA -DDISABLE_WIFI_OTA=1" @@ -2121,7 +2132,6 @@ build_firmware() { fi export PLATFORMIO_BUILD_FLAGS="${original_platformio_build_flags} -DFIRMWARE_BUILD_DATE='\"${firmware_build_date}\"' -DFIRMWARE_BUILD_EPOCH=${firmware_build_epoch} -DFIRMWARE_VERSION='\"${embedded_version_string}\"' -DOTA_VARIANT='\"${env_name}\"'${mota_target_flag}" - unset MESHCORE_PORTABLE_NANO_LIBC disable_debug_flags apply_debug_overrides apply_mqtt_bridge_override @@ -2156,7 +2166,6 @@ build_firmware() { fi restore_platformio_build_flags "$had_platformio_build_flags" "$original_platformio_build_flags" - unset MESHCORE_PORTABLE_NANO_LIBC unset MESHCORE_ESP32_FULL_BUILD if [ "$had_platformio_build_unflags" -eq 1 ]; then export PLATFORMIO_BUILD_UNFLAGS="$original_platformio_build_unflags" diff --git a/docs/flood_filtering.md b/docs/flood_filtering.md index dbe83950..3f8c5422 100644 --- a/docs/flood_filtering.md +++ b/docs/flood_filtering.md @@ -240,6 +240,7 @@ set flood.filter response 9+ set flood.filter 0x06 3+ set flood.filter 0x07 9+ set flood.filter path 9+ +set flood.filter control 1+ get flood.filter ``` @@ -250,13 +251,17 @@ get flood.filter | `0x06 3+` (`grp_data`) | 3 or more path entries | | `0x07 9+` (`anon_req`) | 9 or more path entries | | `path 9+` | 9 or more path entries | +| `control 1+` | 1 or more path entries | On a new table, the factory OTA rule occupies slot 1, so these unnumbered -commands normally fill slots 2 through 6. Existing tables may choose different +commands normally fill slots 2 through 7. Existing tables may choose different free slots. The `response`, `anon_req`, and `path` thresholds are above their -protected `0-6` range, so all five rules take effect at the thresholds shown. -These rules affect only retransmission by the repeater; local reception and -logging remain unchanged. +protected `0-6` range, so all six rules take effect at the thresholds shown. +The Control rule allows a flood received with path count `0` to be forwarded +once, then stops it at the next repeater. Normal node-discovery Control packets +are direct zero-hop packets and never enter `flood.filter`. These rules affect +only retransmission by the repeater; local reception and logging remain +unchanged. Accepted payload names are: diff --git a/docs/ota_protocol.md b/docs/ota_protocol.md index 4dc9ea49..2ee15ead 100644 --- a/docs/ota_protocol.md +++ b/docs/ota_protocol.md @@ -122,22 +122,30 @@ GPS and other board-native telemetry remain enabled. RP2040 and STM32 targets ar platforms do not yet have a safe bootloader/apply path. Two WiFi-heavy non-companion profiles need additional reductions to remain portable. MQTT observer builds -keep MQTT/TLS and their WiFi pull-updater, but omit WebConfig, SNMP, debug logging, SSD1306 display support, -GPS, and optional external sensor drivers. Their compact CLI keeps observer controls and uses UTC or fixed UTC/GMT +keep MQTT/TLS, onboard GPS, and their WiFi pull-updater, but omit WebConfig, SNMP, debug logging, display +support, and optional external sensor drivers. Their compact CLI keeps observer controls plus the radio, +TX power, CAD, interference-threshold, AGC, repeat, and retained bridge controls. It uses UTC or fixed UTC/GMT offsets instead of the full named-timezone table. Built-in TLS presets keep their pinned CA roots; the 66 KB general CA bundle for custom TLS brokers is omitted, so portable observers use a built-in preset or a custom -non-TLS broker. Portable observers also use compact C-library formatting and generic ESP-IDF/mbedTLS error -text; error codes and MQTT status remain available. Classic T-Beam observers retain AXP192/AXP2101 radio -and GPS rail setup plus battery-voltage readings, but omit unrelated PMU policy. ESP-NOW bridge builds keep -the ESP-NOW bridge but likewise omit SSD1306 display support, GPS, optional external sensors, and the general -configuration parser. These reductions do +non-TLS broker. Size-constrained classic ESP32 observers without PSRAM may use Espressif's compact printf +implementation from chip ROM while retaining the normal ESP-IDF C library and ABI. Generic ESP-IDF/mbedTLS +error text keeps error codes and MQTT status available. Classic T-Beam observers retain AXP192/AXP2101 radio +and GPS rail setup plus battery-voltage +readings, but omit unrelated PMU policy. ESP-NOW bridge builds keep the ESP-NOW bridge, onboard GPS, and the +same radio-capable compact CLI, but omit display support and optional external sensors. These reductions do not apply to companion builds. Ordinary repeater builds remain sensor-enabled; only explicitly named `*_lora_ota_no_external_sensors` siblings omit sensors for LoRa distribution. +MQTT observer radio and bridge preferences use verified temporary files plus a recoverable backup. A reset +during a settings save restores the last committed common preference image or publishes the completed new +image; it does not leave a partially written `/com_prefs` file to fail on the next boot. A truncated legacy +image is rejected before any partial radio or string fields are applied, then rewritten from safe defaults. + Option 3 in `build.sh` also emits `*-full-ota-*` ESP32 artifacts for non-companion roles where the portable profile removes a compiled feature and for the constrained companion fallbacks described above. Menu option 8, or `build-full-esp32-firmwares`, builds only those FULL -artifacts. FULL builds restore WebConfig, the full CLI and observer feature set, +artifacts. FULL builds restore WebConfig, display support, optional external sensors, the full CLI and +observer feature set, full ElegantOTA where that target declares the required library, and LoRa OTA for every included role, including room servers, sensors, observers, and bridges. They use expanded A/B partition tables: 1984 KiB application slots on 4 MiB boards and the framework's larger dual-OTA tables on 8 MiB diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index a4987aaa..54bd47a5 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -6590,10 +6590,12 @@ void MyMesh::buildStatsJson(char* buf, size_t buf_size) { } else if (_webconfig && _webconfig->mode() == WebConfigServer::MODE_SETUP) { strncpy(ip, WiFi.softAPIP().toString().c_str(), sizeof(ip) - 1); } + char snr_text[16]; + StrHelper::ftoaFixed(snr_text, sizeof(snr_text), radio_driver.getLastSNR(), 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," + "\"noise\":%d,\"rssi\":%d,\"snr\":%s," "\"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," @@ -6602,7 +6604,7 @@ void MyMesh::buildStatsJson(char* buf, size_t buf_size) { (unsigned long)ESP.getFreeHeap(), (unsigned long)ESP.getMinFreeHeap(), (unsigned long)ESP.getMaxAllocHeap(), (int)_radio->getNoiseFloor(), (int)radio_driver.getLastRSSI(), - radio_driver.getLastSNR(), + snr_text, (unsigned long)(getTotalAirTime() / 1000), (unsigned long)(getReceiveAirTime() / 1000), (unsigned long)radio_driver.getPacketsRecv(), (unsigned long)radio_driver.getPacketsSent(), (unsigned long)radio_driver.getPacketsRecvErrors(), diff --git a/examples/simple_room_server/MyMesh.cpp b/examples/simple_room_server/MyMesh.cpp index 5bd7adbe..f70121f1 100644 --- a/examples/simple_room_server/MyMesh.cpp +++ b/examples/simple_room_server/MyMesh.cpp @@ -1415,10 +1415,12 @@ void MyMesh::buildStatsJson(char* buf, size_t buf_size) { } else if (_webconfig && _webconfig->mode() == WebConfigServer::MODE_SETUP) { strncpy(ip, WiFi.softAPIP().toString().c_str(), sizeof(ip) - 1); } + char snr_text[16]; + StrHelper::ftoaFixed(snr_text, sizeof(snr_text), radio_driver.getLastSNR(), 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," + "\"noise\":%d,\"rssi\":%d,\"snr\":%s," "\"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," @@ -1427,7 +1429,7 @@ void MyMesh::buildStatsJson(char* buf, size_t buf_size) { (unsigned long)ESP.getFreeHeap(), (unsigned long)ESP.getMinFreeHeap(), (unsigned long)ESP.getMaxAllocHeap(), (int)_radio->getNoiseFloor(), (int)radio_driver.getLastRSSI(), - radio_driver.getLastSNR(), + snr_text, (unsigned long)(getTotalAirTime() / 1000), (unsigned long)(getReceiveAirTime() / 1000), (unsigned long)radio_driver.getPacketsRecv(), (unsigned long)radio_driver.getPacketsSent(), (unsigned long)radio_driver.getPacketsRecvErrors(), diff --git a/platformio.ini b/platformio.ini index 9115fd77..46c9db10 100644 --- a/platformio.ini +++ b/platformio.ini @@ -261,6 +261,7 @@ build_src_filter = +<../src/Dispatcher.cpp> +<../src/Identity.cpp> +<../src/Mesh.cpp> + +<../src/helpers/TxtDataHelpers.cpp> +<../src/helpers/StaticPoolPacketManager.cpp> +<../src/helpers/ota/MerkleTree.cpp> +<../src/helpers/ota/MotaContainer.cpp> diff --git a/scripts/generate_cert_bundle.py b/scripts/generate_cert_bundle.py index e42659d5..a35acc17 100644 --- a/scripts/generate_cert_bundle.py +++ b/scripts/generate_cert_bundle.py @@ -179,18 +179,21 @@ class InputError(RuntimeError): def main(): - # Portable observer presets pin their required roots directly in - # MQTTPresets.h. Keep the embedded bundle structurally valid but empty so - # custom TLS trust stores do not consume 66 KB of the legacy app slot. + # Portable observer presets pin their MQTT roots directly in + # MQTTPresets.h. Keep only the GlobalSign root needed by the manifest and + # firmware hosts so HTTPS pull OTA remains verified without carrying the + # roughly 66 KB general-purpose bundle in the legacy app slot. if ( "observer_mqtt" in env.subst("$PIOENV").lower() and os.environ.get("MESHCORE_ESP32_FULL_BUILD") != "1" ): + bundle = CertificateBundle() + bundle.add_from_file(os.path.join(certs_dir, "globalsign_root_ca_r1.pem")) os.makedirs(binary_dir, exist_ok=True) output_file = os.path.join(binary_dir, ca_bundle_bin_file) with open(output_file, 'wb') as f: - f.write(struct.pack('>H', 0)) - status('Created empty portable-observer certificate bundle') + f.write(bundle.create_bundle()) + status('Created portable-observer OTA certificate bundle') return bundle = CertificateBundle() diff --git a/scripts/portable_esp32_link.py b/scripts/portable_esp32_link.py index f926cc86..a8a63247 100644 --- a/scripts/portable_esp32_link.py +++ b/scripts/portable_esp32_link.py @@ -14,15 +14,26 @@ def has_define(name): return False -build_flags = str(env.get("BUILD_FLAGS", "")) + " " + os.environ.get("PLATFORMIO_BUILD_FLAGS", "") -pio_env = env.subst("$PIOENV").lower() -full_build = os.environ.get("MESHCORE_ESP32_FULL_BUILD") == "1" +build_flags = ( + str(env.get("BUILD_FLAGS", "")) + + " " + + os.environ.get("PLATFORMIO_BUILD_FLAGS", "") +) -if (has_define("PORTABLE_ESP32_NANO_LIBC") or - "PORTABLE_ESP32_NANO_LIBC" in build_flags or - os.environ.get("MESHCORE_PORTABLE_NANO_LIBC") == "1" or - (not full_build and ("observer_mqtt" in pio_env or "bridge_espnow" in pio_env))): - # newlib-nano provides the same C ABI with a substantially smaller printf - # implementation. These profiles omit display, GPS, external sensors, and - # the general CLI paths that require floating-point printf. - env.Append(LINKFLAGS=["--specs=nano.specs"]) +if ( + has_define("PORTABLE_ESP32_ROM_NANO_FORMAT") + or "PORTABLE_ESP32_ROM_NANO_FORMAT" in build_flags +): + # ESP-IDF supplies linker tables for the compact printf implementation in + # chip ROM. This retains the framework's normal C library and ABI; it does + # not substitute libc_nano. The classic ESP32 table must not be used with + # that chip's PSRAM cache workaround. The ESP32-S3 has its own ROM table and + # no equivalent restriction in the framework linker script. + mcu = str(env.BoardConfig().get("build.mcu", "")).lower() + classic_esp32_psram = mcu == "esp32" and ( + has_define("BOARD_HAS_PSRAM") or "BOARD_HAS_PSRAM" in build_flags + ) + if mcu == "esp32" and not classic_esp32_psram: + env.Append(LINKFLAGS=["-T", "%s.rom.newlib-nano.ld" % mcu]) + elif mcu == "esp32s3": + env.Append(LINKFLAGS=["-T", "%s.rom.newlib-nano.ld" % mcu]) diff --git a/src/helpers/CLICommandUtils.h b/src/helpers/CLICommandUtils.h index fe337867..b8e7e431 100644 --- a/src/helpers/CLICommandUtils.h +++ b/src/helpers/CLICommandUtils.h @@ -1,5 +1,7 @@ #pragma once +#include + namespace mesh { namespace cli { @@ -18,5 +20,44 @@ inline void normalizeCommandVerb(char* command) { } } +// Parse the decimal syntax used by CLI settings without pulling a general +// strtod implementation into small firmware images. Scientific notation, +// NaN, infinity, and trailing junk are intentionally rejected. +inline bool parseDecimalStrict(const char* text, float& result) { + if (text == nullptr) return false; + while (*text == ' ' || *text == '\t') text++; + + bool negative = false; + if (*text == '-' || *text == '+') { + negative = *text == '-'; + text++; + } + + uint32_t whole = 0; + bool saw_digit = false; + while (*text >= '0' && *text <= '9') { + uint8_t digit = static_cast(*text++ - '0'); + if (whole > (0xFFFFFFFFUL - digit) / 10UL) return false; + whole = whole * 10UL + digit; + saw_digit = true; + } + + float value = static_cast(whole); + if (*text == '.') { + float place = 0.1f; + text++; + while (*text >= '0' && *text <= '9') { + value += static_cast(*text++ - '0') * place; + place *= 0.1f; + saw_digit = true; + } + } + + while (*text == ' ' || *text == '\t') text++; + if (!saw_digit || *text != 0) return false; + result = negative ? -value : value; + return true; +} + } // namespace cli } // namespace mesh diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index e4f6c600..ecbe5bbd 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -9,6 +9,7 @@ #endif #include #include +#include #include #if defined(NRF52_PLATFORM) @@ -52,6 +53,7 @@ static void resetToUf2Bootloader() { #endif #ifdef WITH_MQTT_BRIDGE #include "bridges/MQTTBridge.h" +#include "CommonPrefsRecovery.h" #include "MQTTDefaults.h" #include "MQTTPrefsAtomicStore.h" #include "MQTTPrefsCodec.h" @@ -329,7 +331,8 @@ static bool parseScheduledRadioArgs(const char* args, bool temporary, float& fre uint32_t sf_u32 = 0; uint32_t cr_u32 = 0; - if (!looksNumeric(parts[0]) || !looksNumeric(parts[1]) + if (!mesh::cli::parseDecimalStrict(parts[0], freq) + || !mesh::cli::parseDecimalStrict(parts[1], bw) || !parseUint32Strict(parts[2], sf_u32) || !parseUint32Strict(parts[3], cr_u32) || !parseUint32Strict(parts[4], start_time)) { @@ -339,8 +342,6 @@ static bool parseScheduledRadioArgs(const char* args, bool temporary, float& fre return false; } - freq = atof(parts[0]); - bw = atof(parts[1]); sf = (uint8_t)sf_u32; cr = (uint8_t)cr_u32; if (temporary && !parseUint32Strict(parts[5], end_time)) { @@ -353,7 +354,8 @@ static bool parseScheduledRadioArgs(const char* args, bool temporary, float& fre } static int16_t parseSnrDbX4(const char* s) { - float db = atof(s); + float db = 0.0f; + mesh::cli::parseDecimalStrict(s, db); return (int16_t)(db * 4.0f + (db >= 0.0f ? 0.5f : -0.5f)); } @@ -844,6 +846,9 @@ void CommonCLI::loadPrefs(FILESYSTEM* fs) { #ifdef WITH_MQTT_BRIDGE bool node_prefs_needs_migration = false; + if (!recoverCommonPrefsFiles(fs)) { + MESH_DEBUG_PRINTLN("Prefs: common preference recovery is incomplete"); + } #endif if (fs->exists("/com_prefs")) { @@ -965,6 +970,16 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) { File file = fs->open(filename); #endif if (file) { + // Every supported layout contains the fixed 290-byte common core. Reject + // a truncated in-place write before it can leave strings unterminated or + // feed partial radio values into startup. loadPrefs() rewrites the image. + if (file.size() < 290) { + MESH_DEBUG_PRINTLN("Prefs: %s is truncated (%u bytes); using defaults", + filename, (unsigned)file.size()); + file.close(); + _com_prefs_needs_upgrade = true; + return; + } uint8_t pad[8]; file.read((uint8_t *)&_prefs->airtime_factor, sizeof(_prefs->airtime_factor)); // 0 @@ -1010,6 +1025,11 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) { file.read((uint8_t *)&_prefs->discovery_mod_timestamp, sizeof(_prefs->discovery_mod_timestamp)); // 162 file.read((uint8_t *)&_prefs->adc_multiplier, sizeof(_prefs->adc_multiplier)); // 166 file.read((uint8_t *)_prefs->owner_info, sizeof(_prefs->owner_info)); // 170 + _prefs->node_name[sizeof(_prefs->node_name) - 1] = '\0'; + _prefs->password[sizeof(_prefs->password) - 1] = '\0'; + _prefs->guest_password[sizeof(_prefs->guest_password) - 1] = '\0'; + _prefs->bridge_secret[sizeof(_prefs->bridge_secret) - 1] = '\0'; + _prefs->owner_info[sizeof(_prefs->owner_info) - 1] = '\0'; // MQTT/observer settings are no longer stored in /com_prefs - they live in // /mqtt_prefs (loaded by loadMQTTPrefs). Old fork firmware wrote a zero-filled // MQTT gap here followed by a trailing observer block; detect that layout by the @@ -1300,6 +1320,31 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) { } } + // Sanitize non-finite values before constrain(), whose comparisons leave + // NaN unchanged. Safe defaults keep a damaged image from reaching radio + // initialization or advert coordinate encoding. + if (!isfinite(_prefs->airtime_factor)) _prefs->airtime_factor = 1.0f; +#ifdef DEFAULT_RX_DELAY_BASE + if (!isfinite(_prefs->rx_delay_base)) _prefs->rx_delay_base = DEFAULT_RX_DELAY_BASE; +#else + if (!isfinite(_prefs->rx_delay_base)) _prefs->rx_delay_base = 0.0f; +#endif + 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.3f; +#ifdef LORA_FREQ + if (!isfinite(_prefs->freq)) _prefs->freq = (float)LORA_FREQ; +#else + if (!isfinite(_prefs->freq)) _prefs->freq = 915.0f; +#endif + if (!isfinite(_prefs->bw)) _prefs->bw = defaultLoRaBandwidth(); + if (!isfinite(_prefs->adc_multiplier)) _prefs->adc_multiplier = 0.0f; + if (!isfinite(_prefs->node_lat) || _prefs->node_lat < -90.0 || _prefs->node_lat > 90.0) { + _prefs->node_lat = 0.0; + } + if (!isfinite(_prefs->node_lon) || _prefs->node_lon < -180.0 || _prefs->node_lon > 180.0) { + _prefs->node_lon = 0.0; + } + // sanitise bad pref values _prefs->rx_delay_base = constrain(_prefs->rx_delay_base, 0, 20.0f); _prefs->tx_delay_factor = constrain(_prefs->tx_delay_factor, 0, 2.0f); @@ -1540,6 +1585,13 @@ static bool writeCommonPrefsImage(Writer& writer, NodePrefs* prefs) { #endif void CommonCLI::savePrefs(FILESYSTEM* fs, bool save_mqtt) { +#ifdef WITH_MQTT_BRIDGE + // Observer builds use a verified temp/backup transaction for common prefs. + // Radio and bridge changes must never leave a truncated boot-time image. + saveCommonPrefsImageAtomically(fs); + if (save_mqtt) saveMQTTPrefs(fs); + return; +#else #if defined(NRF52_PLATFORM) mesh::AtomicFileWriter file(fs, "/com_prefs"); #elif defined(STM32_PLATFORM) @@ -1680,10 +1732,6 @@ void CommonCLI::savePrefs(FILESYSTEM* fs, bool save_mqtt) { file.close(); #endif } -#ifdef WITH_MQTT_BRIDGE - // Observer config (MQTT/WiFi/timezone/SNMP/alert) is persisted separately. The - // observer CLI writes _mqtt_prefs directly, so no NodePrefs->MQTTPrefs sync runs. - if (save_mqtt) saveMQTTPrefs(fs); #endif } @@ -1869,9 +1917,9 @@ private: #endif // WITH_MQTT_BRIDGE #ifdef WITH_MQTT_BRIDGE -// The old /node_prefs name is only removed after this transaction has published -// a complete /com_prefs image. At this migration point /com_prefs is absent, so -// rename never needs a platform-specific replace-existing implementation. +// Common preferences use the same verified temp/backup transaction shape as +// MQTT preferences. This protects both old-name migration and normal runtime +// radio/bridge changes from power loss between truncate, write, and close. class CommonPrefsFileStore { public: explicit CommonPrefsFileStore(FILESYSTEM* fs) @@ -1884,13 +1932,9 @@ public: bool begin() { _finished = false; _open = false; + _owns_temp = false; _bytes_written = 0; - // The old-name migration only starts when /com_prefs is absent. Refuse to - // overwrite a destination that appeared unexpectedly before this handoff. - if (_fs->exists("/com_prefs")) return false; - // Clear only stale, unpublished output. Guarded on exists() for the same - // reason as the /mqtt_prefs store above: remove() on a missing file logs a - // spurious VFS error on ESP32. + if (_fs->exists("/com_prefs.bak")) return false; if (_fs->exists("/com_prefs.tmp")) { _fs->remove("/com_prefs.tmp"); if (_fs->exists("/com_prefs.tmp")) return false; // could not clear it @@ -1903,6 +1947,7 @@ public: _file = _fs->open("/com_prefs.tmp", "w", true); #endif _open = _file; + _owns_temp = _open; return _open; } @@ -1931,14 +1976,25 @@ public: } bool commit() { - return _finished && _fs->rename("/com_prefs.tmp", "/com_prefs"); + if (!_finished) return false; + if (_fs->exists("/com_prefs.bak")) return false; + if (_fs->exists("/com_prefs") + && !_fs->rename("/com_prefs", "/com_prefs.bak")) { + return false; + } + if (!_fs->rename("/com_prefs.tmp", "/com_prefs")) return false; + if (_fs->exists("/com_prefs.bak")) _fs->remove("/com_prefs.bak"); + return true; } void abort() { if (_open) _file.close(); _open = false; + if (_owns_temp && !_finished && _fs->exists("/com_prefs.tmp")) { + _fs->remove("/com_prefs.tmp"); + } _finished = false; - if (_fs->exists("/com_prefs.tmp")) _fs->remove("/com_prefs.tmp"); + _owns_temp = false; } private: @@ -1946,6 +2002,7 @@ private: File _file; bool _open = false; bool _finished = false; + bool _owns_temp = false; size_t _bytes_written = 0; }; @@ -1960,14 +2017,59 @@ static const char* commonPrefsSaveResultName(MQTTPrefsAtomicStore::ImageResult r return "unknown"; } +bool CommonCLI::recoverCommonPrefsFiles(FILESYSTEM* fs) { + using CommonPrefsRecovery::Action; + const Action action = CommonPrefsRecovery::select( + fs->exists("/com_prefs"), + fs->exists("/com_prefs.tmp"), + fs->exists("/com_prefs.bak")); + + switch (action) { + case Action::KeepPrimary: + if (fs->exists("/com_prefs.tmp")) fs->remove("/com_prefs.tmp"); + if (fs->exists("/com_prefs.bak")) fs->remove("/com_prefs.bak"); + return !fs->exists("/com_prefs.tmp") && !fs->exists("/com_prefs.bak"); + + case Action::PromoteTemp: + if (fs->rename("/com_prefs.tmp", "/com_prefs")) { + if (fs->exists("/com_prefs.bak")) fs->remove("/com_prefs.bak"); + return fs->exists("/com_prefs"); + } + // The verified new image could not be published. Restore the previous + // image so boot can continue with the last committed radio settings. + if (fs->rename("/com_prefs.bak", "/com_prefs")) { + if (fs->exists("/com_prefs.tmp")) fs->remove("/com_prefs.tmp"); + return true; + } + return false; + + case Action::PromoteBackup: + return fs->rename("/com_prefs.bak", "/com_prefs"); + + case Action::DiscardTemp: + // With no backup, a reset may have interrupted the very first write + // before finish(). Keep defaults or /node_prefs and retry the save. + fs->remove("/com_prefs.tmp"); + return !fs->exists("/com_prefs.tmp"); + + case Action::None: + return true; + } + return false; +} + bool CommonCLI::saveCommonPrefsImageAtomically(FILESYSTEM* fs) { + if (!recoverCommonPrefsFiles(fs)) { + MESH_DEBUG_PRINTLN("Prefs: refusing save while recovery is incomplete"); + return false; + } CommonPrefsFileStore store(fs); const MQTTPrefsAtomicStore::ImageResult result = MQTTPrefsAtomicStore::writeImage( store, [this](CommonPrefsFileStore& target) { return writeCommonPrefsImage(target, _prefs); }); if (!MQTTPrefsAtomicStore::imageCommitted(result)) { - MESH_DEBUG_PRINTLN("Prefs: atomic /com_prefs migration save failed at %s; /node_prefs preserved", + MESH_DEBUG_PRINTLN("Prefs: atomic /com_prefs save failed at %s; previous image preserved", commonPrefsSaveResultName(result)); return false; } @@ -2226,6 +2328,238 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re // Observer-only top-level commands (ota check/update, tls.bundletest, alert test) // live in CommonCLI_Observer.cpp. if (handleObserverCommand(sender_timestamp, command, reply)) return; +#if defined(PORTABLE_ESP32_RADIO_CLI) + // Portable WiFi-heavy images keep the commands needed to commission, + // update, and diagnose the node. Their feature-complete FULL siblings keep + // the complete administration, logging, and external-sensor command tree. + if (strcmp(command, "poweroff") == 0 || strcmp(command, "shutdown") == 0) { + _board->powerOff(); // doesn't return + } else if (strcmp(command, "reboot") == 0) { + _board->reboot(); // doesn't return + } else if (strcmp(command, "advert.zerohop") == 0) { + _callbacks->sendSelfAdvertisement(1500, false); + strcpy(reply, "OK - zerohop advert sent"); + } else if (strcmp(command, "advert") == 0) { + _callbacks->sendSelfAdvertisement(1500, true); + strcpy(reply, "OK - Advert sent"); + } else if (strcmp(command, "clock sync") == 0) { + uint32_t curr = getRTCClock()->getCurrentTime(); + if (sender_timestamp > curr) { + getRTCClock()->setCurrentTime(sender_timestamp + 1); + _callbacks->onManualClockSet(); + uint32_t now = getRTCClock()->getCurrentTime(); + DateTime dt = DateTime(now); + sprintf(reply, "OK - clock set: %02d:%02d - %d/%d/%d UTC", + dt.hour(), dt.minute(), dt.day(), dt.month(), dt.year()); + } else { + strcpy(reply, "ERR: clock cannot go backwards"); + } +#ifdef ESP_PLATFORM + } else if (strcmp(command, "memory") == 0) { + sprintf(reply, "Free: %d, Min: %d, Max: %d, Queue: %d, IntFree: %d, IntMax: %d, PSRAM: %d/%d", + ESP.getFreeHeap(), ESP.getMinFreeHeap(), ESP.getMaxAllocHeap(), + _callbacks->getQueueSize(), + (int)heap_caps_get_free_size(MALLOC_CAP_INTERNAL), + (int)heap_caps_get_largest_free_block(MALLOC_CAP_INTERNAL), + (int)heap_caps_get_free_size(MALLOC_CAP_SPIRAM), + (int)heap_caps_get_total_size(MALLOC_CAP_SPIRAM)); +#endif + } else if (memcmp(command, "start ota", 9) == 0 + && (command[9] == 0 || command[9] == ' ')) { + const bool force_ap = command[9] == ' ' && strcmp(&command[10], "ap") == 0; + if (command[9] == ' ' && !force_ap) { + strcpy(reply, "ERR: usage start ota [ap]"); + } else if (!_board->startOTAUpdate(_prefs->node_name, reply, force_ap)) { + strcpy(reply, "Error"); + } +#if defined(WITH_MQTT_BRIDGE) && defined(LIGHTWEIGHT_WIFI_OTA) + else { + _callbacks->setBridgeState(false); + } +#endif + } else if (strcmp(command, "stop ota") == 0) { + if (!_board->stopOTAUpdate(reply)) { + strcpy(reply, "Error"); + } +#if defined(WITH_MQTT_BRIDGE) && defined(LIGHTWEIGHT_WIFI_OTA) + else if (_prefs->bridge_enabled) { + _callbacks->setBridgeState(true); + } +#endif + } else if (strcmp(command, "clock") == 0) { + uint32_t now = getRTCClock()->getCurrentTime(); + DateTime dt = DateTime(now); + sprintf(reply, "%02d:%02d - %d/%d/%d UTC", + dt.hour(), dt.minute(), dt.day(), dt.month(), dt.year()); + } else if (memcmp(command, "time ", 5) == 0) { + uint32_t secs = _atoi(&command[5]); + uint32_t curr = getRTCClock()->getCurrentTime(); + if (secs > curr) { + getRTCClock()->setCurrentTime(secs); + _callbacks->onManualClockSet(); + uint32_t now = getRTCClock()->getCurrentTime(); + DateTime dt = DateTime(now); + sprintf(reply, "OK - clock set: %02d:%02d - %d/%d/%d UTC", + dt.hour(), dt.minute(), dt.day(), dt.month(), dt.year()); + } else { + strcpy(reply, "(ERR: clock cannot go backwards)"); + } + } else if (strcmp(command, "neighbors") == 0) { + _callbacks->formatNeighborsReply(reply); + } else if (memcmp(command, "password ", 9) == 0 && command[9] != 0) { + StrHelper::strncpy(_prefs->password, &command[9], sizeof(_prefs->password)); + savePrefs(); + sprintf(reply, "password now: %s", _prefs->password); + } else if (memcmp(command, "get ", 4) == 0) { + handleGetCmd(sender_timestamp, command, reply); + } else if (memcmp(command, "set ", 4) == 0) { + handleSetCmd(sender_timestamp, command, reply); + } else if (sender_timestamp == 0 && strcmp(command, "erase") == 0) { + bool success = _callbacks->formatFileSystem(); + sprintf(reply, "File system erase: %s", success ? "OK" : "Err"); + } else if (strcmp(command, "ver") == 0) { + sprintf(reply, "%s (Build: %s)", + _callbacks->getFirmwareVer(), _callbacks->getBuildDate()); + } else if (strcmp(command, "board") == 0) { + sprintf(reply, "%s", _board->getManufacturerName()); + } else if (memcmp(command, "region", 6) == 0 + && (command[6] == 0 || command[6] == ' ')) { + handleRegionCmd(command, reply); +#if ENV_INCLUDE_GPS == 1 + } else if (strcmp(command, "gps on") == 0) { + if (_sensors->setSettingValue("gps", "1")) { + _prefs->gps_enabled = 1; + savePrefs(); + strcpy(reply, _prefs->powersaving_enabled ? "on (powersaving)" : "ok"); + } else { + strcpy(reply, "gps toggle not found"); + } + } else if (strcmp(command, "gps off") == 0) { + if (_sensors->setSettingValue("gps", "0")) { + _prefs->gps_enabled = 0; + savePrefs(); + strcpy(reply, "ok"); + } else { + strcpy(reply, "gps toggle not found"); + } + } else if (strcmp(command, "gps sync") == 0) { + LocationProvider* location = _sensors->getLocationProvider(); + if (!_prefs->gps_enabled) { + strcpy(reply, "gps is off"); + } else if (location != NULL) { + location->syncTime(); + strcpy(reply, "scheduled"); + } else { + strcpy(reply, "gps provider not found"); + } + } else if (strcmp(command, "gps setloc") == 0) { + _prefs->node_lat = _sensors->node_lat; + _prefs->node_lon = _sensors->node_lon; + savePrefs(); + strcpy(reply, "ok"); + } else if (memcmp(command, "gps advert", 10) == 0 + && (command[10] == 0 || command[10] == ' ')) { + if (command[10] == 0) { + switch (_prefs->advert_loc_policy) { + case ADVERT_LOC_NONE: strcpy(reply, "> none"); break; + case ADVERT_LOC_PREFS: strcpy(reply, "> prefs"); break; + case ADVERT_LOC_SHARE: strcpy(reply, "> share"); break; + default: strcpy(reply, "error"); + } + } else if (strcmp(&command[11], "none") == 0) { + _prefs->advert_loc_policy = ADVERT_LOC_NONE; + savePrefs(); + strcpy(reply, "ok"); + } else if (strcmp(&command[11], "share") == 0) { + _prefs->advert_loc_policy = ADVERT_LOC_SHARE; + savePrefs(); + strcpy(reply, "ok"); + } else if (strcmp(&command[11], "prefs") == 0) { + _prefs->advert_loc_policy = ADVERT_LOC_PREFS; + savePrefs(); + strcpy(reply, "ok"); + } else { + strcpy(reply, "error"); + } + } else if (strcmp(command, "gps") == 0) { + LocationProvider* location = _sensors->getLocationProvider(); + if (location == NULL) { + strcpy(reply, "Can't find GPS"); + } else { + bool enabled = location->isEnabled(); + bool fix = location->isValid(); + int sats = location->satellitesCount(); + const char* gps_setting = _sensors->getSettingByKey("gps"); + bool active = gps_setting != NULL && strcmp(gps_setting, "1") == 0; + if (_prefs->powersaving_enabled && location->getGPSPowerSaving()) { + unsigned long now = millis(); + unsigned long next_off = location->getNextGPSOff(); + unsigned long deadline = + next_off != 0 ? next_off : location->getNextGPSOn(); + long remaining_ms = deadline == 0 ? 0 : (long)(deadline - now); + unsigned long mins = + remaining_ms > 0 ? (unsigned long)remaining_ms / 60000UL : 0; + if (next_off != 0) { + snprintf(reply, 160, + "on (powersaving, sleep in %luh %lum), %s, %s, %d sats", + mins / 60UL, mins % 60UL, + active ? "active" : "deactivated", + fix ? "fix" : "no fix", sats); + } else { + snprintf(reply, 160, "off (powersaving, wake in %luh %lum)", + mins / 60UL, mins % 60UL); + } + unsigned long last_sync = location->getLastValidTimeSync(); + size_t used = strlen(reply); + if (last_sync == 0) { + snprintf(reply + used, 160 - used, ", last sync: none"); + } else { + DateTime dt = DateTime(last_sync); + snprintf(reply + used, 160 - used, + ", last sync: %02d:%02d - %d/%d/%d UTC", + dt.hour(), dt.minute(), dt.day(), dt.month(), dt.year()); + } + } else if (enabled) { + sprintf(reply, "on, %s, %s, %d sats", + active ? "active" : "deactivated", + fix ? "fix" : "no fix", sats); + } else { + strcpy(reply, "off"); + } + } +#endif + } else if (strcmp(command, "sensor") == 0) { +#if defined(ENV_PIN_SDA) && defined(ENV_PIN_SCL) + sprintf(reply, "I2C Wire1: SDA=%s,SCL=%s\r\n", + STR(ENV_PIN_SDA), STR(ENV_PIN_SCL)); +#elif defined(PIN_BOARD_SDA) && defined(PIN_BOARD_SCL) + sprintf(reply, "I2C Wire: SDA=%s, SCL=%s\r\n", + STR(PIN_BOARD_SDA), STR(PIN_BOARD_SCL)); +#elif defined(PIN_WIRE_SDA) && defined(PIN_WIRE_SCL) + sprintf(reply, "I2C Wire: SDA=%s, SCL=%s\r\n", + STR(PIN_WIRE_SDA), STR(PIN_WIRE_SCL)); +#else + sprintf(reply, "I2C GPIOs not defined\r\n"); +#endif +#if defined(PIN_GPS_RX) && defined(PIN_GPS_TX) + sprintf(reply + strlen(reply), "GPS Serial: RX=%s, TX=%s", + STR(PIN_GPS_RX), STR(PIN_GPS_TX)); + #if defined(ENV_INCLUDE_GPS) && ENV_INCLUDE_GPS > 0 + sprintf(reply + strlen(reply), ". Configured"); + #else + sprintf(reply + strlen(reply), ". Not configured"); + #endif +#else + sprintf(reply + strlen(reply), "GPS Serial not defined"); +#endif + } else if (strcmp(command, "powerlog") == 0) { + sprintf(reply, "Last reset reason: %s", + _board->getResetReasonString(_board->getResetReason())); + } else { + strcpy(reply, "Unknown command"); + } + return; +#else if (memcmp(command, "poweroff", 8) == 0 || memcmp(command, "shutdown", 8) == 0) { _board->powerOff(); // doesn't return } else if (memcmp(command, "reboot", 6) == 0) { @@ -2346,12 +2680,17 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re strcpy(tmp, &command[10]); const char *parts[5]; int num = mesh::Utils::parseTextParts(tmp, parts, 5); - float freq = num > 0 ? strtof(parts[0], nullptr) : 0.0f; - float bw = num > 1 ? strtof(parts[1], nullptr) : 0.0f; + float freq = 0.0f; + float bw = 0.0f; uint8_t sf = num > 2 ? atoi(parts[2]) : 0; uint8_t cr = num > 3 ? atoi(parts[3]) : 0; int temp_timeout_mins = num > 4 ? atoi(parts[4]) : 0; - if (freq >= 150.0f && freq <= 2500.0f && sf >= 5 && sf <= 12 && cr >= 5 && cr <= 8 && isValidLoRaBandwidth(bw) && temp_timeout_mins > 0) { + if (num == 5 + && mesh::cli::parseDecimalStrict(parts[0], freq) + && mesh::cli::parseDecimalStrict(parts[1], bw) + && freq >= 150.0f && freq <= 2500.0f + && sf >= 5 && sf <= 12 && cr >= 5 && cr <= 8 + && isValidLoRaBandwidth(bw) && temp_timeout_mins > 0) { _callbacks->applyTempRadioParams(freq, bw, sf, cr, temp_timeout_mins); sprintf(reply, "OK - temp params for %d mins", temp_timeout_mins); } else { @@ -2657,6 +2996,7 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re } else { strcpy(reply, "Unknown command"); } +#endif } void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* reply) { @@ -2679,11 +3019,174 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep #endif // Observer/MQTT/WiFi/timezone/alert/SNMP commands live in CommonCLI_Observer.cpp. if (handleObserverSetCmd(sender_timestamp, config, reply)) return; -#if defined(PORTABLE_ESP32_MINIMAL_CLI) - // Size-constrained ESP32 images keep role-specific controls but omit the - // large general configuration parser. Radio and mesh defaults remain fixed - // by the selected build profile. - sprintf(reply, "unknown portable config: %s", config); +#if defined(PORTABLE_ESP32_RADIO_CLI) + // Portable WiFi-heavy images keep the controls needed to configure and + // diagnose their radio. The full parser remains available in the FULL image. + if (memcmp(config, "int.thresh ", 11) == 0) { + _prefs->interference_threshold = atoi(&config[11]); + savePrefs(); + strcpy(reply, "OK"); + } else if (memcmp(config, "cad ", 4) == 0) { + const char* value = &config[4]; + if (strcmp(value, "on") != 0 && strcmp(value, "off") != 0) { + strcpy(reply, "Error: use set cad on|off"); + } else { + _prefs->cad_enabled = strcmp(value, "on") == 0; + savePrefs(); + strcpy(reply, "OK"); + } + } else if (memcmp(config, "agc.reset.interval ", 19) == 0) { + _prefs->agc_reset_interval = atoi(&config[19]) / 4; + savePrefs(); + sprintf(reply, "OK - interval rounded to %d", + ((uint32_t)_prefs->agc_reset_interval) * 4); + } else if (memcmp(config, "repeat ", 7) == 0) { + const char* value = &config[7]; + if (strcmp(value, "on") != 0 && strcmp(value, "off") != 0) { + strcpy(reply, "Error, must be on or off"); + } else { + _prefs->disable_fwd = strcmp(value, "off") == 0; + savePrefs(); + _callbacks->onRetryConfigChanged(); + strcpy(reply, _prefs->disable_fwd + ? "OK - repeat is now OFF" : "OK - repeat is now ON"); + } + } else if (memcmp(config, "radio.rxgain ", 13) == 0) { + const char* value = &config[13]; + if (strcmp(value, "on") != 0 && strcmp(value, "off") != 0) { + strcpy(reply, "Error: use set radio.rxgain on|off"); + } else { + bool enabled = strcmp(value, "on") == 0; + if (_callbacks->setRxBoostedGain(enabled)) { + _prefs->rx_boosted_gain = enabled; + savePrefs(); + strcpy(reply, "OK"); + } else { + strcpy(reply, "Error: unsupported"); + } + } + } else if (memcmp(config, "radio.fem.rxgain ", 17) == 0) { + const char* value = &config[17]; + if (!_board->canControlLoRaFemLna()) { + strcpy(reply, "Error: unsupported"); + } else if (strcmp(value, "on") != 0 && strcmp(value, "off") != 0) { + strcpy(reply, "Error: state must be on or off"); + } else { + bool enabled = strcmp(value, "on") == 0; + bool changed = _board->isLoRaFemLnaEnabled() != enabled; + if (_board->setLoRaFemLnaEnabled(enabled)) { + if (changed) _callbacks->recalibrateNoiseFloor(); + _prefs->radio_fem_rxgain = enabled ? 1 : 0; + savePrefs(); + strcpy(reply, enabled + ? "OK - LoRa FEM RX gain on" : "OK - LoRa FEM RX gain off"); + } else { + strcpy(reply, "Error: failed to apply LoRa FEM RX gain"); + } + } + } else if (memcmp(config, "radio ", 6) == 0) { + strcpy(tmp, &config[6]); + const char* parts[4]; + int num = mesh::Utils::parseTextParts(tmp, parts, 4); + float freq = 0.0f; + float bw = 0.0f; + uint8_t sf = 0; + uint8_t cr = 0; + if (num == 4 + && mesh::cli::parseDecimalStrict(parts[0], freq) + && mesh::cli::parseDecimalStrict(parts[1], bw) + && parseUint8Strict(parts[2], 5, 12, sf) + && parseUint8Strict(parts[3], 5, 8, cr) + && freq >= 150.0f && freq <= 2500.0f + && sf >= 5 && sf <= 12 && cr >= 5 && cr <= 8 + && isValidLoRaBandwidth(bw)) { + _prefs->sf = sf; + _prefs->cr = cr; + _prefs->freq = freq; + _prefs->bw = bw; + bool rxps_retuned = recalculateRxPowerSavingFromLevel(_prefs); + _callbacks->savePrefs(); + strcpy(reply, rxps_retuned + ? "OK - reboot to apply (rxps retuned)" : "OK - reboot to apply"); + } else { + strcpy(reply, "Error, invalid radio params"); + } + } else if (memcmp(config, "tx ", 3) == 0) { + _prefs->tx_power_dbm = atoi(&config[3]); + savePrefs(); + _callbacks->setTxPower(_prefs->tx_power_dbm); + strcpy(reply, "OK"); + } else if (sender_timestamp == 0 && memcmp(config, "freq ", 5) == 0) { + float freq = 0.0f; + if (mesh::cli::parseDecimalStrict(&config[5], freq) + && freq >= 150.0f && freq <= 2500.0f) { + _prefs->freq = freq; + savePrefs(); + strcpy(reply, "OK - reboot to apply"); + } else { + strcpy(reply, "Error, invalid frequency"); + } +#ifdef WITH_BRIDGE + } else if (memcmp(config, "bridge.enabled ", 15) == 0) { + const char* value = &config[15]; + if (strcmp(value, "on") != 0 && strcmp(value, "off") != 0) { + strcpy(reply, "Error: use set bridge.enabled on|off"); + } else { + _prefs->bridge_enabled = strcmp(value, "on") == 0; + _callbacks->setBridgeState(_prefs->bridge_enabled); + savePrefs(); + strcpy(reply, "OK"); + } + } else if (memcmp(config, "bridge.delay ", 13) == 0) { + int delay = _atoi(&config[13]); + if (delay >= 0 && delay <= 10000) { + _prefs->bridge_delay = (uint16_t)delay; + savePrefs(); + strcpy(reply, "OK"); + } else { + strcpy(reply, "Error: delay must be between 0-10000 ms"); + } + } else if (memcmp(config, "bridge.source ", 14) == 0) { + const char* value = &config[14]; + if (strcmp(value, "rx") != 0 && strcmp(value, "tx") != 0) { + strcpy(reply, "Error: use set bridge.source rx|tx"); + } else { + _prefs->bridge_pkt_src = strcmp(value, "rx") == 0; +#ifdef WITH_MQTT_BRIDGE + _mqtt_prefs.mqtt_rx_enabled = _prefs->bridge_pkt_src; + _mqtt_prefs.mqtt_tx_enabled = !_prefs->bridge_pkt_src; +#endif + savePrefs(); + strcpy(reply, "OK"); + } +#endif +#ifdef WITH_ESPNOW_BRIDGE + } else if (memcmp(config, "bridge.channel ", 15) == 0) { + int channel = atoi(&config[15]); + if (channel >= 1 && channel <= 14) { + _prefs->bridge_channel = (uint8_t)channel; + _callbacks->restartBridge(); + savePrefs(); + strcpy(reply, "OK"); + } else { + strcpy(reply, "Error: channel must be between 1-14"); + } + } else if (memcmp(config, "bridge.secret ", 14) == 0) { + const char* secret = &config[14]; + if (secret[0] == 0 || strlen(secret) >= sizeof(_prefs->bridge_secret)) { + sprintf(reply, "Error: secret must be 1-%u characters", + (unsigned)(sizeof(_prefs->bridge_secret) - 1)); + } else { + StrHelper::strncpy(_prefs->bridge_secret, secret, + sizeof(_prefs->bridge_secret)); + _callbacks->restartBridge(); + savePrefs(); + strcpy(reply, "OK"); + } +#endif + } else { + sprintf(reply, "unknown portable config: %s", config); + } return; #else if (isAdvancedRetryConfig(config) && !_callbacks->supportsAdvancedRetryConfig()) { @@ -2695,8 +3198,9 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep return; } if (memcmp(config, "dutycycle ", 10) == 0) { - float dc = atof(&config[10]); - if (dc < 1 || dc > 100) { + float dc = 0.0f; + if (!mesh::cli::parseDecimalStrict(&config[10], dc) + || dc < 1.0f || dc > 100.0f) { strcpy(reply, "ERROR: dutycycle must be 1-100"); } else { _prefs->airtime_factor = (100.0f / dc) - 1.0f; @@ -2724,17 +3228,27 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep strcpy(reply, "Error: unsupported on this platform"); #endif } else if (memcmp(config, "af ", 3) == 0) { - _prefs->airtime_factor = atof(&config[3]); - savePrefs(); - strcpy(reply, "OK"); + float factor = 0.0f; + if (mesh::cli::parseDecimalStrict(&config[3], factor)) { + _prefs->airtime_factor = factor; + savePrefs(); + strcpy(reply, "OK"); + } else { + strcpy(reply, "Error, invalid airtime factor"); + } } else if (memcmp(config, "int.thresh ", 11) == 0) { _prefs->interference_threshold = atoi(&config[11]); savePrefs(); strcpy(reply, "OK"); } else if (memcmp(config, "cad ", 4) == 0) { - _prefs->cad_enabled = memcmp(&config[4], "on", 2) == 0; - savePrefs(); - strcpy(reply, "OK"); + const char* value = &config[4]; + if (strcmp(value, "on") != 0 && strcmp(value, "off") != 0) { + strcpy(reply, "Error: use set cad on|off"); + } else { + _prefs->cad_enabled = strcmp(value, "on") == 0; + savePrefs(); + strcpy(reply, "OK"); + } } else if (memcmp(config, "agc.reset.interval ", 19) == 0) { _prefs->agc_reset_interval = atoi(&config[19]) / 4; savePrefs(); @@ -2951,11 +3465,16 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep strcpy(tmp, &config[6]); const char *parts[4]; int num = mesh::Utils::parseTextParts(tmp, parts, 4); - float freq = num > 0 ? strtof(parts[0], nullptr) : 0.0f; - float bw = num > 1 ? strtof(parts[1], nullptr) : 0.0f; + float freq = 0.0f; + float bw = 0.0f; uint8_t sf = num > 2 ? atoi(parts[2]) : 0; uint8_t cr = num > 3 ? atoi(parts[3]) : 0; - if (freq >= 150.0f && freq <= 2500.0f && sf >= 5 && sf <= 12 && cr >= 5 && cr <= 8 && isValidLoRaBandwidth(bw)) { + if (num == 4 + && mesh::cli::parseDecimalStrict(parts[0], freq) + && mesh::cli::parseDecimalStrict(parts[1], bw) + && freq >= 150.0f && freq <= 2500.0f + && sf >= 5 && sf <= 12 && cr >= 5 && cr <= 8 + && isValidLoRaBandwidth(bw)) { _prefs->sf = sf; _prefs->cr = cr; _prefs->freq = freq; @@ -2991,16 +3510,27 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep _callbacks->addScheduledRadioParams(true, freq, bw, sf, cr, start_time, end_time, reply); } } else if (memcmp(config, "lat ", 4) == 0) { - _prefs->node_lat = atof(&config[4]); - savePrefs(); - strcpy(reply, "OK"); + float latitude = 0.0f; + if (mesh::cli::parseDecimalStrict(&config[4], latitude)) { + _prefs->node_lat = latitude; + savePrefs(); + strcpy(reply, "OK"); + } else { + strcpy(reply, "Error, invalid latitude"); + } } else if (memcmp(config, "lon ", 4) == 0) { - _prefs->node_lon = atof(&config[4]); - savePrefs(); - strcpy(reply, "OK"); + float longitude = 0.0f; + if (mesh::cli::parseDecimalStrict(&config[4], longitude)) { + _prefs->node_lon = longitude; + savePrefs(); + strcpy(reply, "OK"); + } else { + strcpy(reply, "Error, invalid longitude"); + } } else if (memcmp(config, "rxdelay ", 8) == 0) { - float db = atof(&config[8]); - if (db >= 0 && db <= 20.0f) { + float db = 0.0f; + if (mesh::cli::parseDecimalStrict(&config[8], db) + && db >= 0.0f && db <= 20.0f) { _prefs->rx_delay_base = db; savePrefs(); strcpy(reply, "OK"); @@ -3008,8 +3538,9 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep strcpy(reply, "Error, must be 0-20"); } } else if (memcmp(config, "txdelay ", 8) == 0) { - float f = atof(&config[8]); - if (f >= 0 && f <= 2.0f) { + float f = 0.0f; + if (mesh::cli::parseDecimalStrict(&config[8], f) + && f >= 0.0f && f <= 2.0f) { _prefs->tx_delay_factor = f; savePrefs(); strcpy(reply, "OK"); @@ -3044,8 +3575,9 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep strcpy(reply, "Error, max 64"); } } else if (memcmp(config, "direct.txdelay ", 15) == 0) { - float f = atof(&config[15]); - if (f >= 0 && f <= 2.0f) { + float f = 0.0f; + if (mesh::cli::parseDecimalStrict(&config[15], f) + && f >= 0.0f && f <= 2.0f) { _prefs->direct_tx_delay_factor = f; savePrefs(); strcpy(reply, "OK"); @@ -3416,9 +3948,15 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep _callbacks->setTxPower(_prefs->tx_power_dbm); strcpy(reply, "OK"); } else if (sender_timestamp == 0 && memcmp(config, "freq ", 5) == 0) { - _prefs->freq = atof(&config[5]); - savePrefs(); - strcpy(reply, "OK - reboot to apply"); + float freq = 0.0f; + if (mesh::cli::parseDecimalStrict(&config[5], freq) + && freq >= 150.0f && freq <= 2500.0f) { + _prefs->freq = freq; + savePrefs(); + strcpy(reply, "OK - reboot to apply"); + } else { + strcpy(reply, "Error, invalid frequency"); + } #ifdef WITH_BRIDGE } else if (memcmp(config, "bridge.enabled ", 15) == 0) { _prefs->bridge_enabled = memcmp(&config[15], "on", 2) == 0; @@ -3483,8 +4021,11 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep } #endif } else if (memcmp(config, "adc.multiplier ", 15) == 0) { - _prefs->adc_multiplier = atof(&config[15]); - if (_board->setAdcMultiplier(_prefs->adc_multiplier)) { + float multiplier = 0.0f; + if (!mesh::cli::parseDecimalStrict(&config[15], multiplier)) { + strcpy(reply, "Error: invalid multiplier"); + } else if (_board->setAdcMultiplier(multiplier)) { + _prefs->adc_multiplier = multiplier; savePrefs(); if (_prefs->adc_multiplier == 0.0f) { strcpy(reply, "OK - using default board multiplier"); @@ -3492,7 +4033,6 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep sprintf(reply, "OK - multiplier set to %.3f", _prefs->adc_multiplier); } } else { - _prefs->adc_multiplier = 0.0f; strcpy(reply, "Error: unsupported"); }; } else if (memcmp(config, "reboot.interval ", 16) == 0) { @@ -3526,8 +4066,66 @@ void CommonCLI::handleGetCmd(uint32_t sender_timestamp, char* command, char* rep #endif // Observer/MQTT/WiFi/timezone/alert/SNMP commands live in CommonCLI_Observer.cpp. if (handleObserverGetCmd(sender_timestamp, config, reply)) return; -#if defined(PORTABLE_ESP32_MINIMAL_CLI) - sprintf(reply, "unknown portable config: %s", config); +#if defined(PORTABLE_ESP32_RADIO_CLI) + if (strcmp(config, "int.thresh") == 0) { + sprintf(reply, "> %d", (uint32_t)_prefs->interference_threshold); + } else if (strcmp(config, "cad") == 0) { + sprintf(reply, "> %s. # channel busy: %u", + _prefs->cad_enabled ? "on" : "off", _board->n_cad_busy); + } else if (strcmp(config, "agc.reset.interval") == 0) { + sprintf(reply, "> %d", ((uint32_t)_prefs->agc_reset_interval) * 4); + } else if (strcmp(config, "repeat") == 0) { + sprintf(reply, "> %s", _prefs->disable_fwd ? "off" : "on"); + } else if (strcmp(config, "radio.rxgain") == 0) { + sprintf(reply, "> %s", _prefs->rx_boosted_gain ? "on" : "off"); + } else if (strcmp(config, "radio.fem.rxgain") == 0) { + if (!_board->canControlLoRaFemLna()) { + strcpy(reply, "Error: unsupported"); + } else { + sprintf(reply, "> %s", + _board->isLoRaFemLnaEnabled() ? "on" : "off"); + } + } else if (strcmp(config, "radio") == 0) { + char freq[16], bw[16]; + strcpy(freq, StrHelper::ftoa(_prefs->freq)); + strcpy(bw, StrHelper::ftoa3(_prefs->bw)); + sprintf(reply, "> %s,%s,%d,%d", + freq, bw, (uint32_t)_prefs->sf, (uint32_t)_prefs->cr); + } else if (strcmp(config, "tx") == 0) { + sprintf(reply, "> %d", (int32_t)_prefs->tx_power_dbm); + } else if (strcmp(config, "freq") == 0) { + sprintf(reply, "> %s", StrHelper::ftoa(_prefs->freq)); + } else if (strcmp(config, "role") == 0) { + sprintf(reply, "> %s", _callbacks->getRole()); +#ifdef WITH_BRIDGE + } else if (strcmp(config, "bridge.enabled") == 0) { + sprintf(reply, "> %s", _prefs->bridge_enabled ? "on" : "off"); + } else if (strcmp(config, "bridge.delay") == 0) { + sprintf(reply, "> %d", (uint32_t)_prefs->bridge_delay); + } else if (strcmp(config, "bridge.source") == 0) { + sprintf(reply, "> %s", _prefs->bridge_pkt_src ? "logRx" : "logTx"); + } else if (strcmp(config, "bridge.type") == 0) { + sprintf(reply, "> %s", +#ifdef WITH_RS232_BRIDGE + "rs232" +#elif WITH_ESPNOW_BRIDGE + "espnow" +#elif WITH_MQTT_BRIDGE + "mqtt" +#else + "none" +#endif + ); +#endif +#ifdef WITH_ESPNOW_BRIDGE + } else if (strcmp(config, "bridge.channel") == 0) { + sprintf(reply, "> %d", (uint32_t)_prefs->bridge_channel); + } else if (strcmp(config, "bridge.secret") == 0) { + sprintf(reply, "> %s", _prefs->bridge_secret); +#endif + } else { + sprintf(reply, "unknown portable config: %s", config); + } return; #else if (isAdvancedRetryConfig(config) && !_callbacks->supportsAdvancedRetryConfig()) { @@ -3747,6 +4345,8 @@ void CommonCLI::handleGetCmd(uint32_t sender_timestamp, char* command, char* rep "rs232" #elif WITH_ESPNOW_BRIDGE "espnow" +#elif WITH_MQTT_BRIDGE + "mqtt" #else "none" #endif diff --git a/src/helpers/CommonCLI.h b/src/helpers/CommonCLI.h index 8275bc14..f7230d80 100644 --- a/src/helpers/CommonCLI.h +++ b/src/helpers/CommonCLI.h @@ -476,6 +476,7 @@ class CommonCLI { void savePrefs(); void loadPrefsInt(FILESYSTEM* _fs, const char* filename); #ifdef WITH_MQTT_BRIDGE + bool recoverCommonPrefsFiles(FILESYSTEM* fs); bool saveCommonPrefsImageAtomically(FILESYSTEM* fs); void loadMQTTPrefs(FILESYSTEM* fs, MQTTPrefsAtomicStore::LegacyUpgradeGate* legacy_upgrade); bool saveMQTTPrefs(FILESYSTEM* fs); diff --git a/src/helpers/CommonPrefsRecovery.h b/src/helpers/CommonPrefsRecovery.h new file mode 100644 index 00000000..885e77ac --- /dev/null +++ b/src/helpers/CommonPrefsRecovery.h @@ -0,0 +1,25 @@ +#pragma once + +#include + +// Recovery policy for a verified temp/backup preference transaction. +// The writer moves the old primary to backup only after temp is complete. +namespace CommonPrefsRecovery { + +enum class Action : uint8_t { + None, + KeepPrimary, + PromoteTemp, + PromoteBackup, + DiscardTemp, +}; + +inline Action select(bool primary_exists, bool temp_exists, bool backup_exists) { + if (primary_exists) return Action::KeepPrimary; + if (backup_exists && temp_exists) return Action::PromoteTemp; + if (backup_exists) return Action::PromoteBackup; + if (temp_exists) return Action::DiscardTemp; + return Action::None; +} + +} // namespace CommonPrefsRecovery diff --git a/src/helpers/ESP32Board.cpp b/src/helpers/ESP32Board.cpp index 6c7ad4a9..b2de97fb 100644 --- a/src/helpers/ESP32Board.cpp +++ b/src/helpers/ESP32Board.cpp @@ -364,23 +364,257 @@ bool ESP32Board::stopOTAUpdate(char reply[]) { // this. We read the web-flasher manifest (config.json), find the `flash-update` // (app-only) build for our own variant, refuse partition-change releases (OTA // can't rewrite the partition table), skip if already up to date, then stream -// the .bin straight into the inactive OTA slot via HTTPUpdate. +// the .bin straight into the inactive OTA slot. // --------------------------------------------------------------------------- #if defined(WITH_MQTT_BRIDGE) #include #include -#include -#include +#include #include #include #include #include +#include // Embedded CA bundle (produced by board_build.embed_files). Weak so non-bundle // builds still link; we check for presence at runtime. extern const uint8_t rootca_crt_bundle_start[] asm("_binary_src_certs_x509_crt_bundle_bin_start") __attribute__((weak)); extern const uint8_t rootca_crt_bundle_end[] asm("_binary_src_certs_x509_crt_bundle_bin_end") __attribute__((weak)); +struct OtaHttpResponse { + int status; + size_t content_length; + bool has_content_length; +}; + +static bool ota_parseHttpUrl(const char* url, bool require_tls, + char* host, size_t host_size, + char* path, size_t path_size, + uint16_t& port) { + const char* cursor; + bool tls; + if (strncmp(url, "https://", 8) == 0) { + cursor = url + 8; + tls = true; + port = 443; + } else if (strncmp(url, "http://", 7) == 0) { + cursor = url + 7; + tls = false; + port = 80; + } else { + return false; + } + if (tls != require_tls) return false; + + const char* slash = strchr(cursor, '/'); + const char* authority_end = slash ? slash : cursor + strlen(cursor); + const char* colon = nullptr; + for (const char* p = cursor; p < authority_end; p++) { + if (*p == ':') colon = p; + } + const char* host_end = colon ? colon : authority_end; + size_t host_len = (size_t)(host_end - cursor); + if (host_len == 0 || host_len >= host_size) return false; + memcpy(host, cursor, host_len); + host[host_len] = 0; + + if (colon) { + uint32_t parsed_port = 0; + const char* p = colon + 1; + if (p == authority_end) return false; + while (p < authority_end) { + if (*p < '0' || *p > '9') return false; + parsed_port = parsed_port * 10U + (uint32_t)(*p++ - '0'); + if (parsed_port > 65535U) return false; + } + if (parsed_port == 0) return false; + port = (uint16_t)parsed_port; + } + + const char* source_path = slash ? slash : "/"; + size_t path_len = strlen(source_path); + if (path_len >= path_size) return false; + memcpy(path, source_path, path_len + 1); + return true; +} + +static bool ota_readHttpLine(Client& client, char* line, size_t line_size) { + size_t length = 0; + uint32_t deadline = millis() + 20000; + while (client.connected() || client.available()) { + while (client.available()) { + int c = client.read(); + if (c < 0) break; + if (c == '\n') { + if (length && line[length - 1] == '\r') length--; + line[length] = 0; + return true; + } + if (length + 1 < line_size) line[length++] = (char)c; + } + if ((int32_t)(millis() - deadline) >= 0) break; + delay(1); + } + return false; +} + +static bool ota_openHttp(Client& client, const char* url, bool require_tls, + OtaHttpResponse& response, char reply[]) { + char host[96]; + char path[224]; + uint16_t port; + if (!ota_parseHttpUrl(url, require_tls, host, sizeof(host), + path, sizeof(path), port)) { + strcpy(reply, require_tls ? "ERR: invalid HTTPS URL" : "ERR: invalid HTTP URL"); + return false; + } + + client.setTimeout(20000); + if (!client.connect(host, port)) { + strcpy(reply, "ERR: HTTP connect failed"); + return false; + } + client.printf("GET %s HTTP/1.0\r\nHost: %s\r\nUser-Agent: MeshCore-OTA\r\n" + "Accept: */*\r\nAccept-Encoding: identity\r\n" + "Cache-Control: no-cache\r\nConnection: close\r\n\r\n", + path, host); + + char line[192]; + if (!ota_readHttpLine(client, line, sizeof(line))) { + strcpy(reply, "ERR: HTTP response timeout"); + client.stop(); + return false; + } + char* status_text = strchr(line, ' '); + if (strncmp(line, "HTTP/", 5) != 0 || status_text == nullptr) { + strcpy(reply, "ERR: invalid HTTP response"); + client.stop(); + return false; + } + + response.status = atoi(status_text + 1); + response.content_length = 0; + response.has_content_length = false; + bool chunked = false; + bool headers_complete = false; + while (ota_readHttpLine(client, line, sizeof(line))) { + if (line[0] == 0) { + headers_complete = true; + break; + } + if (strncasecmp(line, "Content-Length:", 15) == 0) { + const char* value = line + 15; + while (*value == ' ') value++; + char* end = nullptr; + unsigned long parsed = strtoul(value, &end, 10); + while (end != nullptr && *end == ' ') end++; + if (value == end || end == nullptr || *end != 0) { + strcpy(reply, "ERR: invalid HTTP length"); + client.stop(); + return false; + } + response.content_length = (size_t)parsed; + response.has_content_length = true; + } else if (strncasecmp(line, "Transfer-Encoding:", 18) == 0) { + const char* value = line + 18; + while (*value == ' ') value++; + chunked = strncasecmp(value, "chunked", 7) == 0; + } + } + if (!headers_complete) { + strcpy(reply, "ERR: incomplete HTTP headers"); + client.stop(); + return false; + } + if (chunked) { + strcpy(reply, "ERR: chunked HTTP unsupported"); + client.stop(); + return false; + } + return true; +} + +static bool ota_fetchManifest(Client& client, const char* url, bool require_tls, + JsonDocument& doc, char reply[]) { + OtaHttpResponse response; + if (!ota_openHttp(client, url, require_tls, response, reply)) return false; + if (response.status != 200) { + snprintf(reply, 160, "ERR: manifest HTTP %d", response.status); + client.stop(); + return false; + } + if (response.has_content_length && response.content_length > 2048) { + strcpy(reply, "ERR: manifest too large"); + client.stop(); + return false; + } + + DeserializationError err = deserializeJson(doc, client); + client.stop(); + if (err) { + snprintf(reply, 160, "ERR: manifest parse (%s)", err.c_str()); + return false; + } + return true; +} + +static bool ota_streamFirmware(Client& client, size_t content_length, char reply[]) { + if (content_length == 0) { + strcpy(reply, "ERR: empty firmware image"); + return false; + } + if (!Update.begin(content_length, U_FLASH)) { + snprintf(reply, 160, "ERR: OTA begin: %s", Update.errorString()); + return false; + } + + uint8_t buffer[2048]; + size_t received_total = 0; + int progress_decile = -1; + uint32_t deadline = millis() + 20000; + while (received_total < content_length) { + int available = client.available(); + if (available <= 0) { + if (!client.connected()) { + strcpy(reply, "ERR: firmware download incomplete"); + Update.abort(); + return false; + } + if ((int32_t)(millis() - deadline) >= 0) { + strcpy(reply, "ERR: firmware download timeout"); + Update.abort(); + return false; + } + delay(1); + continue; + } + + size_t wanted = content_length - received_total; + if (wanted > sizeof(buffer)) wanted = sizeof(buffer); + if (wanted > (size_t)available) wanted = (size_t)available; + int received = client.read(buffer, wanted); + if (received <= 0) continue; + deadline = millis() + 20000; + if (Update.write(buffer, (size_t)received) != (size_t)received) { + snprintf(reply, 160, "ERR: OTA write: %s", Update.errorString()); + Update.abort(); + return false; + } + received_total += (size_t)received; + int decile = (int)((received_total * 10U) / content_length); + if (decile != progress_decile) { + progress_decile = decile; + Serial.printf("OTA: %d%%\n", decile * 10); + } + } + + if (!Update.end()) { + snprintf(reply, 160, "ERR: OTA finish: %s", Update.errorString()); + return false; + } + return true; +} + // Extract the trailing build hash. For a filename we first drop a ".bin" // suffix, then take the token after the last '-'. Works for both the manifest // asset name ("...-v1.16.0-8b084d5.bin" -> "8b084d5") and the embedded @@ -474,7 +708,7 @@ static void ota_task_entry(void* param) { } bool ESP32Board::otaFromManifest(const char* current_ver, bool dry_run, char reply[]) { - // The TLS handshake (cert-bundle verify) + JSON parse / HTTPUpdate use far more + // The TLS handshake (cert-bundle verify), JSON parse, and flash stream use far more // stack than the ~8 KB loop task offers - especially when reached via the deep // mesh-receive call chain (it overflows the loopTask canary). Run the work in a // dedicated 24 KB-stack task and block here until it finishes. The big stack is @@ -516,8 +750,7 @@ bool ESP32Board::otaFromManifestImpl(const char* current_ver, bool dry_run, char // /.json - a ~180 byte per-variant file, not the // full config.json. char murl[200]; - HTTPClient http; - WiFiClientSecure mclient; // only used for the HTTPS (update) path + JsonDocument doc; if (dry_run) { // `ota check`: fetch over PLAIN HTTP. With no TLS handshake the fetch costs @@ -532,51 +765,27 @@ bool ESP32Board::otaFromManifestImpl(const char* current_ver, bool dry_run, char } else { snprintf(murl, sizeof(murl), "%s/%s.json", OTA_MANIFEST_BASE, OTA_VARIANT); } - if (!http.begin(murl)) { - strcpy(reply, "ERR: manifest connect failed"); + WiFiClient mclient; + if (!ota_fetchManifest(mclient, murl, false, doc, reply)) { return false; } } else { // `ota update`: HTTPS. The bridge is torn down for an update so heap is free, // and integrity matters because we're about to flash. + WiFiClientSecure mclient; #if ESP_ARDUINO_VERSION_MAJOR >= 3 mclient.setCACertBundle(rootca_crt_bundle_start, bundle_len); #else mclient.setCACertBundle(rootca_crt_bundle_start); #endif - mclient.setTimeout(15000); snprintf(murl, sizeof(murl), "%s/%s.json", OTA_MANIFEST_BASE, OTA_VARIANT); - if (!http.begin(mclient, murl)) { - strcpy(reply, "ERR: manifest connect failed"); + if (!ota_fetchManifest(mclient, murl, true, doc, reply)) { return false; } } if (!dry_run) { Serial.print("OTA: checking manifest "); Serial.println(murl); } - // Force HTTP/1.0: a CDN (e.g. Cloudflare) answers HTTP/1.1 with chunked encoding - // and no Content-Length; the raw chunked stream corrupts the parse. HTTP/1.0 - // yields a Connection: close, unframed body. - http.useHTTP10(true); - http.setTimeout(20000); - int code = http.GET(); - if (code != HTTP_CODE_OK) { - snprintf(reply, 160, "ERR: manifest HTTP %d", code); - http.end(); - return false; - } - - WiFiClient* stream = http.getStreamPtr(); - stream->setTimeout(20000); // readBytes honours this, so a slow TLS link != EOF - - JsonDocument doc; - DeserializationError err = deserializeJson(doc, *stream); - http.end(); - if (err) { - snprintf(reply, 160, "ERR: manifest parse (%s)", err.c_str()); - return false; - } - // Copy fields out before the document is reused/cleared. char file_url[200] = {0}, avail_version[40] = {0}, avail_base[40] = {0}, avail_hash[24] = {0}; strncpy(file_url, doc["file"] | "", sizeof(file_url) - 1); @@ -689,28 +898,39 @@ bool ESP32Board::otaFromManifestImpl(const char* current_ver, bool dry_run, char #else uclient.setCACertBundle(rootca_crt_bundle_start); #endif - uclient.setTimeout(20000); - // Console progress to the USB serial (always on; MESH_DEBUG is off on the default - // observer profile). Non-capturing lambdas + a file-static decile, so the global - // httpUpdate object never holds a dangling reference after this function returns. - static int ota_progress_decile; - ota_progress_decile = -1; - httpUpdate.onProgress([](int cur, int total) { - if (total <= 0) return; - int d = (int)((int64_t)cur * 10 / total); - if (d != ota_progress_decile) { ota_progress_decile = d; Serial.printf("OTA: %d%%\n", d * 10); } - }); - httpUpdate.onEnd([]() { Serial.println("OTA: write complete, rebooting..."); }); - httpUpdate.rebootOnUpdate(true); // reboots into the new image on success - t_httpUpdate_return ret = httpUpdate.update(uclient, file_url); + OtaHttpResponse response; + if (!ota_openHttp(uclient, file_url, true, response, reply)) { + inhibit_sleep = false; + Serial.print("OTA: FAILED - "); Serial.println(reply); + return false; + } + if (response.status != 200) { + inhibit_sleep = false; + snprintf(reply, 160, "ERR: firmware HTTP %d", response.status); + uclient.stop(); + Serial.print("OTA: FAILED - "); Serial.println(reply); + return false; + } + if (!response.has_content_length) { + inhibit_sleep = false; + strcpy(reply, "ERR: firmware size missing"); + uclient.stop(); + Serial.print("OTA: FAILED - "); Serial.println(reply); + return false; + } + if (!ota_streamFirmware(uclient, response.content_length, reply)) { + inhibit_sleep = false; + uclient.stop(); + Serial.print("OTA: FAILED - "); Serial.println(reply); + return false; + } - // Only reached on failure (success reboots inside update()). - inhibit_sleep = false; - snprintf(reply, 160, "ERR: OTA failed (%d): %s", (int)ret, - httpUpdate.getLastErrorString().c_str()); - Serial.print("OTA: FAILED - "); Serial.println(reply); - return false; + uclient.stop(); + Serial.println("OTA: write complete, rebooting..."); + delay(250); + esp_restart(); + return true; // unreachable #endif // OTA_MANIFEST_BASE && OTA_VARIANT } #else diff --git a/src/helpers/ESP32Board.h b/src/helpers/ESP32Board.h index 28257e24..2a096227 100644 --- a/src/helpers/ESP32Board.h +++ b/src/helpers/ESP32Board.h @@ -184,7 +184,7 @@ public: bool stopOTAUpdate(char reply[]) override; bool isOTAUpdateRunning() const override { return ota_server != nullptr; } bool otaFromManifest(const char* current_ver, bool dry_run, char reply[]) override; - // Heavy body (TLS + JSON / HTTPUpdate). Runs in a dedicated large-stack task + // Heavy body (TLS + JSON + flash streaming). Runs in a dedicated large-stack task // spawned by otaFromManifest() - public only so that task entry point can call // it; not meant to be invoked directly. bool otaFromManifestImpl(const char* current_ver, bool dry_run, char reply[]); diff --git a/src/helpers/MQTTPayloadBuilder.cpp b/src/helpers/MQTTPayloadBuilder.cpp index 5a40e502..f68ae044 100644 --- a/src/helpers/MQTTPayloadBuilder.cpp +++ b/src/helpers/MQTTPayloadBuilder.cpp @@ -1,4 +1,5 @@ #include "MQTTPayloadBuilder.h" +#include "TxtDataHelpers.h" #include #include @@ -120,7 +121,7 @@ int MQTTPayloadBuilder::buildPacketMessage( snprintf(len_str, sizeof(len_str), "%d", len); snprintf(packet_type_str, sizeof(packet_type_str), "%d", packet_type); snprintf(payload_len_str, sizeof(payload_len_str), "%d", payload_len); - snprintf(snr_str, sizeof(snr_str), "%.1f", snr); + StrHelper::ftoaFixed(snr_str, sizeof(snr_str), snr, 1); snprintf(rssi_str, sizeof(rssi_str), "%d", rssi); root["timestamp"] = timestamp; diff --git a/src/helpers/StatsFormatHelper.h b/src/helpers/StatsFormatHelper.h index 93dc8082..827f87b2 100644 --- a/src/helpers/StatsFormatHelper.h +++ b/src/helpers/StatsFormatHelper.h @@ -1,6 +1,7 @@ #pragma once #include "Mesh.h" +#include "TxtDataHelpers.h" class StatsFormatHelper { public: @@ -24,11 +25,13 @@ public: RadioDriverType& driver, uint32_t total_air_time_ms, uint32_t total_rx_air_time_ms) { - sprintf(reply, - "{\"noise_floor\":%d,\"last_rssi\":%d,\"last_snr\":%.2f,\"tx_air_secs\":%u,\"rx_air_secs\":%u}", + char last_snr[16]; + StrHelper::ftoaFixed(last_snr, sizeof(last_snr), driver.getLastSNR(), 2); + sprintf(reply, + "{\"noise_floor\":%d,\"last_rssi\":%d,\"last_snr\":%s,\"tx_air_secs\":%u,\"rx_air_secs\":%u}", (int16_t)radio->getNoiseFloor(), (int16_t)driver.getLastRSSI(), - driver.getLastSNR(), + last_snr, total_air_time_ms / 1000, total_rx_air_time_ms / 1000 ); diff --git a/src/helpers/TxtDataHelpers.cpp b/src/helpers/TxtDataHelpers.cpp index 5d9f2ef0..d3063a9e 100644 --- a/src/helpers/TxtDataHelpers.cpp +++ b/src/helpers/TxtDataHelpers.cpp @@ -1,4 +1,6 @@ #include "TxtDataHelpers.h" +#include +#include #include void StrHelper::stripSurroundingQuotes(char* str, size_t buf_sz) { @@ -167,6 +169,52 @@ const char* StrHelper::ftoa3(float f) { return s; } +bool StrHelper::ftoaFixed(char* dest, size_t dest_size, float value, uint8_t precision) { + static const uint32_t scales[] = { + 1U, 10U, 100U, 1000U, 10000U, 100000U, 1000000U, 10000000U + }; + + if (!dest || dest_size == 0) return false; + if (!isfinite(value)) { + strzcpy(dest, "0", dest_size); + return false; + } + if (precision > 7) precision = 7; + + const bool negative = signbit(value); + const float magnitude = negative ? -value : value; + if (magnitude > 4294967040.0f) { + strzcpy(dest, "0", dest_size); + return false; + } + + uint32_t whole = (uint32_t)magnitude; + const uint32_t scale = scales[precision]; + uint32_t fraction = 0; + if (precision > 0) { + fraction = (uint32_t)(((magnitude - (float)whole) * (float)scale) + 0.5f); + if (fraction >= scale) { + whole++; + fraction = 0; + } + } + + int written; + if (precision == 0) { + written = snprintf(dest, dest_size, "%s%lu", + negative ? "-" : "", (unsigned long)whole); + } else { + written = snprintf(dest, dest_size, "%s%lu.%0*lu", + negative ? "-" : "", (unsigned long)whole, + (int)precision, (unsigned long)fraction); + } + if (written < 0 || (size_t)written >= dest_size) { + dest[dest_size - 1] = 0; + return false; + } + return true; +} + uint32_t StrHelper::fromHex(const char* src) { uint32_t n = 0; while (*src) { diff --git a/src/helpers/TxtDataHelpers.h b/src/helpers/TxtDataHelpers.h index d9e82b23..0a8f8369 100644 --- a/src/helpers/TxtDataHelpers.h +++ b/src/helpers/TxtDataHelpers.h @@ -17,6 +17,7 @@ public: static void stripSurroundingQuotes(char* str, size_t buf_sz); static const char* ftoa(float f); static const char* ftoa3(float f); //Converts float to string with 3 decimal places + static bool ftoaFixed(char* dest, size_t dest_size, float value, uint8_t precision); static bool isBlank(const char* str); static uint32_t fromHex(const char* src); }; diff --git a/src/helpers/bridges/MQTTBridge.cpp b/src/helpers/bridges/MQTTBridge.cpp index 1c05b81a..3c2b85f3 100644 --- a/src/helpers/bridges/MQTTBridge.cpp +++ b/src/helpers/bridges/MQTTBridge.cpp @@ -54,6 +54,15 @@ static constexpr size_t kNtpBuiltinFallbackCount = static_assert(MQTTBridge::kMaxNtpServers >= 1 + (int)kNtpBuiltinFallbackCount, "kMaxNtpServers must hold the custom primary plus all built-in fallbacks"); +static void formatRadioInfo(float freq, float bw, int sf, int cr, + char* dest, size_t dest_size) { + char freq_text[20]; + char bw_text[16]; + StrHelper::ftoaFixed(freq_text, sizeof(freq_text), freq, 6); + StrHelper::ftoaFixed(bw_text, sizeof(bw_text), bw, 1); + snprintf(dest, dest_size, "%s,%s,%d,%d", freq_text, bw_text, sf, cr); +} + static bool ntpHostnameEquals(const char* a, const char* b) { if (!a || !b) return false; return strcasecmp(a, b) == 0; @@ -2337,11 +2346,11 @@ void MQTTBridge::publishStatusToSlot(int index) { gettimeofday(&now_tv, nullptr); MQTTMessageBuilder::formatIsoTimestampForMqtt(now_tv.tv_sec, now_tv.tv_usec, _timezone, timestamp, sizeof(timestamp)); - snprintf(radio_info, sizeof(radio_info), "%.6f,%.1f,%d,%d", - _node_info.freq ? *_node_info.freq : 0.0f, - _node_info.bw ? *_node_info.bw : 0.0f, - _node_info.sf ? *_node_info.sf : 0, - _node_info.cr ? *_node_info.cr : 0); + formatRadioInfo(_node_info.freq ? *_node_info.freq : 0.0f, + _node_info.bw ? *_node_info.bw : 0.0f, + _node_info.sf ? *_node_info.sf : 0, + _node_info.cr ? *_node_info.cr : 0, + radio_info, sizeof(radio_info)); strncpy(origin_id, _device_id, sizeof(origin_id) - 1); origin_id[sizeof(origin_id) - 1] = '\0'; @@ -3118,11 +3127,11 @@ bool MQTTBridge::publishStatus() { gettimeofday(&now_tv, nullptr); MQTTMessageBuilder::formatIsoTimestampForMqtt(now_tv.tv_sec, now_tv.tv_usec, _timezone, timestamp, sizeof(timestamp)); - snprintf(radio_info, sizeof(radio_info), "%.6f,%.1f,%d,%d", - _node_info.freq ? *_node_info.freq : 0.0f, - _node_info.bw ? *_node_info.bw : 0.0f, - _node_info.sf ? *_node_info.sf : 0, - _node_info.cr ? *_node_info.cr : 0); + formatRadioInfo(_node_info.freq ? *_node_info.freq : 0.0f, + _node_info.bw ? *_node_info.bw : 0.0f, + _node_info.sf ? *_node_info.sf : 0, + _node_info.cr ? *_node_info.cr : 0, + radio_info, sizeof(radio_info)); strncpy(origin_id, _device_id, sizeof(origin_id) - 1); origin_id[sizeof(origin_id) - 1] = '\0'; diff --git a/src/helpers/sim/SimRadio.h b/src/helpers/sim/SimRadio.h index ef5a01ec..7d6424e0 100644 --- a/src/helpers/sim/SimRadio.h +++ b/src/helpers/sim/SimRadio.h @@ -64,12 +64,26 @@ public: float getLastSNR() const override { return 9.0f; } // --- concrete RadioLibWrapper surface called directly on radio_driver --- - void setParams(float /*freq*/, float /*bw*/, uint8_t /*sf*/, uint8_t /*cr*/) {} + mesh::RadioParamApplyResult trySetParams( + float /*freq*/, float /*bw*/, uint8_t /*sf*/, uint8_t /*cr*/, + const uint32_t* /*rx_ps_timings*/ = nullptr) override { + return mesh::RadioParamApplyResult::APPLIED; + } + bool setParams(float freq, float bw, uint8_t sf, uint8_t cr, + const uint32_t* rx_ps_timings = nullptr) { + return trySetParams(freq, bw, sf, cr, rx_ps_timings) + == mesh::RadioParamApplyResult::APPLIED; + } + void powerOff() {} void setTxPower(int8_t /*dbm*/) {} uint32_t getRngSeed() { return _simRandom(); } uint32_t getPacketsRecv() const { return n_recv; } uint32_t getPacketsSent() const { return n_sent; } void resetStats() { n_recv = n_sent = n_recv_errors = 0; } - void setRxBoostedGainMode(bool) {} + bool setRxBoostedGainMode(bool /*enabled*/) { return false; } bool getRxBoostedGainMode() const { return false; } + uint32_t getRxPsWatchdogSoftCount() const { return 0; } + uint32_t getRxPsWatchdogHardCount() const { return 0; } + bool isWatchdogObserving() const { return false; } + bool isCalibratingNoiseFloor() const { return false; } }; diff --git a/test/mocks/Arduino.h b/test/mocks/Arduino.h index 77499fe4..01864548 100644 --- a/test/mocks/Arduino.h +++ b/test/mocks/Arduino.h @@ -2,6 +2,7 @@ #include #include +#include #include "Stream.h" inline uint32_t g_mock_millis = 0; @@ -15,3 +16,12 @@ inline uint32_t millis() { inline void delay(uint32_t ms) { g_mock_millis += ms; } + +inline char* ltoa(long value, char* dest, int base) { + if (base == 10) { + std::snprintf(dest, 16, "%ld", value); + } else { + dest[0] = '\0'; + } + return dest; +} diff --git a/test/test_cli_command_utils/test_cli_command_utils.cpp b/test/test_cli_command_utils/test_cli_command_utils.cpp index 779220b3..fa75ffb2 100644 --- a/test/test_cli_command_utils/test_cli_command_utils.cpp +++ b/test/test_cli_command_utils/test_cli_command_utils.cpp @@ -40,6 +40,31 @@ TEST(CLICommandUtils, HandlesLeadingWhitespaceAndSingleWordCommands) { EXPECT_STREQ("powersaving", single_command); } +TEST(CLICommandUtils, ParsesStrictDecimalValues) { + float value = 0.0f; + + EXPECT_TRUE(mesh::cli::parseDecimalStrict("910.525", value)); + EXPECT_FLOAT_EQ(910.525f, value); + EXPECT_TRUE(mesh::cli::parseDecimalStrict(" -122.25 ", value)); + EXPECT_FLOAT_EQ(-122.25f, value); + EXPECT_TRUE(mesh::cli::parseDecimalStrict("+.5", value)); + EXPECT_FLOAT_EQ(0.5f, value); + EXPECT_TRUE(mesh::cli::parseDecimalStrict("7.", value)); + EXPECT_FLOAT_EQ(7.0f, value); +} + +TEST(CLICommandUtils, RejectsNonDecimalOrOverflowValues) { + float value = 123.0f; + + EXPECT_FALSE(mesh::cli::parseDecimalStrict(nullptr, value)); + EXPECT_FALSE(mesh::cli::parseDecimalStrict("", value)); + EXPECT_FALSE(mesh::cli::parseDecimalStrict(".", value)); + EXPECT_FALSE(mesh::cli::parseDecimalStrict("1.2x", value)); + EXPECT_FALSE(mesh::cli::parseDecimalStrict("1e3", value)); + EXPECT_FALSE(mesh::cli::parseDecimalStrict("4294967296", value)); + EXPECT_FLOAT_EQ(123.0f, value); +} + int main(int argc, char** argv) { ::testing::InitGoogleTest(&argc, argv); return RUN_ALL_TESTS(); diff --git a/test/test_mqtt_payload_builder/test_mqtt_payload_builder.cpp b/test/test_mqtt_payload_builder/test_mqtt_payload_builder.cpp index 20b5781d..015d3aa9 100644 --- a/test/test_mqtt_payload_builder/test_mqtt_payload_builder.cpp +++ b/test/test_mqtt_payload_builder/test_mqtt_payload_builder.cpp @@ -7,6 +7,7 @@ #include #include "helpers/MQTTPayloadBuilder.h" +#include "helpers/TxtDataHelpers.h" namespace { @@ -134,6 +135,19 @@ TEST(MQTTPayloadBuilder, RxPacketIncludesMetricsScaledScoreAndPath) { EXPECT_STREQ("012f", parsed_path[1].as()); } +TEST(MQTTPayloadBuilder, FixedFloatFormattingDoesNotRequirePrintfFloat) { + char value[20]; + + EXPECT_TRUE(StrHelper::ftoaFixed(value, sizeof(value), 915.0f, 6)); + EXPECT_STREQ("915.000000", value); + EXPECT_TRUE(StrHelper::ftoaFixed(value, sizeof(value), 10.26f, 1)); + EXPECT_STREQ("10.3", value); + EXPECT_TRUE(StrHelper::ftoaFixed(value, sizeof(value), -87.25f, 2)); + EXPECT_STREQ("-87.25", value); + EXPECT_FALSE(StrHelper::ftoaFixed(value, sizeof(value), std::nanf(""), 1)); + EXPECT_STREQ("0", value); +} + TEST(MQTTPayloadBuilder, TxPacketOmitsReceiveOnlyMetricsAndAbsentPath) { JsonDocument scratch; char buffer[2048]; diff --git a/test/test_mqtt_prefs_atomic_store/test_mqtt_prefs_atomic_store.cpp b/test/test_mqtt_prefs_atomic_store/test_mqtt_prefs_atomic_store.cpp index 67880a82..4c50d84c 100644 --- a/test/test_mqtt_prefs_atomic_store/test_mqtt_prefs_atomic_store.cpp +++ b/test/test_mqtt_prefs_atomic_store/test_mqtt_prefs_atomic_store.cpp @@ -5,10 +5,12 @@ #include #include +#include "helpers/CommonPrefsRecovery.h" #include "helpers/MQTTPrefsAtomicStore.h" #include "helpers/MQTTPrefsRecovery.h" namespace AtomicStore = MQTTPrefsAtomicStore; +namespace CommonRecovery = CommonPrefsRecovery; namespace Recovery = MQTTPrefsRecovery; namespace { @@ -160,7 +162,9 @@ public: ++begin_calls; _files.erase("/com_prefs.tmp"); _staging.clear(); + _finished = false; _open = _failure != FailurePoint::Begin; + _owns_temp = _open; return _open; } @@ -178,6 +182,7 @@ public: _open = false; if (_failure == FailurePoint::Finish) return false; _files["/com_prefs.tmp"] = _staging; + _finished = true; return true; } @@ -186,6 +191,7 @@ public: if (_failure == FailurePoint::Commit) return false; _files["/com_prefs"] = _files["/com_prefs.tmp"]; _files.erase("/com_prefs.tmp"); + _finished = false; return true; } @@ -193,7 +199,18 @@ public: ++abort_calls; _open = false; _staging.clear(); - _files.erase("/com_prefs.tmp"); + if (_owns_temp && !_finished) _files.erase("/com_prefs.tmp"); + _finished = false; + _owns_temp = false; + } + + void recover() { + const CommonRecovery::Action action = CommonRecovery::select( + destinationExists(), tempExists(), false); + if (action == CommonRecovery::Action::KeepPrimary || + action == CommonRecovery::Action::DiscardTemp) { + _files.erase("/com_prefs.tmp"); + } } void removeNodeSource() { _files.erase("/node_prefs"); } @@ -215,6 +232,8 @@ public: private: FailurePoint _failure; bool _open = false; + bool _finished = false; + bool _owns_temp = false; std::vector _staging; std::map> _files; }; @@ -497,6 +516,10 @@ TEST(MQTTPrefsAtomicStore, NodePrefsMigrationFailurePreservesSourceAndNeverPrefe for (const auto& test_case : cases) { InMemoryCommonPrefsStore store(test_case.point); EXPECT_EQ(test_case.expected, runCommonPrefsImage(&store)); + if (test_case.point == FailurePoint::Commit) { + EXPECT_TRUE(store.tempExists()); // complete, but uncommitted first image + } + store.recover(); EXPECT_EQ(legacy_node, store.nodeSource()); EXPECT_TRUE(store.nodeSourceIsPreferred()); EXPECT_FALSE(store.destinationExists()); @@ -617,6 +640,19 @@ TEST(MQTTPrefsAtomicStore, RecoveryNeverOverwritesOpaqueNewerLayout) { Recovery::FileState::Preserve)); } +TEST(MQTTPrefsAtomicStore, CommonPrefsRecoveryUsesOnlyVerifiedTempWithBackup) { + EXPECT_EQ(CommonRecovery::Action::KeepPrimary, + CommonRecovery::select(true, true, true)); + EXPECT_EQ(CommonRecovery::Action::PromoteTemp, + CommonRecovery::select(false, true, true)); + EXPECT_EQ(CommonRecovery::Action::PromoteBackup, + CommonRecovery::select(false, false, true)); + EXPECT_EQ(CommonRecovery::Action::DiscardTemp, + CommonRecovery::select(false, true, false)); + EXPECT_EQ(CommonRecovery::Action::None, + CommonRecovery::select(false, false, false)); +} + int main(int argc, char** argv) { ::testing::InitGoogleTest(&argc, argv); return RUN_ALL_TESTS(); diff --git a/variants/rak3x72/platformio.ini b/variants/rak3x72/platformio.ini index 91406437..98e515b7 100644 --- a/variants/rak3x72/platformio.ini +++ b/variants/rak3x72/platformio.ini @@ -22,6 +22,8 @@ build_flags = ${rak3x72.build_flags} -fno-inline-small-functions ; reduce flash without moving the deployed filesystem boundary -fipa-pta -fno-semantic-interposition + -fno-unwind-tables + -fno-asynchronous-unwind-tables -D LFS_FLASH_TOTAL_SIZE=16384 -D ADVERT_NAME='"RAK3x72 Repeater"' -D ADMIN_PASSWORD='"password"' diff --git a/variants/tiny_relay/platformio.ini b/variants/tiny_relay/platformio.ini index c8f11ca7..3d60f9db 100644 --- a/variants/tiny_relay/platformio.ini +++ b/variants/tiny_relay/platformio.ini @@ -23,6 +23,8 @@ build_flags = ${Tiny_Relay.build_flags} -fno-inline-small-functions ; reduce flash without moving the deployed filesystem boundary -fipa-pta -fno-semantic-interposition + -fno-unwind-tables + -fno-asynchronous-unwind-tables -D LFS_FLASH_TOTAL_SIZE=16384 -D ADVERT_NAME='"tiny_relay Repeater"' -D ADVERT_LAT=0.0 diff --git a/variants/wio-e5-dev/platformio.ini b/variants/wio-e5-dev/platformio.ini index 2d0ac15c..448de7ea 100644 --- a/variants/wio-e5-dev/platformio.ini +++ b/variants/wio-e5-dev/platformio.ini @@ -23,6 +23,8 @@ build_flags = ${lora_e5.build_flags} -fno-inline-small-functions ; reduce flash without moving the deployed filesystem boundary -fipa-pta -fno-semantic-interposition + -fno-unwind-tables + -fno-asynchronous-unwind-tables -D LFS_FLASH_TOTAL_SIZE=16384 -D LORA_TX_POWER=22 -D ADVERT_NAME='"WIO-E5 Repeater"' @@ -39,6 +41,8 @@ build_flags = ${lora_e5.build_flags} -fno-inline-small-functions ; reduce flash without moving the deployed filesystem boundary -fipa-pta -fno-semantic-interposition + -fno-unwind-tables + -fno-asynchronous-unwind-tables -D LFS_FLASH_TOTAL_SIZE=16384 -D MESH_ENABLE_RECENT_REPEATERS=1 -D LORA_TX_POWER=22 diff --git a/variants/wio-e5-mini/platformio.ini b/variants/wio-e5-mini/platformio.ini index 29cf5326..54a385ab 100644 --- a/variants/wio-e5-mini/platformio.ini +++ b/variants/wio-e5-mini/platformio.ini @@ -27,6 +27,8 @@ build_flags = ${lora_e5_mini.build_flags} -fno-inline-small-functions ; reduce flash without moving the deployed filesystem boundary -fipa-pta -fno-semantic-interposition + -fno-unwind-tables + -fno-asynchronous-unwind-tables -D RADIO_LIVENESS_SOFT_ONLY=1 ; integrated STM32WL has no separate radio-reset pin -D WIO_E5_MINI_NO_EXTERNAL_SENSORS=1 ; the dedicated sensor env retains BME280 support -D LFS_FLASH_TOTAL_SIZE=16384