Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 59 additions & 1 deletion docs/payloads.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,6 @@ Not defined in `BaseChatMesh`.

Not defined in `BaseChatMesh`.


### Response

| Field | Size (bytes) | Description |
Expand Down Expand Up @@ -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

Expand Down
64 changes: 64 additions & 0 deletions examples/simple_repeater/MyMesh.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand All @@ -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);
}

Expand All @@ -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
}
Expand Down Expand Up @@ -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;
Expand Down
22 changes: 22 additions & 0 deletions examples/simple_repeater/MyMesh.h
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand All @@ -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();

Expand Down
15 changes: 14 additions & 1 deletion platformio.ini
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand All @@ -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
Expand Down
121 changes: 121 additions & 0 deletions src/helpers/ChannelHistory.h
Original file line number Diff line number Diff line change
@@ -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;
};
Loading