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
122 changes: 98 additions & 24 deletions docs/companion_protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -257,31 +257,60 @@ Bytes 34-65: Secret (32 bytes)

---

### 5. Send Channel Message
### 5. Send Channel Text Message

**Purpose**: Send a text message to a channel.
**Purpose**: Send a plain text message to a channel.

**Command Format**:
```
Byte 0: 0x03
Byte 1: 0x00
Byte 1: Text Type
Byte 2: Channel Index (0-7)
Bytes 3-6: Timestamp (32-bit little-endian Unix timestamp, seconds)
Bytes 7+: Message Text (UTF-8, variable length)
Bytes 7+: UTF-8 text bytes (variable length)
```

**Timestamp**: Unix timestamp in seconds (32-bit unsigned integer, little-endian)

**Text Type**:
- Must be `0x00` (`TXT_TYPE_PLAIN`) for this command.

**Example** (send "Hello" to channel 1 at timestamp 1234567890):
```
03 00 01 D2 02 96 49 48 65 6C 6C 6F
```

**Response**: `PACKET_MSG_SENT` (0x06) on success
**Response**: `PACKET_OK` (0x00) on success

---

### 6. Send Channel Data Datagram

**Purpose**: Send binary datagram data to a channel.

**Command Format**:
```
Byte 0: 0x3D
Byte 1: Data Type (`data_type`)
Byte 2: Channel Index (0-7)
Bytes 3-6: Timestamp (32-bit little-endian Unix timestamp, seconds)
Bytes 7+: Binary payload bytes (variable length)
```

**Data Type / Transport Mapping**:
- `0xFF` (`DATA_TYPE_CUSTOM`) must be used for custom-protocol binary datagrams.
- `0x00` (`TXT_TYPE_PLAIN`) is invalid for this command.
- Values other than `0xFF` are reserved for official protocol extensions.

**Limits**:
- Maximum payload length is `163` bytes (`MAX_GROUP_DATA_LENGTH`).
- Larger payloads are rejected with `PACKET_ERROR` / `ERR_CODE_ILLEGAL_ARG`.

**Response**: `PACKET_OK` (0x00) on success

---

### 6. Get Message
### 7. Get Message

**Purpose**: Request the next queued message from the device.

Expand All @@ -297,14 +326,15 @@ Byte 0: 0x0A

**Response**:
- `PACKET_CHANNEL_MSG_RECV` (0x08) or `PACKET_CHANNEL_MSG_RECV_V3` (0x11) for channel messages
- `PACKET_CHANNEL_DATA_RECV` (0x1B) or `PACKET_CHANNEL_DATA_RECV_V3` (0x1C) for channel data
- `PACKET_CONTACT_MSG_RECV` (0x07) or `PACKET_CONTACT_MSG_RECV_V3` (0x10) for contact messages
- `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.

---

### 7. Get Battery
### 8. Get Battery

**Purpose**: Query device battery level.

Expand Down Expand Up @@ -366,11 +396,15 @@ Messages are received via the RX characteristic (notifications). The device send
- `PACKET_CHANNEL_MSG_RECV` (0x08) - Standard format
- `PACKET_CHANNEL_MSG_RECV_V3` (0x11) - Version 3 with SNR

2. **Contact Messages**:
2. **Channel Data**:
- `PACKET_CHANNEL_DATA_RECV` (0x1B) - Standard format
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can probably just have a single type PACKET_CHANNEL_DATA_RECV which also includes the SNR and reserved bytes. The reason we have two types for text message packets, is legacy reasons. We didn't have a way to add SNR data to existing response, because there was no data length byte. So we couldn't add it to the end of the payload.

- `PACKET_CHANNEL_DATA_RECV_V3` (0x1C) - Version 3 with SNR

3. **Contact Messages**:
- `PACKET_CONTACT_MSG_RECV` (0x07) - Standard format
- `PACKET_CONTACT_MSG_RECV_V3` (0x10) - Version 3 with SNR

3. **Notifications**:
4. **Notifications**:
- `PACKET_MESSAGES_WAITING` (0x83) - Indicates messages are queued

### Contact Message Format
Expand Down Expand Up @@ -446,7 +480,7 @@ Byte 1: Channel Index (0-7)
Byte 2: Path Length
Byte 3: Text Type
Bytes 4-7: Timestamp (32-bit little-endian)
Bytes 8+: Message Text (UTF-8)
Bytes 8+: Payload bytes
```

**V3 Format** (`PACKET_CHANNEL_MSG_RECV_V3`, 0x11):
Expand All @@ -458,38 +492,74 @@ Byte 4: Channel Index (0-7)
Byte 5: Path Length
Byte 6: Text Type
Bytes 7-10: Timestamp (32-bit little-endian)
Bytes 11+: Message Text (UTF-8)
Bytes 11+: Payload bytes
```

**Payload Meaning**:
- If `txt_type == 0x00`: payload is UTF-8 channel text.
- If `txt_type != 0x00`: payload is binary (for example image/voice fragments) and must be treated as raw bytes.
For custom app datagrams sent via `CMD_SEND_CHANNEL_DATA`, `data_type` must be `0xFF`.

### Channel Data Format

**Standard Format** (`PACKET_CHANNEL_DATA_RECV`, 0x1B):
```
Byte 0: 0x1B (packet type)
Byte 1: Channel Index (0-7)
Byte 2: Path Length
Byte 3: Data Type
Bytes 4-7: Timestamp (32-bit little-endian)
Bytes 8+: Payload bytes
```

**V3 Format** (`PACKET_CHANNEL_DATA_RECV_V3`, 0x1C):
```
Byte 0: 0x1C (packet type)
Byte 1: SNR (signed byte, multiplied by 4)
Bytes 2-3: Reserved
Byte 4: Channel Index (0-7)
Byte 5: Path Length
Byte 6: Data Type
Bytes 7-10: Timestamp (32-bit little-endian)
Bytes 11+: Payload bytes
```

**Parsing Pseudocode**:
```python
def parse_channel_message(data):
def parse_channel_frame(data):
packet_type = data[0]
offset = 1

# Check for V3 format
if packet_type == 0x11: # V3
if packet_type in (0x11, 0x1C): # V3
snr_byte = data[offset]
snr = ((snr_byte if snr_byte < 128 else snr_byte - 256) / 4.0)
offset += 3 # Skip SNR + reserved

channel_idx = data[offset]
path_len = data[offset + 1]
txt_type = data[offset + 2]
item_type = data[offset + 2]
timestamp = int.from_bytes(data[offset+3:offset+7], 'little')
message = data[offset+7:].decode('utf-8')
payload = data[offset+7:]
is_text = packet_type in (0x08, 0x11)
if is_text and item_type == 0:
message = payload.decode('utf-8')
else:
message = None

return {
'channel_idx': channel_idx,
'item_type': item_type,
'timestamp': timestamp,
'payload': payload,
'message': message,
'snr': snr if packet_type == 0x11 else None
'snr': snr if packet_type in (0x11, 0x1C) else None
}
```

### Sending Messages

Use the `SEND_CHANNEL_MESSAGE` command (see [Commands](#commands)).
Use `CMD_SEND_CHANNEL_TXT_MSG` for plain text, and `CMD_SEND_CHANNEL_DATA` for binary datagrams (see [Commands](#commands)).

**Important**:
- Messages are limited to 133 characters per MeshCore specification
Expand All @@ -510,7 +580,7 @@ Use the `SEND_CHANNEL_MESSAGE` command (see [Commands](#commands)).
| 0x03 | PACKET_CONTACT | Contact information |
| 0x04 | PACKET_CONTACT_END | End of contact list |
| 0x05 | PACKET_SELF_INFO | Device self-information |
| 0x06 | PACKET_MSG_SENT | Message sent confirmation |
| 0x06 | PACKET_MSG_SENT | Direct message sent confirmation |
| 0x07 | PACKET_CONTACT_MSG_RECV | Contact message (standard) |
| 0x08 | PACKET_CHANNEL_MSG_RECV | Channel message (standard) |
| 0x09 | PACKET_CURRENT_TIME | Current time response |
Expand All @@ -520,6 +590,8 @@ Use the `SEND_CHANNEL_MESSAGE` command (see [Commands](#commands)).
| 0x10 | PACKET_CONTACT_MSG_RECV_V3 | Contact message (V3 with SNR) |
| 0x11 | PACKET_CHANNEL_MSG_RECV_V3 | Channel message (V3 with SNR) |
| 0x12 | PACKET_CHANNEL_INFO | Channel information |
| 0x1B | PACKET_CHANNEL_DATA_RECV | Channel data (standard) |
| 0x1C | PACKET_CHANNEL_DATA_RECV_V3| Channel data (V3 with SNR) |
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested to remove PACKET_CHANNEL_DATA_RECV_V3 and only have PACKET_CHANNEL_DATA_RECV. SNR would be included in PACKET_CHANNEL_DATA_RECV.

| 0x80 | PACKET_ADVERTISEMENT | Advertisement packet |
| 0x82 | PACKET_ACK | Acknowledgment |
| 0x83 | PACKET_MESSAGES_WAITING | Messages waiting notification |
Expand Down Expand Up @@ -677,7 +749,7 @@ def parse_self_info(data):
return info
```

**PACKET_MSG_SENT** (0x06):
**PACKET_MSG_SENT** (0x06, used by direct/contact send flows):
```
Byte 0: 0x06
Byte 1: Message Type
Expand Down Expand Up @@ -796,8 +868,9 @@ def on_notification_received(data):
- `DEVICE_QUERY` → `PACKET_DEVICE_INFO`
- `GET_CHANNEL` → `PACKET_CHANNEL_INFO`
- `SET_CHANNEL` → `PACKET_OK` or `PACKET_ERROR`
- `SEND_CHANNEL_MESSAGE` → `PACKET_MSG_SENT`
- `GET_MESSAGE` → `PACKET_CHANNEL_MSG_RECV`, `PACKET_CONTACT_MSG_RECV`, or `PACKET_NO_MORE_MSGS`
- `CMD_SEND_CHANNEL_TXT_MSG` → `PACKET_OK` or `PACKET_ERROR`
- `CMD_SEND_CHANNEL_DATA` → `PACKET_OK` or `PACKET_ERROR`
- `GET_MESSAGE` → `PACKET_CHANNEL_MSG_RECV`, `PACKET_CHANNEL_DATA_RECV`, `PACKET_CONTACT_MSG_RECV`, or `PACKET_NO_MORE_MSGS`
- `GET_BATTERY` → `PACKET_BATTERY`

4. **Timeout Handling**:
Expand Down Expand Up @@ -873,7 +946,7 @@ command = build_channel_message(channel_index, message, timestamp)

# 2. Send command
send_command(tx_char, command)
response = wait_for_response(PACKET_MSG_SENT)
response = wait_for_response(PACKET_OK)
```

### Receiving Messages
Expand All @@ -882,8 +955,9 @@ response = wait_for_response(PACKET_MSG_SENT)
def on_notification_received(data):
packet_type = data[0]

if packet_type == PACKET_CHANNEL_MSG_RECV or packet_type == PACKET_CHANNEL_MSG_RECV_V3:
message = parse_channel_message(data)
if packet_type in (PACKET_CHANNEL_MSG_RECV, PACKET_CHANNEL_MSG_RECV_V3,
PACKET_CHANNEL_DATA_RECV, PACKET_CHANNEL_DATA_RECV_V3):
message = parse_channel_frame(data)
handle_channel_message(message)
elif packet_type == PACKET_MESSAGES_WAITING:
# Poll for messages
Expand Down
69 changes: 67 additions & 2 deletions examples/companion_radio/MyMesh.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@
#define CMD_GET_AUTOADD_CONFIG 59
#define CMD_GET_ALLOWED_REPEAT_FREQ 60
#define CMD_SET_PATH_HASH_MODE 61
#define CMD_SEND_CHANNEL_DATA 62

// Stats sub-types for CMD_GET_STATS
#define STATS_TYPE_CORE 0
Expand Down Expand Up @@ -91,6 +92,8 @@
#define RESP_CODE_STATS 24 // v8+, second byte is stats type
#define RESP_CODE_AUTOADD_CONFIG 25
#define RESP_ALLOWED_REPEAT_FREQ 26
#define RESP_CODE_CHANNEL_DATA_RECV 27
#define RESP_CODE_CHANNEL_DATA_RECV_V3 28
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No need for _V3 based on previous review comments


#define SEND_TIMEOUT_BASE_MILLIS 500
#define FLOOD_SEND_TIMEOUT_FACTOR 16.0f
Expand Down Expand Up @@ -204,7 +207,8 @@ void MyMesh::updateContactFromFrame(ContactInfo &contact, uint32_t& last_mod, co
}

bool MyMesh::Frame::isChannelMsg() const {
return buf[0] == RESP_CODE_CHANNEL_MSG_RECV || buf[0] == RESP_CODE_CHANNEL_MSG_RECV_V3;
return buf[0] == RESP_CODE_CHANNEL_MSG_RECV || buf[0] == RESP_CODE_CHANNEL_MSG_RECV_V3 ||
buf[0] == RESP_CODE_CHANNEL_DATA_RECV || buf[0] == RESP_CODE_CHANNEL_DATA_RECV_V3;
}

void MyMesh::addToOfflineQueue(const uint8_t frame[], int len) {
Expand Down Expand Up @@ -564,6 +568,44 @@ void MyMesh::onChannelMessageRecv(const mesh::GroupChannel &channel, mesh::Packe
#endif
}

void MyMesh::onChannelDataRecv(const mesh::GroupChannel &channel, mesh::Packet *pkt, uint32_t timestamp, uint8_t data_type,
const uint8_t *data, size_t data_len) {
int i = 0;
if (app_target_ver >= 3) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No need for the target version check, since these are brand new request/response codes, which can be checked against a protocol version bump later on.

out_frame[i++] = RESP_CODE_CHANNEL_DATA_RECV_V3;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rename RESP_CODE_CHANNEL_DATA_RECV_V3 to RESP_CODE_CHANNEL_DATA_RECV.

out_frame[i++] = (int8_t)(pkt->getSNR() * 4);
out_frame[i++] = 0; // reserved1
out_frame[i++] = 0; // reserved2
} else {
out_frame[i++] = RESP_CODE_CHANNEL_DATA_RECV;
}

uint8_t channel_idx = findChannelIdx(channel);
out_frame[i++] = channel_idx;
out_frame[i++] = pkt->isRouteFlood() ? pkt->path_len : 0xFF;
out_frame[i++] = data_type;
memcpy(&out_frame[i], &timestamp, 4);
i += 4;

size_t available = MAX_FRAME_SIZE - i;
if (data_len > available) {
MESH_DEBUG_PRINTLN("onChannelDataRecv(): payload_len=%d exceeds frame space=%d, truncating", (uint32_t)data_len, (uint32_t)available);
data_len = available;
}
int copy_len = (int)data_len;
if (copy_len > 0) {
memcpy(&out_frame[i], data, copy_len);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I feel like we should also have a data_length byte that's in the payload so we can in a future update, add extra data at the end if there is room for it. This will help prevent the issue where we needed new packet format versions to add the SNR to txt messages.

i += copy_len;
}
addToOfflineQueue(out_frame, i);

if (_serial->isConnected()) {
uint8_t frame[1];
frame[0] = PUSH_CODE_MSG_WAITING; // send push 'tickle'
_serial->writeFrame(frame, 1);
}
}

uint8_t MyMesh::onContactRequest(const ContactInfo &contact, uint32_t sender_timestamp, const uint8_t *data,
uint8_t len, uint8_t *reply) {
if (data[0] == REQ_TYPE_GET_TELEMETRY_DATA) {
Expand Down Expand Up @@ -1031,7 +1073,7 @@ void MyMesh::handleCmdFrame(size_t len) {
? ERR_CODE_NOT_FOUND
: ERR_CODE_UNSUPPORTED_CMD); // unknown recipient, or unsuported TXT_TYPE_*
}
} else if (cmd_frame[0] == CMD_SEND_CHANNEL_TXT_MSG) { // send GroupChannel msg
} else if (cmd_frame[0] == CMD_SEND_CHANNEL_TXT_MSG) { // send GroupChannel text msg
int i = 1;
uint8_t txt_type = cmd_frame[i++]; // should be TXT_TYPE_PLAIN
uint8_t channel_idx = cmd_frame[i++];
Expand All @@ -1051,6 +1093,29 @@ void MyMesh::handleCmdFrame(size_t len) {
writeErrFrame(ERR_CODE_NOT_FOUND); // bad channel_idx
}
}
} else if (cmd_frame[0] == CMD_SEND_CHANNEL_DATA) { // send GroupChannel datagram
int i = 1;
uint8_t data_type = cmd_frame[i++];
uint8_t channel_idx = cmd_frame[i++];
uint32_t msg_timestamp;
memcpy(&msg_timestamp, &cmd_frame[i], 4);
i += 4;
const uint8_t *payload = &cmd_frame[i];
int payload_len = (len > (size_t)i) ? (int)(len - i) : 0;

ChannelDetails channel;
if (!getChannel(channel_idx, channel)) {
writeErrFrame(ERR_CODE_NOT_FOUND); // bad channel_idx
} else if (data_type != DATA_TYPE_CUSTOM) {
writeErrFrame(ERR_CODE_UNSUPPORTED_CMD);
} else if (payload_len > MAX_GROUP_DATA_LENGTH) {
MESH_DEBUG_PRINTLN("CMD_SEND_CHANNEL_DATA payload too long: %d > %d", payload_len, MAX_GROUP_DATA_LENGTH);
writeErrFrame(ERR_CODE_ILLEGAL_ARG);
} else if (sendGroupData(msg_timestamp, channel.channel, data_type, payload, payload_len)) {
writeOKFrame();
} else {
writeErrFrame(ERR_CODE_TABLE_FULL);
}
} else if (cmd_frame[0] == CMD_GET_CONTACTS) { // get Contact list
if (_iter_started) {
writeErrFrame(ERR_CODE_BAD_STATE); // iterator is currently busy
Expand Down
2 changes: 2 additions & 0 deletions examples/companion_radio/MyMesh.h
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,8 @@ class MyMesh : public BaseChatMesh, public DataStoreHost {
const uint8_t *sender_prefix, const char *text) override;
void onChannelMessageRecv(const mesh::GroupChannel &channel, mesh::Packet *pkt, uint32_t timestamp,
const char *text) override;
void onChannelDataRecv(const mesh::GroupChannel &channel, mesh::Packet *pkt, uint32_t timestamp, uint8_t data_type,
const uint8_t *data, size_t data_len) override;

uint8_t onContactRequest(const ContactInfo &contact, uint32_t sender_timestamp, const uint8_t *data,
uint8_t len, uint8_t *reply) override;
Expand Down
3 changes: 2 additions & 1 deletion src/MeshCore.h
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
#define PATH_HASH_SIZE 1

#define MAX_PACKET_PAYLOAD 184
#define MAX_GROUP_DATA_LENGTH (MAX_PACKET_PAYLOAD - CIPHER_BLOCK_SIZE - 5)
#define MAX_PATH_SIZE 64
#define MAX_TRANS_UNIT 255

Expand Down Expand Up @@ -100,4 +101,4 @@ class RTCClock {
}
};

}
}
Loading