diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index 98f21255..524c65d8 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -41,6 +41,12 @@ jobs: - name: Verify nRF52 UF2-reset CLI coverage run: python3 -B test/test_nrf52_uf2reset_cli.py + - name: Verify T096 Full memory and nRF52 Bluetooth startup + run: | + python3 -B test/test_t096_full_memory.py + python3 -B test/test_nrf52_ble_startup.py + python3 -B test/test_shared_mota_queue.py + - name: Verify Companion Bluetooth settings and WebConfig run: | python3 -B test/test_bluetooth_mac_contract.py diff --git a/boards/nrf52840_s140_v6_extrafs.ld b/boards/nrf52840_s140_v6_extrafs.ld index bd454747..abc0f18a 100644 --- a/boards/nrf52840_s140_v6_extrafs.ld +++ b/boards/nrf52840_s140_v6_extrafs.ld @@ -47,3 +47,4 @@ SECTIONS } INCLUDE "nrf52_common.ld" +INCLUDE "boards/nrf52_heap_reserve.ld" diff --git a/boards/nrf52_heap_reserve.ld b/boards/nrf52_heap_reserve.ld new file mode 100644 index 00000000..b233c6b4 --- /dev/null +++ b/boards/nrf52_heap_reserve.ld @@ -0,0 +1,9 @@ +/* A profile may require runtime space beyond a successful static-data link. + * __HeapBase/__HeapLimit come from the framework's nrf52_common.ld and exclude + * SoftDevice RAM, static data and the main stack. The reserve includes all + * dynamic allocations; it is not a measurement of free heap after startup. + */ +PROVIDE(__mesh_nrf52_min_heap_size = 0); +ASSERT(__HeapLimit >= __HeapBase, "Invalid nRF52 runtime heap boundaries") +ASSERT(__HeapLimit - __HeapBase >= __mesh_nrf52_min_heap_size, + "nRF52 runtime heap reserve too small; reduce static tables or buffers") diff --git a/build.sh b/build.sh index 49e36797..06279afb 100755 --- a/build.sh +++ b/build.sh @@ -3504,6 +3504,20 @@ apply_companion_radio_full_profile() { append_platformio_build_unflags "-UOTA_FOLDER_SERIAL" export PLATFORMIO_BUILD_FLAGS="${PLATFORMIO_BUILD_FLAGS} -DOTA_FOLDER_SERIAL=1 -DCOMPANION_FEATURE_USB_MOTA_SOURCE=1 -DCOMPANION_FEATURE_BLE_MOTA_SOURCE=1 -DCOMPANION_FEATURE_DEDICATED_USB_LOGGING=1 -DCFG_TUD_CDC=2 -DMESH_DUAL_CDC_LOGGING=1 -DMESH_DEBUG=1 -DMESH_PACKET_LOGGING=1" + case "${env_name,,}" in + heltec_t096_companion_radio_full*) + # The TFT allocates 25 KiB after linking. Together with the loop, + # callback and BLE task stacks, packet pool, filesystems and message + # previews, this exhausts the 1.17.1.5 image's ~53 KiB heap. Keep all + # 256 offline slots normally; lend the upper 128 to the mOTA context + # only while needed. Reserve 72 KiB for runtime allocations at link. + append_platformio_build_unflags "-DOFFLINE_QUEUE_SIZE=512 -DOFFLINE_QUEUE_SIZE=128" + export PLATFORMIO_BUILD_FLAGS="${PLATFORMIO_BUILD_FLAGS} -DOFFLINE_QUEUE_SIZE=256 -DOTA_SHARED_COMPANION_QUEUE=1 -Wl,--defsym=__mesh_nrf52_min_heap_size=73728" + record_build_reduction \ + "T096 Full: 256 offline frames normally; 128 while mOTA borrows queue storage" + ;; + esac + if ! pio_env_option_contains "$pio_env_name" build_src_filter "helpers/ota/*.cpp"; then append_platformio_build_src_filter "+" fi diff --git a/docs/companion_offline_queue.md b/docs/companion_offline_queue.md index ae973d99..436d59bc 100644 --- a/docs/companion_offline_queue.md +++ b/docs/companion_offline_queue.md @@ -12,6 +12,7 @@ A reboot clears it. | ESP32 with configured PSRAM | 512 | | ESP32 without PSRAM | 256 | | nRF52840 | 256 | +| T096 Full Companion with the memory correction | 256 normally; 128 while mOTA owns shared storage | | RP2040 | 256 | | STM32 | 16 | | Known constrained classic ESP32 target override | 128 | @@ -25,6 +26,22 @@ Meshadventurer SX1262 and SX1268 Full Companion use 16 frames together with 100 contacts and 30 group channels; their ordinary transport-specific images keep 128 frames and 40 channels. +The [T096 Full memory correction](releases/1.17.1.5.md#t096-full-companion-bluetooth-and-menu-freeze-report) +keeps 256 frames normally, while retaining 350 contacts, 40 channels, and all +Full transports. The upper 128 slots temporarily hold the mOTA context when +a source or TempRadio discovery session starts. Stopping or disconnecting the +USB/Bluetooth source returns all 256 slots; a discovery-only session returns +them when TempRadio ends. This shares a fixed memory region and avoids heap +fragmentation from resizing. + +Existing unread messages retain their order. If more than 128 frames are +pending, mOTA refuses the loan and asks you to sync messages with a Companion +app first. While the loan is active, the overflow policy below applies at 128 +frames. The original `26303793` 1.17.1.5 download reserves the queue and mOTA +state separately, leaving too little runtime headroom for its color display +and Bluetooth. The corrected profile recovers about 19 KiB by sharing storage +and requires at least 72 KiB of heap space at link time. + Standard, logging, MQTT, and Cascade build overlays retain the selected target capacity; they do not silently shrink the queue. diff --git a/docs/companion_radio_full.md b/docs/companion_radio_full.md index 39318901..c94772ef 100644 --- a/docs/companion_radio_full.md +++ b/docs/companion_radio_full.md @@ -333,6 +333,12 @@ direct messages; it is not flash-backed history. See [Companion offline message queue](./companion_offline_queue.md) for all platform defaults and full-queue behavior. +The corrected T096 Full profile keeps **256 offline frames normally** and +temporarily lends 128 slots to mOTA to leave room for +its color framebuffer, Bluetooth tasks, and UI allocations. It retains 350 +contacts, 40 channels, and USB/Bluetooth mOTA sending. The published 1.17.1.5 +`26303793` image predates this [memory correction](releases/1.17.1.5.md#t096-full-companion-bluetooth-and-menu-freeze-report). + The nRF52 target inherits the board's ordinary USB Companion installation format and adds BLE plus the serial mOTA source. It does not enable an SD cache or any other board-specific storage behavior; host files are streamed as they diff --git a/docs/releases/1.17.1.5.md b/docs/releases/1.17.1.5.md index f343f448..98bce82c 100644 --- a/docs/releases/1.17.1.5.md +++ b/docs/releases/1.17.1.5.md @@ -85,6 +85,61 @@ logging toggles, OTA inhibition, simultaneous button/LoRa wake configuration, and the two-minute interval across timer rollover. The published 1.17.1.5 binaries have not been replaced by these corrections. +## T096 Full Companion Bluetooth and menu freeze report + +A report against `Heltec_t096_companion_radio_full_femon` describes no usable +Bluetooth despite a displayed PIN, followed by a freeze while stepping through +the device menu. Reflashing both with and without a full wipe gave the same +result. The exact freeze and Bluetooth behavior still need confirmation on a +physical T096 with the source correction below. + +The published `26303793` Full image leaves only **54,724 bytes (53.4 KiB)** +between the linked heap boundaries. Its static RAM check did not account for +the allocations made at startup: the 160×80 color framebuffer alone needs +25,602 bytes, followed by task stacks, the packet pool, filesystem buffers, +and message previews. This leaves inadequate runtime headroom. The pinned +Bluefruit library also ignores failure to create its BLE/SOC worker tasks, +and the sensor menu could write through a failed CayenneLPP allocation. Those +are code paths consistent with this report; a PIN is not proof of a working +Bluetooth radio. + +The source correction keeps the T096 **Full** offline queue at **256 frames +during normal use**. mOTA borrows the upper 128 slots for its roughly 19 KiB +session state only when needed, leaving 128 message slots during mOTA use. +Starting a source or a TempRadio discovery session acquires this workspace. +Stopping or disconnecting the USB/Bluetooth source restores all 256 slots, +even if the temporary radio window is still open. A discovery-only session +returns the slots when its TempRadio window ends. No heap resizing is involved. + +Unread messages keep their order across both transitions. If more than 128 +frames are pending, mOTA refuses to start and asks you to **sync unread +messages with a Companion app first**; it does not discard them to make room. +OTA configuration and signer keys survive reuse of the workspace. While mOTA +owns it, the normal queue overflow policy applies to the remaining 128 slots. + +The profile requires at least 72 KiB of heap space at link time. It retains +350 contacts, 40 channels, the color display, sensors, USB, Bluetooth, +Bluetooth DFU, and USB/Bluetooth mOTA sending. This reserve is space for +runtime allocations, not a measurement of free heap after boot. +Bluetooth startup now checks both worker tasks and all registered services; +a failed partial initialization does not repeat every five seconds. The +sensor page reports low memory if its buffer could not be allocated. + +Regression tests transfer complete mOTA containers through the actual shared +context, check queue wraparound and message order, and cover source stop, +disconnect, conflicting USB/Bluetooth ownership and repeated sessions. They +also inject worker-task and GATT-service failures, verify that retries do not +initialize the stack again, open the sensor path with a failed allocation, +and use the real linker assertion to reject the old heap budget. +The existing `26303793` firmware downloads do **not** contain this correction. +Another wipe is not a fix for the compiled memory budget. + +For a T096 test build containing the correction, use its matching application +UF2 through the existing USB bootloader. Preserve the bootloader and node data. +Check Bluetooth discovery, pairing and message exchange on battery power and +with USB attached; then step through every menu page, return from screen-off, +and check USB and Bluetooth mOTA sending. Hardware validation is pending. + ## Firmware roles and update support Use the exact board's Full Companion image when available. Every Full diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index f4f3fda2..3bbf0c0a 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -408,7 +408,9 @@ bool MyMesh::Frame::isChannelMsg() const { } int MyMesh::getOfflineQueueCapacity() const { -#if defined(ESP32_PLATFORM) && defined(BOARD_HAS_PSRAM) +#if defined(OTA_SHARED_COMPANION_QUEUE) + return offline_queue.capacity(); +#elif defined(ESP32_PLATFORM) && defined(BOARD_HAS_PSRAM) return offline_queue_capacity; #else return OFFLINE_QUEUE_SIZE; @@ -416,11 +418,29 @@ int MyMesh::getOfflineQueueCapacity() const { } MyMesh::Frame& MyMesh::offlineQueueFrameAt(int logical_index) { +#if defined(OTA_SHARED_COMPANION_QUEUE) + return offline_queue.at((offline_queue_head + logical_index) % getOfflineQueueCapacity()); +#else return offline_queue[(offline_queue_head + logical_index) % getOfflineQueueCapacity()]; +#endif } +#if defined(OTA_SHARED_COMPANION_QUEUE) +mesh::ota::OtaContext* MyMesh::acquireOfflineQueueForOta(void* owner) { + MyMesh* mesh = static_cast(owner); + return mesh->offline_queue.acquire(mesh->offline_queue_len, mesh->offline_queue_head); +} + +void MyMesh::releaseOfflineQueueFromOta(void* owner) { + MyMesh* mesh = static_cast(owner); + mesh->offline_queue.release(mesh->offline_queue_head); +} +#endif + void MyMesh::initializeOfflineQueue() { -#if defined(ESP32_PLATFORM) && defined(BOARD_HAS_PSRAM) +#if defined(OTA_SHARED_COMPANION_QUEUE) + mesh::ota::ota_set_context_storage(this, acquireOfflineQueueForOta, releaseOfflineQueueFromOta); +#elif defined(ESP32_PLATFORM) && defined(BOARD_HAS_PSRAM) if (offline_queue != offline_queue_fallback || OFFLINE_QUEUE_SIZE <= offline_queue_capacity) return; int requested_capacity = OFFLINE_QUEUE_SIZE; @@ -2080,6 +2100,10 @@ bool MyMesh::scheduleTempRadio(float freq, float bw, uint8_t sf, uint8_t cr, return false; } +#if defined(OTA_SHARED_COMPANION_QUEUE) + if (!mesh::ota::ota_acquire_context(reply, reply_size)) return false; + mesh::ota::ota_ctx().release_when_idle = false; +#endif _temp_radio_freq = freq; _temp_radio_bw = bw; _temp_radio_sf = sf; @@ -8192,6 +8216,9 @@ void MyMesh::loop() { } #if COMPANION_FEATURE_TEMP_RADIO serviceTempRadio(); +#endif +#if defined(OTA_SHARED_COMPANION_QUEUE) + mesh::ota::ota_release_context_if_idle(isTempRadioActive() || _temp_radio_set_at != 0); #endif BaseChatMesh::loop(); #ifdef COMPANION_MESH_CLOCK_SYNC diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index 51ba9c60..64309997 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -48,6 +48,10 @@ #include #include #include +#if defined(OTA_SHARED_COMPANION_QUEUE) +#include +#include +#endif #ifdef COMPANION_MESH_CLOCK_SYNC #include @@ -569,7 +573,12 @@ private: void initializeOfflineQueue(); int offline_queue_len; int offline_queue_head; -#if defined(ESP32_PLATFORM) && defined(BOARD_HAS_PSRAM) +#if defined(OTA_SHARED_COMPANION_QUEUE) + static_assert(OFFLINE_QUEUE_SIZE == 256, "Shared mOTA queue requires 256 normal slots"); + mesh::BorrowableFrameBuffer offline_queue; + static mesh::ota::OtaContext* acquireOfflineQueueForOta(void* owner); + static void releaseOfflineQueueFromOta(void* owner); +#elif defined(ESP32_PLATFORM) && defined(BOARD_HAS_PSRAM) enum { OFFLINE_QUEUE_PSRAM_FALLBACK_SIZE = OFFLINE_QUEUE_SIZE < 16 ? OFFLINE_QUEUE_SIZE : 16 diff --git a/examples/companion_radio/main.cpp b/examples/companion_radio/main.cpp index d305f9c3..02f4aff9 100644 --- a/examples/companion_radio/main.cpp +++ b/examples/companion_radio/main.cpp @@ -222,6 +222,7 @@ public: return false; } + if (!mesh::ota::ota_acquire_context(reply, reply_size)) return false; mesh::ota::OtaContext& context = mesh::ota::ota_ctx(); if (context.folder_active && context.folderLink() != mesh::ota::OtaContext::FOLDER_LINK_BLE) { @@ -247,24 +248,32 @@ public: bool stop(char* reply, size_t reply_size) override { if (!reply || reply_size == 0) return false; - mesh::ota::OtaContext& context = mesh::ota::ota_ctx(); bluetooth_interface.setMotaStreamActive(false); + if (!mesh::ota::ota_context_if_active()) { + snprintf(reply, reply_size, "OK Bluetooth mOTA source stopped"); + return true; + } + mesh::ota::OtaContext& context = mesh::ota::ota_ctx(); if (context.folder_active && context.folderLink() == mesh::ota::OtaContext::FOLDER_LINK_BLE) { context.detach_folder(); context.manager.announce(); mesh::usbLoggingPort().println("Bluetooth mOTA source detached"); + _last_packets_sent = context.manager.packetsSent() + - _packets_sent_at_start; } - _last_packets_sent = context.manager.packetsSent() - - _packets_sent_at_start; snprintf(reply, reply_size, "OK Bluetooth mOTA source stopped"); return true; } mesh::companion::MotaSourceStatus status() const override { - const mesh::ota::OtaContext& context = mesh::ota::ota_ctx(); mesh::companion::MotaSourceStatus result; result.channel_ready = bluetooth_interface.isMotaChannelReady(); + if (!mesh::ota::ota_context_if_active()) { + result.packets_sent = _last_packets_sent; + return result; + } + const mesh::ota::OtaContext& context = mesh::ota::ota_ctx(); result.attached = context.folder_active && context.folderLink() == mesh::ota::OtaContext::FOLDER_LINK_BLE && bluetooth_interface.isMotaStreamActive(); @@ -281,6 +290,10 @@ public: } void loop() { + if (!mesh::ota::ota_context_if_active()) { + bluetooth_interface.setMotaStreamActive(false); + return; + } mesh::ota::OtaContext& context = mesh::ota::ota_ctx(); const bool owns_folder = context.folder_active && context.folderLink() == mesh::ota::OtaContext::FOLDER_LINK_BLE; @@ -2054,9 +2067,17 @@ void halt() { clear_bonds, stealth_pair_once, bonded_only_peer_ptr)) { interface_manager.removeInterface(&bluetooth_interface); +#if defined(NRF52_PLATFORM) + // A partly initialized Bluefruit stack cannot be started again safely. + // Leave the interface unregistered so the UI cannot advertise a PIN + // for it, and avoid consuming more heap every five seconds. + mesh::usbLoggingPort().println( + "Companion: Bluetooth initialization failed; reboot required"); +#else mesh::usbLoggingPort().println( "Companion: Bluetooth initialization failed; retrying in 5 seconds"); scheduleCompanionBluetoothRetry(); +#endif return; } #if defined(ESP32_PLATFORM) && COMPANION_BT_MODEM_SLEEP_AVAILABLE diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 6b5cd991..20541ce3 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -456,6 +456,14 @@ class HomeScreen : public UIScreen { int next_sensors_refresh = 0; void refresh_sensors() { + // CayenneLPP retains its requested capacity even if its allocation fails. + // Its add methods would then write through NULL when this page is opened. + if (sensors_lpp.getBuffer() == nullptr) { + sensors_nb = 0; + sensors_scroll = false; + sensors_scroll_offset = 0; + return; + } if (millis() > next_sensors_refresh) { sensors_lpp.reset(); sensors_nb = 0; @@ -947,6 +955,12 @@ public: } else if (_page == HomePage::SENSORS) { int y = 18; refresh_sensors(); + if (sensors_lpp.getBuffer() == nullptr) { + display.setColor(UIColor::warning_txt); + display.drawTextCentered(display.width() / 2, 34, + "Sensors: low memory"); + return 5000; + } char buf[30]; char name[30]; LPPReader r(sensors_lpp.getBuffer(), sensors_lpp.getSize()); diff --git a/src/Mesh.cpp b/src/Mesh.cpp index 6b3364d3..6426b75f 100644 --- a/src/Mesh.cpp +++ b/src/Mesh.cpp @@ -188,7 +188,7 @@ bool Mesh::otaSendAdapter(void* ctx, const uint8_t* msg, uint16_t len, bool /*fl // Runtime OTA flood reach (`ota config hops`, persisted in NodePrefs): accept packets up to N hops away and // relay those still under N hops. 0 = direct only. Overridable per-role by subclassing. -uint8_t Mesh::getOtaHopLimit() const { return ota::ota_ctx().manager.max_hops(); } +uint8_t Mesh::getOtaHopLimit() const { return ota::ota_hop_limit(); } #endif void Mesh::begin() { @@ -263,8 +263,7 @@ void Mesh::begin() { #ifdef MOTA_HW_ID my_hw = MOTA_HW_ID; // human-readable hardware tag (per-variant), for the apply hw gate #endif - ota::ota_ctx().begin(my_tid, Mesh::otaSendAdapter, this, my_hw); // also sets the platform apply codec - ota::ota_ctx().manager.set_seeder_id(self_id.pub_key); // node id (pubkey[0:4]) for advert seeder count + ota::ota_begin_context(my_tid, Mesh::otaSendAdapter, this, my_hw, self_id.pub_key); #endif } @@ -357,6 +356,12 @@ void __attribute__((noinline)) Mesh::serviceLoopMaintenance() { } } } +#endif +#if defined(OTA_SHARED_COMPANION_QUEUE) + if (!ota::ota_context_if_active()) { + _ota_temp_was_active = false; + return; + } #endif const bool ota_active = isTempRadioActive(); if (!ota_active) { @@ -1093,12 +1098,13 @@ DispatcherAction Mesh::onRecvPacket(Packet* pkt) { const uint8_t ota_priority = otaTrafficPriority(pkt->payload, pkt->payload_len); bool terminal_ota = false; #if defined(ENABLE_OTA) - ota::ota_ctx().manager.set_clock(_ms->getMillis()); // discovery jitter/ages - ota::ota_ctx().manager.note_rx_path_hops(n); // adaptive fetch timing - terminal_ota = ota::ota_ctx().manager.on_message(pkt->payload, pkt->payload_len); - // central OTA receive (all roles) - ota::ota_ctx().track_session(ota::ota_ctx().manager.fetchState(), _ms->getMillis()); - onOtaRecv(pkt); // optional per-example hook + if (ota::OtaContext* context = ota::ota_context_if_active()) { + context->manager.set_clock(_ms->getMillis()); + context->manager.note_rx_path_hops(n); + terminal_ota = context->manager.on_message(pkt->payload, pkt->payload_len); + context->track_session(context->manager.fetchState(), _ms->getMillis()); + onOtaRecv(pkt); + } #endif // Re-flood discovery at background priority, but keep an active transfer primary at every relay hop. // The free-pool reserve still sheds periodic discovery under pressure; requested transfer packets are diff --git a/src/Nrf52LoopStack.cpp b/src/Nrf52LoopStack.cpp index 3272e928..51f27bd1 100644 --- a/src/Nrf52LoopStack.cpp +++ b/src/Nrf52LoopStack.cpp @@ -4,6 +4,16 @@ #include #include #include +#include + +namespace mesh { +namespace nrf52 { +static uint8_t ble_task_mask; + +void resetBleTaskStartup() { ble_task_mask = 0; } +bool bleTasksStarted() { return ble_task_mask == 3; } +} // namespace nrf52 +} // namespace mesh #ifndef MESH_NRF52_LOOP_STACK_WORDS #define MESH_NRF52_LOOP_STACK_WORDS 2048 @@ -32,8 +42,18 @@ extern "C" BaseType_t __wrap_xTaskCreate( && stack_depth == 1024) { adjusted_depth = MESH_NRF52_LOOP_STACK_WORDS; } - return __real_xTaskCreate(task_code, task_name, adjusted_depth, parameters, - priority, created_task); + const BaseType_t result = __real_xTaskCreate( + task_code, task_name, adjusted_depth, parameters, priority, created_task); + if (task_name != NULL) { + const uint8_t mask = strcmp(task_name, "BLE") == 0 ? 1 + : strcmp(task_name, "SOC") == 0 ? 2 : 0; + if (result == pdPASS) { + mesh::nrf52::ble_task_mask |= mask; + } else { + mesh::nrf52::ble_task_mask &= ~mask; + } + } + return result; } #endif diff --git a/src/helpers/BorrowableFrameBuffer.h b/src/helpers/BorrowableFrameBuffer.h new file mode 100644 index 00000000..16cdc1a8 --- /dev/null +++ b/src/helpers/BorrowableFrameBuffer.h @@ -0,0 +1,78 @@ +#pragma once + +#include +#include +#include + +namespace mesh { + +// One owner at a time: an offline FIFO borrows the workspace's storage while +// idle. Switching owners compacts the ring in place, without allocating RAM or +// dropping unread frames. The caller owns the FIFO's length and head index. +template +class BorrowableFrameBuffer { + static_assert(Reserved > 0 && Reserved < Capacity, "Invalid shared queue capacities"); + struct ExtraFrames { Frame frames[Capacity - Reserved]; }; + static_assert(sizeof(Workspace) <= sizeof(ExtraFrames), "mOTA exceeds borrowed queue storage"); + + Frame _retained[Reserved]; + union Extra { + ExtraFrames queue; + Workspace workspace; + Extra() : queue() {} + ~Extra() {} + } _extra; + bool _borrowed = false; + + void reverse(size_t first, size_t end) { + while (first < end && first < --end) { + unsigned char saved[sizeof(Frame)]; + memcpy(saved, &at(first), sizeof(Frame)); + memcpy(&at(first), &at(end), sizeof(Frame)); + memcpy(&at(end), saved, sizeof(Frame)); + ++first; + } + } + + void compact(int& head) { + if (head == 0) return; + // Rotate the entire ring, including unused slots, using one frame of stack. + reverse(0, head); + reverse(head, capacity()); + reverse(0, capacity()); + head = 0; + } + +public: + BorrowableFrameBuffer() = default; + BorrowableFrameBuffer(const BorrowableFrameBuffer&) = delete; + BorrowableFrameBuffer& operator=(const BorrowableFrameBuffer&) = delete; + ~BorrowableFrameBuffer() { + if (_borrowed) _extra.workspace.~Workspace(); + } + + size_t capacity() const { return _borrowed ? Reserved : Capacity; } + Frame& at(size_t physical_index) { + return physical_index < Reserved ? _retained[physical_index] + : _extra.queue.frames[physical_index - Reserved]; + } + + Workspace* acquire(int length, int& head) { + if (_borrowed) return &_extra.workspace; + if (length > static_cast(Reserved)) return nullptr; + compact(head); + Workspace* workspace = new (&_extra.workspace) Workspace(); + _borrowed = true; + return workspace; + } + + void release(int& head) { + if (!_borrowed) return; + compact(head); + _extra.workspace.~Workspace(); + new (&_extra.queue) ExtraFrames(); + _borrowed = false; + } +}; + +} // namespace mesh diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index e98db71f..85826f58 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -856,6 +856,7 @@ void CommonCLI::loadPrefs(FILESYSTEM* fs) { #if defined(ENABLE_OTA) // Push the persisted OTA policy + signer allowlist into the running OtaContext (called after load). void CommonCLI::syncOtaConfigFromPrefs() { + if (!mesh::ota::ota_acquire_context(nullptr, 0)) return; mesh::ota::OtaContext& c = mesh::ota::ota_ctx(); c.manager.set_autofetch(_prefs->ota_autofetch); c.manager.set_checkpoint_blocks(_prefs->ota_checkpoint_blocks); @@ -2638,7 +2639,8 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re strcpy(reply, "LoRa OTA needs temp radio on every node. Run: tempradio 909.950,250,5,5,120"); } else { mesh::ota::handle_ota_command(command, reply, *_board); - if (mesh::ota::ota_ctx().config_dirty) { // a policy/key changed via the CLI -> persist it + if (mesh::ota::ota_context_if_active() + && mesh::ota::ota_ctx().config_dirty) { // a policy/key changed via the CLI -> persist it mesh::ota::OtaContext& c = mesh::ota::ota_ctx(); _prefs->ota_autofetch = c.manager.autofetch(); _prefs->ota_checkpoint_blocks = c.manager.checkpoint_blocks(); diff --git a/src/helpers/nrf52/BleTaskStartup.h b/src/helpers/nrf52/BleTaskStartup.h new file mode 100644 index 00000000..f40dea7d --- /dev/null +++ b/src/helpers/nrf52/BleTaskStartup.h @@ -0,0 +1,13 @@ +#pragma once + +namespace mesh { +namespace nrf52 { + +// The pinned Bluefruit core does not check xTaskCreate's return value for its +// BLE and SOC workers. Nrf52LoopStack.cpp observes those calls through the +// existing linker wrapper so a nominally successful begin cannot hide OOM. +void resetBleTaskStartup(); +bool bleTasksStarted(); + +} // namespace nrf52 +} // namespace mesh diff --git a/src/helpers/nrf52/SerialBLEInterface.cpp b/src/helpers/nrf52/SerialBLEInterface.cpp index 3bd7ffeb..01252e78 100644 --- a/src/helpers/nrf52/SerialBLEInterface.cpp +++ b/src/helpers/nrf52/SerialBLEInterface.cpp @@ -1,4 +1,5 @@ #include "SerialBLEInterface.h" +#include "BleTaskStartup.h" #include "../BluetoothMac.h" #include "../CompanionFrameQueue.h" #include @@ -401,6 +402,12 @@ bool SerialBLEInterface::begin(const char* prefix, const char* name, bool clear_bonds, bool stealth_pair_once, const mesh::companion::BluetoothPeerIdentity* bonded_only_peer) { + // Bluefruit cannot safely reinitialize a partly started SoftDevice: doing + // so leaks worker tasks, FIFOs and GATT registrations on every retry. Keep + // USB/UI usable after a failed start; recovery requires a corrected build + // or configuration and a reboot. + if (_begin_attempted) return _begin_ready; + _begin_attempted = true; instance = this; _successfulConnectionPending.store(false, std::memory_order_release); _successfulConnectionStarted.store(0, std::memory_order_relaxed); @@ -417,9 +424,11 @@ bool SerialBLEInterface::begin(const char* prefix, const char* name, // If we want to control BLE LED ourselves, uncomment this: // Bluefruit.autoConnLed(false); Bluefruit.configPrphBandwidth(BANDWIDTH_MAX); - if (!Bluefruit.begin()) { + mesh::nrf52::resetBleTaskStartup(); + if (!Bluefruit.begin() || !mesh::nrf52::bleTasksStarted()) { instance = nullptr; - BLE_DEBUG_PRINTLN("Bluefruit.begin failed"); + mesh::usbLoggingPort().println( + "Bluetooth startup failed (SoftDevice/tasks); check runtime heap and reboot"); return false; } @@ -501,7 +510,11 @@ bool SerialBLEInterface::begin(const char* prefix, const char* name, Bluefruit.setEventCallback(onBLEEvent); bleuart.setPermission(SECMODE_ENC_WITH_MITM, SECMODE_ENC_WITH_MITM); - bleuart.begin(); + if (bleuart.begin() != ERROR_NONE) { + instance = nullptr; + BLE_DEBUG_PRINTLN("Bluetooth UART service begin failed"); + return false; + } bleuart.setRxCallback(onBleUartRX); #if COMPANION_FEATURE_BLE_MOTA_SOURCE @@ -511,6 +524,7 @@ bool SerialBLEInterface::begin(const char* prefix, const char* name, _mota_service.setPermission(SECMODE_ENC_WITH_MITM, SECMODE_ENC_WITH_MITM); if (_mota_service.begin() != ERROR_NONE) { + instance = nullptr; BLE_DEBUG_PRINTLN("Bluetooth mOTA service begin failed"); return false; } @@ -521,6 +535,7 @@ bool SerialBLEInterface::begin(const char* prefix, const char* name, _mota_request.setMaxLen(mesh::ota::BLE_MOTA_REQUEST_MAX); _mota_request.setUserDescriptor("mOTA device request"); if (_mota_request.begin() != ERROR_NONE) { + instance = nullptr; BLE_DEBUG_PRINTLN("Bluetooth mOTA request characteristic begin failed"); return false; } @@ -532,6 +547,7 @@ bool SerialBLEInterface::begin(const char* prefix, const char* name, _mota_response.setUserDescriptor("mOTA host response"); _mota_response.setWriteCallback(onMotaResponse); if (_mota_response.begin() != ERROR_NONE) { + instance = nullptr; BLE_DEBUG_PRINTLN("Bluetooth mOTA response characteristic begin failed"); return false; } @@ -541,7 +557,11 @@ bool SerialBLEInterface::begin(const char* prefix, const char* name, // Register DFU on the main BLE stack so paired clients can discover it // without switching the device into a separate OTA-only BLE mode first. bledfu.setPermission(SECMODE_ENC_WITH_MITM, SECMODE_ENC_WITH_MITM); - bledfu.begin(); + if (bledfu.begin() != ERROR_NONE) { + instance = nullptr; + BLE_DEBUG_PRINTLN("Bluetooth DFU service begin failed"); + return false; + } Bluefruit.Advertising.setType( BLE_GAP_ADV_TYPE_CONNECTABLE_SCANNABLE_UNDIRECTED); @@ -560,6 +580,7 @@ bool SerialBLEInterface::begin(const char* prefix, const char* name, configureBondedOnlyAdvertising(*bonded_only_peer, true); } + _begin_ready = true; return true; } diff --git a/src/helpers/nrf52/SerialBLEInterface.h b/src/helpers/nrf52/SerialBLEInterface.h index 78d0af4c..3d256ecc 100644 --- a/src/helpers/nrf52/SerialBLEInterface.h +++ b/src/helpers/nrf52/SerialBLEInterface.h @@ -28,6 +28,8 @@ class SerialBLEInterface : public BaseSerialInterface { mesh::ota::BleMotaStream _mota_stream; #endif bool _isEnabled; + bool _begin_attempted = false; + bool _begin_ready = false; bool _isDeviceConnected; uint16_t _conn_handle; unsigned long _last_health_check; diff --git a/src/helpers/ota/OtaCli.cpp b/src/helpers/ota/OtaCli.cpp index f1f54761..9a5c8ef1 100644 --- a/src/helpers/ota/OtaCli.cpp +++ b/src/helpers/ota/OtaCli.cpp @@ -155,6 +155,7 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board const char* a = command + 3; if (*a != 0 && *a != ' ') return false; while (*a == ' ') a++; + if (!ota_acquire_context(reply, 160)) return true; OtaContext& c = ota_ctx(); const char* rest = a; diff --git a/src/helpers/ota/OtaContext.cpp b/src/helpers/ota/OtaContext.cpp index 10f2bba4..927af8fa 100644 --- a/src/helpers/ota/OtaContext.cpp +++ b/src/helpers/ota/OtaContext.cpp @@ -1,12 +1,116 @@ #include "OtaContext.h" +#include namespace mesh { namespace ota { +#if defined(OTA_SHARED_COMPANION_QUEUE) +namespace { +OtaContext* active_context = nullptr; +void* storage_owner = nullptr; +OtaContext* (*acquire_storage)(void*) = nullptr; +void (*release_storage)(void*) = nullptr; +OtaSend saved_send = nullptr; +void* saved_send_ctx = nullptr; +uint32_t saved_target = 0; +char saved_hw[33] = {0}; +uint8_t saved_seeder_id[4] = {0}; +// Keep user policy across workspace reuse; discovered catalogs and transfers +// belong to the temporary mOTA session. No allocation is needed while idle. +SignerAllowlist saved_allow; +uint8_t saved_autofetch = OtaManager::AUTOFETCH_OFF; +uint8_t saved_autoinstall = OtaContext::AUTOINSTALL_OFF; +uint8_t saved_hops = OTA_HOP_LIMIT_DEFAULT; +uint16_t saved_checkpoint = OTA_CHECKPOINT_BLOCKS; +uint16_t saved_advert = OTA_ADVERT_INTERVAL_MINS; +} + +void ota_set_context_storage(void* owner, OtaContext* (*acquire)(void*), + void (*release)(void*)) { + assert(!active_context); + storage_owner = owner; + acquire_storage = acquire; + release_storage = release; +} + +OtaContext& ota_ctx() { + assert(active_context); // Only explicit OTA entry points acquire storage. + return *active_context; +} + +OtaContext* ota_context_if_active() { return active_context; } + +void ota_begin_context(uint32_t target, OtaSend send, void* ctx, + const char* hw, const uint8_t* seeder_id) { + saved_target = target; + saved_send = send; + saved_send_ctx = ctx; + strncpy(saved_hw, hw ? hw : "", sizeof(saved_hw) - 1); + saved_hw[sizeof(saved_hw) - 1] = 0; + if (seeder_id) memcpy(saved_seeder_id, seeder_id, sizeof(saved_seeder_id)); +} + +bool ota_acquire_context(char* reply, size_t cap) { + if (active_context) return true; + if (!acquire_storage || !release_storage || !saved_send) { + if (reply && cap) snprintf(reply, cap, "ERR mOTA storage is not ready"); + return false; + } + active_context = acquire_storage(storage_owner); + if (!active_context) { + if (reply && cap) snprintf(reply, cap, + "ERR mOTA needs 128 free queue slots; sync unread messages with an app first"); + return false; + } + OtaContext& c = *active_context; + c.begin(saved_target, saved_send, saved_send_ctx, saved_hw); + c.manager.set_seeder_id(saved_seeder_id); + c.manager.set_autofetch(saved_autofetch); + c.manager.set_checkpoint_blocks(saved_checkpoint); + c.manager.set_advert_mins(saved_advert); + c.manager.set_max_hops(saved_hops); + c.autoinstall = saved_autoinstall; + c.allow = saved_allow; + return true; +} + +uint8_t ota_hop_limit() { + return active_context ? active_context->manager.max_hops() : saved_hops; +} + +void ota_release_context_if_idle(bool temporary_radio_active) { + if (!active_context) return; + OtaContext& c = *active_context; + if (c.folder_active || c.folder_dest || c.serving || c.apply_pending) return; + if (temporary_radio_active && !c.release_when_idle) return; + // No host source/destination remains. Discard pending transfer work before + // the queue reuses these bytes, including dynamic discovery/diff buffers. + saved_autofetch = c.manager.autofetch(); + saved_checkpoint = c.manager.checkpoint_blocks(); + saved_advert = c.manager.advert_mins(); + saved_hops = c.manager.max_hops(); + saved_autoinstall = c.autoinstall; + saved_allow = c.allow; + c.manager.clearPendingEgress(); + c.manager.reset_session(); + active_context = nullptr; + release_storage(storage_owner); +} +#else OtaContext& ota_ctx() { static OtaContext ctx; return ctx; } +OtaContext* ota_context_if_active() { return &ota_ctx(); } +bool ota_acquire_context(char*, size_t) { return true; } +void ota_begin_context(uint32_t target, OtaSend send, void* ctx, + const char* hw, const uint8_t* seeder_id) { + ota_ctx().begin(target, send, ctx, hw); + ota_ctx().manager.set_seeder_id(seeder_id); +} +uint8_t ota_hop_limit() { return ota_ctx().manager.max_hops(); } +#endif + } // namespace ota } // namespace mesh diff --git a/src/helpers/ota/OtaContext.h b/src/helpers/ota/OtaContext.h index a49984da..05ecd205 100644 --- a/src/helpers/ota/OtaContext.h +++ b/src/helpers/ota/OtaContext.h @@ -82,6 +82,10 @@ class FolderMotaStore; // pull destination over the seeder link (full type onl #endif struct OtaContext { +#if defined(OTA_SHARED_COMPANION_QUEUE) + // Release at a main-loop boundary, after callers finish using this context. + bool release_when_idle = false; +#endif OtaManager manager; #if defined(OTA_SEEDER_ONLY) // A seeder-only node never stages an image for itself. Keep a valid default @@ -405,6 +409,9 @@ struct OtaContext { return false; } folder_active = true; +#if defined(OTA_SHARED_COMPANION_QUEUE) + release_when_idle = false; +#endif _folder_link = link; _folder_source = source; uint16_t offered = 0, advertised = 0; @@ -432,6 +439,9 @@ struct OtaContext { folder_active = false; _folder_link = FOLDER_LINK_NONE; _folder_source = nullptr; +#if defined(OTA_SHARED_COMPANION_QUEUE) + release_when_idle = true; +#endif } #if defined(NRF52_PLATFORM) && defined(OTA_SD_STORE) @@ -618,5 +628,22 @@ private: OtaContext& ota_ctx(); // process-wide singleton +// On constrained source-only Companions, the context exists only while its +// queue-backed workspace is owned by mOTA. Other builds keep the singleton. +OtaContext* ota_context_if_active(); +bool ota_acquire_context(char* reply, size_t cap); +void ota_begin_context(uint32_t target, OtaSend send, void* ctx, + const char* hw, const uint8_t* seeder_id); +uint8_t ota_hop_limit(); + +#if defined(OTA_SHARED_COMPANION_QUEUE) +#if !defined(NRF52_PLATFORM) || !defined(OTA_SEEDER_ONLY) || !defined(COMPANION_RADIO_FULL) +#error "Shared mOTA queue storage requires an nRF52 Full source-only Companion" +#endif +void ota_set_context_storage(void* owner, OtaContext* (*acquire)(void*), + void (*release)(void*)); +void ota_release_context_if_idle(bool temporary_radio_active); +#endif + } // namespace ota } // namespace mesh diff --git a/test/README.md b/test/README.md index e9bb3251..da4ef67f 100644 --- a/test/README.md +++ b/test/README.md @@ -35,6 +35,9 @@ python3 test/test_esp32_dram.py # Classic ESP32 static limits an python3 test/test_esp32_usb_serial_hygiene.py # Single-TTY diagnostics/NVS contract python3 test/test_esp32_usb_sleep.py # USB/logging sleep guards, G3 button wake and two-minute hold/rollover python3 test/test_logging_sleep_contract.py # USB enable examples and real Repeater/Room MQTT sleep gates +python3 test/test_t096_full_memory.py # Full profile, shared queue wraparound, heap reserve and sensor OOM +python3 test/test_nrf52_ble_startup.py # Real task wrapper/BLE startup with worker and GATT failures +python3 test/test_shared_mota_queue.py # Real mOTA transfers, BLE stop/disconnect, queue loans and policy retention python3 test/test_esp32_tinyusb_role_hygiene.py # G2/room USB write coverage and bounded-list contracts python3 test/test_esp32_tinyusb_cooperative_output.py # Real role dump/list pumps with host C++ stubs python3 test/test_esp32_tinyusb_nonblocking.py # Native CDC stalled-host/64-byte-FIFO simulation (C++17 compiler) diff --git a/test/fixtures/shared_mota_queue/test_shared_mota_queue.cpp b/test/fixtures/shared_mota_queue/test_shared_mota_queue.cpp new file mode 100644 index 00000000..d3f3416c --- /dev/null +++ b/test/fixtures/shared_mota_queue/test_shared_mota_queue.cpp @@ -0,0 +1,238 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include "../../test_ota/mota_vectors.h" + +using namespace mesh::ota; + +// Only the flash identity probe is replaced: the context, queue, session +// engine, source parsing, Merkle proofs and transfer bytes are production code. +namespace mesh { namespace ota { +bool ota_self_firmware(SelfFwInfo& info) { info = SelfFwInfo(); return false; } +} } + +struct Frame { uint8_t len; uint8_t buf[176]; }; +struct Queue { + mesh::BorrowableFrameBuffer buffer; + int count = 129, head = 191; + unsigned released = 0; + static OtaContext* acquire(void* owner) { + Queue& q = *static_cast(owner); + return q.buffer.acquire(q.count, q.head); + } + static void release(void* owner) { + Queue& q = *static_cast(owner); + q.buffer.release(q.head); + ++q.released; + } +}; + +class Folder : public MotaSource { +public: + uint8_t count() override { return 1; } + bool describe(uint8_t idx, MotaDesc& d) override { + if (idx) return false; + MotaManifest m; + if (!mota_parse(MOTA_VEC, MOTA_VEC_LEN, m)) return false; + memcpy(d.mid, m.merkle_root, 4); + d.target_id = m.target_id; + d.fw_version = m.fw_version; + d.codec_id = m.codec_id; + d.flags = m.flags; + d.block_size_log2 = m.block_size_log2; + d.total_size = MOTA_VEC_LEN; + d.leaves_off = m.leaves - MOTA_VEC; + d.block_count = m.block_count; + d.payload_off = m.payload - MOTA_VEC; + d.payload_size = m.payload_size; + return true; + } + bool read(uint8_t idx, uint32_t offset, uint8_t* out, uint32_t length) override { + if (idx || uint64_t(offset) + length > MOTA_VEC_LEN) return false; + memcpy(out, MOTA_VEC + offset, length); + return true; + } +}; + +// The actual BLE source controller below sees a ready/not-ready link and a +// source with real container bytes. Hardware GATT/serial framing is separate. +struct Bluetooth { + bool ready = false, active = false; + int motaStream() { return 0; } + bool isMotaChannelReady() const { return ready; } + bool isMotaStreamActive() const { return active; } + void setMotaStreamActive(bool value) { active = value; } +} bluetooth_interface; +namespace mesh { +struct Log { void println(const char*) {} }; +Log& usbLoggingPort() { static Log log; return log; } +namespace ota { +class SerialMotaSource : public ::Folder { +public: + SerialMotaSource(int, MotaStreamWritePolicy, uint32_t) {} +}; +} } +#include "ble_control_under_test.h" + +struct Packet { bool to_source; std::vector bytes; }; +static std::deque packets; +static bool send(void* to_source, const uint8_t* bytes, uint16_t length, bool) { + packets.push_back({to_source != nullptr, {bytes, bytes + length}}); + return true; +} + +int main() { + Queue q; + for (int i = 0; i < q.count; ++i) { + Frame& frame = q.buffer.at((q.head + i) % q.buffer.capacity()); + frame.len = 176; + memset(frame.buf, i, sizeof frame.buf); + } + auto check_messages = [&]() { + for (int i = 0; i < q.count; ++i) { + const Frame& frame = q.buffer.at((q.head + i) % q.buffer.capacity()); + assert(frame.len == 176); + for (auto byte : frame.buf) assert(byte == i); + } + }; + char reply[160] = {}; + assert(!ota_acquire_context(reply, sizeof reply)); + assert(strstr(reply, "not ready")); + ota_set_context_storage(&q, Queue::acquire, Queue::release); + uint8_t identity[4] = {1, 2, 3, 4}; + ota_begin_context(0, send, nullptr, "Heltec_t096", identity); + assert(!ota_context_if_active() && q.buffer.capacity() == 256); + assert(ota_hop_limit() == OTA_HOP_LIMIT_DEFAULT); + assert(!ota_acquire_context(reply, sizeof reply)); + assert(strstr(reply, "sync unread messages")); + assert(q.head == 191 && q.count == 129 && q.buffer.capacity() == 256); + check_messages(); + --q.count; + + for (unsigned cycle = 0; cycle < 8; ++cycle) { + assert(ota_acquire_context(reply, sizeof reply)); + OtaContext& context = ota_ctx(); + assert(q.head == 0 && q.buffer.capacity() == 128); + assert(ota_acquire_context(reply, sizeof reply) && &ota_ctx() == &context); + assert(strcmp(context.hw_id, "Heltec_t096") == 0); + check_messages(); + if (cycle) { + assert(context.manager.max_hops() == 5); + assert(context.manager.checkpoint_blocks() == 6); + assert(context.manager.advert_mins() == 42); + assert(context.allow.count() == 1); + } + context.manager.set_max_hops(5); + context.manager.set_checkpoint_blocks(6); + context.manager.set_advert_mins(42); + uint8_t key[32] = {7}; + context.allow.add(key); + + Folder folder; + const auto link = cycle % 2 ? OtaContext::FOLDER_LINK_BLE + : OtaContext::FOLDER_LINK_SERIAL; + assert(context.attach_folder_source(&folder, link, "test", reply, sizeof reply)); + assert(context.manager.servedCount() == 1); + ota_release_context_if_idle(false); // attached sources hold the workspace + assert(ota_context_if_active()); + assert(!context.attach_folder_source(&folder, + cycle % 2 ? OtaContext::FOLDER_LINK_SERIAL : OtaContext::FOLDER_LINK_BLE, + "wrong owner", reply, sizeof reply)); + assert(context.folderLink() == link); + + OtaManager receiver; + OtaStoreRam<8192> destination; + receiver.begin(EXP_TARGET_ID, send, &q); + receiver.set_fetch_store(&destination); + assert(receiver.pull(EXP_MERKLE_ROOT, EXP_TARGET_ID) == OtaManager::PULL_STARTED); + for (uint32_t time = 100; time < 300000 && receiver.fetchState() != OtaManager::COMPLETE; + time += 100) { + context.manager.set_clock(time); + receiver.set_clock(time); + context.manager.serviceEgress(); + receiver.serviceEgress(); + if (time % 1000 == 0) { context.manager.loop(); receiver.loop(); } + while (!packets.empty()) { + Packet packet = packets.front(); + packets.pop_front(); + (packet.to_source ? context.manager : receiver).on_message( + packet.bytes.data(), packet.bytes.size()); + } + } + assert(receiver.fetchState() == OtaManager::COMPLETE); + assert(destination.staged_size() == MOTA_VEC_LEN); + assert(memcmp(destination.data(), MOTA_VEC, MOTA_VEC_LEN) == 0); + check_messages(); + context.detach_folder(); // same cleanup for USB/BLE stop and disconnect + context.manager.announce(); // callers may still use the context until loop boundary + packets.clear(); + ota_release_context_if_idle(true); // return slots even if TempRadio is still up + assert(!ota_context_if_active() && q.buffer.capacity() == 256); + assert(ota_hop_limit() == 5 && q.released == cycle + 1); + check_messages(); + // Reuse every returned slot, so stale transfer state cannot survive. + for (unsigned i = 128; i < 256; ++i) + memset(&q.buffer.at(i), 0xCC, sizeof(Frame)); + ota_release_context_if_idle(false); + assert(q.released == cycle + 1); + } + + assert(ota_acquire_context(reply, sizeof reply)); + ota_release_context_if_idle(true); // discovery holds storage during TempRadio + assert(ota_context_if_active()); + ota_release_context_if_idle(false); // expiry / normalradio releases it + assert(!ota_context_if_active() && q.buffer.capacity() == 256); + + assert(ota_acquire_context(reply, sizeof reply)); + assert(!ota_ctx().attach_folder_source(nullptr, OtaContext::FOLDER_LINK_SERIAL, + "failed start", reply, sizeof reply)); + ota_release_context_if_idle(false); + assert(!ota_context_if_active() && q.buffer.capacity() == 256); + check_messages(); + + Nrf52BleMotaSourceControl ble; + assert(!ble.start(reply, sizeof reply)); // no subscription, no storage loan + assert(!ota_context_if_active() && q.buffer.capacity() == 256); + for (int disconnect = 0; disconnect < 2; ++disconnect) { + bluetooth_interface.ready = true; + assert(ble.start(reply, sizeof reply)); + assert(ble.status().attached && q.buffer.capacity() == 128); + assert(ble.status().offered == 1 && ble.status().advertised == 1); + if (disconnect) { + bluetooth_interface.ready = false; + ble.loop(); + } else { + assert(ble.stop(reply, sizeof reply)); + } + assert(!ble.status().attached && !bluetooth_interface.active); + ota_release_context_if_idle(true); + assert(!ota_context_if_active() && q.buffer.capacity() == 256); + assert(!ble.status().attached); + assert(ble.stop(reply, sizeof reply)); // idle stop/status/poll never acquire + ble.loop(); + assert(!ota_context_if_active()); + check_messages(); + packets.clear(); + } + + // BLE start/stop cannot detach a USB-owned source or return its workspace. + assert(ota_acquire_context(reply, sizeof reply)); + Folder usb; + assert(ota_ctx().attach_folder_source(&usb, OtaContext::FOLDER_LINK_SERIAL, + "USB", reply, sizeof reply)); + bluetooth_interface.ready = true; + assert(!ble.start(reply, sizeof reply)); + assert(ble.status().another_link_active); + assert(ble.stop(reply, sizeof reply)); + ota_release_context_if_idle(false); + assert(ota_ctx().folder_active && q.buffer.capacity() == 128); + ota_ctx().detach_folder(); + ota_release_context_if_idle(true); + assert(!ota_context_if_active() && q.buffer.capacity() == 256); + check_messages(); +} diff --git a/test/test_nrf52_ble_startup.py b/test/test_nrf52_ble_startup.py new file mode 100644 index 00000000..f8180425 --- /dev/null +++ b/test/test_nrf52_ble_startup.py @@ -0,0 +1,207 @@ +#!/usr/bin/env python3 +"""Run the actual task wrapper and BLE startup with injected allocation failures.""" + +from pathlib import Path +import subprocess +import tempfile +import unittest + +from test_t096_full_memory import method + +ROOT = Path(__file__).resolve().parents[1] + +RTOS = r''' +#pragma once +#include +using BaseType_t = int; +using UBaseType_t = unsigned; +using TaskHandle_t = void*; +using TaskFunction_t = void (*)(void*); +using configSTACK_DEPTH_TYPE = uint16_t; +constexpr BaseType_t pdPASS = 1; +''' + +HARNESS = r''' +#include "Arduino.h" +#include +#include +#include +#include +#include +#include +#include +#ifndef COMPANION_FEATURE_BLE_MOTA_SOURCE +#define COMPANION_FEATURE_BLE_MOTA_SOURCE 1 +#endif +#define BLE_DEBUG_PRINTLN(...) do {} while (0) +constexpr int BANDWIDTH_MAX = 3, NRF_SUCCESS = 0, ERROR_NONE = 0; +constexpr int BLE_GAP_ADDR_TYPE_RANDOM_STATIC = 1, BLE_TX_POWER = 4; +constexpr int BLE_MIN_CONN_INTERVAL = 12, BLE_MAX_CONN_INTERVAL = 24; +constexpr int BLE_SLAVE_LATENCY = 4, BLE_CONN_SUP_TIMEOUT = 200; +constexpr int SECMODE_ENC_WITH_MITM = 1, SECMODE_NO_ACCESS = 0; +constexpr int CHR_PROPS_NOTIFY = 1, CHR_PROPS_WRITE = 2; +constexpr int BLE_GAP_ADV_TYPE_CONNECTABLE_SCANNABLE_UNDIRECTED = 0; +constexpr int BLE_GAP_ADV_FLAGS_LE_ONLY_GENERAL_DISC_MODE = 2; +constexpr int BLE_ADV_INTERVAL_MIN = 32, BLE_ADV_INTERVAL_MAX = 244, BLE_ADV_FAST_TIMEOUT = 30; +struct ble_gap_addr_t { int addr_type; uint8_t addr[6]; }; +struct ble_gap_conn_params_t { unsigned min_conn_interval, max_conn_interval, slave_latency, conn_sup_timeout; }; +int sd_ble_gap_addr_set(ble_gap_addr_t*) { return NRF_SUCCESS; } +int sd_ble_gap_addr_get(ble_gap_addr_t* a) { memset(a, 0, sizeof(*a)); return NRF_SUCCESS; } +int sd_ble_gap_ppcp_set(ble_gap_conn_params_t*) { return NRF_SUCCESS; } +namespace mesh { +struct Logging { void println(const char*) {} }; +Logging& usbLoggingPort() { static Logging port; return port; } +} + +static const char* fail_task = nullptr; +static int fail_service = 0; +static bool fail_softdevice = false; +static unsigned last_depth; +extern "C" BaseType_t __wrap_xTaskCreate(TaskFunction_t, const char*, + configSTACK_DEPTH_TYPE, void*, UBaseType_t, TaskHandle_t*); +extern "C" BaseType_t __real_xTaskCreate(TaskFunction_t, const char* name, + configSTACK_DEPTH_TYPE depth, void*, UBaseType_t, TaskHandle_t* out) { + last_depth = depth; + bool fail = fail_task && name && strcmp(name, fail_task) == 0; + if (out) *out = fail ? nullptr : reinterpret_cast(1); + return fail ? 0 : pdPASS; +} +struct Settings { + template void clearBonds(T...) {} + template void setMITM(T...) {} + template void setPIN(T...) {} + template void setIOCaps(T...) {} + template void setPairPasskeyCallback(T...) {} + template void setPairCompleteCallback(T...) {} + template void setConnectCallback(T...) {} + template void setDisconnectCallback(T...) {} + template void setSecuredCallback(T...) {} + template void setType(T...) {} + template void addFlags(T...) {} + template void addTxPower(T...) {} + template void addService(T...) {} + template void addName(T...) {} + template void setInterval(T...) {} + template void setFastTimeout(T...) {} + template void restartOnDisconnect(T...) {} +}; +struct BluefruitStub { + Settings Security, Periph, Advertising, ScanResponse; + int starts = 0; + void configPrphBandwidth(int) {} + bool begin() { + ++starts; + if (fail_softdevice) return false; + TaskHandle_t task; + // Match the pinned core: it ignores both worker creation return values. + __wrap_xTaskCreate(nullptr, "BLE", 1280, nullptr, 3, &task); + __wrap_xTaskCreate(nullptr, "SOC", 200, nullptr, 3, &task); + return true; + } + void setTxPower(int) {} + void setName(const char*) {} + template void setEventCallback(T) {} +} Bluefruit; +struct Service { + int id; + int begin() { return fail_service == id ? 1 : ERROR_NONE; } + template void setPermission(T...) {} + template void setRxCallback(T...) {} + template void setProperties(T...) {} + template void setMaxLen(T...) {} + template void setUserDescriptor(T...) {} + template void setWriteCallback(T...) {} +}; +struct MotaStream { + template void setSender(T...) {} + void setActive(bool) {} +}; +struct SerialBLEInterface { + @STARTUP_FIELDS@ + std::atomic _successfulConnectionPending{false}; + std::atomic _successfulConnectionStarted{0}; + std::atomic _bondedOnlyRecoveryPending{false}, _advertisingSuppressed{false}; + bool _stealth_pair_once = false, _bonded_only = false, _bonded_only_configure_pending = false; + mesh::companion::BluetoothPeerIdentity _pending_bonded_peer; + Service bleuart{1}, _mota_service{2}, _mota_request{3}, _mota_response{4}, bledfu{5}; + MotaStream _mota_stream; + static void onPairingPasskey() {} + static void onPairingComplete() {} + static void onConnect() {} + static void onDisconnect() {} + static void onSecured() {} + static void onBLEEvent() {} + static void onBleUartRX() {} + static void sendMotaRequest() {} + static void onMotaResponse() {} + void configureBondedOnlyAdvertising(const mesh::companion::BluetoothPeerIdentity&, bool) {} + bool begin(const char*, const char*, uint32_t, const uint8_t* = nullptr, + bool = false, bool = false, + const mesh::companion::BluetoothPeerIdentity* = nullptr); +}; +static SerialBLEInterface* instance = nullptr; +@BEGIN@ +int main() { + mesh::nrf52::resetBleTaskStartup(); + assert(!mesh::nrf52::bleTasksStarted()); + __wrap_xTaskCreate(nullptr, "loop", 1024, nullptr, 1, nullptr); + assert(last_depth == 2048 && !mesh::nrf52::bleTasksStarted()); + __wrap_xTaskCreate(nullptr, "callback", 768, nullptr, 1, nullptr); + assert(last_depth == 768); + __wrap_xTaskCreate(nullptr, nullptr, 100, nullptr, 1, nullptr); + assert(last_depth == 100); + for (int fault = 0; fault <= 8; ++fault) { +#if !COMPANION_FEATURE_BLE_MOTA_SOURCE + if (fault >= 5 && fault <= 7) continue; +#endif + fail_softdevice = fault == 1; + fail_task = fault == 2 ? "BLE" : fault == 3 ? "SOC" : nullptr; + fail_service = fault >= 4 ? fault - 3 : 0; + SerialBLEInterface port; + const int starts = Bluefruit.starts; + const bool expected = fault == 0; + assert(port.begin("MC-", "T096", 123456) == expected); + assert(Bluefruit.starts == starts + 1); + assert((instance == &port) == expected); + // Retrying a failed partial init must never allocate another stack/FIFO. + for (int retry = 0; retry < 4; ++retry) { + assert(port.begin("MC-", "T096", 123456) == expected); + assert(Bluefruit.starts == starts + 1); + } + } +} +''' + + +class Nrf52BleStartupTest(unittest.TestCase): + def test_all_startup_failures_are_reported_without_reinitializing(self): + begin = method((ROOT / "src/helpers/nrf52/SerialBLEInterface.cpp").read_text(), + "bool SerialBLEInterface::begin(") + header = (ROOT / "src/helpers/nrf52/SerialBLEInterface.h").read_text() + # Use the real in-class startup initializers, too. + fields = "\n".join(line for line in header.splitlines() + if "bool _begin_" in line) + self.assertTrue(fields) + source = HARNESS.replace("@BEGIN@", begin).replace("@STARTUP_FIELDS@", fields) + with tempfile.TemporaryDirectory(prefix="meshcore-ble-start-") as temp: + temp = Path(temp) + (temp / "Arduino.h").write_text(RTOS) + for name in ("FreeRTOS.h", "task.h"): + (temp / name).write_text('#include "Arduino.h"\n') + cpp = temp / "startup.cpp" + cpp.write_text(source) + binary = temp / "startup" + for mota in (0, 1): + with self.subTest(mota=mota): + built = subprocess.run([ + "c++", "-std=c++17", "-DNRF52_PLATFORM=1", + f"-DCOMPANION_FEATURE_BLE_MOTA_SOURCE={mota}", "-I" + str(temp), + "-I" + str(ROOT / "src"), str(cpp), str(ROOT / "src/Nrf52LoopStack.cpp"), + "-o", str(binary)], text=True, capture_output=True) + self.assertEqual(built.returncode, 0, built.stderr) + result = subprocess.run([str(binary)], text=True, capture_output=True) + self.assertEqual(result.returncode, 0, result.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_shared_mota_queue.py b/test/test_shared_mota_queue.py new file mode 100644 index 00000000..080b5470 --- /dev/null +++ b/test/test_shared_mota_queue.py @@ -0,0 +1,47 @@ +#!/usr/bin/env python3 +"""Run the actual shared OtaContext and byte-exact mOTA transfers under sanitizers.""" + +from pathlib import Path +import subprocess +import tempfile +import unittest +from test_t096_full_memory import method + +ROOT = Path(__file__).resolve().parents[1] + + +class SharedMotaQueueTest(unittest.TestCase): + def test_context_lifecycle_and_real_transfers_preserve_unread_messages(self): + with tempfile.TemporaryDirectory(prefix="meshcore-mota-queue-") as temp: + binary = Path(temp) / "transfer" + tinf = Path(temp) / "tinf.o" + control = method((ROOT / "examples/companion_radio/main.cpp").read_text(), + "class Nrf52BleMotaSourceControl") + (Path(temp) / "ble_control_under_test.h").write_text(control + ";\n") + subprocess.run([ + "cc", "-DENABLE_OTA=1", "-fsanitize=address,undefined", "-g", + "-c", str(ROOT / "src/helpers/ota/OtaTinf.c"), "-o", str(tinf), + ], check=True) + sources = [ + "test/fixtures/shared_mota_queue/test_shared_mota_queue.cpp", + "src/helpers/ota/OtaContext.cpp", "src/helpers/ota/OtaManager.cpp", + "src/helpers/ota/OtaProtocol.cpp", "src/helpers/ota/MotaContainer.cpp", + "src/helpers/ota/MerkleTree.cpp", "src/helpers/ota/OtaDeflate.cpp", + "src/Utils.cpp", + ] + built = subprocess.run([ + "c++", "-std=c++17", "-fsanitize=address,undefined", "-g", + "-DNRF52_PLATFORM=1", "-DOTA_SEEDER_ONLY=1", + "-DCOMPANION_RADIO_FULL=1", "-DOTA_SHARED_COMPANION_QUEUE=1", + "-DENABLE_OTA=1", "-I", str(ROOT / "src"), + "-I", str(ROOT / "test/mocks"), "-I", temp, + *[str(ROOT / source) for source in sources], str(tinf), + "-o", str(binary), + ], text=True, capture_output=True) + self.assertEqual(built.returncode, 0, built.stderr) + result = subprocess.run([str(binary)], text=True, capture_output=True) + self.assertEqual(result.returncode, 0, result.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_t096_full_memory.py b/test/test_t096_full_memory.py new file mode 100644 index 00000000..99a0d7d4 --- /dev/null +++ b/test/test_t096_full_memory.py @@ -0,0 +1,227 @@ +#!/usr/bin/env python3 +"""Exercise the Full overlay, real linker reserve, and sensor allocation failure.""" + +from pathlib import Path +import re +import subprocess +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[1] + + +def method(text, signature): + start = text.index(signature) + end = text.index("{", start) + 1 + depth = 1 + while depth: + depth += (text[end] == "{") - (text[end] == "}") + end += 1 + return text[start:end] + + +def full_flags(target): + result = subprocess.run([ + "bash", "-c", r''' +source build.sh +PIO_ENV_PLATFORM_BY_NAME["$1"]=NRF52_PLATFORM +pio_env_option_contains() { return 0; } +apply_companion_radio_full_profile "$1" "$1" +printf '%s\n%s\n' "$PLATFORMIO_BUILD_FLAGS" "$PLATFORMIO_BUILD_UNFLAGS" +''', "test", target], cwd=ROOT, text=True, capture_output=True, check=True) + return result.stdout + + +class T096FullMemoryTest(unittest.TestCase): + def test_t096_full_overlay_preserves_features_and_reserves_heap(self): + for suffix in ("femon", "femoff"): + with self.subTest(suffix=suffix): + flags = full_flags("Heltec_t096_companion_radio_full_" + suffix) + self.assertIn("-DOFFLINE_QUEUE_SIZE=256", flags.splitlines()[0]) + self.assertNotIn("-DOFFLINE_QUEUE_SIZE=128", flags.splitlines()[0]) + self.assertIn("-DOTA_SHARED_COMPANION_QUEUE=1", flags) + self.assertIn("--defsym=__mesh_nrf52_min_heap_size=73728", flags) + for feature in ("ENABLE_USB_INTERFACE", "OTA_SEEDER_ONLY", + "COMPANION_FEATURE_BLE_MOTA_SOURCE", + "COMPANION_FEATURE_USB_MOTA_SOURCE", + "COMPANION_FEATURE_DEDICATED_USB_LOGGING"): + self.assertIn("-D" + feature + "=1", flags) + self.assertNotIn("-DMAX_CONTACTS", flags) + self.assertNotIn("-DMAX_GROUP_CHANNELS", flags) + + def test_other_nrf52_profiles_keep_their_queue_policy(self): + for target in ("RAK_4631_companion_radio_full", + "Heltec_t096_companion_radio_ble_femon"): + with self.subTest(target=target): + flags = full_flags(target) + self.assertNotIn("-DOTA_SHARED_COMPANION_QUEUE", flags) + self.assertNotIn("__mesh_nrf52_min_heap_size", flags) + + def test_real_linker_rejects_release_heap_and_enforces_boundary(self): + flags = full_flags("Heltec_t096_companion_radio_full_femon") + reserve = int(re.search(r"__mesh_nrf52_min_heap_size=(\d+)", flags)[1]) + self.assertIn('INCLUDE "boards/nrf52_heap_reserve.ld"', + (ROOT / "boards/nrf52840_s140_v6_extrafs.ld").read_text()) + with tempfile.TemporaryDirectory(prefix="meshcore-heap-") as temp: + obj = Path(temp) / "empty.o" + subprocess.run(["cc", "-x", "c", "-c", "-", "-o", str(obj)], + input="int linked_fixture;", text=True, check=True) + # Released 26303793 leaves 54,724 bytes between the real heap + # symbols. Static RAM fitting alone previously admitted it. + for size, minimum, accepted in ( + (54724, reserve, False), (reserve - 1, reserve, False), + (reserve, reserve, True), (reserve + 16384, reserve, True), + (-1, reserve, False), (1024, None, True), + ): + with self.subTest(size=size, minimum=minimum): + command = ["ld", "-r", str(obj), "-o", str(Path(temp) / "linked.o"), + "-T", str(ROOT / "boards/nrf52_heap_reserve.ld"), + "--defsym=__HeapBase=0x20006008", + f"--defsym=__HeapLimit={0x20006008 + size}"] + if minimum is not None: + command += [f"--defsym=__mesh_nrf52_min_heap_size={minimum}"] + result = subprocess.run(command, text=True, capture_output=True) + self.assertEqual(result.returncode == 0, accepted, result.stderr) + if not accepted: + self.assertIn("nRF52", result.stderr) + + def test_sensor_page_survives_failed_cayenne_allocation(self): + refresh = method((ROOT / "examples/companion_radio/ui-new/UITask.cpp").read_text(), + "void refresh_sensors()") + harness = r''' +#include +#include +#include +#define AUTO_OFF_MILLIS 15000 +#define UI_RECENT_LIST_SIZE 3 +#define TELEM_CHANNEL_SELF 0 +unsigned long millis() { return 100; } +struct Lpp { + uint8_t storage[8]; + bool allocated = false; + uint8_t size = 0; + uint8_t* getBuffer() { return allocated ? storage : nullptr; } + void reset() { size = 0; } + uint8_t getSize() { return size; } + void addVoltage(int, float) { + if (!getBuffer()) throw std::runtime_error("sensor menu NULL write"); + size = 4; + } +}; +struct LPPReader { + bool pending; + LPPReader(uint8_t* buf, int size) : pending(buf && size) {} + bool readHeader(uint8_t&, uint8_t&) { bool p = pending; pending = false; return p; } + void skipData(uint8_t) {} +}; +struct Board { int getBattMilliVolts() { return 4000; } } board; +struct Sensors { int calls = 0; void querySensors(int, Lpp&) { ++calls; } } sensors; +struct Home { + Lpp sensors_lpp; + int sensors_nb = 5, sensors_scroll_offset = 4, next_sensors_refresh = 0; + bool sensors_scroll = true; + @REFRESH@ +}; +int main() { + Home home; + home.refresh_sensors(); + assert(home.sensors_nb == 0 && !home.sensors_scroll); + assert(home.sensors_scroll_offset == 0 && sensors.calls == 0); + home.sensors_lpp.allocated = true; + home.refresh_sensors(); + assert(home.sensors_nb == 1 && sensors.calls == 1); + assert(home.next_sensors_refresh == 5100); + home.refresh_sensors(); + assert(sensors.calls == 1); +} +'''.replace("@REFRESH@", refresh) + with tempfile.TemporaryDirectory(prefix="meshcore-sensor-oom-") as temp: + binary = Path(temp) / "menu" + built = subprocess.run(["c++", "-std=c++17", "-x", "c++", "-", + "-o", str(binary)], input=harness, + text=True, capture_output=True) + self.assertEqual(built.returncode, 0, built.stderr) + result = subprocess.run([str(binary)], text=True, capture_output=True) + self.assertEqual(result.returncode, 0, result.stderr) + + def test_shared_queue_preserves_fifo_across_wrap_and_workspace_reuse(self): + harness = r''' +#include +#include +#include +#include +#include +struct Frame { uint8_t len; uint8_t buf[176]; }; +struct Workspace { + static int alive; + uint32_t words[4906]; // matches the roughly 19 KiB embedded mOTA context + Workspace() { ++alive; memset(words, 0xA5, sizeof words); } + ~Workspace() { --alive; memset(words, 0x5A, sizeof words); } +}; +int Workspace::alive = 0; +using Buffer = mesh::BorrowableFrameBuffer; +int main() { + Buffer buffer; + int head = 0, count = 0; + unsigned sequence = 0; + std::deque expected; + auto add = [&]() { + assert(count < int(buffer.capacity())); + Frame& frame = buffer.at((head + count) % buffer.capacity()); + frame.len = 176; + memset(frame.buf, sequence % 251, sizeof frame.buf); + memcpy(frame.buf, &sequence, sizeof sequence); + expected.push_back(sequence++); + ++count; + }; + auto pop = [&]() { + assert(count > 0); + const Frame& frame = buffer.at(head); + unsigned id; + memcpy(&id, frame.buf, sizeof id); + assert(frame.len == 176 && id == expected.front()); + for (unsigned i = sizeof id; i < sizeof frame.buf; ++i) + assert(frame.buf[i] == id % 251); + expected.pop_front(); + --count; + head = count ? (head + 1) % buffer.capacity() : 0; + }; + for (int cycle = 0; cycle < 300; ++cycle) { + while (count < 256) add(); + // A full queue refuses the loan without dropping or reordering messages. + int previous_head = head; + assert(!buffer.acquire(count, head)); + assert(count == 256 && head == previous_head && Workspace::alive == 0); + while (count > 129) pop(); + assert(!buffer.acquire(count, head)); + pop(); + // Exercise every possible wrapped head at the exact 128-frame boundary. + for (int i = 0; i < cycle % 256; ++i) { pop(); add(); } + Workspace* workspace = buffer.acquire(count, head); + assert(workspace && head == 0 && buffer.capacity() == 128); + assert(Workspace::alive == 1 && buffer.acquire(count, head) == workspace); + for (auto& word : workspace->words) word = 0xC0DEC0DE; + for (int i = 0; i < cycle % 128; ++i) { pop(); add(); } + buffer.release(head); + assert(head == 0 && buffer.capacity() == 256 && Workspace::alive == 0); + buffer.release(head); // repeated stop is harmless + while (count < 256) add(); + while (count) pop(); + } + assert(expected.empty()); + assert(buffer.acquire(count, head)); // empty queue / destructor while borrowed +} +''' + with tempfile.TemporaryDirectory(prefix="meshcore-shared-queue-") as temp: + binary = Path(temp) / "shared_queue" + built = subprocess.run([ + "c++", "-std=c++11", "-fsanitize=address,undefined", "-g", + "-I", str(ROOT / "src"), "-x", "c++", "-", "-o", str(binary), + ], input=harness, text=True, capture_output=True) + self.assertEqual(built.returncode, 0, built.stderr) + result = subprocess.run([str(binary)], text=True, capture_output=True) + self.assertEqual(result.returncode, 0, result.stderr) + + +if __name__ == "__main__": + unittest.main()