From 2936598e63dc58ed5dd70a357ef1bf433dc74153 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Sat, 31 Oct 2026 13:29:39 -0700 Subject: [PATCH 1/3] Consolidate Cardputer, Wio L1 and headless KISS builds --- .github/workflows/run-unit-tests.yml | 23 ++ build_legacy.sh | 16 + docs/_javascript/firmware_picker.js | 35 +- docs/full_companion_features.md | 21 +- docs/kiss_modem_protocol.md | 6 + examples/companion_radio/MyMesh.cpp | 3 + examples/companion_radio/main.cpp | 9 +- examples/companion_radio/ui-new/UITask.cpp | 34 +- scripts/check_firmware_ram.py | 8 + scripts/consolidated_profiles.py | 51 ++- scripts/generate_picker_controls.py | 5 + scripts/optional_display_profiles.py | 3 + src/helpers/CompanionRtcQueue.h | 7 +- src/helpers/ui/DisplayPowerSettings.cpp | 5 +- src/helpers/ui/DisplayPowerSettings.h | 3 +- src/helpers/ui/WioL1Display.cpp | 68 ++++ src/helpers/ui/WioL1Display.h | 52 +++ src/helpers/ui/WioL1DisplayLayout.h | 12 + src/helpers/ui/WioL1DisplayProbe.h | 155 ++++++++ test/fixtures/cli_reply_text.json | 2 +- test/test_build_profiles.sh | 54 +-- test/test_cardputer_board_policy.py | 82 ++++ test/test_cardputer_runtime_memory.py | 21 + test/test_combined_board_picker.js | 59 +++ test/test_combined_board_profiles.py | 153 ++++++++ test/test_companion_rtc_queue.py | 6 +- test/test_companion_shared_ota_capacity.py | 41 +- test/test_display_power.py | 18 + test/test_firmware_ram.py | 14 + test/test_headless_kiss_builds.py | 145 +++++++ test/test_indicator_exclusive_transport.py | 6 +- test/test_picker_controls_generator.py | 7 + test/test_shared_mota_queue.py | 30 +- test/test_wio_l1_display_detection.py | 361 ++++++++++++++++++ test/test_wio_tracker_identity.py | 9 +- tools/mota/test_mota.py | 1 + variants/heltec_v4_r8/HeadlessKissPins.h | 17 + variants/heltec_v4_r8/HeltecV4R8Board.cpp | 16 +- variants/heltec_v4_r8/platformio.ini | 4 + .../ui-cardputer/CardputerUITask.cpp | 14 + .../ui-cardputer/CardputerUITask.h | 3 + .../ui-cardputer/screen/SettingsScreen.cpp | 13 + .../ui-cardputer/screen/SettingsScreen.h | 3 + .../hw/CardputerLayout.cpp | 14 +- .../hw/CardputerLayout.h | 2 +- variants/m5stack_cardputer_adv/platformio.ini | 18 + variants/wio-tracker-l1-eink/platformio.ini | 2 + variants/wio-tracker-l1/HeadlessKissPins.h | 12 + variants/wio-tracker-l1/WioTrackerL1Board.cpp | 11 + variants/wio-tracker-l1/WioTrackerL1Board.h | 4 +- variants/wio-tracker-l1/platformio.ini | 19 +- variants/wio-tracker-l1/target.cpp | 5 + variants/wio-tracker-l1/target.h | 4 +- 53 files changed, 1598 insertions(+), 88 deletions(-) create mode 100644 src/helpers/ui/WioL1Display.cpp create mode 100644 src/helpers/ui/WioL1Display.h create mode 100644 src/helpers/ui/WioL1DisplayLayout.h create mode 100644 src/helpers/ui/WioL1DisplayProbe.h create mode 100644 test/test_combined_board_picker.js create mode 100644 test/test_combined_board_profiles.py create mode 100644 test/test_headless_kiss_builds.py create mode 100644 test/test_wio_l1_display_detection.py create mode 100644 variants/heltec_v4_r8/HeadlessKissPins.h create mode 100644 variants/wio-tracker-l1/HeadlessKissPins.h diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index 675a67be2..93f4bfeb9 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -221,6 +221,8 @@ jobs: -e M5Stack_Cardputer_Adv_companion_radio_usb -e M5Stack_Cardputer_Adv_repeater -e M5Stack_Cardputer_Adv_room_server -e LilyGo_TDeck_companion_radio_ble + - name: Qualify combined Cardputer-Adv Full Companion + run: bash build.sh build-firmware M5Stack_Cardputer_Adv_companion_radio_full --firmware-version v1.18.0.2-halo-keymind-cascade-dev --radio-preset usa-cascade-fixed --profile cascade universal-lora-smoke: runs-on: ubuntu-latest @@ -262,6 +264,27 @@ jobs: - name: Qualify Muzi Full Companion run: bash build.sh build-firmware muzi_base_companion_radio_full --firmware-version v1.18.0.2-halo-keymind-cascade-dev --radio-preset usa-cascade-fixed --profile cascade + headless-kiss-smoke: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Setup Build Environment + uses: ./.github/actions/setup-build-environment + - name: Verify optional-display KISS wiring and consolidation evidence + run: | + python3 -B test/test_headless_kiss_builds.py -v + python3 -B test/test_wio_l1_display_detection.py -v + python3 -B test/test_display_power.py -v + python3 -B test/test_combined_board_profiles.py -v + node test/test_combined_board_picker.js + - name: Compile shared and historical KISS recipes + # One process: PlatformIO shares the checkout's build tree. + run: >- + pio run -e heltec_v4_r8_kiss_modem -e heltec_v4_r8_tft_kiss_modem + -e WioTrackerL1_kiss_modem -e WioTrackerL1Eink_kiss_modem + - name: Qualify Wio L1 Full Companion with runtime OLED, e-paper or headless selection + run: bash build.sh build-firmware WioTrackerL1_companion_radio_full --firmware-version v1.18.0.2-halo-keymind-cascade-dev --radio-preset usa-cascade-fixed --profile cascade + management-reports: runs-on: ubuntu-latest steps: diff --git a/build_legacy.sh b/build_legacy.sh index 60ba1c647..77190d963 100755 --- a/build_legacy.sh +++ b/build_legacy.sh @@ -482,6 +482,11 @@ for section, options in data: PIO_ENV_FULL_BUILD_BY_NAME["$full_env"]=0 PIO_ENV_FULL_WIFI_OTA_BY_NAME["$full_env"]=0 PIO_ENV_BUILD_BASE_BY_NAME["$full_env"]="$env_name" + if [ "$full_env" = WioTrackerL1_companion_radio_full ]; then + # The BLE recipe owns runtime OLED/e-paper selection. Keep the old + # USB-only OLED recipe exact for explicit compatibility builds. + PIO_ENV_BUILD_BASE_BY_NAME["$full_env"]="$ble_env" + fi done # Some qualified boards historically published only BLE, or BLE plus USB, @@ -492,6 +497,7 @@ for section, options in data: # remain explicit-build aliases and canonical releases use the Full target. local -a qualified_esp32_full_companion_bases=( M5Stack_Unit_C6L_companion_radio_ble + M5Stack_Cardputer_Adv_companion_radio_ble Heltec_Wireless_Tracker_companion_radio_ble LilyGo_T3S3_sx1276_companion_radio_ble LilyGo_Tlora_C6_companion_radio_ble_ @@ -4593,6 +4599,15 @@ apply_companion_radio_full_profile() { # measured-safe tables for FULL OTA without changing ordinary USB/BLE/WiFi # companion builds. case "${env_name,,}" in + m5stack_cardputer_adv_companion_radio_full) + # Keep the complete keyboard UI and 350/40/256 capacities. USB is + # independent; the saved WiFi/BLE selection applies on the next boot. + # Only CPU-owned retained frames use RTC; the borrowed OTA context + # stays in ordinary DRAM and keeps 128 receive frames while active. + export PLATFORMIO_BUILD_FLAGS="${PLATFORMIO_BUILD_FLAGS} -DCOMPANION_EXCLUSIVE_WIFI_BLE=1 -DOTA_SHARED_COMPANION_QUEUE=1 -DCOMPANION_RTC_FAST_OFFLINE_FRAMES=32 -DCOMPANION_RTC_SLOW_OFFLINE_FRAMES=32" + record_build_reduction \ + "Cardputer Full: USB plus selected WiFi or Bluetooth; 256 offline frames normally, 128 during mOTA" + ;; heltec_wireless_paper_companion_radio_full) # The cold mOTA workspace fits in half the offline queue. Keep the # board's 350 contacts and all simultaneous Full transports. @@ -6159,6 +6174,7 @@ get_consolidated_hardware_replacement() { picow_companion_radio_usb|picow_companion_radio_ble|picow_companion_radio_wifi|\ heltec_v4_tft_*|heltec_v4_3_tft_*|heltec_v4_expansionkit_*|heltec_v4_3_expansionkit_*|\ heltec_v4_r8_tft_portrait_repeater_observer_mqtt|heltec_v4_r8_tft_portrait_room_server_observer_mqtt|\ + heltec_v4_r8_tft_kiss_modem|wiotrackerl1eink_kiss_modem|wiotrackerl1eink_companion_radio_*|\ muzi_base_duo_superio_companion_radio_*|muzi_base_uno_superio_companion_radio_*|lilygo_t-echo-lite_non_shell_companion_radio_*|\ heltec_rc32_without_display_*) ;; *) return 1 ;; diff --git a/docs/_javascript/firmware_picker.js b/docs/_javascript/firmware_picker.js index 272b516a8..87e8fbc79 100644 --- a/docs/_javascript/firmware_picker.js +++ b/docs/_javascript/firmware_picker.js @@ -828,6 +828,12 @@ profile.controls.display === true && releaseBoundControl(profile, "unifiedV4Display")); } + function unifiedWioL1DisplayProfile(profile) { + return Boolean(profile && profile.hardware === "WioTrackerL1" && isFullCompanion(profile) && + profile.controls && profile.controls.platform === "NRF52_PLATFORM" && + optionalAutoDisplayProfile(profile) && releaseBoundControl(profile, "unifiedWioL1Display")); + } + const RADIO_DETECT_FAMILIES = [ ["Tbeam", ["Tbeam_SX1262", "Tbeam_SX1276"]], ["LilyGo_T3S3", ["LilyGo_T3S3_sx1262", "LilyGo_T3S3_sx1276"]], @@ -847,6 +853,17 @@ function consolidationSuccessor(profile, candidate) { if (!profile || !candidate || profile.role !== candidate.role) return false; + if (/^WioTrackerL1Eink_companion_radio_(?:ble|full)$/.test(profile.target) && profile.role === "companion") { + return unifiedWioL1DisplayProfile(candidate); + } + if (profile.target === "heltec_v4_r8_tft_kiss_modem") { + return candidate.target === "heltec_v4_r8_kiss_modem" && candidate.controls && + candidate.controls.platform === "ESP32_PLATFORM" && releaseBoundControl(candidate, "headlessR8Kiss"); + } + if (profile.target === "WioTrackerL1Eink_kiss_modem") { + return candidate.target === "WioTrackerL1_kiss_modem" && candidate.controls && + candidate.controls.platform === "NRF52_PLATFORM" && releaseBoundControl(candidate, "headlessWioL1Kiss"); + } const radioFamily = RADIO_DETECT_FAMILIES.find(function (family) { return family[1].includes(profile.hardware); }); @@ -908,8 +925,9 @@ : /^heltec_(?:wireless_tracker|tracker_v1_1)_/.test(target) ? "st7735" : /^heltec_v4_r8_tft_/.test(target) ? "st7789" : /^muzi_base_(?:(?:uno|duo)_(?:superio_)?)?companion_radio_/.test(target) ? "sh1107" - : /^lilygo_t-echo-lite_(?:non_shell_)?(?:companion_radio_|repeater|room_server)/.test(target) ? "ssd1681" : ""; - const platform = controller === "sh1107" || controller === "ssd1681" ? "NRF52_PLATFORM" : "ESP32_PLATFORM"; + : /^lilygo_t-echo-lite_(?:non_shell_)?(?:companion_radio_|repeater|room_server)/.test(target) ? "ssd1681" + : /^wiotrackerl1_companion_radio_(?:ble|full)$/.test(target) ? "sh1106_ssd1680" : ""; + const platform = ["sh1107", "ssd1681", "sh1106_ssd1680"].includes(controller) ? "NRF52_PLATFORM" : "ESP32_PLATFORM"; return Boolean(controller && controls && controls.platform === platform && controls.display === true && controls.displayAuto === true && controls.displayController === controller && /^[0-9a-f]{40}$/.test(controls.displaySource || "") && @@ -1783,6 +1801,10 @@ ["muzi_base_uno_superIO", "muzi_base_uno", function (profile) { return isFullCompanion(profile) && optionalAutoDisplayProfile(profile); }], ["muzi_base_duo_superIO", "muzi_base_duo", function (profile) { return isFullCompanion(profile) && optionalAutoDisplayProfile(profile); }], ["LilyGo_T-Echo-Lite_non_shell", "LilyGo_T-Echo-Lite", function (profile) { return isFullCompanion(profile) && optionalAutoDisplayProfile(profile); }], + ["WioTrackerL1Eink", "WioTrackerL1", function (profile) { return unifiedWioL1DisplayProfile(profile) || + profile.target === "WioTrackerL1_kiss_modem" && releaseBoundControl(profile, "headlessWioL1Kiss"); }], + ["heltec_v4_r8_tft", "heltec_v4_r8", function (profile) { return profile.target === "heltec_v4_r8_kiss_modem" && + releaseBoundControl(profile, "headlessR8Kiss"); }], ].forEach(function (alias) { if (!profiles.some(function (profile) { return profile.hardware === alias[0] && (!requested.role || profile.role === requested.role); }) && profiles.some(function (profile) { return profile.hardware === alias[1] && alias[2](profile) && (!requested.role || profile.role === requested.role); })) { @@ -2072,6 +2094,13 @@ notes.push("One ordinary Heltec V4 image supports V4.2/V4.3 with OLED, the stock touch TFT kit, or no screen. The saved display.type setting selects auto, oled, tft or off; board reports the detected revision and active display. R8 uses its separate hardware image."); notes.push("When changing from a legacy TFT or expansion-kit target, use a local USB installation or a supported WiFi application update. Their LoRa OTA target IDs remain distinct; never force a different target's LoRa OTA package."); } + if (unifiedWioL1DisplayProfile(profile)) { + notes.push("One Wio L1 Full Companion supports the OLED, e-paper and headless models, including Lite and Pro. The screen is detected at startup; the 1W model uses separate firmware."); + notes.push("E-paper keeps its screen visible for 15 minutes by default. Saved display timeout settings take precedence. Older e-paper LoRa OTA identities remain distinct; use USB to change to this combined target."); + } + if (releaseBoundControl(profile, "headlessR8Kiss") || releaseBoundControl(profile, "headlessWioL1Kiss")) { + notes.push("This KISS modem supports either display version of this board and leaves the display off. Historical display-specific OTA identities remain separate."); + } if (releaseBoundControl(profile, "oledExpansionCompatible")) { notes.push("The same OLED firmware supports the base Heltec V4 and its OLED expansion kit. Existing expansion-kit OTA identities still require their exact historical update or a local USB migration."); } @@ -2422,7 +2451,7 @@ ? "This Full image shares one 2.4 GHz radio between WiFi/MQTT and ESP-NOW. When both are enabled, ESP-NOW begins after WiFi connects and only when bridge.channel matches that access point's fixed channel. ESP-NOW-only mode does not need WiFi credentials. Primary ESP-NOW mesh uses set espnow.channel instead." : "Set bridge.channel to the bridge channel. Primary ESP-NOW mesh uses set espnow.channel instead; see the board guide before changing channels."); } - if (full && /sensecapindicator/i.test(profile.target)) section("Indicator wireless transport", [ + if (full && (/sensecapindicator/i.test(profile.target) || profile.target === "M5Stack_Cardputer_Adv_companion_radio_full")) section("Wireless transport", [ { label: "WiFi", commands: ["set companion.transport wifi", "reboot"] }, { label: "Bluetooth", commands: ["set companion.transport ble", "reboot"] }, { label: "Check", commands: ["get companion.transport"] }, diff --git a/docs/full_companion_features.md b/docs/full_companion_features.md index c60a0bc93..da098ba33 100644 --- a/docs/full_companion_features.md +++ b/docs/full_companion_features.md @@ -18,6 +18,24 @@ New **Heltec T114** Full images automatically detect the optional screen and also run without it. Choose **Heltec T114** in the firmware picker for either board configuration; see the [T114 update guide](heltec_t114.md). +**Wio Tracker L1** Full uses one image for the OLED, E-Ink, Lite and Pro +models. It detects the screen at startup and also runs headless. The E-Ink +idle timeout defaults to 15 minutes; saved display settings take precedence. +The 1W model keeps its separate image. Use USB to move from an older +`WioTrackerL1Eink` image to `WioTrackerL1_companion_radio_full`; historical +LoRa OTA target identities remain separate. + +**Cardputer-Adv** Full combines USB and Bluetooth with the keyboard UI in +`M5Stack_Cardputer_Adv_companion_radio_full`. USB remains available when +Bluetooth is disabled. WiFi and Bluetooth run one at a time; select the +wireless transport and reboot as shown below. Full retains 350 contacts, +40 channels and 256 queued messages. During an mOTA session, 128 queue +slots remain available while the other half holds its workspace. Sync +pending messages before starting if more than 128 are queued. The older +USB and BLE build names remain available for explicit compatibility builds. +When moving from the older BLE image, forget the previous phone pairing and +pair again if necessary; Full uses NimBLE and keeps the saved PIN. + The Wireless Paper Full memory-sharing update keeps 350 contacts and 256 queued messages normally. During an mOTA session, 128 message slots are available while the other half holds its workspace. If more than 128 unread @@ -118,7 +136,8 @@ Reboot does not undo a saved-off preference. See [Bluetooth controls](cli_commands.md#turn-bluetooth-on-or-off-companion) for reply draining, connection checks, and unavailable boot modes. -SenseCAP Indicator selects one secondary wireless transport per boot: +SenseCAP Indicator and Cardputer-Adv Full select one secondary wireless +transport per boot: ```text set companion.transport wifi diff --git a/docs/kiss_modem_protocol.md b/docs/kiss_modem_protocol.md index b91b619f9..79d658eb7 100644 --- a/docs/kiss_modem_protocol.md +++ b/docs/kiss_modem_protocol.md @@ -6,6 +6,12 @@ Standard KISS TNC firmware for MeshCore LoRa radios. Compatible with any KISS client (Direwolf, APRSdroid, YAAC, etc.) for sending and receiving raw packets. MeshCore-specific extensions (cryptography, radio configuration, telemetry) are available through the standard SetHardware (0x06) command. +Use `heltec_v4_r8_kiss_modem` for either the plain or TFT Heltec V4 R8, +and `WioTrackerL1_kiss_modem` for either OLED or E-Ink Wio L1. These modem +images leave the displays off. The historical TFT/E-Ink build names and +their OTA target identities remain available for explicit updates; they +are omitted from ordinary build lists when the shared recipe is present. + KISS protocol version 2 adds an opt-in logical port 1 for MeshCore's second time-shared LoRa profile. It is still one physical transceiver: the firmware fast-switches between `radio` and `radio2`; it does not create a second radio. diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 6ff461e7a..0e541d975 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -3185,6 +3185,9 @@ void MyMesh::begin(bool has_display, bool radio_available, false, #endif true // All Companion transports show startup unless the saved mode forbids it. +#if defined(WIO_L1_DISPLAY_UNIFIED) && WIO_L1_DISPLAY_UNIFIED + , display.defaultTimeoutSeconds() +#endif ); #ifdef MESHCORE_HAS_REAL_DISPLAY // A saved identity makes later boots ordinary even if display preferences diff --git a/examples/companion_radio/main.cpp b/examples/companion_radio/main.cpp index 01c6545b7..9c6d1efe1 100644 --- a/examples/companion_radio/main.cpp +++ b/examples/companion_radio/main.cpp @@ -3823,6 +3823,9 @@ void setup() { #endif #if defined(OLED_AUTO_DETECT) && OLED_AUTO_DETECT mesh::ui::loadOLEDControllerSettings(regulator_prefs_fs); +#endif +#if defined(WIO_L1_DISPLAY_UNIFIED) && WIO_L1_DISPLAY_UNIFIED + display.detect(); #endif mesh::ui::loadDisplayPowerSettings(display_prefs_fs, #ifdef BLE_PIN_CODE @@ -3830,7 +3833,11 @@ void setup() { #else false, #endif - true); + true +#if defined(WIO_L1_DISPLAY_UNIFIED) && WIO_L1_DISPLAY_UNIFIED + , display.defaultTimeoutSeconds() +#endif + ); mesh::ui::StartupScreen startup_screen; #endif diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index f941025df..d8ff27f35 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -122,6 +122,9 @@ static mesh::ui::RadioProfileSystemStatus radioProfileSystemStatus( #ifndef UI_RECENT_LIST_SIZE #define UI_RECENT_LIST_SIZE 4 #endif +#if defined(WIO_L1_DISPLAY_UNIFIED) && WIO_L1_DISPLAY_UNIFIED +#include +#endif #ifdef HAS_TOUCH #define PRESS_LABEL "tap center" @@ -644,6 +647,9 @@ class HomeScreen : public UIScreen { } bool isPageVisible(uint8_t page) const { +#if defined(WIO_L1_DISPLAY_UNIFIED) && WIO_L1_DISPLAY_UNIFIED && !defined(UI_NO_HIBERNATE) + if (page == HomePage::SHUTDOWN && !display.isEink()) return false; +#endif return the_mesh.isDualRadioActive() || (page != HomePage::RADIO2 && page != HomePage::RADIO_STATUS); } @@ -1529,7 +1535,12 @@ public: r.skipData(type); } - for (int i = 0; i < (sensors_scroll?UI_RECENT_LIST_SIZE:sensors_nb); i++) { + int sensor_rows = sensors_scroll ? UI_RECENT_LIST_SIZE : sensors_nb; +#if defined(WIO_L1_DISPLAY_UNIFIED) && WIO_L1_DISPLAY_UNIFIED + sensor_rows = mesh::ui::wio_l1::visibleRows(display.height(), y, normal_line, + row_height > 12 ? row_height : 12, sensor_rows); +#endif + for (int i = 0; i < sensor_rows; i++) { uint8_t channel, type; if (!r.readHeader(channel, type)) { // reached end, reset r.reset(); @@ -1586,7 +1597,7 @@ public: display.print(buf); y += row_height > 12 ? row_height : 12; } - if (sensors_scroll) sensors_scroll_offset = (sensors_scroll_offset+1)%sensors_nb; + if (sensors_nb > sensor_rows) sensors_scroll_offset = (sensors_scroll_offset+1)%sensors_nb; else sensors_scroll_offset = 0; #endif #if UI_WIFI_SETUP_HOME_PAGE == 1 @@ -1598,7 +1609,14 @@ public: int count = the_mesh.getDiscoveredNodes(discovered, UI_RECENT_LIST_SIZE); display.setColor(UIColor::primary_txt); int y = 20; - for (int i = 0; i < count; i++, y += 11) { + int discovery_spacing = 11; +#if defined(WIO_L1_DISPLAY_UNIFIED) && WIO_L1_DISPLAY_UNIFIED + y = body_top; + discovery_spacing = row_height; + count = mesh::ui::wio_l1::visibleRows(display.height(), y, normal_line, + discovery_spacing, count); +#endif + for (int i = 0; i < count; i++, y += discovery_spacing) { char name[32]; auto a = &discovered[i]; if ((a->name[0] == 0) || !discovery_disp_names) { @@ -1624,6 +1642,10 @@ public: return 1000; // more frequent updates just after req } else if (count < UI_RECENT_LIST_SIZE -1) { // show only 5 sec after last disc y = 10 + 11 * UI_RECENT_LIST_SIZE; +#if defined(WIO_L1_DISPLAY_UNIFIED) && WIO_L1_DISPLAY_UNIFIED + y = body_top + count * discovery_spacing; + if (y + normal_line > display.height()) return 5000; +#endif display.drawTextCentered(display.width() / 2, y, "discover: " PRESS_LABEL); } #endif @@ -1977,6 +1999,9 @@ class MsgPreviewScreen : public UIScreen { void renderChannelFilter(DisplayDriver& display) const { #if UI_MESSAGE_CHANNEL_FOOTER == 1 +#if defined(WIO_L1_DISPLAY_UNIFIED) && WIO_L1_DISPLAY_UNIFIED + if (!display.isEink()) return; +#endif display.setTextSize(1); const mesh::ui::CompanionMessageChromeLayout layout = mesh::ui::makeCompanionMessageChromeLayout( @@ -2148,6 +2173,9 @@ public: native_large ? display.textLineHeight() : 0); int body_bottom = UI_MESSAGE_CHANNEL_FOOTER == 1 ? display.height() - layout.filter_height : display.height(); +#if defined(WIO_L1_DISPLAY_UNIFIED) && WIO_L1_DISPLAY_UNIFIED + if (!display.isEink()) body_bottom = display.height(); +#endif display.setCompactText(layout.compact_text); int header_text_y = 0; #if UI_BUTTON_READER_HINT == 1 diff --git a/scripts/check_firmware_ram.py b/scripts/check_firmware_ram.py index d0eebde16..580a5c0e1 100644 --- a/scripts/check_firmware_ram.py +++ b/scripts/check_firmware_ram.py @@ -176,6 +176,14 @@ def requirements(platform, defines, target): depth = integer(defines, "UI_BUFFER_COLOR_DEPTH", 8) pixels = 480 * 480 if "INDICATOR_TRANSPORT_RENDER_PROFILE" in defines else 320 * 320 display_heap = (pixels * depth + 7) // 8 + 1024 + elif display == "WioL1Display": + if platform != "NRF52_PLATFORM" or not integer(defines, "WIO_L1_DISPLAY_UNIFIED", 0): + raise ValueError("unified Wio L1 display budget requires nRF52 and its unified recipe") + # The two drivers share one static placement buffer, counted in the + # linked ELF. Only the selected OLED allocates a framebuffer/adapter; + # e-paper pixel storage lives inside that same static placement buffer. + # Retain the existing nRF52 OLED allowance, even on headless boots. + display_heap = DISPLAY_HEAP["SH1106Display"] elif display == "WioTrackerL2Display": if platform != "ESP32_PLATFORM": raise ValueError("Wio Tracker L2 display budget requires ESP32") diff --git a/scripts/consolidated_profiles.py b/scripts/consolidated_profiles.py index 13596ce59..1c71e8661 100644 --- a/scripts/consolidated_profiles.py +++ b/scripts/consolidated_profiles.py @@ -6,6 +6,23 @@ import sys from optional_display_profiles import resolved_defines, spec_for_defines SPECS = ( + dict(key="unifiedWioL1Display", capability="display.wio_l1.unified", + marker="meshcore.capability.display.wio_l1_unified.v1", + platform="NRF52_PLATFORM", board="seeed-wio-tracker-l1", + target=r"WioTrackerL1_companion_radio_(?:ble|full)", + defines={"WIO_TRACKER_L1": "1", "WIO_L1_DISPLAY_UNIFIED": "1", + "DISPLAY_CLASS": "WioL1Display", "MAX_CONTACTS": "350", + "MAX_GROUP_CHANNELS": "40", "OFFLINE_QUEUE_SIZE": "256"}), + *[dict(key=key, capability="modem.headless." + family, + marker="meshcore.capability.modem.headless." + family + ".v1", + platform=platform, board=board, target=target, + defines={guard: "1", "MESHCORE_HEADLESS_KISS_COMPAT": "1"}) + for key, family, platform, board, target, guard in ( + ("headlessR8Kiss", "heltec_r8", "ESP32_PLATFORM", "heltec_v4_r8", + r"heltec_v4_r8_kiss_modem", "HELTEC_V4_R8"), + ("headlessWioL1Kiss", "wio_l1", "NRF52_PLATFORM", "seeed-wio-tracker-l1", + r"WioTrackerL1_kiss_modem", "WIO_TRACKER_L1"), + )], dict(key="autoLoRaRadio", capability="radio.autodetect.sx126x_sx127x", marker="meshcore.capability.radio.autodetect.sx126x_sx127x.v1", platform="ESP32_PLATFORM", board="ttgo-t-beam", @@ -115,7 +132,9 @@ def specs_for_config(target, env): return [spec for spec in SPECS if env.get("board") == spec["board"] and re.fullmatch(spec["target"], target, re.I) and all(defines.get(key) == value for key, value in spec["defines"].items()) - and (spec["key"] != "autoLoRaRadio" or radio_capacity_matches(target, defines))] + and (spec["key"] != "autoLoRaRadio" or radio_capacity_matches(target, defines)) + and (spec["key"] not in ("headlessR8Kiss", "headlessWioL1Kiss") + or "DISPLAY_CLASS" not in defines)] def proven(manifest, spec): @@ -196,6 +215,12 @@ def radio_replacements(target): def replacements(target): """Known functionality successors, never aliases for deployed update IDs.""" + if re.fullmatch(r"WioTrackerL1Eink_companion_radio_(?:ble|full)", target, re.I): + return [("WioTrackerL1_companion_radio_full", "unifiedWioL1Display")] + if target.lower() == "heltec_v4_r8_tft_kiss_modem": + return [("heltec_v4_r8_kiss_modem", "headlessR8Kiss")] + if target.lower() == "wiotrackerl1eink_kiss_modem": + return [("WioTrackerL1_kiss_modem", "headlessWioL1Kiss")] radio = radio_replacements(target) if radio: return radio @@ -232,7 +257,7 @@ def configuration_replacement(target, envs): return None for candidate, key in replacements(target): radio = key in ("autoLoRaRadio", "muziRadioAccessories") - env = environment(candidate, radio) + env = environment(candidate, radio or key == "unifiedWioL1Display") source_env = environment(target, radio) if not env or not source_env or env.get("board") != source_env.get("board"): continue @@ -268,10 +293,13 @@ def configuration_replacement(target, envs): def omit_redundant_records(records): """Remove resumed alternatives only with an actual qualified successor.""" + def same_sensors(left, right): + return (sorted(x for x in left.get("capabilities", []) if x.startswith("sensor.profile.")) + == sorted(x for x in right.get("capabilities", []) if x.startswith("sensor.profile."))) + def same_policy(left, right): return (left.get("build_profile") == right.get("build_profile") - and sorted(x for x in left.get("capabilities", []) if x.startswith("sensor.profile.")) - == sorted(x for x in right.get("capabilities", []) if x.startswith("sensor.profile."))) + and same_sensors(left, right)) def qualified(manifest, key): if key == "muziRadioAccessories": @@ -296,10 +324,14 @@ def omit_redundant_records(records): for row in manifest.get("verification", []))) return any(s["key"] == key and proven(manifest, s) for s in SPECS) - def radio_successor(left, right): + def radio_successor(left, right, key): source = left.get("source_commit") + wio_full = (key == "unifiedWioL1Display" and right.get("build_profile") == "full" + and left.get("target", "").lower().endswith(("_usb", "_ble")) + and same_sensors(left, right)) if (not isinstance(source, str) or not re.fullmatch(r"[0-9a-f]{40}", source) - or right.get("source_commit") != source or not same_policy(left, right)): + or right.get("source_commit") != source + or not (same_policy(left, right) or wio_full)): return False # Full already requires its full capacity recipe before advertising # this packaged marker. An old transport/terminal image may retire @@ -318,10 +350,11 @@ def omit_redundant_records(records): manifest = record["manifest"] alternatives = replacements(manifest["target"]) if not any(candidate["manifest"]["target"].lower() == target.lower() - and (key not in ("autoLoRaRadio", "muziRadioAccessories") - or radio_successor(manifest, candidate["manifest"])) + and (key not in ("autoLoRaRadio", "muziRadioAccessories", "unifiedWioL1Display", + "headlessR8Kiss", "headlessWioL1Kiss") + or radio_successor(manifest, candidate["manifest"], key)) and (same_policy(manifest, candidate["manifest"]) - or key in ("muziAccessories", "techoLiteDisplay") + or key in ("muziAccessories", "techoLiteDisplay", "unifiedWioL1Display") and candidate["manifest"].get("build_profile") == "full" and manifest["target"].lower().endswith(("_usb", "_ble"))) and qualified(candidate["manifest"], key) diff --git a/scripts/generate_picker_controls.py b/scripts/generate_picker_controls.py index e8c961ce4..417c8efca 100644 --- a/scripts/generate_picker_controls.py +++ b/scripts/generate_picker_controls.py @@ -40,6 +40,11 @@ def partition_migrations(): def capacity_note(reductions, defines): notes = [] for reduction in reductions: + if reduction == 'Cardputer Full: USB plus selected WiFi or Bluetooth; 256 offline frames normally, 128 during mOTA': + notes.append('USB remains available. Select WiFi or Bluetooth with set companion.transport wifi|ble, then reboot. ' + 'Full retains 350 contacts, 40 channels and 256 queued messages; 128 queue slots remain while mOTA borrows storage. ' + 'Sync unread messages before starting mOTA if more than 128 are pending.') + continue if reduction == 'display.canvas limited to 320x320 internal pixels, scaled to the 480x480 panel; 350 contacts, 512 queued frames and all Full transports retained': notes.append('Display renders at 320x320 and scales to the 480x480 panel to preserve internal RAM. All Full transports, 350 contacts, 512 queued messages and full-length message previews are retained.') continue diff --git a/scripts/optional_display_profiles.py b/scripts/optional_display_profiles.py index 254f0f77d..c6d1e9966 100644 --- a/scripts/optional_display_profiles.py +++ b/scripts/optional_display_profiles.py @@ -38,6 +38,9 @@ SPECS = ( driver="GxEPDDisplay", controller="ssd1681", marker="techo_lite", platform="NRF52_PLATFORM", defines={"EINK_DISPLAY_MODEL": "GxEPD2_122_T61"}, targets=r"lilygo_t-echo-lite_(?:non_shell_)?(?:companion_radio_(?:usb|ble|full)|(?:repeater|room_server)(?:_lora_ota(?:_no_external_sensors)?)?)"), + dict(flag="WIO_L1_DISPLAY_UNIFIED", board="seeed-wio-tracker-l1", guard="WIO_TRACKER_L1", + driver="WioL1Display", controller="sh1106_ssd1680", marker="wio_l1", platform="NRF52_PLATFORM", + targets=r"wiotrackerl1_companion_radio_(?:ble|full)"), ) diff --git a/src/helpers/CompanionRtcQueue.h b/src/helpers/CompanionRtcQueue.h index 692fe75ce..2cb44030a 100644 --- a/src/helpers/CompanionRtcQueue.h +++ b/src/helpers/CompanionRtcQueue.h @@ -35,10 +35,9 @@ #if COMPANION_RTC_SLOW_OFFLINE_FRAMES && !defined(CONFIG_IDF_TARGET_ESP32S3) #error "The separate RTC SLOW offline-frame bank requires ESP32-S3" #endif -#if defined(OTA_SHARED_COMPANION_QUEUE) \ - && (!defined(CONFIG_IDF_TARGET_ESP32C3) || COMPANION_RTC_SLOW_OFFLINE_FRAMES) -#error "RTC retained shared-OTA frames are qualified only on ESP32-C3 FAST memory" -#endif +// Shared OTA retains only CPU-owned message frames in these same banks. +// BorrowableFrameBuffer keeps its context/workspace in ordinary DRAM on +// both qualified chips; it never hands an RTC address to an SDK/DMA owner. namespace mesh { namespace companion_rtc { diff --git a/src/helpers/ui/DisplayPowerSettings.cpp b/src/helpers/ui/DisplayPowerSettings.cpp index bbdac4a4f..a1240e899 100644 --- a/src/helpers/ui/DisplayPowerSettings.cpp +++ b/src/helpers/ui/DisplayPowerSettings.cpp @@ -95,7 +95,8 @@ bool savePrefs(const DisplayPowerPrefs& prefs) { #endif // MESHCORE_HAS_REAL_DISPLAY } -bool loadDisplayPowerSettings(FILESYSTEM* fs, bool pairing_supported, bool wake_on_boot) { +bool loadDisplayPowerSettings(FILESYSTEM* fs, bool pairing_supported, bool wake_on_boot, + uint16_t default_timeout) { loadDisplayNightSettings(fs); #ifdef MESHCORE_HAS_REAL_DISPLAY settings_fs = fs; @@ -104,6 +105,8 @@ bool loadDisplayPowerSettings(FILESYSTEM* fs, bool pairing_supported, bool wake_ #endif pairing_available = pairing_supported; DisplayPowerPrefs prefs; + if (default_timeout >= 1 && default_timeout <= 3600) + prefs.battery.seconds = prefs.usb.seconds = default_timeout; prefs.wake_on_boot = wake_on_boot; if (pairing_supported) prefs.battery.mode = prefs.usb.mode = DisplayMode::ButtonPairing; #if CARDPUTER_ADV_UI diff --git a/src/helpers/ui/DisplayPowerSettings.h b/src/helpers/ui/DisplayPowerSettings.h index c87198853..8020b8396 100644 --- a/src/helpers/ui/DisplayPowerSettings.h +++ b/src/helpers/ui/DisplayPowerSettings.h @@ -12,8 +12,9 @@ namespace mesh { namespace ui { // Roles arm first_install_boot after a missing identity is saved successfully; // the initial setup window is runtime-only and is never written to this file. // Headless builds initialize runtime defaults and never access this file. +// A detected display can override the fresh-install timeout; saved profiles win. bool loadDisplayPowerSettings(FILESYSTEM* fs, bool pairing_supported, - bool wake_on_boot = false); + bool wake_on_boot = false, uint16_t default_timeout = 0); bool handleDisplayPowerCommand(const char* command, char* reply, size_t reply_size, bool inbox_supported = false); bool displayPairingSupported(); diff --git a/src/helpers/ui/WioL1Display.cpp b/src/helpers/ui/WioL1Display.cpp new file mode 100644 index 000000000..7ac00584b --- /dev/null +++ b/src/helpers/ui/WioL1Display.cpp @@ -0,0 +1,68 @@ +#include "WioL1Display.h" +#include "WioL1DisplayProbe.h" +#include + +extern const char meshcore_capability_display_wio_l1_unified[]; +const char meshcore_capability_display_wio_l1_unified[] = + "meshcore.capability.display.wio_l1_unified.v1"; +extern const char meshcore_capability_display_autodetect_wio_l1[]; +const char meshcore_capability_display_autodetect_wio_l1[] = + "meshcore.capability.display.autodetect.wio_l1.v1"; + +WioL1Display::~WioL1Display() { + if (_constructed == Type::Oled) _storage.oled.~SH1106Display(); + else if (_constructed == Type::Eink) _storage.eink.~GxEPDDisplay(); +} + +WioL1Display::Type WioL1Display::detect() { + if (_detected) return _type; + _detected = true; + const volatile char* capability = meshcore_capability_display_autodetect_wio_l1; + (void)*capability; + const uint8_t addresses[] = {DISPLAY_ADDRESS, DISPLAY_ADDRESS ^ 1}; + Wire.end(); + for (uint8_t address : addresses) { + if (mesh::ui::wio_l1::probeOled(address)) { + _type = Type::Oled; + Wire.begin(); + return _type; + } + } + Wire.begin(); + if (mesh::ui::wio_l1::probeEink()) _type = Type::Eink; + return _type; +} + +bool WioL1Display::begin() { + if (_started) return _active != nullptr; + _started = true; + const volatile char* capability = meshcore_capability_display_wio_l1_unified; + (void)*capability; + detect(); + bool ready = false; + if (_type == Type::Oled) { + _constructed = Type::Oled; + auto* oled = new (&_storage.oled) SH1106Display(); + ready = oled->begin(); + if (ready) _active = oled; + } else if (_type == Type::Eink) { + _constructed = Type::Eink; + auto* eink = new (&_storage.eink) GxEPDDisplay(); + ready = eink->begin(); + if (ready) { + _active = eink; + eink->forceFullRefresh(); + } + } + if (!ready) { + _type = Type::None; + return false; + } + const ColorVal background = isEink() ? 0xFFFF : 0; + const ColorVal foreground = isEink() ? 0 : 1; + UIColor::window_bkg = UIColor::title_bkg = UIColor::popup_bkg = background; + UIColor::title_txt = UIColor::primary_txt = UIColor::secondary_txt = + UIColor::warning_txt = UIColor::popup_txt = UIColor::corp_blue = foreground; + setDimensions(_active->width(), _active->height()); + return true; +} diff --git a/src/helpers/ui/WioL1Display.h b/src/helpers/ui/WioL1Display.h new file mode 100644 index 000000000..b2d9dbb36 --- /dev/null +++ b/src/helpers/ui/WioL1Display.h @@ -0,0 +1,52 @@ +#pragma once + +#include "SH1106Display.h" +#include "GxEPDDisplay.h" + +// L1/Lite/Pro and L1 E-Ink share the same MCU, storage and radio wiring. +// Construct only the detected backend, sharing the larger static allocation. +// No OLED framebuffer is allocated on e-paper or headless hardware. +class WioL1Display : public DisplayDriver { +public: + enum class Type : uint8_t { None, Oled, Eink }; + +private: + union Storage { + SH1106Display oled; + GxEPDDisplay eink; + Storage() {} + ~Storage() {} + } _storage; + DisplayDriver* _active = nullptr; + Type _type = Type::None; + Type _constructed = Type::None; + bool _detected = false; + bool _started = false; + +public: + WioL1Display() : DisplayDriver(128, 64) {} + ~WioL1Display(); + Type detect(); + Type selectedType() const { return _type; } + uint16_t defaultTimeoutSeconds() const { return _type == Type::Eink ? 900 : 15; } + bool begin(); + bool isOn() override { return _active && _active->isOn(); } + bool isEink() override { return _type == Type::Eink; } + void forceFullRefresh() override { if (_active) _active->forceFullRefresh(); } + void turnOn() override { if (_active) _active->turnOn(); } + void turnOff() override { if (isOn()) _active->turnOff(); } + int textLineHeight() override { return _active ? _active->textLineHeight() : 10; } + void clear() override { if (isOn()) _active->clear(); } + void startFrame(ColorVal bkg = UIColor::window_bkg) override { if (isOn()) _active->startFrame(bkg); } + void setTextSize(int size) override { if (_active) _active->setTextSize(size); } + void setColor(ColorVal color) override { if (_active) _active->setColor(color); } + void setCursor(int x, int y) override { if (isOn()) _active->setCursor(x, y); } + void print(const char* text) override { if (isOn()) _active->print(text); } + void fillRect(int x, int y, int w, int h) override { if (isOn()) _active->fillRect(x, y, w, h); } + void drawRect(int x, int y, int w, int h) override { if (isOn()) _active->drawRect(x, y, w, h); } + void drawXbm(int x, int y, const uint8_t* bits, int w, int h) override { + if (isOn()) _active->drawXbm(x, y, bits, w, h); + } + uint16_t getTextWidth(const char* text) override { return _active ? _active->getTextWidth(text) : 0; } + void endFrame() override { if (isOn()) _active->endFrame(); } +}; diff --git a/src/helpers/ui/WioL1DisplayLayout.h b/src/helpers/ui/WioL1DisplayLayout.h new file mode 100644 index 000000000..ff36dcb00 --- /dev/null +++ b/src/helpers/ui/WioL1DisplayLayout.h @@ -0,0 +1,12 @@ +#pragma once + +namespace mesh { namespace ui { namespace wio_l1 { + +inline int visibleRows(int height, int top, int glyph_height, int spacing, int limit) { + if (spacing <= 0 || glyph_height <= 0 || height - top < glyph_height || limit <= 0) + return 0; + const int rows = 1 + (height - top - glyph_height) / spacing; + return rows < limit ? rows : limit; +} + +}}} diff --git a/src/helpers/ui/WioL1DisplayProbe.h b/src/helpers/ui/WioL1DisplayProbe.h new file mode 100644 index 000000000..7dc2ca746 --- /dev/null +++ b/src/helpers/ui/WioL1DisplayProbe.h @@ -0,0 +1,155 @@ +#pragma once + +#include + +namespace mesh { namespace ui { namespace wio_l1 { + +inline void i2cLow(uint8_t pin) { + digitalWrite(pin, LOW); + pinMode(pin, OUTPUT); +} + +inline bool i2cClockHigh() { + pinMode(PIN_WIRE_SCL, INPUT_PULLUP); + for (uint8_t retry = 0; retry < 50; ++retry) { + if (digitalRead(PIN_WIRE_SCL) == HIGH) return true; + delayMicroseconds(10); + } + return false; +} + +// Called while Wire is disabled, before any radio/RTC background work starts. +// GPIO only drives LOW; INPUT_PULLUP releases each open-drain line. A stuck +// SCL/SDA fails in bounded time, unlike the nRF52 Wire driver's event waits. +inline bool probeOled(uint8_t address) { + pinMode(PIN_WIRE_SDA, INPUT_PULLUP); + bool ready = i2cClockHigh() && digitalRead(PIN_WIRE_SDA) == HIGH; + bool ack = false; + if (ready) { + i2cLow(PIN_WIRE_SDA); + delayMicroseconds(5); + i2cLow(PIN_WIRE_SCL); + for (uint8_t mask = 0x80; mask && ready; mask >>= 1) { + if ((address << 1) & mask) pinMode(PIN_WIRE_SDA, INPUT_PULLUP); + else i2cLow(PIN_WIRE_SDA); + delayMicroseconds(5); + ready = i2cClockHigh(); + delayMicroseconds(5); + i2cLow(PIN_WIRE_SCL); + } + if (ready) { + pinMode(PIN_WIRE_SDA, INPUT_PULLUP); + delayMicroseconds(5); + ready = i2cClockHigh(); + ack = ready && digitalRead(PIN_WIRE_SDA) == LOW; + i2cLow(PIN_WIRE_SCL); + } + i2cLow(PIN_WIRE_SDA); + ready = i2cClockHigh() && ready; + delayMicroseconds(5); + } + pinMode(PIN_WIRE_SDA, INPUT_PULLUP); // STOP, or release a failed bus. + pinMode(PIN_WIRE_SCL, INPUT_PULLUP); + delayMicroseconds(5); + return ready && ack && digitalRead(PIN_WIRE_SDA) == HIGH; +} + +// The L1's SSD1680 uses bidirectional SDA on the display MOSI pin. Read +// status 0x2F before starting SPI1; BUSY alone cannot identify a fitted panel. +// SSD1680 Rev 0.14 pp. 10-11,29: bits 1:0 are 01; bits 5:4 are undefined +// after reset. Opposite input pulls reject an absent or disconnected display. +inline void writeCommand(uint8_t command) { + digitalWrite(PIN_DISPLAY_CS, LOW); + digitalWrite(PIN_DISPLAY_DC, LOW); + pinMode(PIN_SPI1_MOSI, OUTPUT); + for (uint8_t mask = 0x80; mask; mask >>= 1) { + digitalWrite(PIN_SPI1_MOSI, command & mask ? HIGH : LOW); + delayMicroseconds(5); + digitalWrite(PIN_SPI1_SCK, HIGH); + delayMicroseconds(5); + digitalWrite(PIN_SPI1_SCK, LOW); + } + digitalWrite(PIN_DISPLAY_CS, HIGH); +} + +inline uint8_t readStatus(uint8_t bias) { + digitalWrite(PIN_DISPLAY_CS, LOW); + digitalWrite(PIN_DISPLAY_DC, LOW); + pinMode(PIN_SPI1_MOSI, OUTPUT); + for (uint8_t mask = 0x80; mask; mask >>= 1) { + digitalWrite(PIN_SPI1_MOSI, 0x2F & mask ? HIGH : LOW); + delayMicroseconds(5); + digitalWrite(PIN_SPI1_SCK, HIGH); + delayMicroseconds(5); + if (mask == 1) { + // Release SDA before the controller drives the final falling edge. + pinMode(PIN_SPI1_MOSI, bias); + digitalWrite(PIN_DISPLAY_DC, HIGH); + } + digitalWrite(PIN_SPI1_SCK, LOW); + } + uint8_t result = 0; + for (uint8_t bit = 0; bit < 8; ++bit) { + delayMicroseconds(5); + result = uint8_t((result << 1) | (digitalRead(PIN_SPI1_MOSI) ? 1u : 0u)); + digitalWrite(PIN_SPI1_SCK, HIGH); + delayMicroseconds(5); + digitalWrite(PIN_SPI1_SCK, LOW); + } + digitalWrite(PIN_DISPLAY_CS, HIGH); + return result; +} + +inline bool waitReady() { + pinMode(PIN_DISPLAY_BUSY, INPUT_PULLUP); + for (uint16_t elapsed = 0; elapsed < 200; ++elapsed) { + if (digitalRead(PIN_DISPLAY_BUSY) == LOW) return true; + delay(1); + } + return false; +} + +inline void parkAbsent() { + digitalWrite(PIN_DISPLAY_RST, LOW); + pinMode(PIN_DISPLAY_CS, INPUT); + pinMode(PIN_DISPLAY_DC, INPUT); + pinMode(PIN_SPI1_MOSI, INPUT); + pinMode(PIN_SPI1_SCK, INPUT); + pinMode(PIN_DISPLAY_BUSY, INPUT); +} + +inline bool probeEink() { + digitalWrite(PIN_DISPLAY_CS, HIGH); + pinMode(PIN_DISPLAY_CS, OUTPUT); + digitalWrite(PIN_SPI1_SCK, LOW); + pinMode(PIN_SPI1_SCK, OUTPUT); + pinMode(PIN_DISPLAY_DC, OUTPUT); + digitalWrite(PIN_DISPLAY_RST, LOW); + pinMode(PIN_DISPLAY_RST, OUTPUT); + for (uint8_t attempt = 0; attempt < 2; ++attempt) { + digitalWrite(PIN_DISPLAY_RST, LOW); + delay(2); + digitalWrite(PIN_DISPLAY_RST, HIGH); + delay(10); + if (!waitReady()) continue; + writeCommand(0x12); + if (!waitReady()) continue; + uint8_t status[2]; + bool stable = true; + for (uint8_t bias = 0; bias < 2; ++bias) { + const uint8_t mode = bias == 0 ? INPUT_PULLUP : INPUT_PULLDOWN; + (void)readStatus(mode); + status[bias] = readStatus(mode) & 0xCFu; + stable = ((readStatus(mode) & 0xCFu) == status[bias]) && stable; + } + if (stable && status[0] == 0x01u && status[1] == status[0]) { + pinMode(PIN_SPI1_MOSI, INPUT); + pinMode(PIN_DISPLAY_BUSY, INPUT); + return true; + } + } + parkAbsent(); + return false; +} + +}}} // namespace mesh::ui::wio_l1 diff --git a/test/fixtures/cli_reply_text.json b/test/fixtures/cli_reply_text.json index 882e30796..7d53d4a8f 100644 --- a/test/fixtures/cli_reply_text.json +++ b/test/fixtures/cli_reply_text.json @@ -1414,7 +1414,7 @@ "src/helpers/FloodAdvertCLI.h": "0f378e4d0f6c3b9b79991068a4b4208139484d2144ee1d7c5094d39043463be4", "src/helpers/LedSettings.cpp": "df0681234ca1337148ad59caa935857bc692f98bf18603ca6d099c0382028400", "src/helpers/StorageLayout.cpp": "5dd1c8bcbe2902e1f22d14f9ab425465bdc38926fae23989987d850b484cbb18", - "examples/companion_radio/MyMesh.cpp": "b1a97d4f2975c0a8d53f3bbaade327e6edc09af6c4476cf34f27a096e65647bb", + "examples/companion_radio/MyMesh.cpp": "562448d691a8b1b39a4f656c4acdde966d0ce21ebb63cfef4640d1b3ac1db694", "src/helpers/CommonRadioPrefs.cpp": "16b1fb499426abde83566b6481df89b80302c72e00f0f4aa3843b6c9b9de4218", "examples/companion_radio/CompanionRetry.h": "e0ae10c7b2f778346f4645c4e147d87e1fbc7f8b99484752a8eed914bb21380f", "examples/companion_radio/Notifications.cpp": "173fb0d452fdd383585dd88ab35fbb079a90e068adca2db9bc44d604557d3873" diff --git a/test/test_build_profiles.sh b/test/test_build_profiles.sh index 139326745..fa8d11755 100644 --- a/test/test_build_profiles.sh +++ b/test/test_build_profiles.sh @@ -9,16 +9,6 @@ fail() { exit 1 } -# These are the only current ESP32 Companion recipes without a qualified Full -# replacement. Keep inventory assertions consistent without exempting boards. -is_existing_cardputer_companion_recipe() { - case "$1" in - M5Stack_Cardputer_Adv_companion_radio_usb|M5Stack_Cardputer_Adv_companion_radio_ble) - return 0 ;; - esac - return 1 -} - # Optional debug settings must remain usable by strict shell callers before # any PlatformIO flags have been initialized. ( @@ -241,6 +231,17 @@ require("Heltec_E290_companion_usb_ble", "build_flags", "ENABLE_USB_INTERFACE") require("Heltec_E290_companion_usb_ble", "build_flags", "BLE_PIN_CODE=123456") require("Heltec_T190_companion_radio_usb_ble_", "build_flags", "ENABLE_USB_INTERFACE") require("Heltec_T190_companion_radio_usb_ble_", "build_flags", "BLE_PIN_CODE=123456") +cardputer = "M5Stack_Cardputer_Adv_companion_radio_ble" +require(cardputer, "board", "m5stack-stamps3") +require(cardputer, "board_build.partitions", "default_8MB.csv") +for flag in ("CARDPUTER_ADV_UI=1", "MESH_CONTACT_CACHE=1", "MAX_CONTACTS=350", + "MAX_GROUP_CHANNELS=40", "OFFLINE_QUEUE_SIZE=256", + "ENABLE_USB_INTERFACE", "BLE_PIN_CODE=123456", + "RECOVERABLE_EXTERNAL_RADIO", "ENV_INCLUDE_GPS=1"): + require(cardputer, "build_flags", flag) +for source in ("examples/companion_radio", "variants/m5stack_cardputer_adv/companion_radio", + "variants/m5stack_cardputer_adv/hw"): + require(cardputer, "build_src_filter", source) waveshare_full = "Waveshare_S3_LR1121_companion_radio_full" require(waveshare_full, "board", "esp32-s3-zero") require(waveshare_full, "board_build.flash_mode", "dio") @@ -509,11 +510,10 @@ for env_name in ( ): require(env_name, "build_flags", "BLE_TX_POWER=0") -for env_name in ( - "WioTrackerL1_companion_radio_usb", - "WioTrackerL1_companion_radio_ble", -): - require(env_name, "build_flags", "UI_NO_HIBERNATE") +require("WioTrackerL1_companion_radio_usb", "build_flags", "UI_NO_HIBERNATE") +require("WioTrackerL1_companion_radio_ble", "build_flags", "WIO_L1_DISPLAY_UNIFIED=1") +require("WioTrackerL1_companion_radio_ble", "build_flags", "DISPLAY_CLASS=WioL1Display") +reject("WioTrackerL1_companion_radio_ble", "build_flags", "UI_NO_HIBERNATE") rak_usb = "RAK_3401_companion_radio_usb" require(rak_usb, "build_flags", "RAK_BOARD") @@ -532,6 +532,11 @@ for env_name in ("RAK_4631_companion_radio_usb", "RAK_4631_repeater"): # replace each with that exact board's Full target. init_project_context >/dev/null +# The generated L1 Full target must use the recipe with both display drivers, +# rather than the earlier USB-only OLED recipe with the same physical board. +[ "${PIO_ENV_BUILD_BASE_BY_NAME[WioTrackerL1_companion_radio_full]:-}" = WioTrackerL1_companion_radio_ble ] \ + || fail "Wio L1 Full did not select its runtime-display BLE base" + # The interactive board menu must offer the same ordinary Full choices as the # release, while exact compatibility/development recipes stay directly usable. # Test real board inheritance: dropping aliases by suffix alone would also @@ -580,15 +585,18 @@ init_project_context >/dev/null || fail "nRF52 menu depends on alias input order" ) -# Cardputer's existing UI recipes have not qualified a combined Full profile. -# Keep both exact transport recipes available in its ordinary release lists. +# Cardputer keeps its historical recipes for explicit recovery builds while +# ordinary releases publish one USB/BLE/WiFi Full image with the keyboard UI. for cardputer_env in M5Stack_Cardputer_Adv_companion_radio_usb \ M5Stack_Cardputer_Adv_companion_radio_ble; do [ "$(get_pio_build_env "$cardputer_env")" = "$cardputer_env" ] \ || fail "$cardputer_env lost its exact board recipe" for resolver in resolve_all_firmwares resolve_companion_firmwares; do - grep -Fxq "$cardputer_env" <<<"$("$resolver")" \ - || fail "$resolver omitted supported Cardputer recipe $cardputer_env" + if grep -Fxq "$cardputer_env" <<<"$("$resolver")"; then + fail "$resolver republishes Cardputer transport alias $cardputer_env" + fi + grep -Fxq M5Stack_Cardputer_Adv_companion_radio_full <<<"$("$resolver")" \ + || fail "$resolver omitted combined Cardputer Full" done done @@ -604,9 +612,6 @@ for resolver in resolve_all_firmwares resolve_repeater_firmwares \ fi is_redundant_bulk_build_target "$release_env" \ && fail "$resolver republishes utility or legacy alias $release_env" - if is_existing_cardputer_companion_recipe "$release_env"; then - continue - fi if is_kiss_modem_target "$release_env"; then if is_esp32_full_release_build_target "$release_env"; then fail "$release_env host modem was promoted into the node Full profile" @@ -1115,6 +1120,8 @@ while IFS='|' read -r source_env full_env build_base; do is_redundant_bulk_build_target "$source_env" \ || fail "$source_env remained beside its Full Companion" done <<'FULL_COMPANION_SPECS' +M5Stack_Cardputer_Adv_companion_radio_usb|M5Stack_Cardputer_Adv_companion_radio_full|M5Stack_Cardputer_Adv_companion_radio_full +M5Stack_Cardputer_Adv_companion_radio_ble|M5Stack_Cardputer_Adv_companion_radio_full|M5Stack_Cardputer_Adv_companion_radio_full LilyGo_Tlora_C6_companion_radio_ble_|LilyGo_Tlora_C6_companion_radio_full_|LilyGo_Tlora_C6_companion_radio_ble_ Meshimi_companion_radio_ble_|Meshimi_companion_radio_full_|Meshimi_companion_radio_ble_ WHY2025_badge_companion_radio_ble_|WHY2025_badge_companion_radio_full_|WHY2025_badge_companion_radio_ble_ @@ -1362,8 +1369,7 @@ MQTT_BRIDGE_OVERRIDE="" while IFS= read -r env_name; do if [ "${PIO_ENV_PLATFORM_BY_NAME[$env_name]:-}" = ESP32_PLATFORM ] \ - && ! is_companion_radio_full_target "$env_name" \ - && ! is_existing_cardputer_companion_recipe "$env_name"; then + && ! is_companion_radio_full_target "$env_name"; then fail "canonical ESP32 Companion inventory still contains $env_name" fi done < <(resolve_companion_firmwares) diff --git a/test/test_cardputer_board_policy.py b/test/test_cardputer_board_policy.py index 9e2d50f9f..9a26fda03 100644 --- a/test/test_cardputer_board_policy.py +++ b/test/test_cardputer_board_policy.py @@ -18,6 +18,88 @@ VARIANT = ROOT / "variants/m5stack_cardputer_adv" class CardputerBoardPolicyTests(unittest.TestCase): + def test_full_transport_selection_preserves_live_bluetooth_controls(self): + source = (VARIANT / "companion_radio/ui-cardputer/CardputerUITask.cpp").read_text() + toggle = extract_braced(source, "void CardputerUITask::toggleBluetooth()") + transport = extract_braced(source, "void CardputerUITask::toggleTransport()") + code = r''' +#include +#include +#include +#include +#include "CompanionBluetooth.h" +#include "CompanionWiFi.h" +static std::string command; +static bool fail = false; +bool handleCompanionBluetoothCommand(const char* text, char* reply, size_t size, + CompanionBluetoothCommandSource) { + command = text; + snprintf(reply, size, "%s", fail ? "Error: save failed" : "OK - saved"); + return true; +} +#if defined(COMPANION_EXCLUSIVE_WIFI_BLE) +static CompanionTransportMode selected = CompanionTransportMode::Bluetooth; +CompanionTransportMode getCompanionTransportMode() { return selected; } +bool selectCompanionTransportMode(CompanionTransportMode next) { + if (fail) return false; + selected = next; + return true; +} +#endif +struct CardputerUITask { + bool enabled = true; + std::string alert; + bool isBluetoothEnabled() const { return enabled; } + void showAlert(const char* text, int) { alert = text; } + void toggleBluetooth(); +#if defined(COMPANION_EXCLUSIVE_WIFI_BLE) + void toggleTransport(); +#endif +}; +@METHOD@ +#if defined(COMPANION_EXCLUSIVE_WIFI_BLE) +@TRANSPORT@ +#endif +int main() { + CardputerUITask task; + task.toggleBluetooth(); + assert(command == "set bluetooth off" && task.alert == "saved"); + task.enabled = false; task.toggleBluetooth(); + assert(command == "set bluetooth on" && task.alert == "saved"); + fail = true; task.toggleBluetooth(); + assert(task.alert == "Error: save failed"); + fail = false; command.clear(); +#if defined(COMPANION_EXCLUSIVE_WIFI_BLE) + task.toggleTransport(); + assert(selected == CompanionTransportMode::WiFi && command.empty()); + assert(task.alert == "WiFi selected; reboot required"); + fail = true; task.toggleTransport(); + assert(selected == CompanionTransportMode::WiFi && task.alert == "Transport save failed"); + fail = false; task.toggleTransport(); + assert(selected == CompanionTransportMode::Bluetooth && command.empty()); + assert(task.alert == "Bluetooth selected; reboot required"); +#endif +} +'''.replace("@METHOD@", toggle).replace("@TRANSPORT@", transport) + compiler = os.environ.get("CXX") or shutil.which("g++") or shutil.which("clang++") + self.assertIsNotNone(compiler, "a native C++ compiler is required") + with tempfile.TemporaryDirectory(prefix="cardputer-transport-") as directory: + source = Path(directory) / "transport.cpp" + binary = Path(directory) / ("transport.exe" if os.name == "nt" else "transport") + source.write_text(code, encoding="ascii") + for full in (False, True): + with self.subTest(full=full): + flags = (["-DESP32=1", "-DCOMPANION_EXCLUSIVE_WIFI_BLE=1", + '-DWIFI_SSID="test"', "-DBLE_PIN_CODE=123456"] if full else []) + sanitizer = [] if os.name == "nt" else ["-fsanitize=address,undefined", + "-fno-sanitize-recover=all", "-fno-pie", "-no-pie"] + built = subprocess.run([compiler, "-std=c++17", "-Wall", "-Wextra", "-Werror", + *sanitizer, *flags, "-I", str(ROOT / "examples/companion_radio"), + str(source), "-o", str(binary)], capture_output=True, text=True, timeout=30) + self.assertEqual(built.returncode, 0, built.stdout + built.stderr) + tested = subprocess.run([str(binary)], capture_output=True, text=True, timeout=10) + self.assertEqual(tested.returncode, 0, tested.stdout + tested.stderr) + def check_gpio(self, remove_board_guard=False): method = extract_braced((VARIANT / "CardputerAdvBoard.h").read_text(), "bool isUserGpioAvailable(uint8_t pin) const override") diff --git a/test/test_cardputer_runtime_memory.py b/test/test_cardputer_runtime_memory.py index eec59ae8e..ee890d8af 100644 --- a/test/test_cardputer_runtime_memory.py +++ b/test/test_cardputer_runtime_memory.py @@ -13,6 +13,27 @@ from test_replay_reset_integration import extract_braced class CardputerRuntimeMemoryTests(unittest.TestCase): + def test_full_retains_custom_ui_allowances_and_selected_wireless_stack(self): + full = {"ESP32_PLATFORM": "1", "BLE_PIN_CODE": "123456", + "WIFI_SSID": '"WIFI_SSID"', "WIFI_OTA_SEEDER": "1", + "ENABLE_OTA": "1", "OTA_SEEDER_ONLY": "1", + "COMPANION_RADIO_FULL": "1", "CARDPUTER_ADV_UI": "1", + "COMPANION_EXCLUSIVE_WIFI_BLE": "1", + "MESH_CONTACT_CACHE": "1", "MAX_CONTACTS": "350", + "MAX_GROUP_CHANNELS": "40", "OFFLINE_QUEUE_SIZE": "256"} + measured = requirements("ESP32_PLATFORM", full, + "M5Stack_Cardputer_Adv_companion_radio_full") + legacy = requirements("ESP32_PLATFORM", {"ESP32_PLATFORM": "1", + "BLE_PIN_CODE": "123456", "CARDPUTER_ADV_UI": "1"}, + "M5Stack_Cardputer_Adv_companion_radio_ble") + for key, value in legacy["components"].items(): + if key.startswith("cardputer_"): + self.assertEqual(measured["components"][key], value) + self.assertEqual(measured["components"]["wireless_stacks"], max(49152, 32768)) + self.assertIn("ota_source_scratch", measured["components"]) + self.assertIn("ota_generic_source_buffers", measured["components"]) + self.assertGreater(measured["required_heap_bytes"], legacy["required_heap_bytes"]) + def test_custom_ui_owners_are_additive_and_fonts_fit_their_allowance(self): base = {"ESP32_PLATFORM": "1", "BLE_PIN_CODE": "123456"} target = "M5Stack_Cardputer_Adv_companion_radio_ble" diff --git a/test/test_combined_board_picker.js b/test/test_combined_board_picker.js new file mode 100644 index 000000000..db6e29376 --- /dev/null +++ b/test/test_combined_board_picker.js @@ -0,0 +1,59 @@ +"use strict"; +const assert = require("assert"); +const picker = require("../docs/_javascript/firmware_picker.js"); +const source = "a".repeat(40), tag = "v1.18.0.2-dev-aaaaaaaa"; +const cases = [ + ["heltec_v4_r8_tft_kiss_modem", "heltec_v4_r8_kiss_modem", "ESP32_PLATFORM", "headlessR8Kiss"], + ["WioTrackerL1Eink_kiss_modem", "WioTrackerL1_kiss_modem", "NRF52_PLATFORM", "headlessWioL1Kiss"], + ["WioTrackerL1Eink_companion_radio_ble", "WioTrackerL1_companion_radio_full", "NRF52_PLATFORM", "unifiedWioL1Display"], +]; + +function catalog(legacy, target, platform, key, changes = {}, names = [legacy, target]) { + const profiles = {[target]: {platform, [key]: true, [key + "Source"]: source, + ...(key === "unifiedWioL1Display" ? {display: true, displayAuto: true, + displayController: "sh1106_ssd1680", displaySource: source} : {}), ...changes}}; + const release = {tag_name: tag, name: tag, draft: false, published_at: "2026-10-31T00:00:00Z", + html_url: "https://github.com/mikecarper/MeshCore/releases/tag/" + tag, + assets: names.map(name => { + const file = name + "-" + tag + (platform === "NRF52_PLATFORM" ? ".uf2" : ".bin"); + return {name: file, size: 500000, + browser_download_url: "https://github.com/mikecarper/MeshCore/releases/download/" + tag + "/" + file}; + })}; + const before = JSON.stringify(release); + const result = picker.buildCatalog([release], {familyTag: tag, source, profiles}); + assert.strictEqual(JSON.stringify(release), before, "historical filenames and downloads stay exact"); + return result; +} + +for (const args of cases) { + const [legacy, target, platform, key] = args; + const result = catalog(...args); + assert.deepStrictEqual(result.profiles.map(row => row.target), [target]); + assert.strictEqual(result.rows.length, 2, "the exact historical download remains available"); + const old = picker.parseTargetProfile(legacy), next = picker.parseTargetProfile(target); + const selected = picker.selectionFromUrl("https://example.org/?hardware=" + old.hardware + "&role=" + old.role, + result.profiles); + assert.strictEqual(selected.filters.hardware, next.hardware); + assert.deepStrictEqual(selected.unavailable, []); + for (const fault of [{[key]: false}, {[key]: 1}, {[key + "Source"]: "b".repeat(40)}, + {[key + "Source"]: "aaaaaaaa"}, {platform: platform === "ESP32_PLATFORM" ? "NRF52_PLATFORM" : "ESP32_PLATFORM"}]) { + assert.strictEqual(catalog(...args, fault).profiles.length, 2, "unqualified replacements keep old choices"); + } + assert.strictEqual(catalog(...args, {}, [legacy]).profiles.length, 1, "missing successor keeps legacy"); + if (key === "unifiedWioL1Display") { + for (const fault of [{display: false}, {displayAuto: false}, {displaySource: "b".repeat(40)}, + {displayController: "ssd1681"}]) { + assert.strictEqual(catalog(...args, fault).profiles.length, 2, "Wio needs its exact optional display proof"); + } + assert(picker.boardNotes(result.profiles[0]).some(note => note.includes("1W"))); + } else { + assert(picker.boardNotes(result.profiles[0]).some(note => note.includes("display"))); + } +} +const wio = cases[2]; +for (const other of ["WioTrackerL1_1W_companion_radio_ble", "WioTrackerL1Eink_repeater", + "WioTrackerL1Eink_companion_radio_ble_lora_ota"]) { + assert.strictEqual(catalog(other, wio[1], wio[2], wio[3]).profiles.length, 2, + other + ": different physical hardware, roles and deployed OTA identities stay separate"); +} +console.log("Combined Wio L1 / headless KISS choices require exact release proof and preserve historical downloads"); diff --git a/test/test_combined_board_profiles.py b/test/test_combined_board_profiles.py new file mode 100644 index 000000000..2f8145df9 --- /dev/null +++ b/test/test_combined_board_profiles.py @@ -0,0 +1,153 @@ +"""Evidence-bound Wio L1 Companion and headless KISS consolidation.""" +from copy import deepcopy +from pathlib import Path +import sys +import unittest + +ROOT = Path(__file__).resolve().parents[1] +sys.path[:0] = [str(ROOT / "scripts"), str(ROOT / "tools/mota")] +import consolidated_profiles as profiles +import motalib + +SOURCE = "a" * 40 +CASES = ( + dict(target="heltec_v4_r8_kiss_modem", legacy="heltec_v4_r8_tft_kiss_modem", + key="headlessR8Kiss", capability="modem.headless.heltec_r8", + marker="meshcore.capability.modem.headless.heltec_r8.v1", + platform="ESP32_PLATFORM", board="heltec_v4_r8", + flags=["-DHELTEC_V4_R8=1", "-DMESHCORE_HEADLESS_KISS_COMPAT=1"]), + dict(target="WioTrackerL1_kiss_modem", legacy="WioTrackerL1Eink_kiss_modem", + key="headlessWioL1Kiss", capability="modem.headless.wio_l1", + marker="meshcore.capability.modem.headless.wio_l1.v1", + platform="NRF52_PLATFORM", board="seeed-wio-tracker-l1", + flags=["-DWIO_TRACKER_L1=1", "-DMESHCORE_HEADLESS_KISS_COMPAT=1"]), + dict(target="WioTrackerL1_companion_radio_full", legacy="WioTrackerL1Eink_companion_radio_ble", + key="unifiedWioL1Display", capability="display.wio_l1.unified", + marker="meshcore.capability.display.wio_l1_unified.v1", + platform="NRF52_PLATFORM", board="seeed-wio-tracker-l1", + flags=["-DWIO_TRACKER_L1=1", "-DWIO_L1_DISPLAY_UNIFIED=1", + "-DDISPLAY_CLASS=WioL1Display", "-DMAX_CONTACTS=350", + "-DMAX_GROUP_CHANNELS=40", "-DOFFLINE_QUEUE_SIZE=256"]), +) + + +def environment(case): + return dict(board=case["board"], build_flags=case["flags"][:]) + + +def record(case, *, legacy=False): + target = case["legacy"] if legacy else case["target"] + manifest = dict(target=target, source_commit=SOURCE, verified=True, + build_profile="full", platform=case["platform"], + capabilities=["sensor.profile.full"], verification=[]) + if not legacy: + manifest["capabilities"].append(case["capability"]) + manifest["verification"].append(dict(capability=case["capability"], present=True, + source="packaged application", evidence=case["marker"])) + if "_companion_radio_" in target: + for key in ("companion.usb", "companion.bluetooth"): + manifest["capabilities"].append(key) + manifest["verification"].append(dict(capability=key, present=True, + source="linked image", evidence="linked symbol")) + return dict(manifest=manifest, files=[target + ".bin"]) + + +class CombinedBoardProfileTests(unittest.TestCase): + def test_each_successor_requires_exact_config_and_source_bound_packaged_evidence(self): + for case in CASES: + base = record(case)["manifest"] + with self.subTest(target=case["target"]): + self.assertEqual(profiles.runtime_metadata(base, environment(case), SOURCE), + {case["key"]: True, case["key"] + "Source": SOURCE}) + for changes in (dict(verified=False), dict(platform="OTHER"), dict(verification=[]), + dict(source_commit="b" * 40), dict(source_commit="a" * 8)): + with self.subTest(target=case["target"], changes=changes), self.assertRaises(ValueError): + profiles.runtime_metadata({**base, **changes}, environment(case), SOURCE) + for changes in (dict(present=False), dict(present=1), dict(source="linked image"), + dict(evidence="driver linked")): + bad = deepcopy(base) + bad["verification"][0].update(changes) + with self.subTest(target=case["target"], changes=changes), self.assertRaises(ValueError): + profiles.runtime_metadata(bad, environment(case), SOURCE) + + def test_omission_needs_same_source_build_policy_and_real_packaged_marker(self): + for case in CASES: + old, new = record(case, legacy=True), record(case) + self.assertEqual(profiles.omit_redundant_records([old, new]), [new]) + self.assertEqual(profiles.omit_redundant_records([old]), [old]) + faults = (dict(verified=False), dict(platform="OTHER"), dict(verification=[]), + dict(source_commit="b" * 40), dict(source_commit="a" * 8), + dict(build_profile="reduced"), dict(capabilities=["sensor.profile.full"]), + dict(capabilities=[case["capability"]]), + dict(capabilities=[case["capability"], "sensor.profile.reduced"])) + for changes in faults: + candidate = deepcopy(new) + candidate["manifest"].update(changes) + with self.subTest(target=case["target"], changes=changes): + self.assertEqual(profiles.omit_redundant_records([old, candidate]), [old, candidate]) + + def test_companion_replacement_keeps_both_actual_transports(self): + case = CASES[2] + old, new = record(case, legacy=True), record(case) + for capability in ("companion.usb", "companion.bluetooth"): + for changes in (dict(present=False), dict(source="packaged application"), dict(evidence="")): + bad = deepcopy(new) + for row in bad["manifest"]["verification"]: + if row["capability"] == capability: + row.update(changes) + with self.subTest(capability=capability, changes=changes): + self.assertEqual(profiles.omit_redundant_records([old, bad]), [old, bad]) + + def test_resumed_wio_ble_profile_retires_only_with_matching_sensor_policy(self): + old, new = record(CASES[2], legacy=True), record(CASES[2]) + old["manifest"]["build_profile"] = "standard" + self.assertEqual(profiles.omit_redundant_records([old, new]), [new]) + old["manifest"]["capabilities"] = ["sensor.profile.reduced"] + self.assertEqual(profiles.omit_redundant_records([old, new]), [old, new]) + + def test_configuration_cannot_fold_a_different_board_overridden_define_or_visible_kiss_display(self): + for case in CASES: + canonical, legacy = "env:" + case["target"], "env:" + case["legacy"] + envs = {canonical: environment(case), legacy: environment(case)} + self.assertEqual(profiles.configuration_replacement(case["legacy"], envs), case["target"]) + for flag in case["flags"]: + key = flag[2:].split("=", 1)[0] + for override in ("-U" + key, "-D" + key + "=0"): + bad = deepcopy(envs) + bad[canonical]["build_flags"].append(override) + with self.subTest(target=case["target"], override=override): + self.assertIsNone(profiles.configuration_replacement(case["legacy"], bad)) + for name in (canonical, legacy): + bad = deepcopy(envs) + bad[name]["board"] = "different-board" + self.assertIsNone(profiles.configuration_replacement(case["legacy"], bad)) + if case["key"].startswith("headless"): + bad = deepcopy(envs) + bad[canonical]["build_flags"].append("-DDISPLAY_CLASS=SomeDisplay") + self.assertIsNone(profiles.configuration_replacement(case["legacy"], bad)) + + def test_only_exact_old_targets_fold_and_update_ids_are_never_aliased(self): + for case in CASES: + self.assertEqual(profiles.replacements(case["legacy"]), [(case["target"], case["key"])]) + self.assertEqual(profiles.replacements(case["legacy"] + "_custom"), []) + self.assertNotEqual(motalib.target_id_for_env(case["target"]), + motalib.target_id_for_env(case["legacy"])) + for target in ("WioTrackerL1_1W_companion_radio_ble", "WioTrackerL1Eink_repeater", + "WioTrackerL1Eink_companion_radio_ble_lora_ota", + "heltec_v4_r8_tft_repeater", "heltec_v4_r8_tft_sensor"): + self.assertEqual(profiles.replacements(target), [], target) + + def test_wio_generated_full_resolves_from_ble_and_retains_capacity_guards(self): + case = CASES[2] + envs = {"env:" + case["legacy"]: environment(case), + "env:WioTrackerL1_companion_radio_ble": environment(case)} + self.assertEqual(profiles.configuration_replacement(case["legacy"], envs), case["target"]) + for key, value in (("MAX_CONTACTS", "349"), ("MAX_GROUP_CHANNELS", "39"), + ("OFFLINE_QUEUE_SIZE", "255")): + bad = deepcopy(envs) + bad["env:WioTrackerL1_companion_radio_ble"]["build_flags"].append("-D" + key + "=" + value) + self.assertIsNone(profiles.configuration_replacement(case["legacy"], bad)) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_companion_rtc_queue.py b/test/test_companion_rtc_queue.py index bec0754cf..7c1eb6ac8 100644 --- a/test/test_companion_rtc_queue.py +++ b/test/test_companion_rtc_queue.py @@ -291,7 +291,7 @@ class CompanionRtcQueueTests(unittest.TestCase): 'CONFIG_ESP_SYSTEM_ALLOW_RTC_FAST_MEM_AS_HEAP=1', 'COMPANION_RTC_FAST_OFFLINE_FRAMES=32'] cases = ([value for value in base if not value.startswith('CONFIG_IDF_TARGET_')], base + ['BOARD_HAS_PSRAM=1'], [value for value in base if not value.startswith('CONFIG_IDF_TARGET_')] - + ['OTA_SHARED_COMPANION_QUEUE=1', 'CONFIG_IDF_TARGET_ESP32S3=1'], + + ['OTA_SHARED_COMPANION_QUEUE=1', 'CONFIG_IDF_TARGET_ESP32=1'], base + ['ONE_KEY_DM_SHARED_OFFLINE_QUEUE=1'], base + ['COMPANION_RTC_SLOW_OFFLINE_FRAMES=32'], [value for value in base if not value.startswith('COMPANION_RADIO_FULL')], @@ -320,6 +320,7 @@ class CompanionRtcQueueTests(unittest.TestCase): ('MKE_s3_companion_radio_full', 32, 32, 350, 256), ('ThinkNode_M2_companion_radio_full', 32, 32, 350, 256), ('ThinkNode_M5_companion_radio_full', 32, 32, 350, 256), + ('M5Stack_Cardputer_Adv_companion_radio_full', 32, 32, 350, 256), ('SenseCapIndicator-ESPNow_companion_radio_full', 0, 0, 350, 256), ('RAK_4631_companion_radio_full', 0, 0, 350, 256)): with self.subTest(target=target): @@ -337,6 +338,9 @@ class CompanionRtcQueueTests(unittest.TestCase): if target in ('Xiao_C3_companion_radio_full', 'Heltec_CT62_companion_radio_full'): self.assertEqual(flags.get('OTA_SHARED_COMPANION_QUEUE'), '1') self.assertEqual(flags.get('ONE_KEY_DM_COMPACT_HELD'), '1') + elif target == 'M5Stack_Cardputer_Adv_companion_radio_full': + self.assertEqual(flags.get('OTA_SHARED_COMPANION_QUEUE'), '1') + self.assertEqual(flags.get('COMPANION_EXCLUSIVE_WIFI_BLE'), '1') else: self.assertNotIn('OTA_SHARED_COMPANION_QUEUE', flags) diff --git a/test/test_companion_shared_ota_capacity.py b/test/test_companion_shared_ota_capacity.py index 9d247ed65..825a53077 100644 --- a/test/test_companion_shared_ota_capacity.py +++ b/test/test_companion_shared_ota_capacity.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Exercise C3's full queue while its explicit OTA work borrows cold storage.""" +"""Exercise split C3/S3 queues while explicit OTA work borrows cold storage.""" from pathlib import Path import re @@ -30,15 +30,19 @@ class SharedCompanionOtaCapacityTest(unittest.TestCase): binary = Path(temp) / "queue" (Path(temp) / "sdkconfig.h").write_text("#pragma once\n") (Path(temp) / "esp_attr.h").write_text( - '#pragma once\n#define RTC_FAST_ATTR __attribute__((section(".rtc.force_fast.test")))\n') + '#pragma once\n#define RTC_FAST_ATTR __attribute__((section(".rtc.force_fast.test")))\n' + '#define RTC_SLOW_ATTR __attribute__((section(".rtc.force_slow.test")))\n') command = ["c++", "-std=c++17", "-Wall", "-Wextra", "-Werror", "-I", str(ROOT / "src"), "-I", temp, "-x", "c++", "-", "-o", str(binary)] if rtc: + s3 = rtc == "ESP32S3" command[1:1] = ["-DNDEBUG", "-DESP32_PLATFORM=1", "-DCOMPANION_RADIO_FULL=1", - "-DOTA_SHARED_COMPANION_QUEUE=1", "-DOFFLINE_QUEUE_SIZE=224", "-DCONFIG_IDF_TARGET_ESP32C3=1", + "-DOTA_SHARED_COMPANION_QUEUE=1", f"-DOFFLINE_QUEUE_SIZE={256 if s3 else 224}", + f"-DCONFIG_IDF_TARGET_{'ESP32S3' if s3 else 'ESP32C3'}=1", "-DCONFIG_ESP_SYSTEM_ALLOW_RTC_FAST_MEM_AS_HEAP=1", - "-DCOMPANION_RTC_FAST_OFFLINE_FRAMES=32"] + "-DCOMPANION_RTC_FAST_OFFLINE_FRAMES=32", + f"-DCOMPANION_RTC_SLOW_OFFLINE_FRAMES={32 if s3 else 0}"] if run: command[1:1] = ["-fsanitize=address,undefined", "-g"] result = subprocess.run(command, input=text, text=True, @@ -52,6 +56,10 @@ class SharedCompanionOtaCapacityTest(unittest.TestCase): match = re.search(r'\.rtc\.force_fast\.test\s+([0-9a-fA-F]+)', sections) self.assertIsNotNone(match, sections) self.assertEqual(int(match[1], 16), 32 * 177, sections) + if s3: + match = re.search(r'\.rtc\.force_slow\.test\s+([0-9a-fA-F]+)', sections) + self.assertIsNotNone(match, sections) + self.assertEqual(int(match[1], 16), 32 * 177, sections) def no_core_dump(): resource.setrlimit(resource.RLIMIT_CORE, (0, 0)) result = subprocess.run([str(binary), *args], text=True, @@ -59,7 +67,7 @@ class SharedCompanionOtaCapacityTest(unittest.TestCase): self.assertEqual(result.returncode, expected_returncode, result.stderr) return result - def queue_fixture(self, rtc=False): + def queue_fixture(self, rtc=False, capacity=224): source = (ROOT / "examples/companion_radio/MyMesh.cpp").read_text() declaration = (ROOT / "examples/companion_radio/MyMesh.h").read_text() retained = re.search( @@ -140,20 +148,20 @@ struct MyMesh { int main() { using mesh::ota::OtaContext; MyMesh mesh; - for (unsigned head = 0; head < 224; ++head) { - while (mesh.offline_queue_len < 224) mesh.add(); - // Visit every wrapped head with a full 224-frame normal queue. + for (unsigned head = 0; head < @CAPACITY@; ++head) { + while (mesh.offline_queue_len < @CAPACITY@) mesh.add(); + // Visit every wrapped head with a full normal queue. for (unsigned i = 0; i < head; ++i) { mesh.pop(); mesh.add(); } int original_head = mesh.offline_queue_head; unsigned constructed = OtaContext::constructions; assert(!MyMesh::acquireOfflineQueueForOta(&mesh)); - assert(mesh.offline_queue_len == 224); + assert(mesh.offline_queue_len == @CAPACITY@); assert(mesh.offline_queue_head == original_head); assert(OtaContext::constructions == constructed && OtaContext::live == 0); // Only the explicit drain consumes frames, preserving the newest 128. unsigned notified = mesh.notifications; - assert(MyMesh::drainOfflineQueueForOta(&mesh) == 96); - assert(mesh.notifications == notified + 96 && mesh.offline_queue_len == 128); + assert(MyMesh::drainOfflineQueueForOta(&mesh) == @DRAINED@); + assert(mesh.notifications == notified + @DRAINED@ && mesh.offline_queue_len == 128); assert(MyMesh::drainOfflineQueueForOta(&mesh) == 0); OtaContext* owner = MyMesh::acquireOfflineQueueForOta(&mesh); assert(owner && OtaContext::live == 1 && mesh.offline_queue.capacity() == 128); @@ -163,15 +171,16 @@ int main() { memset(owner->bytes, 0xCC, sizeof owner->bytes); for (unsigned i = 0; i < head % 128; ++i) { mesh.pop(); mesh.add(); } MyMesh::releaseOfflineQueueFromOta(&mesh); - assert(OtaContext::live == 0 && mesh.offline_queue.capacity() == 224); + assert(OtaContext::live == 0 && mesh.offline_queue.capacity() == @CAPACITY@); assert(OtaContext::constructions == OtaContext::destructions); MyMesh::releaseOfflineQueueFromOta(&mesh); // idle release cannot destroy twice - while (mesh.offline_queue_len < 224) mesh.add(); + while (mesh.offline_queue_len < @CAPACITY@) mesh.add(); while (mesh.offline_queue_len) mesh.pop(); assert(mesh.expected.empty()); } } -'''.replace("@RETAINED@", retained).replace("@METHODS@", methods) +'''.replace("@RETAINED@", retained).replace("@METHODS@", methods).replace( + "@CAPACITY@", str(capacity)).replace("@DRAINED@", str(capacity - 128)) if rtc: start = declaration.index(' // Concrete definitions in MyMesh.cpp') end = declaration.index('\n#endif\n#if defined(OTA_SHARED_COMPANION_QUEUE)', start) @@ -195,6 +204,10 @@ int main() { def test_rtc_backed_224_and_128_fifo_all_heads_use_actual_mesh_storage_and_callbacks(self): self.compile(self.queue_fixture(rtc=True), run=True, rtc=True) + def test_s3_256_and_128_fifo_all_heads_use_both_banks_and_actual_mesh_callbacks(self): + self.compile(self.queue_fixture(rtc=True, capacity=256), + run=True, rtc="ESP32S3") + def test_rtc_owner_reset_geometry_and_release_build_safeguards(self): harness = self.queue_fixture(rtc=True).split('int main() {', 1)[0] + r''' using Frame = MyMesh::Frame; diff --git a/test/test_display_power.py b/test/test_display_power.py index ebc49fccc..0ab3d97d4 100644 --- a/test/test_display_power.py +++ b/test/test_display_power.py @@ -363,6 +363,24 @@ void firstInstallSettingsTests() { } } void companionSettingsTests() { + for (uint16_t seconds : {uint16_t(15), uint16_t(900)}) { + FakeFS fs; + assert(!loadDisplayPowerSettings(&fs, true, true, seconds)); + assert(displayPowerPrefs().battery.seconds == seconds); + assert(displayPowerPrefs().usb.seconds == seconds); + DisplayPowerPolicy policy; + policy.update(displayPowerPrefs(), false, false, false, 100); + policy.update(displayPowerPrefs(), false, false, false, 100 + seconds * 1000U - 1); + assert(policy.on()); + policy.update(displayPowerPrefs(), false, false, false, 100 + seconds * 1000U); + assert(!policy.on()); + char reply[160]; + assert(handleDisplayPowerCommand("set display.timeout 123", reply, sizeof(reply))); + assert(std::string(reply) == "OK"); + assert(loadDisplayPowerSettings(&fs, true, true, 900)); + assert(displayPowerPrefs().battery.seconds == 123); + assert(displayPowerPrefs().usb.seconds == seconds); + } for (bool ble : {false, true}) { FakeFS fs; assert(!loadDisplayPowerSettings(&fs, ble, true)); diff --git a/test/test_firmware_ram.py b/test/test_firmware_ram.py index 10c8e49ca..714939435 100644 --- a/test/test_firmware_ram.py +++ b/test/test_firmware_ram.py @@ -285,6 +285,20 @@ int main() { elf32(path, {"__HeapBase": (0x2003F800-capacity,0), "__HeapLimit": (0x2003F800,0)}) self.assertEqual(ram.check_firmware(path,"NRF52_PLATFORM","nrf52840",flags,target)==0, accepted) + def test_wio_l1_unified_display_keeps_oled_heap_allowance_on_every_boot(self): + flags = {"DISPLAY_CLASS": "WioL1Display", "WIO_L1_DISPLAY_UNIFIED": 1, + "UI_SMALL_MESSAGE_FONT": 1, "COMPANION_RADIO_FULL": 1, "BLE_PIN_CODE": 123456} + target = "WioTrackerL1_companion_radio_full" + policy = ram.requirements("NRF52_PLATFORM", flags, target) + oled = ram.requirements("NRF52_PLATFORM", {**flags, "DISPLAY_CLASS": "SH1106Display"}, target) + self.assertEqual(policy["components"], oled["components"]) + self.assertEqual(policy["components"]["display_pixels_and_driver"], 4096) + self.assertEqual(policy["components"]["allocation_and_transient_margin"], 4096) + for platform, changed in (("ESP32_PLATFORM", flags), + ("NRF52_PLATFORM", {**flags, "WIO_L1_DISPLAY_UNIFIED": 0})): + with self.subTest(platform=platform, flags=changed), self.assertRaises(ValueError): + ram.requirements(platform, changed, target) + def test_device_encoder_budget_matches_storage_and_role_gates(self): matrix = [ ("ESP32_PLATFORM", {"ENABLE_OTA": 1, "MESHCORE_OTA_DEVICE_DEFLATE": 1}, "v4_repeater", True), diff --git a/test/test_headless_kiss_builds.py b/test/test_headless_kiss_builds.py new file mode 100644 index 000000000..c9ba893d6 --- /dev/null +++ b/test/test_headless_kiss_builds.py @@ -0,0 +1,145 @@ +"""Headless KISS compatibility across optional R8 and Wio L1 displays.""" +import configparser +import os +from pathlib import Path +import re +import shutil +import subprocess +import tempfile +import unittest + + +ROOT = Path(__file__).resolve().parents[1] +PAIRS = ( + ("heltec_v4_r8_kiss_modem", "heltec_v4_r8_tft_kiss_modem"), + ("WioTrackerL1_kiss_modem", "WioTrackerL1Eink_kiss_modem"), +) + + +class HeadlessKissBuildTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.config = configparser.ConfigParser(interpolation=None, strict=False, + inline_comment_prefixes=(";",)) + cls.config.read([ROOT / "platformio.ini", + ROOT / "variants/heltec_v4_r8/platformio.ini", + ROOT / "variants/wio-tracker-l1/platformio.ini", + ROOT / "variants/wio-tracker-l1-eink/platformio.ini"]) + + def option(self, section, key): + options = self.config[section] + if key not in options: + for parent in options.get("extends", "").split(","): + if parent.strip(): + value = self.option(parent.strip(), key) + if value is not None: + return value + return None + return re.sub(r"\$\{([^}.]+)\.([^}]+)\}", + lambda m: self.option(m[1], m[2]) or "", options[key]) + + def flags(self, name): + return dict((m[1], m[2] or "1") for m in re.finditer( + r"-D\s*([A-Za-z_][A-Za-z0-9_]*)(?:=([^\s]+))?", + self.option("env:" + name, "build_flags"))) + + def test_both_historical_names_remain_buildable_without_a_display(self): + for pair in PAIRS: + for name in pair: + with self.subTest(name=name): + flags = self.flags(name) + self.assertEqual(flags["MESHCORE_HEADLESS_KISS_COMPAT"], "1") + self.assertEqual(flags["RADIO_CLASS"], "CustomSX1262") + self.assertNotIn("DISPLAY_CLASS", flags) + sources = self.option("env:" + name, "build_src_filter") + self.assertIn("+<../examples/kiss_modem/>", sources) + self.assertNotIn("../examples/companion_radio", sources) + + def test_pairs_keep_identical_radio_settings_and_boot_layouts(self): + for shared, legacy in PAIRS: + with self.subTest(shared=shared): + shared_flags, legacy_flags = self.flags(shared), self.flags(legacy) + for key in ("USE_SX1262", "RADIO_CLASS", "WRAPPER_CLASS", "LORA_TX_POWER", + "P_LORA_NSS", "P_LORA_RESET", "P_LORA_BUSY", "P_LORA_DIO_1", + "P_LORA_SCLK", "P_LORA_MOSI", "P_LORA_MISO", + "SX126X_DIO3_TCXO_VOLTAGE", "SX126X_CURRENT_LIMIT", + "ENV_INCLUDE_GPS", "ENV_PIN_SDA", "ENV_PIN_SCL"): + self.assertEqual(shared_flags.get(key), legacy_flags.get(key), key) + for key in ("board", "board_build.ldscript", "board_upload.maximum_size"): + self.assertEqual(self.option("env:" + shared, key), + self.option("env:" + legacy, key), key) + + def test_capability_evidence_is_retained_from_the_board_startup(self): + for variant, board, kind, call in ( + ("heltec_v4_r8", "HeltecV4R8Board", "heltec_r8", "parkHeltecR8KissDisplayPins"), + ("wio-tracker-l1", "WioTrackerL1Board", "wio_l1", "parkWioL1KissDisplayPins"), + ): + with self.subTest(variant=variant): + source = (ROOT / "variants" / variant / (board + ".cpp")).read_text() + self.assertIn("meshcore.capability.modem.headless." + kind + ".v1", source) + begin = source.split("void " + board + "::begin()", 1)[1].split("\n}", 1)[0] + self.assertIn("const volatile char* capability", begin) + self.assertIn("(void)*capability", begin) + self.assertIn(call + "();", begin) + + def test_real_pin_parking_deselects_displays_without_touching_radio(self): + compiler = os.environ.get("CXX") or shutil.which("g++") or shutil.which("clang++") + self.assertIsNotNone(compiler, "host C++ compiler required") + with tempfile.TemporaryDirectory(prefix="headless-kiss-pins-") as directory: + work = Path(directory) + (work / "WVariant.h").write_text("#pragma once\n", encoding="ascii") + (work / "Arduino.h").write_text(r''' +#pragma once +#include +#include +#include +constexpr int LOW = 0, HIGH = 1, OUTPUT = 2; +struct Event { int pin, level; bool output; }; +inline std::vector events; +inline std::map levels; +inline void digitalWrite(int pin, int level) { + events.push_back({pin, level, false}); levels[pin] = level; +} +inline void pinMode(int pin, int mode) { + assert(mode == OUTPUT); + assert(levels.count(pin)); // The intended level must be loaded first. + events.push_back({pin, levels.at(pin), true}); +} +''', encoding="ascii") + source = work / "pins.cpp" + source.write_text(r''' +#include +#include "variants/wio-tracker-l1/variant.h" +#include "variants/heltec_v4_r8/HeadlessKissPins.h" +#include "variants/wio-tracker-l1/HeadlessKissPins.h" +void check(const std::map& expected) { + assert(levels == expected); + assert(events.size() == expected.size() * 2); + for (std::size_t i = 0; i < events.size(); i += 2) { + assert(!events[i].output && events[i+1].output); + assert(events[i].pin == events[i+1].pin); + assert(events[i].level == events[i+1].level); + } + events.clear(); levels.clear(); +} +int main() { + for (int pass = 0; pass < 2; ++pass) { + parkHeltecR8KissDisplayPins(); + check({{44, LOW}, {47, HIGH}, {3, HIGH}, {21, LOW}}); + parkWioL1KissDisplayPins(); + check({{36, HIGH}, {32, LOW}}); + } +} +''', encoding="ascii") + executable = work / ("pins.exe" if os.name == "nt" else "pins") + result = subprocess.run([compiler, "-std=c++17", "-Wall", "-Wextra", "-Werror", + "-I" + str(work), "-I" + str(ROOT), + str(source), "-o", str(executable)], + capture_output=True, text=True, timeout=60) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + result = subprocess.run([str(executable)], capture_output=True, text=True, timeout=10) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_indicator_exclusive_transport.py b/test/test_indicator_exclusive_transport.py index 952bf20a3..83178f9af 100644 --- a/test/test_indicator_exclusive_transport.py +++ b/test/test_indicator_exclusive_transport.py @@ -175,7 +175,11 @@ profile_start = build.index("\napply_companion_radio_full_profile() {") profile_end = build.index("\napply_radio_overrides()", profile_start) profile = build[profile_start:profile_end] flag = "-DCOMPANION_EXCLUSIVE_WIFI_BLE=1" -assert profile.count(flag) == 1 +assert profile.count(flag) == 2 +cardputer_start = profile.index(" m5stack_cardputer_adv_companion_radio_full)") +cardputer_case = profile[cardputer_start:profile.index(";;", cardputer_start)] +assert flag in cardputer_case +assert "-DOTA_SHARED_COMPANION_QUEUE=1" in cardputer_case exclusive_policy = profile.index( "# Both Indicator Full layouts select exactly one secondary" ) diff --git a/test/test_picker_controls_generator.py b/test/test_picker_controls_generator.py index 12170c667..7101172c4 100644 --- a/test/test_picker_controls_generator.py +++ b/test/test_picker_controls_generator.py @@ -13,6 +13,13 @@ SPEC.loader.exec_module(GENERATOR) class PickerControlsTests(unittest.TestCase): + def test_cardputer_full_explains_wireless_selection_and_shared_queue(self): + note = GENERATOR.capacity_note([ + 'Cardputer Full: USB plus selected WiFi or Bluetooth; 256 offline frames normally, 128 during mOTA'], {}) + for text in ('USB remains available', 'set companion.transport wifi|ble', 'reboot', + '350 contacts', '40 channels', '256 queued messages', '128 queue slots'): + self.assertIn(text, note) + def test_shared_radio_keeps_supported_runtime_rxps_control(self): shared = dict(RADIO_CLASS='UniversalLoRaRadio', MESH_RADIO_AUTO_FAMILY='1', USE_SX1262='1') self.assertTrue(GENERATOR.has_rxps_control(shared)) diff --git a/test/test_shared_mota_queue.py b/test/test_shared_mota_queue.py index 3f8a0188b..5cfc204aa 100644 --- a/test/test_shared_mota_queue.py +++ b/test/test_shared_mota_queue.py @@ -18,9 +18,12 @@ class SharedMotaQueueTest(unittest.TestCase): self.run_transfer_test("ESP32_PLATFORM") def test_c3_rtc_retained_queue_preserves_real_ota_and_wifi_transfers(self): - self.run_transfer_test("ESP32_PLATFORM", rtc=True) + self.run_transfer_test("ESP32_PLATFORM", rtc="ESP32C3") - def run_transfer_test(self, platform, rtc=False): + def test_s3_both_rtc_banks_preserve_real_ota_and_wifi_transfers(self): + self.run_transfer_test("ESP32_PLATFORM", rtc="ESP32S3") + + def run_transfer_test(self, platform, rtc=None): with tempfile.TemporaryDirectory(prefix="meshcore-mota-queue-") as temp: binary = Path(temp) / "transfer" tinf = Path(temp) / "tinf.o" @@ -48,27 +51,34 @@ class SharedMotaQueueTest(unittest.TestCase): "src/helpers/ota/MotaSourceSerial.cpp", "src/helpers/ota/FolderMotaStore.cpp"] if rtc: + capacity = 224 if rtc == "ESP32C3" else 256 + slow = 0 if rtc == "ESP32C3" else 32 fixture = ROOT / "test/fixtures/shared_mota_queue" source = (fixture / "test_shared_mota_queue.cpp").read_text() source = source[:source.index(" Queue full;")] + "}\n" - source = source.replace("256", "224") + source = source.replace("256", str(capacity)) source = source.replace("struct Queue {", ( "#include \n" - "alignas(4) RTC_FAST_ATTR Frame rtc_frames[32];\nstruct Queue {")) + "alignas(4) RTC_FAST_ATTR Frame rtc_frames[32];\n" + + ("alignas(4) RTC_SLOW_ATTR Frame rtc_slow_frames[32];\n" if slow else "") + + "struct Queue {")) source = source.replace( - "mesh::BorrowableFrameBuffer buffer;", - "mesh::BorrowableFrameBuffer buffer;", + f"mesh::BorrowableFrameBuffer> buffer;\n" - " Queue() : buffer(rtc_frames, nullptr) {}") + + " Queue() : buffer(rtc_frames, " + + ("rtc_slow_frames" if slow else "nullptr") + ") {}") (Path(temp) / "test_shared_rtc.cpp").write_text(source) (Path(temp) / "test_wifi_shared_queue.h").write_text( - (fixture / "test_wifi_shared_queue.h").read_text().replace("256", "224")) + (fixture / "test_wifi_shared_queue.h").read_text().replace("256", str(capacity))) (Path(temp) / "sdkconfig.h").write_text("#pragma once\n") (Path(temp) / "esp_attr.h").write_text( - '#pragma once\n#define RTC_FAST_ATTR __attribute__((section(".rtc.force_fast.test")))\n') + '#pragma once\n#define RTC_FAST_ATTR __attribute__((section(".rtc.force_fast.test")))\n' + '#define RTC_SLOW_ATTR __attribute__((section(".rtc.force_slow.test")))\n') sources[0] = str(Path(temp) / "test_shared_rtc.cpp") flags += ["-DCOMPANION_RTC_FAST_OFFLINE_FRAMES=32", - "-DCONFIG_IDF_TARGET_ESP32C3=1", + f"-DCOMPANION_RTC_SLOW_OFFLINE_FRAMES={slow}", + f"-DCONFIG_IDF_TARGET_{rtc}=1", "-DCONFIG_ESP_SYSTEM_ALLOW_RTC_FAST_MEM_AS_HEAP=1", "-I", str(fixture)] built = subprocess.run([ diff --git a/test/test_wio_l1_display_detection.py b/test/test_wio_l1_display_detection.py new file mode 100644 index 000000000..ffd404895 --- /dev/null +++ b/test/test_wio_l1_display_detection.py @@ -0,0 +1,361 @@ +"""Exercise the real L1 display selector and SSD1680 readback on the host.""" +from pathlib import Path +import configparser +import os +import subprocess +import tempfile +import unittest + +from test_techo_lite_display_detection import ARDUINO as GPIO, PANEL, SPI, SAN +import test_companion_inbox as inbox_fixture +from test_replay_reset_integration import extract_braced + +ROOT = Path(__file__).resolve().parents[1] +UI = ROOT / "src/helpers/ui" + +ARDUINO = GPIO.replace( + '''#if defined(LILYGO_TECHO_LITE) && defined(TECHO_LITE_DISPLAY_AUTODETECT) && TECHO_LITE_DISPLAY_AUTODETECT == 1 + return true; +#else + return false; +#endif''', 'return true;').replace("inline void pinMode(", "inline void epdPinMode(").replace( + "inline void digitalWrite(", "inline void epdDigitalWrite(").replace( + "inline int digitalRead(", "inline int epdDigitalRead(") + r''' +#define PIN_SPI1_MOSI DISP_MOSI +#define PIN_SPI1_SCK DISP_SCLK +#define DISPLAY_ADDRESS 0x3D +#define PIN_WIRE_SDA 14 +#define PIN_WIRE_SCL 15 +inline unsigned oled_constructs=0, oled_inits=0, oled_allocs=0, oled_frees=0, oled_draws=0; +inline unsigned eink_constructs=0; +inline bool wire_running=true, i2c_transaction=false; +inline unsigned wire_ends=0, wire_begins=0, i2c_clocks=0, i2c_command=0; +inline bool i2cPin(int pin) { return pin==PIN_WIRE_SDA || pin==PIN_WIRE_SCL; } +inline bool oledAt(unsigned address) { + return (address==0x3d && (scenario=="oled" || scenario=="oled-fail" || scenario=="both")) + || (address==0x3c && scenario=="oled-alt"); +} +inline void pinMode(int pin,int mode) { + if(!i2cPin(pin)) { epdPinMode(pin,mode); return; } + assert(!wire_running && "release Wire before GPIO probe"); ++events; + assert(mode==INPUT_PULLUP || (mode==OUTPUT && levels[pin]==LOW)); + const int previous=modes[pin]; modes[pin]=mode; + if(pin==PIN_WIRE_SDA && mode==OUTPUT && modes[PIN_WIRE_SCL]==INPUT_PULLUP) { + i2c_transaction=true; i2c_clocks=i2c_command=0; + } + if(pin==PIN_WIRE_SCL && mode==INPUT_PULLUP && previous==OUTPUT && i2c_transaction) { + ++i2c_clocks; + if(i2c_clocks<=8) i2c_command=(i2c_command<<1)|(modes[PIN_WIRE_SDA]==INPUT_PULLUP ? 1u : 0u); + } + if(pin==PIN_WIRE_SDA && mode==INPUT_PULLUP && modes[PIN_WIRE_SCL]==INPUT_PULLUP && i2c_clocks>=10) + i2c_transaction=false; +} +inline void digitalWrite(int pin,int value) { + if(!i2cPin(pin)) { epdDigitalWrite(pin,value); return; } + assert(!wire_running && value==LOW && "I2C GPIO must never drive HIGH"); + levels[pin]=value; ++events; +} +inline int digitalRead(int pin) { + if(!i2cPin(pin)) return epdDigitalRead(pin); + assert(!wire_running && modes[pin]==INPUT_PULLUP); + if(pin==PIN_WIRE_SCL) return scenario=="scl-stuck" ? LOW : HIGH; + if(scenario=="sda-stuck") return LOW; + if(i2c_transaction && i2c_clocks==9) { + assert((i2c_command&1)==0 && "OLED probe uses a write address"); + return oledAt(i2c_command>>1) ? LOW : HIGH; + } + return HIGH; +} +''' + +WIRE = r''' +#pragma once +#include +struct TwoWire { + unsigned probes=0; uint8_t address=0; + void end() { assert(wire_running); wire_running=false; ++wire_ends; } + void begin() { assert(!wire_running); wire_running=true; ++wire_begins; } + void beginTransmission(uint8_t value) { address=value; ++probes; } + uint8_t endTransmission() { + assert(wire_running); return oledAt(address) ? 0 : 2; + } +}; +inline TwoWire Wire; +''' + +OLED = r''' +#pragma once +#include +#include +#define SH110X_BLACK 0 +#define SH110X_WHITE 1 +#define SH110X_DISPLAYON 0xAF +#define SH110X_DISPLAYOFF 0xAE +struct Adafruit_I2CDevice {}; +struct Adafruit_SH1106G { + Adafruit_I2CDevice* i2c_dev=nullptr; + uint8_t* buffer=nullptr; + Adafruit_SH1106G(int,int,TwoWire*,int) { ++oled_constructs; } + ~Adafruit_SH1106G() { if(buffer) { delete[] buffer; ++oled_frees; } delete i2c_dev; } + bool begin(uint8_t address,bool) { + ++oled_inits; + assert((scenario=="oled-alt" ? address==0x3c : address==0x3d)); + if(scenario=="oled-fail") return false; + assert(!buffer); buffer=new uint8_t[1024]; ++oled_allocs; + i2c_dev=new Adafruit_I2CDevice; + return true; + } + void oled_command(int) { assert(buffer); } + void clearDisplay() { assert(buffer); ++oled_draws; } + void display() { assert(buffer); ++oled_draws; } + void setTextColor(int color) { assert(buffer && (color==0 || color==1)); } + void setTextSize(int) { assert(buffer); } + void cp437(bool) { assert(buffer); } + void setCursor(int,int) { assert(buffer); } + void print(const char*) { assert(buffer); ++oled_draws; } + void fillRect(int,int,int,int,int) { assert(buffer); ++oled_draws; } + void drawRect(int,int,int,int,int) { assert(buffer); ++oled_draws; } + void drawBitmap(int,int,const uint8_t*,int,int,int) { assert(buffer); ++oled_draws; } + void getTextBounds(const char* str,int,int,int16_t* x,int16_t* y,uint16_t* w,uint16_t* h) { + assert(buffer); *x=*y=0; *w=strlen(str)*6; *h=8; + } +}; +''' + +MAIN = r''' +#include "WioL1Display.h" +#include "WioL1DisplayLayout.h" +void frame(WioL1Display& display) { + const uint8_t bitmap[]={0x80}; + display.clear(); display.startFrame(); display.setTextSize(1); + display.setColor(UIColor::primary_txt); display.setCursor(1,2); display.print("test"); + display.fillRect(1,2,3,4); display.drawRect(1,2,3,4); + display.drawXbm(1,2,bitmap,1,1); (void)display.getTextWidth("test"); display.endFrame(); +} +int main(int argc,char** argv) { + assert(argc==2); scenario=argv[1]; + assert(mesh::ui::wio_l1::visibleRows(64,18,10,12,6)==4); + assert(mesh::ui::wio_l1::visibleRows(122,29,22,22,6)==4); + assert(mesh::ui::wio_l1::visibleRows(64,60,10,12,6)==0); + for(int height:{40,64,122,250}) for(int top:{0,18,29,60}) + for(int line:{10,12,22}) for(int limit:{0,1,4,6}) { + const int rows=mesh::ui::wio_l1::visibleRows(height,top,line,line,limit); + assert(rows>=0 && rows<=limit); + for(int row=0;rowheight); + } + const bool oled=scenario=="oled" || scenario=="oled-alt" || scenario=="both"; + const bool invalid=scenario=="floating" || scenario=="high" || scenario=="low" + || scenario=="wrong-id" || scenario=="reserved" || scenario=="biased" + || scenario=="noise" || scenario=="busy-high" || scenario=="busy-low" + || scenario=="busy-floating" || scenario=="oled-fail"; + { + WioL1Display display; + frame(display); display.turnOff(); display.forceFullRefresh(); + assert(events==0 && Wire.probes==0 && oled_constructs==0 && eink_constructs==0); + auto selected=display.detect(); auto events_after_probe=events, probes=Wire.probes; + assert(wire_running && wire_ends==1 && wire_begins==1); + assert(display.detect()==selected && events==events_after_probe && Wire.probes==probes); + assert(oled_constructs==0 && eink_constructs==0 && "probe must not allocate backend storage"); + if(oled) { + assert(selected==WioL1Display::Type::Oled && !display.isEink()); + assert(display.defaultTimeoutSeconds()==15 && commands==0 && resets==0); + } else if(!invalid) { + assert(selected==WioL1Display::Type::Eink && display.isEink()); + assert(display.defaultTimeoutSeconds()==900); + } + bool ready=display.begin(); + if(invalid) { + assert(!ready && !display.isOn() && display.selectedType()==WioL1Display::Type::None); + assert(inits==0 && spi_begins==0 && draws==0 && oled_allocs==0); + if(scenario!="oled-fail") { + assert(oled_constructs==0 && eink_constructs==0 && levels[DISP_RST]==LOW); + for(int pin:{DISP_CS,DISP_DC,DISP_MOSI,DISP_SCLK,DISP_BUSY}) assert(modes[pin]==INPUT); + } + auto count=events; display.turnOn(); display.turnOff(); frame(display); + assert(!display.begin() && events==count && draws==0 && oled_draws==0 && now_us<500000); + } else { + assert(ready); + display.turnOn(); assert(display.isOn()); + assert(display.width()==(oled ? 128 : 250) && display.height()==(oled ? 64 : 122)); + assert(UIColor::window_bkg==(oled ? 0 : 0xffff)); + assert(UIColor::primary_txt==(oled ? 1 : 0)); + assert(oled_constructs==(oled?1u:0u) && eink_constructs==(oled?0u:1u)); + assert(oled_allocs==(oled?1u:0u)); + auto commands_before=commands, resets_before=resets; + for(int i=0;i<3;++i) { frame(display); display.turnOff(); } + auto painted=draws+oled_draws; frame(display); assert(draws+oled_draws==painted); + display.turnOn(); frame(display); assert(display.isOn() && draws+oled_draws>painted); + assert(display.begin() && commands==commands_before && resets==resets_before); + assert(oled_inits==(oled?1u:0u) && inits==(oled?0u:1u)); + } + } + assert(oled_frees==oled_allocs); +} +''' + + +class WioL1DisplayDetectionTest(unittest.TestCase): + def build(self, work, mutation=None): + work.mkdir() + (work / "Fonts").mkdir() + for name in ("WioL1Display.h", "WioL1Display.cpp", "WioL1DisplayProbe.h", + "SH1106Display.h", "SH1106Display.cpp", "GxEPDDisplay.h", "GxEPDDisplay.cpp"): + source = (UI / name).read_text() + if mutation and name == "WioL1DisplayProbe.h": + old, new = mutation + self.assertIn(old, source) + source = source.replace(old, new) + (work / name).write_text(source) + panel = PANEL.replace("WIDTH=176,HEIGHT=192", "WIDTH=122,HEIGHT=250") + panel = panel.replace("GxEPD2_122_T61(T...) {}", "GxEPD2_122_T61(T...) { ++eink_constructs; }") + panel += "\nusing GxEPD2_213_B74=GxEPD2_122_T61;\n" + files = { + "Arduino.h": ARDUINO, "Wire.h": WIRE, "SPI.h": SPI, + "GxEPD2_BW.h": panel, "Adafruit_SH110X.h": OLED, + "Adafruit_GrayOLED.h": "#pragma once\n", + "Adafruit_GFX.h": "#pragma once\n", "main.cpp": MAIN, + "CRC32.h": "#pragma once\n#include \n#include \nstruct CRC32 { template void update(T) {} template void update(const T*,size_t) {} void reset() {} uint32_t finalize() { return 1; } };\n", + } + for name in ("GxEPD2_3C.h", "GxEPD2_4C.h", "GxEPD2_7C.h"): + files[name] = "#pragma once\n" + for font in ("FreeSans9pt7b", "FreeSansBold12pt7b", "FreeSans18pt7b"): + files[f"Fonts/{font}.h"] = f"#pragma once\ninline const int {font}=8;\n" + for name, source in files.items(): + (work / name).write_text(source) + binary = work / "native" + result = subprocess.run([ + os.environ.get("CXX", "g++"), "-std=c++17", "-Wall", "-Wextra", "-Werror", + "-Wno-unused-parameter", "-Wno-sign-compare", *SAN, + "-DDISPLAY_CLASS=WioL1Display", "-DWIO_L1_DISPLAY_UNIFIED=1", + "-DEINK_DISPLAY_MODEL=GxEPD2_213_B74", "-DDISPLAY_ROTATION=1", + "-DEINK_MAX_PARTIAL_REFRESH=10", "-I" + str(work), "-I" + str(UI), + "-I" + str(ROOT / "src"), str(work / "main.cpp"), + *[str(work / name) for name in ("WioL1Display.cpp", "GxEPDDisplay.cpp", "SH1106Display.cpp")], + "-o", str(binary)], capture_output=True, text=True, timeout=60) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + return binary + + def run_case(self, binary, scenario, negative=None): + result = subprocess.run([str(binary), scenario], capture_output=True, text=True, timeout=10) + if negative: + self.assertNotEqual(result.returncode, 0) + self.assertIn(negative, result.stderr) + else: + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + def test_actual_selector_gpio_readback_and_headless_paths(self): + with tempfile.TemporaryDirectory(prefix="wio-l1-display-") as temp: + binary = self.build(Path(temp) / "native") + for marker in ("wio_l1_unified", "autodetect.wio_l1"): + self.assertIn(("meshcore.capability.display." + marker + ".v1").encode(), binary.read_bytes()) + for scenario in ("oled", "oled-alt", "oled-fail", "both", "valid", "flags", "prime", + "recover", "floating", "high", "low", "wrong-id", "reserved", + "biased", "noise", "busy-high", "busy-low", "busy-floating", + "scl-stuck", "sda-stuck"): + with self.subTest(scenario=scenario): + self.run_case(binary, scenario) + + def test_negative_controls_reject_wrong_identity_and_turnaround(self): + with tempfile.TemporaryDirectory(prefix="wio-l1-display-") as temp: + for name, mutation, scenario, failure in ( + ("id", ("status[0] == 0x01u", "status[0] == 0x02u"), "wrong-id", "!ready"), + ("settle", ("delay(10);", "delay(0);"), "valid", "reset settle before commands"), + ("turnaround", ("pinMode(PIN_SPI1_MOSI, bias);", "pinMode(PIN_SPI1_MOSI, OUTPUT);"), + "valid", "release SDA before controller read falling edge"), + ("i2c-drive", ("digitalWrite(pin, LOW);", "digitalWrite(pin, HIGH);"), + "oled", "I2C GPIO must never drive HIGH"), + ("i2c-address", ("(address << 1) & mask", "address & mask"), + "oled", "OLED probe uses a write address")): + binary = self.build(Path(temp) / name, mutation) + self.run_case(binary, scenario, negative=failure) + + def test_recipe_keeps_capacities_and_legacy_display_specific_targets(self): + config = configparser.ConfigParser(interpolation=None, inline_comment_prefixes=(";",)) + config.read(ROOT / "variants/wio-tracker-l1/platformio.ini") + combined = config["env:WioTrackerL1_companion_radio_ble"] + for flag in ("DISPLAY_CLASS=WioL1Display", "WIO_L1_DISPLAY_UNIFIED=1", + "MAX_CONTACTS=350", "MAX_GROUP_CHANNELS=40", "OFFLINE_QUEUE_SIZE=256"): + self.assertIn(flag, combined["build_flags"]) + for source in ("WioL1Display.cpp", "GxEPDDisplay.cpp", "SerialBLEInterface.cpp"): + self.assertIn(source, combined["build_src_filter"]) + self.assertIn("DISPLAY_CLASS=SH1106Display", config["env:WioTrackerL1_companion_radio_usb"]["build_flags"]) + config.read(ROOT / "variants/wio-tracker-l1-eink/platformio.ini") + self.assertIn("DISPLAY_CLASS=GxEPDDisplay", config["env:WioTrackerL1Eink_companion_radio_ble"]["build_flags"]) + + def test_real_message_footer_and_hibernate_page_follow_selected_panel(self): + ui = (ROOT / "examples/companion_radio/ui-new/UITask.cpp").read_text() + preamble = ("#define WIO_L1_DISPLAY_UNIFIED 1\n#define UI_MESSAGE_CHANNEL_FOOTER 1\n" + "#define UI_BUTTON_READER_HINT 0\n" + inbox_fixture.PREAMBLE) + preamble = preamble.replace("bool on=true, compact=false;", r''' + bool on=true, compact=false, eink=false; + bool isEink() override { return eink; } + void selectEink(bool selected) { eink=selected; setDimensions(selected?250:128,selected?122:64); } +''') + implementation = extract_braced(ui, "class MsgPreviewScreen :") + ";\n" + implementation += "Display display;\nstruct HomeScreen { enum HomePage { RADIO2,RADIO_STATUS,SHUTDOWN,FIRST };\n" + implementation += extract_braced(ui, "bool isPageVisible(uint8_t page) const {") + "\n};\n" + scenario = r''' +int main() { + UITask task(display); MsgPreviewScreen messages(&task); HomeScreen home; + for(bool eink:{false,true}) { + display.selectEink(eink); display.clear(); messages.render(display); + assert(display.contains("All channels")==eink); + assert(home.isPageVisible(HomeScreen::SHUTDOWN)==eink); + assert(home.isPageVisible(HomeScreen::FIRST)); + assert(!home.isPageVisible(HomeScreen::RADIO2)); + the_mesh.dual_radio_active=true; + assert(home.isPageVisible(HomeScreen::RADIO2)); + the_mesh.dual_radio_active=false; + } +} +''' + inbox_fixture.CompanionInboxTest.compile_and_run(self, preamble + implementation + scenario) + + def test_actual_radio_start_skips_stuck_optional_rtc_bus_only_when_unified(self): + target = (ROOT / "variants/wio-tracker-l1/target.cpp").read_text() + implementation = extract_braced(target, "bool radio_init()") + source = r''' +#include +#define PIN_WIRE_SDA 14 +#define PIN_WIRE_SCL 15 +#define HIGH 1 +int sda=1,scl=1,reads=0,Wire=0,SPI=0; +int digitalRead(int pin) { ++reads; return pin==PIN_WIRE_SDA ? sda : scl; } +struct Clock { int probes=0; void begin(int&) { ++probes; } } rtc_clock; +struct Radio { int starts=0; bool result=true; bool std_init(int*) { ++starts; return result; } } radio; +''' + implementation + r''' +int main() { + for(int data:{0,1}) for(int clock:{0,1}) for(bool success:{false,true}) { + sda=data; scl=clock; radio.result=success; + const int before=rtc_clock.probes, starts=radio.starts; + assert(radio_init()==success && radio.starts==starts+1); +#if defined(WIO_L1_DISPLAY_UNIFIED) && WIO_L1_DISPLAY_UNIFIED + assert(rtc_clock.probes==before+(data && clock)); +#else + assert(rtc_clock.probes==before+1); +#endif + } +#if defined(WIO_L1_DISPLAY_UNIFIED) && WIO_L1_DISPLAY_UNIFIED + assert(reads>0); +#else + assert(reads==0); +#endif +} +''' + # The local initializer lists belong to this standalone harness only. + source = "#include \n" + source + with tempfile.TemporaryDirectory(prefix="wio-l1-rtc-") as temp: + for label, flags in (("unified", ["-DWIO_L1_DISPLAY_UNIFIED=1"]), + ("legacy", []), ("disabled", ["-DWIO_L1_DISPLAY_UNIFIED=0"])): + binary = Path(temp) / label + result = subprocess.run([os.environ.get("CXX", "g++"), "-std=c++17", + "-Wall", "-Wextra", "-Werror", *SAN, *flags, "-x", "c++", "-", + "-o", str(binary)], input=source, capture_output=True, text=True, timeout=60) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + result = subprocess.run([str(binary)], capture_output=True, text=True, timeout=10) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_wio_tracker_identity.py b/test/test_wio_tracker_identity.py index a02a4d23e..ad7b16274 100644 --- a/test/test_wio_tracker_identity.py +++ b/test/test_wio_tracker_identity.py @@ -41,7 +41,7 @@ class NRF52BoardDCDC : virtual public NRF52Board {}; class WioTrackerIdentityTests(unittest.TestCase): - def check_header(self, variant, expected, *, eink=False): + def check_header(self, variant, expected, *, eink=False, unified=False): compiler = os.environ.get("CXX") or shutil.which("g++") or shutil.which("clang++") if not compiler: self.skipTest("C++ compiler unavailable") @@ -65,7 +65,7 @@ void WioTrackerL1Board::begin() {{}} int main() {{ const WioTrackerL1Board board; assert(std::strcmp(board.getManufacturerName(), "{expected}") == 0); - assert(std::strcmp(board.getManufacturerName(), "Seeed Wio Tracker L1") != 0); + assert((std::strcmp(board.getManufacturerName(), "Seeed Wio Tracker L1") == 0) == {str(unified).lower()}); }} ''' cpp.write_text(source, encoding="ascii") @@ -73,6 +73,8 @@ int main() {{ "-I", str(folder), "-I", str(ROOT)] if eink: command.append("-DWIO_TRACKER_L1_EINK") + if unified: + command.append("-DWIO_L1_DISPLAY_UNIFIED=1") result = subprocess.run(command + [str(cpp), "-o", str(executable)], capture_output=True, text=True, timeout=60) self.assertEqual(result.returncode, 0, result.stdout + result.stderr) @@ -91,6 +93,9 @@ int main() {{ def test_one_watt_reports_the_distinct_fem_power_profile(self): self.check_header("wio-tracker-l1-1w", "Seeed Wio Tracker L1 1W") + def test_unified_companion_reports_the_shared_physical_family(self): + self.check_header("wio-tracker-l1", "Seeed Wio Tracker L1", unified=True) + if __name__ == "__main__": unittest.main() diff --git a/tools/mota/test_mota.py b/tools/mota/test_mota.py index 7595d40b2..4b402e450 100644 --- a/tools/mota/test_mota.py +++ b/tools/mota/test_mota.py @@ -521,6 +521,7 @@ def test_measured_full_companion_promotions_are_exact_and_bounded(): expected = [ "M5Stack_Unit_C6L_companion_radio_ble", + "M5Stack_Cardputer_Adv_companion_radio_ble", "Heltec_Wireless_Tracker_companion_radio_ble", "LilyGo_T3S3_sx1276_companion_radio_ble", "Heltec_ct62_companion_radio_ble", diff --git a/variants/heltec_v4_r8/HeadlessKissPins.h b/variants/heltec_v4_r8/HeadlessKissPins.h new file mode 100644 index 000000000..6837f33d3 --- /dev/null +++ b/variants/heltec_v4_r8/HeadlessKissPins.h @@ -0,0 +1,17 @@ +#pragma once + +#include + +inline void parkHeltecR8KissDisplayPins() { + // These optional Expansion Kit pins are unused by the headless modem. + // Preload the output levels before enabling them to avoid a backlight pulse + // or selecting the LCD / microSD during startup. + digitalWrite(44, LOW); // LCD backlight, active high + pinMode(44, OUTPUT); + digitalWrite(47, HIGH); // LCD chip select + pinMode(47, OUTPUT); + digitalWrite(3, HIGH); // Expansion Kit V2 microSD chip select + pinMode(3, OUTPUT); + digitalWrite(21, LOW); // Shared LCD/touch or OLED reset + pinMode(21, OUTPUT); +} diff --git a/variants/heltec_v4_r8/HeltecV4R8Board.cpp b/variants/heltec_v4_r8/HeltecV4R8Board.cpp index 9197c3f43..3c9189f88 100644 --- a/variants/heltec_v4_r8/HeltecV4R8Board.cpp +++ b/variants/heltec_v4_r8/HeltecV4R8Board.cpp @@ -1,6 +1,12 @@ #include "HeltecV4R8Board.h" #include +#if MESHCORE_HEADLESS_KISS_COMPAT + #include "HeadlessKissPins.h" + extern const char meshcore_capability_modem_headless_heltec_r8[] = + "meshcore.capability.modem.headless.heltec_r8.v1"; +#endif + // Expansion Kit V2 control-pin notes (kept here beside the shared-reset // sequencing): display/touch I2C is SDA 17 / SCL 18; GPIO 21 is the shared // LCD_RESET / TP_RESET line; GPIO 44 drives the active-high backlight. The @@ -10,6 +16,12 @@ void HeltecV4R8Board::begin() { ESP32Board::begin(); +#if MESHCORE_HEADLESS_KISS_COMPAT + const volatile char* capability = meshcore_capability_modem_headless_heltec_r8; + (void)*capability; + parkHeltecR8KissDisplayPins(); +#endif + periph_power.begin(); periph_power.claim(); // R8 VEXT also feeds the LoRa antenna boost rail. @@ -88,7 +100,9 @@ bool HeltecV4R8Board::isExternalPowered() { } const char* HeltecV4R8Board::getManufacturerName() const { -#ifdef HELTEC_V4_R8_TFT +#if MESHCORE_HEADLESS_KISS_COMPAT + return "Heltec V4 R8"; +#elif defined(HELTEC_V4_R8_TFT) return "Heltec V4 R8 TFT"; #else return "Heltec V4 R8 OLED"; diff --git a/variants/heltec_v4_r8/platformio.ini b/variants/heltec_v4_r8/platformio.ini index 9e06180a6..b89bf3271 100644 --- a/variants/heltec_v4_r8/platformio.ini +++ b/variants/heltec_v4_r8/platformio.ini @@ -560,10 +560,14 @@ lib_deps = [env:heltec_v4_r8_kiss_modem] extends = Heltec_v4_r8 +build_flags = ${Heltec_v4_r8.build_flags} + -D MESHCORE_HEADLESS_KISS_COMPAT=1 build_src_filter = ${Heltec_v4_r8.build_src_filter} +<../examples/kiss_modem/> [env:heltec_v4_r8_tft_kiss_modem] extends = heltec_v4_r8_tft +build_flags = ${heltec_v4_r8_tft.build_flags} + -D MESHCORE_HEADLESS_KISS_COMPAT=1 build_src_filter = ${heltec_v4_r8_tft.build_src_filter} +<../examples/kiss_modem/> diff --git a/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/CardputerUITask.cpp b/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/CardputerUITask.cpp index f01a75b81..6306113ab 100644 --- a/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/CardputerUITask.cpp +++ b/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/CardputerUITask.cpp @@ -8,6 +8,7 @@ #include "target.h" #include "helpers.h" #include "CompanionBluetooth.h" +#include "CompanionWiFi.h" #include #include @@ -432,6 +433,19 @@ int CardputerUITask::drawModifierBadges(CardputerDisplay &lcd, int x, int y) { return x; } +#if defined(COMPANION_EXCLUSIVE_WIFI_BLE) +void CardputerUITask::toggleTransport() { + const CompanionTransportMode next = getCompanionTransportMode() == CompanionTransportMode::WiFi + ? CompanionTransportMode::Bluetooth : CompanionTransportMode::WiFi; + if (!selectCompanionTransportMode(next)) { + showAlert("Transport save failed", 2500); + return; + } + showAlert(next == CompanionTransportMode::WiFi + ? "WiFi selected; reboot required" : "Bluetooth selected; reboot required", 2500); +} +#endif + void CardputerUITask::toggleBluetooth() { // Same checks and saved preference as the CLI's "set bluetooth on|off" char reply[96]; diff --git a/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/CardputerUITask.h b/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/CardputerUITask.h index b7bf4f2d7..1d0cbe244 100644 --- a/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/CardputerUITask.h +++ b/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/CardputerUITask.h @@ -84,6 +84,9 @@ public: inline uint16_t getBattMilliVoltsCorrected() { return (float)getBattMilliVolts() * _custom_prefs->battery_correction; } bool toggleBuzzer(); void toggleBluetooth(); +#if defined(COMPANION_EXCLUSIVE_WIFI_BLE) + void toggleTransport(); +#endif void cycleTextSize(); bool togglePowerSave(); bool getGPSState(); diff --git a/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/screen/SettingsScreen.cpp b/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/screen/SettingsScreen.cpp index a689c830b..634431b23 100644 --- a/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/screen/SettingsScreen.cpp +++ b/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/screen/SettingsScreen.cpp @@ -1,6 +1,7 @@ #include "SettingsScreen.h" #include "helpers.h" +#include "CompanionWiFi.h" #include @@ -68,6 +69,12 @@ void SettingsScreen::renderItem(CardputerDisplay &lcd, SettingsItem item, int x, case SettingsItem::DeviceBluetooth: snprintf(tmp, sizeof(tmp), "Bluetooth: %s", _task->isBluetoothEnabled() ? "ON" : "OFF"); break; +#if defined(COMPANION_EXCLUSIVE_WIFI_BLE) + case SettingsItem::DeviceTransport: + snprintf(tmp, sizeof(tmp), "Transport: %s", getCompanionTransportMode() == CompanionTransportMode::WiFi + ? "WiFi" : "Bluetooth"); + break; +#endif case SettingsItem::DevicePowersave: snprintf(tmp, sizeof(tmp), "Power save: %s", _task->powerSaveEnabled() ? "ON" : "OFF"); @@ -119,6 +126,12 @@ bool SettingsScreen::enterItemEdit(SettingsItem item) { _task->toggleBluetooth(); return true; +#if defined(COMPANION_EXCLUSIVE_WIFI_BLE) + case SettingsItem::DeviceTransport: + _task->toggleTransport(); + return true; +#endif + case SettingsItem::DevicePowersave: _task->togglePowerSave(); return true; diff --git a/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/screen/SettingsScreen.h b/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/screen/SettingsScreen.h index f81ce828c..925e9a163 100644 --- a/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/screen/SettingsScreen.h +++ b/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/screen/SettingsScreen.h @@ -16,6 +16,9 @@ class SettingsScreen : public CardputerScreen { DeviceTextSize, DeviceBeep, DeviceBluetooth, +#if defined(COMPANION_EXCLUSIVE_WIFI_BLE) + DeviceTransport, +#endif DeviceGps, DevicePowersave, DeviceBatteryCorrection, diff --git a/variants/m5stack_cardputer_adv/hw/CardputerLayout.cpp b/variants/m5stack_cardputer_adv/hw/CardputerLayout.cpp index dccb85be4..bd1f53aee 100644 --- a/variants/m5stack_cardputer_adv/hw/CardputerLayout.cpp +++ b/variants/m5stack_cardputer_adv/hw/CardputerLayout.cpp @@ -4,7 +4,8 @@ using namespace Keyboard::Layout; -static LayoutLayer default_layout_layers[2] = { +// The built-in map is immutable; keep its 450 bytes in flash instead of DRAM. +static const LayoutLayer default_layout_layers[2] = { { (Keyboard::Layout::LAYER_MASK_BASE), { "`", "", "", "", "1", "q", "", "", "2", "w", "a", "", "3", "e", "s", "z", "4", "r", "d", "x", "5", "t", "f", "c", "6", "y", "g", "v", "7", "u", "h", "b", "8", "i", "j", "n", "9", "o", @@ -170,8 +171,9 @@ void CardputerLayout::parseLayout(FS &fs, LayoutType layoutType) { LayoutData* layout = new (std::nothrow) LayoutData(); if (!layout) { layout_file.close(); return; } - layout->layers = new (std::nothrow) LayoutLayer[layer_count](); - if (!layout->layers) { delete layout; layout_file.close(); return; } + LayoutLayer* layers = new (std::nothrow) LayoutLayer[layer_count](); + if (!layers) { delete layout; layout_file.close(); return; } + layout->layers = layers; layout->layers_count = 0; int current_layer_index = -1; remaining = file_bytes; @@ -186,12 +188,12 @@ void CardputerLayout::parseLayout(FS &fs, LayoutType layoutType) { } else if (readLayerMask(line, mask)) { current_layer_index = 0; while (current_layer_index < layout->layers_count - && layout->layers[current_layer_index].modifiers_mask != mask) ++current_layer_index; + && layers[current_layer_index].modifiers_mask != mask) ++current_layer_index; if (current_layer_index == layout->layers_count) { // The file may change or fail between passes. Never grow past the // counted allocation or publish a partial replacement. if (layout->layers_count == layer_count) { valid = false; break; } - layout->layers[current_layer_index].modifiers_mask = mask; + layers[current_layer_index].modifiers_mask = mask; ++layout->layers_count; } } else if (line[0] == 'K' && length >= 5 && line[4] == '=' @@ -200,7 +202,7 @@ void CardputerLayout::parseLayout(FS &fs, LayoutType layoutType) { && line[3] >= '0' && line[3] <= '9') ? (line[2] - '0') * 10 + line[3] - '0' : 0; if (key_id >= 1 && key_id <= TOTAL_KEYS) { - char* dest = layout->layers[current_layer_index].entries[key_id - 1].data; + char* dest = layers[current_layer_index].entries[key_id - 1].data; memset(dest, 0, KB_LAYOUT_CHAR_MAX + 1); memcpy(dest, line + 5, length - 5 < KB_LAYOUT_CHAR_MAX ? length - 5 : KB_LAYOUT_CHAR_MAX); } diff --git a/variants/m5stack_cardputer_adv/hw/CardputerLayout.h b/variants/m5stack_cardputer_adv/hw/CardputerLayout.h index c12ba9bd6..d8c653fda 100644 --- a/variants/m5stack_cardputer_adv/hw/CardputerLayout.h +++ b/variants/m5stack_cardputer_adv/hw/CardputerLayout.h @@ -34,7 +34,7 @@ struct LayoutLayer { struct LayoutData { char language_code[3]; uint8_t layers_count; - LayoutLayer *layers; + const LayoutLayer *layers; }; class CardputerLayout { diff --git a/variants/m5stack_cardputer_adv/platformio.ini b/variants/m5stack_cardputer_adv/platformio.ini index bfef60ef8..9d5063dbd 100644 --- a/variants/m5stack_cardputer_adv/platformio.ini +++ b/variants/m5stack_cardputer_adv/platformio.ini @@ -123,3 +123,21 @@ extends = M5Stack_Cardputer_Adv_ui build_flags = ${M5Stack_Cardputer_Adv_ui.build_flags} -D BLE_PIN_CODE=123456 + +; Canonical USB/BLE/WiFi image. Keep the older recipes for recovery builds. +; Use the existing authenticated NimBLE adapter with the same saved-phone limit. +[env:M5Stack_Cardputer_Adv_companion_radio_full] +extends = env:M5Stack_Cardputer_Adv_companion_radio_ble +build_flags = + ${env:M5Stack_Cardputer_Adv_companion_radio_ble.build_flags} + -D MESHCORE_REQUIRES_COMPANION_RADIO_FULL=1 + -D MESH_USE_NIMBLE_ARDUINO=1 + -D CONFIG_BT_NIMBLE_MAX_CONNECTIONS=1 + -D CONFIG_BT_NIMBLE_MAX_BONDS=15 + -D CONFIG_BT_NIMBLE_MAX_CCCDS=30 + -D CONFIG_BT_NIMBLE_ROLE_CENTRAL_DISABLED=1 + -D CONFIG_BT_NIMBLE_ROLE_OBSERVER_DISABLED=1 +lib_deps = + ${env:M5Stack_Cardputer_Adv_companion_radio_ble.lib_deps} + h2zero/NimBLE-Arduino @ 2.5.1 +lib_ignore = ESP32 BLE Arduino diff --git a/variants/wio-tracker-l1-eink/platformio.ini b/variants/wio-tracker-l1-eink/platformio.ini index 6d403941f..f16e0c26a 100644 --- a/variants/wio-tracker-l1-eink/platformio.ini +++ b/variants/wio-tracker-l1-eink/platformio.ini @@ -67,5 +67,7 @@ debug_tool=stlink [env:WioTrackerL1Eink_kiss_modem] extends = WioTrackerL1Eink +build_flags = ${WioTrackerL1Eink.build_flags} + -D MESHCORE_HEADLESS_KISS_COMPAT=1 build_src_filter = ${WioTrackerL1Eink.build_src_filter} +<../examples/kiss_modem/> diff --git a/variants/wio-tracker-l1/HeadlessKissPins.h b/variants/wio-tracker-l1/HeadlessKissPins.h new file mode 100644 index 000000000..de352868f --- /dev/null +++ b/variants/wio-tracker-l1/HeadlessKissPins.h @@ -0,0 +1,12 @@ +#pragma once + +#include + +inline void parkWioL1KissDisplayPins() { + // OLED, e-paper and Lite share this board. The modem uses neither display; + // keep optional e-paper reset asserted and its separate SPI bus deselected. + digitalWrite(PIN_DISPLAY_CS, HIGH); + pinMode(PIN_DISPLAY_CS, OUTPUT); + digitalWrite(PIN_DISPLAY_RST, LOW); + pinMode(PIN_DISPLAY_RST, OUTPUT); +} diff --git a/variants/wio-tracker-l1/WioTrackerL1Board.cpp b/variants/wio-tracker-l1/WioTrackerL1Board.cpp index 4153714e9..e6cc5b986 100644 --- a/variants/wio-tracker-l1/WioTrackerL1Board.cpp +++ b/variants/wio-tracker-l1/WioTrackerL1Board.cpp @@ -3,8 +3,19 @@ #include "WioTrackerL1Board.h" +#if MESHCORE_HEADLESS_KISS_COMPAT + #include "HeadlessKissPins.h" + extern const char meshcore_capability_modem_headless_wio_l1[] = + "meshcore.capability.modem.headless.wio_l1.v1"; +#endif + void WioTrackerL1Board::begin() { NRF52BoardDCDC::begin(); +#if MESHCORE_HEADLESS_KISS_COMPAT + const volatile char* capability = meshcore_capability_modem_headless_wio_l1; + (void)*capability; + parkWioL1KissDisplayPins(); +#endif btn_prev_state = HIGH; pinMode(PIN_VBAT_READ, INPUT); // VBAT ADC input diff --git a/variants/wio-tracker-l1/WioTrackerL1Board.h b/variants/wio-tracker-l1/WioTrackerL1Board.h index 569e7b64a..011fcd1fc 100644 --- a/variants/wio-tracker-l1/WioTrackerL1Board.h +++ b/variants/wio-tracker-l1/WioTrackerL1Board.h @@ -40,7 +40,9 @@ public: const char* getManufacturerName() const override { // Keep the physical display/power variants distinguishable to read-only // USB clients. Older releases used the same name for Pro, E-Ink and 1W. -#if defined(WIO_TRACKER_L1_EINK) +#if MESHCORE_HEADLESS_KISS_COMPAT || WIO_L1_DISPLAY_UNIFIED + return "Seeed Wio Tracker L1"; +#elif defined(WIO_TRACKER_L1_EINK) return "Seeed Wio Tracker L1 E-Ink"; #else return "Seeed Wio Tracker L1 Pro"; diff --git a/variants/wio-tracker-l1/platformio.ini b/variants/wio-tracker-l1/platformio.ini index f67b22834..d122f0a7a 100644 --- a/variants/wio-tracker-l1/platformio.ini +++ b/variants/wio-tracker-l1/platformio.ini @@ -137,9 +137,16 @@ build_flags = ${WioTrackerL1.build_flags} -D BLE_PIN_CODE=123456 -D BLE_DEBUG_LOGGING=1 -D OFFLINE_QUEUE_SIZE=256 - -D DISPLAY_CLASS=SH1106Display + -D DISPLAY_CLASS=WioL1Display + -D WIO_L1_DISPLAY_UNIFIED=1 + -D EINK_DISPLAY_MODEL=GxEPD2_213_B74 + -D DISPLAY_ROTATION=1 + -D DISABLE_DIAGNOSTIC_OUTPUT + -D EINK_MAX_PARTIAL_REFRESH=10 + -D UI_SMALL_MESSAGE_FONT=1 + -D UI_RECENT_LIST_SIZE=6 + -D UI_SENSORS_PAGE=1 -D UI_HAS_JOYSTICK=1 - -D UI_NO_HIBERNATE -D PIN_BUZZER=12 -D QSPIFLASH=1 -D ADVERT_NAME='"@@MAC"' @@ -149,16 +156,24 @@ build_flags = ${WioTrackerL1.build_flags} ; -D MESH_DEBUG=1 build_src_filter = ${WioTrackerL1.build_src_filter} + + + + + + + +<../examples/companion_radio/*.cpp> +<../examples/companion_radio/ui-new/*.cpp> lib_deps = ${WioTrackerL1.lib_deps} + zinggjm/GxEPD2 @ 1.6.2 + bakercp/CRC32 @ ^2.0.0 adafruit/RTClib @ ^2.1.3 densaugeo/base64 @ ~1.4.0 end2endzone/NonBlockingRTTTL@^1.3.0 [env:WioTrackerL1_kiss_modem] extends = WioTrackerL1 +build_flags = ${WioTrackerL1.build_flags} + -D MESHCORE_HEADLESS_KISS_COMPAT=1 + -D ENV_PIN_SDA=PIN_WIRE1_SDA + -D ENV_PIN_SCL=PIN_WIRE1_SCL build_src_filter = ${WioTrackerL1.build_src_filter} +<../examples/kiss_modem/> diff --git a/variants/wio-tracker-l1/target.cpp b/variants/wio-tracker-l1/target.cpp index 5bfc5693c..edb79c552 100644 --- a/variants/wio-tracker-l1/target.cpp +++ b/variants/wio-tracker-l1/target.cpp @@ -33,6 +33,11 @@ EnvironmentSensorManager sensors = EnvironmentSensorManager(); #endif bool radio_init() { +#if defined(WIO_L1_DISPLAY_UNIFIED) && WIO_L1_DISPLAY_UNIFIED + // The pinned nRF52 Wire driver has no event timeout. A failed OLED bus + // must not stall radio startup during the optional RTC address probes. + if (digitalRead(PIN_WIRE_SDA) == HIGH && digitalRead(PIN_WIRE_SCL) == HIGH) +#endif rtc_clock.begin(Wire); return radio.std_init(&SPI); diff --git a/variants/wio-tracker-l1/target.h b/variants/wio-tracker-l1/target.h index 05bc7ff1c..f8ef05050 100644 --- a/variants/wio-tracker-l1/target.h +++ b/variants/wio-tracker-l1/target.h @@ -9,7 +9,9 @@ #include #include #ifdef DISPLAY_CLASS - #if defined(WIO_TRACKER_L1_EINK) + #if defined(WIO_L1_DISPLAY_UNIFIED) && WIO_L1_DISPLAY_UNIFIED + #include + #elif defined(WIO_TRACKER_L1_EINK) #include #else #include From c23157fa14bef6b8f4f6dbeff261b2e33f4d23cc Mon Sep 17 00:00:00 2001 From: mikecarper Date: Sat, 31 Oct 2026 22:31:07 -0700 Subject: [PATCH 2/3] Finish admitted OTA blocks before timed stops --- tools/lora_ota/ota_block_drain.py | 157 ++++++++++++++++++ tools/lora_ota/ota_wizard_repeater.py | 6 +- tools/lora_ota/test_ota_wizard_block_drain.py | 117 +++++++++++++ tools/lora_ota/test_ota_wizard_repeater.py | 25 +++ 4 files changed, 303 insertions(+), 2 deletions(-) create mode 100644 tools/lora_ota/ota_block_drain.py create mode 100644 tools/lora_ota/test_ota_wizard_block_drain.py diff --git a/tools/lora_ota/ota_block_drain.py b/tools/lora_ota/ota_block_drain.py new file mode 100644 index 000000000..da8a60a4c --- /dev/null +++ b/tools/lora_ota/ota_block_drain.py @@ -0,0 +1,157 @@ +"""Finish admitted OTA blocks at a soft deadline without opening another block. + +The verified baseline must come from an owned idle-to-pull session while DATA +is closed and the source cache is empty. Aggregate receiver counts cannot +prove a drain when an unrelated active fetch already has untracked RAM slots. +""" +from __future__ import annotations + +import math +import struct +import time + + +class BlockDrainError(ValueError): + pass + + +class BlockDrainSeeder: + def __init__(self, blob, *, factory=None, clock=time.monotonic, on_change=None): + if factory is None: + from ota_wizard_repeater import SeederData + factory = SeederData + self.delegate = factory(blob) + self.info = self.delegate.info + self.clock, self.on_change = clock, on_change + self.state = "closed" + self.baseline = self.deadline = self.required_blocks = None + self.admitted = set() + self.blocked_requests = self.payload_bytes = 0 + self.last_verified = None + + def snapshot(self): + return dict(state=self.state, baseline_blocks=self.baseline, + admitted_blocks=sorted(self.admitted), required_blocks=self.required_blocks, + last_verified_blocks=self.last_verified, blocked_data_requests=self.blocked_requests, + host_payload_bytes=self.payload_bytes) + + def changed(self): + if self.on_change is not None: + # Called before a newly admitted DATA response is returned, so a + # caller can persist its admission journal before USB transmits it. + try: self.on_change(self.snapshot()) + except BaseException: + # A retry must not bypass a failed durability barrier merely + # because the index was added to the in-memory set already. + self.state = "failed" + raise + + def arm(self, verified_blocks, *, fresh_resume, deadline=None): + total = math.ceil(self.info.payload_size / self.info.block_size) + if (self.state != "closed" or self.baseline is not None or fresh_resume is not True + or type(verified_blocks) is not int or not 0 <= verified_blocks <= total): + raise BlockDrainError("A closed, owned resume needs a verified baseline") + self.baseline = self.last_verified = verified_blocks + self.deadline = deadline + self.state = "open" + self.changed() + self.check_deadline() + + def freeze(self): + if self.state == "open": + self.state = "draining" + self.required_blocks = self.baseline + len(self.admitted) + self.changed() + return self.required_blocks + + def restore_owned(self, snapshot, *, deadline=None): + """Restore a journal whose receiver/package/owner scope was validated.""" + total = math.ceil(self.info.payload_size / self.info.block_size) + baseline = snapshot.get("baseline_blocks") + blocks = snapshot.get("admitted_blocks") + count = snapshot.get("last_verified_blocks") + state = snapshot.get("state") + if (self.baseline is not None or type(baseline) is not int or not 0 <= baseline <= total + or not isinstance(blocks, list) or any(type(b) is not int or not 0 <= b < total for b in blocks) + or len(set(blocks)) != len(blocks) or baseline + len(blocks) > total + or type(count) is not int or not baseline <= count <= baseline + len(blocks) + or state not in ("open", "draining", "closed")): + raise BlockDrainError("Invalid owned block admission journal") + self.baseline, self.last_verified = baseline, count + self.admitted = set(blocks) + self.deadline = deadline + self.state = "open" if state == "open" else "draining" + if self.state == "draining": self.required_blocks = baseline + len(blocks) + self.changed() + self.check_deadline() + + def check_deadline(self): + if self.state == "open" and self.deadline is not None and self.clock() >= self.deadline: + self.freeze() + + def close(self): + self.state = "closed" + self.changed() + + def observe_verified(self, count): + """Only a receiver's validated, matching-MID FETCH count belongs here.""" + if self.baseline is None or type(count) is not int: + raise BlockDrainError("No verified baseline") + maximum = self.baseline + len(self.admitted) + if count < self.last_verified or count > maximum: + raise BlockDrainError("Receiver progress includes untracked or lost blocks") + self.last_verified = count + return self.state == "draining" and count == self.required_blocks + + def blocked(self): + self.blocked_requests += 1 + return 1, b"" + + def can_serve(self, block): + self.check_deadline() + return self.state == "open" or (self.state == "draining" and block in self.admitted) + + def admit(self, block): + # Recheck after compression/read work. An event loop can be awaiting + # a slow CLI command at cutoff; DATA itself enforces the deadline. + if not self.can_serve(block): return False + if block not in self.admitted: + total = math.ceil(self.info.payload_size / self.info.block_size) + if self.baseline + len(self.admitted) >= total: + raise BlockDrainError("More DATA indices than missing receiver blocks") + self.admitted.add(block) + self.changed() + return True + + def request(self, op, args): + info = self.info + if op == 3 and len(args) == 7: + index, offset, length = struct.unpack("= 3: break + await asyncio.sleep(.01) + self.assertEqual(len(policies), 1) + self.assertIs(self.link._seeder, policies[0]) + gate = policies[0] + offset = gate.info.payload_offset + self.assertEqual(gate.request(3, struct.pack(' Date: Sat, 31 Oct 2026 22:31:08 -0700 Subject: [PATCH 3/3] Use completed transmissions for OTA pacing and recovery --- .github/workflows/run-unit-tests.yml | 5 + docs/ota_user_guide.md | 19 ++ scripts/nrf52_flash_trim.py | 9 + scripts/nrf52_ota_pacing_size.py | 124 +++++++++ src/Dispatcher.cpp | 88 +++--- src/Dispatcher.h | 13 +- src/Mesh.cpp | 52 ++++ src/Mesh.h | 3 + src/helpers/ota/OtaCli.cpp | 74 +++-- src/helpers/ota/OtaManager.cpp | 240 +++++++++++++---- src/helpers/ota/OtaManager.h | 70 ++++- src/helpers/ota/OtaPacketPacer.h | 302 +++++++++++++++++++++ src/helpers/ota/OtaReceiveCadence.h | 76 ++++++ test/fixtures/cli_reply_text.json | 78 +++++- test/fixtures/ota_cli_listing/test.cpp | 1 + test/fixtures/ota_folder_resume.cpp | 1 + test/test_bootloader_update_cli.py | 4 +- test/test_cli_reply_text.py | 30 ++- test/test_nrf52_flash_trim.py | 8 +- test/test_nrf52_ota_pacing_size.py | 203 ++++++++++++++ test/test_ota/test_ota_core.cpp | 301 +++++++++++++++++++-- test/test_ota_cli_listing.py | 3 +- test/test_ota_direct_manager.py | 8 +- test/test_ota_packet_pacer.py | 356 +++++++++++++++++++++++++ test/test_ota_receive_cadence.py | 154 +++++++++++ test/test_ota_self_serve_policy.py | 2 + test/test_ota_transfer_diagnostics.py | 154 +++++++++++ test/test_ota_tx_adapter.py | 200 ++++++++++++++ variants/rak4631/platformio.ini | 2 + 29 files changed, 2430 insertions(+), 150 deletions(-) create mode 100644 scripts/nrf52_ota_pacing_size.py create mode 100644 src/helpers/ota/OtaPacketPacer.h create mode 100644 src/helpers/ota/OtaReceiveCadence.h create mode 100644 test/test_nrf52_ota_pacing_size.py create mode 100644 test/test_ota_packet_pacer.py create mode 100644 test/test_ota_receive_cadence.py create mode 100644 test/test_ota_transfer_diagnostics.py create mode 100644 test/test_ota_tx_adapter.py diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index 93f4bfeb9..4ca533c19 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -896,6 +896,10 @@ jobs: python3 -B test/test_reply_tx_integration.py python3 -B test/test_admin_cli_reply_scope.py python3 -B test/test_ota_speed.py -v + python3 -B test/test_ota_packet_pacer.py -v + python3 -B test/test_ota_receive_cadence.py -v + python3 -B test/test_ota_tx_adapter.py -v + python3 -B test/test_ota_transfer_diagnostics.py -v python3 -B test/test_ota_announce_cadence.py -v python3 -B test/test_ota_apply_delivery.py -v python3 -B test/test_ota_install_preflight.py -v @@ -1276,6 +1280,7 @@ jobs: - name: Verify nRF52 infrastructure trims and unchanged Companion scope run: | python3 -B test/test_nrf52_flash_trim.py -v + python3 -B test/test_nrf52_ota_pacing_size.py -v python3 -B test/test_nrf52_vl53l0x_const.py -v python3 -B test/test_nrf52_gnss_version_trim.py -v python3 -B test/test_nrf52_program_lifetime.py -v diff --git a/docs/ota_user_guide.md b/docs/ota_user_guide.md index abb8bd7a9..8a19c5bad 100644 --- a/docs/ota_user_guide.md +++ b/docs/ota_user_guide.md @@ -416,6 +416,25 @@ The setting is available on OTA-enabled repeaters, rooms, sensors, Companions, and seeder-only builds. Its separate `/ota_speed` settings file preserves the existing preference layouts and does not need an active OTA workspace. +Automatic packet pacing uses actual DATA transmission completion. Requests for +fragments still queued or waiting for CAD do not count as loss. Each radio +profile has its own pacing state, reset when that profile changes. After two +clean response flights and subsequent forward progress, the source probes the +next rate (0.25, 0.5, 0.75, 1x), reverting if a probe loses fragments. CAD and +the configured airtime budget still apply. Receivers also learn valid DATA +spacing so a slower source does not cause premature retries. + +`ota pace` shows the last admitted profile rates, observed DATA gap, and retry +wait. `ota wire` shows completed DATA packet/byte counts, how many packets used +DEFLATE, the last encoded/raw block lengths, and the latest fallback reason. +Counters cover the current OTA manager lifetime. `host-raw` means the host did +not supply a smaller encoded block; `not-smaller` is a normal raw-block choice. +`legacy-peer`, `v2-timeout`, and `v2-stall` select legacy transfer; +`inflate-failed` disables compression while retaining v2. These diagnostics +are local and generate no extra measurement traffic. +RAK4631 Full omits these optional diagnostics to fit its fixed flash slot; +automatic recovery and the existing `get ota.speed` command remain available. + ## Optional: let it update automatically By default your node only *discovers* updates - it won't download or install on its own. If you want more diff --git a/scripts/nrf52_flash_trim.py b/scripts/nrf52_flash_trim.py index 197c524f7..b2b43cb7e 100644 --- a/scripts/nrf52_flash_trim.py +++ b/scripts/nrf52_flash_trim.py @@ -290,6 +290,14 @@ def companion_shared_replies_policy(platform, mcu, env_name, src_filter, "MESH_CLI_SHARE_REPLY_COPIES")) +def install_ota_pacing_size(build_env): + hook_path = build_env.subst("$PROJECT_DIR/scripts/nrf52_ota_pacing_size.py") + hook_spec = importlib.util.spec_from_file_location("mesh_nrf52_ota_pacing_size", hook_path) + hook = importlib.util.module_from_spec(hook_spec) + hook_spec.loader.exec_module(hook) + return hook.install(build_env) + + def install(build_env): platform = build_env.PioPlatform() mcu = build_env.BoardConfig().get("build.mcu", "") @@ -326,6 +334,7 @@ def install(build_env): flags, cppdefines): if "MESH_CLI_SHARE_REPLY_COPIES" not in flag_defines(flags, cppdefines): build_env.AppendUnique(CPPDEFINES=[("MESH_CLI_SHARE_REPLY_COPIES", 1)]) + install_ota_pacing_size(build_env) if infrastructure: names = ("local OTA target name only" if definitions.get("OTA_TARGET_NAME_TABLE") == 0 or flag_defines(flags, cppdefines).get("OTA_TARGET_NAME_TABLE") == "0" diff --git a/scripts/nrf52_ota_pacing_size.py b/scripts/nrf52_ota_pacing_size.py new file mode 100644 index 000000000..f5c3b3b86 --- /dev/null +++ b/scripts/nrf52_ota_pacing_size.py @@ -0,0 +1,124 @@ +"""Qualified RAK Full code generation; keep the naked RTOS port outside LTO. + +No SDK source, assembly, firmware feature, or ABI is changed. The inspected +FreeRTOS port uses assembler literal pools which cannot safely follow GCC's +whole-program function layout. Only that exact file is compiled without LTO. +Instrumented HIL builds retain their existing framework bindings and options. +""" + +import hashlib +import importlib.util +from pathlib import Path +import shlex + + +PROFILE = "RAK_4631_companion_radio_full" +PORT_RELATIVE = "cores/nRF5/freertos/portable/GCC/nrf52/port.c" +PORT_SHA256 = "68f8983b7c67f63d2949829387fdf984635a1d1a23fef46488dd233b3156f36a" +PORT_PATTERN = "*cores*nRF5*freertos*portable*GCC*nrf52*port.c" +SIZE_FLAGS = ( + "-fno-caller-saves", + "-fno-tree-dominator-opts", + "-fno-reorder-blocks", + "-fno-tree-loop-im", +) +_INSTALLED = "MESH_NRF52_OTA_PACING_SIZE_INSTALLED" + + +def _trim_helpers(): + spec = importlib.util.spec_from_file_location( + "mesh_nrf52_ota_size_flags", Path(__file__).with_name("nrf52_flash_trim.py")) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def flag_tokens(flags): + # Environment lists already preserve forced-include option/argument pairs. + # Preserve quoted tokens when a caller supplies an unparsed flag string. + return shlex.split(flags, posix=False) if isinstance(flags, str) else list(flags) + + +def without_lto(flags): + """Remove only positive LTO controls; preserve every other option in order.""" + return [flag for flag in flag_tokens(flags) + if not isinstance(flag, str) or not flag.startswith("-flto")] + + +def size_policy(platform, mcu, env_name, src_filter, flags=(), cppdefines=(), + ccflags=(), linkflags=()): + """Fail closed outside the reviewed board, role, options and non-HIL build.""" + if (platform != "nordicnrf52" or str(mcu).lower() != "nrf52840" + or env_name != PROFILE): + return False + helpers = _trim_helpers() + if (not helpers.source_selected(src_filter, helpers.COMPANION_SOURCES[0]) + or any(helpers.source_selected(src_filter, source) + for source in helpers.ROLE_SOURCES + helpers.COMPANION_SOURCES[1:])): + return False + options = flag_tokens(flags) + flag_tokens(ccflags) + flag_tokens(linkflags) + values = helpers.flag_defines(options, cppdefines) + for name in ("MESH_NRF52_OTA_PACING_SIZE", "MESH_NRF52_FLASH_TRIM", + "MESH_NRF52_COMPANION_TRIM", "COMPANION_RADIO_FULL"): + if name in values and not helpers._enabled(values[name]): + return False + # Some HIL options use #ifdef even for =0. Leave every defined HIL owner + # untouched rather than rebinding its private port copy or instrumentation. + if any("HIL" in name and value is not None for name, value in values.items()): + return False + alias = values.get("OTA_VARIANT") + if alias is not None and str(alias).strip("\"'") != PROFILE: + return False + # Do not override explicit alternate code-generation choices. The base + # recipe has no positive counterparts to these qualified negative flags. + if any(flag.replace("-fno-", "-f", 1) in options for flag in SIZE_FLAGS): + return False + if "-fno-lto" in options: + return False + return not any(isinstance(flag, str) and flag.startswith("-flto-partition=") + and flag != "-flto-partition=one" for flag in options) + + +def source_digest(source): + """Accept the same reviewed C source with checkout-native LF or CRLF.""" + return hashlib.sha256(source.replace(b"\r\n", b"\n")).hexdigest() + + +def reviewed_port(framework_dir): + if not framework_dir: + raise RuntimeError("RAK Full size policy: pinned nRF52 framework not found") + source = (Path(framework_dir) / PORT_RELATIVE).resolve() + if not source.is_file() or source_digest(source.read_bytes()) != PORT_SHA256: + raise RuntimeError("RAK Full size policy: changed pinned FreeRTOS port.c; review before building") + return source + + +def compile_port_without_lto(build_env, node): + framework = build_env.PioPlatform().get_package_dir("framework-arduinoadafruitnrf52") + expected = (Path(framework) / PORT_RELATIVE).resolve() if framework else None + source = Path(node.srcnode().get_abspath()).resolve() + if source != expected: + return node # Includes private HIL copies and unrelated files named port.c. + reviewed_port(framework) + options = {name: without_lto(build_env.get(name, ())) + for name in ("CCFLAGS", "CFLAGS", "CXXFLAGS", "BUILD_FLAGS")} + if "-fno-lto" not in options["CCFLAGS"]: + options["CCFLAGS"].append("-fno-lto") + return build_env.Object(node, **options) + + +def install(build_env): + if build_env.get(_INSTALLED): + return True + platform = build_env.PioPlatform() + if not size_policy(platform.name, build_env.BoardConfig().get("build.mcu", ""), + build_env.get("PIOENV", ""), build_env.get("SRC_FILTER", ()), + build_env.get("BUILD_FLAGS", ()), build_env.get("CPPDEFINES", ()), + build_env.get("CCFLAGS", ()), build_env.get("LINKFLAGS", ())): + return False + reviewed_port(platform.get_package_dir("framework-arduinoadafruitnrf52")) + build_env.AppendUnique(CCFLAGS=list(SIZE_FLAGS), BUILD_FLAGS=list(SIZE_FLAGS), + LINKFLAGS=list(SIZE_FLAGS)) + build_env.AddBuildMiddleware(compile_port_without_lto, PORT_PATTERN) + build_env[_INSTALLED] = True + return True diff --git a/src/Dispatcher.cpp b/src/Dispatcher.cpp index 518e064a2..83776068c 100644 --- a/src/Dispatcher.cpp +++ b/src/Dispatcher.cpp @@ -105,7 +105,7 @@ void Dispatcher::begin() { outbound_radio_retry_pending = false; outbound_radio_retry_used = false; outbound_cancellation = OutboundCancellation::None; - ota_tx_airtime = 0; + for (auto& timing : ota_tx_timing) timing = {}; radio_nonrx_start = _ms->getMillis(); radio_nonrx_timer_armed = false; @@ -395,8 +395,8 @@ void Dispatcher::finishForeignRadioFrame(bool success, bool account_airtime) { const uint32_t elapsed = (uint32_t)_ms->getMillis() - foreign_radio_start; total_air_time += elapsed; if (direct_ota) { - ota_tx_finished_at = _ms->getMillis(); - ota_tx_airtime = elapsed; + noteOtaTransmit(foreign_radio_profile, foreign_radio_generation, elapsed, + _ms->getMillis()); } updateTxBudget(); tx_budget_ms = elapsed >= tx_budget_ms ? 0 : tx_budget_ms - elapsed; @@ -406,6 +406,12 @@ void Dispatcher::finishForeignRadioFrame(bool success, bool account_airtime) { } else next_tx_time = _ms->getMillis(); } if (success && _radio->profiles()) ++_radio->profiles()->tx_packets[foreign_radio_profile]; +#if MESH_ENABLE_OTA_KISS_DIRECT + if (direct_ota) { + onDirectOtaRadioSendComplete(foreign_radio_bytes, foreign_radio_length, + foreign_radio_profile, foreign_radio_generation, success); + } +#endif // Release ownership before calling the application; it may submit another // explicit user message. No retry is scheduled after any failure/timeout. foreign_radio_length = 0; @@ -464,11 +470,8 @@ bool Dispatcher::checkForeignRadioSend() { } if (!restoreOutboundTxOverrides() || _radio->isCarrierWaveActive()) return false; const uint32_t now = _ms->getMillis(); - if (foreign_radio_protocol == RadioProfileProtocol::MeshCore && ota_tx_airtime) { - const uint32_t quiet = ota::packetQuietTime(ota_tx_airtime, getOtaPacketSpeedFactor()); - if (now - ota_tx_finished_at < quiet) return false; - ota_tx_airtime = 0; - } + if (foreign_radio_protocol == RadioProfileProtocol::MeshCore + && otaQuietRemaining(foreign_radio_profile, foreign_radio_generation, now)) return false; // Continuous explicit foreign chat must leave due MeshCore traffic a turn // between messages; a blocked/absent MeshCore queue does not stall MT. if (foreign_radio_yield_meshcore && _mgr->getOutboundCount(now) > 0) { @@ -854,8 +857,8 @@ void Dispatcher::loop() { long t = _ms->getMillis() - outbound_start; total_air_time += t; if (outbound->getPayloadType() == PAYLOAD_TYPE_OTA) { - ota_tx_finished_at = _ms->getMillis(); - ota_tx_airtime = t > 0 ? (uint32_t)t : 0; + noteOtaTransmit(outbound->radio_profile, outbound->radio_generation, + t > 0 ? (uint32_t)t : 0, _ms->getMillis()); } //Serial.print(" airtime="); Serial.println(t); @@ -1291,15 +1294,38 @@ bool Dispatcher::isTransmitChannelReady(const Packet* packet, uint32_t& retry_de return true; } +void Dispatcher::noteOtaTransmit(uint8_t profile, uint32_t generation, + uint32_t airtime, uint32_t now) { + if (profile >= 2) return; + auto& timing = ota_tx_timing[profile]; + timing.finished_at = now; + timing.airtime = airtime; + timing.generation = generation; +} + +uint32_t Dispatcher::otaQuietRemaining(uint8_t profile, uint32_t generation, + uint32_t now) { + if (profile >= 2) return 0; + auto& timing = ota_tx_timing[profile]; + if (timing.generation != generation) { + timing = {}; + return 0; + } + if (!timing.airtime) return 0; + const uint32_t gap = ota::packetQuietTime(timing.airtime, + getOtaPacketSpeedFactor(profile, generation)); + const uint32_t elapsed = now - timing.finished_at; + if (elapsed < gap) return gap - elapsed; + timing.airtime = 0; + return 0; +} + void Dispatcher::checkSend() { if (_radio->isCarrierWaveActive()) return; // Keep queued packets intact while recovering a failed per-packet CR // override. RX processing and ordinary watchdogs continue in loop(). if (!restoreOutboundTxOverrides()) return; const uint32_t now = _ms->getMillis(); - if (ota_tx_airtime && now - ota_tx_finished_at >= ota::packetQuietTime(ota_tx_airtime, getOtaPacketSpeedFactor())) { - ota_tx_airtime = 0; - } uint32_t next_outbound; if (_mgr->getNextOutboundTime(now, next_outbound)) { if ((int32_t)(next_outbound - now) > 0) return; @@ -1317,27 +1343,21 @@ void Dispatcher::checkSend() { failOutboundTransmit(); return; } - if (ota_tx_airtime) { - const uint32_t gap = ota::packetQuietTime(ota_tx_airtime, getOtaPacketSpeedFactor()); - const uint32_t elapsed = now - ota_tx_finished_at; - if (elapsed >= gap) ota_tx_airtime = 0; - else if (pending && pending->getPayloadType() == PAYLOAD_TYPE_OTA) { - // Preserve this packet's priority and retry ownership. A short deferral - // lets ordinary traffic run and makes live speed changes take effect - // within 100 ms, even when the previous speed was much slower. - const uint32_t remaining = gap - elapsed; - int candidates = _mgr->getOutboundTotal(); - do { - if (candidates-- <= 0 || !_mgr->deferOutboundForPacing(pending, now, - now + (remaining > 100 ? 100 : remaining))) return; - pending = _mgr->peekNextOutbound(now); - } while (pending && pending->getPayloadType() == PAYLOAD_TYPE_OTA); - if (!pending) return; - if (!isPacketRadioCurrent(pending)) { - outbound = _mgr->getNextOutbound(now); - failOutboundTransmit(); - return; - } + int candidates = _mgr->getOutboundTotal(); + while (pending && pending->getPayloadType() == PAYLOAD_TYPE_OTA) { + const uint32_t remaining = otaQuietRemaining(pending->radio_profile, + pending->radio_generation, now); + if (!remaining) break; + // Preserve priority and retry ownership while allowing another profile + // or ordinary traffic through. Recheck live speed changes within 100 ms. + if (candidates-- <= 0 || !_mgr->deferOutboundForPacing(pending, now, + now + (remaining > 100 ? 100 : remaining))) return; + pending = _mgr->peekNextOutbound(now); + if (!pending) return; + if (!isPacketRadioCurrent(pending)) { + outbound = _mgr->getNextOutbound(now); + failOutboundTransmit(); + return; } } updateTxBudget(); diff --git a/src/Dispatcher.h b/src/Dispatcher.h index 352bd70e3..5ae6bcf91 100644 --- a/src/Dispatcher.h +++ b/src/Dispatcher.h @@ -442,8 +442,9 @@ class Dispatcher { #endif uint8_t outbound_restore_cr; unsigned long next_tx_time; - uint32_t ota_tx_finished_at = 0; - uint32_t ota_tx_airtime = 0; + struct OtaTxTiming { + uint32_t finished_at = 0, airtime = 0, generation = 0; + } ota_tx_timing[2]; unsigned long cad_busy_start; unsigned long radio_nonrx_start; unsigned long next_floor_calib_time; @@ -494,6 +495,9 @@ class Dispatcher { uint32_t outbound_restore_retry_at = 0; uint32_t outbound_restore_recovery_at = 0; void updateTxBudget(); + void noteOtaTransmit(uint8_t profile, uint32_t generation, uint32_t airtime, + uint32_t now); + uint32_t otaQuietRemaining(uint8_t profile, uint32_t generation, uint32_t now); protected: // Preserve RX affinity while an authenticated command is handled outside @@ -573,6 +577,8 @@ protected: // The KISS host framing is off-air. These are bounded, zero-hop OTA radio // frames, never Packet objects and never a second physical TX owner. virtual bool onDirectOtaRadioFrame(const uint8_t*, size_t, uint8_t, uint32_t) { return false; } + virtual void onDirectOtaRadioSendComplete(const uint8_t*, size_t, uint8_t, + uint32_t, bool) { } virtual bool allowDirectOtaTransmit(uint8_t, uint32_t) const { return false; } // Captured when queued: abort, fallback and workspace replacement retire // this token even when the physical temporary profile remains unchanged. @@ -597,6 +603,9 @@ protected: virtual float getAirtimeBudgetFactor() const; virtual float getOtaSpeedFactor() const { return 1.0f; } virtual float getOtaPacketSpeedFactor() const { return getOtaSpeedFactor(); } + virtual float getOtaPacketSpeedFactor(uint8_t, uint32_t) const { + return getOtaPacketSpeedFactor(); + } virtual int calcRxDelay(float score, uint32_t air_time) const; virtual bool shouldBypassRxDelay(const Packet* packet) { (void)packet; diff --git a/src/Mesh.cpp b/src/Mesh.cpp index f5c878815..5c33d6522 100644 --- a/src/Mesh.cpp +++ b/src/Mesh.cpp @@ -314,6 +314,21 @@ bool Mesh::onDirectOtaRadioFrame(const uint8_t* bytes, size_t length, context->track_session(context->manager.fetchState(), _ms->getMillis()); return true; } + +void Mesh::onDirectOtaRadioSendComplete(const uint8_t* bytes, size_t length, + uint8_t profile, uint32_t generation, + bool success) { + auto* context = ota::ota_context_if_active(); + const uint8_t* message = nullptr; + uint16_t message_len = 0; + if (!context || !ota::ota_direct_wire_decode(bytes, length, message, message_len)) return; + ota::OtaReplyRoute route; + route.profile = profile; + route.generation = generation; + route.direct = true; + if (success) context->manager.noteResponseTransmitted(message, message_len, route, _ms->getMillis()); + else context->manager.noteResponseFailed(message, message_len, route, _ms->getMillis()); +} #endif #endif @@ -834,6 +849,19 @@ float Mesh::getOtaPacketSpeedFactor() const { #endif } +float Mesh::getOtaPacketSpeedFactor(uint8_t profile, uint32_t generation) const { + const float configured = getOtaSpeedFactor(); +#if defined(ENABLE_OTA) + const auto* context = ota::ota_context_if_active(); + const float adaptive = context ? context->manager.adaptivePacketSpeed(profile, generation) : 1.0f; + return configured < adaptive ? configured : adaptive; +#else + (void)profile; + (void)generation; + return configured; +#endif +} + uint32_t Mesh::getOtaPacketAirtime() const { const auto* profiles = _radio->profiles(); if (!profiles || !profiles->enabled()) return _radio->getProfileAirtime(0, MAX_TRANS_UNIT); @@ -1045,6 +1073,18 @@ uint8_t Mesh::getExtraAckTransmitCount() const { } void Mesh::onSendComplete(Packet* packet) { +#if defined(ENABLE_OTA) + if (packet && !packet->forwarded && packet->getPayloadType() == PAYLOAD_TYPE_OTA + && packet->getPathHashCount() == 0) { + if (auto* context = ota::ota_context_if_active()) { + ota::OtaReplyRoute route; + route.profile = packet->radio_profile; + route.generation = packet->radio_generation; + context->manager.noteResponseTransmitted(packet->payload, packet->payload_len, + route, _ms->getMillis()); + } + } +#endif // Profile selection/copying happens after sendOtaFlood(). Remember the // actual transmitting profile as well, including locally injected OTA. if (packet && packet->getPayloadType() == PAYLOAD_TYPE_OTA @@ -1079,6 +1119,18 @@ void Mesh::onTracePacketQueuedForSend(Packet* packet) { } void Mesh::onSendFail(Packet* packet) { +#if defined(ENABLE_OTA) + if (packet && !packet->forwarded && packet->getPayloadType() == PAYLOAD_TYPE_OTA + && packet->getPathHashCount() == 0) { + if (auto* context = ota::ota_context_if_active()) { + ota::OtaReplyRoute route; + route.profile = packet->radio_profile; + route.generation = packet->radio_generation; + context->manager.noteResponseFailed(packet->payload, packet->payload_len, + route, _ms->getMillis()); + } + } +#endif clearPendingDirectRetryOnSendFail(packet); clearPendingFloodRetryOnSendFail(packet); } diff --git a/src/Mesh.h b/src/Mesh.h index fc93fe85b..b09aaf909 100644 --- a/src/Mesh.h +++ b/src/Mesh.h @@ -565,6 +565,8 @@ protected: uint32_t directOtaEpoch() const override; bool onDirectOtaRadioFrame(const uint8_t* bytes, size_t length, uint8_t profile, uint32_t generation) override; + void onDirectOtaRadioSendComplete(const uint8_t* bytes, size_t length, uint8_t profile, + uint32_t generation, bool success) override; #endif void syncOtaTiming(ota::OtaManager& manager); unsigned long _next_ota_tick = 0; @@ -577,6 +579,7 @@ protected: #endif float getOtaSpeedFactor() const override; float getOtaPacketSpeedFactor() const override; + float getOtaPacketSpeedFactor(uint8_t profile, uint32_t generation) const override; uint32_t getOtaPacketAirtime() const; /** diff --git a/src/helpers/ota/OtaCli.cpp b/src/helpers/ota/OtaCli.cpp index 8f8e889bf..63beb2dcd 100644 --- a/src/helpers/ota/OtaCli.cpp +++ b/src/helpers/ota/OtaCli.cpp @@ -11,6 +11,7 @@ #endif #include "Utils.h" #include "helpers/CLICommandUtils.h" +#include "helpers/CLIReplyText.h" #include #include #include @@ -19,6 +20,30 @@ namespace mesh { namespace ota { +static const char* wire_choice_word(OtaManager::WireChoice choice) { + switch (choice) { + case OtaManager::WireChoice::Deflate: return "deflate"; + case OtaManager::WireChoice::LegacyRequest: return "legacy-request"; + case OtaManager::WireChoice::RawRequest: return "raw-request"; + case OtaManager::WireChoice::HostRaw: return "host-raw"; + case OtaManager::WireChoice::NoEncoder: return "no-encoder"; + case OtaManager::WireChoice::NoWorkspace: return "no-workspace"; + case OtaManager::WireChoice::NoGain: return "not-smaller"; + case OtaManager::WireChoice::EncodeFailed: return "encode-failed"; + default: return "none"; + } +} + +static const char* wire_fallback_word(OtaManager::WireFallback reason) { + switch (reason) { + case OtaManager::WireFallback::LegacyPeer: return "legacy-peer"; + case OtaManager::WireFallback::V2Timeout: return "v2-timeout"; + case OtaManager::WireFallback::V2Stall: return "v2-stall"; + case OtaManager::WireFallback::InflateFailed: return "inflate-failed"; + default: return "none"; + } +} + // Read complete whitespace-delimited words, never truncated scanf fields. // Three also denotes an overlong word, so callers reject the whole command. static __attribute__((noinline)) int parse_two_words(const char* text, @@ -393,7 +418,7 @@ static __attribute__((noinline)) bool handle_neighbors( (unsigned)h->n_seeders, (unsigned)age, tag); shown++; } - if (shown == 0) strcpy(reply, "No updates seen yet - re-run `ota ls` in a few seconds (just asked around)."); + if (shown == 0) strcpy(reply, MESH_CLI_REPLY_TEXT("No updates seen yet - re-run `ota ls` in a few seconds (just asked around).", "No updates seen yet; retry `ota ls` shortly.")); return true; } @@ -419,7 +444,7 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board #if defined(NRF52_PLATFORM) && defined(OTA_RAK15002_SD_STORE) && !defined(OTA_SEEDER_ONLY) // Radio/USB startup and status remain independent of removable storage. // Explicit transfer/catalog/cache work may prepare it at this main-loop boundary. - if (!is_cmd(a, "qspi|storage|self|id|stats|help|?|h", &rest)) c.prepareRakStorage(); + if (!is_cmd(a, "qspi|storage|self|id|stats|pace|wire|help|?|h", &rest)) c.prepareRakStorage(); #endif if (is_cmd(a, "neighbors|nbrs|updates|ls|n", &rest)) return handle_neighbors(rest, reply, c); @@ -498,6 +523,23 @@ static __attribute__((noinline)) bool handle_control_command( // ---- admin OTA stats: crypto identities (our fw's content-id + body_hash), serving set, live fetch, // policy - one dense line. The remote-admin CLI path is admin-gated, so this is admin-only over the // mesh (send it from the app's repeater command screen, or the WiFi/serial OTA console). + } else if (OTA_TRANSFER_DIAGNOSTICS && is_cmd(a, "wire", &rest)) { + const auto& wire = c.manager.wireStats(); + snprintf(reply, 160, + "wire tx=%lu deflate=%lu bytes=%lu last=%u/%u:%s rx=%s/%s fallback=%s:%u", + (unsigned long)wire.data_packets, (unsigned long)wire.deflated_packets, + (unsigned long)wire.data_bytes, wire.encoded_bytes, wire.raw_bytes, + wire_choice_word(wire.choice), c.manager.fetchWireV2() ? "v2" : "legacy", + c.manager.fetchAllowsDeflate() ? "deflate" : "raw", + wire_fallback_word(wire.fallback), wire.fallbacks); + } else if (OTA_TRANSFER_DIAGNOSTICS && is_cmd(a, "pace", &rest)) { + char primary[16], secondary[16]; + formatSpeedFactor(c.manager.adaptivePacketSpeed(0), primary, sizeof(primary)); + formatSpeedFactor(c.manager.adaptivePacketSpeed(1), secondary, sizeof(secondary)); + snprintf(reply, 160, "pace last radio=%sx radio2=%sx data-gap=%lums retry=%lums", + primary, secondary, + (unsigned long)c.manager.observedDataIntervalMs(), + (unsigned long)c.manager.fetchRetryTimeoutMs()); } else if (is_cmd(a, "stats", &rest)) { // our running fw's merkle content-id (mid) comes from the self serve entry; the EndF body_hash (fw // identity, matched against a delta base) is separate - surface BOTH (only body_hash showed before). @@ -572,7 +614,7 @@ static __attribute__((noinline)) bool handle_control_command( for (uint8_t k = 0; k < c.manager.catalogCount(); k++) if (memcmp(c.manager.catalogRow(k)->mid, mid, 4) == 0) { sel = c.manager.catalogRow(k); break; } } - if (!sel) { strcpy(reply, "ERR no such update (copy its eight-digit ID from `ota ls`)"); return true; } + if (!sel) { strcpy(reply, MESH_CLI_REPLY_TEXT("ERR no such update (copy its eight-digit ID from `ota ls`)", "ERR no such update; use ID from `ota ls`")); return true; } // destination is MANDATORY: with none given, show the choices (flash always; folder iff a link is up). if (*dst == 0) { #if defined(OTA_SEEDER_ONLY) @@ -580,7 +622,7 @@ static __attribute__((noinline)) bool handle_control_command( snprintf(reply, 160, "choose a destination: `ota pull %s folder` (folder: %s)", selstr, c.folder_dest_info); else - strcpy(reply, "ERR no folder connected (run motatool serve --tcp)"); + strcpy(reply, MESH_CLI_REPLY_TEXT("ERR no folder connected (run motatool serve --tcp)", "ERR no folder; run motatool serve --tcp")); #else if (c.folder_dest) snprintf(reply, 160, "choose a destination: `ota pull %s flash` | `ota pull %s folder` (folder: %s)", @@ -626,7 +668,7 @@ static __attribute__((noinline)) bool handle_control_command( OtaStore* store = nullptr; const char* dname = nullptr; if (to_flash) { #if defined(OTA_SEEDER_ONLY) - strcpy(reply, "ERR seeder-only build cannot stage or install firmware; use `folder`"); + strcpy(reply, MESH_CLI_REPLY_TEXT("ERR seeder-only build cannot stage or install firmware; use `folder`", "ERR source-only: cannot stage or install; use folder")); return true; #else if (selboot) { @@ -774,7 +816,7 @@ static __attribute__((noinline)) bool handle_control_command( store = &c.fetch_store; dname = rescue ? "flash+rescue" : "flash"; #endif } else if (to_folder) { - if (!c.folder_dest) { strcpy(reply, "ERR no folder connected (run motatool serve --tcp/--serial)"); return true; } + if (!c.folder_dest) { strcpy(reply, MESH_CLI_REPLY_TEXT("ERR no folder connected (run motatool serve --tcp/--serial)", "ERR no folder; run motatool serve --tcp/--serial")); return true; } c.folder_dest->set_mid(selmid); store = c.folder_dest; dname = validate ? "folder+validate" : "folder"; } c.manager.reset_session(); @@ -865,9 +907,9 @@ static __attribute__((noinline)) bool handle_control_command( } else if (strcmp(rest, "self") != 0) { strcpy(reply, "ERR usage: ota serve [self|status]"); } else if (!c.self_serve_supported) { - strcpy(reply, "ERR self serve requires ESP32 or external-storage nRF52; host sources use ota folder"); + strcpy(reply, MESH_CLI_REPLY_TEXT("ERR self serve requires ESP32 or external-storage nRF52; host sources use ota folder", "ERR self-serve needs ESP32/external nRF52; use ota folder")); } else if (!ota_serve_self(c, 0)) { - strcpy(reply, "ERR serve self (invalid running image or insufficient RAM)"); + strcpy(reply, MESH_CLI_REPLY_TEXT("ERR serve self (invalid running image or insufficient RAM)", "ERR self-serve: bad image or low RAM")); } else { char midhx[9]; mesh::Utils::toHex(midhx, c.serve_self_manifest + 20, 4); uint32_t image = rd_u32le(c.serve_self_manifest + 11); @@ -941,13 +983,13 @@ static __attribute__((noinline)) bool handle_control_command( (unsigned long)(qspi_capacity / 1024), c.fetch_store.status1(), c.fetch_store.last_stage(), error[0] ? " error=" : "", error); #else - strcpy(reply, "ERR this build does not use a QSPI OTA store"); + strcpy(reply, MESH_CLI_REPLY_TEXT("ERR this build does not use a QSPI OTA store", "ERR no QSPI OTA store")); #endif // ---- running firmware identity (compare against a delta's base_hash) ---- } else if (is_cmd(a, "self|id", &rest)) { SelfFwInfo fi; - if (!ota_self_firmware(fi) || !fi.valid) { strcpy(reply, "ERR no EndF (firmware lacks the trailer?)"); return true; } + if (!ota_self_firmware(fi) || !fi.valid) { strcpy(reply, MESH_CLI_REPLY_TEXT("ERR no EndF (firmware lacks the trailer?)", "ERR no EndF firmware trailer")); return true; } char hx[17]; mesh::Utils::toHex(hx, fi.body_hash, 8); // maxblk is before the optional bootloader diagnostics so it survives the 160-byte remote reply cap. int n = snprintf(reply, 160, "self body=%u image=%u base_hash=%s | maxblk:%u", @@ -1132,7 +1174,7 @@ static __attribute__((noinline)) bool handle_control_command( const bool ok = c.apply_fetched_bootloader(operator_mid, operator_hash8, m2); snprintf(reply, 160, "%s | %s", ok ? "OK" : "ERR", m2); #else - strcpy(reply, "ERR this build cannot update its bootloader over LoRa"); + strcpy(reply, MESH_CLI_REPLY_TEXT("ERR this build cannot update its bootloader over LoRa", "ERR cannot update its bootloader via LoRa")); #endif } else if (is_cmd(a, "install|apply|applydelta", &rest)) { @@ -1141,7 +1183,7 @@ static __attribute__((noinline)) bool handle_control_command( // validates in order (payload hash -> built-for-this-firmware -> signature/trust) and returns the // FIRST failing gate, so the operator knows exactly why it refused; it proceeds only if all pass. #if defined(OTA_SEEDER_ONLY) - strcpy(reply, "ERR seeder-only build cannot install firmware via LoRa"); + strcpy(reply, MESH_CLI_REPLY_TEXT("ERR seeder-only build cannot install firmware via LoRa", "ERR source-only; cannot install via LoRa")); return true; #elif defined(NRF52_PLATFORM) && defined(OTA_SD_STORE) if (c.sdCacheFetching()) { @@ -1262,7 +1304,7 @@ static __attribute__((noinline)) bool handle_control_command( #endif #if defined(OTA_SEEDER_ONLY) if (is_cmd(p, "autofetch|autoinstall", &value)) { - strcpy(reply, "ERR seeder-only build keeps autofetch and autoinstall off"); + strcpy(reply, MESH_CLI_REPLY_TEXT("ERR seeder-only build keeps autofetch and autoinstall off", "ERR source-only: autofetch/autoinstall off")); } else #endif if (is_cmd(p, "autofetch", &value)) { @@ -1339,11 +1381,11 @@ static __attribute__((noinline)) bool handle_control_command( char hx[17]; mesh::Utils::toHex(hx, c.allow.get(i), 8); n += snprintf(reply + n, 160 - n, " %s", hx); } - if (c.allow.count() == 0) strcpy(reply, "no trusted signer keys yet (add one with `ota key add `)"); + if (c.allow.count() == 0) strcpy(reply, MESH_CLI_REPLY_TEXT("no trusted signer keys yet (add one with `ota key add `)", "no trusted signer keys; use `ota key add `")); } } else { - strcpy(reply, "Unknown OTA command. Type `ota help`."); + strcpy(reply, MESH_CLI_REPLY_TEXT("Unknown OTA command. Type `ota help`.", "Unknown OTA command; use ota help")); } return true; } @@ -1353,7 +1395,7 @@ static bool handle_dev(const char* d, char* reply, OtaContext& c) { #if defined(OTA_SEEDER_ONLY) (void)d; (void)c; - strcpy(reply, "ERR ota dev staging/apply is disabled on this seeder-only build"); + strcpy(reply, MESH_CLI_REPLY_TEXT("ERR ota dev staging/apply is disabled on this seeder-only build", "ERR source-only: ota dev staging/apply disabled")); #else if (strncmp(d, "stage ", 6) == 0) { uint32_t sz; diff --git a/src/helpers/ota/OtaManager.cpp b/src/helpers/ota/OtaManager.cpp index 5ef6f0295..31fd615c1 100644 --- a/src/helpers/ota/OtaManager.cpp +++ b/src/helpers/ota/OtaManager.cpp @@ -190,10 +190,10 @@ void OtaManager::begin(uint32_t my_target_id, OtaSend send, void* ctx) { _auto_failure_at = 0; _auto_failure_delay = 0; clearPendingEgress(); releaseColdBuffers(); - _adaptive_packet_speed = 1.0f; - _pace_clean_blocks = 0; - _pace_has_mid = false; - _pace_has_loss = false; + _packet_pacer.reset(); +#if OTA_TRANSFER_DIAGNOSTICS + _wire_stats = {}; +#endif } // ---------------- serve (multi-mota registry) ---------------- @@ -261,6 +261,9 @@ void OtaManager::registerSelfEntry() { memcpy(e.mid, _view0.m.merkle_root, 4); e.target_id = _view0.m.target_id; e.fw_version = _view0.m.fw_version; e.codec_id = _view0.m.codec_id; e.flags = _view0.m.flags; e.have_count = _view0.m.block_count; + e.payload_size = _view0.m.payload_size; + e.block_size_log2 = _view0.m.block_size_log2; + e.source_caps = 0; e.is_self = true; e.src = nullptr; e.src_idx = 0; } @@ -675,6 +678,7 @@ uint32_t OtaManager::proofEgressGapMs() const { void OtaManager::clearPendingEgress() { invalidateDirectTransport(); + _packet_pacer.reset(); _n_manifest_jobs = 0; _n_serve_jobs = 0; _serve_block_len = 0; @@ -815,6 +819,10 @@ bool OtaManager::loadActiveServeBlock() { _serve_wire_deflated = false; // A host-backed MotaSource can provide a pre-compressed block without putting an encoder in firmware. + WireChoice choice = !job.wire_v2 ? WireChoice::LegacyRequest : + !job.allow_deflate ? WireChoice::RawRequest : + v->read_deflated ? WireChoice::HostRaw : + !_deflate_encode ? WireChoice::NoEncoder : WireChoice::NoWorkspace; // Only device encoders need separate raw input and compressed output. Host // output can use the existing outbound cache, which remains pinned until // every DATA fragment has left. Streaming proofs need no output buffer. @@ -833,6 +841,7 @@ bool OtaManager::loadActiveServeBlock() { _serve_wire_block = _serve_block; _serve_wire_len = encoded_len; _serve_wire_deflated = true; + choice = WireChoice::Deflate; } } if (!_serve_wire_block) { @@ -847,57 +856,131 @@ bool OtaManager::loadActiveServeBlock() { if (job.wire_v2 && job.allow_deflate && !v->read_deflated && _deflate_encode && v->scratch && v->scratch_sz >= blen) { uint16_t encoded_len = 0; - if (_deflate_encode(_deflate_encode_ctx, _serve_block, (uint16_t)blen, - v->scratch, (uint16_t)blen, &encoded_len) && - encoded_len > 0 && encoded_len < blen) { + const bool encoded = _deflate_encode(_deflate_encode_ctx, _serve_block, (uint16_t)blen, + v->scratch, (uint16_t)blen, &encoded_len); + choice = encoded ? WireChoice::NoGain : WireChoice::EncodeFailed; + if (encoded && encoded_len > 0 && encoded_len < blen) { _serve_wire_block = v->scratch; _serve_wire_len = encoded_len; _serve_wire_deflated = true; + choice = WireChoice::Deflate; } } } mh4(_serve_wire_id, _serve_wire_block, _serve_wire_len); +#if OTA_TRANSFER_DIAGNOSTICS + _wire_stats.raw_bytes = _serve_block_len; + _wire_stats.encoded_bytes = _serve_wire_len; + _wire_stats.choice = choice; +#endif + (void)choice; _serve_block_loaded = true; return true; } -void OtaManager::noteServedRequestPacing(const uint8_t* mid, uint16_t block, - uint16_t want, uint16_t full_mask) { - if (want != full_mask) { - // A sparse mask is the receiver's explicit evidence that our previous - // DATA burst left holes. One extra packet-airtime gap is much cheaper than - // repeated multi-second request turns on a fast link. - if (!_pace_has_loss || memcmp(_pace_loss_mid, mid, sizeof(_pace_loss_mid)) != 0 - || _pace_last_loss_block != block) { - memcpy(_pace_loss_mid, mid, sizeof(_pace_loss_mid)); - _pace_last_loss_block = block; - _pace_has_loss = true; - if (_adaptive_packet_speed > 0.5f) _adaptive_packet_speed = 0.5f; - else if (_adaptive_packet_speed > 0.25f) { - _adaptive_packet_speed -= 0.1f; - if (_adaptive_packet_speed < 0.25f) _adaptive_packet_speed = 0.25f; - } +float OtaManager::adaptivePacketSpeed(uint8_t profile, uint32_t generation) const { + return _packet_pacer.pace(profile, generation); +} + +uint32_t OtaManager::pacingGraceMs() const { + // A request already travelling back before the last response finished is + // not evidence that those just-transmitted bytes were lost. + uint64_t grace = (uint64_t)_radio_packet_airtime_ms * 2u + 100u; + return grace > OTA_FETCH_RETRY_MAX_MS ? OTA_FETCH_RETRY_MAX_MS : (uint32_t)grace; +} + +bool OtaManager::responseDataKey(const uint8_t* msg, uint16_t len, + const OtaReplyRoute& route, OtaPacketPacer::Key& key, uint16_t& bit, + uint16_t& full_mask, bool& deflated, uint16_t& data_bytes) const { + DataMsg dm; + if (!decode_data(msg, len, dm)) return false; + const int index = serveEntryIndex(dm.manifest_id); + if (index < 0) return false; + // Completion must never resolve a source or read its USB/BLE backing file. + const ServeEntry& entry = _serve[index]; + if (entry.block_size_log2 >= 32) return false; + const uint32_t bs = 1u << entry.block_size_log2; + const uint64_t off = (uint64_t)dm.block_idx * bs; + if (off >= entry.payload_size || bs > OTA_MAX_BLOCK) return false; + const uint16_t raw_len = (uint16_t)((uint64_t)entry.payload_size - off < bs + ? entry.payload_size - off : bs); + key = {}; + key.profile = route.profile; + key.generation = route.generation; + key.block = dm.block_idx; + memcpy(key.mid, dm.manifest_id, 4); + key.format = route.direct ? 4u : 0u; + uint8_t fragment = 0; + uint16_t encoded_len = raw_len; + const bool v2 = (dm.frag_off & OTA_DATA_V2_MARK) != 0; + const uint16_t fragment_data = route.direct ? OTA_DIRECT_FRAG_DATA : + (v2 ? OTA_FRAG_DATA_V2 : OTA_FRAG_DATA); + deflated = false; + if (v2) { + const bool extended = bs > OTA_DATA_V2_LEGACY_MAX_ENCODED; + if (dm.data_len <= OTA_DATA_V2_STREAM_ID_BYTES || !(extended + ? ota_data_v2_unpack_extended(dm.frag_off, raw_len, fragment, encoded_len, deflated) + : ota_data_v2_unpack(dm.frag_off, fragment, encoded_len, deflated))) return false; + if (encoded_len > raw_len || (deflated ? encoded_len >= raw_len : encoded_len != raw_len)) return false; + key.stream = rd_u32le(dm.data); + key.format |= 1u | (extended ? 2u : 0u); + data_bytes = dm.data_len - OTA_DATA_V2_STREAM_ID_BYTES; + } else { + if (route.direct || dm.frag_off % fragment_data != 0) return false; + fragment = dm.frag_off / fragment_data; + data_bytes = dm.data_len; + } + const uint32_t frag_off = (uint32_t)fragment * fragment_data; + if (fragment >= 16 || frag_off >= encoded_len) return false; + const uint16_t remaining = encoded_len - frag_off; + if (data_bytes != (remaining < fragment_data ? remaining : fragment_data)) return false; + bit = (uint16_t)(1u << fragment); + full_mask = frag_full_mask((encoded_len + fragment_data - 1u) / fragment_data); + return true; +} + +void OtaManager::noteResponseTransmitted(const uint8_t* msg, uint16_t len, + const OtaReplyRoute& route, uint32_t now_ms) { + OtaPacketPacer::Key key; + uint16_t bit, full_mask, bytes; + bool deflated; + if (responseDataKey(msg, len, route, key, bit, full_mask, deflated, bytes)) { + if (_packet_pacer.sent(key, bit, now_ms)) { +#if OTA_TRANSFER_DIAGNOSTICS + ++_wire_stats.data_packets; + _wire_stats.data_bytes += bytes; + if (deflated) ++_wire_stats.deflated_packets; +#endif } - _pace_clean_blocks = 0; return; } - if (!_pace_has_mid || memcmp(_pace_mid, mid, sizeof(_pace_mid)) != 0) { - memcpy(_pace_mid, mid, sizeof(_pace_mid)); - _pace_has_mid = true; - _pace_last_full_block = block; - _pace_clean_blocks = 0; + ProofMsg proof; + if (decode_proof(msg, len, proof)) + _packet_pacer.proofSent(route.profile, route.generation, + proof.manifest_id, proof.block_idx, now_ms); +} + +void OtaManager::noteResponseFailed(const uint8_t* msg, uint16_t len, + const OtaReplyRoute& route, uint32_t now_ms) { + (void)now_ms; + OtaPacketPacer::Key key; + uint16_t bit, full_mask, bytes; + bool deflated; + if (responseDataKey(msg, len, route, key, bit, full_mask, deflated, bytes)) { + if (!_packet_pacer.failed(key, bit)) return; + for (uint8_t i = 0; i < _n_serve_jobs; ++i) { + ServeJob& job = _serve_jobs[i]; + if (job.block == key.block && memcmp(job.mid, key.mid, 4) == 0 && job.route == route && + job.wire_v2 == ((key.format & 1u) != 0) && + job.extended_length == ((key.format & 2u) != 0)) + job.emitted_mask &= (uint16_t)~bit; + } return; } - // Count only forward progress; duplicates of a full request are not proof - // that the receiver tolerated the current packet rate. - if (block <= _pace_last_full_block) return; - _pace_last_full_block = block; - if (_adaptive_packet_speed >= 1.0f) return; - if (++_pace_clean_blocks >= 16) { - _adaptive_packet_speed += 0.1f; - if (_adaptive_packet_speed > 1.0f) _adaptive_packet_speed = 1.0f; - _pace_clean_blocks = 0; - } + ProofMsg proof; + if (decode_proof(msg, len, proof)) + _packet_pacer.proofFailed(route.profile, route.generation, + proof.manifest_id, proof.block_idx); } bool OtaManager::handleReq(const uint8_t* m, uint16_t n) { @@ -906,6 +989,8 @@ bool OtaManager::handleReq(const uint8_t* m, uint16_t n) { ServeView* v = resolve(rq.manifest_id); if (!v) return false; bool accepted = false; + bool all_full = true; + uint16_t first_full = UINT16_MAX; const uint32_t block_size = v->m.block_size(); for (uint8_t i = 0; i < rq.n_items; i++) { const uint32_t idx = rq.items[i].block_idx; @@ -927,12 +1012,21 @@ bool OtaManager::handleReq(const uint8_t* m, uint16_t n) { const uint16_t requested = wire_v2 ? ota_req_v2_fragments(rq.items[i].want_mask) : rq.items[i].want_mask; const uint16_t want = (uint16_t)(requested & valid_mask); + if (want != valid_mask) all_full = false; if (want != 0 && queueServeJob(v->m.merkle_root, (uint16_t)idx, want, wire_v2, allow_deflate, extended_length)) { accepted = true; - noteServedRequestPacing(v->m.merkle_root, (uint16_t)idx, want, valid_mask); - } + _packet_pacer.bind(_request_route.profile, _request_route.generation); + const uint8_t format = (wire_v2 ? 1u : 0u) | (extended_length ? 2u : 0u) | + (_request_route.direct ? 4u : 0u); + _packet_pacer.request(_request_route.profile, _request_route.generation, + v->m.merkle_root, (uint16_t)idx, format, want, _now_ms, pacingGraceMs()); + if (want == valid_mask && idx < first_full) first_full = (uint16_t)idx; + } else all_full = false; } + if (accepted && all_full && first_full != UINT16_MAX) + _packet_pacer.forwardRequest(_request_route.profile, _request_route.generation, + v->m.merkle_root, first_full, _now_ms, 0); return accepted; } @@ -1040,7 +1134,13 @@ void OtaManager::serviceEgress(OtaReplyRouteValid route_valid, void* route_ctx) dm.data_len = (uint16_t)frag_len; } uint8_t b[OTA_DIRECT_MESSAGE_MAX]; - if (emit(b, encode_data(b, sizeof(b), dm), false, &job.route)) { + const uint16_t message_len = encode_data(b, sizeof(b), dm); + if (emit(b, message_len, false, &job.route)) { + OtaPacketPacer::Key key; + uint16_t sent_bit, full_mask, bytes; + bool deflated; + if (responseDataKey(b, message_len, job.route, key, sent_bit, full_mask, deflated, bytes)) + _packet_pacer.queued(key, full_mask, sent_bit); const uint16_t bit = (uint16_t)(1u << fragment); job.pending_mask &= (uint16_t)~bit; job.emitted_mask |= bit; @@ -1400,6 +1500,7 @@ void OtaManager::clearReassemblySlot(uint8_t slot) { // Forget all blocks currently being reassembled or awaiting proofs. void OtaManager::clearReassembly() { + _receive_cadence.reset(); for (uint8_t slot = 0; slot < OTA_FETCH_PIPELINE; slot++) clearReassemblySlot(slot); _retry_slot = 0; _req_count = 0; @@ -1416,8 +1517,14 @@ bool OtaManager::anyWireDataReceived() const { return false; } -void OtaManager::downgradeWireToLegacy() { +void OtaManager::downgradeWireToLegacy(WireFallback reason) { if (!_wire_v2_session) return; +#if OTA_TRANSFER_DIAGNOSTICS + _wire_stats.fallback = reason; + if (_wire_stats.fallbacks != UINT16_MAX) ++_wire_stats.fallbacks; +#endif + (void)reason; + _receive_cadence.reset(); if (_direct_session) downgradeDirectTransport(); _wire_v2_session = false; _wire_v2_confirmed = false; @@ -1441,6 +1548,7 @@ void OtaManager::downgradeWireToLegacy() { void OtaManager::downgradeDirectTransport() { if (!_direct_session) return; + _receive_cadence.reset(); invalidateDirectTransport(); _direct_session = false; _direct_confirmed = false; @@ -1494,6 +1602,14 @@ static uint8_t count_mask_bits(uint16_t mask) { return count; } +uint32_t OtaManager::retryServiceIntervalMs(uint32_t physical_interval_ms) const { + uint32_t observed = observedDataIntervalMs(); + // Observations already include the sender's pacing. Undo our later retry + // scaling here so a deliberate slow setting is not applied twice. + if (_speed < 1.0f) observed = (uint32_t)ceil((double)observed * _speed); + return observed > physical_interval_ms ? observed : physical_interval_ms; +} + uint32_t OtaManager::fetchRetryTimeoutMs() const { if (_radio_packet_airtime_ms == 0) return retryDelay(OTA_FETCH_RETRY_FALLBACK_MS); @@ -1516,8 +1632,11 @@ uint32_t OtaManager::fetchRetryTimeoutMs() const { } uint32_t path_transmissions = _observed_path_transmissions; if (path_transmissions == 0) path_transmissions = (uint32_t)_max_hops + 1u; - uint64_t service = (uint64_t)packets * _radio_packet_airtime_ms * path_transmissions; - service = (service * _tx_spacing_permille + 999u) / 1000u; + uint64_t physical_interval = (uint64_t)_radio_packet_airtime_ms * path_transmissions; + physical_interval = (physical_interval * _tx_spacing_permille + 999u) / 1000u; + const uint32_t interval = retryServiceIntervalMs( + physical_interval > UINT32_MAX ? UINT32_MAX : (uint32_t)physical_interval); + uint64_t service = (uint64_t)packets * interval; service = (service * 5u + 3u) / 4u; // 25% CAD/relay contention allowance service += OTA_FETCH_RETRY_GUARD_MS; uint64_t floor = OTA_FETCH_RETRY_MIN_MS; @@ -1527,9 +1646,7 @@ uint32_t OtaManager::fetchRetryTimeoutMs() const { // an unanswered host/relay request. Four full-packet service intervals cover // queued DATA/proof/turnaround; measured airtime and the observed path raise // the floor automatically on slower or relayed links. - uint64_t partial_floor = (uint64_t)_radio_packet_airtime_ms - * _observed_path_transmissions * _tx_spacing_permille * 4u; - partial_floor = (partial_floor + 999u) / 1000u; + uint64_t partial_floor = (uint64_t)interval * 4u; partial_floor += OTA_FETCH_RETRY_GUARD_MS; if (partial_floor < OTA_FETCH_RETRY_PARTIAL_MIN_MS) partial_floor = OTA_FETCH_RETRY_PARTIAL_MIN_MS; @@ -1546,6 +1663,7 @@ uint32_t OtaManager::proofGraceMs() const { if (path_transmissions == 0) path_transmissions = (uint32_t)_max_hops + 1u; uint64_t grace = (uint64_t)_radio_packet_airtime_ms * path_transmissions; grace = (grace * _tx_spacing_permille + 999u) / 1000u; + grace = retryServiceIntervalMs(grace > UINT32_MAX ? UINT32_MAX : (uint32_t)grace); grace += 100u; if (grace < OTA_PROOF_GRACE_MS) grace = OTA_PROOF_GRACE_MS; if (grace > OTA_FETCH_RETRY_MAX_MS) grace = OTA_FETCH_RETRY_MAX_MS; @@ -2185,12 +2303,13 @@ bool OtaManager::handleData(const uint8_t* m, uint16_t n) { // traffic must not force a session-wide downgrade. Never mix it with already accepted v2 bytes. if (_wire_v2_session) { if (_wire_v2_confirmed || anyWireDataReceived()) return false; - downgradeWireToLegacy(); + downgradeWireToLegacy(WireFallback::LegacyPeer); } const uint16_t bit = (uint16_t)(1u << kf); if (slot.mask & bit) return true; // duplicate flood/retry copy is terminal too memcpy(slot.buf + dm.frag_off, dm.data, dm.data_len); slot.mask |= bit; + _receive_cadence.observe(dm.block_idx, (uint8_t)kf, _now_ms); } else { if (!_wire_v2_session || dm.data_len <= OTA_DATA_V2_STREAM_ID_BYTES) return false; uint8_t fragment = 0; @@ -2233,6 +2352,7 @@ bool OtaManager::handleData(const uint8_t* m, uint16_t n) { memcpy(slot.buf + fragment_off, dm.data + OTA_DATA_V2_STREAM_ID_BYTES, fragment_len); slot.mask |= bit; slot.wire_stalls = 0; + _receive_cadence.observe(dm.block_idx, fragment, _now_ms); } noteFetchActivity(); if (slot.mask != slot.need || slot.awaiting_proof) return true; @@ -2260,6 +2380,11 @@ bool OtaManager::handleData(const uint8_t* m, uint16_t n) { _serve_wire_block = nullptr; _serve_wire_len = 0; if (!decoded || decoded_len != blen) { +#if OTA_TRANSFER_DIAGNOSTICS + _wire_stats.fallback = WireFallback::InflateFailed; + if (_wire_stats.fallbacks != UINT16_MAX) ++_wire_stats.fallbacks; +#endif + _receive_cadence.reset(); _wire_allow_deflate = false; // retry this/future blocks as raw v2 slot.mask = 0; const uint16_t fragment_data = fetchFragmentData(); @@ -2384,7 +2509,10 @@ bool OtaManager::requestSlot(uint8_t slot_index) { (unsigned)slot.block, (unsigned)want, (unsigned)_have, (unsigned)_fbc, (unsigned)slot.mask, (unsigned)_req_count); bool sent = emit(b, encode_req(b, sizeof(b), rq), false, fetchReplyRoute()); - if (sent) noteFetchActivity(); + if (sent) { + _receive_cadence.beginFlight(); // recovery turnaround is not DATA cadence + noteFetchActivity(); + } return sent; } @@ -2408,12 +2536,16 @@ bool OtaManager::requestFlight() { (unsigned)_pipeline_width, (unsigned)OTA_FETCH_PIPELINE); bool sent = emit(b, encode_req_window(b, sizeof(b), request), false, fetchReplyRoute()); _flight_request_pending = !sent; - if (sent) noteFetchActivity(); + if (sent) { + _receive_cadence.beginFlight(); + noteFetchActivity(); + } return sent; } bool OtaManager::fillPipeline() { if (activePipelineSlots() != 0) return false; // flights never overlap/refill + _receive_cadence.beginFlight(); uint8_t assigned = 0; while (true) { int slot_index = findEmptyReassemblySlot(); @@ -2459,7 +2591,7 @@ void OtaManager::requestMissing() { // bits (or the first response burst was lost). Retry once in the universal legacy profile instead of // pinning the session to a feature it has not observed on air. if (_wire_v2_session && !_wire_v2_confirmed && !anyWireDataReceived()) { - downgradeWireToLegacy(); + downgradeWireToLegacy(WireFallback::V2Timeout); notePipelineStall(); requestFlight(); return; @@ -2475,7 +2607,7 @@ void OtaManager::requestMissing() { // then clear the whole flight and ask again in the universal legacy profile so an old seeder can take over. const bool empty_v2 = _wire_v2_session && _wire_v2_confirmed && !anyWireDataReceived(); if (empty_v2 && _wire_empty_stalls >= 1) { - downgradeWireToLegacy(); + downgradeWireToLegacy(WireFallback::V2Stall); requestFlight(); return; } @@ -2498,7 +2630,7 @@ void OtaManager::requestMissing() { // remain. Keep one sparse retry, then fall back the whole flight to the universal profile. Clearing // every slot prevents geometry/representation mixing and guarantees that old sources can take over. if (_direct_session) downgradeDirectTransport(); - else downgradeWireToLegacy(); + else downgradeWireToLegacy(WireFallback::V2Stall); } if (requestSlot(slot)) { // Advance fallback evidence only when the sparse retry enters the diff --git a/src/helpers/ota/OtaManager.h b/src/helpers/ota/OtaManager.h index 1ec321ed8..b891ec817 100644 --- a/src/helpers/ota/OtaManager.h +++ b/src/helpers/ota/OtaManager.h @@ -6,6 +6,8 @@ #include "OtaFormat.h" #include "OtaMemoryPolicy.h" #include "OtaTiming.h" +#include "OtaReceiveCadence.h" +#include "OtaPacketPacer.h" #include "OtaProtocol.h" #include "OtaDirectWire.h" #include "OtaByteIO.h" @@ -13,6 +15,9 @@ #include "MotaContainer.h" #include "OtaSource.h" #include "MerkleTree.h" +#ifndef OTA_TRANSFER_DIAGNOSTICS +#define OTA_TRANSFER_DIAGNOSTICS 1 +#endif #if OTA_BOUNDED_SOURCE_PROOFS #include "SourceMerkleProof.h" #endif @@ -493,7 +498,39 @@ public: float speed() const { return _speed; } // Sender-only, loss-responsive packet pacing. This does not alter discovery, // proof, or retry timers; the user's persisted speed remains an upper bound. - float adaptivePacketSpeed() const { return _adaptive_packet_speed; } + float adaptivePacketSpeed() const { return _packet_pacer.pace(); } + float adaptivePacketSpeed(uint8_t profile) const { return _packet_pacer.pace(profile); } + float adaptivePacketSpeed(uint8_t profile, uint32_t generation) const; + void noteResponseTransmitted(const uint8_t* msg, uint16_t len, + const OtaReplyRoute& route, uint32_t now_ms); + void noteResponseFailed(const uint8_t* msg, uint16_t len, + const OtaReplyRoute& route, uint32_t now_ms); + // Local diagnostics; no extra telemetry packets or protocol fields. + enum class WireChoice : uint8_t { + None, Deflate, LegacyRequest, RawRequest, HostRaw, NoEncoder, NoWorkspace, NoGain, EncodeFailed + }; + enum class WireFallback : uint8_t { None, LegacyPeer, V2Timeout, V2Stall, InflateFailed }; + struct WireStats { + uint32_t data_packets = 0; + uint32_t deflated_packets = 0; + uint32_t data_bytes = 0; + uint16_t raw_bytes = 0; + uint16_t encoded_bytes = 0; + uint16_t fallbacks = 0; + WireChoice choice = WireChoice::None; + WireFallback fallback = WireFallback::None; + }; + const WireStats& wireStats() const { +#if OTA_TRANSFER_DIAGNOSTICS + return _wire_stats; +#else + static const WireStats unavailable{}; + return unavailable; +#endif + } + bool fetchWireV2() const { return _wire_v2_session; } + bool fetchAllowsDeflate() const { return _wire_allow_deflate; } + uint32_t observedDataIntervalMs() const { return _receive_cadence.serviceIntervalMs(0); } uint32_t pacedDelay(uint32_t ms) const { return scaleDelay(ms, _speed); } // Faster pacing cannot shorten a loss-recovery deadline below the existing // physical packet-flight allowance. Slower pacing expands that allowance. @@ -506,6 +543,9 @@ public: // Feed the actual TempRadio packet airtime and dispatcher transmit-spacing factor. The fetch retry // deadline then follows SF/BW, configured airtime budget, flight width, and observed/configured path. void set_link_timing(uint32_t max_packet_airtime_ms, uint16_t tx_spacing_permille) { + if (_radio_packet_airtime_ms != max_packet_airtime_ms || + _tx_spacing_permille != (tx_spacing_permille < 1000 ? 1000 : tx_spacing_permille)) + _receive_cadence.reset(); _radio_packet_airtime_ms = max_packet_airtime_ms; _tx_spacing_permille = tx_spacing_permille < 1000 ? 1000 : tx_spacing_permille; } @@ -718,8 +758,11 @@ private: bool queueServeJob(const uint8_t* mid, uint16_t block, uint16_t want_mask, bool wire_v2 = false, bool allow_deflate = false, bool extended_length = false); - void noteServedRequestPacing(const uint8_t* mid, uint16_t block, - uint16_t want, uint16_t full_mask); + bool responseDataKey(const uint8_t* msg, uint16_t len, const OtaReplyRoute& route, + OtaPacketPacer::Key& key, uint16_t& bit, uint16_t& full_mask, + bool& deflated, uint16_t& data_bytes) const; + uint32_t pacingGraceMs() const; + uint32_t retryServiceIntervalMs(uint32_t physical_interval_ms) const; bool queueManifestJob(const uint8_t* mid, uint16_t want_mask, bool leaves = false); uint32_t manifestEgressGapMs() const; uint32_t proofEgressGapMs() const; @@ -753,7 +796,7 @@ private: void requestMissing(); // start a flight, or recover one stalled slot uint16_t wireRequestMask(uint32_t block, uint16_t need, uint16_t received, uint16_t encoded_len) const; - void downgradeWireToLegacy(); // old source answered/ignored the v2 request profile + void downgradeWireToLegacy(WireFallback reason); // old source answered/ignored the v2 request profile void downgradeDirectTransport(); // preserve committed blocks; discard unverified geometry void invalidateDirectTransport(); // retire already-admitted direct raw frames uint16_t fetchFragmentData() const { @@ -817,14 +860,7 @@ private: }; ServeJob _serve_jobs[OTA_SERVE_QUEUE]; uint8_t _n_serve_jobs = 0; - float _adaptive_packet_speed = 1.0f; - uint8_t _pace_clean_blocks = 0; - uint8_t _pace_mid[4] = {0}; - uint16_t _pace_last_full_block = 0; - bool _pace_has_mid = false; - uint8_t _pace_loss_mid[4] = {0}; - uint16_t _pace_last_loss_block = 0; - bool _pace_has_loss = false; + OtaPacketPacer _packet_pacer; struct ManifestServeJob { OtaReplyRoute route; uint8_t mid[4]; @@ -837,7 +873,6 @@ private: uint8_t _n_manifest_jobs = 0; OtaReplyRoute _request_route; const OtaReplyRoute* _reply_route = nullptr; - uint8_t _serve_block[OTA_MAX_BLOCK]; uint16_t _serve_block_len = 0; bool _serve_block_loaded = false; // Raw/host-encoded _serve_block, or device-encoder output in ServeView::scratch. @@ -850,6 +885,9 @@ private: void* _deflate_encode_ctx = nullptr; OtaDeflateDecodeFn _deflate_decode = nullptr; void* _deflate_decode_ctx = nullptr; +#if OTA_TRANSFER_DIAGNOSTICS + WireStats _wire_stats; +#endif // fetch OtaStore* _fetch = nullptr; @@ -917,12 +955,12 @@ private: uint8_t wire_stalls = 0; // eventually unlock a vanished representation and retry cleanly uint8_t buf[OTA_MAX_BLOCK]; }; - ReassemblySlot _reasm[OTA_FETCH_PIPELINE]; uint8_t _retry_slot = 0; // round-robin stalled-slot retry cursor uint8_t _pipeline_width = OTA_FETCH_PIPELINE_INITIAL; // live request window, adaptive up to array capacity bool _flight_dirty = false; // a timeout/bad proof required recovery in this flight bool _flight_request_pending = false; // local queue has not accepted this flight yet uint32_t _fetch_wait_since_ms = 0; // last new fragment/proof/request; retry deadline anchor + OtaReceiveCadence _receive_cadence; uint32_t _radio_packet_airtime_ms = 0; // measured for MAX_TRANS_UNIT at active SF/BW float _speed = OTA_SPEED_DEFAULT; uint16_t _tx_spacing_permille = 2000; // 1/duty-cycle; default airtime factor 1 => 50% TX @@ -980,6 +1018,10 @@ private: uint8_t _n_cat = 0; bool _catalog_browse_requested = false; uint32_t _now_ms = 0; // coarse clock (fed by set_clock; for ages/LRU/jitter) + // Keep scalar state within Thumb's short field-access range. These buffers + // have no persistent or wire layout and retain their existing capacity. + uint8_t _serve_block[OTA_MAX_BLOCK]; + ReassemblySlot _reasm[OTA_FETCH_PIPELINE]; }; } // namespace ota diff --git a/src/helpers/ota/OtaPacketPacer.h b/src/helpers/ota/OtaPacketPacer.h new file mode 100644 index 000000000..4f7092f95 --- /dev/null +++ b/src/helpers/ota/OtaPacketPacer.h @@ -0,0 +1,302 @@ +#pragma once + +#include +#include + +namespace mesh { namespace ota { + +// Source-side evidence only. No wire fields, allocations, RSSI polling, or +// assumptions that admission to a radio queue means the packet was transmitted. +class OtaPacketPacer { +public: + enum Format : uint8_t { V2 = 1, Extended = 2, Direct = 4 }; + enum Feedback : uint8_t { Ignored, Suppressed, Duplicate, Loss }; + static constexpr uint8_t HistorySlots = 6; + static constexpr uint8_t PortableProfile = 0xFF; + + struct Key { + uint32_t generation = 0; + uint32_t stream = 0; // zero for legacy; v2's encoded representation ID + uint8_t mid[4] = {0}; + uint16_t block = 0; + uint8_t profile = PortableProfile; + uint8_t format = 0; + }; + + void reset() { + for (auto& entry : _history) entry = Entry{}; + for (auto& state : _states) state = State{}; + _next = 0; + _last = 2; + } + + // Bind only an accepted, currently valid radio route. Stale TX callbacks + // never bind a route and therefore cannot resurrect an earlier generation. + void bind(uint8_t profile, uint32_t generation) { + const uint8_t slot = slotFor(profile); + if (slot >= 3) return; + State& state = _states[slot]; + if (!(state.flags & StateValid) || state.generation != generation) { + state = State{}; + state.generation = generation; + state.level = state.stable = 3; + state.flags = StateValid; + for (auto& entry : _history) + if ((entry.flags & Valid) && entry.slot == slot) entry.flags = 0; + } + _last = slot; + } + + float pace(uint8_t profile, uint32_t generation) const { + const uint8_t slot = activeSlot(profile, generation); + return slot < 3 ? speed(_states[slot].level) : 1.0f; + } + float pace() const { + return (_states[_last].flags & StateValid) ? speed(_states[_last].level) : 1.0f; + } + float pace(uint8_t profile) const { + const uint8_t slot = slotFor(profile); + return slot < 3 && (_states[slot].flags & StateValid) ? speed(_states[slot].level) : 1.0f; + } + + // Call only after successful queue admission. full_mask describes this exact + // encoded representation, rather than the larger uncompressed block. + bool queued(const Key& key, uint16_t full_mask, uint16_t fragment_bit) { + const uint8_t slot = activeSlot(key.profile, key.generation); + if (slot >= 3 || !singleBit(fragment_bit) || !(full_mask & fragment_bit)) return false; + Entry* entry = find(key, slot); + if (entry && entry->full_mask != full_mask) return false; + if (!entry) { + uint8_t inherited = 0; + for (const auto& prior : _history) + if (matches(prior, slot, key.mid, key.block)) + inherited |= prior.flags & (LossNoted | Consumed); + entry = allocate(slot); + if (!entry) return false; // unknown evidence is never called loss + *entry = Entry{}; + entry->stream = key.stream; + entry->mid = manifestWord(key.mid); + entry->block = key.block; + entry->full_mask = full_mask; + entry->slot = slot; + entry->format = key.format; + entry->flags = Valid | inherited; + } + const uint8_t shift = bitIndex(fragment_bit) * 2; + const uint8_t count = uint8_t((entry->queued_counts >> shift) & 3u); + if (count == 3) { + entry->flags |= TxFailed; // cannot represent another copy: remain conservative + return false; + } + entry->queued_counts += uint32_t(1) << shift; + return true; + } + + bool sent(const Key& key, uint16_t fragment_bit, uint32_t now) { + Entry* entry = queuedEntry(key, fragment_bit); + if (!entry) return false; + decrement(*entry, fragment_bit); + entry->sent_mask |= fragment_bit; + entry->last_tx = now; + return true; + } + bool failed(const Key& key, uint16_t fragment_bit) { + Entry* entry = queuedEntry(key, fragment_bit); + if (!entry) return false; + decrement(*entry, fragment_bit); + entry->flags |= TxFailed; + return true; + } + + // PROOF is common to every encoding of a logical block. It may credit only + // representations whose DATA actually finished, not merely queued ones. + bool proofSent(uint8_t profile, uint32_t generation, const uint8_t mid[4], + uint16_t block, uint32_t now) { + const uint8_t slot = activeSlot(profile, generation); + if (slot >= 3) return false; + bool matched = false; + for (auto& entry : _history) { + if (matches(entry, slot, mid, block) && dataComplete(entry)) { + entry.flags |= ProofSent; + entry.last_tx = now; + matched = true; + } + } + return matched; + } + bool proofFailed(uint8_t profile, uint32_t generation, const uint8_t mid[4], uint16_t block) { + const uint8_t slot = activeSlot(profile, generation); + if (slot >= 3) return false; + bool matched = false; + for (auto& entry : _history) { + if (matches(entry, slot, mid, block)) { + entry.flags |= TxFailed; + matched = true; + } + } + return matched; + } + + Feedback request(uint8_t profile, uint32_t generation, const uint8_t mid[4], + uint16_t block, uint8_t format, uint16_t want, + uint32_t now, uint32_t grace_ms) { + const uint8_t slot = activeSlot(profile, generation); + if (slot >= 3 || !want) return Ignored; + Entry* candidate = nullptr; + for (auto& entry : _history) { + if (!matches(entry, slot, mid, block) || entry.format != format) continue; + // A request does not echo the v2 stream ID. Different recent encodings + // make its mask ambiguous, so do not attribute loss to either of them. + if (candidate) return suppress(slot); + candidate = &entry; + } + if (!candidate) return Ignored; + Entry& entry = *candidate; + want &= entry.full_mask; + // Full requests can be delayed duplicates of the original request. They + // are not evidence of a missing subset, and must not cause a penalty. + if (!want || want == entry.full_mask || (entry.flags & Consumed)) return Ignored; + if (entry.flags & LossNoted) return Duplicate; + if (!dataComplete(entry) || (entry.flags & TxFailed) + || uint32_t(now - entry.last_tx) < grace_ms) return suppress(slot); + // An encoding fallback is still the same logical block. One lost block + // must not receive another penalty solely because its representation changed. + for (auto& prior : _history) + if (matches(prior, slot, mid, block)) prior.flags |= LossNoted; + State& state = _states[slot]; + state.clean = 0; + if (state.level != state.stable) { + state.level = state.stable; + } else { + state.level = state.level > 1 ? 1 : 0; + state.stable = state.level; + } + return Loss; + } + + // Invoke once for an accepted all-full forward request window, using its + // smallest block index. Initial/full duplicates alone never credit recovery. + bool forwardRequest(uint8_t profile, uint32_t generation, const uint8_t mid[4], + uint16_t first_full_block, uint32_t now, uint32_t grace_ms) { + const uint8_t slot = activeSlot(profile, generation); + if (slot >= 3) return false; + bool any = false, clean = true; + for (const auto& entry : _history) { + if (!older(entry, slot, mid, first_full_block)) continue; + any = true; + // One complete encoding proves this block's response finished. An + // abandoned encoding must not pin forward recovery forever, but its + // dirty/incomplete history does prevent calling this a clean sample. + bool completed = false; + for (const auto& representation : _history) { + if (matches(representation, slot, mid, entry.block) + && dataComplete(representation) && (representation.flags & ProofSent) + && uint32_t(now - representation.last_tx) >= grace_ms) completed = true; + } + if (!completed) return false; + if (!dataComplete(entry) || !(entry.flags & ProofSent) + || (entry.flags & (LossNoted | TxFailed))) clean = false; + } + if (!any) return false; + for (auto& entry : _history) + if (older(entry, slot, mid, first_full_block)) entry.flags |= Consumed; + State& state = _states[slot]; + if (!clean) { state.clean = 0; return false; } + state.stable = state.level; // A clean forward flight confirms any active probe. + if (state.level == 3) { state.clean = 0; return true; } + if (++state.clean >= 2) { + state.clean = 0; + state.level = state.stable + 1; + } + return true; + } + +private: + enum : uint8_t { StateValid = 1 }; + enum : uint8_t { Valid = 1, ProofSent = 2, LossNoted = 4, Consumed = 8, TxFailed = 16 }; + struct State { + uint32_t generation = 0; + uint8_t level = 3, stable = 3, clean = 0, flags = 0; + }; + struct Entry { + uint32_t stream = 0, last_tx = 0, queued_counts = 0; + uint32_t mid = 0; + uint16_t block = 0, full_mask = 0, sent_mask = 0; + uint8_t slot = 0, format = 0, flags = 0; + }; + State _states[3] = {}; + Entry _history[HistorySlots] = {}; + uint8_t _next = 0, _last = 2; + + static float speed(uint8_t level) { return float(level + 1) * 0.25f; } + static uint8_t slotFor(uint8_t profile) { return profile < 2 ? profile : profile == PortableProfile ? 2 : 0xFF; } + uint8_t activeSlot(uint8_t profile, uint32_t generation) const { + const uint8_t slot = slotFor(profile); + return slot < 3 && (_states[slot].flags & StateValid) + && _states[slot].generation == generation ? slot : 0xFF; + } + static bool singleBit(uint16_t bit) { return bit && !(bit & (bit - 1)); } + static uint8_t bitIndex(uint16_t bit) { + // Every caller already validated a nonzero single fragment bit. +#if defined(__GNUC__) || defined(__clang__) + return uint8_t(__builtin_ctz(unsigned(bit))); +#else + uint8_t index = 0; + while (bit > 1) { bit >>= 1; ++index; } + return index; +#endif + } + static uint32_t manifestWord(const uint8_t mid[4]) { + uint32_t value; + memcpy(&value, mid, sizeof(value)); // Safe for unaligned packet payloads. + return value; + } + static bool matches(const Entry& entry, uint8_t slot, const uint8_t mid[4], uint16_t block) { + return (entry.flags & Valid) && entry.slot == slot && entry.block == block + && entry.mid == manifestWord(mid); + } + static bool dataComplete(const Entry& entry) { + return entry.full_mask && entry.sent_mask == entry.full_mask && entry.queued_counts == 0; + } + static bool older(const Entry& entry, uint8_t slot, const uint8_t mid[4], uint16_t block) { + return (entry.flags & Valid) && !(entry.flags & Consumed) && entry.slot == slot + && entry.block < block && entry.mid == manifestWord(mid); + } + Entry* find(const Key& key, uint8_t slot) { + for (auto& entry : _history) + if (matches(entry, slot, key.mid, key.block) && entry.format == key.format + && entry.stream == key.stream) return &entry; + return nullptr; + } + Entry* allocate(uint8_t) { + for (auto& entry : _history) if (!(entry.flags & Valid)) return &entry; + for (uint8_t pass = 0; pass < 2; ++pass) { + for (uint8_t n = 0; n < HistorySlots; ++n) { + const uint8_t index = uint8_t((_next + n) % HistorySlots); + Entry& entry = _history[index]; + if (entry.queued_counts || (pass == 0 && !(entry.flags & Consumed))) continue; + _next = uint8_t((index + 1) % HistorySlots); + return &entry; + } + } + return nullptr; + } + Entry* queuedEntry(const Key& key, uint16_t bit) { + const uint8_t slot = activeSlot(key.profile, key.generation); + if (slot >= 3 || !singleBit(bit)) return nullptr; + Entry* entry = find(key, slot); + if (!entry || !(entry->full_mask & bit) + || !((entry->queued_counts >> (bitIndex(bit) * 2)) & 3u)) return nullptr; + return entry; + } + static void decrement(Entry& entry, uint16_t bit) { + entry.queued_counts -= uint32_t(1) << (bitIndex(bit) * 2); + } + static Feedback suppress(uint8_t) { + return Suppressed; + } +}; + +static_assert(sizeof(OtaPacketPacer) <= 288, "OTA pacer must remain a small fixed workspace"); + +} } diff --git a/src/helpers/ota/OtaReceiveCadence.h b/src/helpers/ota/OtaReceiveCadence.h new file mode 100644 index 000000000..b100249ed --- /dev/null +++ b/src/helpers/ota/OtaReceiveCadence.h @@ -0,0 +1,76 @@ +#pragma once + +#include + +namespace mesh { namespace ota { + +// Learn DATA service time, not request/proof/idle turnaround. Call observe() +// only after accepting a new, structurally valid fragment. A flight boundary +// clears timestamp anchors while retaining the learned interval; a new fetch, +// wire representation or radio configuration must reset the whole estimator. +class OtaReceiveCadence { +public: + static constexpr uint32_t MaxSampleMs = 60000; + + OtaReceiveCadence() { reset(); } + + void reset() { + interval_ms_ = 0; + beginFlight(); + } + + void beginFlight() { + for (uint8_t i = 0; i < 2; ++i) anchors_[i].block = NoBlock; + next_anchor_ = 0; + } + + bool observe(uint32_t block, uint8_t fragment, uint32_t now_ms) { + if (block == NoBlock || fragment >= 16) return false; + Anchor* anchor = nullptr; + for (uint8_t i = 0; i < 2; ++i) { + if (anchors_[i].block == block) { anchor = &anchors_[i]; break; } + } + if (!anchor) { + anchor = &anchors_[next_anchor_]; + next_anchor_ ^= 1; + anchor->block = block; + anchor->fragment = fragment; + anchor->at_ms = now_ms; + return false; + } + // Duplicate and backward copies must not move the timestamp forward and + // manufacture a short cadence for the next newly accepted fragment. + if (fragment <= anchor->fragment) return false; + const bool consecutive = fragment == uint8_t(anchor->fragment + 1); + const uint32_t elapsed = now_ms - anchor->at_ms; // millis rollover is valid + anchor->fragment = fragment; + anchor->at_ms = now_ms; + // Skipped fragments can include recovery turns; do not teach their gap as + // normal packet pacing. A later consecutive pair starts sampling again. + if (!consecutive || elapsed == 0 || elapsed > MaxSampleMs) return false; + if (elapsed >= interval_ms_) interval_ms_ = elapsed; + else interval_ms_ -= (interval_ms_ - elapsed + 3) / 4; + return true; + } + + uint32_t serviceIntervalMs(uint32_t physical_fallback_ms) const { + // The caller's airtime/path/duty allowance remains an absolute floor. + return interval_ms_ > physical_fallback_ms ? interval_ms_ : physical_fallback_ms; + } + +private: + static constexpr uint32_t NoBlock = UINT32_MAX; + struct Anchor { + uint32_t block; + uint32_t at_ms; + uint8_t fragment; + }; + Anchor anchors_[2]; + uint32_t interval_ms_; + uint8_t next_anchor_; +}; + +static_assert(sizeof(OtaReceiveCadence) <= 40, + "OTA receive cadence must remain allocation-free and bounded"); + +} } diff --git a/test/fixtures/cli_reply_text.json b/test/fixtures/cli_reply_text.json index 7d53d4a8f..d2866bf4c 100644 --- a/test/fixtures/cli_reply_text.json +++ b/test/fixtures/cli_reply_text.json @@ -1403,6 +1403,81 @@ "file": "examples/companion_radio/MyMesh.cpp", "verbose": " ERROR: trace to %s is still pending\r\n", "concise": " ERROR: trace to %s pending\r\n" + }, + { + "file": "src/helpers/ota/OtaCli.cpp", + "verbose": "No updates seen yet - re-run `ota ls` in a few seconds (just asked around).", + "concise": "No updates seen yet; retry `ota ls` shortly." + }, + { + "file": "src/helpers/ota/OtaCli.cpp", + "verbose": "ERR no such update (copy its eight-digit ID from `ota ls`)", + "concise": "ERR no such update; use ID from `ota ls`" + }, + { + "file": "src/helpers/ota/OtaCli.cpp", + "verbose": "ERR no folder connected (run motatool serve --tcp)", + "concise": "ERR no folder; run motatool serve --tcp" + }, + { + "file": "src/helpers/ota/OtaCli.cpp", + "verbose": "ERR seeder-only build cannot stage or install firmware; use `folder`", + "concise": "ERR source-only: cannot stage or install; use folder" + }, + { + "file": "src/helpers/ota/OtaCli.cpp", + "verbose": "ERR no folder connected (run motatool serve --tcp/--serial)", + "concise": "ERR no folder; run motatool serve --tcp/--serial" + }, + { + "file": "src/helpers/ota/OtaCli.cpp", + "verbose": "ERR self serve requires ESP32 or external-storage nRF52; host sources use ota folder", + "concise": "ERR self-serve needs ESP32/external nRF52; use ota folder" + }, + { + "file": "src/helpers/ota/OtaCli.cpp", + "verbose": "ERR serve self (invalid running image or insufficient RAM)", + "concise": "ERR self-serve: bad image or low RAM" + }, + { + "file": "src/helpers/ota/OtaCli.cpp", + "verbose": "ERR this build does not use a QSPI OTA store", + "concise": "ERR no QSPI OTA store" + }, + { + "file": "src/helpers/ota/OtaCli.cpp", + "verbose": "ERR no EndF (firmware lacks the trailer?)", + "concise": "ERR no EndF firmware trailer" + }, + { + "file": "src/helpers/ota/OtaCli.cpp", + "verbose": "ERR this build cannot update its bootloader over LoRa", + "concise": "ERR cannot update its bootloader via LoRa" + }, + { + "file": "src/helpers/ota/OtaCli.cpp", + "verbose": "ERR seeder-only build cannot install firmware via LoRa", + "concise": "ERR source-only; cannot install via LoRa" + }, + { + "file": "src/helpers/ota/OtaCli.cpp", + "verbose": "ERR seeder-only build keeps autofetch and autoinstall off", + "concise": "ERR source-only: autofetch/autoinstall off" + }, + { + "file": "src/helpers/ota/OtaCli.cpp", + "verbose": "no trusted signer keys yet (add one with `ota key add `)", + "concise": "no trusted signer keys; use `ota key add `" + }, + { + "file": "src/helpers/ota/OtaCli.cpp", + "verbose": "Unknown OTA command. Type `ota help`.", + "concise": "Unknown OTA command; use ota help" + }, + { + "file": "src/helpers/ota/OtaCli.cpp", + "verbose": "ERR ota dev staging/apply is disabled on this seeder-only build", + "concise": "ERR source-only: ota dev staging/apply disabled" } ], "noncompact_tokens_sha256": { @@ -1417,6 +1492,7 @@ "examples/companion_radio/MyMesh.cpp": "562448d691a8b1b39a4f656c4acdde966d0ce21ebb63cfef4640d1b3ac1db694", "src/helpers/CommonRadioPrefs.cpp": "16b1fb499426abde83566b6481df89b80302c72e00f0f4aa3843b6c9b9de4218", "examples/companion_radio/CompanionRetry.h": "e0ae10c7b2f778346f4645c4e147d87e1fbc7f8b99484752a8eed914bb21380f", - "examples/companion_radio/Notifications.cpp": "173fb0d452fdd383585dd88ab35fbb079a90e068adca2db9bc44d604557d3873" + "examples/companion_radio/Notifications.cpp": "173fb0d452fdd383585dd88ab35fbb079a90e068adca2db9bc44d604557d3873", + "src/helpers/ota/OtaCli.cpp": "24215f65dd215a7d80869c7db6da86275f1a3be252e8bc5fe72c51209edc55d2" } } diff --git a/test/fixtures/ota_cli_listing/test.cpp b/test/fixtures/ota_cli_listing/test.cpp index bca7efb92..0bf8f8563 100644 --- a/test/fixtures/ota_cli_listing/test.cpp +++ b/test/fixtures/ota_cli_listing/test.cpp @@ -3,6 +3,7 @@ #include #include #include +#include "helpers/CLIReplyText.h" #include "helpers/ota/OtaTargets.h" namespace mesh { diff --git a/test/fixtures/ota_folder_resume.cpp b/test/fixtures/ota_folder_resume.cpp index 9637eab14..d871341d8 100644 --- a/test/fixtures/ota_folder_resume.cpp +++ b/test/fixtures/ota_folder_resume.cpp @@ -2,6 +2,7 @@ #include #include #include +#include #include #include "test_ota/mota_vectors.h" #include diff --git a/test/test_bootloader_update_cli.py b/test/test_bootloader_update_cli.py index 88bac4a3e..afff8064d 100644 --- a/test/test_bootloader_update_cli.py +++ b/test/test_bootloader_update_cli.py @@ -17,6 +17,7 @@ SOURCE = r''' #include #include #include +#include namespace mesh { struct Utils { static bool fromHex(uint8_t* out, size_t size, const char* text) { @@ -221,7 +222,8 @@ class BootloaderUpdateCliTest(unittest.TestCase): built = subprocess.run([ compiler, "-std=c++17", "-O1", "-g", "-Wall", "-Wextra", "-Werror", "-Wno-unused-function", "-Wno-unused-parameter", *sanitize, - *["-D" + macro + "=1" for macro in macros], str(source), "-o", str(binary), + *["-D" + macro + "=1" for macro in macros], "-I", str(ROOT / "src"), + str(source), "-o", str(binary), ], capture_output=True, text=True, timeout=60) self.assertEqual(built.returncode, 0, built.stdout + built.stderr) tested = subprocess.run([str(binary)], capture_output=True, text=True, timeout=30) diff --git a/test/test_cli_reply_text.py b/test/test_cli_reply_text.py index 1442d949b..6d1aba3a6 100644 --- a/test/test_cli_reply_text.py +++ b/test/test_cli_reply_text.py @@ -43,7 +43,12 @@ class CliReplyTextTests(unittest.TestCase): self.assertEqual(re.findall(r'\d+(?:\.\d+)?', old), re.findall(r'\d+(?:\.\d+)?', new)) self.assertFalse(old.startswith(('> ', '{', 'KEYBACKUP ', 'ACK'))) - self.assertNotRegex(old.lower().replace('[reboot]', ''), + protected = old.lower().replace('[reboot]', '') + if (row['file'] == 'src/helpers/ota/OtaCli.cpp' and + old == 'ERR self serve requires ESP32 or external-storage nRF52; host sources use ota folder'): + # This describes source capabilities, not host data or its retention. + protected = protected.replace('host sources use ota folder', 'ota folder') + self.assertNotRegex(protected, r'host|replay|password|login|save unconfirmed|reboot|' r'retained|warning|applying in|restore scheduled') for protected in ('[reboot]', 'default'): @@ -62,6 +67,29 @@ class CliReplyTextTests(unittest.TestCase): self.assertEqual(hashlib.sha256(original_tokens(source)).hexdigest(), fixture['noncompact_tokens_sha256'][file], file) + def test_ota_prose_keeps_discovery_and_retry_parser_keywords(self): + messages = {row['verbose']: row['concise'] + for row in json.loads(FIXTURE.read_text())['messages'] + if row['file'] == 'src/helpers/ota/OtaCli.cpp'} + self.assertEqual(len(messages), 15) + self.assertGreaterEqual(sum(len(old) - len(new) + for old, new in messages.items()), 240) + for prefix in ('No updates seen yet', 'ERR no such update', + 'no trusted signer keys'): + rows = [(old, new) for old, new in messages.items() if old.startswith(prefix)] + self.assertEqual(len(rows), 1, prefix) + self.assertTrue(rows[0][1].startswith(prefix), prefix) + for old, new in messages.items(): + self.assertFalse(old.startswith(('OK', 'usage:', 'ERR usage:'))) + for token in re.findall(r'`([^`]+)`', old): + self.assertIn(token, new) + if 'disabled' in old: + self.assertIn('disabled', new) + if 'cannot install' in old: + self.assertIn('cannot install', new) + if 'cannot update its bootloader' in old: + self.assertIn('cannot update its bootloader', new) + def test_profile_activation_is_explicit_and_default_is_off(self): fixture = json.loads(FIXTURE.read_text()) enabled = [] diff --git a/test/test_nrf52_flash_trim.py b/test/test_nrf52_flash_trim.py index eef8a07a9..9dc5884d8 100644 --- a/test/test_nrf52_flash_trim.py +++ b/test/test_nrf52_flash_trim.py @@ -295,7 +295,9 @@ char* other(char* destination, const char* source) { return strcpy(destination, companion = BuildEnvironment(BUILD_FLAGS=list(base["BUILD_FLAGS"]), SRC_FILTER=["+<../examples/companion_radio/*.cpp>"], CPPDEFINES=[], PIOENV="RAK_4631_companion_radio_full") - TRIM.install(companion) + with patch.object(TRIM, "install_ota_pacing_size") as size_policy: + TRIM.install(companion) + size_policy.assert_called_once_with(companion) self.assertIn(("MESH_CLI_SHARE_REPLY_COPIES", 1), companion["CPPDEFINES"]) self.assertEqual(companion["BUILD_FLAGS"], base["BUILD_FLAGS"]) @@ -316,7 +318,9 @@ char* other(char* destination, const char* source) { return strcpy(destination, explicit = BuildEnvironment(BUILD_FLAGS=["-DMESH_CLI_SHARE_REPLY_COPIES=1"], SRC_FILTER=["+<../examples/companion_radio/*.cpp>"], CPPDEFINES=[], PIOENV="RAK_4631_companion_radio_full") - TRIM.install(explicit) + with patch.object(TRIM, "install_ota_pacing_size") as size_policy: + TRIM.install(explicit) + size_policy.assert_called_once_with(explicit) self.assertNotIn(("MESH_CLI_SHARE_REPLY_COPIES", 1), explicit["CPPDEFINES"]) self.assertEqual(explicit["BUILD_FLAGS"], ["-DMESH_CLI_SHARE_REPLY_COPIES=1"]) diff --git a/test/test_nrf52_ota_pacing_size.py b/test/test_nrf52_ota_pacing_size.py new file mode 100644 index 000000000..dd0149a22 --- /dev/null +++ b/test/test_nrf52_ota_pacing_size.py @@ -0,0 +1,203 @@ +"""Keep the RAK Full size workaround scoped and preserve the reviewed SDK.""" + +import hashlib +import importlib.util +from pathlib import Path +import tempfile +import unittest +from unittest.mock import patch + + +ROOT = Path(__file__).resolve().parents[1] +SPEC = importlib.util.spec_from_file_location( + "nrf52_ota_pacing_size", ROOT / "scripts/nrf52_ota_pacing_size.py") +POLICY = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(POLICY) +FILTER = "+<*.cpp> +<../examples/companion_radio/*.cpp>" +PORT_FIXTURE = b"/* reviewed test SDK */\nvoid xPortPendSVHandler(void) __attribute__((naked));\n" + + +class Node: + def __init__(self, path): + self.path = Path(path) + + def srcnode(self): + return self + + def get_abspath(self): + return str(self.path) + + +class Environment(dict): + def __init__(self, framework, **options): + super().__init__(PIOENV=POLICY.PROFILE, SRC_FILTER=FILTER, + BUILD_FLAGS=["-Oz", "-flto", "-flto-partition=one"], + CCFLAGS=["-Wall", "-include", "SoftDeviceSvcCompat.h", "-flto"], + CFLAGS=["-std=gnu11", "-flto=auto"], CXXFLAGS=["-std=gnu++11"], + LINKFLAGS=["-flto", "-Wl,--sort-section=alignment"], CPPDEFINES=[]) + self.update(options) + self.framework = framework + self.middleware = [] + self.objects = [] + self.platform_name = "nordicnrf52" + self.mcu = "nrf52840" + + def PioPlatform(self): + owner = self + + class Platform: + name = owner.platform_name + + def get_package_dir(self, package): + assert package == "framework-arduinoadafruitnrf52" + return owner.framework + + return Platform() + + def BoardConfig(self): + return {"build.mcu": self.mcu} + + def AppendUnique(self, **options): + for name, values in options.items(): + original = self.setdefault(name, []) + for value in values: + if value not in original: + original.append(value) + + def AddBuildMiddleware(self, function, pattern): + self.middleware.append((function, pattern)) + + def Object(self, node, **options): + result = (node, options) + self.objects.append(result) + return result + + +class Nrf52OtaPacingSizeTest(unittest.TestCase): + def policy(self, **changes): + arguments = dict(platform="nordicnrf52", mcu="nrf52840", env_name=POLICY.PROFILE, + src_filter=FILTER) + arguments.update(changes) + return POLICY.size_policy(**arguments) + + def test_exact_board_role_and_effective_source_filter(self): + self.assertTrue(self.policy()) + for changes in ( + dict(platform="espressif32"), dict(mcu="nrf52832"), dict(env_name="custom"), + dict(env_name="RAK_4631_companion_radio_usb"), + dict(env_name="RAK_3401_companion_radio_full"), + dict(src_filter="+<*>"), + dict(src_filter=FILTER + " -<../examples/companion_radio>"), + ): + with self.subTest(changes=changes): + self.assertFalse(self.policy(**changes)) + for role in ("simple_repeater", "simple_room_server", "simple_sensor", "kiss_modem", "simple_secure_chat"): + self.assertFalse(self.policy(src_filter=FILTER + " +<../examples/" + role + ">")) + self.assertTrue(self.policy(src_filter=FILTER + " +<../examples/simple_repeater> -<../examples/simple_repeater>")) + + def test_explicit_optouts_conflicts_and_hil_preserve_existing_policy(self): + for flag in ( + "-DMESH_NRF52_OTA_PACING_SIZE=0", "-UMESH_NRF52_OTA_PACING_SIZE", + "-DMESH_NRF52_FLASH_TRIM=0", "-DMESH_NRF52_COMPANION_TRIM=0", + "-DCOMPANION_RADIO_FULL=0", "-DMESH_HIL_IRQ_TIMING=1", + "-DMC_NRF52_HIL_SPIM1_RADIO=1", "-DCOMPANION_PAIRING_UI_HIL=0", + "-DOTA_VARIANT=custom", "-fcaller-saves", "-ftree-dominator-opts", + "-freorder-blocks", "-ftree-loop-im", "-fno-lto", + "-flto-partition=balanced", + ): + with self.subTest(flag=flag): + self.assertFalse(self.policy(flags=flag)) + self.assertFalse(self.policy(cppdefines=[("MESH_HIL_IRQ_TIMING", 1)])) + self.assertFalse(self.policy(ccflags=["-fcaller-saves"])) + self.assertFalse(self.policy(linkflags=["-fcaller-saves"])) + self.assertFalse(self.policy(ccflags=["-DMESH_HIL_IRQ_TIMING=1"])) + self.assertFalse(self.policy(ccflags=["-D", "MESH_NRF52_OTA_PACING_SIZE=0"])) + self.assertTrue(self.policy(flags="-Oz -flto -flto-partition=one -fno-inline-small-functions")) + self.assertTrue(self.policy(flags="-freorder-functions")) + self.assertTrue(self.policy(flags=['-DOTA_VARIANT="' + POLICY.PROFILE + '"'])) + + def test_only_positive_lto_controls_are_removed(self): + original = ["-Oz", "-flto", "-flto=auto", "-flto-partition=one", "-fno-lto", + "-include", "C:/SDK helpers/SoftDeviceSvcCompat.h", "-DVALUE=1", "-fno-inline-small-functions"] + expected = [value for value in original if not value.startswith("-flto")] + self.assertEqual(POLICY.without_lto(original), expected) + self.assertEqual(len(original), 9) + self.assertEqual(POLICY.without_lto('-Oz -flto=auto -include "SVC compat.h"'), + ["-Oz", "-include", '"SVC compat.h"']) + + def make_framework(self, directory): + source = Path(directory) / POLICY.PORT_RELATIVE + source.parent.mkdir(parents=True) + source.write_bytes(PORT_FIXTURE) + return source + + def test_install_is_idempotent_and_all_compiler_stages_match(self): + with tempfile.TemporaryDirectory() as directory: + source = self.make_framework(directory) + original = source.read_bytes() + env = Environment(directory) + with patch.object(POLICY, "PORT_SHA256", hashlib.sha256(PORT_FIXTURE).hexdigest()): + self.assertTrue(POLICY.install(env)) + self.assertTrue(POLICY.install(env)) + self.assertEqual(len(env.middleware), 1) + for stage in ("CCFLAGS", "BUILD_FLAGS", "LINKFLAGS"): + for flag in POLICY.SIZE_FLAGS: + self.assertEqual(env[stage].count(flag), 1) + self.assertEqual(env["CCFLAGS"][:3], ["-Wall", "-include", "SoftDeviceSvcCompat.h"]) + self.assertEqual(source.read_bytes(), original) + self.assertEqual(list(Path(directory).rglob("*port.c")), [source]) + + def test_port_object_is_exact_sdk_only_and_keeps_svc_and_other_flags(self): + with tempfile.TemporaryDirectory() as directory: + source = self.make_framework(directory) + original = source.read_bytes() + env = Environment(directory) + before = {key: list(env[key]) for key in ("CCFLAGS", "CFLAGS", "CXXFLAGS", "BUILD_FLAGS")} + for other in (Path(directory) / "other/port.c", Path(directory) / "patched-nrf52-hil-irqs/port.c"): + node = Node(other) + self.assertIs(POLICY.compile_port_without_lto(env, node), node) + self.assertFalse(env.objects) + with patch.object(POLICY, "PORT_SHA256", hashlib.sha256(PORT_FIXTURE).hexdigest()): + node = Node(source) + returned, options = POLICY.compile_port_without_lto(env, node) + self.assertIs(returned, node) + for stage, values in options.items(): + self.assertFalse(any(value.startswith("-flto") for value in values)) + expected = [value for value in before[stage] if not value.startswith("-flto")] + if stage == "CCFLAGS": + expected.append("-fno-lto") + self.assertEqual(values, expected) + self.assertEqual(env[stage], before[stage]) + self.assertEqual(options["CCFLAGS"][:3], ["-Wall", "-include", "SoftDeviceSvcCompat.h"]) + self.assertEqual(source.read_bytes(), original) + + def test_unknown_sdk_fails_before_flags_change_and_crlf_is_equivalent(self): + with tempfile.TemporaryDirectory() as directory: + source = self.make_framework(directory) + env = Environment(directory) + before = dict(env) + with self.assertRaisesRegex(RuntimeError, "changed pinned FreeRTOS"): + POLICY.install(env) + self.assertEqual(dict(env), before) + self.assertFalse(env.middleware) + expected = hashlib.sha256(PORT_FIXTURE).hexdigest() + with patch.object(POLICY, "PORT_SHA256", expected): + source.write_bytes(PORT_FIXTURE.replace(b"\n", b"\r\n")) + self.assertEqual(POLICY.reviewed_port(directory), source.resolve()) + source.write_bytes(PORT_FIXTURE + b"/* changed */\n") + with self.assertRaisesRegex(RuntimeError, "changed pinned FreeRTOS"): + POLICY.compile_port_without_lto(env, Node(source)) + self.assertFalse(env.objects) + + def test_excluded_install_never_reads_or_changes_sdk(self): + for options in (dict(PIOENV="custom"), dict(BUILD_FLAGS=["-DMESH_HIL_IRQ_TIMING=1"]), + dict(BUILD_FLAGS=["-DMESH_NRF52_OTA_PACING_SIZE=0"]), dict(SRC_FILTER="+<*>")): + env = Environment("/missing/framework", **options) + before = dict(env) + self.assertFalse(POLICY.install(env)) + self.assertEqual(dict(env), before) + self.assertFalse(env.middleware) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_ota/test_ota_core.cpp b/test/test_ota/test_ota_core.cpp index 1d7cb28ed..9e932718e 100644 --- a/test/test_ota/test_ota_core.cpp +++ b/test/test_ota/test_ota_core.cpp @@ -3213,47 +3213,88 @@ TEST(OtaTransfer, ServerPacesOneKilobyteBlockAndProactiveProofWithBackpressure) EXPECT_EQ(server.pendingServeJobs(), 0u); } -TEST(OtaTransfer, SenderPacketPaceBacksOffOnDistinctLostBlocks) { +TEST(OtaTransfer, SenderPacketPaceNeedsActualCompletedResponseEvidence) { MotaManifest manifest; ASSERT_TRUE(mota_parse(SIM_MOTA_1K, SIM_MOTA_1K_LEN, manifest)); - ASSERT_EQ(manifest.block_count, 3u); - OtaManager server; CapturedMessages sent; server.begin(0, capture_send, &sent); ASSERT_TRUE(server.serve(SIM_MOTA_1K, SIM_MOTA_1K_LEN)); - EXPECT_FLOAT_EQ(server.adaptivePacketSpeed(), 1.0f); - + OtaReplyRoute route; + route.profile = 0; + route.generation = 7; auto request = [&](uint16_t block, uint16_t mask) { ReqMsg req{}; memcpy(req.manifest_id, manifest.merkle_root, 4); - req.block_idx = block; - req.want_mask = mask; + req.block_idx = block; req.want_mask = mask; uint8_t wire[MAX_PACKET_PAYLOAD]; const uint16_t len = encode_req(wire, sizeof(wire), req); - return len != 0 && server.on_message(wire, len); + return len && server.on_message(wire, len, route); }; - ASSERT_TRUE(request(0, 0xFFFF)); + ASSERT_TRUE(request(0, 1)); // sparse while still waiting for source/queue/CAD EXPECT_FLOAT_EQ(server.adaptivePacketSpeed(), 1.0f); - ASSERT_TRUE(request(0, 0x0001)); - EXPECT_FLOAT_EQ(server.adaptivePacketSpeed(), 0.5f); - ASSERT_TRUE(request(0, 0x0001)); - EXPECT_FLOAT_EQ(server.adaptivePacketSpeed(), 0.5f); // same hole cannot ratchet down - ASSERT_TRUE(request(1, 0x0001)); - EXPECT_FLOAT_EQ(server.adaptivePacketSpeed(), 0.4f); - ASSERT_TRUE(request(0, 0x0001)); - EXPECT_FLOAT_EQ(server.adaptivePacketSpeed(), 0.3f); - ASSERT_TRUE(request(1, 0x0001)); - EXPECT_FLOAT_EQ(server.adaptivePacketSpeed(), 0.25f); // automatic floor - ASSERT_TRUE(request(1, 0x0001)); - EXPECT_FLOAT_EQ(server.adaptivePacketSpeed(), 0.25f); - - // Reinitializing a source starts with the configured maximum again. + for (unsigned i = 0; i < 7; ++i) server.serviceEgress(); + ASSERT_EQ(sent.items.size(), 7u); + server.set_clock(1000); + ASSERT_TRUE(request(0, 1)); // radio admission is still not TX completion + EXPECT_FLOAT_EQ(server.adaptivePacketSpeed(), 1.0f); + OtaReplyRoute wrong = route; + wrong.profile = 1; + for (const auto& message : sent.items) { + server.noteResponseTransmitted(message.data(), message.size(), wrong, 1000); + } + ASSERT_TRUE(request(0, 1)); + EXPECT_FLOAT_EQ(server.adaptivePacketSpeed(), 1.0f); + for (const auto& message : sent.items) { + server.noteResponseTransmitted(message.data(), message.size(), route, 1000); + } + if (OTA_TRANSFER_DIAGNOSTICS) EXPECT_EQ(server.wireStats().data_packets, 7u); + if (OTA_TRANSFER_DIAGNOSTICS) EXPECT_EQ(server.wireStats().deflated_packets, 0u); + if (OTA_TRANSFER_DIAGNOSTICS) EXPECT_EQ(server.wireStats().data_bytes, 1024u); + if (OTA_TRANSFER_DIAGNOSTICS) EXPECT_EQ(server.wireStats().choice, OtaManager::WireChoice::LegacyRequest); + ASSERT_TRUE(request(0, 1)); // request could have been in flight before final TX + EXPECT_FLOAT_EQ(server.adaptivePacketSpeed(), 1.0f); + server.set_clock(2000); + ASSERT_TRUE(request(0, 1)); + EXPECT_FLOAT_EQ(server.adaptivePacketSpeed(0, 7), 0.5f); + EXPECT_FLOAT_EQ(server.adaptivePacketSpeed(1, 7), 1.0f); + ASSERT_TRUE(request(0, 1)); + EXPECT_FLOAT_EQ(server.adaptivePacketSpeed(), 0.5f); // duplicate loss cannot ratchet + route.generation = 8; + ASSERT_TRUE(request(0, 1)); + EXPECT_FLOAT_EQ(server.adaptivePacketSpeed(), 1.0f); // quiet replacement profile starts fresh server.begin(0, capture_send, &sent); EXPECT_FLOAT_EQ(server.adaptivePacketSpeed(), 1.0f); } +TEST(OtaTransfer, FailedSourceTransmissionCanBeRequestedAgainWithoutLossPenalty) { + MotaManifest manifest; + ASSERT_TRUE(mota_parse(SIM_MOTA_1K, SIM_MOTA_1K_LEN, manifest)); + OtaManager server; + CapturedMessages sent; + server.begin(0, capture_send, &sent); + ASSERT_TRUE(server.serve(SIM_MOTA_1K, SIM_MOTA_1K_LEN)); + ReqMsg req{}; + memcpy(req.manifest_id, manifest.merkle_root, 4); + req.want_mask = 0xFFFF; + uint8_t wire[MAX_PACKET_PAYLOAD]; + uint16_t len = encode_req(wire, sizeof(wire), req); + ASSERT_TRUE(server.on_message(wire, len)); + server.serviceEgress(); + ASSERT_EQ(sent.items.size(), 1u); + server.noteResponseFailed(sent.items[0].data(), sent.items[0].size(), {}, 100); + req.want_mask = 1; + len = encode_req(wire, sizeof(wire), req); + server.set_clock(2000); + ASSERT_TRUE(server.on_message(wire, len)); + server.serviceEgress(); + ASSERT_EQ(sent.items.size(), 2u); + EXPECT_EQ(sent.items[0], sent.items[1]); + EXPECT_FLOAT_EQ(server.adaptivePacketSpeed(), 1.0f); + if (OTA_TRANSFER_DIAGNOSTICS) EXPECT_EQ(server.wireStats().data_packets, 0u); +} + TEST(OtaTransfer, LiteralLegacyFullMaskServesTwoKilobyteBlockInThirteenFragments) { MotaManifest manifest; ASSERT_TRUE(mota_parse(SIM_MOTA_2K, SIM_MOTA_2K_LEN, manifest)); @@ -3355,8 +3396,49 @@ TEST(OtaTransfer, NegotiatedV2Carries171RawBytesAndRepeatsRepresentationId) { manifest.block_size() - offset)); if (i + 1 < fragment_count) EXPECT_EQ(sent.items.back().size(), MAX_PACKET_PAYLOAD); memcpy(reassembled.data() + offset, data.data + OTA_DATA_V2_STREAM_ID_BYTES, slice_len); + server.noteResponseTransmitted(sent.items.back().data(), sent.items.back().size(), {}, i); } EXPECT_EQ(0, memcmp(reassembled.data(), manifest.payload, manifest.block_size())); + if (OTA_TRANSFER_DIAGNOSTICS) EXPECT_EQ(server.wireStats().data_packets, 6u); + if (OTA_TRANSFER_DIAGNOSTICS) EXPECT_EQ(server.wireStats().data_bytes, 1024u); + if (OTA_TRANSFER_DIAGNOSTICS) EXPECT_EQ(server.wireStats().choice, OtaManager::WireChoice::NoEncoder); +} + +TEST(OtaTransfer, CompletedExtendedV2AndDirectDataReportActualCompression) { + MotaManifest manifest; + ASSERT_TRUE(mota_parse(SIM_MOTA_2K, SIM_MOTA_2K_LEN, manifest)); + for (bool direct : {false, true}) { + OtaManager server; + CapturedMessages sent; + VectorDeflateCodec encoder{}; + encoder.manifest = &manifest; + server.begin(0, capture_send, &sent); + server.set_direct_transport_enabled(direct); + server.set_transport_deflate_encoder(vector_2k_wire_encode, &encoder); + ASSERT_TRUE(server.serve(SIM_MOTA_2K, SIM_MOTA_2K_LEN)); + OtaReplyRoute route; + route.profile = 1; route.generation = 19; route.direct = direct; + ReqMsg request{}; + memcpy(request.manifest_id, manifest.merkle_root, 4); + request.want_mask = ota_req_make_v2(0x0FFFu, true, true); + uint8_t wire[MAX_PACKET_PAYLOAD]; + ASSERT_TRUE(server.on_message(wire, encode_req(wire, sizeof(wire), request), route)); + const uint32_t encoded = SIM_MOTA_2K_DEFLATED_LENGTHS[0]; + const uint32_t fragment_data = direct ? OTA_DIRECT_FRAG_DATA : OTA_FRAG_DATA_V2; + const uint32_t fragments = (encoded + fragment_data - 1) / fragment_data; + for (uint32_t i = 0; i < fragments; ++i) { + server.serviceEgress(); + ASSERT_EQ(sent.items.size(), i + 1); + server.noteResponseTransmitted(sent.items.back().data(), sent.items.back().size(), route, i); + server.noteResponseTransmitted(sent.items.back().data(), sent.items.back().size(), route, i); + } + if (OTA_TRANSFER_DIAGNOSTICS) EXPECT_EQ(server.wireStats().data_packets, fragments); + if (OTA_TRANSFER_DIAGNOSTICS) EXPECT_EQ(server.wireStats().deflated_packets, fragments); + if (OTA_TRANSFER_DIAGNOSTICS) EXPECT_EQ(server.wireStats().data_bytes, encoded); + if (OTA_TRANSFER_DIAGNOSTICS) EXPECT_EQ(server.wireStats().raw_bytes, 2048u); + if (OTA_TRANSFER_DIAGNOSTICS) EXPECT_EQ(server.wireStats().encoded_bytes, encoded); + if (OTA_TRANSFER_DIAGNOSTICS) EXPECT_EQ(server.wireStats().choice, OtaManager::WireChoice::Deflate); + } } TEST(OtaTransfer, LegacyProofRequestBypassesProactiveProofTurnaround) { @@ -3882,6 +3964,8 @@ TEST(OtaTransfer, FailedTwoKilobyteInflateRetriesRawWithoutLeavingExtendedV2) { EXPECT_EQ(decoder.calls, 1u); EXPECT_EQ(client.blocksHave(), 0u); EXPECT_TRUE(sent.items.empty()); // retry is paced by the normal deadline + if (OTA_TRANSFER_DIAGNOSTICS) EXPECT_EQ(client.wireStats().fallback, OtaManager::WireFallback::InflateFailed); + if (OTA_TRANSFER_DIAGNOSTICS) EXPECT_EQ(client.wireStats().fallbacks, 1u); const uint32_t timeout = client.fetchRetryTimeoutMs(); client.set_clock(100 + timeout); @@ -5811,6 +5895,177 @@ TEST(OtaSpeed, MissingProofFallbackStaysArmedAtMillisRollover) { EXPECT_EQ(ota_msg_type(sent.items.back().data(), sent.items.back().size()), OTA_REQ_PROOF); } +static void start_cadence_fetch(OtaManager& client, OtaStoreRam<4096>& store, + CapturedMessages& sent, const MotaManifest& manifest, + uint32_t now = 100) { + client.begin(SIM_TARGET_ID, capture_send, &sent); + client.set_fetch_store(&store); + client.set_max_hops(0); + client.set_link_timing(100, 2000); + client.set_clock(now); + ASSERT_EQ(client.pull(manifest.merkle_root, manifest.target_id), OtaManager::PULL_STARTED); + deliver_manifest_fragment(client, manifest.merkle_root, 0, + manifest.manifest_start, OTA_MF_FRAG); + deliver_manifest_fragment(client, manifest.merkle_root, 1, + manifest.manifest_start + OTA_MF_FRAG, + (uint16_t)(MOTA_MFL - OTA_MF_FRAG)); + ASSERT_EQ(client.fetchState(), OtaManager::FETCHING); + client.note_rx_path_hops(0); +} + +static bool deliver_cadence_fragment(OtaManager& client, const MotaManifest& manifest, + uint16_t block, uint8_t fragment, uint32_t now, + bool wire_v2, bool shortened = false) { + const uint32_t block_offset = block * manifest.block_size(); + const uint16_t block_length = (uint16_t)std::min(manifest.block_size(), + manifest.payload_size - block_offset); + const uint16_t fragment_size = wire_v2 ? OTA_FRAG_DATA_V2 : OTA_FRAG_DATA; + const uint16_t offset = fragment * fragment_size; + const uint16_t length = (uint16_t)std::min(fragment_size, block_length - offset); + DataMsg data{}; + memcpy(data.manifest_id, manifest.merkle_root, 4); + data.block_idx = block; + uint8_t representation[OTA_DATA_V2_STREAM_ID_BYTES + OTA_FRAG_DATA_V2]; + if (wire_v2) { + if (!ota_data_v2_pack(fragment, block_length, false, data.frag_off)) return false; + mh4(representation, manifest.payload + block_offset, block_length); + memcpy(representation + OTA_DATA_V2_STREAM_ID_BYTES, + manifest.payload + block_offset + offset, length); + data.data = representation; + data.data_len = (uint16_t)(OTA_DATA_V2_STREAM_ID_BYTES + length); + } else { + data.frag_off = offset; + data.data = manifest.payload + block_offset + offset; + data.data_len = length; + } + if (shortened) --data.data_len; + uint8_t wire[MAX_PACKET_PAYLOAD]; + const uint16_t wire_length = encode_data(wire, sizeof(wire), data); + if (!wire_length) return false; + client.set_clock(now); + return client.on_message(wire, wire_length); +} + +TEST(OtaReceiveCadenceIntegration, SlowValidDataExtendsThePhysicalRetryAllowance) { + MotaManifest manifest; + ASSERT_TRUE(mota_parse(SIM_MOTA_1K, SIM_MOTA_1K_LEN, manifest)); + ASSERT_GE(manifest.block_size(), 3u * OTA_FRAG_DATA_V2); + for (bool wire_v2 : {false, true}) { + uint32_t intervals[2] = {}; + for (unsigned slow = 0; slow < 2; ++slow) { + OtaManager client; + OtaStoreRam<4096> store; + CapturedMessages sent; + start_cadence_fetch(client, store, sent, manifest); + ASSERT_TRUE(deliver_cadence_fragment(client, manifest, 0, 0, 100, wire_v2)); + ASSERT_TRUE(deliver_cadence_fragment(client, manifest, 0, 1, + slow ? 2100 : 300, wire_v2)); + intervals[slow] = client.fetchRetryTimeoutMs(); + EXPECT_GE(intervals[slow], (uint32_t)OTA_FETCH_RETRY_PARTIAL_MIN_MS); + EXPECT_LE(intervals[slow], (uint32_t)OTA_FETCH_RETRY_MAX_MS); + } + EXPECT_GT(intervals[1], intervals[0]); + } +} + +TEST(OtaReceiveCadenceIntegration, DuplicateAndMalformedDataDoNotTrainRetryTiming) { + MotaManifest manifest; + ASSERT_TRUE(mota_parse(SIM_MOTA_1K, SIM_MOTA_1K_LEN, manifest)); + for (bool wire_v2 : {false, true}) { + OtaManager client; + OtaStoreRam<4096> store; + CapturedMessages sent; + start_cadence_fetch(client, store, sent, manifest); + ASSERT_TRUE(deliver_cadence_fragment(client, manifest, 0, 0, 100, wire_v2)); + ASSERT_TRUE(deliver_cadence_fragment(client, manifest, 0, 1, 2100, wire_v2)); + const uint32_t trained = client.fetchRetryTimeoutMs(); + ASSERT_TRUE(deliver_cadence_fragment(client, manifest, 0, 1, 30000, wire_v2)); + EXPECT_EQ(client.fetchRetryTimeoutMs(), trained); + EXPECT_FALSE(deliver_cadence_fragment(client, manifest, 0, 2, 50000, wire_v2, true)); + EXPECT_EQ(client.fetchRetryTimeoutMs(), trained); + } +} + +TEST(OtaReceiveCadenceIntegration, SlowSettingDoesNotMultiplyObservedPacingTwice) { + MotaManifest manifest; + ASSERT_TRUE(mota_parse(SIM_MOTA_1K, SIM_MOTA_1K_LEN, manifest)); + for (bool wire_v2 : {false, true}) { + uint32_t retry[2], proof[2]; + for (unsigned slow = 0; slow < 2; ++slow) { + OtaManager client; + OtaStoreRam<4096> store; + CapturedMessages sent; + start_cadence_fetch(client, store, sent, manifest); + ASSERT_TRUE(client.set_speed(slow ? 0.25f : 1.0f)); + ASSERT_TRUE(deliver_cadence_fragment(client, manifest, 0, 0, 100, wire_v2)); + ASSERT_TRUE(deliver_cadence_fragment(client, manifest, 0, 1, 2100, wire_v2)); + EXPECT_EQ(client.observedDataIntervalMs(), 2000u); + retry[slow] = client.fetchRetryTimeoutMs(); + proof[slow] = client.proofGraceMs(); + } + // Physical floors and guards may grow, but the measured two-second DATA + // gap already includes source pacing and must not become eight seconds. + EXPECT_GE(retry[1], retry[0]); + EXPECT_LT(retry[1], retry[0] * 2u); + EXPECT_GE(proof[1], proof[0]); + EXPECT_LE(proof[1], proof[0] + 1000u); + } +} + +TEST(OtaReceiveCadenceIntegration, NewFlightExcludesIdleButRetainsItsLearnedInterval) { + MotaManifest manifest; + ASSERT_TRUE(mota_parse(SIM_MOTA_1K, SIM_MOTA_1K_LEN, manifest)); + ASSERT_GE(manifest.block_count, 2u); + for (bool wire_v2 : {false, true}) { + uint32_t intervals[2] = {}; + for (unsigned idle = 0; idle < 2; ++idle) { + OtaManager client; + OtaStoreRam<4096> store; + CapturedMessages sent; + start_cadence_fetch(client, store, sent, manifest); + ASSERT_TRUE(deliver_cadence_fragment(client, manifest, 0, 0, 100, wire_v2)); + ASSERT_TRUE(deliver_cadence_fragment(client, manifest, 0, 1, 2100, wire_v2)); + if (wire_v2) deliver_verified_v2_raw_block(client, manifest, 0); + else deliver_verified_block(client, manifest, 0); + ASSERT_EQ(client.blocksHave(), 1u); + ASSERT_EQ(client.fetchState(), OtaManager::FETCHING); + ASSERT_TRUE(deliver_cadence_fragment(client, manifest, 1, 0, + idle ? 22100 : 2200, wire_v2)); + intervals[idle] = client.fetchRetryTimeoutMs(); + EXPECT_GE(intervals[idle], 4000u); // the 2 s service estimate survived + } + EXPECT_EQ(intervals[0], intervals[1]); // the 20 s idle did not become a DATA gap + } +} + +TEST(OtaReceiveCadenceIntegration, ANewFetchDoesNotInheritItsPredecessorsCadence) { + MotaManifest manifest; + ASSERT_TRUE(mota_parse(SIM_MOTA_1K, SIM_MOTA_1K_LEN, manifest)); + for (bool wire_v2 : {false, true}) { + OtaManager client; + OtaStoreRam<4096> first_store; + CapturedMessages sent; + start_cadence_fetch(client, first_store, sent, manifest); + ASSERT_TRUE(deliver_cadence_fragment(client, manifest, 0, 0, 100, wire_v2)); + ASSERT_TRUE(deliver_cadence_fragment(client, manifest, 0, 1, 2100, wire_v2)); + const uint32_t old_interval = client.fetchRetryTimeoutMs(); + client.reset_session(); + OtaStoreRam<4096> next_store; + client.set_fetch_store(&next_store); + client.set_clock(30000); + ASSERT_EQ(client.pull(manifest.merkle_root, manifest.target_id), OtaManager::PULL_STARTED); + deliver_manifest_fragment(client, manifest.merkle_root, 0, + manifest.manifest_start, OTA_MF_FRAG); + deliver_manifest_fragment(client, manifest.merkle_root, 1, + manifest.manifest_start + OTA_MF_FRAG, + (uint16_t)(MOTA_MFL - OTA_MF_FRAG)); + ASSERT_EQ(client.fetchState(), OtaManager::FETCHING); + client.note_rx_path_hops(0); + ASSERT_TRUE(deliver_cadence_fragment(client, manifest, 0, 0, 30100, wire_v2)); + EXPECT_LT(client.fetchRetryTimeoutMs(), old_interval); + } +} + #include "test_ota_direct_manager.inc" int main(int argc, char** argv) { diff --git a/test/test_ota_cli_listing.py b/test/test_ota_cli_listing.py index 0f78d4bdc..7691bfac2 100644 --- a/test/test_ota_cli_listing.py +++ b/test/test_ota_cli_listing.py @@ -53,7 +53,8 @@ class OtaCliListingTest(unittest.TestCase): for seeder in (False, True): with self.subTest(compact=compact, seeder=seeder): executable = directory / f"listing-{compact}-{seeder}" - defines = [f"-DOTA_TARGET_NAME_FRONT_CODED={compact}", "-DNRF52_PLATFORM=1"] + defines = [f"-DOTA_TARGET_NAME_FRONT_CODED={compact}", + f"-DMESH_CLI_COMPACT_REPLIES={compact}", "-DNRF52_PLATFORM=1"] if seeder: defines.append("-DOTA_SEEDER_ONLY=1") subprocess.run([os.environ.get("CXX") or shutil.which("c++") or "g++", "-std=c++11", *flags, "-no-pie", diff --git a/test/test_ota_direct_manager.py b/test/test_ota_direct_manager.py index f4e0f83c2..cb96fae8c 100644 --- a/test/test_ota_direct_manager.py +++ b/test/test_ota_direct_manager.py @@ -28,10 +28,16 @@ class OtaDirectManagerTests(unittest.TestCase): sources = ("OtaManager.cpp", "OtaProtocol.cpp", "MotaContainer.cpp", "MerkleTree.cpp", "FirmwareInfo.cpp", "OtaDeflate.cpp", "OtaApply.cpp", "MotaSourceSerial.cpp", "FolderMotaStore.cpp") - binary = directory / "ota-core" + for diagnostics in (1, 0): + self.run_core(directory, googletest, flags, sources, objects, diagnostics) + + def run_core(self, directory, googletest, flags, sources, objects, diagnostics): + with self.subTest(diagnostics=diagnostics): + binary = directory / ("ota-core-" + str(diagnostics)) built = subprocess.run([ "g++", "-std=c++17", "-pthread", *flags, "-DOTA_TRANSPORT_DEFLATE_TEST=1", "-DOTA_FETCH_PIPELINE=4", + "-DOTA_TRANSFER_DIAGNOSTICS=" + str(diagnostics), "-I", str(ROOT / "src"), "-I", str(ROOT / "test/mocks"), "-I", str(googletest / "include"), "-I", str(googletest), str(ROOT / "test/test_ota/test_ota_core.cpp"), diff --git a/test/test_ota_packet_pacer.py b/test/test_ota_packet_pacer.py new file mode 100644 index 000000000..7c5d6986a --- /dev/null +++ b/test/test_ota_packet_pacer.py @@ -0,0 +1,356 @@ +"""Compile the allocation-free OTA pacing policy against actual TX event traces.""" + +import os +from pathlib import Path +import shutil +import subprocess +import tempfile +import unittest + + +ROOT = Path(__file__).resolve().parents[1] +HARNESS = r''' +#include "src/helpers/ota/OtaPacketPacer.h" +#include +#include +#include + +using mesh::ota::OtaPacketPacer; +using Pacer = OtaPacketPacer; +using Key = Pacer::Key; + +#define CHECK(x) do { if (!(x)) { \ + std::fprintf(stderr, "line %d: %s\n", __LINE__, #x); std::exit(1); \ +} } while (0) + +static Key key(uint16_t block, uint8_t profile = 0, uint32_t generation = 7, + uint8_t format = Pacer::V2, uint32_t stream = 101) { + Key result; + result.profile = profile; + result.generation = generation; + result.block = block; + result.format = format; + result.stream = stream; + result.mid[0] = 0x11; result.mid[1] = 0x22; + result.mid[2] = 0x33; result.mid[3] = 0x44; + return result; +} + +static void data(Pacer& p, const Key& k, uint32_t now, uint16_t mask = 7) { + for (uint16_t bit = 1; bit; bit = uint16_t(bit << 1)) + if (mask & bit) CHECK(p.queued(k, mask, bit)); + for (uint16_t bit = 1; bit; bit = uint16_t(bit << 1)) + if (mask & bit) CHECK(p.sent(k, bit, now++)); +} + +static void complete(Pacer& p, const Key& k, uint32_t now) { + data(p, k, now); + CHECK(p.proofSent(k.profile, k.generation, k.mid, k.block, now + 3)); +} + +static Pacer::Feedback request(Pacer& p, const Key& k, uint16_t want, + uint32_t now, uint32_t grace = 10) { + return p.request(k.profile, k.generation, k.mid, k.block, k.format, want, now, grace); +} + +static bool forward(Pacer& p, const Key& k, uint16_t next, uint32_t now) { + return p.forwardRequest(k.profile, k.generation, k.mid, next, now, 0); +} + +static void lower(Pacer& p) { + for (uint16_t block = 0; block < 2; ++block) { + const Key k = key(block); + complete(p, k, 100 + block * 100); + CHECK(request(p, k, 1, 150 + block * 100) == Pacer::Loss); + } + CHECK(p.pace(0, 7) == 0.25f); + CHECK(!forward(p, key(0), 2, 300)); // dirty blocks cannot credit recovery +} + +static void profiles() { + Pacer p; + const Key main = key(0), second = key(0, 1, 9); + CHECK(!p.queued(main, 7, 1)); // queue admission never binds stale routes + CHECK(p.pace(0, 7) == 1.0f); + p.bind(0, 7); p.bind(1, 9); + complete(p, main, 100); + CHECK(request(p, main, 1, 200) == Pacer::Loss); + CHECK(p.pace(0, 7) == 0.5f && p.pace(1, 9) == 1.0f); + CHECK(p.pace(0) == 0.5f && p.pace(1) == 1.0f); + complete(p, second, 200); + CHECK(request(p, second, 2, 300) == Pacer::Loss); + p.bind(0, 8); + CHECK(p.pace(0, 8) == 1.0f && p.pace(1, 9) == 0.5f); + CHECK(request(p, main, 1, 400) == Pacer::Ignored); + CHECK(!p.sent(main, 1, 400) && !p.failed(main, 1)); + CHECK(!p.queued(main, 7, 1)); + const Key portable = key(0, Pacer::PortableProfile, 0); + p.bind(Pacer::PortableProfile, 0); + CHECK(request(p, portable, 1, 500) == Pacer::Ignored); + complete(p, portable, 500); + CHECK(request(p, portable, 1, 600) == Pacer::Loss); + CHECK(p.pace(Pacer::PortableProfile, 0) == 0.5f); + CHECK(p.pace(1, 9) == 0.5f && p.pace(0, 8) == 1.0f); + p.bind(2, 0); + CHECK(p.pace(2, 0) == 1.0f && !p.queued(key(0, 2, 0), 7, 1)); + p.reset(); + CHECK(p.pace() == 1.0f && p.pace(1, 9) == 1.0f); + CHECK(!p.sent(portable, 1, 700)); +} + +static void pending() { + Pacer p; p.bind(0, 7); + const Key k = key(0); + CHECK(p.queued(k, 7, 1)); + CHECK(request(p, k, 1, 90000) == Pacer::Suppressed); // CAD can hold indefinitely + CHECK(!p.proofSent(0, 7, k.mid, 0, 90000)); + CHECK(p.sent(k, 1, 90001)); + CHECK(request(p, k, 1, 99999) == Pacer::Suppressed); // unsent rest of burst + CHECK(p.queued(k, 7, 2) && p.queued(k, 7, 4)); + CHECK(p.sent(k, 2, 100000)); + CHECK(request(p, k, 1, 200000) == Pacer::Suppressed); + CHECK(p.sent(k, 4, 200001)); + CHECK(request(p, k, 1, 200010) == Pacer::Suppressed); + CHECK(request(p, k, 1, 200011) == Pacer::Loss); // grace begins at final actual TX + CHECK(request(p, k, 1, 400000) == Pacer::Duplicate); + CHECK(p.pace(0, 7) == 0.5f); + CHECK(request(p, k, 7, 400000) == Pacer::Ignored); + CHECK(request(p, k, 0, 400000) == Pacer::Ignored); +} + +static void queued_duplicates() { + Pacer p; p.bind(0, 7); + const Key k = key(0); + CHECK(p.queued(k, 7, 1) && p.queued(k, 7, 1)); + CHECK(p.queued(k, 7, 2) && p.queued(k, 7, 4)); + CHECK(p.sent(k, 1, 100) && p.sent(k, 2, 101) && p.sent(k, 4, 102)); + CHECK(request(p, k, 1, 500) == Pacer::Suppressed); + CHECK(!p.proofSent(0, 7, k.mid, 0, 500)); + CHECK(p.sent(k, 1, 501)); + CHECK(!p.sent(k, 1, 999)); // callback without a queued token does not extend grace + CHECK(request(p, k, 1, 510) == Pacer::Suppressed); + CHECK(request(p, k, 1, 511) == Pacer::Loss); + CHECK(!p.failed(k, 1)); + CHECK(!p.queued(k, 7, 0) && !p.queued(k, 7, 3) && !p.queued(k, 7, 8)); +} + +static void tx_failures() { + Pacer p; p.bind(0, 7); + const Key k = key(0); + CHECK(p.queued(k, 7, 1)); + CHECK(p.failed(k, 1)); + CHECK(!p.failed(k, 1)); + CHECK(request(p, k, 1, 1000) == Pacer::Suppressed); + complete(p, k, 2000); // a requeued block can complete, but is not a clean flight + CHECK(request(p, k, 1, 3000) == Pacer::Suppressed); + CHECK(!forward(p, k, 1, 3000)); + CHECK(p.pace(0, 7) == 1.0f); + const Key proof_failure = key(1); + data(p, proof_failure, 4000); + CHECK(p.proofFailed(0, 7, proof_failure.mid, 1)); + CHECK(p.proofSent(0, 7, proof_failure.mid, 1, 4003)); + CHECK(!forward(p, proof_failure, 2, 4004)); + CHECK(!p.proofFailed(0, 7, proof_failure.mid, 99)); +} + +static void recovery() { + Pacer p; p.bind(0, 7); lower(p); + auto clean = [&](uint16_t block) { + const Key k = key(block); + complete(p, k, 1000 + block * 100); + // A later full request may arrive immediately after PROOF, before sparse-loss grace. + CHECK(forward(p, k, uint16_t(block + 1), 1004 + block * 100)); + }; + clean(2); + CHECK(p.pace(0, 7) == 0.25f); + CHECK(!forward(p, key(2), 3, 1205)); // duplicate full request cannot credit twice + clean(3); + CHECK(p.pace(0, 7) == 0.5f); + complete(p, key(4), 1400); + CHECK(request(p, key(4), 1, 1500) == Pacer::Loss); + CHECK(p.pace(0, 7) == 0.25f); // failed probe rolls back to the last confirmed pace + CHECK(request(p, key(4), 1, 1600) == Pacer::Duplicate); + CHECK(!forward(p, key(4), 5, 1600)); + clean(5); clean(6); + CHECK(p.pace(0, 7) == 0.5f); + clean(7); + CHECK(p.pace(0, 7) == 0.5f); + clean(8); + CHECK(p.pace(0, 7) == 0.75f); + { + Pacer failed_probe = p; + complete(failed_probe, key(90), 5000); + CHECK(request(failed_probe, key(90), 1, 5100) == Pacer::Loss); + CHECK(failed_probe.pace(0, 7) == 0.5f); + } + clean(9); + CHECK(p.pace(0, 7) == 0.75f); + clean(10); + CHECK(p.pace(0, 7) == 1.0f); + { + Pacer failed_probe = p; + complete(failed_probe, key(91), 6000); + CHECK(request(failed_probe, key(91), 1, 6100) == Pacer::Loss); + CHECK(failed_probe.pace(0, 7) == 0.75f); + } + clean(11); + CHECK(p.pace(0, 7) == 1.0f); + complete(p, key(12), 7000); + CHECK(request(p, key(12), 1, 7100) == Pacer::Loss); + CHECK(p.pace(0, 7) == 0.5f); // confirmed 1x is no longer a probe of 0.75x + complete(p, key(13), 7200); + CHECK(request(p, key(13), 1, 7300) == Pacer::Loss); + CHECK(p.pace(0, 7) == 0.25f); +} + +static void forward_evidence() { + Pacer p; p.bind(0, 7); lower(p); + CHECK(!forward(p, key(0), 2, 400)); // initial and full duplicates provide no evidence + const Key a = key(2), b = key(3); + data(p, a, 500); + CHECK(!forward(p, a, 3, 600)); // DATA alone is not a completed flight + CHECK(p.proofSent(0, 7, a.mid, 2, 601)); + CHECK(p.queued(b, 7, 1)); + CHECK(!forward(p, a, 4, 602)); // incomplete burst cannot credit a multi-block window + CHECK(p.sent(b, 1, 603)); + CHECK(p.queued(b, 7, 2) && p.queued(b, 7, 4)); + CHECK(p.sent(b, 2, 604) && p.sent(b, 4, 605)); + CHECK(p.proofSent(0, 7, b.mid, 3, 606)); + CHECK(forward(p, a, 4, 607)); // two blocks are one request flight, not two credits + CHECK(p.pace(0, 7) == 0.25f); + CHECK(!forward(p, a, 4, 608)); + complete(p, key(4), 700); + CHECK(forward(p, key(4), 5, 704)); + CHECK(p.pace(0, 7) == 0.5f); // the prior multi-block window credited exactly once +} + +static void representations() { + Pacer p; p.bind(0, 7); + const Key a = key(0), b = key(0, 0, 7, Pacer::V2, 202); + complete(p, a, 100); complete(p, b, 200); + CHECK(request(p, a, 1, 500) == Pacer::Suppressed); // REQ has no stream ID + CHECK(p.pace(0, 7) == 1.0f); + p.reset(); p.bind(0, 7); + complete(p, a, 100); + CHECK(request(p, a, 1, 200) == Pacer::Loss); + const Key legacy = key(0, 0, 7, Pacer::Extended, 0); + complete(p, legacy, 300); + CHECK(request(p, legacy, 1, 500) == Pacer::Duplicate); // one loss per logical block + CHECK(p.pace(0, 7) == 0.5f); + CHECK(!forward(p, a, 1, 600)); + p.reset(); p.bind(0, 7); + CHECK(p.queued(a, 7, 1)); CHECK(p.failed(a, 1)); // abandoned encoding + complete(p, b, 200); + CHECK(!forward(p, a, 1, 300)); // forward progress consumes dirty fallback, no credit + complete(p, key(1), 400); + CHECK(forward(p, key(1), 2, 404)); // abandoned encoding cannot block all later recovery +} + +static void bounded_history() { + Pacer p; p.bind(0, 7); + for (uint16_t block = 0; block < Pacer::HistorySlots; ++block) + CHECK(p.queued(key(block), 7, 1)); + CHECK(!p.queued(key(Pacer::HistorySlots), 7, 1)); // never evict a queued packet + for (uint16_t block = 0; block < Pacer::HistorySlots; ++block) { + CHECK(p.sent(key(block), 1, block)); + CHECK(request(p, key(block), 1, 500) == Pacer::Suppressed); + } + complete(p, key(10), 1000); // can evict an incomplete but no-longer-queued old entry + CHECK(request(p, key(0), 1, 2000) == Pacer::Ignored); + CHECK(request(p, key(10), 1, 2000) == Pacer::Loss); + CHECK(p.pace(0, 7) == 0.5f); +} + +static void clock_wrap() { + Pacer p; p.bind(0, 7); + const Key k = key(0); + data(p, k, UINT32_MAX - 5); // final DATA at UINT32_MAX - 3 + CHECK(request(p, k, 1, 4) == Pacer::Suppressed); // elapsed 8ms, not a huge time gap + CHECK(request(p, k, 1, 6) == Pacer::Loss); // elapsed 10ms across wrap + CHECK(p.pace(0, 7) == 0.5f); +} + +static void edge_bits_and_unaligned_ids() { + Pacer p; p.bind(0, 7); + const Key k = key(0); + CHECK(p.queued(k, 0x8001, 1) && p.queued(k, 0x8001, 0x8000)); + CHECK(p.sent(k, 1, 100) && p.sent(k, 0x8000, 101)); + alignas(4) const uint8_t unaligned[] = {0, 0x11, 0x22, 0x33, 0x44}; + CHECK(p.request(0, 7, unaligned + 1, 0, k.format, 0x8000, 111, 10) == Pacer::Loss); + CHECK(request(p, k, 1, 200) == Pacer::Duplicate); + CHECK(p.pace(0, 7) == 0.5f); +} + +int main(int argc, char** argv) { + CHECK(argc == 2); + struct Test { const char* name; void (*run)(); } tests[] = { + {"profiles", profiles}, {"pending", pending}, + {"queued_duplicates", queued_duplicates}, {"tx_failures", tx_failures}, + {"recovery", recovery}, {"forward_evidence", forward_evidence}, + {"representations", representations}, {"bounded_history", bounded_history}, + {"clock_wrap", clock_wrap}, {"edge_bits_and_unaligned_ids", edge_bits_and_unaligned_ids}, + }; + for (const auto& test : tests) if (!std::strcmp(argv[1], test.name)) { + test.run(); std::printf("%s: OK (workspace=%zu bytes)\n", test.name, sizeof(Pacer)); + return 0; + } + return 2; +} +''' + + +class TestOtaPacketPacer(unittest.TestCase): + @classmethod + def setUpClass(cls): + compiler = shutil.which("g++") or shutil.which("clang++") + if compiler is None: + raise unittest.SkipTest("C++ compiler not available") + cls.temp = tempfile.TemporaryDirectory(prefix="ota-packet-pacer-") + cls.addClassCleanup(cls.temp.cleanup) + directory = Path(cls.temp.name) + source = directory / "pacer.cpp" + source.write_text(HARNESS, encoding="utf-8") + cls.binary = directory / ("pacer.exe" if os.name == "nt" else "pacer") + command = [compiler, "-std=c++17", "-Wall", "-Wextra", "-Werror", "-pedantic", + "-I", str(ROOT), str(source), "-o", str(cls.binary)] + if os.name != "nt": + command[1:1] = ["-fsanitize=address,undefined", "-fno-omit-frame-pointer"] + subprocess.run(command, check=True, capture_output=True, text=True) + + def run_case(self, name): + result = subprocess.run([str(self.binary), name], capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + def test_profiles_generations_and_portable_fallback(self): + self.run_case("profiles") + + def test_pending_and_incomplete_bursts_do_not_back_off(self): + self.run_case("pending") + + def test_duplicate_queue_tokens_and_callbacks(self): + self.run_case("queued_duplicates") + + def test_transmitter_failures_are_not_receiver_loss(self): + self.run_case("tx_failures") + + def test_clean_probes_recover_and_failed_probe_rolls_back(self): + self.run_case("recovery") + + def test_forward_progress_requires_actual_data_and_proof(self): + self.run_case("forward_evidence") + + def test_representation_ambiguity_and_logical_block_deduplication(self): + self.run_case("representations") + + def test_history_eviction_does_not_evict_pending_packets(self): + self.run_case("bounded_history") + + def test_sparse_loss_grace_across_clock_wrap(self): + self.run_case("clock_wrap") + + def test_high_fragment_bit_and_unaligned_manifest_id(self): + self.run_case("edge_bits_and_unaligned_ids") + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_ota_receive_cadence.py b/test/test_ota_receive_cadence.py new file mode 100644 index 000000000..c6e0b72a9 --- /dev/null +++ b/test/test_ota_receive_cadence.py @@ -0,0 +1,154 @@ +#!/usr/bin/env python3 +"""Run the allocation-free OTA receive cadence helper with real C++ arithmetic.""" +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[1] +HARNESS = r''' +#include +#include +#include +#include +using mesh::ota::OtaReceiveCadence; + +static void baseline_and_slow_source() { + OtaReceiveCadence c; + assert(c.serviceIntervalMs(500) == 500); + assert(c.serviceIntervalMs(0) == 0); + assert(!c.observe(8, 0, 0)); + assert(c.observe(8, 1, 2000)); + assert(c.serviceIntervalMs(500) == 2000); + assert(c.serviceIntervalMs(2500) == 2500); + assert(c.observe(8, 2, 6000)); + assert(c.serviceIntervalMs(500) == 4000); // react before the next retry + c.reset(); + assert(c.serviceIntervalMs(500) == 500); + assert(!c.observe(8, 3, 9000)); // reset does not preserve a timestamp anchor +} + +static void clean_recovery_and_bounded_pause() { + OtaReceiveCadence c; + assert(!c.observe(0, 0, 0)); + assert(c.observe(0, 1, 30000)); + uint32_t now = 30000; + uint32_t previous = 30000; + for (unsigned flight = 0; flight < 3; ++flight) { + c.beginFlight(); + assert(!c.observe(flight + 1, 0, now)); + for (uint8_t fragment = 1; fragment < 16; ++fragment) { + now += 1000; + assert(c.observe(flight + 1, fragment, now)); + uint32_t estimate = c.serviceIntervalMs(0); + assert(estimate >= 1000 && estimate <= previous); + previous = estimate; + } + } + assert(c.serviceIntervalMs(0) == 1000); // a rare pause cannot pin the estimate + assert(c.serviceIntervalMs(1400) == 1400); +} + +static void duplicates_backward_and_skipped_fragments() { + OtaReceiveCadence c; + assert(!c.observe(3, 0, 0)); + assert(c.observe(3, 1, 100)); + assert(!c.observe(3, 1, 199)); + assert(c.observe(3, 2, 200)); + assert(c.serviceIntervalMs(0) == 100); + assert(!c.observe(3, 0, 299)); + assert(c.observe(3, 3, 300)); + assert(c.serviceIntervalMs(0) == 100); + assert(!c.observe(3, 5, 1000)); // missing index is not a clean interval + assert(c.serviceIntervalMs(0) == 100); + assert(c.observe(3, 6, 1100)); + assert(!c.observe(3, 16, 1199)); + assert(c.observe(3, 7, 1200)); + assert(c.serviceIntervalMs(0) == 100); +} + +static void flight_boundaries_and_interleaving() { + OtaReceiveCadence c; + assert(!c.observe(1, 0, 0)); + assert(!c.observe(2, 0, 100)); + assert(c.observe(1, 1, 1000)); + assert(c.observe(2, 1, 1100)); + assert(c.serviceIntervalMs(0) == 1000); + c.beginFlight(); // proof, CLI and idle time is outside DATA cadence + assert(!c.observe(3, 0, 1000000000)); + assert(c.serviceIntervalMs(0) == 1000); + assert(c.observe(3, 1, 1000001000)); + assert(!c.observe(4, 0, 1000050000)); // another block has its own first timestamp + assert(c.serviceIntervalMs(0) == 1000); + assert(c.observe(4, 1, 1000051000)); + assert(c.serviceIntervalMs(0) == 1000); + assert(!c.observe(5, 0, 1000060000)); // bounded anchor eviction is harmless + assert(!c.observe(3, 2, 1000061000)); + assert(c.serviceIntervalMs(0) == 1000); +} + +static void wrap_zero_and_outliers() { + OtaReceiveCadence c; + assert(!c.observe(2, 0, UINT32_MAX - 49)); + assert(c.observe(2, 1, 50)); + assert(c.serviceIntervalMs(0) == 100); + c.reset(); + assert(!c.observe(2, 0, UINT32_MAX - 99)); + assert(c.observe(2, 1, 0)); + assert(c.serviceIntervalMs(0) == 100); + assert(!c.observe(2, 2, 0)); + assert(c.observe(2, 3, 100)); + assert(c.serviceIntervalMs(0) == 100); + c.reset(); + assert(!c.observe(2, 0, 100)); + assert(!c.observe(2, 1, 100 + OtaReceiveCadence::MaxSampleMs + 1)); + assert(c.serviceIntervalMs(500) == 500); + assert(c.observe(2, 2, 200 + OtaReceiveCadence::MaxSampleMs + 1)); + assert(c.serviceIntervalMs(0) == 100); + c.reset(); + assert(!c.observe(2, 0, 1000)); + assert(!c.observe(2, 1, 900)); // not millis wrap: enormous unsigned elapsed + assert(c.observe(2, 2, 1000)); + assert(c.serviceIntervalMs(0) == 100); + assert(c.serviceIntervalMs(UINT32_MAX) == UINT32_MAX); + c.reset(); + assert(!c.observe(UINT32_MAX, 0, 0)); + assert(!c.observe(3, 0, 0)); + assert(c.observe(3, 1, OtaReceiveCadence::MaxSampleMs)); + assert(c.serviceIntervalMs(0) == OtaReceiveCadence::MaxSampleMs); +} + +int main() { + static_assert(sizeof(OtaReceiveCadence) <= 40, "no per-fragment allocation"); + baseline_and_slow_source(); + clean_recovery_and_bounded_pause(); + duplicates_backward_and_skipped_fragments(); + flight_boundaries_and_interleaving(); + wrap_zero_and_outliers(); + std::puts("PASS: physical floor, pacing, recovery, flight isolation and rollover"); +} +''' + + +class OtaReceiveCadenceTest(unittest.TestCase): + def test_production_helper(self): + compiler = shutil.which('g++') or shutil.which('clang++') + self.assertIsNotNone(compiler, 'a host C++ compiler is required') + with tempfile.TemporaryDirectory(prefix='ota-receive-cadence-') as directory: + work = Path(directory) + cpp, binary = work/'cadence.cpp', work/'cadence' + cpp.write_text(HARNESS, encoding='ascii') + sanitizers = (['-fsanitize=address,undefined', '-fno-sanitize-recover=all', + '-fno-pie', '-no-pie'] if sys.platform.startswith('linux') else []) + built = subprocess.run([compiler, '-std=c++11', '-Wall', '-Wextra', '-Werror', + *sanitizers, '-I'+str(ROOT/'src'), str(cpp), '-o', str(binary)], + capture_output=True, text=True, timeout=60) + self.assertEqual(built.returncode, 0, built.stdout+built.stderr) + ran = subprocess.run([str(binary)], capture_output=True, text=True, timeout=10) + self.assertEqual(ran.returncode, 0, ran.stdout+ran.stderr) + self.assertIn('PASS:', ran.stdout) + + +if __name__ == '__main__': unittest.main() diff --git a/test/test_ota_self_serve_policy.py b/test/test_ota_self_serve_policy.py index aacac225f..0b2b49acd 100644 --- a/test/test_ota_self_serve_policy.py +++ b/test/test_ota_self_serve_policy.py @@ -17,6 +17,7 @@ class OtaSelfServePolicyTest(unittest.TestCase): cli = (ROOT / "src/helpers/ota/OtaCli.cpp").read_text(encoding="ascii") branch = method(cli, 'else if (is_cmd(a, "self|id", &rest))').removeprefix("else ") harness = r''' +#include #include #include #include @@ -362,6 +363,7 @@ int main() { '\nreturn true;\n}\n') source = path / "test.cpp" source.write_text(r''' +#include #include #include #include diff --git a/test/test_ota_transfer_diagnostics.py b/test/test_ota_transfer_diagnostics.py new file mode 100644 index 000000000..116aec1d7 --- /dev/null +++ b/test/test_ota_transfer_diagnostics.py @@ -0,0 +1,154 @@ +"""Execute the production OTA wire/pace diagnostics with bounded CLI replies.""" +from pathlib import Path +import os +import shutil +import unittest + +import test_ota_kiss_direct as native_tests +from test_ota_cli_listing import function + + +ROOT = Path(__file__).resolve().parents[1] + + +class OtaTransferDiagnosticsTest(unittest.TestCase): + compile_run = native_tests.OtaKissDirectTest.compile_run + + @classmethod + def setUpClass(cls): + cls.compiler = os.environ.get("CXX") or shutil.which("g++") or shutil.which("clang++") + if not cls.compiler: + raise unittest.SkipTest("a host C++17 compiler is required") + + def test_real_branches_word_helpers_and_maximum_reply_bounds(self): + source = (ROOT / "src/helpers/ota/OtaCli.cpp").read_text() + control = function(source, "handle_control_command") + start = control.index('} else if (OTA_TRANSFER_DIAGNOSTICS && is_cmd(a, "wire", &rest)) {') + end = control.index('} else if (is_cmd(a, "stats", &rest)) {', start) + branches = control[start:end] + header = (ROOT / "src/helpers/ota/OtaManager.h").read_text() + start = header.index("enum class WireChoice") + end = header.index("const WireStats& wireStats()", start) + declarations = header[start:end] + helpers = "\n".join(function(source, name) for name in ( + "is_cmd", "wire_choice_word", "wire_fallback_word")) + helpers += "\n" + function( + (ROOT / "src/helpers/ota/OtaSpeedConfig.cpp").read_text(), "formatSpeedFactor") + code = r''' +#include +#include +#include +#include +#include +#define OTA_TRANSFER_DIAGNOSTICS @DIAGNOSTICS@ +struct OtaManager { + @DECLARATIONS@ + WireStats wire; + bool v2=false,deflate=false; + float rate[2]={1.0f,1.0f}; + uint32_t gap=0,retry=0; + mutable unsigned rate_queries=0; + const WireStats& wireStats() const { return wire; } + bool fetchWireV2() const { return v2; } + bool fetchAllowsDeflate() const { return deflate; } + float adaptivePacketSpeed(uint8_t profile) const { + assert(profile<2);rate_queries|=1u<header=ROUTE_TYPE_FLOOD | (PAYLOAD_TYPE_OTA<<2); + assert(f.d.sendPacket(p,0)); +} +static void finish(Fixture& f) { f.radio.complete=true;f.tick(100); } +int main() { + // A slow secondary cannot delay a native primary OTA frame, even when a + // paced secondary is first in the queue. Both use normal CAD/budget logic. + { Fixture f;directProfile(f);queueNative(f,1);f.tick();finish(f); + queueNative(f,1);queueNative(f,0);f.tick(); + assert(f.radio.sends==2 && f.radio.active==0); + finish(f);f.tick();assert(f.radio.sends==2); + f.tick(800);assert(f.radio.sends==3 && f.radio.active==1); + } + // Direct completion creates the same profile-specific gap. Profile zero + // remains available; changing generation retires profile one's old gap. + { Fixture f;directProfile(f);assert(enqueue(f));f.tick();finish(f); + assert(enqueue(f,0));f.tick();assert(f.radio.sends==2 && f.radio.active==0);finish(f); + assert(enqueue(f));f.tick();assert(f.radio.sends==2); + ++f.radio.p.generation[1];f.tick();assert(!f.d.hasOutbound()); + assert(enqueue(f));f.tick();assert(f.radio.sends==3 && f.radio.active==1); + } + // Native OTA completion also paces direct OTA on that physical profile. + { Fixture f;directProfile(f);queueNative(f,1);f.tick();finish(f); + assert(enqueue(f));f.tick(899);assert(f.radio.sends==1); + f.tick(2);assert(f.radio.sends==2); + } +} +''' + self.compile_run(code, FLAGS, dispatcher=True) + + def test_mesh_completion_ignores_forwarding_and_retains_physical_route(self): + source = (ROOT / "src/Mesh.cpp").read_text() + code = r''' +#include +#include +#include +namespace mesh { namespace ota { +struct OtaReplyRoute { uint8_t profile=255;uint32_t generation=0;bool direct=false; }; +struct Manager { + unsigned completed=0,failed=0;OtaReplyRoute route;uint32_t now=0; + void noteResponseTransmitted(const uint8_t* p,uint16_t n,const OtaReplyRoute& r,uint32_t t) { + assert(p && n==3 && p[0]==OTA_DATA);++completed;route=r;now=t; + } + void noteResponseFailed(const uint8_t* p,uint16_t n,const OtaReplyRoute& r,uint32_t t) { + assert(p && n==3 && p[0]==OTA_DATA);++failed;route=r;now=t; + } + float adaptivePacketSpeed(uint8_t p,uint32_t g) const { assert(p==1 && g==42);return 0.5f; } +}; +struct Context { Manager manager; } context; +bool exists=false; +Context* ota_context_if_active() { return exists ? &context:nullptr; } +} } +namespace mesh { +struct Clock { unsigned long getMillis() const { return 1234; } }; +struct Tables { Packet* seen=nullptr;void markSeen(Packet* p) { seen=p; } }; +static uint8_t otaTrafficPriority(const uint8_t*,uint16_t) { return 0; } +class Mesh { +public: + Clock clock;Clock* _ms=&clock;unsigned lifecycle=0;float configured=1; + Tables tables;Tables* _tables=&tables;uint8_t hops=3; + Packet* queued=nullptr;uint32_t queue_delay=0;unsigned remembered=0; + void watchForwardedAdvertEcho(Packet*) { ++lifecycle; } + void armDirectRetryOnSendComplete(Packet*) { ++lifecycle; } + void armFloodRetryOnSendComplete(Packet*) { ++lifecycle; } + void clearPendingDirectRetryOnSendFail(Packet*) { ++lifecycle; } + void clearPendingFloodRetryOnSendFail(Packet*) { ++lifecycle; } + void rememberOtaForward(Packet*) { ++lifecycle;++remembered; } + uint8_t getOtaHopLimit() const { return hops; } + bool sendPacket(Packet* p,uint8_t,uint32_t delay) { queued=p;queue_delay=delay;return true; } + bool sendOtaFlood(Packet*,uint32_t); + float getOtaSpeedFactor() const { return configured; } + float getOtaPacketSpeedFactor(uint8_t,uint32_t) const; + void onSendComplete(Packet*); + void onSendFail(Packet*); + void onDirectOtaRadioSendComplete(const uint8_t*,size_t,uint8_t,uint32_t,bool); +}; +@COMPLETE@ +@FAILED@ +@DIRECT@ +@SPEED@ +@SEND@ +} +int main() { + using namespace mesh;Mesh m;Packet p; + p.header=ROUTE_TYPE_FLOOD | (PAYLOAD_TYPE_OTA<<2);p.path_len=0;p.payload_len=3; + p.payload[0]=ota::OTA_DATA;p.radio_profile=1;p.radio_generation=42; + m.onSendComplete(&p);m.onSendFail(&p);assert(!ota::exists && !ota::context.manager.completed); + ota::exists=true;m.onSendComplete(&p);m.onSendFail(&p); + auto& manager=ota::context.manager; + assert(manager.completed==1 && manager.failed==1 && manager.now==1234); + assert(manager.route.profile==1 && manager.route.generation==42 && !manager.route.direct); + // Exercise sendOtaFlood itself: a zero-hop native OTA session uses DIRECT0, + // while OtaReplyRoute::direct remains false (it is not the raw/KISS transport). + m.hops=0;p.path_len=9; + assert(m.sendOtaFlood(&p,17)); + assert(p.isRouteDirect() && p.getPathHashCount()==0 && m.queued==&p); + assert(m.tables.seen==&p && m.queue_delay==17); + const unsigned remembered=m.remembered; + m.onSendComplete(&p);m.onSendFail(&p); + assert(manager.completed==2 && manager.failed==2 && !manager.route.direct); + assert(manager.route.profile==1 && manager.route.generation==42); + assert(m.remembered==remembered); // Existing forwarding-cache flood guard stays intact. + p.forwarded=true;m.onSendComplete(&p);m.onSendFail(&p); + p.forwarded=false;p.path_len=1;m.onSendComplete(&p);m.onSendFail(&p); + p.path_len=0;p.header=ROUTE_TYPE_FLOOD | (PAYLOAD_TYPE_GRP_TXT<<2); + m.onSendComplete(&p);m.onSendFail(&p);m.onSendComplete(nullptr);m.onSendFail(nullptr); + assert(manager.completed==2 && manager.failed==2); + const uint8_t direct[]={0xf2,0,ota::OTA_DATA,0,0}; + m.onDirectOtaRadioSendComplete(direct,sizeof(direct),1,42,true); + assert(manager.completed==3 && manager.route.direct && manager.route.profile==1); + m.onDirectOtaRadioSendComplete(direct,sizeof(direct),1,42,false); + assert(manager.failed==3 && manager.route.direct && manager.route.generation==42); + m.onDirectOtaRadioSendComplete(direct,2,1,42,true);assert(manager.completed==3); + assert(m.getOtaPacketSpeedFactor(1,42)==0.5f);m.configured=0.25f; + assert(m.getOtaPacketSpeedFactor(1,42)==0.25f); + ota::exists=false;m.onDirectOtaRadioSendComplete(direct,sizeof(direct),1,42,true); + assert(manager.completed==3 && m.getOtaPacketSpeedFactor(1,42)==0.25f); +} +''' + for token, signature in ( + ("@COMPLETE@", "void Mesh::onSendComplete("), + ("@FAILED@", "void Mesh::onSendFail("), + ("@DIRECT@", "void Mesh::onDirectOtaRadioSendComplete("), + ("@SPEED@", "float Mesh::getOtaPacketSpeedFactor(uint8_t"), + ("@SEND@", "bool Mesh::sendOtaFlood("), + ): + code = code.replace(token, method(source, signature)) + # Packet's constructor lives in Packet.cpp; no radio or scheduler is + # needed to exercise the extracted production integration methods. + code = code.replace("Packet p;", "Packet p{};").replace( + "namespace mesh {\nstruct Clock", "namespace mesh {\nPacket::Packet() {}\nstruct Clock") + self.compile_run(code, FLAGS) + + +if __name__ == "__main__": + unittest.main() diff --git a/variants/rak4631/platformio.ini b/variants/rak4631/platformio.ini index 98c760e29..a8888e1e1 100644 --- a/variants/rak4631/platformio.ini +++ b/variants/rak4631/platformio.ini @@ -494,6 +494,8 @@ build_flags = ; No flash left for named radio presets (~3.9 KB) under the Full Companion ; checkprogsize gate; the preset commands stay out of this image. -D MESH_ENABLE_RADIO_PRESETS=0 + ; Keep automatic OTA recovery; omit only its new optional CLI diagnostics. + -D OTA_TRANSFER_DIAGNOSTICS=0 build_src_filter = ${env:RAK_4631_companion_radio_usb.build_src_filter} + +