mirror of
https://github.com/mikecarper/MeshCore.git
synced 2026-10-23 19:12:23 +00:00
Share T096 Full mOTA memory with the offline queue
This commit is contained in:
1 parent
d28535f503
commit
2ea493994e
26 files changed
+1198
-23
No files matched your search
@@ -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
|
||||
|
||||
@@ -47,3 +47,4 @@ SECTIONS
|
||||
}
|
||||
|
||||
INCLUDE "nrf52_common.ld"
|
||||
INCLUDE "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")
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
@@ -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
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user