diff --git a/n_request.c b/n_request.c index 1c6e388a..bd989bab 100644 --- a/n_request.c +++ b/n_request.c @@ -770,6 +770,167 @@ bool NoteReset(void) return !resetRequired; } +/*! + @internal + + @brief Drain any residual bytes from the transport input buffer. + + On serial, read-and-discard whatever is already buffered, then wait for a + short quiescent period to confirm no further bytes arrive. This handles + stale bytes left over from a prior ping at a different baud rate. On + I2C, query and consume whatever the Notecard has queued. The adaptive + loop on serial has a hard cap so it cannot run away in the presence of + continuous line noise. + */ +static void _notePingDrainInput(void) +{ + const int iface = NoteGetActiveInterface(); + + if (iface == NOTE_C_INTERFACE_SERIAL) { + // Tuned for the `echo` probe used by NotePing: the only residual + // traffic possible is a short echo response or error from a prior + // wrong-baud ping, both well under 80 bytes. At 9600 baud a byte is + // ~1 ms, so a 20 ms quiet window (~19 byte-times) confirms the stream + // has ended, and a 100 ms total cap covers ~77 bytes of continuous + // residual transmission. + const uint32_t quietMs = 20; + const uint32_t maxMs = 100; + const uint32_t startMs = _GetMs(); + uint32_t lastByteMs = startMs; + for (;;) { + bool drained = false; + while (_SerialAvailable()) { + (void)_SerialReceive(); + drained = true; + } + if (drained) { + lastByteMs = _GetMs(); + } + if ((_GetMs() - lastByteMs) >= quietMs) { + return; + } + if ((_GetMs() - startMs) >= maxMs) { + return; + } + _DelayMs(1); + } + } else if (iface == NOTE_C_INTERFACE_I2C) { + // I2C is synchronous — no "in-flight" case. Just query what the + // Notecard has queued and read it off in chunks. + uint8_t scratch[32]; + uint32_t available = 0; + if (_I2CReceive(_I2CAddress(), scratch, 0, &available) != NULL) { + return; + } + while (available > 0) { + uint16_t chunk = (available > sizeof(scratch)) + ? (uint16_t)sizeof(scratch) + : (uint16_t)available; + if (_I2CReceive(_I2CAddress(), scratch, chunk, &available) != NULL) { + return; + } + } + } +} + +bool NotePing(void) +{ + // Short, fixed timeout. Long enough for a round-trip `echo` at 9600 baud + // with comfortable Notecard-side processing headroom; short enough that + // an autobaud scan across many rates completes quickly. + const uint32_t pingTimeoutMs = 500; + + // Generate a 16-character random nonce using xorshift32 seeded from the + // current millisecond clock. No file- or function-scope static state, so + // if this function is never called the linker can drop it all. + char nonce[17]; + uint32_t x = _GetMs() | 1u; // avoid the all-zero xorshift fixed point + for (int i = 0; i < 16; i++) { + x ^= x << 13; + x ^= x >> 17; + x ^= x << 5; + nonce[i] = (char)('A' + (x % 26)); + } + nonce[16] = '\0'; + + // Build the request with note-c's own J* primitives (cleaner than + // hand-assembling the JSON, and avoids pulling in snprintf which is + // not on the libc whitelist). + J *req = JCreateObject(); + if (req == NULL) { + return false; + } + JAddStringToObject(req, c_req, "echo"); + JAddStringToObject(req, "text", nonce); + char *json = JPrintUnformatted(req); + JDelete(req); + if (json == NULL) { + return false; + } + + // _Transaction requires a newline-terminated request with an explicit + // length. JPrintUnformatted returns a tightly-sized NUL-terminated + // buffer; overwrite the NUL with '\n' and pass length+1. This mirrors + // the pattern used in _noteTransactionShouldLock() above. + const size_t jsonLen = strlen(json); + json[jsonLen] = '\n'; + + // Suppress the normal request/response INFO trace: at a wrong baud rate + // both sides will look like garbage in the log, which is alarming but + // expected during an autobaud scan. + _noteSuspendTransactionDebug(); + + if (!_TransactionStart(pingTimeoutMs)) { + _Free(json); + _noteResumeTransactionDebug(); + return false; + } + _LockNote(); + + // Drain residual bytes from the transport before pinging. Must happen + // inside the lock so nothing else can refill the buffer between the + // drain and the transaction. + _notePingDrainInput(); + + // Deliberately do NOT honor `resetRequired` and do NOT call _Reset(): + // reset has its own retries/delays and can itself fail at a wrong baud + // rate, defeating the purpose of a fast connectivity ping. + // Deliberately do NOT add a CRC: CRCs exist to enable retries, and we + // are doing exactly one attempt. + char *rspJson = NULL; + const char *err = _Transaction(json, jsonLen + 1, &rspJson, pingTimeoutMs); + + _UnlockNote(); + _TransactionStop(); + _noteResumeTransactionDebug(); + + _Free(json); + + // Deliberately do NOT call NoteResetRequired() on failure: the caller + // (e.g. autobaud) needs to keep probing at other baud rates without + // paying a reset penalty on the next attempt. + if (err != NULL || rspJson == NULL) { + _Free(rspJson); + return false; + } + + // Parse and verify. The response must be valid JSON, must not carry + // an "err" field, and must contain a "text" field whose value is an + // exact match for the nonce we sent. Any other fields in the response + // (e.g. "cmd":"echo") are ignored. + J *rsp = JParse(rspJson); + _Free(rspJson); + if (rsp == NULL) { + return false; + } + + bool ok = JIsNullString(rsp, c_err) + && (strcmp(JGetString(rsp, "text"), nonce) == 0); + + JDelete(rsp); + return ok; +} + bool NoteErrorContains(const char *errstr, const char *errtype) { return (strstr(errstr, errtype) != NULL); diff --git a/note.h b/note.h index f5c46bbd..311396f1 100644 --- a/note.h +++ b/note.h @@ -296,6 +296,22 @@ typedef void (*txnStopFn) (void); @returns `true` if the reset was successful, `false` otherwise. */ bool NoteReset(void); +/*! + @brief Ping the Notecard to quickly verify that it is reachable. + + Sends a single `echo` request containing a random nonce and verifies that + the Notecard echoes the nonce back. Performs no retries, uses a short + fixed timeout, and does not trigger a Notecard reset on failure. Intended + for fast connectivity checks such as serial autobaud detection. + + On serial, the host UART input buffer is drained before the ping to + discard any residual bytes left over from a prior failed ping at a + different baud rate. + + @returns `true` if the Notecard is present and responded correctly, + `false` otherwise. + */ +bool NotePing(void); /*! @brief Mark that a Notecard reset is required on the next transaction. diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index f4a0c38a..c6fd18f7 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -168,6 +168,7 @@ add_test(NoteNewRequest_test) add_test(NotePayload_test) add_test(NotePayloadRetrieveAfterSleep_test) add_test(NotePayloadSaveAndSleep_test) +add_test(NotePing_test) add_test(NotePrint_test) add_test(NotePrintf_test) add_test(NotePrintln_test) diff --git a/test/src/NotePing_test.cpp b/test/src/NotePing_test.cpp new file mode 100644 index 00000000..1803e844 --- /dev/null +++ b/test/src/NotePing_test.cpp @@ -0,0 +1,259 @@ +/*! + * @file NotePing_test.cpp + * + * Written by the Blues Inc. team. + * + * Copyright (c) 2026 Blues Inc. MIT License. Use of this source code is + * governed by licenses granted by the copyright holder including that found in + * the + * LICENSE + * file. + * + */ + +#include +#include + +#include "n_lib.h" + +#include +#include + +DEFINE_FFF_GLOBALS +FAKE_VALUE_FUNC(bool, _noteTransactionStart, uint32_t) +FAKE_VOID_FUNC(_noteTransactionStop) +FAKE_VOID_FUNC(_noteLockNote) +FAKE_VOID_FUNC(_noteUnlockNote) +FAKE_VALUE_FUNC(const char *, _noteJSONTransaction, const char *, size_t, char **, uint32_t) +FAKE_VALUE_FUNC(bool, _noteSerialAvailable) +FAKE_VALUE_FUNC(char, _noteSerialReceive) +FAKE_VALUE_FUNC(bool, _noteHardReset) + +extern volatile int hookActiveInterface; + +namespace +{ + +enum class PingResponse { + Echo, + WrongNonce, + Error, + InvalidJson, + NoResponse, + TransactionError, +}; + +PingResponse pingResponse = PingResponse::Echo; +uint32_t currentMs = 1000; +size_t serialBytesRemaining = 0; +size_t serialBytesRemainingAtTransaction = 0; +size_t lastRequestLength = 0; +uint32_t lastTransactionTimeoutMs = 0; +bool lastRequestEndedWithNewline = false; +bool lastRequestHadCrc = false; + +char *copyString(const char *src) +{ + const size_t len = strlen(src); + char *dst = static_cast(malloc(len + 1)); + if (dst != NULL) { + memcpy(dst, src, len + 1); + } + return dst; +} + +uint32_t getMs() +{ + return currentMs; +} + +void delayMs(uint32_t ms) +{ + currentMs += ms; +} + +bool serialAvailable() +{ + return serialBytesRemaining > 0; +} + +char serialReceive() +{ + if (serialBytesRemaining > 0) { + --serialBytesRemaining; + } + return 'x'; +} + +char *makeResponse(const char *text) +{ + J *rsp = JCreateObject(); + if (rsp == NULL) { + return NULL; + } + JAddStringToObject(rsp, "text", text); + char *json = JPrintUnformatted(rsp); + JDelete(rsp); + return json; +} + +const char *pingTransaction(const char *request, size_t reqLen, char **response, uint32_t timeoutMs) +{ + lastRequestLength = reqLen; + lastTransactionTimeoutMs = timeoutMs; + lastRequestEndedWithNewline = (reqLen > 0 && request[reqLen - 1] == '\n'); + lastRequestHadCrc = (strstr(request, "\"crc\"") != NULL); + serialBytesRemainingAtTransaction = serialBytesRemaining; + + if (pingResponse == PingResponse::TransactionError) { + return ERRSTR("transaction failed {io}", c_ioerr); + } + if (response == NULL || pingResponse == PingResponse::NoResponse) { + return NULL; + } + if (pingResponse == PingResponse::InvalidJson) { + *response = copyString("not-json"); + return NULL; + } + if (pingResponse == PingResponse::Error) { + *response = copyString("{\"err\":\"failed\"}"); + return NULL; + } + + char *requestCopy = static_cast(malloc(reqLen + 1)); + if (requestCopy == NULL) { + return ERRSTR("malloc failed {mem}", c_mem); + } + memcpy(requestCopy, request, reqLen); + requestCopy[reqLen] = '\0'; + if (reqLen > 0 && requestCopy[reqLen - 1] == '\n') { + requestCopy[reqLen - 1] = '\0'; + } + + J *req = JParse(requestCopy); + free(requestCopy); + if (req == NULL) { + return ERRSTR("parse failed {bad}", c_bad); + } + + const char *nonce = JGetString(req, "text"); + *response = makeResponse(pingResponse == PingResponse::WrongNonce ? "wrong" : nonce); + JDelete(req); + + return NULL; +} + +void resetTestState(void) +{ + RESET_FAKE(_noteTransactionStart); + RESET_FAKE(_noteTransactionStop); + RESET_FAKE(_noteLockNote); + RESET_FAKE(_noteUnlockNote); + RESET_FAKE(_noteJSONTransaction); + RESET_FAKE(_noteSerialAvailable); + RESET_FAKE(_noteSerialReceive); + RESET_FAKE(_noteHardReset); + + pingResponse = PingResponse::Echo; + currentMs = 1000; + serialBytesRemaining = 0; + serialBytesRemainingAtTransaction = 0; + lastRequestLength = 0; + lastTransactionTimeoutMs = 0; + lastRequestEndedWithNewline = false; + lastRequestHadCrc = false; + resetRequired = false; + + NoteSetFnDefault(malloc, free, delayMs, getMs); + RESET_FAKE(_noteLockNote); + RESET_FAKE(_noteUnlockNote); + + hookActiveInterface = NOTE_C_INTERFACE_SERIAL; + _noteTransactionStart_fake.return_val = true; + _noteJSONTransaction_fake.custom_fake = pingTransaction; + _noteSerialAvailable_fake.custom_fake = serialAvailable; + _noteSerialReceive_fake.custom_fake = serialReceive; +} + +SCENARIO("NotePing") +{ + resetTestState(); + + SECTION("returns true when echo response matches the nonce") { + CHECK(NotePing()); + CHECK(_noteJSONTransaction_fake.call_count == 1); + CHECK(lastRequestLength > 0); + CHECK(lastRequestEndedWithNewline); + CHECK(!lastRequestHadCrc); + CHECK(lastTransactionTimeoutMs == 500); + CHECK(_noteTransactionStart_fake.call_count == 1); + CHECK(_noteTransactionStop_fake.call_count == 1); + CHECK(_noteLockNote_fake.call_count == 1); + CHECK(_noteUnlockNote_fake.call_count == 1); + } + + SECTION("returns false when the echoed nonce does not match") { + pingResponse = PingResponse::WrongNonce; + + CHECK(!NotePing()); + CHECK(_noteJSONTransaction_fake.call_count == 1); + } + + SECTION("returns false when the response contains an error") { + pingResponse = PingResponse::Error; + + CHECK(!NotePing()); + CHECK(_noteJSONTransaction_fake.call_count == 1); + } + + SECTION("returns false when the response is not valid JSON") { + pingResponse = PingResponse::InvalidJson; + + CHECK(!NotePing()); + CHECK(_noteJSONTransaction_fake.call_count == 1); + } + + SECTION("returns false when the transaction fails and does not retry") { + pingResponse = PingResponse::TransactionError; + + CHECK(!NotePing()); + CHECK(_noteJSONTransaction_fake.call_count == 1); + } + + SECTION("returns false when no response is received and does not retry") { + pingResponse = PingResponse::NoResponse; + + CHECK(!NotePing()); + CHECK(_noteJSONTransaction_fake.call_count == 1); + } + + SECTION("does not reset even when resetRequired is set") { + resetRequired = true; + pingResponse = PingResponse::TransactionError; + + CHECK(!NotePing()); + CHECK(_noteHardReset_fake.call_count == 0); + CHECK(resetRequired == true); + } + + SECTION("returns false without locking or transacting when transaction start fails") { + _noteTransactionStart_fake.return_val = false; + + CHECK(!NotePing()); + CHECK(_noteLockNote_fake.call_count == 0); + CHECK(_noteJSONTransaction_fake.call_count == 0); + CHECK(_noteTransactionStop_fake.call_count == 0); + } + + SECTION("drains serial input before sending the ping") { + serialBytesRemaining = 3; + + CHECK(NotePing()); + CHECK(_noteSerialReceive_fake.call_count == 3); + CHECK(serialBytesRemainingAtTransaction == 0); + } + + resetTestState(); +} + +}