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

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 closedFeature 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:

PropertyResult
Capture3 messages sent → repeater flood_rx +3, all cached
Serve round-tripreturned records matched the repeater's own cache (recv_ts, lengths, channel hash 0x11)
Decryptionevery replayed record decrypted back to the exact message sent
Non-memberdecrypting with a wrong key yields unreadable bytes
Paging4-message cache returned 3 records then 1 — ordered, no overlap, no gap
max_count2 returned exactly 2
Channel filterchannel_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 closedmeshcore-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 freeto 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

@MDamon