diff --git a/docs/payloads.md b/docs/payloads.md index b4a98b385..8087dadd0 100644 --- a/docs/payloads.md +++ b/docs/payloads.md @@ -149,7 +149,6 @@ Not defined in `BaseChatMesh`. Not defined in `BaseChatMesh`. - ### Response | Field | Size (bytes) | Description | @@ -225,6 +224,65 @@ txt_type | reply path len | 1 | path len for reply | | reply path | (variable) | reply path | +### Repeater - Channel history request + +Implemented by the **repeater** firmware. A repeater relays every public-channel +(`GRP_TXT`) packet that passes through it; with this feature it also keeps a bounded, +in-RAM ring buffer of those packets, so a client that was out of range can pull back +what it missed. It is an **anonymous** request — **no login required** — because public +channel data isn't admin-only. The repeater holds no channel key: packets are cached and +replayed still-encrypted, so a non-member who asks just receives ciphertext it can't +read, and a member decrypts with its own channel key. History is served **directly to the +requesting client** (never re-flooded), so it adds no broadcast airtime. + +The request must be sent **route-direct** (like the other anonymous sub-type requests, +which the repeater gates behind `isRouteDirect()`), and is rate-limited by the shared +anonymous-request limiter. + +| Field | Size (bytes) | Description | +|----------------|--------------|------------------------------------------------------------------| +| timestamp | 4 | sender time (unix timestamp) | +| req type | 1 | 0x04 (request sub type) | +| reply path len | 1 | path len for reply | +| reply path | (variable) | reply path | +| params len | 1 | *optional*; `6` if the params below follow, otherwise absent | +| channel hash | 1 | only return this channel's packets; `0xFF` = any channel | +| since | 4 | unix timestamp; only packets received strictly after are returned | +| max count | 1 | cap on records in this response; `0` = as many as fit | + +Everything from *params len* onwards is **optional**: a client that sends only a reply +path (which is all the stock companion's anonymous-request path emits) gets the defaults +`channel hash = 0xFF`, `since = 0`, `max count = 0` — i.e. recent history for all +channels. The explicit length byte is what makes this safe to detect: the request +plaintext is zero-padded to the AES block size, so a params len read out of that padding +is `0`, which is not `6` and therefore reads as "no params". + +The response is the reflected 4-byte tag, then a record count, then that many records of +`{recv_ts:4}{raw_len:1}{raw:raw_len}`, oldest first. Each `raw` is the original +channel-encrypted `GRP_TXT` payload exactly as it was relayed, so a channel member decrypts +it with its own key and a non-member cannot read it. + +The response is size-capped, so a client must be prepared to page: re-request with *since* +set to the newest `recv_ts` received, until a response comes back with a count of zero. + +Response (after the standard 4-byte reflected-timestamp tag): + +| Field | Size (bytes) | Description | +|---------|--------------|------------------------------------| +| count | 1 | number of records that follow | +| records | rest | `count` × record, oldest → newest | + +Each record: + +| Field | Size (bytes) | Description | +|---------|--------------|------------------------------------------------------| +| recv ts | 4 | when the repeater received the packet | +| raw len | 1 | length of the raw payload | +| raw | `raw len` | the original `GRP_TXT` payload, verbatim (encrypted) | + +A response carries only as many records as fit in one packet. The client pages by +re-requesting with `since` set to the newest `recv ts` it received. + ## Group text message diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 09f74cbea..eb51a5c63 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -55,6 +55,7 @@ #define ANON_REQ_TYPE_REGIONS 0x01 #define ANON_REQ_TYPE_OWNER 0x02 #define ANON_REQ_TYPE_BASIC 0x03 // just remote clock +#define ANON_REQ_TYPE_CHANNEL_HISTORY 0x04 // pull recent public-channel packets, no login required #define CLI_REPLY_DELAY_MILLIS 600 @@ -379,6 +380,52 @@ int MyMesh::handleRequest(ClientInfo *sender, uint32_t sender_timestamp, uint8_t return 0; // unknown command } +// Serve recent public-channel history to an ANONYMOUS requester (no login). Public +// channel data isn't admin-only, and the cached packets are still channel-encrypted, +// so a non-member who asks just receives ciphertext it can't read. Rate-limited via +// the shared anon_limiter, like the other anonymous requests. +uint8_t MyMesh::handleAnonChannelHistoryReq(const mesh::Identity& sender, uint32_t sender_timestamp, const uint8_t* data, size_t len) { + if (anon_limiter.allow(rtc_clock.getCurrentTime())) { + // request data: {reply-path-len}{reply-path}[{params_len=6}{channel_hash}{since_ts(uint32)}{max_count}] + if (len < 1) return 0; + reply_path_len = *data & 63; + reply_path_hash_size = (*data >> 6) + 1; + data++; + uint8_t path_bytes = ((uint8_t)reply_path_len) * reply_path_hash_size; + if (1 + (size_t)path_bytes > len) return 0; // truncated/invalid reply path + memcpy(reply_path, data, path_bytes); + data += path_bytes; // advance to the request params + + // The params are OPTIONAL and self-describing. A stock client sends only a + // reply path, so we must not read params that aren't there -- and we cannot + // just compare lengths, because the ANON_REQ plaintext is zero-padded to the + // AES block size (Utils::encrypt), which makes `len` larger than what the + // sender actually wrote. A leading params length byte solves both: read out + // of the zero padding it is 0, which means "no params, use defaults". + uint8_t want_hash = 0xFF; // 0xFF = any channel + uint32_t since_ts = 0; // 0 = from the beginning + uint8_t max_count = 0; // 0 = all that fit + size_t consumed = 1 + (size_t)path_bytes; + if (len > consumed && data[0] == CHANNEL_HISTORY_PARAMS_LEN + && len >= consumed + 1 + CHANNEL_HISTORY_PARAMS_LEN) { + want_hash = data[1]; + memcpy(&since_ts, &data[2], 4); + max_count = data[6]; + } + + // response: [0..3]=tag, [4]=record count, then packed records from serve(): + // { [0..3]=recv_ts, [4]=raw_len, raw[raw_len] } ... . Client pages by + // re-requesting with since_ts = the newest recv_ts it received. + memcpy(reply_data, &sender_timestamp, 4); // prefix with sender_timestamp, like a tag + uint8_t count = 0; + int written = channel_history.serve(want_hash, since_ts, max_count, + &reply_data[5], CHANNEL_HISTORY_MAX_REPLY - 5, count); + reply_data[4] = count; + return 5 + written; // reply length + } + return 0; +} + mesh::Packet *MyMesh::createSelfAdvert() { uint8_t app_data[MAX_ADVERT_DATA_SIZE]; uint8_t app_data_len = _cli.buildAdvertData(ADV_TYPE_REPEATER, app_data); @@ -549,6 +596,17 @@ uint32_t MyMesh::getDirectRetransmitDelay(const mesh::Packet *packet) { return getRNG()->nextInt(0, 5*t + 1); } +// Cache a public-channel broadcast so a client that was out of range can pull it +// back later. The repeater holds no channel key, so the payload is stored (and +// later replayed) still-encrypted; the requesting client decrypts it itself. +void MyMesh::captureChannelPacket(const mesh::Packet* pkt) { + if (pkt->getPayloadType() != PAYLOAD_TYPE_GRP_TXT) return; // public-channel text only + if (channel_history.capture(pkt->payload, pkt->payload_len, getRTCClock()->getCurrentTime())) { + MESH_DEBUG_PRINTLN("channel history: cached pkt (hash=%02X, len=%d)", + (uint32_t)pkt->payload[0], (uint32_t)pkt->payload_len); + } +} + mesh::DispatcherAction MyMesh::onRecvPacket(mesh::Packet* pkt) { if (pkt->getRouteType() == ROUTE_TYPE_TRANSPORT_FLOOD) { recv_pkt_region = region_map.findMatch(pkt, REGION_DENY_FLOOD); @@ -561,6 +619,9 @@ mesh::DispatcherAction MyMesh::onRecvPacket(mesh::Packet* pkt) { } else { recv_pkt_region = NULL; } + // capture public-channel broadcasts for store-and-forward history (content-deduped + // internally, so re-floods heard from multiple neighbours only cache once) + captureChannelPacket(pkt); return Mesh::onRecvPacket(pkt); } @@ -583,6 +644,8 @@ void MyMesh::onAnonDataRecv(mesh::Packet *packet, const uint8_t *secret, const m reply_len = handleAnonOwnerReq(sender, timestamp, &data[5]); } else if (data[4] == ANON_REQ_TYPE_BASIC && packet->isRouteDirect()) { reply_len = handleAnonClockReq(sender, timestamp, &data[5]); + } else if (data[4] == ANON_REQ_TYPE_CHANNEL_HISTORY && packet->isRouteDirect()) { + reply_len = handleAnonChannelHistoryReq(sender, timestamp, &data[5], len > 5 ? len - 5 : 0); } else { reply_len = 0; // unknown/invalid request type } @@ -870,6 +933,7 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc #if MAX_NEIGHBOURS memset(neighbours, 0, sizeof(neighbours)); #endif + channel_history.clear(); // defaults _prefs.airtime_factor = 1.0; diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index aa7d30b06..e97b313ee 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -25,6 +25,7 @@ #endif #include +#include #include #include #include @@ -69,6 +70,24 @@ struct NeighbourInfo { int8_t snr; // multiplied by 4, user should divide to get float value }; +#ifndef MAX_CHANNEL_HISTORY + #define MAX_CHANNEL_HISTORY 32 // recent public-channel (GRP_TXT) packets kept for store-and-forward +#endif + +// Largest GRP_TXT payload we can cache. A cached packet must fit, verbatim, inside a +// single RESPONSE packet (reply_data is MAX_PACKET_PAYLOAD) alongside the response +// framing: 4-byte reply tag + 1-byte record count + 4-byte recv_ts + 1-byte raw_len. +#define CHANNEL_HISTORY_MAX_RAW (MAX_PACKET_PAYLOAD - 10) +// size of the optional channel-history request params: {channel_hash}{since_ts:u32}{max_count} +#define CHANNEL_HISTORY_PARAMS_LEN 6 +// Cap on the channel-history reply. reply_data is MAX_PACKET_PAYLOAD (184), but that is +// not the binding limit: a companion hands the response to its host app in a serial frame +// of {push code}{reserved}{tag:4} + (reply padded to the AES block size - 4), i.e. +// padded_len + 2, and MAX_FRAME_SIZE is 176. A reply of <= 160 bytes pads to at most 160 +// and so always fits; a larger one is transmitted by the repeater but silently dropped +// before it reaches the app. Clients page for the remainder using `since`. +#define CHANNEL_HISTORY_MAX_REPLY 160 + #ifndef FIRMWARE_BUILD_DATE #define FIRMWARE_BUILD_DATE "6 Jun 2026" #endif @@ -107,6 +126,7 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { #if MAX_NEIGHBOURS NeighbourInfo neighbours[MAX_NEIGHBOURS]; #endif + ChannelHistory channel_history; // recent public-channel packets CayenneLPP telemetry; unsigned long set_radio_at, revert_radio_at; float pending_freq; @@ -121,10 +141,12 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { #endif void putNeighbour(const mesh::Identity& id, uint32_t timestamp, float snr); + void captureChannelPacket(const mesh::Packet* pkt); 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 handleAnonChannelHistoryReq(const mesh::Identity& sender, uint32_t sender_timestamp, const uint8_t* data, size_t len); int handleRequest(ClientInfo* sender, uint32_t sender_timestamp, uint8_t* payload, size_t payload_len); mesh::Packet* createSelfAdvert(); diff --git a/platformio.ini b/platformio.ini index b29e9d849..7522348e1 100644 --- a/platformio.ini +++ b/platformio.ini @@ -163,7 +163,7 @@ build_flags = -std=c++17 -I src -I test/mocks test_build_src = yes -test_ignore = test_kiss_modem +test_ignore = test_kiss_modem, test_channel_history build_src_filter = -<*> +<../src/Utils.cpp> @@ -172,6 +172,19 @@ build_src_filter = lib_deps = google/googletest @ 1.17.0 +[env:native_channel_history] +platform = native +test_framework = googletest +build_flags = -std=c++17 + -I test/mocks + -I src +test_build_src = yes +test_filter = test_channel_history +build_src_filter = + -<*> +lib_deps = + google/googletest @ 1.17.0 + [env:native_kiss_modem] platform = native test_framework = googletest diff --git a/src/helpers/ChannelHistory.h b/src/helpers/ChannelHistory.h new file mode 100644 index 000000000..3a0f1efa2 --- /dev/null +++ b/src/helpers/ChannelHistory.h @@ -0,0 +1,121 @@ +#pragma once + +#include +#include + +// Hardware-independent store-and-forward cache of recent public-channel (GRP_TXT) +// broadcasts. A repeater already relays every channel packet that passes through +// it; this keeps a bounded copy so a companion that was out of range can pull back +// what it missed. +// +// The repeater holds no channel key, so packets are stored - and later replayed - +// still encrypted; the requesting client decrypts them with its own channel key. +// A cached entry's raw[0] is the GRP_TXT channel-hash prefix. +// +// Kept free of Arduino / radio dependencies so it can be unit-tested natively +// (see test/test_channel_history). +// +// NUM_ENTRIES : ring-buffer depth (oldest entry evicted when full) +// MAX_RAW : largest packet payload cached; must be small enough that one entry +// fits inside a single response packet (see serve()). +template +class ChannelHistory { +public: + // per-served-record header: recv_ts(4) + raw_len(1) + static const int REC_HEADER = 5; + + ChannelHistory() { clear(); } + + void clear() { + memset(_entries, 0, sizeof(_entries)); + _next_idx = 0; + } + + // Cache one public-channel payload (caller has already checked it is GRP_TXT). + // Returns true if newly stored, false if skipped: empty, too large to ever replay + // in one response, or a duplicate re-flood already held. + // + // Dedup is by content: a GRP_TXT payload embeds the sender's encrypted timestamp, + // so identical bytes mean the very same packet; distinct messages never collide. + bool capture(const uint8_t* payload, uint16_t payload_len, uint32_t now_ts) { + if (payload_len < 1 || payload_len > MAX_RAW) return false; + + for (int k = 0; k < NUM_ENTRIES; k++) { + const Entry& e = _entries[k]; + if (e.recv_timestamp != 0 && e.raw_len == payload_len + && memcmp(e.raw, payload, payload_len) == 0) { + return false; // already cached + } + } + + Entry& slot = _entries[_next_idx]; + slot.recv_timestamp = now_ts; + slot.raw_len = (uint8_t)payload_len; + memcpy(slot.raw, payload, payload_len); + _next_idx = (_next_idx + 1) % NUM_ENTRIES; + return true; + } + + // Pack matching cached packets into out[], oldest -> newest, for entries strictly + // newer than since_ts, up to max_count (0 == no explicit cap), stopping early if + // the next record would not fit in out_cap. Each record is: + // [recv_ts(4)][raw_len(1)][raw ...] + // The client pages by re-requesting with since_ts = the newest recv_ts it received. + // Writes the number of records emitted to out_count; returns bytes written. + int serve(uint8_t want_hash, uint32_t since_ts, uint8_t max_count, + uint8_t* out, int out_cap, uint8_t& out_count) const { + if (max_count == 0) max_count = 255; + uint8_t count = 0; + int ofs = 0; + // _next_idx points at the oldest slot once the ring has wrapped, so walking + // forward from it yields oldest -> newest; empty slots (recv_timestamp==0) skip. + for (int k = 0; k < NUM_ENTRIES && count < max_count; k++) { + const Entry& e = _entries[(_next_idx + k) % NUM_ENTRIES]; + if (e.recv_timestamp == 0 || e.recv_timestamp <= since_ts) continue; + if (want_hash != 0xFF && e.raw[0] != want_hash) continue; // channel filter + if (ofs + REC_HEADER + e.raw_len > out_cap) break; // full; client pages for more + memcpy(&out[ofs], &e.recv_timestamp, 4); ofs += 4; + out[ofs++] = e.raw_len; + memcpy(&out[ofs], e.raw, e.raw_len); ofs += e.raw_len; + count++; + } + out_count = count; + return ofs; + } + + // Number of non-empty entries currently held (for diagnostics/debug dumps). + int countStored() const { + int n = 0; + for (int k = 0; k < NUM_ENTRIES; k++) { + if (_entries[k].recv_timestamp != 0) n++; + } + return n; + } + + // Read the i-th non-empty entry oldest -> newest (for diagnostics). Returns false + // past the end. channel_hash / raw_len / recv_ts are optional out-params. + bool entryAt(int i, uint8_t* channel_hash, uint8_t* raw_len, uint32_t* recv_ts) const { + int seen = 0; + for (int k = 0; k < NUM_ENTRIES; k++) { + const Entry& e = _entries[(_next_idx + k) % NUM_ENTRIES]; + if (e.recv_timestamp == 0) continue; + if (seen == i) { + if (channel_hash) *channel_hash = e.raw[0]; + if (raw_len) *raw_len = e.raw_len; + if (recv_ts) *recv_ts = e.recv_timestamp; + return true; + } + seen++; + } + return false; + } + +private: + struct Entry { + uint32_t recv_timestamp; // 0 == empty slot + uint8_t raw_len; + uint8_t raw[MAX_RAW]; // GRP_TXT payload, verbatim (still encrypted); raw[0] = channel hash + }; + Entry _entries[NUM_ENTRIES]; + uint8_t _next_idx; +}; diff --git a/test/test_channel_history/test_channel_history.cpp b/test/test_channel_history/test_channel_history.cpp new file mode 100644 index 000000000..e5182939c --- /dev/null +++ b/test/test_channel_history/test_channel_history.cpp @@ -0,0 +1,173 @@ +#include + +#include +#include +#include +#include + +#include "helpers/ChannelHistory.h" + +// Build a fake GRP_TXT payload: raw[0] = channel hash, followed by a body. The real +// firmware never inspects the body (it's ciphertext); these tests only care that the +// bytes round-trip verbatim and that dedup is by exact content. +static std::vector mkPkt(uint8_t channel_hash, const std::string& body) { + std::vector p; + p.push_back(channel_hash); + p.insert(p.end(), body.begin(), body.end()); + return p; +} + +static bool cap(ChannelHistory<8, 64>& h, uint8_t chan, const std::string& body, uint32_t ts) { + auto p = mkPkt(chan, body); + return h.capture(p.data(), (uint16_t)p.size(), ts); +} + +// -------- capture / dedup -------- + +TEST(ChannelHistory, CapturesAndCounts) { + ChannelHistory<8, 64> h; + EXPECT_EQ(h.countStored(), 0); + EXPECT_TRUE(cap(h, 0xAA, "hello", 100)); + EXPECT_EQ(h.countStored(), 1); +} + +TEST(ChannelHistory, DedupsIdenticalReflood) { + ChannelHistory<8, 64> h; + EXPECT_TRUE (cap(h, 0xAA, "same-bytes", 100)); + EXPECT_FALSE(cap(h, 0xAA, "same-bytes", 105)); // re-flood heard from another neighbour + EXPECT_FALSE(cap(h, 0xAA, "same-bytes", 110)); + EXPECT_EQ(h.countStored(), 1); +} + +TEST(ChannelHistory, DistinctMessagesBothStored) { + ChannelHistory<8, 64> h; + EXPECT_TRUE(cap(h, 0xAA, "msg-one", 100)); + EXPECT_TRUE(cap(h, 0xAA, "msg-two", 101)); // same channel, different body + EXPECT_TRUE(cap(h, 0xBB, "msg-one", 102)); // same body, different channel prefix + EXPECT_EQ(h.countStored(), 3); +} + +TEST(ChannelHistory, RejectsEmptyAndOversize) { + ChannelHistory<8, 64> h; + uint8_t one = 0x01; + EXPECT_FALSE(h.capture(&one, 0, 100)); // empty + std::vector big(65, 0x7); // > MAX_RAW (64) + EXPECT_FALSE(h.capture(big.data(), (uint16_t)big.size(), 100)); + std::vector fits(64, 0x7); // exactly MAX_RAW + EXPECT_TRUE(h.capture(fits.data(), (uint16_t)fits.size(), 100)); + EXPECT_EQ(h.countStored(), 1); +} + +// -------- ring wrap / eviction -------- + +TEST(ChannelHistory, EvictsOldestOnWrap) { + ChannelHistory<4, 64> h; + for (int i = 0; i < 6; i++) { // 6 into a depth-4 ring + auto p = mkPkt(0xAA, "m" + std::to_string(i)); + ASSERT_TRUE(h.capture(p.data(), (uint16_t)p.size(), 100 + i)); + } + EXPECT_EQ(h.countStored(), 4); // capped at depth + uint32_t ts = 0; + ASSERT_TRUE(h.entryAt(0, nullptr, nullptr, &ts)); + EXPECT_EQ(ts, 102u); // m0,m1 evicted; oldest survivor is m2 +} + +// -------- serve -------- + +// Decode a serve() buffer into (recv_ts, raw) records for assertions. +struct Rec { uint32_t ts; std::vector raw; }; +static std::vector decode(const uint8_t* buf, int len) { + std::vector out; + int ofs = 0; + while (ofs + 5 <= len) { + Rec r; + memcpy(&r.ts, &buf[ofs], 4); ofs += 4; + uint8_t rl = buf[ofs++]; + r.raw.assign(&buf[ofs], &buf[ofs + rl]); ofs += rl; + out.push_back(r); + } + return out; +} + +TEST(ChannelHistory, ServeReturnsNewerThanSinceInOrder) { + ChannelHistory<8, 64> h; + cap(h, 0xAA, "a", 100); + cap(h, 0xAA, "b", 200); + cap(h, 0xAA, "c", 300); + + uint8_t buf[256]; uint8_t count = 0; + int n = h.serve(0xFF, 150, 0, buf, sizeof(buf), count); + auto recs = decode(buf, n); + ASSERT_EQ(count, 2); + ASSERT_EQ(recs.size(), 2u); + EXPECT_EQ(recs[0].ts, 200u); // oldest-first, and 'a'@100 excluded by since=150 + EXPECT_EQ(recs[1].ts, 300u); + EXPECT_EQ(std::string(recs[1].raw.begin() + 1, recs[1].raw.end()), "c"); +} + +TEST(ChannelHistory, ServeFiltersByChannelHash) { + ChannelHistory<8, 64> h; + cap(h, 0xAA, "on-aa", 100); + cap(h, 0xBB, "on-bb", 101); + cap(h, 0xAA, "on-aa-2", 102); + + uint8_t buf[256]; uint8_t count = 0; + int n = h.serve(0xAA, 0, 0, buf, sizeof(buf), count); + auto recs = decode(buf, n); + ASSERT_EQ(count, 2); + for (auto& r : recs) EXPECT_EQ(r.raw[0], 0xAA); +} + +TEST(ChannelHistory, ServeRespectsMaxCount) { + ChannelHistory<8, 64> h; + for (int i = 0; i < 5; i++) cap(h, 0xAA, "m" + std::to_string(i), 100 + i); + uint8_t buf[256]; uint8_t count = 0; + h.serve(0xFF, 0, 2, buf, sizeof(buf), count); + EXPECT_EQ(count, 2); +} + +TEST(ChannelHistory, ServePagesByAdvancingSince) { + ChannelHistory<8, 64> h; + for (int i = 0; i < 5; i++) cap(h, 0xAA, "m" + std::to_string(i), 100 + i); + + uint8_t buf[256]; uint8_t count = 0; + int n = h.serve(0xFF, 0, 2, buf, sizeof(buf), count); // page 1 + auto page1 = decode(buf, n); + ASSERT_EQ(count, 2); + uint32_t cursor = page1.back().ts; + + n = h.serve(0xFF, cursor, 2, buf, sizeof(buf), count); // page 2 + auto page2 = decode(buf, n); + ASSERT_EQ(count, 2); + EXPECT_GT(page2.front().ts, cursor); // strictly newer; no overlap +} + +TEST(ChannelHistory, ServeStopsWhenBufferFull) { + ChannelHistory<8, 64> h; + std::vector body(60, 0x9); // record = 5 + 61 = 66 bytes + for (int i = 0; i < 4; i++) { + std::vector p; p.push_back(0xAA); + p.insert(p.end(), body.begin(), body.end()); + p[1] = (uint8_t)i; // make each distinct + ASSERT_TRUE(h.capture(p.data(), (uint16_t)p.size(), 100 + i)); + } + uint8_t buf[140]; uint8_t count = 0; // fits only 2 records (132 bytes) + int n = h.serve(0xFF, 0, 0, buf, sizeof(buf), count); + EXPECT_EQ(count, 2); // partial; client pages for the rest + EXPECT_LE(n, (int)sizeof(buf)); +} + +TEST(ChannelHistory, ClearEmpties) { + ChannelHistory<8, 64> h; + cap(h, 0xAA, "x", 100); + h.clear(); + EXPECT_EQ(h.countStored(), 0); + uint8_t buf[64]; uint8_t count = 0; + h.serve(0xFF, 0, 0, buf, sizeof(buf), count); + EXPECT_EQ(count, 0); +} + +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +}