Browse Source

Repeater: public-channel message history (store-and-forward)

A repeater already relays every public-channel (GRP_TXT) flood packet that
passes through it. This keeps a bounded, in-RAM ring buffer of those packets so
a client that was out of range can pull back the messages it missed, using an
anonymous request (ANON_REQ sub-type 0x04) -- no login.

Design points:

- Repeater-only. Room server untouched.
- The repeater holds no channel key. Packets are cached and replayed still
  channel-encrypted, so a non-member who asks receives ciphertext it cannot
  read, and a member decrypts with the key it already has.
- Served directly to the requesting client, never re-flooded, so it adds no
  broadcast airtime.
- No login: public channel data is not admin-only. Rate-limited by the existing
  anon_limiter, like the other anonymous requests.
- Bounded RAM (32 entries, ~6 KB), nothing persisted -- a rotating cache in the
  small internal-flash FS would be a wear anti-pattern.
- Channel broadcasts only, never direct messages, so this does not reintroduce
  the ACK/timeout problem that closed #613.

The request params {channel_hash, since_ts, max_count} are optional and
self-describing via a leading length byte, so a stock client that sends only a
reply path gets sensible defaults. A plain length check cannot work here: the
ANON_REQ plaintext is zero-padded to the AES block size, so the decrypted
length exceeds what the sender wrote.

The reply is capped at 160 bytes. reply_data is MAX_PACKET_PAYLOAD (184), but
the binding limit is the companion's host serial frame -- {push code}{reserved}
{tag:4} plus the reply padded to the AES block size minus 4, against
MAX_FRAME_SIZE of 176. Larger replies are transmitted but dropped before the
app sees them. Clients page for the remainder with `since`.

ChannelHistory is a hardware-independent template, covered by 11 googletest
cases under a dedicated native env.
pull/3078/head
Mike Damon 10 hours ago
parent
commit
4860768915
  1. 60
      docs/payloads.md
  2. 64
      examples/simple_repeater/MyMesh.cpp
  3. 22
      examples/simple_repeater/MyMesh.h
  4. 15
      platformio.ini
  5. 121
      src/helpers/ChannelHistory.h
  6. 173
      test/test_channel_history/test_channel_history.cpp

60
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

64
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;

22
examples/simple_repeater/MyMesh.h

@ -25,6 +25,7 @@
#endif
#include <helpers/AdvertDataHelpers.h>
#include <helpers/ChannelHistory.h>
#include <helpers/ArduinoHelpers.h>
#include <helpers/ClientACL.h>
#include <helpers/CommonCLI.h>
@ -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<MAX_CHANNEL_HISTORY, CHANNEL_HISTORY_MAX_RAW> 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();

15
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

121
src/helpers/ChannelHistory.h

@ -0,0 +1,121 @@
#pragma once
#include <stdint.h>
#include <string.h>
// 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 <int NUM_ENTRIES, int MAX_RAW>
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;
};

173
test/test_channel_history/test_channel_history.cpp

@ -0,0 +1,173 @@
#include <gtest/gtest.h>
#include <cstdint>
#include <cstring>
#include <string>
#include <vector>
#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<uint8_t> mkPkt(uint8_t channel_hash, const std::string& body) {
std::vector<uint8_t> 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<uint8_t> big(65, 0x7); // > MAX_RAW (64)
EXPECT_FALSE(h.capture(big.data(), (uint16_t)big.size(), 100));
std::vector<uint8_t> 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<uint8_t> raw; };
static std::vector<Rec> decode(const uint8_t* buf, int len) {
std::vector<Rec> 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<uint8_t> body(60, 0x9); // record = 5 + 61 = 66 bytes
for (int i = 0; i < 4; i++) {
std::vector<uint8_t> 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();
}
Loading…
Cancel
Save