diff --git a/docs/payloads.md b/docs/payloads.md index 4742bfbbc..75f8ba785 100644 --- a/docs/payloads.md +++ b/docs/payloads.md @@ -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 | |--------------|-----------------|----------------------------------------------| @@ -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 | |--------------|-----------------|--------------------------------------------| @@ -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. \ No newline at end of file +Custom packets have no defined format. diff --git a/docs/protocol_guide.md b/docs/protocol_guide.md index ceedbbf05..53e7e817e 100644 --- a/docs/protocol_guide.md +++ b/docs/protocol_guide.md @@ -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 @@ -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 @@ -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: @@ -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 @@ -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+ diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 59a0078f4..cdf2094a2 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -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 @@ -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 @@ -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(×tamp, &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], ×tamp, 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], ×tamp, 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; @@ -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]); diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index 1fcc5697d..883e23312 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -150,6 +150,7 @@ class MyMesh : public BaseChatMesh, public DataStoreHost { void clearPendingReqs() { pending_login = pending_status = pending_telemetry = pending_discovery = pending_req = 0; + pending_advert_request = 0; } public: @@ -172,6 +173,7 @@ class MyMesh : public BaseChatMesh, public DataStoreHost { void checkCLIRescueCmd(); void checkSerialInterface(); + void handleAdvertResponse(mesh::Packet* packet); // helpers, short-cuts void saveChannels() { _store->saveChannels(this); } @@ -183,6 +185,7 @@ class MyMesh : public BaseChatMesh, public DataStoreHost { uint32_t pending_status; uint32_t pending_telemetry, pending_discovery; // pending _TELEMETRY_REQ uint32_t pending_req; // pending _BINARY_REQ + uint32_t pending_advert_request; // tag for pending advert request (0 = none) BaseSerialInterface *_serial; AbstractUITask* _ui; diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index d926148d6..d13077dd6 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -1,5 +1,6 @@ #include "MyMesh.h" #include +#include /* ------------------------------ Config -------------------------------- */ @@ -367,8 +368,11 @@ int MyMesh::handleRequest(ClientInfo *sender, uint32_t sender_timestamp, uint8_t } mesh::Packet *MyMesh::createSelfAdvert() { + // Zero-hop adverts do NOT include GPS location (privacy + smaller packets) + // GPS is only shared via pull-advert responses on explicit request uint8_t app_data[MAX_ADVERT_DATA_SIZE]; - uint8_t app_data_len = _cli.buildAdvertData(ADV_TYPE_REPEATER, app_data); + AdvertDataBuilder builder(ADV_TYPE_REPEATER, _prefs.node_name); + uint8_t app_data_len = builder.encodeTo(app_data); return createAdvert(self_id, app_data, app_data_len); } @@ -390,6 +394,21 @@ bool MyMesh::allowPacketForward(const mesh::Packet *packet) { MESH_DEBUG_PRINTLN("allowPacketForward: unknown transport code, or wildcard not allowed for FLOOD packet"); return false; } + + // Don't forward flooded adverts from repeaters + if (packet->isRouteFlood() && packet->getPayloadType() == PAYLOAD_TYPE_ADVERT) { + int app_data_offset = PUB_KEY_SIZE + 4 + SIGNATURE_SIZE; + if (packet->payload_len > app_data_offset) { + const uint8_t *app_data = &packet->payload[app_data_offset]; + int app_data_len = packet->payload_len - app_data_offset; + AdvertDataParser parser(app_data, app_data_len); + if (parser.isValid() && parser.getType() == ADV_TYPE_REPEATER) { + MESH_DEBUG_PRINTLN("allowPacketForward: dropping flooded advert from repeater"); + return false; + } + } + } + return true; } @@ -707,11 +726,125 @@ bool MyMesh::onPeerPathRecv(mesh::Packet *packet, int sender_idx, const uint8_t return false; } -#define CTL_TYPE_NODE_DISCOVER_REQ 0x80 -#define CTL_TYPE_NODE_DISCOVER_RESP 0x90 +void MyMesh::handleAdvertRequest(mesh::Packet* packet) { + // Validate packet length: sub_type(1) + prefix(PATH_HASH_SIZE) + tag(4) + path_len(1) + if (packet->payload_len < 1 + PATH_HASH_SIZE + 4 + 1) { + MESH_DEBUG_PRINTLN("handleAdvertRequest: packet too short (%d bytes)", packet->payload_len); + return; + } + + // Extract target prefix, tag, and return path from payload + int offset = 1; + const uint8_t* target_prefix = &packet->payload[offset]; offset += PATH_HASH_SIZE; + uint32_t tag; + memcpy(&tag, &packet->payload[offset], 4); offset += 4; + uint8_t return_path_len = packet->payload[offset++]; + const uint8_t* return_path = &packet->payload[offset]; + + // Validate path length + if (packet->payload_len < offset + return_path_len) { + MESH_DEBUG_PRINTLN("handleAdvertRequest: invalid path_len (%d)", return_path_len); + return; + } + + // Check if request is for us + if (memcmp(target_prefix, self_id.pub_key, PATH_HASH_SIZE) != 0) { + MESH_DEBUG_PRINTLN("handleAdvertRequest: not for us (prefix mismatch)"); + return; + } + + MESH_DEBUG_PRINTLN("handleAdvertRequest: request for us, tag=%08X, return_path_len=%d", tag, return_path_len); + + // Build app_data (adv_type + node_name + flags + optional: lat, lon, desc) + uint8_t app_data[1 + 32 + 1 + 4 + 4 + 32]; + int app_data_len = 0; + + // adv_type + app_data[app_data_len++] = ADV_TYPE_REPEATER; + + // node_name (32 bytes) + memcpy(&app_data[app_data_len], _prefs.node_name, 32); app_data_len += 32; + + // flags - determine which optional fields are present + uint8_t flags = 0; + int32_t lat_i32 = 0, lon_i32 = 0; + + if (_prefs.advert_loc_policy != ADVERT_LOC_NONE) { + double lat = (_prefs.advert_loc_policy == ADVERT_LOC_SHARE) ? sensors.node_lat : _prefs.node_lat; + double lon = (_prefs.advert_loc_policy == ADVERT_LOC_SHARE) ? sensors.node_lon : _prefs.node_lon; + if (lat != 0.0 || lon != 0.0) { + flags |= ADVERT_RESP_FLAG_HAS_LAT | ADVERT_RESP_FLAG_HAS_LON; + lat_i32 = (int32_t)(lat * 1e6); + lon_i32 = (int32_t)(lon * 1e6); + } + } + if (_prefs.node_desc[0] != '\0') flags |= ADVERT_RESP_FLAG_HAS_DESC; + + app_data[app_data_len++] = flags; + + // Optional fields (order must match receiver) + if (flags & ADVERT_RESP_FLAG_HAS_LAT) { memcpy(&app_data[app_data_len], &lat_i32, 4); app_data_len += 4; } + if (flags & ADVERT_RESP_FLAG_HAS_LON) { memcpy(&app_data[app_data_len], &lon_i32, 4); app_data_len += 4; } + if (flags & ADVERT_RESP_FLAG_HAS_DESC) { memcpy(&app_data[app_data_len], _prefs.node_desc, 32); app_data_len += 32; } + + // Build signature message: pubkey + timestamp + app_data + uint32_t timestamp = getRTCClock()->getCurrentTime(); + uint8_t message[PUB_KEY_SIZE + 4 + sizeof(app_data)]; + int msg_len = 0; + memcpy(&message[msg_len], self_id.pub_key, PUB_KEY_SIZE); msg_len += PUB_KEY_SIZE; + memcpy(&message[msg_len], ×tamp, 4); msg_len += 4; + memcpy(&message[msg_len], app_data, app_data_len); msg_len += app_data_len; + + uint8_t signature[SIGNATURE_SIZE]; + self_id.sign(signature, message, msg_len); + + // Now build the response packet + uint8_t response[MAX_PACKET_PAYLOAD]; + int pos = 0; + + response[pos++] = CTL_TYPE_ADVERT_RESPONSE; // sub_type + memcpy(&response[pos], &tag, 4); pos += 4; // tag (echo back) + memcpy(&response[pos], self_id.pub_key, PUB_KEY_SIZE); pos += PUB_KEY_SIZE; // pub_key + memcpy(&response[pos], ×tamp, 4); pos += 4; // timestamp + memcpy(&response[pos], signature, SIGNATURE_SIZE); pos += SIGNATURE_SIZE; // signature + memcpy(&response[pos], app_data, app_data_len); pos += app_data_len; // app_data + + // Create response packet and send using reversed path from payload + mesh::Packet* resp_packet = createControlData(response, pos); + if (resp_packet) { + // Exclude ourselves (last element) from the path + uint8_t response_path_len = (return_path_len > PATH_HASH_SIZE) ? return_path_len - PATH_HASH_SIZE : 0; + + MESH_DEBUG_PRINTLN("handleAdvertRequest: sending response, %d bytes, flags=%02X, response_path_len=%d", pos, flags, response_path_len); + + if (response_path_len == 0) { + // Direct neighbor (or empty path) - send zero-hop + sendZeroHop(resp_packet); + } else { + // Reverse the path excluding ourselves + uint8_t reversed_path[MAX_PATH_SIZE]; + for (int i = 0; i < response_path_len; i++) { + reversed_path[i] = return_path[response_path_len - 1 - i]; + } + MESH_DEBUG_PRINTLN("handleAdvertRequest: response path = [%02X%s%s%s]", + reversed_path[0], + response_path_len > 1 ? ", " : "", + response_path_len > 1 ? "" : "", + response_path_len > 1 ? "..." : ""); + sendDirect(resp_packet, reversed_path, response_path_len, SERVER_RESPONSE_DELAY); + } + } +} void MyMesh::onControlDataRecv(mesh::Packet* packet) { uint8_t type = packet->payload[0] & 0xF0; // just test upper 4 bits + + if (type == CTL_TYPE_ADVERT_REQUEST && discover_limiter.allow(rtc_clock.getCurrentTime())) { + handleAdvertRequest(packet); + return; + } + + // Handle legacy node discovery if (type == CTL_TYPE_NODE_DISCOVER_REQ && packet->payload_len >= 6 && !_prefs.disable_fwd && discover_limiter.allow(rtc_clock.getCurrentTime()) ) { @@ -756,7 +889,7 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc { last_millis = 0; uptime_millis = 0; - next_local_advert = next_flood_advert = 0; + next_local_advert = 0; // zero-hop adverts still active dirty_contacts_expiry = 0; set_radio_at = revert_radio_at = 0; _logging = false; @@ -781,8 +914,8 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc _prefs.bw = LORA_BW; _prefs.cr = LORA_CR; _prefs.tx_power_dbm = LORA_TX_POWER; - _prefs.advert_interval = 1; // default to 2 minutes for NEW installs - _prefs.flood_advert_interval = 12; // 12 hours + _prefs.advert_interval = 1; // default to 2 minutes for NEW installs (zero-hop adverts) + _prefs.flood_advert_interval = 0; // disabled - repeaters no longer send flood adverts _prefs.flood_max = 64; _prefs.interference_threshold = 0; // disabled @@ -822,7 +955,6 @@ void MyMesh::begin(FILESYSTEM *fs) { radio_set_tx_power(_prefs.tx_power_dbm); updateAdvertTimer(); - updateFloodAdvertTimer(); board.setAdcMultiplier(_prefs.adc_multiplier); @@ -857,7 +989,7 @@ bool MyMesh::formatFileSystem() { void MyMesh::sendSelfAdvertisement(int delay_millis) { mesh::Packet *pkt = createSelfAdvert(); if (pkt) { - sendFlood(pkt, delay_millis); + sendZeroHop(pkt, delay_millis); } else { MESH_DEBUG_PRINTLN("ERROR: unable to create advertisement packet!"); } @@ -872,11 +1004,9 @@ void MyMesh::updateAdvertTimer() { } void MyMesh::updateFloodAdvertTimer() { - if (_prefs.flood_advert_interval > 0) { // schedule flood advert timer - next_flood_advert = futureMillis(((uint32_t)_prefs.flood_advert_interval) * 60 * 60 * 1000); - } else { - next_flood_advert = 0; // stop the timer - } + // Repeaters no longer send flood adverts - this function kept for API compatibility + // Always ensure flood_advert_interval is disabled + _prefs.flood_advert_interval = 0; } void MyMesh::dumpLogFile() { @@ -1158,13 +1288,8 @@ void MyMesh::loop() { mesh::Mesh::loop(); - if (next_flood_advert && millisHasNowPassed(next_flood_advert)) { - mesh::Packet *pkt = createSelfAdvert(); - if (pkt) sendFlood(pkt); - - updateFloodAdvertTimer(); // schedule next flood advert - updateAdvertTimer(); // also schedule local advert (so they don't overlap) - } else if (next_local_advert && millisHasNowPassed(next_local_advert)) { + // Periodic zero-hop adverts (local discovery only) + if (next_local_advert && millisHasNowPassed(next_local_advert)) { mesh::Packet *pkt = createSelfAdvert(); if (pkt) sendZeroHop(pkt); diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index f930ee7eb..e4d8e1231 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -83,7 +83,7 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { FILESYSTEM* _fs; uint32_t last_millis; uint64_t uptime_millis; - unsigned long next_local_advert, next_flood_advert; + unsigned long next_local_advert; bool _logging; NodePrefs _prefs; CommonCLI _cli; @@ -121,6 +121,7 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { uint8_t handleAnonClockReq(const mesh::Identity& sender, uint32_t sender_timestamp, const uint8_t* data); int handleRequest(ClientInfo* sender, uint32_t sender_timestamp, uint8_t* payload, size_t payload_len); mesh::Packet* createSelfAdvert(); + void handleAdvertRequest(mesh::Packet* packet); File openAppend(const char* fname); diff --git a/examples/simple_sensor/SensorMesh.cpp b/examples/simple_sensor/SensorMesh.cpp index 4995c55fc..7a43ad31b 100644 --- a/examples/simple_sensor/SensorMesh.cpp +++ b/examples/simple_sensor/SensorMesh.cpp @@ -621,9 +621,6 @@ bool SensorMesh::handleIncomingMsg(ClientInfo& from, uint32_t timestamp, uint8_t return false; } -#define CTL_TYPE_NODE_DISCOVER_REQ 0x80 -#define CTL_TYPE_NODE_DISCOVER_RESP 0x90 - void SensorMesh::onControlDataRecv(mesh::Packet* packet) { uint8_t type = packet->payload[0] & 0xF0; // just test upper 4 bits if (type == CTL_TYPE_NODE_DISCOVER_REQ && packet->payload_len >= 6) { diff --git a/src/Mesh.cpp b/src/Mesh.cpp index 0548c9073..34401b931 100644 --- a/src/Mesh.cpp +++ b/src/Mesh.cpp @@ -102,6 +102,12 @@ DispatcherAction Mesh::onRecvPacket(Packet* pkt) { if (!_tables->hasSeen(pkt)) { removeSelfFromPath(pkt); + // If we're the final destination (path exhausted), deliver CONTROL packets locally + if (pkt->path_len == 0 && pkt->getPayloadType() == PAYLOAD_TYPE_CONTROL) { + onControlDataRecv(pkt); + return ACTION_RELEASE; + } + uint32_t d = getDirectRetransmitDelay(pkt); return ACTION_RETRANSMIT_DELAYED(0, d); // Routed traffic is HIGHEST priority } @@ -311,6 +317,15 @@ DispatcherAction Mesh::onRecvPacket(Packet* pkt) { } break; + case PAYLOAD_TYPE_CONTROL: { + // Handle control packets without high bit set (0x00-0x7F) + // High-bit sub-types (0x80+) are handled earlier at line 72-78 (zero-hop only) + if (!_tables->hasSeen(pkt)) { + onControlDataRecv(pkt); + } + break; + } + default: MESH_DEBUG_PRINTLN("%s Mesh::onRecvPacket(): unknown payload type, header: %d", getLogDateTime(), (int) pkt->header); // Don't flood route unknown packet types! action = routeRecvPacket(pkt); diff --git a/src/Packet.h b/src/Packet.h index 42d73f416..96bdc8daa 100644 --- a/src/Packet.h +++ b/src/Packet.h @@ -31,6 +31,21 @@ namespace mesh { //... #define PAYLOAD_TYPE_RAW_CUSTOM 0x0F // custom packet as raw bytes, for applications with custom encryption, payloads, etc +// Control packet sub-types (used with PAYLOAD_TYPE_CONTROL) +// Node discovery uses nibble pattern: upper nibble = type, lower nibble = flags/node_type +#define CTL_TYPE_NODE_DISCOVER_REQ 0x80 // Node discovery request (lower 4 bits = flags) +#define CTL_TYPE_NODE_DISCOVER_RESP 0x90 // Node discovery response (lower 4 bits = node type) + +// Pull-based advert system - uses sub-types WITHOUT high bit (0x00-0x7F) to allow +// multi-hop forwarding by stock firmware. Sub-types with high bit (0x80+) are zero-hop only. +#define CTL_TYPE_ADVERT_REQUEST 0x20 // Request advert from specific node (multi-hop capable) +#define CTL_TYPE_ADVERT_RESPONSE 0x30 // Response with full advert + extended metadata + +// Advert response flags (optional fields in CTL_TYPE_ADVERT_RESPONSE) +#define ADVERT_RESP_FLAG_HAS_LAT 0x01 +#define ADVERT_RESP_FLAG_HAS_LON 0x02 +#define ADVERT_RESP_FLAG_HAS_DESC 0x04 + #define PAYLOAD_VER_1 0x00 // 1-byte src/dest hashes, 2-byte MAC #define PAYLOAD_VER_2 0x01 // FUTURE (eg. 2-byte hashes, 4-byte MAC ??) #define PAYLOAD_VER_3 0x02 // FUTURE diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index 2fc93006b..936aa5d93 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -81,7 +81,8 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) { file.read((uint8_t *)&_prefs->discovery_mod_timestamp, sizeof(_prefs->discovery_mod_timestamp)); // 162 file.read((uint8_t *)&_prefs->adc_multiplier, sizeof(_prefs->adc_multiplier)); // 166 file.read((uint8_t *)_prefs->owner_info, sizeof(_prefs->owner_info)); // 170 - // 290 + file.read((uint8_t *)&_prefs->node_desc, sizeof(_prefs->node_desc)); // 290 + // 322 // sanitise bad pref values _prefs->rx_delay_base = constrain(_prefs->rx_delay_base, 0, 20.0f); @@ -108,6 +109,9 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) { _prefs->gps_enabled = constrain(_prefs->gps_enabled, 0, 1); _prefs->advert_loc_policy = constrain(_prefs->advert_loc_policy, 0, 2); + // Ensure null-termination of extended metadata field + _prefs->node_desc[31] = '\0'; + file.close(); } } @@ -165,7 +169,8 @@ void CommonCLI::savePrefs(FILESYSTEM* fs) { file.write((uint8_t *)&_prefs->discovery_mod_timestamp, sizeof(_prefs->discovery_mod_timestamp)); // 162 file.write((uint8_t *)&_prefs->adc_multiplier, sizeof(_prefs->adc_multiplier)); // 166 file.write((uint8_t *)_prefs->owner_info, sizeof(_prefs->owner_info)); // 170 - // 290 + file.write((uint8_t *)&_prefs->node_desc, sizeof(_prefs->node_desc)); // 290 + // 322 file.close(); } @@ -280,7 +285,7 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, const char* command, ch } else if (memcmp(config, "allow.read.only", 15) == 0) { sprintf(reply, "> %s", _prefs->allow_read_only ? "on" : "off"); } else if (memcmp(config, "flood.advert.interval", 21) == 0) { - sprintf(reply, "> %d", ((uint32_t) _prefs->flood_advert_interval)); + strcpy(reply, "> 0 (disabled - repeaters no longer send flood adverts)"); } else if (memcmp(config, "advert.interval", 15) == 0) { sprintf(reply, "> %d", ((uint32_t) _prefs->advert_interval) * 2); } else if (memcmp(config, "guest.password", 14) == 0) { @@ -364,6 +369,12 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, const char* command, ch } else { sprintf(reply, "> %.3f", adc_mult); } + } else if (memcmp(config, "node.desc", 9) == 0) { + if (_prefs->node_desc[0]) { + sprintf(reply, "> %s", _prefs->node_desc); + } else { + strcpy(reply, "> (not set)"); + } } else { sprintf(reply, "??: %s", config); } @@ -393,15 +404,7 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, const char* command, ch savePrefs(); strcpy(reply, "OK"); } else if (memcmp(config, "flood.advert.interval ", 22) == 0) { - int hours = _atoi(&config[22]); - if ((hours > 0 && hours < 3) || (hours > 48)) { - strcpy(reply, "Error: interval range is 3-48 hours"); - } else { - _prefs->flood_advert_interval = (uint8_t)(hours); - _callbacks->updateFloodAdvertTimer(); - savePrefs(); - strcpy(reply, "OK"); - } + strcpy(reply, "Error: Repeaters no longer send flood adverts. Use zero-hop adverts only (advert.interval)"); } else if (memcmp(config, "advert.interval ", 16) == 0) { int mins = _atoi(&config[16]); if ((mins > 0 && mins < MIN_LOCAL_ADVERT_INTERVAL) || (mins > 240)) { @@ -436,6 +439,10 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, const char* command, ch } else { strcpy(reply, "Error, bad chars"); } + } else if (memcmp(config, "node.desc ", 10) == 0) { + StrHelper::strncpy(_prefs->node_desc, &config[10], sizeof(_prefs->node_desc)); + savePrefs(); + strcpy(reply, "OK"); } else if (memcmp(config, "repeat ", 7) == 0) { _prefs->disable_fwd = memcmp(&config[7], "off", 3) == 0; savePrefs(); diff --git a/src/helpers/CommonCLI.h b/src/helpers/CommonCLI.h index 3b1d05f96..2c746c28d 100644 --- a/src/helpers/CommonCLI.h +++ b/src/helpers/CommonCLI.h @@ -51,6 +51,8 @@ struct NodePrefs { // persisted to file uint32_t discovery_mod_timestamp; float adc_multiplier; char owner_info[120]; + // Extended advert metadata (only shared in pull-based responses) + char node_desc[32]; // Short description (e.g., "Rooftop 25m, Heltec V3") }; class CommonCLICallbacks {