Browse Source

MAX_FRAMESIZE limit fragmentation (reworked)

Now node-to-companion packets are fragmentable. Used with updated companion protocol version. It's compatible with current versions of client apps, i.e. MeshCore app.
Changes from initial PR: Removed APP_SAFE_FRAME_SIZE, updated documentation, fixed «queued_fragment_id == 0» bug, fullfilled code with comments.
pull/2888/head
HDDen 4 weeks ago
parent
commit
554ebbc315
  1. 65
      docs/companion_protocol.md
  2. 397
      examples/companion_radio/MyMesh.cpp
  3. 33
      examples/companion_radio/MyMesh.h

65
docs/companion_protocol.md

@ -1,7 +1,7 @@
# Companion Protocol # Companion Protocol
- **Last Updated**: 2026-03-08 - **Last Updated**: 2026-07-04
- **Protocol Version**: Companion Firmware v1.12.0+ - **Protocol Version**: Companion Firmware v1.16.0+
> NOTE: This document is still in development. Some information may be inaccurate. > NOTE: This document is still in development. Some information may be inaccurate.
@ -107,6 +107,12 @@ The default BLE MTU is 23 bytes (20 bytes payload). For larger commands like `SE
- iOS: `peripheral.maximumWriteValueLength(for:)` - iOS: `peripheral.maximumWriteValueLength(for:)`
- Python (bleak): MTU is negotiated automatically - Python (bleak): MTU is negotiated automatically
### Companion Frame Size Limits
Firmware transports use `MAX_FRAME_SIZE = 176` as the companion frame buffer size.
When an app advertises target protocol version 14 or newer in `CMD_DEVICE_QUERY`, firmware emits `PACKET_FRAME_FRAGMENT` for original firmware-to-app companion frames larger than `MAX_FRAME_SIZE`. Frames up to 176 bytes are sent unchanged. Each emitted fragment is also kept at or below `MAX_FRAME_SIZE`. Apps that do not advertise version 14 keep the legacy behavior based on the older single-frame transport limit.
### Command Sequencing ### Command Sequencing
**Critical**: Commands must be sent in the correct sequence: **Critical**: Commands must be sent in the correct sequence:
@ -187,14 +193,16 @@ Bytes 8+: Application name (UTF-8, optional)
**Command Format**: **Command Format**:
``` ```
Byte 0: 0x16 Byte 0: 0x16
Byte 1: 0x03 Byte 1: App target protocol version
``` ```
**Example** (hex): **Example** (hex):
``` ```
16 03 16 0E
``` ```
Set byte 1 to the highest companion protocol version the app understands. Firmware uses this value to decide whether it may emit newer optional frame formats. Apps that support `PACKET_FRAME_FRAGMENT` should send `0x0E` (14) or higher.
**Response**: `PACKET_DEVICE_INFO` (0x0D) with device information **Response**: `PACKET_DEVICE_INFO` (0x0D) with device information
--- ---
@ -395,19 +403,38 @@ def parse_channel_data_recv(data):
Byte 0: 0x0A Byte 0: 0x0A
``` ```
**Command Format With Fragment ACK** (app target version 14+ only):
```
Byte 0: 0x0A
Byte 1: Flags
Bytes 2-3: ACK Fragment ID (uint16 little-endian)
Byte 4: ACK Fragment Index
```
Flags:
- Bit 0 (`0x01`): Fragment ACK is present
**Example** (hex): **Example** (hex):
``` ```
0A 0A
``` ```
**Example With Fragment ACK** (hex, ACK fragment ID `0x1234`, index `1`):
```
0A 01 34 12 01
```
**Response**: **Response**:
- `PACKET_CHANNEL_MSG_RECV` (0x08) or `PACKET_CHANNEL_MSG_RECV_V3` (0x11) for channel messages - `PACKET_CHANNEL_MSG_RECV` (0x08) or `PACKET_CHANNEL_MSG_RECV_V3` (0x11) for channel messages
- `PACKET_CONTACT_MSG_RECV` (0x07) or `PACKET_CONTACT_MSG_RECV_V3` (0x10) for contact messages - `PACKET_CONTACT_MSG_RECV` (0x07) or `PACKET_CONTACT_MSG_RECV_V3` (0x10) for contact messages
- `PACKET_CHANNEL_DATA_RECV` (0x1B) for channel data datagrams - `PACKET_CHANNEL_DATA_RECV` (0x1B) for channel data datagrams
- `PACKET_FRAME_FRAGMENT` (0x91) for one fragment of an oversized queued frame when the app advertised target protocol version 14+
- `PACKET_NO_MORE_MSGS` (0x0A) if no messages available - `PACKET_NO_MORE_MSGS` (0x0A) if no messages available
**Note**: Poll this command periodically to retrieve queued messages. The device may also send `PACKET_MESSAGES_WAITING` (0x83) as a notification when messages are available. **Note**: Poll this command periodically to retrieve queued messages. The device may also send `PACKET_MESSAGES_WAITING` (0x83) as a notification when messages are available.
For fragmented queued frames, apps that advertised target protocol version 14+ must ACK the previous fragment in the next `CMD_SYNC_NEXT_MESSAGE` request before the firmware advances to the next fragment. If the ACK is missing or does not match the last sent fragment, the firmware repeats that fragment. After the app ACKs the last fragment, the firmware removes the original queued frame and returns the next queued frame or `PACKET_NO_MORE_MSGS`.
--- ---
### 8. Get Battery and Storage ### 8. Get Battery and Storage
@ -640,9 +667,35 @@ Byte values are authoritative; names are aliases. When reading firmware source,
| 0x82 | PACKET_ACK | Acknowledgment | | 0x82 | PACKET_ACK | Acknowledgment |
| 0x83 | PACKET_MESSAGES_WAITING | Messages waiting notification | | 0x83 | PACKET_MESSAGES_WAITING | Messages waiting notification |
| 0x88 | PACKET_LOG_DATA | RF log data (can be ignored) | | 0x88 | PACKET_LOG_DATA | RF log data (can be ignored) |
| 0x91 | PACKET_FRAME_FRAGMENT | Fragment of a larger frame |
### Parsing Responses ### Parsing Responses
**PACKET_FRAME_FRAGMENT** (0x91, app target version 14+):
Firmware emits this wrapper only when the app has advertised target protocol version 14 or newer in `CMD_DEVICE_QUERY`, and only when an original companion frame is larger than `MAX_FRAME_SIZE` (176 bytes). Frames up to 176 bytes are sent unchanged.
Queued messages are stored by the firmware as full logical companion frames. The firmware chooses the delivery format when the app polls `CMD_SYNC_NEXT_MESSAGE`: apps that advertised protocol version 14+ receive oversized queued frames as `PACKET_FRAME_FRAGMENT`; older apps receive the same legacy truncated single-frame form they received before fragmentation support. Queued fragments require ACKs via `CMD_SYNC_NEXT_MESSAGE`; the firmware repeats the last sent fragment until the app acknowledges it.
Oversized live push frames, such as raw packet data, control data, and RF log data, may also be emitted as `PACKET_FRAME_FRAGMENT` when the app advertised protocol version 14+. Live push frames up to `MAX_FRAME_SIZE` are sent unchanged for v14 apps. Live push fragments are not ACKed by `CMD_SYNC_NEXT_MESSAGE`.
Each fragment frame is also capped to `MAX_FRAME_SIZE` (176 bytes). With the 10-byte fragment header, the maximum chunk size is 166 bytes.
The fragment payload contains a chunk of the original companion frame, including that original frame's byte 0. Apps must reassemble all chunks with the same Fragment ID, then parse the reconstructed bytes as the original companion frame.
```
Byte 0: 0x91
Bytes 1-2: Fragment ID (uint16 little-endian)
Byte 3: Fragment Index (0-based)
Byte 4: Fragment Count
Byte 5: Original Frame Type (copy of reconstructed byte 0)
Bytes 6-7: Original Frame Length (uint16 little-endian)
Bytes 8-9: Chunk Offset in original frame (uint16 little-endian)
Bytes 10+: Chunk bytes
```
Fragment IDs are scoped to the current device connection/session and may wrap. Apps should discard incomplete fragment sets on disconnect, timeout, duplicate metadata mismatch, or if the accumulated chunks do not exactly match the advertised original frame length.
**PACKET_OK** (0x00): **PACKET_OK** (0x00):
``` ```
Byte 0: 0x00 Byte 0: 0x00
@ -826,7 +879,9 @@ BLE implementations enqueue and deliver one protocol frame per BLE write/notific
- Apps should treat each characteristic write/notification as exactly one companion protocol frame - Apps should treat each characteristic write/notification as exactly one companion protocol frame
- Apps should still validate frame lengths before parsing - Apps should still validate frame lengths before parsing
- Future transports or firmware revisions may differ, so avoid assuming fixed payload sizes for variable-length responses - For app target protocol version 14+, apps should accept unfragmented firmware-to-app frames up to `MAX_FRAME_SIZE` (176 bytes) and `PACKET_FRAME_FRAGMENT` frames up to the same size
- Future transports or firmware revisions may differ, so avoid assuming fixed payload sizes beyond the negotiated protocol behavior
- Apps that send target protocol version 14+ in `CMD_DEVICE_QUERY` must handle `PACKET_FRAME_FRAGMENT` and reassemble the original companion frame before normal parsing
### Response Handling ### Response Handling

397
examples/companion_radio/MyMesh.cpp

@ -63,6 +63,8 @@
#define CMD_GET_DEFAULT_FLOOD_SCOPE 64 #define CMD_GET_DEFAULT_FLOOD_SCOPE 64
#define CMD_SEND_RAW_PACKET 65 #define CMD_SEND_RAW_PACKET 65
#define SYNC_FLAG_FRAGMENT_ACK 0x01
// Stats sub-types for CMD_GET_STATS // Stats sub-types for CMD_GET_STATS
#define STATS_TYPE_CORE 0 #define STATS_TYPE_CORE 0
#define STATS_TYPE_RADIO 1 #define STATS_TYPE_RADIO 1
@ -216,41 +218,242 @@ bool MyMesh::Frame::isChannelMsg() const {
buf[0] == RESP_CODE_CHANNEL_DATA_RECV; buf[0] == RESP_CODE_CHANNEL_DATA_RECV;
} }
void MyMesh::addToOfflineQueue(const uint8_t frame[], int len) { bool MyMesh::canSendFrameFragments() const {
if (offline_queue_len >= OFFLINE_QUEUE_SIZE) { // Frame fragments are opt-in by app protocol version. Older apps keep the
// exact single-frame companion protocol they already understand.
return app_target_ver >= APP_TARGET_VER_FRAME_FRAGMENTS;
}
bool MyMesh::makeRoomInOfflineQueue(int slots_needed) {
// Preserve the old queue policy: channel traffic is discardable first, while
// contact messages are kept unless there is no room after channel eviction.
while (offline_queue_len + slots_needed > OFFLINE_QUEUE_SIZE) {
MESH_DEBUG_PRINTLN("WARN: offline_queue is full!"); MESH_DEBUG_PRINTLN("WARN: offline_queue is full!");
int pos = 0; // If the first queued item is being delivered as fragments, do not evict it
// mid-transfer. Try later channel messages first.
int pos = queued_fragment_id != 0 ? 1 : 0;
bool removed = false;
while (pos < offline_queue_len) { while (pos < offline_queue_len) {
if (offline_queue[pos].isChannelMsg()) { if (offline_queue[pos].isChannelMsg()) {
for (int i = pos; i < offline_queue_len - 1; i++) { // delete oldest channel msg from queue for (int i = pos; i < offline_queue_len - 1; i++) { // delete oldest channel msg from queue
offline_queue[i] = offline_queue[i + 1]; offline_queue[i] = offline_queue[i + 1];
} }
MESH_DEBUG_PRINTLN("INFO: removed oldest channel message from queue."); MESH_DEBUG_PRINTLN("INFO: removed oldest channel message from queue.");
offline_queue[offline_queue_len - 1].len = len; offline_queue_len--;
memcpy(offline_queue[offline_queue_len - 1].buf, frame, len); removed = true;
return; break;
} }
pos++; pos++;
} }
MESH_DEBUG_PRINTLN("INFO: no channel messages to remove from queue."); if (!removed) {
MESH_DEBUG_PRINTLN("INFO: no channel messages to remove from queue.");
return false;
}
}
return true;
}
void MyMesh::removeTopOfflineQueue() {
// Centralized removal is used by both legacy whole-frame delivery and the
// v14 fragmented queue path after the last fragment has been ACKed.
if (offline_queue_len == 0) return;
offline_queue_len--;
for (int i = 0; i < offline_queue_len; i++) { // delete top item from queue
offline_queue[i] = offline_queue[i + 1];
}
}
void MyMesh::resetQueuedFragmentState() {
// A queued fragmented frame is delivered one fragment per sync request. This
// state is reset when the frame is consumed, the app reconnects, or the app
// negotiates its protocol version again.
queued_fragment_id = 0;
queued_fragment_index = 0;
queued_fragment_ack_pending = false;
}
uint16_t MyMesh::allocFrameFragmentId() {
// queued_fragment_id uses 0 as "no queued fragment is active", so skip 0
// when the uint16_t fragment counter wraps.
if (next_fragment_id == 0) next_fragment_id = 1;
return next_fragment_id++;
}
static uint8_t getFrameFragmentCount(uint16_t len) {
// Each fragment carries a small wrapper plus a chunk of the original
// companion frame, so the chunk size is smaller than MAX_FRAME_SIZE.
return (len + FRAME_FRAGMENT_CHUNK_LEN - 1) / FRAME_FRAGMENT_CHUNK_LEN;
}
static uint8_t buildFrameFragment(uint8_t dest[], const uint8_t frame[], uint16_t len,
uint16_t fragment_id, uint8_t index) {
// PACKET_FRAME_FRAGMENT includes enough metadata for the app to reassemble
// the original companion frame before normal parsing.
uint16_t offset = index * FRAME_FRAGMENT_CHUNK_LEN;
uint16_t chunk_len = len - offset;
if (chunk_len > FRAME_FRAGMENT_CHUNK_LEN) chunk_len = FRAME_FRAGMENT_CHUNK_LEN;
uint8_t i = 0;
dest[i++] = PUSH_CODE_FRAME_FRAGMENT;
dest[i++] = (uint8_t)(fragment_id & 0xFF);
dest[i++] = (uint8_t)(fragment_id >> 8);
dest[i++] = index;
dest[i++] = getFrameFragmentCount(len);
dest[i++] = frame[0]; // original companion frame code, useful before reassembly completes
dest[i++] = (uint8_t)(len & 0xFF);
dest[i++] = (uint8_t)(len >> 8);
dest[i++] = (uint8_t)(offset & 0xFF);
dest[i++] = (uint8_t)(offset >> 8);
memcpy(&dest[i], &frame[offset], chunk_len);
i += chunk_len;
return i;
}
void MyMesh::addToOfflineQueue(const uint8_t frame[], uint16_t len) {
// Store the full logical companion frame. The delivery format is chosen when
// the app later polls the queue, after we know whether it supports v14.
if (len > MAX_COMPANION_LONG_FRAME_SIZE) {
MESH_DEBUG_PRINTLN("addToOfflineQueue: frame too big, len=%u", (unsigned)len);
return;
}
if (!makeRoomInOfflineQueue(1)) return;
offline_queue[offline_queue_len].len = len;
memcpy(offline_queue[offline_queue_len].buf, frame, len);
offline_queue_len++;
}
void MyMesh::writeFrameMaybeFragmented(const uint8_t frame[], uint16_t len) {
// Direct writes remain the default. Fragmentation is only used for oversized
// live frames after the app has advertised support for the v14 wrapper.
if (!_serial || !_serial->isConnected()) return;
if (len <= MAX_FRAME_SIZE) {
// Keep the original wire format for frames that already fit.
_serial->writeFrame(frame, len);
} else if (canSendFrameFragments()) {
MESH_DEBUG_PRINTLN("writeFrameMaybeFragmented: fragmenting frame type=0x%02X len=%u limit=%u fragments=%u",
frame[0], (unsigned)len, (unsigned)MAX_FRAME_SIZE,
(unsigned)getFrameFragmentCount(len));
writeFrameFragments(frame, len);
} else { } else {
offline_queue[offline_queue_len].len = len; MESH_DEBUG_PRINTLN("writeFrameMaybeFragmented: oversized frame without app support, len=%u", (unsigned)len);
memcpy(offline_queue[offline_queue_len].buf, frame, len);
offline_queue_len++;
} }
} }
int MyMesh::getFromOfflineQueue(uint8_t frame[]) { void MyMesh::writeFrameFragments(const uint8_t frame[], uint16_t len) {
if (offline_queue_len > 0) { // check offline queue // Used for live push frames only. Queued frames are sent by
size_t len = offline_queue[0].len; // take from top of queue // getFromOfflineQueue(), one fragment per CMD_SYNC_NEXT_MESSAGE ACK cycle.
memcpy(frame, offline_queue[0].buf, len); uint8_t fragment_count = getFrameFragmentCount(len);
if (fragment_count == 0) {
MESH_DEBUG_PRINTLN("writeFrameFragments: invalid fragment_count=%u", (unsigned)fragment_count);
return;
}
offline_queue_len--; uint16_t fragment_id = allocFrameFragmentId();
for (int i = 0; i < offline_queue_len; i++) { // delete top item from queue for (uint8_t index = 0; index < fragment_count; index++) {
offline_queue[i] = offline_queue[i + 1]; uint8_t frag[MAX_FRAME_SIZE];
uint8_t i = buildFrameFragment(frag, frame, len, fragment_id, index);
_serial->writeFrame(frag, i);
}
}
void MyMesh::writePacketPayloadFrameMaybeFragmented(mesh::Packet *packet, uint8_t push_code, uint8_t path_byte,
const char *debug_name) {
// Raw/control payload pushes are not stored in the offline queue. For old
// apps they keep the old MAX_FRAME_SIZE limit; v14 apps may receive them as
// best-effort live fragments.
uint16_t frame_cap = canSendFrameFragments() ? MAX_COMPANION_LONG_FRAME_SIZE : MAX_FRAME_SIZE;
if (packet->payload_len + 4 > frame_cap) {
MESH_DEBUG_PRINTLN("%s(), payload_len too long: %d", debug_name, packet->payload_len);
return;
}
if (!_serial || !_serial->isConnected()) {
MESH_DEBUG_PRINTLN("%s(), data received while app offline", debug_name);
return;
}
uint8_t frame[MAX_COMPANION_LONG_FRAME_SIZE];
int i = 0;
frame[i++] = push_code;
frame[i++] = (int8_t)(_radio->getLastSNR() * 4);
frame[i++] = (int8_t)(_radio->getLastRSSI());
frame[i++] = path_byte;
memcpy(&frame[i], packet->payload, packet->payload_len);
i += packet->payload_len;
writeFrameMaybeFragmented(frame, i);
}
int MyMesh::getFromOfflineQueue(uint8_t frame[], bool has_fragment_ack, uint16_t ack_fragment_id,
uint8_t ack_fragment_index) {
// Queued frames are delivered synchronously: the app asks for the next item,
// then ACKs each oversized fragment before the firmware advances.
while (offline_queue_len > 0) {
Frame& queued = offline_queue[0];
uint16_t len = queued.len; // take from top of queue
uint16_t out_len = len;
if (len <= MAX_FRAME_SIZE) {
// Small queued frames are still returned exactly as before.
resetQueuedFragmentState();
memcpy(frame, queued.buf, len);
removeTopOfflineQueue();
return out_len;
}
if (!canSendFrameFragments()) {
// Legacy clients cannot parse PACKET_FRAME_FRAGMENT. Keep the previous
// behavior for them by returning a truncated single companion frame.
resetQueuedFragmentState();
out_len = MAX_FRAME_SIZE;
memcpy(frame, queued.buf, out_len);
removeTopOfflineQueue();
return out_len;
}
uint8_t fragment_count = getFrameFragmentCount(len);
if (fragment_count == 0) {
MESH_DEBUG_PRINTLN("getFromOfflineQueue: invalid fragment_count=0");
resetQueuedFragmentState();
removeTopOfflineQueue();
continue;
}
if (queued_fragment_id == 0) {
queued_fragment_id = allocFrameFragmentId();
queued_fragment_index = 0;
queued_fragment_ack_pending = false;
MESH_DEBUG_PRINTLN("getFromOfflineQueue: fragmenting queued frame type=0x%02X len=%u limit=%u fragments=%u id=%u",
queued.buf[0], (unsigned)len, (unsigned)MAX_FRAME_SIZE,
(unsigned)fragment_count, (unsigned)queued_fragment_id);
}
if (queued_fragment_ack_pending) {
// Without a matching ACK, repeat the last fragment. This keeps fragment
// delivery reliable without changing the old one-byte sync command.
bool ack_matches = has_fragment_ack && ack_fragment_id == queued_fragment_id &&
ack_fragment_index == queued_fragment_index;
if (ack_matches) {
queued_fragment_ack_pending = false;
if (queued_fragment_index + 1 >= fragment_count) {
resetQueuedFragmentState();
removeTopOfflineQueue();
has_fragment_ack = false; // ACK consumed; do not apply it to the next queued frame.
continue;
}
queued_fragment_index++;
} else {
MESH_DEBUG_PRINTLN("getFromOfflineQueue: fragment ACK missing or mismatched, repeating fragment");
}
} }
return len;
out_len = buildFrameFragment(frame, queued.buf, len, queued_fragment_id, queued_fragment_index);
queued_fragment_ack_pending = true;
return out_len;
} }
return 0; // queue is empty return 0; // queue is empty
} }
@ -284,15 +487,19 @@ uint8_t MyMesh::getExtraAckTransmitCount() const {
} }
void MyMesh::logRxRaw(float snr, float rssi, const uint8_t raw[], int len) { void MyMesh::logRxRaw(float snr, float rssi, const uint8_t raw[], int len) {
if (_serial->isConnected() && len + 3 <= MAX_FRAME_SIZE) { // RF logs are live push frames. Keep the legacy size limit until the app has
// opted into frame fragmentation.
uint16_t frame_cap = canSendFrameFragments() ? MAX_COMPANION_LONG_FRAME_SIZE : MAX_FRAME_SIZE;
if (_serial->isConnected() && len + 3 <= frame_cap) {
uint8_t frame[MAX_COMPANION_LONG_FRAME_SIZE];
int i = 0; int i = 0;
out_frame[i++] = PUSH_CODE_LOG_RX_DATA; frame[i++] = PUSH_CODE_LOG_RX_DATA;
out_frame[i++] = (int8_t)(snr * 4); frame[i++] = (int8_t)(snr * 4);
out_frame[i++] = (int8_t)(rssi); frame[i++] = (int8_t)(rssi);
memcpy(&out_frame[i], raw, len); memcpy(&frame[i], raw, len);
i += len; i += len;
_serial->writeFrame(out_frame, i); writeFrameMaybeFragmented(frame, i);
} }
} }
@ -431,37 +638,42 @@ ContactInfo* MyMesh::processAck(const uint8_t *data) {
void MyMesh::queueMessage(const ContactInfo &from, uint8_t txt_type, mesh::Packet *pkt, void MyMesh::queueMessage(const ContactInfo &from, uint8_t txt_type, mesh::Packet *pkt,
uint32_t sender_timestamp, const uint8_t *extra, int extra_len, const char *text) { uint32_t sender_timestamp, const uint8_t *extra, int extra_len, const char *text) {
// Build and queue the complete companion message frame. It may exceed
// MAX_FRAME_SIZE; getFromOfflineQueue() will either fragment it for v14 apps
// or preserve the legacy truncated delivery for older apps.
uint8_t frame[MAX_COMPANION_LONG_FRAME_SIZE];
int frame_cap = MAX_COMPANION_LONG_FRAME_SIZE;
int i = 0; int i = 0;
if (app_target_ver >= 3) { if (app_target_ver >= 3) {
out_frame[i++] = RESP_CODE_CONTACT_MSG_RECV_V3; frame[i++] = RESP_CODE_CONTACT_MSG_RECV_V3;
out_frame[i++] = (int8_t)(pkt->getSNR() * 4); frame[i++] = (int8_t)(pkt->getSNR() * 4);
out_frame[i++] = 0; // reserved1 frame[i++] = 0; // reserved1
out_frame[i++] = 0; // reserved2 frame[i++] = 0; // reserved2
} else { } else {
out_frame[i++] = RESP_CODE_CONTACT_MSG_RECV; frame[i++] = RESP_CODE_CONTACT_MSG_RECV;
} }
memcpy(&out_frame[i], from.id.pub_key, 6); memcpy(&frame[i], from.id.pub_key, 6);
i += 6; // just 6-byte prefix i += 6; // just 6-byte prefix
uint8_t path_len = out_frame[i++] = pkt->isRouteFlood() ? pkt->path_len : 0xFF; uint8_t path_len = frame[i++] = pkt->isRouteFlood() ? pkt->path_len : 0xFF;
out_frame[i++] = txt_type; frame[i++] = txt_type;
memcpy(&out_frame[i], &sender_timestamp, 4); memcpy(&frame[i], &sender_timestamp, 4);
i += 4; i += 4;
if (extra_len > 0) { if (extra_len > 0) {
memcpy(&out_frame[i], extra, extra_len); memcpy(&frame[i], extra, extra_len);
i += extra_len; i += extra_len;
} }
int tlen = strlen(text); // TODO: UTF-8 ?? int tlen = strlen(text); // TODO: UTF-8 ??
if (i + tlen > MAX_FRAME_SIZE) { if (i + tlen > frame_cap) {
tlen = MAX_FRAME_SIZE - i; tlen = frame_cap - i;
} }
memcpy(&out_frame[i], text, tlen); memcpy(&frame[i], text, tlen);
i += tlen; i += tlen;
addToOfflineQueue(out_frame, i); addToOfflineQueue(frame, i);
if (_serial->isConnected()) { if (_serial->isConnected()) {
uint8_t frame[1]; uint8_t tickle_frame[1];
frame[0] = PUSH_CODE_MSG_WAITING; // send push 'tickle' tickle_frame[0] = PUSH_CODE_MSG_WAITING; // send push 'tickle'
_serial->writeFrame(frame, 1); _serial->writeFrame(tickle_frame, 1);
} }
#ifdef DISPLAY_CLASS #ifdef DISPLAY_CLASS
@ -544,35 +756,39 @@ void MyMesh::onSignedMessageRecv(const ContactInfo &from, mesh::Packet *pkt, uin
void MyMesh::onChannelMessageRecv(const mesh::GroupChannel &channel, mesh::Packet *pkt, uint32_t timestamp, void MyMesh::onChannelMessageRecv(const mesh::GroupChannel &channel, mesh::Packet *pkt, uint32_t timestamp,
const char *text) { const char *text) {
// Channel messages follow the same queued-frame path as contact messages so
// the app capability decision can be made later, at sync time.
uint8_t frame[MAX_COMPANION_LONG_FRAME_SIZE];
int frame_cap = MAX_COMPANION_LONG_FRAME_SIZE;
int i = 0; int i = 0;
if (app_target_ver >= 3) { if (app_target_ver >= 3) {
out_frame[i++] = RESP_CODE_CHANNEL_MSG_RECV_V3; frame[i++] = RESP_CODE_CHANNEL_MSG_RECV_V3;
out_frame[i++] = (int8_t)(pkt->getSNR() * 4); frame[i++] = (int8_t)(pkt->getSNR() * 4);
out_frame[i++] = 0; // reserved1 frame[i++] = 0; // reserved1
out_frame[i++] = 0; // reserved2 frame[i++] = 0; // reserved2
} else { } else {
out_frame[i++] = RESP_CODE_CHANNEL_MSG_RECV; frame[i++] = RESP_CODE_CHANNEL_MSG_RECV;
} }
uint8_t channel_idx = findChannelIdx(channel); uint8_t channel_idx = findChannelIdx(channel);
out_frame[i++] = channel_idx; frame[i++] = channel_idx;
uint8_t path_len = out_frame[i++] = pkt->isRouteFlood() ? pkt->path_len : 0xFF; uint8_t path_len = frame[i++] = pkt->isRouteFlood() ? pkt->path_len : 0xFF;
out_frame[i++] = TXT_TYPE_PLAIN; frame[i++] = TXT_TYPE_PLAIN;
memcpy(&out_frame[i], &timestamp, 4); memcpy(&frame[i], &timestamp, 4);
i += 4; i += 4;
int tlen = strlen(text); // TODO: UTF-8 ?? int tlen = strlen(text); // TODO: UTF-8 ??
if (i + tlen > MAX_FRAME_SIZE) { if (i + tlen > frame_cap) {
tlen = MAX_FRAME_SIZE - i; tlen = frame_cap - i;
} }
memcpy(&out_frame[i], text, tlen); memcpy(&frame[i], text, tlen);
i += tlen; i += tlen;
addToOfflineQueue(out_frame, i); addToOfflineQueue(frame, i);
if (_serial->isConnected()) { if (_serial->isConnected()) {
uint8_t frame[1]; uint8_t tickle_frame[1];
frame[0] = PUSH_CODE_MSG_WAITING; // send push 'tickle' tickle_frame[0] = PUSH_CODE_MSG_WAITING; // send push 'tickle'
_serial->writeFrame(frame, 1); _serial->writeFrame(tickle_frame, 1);
} else { } else {
#ifdef DISPLAY_CLASS #ifdef DISPLAY_CLASS
if (_ui) _ui->notify(UIEventType::channelMessage); if (_ui) _ui->notify(UIEventType::channelMessage);
@ -774,43 +990,15 @@ bool MyMesh::onContactPathRecv(ContactInfo& contact, uint8_t* in_path, uint8_t i
} }
void MyMesh::onControlDataRecv(mesh::Packet *packet) { void MyMesh::onControlDataRecv(mesh::Packet *packet) {
if (packet->payload_len + 4 > sizeof(out_frame)) { // Control-data pushes share the same live-frame fragmentation helper as raw
MESH_DEBUG_PRINTLN("onControlDataRecv(), payload_len too long: %d", packet->payload_len); // packet pushes; they are not queued or ACKed through CMD_SYNC_NEXT_MESSAGE.
return; writePacketPayloadFrameMaybeFragmented(packet, PUSH_CODE_CONTROL_DATA, packet->path_len, "onControlDataRecv");
}
int i = 0;
out_frame[i++] = PUSH_CODE_CONTROL_DATA;
out_frame[i++] = (int8_t)(_radio->getLastSNR() * 4);
out_frame[i++] = (int8_t)(_radio->getLastRSSI());
out_frame[i++] = packet->path_len;
memcpy(&out_frame[i], packet->payload, packet->payload_len);
i += packet->payload_len;
if (_serial->isConnected()) {
_serial->writeFrame(out_frame, i);
} else {
MESH_DEBUG_PRINTLN("onControlDataRecv(), data received while app offline");
}
} }
void MyMesh::onRawDataRecv(mesh::Packet *packet) { void MyMesh::onRawDataRecv(mesh::Packet *packet) {
if (packet->payload_len + 4 > sizeof(out_frame)) { // Raw-data pushes are best-effort live notifications. The helper preserves
MESH_DEBUG_PRINTLN("onRawDataRecv(), payload_len too long: %d", packet->payload_len); // the old MAX_FRAME_SIZE behavior unless v14 fragmentation was negotiated.
return; writePacketPayloadFrameMaybeFragmented(packet, PUSH_CODE_RAW_DATA, 0xFF, "onRawDataRecv");
}
int i = 0;
out_frame[i++] = PUSH_CODE_RAW_DATA;
out_frame[i++] = (int8_t)(_radio->getLastSNR() * 4);
out_frame[i++] = (int8_t)(_radio->getLastRSSI());
out_frame[i++] = 0xFF; // reserved (possibly path_len in future)
memcpy(&out_frame[i], packet->payload, packet->payload_len);
i += packet->payload_len;
if (_serial->isConnected()) {
_serial->writeFrame(out_frame, i);
} else {
MESH_DEBUG_PRINTLN("onRawDataRecv(), data received while app offline");
}
} }
void MyMesh::onTraceRecv(mesh::Packet *packet, uint32_t tag, uint32_t auth_code, uint8_t flags, void MyMesh::onTraceRecv(mesh::Packet *packet, uint32_t tag, uint32_t auth_code, uint8_t flags,
@ -862,6 +1050,8 @@ MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMe
_cli_rescue = false; _cli_rescue = false;
offline_queue_len = 0; offline_queue_len = 0;
app_target_ver = 0; app_target_ver = 0;
next_fragment_id = 1;
resetQueuedFragmentState();
clearPendingReqs(); clearPendingReqs();
next_ack_idx = 0; next_ack_idx = 0;
sign_data = NULL; sign_data = NULL;
@ -1005,12 +1195,17 @@ bool MyMesh::isValidClientRepeatFreq(uint32_t f) const {
void MyMesh::startInterface(BaseSerialInterface &serial) { void MyMesh::startInterface(BaseSerialInterface &serial) {
_serial = &serial; _serial = &serial;
// Fragment IDs and pending ACK state are scoped to a companion connection.
resetQueuedFragmentState();
serial.enable(); serial.enable();
} }
void MyMesh::handleCmdFrame(size_t len) { void MyMesh::handleCmdFrame(size_t len) {
if (cmd_frame[0] == CMD_DEVICE_QUERY && len >= 2) { // sent when app establishes connection if (cmd_frame[0] == CMD_DEVICE_QUERY && len >= 2) { // sent when app establishes connection
app_target_ver = cmd_frame[1]; // which version of protocol does app understand app_target_ver = cmd_frame[1]; // which version of protocol does app understand
// Re-negotiate the app capability boundary before sending any queued
// fragmented frames under the new target version.
resetQueuedFragmentState();
int i = 0; int i = 0;
out_frame[i++] = RESP_CODE_DEVICE_INFO; out_frame[i++] = RESP_CODE_DEVICE_INFO;
@ -1356,14 +1551,30 @@ void MyMesh::handleCmdFrame(size_t len) {
} }
} else if (cmd_frame[0] == CMD_SYNC_NEXT_MESSAGE) { } else if (cmd_frame[0] == CMD_SYNC_NEXT_MESSAGE) {
int out_len; int out_len;
if ((out_len = getFromOfflineQueue(out_frame)) > 0) { int prev_queue_len = offline_queue_len;
bool has_fragment_ack = false;
uint16_t ack_fragment_id = 0;
uint8_t ack_fragment_index = 0;
// v14 extends CMD_SYNC_NEXT_MESSAGE with optional ACK fields. The old
// one-byte command remains valid and simply does not advance a pending
// fragmented frame.
if (canSendFrameFragments() && len >= 5 && (cmd_frame[1] & SYNC_FLAG_FRAGMENT_ACK) != 0) {
ack_fragment_id = ((uint16_t)cmd_frame[2]) | (((uint16_t)cmd_frame[3]) << 8);
ack_fragment_index = cmd_frame[4];
has_fragment_ack = true;
}
if ((out_len = getFromOfflineQueue(out_frame, has_fragment_ack, ack_fragment_id, ack_fragment_index)) > 0) {
_serial->writeFrame(out_frame, out_len); _serial->writeFrame(out_frame, out_len);
#ifdef DISPLAY_CLASS #ifdef DISPLAY_CLASS
if (_ui) _ui->msgRead(offline_queue_len); if (_ui && offline_queue_len != prev_queue_len) _ui->msgRead(offline_queue_len);
#endif #endif
} else { } else {
out_frame[0] = RESP_CODE_NO_MORE_MESSAGES; out_frame[0] = RESP_CODE_NO_MORE_MESSAGES;
_serial->writeFrame(out_frame, 1); _serial->writeFrame(out_frame, 1);
#ifdef DISPLAY_CLASS
if (_ui && offline_queue_len != prev_queue_len) _ui->msgRead(offline_queue_len);
#endif
} }
} else if (cmd_frame[0] == CMD_SET_RADIO_PARAMS) { } else if (cmd_frame[0] == CMD_SET_RADIO_PARAMS) {
int i = 1; int i = 1;

33
examples/companion_radio/MyMesh.h

@ -5,7 +5,7 @@
#include "AbstractUITask.h" #include "AbstractUITask.h"
/*------------ Frame Protocol --------------*/ /*------------ Frame Protocol --------------*/
#define FIRMWARE_VER_CODE 13 #define FIRMWARE_VER_CODE 14
#ifndef FIRMWARE_BUILD_DATE #ifndef FIRMWARE_BUILD_DATE
#define FIRMWARE_BUILD_DATE "6 Jun 2026" #define FIRMWARE_BUILD_DATE "6 Jun 2026"
@ -34,6 +34,15 @@
#include <helpers/StaticPoolPacketManager.h> #include <helpers/StaticPoolPacketManager.h>
#include <target.h> #include <target.h>
// Fragment frames wrap an existing companion frame when it does not fit in the
// firmware companion frame buffer. Apps reassemble the original frame before
// handing it to the normal companion protocol parser.
#define APP_TARGET_VER_FRAME_FRAGMENTS 14
#define PUSH_CODE_FRAME_FRAGMENT 0x91
#define FRAME_FRAGMENT_HEADER_LEN 10
#define FRAME_FRAGMENT_CHUNK_LEN (MAX_FRAME_SIZE - FRAME_FRAGMENT_HEADER_LEN)
#define MAX_COMPANION_LONG_FRAME_SIZE (MAX_TRANS_UNIT + 4)
/* ---------------------------------- CONFIGURATION ------------------------------------- */ /* ---------------------------------- CONFIGURATION ------------------------------------- */
#ifndef LORA_FREQ #ifndef LORA_FREQ
@ -187,8 +196,18 @@ private:
void writeDisabledFrame(); void writeDisabledFrame();
void writeContactRespFrame(uint8_t code, const ContactInfo &contact); void writeContactRespFrame(uint8_t code, const ContactInfo &contact);
void updateContactFromFrame(ContactInfo &contact, uint32_t& last_mod, const uint8_t *frame, int len); void updateContactFromFrame(ContactInfo &contact, uint32_t& last_mod, const uint8_t *frame, int len);
void addToOfflineQueue(const uint8_t frame[], int len); void addToOfflineQueue(const uint8_t frame[], uint16_t len);
int getFromOfflineQueue(uint8_t frame[]); int getFromOfflineQueue(uint8_t frame[], bool has_fragment_ack=false, uint16_t ack_fragment_id=0,
uint8_t ack_fragment_index=0);
bool canSendFrameFragments() const;
bool makeRoomInOfflineQueue(int slots_needed);
void removeTopOfflineQueue();
void resetQueuedFragmentState();
uint16_t allocFrameFragmentId();
void writeFrameMaybeFragmented(const uint8_t frame[], uint16_t len);
void writeFrameFragments(const uint8_t frame[], uint16_t len);
void writePacketPayloadFrameMaybeFragmented(mesh::Packet *packet, uint8_t push_code, uint8_t path_byte,
const char *debug_name);
int getBlobByKey(const uint8_t key[], int key_len, uint8_t dest_buf[]) override { int getBlobByKey(const uint8_t key[], int key_len, uint8_t dest_buf[]) override {
return _store->getBlobByKey(key, key_len, dest_buf); return _store->getBlobByKey(key, key_len, dest_buf);
} }
@ -222,6 +241,10 @@ private:
bool send_unscoped; // force un-scoped flood (instead of using send_scope) bool send_unscoped; // force un-scoped flood (instead of using send_scope)
char cli_command[80]; char cli_command[80];
uint8_t app_target_ver; uint8_t app_target_ver;
uint16_t next_fragment_id;
uint16_t queued_fragment_id;
uint8_t queued_fragment_index;
bool queued_fragment_ack_pending;
uint8_t *sign_data; uint8_t *sign_data;
uint32_t sign_data_len; uint32_t sign_data_len;
unsigned long dirty_contacts_expiry; unsigned long dirty_contacts_expiry;
@ -233,8 +256,8 @@ private:
CayenneLPP telemetry; CayenneLPP telemetry;
struct Frame { struct Frame {
uint8_t len; uint16_t len;
uint8_t buf[MAX_FRAME_SIZE]; uint8_t buf[MAX_COMPANION_LONG_FRAME_SIZE];
bool isChannelMsg() const; bool isChannelMsg() const;
}; };

Loading…
Cancel
Save