diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index e68583335..39951736a 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -101,6 +101,17 @@ jobs: - name: Run Unit Tests run: pio test -e native -e native_kiss_modem -e native_ota_channel -vv + - name: Verify parser, delivery, storage, permissions, clock and radio stability + run: | + python3 -B test/test_protocol_parser_bounds.py -v + python3 -B test/test_companion_offline_delivery.py -v + python3 -B test/test_companion_lfs_traversal.py -v + python3 -B test/test_room_post_permissions.py -v + python3 -B test/test_contact_clock_bootstrap.py -v + python3 -B test/test_rtc_discovery.py -v + python3 -B test/test_radio_interrupt_recovery.py -v + python3 -B test/test_bridge_recovery.py -v + - name: Verify signed private fleet management run: | python3 -B test/test_fleet_command_crypto.py -v @@ -471,8 +482,10 @@ jobs: - name: Verify nRF52 infrastructure trims and unchanged Companion scope run: | python3 -B test/test_nrf52_flash_trim.py -v + python3 -B test/test_nrf52_size_profile.py -v python3 -B test/test_nrf52_tft_font_trim.py -v python3 -B test/test_ota_target_names.py -v + python3 -B test/test_ota_cli_listing.py -v - name: Verify compact same-size fonts and lossless rendering run: | diff --git a/build_legacy.sh b/build_legacy.sh index e981d348d..6701eba77 100755 --- a/build_legacy.sh +++ b/build_legacy.sh @@ -3835,18 +3835,13 @@ apply_nrf52_size_profile() { return 0 fi - # The Adafruit nRF52 platform defaults release builds to -Ofast. Once the - # runtime software Ed25519 fallback is linked alongside CC310, that setting - # fully expands repeated Curve25519 arithmetic and wastes tens of kilobytes. - # Keep hardware crypto, RNG mixing, the software fallback, and board features; - # select the size optimizer for repeaters, constrained self-updating images - # and Full Companion source images, especially their diagnostic profile. - # Full-sensor repeaters also need this after adding the second radio profile; - # changing optimization preserves their sensors and protocol features. - # The legacy RAK4631 Ethernet Companion also needs -Os to fit its declared - # application limit; keep its sensors, transports, OTA and layout unchanged. + # The framework defaults to -Ofast, which expands repeated Curve25519 + # arithmetic when software Ed25519 is linked alongside CC310. Inherit the + # canonical nrf52_base -Oz optimizer rather than appending a weaker -Os + # override for repeaters, Full Companion, Ethernet, and constrained OTA + # recipes. Keep hardware crypto, software fallback, sensors, transports, + # OTA, the 8 KiB loop stack, and each board's application/storage layout. append_platformio_build_unflags "-Ofast" - export PLATFORMIO_BUILD_FLAGS="${PLATFORMIO_BUILD_FLAGS} -Os" # These color-display repeaters reserve a 64 KiB reset-retained OTA arena # plus a 25 KiB framebuffer. With the 8 KiB loop stack, the complete sensor @@ -4180,6 +4175,9 @@ apply_companion_radio_full_profile() { # Apply the proven bootstrap to every nRF52 Full Companion rather than # making a board-specific Bluetooth reliability promise. export PLATFORMIO_BUILD_FLAGS="${PLATFORMIO_BUILD_FLAGS} -DOTA_FOLDER_SERIAL=1 -DCOMPANION_FEATURE_USB_MOTA_SOURCE=1 -DCOMPANION_FEATURE_BLE_MOTA_SOURCE=1 -DMESH_DEBUG=1 -DMESH_PACKET_LOGGING=1 -DCFG_DEBUG=1 -DRECOVERABLE_EXTERNAL_RADIO=1" + # Lossless name compression retains every seeder target and its wire ID + # while recovering flash headroom; reuse the existing bounded OTA decoder. + export PLATFORMIO_BUILD_FLAGS="${PLATFORMIO_BUILD_FLAGS} -DOTA_TARGET_NAME_FRONT_CODED=1" if [ "$env_name" = "t1000e_companion_radio_full" ]; then # The T1000-E's primary CDC transport is the only USB interface. Its # `usb.logging on` selects an input-capable ASCII logging terminal on diff --git a/docs/research/firmware_memory_budget.md b/docs/research/firmware_memory_budget.md index fee39bfbc..cc6a00cc6 100644 --- a/docs/research/firmware_memory_budget.md +++ b/docs/research/firmware_memory_budget.md @@ -31,20 +31,34 @@ Classic ESP32 additionally retains its existing 8 KiB **static DRAM** check. The policy adds allowances for task stacks, radio packet pools, screen objects and pixel buffers, filesystem/sensor allocations, enabled wireless stacks, MQTT connections, OTA scratch and transient allocations. ESP32 images with -RS-232 reserve another 8 KiB even when UART defaults off: 4 KiB for the -source-bounded bridge object and 4 KiB for the UART driver and buffers. This +RS-232 reserve another 6,656 bytes even when UART defaults off: 2,560 bytes for +the source-bounded bridge object and allocation metadata, plus an unchanged +4 KiB for the UART driver and buffers. This keeps runtime UART enablement in the budget beside MQTT and ESP-NOW. T-LoRa V2.1-1.6 cannot fit all three transports: the qualified linked triple -image had 190,488 available internal bytes against 196,984 required bytes. +image had 190,488 available internal bytes against 196,984 required bytes +under the earlier allocation estimates. The corrected equivalent requirement +is 193,400 bytes, still 2,912 bytes above capacity. Its ordinary release retains separate UART/ESP-NOW and MQTT/ESP-NOW Full -images; the memory policy and routing capacities remain unchanged. +images; routing capacities remain unchanged. A 160x80 ST7735 -framebuffer needs 25,602 bytes; an OLED allowance is 4 KiB. nRF52 Full with that +framebuffer needs 25,602 bytes. ESP32 SSD1306 uses a source-bounded 2 KiB +allowance for its fixed 1,024-byte framebuffer, driver and metadata; other +OLED and ordinary non-ESP32 allowances remain unchanged. nRF52 Full with that color framebuffer must have at least 72 KiB available before startup allocations. Headless and OLED devices use their own smaller totals. The JSON lists each component and checks the largest available region against the largest planned single allocation. +The W12 Full MQTT profile remains unqualified: the current linked image has +182,768 internal bytes available against 198,432 required, including the +unchanged 16 KiB transient margin. Its standard repeater compiles and passes +the RAM check, but the 1,322,304-byte application exceeds the 1,310,720-byte +portable LoRa-OTA slot. Both limits also occur in the pre-stability baseline +(Full: 182,800 available against 200,480 required under the earlier OLED +estimate; standard application: 1,321,088 bytes). LR2021 compile and fault-test +coverage does not qualify either package for release. + The experimental RAK4631 combined Ethernet/UART2/adaptive-storage config adds an explicit 2,560-byte allowance for the UART bridge and its TX semaphore. Its Ethernet worker reserves 1,024 stack words (4,096 bytes) plus 256 bytes for diff --git a/examples/companion_radio/DataStore.cpp b/examples/companion_radio/DataStore.cpp index ee304993c..cf8422dbe 100644 --- a/examples/companion_radio/DataStore.cpp +++ b/examples/companion_radio/DataStore.cpp @@ -338,8 +338,19 @@ int _countLfsBlock(void *p, lfs_block_t block){ } lfs_ssize_t _getLfsUsedBlockCount(FILESYSTEM* fs) { - LfsTraversalState state = {0, fs->_getFS()->cfg->block_count}; - int err = lfs_traverse(fs->_getFS(), _countLfsBlock, &state); + if (fs == nullptr) return -1; + // Raw LittleFS traversal touches the same metadata/cache as file writes. + // Hold this filesystem's lock, separately from the physical flash lock. + fs->_lockFS(); + lfs_t* raw_fs = fs->_getFS(); + if (raw_fs == nullptr || raw_fs->cfg == nullptr + || raw_fs->cfg->block_count == 0) { + fs->_unlockFS(); + return -1; + } + LfsTraversalState state = {0, raw_fs->cfg->block_count}; + int err = lfs_traverse(raw_fs, _countLfsBlock, &state); + fs->_unlockFS(); if (err) { MESH_DEBUG_PRINTLN("ERROR: lfs_traverse() error: %d", err); return -1; diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 47ec2048a..4f0f02522 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -602,12 +602,15 @@ bool MyMesh::addToOfflineQueue(const uint8_t frame[], int len) { return false; } -int MyMesh::getFromOfflineQueue(uint8_t frame[]) { - if (offline_queue_len > 0) { // check offline queue - Frame& head = offlineQueueFrameAt(0); - size_t len = head.len; // take from top of queue - memcpy(frame, head.buf, len); +int MyMesh::peekOfflineQueue(uint8_t frame[]) { + if (!frame || offline_queue_len <= 0) return 0; + Frame& head = offlineQueueFrameAt(0); + memcpy(frame, head.buf, head.len); + return head.len; +} +void MyMesh::popOfflineQueue() { + if (offline_queue_len > 0) { offline_queue_len--; #if ONE_KEY_DM_SHARED_OFFLINE_QUEUE for (int i = 0; i < offline_queue_len; ++i) { @@ -623,9 +626,38 @@ int MyMesh::getFromOfflineQueue(uint8_t frame[]) { #ifdef DISPLAY_CLASS if (_ui) _ui->syncMessageQueue(offline_queue_len, 0); #endif - return len; } - return 0; // queue is empty +} + +int MyMesh::getFromOfflineQueue(uint8_t frame[]) { + // Explicit OTA queue draining intentionally consumes frames without app + // delivery. Normal app downloads commit only after transport acceptance. + const int len = peekOfflineQueue(frame); + if (len > 0) popOfflineQueue(); + return len; +} + +void MyMesh::syncNextOfflineMessage() { + BaseSerialInterface* reply_route = _serial->captureReplyRoute(); + const Frame* head = offline_queue_len > 0 ? &offlineQueueFrameAt(0) : nullptr; + size_t out_len = head ? head->len : 0; + const bool have_message = out_len > 0; + const uint8_t* reply_frame = out_frame; + if (have_message) { + // A full or disconnected requester must leave this message pending. + // Another connected transport cannot accept its reply on its behalf. + // Transports consume or copy the frame before returning, so the retained + // queue head can be sent directly and is only removed after acceptance. + if (!_serial->isReplyRouteAvailable(reply_route)) return; + reply_frame = head->buf; + } else { + out_frame[0] = RESP_CODE_NO_MORE_MESSAGES; + out_len = 1; + } + const size_t accepted = _serial->writeFrameToRoute(reply_route, reply_frame, out_len); + if (have_message && accepted == out_len) { + popOfflineQueue(); + } } float MyMesh::getAirtimeBudgetFactor() const { @@ -5908,13 +5940,7 @@ void MyMesh::handleCmdFrame(size_t len) { writeErrFrame(ERR_CODE_ILLEGAL_ARG); } } else if (cmd_frame[0] == CMD_SYNC_NEXT_MESSAGE) { - int out_len; - if ((out_len = getFromOfflineQueue(out_frame)) > 0) { - _serial->writeFrame(out_frame, out_len); - } else { - out_frame[0] = RESP_CODE_NO_MORE_MESSAGES; - _serial->writeFrame(out_frame, 1); - } + syncNextOfflineMessage(); } else if (cmd_frame[0] == CMD_SET_RADIO_PARAMS) { if (len < 11) { writeErrFrame(ERR_CODE_ILLEGAL_ARG); diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index 3b50d486b..da497eb5d 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -271,6 +271,11 @@ public: void setNotificationOutputMute(mesh::notify::Output, bool) {} #endif bool handleNotificationCommand(const char* command, char* reply, size_t size); +#if defined(STM32_PLATFORM) + // Keep this large dispatcher separate from contact streaming on flash- + // constrained STM32 targets; merging their branches increases code size. + __attribute__((noinline)) +#endif void handleCmdFrame(size_t len); bool advert(); void enterCLIRescue(); @@ -496,7 +501,10 @@ private: void stopContactsIterator(); bool updateContactFromFrame(ContactInfo &contact, uint32_t& last_mod, const uint8_t *frame, int len); bool addToOfflineQueue(const uint8_t frame[], int len); + int peekOfflineQueue(uint8_t frame[]); + void popOfflineQueue(); int getFromOfflineQueue(uint8_t frame[]); + void syncNextOfflineMessage(); int getBlobByKey(const uint8_t key[], int key_len, uint8_t dest_buf[]) override { return _store->getBlobByKey(key, key_len, dest_buf); } diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 72e75f3a9..d2ee7095a 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -676,12 +676,18 @@ static int cmp_neighbours_weakest_to_strongest(const void* a, const void* b) { return 0; } -uint8_t MyMesh::handleAnonRegionsReq(const mesh::Identity& sender, uint32_t sender_timestamp, const uint8_t* data) { +static bool hasCompleteAnonReplyPath(const uint8_t* data, size_t data_len) { + if (data == nullptr || data_len == 0 || !mesh::Packet::isValidPathLen(data[0])) return false; + const size_t path_bytes = (data[0] & 63U) * ((data[0] >> 6) + 1U); + return path_bytes <= data_len - 1; +} + +uint8_t MyMesh::handleAnonRegionsReq(const mesh::Identity& sender, uint32_t sender_timestamp, + const uint8_t* data, size_t data_len) { + if (!hasCompleteAnonReplyPath(data, data_len)) return 0; if (anon_limiter.allow(rtc_clock.getCurrentTime())) { // request data has: {reply-path-len}{reply-path} reply_path_len = *data++; - if (!mesh::Packet::isValidPathLen(reply_path_len)) return 0; // reject - bad encoding - mesh::Packet::writePath(reply_path, data, reply_path_len); // data += (uint8_t)reply_path_len * reply_path_hash_size; @@ -696,12 +702,12 @@ uint8_t MyMesh::handleAnonRegionsReq(const mesh::Identity& sender, uint32_t send return 0; } -uint8_t MyMesh::handleAnonOwnerReq(const mesh::Identity& sender, uint32_t sender_timestamp, const uint8_t* data) { +uint8_t MyMesh::handleAnonOwnerReq(const mesh::Identity& sender, uint32_t sender_timestamp, + const uint8_t* data, size_t data_len) { + if (!hasCompleteAnonReplyPath(data, data_len)) return 0; if (anon_limiter.allow(rtc_clock.getCurrentTime())) { // request data has: {reply-path-len}{reply-path} reply_path_len = *data++; - if (!mesh::Packet::isValidPathLen(reply_path_len)) return 0; // reject - bad encoding - mesh::Packet::writePath(reply_path, data, reply_path_len); // data += (uint8_t)reply_path_len * reply_path_hash_size; @@ -715,12 +721,12 @@ uint8_t MyMesh::handleAnonOwnerReq(const mesh::Identity& sender, uint32_t sender return 0; } -uint8_t MyMesh::handleAnonClockReq(const mesh::Identity& sender, uint32_t sender_timestamp, const uint8_t* data) { +uint8_t MyMesh::handleAnonClockReq(const mesh::Identity& sender, uint32_t sender_timestamp, + const uint8_t* data, size_t data_len) { + if (!hasCompleteAnonReplyPath(data, data_len)) return 0; if (anon_limiter.allow(rtc_clock.getCurrentTime())) { // request data has: {reply-path-len}{reply-path} reply_path_len = *data++; - if (!mesh::Packet::isValidPathLen(reply_path_len)) return 0; // reject - bad encoding - mesh::Packet::writePath(reply_path, data, reply_path_len); // data += (uint8_t)reply_path_len * reply_path_hash_size; @@ -2679,9 +2685,12 @@ mesh::DispatcherAction MyMesh::onRecvPacket(mesh::Packet* pkt) { void MyMesh::onAnonDataRecv(mesh::Packet *packet, const uint8_t *secret, const mesh::Identity &sender, uint8_t *data, size_t len) { + if (packet == nullptr) return; if (packet->getPayloadType() == PAYLOAD_TYPE_ANON_REQ) { // received an initial request by a possible admin // client (unknown at this stage) - if (len < 4) { + // A timestamp-only request is a valid empty-password login. The bounded + // terminator below supplies data[4]; typed requests still need their path. + if (data == nullptr || len < 4 || len >= MAX_PACKET_PAYLOAD) { MESH_DEBUG_PRINTLN("Rejected incomplete anonymous request"); return; } @@ -2695,11 +2704,11 @@ void MyMesh::onAnonDataRecv(mesh::Packet *packet, const uint8_t *secret, const m if (data[4] == 0 || data[4] >= ' ') { // is password, ie. a login request reply_len = handleLoginReq(sender, secret, timestamp, &data[4], packet->isRouteFlood()); } else if (data[4] == ANON_REQ_TYPE_REGIONS && packet->isRouteDirect()) { - reply_len = handleAnonRegionsReq(sender, timestamp, &data[5]); + reply_len = handleAnonRegionsReq(sender, timestamp, &data[5], len - 5); } else if (data[4] == ANON_REQ_TYPE_OWNER && packet->isRouteDirect()) { - reply_len = handleAnonOwnerReq(sender, timestamp, &data[5]); + reply_len = handleAnonOwnerReq(sender, timestamp, &data[5], len - 5); } else if (data[4] == ANON_REQ_TYPE_BASIC && packet->isRouteDirect()) { - reply_len = handleAnonClockReq(sender, timestamp, &data[5]); + reply_len = handleAnonClockReq(sender, timestamp, &data[5], len - 5); } else { reply_len = 0; // unknown/invalid request type } diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index f7e5dd252..78d249f8c 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -781,9 +781,9 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks void putNeighbour(const mesh::Identity& id, uint32_t timestamp, float snr, int16_t rssi); uint8_t handleLoginReq(const mesh::Identity& sender, const uint8_t* secret, uint32_t sender_timestamp, const uint8_t* data, bool is_flood); - uint8_t handleAnonRegionsReq(const mesh::Identity& sender, uint32_t sender_timestamp, const uint8_t* data); - uint8_t handleAnonOwnerReq(const mesh::Identity& sender, uint32_t sender_timestamp, const uint8_t* data); - uint8_t handleAnonClockReq(const mesh::Identity& sender, uint32_t sender_timestamp, const uint8_t* data); + uint8_t handleAnonRegionsReq(const mesh::Identity& sender, uint32_t sender_timestamp, const uint8_t* data, size_t data_len); + uint8_t handleAnonOwnerReq(const mesh::Identity& sender, uint32_t sender_timestamp, const uint8_t* data, size_t data_len); + uint8_t handleAnonClockReq(const mesh::Identity& sender, uint32_t sender_timestamp, const uint8_t* data, size_t data_len); int handleRequest(ClientInfo* sender, uint32_t sender_timestamp, uint8_t* payload, size_t payload_len, size_t reply_capacity = MAX_PACKET_PAYLOAD - CIPHER_MAC_SIZE - (CIPHER_BLOCK_SIZE - 1)); mesh::Packet* createSelfAdvert(); diff --git a/examples/simple_room_server/MyMesh.cpp b/examples/simple_room_server/MyMesh.cpp index 4f9dcbc3d..7d361ab1f 100644 --- a/examples/simple_room_server/MyMesh.cpp +++ b/examples/simple_room_server/MyMesh.cpp @@ -913,36 +913,30 @@ void MyMesh::onPeerDataRecv(mesh::Packet *packet, uint8_t type, int sender_idx, bool send_ack = false; if (flags == TXT_TYPE_PLAIN) { - const bool is_guest = - (client->permissions & PERM_ACL_ROLE_MASK) == PERM_ACL_GUEST; - if (is_guest) { - if (sender_timestamp < client->extra.room.last_post_timestamp) { - MESH_DEBUG_PRINTLN("onPeerDataRecv: stale room post detected"); - return; - } - client->extra.room.last_post_timestamp = sender_timestamp; - } else { - uint8_t message_fingerprint[MAX_HASH_SIZE]; - mesh::Utils::sha256(message_fingerprint, sizeof(message_fingerprint), - client->id.pub_key, PUB_KEY_SIZE, - (const uint8_t*)text, text_len); - const auto replay_decision = recent_room_posts.classifyAndRemember( - message_fingerprint, sender_timestamp, - client->extra.room.last_post_timestamp); + const uint8_t role = client->permissions & PERM_ACL_ROLE_MASK; + // Management roles grant their specific CLI permissions, not room posting. + if (role != PERM_ACL_READ_WRITE && role != PERM_ACL_ADMIN) return; - using ReplayDecision = - mesh::LogicalMessageCache::ReplayDecision; - if (replay_decision == ReplayDecision::StaleOrMismatched) { - MESH_DEBUG_PRINTLN("onPeerDataRecv: stale or mismatched room post detected"); - return; - } - if (replay_decision == ReplayDecision::NewMessage) { - addPost(client, text); - } - // Exact retries are ACKed again regardless of whether newer posts have - // advanced this client's room-post replay timestamp. - send_ack = true; + uint8_t message_fingerprint[MAX_HASH_SIZE]; + mesh::Utils::sha256(message_fingerprint, sizeof(message_fingerprint), + client->id.pub_key, PUB_KEY_SIZE, + (const uint8_t*)text, text_len); + const auto replay_decision = recent_room_posts.classifyAndRemember( + message_fingerprint, sender_timestamp, + client->extra.room.last_post_timestamp); + + using ReplayDecision = + mesh::LogicalMessageCache::ReplayDecision; + if (replay_decision == ReplayDecision::StaleOrMismatched) { + MESH_DEBUG_PRINTLN("onPeerDataRecv: stale or mismatched room post detected"); + return; } + if (replay_decision == ReplayDecision::NewMessage) { + addPost(client, text); + } + // Exact retries are ACKed again regardless of whether newer posts have + // advanced this client's room-post replay timestamp. + send_ack = true; } else { // TXT_TYPE_CLI_DATA or TXT_TYPE_CLI_COMMAND uint32_t request_id = sender_timestamp; mesh::RemoteCliRequest::parse(data, len, 5, request_id); diff --git a/scripts/check_firmware_ram.py b/scripts/check_firmware_ram.py index 95c7acb49..3eaad6f2b 100644 --- a/scripts/check_firmware_ram.py +++ b/scripts/check_firmware_ram.py @@ -68,12 +68,13 @@ def requirements(platform, defines, target): if combined_rak_ethernet and (platform != "NRF52_PLATFORM" or not {"RAK_4631", "ETHERNET_ENABLED", "OTA_RAK_AUTO_STORE"}.issubset(defines)): raise ValueError("combined RAK Ethernet budget requires RAK4631 adaptive nRF52 storage") - if combined_rak_ethernet and display == "SSD1306Display": + if (combined_rak_ethernet or platform == "ESP32_PLATFORM") and display == "SSD1306Display": # The fixed 128x64 driver allocates one 1024-byte framebuffer and # reuses it after power cycling. Its linked source assertion binds # framebuffer + complete driver object + allocator overhead to 2 KiB. - # The actual linked ARM driver is 156 bytes; it is already static, - # so including it here again retains a conservative allocation bound. + # The driver is already linked as a global (124 bytes on ESP32), so + # including it again retains a conservative allocation bound. Other + # display drivers and ordinary non-ESP32 policies are unchanged. display_heap = 2048 if platform == "NRF52_PLATFORM": parts["loop_and_callback_stacks"] = integer(defines, "MESH_NRF52_LOOP_STACK_WORDS", 2048) * 4 + 3072 @@ -95,9 +96,10 @@ def requirements(platform, defines, target): parts["mqtt_connections_buffers"] = 24576 if "WITH_RS232_BRIDGE" in defines: # The UART bridge is allocated on enable, alongside WiFi/MQTT. - # RS232Bridge.cpp bounds its object to 4 KiB; reserve another - # 4 KiB for the SDK UART task, buffers and allocator overhead. - parts["uart_bridge_and_driver"] = 8192 + # RS232Bridge.cpp bounds its object to 2304 bytes. Reserve 2560 + # including allocation metadata, plus the unchanged 4 KiB for + # the SDK UART task, buffers and allocator overhead. + parts["uart_bridge_and_driver"] = 2560 + 4096 if "ENABLE_OTA" in defines: # Existing own-image/manual-source allowance is independent of # the generic folder leaf/output buffers counted below. diff --git a/src/helpers/AdvertDataHelpers.cpp b/src/helpers/AdvertDataHelpers.cpp index 25e5cd3fe..93cd7405b 100644 --- a/src/helpers/AdvertDataHelpers.cpp +++ b/src/helpers/AdvertDataHelpers.cpp @@ -1,5 +1,7 @@ #include #include +#include +#include uint8_t AdvertDataBuilder::encodeTo(uint8_t app_data[]) { app_data[0] = _type; @@ -39,11 +41,22 @@ bool AdvertDataParser::isValidName(const char *n) { AdvertDataParser::AdvertDataParser(const uint8_t app_data[], uint8_t app_data_len) { _name[0] = 0; _lat = _lon = 0; - _flags = app_data[0]; + _flags = 0; _valid = false; _extra1 = _extra2 = 0; - - int i = 1; + + if (app_data == nullptr || app_data_len == 0 || app_data_len > MAX_ADVERT_DATA_SIZE) return; + + _flags = app_data[0]; + const size_t prefix_len = 1 + + ((_flags & ADV_LATLON_MASK) ? sizeof(_lat) + sizeof(_lon) : 0) + + ((_flags & ADV_FEAT1_MASK) ? sizeof(_extra1) : 0) + + ((_flags & ADV_FEAT2_MASK) ? sizeof(_extra2) : 0); + // Validate the complete fixed prefix before copying any optional fields. + // This also preserves the defaults if a later field is truncated. + if (app_data_len < prefix_len) return; + + size_t i = 1; if (_flags & ADV_LATLON_MASK) { memcpy(&_lat, &app_data[i], 4); i += 4; memcpy(&_lon, &app_data[i], 4); i += 4; @@ -55,17 +68,12 @@ bool AdvertDataParser::isValidName(const char *n) { memcpy(&_extra2, &app_data[i], 2); i += 2; } - if (app_data_len >= i) { - int nlen = 0; - if (_flags & ADV_NAME_MASK) { - nlen = app_data_len - i; // remainder of app_data - } - if (nlen > 0) { - memcpy(_name, &app_data[i], nlen); - _name[nlen] = 0; // set null terminator - } - _valid = true; + const size_t nlen = (_flags & ADV_NAME_MASK) ? app_data_len - i : 0; + if (nlen > 0) { + memcpy(_name, &app_data[i], nlen); + _name[nlen] = 0; // set null terminator } + _valid = true; } #include diff --git a/src/helpers/AutoDiscoverRTCClock.cpp b/src/helpers/AutoDiscoverRTCClock.cpp index 53e58dd44..0e7d9d854 100644 --- a/src/helpers/AutoDiscoverRTCClock.cpp +++ b/src/helpers/AutoDiscoverRTCClock.cpp @@ -26,14 +26,107 @@ bool AutoDiscoverRTCClock::i2c_probe(TwoWire& wire, uint8_t addr) { return (error == 0); } +// An ACK only says that something answers at an RTC's address: an IMU at 0x68 or an EEPROM in +// 0x50-0x57 answers too, and would then be read as the clock and written on every time sync. +// So an RTC is adopted only if its time registers read like that chip's, per its datasheet: +// - bits documented as always 0 must read 0 (the PCF8563 documents none), and the seven +// registers must not all read 0xFF, as an erased EEPROM does; +// - seconds, minutes, date and month must be valid BCD in range, unless the chip's power-loss +// flag is set, or a DS1307's clock-halt bit is set: startup time may be undefined +// (not checked for the RX8130CE, see its entry below). +// The year is not checked, as MeshCore can write an out-of-range one from a bad epoch. This +// cannot catch every device: one whose bytes happen to fit is still adopted, as before. +struct RtcId { + uint8_t time_reg; // seconds register; the seven time registers follow it + uint8_t zero[7]; // bits that always read 0 + uint8_t date_idx; // index of the date register within the seven + uint8_t flag_reg; // power-loss flag register and bit; flag_bit 0 skips the field check + uint8_t flag_bit; + uint8_t halt_bit; // seconds clock-halt flag; 0 for every profile except DS1307 at 0x68 +}; + +#if !defined(DISABLE_DS3231_PROBE) +// DS3231 (Maxim 19-5170 Rev 10): Figure 1, p. 11; OSF in Status (0Fh) bit 7, p. 14. 00h bit 7 +// is left out: a DS1307 at 0x68, which this code also drives, powers up with its clock-halt bit +// there set (Maxim DS1307 Rev 3/15, Table 2 and text, p. 8). Its other bits match. +// A halted DS1307 may have unset calendar fields; 0Fh is RAM on that chip, not OSF. +static const RtcId DS3231_ID = + { 0x00, { 0x00, 0x80, 0x80, 0xF8, 0xC0, 0x60, 0x00 }, 4, 0x0F, 0x80, 0x80 }; +#endif +// RV3028 (RV-3028-C7 App Manual Rev 1.4): 3.2, p. 12; PORF in Status (0Eh) bit 0, 3.7, p. 22 +static const RtcId RV3028_ID = + { 0x00, { 0x80, 0x80, 0xC0, 0xF8, 0xC0, 0xE0, 0x00 }, 4, 0x0E, 0x01, 0 }; +// PCF8563 (NXP Rev 11.1): unused bits are "not relevant", not 0, Table 4, p. 10; VL in +// VL_seconds (02h) bit 7, Table 8, p. 13, and set at power-up, Table 27, p. 24 +static const RtcId PCF8563_ID = { 0x02, { 0 }, 3, 0x02, 0x80, 0 }; +// RX8130CE (Epson ETM50E-10): read value always 0, 13.2.1, p. 22. No field check: its power-loss +// flag VLF (1Dh bit 1) cannot vouch for the fields, as RTC_RX8130CE::begin() clears it on every +// boot without setting the time. +static const RtcId RX8130CE_ID = + { 0x10, { 0x80, 0x80, 0xC0, 0x80, 0xC0, 0xE0, 0x00 }, 4, 0x1D, 0x00, 0 }; + +// Reads n registers from reg, with a repeated start as every one of these datasheets documents +static bool rtcRead(TwoWire& wire, uint8_t addr, uint8_t reg, uint8_t* buf, uint8_t n) { + wire.beginTransmission(addr); + wire.write(reg); + if (wire.endTransmission(false) != 0) return false; + if (wire.requestFrom(addr, n) != n) return false; + for (uint8_t i = 0; i < n; i++) { + const int value = wire.read(); + if (value < 0) return false; + buf[i] = uint8_t(value); + } + return true; +} + +static bool bcdInRange(uint8_t v, uint8_t lo, uint8_t hi) { + if ((v & 0x0F) > 9 || (v >> 4) > 9) return false; + uint8_t d = (v >> 4) * 10 + (v & 0x0F); + return d >= lo && d <= hi; +} + +// 1 if this read rules the device out, 0 if not, -1 if a read failed +static int rtcCheck(TwoWire& wire, uint8_t addr, const RtcId& id) { + uint8_t t[7]; + if (!rtcRead(wire, addr, id.time_reg, t, 7)) return -1; + bool all_ff = true; + for (int i = 0; i < 7; i++) { + if (t[i] & id.zero[i]) return 1; + all_ff = all_ff && t[i] == 0xFF; + } + if (all_ff) return 1; // an erased EEPROM + if (t[0] & id.halt_bit) return 0; // DS1307 startup; never rely on its RAM at 0Fh + if (id.flag_bit == 0) return 0; + if (bcdInRange(t[0] & 0x7F, 0, 59) && bcdInRange(t[1] & 0x7F, 0, 59) + && bcdInRange(t[id.date_idx] & 0x3F, 1, 31) && bcdInRange(t[5] & 0x1F, 1, 12)) { + return 0; + } + uint8_t flag; + if (!rtcRead(wire, addr, id.flag_reg, &flag, 1)) return -1; + return (flag & id.flag_bit) ? 0 : 1; // power was lost, so the time is undefined +} + +// Retry one failed/corrupted read, but do not initialize or write an unknown +// device when neither read identifies a compatible register layout. +static bool rtcLooksCompatible(TwoWire& wire, uint8_t addr, const RtcId& id) { + for (int attempt = 0; attempt < 2; ++attempt) { + if (rtcCheck(wire, addr, id) == 0) return true; + } + MESH_DEBUG_PRINTLN("RTC: skipping unrecognized or unreadable device at 0x%02X", addr); + return false; +} + void AutoDiscoverRTCClock::begin(TwoWire& wire) { + ds3231_success = rv3028_success = rtc_8563_success = rtc_8130_success = false; #if !defined(DISABLE_DS3231_PROBE) - if (i2c_probe(wire, DS3231_ADDRESS)) { + if (i2c_probe(wire, DS3231_ADDRESS) + && rtcLooksCompatible(wire, DS3231_ADDRESS, DS3231_ID)) { ds3231_success = rtc_3231.begin(&wire); } #endif - if (i2c_probe(wire, RV3028_ADDRESS)) { + if (i2c_probe(wire, RV3028_ADDRESS) + && rtcLooksCompatible(wire, RV3028_ADDRESS, RV3028_ID)) { rtc_rv3028.initI2C(wire); rtc_rv3028.writeToRegister(0x35, 0x00); rtc_rv3028.writeToRegister(0x37, 0xB4); // Direct Switching Mode (DSM): when VDD < VBACKUP, switchover occurs from VDD to VBACKUP @@ -41,16 +134,17 @@ void AutoDiscoverRTCClock::begin(TwoWire& wire) { rv3028_success = true; } - if (i2c_probe(wire, PCF8563_ADDRESS)) { + if (i2c_probe(wire, PCF8563_ADDRESS) + && rtcLooksCompatible(wire, PCF8563_ADDRESS, PCF8563_ID)) { MESH_DEBUG_PRINTLN("PCF8563: Found"); rtc_8563_success = rtc_8563.begin(&wire); } - if (i2c_probe(wire, RX8130CE_ADDRESS)) { + if (i2c_probe(wire, RX8130CE_ADDRESS) + && rtcLooksCompatible(wire, RX8130CE_ADDRESS, RX8130CE_ID)) { MESH_DEBUG_PRINTLN("RX8130CE: Found"); - rtc_8130.begin(&wire); - rtc_8130_success = true; - MESH_DEBUG_PRINTLN("RX8130CE: Initialized"); + rtc_8130_success = rtc_8130.begin(&wire); + if (rtc_8130_success) MESH_DEBUG_PRINTLN("RX8130CE: Initialized"); } } diff --git a/src/helpers/BaseChatMesh.cpp b/src/helpers/BaseChatMesh.cpp index fa6505758..0f9d42489 100644 --- a/src/helpers/BaseChatMesh.cpp +++ b/src/helpers/BaseChatMesh.cpp @@ -127,16 +127,39 @@ void BaseChatMesh::sendAckTo(const ContactInfo& dest, const uint8_t* ack_hash, u } } +static uint32_t contactClockBootstrapLimit(uint32_t current_time) { + // A corrupt contact must not permanently move the clock into the far future. + // Use the build year while the RTC is unset, and a rolling bound when a + // long-lived installation already has a clock beyond that build horizon. + constexpr const char* date = __DATE__; + constexpr unsigned year = (date[7] - '0') * 1000 + (date[8] - '0') * 100 + + (date[9] - '0') * 10 + (date[10] - '0') + 4; + constexpr unsigned previous_year = year - 1; + // Gregorian leap days before this year, minus the 477 before 1970. + // Fold the build horizon at compile time instead of linking a calendar loop. + constexpr uint64_t days = 365ULL * (year - 1970) + + previous_year / 4 - previous_year / 100 + previous_year / 400 - 477; + constexpr uint64_t build_limit = days * 86400ULL; + constexpr uint32_t build_bound = build_limit < UINT32_MAX + ? uint32_t(build_limit) : UINT32_MAX; + constexpr uint32_t clock_window = 366UL * 86400UL; + const uint32_t clock_bound = current_time > UINT32_MAX - clock_window + ? UINT32_MAX : current_time + clock_window; + return clock_bound > build_bound ? clock_bound : build_bound; +} + void BaseChatMesh::bootstrapRTCfromContacts() { + const uint32_t current_time = getRTCClock()->getCurrentTime(); + const uint32_t max_time = contactClockBootstrapLimit(current_time); uint32_t latest = 0; for (int i = 0; i < num_contacts; i++) { - if (contacts[i].lastmod > latest) { + if (contacts[i].lastmod > latest && contacts[i].lastmod < max_time) { latest = contacts[i].lastmod; } } if (latest != 0 && latest != UINT32_MAX) { const uint32_t contact_floor = latest + 1; - if (contact_floor > getRTCClock()->getCurrentTime()) { + if (contact_floor > current_time) { getRTCClock()->setCurrentTime(contact_floor); } } diff --git a/src/helpers/bridges/ESPNowBridge.cpp b/src/helpers/bridges/ESPNowBridge.cpp index 4c5324f27..4720b820a 100644 --- a/src/helpers/bridges/ESPNowBridge.cpp +++ b/src/helpers/bridges/ESPNowBridge.cpp @@ -77,6 +77,10 @@ void ESPNowBridge::begin() { BRIDGE_DEBUG_PRINTLN("Initializing...\n"); if (_initialized) return; + if (_sdk_teardown_failed) { + end(); + if (_sdk_teardown_failed) return; + } _active_format = mesh::bridge::isValidEspNowFormat(_prefs->bridge_format) ? _prefs->bridge_format : mesh::bridge::ESPNOW_FORMAT_WRAPPED; @@ -103,6 +107,7 @@ void ESPNowBridge::begin() { _tx_dropped = 0; portEXIT_CRITICAL(&_tx_mux); _tx_dropped_reported = 0; + _tx_started_at = _tx_retry_at = 0; if (!mesh::bridge::isValidEspNowBridgeChannel( _prefs->bridge_channel, _active_format)) { @@ -215,7 +220,7 @@ void ESPNowBridge::begin() { } void ESPNowBridge::end() { - if (!_initialized) return; + if (!_initialized && !_sdk_teardown_failed) return; BRIDGE_DEBUG_PRINTLN("Stopping...\n"); // Remove broadcast peer @@ -229,7 +234,8 @@ void ESPNowBridge::end() { esp_now_register_send_cb(nullptr); // Deinitialize ESP-NOW - if (esp_now_deinit() != ESP_OK) { + _sdk_teardown_failed = esp_now_deinit() != ESP_OK; + if (_sdk_teardown_failed) { BRIDGE_DEBUG_PRINTLN("Error deinitializing ESP-NOW\n"); } @@ -267,11 +273,17 @@ void ESPNowBridge::end() { } void ESPNowBridge::loop() { + if (!_initialized) return; pumpTransmitQueue(); + if (!_initialized) return; uint8_t frame[MAX_ESPNOW_PACKET_SIZE]; uint8_t source[mesh::espnow::ESPNOW_RAW_SOURCE_MAC_SIZE]; for (uint8_t processed = 0; processed < RX_QUEUE_DEPTH; ++processed) { + // Reserve a mesh slot before removing a callback frame. Even a completing + // raw fragment must remain queued until the reconstructed packet can live. + mesh::Packet* packet = _mgr->allocNew(); + if (packet == nullptr) break; size_t frame_len = 0; uint32_t dropped = 0; @@ -295,8 +307,10 @@ void ESPNowBridge::loop() { (unsigned long)dropped); _rx_dropped_reported = dropped; } - if (frame_len == 0) break; - processReceivedFrame(source, frame, frame_len); + if (frame_len == 0) { _mgr->free(packet); break; } + if (!processReceivedFrame(packet, source, frame, frame_len)) { + _mgr->free(packet); + } } } @@ -321,6 +335,7 @@ bool ESPNowBridge::queueTransmitFrames( queued_tx.packet = packet; queued_tx.next_frame = 0; queued_tx.started = false; + queued_tx.start_retries = 0; _tx_head = static_cast((_tx_head + 1) % TX_QUEUE_DEPTH); _tx_count++; queued = true; @@ -339,6 +354,24 @@ void ESPNowBridge::pumpTransmitQueue() { bool clear_unsent_seen = false; mesh::Packet unsent_packet; uint32_t dropped = 0; + const uint32_t now = millis(); + bool timed_out = false; + + portENTER_CRITICAL(&_tx_mux); + timed_out = _tx_waiting && !_tx_callback_done + && static_cast(now - _tx_started_at) >= TX_CALLBACK_TIMEOUT_MS; + portEXIT_CRITICAL(&_tx_mux); + if (timed_out) { + // A completion carries no frame identity. Continuing after timeout could + // mistake a late callback for the next fragment. Tear down that SDK session + // before accepting another frame; never transmit an orphan sibling. + BRIDGE_DEBUG_PRINTLN("TX callback timeout; restarting ESP-NOW\n"); + end(); + // Fail closed if the old session could still own an accepted frame. A + // later explicit begin retries teardown before accepting new traffic. + if (!_sdk_teardown_failed) begin(); + return; + } portENTER_CRITICAL(&_tx_mux); dropped = _tx_dropped; @@ -352,21 +385,27 @@ void ESPNowBridge::pumpTransmitQueue() { QueuedTransmit& queued_tx = _tx_queue[_tx_tail]; if (completed_status == ESP_NOW_SEND_SUCCESS) { queued_tx.next_frame++; + queued_tx.start_retries = 0; if (queued_tx.next_frame >= queued_tx.frames.count) { _tx_tail = static_cast( (_tx_tail + 1) % TX_QUEUE_DEPTH); _tx_count--; } + } else if (queued_tx.start_retries < TX_MAX_START_RETRIES) { + // This completion is definite, unlike a missing callback. Retry the + // same frame within the shared start/callback budget; its sibling + // cannot be submitted until this frame succeeds. + ++queued_tx.start_retries; + _tx_retry_at = now + TX_RETRY_DELAY_MS; } else { - // esp_now_send() accepted at least one frame, matching the old bridge - // definition of "seen", but never submit any remaining fragments from - // a logical packet whose preceding frame failed. _tx_tail = static_cast((_tx_tail + 1) % TX_QUEUE_DEPTH); _tx_count--; } } } - if (!_tx_waiting && _tx_count > 0) { + if (!_tx_waiting && _tx_count > 0 + && (_tx_queue[_tx_tail].start_retries == 0 + || static_cast(now - _tx_retry_at) >= 0)) { QueuedTransmit& queued_tx = _tx_queue[_tx_tail]; if (queued_tx.next_frame < queued_tx.frames.count) { frame_len = queued_tx.frames.lengths[queued_tx.next_frame]; @@ -379,6 +418,8 @@ void ESPNowBridge::pumpTransmitQueue() { if (frame_len > 0) { _tx_waiting = true; _tx_callback_done = false; + _tx_started_at = now; + _tx_retry_at = 0; } } portEXIT_CRITICAL(&_tx_mux); @@ -404,12 +445,19 @@ void ESPNowBridge::pumpTransmitQueue() { _tx_callback_done = false; if (_tx_count > 0) { QueuedTransmit& queued_tx = _tx_queue[_tx_tail]; - if (!queued_tx.started) { - unsent_packet = queued_tx.packet; - clear_unsent_seen = true; + if (result == ESP_ERR_ESPNOW_NO_MEM + && queued_tx.start_retries < TX_MAX_START_RETRIES) { + ++queued_tx.start_retries; + _tx_retry_at = now + TX_RETRY_DELAY_MS; + } else { + if (!queued_tx.started) { + unsent_packet = queued_tx.packet; + clear_unsent_seen = true; + } + _tx_tail = static_cast((_tx_tail + 1) % TX_QUEUE_DEPTH); + _tx_count--; + _tx_retry_at = 0; } - _tx_tail = static_cast((_tx_tail + 1) % TX_QUEUE_DEPTH); - _tx_count--; } portEXIT_CRITICAL(&_tx_mux); // Admission marks the packet to suppress duplicate queue entries. Restore @@ -436,9 +484,7 @@ bool ESPNowBridge::xorCrypt(uint8_t *data, size_t len) { return true; } -void ESPNowBridge::receiveMeshPacket(const uint8_t *data, size_t len) { - mesh::Packet *pkt = _mgr->allocNew(); - if (!pkt) return; +void ESPNowBridge::receiveMeshPacket(mesh::Packet* pkt, const uint8_t *data, size_t len) { if (pkt->readFrom(data, len)) { onPacketReceived(pkt); @@ -481,10 +527,10 @@ void ESPNowBridge::queueReceivedFrame(const uint8_t *mac, const uint8_t *data, portEXIT_CRITICAL(&_rx_mux); } -void ESPNowBridge::processReceivedFrame(const uint8_t *mac, +bool ESPNowBridge::processReceivedFrame(mesh::Packet* packet_slot, const uint8_t *mac, const uint8_t *data, size_t len) { if (data == nullptr || len == 0 || len > MAX_ESPNOW_PACKET_SIZE) { - return; + return false; } if (_active_format == mesh::bridge::ESPNOW_FORMAT_RAW) { @@ -500,28 +546,29 @@ void ESPNowBridge::processReceivedFrame(const uint8_t *mac, (unsigned)packet_len, result == mesh::espnow::ESPNowRawReassemblyResult::PACKET_COMPLETE ? " (reassembled)" : ""); - receiveMeshPacket(packet, packet_len); + receiveMeshPacket(packet_slot, packet, packet_len); + return true; } else if (result == mesh::espnow::ESPNowRawReassemblyResult::REJECTED || result == mesh::espnow::ESPNowRawReassemblyResult::OUTPUT_TOO_SMALL) { BRIDGE_DEBUG_PRINTLN("RX raw fragment rejected, result=%u len=%u\n", (unsigned)result, (unsigned)len); } - return; + return false; } // Wrapped mode is strict: ignore packets too small to contain its header // and checksum rather than trying to auto-detect raw MeshCore traffic. if (len < (BRIDGE_MAGIC_SIZE + BRIDGE_CHECKSUM_SIZE)) { BRIDGE_DEBUG_PRINTLN("RX packet too small, len=%u\n", (unsigned)len); - return; + return false; } // Check packet header magic uint16_t received_magic = (data[0] << 8) | data[1]; if (received_magic != BRIDGE_PACKET_MAGIC) { BRIDGE_DEBUG_PRINTLN("RX invalid magic 0x%04X\n", received_magic); - return; + return false; } // Make a copy we can decrypt @@ -530,7 +577,7 @@ void ESPNowBridge::processReceivedFrame(const uint8_t *mac, memcpy(decrypted, data + BRIDGE_MAGIC_SIZE, encryptedDataLen); // Try to decrypt (checksum + payload) - if (!xorCrypt(decrypted, encryptedDataLen)) return; + if (!xorCrypt(decrypted, encryptedDataLen)) return false; // Validate checksum uint16_t received_checksum = (decrypted[0] << 8) | decrypted[1]; @@ -539,11 +586,12 @@ void ESPNowBridge::processReceivedFrame(const uint8_t *mac, if (!validateChecksum(decrypted + BRIDGE_CHECKSUM_SIZE, payloadLen, received_checksum)) { // Failed to decrypt - likely from a different network BRIDGE_DEBUG_PRINTLN("RX checksum mismatch, rcv=0x%04X\n", received_checksum); - return; + return false; } BRIDGE_DEBUG_PRINTLN("RX, payload_len=%u\n", (unsigned)payloadLen); - receiveMeshPacket(decrypted + BRIDGE_CHECKSUM_SIZE, payloadLen); + receiveMeshPacket(packet_slot, decrypted + BRIDGE_CHECKSUM_SIZE, payloadLen); + return true; } void ESPNowBridge::onDataSent(const uint8_t *mac_addr, esp_now_send_status_t status) { diff --git a/src/helpers/bridges/ESPNowBridge.h b/src/helpers/bridges/ESPNowBridge.h index b0e7ac936..b574baa3c 100644 --- a/src/helpers/bridges/ESPNowBridge.h +++ b/src/helpers/bridges/ESPNowBridge.h @@ -105,6 +105,7 @@ private: mesh::Packet packet; uint8_t next_frame; bool started; + uint8_t start_retries; }; QueuedTransmit _tx_queue[TX_QUEUE_DEPTH]; portMUX_TYPE _tx_mux = portMUX_INITIALIZER_UNLOCKED; @@ -116,6 +117,11 @@ private: volatile int _tx_callback_status; volatile uint32_t _tx_dropped; uint32_t _tx_dropped_reported; + uint32_t _tx_started_at = 0, _tx_retry_at = 0; + bool _sdk_teardown_failed = false; + static constexpr uint32_t TX_CALLBACK_TIMEOUT_MS = 1000; + static constexpr uint32_t TX_RETRY_DELAY_MS = 20; + static constexpr uint8_t TX_MAX_START_RETRIES = 3; /** Raw-mode fragment assemblies, keyed by source MAC. */ mesh::espnow::ESPNowRawReassembler _raw_reassembler; @@ -138,7 +144,7 @@ private: bool xorCrypt(uint8_t *data, size_t len); /** Parse and queue one serialized MeshCore packet. */ - void receiveMeshPacket(const uint8_t *data, size_t len); + void receiveMeshPacket(mesh::Packet* packet, const uint8_t *data, size_t len); /** * ESP-NOW receive callback @@ -151,8 +157,8 @@ private: void queueReceivedFrame(const uint8_t *mac, const uint8_t *data, int len); /** Parse a queued frame from loop(), never from the WiFi callback. */ - void processReceivedFrame(const uint8_t *mac, const uint8_t *data, - size_t len); + bool processReceivedFrame(mesh::Packet* packet, const uint8_t *mac, + const uint8_t *data, size_t len); /** Atomically append one logical packet's one or two transport frames. */ bool queueTransmitFrames(const mesh::espnow::ESPNowRawFrames& frames, diff --git a/src/helpers/bridges/RS232Bridge.cpp b/src/helpers/bridges/RS232Bridge.cpp index e86e7d3a1..a43d7d3ac 100644 --- a/src/helpers/bridges/RS232Bridge.cpp +++ b/src/helpers/bridges/RS232Bridge.cpp @@ -8,7 +8,7 @@ #ifdef ESP32 // check_firmware_ram.py reserves this object plus the UART driver's runtime // allocation even when a combined Full image starts with the UART disabled. -static_assert(sizeof(RS232Bridge) <= 4096, "Update the ESP32 UART heap budget"); +static_assert(sizeof(RS232Bridge) <= 2304, "Update the ESP32 UART heap budget"); #endif #if defined(NRF52_PLATFORM) && defined(RAK4631_COMBINED_ETHERNET) // The combined RAK RAM policy reserves this object plus allocator metadata @@ -24,6 +24,8 @@ RS232Bridge::RS232Bridge(NodePrefs *prefs, Stream &serial, int16_t rx_pin, void RS232Bridge::begin() { _initialized = false; + _rx_buffer_pos = 0; + _rx_packet_ready = false; BRIDGE_DEBUG_PRINTLN("Initializing at %d baud...\n", _prefs->bridge_baud); #if defined(ESP32) if (!((HardwareSerial *)_serial)->setPins(_rx_pin, _tx_pin)) return; @@ -58,6 +60,20 @@ void RS232Bridge::end() { // Update bridge state _initialized = false; + _rx_buffer_pos = 0; + _rx_packet_ready = false; +} + +void RS232Bridge::drainReceivedPackets() { + if (!_rx_packet_ready) return; + mesh::Packet* pkt = _mgr->allocNew(); + if (!pkt) return; + const uint16_t len = (_rx_buffer[2] << 8) | _rx_buffer[3]; + const bool valid = pkt->readFrom(_rx_buffer + 4, len); + _rx_buffer_pos = 0; + _rx_packet_ready = false; + if (valid) onPacketReceived(pkt); + else _mgr->free(pkt); } void RS232Bridge::loop() { @@ -66,8 +82,14 @@ void RS232Bridge::loop() { return; } - while (_serial->available()) { - uint8_t b = _serial->read(); + drainReceivedPackets(); + // Snapshot bounded work; a noisy UART must not starve mesh/radio service. + int remaining = _serial->available(); + if (remaining > 512) remaining = 512; + while (remaining-- > 0 && !_rx_packet_ready) { + const int value = _serial->read(); + if (value < 0) break; + const uint8_t b = static_cast(value); if (_rx_buffer_pos < 2) { // Waiting for magic word @@ -90,7 +112,7 @@ void RS232Bridge::loop() { uint16_t len = (_rx_buffer[2] << 8) | _rx_buffer[3]; // Validate length field - if (len > (MAX_TRANS_UNIT + 1)) { + if (len == 0 || len > (MAX_TRANS_UNIT + 1)) { BRIDGE_DEBUG_PRINTLN("RX invalid length %d, resetting\n", len); _rx_buffer_pos = 0; // Invalid length, reset continue; @@ -101,25 +123,17 @@ void RS232Bridge::loop() { if (validateChecksum(_rx_buffer + 4, len, received_checksum)) { BRIDGE_DEBUG_PRINTLN("RX, len=%d crc=0x%04x\n", len, received_checksum); - mesh::Packet *pkt = _mgr->allocNew(); - if (pkt) { - if (pkt->readFrom(_rx_buffer + 4, len)) { - onPacketReceived(pkt); - } else { - BRIDGE_DEBUG_PRINTLN("RX failed to parse packet\n"); - _mgr->free(pkt); - } - } else { - BRIDGE_DEBUG_PRINTLN("RX failed to allocate packet\n"); - } + _rx_packet_ready = true; + drainReceivedPackets(); // continue this bounded batch when the pool is available } else { BRIDGE_DEBUG_PRINTLN("RX checksum mismatch, rcv=0x%04x\n", received_checksum); } - _rx_buffer_pos = 0; // Reset for next packet + if (!_rx_packet_ready) _rx_buffer_pos = 0; // Keep a complete valid frame } } } } + drainReceivedPackets(); } void RS232Bridge::sendPacket(mesh::Packet *packet) { @@ -137,8 +151,8 @@ void RS232Bridge::sendPacket(mesh::Packet *packet) { if (!allowsPacket(packet)) return; if (!_seen_packets.wasSeen(packet)) { - _seen_packets.markSeen(packet); - + const int expected = packet->getRawLength(); + if (expected <= 0 || expected > MAX_TRANS_UNIT + 1) return; uint8_t buffer[MAX_SERIAL_PACKET_SIZE]; uint16_t len = packet->writeTo(buffer + 4); @@ -160,7 +174,9 @@ void RS232Bridge::sendPacket(mesh::Packet *packet) { buffer[5 + len] = checksum & 0xFF; // Checksum low byte // Send complete packet - _serial->write(buffer, len + SERIAL_OVERHEAD); + if (_serial->write(buffer, len + SERIAL_OVERHEAD) == static_cast(len + SERIAL_OVERHEAD)) { + _seen_packets.markSeen(packet); + } BRIDGE_DEBUG_PRINTLN("TX, len=%d crc=0x%04x\n", len, checksum); } diff --git a/src/helpers/bridges/RS232Bridge.h b/src/helpers/bridges/RS232Bridge.h index 2c35ab471..895d5a394 100644 --- a/src/helpers/bridges/RS232Bridge.h +++ b/src/helpers/bridges/RS232Bridge.h @@ -142,6 +142,11 @@ private: /** Buffer for building received packets */ uint8_t _rx_buffer[MAX_SERIAL_PACKET_SIZE]; + // Retain the completed parser buffer while the shared pool is exhausted; + // unread bytes remain in the UART driver until this frame is delivered. + bool _rx_packet_ready = false; + void drainReceivedPackets(); + /** Current position in the receive buffer */ uint16_t _rx_buffer_pos = 0; }; diff --git a/src/helpers/ota/OtaCli.cpp b/src/helpers/ota/OtaCli.cpp index 18c50688e..99d88518e 100644 --- a/src/helpers/ota/OtaCli.cpp +++ b/src/helpers/ota/OtaCli.cpp @@ -147,81 +147,10 @@ static bool is_cmd(const char* a, const char* names, const char** rest) { // packet so it works as remote-admin over LoRa. static bool handle_dev(const char* d, char* reply, OtaContext& c); -bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board) { - const auto* active = ota_context_if_active(); - const float adaptive_pace = active ? active->manager.adaptivePacketSpeed() : OTA_SPEED_DEFAULT; - if (handleSpeedCommand(command, reply, 160, adaptive_pace)) return true; - 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(); - c.manager.set_clock(millis()); - c.manager.set_speed(speedFactor()); -#if defined(NRF52_PLATFORM) && defined(OTA_TOWER_AUTO_STORE) - c.prepareTowerStorage(); -#endif - const char* rest = a; - - // ---- raw / internal primitives, tucked under `ota dev ...` ---- - if (is_cmd(a, "dev", &rest)) { - return handle_dev(rest, reply, c); - } - - // ---- help: list the commands in plain words (aliases in parentheses) ---- - if (is_cmd(a, "help|?|h", &rest)) { -#if defined(OTA_SEEDER_ONLY) - snprintf(reply, 160, - "OTA seeder: status | stats | ls=find images | get folder=capture | cancel | " - "announce | folder | config. LoRa install is disabled."); -#elif defined(NRF52_PLATFORM) && defined(OTA_FLASH_STORE) && !defined(OTA_SD_STORE) && \ - !defined(OTA_QSPI_STORE) -#if defined(OTA_INTERNAL_BOOTLOADER_UPDATE) - strcpy(reply, - "OTA: status | stats | ls | get flash | install | " - "bootloader | cancel | announce | self | folder | config | key"); -#else - strcpy(reply, - "OTA: status | stats | ls | get flash [rescue] | install | rescue install | " - "cancel | announce | self | folder | config | key"); -#endif -#elif defined(NRF52_PLATFORM) && defined(OTA_RAK_AUTO_STORE) - strcpy(reply, - "OTA: status | stats | ls | get flash | install | bootloader | cancel | announce | self | serve | storage | folder | config | key"); -#elif defined(NRF52_PLATFORM) && defined(OTA_QSPI_STORE) -#if defined(OTA_QSPI_BOOTLOADER_UPDATE) - strcpy(reply, - "OTA: status | stats | ls | get flash | install | bootloader | cancel | announce | self | serve | " - "qspi | folder | config | key"); -#else - strcpy(reply, - "OTA: status | stats | ls | get flash | install | cancel | announce | self | serve | qspi | " - "folder | config | key"); -#endif -#elif defined(NRF52_PLATFORM) && defined(OTA_SD_STORE) -#if defined(OTA_TOWER_AUTO_STORE) - strcpy(reply, - "OTA: status | stats | ls | get flash | install | bootloader | cancel | announce | self | serve | " - "storage | folder | cache | config | key"); -#else -#if defined(OTA_SD_BOOTLOADER_UPDATE) - strcpy(reply, - "OTA: status | stats | ls | get flash | install | bootloader | cancel | announce | self | serve | " - "folder | cache | config | key"); -#else - strcpy(reply, - "OTA: status | stats | ls | get flash | install | cancel | announce | self | serve | folder | " - "cache | config | key"); -#endif -#endif -#else - snprintf(reply, 160, - "OTA: status | stats | ls | get flash | install | cancel | announce | self | serve | folder | " - "cache | config | key. `ota ls [page]`; folder [validate]."); -#endif - - // ---- inventory dashboard: running fw (self), the one fetch session, serving state ---- - } else if (*a == 0 || is_cmd(a, "status|st", &rest)) { +// These explicit call boundaries keep unrelated control/staging buffers out of +// the target-name decoder call chain. This matters on the fixed 8 KiB nRF52 +// loop stack, especially when listing is entered through a decrypted LoRa CLI. +static __attribute__((noinline)) bool handle_status(char* reply, OtaContext& c) { #if defined(OTA_SEEDER_ONLY) uint8_t dig[4]; c.manager.servedDigest(dig); char dighx[9]; mesh::Utils::toHex(dighx, dig, 4); @@ -304,49 +233,11 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board (unsigned)c.allow.count(), tenv ? tenv : "?"); #endif #endif + return true; +} - // ---- admin OTA stats: crypto identities (our fw's content-id + body_hash), serving set, live fetch, - // policy - one dense line. The remote-admin CLI path is admin-gated, so this is admin-only over the - // mesh (send it from the app's repeater command screen, or the WiFi/serial OTA console). - } else if (is_cmd(a, "stats", &rest)) { - // our running fw's merkle content-id (mid) comes from the self serve entry; the EndF body_hash (fw - // identity, matched against a delta base) is separate - surface BOTH (only body_hash showed before). - const OtaManager::ServeEntry* self = nullptr; - for (uint8_t i = 0; i < c.manager.servedCount(); i++) { - const OtaManager::ServeEntry* e = c.manager.servedEntry(i); - if (e && e->is_self) { self = e; break; } - } - SelfFwInfo fi; bool sok = ota_self_firmware_for_display(fi); - char midhx[9], bodyhx[9], verbuf[20], dighx[9]; - if (self) mesh::Utils::toHex(midhx, self->mid, 4); else strcpy(midhx, "?"); - if (sok && fi.valid) mesh::Utils::toHex(bodyhx, fi.body_hash, 4); else strcpy(bodyhx, "?"); - if (self) ver_str(verbuf, sizeof verbuf, self->fw_version); else strcpy(verbuf, "v?"); - uint8_t dig[4]; c.manager.servedDigest(dig); mesh::Utils::toHex(dighx, dig, 4); - OtaManager::FetchState fs = c.manager.fetchState(); - char fbuf[72]; - if (fs == OtaManager::IDLE) { - strcpy(fbuf, "fetch idle"); - } else { - char fmid[9]; mesh::Utils::toHex(fmid, c.manager.fetchManifestId(), 4); - unsigned have = (unsigned)c.manager.blocksHave(), tot = (unsigned)c.manager.blocksTotal(); - unsigned pct = tot ? (unsigned)((uint64_t)have * 100 / tot) : 0; - unsigned age = c.session_started_ms ? (unsigned)((millis() - c.session_started_ms) / 1000) : 0; - if (fs == OtaManager::FAILED) - snprintf(fbuf, sizeof fbuf, "fetch failed:%s %u/%u id=%s", - fetch_error_word(c.manager.fetchError()), have, tot, fmid); - else - snprintf(fbuf, sizeof fbuf, "fetch %s %u/%u %u%% id=%s %us", - state_short(fs), have, tot, pct, fmid, age); - } - uint8_t af = c.manager.autofetch(); - snprintf(reply, 160, "OTA | fw %s id=%s body=%s %ub %uK | serv %u dg=%s | %s | af=%s hops=%u", - verbuf, midhx, bodyhx, (unsigned)(self ? self->have_count : 0), - (unsigned)((sok ? fi.image_len : 0) / 1024), (unsigned)c.manager.servedCount(), dighx, fbuf, - af == OtaManager::AUTOFETCH_ANY ? "any" : af == OtaManager::AUTOFETCH_SIGNED ? "signed" : "off", - (unsigned)c.manager.max_hops()); - - // ---- what's available around me (catalogued from beacons + OTA_HAVE), best/most-recent first ---- - } else if (is_cmd(a, "neighbors|nbrs|updates|ls|n", &rest)) { +static __attribute__((noinline)) bool handle_neighbors( + const char* rest, char* reply, OtaContext& c) { // Kick a fresh round of catalog queries (async - rows arrive over the next seconds); render what we // have now in plain words. The reply buffer is 160 B (serial / one LoRa packet for remote-admin), so // the retained protocol catalog is rendered two rows at a time. @@ -472,6 +363,133 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board shown++; } if (shown == 0) strcpy(reply, "No updates seen yet - re-run `ota ls` in a few seconds (just asked around)."); + return true; +} + +static __attribute__((noinline)) bool handle_control_command( + const char* a, char* reply, mesh::MainBoard& board, OtaContext& c); + +bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board) { + const auto* active = ota_context_if_active(); + const float adaptive_pace = active ? active->manager.adaptivePacketSpeed() : OTA_SPEED_DEFAULT; + if (handleSpeedCommand(command, reply, 160, adaptive_pace)) return true; + 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(); + c.manager.set_clock(millis()); + c.manager.set_speed(speedFactor()); +#if defined(NRF52_PLATFORM) && defined(OTA_TOWER_AUTO_STORE) + c.prepareTowerStorage(); +#endif + const char* rest = a; + if (*a == 0 || is_cmd(a, "status|st", &rest)) return handle_status(reply, c); + if (is_cmd(a, "neighbors|nbrs|updates|ls|n", &rest)) + return handle_neighbors(rest, reply, c); + return handle_control_command(a, reply, board, c); +} + +static __attribute__((noinline)) bool handle_control_command( + const char* a, char* reply, mesh::MainBoard& board, OtaContext& c) { + const char* rest = a; + + // ---- raw / internal primitives, tucked under `ota dev ...` ---- + if (is_cmd(a, "dev", &rest)) { + return handle_dev(rest, reply, c); + } + + // ---- help: list the commands in plain words (aliases in parentheses) ---- + if (is_cmd(a, "help|?|h", &rest)) { +#if defined(OTA_SEEDER_ONLY) + snprintf(reply, 160, + "OTA seeder: status | stats | ls=find images | get folder=capture | cancel | " + "announce | folder | config. LoRa install is disabled."); +#elif defined(NRF52_PLATFORM) && defined(OTA_FLASH_STORE) && !defined(OTA_SD_STORE) && \ + !defined(OTA_QSPI_STORE) +#if defined(OTA_INTERNAL_BOOTLOADER_UPDATE) + strcpy(reply, + "OTA: status | stats | ls | get flash | install | " + "bootloader | cancel | announce | self | folder | config | key"); +#else + strcpy(reply, + "OTA: status | stats | ls | get flash [rescue] | install | rescue install | " + "cancel | announce | self | folder | config | key"); +#endif +#elif defined(NRF52_PLATFORM) && defined(OTA_RAK_AUTO_STORE) + strcpy(reply, + "OTA: status | stats | ls | get flash | install | bootloader | cancel | announce | self | serve | storage | folder | config | key"); +#elif defined(NRF52_PLATFORM) && defined(OTA_QSPI_STORE) +#if defined(OTA_QSPI_BOOTLOADER_UPDATE) + strcpy(reply, + "OTA: status | stats | ls | get flash | install | bootloader | cancel | announce | self | serve | " + "qspi | folder | config | key"); +#else + strcpy(reply, + "OTA: status | stats | ls | get flash | install | cancel | announce | self | serve | qspi | " + "folder | config | key"); +#endif +#elif defined(NRF52_PLATFORM) && defined(OTA_SD_STORE) +#if defined(OTA_TOWER_AUTO_STORE) + strcpy(reply, + "OTA: status | stats | ls | get flash | install | bootloader | cancel | announce | self | serve | " + "storage | folder | cache | config | key"); +#else +#if defined(OTA_SD_BOOTLOADER_UPDATE) + strcpy(reply, + "OTA: status | stats | ls | get flash | install | bootloader | cancel | announce | self | serve | " + "folder | cache | config | key"); +#else + strcpy(reply, + "OTA: status | stats | ls | get flash | install | cancel | announce | self | serve | folder | " + "cache | config | key"); +#endif +#endif +#else + snprintf(reply, 160, + "OTA: status | stats | ls | get flash | install | cancel | announce | self | serve | folder | " + "cache | config | key. `ota ls [page]`; folder [validate]."); +#endif + + // ---- admin OTA stats: crypto identities (our fw's content-id + body_hash), serving set, live fetch, + // policy - one dense line. The remote-admin CLI path is admin-gated, so this is admin-only over the + // mesh (send it from the app's repeater command screen, or the WiFi/serial OTA console). + } else if (is_cmd(a, "stats", &rest)) { + // our running fw's merkle content-id (mid) comes from the self serve entry; the EndF body_hash (fw + // identity, matched against a delta base) is separate - surface BOTH (only body_hash showed before). + const OtaManager::ServeEntry* self = nullptr; + for (uint8_t i = 0; i < c.manager.servedCount(); i++) { + const OtaManager::ServeEntry* e = c.manager.servedEntry(i); + if (e && e->is_self) { self = e; break; } + } + SelfFwInfo fi; bool sok = ota_self_firmware_for_display(fi); + char midhx[9], bodyhx[9], verbuf[20], dighx[9]; + if (self) mesh::Utils::toHex(midhx, self->mid, 4); else strcpy(midhx, "?"); + if (sok && fi.valid) mesh::Utils::toHex(bodyhx, fi.body_hash, 4); else strcpy(bodyhx, "?"); + if (self) ver_str(verbuf, sizeof verbuf, self->fw_version); else strcpy(verbuf, "v?"); + uint8_t dig[4]; c.manager.servedDigest(dig); mesh::Utils::toHex(dighx, dig, 4); + OtaManager::FetchState fs = c.manager.fetchState(); + char fbuf[72]; + if (fs == OtaManager::IDLE) { + strcpy(fbuf, "fetch idle"); + } else { + char fmid[9]; mesh::Utils::toHex(fmid, c.manager.fetchManifestId(), 4); + unsigned have = (unsigned)c.manager.blocksHave(), tot = (unsigned)c.manager.blocksTotal(); + unsigned pct = tot ? (unsigned)((uint64_t)have * 100 / tot) : 0; + unsigned age = c.session_started_ms ? (unsigned)((millis() - c.session_started_ms) / 1000) : 0; + if (fs == OtaManager::FAILED) + snprintf(fbuf, sizeof fbuf, "fetch failed:%s %u/%u id=%s", + fetch_error_word(c.manager.fetchError()), have, tot, fmid); + else + snprintf(fbuf, sizeof fbuf, "fetch %s %u/%u %u%% id=%s %us", + state_short(fs), have, tot, pct, fmid, age); + } + uint8_t af = c.manager.autofetch(); + snprintf(reply, 160, "OTA | fw %s id=%s body=%s %ub %uK | serv %u dg=%s | %s | af=%s hops=%u", + verbuf, midhx, bodyhx, (unsigned)(self ? self->have_count : 0), + (unsigned)((sok ? fi.image_len : 0) / 1024), (unsigned)c.manager.servedCount(), dighx, fbuf, + af == OtaManager::AUTOFETCH_ANY ? "any" : af == OtaManager::AUTOFETCH_SIGNED ? "signed" : "off", + (unsigned)c.manager.max_hops()); // ---- start fetching a specific catalogued mOTA (by list index or manifest_id) ---- } else if (is_cmd(a, "pull|get|download", &rest)) { diff --git a/src/helpers/radiolib/CustomLLCC68Wrapper.h b/src/helpers/radiolib/CustomLLCC68Wrapper.h index aca75fdbe..990c17acb 100644 --- a/src/helpers/radiolib/CustomLLCC68Wrapper.h +++ b/src/helpers/radiolib/CustomLLCC68Wrapper.h @@ -10,6 +10,7 @@ protected: int8_t readReceiveMode() override { return sx126xReceiveMode((SX126x*)_radio); } public: + ~CustomLLCC68Wrapper() { unregisterInterruptAction(); } CustomLLCC68Wrapper(CustomLLCC68& radio, mesh::MainBoard& board) : RadioLibWrapper(radio, board) { } void powerOff() { _radio->standby(); _radio->sleep(); } diff --git a/src/helpers/radiolib/CustomLR1110Wrapper.h b/src/helpers/radiolib/CustomLR1110Wrapper.h index 604d68064..884f47f51 100644 --- a/src/helpers/radiolib/CustomLR1110Wrapper.h +++ b/src/helpers/radiolib/CustomLR1110Wrapper.h @@ -13,6 +13,7 @@ class CustomLR1110Wrapper : public RadioLibWrapper { DeepInitCallback _deep_init; public: + ~CustomLR1110Wrapper() { unregisterInterruptAction(); } CustomLR1110Wrapper(CustomLR1110& radio, mesh::MainBoard& board) : RadioLibWrapper(radio, board), _deep_init(NULL) { } bool supportsCarrierWave() const override { return true; } diff --git a/src/helpers/radiolib/CustomLR1121Wrapper.h b/src/helpers/radiolib/CustomLR1121Wrapper.h index 0247a9690..eab21f7d9 100644 --- a/src/helpers/radiolib/CustomLR1121Wrapper.h +++ b/src/helpers/radiolib/CustomLR1121Wrapper.h @@ -6,6 +6,7 @@ class CustomLR1121Wrapper : public RadioLibWrapper { public: + ~CustomLR1121Wrapper() { unregisterInterruptAction(); } CustomLR1121Wrapper(CustomLR1121& radio, mesh::MainBoard& board) : RadioLibWrapper(radio, board) { } void powerOff() { diff --git a/src/helpers/radiolib/CustomLR2021.h b/src/helpers/radiolib/CustomLR2021.h index 6d0561688..625f62696 100644 --- a/src/helpers/radiolib/CustomLR2021.h +++ b/src/helpers/radiolib/CustomLR2021.h @@ -156,6 +156,13 @@ class CustomLR2021 : public LR2021 { } int16_t startReceive() override { + // setRxPath requires standby, including retries after failed read cleanup. + int16_t status = standby(); + if (status != RADIOLIB_ERR_NONE) return status; + // Failed cleanup can leave bytes behind; no new RX window may inherit + // the preceding FIFO even when the first discard attempt failed. + status = clearRxFifo(); + if (status != RADIOLIB_ERR_NONE) return status; // include the PREAMBLE_DETECTED irq bit in reported flags return LR2021::startReceive(RADIOLIB_LR2021_RX_TIMEOUT_INF, RADIOLIB_IRQ_RX_DEFAULT_FLAGS | (1UL << RADIOLIB_IRQ_PREAMBLE_DETECTED), RADIOLIB_IRQ_RX_DEFAULT_MASK, 0); } @@ -165,18 +172,10 @@ class CustomLR2021 : public LR2021 { uint32_t irq = getIrqStatus(); bool preamble = irq & RADIOLIB_LR2021_IRQ_PREAMBLE_DETECTED; // bit 5 bool header = irq & RADIOLIB_LR2021_IRQ_LORA_HEADER_VALID; // bit 6 - bool hdrErr = irq & RADIOLIB_LR2021_IRQ_LORA_HDR_CRC_ERROR; // bit 9 uint32_t now = millis(); - if (hdrErr) { - clearIrqFlags(RADIOLIB_LR2021_IRQ_PREAMBLE_DETECTED | RADIOLIB_LR2021_IRQ_LORA_HEADER_VALID | RADIOLIB_LR2021_IRQ_LORA_HDR_CRC_ERROR); - _activityAt = 0; - _headerSeen = false; - return false; - } if (!header && _headerSeen) { // something cleared the header flag, reset our state. _activityAt = 0; _headerSeen = false; - return false; } if (header) { @@ -203,6 +202,22 @@ class CustomLR2021 : public LR2021 { return false; } + int16_t readData(uint8_t* data, size_t len) override { + uint16_t fifo_level = 0; + const int16_t status = getRxFifoLevel(&fifo_level); + if (status != RADIOLIB_ERR_NONE) return status; + const size_t expected = len != 0 ? len : getPacketLength(); + if (fifo_level != expected || expected == 0) { + // RX_DONE with an empty FIFO, or overlapping continuous-RX packets, + // must not turn repeated padding or mixed bytes into a mesh packet. + // Consume the suspect FIFO so a subsequent clean frame can recover. + const int16_t cleared = clearRxFifo(); + if (cleared != RADIOLIB_ERR_NONE) return cleared; + return RADIOLIB_ERR_PACKET_TOO_SHORT; + } + return LR2021::readData(data, len); + } + void setPreambleMillis(uint32_t preambleMillis) { _preambleMillis = preambleMillis; MESH_DEBUG_PRINTLN("Set _preambleMillis=%u", _preambleMillis); diff --git a/src/helpers/radiolib/CustomLR2021Wrapper.h b/src/helpers/radiolib/CustomLR2021Wrapper.h index 2b3d88f8c..51ba6bab3 100644 --- a/src/helpers/radiolib/CustomLR2021Wrapper.h +++ b/src/helpers/radiolib/CustomLR2021Wrapper.h @@ -16,6 +16,7 @@ class CustomLR2021Wrapper : public RadioLibWrapper { public: + ~CustomLR2021Wrapper() { unregisterInterruptAction(); } CustomLR2021Wrapper(CustomLR2021& radio, mesh::MainBoard& board) : RadioLibWrapper(radio, board) { } void powerOff() { _radio->standby(); _radio->sleep(); } diff --git a/src/helpers/radiolib/CustomSTM32WLxWrapper.h b/src/helpers/radiolib/CustomSTM32WLxWrapper.h index ef4b21fbe..af3c94cbd 100644 --- a/src/helpers/radiolib/CustomSTM32WLxWrapper.h +++ b/src/helpers/radiolib/CustomSTM32WLxWrapper.h @@ -11,6 +11,7 @@ protected: int8_t readReceiveMode() override { return sx126xReceiveMode((SX126x*)_radio); } public: + ~CustomSTM32WLxWrapper() { unregisterInterruptAction(); } CustomSTM32WLxWrapper(CustomSTM32WLx& radio, mesh::MainBoard& board) : RadioLibWrapper(radio, board) { } protected: diff --git a/src/helpers/radiolib/CustomSX1262Wrapper.h b/src/helpers/radiolib/CustomSX1262Wrapper.h index fd87eee49..5e7bf95e6 100644 --- a/src/helpers/radiolib/CustomSX1262Wrapper.h +++ b/src/helpers/radiolib/CustomSX1262Wrapper.h @@ -35,6 +35,7 @@ protected: } public: + ~CustomSX1262Wrapper() { unregisterInterruptAction(); } CustomSX1262Wrapper(CustomSX1262& radio, mesh::MainBoard& board) : RadioLibWrapper(radio, board) { } bool supportsCarrierWave() const override { return true; } diff --git a/src/helpers/radiolib/CustomSX1268Wrapper.h b/src/helpers/radiolib/CustomSX1268Wrapper.h index a2cf3c213..0cff501de 100644 --- a/src/helpers/radiolib/CustomSX1268Wrapper.h +++ b/src/helpers/radiolib/CustomSX1268Wrapper.h @@ -14,6 +14,7 @@ protected: int8_t readReceiveMode() override { return sx126xReceiveMode((SX126x*)_radio); } public: + ~CustomSX1268Wrapper() { unregisterInterruptAction(); } CustomSX1268Wrapper(CustomSX1268& radio, mesh::MainBoard& board) : RadioLibWrapper(radio, board) { } void powerOff() { _radio->standby(); _radio->sleep(); } diff --git a/src/helpers/radiolib/CustomSX1276Wrapper.h b/src/helpers/radiolib/CustomSX1276Wrapper.h index 0f34fd6cd..faed96f2a 100644 --- a/src/helpers/radiolib/CustomSX1276Wrapper.h +++ b/src/helpers/radiolib/CustomSX1276Wrapper.h @@ -9,6 +9,7 @@ class CustomSX1276Wrapper : public RadioLibWrapper { public: + ~CustomSX1276Wrapper() { unregisterInterruptAction(); } CustomSX1276Wrapper(CustomSX1276& radio, mesh::MainBoard& board) : RadioLibWrapper(radio, board) { } bool supportsCarrierWave() const override { return supportsRadioDeepInit(); } diff --git a/src/helpers/radiolib/RadioInterruptEvents.h b/src/helpers/radiolib/RadioInterruptEvents.h new file mode 100644 index 000000000..c7cc04562 --- /dev/null +++ b/src/helpers/radiolib/RadioInterruptEvents.h @@ -0,0 +1,27 @@ +#pragma once + +#include + +// One radio ISR publishes; its owning loop consumes. Aligned word loads/stores +// are sufficient even on Cortex-M0, where an atomic exchange needs a lock. A +// mode transition never writes the producer's generation, so an IRQ arriving +// during claim remains pending. Hardware IRQ handlers do not reenter themselves. +class RadioInterruptEvents { + uint32_t _published = 0; + uint32_t _consumed = 0; + +public: + __attribute__((always_inline)) inline void record() { + const uint32_t next = __atomic_load_n(&_published, __ATOMIC_RELAXED) + 1; + __atomic_store_n(&_published, next, __ATOMIC_RELEASE); + } + bool pending() const { + return __atomic_load_n(&_published, __ATOMIC_ACQUIRE) != _consumed; + } + bool claim() { + const uint32_t current = __atomic_load_n(&_published, __ATOMIC_ACQUIRE); + const bool ready = current != _consumed; + _consumed = current; + return ready; + } +}; diff --git a/src/helpers/radiolib/RadioLibWrappers.cpp b/src/helpers/radiolib/RadioLibWrappers.cpp index 1d5dcd6de..108782bfe 100644 --- a/src/helpers/radiolib/RadioLibWrappers.cpp +++ b/src/helpers/radiolib/RadioLibWrappers.cpp @@ -1,12 +1,12 @@ #define RADIOLIB_STATIC_ONLY 1 #include "RadioLibWrappers.h" +#include "RadioInterruptEvents.h" #define STATE_IDLE 0 #define STATE_RX 1 #define STATE_TX_WAIT 3 #define STATE_TX_DONE 4 -#define STATE_INT_READY 16 // Bounded noise-floor calibration in continuous RX, including RXPS refreshes. // Requests are coalesced so retries cannot repeatedly force continuous RX. @@ -19,7 +19,24 @@ // sample set every fifteen minutes instead. #define NF_FAST_PROFILE_REFRESH_INTERVAL_MS (15UL * 60UL * 1000UL) -static volatile uint8_t state = STATE_IDLE; +// RadioLib callbacks carry no context. The internal STM32WLx radio needs one +// physical slot; other classes retain two, including external STM32 radios. +// Dual-profile scanning uses one slot. ISR storage has static lifetime, so a +// callback already in flight cannot access a destroyed/rebound wrapper. +#define RADIO_INTERRUPT_INTERNAL_CustomSTM32WLx 1 +#define RADIO_INTERRUPT_INTERNAL_(radio) RADIO_INTERRUPT_INTERNAL_##radio +#define RADIO_INTERRUPT_INTERNAL(radio) RADIO_INTERRUPT_INTERNAL_(radio) +#if RADIO_INTERRUPT_INTERNAL(RADIO_CLASS) + #define RADIO_INTERRUPT_SLOT_COUNT 1 +#else + #define RADIO_INTERRUPT_SLOT_COUNT 2 +#endif +#undef RADIO_INTERRUPT_INTERNAL +#undef RADIO_INTERRUPT_INTERNAL_ +#undef RADIO_INTERRUPT_INTERNAL_CustomSTM32WLx +static RadioInterruptEvents radio_interrupt_events[RADIO_INTERRUPT_SLOT_COUNT]; +static RadioLibWrapper* radio_interrupt_owners[RADIO_INTERRUPT_SLOT_COUNT] = {}; + // The Adafruit nRF52 core's micros() falls back to 1024 Hz RTOS ticks when // DWT is disabled. A sub-millisecond retune then appears to take exactly @@ -66,15 +83,76 @@ static uint32_t profileElapsedUs(uint32_t since, uint32_t now, bool dual_profile #endif } -// this function is called when a complete packet -// is transmitted by the module static #if defined(ESP8266) || defined(ESP32) ICACHE_RAM_ATTR #endif -void setFlag(void) { - // we sent a packet, set the flag - state |= STATE_INT_READY; +void setFlag0() { radio_interrupt_events[0].record(); } +#if RADIO_INTERRUPT_SLOT_COUNT > 1 +static +#if defined(ESP8266) || defined(ESP32) + ICACHE_RAM_ATTR +#endif +void setFlag1() { radio_interrupt_events[1].record(); } +#endif + +bool RadioLibWrapper::registerInterruptAction() { + _loop_event_pending = false; +#if RADIO_INTERRUPT_SLOT_COUNT == 1 + // There is only one integrated radio. Ownership is still checked so a + // second wrapper cannot steal its callback or consume its pending event. + if (radio_interrupt_owners[0] != nullptr && radio_interrupt_owners[0] != this) { + _interrupt_slot = 0xff; + return false; + } + radio_interrupt_owners[0] = this; + _interrupt_slot = 0; + _radio->clearPacketReceivedAction(); + radio_interrupt_events[0].claim(); + _radio->setPacketReceivedAction(setFlag0); +#else + if (_interrupt_slot >= RADIO_INTERRUPT_SLOT_COUNT || radio_interrupt_owners[_interrupt_slot] != this) { + _interrupt_slot = 0xff; + for (uint8_t i = 0; i < RADIO_INTERRUPT_SLOT_COUNT; ++i) { + if (radio_interrupt_owners[i] == nullptr) { + radio_interrupt_owners[i] = this; + _interrupt_slot = i; + break; + } + } + } + if (_interrupt_slot >= RADIO_INTERRUPT_SLOT_COUNT) return false; + _radio->clearPacketReceivedAction(); + radio_interrupt_events[_interrupt_slot].claim(); + _radio->setPacketReceivedAction(_interrupt_slot == 0 ? setFlag0 : setFlag1); +#endif + return true; +} + +void RadioLibWrapper::unregisterInterruptAction() { +#if RADIO_INTERRUPT_SLOT_COUNT == 1 + if (radio_interrupt_owners[0] == this) { + _radio->clearPacketReceivedAction(); + radio_interrupt_owners[0] = nullptr; + } +#else + if (_interrupt_slot < RADIO_INTERRUPT_SLOT_COUNT && radio_interrupt_owners[_interrupt_slot] == this) { + _radio->clearPacketReceivedAction(); + radio_interrupt_owners[_interrupt_slot] = nullptr; + } +#endif + _interrupt_slot = 0xff; +} + +bool RadioLibWrapper::hasPendingRadioInterrupt() const { + return _loop_event_pending || (_interrupt_slot < RADIO_INTERRUPT_SLOT_COUNT && radio_interrupt_events[_interrupt_slot].pending()); +} + +bool RadioLibWrapper::claimRadioInterrupt() { + const bool startup = _loop_event_pending; + _loop_event_pending = false; + const bool interrupt = _interrupt_slot < RADIO_INTERRUPT_SLOT_COUNT && radio_interrupt_events[_interrupt_slot].claim(); + return startup || interrupt; } void RadioLibWrapper::begin() { @@ -84,7 +162,10 @@ void RadioLibWrapper::begin() { // still initializes lazily if an earlier crypto operation runs first. (void) mesh::initializeCC310Crypto(); #endif - _radio->setPacketReceivedAction(setFlag); // this is also SentComplete interrupt + if (!registerInterruptAction()) { + MESH_DEBUG_PRINTLN("RadioLibWrapper: no interrupt callback slot"); + return; + } _radio->setPreambleLength(currentPreambleLength()); // longer preamble for lower SF improves reliability #if defined(LORA_FREQ) && defined(LORA_BW) && defined(LORA_SF) if (!_params_valid) { @@ -109,7 +190,7 @@ void RadioLibWrapper::begin() { state = STATE_IDLE; if (_board->getStartupReason() == BD_STARTUP_RX_PACKET) { // received a LoRa packet (while in deep sleep) - setFlag(); // LoRa packet is already received + _loop_event_pending = true; // Main-owned wake event; ISR remains sole publisher } _noise_floor = 0; @@ -176,11 +257,11 @@ bool RadioLibWrapper::setTxPower(int8_t dbm) { uint8_t RadioLibWrapper::beginReconfigure() { if (_cw_active) return 2; - const uint8_t base_state = state & ~STATE_INT_READY; + const uint8_t base_state = state; // On SX126x/LR11xx duty-cycle RX, BUSY may remain asserted during the sleep // side of the cycle. Do not issue an IRQ/preamble query over SPI then; due // scheduled work will retry as soon as the next safe listen window opens. - if ((state & STATE_INT_READY) != 0 || base_state == STATE_TX_WAIT + if (hasPendingRadioInterrupt() || base_state == STATE_TX_WAIT || isChipBusy() || isReceivingPacket()) { return 2; } @@ -191,6 +272,10 @@ uint8_t RadioLibWrapper::beginReconfigure() { } else if (resume_rx) { _radio->standby(); } + // An RX completion during standby still owns its FIFO and old profile. + // Defer reconfiguration until recvRaw consumes it, rather than rearming and + // clearing that completion as part of endReconfigure(). + if (hasPendingRadioInterrupt()) return 2; state = STATE_IDLE; return resume_rx; } @@ -204,7 +289,7 @@ bool RadioLibWrapper::restoreAfterDeepInit() { _rx_ps_armed = false; state = STATE_IDLE; - _radio->setPacketReceivedAction(setFlag); + if (!registerInterruptAction()) return false; bool restored; if (_params_valid) { @@ -663,8 +748,8 @@ void RadioLibWrapper::resetAGC() { bool RadioLibWrapper::recoverRadio(bool hard) { if (_cw_active) return false; - const uint8_t base_state = state & ~STATE_INT_READY; - if ((state & STATE_INT_READY) != 0 || base_state == STATE_TX_WAIT) return false; + const uint8_t base_state = state; + if (hasPendingRadioInterrupt() || base_state == STATE_TX_WAIT) return false; #ifdef RADIO_LIVENESS_SOFT_ONLY // STM32WL integrates the radio into the MCU and has no independent reset. @@ -709,7 +794,7 @@ void RadioLibWrapper::rxPsWatchdogCheck() { // don't interfere mid-transmit or with a completed-but-unread packet // (a pending DIO1 event is itself proof the radio is alive; recvRaw() will // re-arm and re-base the watchdog) - if ((state & STATE_INT_READY) != 0 || (state & ~STATE_INT_READY) == STATE_TX_WAIT) { + if (hasPendingRadioInterrupt() || state == STATE_TX_WAIT) { _wd_observe_until = 0; return; } @@ -780,15 +865,11 @@ void RadioLibWrapper::rxPsWatchdogCheck() { void RadioLibWrapper::requestRestartRecv() { // An RX interrupt can arrive between the caller's idle check and this state // transition. Preserve that flag so the completed packet is still consumed. - noInterrupts(); - if ((state & ~STATE_INT_READY) != STATE_TX_WAIT) { - state &= STATE_INT_READY; - } - interrupts(); + if (state != STATE_TX_WAIT) state = STATE_IDLE; } bool RadioLibWrapper::isPacketPendingOrReceiving() { - return (state & STATE_INT_READY) != 0 || isReceivingPacket(); + return hasPendingRadioInterrupt() || isReceivingPacket(); } void RadioLibWrapper::noiseFloorCalibCheck(unsigned long now) { @@ -828,7 +909,7 @@ void RadioLibWrapper::endNoiseFloorCalib(unsigned long now) { // force a receive re-arm back into duty-cycle mode, but don't clobber a // completed-but-unread packet or an in-flight TX (recvRaw()/onSendFinished() // will re-arm right after those anyway; same guard style as setRxPowerSaving) - if ((state & ~STATE_INT_READY) != STATE_TX_WAIT + if (state != STATE_TX_WAIT && !isPacketPendingOrReceiving()) { requestRestartRecv(); } @@ -924,11 +1005,14 @@ bool RadioLibWrapper::stopCarrierWave() { _cw_retry_at = static_cast(millis()) + 100; return false; // hold normal work until stopping the carrier is confirmed } + if (!registerInterruptAction()) { + _cw_retry_at = static_cast(millis()) + 100; + return false; + } _cw_active = false; _cw_stopping = false; _rx_ps_armed = false; state = STATE_IDLE; - _radio->setPacketReceivedAction(setFlag); _profile_refresh_required = true; const uint8_t restore = _profiles.enabled() ? _cw_restore_profile : 0; const bool tuned = tuneProfile(restore) == mesh::RadioParamApplyResult::APPLIED; @@ -1103,9 +1187,7 @@ void RadioLibWrapper::startRecv() { // A very short frame may complete while startReceiveMode() returns. _profile_visit_stamp = profileTimestamp(_profiles.enabled()); // Retain that RX interrupt instead of overwriting it with software state. - noInterrupts(); - state = (state & STATE_INT_READY) | STATE_RX; - interrupts(); + state = STATE_RX; // RSSI is unsettled after every RX entry, including CAD/TX completion. // Keep the partial block, but wait for the frontend before its next sample. _nf_sample_from = millis() + NF_CALIB_SETTLE_MS; @@ -1145,18 +1227,25 @@ void RadioLibWrapper::stopReceiveDutyCycle() { } bool RadioLibWrapper::isPacketReady() { - if (!_rx_ps_armed) return true; // continuous RX: DIO1 only fires for RxDone/TxDone here - // In duty-cycle RX the DIO1 interrupt also fires for RX timeout (false // preamble detect) and header errors. GetRxBufferStatus still reports the // *previous* packet's length then, so reading the buffer would re-deliver // stale bytes as a ghost packet. Only read when the radio reports RxDone. - // (checkIrq errors are treated as ready, falling back to old behaviour.) - return _radio->checkIrq(RADIOLIB_IRQ_RX_DONE) != 0; + // An unsupported probe retains the legacy event-only behavior. + const int16_t ready = _radio->checkIrq(RADIOLIB_IRQ_RX_DONE); + if (ready <= 0 && ready != RADIOLIB_ERR_UNSUPPORTED) { + // DIO can remain high after a transient SPI error, so no second edge will + // wake us. Retry the probe without treating the error as a valid packet. + _loop_event_pending = ++_irq_probe_failures < 3; + if (!_loop_event_pending) _irq_probe_failures = 0; + return false; + } + _irq_probe_failures = 0; + return ready > 0 || ready == RADIOLIB_ERR_UNSUPPORTED; } bool RadioLibWrapper::isInRecvMode() const { - return (state & ~STATE_INT_READY) == STATE_RX; + return state == STATE_RX; } // RX PowerSaving @@ -1187,9 +1276,23 @@ bool RadioLibWrapper::setRxPowerSaving(bool enabled, uint32_t rx_us, uint32_t sl int RadioLibWrapper::recvRaw(uint8_t* bytes, int sz) { if (_cw_active) return 0; int len = 0; - if (state & STATE_INT_READY) { + #if defined(USE_LR2021) + bool receiver_stopped = false; + #endif + if (claimRadioInterrupt()) { last_radio_interrupt_millis = millis(); // ISR fired -> radio hardware is alive if (isPacketReady()) { + #if defined(USE_LR2021) + // The LR2021 base reader clears FIFO/IRQs while otherwise staying in + // continuous RX. Freeze reception before taking length/status/FIFO + // snapshots so a new packet cannot be cleared halfway through the read. + if (_radio->standby() != RADIOLIB_ERR_NONE) { + n_recv_errors++; + state = STATE_IDLE; + return 0; + } + receiver_stopped = true; + #endif if (_rx_ps_armed) { // RxDone stops the active receive window, but the duty-cycle RTC can // still be running. Stop it before reading and re-arming another mode. @@ -1214,11 +1317,21 @@ int RadioLibWrapper::recvRaw(uint8_t* bytes, int sz) { } } } + // Retry a transient IRQ-status probe before rearming/clearing hardware. + if (_loop_event_pending) return 0; #if defined(USE_LR2021) - state = STATE_RX; // LR2021 stays in Rx after readData, calling startReceive while still in Rx throws -706 errors - #else - state = STATE_IDLE; // need another startReceive() + // An early FIFO/SPI failure must not leave RX_DONE asserted. Standby also + // covers spurious events for which no packet was read. No new hardware RX + // event can arrive during this acknowledgement; rearm happens afterwards. + if ((!receiver_stopped && _radio->standby() != RADIOLIB_ERR_NONE) + || static_cast(_radio)->clearRxFifo() != RADIOLIB_ERR_NONE + || _radio->clearIrqFlags(RADIOLIB_LR2021_IRQ_RX_DONE) != RADIOLIB_ERR_NONE) { + n_recv_errors++; + state = STATE_IDLE; + return 0; + } #endif + state = STATE_IDLE; // need another startReceive() } if (len > 0 && _rx_ps_enabled && !_rx_ps_continuous_fallback) { @@ -1247,13 +1360,13 @@ int RadioLibWrapper::recvRaw(uint8_t* bytes, int sz) { void RadioLibWrapper::finishReceiveProcessing() { if (!_rx_hold_continuous) return; - if ((state & ~STATE_INT_READY) == STATE_TX_WAIT) { + if (state == STATE_TX_WAIT) { _rx_hold_continuous = false; return; } // A second packet may have completed while Dispatcher handled the first. // Leave continuous RX intact until recvRaw() consumes that pending packet. - if ((state & STATE_INT_READY) != 0 || isReceivingPacket()) { + if (hasPendingRadioInterrupt() || isReceivingPacket()) { return; } @@ -1277,7 +1390,7 @@ uint32_t RadioLibWrapper::getEstAirtimeFor(int len_bytes) { } bool RadioLibWrapper::startSendRaw(const uint8_t* bytes, int len) { - if (_cw_active) return false; + if (_cw_active || hasPendingRadioInterrupt()) return false; _rx_hold_continuous = false; if (_rx_ps_armed) { // stop the duty-cycle sequencer before SetTx, otherwise its next RTC @@ -1285,6 +1398,11 @@ bool RadioLibWrapper::startSendRaw(const uint8_t* bytes, int len) { stopReceiveDutyCycle(); } _board->onBeforeTransmit(); + if (hasPendingRadioInterrupt()) { + _board->onAfterTransmit(); + return false; + } + _irq_probe_failures = 0; int err = _radio->startTransmit((uint8_t *) bytes, len); if (err == RADIOLIB_ERR_NONE) { state = STATE_TX_WAIT; @@ -1302,7 +1420,15 @@ bool RadioLibWrapper::startSendRaw(const uint8_t* bytes, int len) { } bool RadioLibWrapper::isSendComplete() { - if (state & STATE_INT_READY) { + if (state == STATE_TX_WAIT && claimRadioInterrupt()) { + const int16_t done = _radio->checkIrq(RADIOLIB_IRQ_TX_DONE); + if (done <= 0 && done != RADIOLIB_ERR_UNSUPPORTED) { + // getIrqFlags() may hide a failed SPI read as zero. Once DIO fired, + // keep probing until TX_DONE is observed or Dispatcher expires this TX. + _loop_event_pending = true; + return false; + } + _irq_probe_failures = 0; state = STATE_IDLE; n_sent++; return true; @@ -1312,6 +1438,10 @@ bool RadioLibWrapper::isSendComplete() { void RadioLibWrapper::onSendFinished() { _radio->finishTransmit(); + // Receive has not been rearmed yet: these are completion/probe events for + // the retired TX. They must not block the Dispatcher's timeout recovery. + claimRadioInterrupt(); + _irq_probe_failures = 0; _board->onAfterTransmit(); state = STATE_IDLE; } @@ -1347,7 +1477,7 @@ int16_t RadioLibWrapper::performChannelScanWithTimeout(unsigned long timeout_ms) bool RadioLibWrapper::isChannelActive() { if (_cw_active) return true; - if (isPacketPendingOrReceiving() || (state & ~STATE_INT_READY) == STATE_TX_WAIT) return true; + if (isPacketPendingOrReceiving() || state == STATE_TX_WAIT) return true; // int.thresh: RSSI-based interference detection (relative to noise floor). // In RX duty-cycle mode only checked while the chip is in a listen window // (during the sleep window the frontend is off and the read would stall). @@ -1362,12 +1492,12 @@ bool RadioLibWrapper::isChannelActive() { stopReceiveDutyCycle(); } int16_t result = performChannelScan(); - // scanChannel() triggers DIO interrupt (CAD done) which sets STATE_INT_READY - // via setFlag() ISR. Clear it before restarting RX so recvRaw() doesn't + // CAD also publishes an IRQ event. Consume it before restarting RX so recvRaw() doesn't // try to read a non-existent packet and count a spurious recv error. + claimRadioInterrupt(); state = STATE_IDLE; startRecv(); - if (result != RADIOLIB_CHANNEL_FREE || (state & ~STATE_INT_READY) != STATE_RX + if (result != RADIOLIB_CHANNEL_FREE || state != STATE_RX || isPacketPendingOrReceiving()) { _board->n_cad_busy++; return true; diff --git a/src/helpers/radiolib/RadioLibWrappers.h b/src/helpers/radiolib/RadioLibWrappers.h index e8dc6784b..f92329375 100644 --- a/src/helpers/radiolib/RadioLibWrappers.h +++ b/src/helpers/radiolib/RadioLibWrappers.h @@ -29,6 +29,15 @@ struct PacketMillis { class RadioLibWrapper : public mesh::Radio { protected: + // Mode is owned by this loop, not by the ISR or another wrapper. + uint8_t state = 0; + uint8_t _interrupt_slot = 0xff; + bool _loop_event_pending = false; + uint8_t _irq_probe_failures = 0; + bool registerInterruptAction(); + void unregisterInterruptAction(); + bool hasPendingRadioInterrupt() const; + bool claimRadioInterrupt(); PhysicalLayer* _radio; mesh::MainBoard* _board; uint32_t n_recv, n_sent, n_recv_errors; @@ -217,7 +226,7 @@ protected: void checkReceiveMode(uint32_t now); virtual void doResetAGC(); -public: +protected: RadioLibWrapper(PhysicalLayer& radio, mesh::MainBoard& board) : _radio(&radio), _board(&board), _noise_floor_valid(false), _nf_refresh_requested(true), _rx_ps_enabled(false), _rx_ps_armed(false), @@ -237,6 +246,12 @@ public: _cad_scan_timeout_override_ms = 0; } + // Concrete wrappers detach their callbacks before base destruction. Keep + // this destructor trivial so compact targets do not need a base vtable; a + // protected lifetime also rejects deletion through a borrowed base pointer. + ~RadioLibWrapper() = default; + +public: void begin() override; mesh::RadioParamApplyResult setCarrierWave(uint8_t profile, uint32_t duration_ms) override; bool isCarrierWaveActive() const override { return _cw_active; } diff --git a/src/helpers/ui/SSD1306Display.cpp b/src/helpers/ui/SSD1306Display.cpp index 0aaa05846..3905dcb92 100644 --- a/src/helpers/ui/SSD1306Display.cpp +++ b/src/helpers/ui/SSD1306Display.cpp @@ -1,14 +1,14 @@ #include "SSD1306Display.h" -#if defined(NRF52_PLATFORM) && defined(RAK4631_COMBINED_ETHERNET) +#if defined(ESP32_PLATFORM) || (defined(NRF52_PLATFORM) && defined(RAK4631_COMBINED_ETHERNET)) // Adafruit_SSD1306::begin allocates WIDTH * ((HEIGHT + 7) / 8) once and // reuses that framebuffer on later begin calls. The driver itself is embedded // in the linked global display, not allocated on the heap. Even including -// that object again plus allocator overhead fits the combined 2 KiB bound. +// that object again plus allocator overhead fits the 2 KiB runtime bound. static_assert(SSD1306Display::FRAMEBUFFER_BYTES == 1024, - "Update the combined RAK OLED framebuffer budget"); + "Update the SSD1306 OLED framebuffer budget"); static_assert(sizeof(SSD1306Display) + SSD1306Display::FRAMEBUFFER_BYTES + 32 <= 2048, - "Update the combined RAK OLED runtime budget"); + "Update the SSD1306 OLED runtime budget"); #endif bool SSD1306Display::i2c_probe(TwoWire& wire, uint8_t addr) { diff --git a/test/README.md b/test/README.md index 763f77b39..1d4ff0201 100644 --- a/test/README.md +++ b/test/README.md @@ -14,6 +14,14 @@ pio test -e native # all suites except KISS modem pio test -e native_kiss_modem # KISS modem suite pio test -e native -f test_webconfig_keys # a single suite python3 test/test_radio_receive_contract.py # real CAD/re-arm/calibration/mode-watchdog methods +python3 -B test/test_protocol_parser_bounds.py -v # malformed anonymous requests/adverts, ASan/UBSan +python3 -B test/test_companion_offline_delivery.py -v # full/disconnected requester and shared inbox delivery +python3 -B test/test_companion_lfs_traversal.py -v # raw LittleFS traversal lock and error cleanup +python3 -B test/test_room_post_permissions.py -v # all ACL roles, revocation and exact retry ACKs +python3 -B test/test_contact_clock_bootstrap.py -v # future contact corruption and long-lived clock bounds +python3 -B test/test_rtc_discovery.py -v # register screening, I2C faults and fallback without device writes +python3 -B test/test_radio_interrupt_recovery.py -v # real ISR handoff, RX/TX races, LR2021 FIFO/IRQ recovery +python3 -B test/test_bridge_recovery.py -v # packet-pool exhaustion, ESP-NOW start failure and callback timeout python3 test/test_sx126x_receive_mode.py # pinned RadioLib SPI transport/status-byte regression python3 test/test_firmware_ram.py # every firmware hook, ELF heaps, reservations, RAM report binding python3 test/test_indicator_display_profile.py # Indicator RAM/scale contract @@ -297,3 +305,14 @@ unbonded-central rejection test, or a true all-power-removed cold boot. See the - Keep logic testable by extracting pure functions into headers (as `MQTTObserverValidation.h` / `WebConfigKeys.h` / `MQTTTopicTemplate.h` do) and having the firmware call the same functions. +- nRF52 build recipes: `python3 -B test/test_nrf52_size_profile.py -v` sources + the real shell helper without running PlatformIO. It checks repeater, Full + Companion, Ethernet, and constrained OTA eligibility; the effective `-Oz` + optimizer; retained features and the 8 KiB stack; unchanged excluded targets; + the existing measured flood-table limits; and lossless target-name compression + scoped to nRF52 Full Companion seeders. `test/test_ota_target_names.py` verifies + every decoded name, target ID, bounded chunk, and malformed-record rejection. + `test/test_ota_cli_listing.py` runs the actual OTA status/listing dispatcher + with plain and compressed tables in seeder and receiver profiles. It checks + all target names, bounded replies, pagination and aliases, and keeps decoder + calls separate from unrelated control-command stack buffers. diff --git a/test/fixtures/espnow_wifi_handoff.cpp b/test/fixtures/espnow_wifi_handoff.cpp index 7787b37e3..d4c9ca9f3 100644 --- a/test/fixtures/espnow_wifi_handoff.cpp +++ b/test/fixtures/espnow_wifi_handoff.cpp @@ -169,6 +169,8 @@ public: struct { void clear(const int*) {} } _seen_packets; static void recv_cb() {} static void send_cb() {} + uint32_t _tx_started_at = 0, _tx_retry_at = 0; + bool _sdk_teardown_failed = false; void begin(); void end(); }; diff --git a/test/fixtures/ota_cli_listing/test.cpp b/test/fixtures/ota_cli_listing/test.cpp new file mode 100644 index 000000000..119d7e03d --- /dev/null +++ b/test/fixtures/ota_cli_listing/test.cpp @@ -0,0 +1,217 @@ +#include +#include +#include +#include +#include +#include "helpers/ota/OtaTargets.h" + +namespace mesh { +struct MainBoard {}; +struct Utils { + static void toHex(char* out, const uint8_t* bytes, size_t length) { + static const char hex[] = "0123456789abcdef"; + for (size_t i = 0; i < length; ++i) { + out[2 * i] = hex[bytes[i] >> 4]; out[2 * i + 1] = hex[bytes[i] & 15]; + } + out[2 * length] = 0; + } +}; +namespace ota { +constexpr unsigned MFLAG_BOOTLOADER = 8, CODEC_FULL = 0, MOTA_FORMAT_VER = 2; +constexpr float OTA_SPEED_DEFAULT = 1.0f; +struct FwVersion { + unsigned major, minor, patch, prerelease; + static FwVersion unpack(uint32_t value) { + return {value >> 24, (value >> 16) & 255, (value >> 8) & 255, value & 255}; + } +}; +struct OtaBlCaps { + bool present = true; + uint8_t apply_abi = MOTA_FORMAT_VER; + uint16_t codec_mask = 1; +}; +struct SelfFwInfo { + bool valid = true; + uint8_t body_hash[8] = {0}; + uint32_t image_len = 1024; +}; +struct OtaManager { + enum FetchState { IDLE, WANT_MANIFEST, WANT_LEAVES, VERIFYING_STAGED, FETCHING, COMPLETE, PAUSED, FAILED }; + enum FetchError { FETCH_ERROR_MANIFEST, FETCH_ERROR_HASH_ALGO, FETCH_ERROR_VERSION, + FETCH_ERROR_CODEC, FETCH_ERROR_GEOMETRY, FETCH_ERROR_TOO_LARGE, FETCH_ERROR_STORAGE, + FETCH_ERROR_INTEGRITY, FETCH_ERROR_MANIFEST_TIMEOUT, FETCH_ERROR_LEAVES_TIMEOUT }; + struct CatRow { + uint8_t mid[4] = {1, 2, 3, 4}; + uint32_t target_id = 0, fw_version = 0x01020300, last_ms = 9000; + uint8_t flags = 0, codec = CODEC_FULL, n_seeders = 2; + }; + CatRow rows[4]; + unsigned count = 0, queries = 0, clock_calls = 0, speed_calls = 0; + uint32_t target_id = 0; + FetchState state = IDLE; + bool codec_supported = true; + uint8_t served = 3; + float adaptivePacketSpeed() const { return 2.5f; } + void set_clock(uint32_t value) { assert(value == 10000); ++clock_calls; } + void set_speed(float value) { assert(value == 1.25f); ++speed_calls; } + void queryAll() { ++queries; } + uint16_t catalogCount() const { return count; } + unsigned sourceCount() const { return 2; } + FetchState fetchState() const { return state; } + const uint8_t* fetchManifestId() const { return rows[0].mid; } + uint32_t target() const { return target_id; } + const CatRow* catalogRow(uint16_t index) const { return index < count ? &rows[index] : nullptr; } + bool codecOk(uint8_t) const { return codec_supported; } + unsigned servedCount() const { return served; } + void servedDigest(uint8_t* digest) const { memcpy(digest, rows[0].mid, 4); } + unsigned blocksHave() const { return 1; } + unsigned blocksTotal() const { return 2; } + FetchError fetchError() const { return FETCH_ERROR_STORAGE; } + bool fetched_is_bootloader() const { return false; } +}; +struct OtaContext { + OtaManager manager; + OtaBlCaps caps; + bool folder_active = false, serving = true; + const char* folder_dest_info = "test-folder"; + uint32_t session_started_ms = 1000; + char hw_id[2] = "r"; + struct { unsigned count() const { return 1; } } allow; + const OtaBlCaps& bootloaderAppCaps() const { return caps; } +}; +static OtaContext context; +static bool available = true, speed_consumed = false; +static unsigned acquisitions = 0, speed_commands = 0, control_commands = 0; +static float last_adaptive; +static const OtaContext* ota_context_if_active() { return available ? &context : nullptr; } +static bool ota_acquire_context(char* reply, size_t capacity) { + ++acquisitions; + if (!available) snprintf(reply, capacity, "ERR context unavailable"); + return available; +} +static OtaContext& ota_ctx() { return context; } +static bool handleSpeedCommand(const char*, char*, size_t, float pace) { + ++speed_commands; last_adaptive = pace; return speed_consumed; +} +static float speedFactor() { return 1.25f; } +static unsigned millis() { return 10000; } +static unsigned ota_max_block_capability() { return 128; } +static bool ota_self_firmware_for_display(SelfFwInfo& info) { info = SelfFwInfo{}; return true; } +static uint8_t ota_bootloader_last_rc() { return 0; } +static bool ota_nrf52_boot_update_result(uint8_t) { return false; } + +// @PRODUCTION_FUNCTIONS@ + +static __attribute__((noinline)) bool handle_control_command( + const char* command, char* reply, mesh::MainBoard&, OtaContext&) { + ++control_commands; + snprintf(reply, 160, "control:%s", command); + return true; +} + +struct Reply { + unsigned char before = 0xa5; + char text[160]; + unsigned char after = 0x5a; + Reply() { memset(text, 0xcc, sizeof text); } + void checked() const { + assert(before == 0xa5 && after == 0x5a); + assert(memchr(text, 0, sizeof text)); + } +}; +static mesh::MainBoard board; +static void invoke(const char* command, Reply& reply) { + assert(handle_ota_command(command, reply.text, board)); + reply.checked(); +} +static void reset() { + context = OtaContext{}; available = true; speed_consumed = false; + acquisitions = speed_commands = control_commands = 0; +} +static void dispatch_tests() { + reset(); Reply reply; + speed_consumed = true; + assert(handle_ota_command("ota speed", reply.text, board)); + assert(acquisitions == 0 && last_adaptive == 2.5f); + speed_consumed = false; + assert(!handle_ota_command("otas", reply.text, board)); + assert(acquisitions == 0); + available = false; + invoke("ota ls", reply); + assert(strcmp(reply.text, "ERR context unavailable") == 0); + assert(last_adaptive == OTA_SPEED_DEFAULT && context.manager.queries == 0); + reset(); + const char* statuses[] = {"ota", "ota status", "ota st", "ota status"}; + for (const char* command : statuses) { + invoke(command, reply); +#if defined(OTA_SEEDER_ONLY) + assert(strstr(reply.text, "OTA seeder | install:disabled") == reply.text); +#else + assert(strstr(reply.text, "OTA | this fw") == reply.text); + assert(strstr(reply.text, "target:00000000 | maxblk:128")); +#endif + } + const char* aliases[] = {"ota neighbors", "ota nbrs", "ota updates", "ota ls", "ota n"}; + for (const char* command : aliases) { + invoke(command, reply); + assert(strstr(reply.text, "No updates seen yet")); + } + assert(context.manager.queries == 5); + assert(context.manager.clock_calls == 9 && context.manager.speed_calls == 9); + const char* controls[] = {"ota help", "ota stats", "ota dev verify", "ota pull 1 folder", "ota unknown"}; + for (const char* command : controls) { + invoke(command, reply); + assert(strstr(reply.text, "control:") == reply.text); + } + assert(control_commands == 5 && acquisitions == 14); +} +static void listing_tests() { + reset(); Reply reply; + const char* invalid[] = {"ota ls 0", "ota ls 256", "ota ls -1", "ota ls 1x", "ota ls 1 2", "ota ls 999999999999999999999"}; + for (const char* command : invalid) { + invoke(command, reply); + assert(strcmp(reply.text, "ERR usage: ota ls [page]") == 0); + } + assert(context.manager.queries == 0); + invoke("ota ls 2", reply); + assert(strcmp(reply.text, "ERR update page 2 out of range (1-1)") == 0); + context.manager.count = 3; + for (unsigned i = 0; i < 3; ++i) context.manager.rows[i].mid[0] = i + 1; + invoke("ota ls 1", reply); + assert(strstr(reply.text, "Updates 1/2") && strstr(reply.text, "1) 01020304") && strstr(reply.text, "2) 02020304")); + assert(!strstr(reply.text, "3) 03020304")); + invoke("ota ls 2", reply); + assert(strstr(reply.text, "Updates 2/2") && strstr(reply.text, "3) 03020304")); + context.manager.count = 1; + const OtaManager::FetchState states[] = {OtaManager::COMPLETE, OtaManager::PAUSED, OtaManager::FAILED, OtaManager::FETCHING}; + for (OtaManager::FetchState state : states) { + context.manager.state = state; invoke("ota ls", reply); + const char* tag = state == OtaManager::COMPLETE ? "[ready]" : state == OtaManager::PAUSED ? "[paused]" : state == OtaManager::FAILED ? "[failed]" : "[downloading]"; + assert(strstr(reply.text, tag)); + } + context.manager.state = OtaManager::IDLE; + context.manager.rows[0].target_id = 0xffffffff; + invoke("ota ls", reply); assert(strstr(reply.text, "[hw FFFFFFFF]")); + context.manager.target_id = 0xffffffff; + invoke("ota ls", reply); assert(strstr(reply.text, "[same target]")); + context.manager.codec_supported = false; + invoke("ota ls", reply); assert(strstr(reply.text, "[unsupported]")); + context.manager.rows[0].flags = MFLAG_BOOTLOADER; + invoke("ota ls", reply); assert(strstr(reply.text, "[bootloader unsupported]")); +} +static void name_tests(unsigned id, const char* name) { + reset(); Reply reply; context.manager.count = 1; + context.manager.rows[0].target_id = id; + invoke("ota ls", reply); + char expected[160]; + snprintf(expected, sizeof expected, "Updates 1/1 (2 src; refreshing):\n 1) 01020304 v1.2.3 full [%s] 2n 1s", name); + assert(strcmp(reply.text, expected) == 0); +} +} // ota +} // mesh +int main() { + mesh::ota::dispatch_tests(); + mesh::ota::listing_tests(); + unsigned id; char name[68]; + while (scanf("%x %67s", &id, name) == 2) mesh::ota::name_tests(id, name); +} diff --git a/test/fixtures/protocol_parser_bounds/test.cpp b/test/fixtures/protocol_parser_bounds/test.cpp new file mode 100644 index 000000000..257559a0b --- /dev/null +++ b/test/fixtures/protocol_parser_bounds/test.cpp @@ -0,0 +1,304 @@ +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include "examples/simple_repeater/RateLimiter.h" +#include "production_constants.inc" +#include "production_packet.inc" + +static void require(bool condition, const char* message) { + if (!condition) { + std::fprintf(stderr, "protocol parser bounds: %s\n", message); + std::exit(1); + } +} + +struct ClientInfo { + uint8_t out_path[MAX_PATH_SIZE] = {}; + uint8_t out_path_len = OUT_PATH_UNKNOWN; + uint8_t observed_path[MAX_PATH_SIZE] = {}; + uint8_t observed_path_len = OUT_PATH_UNKNOWN; + bool observed_path_pending = false; + uint32_t observed_path_expiry = 0; +}; + +struct MyMesh { + struct { + char node_name[32] = "Bellevue"; + char owner_info[80] = "owner"; + bool disable_fwd = false; + } _prefs; + struct Clock { + uint32_t now = 123456; + uint32_t getCurrentTime() { return now; } + } rtc_clock; + Clock* getRTCClock() { return &rtc_clock; } + struct RegionMap { + unsigned exports = 0; + size_t exportNamesTo(char* output, size_t capacity, uint8_t) { + ++exports; + const char names[] = "sea,pdx"; + require(capacity >= sizeof(names), "region reply capacity changed"); + memcpy(output, names, sizeof(names)); + return sizeof(names) - 1; + } + } region_map; + RateLimiter anon_limiter{10000, 1}; + uint8_t reply_path[MAX_PATH_SIZE]; + uint8_t reply_path_len = OUT_PATH_UNKNOWN; + uint8_t reply_data[MAX_PACKET_PAYLOAD] = {}; + mesh::Packet response; + unsigned allocations = 0, direct_sends = 0, flood_sends = 0, logins = 0; + uint32_t sent_delay = 0, login_timestamp = 0; + bool login_flood = false, login_empty = false; + struct ACL { + ClientInfo client; + bool present = false; + ClientInfo* getClient(const uint8_t*, size_t) { return present ? &client : nullptr; } + } acl; + MyMesh() { memset(reply_path, 0xa5, sizeof(reply_path)); } + uint8_t handleAnonRegionsReq(const mesh::Identity&, uint32_t, const uint8_t*, size_t); + uint8_t handleAnonOwnerReq(const mesh::Identity&, uint32_t, const uint8_t*, size_t); + uint8_t handleAnonClockReq(const mesh::Identity&, uint32_t, const uint8_t*, size_t); + void onAnonDataRecv(mesh::Packet*, const uint8_t*, const mesh::Identity&, uint8_t*, size_t); + uint8_t handleLoginReq(const mesh::Identity&, const uint8_t*, uint32_t timestamp, + const uint8_t* password, bool flood) { + ++logins; + login_timestamp = timestamp; + login_flood = flood; + login_empty = password[0] == 0; + return 13; + } + bool isRs232BridgeRunning() const { return true; } + bool isEspNowBridgeRunning() const { return true; } + bool isBridgeRunning() const { return true; } + unsigned long futureMillis(unsigned long delay) const { return 100 + delay; } + mesh::Packet* createDatagram(uint8_t, const mesh::Identity&, const uint8_t*, + const uint8_t* data, size_t length) { + ++allocations; + require(length <= sizeof(response.payload), "reply exceeded datagram buffer"); + memcpy(response.payload, data, length); + response.payload_len = length; + return &response; + } + mesh::Packet* createPathReturn(const mesh::Identity& identity, const uint8_t* secret, + const uint8_t*, uint8_t, uint8_t, + const uint8_t* data, size_t length) { + return createDatagram(PAYLOAD_TYPE_RESPONSE, identity, secret, data, length); + } + bool sendDirect(mesh::Packet*, const uint8_t* path, uint8_t encoded, uint32_t delay) { + ++direct_sends; + sent_delay = delay; + require(mesh::Packet::isValidPathLen(encoded), "invalid path was transmitted"); + const size_t bytes = (encoded & 63U) * ((encoded >> 6) + 1U); + response.path_len = encoded; + memcpy(response.path, path, bytes); + return true; + } + bool sendFloodReply(mesh::Packet*, uint32_t delay, uint8_t) { + ++flood_sends; + sent_delay = delay; + return true; + } +}; + +#include "production_handlers.inc" + +static bool expectedCompletePath(unsigned encoded, size_t length) { + const unsigned hash_size = (encoded >> 6) + 1; + const unsigned path_bytes = (encoded & 63) * hash_size; + return hash_size <= 3 && path_bytes <= MAX_PATH_SIZE && length >= path_bytes + 1; +} + +static uint8_t invokeHandler(MyMesh& mesh, unsigned request_type, + const mesh::Identity& sender, const uint8_t* data, size_t length) { + switch (request_type) { + case ANON_REQ_TYPE_REGIONS: return mesh.handleAnonRegionsReq(sender, 42, data, length); + case ANON_REQ_TYPE_OWNER: return mesh.handleAnonOwnerReq(sender, 42, data, length); + default: return mesh.handleAnonClockReq(sender, 42, data, length); + } +} + +static void checkAnonymousHandlers() { + const mesh::Identity sender; + for (unsigned type : {ANON_REQ_TYPE_REGIONS, ANON_REQ_TYPE_OWNER, ANON_REQ_TYPE_BASIC}) { + MyMesh empty; + require(invokeHandler(empty, type, sender, nullptr, 0) == 0, "null path accepted"); + require(invokeHandler(empty, type, sender, nullptr, 100) == 0, "null nonempty path accepted"); + for (unsigned encoded = 0; encoded <= UINT8_MAX; ++encoded) { + for (size_t length = 0; length <= MAX_PATH_SIZE + 1; ++length) { + std::vector bytes(length, 0x36); + if (length) bytes[0] = encoded; + MyMesh mesh; + const bool expected = length > 0 && expectedCompletePath(encoded, length); + const uint8_t reply_len = invokeHandler(mesh, type, sender, bytes.data(), length); + require((reply_len != 0) == expected, "reply path encoding/length accepted incorrectly"); + if (!expected) { + require(mesh.reply_path_len == OUT_PATH_UNKNOWN, "bad path changed reply route"); + require(std::all_of(std::begin(mesh.reply_path), std::end(mesh.reply_path), + [](uint8_t byte) { return byte == 0xa5; }), + "bad path copied bytes before rejection"); + require(mesh.region_map.exports == 0, "bad path generated region reply"); + } else { + const size_t path_bytes = (encoded & 63U) * ((encoded >> 6) + 1U); + require(mesh.reply_path_len == encoded, "valid path mode changed"); + require(memcmp(mesh.reply_path, bytes.data() + 1, path_bytes) == 0, + "valid path copied incorrectly"); + uint32_t timestamp = 0; + memcpy(×tamp, mesh.reply_data, sizeof(timestamp)); + require(timestamp == 42, "reply request tag changed"); + memcpy(×tamp, mesh.reply_data + 4, sizeof(timestamp)); + require(timestamp == mesh.rtc_clock.now, "reply clock changed"); + } + } + } + } +} + +static void checkAnonymousDispatch() { + const mesh::Identity sender; + const uint8_t secret[PUB_KEY_SIZE] = {}; + mesh::Packet request; + request.header = (PAYLOAD_TYPE_ANON_REQ << PH_TYPE_SHIFT) | ROUTE_TYPE_DIRECT; + for (unsigned type : {ANON_REQ_TYPE_REGIONS, ANON_REQ_TYPE_OWNER, ANON_REQ_TYPE_BASIC}) { + for (size_t length = 0; length < 6; ++length) { + if (length == 4) continue; // The valid timestamp-only login is tested below. + MyMesh mesh; + std::vector bytes(length + 1, 0xa5); + if (length >= 5) bytes[4] = type; + mesh.onAnonDataRecv(&request, secret, sender, bytes.data(), length); + require(mesh.allocations == 0 && mesh.logins == 0, "incomplete request produced a response"); + if (length < 4) require(bytes[length] == 0xa5, "short request wrote a terminator"); + } + for (unsigned encoded = 0; encoded <= UINT8_MAX; ++encoded) { + for (size_t path_length = 0; path_length <= MAX_PATH_SIZE; ++path_length) { + const size_t length = 6 + path_length; + std::vector bytes(length + 1, 0x59); + bytes[4] = type; + bytes[5] = encoded; + MyMesh mesh; + const bool expected = expectedCompletePath(encoded, 1 + path_length); + mesh.onAnonDataRecv(&request, secret, sender, bytes.data(), length); + require(mesh.allocations == (expected ? 1U : 0U), "dispatch did not bound remaining path bytes"); + require(mesh.direct_sends == (expected ? 1U : 0U) && mesh.flood_sends == 0, + "anonymous reply route changed"); + if (expected) { + require(mesh.response.path_len == encoded, "transmitted encoded path changed"); + require(mesh.sent_delay == SERVER_RESPONSE_DELAY, "response delay changed"); + if (type == ANON_REQ_TYPE_BASIC) { + uint8_t expected_features = 0; +#ifdef WITH_RS232_BRIDGE + expected_features |= 1; +#ifdef WITH_ESPNOW_BRIDGE + expected_features |= 3; +#endif +#elif WITH_ESPNOW_BRIDGE + expected_features |= 3; +#endif + require(mesh.response.payload[8] == expected_features, "bridge discovery features changed"); + } + } + } + } + // Match the real decrypt buffer shape: padding/stale capacity must not + // allow a 16-byte plaintext to invent a 64-byte return path. + uint8_t padded[MAX_PACKET_PAYLOAD]; + memset(padded, 0xa5, sizeof(padded)); + padded[4] = type; + padded[5] = (1U << 6) | 32U; + MyMesh mesh; + mesh.onAnonDataRecv(&request, secret, sender, padded, CIPHER_BLOCK_SIZE); + require(mesh.allocations == 0, "short decrypted payload exposed scratch-buffer bytes"); + } + + MyMesh invalid; + uint8_t one_byte = 0x5a; + invalid.onAnonDataRecv(nullptr, secret, sender, &one_byte, 1); + invalid.onAnonDataRecv(&request, secret, sender, nullptr, 16); + invalid.onAnonDataRecv(&request, secret, sender, &one_byte, MAX_PACKET_PAYLOAD); + invalid.onAnonDataRecv(&request, secret, sender, &one_byte, SIZE_MAX); + require(one_byte == 0x5a && invalid.allocations == 0 && invalid.logins == 0, + "oversized or null input was read/written"); + + // BaseChatMesh::sendLogin encodes only the timestamp for an empty password. + // Use exactly one spare terminator byte, as allowed by the decrypt buffer, + // and make that byte nonzero to prove dispatch supplies the empty password. + for (unsigned route : {ROUTE_TYPE_DIRECT, ROUTE_TYPE_FLOOD}) { + uint8_t empty_login[5] = {0x78, 0x56, 0x34, 0x12, 0xa5}; + request.header = (PAYLOAD_TYPE_ANON_REQ << PH_TYPE_SHIFT) | route; + MyMesh mesh; + mesh.onAnonDataRecv(&request, secret, sender, empty_login, 4); + require(mesh.logins == 1 && mesh.allocations == 1 && mesh.flood_sends == 1, + "exact timestamp-only login was rejected"); + require(mesh.login_timestamp == 0x12345678 && mesh.login_empty && empty_login[4] == 0, + "timestamp-only login did not receive its empty password/clock"); + require(mesh.login_flood == (route == ROUTE_TYPE_FLOOD), + "timestamp-only login lost its receive route"); + } + + request.header = (PAYLOAD_TYPE_ANON_REQ << PH_TYPE_SHIFT) | ROUTE_TYPE_DIRECT; + uint8_t login[17] = {}; + MyMesh direct_login; + direct_login.onAnonDataRecv(&request, secret, sender, login, 16); + require(direct_login.logins == 1 && direct_login.flood_sends == 1, + "valid blank login dispatch changed"); + request.header = (PAYLOAD_TYPE_ANON_REQ << PH_TYPE_SHIFT) | ROUTE_TYPE_FLOOD; + MyMesh flood_login; + flood_login.onAnonDataRecv(&request, secret, sender, login, 16); + require(flood_login.logins == 1 && flood_login.flood_sends == 1, + "valid flood login dispatch changed"); + login[4] = ANON_REQ_TYPE_BASIC; + MyMesh flood_query; + flood_query.onAnonDataRecv(&request, secret, sender, login, 16); + require(flood_query.allocations == 0, "direct-only anonymous query accepted as flood"); +} + +static void checkAdverts() { + const uint8_t one_byte = ADV_NAME_MASK; + require(!AdvertDataParser(nullptr, 0).isValid(), "null advert accepted"); + require(!AdvertDataParser(nullptr, 1).isValid(), "null nonempty advert accepted"); + require(!AdvertDataParser(&one_byte, MAX_ADVERT_DATA_SIZE + 1).isValid(), "oversized advert accepted"); + require(!AdvertDataParser(&one_byte, UINT8_MAX).isValid(), "max byte length advert accepted"); + for (unsigned flags = 0; flags <= UINT8_MAX; ++flags) { + const size_t required = 1 + ((flags & ADV_LATLON_MASK) ? 8 : 0) + + ((flags & ADV_FEAT1_MASK) ? 2 : 0) + ((flags & ADV_FEAT2_MASK) ? 2 : 0); + for (size_t length = 0; length <= MAX_ADVERT_DATA_SIZE; ++length) { + std::vector bytes(length, 'x'); + if (length) bytes[0] = flags; + AdvertDataParser parser(bytes.data(), length); + require(parser.isValid() == (length >= required), "advert field boundary rejected incorrectly"); + if (parser.isValid()) { + require(parser.getType() == (flags & 15), "advert type changed"); + require(strlen(parser.getName()) == ((flags & ADV_NAME_MASK) ? length - required : 0), + "advert name length/termination changed"); + } else { + require(parser.getIntLat() == 0 && parser.getIntLon() == 0 + && parser.getFeat1() == 0 && parser.getFeat2() == 0 + && !parser.hasName(), "truncated advert changed optional-field defaults"); + } + } + } + uint8_t bytes[MAX_ADVERT_DATA_SIZE]; + AdvertDataBuilder builder(ADV_TYPE_REPEATER, "SEA Bellevue", 47.0, -122.0); + builder.setFeat1(0x1234); + builder.setFeat2(0x5678); + AdvertDataParser parsed(bytes, builder.encodeTo(bytes)); + require(parsed.isValid() && parsed.getType() == ADV_TYPE_REPEATER + && parsed.getIntLat() == 47000000 && parsed.getIntLon() == -122000000 + && parsed.getFeat1() == 0x1234 && parsed.getFeat2() == 0x5678 + && strcmp(parsed.getName(), "SEA Bellevue") == 0, "valid advert round trip changed"); +} + +int main() { + checkAdverts(); + checkAnonymousHandlers(); + checkAnonymousDispatch(); + std::puts("PASS: production protocol parser bounds"); +} diff --git a/test/test_advert_data_parser/test_advert_data_parser.cpp b/test/test_advert_data_parser/test_advert_data_parser.cpp new file mode 100644 index 000000000..ae8b6805d --- /dev/null +++ b/test/test_advert_data_parser/test_advert_data_parser.cpp @@ -0,0 +1,121 @@ +#include + +#include +#include +#include + +// The advert helper is also included by the existing contact-import suite; +// keep each native test executable on the same production implementation. +#include "../../src/helpers/AdvertDataHelpers.cpp" + +TEST(AdvertDataParser, RejectsNullEmptyAndOversizedInputsBeforeReading) { + EXPECT_FALSE(AdvertDataParser(nullptr, 0).isValid()); + EXPECT_FALSE(AdvertDataParser(nullptr, 1).isValid()); + const uint8_t byte = ADV_NAME_MASK; + EXPECT_FALSE(AdvertDataParser(&byte, 0).isValid()); + EXPECT_FALSE(AdvertDataParser(&byte, MAX_ADVERT_DATA_SIZE + 1).isValid()); + EXPECT_FALSE(AdvertDataParser(&byte, UINT8_MAX).isValid()); +} + +TEST(AdvertDataParser, RejectsEveryTruncatedOptionalFieldCombination) { + for (unsigned combination = 0; combination < 8; ++combination) { + const bool location = (combination & 1) != 0; + const bool feature1 = (combination & 2) != 0; + const bool feature2 = (combination & 4) != 0; + const size_t required = 1 + (location ? 8 : 0) + + (feature1 ? 2 : 0) + (feature2 ? 2 : 0); + const uint8_t flags = ADV_TYPE_REPEATER | (location ? ADV_LATLON_MASK : 0) + | (feature1 ? ADV_FEAT1_MASK : 0) | (feature2 ? ADV_FEAT2_MASK : 0); + for (size_t length = 0; length <= required; ++length) { + // Allocate only the supplied length so sanitizers detect even one read + // past a short field, rather than hiding it in a padded scratch buffer. + std::vector bytes(length, 0x5a); + if (length) bytes[0] = flags; + AdvertDataParser parser(bytes.data(), static_cast(length)); + EXPECT_EQ(length == required, parser.isValid()) + << "flags=" << unsigned(flags) << " length=" << length; + if (length < required) { + EXPECT_EQ(0, parser.getIntLat()); + EXPECT_EQ(0, parser.getIntLon()); + EXPECT_EQ(0, parser.getFeat1()); + EXPECT_EQ(0, parser.getFeat2()); + EXPECT_FALSE(parser.hasName()); + } + } + } +} + +TEST(AdvertDataParser, PreservesCompleteLocationFeaturesAndName) { + const int32_t latitude = 47606200, longitude = -122332100; + const uint16_t feature1 = 0x1234, feature2 = 0xabcd; + const char name[] = "SEA Bellevue"; + std::vector bytes(1 + 8 + 4 + sizeof(name) - 1); + bytes[0] = ADV_TYPE_REPEATER | ADV_LATLON_MASK | ADV_FEAT1_MASK + | ADV_FEAT2_MASK | ADV_NAME_MASK; + size_t offset = 1; + memcpy(bytes.data() + offset, &latitude, sizeof(latitude)); offset += sizeof(latitude); + memcpy(bytes.data() + offset, &longitude, sizeof(longitude)); offset += sizeof(longitude); + memcpy(bytes.data() + offset, &feature1, sizeof(feature1)); offset += sizeof(feature1); + memcpy(bytes.data() + offset, &feature2, sizeof(feature2)); offset += sizeof(feature2); + memcpy(bytes.data() + offset, name, sizeof(name) - 1); + + AdvertDataParser parser(bytes.data(), static_cast(bytes.size())); + ASSERT_TRUE(parser.isValid()); + EXPECT_EQ(ADV_TYPE_REPEATER, parser.getType()); + EXPECT_TRUE(parser.hasLatLon()); + EXPECT_EQ(latitude, parser.getIntLat()); + EXPECT_EQ(longitude, parser.getIntLon()); + EXPECT_EQ(feature1, parser.getFeat1()); + EXPECT_EQ(feature2, parser.getFeat2()); + EXPECT_STREQ(name, parser.getName()); +} + +TEST(AdvertDataParser, TerminatesMaximumNameWithinItsBuffer) { + std::vector bytes(MAX_ADVERT_DATA_SIZE, 'a'); + bytes[0] = ADV_TYPE_CHAT | ADV_NAME_MASK; + AdvertDataParser parser(bytes.data(), static_cast(bytes.size())); + ASSERT_TRUE(parser.isValid()); + EXPECT_EQ(size_t(MAX_ADVERT_DATA_SIZE - 1), strlen(parser.getName())); + EXPECT_EQ('\0', parser.getName()[MAX_ADVERT_DATA_SIZE - 1]); +} + +TEST(AdvertDataParser, PreservesNamesProducedByTheBuilder) { + const char name[] = "Bellevue \xe2\x98\x80 mesh with a long name"; + AdvertDataBuilder builder(ADV_TYPE_REPEATER, name, 47.0, -122.0); + builder.setFeat1(0x1234); + builder.setFeat2(0x5678); + uint8_t bytes[MAX_ADVERT_DATA_SIZE]; + const uint8_t length = builder.encodeTo(bytes); + AdvertDataParser parser(bytes, length); + ASSERT_TRUE(parser.isValid()); + EXPECT_EQ(ADV_TYPE_REPEATER, parser.getType()); + EXPECT_EQ(47000000, parser.getIntLat()); + EXPECT_EQ(-122000000, parser.getIntLon()); + EXPECT_EQ(0x1234, parser.getFeat1()); + EXPECT_EQ(0x5678, parser.getFeat2()); + EXPECT_STREQ("Bellevue \xe2\x98\x80 mesh w", parser.getName()); +} + +TEST(AdvertDataParser, AllFlagAndLengthCombinationsStayWithinSuppliedBytes) { + for (unsigned flags = 0; flags <= UINT8_MAX; ++flags) { + const size_t required = 1 + ((flags & ADV_LATLON_MASK) ? 8 : 0) + + ((flags & ADV_FEAT1_MASK) ? 2 : 0) + ((flags & ADV_FEAT2_MASK) ? 2 : 0); + for (size_t length = 0; length <= MAX_ADVERT_DATA_SIZE; ++length) { + std::vector bytes(length, 'x'); + if (length) bytes[0] = static_cast(flags); + AdvertDataParser parser(bytes.data(), static_cast(length)); + EXPECT_EQ(length >= required, parser.isValid()) + << "flags=" << flags << " length=" << length; + if (parser.isValid()) { + EXPECT_EQ(flags & 15, parser.getType()); + EXPECT_EQ((flags & ADV_NAME_MASK) ? length - required : 0, + strlen(parser.getName())); + } + } + } +} + +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/test/test_bridge_recovery.py b/test/test_bridge_recovery.py new file mode 100644 index 000000000..1d30a2074 --- /dev/null +++ b/test/test_bridge_recovery.py @@ -0,0 +1,271 @@ +"""Fault-inject real bridge sources: pool pressure, TX backoff and lost callbacks.""" +from pathlib import Path +import os +import re +import subprocess +import tempfile +import unittest + +from test_radio_receive_contract import method + +ROOT = Path(__file__).resolve().parents[1] + +MOCKS = r''' +#include +#include +#include +#define MAX_TRANS_UNIT 255 +using portMUX_TYPE=int; +#define portMUX_INITIALIZER_UNLOCKED 0 +struct esp_now_recv_info_t {const uint8_t* src_addr;}; +struct esp_now_send_info_t {const uint8_t* des_addr;}; +constexpr int ESP_ERR_ESPNOW_NO_MEM=-77; +uint32_t now_ms=100; +uint32_t millis(){return now_ms;} +int send_result=0,send_calls=0; +std::vector> sent; +int esp_now_send(const uint8_t*,const uint8_t* bytes,size_t len){ + assert(espnow_active);++send_calls; + if(send_result!=ESP_OK)return send_result; + sent.emplace_back(bytes,bytes+len);return ESP_OK; +} +namespace mesh { +struct RTCClock {}; +struct Packet { + uint8_t data[MAX_TRANS_UNIT]={};uint16_t length=4; + bool readFrom(const uint8_t* bytes,size_t len){ + if(len==0||len>sizeof(data)||bytes[0]==0xee)return false; + memcpy(data,bytes,len);length=len;return true; + } + int getRawLength()const{return length;} + uint16_t writeTo(uint8_t* out)const{memcpy(out,data,length);return length;} + bool violatesRoutePolicy()const{return false;} +}; +struct PacketManager { + std::array pool;std::array used{}; + bool exhausted=false;unsigned frees=0,allocs=0; + std::vector received; + Packet* allocNew(){ + if(exhausted)return nullptr; + for(size_t i=0;idata[0]);free(p);} +}; +} +struct NodePrefs { + int bridge_format=mesh::bridge::ESPNOW_FORMAT_RAW; + uint8_t bridge_channel=6;uint32_t bridge_baud=115200,bridge_delay=1; + char bridge_secret[32]="fleet-test"; +}; +struct Seen { + std::vector ids; + bool wasSeen(const mesh::Packet* p){return std::find(ids.begin(),ids.end(),p->data[0])!=ids.end();} + void markSeen(const mesh::Packet* p){if(!wasSeen(p))ids.push_back(p->data[0]);} + void clear(const mesh::Packet* p){ids.erase(std::remove(ids.begin(),ids.end(),p->data[0]),ids.end());} +}; +class BridgeBase { +public: + static constexpr uint16_t BRIDGE_PACKET_MAGIC=0xc03e; + static constexpr uint16_t BRIDGE_MAGIC_SIZE=2,BRIDGE_LENGTH_SIZE=2,BRIDGE_CHECKSUM_SIZE=2; + NodePrefs* _prefs;mesh::PacketManager* _mgr;mesh::RTCClock* _rtc; + bool _initialized=false,allowed=true;Seen _seen_packets; + BridgeBase(NodePrefs* prefs,mesh::PacketManager* mgr,mesh::RTCClock* rtc):_prefs(prefs),_mgr(mgr),_rtc(rtc){} + virtual ~BridgeBase()=default; + virtual void begin()=0;virtual void end()=0;virtual void loop()=0; + virtual void sendPacket(mesh::Packet*)=0;virtual void onPacketReceived(mesh::Packet*)=0; + bool allowsPacket(const mesh::Packet*)const{return allowed;} + static uint16_t fletcher16(const uint8_t*,size_t); + bool validateChecksum(const uint8_t*,size_t,uint16_t); + void handleReceivedPacket(mesh::Packet*); +}; +class Stream { +public: + std::vector input,output;size_t position=0,write_limit=1000;unsigned reads=0; + int available(){return input.size()-position;} + int read(){++reads;return position uart_frame(uint8_t id){ + uint8_t payload[4]={id,1,2,3};uint16_t sum=BridgeBase::fletcher16(payload,4); + return {0xc0,0x3e,0,4,id,1,2,3,uint8_t(sum>>8),uint8_t(sum)}; +} +static void append(HardwareSerial& serial,const std::vector& frame){ + serial.input.insert(serial.input.end(),frame.begin(),frame.end()); +} +static mesh::Packet packet(uint8_t id,size_t length=4){ + mesh::Packet p;p.length=length;memset(p.data,id,sizeof(p.data));return p; +} +static void complete(ESPNowBridge& bridge,int status=ESP_NOW_SEND_SUCCESS){ + const uint8_t mac[6]={255,255,255,255,255,255}; + bridge.onDataSent(mac,static_cast(status));bridge.loop(); +} +''' + + +class BridgeRecoveryTests(unittest.TestCase): + def run_fixture(self, checks, versions=(50200,)): + with tempfile.TemporaryDirectory(prefix="meshcore-bridge-recovery-") as tmp: + cpp, exe = Path(tmp) / "test.cpp", Path(tmp) / "test" + cpp.write_text(code() + COMMON + checks) + for version in versions: + with self.subTest(idf=version): + result = subprocess.run([ + os.environ.get("CXX", "g++"), "-std=c++17", "-O1", "-Wall", "-Wextra", + "-fsanitize=address,undefined", "-fno-omit-frame-pointer", "-fno-pie", "-no-pie", + "-DESP32=1", "-DWITH_ESPNOW_BRIDGE=1", "-DWITH_RS232_BRIDGE=1", + f"-DESP_IDF_VERSION={version}", f"-DESP_IDF_VERSION_MAJOR={version//10000}", + "-I", str(ROOT / "src"), str(cpp), "-o", str(exe), + ], capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stderr) + result = subprocess.run([str(exe)], capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stderr) + + def test_uart_pool_pressure_order_parser_bounds_and_restart(self): + self.run_fixture(r''' +int main(){ + NodePrefs prefs;mesh::PacketManager mgr;mesh::RTCClock rtc;HardwareSerial serial; + RS232Bridge bridge(&prefs,serial,1,2,&mgr,&rtc);bridge.begin(); + append(serial,uart_frame(1));append(serial,uart_frame(2));mgr.exhausted=true; + bridge.loop();assert(bridge._rx_packet_ready&&serial.available()==10&&mgr.received.empty()); + const auto read=serial.reads;bridge.loop();assert(serial.reads==read&&mgr.received.empty()); + mgr.exhausted=false;bridge.loop();assert((mgr.received==std::vector{1,2})); + append(serial,{0xc0,0x3e,0,0}); // empty length must resynchronize + append(serial,{0xc0,0x3e,1,1}); // oversized length must resynchronize + auto corrupt=uart_frame(3);corrupt.back()^=1;append(serial,corrupt); + auto invalid=uart_frame(0xee);append(serial,invalid);append(serial,uart_frame(4)); + for(unsigned i=0;i<4;++i)bridge.loop(); + assert((mgr.received==std::vector{1,2,4})); + serial.input.insert(serial.input.end(),2000,0x00);const auto before=serial.reads; + bridge.loop();assert(serial.reads-before==512); // noisy UART has bounded loop work + serial.position=serial.input.size();append(serial,uart_frame(5));mgr.exhausted=true; + bridge.loop();assert(bridge._rx_packet_ready);bridge.end();mgr.exhausted=false;bridge.begin(); + bridge.loop();assert((mgr.received==std::vector{1,2,4})); // held frame cannot cross restart + auto outgoing=packet(6);serial.write_limit=2;bridge.sendPacket(&outgoing); + assert(!bridge._seen_packets.wasSeen(&outgoing)); // failed admission can retry + serial.write_limit=1000;bridge.sendPacket(&outgoing);assert(bridge._seen_packets.wasSeen(&outgoing)); + bridge.allowed=false;auto blocked=packet(7);auto total=serial.output.size(); + bridge.sendPacket(&blocked);assert(serial.output.size()==total); + bridge.allowed=true;serial.input.clear();serial.position=0; + for(uint8_t id=10;id<40;++id)append(serial,uart_frame(id)); + bridge.loop();assert(serial.available()==0&&mgr.received.size()==33); // one bounded batch preserves throughput +} +''') + + def test_espnow_rx_pool_pressure_reassembly_and_filter_ownership(self): + self.run_fixture(r''' +int main(){ + Backend backend;backend.wifi=true;mesh::wireless::control().begin(backend); + sdk_initialized=sdk_started=true;sdk_mode=WIFI_STA; + NodePrefs prefs;mesh::PacketManager mgr;mesh::RTCClock rtc;ESPNowBridge bridge(&prefs,&mgr,&rtc);bridge.begin(); + const uint8_t mac[6]={1,2,3,4,5,6};auto p=packet(1); + bridge.queueReceivedFrame(mac,p.data,p.length);mgr.exhausted=true;bridge.loop(); + assert(bridge._rx_count==1&&mgr.received.empty()); + mgr.exhausted=false;bridge.loop();assert((mgr.received==std::vector{1})); + mesh::espnow::ESPNowRawFrames fragments;auto full=packet(2,255); + assert(mesh::espnow::encodeEspNowRawFrames(full.data,full.length,fragments)); + bridge.queueReceivedFrame(mac,fragments.data[0],fragments.lengths[0]);bridge.loop(); + bridge.queueReceivedFrame(mac,fragments.data[1],fragments.lengths[1]);mgr.exhausted=true; + bridge.loop();assert(bridge._rx_count==1&&mgr.received.size()==1); + mgr.exhausted=false;bridge.loop();assert((mgr.received==std::vector{1,2})); + auto blocked=packet(3);bridge.allowed=false;bridge.queueReceivedFrame(mac,blocked.data,blocked.length); + bridge.loop();assert(mgr.received.size()==2);bridge.allowed=true; + auto bad=packet(0xee);bridge.queueReceivedFrame(mac,bad.data,bad.length);bridge.loop(); + assert(mgr.received.size()==2&&mgr.allocs==mgr.frees); + bridge.end();prefs.bridge_format=mesh::bridge::ESPNOW_FORMAT_WRAPPED;bridge.begin(); + auto wrapped=packet(4);uint8_t bytes[8]={0xc0,0x3e};uint16_t sum=BridgeBase::fletcher16(wrapped.data,4); + bytes[2]=sum>>8;bytes[3]=sum;memcpy(bytes+4,wrapped.data,4);assert(bridge.xorCrypt(bytes+2,6)); + bridge.queueReceivedFrame(mac,bytes,sizeof(bytes));mgr.exhausted=true;bridge.loop(); + assert(bridge._rx_count==1);mgr.exhausted=false;bridge.loop(); + assert((mgr.received==std::vector{1,2,4})); + bridge.end();assert(mgr.allocs==mgr.frees&&sdk_started); // shared WiFi survives +} +''') + + def test_espnow_start_retry_budget_wrap_and_fragment_order(self): + self.run_fixture(r''' +int main(){ + Backend backend;backend.wifi=true;mesh::wireless::control().begin(backend); + sdk_initialized=sdk_started=true;sdk_mode=WIFI_STA; + NodePrefs prefs;mesh::PacketManager mgr;mesh::RTCClock rtc;ESPNowBridge bridge(&prefs,&mgr,&rtc);bridge.begin(); + auto p=packet(1,255);bridge.sendPacket(&p);send_result=ESP_ERR_ESPNOW_NO_MEM; + now_ms=UINT32_MAX-19;bridge.loop();assert(send_calls==1&&bridge._tx_count==1); + for(unsigned i=0;i<3;++i){now_ms+=19;bridge.loop();assert(send_calls==int(i+1));++now_ms;bridge.loop();} + assert(send_calls==4&&bridge._tx_count==0&&!bridge._seen_packets.wasSeen(&p)); + send_result=ESP_OK;bridge.sendPacket(&p);bridge.loop();assert(sent.size()==1); + complete(bridge);assert(sent.size()==2&&bridge._tx_count==1);complete(bridge);assert(bridge._tx_count==0); + mesh::espnow::ESPNowRawReassembler assembler;uint8_t out[255],mac[6]={};size_t size=0; + assembler.acceptFrame(mac,sent[0].data(),sent[0].size(),now_ms,out,sizeof(out),size); + assert(assembler.acceptFrame(mac,sent[1].data(),sent[1].size(),now_ms,out,sizeof(out),size)==mesh::espnow::ESPNowRawReassemblyResult::PACKET_COMPLETE); + assert(size==255&&!memcmp(out,p.data,size)); + auto fail=packet(2,255);bridge.sendPacket(&fail);bridge.loop();const size_t prior=sent.size(); + for(unsigned i=0;i<3;++i){ + complete(bridge,ESP_NOW_SEND_FAIL);assert(sent.size()==prior+i&&bridge._tx_count==1); + now_ms+=19;bridge.loop();assert(sent.size()==prior+i); + ++now_ms;bridge.loop();assert(sent.size()==prior+i+1); + } + complete(bridge,ESP_NOW_SEND_FAIL);assert(bridge._tx_count==0); // bounded budget, no orphan sibling + for(size_t i=prior-1;i #include #include +#include #include #include #define STATE_IDLE 0 #define STATE_RX 1 #define STATE_TX_WAIT 3 -#define STATE_INT_READY 16 #define RADIOLIB_ERR_NONE 0 #define RADIOLIB_ERR_INVALID_OUTPUT_POWER -1 #define SX127X_CURRENT_LIMIT 120 #define LORA_TX_POWER 17 #define MESH_DEBUG_PRINTLN(...) ((void)0) -static uint8_t state; -static void setFlag() { state |= STATE_INT_READY; } +static void setFlag() {} struct Board { bool awake=false; unsigned before=0, after=0; void setRadioTestActive(bool value) { awake=value; } @@ -56,6 +55,10 @@ struct Chip { }; using CustomSX1276=Chip; struct RadioLibWrapper : mesh::Radio { + uint8_t state = STATE_RX; + RadioInterruptEvents events; + bool hasPendingRadioInterrupt() const { return events.pending(); } + bool registerInterruptAction() { chip.setPacketReceivedAction(setFlag); events.claim(); return true; } Chip chip; Chip* _radio=&chip; Board board; Board* _board=&board; mesh::RadioProfiles _profiles; @@ -94,7 +97,7 @@ struct RadioLibWrapper : mesh::Radio { void endReconfigure(bool resume) { if (resume) startRecv(); } bool isChipBusy() { return busy; } bool isReceivingPacket() { return packet; } - bool isInRecvMode() const override { return (state & ~STATE_INT_READY)==STATE_RX; } + bool isInRecvMode() const override { return state==STATE_RX; } void stopReceiveDutyCycle() { ++rtc_stops; _rx_ps_armed=false; } mesh::RadioParamApplyResult tuneProfile(uint8_t target) { ++tunes; @@ -164,8 +167,8 @@ int main() { // No mutation of packets already transmitting, completed RX, busy or receiving hardware. for (int reason : {0,1,2,3}) { RadioLibWrapper w; - if(reason==0) state=STATE_TX_WAIT; - if(reason==1) state|=STATE_INT_READY; + if(reason==0) w.state=STATE_TX_WAIT; + if(reason==1) w.events.record(); if(reason==2) w.busy=true; if(reason==3) w.packet=true; assert(w.setCarrierWave(1,100)==Result::BUSY); diff --git a/test/test_companion_inbox.py b/test/test_companion_inbox.py index 18ae07cbb..481deb3cb 100644 --- a/test/test_companion_inbox.py +++ b/test/test_companion_inbox.py @@ -49,6 +49,8 @@ public: int getOfflineQueueCapacity() const { return 4; } Frame& offlineQueueFrameAt(int index) { return frames[(offline_queue_head + index) % 4]; } bool addToOfflineQueue(const uint8_t*, int); + int peekOfflineQueue(uint8_t*); + void popOfflineQueue(); int getFromOfflineQueue(uint8_t*); bool receive(const char* text, uint8_t kind = 17) { uint8_t bytes[2] = {kind, uint8_t(text[0])}; @@ -312,7 +314,8 @@ int main() { "const char* UITask::inboxTitle("): implementation += extract_braced(ui, signature) + "\n" implementation += QUEUE - for signature in ("bool MyMesh::addToOfflineQueue(", "int MyMesh::getFromOfflineQueue("): + for signature in ("bool MyMesh::addToOfflineQueue(", "int MyMesh::peekOfflineQueue(", + "void MyMesh::popOfflineQueue(", "int MyMesh::getFromOfflineQueue("): implementation += extract_braced(mesh, signature) + "\n" for reader_enabled in (0, 1): with self.subTest(reader_enabled=reader_enabled): diff --git a/test/test_companion_lfs_traversal.py b/test/test_companion_lfs_traversal.py new file mode 100644 index 000000000..fc7e1458c --- /dev/null +++ b/test/test_companion_lfs_traversal.py @@ -0,0 +1,190 @@ +#!/usr/bin/env python3 +"""Exercise Companion traversal against real LittleFS and competing writes.""" + +from pathlib import Path +import subprocess +import tempfile +import unittest + +from test_replay_reset_integration import extract_braced + +ROOT = Path(__file__).resolve().parents[1] +LFS = ROOT / "arch/stm32/Adafruit_LittleFS_stm32/src/littlefs" + +HARNESS = r''' +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#define MESH_DEBUG_PRINTLN(...) ((void)0) +using namespace std::chrono_literals; +struct FILESYSTEM { + lfs_t fs{}; + lfs_config cfg{}; + std::mutex mutex, gate_mutex; + std::condition_variable gate; + std::atomic locked{false}, writer_attempted{false}, writer_done{false}; + unsigned locks=0, unlocks=0; + bool armed=false, entered=false, released=false, fail_reads=false, null_raw=false; + std::vector bytes; + FILESYSTEM():bytes(4096*32,0xFF) { + cfg.context=this;cfg.read=read;cfg.prog=prog;cfg.erase=erase;cfg.sync=sync; + cfg.read_size=16;cfg.prog_size=16;cfg.block_size=4096;cfg.block_count=32; + cfg.lookahead=32; + _lockFS();assert(lfs_format(&fs,&cfg)==0);assert(lfs_mount(&fs,&cfg)==0);_unlockFS(); + } + ~FILESYSTEM() {_lockFS();assert(lfs_unmount(&fs)==0);_unlockFS();} + void _lockFS() {mutex.lock();assert(!locked.exchange(true));++locks;} + void _unlockFS() {++unlocks;assert(locked.exchange(false));mutex.unlock();} + lfs_t* _getFS() {assert(locked);return null_raw?nullptr:&fs;} + static FILESYSTEM& owner(const lfs_config* cfg) { + auto& owner=*static_cast(cfg->context);assert(owner.locked);return owner; + } + static int read(const lfs_config* cfg,lfs_block_t block,lfs_off_t off,void* out,lfs_size_t len) { + auto& self=owner(cfg); + if(self.fail_reads) return LFS_ERR_IO; + { + std::unique_lock guard(self.gate_mutex); + if(self.armed) { + self.armed=false;self.entered=true;self.gate.notify_all(); + assert(self.gate.wait_for(guard,3s,[&]{return self.released;})); + } + } + assert(blockblock_count&&off+len<=cfg->block_size); + memcpy(out,self.bytes.data()+block*cfg->block_size+off,len);return 0; + } + static int prog(const lfs_config* cfg,lfs_block_t block,lfs_off_t off,const void* in,lfs_size_t len) { + auto& self=owner(cfg);assert(blockblock_count&&off+len<=cfg->block_size); + auto* dest=self.bytes.data()+block*cfg->block_size+off; + auto* src=static_cast(in); + for(unsigned i=0;iblock_count); + memset(self.bytes.data()+block*cfg->block_size,0xFF,cfg->block_size);return 0; + } + static int sync(const lfs_config* cfg) {owner(cfg);return 0;} + void write(unsigned marker) { + _lockFS();lfs_file_t file{}; + assert(lfs_file_open(&fs,&file,"/contacts",LFS_O_WRONLY|LFS_O_CREAT|LFS_O_TRUNC)==0); + std::vector contents(5000,uint8_t(marker)); + assert(lfs_file_write(&fs,&file,contents.data(),contents.size())==int(contents.size())); + assert(lfs_file_close(&fs,&file)==0);_unlockFS(); + } + void verify(unsigned marker) { + _lockFS();lfs_file_t file{}; + assert(lfs_file_open(&fs,&file,"/contacts",LFS_O_RDONLY)==0); + std::vector contents(5000); + assert(lfs_file_read(&fs,&file,contents.data(),contents.size())==int(contents.size())); + for(auto byte:contents) assert(byte==uint8_t(marker)); + assert(lfs_file_close(&fs,&file)==0);_unlockFS(); + } +}; +@METHODS@ +int main() { + assert(_getLfsUsedBlockCount(nullptr)==-1&&!validateLfsFilesystem(nullptr)); + FILESYSTEM fs;assert(validateLfsFilesystem(&fs));fs.write(7); + assert(_getLfsUsedBlockCount(&fs)>0&&fs.locks==fs.unlocks&&!fs.locked); + fs.verify(7); + + // Failed/uninitialized mounts must fail closed without dereferencing their + // raw handle/configuration or leaving the filesystem mutex held. + fs.null_raw=true; + assert(_getLfsUsedBlockCount(&fs)==-1&&!validateLfsFilesystem(&fs)); + assert(fs.locks==fs.unlocks&&!fs.locked);fs.null_raw=false; + const auto* mounted_config=fs.fs.cfg;fs.fs.cfg=nullptr; + assert(_getLfsUsedBlockCount(&fs)==-1&&!validateLfsFilesystem(&fs)); + assert(fs.locks==fs.unlocks&&!fs.locked);fs.fs.cfg=mounted_config; + const auto mounted_blocks=fs.cfg.block_count;fs.cfg.block_count=0; + assert(_getLfsUsedBlockCount(&fs)==-1&&!validateLfsFilesystem(&fs)); + assert(fs.locks==fs.unlocks&&!fs.locked);fs.cfg.block_count=mounted_blocks; + fs.verify(7);assert(validateLfsFilesystem(&fs)); + + // Pause a real metadata read inside lfs_traverse. A competing writer must + // wait for the same filesystem mutex until the entire traversal finishes. + for(unsigned repeat=0;repeat<10;++repeat) { + { + std::lock_guard guard(fs.gate_mutex); + fs.armed=true;fs.entered=false;fs.released=false; + } + fs.writer_attempted=false;fs.writer_done=false; + auto traversal=std::async(std::launch::async,[&]{return _getLfsUsedBlockCount(&fs);}); + { + std::unique_lock guard(fs.gate_mutex); + assert(fs.gate.wait_for(guard,3s,[&]{return fs.entered;})); + } + auto writer=std::async(std::launch::async,[&] { + fs.writer_attempted=true;fs.write(repeat+10);fs.writer_done=true; + }); + const auto deadline=std::chrono::steady_clock::now()+3s; + while(!fs.writer_attempted) {assert(std::chrono::steady_clock::now() guard(fs.gate_mutex);fs.released=true;fs.gate.notify_all(); + } + assert(traversal.get()>0);writer.get();fs.verify(repeat+10); + assert(fs.locks==fs.unlocks&&!fs.locked); + } + + // I/O failure must release the lock and report a failed validation, never + // an empty healthy filesystem. A later write and traversal still succeed. + fs.fail_reads=true; + assert(_getLfsUsedBlockCount(&fs)==-1&&!validateLfsFilesystem(&fs)); + assert(fs.locks==fs.unlocks&&!fs.locked); + fs.fail_reads=false;fs.write(99);fs.verify(99);assert(validateLfsFilesystem(&fs)); + + // Existing corruption bounds remain inclusive at block_count, and cyclic + // metadata cannot traverse more blocks than the physical filesystem has. + LfsTraversalState state={0,3}; + assert(_countLfsBlock(&state,3)==LFS_ERR_CORRUPT&&state.visited==0); + assert(_countLfsBlock(&state,0)==0&&_countLfsBlock(&state,1)==0); + assert(_countLfsBlock(&state,2)==0&&state.visited==3); + assert(_countLfsBlock(&state,0)==LFS_ERR_CORRUPT&&state.visited==3); + LfsTraversalState empty={0,0};assert(_countLfsBlock(&empty,0)==LFS_ERR_CORRUPT); +} +''' + + +class CompanionLfsTraversalTest(unittest.TestCase): + def test_actual_traversal_serializes_metadata_writes_and_unlocks_on_error(self): + source = (ROOT / "examples/companion_radio/DataStore.cpp").read_text() + methods = extract_braced(source, "struct LfsTraversalState") + ";\n" + methods += "\n".join(extract_braced(source, signature) for signature in ( + "int _countLfsBlock(", "lfs_ssize_t _getLfsUsedBlockCount(", + "static bool validateLfsFilesystem(FILESYSTEM* fs) {", + )) + with tempfile.TemporaryDirectory(prefix="mesh-lfs-traversal-") as temp: + work = Path(temp) + path = work / "test.cpp" + path.write_text(HARNESS.replace("@METHODS@", methods)) + flags = ["-g", "-fsanitize=address,undefined", "-fno-sanitize-recover=all", + "-fno-pie", "-no-pie", "-I", str(LFS)] + objects = [] + for name in ("lfs", "lfs_util"): + obj = work / (name + ".o") + compiled = subprocess.run(["cc", *flags, "-c", str(LFS / (name + ".c")), + "-o", str(obj)], capture_output=True, text=True, timeout=60) + self.assertEqual(compiled.returncode, 0, compiled.stdout + compiled.stderr) + objects.append(str(obj)) + binary = work / "test" + compiled = subprocess.run([ + "c++", "-std=c++17", "-Werror", *flags, "-pthread", str(path), + *objects, "-o", str(binary), + ], capture_output=True, text=True, timeout=60) + self.assertEqual(compiled.returncode, 0, compiled.stdout + compiled.stderr) + result = subprocess.run([str(binary)], capture_output=True, text=True, timeout=20) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_companion_offline_delivery.py b/test/test_companion_offline_delivery.py new file mode 100644 index 000000000..c020e453e --- /dev/null +++ b/test/test_companion_offline_delivery.py @@ -0,0 +1,341 @@ +#!/usr/bin/env python3 +"""Run real Companion inbox downloads against bounded UART/TCP transports.""" + +from pathlib import Path +import re +import subprocess +import tempfile +import unittest + +from test_replay_reset_integration import extract_braced + +ROOT = Path(__file__).resolve().parents[1] + +HARNESS = r''' +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#define DISPLAY_CLASS TestDisplay +#define MESH_DEBUG_PRINTLN(...) ((void)0) +#define OFFLINE_QUEUE_SIZE 256 +@CONSTANTS@ +@STREAM@ +std::deque WiFiServer::incoming; +static bool usb_connected = true; +static bool usbConnected() { return usb_connected; } +namespace mesh { namespace ota { struct OtaContext { unsigned marker=0x11223344; }; } } +struct UI { + int pending=0, last_removed=-1; + unsigned changes=0; + void syncMessageQueue(int count,int removed=-1) { + pending=count; last_removed=removed; ++changes; + } +}; +struct MyMesh { +@FRAME@ + UI ui, *_ui=&ui; + BaseSerialInterface* _serial=nullptr; + uint8_t out_frame[MAX_FRAME_SIZE]{}; + int offline_queue_len=0, offline_queue_head=0; +#if defined(OTA_SHARED_COMPANION_QUEUE) + mesh::BorrowableFrameBuffer offline_queue; +#elif ONE_KEY_DM_SHARED_OFFLINE_QUEUE + Frame offline_queue[OFFLINE_QUEUE_SIZE]{}; + unsigned held_dm_count=15; +#elif defined(BOARD_HAS_PSRAM) + Frame frames[OFFLINE_QUEUE_SIZE]{}; + Frame* offline_queue=frames; + int offline_queue_capacity=128; +#else + Frame offline_queue[OFFLINE_QUEUE_SIZE]{}; +#endif + int getOfflineQueueCapacity() const; + Frame& offlineQueueFrameAt(int); + bool addToOfflineQueue(const uint8_t[],int); + int peekOfflineQueue(uint8_t[]); + void popOfflineQueue(); + int getFromOfflineQueue(uint8_t[]); + void syncNextOfflineMessage(); +#if defined(OTA_SHARED_COMPANION_QUEUE) + static mesh::ota::OtaContext* acquireOfflineQueueForOta(void*); + static uint16_t drainOfflineQueueForOta(void*); + static void releaseOfflineQueueFromOta(void*); +#endif + void sync() { + uint8_t cmd_frame[]={CMD_SYNC_NEXT_MESSAGE}; + @SYNC@ + } + void add(uint8_t code,uint8_t marker) { + const uint8_t bytes[]={code,marker}; + assert(addToOfflineQueue(bytes,sizeof(bytes))); + } +}; +@METHODS@ +using Bytes=std::vector; +static std::vector frames(const Bytes& bytes) { + std::vector result; + size_t at=0; + while(at=3&&bytes[at]=='>'); + size_t len=bytes[at+1]|size_t(bytes[at+2])<<8; + at+=3; assert(len<=bytes.size()-at); + result.emplace_back(bytes.begin()+at,bytes.begin()+at+len); at+=len; + } + return result; +} +struct Fixture { + BufferStream stream, other_stream; + ArduinoSerialInterface usb, other; + SerialWifiInterface tcp; + MultiSerialInterface manager; + MyMesh mesh; + uint8_t command[MAX_FRAME_SIZE]{}; + Fixture() { + g_mock_millis=100; usb_connected=true; WiFiServer::incoming.clear(); + usb.begin(stream); usb.enableFlowControl(true); usb.setConnectedCheck(usbConnected); + other.begin(other_stream); other.enableFlowControl(true); tcp.begin(5000); + assert(manager.addInterface(InterfaceType::USB,&usb)); + assert(manager.addInterface(InterfaceType::Bluetooth,&other)); + assert(manager.addInterface(InterfaceType::WiFi,&tcp)); + manager.enable();mesh._serial=&manager;select(stream,&usb); + } + ~Fixture() {tcp.end();} + void select(BufferStream& stream,BaseSerialInterface* target) { + const uint8_t bytes[]={'<',1,0,CMD_SYNC_NEXT_MESSAGE}; + stream.push(bytes,sizeof(bytes)); + assert(manager.checkRecvFrame(command)==1); + assert(manager.captureReplyRoute()==target); + } + std::shared_ptr connect() { + auto socket=std::make_shared(IPAddress(192,168,1,10)); + socket->received={'<',1,0,CMD_SYNC_NEXT_MESSAGE}; + WiFiServer::incoming.push_back(WiFiClient(socket)); + unsigned len=0; + for(unsigned i=0;i<8&&len==0;++i) len=manager.checkRecvFrame(command); + assert(len==1&&manager.captureReplyRoute()==&tcp);return socket; + } + void fill(BaseSerialInterface* route) { + const uint8_t required=0; + for(unsigned i=0;i<4;++i) assert(manager.writeFrameToRoute(route,&required,1)==1); + } + void drain() { + stream.write_capacity=4096;other_stream.write_capacity=4096; + for(unsigned i=0;i<8;++i) {g_mock_millis+=100;manager.loop();manager.checkRecvFrame(command);} + } +}; +struct ShortReply : BaseSerialInterface { + bool enabled=true, connected=true; + size_t accepted=1; unsigned writes=0; + mutable unsigned availability_checks=0; + Bytes last_reply; + void enable() override {enabled=true;} + void disable() override {enabled=false;} + bool isEnabled() const override {return enabled;} + bool isConnected() const override {return connected;} + bool isReadBusy() const override {return false;} + bool isWriteBusy() const override {return false;} + bool isReplyRouteAvailable(BaseSerialInterface* route) const override { + ++availability_checks;return BaseSerialInterface::isReplyRouteAvailable(route); + } + size_t writeFrame(const uint8_t* data,size_t len) override { + ++writes;last_reply.assign(data,data+len);return std::min(accepted,len); + } + size_t checkRecvFrame(uint8_t*) override {return 0;} +}; +int main() { + // Every downloaded frame type must survive a full requester's real UART + // queue, even though another live transport has room to accept it. + for(uint8_t type:{7,8,16,17,27}) { + Fixture f;f.mesh.add(type,42);f.mesh.add(7,43); + f.stream.write_capacity=0;f.fill(&f.usb); + const unsigned ui_changes=f.mesh.ui.changes; + for(unsigned retry=0;retry<5;++retry) f.mesh.sync(); + assert(f.mesh.offline_queue_len==2&&f.mesh.ui.pending==2); + assert(f.mesh.ui.changes==ui_changes&&f.other_stream.output.empty()); + uint8_t peek[MAX_FRAME_SIZE]{}; + assert(f.mesh.peekOfflineQueue(peek)==2&&peek[0]==type&&peek[1]==42); + f.drain();f.mesh.sync();f.mesh.sync();f.mesh.sync(); + const auto received=frames(f.stream.output); + assert(received.size()==7&&received[4]==Bytes({type,42})); + assert(received[5]==Bytes({7,43})&&received[6]==Bytes({10})); + assert(f.mesh.offline_queue_len==0&&f.mesh.ui.pending==0); + assert(f.mesh.ui.changes==ui_changes+2&&f.other_stream.output.empty()); + } + // The application's request can be received just before USB disappears. + // Neither a disconnected route nor a disabled manager consumes the inbox. + for(unsigned fault=0;fault<3;++fault) { + Fixture f;f.mesh.add(7,51);const auto changes=f.mesh.ui.changes; + if(fault==0) usb_connected=false; + if(fault==1) f.usb.disable(); + if(fault==2) f.manager.disable(); + f.mesh.sync();assert(f.mesh.offline_queue_len==1&&f.mesh.ui.changes==changes); + assert(f.stream.output.empty()&&f.other_stream.output.empty()); + usb_connected=true;f.manager.enable();f.select(f.other_stream,&f.other); + f.mesh.sync();assert(f.mesh.offline_queue_len==0); + assert(frames(f.other_stream.output)==std::vector({{7,51}})); + assert(f.stream.output.empty()); + } + // A positive short delivery is still a rejection; null/empty peeks and + // empty commits leave queue and display state unchanged. + { + MyMesh node;ShortReply reply;node._serial=&reply;node.add(7,55); + const auto changes=node.ui.changes; + node.sync();assert(node.offline_queue_len==1&&node.ui.changes==changes); + reply.accepted=MAX_FRAME_SIZE;node.sync();assert(node.offline_queue_len==0); + assert(node.ui.changes==changes+1); + node.popOfflineQueue();assert(node.ui.changes==changes+1); + assert(node.peekOfflineQueue(nullptr)==0); + reply.connected=false;node.add(7,56);node.sync();assert(reply.writes==2); + } + // An empty response still delegates to the captured transport without a + // nonempty-message availability precheck. A zero-length retained head has + // the same behavior and must never be committed as a delivered message. + { + MyMesh node;ShortReply reply;node._serial=&reply;reply.connected=false; + node.sync();assert(reply.writes==1&&reply.availability_checks==0); + assert(reply.last_reply==Bytes({10})&&node.offline_queue_len==0); + node.add(7,57);node.offlineQueueFrameAt(0).len=0; + const auto changes=node.ui.changes; + node.sync();assert(reply.writes==2&&reply.availability_checks==0); + assert(reply.last_reply==Bytes({10})&&node.offline_queue_len==1); + assert(node.ui.changes==changes); + node.offlineQueueFrameAt(0).len=2;reply.connected=true;reply.accepted=MAX_FRAME_SIZE; + node.sync();assert(reply.last_reply==Bytes({7,57})&&node.offline_queue_len==0); + assert(reply.availability_checks==1&&node.ui.changes==changes+1); + } + // Keep the non-flow-controlled UART's existing short-write contract. A + // rejected frame remains pending and is retried in full on a new request. + { + Fixture f;f.usb.enableFlowControl(false);f.stream.max_write=2;f.mesh.add(7,61); + f.mesh.sync();assert(f.mesh.offline_queue_len==1&&f.stream.output.size()==2); + f.stream.output.clear();f.stream.max_write=999;f.mesh.sync(); + assert(f.mesh.offline_queue_len==0&&frames(f.stream.output)==std::vector({{7,61}})); + } + // Actual TCP backpressure must retain the unread head for that requester; + // a different transport can later request it exactly once. + { + Fixture f;auto socket=f.connect();f.fill(&f.tcp);f.mesh.add(7,71); + const auto changes=f.mesh.ui.changes;f.mesh.sync(); + assert(f.mesh.offline_queue_len==1&&f.mesh.ui.changes==changes); + assert(f.stream.output.empty()&&socket->sent.empty()); + f.select(f.stream,&f.usb);f.mesh.sync(); + assert(f.mesh.offline_queue_len==0&&frames(f.stream.output)==std::vector({{7,71}})); + socket->write_limit=4096;f.drain();assert(frames(socket->sent).size()==4); + } + // Circular-head wrap and the held-DM tail must keep FIFO ordering. The + // configured PSRAM capacity follows the actual allocation size. + { + Fixture f;const unsigned capacity=f.mesh.getOfflineQueueCapacity() +#if ONE_KEY_DM_SHARED_OFFLINE_QUEUE + -f.mesh.held_dm_count +#endif + ; +#if ONE_KEY_DM_SHARED_OFFLINE_QUEUE + memset(f.mesh.offline_queue+capacity,0xA5,sizeof(MyMesh::Frame)*f.mesh.held_dm_count); +#endif + for(unsigned round=0;round<3;++round) { + f.drain(); + for(unsigned i=0;i(f.mesh.offline_queue+capacity); + for(unsigned i=0;imarker==0x11223344&&f.mesh.getOfflineQueueCapacity()==128); + f.stream.write_capacity=0;f.fill(&f.usb);f.mesh.sync(); + assert(f.mesh.offline_queue_len==128&&f.mesh.ui.pending==128); + MyMesh::releaseOfflineQueueFromOta(&f.mesh); + assert(f.mesh.getOfflineQueueCapacity()==256);f.drain(); + for(unsigned i=228;i<356;++i) { + uint8_t head[MAX_FRAME_SIZE];assert(f.mesh.peekOfflineQueue(head)==2&&head[1]==uint8_t(i)); + f.mesh.sync(); + } + assert(f.mesh.offline_queue_len==0&&f.mesh.ui.pending==0); + } +#endif +} +''' + + +class CompanionOfflineDeliveryTest(unittest.TestCase): + def test_real_download_paths_in_every_queue_layout(self): + source = (ROOT / "examples/companion_radio/MyMesh.cpp").read_text() + header = (ROOT / "examples/companion_radio/MyMesh.h").read_text() + methods = "\n".join(extract_braced(source, signature) for signature in ( + "bool MyMesh::Frame::isChannelMsg() const", + "int MyMesh::getOfflineQueueCapacity() const", + "MyMesh::Frame& MyMesh::offlineQueueFrameAt(", + "bool MyMesh::addToOfflineQueue(", "int MyMesh::peekOfflineQueue(", + "void MyMesh::popOfflineQueue(", "int MyMesh::getFromOfflineQueue(", + "void MyMesh::syncNextOfflineMessage(", + )) + methods += "\n#if defined(OTA_SHARED_COMPANION_QUEUE)\n" + "\n".join( + extract_braced(source, signature) for signature in ( + "mesh::ota::OtaContext* MyMesh::acquireOfflineQueueForOta(", + "uint16_t MyMesh::drainOfflineQueueForOta(", + "void MyMesh::releaseOfflineQueueFromOta(", + )) + "\n#endif\n" + constants = "\n".join(re.findall( + r"^#define (?:RESP_CODE_(?:CONTACT_MSG_RECV|CHANNEL_MSG_RECV|CHANNEL_DATA_RECV|NO_MORE_MESSAGES)(?:_V3)?|CMD_SYNC_NEXT_MESSAGE)\s+.+$", + source, re.MULTILINE)) + native = (ROOT / "test/test_serial_mode_switch/test_serial_mode_switch.cpp").read_text() + harness = HARNESS.replace("@METHODS@", methods).replace("@CONSTANTS@", constants) + harness = harness.replace("@STREAM@", extract_braced(native, "class BufferStream") + ";") + harness = harness.replace("@FRAME@", extract_braced(header, "struct Frame {") + ";") + harness = harness.replace("@SYNC@", extract_braced(source, "if (cmd_frame[0] == CMD_SYNC_NEXT_MESSAGE)")) + configurations = { + "circular": [], + "held-dm": ["-DONE_KEY_DM_SHARED_OFFLINE_QUEUE=1"], + "mota": ["-DOTA_SHARED_COMPANION_QUEUE=1"], + "psram": ["-DESP32_PLATFORM=1", "-DBOARD_HAS_PSRAM=1"], + } + with tempfile.TemporaryDirectory(prefix="mesh-offline-delivery-") as temp: + work = Path(temp) + path = work / "test.cpp" + path.write_text(harness) + for name, flags in configurations.items(): + with self.subTest(layout=name): + binary = work / name + compiled = subprocess.run([ + "c++", "-std=c++17", "-g", "-Werror", "-fsanitize=address,undefined", + "-fno-sanitize-recover=all", "-fno-pie", "-no-pie", *flags, + "-I", str(ROOT / "test/mocks"), + "-I", str(ROOT / "test/fixtures/serial_wifi_sessions/mocks"), + "-I", str(ROOT / "src"), str(path), + str(ROOT / "src/helpers/ArduinoSerialInterface.cpp"), + str(ROOT / "src/helpers/wifi/SerialWifiInterface.cpp"), + "-o", str(binary), + ], text=True, capture_output=True, timeout=60) + self.assertEqual(compiled.returncode, 0, compiled.stdout + compiled.stderr) + result = subprocess.run([str(binary)], text=True, capture_output=True, timeout=20) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_companion_wifi_ntp.py b/test/test_companion_wifi_ntp.py index dc298f67f..837ee59ce 100644 --- a/test/test_companion_wifi_ntp.py +++ b/test/test_companion_wifi_ntp.py @@ -152,7 +152,8 @@ class CompanionWiFiNtpIntegrationTests(unittest.TestCase): implementation.index("ContactInfo* BaseChatMesh::allocateContactSlot") ] self.assertIn("latest != 0 && latest != UINT32_MAX", bootstrap) - self.assertIn("contact_floor > getRTCClock()->getCurrentTime()", bootstrap) + self.assertIn("const uint32_t current_time = getRTCClock()->getCurrentTime();", bootstrap) + self.assertIn("contact_floor > current_time", bootstrap) if __name__ == "__main__": diff --git a/test/test_contact_clock_bootstrap.py b/test/test_contact_clock_bootstrap.py new file mode 100644 index 000000000..fae4cfbd0 --- /dev/null +++ b/test/test_contact_clock_bootstrap.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python3 +"""Run the actual contact-clock bootstrap against poisoned timestamps.""" +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile +import unittest + +from test_replay_reset_integration import extract_braced + +ROOT = Path(__file__).resolve().parents[1] + +HARNESS = r''' +#include +#include +#include +#include +#include +struct Clock { + uint32_t now; + unsigned reads = 0, writes = 0; + uint32_t getCurrentTime() { ++reads; return now; } + void setCurrentTime(uint32_t value) { ++writes; now = value; } +}; +struct BaseChatMesh { + struct Contact { uint32_t lastmod; }; + std::vector contacts; + int num_contacts = 0; + Clock clock; + BaseChatMesh(uint32_t now, std::initializer_list times) : clock{now} { + for (auto time : times) contacts.push_back({time}); + num_contacts = contacts.size(); + } + Clock* getRTCClock() { return &clock; } + void bootstrapRTCfromContacts(); +}; +@METHODS@ +static void check(uint32_t now, std::initializer_list times, + uint32_t expected, unsigned writes) { + BaseChatMesh mesh(now, times); + mesh.bootstrapRTCfromContacts(); + assert(mesh.clock.now == expected && mesh.clock.writes == writes); + assert(mesh.clock.reads == 1); +} +int main() { + const uint32_t baseline = 1772323200U; // 1 March 2026: nRF52 fallback clock. + const uint32_t plausible = baseline + 86400; + const uint32_t corrupt = 3944678400U; // 1 January 2095. + check(baseline, {}, baseline, 0); + check(baseline, {0, UINT32_MAX, corrupt}, baseline, 0); + check(baseline, {plausible, corrupt, UINT32_MAX}, plausible + 1, 1); + check(baseline, {corrupt, UINT32_MAX, plausible}, plausible + 1, 1); + check(plausible + 1, {baseline, plausible}, plausible + 1, 0); + const uint32_t limit = contactClockBootstrapLimit(0); + assert(limit == 1893456000U); // 1 January 2030, four years past the fixed build year. + check(baseline, {limit, limit + 1, UINT32_MAX - 1}, baseline, 0); + check(baseline, {limit - 1}, limit, 1); + // An old build with an already-set RTC keeps accepting near-current contacts. + const uint32_t old_installation = limit + 5 * 365U * 86400U; + check(old_installation, {old_installation + 86400}, old_installation + 86401, 1); + check(old_installation, {old_installation + 2 * 366U * 86400U}, old_installation, 0); + assert(contactClockBootstrapLimit(UINT32_MAX - 10) == UINT32_MAX); + check(UINT32_MAX - 10, {UINT32_MAX, 0, plausible}, UINT32_MAX - 10, 0); + puts("contact clock corruption, boundary and long-lived firmware checks passed"); +} +''' + + +class ContactClockBootstrapTests(unittest.TestCase): + def test_production_bootstrap(self): + compiler = shutil.which("g++") or shutil.which("clang++") + self.assertIsNotNone(compiler) + source = (ROOT / "src/helpers/BaseChatMesh.cpp").read_text() + methods = "\n".join(extract_braced(source, signature) for signature in ( + "static uint32_t contactClockBootstrapLimit(", + "void BaseChatMesh::bootstrapRTCfromContacts()")) + with tempfile.TemporaryDirectory(prefix="contact-clock-bootstrap-") as directory: + work = Path(directory) + (work / "test.cpp").write_text(HARNESS.replace("@METHODS@", methods)) + binary = work / "bootstrap" + cmd = [compiler, "-std=c++17", "-Wall", "-Wextra", "-Werror", + '-D__DATE__="Oct 9 2026"', "-Wno-builtin-macro-redefined", + str(work / "test.cpp"), "-o", str(binary)] + if sys.platform.startswith("linux"): + cmd[1:1] = ["-fsanitize=address,undefined", "-fno-sanitize-recover=all", + "-fno-pie", "-no-pie"] + built = subprocess.run(cmd, capture_output=True, text=True, timeout=60) + self.assertEqual(built.returncode, 0, built.stdout + built.stderr) + tested = subprocess.run([str(binary)], capture_output=True, text=True, timeout=10) + self.assertEqual(tested.returncode, 0, tested.stdout + tested.stderr) + self.assertIn("long-lived firmware checks passed", tested.stdout) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_espnow_radio_lifecycle.py b/test/test_espnow_radio_lifecycle.py index 7576f8058..d1f587a8b 100644 --- a/test/test_espnow_radio_lifecycle.py +++ b/test/test_espnow_radio_lifecycle.py @@ -218,6 +218,7 @@ public: int _active_format=0,_rx_mux=0,_rx_head=0,_rx_tail=0,_rx_count=0,_rx_dropped=0,_rx_dropped_reported=0; int _tx_mux=0,_tx_head=0,_tx_tail=0,_tx_count=0,_tx_waiting=0,_tx_callback_done=0; int _tx_callback_status=0,_tx_dropped=0,_tx_dropped_reported=0; + uint32_t _tx_started_at=0,_tx_retry_at=0;bool _sdk_teardown_failed=false; mesh::espnow::ESPNowRawReassembler _raw_reassembler; struct QueuedTransmit {bool started=false;int packet=0;}; static constexpr int TX_QUEUE_DEPTH=2; diff --git a/test/test_firmware_ram.py b/test/test_firmware_ram.py index 7cc651941..b9db6b67b 100644 --- a/test/test_firmware_ram.py +++ b/test/test_firmware_ram.py @@ -181,8 +181,8 @@ class FirmwareRamTest(unittest.TestCase): combined = ram.requirements("ESP32_PLATFORM", { **defines, "WITH_RS232_BRIDGE": "Serial2", "RS232_BRIDGE_MERGED": 1, }, "v3_repeater") - self.assertEqual(combined["required_heap_bytes"] - mqtt["required_heap_bytes"], 8192) - self.assertEqual(combined["components"]["uart_bridge_and_driver"], 8192) + self.assertEqual(combined["required_heap_bytes"] - mqtt["required_heap_bytes"], 2560 + 4096) + self.assertEqual(combined["components"]["uart_bridge_and_driver"], 2560 + 4096) # A selectable alternate port does not create two UART owners. alternate = ram.requirements("ESP32_PLATFORM", { **defines, "WITH_RS232_BRIDGE": "Serial2", "RS232_BRIDGE_MERGED": 1, @@ -303,6 +303,65 @@ class FirmwareRamTest(unittest.TestCase): }, "RAK_4631_repeater") self.assertEqual(ordinary["components"]["display_pixels_and_driver"], 4096) + def test_esp32_fixed_oled_and_uart_bounds_preserve_full_runtime_reserves(self): + defines = { + "WIFI_OTA_SEEDER": 1, "WITH_MQTT_BRIDGE": 1, + "WITH_RS232_BRIDGE": "Serial2", "WITH_ESPNOW_BRIDGE": 1, + "ENABLE_OTA": 1, "ADMIN_PASSWORD": "test", + "MESHCORE_OTA_DEVICE_DEFLATE": 1, + "MESH_CLIENT_REPEATER_ONLY": 1, + "DISPLAY_CLASS": "SSD1306Display", + } + full = ram.requirements("ESP32_PLATFORM", defines, "Heltec_v3_repeater_observer_mqtt") + parts = full["components"] + self.assertEqual(full["required_heap_bytes"], 209184) + self.assertEqual(parts["display_pixels_and_driver"], 2048) + self.assertEqual(parts["uart_bridge_and_driver"], 2560 + 4096) + self.assertEqual(parts["wireless_stacks"], 49152) + self.assertEqual(parts["mqtt_connections_buffers"], 24576) + self.assertEqual(parts["allocation_and_transient_margin"], 16384) + self.assertEqual(full["required_contiguous_bytes"], 18464) + for driver, expected in (("SH1106Display", 4096), ("SH1107Display", 4096), + ("ST7735Display", 25602)): + with self.subTest(driver=driver): + other = ram.requirements("ESP32_PLATFORM", { + **defines, "DISPLAY_CLASS": driver, + }, "Heltec_v3_repeater_observer_mqtt") + self.assertEqual(other["components"]["display_pixels_and_driver"], expected) + + def test_source_allocation_guards_reject_growth_at_the_budget_boundary(self): + # Compile the production guards themselves with exact-size stand-ins. + # Actual firmware compilation additionally checks the real classes; + # these negative cases prove future growth cannot silently bypass them. + sources = ( + ("src/helpers/bridges/RS232Bridge.cpp", "RS232Bridge::RS232Bridge(", + "struct RS232Bridge { unsigned char bytes[OBJECT_BYTES]; };\n", + ((2304, 0, True), (2305, 0, False))), + ("src/helpers/ui/SSD1306Display.cpp", "bool SSD1306Display::i2c_probe(", + "struct SSD1306Display { unsigned char bytes[OBJECT_BYTES]; " + "static constexpr unsigned FRAMEBUFFER_BYTES = FRAME_BYTES; };\n", + ((992, 1024, True), (993, 1024, False), (124, 1025, False))), + ) + for path, end, stand_in, cases in sources: + prefix = (ROOT / path).read_text().split(end, 1)[0] + prefix = "\n".join(line for line in prefix.splitlines() + if not line.lstrip().startswith("#include")) + if path.endswith("RS232Bridge.cpp"): + prefix += "\n#endif\n" # WITH_RS232_BRIDGE also encloses the methods below. + for platform in (("ESP32=1", "ESP32_PLATFORM=1"), + ("NRF52_PLATFORM=1", "RAK4631_COMBINED_ETHERNET=1"), + ("NRF52_PLATFORM=1",)): + for size, frame, bounded in cases: + with self.subTest(path=path, platform=platform, size=size, frame=frame): + result = subprocess.run([ + "c++", "-std=c++17", "-fsyntax-only", "-x", "c++", "-", + "-DWITH_RS232_BRIDGE=1", f"-DOBJECT_BYTES={size}", + f"-DFRAME_BYTES={frame}", *["-D" + flag for flag in platform], + ], input=stand_in + prefix, text=True, capture_output=True) + enforced = "ESP32=1" in platform or "RAK4631_COMBINED_ETHERNET=1" in platform + self.assertEqual(result.returncode == 0, bounded or not enforced, + result.stderr) + def test_combined_rak_budget_rejects_unmatched_hardware_or_short_task_stack(self): defines = { "RAK_4631": 1, "ETHERNET_ENABLED": 1, "OTA_RAK_AUTO_STORE": 1, diff --git a/test/test_nrf52_size_profile.py b/test/test_nrf52_size_profile.py new file mode 100644 index 000000000..aa93f6585 --- /dev/null +++ b/test/test_nrf52_size_profile.py @@ -0,0 +1,194 @@ +#!/usr/bin/env python3 +"""Exercise the sourced nRF52 recipe helper without invoking PlatformIO.""" + +import configparser +import os +from pathlib import Path +import re +import shlex +import subprocess +import unittest + + +ROOT = Path(__file__).resolve().parents[1] +CONFIG = configparser.ConfigParser(interpolation=None, inline_comment_prefixes=(";",)) +CONFIG.read(ROOT / "platformio.ini") +BASE_FLAGS = shlex.split(CONFIG["nrf52_base"]["build_flags"]) +SEED_FLAGS = " ".join(( + "-DKEEP_CALLER_FLAG=1", "-DENABLE_OTA=1", "-DOTA_FLASH_STORE=1", + "-DUSE_CC310_HW_CRYPTO=1", "-DENV_INCLUDE_BME680_BSEC=1", + "-DMESH_NRF52_LOOP_STACK_WORDS=2048", "-DMAX_NEIGHBOURS=50", + "-DFLOOD_PACKET_FILTER_SLOTS=63", +)) + + +def recipe(target, *, platform="NRF52_PLATFORM", ota=True, profile="standard", full=False): + # Use the real eligibility predicates as well as the actual mutating helper. + # A pio guard makes an accidental expansion into a firmware build fail fast. + script = r''' +set -euo pipefail +pio() { printf 'unexpected PlatformIO invocation\n' >&2; return 99; } +source ./build.sh +target=$1 +PIO_ENV_PLATFORM_BY_NAME[$target]=$2 +PIO_ENV_OTA_BY_NAME[$target]=$3 +BUILD_PROFILE_FOR_TARGET=$4 +BUILD_PROFILE_EFFECTIVE=$4 +MESHDEBUG_OVERRIDE= +PACKET_LOGGING_OVERRIDE= +MQTT_BRIDGE_OVERRIDE= +PLATFORMIO_BUILD_FLAGS=$5 +PLATFORMIO_BUILD_UNFLAGS=-DKEEP_EXISTING_UNFLAG=1 +BUILD_REDUCTIONS=() +repeater=0; full=0; lora_ota=0; ota_only=0 +if is_repeater_role_target "$target"; then repeater=1; fi +if is_nrf52_companion_radio_full_target "$target"; then full=1; fi +if is_lora_ota_build "$target"; then lora_ota=1; fi +if is_lora_ota_only_target "$target"; then ota_only=1; fi +apply_nrf52_size_profile "$target" +if [ "$6" = 1 ]; then + # Supply actual option data to the real query helper without asking PIO to + # resolve the configuration. Both source directories already exist here. + printf -v PIO_CONFIG_JSON '[ [ "env:%s", [ [ "build_src_filter", [ "+", "+" ] ] ] ] ]' "$target" + PLATFORMIO_BUILD_SRC_FILTER= + apply_companion_radio_full_profile "$target" "$target" +fi +printf '%s\0' "$PLATFORMIO_BUILD_FLAGS" "$PLATFORMIO_BUILD_UNFLAGS" \ + "$repeater" "$full" "$lora_ota" "$ota_only" "${BUILD_REDUCTIONS[*]}" +''' + environment = dict(os.environ) + for name in ("PLATFORMIO_BUILD_FLAGS", "PLATFORMIO_BUILD_UNFLAGS"): + environment.pop(name, None) + completed = subprocess.run( + ["bash", "-c", script, "size-profile-test", target, platform, + str(int(ota)), profile, SEED_FLAGS, str(int(full))], cwd=ROOT, env=environment, + capture_output=True, check=True, + ) + fields = completed.stdout.decode().split("\0") + if len(fields) != 8 or fields[-1]: + raise AssertionError("Unexpected sourced recipe output: %r" % completed.stdout) + return dict(zip(("flags", "unflags", "repeater", "full", "ota", "ota_only", + "reductions"), fields[:-1])) + + +class Nrf52SizeProfileTest(unittest.TestCase): + def assert_preserved_policy(self, result): + flags = BASE_FLAGS + shlex.split(result["flags"]) + optimizers = [flag for flag in flags if re.fullmatch(r"-O(?:[0-3sgz]|fast)", flag)] + # GCC uses the last optimization flag: -Oz followed by -Os regresses + # Full Companion even though a simple "contains -Oz" assertion passes. + self.assertTrue(optimizers) + self.assertEqual(optimizers[-1], "-Oz") + self.assertNotIn("-Os", shlex.split(result["flags"])) + self.assertIn("-Ofast", shlex.split(result["unflags"])) + self.assertIn("-DKEEP_EXISTING_UNFLAG=1", shlex.split(result["unflags"])) + self.assertEqual(result["flags"], SEED_FLAGS) + self.assertEqual(result["reductions"], "") + self.assertIn("MESH_NRF52_LOOP_STACK_WORDS=2048", flags) + self.assertIn("-Wl,--wrap=xTaskCreate", flags) + self.assertIn("USE_CC310_HW_CRYPTO=1", flags) + + def test_repeater_inherits_size_optimizer_without_feature_reductions(self): + result = recipe("RAK_4631_repeater") + self.assertEqual(result["repeater"], "1") + self.assertEqual(result["full"], "0") + self.assert_preserved_policy(result) + + def test_full_companion_inherits_size_optimizer_without_feature_reductions(self): + result = recipe("RAK_4631_companion_radio_full") + self.assertEqual(result["repeater"], "0") + self.assertEqual(result["full"], "1") + self.assert_preserved_policy(result) + + def test_ethernet_inherits_size_optimizer_without_feature_reductions(self): + result = recipe("RAK_4631_companion_radio_ethernet") + self.assertEqual(result["repeater"], "0") + self.assertEqual(result["full"], "0") + self.assertEqual(result["ota_only"], "0") + self.assert_preserved_policy(result) + + def test_constrained_sensor_ota_uses_real_opt_in_eligibility(self): + result = recipe("RAK_3401_sensor_lora_ota_no_external_sensors") + self.assertEqual(result["repeater"], "0") + self.assertEqual(result["full"], "0") + self.assertEqual(result["ota"], "1") + self.assertEqual(result["ota_only"], "1") + self.assert_preserved_policy(result) + + def test_nrf52_full_seeder_compresses_all_names_and_preserves_capabilities(self): + for target in ("RAK_4631_companion_radio_full", "t1000e_companion_radio_full"): + with self.subTest(target=target): + result = recipe(target, full=True) + flags = shlex.split(result["flags"]) + self.assertEqual(result["full"], "1") + self.assertIn("-DOTA_TARGET_NAME_FRONT_CODED=1", flags) + self.assertNotIn("-DOTA_TARGET_NAME_TABLE=0", flags) + for flag in ("-DOTA_SEEDER_ONLY=1", "-DCOMPANION_RADIO_FULL=1", + "-DCOMPANION_FEATURE_USB_MOTA_SOURCE=1", + "-DCOMPANION_FEATURE_BLE_MOTA_SOURCE=1", + "-DCOMPANION_FEATURE_TEMP_RADIO=1", + "-DCOMPANION_FEATURE_OTA_CLI=1", + "-DOFFLINE_QUEUE_SIZE=256", "-DOTA_SHARED_COMPANION_QUEUE=1", + "-DMESH_NRF52_LOOP_STACK_WORDS=2048", + "-DUSE_CC310_HW_CRYPTO=1", "-DENV_INCLUDE_BME680_BSEC=1"): + self.assertIn(flag, flags) + self.assertNotIn("-DED25519_COMPACT_BASE=1", flags) + self.assertNotIn("-DED25519_COMPACT_SHA512=1", flags) + optimizers = [flag for flag in BASE_FLAGS + flags + if re.fullmatch(r"-O(?:[0-3sgz]|fast)", flag)] + self.assertEqual(optimizers[-1], "-Oz") + dedicated = "-DCOMPANION_FEATURE_DEDICATED_USB_LOGGING=1" + self.assertEqual(dedicated in flags, target != "t1000e_companion_radio_full") + + def test_name_compression_does_not_expand_ordinary_companion_or_esp32_policy(self): + for target, platform in (("RAK_4631_companion_radio_usb", "NRF52_PLATFORM"), + ("RAK_4631_companion_radio_ble", "NRF52_PLATFORM"), + ("heltec_v4_companion_radio_full", "ESP32_PLATFORM")): + with self.subTest(target=target): + result = recipe(target, platform=platform, full=True) + flags = shlex.split(result["flags"]) + self.assertNotIn("-DOTA_TARGET_NAME_FRONT_CODED=1", flags) + if platform == "NRF52_PLATFORM": + self.assertEqual(result["flags"], SEED_FLAGS) + self.assertEqual(result["unflags"], "-DKEEP_EXISTING_UNFLAG=1") + + def test_non_eligible_platforms_roles_and_ota_opt_out_are_unchanged(self): + cases = ( + ("Heltec_v4_repeater", "ESP32_PLATFORM", True), + ("RAK_3x72_companion_radio_usb", "STM32_PLATFORM", True), + ("RAK_4631_companion_radio_usb", "NRF52_PLATFORM", True), + ("RAK_3401_sensor_lora_ota_no_external_sensors", "NRF52_PLATFORM", False), + ("RAK_3401_sensor", "NRF52_PLATFORM", True), + ) + for target, platform, ota in cases: + with self.subTest(target=target, platform=platform, ota=ota): + result = recipe(target, platform=platform, ota=ota) + self.assertEqual(result["flags"], SEED_FLAGS) + self.assertEqual(result["unflags"], "-DKEEP_EXISTING_UNFLAG=1") + self.assertEqual(result["reductions"], "") + + def test_measured_table_reductions_keep_stack_sensors_and_ota(self): + cases = ( + ("Heltec_t096_repeater_lora_ota_no_external_sensors", 4), + ("Heltec_t1_repeater_lora_ota_no_external_sensors", 4), + ("RAK_3401_repeater_unified_lora_ota", 47), + ) + for target, slots in cases: + with self.subTest(target=target): + result = recipe(target) + self.assertEqual(result["repeater"], "1") + self.assertEqual(result["flags"], + SEED_FLAGS + " -DFLOOD_PACKET_FILTER_SLOTS=%d" % slots) + unflags = shlex.split(result["unflags"]) + self.assertIn("-Ofast", unflags) + self.assertIn("FLOOD_PACKET_FILTER_SLOTS=63", unflags) + self.assertIn("8 KiB loop stack", result["reductions"]) + self.assertIn("sensors", result["reductions"]) + self.assertIn("OTA retained", result["reductions"]) + optimizers = [flag for flag in BASE_FLAGS + shlex.split(result["flags"]) + if re.fullmatch(r"-O(?:[0-3sgz]|fast)", flag)] + self.assertEqual(optimizers[-1], "-Oz") + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_one_key_dm_shared_queue.py b/test/test_one_key_dm_shared_queue.py index de427e006..2204b8f63 100644 --- a/test/test_one_key_dm_shared_queue.py +++ b/test/test_one_key_dm_shared_queue.py @@ -46,6 +46,8 @@ class OneKeyDMSharedQueueTest(unittest.TestCase): "MyMesh::Frame& MyMesh::heldDMFrameAt(uint8_t index)", "void MyMesh::removeHeldOneKeyDM(uint8_t index)", "bool MyMesh::addToOfflineQueue(const uint8_t frame[], int len)", + "int MyMesh::peekOfflineQueue(uint8_t frame[])", + "void MyMesh::popOfflineQueue()", "int MyMesh::getFromOfflineQueue(uint8_t frame[])", )) harness = r''' @@ -77,6 +79,8 @@ public: Frame& heldDMFrameAt(uint8_t index); void removeHeldOneKeyDM(uint8_t index); bool addToOfflineQueue(const uint8_t frame[], int len); + int peekOfflineQueue(uint8_t frame[]); + void popOfflineQueue(); int getFromOfflineQueue(uint8_t frame[]); }; static_assert(sizeof(MyMesh::Frame) == 177); @@ -161,6 +165,8 @@ int main() { "MyMesh::Frame& MyMesh::heldDMFrameAt(uint8_t index)", "void MyMesh::removeHeldOneKeyDM(uint8_t index)", "bool MyMesh::addToOfflineQueue(const uint8_t frame[], int len)", + "int MyMesh::peekOfflineQueue(uint8_t frame[])", + "void MyMesh::popOfflineQueue()", "int MyMesh::getFromOfflineQueue(uint8_t frame[])", "bool MyMesh::onAddressedTextPacket(mesh::Packet* packet, uint8_t src_hash,", "void MyMesh::releaseHeldOneKeyDMs()", @@ -237,6 +243,8 @@ public: Frame& heldDMFrameAt(uint8_t index); void removeHeldOneKeyDM(uint8_t index); bool addToOfflineQueue(const uint8_t frame[], int len); + int peekOfflineQueue(uint8_t frame[]); + void popOfflineQueue(); int getFromOfflineQueue(uint8_t frame[]); bool onAddressedTextPacket(mesh::Packet*, uint8_t, const uint8_t*, size_t); void releaseHeldOneKeyDMs(); diff --git a/test/test_ota_cli_listing.py b/test/test_ota_cli_listing.py new file mode 100644 index 000000000..1165dc200 --- /dev/null +++ b/test/test_ota_cli_listing.py @@ -0,0 +1,77 @@ +"""Exercise the actual outlined OTA dispatch/listing with the real name decoder.""" +import os +import re +import shutil +import subprocess +import tempfile +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +SOURCE = ROOT / "src/helpers/ota/OtaCli.cpp" +FIXTURE = ROOT / "test/fixtures/ota_cli_listing/test.cpp" + + +def function(text, name): + # Definitions have an opening brace, unlike the forward control declaration. + match = re.search(r"^(?:static )?(?:__attribute__\(\(noinline\)\) )?[^;{}\n]+\b" + name + r"\([^;{}]*\)\s*\{", text, re.M) + if not match: + raise AssertionError(f"Missing production function: {name}") + start = match.start() + depth = 0 + # Strip strings/comments for brace counting; retain original source for compile. + tokens = re.finditer(r'"(?:\\.|[^"\\])*"|\'(?:\\.|[^\'\\])*\'|//[^\n]*|/\*.*?\*/|[{}]', text[match.end() - 1:], re.S) + for token in tokens: + if token.group() == "{": + depth += 1 + elif token.group() == "}": + depth -= 1 + if depth == 0: + return text[start:match.end() - 1 + token.end()] + raise AssertionError(f"Unterminated production function: {name}") + + +class OtaCliListingTest(unittest.TestCase): + def test_real_dispatch_listing_and_all_target_names(self): + source = SOURCE.read_text() + names = ("parse_page", "codec_kind", "state_word", "fetch_error_word", "ver_str", "is_cmd", "handle_status", "handle_neighbors", "handle_ota_command") + bodies = "\n\n".join(function(source, name) for name in names) + bodies = "static __attribute__((noinline)) bool handle_control_command(const char*, char*, mesh::MainBoard&, OtaContext&);\n" + bodies + harness = FIXTURE.read_text().replace("// @PRODUCTION_FUNCTIONS@", bodies) + rows = re.findall(r'\{ 0x([0-9a-fA-F]{8}), "([^"]+)" \},', (ROOT / "src/helpers/ota/OtaTargets.h").read_text()) + self.assertGreater(len(rows), 600) + input_text = "\n".join(f"{target} {name}" for target, name in rows) + "\n" + with tempfile.TemporaryDirectory() as directory: + directory = Path(directory) + unit = directory / "listing.cpp" + unit.write_text(harness) + flags = ["-g", "-O2", "-fno-omit-frame-pointer", "-fno-pie", "-fsanitize=address,undefined"] + decoder = directory / "tinf.o" + subprocess.run([os.environ.get("CC") or shutil.which("cc") or "gcc", *flags, + "-DMESHCORE_TINF_IMPLEMENTATION=1", "-c", str(ROOT / "src/helpers/ota/tinf/tinflate.c"), "-o", str(decoder)], check=True) + for compact in (0, 1): + for seeder in (False, True): + with self.subTest(compact=compact, seeder=seeder): + executable = directory / f"listing-{compact}-{seeder}" + defines = [f"-DOTA_TARGET_NAME_FRONT_CODED={compact}", "-DNRF52_PLATFORM=1"] + if seeder: + defines.append("-DOTA_SEEDER_ONLY=1") + subprocess.run([os.environ.get("CXX") or shutil.which("c++") or "g++", "-std=c++11", *flags, "-no-pie", + "-Wall", "-Wextra", "-Wno-unused-function", *defines, f"-I{ROOT / 'src'}", + str(unit), str(decoder), "-o", str(executable)], check=True) + subprocess.run([str(executable)], input=input_text, text=True, check=True, + env={**os.environ, "ASAN_OPTIONS": "detect_leaks=0:halt_on_error=1", "UBSAN_OPTIONS": "halt_on_error=1"}) + + def test_name_decoder_has_separate_stack_boundary(self): + source = SOURCE.read_text() + dispatcher = function(source, "handle_ota_command") + control = function(source, "handle_control_command") + self.assertNotIn("ota_target_env_name", dispatcher) + self.assertNotIn("ota_target_env_name", control) + for name in ("handle_status", "handle_neighbors", "handle_control_command"): + self.assertIn("__attribute__((noinline))", function(source, name).split("{", 1)[0]) + self.assertIn(name, dispatcher) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_ota_nrf52_display_cache.py b/test/test_ota_nrf52_display_cache.py index 0f39bbc0b..b2fae8eeb 100644 --- a/test/test_ota_nrf52_display_cache.py +++ b/test/test_ota_nrf52_display_cache.py @@ -114,7 +114,9 @@ const char* ota_target_env_name(uint32_t) { return "test"; } uint8_t ota_bootloader_last_rc() { return 0xB8; } bool ota_nrf52_boot_update_result(uint8_t) { return false; } @CLI_HELPERS@ -void status(Context& c, char* reply) { @STATUS@ } +using OtaContext = Context; +@STATUS@ +void status(Context& c, char* reply) { assert(handle_status(reply, c)); } void stats(Context& c, char* reply) { @STATS@ } static SelfFwInfo image(uint32_t body, uint8_t tag) { @@ -291,7 +293,7 @@ class OtaNrf52DisplayCacheTest(unittest.TestCase): geometry = method(layout, "inline bool mota_nrf52_stage_ceiling_valid(") + "\n" + method( layout, "inline bool mota_nrf52_layout_valid(uint32_t app_base, uint32_t stage_ceiling)") cli = (ROOT / "src/helpers/ota/OtaCli.cpp").read_text(encoding="utf-8") - status = method(cli, '} else if (*a == 0 || is_cmd(a, "status|st", &rest))') + status = method(cli, "static __attribute__((noinline)) bool handle_status(") stats = method(cli, '} else if (is_cmd(a, "stats", &rest))') helpers = "\n".join(method(cli, signature) for signature in ( "static const char* state_word(", "static const char* state_short(", @@ -302,7 +304,7 @@ class OtaNrf52DisplayCacheTest(unittest.TestCase): "FRESH": method(nrf_source, "bool ota_self_firmware(SelfFwInfo& out)"), "DISPLAY": method(self_source, "bool ota_self_firmware_for_display(SelfFwInfo& out)"), "CLI_HELPERS": helpers, - "STATUS": status[status.index("{") + 1:-1], + "STATUS": status, "STATS": stats[stats.index("{") + 1:-1], }.items(): source = source.replace("@" + token + "@", value) diff --git a/test/test_protocol_parser_bounds.py b/test/test_protocol_parser_bounds.py new file mode 100644 index 000000000..369a9abff --- /dev/null +++ b/test/test_protocol_parser_bounds.py @@ -0,0 +1,89 @@ +#!/usr/bin/env python3 +"""Run the production advert parser and anonymous repeater handlers under ASan/UBSan. + +Radio/allocator boundaries are doubles. Path decoding, all three anonymous +handlers, their receive dispatcher, and the advert implementation are real. +""" + +from pathlib import Path +import os +import re +import shutil +import subprocess +import sys +import tempfile +import unittest + +from test_replay_reset_integration import extract_braced + + +ROOT = Path(__file__).resolve().parents[1] +FIXTURE = ROOT / "test/fixtures/protocol_parser_bounds/test.cpp" +SANITIZERS = (["-fsanitize=address,undefined", "-fno-sanitize-recover=all", + "-fno-pie", "-no-pie"] if sys.platform.startswith("linux") else []) + + +class ProtocolParserBoundsTests(unittest.TestCase): + def test_production_parsers_with_exact_buffers_and_all_bridge_modes(self): + compiler = os.environ.get("CXX") or shutil.which("g++") or shutil.which("clang++") + if compiler is None: + self.skipTest("a host C++17 compiler is required") + repeater = (ROOT / "examples/simple_repeater/MyMesh.cpp").read_text() + packet = (ROOT / "src/Packet.cpp").read_text() + identity = (ROOT / "src/Identity.cpp").read_text() + handlers = "\n".join(extract_braced(repeater, signature) for signature in ( + "static bool hasCompleteAnonReplyPath(", + "uint8_t MyMesh::handleAnonRegionsReq(", + "uint8_t MyMesh::handleAnonOwnerReq(", + "uint8_t MyMesh::handleAnonClockReq(", + "void MyMesh::onAnonDataRecv(", + )) + constructors = "namespace mesh {\n" + "\n".join( + extract_braced(packet, signature) for signature in ( + "Packet::Packet()", "bool Packet::isValidPathLen(", + "size_t Packet::writePath(")) + constructors += "\n" + extract_braced(identity, "Identity::Identity()") + "\n}\n" + constants = "\n".join(re.findall( + r"^#define ANON_REQ_TYPE_(?:REGIONS|OWNER|BASIC)\s+.+$", + repeater, re.MULTILINE)) + constants += "\n" + re.search( + r"static constexpr size_t MAX_ANON_REPLY_LEN =[^;]+;", repeater).group() + constants += "\n" + re.search( + r"static constexpr uint32_t LOGIN_PATH_OBSERVATION_TIMEOUT_MS =[^;]+;", + repeater).group() + constants += "\n" + re.search( + r"^\s*#define SERVER_RESPONSE_DELAY\s+.+$", repeater, re.MULTILINE).group() + constants += "\n" + re.search( + r"^#define OUT_PATH_UNKNOWN\s+.+$", + (ROOT / "src/helpers/ContactInfo.h").read_text(), re.MULTILINE).group() + constants += "\n" + re.search( + r"^#define REGION_DENY_FLOOD\s+.+$", + (ROOT / "src/helpers/RegionMap.h").read_text(), re.MULTILINE).group() + + with tempfile.TemporaryDirectory(prefix="meshcore-parser-bounds-") as directory: + work = Path(directory) + (work / "production_handlers.inc").write_text(handlers) + (work / "production_packet.inc").write_text(constructors) + (work / "production_constants.inc").write_text(constants) + variants = (("ordinary", []), ("rs232", ["-DWITH_RS232_BRIDGE=1"]), + ("espnow", ["-DWITH_ESPNOW_BRIDGE=1"]), + ("both", ["-DWITH_RS232_BRIDGE=1", "-DWITH_ESPNOW_BRIDGE=1"])) + for name, defines in variants: + with self.subTest(bridge=name): + binary = work / f"bounds-{name}" + built = subprocess.run([ + compiler, "-std=c++17", "-Wall", "-Wextra", "-Werror", + "-Wno-unused-parameter", "-Wno-reorder", "-Wno-sign-compare", + *SANITIZERS, *defines, f"-I{work}", f"-I{ROOT / 'src'}", + f"-I{ROOT / 'test/mocks'}", f"-I{ROOT}", + str(FIXTURE), str(ROOT / "src/helpers/AdvertDataHelpers.cpp"), + "-o", str(binary), + ], capture_output=True, text=True, timeout=60) + self.assertEqual(built.returncode, 0, built.stdout + built.stderr) + checked = subprocess.run([str(binary)], capture_output=True, text=True, timeout=30) + self.assertEqual(checked.returncode, 0, checked.stdout + checked.stderr) + self.assertIn("PASS: production protocol parser bounds", checked.stdout) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_radio_interrupt_recovery.py b/test/test_radio_interrupt_recovery.py new file mode 100644 index 000000000..a09126251 --- /dev/null +++ b/test/test_radio_interrupt_recovery.py @@ -0,0 +1,385 @@ +"""Execute real ISR handoff, wrapper lifecycle and LR2021 receive failure paths.""" +from pathlib import Path +import os +import subprocess +import tempfile +import unittest + +from test_radio_receive_contract import method + +ROOT = Path(__file__).resolve().parents[1] + + +def compile_run(code, defines=()): + with tempfile.TemporaryDirectory(prefix="meshcore-radio-irq-") as tmp: + cpp, exe = Path(tmp) / "test.cpp", Path(tmp) / "test" + cpp.write_text(code) + command = [os.environ.get("CXX", "g++"), "-std=c++17", "-O1", "-Wall", "-Wextra", + "-fsanitize=address,undefined", "-fno-omit-frame-pointer", "-fno-pie", "-no-pie", + "-pthread", "-I", str(ROOT / "src"), *defines, str(cpp), "-o", str(exe)] + result = subprocess.run(command, capture_output=True, text=True) + if result.returncode: + raise AssertionError(result.stderr) + result = subprocess.run([str(exe)], capture_output=True, text=True) + if result.returncode: + raise AssertionError(result.stderr) + + +PRELUDE = r''' +#include +#include +#include +#include +#include +#define STATE_IDLE 0 +#define STATE_RX 1 +#define STATE_TX_WAIT 3 +#define RADIOLIB_ERR_NONE 0 +#define RADIOLIB_ERR_UNSUPPORTED -2 +#define RADIOLIB_ERR_PACKET_TOO_SHORT -5 +#define RADIOLIB_ERR_SPI_CMD_TIMEOUT -77 +#define NF_CALIB_SETTLE_MS 7 +#define RADIOLIB_LR2021_RX_TIMEOUT_INF UINT32_MAX +#define RADIOLIB_IRQ_RX_DEFAULT_FLAGS 0 +#define RADIOLIB_IRQ_RX_DEFAULT_MASK 0 +#define RADIOLIB_IRQ_PREAMBLE_DETECTED 5 +#define RADIOLIB_IRQ_RX_DONE 1 +#define RADIOLIB_IRQ_TX_DONE 2 +#define RADIOLIB_LR2021_IRQ_RX_DONE 1 +#define RADIOLIB_LR2021_IRQ_PREAMBLE_DETECTED 32 +#define RADIOLIB_LR2021_IRQ_LORA_HEADER_VALID 64 +#define RADIOLIB_LR2021_IRQ_LORA_HDR_CRC_ERROR 512 +#define MESH_DEBUG_PRINTLN(...) ((void)0) +static uint32_t now_ms=100; +uint32_t millis(){return now_ms;} +struct LR2021 { + bool stopped=false, inject_read_irq=false; + int rx_status=1, tx_status=1, fifo_status=0, clear_status=0, base_status=0, standby_status=0; + uint32_t irq=0; + uint16_t fifo=4; + int packet_length=4; + unsigned reads=0, clears=0, standbys=0, arms=0, irq_clears=0, tx_starts=0; + void (*callback)()=nullptr; + void clearPacketReceivedAction(){callback=nullptr;} + void setPacketReceivedAction(void (*fn)()){callback=fn;} + int checkIrq(int type){return type==RADIOLIB_IRQ_RX_DONE?rx_status:tx_status;} + int standby(){++standbys;if(standby_status)return standby_status;stopped=true;return 0;} + int clearIrqFlags(uint32_t){assert(stopped);++irq_clears;rx_status=0;return 0;} + int getPacketLength(){return packet_length;} + float getSNR(){return 7;} + float getRSSI(){return -90;} + int getRxFifoLevel(uint16_t* output){*output=fifo;return fifo_status;} + int clearRxFifo(){++clears;if(clear_status)return clear_status;fifo=0;return 0;} + virtual int16_t readData(uint8_t* output,size_t len){ + ++reads; +#if defined(USE_LR2021) + // Real RadioLib clears FIFO and all IRQs. A concurrent packet must not be + // able to modify the FIFO while those destructive operations run. + assert(stopped); +#endif + memset(output,0xa5,len);fifo=0; + if(inject_read_irq && !stopped && callback) callback(); + return base_status; + } + virtual int16_t startReceive(){++arms;stopped=false;return 0;} + int16_t startReceive(uint32_t,uint32_t,uint32_t,int){assert(stopped);++arms;stopped=false;return 0;} + int startTransmit(uint8_t*,int){++tx_starts;return 0;} + int finishTransmit(){stopped=true;return 0;} + uint32_t getIrqStatus(){return irq;} +}; +struct Chip : LR2021 { + uint32_t _activityAt=0, _preambleMillis=66, _maxPayloadMillis=3934; + bool _headerSeen=false; + bool isChipBusy(){return false;} + @LR_METHODS@ +}; +struct Board { + unsigned before=0,after=0; + void (*before_hook)()=nullptr; + void onBeforeTransmit(){++before;if(before_hook)before_hook();} + void onAfterTransmit(){++after;} +}; +struct RadioLibWrapper { + uint8_t state=STATE_RX, _interrupt_slot=0xff, _irq_probe_failures=0; + bool _loop_event_pending=false, _cw_active=false; + bool _rx_ps_armed=false, _rx_hold_continuous=false, _rx_ps_enabled=false; + bool _rx_ps_continuous_fallback=false, _nf_calib_active=false; + float _last_snr=0, _last_rssi=0; + unsigned n_recv=0,n_recv_errors=0,n_sent=0; + uint32_t last_recv_millis=0,last_radio_interrupt_millis=0,_nf_sample_from=0; + Board board;Board* _board=&board; + Chip chip;Chip* _radio=&chip; +protected: + ~RadioLibWrapper()=default; +public: + bool registerInterruptAction();void unregisterInterruptAction(); + bool hasPendingRadioInterrupt()const;bool claimRadioInterrupt(); + void requestRestartRecv();bool isPacketReady(); + bool startSendRaw(const uint8_t*,int);bool isSendComplete(); + int recvRaw(uint8_t*,int); + void onSendFinished(); + void idle(){state=STATE_IDLE;} + bool recoverRadio(bool){return !hasPendingRadioInterrupt();} + void stopReceiveDutyCycle(){_rx_ps_armed=false;chip.standby();} + void startRecv(){if(chip.startReceive()==0)state=STATE_RX;} +}; +@CALLBACKS@ +@METHODS@ +@CONCRETE_CLASSES@ +''' + + +class RadioInterruptRecoveryTests(unittest.TestCase): + def test_actual_wrapper_header_uses_actual_radio_base(self): + # Keep Mesh/Dispatcher and the wrapper header real: the common test + # Mesh.h gives Radio a virtual destructor that production does not. + with tempfile.TemporaryDirectory(prefix="meshcore-radio-header-") as tmp: + folder = Path(tmp) + (folder / "RadioLib.h").write_text(r''' +#pragma once +#include +#define RADIOLIB_ERR_NONE 0 +#define RADIOLIB_NC 0xffffffff +class PhysicalLayer { +public: + int setOutputPower(int8_t){return 0;} + int transmitDirect(){return 0;} + int setPreambleLength(uint16_t){return 0;} + uint8_t randomByte(){return 0;} +}; +''') + (folder / "header.cpp").write_text(r''' +long random(long,long); +#include +#include +static_assert(!std::has_virtual_destructor::value, "real Radio ABI"); +static_assert(!std::has_virtual_destructor::value, "concrete wrapper ownership ABI"); +static_assert(!std::is_destructible::value, "borrowed base cannot own destruction"); +''') + result = subprocess.run([ + os.environ.get("CXX", "g++"), "-std=c++17", "-fsyntax-only", + "-DLORA_SF=7", "-DLORA_BW=125", "-I", str(folder), + "-I", str(ROOT / "src"), "-I", str(ROOT / "test/mocks"), + str(folder / "header.cpp"), + ], capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stderr) + + def test_atomic_generation_latch_and_counter_wrap(self): + compile_run(r''' +#include +#include +#include +#include +#include +int main(){ + RadioInterruptEvents a,b; + assert(!a.claim());a.record();assert(a.pending()&&!b.pending()); + assert(a.claim()&&!a.pending());a.record();assert(a.claim()); + uint32_t wrapped[2]={UINT32_MAX,UINT32_MAX}; + static_assert(sizeof(a)==sizeof(wrapped),"bounded ISR storage"); + memcpy(&a,wrapped,sizeof(a));a.record();assert(a.pending()&&a.claim()&&!a.pending()); + std::atomic done{false}; + std::thread producer([&]{for(unsigned i=0;i<1000000;++i)a.record();done.store(true);}); + while(!done.load())a.claim();producer.join();a.claim();assert(!a.pending()); + b.record();assert(!a.pending()&&b.pending()); +} +''') + + def source(self): + wrapper = (ROOT / "src/helpers/radiolib/RadioLibWrappers.cpp").read_text() + custom = (ROOT / "src/helpers/radiolib/CustomLR2021.h").read_text() + callbacks = wrapper[wrapper.index("#define RADIO_INTERRUPT_INTERNAL_"): + wrapper.index("// The Adafruit nRF52")] + callbacks += "\n" + wrapper[wrapper.index("static\n#if defined(ESP8266)"): + wrapper.index("bool RadioLibWrapper::registerInterruptAction()")] + names = [("bool", "registerInterruptAction"), ("void", "unregisterInterruptAction"), + ("bool", "hasPendingRadioInterrupt"), ("bool", "claimRadioInterrupt"), + ("void", "requestRestartRecv"), ("bool", "isPacketReady"), + ("bool", "startSendRaw"), ("bool", "isSendComplete"), ("int", "recvRaw"), + ("void", "onSendFinished")] + methods = "\n".join(method(wrapper, f"{kind} RadioLibWrapper::{name}(") + for kind, name in names) + lr_methods = "\n".join(method(custom, signature) for signature in + ("int16_t readData(", "bool isReceiving()", "int16_t startReceive()")) + concrete = [] + for name in ("CustomLLCC68Wrapper", "CustomLR1110Wrapper", "CustomLR1121Wrapper", + "CustomLR2021Wrapper", "CustomSTM32WLxWrapper", "CustomSX1262Wrapper", + "CustomSX1268Wrapper", "CustomSX1276Wrapper"): + header = (ROOT / f"src/helpers/radiolib/{name}.h").read_text() + concrete.append(f"struct {name} : RadioLibWrapper {{" + method(header, f"~{name}()") + "};") + # The two further-derived production families inherit concrete cleanup. + for file in (ROOT / "src/helpers/radiolib/DacPaSX1276Wrapper.h", + ROOT / "variants/sensecap_indicator-espnow/IndicatorSX1262Wrapper.h"): + import re + declaration = re.search(r"class (\w+) : public (\w+)", file.read_text()) + concrete.append(f"struct {declaration[1]} : {declaration[2]} {{}};") + return (PRELUDE.replace("@LR_METHODS@", lr_methods).replace("@CALLBACKS@", callbacks) + .replace("@METHODS@", methods).replace("@CONCRETE_CLASSES@", "\n".join(concrete))) + + def test_mode_changes_lifecycle_late_callbacks_and_tx_status_retry(self): + compile_run(self.source() + r''' +int main(){ + uint8_t bytes[16]={}; + auto* a=new CustomSX1262Wrapper;auto* b=new CustomSX1262Wrapper; + assert(a->registerInterruptAction()&&b->registerInterruptAction()); + assert(a->chip.callback!=b->chip.callback); + auto* c=new CustomSX1262Wrapper;assert(!c->registerInterruptAction()); + a->chip.callback();assert(a->hasPendingRadioInterrupt()&&!b->hasPendingRadioInterrupt()); + a->requestRestartRecv();assert(a->state==STATE_IDLE&&a->hasPendingRadioInterrupt()); + assert(!a->startSendRaw(bytes,4));assert(a->chip.tx_starts==0); + assert(a->recvRaw(bytes,sizeof(bytes))==4&&a->n_recv==1); + b->chip.callback();assert(b->hasPendingRadioInterrupt()&&!a->hasPendingRadioInterrupt()); + assert(b->registerInterruptAction()&&!b->hasPendingRadioInterrupt()); + void(*late)()=a->chip.callback;delete a;late(); // static storage, no UAF + assert(c->registerInterruptAction()&&!c->hasPendingRadioInterrupt()); + late();c->chip.rx_status=0;assert(c->recvRaw(bytes,sizeof(bytes))==0); // no ghost frame + assert(c->recvRaw(bytes,sizeof(bytes))==0&&c->recvRaw(bytes,sizeof(bytes))==0); + assert(!c->hasPendingRadioInterrupt()); + c->chip.rx_status=1; + c->board.before_hook=c->chip.callback; + assert(!c->startSendRaw(bytes,4)&&c->board.before==1&&c->board.after==1); + assert(c->hasPendingRadioInterrupt()&&c->chip.tx_starts==0); + c->claimRadioInterrupt();c->board.before_hook=nullptr; + assert(c->startSendRaw(bytes,4)&&c->state==STATE_TX_WAIT); + c->chip.callback();c->chip.tx_status=0; + assert(!c->isSendComplete()&&c->hasPendingRadioInterrupt()); + c->chip.tx_status=-99;assert(!c->isSendComplete()&&c->hasPendingRadioInterrupt()); + c->chip.tx_status=1;assert(c->isSendComplete()&&c->n_sent==1); + assert(!c->isSendComplete()&&c->n_sent==1); + c->chip.tx_status=0;assert(c->startSendRaw(bytes,4));c->chip.callback(); + assert(!c->isSendComplete()&&c->hasPendingRadioInterrupt()); + c->onSendFinished();assert(!c->hasPendingRadioInterrupt()&&c->recoverRadio(true)); + // A receive status read temporarily returning zero retains the event. + c->chip.callback();c->chip.rx_status=0;assert(c->recvRaw(bytes,sizeof(bytes))==0); + c->chip.rx_status=1;c->chip.fifo=4; + assert(c->recvRaw(bytes,sizeof(bytes))==4&&c->n_recv==1); + delete b;delete c; +} +''') + + def test_all_concrete_families_release_slot_on_destruction(self): + names = ("CustomLLCC68Wrapper", "CustomLR1110Wrapper", "CustomLR1121Wrapper", + "CustomLR2021Wrapper", "CustomSTM32WLxWrapper", "CustomSX1262Wrapper", + "CustomSX1268Wrapper", "CustomSX1276Wrapper", "DacPaSX1276Wrapper", + "IndicatorSX1262Wrapper") + checks = ["int main(){"] + for name in names: + checks.append(f""" ++ {{ ++ auto* original=new {name};assert(original->registerInterruptAction()); ++ void(*late)()=original->chip.callback;delete original; ++ assert(!radio_interrupt_owners[0]&&!radio_interrupt_owners[1]); ++ late(); // no object access through an in-flight hardware callback ++ auto* replacement=new {name};assert(replacement->registerInterruptAction()); ++ assert(!replacement->hasPendingRadioInterrupt());delete replacement; ++ }} ++""".replace("\n+", "\n")) + checks.append("}") + compile_run(self.source() + "\n".join(checks)) + + def test_internal_stm32_slot_capacity_and_external_radio_capacity(self): + checks = self.source() + r''' +int main(){ + CustomSTM32WLxWrapper first,second,third; + assert(first.registerInterruptAction()); + second._interrupt_slot=0; // stale slot must never steal another owner +#if RADIO_INTERRUPT_SLOT_COUNT == 1 + static_assert(sizeof(radio_interrupt_owners)==sizeof(RadioLibWrapper*),"internal radio has one slot"); + assert(!second.registerInterruptAction()&&second._interrupt_slot==0xff); + assert(!second.chip.callback&&radio_interrupt_owners[0]==&first); +#else + static_assert(sizeof(radio_interrupt_owners)==2*sizeof(RadioLibWrapper*),"external radio retains two slots"); + assert(second.registerInterruptAction()&&second._interrupt_slot==1); + assert(second.chip.callback!=first.chip.callback); + assert(!third.registerInterruptAction()&&third._interrupt_slot==0xff); + second.unregisterInterruptAction(); +#endif + first.chip.callback();assert(first.hasPendingRadioInterrupt()); + assert(first.registerInterruptAction()&&!first.hasPendingRadioInterrupt()); + void(*late)()=first.chip.callback;first.unregisterInterruptAction();late(); + assert(second.registerInterruptAction()&&!second.hasPendingRadioInterrupt()); + assert(!first.hasPendingRadioInterrupt()); + second.chip.callback();assert(second.hasPendingRadioInterrupt()); + second.unregisterInterruptAction(); + for(auto* owner:radio_interrupt_owners)assert(!owner); +} +''' + for defines in ([], ["-DRADIO_CLASS=CustomSTM32WLx"], + ["-DSTM32_PLATFORM=1", "-DRADIO_CLASS=CustomSX1262"]): + compile_run(checks, defines) + + def test_shared_phy_rejected_registration_preserves_owner_callback(self): + checks = self.source() + r''' +int main(){ + auto* owner=new CustomSTM32WLxWrapper; + auto* filler=new CustomSTM32WLxWrapper; + assert(owner->registerInterruptAction()); +#if RADIO_INTERRUPT_SLOT_COUNT > 1 + assert(filler->registerInterruptAction()); +#endif + auto* denied=new CustomSTM32WLxWrapper; + denied->_radio=owner->_radio; // same integrated PhysicalLayer, distinct wrapper + denied->_interrupt_slot=0; // stale slot is not ownership + void(*active)()=owner->chip.callback; + assert(!denied->registerInterruptAction()&&denied->_interrupt_slot==0xff); + assert(owner->chip.callback==active&&radio_interrupt_owners[0]==owner); + active();assert(owner->hasPendingRadioInterrupt()); + delete denied; // denied concrete destruction must not detach the owner's PHY + assert(owner->chip.callback==active&&radio_interrupt_owners[0]==owner); + assert(owner->claimRadioInterrupt());active();assert(owner->hasPendingRadioInterrupt()); + delete filler;delete owner; + for(auto* slot_owner:radio_interrupt_owners)assert(!slot_owner); +} +''' + for defines in ([], ["-DRADIO_CLASS=CustomSTM32WLx"], + ["-DSTM32_PLATFORM=1", "-DRADIO_CLASS=CustomSX1262"]): + compile_run(checks, defines) + + def test_lr2021_fifo_guards_stopped_snapshot_cleanup_and_recovery(self): + compile_run(self.source() + r''' +int main(){ + CustomLR2021Wrapper w;assert(w.registerInterruptAction());uint8_t bytes[16]={}; + for(int fifo:{0,3,5}){ + w.chip.rx_status=1;w.chip.fifo=fifo;w.chip.callback(); + const unsigned prior=w.chip.reads; + assert(w.recvRaw(bytes,sizeof(bytes))==0&&w.chip.reads==prior); + assert(w.chip.fifo==0&&!w.chip.stopped&&w.state==STATE_RX); + } + w.chip.rx_status=1;w.chip.fifo=4;w.chip.fifo_status=-99;w.chip.callback(); + assert(w.recvRaw(bytes,sizeof(bytes))==0&&w.chip.irq_clears==4&&w.chip.fifo==0); + w.chip.fifo_status=0;w.chip.rx_status=1;w.chip.fifo=4; + w.chip.inject_read_irq=true;w.chip.callback(); + assert(w.recvRaw(bytes,sizeof(bytes))==4&&w.n_recv==1&&!w.hasPendingRadioInterrupt()); + assert(w.chip.arms==5&&w.chip.irq_clears==5&&!w.chip.stopped); + w.chip.rx_status=1;w.chip.fifo=4;w.chip.base_status=-88;w.chip.callback(); + assert(w.recvRaw(bytes,sizeof(bytes))==0&&w.n_recv_errors==5); + w.chip.base_status=0;w.chip.rx_status=1;w.chip.fifo=4;w.chip.callback(); + assert(w.recvRaw(bytes,sizeof(bytes))==4&&w.n_recv==2); + // Zero-length metadata (including a silently failed status read) discards + // its suspect FIFO before the next receive window. + w.chip.packet_length=0;w.chip.fifo=8;w.chip.rx_status=1;w.chip.callback(); + assert(w.recvRaw(bytes,sizeof(bytes))==0&&w.chip.fifo==0&&!w.chip.stopped); + w.chip.packet_length=4; + // A failed standby never clears FIFO/IRQ or rearms as though stopped. + w.chip.standby_status=-55;w.chip.fifo=4;w.chip.rx_status=1;w.chip.callback(); + const unsigned armed=w.chip.arms,cleared=w.chip.irq_clears; + assert(w.recvRaw(bytes,sizeof(bytes))==0&&w.chip.fifo==4); + assert(w.chip.arms==armed&&w.chip.irq_clears==cleared); + assert(w.recvRaw(bytes,sizeof(bytes))==0&&w.chip.arms==armed); + w.chip.standby_status=0;w.chip.clear_status=-44; + assert(w.recvRaw(bytes,sizeof(bytes))==0&&w.chip.arms==armed); // discard failure also blocks Set_RX + w.chip.clear_status=0;w.recvRaw(bytes,sizeof(bytes));assert(w.chip.fifo==0&&w.chip.arms==armed+1); + w.chip.rx_status=1;w.chip.fifo=4;w.chip.callback(); + assert(w.recvRaw(bytes,sizeof(bytes))==4&&w.n_recv==3); + // A stale header CRC flag must not hide a newly valid header/preamble. + w.chip.irq=RADIOLIB_LR2021_IRQ_LORA_HEADER_VALID|RADIOLIB_LR2021_IRQ_LORA_HDR_CRC_ERROR; + assert(w.chip.isReceiving()); + w.chip.irq=RADIOLIB_LR2021_IRQ_PREAMBLE_DETECTED; + assert(w.chip.isReceiving()); + w.chip.irq=0;assert(!w.chip.isReceiving()); +} +''', ["-DUSE_LR2021=1"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_radio_profile_clock.py b/test/test_radio_profile_clock.py index f0eed6101..1b14634ba 100644 --- a/test/test_radio_profile_clock.py +++ b/test/test_radio_profile_clock.py @@ -10,7 +10,7 @@ ROOT = Path(__file__).resolve().parents[1] SOURCE = (ROOT / "src/helpers/radiolib/RadioLibWrappers.cpp").read_text() CLOCK = "static void syncProfileClock(bool dual_profile) {" + SOURCE.split( "static void syncProfileClock(bool dual_profile) {", 1 -)[1].split("// this function is called", 1)[0] +)[1].split("\nstatic\n#if defined(ESP8266)", 1)[0] HARNESS = r''' #include diff --git a/test/test_radio_profile_scan.py b/test/test_radio_profile_scan.py index ed3242f4c..817ac5440 100644 --- a/test/test_radio_profile_scan.py +++ b/test/test_radio_profile_scan.py @@ -11,18 +11,17 @@ HARNESS = r''' #include #include #include +#include #include #include #define RADIOLIB_ERR_NONE 0 #define STATE_IDLE 0 #define STATE_RX 1 #define STATE_TX_WAIT 3 -#define STATE_INT_READY 16 #define NF_CALIB_SETTLE_MS 7UL #define MESH_DEBUG_PRINTLN(...) ((void)0) namespace mesh { enum class RadioParamApplyResult { APPLIED, BUSY, FAILED }; } static uint64_t elapsed_us; -static uint8_t state; uint32_t micros() { return (uint32_t)elapsed_us; } static bool profile_clock_active=false; void syncProfileClock(bool dual_profile) { profile_clock_active=dual_profile; } @@ -32,6 +31,10 @@ uint32_t millis() { return (uint32_t)(elapsed_us / 1000); } struct Chip { bool standbyXOSC=false; int standby() { return 0; } }; using CustomSX1262 = Chip; struct RadioLibWrapper { + uint8_t state = STATE_RX; + RadioInterruptEvents events; + bool hasPendingRadioInterrupt() const { return events.pending(); } + bool claimRadioInterrupt() { return events.claim(); } bool _cw_active=false; Chip chip; Chip* _radio = &chip; mesh::RadioProfiles _profiles; @@ -64,11 +67,11 @@ struct RadioLibWrapper { _profile_visit_stamp=profileTimestamp(_profiles.enabled()); } bool isChipBusy() { return busy; } - bool isInRecvMode() const { return (state & ~STATE_INT_READY) == STATE_RX; } + bool isInRecvMode() const { return state == STATE_RX; } void beginProfileRetune(bool) {} void endProfileRetune(bool) {} bool isReceivingPacket() { return packet; } - bool isPacketPendingOrReceiving() { return packet || (state & STATE_INT_READY); } + bool isPacketPendingOrReceiving() { return packet || hasPendingRadioInterrupt(); } bool supportsRxPowerSaving() { return true; } void startRecv() { if (failRxStarts) { --failRxStarts; state=STATE_IDLE; return; } @@ -143,25 +146,25 @@ int main() { for (unsigned blocked=0; blocked<5; ++blocked) { RadioLibWrapper w; w._cw_active = blocked==0; w.busy = blocked==1; w.packet = blocked==2; - if (blocked==3) state |= STATE_INT_READY; - if (blocked==4) state = STATE_TX_WAIT; + if (blocked==3) w.events.record(); + if (blocked==4) w.state = STATE_TX_WAIT; assert(w.tryRestoreCodingRate(5)==Result::BUSY && w.coding_writes==0); } for (bool success : {false,true}) { RadioLibWrapper w; w.coding_success=success; assert(w.tryRestoreCodingRate(5)==(success?Result::APPLIED:Result::FAILED)); - assert(w.coding_writes==1 && state==STATE_RX); + assert(w.coding_writes==1 && w.state==STATE_RX); assert(w._profile_refresh_required==!success); } { - RadioLibWrapper w; state=STATE_IDLE; + RadioLibWrapper w; w.state=STATE_IDLE; assert(w.tryRestoreCodingRate(5)==Result::APPLIED); - assert(w.coding_writes==1 && state==STATE_IDLE && !w._profile_refresh_required); + assert(w.coding_writes==1 && w.state==STATE_IDLE && !w._profile_refresh_required); } { RadioLibWrapper w; w.failRxStarts=1; assert(w.tryRestoreCodingRate(5)==Result::FAILED); - assert(w.coding_writes==1 && state==STATE_IDLE && w._profile_refresh_required); + assert(w.coding_writes==1 && w.state==STATE_IDLE && w._profile_refresh_required); // The normal receive re-arm and profile refresh can recover after a // transient restart failure; a later coding-rate restore then succeeds. w.startRecv(); w.serviceProfileScan(); @@ -390,11 +393,11 @@ int main() { RadioLibWrapper w; if (reason==0) w.busy=true; if (reason==1) w.packet=true; - if (reason==2) state=STATE_RX|STATE_INT_READY; - if (reason==3) state=STATE_TX_WAIT; + if (reason==2) { w.state=STATE_RX; w.events.record(); } + if (reason==3) w.state=STATE_TX_WAIT; w.enable(); assert(!w._profile_standby_held && !w.chip.standbyXOSC && w._rx_ps_enabled); - w.busy=w.packet=false; state=STATE_RX; w.serviceProfileScan(); + w.busy=w.packet=false; w.events.claim(); w.state=STATE_RX; w.serviceProfileScan(); assert(w.chip.standbyXOSC && w._profile_rxps_suspended); w._profiles.setSecondary({},false); w.busy=true; w.serviceProfileScan(); assert(w.chip.standbyXOSC && w._profile_rxps_suspended); diff --git a/test/test_radio_receive_contract.py b/test/test_radio_receive_contract.py index 3612322cb..3008cdac7 100644 --- a/test/test_radio_receive_contract.py +++ b/test/test_radio_receive_contract.py @@ -22,11 +22,11 @@ HARNESS = r''' #include #include #include +#include #include #define STATE_IDLE 0 #define STATE_RX 1 #define STATE_TX_WAIT 3 -#define STATE_INT_READY 16 #define RADIOLIB_ERR_NONE 0 #define RADIOLIB_CHANNEL_FREE -15 #define RADIOLIB_ERR_UNKNOWN -16 @@ -38,7 +38,6 @@ HARNESS = r''' #define NF_CONTINUOUS_TIMEOUT_MS NoiseFloorEstimator::WINDOW_TIMEOUT_MS #define NF_CALIB_SETTLE_MS 7UL #define NF_FAST_PROFILE_REFRESH_INTERVAL_MS (15UL * 60UL * 1000UL) -static volatile uint8_t state = STATE_RX; static uint32_t now_ms = 0; uint32_t millis() { return now_ms; } uint32_t micros() { return now_ms * 1000UL; } @@ -54,15 +53,20 @@ struct Board { void serviceWatchdog() { ++services; } }; struct Radio { + RadioInterruptEvents* events; int scan_result = RADIOLIB_CHANNEL_FREE, start_result = 0; unsigned scans = 0; int startChannelScan() { ++scans; return start_result; } int getChannelScanResult() { - if (scan_result != RADIOLIB_ERR_UNKNOWN) state |= STATE_INT_READY; + if (scan_result != RADIOLIB_ERR_UNKNOWN) events->record(); return scan_result; } }; struct RadioLibWrapper { + uint8_t state = STATE_RX; + RadioInterruptEvents events; + bool hasPendingRadioInterrupt() const { return events.pending(); } + bool claimRadioInterrupt() { return events.claim(); } bool _cw_active = false; bool serviceCarrierWave() { return false; } // dedicated CW harness owns this path mesh::RadioProfiles _profiles; @@ -72,7 +76,7 @@ struct RadioLibWrapper { bool _profile_rxps_suspended = false; void serviceProfileScan() {} // separate profile-scan harness exercises tuning Board board; Board* _board = &board; - Radio radio; Radio* _radio = &radio; + Radio radio{&events}; Radio* _radio = &radio; bool _rx_ps_enabled = false, _rx_ps_armed = false, _rx_ps_continuous_fallback = false; bool _nf_calib_active = false, _nf_refresh_requested = true, _noise_floor_valid = true, _noise_floor_secondary_valid = true; @@ -101,7 +105,7 @@ struct RadioLibWrapper { float getCurrentRSSI() { ++reads; return rssi; } int8_t readReceiveMode() { ++mode_reads; - if (inject_mode_irq) state |= STATE_INT_READY; + if (inject_mode_irq) events.record(); return chip_mode; } bool recoverRadio(bool use_hard) { use_hard ? ++hard : ++soft; return true; } @@ -113,7 +117,7 @@ struct RadioLibWrapper { int startReceiveMode() { ++arms; if (!arm_result) _rx_ps_armed = _rx_ps_enabled && !_nf_calib_active; - if (inject_arm_irq) state |= STATE_INT_READY; + if (inject_arm_irq) events.record(); return arm_result; } int16_t performChannelScan() { return performChannelScanWithTimeout(2500); } @@ -137,9 +141,9 @@ int main() { // A completed packet, current frame, or TX owns the radio; CAD cannot erase it. for (int owned : {1, 2, 3}) { RadioLibWrapper w; - if (owned == 1) state |= STATE_INT_READY; + if (owned == 1) w.events.record(); if (owned == 2) w.packet = true; - if (owned == 3) state = STATE_TX_WAIT; + if (owned == 3) w.state = STATE_TX_WAIT; assert(w.isChannelActive()); assert(w.radio.scans == 0 && w.arms == 0); } @@ -151,7 +155,7 @@ int main() { const bool active = w.isChannelActive(); assert(active == (result != RADIOLIB_CHANNEL_FREE)); assert(w.stops == unsigned(ps)); - assert(w.arms == 1 && state == STATE_RX); + assert(w.arms == 1 && w.state == STATE_RX); assert(w._rx_ps_armed == ps); if (result == RADIOLIB_ERR_UNKNOWN) { assert(now_ms == 2600 && w.board.services == 2); @@ -162,19 +166,19 @@ int main() { RadioLibWrapper w; w.radio.start_result = -5; assert(w.isChannelActive()); - assert(w.arms == 1 && state == STATE_RX); + assert(w.arms == 1 && w.state == STATE_RX); } { RadioLibWrapper w; w.arm_result = -5; assert(w.isChannelActive()); // a free CAD with failed RX restoration is not success - assert(state == STATE_IDLE); + assert(w.state == STATE_IDLE); } { RadioLibWrapper w; w.inject_arm_irq = true; assert(w.isChannelActive()); - assert(state == (STATE_RX | STATE_INT_READY)); // preserves the new packet + assert(w.state == STATE_RX && w.hasPendingRadioInterrupt()); // preserves the new packet } // A quiet bounded window completes in ~3.2 s and powersaving resumes. for (bool ps : {false, true}) { @@ -184,7 +188,7 @@ int main() { w._nf_last_calib = 0; for (; now_ms < 4000; ++now_ms) { w.loop(); - if (state == STATE_IDLE) w.startRecv(); + if (w.state == STATE_IDLE) w.startRecv(); } assert(!w._nf_refresh_requested && !w._nf_calib_active); assert(w._noise_floor_valid && w._noise_floor_centi_dbm == -10000); @@ -345,7 +349,7 @@ int main() { if (skip == 1) w.busy = true; if (skip == 2) w._rx_ps_armed = true; if (skip == 3) w.packet = true; - if (skip == 4) state |= STATE_INT_READY; + if (skip == 4) w.events.record(); for (uint32_t t : {10000U,20000U,30000U}) w.checkReceiveMode(t); assert(w.soft == 0 && w.hard == 0); } diff --git a/test/test_regular_file_reads.py b/test/test_regular_file_reads.py index c0a34deb6..d974de19f 100644 --- a/test/test_regular_file_reads.py +++ b/test/test_regular_file_reads.py @@ -8,6 +8,8 @@ import subprocess import tempfile import unittest +from test_replay_reset_integration import extract_braced + ROOT = Path(__file__).resolve().parents[1] @@ -249,9 +251,11 @@ class RegularFileReadTest(unittest.TestCase): with self.subTest(path=path): self.assertIn("mesh::openFileRead(", section(path, marker, end)) for role in ("simple_repeater", "simple_room_server"): - dump = section(f"examples/{role}/MyMesh.cpp", "void MyMesh::dumpLogFile()", - "bool MyMesh::hasPendingSerialOutput()") - self.assertEqual(dump.count("mesh::openFileRead(_fs, PACKET_LOG_FILE)"), 2) + source = (ROOT / f"examples/{role}/MyMesh.cpp").read_text(encoding="utf-8") + dump = extract_braced(source, "void MyMesh::dumpLogFile()") + # Command-output, cooperative USB and immediate USB destinations + # must all reject phantom directories through the guarded reader. + self.assertEqual(dump.count("mesh::openFileRead(_fs, PACKET_LOG_FILE)"), 3) self.assertNotIn("_fs->open(", dump) diff --git a/test/test_room_post_permissions.py b/test/test_room_post_permissions.py new file mode 100644 index 000000000..734f5fd3b --- /dev/null +++ b/test/test_room_post_permissions.py @@ -0,0 +1,114 @@ +#!/usr/bin/env python3 +"""Execute the production room post gate with every ACL role and replay case.""" +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile +import unittest + +from test_replay_reset_integration import extract_braced + +ROOT = Path(__file__).resolve().parents[1] + +HARNESS = r''' +#include +#include +#include +#include +#include +#define TXT_TYPE_PLAIN 0 +#define ROOM_MESSAGE_CACHE_SIZE 8 +@ROLES@ +namespace mesh { +struct Utils { + static void sha256(uint8_t* out, size_t n, const uint8_t* key, size_t key_n, + const uint8_t* text, size_t text_n) { + // Distinct fixture messages have distinct fingerprints; crypto is not the + // behavior under test. Production LogicalMessageCache is used unchanged. + memset(out, 0, n); + for (size_t i = 0; i < key_n; ++i) out[i % n] ^= key[i]; + for (size_t i = 0; i < text_n; ++i) out[i % n] ^= text[i]; + } +}; +} +struct ClientInfo { + struct { uint8_t pub_key[PUB_KEY_SIZE] = {1}; } id; + uint8_t permissions = 0; + struct { struct { uint32_t last_post_timestamp = 0; } room; } extra; +}; +struct MyMesh { + mesh::LogicalMessageCache recent_room_posts; + unsigned posts = 0, acks = 0; + void addPost(ClientInfo*, const char*) { ++posts; } + void receive(ClientInfo* client, const char* text, uint32_t sender_timestamp) { + const uint8_t flags = TXT_TYPE_PLAIN; + const size_t text_len = strlen(text); + bool send_ack = false; + @POST_GATE@ + if (send_ack) ++acks; + } +}; +int main() { + // Include unrelated permission bits and unassigned roles: only RW/Admin post. + for (unsigned permissions = 0; permissions < 256; ++permissions) { + MyMesh mesh; + ClientInfo client; + client.permissions = permissions; + const unsigned role = permissions & PERM_ACL_ROLE_MASK; + const bool writable = role == PERM_ACL_READ_WRITE || role == PERM_ACL_ADMIN; + mesh.receive(&client, "first", 100); + mesh.receive(&client, "first", 100); // Exact retry: ACK again, store once. + assert(mesh.posts == (writable ? 1U : 0U)); + assert(mesh.acks == (writable ? 2U : 0U)); + assert(client.extra.room.last_post_timestamp == (writable ? 100U : 0U)); + mesh.receive(&client, "second", 101); + mesh.receive(&client, "first", 100); // Older exact retry after a newer post. + assert(mesh.posts == (writable ? 2U : 0U)); + assert(mesh.acks == (writable ? 4U : 0U)); + mesh.receive(&client, "mismatch", 101); + mesh.receive(&client, "unseen old", 99); + assert(mesh.posts == (writable ? 2U : 0U)); + assert(mesh.acks == (writable ? 4U : 0U)); + assert(client.extra.room.last_post_timestamp == (writable ? 101U : 0U)); + if (writable) { + client.permissions = PERM_ACL_READ_ONLY; // Revocation also blocks cached retries. + mesh.receive(&client, "second", 101); + assert(mesh.posts == 2 && mesh.acks == 4); + } + } + puts("256 room permission and replay scenarios passed"); +} +''' + + +class RoomPostPermissionTests(unittest.TestCase): + def test_production_post_gate_and_retries(self): + compiler = shutil.which("g++") or shutil.which("clang++") + self.assertIsNotNone(compiler) + source = (ROOT / "examples/simple_room_server/MyMesh.cpp").read_text() + receive = extract_braced(source, "void MyMesh::onPeerDataRecv(") + gate = extract_braced(receive, "if (flags == TXT_TYPE_PLAIN)") + header = (ROOT / "src/helpers/ClientACL.h").read_text() + roles = "\n".join(line for line in header.splitlines() + if line.startswith("#define PERM_ACL_")) + generated = HARNESS.replace("@POST_GATE@", gate).replace("@ROLES@", roles) + with tempfile.TemporaryDirectory(prefix="room-post-permissions-") as directory: + work = Path(directory) + (work / "test.cpp").write_text(generated) + binary = work / "room-posts" + cmd = [compiler, "-std=c++17", "-Wall", "-Wextra", "-Werror", "-Wno-unused-parameter", + "-I" + str(ROOT / "test/mocks"), "-I" + str(ROOT / "src"), + str(work / "test.cpp"), "-o", str(binary)] + if sys.platform.startswith("linux"): + cmd[1:1] = ["-fsanitize=address,undefined", "-fno-sanitize-recover=all", + "-fno-pie", "-no-pie"] + built = subprocess.run(cmd, capture_output=True, text=True, timeout=60) + self.assertEqual(built.returncode, 0, built.stdout + built.stderr) + tested = subprocess.run([str(binary)], capture_output=True, text=True, timeout=10) + self.assertEqual(tested.returncode, 0, tested.stdout + tested.stderr) + self.assertIn("256 room permission and replay scenarios passed", tested.stdout) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_rtc_discovery.py b/test/test_rtc_discovery.py new file mode 100644 index 000000000..e9d87d21f --- /dev/null +++ b/test/test_rtc_discovery.py @@ -0,0 +1,280 @@ +#!/usr/bin/env python3 +"""Execute production RTC discovery with register, bus and initialization faults.""" +from pathlib import Path +import re +import shutil +import subprocess +import sys +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[1] + +HARNESS = r''' +#include +#include +#include +#include +#include +#include +#include +#include +#define MESH_DEBUG_PRINTLN(...) ((void)0) +namespace mesh { +struct RTCClock { + virtual ~RTCClock() = default; + virtual uint32_t getCurrentTime() = 0; + virtual void setCurrentTime(uint32_t) = 0; + virtual void tick() {} +}; +} +struct TwoWire { + struct Device { + std::array registers{}; + unsigned failed_reads = 0, short_reads = 0, empty_reads = 0; + bool initialize_ok = true; + std::deque> scripted_reads; + std::vector read_registers; + }; + std::map devices; + std::vector initialized, configured; + uint8_t address = 0, reg = 0; + std::vector transmit, receive; + size_t read_index = 0; + bool all_reads_repeated_start = true; + void beginTransmission(uint8_t addr) { address = addr; transmit.clear(); } + size_t write(uint8_t value) { transmit.push_back(value); return 1; } + uint8_t endTransmission(bool stop = true) { + if (!devices.count(address)) return 2; + if (!transmit.empty()) { + reg = transmit[0]; + all_reads_repeated_start = all_reads_repeated_start && !stop; + } + return 0; + } + uint8_t requestFrom(uint8_t addr, uint8_t n) { + auto& dev = devices.at(addr); + dev.read_registers.push_back(reg); + receive.clear(); read_index = 0; + if (dev.failed_reads) { --dev.failed_reads; return 0; } + if (dev.short_reads) { --dev.short_reads; return n - 1; } + if (dev.empty_reads) { --dev.empty_reads; return n; } + if (!dev.scripted_reads.empty()) { + receive = dev.scripted_reads.front(); dev.scripted_reads.pop_front(); + return receive.size(); + } + for (unsigned i = 0; i < n; ++i) receive.push_back(dev.registers[reg + i]); + return n; + } + int read() { return read_index < receive.size() ? receive[read_index++] : -1; } +}; +struct DateTime { + uint32_t epoch; + explicit DateTime(uint32_t value = 0) : epoch(value) {} + DateTime(int, int, int, int, int, int) : epoch(5200) {} + uint32_t unixtime() const { return epoch; } + int year() const { return 2026; } + int month() const { return 10; } + int dayOfTheWeek() const { return 5; } + int day() const { return 9; } + int hour() const { return 12; } + int minute() const { return 30; } + int second() const { return 45; } +}; +template struct FakeRTC { + TwoWire* wire = nullptr; + bool begin(TwoWire* value) { + wire = value; wire->initialized.push_back(Addr); + return wire->devices.at(Addr).initialize_ok; + } + DateTime now() { return DateTime(Epoch); } + void adjust(DateTime) { wire->configured.push_back(Addr); } +}; +using RTC_DS3231 = FakeRTC<0x68, 6800>; +using RTC_PCF8563 = FakeRTC<0x51, 5100>; +using RTC_RX8130CE = FakeRTC<0x32, 3200>; +struct Melopero_RV3028 { + TwoWire* wire = nullptr; + void initI2C(TwoWire& value) { wire = &value; wire->initialized.push_back(0x52); } + void writeToRegister(int, int) { wire->configured.push_back(0x52); } + void set24HourMode() { wire->configured.push_back(0x52); } + void setTime(int, int, int, int, int, int, int) { wire->configured.push_back(0x52); } + int getYear() const { return 2026; } + int getMonth() const { return 10; } + int getDate() const { return 9; } + int getHour() const { return 12; } + int getMinute() const { return 30; } + int getSecond() const { return 45; } +}; +struct Fallback : mesh::RTCClock { + uint32_t epoch = 1234; + uint32_t getCurrentTime() override { return epoch; } + void setCurrentTime(uint32_t value) override { epoch = value; } +}; +@HEADER@ +@SOURCE@ +struct Kind { uint8_t addr; const RtcId* id; uint32_t epoch; }; +static void makeValid(TwoWire& wire, const Kind& kind) { + auto& regs = wire.devices[kind.addr].registers; + regs[kind.id->time_reg] = 0x45; + regs[kind.id->time_reg + 1] = 0x30; + regs[kind.id->time_reg + 2] = 0x12; + regs[kind.id->time_reg + 3] = 0x01; + regs[kind.id->time_reg + kind.id->date_idx] = 0x09; + regs[kind.id->time_reg + 5] = 0x10; + regs[kind.id->time_reg + 6] = 0x26; +} +static void expectRejected(TwoWire& wire, Fallback& fallback) { + AutoDiscoverRTCClock clock(fallback); + clock.begin(wire); + assert(wire.initialized.empty() && wire.configured.empty()); + assert(clock.getCurrentTime() == fallback.epoch); + clock.setCurrentTime(9876); + assert(fallback.epoch == 9876 && wire.configured.empty()); + fallback.epoch = 1234; +} +int main() { + Fallback fallback; + const Kind kinds[] = { +#if !defined(DISABLE_DS3231_PROBE) + {0x68, &DS3231_ID, 6800}, +#endif + {0x52, &RV3028_ID, 5200}, {0x51, &PCF8563_ID, 5100}, {0x32, &RX8130CE_ID, 3200} + }; + unsigned checks = 0; + for (auto kind : kinds) { + TwoWire valid; makeValid(valid, kind); + AutoDiscoverRTCClock clock(fallback); clock.begin(valid); + assert(valid.initialized == std::vector{kind.addr}); + assert(clock.getCurrentTime() == kind.epoch); + assert(valid.all_reads_repeated_start); + clock.setCurrentTime(77); + assert(valid.configured.back() == kind.addr && fallback.epoch == 1234); + ++checks; + // A second begin must discard a previous successful discovery. + TwoWire absent; clock.begin(absent); + assert(clock.getCurrentTime() == fallback.epoch); ++checks; + TwoWire erased; erased.devices[kind.addr].registers.fill(0xFF); + expectRejected(erased, fallback); ++checks; + for (unsigned field = 0; field < 7; ++field) for (unsigned bit = 0; bit < 8; ++bit) { + if (!(kind.id->zero[field] & (1U << bit))) continue; + TwoWire wrong; makeValid(wrong, kind); + wrong.devices[kind.addr].registers[kind.id->time_reg + field] |= 1U << bit; + expectRejected(wrong, fallback); ++checks; + } + for (unsigned fault = 0; fault < 3; ++fault) { + TwoWire failed; makeValid(failed, kind); + auto& dev = failed.devices[kind.addr]; + if (fault == 0) dev.failed_reads = 2; + if (fault == 1) dev.short_reads = 2; + if (fault == 2) dev.empty_reads = 2; + expectRejected(failed, fallback); ++checks; + TwoWire recovered; makeValid(recovered, kind); + recovered.devices[kind.addr].failed_reads = 1; + clock.begin(recovered); + assert(clock.getCurrentTime() == kind.epoch); ++checks; + } + if (kind.id->flag_bit) { + const std::pair bad_fields[] = { + {0, 0x60}, {0, 0x1A}, {1, 0x60}, {kind.id->date_idx, 0}, + {kind.id->date_idx, 0x32}, {5, 0}, {5, 0x13} + }; + for (auto bad : bad_fields) { + TwoWire invalid; makeValid(invalid, kind); + invalid.devices[kind.addr].registers[kind.id->time_reg + bad.first] = bad.second; + expectRejected(invalid, fallback); ++checks; + } + // First power-up permits undefined BCD fields when power loss is flagged. + TwoWire lost; makeValid(lost, kind); + lost.devices[kind.addr].registers[kind.id->time_reg] = 0; + lost.devices[kind.addr].registers[kind.id->time_reg + kind.id->date_idx] = 0; + lost.devices[kind.addr].registers[kind.id->flag_reg] |= kind.id->flag_bit; + clock.begin(lost); + assert(clock.getCurrentTime() == kind.epoch); ++checks; + } + if (kind.addr != 0x52) { + TwoWire init_failed; makeValid(init_failed, kind); + init_failed.devices[kind.addr].initialize_ok = false; + clock.begin(init_failed); + assert(clock.getCurrentTime() == fallback.epoch); ++checks; + } + } +#if !defined(DISABLE_DS3231_PROBE) + // DS1307 0Fh is ordinary RAM, unlike DS3231's OSF status register. Startup + // identification and later initialization must not depend on that RAM byte. + for (uint8_t ram : {0, 0x80, 0xFF}) { + const uint8_t typical_time[] = {0x80, 0, 0, 1, 1, 1, 0}; + TwoWire startup; + auto& dev = startup.devices[0x68]; + std::copy(std::begin(typical_time), std::end(typical_time), dev.registers.begin()); + dev.registers[0x0F] = ram; + AutoDiscoverRTCClock clock(fallback); clock.begin(startup); + assert(startup.initialized == std::vector{0x68}); + assert(dev.read_registers == std::vector{0}); + clock.setCurrentTime(9876); + assert(startup.configured == std::vector{0x68}); ++checks; + + TwoWire halted; + auto& stopped = halted.devices[0x68]; + stopped.registers[0] = 0x80; stopped.registers[3] = 1; + stopped.registers[0x0F] = ram; // Date/month intentionally remain uninitialized. + clock.begin(halted); + assert(halted.initialized == std::vector{0x68}); + assert(stopped.read_registers == std::vector{0}); + clock.setCurrentTime(9876); + assert(halted.configured == std::vector{0x68}); ++checks; + } + // With CH clear and no OSF-shaped RAM bit, the invalid fields still reject. + TwoWire running_invalid; + running_invalid.devices[0x68].registers[3] = 1; + expectRejected(running_invalid, fallback); ++checks; + // The clock-halt exception never overrides reserved-bit or all-FF checks. + for (unsigned field = 0; field < 7; ++field) for (unsigned bit = 0; bit < 8; ++bit) { + if (!(DS3231_ID.zero[field] & (1U << bit))) continue; + TwoWire reserved; + reserved.devices[0x68].registers[0] = 0x80; + reserved.devices[0x68].registers[field] |= 1U << bit; + expectRejected(reserved, fallback); ++checks; + } + TwoWire erased_halted; erased_halted.devices[0x68].registers.fill(0xFF); + expectRejected(erased_halted, fallback); ++checks; +#endif +#ifdef DISABLE_DS3231_PROBE + TwoWire ignored; ignored.devices[0x68].registers.fill(0xFF); + expectRejected(ignored, fallback); ++checks; +#endif + printf("%u RTC discovery and fault checks passed\n", checks); +} +''' + + +class RtcDiscoveryTests(unittest.TestCase): + def test_production_discovery_and_disabled_ds3231(self): + compiler = shutil.which("g++") or shutil.which("clang++") + self.assertIsNotNone(compiler) + source = (ROOT / "src/helpers/AutoDiscoverRTCClock.cpp").read_text() + header = (ROOT / "src/helpers/AutoDiscoverRTCClock.h").read_text() + # Compile every production method, replacing only platform dependencies. + source = re.sub(r"^#include[^\n]*\n", "", source, flags=re.MULTILINE) + header = re.sub(r"^#(?:include|pragma)[^\n]*\n", "", header, flags=re.MULTILINE) + generated = HARNESS.replace("@HEADER@", header).replace("@SOURCE@", source) + with tempfile.TemporaryDirectory(prefix="rtc-discovery-") as directory: + work = Path(directory) + (work / "test.cpp").write_text(generated) + for disabled in (False, True): + binary = work / ("rtc-disabled" if disabled else "rtc") + cmd = [compiler, "-std=c++17", "-Wall", "-Wextra", "-Werror", + str(work / "test.cpp"), "-o", str(binary)] + if disabled: cmd.insert(1, "-DDISABLE_DS3231_PROBE=1") + if sys.platform.startswith("linux"): + cmd[1:1] = ["-fsanitize=address,undefined", "-fno-sanitize-recover=all", + "-fno-pie", "-no-pie"] + built = subprocess.run(cmd, capture_output=True, text=True, timeout=60) + self.assertEqual(built.returncode, 0, built.stdout + built.stderr) + tested = subprocess.run([str(binary)], capture_output=True, text=True, timeout=10) + self.assertEqual(tested.returncode, 0, tested.stdout + tested.stderr) + self.assertRegex(tested.stdout, r"\d+ RTC discovery and fault checks passed") + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_transport_filter_wiring.py b/test/test_transport_filter_wiring.py index d0e7dd587..99d2c153e 100644 --- a/test/test_transport_filter_wiring.py +++ b/test/test_transport_filter_wiring.py @@ -79,7 +79,7 @@ struct BridgeBase : AbstractBridge { void handleReceivedPacket(mesh::Packet*); uint16_t fletcher16(const uint8_t*,size_t) {return 0;} }; -struct Serial {unsigned writes=0;void write(const uint8_t*,size_t) {++writes;}}; +struct Serial {unsigned writes=0;size_t write(const uint8_t*,size_t length) {++writes;return length;}}; struct RS232Bridge : BridgeBase { Serial serial; Serial* _serial=&serial; void sendPacket(mesh::Packet*) override; diff --git a/variants/rak3x72/platformio.ini b/variants/rak3x72/platformio.ini index 08d496a0e..7950677c1 100644 --- a/variants/rak3x72/platformio.ini +++ b/variants/rak3x72/platformio.ini @@ -70,6 +70,7 @@ build_flags = ${rak3x72.build_flags} -Oz -flto -fno-inline-small-functions + -fno-tree-dominator-opts ; preserve the 32 KiB filesystem with the stability guards -fipa-pta -fmerge-all-constants -fno-ipa-cp-clone diff --git a/variants/wio-e5-mini/platformio.ini b/variants/wio-e5-mini/platformio.ini index bc4d8e7d2..d979b0169 100644 --- a/variants/wio-e5-mini/platformio.ini +++ b/variants/wio-e5-mini/platformio.ini @@ -84,6 +84,7 @@ build_flags = ${lora_e5_mini.build_flags} -flto -fno-inline-small-functions -fno-tree-dominator-opts ; save 160 release bytes while preserving the 32 KiB filesystem + -fno-caller-saves ; avoid spill-code growth and retain the 32 KiB filesystem -fipa-pta -fmerge-all-constants -fno-ipa-cp-clone