Skip to content

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

Open
MDamon wants to merge 1 commit into
meshcore-dev:devfrom
MDamon:feature/repeater-channel-history
Open

Repeater: public-channel message history (store-and-forward) 🤖🤖#3078
MDamon wants to merge 1 commit into
meshcore-dev:devfrom
MDamon:feature/repeater-channel-history

Conversation

@MDamon

@MDamon MDamon commented Jul 30, 2026

Copy link
Copy Markdown

Implements #3077. Opening the code alongside the feature request rather than waiting, since it is already written and validated — but treating #3077 as the place to settle the design questions. Happy to rework or drop this if maintainers want a different shape.

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, via an anonymous request (ANON_REQ sub-type 0x04).

Design

  • 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 requester, never re-flooded, so it adds no broadcast airtime — the concern raised on [Feature Request] Allow a room server to save public channel messages #1290.
  • Channel broadcasts only, never direct messages, so it does not reintroduce the ACK/timeout problem that closed Feature request - repeaters - caching msgs for later delivery #613.
  • No login. Public channel data is not admin-only. Rate-limited by the existing anon_limiter, like the other anonymous requests.
  • Bounded RAM, nothing persisted. 32 entries (~6 KB); a rotating cache in the small internal flash FS would be a wear anti-pattern.

Only two existing code paths change: a capture hook in the repeater's onRecvPacket(), and a new sub-type branch in onAnonDataRecv(). ChannelHistory itself is a hardware-independent template.

Wire format

ANON_REQ 0x04  {reply-path-len}{reply-path}[{params_len=6}{channel_hash}{since_ts:u32}{max_count}]
response       {tag:4}{count:u8}  then count x  {recv_ts:u32}{raw_len:u8}{raw[raw_len]}   (oldest first)

Documented in docs/payloads.md.

Two details worth calling out for reviewers, both found on hardware:

Params are optional and self-describing. A stock client that sends only a reply path gets sensible defaults. This needs the explicit params_len byte rather than a length comparison, because the ANON_REQ plaintext is zero-padded to the AES block size — the decrypted length is larger than what the sender wrote, so a naive check reads its params out of the padding. Reading the length byte out of that same zero padding yields 0, which correctly means "no params".

The reply is capped at 160 bytes. 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} plus the reply padded to the AES block size minus 4 — padded_len + 2 — against MAX_FRAME_SIZE of 176. A larger reply is transmitted by the repeater and then dropped before the app ever sees it. Clients page for the remainder with since.

Testing

Unit tests — 11 googletest cases, pio test -e native_channel_history: content dedup of re-floods, oldest→newest ordering, since paging with no overlap, oversize/empty rejection, channel-hash filter, ring wrap eviction, max_count, output-buffer-full stop, clear.

A separate [env:native_channel_history] was added rather than reusing [env:native], because that env does not compile on macOS/clang for an unrelated reason (ConfigSerializer.cpp uses atol/atoi/atof without <cstdlib>, which only builds because Linux/gcc includes it transitively). This mirrors the isolation already used for native_kiss_modem.

Hardware — two SenseCAP T1000-E units at 910.525 MHz / BW 62.5 / SF 8 / CR 5, one repeater running this firmware, one stock companion driving the requests:

Property Result
Capture 3 messages sent → repeater flood_rx +3, all cached
Serve round-trip returned records matched the repeater's own cache (recv_ts, lengths, channel hash 0x11)
Decryption every replayed record decrypted back to the exact message sent
Non-member decrypting with a wrong key yields unreadable bytes
Paging 4-message cache returned 3 records then 1 — ordered, no overlap, no gap
max_count 2 returned exactly 2
Channel filter channel_hash=0xAA (absent) returned 0

The decryption check is the one that matters: it demonstrates the repeater — which holds no channel key — replaying packets a member can still read.

Builds verified for Station_G2_repeater (ESP32-S3) and t1000e_repeater (nRF52840). RAM cost of the buffer is negligible (Station G2 build: 23.7%).

Note: validating this on T1000-E hardware first required #3075 / #3076 — an unrelated LR1110 receive bug on dev. This feature does not depend on that fix to build or run; it was only needed to get a working radio link for testing.

On the examples/ guideline

CONTRIBUTING asks that new features include an example sketch. This extends the existing examples/simple_repeater rather than adding a new one, since a separate sketch would mean shipping a second, near-duplicate repeater firmware. Happy to split it out if you would prefer that.

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 meshcore-dev#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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant