From 96ba429b7359c0eae79405cb1bfb0d8d6d53305c Mon Sep 17 00:00:00 2001 From: Michiel Appelman Date: Sat, 10 Jan 2026 20:18:25 +0100 Subject: [PATCH 01/14] Add pull-based adverts initial implementation. --- examples/companion_radio/MyMesh.cpp | 206 ++++++++++++++++++++++++++++ examples/companion_radio/MyMesh.h | 18 ++- examples/simple_repeater/MyMesh.cpp | 137 +++++++++++++++--- examples/simple_repeater/MyMesh.h | 4 +- src/Packet.h | 10 ++ src/helpers/CommonCLI.cpp | 46 +++++-- src/helpers/CommonCLI.h | 3 + 7 files changed, 391 insertions(+), 33 deletions(-) diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 59a0078f4..753d9b409 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,125 @@ 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) + adv_type(1) + name(32) + timestamp(4) + signature(64) + flags(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; + + // Find matching pending request + int slot = -1; + for (int i = 0; i < MAX_PENDING_ADVERT_REQUESTS; i++) { + if (pending_advert_requests[i].tag == tag) { + slot = i; + break; + } + } + + if (slot == -1) { + MESH_DEBUG_PRINTLN("handleAdvertResponse: no matching request for tag=%08X", tag); + return; + } + + MESH_DEBUG_PRINTLN("handleAdvertResponse: matched tag=%08X", tag); + + // Extract pubkey + uint8_t pubkey[PUB_KEY_SIZE]; + memcpy(pubkey, &packet->payload[pos], PUB_KEY_SIZE); pos += PUB_KEY_SIZE; + + // Extract adv_type, node_name, timestamp + uint8_t adv_type = packet->payload[pos++]; + char node_name[32]; + memcpy(node_name, &packet->payload[pos], 32); pos += 32; + uint32_t timestamp; + memcpy(×tamp, &packet->payload[pos], 4); pos += 4; + + // Extract signature + uint8_t signature[64]; + memcpy(signature, &packet->payload[pos], 64); pos += 64; + + // Verify signature + mesh::Identity id; + memcpy(id.pub_key, pubkey, PUB_KEY_SIZE); + if (!id.verify(signature, packet->payload + 1, pos - 1 - 64)) { + MESH_DEBUG_PRINTLN("handleAdvertResponse: signature verification failed"); + // Clear slot and return + pending_advert_requests[slot].tag = 0; + return; + } + + // Extract flags + uint8_t flags = packet->payload[pos++]; + + // Optional: GPS location + double lat = 0.0, lon = 0.0; + if (flags & ADVERT_RESP_FLAG_HAS_LAT) { + memcpy(&lat, &packet->payload[pos], 8); pos += 8; + } + if (flags & ADVERT_RESP_FLAG_HAS_LON) { + memcpy(&lon, &packet->payload[pos], 8); pos += 8; + } + + // Optional: node description + char node_desc[32] = {0}; + if (flags & ADVERT_RESP_FLAG_HAS_DESC) { + memcpy(node_desc, &packet->payload[pos], 32); pos += 32; + } + + // Optional: operator name + char operator_name[32] = {0}; + if (flags & ADVERT_RESP_FLAG_HAS_OPERATOR) { + memcpy(operator_name, &packet->payload[pos], 32); pos += 32; + } + + // Build push notification to app + int i = 0; + out_frame[i++] = PUSH_CODE_ADVERT_RESPONSE; + 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, 8); i += 8; + } + if (flags & ADVERT_RESP_FLAG_HAS_LON) { + memcpy(&out_frame[i], &lon, 8); i += 8; + } + if (flags & ADVERT_RESP_FLAG_HAS_DESC) { + memcpy(&out_frame[i], node_desc, 32); i += 32; + } + if (flags & ADVERT_RESP_FLAG_HAS_OPERATOR) { + memcpy(&out_frame[i], operator_name, 32); i += 32; + } + + _serial->writeFrame(out_frame, i); + + // Clear slot + pending_advert_requests[slot].tag = 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]; + + // 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 +1783,64 @@ 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]; + + // Find free slot + int slot = -1; + for (int i = 0; i < MAX_PENDING_ADVERT_REQUESTS; i++) { + if (pending_advert_requests[i].tag == 0) { + slot = i; + break; + } + } + + if (slot == -1) { + writeErrFrame(ERR_CODE_TABLE_FULL); + return; + } + + // Generate random tag + uint32_t tag; + getRNG()->random((uint8_t*)&tag, 4); + + // Build request packet + uint8_t payload[1 + PATH_HASH_SIZE + 4]; + 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; + + mesh::Packet* packet = createControlData(payload, pos); + if (!packet) { + writeErrFrame(ERR_CODE_BAD_STATE); + return; + } + + sendDirect(packet, path, path_len, 0); + + // Track request + pending_advert_requests[slot].tag = tag; + pending_advert_requests[slot].created_at = millis(); + memcpy(pending_advert_requests[slot].target_prefix, target_prefix, PATH_HASH_SIZE); + + MESH_DEBUG_PRINTLN("CMD_REQUEST_ADVERT: sent request, tag=%08X", tag); + + writeOKFrame(); } else { writeErrFrame(ERR_CODE_UNSUPPORTED_CMD); MESH_DEBUG_PRINTLN("ERROR: unknown command: %02X", cmd_frame[0]); @@ -1874,6 +2052,31 @@ void MyMesh::checkSerialInterface() { } } +void MyMesh::checkPendingAdvertRequests() { + unsigned long now = millis(); + + for (int i = 0; i < MAX_PENDING_ADVERT_REQUESTS; i++) { + if (pending_advert_requests[i].tag != 0) { + unsigned long elapsed = now - pending_advert_requests[i].created_at; + + if (elapsed > ADVERT_REQUEST_TIMEOUT_MILLIS) { + MESH_DEBUG_PRINTLN("checkPendingAdvertRequests: request timed out, tag=%08X", + pending_advert_requests[i].tag); + // Send timeout notification to app + int j = 0; + out_frame[j++] = PUSH_CODE_ADVERT_RESPONSE; + out_frame[j++] = 0xFF; // Special marker for timeout + memcpy(&out_frame[j], pending_advert_requests[i].target_prefix, PATH_HASH_SIZE); + j += PATH_HASH_SIZE; + _serial->writeFrame(out_frame, j); + + // Clear slot + pending_advert_requests[i].tag = 0; + } + } + } +} + void MyMesh::loop() { BaseChatMesh::loop(); @@ -1883,6 +2086,9 @@ void MyMesh::loop() { checkSerialInterface(); } + // Check for timed out advert requests + checkPendingAdvertRequests(); + // is there are pending dirty contacts write needed? if (dirty_contacts_expiry && millisHasNowPassed(dirty_contacts_expiry)) { saveContacts(); diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index 1fcc5697d..a3fea025e 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -148,8 +148,21 @@ class MyMesh : public BaseChatMesh, public DataStoreHost { bool onChannelLoaded(uint8_t channel_idx, const ChannelDetails& ch) override { return setChannel(channel_idx, ch); } bool getChannelForSave(uint8_t channel_idx, ChannelDetails& ch) override { return getChannel(channel_idx, ch); } +private: + struct PendingAdvertRequest { + uint32_t tag; // Random tag for matching (0 = slot unused) + unsigned long created_at; // Millis when request was created + uint8_t target_prefix[PATH_HASH_SIZE]; // Target node prefix + }; + #define MAX_PENDING_ADVERT_REQUESTS 4 + #define ADVERT_REQUEST_TIMEOUT_MILLIS 30000 // 30 seconds + +public: void clearPendingReqs() { pending_login = pending_status = pending_telemetry = pending_discovery = pending_req = 0; + for (int i = 0; i < MAX_PENDING_ADVERT_REQUESTS; i++) { + pending_advert_requests[i].tag = 0; + } } public: @@ -163,7 +176,7 @@ class MyMesh : public BaseChatMesh, public DataStoreHost { 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[]); - int getBlobByKey(const uint8_t key[], int key_len, uint8_t dest_buf[]) override { + int getBlobByKey(const uint8_t key[], int key_len, uint8_t dest_buf[]) override { return _store->getBlobByKey(key, key_len, dest_buf); } bool putBlobByKey(const uint8_t key[], int key_len, const uint8_t src_buf[], int len) override { @@ -172,6 +185,8 @@ class MyMesh : public BaseChatMesh, public DataStoreHost { void checkCLIRescueCmd(); void checkSerialInterface(); + void handleAdvertResponse(mesh::Packet* packet); + void checkPendingAdvertRequests(); // helpers, short-cuts void saveChannels() { _store->saveChannels(this); } @@ -183,6 +198,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 + PendingAdvertRequest pending_advert_requests[MAX_PENDING_ADVERT_REQUESTS]; BaseSerialInterface *_serial; AbstractUITask* _ui; diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index d926148d6..2ceec365a 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -710,8 +710,113 @@ bool MyMesh::onPeerPathRecv(mesh::Packet *packet, int sender_idx, const uint8_t #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) + if (packet->payload_len < 1 + PATH_HASH_SIZE + 4) { + MESH_DEBUG_PRINTLN("handleAdvertRequest: packet too short (%d bytes)", packet->payload_len); + return; + } + + // Extract target prefix and tag + const uint8_t* target_prefix = &packet->payload[1]; + uint32_t tag; + memcpy(&tag, &packet->payload[1 + PATH_HASH_SIZE], 4); + + // 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", tag); + + // Build response with extended metadata + uint8_t response[MAX_PACKET_PAYLOAD]; + int pos = 0; + + // sub_type + response[pos++] = CTL_TYPE_ADVERT_RESPONSE; + + // tag (echo back) + memcpy(&response[pos], &tag, 4); pos += 4; + + // pub_key (full 32 bytes) + memcpy(&response[pos], self_id.pub_key, PUB_KEY_SIZE); pos += PUB_KEY_SIZE; + + // adv_type + response[pos++] = ADV_TYPE_REPEATER; + + // node_name (32 bytes) + memcpy(&response[pos], _prefs.node_name, 32); pos += 32; + + // timestamp + uint32_t timestamp = getRTCClock()->getCurrentTime(); + memcpy(&response[pos], ×tamp, 4); pos += 4; + + // signature (64 bytes) + uint8_t signature[64]; + self_id.sign(signature, response, pos); // Sign everything up to this point + memcpy(&response[pos], signature, 64); pos += 64; + + // flags (indicating which optional fields are present) + uint8_t flags = 0; + int flags_pos = pos; + pos++; // reserve space for flags byte + + // Optional: GPS location (only if configured to share) + 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; + memcpy(&response[pos], &lat, 8); pos += 8; + memcpy(&response[pos], &lon, 8); pos += 8; + } + } + + // Optional: node description + if (_prefs.node_desc[0] != '\0') { + flags |= ADVERT_RESP_FLAG_HAS_DESC; + memcpy(&response[pos], _prefs.node_desc, 32); pos += 32; + } + + // Optional: operator name + if (_prefs.operator_name[0] != '\0') { + flags |= ADVERT_RESP_FLAG_HAS_OPERATOR; + memcpy(&response[pos], _prefs.operator_name, 32); pos += 32; + } + + // Write flags byte + response[flags_pos] = flags; + + MESH_DEBUG_PRINTLN("handleAdvertRequest: sending response, %d bytes, flags=%02X", pos, flags); + + // Create response packet and send using reverse path + mesh::Packet* resp_packet = createControlData(response, pos); + if (resp_packet) { + // Copy and reverse the path + uint8_t reversed_path[MAX_PATH_SIZE]; + for (int i = 0; i < packet->path_len; i++) { + reversed_path[i] = packet->path[packet->path_len - 1 - i]; + } + sendDirect(resp_packet, reversed_path, packet->path_len, SERVER_RESPONSE_DELAY); + } +} + void MyMesh::onControlDataRecv(mesh::Packet* packet) { - uint8_t type = packet->payload[0] & 0xF0; // just test upper 4 bits + if (packet->payload_len < 1) return; + + uint8_t sub_type = packet->payload[0]; + + // Handle pull-based advert request (with rate limiting) + if (sub_type == CTL_TYPE_ADVERT_REQUEST && discover_limiter.allow(rtc_clock.getCurrentTime())) { + handleAdvertRequest(packet); + return; + } + + // Handle legacy node discovery + uint8_t type = sub_type & 0xF0; // just test upper 4 bits if (type == CTL_TYPE_NODE_DISCOVER_REQ && packet->payload_len >= 6 && !_prefs.disable_fwd && discover_limiter.allow(rtc_clock.getCurrentTime()) ) { @@ -756,7 +861,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 +886,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 @@ -821,8 +926,8 @@ void MyMesh::begin(FILESYSTEM *fs) { radio_set_params(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr); radio_set_tx_power(_prefs.tx_power_dbm); - updateAdvertTimer(); - updateFloodAdvertTimer(); + updateAdvertTimer(); // zero-hop adverts still active + // updateFloodAdvertTimer() not called - repeaters no longer send flood adverts board.setAdcMultiplier(_prefs.adc_multiplier); @@ -855,9 +960,10 @@ bool MyMesh::formatFileSystem() { } void MyMesh::sendSelfAdvertisement(int delay_millis) { + // Repeaters only send zero-hop adverts (no flood) 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 +978,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 +1262,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..51b1d44ab 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -83,7 +83,8 @@ 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; // zero-hop adverts still active + // next_flood_advert removed - repeaters no longer send flood adverts bool _logging; NodePrefs _prefs; CommonCLI _cli; @@ -121,6 +122,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/src/Packet.h b/src/Packet.h index 42d73f416..4396be44e 100644 --- a/src/Packet.h +++ b/src/Packet.h @@ -31,6 +31,16 @@ namespace mesh { //... #define PAYLOAD_TYPE_RAW_CUSTOM 0x0F // custom packet as raw bytes, for applications with custom encryption, payloads, etc +// Pull-based advert system (control sub-types) +#define CTL_TYPE_ADVERT_REQUEST 0x90 // Request advert from specific node +#define CTL_TYPE_ADVERT_RESPONSE 0x91 // 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 ADVERT_RESP_FLAG_HAS_OPERATOR 0x08 + #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..f0e1f7d18 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -81,7 +81,9 @@ 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 + file.read((uint8_t *)&_prefs->operator_name, sizeof(_prefs->operator_name)); // 322 + // 354 // sanitise bad pref values _prefs->rx_delay_base = constrain(_prefs->rx_delay_base, 0, 20.0f); @@ -108,6 +110,10 @@ 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 fields + _prefs->node_desc[31] = '\0'; + _prefs->operator_name[31] = '\0'; + file.close(); } } @@ -165,7 +171,9 @@ 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 + file.write((uint8_t *)&_prefs->operator_name, sizeof(_prefs->operator_name)); // 322 + // 354 file.close(); } @@ -280,7 +288,8 @@ 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)); + // Repeaters no longer send flood adverts + 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 +373,18 @@ 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 if (memcmp(config, "operator.name", 13) == 0) { + if (_prefs->operator_name[0]) { + sprintf(reply, "> %s", _prefs->operator_name); + } else { + strcpy(reply, "> (not set)"); + } } else { sprintf(reply, "??: %s", config); } @@ -393,15 +414,8 @@ 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"); - } + // Repeaters no longer send flood adverts - show error for backwards compatibility + 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 +450,14 @@ 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, "operator.name ", 14) == 0) { + StrHelper::strncpy(_prefs->operator_name, &config[14], sizeof(_prefs->operator_name)); + 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..0c47a7d82 100644 --- a/src/helpers/CommonCLI.h +++ b/src/helpers/CommonCLI.h @@ -51,6 +51,9 @@ 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") + char operator_name[32]; // Operator name/callsign (e.g., "PA0XXX" or "John") }; class CommonCLICallbacks { From 16b7b7c3eb583bfcda273eba0d4948cf55a25455 Mon Sep 17 00:00:00 2001 From: Michiel Appelman Date: Sat, 10 Jan 2026 20:44:32 +0100 Subject: [PATCH 02/14] Fix pull-based adverts: GPS encoding and signature verification. - Change GPS coordinates from double (8 bytes) to int32 (4 bytes) - Fix signature to sign pubkey+timestamp only (matches regular adverts) - Fix signature verification to verify pubkey+timestamp - Add tag to push notification for app matching - Update timeout notification to include tag Co-Authored-By: Claude Opus 4.5 --- examples/companion_radio/MyMesh.cpp | 28 ++++++++++++++++++---------- examples/simple_repeater/MyMesh.cpp | 13 +++++++++---- 2 files changed, 27 insertions(+), 14 deletions(-) diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 753d9b409..54066697d 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -682,10 +682,14 @@ void MyMesh::handleAdvertResponse(mesh::Packet* packet) { uint8_t signature[64]; memcpy(signature, &packet->payload[pos], 64); pos += 64; - // Verify signature + // Verify signature (over pubkey + timestamp, same as regular adverts) + uint8_t sig_data[PUB_KEY_SIZE + 4]; + memcpy(sig_data, pubkey, PUB_KEY_SIZE); + memcpy(sig_data + PUB_KEY_SIZE, ×tamp, 4); + mesh::Identity id; memcpy(id.pub_key, pubkey, PUB_KEY_SIZE); - if (!id.verify(signature, packet->payload + 1, pos - 1 - 64)) { + if (!id.verify(signature, sig_data, sizeof(sig_data))) { MESH_DEBUG_PRINTLN("handleAdvertResponse: signature verification failed"); // Clear slot and return pending_advert_requests[slot].tag = 0; @@ -695,14 +699,16 @@ void MyMesh::handleAdvertResponse(mesh::Packet* packet) { // Extract flags uint8_t flags = packet->payload[pos++]; - // Optional: GPS location - double lat = 0.0, lon = 0.0; + // Optional: GPS location (stored as int32 * 1e6) + int32_t lat_i32 = 0, lon_i32 = 0; if (flags & ADVERT_RESP_FLAG_HAS_LAT) { - memcpy(&lat, &packet->payload[pos], 8); pos += 8; + memcpy(&lat_i32, &packet->payload[pos], 4); pos += 4; } if (flags & ADVERT_RESP_FLAG_HAS_LON) { - memcpy(&lon, &packet->payload[pos], 8); pos += 8; + memcpy(&lon_i32, &packet->payload[pos], 4); pos += 4; } + double lat = lat_i32 / 1e6; + double lon = lon_i32 / 1e6; // Optional: node description char node_desc[32] = {0}; @@ -719,16 +725,17 @@ void MyMesh::handleAdvertResponse(mesh::Packet* packet) { // 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, 8); i += 8; + memcpy(&out_frame[i], &lat_i32, 4); i += 4; } if (flags & ADVERT_RESP_FLAG_HAS_LON) { - memcpy(&out_frame[i], &lon, 8); i += 8; + 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; @@ -2062,10 +2069,11 @@ void MyMesh::checkPendingAdvertRequests() { if (elapsed > ADVERT_REQUEST_TIMEOUT_MILLIS) { MESH_DEBUG_PRINTLN("checkPendingAdvertRequests: request timed out, tag=%08X", pending_advert_requests[i].tag); - // Send timeout notification to app + // Send timeout notification to app (tag + 0xFF marker + target_prefix) int j = 0; out_frame[j++] = PUSH_CODE_ADVERT_RESPONSE; - out_frame[j++] = 0xFF; // Special marker for timeout + memcpy(&out_frame[j], &pending_advert_requests[i].tag, 4); j += 4; // Include tag for app matching + out_frame[j++] = 0xFF; // Special marker for timeout (in place of adv_type) memcpy(&out_frame[j], pending_advert_requests[i].target_prefix, PATH_HASH_SIZE); j += PATH_HASH_SIZE; _serial->writeFrame(out_frame, j); diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 2ceec365a..1c172d708 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -753,9 +753,12 @@ void MyMesh::handleAdvertRequest(mesh::Packet* packet) { uint32_t timestamp = getRTCClock()->getCurrentTime(); memcpy(&response[pos], ×tamp, 4); pos += 4; - // signature (64 bytes) + // signature (64 bytes) - sign pubkey + timestamp only (same as regular adverts) + uint8_t sig_data[PUB_KEY_SIZE + 4]; + memcpy(sig_data, self_id.pub_key, PUB_KEY_SIZE); + memcpy(sig_data + PUB_KEY_SIZE, ×tamp, 4); uint8_t signature[64]; - self_id.sign(signature, response, pos); // Sign everything up to this point + self_id.sign(signature, sig_data, sizeof(sig_data)); memcpy(&response[pos], signature, 64); pos += 64; // flags (indicating which optional fields are present) @@ -770,8 +773,10 @@ void MyMesh::handleAdvertRequest(mesh::Packet* packet) { if (lat != 0.0 || lon != 0.0) { flags |= ADVERT_RESP_FLAG_HAS_LAT | ADVERT_RESP_FLAG_HAS_LON; - memcpy(&response[pos], &lat, 8); pos += 8; - memcpy(&response[pos], &lon, 8); pos += 8; + int32_t lat_i32 = (int32_t)(lat * 1e6); + int32_t lon_i32 = (int32_t)(lon * 1e6); + memcpy(&response[pos], &lat_i32, 4); pos += 4; + memcpy(&response[pos], &lon_i32, 4); pos += 4; } } From 7facc074c68d8c11dba1c03951b8589efe3a35f3 Mon Sep 17 00:00:00 2001 From: Michiel Appelman Date: Sat, 10 Jan 2026 21:01:54 +0100 Subject: [PATCH 03/14] Consolidate control packet sub-types in Packet.h. - Move CTL_TYPE_NODE_DISCOVER_REQ/RESP defines to Packet.h - Change CTL_TYPE_ADVERT_REQUEST/RESPONSE to 0xA0/0xA1 to avoid conflict with node discovery response (0x90-0x9F range) - Remove duplicate defines from repeater and sensor examples Co-Authored-By: Claude Opus 4.5 --- examples/simple_repeater/MyMesh.cpp | 3 --- examples/simple_sensor/SensorMesh.cpp | 3 --- src/Packet.h | 11 ++++++++--- 3 files changed, 8 insertions(+), 9 deletions(-) diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 1c172d708..2bfb46f7a 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -707,9 +707,6 @@ 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) if (packet->payload_len < 1 + PATH_HASH_SIZE + 4) { 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/Packet.h b/src/Packet.h index 4396be44e..8da19eef6 100644 --- a/src/Packet.h +++ b/src/Packet.h @@ -31,9 +31,14 @@ namespace mesh { //... #define PAYLOAD_TYPE_RAW_CUSTOM 0x0F // custom packet as raw bytes, for applications with custom encryption, payloads, etc -// Pull-based advert system (control sub-types) -#define CTL_TYPE_ADVERT_REQUEST 0x90 // Request advert from specific node -#define CTL_TYPE_ADVERT_RESPONSE 0x91 // Response with full advert + extended metadata +// 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 0xA0 range to avoid conflict with node discovery) +#define CTL_TYPE_ADVERT_REQUEST 0xA0 // Request advert from specific node +#define CTL_TYPE_ADVERT_RESPONSE 0xA1 // Response with full advert + extended metadata // Advert response flags (optional fields in CTL_TYPE_ADVERT_RESPONSE) #define ADVERT_RESP_FLAG_HAS_LAT 0x01 From 6f0eccca95d02f13f80b464b573a3a9ad6efcdbc Mon Sep 17 00:00:00 2001 From: Michiel Appelman Date: Sat, 10 Jan 2026 21:22:16 +0100 Subject: [PATCH 04/14] Simplify pending advert request tracking. Replace array of PendingAdvertRequest structs with simple variables: - pending_advert_request (tag, 0 = none) - pending_advert_request_time (millis when created) This aligns with how other pending requests (login, status, etc.) work. Only one advert request can be pending at a time now. Co-Authored-By: Claude Opus 4.5 --- examples/companion_radio/MyMesh.cpp | 72 +++++++++-------------------- examples/companion_radio/MyMesh.h | 13 ++---- 2 files changed, 26 insertions(+), 59 deletions(-) diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 54066697d..0a6a0f5d1 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -651,16 +651,8 @@ void MyMesh::handleAdvertResponse(mesh::Packet* packet) { uint32_t tag; memcpy(&tag, &packet->payload[pos], 4); pos += 4; - // Find matching pending request - int slot = -1; - for (int i = 0; i < MAX_PENDING_ADVERT_REQUESTS; i++) { - if (pending_advert_requests[i].tag == tag) { - slot = i; - break; - } - } - - if (slot == -1) { + // 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; } @@ -691,8 +683,7 @@ void MyMesh::handleAdvertResponse(mesh::Packet* packet) { memcpy(id.pub_key, pubkey, PUB_KEY_SIZE); if (!id.verify(signature, sig_data, sizeof(sig_data))) { MESH_DEBUG_PRINTLN("handleAdvertResponse: signature verification failed"); - // Clear slot and return - pending_advert_requests[slot].tag = 0; + pending_advert_request = 0; return; } @@ -746,8 +737,8 @@ void MyMesh::handleAdvertResponse(mesh::Packet* packet) { _serial->writeFrame(out_frame, i); - // Clear slot - pending_advert_requests[slot].tag = 0; + // Clear pending request + pending_advert_request = 0; MESH_DEBUG_PRINTLN("handleAdvertResponse: forwarded to app, %d bytes", i); } @@ -1807,16 +1798,8 @@ void MyMesh::handleCmdFrame(size_t len) { const uint8_t* path = &cmd_frame[1 + PATH_HASH_SIZE + 1]; - // Find free slot - int slot = -1; - for (int i = 0; i < MAX_PENDING_ADVERT_REQUESTS; i++) { - if (pending_advert_requests[i].tag == 0) { - slot = i; - break; - } - } - - if (slot == -1) { + // Check if there's already a pending request + if (pending_advert_request != 0) { writeErrFrame(ERR_CODE_TABLE_FULL); return; } @@ -1841,9 +1824,8 @@ void MyMesh::handleCmdFrame(size_t len) { sendDirect(packet, path, path_len, 0); // Track request - pending_advert_requests[slot].tag = tag; - pending_advert_requests[slot].created_at = millis(); - memcpy(pending_advert_requests[slot].target_prefix, target_prefix, PATH_HASH_SIZE); + pending_advert_request = tag; + pending_advert_request_time = millis(); MESH_DEBUG_PRINTLN("CMD_REQUEST_ADVERT: sent request, tag=%08X", tag); @@ -2060,28 +2042,20 @@ void MyMesh::checkSerialInterface() { } void MyMesh::checkPendingAdvertRequests() { - unsigned long now = millis(); - - for (int i = 0; i < MAX_PENDING_ADVERT_REQUESTS; i++) { - if (pending_advert_requests[i].tag != 0) { - unsigned long elapsed = now - pending_advert_requests[i].created_at; - - if (elapsed > ADVERT_REQUEST_TIMEOUT_MILLIS) { - MESH_DEBUG_PRINTLN("checkPendingAdvertRequests: request timed out, tag=%08X", - pending_advert_requests[i].tag); - // Send timeout notification to app (tag + 0xFF marker + target_prefix) - int j = 0; - out_frame[j++] = PUSH_CODE_ADVERT_RESPONSE; - memcpy(&out_frame[j], &pending_advert_requests[i].tag, 4); j += 4; // Include tag for app matching - out_frame[j++] = 0xFF; // Special marker for timeout (in place of adv_type) - memcpy(&out_frame[j], pending_advert_requests[i].target_prefix, PATH_HASH_SIZE); - j += PATH_HASH_SIZE; - _serial->writeFrame(out_frame, j); - - // Clear slot - pending_advert_requests[i].tag = 0; - } - } + if (pending_advert_request == 0) return; + + unsigned long elapsed = millis() - pending_advert_request_time; + if (elapsed > ADVERT_REQUEST_TIMEOUT_MILLIS) { + MESH_DEBUG_PRINTLN("checkPendingAdvertRequests: request timed out, tag=%08X", pending_advert_request); + + // Send timeout notification to app (tag + 0xFF marker) + int j = 0; + out_frame[j++] = PUSH_CODE_ADVERT_RESPONSE; + memcpy(&out_frame[j], &pending_advert_request, 4); j += 4; + out_frame[j++] = 0xFF; // Special marker for timeout + _serial->writeFrame(out_frame, j); + + pending_advert_request = 0; } } diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index a3fea025e..b07c76693 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -149,20 +149,12 @@ class MyMesh : public BaseChatMesh, public DataStoreHost { bool getChannelForSave(uint8_t channel_idx, ChannelDetails& ch) override { return getChannel(channel_idx, ch); } private: - struct PendingAdvertRequest { - uint32_t tag; // Random tag for matching (0 = slot unused) - unsigned long created_at; // Millis when request was created - uint8_t target_prefix[PATH_HASH_SIZE]; // Target node prefix - }; - #define MAX_PENDING_ADVERT_REQUESTS 4 #define ADVERT_REQUEST_TIMEOUT_MILLIS 30000 // 30 seconds public: void clearPendingReqs() { pending_login = pending_status = pending_telemetry = pending_discovery = pending_req = 0; - for (int i = 0; i < MAX_PENDING_ADVERT_REQUESTS; i++) { - pending_advert_requests[i].tag = 0; - } + pending_advert_request = 0; } public: @@ -198,7 +190,8 @@ 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 - PendingAdvertRequest pending_advert_requests[MAX_PENDING_ADVERT_REQUESTS]; + uint32_t pending_advert_request; // tag for pending advert request (0 = none) + unsigned long pending_advert_request_time; // millis() when request was created BaseSerialInterface *_serial; AbstractUITask* _ui; From c3dc923199aa615edf7e1fd2a2ee65aa179e16c2 Mon Sep 17 00:00:00 2001 From: Michiel Appelman Date: Sat, 10 Jan 2026 21:27:01 +0100 Subject: [PATCH 05/14] Remove timeout tracking from companion radio advert requests. The app handles its own timeouts - no need for the companion radio to track them. Co-Authored-By: Claude Opus 4.5 --- examples/companion_radio/MyMesh.cpp | 24 ------------------------ examples/companion_radio/MyMesh.h | 7 +------ 2 files changed, 1 insertion(+), 30 deletions(-) diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 0a6a0f5d1..3417fea8e 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -1823,10 +1823,7 @@ void MyMesh::handleCmdFrame(size_t len) { sendDirect(packet, path, path_len, 0); - // Track request pending_advert_request = tag; - pending_advert_request_time = millis(); - MESH_DEBUG_PRINTLN("CMD_REQUEST_ADVERT: sent request, tag=%08X", tag); writeOKFrame(); @@ -2041,24 +2038,6 @@ void MyMesh::checkSerialInterface() { } } -void MyMesh::checkPendingAdvertRequests() { - if (pending_advert_request == 0) return; - - unsigned long elapsed = millis() - pending_advert_request_time; - if (elapsed > ADVERT_REQUEST_TIMEOUT_MILLIS) { - MESH_DEBUG_PRINTLN("checkPendingAdvertRequests: request timed out, tag=%08X", pending_advert_request); - - // Send timeout notification to app (tag + 0xFF marker) - int j = 0; - out_frame[j++] = PUSH_CODE_ADVERT_RESPONSE; - memcpy(&out_frame[j], &pending_advert_request, 4); j += 4; - out_frame[j++] = 0xFF; // Special marker for timeout - _serial->writeFrame(out_frame, j); - - pending_advert_request = 0; - } -} - void MyMesh::loop() { BaseChatMesh::loop(); @@ -2068,9 +2047,6 @@ void MyMesh::loop() { checkSerialInterface(); } - // Check for timed out advert requests - checkPendingAdvertRequests(); - // is there are pending dirty contacts write needed? if (dirty_contacts_expiry && millisHasNowPassed(dirty_contacts_expiry)) { saveContacts(); diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index b07c76693..4bf92d3b4 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -148,9 +148,6 @@ class MyMesh : public BaseChatMesh, public DataStoreHost { bool onChannelLoaded(uint8_t channel_idx, const ChannelDetails& ch) override { return setChannel(channel_idx, ch); } bool getChannelForSave(uint8_t channel_idx, ChannelDetails& ch) override { return getChannel(channel_idx, ch); } -private: - #define ADVERT_REQUEST_TIMEOUT_MILLIS 30000 // 30 seconds - public: void clearPendingReqs() { pending_login = pending_status = pending_telemetry = pending_discovery = pending_req = 0; @@ -178,7 +175,6 @@ class MyMesh : public BaseChatMesh, public DataStoreHost { void checkCLIRescueCmd(); void checkSerialInterface(); void handleAdvertResponse(mesh::Packet* packet); - void checkPendingAdvertRequests(); // helpers, short-cuts void saveChannels() { _store->saveChannels(this); } @@ -190,8 +186,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) - unsigned long pending_advert_request_time; // millis() when request was created + uint32_t pending_advert_request; // tag for pending advert request (0 = none) BaseSerialInterface *_serial; AbstractUITask* _ui; From a528752554cfd2a2d12ac591080af36aac0751ca Mon Sep 17 00:00:00 2001 From: Michiel Appelman Date: Sat, 10 Jan 2026 21:31:38 +0100 Subject: [PATCH 06/14] Remove unused lat/lon double variables in handleAdvertResponse. The int32 values are sent directly to the app, no conversion needed. Co-Authored-By: Claude Opus 4.5 --- examples/companion_radio/MyMesh.cpp | 2 -- 1 file changed, 2 deletions(-) diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 3417fea8e..4f48caff2 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -698,8 +698,6 @@ void MyMesh::handleAdvertResponse(mesh::Packet* packet) { if (flags & ADVERT_RESP_FLAG_HAS_LON) { memcpy(&lon_i32, &packet->payload[pos], 4); pos += 4; } - double lat = lat_i32 / 1e6; - double lon = lon_i32 / 1e6; // Optional: node description char node_desc[32] = {0}; From db33a545f08e585d9554c5bab588de363e642622 Mon Sep 17 00:00:00 2001 From: Michiel Appelman Date: Sat, 10 Jan 2026 21:38:34 +0100 Subject: [PATCH 07/14] Document pull-based advert system in protocol docs. payloads.md: - Add ADVERT_REQUEST (0xA0) control sub-type - Add ADVERT_RESPONSE (0xA1) control sub-type with all fields and flags protocol_guide.md: - Add CMD_REQUEST_ADVERT (0x39) command - Add PUSH_CODE_ADVERT_RESPONSE (0x8F) to packet types - Add parsing pseudocode for advert response - Update response matching table Co-Authored-By: Claude Opus 4.5 --- docs/payloads.md | 38 ++++++++++++++++ docs/protocol_guide.md | 101 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 139 insertions(+) diff --git a/docs/payloads.md b/docs/payloads.md index 4742bfbbc..d71cfc121 100644 --- a/docs/payloads.md +++ b/docs/payloads.md @@ -266,6 +266,44 @@ 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 0xA0) + +Pull-based advert request. Sent to a specific node (via known path) to request its full advertisement with extended metadata. + +| Field | Size (bytes) | Description | +|---------------|-----------------|--------------------------------------------| +| sub_type | 1 | 0xA0 | +| target_prefix | 6 | first 6 bytes of target node's public key | +| tag | 4 | randomly generated by sender for matching | + +## ADVERT_RESPONSE (sub_type 0xA1) + +Response to ADVERT_REQUEST containing full advertisement with extended metadata. + +| Field | Size (bytes) | Description | +|---------------|-----------------|-----------------------------------------------------| +| sub_type | 1 | 0xA1 | +| tag | 4 | echoed from ADVERT_REQUEST | +| pubkey | 32 | responder's full Ed25519 public key | +| adv_type | 1 | node type (1=chat, 2=repeater, 3=room, 4=sensor) | +| node_name | 32 | node name, null-padded | +| timestamp | 4 | unix timestamp | +| signature | 64 | Ed25519 signature over pubkey + timestamp | +| 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 | +| operator_name | 32 (optional) | if flags & 0x08: operator name, null-padded | + +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 | +| `0x08` | has operator | operator_name field is present | + # Custom packet diff --git a/docs/protocol_guide.md b/docs/protocol_guide.md index ceedbbf05..7c52f011c 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: 0x39 +Bytes 1-6: Target Prefix (first 6 bytes of target node's public key) +Byte 7: Path Length +Bytes 8+: Path (list of node hash bytes to reach target) +``` + +**Example** (request from node with prefix `a1b2c3d4e5f6` via 2-hop path): +``` +39 a1 b2 c3 d4 e5 f6 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,82 @@ 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) +- `0x08`: Operator name 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 + + if flags & 0x08: # has operator + result['operator_name'] = 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 +1090,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 From 336d406217e30c8bd75753927f6a577915b60584 Mon Sep 17 00:00:00 2001 From: Michiel Appelman Date: Sat, 10 Jan 2026 22:06:21 +0100 Subject: [PATCH 08/14] Fix documentation of packet and payload description. --- docs/payloads.md | 4 ++-- docs/protocol_guide.md | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/payloads.md b/docs/payloads.md index d71cfc121..06772f9aa 100644 --- a/docs/payloads.md +++ b/docs/payloads.md @@ -273,7 +273,7 @@ Pull-based advert request. Sent to a specific node (via known path) to request i | Field | Size (bytes) | Description | |---------------|-----------------|--------------------------------------------| | sub_type | 1 | 0xA0 | -| target_prefix | 6 | first 6 bytes of target node's public key | +| target_prefix | 1 | first byte of target node's public key | | tag | 4 | randomly generated by sender for matching | ## ADVERT_RESPONSE (sub_type 0xA1) @@ -307,4 +307,4 @@ ADVERT_RESPONSE Flags: # 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 7c52f011c..2c18602b2 100644 --- a/docs/protocol_guide.md +++ b/docs/protocol_guide.md @@ -383,14 +383,14 @@ Byte 0: 0x14 **Command Format**: ``` Byte 0: 0x39 -Bytes 1-6: Target Prefix (first 6 bytes of target node's public key) -Byte 7: Path Length -Bytes 8+: Path (list of node hash bytes to reach target) +Bytes 1: Target Prefix +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): ``` -39 a1 b2 c3 d4 e5 f6 02 [path_hash_1] [path_hash_2] +39 a1 02 [path_hash_1] [path_hash_2] ``` **Response**: `PACKET_OK` (0x00) on success, `PACKET_ERROR` (0x01) if request already pending From ae4422af213045410447db16f17a9c666ffd2945 Mon Sep 17 00:00:00 2001 From: Michiel Appelman Date: Sat, 10 Jan 2026 22:18:32 +0100 Subject: [PATCH 09/14] Fix control subtype encoding to use distinct upper nibbles. Change ADVERT_RESPONSE from 0xA1 to 0xB0 so each control subtype has a unique upper nibble per the protocol convention. Update handlers to use nibble matching consistently. Co-Authored-By: Claude Opus 4.5 --- docs/payloads.md | 4 ++-- examples/companion_radio/MyMesh.cpp | 2 +- examples/simple_repeater/MyMesh.cpp | 5 ++--- src/Packet.h | 4 ++-- 4 files changed, 7 insertions(+), 8 deletions(-) diff --git a/docs/payloads.md b/docs/payloads.md index 06772f9aa..fd1d46466 100644 --- a/docs/payloads.md +++ b/docs/payloads.md @@ -276,13 +276,13 @@ Pull-based advert request. Sent to a specific node (via known path) to request i | target_prefix | 1 | first byte of target node's public key | | tag | 4 | randomly generated by sender for matching | -## ADVERT_RESPONSE (sub_type 0xA1) +## ADVERT_RESPONSE (sub_type 0xB0) Response to ADVERT_REQUEST containing full advertisement with extended metadata. | Field | Size (bytes) | Description | |---------------|-----------------|-----------------------------------------------------| -| sub_type | 1 | 0xA1 | +| sub_type | 1 | 0xB0 | | tag | 4 | echoed from ADVERT_REQUEST | | pubkey | 32 | responder's full Ed25519 public key | | adv_type | 1 | node type (1=chat, 2=repeater, 3=room, 4=sensor) | diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 4f48caff2..7f81d2441 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -744,7 +744,7 @@ void MyMesh::handleAdvertResponse(mesh::Packet* packet) { void MyMesh::onControlDataRecv(mesh::Packet *packet) { if (packet->payload_len < 1) return; - uint8_t sub_type = packet->payload[0]; + uint8_t sub_type = packet->payload[0] & 0xF0; // upper nibble is subtype // Handle pull-based advert response if (sub_type == CTL_TYPE_ADVERT_RESPONSE) { diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 2bfb46f7a..251191497 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -809,7 +809,7 @@ void MyMesh::handleAdvertRequest(mesh::Packet* packet) { void MyMesh::onControlDataRecv(mesh::Packet* packet) { if (packet->payload_len < 1) return; - uint8_t sub_type = packet->payload[0]; + uint8_t sub_type = packet->payload[0] & 0xF0; // upper nibble is subtype // Handle pull-based advert request (with rate limiting) if (sub_type == CTL_TYPE_ADVERT_REQUEST && discover_limiter.allow(rtc_clock.getCurrentTime())) { @@ -818,8 +818,7 @@ void MyMesh::onControlDataRecv(mesh::Packet* packet) { } // Handle legacy node discovery - uint8_t type = sub_type & 0xF0; // just test upper 4 bits - if (type == CTL_TYPE_NODE_DISCOVER_REQ && packet->payload_len >= 6 + if (sub_type == CTL_TYPE_NODE_DISCOVER_REQ && packet->payload_len >= 6 && !_prefs.disable_fwd && discover_limiter.allow(rtc_clock.getCurrentTime()) ) { int i = 1; diff --git a/src/Packet.h b/src/Packet.h index 8da19eef6..4470d9851 100644 --- a/src/Packet.h +++ b/src/Packet.h @@ -36,9 +36,9 @@ namespace mesh { #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 0xA0 range to avoid conflict with node discovery) +// Pull-based advert system (each uses distinct upper nibble per control subtype convention) #define CTL_TYPE_ADVERT_REQUEST 0xA0 // Request advert from specific node -#define CTL_TYPE_ADVERT_RESPONSE 0xA1 // Response with full advert + extended metadata +#define CTL_TYPE_ADVERT_RESPONSE 0xB0 // Response with full advert + extended metadata // Advert response flags (optional fields in CTL_TYPE_ADVERT_RESPONSE) #define ADVERT_RESP_FLAG_HAS_LAT 0x01 From 35876ef39b872060387b4d60e0427dcdc0e9af14 Mon Sep 17 00:00:00 2001 From: Michiel Appelman Date: Sat, 10 Jan 2026 22:31:22 +0100 Subject: [PATCH 10/14] Explicitly refer to PATH_HASH_SIZE in docs --- docs/payloads.md | 2 +- docs/protocol_guide.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/payloads.md b/docs/payloads.md index fd1d46466..9bd97c0a3 100644 --- a/docs/payloads.md +++ b/docs/payloads.md @@ -273,7 +273,7 @@ Pull-based advert request. Sent to a specific node (via known path) to request i | Field | Size (bytes) | Description | |---------------|-----------------|--------------------------------------------| | sub_type | 1 | 0xA0 | -| target_prefix | 1 | first byte of target node's public key | +| target_prefix | 1 | PATH_HASH_SIZE of target node's public key | | tag | 4 | randomly generated by sender for matching | ## ADVERT_RESPONSE (sub_type 0xB0) diff --git a/docs/protocol_guide.md b/docs/protocol_guide.md index 2c18602b2..c3ed7ba37 100644 --- a/docs/protocol_guide.md +++ b/docs/protocol_guide.md @@ -383,7 +383,7 @@ Byte 0: 0x14 **Command Format**: ``` Byte 0: 0x39 -Bytes 1: Target Prefix +Bytes 1: PATH_HASH_SIZE bytes (currently 1) Byte 2: Path Length Bytes 3+: Path (list of node hash bytes to reach target) ``` From 8d83b37e688933177334db951d58581af6f09e12 Mon Sep 17 00:00:00 2001 From: Michiel Appelman Date: Sat, 10 Jan 2026 22:37:22 +0100 Subject: [PATCH 11/14] Prevent repeaters from forwarding flooded adverts from other repeaters. This reduces unnecessary network traffic since repeater adverts are primarily useful for local neighbor discovery (zero-hop), not for multi-hop propagation. Co-Authored-By: Claude Opus 4.5 --- examples/simple_repeater/MyMesh.cpp | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 251191497..dfc1683ab 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -390,6 +390,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; } From caae8e5482c122ce0e473de6d4975e038bb8d46c Mon Sep 17 00:00:00 2001 From: Michiel Appelman Date: Sat, 10 Jan 2026 22:55:38 +0100 Subject: [PATCH 12/14] Include all app_data fields in ADVERT_RESPONSE signature. Restructured packet to match regular advert pattern: - Signature now covers pubkey + timestamp + app_data - app_data includes: adv_type, node_name, flags, and all optional fields - This prevents intermediate nodes from tampering with any metadata Co-Authored-By: Claude Opus 4.5 --- docs/payloads.md | 6 +- examples/companion_radio/MyMesh.cpp | 84 ++++++++++++---------------- examples/simple_repeater/MyMesh.cpp | 85 +++++++++++++---------------- 3 files changed, 78 insertions(+), 97 deletions(-) diff --git a/docs/payloads.md b/docs/payloads.md index 9bd97c0a3..ae7d47924 100644 --- a/docs/payloads.md +++ b/docs/payloads.md @@ -285,16 +285,18 @@ Response to ADVERT_REQUEST containing full advertisement with extended metadata. | sub_type | 1 | 0xB0 | | 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 | -| timestamp | 4 | unix timestamp | -| signature | 64 | Ed25519 signature over pubkey + timestamp | | 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 | | operator_name | 32 (optional) | if flags & 0x08: operator name, null-padded | +Note: app_data = adv_type + node_name + flags + optional fields (same pattern as regular adverts). + ADVERT_RESPONSE Flags: | Value | Name | Description | diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 7f81d2441..10ad102f7 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -639,7 +639,7 @@ bool MyMesh::onContactPathRecv(ContactInfo& contact, uint8_t* in_path, uint8_t i } void MyMesh::handleAdvertResponse(mesh::Packet* packet) { - // Minimum: sub_type(1) + tag(4) + pubkey(32) + adv_type(1) + name(32) + timestamp(4) + signature(64) + flags(1) = 139 bytes + // 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; @@ -659,56 +659,52 @@ void MyMesh::handleAdvertResponse(mesh::Packet* packet) { MESH_DEBUG_PRINTLN("handleAdvertResponse: matched tag=%08X", tag); - // Extract pubkey + // 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; - // Extract adv_type, node_name, timestamp - uint8_t adv_type = packet->payload[pos++]; - char node_name[32]; - memcpy(node_name, &packet->payload[pos], 32); pos += 32; uint32_t timestamp; memcpy(×tamp, &packet->payload[pos], 4); pos += 4; - // Extract signature - uint8_t signature[64]; - memcpy(signature, &packet->payload[pos], 64); pos += 64; + uint8_t signature[SIGNATURE_SIZE]; + memcpy(signature, &packet->payload[pos], SIGNATURE_SIZE); pos += SIGNATURE_SIZE; - // Verify signature (over pubkey + timestamp, same as regular adverts) - uint8_t sig_data[PUB_KEY_SIZE + 4]; - memcpy(sig_data, pubkey, PUB_KEY_SIZE); - memcpy(sig_data + PUB_KEY_SIZE, ×tamp, 4); + // 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; - mesh::Identity id; - memcpy(id.pub_key, pubkey, PUB_KEY_SIZE); - if (!id.verify(signature, sig_data, sizeof(sig_data))) { - MESH_DEBUG_PRINTLN("handleAdvertResponse: signature verification failed"); - pending_advert_request = 0; - return; - } + // 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; - // Extract flags - uint8_t flags = packet->payload[pos++]; + uint8_t flags = app_data[app_pos++]; - // Optional: GPS location (stored as int32 * 1e6) int32_t lat_i32 = 0, lon_i32 = 0; - if (flags & ADVERT_RESP_FLAG_HAS_LAT) { - memcpy(&lat_i32, &packet->payload[pos], 4); pos += 4; - } - if (flags & ADVERT_RESP_FLAG_HAS_LON) { - memcpy(&lon_i32, &packet->payload[pos], 4); pos += 4; - } + 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; } - // Optional: node description char node_desc[32] = {0}; - if (flags & ADVERT_RESP_FLAG_HAS_DESC) { - memcpy(node_desc, &packet->payload[pos], 32); pos += 32; - } + if (flags & ADVERT_RESP_FLAG_HAS_DESC) { memcpy(node_desc, &app_data[app_pos], 32); app_pos += 32; } - // Optional: operator name char operator_name[32] = {0}; - if (flags & ADVERT_RESP_FLAG_HAS_OPERATOR) { - memcpy(operator_name, &packet->payload[pos], 32); pos += 32; + if (flags & ADVERT_RESP_FLAG_HAS_OPERATOR) { memcpy(operator_name, &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 @@ -720,18 +716,10 @@ void MyMesh::handleAdvertResponse(mesh::Packet* packet) { 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; - } - if (flags & ADVERT_RESP_FLAG_HAS_OPERATOR) { - memcpy(&out_frame[i], operator_name, 32); i += 32; - } + 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; } + if (flags & ADVERT_RESP_FLAG_HAS_OPERATOR) { memcpy(&out_frame[i], operator_name, 32); i += 32; } _serial->writeFrame(out_frame, i); diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index dfc1683ab..37b531e0c 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -742,70 +742,61 @@ void MyMesh::handleAdvertRequest(mesh::Packet* packet) { MESH_DEBUG_PRINTLN("handleAdvertRequest: request for us, tag=%08X", tag); - // Build response with extended metadata - uint8_t response[MAX_PACKET_PAYLOAD]; - int pos = 0; - - // sub_type - response[pos++] = CTL_TYPE_ADVERT_RESPONSE; - - // tag (echo back) - memcpy(&response[pos], &tag, 4); pos += 4; - - // pub_key (full 32 bytes) - memcpy(&response[pos], self_id.pub_key, PUB_KEY_SIZE); pos += PUB_KEY_SIZE; + // Build app_data first (needed for signature, same pattern as regular adverts) + uint8_t app_data[1 + 32 + 1 + 4 + 4 + 32 + 32]; // adv_type + node_name + flags + lat + lon + desc + operator + int app_data_len = 0; // adv_type - response[pos++] = ADV_TYPE_REPEATER; + app_data[app_data_len++] = ADV_TYPE_REPEATER; // node_name (32 bytes) - memcpy(&response[pos], _prefs.node_name, 32); pos += 32; + memcpy(&app_data[app_data_len], _prefs.node_name, 32); app_data_len += 32; - // timestamp - uint32_t timestamp = getRTCClock()->getCurrentTime(); - memcpy(&response[pos], ×tamp, 4); pos += 4; - - // signature (64 bytes) - sign pubkey + timestamp only (same as regular adverts) - uint8_t sig_data[PUB_KEY_SIZE + 4]; - memcpy(sig_data, self_id.pub_key, PUB_KEY_SIZE); - memcpy(sig_data + PUB_KEY_SIZE, ×tamp, 4); - uint8_t signature[64]; - self_id.sign(signature, sig_data, sizeof(sig_data)); - memcpy(&response[pos], signature, 64); pos += 64; - - // flags (indicating which optional fields are present) + // flags - determine which optional fields are present uint8_t flags = 0; - int flags_pos = pos; - pos++; // reserve space for flags byte + int32_t lat_i32 = 0, lon_i32 = 0; - // Optional: GPS location (only if configured to share) 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; - int32_t lat_i32 = (int32_t)(lat * 1e6); - int32_t lon_i32 = (int32_t)(lon * 1e6); - memcpy(&response[pos], &lat_i32, 4); pos += 4; - memcpy(&response[pos], &lon_i32, 4); pos += 4; + lat_i32 = (int32_t)(lat * 1e6); + lon_i32 = (int32_t)(lon * 1e6); } } + if (_prefs.node_desc[0] != '\0') flags |= ADVERT_RESP_FLAG_HAS_DESC; + if (_prefs.operator_name[0] != '\0') flags |= ADVERT_RESP_FLAG_HAS_OPERATOR; - // Optional: node description - if (_prefs.node_desc[0] != '\0') { - flags |= ADVERT_RESP_FLAG_HAS_DESC; - memcpy(&response[pos], _prefs.node_desc, 32); pos += 32; - } + app_data[app_data_len++] = flags; - // Optional: operator name - if (_prefs.operator_name[0] != '\0') { - flags |= ADVERT_RESP_FLAG_HAS_OPERATOR; - memcpy(&response[pos], _prefs.operator_name, 32); pos += 32; - } + // 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; } + if (flags & ADVERT_RESP_FLAG_HAS_OPERATOR) { memcpy(&app_data[app_data_len], _prefs.operator_name, 32); app_data_len += 32; } + + // Build signature message: pubkey + timestamp + app_data (same as regular adverts) + 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; - // Write flags byte - response[flags_pos] = flags; + 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 MESH_DEBUG_PRINTLN("handleAdvertRequest: sending response, %d bytes, flags=%02X", pos, flags); From 9fd89101af4c021d9d78b72b9845825399fdeb92 Mon Sep 17 00:00:00 2001 From: Michiel Appelman Date: Sun, 11 Jan 2026 21:18:23 +0100 Subject: [PATCH 13/14] Fixes after real-world radio testing. --- docs/payloads.md | 30 +++++++++++++++------ docs/protocol_guide.md | 9 ++----- examples/companion_radio/MyMesh.cpp | 21 +++++++-------- examples/companion_radio/MyMesh.h | 3 +-- examples/simple_repeater/MyMesh.cpp | 42 ++++++++++++++--------------- examples/simple_repeater/MyMesh.h | 3 +-- src/Mesh.cpp | 15 +++++++++++ src/Packet.h | 8 +++--- src/helpers/CommonCLI.cpp | 21 +++------------ src/helpers/CommonCLI.h | 1 - 10 files changed, 79 insertions(+), 74 deletions(-) diff --git a/docs/payloads.md b/docs/payloads.md index ae7d47924..01c96dfc0 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,23 +278,27 @@ 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 0xA0) +## 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 | 0xA0 | +| sub_type | 1 | 0x20 | | target_prefix | 1 | PATH_HASH_SIZE of target node's public key | | tag | 4 | randomly generated by sender for matching | -## ADVERT_RESPONSE (sub_type 0xB0) +## 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 | 0xB0 | +| sub_type | 1 | 0x30 | | tag | 4 | echoed from ADVERT_REQUEST | | pubkey | 32 | responder's full Ed25519 public key | | timestamp | 4 | unix timestamp | @@ -293,7 +309,6 @@ Response to ADVERT_REQUEST containing full advertisement with extended metadata. | 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 | -| operator_name | 32 (optional) | if flags & 0x08: operator name, null-padded | Note: app_data = adv_type + node_name + flags + optional fields (same pattern as regular adverts). @@ -304,7 +319,6 @@ ADVERT_RESPONSE Flags: | `0x01` | has latitude | latitude field is present | | `0x02` | has longitude | longitude field is present | | `0x04` | has description | node_desc field is present | -| `0x08` | has operator | operator_name field is present | # Custom packet diff --git a/docs/protocol_guide.md b/docs/protocol_guide.md index c3ed7ba37..36da92bdc 100644 --- a/docs/protocol_guide.md +++ b/docs/protocol_guide.md @@ -925,7 +925,6 @@ Byte 74: Flags (indicates which optional fields are present) - `0x01`: Latitude present (4 bytes, int32, value * 1e6) - `0x02`: Longitude present (4 bytes, int32, value * 1e6) - `0x04`: Node description present (32 bytes, null-padded) -- `0x08`: Operator name present (32 bytes, null-padded) **Parsing Pseudocode**: ```python @@ -975,10 +974,6 @@ def parse_advert_response(data): result['node_desc'] = data[offset:offset+32].decode('utf-8').rstrip('\x00') offset += 32 - if flags & 0x08: # has operator - result['operator_name'] = data[offset:offset+32].decode('utf-8').rstrip('\x00') - offset += 32 - return result ``` @@ -1297,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 10ad102f7..f76d33f78 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -689,9 +689,6 @@ void MyMesh::handleAdvertResponse(mesh::Packet* packet) { char node_desc[32] = {0}; if (flags & ADVERT_RESP_FLAG_HAS_DESC) { memcpy(node_desc, &app_data[app_pos], 32); app_pos += 32; } - char operator_name[32] = {0}; - if (flags & ADVERT_RESP_FLAG_HAS_OPERATOR) { memcpy(operator_name, &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; @@ -719,7 +716,6 @@ void MyMesh::handleAdvertResponse(mesh::Packet* packet) { 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; } - if (flags & ADVERT_RESP_FLAG_HAS_OPERATOR) { memcpy(&out_frame[i], operator_name, 32); i += 32; } _serial->writeFrame(out_frame, i); @@ -1784,11 +1780,8 @@ void MyMesh::handleCmdFrame(size_t len) { const uint8_t* path = &cmd_frame[1 + PATH_HASH_SIZE + 1]; - // Check if there's already a pending request - if (pending_advert_request != 0) { - writeErrFrame(ERR_CODE_TABLE_FULL); - return; - } + // Clear any stale pending requests (same pattern as other request commands) + clearPendingReqs(); // Generate random tag uint32_t tag; @@ -1807,10 +1800,16 @@ void MyMesh::handleCmdFrame(size_t len) { return; } - sendDirect(packet, path, path_len, 0); + // For CONTROL packets with high-bit set, Mesh.cpp only processes them + // if path_len == 0 (zero-hop). For direct neighbors (path_len == 1 and + // path matches target prefix), use sendZeroHop. + if (path_len == PATH_HASH_SIZE && memcmp(path, target_prefix, PATH_HASH_SIZE) == 0) { + sendZeroHop(packet); + } else { + sendDirect(packet, path, path_len, 0); + } pending_advert_request = tag; - MESH_DEBUG_PRINTLN("CMD_REQUEST_ADVERT: sent request, tag=%08X", tag); writeOKFrame(); } else { diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index 4bf92d3b4..883e23312 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -148,7 +148,6 @@ class MyMesh : public BaseChatMesh, public DataStoreHost { bool onChannelLoaded(uint8_t channel_idx, const ChannelDetails& ch) override { return setChannel(channel_idx, ch); } bool getChannelForSave(uint8_t channel_idx, ChannelDetails& ch) override { return getChannel(channel_idx, ch); } -public: void clearPendingReqs() { pending_login = pending_status = pending_telemetry = pending_discovery = pending_req = 0; pending_advert_request = 0; @@ -165,7 +164,7 @@ class MyMesh : public BaseChatMesh, public DataStoreHost { 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[]); - int getBlobByKey(const uint8_t key[], int key_len, uint8_t dest_buf[]) override { + int getBlobByKey(const uint8_t key[], int key_len, uint8_t dest_buf[]) override { return _store->getBlobByKey(key, key_len, dest_buf); } bool putBlobByKey(const uint8_t key[], int key_len, const uint8_t src_buf[], int len) override { diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 37b531e0c..3e1247bf7 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); } @@ -742,8 +746,8 @@ void MyMesh::handleAdvertRequest(mesh::Packet* packet) { MESH_DEBUG_PRINTLN("handleAdvertRequest: request for us, tag=%08X", tag); - // Build app_data first (needed for signature, same pattern as regular adverts) - uint8_t app_data[1 + 32 + 1 + 4 + 4 + 32 + 32]; // adv_type + node_name + flags + lat + lon + desc + operator + // 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 @@ -766,7 +770,6 @@ void MyMesh::handleAdvertRequest(mesh::Packet* packet) { } } if (_prefs.node_desc[0] != '\0') flags |= ADVERT_RESP_FLAG_HAS_DESC; - if (_prefs.operator_name[0] != '\0') flags |= ADVERT_RESP_FLAG_HAS_OPERATOR; app_data[app_data_len++] = flags; @@ -774,9 +777,8 @@ void MyMesh::handleAdvertRequest(mesh::Packet* packet) { 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; } - if (flags & ADVERT_RESP_FLAG_HAS_OPERATOR) { memcpy(&app_data[app_data_len], _prefs.operator_name, 32); app_data_len += 32; } - // Build signature message: pubkey + timestamp + app_data (same as regular adverts) + // 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; @@ -800,31 +802,31 @@ void MyMesh::handleAdvertRequest(mesh::Packet* packet) { MESH_DEBUG_PRINTLN("handleAdvertRequest: sending response, %d bytes, flags=%02X", pos, flags); - // Create response packet and send using reverse path + // Create response packet and send mesh::Packet* resp_packet = createControlData(response, pos); if (resp_packet) { - // Copy and reverse the path - uint8_t reversed_path[MAX_PATH_SIZE]; - for (int i = 0; i < packet->path_len; i++) { - reversed_path[i] = packet->path[packet->path_len - 1 - i]; + if (packet->path_len == 0) { + sendZeroHop(resp_packet); + } else { + uint8_t reversed_path[MAX_PATH_SIZE]; + for (int i = 0; i < packet->path_len; i++) { + reversed_path[i] = packet->path[packet->path_len - 1 - i]; + } + sendDirect(resp_packet, reversed_path, packet->path_len, SERVER_RESPONSE_DELAY); } - sendDirect(resp_packet, reversed_path, packet->path_len, SERVER_RESPONSE_DELAY); } } void MyMesh::onControlDataRecv(mesh::Packet* packet) { - if (packet->payload_len < 1) return; - - uint8_t sub_type = packet->payload[0] & 0xF0; // upper nibble is subtype + uint8_t type = packet->payload[0] & 0xF0; // just test upper 4 bits - // Handle pull-based advert request (with rate limiting) - if (sub_type == CTL_TYPE_ADVERT_REQUEST && discover_limiter.allow(rtc_clock.getCurrentTime())) { + if (type == CTL_TYPE_ADVERT_REQUEST && discover_limiter.allow(rtc_clock.getCurrentTime())) { handleAdvertRequest(packet); return; } // Handle legacy node discovery - if (sub_type == CTL_TYPE_NODE_DISCOVER_REQ && packet->payload_len >= 6 + if (type == CTL_TYPE_NODE_DISCOVER_REQ && packet->payload_len >= 6 && !_prefs.disable_fwd && discover_limiter.allow(rtc_clock.getCurrentTime()) ) { int i = 1; @@ -933,8 +935,7 @@ void MyMesh::begin(FILESYSTEM *fs) { radio_set_params(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr); radio_set_tx_power(_prefs.tx_power_dbm); - updateAdvertTimer(); // zero-hop adverts still active - // updateFloodAdvertTimer() not called - repeaters no longer send flood adverts + updateAdvertTimer(); board.setAdcMultiplier(_prefs.adc_multiplier); @@ -967,7 +968,6 @@ bool MyMesh::formatFileSystem() { } void MyMesh::sendSelfAdvertisement(int delay_millis) { - // Repeaters only send zero-hop adverts (no flood) mesh::Packet *pkt = createSelfAdvert(); if (pkt) { sendZeroHop(pkt, delay_millis); diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index 51b1d44ab..e4d8e1231 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -83,8 +83,7 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { FILESYSTEM* _fs; uint32_t last_millis; uint64_t uptime_millis; - unsigned long next_local_advert; // zero-hop adverts still active - // next_flood_advert removed - repeaters no longer send flood adverts + unsigned long next_local_advert; bool _logging; NodePrefs _prefs; CommonCLI _cli; 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 4470d9851..96bdc8daa 100644 --- a/src/Packet.h +++ b/src/Packet.h @@ -36,15 +36,15 @@ namespace mesh { #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 (each uses distinct upper nibble per control subtype convention) -#define CTL_TYPE_ADVERT_REQUEST 0xA0 // Request advert from specific node -#define CTL_TYPE_ADVERT_RESPONSE 0xB0 // Response with full advert + extended metadata +// 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 ADVERT_RESP_FLAG_HAS_OPERATOR 0x08 #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 ??) diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index f0e1f7d18..936aa5d93 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -82,8 +82,7 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) { file.read((uint8_t *)&_prefs->adc_multiplier, sizeof(_prefs->adc_multiplier)); // 166 file.read((uint8_t *)_prefs->owner_info, sizeof(_prefs->owner_info)); // 170 file.read((uint8_t *)&_prefs->node_desc, sizeof(_prefs->node_desc)); // 290 - file.read((uint8_t *)&_prefs->operator_name, sizeof(_prefs->operator_name)); // 322 - // 354 + // 322 // sanitise bad pref values _prefs->rx_delay_base = constrain(_prefs->rx_delay_base, 0, 20.0f); @@ -110,9 +109,8 @@ 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 fields + // Ensure null-termination of extended metadata field _prefs->node_desc[31] = '\0'; - _prefs->operator_name[31] = '\0'; file.close(); } @@ -172,8 +170,7 @@ void CommonCLI::savePrefs(FILESYSTEM* fs) { file.write((uint8_t *)&_prefs->adc_multiplier, sizeof(_prefs->adc_multiplier)); // 166 file.write((uint8_t *)_prefs->owner_info, sizeof(_prefs->owner_info)); // 170 file.write((uint8_t *)&_prefs->node_desc, sizeof(_prefs->node_desc)); // 290 - file.write((uint8_t *)&_prefs->operator_name, sizeof(_prefs->operator_name)); // 322 - // 354 + // 322 file.close(); } @@ -288,7 +285,6 @@ 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) { - // Repeaters no longer send flood adverts 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); @@ -379,12 +375,6 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, const char* command, ch } else { strcpy(reply, "> (not set)"); } - } else if (memcmp(config, "operator.name", 13) == 0) { - if (_prefs->operator_name[0]) { - sprintf(reply, "> %s", _prefs->operator_name); - } else { - strcpy(reply, "> (not set)"); - } } else { sprintf(reply, "??: %s", config); } @@ -414,7 +404,6 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, const char* command, ch savePrefs(); strcpy(reply, "OK"); } else if (memcmp(config, "flood.advert.interval ", 22) == 0) { - // Repeaters no longer send flood adverts - show error for backwards compatibility 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]); @@ -454,10 +443,6 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, const char* command, ch StrHelper::strncpy(_prefs->node_desc, &config[10], sizeof(_prefs->node_desc)); savePrefs(); strcpy(reply, "OK"); - } else if (memcmp(config, "operator.name ", 14) == 0) { - StrHelper::strncpy(_prefs->operator_name, &config[14], sizeof(_prefs->operator_name)); - 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 0c47a7d82..2c746c28d 100644 --- a/src/helpers/CommonCLI.h +++ b/src/helpers/CommonCLI.h @@ -53,7 +53,6 @@ struct NodePrefs { // persisted to file 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") - char operator_name[32]; // Operator name/callsign (e.g., "PA0XXX" or "John") }; class CommonCLICallbacks { From dc9b66fa998aa6810e27ca3765bc1c0493757580 Mon Sep 17 00:00:00 2001 From: Michiel Appelman Date: Mon, 12 Jan 2026 13:39:20 +0100 Subject: [PATCH 14/14] Fix multi-hop response routing by embedding path in request payload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DIRECT routing consumes the header path at each hop, so the target node couldn't reverse it for the response. This fix: - Embeds the forward path in the ADVERT_REQUEST payload - Target extracts path, excludes itself (last element), and reverses - For direct neighbors (path=[target]), response sent as zero-hop - Works with stock firmware intermediate nodes (they only use header) Example: path [0x23, 0x1F] → response path [0x23] Co-Authored-By: Claude Opus 4.5 --- docs/payloads.md | 14 +++++---- docs/protocol_guide.md | 4 +-- examples/companion_radio/MyMesh.cpp | 16 ++++------ examples/simple_repeater/MyMesh.cpp | 45 ++++++++++++++++++++--------- 4 files changed, 49 insertions(+), 30 deletions(-) diff --git a/docs/payloads.md b/docs/payloads.md index 01c96dfc0..75f8ba785 100644 --- a/docs/payloads.md +++ b/docs/payloads.md @@ -284,11 +284,15 @@ Pull-based advert request. Sent to a specific node (via known path) to request i **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 | +| 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) diff --git a/docs/protocol_guide.md b/docs/protocol_guide.md index 36da92bdc..53e7e817e 100644 --- a/docs/protocol_guide.md +++ b/docs/protocol_guide.md @@ -382,7 +382,7 @@ Byte 0: 0x14 **Command Format**: ``` -Byte 0: 0x39 +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) @@ -390,7 +390,7 @@ Bytes 3+: Path (list of node hash bytes to reach target) **Example** (request from node with prefix `a1b2c3d4e5f6` via 2-hop path): ``` -39 a1 02 [path_hash_1] [path_hash_2] +3a a1 02 [path_hash_1] [path_hash_2] ``` **Response**: `PACKET_OK` (0x00) on success, `PACKET_ERROR` (0x01) if request already pending diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index f76d33f78..cdf2094a2 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -1787,12 +1787,15 @@ void MyMesh::handleCmdFrame(size_t len) { uint32_t tag; getRNG()->random((uint8_t*)&tag, 4); - // Build request packet - uint8_t payload[1 + PATH_HASH_SIZE + 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) { @@ -1800,14 +1803,7 @@ void MyMesh::handleCmdFrame(size_t len) { return; } - // For CONTROL packets with high-bit set, Mesh.cpp only processes them - // if path_len == 0 (zero-hop). For direct neighbors (path_len == 1 and - // path matches target prefix), use sendZeroHop. - if (path_len == PATH_HASH_SIZE && memcmp(path, target_prefix, PATH_HASH_SIZE) == 0) { - sendZeroHop(packet); - } else { - sendDirect(packet, path, path_len, 0); - } + sendDirect(packet, path, path_len, 0); pending_advert_request = tag; diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 3e1247bf7..d13077dd6 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -727,16 +727,25 @@ bool MyMesh::onPeerPathRecv(mesh::Packet *packet, int sender_idx, const uint8_t } void MyMesh::handleAdvertRequest(mesh::Packet* packet) { - // Validate packet length: sub_type(1) + prefix(PATH_HASH_SIZE) + tag(4) - if (packet->payload_len < 1 + PATH_HASH_SIZE + 4) { + // 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 and tag - const uint8_t* target_prefix = &packet->payload[1]; + // 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[1 + PATH_HASH_SIZE], 4); + 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) { @@ -744,7 +753,7 @@ void MyMesh::handleAdvertRequest(mesh::Packet* packet) { return; } - MESH_DEBUG_PRINTLN("handleAdvertRequest: request for us, tag=%08X", tag); + 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]; @@ -800,19 +809,29 @@ void MyMesh::handleAdvertRequest(mesh::Packet* packet) { memcpy(&response[pos], signature, SIGNATURE_SIZE); pos += SIGNATURE_SIZE; // signature memcpy(&response[pos], app_data, app_data_len); pos += app_data_len; // app_data - MESH_DEBUG_PRINTLN("handleAdvertRequest: sending response, %d bytes, flags=%02X", pos, flags); - - // Create response packet and send + // Create response packet and send using reversed path from payload mesh::Packet* resp_packet = createControlData(response, pos); if (resp_packet) { - if (packet->path_len == 0) { + // 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 < packet->path_len; i++) { - reversed_path[i] = packet->path[packet->path_len - 1 - i]; + for (int i = 0; i < response_path_len; i++) { + reversed_path[i] = return_path[response_path_len - 1 - i]; } - sendDirect(resp_packet, reversed_path, packet->path_len, SERVER_RESPONSE_DELAY); + 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); } } }