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
64 changes: 61 additions & 3 deletions docs/payloads.md
Original file line number Diff line number Diff line change
Expand Up @@ -248,7 +248,17 @@ The plaintext contained in the ciphertext matches the format described in [plain
| flags | 1 | upper 4 bits is sub_type |
| data | rest of payload | typically unencrypted data |

## DISCOVER_REQ (sub_type)
## Control Sub-Type Routing

Control packet sub-types are divided into two categories based on routing behavior:

**Zero-hop only (0x80-0xFF):** Sub-types with the high bit set are restricted to zero-hop delivery. In `Mesh.cpp`, these packets are handled only when `path_len == 0`. Examples: DISCOVER_REQ (0x80), DISCOVER_RESP (0x90).

**Multi-hop capable (0x00-0x7F):** Sub-types without the high bit follow normal DIRECT routing and can traverse multiple hops. When a packet reaches its destination (path exhausted after `removeSelfFromPath()`), it is delivered to `onControlDataRecv()`. Examples: ADVERT_REQUEST (0x20), ADVERT_RESPONSE (0x30).

## DISCOVER_REQ (sub_type 0x80)

Node discovery request. Flooded to find nodes in range.

| Field | Size (bytes) | Description |
|--------------|-----------------|----------------------------------------------|
Expand All @@ -257,7 +267,9 @@ The plaintext contained in the ciphertext matches the format described in [plain
| tag | 4 | randomly generate by sender |
| since | 4 | (optional) epoch timestamp (0 by default) |

## DISCOVER_RESP (sub_type)
## DISCOVER_RESP (sub_type 0x90)

Response to DISCOVER_REQ containing node information.

| Field | Size (bytes) | Description |
|--------------|-----------------|--------------------------------------------|
Expand All @@ -266,7 +278,53 @@ The plaintext contained in the ciphertext matches the format described in [plain
| tag | 4 | reflected back from DISCOVER_REQ |
| pubkey | 8 or 32 | node's ID (or prefix) |

## ADVERT_REQUEST (sub_type 0x20)

Pull-based advert request. Sent to a specific node (via known path) to request its full advertisement with extended metadata.

**Note on sub-type value:** Uses 0x20 (not 0xA0) to enable multi-hop forwarding. In `Mesh.cpp`, control packets with high-bit sub-types (0x80+) are restricted to zero-hop only, while sub-types 0x00-0x7F follow normal DIRECT routing through intermediate nodes.

| Field | Size (bytes) | Description |
|---------------|-----------------|-----------------------------------------------------|
| sub_type | 1 | 0x20 |
| target_prefix | 1 | PATH_HASH_SIZE of target node's public key |
| tag | 4 | randomly generated by sender for matching |
| path_len | 1 | length of return path |
| path | path_len | forward path to target (target is last element) |

**Note on path:** The path is included in the payload (not just the header) so the target can reverse it for the response. With DIRECT routing, the header path is consumed at each hop, so embedding it in the payload preserves it for multi-hop response routing. The path includes the target as the last element (e.g., `[0x23, 0x1F]` to reach node `0x1F` via `0x23`). When building the response, the target excludes itself and reverses the remaining path (e.g., response path becomes `[0x23]`). For direct neighbors where the path is just `[target]`, the response is sent as zero-hop. Intermediate nodes (including stock firmware) forward normally based on the header path.

## ADVERT_RESPONSE (sub_type 0x30)

Response to ADVERT_REQUEST containing full advertisement with extended metadata.

**Note on sub-type value:** Uses 0x30 (not 0xB0) to enable multi-hop response routing.

| Field | Size (bytes) | Description |
|---------------|-----------------|-----------------------------------------------------|
| sub_type | 1 | 0x30 |
| tag | 4 | echoed from ADVERT_REQUEST |
| pubkey | 32 | responder's full Ed25519 public key |
| timestamp | 4 | unix timestamp |
| signature | 64 | Ed25519 signature over pubkey + timestamp + app_data |
| adv_type | 1 | node type (1=chat, 2=repeater, 3=room, 4=sensor) |
| node_name | 32 | node name, null-padded |
| flags | 1 | indicates which optional fields are present |
| latitude | 4 (optional) | if flags & 0x01: decimal latitude * 1000000, int32 |
| longitude | 4 (optional) | if flags & 0x02: decimal longitude * 1000000, int32 |
| node_desc | 32 (optional) | if flags & 0x04: node description, null-padded |

Note: app_data = adv_type + node_name + flags + optional fields (same pattern as regular adverts).

ADVERT_RESPONSE Flags:

| Value | Name | Description |
|--------|------------------|---------------------------------|
| `0x01` | has latitude | latitude field is present |
| `0x02` | has longitude | longitude field is present |
| `0x04` | has description | node_desc field is present |


# Custom packet

Custom packets have no defined format.
Custom packets have no defined format.
100 changes: 98 additions & 2 deletions docs/protocol_guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -376,6 +376,29 @@ Byte 0: 0x14

---

### 8. Request Advert (Pull-Based)

**Purpose**: Request a full advertisement with extended metadata from a specific node via a known path.

**Command Format**:
```
Byte 0: 0x3A
Bytes 1: PATH_HASH_SIZE bytes (currently 1)
Byte 2: Path Length
Bytes 3+: Path (list of node hash bytes to reach target)
```

**Example** (request from node with prefix `a1b2c3d4e5f6` via 2-hop path):
```
3a a1 02 [path_hash_1] [path_hash_2]
```

**Response**: `PACKET_OK` (0x00) on success, `PACKET_ERROR` (0x01) if request already pending

**Note**: Only one advert request can be pending at a time. The response arrives asynchronously as `PUSH_CODE_ADVERT_RESPONSE` (0x8F).

---

## Channel Management

### Channel Types
Expand Down Expand Up @@ -715,6 +738,7 @@ Use the `SEND_CHANNEL_MESSAGE` command (see [Commands](#commands)).
| 0x82 | PACKET_ACK | Acknowledgment |
| 0x83 | PACKET_MESSAGES_WAITING | Messages waiting notification |
| 0x88 | PACKET_LOG_DATA | RF log data (can be ignored) |
| 0x8F | PUSH_CODE_ADVERT_RESPONSE | Pull-based advert response |

### Parsing Responses

Expand Down Expand Up @@ -882,6 +906,77 @@ Byte 0: 0x82
Bytes 1-6: ACK Code (6 bytes, hex)
```

**PUSH_CODE_ADVERT_RESPONSE** (0x8F):

Response to a pull-based advert request containing full advertisement with extended metadata.

```
Byte 0: 0x8F (packet type)
Bytes 1-4: Tag (32-bit little-endian, matches request)
Bytes 5-36: Public Key (32 bytes)
Byte 37: Advertisement Type (1=chat, 2=repeater, 3=room, 4=sensor)
Bytes 38-69: Node Name (32 bytes, null-padded)
Bytes 70-73: Timestamp (32-bit little-endian Unix timestamp)
Byte 74: Flags (indicates which optional fields are present)
[Optional fields based on flags]
```

**Flags**:
- `0x01`: Latitude present (4 bytes, int32, value * 1e6)
- `0x02`: Longitude present (4 bytes, int32, value * 1e6)
- `0x04`: Node description present (32 bytes, null-padded)

**Parsing Pseudocode**:
```python
def parse_advert_response(data):
if len(data) < 75:
return None

offset = 1
tag = int.from_bytes(data[offset:offset+4], 'little')
offset += 4

pubkey = data[offset:offset+32].hex()
offset += 32

adv_type = data[offset]
offset += 1

node_name = data[offset:offset+32].decode('utf-8').rstrip('\x00')
offset += 32

timestamp = int.from_bytes(data[offset:offset+4], 'little')
offset += 4

flags = data[offset]
offset += 1

result = {
'tag': tag,
'pubkey': pubkey,
'adv_type': adv_type,
'node_name': node_name,
'timestamp': timestamp,
'flags': flags
}

if flags & 0x01: # has latitude
lat_i32 = int.from_bytes(data[offset:offset+4], 'little', signed=True)
result['latitude'] = lat_i32 / 1e6
offset += 4

if flags & 0x02: # has longitude
lon_i32 = int.from_bytes(data[offset:offset+4], 'little', signed=True)
result['longitude'] = lon_i32 / 1e6
offset += 4

if flags & 0x04: # has description
result['node_desc'] = data[offset:offset+32].decode('utf-8').rstrip('\x00')
offset += 32

return result
```

### Error Codes

**PACKET_ERROR** (0x01) may include an error code in byte 1:
Expand Down Expand Up @@ -990,6 +1085,7 @@ def on_notification_received(data):
- `SEND_CHANNEL_MESSAGE` → `PACKET_MSG_SENT`
- `GET_MESSAGE` → `PACKET_CHANNEL_MSG_RECV`, `PACKET_CONTACT_MSG_RECV`, or `PACKET_NO_MORE_MSGS`
- `GET_BATTERY` → `PACKET_BATTERY`
- `REQUEST_ADVERT` → `PACKET_OK` (response arrives async as `PUSH_CODE_ADVERT_RESPONSE`)

4. **Timeout Handling**:
- Default timeout: 5 seconds per command
Expand Down Expand Up @@ -1196,6 +1292,6 @@ img.save("channel_qr.png")

---

**Last Updated**: 2025-01-01
**Protocol Version**: Based on MeshCore v1.36.0+
**Last Updated**: 2026-01-11
**Protocol Version**: Based on MeshCore v1.37.0+

145 changes: 145 additions & 0 deletions examples/companion_radio/MyMesh.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
#define CMD_SEND_CONTROL_DATA 55 // v8+
#define CMD_GET_STATS 56 // v8+, second byte is stats type
#define CMD_SEND_ANON_REQ 57
#define CMD_REQUEST_ADVERT 58 // Request advert from node via pull-based system

// Stats sub-types for CMD_GET_STATS
#define STATS_TYPE_CORE 0
Expand Down Expand Up @@ -110,6 +111,7 @@
#define PUSH_CODE_BINARY_RESPONSE 0x8C
#define PUSH_CODE_PATH_DISCOVERY_RESPONSE 0x8D
#define PUSH_CODE_CONTROL_DATA 0x8E // v8+
#define PUSH_CODE_ADVERT_RESPONSE 0x8F // Pull-based advert response

#define ERR_CODE_UNSUPPORTED_CMD 1
#define ERR_CODE_NOT_FOUND 2
Expand Down Expand Up @@ -636,7 +638,105 @@ bool MyMesh::onContactPathRecv(ContactInfo& contact, uint8_t* in_path, uint8_t i
return BaseChatMesh::onContactPathRecv(contact, in_path, in_path_len, out_path, out_path_len, extra_type, extra, extra_len);
}

void MyMesh::handleAdvertResponse(mesh::Packet* packet) {
// Minimum: sub_type(1) + tag(4) + pubkey(32) + timestamp(4) + signature(64) + app_data(1+32+1) = 139 bytes
if (packet->payload_len < 139) {
MESH_DEBUG_PRINTLN("handleAdvertResponse: packet too short (%d bytes)", packet->payload_len);
return;
}

int pos = 1; // skip sub_type

// Extract tag
uint32_t tag;
memcpy(&tag, &packet->payload[pos], 4); pos += 4;

// Check if tag matches pending request
if (pending_advert_request == 0 || pending_advert_request != tag) {
MESH_DEBUG_PRINTLN("handleAdvertResponse: no matching request for tag=%08X", tag);
return;
}

MESH_DEBUG_PRINTLN("handleAdvertResponse: matched tag=%08X", tag);

// Extract pubkey, timestamp, signature (new packet order)
uint8_t pubkey[PUB_KEY_SIZE];
memcpy(pubkey, &packet->payload[pos], PUB_KEY_SIZE); pos += PUB_KEY_SIZE;

uint32_t timestamp;
memcpy(&timestamp, &packet->payload[pos], 4); pos += 4;

uint8_t signature[SIGNATURE_SIZE];
memcpy(signature, &packet->payload[pos], SIGNATURE_SIZE); pos += SIGNATURE_SIZE;

// app_data starts here - extract it for signature verification
const uint8_t* app_data = &packet->payload[pos];
int app_data_len = packet->payload_len - pos;

// Parse app_data fields
int app_pos = 0;
uint8_t adv_type = app_data[app_pos++];

char node_name[32];
memcpy(node_name, &app_data[app_pos], 32); app_pos += 32;

uint8_t flags = app_data[app_pos++];

int32_t lat_i32 = 0, lon_i32 = 0;
if (flags & ADVERT_RESP_FLAG_HAS_LAT) { memcpy(&lat_i32, &app_data[app_pos], 4); app_pos += 4; }
if (flags & ADVERT_RESP_FLAG_HAS_LON) { memcpy(&lon_i32, &app_data[app_pos], 4); app_pos += 4; }

char node_desc[32] = {0};
if (flags & ADVERT_RESP_FLAG_HAS_DESC) { memcpy(node_desc, &app_data[app_pos], 32); app_pos += 32; }

// Verify signature (over pubkey + timestamp + app_data, same as regular adverts)
uint8_t message[PUB_KEY_SIZE + 4 + MAX_PACKET_PAYLOAD];
int msg_len = 0;
memcpy(&message[msg_len], pubkey, PUB_KEY_SIZE); msg_len += PUB_KEY_SIZE;
memcpy(&message[msg_len], &timestamp, 4); msg_len += 4;
memcpy(&message[msg_len], app_data, app_data_len); msg_len += app_data_len;

mesh::Identity id;
memcpy(id.pub_key, pubkey, PUB_KEY_SIZE);
if (!id.verify(signature, message, msg_len)) {
MESH_DEBUG_PRINTLN("handleAdvertResponse: signature verification failed");
pending_advert_request = 0;
return;
}

// Build push notification to app
int i = 0;
out_frame[i++] = PUSH_CODE_ADVERT_RESPONSE;
memcpy(&out_frame[i], &tag, 4); i += 4; // Include tag for app matching
memcpy(&out_frame[i], pubkey, PUB_KEY_SIZE); i += PUB_KEY_SIZE;
out_frame[i++] = adv_type;
memcpy(&out_frame[i], node_name, 32); i += 32;
memcpy(&out_frame[i], &timestamp, 4); i += 4;
out_frame[i++] = flags;
if (flags & ADVERT_RESP_FLAG_HAS_LAT) { memcpy(&out_frame[i], &lat_i32, 4); i += 4; }
if (flags & ADVERT_RESP_FLAG_HAS_LON) { memcpy(&out_frame[i], &lon_i32, 4); i += 4; }
if (flags & ADVERT_RESP_FLAG_HAS_DESC) { memcpy(&out_frame[i], node_desc, 32); i += 32; }

_serial->writeFrame(out_frame, i);

// Clear pending request
pending_advert_request = 0;

MESH_DEBUG_PRINTLN("handleAdvertResponse: forwarded to app, %d bytes", i);
}

void MyMesh::onControlDataRecv(mesh::Packet *packet) {
if (packet->payload_len < 1) return;

uint8_t sub_type = packet->payload[0] & 0xF0; // upper nibble is subtype

// Handle pull-based advert response
if (sub_type == CTL_TYPE_ADVERT_RESPONSE) {
handleAdvertResponse(packet);
return;
}

// Forward all other control data to app
if (packet->payload_len + 4 > sizeof(out_frame)) {
MESH_DEBUG_PRINTLN("onControlDataRecv(), payload_len too long: %d", packet->payload_len);
return;
Expand Down Expand Up @@ -1663,6 +1763,51 @@ void MyMesh::handleCmdFrame(size_t len) {
} else {
writeErrFrame(ERR_CODE_TABLE_FULL);
}
} else if (cmd_frame[0] == CMD_REQUEST_ADVERT) {
// Format: cmd(1) + prefix(PATH_HASH_SIZE) + path_len(1) + path(variable)
if (len < 1 + PATH_HASH_SIZE + 1) {
writeErrFrame(ERR_CODE_ILLEGAL_ARG);
return;
}

const uint8_t* target_prefix = &cmd_frame[1];
uint8_t path_len = cmd_frame[1 + PATH_HASH_SIZE];

if (len < 1 + PATH_HASH_SIZE + 1 + path_len) {
writeErrFrame(ERR_CODE_ILLEGAL_ARG);
return;
}

const uint8_t* path = &cmd_frame[1 + PATH_HASH_SIZE + 1];

// Clear any stale pending requests (same pattern as other request commands)
clearPendingReqs();

// Generate random tag
uint32_t tag;
getRNG()->random((uint8_t*)&tag, 4);

// Build request packet - include path in payload for multi-hop response routing
// Format: sub_type(1) + target_prefix(1) + tag(4) + path_len(1) + path(N)
uint8_t payload[1 + PATH_HASH_SIZE + 4 + 1 + MAX_PATH_SIZE];
int pos = 0;
payload[pos++] = CTL_TYPE_ADVERT_REQUEST;
memcpy(&payload[pos], target_prefix, PATH_HASH_SIZE); pos += PATH_HASH_SIZE;
memcpy(&payload[pos], &tag, 4); pos += 4;
payload[pos++] = path_len;
memcpy(&payload[pos], path, path_len); pos += path_len;

mesh::Packet* packet = createControlData(payload, pos);
if (!packet) {
writeErrFrame(ERR_CODE_BAD_STATE);
return;
}

sendDirect(packet, path, path_len, 0);

pending_advert_request = tag;

writeOKFrame();
} else {
writeErrFrame(ERR_CODE_UNSUPPORTED_CMD);
MESH_DEBUG_PRINTLN("ERROR: unknown command: %02X", cmd_frame[0]);
Expand Down
Loading