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();
+}
+
+}