From 736adf230808c34d75133b3c2e9eda261cb9c54a Mon Sep 17 00:00:00 2001 From: HDDen <62592944+HDDen@users.noreply.github.com> Date: Fri, 3 Jul 2026 12:07:55 +0300 Subject: [PATCH 1/2] init --- examples/companion_radio/MyMesh.cpp | 368 ++++++++++++++++++++++------ examples/companion_radio/MyMesh.h | 32 ++- 2 files changed, 320 insertions(+), 80 deletions(-) diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 5fb9bf9d37..cba9947e37 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -63,6 +63,8 @@ #define CMD_GET_DEFAULT_FLOOD_SCOPE 64 #define CMD_SEND_RAW_PACKET 65 +#define SYNC_FLAG_FRAGMENT_ACK 0x01 + // Stats sub-types for CMD_GET_STATS #define STATS_TYPE_CORE 0 #define STATS_TYPE_RADIO 1 @@ -216,41 +218,189 @@ bool MyMesh::Frame::isChannelMsg() const { buf[0] == RESP_CODE_CHANNEL_DATA_RECV; } -void MyMesh::addToOfflineQueue(const uint8_t frame[], int len) { - if (offline_queue_len >= OFFLINE_QUEUE_SIZE) { +bool MyMesh::canSendFrameFragments() const { + 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!"); - int pos = 0; + int pos = queued_fragment_id != 0 ? 1 : 0; + bool removed = false; while (pos < offline_queue_len) { if (offline_queue[pos].isChannelMsg()) { for (int i = pos; i < offline_queue_len - 1; i++) { // delete oldest channel msg from queue offline_queue[i] = offline_queue[i + 1]; } MESH_DEBUG_PRINTLN("INFO: removed oldest channel message from queue."); - offline_queue[offline_queue_len - 1].len = len; - memcpy(offline_queue[offline_queue_len - 1].buf, frame, len); - return; + offline_queue_len--; + removed = true; + break; } 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() { + 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() { + queued_fragment_id = 0; + queued_fragment_index = 0; + queued_fragment_ack_pending = false; +} + +static uint8_t getFrameFragmentCount(uint16_t len) { + return (len + FRAME_FRAGMENT_CHUNK_LEN - 1) / FRAME_FRAGMENT_CHUNK_LEN; +} + +uint16_t MyMesh::getCompanionDirectFrameLimit() const { + return canSendFrameFragments() ? APP_SAFE_FRAME_SIZE : MAX_FRAME_SIZE; +} + +static uint8_t buildFrameFragment(uint8_t dest[], const uint8_t frame[], uint16_t len, + uint16_t fragment_id, uint8_t index) { + 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) { + 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) { + if (!_serial || !_serial->isConnected()) return; + uint16_t direct_limit = getCompanionDirectFrameLimit(); + if (len <= direct_limit) { + _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)direct_limit, + (unsigned)getFrameFragmentCount(len)); + writeFrameFragments(frame, len); } else { - offline_queue[offline_queue_len].len = len; - memcpy(offline_queue[offline_queue_len].buf, frame, len); - offline_queue_len++; + MESH_DEBUG_PRINTLN("writeFrameMaybeFragmented: oversized frame without app support, len=%u", (unsigned)len); } } -int MyMesh::getFromOfflineQueue(uint8_t frame[]) { - if (offline_queue_len > 0) { // check offline queue - size_t len = offline_queue[0].len; // take from top of queue - memcpy(frame, offline_queue[0].buf, len); +void MyMesh::writeFrameFragments(const uint8_t frame[], uint16_t len) { + uint8_t fragment_count = getFrameFragmentCount(len); + if (fragment_count == 0) { + MESH_DEBUG_PRINTLN("writeFrameFragments: invalid fragment_count=%u", (unsigned)fragment_count); + return; + } + + uint16_t fragment_id = next_fragment_id++; + for (uint8_t index = 0; index < fragment_count; index++) { + uint8_t frag[MAX_FRAME_SIZE]; + uint8_t i = buildFrameFragment(frag, frame, len, fragment_id, index); + + _serial->writeFrame(frag, i); + } +} - offline_queue_len--; - for (int i = 0; i < offline_queue_len; i++) { // delete top item from queue - offline_queue[i] = offline_queue[i + 1]; +int MyMesh::getFromOfflineQueue(uint8_t frame[], bool has_fragment_ack, uint16_t ack_fragment_id, + uint8_t ack_fragment_index) { + 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; + uint16_t direct_limit = getCompanionDirectFrameLimit(); + + if (len <= direct_limit) { + 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 = next_fragment_id++; + 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)direct_limit, + (unsigned)fragment_count, (unsigned)queued_fragment_id); + } + + if (queued_fragment_ack_pending) { + 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 } @@ -284,15 +434,16 @@ uint8_t MyMesh::getExtraAckTransmitCount() const { } void MyMesh::logRxRaw(float snr, float rssi, const uint8_t raw[], int len) { - if (_serial->isConnected() && len + 3 <= MAX_FRAME_SIZE) { + if (_serial->isConnected() && len + 3 <= MAX_COMPANION_LONG_FRAME_SIZE) { + uint8_t frame[MAX_COMPANION_LONG_FRAME_SIZE]; int i = 0; - out_frame[i++] = PUSH_CODE_LOG_RX_DATA; - out_frame[i++] = (int8_t)(snr * 4); - out_frame[i++] = (int8_t)(rssi); - memcpy(&out_frame[i], raw, len); + frame[i++] = PUSH_CODE_LOG_RX_DATA; + frame[i++] = (int8_t)(snr * 4); + frame[i++] = (int8_t)(rssi); + memcpy(&frame[i], raw, len); i += len; - _serial->writeFrame(out_frame, i); + writeFrameMaybeFragmented(frame, i); } } @@ -431,32 +582,34 @@ ContactInfo* MyMesh::processAck(const uint8_t *data) { 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) { + uint8_t frame[MAX_COMPANION_LONG_FRAME_SIZE]; + int frame_cap = MAX_COMPANION_LONG_FRAME_SIZE; int i = 0; if (app_target_ver >= 3) { - out_frame[i++] = RESP_CODE_CONTACT_MSG_RECV_V3; - out_frame[i++] = (int8_t)(pkt->getSNR() * 4); - out_frame[i++] = 0; // reserved1 - out_frame[i++] = 0; // reserved2 + frame[i++] = RESP_CODE_CONTACT_MSG_RECV_V3; + frame[i++] = (int8_t)(pkt->getSNR() * 4); + frame[i++] = 0; // reserved1 + frame[i++] = 0; // reserved2 } 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 - uint8_t path_len = out_frame[i++] = pkt->isRouteFlood() ? pkt->path_len : 0xFF; - out_frame[i++] = txt_type; - memcpy(&out_frame[i], &sender_timestamp, 4); + uint8_t path_len = frame[i++] = pkt->isRouteFlood() ? pkt->path_len : 0xFF; + frame[i++] = txt_type; + memcpy(&frame[i], &sender_timestamp, 4); i += 4; if (extra_len > 0) { - memcpy(&out_frame[i], extra, extra_len); + memcpy(&frame[i], extra, extra_len); i += extra_len; } int tlen = strlen(text); // TODO: UTF-8 ?? - if (i + tlen > MAX_FRAME_SIZE) { - tlen = MAX_FRAME_SIZE - i; + if (i + tlen > frame_cap) { + tlen = frame_cap - i; } - memcpy(&out_frame[i], text, tlen); + memcpy(&frame[i], text, tlen); i += tlen; - addToOfflineQueue(out_frame, i); + addToOfflineQueue(frame, i); if (_serial->isConnected()) { uint8_t frame[1]; @@ -544,30 +697,32 @@ void MyMesh::onSignedMessageRecv(const ContactInfo &from, mesh::Packet *pkt, uin void MyMesh::onChannelMessageRecv(const mesh::GroupChannel &channel, mesh::Packet *pkt, uint32_t timestamp, const char *text) { + uint8_t frame[MAX_COMPANION_LONG_FRAME_SIZE]; + int frame_cap = MAX_COMPANION_LONG_FRAME_SIZE; int i = 0; if (app_target_ver >= 3) { - out_frame[i++] = RESP_CODE_CHANNEL_MSG_RECV_V3; - out_frame[i++] = (int8_t)(pkt->getSNR() * 4); - out_frame[i++] = 0; // reserved1 - out_frame[i++] = 0; // reserved2 + frame[i++] = RESP_CODE_CHANNEL_MSG_RECV_V3; + frame[i++] = (int8_t)(pkt->getSNR() * 4); + frame[i++] = 0; // reserved1 + frame[i++] = 0; // reserved2 } else { - out_frame[i++] = RESP_CODE_CHANNEL_MSG_RECV; + frame[i++] = RESP_CODE_CHANNEL_MSG_RECV; } uint8_t channel_idx = findChannelIdx(channel); - out_frame[i++] = channel_idx; - uint8_t path_len = out_frame[i++] = pkt->isRouteFlood() ? pkt->path_len : 0xFF; + frame[i++] = channel_idx; + uint8_t path_len = frame[i++] = pkt->isRouteFlood() ? pkt->path_len : 0xFF; - out_frame[i++] = TXT_TYPE_PLAIN; - memcpy(&out_frame[i], ×tamp, 4); + frame[i++] = TXT_TYPE_PLAIN; + memcpy(&frame[i], ×tamp, 4); i += 4; int tlen = strlen(text); // TODO: UTF-8 ?? - if (i + tlen > MAX_FRAME_SIZE) { - tlen = MAX_FRAME_SIZE - i; + if (i + tlen > frame_cap) { + tlen = frame_cap - i; } - memcpy(&out_frame[i], text, tlen); + memcpy(&frame[i], text, tlen); i += tlen; - addToOfflineQueue(out_frame, i); + addToOfflineQueue(frame, i); if (_serial->isConnected()) { uint8_t frame[1]; @@ -774,43 +929,89 @@ bool MyMesh::onContactPathRecv(ContactInfo& contact, uint8_t* in_path, uint8_t i } void MyMesh::onControlDataRecv(mesh::Packet *packet) { - if (packet->payload_len + 4 > sizeof(out_frame)) { + if (!canSendFrameFragments()) { + if (packet->payload_len + 4 > sizeof(out_frame)) { + MESH_DEBUG_PRINTLN("onControlDataRecv(), payload_len too long: %d", packet->payload_len); + return; + } + 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"); + } + return; + } + + if (packet->payload_len + 4 > MAX_COMPANION_LONG_FRAME_SIZE) { MESH_DEBUG_PRINTLN("onControlDataRecv(), payload_len too long: %d", packet->payload_len); return; } + if (!_serial || !_serial->isConnected()) { + MESH_DEBUG_PRINTLN("onControlDataRecv(), data received while app offline"); + return; + } + + uint8_t frame[MAX_COMPANION_LONG_FRAME_SIZE]; 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); + frame[i++] = PUSH_CODE_CONTROL_DATA; + frame[i++] = (int8_t)(_radio->getLastSNR() * 4); + frame[i++] = (int8_t)(_radio->getLastRSSI()); + frame[i++] = packet->path_len; + memcpy(&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"); - } + writeFrameMaybeFragmented(frame, i); } void MyMesh::onRawDataRecv(mesh::Packet *packet) { - if (packet->payload_len + 4 > sizeof(out_frame)) { + if (!canSendFrameFragments()) { + if (packet->payload_len + 4 > sizeof(out_frame)) { + MESH_DEBUG_PRINTLN("onRawDataRecv(), payload_len too long: %d", packet->payload_len); + return; + } + 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"); + } + return; + } + + if (packet->payload_len + 4 > MAX_COMPANION_LONG_FRAME_SIZE) { MESH_DEBUG_PRINTLN("onRawDataRecv(), payload_len too long: %d", packet->payload_len); return; } + if (!_serial || !_serial->isConnected()) { + MESH_DEBUG_PRINTLN("onRawDataRecv(), data received while app offline"); + return; + } + + uint8_t frame[MAX_COMPANION_LONG_FRAME_SIZE]; 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); + frame[i++] = PUSH_CODE_RAW_DATA; + frame[i++] = (int8_t)(_radio->getLastSNR() * 4); + frame[i++] = (int8_t)(_radio->getLastRSSI()); + frame[i++] = 0xFF; // reserved (possibly path_len in future) + memcpy(&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"); - } + writeFrameMaybeFragmented(frame, i); } void MyMesh::onTraceRecv(mesh::Packet *packet, uint32_t tag, uint32_t auth_code, uint8_t flags, @@ -862,6 +1063,8 @@ MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMe _cli_rescue = false; offline_queue_len = 0; app_target_ver = 0; + next_fragment_id = 1; + resetQueuedFragmentState(); clearPendingReqs(); next_ack_idx = 0; sign_data = NULL; @@ -1005,12 +1208,14 @@ bool MyMesh::isValidClientRepeatFreq(uint32_t f) const { void MyMesh::startInterface(BaseSerialInterface &serial) { _serial = &serial; + resetQueuedFragmentState(); serial.enable(); } void MyMesh::handleCmdFrame(size_t len) { 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 + resetQueuedFragmentState(); int i = 0; out_frame[i++] = RESP_CODE_DEVICE_INFO; @@ -1356,14 +1561,27 @@ void MyMesh::handleCmdFrame(size_t len) { } } else if (cmd_frame[0] == CMD_SYNC_NEXT_MESSAGE) { 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; + 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); #ifdef DISPLAY_CLASS - if (_ui) _ui->msgRead(offline_queue_len); + if (_ui && offline_queue_len != prev_queue_len) _ui->msgRead(offline_queue_len); #endif } else { out_frame[0] = RESP_CODE_NO_MORE_MESSAGES; _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) { int i = 1; diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index f4190f30ac..3ec7551b2c 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -5,7 +5,7 @@ #include "AbstractUITask.h" /*------------ Frame Protocol --------------*/ -#define FIRMWARE_VER_CODE 13 +#define FIRMWARE_VER_CODE 14 #ifndef FIRMWARE_BUILD_DATE #define FIRMWARE_BUILD_DATE "6 Jun 2026" @@ -34,6 +34,16 @@ #include #include +// Fragment frames wrap an existing companion frame when it does not fit in the +// app-safe companion frame size. Keep this lower than firmware MAX_FRAME_SIZE +// so v14 apps do not receive unfragmented 173..176 byte frames. +#define APP_TARGET_VER_FRAME_FRAGMENTS 14 +#define APP_SAFE_FRAME_SIZE 172 +#define PUSH_CODE_FRAME_FRAGMENT 0x91 +#define FRAME_FRAGMENT_HEADER_LEN 10 +#define FRAME_FRAGMENT_CHUNK_LEN (APP_SAFE_FRAME_SIZE - FRAME_FRAGMENT_HEADER_LEN) +#define MAX_COMPANION_LONG_FRAME_SIZE (MAX_TRANS_UNIT + 4) + /* ---------------------------------- CONFIGURATION ------------------------------------- */ #ifndef LORA_FREQ @@ -187,8 +197,16 @@ class MyMesh : public BaseChatMesh, public DataStoreHost { void writeDisabledFrame(); void writeContactRespFrame(uint8_t code, const ContactInfo &contact); void updateContactFromFrame(ContactInfo &contact, uint32_t& last_mod, const uint8_t *frame, int len); - void addToOfflineQueue(const uint8_t frame[], int len); - int getFromOfflineQueue(uint8_t frame[]); + void addToOfflineQueue(const uint8_t frame[], uint16_t len); + 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; + uint16_t getCompanionDirectFrameLimit() const; + bool makeRoomInOfflineQueue(int slots_needed); + void removeTopOfflineQueue(); + void resetQueuedFragmentState(); + void writeFrameMaybeFragmented(const uint8_t frame[], uint16_t len); + void writeFrameFragments(const uint8_t frame[], uint16_t len); int getBlobByKey(const uint8_t key[], int key_len, uint8_t dest_buf[]) override { return _store->getBlobByKey(key, key_len, dest_buf); } @@ -222,6 +240,10 @@ class MyMesh : public BaseChatMesh, public DataStoreHost { bool send_unscoped; // force un-scoped flood (instead of using send_scope) char cli_command[80]; 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; uint32_t sign_data_len; unsigned long dirty_contacts_expiry; @@ -233,8 +255,8 @@ class MyMesh : public BaseChatMesh, public DataStoreHost { CayenneLPP telemetry; struct Frame { - uint8_t len; - uint8_t buf[MAX_FRAME_SIZE]; + uint16_t len; + uint8_t buf[MAX_COMPANION_LONG_FRAME_SIZE]; bool isChannelMsg() const; }; From e2be6b364f7feb1fb10ed2bcff4e37dcc394ce78 Mon Sep 17 00:00:00 2001 From: HDDen <62592944+HDDen@users.noreply.github.com> Date: Fri, 3 Jul 2026 12:35:43 +0300 Subject: [PATCH 2/2] updated docs --- docs/companion_protocol.md | 68 +++++++++++++++++++++++++++++++++++--- 1 file changed, 63 insertions(+), 5 deletions(-) diff --git a/docs/companion_protocol.md b/docs/companion_protocol.md index 7cca7bc9a2..c4a68d88e5 100644 --- a/docs/companion_protocol.md +++ b/docs/companion_protocol.md @@ -1,7 +1,7 @@ # Companion Protocol -- **Last Updated**: 2026-03-08 -- **Protocol Version**: Companion Firmware v1.12.0+ +- **Last Updated**: 2026-07-03 +- **Protocol Version**: Companion Firmware v1.16.0+ > NOTE: This document is still in development. Some information may be inaccurate. @@ -107,6 +107,14 @@ The default BLE MTU is 23 bytes (20 bytes payload). For larger commands like `SE - iOS: `peripheral.maximumWriteValueLength(for:)` - Python (bleak): MTU is negotiated automatically +### Companion Frame Size Limits + +Firmware transports still use `MAX_FRAME_SIZE = 176` as the low-level companion frame buffer size. Apps should treat `APP_SAFE_FRAME_SIZE = 172` as the maximum unfragmented firmware-to-app frame size for app target protocol version 14+. + +This intentionally leaves a 4-byte safety margin for existing app TCP/USB/BLE decoders that historically accepted 172-byte frames even though firmware buffers can hold 176 bytes. Without this margin, frames of 173..176 bytes can fall into a compatibility gap: firmware may send them unfragmented while an app-side decoder rejects them as too large. + +When an app advertises target protocol version 14 or newer in `CMD_DEVICE_QUERY`, firmware emits `PACKET_FRAME_FRAGMENT` for original companion frames larger than 172 bytes. Each emitted fragment is also kept at or below 172 bytes. Apps that do not advertise version 14 keep the legacy behavior based on the older transport limit. + ### Command Sequencing **Critical**: Commands must be sent in the correct sequence: @@ -187,14 +195,16 @@ Bytes 8+: Application name (UTF-8, optional) **Command Format**: ``` Byte 0: 0x16 -Byte 1: 0x03 +Byte 1: App target protocol version ``` **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 --- @@ -395,19 +405,38 @@ def parse_channel_data_recv(data): 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): ``` 0A ``` +**Example With Fragment ACK** (hex, ACK fragment ID `0x1234`, index `1`): +``` +0A 01 34 12 01 +``` + **Response**: - `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_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 **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 @@ -640,9 +669,35 @@ Byte values are authoritative; names are aliases. When reading firmware source, | 0x82 | PACKET_ACK | Acknowledgment | | 0x83 | PACKET_MESSAGES_WAITING | Messages waiting notification | | 0x88 | PACKET_LOG_DATA | RF log data (can be ignored) | +| 0x91 | PACKET_FRAME_FRAGMENT | Fragment of a larger frame | ### 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 `APP_SAFE_FRAME_SIZE` (172 bytes). Frames up to 172 bytes are sent unchanged. `MAX_FRAME_SIZE` remains 176 bytes in firmware internals, but v14 fragmentation deliberately starts at 173 bytes to avoid sending unfragmented frames that older app-side TCP/USB/BLE decoders may reject. + +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 `APP_SAFE_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 `APP_SAFE_FRAME_SIZE` (172 bytes). With the 10-byte fragment header, the maximum chunk size is 162 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): ``` Byte 0: 0x00 @@ -826,7 +881,10 @@ 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 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 `APP_SAFE_FRAME_SIZE` (172 bytes) and `PACKET_FRAME_FRAGMENT` frames up to the same size +- `MAX_FRAME_SIZE` (176 bytes) is a firmware transport buffer limit, not the v14 app-facing unfragmented frame limit +- 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