Fix Cascade power saving, USB Companion, and mOTA flows

This commit is contained in:
mikecarper committed 2026-08-18 14:02:10 -07:00
1 parent 0d4274ceea
commit 818f610ba3
59 files changed
+3946 -2437

No files matched your search

+29 -8
View File
@@ -1,16 +1,17 @@
# Host unit tests
Fast, hardware-free unit tests for the fork's pure logic, run on the host with
GoogleTest via PlatformIO's `native` environment. They cover the extractable
observer/WebConfig logic (validation, preset table, topic templates, key
parsing) -- the parts that don't depend on the ESP32, radio, or network stack.
Integration behavior (AsyncTCP transport, WiFi/MQTT, SoftAP) is exercised
separately; see "Local testing without hardware" in `MQTT_IMPLEMENTATION.md`.
Fast, hardware-free unit tests for pure and host-simulated logic, run with
GoogleTest through PlatformIO. The `native` environment runs every suite except
the KISS modem; `native_kiss_modem` builds that suite with its separate source
filter. Hardware, real radio, AsyncTCP, Wi-Fi/MQTT, and SoftAP behavior still
require integration or target testing; see "Local testing without hardware" in
`MQTT_IMPLEMENTATION.md`.
## Running
```sh
pio test -e native # all suites
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
```
@@ -47,8 +48,28 @@ does not reflect the GoogleTest count -- run the built binary directly
| `test_identity_generation` | `src/helpers/IdentityGeneration.h` | reserved-prefix rejection; bounded retries; final provisioned attempt; fail-closed exhaustion |
| `test_remote_cli_reply_cache` | `src/helpers/RemoteCliReplyCache.h`, `src/helpers/RemoteCliRequest.h`, `src/helpers/RemoteCliTimeout.h` | authenticated logical-request matching; bounded recent-reply history; backward-compatible retry identity; 300% response timeout; empty-response completion; on-air truncation and clearing |
| `test_companion_frame_queue` | `src/helpers/CompanionFrameQueue.h` | response/required/best-effort classification; reserved capacity; stable priority; safe eviction; message-waiting coalescing |
| `test_serial_mode_switch` | `src/helpers/ArduinoSerialInterface.cpp`, `src/helpers/MultiSerialInterface.h` | independent terminal/seeder control-sequence recognition across reads and binary-frame boundaries; passthrough ownership of USB input and suppression of binary output; Bluetooth-only connection and pairing-request routing |
| `test_serial_mode_switch` | `src/helpers/ArduinoSerialInterface.cpp`, `src/helpers/MultiSerialInterface.h` | terminal/seeder control-sequence recognition and passthrough ownership; queued/atomic USB output under backpressure and short writes; partial-frame busy state; requester-affine replies, locked contact streams, and Bluetooth-only pairing routing |
| `test_ble_tx_stall_watchdog` | `src/helpers/BleTxStallWatchdog.h` | exact BLE fragment progress; blocked-reply timeout; rollover-safe elapsed time; disconnect recovery retry and completion |
| `test_atomic_file_writer` | `src/helpers/AtomicFileWriter.h` | verified temporary-file commit; short-write, readback, validation, and rename failures; preservation of the live file and stale-temp cleanup |
| `test_cad_timing` | `src/helpers/radiolib/CadTiming.h`, `LR2021SideDetectorConfig.h`, `RadioAirtime.h` | Cascade and slow-profile CAD deadlines; invalid airtime handling; bounded LR2021 side-detector parsing and LDRO recomputation |
| `test_companion_node_prefs` | `examples/companion_radio/NodePrefs.h` | independent device power saving, RXPS, Wi-Fi, and FEM preferences; one-time migration of the regressed power-saving default |
| `test_config_serializer` | `src/helpers/ConfigSerializer.cpp`, Companion `NodePrefs` | escaped config save/load, whitespace and malformed input, unknown fields, and FEM preference round trips |
| `test_deferred_cli_command` | `src/helpers/DeferredCliCommand.h` | copying authenticated command context, single-pending-command enforcement, clearing, and length rejection |
| `test_kiss_modem` | `examples/kiss_modem/KissModem.cpp` | KISS escaping/framing and packet metadata under partial writes, host TX backpressure, queue saturation, and radio completion; run with `native_kiss_modem` |
| `test_mesh_tables` | `src/helpers/SimpleMeshTables.h` | packet and ACK/multipart deduplication, scope-independent identity, route-prefix matching, and deterministic recent-repeater expiry/eviction |
| `test_mqtt_lifecycle` | `src/helpers/MQTTLifecycle.h` | idempotent start/stop, initialization rollback, cooperative stop acknowledgment and timeout, callback ownership, restart, and the OTA flash barrier |
| `test_mqtt_reply_format` | `src/helpers/MQTTReplyFormat.h` | bounded formatted appends, exact-fit and one-byte buffers, truncation, NUL termination, and invalid starting positions |
| `test_packet_manager` | `src/Packet.cpp`, `src/Dispatcher.cpp`, `src/helpers/StaticPoolPacketManager.cpp` | truncated-packet rejection, unavailable-radio behavior, scoped RX-delay replacement, queue/CAD scheduling, and staged radio/TX recovery |
| `test_persistent_store_format` | `src/helpers/PersistentStoreFormat.h` | contact-page headers and CRCs, dirty-page state, stable slot allocation, and bounded resumable legacy migration across power loss |
| `test_power_management` | `src/helpers/PowerManagementUtils.h` | median filtering of a brownout outlier and valid-reading requirements for the boot lock |
| `test_region_names` | `src/helpers/RegionNameUtils.h` | canonical public-region markers while preserving distinct private and differently named regions |
| `test_routing_policy` | `src/helpers/RoutingPolicy.h` | scoped/unscoped flood hop limits and selection of direct, path-return, mirrored-scope, default-scope, or unscoped replies |
| `test_rs232_uart` | `src/helpers/bridges/RS232UartUtils.h` | stopping the active UART peripheral before reassigning its pins |
| `test_security_session_timer` | `src/helpers/nrf52/SecuritySessionTimer.h` | two-minute security-session expiry, cancellation, restart, and `millis()` rollover |
| `test_trace_path_helpers` | `src/helpers/TracePathHelpers.h` | round-trip route construction, hash-width conversion, raw path parsing/limits, and terminal trace timeout bounds |
| `test_user_gpio` | `src/helpers/UserGpio.cpp`, `UserGpioReplyTracker.h` | board-approved pins, get/set/reset, timed nonblocking transitions, duplicate suppression, rollover, and completion-reply routing |
| `test_utf8_helpers` | `src/helpers/UTF8Helpers.h` | byte-limit truncation at complete code-point boundaries and rejection of malformed or truncated UTF-8 |
| `test_wifi_ota_seeder_policy` | `src/helpers/WiFiOtaSeederPolicy.h`, `WiFiOtaSeederStatus.h` | listener state versus network availability, serial/TCP folder ownership, detach detection, and bounded status formatting |
| `test_ota` | `src/helpers/ota/` | container and EndF integrity; protocol codecs; transfer, resume, and apply safety; adaptive 2-to-4 block-request window growth and stall contraction; active-transfer priority classification |
| `test_trace_retry` | `src/Mesh.cpp` retry and relay policy | opaque OTA relay behavior during TempRadio; background discovery priority; immediate primary transfer relay, receive-delay bypass, fast CAD retry, and no generic flood retry; trace and non-OTA flood retry timing |
| `test_utils` | `src/Utils.cpp` | `Utils::toHex` (upstream) |
+106 -2
View File
@@ -1428,6 +1428,20 @@ TEST(OtaCatalog, RejectsAdvertisedSourceWithOversizedBlocks) {
EXPECT_EQ(server.servedCount(), 0);
}
TEST(OtaFolder, ReportsEntriesOmittedByServeRegistryCapacity) {
OtaManager server;
server.begin(0, nullptr, nullptr);
ASSERT_TRUE(server.serve(SIM_MOTA, SIM_MOTA_LEN));
SyntheticCatalogSource source((uint8_t)(OTA_MAX_SERVE + 3));
ASSERT_TRUE(server.add_source(&source));
uint16_t offered = 0, advertised = 0;
ASSERT_TRUE(server.sourceStats(&source, offered, advertised));
EXPECT_EQ(offered, (uint16_t)(OTA_MAX_SERVE + 3));
EXPECT_EQ(advertised, (uint16_t)(OTA_MAX_SERVE - 1)); // primary image occupies slot zero
EXPECT_EQ(server.servedCount(), OTA_MAX_SERVE);
}
// Fetch-resume across a reboot: a client commits some blocks, "reboots" (a fresh OtaManager on the SAME
// persisted store), and resumeStaged() re-adopts the partial container and finishes the remaining blocks -
// without re-fetching the manifest or the blocks already present.
@@ -1633,6 +1647,49 @@ TEST(OtaTransfer, RejectsIncompatibleCodec) {
g_q.clear();
}
TEST(OtaTransfer, ManualPullReportsIncompatibleManifestInsteadOfGoingIdle) {
g_q.clear();
OtaManager server, client;
OtaStoreRam<4096> store;
SendTo to_client{&client}, to_server{&server};
server.begin(0, sim_send, &to_client);
client.begin(SIM_TARGET_ID, sim_send, &to_server);
client.set_fetch_store(&store);
client.set_apply_codec(CODEC_DETOOLS_INPLACE);
client.set_accept_full(false);
ASSERT_TRUE(server.serve(SIM_MOTA, SIM_MOTA_LEN));
MotaManifest manifest;
ASSERT_TRUE(mota_parse(SIM_MOTA, SIM_MOTA_LEN, manifest));
EXPECT_EQ(client.pull(manifest.merkle_root, manifest.target_id), OtaManager::PULL_STARTED);
pump(client, &server);
EXPECT_EQ(client.fetchState(), OtaManager::FAILED);
EXPECT_EQ(client.fetchError(), OtaManager::FETCH_ERROR_CODEC);
EXPECT_EQ(client.wanted(), 0u); // terminal rejection no longer leaves discovery armed
EXPECT_EQ(store.staged_size(), 0u); // compatibility is rejected before the store is begun
}
TEST(OtaTransfer, PullAdmissionReportsNoStoreAndBusyWithoutReplacingIntent) {
g_q.clear();
OtaManager client;
SendTo to_none{&client};
client.begin(SIM_TARGET_ID, sim_send, &to_none);
uint8_t first[4] = {1, 2, 3, 4}, second[4] = {5, 6, 7, 8};
EXPECT_EQ(client.pull(first, SIM_TARGET_ID), OtaManager::PULL_NO_STORE);
EXPECT_EQ(client.fetchState(), OtaManager::IDLE);
EXPECT_EQ(client.wanted(), 0u);
OtaStoreRam<4096> store;
client.set_fetch_store(&store);
EXPECT_EQ(client.pull(first, SIM_TARGET_ID), OtaManager::PULL_STARTED);
EXPECT_EQ(client.pull(second, SIM_TARGET_ID ^ 0x55AAu), OtaManager::PULL_BUSY);
EXPECT_EQ(client.fetchState(), OtaManager::WANT_MANIFEST);
EXPECT_EQ(client.wanted(), SIM_TARGET_ID);
EXPECT_EQ(0, memcmp(client.fetchManifestId(), first, sizeof(first)));
g_q.clear();
}
// An archive capture is not an install. It must retain cross-target and otherwise unsupported containers
// byte-for-byte so this node can relay them to hardware that does understand their codec.
TEST(OtaTransfer, ArchivePullAcceptsCrossTargetUnsupportedCodec) {
@@ -1660,14 +1717,16 @@ TEST(OtaTransfer, ArchivePullAcceptsCrossTargetUnsupportedCodec) {
// Encode a 1-row OTA_HAVE from a specific seeder, carrying have_count (Phase-2 awareness).
static uint16_t make_have_row(uint8_t* buf, uint16_t cap, const uint8_t mid[4], uint32_t target,
uint32_t fwver, uint8_t codec, uint8_t flags,
const uint8_t seeder[4], uint16_t have_count) {
const uint8_t seeder[4], uint16_t have_count,
const uint8_t digest[4] = nullptr) {
uint8_t row[OTA_HAVE_ROW_BYTES];
memcpy(row, mid, 4);
row[4]=target; row[5]=target>>8; row[6]=target>>16; row[7]=target>>24;
row[8]=fwver; row[9]=fwver>>8; row[10]=fwver>>16; row[11]=fwver>>24;
row[12]=codec; row[13]=flags;
row[14]=(uint8_t)(have_count & 0xFF); row[15]=(uint8_t)(have_count >> 8);
HaveMsg hv; memcpy(hv.seeder_id, seeder, 4); memset(hv.set_digest, 0, 4);
HaveMsg hv; memcpy(hv.seeder_id, seeder, 4);
if (digest) memcpy(hv.set_digest, digest, 4); else memset(hv.set_digest, 0, 4);
hv.frag_idx=0; hv.frag_total=1; hv.n_rows=1; hv.rows=row;
return encode_have(buf, cap, hv);
}
@@ -1689,6 +1748,49 @@ TEST(OtaCatalog, DistinctSeederCountAndHaveCount) {
g_q.clear();
}
TEST(OtaCatalog, DigestChangePurgesOnlyThatSeedersRowsAndProgress) {
OtaManager m; SendTo none{&m}; m.begin(SIM_TARGET_ID, sim_send, &none);
m.set_archive_interest(true);
uint8_t wire[64], mid[4] = {9, 8, 7, 6};
uint8_t s1[4] = {1, 0, 0, 0}, s2[4] = {2, 0, 0, 0};
uint8_t d1[4] = {0x11, 0, 0, 0}, d2[4] = {0x22, 0, 0, 0}, changed[4] = {0x33, 0, 0, 0};
auto advertise = [&](const uint8_t sid[4], const uint8_t digest[4], uint8_t count = 1) {
AdvMsg adv{}; memcpy(adv.seeder_id, sid, 4); memcpy(adv.set_digest, digest, 4); adv.n_motas = count;
uint16_t n = encode_adv(wire, sizeof(wire), adv);
ASSERT_GT(n, 0);
m.on_message(wire, n);
};
advertise(s1, d1);
advertise(s2, d2);
m.set_clock(100);
m.on_message(wire, make_have_row(wire, sizeof(wire), mid, SIM_TARGET_ID, 0x01020300,
CODEC_FULL, MFLAG_FULL, s1, 7, d1));
m.set_clock(200);
m.on_message(wire, make_have_row(wire, sizeof(wire), mid, SIM_TARGET_ID, 0x01020300,
CODEC_FULL, MFLAG_FULL, s2, 3, d2));
ASSERT_EQ(m.catalogCount(), 1);
ASSERT_EQ(m.catalogRow(0)->n_seeders, 2);
ASSERT_EQ(m.catalogRow(0)->have_max, 7u);
advertise(s1, changed);
ASSERT_EQ(m.catalogCount(), 1);
EXPECT_EQ(m.catalogRow(0)->n_seeders, 1);
EXPECT_EQ(m.catalogRow(0)->have_max, 3u);
EXPECT_EQ(m.catalogRow(0)->last_ms, 200u);
m.set_clock(300);
m.on_message(wire, make_have_row(wire, sizeof(wire), mid, SIM_TARGET_ID, 0x01020300,
CODEC_FULL, MFLAG_FULL, s1, 9, d1)); // delayed row from the old digest
ASSERT_EQ(m.catalogCount(), 1);
EXPECT_EQ(m.catalogRow(0)->n_seeders, 1);
EXPECT_EQ(m.catalogRow(0)->have_max, 3u);
advertise(s2, changed, 0); // an explicit empty advert withdraws the source
EXPECT_EQ(m.catalogCount(), 0); // nobody still advertises the old set
g_q.clear();
}
// An unanswered GET_MANIFEST must not pin the fetch slot forever: after OTA_MANIFEST_MAX_RETRY ticks with
// no manifest, the session gives up (FAILED) so a new pull can take the slot. (Bounded primary operation.)
TEST(OtaTransfer, ManifestGiveUpAfterRetries) {
@@ -1701,6 +1803,8 @@ TEST(OtaTransfer, ManifestGiveUpAfterRetries) {
EXPECT_EQ(client.fetchState(), OtaManager::WANT_MANIFEST);
for (int i = 0; i < OTA_MANIFEST_MAX_RETRY + 2; i++) { g_clk += 5000; client.set_clock(g_clk); client.loop(); g_q.clear(); }
EXPECT_EQ(client.fetchState(), OtaManager::FAILED);
EXPECT_EQ(client.fetchError(), OtaManager::FETCH_ERROR_MANIFEST_TIMEOUT);
EXPECT_EQ(client.wanted(), 0u);
}
// A receiver never becomes a source, either while fetching or after completion.
@@ -1,6 +1,8 @@
#include <gtest/gtest.h>
#include <algorithm>
#include <deque>
#include <limits>
#include <vector>
#include "helpers/ArduinoSerialInterface.h"
@@ -10,6 +12,8 @@ class BufferStream : public Stream {
public:
std::deque<uint8_t> input;
std::vector<uint8_t> output;
int write_capacity = 4096;
size_t max_write = std::numeric_limits<size_t>::max();
void push(const char* data) {
while (*data) input.push_back((uint8_t)*data++);
@@ -20,6 +24,7 @@ public:
}
int available() override { return (int)input.size(); }
int availableForWrite() override { return write_capacity; }
int read() override {
if (input.empty()) return -1;
@@ -29,13 +34,15 @@ public:
}
size_t write(uint8_t value) override {
output.push_back(value);
return 1;
return write(&value, 1);
}
size_t write(const uint8_t* data, size_t len) override {
output.insert(output.end(), data, data + len);
return len;
size_t accepted = std::min(len, max_write);
accepted = std::min(accepted, (size_t)std::max(write_capacity, 0));
output.insert(output.end(), data, data + accepted);
write_capacity -= (int)accepted;
return accepted;
}
};
@@ -47,20 +54,32 @@ public:
bool enabled = false;
bool connected = false;
bool pairing_request = false;
bool write_busy = false;
std::deque<std::vector<uint8_t>> received_frames;
std::vector<std::vector<uint8_t>> sent_frames;
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 isWriteBusy() const override { return write_busy; }
bool takePairingRequest() override {
bool pending = pairing_request;
pairing_request = false;
return pending;
}
size_t writeFrame(const uint8_t[], size_t len) override { return len; }
size_t checkRecvFrame(uint8_t[]) override { return 0; }
size_t writeFrame(const uint8_t src[], size_t len) override {
sent_frames.emplace_back(src, src + len);
return len;
}
size_t checkRecvFrame(uint8_t dest[]) override {
if (received_frames.empty()) return 0;
const std::vector<uint8_t> frame = received_frames.front();
received_frames.pop_front();
memcpy(dest, frame.data(), frame.size());
return frame.size();
}
};
TEST(MultiSerialInterface, TracksBluetoothConnectionSeparately) {
@@ -99,6 +118,120 @@ TEST(MultiSerialInterface, PairingRequestsComeOnlyFromBluetooth) {
EXPECT_FALSE(manager.takePairingRequest());
}
TEST(MultiSerialInterface, RoutesRequiredRepliesToTheirRequestingInterface) {
MultiSerialInterface manager;
FakeSerialInterface usb;
FakeSerialInterface wifi;
usb.connected = true;
wifi.connected = true;
ASSERT_TRUE(manager.addInterface(InterfaceType::USB, &usb));
ASSERT_TRUE(manager.addInterface(InterfaceType::WiFi, &wifi));
manager.enable();
usb.received_frames.push_back({0x01});
uint8_t command[MAX_FRAME_SIZE] = {};
ASSERT_EQ(manager.checkRecvFrame(command), 1u);
const uint8_t response[] = {0x05, 0xAA};
EXPECT_EQ(manager.writeFrame(response, sizeof(response)), sizeof(response));
ASSERT_EQ(usb.sent_frames.size(), 1u);
EXPECT_TRUE(wifi.sent_frames.empty());
// Login/status-style pushes complete a client operation and follow the same
// requester route rather than exposing the result on another transport.
const uint8_t required_push[] = {0x85, 0xBB};
EXPECT_EQ(manager.writeFrame(required_push, sizeof(required_push)),
sizeof(required_push));
ASSERT_EQ(usb.sent_frames.size(), 2u);
EXPECT_TRUE(wifi.sent_frames.empty());
// Passive observations remain visible to every enabled client.
const uint8_t best_effort_push[] = {0x80, 0xCC};
EXPECT_EQ(manager.writeFrame(best_effort_push, sizeof(best_effort_push)),
sizeof(best_effort_push));
ASSERT_EQ(usb.sent_frames.size(), 3u);
ASSERT_EQ(wifi.sent_frames.size(), 1u);
}
TEST(MultiSerialInterface, LocksMultiFrameRepliesToOneRequester) {
MultiSerialInterface manager;
FakeSerialInterface usb;
FakeSerialInterface wifi;
usb.connected = true;
wifi.connected = true;
ASSERT_TRUE(manager.addInterface(InterfaceType::USB, &usb));
ASSERT_TRUE(manager.addInterface(InterfaceType::WiFi, &wifi));
manager.enable();
usb.received_frames.push_back({0x04});
uint8_t command[MAX_FRAME_SIZE] = {};
ASSERT_EQ(manager.checkRecvFrame(command), 1u);
manager.lockReplyRoute();
wifi.received_frames.push_back({0x16});
EXPECT_EQ(manager.checkRecvFrame(command), 0u);
EXPECT_EQ(wifi.received_frames.size(), 1u);
const uint8_t contact[] = {0x03, 0x42};
EXPECT_EQ(manager.writeFrame(contact, sizeof(contact)), sizeof(contact));
ASSERT_EQ(usb.sent_frames.size(), 1u);
EXPECT_TRUE(wifi.sent_frames.empty());
manager.unlockReplyRoute();
ASSERT_EQ(manager.checkRecvFrame(command), 1u);
EXPECT_EQ(command[0], 0x16);
const uint8_t device_info[] = {0x0D, 0x43};
EXPECT_EQ(manager.writeFrame(device_info, sizeof(device_info)),
sizeof(device_info));
ASSERT_EQ(wifi.sent_frames.size(), 1u);
}
TEST(MultiSerialInterface, LosingLockedRequesterCannotFallBackToBroadcast) {
MultiSerialInterface manager;
FakeSerialInterface usb;
FakeSerialInterface bluetooth;
usb.connected = true;
bluetooth.connected = true;
ASSERT_TRUE(manager.addInterface(InterfaceType::USB, &usb));
ASSERT_TRUE(manager.addInterface(InterfaceType::Bluetooth, &bluetooth));
manager.enable();
bluetooth.received_frames.push_back({0x04});
uint8_t command[MAX_FRAME_SIZE] = {};
ASSERT_EQ(manager.checkRecvFrame(command), 1u);
manager.lockReplyRoute();
manager.disableBluetooth();
EXPECT_FALSE(manager.isConnected());
const uint8_t contact[] = {0x03, 0x42};
EXPECT_EQ(manager.writeFrame(contact, sizeof(contact)), 0u);
EXPECT_TRUE(usb.sent_frames.empty());
manager.unlockReplyRoute();
EXPECT_TRUE(manager.isConnected());
}
TEST(MultiSerialInterface, PacesOnlyTheActiveReplyTransport) {
MultiSerialInterface manager;
FakeSerialInterface usb;
FakeSerialInterface wifi;
usb.connected = true;
wifi.connected = true;
wifi.write_busy = true;
ASSERT_TRUE(manager.addInterface(InterfaceType::USB, &usb));
ASSERT_TRUE(manager.addInterface(InterfaceType::WiFi, &wifi));
manager.enable();
usb.received_frames.push_back({0x04});
uint8_t command[MAX_FRAME_SIZE] = {};
ASSERT_EQ(manager.checkRecvFrame(command), 1u);
EXPECT_FALSE(manager.isWriteBusy());
wifi.received_frames.push_back({0x04});
ASSERT_EQ(manager.checkRecvFrame(command), 1u);
EXPECT_TRUE(manager.isWriteBusy());
}
TEST(SerialModeSwitch, RecognizesControlSequenceAcrossReads) {
BufferStream stream;
ArduinoSerialInterface interface;
@@ -230,6 +363,123 @@ TEST(SerialModeSwitch, PassthroughLeavesInputAndSuppressesBinaryOutput) {
EXPECT_EQ(stream.output[0], '>');
}
TEST(SerialFlowControl, KeepsAFrameQueuedUntilUsbHasSpace) {
BufferStream stream;
stream.write_capacity = 0;
ArduinoSerialInterface interface;
interface.begin(stream);
interface.enableFlowControl(true);
interface.enable();
const uint8_t payload[] = {0x05, 0xA5, 0x5A};
EXPECT_EQ(interface.writeFrame(payload, sizeof(payload)), sizeof(payload));
EXPECT_TRUE(stream.output.empty());
EXPECT_TRUE(interface.hasPendingIO());
EXPECT_TRUE(interface.isWriteBusy());
stream.write_capacity = MAX_FRAME_SIZE + 3;
interface.loop();
const std::vector<uint8_t> expected = {'>', 3, 0, 0x05, 0xA5, 0x5A};
EXPECT_EQ(stream.output, expected);
EXPECT_FALSE(interface.hasPendingIO());
}
TEST(SerialFlowControl, FinishesAnUnexpectedShortWriteBeforeNextFrame) {
BufferStream stream;
stream.write_capacity = MAX_FRAME_SIZE + 3;
stream.max_write = 2;
ArduinoSerialInterface interface;
interface.begin(stream);
interface.enableFlowControl(true);
interface.enable();
const uint8_t first[] = {0x05, 0x11, 0x22};
const uint8_t second[] = {0x00};
EXPECT_EQ(interface.writeFrame(first, sizeof(first)), sizeof(first));
ASSERT_EQ(stream.output.size(), 2u);
EXPECT_EQ(interface.writeFrame(second, sizeof(second)), sizeof(second));
stream.max_write = std::numeric_limits<size_t>::max();
interface.loop();
const std::vector<uint8_t> expected = {
'>', 3, 0, 0x05, 0x11, 0x22,
'>', 1, 0, 0x00};
EXPECT_EQ(stream.output, expected);
EXPECT_FALSE(interface.hasPendingIO());
}
TEST(SerialFlowControl, DrainsFramesThroughAFifoSmallerThanTheFrame) {
BufferStream stream;
stream.write_capacity = 4;
ArduinoSerialInterface interface;
interface.begin(stream);
interface.enableFlowControl(true);
interface.enable();
const uint8_t payload[] = {0x05, 1, 2, 3, 4, 5};
EXPECT_EQ(interface.writeFrame(payload, sizeof(payload)), sizeof(payload));
ASSERT_EQ(stream.output.size(), 4u);
EXPECT_TRUE(interface.hasPendingIO());
stream.write_capacity = 4;
interface.loop();
ASSERT_EQ(stream.output.size(), 8u);
EXPECT_TRUE(interface.hasPendingIO());
stream.write_capacity = 4;
interface.loop();
const std::vector<uint8_t> expected = {'>', 6, 0, 0x05, 1, 2, 3, 4, 5};
EXPECT_EQ(stream.output, expected);
EXPECT_FALSE(interface.hasPendingIO());
}
TEST(SerialFlowControl, PartialInboundFrameKeepsTransportBusy) {
BufferStream stream;
ArduinoSerialInterface interface;
interface.begin(stream);
interface.enable();
uint8_t frame[MAX_FRAME_SIZE] = {};
const uint8_t partial[] = {'<', 2};
stream.push(partial, sizeof(partial));
EXPECT_EQ(interface.checkRecvFrame(frame), 0u);
EXPECT_TRUE(interface.isReadBusy());
EXPECT_TRUE(interface.hasPendingIO());
const uint8_t remainder[] = {0, 0xA5, 0x5A};
stream.push(remainder, sizeof(remainder));
EXPECT_EQ(interface.checkRecvFrame(frame), 2u);
EXPECT_FALSE(interface.isReadBusy());
EXPECT_EQ(frame[0], 0xA5);
EXPECT_EQ(frame[1], 0x5A);
}
TEST(SerialFlowControl, AbandonsATruncatedInboundFrameAfterTimeout) {
resetArduinoMock();
BufferStream stream;
ArduinoSerialInterface interface;
interface.begin(stream);
interface.enable();
uint8_t frame[MAX_FRAME_SIZE] = {};
const uint8_t partial[] = {'<', 2, 0, 0xA5};
stream.push(partial, sizeof(partial));
EXPECT_EQ(interface.checkRecvFrame(frame), 0u);
EXPECT_TRUE(interface.isReadBusy());
delay(999);
interface.loop();
EXPECT_TRUE(interface.isReadBusy());
delay(1);
interface.loop();
EXPECT_FALSE(interface.isReadBusy());
const uint8_t complete[] = {'<', 1, 0, 0x5A};
stream.push(complete, sizeof(complete));
EXPECT_EQ(interface.checkRecvFrame(frame), 1u);
EXPECT_EQ(frame[0], 0x5A);
}
int main(int argc, char** argv) {
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();