Share T096 Full mOTA memory with the offline queue

This commit is contained in:
mikecarper committed 2026-09-08 01:15:19 -07:00
1 parent d28535f503
commit 2ea493994e
26 files changed
+1198 -23

No files matched your search

+6
View File
@@ -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
+1
View File
@@ -47,3 +47,4 @@ SECTIONS
}
INCLUDE "nrf52_common.ld"
INCLUDE "boards/nrf52_heap_reserve.ld"
+9
View File
@@ -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")
+14
View File
@@ -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 "+<helpers/ota/*.cpp>"
fi
+17
View File
@@ -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.
+6
View File
@@ -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
+55
View File
@@ -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
+29 -2
View File
@@ -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<MyMesh*>(owner);
return mesh->offline_queue.acquire(mesh->offline_queue_len, mesh->offline_queue_head);
}
void MyMesh::releaseOfflineQueueFromOta(void* owner) {
MyMesh* mesh = static_cast<MyMesh*>(owner);
mesh->offline_queue.release(mesh->offline_queue_head);
}
#endif
void MyMesh::initializeOfflineQueue() {
#if defined(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
+10 -1
View File
@@ -48,6 +48,10 @@
#include <helpers/TerminalCommandTracker.h>
#include <helpers/TerminalDisplayFilter.h>
#include <target.h>
#if defined(OTA_SHARED_COMPANION_QUEUE)
#include <helpers/BorrowableFrameBuffer.h>
#include <helpers/ota/OtaContext.h>
#endif
#ifdef COMPANION_MESH_CLOCK_SYNC
#include <helpers/ClientACL.h>
@@ -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<Frame, OFFLINE_QUEUE_SIZE, 128, mesh::ota::OtaContext> 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
+25 -4
View File
@@ -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
@@ -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());
+15 -9
View File
@@ -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
+22 -2
View File
@@ -4,6 +4,16 @@
#include <FreeRTOS.h>
#include <task.h>
#include <string.h>
#include <helpers/nrf52/BleTaskStartup.h>
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
+78
View File
@@ -0,0 +1,78 @@
#pragma once
#include <new>
#include <stddef.h>
#include <string.h>
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 Frame, size_t Capacity, size_t Reserved, class Workspace>
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<int>(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
+3 -1
View File
@@ -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();
+13
View File
@@ -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
+25 -4
View File
@@ -1,4 +1,5 @@
#include "SerialBLEInterface.h"
#include "BleTaskStartup.h"
#include "../BluetoothMac.h"
#include "../CompanionFrameQueue.h"
#include <stdio.h>
@@ -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;
}
+2
View File
@@ -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;
+1
View File
@@ -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;
+104
View File
@@ -1,12 +1,116 @@
#include "OtaContext.h"
#include <assert.h>
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
+27
View File
@@ -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
+3
View File
@@ -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)
@@ -0,0 +1,238 @@
#include <helpers/BorrowableFrameBuffer.h>
#include <helpers/ota/OtaContext.h>
#include <helpers/CompanionMotaControl.h>
#include <helpers/ota/MotaStreamWritePolicy.h>
#include <cassert>
#include <cstring>
#include <deque>
#include <vector>
#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<Frame, 256, 128, OtaContext> buffer;
int count = 129, head = 191;
unsigned released = 0;
static OtaContext* acquire(void* owner) {
Queue& q = *static_cast<Queue*>(owner);
return q.buffer.acquire(q.count, q.head);
}
static void release(void* owner) {
Queue& q = *static_cast<Queue*>(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<uint8_t> bytes; };
static std::deque<Packet> 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();
}
+207
View File
@@ -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 <cstdint>
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 <cassert>
#include <atomic>
#include <cstdio>
#include <cstring>
#include <helpers/BluetoothMac.h>
#include <helpers/BleMotaProtocol.h>
#include <helpers/nrf52/BleTaskStartup.h>
#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<void*>(1);
return fail ? 0 : pdPASS;
}
struct Settings {
template<class... T> void clearBonds(T...) {}
template<class... T> void setMITM(T...) {}
template<class... T> void setPIN(T...) {}
template<class... T> void setIOCaps(T...) {}
template<class... T> void setPairPasskeyCallback(T...) {}
template<class... T> void setPairCompleteCallback(T...) {}
template<class... T> void setConnectCallback(T...) {}
template<class... T> void setDisconnectCallback(T...) {}
template<class... T> void setSecuredCallback(T...) {}
template<class... T> void setType(T...) {}
template<class... T> void addFlags(T...) {}
template<class... T> void addTxPower(T...) {}
template<class... T> void addService(T...) {}
template<class... T> void addName(T...) {}
template<class... T> void setInterval(T...) {}
template<class... T> void setFastTimeout(T...) {}
template<class... T> 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<class T> void setEventCallback(T) {}
} Bluefruit;
struct Service {
int id;
int begin() { return fail_service == id ? 1 : ERROR_NONE; }
template<class... T> void setPermission(T...) {}
template<class... T> void setRxCallback(T...) {}
template<class... T> void setProperties(T...) {}
template<class... T> void setMaxLen(T...) {}
template<class... T> void setUserDescriptor(T...) {}
template<class... T> void setWriteCallback(T...) {}
};
struct MotaStream {
template<class... T> void setSender(T...) {}
void setActive(bool) {}
};
struct SerialBLEInterface {
@STARTUP_FIELDS@
std::atomic<bool> _successfulConnectionPending{false};
std::atomic<uint32_t> _successfulConnectionStarted{0};
std::atomic<bool> _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()
+47
View File
@@ -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()
+227
View File
@@ -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 <cassert>
#include <cstdint>
#include <stdexcept>
#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 <helpers/BorrowableFrameBuffer.h>
#include <cassert>
#include <cstdint>
#include <deque>
#include <cstring>
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<Frame, 256, 128, Workspace>;
int main() {
Buffer buffer;
int head = 0, count = 0;
unsigned sequence = 0;
std::deque<unsigned> 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()